Compare commits

...
Sign in to create a new pull request.

159 commits
eyes ... master

Author SHA1 Message Date
Your Name
ddef5c6bfd A node has a pivot
Rotation and scale are composed about `[:xform :pivot]`, a point in the
node's own coordinates:

    local = T(pos) · T(piv) · R · K · S · T(-piv)

Schema 7 deleted this field, on the argument that an anchor is a peg. The
algebra was right and the conclusion was not. The identity holds between a
pivot and a peg THAT ALREADY EXISTS; it says nothing about what a node
turns about when nobody has made one, and that default is what a person
meets. With no pivot in the composition, a turn about anything but the
node's own origin has to be paid for by solving `pos` per frame —
`gesture/about` — and that solution is an arc in the angle while `pos`
tweens along the chord. Right on the frame it is written, wrong on every
frame between two keys.

A drawing escaped it: `paint/centred` puts a shape's origin on the middle
of what it draws. A symbol instance cannot — its origin is its symbol's,
and a symbol is drawn on the stage, so its origin is the top-left corner
of the stage. Off the document this was reported on: a symbol's content
centred 161 px from its own origin, and one instance of it keyed rot 0→60
put the drawing where it was put on both keys and at (-88, 121) halfway
between, a stage and a half away. The advice on offer was "make a peg
first", for wanting to spin a drawing.

So a turn now writes `rot` and nothing else, always, and the pivot is held
exactly between two keys because the matrix is built about it on every
frame. The default, and the way back to it, are the parts the old anchor
was missing:

  - a node nobody has pivoted turns about the middle of what it draws,
    `pick/bounds-of` — the same bounds the selection box comes from
  - the first turn or scale writes that middle down, in the same edit,
    with the `pos` that holds the picture still (`gesture/with-pivot`)
  - `clip/place-symbol` stores the middle of what a symbol draws as the
    instance's pivot, so a drop spins in place from the start
  - ⌃/⌘-drag the cross on the stage to put the pivot anywhere, moving
    nothing — on any node now, not pegs alone
  - ⌖ beside the pivot row in the inspector puts it back on the middle of
    what the node draws NOW (`gesture/centred`)

A pivot is a CHOICE and does not follow the drawing: once it is the node's
own, adding a shape inside a symbol cannot re-aim a keyed spin of any
instance of it. `instance-test` has asserted both answers to that now, and
the stored one is right.

A peg stays a peg, for the three things a node's own pivot is not: a pivot
SHARED between nodes, a SECOND transform on one node, and a hand transform
over a measured one. `nest/repivot` is gone — a pivot inside the node's own
transform has nothing to correct in anybody else's `:pinv`, so the gesture
works on every node and is no longer refused on an animated one. A measured
node's pivot is authored like any other, so a traced mouth can be told
where to turn without a peg.

Schema 8, and the first version that converts rather than refusing: an
absent pivot reads as [0 0] and T(pos)·T(0)·M·T(-0) is T(pos)·M to the
bit, so every stored document composes to exactly the matrices it did and
the migration only restamps the version.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 14:33:35 -04:00
Your Name
e7f5f82845 A drawing's origin is the middle of what it draws
A shape keyed from the bottom left to the top centre with a 360° turn on the way
left the stage completely in the middle of the spin and came back. The keys were
right and every frame between them was wrong, which is the signature of a wrong
pivot and a full turn: 0° and 360° are the only two frames where a wrong pivot
cannot be seen at all.

`paint/new-shape` stored a stroke EXACTLY AS DRAWN, in the containing symbol's
coordinates, and wrote no `pos`. So a drawing's origin was the SYMBOL's origin —
on the stage, its top-left corner. `node/local!` turns and scales about the
node's own origin and nothing else, deliberately, since `[:xform :anchor]` was
deleted in 925c12f. A node whose origin is nowhere near its content therefore
turns about nowhere near its content: the reported shape orbited at a radius of
126 px on a 320x200 stage.

It could not be seen while a drag was the only way to turn something, because
`gesture/about` solves for the `pos` that holds the chosen pivot still and
`turn` wrote it alongside the rotation — exactly right on the frame of the drag.
But that solution is `p' = c + R(θ)(p − c)`, an ARC, and `pos` interpolates along
the CHORD. Right on a drag, right on a key, wrong on every frame between two.

So `paint/centred` splits a stroke into a ring about its own middle and the `pos`
that puts it back, and `new-shape` is the one place every drawing is born — the
pen, the brush, and each piece the eraser leaves. The pivot rule is unchanged,
the middle of what the node draws; for a drawing that point is now its ORIGIN, so
`gesture/at-origin?` holds, `turn` writes `rot` alone, `scale` writes `scale`
alone, and a keyed turn is right on every frame. `pos` goes back to being the
motion path it reads as. Hand-authored scenes were always written this way:
`demo/scene.edn`'s card is `[-44 -30 44 -30 44 30 -44 30]` with its place in
`pos`.

NOT the universal rule, and `a-face-part-scales-about-its-own-middle` is why. A
measured part's points and position are dense tier-2 geometry in the footage's
space and cannot be re-originated, so its pivot is not its origin and `about` is
the only thing that will hold it; the same is true of an instance, whose origin
IS its symbol's coordinate system. Both still drag correctly about their middle,
both are inexact if that drag is keyed, and for both a pivot that has to persist
or be keyed is a peg — which is a node, so its pivot is its own origin, so it
collapses again one level up.

`cut/erase` took EVERY leftover piece back through `world⁻¹`, the cut shape's own
coordinates, and handed the offcuts to `new-shape`, which gives them a fresh
identity transform. Those two spaces coincide only while a shape has `pos [0 0]`,
which was every shape, so erasing anything that had been moved already scattered
its offcuts, silently. The kept piece comes back through `world⁻¹` and the new
ones through `parent⁻¹`, the space a node's `pos` lives in.

And `::adjust-last` re-traces the same stroke from stage pixels, so it goes
through `paint/place-points` rather than writing symbol-space points into a node
that now has a position of its own.

No schema change: the same fields, better values. An existing document keeps
evaluating exactly as it does now.

`a-keyed-turn-holds-its-pivot-between-its-keys` checks all 31 frames of the
tween. Checking the keys is what let this through. 604 CLJS tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 10:37:31 -04:00
Your Name
2e021a13eb Give a remap shape something to drag it by
A remap draws light rather than colour, so `pick/hit-op` let a click go THROUGH
it to what it lights, and `op-bounds` left it out of a marquee's reach. Both of
those rules are about getting PAST a shape, and neither has anything to say
about the shape you have in your hands — but the stage's move gesture IS that
hit-test, while `handles` hangs its box and corners off `pick/bounds-of`, which
reads the node's points and knows nothing about colour.

So a remap shape got a full set of handles over a shape that answered no point
on the stage at all. The first press on it started a marquee, the marquee
selected nothing, and the selection a timeline row had just given it was thrown
away: selectable in the timeline, and then neither movable nor resizable, with
the handles sitting right there. A knockout was the same bug for the same
reason, and both of them read as handles that do not work.

`hit-op` now takes what is selected, and what is selected is never
click-through. That is `choose`'s own rule — a click inside what is selected
keeps it, so a deep selection can be dragged — reaching one step further down;
clicking a remap you have NOT selected still goes through to what it lights,
which is the point of it. `op-bounds` keeps a hole's and a light's bounds like
anything else, so a marquee reaches one without going to the timeline for it.

`browser/remap.mjs` asserts it as `peg.mjs` does, with real pointer events
through the handles, and for the same reason: every assertion about the maths
passed throughout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-05 22:22:52 -04:00
Your Name
39c436584b Place a peg's pivot with ⌃/⌘ rather than ⌥
⌥-drag does not survive the trip. Most Linux window managers grab Alt-drag to
move the window, so the page never sees the pointer and the gesture is simply
absent — on the machine it is absent from, with no error and nothing to find.
Reported from one.

⌃ (⌘ on a Mac) now places the pivot, which is the modifier the rest of this
stage already reaches for. ⌥ keeps working for anyone whose desktop leaves it
alone; ⇧ is deliberately not it, since it means CONSTRAIN everywhere else here —
uniform scale, 15° turn steps — and is what a snap to the child's corners will
want when this drag grows one.

The menu row says so, because a modifier nothing mentions is a modifier nobody
finds: "add peg · ⌃/⌘-drag its cross to place the pivot".

`peg.mjs` now asserts each modifier through real pointer events instead of
reading the handler, which is the only way this class of bug shows up — all
three place the pivot with a child drift of 0, and an unmodified drag still
translates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HkinzDz1VtahZVsujAGRBD
2026-10-05 21:48:59 -04:00
Your Name
357ff1f3b9 Move a peg's pivot without moving what hangs off it
Two bugs in the handles, and the second one meant a peg still had no movable
pivot — which is the complaint pegs exist to answer.

`.peg-grab` was drawn LAST, on top of the four scale corners, and at r=7 against
corners spanning 5.2 to 8.8 from the middle it swallowed the inner half of each
one. It is drawn first now, at r=5, so it stops short of them.

And dragging a peg's cross is a TRANSLATE, not a new pivot. It writes the peg's
`pos`, and a peg is a parent, so everything under it comes along — `peg.mjs` said
so all along: "dragging the peg cross moved its child — dx=18.000". That is the
right behaviour for the drag and no way to say "put the pivot here", so a peg
could be made with its pivot wherever `nest/peg` happened to put it and never
moved again.

⌥-drag now relocates it, which is After Effects' pan-behind split. `nest/repivot`
moves the peg and solves

    pinv(child)' = local'⁻¹ · local · pinv(child)

for each child, because only `local(peg) · pinv(child)` reaches a child, so
preserving that product holds the picture exactly still — verified as a drift of
0, both in `nest-test` and through real ⌥-pointer events in `peg.mjs`. The
child's own channels are never touched, so this works over a measured child,
which is the case the peg exists for.

Refused on a peg whose position is animated, rather than quietly wrong: the
compensation depends on the peg's own transform, so a keyed position wants a
different `pinv` on every frame and one stored matrix is not it. The message says
to put a peg over it instead.

One edit at the end of the drag, not per pointermove: a repivot moves nothing on
screen by construction, so only the cross needs to follow the pointer.

603 CLJS tests, and peg.mjs and onion.mjs pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HkinzDz1VtahZVsujAGRBD
2026-10-05 19:25:04 -04:00
Your Name
00b8ed34ee Give a peg handles on the stage
A peg was reachable and not usable. It is a `:group`, so it draws nothing, and
both of the stage's ways in key off a DRAWN op: `pick/choose` hit-tests the ops,
so a click never found it, and `handles` hangs its box, corners and turn knob off
`pick/bounds-of`, so it got only the pivot cross — which is `pointer-events:
none`. So the pivot `gesture/refusal` tells you to make could be made, and then
only typed at in the inspector, and reselected only from a timeline row.

Every assertion about the maths passed throughout, which is the point: nothing
under `domain/` can see this.

So the handles fall back to a fixed-size rosette about the peg's own origin —
cross to move, knob to turn, four corners to scale. Fixed size, in stage pixels,
because there is no drawing for it to be proportional to. The cross gets a
transparent `.peg-grab` disc rather than taking the handler itself, since
`.pivot` must stay `pointer-events: none` everywhere else: it is a mark on the
picture and must not eat a click meant for the shape under it.

`test/browser/peg.mjs` asserts the handles as DOM and drags through them with
real pointer events, because that is the half a unit test cannot reach: a peg
appears with no drift at all, is selected, renders one knob, four corners and no
box, and dragging its cross moves its child by exactly the drag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HkinzDz1VtahZVsujAGRBD
2026-10-05 19:17:59 -04:00
Your Name
925c12fc77 An anchor is a peg
`[:xform :anchor]` is deleted. `T(a)·M·T(-a)` is a transform conjugated by a
translation — "do M in a frame shifted by a" — and a parent already IS a shifted
frame, so an anchor was a peg written inline: one that could not be selected,
keyed, shared between nodes, or placed above a measured channel. Same expressive
content, strictly less reach. `node-test` asserts the two produce the same matrix.

Its two jobs split, and neither is a field on a node any more.

A pivot nobody chose is DERIVED PER DRAG and stored nowhere. `gesture/pivot` is
the middle of what the node draws — `pick/bounds-of`, the same call the stage
draws the selection box from, on the same frame — or the node's own origin when it
draws nothing. `gesture/about` solves for the position that holds that point
still, so a turn now writes `pos` as well as `rot`. Nothing is cached, so nothing
goes stale: the stored anchor was that same middle captured once at creation while
the box beside it was recomputed every render, so on anything edited since it was
made the cross and the box disagreed and the pivot was wrong. A symbol with more
than one node diverged on its first edit.

A pivot somebody chose is a PEG — `nest/peg`, an ordinary `:group` parent sitting
on the derived pivot with `:pinv` captured so nothing moves. It is the answer to
the three things a derived pivot cannot do: a pivot that persists (an arm about
its shoulder), a pivot that travels (a keyed `pos`), and a hand transform over a
measured one. The last was impossible before — `local`'s translation is
`pos − M·a`, so under a measured `M` writing an anchor moves the thing it was
meant to leave alone. `flow/freeze`'s `pivoted` pass knew this and skipped every
`node/measured?` node, which is exactly why the traced mouth pivoted about
(-234, -395) on a 320x200 stage: the top-left corner of the footage. That pass is
gone; there is no node a derived pivot can be missing from.

`demo/stage` is the one place the anchor did work a static `pos` cannot: `:scale`
is keyed, and the source's middle has to stay on its authored centre throughout.
It is now seven pegs, identical to the pixel.

Also: `events/ui`'s `fitted` rescales a dropped tracing right after placement, and
the anchor had been silently keeping the picture centred through that; it solves
for the middle explicitly now.

Schema 7. Nothing is converted, as in 6: every project is marked 7 and one still
carrying an anchor is refused by name, with what to do about it. Dropping an
anchor is pixel-exact wherever rotation and scale are the identity — everywhere a
freeze or a drop wrote one — but not on anything since turned by hand, and not at
all where `pos` is dense, so a conversion would be silent and wrong for exactly
the nodes somebody had placed themselves.

601 CLJS tests, 68 Django tests, and the onion and take browser suites pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HkinzDz1VtahZVsujAGRBD
2026-10-05 19:12:16 -04:00
Your Name
a6b6c116c6 Count the upload in the status line
The transfer is the longest part of an import on anything but a local
server, and it was the only part with no number on it: `uploading video…`
sat unchanged from the first byte to the last, however many there were, and
only the extraction that followed it ever counted. Most of what "slow"
means to whoever is waiting was a spinner of unknown length.

`fetch` cannot report this. A Request built from a FormData gives no way to
observe its own upload — the promise settles when the response arrives — so
`POST-form` gained an XMLHttpRequest arity, which is the one thing XHR can
still do that fetch cannot. `fail` became `failure`, building the ex-info
rather than throwing it, because the two transports raise it differently:
fetch throws inside a `.then` and XHR has to reject by hand.

`sending` dispatches only when the whole percentage moves, since the
browser fires progress as often as it pleases and each dispatch re-renders
the pane. Video, sound and image uploads all report.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 23:22:58 -04:00
Your Name
e26ad723fa Measure the frame rate rather than believing the container
`probe` took `r_frame_rate` whenever it was at or under the cap, on the
grounds that it is the rate that keeps every distinct source frame. It is
not a claim about frames at all: ordinary iPhone footage declares 120 over
a stream whose timestamps are 1/30s apart, and resampling it up turned an
11-second clip into 1293 proxy frames instead of 323 — four times the
encode, four times the tracing stills (91MB against 23MB), four times the
blobs and the rows, for 970 frames that are copies of their neighbours.

So `_measured_rate` reads the timestamps and `_choose_rate` keeps whichever
declared rate they bear out. Two details carry it: the times are sorted
before differencing, because an HEVC stream arrives in decode order and
differencing that measures the reordering delay instead of the rate; and
the statistic is the MEDIAN interval, which is what keeps the property the
nominal rate was being taken for — a take held on one frame still reports
the rate of the parts that move, so no distinct frame is dropped. A genuine
120fps capture still extracts at 120, and there is a test on that
specifically.

`Source.probe` also stopped being the place a reading goes to be preserved.
The facts are a pure function of bytes that are the row's own identity, so
a re-upload re-reads them: otherwise every already-uploaded source would
have gone on resampling to four times the frames with no way to correct it
short of deleting the row.

Already-extracted footage is untouched — `extraction_key` still says
scheme 3, so those jobs stay done and reachable. Bumping it re-extracts
everything at the corrected rate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 23:22:50 -04:00
Olive Vaughn
8d20097e61 Repaint the stage after edits that move the selection; onion any symbol
The player's tracker derefed its inputs one by one inside `ratom/run!`. An
edit that also changed the selection ran it from the selection's change: it
read `::render/shown` while still stale, then pulled the dirty
`::render/clip`, which re-ran it nested with the new resolver — and the outer
run, finishing last, wrote the old one back. A deleted layer stayed on the
stage until something else ran the tracker. The snapshot is now one sub, so
the tracker derefs a single clean input and is never re-entered.

Onion skinning only ghosted cels in `:display :lane` symbols, so a drawing
made of keyed shapes showed nothing. A ghost is now just the picture the
resolver draws n frames back and ahead, limited to the selection when there
is one. The scope setting is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:35:04 -04:00
Olive Vaughn
78fb120edd ikd mn 2026-10-04 18:14:05 -04:00
Olive Vaughn
208dddee07 Put the top bar back to words
The icon run in the top bar read worse than what it replaced. Restore the
earlier top bar — pane words, new, open ▾, undo ▾ redo, the edit menu,
snapshots ▾, title, status, export… — and its CSS. The stage bar's overlay
switches, the palette chooser and `menu/popover` stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 03:16:09 -04:00
Olive Vaughn
f238ff4f62 Put the top bar on the surface as icons, and give palettes a real chooser
Top bar: every document command is an icon button in a run — panes, new/open,
undo/history/redo, cut/copy/paste/duplicate, snapshots — with the title and
status in the middle and export, share and account on the right. Pane toggles
are pictures of the window with that pane filled in.

Stage bar: tracing, onion and passepartout are all the same toggle-plus-settings
split button, followed by one zoom group, with fit inside it.

Popovers: one `menu/popover`, measured against its button and dismissed by a
scrim, replaces the <details> popouts that never closed on an outside click.

Palette: the strip ends in a palette button whose popover lists every palette
with its swatch strip, renames the open one in place, and marks the ones on
the stage. An info badge says when the palette being edited is not one the
stage draws in at the playhead; both ends of a palette-lane blend count.
`clip/palette-at` is the resolver's root palette lookup, pulled out so the
strip asks the same question the renderer answers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 03:08:21 -04:00
Olive Vaughn
eca1a96b82 correction things 2026-10-04 02:47:29 -04:00
Olive Vaughn
064a3d7c19 Cover large timeline keyframe selections in the browser suite 2026-10-04 02:14:02 -04:00
Olive Vaughn
6de71c4c66 Align stage multi-selection modifiers with the timeline 2026-10-04 02:12:06 -04:00
Olive Vaughn
9bc406f413 Show project settings only when the root is selected 2026-10-04 02:12:06 -04:00
Olive Vaughn
1c520e6a68 Add instance playback controls to the inspector 2026-10-04 02:12:06 -04:00
Olive Vaughn
52f25e0bb1 Fix inspector automation clock for looped symbol instances 2026-10-04 02:11:17 -04:00
Olive Vaughn
4d441ae606 Keep overview keyframes passive and edit expanded automation lanes 2026-10-04 02:05:22 -04:00
Olive Vaughn
606055382b Add per-symbol onion skinning controls and cel ghosts 2026-10-04 02:04:00 -04:00
Olive Vaughn
f2fc261221 Fix stage zoom anchoring and continuous passepartout shading 2026-10-04 00:59:08 -04:00
Olive Vaughn
e5b61bfcdd Fix playback stopping when fallback audio ends 2026-10-04 00:58:51 -04:00
Olive Vaughn
ee603a351b Simplify stage passepartout control 2026-10-04 00:49:28 -04:00
Olive Vaughn
981bef98f9 Start playback at the playhead 2026-10-04 00:48:43 -04:00
Olive Vaughn
6df315b73d Pan with trackpad scrolling and zoom with pinch gestures 2026-10-04 00:44:47 -04:00
Olive Vaughn
484b4f1698 Move palette controls to sidebar and expand stage navigation 2026-10-04 00:40:20 -04:00
Olive Vaughn
1ae49a4015 Group tracing layers in the pool and fit new drops to the stage 2026-10-04 00:16:03 -04:00
Olive Vaughn
f4dd047642 Merge master drawing tools with tracing layers 2026-10-04 00:05:16 -04:00
Olive Vaughn
17e1b4f403 Represent tracing media as symbols and add pool thumbnails 2026-10-04 00:02:12 -04:00
Olive Vaughn
353cb6e050 Add remap ink, eraser cuts, and improved brush geometry 2026-10-04 00:00:09 -04:00
Olive Vaughn
1f0b4d9918 Draw with tools: a pen, a brush that makes polygons, and knockouts
One tool at a time, picked from a strip down the left of the stage or by its
letter, as in Photoshop, Illustrator and Flash: V selects and transforms, P is
the pen, B the brush, E the eraser. The options bar above the stage holds the
tool's settings, and the palette is a grid under the tools with the colour a
new shape gets above it, as Deluxe Paint's was.

The pen's draft is filled into the picture as it is drawn, so the edge on
screen is the pixels the shape will be. Click the first point, Enter, Esc or
leaving the pen closes it; the pen stays the tool. Editing a shape's points is
the pen ON that shape — Figma's vector edit mode — so the separate points mode
is gone: click an edge to add a point, ⌥-click one to delete it, on every key
at once so tweens keep meaning something.

The brush paints a mask, and what it will be is shown while painting: the
stroke is traced round its pixel edges, holes and all, and simplified to a
point every so many pixels of outline by the same function the saved shapes
come from. A hole is bridged into the one ring along a whole-pixel row, which
the fill never samples. Blender's Adjust Last Operation re-traces the last
stroke from what it was made of.

A shape coloured CLEAR is a knockout: its symbol is drawn into a layer of its
own and the knockout clears it, every colour or one. The eraser makes one in
the symbol it starts on, of the colour it starts on, or of every colour with
⌥. A click goes through a knockout to what shows. Previews are drawn in the
stacking context of what they will land in.

The polygon's points no longer stay behind when the shape is moved: they were
read off the saved document while the box handles read the one mid-drag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:43:28 -04:00
Your Name
551d572347 idk man something hopefully helpful 2026-10-03 04:48:03 -04:00
Your Name
5b5b9ae4c3 Play the audio mixdown instead of encoding a WAV of it
Opening the 8625 study froze the main thread for 4.7 seconds and settled at
615MB of heap. A profile put three quarters of a project open inside
mix/wav-bytes, which playback had no business calling at all.

Two separate causes. The peak scan built a lazy sequence of one boxed double
per SAMPLE -- ten million of them for a seven-minute mix -- to compute a
single maximum over data already sitting in Float32Arrays; the hand-written
loop is 73x faster and agrees to the bit. The rest was structural: the WAV
existed only because an <audio> element can hold a URL and nothing else, and
the element existed only to be the clock. So a mixdown that was already
rendered got encoded to 73MB of 16-bit PCM, on the main thread, on open, on
every tab switch and on every edit to a track -- and a symbol with no sound
got silence synthesized and encoded full length so the element had a duration
to report.

arthur.clock keeps its interface and all of its arithmetic; the position now
comes from a backend behind a protocol. clock.graph plays the AudioBuffer
through an AudioBufferSourceNode and derives the frame from the context's own
clock, which is the audio device's position in double precision rather than
whatever the media pipeline last published. clock.element is the old path,
kept switchable while the new one earns trust -- BACKEND, or use-backend! --
which is also why every one of the original clock tests passes unchanged: the
derivation they assert is shared, and the backends can only disagree about the
position under it. 6.5s to 1.8s, 4.7s of blocking to 370ms, 615MB to 68MB.

THE POSITION IS COMPENSATED FOR OUTPUT LATENCY, and piecewise because of it.
currentTime is the quantum being rendered, which the speaker is tens of
milliseconds behind; report the renderer and the picture leads the sound,
which in a lip-sync tool is the only artefact that matters. Audio already
rendered cannot be re-rated, though, so reading it back at a new rate jumped
the playhead backwards by three latencies on every press of the rate button.
Each play, pause, seek and rate change now records a segment and a position is
read against whichever segment was in force when that audio was rendered.

One duplicate fell out of this. Opening a project asked for its clock twice --
once from ::opened and once from a ::refresh-clock the shell raised because it
compared symbol ids, and two different documents both open on :main. The
sounds subscription carries the clip id now, so "an edit under the same
symbol" means what it says.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 04:47:35 -04:00
Your Name
90b1fbe2f8 Create in a lane at the frame double-clicked, palette lanes included
The gesture was handed the pointer's frame and threw it away, creating at
the playhead instead; and the palette row carried :lane? only once its
track existed, so the first double-click on a palette lane -- the one that
has to make the track -- dispatched nothing at all.

Both are one rule now. ::new-symbol-at uses the frame it is given and
resolves every row through drop-destination, with the destination deciding
what is created: a clip of a palette track is a palette symbol, a clip of
any other lane is blank. A row with no path of its own resolves to the
symbol it names, which is what lets a palette track -- hanging off its
owner by :palette-track rather than placed in it -- be reached without a
special case; it also stops a palette cel's slide resolving against the
open symbol and looking like a transfer out of the track. The one thing
left that knows about palettes is materializing the lane a palette row
names before anything asks where the row leads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 03:52:39 -04:00
Your Name
09b74de162 Duplicate a palette from the palette bar
Making a variant meant 16 colour pickers from black: the only button was
"+", which seeds an untitled palette from the built-in defaults. The copy
is selected on creation, so the next colour edit lands on it rather than
on the palette it came from.

`pal/palettes` is the source, so the implicit default — a project that
has never had a palette asset of its own — duplicates like any other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 03:51:17 -04:00
Your Name
2460dce0a5 Refuse a symbol placed inside itself, and label instances by what they place
A paste put an instance of "bg" inside "bg" itself. It saved, loaded, and then
threw "symbol cycle in audio" out of nest/audio-tracks, because a container that
contains itself has no finite expansion.

place-symbol and ui/drag already refused that, each by asking
clip/contains-symbol?. Paste asks clip/problems instead, and problems did not
encode the invariant at all -- it checked missing symbols, pose tracks and audio
links, but never the placement graph. So the rule goes where every command is
already checked: paste, cut, duplicate, correction and the span ops all gate on
problems, so one rule covers them all.

Why it looked like a reasonable thing to do is the other half. place-symbol
copied the symbol's name onto the instance it made, and that copy went stale on
the next rename: the symbol read "bg" in its tab while an instance of it still
read "symbol-18", which is its id from before it was named. One object under two
names, with nothing on screen to connect them.

So instances are no longer given a name at creation, and clip/node-label reads
the symbol's name through on every render. :name on an instance now means only
what a person typed, which is what tells two instances of one symbol apart --
"8625 left" and "8625 right" of one "face" -- so an authored name still wins and
read-through is the fallback. The label logic was duplicated across four call
sites with three different fallback orders; location.cljs already read through
and the others did not. They now share one function.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 03:43:07 -04:00
Your Name
0d49db793b fix some stuff 2026-10-03 02:23:54 -04:00
Your Name
a90e0cfb61 fixed creating in 2026-10-03 02:15:27 -04:00
Your Name
2697401aad Scope inspector controls and key shape colors 2026-10-03 01:48:26 -04:00
Your Name
a834ccb1e2 Unify lane symbol creation and palette automation 2026-10-03 01:35:33 -04:00
Your Name
15deaea19e Support multiple regeneratable footage analyses 2026-10-03 01:24:45 -04:00
Your Name
5bcf22e458 perfect target area thing 2026-10-03 00:39:07 -04:00
Your Name
664252e0fc idk 2026-10-02 17:44:15 -04:00
Your Name
edca82cd5d Add multi-selection clipboard commands 2026-10-02 16:13:03 -04:00
Your Name
987e289f89 Enable browser source maps 2026-10-02 16:11:32 -04:00
Your Name
7c49c08bc7 Fix palette swap backgrounds and timeline drop jitter 2026-10-02 10:24:23 -04:00
Your Name
26fab9392e Model palette tracks as typed symbol lanes 2026-10-02 10:06:05 -04:00
Your Name
dff23d7994 Add multi-object stage selection and transforms 2026-10-02 09:09:18 -04:00
Your Name
b41180db08 Add project palette assets and overrides 2026-10-02 09:08:53 -04:00
Your Name
8f09b7b47f Unify lane overlap and nested timing 2026-10-02 09:06:28 -04:00
Your Name
1b2b4ad3d2 Unify root timing and persistent lane targets 2026-10-02 01:11:55 -04:00
Your Name
f5a39aee39 Keep footage audio with generated face symbols 2026-10-01 23:38:16 -04:00
Your Name
0cb9d0ebf6 Unify timeline placement and gesture geometry 2026-10-01 23:35:22 -04:00
Your Name
4e5e02c856 The window's shape is editor state, not a constant
The grid was five panes at three fixed widths and one fixed height, written as
lengths in the stylesheet. That is a claim about every screen the tool is ever
opened on, and it was wrong on two of them: a phone, where 210 + 250 pixels of
side pane leave nothing for a 320x200 stage, and a large display, where the
inspector is the width somebody once typed rather than the width their work
wants.

So the tracks are custom properties and `ui/layout` owns the numbers. A drag of
an edge is one `assoc-in` and one property on `.app`; no pane's contents
re-render, which is the same reason the picture is painted by `ui/player`'s loop
rather than by anything reactive. Sizes stay in PIXELS and not fractions —
three of these panes hold fixed-width rows, so what you drag an edge to is the
width you meant at any window size.

State, events and widgets are one namespace, which is not this repo's shape
anywhere else. They are the same six integers: `:pool` is a width, a grid track,
a drag's clamp and a button's label at once, and splitting six integers across
`db`, `events/` and `ui/` is more wiring than state.

Three things follow from making it state at all:

SHUTTING A PANE. Pool and inspector unmount; the timeline collapses to its
transport strip instead, because play, pause and the frame readout live in that
strip and a window with nowhere to press play is broken rather than small. The
toggles are in the top bar because a shut pane has no head left to carry its
own handle.

A NARROW WINDOW. Below 760px the grid is one column and the side panes are
drawers over the stage rather than columns beside it. The breakpoint is read
once, at load, and only decides what the layout OPENS at: throwing away the
sizes somebody dragged because they turned a tablet sideways is worse than a
layout that is briefly the wrong shape.

ZOOM. The stage's was already a constant 2 in `ui/stage`; it is now an integer
in [1 8], still CSS over a canvas that is the raster's own size, so the browser
suite still reads 320x200 of real pixels off `canvas.stage`. Whole pixels
only — a fractional scale under `image-rendering: pixelated` draws some rows of
the raster thicker than others, which misrepresents the one thing the preview
exists to judge. The timeline's is the stylesheet's entirely: every mark in the
tracks is positioned as a percentage of their width, so one multiplier on that
width spreads the grid, the spans, the keys, the ruler and the playhead apart
together. Both readouts are the button back to normal, accented while there is
something to return from, because the quick way back belongs in the one place
you already look to find out where you are.

One trap, paid for and then found: the top strip has to scroll sideways on a
narrow screen, and `overflow-x: auto` makes an element a clipping container on
BOTH axes. Every top-bar menu — open, snapshots, share, sign in — was then cut
off by a 30px-tall bar and drawn at whatever x the strip happened to be
scrolled to, which reads as the button doing nothing at all. They are fixed to
the viewport there, as `.menu-drop` already was one pane down for the same
reason. The same shape of bug ate the polygon tool and both zoom readouts: a
flex row that does not fit shrinks every item and pushes the last ones off the
end, so nothing in a strip shrinks now and the strip scrolls as a strip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 21:50:05 -04:00
Your Name
93f5112bb3 Unify clip placement across symbol modes 2026-10-01 20:09:35 -04:00
Your Name
e459307a4a Make lanes explicit symbol views 2026-10-01 19:40:53 -04:00
Your Name
fb38990090 Loop is two things, and the one that matters is unreachable
Loop playback — the transport repeating the open symbol — is editor state and
the document has never heard of it. A looping INSTANCE is a node repeating the
symbol it places, which is the four-frame tire turning for the hundred and
twenty frames it is on screen, and it lives in the document as
`:playback {:end :loop}`. Same word, two scopes; named apart here before
anything is built on either.

The status of the second one is the surprise: it already works and cannot be
asked for. `node/placed-frame` does the modulo, `node/problems` already admits
`:end :loop`, `audio-tracks` already expands the periods — and no control sets
it anywhere in the UI. So the tire is three pieces of work, not one: somewhere
to edit an instance's playback, a block that draws its repeats rather than
only its first pass as the timeline's own docstring admits it does, and the
audio period guard.

This also pins down when the held cel can be torn out. Turning every drawing
into a one-frame looping instance is only safe once a looping instance can be
seen and edited; otherwise every drawing in the document quietly acquires a
property with no control on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 16:32:02 -04:00
Your Name
d029908f0b A held cel is a one-frame symbol that loops
Holding a drawing and looping a one-frame symbol are the same picture, and the
second is a case the model already carries, so the first is a special case
kept for nothing. Speed 0 goes; a cel becomes a one-frame symbol with
`:end :loop` and a span. Looping and span are already properties of the
instance and `:frames` already belongs to the symbol, so nothing moves — a
mode is deleted. Two spellings of looping collapse to one, `:time :loop?`
giving way to `:playback :end :loop`, and `extend-hold` collapses into
`resize-out`, since it exists only to refuse anything that is not frozen
before editing a span.

What it buys is one rule where there were three refusals. `nest/inside` has no
invertible clock for a hold, for `:end :hold`, or for a loop, so
shift-to-reparent refuses all three — which is most of what anybody would drag
onto. Resolving the move with the destination's map at the CURRENT frame
covers every one: a loop is affine within the period the frame falls in, a
one-frame loop is that rule with a period of one — which lands exactly where
the nesting notes argued it should from first principles — and `:end :hold` is
affine in the played part and frozen in the tail.

The trap is written down beside it, because it would be found the hard way:
`audio-tracks` expands a loop into one walk per period, and is saved today
only by the `(pos? speed)` guard that a held cel fails. Make every drawing a
loop and a drawing held for 120 frames becomes 120 walks emitting any nested
sound 120 times. An audibility precheck has to land in the same change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 16:31:06 -04:00
Your Name
6e7827e03f Non-overlap is an invariant, not a report
The plan asked what to draw when a symbol in lane mode holds overlapping
children, and offered to report it as a decision waiting for a person. Wrong
question: they never overlap. Placement claims time — anything placed, moved
or grown over occupied time trims, removes or splits what it lands on — so the
operation that could have made an overlap did not, and `span/finish`, which is
already the single commit path and already refuses rather than half-applying,
is where that is enforced. An overlap is then a bug in a command and not a
state to design around.

The check stays, named `symbol/overlaps` and used three ways: the commit path
refuses one, a property test asserts no command can produce one, and a
document that somehow holds one still LOADS and is drawn visibly wrong with
the status saying so. Not `problems`, which stops a document loading, and not
`conflicts`, which means somebody has a decision to make — a display hint must
never be able to keep a document from opening.

The one place a person can ask for the impossible is toggling lane mode on
over children that already overlap. That refuses and offers to trim them into
a sequence, through the `:required-frames` retry the model already uses.

Also written down, because it is the pair the modifier exists to separate:
a plain body drag is temporal and replaces, trimming extents as needed; shift
is structural and goes through `nest/move-node` into the symbol under the
pointer, which has to keep working for symbols held in a lane.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 16:22:33 -04:00
Your Name
10b96761fa Plan: a lane is a view, not a thing in the document
The lane model put a second container in the node map — a group with
`:layout :sequence`, with its own membership, its own validation and its own
fourteen commands — and every part of the editor then had to ask which kind of
container it was looking at. The decision recorded here is to delete all of
it: a lane becomes a way of DRAWING a symbol whose children are sequential and
non-overlapping, the display goes back to a row per symbol, and the word
survives only in the timeline and in the drag handling that re-spans a
symbol's children while it is drawn that way.

The commands are not the part being thrown away. `extend-hold`, `resize-out`,
`roll`, `blank` and the rest are what endpoint dragging IS, and their
arithmetic is right; what changes is their subject, from "the children of lane
L in symbol S" to "the children of symbol S". They belong in `span.cljs`,
which already owns re-spanning and `finish`.

The plan takes a position on the one question that decides whether this is a
simplification or a circle: lane mode is a saved hint on the symbol rather
than unsaved view state, because the drag rules follow the mode, and a toggle
the document does not record would make one gesture do two different things
to it. Nothing outside the timeline may read the hint.

It also lists what must not be lost on the way, all of which broke at least
once today: a held clip's contents reachable with no keys and no draggable
edges, double-click to open surviving the selection it leaves behind,
selection waiting for pointer-up, no drop silently deleting what it lands on,
and a sound drawn once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 16:16:30 -04:00
Your Name
2dc5735ded The timeline opens the whole document
Making a lane one row cost the thing a row was for. A clip stopped being a row,
so there was no longer any way to open a clip and see what was inside it, and
the inside of a drawing — the most ordinary thing in the document — became
reachable only by opening it as its own tab. This is that capability back,
from the root timeline, down as far as it goes.

An expanded lane opens exactly ONE clip: the selected one. Its own keys, then
the lanes and nodes of the symbol it places, then theirs, each mapped into this
ruler by the recursive walk that was already there. Twelve clips in a lane
still cost one row, and inspection costs one branch rather than twelve.

Two things that only showed up once it ran. The portal is chosen by the whole
LINEAGE of the selection and not by the selected id: selecting a shape inside
the clip — or the end of its span — is still working inside that clip, and
matching the id alone shut the portal the instant anything under it was
touched. And selecting now waits for the pointer to come UP, because selecting
on the way down re-drew the timeline before the gesture had said anything: it
shut the portal holding the lane being dragged INTO, out from under the
pointer.

A HELD clip opens too, which the old row walk never did either. `source-time`
is nil for a hold, so the walk stopped there and the contents of every drawing
were invisible from here. Its rows are shown across the hold — which is when
the node is on screen — and marked `:unmapped?`: no keys, and no draggable
edges, because a frozen clock gives no frame inside it a place on this ruler.
Refusing to place the keys is the honest half; refusing to show the rows was
not.

Double-clicking a clip opens the symbol it places as a tab, as double-clicking
the same symbol in the pool does. That was already written and had never once
run: the track captures the pointer for a slide, so the click and double-click
that follow are delivered to the track and never to the block. The track now
resolves them itself. Fixing the delivery exposed two more: `symbol/lineage`
reported a `parent cycle` for any id in a symbol with NO nodes, because a
one-element chain is longer than zero nodes — and opening a symbol left the
selection pointing into the symbol being left, which the breadcrumb and the
inspector then tried to resolve. The editor unmounted. Both are fixed where
they were wrong, and the browser test asserts the editor is still standing
afterwards.

Audio is a clip in a lane like everything else. A dropped sound lands in one
and is trimmed and moved by the same commands; a lane holds picture or sound
and not both, which is the explicit capability the model asked for rather than
a guess per frame. The refusal lives in the commands and not only in
validation, because placement claims time: `blank` would have deleted the
sound to make room for the picture and left a perfectly valid document behind.
What is in a lane of the open symbol is drawn as a lane; what is nested inside
a placed symbol is still flattened by `audio-tracks`, so no sound is on two
rows.

Everything that enters the timeline now enters a lane: a converted take, a
symbol brought in from another project, a sound. One rule answers where —
`lane-destination` — and every symbol is born with a lane for it to answer
with. An unaimed drop fills an EMPTY lane rather than taking an occupied one
nobody pointed at, because the alternative is trimming away what was there to
make room for what was dropped.

Shift during a clip-body drag means the other intention: put this node INSIDE
the symbol the clip under the pointer places, through `nest/move-node`, which
is what keeps the world transform and the root timing. Overlap cannot say
which of the two is meant — dropping on occupied time already means claiming
it — so the person says, and a label by the pointer says it back. The label
asks `nest/move-refusal`, the same check the command makes, so it cannot
promise what the drop would refuse. Today it refuses more than it allows:
both clips have to be on screen at one frame, which two clips in one lane
never are, and a held destination has no clock to move through at all.
`docs/lane-nesting-notes.md` argues that the second refusal is stronger than
the facts require and says what would settle it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 16:13:02 -04:00
Your Name
95451798d2 Nesting is a gesture of its own, not an overlap
Now that a lane is generic, the obvious next move is to read a clip dropped on
another clip as "put it inside that symbol". It cannot mean that: dropping a
clip on occupied lane time already means it claims that time and trims the
incumbent. Structural nesting therefore needs an explicit affordance -- a grab
handle with `grab`/`grabbing` cursors, distinct from the body's temporal move
and the edges' trims -- and its drop must route through `nest/move-node`, which
preserves world transform and root timing, rather than through a weaker
`:parent` assignment that would make a drawing jump when it is rehoused.

The second half of the note is what expansion should be. One permanently
expanded row per lane clip is the vertical growth the one-row lane exists to
avoid, so an expanded lane shows exactly one portal: the currently selected
clip, swapped in place when the selection changes, not following the playhead.
The portal header is the structural drop target, sub-expanding it walks the
source symbol's lanes through the existing recursive root-time mapping, and
collapsed clips carry their instance-level keys as ticks. The cost of
inspection stays constant.

No lane-as-symbol type and no second ownership edge: the hierarchy is still
symbol, lane, clip, source symbol, its nodes. Lane membership owns time; a
symbol instance owns composition. Written before the interaction is built,
because the capability it protects is easy to lose by accident.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 15:13:18 -04:00
Your Name
5ebe776ce4 A lane is a generic row of symbol clips
A lane was a drawing lane: the only thing that could go in one was a one-frame
held cel, and every other symbol instance stayed a permanent root row of its
own. Those are not two kinds of timing, they are one kind with two creation
policies. `lane/place-symbol` drops any library symbol in as a clip that plays
naturally at speed one, `lane/adopt` moves an instance that is already in the
document into a lane keeping its source, span, playback and corrections, and
`append-drawing`/`overwrite-drawing` keep being the policy that makes a new
empty symbol a one-frame hold. The child shape they produce is the same.

Both new commands claim their interval through `blank` before they write, so
the partition rule is unchanged and unduplicated: placing into occupied lane
time trims, removes or splits the incumbents, and a lane still never stores an
overlap. Real compositing overlap is another lane, where the order is explicit.

Creating a symbol with nothing aimed now makes a lane and a clip in it instead
of a loose root instance, and a pool drop prefers an explicitly targeted lane,
then the selected one, and makes a lane only when there is neither. That is
what stops the row-per-symbol growth coming back in through the drop path, and
it is why `add-lane` now takes a z in front of the existing root nodes and
calls what it makes a "lane" rather than "drawings".

The timeline learned the two gestures that a generic lane needs. A clip body
dragged over another lane's track previews there as a dashed block and lands
through `::adopt-in-lane`; the track is found with `elementsFromPoint` and its
selection read back off the element, because a pointer capture does not
retarget. A pool drop over an existing lane previews as a dashed clip inside
that lane instead of a temporary new row that appears and then vanishes --
which also needed the drag-leave check to be geometric, since inserting the
preview changes the element under the pointer and Chromium then reports a leave
with no related target. Lanes are renameable from their label, by double-click,
F2, or the pencil, through `::rename-node`.

`symbol/lane-cels` is `symbol/lane-clips`, and the vocabulary table in the
handoff now separates the two words it had merged: a clip is an instance in a
lane, and a cel is specifically the one-frame held source that drawing creation
makes. Keeping `cel` for the policy is what lets the lane stop being about
drawings at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 15:13:07 -04:00
Your Name
26ada03591 Unify cel editing in the timeline 2026-10-01 14:37:59 -04:00
Your Name
4ddd6a8d1d Suspend parameter automation during live control 2026-10-01 12:40:50 -04:00
Your Name
0a53157b7e Buffer auto-key performance takes in memory 2026-10-01 12:36:03 -04:00
Your Name
abefa1c452 Sample auto-key drags once per playback frame 2026-10-01 12:30:24 -04:00
Your Name
6e9409b0de Write auto-keys during transform drags 2026-10-01 12:25:41 -04:00
Your Name
34f62ab007 Add auto-key mode to main toolbar 2026-10-01 12:22:30 -04:00
Your Name
3879d76d57 The cel sheet creates the place a polygon needs 2026-10-01 12:17:35 -04:00
Your Name
6443366748 The snap reads back into the slot's own gap, per pose group
Step 2 of docs/frame-selection.md, which f7e16e5 planned and left unbuilt: the
preserve-snap, and nothing of the plate side. `pose/snapped-frame` is the whole
rule — the latest mark in `(lo, hi]`, and `hi` when there is none — where `hi`
is the native frame the slot defaults to and `lo` is the one the slot before it
defaulted to. So the interval is exactly the frames this slot is the first to
cover, which are exactly the ones the grid shows to nobody: it recovers a
dropped frame out of its own gap, can never read a frame another slot already
showed, and cannot reach past `hi`.

Backward only. A closure at native 13 in a 12-from-30 output is recovered by the
slot whose default is 15, reading 13 — not by the slot at 12 reaching forward,
which would show the mouth shut 17ms before it did and break the no-lead
invariant `cadence_test` asserts over every grid and native pair. That test now
covers the snap too.

Seated as the DEFAULT pose that `pose/source-frame` reaches, so an explicit hand
cut beats a snap with nothing having to say so, and per pose group rather than
at the slot: snapping where the grid becomes native is one frame for the whole
picture, so a head would go two frames stale to fix one mouth. Groups exist only
in `symbol/base-channel-frame`, which is why the interval is threaded that far
down — `(:pre parent)` carried beside `(:f parent)` through the same time maps,
so an ancestor's exposure fold or retime is already in it.

The marks are the `[:vis]` cuts `flow/freeze` already stores, with their
thresholds and hysteresis already decided: no new signal, no new stored field.
A whitelist of `:roto/mouth-aperture` and `:roto/blink` and not a test for
`:generated`, because a skipped frame, a hidden feature and an absent
measurement are three different facts — snapping onto the frames a teeth contour
happened to be missing on is the cadence being dragged about by an absence.

Off is the default and needs no second code path: an opts map that says nothing
gets the behaviour it got before the snap existed. `:snap` is asked about the
SYMBOL, because the cuts are the face's own nodes' and every placement of one
face has the same ones.

The switch is `performance · <face>` in the inspector, and the readout says it
is the stage only. The document setting docs/frame-selection.md specifies wants
a leaf and a round trip of its own; until then this is `[:ui :smart]`, not
undoable, not synced, and unable to reach an export.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 11:34:43 -04:00
Your Name
340a8dbbd6 The footage switch is the inspector's, and the open symbol's
Moves the tracing toggle and its opacity slider off the palette bar. The bar is
what you set before you draw — the tone and the tool — and whether a reference
photo is showing is not that; it is a viewing aid, like solo, and it belongs
with the other things the inspector says about what is on the stage.

Its own `footage` section rather than part of `tracing · <face>`, which is the
whole reason it could come back to the inspector at all. That section needs a
face to be about, so it is absent when a take is open and nothing is selected —
the commonest case there is — and a switch that appears only once the right row
has been found makes the way to see the footage you are tracing depend on what
you clicked, which is what put the thing on the bar in the first place. So the
section keys off `trace/traceable-faces` of the OPEN symbol: present whenever
the picture has any footage behind it, wherever the selection happens to be.

No change to the state or the events. `[:ui :trace]` is still a set of faces and
one opacity, `::trace-faces` still switches every face the open symbol has on
unless they all already are, and a face's own timeline row still singles it out.
The label loses the word "footage" because the section heading now says it.

`.trace-opacity` was 64px to survive a crowded flex bar and is `flex: 1` on a
pane row instead; `.palette-bar label.dim` had no remaining user.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 11:33:03 -04:00
Your Name
f7e16e5ef4 The snap is per group, and the slot interval has to reach it
Pulls the preserve-snap forward to step 2: it is the half with no UI and nothing
proposing today, and it needs nothing from the plate side but a fold that can
land last.

Makes the rule exact, because the direction is easy to get backwards and the
draft was vague about it. Slot k reads the latest preserved frame in
(d(k-1), d(k)], else d(k). The closure at native 13 in a 12-from-30 output is
recovered by slot 6, whose default is 15, reading 13 — NOT by slot 5 reaching
forward from 12, which would show it 17ms before the mouth shut and break the
invariant cadence_test already asserts.

Records where it goes, which is the part that was understated as "about thirty
lines". The slot interval exists only at clip.cljs:261 and group identity exists
only at symbol.cljs:399, so the interval has to be threaded down: two internal
signatures, not a drop-in. The rule's seat is the `(js/Math.floor lf)` default
`base-channel-frame` hands `pose/source-frame`, which leaves an explicit hand cut
beating a snap, as manual precedence requires.

And records the shortcut not to take: snapping at clip.cljs:261 needs no
threading and is wrong, because one native frame per output frame means the whole
picture reads 13 instead of 15 — a 67ms stale head to fix the mouth, fighting the
trace selection's own opinion about which head frame to show.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 10:21:35 -04:00
Your Name
cc42155ffa The plate side chooses frames; the performance side nudges the grid
Corrects this plan's central claim. It said one component used twice, because
the prototype's `suggestPlateFrames` and timing-handoff's performance poses
looked like the same function. They are not.

A plate selection has to be NON-UNIFORM — that is the entire reason it exists. A
head still for sixty frames and then whipping across in ten wants two drawings
and then eight, and no nudging of a uniform grid produces that distribution,
because the spacing itself is the answer. A tolerance belongs here: the artist is
buying drawings.

A performance selection is not choosing sparseness at all; the output rate or the
exposure setting already did. What is left is which native frame each decided
slot reads, so nudging the grid is the right size of answer and a tolerance would
be a knob with nothing to control.

And the harder reason, which settles it: a selection cannot put a frame on screen
that the output grid never samples. At 12fps out of 30 a closure at native 13
falls between output frames 5 and 6, so keeping 13 in a set makes it available
and never shows it — exactly what time.md says about an event between output
frames. Only moving what output frame 6 reads can show it.

So the performance half is a backward-only snap of the grid's own pick toward a
preserved frame, about thirty lines plus a prepare step, and its marks come from
cuts `flow/freeze` already stores: the mouth's aperture threshold and the eyes'
`resolve-blink` with its hysteresis. No aperture signal, no new dense track, no
threshold decided twice. The nesting survives unchanged, because a kept plate
frame is just another mark in the same pile.

Records why the aperture signal is impossible as drafted — positions 5 and 15 of
LIPS-INNER survive `freeze/rings->flat` only at verts divisible by four — and
names commit 02069e8 as the one holding the now-uncalled extrema detection, with
the two conditions under which it would be wanted again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 10:14:54 -04:00
Your Name
02069e88f0 A protected frame is on screen, so it anchors the walk
`domain/select`: the choosing half of a selection, pure and handed a plain
vector by each site. `pose/held-frame` was already the shared reading half;
nothing chose automatically at all.

The greedy walk rather than the DP, deliberately — it is what the prototype
shipped, and having two implementations is how the DP gets tested. Several tests
pin its exact output for that comparison and will need rewriting when it lands.

Protection is settled before the walk and is not unioned onto its result,
because a protected frame anchors the walk like any other kept frame. The anchor
is what is on screen, so measuring the next frame's drift from a frame that is
no longer displayed holds a one-frame closure across two frames and shows it
twice as long as it was measured. The test that says so is about the picture
rather than the algorithm: the frames the mouth was measured shut on and the
frames the selection shows it shut on are the same list.

A strict local extremum compares against the nearest DIFFERING samples, not the
immediate neighbours, and a run of equal samples is one extremum at the frame it
begins. Immediate neighbours find nothing at all on a closure lasting more than
one frame, which is most of them.

One width for the whole signal, so `distance` is never comparing the prefix two
samples happen to share; a non-finite tolerance falls back to nought, since NaN
compares false against everything and would quietly collapse a performance to a
single pose.

THE EXTREMA HALF HAS NO CALLER under the plan as it now stands, and this is the
commit to revert if it stays that way: `segments`, `turns`, the `:extrema`
branch of `propose` and the multi-component refusal that only guards it, plus
the three tests over them. Plate selections take no extrema by design, and the
performance half gets its closures from the `[:vis]` cut `flow/freeze` already
stores, so nothing asks this code for anything. It is correct and tested and
speculative; docs/frame-selection.md says so beside the build order.

Also corrects `ring/subsample-slots`, which claimed every even budget lands on
the cardinal positions. The corners do; the lip centres survive only multiples
of 4, so the aperture pair cannot be read off a subsampled ring at verts 6, 10,
14 or 18. That is why the performance signal reads a stored cut instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 10:13:00 -04:00
Your Name
14673385d4 Remove completed timing worktree 2026-10-01 09:11:57 -04:00
Your Name
95cf2598ba dockerignore 2026-10-01 01:53:04 -04:00
Your Name
05878ca48b grand unification of time 2026-10-01 01:47:08 -04:00
Your Name
a4ce750be2 The face owns its placement, not the take that holds it
The source-to-stage mapping moves off :main's :face group and onto each face's
own :place, above its head. `face-placement` computes exactly what it computed
before, over every subject together, so two faces filmed side by side keep
their filmed relation — it is written into each face instead of onto a group
above them all. Same transform, same subtree, one level lower, and the
composite is identical to the pixel: a digest over every op :main emits across
the whole take is unchanged either way.

THE OWNER IS THE POINT. A face carrying its own mapping is the right size
wherever it is put — dropped into another symbol, or opened in its own tab to
be drawn over — and the take that holds it needs to know nothing. On a group
above the instances the scale belonged to the take, so a face taken out of it
had no size at all and drew at a fraction of a pixel.

The pool's thumbnails drop the workaround that knew about this: a symbol is
rendered rooted at itself again, because a face now carries the placement that
makes that honest, so the pool needs to know nothing about where a symbol
happens to be used. `domain/node` and `arthur.export` leave its requires with it.

The tests here were reading the placement off :main. The photo registration
test changes shape rather than location: its premise was that face-1's head is
its own root, so a photo sitting where it was filmed was image pixels over
image height and nothing else. The head still cancels — that is what the test
is about — but it now cancels against the face's own placement, which is why
the photo comes with the face into its own tab instead of sitting at a
fraction of a pixel beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 01:25:37 -04:00
Your Name
e6d0ededb1 spreadsheet ui 2026-10-01 01:25:26 -04:00
Your Name
7e34d0c704 Make the media pool a list you can actually read
Every row is one line now — a thumbnail at the stage's own 16:10, the name,
and the one number you need before dragging it somewhere — at the timeline's
own row height. Two always-open folders cost eight headings before the first
row in a pane 210px wide; they are two tabs, and a search counts its hits in
the scope you are not looking at so nothing can hide behind the one you did
not pick.

Symbols draw their own first frame, through the resolver and rasteriser the
stage uses. A PLACED symbol is drawn where it is placed, with the rest of its
host isolated away: rooting at a symbol renders its DRAWING, and a rotoscoped
face is head-local in units of one image height, so the source-to-stage scale
that makes it pixels lives on the :face group of whatever places it. Rendered
rooted at itself a face is correct and under a pixel across. Thumbnails are
smoothly downscaled for the same kind of reason the preview is not: at a tenth
of the stage's size nearest neighbour samples one pixel in a hundred, and the
silhouette is the whole of what makes a thumbnail recognisable.

Names are editable, and that is two operations behind one pencil. A symbol's
name is a field of this document, so it is an undoable edit and blank gives it
back its id. Footage and sounds live beside projects rather than inside one,
so theirs is a server write shared by every project using the row — PATCH on
the existing Footage.label, and a new Sound.label kept separate from the
filename, which is a fact about the upload and not somebody's name for it.

No TIMELINES section beside a SYMBOLS one: every symbol here IS a timeline, so
that pair named one thing twice. What is true is that exactly one of them is
where the work happens, and clip/opens-on already answers which — it leads the
pane as PROJECT, drawn at a size you can read a pose off, with the library
under it. Still not :main being special; rename it or place it inside
something else and the pool follows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 00:56:39 -04:00
Your Name
815ce449ea Author corrections without baking them into motion
Constant, ramp, and return offsets now append ordinary channel layers to a lane or cel in explicit owner frames. The inspector exposes the commands as one undoable transaction, shows conflicts, and offers removal or retry while preserving generated bases through regeneration.

Ordered-stack compatibility is shared by validation, conflict reporting, and regeneration, including adjacent replacement coverage. Cel-sheet gaps and headers select their lane, so commands cannot fall through to another column's stale selection.

437 tests, 5,804 assertions; both browser flows; 56 Django tests; optimized frontend build.
2026-10-01 00:29:59 -04:00
Your Name
3dbbe285fc bread crumbs 2026-10-01 00:27:01 -04:00
Your Name
5fb04f6a7d better toolbar 2026-10-01 00:11:16 -04:00
Your Name
2f1c9b9c02 Turn the lane sideways without changing what it means
The cel sheet is a second projection of the same lane rows: frames run down, lanes run across, and each occupied cell carries the timeline cel's exact selection address. The shared action strip proves the point in the browser test by selecting a cell and issuing the existing hold command.

Before exposing that second entrance, fix the boundary mistakes it revealed. Nested commands now convert the open playhead through their enclosing instance path. Overwrite composes blanking with non-rippling placement as one transaction. Picture-rate and pose sampling select only the generated base frame while hand corrections retain the node's authored frame. Stack validation follows covering replacement layers so a document accepted by the validator cannot throw solely because a later offset sees a different shape.

429 tests, 5,767 assertions; both browser flows; 56 Django tests; optimized frontend build.
2026-09-30 19:59:38 -04:00
Your Name
7a54bfca56 Write down what the next person needs
`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>
2026-09-30 19:45:28 -04:00
Your Name
598c186c4f One word for one thing: it is a cel
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>
2026-09-30 19:43:18 -04:00
Your Name
76106d36ee The shot is as long as somebody said it was
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>
2026-09-30 16:32:59 -04:00
Your Name
72b57e3786 Regenerate the base, keep the hand work, and say when you cannot
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>
2026-09-30 16:22:02 -04:00
Your Name
94c0a21de1 A correction is a layer, and a layer's values are a channel
`: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>
2026-09-30 16:07:38 -04:00
Your Name
26517af2fd A position is an argument, not another command
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>
2026-09-30 15:56:26 -04:00
Your Name
9446829774 Reuse, duplicate and make unique: deciding what is shared
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>
2026-09-30 15:30:59 -04:00
Your Name
3d3c1bbca0 An occurrence is a node, with a clock of its own
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>
2026-09-30 15:20:23 -04:00
Your Name
624242b407 An options map for the resolvers, and sid beside the clip it is in
Finishing the last commit, which traded a simpler definition for noisier call
sites: deleting the arity ladders left `(symbol/resolver sym st pal/index-of
nil nil)` at twenty-odd places, and two trailing nils tell a reader nothing
except to go and count positions.

The ladder was a symptom. The disease is five positional parameters, and the
split that matters is which of them are OPTIONAL:

  store, palette    positional, because neither is optional. A dense channel
                    cannot be read without the store it names — that is the
                    crash two commits ago — and every op carries a colour.
  pose-tracks       one call site, in `clip/resolver`'s own recursion
  source/picture-fps  two call sites

So the last three become one `opts` map, and the common call loses a nil. The
point is not the nil: it is that the sixth option, whenever it arrives, is a
key at one call site rather than a nil at fifty.

`clip/resolver` also had `sid` FOURTH, behind two arguments that say nothing
about which symbol is being resolved. It is second now, beside the clip it is
in: `(clip/resolver c :main store pal/index-of nil)`.

62 call sites rewritten by parsing the forms rather than by regex, because
`clip/resolver`'s arguments move past each other and a regex cannot see that.
An earlier attempt at this dropped `palette` on the floor and still compiled
at 62 sites — it only surfaced as an arity error, so if that had been a
same-arity mistake the tests would have been the last line of defence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 12:21:12 -04:00
Your Name
ee66680a0c Make every caller say what it means: no defaulted arities
Pre-alpha. Nothing here is owed a call shape it used to have.

Twelve convenience arities deleted, and the only reason to single any of
them out is that one of them was a live bug: `channel/value-at`'s `([ch f])`
filled in a nil tier-2 store, so a caller could omit it, read correctly for
every channel that happened not to be dense, and throw the first time one
was. That is the iris crash, and threading the store through `gesture/values`
last commit fixed the symptom while leaving the trapdoor open. Deleting the
arity found `node/toggle-key` standing on it too — the inspector's stopwatch
on a measured channel, the same throw, never reported.

Gone, and what the compiler then made explicit at each site:

  channel/value-at, cursor, dense-at   the store, and `nil` where a caller
                                       genuinely has none and means it
  channel/keyed                        `:hold`, which is a cut rather than a
                                       tween and not a thing to leave implied
  symbol/resolver (4), eval-frame (3)  store, palette, pose-tracks, opts
  clip/resolver                        opts
  mix/buffer!, store/install!          dead: no caller used the short form

`pick/local-bounds` goes the same way — it was `bounds-of` with the closure
thrown away, so callers build the closure and call it.

Every site was found by shadow-cljs `:fn-arity` rather than by grep, which is
the argument for the change: 90-odd call sites, and the compiler listed all of
them. BUILD BOTH TARGETS — the last three only appear in `:app`, since `:test`
compiles what the tests reach and the inspector, the pool drag and the vertex
overlay are not that.

Left alone, because an argument with a default is not the same thing as a
shim: genuine optionality like `fx/http`'s body, `geom`'s iteration count,
`zip`'s injected clock.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 12:10:22 -04:00
Your Name
11093079de Scale a part about the middle of what it draws, from the document as it is
Three faults, one gesture. None of them was in `gesture/scale`, whose
`s' = s·b/a` about the pivot was right all along.

THE PIVOT. `clip/place-symbol` writes an instance's anchor, `paint/new-shape`
a drawing's, `nest/group` a new symbol's and `face-placement` the source
placement's — and `flow/freeze` wrote none for the parts underneath, so a
traced mouth turned and scaled about its own coordinate ORIGIN, which for
head-local geometry is the top-left corner of the footage. On a 320x200 stage
that put the mouth's pivot at (-234, -395), so dragging a corner outward slid
the shape about and shrank it. `pick/pivot` is `clip/center`'s rule for a node
rather than a symbol; `freeze/pivoted` applies it to every node the freeze
makes, skipping `node/measured?` — the predicate `gesture/refusal` already
refuses a hand edit by, so a pivot is written exactly where a hand can use
one. That also keeps it off `:head`, whose scale is not 1 and where an anchor
would NOT cancel out of `local!`; skipping it for drawing nothing would have
been true only by accident. Asserted in pixels: the pass moves nothing.

THE JUMP. `ui/stage`'s overlay dereferenced the document while RENDERING and
used it when the pointer went down. The store is a mutable handle behind an id
that does not change when the document does — `:paint/revision` says that, and
the overlay subscribes to neither it nor `::render/clip` — so after any edit
the next drag began from the transform the node had before the last one: still
under the press, jumping on the first pointermove. `ctx` now carries the id and
`loaded` reads at pointer-down.

THE CRASH. `gesture/values` read its channels without the tier-2 store, which
is fine until a selection lands on a dense transform — an iris follows the
gaze, a brow the raise, a head the similarity — and then `dense-at` throws and
takes the stage down, in `begin!` and again in `handles`. It takes the store
now. Kept in this commit because it is the same two functions.

`pick/bounds-of` yields a closure, as `clip/resolver` does, so an instance's
resolver is built once for a walk instead of once per frame — which is what
let `pivot` be the one walk it is rather than a separate path for instances.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 11:53:50 -04:00
Your Name
ed88c5e674 Hold a tracing photo steady, and switch it on where you would look
The photo blinked out for a frame or two and sometimes never arrived, from
three causes that each present as the same bug. The still cache emptied
itself on the frame it filled, so every still on screen had to be fetched
and decoded again — every 48 frames of a scrub, and with two traced faces a
permanent flicker, each one's still evicting the other's; it drops the least
recently used now, a still being touched on every frame it is drawn. A still
cannot decode in the animation frame that asks for it, and a face whose next
one had not arrived drew nothing, so it now keeps the still it was showing
until the new one is there. And nothing was read ahead, so continuous
playback was always a frame behind its own footage; the next few frames'
stills are asked for while the playhead is moving on its own, and only then,
because a scrub asks for a different few at every step.

Whether the footage shows is no longer the document's. It was an :underlay
on an instance, inherited down the row path, nearest wins, and it went
through edit — so showing a reference photo was an undo step that travelled
to collaborators. It is [:ui :trace] now, the faces switched on and one
opacity, like solo, and there is nothing left to inherit: a face is the same
face wherever it is placed, so one switch covers every placement of it. The
paint loop asks for its own redraw when that changes, since the resolver no
longer does it for them.

Opening a face shows its footage, because a symbol has measured footage
behind it only because it was traced from that; a take does not, because a
take is the picture. The switch is on the bar above the stage, with the tone
and the tool, and on each face's timeline row beside solo — not a section
that appeared once the right row had been found. A face open in its own tab
could not show its footage at all before, shown having walked instances, and
that is the one place tracing matters most. The inspector keeps the face's
own keyed facts, its trace keys and its origin, and says why it cannot key a
frame rather than greying out the only button in the section.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 10:42:39 -04:00
Olive Vaughn
ae03b61dca sound stuff 2026-09-30 03:31:19 -04:00
Olive Vaughn
309c47e0a7 Trace a face over its footage, and choose where its origin goes
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>
2026-09-30 02:49:06 -04:00
Olive Vaughn
550cfe91e5 Select, move, turn and scale on the stage, at any depth
A click selects the thing in the open symbol, a double-click goes one
level in, ⌘-click goes to the shape itself and Esc comes back out —
Figma's rule — and a click inside the selection keeps it, so a shape
several symbols down can be dragged. The selection is the one a
timeline row makes, so the inspector shows it and its row opens and
scrolls into view.

A drag writes what the inspector writes: a key on the node's own frame
where the channel has keys, its one value where it has none. It is
previewed like a bar being slid and let go as one edit, so one undo
step. Measured transforms refuse. A shape's points are edited by
double-clicking it, and new shapes turn about their middle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 02:12:31 -04:00
Olive Vaughn
2a0426707a Hold or tween any keyed channel's gaps, as a drawing's
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 01:51:30 -04:00
Olive Vaughn
da7e293814 Solo an instance from its timeline row, shift-click for more than one
A nested mark is now named by the flat path its row has, [a b mark] rather
than [a [b mark]], so a soloed row is a prefix of what it shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 01:44:24 -04:00
Olive Vaughn
7aaf0a15bd Inspector number boxes edit as you type, and are one undo step per focus
The boxes wrote nothing until blur, so a spinner click showed nothing on
screen. Now every keystroke and step is an edit, and history holds the
step open from focus to blur, so typing 4 then 5 is seen as 4, then 45,
and undone once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 01:35:52 -04:00
Olive Vaughn
277c0c3b63 Keep the timeline's ruler and playhead dot in view while its rows scroll
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 01:22:01 -04:00
Olive Vaughn
c139e4d747 Rename the project from the top bar; export from a dialog
The title is a button that becomes a field, saved on Enter or blur. Export's
target and scale move out of the bar into a dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 01:12:08 -04:00
Olive Vaughn
0f9ce826ef Set the project's stage and rate, and give a symbol its own stage
The inspector edits the project's width, height and fps, and a symbol's length
and, optionally, its own width and height. A symbol without them follows the
project's (`clip/stage`); the stage, export and placement all read it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 01:12:08 -04:00
Olive Vaughn
1ac21fdcab Brought-in footage plays at the project's rate
The new symbol gets project-rate frames covering the same wall-clock length,
and its root and sound map them back onto the source's frames.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 01:11:32 -04:00
Olive Vaughn
4b8123d5e2 Delete a timeline row with everything in it
Delete or Backspace, or the × on the selected row, takes the node out of its
symbol along with every node hanging off it. One edit, so undo brings it back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 01:11:16 -04:00
Olive Vaughn
7246534505 Key a node's transform and visibility from the inspector
Each of vis, anchor, pos, rot, scale and skew is a row: ◆ keys the channel on
the node's own frame, or takes the key there off, and a typed value is written
on blur or Enter — a key on a keyed channel, the one value on one that is not.
Rotation reads in degrees. Dense and other channels keep their readout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 01:11:16 -04:00
Olive Vaughn
38850a29ba Slide rows along time and restack them, at any depth
A node's bar drags along the timeline: one write to its :at, for every node
alike, carried down through the instances above it. The stage and rows show
the slide live through ::render/clip, and it lands as one edit on release.

A row dropped on another's top or bottom edge goes in front of it or behind:
one write to :z, between its new neighbours (symbol/z-between). Onto the edge
of a row in another symbol, it moves there first. Neither needs anything on
screen — nest/down walks a row path by structure, without a frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 00:51:34 -04:00
Olive Vaughn
1b8bbc7372 Edit shapes nested in other symbols, from any symbol above them
`nest/inside` walked a row path only through instances. Every node has
the same two maps to its parent, so the walk now steps into any node:
inside an instance is the symbol it places, inside a shape is where its
points and keys are. The selected shape at any depth is `inside` over
its full path, which gives the stage editor its handles (through the
matrix, drags back through the inverse) and the inspector the shape's
own frame to key at, and the time map back for jumping to a key.

`inside` resolves only the node's lineage: where a node is depends on
its parents and nothing else, and resolving the whole symbol cost more
than a stage frame (7.9ms against 3ms on the swarm; now 0.09ms).
Checked equal to the whole-symbol resolve on every node and frame of
the swarm, two instances deep.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 00:12:21 -04:00
Olive Vaughn
6a53adb5e0 Projects live at URLs, have owners, and are edited together live
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>
2026-09-29 22:04:03 -04:00
Olive Vaughn
c17ee138f2 Split domain/clip along the data: clip, nest, bring
domain/clip is the document and what only needs the document: its symbol
table, placing, the resolver, problems. domain/nest is how nested symbols
relate — one walk down a row path gives the frame, the matrix and the time
map, which merges inside and time-down — and moving and grouping between
them, and nested sound. domain/bring is copying symbols in from another
clip, a take from footage, and one placed that merges the store and places
the instance, which conversion and import both now call instead of each
doing it in their own words. Tests follow the same split.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 14:11:41 -04:00
Olive Vaughn
7bb80d315d Bring in symbols from other projects; new and open send the clock home
Dropping a symbol out of the pool's all-assets folder fetches that project's
clip and the blocks it names, copies the symbol and everything it places in
through clip/adopt — renamed where ids collide — and places it where it was
dropped. It comes in as drawing: its tracking stays with the analysis that
measured it. New and open now seek the audio clock to 0 with the readout,
where play used to pick up wherever the last document's audio had got to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:51:39 -04:00
Olive Vaughn
490460bf45 Drag timeline rows into symbols, into groups, and back out
A node's row can be dragged onto an instance's row, to go inside the symbol
it places; onto any other node's row, to be grouped with it into a new
symbol; or onto empty label space, to come back to the top of the open
symbol. All three keep the picture and the timing as they are, and a refused
move says why in the status line. Pool drop targets now only take things out
of the pool, and row drags set a move effect so the browser delivers the drop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:50:34 -04:00
Olive Vaughn
eafbe6c4d2 One time map for every node; move and group nodes between symbols
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>
2026-09-29 13:45:34 -04:00
Olive Vaughn
6bec121108 Draw into the selected symbol
With an instance selected, a finished polygon goes into the symbol it places,
its points and frame carried in through clip/inside — which resolves each
level, so the frame and matrix are the ones the stage draws with — so the
shape lands exactly where it was drawn however the instance is moved, turned,
scaled or retimed. Beside any other selected node, or at the top of the open
symbol with nothing selected. clip/inside also replaces frame-inside for new
symbols, so there is one account of 'which frame is it in there'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:35:06 -04:00
Olive Vaughn
1a42481575 An instance pivots about the middle of what its symbol draws
place-symbol sets a new instance's anchor to clip/center — the middle of the
bounds of everything the symbol draws over all its frames, or the stage's
middle for a symbol that draws nothing — and a stage drop puts that middle
under the pointer. The anchor is set once and never follows the symbol, as
Flash's transformation point and After Effects' anchor point do, so a symbol
that grows later moves nothing on screen. The drop preview marks the pivot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:32:52 -04:00
Olive Vaughn
41b4bdf110 Make a symbol from a dropped video
Dropping footage on the stage or the timeline asks which frames and what name:
a dialog plays the video with start and end handles that seek it. Detection
runs on that range only (decoding stops at the end, frames before the start
are skipped) and the range is part of the analysis address, so a partial
analysis is never served as a whole one. The frozen take comes in as one
named symbol, carrying its sound as an audio node, placed where it was
dropped; tracking and tuning come with it when the document has no analysis
of its own.

clip/adopt copies symbols between documents, renaming ids that collide, and
clip/audio-tracks carries sounds out of nested instances so a placed symbol
is heard where it is placed.

Fixes the stage going blank after a conversion: ::store recomputed only when
the clip id changed, so blocks merged into the loaded entry were invisible to
the resolver. And the paint loop now schedules its next frame before
painting and reports a frame it cannot draw instead of stopping.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:21:08 -04:00
Olive Vaughn
270c5a4369 Version static URLs by mtime; flatter tabs
The stylesheet and bundle were served with Last-Modified only, so a browser
could pair new JavaScript with a stale app.css and render the tab strip and
thumbnails unstyled. Their URLs now carry the file's mtime. Tabs are flat on
the chrome with the open one underlined; the close button shows on hover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:08:38 -04:00
Olive Vaughn
f590ff19cf Drag symbols onto the stage or the timeline, with previews
A symbol dragged out of the pool shows where it would land: on the stage, a
dashed outline of its first frame centred on the pointer, and on the timeline
a preview row of its own length. A stage drop places it at the playhead under
the pointer; a timeline drop places it at the frame under the pointer, in its
own coordinates. A drop that would make a cycle is refused while hovering.
Pool thumbnails are capped at 40x30.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:06:38 -04:00
Olive Vaughn
d5f044c6da Media pool: this project and all assets
This project lists every symbol in the document and the video it uses or was
given this session, each with a frame from its proxy as a thumbnail. All
assets lists every upload on the server and every other saved project's
symbols, from a new /api/symbols that reads symbol leaf paths. Every row is a
drag source (symbol:, import:, footage:). Uploading no longer runs straight
into detection; it lands in the project's media.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 12:59:17 -04:00
Olive Vaughn
c81f91c442 Tabs: open any symbol, and the clock follows it
Double-clicking a symbol in the pool, or an instance's row in the timeline,
opens it as a tab above the stage; the stage, the rows, the transport and new
shapes follow the open tab. Each tab gets a clock as long as it is: its own
mixed tracks, the document's audio for the symbol it opens on, or silence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 12:54:58 -04:00
Olive Vaughn
6b41c6db94 An instance's span is in its own frames
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>
2026-09-29 12:53:21 -04:00
Olive Vaughn
2d2eb0fc9f New empty symbol, nested in the selected instance or the open symbol
'+ symbol' in the timeline makes an empty symbol and places it at the
playhead: inside the selected instance, beside any other selected node, or in
the open symbol. Timeline selections carry their row path so a symbol placed
twice nests into the row that was clicked. Symbols gain a :name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 12:49:46 -04:00
Olive Vaughn
5dff490162 Symbols, not timelines; no symbol is special
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>
2026-09-29 12:46:42 -04:00
Olive Vaughn
179770d7d4 Split the shell into topbar, pool, stage, timeline and params panes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 12:25:46 -04:00
Olive Vaughn
ddabfbeaa8 multi fce stuff 2026-09-29 02:34:53 -04:00
Olive Vaughn
49ece8dee6 Model head anchors and independent pose timing 2026-09-29 00:46:08 -04:00
Olive Vaughn
a45e89f4e4 Add polygon painting with per-gap drawing keys 2026-09-28 22:37:23 -04:00
Olive Vaughn
73ab153b02 Add project schema version 2026-09-28 21:03:18 -04:00
Olive Vaughn
e22ee600b9 Add PNG sequence export and uuid-keyed stage placements
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016JBYKfeMPQTK1WNcgcw41o
2026-09-28 20:47:10 -04:00
Olive Vaughn
3058b9a5f2 Add scoped feature regeneration and retained pixel measurements 2026-09-28 16:31:27 -04:00
Olive Vaughn
39ee37db02 Animate seven stage symbols and bound playback memory 2026-09-28 15:29:31 -04:00
Olive Vaughn
ffb95543a3 Add instanced 8625 stage with independent audio controls 2026-09-28 15:05:04 -04:00
Olive Vaughn
a611b86c0d Stream block uploads and compress cached mouth crops 2026-09-28 14:37:09 -04:00
Olive Vaughn
65ad67c129 Decode uploaded footage in order with WebCodecs 2026-09-28 14:18:09 -04:00
Olive Vaughn
131b39bff0 Calibrate the video's frame origin instead of assuming it is zero
"the video never presented frame 1; it offered 2" was this function waiting
for a frame it had not asked for a second time. The walk discarded a wrong
frame and re-armed the callback WITHOUT seeking again, and nothing further is
ever presented to a paused element that has not been asked to move — so one
wrong answer starved until the timeout and reported it as the browser refusing.

Underneath that, the wrong answer was not wrong. A container can carry an edit
list, and `currentTime` then counts from the start of the edited presentation
while a frame's `mediaTime` counts from the start of the media. The two differ
by a constant, so the frame at currentTime 0 can honestly report a mediaTime
two frames in. Seeking cannot correct for it in the positive direction: source
frame 0 would have to be found before the start of the video, every attempt
clamps at zero, and the walk offers frame 2 forever.

So the constant is measured once and subtracted. `calibrate!` takes whatever
the browser calls the first frame it shows and makes that the origin, which is
the honest definition anyway — it is what a viewer sees at time zero, and the
audio clock this take plays against starts in the same place.

Calibration also leaves the element on frame 0, so the walk starts holding it.
`frame!` returns immediately for a frame already on screen rather than seeking
to where it already is, which presents nothing and would hang.

Residual error still re-seeks, corrected by exactly the measured miss, and
still fails loudly with every frame that was offered and where it was asked
from, so a next failure is diagnosable in one shot rather than four.

The proxy also drops B-frames now. That removes the edit list at the source
rather than only coping with it, and makes decode order presentation order
should this ever be fed to WebCodecs. 14% larger, and extraction refuses a
proxy whose timeline is shifted so it cannot come back silently. Honest note:
this was my first diagnosis and it did NOT reproduce the failure — both
proxies walk correctly here on hardware decode — so it is hardening, not the
fix.

Verified by forcing the fault: a harness that offsets the reported timeline by
-2, -1, 0, +1, +2 and +5 frames failed on every positive offset before and
recovers on all six now, first attempt. Real app in headed Chrome with Metal
hardware decode: 280/280. 41 backend and 234 frontend tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 12:50:11 -04:00
Olive Vaughn
44976cbb4b Stop refusing ordinary phone footage as variable-frame-rate
Uploading a clip shot straight from the iPhone camera app failed with
"variable-frame-rate video needs timestamp-aware playback". The file was not
variable: its container reports avg_frame_rate 8670/299 and nb_frames 289 over
a stream whose decoded timestamps are 280 frames exactly 1/30s apart. The
guard compared two pieces of container metadata and rejected CFR video on the
strength of a summary the container had got wrong about its own contents.

The guard was also obsolete. It dates from when the page measured the source's
own frames, where a wandering frame duration really does break
`frame = floor(t * fps)`. Nothing measures the source now — ffmpeg resamples it
onto a constant rate and the proxy is re-probed after it is written — so
variable input is a thing this converts rather than a thing it refuses.

So: probe picks a rate instead of validating one. It takes the nominal rate,
which is the rate every timestamp in the stream can be expressed at and so the
one that keeps every distinct source frame, and carries it as an exact fraction
because 30000/1001 is not a float and a rounded -r is how a long take drifts.
The disagreement is still recorded as `vfr`, just not fatal.

The frame-count cross-check went with it. It compared the proxy against the
source's nb_frames, which is the number this whole bug proves can lie, and a
resample to a constant rate legitimately changes the count. It now checks the
proxy's DURATION against the source's, because what must not drift is how long
the picture lasts against how long the audio lasts.

Verified on the reported file: 280 frames at 30fps, picture 9.3333s against
audio 9.3167s — half a frame — and 280/280 detected in the real app. 41 backend
tests green, including a genuinely variable fixture end to end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 12:37:21 -04:00
Olive Vaughn
83d106bbc5 Measure the video, not a PNG per frame
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>
2026-09-28 11:32:01 -04:00
Olive Vaughn
686f897401 Add video upload, extraction progress, and reusable analysis sources 2026-09-28 10:45:51 -04:00
Olive Vaughn
690de21fa4 Add Fly release image and local start command 2026-09-28 10:45:51 -04:00
Olive Vaughn
7249e73e7c Keep mouth crops independent of teeth settings 2026-09-28 09:06:30 -04:00
Olive Vaughn
7738c4e1c8 Align project paths with timeline model 2026-09-28 02:48:57 -04:00
Olive Vaughn
9778b9023b Split clips from timelines 2026-09-28 02:33:26 -04:00
Olive Vaughn
27bfe18bee Keep one invalidation table, and derive the inverse a UI wants
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>
2026-09-28 01:23:26 -04:00
Olive Vaughn
9cd5243983 Serve the document from a Django backend, split into three tiers
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>
2026-09-28 01:11:41 -04:00
Olive Vaughn
b6517f837a Pin every dense track to the feature whose presence it follows
The absence mapping was asserted for two of the ten dense tracks. Each block
hands `pack` an :absent predicate that derives a feature from a track INDEX, so
the predicate and the vector literal beside it agree only by hand, in four
places — and three of the four blocks had nothing checking them.

Give each feature a DIFFERENT gap window, so a track wired to the wrong feature
shows up as absence inside somebody else's window. A single shared window passes
under any permutation, which is the failure port-plan mechanical fact #2 warns
about: swap left for right and every part is still roughly where it belongs, so
it survives inspection.

Verified by mutation, since a test that cannot fail is worth nothing. Swapping
the brow block's two tracks, and swapping eye-block tracks 1 and 3 while leaving
0 and 2 correct, both now fail loudly; neither was caught before.

Also pin the teeth. They are their own feature so they can carry their own
:area :teeth parameters, which means an occluded mouth sets no bit on them; they
are dropped regardless because they stencil on :mouth-in and scene/finish drops a
node whose stencil drew nothing. Both frames come from an unannotated reference
clip, because the interior only draws on an open mouth and a frame the mouth was
shut on would have passed for the wrong reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B87NVmiU36qQmN9gmFYnJ9
2026-09-27 23:38:26 -04:00
Olive Vaughn
35ef150b48 Give features stable identity, eye pairs and per-feature presence
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
2026-09-27 22:44:36 -04:00
Olive Vaughn
ccca93e233 Check the mouth interior by its own tone, and stop teardown failing the run
Two bugs in the browser suite, both dating from step 7 adding eyes, brows and
teeth to the stage.

The shut-mouth check asked for exactly one tone on the whole canvas. That was
true when the stage held nothing but a mouth; since step 7 every frame carries
five or six tones whatever the mouth is doing, so the check failed on a correct
picture. Ask instead whether :mouth-dark is on the stage at all: nothing else
carries that tone, so it answers "is the interior drawn" without needing to know
where the mouth is. freeze_test already got this same rescoping; its browser twin
did not.

The failure went unnoticed because close() raced Chrome's own profile writes and
threw ENOTEMPTY out of main's `finally`, past the summary line and the
process.exit that reports the failure count. The suite therefore exited 2 and
printed no verdict whether it passed or failed. Teardown is now allowed to fail
out loud without taking the exit code with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B87NVmiU36qQmN9gmFYnJ9
2026-09-27 22:44:16 -04:00
Olive Vaughn
663b7c367a Add eyes brows and pixel-derived mouth interior to CLJS take 2026-09-27 19:48:42 -04:00
Olive Vaughn
06cf02db83 Preserve source cadence and sample picture fps after analysis 2026-09-27 19:31:39 -04:00
Olive Vaughn
32683efccf Port step 6: detect real footage with local MediaPipe 2026-09-27 19:13:20 -04:00
Olive Vaughn
8a06835895 Port step 5: freeze measured mouth into playable channels 2026-09-27 19:01:39 -04:00
Olive Vaughn
942e2f38ab Port step 4: measure the anchor and the mouth, condition on its own
`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>
2026-09-27 18:00:11 -04:00
Olive Vaughn
11192d61c6 Speed up the frame, and drop loop/recur from the domain
The frame went 5.86ms to 3.17ms -- a 170fps ceiling to 315 -- and `loop`/`recur`
is gone from src/ entirely.

Two real wins, both from measuring rather than guessing:

- `->rgba` was 3.16ms of that frame and was scene-independent: a `nth` into a
  vector of vectors is four protocol dispatches per pixel, 64,000 pixels a frame.
  The palette is now flattened once and cached by identity of the source vector
  -- palettes are values, so identity is exactly the right test and there is no
  invalidation to get wrong. At zoom 1 on a little-endian machine the inner loop
  is one 32-bit write per pixel through a Uint32Array view of the same buffer:
  0.11ms, 28x. Every other case walks bytes off the same flat palette.
  raster-test pins both against a naive per-pixel reference at three zooms,
  because a fast path that is subtly wrong about colour would look like a palette
  bug rather than like an optimisation.

- The per-frame z sort was re-deriving a constant. Draw order is a function of
  the z paths, which change when the scene changes and never because the playhead
  moved, so `draw-rank` computes it once and a frame sorts small integers. Every
  op drops its `:i` and `:z-path` fields as a result.

The loop pass, and an honest note on it: it came out NET POSITIVE on lines, which
is the wrong direction for a cleanup. geom is -3 (transduce for the accumulators,
`(-> (iterate refine ref) (nth iters))` for Procrustes, which is what the
algorithm says rather than a counter that happens to stop), channel -3,
fill-poly!'s copy loop 7 lines to 1. Against that, eval-into went from one
four-deep pyramid with seven positional parameters to `place` / `emit` / a fold
over a ctx map -- less nesting, more lines, and a different change from "fix the
loops" that should not have been bundled with it.

Two idioms were reverted for being worse here than what they replaced, both the
same mistake -- reaching for a form that allocates inside a hot loop:

- `partition 2` over an `array-seq` per scanline is some five thousand throwaway
  objects a frame and took draw from 0.88ms to 1.48ms. Now a pairwise `dotimes`
  over the array.
- `z-lex` via `(map compare a b)` allocated three lazy seqs per call, ~700 calls
  a frame. Made moot by `draw-rank`.

And one DRY move reverted for coupling things that only coincide: a `geom-path`
table had `node/valid-paths` and `scene/emit` deriving from one source, which
ties what a kind may CARRY to what the renderer READS off it. Those are the same
today and are not the same question, and the table put a spec change in charge of
what gets drawn, across a namespace boundary. `emit`'s three branches are three
different marks and stay three branches.

Kept, because it is one operation with two callers rather than two concerns that
rhyme: `lineage`, which `depth` and `z-path` were both walking separately. Its
cycle check is now a length bound -- a chain that does not repeat cannot be
longer than the node count -- instead of a `seen` set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDfHGdV39zu6rvgbBTfDaT
2026-09-27 17:44:07 -04:00
Olive Vaughn
18d6495592 Port steps 2-3: the data model and the player
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
2026-09-27 17:28:05 -04:00
Olive Vaughn
eb06be005c Port steps 0-1: scaffold, the oracle, and the pure bottom
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>
2026-09-27 14:43:34 -04:00
252 changed files with 70789 additions and 58 deletions

@ -0,0 +1 @@
Subproject commit f4dd04764204506fc275180364d0366d693036f4

18
.dockerignore Normal file
View file

@ -0,0 +1,18 @@
.git
.venv
**/node_modules
frontend/.shadow-cljs
frontend/out
frontend/.cpcache
frontend/test/browser/out
static/arthur/js
db.sqlite3
var
frames
scratch
audio.wav
manifest.json
*.take
*.tflite
.claude
.venv*

35
.gitignore vendored
View file

@ -1,5 +1,40 @@
# extract.sh's output. It is TIER 3 — immutable, large, and the backend's to serve
# once `manage.py ingest_bundle` has hashed it into var/blobs — so none of it
# belongs in the repo. `audio.wav` was tracked before step 9 because the synthetic
# take borrowed it for a clock; that copy now lives at static/arthur/audio.wav,
# which is an asset the project owns rather than an extraction that churns.
frames/ frames/
/audio.wav
/manifest.json
# local extracted takes for comparing source cadences
/scratch/
*.task *.task
*.take *.take
*.tflite *.tflite
# CLJS build
frontend/node_modules/
# screenshots from the browser suite; regenerated by `npm run browser`
frontend/test/browser/out/
frontend/.shadow-cljs/
frontend/out/
frontend/.cpcache/
static/arthur/js/
# mise-managed venv for the Django half
.venv/
# the Django half's own state: the document database, the content-addressed blob
# store (tiers 2 and 3), and collectstatic's output
db.sqlite3
db.sqlite3-shm
db.sqlite3-wal
/var/
# vim swap files
*.swp
# Python bytecode
__pycache__/
*.py[cod]

43
Dockerfile Normal file
View file

@ -0,0 +1,43 @@
FROM node:20-bookworm AS frontend
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl tar \
&& rm -rf /var/lib/apt/lists/* \
&& curl -fsSL 'https://api.adoptium.net/v3/binary/latest/21/ga/linux/x64/jdk/hotspot/normal/eclipse' -o /tmp/jdk.tar.gz \
&& mkdir -p /opt/java \
&& tar -xzf /tmp/jdk.tar.gz -C /opt/java --strip-components=1 \
&& rm /tmp/jdk.tar.gz
ENV JAVA_HOME=/opt/java
ENV PATH="/opt/java/bin:${PATH}"
WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npx shadow-cljs release app
FROM python:3.12-slim-bookworm
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
DJANGO_DEBUG=0 \
DJANGO_DB_PATH=/data/db.sqlite3 \
DJANGO_BLOB_ROOT=/data/blobs
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --create-home --shell /usr/sbin/nologin app
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . ./
COPY --from=frontend /app/static/arthur/js/ ./static/arthur/js/
RUN python manage.py collectstatic --noinput \
&& mkdir -p /data \
&& chown -R app:app /app /data
USER app
EXPOSE 8000
CMD ["sh", "-c", "python manage.py migrate --noinput && exec daphne --bind 0.0.0.0 --port 8000 server.asgi:application"]

123
README.md
View file

@ -14,6 +14,62 @@ Animator Pro, where this started, but they are why the output looks right —
modern conveniences belong in the workflow, not the output. See modern conveniences belong in the workflow, not the output. See
[docs/design.md](docs/design.md). [docs/design.md](docs/design.md).
## ClojureScript port
The active port plays the synthetic take, accepts video uploads, transcodes them
to an H.264 proxy and decodable stream plus audio and tracing stills, analyzes real footage
for mouth, eyes, brows and pixel-derived teeth, and saves the project with
reusable analysis data. The step 8 data model
represents persistent feature IDs, eye pairs and feature-level observation gaps;
its controls are still pending. See the [port plan](docs/port-plan.md).
```sh
mise install # both halves
pip install -r requirements.txt
mise exec -- python manage.py migrate
./do start # Django + frontend watcher
```
In the app, drop a video on the media pool — it uploads, extracts and runs
detection — then **save**. Opening that project on another client reuses its saved landmarks and
mouth crops without detecting source frames again. The upload path derives its
footage response from database records; it does not create or consume a
`manifest.json` file. See [frontend/README.md](frontend/README.md) for details.
Anything under "## Run" and below describes the older JS prototype, which still
runs separately on port 8777.
### Deploy to Fly.io
The app uses a persistent Fly volume for its SQLite document database and
content-addressed footage blobs. Create the app once, set its Django secret, and
deploy from the repository root:
```sh
fly apps create arthur --org personal
fly volumes create data --region iad --size 1
fly secrets set DJANGO_SECRET_KEY="$(openssl rand -hex 32)" --app arthur
fly deploy --app arthur
```
The deployed app is at <https://arthur.fly.dev>. The container builds the
ClojureScript frontend, collects static assets, and runs database migrations on
startup.
### How it is stored
Three tiers, cut by mutability and size — the full argument is in
[docs/architecture.md](docs/architecture.md):
| Tier | What | Where |
| --- | --- | --- |
| 1 **authored** | the scene: nodes, channels, features, time maps | the database, as independently addressed leaves. Kilobytes |
| 2 **derived** | detected landmarks, raw mouth crops, and dense channel blocks | `var/blobs`, addressed by analysis and block inputs, including the detector version |
| 3 **source** | the uploaded video, H.264 proxy and elementary stream, tracing stills, and audio | the same blob store, by the hash of their bytes |
Only tier 1 is the document. Tier 2 is a pure function of tiers 1 and 3, so a
saved project names its blocks rather than carrying them, and a knob change gives
a block a new name rather than overwriting an old one.
## Run ## Run
```sh ```sh
@ -33,27 +89,35 @@ wasm, which is fetched from a CDN on first use.
For real footage: For real footage:
```sh Upload it in the app. `./extract.sh` still writes the old PNG-sequence bundle and
./extract.sh /path/to/clip.mov 12 # -> frames/*.png, audio.wav, manifest.json `ingest_bundle` still registers it, but footage ingested that way has no decodable
``` stream and the loader will say so — the measured pixels come out of the video now.
then **Load frames**. MediaPipe's wasm is fetched from jsdelivr on first use; MediaPipe's wasm and `face_landmarker.task` are both local; nothing in detection
`face_landmarker.task` is local. touches the network.
Frames are pre-extracted rather than decoded in the page because browser video Detection reads the H.264 elementary stream with WebCodecs, one coded frame at a
seeking is approximate and `requestVideoFrameCallback` only delivers frames at time. The proxy has no B-frames, so decode order is frame order. Each decoded
playback speed — neither gives a deterministic per-frame pass. frame reaches MediaPipe in VIDEO running mode at its footage timestamp. The
decoder and detector advance together, with a pause between frames so progress
can paint. Saved analyses reuse their stored crop pixels and measure them with
the same pauses.
`manifest.json` records the true extraction rate. The page reads it rather than This is what replaced the PNG sequence, which was 112MB for 7.6 seconds and would
assuming, because a guessed fps desynchronises audio from picture — and sync is be 1.1GB at the 900-frame limit. The proxy is 6MB, and the landmarks barely
the one thing this view exists to show. notice: detected off decoded H.264 rather than off the PNGs, they moved at most
0.0033 of frame width.
**Exposure** decides how often the picture gets a new drawing: rip at 24 and The server's footage manifest records the proxy's frame rate and frame count;
render `on 2s` for 12, `on 3s` for 8. The dense track and the audio are the page reads that rate because a guessed fps desynchronises audio from picture.
untouched, so it is a dropdown rather than a re-rip, and the export emits keys Choosing a lower picture rate happens after analysis.
only on the grid instead of the same pose twice. Everything rides the same grid
— mouth, eyes, teeth, plate — because a head cutting on the odd frames while the **Picture fps** decides how often the finished roto gets a new pose. Analyze all
mouth cuts on the even ones reads as two performances laid over each other. source frames, then sample those frozen poses at 12, 24 or the source rate while
keeping the original duration and audio. **Exposure** can hold a drawing across
more than one picture slot. The tracing editor chooses source frames for cel
references separately. Shared timing is the useful default for mouth, eyes,
teeth and plate so their changes read as one performance.
**Audio is the playback clock**: `frame = floor(audio.currentTime * fps)`. A slow **Audio is the playback clock**: `frame = floor(audio.currentTime * fps)`. A slow
render loop therefore drops frames instead of drifting, and ½x / ¼x work by render loop therefore drops frames instead of drifting, and ½x / ¼x work by
@ -314,13 +378,13 @@ the tool a person made by hand; everything else regenerates. They are not in the
## Two kinds of sparseness ## Two kinds of sparseness
Sparseness has two unrelated causes, and conflating them was the original design Sparseness has two unrelated causes, and conflating them was the original design
error here. **Aesthetic** sparseness is set by the extraction rate — pick 12fps and error here. **Aesthetic** sparseness is chosen from the full analyzed source
you have already chosen your timing. **Labour** sparseness is a human drawing track at rendering time. **Labour** sparseness is a human drawing each cel and
each one, and it binds only on the plate. selecting which source frames to use as tracing references.
Aesthetic sparseness is the **exposure** control, not the extraction rate — Aesthetic sparseness is the **picture fps** control, with exposure available for
making it a render-time grid means auditioning 12 against 24 costs a dropdown longer holds. Both happen after analysis, so auditioning 12 against 24 needs no
instead of a re-rip and a full re-detection. re-extraction or re-detection.
So the mouth keeps **every** frame: it is traced, and therefore free. In limited So the mouth keeps **every** frame: it is traced, and therefore free. In limited
animation lip sync is routinely the densest element, on 1s, while heads hold on animation lip sync is routinely the densest element, on 1s, while heads hold on
@ -373,3 +437,16 @@ performer→character calibration (currently identity, fitting the face oval to
canvas); the override layer; anything on the Animator Pro side. The plate is a canvas); the override layer; anything on the Animator Pro side. The plate is a
face-oval polygon per kept frame — it exists so the mouth has a face to read face-oval polygon per kept frame — it exists so the mouth has a face to read
against, not to look good. against, not to look good.
In the port specifically: the parameter UI and scoped regeneration (the model is
built, the controls are not); automatic per-feature detection, so presence still
comes from the full-face mask plus a manifest annotation; multiplayer, for which
step 9 built the addressing and none of the socket; and in-browser extraction, so
`extract.sh` plus `manage.py ingest_bundle` is still how footage arrives.
Two smaller things that are known and undecided. `measure/brows` takes no
`presence` where `measure/eyes` does, so an occluded brow affects the freeze mask
but not brow measurement, and occluded landmarks still enter contour smoothing —
asymmetric with the eyes, and it is not settled which way is right. And `open`
takes the most recently updated project and shows its first clip: there is no
project browser, and the runtime store holds one clip at a time.

BIN
audio.wav

Binary file not shown.

0
clips/__init__.py Normal file
View file

63
clips/admin.py Normal file
View file

@ -0,0 +1,63 @@
"""The admin, which is here for one reason: tier 1 is readable.
docs/architecture.md's argument against a CRDT is partly this — "the canonical
document moves into an opaque blob, and every server-side thing that reads the
document needs it materialised back out". A leaf is transit-as-JSON in a
JSONField, so it is legible here, and that is a property worth being able to see.
"""
from django.contrib import admin
from .models import Analysis, Block, Blob, Clip, Footage, FootageFrame, Leaf, Project, Revision
@admin.register(Project)
class ProjectAdmin(admin.ModelAdmin):
list_display = ("name", "id", "seq", "updated")
search_fields = ("name", "id")
@admin.register(Clip)
class ClipAdmin(admin.ModelAdmin):
list_display = ("cid", "project", "name")
list_filter = ("project",)
@admin.register(Leaf)
class LeafAdmin(admin.ModelAdmin):
list_display = ("path", "project", "version", "updated")
list_filter = ("project",)
search_fields = ("path",)
@admin.register(Revision)
class RevisionAdmin(admin.ModelAdmin):
list_display = ("project", "seq", "summary", "author", "created")
@admin.register(Footage)
class FootageAdmin(admin.ModelAdmin):
list_display = ("label", "source", "fps", "frames", "width", "height", "created")
@admin.register(FootageFrame)
class FootageFrameAdmin(admin.ModelAdmin):
list_display = ("footage", "index", "blob")
list_filter = ("footage",)
@admin.register(Analysis)
class AnalysisAdmin(admin.ModelAdmin):
list_display = ("key", "detector", "version", "footage", "created")
search_fields = ("key", "detector", "version")
@admin.register(Block)
class BlockAdmin(admin.ModelAdmin):
list_display = ("key", "role", "analysis", "data", "state", "created")
list_filter = ("role",)
search_fields = ("key",)
@admin.register(Blob)
class BlobAdmin(admin.ModelAdmin):
list_display = ("digest", "media_type", "size", "created")

14
clips/apps.py Normal file
View file

@ -0,0 +1,14 @@
from django.apps import AppConfig
class ClipsConfig(AppConfig):
"""The one app.
`clips` because the CLIP is the entity the whole tool is about and the one the
prototype had exactly one of and never named — `state` in `js/app.js` is a clip
with its analysis inlined and its palette global. Project, Footage, Analysis,
Block, Leaf and Revision all hang off it.
"""
default_auto_field = "django.db.models.BigAutoField"
name = "clips"

146
clips/blobs.py Normal file
View file

@ -0,0 +1,146 @@
"""The content-addressed blob store: tiers 2 and 3 on disk.
One store for both, and docs/architecture.md says why in a sentence: once tier 3
is decoded by the app rather than by a shell script, frames and audio become "the
same kind of thing as tier 2 — a cache with a hash". So there is one place that
writes bytes, one that reads them, and one URL shape for both.
TWO KINDS OF HASH, AND THEY ARE NOT THE SAME HASH. A blob is named by the sha256
of its BYTES: that is what makes identical frames in two extractions one file. A
derived thing — an analysis artifact, a dense block — is named by a sha256 over
its INPUTS, which is what lets the client ask for the block the current settings
want before anything has computed it. So `Block.key` is an input hash and
`Block.data.digest` is a byte hash, and conflating them would break the half of
addressing that answers questions about work not yet done.
"""
import hashlib
import os
import tempfile
import zlib
from pathlib import Path
from django.conf import settings
CHUNK = 1 << 20
CROP_MEDIA_TYPE = "application/zlib"
def digest_bytes(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def digest_file(path: Path) -> str:
h = hashlib.sha256()
with open(path, "rb") as fh:
while chunk := fh.read(CHUNK):
h.update(chunk)
return h.hexdigest()
def path_for(digest: str) -> Path:
"""Where a blob lives.
Fanned out two levels, so that a take's worth of frames does not put a hundred
thousand entries in one directory — which is slow on every filesystem and
unusable on some.
"""
if len(digest) != 64 or any(c not in "0123456789abcdef" for c in digest):
raise ValueError(f"not a sha256: {digest!r}")
return Path(settings.BLOB_ROOT) / digest[:2] / digest[2:4] / digest
def write(data: bytes) -> tuple[str, int]:
"""Store bytes, return (digest, size). Writing the same bytes twice is a
no-op, which is what content addressing is for."""
digest = digest_bytes(data)
dest = path_for(digest)
if not dest.exists():
dest.parent.mkdir(parents=True, exist_ok=True)
tmp = dest.with_suffix(".part")
with open(tmp, "wb") as fh:
fh.write(data)
os.replace(tmp, dest)
return digest, len(data)
def write_stream(chunks) -> tuple[str, int]:
"""Store an uploaded file without reading the whole video into memory."""
root = Path(settings.BLOB_ROOT)
root.mkdir(parents=True, exist_ok=True)
digest = hashlib.sha256()
size = 0
with tempfile.NamedTemporaryFile(dir=root, prefix="upload-", delete=False) as out:
temporary = Path(out.name)
try:
for chunk in chunks:
digest.update(chunk)
size += len(chunk)
out.write(chunk)
except BaseException:
temporary.unlink(missing_ok=True)
raise
dest = path_for(digest.hexdigest())
dest.parent.mkdir(parents=True, exist_ok=True)
if dest.exists():
temporary.unlink()
else:
os.replace(temporary, dest)
return digest.hexdigest(), size
def write_compressed_stream(chunks) -> tuple[str, int]:
"""Store a losslessly compressed stream; the digest names stored bytes."""
compressor = zlib.compressobj()
def compressed():
for chunk in chunks:
if part := compressor.compress(chunk):
yield part
if part := compressor.flush():
yield part
return write_stream(compressed())
def adopt(source: Path) -> tuple[str, int]:
"""Store a file already on disk, by hard link where the filesystem allows it.
112MB of PNGs is a normal extraction and copying them into a second place in
the tree for no reason is not. A hard link is exact — the blob is immutable, so
two names for one inode is the whole of what is wanted — and a copy is the
fallback when `extract.sh` wrote to another volume.
"""
digest = digest_file(source)
dest = path_for(digest)
size = source.stat().st_size
if not dest.exists():
dest.parent.mkdir(parents=True, exist_ok=True)
try:
os.link(source, dest)
except OSError:
tmp = dest.with_suffix(".part")
with open(source, "rb") as src, open(tmp, "wb") as out:
while chunk := src.read(CHUNK):
out.write(chunk)
os.replace(tmp, dest)
return digest, size
def read(digest: str) -> bytes:
with open(path_for(digest), "rb") as fh:
return fh.read()
def png_size(path: Path) -> tuple[int, int]:
"""A PNG's dimensions, out of its IHDR.
Twenty-four bytes rather than a dependency. The footage's width and height are
manifest data — docs/architecture.md's entity model puts them there — and
Pillow to read two integers out of a header that has held them in the same
place since 1996 is not a trade worth making.
"""
with open(path, "rb") as fh:
head = fh.read(24)
if head[:8] != b"\x89PNG\r\n\x1a\n" or head[12:16] != b"IHDR":
raise ValueError(f"{path} is not a PNG")
return int.from_bytes(head[16:20], "big"), int.from_bytes(head[20:24], "big")

87
clips/consumers.py Normal file
View file

@ -0,0 +1,87 @@
"""One socket per open project, and it is tl's, nearly line for line.
Two things ride it. DELTAS, which the server sends after a write commits — the
socket is read-only for the document, and a dropped socket cannot lose a write.
PRESENCE, which peers gossip between themselves: all the server does is hand out
a connection id and stamp the sender's identity onto every message, so nobody can
post as somebody else.
"""
import json
import uuid
from asgiref.sync import async_to_sync
from channels.generic.websocket import AsyncWebsocketConsumer
from channels.layers import get_channel_layer
# Who is connected, per project: {group: {cid: presence}}. A cache of what has
# already been relayed, so a joiner gets the room in one message. Process-local,
# like the in-memory channel layer this runs on.
ROOMS = {}
def group(project_id):
return f"project_{project_id}"
def broadcast(project_id, delta, kind="delta"):
"""Send a committed write to everyone in the project's room. `access` says
only that who may write has changed, and each client asks for itself."""
async_to_sync(get_channel_layer().group_send)(
group(project_id), {"type": "project.delta", "delta": {"kind": kind, **delta}},
)
class ProjectConsumer(AsyncWebsocketConsumer):
RELAYED = ("state",)
@property
def room(self):
return ROOMS.setdefault(self.group, {})
async def connect(self):
self.group = group(self.scope["url_route"]["kwargs"]["project_id"])
self.cid = uuid.uuid4().hex[:12]
user = self.scope.get("user")
self.username = user.get_username() if user and user.is_authenticated else None
await self.channel_layer.group_add(self.group, self.channel_name)
await self.accept()
me = {"cid": self.cid, "user": self.username}
others = list(self.room.values())
self.room[self.cid] = me
await self.send(text_data=json.dumps({"kind": "welcome", **me}))
await self.send(text_data=json.dumps({"kind": "roster", "peers": others}))
await self._relay({"kind": "join"})
async def disconnect(self, code):
if hasattr(self, "cid"):
self.room.pop(self.cid, None)
if not self.room:
ROOMS.pop(self.group, None)
await self._relay({"kind": "leave"})
await self.channel_layer.group_discard(self.group, self.channel_name)
async def receive(self, text_data=None, bytes_data=None):
try:
msg = json.loads(text_data or "{}")
except ValueError:
return
if not isinstance(msg, dict) or msg.get("kind") not in self.RELAYED:
return
if self.cid in self.room:
self.room[self.cid].update(
{k: v for k, v in msg.items() if k not in ("kind", "cid", "user")}
)
await self._relay(msg)
async def _relay(self, msg):
await self.channel_layer.group_send(
self.group,
{"type": "peer.msg", "msg": {**msg, "cid": self.cid, "user": self.username}},
)
async def peer_msg(self, event):
await self.send(text_data=json.dumps(event["msg"]))
async def project_delta(self, event):
await self.send(text_data=json.dumps(event["delta"]))

501
clips/extraction.py Normal file
View file

@ -0,0 +1,501 @@
"""Upload a video once, then turn it into the two things the app actually reads.
WHAT CHANGED AND WHY. This used to decode one PNG per source frame and store every
one of them. A 7.6-second 1440x1920 take is 112MB that way, and the 900-frame limit
is 1.1GB — for pixels whose only consumer was a canvas that MediaPipe then read
once. The page now detects from the video itself (see `frontend/src/arthur/flow/
ingest.cljs`), so this produces:
THE PROXY. One browser-safe H.264/yuv420p MP4, CFR, `+faststart`. The same take
is 6MB. This is the analysis source, and it is re-encoded RATHER THAN KEPT AS
UPLOADED even when the upload is already H.264, for two reasons that are both
about not guessing: an iPhone's HEVC is not decodable in every browser, and the
footage's identity is the digest of this file — one produced by one ffmpeg
invocation, not one that depends on which branch the source happened to take.
THE TRACING STILLS. One JPEG per frame, long edge capped, for the tracing editor
to draw over. Reference images; nothing measures them. They are not in the
footage digest — see `models.Footage`.
The proxy is probed after it is written rather than before. `width`, `height` and
`frames` are properties of the file the browser will decode, and taking them from
the source instead is how a scaler or a dropped frame becomes a silent one-frame
offset between the landmarks and the audio.
"""
import hashlib
import json
import subprocess
import tempfile
import threading
import time
from fractions import Fraction
from pathlib import Path
from django.db import close_old_connections, transaction
from . import blobs
from .models import Blob, Extraction, Footage, FootageFrame
_active = set()
_lock = threading.Lock()
TIMEOUT = 3600
# Visually lossless enough that landmarks do not move: measured against the same
# frames as PNGs, IMAGE-mode landmarks shifted at most 0.0033 of frame width.
PROXY_CRF = "18"
# The long edge of a tracing still. The proxy keeps full resolution because the
# detector reads it; a still only has to be good enough to draw a cel over.
TRACING_EDGE = 1280
TRACING_QUALITY = "4"
def _command(args):
result = subprocess.run(args, capture_output=True, text=True, timeout=TIMEOUT)
if result.returncode:
raise ValueError((result.stderr or result.stdout or "media tool failed")[-1200:])
return result.stdout
def _run_with_progress(job, args, root, name, total, span):
"""Run one ffmpeg and publish its live frame count as `span` of the job.
ffmpeg's `-progress` file is the only honest source for this: parsing its
stderr means parsing a format that is explicitly not an interface, and a
spinner that is not attached to frames is a spinner that lies on a long take.
"""
progress_path = root / f"{name}.progress"
log_path = root / f"{name}.log"
first, last = span
args = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-stats_period", "0.25", "-progress", str(progress_path)] + args
with open(log_path, "wb") as log:
proc = subprocess.Popen(args, stdout=log, stderr=subprocess.STDOUT)
deadline = time.monotonic() + TIMEOUT
try:
while proc.poll() is None:
if time.monotonic() >= deadline:
raise TimeoutError(f"{name} timed out")
if progress_path.exists():
lines = progress_path.read_text(errors="replace").splitlines()
count = next((int(line[6:].strip()) for line in reversed(lines)
if line.startswith("frame=") and
line[6:].strip().isdigit()), 0)
if count and total:
reached = first + int((last - first) * min(1.0, count / total))
if reached > job.progress:
job.progress = reached
job.save(update_fields=["progress", "updated"])
time.sleep(0.2)
finally:
if proc.poll() is None:
proc.kill()
proc.wait()
if proc.returncode:
raise ValueError(log_path.read_text(errors="replace")[-1200:] or f"{name} failed")
def _encode_proxy(job, source_path, proxy_path, facts, root):
"""The uploaded video -> one H.264 file every browser can decode and seek."""
total = facts.get("reported_frames") or round(facts["duration"] * facts["fps"])
_run_with_progress(
job,
["-i", str(source_path), "-an",
# Constant frame rate at the rate `probe` chose. This RESAMPLES rather
# than asserts: the upload is allowed to be variable, and this is the
# step that makes the thing the page measures not be.
"-fps_mode", "cfr", "-r", facts.get("rate") or str(facts["fps"]),
"-c:v", "libx264", "-preset", "veryfast", "-crf", PROXY_CRF,
# NO B-FRAMES, AND THIS IS THE LOAD-BEARING FLAG. It is what makes
# decode order presentation order, so the page can treat access unit k
# of the elementary stream as frame k without demuxing a container or
# consulting a timestamp. With them x264 has a
# two-frame reordering delay, ffmpeg compensates by writing an edit list
# (`elst` media_time 1024 at timebase 1/15360 — exactly two frames), and
# the browser then lives on two timelines at once: `currentTime` obeys the
# edit list and the `mediaTime` reported by requestVideoFrameCallback does
# not. Seek to frame 0 and the browser correctly hands back a frame whose
# mediaTime says 2. Software decoding hides it; hardware decoding does
# not, which is the worst possible way for it to be wrong. Without
# B-frames DTS equals PTS, no edit list is written, and the two timelines
# are the same one. It also makes decode order presentation order, should
# this ever be fed to a WebCodecs VideoDecoder.
"-bf", "0",
# yuv420p and an even frame size are what makes this playable everywhere
# rather than only in the browser that happened to be tested.
"-pix_fmt", "yuv420p", "-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2",
"-movflags", "+faststart", str(proxy_path)],
root, "proxy", total, (0, 55))
def _elementary_stream(proxy_path, out_path):
"""The proxy's video, unwrapped into a raw Annex-B H.264 stream.
A STREAM COPY, not a second encode: the same coded frames as the MP4, with
the container's length-prefixed NAL units rewritten as start-code-delimited
ones. It costs a file read and nothing else.
This exists because the page decodes with WebCodecs, and `VideoDecoder` takes
demuxed chunks rather than a container. Handing it Annex-B means the client
needs no demuxer: NAL start codes are findable in a loop, and because the
proxy is encoded with no B-frames, decode order is presentation order — so
access unit k IS frame k, with no container timing to consult and no clock to
reconcile. That is the whole reason this file is worth the bytes it costs.
"""
_command(["ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-i", str(proxy_path), "-an", "-c:v", "copy",
"-bsf:v", "h264_mp4toannexb", "-f", "h264", str(out_path)])
def _extract_stills(job, proxy_path, frames_dir, frames, root):
"""The proxy -> one tracing JPEG per frame, long edge capped."""
_run_with_progress(
job,
["-i", str(proxy_path), "-fps_mode", "passthrough",
"-vf", f"scale='if(gt(iw,ih),min({TRACING_EDGE},iw),-2)':"
f"'if(gt(iw,ih),-2,min({TRACING_EDGE},ih))'",
"-q:v", TRACING_QUALITY, str(frames_dir / "%04d.jpg")],
root, "stills", frames, (55, 85))
MAX_RATE = 120 # a capture rate; past this the container is describing something else
# How many packet timestamps `_measured_rate` reads, and the fewest intervals it
# will draw a conclusion from. 300 is a flat cost on a long take and still a
# wide enough sample for a median; below 8 intervals there is not enough of a
# stream to outvote one odd timestamp, so the metadata is left to speak.
RATE_SAMPLE = 300
RATE_MINIMUM = 8
# How far a declared rate may sit from the measured one and still be taken as
# what the stream is: 2% covers 30 against 30000/1001 and nothing like 120
# against 30.
RATE_TOLERANCE = 0.02
def probe_image(path):
"""An uploaded still's pixel size as (width, height), refusing anything that
is not one picture."""
data = json.loads(_command(["ffprobe", "-v", "error", "-show_streams",
"-of", "json", str(path)]))
video = [s for s in data.get("streams", []) if s.get("codec_type") == "video"]
if len(video) != 1 or not (video[0].get("width") and video[0].get("height")):
raise ValueError("the uploaded file is not an image")
return int(video[0]["width"]), int(video[0]["height"])
def probe_audio(path):
"""The length of an uploaded sound in seconds, refusing a file with no audio."""
data = json.loads(_command(["ffprobe", "-v", "error", "-show_streams",
"-show_format", "-of", "json", str(path)]))
if not any(s.get("codec_type") == "audio" for s in data.get("streams", [])):
raise ValueError("the uploaded file has no audio stream")
duration = float(data.get("format", {}).get("duration") or 0)
if duration <= 0:
raise ValueError("the sound's length is unknown")
return duration
def _measured_rate(path):
"""The rate the stream's own packet timestamps imply, or None.
THE CONTAINER'S SUMMARY OF ITSELF IS NOT EVIDENCE, and this is the function
that goes and looks. An iPhone's `r_frame_rate` is 120 on footage whose
timestamps are 1/30s apart, which is the difference between 323 frames and
1293 — four times the encode, four times the tracing stills, four times the
blobs, for 970 frames that are copies of their neighbours.
It reads TIMESTAMPS, not frames: `-show_entries packet=pts_time` demuxes
without decoding, so this costs a file read and no pixels. The times are
SORTED before differencing because a stream with B-frames arrives in decode
order — an HEVC clip's first packets come out 0, 0.133, 0.067, 0.033 — and
differencing that order measures the reordering rather than the rate.
THE MEDIAN INTERVAL, which is what makes this safe on genuinely variable
input. It answers "how far apart are two frames normally", so a take held on
one frame for a second still reports the rate of the parts that move, and
choosing it keeps every distinct frame — the property `probe` used to reach
for by taking the nominal rate. Only the last few intervals of the sample are
unreliable (a frame whose turn comes after the window is missing from it), and
a median does not care.
Returning None is the honest answer for a clip too short to sample, and this
also swallows a probe that fails outright: the rate the metadata declares is
the documented fallback, so an optimisation must not be able to refuse an
upload that would otherwise have been accepted.
"""
try:
text = _command(["ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries", "packet=pts_time", "-of", "json",
"-read_intervals", f"%+#{RATE_SAMPLE}", str(path)])
packets = json.loads(text).get("packets") or []
times = sorted(float(packet["pts_time"]) for packet in packets
if (packet.get("pts_time") or "N/A") != "N/A")
except (ValueError, OSError):
return None
intervals = sorted(b - a for a, b in zip(times, times[1:]) if b > a)
if len(intervals) < RATE_MINIMUM:
return None
median = intervals[len(intervals) // 2]
return 1.0 / median if median > 0 else None
def _choose_rate(nominal, average, measured):
"""The rate to resample onto, as an exact Fraction.
A DECLARED RATE IS PREFERRED WHEN IT AGREES WITH THE TIMESTAMPS, because it is
the exact rational the stream was authored at — 30000/1001 is not a float, and
`limit_denominator` on a measured 29.97 is a guess at a number the container
already states. So the measured rate is used to CHOOSE between what the
container declares, and only stands in itself when neither declaration
describes the stream.
"""
candidates = [rate for rate in (nominal, average) if 0 < rate <= MAX_RATE]
if measured:
agreeing = [rate for rate in candidates
if abs(float(rate) - measured) <= RATE_TOLERANCE * measured]
if agreeing:
return min(agreeing, key=lambda rate: abs(float(rate) - measured))
from_timestamps = Fraction(measured).limit_denominator(1001)
if 0 < from_timestamps <= MAX_RATE:
return from_timestamps
# Nothing to go on but the metadata, and nominal first keeps the rate that
# drops no distinct frame. An unusable pair falls through to the refusal
# below, which names the rate the file claimed rather than one of these.
return nominal if 0 < nominal <= MAX_RATE else average
def probe(path):
"""What the upload is, as far as choosing a proxy rate goes.
IT NO LONGER REFUSES VARIABLE-FRAME-RATE INPUT, and the reason is the proxy.
That refusal was written when the page measured the source's own frames, where
a wandering frame duration really does break `frame = floor(t * fps)`. Nothing
measures the source now: ffmpeg resamples it onto a constant rate, and the
proxy — constant by construction, and re-probed after it is written — is the
only timeline anything downstream sees.
Keeping the check would have been worse than useless, because the thing it
tested is not reliable. Ordinary iPhone footage, shot straight from the camera
app, reports `avg_frame_rate` 8670/299 and `nb_frames` 289 on a stream whose
decoded timestamps are 280 frames exactly 1/30s apart. The container's summary
of itself disagreed with the container's own contents, so the guard rejected
CFR video for being variable.
THE RATE IS MEASURED AND THE DECLARATIONS ARE VOTED ON, which is the same
distrust applied to the one number that still comes from here. This used to
take `r_frame_rate` outright — the rate every timestamp in the stream can be
expressed at, and so the rate that keeps every distinct source frame. The
trouble is that it is not a claim about frames at all: the file above declares
120 and holds 30, and resampling it up cost four times the encode, four times
the tracing stills and four times the blobs for 970 duplicated frames. So
`_measured_rate` reads the timestamps, `_choose_rate` keeps whichever declared
rate they bear out, and the nominal rate is believed when it is true rather
than because it is nominal.
Duration is preserved either way — ffmpeg's CFR conversion is driven by
timestamps, so the audio stays in sync at any rate — and the median interval
keeps the no-distinct-frame-dropped property that taking the nominal rate was
reaching for. See `_measured_rate`.
"""
data = json.loads(_command(["ffprobe", "-v", "error", "-show_streams",
"-show_format", "-of", "json", str(path)]))
video = next((s for s in data.get("streams", []) if s.get("codec_type") == "video"), None)
if not video:
raise ValueError("the uploaded file has no video stream")
nominal = Fraction(video.get("r_frame_rate") or "0")
average = Fraction(video.get("avg_frame_rate") or "0")
if nominal <= 0 and average <= 0:
raise ValueError("the video's frame rate is unknown")
measured = _measured_rate(path)
rate = _choose_rate(nominal, average, measured)
if not 0 < rate <= MAX_RATE:
raise ValueError(f"the video reports a frame rate of {float(rate):g}, which is "
"not a rate footage can be measured at")
duration = float(data.get("format", {}).get("duration") or 0)
# if duration > 0 and duration * float(rate) > 901:
# raise ValueError("video is longer than the 900-frame footage limit")
frames = video.get("nb_frames")
return {"fps": float(rate),
# The exact rate, for ffmpeg. 30000/1001 is not a float, and handing
# `-r` a rounded one is how a long take drifts out of sync.
"rate": f"{rate.numerator}/{rate.denominator}",
"nominal_fps": float(nominal), "average_fps": float(average),
# What the timestamps said, and null when there were too few to ask.
# Recorded because it is the input to a decision this file used not to
# make, and the one number that explains a chosen rate matching
# neither declaration.
"measured_fps": measured,
"width": int(video["width"]), "height": int(video["height"]),
"duration": duration,
# KEPT, AND NO LONGER TRUSTED AS A COUNT. See the docstring: this is
# the container's claim about itself, it is wrong on ordinary phone
# footage, and `run` checks the proxy's DURATION instead.
"reported_frames": int(frames) if frames and frames.isdigit() else None,
"has_audio": any(s.get("codec_type") == "audio" for s in data.get("streams", [])),
"vfr": nominal != average}
def _refuse_a_shifted_timeline(path):
"""The proxy must put frame `i` at `i / fps` on BOTH of the browser's clocks.
Asserted rather than assumed, because the failure is silent and the symptom is
unrecognisable. An encoder delay makes ffmpeg write an edit list, `currentTime`
then obeys it while `requestVideoFrameCallback`'s `mediaTime` does not, and the
page's frame walk is uniformly off by the delay — on hardware decoding only. It
cost two wrong diagnoses to find, so it does not get to come back silently if
somebody changes an encoder flag.
"""
data = json.loads(_command(["ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_streams", "-of", "json", str(path)]))
stream = data["streams"][0]
if int(stream.get("has_b_frames") or 0):
raise ValueError(
"the proxy was encoded with B-frames, whose reordering delay makes the "
"browser's seek clock and its frame-timestamp clock disagree")
if float(stream.get("start_time") or 0) != 0:
raise ValueError(f"the proxy starts at {stream['start_time']}s rather than 0")
def count_frames(path):
"""How many frames a file really holds, counted rather than reported.
`nb_frames` is a container's claim. This is the decoder's answer, and it is
what the page will get when it walks the proxy — so a disagreement between the
two has to be settled before the count reaches a manifest, not after it has
become a one-frame audio offset nobody can find.
"""
text = _command(["ffprobe", "-v", "error", "-select_streams", "v:0",
"-count_frames", "-show_entries", "stream=nb_read_frames",
"-of", "default=nokey=1:noprint_wrappers=1", str(path)])
counted = text.strip()
if not counted.isdigit():
raise ValueError("could not count the proxy's frames")
return int(counted)
def extraction_key(source, settings):
# Scheme 3: the extraction now also produces the elementary stream the page
# decodes, so a job run under scheme 2 did not make everything this one does.
text = json.dumps({"scheme": 3, "source": source.blob_id, "settings": settings},
sort_keys=True, separators=(",", ":"))
return "sha256:" + hashlib.sha256(text.encode()).hexdigest()
def _register(job, proxy_path, stream_path, stills, audio_path, facts):
proxy_digest, proxy_size = blobs.adopt(proxy_path)
stream_digest, stream_size = blobs.adopt(stream_path)
audio_digest, audio_size = blobs.adopt(audio_path)
still_blobs = [(index, *blobs.adopt(path)) for index, path in enumerate(stills)]
width, height, fps, frames = facts["width"], facts["height"], facts["fps"], facts["frames"]
# The footage's own identity: the bytes the page will measure, the audio it
# will clock against, and the rate that ties them together. Scheme 2 — scheme
# 1 hashed a PNG per frame, and those footages name pixels this no longer has.
h = hashlib.sha256()
h.update(f"arthur-footage-2/{fps}/{frames}/{width}x{height}\n".encode())
h.update(proxy_digest.encode())
h.update(audio_digest.encode())
with transaction.atomic():
proxy_blob, _ = Blob.objects.get_or_create(
digest=proxy_digest, defaults={"size": proxy_size, "media_type": "video/mp4"})
stream_blob, _ = Blob.objects.get_or_create(
digest=stream_digest, defaults={"size": stream_size, "media_type": "video/h264"})
audio_blob, _ = Blob.objects.get_or_create(
digest=audio_digest, defaults={"size": audio_size, "media_type": "audio/wav"})
footage, created = Footage.objects.get_or_create(
digest=h.hexdigest(),
defaults={"label": job.source.filename[:200], "source": job.source.filename[:200],
"fps": fps, "frames": frames, "width": width, "height": height,
"audio": audio_blob, "video": proxy_blob, "stream": stream_blob})
if not created and not footage.stream_id:
# The same footage by identity, extracted before the elementary
# stream existed. Its digest is over the proxy and the audio, which
# have not changed — so this is the same footage gaining a file it
# was always entitled to, not a different one.
footage.stream = stream_blob
if not footage.video_id:
footage.video = proxy_blob
footage.save(update_fields=["stream", "video"])
if created:
rows = []
for index, digest, size in still_blobs:
blob, _ = Blob.objects.get_or_create(
digest=digest, defaults={"size": size, "media_type": "image/jpeg"})
rows.append(FootageFrame(footage=footage, index=index, blob=blob))
FootageFrame.objects.bulk_create(rows)
return footage
def run(key):
close_old_connections()
try:
job = Extraction.objects.select_related("source", "source__blob").get(key=key)
job.state, job.progress, job.error = "running", 0, ""
job.save(update_fields=["state", "progress", "error", "updated"])
facts = job.source.probe
source_path = blobs.path_for(job.source.blob_id)
with tempfile.TemporaryDirectory(prefix="arthur-extract-") as directory:
root = Path(directory)
proxy_path = root / "proxy.mp4"
_encode_proxy(job, source_path, proxy_path, facts, root)
# Everything downstream describes the PROXY, not the upload.
proxy_facts = probe(proxy_path)
_refuse_a_shifted_timeline(proxy_path)
frames = count_frames(proxy_path)
#if not 1 <= frames <= 900:
# raise ValueError(f"the proxy holds {frames} frames; the limit is 1–900")
# CHECKED AS A DURATION, not as a frame count. The page's clock is
# `frame = floor(audio.currentTime * fps)`, so what must not drift is
# how long the picture lasts against how long the audio lasts — and
# the source's own frame count is a number this has already caught
# lying. A resample to a constant rate legitimately changes the count
# and must not change the duration.
drift = abs(frames / proxy_facts["fps"] - facts["duration"])
if facts["duration"] > 0 and drift > 0.5:
raise ValueError(
f"the proxy runs {frames / proxy_facts['fps']:.2f}s and the upload "
f"runs {facts['duration']:.2f}s; refusing footage whose picture and "
"audio would drift")
proxy_facts["frames"] = frames
stream_path = root / "proxy.h264"
_elementary_stream(proxy_path, stream_path)
frames_dir = root / "stills"
frames_dir.mkdir()
_extract_stills(job, proxy_path, frames_dir, frames, root)
stills = sorted(frames_dir.glob("*.jpg"))
if len(stills) != frames:
raise ValueError(f"wrote {len(stills)} tracing stills for {frames} frames")
job.progress = 85
job.save(update_fields=["progress", "updated"])
audio_path = root / "audio.wav"
if facts["has_audio"]:
_command(["ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-i", str(source_path), "-vn", "-ac", "1", "-ar", "44100",
str(audio_path)])
else:
_command(["ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-f", "lavfi", "-i", "anullsrc=r=44100:cl=mono",
"-t", str(frames / proxy_facts["fps"]), "-c:a", "pcm_s16le",
str(audio_path)])
footage = _register(job, proxy_path, stream_path, stills, audio_path, proxy_facts)
job.footage, job.state, job.progress = footage, "done", 100
job.save(update_fields=["footage", "state", "progress", "updated"])
except Exception as exc:
Extraction.objects.filter(key=key).update(state="failed", error=str(exc)[:2000])
finally:
with _lock:
_active.discard(key)
close_old_connections()
def enqueue(key):
with _lock:
if key in _active:
return
_active.add(key)
threading.Thread(target=run, args=(key,), daemon=True,
name=f"arthur-extract-{key[7:15]}").start()

View file

View file

View file

@ -0,0 +1,57 @@
"""Compress existing raw mouth crop blocks without changing their public bytes."""
import hashlib
import zlib
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from django.db.models.deletion import ProtectedError
from clips import blobs
from clips.models import Blob, Block
class Command(BaseCommand):
help = "Compress existing source/crops blobs and remove unreferenced raw copies"
def handle(self, *args, **options):
converted = 0
before = after = 0
for block in Block.objects.filter(role="source/crops").select_related("data"):
old = block.data
if old.media_type == blobs.CROP_MEDIA_TYPE:
continue
old_digest = old.digest
with open(blobs.path_for(old_digest), "rb") as source:
digest, size = blobs.write_compressed_stream(
iter(lambda: source.read(blobs.CHUNK), b"")
)
check = hashlib.sha256()
decompressor = zlib.decompressobj()
with open(blobs.path_for(digest), "rb") as compressed:
while chunk := compressed.read(blobs.CHUNK):
check.update(decompressor.decompress(chunk))
check.update(decompressor.flush())
if not decompressor.eof or check.hexdigest() != old_digest:
raise CommandError(f"crop compression failed verification: {block.key}")
with transaction.atomic():
new, _ = Blob.objects.get_or_create(
digest=digest,
defaults={"size": size, "media_type": blobs.CROP_MEDIA_TYPE},
)
changed = Block.objects.filter(key=block.key, data=old).update(data=new)
if not changed:
continue
converted += 1
before += old.size
after += size
if old_digest != new.digest:
try:
old.delete()
except ProtectedError:
pass
else:
blobs.path_for(old_digest).unlink(missing_ok=True)
self.stdout.write(
f"Compressed {converted} crop blocks: {before:,} -> {after:,} bytes"
)

View file

@ -0,0 +1,120 @@
"""Register an extracted bundle as tier 3.
python manage.py ingest_bundle # ./manifest.json
python manage.py ingest_bundle scratch/my-take # that bundle
WHAT THIS REPLACES. Until step 9 the page fetched `/manifest.json` and then built
`frames/0001.png` itself, with shadow-cljs's `:dev-http` serving the repo root. So
the frame layout was a shared secret between a shell script and a ClojureScript
namespace, and "where the frames are" was answered by a directory listing.
Now the server names every frame, and the client asks it. The frames go into the
content-addressed blob store — by hard link, so 112MB of PNGs is not copied — and
the manifest the client receives carries a URL per frame. That is the whole of what
makes the frames the backend's to serve, and it is what the in-browser wasm-ffmpeg
extraction docs/architecture.md describes will upload INTO, without the client
learning anything new when it arrives: the same blobs, the same manifest, a
different producer.
`extract.sh` still does the decoding. It is out of step 9's scope, it works, and it
is the only part of this that needs a terminal.
"""
import json
from pathlib import Path
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from clips import blobs
from clips.models import Blob, Footage, FootageFrame
class Command(BaseCommand):
help = "Register an extracted frames+audio+manifest bundle as footage."
def add_arguments(self, parser):
parser.add_argument(
"bundle", nargs="?", default=".",
help="a directory holding manifest.json, or the manifest itself",
)
parser.add_argument("--label", default="", help="what to call it in the UI")
def handle(self, *args, **options):
manifest_path = Path(options["bundle"])
if manifest_path.is_dir():
manifest_path = manifest_path / "manifest.json"
if not manifest_path.exists():
raise CommandError(f"{manifest_path} does not exist — run ./extract.sh first")
manifest = json.loads(manifest_path.read_text())
root = manifest_path.parent
frames_dir = root / manifest["dir"]
audio_path = root / manifest["audio"]
count = int(manifest["frames"])
pngs = sorted(frames_dir.glob("*.png"))
if len(pngs) != count:
raise CommandError(
f"the manifest says {count} frames and {frames_dir} holds {len(pngs)}; "
"refusing an inaccurate footage"
)
if not audio_path.exists():
raise CommandError(f"{audio_path} does not exist")
width, height = blobs.png_size(pngs[0])
self.stdout.write(f"hashing {len(pngs)} frames…")
frame_blobs = []
for i, png in enumerate(pngs):
digest, size = blobs.adopt(png)
frame_blobs.append((i, digest, size))
if (i + 1) % 25 == 0 or i + 1 == len(pngs):
self.stdout.write(f" {i + 1}/{len(pngs)}")
audio_digest, audio_size = blobs.adopt(audio_path)
# The footage's own identity: every frame in order, plus the audio and the
# rate. Two extractions of one clip at one rate are one footage, so an
# analysis over it is reusable across both.
import hashlib
h = hashlib.sha256()
h.update(f"arthur-footage-1/{manifest['fps']}/{count}/{width}x{height}\n".encode())
for _, digest, _ in frame_blobs:
h.update(digest.encode())
h.update(audio_digest.encode())
footage_digest = h.hexdigest()
if existing := Footage.objects.filter(digest=footage_digest).first():
self.stdout.write(self.style.SUCCESS(f"already ingested: {existing.id}"))
return
with transaction.atomic():
audio_blob, _ = Blob.objects.get_or_create(
digest=audio_digest,
defaults={"size": audio_size, "media_type": "audio/wav"},
)
footage = Footage.objects.create(
digest=footage_digest,
label=options["label"] or manifest.get("source") or frames_dir.name,
source=manifest.get("source") or "",
fps=float(manifest["fps"]),
frames=count,
width=width,
height=height,
audio=audio_blob,
feature_absence=manifest.get("feature-absence") or {},
)
rows = []
for index, digest, size in frame_blobs:
blob, _ = Blob.objects.get_or_create(
digest=digest, defaults={"size": size, "media_type": "image/png"}
)
rows.append(FootageFrame(footage=footage, index=index, blob=blob))
FootageFrame.objects.bulk_create(rows)
self.stdout.write(
self.style.SUCCESS(
f"{count} frames at {manifest['fps']}fps, {width}x{height} -> footage {footage.id}"
)
)

View file

@ -0,0 +1,149 @@
# Generated by Django 5.2.17 on 2026-09-28 04:44
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Blob',
fields=[
('digest', models.CharField(max_length=64, primary_key=True, serialize=False)),
('media_type', models.CharField(default='application/octet-stream', max_length=100)),
('size', models.BigIntegerField()),
('created', models.DateTimeField(auto_now_add=True)),
],
),
migrations.CreateModel(
name='Project',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(default='untitled', max_length=200)),
('seq', models.PositiveBigIntegerField(default=0)),
('palette', models.CharField(default='arthur/default', max_length=64)),
('created', models.DateTimeField(auto_now_add=True)),
('updated', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['-updated'],
},
),
migrations.CreateModel(
name='Analysis',
fields=[
('key', models.CharField(max_length=71, primary_key=True, serialize=False)),
('descriptor', models.TextField()),
('detector', models.CharField(max_length=64)),
('version', models.CharField(max_length=64)),
('created', models.DateTimeField(auto_now_add=True)),
('artifact', models.ForeignKey(blank=True, help_text='the dense landmark track, once bake A is uploaded', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='analysis_for', to='clips.blob')),
],
options={
'verbose_name_plural': 'analyses',
},
),
migrations.CreateModel(
name='Block',
fields=[
('key', models.CharField(max_length=71, primary_key=True, serialize=False)),
('descriptor', models.TextField()),
('role', models.CharField(max_length=32)),
('created', models.DateTimeField(auto_now_add=True)),
('analysis', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='blocks', to='clips.analysis')),
('data', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='block_data_for', to='clips.blob')),
('state', models.ForeignKey(blank=True, help_text='the per-track absence mask, when the take has one', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='block_state_for', to='clips.blob')),
],
),
migrations.CreateModel(
name='Footage',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('digest', models.CharField(max_length=64, unique=True)),
('label', models.CharField(blank=True, max_length=200)),
('source', models.CharField(blank=True, max_length=200)),
('fps', models.FloatField()),
('frames', models.PositiveIntegerField()),
('width', models.PositiveIntegerField()),
('height', models.PositiveIntegerField()),
('feature_absence', models.JSONField(blank=True, default=dict)),
('created', models.DateTimeField(auto_now_add=True)),
('audio', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='audio_for', to='clips.blob')),
],
options={
'ordering': ['-created'],
},
),
migrations.AddField(
model_name='analysis',
name='footage',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='analyses', to='clips.footage'),
),
migrations.CreateModel(
name='Revision',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('seq', models.PositiveBigIntegerField()),
('author', models.CharField(blank=True, max_length=200)),
('summary', models.CharField(blank=True, max_length=500)),
('document', models.JSONField(help_text='every leaf of the project, by path')),
('created', models.DateTimeField(auto_now_add=True)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='revisions', to='clips.project')),
],
options={
'ordering': ['-seq'],
},
),
migrations.CreateModel(
name='FootageFrame',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('index', models.PositiveIntegerField(help_text="0-based; source frame index + 1 is the PNG's name")),
('blob', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='frame_for', to='clips.blob')),
('footage', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='frame_set', to='clips.footage')),
],
options={
'ordering': ['index'],
'constraints': [models.UniqueConstraint(fields=('footage', 'index'), name='one_blob_per_frame')],
},
),
migrations.CreateModel(
name='Leaf',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('path', models.CharField(max_length=300)),
('value', models.JSONField()),
('version', models.PositiveBigIntegerField(default=1)),
('updated', models.DateTimeField(auto_now=True)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='leaves', to='clips.project')),
],
options={
'ordering': ['path'],
'constraints': [models.UniqueConstraint(fields=('project', 'path'), name='one_leaf_per_path')],
},
),
migrations.CreateModel(
name='Clip',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('cid', models.SlugField(max_length=64)),
('name', models.CharField(blank=True, max_length=200)),
('order', models.IntegerField(default=0)),
('analysis', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='clips', to='clips.analysis')),
('blocks', models.ManyToManyField(blank=True, help_text="the tier-2 blocks this clip's channels name", related_name='clips', to='clips.block')),
('footage', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='clips', to='clips.footage')),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='clips', to='clips.project')),
],
options={
'ordering': ['order', 'cid'],
'constraints': [models.UniqueConstraint(fields=('project', 'cid'), name='one_cid_per_project')],
},
),
]

View file

@ -0,0 +1,22 @@
# Generated by Django 5.2.17 on 2026-09-28 13:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('clips', '0001_initial'),
]
operations = [
migrations.RemoveField(
model_name='analysis',
name='artifact',
),
migrations.AddField(
model_name='analysis',
name='source_blocks',
field=models.ManyToManyField(blank=True, help_text='pixel-dependent landmarks, detection mask and mouth crops', related_name='source_for', to='clips.block'),
),
]

View file

@ -0,0 +1,39 @@
# Generated by Django 5.2.17 on 2026-09-28 13:23
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('clips', '0002_remove_analysis_artifact_analysis_source_blocks'),
]
operations = [
migrations.CreateModel(
name='Source',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('filename', models.CharField(max_length=255)),
('probe', models.JSONField(default=dict)),
('created', models.DateTimeField(auto_now_add=True)),
('blob', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='video_source', to='clips.blob')),
],
),
migrations.CreateModel(
name='Extraction',
fields=[
('key', models.CharField(max_length=71, primary_key=True, serialize=False)),
('settings', models.JSONField(default=dict)),
('state', models.CharField(default='queued', max_length=16)),
('progress', models.PositiveIntegerField(default=0)),
('error', models.TextField(blank=True)),
('created', models.DateTimeField(auto_now_add=True)),
('updated', models.DateTimeField(auto_now=True)),
('footage', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='extractions', to='clips.footage')),
('source', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='extractions', to='clips.source')),
],
),
]

View file

@ -0,0 +1,24 @@
# Generated by Django 5.2.17 on 2026-09-28 15:10
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('clips', '0003_source_extraction'),
]
operations = [
migrations.AddField(
model_name='footage',
name='video',
field=models.ForeignKey(blank=True, help_text='the browser-safe proxy the page detects from; null on pre-proxy footage', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='video_for', to='clips.blob'),
),
migrations.AlterField(
model_name='footageframe',
name='index',
field=models.PositiveIntegerField(help_text="0-based; source frame index + 1 is the JPEG's name"),
),
]

View file

@ -0,0 +1,24 @@
# Generated by Django 5.2.17 on 2026-09-28 17:11
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('clips', '0004_footage_video_alter_footageframe_index'),
]
operations = [
migrations.AddField(
model_name='footage',
name='stream',
field=models.ForeignKey(blank=True, help_text="the proxy's video as raw Annex-B H.264: what the page DECODES, one access unit per frame; null on footage extracted before it", null=True, on_delete=django.db.models.deletion.PROTECT, related_name='stream_for', to='clips.blob'),
),
migrations.AlterField(
model_name='footage',
name='video',
field=models.ForeignKey(blank=True, help_text='the browser-safe proxy, playable and seekable', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='video_for', to='clips.blob'),
),
]

View file

@ -0,0 +1,15 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("clips", "0005_footage_stream_alter_footage_video"),
]
operations = [
migrations.AddField(
model_name="project",
name="schema_version",
field=models.PositiveIntegerField(default=1),
),
]

View file

@ -0,0 +1,75 @@
"""Schema 2: a document holds symbols, not timelines, and no symbol is reserved.
Three renames, each in the stored transit and nowhere else:
clip/<cid>/timeline/... -> clip/<cid>/symbol/...
a node leaf's :kind :symbol -> :kind :instance
a feature leaf's :timeline key -> :symbol
A leaf value is transit's map form, ["^ ", k1, v1, k2, v2, ...]. Only TOP-LEVEL
pairs are rewritten, and only literal ones: transit caches a repeated keyword as
"^N", and a rename that met a cache reference where it expected the keyword would
be guessing. Every saved leaf at the time of writing had these as literals; if one
does not, the migration stops rather than writing a document that decodes to
something else.
Renaming a cached keyword in place is safe because the cache is positional: the
literal keeps its slot, so any later "^N" that referred to it now refers to the
new name, which is what it meant.
"""
import re
from django.db import migrations, models
PATH = re.compile(r"^(clip/[^/]+/)timeline(/|$)")
def _rename_pair(value, key, old, new, path):
if not (isinstance(value, list) and value[:1] == ["^ "]):
return value
out = list(value)
for i in range(1, len(out) - 1, 2):
if out[i] != key:
continue
if old is None:
out[i] = new
elif out[i + 1] == old:
out[i + 1] = new
elif isinstance(out[i + 1], str) and out[i + 1].startswith("^") and out[i + 1] != "^ ":
raise RuntimeError(f"leaf {path!r} has a cached {key} value; migrate it by hand")
return out
def forwards(apps, schema_editor):
Leaf = apps.get_model("clips", "Leaf")
Project = apps.get_model("clips", "Project")
for leaf in Leaf.objects.all():
path = PATH.sub(r"\1symbol\2", leaf.path)
value = leaf.value
parts = path.split("/")
if len(parts) == 6 and parts[2] == "symbol" and parts[4] == "node":
value = _rename_pair(value, "~:kind", "~:symbol", "~:instance", leaf.path)
if len(parts) == 4 and parts[2] == "feature":
value = _rename_pair(value, "~:timeline", None, "~:symbol", leaf.path)
if path != leaf.path or value != leaf.value:
leaf.path = path
leaf.value = value
leaf.version += 1
leaf.save(update_fields=["path", "value", "version"])
Project.objects.update(schema_version=2)
class Migration(migrations.Migration):
dependencies = [
("clips", "0006_project_schema_version"),
]
operations = [
migrations.AlterField(
model_name="project",
name="schema_version",
field=models.PositiveIntegerField(default=2),
),
migrations.RunPython(forwards, migrations.RunPython.noop),
]

View file

@ -0,0 +1,39 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
def orphans(apps, schema_editor):
# Every project has an owner, and none of the ones saved before owners did.
apps.get_model("clips", "Project").objects.all().delete()
class Migration(migrations.Migration):
dependencies = [
("clips", "0007_symbols_not_timelines"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.RunPython(orphans, migrations.RunPython.noop),
migrations.AddField(
model_name="leaf",
name="seq",
field=models.PositiveBigIntegerField(
default=0, help_text="the project seq of the write that last changed it"),
),
migrations.AddField(
model_name="project",
name="editors",
field=models.ManyToManyField(blank=True, related_name="shared_projects",
to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name="project",
name="owner",
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
related_name="projects", to=settings.AUTH_USER_MODEL),
preserve_default=False,
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 5.2.17 on 2026-09-30 01:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('clips', '0008_owners_editors_leaf_seq'),
]
operations = [
migrations.AddField(
model_name='revision',
name='blocks',
field=models.JSONField(default=dict, help_text="each clip's tier-2 block keys, by cid, so a restore can name them"),
),
]

View file

@ -0,0 +1,25 @@
# Generated by Django 5.2.17 on 2026-09-30 07:14
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('clips', '0009_revision_blocks'),
]
operations = [
migrations.CreateModel(
name='Sound',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('filename', models.CharField(max_length=255)),
('duration', models.FloatField(help_text='seconds, as ffprobe reports it')),
('created', models.DateTimeField(auto_now_add=True)),
('blob', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='sound_for', to='clips.blob')),
],
),
]

View file

@ -0,0 +1,13 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("clips", "0010_sounds")]
operations = [
migrations.AlterField(
model_name="project",
name="schema_version",
field=models.PositiveIntegerField(default=3),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 5.2.17 on 2026-10-01 04:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('clips', '0011_occurrence_schema'),
]
operations = [
migrations.AddField(
model_name='sound',
name='label',
field=models.CharField(blank=True, help_text='what a person called it; the filename when empty. Separate from `filename` because the name on disk is a fact about the upload and renaming must not rewrite it', max_length=200),
),
]

View file

@ -0,0 +1,45 @@
"""Schema 4: split animated symbol palettes from the authoring palette.
Before schema 4 a symbol's ``:palette`` leaf value was always a channel. It now
names the static palette used when that symbol is the viewed root, while the
old channel is retained as ``:palette-channel`` compatibility data. New edits
use a real lane symbol referenced by ``:palette-track``.
"""
from django.db import migrations, models
def forwards(apps, schema_editor):
Leaf = apps.get_model("clips", "Leaf")
Project = apps.get_model("clips", "Project")
for leaf in Leaf.objects.filter(path__contains="/symbol/"):
parts = leaf.path.split("/")
if len(parts) != 4 or parts[2] != "symbol":
continue
value = leaf.value
if not (isinstance(value, list) and value[:1] == ["^ "]):
continue
out = list(value)
changed = False
for i in range(1, len(out) - 1, 2):
if out[i] == "~:palette" and isinstance(out[i + 1], list):
out[i] = "~:palette-channel"
changed = True
if changed:
leaf.value = out
leaf.version += 1
leaf.save(update_fields=["value", "version"])
Project.objects.update(schema_version=4)
class Migration(migrations.Migration):
dependencies = [("clips", "0012_sound_label")]
operations = [
migrations.AlterField(
model_name="project",
name="schema_version",
field=models.PositiveIntegerField(default=4),
),
migrations.RunPython(forwards, migrations.RunPython.noop),
]

View file

@ -0,0 +1,15 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("clips", "0013_palette_track")]
operations = [
migrations.RemoveField(model_name="clip", name="analysis"),
migrations.RemoveField(model_name="clip", name="footage"),
migrations.AlterField(
model_name="project",
name="schema_version",
field=models.PositiveIntegerField(default=5),
),
]

View file

@ -0,0 +1,48 @@
"""Schema 6: tracing is a symbol.
A face's `:head :trace` is gone: its footage is a placement of a `:type :trace`
symbol under the head, the trace keys are that placement's `:time :holds`, and the
head follows them with `:reads`. Nothing is converted. Every project is marked 6,
and one that still carries a `:trace` is refused when it is opened, by name and
with what to do about it; the rest open as they did.
Images are stills to trace over, stored like sounds.
"""
import uuid
from django.db import migrations, models
import django.db.models.deletion
def forwards(apps, schema_editor):
apps.get_model("clips", "Project").objects.update(schema_version=6)
class Migration(migrations.Migration):
dependencies = [("clips", "0014_multiple_analyses")]
operations = [
migrations.CreateModel(
name="Image",
fields=[
("id", models.UUIDField(default=uuid.uuid4, editable=False,
primary_key=True, serialize=False)),
("filename", models.CharField(max_length=255)),
("label", models.CharField(
blank=True, max_length=200,
help_text="what a person called it; the filename when empty")),
("width", models.PositiveIntegerField(help_text="pixels, as ffprobe reports them")),
("height", models.PositiveIntegerField()),
("created", models.DateTimeField(auto_now_add=True)),
("blob", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT,
related_name="image_for", to="clips.blob")),
],
),
migrations.AlterField(
model_name="project",
name="schema_version",
field=models.PositiveIntegerField(default=6),
),
migrations.RunPython(forwards, migrations.RunPython.noop),
]

View file

@ -0,0 +1,42 @@
"""Schema 7: an anchor is a peg.
`[:xform :anchor]` is gone from the transform. `T(a)·M·T(-a)` is a transform
conjugated by a translation — "do M in a frame shifted by a" — and a parent
already is a shifted frame, so an anchor was a peg written inline: one that could
not be selected, keyed, shared between nodes, or placed above a measured channel.
A pivot nobody chose is now derived from what the node draws, per drag, and stored
nowhere; a pivot to keep is a peg, an ordinary `:group` parent.
Nothing is converted, as in schema 6. Every project is marked 7, and one that
still carries an anchor is refused when it is opened, by name and with what to do
about it — `node/problems` in the frontend.
Not converted rather than not worth converting. Dropping an anchor is in fact
pixel-exact wherever rotation and scale are the identity, since the anchor
cancels out of the composition there — and that is everywhere a freeze, a drop or
a new drawing wrote one. It is NOT exact on anything a hand has since turned or
scaled, where the composed translation is `a + p - M·a`, and it cannot be made
exact at all where `pos` is dense, because tier 2 is content-addressed and not
rewritable here. A conversion would therefore be silent and right for most nodes
and silent and wrong for exactly the ones somebody had hand-placed, which is the
worse failure: a refusal names the document and says what to do.
"""
from django.db import migrations, models
def forwards(apps, schema_editor):
apps.get_model("clips", "Project").objects.update(schema_version=7)
class Migration(migrations.Migration):
dependencies = [("clips", "0015_tracing_images")]
operations = [
migrations.AlterField(
model_name="project",
name="schema_version",
field=models.PositiveIntegerField(default=7),
),
migrations.RunPython(forwards, migrations.RunPython.noop),
]

View file

@ -0,0 +1,45 @@
"""Schema 8: a node has a pivot.
`[:xform :pivot]` is back in the transform, as the point rotation and scale are
composed about: `local = T(pos)·T(piv)·R·K·S·T(-piv)`. Schema 7 deleted it, on
the argument that an anchor is a peg — true as algebra, and not true as a feature.
A peg is a node, and a turn about a point that is not the turning node's own
origin still has to solve for a position to hold that point still; that solution
is an arc in the angle while a position channel tweens along the chord, so it is
right on the frame it is written and wrong on every frame between two keys. A
drawing escaped it, since its origin is the middle of what it draws. A symbol
instance could not: its origin is its symbol's, which is the top-left corner of
the stage, so one keyed turn of an instance swung its drawing round that corner
on an orbit the size of the stage.
CONVERTED, unlike 6 and 7, because adding this one is exact. A schema-7 node has
no pivot; an absent pivot reads as [0 0]; and T(pos)·T(0)·M·T(-0) is T(pos)·M to
the last bit of the mantissa. Every stored document therefore composes to exactly
the matrices it composed to before, dense tier-2 transforms included, so there is
nothing to guess at and no node a conversion could silently move. The version is
restamped and nothing else is touched.
What a converted document does NOT get is a pivot somebody chose: nodes placed
before this carry none, so they still turn about their own origin until the first
turn or scale writes one — `gesture/with-pivot`, from the middle of what the node
draws at that moment — or until the cross is dragged (ctrl/cmd-drag on the stage).
"""
from django.db import migrations, models
def forwards(apps, schema_editor):
apps.get_model("clips", "Project").objects.update(schema_version=8)
class Migration(migrations.Migration):
dependencies = [("clips", "0016_an_anchor_is_a_peg")]
operations = [
migrations.AlterField(
model_name="project",
name="schema_version",
field=models.PositiveIntegerField(default=8),
),
migrations.RunPython(forwards, migrations.RunPython.noop),
]

View file

366
clips/models.py Normal file
View file

@ -0,0 +1,366 @@
"""The entity model, as tables.
It follows docs/architecture.md's model exactly, and the one thing worth reading
it for is which tier each table is in, because that is what decides whether a row
is a document, a cache entry or a source.
TIER 1, the document. Project, Clip, Leaf, Revision. Kilobytes, authored,
versioned, and the only tier anything will ever sync.
TIER 2, derived. Analysis, Block. Content-addressed by a hash over every input
that produced them — including the detector version — so a stale bake is
unreachable rather than wrong, and a collaborator's bake is fetchable by the
same key.
TIER 3, source. Footage, FootageFrame. Immutable, by hash.
Blob is under all three of them: bytes, named by the sha256 of themselves.
WHAT IS DELIBERATELY NOT HERE. `Clip` does not store fps, frames, width or height.
They are in the document — the `timing` and `stage` leaves — and a copy of them in
a column is a copy that comes to disagree with the scene it describes. The columns
`Clip` does have are the ones the SERVER needs to answer a question about a clip
without parsing its leaves: which footage, which analysis, which blocks.
"""
import uuid
from django.conf import settings
from django.db import models
from django.utils import timezone
class Blob(models.Model):
"""Bytes, named by the sha256 of themselves. The file is on disk under
`BLOB_ROOT`; this row is the index and the size."""
digest = models.CharField(primary_key=True, max_length=64)
media_type = models.CharField(max_length=100, default="application/octet-stream")
size = models.BigIntegerField()
created = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.digest[:12]}… {self.size}B {self.media_type}"
class Source(models.Model):
"""An uploaded video, identified by its byte digest."""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
blob = models.OneToOneField(Blob, on_delete=models.PROTECT, related_name="video_source")
filename = models.CharField(max_length=255)
probe = models.JSONField(default=dict)
created = models.DateTimeField(auto_now_add=True)
class Sound(models.Model):
"""An uploaded sound file — mp3, wav, whatever the browser can decode — kept
as uploaded. Not footage: it has no frames and nothing measures it, so it
skips extraction and an audio node plays its bytes directly."""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
blob = models.ForeignKey(Blob, on_delete=models.PROTECT, related_name="sound_for")
filename = models.CharField(max_length=255)
label = models.CharField(
max_length=200, blank=True,
help_text="what a person called it; the filename when empty. Separate "
"from `filename` because the name on disk is a fact about the "
"upload and renaming must not rewrite it",
)
duration = models.FloatField(help_text="seconds, as ffprobe reports it")
created = models.DateTimeField(auto_now_add=True)
class Image(models.Model):
"""An uploaded still — a drawing, a photo, a model sheet — kept as uploaded, to
be traced over. Never part of the picture: a document names its blob as a
tracing symbol's `:media`, and the page draws it over the stage."""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
blob = models.ForeignKey(Blob, on_delete=models.PROTECT, related_name="image_for")
filename = models.CharField(max_length=255)
label = models.CharField(max_length=200, blank=True,
help_text="what a person called it; the filename when empty")
width = models.PositiveIntegerField(help_text="pixels, as ffprobe reports them")
height = models.PositiveIntegerField()
created = models.DateTimeField(auto_now_add=True)
class Extraction(models.Model):
"""One requested decode of a source into immutable footage."""
key = models.CharField(primary_key=True, max_length=71)
source = models.ForeignKey(Source, on_delete=models.CASCADE, related_name="extractions")
settings = models.JSONField(default=dict)
state = models.CharField(max_length=16, default="queued")
progress = models.PositiveIntegerField(default=0)
error = models.TextField(blank=True)
footage = models.ForeignKey(
"Footage", null=True, blank=True, on_delete=models.SET_NULL,
related_name="extractions",
)
created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)
class Footage(models.Model):
"""Tier 3: the frames and audio of one extraction, immutable.
`digest` is over the PROXY VIDEO's digest plus the audio's and the rate, so
two extractions of the same clip at the same settings are one footage and the
same analysis can be reused across both.
THE PROXY IS THE ANALYSIS SOURCE AND THE FRAMES ARE NOT. `video` is one
browser-safe H.264 file, and it is what the page seeks through to detect
landmarks. `frame_set` is a JPEG per frame at tracing size: reference stills
for the tracing editor, never the thing measured. The two are not
interchangeable, and which one carries the pixels an analysis was computed
from is the difference between a 6MB take and a 1.1GB one.
So the frame JPEGs are deliberately NOT in `digest`. They are a rendering of
this footage for a human to trace over; re-rendering them at another size does
not make it different footage, and putting them in the identity would throw
away every analysis when the tracing size changed.
`feature_absence` is the manifest annotation step 8 introduced: known
occlusion intervals, one-based and inclusive, expanded into presence tracks by
the loader. An input format, not a control UI.
"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
digest = models.CharField(max_length=64, unique=True)
label = models.CharField(max_length=200, blank=True)
source = models.CharField(max_length=200, blank=True)
fps = models.FloatField()
frames = models.PositiveIntegerField()
width = models.PositiveIntegerField()
height = models.PositiveIntegerField()
audio = models.ForeignKey(Blob, on_delete=models.PROTECT, related_name="audio_for")
video = models.ForeignKey(
Blob, null=True, blank=True, on_delete=models.PROTECT, related_name="video_for",
help_text="the browser-safe proxy, playable and seekable",
)
stream = models.ForeignKey(
Blob, null=True, blank=True, on_delete=models.PROTECT, related_name="stream_for",
help_text="the proxy's video as raw Annex-B H.264: what the page DECODES, "
"one access unit per frame; null on footage extracted before it",
)
feature_absence = models.JSONField(default=dict, blank=True)
created = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-created"]
def __str__(self):
return f"{self.label or self.source or self.id} ({self.frames}f @{self.fps})"
class FootageFrame(models.Model):
"""One tracing still. A row rather than an entry in a JSON list, because a
frame is a thing the server serves, and because a blob's references have to be
countable before anything can be collected.
A REFERENCE IMAGE, NOT A MEASUREMENT INPUT. See `Footage.video`."""
footage = models.ForeignKey(Footage, on_delete=models.CASCADE, related_name="frame_set")
index = models.PositiveIntegerField(help_text="0-based; source frame index + 1 is the JPEG's name")
blob = models.ForeignKey(Blob, on_delete=models.PROTECT, related_name="frame_for")
class Meta:
ordering = ["index"]
constraints = [
models.UniqueConstraint(fields=["footage", "index"], name="one_blob_per_frame"),
]
class Analysis(models.Model):
"""Tier 2: one detector, at one version, over one footage.
`key` is a content address over every input, and `descriptor` is the exact
canonical text that key is the sha256 of — sent by the client and stored, not
recomputed here. `clips/views.py` says why that is the honest arrangement: JS
prints an integral double as `1` and Python as `1.0`, so a scheme where both
sides re-render the numbers breaks on the first one of them.
`detector` and `version` are columns as well as descriptor fields so that the
question "which model produced this take" is answerable in the admin and in a
query, rather than only by parsing a hash's preimage.
"""
key = models.CharField(primary_key=True, max_length=71)
descriptor = models.TextField()
detector = models.CharField(max_length=64)
version = models.CharField(max_length=64)
footage = models.ForeignKey(
Footage, null=True, blank=True, on_delete=models.SET_NULL, related_name="analyses"
)
source_blocks = models.ManyToManyField(
"Block", blank=True, related_name="source_for",
help_text="pixel-dependent landmarks, detection mask and mouth crops",
)
created = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name_plural = "analyses"
def __str__(self):
return f"{self.detector} {self.version} → {self.key[7:19]}…"
class Block(models.Model):
"""Tier 2: one dense channel block.
Two hashes, and they are not the same hash. `key` is over the block's INPUTS,
which is what lets a client ask for the block its current settings want before
anything has computed it. `data.digest` is over the bytes. See clips/blobs.py.
"""
key = models.CharField(primary_key=True, max_length=71)
descriptor = models.TextField()
role = models.CharField(max_length=32)
analysis = models.ForeignKey(
Analysis, null=True, blank=True, on_delete=models.SET_NULL, related_name="blocks"
)
data = models.ForeignKey(Blob, on_delete=models.PROTECT, related_name="block_data_for")
state = models.ForeignKey(
Blob, null=True, blank=True, on_delete=models.PROTECT, related_name="block_state_for",
help_text="the per-track absence mask, when the take has one",
)
created = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.role} {self.key[7:19]}…"
class Project(models.Model):
"""Tier 1: the document's root.
`schema_version` identifies the stored document format. `seq` counts writes
to this particular project; it is not a format version. Every write bumps
`seq`, and a client that sees `seq > local + 1` refetches.
ANYONE WITH THE LINK CAN VIEW; the owner and the editors can write. Every
project has an owner.
"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
owner = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="projects",
)
editors = models.ManyToManyField(
settings.AUTH_USER_MODEL, blank=True, related_name="shared_projects",
)
name = models.CharField(max_length=200, default="untitled")
schema_version = models.PositiveIntegerField(default=8)
seq = models.PositiveBigIntegerField(default=0)
palette = models.CharField(max_length=64, default="arthur/default")
created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-updated"]
def __str__(self):
return f"{self.name} ({self.id})"
def bump(self):
"""The next seq, taken with an UPDATE so that inside a transaction it is
also the write lock: two concurrent saves cannot both get the same one."""
Project.objects.filter(id=self.id).update(
seq=models.F("seq") + 1, updated=timezone.now()
)
self.refresh_from_db(fields=["seq", "updated"])
return self.seq
def can_edit(self, user):
return user.is_authenticated and (
user.id == self.owner_id or self.editors.filter(id=user.id).exists()
)
class Clip(models.Model):
"""Tier 1: the unit of work, and the thing leaf paths are scoped by.
`cid` is what appears in `clip/<cid>/...`, so it is the clip's identity as far
as addressing is concerned and it does not change.
"""
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="clips")
cid = models.SlugField(max_length=64)
name = models.CharField(max_length=200, blank=True)
order = models.IntegerField(default=0)
blocks = models.ManyToManyField(
Block, blank=True, related_name="clips",
help_text="the tier-2 blocks this clip's channels name",
)
class Meta:
ordering = ["order", "cid"]
constraints = [
models.UniqueConstraint(fields=["project", "cid"], name="one_cid_per_project"),
]
def __str__(self):
return f"{self.cid} of {self.project.name}"
class Leaf(models.Model):
"""Tier 1: one independently addressed, independently versioned piece of the
document.
The value is transit-as-JSON in a JSONField, so the column holds JSON rather
than a string containing JSON: the admin can read a leaf, and the field-wise
merge of a channel leaf that docs/architecture.md describes as fifteen lines of
Python is possible over it. `version` is the entity tag a conditional write
compares — RFC 7232, not a bespoke invention.
"""
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="leaves")
path = models.CharField(max_length=300)
value = models.JSONField()
version = models.PositiveBigIntegerField(default=1)
seq = models.PositiveBigIntegerField(
default=0, help_text="the project seq of the write that last changed it",
)
updated = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["path"]
constraints = [
models.UniqueConstraint(fields=["project", "path"], name="one_leaf_per_path"),
]
@property
def etag(self):
return f'"{self.version}"'
def __str__(self):
return f"{self.path}@{self.version}"
class Revision(models.Model):
"""Tier 1: a snapshot of the authored layer, with a user and a summary — a
named snapshot, which is how a person marks a version now that every edit
saves itself.
ON AN EXPLICIT TRIGGER, not on every save. tl snapshots a small annotation
layer; arthur's tier 1 will contain cel polygons, so a snapshot per save bloats
the table — docs/architecture.md's "revisions need a coarser trigger". So this
is written by `POST /api/projects/<id>/revisions`, which is a "mark version"
button, and never by a save.
"""
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="revisions")
seq = models.PositiveBigIntegerField()
author = models.CharField(max_length=200, blank=True)
summary = models.CharField(max_length=500, blank=True)
document = models.JSONField(help_text="every leaf of the project, by path")
blocks = models.JSONField(
default=dict, help_text="each clip's tier-2 block keys, by cid, so a restore can name them",
)
created = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-seq"]
def __str__(self):
return f"{self.project.name} r{self.seq}: {self.summary}"

7
clips/routing.py Normal file
View file

@ -0,0 +1,7 @@
from django.urls import path
from .consumers import ProjectConsumer
websocket_urlpatterns = [
path("ws/projects/<uuid:project_id>", ProjectConsumer.as_asgi()),
]

View file

@ -0,0 +1,31 @@
{% load static %}<!doctype html>
{% comment %}
The host page, served by Django since port-plan step 9.
It carries no styles of its own any more. They are `static/arthur/app.css`, which
staticfiles serves from the same tree as the bundle — the page grew a five-pane
application chrome and "the styles" stopped being a thing you read in passing on
the way to the markup.
The bundle is unchanged: shadow-cljs writes it into `static/arthur/js` and
staticfiles serves it from there, so `manage.py runserver` and `shadow-cljs watch
app` are the whole dev loop with nothing copying files between them.
The CSRF token is rendered so that Django sets its cookie, which is what
`arthur.fx.http` reads to write the `X-CSRFToken` header. Saves are ordinary POSTs
and PUTs with ordinary CSRF protection — no endpoint in this app is exempt.
{% endcomment %}
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>arthur</title>
<link rel="stylesheet" href="{% static 'arthur/app.css' %}?v={{ css_version }}">
</head>
<body>
{% csrf_token %}
<div id="app"></div>
<script src="{% static 'mediapipe/vision_bundle.js' %}"></script>
<script src="{% static 'arthur/js/main.js' %}?v={{ js_version }}"></script>
</body>
</html>

0
clips/tests/__init__.py Normal file
View file

1218
clips/tests/test_api.py Normal file

File diff suppressed because it is too large Load diff

46
clips/urls.py Normal file
View file

@ -0,0 +1,46 @@
"""The API, which is nine endpoints and no framework.
The shape is RFC 7232 over addressed resources: a leaf is a resource, its version
is an entity tag, and a conditional write answers 409. docs/architecture.md is
explicit that this part is not a bespoke invention — "optimistic concurrency
control over addressed resources with an entity tag" is what HTTP has done for
thirty years — so the plumbing here is deliberately boring.
WRITES ARE ON HTTP AND STAY THERE. When the websocket arrives it carries presence
and broadcasts, and not writes: auth, idempotency, status codes, retries and
conditional requests all come for free here, and a dropped socket cannot lose a
write.
"""
from django.urls import path
from . import views
urlpatterns = [
path("me", views.me),
path("login", views.login),
path("signup", views.signup),
path("logout", views.logout),
path("detector", views.detector),
path("sources", views.sources),
path("sounds", views.sounds),
path("sounds/<uuid:sound_id>", views.sound_detail),
path("images", views.images),
path("images/<uuid:image_id>", views.image_detail),
path("extractions", views.extractions),
path("extractions/<str:key>", views.extraction_detail),
path("footage", views.footage_list),
path("footage/<uuid:footage_id>", views.footage_detail),
path("projects", views.projects),
path("symbols", views.symbols),
path("projects/<uuid:project_id>", views.project_detail),
path("projects/<uuid:project_id>/leaves/<path:leaf_path>", views.leaf_detail),
path("projects/<uuid:project_id>/revisions", views.revisions),
path("projects/<uuid:project_id>/revisions/<int:revision_id>/restore", views.restore),
path("projects/<uuid:project_id>/editors", views.editors),
path("projects/<uuid:project_id>/editors/<str:username>", views.editors),
path("analyses", views.analyses),
path("analyses/<str:key>", views.analysis_detail),
path("blocks", views.blocks),
path("blocks/missing", views.blocks_missing),
path("blocks/<str:key>", views.block_detail),
]

1202
clips/views.py Normal file

File diff suppressed because it is too large Load diff

59
do Executable file
View file

@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""Run the local app and its frontend watcher with one command."""
import os
import signal
import socket
import subprocess
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parent
def start():
with socket.socket() as probe:
if probe.connect_ex(("127.0.0.1", 8778)) == 0:
raise SystemExit("port 8778 is already in use")
commands = [
(["mise", "exec", "--", "python", "manage.py", "runserver", "8778"], ROOT),
(["mise", "exec", "--", "npx", "shadow-cljs", "watch", "app"], ROOT / "frontend"),
]
children = []
stopping = False
def stop(_signal, _frame):
nonlocal stopping
stopping = True
signal.signal(signal.SIGINT, stop)
signal.signal(signal.SIGTERM, stop)
try:
for command, directory in commands:
children.append(subprocess.Popen(command, cwd=directory, start_new_session=True))
print("arthur: http://localhost:8778 (Ctrl-C stops both processes)", flush=True)
while not stopping:
for child in children:
if child.poll() is not None:
raise SystemExit(f"app process exited with status {child.returncode}")
time.sleep(0.25)
finally:
for child in children:
if child.poll() is None:
os.killpg(child.pid, signal.SIGTERM)
for child in children:
try:
child.wait(timeout=5)
except subprocess.TimeoutExpired:
os.killpg(child.pid, signal.SIGKILL)
child.wait()
if __name__ == "__main__":
if sys.argv[1:] == ["start"]:
start()
else:
raise SystemExit("usage: ./do start")

937
docs/animation-model.md Normal file
View file

@ -0,0 +1,937 @@
# arthur — the animation model
The revised target for lanes, occurrences, source playback, shared editing, and
multi-view UX is [The Lane Model](lane-model.md). It supersedes conflicting
proposals below. Backward compatibility is not required; this document still
contains descriptions of earlier shapes and planned features.
The data that describes a moving picture: what the primitives are, how they
nest, how they change over time, and how rotoscoped and hand-authored work end
up being the same thing with one flag between them.
`docs/design.md` is the aesthetic argument. `docs/architecture.md` is where the
code goes. This is the type that both of them are about.
## What this replaces
`docs/design.md` has a table of five kinds of part — plate, feature, interior,
primitive, scalar — each with its own source, vocabulary and interpolation. That
table is a good description of **where data comes from** and a bad description of
**what data is**, and the current code follows it too literally: eyes, brows,
teeth and mouth each get their own build function, their own key shape and their
own path through the prototype's writer.
They are all one thing. A part is a **node** with **channels**, and the five
kinds collapse into differences of which channels exist and who filled them in.
## Prior art, and what each one gets right
| System | The idea worth taking |
| --- | --- |
| **Flash / SWF** | A **library of symbols** and a timeline of **instances** at depths. "Framed" content that simply exists on a frame, versus tweened content. `DefineMorphShape` requires matching vertex counts — the fixed-topology rule, arrived at from the other direction. |
| **Blender** | Animation is **addressed by path into the data** (`location[0]`), not stored as fields on the object. An Action is a bag of F-Curves. That decoupling is what makes the dope sheet, the graph editor and the NLA three views of one dataset. Also: parenting captures a `parent_inverse` so the child does not jump. |
| **After Effects** | Every leaf property is animatable, uniformly. Property groups form a tree. Pre-comps nest arbitrarily and a pre-comp is just a layer. |
| **Lottie** | The uniform property shape: `{a: 0, k: <value>}` or `{a: 1, k: [<keys>]}`. One representation for static and animated, which is exactly "framed or keyframed". |
| **Grease Pencil** | A 2D layer holds frames at frame numbers, and a frame **holds until the next one**. Hold is the default, not a special case. |
What none of them get right for this project: colour. All four store RGB on the
shape. `docs/design.md` forbids that, so colour is a palette index here and it is
a channel like any other.
## The one idea
**Analysis is a channel generator.** It does not produce a different kind of
data; it produces keys, densely, on the same channels a hand would fill in
sparsely. So:
```
footage ──▶ analysis ──▶ FREEZE ──▶ channels on nodes ──▶ evaluate ──▶ raster
▲
hand authoring ──┘
```
Freezing is not a conversion into a second format. There is one format, and
freezing fills it in. That is what makes "the only difference is a special flag"
literally true: the flag is provenance on a channel, and nothing in the renderer
reads it.
## Node
A node is an instance in the scene. The tree is stored **flat, with parent
pointers** — never as nested maps.
```clojure
{:id :mouth
:name "mouth"
:kind :poly ; :poly :disc :rect :group :bitmap :symbol
:parent :head ; nil at the root
:z "a3" ; fractional index, ordered among all siblings
:symbol nil ; or :sym/blink — see Symbols
:stencil :mouth-in ; colour-key clip; structural, not a channel
:span [0 240] ; in/out in the parent's frame space
:pinv [1 0 0 1 0 0] ; parent-inverse, captured when parented
:channels {...}}
```
Flat with pointers, for four reasons that all point the same way: any node is
addressable without a walk; reparenting is a one-field write rather than a
subtree move; an edit to a leaf does not change the identity of its ancestors, so
re-frame's structural sharing keeps ancestor subs from invalidating; and it is
what lets every node be its own sync leaf. Flash, Blender and AE all store it
this way.
`:span` is Lottie's `ip`/`op` and Flash's `PlaceObject`/`RemoveObject`: the range
over which the node exists at all. Distinct from a `[:vis]` channel, which
blinks an existing node on and off.
**Every node has the same two maps into its parent**, whatever kind it is:
- **space** — the matrix its transform channels compose to, times a `:pinv` if
it has been moved in from elsewhere;
- **time** — `local = rate · (parent − at)`, from `:time :at` and `:rate`,
identity when absent. `:span` and every key are in the node's **own** frames.
A move keeps a node's world maps and re-expresses them under its new parent:
the matrix becomes a `:pinv`, the time becomes a new `:at` and `:rate`, and its
channels, keys and span are not touched. Both maps are affine, so any depth of
nesting is one map and every move is one inverse. `node/time-of`,
`node/then-time` and `node/placed-span` are the time half; `clip/move-node` and
`clip/group` are the move.
### Subjects and tracked features
Scene nodes describe drawings, not tracking identity. A scene may also carry a
flat `:features` map. A feature ID stays stable for the whole clip, including
frames where that feature is occluded and later reappears:
```clojure
:subjects {:face-1 {:id :face-1}}
:features
{:eye-r {:id :eye-r :subject :face-1 :area :eye
:nodes [:eye-r :eye-r-in :iris-r :pupil-r] :params {}}
:eye-l {:id :eye-l :subject :face-1 :area :eye
:nodes [:eye-l :eye-l-in :iris-l :pupil-l] :params {}}
:mouth {:id :mouth :subject :face-1 :area :mouth
:nodes [:mouth :mouth-in] :params {}}
;; The teeth are their OWN feature and not three nodes of the mouth. A feature
;; carries the params of exactly one area, and the teeth have an `:area :teeth`
;; of their own — the otsu threshold, the tongue rejection, the radial contour's
;; vertex budget — which could not be reached if they were part of `:mouth`.
;; The coupling that made them look like the mouth's is real and is enforced
;; elsewhere: `:teeth` is STENCILLED by `:mouth-in`, and a node whose stencil drew
;; nothing is dropped, so an absent mouth takes the teeth with it without either
;; of them sharing an absence mask. An earlier draft of this block listed them
;; together; the code is right and this document was wrong.
:teeth {:id :teeth :subject :face-1 :area :teeth
:nodes [:teeth] :params {}}}
:groups
{:eyes-1 {:id :eyes-1 :kind :eye-pair :subject :face-1
:members [:eye-r :eye-l] :params {}}}
```
An eye pair is an explicit relationship between one or two eyes of the **same
subject**. It may have one member when only one eye has been identified; it does
not invent a second eye. Five subjects with nine identified eyes can have four
two-member pairs and one one-member pair. Each eye still has its own feature ID
and presence track. A group is a settings association, not a scene parent or a
tracking ID. Membership lives only on the group, avoiding a second pointer on
the feature that could disagree with it.
Each feature resolves settings from its area's definitions, then its group,
then its own `:params`. An eye can therefore inherit a pair setting or override
it without changing its partner. Removing it from a pair copies its effective
values into the feature first, so the result does not jump. Feature identity
and pair membership are clip-wide; a future parameter track can vary values
over time without splitting a feature at an observation gap.
Parameter definitions live in one registry: key, default, applicable area,
value constraints and affected areas. The registry supplies the take's defaults
today. The parameter UI and regeneration from edited values are later work.
Dense channel state records whether a measurement exists **for that feature on
that frame**. Occlusion means absent data on that frame, not a false `[:vis]`
value and not the end of the feature's identity. A full-face detection failure
makes all its features absent. A single occluded eye need only make that eye's
channels absent. Footage can carry explicit feature absence intervals in its
manifest, with one-based inclusive source frame numbers, for example
`"feature-absence": {"eye-r": [[10, 14]]}`. The loader expands these into
per-frame observation tracks before measurement. Unobserved landmarks may fill
rectangular numeric buffers, but they cannot contribute to an eye's contour,
blink or shared gaze. When one eye is absent, gaze uses the observed eye.
Until a detector supplies feature-level confidence, footage without annotations
uses the full-face detection mask as the fallback; it must not claim to detect
individual occlusions that it cannot see.
## Channel
Every animatable property is a channel, and channels are addressed **by path**:
```clojure
:channels
{[:xform :pos] {:animated? false :value [0.0 0.0]}
[:xform :rot] {:animated? false :value 0.0}
[:xform :scale] {:animated? false :value [1.0 1.0]}
[:xform :skew] {:animated? false :value [0.0 0.0]}
[:geom :pts] {:animated? true :interp :hold :dense {...} :generated {...}}
[:style :color] {:animated? false :value :skin-dark}
[:vis] {:animated? true :interp :hold :keys {0 true, 37 false}}}
```
A path is a **vector**, not a string — CLJS maps take vectors as keys natively,
so Blender's `data_path` idea arrives with no parsing. The set of valid paths for
a node follows from its `:kind`, and that is a spec, not a schema migration.
Three channel shapes, and the uniformity across them is the point:
```clojure
;; FRAMED — one static thing. No animation, no vertex correspondence to worry
;; about. A painted background cel is this.
{:animated? false :value v}
;; KEYED — sparse, authored, in the document. Undoable and syncable.
{:animated? true :interp :hold :keys {0 v, 4 v, 12 v}}
;; DENSE — generated, one value per frame, held in tier 2 as a typed array.
{:animated? true :interp :hold
:dense {:store "sha256:…" :offset 0 :stride 40 :frames 600}
:generated {...}}
```
`:interp` defaults to `:hold`, which `docs/design.md` requires of every cut part.
An authored keyed channel may also carry `:segments {8 :linear}`: the key at 8
tweens toward the next key, while other gaps use the channel default. The
transition belongs to the gap starting at a key, so a shape can cut into one
drawing and tween out of it. Per-key easing beyond hold and linear is deferred.
### Keys are a map by frame, not a list
Already argued in `docs/architecture.md` for merge reasons; here it also gives
"the most recent key at or before `f`" as a `rsubseq` on a sorted map instead of
a scan. **Store a plain map** in the document — transit and JSON both lose
sortedness — and build the sorted index in the resolver.
### The flag lives on the channel, not the node
```clojure
:generated {:by :roto/lips-outer
:analysis "sha256:…" ; which analysis artifact
:params {:verts 8 :contour-avg 1 :aperture-cut 0.004}}
```
Present means the UI offers a parameter panel and a re-freeze button. Absent
means the UI offers the keys directly. **The renderer never reads it.**
It belongs on the channel rather than the node because a node routinely wants
both at once: a mouth whose `[:geom :pts]` is rotoscoped and whose `[:xform :pos]`
is hand-animated to sit on a plate. Putting the flag on the node would forbid the
most useful thing in the model.
### Channels are layered
A channel is a base plus optional override layers, and a layer declares how it
combines:
```clojure
{:animated? true :interp :hold
:dense {...} :generated {...}
:over [{:id :nudge :support [88 98] :op :offset
:values {:animated? true :interp :linear :keys {88 [2 0], 96 [0 0]}}}
{:id :redraw :support [104 105] :op :replace
:values {:animated? false :value [[3 7] [4 7] …]}}]}
```
A LAYER'S VALUES ARE A CHANNEL, which is what keeps a constant adjustment, a
ramp and a return motion from being three mechanisms: a framed one says the same
thing on every frame it covers, a keyed one moves. They read through `value-at`
and `cursor` like any channel, one reading head each, so the specification and
the playback path share their blending and differ only in how they read — and a
layer's values may not carry layers of their own, which the stack already
orders.
`:support` is half-open and explicit, `[in out)`. Outside it a layer is inactive
and the base evaluates exactly as it did before, which is the difference between
a bounded correction and inserting boundary keys — the latter alters the
neighbouring segments. And a layer has NO TIME SPACE of its own: its support and
its values' keys are in the frames the base channel's keys are in, the node's
own. A correction on a lane is therefore in lane frames and reaches across the
drawings exposed under it; one on a single occurrence is in that occurrence's
frames and travels with it when the exposure moves. Ownership had already
answered the question, so there is no field to disagree with.
- **`:offset`** adds a delta to the base. "Nudge the mouth two pixels right for
ten frames" survives a re-freeze at different parameters, because it was never
a position — it was a correction.
- **`:replace`** wins outright. For the frame where detection simply failed.
This is what `docs/design.md` means by an override layer, and it is why
re-freezing is safe: the base is regenerated, the layers are untouched. It is
Blender's NLA blending and AE's effect stack at one property.
WHEN THE BASE OUTGROWS A CORRECTION it is a CONFLICT, which is neither a dropped
layer nor an applied one. Turning `:verts` gives the mouth a different number of
points, and an `:offset` is a row of components that has to match: so the
regeneration records `:conflict` on the layer, the layer stays in the document,
the picture is the base meanwhile, and `clip/conflicts` is the list a view
offers to resolve. Deliberately not `problems` — the document loads and saves
fine, it just contains a decision nobody has made yet. A later regeneration
that restores the shape clears the mark. Only `:offset` can conflict; `:replace`
states a whole value and has nothing to agree with.
A correction is NOT a hand placement. `regenerate-head` leaves the head's
authored channels alone once somebody has placed it by hand, and it compares the
channels WITHOUT their layers to decide: otherwise the first correction anyone
made would stop the head following re-measurement forever, which is the opposite
of what a layer is for.
Layers are what "set it by hand" means for anything measured, and the measured
channel does not need to know. A hand-set gaze is an `:over` on
`[:xform :pos]` of the iris; a hand-set mouth shape is an `:over` on
`[:geom :pts]`. Turning the gaze-step or gaze-dwell knob regenerates the base and
leaves the correction alone, which is the entire reason a correction is stored as
a layer rather than written into the track.
**A `:replace` layer overrides absence, an `:offset` layer does not.** Sampling a
channel is: read the base, then apply the layers — and the base coming back
`absent` does not short-circuit that. `:replace` is explicitly for the frame
where detection failed, so it has to be able to supply a value where there is
none; `:offset` is a delta, and there is nothing to nudge, so an offset over an
absent base stays absent. Implemented the obvious way — bail out on absence
before reaching the layers — the one case the feature exists for is the one case
it would not cover.
### One signal, two nodes
Gaze is deliberately **one measurement shared by both eyes**: at this size the
per-eye difference is noise, and independent noise reads as wall-eyed
immediately, which is the most expensive artefact on a face. But it is stored as
`[:xform :pos]` on `:iris-r` and on `:iris-l`, which are two channels on two
nodes with two different parents — so the invariant lives in `measure` and
nothing in the document enforces it.
That matters as soon as either one can be overridden by hand, because an `:over`
on one iris alone reproduces exactly the artefact the shared measurement exists
to prevent. Until drivers exist, **the override is on both or on neither**, and
that is a rule the UI has to keep rather than one the data can.
This is the case that will eventually justify **drivers** — one value, evaluated
once, feeding several channels — which is why gaze is named in Deferred as the
obvious first one. Nothing here forecloses it: a driver needs a place in the
document and a `:driven-by` on a channel, both of which are additive, and an
absent key means "not driven". So it stays deferred, and the shape does not have
to change to allow it.
## Transform: decomposed, never a matrix
```clojure
{:pos [x y] :rot θ :scale [sx sy] :skew [kx ky]}
```
Stored decomposed for two reasons. Each component has to be independently
keyframable, which is the entire point of channels. And interpolating matrix
entries is meaningless — a rotation tweened through its matrix shears on the way.
Composition, per node:
```
local = T(pos) · T(piv) · R(rot) · K(skew) · S(scale) · T(-piv)
world = world(parent) · pinv · local
```
`:pinv` is Blender's `parent_inverse`, captured at the moment of parenting so the
child does not jump when it acquires a parent. Small, and its absence is the kind
of thing that makes a parenting feature feel broken.
### A node has a `:pivot`, and a peg is still a peg
Rotation and scale happen about the node's **pivot**, `[:xform :pivot]`, a point
in its own coordinates:
```
local = T(pos) · T(piv) · R(rot) · K(skew) · S(scale) · T(-piv)
= T(pos + piv - M·piv) · M
```
Toon Boom gives every layer and every peg a pivot, Flash gives every instance a
transformation point, After Effects calls it the anchor point. All three store
it, and the reason is one sentence: **a turn has to be a turn on every frame**,
and the only way to keep a point still through an interpolated angle is for the
angle to be composed about that point.
This was deleted in schema 7 and restored in schema 8, and the argument for
deleting it was *not wrong*, which is why it is worth writing down. It was:
```
T(pos) · T(a) · R·K·S · T(-a) ≡ peg at pos+a carrying R·K·S, child at -a
```
to the last bit of the mantissa — `node-test` asserts it, still. `T(a)·M·T(-a)`
is `M` conjugated by a translation, which is "do `M` in a frame shifted by `a`",
and a **parent already is a shifted frame**. So an anchor was a peg written
inline, and a peg can be selected, keyed, shared between nodes and put above a
measured channel. Same expressive content, strictly more reach.
**What that identity does not say is what a node turns about when nobody has
made a peg.** It is an equivalence between a pivot and a peg *that already
exists*; it is silent on the default, and the default is what a person meets.
With no pivot in the composition, a turn about any point that is not the node's
own origin has to be paid for by writing `pos` as well — `gesture/about` solves
for it:
```
q = M⁻¹(c − t) the material point under c
p' = c − M'·q
```
and that solution is an **arc** in the angle while `pos` interpolates along the
**chord**:
| | pivot = origin | pivot ≠ origin |
| --- | --- | --- |
| one drag | right | right |
| between two keys | right | **wrong**, by the sagitta of the arc |
A 360° turn is where that is unmissable: 0° and 360° are the only two frames
where a wrong pivot cannot be seen at all, so the keys look right and every
frame between them is wrong.
**A drawing escaped it. A symbol instance could not.** `paint/centred` puts a
shape's origin on the middle of what it draws the moment it is drawn, so for a
drawing the pivot *is* the origin, `about` has nothing to do, and a keyed turn is
right between its keys. An instance's origin is its **symbol's**, and a symbol is
drawn on the stage, so its origin is the stage's top-left corner. Measured from
the document this was reported on: a symbol holding six drawn shapes had its
content centred at (99, 127), 161 px from its own origin, on a 320×200 stage. One
instance of it, keyed `rot` 0 → 60 and dragged round by hand, put the drawing at
(115, 116) on frame 0 and (241, 104) on frame 60 — both where they were put — and
at (−88, 121) on frame 30, a stage and a half from either. The answer on offer
was "make a peg first", for wanting to spin a drawing.
So the pivot is back, with the default and the escape hatch spelled out, because
a stored pivot without either is the field that was deleted:
| | what | where |
| --- | --- | --- |
| **the default, for a node nobody has pivoted** | the middle of what it draws — `pick/bounds-of`, the same call the selection box comes from, so the cross starts out on the middle of the box | `gesture/pivot` |
| **choosing it, invisibly** | the first turn or scale writes that middle down, in the same edit, with the `pos` that holds the picture still | `gesture/with-pivot` |
| **choosing it, by hand** | ⌃/⌘-drag the cross on the stage: the pivot goes under the pointer and nothing moves | `gesture/repivot`, `::ui/repivot` |
| **a placement** | `clip/place-symbol` stores the middle of what the symbol draws as the instance's pivot, so an instance turns about its drawing from the moment it is dropped | `clip/place-symbol` |
| **putting it back** | ⌖ beside the pivot row in the inspector: back to the middle of what the node draws *now*, moving nothing | `gesture/centred`, `::ui/centre-pivot` |
**A pivot is a choice, and does not follow the drawing.** Once it is the node's
own, the derived middle is never consulted for it again. This is the half the
old stored anchor got right and the derived pivot got wrong: adding a shape
inside a symbol must not re-aim every keyed spin of every instance of it, and a
pivot that tracked the content did exactly that, silently, with nothing changing
on screen at the moment it happened. The cross is visible and draggable and ⌖
puts it back, which is what the anchor was missing — it was never the storing
that was wrong.
**A peg is an ordinary `:group` parent, `nest/peg`, with `:pinv` captured so
nothing moves when it appears.** It is no longer the answer to "this turns about
the wrong point", and it is still the answer to three things a node's own pivot
is not:
| want | why the node's own pivot is not it | what the peg does |
| --- | --- | --- |
| a pivot **shared** between nodes — an arm and a forearm about one shoulder | two pivots that have to agree frame for frame are not one pivot | one transform, two children hanging off it |
| a **second** transform on one node — a drawing spinning about its middle while the limb swings about the shoulder | a node has one `rot` | stack them, as Harmony does |
| a hand transform over a **measured** one | `gesture/refusal` turns a drag on a measured channel away, because the next regenerate would discard it | the peg's channels are its own, so the hand transform composes outside the measurement, which stays regenerable |
The pivot of a measured node is *not* in that table: `[:xform :pivot]` is
authored on every node alike, never dense and never regenerated, so a traced
mouth can be told to turn about its own middle without a peg and with nothing a
regenerate will throw away. That is the row that used to be impossible — writing
an anchor under a measured `M` moved the thing it was meant to leave alone,
because the old composition was `T(pos)·M·T(-a)` and `pos` was the measurement's.
The conjugated form has no such problem: `T(a)·M·T(-a)` is the identity at `a`
whatever `M` is.
`demo/stage` places its seven faces on pegs, and that is now one way of writing
something a pivot says directly: the faces' `:scale` is **keyed** — they pulse —
and the source's middle has to stay on its authored centre throughout, which a
static `pos` cannot do since `T(pos)·S(k(f))` moves that point whenever `k`
changes. `T(center)·S(k(f))·T(-origin)` does, for every `k`, and so does one
instance with its pivot on the middle. The demo is left as it is, pegs and all:
it is a hand-authored scene that renders correctly and `instance-test` asserts
its structure, and a peg carrying a keyed scale is a perfectly good thing to
have written.
`gesture/about` survives for the one gesture whose pivot belongs to no node: a
**multi-selection** scaling about the middle of its shared box, where every
member has to move to keep the arrangement. Nobody keys that.
Schema 8 is the first version that **converts** rather than refusing. A schema-7
node has no pivot, an absent pivot reads as `[0 0]`, and `T(pos)·T(0)·M·T(-0)` is
`T(pos)·M` to the bit — so every stored document composes to exactly the matrices
it did, dense tier-2 transforms included, and the migration only restamps the
version. What a converted document does not get is a pivot anybody chose; its
nodes still turn about their origins until the first turn writes one or the cross
is dragged.
**The similarity fit already produces a decomposition.** `fitSimilarity` returns
`{s θ tx ty}`, which drops straight into `[:xform :scale]`, `[:xform :rot]` and
`[:xform :pos]` with no conversion. The analysis output and the animation model
meet without an adapter, which is a sign the decomposition is the right one.
## What space geometry is in
**`[:geom :pts]` is always in the node's own local space, and the transform
chain says what that means.** There is no global geometry space and no decision
to make about one.
| Node | Its local space | Why that one |
| --- | --- | --- |
| a rotoscoped feature | head-local, isotropic, unit = one image height | what the anchor fit already produces; the `xform` to raster is not applied and not stored |
| a painted cel | the stage, in pixels, grid-snapped | the artist is placing pixels, so the pixel grid is the thing being authored |
| a primitive under a feature | its parent's | the iris is positioned on the lid ring, not on the stage |
This looks like a small clarification and it removes a whole class of argument.
The prototype bakes the framing into the numbers: `toRasterRing` applies
`makeXform`, which centres on the face oval's bounding box and zooms until the
face is 80% of the raster height, so **every stored vertex carries a cropping
decision** that was made once, at analysis time, from one frame's landmarks.
Dropping that step is a deletion, not a feature, and after it the framing is
simply a transform on a node.
Grid snapping belongs to the cel and not to the roto, for the same reason: a cel
is authored on the grid and a traced contour is not. So it is a property of a
node's space rather than a rule about all geometry, and the tension between
"integer polygons" and "arbitrary placement" was never real.
Each dense block therefore carries its own **fixed-point scale** in its header,
because a block in image-height units and a block in stage pixels need different
ones to fill an `Int16` usefully.
### There is no camera node
A camera is a global transform over everything, and nothing here wants one.
Placing the face on the stage is a transform on a node, which already exists;
what is not on the stage hangs off the edges and the canvas clips it. Every fill
in `domain/raster` already clamps rather than assuming it is inside, so drawing
past the edge is not a feature to add.
Project dimensions are therefore **independent of the footage**. A 1440x1920
portrait clip composited onto a 320x200 stage is not a problem to solve — the
head is placed and scaled where it belongs and the rest of the frame is simply
not on stage. The full frame stays *available* for tracing without being
*visible*, and those are different requirements.
## Head motion: free or anchored to measured frames
`stabilize` produces `{s, θ, tx, ty}` per source frame. Its inverse is stored
densely on `:head`'s position, rotation and scale channels. The same measured
track serves every placement choice:
```clojure
;; no :anchors — free: read the measured transform at the current frame
;; one key — lock to a chosen measured frame throughout
:anchors {0 12}
;; several keys — cut to another measured head transform at frame 40
:anchors {0 12, 40 42}
```
The map is `local change frame -> measured source frame`. A single lock is a
one-key map. Position, rotation and scale read the same held source frame. The
frame set belongs to head placement, independently of plate drawings and stage
pose cuts. No measured block is copied into authored transform keys.
**Always measure, always store factored.** The fit is computed and the geometry
is stored head-local in every mode. Only the frame address used to read the
head's measured transform changes. Two things downstream require that split:
- *Smoothing.* "Smooth the transform, never the contour" only means anything
while the two are separate.
- *Key selection.* A velocity minimum is "articulation paused" in head-local
space and "the head happened to be still" in image space.
This is a document edit, not a reason to re-analyse. A registered tracing photo
will use its own source frame's stabilising transform followed by the same
selected head placement, so it aligns with the vectors drawn over it.
### Two nodes, because two different things want that transform
```
:face group — AUTHORED. where the face sits on the stage, and how big.
:head group — MEASURED. dense head motion read at the selected frame.
:mouth :mouth-in :teeth :lid-r :lid-l :brow-r :brow-l …
```
Changing anchor keys edits `:head` and never touches `:place`, so it cannot move
something that was placed by hand. A group node is free, and keeping the authored
and the measured transform apart is the whole reason the transform is decomposed
in the first place.
## Time maps — exposure, lead and symbol timing are one thing
Every node may map the frame it is evaluated at:
```clojure
:time {:mode :inherit} ; the default, and almost always right
:time {:mode :map :expose 2 :offset -1 :rate 1.0 :loop? false}
:time {:mode :map :source-fps 30 :sample-fps 12} ; root: lower picture cadence
```
Three features that look unrelated are this one mechanism:
- **exposure** is `⌊f/n⌋·n`,
- **picture fps** quantises source time to a chosen picture grid, then reads the
latest source pose at or before that time; source analysis and audio keep their
original cadence,
- **mouth lead** is `f + k`,
- **a symbol instance's timing** is `(f - at)·rate + in`, with optional looping.
Composed along the nesting chain, outermost first. Two rules follow, and they are
different rules:
- **Exposure inherits strictly.** `docs/design.md` is emphatic that everything
rides one grid, because a head cutting on odd frames against a mouth cutting on
even ones reads as two performances. The model permits a per-node grid; the
default must be `:inherit`, and setting it lower is a deliberate act the UI
should make feel like one.
- **Offset is per-node by design.** Mouth lead applies to performance nodes and
*not* to the plate, which is the whole point of it — so the offset genuinely
belongs at the node, not the clip.
## Symbols, and why a scene is one
A **symbol** is an ordered bag of nodes in its own frame space. (Earlier drafts
and code called this a *timeline*; that word now means only the UI pane that
shows one.)
```clojure
{:frames 91
:palette {...} ; see Palettes
:nodes {id -> node}}
```
That is the whole type, and **everything that holds nodes is one of these**:
- what a document opens on is a symbol, and **no symbol is reserved** — a new
document's is called `main` only because it has to be called something,
- anything placed inside another symbol is a symbol,
- a node with `:kind :instance` is an **instance** of one.
An earlier draft of this document had a scene and a `:kind :timeline` symbol as
two structures with the same fields and never said they were the same thing.
They are. Flash's `_root` is a MovieClip; After Effects' "a pre-comp is just a
layer" is already in the prior-art table above. Collapsing them is what makes
nesting arbitrary and free, rather than a feature to be added.
### Two axes of nesting, and they are different
This is the distinction the flat-storage rule is about, and conflating the two is
why "nested" and "flat with parent pointers" sound contradictory when they are
not:
| Axis | What nests | How it is stored |
| --- | --- | --- |
| **parent / child** | transform composition within one timeline | **flat, with parent pointers** — never nested maps |
| **instance** | a timeline inside another timeline | by reference into the library |
Each timeline is flat. Timelines nest. Every argument for flat storage —
addressability, one-field reparenting, structural sharing, per-node sync leaves —
is about the first axis and is untouched by the second.
The instance boundary is also **the only place the frame space changes.** Within
a timeline, `:time` is exposure and lead: a shift inside one space. At an
instance it is `(f - at)·rate + in`, into a different one. That is why `:rate` is
meaningless on an ordinary node and why sampling one must fail loudly rather than
be ignored.
### What is scoped to a timeline
Three fields on a node only have meaning relative to a timeline, and the answer
for all three is the same — **their own**:
- **`:z`** orders among siblings; a node cannot interleave with nodes inside a
nested instance. The instance occupies one position in its parent's order and
its contents sort beneath it, which the z path gives for free by being a
vector.
- **`:stencil`** names a node in the same timeline. A colour key does not
naturally respect a boundary — it is just pixels — so this is a rule rather
than a consequence, and it is Flash's rule for masks.
- **`:span`** is in the parent node's frame space.
### Instances
A node with `:kind :instance` and `:source {:symbol :sym/blink}` places one, and
its `:playback` says how time runs inside it — which drawing is used and how it
is played are separate facts, per [the lane model](lane-model.md). Its own channels
compose *over* the symbol's, so one definition is placed many times and tinted,
offset or retimed at each placement — that is how a three-frame blink is reused
at frames 40, 88 and 200 without copying it.
This is also where `docs/design.md`'s "closed vocabulary is right for the head"
lands: a plate library is a set of `:sym/head-*` timelines, and the strip chooses
which is instanced on which frame.
**Cursors and point buffers are per-instance, not per-node.** Two instances of
one symbol sit at different frames in their own space, so they cannot share a
reading head over the same channel. The resolver keys its caches by the instance
path, not by node id — which is a detail of `Making it fast` below, and the one
place symbol nesting is not free.
### Audio placements and controls
Sound is placed on a timeline as a separate `:audio` node. It uses the same
`:span`, `:time`, and channel representation as a drawn node. A `:linked-to` id
records which picture instance it was placed with; it does not force the two
spans or source in-points to match.
```clojure
{:id :voice-right :kind :audio :parent :root :z "a4"
:linked-to :right
:source {:footage "f8cace9e-..."}
:span [48 260]
:time {:mode :map :at 48 :in 0 :rate 1}
:channels {[:audio :gain]
{:animated? true :interp :linear
:keys {48 0.0, 60 1.0, 245 1.0, 259 0.0} :over []}}}
```
`[:audio :gain]`, `[:audio :pan]`, and `[:audio :rate]` are ordinary scalar
channels. They may be framed, keyed, or dense; numeric keyed channels can ramp
linearly. The time map sets the placement's base source rate, and
`[:audio :rate]` multiplies it. Audio is mixed from the referenced immutable
footage when the clip opens. The mix is derived output; the saved document holds
the nodes and channel keys, not another audio file. One audio element plays that
mix and remains the clock for both sound and picture.
This is also the boundary for a future control surface. A control has a stable
target, such as a feature's `:verts` setting or an audio node's
`[:audio :gain]` channel. The UI and a MIDI binding can address both through the
same control interface. Their update costs differ: gain can be keyed over time;
changing the number of lip vertices changes topology and must regenerate its
dense geometry. A topology setting cannot be treated as a per-frame gain curve.
## Evaluating a frame
```clojure
(defn eval-frame
"Scene at clip frame f -> draw ops in z order. Pure."
[scene f] ...)
```
1. Walk nodes in **topological order** by parent depth (cached; recompute only
when parentage changes).
2. Skip nodes outside `:span`.
3. Apply the node's time map to get its own local frame `fn`.
4. **Sample** each channel at `fn`: a map lookup for framed, a sorted-index
lookup for keyed, an array read for dense. Then apply `:over` layers.
5. Compose `world` from the parent's.
6. Transform geometry into raster space, writing into a **preallocated buffer**
owned by the node.
7. Emit `{:kind :poly :pts buf :n 20 :color idx :stencil id}`.
8. Sort by resolved `z`.
The op list is the boundary with stage 7 in `docs/architecture.md`: the
rasteriser takes ops and knows nothing about nodes, channels or time.
**A tracing layer is an op that never reaches the raster.** Footage or a still
to draw over is a symbol with `:type :trace` and a `:media`, placed by an ordinary
instance — so it is moved, scaled, trimmed, held and put in a lane like anything
else — and it resolves to one `:trace` op: `{:kind :trace :node :layer :media
:frame :size :m}`. The raster refuses that kind, the player hands it to a
`drawImage` on a separate canvas over the picture, and `clip/resolver` makes one
only when asked with `:tracing?`, which only the stage does. An export, a
symbol's centre and a thumbnail never ask, so a reference cannot reach the
picture by any path that forgets to filter it. See `docs/tracing-symbol-plan.md`.
A face's footage is one of these, placed as `:plate` under `:head` with the
anchor fit itself as its measured transform — the inverse of the head's, over
image height. Its world is `head · fit · 1/H`, so on a frame where the head and
the plate read the same measured frame the two cancel and the photo sits where
the face was filmed; on any other frame it rides the head. Registration is the
ordinary walk, not a matrix built beside it.
Which frame it shows is the placement's: `:time {:holds [...]}` holds it on
chosen frames, and a head with `:reads {:holds-of :plate}` jumps to the same
ones. Whether it is showing at all is the editor's, `[:ui :tracing]`.
### Making it fast in CLJS
Three things, and only these three matter:
- **Decomposed and persistent for storage; flat and mutable for evaluation.**
Composed transforms are 6-element `Float64Array`s, not maps. Every renderer
does this; the storage form and the evaluation form are allowed to differ.
- **A cursor per channel.** Playback is sequential, so "most recent key at or
before `f`" is an advance of a saved index, O(1) amortised. Binary search only
on a seek. This is the difference between a `rsubseq` allocation per channel per
frame and none.
- **Preallocated point buffers per node.** Fixed topology means the size is known
at freeze time, so the vertices — the overwhelming majority of the per-frame
bytes — are written into a buffer the node already owns. A frame still
allocates its op maps and the sorted op vector; that is a dozen small objects
against hundreds of points, and pooling them would buy nothing and cost the
ability to pass an op list around as plain data. At 30fps, per-vertex
allocation is the thing that will make this stutter.
Because the buffers are reused, **ops must be consumed before the next frame is
asked for.** That is the contract the rAF loop wants anyway: it reads, blits,
and dispatches nothing.
### What is in app-db, and what is not
| In app-db (tier 1) | In tier 2, behind a handle |
| --- | --- |
| nodes, parentage, z, spans, stencils | dense channel blocks |
| channel definitions, `:interp`, `:generated` | analysis artifacts |
| **framed** values, **keyed** keys, `:over` layers | preallocated eval buffers |
| library / symbol definitions | composed transform scratch |
The rule: **anything a human placed is in the document; anything a generator
produced is a handle.** Which is the same line `docs/architecture.md` draws for
sync and baking, arrived at again from the renderer's side.
## The current parts, in this model
Proof that it covers what exists, not just what is wanted:
| Now | Becomes |
| --- | --- |
| `mouth` outer ring, every frame | node `:mouth`, `[:geom :pts]` dense, `:generated {:by :roto/lips-outer}` |
| `mouth_in`, hidden below aperture | node `:mouth-in`, parent `:mouth`, `[:geom :pts]` dense + `[:vis]` dense |
| `teeth` from image content | node `:teeth`, stencil `:mouth-in`, `[:geom :pts]` dense, `:generated {:by :interior/teeth}` |
| lid rings | nodes `:lid-r/-l`, `[:geom :pts]` dense |
| lash line (`offsetRing`) | not data — a stage-6 parameter on the node, `{:grow px}` |
| iris disc | node `:iris-r`, `:kind :disc`, parent `:lid-r`, stencil `:sclera-r`, `[:xform :pos]` dense (quantised at freeze), radius framed |
| square pupil | node `:pupil-r`, `:kind :rect`, parent `:iris-r`, stencil `:iris-r` |
| brow ring + quantised raise | node `:brow-r`, `[:geom :pts]` dense (the traced ring with height removed), `[:xform :pos]` dense (the quantised raise). **The decomposition design.md insists on is two channels.** |
| head plate, kept frames | node `:head`, `:symbol` per instance, keys on `[:symbol]` at kept frames |
| `makeXform` face-oval crop | **gone.** Placement is `[:xform :*]` on the face's own `:place`; the stage clips |
| `stabilize` transforms | dense `[:xform :*]` on `:head` (the inverse fit) and on its `:plate` (the fit), read where `:reads` and `:time :holds` say |
| registered underlay | the face's `:plate`, an instance of the footage's tracing symbol under `:head`; a `:trace` op the raster never sees |
| painted background cel | node per layer, `[:geom :pts]` **framed**, `[:style :color]` framed |
| `mouth lead` | `:time {:offset k}` on performance nodes only |
| `exposure` | `:time {:expose n}` on the clip root, inherited |
| picture fps | resolver samples marked generated channels at the picture rate; authored keys keep their own time |
| hand correction | an `:over` layer, `:offset` or `:replace` |
The brow row is the one worth looking at twice. `docs/design.md` argues at length
that the traced ring already contains the height, so the quantised raise must be
measured *out* and put *back* or the brow moves twice. In this model that is not
an argument to remember — it is two channels on one node, and getting it wrong
would mean writing the height into both.
## Palettes
Three levels, and keeping them apart is what makes a palette swap a
**reinterpretation** rather than an edit:
| Level | Holds | Lives on |
| --- | --- | --- |
| **tone** | which mark this is — `:skin-dark` | `[:style :color]`, a channel on the node |
| **ramp** | what that tone looks like *here* | `:palette`, a channel on the timeline |
| **the ramps** | every named palette | the project |
A node names a **tone**, never a colour and never a ramp. Which ramp the tone is
read in is decided by the timeline the node is in. So the same drawing reads day
or night without one stored value changing — which is the entire payoff of
indexed colour, and is why `docs/design.md` forbids sampled RGB: once a shape
holds a measured colour there is nothing left to reinterpret.
Named palettes are **variants over one tone vocabulary**, not arbitrary colour
lists. `:day` and `:night` both define `:skin-dark`; that is what keeps a swap
total and keeps `docs/design.md`'s closed vocabulary closed. A tone the ramp in
scope does not define resolves to the loud magenta, like any other missing index.
### The scope rule
`:palette-track` points to an ordinary lane symbol. Its clips are instances of
restricted palette symbols: a palette symbol owns no nodes and points at exactly
one project palette.
```clojure
{:id :shot :frames 91 :palette :day :palette-track :shot-palettes :nodes {...}}
{:id :shot-palettes :type :palette-track :display :lane :frames 91
:nodes {:day-clip {:kind :instance :source {:symbol :day-palette} ...}
:dusk-clip {:kind :instance :source {:symbol :dusk-palette} ...}}}
{:id :day-palette :type :palette :palette-ref :day :frames 1 :nodes {}}
```
`:palette` is the symbol's authoring/preview palette. It seeds evaluation only
when that symbol is the viewed root; nested symbols do not replace the root's
choice merely because they were authored under another ramp. When absent, the
project default seeds evaluation.
Covered clips of the viewed root's palette track override that seed. An
uncovered lane interval is a genuine gap, restoring the authoring palette or
project default. Palette clips use the same trim, roll, slide, claim-time and
undo commands as visual clips; palette code does not duplicate those edits.
Thus palette-track coverage, authoring preview, and project fallback are
separate facts rather than three accidental meanings of one field. There is no
second keyed palette control on symbols or instances: time-varying palette
changes are authored only as clips in the palette lane.
### One index space, partitioned by palette
A raster is one `Uint8Array` and an index means one colour in it, so two ramps in
one frame cannot both own index 2. The resolution: **the output index space is
the concatenation of the named palettes**, and a tone resolves to
`palette-base + tone-index`.
Everything downstream is then unchanged — one buffer, one flat table for
`->rgba`, no per-frame palette construction, and an index does not change meaning
between frames, so bakes and thumbnails stay valid.
Two consequences worth stating rather than discovering:
- **The limit is real and reachable.** 256 indices over a nine-tone vocabulary is
twenty-eight palettes. Detect it and say so; do not let it arrive as wrapped
colour.
- **It makes the stencil sharper.** A stencil is a colour key, so two nodes
sharing a tone share a stencil — a genuine weakness of the technique.
Partitioning the index space by palette means two nodes in *different* palettes
no longer collide at all, and the resolved stencil picks up whichever index the
stencil node actually drew in.
### Where it is resolved
At the op boundary, and nowhere else. `[:style :color]` holds a keyword all the
way through evaluation; the walk carries the palette in scope the same way it
carries the parent transform and the local frame; the op carries a resolved
index. The rasteriser never sees a tone name and the node never sees an index.
This also means the palette is a **parameter of evaluation**, not a global. The
resolver takes it alongside the store.
## Format on disk and on the wire
Tier 1 is EDN/transit: the node tree, channel definitions, framed values, keys,
layers, library. Kilobytes, human-readable, diffable, and leaf-addressable for
sync.
Dense blocks are separate content-addressed binaries — `Int16Array` for
geometry, `Float32Array` for transforms — with a small header naming the channel
path, frame count, stride, and the **fixed-point scale** of the node-local space
the block is in. Geometry is stored in the node's own space, not in raster space;
see "What space geometry is in".
**Not Lottie internally**, despite the property shape being borrowed from it.
Lottie has no palette-indexed colour, its shapes are bezier with in/out tangents
where these are integer polygons, and its interpolation defaults are the opposite
of what is wanted. It is a fine thing to write out one day and a bad thing to
store.
Output is deliberately not specified here. The target is encoding video in the
browser, which touches the op list and nothing above it — a writer consumes
frames, and frames are what stage 7 already produces.
## Deferred
- **Per-key easing.** The structure allows it; nothing should use it until a
parented transform on a painted cel asks for it.
- **More than two channel layers.** The `:over` vector is already a list; a real
blend stack with weights is the NLA, and it is not needed to fix a bad frame.
- **Skew beyond the field.** `[:xform :skew]` is in the transform and in the
composition order from the start, because adding a component to a decomposition
later means migrating every stored transform.
- **Instance channel overrides on symbols.** Compose-over is specified; only
colour and transform need it at first.
- **Constraints and drivers.** Blender's other half. A gaze that aims at a null
object is the obvious first one, and it is a long way off. Until then the one
gaze shared by two iris nodes is a UI rule, not a stored relationship — see
"One signal, two nodes".

1059
docs/architecture.md Normal file

File diff suppressed because it is too large Load diff

135
docs/clipboard-plan.md Normal file
View file

@ -0,0 +1,135 @@
# Selection and clipboard
This is the implementation contract for multi-selection, copy, cut, paste,
duplicate, and duplicate unique. It deliberately replaces any incidental older
behavior. The document model is the authority; the stage and timeline are two
views of the same editor state.
## One selection
`[:ui :selections]` is the ordered selection set. Its last member is the primary
selection in `[:ui :selection]`, used by the inspector and single-subject tools.
Every member is an occurrence address:
```clojure
[:node owner-symbol-id node-id row-path]
```
The row path distinguishes two occurrences of shared content. Commands that
write the document canonicalize those addresses before acting:
- invalid and non-node addresses are ignored;
- the same owned node, `[owner-symbol-id node-id]`, is acted on once;
- when one selected row path is below another selected row path, only the
ancestor is a clipboard root. Its ordinary parent-pointer subtree comes with
it, and an instance already displays the symbol it references, so also
materializing the visibly nested selection would duplicate it twice.
Plain click replaces the selection. Shift-click toggles membership, on both the
stage and timeline. A stage marquee replaces, or with Shift adds to, the same
set. Timeline rows, bars, and cel blocks render membership from that same set;
the primary member gets the inspector/focus treatment.
Creation targeting is derived rather than stored. The primary (last-selected)
occurrence is the preferred row; the playhead validates it and, when necessary,
walks outward to the nearest valid containing occurrence. A target is singular
even when selection is plural. See `docs/creating-in.md` for the resolver
contract.
## Clipboard value
The clipboard is editor state, not document state and not history. Copy records
a detached snapshot of each canonical root and its complete parent-pointer
subtree. It records source occurrence paths and authored node data, but normal
copy deliberately keeps referenced symbol identities. Therefore a pasted
instance is another use of the same symbol. The clipboard survives cutting its
nodes because it contains the node snapshot, not merely their addresses.
This first implementation is the application's clipboard, not the operating
system clipboard. It is consequently project-local and has no serialization or
cross-project identity collision policy hidden inside it.
## Paste
Paste resolves one destination from the primary selection and playhead. An
ordinary occurrence is usable only while the playhead maps through every
enclosing occurrence and lies within its extent. Otherwise resolution walks
outward, with the open symbol as the total fallback. A selected lane is an
insertion surface only while all occurrences enclosing its parent symbol are
valid. Multi-selection supplies one ordered payload, not several destinations;
only its primary member anchors this resolution.
The earliest finite start among the copied roots is aligned with the playhead
in the destination. All other root starts retain their offset from it. Roots
without a finite span remain timeless; paste does not invent a span for a shape
that was authored for the whole symbol. Parent/child timing, transforms,
channels, corrections, playback, stencil links, and relative root order are
otherwise copied exactly. Every node receives a new identity, and all internal
parent and stencil references are remapped.
In an ordinary composition overlap is valid. In a lane the pasted finite spans
claim their intervals using the lane's existing overwrite rule: covered cels
are removed, crossing cels are trimmed or split, and the pasted roots do not
overlap one another. Pasting a timeless root into a lane is refused. Validation
is all-or-nothing.
After paste, the new roots are the selection, in clipboard order, and the last
one is primary.
## Cut
Cut first takes exactly the same snapshot as copy, then deletes every canonical
root and its parent-pointer subtree. The clipboard write is editor state; the
whole document deletion is one history transaction. The selection is cleared.
Undo restores the deleted document nodes. It does not roll back the clipboard,
which matches ordinary editor behavior.
## Duplicate and Duplicate Unique
Duplicate does not read the insertion target or playhead. It is a local
operation beside the selected material:
- in an ordinary composition, copies keep the originals' parent, transform,
timing, and span, and are stacked immediately in front;
- for direct children of a lane, the selected temporal envelope is repeated
immediately after itself. Relative timing and gaps inside the selected set are
preserved, and later cels ripple forward by the envelope duration. This is
the lane's useful "duplicate forward" behavior; it is not a second command.
Selections in several owners are handled per owner in one command. Thus two
lane selections repeat in their respective lanes, while a selected composition
node duplicates in place, all as one history step.
Normal Duplicate preserves symbol references, just like normal copy/paste.
Duplicate Unique performs the same placement but deep-copies the complete graph
of every referenced symbol. One shared remap table is used for the whole batch,
so two duplicated instances that shared a nested part still share one new copy
with each other, while sharing nothing mutable with the originals. Immutable
media/store blocks may remain shared.
After either duplicate command, the new roots replace the selection.
## History, refusal, and stale state
Cut, paste, duplicate, and duplicate unique each call one domain command and
commit through one `edit/transaction`; each is exactly one undo/redo step no
matter how many nodes or symbols it touches. Copy and selection do not
touch history. A refusal changes no document leaves and creates no history step.
Undo/redo filters the complete selection set against the restored document and
repairs the primary selection. Clipboard payloads remain snapshots. Paste
validates the resolved destination and all remapped references at commit time,
so a stale selection or a newly impossible symbol cycle refuses rather than
partially editing.
## Required tests
Domain tests cover canonical ancestor/descendant selection, subtree ID remaps,
normal shared references, deep unique graph remaps, multi-root relative timing,
composition overlap, lane overwrite, lane forward duplication and ripple,
mixed-owner duplication, cycle refusal, stale targets, and all-or-nothing
failure. Event tests cover copy without history, atomic cut/paste/duplicates,
resulting multi-selection, target fallback at the playhead,
and one undo plus redo of each mutation. Browser tests cover mirrored stage and
timeline selection, Shift-toggle on labels/bars/cels, singular derived creation
targeting, and the keyboard commands.

View file

@ -0,0 +1,234 @@
# Correction authoring implementation plan
Written against `2f1c9b9` (2026-09-30), following the lane handoff in
`7a54bfc`. Implemented on `codex/correction-authoring`; this now records the
scope and acceptance criteria of that implementation.
The cel-sheet targeting work described below was removed with the cel-sheet UI
on 2026-10-01; it remains here only as history of that implementation.
Read [lane-handoff.md](lane-handoff.md) and the correction section of
[lane-model.md](lane-model.md) first. Their ownership and document rules remain
the foundation. The choices below settle the first implementation's scope.
## Outcome
A person can select a lane or cel, specify a range, and apply Constant
adjustment, Ramp, or Return motion to rotation or position. The result is one
correction layer and one undo step. It works from either timing view. A lane
correction crosses drawing boundaries; a cel correction travels with its cel.
Regeneration preserves the hand work and presents incompatible layers for an
explicit decision. Frames outside the support evaluate exactly as before.
Finish this vertical slice before adding more property types or gestures.
Numeric range fields and an Apply button are sufficient for this pass. Dragging
a range or manipulating a peak on the stage can later issue the same command.
## 1. Fix sheet targeting first
In `ui/timeline.cljs`, `cel-sheet` currently drops each lane row's `:select`.
An occupied cell selects its cel; a gap only seeks, leaving the previous target
selected. Thus clicking lane B's gap after selecting lane A can send an insert
or overwrite to A.
Carry the row's complete selection address into its column and gap cells.
An occupied cell selects its cel; a gap selects its lane. Make the column header
select the lane too: this is the explicit way to author across drawings.
Preserve full paths, not just `(peek path)`, as view identity. Keep seek and
selection dispatch order deterministic.
Add a two-lane browser case: select A, click a gap in B, overwrite, assert that
only B changes, and undo once. Add a header-selection assertion. Retain the
existing occupied-cell hold test. Do not redesign sheet rendering in this step.
## 2. Make stack compatibility consistent
There is a concrete discrepancy at this HEAD:
- `channel/problems` uses `stack-conflict`, accounting for prior replacements.
- `channel/conflicts` and `flow/regenerate.cljs`'s `rebased` use
`conflict-with`, comparing an offset directly with the base.
A two-component base, a covering three-component replacement, then a
three-component offset is valid and evaluates correctly, but the latter paths
can report or mark that offset incompatible. Conversely a replacement can make
an offset incompatible even when it fits the original base.
Extract one ordered-stack compatibility operation and use it for validation,
conflict discovery, regeneration, and resolution. Keep `conflict-with` if useful
for the narrower question its name/docstring describe. Do not use it alone to
decide whether a stacked layer is applicable.
Compute compatibility against the values that can actually reach a layer over
its support. Partition at overlapping support boundaries if needed: two adjacent
replacements can jointly cover an offset even though neither covers it alone.
An empty replacement channel does not supply a value and must not erase the
possible input shape. Preserve the evaluator's absence behavior. Explicitly
marked conflicts are skipped, so later layers must be checked against the stack
that actually runs. During regeneration, recompute compatibility in order, using
each preceding layer's resulting active/conflicted state. Preserve IDs, values,
support, and order; update compatibility reasons without dropping hand work.
Use structural shape reasoning for dense data rather than requiring every block
to be sampled. If unknown shape or missing samples limit what can be proven,
retain the current absence contract and document that limit; do not claim an
unconditional proof of runtime safety from incomplete metadata.
Tests: covering replacement of a different shape; partial coverage; adjacent
covering replacements; empty replacement; inactive conflicted replacement;
regeneration changing base shape; and the same cases through cursor evaluation.
Assert that an accepted compatible stack is not listed as a conflict, and that
an incompatible regenerated layer remains persisted but is skipped.
## 3. Pure correction commands
Add `frontend/src/arthur/domain/correction.cljs`. It owns authoring and resolving
corrections; `channel.cljs` continues to own evaluation and compatibility.
Suggested API (names may follow repository conventions):
```clojure
(add clip sid node-id channel-path
{:id layer-id :support [a b] :motion :return
:start 0 :peak angle :peak-frame p})
(remove-layer clip sid node-id channel-path layer-id)
(retry-layer clip sid node-id channel-path layer-id)
```
Return `{:clip updated :selection node-id}` or `{:refused reason}`. IDs come
from the event caller (`random-uuid`), never from the pure command. Reject a nil
ID or one already used within that channel stack. Address layers by the full
symbol/node/channel/layer tuple; no global layer registry is needed.
Resolve the base via `node/channels`, which supplies defaults. A lane with no
explicit rotation channel already has a zero rotation; materialize that channel
with its new `:over`. Preserve every existing base field, generated provenance,
and previous layer. Never route this through a setter that bakes the correction
into base keys. Append to the ordered stack and validate the resulting document.
Do not run correction edits through `lane/finish`, whose extent policy belongs
to cel arrangement. Use `clip/problems` for the candidate document instead.
First authoring properties: `[:xform :rot]` (scalar radians) and `[:xform :pos]`
(two numeric components). First blend operation: `:offset`. UI labels must say
offset/delta, since a target offset of 20 degrees does not mean an absolute
rotation of 20 degrees. Keep existing `:replace` evaluation and loaded stacks;
there is no new replacement-authoring UI in this slice.
All command support endpoints are finite integer OWNER frames, `[a b)`, with
`a < b`. Do not ban negative owner frames merely because displayed shot frames
start at zero. Validate all supplied values for finite numbers and exact shape.
Refuse unknown targets, unsupported properties/motions, malformed ranges, and
incompatible stacks with useful messages. Refusal must not mutate store/history.
Motion construction uses existing channels only:
| Command | Values | Minimum samples |
| --- | --- | --- |
| Constant adjustment | `(ch/framed delta)` | 1 |
| Ramp | `(ch/keyed {a start, (dec b) end} :linear)` | 2 |
| Return motion | `(ch/keyed {a start, p peak, (dec b) start} :linear)` | 3 |
For Return, require integer `a < p < b-1`. Default the UI peak to
`a + floor((b-a-1)/2)`; on an even-length range the earlier middle sample wins.
Expose the peak frame so this is visible and adjustable. Never place an endpoint
at `b`: it is outside the selected samples. `[10 13)` with start 0 and peak 0.5
must yield offsets `0, 0.5, 0` at 10, 11, 12. Support controls the boundary;
there is no need to insert zero keys into the base before/after it.
## 4. Owner and range UI
Add a Corrections section to the right pane (`ui/params.cljs`), extracting a
`ui/corrections.cljs` component if that keeps the pane readable. Provide an
explicit target readout (symbol, lane or cel), Rotation/Position, motion choice,
From/Through fields, relevant value fields, peak frame for Return, and Apply.
Offer a selected cel's owning lane as an explicit target choice. Do not silently
promote a cel edit to a lane edit. Shared drawing content is outside this first
UI; it has different sharing consequences.
For this first pass the range fields explicitly read **owner frames**, with
inclusive From/Through converted to `[from, through+1)`. This is a deliberate
UI scope choice, not a claim that a displayed shot range and owner range are
interchangeable. It lets nested and retimed owners be addressed without an
unproven range conversion. Match the existing zero-based numbering and show the
owner beside the range. The handoff must record that displayed-range dragging
is still outstanding.
Use an explicit three-sample initial draft in owner coordinates; for a cel,
prefer its span start where it is integral. Show the range, allow adjustment,
and do not extend a cel or shot to make the correction visible. Reset the draft
when the target changes. Rotation is shown in degrees and converted to radians
at the event boundary, following the existing inspector convention. Position
uses x/y inputs in the owner's transform coordinates.
Draft inputs must not write document state, start history groups, or invoke the
existing inspector `number-input`'s hold/settle behavior. Apply dispatches one
event; success uses `edit/transaction` once and preserves the selected node's
full address. Do not use a layer ID as node selection. Validate again at Apply,
since the target/document may have changed since the draft was opened.
If adding a “use playhead” convenience, prove its mapping separately. The clock
of a cel's transform is its own node clock, not the drawing source clock selected
by `:playback`. `nest/inside` on the complete cel path enters the source and is
therefore the wrong shortcut. The existing `selection-frame` resolves only the
owning symbol; node/ancestor time conversion remains necessary. Floors, loops,
and nonintegral mappings must never silently snap an authored range. Omit this
convenience rather than expanding the first pass into a new timing system.
## 5. Conflict actions and regeneration proof
Show a document-wide list from `clip/conflicts` in the pane, including symbol,
node, property, layer ID, and reason. Keep it accessible even when a different
node is selected. Also list the selected target's layers in stack order with
support, motion values, and status; do not require a new persisted motion label.
Provide Remove correction and Retry compatibility. Remove is explicit and
undoable; Retry rechecks the complete candidate stack and clears a conflict only
when it is valid. If retry would invalidate a downstream offset, refuse and say
why. Similarly, removing a replacement that makes a later offset invalid must
refuse, rather than commit an invalid document or silently remove more layers.
A retry that changes nothing must not manufacture an undo step.
These are minimal resolution actions, not topology remapping. Automatic geometry
remapping, reordering layers, editing arbitrary stored vector values, and resolving
removed targets are separate work. Preserve all existing generated-data behavior.
Exercise the actual `flow/regenerate.cljs` entry points in integration tests:
author a correction, regenerate compatible base data, and confirm the correction
survives with its ID/support/values intact and affects the new base. Then change
topology on a geometry fixture and assert persisted actionable conflicts. The
geometry fixture can use an existing layer directly; geometry authoring is not
required to expose and resolve a conflict already present in a document.
## 6. Verification and completion
Use focused tests that establish observable promises:
- Domain: each motion's sample values; refusal on short ranges/nonfinite values;
default channel materialization; unchanged base and prior layers; duplicate ID.
- Evaluation: compare before/after on every frame outside support, including
neighboring interpolated frames. Cursor/spec agreement in nonmonotonic order.
- Ownership: a lane Return crosses a drawing boundary; a cel correction moves
with the cel and survives split/trim. Neither alters another use of its drawing.
- Sampling: picture-rate/pose selection changes the generated base frame while
the authored correction still reads owner time. Keep HEAD's regression tests.
- Events: one Apply is one undo step; undo/redo restores complete layer data;
refusal leaves clip/history unchanged; stale target refuses; selection survives.
- Persistence: leaf and Transit round trips retain layers, order, IDs, conflicts.
- Browser: both views can select a target and apply the same correction using
actual controls. Assert evaluated results and history, not only a layer count.
Include the two-lane gap-targeting case from step 1.
- Regeneration and conflict actions: use the real flow and test undoable removal,
valid retry, invalid retry, and removal that would break a downstream layer.
Run the suites documented in `lane-handoff.md`: CLJS tests, lane and take browser
flows, Django tests, and optimized frontend build. Restore the dev app bundle
after the release build. Note that `take.mjs` writes a local project. Report
actual results and any unrun checks; do not copy previous test counts as evidence.
Suggested commit sequence: sheet targeting; consistent stack compatibility;
pure correction commands; pane/events plus browser proof; updated handoff.
Keep each commit coherent and tested. No schema version bump should be needed:
the layers already have a persisted representation.
Update `lane-handoff.md` and `lane-model.md` with what shipped, the owner-frame
range UI limitation, conflict actions available, and verified test counts. Done
means a person can author and undo the correction, regenerate its base, and see
either their preserved edit or a useful conflict. A constructor without reachable
controls, or controls without that regeneration proof, does not finish this work.

155
docs/creating-in.md Normal file
View file

@ -0,0 +1,155 @@
# Creating in
Revised 2026-10-02.
Arthur's creation model is a layer list plus a playhead. The primary active row
is the preferred place for a new thing. The playhead decides whether that row is
present in the current occurrence and supplies the frame at which the thing is
created.
This is editor behavior, not document structure. A saved clip has symbols,
instances and spans; it does not have timeline rows or a creation target.
## Selection and the active row
The selection set names the objects affected by copy, delete and transform. Its
primary member is also the active row, analogous to the active layer in a paint
program. There is no second targeting gesture for a user to maintain.
- Clicking a row label, its timeline body, or the same occurrence on the stage
makes that occurrence primary.
- Shift/marquee selection may retain several objects, but only the primary row
anchors creation.
- Clicking blank stage or timeline space returns the active row to the symbol in
the current tab.
- Row disclosure is independent. Selecting or creating never implicitly expands
a row.
The selection does not change merely because the playhead moves. An off-frame
object remains available for copying, deletion and inspection.
## Resolving the active row at the playhead
Resolution starts with the structural destination implied by the primary row:
- A lane row means the lane symbol itself.
- An ordinary symbol-instance row means the symbol placed by that occurrence.
- A non-instance row means the symbol containing that node.
- No row means the symbol in the current tab.
For an ordinary symbol occurrence, the playhead must be within that occurrence's
extent in the current nested context. Extents are tested after walking all parent
time maps and use the half-open interval `[in, out)`. If the preferred occurrence
is not present, resolution walks outward to the nearest parent whose occurrence
is present. A containing lane is therefore the natural fallback from an inactive
cel. If no nested occurrence is present, the current tab is the destination.
The preferred row is retained during fallback. Scrubbing back into its extent
makes it the effective destination again.
A lane differs only in what its row means. It is an insertion surface across its
containing timeline and does not require an existing cel under the playhead. A
cel within the lane is still an ordinary symbol occurrence with an extent.
For `A -> B -> lane C -> cel D`:
- over D, with D primary, creation happens inside D;
- past D but while C is available, creation inserts a new cel in C;
- outside C's containing occurrence but inside B, creation happens inside B;
- outside every nested occurrence, creation happens in A, the current tab.
## What creation does
Once resolved, every creation command follows the destination kind.
### Lane destination
- A new or dropped symbol is instantiated directly in the lane at the playhead.
- Its interval claims that time. Existing cels under the interval are removed or
trimmed by the lane's ordinary claim-time command.
- Beginning a drawing creates a new one-frame drawing symbol in the lane and the
finished shape is a child of that drawing.
- Selecting an existing cel changes the destination from the lane to the symbol
placed by that cel; subsequent symbols and shapes become children there.
Double-clicking a lane creates an empty cel at the playhead; beginning a drawing
creates a drawing cel there. These are the same lane-creation operation with
different payloads. The pointer chooses the lane, never a second creation time.
The resulting cel is selected, so it immediately becomes the preferred target:
drawing again enters that cel's symbol instead of replacing it.
Thus no separate "new cel" versus "add inside" mode is needed. Selecting the
lane header says new cel; selecting a cel says add inside.
### Ordinary symbol destination
- A new symbol is instantiated as a child at the mapped playhead frame.
- A new shape is authored directly in the symbol at that frame.
- Stage coordinates are transformed through the occurrence into the destination
symbol's local coordinates.
### Explicit timeline drop
A timeline drop uses the row and frame under the pointer, not the stored active
row and playhead. Dropping onto a lane therefore always instantiates in that lane
and claims the pointer's interval. A stage drop uses the resolved active row and
the playhead.
Paste, imported symbols and converted footage obey the same resolver as direct
creation. They must not each reconstruct nesting or extent fallback separately.
## Multi-selection and paste
Multi-selection does not create multiple insertion targets. Copy and cut take
the canonical forest of selected roots as one ordered payload; the primary
(last-selected) occurrence alone supplies the preferred row for a later paste.
At paste time that row and the current playhead resolve one effective
destination, and every root in the payload is inserted there in one transaction.
The earliest finite root start is aligned to the destination frame. Other roots
keep their timing offsets, hierarchy, and clipboard order. In an ordinary symbol
the roots may overlap. In a lane every root must have a finite span and the
payload's root spans must not overlap one another; valid spans claim their times
and trim or remove existing cels as a batch. An invalid member refuses the whole
paste rather than inserting a partial payload.
After paste, all new roots form the selection and the final root is primary, so
it becomes the preferred row for the next creation. Pasting one payload into
several selected destinations is intentionally not implicit: that would be a
separate distribute command. Duplicate is also distinct from paste—it stays
beside each source in its original owner and does not consult the playhead or
creation target.
## Palette lanes
Palette lanes use the same row-and-playhead resolution. Their content filter and
transition command remain palette-specific: only palettes and palette
transitions can be inserted there. This is a type restriction, not a second
targeting model.
## Implementation boundary
One pure resolver returns the effective destination:
```clojure
{:kind :lane | :symbol
:sid destination-symbol
:path effective-occurrence-path
:frame destination-local-frame
:matrix destination-to-current-tab-transform}
```
Callers may add the unchanged document as `:clip` or rename `:frame` to `:at`,
but they must not reinterpret the active row. Polygon creation, symbol creation,
stage drops, paste, import and footage conversion all consume this answer.
Explicit timeline drops use the same structural row rule with the row and frame
under the pointer; unlike playhead resolution, an invalid pointer destination is
refused rather than allowed to fall outward.
The invariants are:
1. The primary row is the preferred structural destination.
2. The playhead validates occurrences and supplies creation time.
3. Inactive targets fall outward; selection does not follow them.
4. A lane row inserts a cel, while a cel row enters its symbol.
5. Stage creation uses the playhead; timeline drops use pointer time.

510
docs/frame-selection.md Normal file
View file

@ -0,0 +1,510 @@
# Frame selection
> Since `docs/tracing-symbol-plan.md`: `domain/trace` is gone. Trace keys are
> the face's `:plate` placement's `:time :holds`, the origin is the head's
> `:reads`, and the photo's registration is the plate's own measured channels.
> Where this document names `trace/measured-local` or `trace/prepare`, read the
> plate's or the head's measured channels and `node/hold`.
Two mechanisms. One vocabulary. An earlier draft of this document claimed they
were one component used twice — because `suggestPlateFrames` in the old
`js/pipeline.js` and the never-built "performance poses" of
[timing-handoff](timing-handoff.md) looked like the same function — and that claim
is wrong. They share how a selection is *read* and how the hand overrides one.
They do not share how frames get chosen, because the two are answering questions
of different shapes.
[Time selection](time.md) is the floor both stand on: an output frame reads the
latest native frame at or before its time, and nothing rewrites the dense
measurements. That is a *cadence*: an answer with no opinion about content. It
cannot know that the one frame where the eye is fully closed is worth more than
its neighbours, so at 12fps out of 30 it drops that frame two times in three.
This document is how the picture gets an opinion.
## The one idea
**A selection is a set of frames chosen out of a dense measurement, and read by
holding the latest one at or before now.**
The holding half already exists and is already shared: `pose/held-frame` is called
by `node/hold` (a placement's `:time :holds`) and by `pose/source-frame`, which is the two sites agreeing
about reading. The hand half is shared too — see *Three layers* below. Choosing is
what differs.
| | Plate drawings (tracing) | Performance poses |
| --- | --- | --- |
| the question | which frames does an artist have to draw a head on? | which frames does the picture change a shape on? |
| the cost being managed | a person drawing | a pose looking wrong |
| signal | the measured head's motion | — none; a stored cut |
| the baseline it improves on | drawing on 2s | the cadence, or the exposure grid |
| the shape of the answer | a non-uniform set out of dense | the same grid, nudged |
| lives on | the face's `:plate` `:time :holds` | the instance's `:playback :tracks` |
| hand edit today | `::project/toggle-hold` | `pose/put-cut` / `pose/remove-cut` |
| UI today | `params/layer-section` | **none** |
| proposes today | **nothing** | **nothing** |
## Why they are not one function
A plate selection has to be **non-uniform**, and that is the whole reason it
exists. A head still for sixty frames and then whipping across in ten wants two
drawings for the first stretch and eight for the second. Drawing on 2s gives
thirty-five drawings, most of them identical, and no amount of nudging a uniform
grid will produce the distribution that is wanted — the spacing itself is the
answer. That is what the prototype's walk was for, and it is why a cost knob
(`:tolerance`) belongs on this side: the artist is buying drawings.
A performance selection is **not choosing sparseness at all**. The output rate or
the exposure setting has already chosen it. The question left over is only *which*
native frame each already-decided slot reads, and the failure it fixes is narrow:
a slot landing one or two frames off the closure. Nudging the grid is the right
size of answer, and there is nothing for a tolerance to mean.
There is a second, harder reason, and it is the one that settles it:
**A selection cannot put a frame on screen that the output grid never samples.**
At 12fps out of 30, output frame 5 reads native 12 and output frame 6 reads native
15. A closure at native 13 is *between* them. Protecting frame 13 in a set of
kept frames makes it available and makes it the frame held across 13 and 14 in
native space — and at a 12fps output it still never appears, exactly as
[time.md](time.md) says: an event between output frames cannot create an extra
frame in a 12fps output. Only moving what output frame 6 reads can show it. So
the performance side has to act on the grid, not on a set beside it.
## Three layers, and the middle one is derived
The trap this is designed around is stated in
[timing-handoff](timing-handoff.md) and is worth repeating because it is the
only hard rule here:
> Store manual edits separately from generated proposals so changing the rate or
> tolerance retains hand decisions.
So a selection is:
```clojure
{:policy {:tolerance 0.02} ; what the proposer was asked for
:keep #{47} ; frames the hand insists on
:drop #{30}} ; frames the hand refuses
```
and the effective set is `(proposed ∪ keep) \ drop`, always containing frame 0.
`:keep` and `:drop` are the document. The proposal is not: it is recomputed from
`:policy` and the dense signal whenever either changes. **Re-suggesting at a new
tolerance must never cost somebody their pinned blink**, and that is the entire
reason the hand decisions are stored as their own two sets rather than as the
resulting frame list.
This layering is the part that really is shared. On the performance side there is
no `:policy` worth storing — the grid is the policy — but `:keep` and `:drop` mean
exactly what they mean on the plate side, and `select/effective` is the one
implementation for both. A preserve mark *is* a keep.
### Materialise the result, do not derive it on the render path
The effective set is written back to where each site already reads it —
the plate's `:time :holds`, or the pose track — so that every existing reader is untouched
and nothing on the per-frame path has to open a dense block. Proposing is a
command, not a subscription. `ch/value-at` allocates per call and says so; that
is fine for a button press over a few hundred frames and would not be fine at
30fps.
This means the stored frame list is redundant with `policy + keep + drop`. That
is deliberate and it is the cheap direction of the trade: a stale list is
recoverable by pressing Suggest again, and a dense read per node per frame is
not recoverable at all.
## The plate selection: a non-uniform chooser
`arthur.domain.select`, built — see *What is revertible* for the one part of it
that is not yet wanted. Pure, no store access, no clip access: the site hands it a
signal it has already read.
```clojure
(defn propose
"Frames worth keeping out of `n`, given `signal`."
[n signal {:keys [tolerance protect]}])
(defn effective
"`proposed` with the hand's decisions applied. Always contains 0."
[proposed keep drop])
```
`propose` is the prototype's walk, generalised off landmarks:
1. keep frame 0, make it the anchor;
2. settle the protected frames from `:protect` *before* walking;
3. for each later frame, keep it when it is protected, or when
`distance(anchor, f) > tolerance`. Either way it becomes the anchor.
`distance` is the max absolute difference over components, so a signal of
landmark pairs and a signal of one number both work without the caller saying
which it handed over. Every sample in a signal is the same width, and a signal of
two widths is refused: comparing the prefix two samples happen to share would let
a reader that drops a component read as no movement at all.
**A protected frame anchors the walk like any other kept frame**, which is why
protection is settled first and is not unioned onto the walk's result. The anchor
is what is on screen; once a protected frame is kept the viewer is looking at it,
so measuring the next frame's drift from a frame no longer displayed is wrong.
**An absent measurement is `nil`, and converting to that is the reader's job.** A
frame where the face was not found says nothing about the signal: it cannot move
the anchor and it is not a frame worth keeping. `trace/measured-local` already
returns nil there. A protected frame with no measurement is still kept — a plate
frame is a frame somebody draws on whether or not the detector found a face.
**A non-finite tolerance falls back to nought.** A cleared slider reads as NaN and
every comparison against NaN is false, which taken literally proposes frame 0
alone and collapses the whole take to one drawing. Nought proposes every frame
that changes, which is merely the baseline back again: wrong in a way somebody can
see and undo.
### The signal
The head's measured transform, applied to a fixed reference quad, giving
displacement in stage units — so a tolerance means "the head has moved this far"
and is a number a person can reason about. `trace/measured-local` already builds
that matrix per frame and is private; make it public rather than writing a second
one. Map it over the frames and transform four corners through `node/apply-pt!`.
The quad's size is a real parameter hiding in the word "fixed": it sets how much
rotation registers against translation. Give it a name and a comment rather than
an inline literal.
Do not reach for raw landmarks. The prototype used them because it had them lying
around; the transform is what the drawing actually follows, it is already on the
node, and it is three channels instead of a block.
### The upgrade path, and do not start here
The greedy walk is order-dependent and slightly suboptimal. The optimal version
is a dynamic program — choose `k` frames minimising held-reconstruction error,
which is textbook segmented least squares and is O(n²k), nothing at n≈300 — and
it keeps extrema *for free*, because an extremum is exactly where a zero-order
hold is most wrong.
Build the greedy one first anyway. It is proven, it shipped in the prototype, and
having two implementations to compare is how the DP gets tested. Swap it behind
`propose` afterwards, where the signature already permits it.
Most of `select_test` pins the greedy walk's exact output, deliberately, for that
comparison — so expect to rewrite those expectations when the DP lands, and keep
them as greedy-specific tests rather than deleting them. The assertion that is a
*spec* rather than a pinned vector, and should be written on this side before the
swap, is:
> reading the signal through the selection, held, never differs from the dense
> measurement by more than `tolerance`
That is what makes the tolerance number mean something to a person. It is true of
the greedy walk by construction and it is what the DP optimises, so it survives
the swap untouched.
## The performance selection: a preserve-snap on the grid
Not built. The rule is ten lines; getting the two halves of it into the same place
is the work. An earlier draft of this section said "about thirty lines" and that
was understated — see *Where it goes* below.
A grid slot already picks a native frame — `cadence/frame` for the output rate, or
the exposure fold for a deliberate hold at full rate. Write `d(k)` for the native
frame slot `k` defaults to. Slot `k` is the first slot to cover everything in
`(d(k-1), d(k)]`, and the frames strictly inside that interval are the ones the
grid shows to nobody. So:
> **Slot `k` reads the latest preserved frame in `(d(k-1), d(k)]`, and `d(k)` when
> there is none.**
That is the whole mechanism. It recovers a dropped frame out of the slot's own gap,
it can never read a frame another slot already showed, and it cannot reach past
`d(k)`.
**Snap backward only, never forward**, and note which direction that actually is,
because it is easy to get backwards. The closure at native 13 in a 12-from-30
output is recovered by slot **6** — whose default is 15 — reading 13. It is *not*
recovered by slot 5, whose default is 12, reaching forward to 13: slot 5's instant
is 5/12s = 0.4167s and native 13's is 13/30s = 0.4333s, so that would show the
closure 17ms before the mouth shut. `cadence/frame`'s contract is the latest native
frame at or before the slot's time and `cadence_test` asserts
`selected <= f*native/grid` over every grid and native pair, so reaching forward
breaks a tested invariant as well as the no-lead rule. Reading 13 at slot 6 shows
the closure two native frames late, which is the same lateness every hold already
has.
**The marks come from a cut that is already stored.** `flow/freeze` computes both
closures and keys them as `[:vis]`, with the thresholding and hysteresis already
decided:
- the mouth, from the aperture relative to the take's peak — `[:vis]` on
`:mouth-in`, provenance `:roto/mouth-aperture` (`freeze.cljs:473`);
- the eyes, from `condition/resolve-blink` with its cut, dwell and hold — `[:vis]`
keyed per eye part, provenance `:roto/blink` (`freeze.cljs:533`).
So "preserve the frames the cut says shut" reads what the document already holds.
No new signal, no new dense track, no threshold decided twice. Brows have no
closure and get no marks, which is correct: there is no extreme brow position
worth protecting.
**Precompute the mark set when the resolver is built**, the way `pose/prepare` and
`trace/prepare` already do (`symbol.cljs:420`, `:470`, `:536`). A `[:vis]` channel
is keys, not dense, so reading it per node per frame would be cheap — but the snap
also needs the marks sorted for a backward lookup, and building that per frame is
the one thing [animation-model.md](animation-model.md) and the render-path note
above both forbid.
### Where it goes, and why it is not a one-liner
The rule needs two things that currently live at opposite ends of the resolver:
- **the slot interval** `(d(k-1), d(k)]`, which needs the output slot index and the
fps ratio. Both exist at `clip.cljs:261`, the single place the grid becomes a
native frame: `(cadence/frame f (or (:grid-fps opts) (:fps clip)) (fps clip sid))`.
- **the marks**, which are per pose group, and so belong where group identity
exists: `symbol/base-channel-frame` (`symbol.cljs:399`), whose `:pose-sampled?`
branch already calls `pose/source-frame` with `(js/Math.floor lf)` as the default
pose. **That default is the snap's seat.** Replacing it leaves an explicit hand
cut winning over a snap, which is correct — manual precedence is absolute — and
costs no new plumbing on the pose side, because per-group choices are already
threaded and already prepared.
By the time control reaches `base-channel-frame` there is only `lf`, a native local
frame that placement and retime have already been through, so the slot interval
cannot be recovered there. It has to be threaded down from `clip.cljs:261`
alongside the frame. That is the actual work of this step: two namespaces' internal
signatures, not a drop-in.
**Do not take the shortcut of snapping at `clip.cljs:261` itself.** It is right
there, it needs no threading, and it is wrong: one native frame per output frame
means the *whole picture* reads 13 instead of 15, so the head goes two frames stale
for one output frame to fix the mouth. At 12fps that is a 67ms hitch on a moving
head, and it fights the trace selection, which has its own opinion about which head
frame to show. The snap is per group because the thing being recovered is one
group's closure.
### Why not an aperture signal
An earlier draft had this side read the group's aperture as a single-component
signal and hand it to `propose` with `:protect :extrema`. It cannot. The aperture
is the separation of landmarks 13 and 14, at positions 5 and 15 of the 20-slot
`LIPS-INNER` ring, and `freeze/rings->flat` subsamples the ring to the `verts`
budget: those two positions survive only when `verts` is a multiple of four. At
`verts` 6, 10, 14 and 18 — all legal, all even — they are not in the stored data
at all. `ring/subsample-slots` used to claim otherwise and has been corrected.
`flow/measure/mouth` does compute the exact scalar and `freeze` does throw it away
after thresholding, so storing it was an option. Reading the cut is strictly less
work and decides nothing twice.
## How the two interact
They are keyed in **the same frame space**: `clip/resolver` hands an instance's
`:playback :tracks` down into the child it places, and `symbol/base-channel-frame`
reads both the trace and the pose choices at `lf`, the node's local frame inside
that face. A trace frame and a pose cut are the same kind of number.
What differs is the owner, and that asymmetry is load-bearing:
- the **trace** is the face's, on its `:head` — every instance of that face shares
it, because it says how the drawings were made;
- the **pose tracks** are the instance's — two placements of one face can be
timed differently.
### The hazard, which is already written down
[animation-model.md](animation-model.md) states it for exposure and it is the
same hazard here:
> a head cutting on odd frames against a mouth cutting on even ones reads as two
> performances
**It is not a correctness problem.** The mouth is a child of `:head` and its
geometry is stored head-local, so a mouth from frame 17 composed onto a head held
at frame 12 is exactly lip-sync on a held drawing — the decomposition already
decoupled them and nothing is geometrically wrong. The problem is perceptual, and
perceptual problems want a constraint rather than a repair.
### Nest them, do not couple them
The two are not peers. One is coarse and expensive — the prototype's own comment
says it: *"The cost being managed is an artist drawing a head, which is why the
signal is head pose and not the mouth — the mouth is traced and free."* The other
is fine and cheap.
So the rule is a subset, in one direction only:
**Every kept plate frame is a preserved frame of the performance selection.**
When the drawing changes, the performance changes with it, so the two can never
cut against each other on neighbouring frames. The mouth stays free to change on
frames where the head does not, which is what shooting a held drawing with a live
mouth *is*.
This is why the preserve set is a set and not a closure predicate: plate frames
and shut-mouth frames go into the same pile, and the snap does not care which is
which. It needs no new mechanism on either side.
The reverse is a suggestion and never automatic. A mouth closure is a reasonable
place to want a new drawing, but proposing one spends somebody's afternoon. Offer
it; do not take it.
It also composes with `:origin` for free. A head on `:continuous` has opted out
of its own selection, so there are no plate frames to preserve and the constraint
is vacuous — which is correct, because a continuously moving head cannot cut
against anything.
### Between the kept frames is a third shared field
The head's `:reads` (once `:trace :origin`) is not a tracing setting. It is the answer to *what happens
between kept frames*, and the plate selection has to answer it:
- `:continuous` — ignore the selection for this purpose and read the frame you
are on,
- `:keys` — jump to each kept frame and hold it, a hold and not a tween,
- `:start` — hold the first forever.
The performance side does not need the field: a snap picks which frame a slot
reads and the grid does the holding, so `:keys` is the only behaviour there is.
Do not rename `:origin` to match anything: for a head it genuinely means where the
face's origin goes, and a saved field in a shipped UI is not worth churning for a
vocabulary tidy.
## The modes
One setting, two states, and **"manual" is not a third state.**
- **off** — the cadence alone, which is what ships today. The output frame
reads the latest native frame at or before it, and nothing has an opinion.
- **smart frame picking** — on the plate side the proposal is live, and `:policy`
holds the tolerance; on the performance side the snap is active.
Hand keeps and drops apply in **both** states, which is why they are not a mode:
turning smart picking off must not throw away the frames somebody pinned, and
pinning a frame with smart picking off is a perfectly reasonable thing to want.
Manual precedence is absolute — a drop beats a proposal, always.
The name on the toggle should be the same word in both sections. "Smart frame
picking" is fine. What it must not be is two different names for the one idea,
which is how these became two features the first time. That the two sections are
now backed by different code is an implementation fact and must not reach the UI.
## The UI
One component rendered twice, in `ui/params.cljs`:
```
smart frame picking [ off | on ]
tolerance [ ----•------- ] 0.02 (plate section only)
[ Suggest ]
frames 0 12 30 47* 61 (* = kept by hand, strikethrough = dropped)
```
The frame strip already exists in miniature — `params/trace-keys` draws the trace
keys as seek buttons (`params.cljs:386`). Lift it into a shared component and
give it three affordances: click to seek, a modifier to pin, a modifier to drop.
A pinned frame and a proposed frame must be visually distinct, because "will this
survive me moving the slider?" is the question the strip exists to answer.
Render it in two sections:
- **`tracing · <face>`** (`params.cljs:426`), beside the existing origin row,
with the tolerance slider and Suggest.
- **`performance · <group>`** — new, on an instance's inspector, one per pose
group the placed symbol has. No tolerance: the strip shows the preserved frames
and the grid slots that snapped to them.
## Order to build it
1. **`domain/select` with the greedy walk, and its tests.** **Done**, in commit
`02069e8`. Pure, no store, no clip. Note that the extrema part of it is not
wanted by anything below — see *What is revertible*.
2. **The preserve-snap**, pulled forward ahead of the plate side because it is the
half with no UI and nothing proposing today, and because it needs nothing from
the plate side except a fold that can land last. Three pieces: the mark set from
the stored `[:vis]` cuts, built in a prepare step beside `pose/prepare`; the
slot interval threaded from `clip.cljs:261`; the rule itself, seated in
`base-channel-frame`'s default pose. The test that matters is the same synthetic
case `select_test` uses — a one-frame closure at native 13 that a 12-from-30
grid drops — asserted end to end this time: the resolver shows a shut mouth on
exactly one output frame, and the head's frame does not move while it happens.
Write it first.
3. **The plate signal reader.** `trace/head-signal`: make `trace/measured-local`
public, map it over the frames, four corners through `node/apply-pt!`. Test
that it returns a vector of the right length, that a motionless take proposes
`[0]`, and that a stretch where the face was not found becomes `nil` rather
than a pose, a zero or a gap in the vector. Add the held-reconstruction
invariant from *The upgrade path* here, since this is the first place a real
signal exists to assert it over.
4. **Storage for the plate selection.** `:policy`/`:keep`/`:drop` beside
the plate's `:time :holds`. Extend `leaf/leaves` and the key whitelists in the same
commit — a field without a leaf saves silently and comes back missing, which
is the one bug persistence must not be able to have. Round-trip test.
5. **Re-suggest preserves hand decisions.** Propose at one tolerance, pin a frame,
drop a frame, propose at another, assert both survive. Settle here whether the
hand's `:keep` is also fed to `propose` as `:protect`: under the layering as
written it is not, so a pinned frame does not re-anchor the walk even though it
is on screen, which contradicts the anchor rule above. It is the one live
caller for `propose`'s `:protect` frames.
6. **Fold the plate frames into the mark set**, which is the nesting rule and is
one line once both sides exist.
7. **The shared UI component**, then its two mountings.
## What is revertible, and where it is
Commit `02069e8` contains one part that **nothing below asks for**: extrema
detection. Specifically `segments`, `turns`, the `:extrema` branch of `propose`,
the multi-component refusal that exists only to guard it, and three tests —
`a-one-frame-closure-survives-only-because-it-is-protected`,
`extrema-are-turns-worth-more-than-the-tolerance` and
`extrema-are-refused-on-a-multi-component-signal`.
It has no caller because plate selections take no extrema by design — displacement
is the whole story for a head — and the performance side reads a stored cut
instead of finding extrema in a signal. It is correct, tested and speculative.
Revert it if the shape above holds. Keep it if either of these turns out to be
wanted: a group whose extreme is not a closure and therefore has no `[:vis]` cut
to read (a mouth at its widest, a head at the top of a nod), or a take whose mouth
never shuts far enough to cross `aperture-cut`, where a peak-relative threshold
marks nothing and an extremum would still find the most closed frame. Neither is
asked for today. The DP in *The upgrade path* gets extrema for free regardless, so
reverting costs nothing that cannot be had again more cheaply.
## Traps
- **Do not let Suggest write `:keep`.** The proposal and the hand are different
layers; collapsing them is the bug this whole shape exists to avoid, and it
will look like it works right up until somebody moves the tolerance slider.
- **Do not snap a grid slot forward.** Every hold and every pick is "at or
before". Showing a closure before the mouth shut is a lead, which is a different
control for a different reason.
- **Do not quantise a plate selection onto the output grid.** A kept frame is a
native frame and lands where it lands. [time.md](time.md) is explicit that an
event between output frames appears on the next one; forcing kept frames onto
the grid would re-create the problem the selection exists to solve. The snap is
the opposite operation and is on the other side of the fence: it moves the grid's
pick, never the kept frame.
- **Do not read the aperture pair off a subsampled ring.** Positions 5 and 15 of
`LIPS-INNER` survive only at `verts` divisible by four.
- **Do not thin the plate selection with the mouth's.** Two scopes, two owners,
and the nesting runs one way only: plate frames preserve performance frames,
never the reverse.
- **Do not give the performance side a tolerance.** The grid has already chosen
the sparseness. A second knob there would be a control with nothing to control.
- **A skipped frame, a hidden feature and an absent measurement remain three
different facts.** A selection says nothing about visibility and nothing about
whether a face was found. Note that the preserve marks are *derived from* a
visibility cut, which makes this easy to blur: the cut says the interior is
hidden, the mark says the frame is worth landing on, and one is not the other.
## Not in scope
- Automatic *grouping* of related parts beyond the existing `:pose-group`.
Related parts must share one selection — a mouth outline, its interior, the
teeth and the generated visibility reading different frames is the bug that
grouping prevents — and `:pose-group` is where the grouping already lives.
- A per-instance request for a different tolerance than the symbol's. The
resolver threads no such option today and should not grow one until something
needs it.
- Variable frame rate. [time.md](time.md) assumes constant fps and so does this;
a VFR source needs presentation timestamps before any of this means anything.

247
docs/lane-handoff.md Normal file
View file

@ -0,0 +1,247 @@
# Lane and symbol-clip handoff
Status (2026-10-01): the timeline is the one timing interface. A lane is a
generic non-overlapping row of symbol clips; it is not a special drawing type.
Dropping a library symbol makes a naturally playing clip, while creating a new
empty symbol makes a one-frame held clip at the playhead. With no destination
lane, either operation creates one. Existing legacy root symbol rows can be
dragged into a lane. Blocks move by mouse; edge drags claim time by trimming
neighbors; Shift-edge drags ripple every later clip; and the center of a shared
cut composes the two edge edits into a rolling edit. Linked audio follows picture
moves while its edges remain independently trimmable.
The commits beginning at `3d3c1bb` are the argument for the model and are worth
reading before touching what they did — they are the design record, more than
this file is.
3d3c1bb An occurrence is a node, with a clock of its own
9446829 Reuse, duplicate and make unique: deciding what is shared
26517af A position is an argument, not another command
94c0a21 A correction is a layer, and a layer's values are a channel
72b57e3 Regenerate the base, keep the hand work, and say when you cannot
76106d3 The shot is as long as somebody said it was
598c186 One word for one thing: it is a cel
## Read first, in this order
1. [The Lane Model](lane-model.md) — the design, and the status note under
*Proof obligations* says what is built. It supersedes `animation-model.md`,
`timing-model.md` and `architecture.md` wherever they overlap.
2. `frontend/src/arthur/domain/lane.cljs` — every command, and the reasoning in
its docstrings.
3. `frontend/test/arthur/domain/lane_test.cljs` — what the model is asserted to
do. It is the fastest way to see the shapes.
4. `frontend/src/arthur/domain/channel.cljs`, the correction-layer section.
## Vocabulary — one word for one thing
Renamed in `598c186`, after four words had accumulated for one object. Use these
and do not reintroduce the others.
| word | means |
| --- | --- |
| instance | the `:kind`. The general thing, anywhere in a document |
| clip | an instance in a lane. It can hold one source frame or play a symbol naturally |
| cel | specifically a one-frame source held over a clip's duration; the empty-symbol/drawing creation policy |
| lane | a group with `:layout :sequence` |
| drawing | content authored into a symbol; not a different timeline node type |
| placement | ONLY where a node sits: `nest/placement`, and the transform that puts a face on the stage. Never the node itself |
`occurrence` and `exposure` are not words for a cel. **`exposure` means something
else and still does**: `:time :expose` is how many frames each step of a subtree
lasts, which is what shooting on twos is — `node/expose`, `clock/exposed-frame`,
`subs/render ::exposure`. Keeping these apart is why the block is called a cel.
`:layout :sequence` stays as the field, and is the one place two words are kept
on purpose: the layout names the RULE — children follow one another and may not
overlap — and a group carrying it is called a lane. `node/lane?` is where they
meet.
## Decisions already made — do not re-litigate
These were each argued out and are load-bearing. Changing one is a design
decision, not a cleanup.
- **The shot length is authored.** `:frames` is the symbol's window; the
occupied extent of its lanes is a different fact derived from the cels. A
command grows the window only when the caller passes `:extent :grow-symbol`,
and never shrinks it. Blanking the end of a shot leaves empty frames at the
end, because deriving the window from the extent would make deleting the last
drawing silently shorten the film. `lane/finish`.
- **Placing ripples; overwrite is `blank` then non-rippling placement.**
`lane/overwrite-drawing` composes those pieces as one transaction. Insertion
retains its ripple rule; overwrite does not move any surviving cel.
- **A position inside a cel refuses and names `split`.** One command must not
quietly perform two. The UI offers the retry.
- **A correction has no time space of its own.** 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 in lane frames and reaches across the drawings under
it; one on a cel travels with that cel. Ownership already answered it.
- **A layer's values are a channel.** Constant, ramp and return motion are one
mechanism. Do not add a second way to say what a value is over time.
- **A conflict is not a `problem`.** A document whose topology outgrew a
correction loads, evaluates and saves; `clip/conflicts` lists the decisions
waiting for a person. `problems` means the document will not load.
- **Refuse rather than guess.** Every command returns `{:clip :selection}` or
`{:refused why}`, never a half-applied edit. Where the model needs a choice
nobody has made, refusing and saying why is the behaviour, not a placeholder.
- **A clip is not a row.** Rows, expansion and selection are editor state. The
document has never known about rows and must not learn — which is what let
the row model change three times in one sitting (blocks, then a
selected-clip portal, then sound lanes under the audio heading) without
touching a single document.
- **A lane is generic.** Drawing creation, library placement, and adopting an
existing root instance all produce the same child instance shape. The only
difference is playback policy: a new empty drawing holds source frame zero;
a dropped library symbol plays at speed one.
- **Lanes are explicit.** A symbol may contain ordinary overlapping children
without a lane. Selecting a lane row opts creation into its claim-time
behavior; selecting a cel enters that cel's source symbol instead.
- **Placement claims time.** Lanes never store overlaps. A new or extended clip
trims, removes, or splits whatever previously owned the claimed interval.
Real compositing overlap uses another lane, where ordering remains explicit.
## The timeline opens the whole document
Expanding a lane opens exactly one clip — the selected one — and that portal
opens the lanes and nodes of the symbol it places, recursively, mapped into
the open symbol's ruler. The portal follows the LINEAGE of the selection, so
working on something nested keeps the rows that revealed it open. A held clip
opens too, with its rows marked `:unmapped?`: shown across the hold, with no
keys and no draggable edges, because a frozen clock gives its frames no place
on this ruler. `docs/lane-nesting-notes.md` has the reasoning and what is
still missing.
Double-clicking a clip opens the symbol it places as a tab, the same as
double-clicking that symbol in the pool. Shift while dragging a clip body
turns the temporal move into a structural one — see the nesting notes for why
that is mostly refused today.
## Current timeline interaction
- Creation follows the primary active row and the playhead; the complete rule is
in `docs/creating-in.md`. A lane row creates a new cel and claims its interval,
while selecting a cel creates inside the symbol that cel places.
- Drawing with a lane row active creates a new one-frame drawing cel. Dropping a
library symbol there creates a natural-duration playing cel. Explicit timeline
drops use the row and frame under the pointer.
- Dragging a clip body moves it. A linked audio node follows a picture move;
moving or trimming the audio itself remains independent.
- Dragging a right edge changes its endpoint. Growth consumes adjacent spans
instead of overlapping them. Shift-drag inserts or removes lane time by moving
every later clip by the same delta.
- At a shared boundary, the left and right hit zones trim one side. The center
is a rolling edit: right-edge resize followed by left-edge resize at one frame.
- Split, trim-in, and trim-out are direct buttons and are disabled without an
editable selected span. Movement is a mouse gesture, not a toolbar command.
## Next steps, in order
The implemented correction slice and its remaining UI limits are recorded in
[Correction authoring](correction-authoring-plan.md).
1. **Slip source and retime.** Both have real design questions open and the doc
says to refuse rather than approximate: retime needs a defined warp and
interpolation behaviour, and is not moving keys whose numbers happen to fall
inside a selection.
2. **Deleting reused content.** Reference discovery exists (`node/sources`,
`clip/places`, `clip/contains-symbol?`); the policy does not.
3. **Displayed-range correction gestures.** The first correction panel asks for
explicit owner frames. Dragging a range in a retimed/nested view still needs
a proved mapping; do not make it snap through floors or loops.
4. **Collaboration.** `lane-model.md` is explicit that one leaf per channel does
NOT solve two people editing different keys of the same channel. No conflict
policy exists for that.
## Mechanisms to reuse — these keep paying out
- **`:span` is in the node's OWN frames** and `:time` says where they land in
the lane. Moving an edge of a cel is therefore one write to `:span`, with
`:time` and `:playback` untouched. This is why split costs nothing, why the
two halves of a split go on meaning what the one cel meant, why trimming the
front of a playing insert starts it later into its animation instead of
restarting it, and why extending a hold leaves lane keys alone. `lane/local`
and `lane/edged` are the whole geometry; trim, split and blank are all it.
- **`lane/finish`** is the one commit path: it validates, applies the shot-length
policy, and returns the refusal. New commands go through it.
- **`:required-frames` plus the retry event** is the pattern for "this needs a
decision you have not made": the domain reports what it would need, the UI
offers one button. `events/ui/lane-retry`.
- **`lane/lane-frame`** converts a symbol frame to a lane frame, or returns nil
through a stepped or looping lane where there is no single answer. Nil refuses;
it never snaps.
- **`channel/conflict-with`** is the rule for whether one offset fits a base,
used by `conflicts` and regeneration. Validation additionally follows prior
replacement layers, so it cannot approve a stack that throws when read.
- **Generated sampling applies to the base, not the hand correction.** Picture
rate and pose selection may choose an earlier generated frame; correction
support and values still read the node's current authored frame.
- **Correction commands live in `domain/correction.cljs`.** IDs come from the
event caller; the pure command materializes default transform channels,
appends one layer, and validates the complete document. The inspector authors
rotation and position offsets in explicit owner frames. One Apply is one undo
step. `channel/reconcile` is the shared ordered-stack compatibility rule used
by validation, conflict reporting, and regeneration.
- **Two test patterns worth copying.** `the-cursor-agrees-with-the-specification-in-any-frame-order`
holds the optimized cursor to `value-at` in forward, backward and random order
— add a case to it for any new channel shape. And `drawn` in `lane_test`
samples every frame before and after an edit, which is how split and trim are
proved to change nothing: state a claim as "the same picture" rather than as
numbers computed by hand.
## Known gaps and traps
- **Audio is a clip in a lane too, and a lane holds one kind.** A sound placed
from the pool lands in a lane and is moved and trimmed by the same commands
as picture. The capability the earlier note asked for is the homogeneity
rule rather than a field: `symbol/lane-problems` refuses a lane holding both
kinds, and `lane/place-symbol` and `lane/adopt` refuse BEFORE claiming time,
because placement claims time and would otherwise have deleted the sound to
make room for the picture and left a valid document behind. Audio nested
inside a placed symbol — a take's own sound — is still shown flattened by
`nest/audio-tracks`; what is in a lane of the open symbol is drawn as a lane
and not flattened twice.
- **`:z` is required on cels and means nothing there.** A lane never has two
cels on one frame, so draw order between them cannot matter. `node/problems`
requires `:z` on every node uniformly, which is its own kind of simplicity —
but the field is noise on a cel.
- **`channel/offset-onto` throws** on a shape mismatch that no regeneration has
recorded as a conflict. That is deliberate — a correction that silently does
not take is the failure the design exists to prevent, and `channel/problems`
catches the authored case — but it is a throw in the read path, so any new
producer of layers must not create a mismatched one.
- **`docs/timing-handoff.md` is a separate, unreconciled thread.** Performance-
pose selection and plate drawings/tracing, instance-specific picture-rate
requests, `pose/put-cut` addressing only `:main`. It predates the lane model
and nobody has squared the two.
- **Slip and retime are still absent.** The timeline action strip now applies
split/trim uniformly to a selected root or lane clip, but source-time slip and
retime still need their own proved semantics before they become controls.
- **`shadow-cljs release app` clobbers the dev bundle.** Both builds write
`../static/arthur/js`, which Django serves, and the optimized build does not
export the `arthur` global — so after a release the browser tests fail with
`ReferenceError: arthur is not defined`. Run `npx shadow-cljs compile app` to
restore it. A running `watch app` does not notice; it rebuilds on the next
source change.
## Running it
From `frontend/`:
npx shadow-cljs compile test && node out/node-tests.js # 469 tests, 9,592 assertions
npx shadow-cljs compile app # the bundle Django serves
npx shadow-cljs release app # then `compile app` again — see above
The browser tests need the Django dev server up (`mise exec -- python manage.py
runserver 8778` from the repo root) and a compiled dev bundle:
node --experimental-websocket test/browser/lane.mjs # generic symbol-lane flow
CHROME=/usr/bin/chromium node --experimental-websocket test/browser/take.mjs
`take.mjs` defaults to a macOS Chrome path, hence `CHROME=`. It writes a real
project to the local server by design; `lane.mjs` never writes to the server.
From the repo root: `mise exec -- python manage.py test clips` — 56 tests.
Documents are schema 3. A version 2 document is not read and nothing converts
one; there is no backward compatibility to preserve anywhere in this work.

View file

@ -0,0 +1,71 @@
# Implementation notes — "A lane is a view"
Running log for `docs/lane-is-a-view-plan.md`. `[ ]` not started, `[~]` in
progress, `[x]` done with `npm test` green.
Baseline at `fb38990`: 475 tests, 9621 assertions, 0 failures.
## Order of work
The plan's seven steps, re-grouped — see *Deviation from the plan's order* below.
- [x] A. Domain: `symbol/children`, `symbol/lane?`, `symbol/overlaps`,
`lane.cljs` → `span.cljs`, the overlap check in `span/finish`
(plan steps 3, 4, and the domain half of 5)
- [x] B. Events: re-base callers and remove lane-node-specific commands; keep
explicit `::new-lane` and cross-lane adoption (plan step 5)
- [x] C. UI: row per symbol, explicit lane creation, and drag handling
(plan steps 1, 2, and the UI half of 5)
- [x] D. Audio: delete `holds-other?`, the mixed-lane refusal, the `in-lane`
filter in `sound-rows` (plan step 6)
- [x] E. Tests: `domain/sequence_test`, `events/lane_test`, `browser/lane.mjs`
- [x] F. Shift-to-reparent still works, untouched (plan step 7)
Not in this pass — see *Left for a second pass*: tearing out the held cel, the
instance-playback control, drawing a loop's repeats, the audio period guard.
## Deviation from the plan's order
The plan's steps 1 and 2 are display work that keys off "a symbol's children",
and step 3 is what MAKES the cels a symbol's children. Until then a cel's
`:parent` is the lane node, so there is nothing for the display to read: step 2
cannot draw "a symbol's children as blocks" while the children belong to a
group. So the data model moves first (A) and the display follows (C). The
content of each step is unchanged; only the order is.
The one thing this gives up is the plan's promise that every step leaves the
editor usable — between A and C the timeline draws the new shape with the old
code. `npm test` is green at each step either way.
## Decisions taken
1. **Lane mode is `:display :lane` on the SYMBOL** — plan's recommendation 2,
and open question 1 answered "the symbol, not the instance". A symbol placed
twice is drawn as a lane in both places. Added to `symbol/symbol-keys` and to
`leaf/leaves`' `select-keys` so it saves like `:frames`.
2. **A symbol's children are its parent-less nodes** that have a placed span.
The plan's step 6 settles it: "an audio node is already a parent-less child
of a symbol, which is exactly the new shape". Span-less nodes — a shape on
screen for the whole shot — are not in the sequence and are skipped, which is
also what stops the commands destructuring a nil span.
3. **A symbol holds at most one sequence.** It follows from 1 and 2: the
container is the symbol. Two lanes of picture is now two symbols placed in a
third, which is what compositing already was.
4. **The open symbol gets a row of its own in lane mode**, and only then. The
blocks have to sit on a row and the open symbol had none; expanding it turns
its children into ordinary rows. Not a row always, which would shift every
row in the pane for no gain.
5. **Open question 2** — a lane row's edge drag trims the PLACING INSTANCE's
span, via `span/resize-out`, like the handle on every other row. Rippling
the children is what the cel blocks' own edges already do, and giving one
handle two meanings is what the plan refuses elsewhere.
6. **Open question 3** — the lane work first, the held cel after. The plan says
they are independent, and the held cel is joined to a loop control that does
not exist yet; doing it second costs one more pass over `lane_test`'s
fixtures and risks nothing.
7. **Lane creation stays explicit.** A blank document and `new symbol` create
ordinary symbols. The separate `new → lane` command creates and places a
symbol with `:display :lane` in the effective creation target derived from
selection and playhead; the new lane then becomes the primary selection.
## Notes

301
docs/lane-is-a-view-plan.md Normal file
View file

@ -0,0 +1,301 @@
# A lane is a view
Plan, 2026-10-01, written at `2dc5735`. It undoes the lane model as a thing in
the document and keeps what it was for. Build on what is there and tear out
half of it.
## The decision
> A lane is a view over a symbol with sequential, non-overlapping children.
Nothing in the document is a lane. There is no lane type, no lane group, no
`:layout :sequence`, no lane commands and no lane validation. The word
survives in exactly two places: the UI, where a symbol can be DRAWN as a lane,
and the drag handling that re-spans a symbol's children while it is being
drawn that way.
The display model goes back to a row per symbol. A symbol in lane mode draws
its children as blocks on its own single row; expanded, they are rows like
anything else. Everything else is an ordinary row that expands into what it
places.
## What a lane was, and what each part becomes
| was | becomes |
| --- | --- |
| a group node with `:layout :sequence` | nothing — the symbol is the container |
| `node/lane?` | a view question: is this symbol drawn in lane mode |
| `symbol/lane-clips nodes lane-id` | the children of a symbol, sorted by `node/placed-span` |
| `symbol/lane-problems` | `symbol/overlaps`, a diagnostic the write path calls |
| `lane/lane-frame` | `clip/source-time` — one clock instead of two |
| `domain/lane.cljs` | re-based onto `domain/span.cljs`: re-spanning a symbol's children |
| `clip/lane-node`, the born-with lane | gone; a symbol is born empty again |
| `::ui/new-lane`, `::ui/adopt-in-lane`, lane renaming | gone, gone, and ordinary node renaming |
`lane.cljs`'s fourteen commands are not deleted — they are what "endpoint drag
overlap handling" means, and they already do the right arithmetic. What
changes is their subject: every one of them currently takes a host symbol AND
a lane id and asks `lane-clips nodes lane-id`; each takes a symbol and asks
for its children. `extend-hold`, `resize-out`, `resize-in`, `roll`, `blank`,
`place-symbol`, `adopt`, `append-drawing`, `reuse-drawing`,
`duplicate-drawing`, `overwrite-drawing`, `make-unique`. `span/finish` is
already the one commit path and stays exactly as it is.
Put them in `span.cljs`, which already owns "one write to one node's span" and
`finish`. The sequence operations are the same subject — re-spanning children
— and keeping them apart was a consequence of lanes existing.
## Children in lane mode never overlap
This is an invariant, not a condition to check for and report. Placement
claims time: anything placed, moved or grown over occupied time TRIMS the
extents it lands on — trimming the incumbent, removing one wholly covered, or
splitting one it lands inside — so the result has no overlap because the
operation that could have made one did not. That is `blank` followed by a
non-rippling placement, which is what `overwrite-drawing` already composes.
Enforced at the boundary, which already exists: `span/finish` is the single
commit path for every one of these commands, it validates before it returns,
and it refuses rather than half-applying. So `finish` gains the overlap check
for a symbol in lane mode, and no command can commit one. An overlap that
appears anyway is a bug in a command, not a state to design around.
Keep the check as a named diagnostic — `symbol/overlaps`, taking a symbol and
returning the pairs — used three ways:
1. `span/finish` refuses when it would commit one.
2. The test suite asserts no command can produce one: a property over the
commands in the style of `drawn` in `lane_test`, which samples rather than
computing expected numbers by hand.
3. A document that somehow arrives holding one still LOADS — a display hint
must never be able to stop a document loading — and the timeline draws it
visibly wrong with the status line saying so. Not `clip/problems`, which
means the document will not load, and not `clip/conflicts`, which means a
person has a decision to make. This is neither: it is a bug report.
Toggling lane mode ON for a symbol whose children already overlap is the one
place a person can ask for the impossible. Refuse it and say why, with the
`:required-frames` retry pattern offering to trim them into a sequence — the
domain reports what it would need, the UI offers one button.
Outside lane mode nothing is enforced, because overlapping children are what
compositing IS. An endpoint drag there is an ordinary span edit that may
overlap; the claim-time rule follows the mode.
## The two drag intentions
Unchanged from `docs/lane-nesting-notes.md`, and both kept:
- **Plain drag** of a clip body is temporal: it moves in time, within its
symbol or into another symbol drawn as a lane, and it REPLACES — trimming,
removing and splitting extents as needed so nothing overlaps.
- **Shift-drag** is structural: the dragged node goes INSIDE the symbol the
clip under the pointer places, through `nest/move-node`, which preserves the
world transform and the root timing. This must keep working for symbols
contained in a lane, which is the case it exists for.
Overlap cannot distinguish them — dropping on occupied time already means
claiming it — so the modifier says which, and the label by the pointer says it
back. `nest/move-refusal` already answers before the drop.
## Where lane mode lives
A symbol is drawn as a lane because somebody said so, not because of what its
children happen to look like at this moment. Deriving it from "the children do
not currently overlap" means a symbol stops being a lane the moment anything
overlaps, and the rules that maintain non-overlap switch off exactly when they
are needed.
Two options:
1. **Editor state**, `[:ui :lane-mode #{sid}]`. Purest reading of "a lane is a
view". But the drag rules follow the mode, so an unsaved, per-person toggle
would decide whether dropping a symbol trims its neighbour or composites
over it — the same gesture doing two different things to the document
depending on something the document does not record.
2. **A display hint on the symbol**, e.g. `:display :lane`, saved like any
other field (`clip-keys`, `leaf/leaves`, `leaf/clip` in the same commit).
Still not a type: nothing in evaluation reads it, `symbol/problems` does
not check it, and a symbol with it set behaves identically on the stage.
Recommended: 2. It is one field, it keeps editing rules reproducible between
people, and it does not make the symbol a different kind of thing. The thing
to hold the line on is that nothing outside the timeline, and the commit
path's overlap check, is allowed to read it.
## Two things are called loop
Before any of this, name them apart, in the way the vocabulary table in
`lane-handoff.md` names a cel apart from an exposure.
- **Loop playback** is the transport repeating the open symbol while it plays.
It is `[:playback :loop?]` in app-db, the ⟳ button in the strip, and it is
EDITOR STATE. The document does not know about it.
- **A looping instance** is a node repeating the symbol it places: a four-frame
tire turning for the hundred and twenty frames the instance is on screen.
It is `:playback {:end :loop}` on the node, and it is in the DOCUMENT.
The car tire is the second one, and here is its actual status: it already
works in the evaluator and cannot be asked for. `node/placed-frame` does the
modulo, `node/problems` already admits `:end` of `:stop`, `:hold` or `:loop`,
`nest/audio-tracks` already expands a loop into its periods — and nothing in
the UI sets it. It is implemented and unreachable.
So three things are missing, and they are the work:
1. **A control.** Where an instance's playback is edited: `:in`, `:speed`, and
what happens at the end. One place, three fields, rather than a loop
checkbox somewhere else.
2. **Drawing the repeats.** `ui/timeline`'s docstring already admits that only
the first pass of a looping instance is drawn, so a tire turning thirty
times shows one turn's keys and then nothing. A looping block should show
its passes — at minimum the period boundaries, so the row says how many
times round it goes.
3. **The audio period guard** below, which a loop needs whether or not holds
become loops.
## Tear out the held cel
A held cel is a 1-frame symbol shown for many frames. A 1-frame symbol with
`:end :loop`, lengthened, is the same picture by a different route — and the
second route is a case the model already has, so keeping the first one is
keeping a special case for free.
**What is already true**, so that nothing has to move: `:span` is on the node,
in its own frames; `:time` (`:at`, `:rate`) is on the node; looping is on the
node, as `:playback :end`. The SYMBOL owns only `:frames`, the authored
window. So looping and span are instance properties already, and this change
is about deleting a mode, not relocating a field.
It does mean the two changes are joined at one point: making every drawing a
looping instance is not safe until a looping instance can be seen and edited,
or every drawing in the document acquires a property with no control on it.
**What has to be decided and collapsed:**
- There are two spellings of looping — `:time :loop?` and `:playback :end
:loop` — and both are read, in `node/placed-frame` and in
`nest/audio-tracks`. Keep one. `:playback {:in :speed :end}` already says
what happens at the ends, so `:end :loop` is the one to keep and
`:time :loop?` is the one to delete.
- `:playback :speed 0` stops being produced. Make it illegal in
`node/problems` rather than legal-but-unused, so a frozen clock has exactly
one spelling: a 1-frame loop.
- `lane/extend-hold` exists only because holds were special — it refuses
anything whose speed is not 0 and then edits a span. Once a hold is a loop,
lengthening one IS `resize-out`, and the command collapses into it.
- `cel` survives as the word for the creation policy — a new empty symbol is
one frame — but stops naming a playback mode.
**What it buys, and this is the point:** one rule for nesting, which settles
the refusal that blocks shift-to-reparent today. `nest/inside` currently has
no `:time` for a hold, for `:end :hold`, or for a loop, and so refuses all
three. The general rule that covers all of them: **resolve the move with the
destination's map at the CURRENT frame — the affine piece the current frame
falls in.**
- A loop of length L is affine within the period the current frame is in.
Timing is preserved inside that period and repeats after it, which is what
looping means.
- A 1-frame loop — the ex-hold — has a period of length 1, so the map within
it is trivially invertible and lands the moved node on frame 0, aligned to
the current frame. That is exactly the answer `docs/lane-nesting-notes.md`
argues for from first principles, arrived at here as an instance of the
general rule instead of a special case.
- `:end :hold` is affine in the played part and frozen in the tail, which the
same sentence covers.
**The trap, which must be handled in the same change.** `nest/audio-tracks`
expands a loop into one walk PER PERIOD:
periods (range (floor (/ (to-local source lo) length))
(ceil (/ (to-local source hi) length)))
Today a held cel is skipped entirely — `(pos? speed)` is the guard, and the
comment says a visual freeze does not emit a sustained audio sample. Turn
every drawing into a 1-frame loop and that guard stops firing: a drawing held
for 120 frames becomes 120 recursive walks, and any sound inside it is emitted
120 times. That is both a wrong mix and a performance cliff on the most common
node in the document. Required with this change: a cheap `symbol/audible?`
precheck so a source with no audio anywhere inside it is never period-expanded,
and a cap or a different formulation for the ones that are.
Also worth knowing before the change: `ui/timeline`'s own docstring already
says a looping instance draws only its first pass. With every drawing a loop,
that sentence now describes every drawing — harmless, since one pass of a
1-frame symbol is the whole of it, but the docstring should stop sounding like
a limitation.
**Dropping into a 1-frame symbol.** The window is authored and crops what it
holds, so a 10-frame symbol dropped into a 1-frame drawing shows its frame 0
and nothing else. That is consistent — `:frames` is the shot length and
`:extent :grow-symbol` is the opt-in — but it is probably not what somebody
dragging means. Offer the growth through the `:required-frames` retry the
model already uses: the command reports what it would need, the UI offers one
button.
## Order of work
Each step compiles, passes `npm test`, and leaves the editor usable.
1. **Row per symbol.** In `ui/timeline.cljs`, delete `portal`, the `::portal`
hint row, the `under?` lineage predicate and the `chosen` argument; emit a
row for a clip whose parent is a lane instead of skipping it. Keep the
`:cels` blocks for the collapsed row, and keep `inside-rows` — including
its `:unmapped?` branch, which is what makes a held drawing's contents
reachable at all.
2. **Lane mode as a hint.** Add the field and the toggle, draw a symbol's
children as blocks when it is set and as rows when it is not, and move the
lane-row drag handling onto it. Both display paths now exist and nothing in
the domain has changed.
3. **Re-base the commands.** Move `lane.cljs` into `span.cljs`, replacing
`(lane-clips nodes lane-id)` with the symbol's children and dropping the
`lane-id` argument. `frontend/test/arthur/domain/lane_test.cljs` is the
proof: its fixtures should change and its assertions should not, and any
assertion that has to change is a behaviour change worth noticing.
4. **Move the invariant.** `symbol/lane-problems` becomes `symbol/overlaps`,
called by `span/finish` for a symbol in lane mode, plus the property test
that no command can produce an overlap.
5. **Delete the rest.** `node/lane?`, `symbol/lane-clips`, `clip/lane-node`,
`::ui/new-lane`, `::ui/adopt-in-lane`, the lane branch of
`::ui/new-symbol`, lane renaming, `aimed-lane`, and the
`:lane?`/`sound-lane?` row flags. Rename what is left so the word does not
appear outside the timeline.
6. **Audio falls out.** An audio node is already a parent-less child of a
symbol, which is exactly the new shape — so the audio-in-lane rules added
in `2dc5735` (`holds-other?`, the mixed-lane refusal, the `in-lane` filter
in `sound-rows`) delete rather than migrate. A symbol drawn as a lane whose
children are sounds is an audio lane, and that is the whole of it.
7. **Shift-to-reparent stays** as it is: `nest/move-node` and
`nest/move-refusal` never knew about lanes.
## What must not be lost
All of this was broken at some point today and is now proved; each has a test
to keep.
- A held clip's contents are reachable from the root timeline, with no keys
and no draggable edges — `source-time` is nil for a hold, and the walk used
to stop there.
- Double-clicking a clip opens its symbol as a tab, and the editor survives
it: `symbol/lineage` must not report a cycle for an id the symbol does not
hold, and opening a symbol must drop a selection pointing into the one being
left.
- Selection waits for pointer-up, so a press does not re-draw the timeline out
from under the gesture it is starting.
- A drop never silently deletes what it lands on.
- A sound is drawn once, not twice.
## Open questions
1. **Is lane mode a property of the symbol or of the instance placing it?** A
symbol placed twice would be drawn the same way in both places under the
first reading. That is probably right, and worth saying out loud.
2. **Does a lane row's edge drag trim the placing instance's span, or ripple
the children?** Same handle, two commands; the row is now an instance, so
it has a span of its own for the first time.
3. **Does tearing out the held cel come before or after the lane work?** It
is independent of it — `nest` never knew about lanes — and it is what makes
shift-to-reparent work on the thing people would actually drag onto. Doing
it first means the lane work lands on a model with one playback mode fewer;
doing it after means two changes to `lane_test`'s fixtures instead of one.

591
docs/lane-model.md Normal file
View file

@ -0,0 +1,591 @@
# The Lane Model
Revised 2026-10-01. Clip ownership, source playback, one-row generic lanes,
direct clip movement and edge editing, correction evaluation, and correction
authoring for rotation and position are implemented. The former cel-sheet
projection was removed: the timeline is the single timing interface. Sections
below that describe a cel sheet are retained as design history and are superseded
by this revision. Retiming commands are not.
See the status note under
[Proof obligations](#proof-obligations-and-implementation-order).
[Lane and cel handoff](lane-handoff.md) records what is built, the decisions
that are settled, and what to do next.
This revises the Claude artifact [The Lane Model](https://claude.ai/code/artifact/cd42981d-ed08-493f-94df-b7dd6657f0e6).
Its prose and diagram source were recovered from session
`1c603f71-84eb-498e-aeaf-4c0346f1f513`; the live artifact was not accessible for
reading or editing here. This repository document is the revised design. The
original artifact has not been updated, and edits made there outside the recorded
session may not be represented here.
For the subjects covered here, this document supersedes the original artifact
and conflicting proposals in `animation-model.md`, `timing-model.md`, and
`architecture.md`. Those documents retain useful detail about the existing system.
## Goal and compatibility policy
Arthur is one animation document with several ways to see and edit it: drawing
on the stage, arranging clips, timing cels, editing curves, and generating
motion from footage. Each view exposes relevant facts and invokes shared editing
operations. Switching views must preserve the meaning of the work.
The user explicitly requires no backward compatibility. Replace obsolete shapes,
APIs, and tests when a better model requires it. Do not retain compatibility
branches, adapters, or migrations solely to preserve the current document format.
A format marker can reject unsupported files clearly; it does not promise to
convert them. This policy does not authorize deleting existing user assets.
Simplicity means predictable composition, clear ownership, and few independent
rules. Minimizing field count is secondary to representing independent choices.
## What stays
- A symbol is the one container for authored scene nodes. A drawing can be a
one-frame symbol; an animation uses the same container over more frames.
- Nodes have stable identities and flat parent references. Shared content is
referenced rather than copied implicitly.
- Animatable properties are addressed by channel paths. Generated and authored
values participate in the same evaluation machinery.
- Authored data, generated blocks, and source media remain separate. Documents
reference immutable blocks; caches and resolver indexes remain derived.
- A pure reference evaluator specifies the result. Playback, seeking, preview,
export, and optimized cursors must agree with it.
- Existence, visibility, and missing measured data remain distinct facts.
## Content, cels, lanes, and rows
These have different identities and responsibilities:
| Concept | Owns | Example |
| --- | --- | --- |
| Content | Reusable nodes and their animation | Drawing `a2`, an animated head, or a sound asset |
| Cel | One use of content, its interval, source playback, and local treatment | `a2` exposed on frames 12–16 |
| Lane | A sequence of cels and shared properties | The girl's drawings and the girl's overall transform |
| View row or column | Presentation and editor state | Timeline row, cel-sheet column, or property curve |
Use the existing instance/node identity mechanism for cels. A cel
should not acquire a second identity system just because it is shown as a cel.
Lanes group cels; they do not introduce another node-holding content type.
The concrete candidate below uses existing group and instance nodes; its ownership
boundaries are part of the design. It is now the implemented shape, and the field
spellings below are the ones the runtime reads.
Each cel has a stable ID. Moving it, changing its hold, swapping its source,
or trimming it preserves that ID. Repeating it creates a new cel that may
reference the same content. A split retains the original ID on the left and gives
the right piece a new ID; commands return the resulting selection explicitly.
Cels are the canonical authored arrangement. A source-at-time channel or
interval index may be compiled from them for evaluation, but is not a second
editable copy of the schedule. This replaces the earlier proposal that every cel
must be represented solely as a source key. Ordinary property animation still
uses lightweight keys; it does not need cel objects.
A lane has non-overlapping half-open cel intervals `[start,end)`
in its own time space. Uncovered intervals are gaps. Empty lanes are valid.
Compositing and simultaneous sounds are represented by multiple lanes or ordinary
scene composition; an accidental overlap never silently selects a winner.
Transitions, if added, need explicit overlap and mixing semantics.
Properties can belong to content, one cel, or the lane. For example:
- Rotate the reusable drawing: all its uses change.
- Rotate one cel: only that cel changes.
- Animate the lane's rotation: whichever drawing is showing follows it.
A cel can have its own transform, gain, corrections, and source timing
while remaining a block in the same timeline row. Independent treatment never
requires a new row or an otherwise unnecessary wrapper symbol.
### Concrete candidate: a lane and ordinary instances
A lane is a group node with `:layout :sequence`. Its cels are ordinary
instance nodes whose `:parent` points to the group. All remain in their symbol's
flat node map. The sequence constraint is document semantics; which rows the UI
expands remains editor state. Ordinary groups retain unconstrained composition.
This example is a document the runtime accepts, built and evaluated by
`frontend/test/arthur/domain/lane_test.cljs`. Times here are zero-based. Channels
use the existing representation; cel source references and playback have
replaced the `[:source]` channel, which no longer exists.
```clojure
;; Within :main's :nodes; referenced drawings/animations live in :symbols.
{:girl
{:id :girl :kind :group :layout :sequence :z "b"
:channels {[:xform :rot]
{:animated? true :interp :linear :keys {0 0, 6 30, 12 0}}}}
:cel-a
{:id :cel-a :kind :instance :parent :girl :z "a"
:time {:at 0 :rate 1} :span [0 4]
:source {:symbol :drawing-a}
:playback {:in 0 :speed 0 :end :stop}}
:cel-b
{:id :cel-b :kind :instance :parent :girl :z "b"
:time {:at 4 :rate 1} :span [0 4]
:source {:symbol :drawing-b}
:playback {:in 0 :speed 0 :end :stop}
:channels {[:xform :pos]
{:animated? true :interp :hold :keys {0 [0 0], 1 [2 0]}}}}
:animated-insert
{:id :animated-insert :kind :instance :parent :girl :z "c"
:time {:at 8 :rate 1} :span [0 4]
:source {:symbol :wave}
:playback {:in 3 :speed 1 :end :stop}}}
```
The lane's rotation reads lane time. Each cel's channels read cel
time. Its content reads source time. The stills sample frame 0, while the insert
samples source frames 3, 4, 5, and 6. The group transform composes with the
cel transform and then the content's own transform.
`:span` remains in the node's own coordinates, consistent with ordinary nodes.
The interval in lane time is derived through `:time`; do not also store parent
start/end values. Sequence children require finite intervals and positive
placement rates. Ordering and overlap checks use the mapped intervals, not `:z`.
The current sequence group contains visual symbol clips. Audio remains an
independent root node (and can be linked to picture); if audio lanes are added,
their capability must be explicit rather than inferred per frame.
A source reference is fixed within a cel. The lane changes content when
another cel becomes active. This is a deliberate revision of the original
diagnosis that making `:of` a channel was necessary to avoid vertical growth:
multiple instances can occupy one row when the view presents their containing
sequence. A lane-level source schedule is therefore derived, not authored twice.
## Source selection and source playback are independent
The current implementation makes framed sources play and keyed sources hold.
Retire that rule. Channel storage shape must not determine playback behavior.
Adding or removing a key must not turn a still into an animation or vice versa.
A cel names content and describes how its source time is sampled. In the
basic case, after mapping lane time into cel time:
```text
source_time = in_point + speed × cel_time
```
A newly created cel starts at local time zero. Moving it preserves this
origin relative to its content. Trimming can narrow its local support without
resetting that origin; split pieces likewise preserve the source and property
values at the cut. Trimming, slipping, and retiming are distinct operations with
explicitly different effects on the interval and the source map.
| Intent | Source playback |
| --- | --- |
| Hold a drawing | Constant source frame, equivalently speed 0 |
| Play an animated symbol | Advancing source time, normally speed 1 |
| Cut between animations | Several cels, each with its own in-point and speed |
| Mix stills and animation in a lane | Constant and advancing maps in the same sequence |
The source reference itself is discrete and never numerically interpolated.
Interpolation belongs to properties that support it; a property registry should
declare value types, defaults, and permitted interpolation and correction modes.
Generic key toggles must consult those capabilities rather than assume every
non-boolean value can be tweened.
Define source bounds and end behavior explicitly: stop contributing outside the
source, hold an endpoint, or loop an explicit range. A still uses a valid constant
frame. A loop uses a nonempty half-open range and a defined modulo rule. Playback
never guesses these policies from whether a channel happens to have keys.
Audio shares cel arrangement, trimming, gain ownership, and clock mapping.
It does not inherit visual frame-hold semantics: holding one audio sample is not
an audio freeze effect. Validate supported playback policies by media capability.
Actual audio scheduling must follow active cels, including gaps and cuts,
rather than playing every sound reachable through a structural reference.
## Time spaces and sampling
Name the relevant space whenever an API accepts a time or range: project,
symbol/lane, cel, or source. Store authored frame coordinates exactly;
avoid cumulative rounding when moving through nested mappings. Quantize at a
declared sampling boundary, not at every traversal step. Audio also needs its
continuous clock/sample space rather than visual frame quantization.
A hold is an evaluable time map with no unique inverse. A loop can map many
displayed cels to one source time. APIs must distinguish forward sampling
from inverse editing, and expose enough context to resolve a cel or
explicitly refuse an ambiguous operation. Do not report a missing time map merely
because inversion is unavailable.
Separate invertible placement timing from source sampling. A zero source speed
can mean hold without making the cel's own edit clock non-invertible.
Reparenting through changing transforms or non-invertible timing must either
preserve the full result by an explicit bake or return a reason it cannot; a
matrix captured at one frame does not prove preservation across the animation.
The source's frame step, generated-pose sampling, and the lane's transform clock
are independent scopes. Drawing on twos must not accidentally step a smooth lane
transform. An explicit whole-subtree stepping operation can exist separately.
Share the quantization primitive where possible, but retain its units, phase,
rounding policy, and order relative to retiming and lead. The original suggestion
that cel and picture-rate sampling are simply one floor is insufficient:
noninteger grids and source-frame quantization require specified behavior.
Identity timing can be implicit; remove `:time :mode` if it only duplicates that.
A cel interval is authored. Lane content extent is derived from its
cels, including the explicit end of the last one. A separately authored
container trim/window is legitimate when it intentionally gates children. Do not
conflate that window with occupied extent or infer a final hold from the next key
when no next key exists. A range of frame numbers alone cannot encode visibility
or a missing measurement.
## Shared editing operations
Every view issues the same domain commands. A command accepts an explicit target
and edit policy, computes a valid change, and returns the change, resulting
selection, and any refusal reason. A button and a drag must not implement two
versions of cel extension.
An edit target identifies the symbol, cel path, selected entities or
properties, and the time range with its space. Navigation also distinguishes
editing shared content directly from editing it through a particular cel.
Crossing a source cut must not silently redirect an active drawing edit to a
different symbol: retain the explicit content target until navigation changes it.
Core commands include new drawing, reuse drawing, duplicate drawing, make unique,
blank range, split, trim, move, extend cel, slip source, retime, and apply a
bounded property edit. Ripple/overwrite policy and the set of affected lanes are
explicit command arguments. Preview consequences before committing a gesture.
New drawing creates fresh empty content and a cel. Blank range removes
content coverage without inventing a hidden drawing. These are different actions.
Reuse creates another cel pointing at existing content. Duplicate creates
a new content identity. Make unique rebinds the selected cel only.
Copy semantics must specify nested sharing. A normal content copy duplicates its
owned nodes and channels while preserving references to other reusable symbols.
For a fully independent drawing assembled from nested symbols, provide an
explicit deep-copy operation with ID remapping. Never promise decoupling while
leaving the relevant edited object shared. Immutable media blocks may remain shared.
Commands are atomic undo transactions, even when they touch several leaves.
Pointer movement and keyboard invocation use explicit begin/preview/commit or
cancel boundaries; a timing heuristic alone must not decide user intent.
Collaboration applies a transaction consistently, validates affected references,
and detects conflicts at the owned data being changed. One leaf per channel does
not solve simultaneous edits to different keys of that same channel; define a
conflict policy rather than claiming that granularity solves all collaboration.
### Default timing behavior: cel edits preserve lane keys
Working default from the follow-up discussion: extending a drawing's hold changes
cel timing, leaving lane animation at its authored times. The user raised
keeping keyframes in place as a possibility; this is the proposed predictable
default, not a claim that they selected every timing policy below.
Ownership supplies the remaining rule: properties attached to a cel
travel with it. Extending its end does not stretch those properties; moving it
changes where their existing local times land. No per-key attachment flag is
needed to recover ownership that the document already expresses.
For the concrete example, extend `:cel-a` by two lane frames with ripple:
| Fact | Before | After |
| --- | --- | --- |
| Cel A's lane interval | `[0,4)` | `[0,6)` |
| Cel B's lane interval | `[4,8)` | `[6,10)` |
| Animated insert's lane interval | `[8,12)` | `[10,14)` |
| Girl's rotation peak | Lane frame 6 | Lane frame 6 |
| B's position change | B frame 1, lane frame 5 | B frame 1, lane frame 7 |
| Insert's first source frame | Source frame 3 | Source frame 3 |
The rotation peak now coincides with a different point in the drawing sequence.
That is the intended consequence of changing cels underneath timed motion.
The position correction stays attached to drawing B's cel. Neither the
background's keys nor audio on another lane moves.
The command contract for this edit names the symbol and cel, a delta in
lane frames, `:ripple` behavior, and an explicit scope of cel timing. It
extends A's local support by the delta converted through A's placement rate,
and shifts subsequent cel placements by that delta in lane time. It does
not modify any channel's key map, source in-point, or playback speed. Reject a
nonpositive resulting duration. Validate and commit the entire change together.
The symbol's authored end is another explicit boundary: preview an overflow and
offer to extend the symbol or cancel. A command can request that extension as
part of its transaction; it must not silently truncate later cels or grow
other uses of a shared symbol. In the example, a 12-frame symbol needs an explicit
extension to 14 frames or the edit must be refused without partial changes.
Retime performance is a separate operation over explicitly selected cels
and channels. It applies the same time transformation to their relevant clocks,
keys, and correction supports. Stretching an interval requires a defined warp
and interpolation behavior; it is not merely moving keys whose frame numbers
happen to lie inside the selection. Until supported, refuse this operation
rather than approximating it with a cel ripple.
The initial UI should default stage transforms to the lane when drawing in a cel
workflow, so movement usually remains independent of cel timing. The
inspector names the target: lane motion, this cel, or shared drawing.
Changing that scope is explicit. It changes what the edit means, not just which
panel happens to be open.
## Three-frame rotation and correction layers
A range says where an edit applies; it does not specify the motion. Offer distinct
commands for a constant adjustment, a ramp, and a return-to-start motion. For UI
frames 10–12, the internal range contains exactly three frame samples after
conversion from the displayed numbering convention.
- Constant adjustment: the same offset throughout those three samples.
- Ramp: interpolate from the specified start value to the target over the range.
- Return motion: interpolate from the starting value to a peak and back.
For a return motion sampled on three frames, the values can be `0, angle, 0`.
Outside the selected range, the underlying animation must evaluate exactly as it
did before. A range-scoped correction layer expresses this directly; blindly
inserting boundary keys can alter neighboring segments or destroy existing motion.
Implement corrections as an ordered stack over the base channel. Each correction
has stable identity, explicit support interval, blend operation, and values in a
named time space. Outside its support it is inactive. `replace` can supply a value
over an absent base; `offset` cannot offset a nonexistent value. Blend capability
depends on property type, and geometry corrections require compatible topology.
Regeneration replaces the generated base and preserves corrections. If changed
topology or removed targets make a correction incompatible, report a resolvable
conflict instead of silently dropping or misapplying it. Provenance explains
where the base came from; explicit sampling policy determines its playback.
This is core to the workflow: generate motion, correct it by hand, adjust the
generator, and keep the corrections. It should be proven before adding many views.
## Other unifications worth keeping
Pose choices, tracing-frame choices, and ordinary held values should share the
channel evaluator and cursor infrastructure. Preserve their different ownership,
fallback behavior, and sampling scope. A pose choice must address the relevant
content/feature explicitly; switching to another symbol must not accidentally
reuse a track just because both symbols contain a node with the same local name.
Keep the two animation idioms distinct: keyed geometry modifies one mark over
time; drawing substitution selects content that may have different structure.
Linear geometry interpolation requires compatible vertex correspondence, not
merely two drawings that happen to look related.
Derived library grouping may collect drawings used by a single lane. This is a
convenience, not ownership or deletion authority. Reference discovery for cycle
validation, copying, and deletion examines all structural references, including
currently inactive cels. Authored folders, favorites, and labels remain
legitimate user data even when the UI could have suggested defaults.
## UX: location, selection, and controls
The breadcrumb sits above the timeline and states the editing location, shared
content identity, and cel context when applicable. Show local time and
its project context where a useful mapping exists. Holds and loops need an honest
description instead of a fictitious unique global frame.
Creation follows the primary active row, resolved at the playhead as specified in
[`creating-in.md`](creating-in.md). The wider selection set still names what copy,
delete and transform affect; it is not a second list of creation destinations. A
shared drawing indicates its reuse and offers Make this cel unique. Names help identify content;
linked-use indicators must rely on IDs, because different drawings can share names.
| Surface | Primary scope and controls |
| --- | --- |
| Topbar | Project name, save/open/export, project rate and stage size |
| Location bar | Breadcrumb, add lane/content, shared-content context |
| Cel action strip | New drawing, duplicate drawing, hold longer/shorter, blank range |
| Lane header | Lane selection, lock, mute/solo where applicable, onion settings, expansion |
| Stage tools | Drawing and transform modes, active target and scope |
| Inspector | Selected content/cel/lane properties and valid key controls |
Cel actions have visible contextual buttons, shortcuts, a context menu, and
command-palette entries. These are different entrances to the same commands.
Shortcut names from the original sketch (`N`, `D`, `H`, `B`, `K`) are provisional;
their meanings must match the visible labels and avoid tool conflicts.
The inspector normally edits values and the timeline normally edits timing, but
this is an organizational default. Numeric duration and in-point controls are
useful inspector edits to the same domain facts. Do not ban a convenient control
just to preserve a visual division.
Default nesting navigation enters content; expanding a lane reveals properties.
Other views may show hierarchies differently without changing the document.
Tabs can pin explicit locations. Zoom, expansion, onion preferences, and current
selection are editor state rather than animation content. Persistent workspace
preferences can be saved separately.
## A session, revised
1. In `main`, create a girl lane and a new drawing. Draw; use New drawing (`N`)
to create the next one with the previous cel ghosted behind it.
2. Use Duplicate drawing (`D`) when the current shapes are the starting point.
Use Reuse drawing for a deliberately linked cel. The UI shows the
difference before an edit can change other uses.
3. Time the performance. Hold longer (`H`) extends the selected cel and
ripples later cels in the explicitly targeted lane. A trim gesture
can use overwrite instead. The preview shows which boundaries will move.
4. Choose a two-frame default cel for newly created drawings, or run a
separate Retime cels command on a selected range. This does not quantize
lane transforms or silently retime already authored cels.
5. Place the background in a lane below. Its source holds one frame throughout
its cel. Key the lane's X position at the beginning and end and choose
linear interpolation. The background slides while the girl's drawings cut.
6. Select three frames on the girl's lane, choose Return motion, and rotate to
the desired peak. A bounded rotation correction affects the girl across any
drawing boundaries in that range. Existing motion survives outside it.
7. Insert a playing animated symbol among the girl's held drawings. Set that
cel's source playback to advance. No lane conversion is required.
The timeline shows named cel blocks with property marks and optional curve
subrows. The cel sheet shows the same cels by frame and lane. The
graph editor edits the same properties; the stage resolves the same document.
Onion skin is configurable and counts neighboring cel events, skipping gaps
by default; a long hold does not consume the budget. Repeated uses of the same
drawing remain distinct events. Deduplicating identical ghosts is a display option.
## Proof obligations and implementation order
The source-channel prototype has been removed: a cel names one symbol
and carries its own playback clock, and `node/problems` rejects the old
`[:source]` channel. What a lane IS lives in `arthur.domain.symbol` beside the
other rules about a node map; `arthur.domain.lane` holds the commands over
one — add lane, place a drawing (new, reused or duplicated), make unique, split,
trim, move, blank and extend hold. Each is one history step, and each refuses rather than
half-applying. The timeline draws a lane's cels as cel blocks on the
lane's own row, and offers Make unique only where the selected cel actually
shares its drawing.
There is ONE placement function and a position argument, so appending is not a
different operation from inserting: `:end` is a position like any other, the one
where nothing has to move. Placing ripples — cels at or after the
position move later by the new cel's duration — and `:keep` versus
`:grow-symbol` still decides what happens at the shot's end. OVERWRITE is not a
policy argument yet, deliberately: taking frames away from the cel
already there is trimming, and until `trim` exists, placement that would need it
refuses instead of approximating it. A position inside an existing cel
refuses too, and names `split` — one command does not quietly perform two.
Splitting turned out to cost almost nothing, which is evidence for the
representation rather than for the command. The two pieces keep ONE `:time` and
differ only in `:span`, so the right piece's own frames carry on where the
left's stopped and its source clock, keys and corrections go on meaning what
they meant — a held drawing holds the same frame either side, a playing insert
plays through the cut without a seam, and the test for it samples every frame
before and after and asserts the picture is identical. That falls out of `:span`
being in the node's own coordinates; it is not something split arranges.
Content copies are shallow by default and keep their references to other
symbols; `:deep? true` is the explicit copy that shares nothing, so the promise
of independence is only made where it is kept.
THE SHOT LENGTH IS AUTHORED, which is the decision the range commands forced.
`:frames` is the symbol's window — how long the shot IS — and the occupied
extent of its lanes is a different fact derived from the cels. A command
grows the window only when the caller says `:grow-symbol`, and never shrinks it:
blanking the end of a shot leaves a shot with empty frames at the end, because
that is a true statement about what somebody authored, and deriving the window
from the extent would make deleting the last drawing quietly shorten the film.
`finish` keeps the two numbers apart by name now rather than by a `max` that
read like an accident.
Trim NARROWS one edge and moves nothing else; lengthening is `extend-hold`,
which carries the ripple and shot-length policies because it needs them.
Move is one write to `:time :at` and REFUSES a destination that would overlap,
because moving a drawing and re-timing the ones around it are different
intentions — clear the room with `blank` or `trim` first, which is the
composition. Blank leaves a gap and does not close it; a cel wholly inside
the range goes, one overlapping an end is trimmed to it, and the one spanning
the range is split. Their drawings stay in the library, since a lane does not
own its content.
All three are the same geometry as `split`: a `:span` is in the cel's own
frames, so moving an edge is one write and `:time` and `:playback` are never
touched. That is why trimming the front of a playing insert starts it later into
its animation instead of restarting it — the difference between trimming and
slipping, and the reason they stay separate commands.
Correction layers EVALUATE. `channel/problems` used to refuse an `:over` stack
and `value-at`/`cursor` used to throw on one; both now read it, and the
agreement test that holds the optimized cursor to the specification covers
stacked channels in forward, backward and random frame order. A layer's values
are themselves a channel, so a constant adjustment, a ramp and a return motion
are one mechanism; `:support` is half-open and a layer is inactive outside it;
and a layer has no time space of its own, because the node its channel is on
already has one. Nothing had to change in the codec — a channel is one leaf, so
a correction persists inside it — and nothing had to change in validation
plumbing, since `node/problems` already reports every channel's problems.
Both halves of ownership are under test at lane level: a three-frame correction
on the girl's lane reaches across the drawing boundary beneath it and leaves
every frame outside its support identical, and a correction owned by one
cel travels with that cel when a hold before it grows.
Regeneration keeps them, which is the obligation the layer design exists to
meet: `rebased` replaces a base and carries its corrections across, and a
correction the new base no longer fits is MARKED rather than dropped or
misapplied — `clip/conflicts` lists those for a view to offer, separately from
`problems`, because a conflict is a decision nobody has made yet and not a
document that will not load. Turning the mouth's `:verts` knob is a real
topology change and is what the test uses. Two latent faults turned up there and
are fixed: `regenerate-head` compared authored channels to measured ones
directly, so the first correction on the head would have stopped it following
re-measurement for good; and an incompatible offset threw in the read path,
which would have taken the stage down on exactly the case the model says to
report.
Overwrite is `blank` followed by non-rippling placement, composed inside one
transaction; insertion keeps its ripple rule. Still unbuilt: slip source,
retime, and deleting reused content. A lane cannot hold AUDIO cels — `lane-problems`
requires visual ones, though this document says a lane may hold either and
should reject only a mixture.
`domain/correction.cljs` now produces Constant adjustment, Ramp, and Return
motion layers for rotation and position. The inspector exposes them on a selected
lane or cel using an explicit range in that owner's frames; this deliberately
leaves displayed-range dragging through nested or retimed owners for later. One
Apply is one undo step. Conflicted layers are listed, can be removed, and can be
retried when the complete ordered stack is compatible again. Validation,
conflict reporting, and regeneration share that ordered-stack rule, including
coverage by adjacent replacement layers. Slip source and retime are still not
implemented; a refusal is the current behavior where the model demands an
explicit choice nobody has made yet.
The cel sheet is the same projected cels and selection addresses with its axes
turned: frames down and lanes across, so commands selected there and in the
timeline have identical targets; a gap selects its column's lane rather than
retaining a stale selection from another column. The suite stands at 437 tests and 5,804
assertions, with `frontend/test/browser/lane.mjs` driving the editor through
create, hold, overflow, undo, reuse, make unique, duplicate, split, insert,
trim, move, blank, correction authoring, and two-lane sheet targeting. Rewrite tests that encode superseded
behavior rather than preserving behavior to keep them green.
Build small adversarial documents and test their domain operations before
expanding the interface:
| Scenario | Required invariant |
| --- | --- |
| Same drawing exposed twice, then one made unique | Linked edits affect both before copying and only the selected content after |
| Holds, playing inserts, nonzero in-points, and gaps on one lane | Source behavior is independent of property key count and channel encoding |
| Adjacent cels, final hold, split, trim, ripple, and overwrite | Exact boundaries, stable IDs, deterministic collision handling |
| Extend a hold under lane keys and cel-local corrections | Lane key times remain fixed; later cel corrections travel with their owners; source playback origins survive |
| Ripple beyond the symbol end | Explicit extent policy; refusal leaves the document unchanged; resizing and retiming undo together |
| Girl on twos over a moving background | Drawing cadence does not quantize either lane's continuous properties |
| Three-frame correction crossing a drawing boundary | Exact support, same result outside it, one undo step |
| Nested retiming, holds, loops, and fractional sampling | Explicit time spaces; ambiguous inverse edits cannot silently choose a target |
| Audio inside changing source cels | Only active intervals sound, with correct trim and source timing |
| Regenerate with corrections and a topology change | Compatible edits survive; incompatible ones produce actionable conflicts |
| Reference cycles and deletion of reused content | Inactive references are validated too; no dangling references |
| Save/load and command undo/redo | Identity, source maps, corrections, and evaluation round-trip |
| Timeline and cel-sheet invocation of one command | Identical document changes and selection targets |
| Random forward/backward seeks and export | Reference and optimized evaluation agree, including defaults and absence |
| Concurrent commands on overlapping and disjoint targets | Transactions remain valid; conflicts are explicit and undo preserves others' work |
Implementation order: cel ownership and playback semantics; shared
commands and validation; correction layers and time-addressing contracts; then
breadcrumb, cel strip, and a cel-sheet projection. Use those two temporal
views plus direct stage editing to prove the model before broadening the UI.
A new presentation should not require duplicate animation state. A genuinely new
authoring capability may require new domain data. The model is successful when
such additions have a clear owner and compose with existing operations, not when
it can claim that no future feature will ever need another field.

161
docs/lane-nesting-notes.md Normal file
View file

@ -0,0 +1,161 @@
# Lane nesting interaction notes
Status: design note, 2026-10-01. This records the interaction before more lane
UI is implemented.
## The capability that must not be lost
A lane owns temporal placement, but a symbol instance is still a doorway into
another symbol. A drawing accidentally authored at the root must be movable into
an instance in any lane, including another lane, without changing its visible
position or timing.
That operation already exists as `nest/move-node`. It resolves the source and
destination at the current root frame, transplants the node, and re-expresses
its transform and time under the new parent. The lane UI must expose a target
path for it; it must not replace it with a weaker `:parent` assignment.
There are therefore two different drag intentions:
1. **Temporal move:** drag a clip body onto lane space. It remains a clip in a
lane, moves in time, and claims the destination interval by trimming/removing
incumbents.
2. **Structural move:** drag from the clip's grab affordance onto another symbol
instance. The dragged node is transplanted into the target instance's source
symbol with `nest/move-node`, preserving its world transform and root timing.
These cannot be inferred from overlap alone. Dropping clip A onto time occupied
by clip B already means “A claims that time and trims B.” Structural nesting
therefore needs an explicit grab affordance/mode. Its cursor is `grab` and
`grabbing`; trim edges keep their resize cursors and the ordinary body keeps its
timeline-move behavior.
Both visible clip blocks and an expanded symbol header are structural drop
targets. This permits moving a root drawing directly into `symbol-3` even when
its lane is collapsed.
## Compact expansion: one selected-clip portal
Expanding a lane must not restore row-per-clip vertical growth. Instead, an
expanded lane reveals exactly one clip portal: the currently selected clip in
that lane.
```text
▾ foreground lane [symbol-1][symbol-2][symbol-3]
▾ symbol-3 instance/source header and drop target
▸ body lane nested rows, mapped to the root ruler
▸ face lane
position nested keyframes mapped to root time
```
- Selecting another block in the same lane swaps the portal in place.
- With no selected clip in that lane, expansion shows a compact “select a clip
to inspect” row. It must not follow the playhead during playback; that would
make the timeline restructure itself while playing.
- The portal header represents the selected instance and is the structural drop
target for moving root or sibling content into its source symbol.
- Sub-expanding the portal uses the existing recursive symbol-row walk. Nested
lanes and channels are mapped through the instance clock into the open/root
ruler, as ordinary expanded instances already are.
- The lane's own transform/channel rows remain available separately. They affect
every clip in the lane and are not properties of the selected portal.
This keeps the cost of inspection constant: an expanded lane adds one selected
symbol branch, not one branch for every temporal clip it contains.
## Keyframe visibility
Two levels should be visible without changing editors:
- The selected clip's instance-level keys (transform, visibility, corrections)
appear as ticks inside that clip block on the lane row.
- Expanding the lane opens the selected clip portal, where source-symbol and
recursively nested keys appear on their own rows, mapped to root time.
Thus the collapsed lane answers “where does this clip change?” and the expanded
portal answers “which property inside this symbol changes?” The second view is
still the root timeline; entering the symbol is not required merely to see or
edit its keys.
## Drag targets and feedback
- Grab onto lane background: move/adopt the instance into that lane.
- Grab onto a symbol clip: structurally transplant into that clip's source
symbol.
- Grab onto the expanded portal header: the same structural transplant, with a
larger and less ambiguous target.
- Grab onto itself or one of its descendants: refuse before drop to prevent a
symbol cycle.
- A structural target receives an inset highlight and the preview stays in that
target. A lane-time target receives the dashed temporal clip preview.
- Successful structural drops expand the target lane and select the moved node
beneath the target portal, so the result is immediately visible.
## Data model consequence
No lane-as-symbol type is required. The hierarchy remains:
```text
symbol -> sequence lane -> instance clip -> source symbol -> its lanes/nodes
```
Lane membership owns time partitioning. Symbol instances own composition
nesting. The UI may present the selected instance below its lane, but that is a
derived portal, not another ownership edge and not a duplicated node.
## Implementation order
1. ~~Render instance-level key ticks within lane clips.~~ Done: a clip's keys
are on its block, drawn after the blocks so they land on the one they
belong to.
2. ~~Add selected-clip portal expansion to `timeline/rows`.~~ Done, with two
additions the note did not anticipate:
- The portal is chosen by the whole LINEAGE of the selection, not the
selected id. Selecting a shape inside the clip, or the end of its span,
is still working inside that clip, and matching the id alone closed the
portal the moment anything under it was touched.
- A HELD clip opens too. `clip/source-time` is nil for a hold, so the walk
used to stop there and the inside of every drawing was unreachable from
the root timeline. Its rows are now shown across the hold and marked
`:unmapped?`: no keys, and no draggable edges, because no frame inside it
has a place on this ruler.
3. ~~Add the explicit structural affordance.~~ Done as SHIFT on a clip-body
drag rather than a separate grab handle: shift turns a temporal move into a
structural one, the target clip takes an inset highlight, and a label by the
pointer says which of the two is about to happen.
4. Route structural drops through `nest/move-node`. **Wired, and blocked in
the domain.** The gesture asks `nest/move-refusal` on the way past, so the
label says before the drop what the command would say after it. Two
refusals stand in the way of ordinary use:
- *both have to be on screen at this frame.* Inherent, and worth keeping:
the move preserves the world transform and there is no common frame to
preserve it at otherwise. It does mean nesting one clip into another in
the SAME lane can never work — a lane never overlaps itself — so this is
a between-lanes gesture with the playhead somewhere both are showing.
- *a held or looping clip has no clock to move through.* `nest/inside`
returns no `:time` for a hold, and a held one-frame drawing is the most
common thing in a document, so today nesting into one is refused — which
is most of what anybody would try.
5. After the transplant, expand the destination portal and reveal/select the
moved row. `::ui/move-node` already selects the moved node and opens the
rows down to it; the portal follows from the lineage rule in 2.
## The held destination, unresolved
A held cel shows ONE source frame for its whole span, so there is no
invertible map from the lane's frames to the drawing's and `move-node`
refuses. But the refusal is stronger than the facts require. Inside a frozen
destination only one frame is ever observed, so:
- the RATE of any map into it is unobservable — every rate shows frame `in`;
- what IS observable is that the moved node should show, at that one frame,
what it shows now at the current root frame.
That pins a unique sensible answer — rate 1, aligned so the current frame maps
to the shown frame — and nothing else about the mapping can be seen. If that
argument holds, it is a rule rather than a guess, and it is the difference
between structural nesting working for drawings and not working at all. It
needs its own proof: a drawing authored at the root, nested into a held cel in
another lane, sampled before and after to show the same picture, in the style
of `drawn` in `lane_test`.

View file

@ -0,0 +1,103 @@
# Multi-face representation
Status (2026-09-29): representation work complete. Reopen it for a concrete
requirement, rather than another round of abstract alternatives.
Implemented: each tracked face has a drawing timeline, placed by an ordinary
symbol instance. Timelines already provide local node names, independent playback,
and persistence. No new kind of scene container is needed.
```clojure
:symbols
{:main {:nodes {:root {:time {:mode :map :expose 2}}
:face {:parent :root :channels <source-to-stage placement>}
:face-1 {:kind :instance :source {:symbol :face-1}
:parent :face :z "a0"}
:face-2 {:kind :instance :source {:symbol :face-2}
:parent :face :z "a1"}}}
:face-1 {:nodes {:head {...} :mouth {:parent :head ...} ...}}
:face-2 {:nodes {:head {...} :mouth {:parent :head ...} ...}}}
:features
{:face-1/mouth {:subject :face-1 :timeline :face-1 :area :mouth
:nodes [:mouth :mouth-in]}
:face-2/mouth {:subject :face-2 :timeline :face-2 :area :mouth
:nodes [:mouth :mouth-in]}}
```
The example omits ordinary ids, frame counts and channel details.
## What belongs where
- A **subject** identifies a source track and supplies shared measurement settings.
Its timeline has the same id and contains its measured `:head`.
- A **feature** owns nodes in an explicitly named timeline. Its clip-level id is
qualified when generated; ownership is read from fields, never parsed from ids.
- A **node** has a local name. Parents, stencils and pose groups use local names too.
- An **instance** places and retimes a drawing. Its pose tracks can hold one face's
mouth while the other face continues moving.
Subject metadata and drawing timelines remain separate facts. Hand-drawn timelines
need no subject. Features retain explicit timeline references, so their locations
are not inferred from their labels.
Both filmed faces share one source-to-stage transform. Fitting them independently
would stack them at the center. Additional placement uses each instance's ordinary
channels. Cross-face draw order is the instances' `:z` order.
## Consequences
Regeneration updates the addressed timeline directly. There is no temporary swap
into `:main`, no special stage regeneration path, and no renaming of parents or
stencils. Composing a stage moves the take's root into the library and preserves
its child timelines. Settings appear once per tracked object, since all placements
read that same drawing.
Presence masks inside a subject use local feature names, matching measurement.
Freeze qualifies them when building block descriptors. Head and retained-source
blocks explicitly name their subject; otherwise two faces with identical detection
masks could produce different bytes under the same key. Analysis addresses also
include detection capacity and assignment settings, so old single-face detection
results cannot satisfy a new multi-face request.
Validation counts node ownership by `[timeline node]`. The server requires one
complete set of retained source roles **per subject**, rather than exactly three
blocks for the entire analysis.
Nested rectangles retain fractional sizes until rasterization. Rounding inside a
face timeline discarded small head-local pupils before the source-to-stage scale
was applied. This was a real rendering error missed by the earlier proposal's
coordinate-only benchmark; the regression now compares all mark extents as well.
## Verification and limits
`frontend/test/arthur/flow/multi_face_test.cljs` exercises two distinct subjects,
source block separation, detection and feature gaps, independent pose cuts and
regeneration, nested stage save/load, and equivalence to a flat single-face scene.
Existing geometry, raster, source, and regeneration tests cover the same paths.
`clips/tests/test_api.py` checks complete source roles per subject and immutability.
The browser suite checks rendering, pupils, playback, save/open, drawing and upload.
Assignment remains a nearest-centroid heuristic, with a version and distance gate
recorded in the analysis. Reordered detections, late arrivals and gaps are tested;
identity through crossings or long disappearances is not guaranteed. Assignment
happens before measurement, so correcting it requires measuring again.
This changes the freeze and retained-source contracts. It does not migrate older
flat captures; reanalyze their footage to use the new regeneration path. Existing
rendering and leaf codecs still understand their node/channel representation.
## Next steps
1. Commit the verified checkpoint: 319 frontend tests, 44 API tests, browser
checks, and app/test builds passed. Builds reported no warnings.
2. Exercise real two-person footage, especially crossings, late arrivals and
disappearances. Check assignment before treating the resulting geometry as
evidence about the representation.
3. Build performance-pose selection and instance-scoped picture rates, reusing
the existing held-frame lookup and pose groups.
4. Then build plate-drawing selection and independent tracing references.
The [timing handoff](timing-handoff.md) owns the detailed next implementation
sequence. Older flat captures need reanalysis unless a migration is separately
undertaken to preserve their authored edits.

66
docs/one-grid-plan.md Normal file
View file

@ -0,0 +1,66 @@
# The editing grid is the symbol's own frames
## What was wrong
Every number a person authors — a span, a `:time :at`, a key, a cut — is in the
frame space of the symbol it lives in (`docs/time.md`, and that part is right).
The timeline, though, drew its ruler in OUTPUT frames: `clip/output-frames`, the
transport's length. In a 12fps project holding 30fps symbols those two spaces sit
at a ratio of 2.5, so:
* a clip at symbol frame 31 was drawn at ruler frame 12.4 — **no clip edge landed
on a frame mark**, because almost none of them can;
* a gesture measured in ruler frames had to be multiplied into the symbol's
frames and rounded (`nest/dragged`), so dragging one ruler frame moved the clip
2 or 3 symbol frames — **0.8 or 1.2 ruler frames, never the 1 the pointer
said**. That is the jumpiness;
* the preview drew the gesture's own number of ruler frames while the commit
wrote the rounded one, so **the ghost sat somewhere the settled clip did not**;
* before the rounding was added, the fraction went into the document and every
later edge edit on that clip was refused for ever (`span/*` refuses a
fractional edge, as it should).
One cause, four symptoms. None of them is an edge case to patch.
## The model
1. **A symbol's own frames are the only coordinate anything authored lives in.**
Unchanged.
2. **The editor edits in the open symbol's frames.** The ruler, the marks, the
playhead's position on it, every pointer→frame answer, every drop frame and
every drag delta are the open symbol's frames. No multiplication anywhere in
the gesture path, so no rounding and nothing fractional to refuse.
3. **The output grid is playback's alone** — the clock, the audio mix, export,
and the frame the stage draws. Exactly two pure functions cross between them
and nothing else does:
* `clip/shown-frame clip sid f` — which of `sid`'s frames output frame `f`
shows (`cadence/frame`: the latest at or before it).
* `clip/first-output-frame clip sid n` — the output frame that first shows
symbol frame `n`; the inverse, for seeking from the ruler.
4. `nest/inside`, `nest/placement`, `nest/spans` and the gestures take the
subject symbol's OWN frame. They used to take an output frame and multiply it
secretly, which is what made every caller's units a guess. A caller holding
the playhead converts with `clip/shown-frame`, at its own edge, visibly.
## The gestures
5. **One pointer→frame function** for the whole timeline, `frame-under`. A drag's
delta is the difference of two of its answers, never a pixel ratio rounded
separately — so the preview and the commit are the same number by
construction.
6. **The junction between two clips is one handle with one meaning**: roll. It
moves the end of the left clip and the start of the right one together, which
is `span/roll`, which is already nothing but `resize-out` then `resize-in`.
The three 4px-wide zones it used to pick between — trim-left, roll,
trim-right, inside twelve pixels — were the "it just picks one" the handle
was accused of. An edge that is not shared still has its own in/out handles.
## Audio goes with the picture it belongs to
A take's sound lived inside the take symbol, so placing the take brought it and
placing the FACE the take is made of brought nothing. A symbol now says what it
sounds like — `:audio`, a sound source — and placing one places a linked audio
node beside the clip. Detection sets it on the face it extracts, which is the
automatic link; `::ui/link-audio` sets or clears it by hand, which is the manual
one. `:linked-to` on the audio node already existed and already follows a moved
picture.

470
docs/port-plan.md Normal file
View file

@ -0,0 +1,470 @@
# arthur — port plan and handoff
Self-contained. You should not need any prior conversation to execute this.
**Implementation status (2026-09-29):** steps 0–9 are in. Step 6 reads extracted
footage, detects landmarks with local MediaPipe assets at full source cadence, and
runs the same freeze path as the synthetic take. The scene time map can sample the
frozen roto at a lower picture fps without changing source analysis, duration or
audio. Step 7 adds dense eyelids, shared gaze, brows and pixel-derived teeth.
Step 8's data model has stable feature identity, feature-level presence, explicit
eye pairs and shared parameter definitions; a manifest can supply known feature
absence intervals through measurement and freeze. Step 9 adds the Django backend,
the three-tier split, content-addressed tier 2 with the detector version inside
every key, leaf addressing for tier 1, and project load/save that round-trips.
Step 8 now has parameter controls and scoped regeneration from retained source.
Multi-face representation is complete: each tracked subject has a drawing
timeline, placed by an ordinary symbol instance. See
[multi-face representation](multi-face-representation.md) for the implemented
model, verification and compatibility limits.
**Next, in order:** commit the verified checkpoint; exercise real two-person
footage, including crossings and disappearances; build performance-pose
Suggest/Keep/Drop and instance-scoped picture rates; then add plate-drawing
selection and independent tracing references. The
[timing handoff](timing-handoff.md) records current code and implementation order.
Reopen the representation only for a concrete requirement it cannot express.
**Still open:** real-footage identity validation, automatic per-feature detection,
the timing and tracing work above, and time-varying parameter settings. Older flat
captures need reanalysis for the new regeneration path; no migration is included.
The step descriptions below retain the original port scope; this status and the
linked handoffs describe subsequent work.
## What arthur is
A tool that turns live-action video into 2D animation that reads as
hand-authored: flat polygons, a tiny indexed palette, hard edges, 320×200, no
antialiasing, motion carried by silhouette. It tracks a face out of a clip,
reduces the lip contour to a handful of vertices, derives teeth from image
content, and renders flat indexed fills.
It currently works, as vanilla JS ES modules with no build step. `python3
serve.py`, open `127.0.0.1:8777`. **Synthetic take** exercises everything below
detection with no video needed.
This plan converts it to ClojureScript + re-frame, restructured around one
uniform animation data model, and adds a Django backend for persistence and
(later) collaboration.
## Status of the existing documents
| File | What it is | Authority |
| --- | --- | --- |
| `js/**` | the working tool, ~4,800 lines | **authoritative.** The comments encode bugs that actually happened. |
| `docs/animation-model.md` | the target data model: nodes, channels, symbols, time maps | build to this |
| `docs/architecture.md` | module layout, stages, sync and baking design | build to this; much of it is future scope |
| `docs/design.md`, `README.md` | prior synthesis by an earlier agent | useful, **not authoritative**. Revise freely. Do not treat its aesthetic claims as settled requirements. |
Where a document and the code disagree, the code wins, and the invariant list
below is lifted from the code for exactly that reason.
## Target repo layout
Both halves live here. Django at the root, because `manage.py` at the root is the
convention and keeps every `python manage.py` invocation working with no `cd`.
```
arthur/
mise.toml toolchain for both halves
manage.py
requirements.txt
server/ Django project: settings, urls, asgi, wsgi
clips/ Django app: models, views, consumers, routing, migrations
frontend/ the CLJS app
shadow-cljs.edn
package.json
src/arthur/** namespace root stays arthur.* whatever the dir is called
test/arthur/**
static/arthur/js/ shadow-cljs output, collected by Django staticfiles
static/arthur/audio.wav the synthetic take's clock. NOT extract.sh's output —
that is tier 3 and lives in the blob store
var/blobs/ the content-addressed blob store: tiers 2 and 3. Gitignored
docs/
js/ index.html serve.py extract.sh the old tool — see "the oracle"
```
The namespaces step 9 added, since the list under **Namespaces** in
`docs/architecture.md` predates them:
```
domain/sha256.cljs SHA-256, synchronous and pure, byte-compatible with hashlib
domain/canon.cljs the one canonical text for a descriptor, so hashing it means
something
domain/leaf.cljs leaf addressing: the document as path -> value
domain/wire.cljs transit for tier 1, base64 for tier 2
domain/project.cljs clip <-> the document and blocks that travel
flow/address.cljs tier-2 keys, and the invalidation table they are built from
fx/http.cljs the only namespace that talks to the server
events/project.cljs save and open
```
`clips` is a naming call, not a constraint — it is the Django app holding
Project, Clip, Footage, Analysis, Leaf and Revision. Rename in one line if
something fits better.
Dev runs two processes and they do not talk to each other: Django serves the page,
`shadow-cljs watch app` rebuilds into `static/arthur/js`, which is already
`:output-dir` in `shadow-cljs.edn`. `:dev-http` is gone.
```sh
mise exec -- python manage.py runserver 8778 # from the repo root
cd frontend && mise exec -- npx shadow-cljs watch app
```
## Toolchain
`mise install` from the repo root. `mise.toml` pins java 21+, node 20, clojure,
python 3.12, and creates `.venv`.
Verified to resolve cleanly: `reagent 1.2.0`, `re-frame 1.4.3`, current
shadow-cljs.
## Scope
**In:** analysis → keyframes → playback. The pure numeric core, the animation
data model, a player, the measurement stages, and freezing measurements into
channels.
**Out, and do not build it:** paint and cels; `suggest` (it only decides which
frames get a hand-drawn cel, so it has no job until drawing exists); the timeline
and sequences; symbols and the plate library; multiplayer; the override layer.
Each is designed for in `docs/architecture.md` and `docs/animation-model.md`.
Leave the `:over` field present and empty; leave `:symbol` out entirely.
## The data model
Full specification in `docs/animation-model.md`. The subset to build:
```clojure
;; The scene is a flat map of id -> node. Parent pointers, never nested maps.
{:id :mouth :kind :poly :parent :head :z "a3" :stencil nil :span [0 240]
:time {:mode :inherit} ; or {:mode :map :expose 2 :offset -1 :rate 1.0}
:channels
{[:xform :pos] {:animated? false :value [0.0 0.0]}
[:xform :rot] {:animated? false :value 0.0}
[:xform :scale] {:animated? false :value [1.0 1.0]}
[:xform :skew] {:animated? false :value [0.0 0.0]}
[:geom :pts] {:animated? true :interp :hold
:dense {:store "sha256:…" :offset 0 :stride 40 :frames 600}
:generated {:by :roto/lips-outer :analysis "sha256:…"
:params {:verts 8 :contour-avg 1}}
:over []}
[:style :color] {:animated? false :value :skin-dark}
[:vis] {:animated? false :value true}}}
```
Three channel shapes, one accessor `(value-at channel f)`:
- `{:animated? false :value v}` — static. A thing that simply exists.
- `{:animated? true :interp :hold :keys {0 v, 4 v}}` — sparse, authored, in the
document. **Keys are a map by frame, never a vector.** Store a plain map
(transit loses sortedness) and build the sorted index in the resolver.
- `{:animated? true :interp :hold :dense {...}}` — generated, one value per
frame, in a typed array outside app-db.
`:generated` is provenance and **the renderer never reads it.** It is what the UI
uses to offer a parameter panel instead of raw keys. It lives on the *channel*,
not the node, because a node wants a rotoscoped `[:geom :pts]` and a
hand-animated `[:xform :pos]` at the same time.
`:skew`, `:span` and `:over` stay in the shape even though nothing drives them
yet: each is a component of a decomposition or of a composition order, and adding
one later migrates every stored transform.
`:anchor` was in this list and has since been **deleted**, which is the one place
the reasoning above came out wrong. It is not a component of the decomposition:
`T(a)·M·T(-a)` is `M` conjugated by a translation, and a parent already is a
translated frame, so an anchor is a peg written inline — one that cannot be
selected, keyed, shared, or put above a measured channel. Rotation and scale
happen about the node's own origin; a pivot nobody chose is derived per drag by
`domain/gesture` and a pivot somebody chose is a peg. See
docs/animation-model.md, "There is no `:anchor`, because an anchor is a peg".
Transform composition, per node:
```
local = T(pos) · R(rot) · K(skew) · S(scale)
world = world(parent) · pinv · local
```
## What the prototype knows that you would otherwise rediscover
**The JS is a prototype.** Its conclusions about what looks right are provisional
and you may revisit any of them; several contradict each other already. But a few
things in it are not taste — they are facts about MediaPipe, about the maths, or
about what an operation means — and those cost real time to rediscover.
### Mechanical. Getting these wrong produces wrong output, not a different look.
1. **MediaPipe's normalised space is anisotropic.** It divides x by image *width*
and y by *height*, so equal numbers do not mean equal pixels. Multiply x by
`aspect = W/H` before any fit, or a "similarity" fitted in that space is not
one and head roll comes out subtly wrong. When mapping pixels for an underlay,
**both** axes divide by `imgH`.
2. **MediaPipe's left/right naming is viewer-relative in some places and
subject-relative in others.** Any left/right pairing read off a table is a coin
flip, and a swap looks *almost* right — each eye still has an iris roughly
where it belongs — so it survives inspection. Resolve it from geometry.
3. **Ring tables are ordered traversals**, and slot position is the vertex's
identity. That is what makes temporal correspondence possible at all, whatever
you decide the shapes should look like. `subsampleSlots` returns ring
*positions*, not landmark ids.
4. **A wrongly-ordered ring self-intersects, and it is invisible at odd vertex
budgets and obvious at even ones.** If you keep ordered rings, assert
simplicity in a test; no amount of looking will catch it reliably.
5. **Scaling a ring to thicken it collapses when the ring is degenerate** — a shut
eyelid scaled by 1.1 is still shut, so the lash line vanishes on exactly the
frames where it is the whole drawing. A fixed radial offset does not. Maths,
not taste.
6. **A fractional centre for a small integer-sized shape changes its size.**
Round the origin, not the extents, or a 3px mark is 3px on one frame and 4px on
the next.
7. **Order of operations on time:** flooring onto a grid and shifting against the
clock do not commute. Shift first and the floor discards it on most frames.
### Choices the prototype made. Revisit freely; here is what each was for.
| Choice | Its stated reason | How you would learn it was wrong |
| --- | --- | --- |
| similarity (4 DOF), not affine | extra DOF absorbs out-of-plane head rotation as shear and smears it into the mouth | the residual readout stops responding to head turn |
| reference is the Procrustes mean over the shot, not frame 0 | no single frame's idiosyncrasies get baked into every other | one frame's detection error biases the whole take |
| smooth the transform, not the contour | sparse keys at velocity minima rejected detector noise for free | it was already broken by a "bounded exception" once keys went dense, so it was never a law |
| gaze measured against the eye's corner midpoint | measured against the lid, every blink drags the origin down and fakes a glance at the floor | gaze correlates with blinks |
| one gaze shared by both eyes | at this size the per-eye difference is noise, and independent noise reads as wall-eyed | a wink or a real vergence is lost |
| hold, never interpolate | a tweened mouth reads as puppet software | motion looks stepped rather than snappy |
| palette indices, never sampled RGB | sampling colour produces a pixel-art filter irrecoverably | — |
These are where to look first if the output is wrong. They are also where to look
first if you want to change the look.
## Conventions
- `domain/*` may not require `flow/*`; neither may require `re-frame`.
- Every flow function is `(f params inputs) -> output`. No state, no db, no atoms.
- Nothing below `subs/` calls `subscribe`.
- Every analysis function that reads pixels takes a `debug?` flag and returns its
intermediate masks alongside its result, the way
`interior.js/extractTeeth(..., wantDebug)` already does.
- Port the invariant comments across verbatim. They are the most valuable text in
the repo.
## The oracle
**Keep `js/`, `index.html` and `serve.py` in the tree through step 5.** They cost
nothing, `serve.py` still runs the old tool, and they are the numeric oracle:
run both implementations on the same synthetic track and diff.
`fit-similarity` and `procrustes-mean` should agree to **1e-9**; a larger gap is a
port bug, not float noise.
**Parity proves the port is faithful, not that the answer is right.** The JS is a
prototype, so keep the two kinds of test apart: a *parity* test pins behaviour
while you move it, and is deleted once the move is done; a *correctness* test
asserts something you have decided you want, and stays. Conflating them bakes the
prototype's mistakes into the rewrite and makes them permanent. Delete them in one commit once the CLJS player renders
the synthetic take correctly.
**Do not port the debug views** (`drawPanes`, `drawInteriorDebug`,
`drawEyeOverlay`, `drawGazeDebug` in `js/app.js`). The knowledge in them is not
the canvas calls — it is *which things you must see to tune teeth*: the source
crop, the in-region mask, the surviving mask, and the local contour. That contract
already exists as `extractTeeth(..., wantDebug)` returning
`debugCanvas(src, inReg, mask, pw, ph, local)`. **Port the payload, skip the
drawing.** Redrawing it is ten lines whenever it is wanted.
## Steps
Each step ends somewhere runnable. Do not proceed past a step whose "done" does
not hold.
### 0 — scaffold and the oracle
`mise install`. Create `frontend/` with shadow-cljs, reagent, re-frame. Port
`synth.js` (the synthetic landmark generator, including its `swapIris` flag) and
the numeric assertions from `selftest.js` to `cljs.test`.
**Done:** the suite runs and fails informatively.
### 1 — the pure bottom
Port verbatim: `landmarks.js` → `domain/landmarks`, `mathutil.js` → `domain/geom`,
ring helpers → `domain/ring`, `raster.js` → `domain/raster`, the palette →
`domain/palette`.
**Done:** tests pass, including ring simplicity and the swapped-iris vote. Numeric
agreement with the JS to 1e-9. Nothing renders.
### 2 — the data model, with no analysis in it
`domain/channel` (`value-at` across all three shapes, plus a per-channel cursor),
`domain/node` (transform composition), `domain/scene` (topological order by parent
depth, `eval-frame` → draw ops in z order).
Hand-write a scene in EDN — a rectangle parented to a group whose
`[:xform :pos]` is keyed on four frames — and render it through `domain/raster`
into a canvas.
This is deliberately before any analysis. **The data model has never been
validated; find out here**, with fifty lines to throw away, rather than after
porting nine hundred lines of measurement into a shape that does not work.
**Done:** something moves on screen.
### 3 — the player
`clock` (audio-clocked: `frame = ⌊currentTime · fps⌋`, so a slow loop drops frames
instead of drifting; ½× and ¼× come free from `playbackRate`), the rAF loop, a
`::resolver` sub, and transport UI.
The loop reads and blits and **dispatches nothing**. The sub yields a resolver
closure; the loop applies it at the playhead. The playhead itself lives in app-db
like everything else — with layer-2 extractors and layer-3 computations, a
playhead tick does not invalidate the expensive stages.
**Done:** the hand-written scene plays at 30fps against audio, scrubs, and runs at
½× and ¼×.
### 4 — measure: anchor and mouth
Port `stabilize` and the lip rings out of `pipeline.js`. Split **condition**
(`smoothTransforms`, `smoothContours`) into its own stage so the two smoothing
knobs do not re-run measurement.
**Done:** measured numbers match the JS on the synthetic track. Note that
parity here is on `stabilize`'s output, not on `toRasterRing`'s — the framing
step is being deleted, not ported.
### 5 — freeze
The new module, and the heart of this work: measurements → channels. A dense
`[:geom :pts]` block per node — `Int16Array[frames × verts × 2]` in the node's
own local space, with the block's fixed-point scale in its header — plus
`:generated`. Fixed topology is what makes this a rectangular array with no
per-frame header.
**Do not port `makeXform`.** The prototype bakes the framing into the stored
numbers: it centres on the face oval's bbox and zooms until the face is 80% of
the raster height, so every vertex carries a cropping decision made once from one
frame's landmarks. Dropping it is a deletion. Placement becomes `[:xform :*]` on
an authored `:place` node inside the face, the stage clips whatever hangs off,
and project
dimensions stop being tied to the footage. See "What space geometry is in" in
`docs/animation-model.md`.
The anchor transform freezes onto `:head`, one level under `:place`, and the
normalise on/off/per-plate toggle is which of the three channel shapes that node
carries. Always measure and always store factored, whatever the toggle says:
smoothing and velocity-minimum key selection both require the split to exist in
storage.
**Done:** the synthetic take plays back as a moving mouth. Full vertical slice.
### 6 — detect
MediaPipe interop behind one namespace; real frames, real audio, real fps from
the manifest. **Vendor the wasm** rather than fetching from jsdelivr — it is
currently the only thing in the tool that silently requires a network.
Decode every source frame for analysis. A lower output picture fps is a time map
over frozen channels, not a reduced detection track. Selecting source frames to
trace into cels is independent again and remains outside this port's paint scope.
**Done:** real footage plays back as a rotoscoped mouth.
### 7 — the rest of measure
Eyes (openness, gaze, iris pairing vote, blink resolution with its `hold`), brows
(raise and tilt at both ends, both correspondence votes), interior (otsu,
morphology, components, radial contour). Each keeps its `debug?` payload.
Two things fall out of the model instead of being written: the brow's
measure-the-height-out-and-put-it-back is `[:geom :pts]` plus `[:xform :pos]`, two
channels on one node; and the iris is a `:disc` node parented to the lid ring and
stencilled by the sclera.
**Done:** the same face parts are measured and rendered through the CLJS scene,
minus paint. The fixed pixel thresholds remain provisional; step 8 exposes their
parameters for tuning without changing the source track or picture timing.
### 8 — knobs — DONE for static settings and scoped regeneration
Build the parameter model before its UI. Define each parameter once with its
default, validation, applicable area and regeneration dependencies. Store values
by stable subject and feature ID. Represent an eye pair as one group with one or
two eye member IDs from the same subject; a profile view with one identified eye
needs no invented partner. Each eye may override a pair value. Removing an eye
from a pair materialises its effective values so playback does not change. Keep the
existing frozen channels as renderer input; settings and provenance do not enter
the render path.
Carry feature-level presence through freeze and dense channel state. The same
feature ID covers every observed run across occlusion; a missing measurement
has no channel value on that frame. Full-face detection is the fallback mask
until there is a feature-level detector or authored presence data. A shared gaze
measurement may still feed two independently identified eyes. A small manifest
annotation can supply feature absence intervals now: the loader expands them
before measurement, so invalid eye landmarks are ignored and gaze uses the
visible eye. This is an input format, not a control UI or an automatic detector.
Use leaf-addressable settings under the clip, subject, feature and optional
group. Retain source measurements so a setting change can regenerate affected
channels without re-detecting footage. Static parameter controls and scoped
regeneration are implemented for takes and composed stages. Time-varying
parameter values remain deferred.
### 9 — backend — DONE
Django project, the `clips` app, models for
Project/Clip/Footage/FootageFrame/Analysis/Block/Leaf/Revision/Blob, and project
load/save. Round-tripping a project through the server is the proof the model
serialises.
Django was the easy half; the tier split was the work. What it came to:
**Tier 2 keys are content addresses over every input**, and the detector version
is in every one of them, through the analysis id that each block descriptor names.
`flow/address`'s `block-knobs` is the invalidation table, and it is not trusted:
`address-test` re-freezes the take once per knob and asserts the biconditional —
a block's bytes changed if and only if its key changed. That test found two
things reading the code would not have. `brow-pos` does not depend on
`contour-avg`, because the brow RING is smoothed and the raise is not. And the
first version of the test was itself wrong: a 3% perturbation of `gaze-gain`
moves every sample inside the grid cell `quantize-snap` had already rounded it
into, so the bytes came out identical and the knob looked like an input the block
did not have.
**The server verifies what it is handed.** It recomputes every key from the
descriptor stored beside it and refuses a mismatch, refuses an analysis that does
not declare a detector version, and refuses a document naming blocks it does not
hold. It hashes the descriptor TEXT rather than re-rendering it from parsed
values, because JS prints an integral double as `1` and Python as `1.0` — a
scheme where both sides re-render breaks on the first parameter whose value
happens to be whole.
**Tier 3 is served by hash.** `extract.sh` still decodes; `manage.py
ingest_bundle` hashes the result into the blob store, by hard link. The manifest
the client receives now carries a URL per frame, so the frame layout stopped being
a shared secret between a shell script and a ClojureScript namespace. The
cache-busting `?v=` on every frame URL went with it: a blob's name is the hash of
its bytes, so a stale copy is not a thing that can happen.
**Leaf addressing exists**, with conditional writes and a monotonic project
version, so the sync design has nothing to retrofit. The socket, presence and
broadcasts are still out of scope.
Two loose ends from step 8 closed on the way. `pack` no longer takes a
`(track, frame)` predicate whose call sites each derived a feature from an index —
every track names the feature it follows, which deleted five hand-maintained
mappings and handed `flow/address` the same list for its observation digest. And
the `presence-check` binding in a `let` nobody read is now an ordinary `doseq`.
**Output is not in this plan.** The `.take` writer in `js/take.js` was for
driving an Animator Pro render script and it is not where this is going: the
target is encoding video in the browser, and that is a separate piece of design
nobody should pre-empt by porting the old one.
## Two things to not foreclose
Feature controls now handle more than one face; editing presence remains future work.
The underlying identity, occlusion and group association model begins in step 8:
- **Presence is not visibility.** An occluded feature has *no value* on a frame,
which is different from a part being hidden. Dense blocks carry a per-track,
per-frame absence mask; `[:vis]` remains the sole hiding mechanism.
- **Params carry stable identity.** A subject and its features keep their IDs
across observation gaps. A run of visible frames is not a new identity.
The current identity tracker uses nearest-centroid assignment. Validate it on
real crossings and disappearances before choosing a more elaborate policy; the
iris and brow correspondence code offers whole-take voting as one option.

41
docs/time.md Normal file
View file

@ -0,0 +1,41 @@
# Time selection
Project `:fps` is the playback and export grid. Each symbol has its own native
`:fps` and `:frames`; keys, spans, trace choices and corrections stay in that
native space. A symbol without an explicit rate inherits the document rate;
changing project fps first records that rate so its existing timing stays put.
The untouched symbol in a new document is deliberately different: it has no
authored timing to preserve, so it stays on the project grid and its empty frame
extent is rescaled to keep the same duration. This makes changing fps before
authoring establish the editor's grid instead of preserving the 30fps default.
An output frame selects the latest native frame at or before its time:
`floor(output-frame * native-fps / output-fps)`. Thus 30fps content in a 12fps
project reads source frames 0, 2, 5, 7, 10… and retains its duration. A partial
last output frame is included. Changing back to 30 restores the original grid.
Nothing rewrites or discards the dense measurements.
The same boundary selection runs when entering a placed symbol. Placement and
artistic speed are applied before selection; the stored `:time :rate` and
`:playback :speed` never contain a frame-rate conversion. The derived maps used
by timeline rows, picking and editing account for the units of each symbol.
`clip/frames` is a native length; `clip/output-frames` is a transport/export
length. Resolver frame queries return native frames for edits.
There is one fps control. The old transient picture-fps control and node
sample-fps fields are gone. Existing exposure, trace choices and per-instance
pose tracks remain available: a pose track can hold a chosen closed-mouth frame
without deleting its neighboring measurements. Those choices stay in native
frames when output fps changes. Automatic content-aware frame selection is not
implemented; [frame-selection.md](frame-selection.md) is how it should be. An
event between output frames appears on the next output frame; it cannot create
an extra frame in a 12fps output.
Audio uses continuous time through the same derived placement maps, without
picture floors or holds. Frame-rate units cancel before Web Audio playbackRate
is set, so only deliberate speed changes affect pitch and duration. Export and
playback use the same output count and resolver.
Earlier imports with frame-rate conversion baked into stored retimes must be
re-imported. There is no second reader for that representation. Source video
presentation timestamps are still future work; this model assumes constant fps.

128
docs/timing-handoff.md Normal file
View file

@ -0,0 +1,128 @@
# Timing and frame-selection handoff
Status (2026-09-29): the multi-face representation is complete. Each face has a
local drawing timeline and an ordinary symbol instance. Keep that model; the next
feature is performance-pose selection, followed by plate drawings and tracing.
See [multi-face representation](multi-face-representation.md) for verification
and compatibility limits.
## Next steps, in order
1. **Commit the verified checkpoint.** Representation, scoped regeneration,
nested stage composition and source persistence are implemented and tested.
2. **Exercise real two-person footage.** Include crossings, late arrivals and
disappearances. Assignment is still a nearest-centroid heuristic; inspect
whether identities, landmarks and mouth crops stay together. Correcting an
assignment requires measuring again. Do not redesign the representation to
compensate for an assignment failure.
3. **Build performance-pose selection.** Propose frames from a target picture
rate, allow explicit Keep/Drop edits, and apply requests per instance. Reuse
the existing held-frame lookup and generated pose groups. Keep authored keys
and audio timing intact.
4. **Then build plate drawings and tracing.** Suggest drawing frames from head
displacement, allow manual choices, and give each cel an independently
selectable tracing reference.
Older flat captures need reanalysis for the new regeneration path. Migrating their
existing authored edits is separate work; it is not implemented by this checkpoint.
## Timing decisions
Keep the dense analyzed frames. Generated motion holds the most recent selected
source pose; removing a selected pose never deletes source data or shortens the
clip. Store edits in the animation's local frame space, so moving an instance
does not move its edits. Authored keys follow intentional instance retiming but
must not be quantized by a picture-rate request. Clip FPS and audio duration stay
fixed.
There are two selections with different owners, sharing held-frame lookup:
- **Performance poses:** propose a kept-frame list from the target picture rate,
then apply explicit keep/drop edits. A parent instance may request a lower
rate. Mouth outline, interior, teeth and visibility read the same selected
source frame; likewise each eye's coupled parts. Use group overrides when
needed, rather than a setting on every channel. Head motion currently has its
own anchor selection; do not silently put it under mouth timing.
- **Plate drawings:** start with frame 0, walk measured rigid head poses, and
suggest a frame when maximum landmark displacement from the last kept pose
exceeds tolerance. Let the artist add/remove frames. A removed drawing stays
stored so it can reappear if restored. This selection does not thin the mouth.
A target rate is approximate. Pin a useful closed-mouth pose at its actual frame,
even if that produces more changes than the target. Do not show a future pose
early to fit a grid. Manual drop wins over an automatic suggestion; make removal
of the only closed pose in a beat visible in the UI. Skip missing detections when
suggesting a replacement. Keep a frame-zero selection and hold the last selection
through the end. A skipped pose (hold), `[:vis] false` (hidden), and an absent
measurement remain different facts.
Store manual edits separately from generated proposals so changing the rate or
tolerance retains hand decisions. Selection edits change the document, not dense
blocks or analysis addresses. Verify save/open for every new field; extend leaf
handling and the relevant key whitelist if its storage location requires it.
## Current code: reuse these mechanisms
- `domain/pose.cljs` already has `prepare`, `held-frame` and `source-frame`.
Instance `:playback :tracks` map local change frames to held source frames,
keyed by pose group. Reuse this lookup; frame suggestion and Keep/Drop policy
are the missing layer. An explicit cut is not itself a complete selection UI.
- `freeze/performance-nodes` marks generated animated channels with
`:pose-sampled?` and local `:pose-group` names. This includes keyed visibility
as well as dense geometry. `:generated` remains provenance for regeneration.
- `symbol/channel-frame` already applies explicit pose choices and default
picture sampling to marked channels. Playback and export both use
`clip/resolver` with `:picture-fps`; there is no need for a second sampling
implementation. Export's pose count is still a rate-based estimate.
- The picture-rate option is currently passed through the resolver tree
unchanged. Instance-specific parent requests are still to be implemented.
Instance offset/rate must apply before selecting the local source pose.
- `pose/put-cut` and `remove-cut` currently address instances in `:main`.
A take's face instances are there, but a composed stage nests them inside a
shared source timeline. Make the editing scope explicit when adding nested
controls. A request on one outer placement must not rewrite the shared
drawing's playback settings for every placement.
- Generic root `:time :expose` still retimes descendants, and frozen takes still
store it. Paint nodes are rootless to escape it. When the selection path
replaces take picture cadence, remove that redundant quantization from the
take default; preserve intentional generic time maps. Moving exposure to
`:head` would still retime authored children.
- `freeze/head-mode` supports `:free` and `:anchored`. It keeps measured channels
dense and writes optional per-subject `:anchors` maps; it does **not** implement
`:per-plate` mode or materialize transform keys from `:kept`. Plate selection
should reuse held measured-frame addresses where appropriate, without
rerunning analysis or copying the measurements.
- `:over` hand corrections are currently refused by the channel reader. Their
future application belongs after generated pose selection.
## Performance-pose implementation sequence
1. Add pure proposal and Keep/Drop policy around the existing held-frame lookup.
Cover frame zero, nondivisible rates, manual precedence, missing poses and a
protected mouth closure. Preserve all source frames.
2. Feed instance requests and group selections into the existing channel read
path. Cover two faces, two differently timed placements of one source, nested
instances, coupled visibility/geometry, and authored keys at their normal time.
Use this same path for preview and export; keep audio duration unchanged.
3. Wire the performance strip's Suggest/Keep/Drop controls and persistence.
Replace the export pose estimate with the actual selection count. Retire the
take's redundant root exposure only when this path replaces its behavior.
## Plate drawings and tracing, afterward
The old suggestion algorithm is `js/pipeline.js:suggestPlateFrames`; the strip,
worksheet and tracing photo are in `js/app.js`. Port the useful policy over the
measured head poses and reuse held-frame lookup for the resulting drawing set.
Give a cel an editor-only source-frame reference, defaulting to its plate frame
but independently changeable. It may point to a frame omitted from either rendered
selection. Register the photo using that source frame's measured transform. The
old prototype coupled photo and cel addresses; independent tracing is new work.
The old iris socket lock, gaze origin, CLJS head anchors and registration pivot
are separate settings. Clarify what an "origin-lock" request means before adding
that control.
Keep the UI to two scopes: **performance poses** and **plate drawings**, each with
Suggest/Keep/Drop. Tracing reference and lock controls live with the cel or feature
they affect. No general keyframe framework is needed for this work.

93
docs/timing-model.md Normal file
View file

@ -0,0 +1,93 @@
# Timing model
[Time selection](time.md) defines the current frame-rate representation.
[The Lane Model](lane-model.md) defines the revised target for occurrence timing,
source playback, sampling scope, and inverse editing. It supersedes conflicting
proposals here; the sections below describe earlier implementation decisions.
The source footage, authored drawings, generated face motion, and stage placement
have different frame decisions. They share a clock but do not share one kept-frame
list. `timing-handoff.md` records earlier implementation notes.
## Frame spaces
- A source frame addresses a decoded image and its measured face data. Keep the
source cadence and, for variable-rate video, its presentation timestamp.
- A timeline frame addresses authored keys in the clip or symbol's local space.
- A stage frame is mapped through the symbol instance's offset and rate before
local frame decisions are read. Moving a placement does not rewrite its keys.
The analyzed source poses remain dense. A lower picture rate or a skipped pose
never removes source data or shortens audio.
## Head placement
Analysis fits each source frame's rigid landmarks into one common head-local
space. Its inverse is the measured head transform, stored densely on `:head`.
The head node has one optional anchor map:
```clojure
;; no :anchors free movement: read measured frame f at f
:anchors {0 12} ; one lock: use frame 12's transform throughout
:anchors {0 12, 40 42} ; keyed locks: switch to frame 42 at local frame 40
```
A key is `(local change frame -> measured source frame)`. Its value holds to the
next key. The map chooses position, rotation and scale together. Frame zero must
have a key when the map exists. The dense transform blocks remain intact, so
editing anchors is a small document change and re-freezing can replace the
measurements without losing the anchor choices.
A source image used for tracing should be registered with that image's measured
stabilizing transform, then the selected head transform, then the authored
`:place` placement the face carries. This makes the photo and head-local vectors share the same
orientation and position. Tracing-photo selection is a separate editor address;
it does not choose the head anchor.
The prototype stabilizes into the shot's mean rigid pose and uses an early
closed-mouth frame for raster framing. Those are internal analysis and framing
choices. The authored head-anchor map above controls which measured head pose is
shown over each range. It is independent of plate drawing starts.
## Performance poses
Generated mouth, eye, and brow channels can be sampled at a lower picture rate
without retiming authored keys. The normal rule picks the latest available pose
at or before a picture-grid time. A future performance policy may add important
closed-mouth poses and store manual keeps/drops separately from the rate's
proposal. Related parts should share a selected pose by default: a mouth outline,
interior, teeth and generated visibility must not disagree about its frame.
## Stage placement
A symbol placement has optional pose-cut tracks, separate from head anchors:
```clojure
:playback {:tracks {:mouth {0 12, 8 27}
:eye-r {0 0, 4 6}}}
```
These maps are also `(local change frame -> source pose frame)`. They select which
baked/generated shape pose appears on that placement. Before the first explicit
cut, normal generated motion continues. Cuts hold, without interpolation, until
the next cut. A `[:node id]` track can override one shape in a shared group.
Authored cels, transforms, and audio remain on their normal local time.
The current implementation reads retained frozen channels. A separate resolved
geometry bake is not implemented; when added, it must preserve addressable
candidate poses so stage cuts can still select any of them.
## Ownership
| Choice | Owner | Current state |
| --- | --- | --- |
| Source frames and timestamps | Footage/analysis | Constant-rate frame indexing exists; variable timestamps remain future work |
| Head anchor map | `:head` node | Implemented, stored with the node |
| Trace frames (photo address) and origin | The face's `:plate` `:time :holds`, and its `:head` `:reads` | Implemented, see `docs/tracing-symbol-plan.md` |
| Showing a tracing layer, and its opacity | Editor state, `[:ui :tracing]` | Implemented; a drawing aid, never saved or keyed |
| Generated picture-rate proposal and closure protection | Roto clip/symbol | Generated-only picture sampling exists; closure protection remains future work |
| Stage pose cuts | Symbol instance | Implemented, stored with the instance |
Preview and export use the same resolver for generated picture sampling and stage
cuts. Export still emits every timeline frame at the clip's audio rate.

411
docs/tracing-symbol-plan.md Normal file
View file

@ -0,0 +1,411 @@
# Plan: tracing is a symbol
Status: built, 2026-10-03, on branch `worktree-tracing-layers`. Where the build
differs from the plan below, the build wins:
- The head's field is `:reads` (`{:holds [...]}` or `{:holds-of :plate}`), not
`:follow`.
- A still is not one frame. It gets as many frames as remain in the symbol it is
dropped into, at that symbol's rate, and is trimmed like any clip.
- An image's `:media` is `{:image <blob sha256>}`, served at `/blob/<sha>`. The
server's `Image` row exists for the pool's list and labels, not for identity.
- Dropping media that a tracing symbol in the document already shows reuses that
symbol.
- Nothing can be created or dropped inside a tracing symbol (`creation/target`,
`drop-destination-at`, `nest/move-refusal`), and one cannot be opened in a tab.
- Schema 6. Every project is marked 6. One that still carries `:trace` on a
node is refused when opened, with what to do about it, rather than all old
projects being refused.
No backward compatibility (see `lane-model.md`, "Goal and compatibility policy").
## What is wrong today
Tracing is not a thing in the document. It is three mechanisms that each know
about faces:
- **`:trace {:frames :origin}` on a face's `:head`.** It decides which measured
frame the head reads (`symbol/base-channel-frame` → `trace/held-frame`), and
the underlay also reads it to decide which photo to show (`trace/photo-frame`).
- **`ui/underlay`**, a painter that walks `trace/shown` → `trace/faces` (a
separate instance walk), looks up each face's subject → analysis → footage,
asks the resolver where `[...path :head]` went, and builds the photo's matrix
by hand: `world(head) · M(p)⁻¹ · 1/imageH` (`trace/photo-matrix`).
- **`[:ui :trace {:faces #{} :opacity}]`**, a per-face switch in editor state,
plus the special "opening a face shows its footage, opening a take does not"
rule (`trace/showing-for`).
What you cannot do: place footage or a still as a reference where you like, move
it, scale it, turn it, trim it, hold it, put it in a lane, or trace something
that is not a tracked face. The photo is not selectable and has no row. The one
case that works (a face over its own footage) runs on code that nothing else
uses.
## The model in one paragraph
A **tracing symbol** is a symbol with `:type :trace`. It has no nodes. It names
media (a footage range or a still image) and has that media's frame count, fps and
pixel size. It is **placed by an ordinary instance**, so lanes, spans, trim,
split, move, playback (`:in :speed :end`), transform, nesting, selection, picking
and gestures all work on it with no new code. It is evaluated to a single
**`:trace` op**, which never reaches the raster or an export. A face's footage is
not a special case. It is one of these instances, placed inside the face as a
child of `:head` and carrying the measured registration transform. The trace keys
become **holds on that instance's time**. The head's "origin" becomes the head
saying **which node's frames it follows**.
This follows the precedent of `:type :palette` symbols (no nodes, they name an
asset, and they are placed by instances in a lane), so the uniformity rule holds
(see the `model-uniformity` memory). Nothing new holds nodes, and no special
instance kind is added.
## Data model
### The tracing symbol
```clojure
:footage-8625 ; a symbol id like any other
{:id :footage-8625 :name "8625.mov"
:type :trace
:media {:footage #uuid "f8ca…" :range [12 241]} ; or {:image "sha256:…"}
:frames 229 ; the range's length; 1 for a still
:fps 30 ; the footage's own rate, so cadence handles 30→12 for free
:width 1440 :height 1920 ; pixel size = the symbol's own stage
:audio {:footage #uuid "f8ca…"} ; optional, the existing "a symbol says what it sounds like"
:nodes {}}
```
- `:media` is the one new symbol key. Add it to `symbol/symbol-keys` and to the
symbol leaf's `select-keys` in `leaf/leaves`.
- The symbol's local space is **pixels of the media**: `[0 w) × [0 h)`.
`clip/center` of a node-less symbol already returns its stage middle, which here
is `[w/2 h/2]`. So `place-symbol` puts the anchor at the image centre with no
new code.
- `symbol/problems`: `:type :trace` needs `:media` with exactly one of
`:footage`/`:image`, needs `(empty? nodes)`, and for footage needs
`(= frames (- end start))`.
- **One tracing symbol per footage range, shared.** Two faces from one take
place the same symbol, and so does a hand-placed reference. Like any symbol it
is reused by reference, and `bring/symbols` copies it like any other.
### Placing it
It is an ordinary `:kind :instance` with `:source {:symbol :footage-8625}`:
- **Start and end** are `:span` (in its own frames) and `:time :at`. You get
these from the existing trim, split, move and roll.
- **Which frame shows** comes from `:playback {:in :speed :end}`. Footage plays
with speed 1, a frozen frame has speed 0, and a still is 1 frame with `:end :hold`.
- **Placement in space** is `[:xform …]`, the same as every node: gestures,
inspector and keys.
- **In a lane, or on its own**: it is a parent-less spanned node, so a `:display
:lane` symbol draws it as a block like any cel. It can also sit as a free node
or under a group. To have a reference with its own tab, wrap it in an ordinary
symbol.
**Default on creation** (in the drop event, not in `place-symbol`): scale so the
image's height fits the stage height, centred on the drop point. This is a
creation default like "use center anchor when dropping", and nothing updates it
afterwards.
### Holds: the one new time feature
`:time {:holds [0 12 30]}` floors a node's local frame to the last hold at or
before it. Before the first hold, the first hold applies. This is `:expose`
generalized from a regular grid to authored frames. It is applied in
`node/local-frame` at the same point as `:expose`, and is inherited in the same
way. It is in the node's **own** frames. On a tracing instance with
`:in 0 :speed 1`, own frames are source frames, so the hold list is the set of
traced frames.
It is general on purpose. Holding a playing symbol on chosen drawings is the
same feature. It costs about three lines in `local-frame`, one `problems` clause
(sorted, distinct, finite), and the timeline drawing hold frames as marks on the
row.
`symbol/frame-map` refuses floors (`:expose > 1`). It must also refuse a
non-empty `:holds`, for the same reason.
## The face: "a child symbol that represents the trace"
The face symbol after a freeze:
```
:place group authored source→stage mapping (image heights → stage px)
:head group measured M(p) :reads — see below
:mouth … parts generated, every frame
:plate instance of :footage-8625 ← the trace
measured channels: fit(p) = M(p)⁻¹ · S(1/imageH)
:time {:holds [0 12 30]} ← the trace keys
```
### Registration comes from the parent
The plate is a child of `:head`. Its own measured channels are the
**stabilizing fit** at frame p: the inverse of the head's measured transform,
with pixels → image heights folded into the scale (it stays a similarity, so it
decomposes into `pos`/`rot`/`scale`). Freeze already computes the fit; the
head's measured channels are its inverse (`freeze/invert`). The plate gets a
second dense block, which is three small channels.
Because holds are a **time** floor, the plate's channels and the frame its
content shows are read at the **same** held frame q. Its world transform is:
```
world(plate) = place · M(p_head) · M(q)⁻¹ · S(1/H)
```
That is exactly `trace/photo-matrix`, but now it falls out of the ordinary walk.
It is registered to whatever the head is doing, by construction:
| head reads (p_head) | plate shows (q) | result |
| --- | --- | --- |
| f (continuous) | f (no holds) | footage where filmed |
| f (continuous) | held key | held photo rides the moving head (today's behaviour) |
| held key (same as plate) | held key | `M(q)·M(q)⁻¹ = I`: photo sits where filmed |
| 0 (start) | f or held | stabilized footage under a still head |
If a frame has no measurement, the plate's dense channel has nothing there, so
`xform-at` gives nil, so the plate is not placed and no photo shows. That is what
happens today too.
### Trace keys versus origin: who owns what
The two decisions are separate and stay separate:
- **Trace keys** are which footage frames get drawn over (the "plate drawings"
in `frame-selection.md`). They belong to the **plate**, as `:time :holds`. A
hand-placed tracing layer with holds is the *same thing*: the face's plate is
an ordinary tracing placement and nothing more.
- **Origin** is how the head moves between kept frames. `frame-selection.md`
already says `:origin` "is not a tracing setting" but a performance one, so it
belongs to the **head**:
```clojure
:head {…} ; continuous — reads its own frame
:head {… :reads {:holds [0]}} ; start
:head {… :reads {:holds-of :plate}} ; at keys — reads its measured channels at
; the frames :plate's holds select
```
`{:holds [...]}` is also what a face with no footage uses for "at keys", since it
has no plate to follow.
`:reads` changes only the **head's own channel reads**, not its children's
frames. The parts must keep running every frame, which is why this cannot be a
`:time` hold on the head. It is today's `traces` branch of
`base-channel-frame` with the hold list read from the named node. A node
reference has precedent (`:stencil`, `:pose-group`). It points from the follower
to the thing followed, so there is still one stored list of frames and nothing to
keep in sync. Validate it in `symbol/problems` the way `:stencil` is validated:
the target exists in the symbol, and it is not the head itself or an ancestor of
the head.
Rejected alternatives, so they are not re-proposed:
- **Keys on the head, and the plate reads them.** This is today's direction. It
makes the plate special: a free tracing layer could not have keys that a face's
plate also understands.
- **Hold `:time` on `:head`.** Exposure inherits strictly, so the mouth and eyes
would freeze along with the head.
- **A wrapper "registered footage" symbol holding the fit.** It is correct but
adds a symbol per face. The time-floor holds already put the fit read and the
content read on the same frame, so the wrapper buys nothing.
- **Plate as a sibling of `:head` at identity.** It is only registered when the
head and the photo read the same frame, so it breaks the continuous-plus-holds
and start rows above.
## Evaluation: an op that is never rendered
- **`clip/resolver`**, in the instance branch: when the source symbol has
`:type :trace`, it does not recurse. If `(:tracing? opts)` is set, it emits one op:
```clojure
{:kind :trace :node [id] :m <copy of world> :media … :frame shown-frame :size [w h]}
```
The frame comes from the same `placed-frame` path as any instance, so playback,
holds and the fps cadence apply. Do not build a child resolver for a trace
symbol.
- **`transform-op`** gets a `:trace` case that composes the matrix. Row paths,
solo filtering and nesting at any depth then work for free.
- **The output guarantee is structural.** `:tracing?` defaults to false. Only the
stage's `::render/resolver` passes true. Export, `clip/center` (a big photo
must not pull a symbol's pivot), thumbnails and the bench never ask for trace
ops. `raster/draw-ops!` keeps throwing on unknown kinds, so a leak fails loudly.
- **`ui/player`** sends picture ops to the raster and `:trace` ops to the
painter.
- **`ui/underlay` becomes `ui/tracing`.** It paints `:trace` ops in draw order
with `drawImage` at `op.m` and the global opacity. The media URL is the footage
manifest's `urls[range-start + frame]`, or the image blob URL. The three
steadiness fixes stay: LRU cache, hold the last still per op `:node`, and read
ahead while playing. Everything that walked faces is deleted.
- **`pick`**: a `:trace` op is hit when the point, mapped through `m⁻¹`, falls in
`[0 w) × [0 h)`. Picture ops are tested first and trace ops only if nothing
drawn is under the pointer, so a full-frame photo does not steal every click.
When the global switch is off, traces are neither painted nor picked.
- **Gestures**: no change. The face's plate is measured, so `gesture/refusal`
already says "place the instance it is in". A hand-placed layer is authored and
moves, turns and scales like anything else.
## On and off
All of it is EDITOR STATE, as ed88c5e decided for the per-face switch: showing
a reference is a way of looking at the stage, so it is not an undo step, does not
travel to collaborators, and cannot reach an export.
```clojure
[:ui :tracing {:on? true :opacity 0.5 :hidden #{[sid node-id] …}}]
```
- **One layer** is an entry in `:hidden`, keyed by the symbol the tracing
instance is in and its node id. The symbol id is needed because every face's
plate is called `:plate`. Keyed this way, hiding a face's plate hides it in
every placement of that face, which is what the per-face switch did. Shown is
the default, so opening a face shows its footage with no setup.
- **How it's applied**: `clip/resolver` already knows which symbol and node a
trace op comes from, so the op carries `:layer [sid id]`. The painter and
`pick` skip ops whose layer is hidden. The resolver does not change when you
toggle a layer, so nothing is rebuilt.
- **Global**: `:on?` and `:opacity`, a toggle plus an opacity slider in
`ui/palette/bar` next to the palette controls. When it is off, traces are
neither painted nor picked.
- **Switching one layer on also switches the global setting on.** This applies
from the tracing instance's inspector, from its timeline row, and from the
face/roto section. A layer you just enabled must not stay invisible behind a
switch you forgot. Turning one layer off never touches the global switch. It is
one event (`::ui/show-trace layer on?`) that updates `:hidden` and, when
turning on, sets `:on? true` in the same handler, so the three places cannot
behave differently.
- **`[:vis]`** still works on a tracing instance like on any node: key it to
show a reference only over part of the shot. It is not the on/off switch.
- **Deleted**: `[:ui :trace :faces]`, `::trace-face`, `::trace-faces`,
`trace/showing-for`, `traceable-faces`, `shown`, `faces`, and the "a take shows
nothing by default" rule.
## UI
- **Timeline.** A tracing instance is an ordinary row or clip block, styled to
read as reference-only (hatched block, an eye icon in place of the colour
chip). Hold frames are marks on the row. The row's eye button dispatches
`::ui/show-trace`, which replaces the face row's `tl-trace` button.
- **Inspector, tracing instance.** Show the media (footage name and range, or
image), an on/off control (which goes through `::ui/show-trace`), and the hold
list ("hold here" / "remove hold" plus seek buttons, which is `trace-keys`
re-aimed at `:time :holds`). Playback, transform and span use the existing
sections.
- **Inspector, face/roto section.** The same hold controls, aimed at the face's
`:plate`. Origin buttons write `:head :reads`. The section is found by "this
face has a `:plate`", not by `trace/traceable?`.
- **Palette bar.** The global toggle and opacity slider.
- **Making one.**
- Footage: the convert dialog gets a choice between "animate faces" (today's
flow) and "tracing layer" (no detection). The tracing-layer choice makes the
`:type :trace` symbol for the chosen range and places it where the video was
dropped.
- Footage can also be dragged from the pool with a modifier or as a second
drag kind.
- A still image: drop it on the pool and it uploads; drop it on the stage or
timeline and it is placed with `:end :hold` and a span of the host's
remaining frames.
## Freeze, bring, regenerate
- **`freeze/subject-part`** emits the `:plate` instance under `:head`, with fit
measured channels and `:time {:holds []}`, and emits one `:type :trace` symbol
for the analysed footage range. `bring/take` already copies every symbol the
take reaches and rewrites `:source :symbol` references, so the tracing symbol
comes along. It gets the footage's `:audio` too, so a dropped tracing layer
can bring its sound through the existing link.
- **`head-mode`** writes `:reads` on the head and `:holds` on the plate, in
place of `:trace`. The `frame-selection.md` plate proposal materializes into the
plate's `:time :holds` in place of `:trace :frames`.
- **Regenerate** replaces both measured blocks (head and plate) and keeps
`:holds` and `:reads`. Check that `regenerate-head`'s measured/authored
comparison handles a second measured node.
## Server
- `Image` endpoints mirroring `Sound`: `POST /api/images` stores a `Blob` and
returns `{id, width, height, url}`, and `GET /api/images` lists them. The
migration is a model only. Bump `schema_version` and refuse older documents
clearly. Do not convert them.
## Deleted outright
`trace/of`, `prepare`, `held-frame`, `problems`, `toggle-frame`, `photo-frame`,
`measured-local`, `photo-matrix`, `traceable?`, `faces`, `traceable-faces`,
`showing-for`, `shown`, `opacity-default`. `domain/trace.cljs` probably goes
away entirely; if anything is left, it is a hold helper that belongs in `node`.
Also deleted: `symbol/prepared-traces` and the `traces` arm of
`base-channel-frame`, which becomes the `:reads` lookup. `:trace` on nodes (and
`symbol/problems` reports it as removed). `::project/set-trace`.
`::render/underlay` and `::render/tracing` become one `::render/tracing` that
returns `[:ui :tracing]`. The face lookup in `params/view`.
Expected effect on code size: net negative. One time-floor clause, one op kind,
one resolver branch, one pick case and a `:reads` lookup replace the face walk,
the hand-built photo matrix, per-face showing state and the underlay's face
bookkeeping. Measure the change and report the number honestly (see the
`cljs-style` memory).
## Tests
**Domain (node).**
- `:holds` in `local-frame`, with eval-frame and resolver agreeing forward,
backward and in random order.
- A trace op appears only with `:tracing?`, and `export/run!` output contains no
`:trace` op.
- `transform-op` composes the matrix through two nesting levels.
- `clip/center` ignores traces, and is `[w/2 h/2]` for a tracing symbol.
- `pick` returns picture ops before traces and inverse-maps through a rotated
layer.
- `symbol/problems` covers `:type :trace`, `:reads` targets and cycles, and
`:holds` shape.
- Registration: port `trace_test`'s photo-matrix assertions onto the walk.
For every row of the registration table, the plate's world transform equals
the expected matrix. At a held key with `:reads {:holds-of :plate}`, it equals
`world(:place) · S(1/H)`.
- Freeze emits the plate and the tracing symbol. Regeneration keeps the holds.
- The `::ui/show-trace` event sets the global switch on when turning a layer on
and leaves it alone when turning one off.
**Browser** (CDP, see the `arthur-verify-dont-guess` memory):
- Drop a still, then drag, turn and scale it.
- Toggle a layer on with the global switch off, and confirm both are on and the
photo paints.
- Export a frame and check it has no photo pixels.
- Open a face: the plate is registered at a hold with origin "at keys", and
stabilized with origin "start".
## Order of work, each step green
1. `:time :holds` in `node/local-frame`, `problems`, `frame-map`, and timeline
marks.
2. `:type :trace` symbol, `:media`, the `:trace` op behind `:tracing?`,
`transform-op`, the player split, `ui/tracing` painter and `pick`. Footage
media only, placed by hand from a REPL or test document.
3. The face: freeze and bring emit the plate and tracing symbol, `:reads`
replaces `:trace`, delete the old trace and underlay paths, and re-aim the
inspector's face section.
4. On/off: the `:hidden` set and `:layer` on trace ops, the row eye, the
`::ui/show-trace` rule, the global toggle and opacity in the palette bar, and
delete `[:ui :trace :faces]`.
5. Creation: "tracing layer" in the convert dialog, pool drag, then the image
endpoint, pool images and image drops.
6. Docs: `animation-model.md` "A photographic underlay is not an op" becomes "a
trace is an op that never reaches the raster". Update `frame-selection.md`
(where plate keys live) and `lane-model.md` (tracing clips in lanes).
## Open questions
1. **"A take shows its faces' footage."** Dropping the old "only in a face's own
tab" default means opening a take shows every face's plate if the global
switch is on. The recommendation is to accept that, since the switch is one
click.
2. **Lane-model audio rule.** `lane-problems` requires visual cels. A tracing cel
is visual for this purpose, but say so explicitly when a lane may hold both
tracing and drawing cels.

View file

@ -7,32 +7,77 @@
# pass. A PNG sequence is exact, instantly seekable, and reproducible. # pass. A PNG sequence is exact, instantly seekable, and reproducible.
# #
# Audio comes out alongside because the page uses it as the PLAYBACK CLOCK - # Audio comes out alongside because the page uses it as the PLAYBACK CLOCK -
# frame = floor(audio.currentTime * fps) - so picture and sound cannot drift # frame = floor(audio.currentTime * fps). Detection sees every decoded source
# apart no matter how long the shot is or how slow the render loop runs. # frame; a lower drawing rate is a later playback choice, never an extraction
# choice. This script accepts CFR footage because frame-index timing needs a
# single rate. VFR needs per-frame timestamps in the manifest first.
set -euo pipefail set -euo pipefail
src="${1:?usage: ./extract.sh CLIP [FPS] [OUTDIR]}" src="${1:?usage: ./extract.sh CLIP [BUNDLE_DIR]}"
fps="${2:-12}" bundle="${2:-.}"
out="${3:-frames}" if [[ "$bundle" =~ ^[0-9]+([.][0-9]+)?$ ]]; then
echo "The FPS argument was removed: extraction always keeps the source rate. Use a directory as argument 2." >&2
rm -rf "$out"; mkdir -p "$out" exit 2
ffmpeg -hide_banner -loglevel warning -i "$src" -vf "fps=$fps" "$out/%04d.png" fi
count=$(ls -1 "$out" | wc -l) if [[ "$bundle" = /* || "$bundle" = *..* ]]; then
echo "BUNDLE_DIR must be a relative directory inside this repo" >&2
# Mono is enough for judging sync and halves the file. Absent audio is not fatal. exit 2
if ffprobe -v error -select_streams a:0 -show_entries stream=codec_type \
-of csv=p=0 "$src" 2>/dev/null | grep -q audio; then
ffmpeg -hide_banner -loglevel warning -y -i "$src" -vn -ac 1 -ar 44100 audio.wav
audio='"audio.wav"'
echo "audio -> audio.wav"
else
audio='null'
echo "no audio stream"
fi fi
# The page must know the true extraction rate: if it guessed, audio and picture probe=$(ffprobe -v error -select_streams v:0 \
# would drift. Source of truth lives here, next to the frames it describes. -show_entries stream=r_frame_rate,avg_frame_rate,nb_frames \
printf '{"fps":%s,"frames":%s,"dir":"%s","audio":%s,"source":"%s"}\n' \ -of json "$src")
"$fps" "$count" "$out" "$audio" "$(basename "$src")" > manifest.json fps=$(python3 -c '
import json, sys
from fractions import Fraction
streams = json.load(sys.stdin).get("streams", [])
if not streams:
raise SystemExit("no video stream in source")
s = streams[0]
nominal = Fraction(s["r_frame_rate"])
average = Fraction(s["avg_frame_rate"])
if nominal <= 0 or average <= 0 or abs(float(nominal / average) - 1) > 0.001:
raise SystemExit("variable-frame-rate source needs timestamp-aware playback; refusing to guess its fps")
print(float(average))
' <<< "$probe")
echo "$count frames at ${fps}fps -> $out/ (manifest.json written)" if [[ "$bundle" = "." ]]; then
dir="frames"; audio_path="audio.wav"; manifest_path="manifest.json"
else
dir="${bundle%/}/frames"
audio_path="${bundle%/}/audio.wav"
manifest_path="${bundle%/}/manifest.json"
fi
rm -rf "$dir"; mkdir -p "$dir"
ffmpeg -hide_banner -loglevel warning -i "$src" -fps_mode passthrough "$dir/%04d.png"
count=$(find "$dir" -maxdepth 1 -name '*.png' -type f | wc -l | tr -d ' ')
expected=$(python3 -c 'import json,sys; print(json.load(sys.stdin)["streams"][0].get("nb_frames", ""))' <<< "$probe")
if [[ "$expected" =~ ^[0-9]+$ && "$count" != "$expected" ]]; then
echo "decoded $count frames but source reports $expected; refusing an inaccurate manifest" >&2
exit 1
fi
# Mono is enough for judging sync and halves the file. A silent clock lets a
# mute source use the same audio-driven transport.
if ffprobe -v error -select_streams a:0 -show_entries stream=codec_type \
-of csv=p=0 "$src" 2>/dev/null | grep -q audio; then
ffmpeg -hide_banner -loglevel warning -y -i "$src" -vn -ac 1 -ar 44100 "$audio_path"
else
duration=$(python3 -c 'import sys; print(int(sys.argv[1]) / float(sys.argv[2]))' "$count" "$fps")
ffmpeg -hide_banner -loglevel warning -y -f lavfi -i anullsrc=r=44100:cl=mono \
-t "$duration" -c:a pcm_s16le "$audio_path"
fi
# JSON escaping belongs to a JSON writer, especially for source filenames.
python3 - "$fps" "$count" "$dir" "$audio_path" "$src" "$manifest_path" <<'PY'
import json, os, sys
fps, count, frames, audio, source, path = sys.argv[1:]
with open(path, "w") as out:
json.dump({"fps": float(fps), "frames": int(count), "dir": frames,
"audio": audio, "source": os.path.basename(source)}, out)
out.write("\n")
PY
echo "$count source frames at ${fps}fps -> $dir/ ($manifest_path written)"

38
fly.toml Normal file
View file

@ -0,0 +1,38 @@
app = "arthur"
primary_region = "iad"
[build]
dockerfile = "Dockerfile"
[env]
DJANGO_DEBUG = "0"
DJANGO_ALLOWED_HOSTS = ".fly.dev"
DJANGO_CSRF_TRUSTED = "https://arthur.fly.dev"
DJANGO_DB_PATH = "/data/db.sqlite3"
DJANGO_BLOB_ROOT = "/data/blobs"
[[mounts]]
source = "data"
destination = "/data"
initial_size = "1gb"
[http_service]
internal_port = 8000
force_https = true
auto_stop_machines = "stop"
auto_start_machines = true
min_machines_running = 1
processes = ["app"]
[[http_service.checks]]
interval = "30s"
timeout = "5s"
grace_period = "60s"
method = "GET"
path = "/"
headers = { Host = "arthur.fly.dev" }
[[vm]]
cpu_kind = "shared"
cpus = 1
memory = "1gb"

384
frontend/README.md Normal file
View file

@ -0,0 +1,384 @@
# frontend
The ClojureScript half. See `docs/port-plan.md` for what is being built and in
what order; this file is only how to run it.
## Once
```sh
mise install # from the REPO ROOT
pip install -r requirements.txt # the Django half; one dependency
mise exec -- python manage.py migrate # the document database
cd frontend && npm install
```
`java` must be 21+. On an older JDK shadow-cljs fails with "CompilerOptions has
been compiled by a more recent version of the Java Runtime", which reads like a
shadow-cljs bug and is not one. `mise install` is what prevents it.
## The tests
```sh
cd frontend && mise exec -- npm test
```
Two things: compile the `:test` build, run it under node.
```
shadow-cljs compile test
node out/node-tests.js
```
Run them separately if a compile error is in the way.
**Run them through `mise`**, or make sure `mise`'s node is first on PATH. `java`
must be 21+ and node 20.19+. On an nvm node 20.11 shadowing the pinned one,
things fail in ways that read like the code being broken and are not.
### And the Django one
```sh
mise exec -- python manage.py test clips # from the REPO ROOT
```
Tests cover the API: the blob store, key verification, the load/save round trip,
the conditional write, source analysis blocks, and video upload and extraction.
The two groups worth
reading are the ones that make the tier split a property of the system rather than
a convention in ClojureScript — the server recomputes every tier-2 key it is
handed, and refuses a block whose analysis does not declare a detector version.
### And the browser one
Step 5's done-criterion is a PICTURE, and no assertion in `cljs.test` can check
one: a take that resolves to the right numbers and draws nothing would pass every
test in `arthur.flow.freeze-test`. A blank canvas under a perfectly correct
transport is the bug class unit tests miss, and it has happened here once.
Step 9's is a picture too, for a different reason: the ways a document survives a
round trip LOOKING correct are the interesting ones. So the suite now also saves
the take, reopens it, and checks the frames are the same pixels.
It drives a real Chrome over CDP, and needs both processes up:
```sh
mise exec -- python manage.py runserver 8778 # from the REPO ROOT
cd frontend && mise exec -- npx shadow-cljs watch app # in one shell
cd frontend && mise exec -- npm run browser # in another
```
`ARTHUR_URL` overrides the page it drives; it defaults to
`http://localhost:8778/index.html`, which since step 9 is Django's.
No dependencies. Playwright is not installed and CDP needs none —
`node --experimental-websocket` has a global `WebSocket` and
`--headless=new --remote-debugging-port=N` is the whole of the other side. It
reads the canvas's own pixels rather than a screenshot, because the CSS scales
the stage up by 2 and a screenshot is four pixels per raster pixel; it writes
PNGs into `test/browser/out/` anyway, so "it drew something" can be checked by
eye as well as by count.
## The app
Two processes, which do not talk to each other:
```sh
mise exec -- python manage.py runserver 8778 # from the REPO ROOT
cd frontend && mise exec -- npx shadow-cljs watch app
```
Then open **<http://localhost:8778/>**. Django serves the page from
`clips/templates/clips/index.html`, its styles from `static/arthur/app.css`, and
the bundle out of `static/arthur/js`, where `shadow-cljs` already writes it — so
nothing copies files between the two.
### The window
One screen, five panes, no scrolling page. `src/arthur/ui/shell.cljs` is the grid
and nothing else; each pane owns its own subscriptions.
```
top the document: its name and last status, export, new / open / save
left media pool — the open document's symbols, and footage on the server
centre the palette strip (16 slots) above the stage
right inspector — the clip, the selected node, the tracked objects
bottom timeline — transport, ruler, a row per node
```
**It opens on a blank document**, and **new** makes another one. Nothing is
loaded until it is asked for.
**Whole documents live under `open ▾`, not in the media pool**, and the split is
load-bearing rather than tidy. Opening a project REPLACES the stage; everything
in the pool is a thing to put ON it. Listing documents beside the symbols inside
one of them makes them read as two kinds of the same thing. The menu lists the
projects the server holds; the built-in scenes are under their own heading,
italic, and are not projects — they are compiled into the bundle and the server
has never heard of them.
Everything that holds nodes is a **symbol**, and none is special: a new document
has one called `main` because it has to be called something. Which symbol is on
screen is editor state, `[:ui :open]`, not a fact about the document — the stage
draws it, the timeline lists it, the transport plays it and a new shape goes into
it. A document opens on the longest symbol nothing else places.
Selection lives in app-db under `:ui`, as `[:node <symbol> <node>]`,
`[:symbol <id>]` or `[:subject|:feature|:group <id>]` — four panes ask what is
selected, and a ratom private to one of them can only be shared by making the
other three require it.
**Drop a video on the media pool** and it uploads, extracts and goes straight on
into detection. Dragging a symbol out of the pool onto the stage places an
instance of it at the playhead.
The timeline's rows are the open symbol's nodes, front-most first, with a dot per
keyframe and a bar over the frames the node exists on; a dense channel is hatched
rather than ticked, because one value per frame is a solid block that says less
than the bar does. Opening a row shows its channels; opening an **instance** row
shows the symbol it places, with every frame number mapped back into the open
symbol's frame space — see the namespace docstring in `ui/timeline.cljs`, which
is where that mapping is argued.
### Paint sketch
Pick a tone from the palette strip, click **polygon**, place at least three
vertices on the stage, then click **finish**. Select a shape — on the stage, or by
its timeline row — to drag its vertices. Scrub to another frame and click
**drawing key here** in the inspector to copy the visible outline there; the
previous drawing holds until that key. The numbered key buttons jump to editable
keys. The transition control between two drawing keys can switch that gap between
a hold and linear vertex tweening. Other gaps keep their own timing. Tweening works
best when the same vertex
keeps the same meaning in every drawing. Paint shapes use the timeline clock
directly, so the roto exposure grid does not delay a drawing key or step its
tween. Use the project **save** button to persist the drawings.
`/index.html` still works, and that is deliberate: it is the URL the browser suite
has used since step 5, when shadow-cljs's `:dev-http` did no directory-index
resolution and the suite learned to ask for the file.
Four built-in clips, under **built-in examples** in the open menu:
| | |
| --- | --- |
| `take` | the synthetic take, head **as filmed**. Step 5's deliverable: a moving mouth, frozen into dense channels, with no video file anywhere. |
| `locked` | the same freeze, head **locked**. The same blocks — `:head`'s channels are written as framed identity instead of as a dense track, and nothing in tier 2 differs. |
| `demo` | the hand-written scene from step 2. Not a face: the smallest scene that exercises every mechanism the model claims to have, so that each one is visible when it breaks. |
| `swarm` | a hundred and twenty dense nodes. Not useful; it is the load test. |
`take` and `locked` are the pair worth looking at together, because switching
between them is the whole of what "stabilisation is a channel, not a mode" means.
The demo scene itself is `src/arthur/demo/scene.edn`. Both the synthetic take
and real footage use `src/arthur/flow/take.cljs` for the measurement order and
`src/arthur/flow/freeze.cljs` for the landmark-to-channel conversion.
**8625 stage study**, in the open menu, loads the locally saved `IMG_8625.MOV` project and places its
post-processed face symbol twice. The stage layout is
`src/arthur/demo/stage_8625.edn`: the right picture and sound start at frame 48,
and the two pictures overlap slightly in stage space. Audio has its own
nodes, linked to the picture instances but with independent spans and gain
channels. The right sound swells and pans across the stage, then fades out at
frame 260 while its picture continues to
frame 280. The row needs that saved 8625 project in the local server database.
### Projects and the EDN fixtures
The EDN files under `demo/` are authored examples compiled into the frontend.
They seed a clip in memory; the server does not read EDN. Clicking **save** on a
clip without a project id creates a project through `POST /api/projects`, uploads
any missing content-addressed blocks, then writes the clip's addressed leaves
through `PUT /api/projects/<id>`. Each leaf value is Transit JSON inside the
request's ordinary JSON envelope. Python stores those values in JSON columns and
does not need an EDN parser. **open** reads the leaves and blocks and rebuilds
the same ClojureScript clip.
The intended editor creates and changes that in-memory clip directly: a project
browser and **new stage** action, timeline instance placement, node and channel
editors, then the existing save path. EDN remains useful for checked-in examples
and reproducible studies. The UI now has the blank-stage action (**new**), a
project browser (`open ▾`), and placement by dragging a symbol out of the media
pool; node and channel editors are still to come — the inspector reports a
channel's shape but has nowhere to change its values.
### Real footage
Drop a video on the media pool, or use its **+** button. The server probes it, re-encodes it
to an H.264 proxy and a raw stream of the same coded frames, pulls WAV audio and one tracing JPEG per frame,
then makes the resulting footage selectable and runs detection on it. **roto**, in
the pool's header, does the same for footage that is already there. Extraction progress is currently read from `/api/extractions/<key>`; a
future WebSocket can push the same job state. The uploaded bytes, extraction job,
and decoded footage have separate records, so the same uploaded video can be
reopened without decoding it again.
**The proxy is what gets measured, and the stills are not.** `flow/ingest` cuts
its raw H.264 stream into coded frames and decodes them in order with WebCodecs.
The proxy has no B-frames, so decode order matches frame order. `flow/detect`
hands each decoded frame to MediaPipe in **VIDEO** running mode at
`i * 1000 / fps` milliseconds. That timestamp has to increase
strictly and has to be real footage time: video mode is a tracker, it reads the
gap between timestamps as motion, and a repeat leaves the graph in an error state
that every later call re-throws. The JPEGs beside the proxy are reference images
for the tracing editor and nothing measures them, so they are not in the footage
digest.
It is re-encoded even when the upload is already H.264, for two reasons: an
iPhone's HEVC is not decodable in every browser, and the footage's identity is the
proxy's digest — one produced by one ffmpeg invocation, not one that depends on
which branch the source happened to take. Its raw stream is copied from that
proxy without another encode.
The command-line route is also available for an existing extracted bundle:
```sh
./extract.sh /path/to/clip.mov # decode to frames + audio + manifest
mise exec -- python manage.py ingest_bundle
```
`extract.sh` keeps every source frame and writes `frames/0001.png` onward,
`audio.wav` and `manifest.json`. Variable frame rate sources are rejected until the
manifest and clock carry per-frame timestamps.
`ingest_bundle` then hashes all of it into the content-addressed blob store under
`var/blobs` — by hard link, so 112MB of PNGs is not copied — and registers one
`Footage` row. From then on the frames are the backend's: `GET /api/footage/<id>`
answers with a manifest carrying **a URL per frame**, and the app fetches those.
That replaced a shared secret. Until step 9 the page fetched `/manifest.json` off
the filesystem and built `frames/0001.png` itself, with shadow-cljs serving the
repo root — so the frame layout was agreed between a shell script and a
ClojureScript namespace, and "where are the frames" was answered by a directory
listing. The cache-busting `?v=` that used to hang off every frame URL went with
it: a blob's name is the hash of its bytes, so re-extracting gives a frame a
different URL rather than overwriting one.
To keep several takes, pass a bundle directory; each ingests separately and both
stay selectable in the app.
```sh
./extract.sh /path/to/clip.mov scratch/my-take
mise exec -- python manage.py ingest_bundle scratch/my-take
```
`scratch/` is ignored by Git, as are `frames/`, `audio.wav` and `manifest.json` at
the root — all of it is extraction output, and tier 3 does not belong in the repo.
Loading detects one face per frame, measures the mouth, eyes and brows from
landmarks and the teeth from source pixels, then freezes them into channels,
and opens the footage clip. Detection happens once when you load;
playback only resolves channels and paints. Frames without a detection remain
marked absent even though their neighbouring poses are used to condition the
track. The scene now records stable subject and feature IDs and explicit eye
pairs; dense channels can mark one feature absent while another is observed.
Current MediaPipe loading supplies only the full-face detection mask. The stage
stays 320×200 regardless of the footage dimensions. Real
footage starts at the source picture rate. The **picture** buttons in the inspector sample the
frozen roto at lower rates while the source track, duration and audio clock stay
unchanged. Picking frames to trace into cels is a separate future editing step.
**save** also stores the detection mask, dense landmarks and raw RGBA mouth crops
as three analysis blocks. **open** restores these without running MediaPipe or
loading source PNGs. The frozen shapes remain separate channel blocks.
For known occlusion intervals, an extracted manifest may add
`"feature-absence": {"eye-r": [[10, 14]]}`. Frame numbers are one-based and
inclusive, matching PNG filenames. The eye remains the same feature when it
reappears; the other eye and the mouth continue through the gap. This is an
input annotation, with no UI for editing it yet.
MediaPipe's JS, wasm and model are under `public/mediapipe/`, served by Django's
staticfiles under `/static/mediapipe/`. No CDN is used by this app. See that
directory's README for provenance.
The server reports what it serves at `GET /api/detector`: the package version plus
the **sha256 of the model asset**, and that string goes inside the content address
of every block a detection produces. Asked rather than assumed, because a version
constant in the client is one somebody has to remember to bump — and
`docs/architecture.md` is explicit that a model upgrade silently reusing old
landmarks presents as "the tool got worse", with no event to attach it to.
Port 8778 is deliberately not 8777. `python3 serve.py` from the repo root still
runs the old JS tool on 8777, and the two are meant to run side by side.
## Saving
**new**, **open** and **save** in the top bar. A save has three ordered stages:
is the tier split:
1. the **analysis** record, so every block stored afterwards can name the detector
version that produced it. The server refuses a block whose analysis it does not
know.
2. ask which **blocks** are missing, upload the source analysis blocks and frozen
channel blocks, then link the source blocks to the analysis.
3. the **document** — tier 1, as leaves. The server refuses a clip that names
blocks it does not hold, so a saved document cannot load into a blank stage
somewhere else.
The status line says what happened: `saved r3 · 64 leaves · 8 blocks`. Saving an
unchanged document says `0 leaves · 0 blocks`, which is both halves of the
addressing working at once — an unchanged leaf keeps its version, and a
content-addressed block is already there.
Saving `swarm` is deliberately visible as a failure: its blocks have
hand-written names and a document may only name content addresses.
`open ▾` lists every project the server holds, newest first, and shows the first
clip of whichever one is picked — the store holds one clip at a time.
## The oracle, which is finished
`js/` was the numeric oracle through step 4: `test/parity/` ran both
implementations on the same synthetic track and diffed `fit-similarity`,
`procrustes-mean`, the raster and `stabilize` to 1e-9.
**It was deleted at step 5, on purpose.** Parity proves the port is FAITHFUL, not
that the answer is RIGHT. The JS is a prototype and several of its conclusions
contradict each other; a parity test pins behaviour while code moves, and keeping
it afterwards would bake the prototype's mistakes into the rewrite and make them
permanent. `docs/port-plan.md` says to delete it in one commit once the CLJS
player renders the synthetic take, and that is what happened.
`js/` itself stays as the reference for the MediaPipe setup, face measurements
and pixel extraction. Its comments encode bugs that actually happened.
## Layout
```
src/arthur/domain/ pure. No re-frame, no DOM, no flow/.
src/arthur/fx/ the only namespaces that talk to the network
src/arthur/flow/ the stages. `(f params inputs) -> output`, no state.
src/arthur/synth.cljs the synthetic track. In src/ because the take PLAYS it —
it stands in for flow/detect, and a tool that needs a
video file before it shows you anything is one you
cannot debug.
src/arthur/demo.cljs the hand-written scene, read from demo/scene.edn
src/arthur/demo/take.cljs the synthetic source for the shared flow/take path
src/arthur/ui/canvas.cljs indexed raster blit to the display canvas
test/arthur/support/ machinery shared between suites; not tests itself
test/browser/ drives a real Chrome over CDP. Not run by `npm test`.
public/mediapipe/ vendored wasm and model, served under /static/mediapipe/
```
`public/` holds nothing but those assets now. The host page that used to sit beside
them is `clips/templates/clips/index.html`.
## Two evaluators, on purpose
`domain/symbol` has both `eval-frame` and `resolver`, and they are not
alternatives:
- **`(eval-frame symbol f store)`** is the specification. Allocating, order-free,
obviously correct. Tests and one-off renders use it.
- **`(resolver symbol store)` -> `(fn [f] ops)`** is what playback uses. It caches
the topological order and the z paths, holds a cursor per channel and reuses
one point buffer per node, so a frame allocates the op maps and nothing else.
Both run the same walk, parameterised by how a channel is read and where its
points are written — two independent implementations of frame evaluation would
drift, and the drift would look like a rendering bug rather than like two
functions disagreeing. What differs between them is exactly the part that can be
wrong, and `scene-test` asserts they agree frame for frame in forward, backward
and random order.
Because the resolver reuses its buffers, **ops must be rasterised before the
next frame is asked for.** That is the contract the rAF loop wants anyway: it
reads, blits, and dispatches nothing.

1657
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

20
frontend/package.json Normal file
View file

@ -0,0 +1,20 @@
{
"name": "arthur-frontend",
"private": true,
"version": "0.0.1",
"scripts": {
"watch": "shadow-cljs watch app",
"release": "shadow-cljs release app",
"test": "shadow-cljs compile test && node out/node-tests.js",
"browser": "node --experimental-websocket test/browser/take.mjs"
},
"dependencies": {
"@mediapipe/tasks-vision": "1.0.1",
"polygon-clipping": "^0.15.7",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"shadow-cljs": "^2.28.21"
}
}

View file

@ -0,0 +1,218 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
===========================================================================
For files under tasks/cc/text/language_detector/custom_ops/utils/utf/
===========================================================================
/*
* The authors of this software are Rob Pike and Ken Thompson.
* Copyright (c) 2002 by Lucent Technologies.
* Permission to use, copy, modify, and distribute this software for any
* purpose without fee is hereby granted, provided that this entire notice
* is included in all copies of any software which is or includes a copy
* or modification of this software and in all copies of the supporting
* documentation for such software.
* THIS SOFTWARE IS BEING PROVIDED "AS IS", WITHOUT ANY EXPRESS OR IMPLIED
* WARRANTY. IN PARTICULAR, NEITHER THE AUTHORS NOR LUCENT TECHNOLOGIES MAKE ANY
* REPRESENTATION OR WARRANTY OF ANY KIND CONCERNING THE MERCHANTABILITY
* OF THIS SOFTWARE OR ITS FITNESS FOR ANY PARTICULAR PURPOSE.
*/

View file

@ -0,0 +1,17 @@
# Local MediaPipe assets
`vision_bundle.js` and the four wasm loader/binary files under `wasm/` come
from `@mediapipe/tasks-vision` **1.0.1**, pinned in `frontend/package.json`.
`FilesetResolver.forVisionTasks` uses the SIMD pair where supported and the
no-SIMD pair elsewhere. The package and these files are Apache-2.0; see
[LICENSE](LICENSE).
`face_landmarker.task` is the official [Face Landmarker model](https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task).
Its SHA-256 is
`64184e229b263107bc2b804c6625db1341ff2bb731874b0bcc2fe6544e0bc9ff`.
These are served from `/mediapipe/` so detection needs no CDN at runtime. The
browser bundle is loaded as a script before the CLJS app because Shadow CLJS
cannot parse the package's CommonJS bundle (its dynamic `import()` is unsupported
by the current compiler). `flow/detect.cljs` is the sole call site for its
`Vision` global.

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

49
frontend/shadow-cljs.edn Normal file
View file

@ -0,0 +1,49 @@
;; Two builds and no more:
;;
;; app the tool. Output goes straight into the Django staticfiles tree, so
;; `python manage.py runserver` and `shadow-cljs watch app` are the whole
;; dev loop with nothing copying files between them.
;; test :node-test, because everything below `ui/` and `fx/` is pure and has
;; no business needing a browser to be asserted about. The canvas-facing
;; parts get asserted through domain/raster's byte buffer instead, which
;; is what the JS selftest already did.
{:source-paths ["src" "test"]
:dependencies [[reagent "1.2.0"]
[re-frame "1.4.3"]
;; The document's wire format. JSON would do for the shape of tier
;; 1 but not for its VALUES: channel keys are a map by FRAME
;; NUMBER and every id is a keyword, and JSON has neither, so a
;; save would quietly turn `{0 v}` into `{"0" v}` and `:mouth`
;; into "mouth". Transit is JSON on the wire, so Django stores a
;; leaf in a JSONField and the admin can still read it.
[com.cognitect/transit-cljs "0.8.280"]]
;; THERE IS NO :dev-http, since step 9. Django serves the page — one template,
;; out of `clips/templates/` — and shadow-cljs only builds into the staticfiles
;; tree, which is what `:output-dir` below already did. So the dev loop is two
;; processes that do not talk to each other:
;;
;; mise exec -- python manage.py runserver 8778 (from the repo root)
;; cd frontend && mise exec -- npx shadow-cljs watch app
;;
;; What went away with the key was a set of problems rather than a feature. The
;; two roots it needed — `public` for the host page and `..` for the repo root, IN
;; THAT ORDER, because the root has the old tool's index.html and serving that one
;; instead would look like the port having regressed to a suspiciously complete
;; tool — were a way of reaching frames/, audio.wav and manifest.json off the
;; filesystem. Those are tier 3, and tier 3 is now the backend's, by hash.
;;
;; 8778 is still deliberately not 8777, which is still the old JS tool's under
;; `python3 serve.py`. The two are meant to run side by side.
:builds
{:app {:target :browser
:output-dir "../static/arthur/js"
:asset-path "/static/arthur/js"
:compiler-options {:source-map true}
:modules {:main {:init-fn arthur.core/init}}}
:test {:target :node-test
:output-to "out/node-tests.js"
:ns-regexp "-test$"}}}

View file

@ -0,0 +1,262 @@
(ns arthur.audio.mix
"Render independently placed audio tracks into one stage audio clock.
The mix is derived from saved audio track leaves and immutable footage blobs.
The transport still has ONE clock, so seeking, rate changes and looping stay
tied to the same position the picture is drawn from.
THE AUDIO BUFFER IS THE PRODUCT AND THE WAV IS ONE PACKAGING OF IT. `buffer!`
renders and the wrappers below it package, rather than the render being spelled
once per consumer.
PLAYBACK NO LONGER PACKAGES AT ALL. It takes the buffer as it is — see
`clock-source!` and `arthur.clock.graph` — because encoding a WAV so an
`<audio>` element had a URL to hold cost O(the clip's length) on the main
thread, on open, on every tab switch and on every edit to a track. The WAV is
now what an EXPORT wants: bytes for an archive, or the buffer itself for a
muxer. `clock!` below is the element backend's packaging and goes when it does."
(:require [arthur.domain.channel :as ch]
[arthur.domain.clip :as clip]
[arthur.domain.nest :as nest]
[arthur.domain.node :as node]))
(defn wav-bytes
"An `AudioBuffer` -> the bytes of a 16-bit PCM WAV.
PEAK-NORMALISED ONLY IF IT WOULD CLIP. A mix of several tracks can sum past
1.0, and 16-bit PCM has nowhere to put that, so the alternative to scaling is
audible clipping on exactly the loudest moment. Below the threshold nothing is
touched, so a single-track mix is the footage's own audio sample for sample."
[^js buffer]
(let [channels (.-numberOfChannels buffer)
frames (.-length buffer)
rate (.-sampleRate buffer)
bytes (js/ArrayBuffer. (+ 44 (* frames channels 2)))
view (js/DataView. bytes)
samples (mapv #(.getChannelData buffer %) (range channels))
;; A HAND-WRITTEN LOOP over the typed arrays, not `(reduce max (for ...))`.
;; The lazy sequence that read beautifully allocated one boxed double per
;; SAMPLE — ten million of them for a three-minute mix — and spent the
;; whole of a sixteen-second project open walking them and collecting
;; them. Same arithmetic, no allocation.
peak (loop [c 0 p 0]
(if (< c channels)
(recur (inc c)
(let [^js data (nth samples c)]
(loop [i 0 p p]
(if (< i frames)
(recur (inc i)
(let [a (js/Math.abs (aget data i))]
(if (> a p) a p)))
p))))
p))
level (if (> peak 0.98) (/ 0.98 peak) 1)]
(doseq [[offset word] [[0 "RIFF"] [8 "WAVE"] [12 "fmt "] [36 "data"]]]
(dotimes [i 4] (.setUint8 view (+ offset i) (.charCodeAt word i))))
(.setUint32 view 4 (- (.-byteLength bytes) 8) true)
(.setUint32 view 16 16 true)
(.setUint16 view 20 1 true)
(.setUint16 view 22 channels true)
(.setUint32 view 24 rate true)
(.setUint32 view 28 (* rate channels 2) true)
(.setUint16 view 32 (* channels 2) true)
(.setUint16 view 34 16 true)
(.setUint32 view 40 (* frames channels 2) true)
;; Channel-outer so the channel's array is looked up once rather than once
;; per frame. The byte offsets are unchanged, so the interleaving is too.
(dotimes [c channels]
(let [^js data (nth samples c)]
(dotimes [i frames]
(let [sample (* level (aget data i))]
(.setInt16 view (+ 44 (* (+ (* i channels) c) 2))
(js/Math.round (* 32767 (max -1 (min 1 sample)))) true)))))
(js/Uint8Array. bytes)))
(defn- wav-url [^js buffer]
(js/URL.createObjectURL
(js/Blob. #js [(wav-bytes buffer)] #js {:type "audio/wav"})))
(defn- fetch-ok! [url what]
(-> (js/fetch url)
(.then (fn [response]
(when-not (.-ok response)
(throw (ex-info (str "audio track's " what " is missing")
{:url url :status (.-status response)})))
response))))
(defn- decode-bytes! [bytes]
(.decodeAudioData (js/OfflineAudioContext. 1 1 44100) bytes))
(defn- source!
"Promise of `[source {:buffer :fps}]` for an audio node's `:source`. Footage
counts its frames at its own rate; a sound file has no frames of its own, so
its `:fps` is nil and it counts in the document's."
[{:keys [footage sound] :as source}]
(if sound
(-> (fetch-ok! (str "/api/sounds/" sound) "sound")
(.then #(.json %))
(.then #(fetch-ok! (.-audio %) "blob"))
(.then #(.arrayBuffer %))
(.then decode-bytes!)
(.then (fn [buffer] [source {:buffer buffer}])))
(-> (fetch-ok! (str "/api/footage/" footage) "footage")
(.then #(.json %))
(.then (fn [^js manifest]
(-> (fetch-ok! (.-audio manifest) "blob")
(.then #(.arrayBuffer %))
(.then decode-bytes!)
(.then (fn [buffer] [source {:buffer buffer :fps (.-fps manifest)}]))))))))
(defn- automate! [^js param channel start end fps factor default store]
(let [channel (or channel (ch/framed default))
sample (fn [f] (ch/value-at channel
(if-let [{:keys [at rate]} (:sample-time channel)]
(js/Math.floor (* rate (- f at))) f)
store))]
(.setValueAtTime param (* factor (sample start)) (/ start fps))
(cond
(:dense channel)
(doseq [f (range (inc start) end)]
(.setValueAtTime param (* factor (sample f)) (/ f fps)))
(:animated? channel)
(doseq [[f v] (sort-by key (:keys channel))
:when (and (> f start) (< f end))]
(if (= :linear (:interp channel))
(.linearRampToValueAtTime param (* factor v) (/ f fps))
(.setValueAtTime param (* factor v) (/ f fps)))))))
(defn tracks-of
"The sounds symbol `sid` plays, including those inside what it places — see
`nest/audio-tracks`. Playback mixes the open symbol's."
[document sid]
(nest/audio-tracks document sid))
(defn- render! [document sid sources store]
(let [fps (:fps document)
frames (clip/output-frames document sid)
tracks (tracks-of document sid)
output (js/OfflineAudioContext.
2 (js/Math.ceil (* (/ frames fps) 44100)) 44100)]
(doseq [track tracks]
(let [[start end] (or (node/placed-span track) [0 frames])
start (max 0 start)
end (min frames end)
{:keys [buffer fps] :or {fps (:fps track)}} (get sources (:source track))
sound (.createBufferSource output)
gain (.createGain output)
pan (.createStereoPanner output)]
(when (< start end)
(set! (.-buffer sound) buffer)
(set! (.-loop sound) (boolean (get-in track [:time :loop?])))
(automate! (.-playbackRate sound)
(get-in track [:channels [:audio :rate]])
start end (:fps document) (* (or (get-in track [:time :rate]) 1) (/ (:fps document) fps)) 1 store)
(automate! (.-gain gain)
(get-in track [:channels [:audio :gain]])
start end (:fps document) 1 1 store)
(automate! (.-pan pan)
(get-in track [:channels [:audio :pan]])
start end (:fps document) 1 0 store)
(.connect sound gain)
(.connect gain pan)
(.connect pan (.-destination output))
(.start sound (/ start (:fps document)) (/ (node/local-frame track start) fps))
(.stop sound (/ end (:fps document))))))
(.startRendering output)))
(defn buffer!
"Promise of the `AudioBuffer` one symbol's audio tracks mix down to, or nil
when it has none.
The raw product. `mix!` packages it as a WAV URL for the transport and
`export/frames` packages it as WAV bytes in an archive; a muxer would take it as
it is, which is why this is the function the others are written in terms of."
[document sid store]
(let [tracks (tracks-of document sid)]
(if (empty? tracks)
(js/Promise.resolve nil)
(-> (js/Promise.all
(into-array (map source! (distinct (map :source tracks)))))
(.then (fn [pairs] (render! document sid (into {} (array-seq pairs)) store)))))))
(defn decode!
"Promise of the `AudioBuffer` behind a URL. What a clip whose audio is a plain
file rather than placed tracks exports."
[url]
(-> (js/fetch url)
(.then (fn [^js response]
(when-not (.-ok response)
(throw (ex-info "the clip's audio did not load"
{:url url :status (.-status response)})))
(.arrayBuffer response)))
(.then decode-bytes!)))
(defn fit-buffer
"Fit fallback audio to the open timeline, padding with silence or trimming.
The buffer and clock must share a duration so looping wraps at the timeline's
end rather than repeating a short soundtrack underneath a longer animation."
[^js buffer seconds]
(let [rate (.-sampleRate buffer)
frames (max 1 (js/Math.ceil (* seconds rate)))]
(if (= frames (.-length buffer))
buffer
(let [channels (.-numberOfChannels buffer)
fitted (.createBuffer (js/OfflineAudioContext. channels 1 rate)
channels frames rate)]
(dotimes [c channels]
(.set (.getChannelData fitted c)
(.subarray (.getChannelData buffer c) 0 (min frames (.-length buffer)))))
fitted))))
(defn clock-source!
"Promise of `{:buffer :seconds}` — the audio the transport runs its clock on
while symbol `sid` is open, and how long that clock is.
Same order of preference as the WAV packaging in `clock!` below: the symbol's
own placed tracks, mixed; the document's audio file, for the symbol the
document opens on and only that one; and otherwise NO BUFFER AT ALL and the
symbol's own length.
THE SILENT CASE IS WHY THE DURATION IS RETURNED BESIDE THE BUFFER rather than
read off it. A symbol with no sound still needs a clock exactly as long as it
is, and `clock!` had to synthesize that silence and then ENCODE it, full
length, so an element had a duration to report. There is nothing to decode for
a symbol with no sound: saying how long it is answers the only question the
silence was ever asked."
[document sid fallback-url store]
(-> (buffer! document sid store)
(.then (fn [^js buffer]
(cond
buffer
{:buffer buffer :seconds (.-duration buffer)}
(and fallback-url (= sid (clip/opens-on document)))
(-> (decode! fallback-url)
(.then (fn [b]
(let [seconds (/ (clip/output-frames document sid) (:fps document))]
{:buffer (fit-buffer b seconds) :seconds seconds}))))
:else
{:buffer nil
:seconds (/ (clip/output-frames document sid) (:fps document))})))))
(defn clock!
"Promise of the URL the transport should play while symbol `sid` is open.
THE ELEMENT BACKEND'S PACKAGING of `clock-source!`, kept while that backend is
— see `arthur.clock`. Every cost this namespace had on open is in the two
`wav-url` calls below.
The frame is derived from the audio element and from nothing else, so every
open symbol needs a sound exactly as long as it is. In order: its own placed
tracks, mixed; the document's audio file, for the symbol the document opens on
and only that one; and otherwise SILENCE of the symbol's length — a ten-frame
symbol played against the whole take's soundtrack would run ten frames and then
keep the clock going for minutes."
[document sid fallback-url store]
(-> (clock-source! document sid fallback-url store)
(.then (fn [{:keys [buffer seconds]}]
(wav-url (or buffer
(.createBuffer (js/OfflineAudioContext. 1 1 44100)
1 (max 1 (js/Math.ceil (* 44100 seconds))) 44100)))))))

View file

@ -0,0 +1,148 @@
(ns arthur.clock
"The audio clock. Lives OUTSIDE app-db, deliberately.
THE FRAME IS DERIVED FROM THE AUDIO, never counted:
frame = ⌊position · fps⌋
A loop that counted frames and hoped to keep up would drift, and drift against
a voice is the one artefact that cannot be fixed downstream — a lip-sync tool
whose sync wanders is not a lip-sync tool. Deriving instead means a slow frame
DROPS the frames it missed and the next one lands where the audio already is.
The failure mode becomes a visible stutter rather than an invisible slide, and
those are very different bugs to own.
½× and ¼× are the backend's playback rate and nothing else. The audio slows,
the position advances proportionally, and the derived frame follows — so slow
motion cannot desync by construction. Implementing rate as a multiplier on a
counted frame would give the picture a rate and the sound another.
It is outside app-db because the audio is the source of truth and copying it
into the db every frame would make the db a lagging mirror of something
authoritative elsewhere. What DOES belong in the db is the playhead as a piece
of document state — see events/playback — and that is written from here, not
read by here.
TWO BACKENDS, ONE ARITHMETIC. `position` comes from either a Web Audio graph
(`clock.graph`, the default) or an `<audio>` element (`clock.element`, kept
switchable while the first earns trust). Everything below the position — the
derivation, the clamp, the exposure grid — is here and is the same either way,
so the two can be compared on the same take rather than swapped on faith.
Build with `:closure-defines {arthur.clock/BACKEND \"element\"}`, or call
`use-backend!` from the console, to put the element back."
(:require [arthur.clock.element :as element]
[arthur.clock.graph :as graph]
[arthur.clock.transport :as t]
[arthur.domain.node :as node]))
(goog-define ^String BACKEND "graph")
(defonce ^:private mode (atom (keyword BACKEND)))
;; `{:source x :backend b}`. The source is kept so that re-attaching the same
;; thing can be recognised as the no-op it is — see `install!`.
(defonce ^:private current (atom nil))
(defn graph?
"Whether the app should wire up the graph backend. Read by `ui/shell`, which
renders the audio element only when this is false, and by `events/playback`,
which fetches a buffer rather than a WAV URL when it is true."
[]
(and (= :graph @mode) (graph/available?)))
(defn use-backend!
"Switch backends. Takes effect on the next thing that attaches one, which is
the next tab switch or document open — nothing is torn down under a take
that is already playing."
[m]
(reset! mode m))
(defn- install!
"Put a backend on `source`, unless `source` is already the clock's.
IDEMPOTENCE IS LOAD-BEARING HERE. The element's `:ref` is an inline closure,
so React hands it the same node again on every re-render of the shell —
rebuilding the clock there would mean a pane being dragged released whatever
was playing. The source is the identity: the element, or the buffer and its
length."
[source make]
(let [{:keys [backend] prev :source} @current]
(when-not (and backend (= prev source))
(when backend (t/-release! backend))
(reset! current {:source source
:backend (when (some? source) (make))}))))
(defn attach!
"Hand the clock an audio element. Idempotent. Element backend only — the
`:ref` that calls this is on a node `ui/shell` renders only in that mode."
[audio-el]
(install! audio-el #(element/backend audio-el)))
(defn attach-buffer!
"Hand the clock `seconds` of audio to run on, as an `AudioBuffer` or as nil
for silence of that length. Graph backend only."
[buffer seconds]
;; Keyed on the length as well as the buffer, because two silent clocks of
;; different lengths are two different clocks and both have a nil buffer.
(install! [buffer seconds] #(graph/backend buffer seconds)))
(defn- clamp [f frames]
(-> f (max 0) (min (dec frames))))
(defn frame
"The clip frame the audio is currently on."
[fps frames]
(if-let [b (:backend @current)]
(clamp (js/Math.floor (* (t/-position b) fps)) frames)
0))
(defn playing? []
(boolean (when-let [b (:backend @current)] (t/-playing? b))))
(defn rate []
(if-let [b (:backend @current)] (t/-rate b) 1.0))
(defn set-rate! [r]
(when-let [b (:backend @current)] (t/-set-rate! b r)))
(defn play! []
(when-let [b (:backend @current)] (t/-play! b)))
(defn pause! []
(when-let [b (:backend @current)] (t/-pause! b)))
(defn seek!
"Put the audio at the start of frame f. Seeking to the frame's start rather
than its middle keeps `frame` idempotent: seek to f, read back f."
[fps frames f]
(when-let [b (:backend @current)]
(t/-seek! b (/ (clamp f frames) fps))))
(defn set-loop!
"Wrap at the end instead of stopping. The frame stays derived — the position
simply returns to zero — so nothing about the sync changes, which is the point
of not counting frames.
It earns its place at 2x and 4x, where the whole clip is gone in under four
seconds and a profile wants more than that to look at."
[on?]
(when-let [b (:backend @current)] (t/-set-loop! b on?)))
(defn set-muted! [on?]
(when-let [b (:backend @current)] (t/-set-muted! b on?)))
(defn duration-frames
"How many frames the audio actually covers, which need not be the clip's
length. Reported rather than assumed: a clip longer than its audio is a
legitimate thing to be told about, not a thing to silently truncate."
[fps]
(when-let [b (:backend @current)]
(let [d (t/-duration b)]
(when (and d (js/isFinite d)) (js/Math.ceil (* d fps))))))
(defn exposed-frame
"The frame a clip-level exposure grid holds `f` back onto. The player shows it
as a readout so that `exposure 2` is visibly doing something at the transport
rather than only inside the scene."
[f expose]
(node/expose f expose))

View file

@ -0,0 +1,45 @@
(ns arthur.clock.element
"The clock on an `<audio>` element. The original backend, kept switchable.
Reads `currentTime` and takes it as the position. That is the whole of it, and
it is why this backend needs a WAV: an element holds a URL, so a mixdown has
to be encoded and blobbed before it can be played — see `audio/mix`'s
`clock!`. The encode is O(the clip's length) and runs on the main thread, so a
long take costs seconds of frozen UI on open, on every tab switch and on every
edit to a track. `arthur.clock.graph` exists to not do that.
What this backend still has that the graph one has to be told: the OS media
keys and the media session, which the element gets from the browser for free.
KEPT so the two can be compared on the same document rather than swapped on
faith. When the graph backend has been trusted for a while, this namespace and
`mix/clock!`'s WAV packaging go together."
(:require [arthur.clock.transport :as t]))
(deftype Element [^js el]
t/Transport
(-position [_] (.-currentTime el))
(-duration [_] (.-duration el))
(-playing? [_] (and (not (.-paused el)) (not (.-ended el))))
(-rate [_] (.-playbackRate el))
(-set-rate! [_ r] (set! (.-playbackRate el) r))
(-play! [_]
;; Returns a promise that rejects if the browser has not seen a gesture yet.
;; Swallowed: the transport button IS the gesture, so this can only fire on a
;; programmatic play, where a console error is noise rather than news.
(some-> (.play el) (.catch (fn [_]))))
(-pause! [_] (.pause el))
(-seek! [_ seconds] (set! (.-currentTime el) seconds))
(-set-loop! [_ on?] (set! (.-loop el) (boolean on?)))
(-set-muted! [_ on?] (set! (.-muted el) (boolean on?)))
(-release! [_]
;; Nothing. React owns the element and unmounting it is what stops it; this
;; backend is a few property accesses wrapped in a type and holds no more
;; than that.
nil))
(defn backend
"Wrap an audio element — or anything that answers the same five properties,
which is what `clock-test`'s fake is."
[el]
(->Element el))

View file

@ -0,0 +1,266 @@
(ns arthur.clock.graph
"The clock on a Web Audio graph. No encode, no blob, no element.
THE BUFFER IS PLAYED, NOT PACKAGED. `audio/mix`'s `buffer!` already renders
the mixdown; the element backend then spent O(the clip's length) encoding that
buffer to a WAV purely so a `src` attribute had something to point at. An
`AudioBufferSourceNode` takes the buffer as it is, so opening a document costs
one node instead of a hundred megabytes of 16-bit PCM.
THE FRAME IS STILL DERIVED, AND FROM A BETTER CLOCK. `AudioContext.currentTime`
is the audio device's own position in double precision, advancing every 128
samples; an element's `currentTime` is whatever the media pipeline last
published and is permitted to lag. Nothing is counted here either — position is
an anchor plus elapsed context time, and the anchor is re-set on every seek and
every rate change, so no arithmetic accumulates across either.
AND IT IS LATENCY-COMPENSATED, which is the one thing this backend must get
right. `currentTime` is the time of the quantum being RENDERED, which the
speaker is `outputLatency` behind — tens of milliseconds, far more over
Bluetooth. Report the renderer's position and the picture leads the sound by
exactly that much, which in a lip-sync tool is the only artefact that matters.
So `scheduled` is the bookkeeping and `-position` is `scheduled` read one
latency in the past. The element has the same lag underneath; the difference is
that this one is a number we can subtract rather than an error we inherit.
A SYMBOL WITH NO SOUND GETS A CLOCK ANYWAY, with no buffer at all: `seconds`
is its length and the context's clock does the rest. The element backend had to
synthesize silence and encode THAT to a WAV, full length, so the element had a
duration to report — the clearest sign that the element was driving the design
rather than serving it."
(:require [arthur.clock.transport :as t]))
(defonce ^:private shared (atom nil))
(defn available?
"Whether this backend can run at all. False under node, where the tests live."
[]
(exists? js/AudioContext))
(defn context
"The app's one `AudioContext`, made on first use.
Lazy because constructing one before anything wants to play is how a browser
decides the page is trying to autoplay, and because `available?` is false in
the test runner."
[]
(or (:ctx @shared)
(let [ctx (js/AudioContext.)
;; ONE output gain for the app, not one per backend: a tab switch
;; replaces the backend, and a gain node per backend would leave the
;; old one wired to the destination. Mute lives here for the same
;; reason — it is a property of the transport, not of whichever
;; buffer happens to be loaded.
out (.createGain ctx)]
(.connect out (.-destination ctx))
(:ctx (reset! shared {:ctx ctx :out out})))))
(defn output [] (do (context) (:out @shared)))
(defn- latency
"How far ahead of the speaker `currentTime` is, in seconds.
`outputLatency` is the whole path and the number we want. Firefox reports 0
until the graph has actually run, so `baseLatency` stands in — it is only the
graph's own buffering and therefore an underestimate, which errs towards the
picture leading slightly rather than the correction overshooting. Neither
exists everywhere; 0 is then no worse than the element."
[^js ctx]
(let [out (.-outputLatency ctx)
base (.-baseLatency ctx)]
(cond
(and (number? out) (js/isFinite out) (pos? out)) out
(and (number? base) (js/isFinite base) (pos? base)) base
:else 0)))
(defn- seg-at
"The segment governing context time `t`."
[segs t]
(or (last (filter #(<= (:from %) t) segs)) (first segs)))
(defn- raw
"Where the audio is at context time `t`, in seconds into the buffer.
PIECEWISE, and that is the whole subtlety of this namespace. Audio already
rendered cannot be re-rated: when the transport goes 1x -> 4x, the samples
still travelling to the speaker were rendered at 1x, so reading them back at
4x jumps the playhead BACKWARDS by three output latencies — about fourteen
frames on a laptop, which is a visible lurch on every rate change and was
exactly what the first version of this did. So each `play`, `pause`, `seek`
and rate change records a segment, and a position is read against whichever
segment was in force when that audio was rendered.
Evaluated before the oldest segment we kept, it clamps. That is what makes a
seek read back exactly what was seeked to: a seek has nothing in flight worth
honouring — the user has jumped — so its segment starts the timeline over."
[segs t]
(let [t (max t (:from (first segs)))
s (seg-at segs t)]
(+ (:anchor s) (* (:rate s) (- t (:from s))))))
(defn- at-renderer
"Where the graph has rendered up to: `raw` at the context's own clock."
[{:keys [^js ctx segs]}]
(raw segs (.-currentTime ctx)))
(defn- at-speaker
"Where the sound being heard is: `raw` one output latency in the past. See the
namespace docstring — this is the whole of the compensation."
[{:keys [^js ctx segs]}]
(raw segs (- (.-currentTime ctx) (latency ctx))))
(defn- running?
"Whether the renderer is advancing. Read off the segments rather than kept
beside them, so there is one answer and not two that can disagree."
[{:keys [segs]}]
(pos? (:rate (last segs))))
(defn- prune
"Drop segments no position can still need: everything before the last one that
began at or before the in-flight window. Unbounded history would otherwise
grow by one entry per rate change for the life of the document."
[segs cutoff]
(let [n (count (take-while #(<= (:from %) cutoff) segs))]
(if (<= n 1) segs (subvec segs (dec n)))))
(defn- position
"`at-speaker`, brought inside the audio. Clamped before the wrap, so the few
milliseconds of negative position right after a looped play — the first
samples are still in the output buffer — read as 0 rather than as the end."
[{:keys [seconds loop?] :as st}]
(let [p (at-speaker st)]
(cond
(not (and seconds (pos? seconds))) 0
loop? (mod (max 0 p) seconds)
:else (-> p (max 0) (min seconds)))))
(defn- done?
"Run off the end. Judged at the SPEAKER, so playback is still reported as
running while the last scheduled samples are on their way out."
[{:keys [seconds loop?] :as st}]
(and (not loop?) seconds (pos? seconds) (>= (at-speaker st) seconds)))
(defn- spin-up!
"A fresh source node playing from where the renderer now is.
`AudioBufferSourceNode`s are single-use, so a play, a seek while playing and a
resumed pause each make a new one; they are cheap, which is the point of this
backend.
Nil when there is no buffer — the silent clock runs on the context alone."
[{:keys [^js ctx ^js buffer ^js out rate loop? seconds] :as st}]
(when buffer
(let [node (.createBufferSource ctx)]
(set! (.-buffer node) buffer)
(set! (.-loop node) (boolean loop?))
(set! (.-value (.-playbackRate node)) rate)
(.connect node out)
;; `when` 0 is "as soon as the graph can"; the offset is where in the
;; buffer to begin. Clamped because starting past the end is a range
;; error rather than a no-op in some engines.
(.start node 0 (-> (at-renderer st) (max 0) (min (or seconds 0))))
node)))
(defn- spin-down! [{:keys [^js node]}]
(when node
(try (.stop node) (catch :default _ nil))
(try (.disconnect node) (catch :default _ nil))))
(defn- restart!
"Begin a new timeline at `anchor`, running at `rate` or held when it is 0.
A RESET rather than a segment, for the three cases that have nothing in flight
worth honouring: a play starts fresh, a seek means the user has jumped, and a
pause wants one stable number for the readout rather than a position that
creeps forward as the output buffer drains."
[state anchor rate]
(let [st @state]
(spin-down! st)
(swap! state assoc
:segs [{:from (.-currentTime ^js (:ctx st)) :anchor anchor :rate rate}]
:node nil)
(when (pos? rate)
(swap! state assoc :node (spin-up! @state)))))
(deftype Graph [state]
t/Transport
(-position [_] (position @state))
(-duration [_] (:seconds @state))
(-playing? [_] (let [st @state] (boolean (and (running? st) (not (done? st))))))
(-rate [_] (:rate @state))
(-set-rate! [_ r]
;; A SEGMENT, not a reset — the one case where what is already in flight has
;; to keep its old rate or the playhead lurches. See `raw`.
(let [st @state
now (.-currentTime ^js (:ctx st))]
(if (running? st)
(let [anchor (at-renderer st)]
(swap! state #(-> %
(assoc :rate r)
(update :segs (fn [segs]
(prune (conj segs {:from now :anchor anchor :rate r})
(- now (latency ^js (:ctx st))))))))
(when-let [^js node (:node st)]
(set! (.-value (.-playbackRate node)) r)))
;; Stopped: nothing is in flight and nothing is rendering, so the rate
;; is simply what the next play will run at.
(swap! state assoc :rate r))))
(-play! [_]
(let [st @state]
;; `done?` counts as not playing. It is DERIVED — no flag is cleared when
;; the take runs off its end — so testing "running" alone would make play
;; a dead button from the moment the audio finished.
(when (or (not (running? st)) (done? st))
;; The context starts suspended and only a gesture may resume it. The
;; transport button IS the gesture, so a rejection here means a
;; programmatic play and is noise rather than news — as on the element.
(some-> (.resume ^js (:ctx st)) (.catch (fn [_])))
;; Play at the end starts over, which is what the element does.
(restart! state (if (done? st) 0 (position st)) (:rate st)))))
(-pause! [_]
(let [st @state]
(when (running? st)
;; Anchored at what was HEARD, not at what was scheduled, so play after
;; pause resumes from where the sound stopped. It re-plays the last few
;; milliseconds rather than skipping them, which is the kinder of the
;; two roundings.
(restart! state (position st) 0))))
(-seek! [_ seconds]
(restart! state seconds (if (running? @state) (:rate @state) 0)))
(-set-loop! [_ on?]
(swap! state assoc :loop? (boolean on?))
;; The audio thread reads `loop` every quantum, so a live node picks this up
;; without being restarted — the same as setting `loop` on a playing element.
(when-let [^js node (:node @state)]
(set! (.-loop node) (boolean on?))))
(-set-muted! [_ on?]
(set! (.-value (.-gain ^js (:out @state))) (if on? 0 1)))
(-release! [_]
;; The source node is wired to the output the whole app shares, so a backend
;; dropped while playing would go on being heard under the one that replaced
;; it. Nothing else here needs releasing: the context and its gain outlive
;; every backend by design.
(let [st @state]
(spin-down! st)
(swap! state assoc
:segs [{:from (.-currentTime ^js (:ctx st))
:anchor (position st) :rate 0}]
:node nil))))
(defn backend
"A clock on `buffer`, `seconds` long. A nil buffer is a silent clock of that
length — see the namespace docstring.
The context and output are injected by the four-argument form so the whole of
this is assertable against a fake in node, where there is no Web Audio."
([buffer seconds] (backend (context) (output) buffer seconds))
([^js ctx ^js out buffer seconds]
(->Graph (atom {:ctx ctx :out out :buffer buffer :seconds seconds
:rate 1.0 :loop? false :node nil
:segs [{:from (.-currentTime ctx) :anchor 0 :rate 0}]}))))

View file

@ -0,0 +1,38 @@
(ns arthur.clock.transport
"What the clock needs of a thing that plays sound, and nothing more.
`arthur.clock` does the arithmetic — the frame derivation, the clamping, the
exposure grid — and a backend only has to answer where the sound has got to
and do as it is told. Keeping the protocol this thin is what makes the two
implementations comparable: if the graph backend and the element backend
disagree about a take's sync, the difference is in these nine methods and not
in anything derived from them.
POSITION IS WHERE THE SPEAKER IS, not where the renderer is. A backend that
schedules audio ahead of the output owes the difference back here — see
`arthur.clock.graph` — because the picture is drawn against this number and a
lip-sync tool that draws against the scheduler leads the sound it is matching.")
(defprotocol Transport
(-position [this]
"Seconds into the audio, as heard. Never negative, never past `-duration`,
and wrapped rather than clamped while looping.")
(-duration [this]
"Seconds of audio, or nil when the backend has not been told yet.")
(-playing? [this]
"Running AND not finished. A backend that has reached its end reports false
even if nothing told it to stop, because the end of the sound is the
authority on playback having stopped — see `ui/player`'s loop.")
(-rate [this])
(-set-rate! [this r])
(-play! [this])
(-pause! [this])
(-seek! [this seconds]
"Put the audio at `seconds`. Reading `-position` back must give the same
number, which is what makes a scrub idempotent.")
(-set-loop! [this on?])
(-set-muted! [this on?])
(-release! [this]
"Give up whatever this backend holds, because something else is about to be
the clock. NOT a pause: a backend that owns nothing has nothing to do here,
and the position it was last at is no longer anybody's business."))

View file

@ -0,0 +1,53 @@
(ns arthur.core
"The app's entry point.
port-plan step 3: the hand-written scene plays at 30fps against audio, scrubs,
and runs at ½× and ¼×."
(:require [arthur.db :as db]
[arthur.events.collab :as collab]
[arthur.events.footage :as footage]
[arthur.events.history :as history]
[arthur.events.playback]
[arthur.events.paint]
[arthur.events.project :as project]
[arthur.events.ui]
[arthur.subs.playback]
[arthur.subs.render]
[arthur.subs.ui]
[arthur.ui.index :as index]
[arthur.ui.player :as player]
[arthur.ui.shell :as shell]
[arthur.ui.tools :as tools]
[re-frame.core :as rf]
[reagent.dom.client :as rdc]))
(defonce root (atom nil))
(rf/reg-event-db ::init (fn [_ _] db/default))
(defn ^:dev/after-load mount []
;; A hot reload changes the scene or the rasteriser and not the playhead, so
;; the loop would otherwise sit on an unchanged frame number and never redraw.
(rf/clear-subscription-cache!)
(player/refresh-subs!)
(rdc/render @root [:<> [shell/view] [index/view]]))
(defn init []
(rf/dispatch-sync [::init])
;; A blank document, before the first render. Synchronous for the same reason
;; `::init` is: the shell reads the clip's dimensions, and mounting against a
;; db that has no clip in it yet is a frame of nothing for no reason.
(rf/dispatch-sync [::project/new])
;; What the server already holds, asked for once. The list is small — a row per
;; ingested take — and having it before the first click is what lets the footage
;; picker be a picker rather than a path to type.
(rf/dispatch [::footage/refresh])
(rf/dispatch [::project/list-symbols])
;; After the blank document, so an address that names a project opens it over
;; the blank one, and the blank one is what a bad address leaves on screen.
(collab/start!)
(history/install-keys!)
(tools/install-keys!)
(reset! root (rdc/create-root (js/document.getElementById "app")))
(mount)
(player/start!))

195
frontend/src/arthur/db.cljs Normal file
View file

@ -0,0 +1,195 @@
(ns arthur.db
"app-db: authored data and ids. Nothing derived, and nothing large.
That sounds like hygiene and it is the precondition for two things that are
otherwise unbuildable — spec validation on every event, which is only
affordable over authored data, and cheap writes, since every edit `assoc`es
into this map and every mounted layer-2 sub compares the result.
So the clip is here (it is a document — a human placed every node) and dense
channel blocks are not; they live behind a handle in `store`. The hand-written
demo clip has no dense blocks and its store is empty; the swarm and the take
are entirely dense."
(:require [arthur.demo :as demo]
[arthur.domain.clip :as domain-clip]
[arthur.demo.swarm :as swarm]
[arthur.demo.take :as take]))
(defn- entry
"A clip plus what the transport and the stage read off it.
Read OFF the clip rather than written again beside it: copying a number by hand
into this table is how it comes to disagree with the document it describes.
There is no `:frames` here, because a length belongs to a symbol and which
symbol is open is the editor's state — see `events/playback/frames`."
[label-key label clip store]
(merge {:label label :clip clip :store store
;; A static asset since step 9, and not the repo root's `audio.wav`.
;; That file is `extract.sh`'s output — tier 3, which the backend now
;; serves by hash — and the synthetic take needs a sound of its own so
;; that the clock has something to run against with no footage ingested.
:audio "/static/arthur/audio.wav"
:cid (name label-key)}
(select-keys clip [:fps :width :height])))
(def clips
"The hand-made clips, selectable from the transport.
`:swarm` is the load test: a hundred and twenty nodes, entirely dense. The two
takes are step 5's deliverable and they are ONE freeze — the same blocks, with
`:head` written as a dense track in one and as framed identity in the other, so
the button that switches between them switches a document field and nothing
else."
{:demo {:label "demo" :entry (delay (entry :demo "demo" demo/clip nil))}
:swarm {:label "swarm" :entry (delay (entry :swarm "swarm" @swarm/clip @swarm/store))}
:take {:label "take" :entry (delay (entry :take "take" @take/clip @take/store))}
:take-locked {:label "locked" :entry (delay (entry :take-locked "locked" @take/locked @take/store))}})
(defn clip-entry [id]
(some-> (get-in clips [id :entry]) deref))
(def tracing
"How tracing layers show on the stage: all of them or none, how strongly, and
which ones are switched off, as `[symbol-id node-id]` — the symbol a tracing
placement is in and its id, so a face's footage is one layer wherever the face
is placed.
THE EDITOR'S, NOT THE DOCUMENT'S. Showing a reference is a way of looking at
the stage, like solo and zoom: not an undo step, not sent to collaborators,
and it cannot reach an export. A layer is shown unless it is in `:hidden`, so
footage brought in shows without being found and switched on first — once
tracing itself is on, which it is not until asked for."
{:on? false :opacity 0.5 :hidden #{}})
(def default
{;; --- the document ---
;;
;; NOTHING IS LOADED. `core/init` dispatches `::project/new` before the first
;; render, so the app opens on a blank stage rather than on whichever built-in
;; scene happened to be convenient — the demos, the swarm and the two takes are
;; rows in the media pool like anything else, and reference material is not a
;; default. The values below are what a blank document is; they are replaced by
;; that dispatch and exist so this map is a valid db on its own.
:clip/current nil
:paint/revision 0
:palette :arthur/default ; a NAME; the ramp itself is project data
;; --- the clip ---
;;
;; Including the STAGE DIMENSIONS, which are the project's and not the
;; footage's. That is what deleting `makeXform` buys — the framing became a
;; transform on a node, so nothing downstream of the freeze knows the frame
;; size — and it is why ui/player no longer hardcodes 320x200.
:clip (let [c (domain-clip/blank)]
{:fps (:fps c)
:width (:width c) :height (:height c)
:audio nil})
;; Which ingested footage to detect, and what the last load said. The list
;; comes from the server — tier 3 is the backend's since step 9 — so there is
;; no path to type any more.
:footage {:id nil :label nil :loading? false :status nil
:available [] :chosen nil :uploaded #{}}
;; Every symbol in every saved project, for the pool's all-assets folder. Rows
;; from `/api/symbols`, nothing loaded: a symbol from elsewhere is fetched when
;; it is dropped.
:assets {:symbols [] :palettes [] :loading? false}
;; The document's own identity on the server. `:seq` is the monotonic project
;; version: a client that sees a delta with `seq > local + 1` refetches, which
;; is what will make staleness self-healing once there is a broadcast to miss.
:project {:id nil :cid nil :name nil :seq nil :busy? false :status nil}
;; What the server holds, for the open menu. A list of rows and nothing more —
;; opening one fetches the document itself.
:projects {:items [] :loading? false}
;; --- transport ---
;;
;; The playhead is in app-db like everything else. An earlier draft of
;; docs/architecture.md put it in a standalone ratom to dodge an invalidation
;; storm that does not happen: with layer-2 extractors and layer-3
;; computations, a tick re-runs one cheap extractor per mounted sub, each
;; returning the same value for every subtree the tick did not touch, and
;; therefore notifying nobody. ::resolver does not re-run.
;;
;; Two reasons it belongs here rather than outside: a seek in the event log is
;; how scrubbing becomes inspectable in re-frame-10x, and a collaborator's
;; playhead is a feature — putting it outside app-db puts it outside the
;; machinery that would share it.
;; --- export ---
;;
;; The REQUEST and its progress, never the frames. Which symbol to write and
;; at what integer zoom is authored state like anything else; the megabytes the
;; render produces are handed straight to a download and never enter the db.
;; `:isolate` is the placement to render alone, or nil for the whole symbol;
;; `:symbol` nil means whichever symbol is open.
:export {:symbol nil :isolate nil :zoom 4 :busy? false :done 0 :total 0
:status nil}
:playback {:frame 0
:playing? false
:rate 1.0
;; Both for profiling: loop so a run at 4x lasts longer than the
;; clip, mute so sitting in one does not require enduring it.
:loop? false
:muted? false}
;; --- the editor's own state ---
;;
;; IN app-db, not in ratoms beside the components that read it. What is
;; selected is asked by four panes at once — the params pane renders it, the
;; timeline highlights its row, the stage draws its handles, the palette says
;; which tone a new shape gets — and a `defonce` atom private to one namespace
;; can only be shared by making the other three require that namespace for its
;; state. It is also small and authored, which is the bar `arthur.db` sets.
;;
;; `:selection` is a vector whose first element says what kind of thing it
;; names, so a pane dispatches on it rather than on which of several
;; "selected-x" keys happens to be non-nil:
;;
;; [:node <symbol> <node>] a shape or an instance
;; [:symbol <id>] a symbol
;; [:subject <id>] [:feature <id>] [:group <id>] a tracked object
;;
;; `:draft` is the polygon being clicked out, flat [x y x y …] as geometry is
;; stored everywhere. `:expanded` holds timeline row PATHS — a path and not a
;; node id, because one symbol placed twice is two rows that open separately.
;;
;; `:open` is the symbol on screen — the one the stage draws, the timeline
;; lists, the transport plays and a new shape goes into — and `:tabs` the
;; symbols open beside it. Editor state and not the document's, because no
;; symbol is special to the document: which one you are looking at is a fact
;; about you.
;;
;; `:knobs` holds a generated setting's value WHILE THE REGENERATION IS IN
;; FLIGHT, keyed by [scope id knob]. Moving a slider dispatches a preview that
;; re-freezes blocks asynchronously, so until it lands the clip still reports
;; the old value — and a slider reading from the clip would spring back under
;; the user's finger on every frame of the drag.
:ui {:open nil
:tabs []
:selection nil
:selections []
:tone 1
:tool :select
:brush 6
:auto-key? false
:draft []
:knobs {}
:tracing tracing
:expanded #{}}})
(def rates
"The transport's rates — all of them `playbackRate` on the audio element, so
the picture cannot drift from the sound at any of them.
2x and 4x are there to be profiled at rather than watched. A 30fps clip at 2x
wants sixty clip frames a second against a 60Hz display, so every animation
frame has to paint a new one: it is the point where the loop stops having
slack. Past that the clock starts dropping frames rather than falling behind,
which is the whole reason the frame is derived from the audio instead of
counted — and the transport reports the drop rate so that it is visible rather
than merely survivable."
[0.25 0.5 1.0 2.0 4.0])

View file

@ -0,0 +1,29 @@
(ns arthur.demo
"The hand-written clip from port-plan step 2, and nothing else.
The EDN is a resource rather than a literal in this file so that the test and
the page read the SAME bytes. If the scene were written twice, the one the test
validates would not be the one that renders, and the model would be validated
against a scene nobody ever looked at."
(:require [arthur.domain.clip :as domain-clip]
[arthur.domain.palette :as pal]
[arthur.domain.symbol :as symbol]
[cljs.reader :as reader]
[shadow.resource :as rc]))
(def source (rc/inline "arthur/demo/scene.edn"))
(def clip (reader/read-string source))
(def main
"The scene's one symbol: what an evaluator takes. `clip` is the document."
(domain-clip/symbol clip :main))
(def fps (:fps clip))
(def frames (domain-clip/frames clip :main))
(defn ops-at
"Draw ops for one frame, via the specification path. The page uses
`symbol/resolver` instead; this is here for the REPL."
[f]
(symbol/eval-frame main f nil pal/index-of nil))

View file

@ -0,0 +1,102 @@
;; A scene written by hand, before any analysis exists.
;;
;; port-plan step 2 is deliberately ahead of measurement: the data model has
;; never been validated, and it is worth finding out here, with fifty lines to
;; throw away, rather than after nine hundred lines of measurement have been
;; ported into a shape that does not work.
;;
;; So this is not a demo of a face. It is the smallest scene that exercises every
;; mechanism the model claims to have, chosen so that each one is visible when it
;; breaks:
;;
;; exposure inherited from the clip root the motion steps on 2s
;; a keyed [:xform :pos], sparse, held the card jumps between 4 poses
;; transform composition through a group the eye rides the card
;; rotation about an anchor the card turns, it does not swing
;; a stencil as a colour key the iris cannot leave the card
;; a stencil chain nor can the pupil
;; a keyed [:vis] the bar blinks off and back
;; a :span the bar does not exist at either end
;; fractional z among siblings the bar is behind, the pupil in front
;;
;; Everything is in 320x200 raster space, which is what [:geom :pts] holds.
{:name "step-2 demo"
;; 229 frames at 30fps is 7.63s, which covers audio.wav (7.601s) with a frame to
;; spare. fps belongs to the CLIP rather than to the timeline — a timeline has a
;; frame space, not a rate — and it is here only because there is one clip.
:fps 30
;; The STAGE, in pixels. The project's dimensions, not the footage's — which is
;; what makes `makeXform` deletable: placement is a transform on a node and the
;; stage clips whatever hangs off. Here everything is authored in stage pixels
;; already, because a hand-written scene is a painted one.
:width 320
:height 200
:symbols
{:main
{:id :main
:frames 229
:nodes
{;; The clip root. EXPOSURE LIVES HERE and is inherited, because
;; docs/design.md is emphatic that everything rides one grid: a head cutting on
;; odd frames against a mouth cutting on even ones reads as two performances.
;; Setting it lower on a child is possible and is meant to feel deliberate.
:root
{:id :root :name "clip" :kind :group :parent nil :z "a1"
:time {:mode :map :expose 2}}
;; A bar, behind everything, purely to assert that :vis and :span are different
;; questions. It stops existing outside [6 66) — nothing to hide, nothing to
;; hold — and inside that range it is switched off between frames 76 and 153.
:bar
{:id :bar :name "bar" :kind :poly :parent :root :z "a0"
:span [19 210]
:channels
{[:vis] {:animated? true :interp :hold :keys {0 true, 76 false, 153 true} :over []}
[:geom :pts] {:animated? false :value [20 168 300 168 300 176 20 176]}
[:style :color] {:animated? false :value :brow}}}
;; The group the plan asks for: four sparse keys on [:xform :pos], held. At
;; exposure 2 the card reads its pose from an even frame, so a key landing on
;; an odd frame would be seen on the even frame after it — which is the whole
;; reason exposure is applied before anything else and not folded into keys.
:swing
{:id :swing :name "swing" :kind :group :parent :root :z "a1"
:channels
{[:xform :pos] {:animated? true :interp :hold
:keys {0 [90.0 100.0], 57 [200.0 70.0], 114 [230.0 140.0], 171 [110.0 150.0]}
:over []}
[:xform :rot] {:animated? true :interp :hold
:keys {0 0.0, 57 0.35, 114 0.0, 171 -0.35}
:over []}}}
;; The rectangle. Its points are centred on the origin and its :anchor is the
;; origin too, so :swing's rotation TURNS it rather than swinging it round a
;; corner — which is the failure mode :anchor exists to prevent.
:card
{:id :card :name "card" :kind :poly :parent :swing :z "a1"
:channels
{[:geom :pts] {:animated? false :value [-44 -30 44 -30 44 30 -44 30]}
[:style :color] {:animated? false :value :skin-base}}}
;; A disc stencilled by the card. The stencil is a COLOUR KEY, not a node
;; reference — it is the take format's clip= — so the iris is written only over
;; pixels that currently hold the card's index. Push the radius up and it is
;; cropped by the card's edge rather than spilling, at any position, with no
;; clamp anywhere.
:iris
{:id :iris :name "iris" :kind :disc :parent :card :stencil :card :z "a2"
:channels
{[:xform :pos] {:animated? false :value [14.0 -6.0]}
[:geom :radius] {:animated? false :value 13.0}
[:style :color] {:animated? false :value :iris}}}
;; The pupil is a SQUARE, and three pixels of it. A circle of radius 1.5 is not
;; a circle, it is a plus sign with the corners gnawed off, and it changes shape
;; as it moves. Stencilled by the iris, which is itself already cropped by the
;; card, so the clip composes without the chain being expressed anywhere.
:pupil
{:id :pupil :name "pupil" :kind :rect :parent :iris :stencil :iris :z "a3"
:channels
{[:geom :size] {:animated? false :value 5.0}
[:style :color] {:animated? false :value :pupil}}}}}}}

View file

@ -0,0 +1,120 @@
(ns arthur.demo.stage
"A saved take placed seven times on a stage. The EDN is the authored layout."
(:require [arthur.domain.channel :as ch]
[cljs.reader :as reader]
[shadow.resource :as rc]))
(def layout (reader/read-string (rc/inline "arthur/demo/stage_8625.edn")))
(defn- position-track
"Where the peg sits over time: its `center` plus a slow two-axis drift. The
peg's own position, so the stage point the face is pinned to is what moves —
not an offset that has to be kept in step with a changing scale."
[center drift phase frames]
(let [base center
[dx dy] drift
wave (fn [f period] (js/Math.sin (* 2 js/Math.PI (/ (+ f phase) period))))
x0 (wave 0 96)
y0 (wave 0 132)]
(ch/keyed
(into {}
(for [f (conj (vec (range 0 frames 20)) (dec frames))]
[f [(+ (first base) (* dx (- (wave f 96) x0)))
(+ (second base) (* dy (- (wave f 132) y0)))]]))
:linear)))
(defn compose
"The authored layout plus a source clip -> the composed stage document.
A PEG'S IDENTITY IS AUTHORED TOO, beside its instance's, for the reason the
instance's is: `compose` is a pure function of the layout, so a generated one
would make the same stage a different document on every call.
AN INSTANCE IS KEYED BY ITS :uuid, not by the authored id. The authored id
(`:left`, `:voice-right`) is a handle for reading the EDN and for the
`:linked-to` written there; it does not appear in the document this returns.
What replaces it is an identity that means one instance and nothing else: seven
instances of one symbol are seven different things to name — to export on their
own, to link a voice to, to point at later — and an id like `:left` is a
description of where a thing sits, which is exactly what changes when the stage
is re-arranged. `:name` carries the label for a human and `:source :symbol`
carries the symbol, so the node still says what it is and which drawing it
plays — and `:playback` says how time runs inside it, which is a separate
question from which drawing that is."
[source]
(let [{:keys [name width height frames symbol instances audio scale]} layout
;; Which point of the SOURCE is pinned to the stage `:center`. Its
;; middle, unless the layout says otherwise for an off-centre drawing.
default-origin (or (:origin layout)
[(/ (:width source) 2) (/ (:height source) 2)])
original (get-in source [:symbols :main])
;; Authored id -> uuid, so the `:linked-to` in the EDN resolves to the
;; identity the document uses. Built before either pass because the audio
;; nodes refer to the instances.
by-id (into {} (map (juxt :id :uuid)) (concat instances audio))
uuid-of (fn [what id]
(or (get by-id id)
(throw (ex-info "the stage layout names an instance that is not there"
{:in what :id id
:known (vec (sort-by str (keys by-id)))}))))
;; EVERY PLACEMENT IS A PEG AND AN INSTANCE, and this layout is what
;; makes the pair necessary rather than tidy. `:scale` is KEYED — the
;; faces pulse — and it has to happen about the point the face is pinned
;; to. A stored `[:xform :anchor]` used to buy that: a static position
;; and a moving scale, turning about a fixed point. No static position
;; can do it alone, because `T(pos)·S(k(f))` moves the source's middle
;; whenever `k` changes, so holding it still would mean keying `pos` in
;; lockstep with `scale` — two channels that have to agree frame for
;; frame, which is the thing channels exist to avoid.
;;
;; A peg does it with neither:
;;
;; peg pos = center (+ drift), scale = k(f)
;; └ face pos = -origin
;;
;; world = T(center)·S(k(f))·T(-origin)
;;
;; which takes `origin` to `center` for EVERY k, with nothing keyed that
;; was not keyed before. That is the same matrix the anchor produced —
;; `node-test` asserts the identity — and it is reachable, keyable and
;; selectable, which the anchor was not.
;;
;; THE PEG IS THE PLACEMENT: it carries WHERE (pos, scale) and WHEN
;; (`:at`, `:span`), and the face under it carries only which drawing and
;; the offset to its origin. The time map has to be the peg's, because
;; `:scale` is read in the placement's own frames — that is what staggers
;; the entrances' growth — and `:span` goes with it so that
;; `node/placed-span` still answers where the placement sits on the stage.
;; The face then reads its peg's frames as its own and shows whenever the
;; peg does.
nodes (into
{:root {:id :root :name "stage" :kind :group :z "a1"}}
(mapcat (fn [{:keys [uuid peg name z span at center origin drift phase]}]
(let [origin (or origin default-origin)]
[[peg {:id peg :name name :kind :group
:parent :root :z z :span span
:time {:mode :map :at at :rate 1}
:channels {[:xform :pos]
(if drift
(position-track center drift phase frames)
(ch/framed center))
[:xform :scale] scale}}]
[uuid {:id uuid :name (str name " face") :kind :instance
:parent peg :z "a1"
:source {:symbol symbol}
:channels {[:xform :pos]
(ch/framed (mapv - origin))}}]]))
instances))
nodes (into nodes
(map (fn [{:keys [uuid linked-to z source span at gain pan]}]
[uuid {:id uuid :kind :audio :parent :root :z z
:linked-to (uuid-of uuid linked-to)
:source source :span span
:time {:mode :map :at at :rate 1}
:channels (cond-> {[:audio :gain] gain}
pan (assoc [:audio :pan] pan))}])
audio))]
(assoc source :name name :width width :height height
:symbols (assoc (:symbols source)
:main {:id :main :frames frames :nodes nodes}
symbol (assoc original :id symbol)))))

View file

@ -0,0 +1,89 @@
;; A local stage sketch. The source is the saved, post-processed IMG_8625.MOV
;; clip in the project store; its dense channel blocks are shared by all seven
;; instances. Centers, drift and timing are authored in stage pixels and frames.
{:source-project "4379f900-bdd2-409b-acf6-32081f8ce01f"
:source-cid "f8cace9e-4ad3-4796-973c-c62eeebe3d01"
:symbol :sym/face-8625
:name "8625 stage study"
:width 320 :height 200 :frames 280
;; Each :center below places the source clip's center on the stage; :origin
;; overrides which point of the source that is, for an off-centre drawing.
;; :peg is the identity of the transform node the placement hangs off — it
;; carries :center and :scale, the face below it carries -:origin, and that pair
;; is what makes a KEYED scale happen about the pinned point. See demo/stage.
;; Each placement reads this pulse in its own local time, so the staggered
;; entrances start their growth at different moments on the master timeline.
:scale {:animated? true :interp :linear
:keys {0 [0.4 0.4], 12 [0.56 0.56], 24 [0.48 0.48],
48 [0.52 0.52], 72 [0.48 0.48], 96 [0.52 0.52],
120 [0.48 0.48], 144 [0.52 0.52], 168 [0.48 0.48],
192 [0.52 0.52], 216 [0.48 0.48], 240 [0.52 0.52],
279 [0.48 0.48]}
:over []}
;; Audio placements are ordinary timeline nodes with channel parameters.
;; :linked-to is an editorial link; their spans and time maps are independent.
;; A span is in the placement's OWN frames and :at is where its frame 0 lands on
;; the stage, so every entrance below plays from its own start.
:audio
[{:id :voice-left :uuid #uuid "eeaa49c3-1238-469f-bf54-44929e379f6b"
:linked-to :left :z "a3"
:source {:footage "f8cace9e-4ad3-4796-973c-c62eeebe3d01"}
:at 0 :span [0 280]
:gain {:animated? false :value 1.0}}
{:id :voice-right :uuid #uuid "468239dd-0e3a-4e5c-ac6f-1858430a0355"
:linked-to :right :z "a4"
:source {:footage "f8cace9e-4ad3-4796-973c-c62eeebe3d01"}
:at 48 :span [0 212]
:gain {:animated? true :interp :linear
:keys {48 0.0, 60 1.0, 90 0.35, 115 0.9, 145 0.45,
170 1.0, 195 0.4, 220 0.85, 245 1.0, 259 0.0}
:over []}
:pan {:animated? true :interp :linear
:keys {48 -0.8, 90 -0.8, 130 0.7, 175 0.7, 220 -0.65, 259 0.65}
:over []}}]
;;
;; EVERY PLACEMENT CARRIES A :uuid, and it is authored here rather than generated
;; in `compose`. The uuid is the node's identity in the composed document — it is
;; the key in the timeline's node map — so generating one per load would give the
;; same stage a different document on every load, and nothing that refers to a
;; placement (`:linked-to` above, an export target in the UI, a comment in a
;; review) could survive a reload. The `:id` beside it stays as the AUTHORING
;; handle: it is what the reader of this file uses to see which placement is
;; which, and what the `:linked-to` above names, and `compose` resolves it to the
;; uuid. Nothing downstream of `compose` sees the authored id.
:instances
[{:id :left :uuid #uuid "ee7321c8-faf1-46d7-8029-37771898accb"
:peg #uuid "b1e0a7c4-5f3d-4a8e-9c21-70e4d1a90f01"
:name "8625 left" :z "a1"
:at 0 :span [0 280]
:center [40 40] :drift [3 2] :phase 0}
{:id :right :uuid #uuid "1aa0da78-b4ed-4bb6-8d70-b09a3ec5e2c3"
:peg #uuid "b1e0a7c4-5f3d-4a8e-9c21-70e4d1a90f02"
:name "8625 right" :z "a2"
:at 48 :span [0 232]
:center [120 40] :drift [-3 2] :phase 17}
{:id :top-third :uuid #uuid "23bb697d-eba7-4af6-a86c-606c50107088"
:peg #uuid "b1e0a7c4-5f3d-4a8e-9c21-70e4d1a90f03"
:name "8625 top third" :z "a5"
:at 24 :span [0 256]
:center [200 40] :drift [2 -3] :phase 31}
{:id :top-fourth :uuid #uuid "f4f0241d-026e-4e50-9bea-a4ccde896d8a"
:peg #uuid "b1e0a7c4-5f3d-4a8e-9c21-70e4d1a90f04"
:name "8625 top fourth" :z "a6"
:at 72 :span [0 208]
:center [280 40] :drift [-2 -2] :phase 49}
{:id :bottom-left :uuid #uuid "8f594d72-a97f-4a32-82fd-08d1670a2218"
:peg #uuid "b1e0a7c4-5f3d-4a8e-9c21-70e4d1a90f05"
:name "8625 bottom left" :z "a7"
:at 96 :span [0 184]
:center [70 135] :drift [3 -2] :phase 63}
{:id :bottom-middle :uuid #uuid "63f3fb32-9e94-4d68-a1c2-12e6de2d04b5"
:peg #uuid "b1e0a7c4-5f3d-4a8e-9c21-70e4d1a90f06"
:name "8625 bottom middle" :z "a8"
:at 120 :span [0 160]
:center [160 135] :drift [-2 3] :phase 81}
{:id :bottom-right :uuid #uuid "fa338701-cb21-4d45-89f1-a5e706f045ec"
:peg #uuid "b1e0a7c4-5f3d-4a8e-9c21-70e4d1a90f07"
:name "8625 bottom right" :z "a9"
:at 144 :span [0 136]
:center [250 135] :drift [2 2] :phase 107}]}

View file

@ -0,0 +1,168 @@
(ns arthur.demo.swarm
"A hundred and twenty shapes, orbiting, spinning, pulsing and blinking.
Not useful. It is here because it is the first thing to exercise the DENSE
channel path end to end — typed-array blocks behind a store handle, one value
per frame, read through a cursor — which until now had tests and no traffic.
Step 5 writes exactly this shape out of the freeze module, so it is worth
knowing the resolver can carry it at rate before anything depends on that.
Everything is generated from deterministic trigonometry rather than from a
random seed: the same scene every load, so a stutter or a wrong pose is
reproducible instead of being a thing that happened once.
Layout of each block is the rectangular one freeze produces — node-major,
frame-minor, no per-frame header and no indirection:
offset(node i) = i · frames · stride
value(i, f) = data[offset(i) + f · stride]"
(:require [arthur.domain.channel :as ch]
[arthur.domain.palette :as pal]))
(def frames 229)
(def fps 30)
(def n-orbits 6)
(def n-shapes 120)
(def ^:private TAU (* 2 js/Math.PI))
;; Every tone except the background, so the swarm uses the whole ramp.
(def ^:private tones
(vec (remove #{:bg} (map :name pal/entries))))
(defn- regular-poly
"A closed n-gon about the origin, flat in [x0 y0 x1 y1 …] — the same layout a
dense block holds, which is the point of geometry being flat everywhere."
[n radius phase]
(vec (mapcat (fn [k]
(let [a (+ phase (/ (* TAU k) n))]
[(* radius (js/Math.cos a))
(* radius (js/Math.sin a))]))
(range n))))
;; ---------------------------------------------------------------------------
;; the dense blocks
(defn- fill-block!
"Write one node's frames into a node-major block."
[^js data i stride f->vals]
(let [base (* i frames stride)]
(dotimes [f frames]
(let [vs (f->vals f)
o (+ base (* f stride))]
(dotimes [k stride]
(aset data (+ o k) (nth vs k)))))))
(defn- orbit-blocks []
(let [pos (js/Float32Array. (* n-orbits frames 2))
rot (js/Float32Array. (* n-orbits frames 1))]
(dotimes [i n-orbits]
(let [ph (/ (* TAU i) n-orbits)
;; Lissajous, so the six orbits drift in and out of phase with each
;; other instead of marching in step.
wx (+ 0.011 (* 0.004 (mod i 3)))
wy (+ 0.017 (* 0.003 (mod i 4)))
spin (* 0.008 (if (even? i) 1 -1) (inc (mod i 3)))]
(fill-block! pos i 2
(fn [f] [(+ 160 (* 104 (js/Math.sin (+ (* f wx) ph))))
(+ 100 (* 64 (js/Math.sin (+ (* f wy) (* 1.7 ph)))))]))
(fill-block! rot i 1 (fn [f] [(* f spin)]))))
{"swarm/orbit-pos" {:data pos :state nil}
"swarm/orbit-rot" {:data rot :state nil}}))
(defn- shape-blocks []
(let [pos (js/Float32Array. (* n-shapes frames 2))
rot (js/Float32Array. (* n-shapes frames 1))
scale (js/Float32Array. (* n-shapes frames 2))
;; The state mask: a handful of shapes wink out entirely for a stretch.
;; ABSENT, not hidden — this is the mask meaning "there is no value on
;; this frame", which is what an occluded subject will mean at step 6.
state (js/Uint8Array. (* n-shapes frames))]
(dotimes [i n-shapes]
(let [ph (/ (* TAU i) n-shapes)
ring (+ 18 (* 26 (js/Math.abs (js/Math.sin (* 2.3 ph)))))
wob (+ 0.03 (* 0.02 (mod i 5)))
spin (* (if (zero? (mod i 3)) -1 1) (+ 0.02 (* 0.011 (mod i 7))))
pulse (+ 0.05 (* 0.013 (mod i 6)))]
(fill-block! pos i 2
(fn [f]
;; Orbit position plus a small independent wobble, so no
;; two neighbours trace the same path.
(let [a (+ ph (* f 0.014 (if (even? i) 1 -1)))]
[(+ (* ring (js/Math.cos a)) (* 5 (js/Math.sin (* f wob))))
(+ (* ring (js/Math.sin a)) (* 5 (js/Math.cos (+ 1.1 (* f wob)))))])))
(fill-block! rot i 1 (fn [f] [(+ ph (* f spin))]))
(fill-block! scale i 2
(fn [f]
(let [s (+ 1.0 (* 0.45 (js/Math.sin (+ ph (* f pulse)))))]
[s s])))
;; Every eleventh shape is absent for a window that moves with i.
(when (zero? (mod i 11))
(let [from (mod (* i 9) frames)
to (min frames (+ from 34))]
(doseq [f (range from to)]
(aset state (+ (* i frames) f) ch/absent-bit))))))
{"swarm/pos" {:data pos :state state}
"swarm/rot" {:data rot :state nil}
"swarm/scale" {:data scale :state nil}}))
;; ---------------------------------------------------------------------------
;; the nodes
(defn- dense [store i stride]
{:animated? true :interp :hold
:dense {:store store :offset (* i frames stride) :stride stride :frames frames}
;; Provenance, which nothing in the renderer reads. Here it is honest about
;; where these numbers came from, the same way :roto/lips-outer will be.
:generated {:by :demo/swarm}
:over []})
(defn- orbit-node [i]
{:id (keyword (str "orbit-" i)) :kind :group :parent :root
:z (str "b" i)
:channels {[:xform :pos] (dense "swarm/orbit-pos" i 2)
[:xform :rot] (dense "swarm/orbit-rot" i 1)}})
(defn- shape-node [i]
(let [orbit (keyword (str "orbit-" (mod i n-orbits)))
tone (nth tones (mod i (count tones)))
kind (case (mod i 7) 5 :disc 6 :rect :poly)
verts (+ 3 (mod i 10))
size (+ 3.5 (* 0.9 (mod i 8)))
base {:id (keyword (str "s-" i)) :kind kind :parent orbit
;; Fractional index among siblings. Zero-padded so the strings
;; sort the way the numbers do — "c9" would otherwise land after
;; "c10", which is the classic way a z order goes subtly wrong.
:z (str "c" (.padStart (str i) 4 "0"))
:channels {[:xform :pos] (dense "swarm/pos" i 2)
[:xform :rot] (dense "swarm/rot" i 1)
[:xform :scale] (dense "swarm/scale" i 2)
[:style :color] (ch/framed tone)}}]
(update base :channels merge
(case kind
:poly {[:geom :pts] (ch/framed (regular-poly verts size (* 0.3 i)))}
:disc {[:geom :radius] (ch/framed (* 0.75 size))}
:rect {[:geom :size] (ch/framed (js/Math.round size))}))))
(def store
(delay (merge (orbit-blocks) (shape-blocks))))
(def clip
(delay
{:name "swarm"
:fps fps
:width 320
:height 200
:symbols
{:main
{:id :main
:frames frames
:nodes
(into {:root {:id :root :kind :group :parent nil :z "a1"
;; On 2s, like everything else. A hundred and twenty shapes
;; cutting on one grid reads as animation; the same shapes on
;; their own grids read as a screensaver, which is the whole
;; argument for exposure inheriting strictly.
:time {:mode :map :expose 2}}}
(concat (map (juxt :id identity) (map orbit-node (range n-orbits)))
(map (juxt :id identity) (map shape-node (range n-shapes)))))}}}))

View file

@ -0,0 +1,98 @@
(ns arthur.demo.take
"The synthetic take: the whole vertical slice, with no video file in it.
This is port-plan step 5's deliverable. `flow/take` now composes the shared
measurement path for both this generator and real footage —
synth ──▶ measure/anchor ──▶ condition/anchor
│ │
└──▶ mouth, eyes, brows ◀─┘
│
condition/parts
│
FREEZE ──▶ channels on nodes
│
symbol/resolver ──▶ raster
— and the order of that diagram is the whole argument for the stage split. The
anchor fit is knob-free. Conditioning smooths its four parameters. The rings are
then measured THROUGH the conditioned transform, so `anchor avg` does re-run the
ring mapping — a few hundred frames of twenty points, free — and does not re-run
anything that reads a source pixel, because that part takes the landmarks and
the frames and never the transform.
TWO CLIPS, ONE STORE. `:take` reads each measured head transform in time;
`:take-locked` holds the measured transform from frame zero. They share the
same dense blocks; only the head node's anchor map differs."
(:require [arthur.flow.address :as address]
[arthur.flow.freeze :as freeze]
[arthur.flow.take :as take]
[arthur.synth :as synth]))
(def frames 229)
(def fps 30)
(def ^:private stage
;; The project's dimensions, and NOT the footage's. This is what deleting
;; `makeXform` buys: the head is placed and scaled on the stage by a transform
;; on a node, so a 1440x1920 portrait clip and a 320x200 stage are not a
;; conflict to resolve. Whatever hangs off the edge is clipped.
[320 200])
(def ^:private aspect
;; The synth writes x and y in the SAME unit, so its normalised space is already
;; isotropic and the anisotropy correction is the identity here. Real footage
;; passes W/H from the manifest at step 6. Worth knowing while reading anything
;; here: aspect 1 is the one setting at which a port that dropped the
;; anisotropy correction entirely would still look right, which is why
;; anchor-test exercises 0.5625 and this does not.
1)
(def analysis
"Stage 2's output, synthesised. A seeded generator, so a wrong pose is
reproducible rather than something that happened once."
(delay (synth/synth-dense frames {:seed 1})))
(def measured
"Stages 3 and 4, in the order the stage split requires."
(delay (take/measure (assoc take/knobs :aspect aspect :fps fps)
{:dense @analysis})))
(def params
"What the freeze was handed. Public because it is the honest way to re-freeze at
other settings — a test that built its own copy would be asserting about a clip
nobody looks at."
(merge take/knobs
{:name "take"
:fps fps
:stage stage
;; On 2s. docs/design.md is emphatic that everything rides ONE grid: a
;; head cutting on odd frames against a mouth cutting on even ones reads
;; as two performances, so exposure lives on the clip root and inherits.
:expose 2
;; The head as filmed. `:take-locked` is the same freeze with this one
;; field changed, which is the point.
:head :free
;; Provenance, and now a content address. There is no detector here, so
;; the generator IS the detector and its seed is the source: two synth
;; takes at different seeds are different analyses, which is the same
;; statement content addressing makes about two model versions.
:analysis (address/analysis {:detector "synth"
:version "mulberry32"
:seed 1
:frames frames
:fps fps
:aspect aspect})}))
(def subject :face-1)
(def frozen
(delay (freeze/clip params {subject @measured})))
(def store (delay (:store @frozen)))
(def clip (delay (:clip @frozen)))
(def locked
"The same blocks, with `:head` held at measured frame zero."
(delay (freeze/head-mode {:trace {:origin :start}} @frozen)))

View file

@ -0,0 +1,151 @@
(ns arthur.domain.bring
"Bringing symbols into a clip from another: out of a saved project, or out of
a freeze of new footage.
Copied, never linked. What comes in gets ids of its own where they are taken,
and editing it here does not touch where it came from. Tracking identities and
the analysis they were measured by come along only when the receiving clip can
hold them; otherwise what comes in is drawing, which plays but does not re-tune.
Plain data in and out — documents, and in `placed` a document with its store —
so the events that fetch them are only fetching."
(:refer-clojure :exclude [take])
(:require [arthur.domain.clip :as clip]
[arthur.domain.feature :as feature]
[arthur.domain.node :as node]
[clojure.string :as string]))
(defn symbols
"Copy symbols `roots` of clip `other`, and every symbol they place, into
`clip`. Returns `{:clip :ids}`, where `:ids` maps each copied symbol's id in
`other` to its id here.
AN ID THAT IS TAKEN IS RENAMED, never merged: two symbols that happen to share
an id are two drawings, and a cel's `:source :symbol` inside the copy is
rewritten to follow. `wanted` maps a root's id in `other` to the id it should preferably get,
which is how a symbol made from footage is called what the person typed rather
than `:main`.
Only symbols travel. What else `other` holds — tracking identities, an analysis
— is the caller's decision, because whether it can come too depends on what
`clip` already has."
[clip other roots wanted]
(let [;; A tree walk is safe because placing cannot make a cycle.
reach (into #{} (mapcat #(tree-seq any? (partial clip/places other) %)) roots)
ids (reduce (fn [ids sid]
(let [taken? #(or (contains? (:symbols clip) %)
(some #{%} (vals ids)))]
(assoc ids sid (clip/free-id taken? (get wanted sid sid)))))
{} (sort-by str reach))
;; Cel identity and timing stay put; content references follow
;; the symbol IDs assigned in the destination document.
repoint (fn [n ids]
(if (node/source n)
(update-in n [:source :symbol] ids)
n))
copy (fn [sid]
(-> (clip/symbol other sid)
(assoc :id (ids sid) :fps (or (:fps (clip/symbol other sid)) (:fps other)))
(update :nodes #(into {} (map (fn [[id n]] [id (repoint n ids)])) %))))]
{:clip (reduce (fn [c sid] (assoc-in c [:symbols (ids sid)] (copy sid))) clip reach)
:ids ids}))
(defn symbol-id
"An id for a symbol a person has named: the name, lower-cased and hyphenated,
or `:symbol` when nothing of it survives."
[label]
(let [slug (-> (str label) string/lower-case
(string/replace #"[^a-z0-9]+" "-")
(string/replace #"^-+|-+$" ""))]
(keyword (if (seq slug) slug "symbol"))))
(defn take
"Put `frozen`, a take, into `clip` as ONE symbol called `label`. Returns the
changed clip, the imported symbol id and the old-to-scoped subject ids.
`frozen` is what `flow/freeze/clip` makes: a `:main` that places one symbol per
tracked face. `:main` becomes the named symbol — it is what holds the faces in
stage pixels, so it is the thing worth placing. Each generated face symbol gets
an automatic association with the take's SOUND, source frames `range` of
footage `footage-id`. Thus the take is heard through the faces it places, and
a face subsequently placed by itself still brings its sound. This is the same
shape a future manual symbol/sound association can write; dropping a face is
not a special operation.
A multi-face take consequently reaches the same recording through several
symbols. `nest/audio-tracks` collapses simultaneous copies carrying the same
`:media-link`; placing those faces at different times still schedules each one.
The unique imported symbol id scopes every subject id. Thus two takes may both
arrive with detector subject `:face-1` without colliding in the document."
[clip frozen label footage-id range]
(let [scope (fn [root subject] (keyword (str (name root) "." (name subject))))
root (clip/free-id
(fn [candidate]
(or (contains? (:symbols clip) candidate)
(some #(contains? (:symbols clip) (scope candidate %))
(keys (:subjects frozen)))))
(symbol-id label))
scoped (partial scope root)
wanted (into {:main root :footage (scoped :footage)}
(map (fn [subject] [subject (scoped subject)]))
(keys (:subjects frozen)))
{c :clip ids :ids} (symbols clip frozen [:main] wanted)
sid (ids :main)
faces (mapv ids (sort-by str (keys (:subjects frozen))))
sound {:id :sound :name "sound" :kind :audio :parent nil
:z "z-sound" :source {:footage footage-id}
;; All automatic copies name one recording. The audio walk
;; uses this identity only to avoid mixing that recording once
;; per detected face when the complete take is played.
:media-link [:footage footage-id range]
;; Source frame `start` plays on the symbol's 0.
:span range
:time {:mode :map :at (- (first range)) :rate 1}}
feature-ids (into {}
(map (fn [[id f]]
[id (feature/owned (ids (:subject f)) (keyword (name id)))]))
(:features frozen))
subjects (into {}
(map (fn [[id subject]]
(let [new-id (ids id)]
[new-id (assoc subject :id new-id
:footage footage-id)])))
(:subjects frozen))
features (into {}
(map (fn [[id f]]
(let [new-id (feature-ids id)]
[new-id (-> f
(assoc :id new-id
:subject (ids (:subject f))
:symbol (ids (:symbol f))))])))
(:features frozen))
groups (into {}
(map (fn [[id g]]
(let [new-subject (ids (:subject g))
new-id (feature/owned new-subject (keyword (name id)))]
[new-id (-> g
(assoc :id new-id :subject new-subject)
(update :members #(mapv feature-ids %)))])))
(:groups frozen))]
{:sid sid
:subject-ids (select-keys ids (keys (:subjects frozen)))
:clip (-> (reduce (fn [document face]
(assoc-in document [:symbols face :nodes :sound] sound))
c faces)
(assoc-in [:symbols sid :name] (str label))
(update :analyses merge (:analyses frozen))
(update :subjects merge subjects)
(update :features merge features)
(update :groups merge groups))}))
(defn placed
"`entry` — a document and its store — once `brought` holds the symbols brought
in and `store` their blocks: the stores merged and an instance of `sid` placed
in `host` at `frame`, its middle on stage pixel `point` or where it was drawn
when there is none. See `clip/place-symbol`."
[entry brought store sid host frame uuid point]
(let [st (merge (:store entry) store)]
(assoc entry :store st :clip (clip/place-symbol brought st host sid
(* frame (:rate (clip/grid-time brought host))) uuid point))))

View file

@ -0,0 +1,25 @@
(ns arthur.domain.cadence
"Select integer content frames across frame grids. Stored frames never change.")
(defn ratio [grid native]
(if (and grid native (pos? grid) (pos? native)) (/ native grid) 1))
(defn frame
"Latest native frame at or before reader frame f. Never sample the future."
[f grid native]
(js/Math.floor (if (and grid native) (/ (* f native) grid) f)))
(defn frames
"Reader frames covering a native length, including a partial final frame."
[n grid native]
(when n (max 1 (js/Math.ceil (/ n (ratio grid native))))))
(defn reader-frame
"The earliest reader frame whose `frame` is at or after native frame n — the
inverse of `frame`, as far as it has one.
`frame` is a floor, so several reader frames can show one native frame and a
native frame between two of them is shown by none: this answers with the
reader frame that first reaches it, which is what seeking to a mark means."
[n grid native]
(js/Math.ceil (if (and grid native) (/ (* n grid) native) n)))

View file

@ -0,0 +1,77 @@
(ns arthur.domain.canon
"One canonical text for a map, so that hashing it means something.
A content address is a hash of a DESCRIPTION of every input, and a description
only addresses anything if the same inputs always write the same bytes. A CLJS
map has no key order, `pr-str` will happily print `{:a 1 :b 2}` in either order
between runs, and JSON has no canonical form of its own. So this is the one
place that decides.
The text is VALID JSON, deliberately. The server stores it beside the key and
verifies `sha256(descriptor) == key` (clips/views.py), and it also has to read
two fields out of it to enforce that a detector version was declared at all.
Hashing the text the client sent, rather than recomputing it from parsed
values, is what keeps that check free of a cross-language float-formatting
agreement nobody could hold: Python writes `1.0` where JS writes `1`, and a
scheme where both sides re-render the numbers would break on the first integral
double. The bytes are the contract; the schema on top of them is a convention.
It is also meant to be READ. A stale bake presents as a picture that will not
update, and the descriptor is the only thing that can say which input moved, so
it is short, flat where it can be, and never has a 229-frame mask inlined —
see `arthur.flow.address`, which digests masks before they reach here.
Three refusals, all of them cases where a canonical text is not possible or
the key would be ambiguous:
A KEYWORD VALUE. Keys are keywords and become their names, because a key is
a name and nothing else. A keyword VALUE is refused instead of being named,
because then `:mouth` and \"mouth\" would hash alike, and the server would be
reading a field whose type depended on the caller's mood. Callers convert at
the boundary, which is also what makes the stored JSON clean.
A SET. Unordered, so there is no one text for it. Sort it into a vector at
the call site, where it is obvious which order was meant.
NaN OR INFINITY. Neither is JSON, and both mean a measurement went wrong
upstream of here — silently addressing it would cache the mistake."
(:require [clojure.string :as str]))
(defn- number->text [x]
(when-not (js/Number.isFinite x)
(throw (ex-info "a descriptor cannot hold NaN or infinity" {:value x})))
;; `(str 1.0)` is "1" and `(str 0.12)` is "0.12": JS prints the shortest decimal
;; that round-trips, so this is stable without a format string.
(str x))
(defn- key->text [k]
(cond
(keyword? k) (subs (str k) 1) ; :a -> "a", :roto/b -> "roto/b"
(string? k) k
:else (throw (ex-info "a descriptor key is a keyword or a string"
{:key k :type (type k)}))))
(declare write)
(defn- write-map [m]
(str "{"
(str/join "," (map (fn [[k v]] (str (js/JSON.stringify (key->text k)) ":" (write v)))
(sort-by (comp key->text key) (seq m))))
"}"))
(defn write
"The canonical JSON text of a descriptor value."
[v]
(cond
(nil? v) "null"
(true? v) "true"
(false? v) "false"
(number? v) (number->text v)
(string? v) (js/JSON.stringify v)
(map? v) (write-map v)
(set? v) (throw (ex-info "a descriptor cannot hold a set: sort it into a vector where the order is visible"
{:value v}))
(keyword? v) (throw (ex-info "a descriptor cannot hold a keyword VALUE: name it at the call site, so \"mouth\" and :mouth cannot address the same block"
{:value v}))
(sequential? v) (str "[" (str/join "," (map write v)) "]")
:else (throw (ex-info "not a descriptor value" {:value v :type (type v)}))))

View file

@ -0,0 +1,643 @@
(ns arthur.domain.channel
"A channel is one animatable property, sampled at a frame.
Three shapes, and the uniformity across them is the entire point of the model
— analysis does not produce a different kind of data, it produces keys densely
on the same channels a hand fills in sparsely:
FRAMED {:animated? false :value v}
A thing that simply exists. A painted background cel is this.
KEYED {:animated? true :interp :hold :keys {0 v, 4 v, 12 v}}
Sparse, authored, in the document. Undoable and syncable.
DENSE {:animated? true :interp :hold
:dense {:store \"sha256:…\" :offset 0 :stride 40 :frames 600}
:generated {...}}
Generated, one value per frame, in a typed array outside app-db.
`value-at` reads all three and is the specification. `cursor`/`sample!` is the
fast path for playback and must agree with it exactly; scene-test asserts that
across forward, backward and random frame order, because a cursor that drifts
is a bug you would see as the wrong pose rather than as an error.
KEYS ARE A MAP BY FRAME, NEVER A VECTOR, and the map stored in the document is
a PLAIN map — transit and JSON both lose sortedness, so the sorted index is
built here at read time and never persisted.
:generated is provenance. NOTHING IN HERE READS IT, and nothing downstream may:
it exists so the UI can offer a parameter panel instead of raw keys. It lives
on the channel rather than the node because a mouth wants a rotoscoped
[:geom :pts] and a hand-animated [:xform :pos] at the same time, and putting
the flag on the node would forbid the most useful thing in the model.")
;; ---------------------------------------------------------------------------
;; the state mask
;;
;; PRESENCE IS NOT VISIBILITY, and the distinction is free now and expensive to
;; retrofit. A part that is hidden EXISTS and is not drawn, which is `[:vis]`, a
;; channel like any other. A subject that is occluded has NO VALUE on that frame
;; — there is nothing to hide and nothing to fall back on — and that is this.
;;
;; The mask carries ABSENCE ONLY. An earlier draft gave it a hidden bit as well,
;; per docs/architecture.md's "hidden flag + palette index", and that bit was
;; simply a dense `[:vis]` wearing a different hat: two mechanisms for one
;; question, which is how you end up with a part that is hidden by one and shown
;; by the other. Hiding is a channel; absence is a state. Remaining bits are
;; reserved.
(def ^:const present 0)
(def ^:const absent-bit 1)
(def absent
"Sampled value for a frame the subject was not on.
Distinct from a part being switched off, which is `[:vis]` being false, and
distinct from a part having no keys. The identity tracker, when it arrives,
needs somewhere to say \"not on screen\" without inventing a pose."
::absent)
(defn nothing?
"True when there is no value to draw with."
[v]
(identical? v absent))
;; ---------------------------------------------------------------------------
;; constructors, for hand-written scenes and tests
(defn framed [v] {:animated? false :value v})
(defn keyed
"A channel of keys, and how each one leads to the next. `interp` is an
argument, never a default: `:hold` and `:linear` are the difference between a
cut and a tween, which is the whole content of the channel."
[ks interp]
{:animated? true :interp interp :keys ks :over []})
;; ---------------------------------------------------------------------------
(defn component
"Component i of a multi-component channel value.
An authored value is a CLJS vector; a value read out of a dense block is a
typed-array view over the block, because copying it would allocate per node
per frame. Both have to read the same way here or every consumer downstream
grows the same two-way branch."
[v i]
(if (vector? v) (-nth v i) (aget v i)))
(defn frames
"Sorted vector of the frames a keyed channel has keys on, or nil. Built here
and cached by `cursor`; `value-at` rebuilds it, which is why `value-at` is the
specification and not the playback path."
[ch]
(when-let [ks (:keys ch)]
(vec (sort (keys ks)))))
;; ---------------------------------------------------------------------------
;; correction layers
;;
;; `:over` is an ORDERED STACK on top of whatever the channel already says.
;; Generated motion stays the base; a hand correction is a layer above it, so
;; regenerating replaces the base and the corrections survive. That is the whole
;; reason the stack exists rather than the hand edit being written into the keys.
;;
;; A LAYER'S VALUES ARE A CHANNEL. A constant adjustment is a framed one, a ramp
;; or a return motion is a keyed one, and neither needs a second way of saying
;; what a value is over time: layers read through `value-at` and `cursor` like
;; anything else, which is also what stops the fast path and the specification
;; from being two implementations of blending.
;;
;; A LAYER HAS NO TIME SPACE OF ITS OWN. Its `:support` and its values' keys are
;; in the frames the base channel's keys are in — the node's own. A correction on
;; a lane is therefore in lane frames and crosses the drawing boundaries under
;; it; a correction on one cel is in that cel's frames and travels
;; with it when it moves. Ownership already answered the question, so there is no
;; field to get wrong.
(defn layer
"One correction: `values` applied to the base wherever `support` covers the
frame. `op` is `:offset` or `:replace`."
[id support op values]
{:id id :support support :op op :values values})
(defn- covers?
"Half-open, as a span is: a correction over frames 10 to 12 is `[10 13)`."
[[in out] f]
(and (<= in f) (< f out)))
(defn- width
"Components in a value, or nil for a number. A dense value is a typed-array
view, an authored one a vector, and a correction has to add to either."
[v]
(cond (number? v) nil (vector? v) (count v) :else (.-length v)))
(defn- shape
"What kind of value this is, for asking whether one can be added to another:
`:scalar`, a component count, or `:opaque` for a value that is neither — a
`[:vis]` boolean is opaque, and can be replaced but not offset."
[v]
(cond
(number? v) :scalar
(vector? v) (count v)
(and (some? v) (number? (.-length v))) (.-length v)
:else :opaque))
(defn value-shape
"The shape of the values a channel yields, without sampling it, or nil where
there is nothing to read it off — an empty key map says nothing about what its
values would have been, and nil must not be taken for a scalar."
[ch]
(cond
(not (:animated? ch)) (when (some? (:value ch)) (shape (:value ch)))
(:dense ch) (if (= 1 (:stride (:dense ch))) :scalar (:stride (:dense ch)))
(seq (:keys ch)) (shape (val (first (:keys ch))))
:else nil))
(defn- shape-conflict [base-shape correction-shape]
(cond
(or (nil? base-shape) (nil? correction-shape)) nil
(= :opaque base-shape) "the base is not a number or a row of components"
(= :opaque correction-shape) "the correction is not a number or a row of components"
(not= base-shape correction-shape)
(str "the base has " (pr-str base-shape) " and the correction "
(pr-str correction-shape) " — a correction cannot offset a value of"
" a different shape")))
(defn conflict-with
"Why correction `l` cannot apply to base channel `base`, or nil.
ONLY `:offset` can conflict. It adds component by component, so it needs the
base to have the components it has — which is what a topology change takes
away when a re-freeze gives a mouth a different number of points. `:replace`
states a whole value and so has nothing to agree with.
Shapes that cannot be read yet do not conflict: an empty key map is not a
disagreement, it is a channel with nothing in it."
[base l]
(when (= :offset (:op l))
(shape-conflict (value-shape base) (value-shape (:values l)))))
(defn- support-of [l]
(let [s (:support l)]
(when (and (vector? s) (= 2 (count s))
(every? number? s) (< (first s) (second s)))
s)))
(defn stack-conflict
"Why layer `i` can encounter a value of the wrong shape after the active
layers before it, or nil.
Replacement coverage is considered at every interval boundary. This matters
when adjacent replacements jointly cover an offset: neither covers its whole
support, but the base can never reach it. A conflicted replacement is skipped,
exactly as the evaluator skips it."
[ch i]
(let [l (nth (:over ch) i nil)]
(when (and (= :offset (:op l)) (support-of l))
(let [[a b] (support-of l)
prior (take i (:over ch))
cuts (->> prior
(keep support-of)
(mapcat identity)
(filter #(< a % b))
(into [a b])
distinct sort)
;; Shape at a point is the last active, nonempty replacement's
;; shape, or the base shape when no replacement supplies a value.
at (fn [f]
(or (last (keep (fn [p]
(let [s (support-of p)
v (value-shape (:values p))]
(when (and (= :replace (:op p))
(not (:conflict p)) v s
(covers? s f))
v)))
prior))
(value-shape ch)))
shapes (into #{} (map (fn [[x y]] (at (/ (+ x y) 2))))
(partition 2 1 cuts))
v (value-shape (:values l))]
(some #(shape-conflict % v) shapes)))))
(defn reconcile
"Recheck an ordered layer stack against this channel's base.
Old conflict marks are findings from an earlier base, so they are cleared and
recomputed in order. A newly conflicted replacement is then invisible to the
layers after it, matching evaluation. Nothing is dropped or reordered."
[ch]
(let [layers (mapv #(dissoc % :conflict) (:over ch))]
(reduce (fn [out l]
(let [candidate (assoc ch :over (conj out l))
why (stack-conflict candidate (count out))]
(conj out (cond-> l why (assoc :conflict why)))))
[] layers)))
(defn conflicts
"Corrections on `ch` that cannot apply to its base, as `[{:id :why}]`.
NOT `problems`. A conflict is a legitimate state for a document to be in: a
regeneration changed the topology under a correction that was right when it was
made, and resolving it is a person's decision, not a reason the document will
not load. `flow/regenerate` records one on the layer, a conflicted layer is not
applied, and this is how a view finds them to offer."
[ch]
(vec (for [[i l] (map-indexed vector (:over ch))
:let [why (or (:conflict l) (stack-conflict ch i))]
:when why]
{:id (:id l) :why why})))
(defn- offset-onto
"`base` plus `v`, component-wise. A vector, never a write into `base`, which
for a dense channel is a view onto the block itself."
[base v ch]
(let [wb (width base) wv (width v)]
(cond
(and (nil? wb) (nil? wv)) (+ base v)
(and wb wv (= wb wv))
(mapv (fn [i] (+ (component base i) (component v i))) (range wb))
:else
(throw (ex-info "a correction cannot offset a value of a different shape"
{:base wb :correction wv :channel (dissoc ch :dense)})))))
(defn- eye-opening-onto [points amount]
(let [n (width points)
ys (map #(component points %) (range 1 n 2))
center (/ (+ (reduce min ys) (reduce max ys)) 2)]
(mapv (fn [i] (let [v (component points i)]
(if (odd? i) (+ center (* amount (- v center))) v)))
(range n))))
(defn- over-at
"Fold `ch`'s layers onto `base` at frame f. `read` samples one layer's values
and is the only thing that differs between the specification and the cursor."
[ch f base read]
(reduce-kv
(fn [v i {:keys [support op values conflict]}]
;; A conflicted correction is neither applied nor forgotten: it stays in
;; the document, `conflicts` reports it, and a person decides. Applying it
;; would misapply it; removing it would throw away hand work.
(if (or conflict (not (covers? support f)))
v
(let [x (read i values f)]
(cond
(nothing? x) v
(= :replace op) x
;; `replace` can supply a value over an absent base; `offset` has
;; nothing to add to and says so rather than inventing a pose.
(nothing? v) absent
(= :eye-opening op) (eye-opening-onto v x)
:else (offset-onto v x ch)))))
base
(vec (:over ch))))
;; ---------------------------------------------------------------------------
;; dense blocks
(defn- absent-at?
"Is the subject absent on frame f of this block's slice?
INDEXED THE WAY THE DATA IS. A block is node-major — offset(node i) =
i·frames·stride — so a block holding several nodes holds several mask regions,
and the one belonging to this channel starts at offset/stride. Indexing the
mask by f alone reads the FIRST node's absence for every node in the block,
which is not a subtly wrong pose: it is every part in the block vanishing on
the frames where one of them was occluded."
[state offset stride f]
(and (some? state)
(pos? (bit-and (aget state (+ (quot offset stride) f)) absent-bit))))
(defn dense-at
"Read frame f out of a dense block.
`store` is {store-key -> {:data <typed array> :state <Uint8Array or nil>}},
tier 2, behind a handle and never in app-db.
The frame is CLAMPED into the block. A time map with an offset deliberately
reads the future — mouth lead is the whole reason `:offset` exists — so the
last frame of a leading track is asked for a frame past the end on every one
of the last `lead` frames. Clamping there is what the JS `shiftIndex` does and
it is the right answer: the track holds its final pose. Returning nothing
instead would blank the mouth at the end of every take.
stride 1 yields a number; anything wider yields a SUBARRAY VIEW over the
block, not a copy. Fixed topology is what makes that possible — the frame's
data is a rectangular slice at a known offset with no per-frame header.
FIXED POINT. `:scale` in the block header means the stored integers are the
value times that scale, so a block of geometry in image-height units fills an
Int16 usefully and a block of stage pixels — which wants a different scale
entirely — fills one too. It is in the header rather than agreed by convention
for exactly that reason, and it is why the block in memory is byte for byte the
block on the wire: a handle that names a sha256 has to name the bytes you
actually hold.
Decoding costs the view. `out` is a stride-sized destination the caller owns —
`cursor` allocates one per channel — because a copy per node per frame is the
allocation this whole model is arranged to avoid; passing nil allocates, which
is what `value-at`, the specification, does — and it says so by passing nil,
because there is no arity here that decides it for a caller."
[{:keys [store offset stride scale] nf :frames} f st out]
(let [{:keys [data state]} (get st store)]
(when (nil? data)
(throw (ex-info "dense channel's store key is not in the store"
{:store store :have (vec (sort (map str (keys st))))})))
(let [f (-> f (max 0) (min (dec nf)))]
(if (absent-at? state offset stride f)
absent
(let [o (+ offset (* f stride))]
(cond
(= 1 stride) (let [v (aget data o)] (if scale (/ v scale) v))
(nil? scale) (.subarray data o (+ o stride))
:else (let [dst (or out (js/Float64Array. stride))]
(dotimes [k stride]
(aset dst k (/ (aget data (+ o k)) scale)))
dst)))))))
;; ---------------------------------------------------------------------------
;; the specification
(defn segment-interp
"How the key at `left` leads to the next key. A channel default remains useful
for uniform tracks; :segments overrides only the gaps an artist chose."
[ch left]
(get (:segments ch) left (:interp ch)))
(defn- interpolate [ch f left right]
(let [a (get (:keys ch) left)]
(if (and (= :linear (segment-interp ch left)) right (>= f left) (> right left))
(let [b (get (:keys ch) right)
t (/ (- f left) (- right left))]
(cond
;; Palette-choice channels interpolate identities into a blend
;; descriptor. The renderer keeps indexed geometry in the left
;; palette's bank and blends that bank's ramp toward the right one.
;; Palette ids are opaque identities. Built-ins happen to use
;; keywords, while palettes made in the editor use UUIDs; treating
;; the latter as numbers produces NaN and therefore the renderer's
;; pink bad-data sentinel.
(and (= :palette (:semantic ch)) (some? a) (some? b))
{:from a :to b :t t}
(vector? a)
(mapv (fn [x y] (+ x (* t (- y x)))) a b)
:else (+ a (* t (- b a)))))
a)))
(defn- keyed-at
"The most recent key at or before f, CLAMPED to the first key below it.
Hold is the default and clamping at the low end is the JS `activeKey`'s
behaviour, kept: a channel's first key is the pose the part starts in, so a
frame before it reads that pose rather than having no value. This is not the
same question as presence — a part with no value at all is `absent`, which is
a state bit, not an empty key map."
[ch f]
(let [fr (sort (keys (:keys ch)))
left (or (last (take-while #(<= % f) fr)) (first fr))
right (first (drop-while #(<= % f) fr))]
(interpolate ch f left right)))
(defn repair-frame
"Map a damaged frame to a donor in the current base. Latest interval wins;
donors are sampled directly, never recursively through other repairs."
[ch f]
(reduce (fn [frame {:keys [from through donor]}]
(if (<= from f through) donor frame))
f (:repairs ch)))
(defn value-at
"Sample a channel at frame f. THE SPECIFICATION — correct, allocating, and
O(n) in the keys. `cursor`/`sample!` is what playback uses.
`store` IS AN ARGUMENT, NEVER A DEFAULT. A dense channel cannot be read
without the tier-2 store it names, and an arity that filled in nil let a
caller omit it, read correctly for every channel that happened not to be
dense, and throw the first time a selection landed on one that was. That is
how `gesture/values` took the stage down on an iris. A caller with no store
says `nil` and means it."
([ch f store] (value-at ch f f store))
([ch base-f correction-f store]
(let [base-f (repair-frame ch base-f)
base (cond
(not (:animated? ch)) (:value ch)
(:dense ch) (dense-at (:dense ch) base-f store nil)
(:keys ch) (let [ks (:keys ch)]
(if (empty? ks) absent (keyed-at ch base-f)))
:else
(throw (ex-info "animated channel has neither :keys nor :dense"
{:channel ch})))]
(if (seq (:over ch))
(over-at ch correction-f base
(fn [_ values f] (value-at values f store)))
base))))
;; ---------------------------------------------------------------------------
;; the playback path
;;
;; Playback is SEQUENTIAL, so "the most recent key at or before f" is an advance
;; of a saved index rather than a search. The difference at 30fps is a `sort` and
;; a `take-while` allocation per channel per frame against none, which is the
;; difference between the model being usable and being a demo.
(defn- bsearch
"Largest index i with ks[i] <= f, or 0 when f precedes every key (hold clamps
low, see keyed-at)."
[ks f]
(loop [lo 0, hi (dec (count ks)), best 0]
(if (> lo hi)
best
(let [mid (bit-shift-right (+ lo hi) 1)]
(if (<= (nth ks mid) f)
(recur (inc mid) hi mid)
(recur lo (dec mid) best))))))
(deftype Cursor [ch ks store buf overs ^:mutable i]
Object
(toString [_] (str "#Cursor{" (pr-str (if ks :keyed (if (:dense ch) :dense :framed))) " i=" i "}")))
(defn cursor
"A reading head on one channel. Build once per channel per resolver, then
`sample!` it per frame. Holds the sorted key index, which is why the index is
built here and not in the document — and the decode buffer a fixed-point block
needs, for the same reason the resolver owns one point buffer per node.
Only a wide fixed-point block gets a buffer: a stride-1 block decodes to a
number and a block with no `:scale` is handed back as a view.
A correction layer gets a reading head of its own, because its values are a
channel and this is how a channel is read fast. One level deep: a layer's
values may not themselves carry layers, which `problems` refuses.
`store` is an argument for the reason it is one on `value-at`."
[ch store]
(let [d (:dense ch)]
(->Cursor ch
(when (and (:animated? ch) (not d) (seq (:keys ch))) (frames ch))
store
(when (and d (:scale d) (> (:stride d) 1))
(js/Float64Array. (:stride d)))
(mapv #(cursor (:values %) store) (:over ch))
0)))
(defn- base-sample!
"What the cursor's channel says at f BEFORE its corrections. Advancing the
reading head is this function's whole job, and it is separate from blending so
that a layer cannot accidentally be read through the base's index."
[^Cursor cur ch ks f]
(cond
(not (:animated? ch)) (:value ch)
(:dense ch) (dense-at (:dense ch) f (.-store cur) (.-buf cur))
(nil? ks) absent ; animated with an empty key map
:else
(let [n (count ks)
i (.-i cur)
last (dec n)
i' (cond
;; still inside the key the cursor sits on
(and (<= (nth ks i) f)
(or (= i last) (> (nth ks (inc i)) f)))
i
;; the next one — one frame of playback crossed one key
(and (< i last)
(<= (nth ks (inc i)) f)
(or (= (inc i) last) (> (nth ks (+ i 2)) f)))
(inc i)
:else (bsearch ks f))]
(set! (.-i cur) i')
(interpolate ch f (nth ks i') (when (< i' last) (nth ks (inc i')))))))
(defn sample!
"Value of the cursor's channel at f. O(1) when f is at or one key past where
the cursor already sits — the playback case — and O(log n) otherwise, which is
a seek. Advancing and seeking are deliberately different costs: a scrub can
afford a binary search and a frame cannot.
A correction layer is sampled through its OWN cursor, so a stacked channel is
still one reading head per key map and `value-at` stays the specification for
the blending as well as for the base."
([cur f] (sample! cur f f))
([^Cursor cur base-f correction-f]
(let [ch (.-ch cur)
base (base-sample! cur ch (.-ks cur) (repair-frame ch base-f))]
(if (seq (:over ch))
(over-at ch correction-f base
(fn [i _ f] (sample! (nth (.-overs cur) i) f)))
base))))
;; ---------------------------------------------------------------------------
(defn describe
"Which of the three shapes, for error messages and the parameter panel."
[ch]
(cond
(not (:animated? ch)) :framed
(:dense ch) :dense
:else :keyed))
(defn problems
"Human-readable reasons this map is not a channel. Empty means it is one."
[ch]
(let [values (when (map? (:keys ch)) (vals (:keys ch)))
first-value (first values)
linear-values? (or (every? number? values)
(and (= :palette (:semantic ch)) (every? some? values))
(and (vector? first-value)
(pos? (count first-value))
(every? (fn [v] (and (vector? v)
(= (count v) (count first-value))
(every? number? v)))
values)))
linear? (or (= :linear (:interp ch))
(some #{:linear} (vals (:segments ch))))]
(cond-> []
(and (contains? ch :repairs)
(not (and (vector? (:repairs ch))
(every? (fn [{:keys [id from through donor]}]
(and id (every? integer? [from through donor])
(<= 0 from through) (<= 0 donor)))
(:repairs ch)))))
(conj "repairs require an ID and nonnegative whole donor and interval frames")
(not (map? ch))
(conj "not a map")
(and (map? ch) (not (contains? ch :animated?)))
(conj ":animated? is required — the flag is what makes framed and keyed one type")
(and (map? ch) (:animated? ch) (not (or (:keys ch) (:dense ch))))
(conj "animated but has neither :keys nor :dense")
(and (map? ch) (:animated? ch) (:keys ch) (:dense ch))
(conj "has both :keys and :dense; a channel is one shape at a time")
(and (map? ch) (:keys ch) (not (map? (:keys ch))))
(conj (str ":keys is a " (if (vector? (:keys ch)) "vector" "non-map")
" — keys are a MAP by frame, so a merge can be per-key"))
(and (map? ch) (:keys ch) (map? (:keys ch)) (not (every? number? (keys (:keys ch)))))
(conj ":keys has a non-numeric frame")
(and (map? ch) (:animated? ch) (not (#{:hold :linear nil} (:interp ch))))
(conj (str ":interp " (:interp ch) " is not implemented"))
(and (map? ch) (contains? ch :segments)
(or (not (map? (:segments ch)))
(not (:keys ch))
(not (every? (set (keys (:keys ch))) (keys (:segments ch))))
(not (every? #{:hold :linear} (vals (:segments ch))))))
(conj ":segments must map existing key frames to :hold or :linear")
(and (map? ch) linear?
(or (:dense ch) (not linear-values?)))
(conj ":linear interpolation needs numeric keys of one shape")
(and (map? ch) (contains? ch :over) (not (vector? (:over ch))))
(conj ":over is an ORDERED stack, so it is a vector")
(and (map? ch) (vector? (:over ch)))
(into (for [{:keys [id support op values]} (:over ch)
p (cond-> []
(nil? id)
(conj "needs an :id — a correction has an identity a regeneration can keep")
(not (and (vector? support) (= 2 (count support))
(every? #(and (number? %) (js/Number.isFinite %)) support)
(< (first support) (second support))))
(conj (str ":support " (pr-str support)
" must be a finite, increasing [in out)"))
(not (#{:offset :replace :eye-opening} op))
(conj (str ":op " (pr-str op) " is not :offset, :replace or :eye-opening"))
;; One level. A layer over a layer is an ordering mechanism
;; the stack already is, and it would make the read
;; unbounded in depth for nothing.
(seq (:over values))
(conj "a layer's values cannot carry layers of their own")
(seq (problems (dissoc values :over)))
(conj (str "values are not a channel: "
(first (problems (dissoc values :over))))))]
(str "correction " (pr-str id) " " p)))
;; A shape mismatch NOBODY HAS RECORDED is an authoring bug; one a
;; regeneration recorded is a conflict awaiting a person, and `conflicts`
;; reports those. The distinction is what keeps a topology change from
;; making a document that will not load.
(and (map? ch) (vector? (:over ch)))
(into (for [[i l] (map-indexed vector (:over ch))
:when (not (:conflict l))
:let [why (stack-conflict ch i)]
:when why]
(str "correction " (pr-str (:id l)) " " why)))
;; A scale of zero divides every value in the block by zero, and a negative
;; one mirrors the geometry. Both are authored-data bugs that present as a
;; part drawn nowhere or inside out, not as an error.
(and (map? ch) (:dense ch) (contains? (:dense ch) :scale)
(not (and (number? (:scale (:dense ch))) (pos? (:scale (:dense ch))))))
(conj (str ":dense :scale is " (pr-str (:scale (:dense ch)))
" — a fixed-point scale is a positive number the stored integers"
" were multiplied by")))))

View file

@ -0,0 +1,820 @@
(ns arthur.domain.clip
"A CLIP: the unit of work, and a library of symbols.
{:name \"take\"
:fps 30
:width 320 :height 200
:analyses {analysis-id {...}}
:subjects {...} :features {...} :groups {...}
:symbols {:main {:id :main :frames 229 :nodes {...}}}}
Every field here is a fact about the clip and NOT about a bag of nodes, which is
the cut this namespace exists to make. Before it, one map carried both: `:fps`,
the stage dimensions, the analysis record and the tracking identities sat beside
`:nodes`. The cost of leaving them together was not untidiness. It was that a
SYMBOL had nowhere to live: a symbol is a bag of nodes with a frame space and
nothing else, so under the old shape it would have had to be a clip with seven
meaningless fields.
Now there is one node-holding type — `arthur.domain.symbol` — and a clip holds
a MAP of them. A `:kind :instance` node places one symbol inside another, and
the clip resolver gives each instance its own reading heads.
WHAT IS NOT HERE: how nested symbols' frames and coordinates relate, and
moving nodes between them, are `arthur.domain.nest`; bringing symbols in from
another clip is `arthur.domain.bring`. This namespace is the document and the
operations that only need the document.
NO SYMBOL IS SPECIAL. There is no reserved root id: which symbol is on screen
is the editor's state, not the document's, and every function here that needs
a symbol is told which. A new document has one symbol called `:main` because
it has to be called something, and that is all the name means — it can be
renamed or placed inside another symbol like any of them. What the document
does say is `:root`, which symbol it opens on: a pointer, not a kind of
symbol, the way a Flash file names its scene. See `opens-on` for why that
cannot be worked out instead.
:fps is the output grid. A symbol's optional :fps names the native grid its
frames were authored or measured on; absent means the document's grid."
(:refer-clojure :exclude [symbol])
(:require [arthur.domain.cadence :as cadence]
[arthur.domain.channel :as ch]
[arthur.domain.feature :as feature]
[arthur.domain.node :as node]
[arthur.domain.palette :as pal]
[arthur.domain.pose :as pose]
[arthur.domain.symbol :as symbol]))
(def clip-keys
"Every top-level field of a clip, and the reason `arthur.domain.leaf` refuses
one it does not know: a field added to the clip without a leaf to save it in is
a field that saves silently and comes back missing. The failure is a document
that loses something on every round trip, which is the one bug a persistence
layer must not be able to have. Add the field here and to `leaf/leaves` and
`leaf/clip` in the same commit."
#{:name :fps :analyses :subjects :features :groups :width :height :symbols
:palettes :default-palette :root})
(defn symbol
"One of the clip's symbols, by id."
[clip sid]
(get-in clip [:symbols sid]))
(defn trace?
"Is `sym` a tracing symbol: footage or a still to draw over, which is placed and
moved like any symbol and never drawn into the picture? See `trace-op`."
[sym]
(= :trace (:type sym)))
(defn symbol-name
"What to call a symbol: its `:name`, or its id when it has none."
[clip sid]
(or (:name (symbol clip sid)) (name sid)))
(defn node-label
"What to call node `n` on screen.
A NAME A PERSON TYPED WINS, and for an instance that is the ONLY thing `:name`
now means: `place-symbol` deliberately does not copy the symbol's name onto the
node it makes. Two instances of one symbol are told apart by what somebody
called them — `8625 left` and `8625 right` of one `face` — and reading through
in front of that would collapse them to the same word.
OTHERWISE AN INSTANCE IS LABELLED BY WHAT IT PLACES, read through on every
render. A name copied at creation goes stale the moment the symbol is renamed,
and then the document shows one thing under two names: the symbol reads `bg` in
its tab while an instance of it still reads `symbol-18`, which is how a person
comes to paste a symbol into itself without being able to see that is what they
are doing. `problems` refuses that cycle; this is why it stops looking like a
reasonable thing to try.
An id is a uuid for a placement and a keyword for an authored node, and neither
reads as a name, so the last resort is a legible stand-in rather than `(str
id)` — `:face-1` keeps its colon and a uuid pushes a column open."
[clip id n]
(or (:name n)
(some->> (node/source n) (symbol-name clip))
(if (keyword? id) (subs (str id) 1) (subs (str id) 0 8))))
(defn frames
"A symbol's length. Read off the symbol, never copied beside it."
[clip sid]
(:frames (symbol clip sid)))
(defn fps [clip sid] (or (:fps (symbol clip sid)) (:fps clip)))
(defn set-fps
"Change the output grid without rewriting authored content's frames.
A new document's empty symbol is the one exception: it has no native rate yet,
so it follows the project grid and its empty extent is rescaled to preserve its
duration. Once a symbol contains anything, changing the project rate records
the old effective rate on it before changing the output grid."
[clip rate]
(let [old (:fps clip)]
(-> clip
(update :symbols
#(into {}
(map (fn [[sid sym]]
[sid (cond
(:fps sym) sym
(empty? (:nodes sym))
(update sym :frames cadence/frames rate old)
:else (assoc sym :fps old))]))
%))
(assoc :fps rate))))
(defn output-frames [clip sid]
(cadence/frames (frames clip sid) (:fps clip) (fps clip sid)))
(defn grid-time [clip sid]
{:at 0 :rate (cadence/ratio (:fps clip) (fps clip sid))})
;; ---------------------------------------------------------------------------
;; the only crossing between the output grid and a symbol's own frames
;;
;; Everything authored is in a symbol's own frames and every editing gesture is
;; too — see `docs/one-grid-plan.md`. The output grid belongs to playback: the
;; clock, the audio mix, export, and the frame number the stage is drawing. These
;; two functions are the whole of the way between, so a caller that has a
;; playhead and needs a document coordinate says so in one visible call instead
;; of multiplying by a rate it had to know about.
(defn shown-frame
"Which of symbol `sid`'s own frames the output frame `f` shows."
[clip sid f]
(cadence/frame f (:fps clip) (fps clip sid)))
(defn first-output-frame
"The output frame that first shows symbol `sid`'s own frame `n`: what to seek
to to put the playhead on a mark of `sid`'s ruler."
[clip sid n]
(cadence/reader-frame n (:fps clip) (fps clip sid)))
(defn source-time
"Derived cel-to-content map. Frame-rate units never enter stored retimes."
[clip host n]
(when-let [t (node/source-time n)]
(let [r (cadence/ratio (fps clip host) (fps clip (node/source n)))]
(-> t (update :rate * r) (update :at / r)))))
(defn placed-frame [clip host n f]
(let [child (node/source n)
r (cadence/ratio (fps clip host) (fps clip child))]
(some-> (node/placed-frame (update-in n [:playback :speed] #(* (or % 1) r))
f (frames clip child))
(update :frame js/Math.floor))))
(defn stage
"A symbol's stage as `[width height]`: its own, or the clip's where it has none.
Absent rather than copied in at creation, so a symbol nobody has sized follows
the project's size when that changes."
[clip sid]
(let [sym (symbol clip sid)]
[(or (:width sym) (:width clip)) (or (:height sym) (:height clip))]))
(defn update-symbol
"Apply f to one symbol in place."
[clip sid f & args]
(apply update-in clip [:symbols sid] f args))
(defn places
"The ids of the symbols `sid` places, directly."
[clip sid]
(into #{} (mapcat node/sources) (vals (:nodes (symbol clip sid)))))
(defn contains-symbol?
"Whether `inner` is `outer` or is placed anywhere inside it. Placing `outer`
into `inner` when this is true is a cycle."
[clip outer inner]
(let [seen (volatile! #{})]
(letfn [(walk [sid]
(or (= sid inner)
(when-not (@seen sid)
(vswap! seen conj sid)
(some walk (places clip sid)))))]
(boolean (walk outer)))))
(defn unplaced
"The symbols no other symbol places, sorted by id. What to open when a
document is opened."
[clip]
(let [placed (into #{} (mapcat #(places clip %)) (keys (:symbols clip)))]
(vec (sort-by str (remove placed (keys (:symbols clip)))))))
(defn- longest-unplaced
"The longest symbol nothing else places, ties broken by id, and never a
tracing symbol: that is footage nobody has placed yet, as long as its take."
[clip]
(first (sort-by (fn [sid] [(- (or (frames clip sid) 0)) (str sid)])
(remove #(trace? (symbol clip %)) (unplaced clip)))))
(defn opens-on
"The symbol a document opens on: its `:root`.
IT IS STORED, NOT WORKED OUT. It used to be the longest unplaced symbol, on
the theory that the one containing everything else is always that. A take
disproves it: imported footage is a symbol a thousand frames long, and the
moment its instance is deleted, or the drop lands somewhere other than the
root, nothing places it and it outranks a 120-frame `:main`. The document then
opened on the take, and `set-root-fps` rewrote the take's rate to the
project's on the way in.
A document with no `:root` — a demo, a fixture — still gets the old answer."
[clip]
(let [root (:root clip)]
(if (contains? (:symbols clip) root) root (longest-unplaced clip))))
(defn pin-root
"Give a document saved before `:root` existed the root it was made with.
Every such document started as `blank`, whose root is `:main`, and ids never
change, so `:main` is the answer wherever it survives; the old rule is only
for documents that never had one."
[clip]
(cond-> clip
(not (:root clip))
(assoc :root (if (contains? (:symbols clip) :main) :main (longest-unplaced clip)))))
(defn set-root-fps
"Set the document/output rate and the root symbol's editing rate together.
Project FPS is the root timeline's clock. Nested symbols keep their own native
rates and are sampled when placed across that boundary; only the root changes
here. Frame numbers are authored positions, so changing the rate does not
rewrite them or silently move cuts and keys."
[clip rate]
(let [root (opens-on clip)]
(cond-> (assoc clip :fps rate)
root (assoc-in [:symbols root :fps] rate))))
(def ^:const blank-frames
"How long a new document is before anything says otherwise. Four seconds at 30,
which is long enough to key something into and short enough to scrub by hand."
120)
(defn blank
"A new, empty document: one symbol, and nothing in it.
A SYMBOL IS BORN EMPTY. It used to be born holding a lane, because a lane was
the only place temporal content could go; now the symbol itself is the
container — see `symbol/children` — so there is nothing to invent and the
first drop into a symbol is the same operation as the second.
The tracking maps are ABSENT rather than empty, because `leaf/leaves` writes no
leaf for an empty one and so cannot bring it back: a blank document that opened
as a different map than it saved from is exactly the round trip that namespace
promises not to have. Nothing drawn by hand has them either — the demo scene
and the swarm carry no `:subjects` — so every reader already reads absence as
none, and `clip-keys` says which fields MAY be here, not which must."
[]
{:name "untitled"
:fps 30
:width 320 :height 200
:palettes {pal/default-id pal/default-palette}
:default-palette pal/default-id
:root :main
;; No native fps yet: an untouched canvas follows the project grid. Imported
;; and generated symbols carry their own rate explicitly.
:symbols {:main {:id :main :frames blank-frames :nodes {}}}})
(defn- op-path [op] (let [n (:node op)] (if (vector? n) n [n])))
(defn- own-span
"Where in `ops` the symbol at row path `path` is drawn: the end of its layer
when it has one, and the first and last of its own ops."
[ops path]
(let [own? #(and (< (count path) (count (op-path %)))
(= path (subvec (op-path %) 0 (count path))))
begin (first (keep-indexed #(when (and (= :begin (:kind %2)) (= path (op-path %2))) %1) ops))
end (when begin
(reduce (fn [depth i]
(case (:kind (ops i))
:begin (inc depth)
:end (if (= 1 depth) (reduced i) (dec depth))
depth))
0 (range begin (count ops))))
mine (keep-indexed #(when (own? %2) %1) ops)]
{:end (when (integer? end) end) :from (first mine) :to (last mine)}))
(defn- insert-at [ops i & more] (into (into (subvec ops 0 i) more) (subvec ops i)))
(defn atop
"`ops` with `op` drawn on top of what the symbol at row path `path` draws —
in ITS stacking context, where a shape added to it would land, so what is
above that symbol stays above. At the end when it drew nothing."
[ops path op]
(let [ops (vec ops)
{:keys [end to]} (own-span ops path)]
(cond end (insert-at ops end op)
to (insert-at ops (inc to) op)
:else (conj ops op))))
(defn in-layer
"`ops` with `op` drawn last INSIDE the symbol at row path `path` — in its
layer, so a knockout clears only what that symbol drew, as one saved there
would. A symbol that has no layer yet is given one around its ops. `ops`
unchanged when that symbol drew nothing."
[ops path op]
(let [ops (vec ops)
{:keys [end from to]} (own-span ops path)]
(cond
end (insert-at ops end op)
from (-> ops
(insert-at (inc to) op {:kind :end :node path})
(insert-at from {:kind :begin :node path}))
:else ops)))
(defn- transform-op
"Put a symbol's already resolved mark into its instance's parent space. Its
name becomes its path of instances down to it, the path its timeline row has."
[op m path]
(let [at (fn [x y] [(+ (* (aget m 0) x) (* (aget m 2) y) (aget m 4))
(+ (* (aget m 1) x) (* (aget m 3) y) (aget m 5))])
scale (node/mean-scale m)
n (:node op)
op (assoc op :node (if (vector? n) (into path n) (conj path n)))]
(case (:kind op)
:poly (let [out (js/Float64Array. (.-length (:pts op)))]
(dotimes [i (:n op)]
(let [[x y] (at (aget (:pts op) (* 2 i))
(aget (:pts op) (inc (* 2 i))))]
(aset out (* 2 i) x)
(aset out (inc (* 2 i)) y)))
(assoc op :pts out))
:disc (let [[x y] (at (:cx op) (:cy op))]
(assoc op :cx x :cy y :r (* scale (:r op))))
:rect (let [[x y] (at (:cx op) (:cy op))]
(assoc op :cx x :cy y :size (* scale (:size op))))
:trace (assoc op :m (node/mul! (node/mat) m (:m op)))
op)))
(defn- trace-op
"The one op an instance of tracing symbol `sym` makes, showing its frame `frame`
at world `m`, from node `id` of symbol `sid`.
NOT A PICTURE OP. Nothing indexed can show a photo, so the raster refuses this
kind and the player hands it to `ui/tracing` instead; and the resolver makes one
only when asked with `:tracing?`, which only the stage does. An export, a
symbol's centre and a thumbnail never ask, so a reference cannot reach the
picture by any path that forgets to filter it.
`:layer` is what the on/off switch for one layer is keyed by: the symbol the
placement is in and its id, so a face's plate is one layer wherever the face
is placed."
[sym frame m sid id]
{:kind :trace
:node id
:layer [sid id]
:media (:media sym)
:frame frame
:size [(:width sym) (:height sym)]
:m (js/Float64Array.from m)})
(defprotocol IActivePalette
(active-palette [this]
"The palette selected by this resolver's most recently resolved frame."))
(defn- channel-value [store selection frame]
(cond
(nil? selection) nil
(and (map? selection) (contains? selection :animated?))
(ch/value-at selection frame store)
:else selection))
(defn palette-at
"The palette symbol `owner` draws in at `frame`, as the stage's root does: a
palette id, or `{:from :to :t}` while its palette lane blends from one to the
next. `inherited` is what an unset palette falls back to, then `default`."
[clip store default owner frame inherited]
;; A palette track has meaningful uncovered time. Ordinary held
;; channels clamp to their first key before it, but doing that
;; here would erase the gap before the first palette segment.
(let [channel-value (partial channel-value store)
fallback (or (channel-value (:palette owner) frame)
inherited default)
materialize (fn [choice]
(cond
(= pal/inherit choice) fallback
(map? choice) (-> choice
(update :from #(if (= pal/inherit %) fallback %))
(update :to #(if (= pal/inherit %) fallback %)))
:else choice))
track (:palette-channel owner)
track-value (if-let [ks (:keys track)]
(some->> (keys ks)
(filter #(<= % frame))
sort last
(get ks))
(channel-value track frame))]
(or (when-let [track-sid (and (keyword? (:palette-track owner))
(:palette-track owner))]
(let [track-symbol (symbol clip track-sid)
palette-clip (first
(filter (fn [n]
(let [[a b] (node/placed-span n)]
(and a (<= a frame) (< frame b))))
(symbol/children (:nodes track-symbol))))
palette-symbol (some-> palette-clip node/source
(#(symbol clip %)))
fallback (when (= :palette (:type palette-symbol))
(:palette-ref palette-symbol))
choice (get-in palette-clip [:channels [:palette]])
start (some-> palette-clip node/placed-span first)]
(or (when (and choice start)
(materialize (channel-value choice (- frame start))))
fallback)))
track-value
fallback)))
(defn resolver
"Resolve an output frame, selecting native content at each symbol boundary.
Every instance owns its cursors and buffers. The IResolver queries return
native node frames and world matrices for the last rendered output frame."
[clip sid store palette opts]
(let [context? (and (map? palette) (:palettes palette) (:offsets palette))
active-palette-state (atom (:default palette))]
(letfn [(root-selection-at [owner frame inherited]
(palette-at clip store (:default palette) owner frame inherited))
(selection-at [owner frame inherited]
(let [selection (:palette owner)
chosen (cond
(nil? selection) nil
(and (map? selection) (contains? selection :animated?))
(ch/value-at selection frame store)
:else selection)]
(or chosen inherited (:default palette))))
(build [sid chain pose-tracks root?]
(when (some #{sid} chain)
(throw (ex-info "symbol cycle" {:chain (conj chain sid)})))
(let [sym (or (symbol clip sid)
(throw (ex-info "an instance names a missing symbol" {:symbol sid})))
active (volatile! (when context? (:default palette)))
nodes (:nodes sym)
rank (symbol/draw-rank nodes (symbol/order nodes))
ids (sort-by rank (keys nodes))
own (symbol/resolver sym store (if context?
#(pal/render-index palette @active %)
palette)
(assoc opts :pose-tracks pose-tracks))
;; Each cel owns its source resolver and mutable buffers. A
;; tracing symbol has nothing to resolve: it is one op.
children (into {}
(for [[id n] nodes
:when (= :instance (:kind n))
child (sort-by str (node/sources n))
:when (not (trace? (symbol clip child)))]
[[id child] (build child (conj chain sid)
(get-in n [:playback :tracks]) false)]))
;; The instances that were on the last frame, and WHICH
;; drawing each was showing — a row path is read back through
;; the child that was actually resolved, not the only one
;; there used to be. Their resolvers still hold the frame
;; before whenever they were not on.
entered (volatile! {})
;; A symbol that knocks out is drawn into a layer of its own,
;; so what it clears is only ever its own.
layered? (symbol/knocks? nodes)
step (fn [f pre inherited forced]
(when context?
(vreset! active (or forced
(if root?
(root-selection-at sym (js/Math.floor f) inherited)
inherited)
(:default palette))))
;; The same decision that maps local drawing slots to
;; the active bank also names the clear colour.
(when root? (reset! active-palette-state @active))
(vreset! entered {})
(let [by-id (into {} (map (juxt :node identity))
(own (js/Math.floor f) (js/Math.floor pre)))]
(cond-> (into (if layered? [{:kind :begin :node []}] [])
(mapcat
(fn [id]
(let [n (get nodes id)]
(if (= :instance (:kind n))
(let [m (symbol/world-of own id)
local (symbol/frame-of own id)
prior (symbol/pre-frame-of own id)
length (frames clip (node/source n))
shown (when (and m (number? local))
(placed-frame clip sid n local))
frame (:frame shown)]
(cond
(not (and frame (<= 0 frame) (< frame length)))
[]
(trace? (symbol clip (:symbol shown)))
(when (:tracing? opts)
[(trace-op (symbol clip (:symbol shown)) frame m sid id)])
:else
(do (vswap! entered assoc id (:symbol shown))
(map #(transform-op % m [id])
((get children [id (:symbol shown)])
frame
;; The slot's interval crosses the
;; boundary by being MAPPED, not
;; carried: the previous slot's
;; frame goes through the same
;; placement and retime as this
;; one, so the gap comes out in the
;; child's frames and at the child's
;; rate. An instance appearing for
;; the first time on this slot has no
;; previous frame, and so no gap.
(or (:frame (when (number? prior)
(placed-frame clip sid n prior)))
(dec frame))
@active
(when (and context? (:palette n))
(selection-at n frame nil)))))))
(when-let [op (get by-id id)] [op]))))
ids))
layered? (conj {:kind :end :node []}))))]
(reify
IFn
(-invoke [_ f] (step f (dec f) nil nil))
(-invoke [_ f pre] (step f pre nil nil))
(-invoke [_ f pre inherited forced] (step f pre inherited forced))
symbol/IResolver
(world-of [_ [id & more]]
(if more
(when-let [w (and (contains? @entered id)
(symbol/world-of (get children [id (get @entered id)])
(vec more)))]
(node/mul! (node/mat) (symbol/world-of own id) w))
(symbol/world-of own id)))
(frame-of [_ [id & more]]
(if more
(when (contains? @entered id)
(symbol/frame-of (get children [id (get @entered id)]) (vec more)))
(symbol/frame-of own id)))
(pre-frame-of [_ [id & more]]
(if more
(when (contains? @entered id)
(symbol/pre-frame-of (get children [id (get @entered id)]) (vec more)))
(symbol/pre-frame-of own id))))))]
(let [r (build sid [] nil true)
grid (or (:grid-fps opts) (:fps clip))
native (fps clip sid)]
(reify
IFn
(-invoke [_ f]
(r (cadence/frame f grid native)
;; `d(k-1)`: the native frame the slot BEFORE this one selected, which
;; with `d(k)` is the interval `(d(k-1), d(k)]` the preserve-snap may
;; reach back into — the frames this slot is the first to cover, and so
;; the ones the grid would otherwise show to nobody. Slot 0 has no slot
;; before it, so its interval is its own frame alone.
;;
;; THE SNAP DOES NOT HAPPEN HERE, although the interval is born here and
;; nothing would need threading. One native frame per output frame means
;; the WHOLE PICTURE reading 13 instead of 15 — a head going two frames
;; stale, a 67ms hitch at 12fps, to fix one group's mouth. It is per
;; group, so it is seated where groups exist.
(if (pos? f) (cadence/frame (dec f) grid native) -1)
(when context? (:default palette))
nil))
symbol/IResolver
(world-of [_ path] (symbol/world-of r path))
(frame-of [_ path] (symbol/frame-of r path))
(pre-frame-of [_ path] (symbol/pre-frame-of r path))
IActivePalette
(active-palette [_] @active-palette-state))))))
(defn center
"The middle of everything symbol `sid` draws, over all its frames, in its own
coordinates. ALL frames rather than the first, so a symbol whose drawing
enters late, or travels, still has its middle where the drawing is. A symbol
that draws nothing gets the STAGE's middle, which is where a drawing made into
it will be, because drawings are made on the stage.
WHERE A DROP LANDS, AND WHAT THE INSTANCE TURNS ABOUT. `place-symbol` puts this
point under the pointer and stores it as the instance's `[:xform :pivot]`, and
`ui/drag`'s ghost draws the cross there so a drop lands where it was aimed —
one point, one meaning, three uses.
IT IS A DEFAULT AND NOT A CACHE, which is the distinction the stored anchor got
wrong. A pivot written here is a CHOICE made on the instance's behalf at the
moment it is placed, the same way `paint/centred` chooses a drawing's origin
when it is drawn; editing the symbol afterwards does not revise either, and the
cross is draggable so neither is a trap. What the anchor got wrong was being
invisible and unmovable, not being stored."
[clip store sid]
(let [resolve (resolver clip sid store pal/index-of {:grid-fps (fps clip sid)})
bounds (fn [[x0 y0 x1 y1 :as b] x y]
(if b [(min x0 x) (min y0 y) (max x1 x) (max y1 y)] [x y x y]))
[x0 y0 x1 y1]
(reduce
(fn [b {:keys [kind pts n cx cy r size]}]
(case kind
:poly (reduce (fn [b i] (bounds b (aget pts (* 2 i)) (aget pts (inc (* 2 i)))))
b (range n))
:disc (-> b (bounds (- cx r) (- cy r)) (bounds (+ cx r) (+ cy r)))
:rect (let [h (/ size 2)] (-> b (bounds (- cx h) (- cy h)) (bounds (+ cx h) (+ cy h))))
b))
nil
(mapcat resolve (range (frames clip sid))))]
(if x0
[(/ (+ x0 x1) 2) (/ (+ y0 y1) 2)]
(mapv #(/ % 2) (stage clip sid)))))
(defn place-symbol
"An instance of symbol `sid`, inside symbol `host`, at `frame` of `host`.
THE MIDDLE GOES UNDER THE POINTER, AND IS WHAT THE INSTANCE TURNS ABOUT.
`center` says where the symbol's drawing sits in its own coordinates; `pos` is
set so that point lands on `point`, a stage pixel — without one, a drop on the
timeline, the drawing stays where it was drawn — and the same point is stored as
the instance's `[:xform :pivot]`, so a turn or a scale happens about the middle
of the drawing rather than about the symbol's origin.
WITHOUT THAT PIVOT AN INSTANCE TURNS ABOUT THE CORNER OF THE STAGE. A symbol's
origin is the stage's, because that is where its contents were drawn, so the
middle of a drawing inside one is typically a hundred-odd pixels away from it on
a 320x200 stage. `node/local!` composes about the pivot, so this one stored
point is the difference between spinning in place and orbiting the top-left
corner. See `domain/gesture`.
THE UUID IS AN ARGUMENT. An instance's identity is the key it has in the node
map — it is what `:linked-to`, an export target and a saved leaf all name — so
generating one in here would make this function's result depend on when it was
called, and this namespace is the pure one.
The instance's own time starts where it was dropped: `:at frame` means frame 0
of the symbol plays on `frame` of `host`, which is what dragging something onto
a playhead is asking for. Its `:span` is in its OWN frames — the whole symbol,
0 to its length — wherever it was dropped; see `node/placed-span`.
Refused, returning the clip unchanged, when it would make a cycle: a symbol
cannot be placed inside itself or inside anything it places."
[clip store host sid frame uuid point]
(let [target (symbol clip sid)
end (frames clip host)]
(if (or (nil? target) (nil? end) (nil? frame) (neg? frame) (>= frame end)
(contains-symbol? clip sid host))
clip
(let [middle (center clip store sid)]
(update-symbol
clip host assoc-in [:nodes uuid]
{:id uuid
:kind :instance
:parent nil
;; Lexicographic draw order, as `domain/paint` does it: an instance made
;; later sits above one made earlier, and neither has to renumber.
:z (str "z" (js/Date.now) "-" (name sid))
:span [0 (cadence/frames (:frames target) (fps clip host) (fps clip sid))]
:time {:mode :map :at frame :rate 1}
:source {:symbol sid}
:playback {:in 0 :speed 1 :end :stop}
:channels {[:xform :pos] {:animated? false
:value (if point (mapv - point middle) [0 0])}
[:xform :pivot] {:animated? false :value middle}}})))))
(defn place-sound
"Place a sound at host frame `frame`. Its span uses `source`'s fps when
supplied, otherwise the host's. `rate` is a deliberate playback speed."
[clip host source label length rate frame uuid]
(let [end (frames clip host)]
(if (or (nil? end) (nil? frame) (neg? frame) (>= frame end))
clip
(update-symbol
clip host assoc-in [:nodes uuid]
{:id uuid
:name label
:kind :audio
:parent nil
:z (str "z" (js/Date.now) "-sound")
:source source
:span [0 (max 1 length)]
:time {:mode :map :at frame :rate rate}}))))
(defn fresh-id
"The first `:symbol-N` the clip does not already hold. Readable because an id
shows up in saved leaf paths, and deterministic because this namespace is pure."
[clip]
(first (remove (:symbols clip) (map #(keyword (str "symbol-" %)) (iterate inc 1)))))
(defn new-symbol
"A new, empty symbol `sid`, placed inside `host` at `frame` and running to the
end of it. Placed at the origin, so whatever is drawn into it lands where it was
drawn until the instance is moved."
[clip host sid frame uuid]
(let [end (frames clip host)]
(if (or (nil? end) (symbol clip sid) (nil? frame) (neg? frame) (>= frame end))
clip
(-> clip
(assoc-in [:symbols sid] {:id sid :name (name sid) :fps (fps clip host)
:frames (- end frame) :nodes {}})
(place-symbol nil host sid frame uuid nil)))))
(defn free-id
"`wanted`, or the first `wanted-2`, `wanted-3`… `taken?` does not claim.
Keeps the namespace, so `:sym/face` becomes `:sym/face-2`."
[taken? wanted]
(first (remove taken?
(cons wanted
(map #(keyword (namespace wanted) (str (name wanted) "-" %))
(iterate inc 2))))))
(defn conflicts
"Every hand correction in the document that its base has outgrown, as
`[{:symbol :node :channel :id :why}]`.
SEPARATE FROM `problems` on purpose. A conflict is a document a person still
has to make a decision about — a regeneration changed the topology under a
correction that was right when it was made — and not a reason the document
will not load. Nothing is dropped and nothing is misapplied meanwhile: the
layer stays where it is, the picture is the base, and this is the list a view
offers to resolve."
[clip]
(vec (for [[sid sym] (:symbols clip)
[id n] (:nodes sym)
[prop c] (:channels n)
{:keys [why] :as x} (ch/conflicts c)]
(assoc (select-keys x [:id]) :symbol sid :node id :channel prop :why why))))
(defn problems
"Human-readable reasons this clip will not evaluate or save."
[clip]
(vec
(concat
(for [k (remove clip-keys (keys clip))]
(str "clip has a field with no leaf to save it in: " (pr-str k)))
(when-not (map? (:symbols clip))
[":symbols must be a map of id -> symbol"])
(when (and (contains? clip :analyses) (not (map? (:analyses clip))))
[":analyses must be a map of analysis id -> analysis"])
(for [[id analysis] (:analyses clip)
:when (not= id (:id analysis))]
(str "analysis under key " (pr-str id) " has :id " (pr-str (:id analysis))))
(for [[id subject] (:subjects clip)
:when (not (contains? (:analyses clip) (:analysis subject)))]
(str "subject " (pr-str id) " names missing analysis "
(pr-str (:analysis subject))))
(for [[id subject] (:subjects clip)
:when (not (keyword? (:source-subject subject)))]
(str "subject " (pr-str id) " has no source subject"))
(when (and (contains? clip :palettes) (not (map? (:palettes clip))))
[":palettes must be a map of id -> palette"])
(when (and (contains? clip :default-palette) (map? (:palettes clip))
(not (contains? (:palettes clip) (:default-palette clip))))
[":default-palette must name a project palette"])
(when (and (contains? clip :root) (map? (:symbols clip))
(not (contains? (:symbols clip) (:root clip))))
[(str ":root names missing symbol " (pr-str (:root clip)))])
(for [[id p] (:palettes clip)
:when (or (not= id (:id p)) (not (pal/valid-palette? p)))]
(str "palette " (pr-str id) " is invalid or has a different :id"))
(when-not (or (nil? (:fps clip)) (and (number? (:fps clip)) (pos? (:fps clip))))
[(str ":fps is " (pr-str (:fps clip)) " — a rate is a positive number")])
(for [[id sym] (:symbols clip)
:when (not= id (:id sym))]
(str "symbol under key " (pr-str id) " has :id " (pr-str (:id sym))))
(for [[id sym] (:symbols clip)
p (symbol/problems sym)]
(str "symbol " (pr-str id) ": " p))
(for [[sid sym] (:symbols clip)
[id n] (:nodes sym)
:when (= :instance (:kind n))
missing (remove (:symbols clip) (node/sources n))]
(str "symbol " (pr-str sid) " instance " (pr-str id)
" names missing symbol " (pr-str missing)))
;; THE INVARIANT `place-symbol` AND `ui/drag` ALREADY ENFORCE, stated here so
;; that every command is checked against it rather than the two that remember
;; to ask. A symbol placed inside itself, or inside anything it places, has no
;; finite expansion: `build` above and `nest/audio-tracks` both walk instances
;; and both throw on the way round. Paste reached this function without it and
;; wrote a document that saved, loaded, and only then threw — which is the one
;; outcome `problems` exists to make impossible.
(for [[sid sym] (:symbols clip)
[id n] (:nodes sym)
:when (= :instance (:kind n))
src (node/sources n)
;; A source that does not exist is the rule above's to report, not this
;; one's, so it does not get named twice.
:when (and (contains? (:symbols clip) src)
(contains-symbol? clip src sid))]
(str "symbol " (pr-str sid) " instance " (pr-str id) " places "
(pr-str src) (if (= src sid) ", which is itself" ", which contains it")))
;; Pose tracks belong to this cel's single source symbol.
(for [[sid sym] (:symbols clip)
[id n] (:nodes sym)
:when (= :instance (:kind n))
:let [targets (keep #(get-in clip [:symbols %]) (node/sources n))
active (filter (fn [node]
(some :pose-sampled? (vals (:channels node))))
(mapcat #(vals (:nodes %)) targets))
groups (set (concat
(map #(or (:pose-group %) (:id %)) active)
(map #(vector :node (:id %)) active)))]
p (pose/problems (get-in n [:playback :tracks])
(apply max 0 (keep :frames targets)) groups)]
(str "symbol " (pr-str sid) " instance " (pr-str id) ": " p))
(for [[sid sym] (:symbols clip)
[id n] (:nodes sym)
:when (and (= :audio (:kind n)) (:linked-to n)
(not (contains? (:nodes sym) (:linked-to n))))]
(str "symbol " (pr-str sid) " audio " (pr-str id)
" links to missing node " (pr-str (:linked-to n))))
(feature/problems clip))))

View file

@ -0,0 +1,321 @@
(ns arthur.domain.clipboard
"Pure multi-node clipboard commands.
A clipboard value is a detached forest of node maps. Normal copies keep symbol
references; `duplicate` with `:unique?` copies the complete referenced symbol
graph once for the whole forest. UI state, playhead conversion, and history
stay in events.ui. See docs/clipboard-plan.md."
(:require [arthur.domain.bring :as bring]
[arthur.domain.clip :as clip]
[arthur.domain.nest :as nest]
[arthur.domain.node :as node]
[arthur.domain.span :as span]
[arthur.domain.symbol :as symbol]))
(defn- prefix? [a b]
(and (<= (count a) (count b)) (= a (subvec b 0 (count a)))))
(defn- node-selections [clip selections]
(->> selections
(keep (fn [[kind sid id path :as address]]
(when (and (= :node kind) id (get-in clip [:symbols sid :nodes id]))
{:address address :sid sid :id id :path (vec (or path [id]))})))
;; One owned node reached through two shared occurrences is still one edit.
(reduce (fn [{:keys [seen out] :as acc} {:keys [sid id] :as x}]
(if (contains? seen [sid id]) acc
{:seen (conj seen [sid id]) :out (conj out x)}))
{:seen #{} :out []})
:out))
(defn canonical
"Valid selected node occurrences, with anything visibly below another selected
occurrence omitted. Order is selection order and therefore keeps the primary
member last in the ordinary case."
[clip selections]
(let [xs (node-selections clip selections)]
(filterv (fn [{p :path}]
(not-any? (fn [{q :path}]
(and (< (count q) (count p)) (prefix? q p)))
xs))
xs)))
(defn- subtree [nodes root]
(into {} (filter (fn [[id _]] (some #{root} (symbol/lineage nodes id)))) nodes))
(defn snapshot
"Snapshot the canonical selected forest, or `{:refused why}`."
[clip selections]
(let [roots (canonical clip selections)]
(if (empty? roots)
{:refused "select something to copy"}
{:clipboard
{:items
(mapv (fn [{:keys [sid id path]}]
(let [nodes (get-in clip [:symbols sid :nodes])]
{:sid sid :root id :path path :parent (:parent (get nodes id))
:nodes (subtree nodes id)}))
roots)}})))
(defn cut
"Delete a previously snapshotted forest. Snapshotting first is what makes cut
retain data even though its source nodes are gone."
[clip {:keys [items]}]
(let [after (reduce (fn [c {:keys [sid root]}] (nest/delete-node c sid root)) clip items)]
(if-let [why (first (clip/problems after))]
{:refused why}
{:clip after :selections []})))
(defn- fresh
[taken fresh-id]
(loop [id (fresh-id)]
(if (contains? taken id) (recur (fresh-id)) id)))
(defn- allocate
[clip items fresh-id]
(loop [pending (vec (mapcat (fn [[i item]] (map #(vector i %) (keys (:nodes item))))
(map-indexed vector items)))
taken (into #{} (mapcat (comp keys :nodes val) (:symbols clip)))
ids {}]
(if-let [k (first pending)]
(let [id (fresh taken fresh-id)]
(recur (subvec pending 1) (conj taken id) (assoc ids k id)))
ids)))
(defn- unique-content
[clip items unique?]
(let [roots (into #{} (comp (mapcat #(vals (:nodes %))) (keep node/source)) items)]
(if (and unique? (seq roots))
(let [{c :clip ids :ids} (bring/symbols clip clip roots {})]
[c ids])
[clip {}])))
(defn- materialize
[clip items fresh-id unique? paste?]
(let [[clip source-ids] (unique-content clip items unique?)
ids (allocate clip items fresh-id)
made
(mapv
(fn [[i {:keys [sid root path parent nodes]}]]
(let [id-of #(get ids [i %])
copied (into {}
(map (fn [[old n]]
(let [id (id-of old)]
[id (cond-> (assoc n :id id)
(:parent n) (assoc :parent (id-of (:parent n)))
(:stencil n) (assoc :stencil (id-of (:stencil n)))
(node/source n)
(assoc-in [:source :symbol]
(get source-ids (node/source n)
(node/source n))))])))
nodes)
new-root (id-of root)
;; Paste reparents roots into one explicit destination.
;; Duplicate leaves them beside their originals.
copied (assoc-in copied [new-root :parent]
(when-not paste? parent))]
{:source-sid sid :old-root root :path path :root new-root
:nodes copied}))
(map-indexed vector items))]
{:clip clip :items made}))
(defn- shifted [n delta]
(if (node/placed-span n)
(update-in n [:time :at] (fnil + 0) delta)
n))
(defn- top-zs [nodes n]
(let [base (or (last (sort (map #(or (:z %) "") (vals nodes)))) "")]
(map #(str base (apply str (repeat % "m"))) (range 1 (inc n)))))
(defn- add-composition
[clip sid items at]
(let [starts (keep #(some-> (get-in % [:nodes (:root %)]) node/placed-span first) items)
anchor (when (seq starts) (apply min starts))
delta (if anchor (- at anchor) 0)
existing (get-in clip [:symbols sid :nodes])
zs (top-zs existing (count items))
nodes (reduce (fn [nodes [{:keys [root] copied :nodes} z]]
(into nodes (assoc-in copied [root]
(-> (get copied root)
(shifted delta)
(assoc :z z)))))
existing (map vector items zs))]
(assoc-in clip [:symbols sid :nodes] nodes)))
(defn- add-lane
[clip sid items at fresh-id]
(let [roots (map #(get-in % [:nodes (:root %)]) items)
starts (map #(some-> % node/placed-span first) roots)
anchor (when (every? some? starts) (apply min starts))
intervals (when anchor
(sort-by first
(map (fn [n]
(let [[lo hi] (node/placed-span n)]
[(+ at (- lo anchor)) (+ at (- hi anchor))]))
roots)))
overlaps? (some (fn [[[a b] [c d]]] (and (< a d) (< c b)))
(partition 2 1 intervals))]
(if (some nil? starts)
{:refused "a lane accepts copied things only when they have a finite span"}
(if overlaps?
{:refused "overlapping copied things cannot be pasted into one lane"}
(reduce
(fn [result item]
(if (:refused result)
(reduced result)
(let [c (:clip result)
root (:root item)
n (get-in item [:nodes root])
desired (+ at (- (first (node/placed-span n)) anchor))
r (span/place-node c sid n desired
{:extent :grow-symbol
:remainder-id (fresh (into #{} (keys (get-in c [:symbols sid :nodes])))
fresh-id)})]
(if-let [made (:clip r)]
{:clip (update-in made [:symbols sid :nodes]
into (dissoc (:nodes item) root))}
r))))
{:clip clip} items)))))
(defn paste
"Paste `clipboard` into `sid`, anchoring its first finite start at `at`.
`fresh-id` is supplied by the event so this domain command remains testable."
[clip clipboard sid at {:keys [fresh-id] :or {fresh-id random-uuid}}]
(cond
(nil? (clip/symbol clip sid)) {:refused "the paste target no longer exists"}
(not (and (integer? at) (not (neg? at))))
{:refused "the playhead is not on one frame of the paste target"}
(empty? (:items clipboard)) {:refused "there is nothing to paste"}
:else
(let [{base :clip items :items} (materialize clip (:items clipboard) fresh-id false true)
r (if (symbol/lane? (clip/symbol base sid))
(add-lane base sid items at fresh-id)
{:clip (add-composition base sid items at)})
made (:clip r)
why (when made (first (clip/problems made)))]
(cond
(:refused r) r
why {:refused why}
:else {:clip made :roots (mapv :root items)}))))
(defn- add-duplicate-composition [clip sid items]
(let [existing (get-in clip [:symbols sid :nodes])
zs (top-zs existing (count items))]
(assoc-in clip [:symbols sid :nodes]
(reduce (fn [nodes [{:keys [root] copied :nodes} z]]
(into nodes (assoc-in copied [root :z] z)))
existing (map vector items zs)))))
(defn- add-duplicate-lane
[clip sid items fresh-id]
(let [old-roots (map #(get-in clip [:symbols sid :nodes (:old-root %)]) items)
spans (map node/placed-span old-roots)
start (apply min (map first spans))
end (apply max (map second spans))
duration (- end start)
selected (set (map :old-root items))
shifted-clip
(update-in clip [:symbols sid :nodes]
(fn [nodes]
(reduce (fn [ns n]
(let [lo (some-> (node/placed-span n) first)]
(if (and (nil? (:parent n))
(not (contains? selected (:id n)))
lo (>= lo end))
(update-in ns [(:id n) :time :at] (fnil + 0) duration)
ns)))
nodes (vals nodes))))]
(reduce
(fn [result item]
(if (:refused result)
(reduced result)
(let [c (:clip result)
root (:root item)
n (get-in item [:nodes root])
old (get-in clip [:symbols sid :nodes (:old-root item)])
desired (+ (first (node/placed-span old)) duration)
r (span/place-node c sid n desired
{:extent :grow-symbol
:remainder-id (fresh (into #{} (keys (get-in c [:symbols sid :nodes])))
fresh-id)})]
(if-let [made (:clip r)]
{:clip (update-in made [:symbols sid :nodes]
into (dissoc (:nodes item) root))}
r))))
{:clip shifted-clip}
(sort-by #(first (node/placed-span
(get-in clip [:symbols sid :nodes (:old-root %)]))) items))))
(defn duplicate
"Duplicate a snapshot beside its sources. Direct finite children of lane
symbols repeat forward and ripple later cels; everything else copies in place.
With `:unique?`, referenced symbol graphs are deep-copied once for the batch."
[clip clipboard {:keys [fresh-id unique?] :or {fresh-id random-uuid}}]
(if (empty? (:items clipboard))
{:refused "select something to duplicate"}
(let [{base :clip items :items}
(materialize clip (:items clipboard) fresh-id unique? false)
groups (vals (group-by :source-sid items))
result
(reduce
(fn [result group]
(if (:refused result)
(reduced result)
(let [c (:clip result)
sid (:source-sid (first group))
lane? (symbol/lane? (clip/symbol c sid))
[lane-items other]
((juxt filter remove)
#(let [old (get-in clip [:symbols sid :nodes (:old-root %)])]
(and lane? (nil? (:parent old)) (node/placed-span old)))
group)
c (if (seq other) (add-duplicate-composition c sid other) c)
r (if (seq lane-items)
(add-duplicate-lane c sid lane-items fresh-id)
{:clip c})]
r)))
{:clip base} groups)
made (:clip result)
why (when made (first (clip/problems made)))]
(cond
(:refused result) result
why {:refused why}
:else {:clip made
:roots (mapv (fn [{:keys [source-sid root path]}]
{:sid source-sid :id root
:path (conj (vec (butlast path)) root)})
items)}))))
(defn move-many
"Reparent the selected roots atomically, preserving their world transforms and
clocks. Optional delta places the forest later on the open ruler."
[document store open selections to frame delta]
(let [roots (canonical document selections)
under? (fn [path] (prefix? path (vec to)))]
(cond
(empty? roots) {:refused "select something to move"}
(some #(under? (:path %)) roots) {:refused "a selection cannot go inside itself"}
:else
(let [moved
(reduce (fn [result {:keys [path]}]
(if (:refused result) (reduced result)
(let [r (nest/move-node (:clip result) store open path to frame)]
(if (:refused r) (reduced r)
{:clip (:clip r)
:selections (conj (:selections result)
[:node (:sid r) (:id r) (conj (vec to) (:id r))])}))))
{:clip document :selections []} roots)]
(if (:refused moved) moved
(let [shifted (nest/slide-many (:clip moved) open
(mapv #(nth % 3) (:selections moved)) (or delta 0))
checked (if (:refused shifted) shifted
(reduce (fn [result sid]
(if (:refused result) (reduced result)
(span/finish (:clip result) sid
(get-in (:clip result) [:symbols sid :nodes])
nil :grow-symbol)))
shifted (distinct (map :sid roots))))]
(if (:refused checked) checked
(if-let [why (first (clip/problems (:clip checked)))]
{:refused why}
(assoc checked :selections (:selections moved))))))))))

View file

@ -0,0 +1,193 @@
(ns arthur.domain.correction
"Pure commands that author and resolve correction layers.
Evaluation belongs to `channel`; this namespace only constructs a layer,
places it on its owning node, and refuses a document that would not be valid."
(:require [arthur.domain.channel :as ch]
[arthur.domain.clip :as clip]
[arthur.domain.node :as node]))
(def ^:private supported-paths #{[:xform :rot] [:xform :pos]})
(def ^:private motions #{:constant :ramp :return})
(defn- finite? [x] (and (number? x) (js/Number.isFinite x)))
(defn- numeric-value? [v]
(or (finite? v)
(and (vector? v) (pos? (count v)) (every? finite? v))))
(defn- same-shape? [a b]
(or (and (number? a) (number? b))
(and (vector? a) (vector? b) (= (count a) (count b)))))
(defn- expected-value? [path v]
(case path
[:xform :rot] (finite? v)
[:xform :pos] (and (vector? v) (= 2 (count v)) (every? finite? v))
false))
(defn- values-channel
[{:keys [motion support delta start end peak peak-frame]}]
(let [[a b] support]
(case motion
:constant (ch/framed delta)
:ramp (ch/keyed {a start, (dec b) end} :linear)
:return (ch/keyed {a start, peak-frame peak, (dec b) start} :linear)
nil)))
(defn- invalid
[path {:keys [id support motion delta start end peak peak-frame]} existing]
(let [[a b] (when (and (vector? support) (= 2 (count support))) support)
samples (case motion :constant [delta] :ramp [start end]
:return [start peak] [])]
(cond
(nil? id) "a correction needs an ID"
(some #(= id (:id %)) existing) "the correction ID is already used on this channel"
(not (contains? supported-paths path)) "that property does not support correction authoring"
(not (contains? motions motion)) "choose constant, ramp, or return motion"
(not (and (integer? a) (integer? b) (< a b)))
"support must be an increasing [in out) of whole owner frames"
(not-every? numeric-value? samples) "correction values must be finite numbers"
(not-every? #(expected-value? path %) samples)
"correction values do not have the property's shape"
(and (= :ramp motion) (< (- b a) 2)) "a ramp needs at least two samples"
(and (= :return motion) (< (- b a) 3)) "return motion needs at least three samples"
(and (= :return motion)
(not (and (integer? peak-frame) (< a peak-frame (dec b)))))
"the return peak must be a whole owner frame inside both endpoints"
(and (#{:ramp :return} motion) (not (same-shape? start (if (= :ramp motion) end peak))))
"motion endpoints must have the same shape")))
(defn- finish [candidate selection]
(if-let [why (first (clip/problems candidate))]
{:refused why}
{:clip candidate :selection selection}))
(defn add
"Append one offset correction to a node channel.
Support and value keys are in the selected node's own frames. Defaults are
materialized through `node/channels`, so correcting an unkeyed transform does
not need a special representation."
[document sid node-id path spec]
(let [n (get-in document [:symbols sid :nodes node-id])
base (when n (get (node/channels n) path))
existing (:over base)
why (cond
(nil? (clip/symbol document sid)) "the owning symbol does not exist"
(nil? n) "the correction target does not exist"
(nil? base) "the correction target has no such channel"
:else (invalid path spec existing))]
(if why
{:refused why}
(let [layer (ch/layer (:id spec) (:support spec) :offset (values-channel spec))
corrected (update base :over (fnil conj []) layer)
candidate (assoc-in document [:symbols sid :nodes node-id :channels path] corrected)]
(finish candidate node-id)))))
(defn remove-layer
"Remove one named layer, refusing when a later layer depended on its shape."
[document sid node-id path layer-id]
(let [at [:symbols sid :nodes node-id :channels path]
c (get-in document at)
layers (:over c)]
(cond
(nil? c) {:refused "the correction channel does not exist"}
(not-any? #(= layer-id (:id %)) layers) {:refused "the correction does not exist"}
:else (finish (assoc-in document at
(assoc c :over (vec (remove #(= layer-id (:id %)) layers))))
node-id))))
(defn retry-layer
"Clear one recorded conflict when the complete resulting stack is valid."
[document sid node-id path layer-id]
(let [at [:symbols sid :nodes node-id :channels path]
c (get-in document at)
found (some #(when (= layer-id (:id %)) %) (:over c))]
(cond
(nil? found) {:refused "the correction does not exist"}
(nil? (:conflict found)) {:refused "the correction has no recorded conflict"}
:else
(finish (update-in document (conj at :over)
(fn [layers]
(mapv #(if (= layer-id (:id %)) (dissoc % :conflict) %) layers)))
node-id))))
(defn borrow-pose [document sid {:keys [from through donor head?] :as spec} store]
(let [frames (get-in document [:symbols sid :frames])
ids (into #{} (mapcat :nodes)
(filter #(= sid (:symbol %)) (vals (:features document))))
ids (cond-> ids head? (conj :head))
paths (for [id ids [path c] (get-in document [:symbols sid :nodes id :channels])]
[id path c])]
(cond
(not (and (integer? frames) (every? integer? [from through donor])
(<= 0 from through (dec frames)) (<= 0 donor (dec frames))))
{:refused "choose whole face frames within this symbol"}
(<= from donor through) {:refused "choose a clean donor outside the repair interval"}
(empty? paths) {:refused "this symbol has no tracked face features"}
(not-any? (fn [[_ path c]]
(and (= path [:geom :pts])
(not (ch/nothing? (ch/value-at (dissoc c :repairs :over) donor store)))))
paths)
{:refused "the donor has no face pose; choose another frame"}
:else
(finish (reduce (fn [doc [node path _]]
(update-in doc [:symbols sid :nodes node :channels path :repairs]
(fnil conj []) (select-keys spec [:id :from :through :donor])))
document paths) nil))))
(declare remove-eye-keys)
(defn remove-repair [document sid repair-id]
{:clip (reduce (fn [doc [id path]]
(update-in doc [:symbols sid :nodes id :channels path :repairs]
#(vec (remove (fn [r] (= repair-id (:id r))) %))))
(-> document
(remove-eye-keys sid repair-id :l) :clip
(remove-eye-keys sid repair-id :r) :clip)
(for [[id n] (get-in document [:symbols sid :nodes])
[path c] (:channels n) :when (:repairs c)] [id path]))})
(defn eye-key
"Key a procedural lid adjustment and gaze offset over one repair interval."
[document sid repair-id side frame {:keys [opening gaze-x gaze-y]}]
(let [outer (keyword (str "eye-" (name side)))
inner (keyword (str "eye-" (name side) "-in"))
iris (keyword (str "iris-" (name side)))
repair (some #(when (= repair-id (:id %)) %)
(get-in document [:symbols sid :nodes outer :channels [:geom :pts] :repairs]))
{:keys [from through]} repair
layer-id (str repair-id "/eye/" (name side))
edits [[outer [:geom :pts] :eye-opening opening 1]
[inner [:geom :pts] :eye-opening opening 1]
[iris [:xform :pos] :offset [gaze-x gaze-y] [0 0]]]]
(cond
(nil? repair) {:refused "this eye has no such repair interval"}
(not (and (integer? frame) (<= from frame through)))
{:refused "move the playhead inside this repair interval"}
(not (and (every? finite? [opening gaze-x gaze-y]) (<= 0 opening 3)))
{:refused "eye opening must be between 0 and 3; gaze offsets must be finite"}
:else
(finish
(reduce
(fn [doc [id path op value neutral]]
(update-in doc [:symbols sid :nodes id :channels path :over]
(fn [layers]
(let [existing (some #(when (= layer-id (:id %)) %) layers)
values (or (:values existing)
(ch/keyed {from neutral through neutral} :linear))
layer (ch/layer layer-id [from (inc through)] op
(assoc-in values [:keys frame] value))]
(conj (vec (remove #(= layer-id (:id %)) layers)) layer)))))
document edits)
nil))))
(defn remove-eye-keys [document sid repair-id side]
(let [layer-id (str repair-id "/eye/" (name side))]
{:clip (reduce (fn [doc [id path]]
(update-in doc [:symbols sid :nodes id :channels path :over]
#(vec (remove (fn [l] (= layer-id (:id l))) %))))
document
(for [[id n] (get-in document [:symbols sid :nodes])
[path c] (:channels n) :when (:over c)] [id path]))}))

View file

@ -0,0 +1,47 @@
(ns arthur.domain.crc32
"CRC-32, as PNG chunks and ZIP entries both define it.
ONE implementation for both, and that is not premature sharing: a PNG chunk's
trailing checksum and a ZIP local header's `crc-32` field are the same function
of the same bytes — IEEE 802.3, reflected, with an initial and final complement
— down to the polynomial. Two copies would be two chances to get the table
wrong in a way that reads as \"the file is corrupt\" rather than as \"these two
functions disagree\".
It lives beside `domain/sha256` for the same reason that one does: a digest is
a pure function of bytes with no DOM in it, so every assertion about it runs
under node.")
(def ^:private table
;; The standard 256-entry table, built once. The bit-twiddling loop IS the
;; definition of the polynomial and there is no collection idiom hiding in it:
;; each entry is eight dependent shifts of one accumulator.
(let [t (js/Uint32Array. 256)]
(dotimes [n 256]
(aset t n (loop [c n k 0]
(if (= k 8)
c
(recur (if (odd? c)
(bit-xor 0xedb88320 (unsigned-bit-shift-right c 1))
(unsigned-bit-shift-right c 1))
(inc k))))))
t))
(defn of
"CRC-32 of a byte array, or of the half-open range [from to) of one, as an
unsigned 32-bit number.
`loop` over the bytes rather than a reduce over a `range`: this walks the whole
of every PNG written, which at 1920x1200 is seven megabytes a frame, and a seq
cell per byte is the allocation the rest of this codebase is arranged to
avoid."
([bytes] (of bytes 0 (.-length bytes)))
([bytes from to]
(-> (loop [c 0xffffffff i from]
(if (>= i to)
c
(recur (bit-xor (aget table (bit-and (bit-xor c (aget bytes i)) 0xff))
(unsigned-bit-shift-right c 8))
(inc i))))
(bit-xor 0xffffffff)
(unsigned-bit-shift-right 0))))

View file

@ -0,0 +1,67 @@
(ns arthur.domain.creation
"Resolve where a new thing goes from the primary selection and playhead.
This namespace owns no editor state. A row address is a preference; walking
the occurrence path at one frame answers which preferred or enclosing symbol
is actually available. See `docs/creating-in.md`."
(:require [arthur.domain.clip :as clip]
[arthur.domain.nest :as nest]
[arthur.domain.node :as node]
[arthur.domain.symbol :as symbol]))
(defn- path-node
"The node at the end of an occurrence `path`, walked from `open`.
Selection also carries an owner sid and node id for commands that edit the
node directly. Those are deliberately not used here: the path is the address
of the row as seen from the open symbol, and is the only part that describes
every enclosing occurrence at arbitrary depth."
[document open path]
(loop [sid open [id & more] (seq path) found nil]
(if-not id
found
(when-let [n (get-in document [:symbols sid :nodes id])]
(if (seq more)
(when-let [inner (and (= :instance (:kind n)) (node/source n))]
(recur inner more n))
n)))))
(defn preferred-path
"The container path structurally implied by `selection`.
Selecting an instance means inside it. Selecting any other node means its
containing symbol. An empty or non-node selection means the open symbol."
[document open selection]
(let [[kind _sid id selected-path] selection
path (when (= :node kind)
(vec (or (seq selected-path) (when id [id]))))
selected (path-node document open path)]
(cond
(empty? path) []
(= :instance (:kind selected)) path
:else (vec (butlast path)))))
(defn target
"The nearest creation context available at `frame` of `open`.
Every non-empty candidate is validated by `nest/inside`, so every enclosing
symbol occurrence must be under the playhead in the current context. Walking
outward stops at the first valid symbol. A lane needs no cel at that frame,
but the occurrence chain that reaches the lane must still be valid. The empty
path always resolves to the open symbol.
A tracing symbol is never one: it holds a picture to draw over and no nodes,
so selecting a tracing layer creates beside it, as selecting a shape does.
Returns `{:kind :lane|:symbol :sid :path :frame :matrix :time}`."
[document store open selection frame]
(let [preferred (preferred-path document open selection)]
(some (fn [path]
(when-let [inside (nest/inside document store open path frame)]
(when-let [sid (and (:sid inside)
(not (clip/trace? (clip/symbol document (:sid inside))))
(:sid inside))]
(assoc inside :path path
:kind (if (symbol/lane? (clip/symbol document sid))
:lane :symbol)))))
(take (inc (count preferred)) (iterate pop preferred)))))

View file

@ -0,0 +1,88 @@
(ns arthur.domain.cut
"The eraser's cut: a shape's ring with a stroke taken out of it.
Illustrator's and Flash's eraser, not a raster one: what is left is the
SHAPE, with the cut edge made of its own points — points the pen moves, keys
and tweens like any others. A cut through the middle leaves two pieces, and
a cut inside it leaves a hole, which is bridged into the one ring as a brush
stroke's is (see `outline`).
In stage pixels, on both sides: the preview cuts the rings it is about to
draw, and the saved cut cuts the same rings and takes the answer back into
the shape's own coordinates — one function, so the preview is the result."
(:require ["polygon-clipping" :as clipping]
[arthur.domain.channel :as channel]
[arthur.domain.nest :as nest]
[arthur.domain.node :as node]
[arthur.domain.outline :as outline]
[arthur.domain.paint :as paint]))
(defn- area [ring]
(let [ps (vec (partition 2 ring)) n (count ps)]
(js/Math.abs (/ (reduce + (map (fn [i] (let [[ax ay] (ps i) [bx by] (ps (mod (inc i) n))]
(- (* ax by) (* bx ay))))
(range n)))
2))))
(defn cut
"Ring `ring` with `cutters` — each `[outer & holes]` — taken out of it: one
ring per piece left, biggest first, an empty vector when nothing is left, or
nil when the cut does not touch it."
[ring cutters]
(let [shape #js [(outline/->js ring)]
knife (into-array (map #(into-array (map outline/->js %)) cutters))]
(when (seq (array-seq (clipping/intersection shape knife)))
(->> (array-seq (clipping/difference shape knife))
(map (fn [^js poly] (mapv outline/->ring (array-seq poly))))
(sort-by (comp - area first))
(mapv outline/join)))))
(defn- through [m pts]
(let [out (js/Float64Array. 2)]
(into [] (mapcat (fn [[x y]] (node/apply-pt! out 0 m x y) [(aget out 0) (aget out 1)]))
(partition 2 pts))))
(defn erase
"`clip` with `cutters`, in the stage pixels of symbol `open` at frame `f`,
cut out of each shape at row path in `paths`, on the frame each is showing.
The shape keeps the biggest piece, on a key at that frame — made there if
the frame had none, so the keys either side keep their points. Every other
piece is a new shape of the same colour, its id the next of `ids`. A shape
cut away entirely is deleted.
TWO SPACES COME BACK OUT, and they are not the same one. The piece the shape
KEEPS is written into that shape's own geometry, so it comes back through
`world⁻¹`, the node's own coordinates. Every other piece becomes a NEW node
beside it, whose points are read against its own fresh transform, so those come
back through `parent⁻¹` — the space a node's `pos` lives in, which is what
`paint/new-shape` takes and centres.
Both were `world⁻¹` before, and that was wrong for the new pieces by exactly
the cut shape's own transform. It could not be seen while every drawing had an
identity transform, which was true of all of them for as long as a stroke was
stored exactly as drawn: the two spaces coincided, so erasing a shape nobody had
moved worked, and erasing one somebody had moved scattered the offcuts."
[clip store open f paths cutters ids]
(first
(reduce
(fn [[clip ids] path]
(let [{:keys [sid id frame world parent]} (nest/placement clip store open path f)
n (get-in clip [:symbols sid :nodes id])
geom (get-in n [:channels paint/geometry])
inv (when world (node/invert world))
up (when parent (node/invert parent))
left (when (and inv up geom)
(cut (through world (channel/value-at geom frame store)) cutters))]
(cond
(nil? left) [clip ids]
(empty? left) [(nest/delete-node clip sid id) ids]
:else
(let [[keep & more] left
colour (channel/value-at (get-in n [:channels [:style :color]]) frame store)
kept (-> (if (contains? (:keys geom) frame) clip (paint/add-key clip sid id frame))
(paint/set-points sid id frame (through inv keep)))]
[(reduce (fn [c [nid pts]] (paint/new-shape c sid nid frame (through up pts) colour))
kept (map vector ids more))
(drop (count more) ids)]))))
[clip ids] paths)))

View file

@ -0,0 +1,98 @@
(ns arthur.domain.feature
"Tracked subjects, feature ownership, and eye-pair settings.
Features name their symbol explicitly; node ids are local to that symbol."
(:require [arthur.domain.params :as params]))
(defn owned
"A deterministic clip-level feature or group id. Nodes keep local names."
[subject role]
(keyword (subs (str subject) 1) (name role)))
(defn group-for [clip feature-id]
(first (filter (fn [[_ group]] (some #{feature-id} (:members group)))
(:groups clip))))
(defn effective-params
"Resolve static settings for one feature. A future parameter channel can
replace a scalar at this boundary without changing feature or pair identity."
[clip feature-id]
(let [{:keys [subject area params] :as feature} (get-in clip [:features feature-id])
[_ group] (group-for clip feature-id)]
(when-not feature
(throw (ex-info "unknown feature" {:feature feature-id})))
(merge (params/for-area :subject)
(get-in clip [:subjects subject :params])
(params/for-area area)
(:params group)
params)))
(defn remove-from-pair
"Keep the eye's current settings when its association is removed. Empty pairs
are removed; a one-eye pair remains valid and can acquire a partner later."
[clip feature-id]
(if-let [[group-id group] (group-for clip feature-id)]
(let [area (get-in clip [:features feature-id :area])
values (select-keys (effective-params clip feature-id)
(keys (params/for-area area)))
members (vec (remove #{feature-id} (:members group)))]
(-> clip
(assoc-in [:features feature-id :params] values)
(update :groups (fn [groups]
(if (seq members)
(assoc-in groups [group-id :members] members)
(dissoc groups group-id))))))
clip))
(defn problems
"Check tracked identities and symbol-local node ownership."
[clip]
(let [subjects (:subjects clip)
features (:features clip)
groups (:groups clip)
memberships (mapcat (comp :members val) groups)
node-owners (for [[_ f] features n (:nodes f)]
[(:symbol f) n])]
(vec
(concat
(for [[id s] subjects :when (not= id (:id s))]
(str "subject " (pr-str id) " has a different :id"))
(for [[id s] subjects
:when (not (params/valid-settings? :subject (or (:params s) {})))]
(str "subject " (pr-str id) " has invalid settings"))
(for [[id _] subjects
:when (not (seq (get-in clip [:symbols id :nodes :head :measured])))]
(str "subject " (pr-str id) " has no measured head in its symbol"))
(for [[id f] features :when (not= id (:id f))]
(str "feature " (pr-str id) " has a different :id"))
(for [[id f] features :when (not (contains? subjects (:subject f)))]
(str "feature " (pr-str id) " has no subject"))
(for [[id f] features :when (not (contains? (disj params/areas :subject) (:area f)))]
(str "feature " (pr-str id) " has an unknown area"))
(for [[id f] features
:when (not (params/valid-settings? (:area f) (or (:params f) {})))]
(str "feature " (pr-str id) " has invalid settings for " (pr-str (:area f))))
(for [[id f] features
:when (not (contains? (:symbols clip) (:symbol f)))]
(str "feature " (pr-str id) " names a missing symbol"))
(for [[id f] features node-id (:nodes f)
:let [owned-nodes (get-in clip [:symbols (:symbol f) :nodes])]
:when (not (contains? owned-nodes node-id))]
(str "feature " (pr-str id) " refers to missing node " (pr-str node-id)))
(for [[id n] (frequencies node-owners) :when (> n 1)]
(str "node " (pr-str id) " belongs to more than one feature"))
(for [[id g] groups :when (not= id (:id g))]
(str "group " (pr-str id) " has a different :id"))
(for [[id g] groups
:when (not (and (= :eye-pair (:kind g))
(<= 1 (count (:members g)) 2)
(= (count (:members g)) (count (distinct (:members g))))))]
(str "group " (pr-str id) " must be an eye pair of one or two distinct eyes"))
(for [[id g] groups
:when (not (params/valid-settings? :eye (or (:params g) {})))]
(str "group " (pr-str id) " has invalid eye settings"))
(for [[id g] groups member (:members g)
:let [f (get features member)]
:when (not (and f (= :eye (:area f)) (= (:subject g) (:subject f))))]
(str "group " (pr-str id) " has an eye from another subject or an unknown feature"))
(for [[id n] (frequencies memberships) :when (> n 1)]
(str "feature " (pr-str id) " belongs to more than one group"))))))

View file

@ -0,0 +1,136 @@
(ns arthur.domain.geom
"2D similarity transforms and temporal smoothing.
A transform is {:s :theta :tx :ty}; a point is {:x :y}. Both stay maps at this
layer: this is the numeric oracle the JS is diffed against, and a faithful
port is worth more here than a fast one. The dense typed-array
representations appear at the freeze boundary, not below it.")
(defn centroid
"Mean of a point set."
[pts]
(let [n (count pts)]
{:x (/ (transduce (map :x) + 0.0 pts) n)
:y (/ (transduce (map :y) + 0.0 pts) n)}))
(defn fit-similarity
"Least-squares similarity (translation + rotation + uniform scale, 4 DOF)
mapping P onto Q. Closed form; no iteration.
Deliberately NOT affine or homography: the extra degrees of freedom absorb
out-of-plane head rotation as shear/perspective and smear it into the mouth.
Four DOF removes exactly translation, roll and depth-scale, and leaves yaw and
pitch as a measurable residual."
[P Q]
(let [n (count P)
cp (centroid P)
cq (centroid Q)
;; Dot, cross and squared norm of the centred configurations, in one
;; pass. Reduced in input order, so the floating-point result is bit for
;; bit what an index loop would give and the 1e-9 parity against the JS
;; holds.
[a b norm]
(reduce (fn [[a b norm] [p q]]
(let [px (- (:x p) (:x cp)) py (- (:y p) (:y cp))
qx (- (:x q) (:x cq)) qy (- (:y q) (:y cq))]
[(+ a (+ (* px qx) (* py qy))) ; dot
(+ b (- (* px qy) (* py qx))) ; cross
(+ norm (+ (* px px) (* py py)))]))
[0.0 0.0 0.0]
(map vector P Q))
pcx (:x cp) pcy (:y cp)
qcx (:x cq) qcy (:y cq)
theta (js/Math.atan2 b a)
;; A degenerate configuration has nothing to recover a scale from. Fall
;; back to 1 rather than dividing by zero: one bad detection frame would
;; otherwise poison the Procrustes mean and therefore every frame.
s (if (> norm 1e-12) (/ (js/Math.hypot a b) norm) 1)
c (js/Math.cos theta)
sn (js/Math.sin theta)]
{:s s
:theta theta
:tx (- qcx (* s (- (* c pcx) (* sn pcy))))
:ty (- qcy (* s (+ (* sn pcx) (* c pcy))))}))
(defn apply-sim [tf p]
(let [c (js/Math.cos (:theta tf))
sn (js/Math.sin (:theta tf))]
{:x (+ (* (:s tf) (- (* c (:x p)) (* sn (:y p)))) (:tx tf))
:y (+ (* (:s tf) (+ (* sn (:x p)) (* c (:y p)))) (:ty tf))}))
(defn apply-sim-all [tf pts]
(mapv #(apply-sim tf %) pts))
(defn fit-residual
"Residual RMS after the fit, in the units of Q. Rises with out-of-plane
rotation, so it is the signal for \"this section is not stabilisable\"."
[tf P Q]
(let [sq (fn [d] (* d d))]
(js/Math.sqrt
(/ (transduce (map (fn [[p q]]
(let [m (apply-sim tf p)]
(+ (sq (- (:x m) (:x q)))
(sq (- (:y m) (:y q)))))))
+ 0.0 (map vector P Q))
(count P)))))
(defn procrustes-mean
"Generalised Procrustes: the reference is the MEAN rigid configuration over the
shot, not frame zero, so no single frame's idiosyncrasies get baked into every
other frame. Three passes is plenty."
([frames-rigid] (procrustes-mean frames-rigid 3))
([frames-rigid iters]
(let [n (count frames-rigid)
;; One pass: fit every frame onto the current reference, sum the
;; aligned configurations, divide. Iterative refinement, so the whole
;; thing is `iterate` taken `iters` deep — which is what the algorithm
;; actually says, rather than a counter that happens to stop.
refine (fn [ref]
(->> frames-rigid
(reduce (fn [acc rig]
(let [moved (apply-sim-all (fit-similarity rig ref) rig)]
(mapv (fn [a m] {:x (+ (:x a) (:x m))
:y (+ (:y a) (:y m))})
acc moved)))
(mapv (constantly {:x 0.0 :y 0.0}) ref))
(mapv (fn [p] {:x (/ (:x p) n) :y (/ (:y p) n)}))))]
(-> (iterate refine (mapv (fn [p] {:x (:x p) :y (:y p)}) (first frames-rigid)))
(nth iters)))))
(defn moving-average
"`radius` is in frames either side: 0 is off, 1 averages over 3 frames, 2 over
5. Expressed as a radius rather than a window so that \"off\" is 0 and every
value is symmetric - an even window would be lopsided in time."
[vals radius]
(if (<= radius 0)
(vec vals)
(let [v (vec vals)
n (count v)
half (js/Math.floor radius)]
(mapv (fn [i]
;; Clamped at the ends rather than shortened, so every output is an
;; average of the same COUNT of samples and the first frame is not
;; noisier than the rest.
(let [lo (- i half) hi (+ i half)]
(/ (reduce + (map (fn [j] (nth v (min (dec n) (max 0 j))))
(range lo (inc hi))))
(inc (- hi lo)))))
(range n)))))
(defn smooth-transforms
"Smooth the four transform parameters, NEVER the contour. Landmark jitter of a
pixel is smeared into the mouth by the inverse transform, so the transform is
where the low-pass belongs; smoothing the contour would destroy the
performance, which is the entire asset.
Angles are smoothed as (cos, sin) so wrapping cannot produce a spike."
[tfs radius]
(let [c (moving-average (map #(js/Math.cos (:theta %)) tfs) radius)
sn (moving-average (map #(js/Math.sin (:theta %)) tfs) radius)
s (moving-average (map :s tfs) radius)
tx (moving-average (map :tx tfs) radius)
ty (moving-average (map :ty tfs) radius)]
(mapv (fn [i] {:theta (js/Math.atan2 (nth sn i) (nth c i))
:s (nth s i)
:tx (nth tx i)
:ty (nth ty i)})
(range (count tfs)))))

View file

@ -0,0 +1,384 @@
(ns arthur.domain.gesture
"Moving, turning and scaling a node by hand on the stage, as channel values.
A drag says where the pointer went in stage pixels; this says what that makes
the node's `[:xform :pos]`, `[:xform :rot]`, `[:xform :scale]` or
`[:xform :pivot]`, given its `nest/placement`. Whatever is above the node —
instances, parents, a `:pinv` — is in the placement's matrices, so a shape five
symbols down moves under the pointer like one on top.
A GESTURE TURNS AND SCALES ABOUT THE NODE'S OWN PIVOT, and nothing here solves
for a position to fake one with. `node/local!` composes the rotation and the
scale about `[:xform :pivot]`, so a turn is `rot` alone, ALWAYS — one channel,
one key, and the pivot held exactly on every frame between two keys because the
matrix is built about it on every frame. This is Toon Boom's layer pivot and
Flash's transformation point, and the reason both store one.
A PIVOT NOBODY HAS CHOSEN IS THE MIDDLE OF WHAT THE NODE DRAWS, and the first
turn or scale WRITES IT DOWN — `pivot` derives it from `pick/bounds-of` on the
frame the drag starts, and `with-pivot` turns that into a `[:xform :pivot]` and
the `[:xform :pos]` that leaves the picture exactly where it is. After that it
is an ordinary stored, keyable, draggable channel, and the derived value is
never consulted again: a pivot is a CHOICE, and re-deriving it per drag means
editing a symbol silently moves what its instances turn about.
WHAT THIS REPLACES, because it was a deletion that cost a feature. There used
to be no pivot in the decomposition at all: a drag derived the middle of the
drawing, and `about` solved for the `pos` that holds that point still under the
new angle. That solution is an ARC in the angle while `pos` interpolates along
the CHORD, so it is exact on the frame it is written and WRONG EVERYWHERE
BETWEEN TWO KEYS. A drawing escaped it, because `paint/centred` puts a shape's
origin on the middle of what it draws and the correction is then nil — but a
SYMBOL INSTANCE cannot: its origin is its symbol's, and a symbol is drawn on the
stage, so its origin is the stage's top-left corner. One keyed turn of an
instance therefore swung its drawing round the corner of the stage on an orbit
the size of the stage. The answer at the time was a peg, and a peg is a real
thing — it is how a pivot is SHARED, or put over a measured transform — but it
is not something anybody should have to make in order to spin a drawing.
`about` is still here and is still that equation, for the one gesture that
genuinely has a pivot belonging to no node: a MULTI-SELECTION scaling about the
middle of its shared box. Nobody keys that.
The normal keying rule is `node/set-channel`, the inspector's: a channel with
keys gets one on the node's own frame, and one without has its one value
changed. Auto-key deliberately replaces that rule with `set-keyed-channel`,
so touching an otherwise static transform starts its animation at this frame."
(:require [arthur.domain.channel :as ch]
[arthur.domain.node :as node]))
(defn values
"Node `n`'s transform on its own frame `f`, as vectors.
`store` IS NOT OPTIONAL, though `ch/value-at` would let it be. A measured
transform is a dense channel, and a dense channel read without the tier-2
store it names throws — so leaving it off read correctly for every hand-placed
node and crashed the stage the moment a selection landed on an iris, a brow or
a head. Those are not hard to land on: `pick/choose` keeps a selection at the
depth it already has, so once anything inside a face is selected, an ordinary
click beside it selects its neighbour — which near the eyes is an iris."
[n f store]
(let [at #(ch/value-at (get (node/channels n) [:xform %]) f store)
xy #(let [v (at %)] [(ch/component v 0) (ch/component v 1)])]
{:pos (xy :pos) :pivot (xy :pivot) :rot (at :rot)
:scale (xy :scale) :skew (xy :skew)
;; WHETHER THE NODE HAS A PIVOT OF ITS OWN, and not what it is: a node
;; with no `[:xform :pivot]` channel reads `[0 0]` off `node/defaults`,
;; which is a real pivot — a drawing's own middle — and also what a node
;; nobody has pivoted yet looks like. The two have to be told apart
;; exactly once, when a drag decides whether to write the derived middle
;; down; see `pivot` and `with-pivot`.
:chosen? (contains? (:channels n) [:xform :pivot])}))
(defn- measured-channel? [n path]
(let [c (get-in n [:channels path])]
(boolean (or (:dense c) (:generated c)))))
(defn refusal
"Why `kind` cannot transform node `n`, or nil. Measured position and rotation
accept authored correction layers; measured scale cannot yet be decomposed
safely. The one-argument form asks whether any stage gesture is possible."
([n] (when (node/measured? n)
"its transform is measured — use a correction, or put a peg over it"))
([n kind]
(when (and (= :scale kind) (measured-channel? n [:xform :scale]))
"its scale is measured — put a peg over it and scale that")))
(defn- through [m [x y]]
(let [out (js/Float64Array. 2)]
(node/apply-pt! out 0 m x y)
[(aget out 0) (aget out 1)]))
(defn linear
"The node's own linear part, `R(rot)·K(skew)·S(scale)`, as a 2x3 whose
translation is zero — so putting a point through it applies the rotation, skew
and scale and nothing else. `node/local!` with a zero `pos` rather than a
second closed form, so there is one place the decomposition is written out."
[{:keys [rot scale skew]}]
(node/local! (node/mat) [0 0] [0 0] rot scale skew))
(defn local-of
"The node's whole local transform, `T(pos)·T(piv)·R·K·S·T(-piv)` — what takes a
point in the node's own coordinates to its parent's."
[{:keys [pos pivot rot scale skew]}]
(node/local! (node/mat) pos pivot rot scale skew))
(defn about
"The `pos` that keeps parent-space point `c` still while the node's linear part
changes from `v`'s to `v'`'s. Nil when `v`'s is singular — a node scaled to
nothing has no point under `c` to hold.
FOR A PIVOT THAT IS NO NODE'S, which since the pivot went into the
decomposition is one gesture and only one: a multi-selection scaling about the
middle of its shared box, where every member has to move to keep the
arrangement and none of them owns the point. `turn` and `scale` do not call
this, and the namespace docstring says why — the position it solves for is an
ARC in the angle and `pos` tweens along the CHORD, so it is exact on the frame
it is written and wrong between two keys.
Local is `T(t)·M` with `t = pos + a − M·a`, the composed translation. The
material point sitting under `c` is `q = M⁻¹(c − t)`, holding it there under the
new `M'` wants `t' = c − M'·q`, and the `pos` that composes to that `t'` is
p' = t' − a + M'·a = c − M'·(q − a) − a
Checkable at both ends it has to be right at: with `c` the node's own pivot
point, `c = pos + a`, so `q = a` and `p' = c − a = pos` — turning about your own
pivot never moves you. And with no pivot at all, `a = 0`, it is the familiar
`p' = c − M'·M⁻¹(c − p)`."
[v v' c]
(let [m (linear v)
l (local-of v)
t [(aget l 4) (aget l 5)]
a (:pivot v)]
(when-let [inv (node/invert m)]
(let [q (through inv (mapv - c t))]
(mapv - (mapv - c (through (linear v') (mapv - q a))) a)))))
(defn middle
"The middle of `bounds` — what the node draws, in its own coordinates — through
its own transform, so a parent-space point; or its own origin when it draws
nothing. What a node nobody has pivoted yet turns about."
[v bounds]
(let [[x0 y0 x1 y1] bounds]
(through (local-of v)
(if bounds [(/ (+ x0 x1) 2) (/ (+ y0 y1) 2)] [0 0]))))
(defn pivot
"The parent-space point a drag on this node turns and scales about: ITS OWN
PIVOT, `pos + piv` — or, for a node nobody has pivoted yet, the middle of
`bounds`, what it draws in its own coordinates, through its own transform.
THE CHOSEN ONE WINS, and that is the whole of the rule. A pivot is where
somebody put it: it does not follow the drawing afterwards, any more than
Flash's transformation point or a Harmony layer's pivot does, because a turn
that quietly changes its centre when a symbol is edited is worse than one
sitting somewhere a hand can see and move it. `ui/stage` draws the cross here
and `::ui/repivot` drags it.
THE DERIVED ONE IS A DEFAULT AND NOT A BEHAVIOUR. `pick/bounds-of` on the same
frame is where the bounds come from, so the cross and the selection box start
out as one computation — and the first turn or scale WRITES IT DOWN, which is
`with-pivot`. A `:group` draws nothing, so `bounds` is nil and this is its own
origin, which is where a peg was put.
`middle` is the derived half on its own, for `centred` — which is the way back
to it once a pivot HAS been chosen."
[v bounds]
(if (:chosen? v) (mapv + (:pos v) (:pivot v)) (middle v bounds)))
(defn at-pivot?
"Is parent-space point `c` where the node already pivots?
WITHIN A MILLIONTH OF A PIXEL, because this asks a question about intent and
gets an answer in floating point: `paint/centred` subtracts the middle of a
ring from its own points, so re-deriving that middle from the result lands on
zero to within the rounding of the subtraction, a part in 1e14 of the
coordinates. A point that close to the pivot IS the pivot — there is no gesture
in which a millionth of a pixel is a pivot somewhere else — and the whole point
of asking is to leave a drawing's channels alone: a pivot of `[0 0]` written
onto a shape that already turns about its own middle is a key nobody asked for
on a value that was already right."
[v c]
(let [[dx dy] (mapv - c (mapv + (:pos v) (:pivot v)))]
(< (js/Math.hypot dx dy) 1e-6)))
(defn repivot
"The channel values that put the node's pivot on parent-space point `c` WITHOUT
MOVING THE PICTURE, or nil when its linear part is singular.
Local is `T(pos)·T(a)·M·T(-a)`, so the new pivot has to be the material point
that is under `c` now, and the new position has to put it there:
a' = a + M⁻¹(c − (pos + a)) the point under c, in the node's own space
p' = c − a' so that p' + a' = c
and then `p' + a' + M(x − a')` is `pos + a + M(x − a)` for EVERY x, which is
the \"moving nothing\" in the first line, exact and not to a tolerance.
WITH NO ROTATION OR SCALE IT DOES NOT TOUCH `pos` AT ALL — `M = I` gives
`a' = c − pos` and `p' = pos` — which is the ordinary case of setting a pivot up
before animating, and is why this can be done quietly inside a first turn
without starting a position channel nobody asked for.
EXACT ON THE FRAME IT IS WRITTEN. `M` is this frame's, so on a node whose
rotation or scale is already keyed, the compensation that holds the picture
still here is not the one that would hold it still three frames later. That is
not an artefact of the arithmetic: moving a pivot genuinely changes what the
keyed angles mean. Flash and Harmony both let you do it and both move the
in-betweens; the alternative is refusing to repivot anything already animated,
which is the node a pivot is most often wrong on."
[v c]
(when-let [inv (node/invert (linear v))]
(let [a' (mapv + (:pivot v) (through inv (mapv - c (mapv + (:pos v) (:pivot v)))))]
{[:xform :pivot] a'
[:xform :pos] (mapv - c a')})))
(defn centred
"The channel values that put the node's pivot back on the middle of what it
draws NOW, moving nothing. Nil when its linear part is singular.
THE WAY BACK, and the thing a stored pivot needs in order to be safe to store.
A pivot does not follow the drawing — that is the point of storing it, since a
keyed spin must not be re-aimed by someone drawing one more shape inside the
symbol — but a drawing does grow, and \"put it back in the middle of what is
there now\" is then an obvious thing to want and an unobvious thing to do by
hand. It is `repivot` at the point `pivot` would have derived, so the button
and the default cannot disagree: this is exactly where an untouched node's
cross already is."
[v bounds]
(repivot v (middle v bounds)))
(defn- with-pivot
"`[v vs]`: the node's transform with its pivot on parent-space `c`, and the
channel values that put it there — or `v` untouched and nil, when that is where
it pivots already or when it has a pivot of its own.
THE ONE PLACE A DERIVED PIVOT BECOMES A STORED ONE. `turn` and `scale` both
start here, so the first drag on a node nobody has pivoted writes the middle of
what it draws down with the gesture, in the same edit, and every drag after it
turns about the stored one. The returned `v` carries the new pivot, because the
gesture itself is measured about it: scaling about the pivot it is in the act of
choosing is one answer, not two."
[v c]
(if (and c (not (:chosen? v)) (not (at-pivot? v c)))
(if-let [vs (repivot v c)]
[(assoc v :pivot (get vs [:xform :pivot]) :pos (get vs [:xform :pos])) vs]
[v nil])
[v nil]))
(defn move
"The node's position with the drag carried from stage point `p0` to `p1`.
ONE CHANNEL, AND IT NEVER DISTURBS THE PIVOT: `[:xform :pivot]` is in the
node's own coordinates, so it travels with the node and a move is `pos` alone,
exactly as it was before there was a pivot at all."
[{:keys [parent]} {:keys [pos]} p0 p1]
(when-let [inv (node/invert parent)]
{[:xform :pos] (mapv + pos (mapv - (through inv p1) (through inv p0)))}))
(defn angle
"The angle of stage point `p` about parent-space pivot `c`, in the space the
node's rotation is in."
[{:keys [parent]} c p]
(when-let [inv (node/invert parent)]
(let [[x y] (mapv - (through inv p) c)]
(js/Math.atan2 y x))))
(defn turn
"The node turned by `da` radians about parent-space point `c`: the rotation, and
— only on the first turn of a node nobody has pivoted — the pivot and position
that put its pivot on `c` without moving it.
ONE CHANNEL, ONCE THE PIVOT IS ITS OWN, and that is the whole reason the pivot
is in `node/local!` rather than solved for here. `rot` is the only thing a turn
changes, so a keyed turn interpolates ONE number and the matrix is composed
about the pivot on every frame of it: the pivot is held exactly between two keys
and not merely at them. A `pos` written beside the rotation — which is what
`about` solves for, and what this used to do for anything whose origin was not
its middle — tweens along the chord of an arc it has no way to know about, and a
360° turn keyed that way leaves the stage in the middle and comes back.
`c` IS A DEFAULT, NOT A TARGET. A node with its own pivot ignores it and turns
about what it has, which is what makes a pivot something a hand can place and
rely on; `pivot` is where `c` comes from either way."
[v c da]
(let [[v vs] (with-pivot v c)]
(merge vs {[:xform :rot] (+ (:rot v) da)})))
(defn scale
"The node's scale with the point under stage `p0` taken to `p1`, about its own
pivot, along the node's own axes — or by the same factor on both when
`uniform?` — and, on the first scale of a node nobody has pivoted, the pivot and
position that put its pivot on `c` without moving it.
The factors are measured in the node's OWN coordinates, which is what makes a
corner drag track the pointer on a node that has been turned, and they are
measured FROM THE PIVOT `q`: scaling by `k` takes `q + d` to `q + k·d`, so the
factor a corner wants is the ratio of its offsets from the pivot before and
after. `with-pivot` runs first because a pivot being chosen by this very drag is
the pivot the drag has to be measured about."
[{:keys [world]} v c p0 p1 uniform?]
(let [[v vs] (with-pivot v c)]
(when-let [winv (node/invert world)]
(let [q (:pivot v)
a (mapv - (through winv p0) q)
b (mapv - (through winv p1) q)
k (fn [a b] (if (< (js/Math.abs a) 1e-6) 1 (/ b a)))
r (when uniform?
(let [aa (reduce + (map * a a))]
(if (< aa 1e-9) 1 (/ (reduce + (map * a b)) aa))))
s (:scale v)
s' (if r (mapv #(* r %) s) (mapv * s (map k a b)))]
(merge vs {[:xform :scale] s'})))))
(defn scale-by
"The node scaled by factor `k` on both axes about parent-space point `c`, and
the position that holds `c` still.
What a MULTI-SELECTION scales by: one factor for everything about the shared
box, so a group of shapes keeps its arrangement instead of each member solving
for its own factors. The point belongs to the box and not to any node in it, so
this is the one gesture that still writes a position to hold a pivot still —
`about`, with everything its docstring says that costs. `scale` is the
single-node form, where the factors come out of the pointer in the node's own
axes and the pivot is the node's own."
[v c k]
(let [v' (update v :scale #(mapv (partial * k) %))]
(cond-> {[:xform :scale] (:scale v')}
c (into (when-let [p (about v v' c)] {[:xform :pos] p})))))
(def ^:private manual-layer ::manual-transform)
(defn- plus [a b]
(if (number? a) (+ a b) (mapv + (vec a) (vec b))))
(defn- minus [a b]
(if (number? a) (- a b) (mapv - (vec a) (vec b))))
(defn- corrected-channel
"Set the effective value of a measured channel without replacing its base."
[channel f target store extent auto-key?]
(let [current (ch/value-at channel f store)
layers (vec (:over channel))
i (first (keep-indexed #(when (= manual-layer (:id %2)) %1) layers))
old (when i (ch/value-at (get-in layers [i :values]) f store))
zero (if (number? target) 0 (vec (repeat (count target) 0)))
value (plus (or old zero) (minus target current))
values (or (when i (get-in layers [i :values])) (ch/framed zero))
values ((if auto-key? node/set-keyed-channel node/set-channel)
{:kind :group :channels {[:xform :pos] values}}
[:xform :pos] f value)
values (get-in values [:channels [:xform :pos]])
layer (ch/layer manual-layer [0 extent] :offset values)]
(assoc channel :over (if i (assoc layers i layer) (conj layers layer)))))
(defn apply-values
"Clip with channel values `vs`, `{path value}`, written into node `id` of
symbol `sid` on the node's own frame `f`, by the keying rule above. The sixth
argument arms auto-key; the five-argument form retains the normal rule."
([clip sid id f vs] (apply-values clip sid id f vs false nil))
([clip sid id f vs auto-key?] (apply-values clip sid id f vs auto-key? nil))
([clip sid id f vs auto-key? store]
(let [put (if auto-key? node/set-keyed-channel node/set-channel)]
(update-in clip [:symbols sid :nodes id]
#(reduce-kv
(fn [n path v]
(if (measured-channel? n path)
(update-in n [:channels path] corrected-channel f v store
(get-in clip [:symbols sid :frames]) auto-key?)
(put n path f v)))
% vs)))))
(defn apply-take
"Apply a buffered performance take. `take` is keyed by `[symbol node]`, then
local frame, then channel path. It becomes ordinary authored keys in one
document edit rather than making the edit pipeline run for every sample."
([clip take] (apply-take clip take nil))
([clip take store]
(reduce-kv
(fn [c [sid id] frames]
(reduce-kv (fn [c f values]
(apply-values c sid id f values true store))
c frames))
clip take)))

Some files were not shown because too many files have changed in this diff Show more