14 KiB
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
- The Lane Model — the design, and the status note under
Proof obligations says what is built. It supersedes
animation-model.md,timing-model.mdandarchitecture.mdwherever they overlap. frontend/src/arthur/domain/lane.cljs— every command, and the reasoning in its docstrings.frontend/test/arthur/domain/lane_test.cljs— what the model is asserted to do. It is the fastest way to see the shapes.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.
:framesis 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
blankthen non-rippling placement.lane/overwrite-drawingcomposes 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
:supportand 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/conflictslists the decisions waiting for a person.problemsmeans 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.
- 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.
- Deleting reused content. Reference discovery exists (
node/sources,clip/places,clip/contains-symbol?); the policy does not. - 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.
- Collaboration.
lane-model.mdis 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
:spanis in the node's OWN frames and:timesays where they land in the lane. Moving an edge of a cel is therefore one write to:span, with:timeand:playbackuntouched. 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/localandlane/edgedare the whole geometry; trim, split and blank are all it.lane/finishis the one commit path: it validates, applies the shot-length policy, and returns the refusal. New commands go through it.:required-framesplus 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-frameconverts 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-withis the rule for whether one offset fits a base, used byconflictsand 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/reconcileis 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-orderholds the optimized cursor tovalue-atin forward, backward and random order — add a case to it for any new channel shape. Anddrawninlane_testsamples 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-problemsrefuses a lane holding both kinds, andlane/place-symbolandlane/adoptrefuse 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 bynest/audio-tracks; what is in a lane of the open symbol is drawn as a lane and not flattened twice. :zis required on cels and means nothing there. A lane never has two cels on one frame, so draw order between them cannot matter.node/problemsrequires:zon every node uniformly, which is its own kind of simplicity — but the field is noise on a cel.channel/offset-ontothrows 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, andchannel/problemscatches 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.mdis a separate, unreconciled thread. Performance- pose selection and plate drawings/tracing, instance-specific picture-rate requests,pose/put-cutaddressing 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 appclobbers the dev bundle. Both builds write../static/arthur/js, which Django serves, and the optimized build does not export thearthurglobal — so after a release the browser tests fail withReferenceError: arthur is not defined. Runnpx shadow-cljs compile appto restore it. A runningwatch appdoes 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.