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>
219 lines
13 KiB
Markdown
219 lines
13 KiB
Markdown
# 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.
|
|
- **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.
|
|
- **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.
|
|
|
|
## Current timeline interaction
|
|
|
|
- Creating a symbol inside an aimed lane creates a one-frame held clip at the
|
|
playhead. With no aimed lane it first creates a lane. Drawing a polygon uses
|
|
the existing clip there or creates the same one-frame clip when the frame is
|
|
empty.
|
|
- Dropping any library symbol into a lane creates a natural-duration playing
|
|
clip. Dropping it on unclaimed timeline or stage space first creates a lane.
|
|
- 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 remains outside visual lanes.** `symbol/lane-problems` deliberately
|
|
requires symbol instances. Audio is still an independent root node that can
|
|
link to picture; making audio itself lane-based would need an explicit lane
|
|
capability rather than a mixed child rule.
|
|
- **`: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.
|