Commit graph

144 commits

Author SHA1 Message Date
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