411 lines
21 KiB
Markdown
411 lines
21 KiB
Markdown
# 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.
|