arthur/docs/tracing-symbol-plan.md

411 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Plan: tracing is a symbol
Status: built, 2026-10-03, on branch `worktree-tracing-layers`. Where the build
differs from the plan below, the build wins:
- The head's field is `:reads` (`{:holds [...]}` or `{:holds-of :plate}`), not
`:follow`.
- A still is not one frame. It gets as many frames as remain in the symbol it is
dropped into, at that symbol's rate, and is trimmed like any clip.
- An image's `:media` is `{:image <blob sha256>}`, served at `/blob/<sha>`. The
server's `Image` row exists for the pool's list and labels, not for identity.
- Dropping media that a tracing symbol in the document already shows reuses that
symbol.
- Nothing can be created or dropped inside a tracing symbol (`creation/target`,
`drop-destination-at`, `nest/move-refusal`), and one cannot be opened in a tab.
- Schema 6. Every project is marked 6. One that still carries `:trace` on a
node is refused when opened, with what to do about it, rather than all old
projects being refused.
No backward compatibility (see `lane-model.md`, "Goal and compatibility policy").
## What is wrong today
Tracing is not a thing in the document. It is three mechanisms that each know
about faces:
- **`:trace {:frames :origin}` on a face's `:head`.** It decides which measured
frame the head reads (`symbol/base-channel-frame` → `trace/held-frame`), and
the underlay also reads it to decide which photo to show (`trace/photo-frame`).
- **`ui/underlay`**, a painter that walks `trace/shown` → `trace/faces` (a
separate instance walk), looks up each face's subject → analysis → footage,
asks the resolver where `[...path :head]` went, and builds the photo's matrix
by hand: `world(head) · M(p)⁻¹ · 1/imageH` (`trace/photo-matrix`).
- **`[:ui :trace {:faces #{} :opacity}]`**, a per-face switch in editor state,
plus the special "opening a face shows its footage, opening a take does not"
rule (`trace/showing-for`).
What you cannot do: place footage or a still as a reference where you like, move
it, scale it, turn it, trim it, hold it, put it in a lane, or trace something
that is not a tracked face. The photo is not selectable and has no row. The one
case that works (a face over its own footage) runs on code that nothing else
uses.
## The model in one paragraph
A **tracing symbol** is a symbol with `:type :trace`. It has no nodes. It names
media (a footage range or a still image) and has that media's frame count, fps and
pixel size. It is **placed by an ordinary instance**, so lanes, spans, trim,
split, move, playback (`:in :speed :end`), transform, nesting, selection, picking
and gestures all work on it with no new code. It is evaluated to a single
**`:trace` op**, which never reaches the raster or an export. A face's footage is
not a special case. It is one of these instances, placed inside the face as a
child of `:head` and carrying the measured registration transform. The trace keys
become **holds on that instance's time**. The head's "origin" becomes the head
saying **which node's frames it follows**.
This follows the precedent of `:type :palette` symbols (no nodes, they name an
asset, and they are placed by instances in a lane), so the uniformity rule holds
(see the `model-uniformity` memory). Nothing new holds nodes, and no special
instance kind is added.
## Data model
### The tracing symbol
```clojure
:footage-8625 ; a symbol id like any other
{:id :footage-8625 :name "8625.mov"
:type :trace
:media {:footage #uuid "f8ca…" :range [12 241]} ; or {:image "sha256:…"}
:frames 229 ; the range's length; 1 for a still
:fps 30 ; the footage's own rate, so cadence handles 30→12 for free
:width 1440 :height 1920 ; pixel size = the symbol's own stage
:audio {:footage #uuid "f8ca…"} ; optional, the existing "a symbol says what it sounds like"
:nodes {}}
```
- `:media` is the one new symbol key. Add it to `symbol/symbol-keys` and to the
symbol leaf's `select-keys` in `leaf/leaves`.
- The symbol's local space is **pixels of the media**: `[0 w) × [0 h)`.
`clip/center` of a node-less symbol already returns its stage middle, which here
is `[w/2 h/2]`. So `place-symbol` puts the anchor at the image centre with no
new code.
- `symbol/problems`: `:type :trace` needs `:media` with exactly one of
`:footage`/`:image`, needs `(empty? nodes)`, and for footage needs
`(= frames (- end start))`.
- **One tracing symbol per footage range, shared.** Two faces from one take
place the same symbol, and so does a hand-placed reference. Like any symbol it
is reused by reference, and `bring/symbols` copies it like any other.
### Placing it
It is an ordinary `:kind :instance` with `:source {:symbol :footage-8625}`:
- **Start and end** are `:span` (in its own frames) and `:time :at`. You get
these from the existing trim, split, move and roll.
- **Which frame shows** comes from `:playback {:in :speed :end}`. Footage plays
with speed 1, a frozen frame has speed 0, and a still is 1 frame with `:end :hold`.
- **Placement in space** is `[:xform …]`, the same as every node: gestures,
inspector and keys.
- **In a lane, or on its own**: it is a parent-less spanned node, so a `:display
:lane` symbol draws it as a block like any cel. It can also sit as a free node
or under a group. To have a reference with its own tab, wrap it in an ordinary
symbol.
**Default on creation** (in the drop event, not in `place-symbol`): scale so the
image's height fits the stage height, centred on the drop point. This is a
creation default like "use center anchor when dropping", and nothing updates it
afterwards.
### Holds: the one new time feature
`:time {:holds [0 12 30]}` floors a node's local frame to the last hold at or
before it. Before the first hold, the first hold applies. This is `:expose`
generalized from a regular grid to authored frames. It is applied in
`node/local-frame` at the same point as `:expose`, and is inherited in the same
way. It is in the node's **own** frames. On a tracing instance with
`:in 0 :speed 1`, own frames are source frames, so the hold list is the set of
traced frames.
It is general on purpose. Holding a playing symbol on chosen drawings is the
same feature. It costs about three lines in `local-frame`, one `problems` clause
(sorted, distinct, finite), and the timeline drawing hold frames as marks on the
row.
`symbol/frame-map` refuses floors (`:expose > 1`). It must also refuse a
non-empty `:holds`, for the same reason.
## The face: "a child symbol that represents the trace"
The face symbol after a freeze:
```
:place group authored source→stage mapping (image heights → stage px)
:head group measured M(p) :reads — see below
:mouth … parts generated, every frame
:plate instance of :footage-8625 ← the trace
measured channels: fit(p) = M(p)⁻¹ · S(1/imageH)
:time {:holds [0 12 30]} ← the trace keys
```
### Registration comes from the parent
The plate is a child of `:head`. Its own measured channels are the
**stabilizing fit** at frame p: the inverse of the head's measured transform,
with pixels → image heights folded into the scale (it stays a similarity, so it
decomposes into `pos`/`rot`/`scale`). Freeze already computes the fit; the
head's measured channels are its inverse (`freeze/invert`). The plate gets a
second dense block, which is three small channels.
Because holds are a **time** floor, the plate's channels and the frame its
content shows are read at the **same** held frame q. Its world transform is:
```
world(plate) = place · M(p_head) · M(q)⁻¹ · S(1/H)
```
That is exactly `trace/photo-matrix`, but now it falls out of the ordinary walk.
It is registered to whatever the head is doing, by construction:
| head reads (p_head) | plate shows (q) | result |
| --- | --- | --- |
| f (continuous) | f (no holds) | footage where filmed |
| f (continuous) | held key | held photo rides the moving head (today's behaviour) |
| held key (same as plate) | held key | `M(q)·M(q)⁻¹ = I`: photo sits where filmed |
| 0 (start) | f or held | stabilized footage under a still head |
If a frame has no measurement, the plate's dense channel has nothing there, so
`xform-at` gives nil, so the plate is not placed and no photo shows. That is what
happens today too.
### Trace keys versus origin: who owns what
The two decisions are separate and stay separate:
- **Trace keys** are which footage frames get drawn over (the "plate drawings"
in `frame-selection.md`). They belong to the **plate**, as `:time :holds`. A
hand-placed tracing layer with holds is the *same thing*: the face's plate is
an ordinary tracing placement and nothing more.
- **Origin** is how the head moves between kept frames. `frame-selection.md`
already says `:origin` "is not a tracing setting" but a performance one, so it
belongs to the **head**:
```clojure
:head {…} ; continuous — reads its own frame
:head {… :reads {:holds [0]}} ; start
:head {… :reads {:holds-of :plate}} ; at keys — reads its measured channels at
; the frames :plate's holds select
```
`{:holds [...]}` is also what a face with no footage uses for "at keys", since it
has no plate to follow.
`:reads` changes only the **head's own channel reads**, not its children's
frames. The parts must keep running every frame, which is why this cannot be a
`:time` hold on the head. It is today's `traces` branch of
`base-channel-frame` with the hold list read from the named node. A node
reference has precedent (`:stencil`, `:pose-group`). It points from the follower
to the thing followed, so there is still one stored list of frames and nothing to
keep in sync. Validate it in `symbol/problems` the way `:stencil` is validated:
the target exists in the symbol, and it is not the head itself or an ancestor of
the head.
Rejected alternatives, so they are not re-proposed:
- **Keys on the head, and the plate reads them.** This is today's direction. It
makes the plate special: a free tracing layer could not have keys that a face's
plate also understands.
- **Hold `:time` on `:head`.** Exposure inherits strictly, so the mouth and eyes
would freeze along with the head.
- **A wrapper "registered footage" symbol holding the fit.** It is correct but
adds a symbol per face. The time-floor holds already put the fit read and the
content read on the same frame, so the wrapper buys nothing.
- **Plate as a sibling of `:head` at identity.** It is only registered when the
head and the photo read the same frame, so it breaks the continuous-plus-holds
and start rows above.
## Evaluation: an op that is never rendered
- **`clip/resolver`**, in the instance branch: when the source symbol has
`:type :trace`, it does not recurse. If `(:tracing? opts)` is set, it emits one op:
```clojure
{:kind :trace :node [id] :m <copy of world> :media … :frame shown-frame :size [w h]}
```
The frame comes from the same `placed-frame` path as any instance, so playback,
holds and the fps cadence apply. Do not build a child resolver for a trace
symbol.
- **`transform-op`** gets a `:trace` case that composes the matrix. Row paths,
solo filtering and nesting at any depth then work for free.
- **The output guarantee is structural.** `:tracing?` defaults to false. Only the
stage's `::render/resolver` passes true. Export, `clip/center` (a big photo
must not pull a symbol's pivot), thumbnails and the bench never ask for trace
ops. `raster/draw-ops!` keeps throwing on unknown kinds, so a leak fails loudly.
- **`ui/player`** sends picture ops to the raster and `:trace` ops to the
painter.
- **`ui/underlay` becomes `ui/tracing`.** It paints `:trace` ops in draw order
with `drawImage` at `op.m` and the global opacity. The media URL is the footage
manifest's `urls[range-start + frame]`, or the image blob URL. The three
steadiness fixes stay: LRU cache, hold the last still per op `:node`, and read
ahead while playing. Everything that walked faces is deleted.
- **`pick`**: a `:trace` op is hit when the point, mapped through `m⁻¹`, falls in
`[0 w) × [0 h)`. Picture ops are tested first and trace ops only if nothing
drawn is under the pointer, so a full-frame photo does not steal every click.
When the global switch is off, traces are neither painted nor picked.
- **Gestures**: no change. The face's plate is measured, so `gesture/refusal`
already says "place the instance it is in". A hand-placed layer is authored and
moves, turns and scales like anything else.
## On and off
All of it is EDITOR STATE, as ed88c5e decided for the per-face switch: showing
a reference is a way of looking at the stage, so it is not an undo step, does not
travel to collaborators, and cannot reach an export.
```clojure
[:ui :tracing {:on? true :opacity 0.5 :hidden #{[sid node-id] …}}]
```
- **One layer** is an entry in `:hidden`, keyed by the symbol the tracing
instance is in and its node id. The symbol id is needed because every face's
plate is called `:plate`. Keyed this way, hiding a face's plate hides it in
every placement of that face, which is what the per-face switch did. Shown is
the default, so opening a face shows its footage with no setup.
- **How it's applied**: `clip/resolver` already knows which symbol and node a
trace op comes from, so the op carries `:layer [sid id]`. The painter and
`pick` skip ops whose layer is hidden. The resolver does not change when you
toggle a layer, so nothing is rebuilt.
- **Global**: `:on?` and `:opacity`, a toggle plus an opacity slider in
`ui/palette/bar` next to the palette controls. When it is off, traces are
neither painted nor picked.
- **Switching one layer on also switches the global setting on.** This applies
from the tracing instance's inspector, from its timeline row, and from the
face/roto section. A layer you just enabled must not stay invisible behind a
switch you forgot. Turning one layer off never touches the global switch. It is
one event (`::ui/show-trace layer on?`) that updates `:hidden` and, when
turning on, sets `:on? true` in the same handler, so the three places cannot
behave differently.
- **`[:vis]`** still works on a tracing instance like on any node: key it to
show a reference only over part of the shot. It is not the on/off switch.
- **Deleted**: `[:ui :trace :faces]`, `::trace-face`, `::trace-faces`,
`trace/showing-for`, `traceable-faces`, `shown`, `faces`, and the "a take shows
nothing by default" rule.
## UI
- **Timeline.** A tracing instance is an ordinary row or clip block, styled to
read as reference-only (hatched block, an eye icon in place of the colour
chip). Hold frames are marks on the row. The row's eye button dispatches
`::ui/show-trace`, which replaces the face row's `tl-trace` button.
- **Inspector, tracing instance.** Show the media (footage name and range, or
image), an on/off control (which goes through `::ui/show-trace`), and the hold
list ("hold here" / "remove hold" plus seek buttons, which is `trace-keys`
re-aimed at `:time :holds`). Playback, transform and span use the existing
sections.
- **Inspector, face/roto section.** The same hold controls, aimed at the face's
`:plate`. Origin buttons write `:head :reads`. The section is found by "this
face has a `:plate`", not by `trace/traceable?`.
- **Palette bar.** The global toggle and opacity slider.
- **Making one.**
- Footage: the convert dialog gets a choice between "animate faces" (today's
flow) and "tracing layer" (no detection). The tracing-layer choice makes the
`:type :trace` symbol for the chosen range and places it where the video was
dropped.
- Footage can also be dragged from the pool with a modifier or as a second
drag kind.
- A still image: drop it on the pool and it uploads; drop it on the stage or
timeline and it is placed with `:end :hold` and a span of the host's
remaining frames.
## Freeze, bring, regenerate
- **`freeze/subject-part`** emits the `:plate` instance under `:head`, with fit
measured channels and `:time {:holds []}`, and emits one `:type :trace` symbol
for the analysed footage range. `bring/take` already copies every symbol the
take reaches and rewrites `:source :symbol` references, so the tracing symbol
comes along. It gets the footage's `:audio` too, so a dropped tracing layer
can bring its sound through the existing link.
- **`head-mode`** writes `:reads` on the head and `:holds` on the plate, in
place of `:trace`. The `frame-selection.md` plate proposal materializes into the
plate's `:time :holds` in place of `:trace :frames`.
- **Regenerate** replaces both measured blocks (head and plate) and keeps
`:holds` and `:reads`. Check that `regenerate-head`'s measured/authored
comparison handles a second measured node.
## Server
- `Image` endpoints mirroring `Sound`: `POST /api/images` stores a `Blob` and
returns `{id, width, height, url}`, and `GET /api/images` lists them. The
migration is a model only. Bump `schema_version` and refuse older documents
clearly. Do not convert them.
## Deleted outright
`trace/of`, `prepare`, `held-frame`, `problems`, `toggle-frame`, `photo-frame`,
`measured-local`, `photo-matrix`, `traceable?`, `faces`, `traceable-faces`,
`showing-for`, `shown`, `opacity-default`. `domain/trace.cljs` probably goes
away entirely; if anything is left, it is a hold helper that belongs in `node`.
Also deleted: `symbol/prepared-traces` and the `traces` arm of
`base-channel-frame`, which becomes the `:reads` lookup. `:trace` on nodes (and
`symbol/problems` reports it as removed). `::project/set-trace`.
`::render/underlay` and `::render/tracing` become one `::render/tracing` that
returns `[:ui :tracing]`. The face lookup in `params/view`.
Expected effect on code size: net negative. One time-floor clause, one op kind,
one resolver branch, one pick case and a `:reads` lookup replace the face walk,
the hand-built photo matrix, per-face showing state and the underlay's face
bookkeeping. Measure the change and report the number honestly (see the
`cljs-style` memory).
## Tests
**Domain (node).**
- `:holds` in `local-frame`, with eval-frame and resolver agreeing forward,
backward and in random order.
- A trace op appears only with `:tracing?`, and `export/run!` output contains no
`:trace` op.
- `transform-op` composes the matrix through two nesting levels.
- `clip/center` ignores traces, and is `[w/2 h/2]` for a tracing symbol.
- `pick` returns picture ops before traces and inverse-maps through a rotated
layer.
- `symbol/problems` covers `:type :trace`, `:reads` targets and cycles, and
`:holds` shape.
- Registration: port `trace_test`'s photo-matrix assertions onto the walk.
For every row of the registration table, the plate's world transform equals
the expected matrix. At a held key with `:reads {:holds-of :plate}`, it equals
`world(:place) · S(1/H)`.
- Freeze emits the plate and the tracing symbol. Regeneration keeps the holds.
- The `::ui/show-trace` event sets the global switch on when turning a layer on
and leaves it alone when turning one off.
**Browser** (CDP, see the `arthur-verify-dont-guess` memory):
- Drop a still, then drag, turn and scale it.
- Toggle a layer on with the global switch off, and confirm both are on and the
photo paints.
- Export a frame and check it has no photo pixels.
- Open a face: the plate is registered at a hold with origin "at keys", and
stabilized with origin "start".
## Order of work, each step green
1. `:time :holds` in `node/local-frame`, `problems`, `frame-map`, and timeline
marks.
2. `:type :trace` symbol, `:media`, the `:trace` op behind `:tracing?`,
`transform-op`, the player split, `ui/tracing` painter and `pick`. Footage
media only, placed by hand from a REPL or test document.
3. The face: freeze and bring emit the plate and tracing symbol, `:reads`
replaces `:trace`, delete the old trace and underlay paths, and re-aim the
inspector's face section.
4. On/off: the `:hidden` set and `:layer` on trace ops, the row eye, the
`::ui/show-trace` rule, the global toggle and opacity in the palette bar, and
delete `[:ui :trace :faces]`.
5. Creation: "tracing layer" in the convert dialog, pool drag, then the image
endpoint, pool images and image drops.
6. Docs: `animation-model.md` "A photographic underlay is not an op" becomes "a
trace is an op that never reaches the raster". Update `frame-selection.md`
(where plate keys live) and `lane-model.md` (tracing clips in lanes).
## Open questions
1. **"A take shows its faces' footage."** Dropping the old "only in a face's own
tab" default means opening a take shows every face's plate if the global
switch is on. The recommendation is to accept that, since the switch is one
click.
2. **Lane-model audio rule.** `lane-problems` requires visual cels. A tracing cel
is visual for this purpose, but say so explicitly when a lane may hold both
tracing and drawing cels.