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>
This commit is contained in:
Your Name 2026-10-01 16:31:06 -04:00
parent 6e7827e03f
commit d029908f0b

View file

@ -123,6 +123,82 @@ 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.
## 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. 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.
**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.
@ -183,7 +259,8 @@ to keep.
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. **The held destination for shift-to-reparent** is still refused —
`nest/inside` has no invertible clock for a hold, which is most of what
anybody would try to nest into. `docs/lane-nesting-notes.md` argues the
refusal is stronger than the facts require and says what would settle it.
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.