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>
453 lines
23 KiB
Markdown
453 lines
23 KiB
Markdown
# Frame selection
|
||
|
||
Two mechanisms. One vocabulary. An earlier draft of this document claimed they
|
||
were one component used twice — because `suggestPlateFrames` in the old
|
||
`js/pipeline.js` and the never-built "performance poses" of
|
||
[timing-handoff](timing-handoff.md) looked like the same function — and that claim
|
||
is wrong. They share how a selection is *read* and how the hand overrides one.
|
||
They do not share how frames get chosen, because the two are answering questions
|
||
of different shapes.
|
||
|
||
[Time selection](time.md) is the floor both stand on: an output frame reads the
|
||
latest native frame at or before its time, and nothing rewrites the dense
|
||
measurements. That is a *cadence*: an answer with no opinion about content. It
|
||
cannot know that the one frame where the eye is fully closed is worth more than
|
||
its neighbours, so at 12fps out of 30 it drops that frame two times in three.
|
||
This document is how the picture gets an opinion.
|
||
|
||
## The one idea
|
||
|
||
**A selection is a set of frames chosen out of a dense measurement, and read by
|
||
holding the latest one at or before now.**
|
||
|
||
The holding half already exists and is already shared: `pose/held-frame` is called
|
||
by `trace/held-frame` and by `pose/source-frame`, which is the two sites agreeing
|
||
about reading. The hand half is shared too — see *Three layers* below. Choosing is
|
||
what differs.
|
||
|
||
| | Plate drawings (tracing) | Performance poses |
|
||
| --- | --- | --- |
|
||
| the question | which frames does an artist have to draw a head on? | which frames does the picture change a shape on? |
|
||
| the cost being managed | a person drawing | a pose looking wrong |
|
||
| signal | the measured head's motion | — none; a stored cut |
|
||
| the baseline it improves on | drawing on 2s | the cadence, or the exposure grid |
|
||
| the shape of the answer | a non-uniform set out of dense | the same grid, nudged |
|
||
| lives on | the face's `:head` `:trace` | the instance's `:playback :tracks` |
|
||
| hand edit today | `trace/toggle-frame` | `pose/put-cut` / `pose/remove-cut` |
|
||
| UI today | `params/trace-keys` | **none** |
|
||
| proposes today | **nothing** | **nothing** |
|
||
|
||
## Why they are not one function
|
||
|
||
A plate selection has to be **non-uniform**, and that is the whole reason it
|
||
exists. A head still for sixty frames and then whipping across in ten wants two
|
||
drawings for the first stretch and eight for the second. Drawing on 2s gives
|
||
thirty-five drawings, most of them identical, and no amount of nudging a uniform
|
||
grid will produce the distribution that is wanted — the spacing itself is the
|
||
answer. That is what the prototype's walk was for, and it is why a cost knob
|
||
(`:tolerance`) belongs on this side: the artist is buying drawings.
|
||
|
||
A performance selection is **not choosing sparseness at all**. The output rate or
|
||
the exposure setting has already chosen it. The question left over is only *which*
|
||
native frame each already-decided slot reads, and the failure it fixes is narrow:
|
||
a slot landing one or two frames off the closure. Nudging the grid is the right
|
||
size of answer, and there is nothing for a tolerance to mean.
|
||
|
||
There is a second, harder reason, and it is the one that settles it:
|
||
|
||
**A selection cannot put a frame on screen that the output grid never samples.**
|
||
At 12fps out of 30, output frame 5 reads native 12 and output frame 6 reads native
|
||
15. A closure at native 13 is *between* them. Protecting frame 13 in a set of
|
||
kept frames makes it available and makes it the frame held across 13 and 14 in
|
||
native space — and at a 12fps output it still never appears, exactly as
|
||
[time.md](time.md) says: an event between output frames cannot create an extra
|
||
frame in a 12fps output. Only moving what output frame 6 reads can show it. So
|
||
the performance side has to act on the grid, not on a set beside it.
|
||
|
||
## Three layers, and the middle one is derived
|
||
|
||
The trap this is designed around is stated in
|
||
[timing-handoff](timing-handoff.md) and is worth repeating because it is the
|
||
only hard rule here:
|
||
|
||
> Store manual edits separately from generated proposals so changing the rate or
|
||
> tolerance retains hand decisions.
|
||
|
||
So a selection is:
|
||
|
||
```clojure
|
||
{:policy {:tolerance 0.02} ; what the proposer was asked for
|
||
:keep #{47} ; frames the hand insists on
|
||
:drop #{30}} ; frames the hand refuses
|
||
```
|
||
|
||
and the effective set is `(proposed ∪ keep) \ drop`, always containing frame 0.
|
||
|
||
`:keep` and `:drop` are the document. The proposal is not: it is recomputed from
|
||
`:policy` and the dense signal whenever either changes. **Re-suggesting at a new
|
||
tolerance must never cost somebody their pinned blink**, and that is the entire
|
||
reason the hand decisions are stored as their own two sets rather than as the
|
||
resulting frame list.
|
||
|
||
This layering is the part that really is shared. On the performance side there is
|
||
no `:policy` worth storing — the grid is the policy — but `:keep` and `:drop` mean
|
||
exactly what they mean on the plate side, and `select/effective` is the one
|
||
implementation for both. A preserve mark *is* a keep.
|
||
|
||
### Materialise the result, do not derive it on the render path
|
||
|
||
The effective set is written back to where each site already reads it —
|
||
`:trace :frames`, or the pose track — so that every existing reader is untouched
|
||
and nothing on the per-frame path has to open a dense block. Proposing is a
|
||
command, not a subscription. `ch/value-at` allocates per call and says so; that
|
||
is fine for a button press over a few hundred frames and would not be fine at
|
||
30fps.
|
||
|
||
This means the stored frame list is redundant with `policy + keep + drop`. That
|
||
is deliberate and it is the cheap direction of the trade: a stale list is
|
||
recoverable by pressing Suggest again, and a dense read per node per frame is
|
||
not recoverable at all.
|
||
|
||
## The plate selection: a non-uniform chooser
|
||
|
||
`arthur.domain.select`, built — see *What is revertible* for the one part of it
|
||
that is not yet wanted. Pure, no store access, no clip access: the site hands it a
|
||
signal it has already read.
|
||
|
||
```clojure
|
||
(defn propose
|
||
"Frames worth keeping out of `n`, given `signal`."
|
||
[n signal {:keys [tolerance protect]}])
|
||
|
||
(defn effective
|
||
"`proposed` with the hand's decisions applied. Always contains 0."
|
||
[proposed keep drop])
|
||
```
|
||
|
||
`propose` is the prototype's walk, generalised off landmarks:
|
||
|
||
1. keep frame 0, make it the anchor;
|
||
2. settle the protected frames from `:protect` *before* walking;
|
||
3. for each later frame, keep it when it is protected, or when
|
||
`distance(anchor, f) > tolerance`. Either way it becomes the anchor.
|
||
|
||
`distance` is the max absolute difference over components, so a signal of
|
||
landmark pairs and a signal of one number both work without the caller saying
|
||
which it handed over. Every sample in a signal is the same width, and a signal of
|
||
two widths is refused: comparing the prefix two samples happen to share would let
|
||
a reader that drops a component read as no movement at all.
|
||
|
||
**A protected frame anchors the walk like any other kept frame**, which is why
|
||
protection is settled first and is not unioned onto the walk's result. The anchor
|
||
is what is on screen; once a protected frame is kept the viewer is looking at it,
|
||
so measuring the next frame's drift from a frame no longer displayed is wrong.
|
||
|
||
**An absent measurement is `nil`, and converting to that is the reader's job.** A
|
||
frame where the face was not found says nothing about the signal: it cannot move
|
||
the anchor and it is not a frame worth keeping. `trace/measured-local` already
|
||
returns nil there. A protected frame with no measurement is still kept — a plate
|
||
frame is a frame somebody draws on whether or not the detector found a face.
|
||
|
||
**A non-finite tolerance falls back to nought.** A cleared slider reads as NaN and
|
||
every comparison against NaN is false, which taken literally proposes frame 0
|
||
alone and collapses the whole take to one drawing. Nought proposes every frame
|
||
that changes, which is merely the baseline back again: wrong in a way somebody can
|
||
see and undo.
|
||
|
||
### The signal
|
||
|
||
The head's measured transform, applied to a fixed reference quad, giving
|
||
displacement in stage units — so a tolerance means "the head has moved this far"
|
||
and is a number a person can reason about. `trace/measured-local` already builds
|
||
that matrix per frame and is private; make it public rather than writing a second
|
||
one. Map it over the frames and transform four corners through `node/apply-pt!`.
|
||
|
||
The quad's size is a real parameter hiding in the word "fixed": it sets how much
|
||
rotation registers against translation. Give it a name and a comment rather than
|
||
an inline literal.
|
||
|
||
Do not reach for raw landmarks. The prototype used them because it had them lying
|
||
around; the transform is what the drawing actually follows, it is already on the
|
||
node, and it is three channels instead of a block.
|
||
|
||
### The upgrade path, and do not start here
|
||
|
||
The greedy walk is order-dependent and slightly suboptimal. The optimal version
|
||
is a dynamic program — choose `k` frames minimising held-reconstruction error,
|
||
which is textbook segmented least squares and is O(n²k), nothing at n≈300 — and
|
||
it keeps extrema *for free*, because an extremum is exactly where a zero-order
|
||
hold is most wrong.
|
||
|
||
Build the greedy one first anyway. It is proven, it shipped in the prototype, and
|
||
having two implementations to compare is how the DP gets tested. Swap it behind
|
||
`propose` afterwards, where the signature already permits it.
|
||
|
||
Most of `select_test` pins the greedy walk's exact output, deliberately, for that
|
||
comparison — so expect to rewrite those expectations when the DP lands, and keep
|
||
them as greedy-specific tests rather than deleting them. The assertion that is a
|
||
*spec* rather than a pinned vector, and should be written on this side before the
|
||
swap, is:
|
||
|
||
> reading the signal through the selection, held, never differs from the dense
|
||
> measurement by more than `tolerance`
|
||
|
||
That is what makes the tolerance number mean something to a person. It is true of
|
||
the greedy walk by construction and it is what the DP optimises, so it survives
|
||
the swap untouched.
|
||
|
||
## The performance selection: a preserve-snap on the grid
|
||
|
||
Not built. About thirty lines, plus a prepare step.
|
||
|
||
A grid slot already picks a native frame — `cadence/frame` for the output rate, or
|
||
the exposure fold for a deliberate hold at full rate. The change is: when a slot's
|
||
pick has a *preserved* frame within the span of native frames that slot is
|
||
responsible for, read the preserved frame instead.
|
||
|
||
**Snap backward only, never forward.** `cadence/frame`'s contract is the latest
|
||
native frame at or before the slot's time, and `cadence_test` asserts
|
||
`selected <= f*native/grid` over every grid and native pair. Snapping forward
|
||
would show a closure before the mouth shut, which is a lead — a different control,
|
||
applied for a different reason, after this one. A backward snap shows the closure
|
||
up to a frame or two late, which is the same lateness a hold already has.
|
||
|
||
**The marks come from a cut that is already stored.** `flow/freeze` computes both
|
||
closures and keys them as `[:vis]`, with the thresholding and hysteresis already
|
||
decided:
|
||
|
||
- the mouth, from the aperture relative to the take's peak — `[:vis]` on
|
||
`:mouth-in`, provenance `:roto/mouth-aperture` (`freeze.cljs:473`);
|
||
- the eyes, from `condition/resolve-blink` with its cut, dwell and hold — `[:vis]`
|
||
keyed per eye part, provenance `:roto/blink` (`freeze.cljs:533`).
|
||
|
||
So "preserve the frames the cut says shut" reads what the document already holds.
|
||
No new signal, no new dense track, no threshold decided twice. Brows have no
|
||
closure and get no marks, which is correct: there is no extreme brow position
|
||
worth protecting.
|
||
|
||
**Precompute the mark set when the resolver is built**, the way `pose/prepare` and
|
||
`trace/prepare` already do. A `[:vis]` channel is keys, not dense, so reading it
|
||
per node per frame would be cheap — but the snap also needs the marks as a set
|
||
with a span lookup, and building that per frame is the one thing
|
||
[animation-model.md](animation-model.md) and the render-path note above both
|
||
forbid.
|
||
|
||
### Why not an aperture signal
|
||
|
||
An earlier draft had this side read the group's aperture as a single-component
|
||
signal and hand it to `propose` with `:protect :extrema`. It cannot. The aperture
|
||
is the separation of landmarks 13 and 14, at positions 5 and 15 of the 20-slot
|
||
`LIPS-INNER` ring, and `freeze/rings->flat` subsamples the ring to the `verts`
|
||
budget: those two positions survive only when `verts` is a multiple of four. At
|
||
`verts` 6, 10, 14 and 18 — all legal, all even — they are not in the stored data
|
||
at all. `ring/subsample-slots` used to claim otherwise and has been corrected.
|
||
|
||
`flow/measure/mouth` does compute the exact scalar and `freeze` does throw it away
|
||
after thresholding, so storing it was an option. Reading the cut is strictly less
|
||
work and decides nothing twice.
|
||
|
||
## How the two interact
|
||
|
||
They are keyed in **the same frame space**: `clip/resolver` hands an instance's
|
||
`:playback :tracks` down into the child it places, and `symbol/base-channel-frame`
|
||
reads both the trace and the pose choices at `lf`, the node's local frame inside
|
||
that face. A trace frame and a pose cut are the same kind of number.
|
||
|
||
What differs is the owner, and that asymmetry is load-bearing:
|
||
|
||
- the **trace** is the face's, on its `:head` — every instance of that face shares
|
||
it, because it says how the drawings were made;
|
||
- the **pose tracks** are the instance's — two placements of one face can be
|
||
timed differently.
|
||
|
||
### The hazard, which is already written down
|
||
|
||
[animation-model.md](animation-model.md) states it for exposure and it is the
|
||
same hazard here:
|
||
|
||
> a head cutting on odd frames against a mouth cutting on even ones reads as two
|
||
> performances
|
||
|
||
**It is not a correctness problem.** The mouth is a child of `:head` and its
|
||
geometry is stored head-local, so a mouth from frame 17 composed onto a head held
|
||
at frame 12 is exactly lip-sync on a held drawing — the decomposition already
|
||
decoupled them and nothing is geometrically wrong. The problem is perceptual, and
|
||
perceptual problems want a constraint rather than a repair.
|
||
|
||
### Nest them, do not couple them
|
||
|
||
The two are not peers. One is coarse and expensive — the prototype's own comment
|
||
says it: *"The cost being managed is an artist drawing a head, which is why the
|
||
signal is head pose and not the mouth — the mouth is traced and free."* The other
|
||
is fine and cheap.
|
||
|
||
So the rule is a subset, in one direction only:
|
||
|
||
**Every kept plate frame is a preserved frame of the performance selection.**
|
||
|
||
When the drawing changes, the performance changes with it, so the two can never
|
||
cut against each other on neighbouring frames. The mouth stays free to change on
|
||
frames where the head does not, which is what shooting a held drawing with a live
|
||
mouth *is*.
|
||
|
||
This is why the preserve set is a set and not a closure predicate: plate frames
|
||
and shut-mouth frames go into the same pile, and the snap does not care which is
|
||
which. It needs no new mechanism on either side.
|
||
|
||
The reverse is a suggestion and never automatic. A mouth closure is a reasonable
|
||
place to want a new drawing, but proposing one spends somebody's afternoon. Offer
|
||
it; do not take it.
|
||
|
||
It also composes with `:origin` for free. A head on `:continuous` has opted out
|
||
of its own selection, so there are no plate frames to preserve and the constraint
|
||
is vacuous — which is correct, because a continuously moving head cannot cut
|
||
against anything.
|
||
|
||
### Between the kept frames is a third shared field
|
||
|
||
`:trace :origin` is not a tracing setting. It is the answer to *what happens
|
||
between kept frames*, and the plate selection has to answer it:
|
||
|
||
- `:continuous` — ignore the selection for this purpose and read the frame you
|
||
are on,
|
||
- `:keys` — jump to each kept frame and hold it, a hold and not a tween,
|
||
- `:start` — hold the first forever.
|
||
|
||
The performance side does not need the field: a snap picks which frame a slot
|
||
reads and the grid does the holding, so `:keys` is the only behaviour there is.
|
||
Do not rename `:origin` to match anything: for a head it genuinely means where the
|
||
face's origin goes, and a saved field in a shipped UI is not worth churning for a
|
||
vocabulary tidy.
|
||
|
||
## The modes
|
||
|
||
One setting, two states, and **"manual" is not a third state.**
|
||
|
||
- **off** — the cadence alone, which is what ships today. The output frame
|
||
reads the latest native frame at or before it, and nothing has an opinion.
|
||
- **smart frame picking** — on the plate side the proposal is live, and `:policy`
|
||
holds the tolerance; on the performance side the snap is active.
|
||
|
||
Hand keeps and drops apply in **both** states, which is why they are not a mode:
|
||
turning smart picking off must not throw away the frames somebody pinned, and
|
||
pinning a frame with smart picking off is a perfectly reasonable thing to want.
|
||
Manual precedence is absolute — a drop beats a proposal, always.
|
||
|
||
The name on the toggle should be the same word in both sections. "Smart frame
|
||
picking" is fine. What it must not be is two different names for the one idea,
|
||
which is how these became two features the first time. That the two sections are
|
||
now backed by different code is an implementation fact and must not reach the UI.
|
||
|
||
## The UI
|
||
|
||
One component rendered twice, in `ui/params.cljs`:
|
||
|
||
```
|
||
smart frame picking [ off | on ]
|
||
tolerance [ ----•------- ] 0.02 (plate section only)
|
||
[ Suggest ]
|
||
frames 0 12 30 47* 61 (* = kept by hand, strikethrough = dropped)
|
||
```
|
||
|
||
The frame strip already exists in miniature — `params/trace-keys` draws the trace
|
||
keys as seek buttons (`params.cljs:386`). Lift it into a shared component and
|
||
give it three affordances: click to seek, a modifier to pin, a modifier to drop.
|
||
A pinned frame and a proposed frame must be visually distinct, because "will this
|
||
survive me moving the slider?" is the question the strip exists to answer.
|
||
|
||
Render it in two sections:
|
||
|
||
- **`tracing · <face>`** (`params.cljs:426`), beside the existing origin row,
|
||
with the tolerance slider and Suggest.
|
||
- **`performance · <group>`** — new, on an instance's inspector, one per pose
|
||
group the placed symbol has. No tolerance: the strip shows the preserved frames
|
||
and the grid slots that snapped to them.
|
||
|
||
## Order to build it
|
||
|
||
1. **`domain/select` with the greedy walk, and its tests.** **Done**, in commit
|
||
`02069e8`. Pure, no store, no clip. Note that the extrema part of it is not
|
||
wanted by anything below — see *What is revertible*.
|
||
2. **The plate signal reader.** `trace/head-signal`: make `trace/measured-local`
|
||
public, map it over the frames, four corners through `node/apply-pt!`. Test
|
||
that it returns a vector of the right length, that a motionless take proposes
|
||
`[0]`, and that a stretch where the face was not found becomes `nil` rather
|
||
than a pose, a zero or a gap in the vector. Add the held-reconstruction
|
||
invariant from *The upgrade path* here, since this is the first place a real
|
||
signal exists to assert it over.
|
||
3. **Storage for the plate selection.** `:policy`/`:keep`/`:drop` beside
|
||
`:trace :frames`. Extend `leaf/leaves` and the key whitelists in the same
|
||
commit — a field without a leaf saves silently and comes back missing, which
|
||
is the one bug persistence must not be able to have. Round-trip test.
|
||
4. **Re-suggest preserves hand decisions.** Propose at one tolerance, pin a frame,
|
||
drop a frame, propose at another, assert both survive. Settle here whether the
|
||
hand's `:keep` is also fed to `propose` as `:protect`: under the layering as
|
||
written it is not, so a pinned frame does not re-anchor the walk even though it
|
||
is on screen, which contradicts the anchor rule above. It is the one live
|
||
caller for `propose`'s `:protect` frames.
|
||
5. **The preserve-snap.** The mark set from the stored `[:vis]` cuts, built in a
|
||
prepare step; the backward-only snap in the grid pick; plate frames folded into
|
||
the same mark set. Independent of steps 2–4 and cheaper than any of them, so it
|
||
can be pulled forward if the performance side is wanted sooner — the only thing
|
||
it needs from the plate side is the fold, which can land last.
|
||
6. **The shared UI component**, then its two mountings.
|
||
|
||
## What is revertible, and where it is
|
||
|
||
Commit `02069e8` contains one part that **nothing below asks for**: extrema
|
||
detection. Specifically `segments`, `turns`, the `:extrema` branch of `propose`,
|
||
the multi-component refusal that exists only to guard it, and three tests —
|
||
`a-one-frame-closure-survives-only-because-it-is-protected`,
|
||
`extrema-are-turns-worth-more-than-the-tolerance` and
|
||
`extrema-are-refused-on-a-multi-component-signal`.
|
||
|
||
It has no caller because plate selections take no extrema by design — displacement
|
||
is the whole story for a head — and the performance side reads a stored cut
|
||
instead of finding extrema in a signal. It is correct, tested and speculative.
|
||
|
||
Revert it if the shape above holds. Keep it if either of these turns out to be
|
||
wanted: a group whose extreme is not a closure and therefore has no `[:vis]` cut
|
||
to read (a mouth at its widest, a head at the top of a nod), or a take whose mouth
|
||
never shuts far enough to cross `aperture-cut`, where a peak-relative threshold
|
||
marks nothing and an extremum would still find the most closed frame. Neither is
|
||
asked for today. The DP in *The upgrade path* gets extrema for free regardless, so
|
||
reverting costs nothing that cannot be had again more cheaply.
|
||
|
||
## Traps
|
||
|
||
- **Do not let Suggest write `:keep`.** The proposal and the hand are different
|
||
layers; collapsing them is the bug this whole shape exists to avoid, and it
|
||
will look like it works right up until somebody moves the tolerance slider.
|
||
- **Do not snap a grid slot forward.** Every hold and every pick is "at or
|
||
before". Showing a closure before the mouth shut is a lead, which is a different
|
||
control for a different reason.
|
||
- **Do not quantise a plate selection onto the output grid.** A kept frame is a
|
||
native frame and lands where it lands. [time.md](time.md) is explicit that an
|
||
event between output frames appears on the next one; forcing kept frames onto
|
||
the grid would re-create the problem the selection exists to solve. The snap is
|
||
the opposite operation and is on the other side of the fence: it moves the grid's
|
||
pick, never the kept frame.
|
||
- **Do not read the aperture pair off a subsampled ring.** Positions 5 and 15 of
|
||
`LIPS-INNER` survive only at `verts` divisible by four.
|
||
- **Do not thin the plate selection with the mouth's.** Two scopes, two owners,
|
||
and the nesting runs one way only: plate frames preserve performance frames,
|
||
never the reverse.
|
||
- **Do not give the performance side a tolerance.** The grid has already chosen
|
||
the sparseness. A second knob there would be a control with nothing to control.
|
||
- **A skipped frame, a hidden feature and an absent measurement remain three
|
||
different facts.** A selection says nothing about visibility and nothing about
|
||
whether a face was found. Note that the preserve marks are *derived from* a
|
||
visibility cut, which makes this easy to blur: the cut says the interior is
|
||
hidden, the mark says the frame is worth landing on, and one is not the other.
|
||
|
||
## Not in scope
|
||
|
||
- Automatic *grouping* of related parts beyond the existing `:pose-group`.
|
||
Related parts must share one selection — a mouth outline, its interior, the
|
||
teeth and the generated visibility reading different frames is the bug that
|
||
grouping prevents — and `:pose-group` is where the grouping already lives.
|
||
- A per-instance request for a different tolerance than the symbol's. The
|
||
resolver threads no such option today and should not grow one until something
|
||
needs it.
|
||
- Variable frame rate. [time.md](time.md) assumes constant fps and so does this;
|
||
a VFR source needs presentation timestamps before any of this means anything.
|