Commit graph

11 commits

Author SHA1 Message Date
Your Name
26ada03591 Unify cel editing in the timeline 2026-10-01 14:37:59 -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
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