Compare commits

..

6 commits

Author SHA1 Message Date
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
17 changed files with 1456 additions and 96 deletions

@ -1 +0,0 @@
Subproject commit e369ad9462ed1816a39b1a0f5c79055a2c013f4f

504
docs/frame-selection.md Normal file
View file

@ -0,0 +1,504 @@
# 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. The rule is ten lines; getting the two halves of it into the same place
is the work. An earlier draft of this section said "about thirty lines" and that
was understated — see *Where it goes* below.
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. Write `d(k)` for the native
frame slot `k` defaults to. Slot `k` is the first slot to cover everything in
`(d(k-1), d(k)]`, and the frames strictly inside that interval are the ones the
grid shows to nobody. So:
> **Slot `k` reads the latest preserved frame in `(d(k-1), d(k)]`, and `d(k)` when
> there is none.**
That is the whole mechanism. It recovers a dropped frame out of the slot's own gap,
it can never read a frame another slot already showed, and it cannot reach past
`d(k)`.
**Snap backward only, never forward**, and note which direction that actually is,
because it is easy to get backwards. The closure at native 13 in a 12-from-30
output is recovered by slot **6** — whose default is 15 — reading 13. It is *not*
recovered by slot 5, whose default is 12, reaching forward to 13: slot 5's instant
is 5/12s = 0.4167s and native 13's is 13/30s = 0.4333s, so that would show the
closure 17ms before the mouth shut. `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, so reaching forward
breaks a tested invariant as well as the no-lead rule. Reading 13 at slot 6 shows
the closure two native frames late, which is the same lateness every 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 (`symbol.cljs:420`, `:470`, `:536`). A `[:vis]` channel
is keys, not dense, so reading it per node per frame would be cheap — but the snap
also needs the marks sorted for a backward lookup, and building that per frame is
the one thing [animation-model.md](animation-model.md) and the render-path note
above both forbid.
### Where it goes, and why it is not a one-liner
The rule needs two things that currently live at opposite ends of the resolver:
- **the slot interval** `(d(k-1), d(k)]`, which needs the output slot index and the
fps ratio. Both exist at `clip.cljs:261`, the single place the grid becomes a
native frame: `(cadence/frame f (or (:grid-fps opts) (:fps clip)) (fps clip sid))`.
- **the marks**, which are per pose group, and so belong where group identity
exists: `symbol/base-channel-frame` (`symbol.cljs:399`), whose `:pose-sampled?`
branch already calls `pose/source-frame` with `(js/Math.floor lf)` as the default
pose. **That default is the snap's seat.** Replacing it leaves an explicit hand
cut winning over a snap, which is correct — manual precedence is absolute — and
costs no new plumbing on the pose side, because per-group choices are already
threaded and already prepared.
By the time control reaches `base-channel-frame` there is only `lf`, a native local
frame that placement and retime have already been through, so the slot interval
cannot be recovered there. It has to be threaded down from `clip.cljs:261`
alongside the frame. That is the actual work of this step: two namespaces' internal
signatures, not a drop-in.
**Do not take the shortcut of snapping at `clip.cljs:261` itself.** It is right
there, it needs no threading, and it is wrong: one native frame per output frame
means the *whole picture* reads 13 instead of 15, so the head goes two frames stale
for one output frame to fix the mouth. At 12fps that is a 67ms hitch on a moving
head, and it fights the trace selection, which has its own opinion about which head
frame to show. The snap is per group because the thing being recovered is one
group's closure.
### 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 preserve-snap**, pulled forward ahead of the plate side because it is the
half with no UI and nothing proposing today, and because it needs nothing from
the plate side except a fold that can land last. Three pieces: the mark set from
the stored `[:vis]` cuts, built in a prepare step beside `pose/prepare`; the
slot interval threaded from `clip.cljs:261`; the rule itself, seated in
`base-channel-frame`'s default pose. The test that matters is the same synthetic
case `select_test` uses — a one-frame closure at native 13 that a 12-from-30
grid drops — asserted end to end this time: the resolver shows a shut mouth on
exactly one output frame, and the head's frame does not move while it happens.
Write it first.
3. **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.
4. **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.
5. **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.
6. **Fold the plate frames into the mark set**, which is the nesting rule and is
one line once both sides exist.
7. **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.

View file

@ -23,8 +23,9 @@ sample-fps fields are gone. Existing exposure, trace choices and per-instance
pose tracks remain available: a pose track can hold a chosen closed-mouth frame pose tracks remain available: a pose track can hold a chosen closed-mouth frame
without deleting its neighboring measurements. Those choices stay in native without deleting its neighboring measurements. Those choices stay in native
frames when output fps changes. Automatic content-aware frame selection is not frames when output fps changes. Automatic content-aware frame selection is not
implemented. An event between output frames appears on the next output frame; implemented; [frame-selection.md](frame-selection.md) is how it should be. An
it cannot create an extra frame in a 12fps output. event between output frames appears on the next output frame; it cannot create
an extra frame in a 12fps output.
Audio uses continuous time through the same derived placement maps, without Audio uses continuous time through the same derived placement maps, without
picture floors or holds. Frame-rate units cancel before Web Audio playbackRate picture floors or holds. Frame-rate units cancel before Web Audio playbackRate

View file

@ -218,9 +218,10 @@
;; there used to be. Their resolvers still hold the frame ;; there used to be. Their resolvers still hold the frame
;; before whenever they were not on. ;; before whenever they were not on.
entered (volatile! {}) entered (volatile! {})
step (fn [f] step (fn [f pre]
(vreset! entered {}) (vreset! entered {})
(let [by-id (into {} (map (juxt :node identity)) (own (js/Math.floor f)))] (let [by-id (into {} (map (juxt :node identity))
(own (js/Math.floor f) (js/Math.floor pre)))]
(into [] (into []
(mapcat (mapcat
(fn [id] (fn [id]
@ -228,6 +229,7 @@
(if (= :instance (:kind n)) (if (= :instance (:kind n))
(let [m (symbol/world-of own id) (let [m (symbol/world-of own id)
local (symbol/frame-of own id) local (symbol/frame-of own id)
prior (symbol/pre-frame-of own id)
length (frames clip (node/source n)) length (frames clip (node/source n))
shown (when (and m (number? local)) shown (when (and m (number? local))
(placed-frame clip sid n local)) (placed-frame clip sid n local))
@ -235,13 +237,28 @@
(if (and frame (<= 0 frame) (< frame length)) (if (and frame (<= 0 frame) (< frame length))
(do (vswap! entered assoc id (:symbol shown)) (do (vswap! entered assoc id (:symbol shown))
(map #(transform-op % m [id]) (map #(transform-op % m [id])
((get children [id (:symbol shown)]) frame))) ((get children [id (:symbol shown)])
frame
;; The slot's interval crosses the
;; boundary by being MAPPED, not
;; carried: the previous slot's
;; frame goes through the same
;; placement and retime as this
;; one, so the gap comes out in the
;; child's frames and at the child's
;; rate. An instance appearing for
;; the first time on this slot has no
;; previous frame, and so no gap.
(or (:frame (when (number? prior)
(placed-frame clip sid n prior)))
(dec frame)))))
[])) []))
(when-let [op (get by-id id)] [op])))) (when-let [op (get by-id id)] [op]))))
ids))))] ids))))]
(reify (reify
IFn IFn
(-invoke [_ f] (step f)) (-invoke [_ f] (step f (dec f)))
(-invoke [_ f pre] (step f pre))
symbol/IResolver symbol/IResolver
(world-of [_ [id & more]] (world-of [_ [id & more]]
(if more (if more
@ -254,14 +271,35 @@
(if more (if more
(when (contains? @entered id) (when (contains? @entered id)
(symbol/frame-of (get children [id (get @entered id)]) (vec more))) (symbol/frame-of (get children [id (get @entered id)]) (vec more)))
(symbol/frame-of own id))))))] (symbol/frame-of own id)))
(let [r (build sid [] nil)] (pre-frame-of [_ [id & more]]
(if more
(when (contains? @entered id)
(symbol/pre-frame-of (get children [id (get @entered id)]) (vec more)))
(symbol/pre-frame-of own id))))))]
(let [r (build sid [] nil)
grid (or (:grid-fps opts) (:fps clip))
native (fps clip sid)]
(reify (reify
IFn IFn
(-invoke [_ f] (r (cadence/frame f (or (:grid-fps opts) (:fps clip)) (fps clip sid)))) (-invoke [_ f]
(r (cadence/frame f grid native)
;; `d(k-1)`: the native frame the slot BEFORE this one selected, which
;; with `d(k)` is the interval `(d(k-1), d(k)]` the preserve-snap may
;; reach back into — the frames this slot is the first to cover, and so
;; the ones the grid would otherwise show to nobody. Slot 0 has no slot
;; before it, so its interval is its own frame alone.
;;
;; THE SNAP DOES NOT HAPPEN HERE, although the interval is born here and
;; nothing would need threading. One native frame per output frame means
;; the WHOLE PICTURE reading 13 instead of 15 — a head going two frames
;; stale, a 67ms hitch at 12fps, to fix one group's mouth. It is per
;; group, so it is seated where groups exist.
(if (pos? f) (cadence/frame (dec f) grid native) -1)))
symbol/IResolver symbol/IResolver
(world-of [_ path] (symbol/world-of r path)) (world-of [_ path] (symbol/world-of r path))
(frame-of [_ path] (symbol/frame-of r path)))))) (frame-of [_ path] (symbol/frame-of r path))
(pre-frame-of [_ path] (symbol/pre-frame-of r path))))))
(defn center (defn center
"The middle of everything symbol `sid` draws, over all its frames, in its own "The middle of everything symbol `sid` draws, over all its frames, in its own

View file

@ -2,8 +2,18 @@
"An instance's explicit, held choices of source pose for each shape group. "An instance's explicit, held choices of source pose for each shape group.
A track is {local-frame -> source-frame}. The key is when the cut happens; A track is {local-frame -> source-frame}. The key is when the cut happens;
the value is the frozen pose to read. Skipped source frames remain available." the value is the frozen pose to read. Skipped source frames remain available.
(:require [arthur.domain.node :as node]))
AND THE PRESERVE-SNAP, which is the same question asked where nobody has
answered it by hand. A grid slot already picks a native frame — the latest one
at or before its time, see `domain/cadence` — and that pick has no opinion about
content, so at 12fps out of 30 it drops two frames in three and cannot know that
one of them is where the mouth shut. The snap gives it one: a slot reads the
latest MARKED frame inside its own gap. `marks` is where the marks come from and
`snapped-frame` is the rule; `domain/symbol` seats the rule as the DEFAULT pose,
so a hand cut still beats it without anything having to say so."
(:require [arthur.domain.channel :as ch]
[arthur.domain.node :as node]))
(defn prepare (defn prepare
"Sort pose tracks once when building a resolver." "Sort pose tracks once when building a resolver."
@ -13,6 +23,125 @@
[group (vec (sort-by first entries))])) [group (vec (sort-by first entries))]))
tracks)) tracks))
;; ---------------------------------------------------------------------------
;; the preserve-snap: which frames are worth landing on
(def ^:private closure-cuts
"The `[:vis]` provenances that are a CLOSURE, and therefore a frame worth
landing on.
A SKIPPED FRAME, A HIDDEN FEATURE AND AN ABSENT MEASUREMENT ARE THREE
DIFFERENT FACTS, and marks being derived from a visibility cut is exactly what
makes that easy to blur. `flow/freeze` writes `[:vis]` for three different
reasons: the mouth's aperture against the take's peak, `condition/resolve-blink`
with its cut and dwell, and whether a teeth contour could be extracted. The
first two are a part CLOSING — a thing the picture should land on. The third is
a feature not being there to draw, which says nothing about a performance, and
nor does a `[:vis]` somebody keyed by hand to switch a part off.
So this is a whitelist of two and not a test for `:generated`: reading every
stored `[:vis]` would snap the grid onto the frames a part happened to be
missing on, which is the cadence being dragged about by an absence."
#{:roto/mouth-aperture :roto/blink})
(defn- shut-frames
"The frames channel `c` calls shut, or nil when it is not a closure cut.
Sampled through a CURSOR rather than `ch/value-at` because the frames are read
in order: `value-at` rebuilds the sorted key index per call and the two are
required to agree exactly, so the sequential reader is the cheap half of an
equality the model already guarantees.
`false?` and not falsiness. `ch/absent` is not a closure — a frame the subject
was not on has no mouth to be shut — and a dense `[:vis]` yields 0, which is
truthy in CLJS, so neither obvious test is right. `symbol/visible?` insists on
the same boolean for the same reason."
[c frames store]
(when (contains? closure-cuts (:by (:generated c)))
(let [cur (ch/cursor c store)]
(into [] (filter #(false? (ch/sample! cur %))) (range frames)))))
(defn marks
"Frames worth landing on, per pose group: `{group -> ascending frames}`, or nil
where nothing is marked.
PRECOMPUTED WHEN THE RESOLVER IS BUILT, beside `prepare` and `trace/prepare`,
and this is the reason it is a function rather than a line inside the snap. A
`[:vis]` channel is keys and not dense, so reading one per node per frame would
be cheap — but the snap needs the marks SORTED for a backward lookup, and
building that per frame is what docs/animation-model.md forbids on the render
path.
PER POSE GROUP, because the thing being recovered is one group's closure. The
alternative — snapping the slot itself, where the grid becomes a native frame —
is one native frame for the whole picture, so a head would go two frames stale
for one output frame to fix a mouth. A group's related parts share one answer,
which is what `:pose-group` is already for: the mouth outline, its interior and
the teeth reading different frames is the bug grouping prevents.
No new signal and no new stored field: the cut `flow/freeze` already wrote is
read where it lies, with its thresholding and hysteresis already decided. A
group whose parts carry no closure cut gets no marks and no entry, which is
correct for brows — there is no extreme brow position worth protecting."
[nodes frames store]
(when (and (integer? frames) (pos? frames))
(not-empty
(into {}
(keep (fn [[group ns]]
(let [fs (into (sorted-set)
(mapcat #(shut-frames (get (:channels %) [:vis])
frames store))
ns)]
(when (seq fs) [group (vec fs)]))))
(group-by #(or (:pose-group %) (:id %)) (vals nodes))))))
(defn- latest-mark
"The largest mark at or before `f`, or nil. Binary search, as `held-frame` is:
the marks are sorted once and read in whatever order the transport asks for."
[ms f]
(loop [lo 0 hi (dec (count ms)) hit nil]
(if (> lo hi)
(when (some? hit) (nth ms hit))
(let [mid (bit-shift-right (+ lo hi) 1)]
(if (<= (nth ms mid) f)
(recur (inc mid) hi mid)
(recur lo (dec mid) hit))))))
(defn snapped-frame
"The native frame a grid slot reads: THE LATEST MARK IN `(lo, hi]`, AND `hi`
WHEN THERE IS NONE.
`hi` is the frame the slot defaults to — the latest native frame at or before
its time — and `lo` is the frame the slot BEFORE it defaulted to. So the
half-open interval is exactly the frames this slot is the first to cover, which
are exactly the ones the grid shows to nobody. That one sentence is the whole
mechanism: it recovers a dropped frame out of the slot's own gap, it can never
read a frame another slot already showed, and it cannot reach past `hi`.
BACKWARD ONLY, NEVER FORWARD, and note which direction that actually is because
it is the easy thing to get wrong. A closure at native 13 in a 12-from-30 output
is recovered by the slot whose default is 15, reading 13. It is NOT recovered by
the slot whose default is 12 reaching forward to 13: that slot's instant is
5/12s and native 13's is 13/30s, so it would show the closure 17ms before the
mouth shut. `cadence/frame`'s contract is at-or-before and `cadence_test` asserts
`selected <= f*native/grid` over every grid and native pair, so reaching forward
would break a tested invariant as well as the no-lead rule. Reading 13 two
native frames late is the lateness every hold already has.
An EMPTY interval snaps nothing. A slot that reads what the slot before it read
— a held exposure, or an output grid faster than the content — has no gap of its
own, and `(hi, hi]` contains nothing to find.
Takes no tolerance and will not grow one. The output rate or the exposure
setting has already chosen the sparseness; the only question left is WHICH
native frame an already-decided slot reads, and a second knob here would be a
control with nothing to control."
[marks group lo hi]
(if-let [ms (get marks group)]
(let [m (latest-mark ms hi)]
(if (and (some? m) (> m lo)) m hi))
hi))
(defn held-frame (defn held-frame
"Last value keyed at or before f, or default before the first key." "Last value keyed at or before f, or default before the first key."
[entries f default-frame] [entries f default-frame]

View file

@ -13,9 +13,15 @@
back to positions with indexOf would silently pick the wrong slot if a table back to positions with indexOf would silently pick the wrong slot if a table
ever repeated an id. ever repeated an id.
For even n this naturally lands on the cardinal positions (corners and lip Fixed indices, never adaptive decimation: the vertex at slot k means the same
centres) of a 20-point ring. Fixed indices, never adaptive decimation: the thing on every frame of the shot.
vertex at slot k means the same thing on every frame of the shot."
WHICH CARDINAL POSITIONS SURVIVE DEPENDS ON n, and not merely on n being even.
On a 20-point ring the corners at 0 and 10 are kept by every even n, but the lip
centres at 5 and 15 are kept only when n is a multiple of 4: n = 6, 10, 14 and
18 all drop them. So nothing may assume a named slot is still in the output —
the aperture pair least of all. A signal that needs those two landmarks reads
them from a measurement, not from a subsampled ring."
[len n] [len n]
(mapv (fn [k] (mod (js/Math.round (/ (* k len) n)) len)) (range n))) (mapv (fn [k] (mod (js/Math.round (/ (* k len) n)) len)) (range n)))

View file

@ -0,0 +1,191 @@
(ns arthur.domain.select
"Which frames out of a dense measurement are worth keeping.
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 is `pose/held-frame`
and is already shared by tracing and by pose tracks; this is the choosing half,
which nothing did automatically before. One component, two sites: the plate
frames an artist has to draw a head on, and the frames a performance changes a
shape on.
`domain/cadence` answers the same question with no opinion about content, and
cannot know that the one frame where a mouth is fully shut is worth more than
its neighbours. That is the gap `:protect` closes.
THREE LAYERS, AND THE MIDDLE ONE IS DERIVED. A selection is a `:policy` (what
the proposer was asked for), a `:keep` and a `:drop` (what the hand insists on
and refuses). Only the hand's two sets are the document: the proposal is
recomputed whenever the policy or the signal changes, which is the whole reason
the hand's decisions are stored as their own sets rather than as the resulting
frame list. Re-suggesting at a new tolerance must never cost somebody their
pinned blink.
Pure. No store access and no clip access — each site reads its own dense data
and hands over a plain vector. Proposing is a command and not a subscription:
this allocates, which is fine for a button press over a few hundred frames and
would not be fine on the per-frame path.")
(defn- sample
"One sample as a flat vector of numbers, or nil where there is no measurement.
A plain number is a one-component sample, and nested points are flattened:
four transformed corners and the eight numbers in them are the same signal, so
neither site has to flatten on the way in. Nil is an ABSENT measurement — a
frame where the face was not found — which says nothing about the signal and is
not the same fact as a frame being skipped."
[s]
(cond
(nil? s) nil
(number? s) [s]
:else (not-empty (vec (flatten s)))))
(defn- distance
"How far apart two samples are: the largest absolute difference over their
components. A tolerance therefore means \"this far\" in the units the site
handed over, whether it handed over an aperture or a quad of corners, and
nobody has to say which.
Both samples are the same width, which `propose` has already insisted on rather
than comparing whatever prefix the two happen to share."
[a b]
(reduce (fn [m i] (max m (js/Math.abs (- (nth a i) (nth b i)))))
0 (range (count a))))
(defn- walk
"The greedy walk: keep frame 0 as the anchor, keep every frame in `forced`, and
keep every other frame that has moved further than `tolerance` from the anchor.
Each kept frame becomes the anchor.
A FORCED FRAME BECOMES THE ANCHOR LIKE ANY OTHER, which is why protection is
settled before the walk rather than unioned into its result. The anchor is what
is on screen; once a protected frame is kept the viewer is looking at it, so
measuring drift from a frame that is no longer displayed is simply wrong. Doing
it the other way holds a protected one-frame closure across two frames and
shows it twice as long as it was measured, which is the perceptual error the
protection was added to prevent.
Order-dependent and slightly suboptimal, and deliberately the first
implementation anyway — it is what the prototype shipped, and the dynamic
program that replaces it gets tested by being compared against it. Keeping the
anchor honest here leaves greed as the only difference between the two.
An absent sample is not a change: it cannot move the anchor and it is not worth
a frame. A measurement arriving where the anchor has none is, since a signal
coming back is a frame the picture has to show something on. A forced frame
with no measurement is still kept — it was asked for — and leaves the anchor
where it was, there being nothing there to measure from."
[n sig tolerance forced]
(loop [f 1 anchor (nth sig 0) kept (transient [0])]
(if (>= f n)
(persistent! kept)
(let [s (nth sig f)]
(if (or (contains? forced f)
(and s (or (nil? anchor) (> (distance anchor s) tolerance))))
(recur (inc f) (or s anchor) (conj! kept f))
(recur (inc f) anchor kept))))))
(defn- segments
"Maximal runs of consecutive measured frames. A gap is not something to compare
across: the samples either side of an absent measurement are not neighbours."
[n sig]
(->> (range n)
(partition-by #(some? (nth sig %)))
(remove #(nil? (nth sig (first %))))))
(defn- turns
"Frames in one measured segment that are a strict local extremum by more than
`tolerance` — a full mouth closure, a full blink. These are exactly the frames
a zero-order hold is most wrong about, and exactly what a cadence drops.
A run of equal samples is ONE extremum, reported at the frame it begins: the
hold reads from there, so naming any later frame of the run would show the
closure late. The ends of a segment are not extrema; they have only one side."
[sig tolerance frames]
(let [runs (vec (partition-by #(nth sig %) frames))
at #(first (nth sig %))]
(for [i (range 1 (dec (count runs)))
:let [f (first (nth runs i))
v (at f)
p (at (first (nth runs (dec i))))
q (at (first (nth runs (inc i))))]
:when (and (or (and (< v p) (< v q)) (and (> v p) (> v q)))
(> (min (js/Math.abs (- v p)) (js/Math.abs (- v q)))
tolerance))]
f)))
(defn propose
"Frames worth keeping out of `n`, given `signal`.
`signal` is a vector of n samples, each a number or a seq of numbers in a fixed
order. FRAME REMOVAL, NOT KEY EXTRACTION: every frame is a candidate and the
question is which can be dropped, which is why this walks and holds rather than
looking for peaks.
`:tolerance` is how far the signal may move before a frame has to be kept, in
whatever units the signal is in. `:protect` is the ONE input for frames that
have to be kept whatever the walk thought, and it carries two kinds of thing at
once so that it never has to become two parameters:
:extrema keep the signal's own strict local extrema as well
[12 30] keep these frames, whatever the walk thought
#{:extrema 12} both
The frames are how one selection constrains another — every kept plate frame is
a protected frame of the performance selection, so the drawing and the
performance can never cut against each other on neighbouring frames — and it is
the same mechanism that keeps a blink, which is why there is only one input.
`:extrema` is refused on a multi-component signal. The magnitude of a vector of
landmarks has local maxima that mean nothing; an aperture's closure is a real
extremum and a head has no such thing as an extreme position worth protecting.
Protection is settled BEFORE the walk, not unioned into its result, because a
protected frame anchors the walk like any other kept frame — see `walk`. The
result is therefore not `(walk) ∪ (protected)`: protected frames change what is
kept after them, which is the whole reason they are not applied afterwards.
Always contains frame 0 when there is a frame at all. The result is an ascending
vector of distinct frames, which is the shape both sites already store."
[n signal {:keys [tolerance protect]}]
(if-not (pos? n)
[]
(let [;; A slider that has been cleared reads as NaN, and every comparison
;; against NaN is false, which would propose frame 0 alone and quietly
;; collapse a whole performance to one pose. Nought keeps every frame
;; that changes, which is merely the cadence back again: wrong in a way
;; somebody can see and undo.
tol (if (js/Number.isFinite tolerance) tolerance 0)
sig (mapv #(sample (nth signal % nil)) (range n))
;; One width for the whole signal, so `distance` is never comparing the
;; prefix two samples happen to share. A reader that drops a component
;; on one frame is a bug with a loud version and a silent version, and
;; the silent version is a signal that is quietly the wrong shape.
seen (into #{} (map count) (remove nil? sig))
width (first seen)
p (cond (nil? protect) #{}
(keyword? protect) #{protect}
:else (set protect))
pins (into #{} (filter #(and (integer? %) (<= 0 %) (< % n))) p)]
(when (< 1 (count seen))
(throw (ex-info "signal samples are not all the same width"
{:widths (vec (sort seen))})))
(when (and (contains? p :extrema) width (< 1 width))
(throw (ex-info ":protect :extrema needs a single-component signal"
{:components width})))
(walk n sig tol (cond-> pins
(contains? p :extrema)
(into (mapcat #(turns sig tol %) (segments n sig))))))))
(defn effective
"`proposed` with the hand's decisions applied. Always contains 0: something has
to be on screen at the start.
Manual precedence is absolute — a drop beats a proposal, always — and it is the
proposal it beats, not the start of the take. Hand decisions apply whether or
not the proposer is switched on, which is why they are not a mode: turning
smart picking off must not throw away the frames somebody pinned."
[proposed keep drop]
(-> (reduce disj (into (set proposed) keep) drop)
(conj 0)
sort
vec))

View file

@ -321,21 +321,29 @@
business. An absent mouth outline has nothing to draw, but the head it hangs business. An absent mouth outline has nothing to draw, but the head it hangs
off is still exactly where it was, and that asymmetry is the whole reason off is still exactly where it was, and that asymmetry is the whole reason
presence is tracked per channel rather than per node." presence is tracked per channel rather than per node."
[{:keys [read mat-for pinv-for scratch]} n parent f] [{:keys [read mat-for pinv-for scratch]} n parent f pre]
(let [pf (if parent (:f parent) f)] (let [pf (if parent (:f parent) f)
;; Where the PREVIOUS grid slot landed, carried down the same path as `f`
;; through the same time maps. `(:pre parent)` rather than a second walk,
;; so an exposure fold or a retime on an ancestor is in it already — which
;; is what makes a held exposure's interval come out right without the snap
;; knowing exposure exists.
ppf (if parent (:pre parent) pre)]
(when (in-span? n pf) (when (in-span? n pf)
(let [id (:id n) (let [id (:id n)
chs (node/channels n) chs (node/channels n)
lf (node/local-frame n pf) lf (node/local-frame n pf)
rd (fn [path] (read id path (get chs path) lf))] plf (node/local-frame n ppf)
rd (fn [path] (read id path (get chs path) lf plf))]
(when (visible? id (rd [:vis])) (when (visible? id (rd [:vis]))
(when-let [[pos rot scl skw anc] (xform-at rd)] (when-let [[pos rot scl skw anc] (xform-at rd)]
;; dest aliases `local` here, which mul! allows: it reads both ;; dest aliases `local` here, which mul! allows: it reads both
;; operands fully before writing either. ;; operands fully before writing either.
(let [m (node/local! (mat-for id) pos rot scl skw anc)] (let [m (node/local! (mat-for id) pos rot scl skw anc)]
{:m (node/world! m (:m parent) (pinv-for id) m scratch) {:m (node/world! m (:m parent) (pinv-for id) m scratch)
:f lf :f lf
:rd rd}))))))) :pre plf
:rd rd})))))))
(defn- emit (defn- emit
"Emit geometry in the symbol's space. Rect sizes stay fractional until "Emit geometry in the symbol's space. Rect sizes stay fractional until
@ -398,19 +406,32 @@
(defn- base-channel-frame (defn- base-channel-frame
"A trace selects the measured frames its node reads; marked channels read "A trace selects the measured frames its node reads; marked channels read
instance pose choices." instance pose choices, and the preserve-snap where nobody has made one.
[choices traces nodes id c lf]
`lf` is this slot's local frame and `plf` the PREVIOUS slot's, both already
through placement and retime, so `(plf, lf]` is the interval this slot is the
first to cover — the frames the grid shows to nobody. That is the only thing the
snap needs from the grid, and it is why the pair is threaded this far down
instead of the snap happening where the grid becomes a native frame: the snap is
per pose group, and a group is a fact that only exists here."
[choices marks traces nodes id c lf plf]
(cond (cond
(contains? traces id) (contains? traces id)
(trace/held-frame (get traces id) lf) (trace/held-frame (get traces id) lf)
(:pose-sampled? c) (:pose-sampled? c)
(pose/source-frame choices (let [group (or (:pose-group (get nodes id)) id)]
(if (contains? choices [:node id]) (pose/source-frame choices
[:node id] (if (contains? choices [:node id]) [:node id] group)
(or (:pose-group (get nodes id)) id)) lf
lf ;; THE SNAP IS THE DEFAULT POSE. `source-frame` reaches a
(js/Math.floor lf)) ;; default only where the hand has said nothing, so seating
;; it here leaves an explicit cut beating a snap for free —
;; manual precedence is absolute — and costs no plumbing on
;; the pose side, per-group choices being threaded already.
(pose/snapped-frame marks group
(js/Math.floor plf)
(js/Math.floor lf))))
:else (js/Math.floor lf))) :else (js/Math.floor lf)))
@ -432,7 +453,7 @@
:pinv-for (fn [id] -> Float64Array|nil) its parent-inverse :pinv-for (fn [id] -> Float64Array|nil) its parent-inverse
:buf-for (fn [id n-points] -> Float64Array) :buf-for (fn [id n-points] -> Float64Array)
:scratch one spare 6-element matrix" :scratch one spare 6-element matrix"
[ctx nodes ord rank f] [ctx nodes ord rank f pre]
(-> (reduce (-> (reduce
(fn [{:keys [placed ops] :as acc} id] (fn [{:keys [placed ops] :as acc} id]
(let [n (get nodes id) (let [n (get nodes id)
@ -443,7 +464,7 @@
;; lookup instead of a subtree walk. ;; lookup instead of a subtree walk.
(if (and pid (nil? parent)) (if (and pid (nil? parent))
acc acc
(if-let [p (place ctx n parent f)] (if-let [p (place ctx n parent f pre)]
(let [_ (when-let [on-place (:on-place ctx)] (on-place id p)) (let [_ (when-let [on-place (:on-place ctx)] (on-place id p))
op (emit ctx n p {:node id :stencil (:stencil n)})] op (emit ctx n p {:node id :stencil (:stencil n)})]
(cond-> (update acc :placed assoc id p) (cond-> (update acc :placed assoc id p)
@ -464,22 +485,31 @@
transport's frame; inside an instance it is the instance's own space, and the instance boundary is transport's frame; inside an instance it is the instance's own space, and the instance boundary is
the only place the space changes. the only place the space changes.
`pre` is where the grid slot BEFORE this one landed, which with `f` is the
interval the preserve-snap may reach back into — see `pose/snapped-frame`. The
shorter arity means one native frame per slot, so the interval is `f` alone and
nothing snaps: that is what a caller rendering a native frame directly is asking
for, and it is what keeps this evaluator and `resolver` the same answer.
This is the definition of what a frame means. `resolver` is what plays it." This is the definition of what a frame means. `resolver` is what plays it."
[sym f store palette {:keys [pose-tracks]}] ([sym f store palette opts] (eval-frame sym f (dec f) store palette opts))
([sym f pre store palette {:keys [pose-tracks snap]}]
(let [nodes (nodes-of sym) (let [nodes (nodes-of sym)
choices (pose/prepare pose-tracks) choices (pose/prepare pose-tracks)
marks (when (and snap (snap (:id sym)))
(pose/marks nodes (:frames sym) store))
traces (prepared-traces nodes) traces (prepared-traces nodes)
ord (order nodes)] ord (order nodes)]
(eval-into {:read (fn [id path c lf] (eval-into {:read (fn [id path c lf plf]
(ch/value-at c (base-channel-frame choices traces nodes (ch/value-at c (base-channel-frame choices marks traces
id c lf) nodes id c lf plf)
lf store)) lf store))
:palette palette :palette palette
:mat-for (fn [_id] (node/mat)) :mat-for (fn [_id] (node/mat))
:pinv-for (fn [id] (node/pinv (get nodes id))) :pinv-for (fn [id] (node/pinv (get nodes id)))
:buf-for (fn [_id n] (js/Float64Array. (* 2 n))) :buf-for (fn [_id n] (js/Float64Array. (* 2 n)))
:scratch (node/mat)} :scratch (node/mat)}
nodes ord (draw-rank nodes ord) f))) nodes ord (draw-rank nodes ord) f pre))))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; the playback path ;; the playback path
@ -511,7 +541,14 @@
through or it is merely decorative, and it paints immediately after the frame through or it is merely decorative, and it paints immediately after the frame
it belongs to, so \"as of the last frame\" is the only answer that can be it belongs to, so \"as of the last frame\" is the only answer that can be
correct.") correct.")
(frame-of [this id] "The placed node's local frame on the last resolve.")) (frame-of [this id] "The placed node's local frame on the last resolve.")
(pre-frame-of [this id]
"The same node's local frame for the grid slot BEFORE the last resolve.
Half of the preserve-snap's interval, and the half only the walk can answer:
the mapping from a grid slot to a node's local frame runs through every time
map between them, so the frame the previous slot landed on is what the walk
carried, not something a caller can recompute from the slot index."))
(defn resolver (defn resolver
"(fn [f] -> ops). Holds everything that does not change per frame. "(fn [f] -> ops). Holds everything that does not change per frame.
@ -531,9 +568,26 @@
because a fifth of them later is then a key rather than a nil at every one of because a fifth of them later is then a key rather than a nil at every one of
these call sites — which is what the arity ladder that used to be here was these call sites — which is what the arity ladder that used to be here was
standing in for." standing in for."
[sym store palette {:keys [pose-tracks]}] [sym store palette {:keys [pose-tracks snap]}]
(let [nodes (nodes-of sym) (let [nodes (nodes-of sym)
choices (pose/prepare pose-tracks) choices (pose/prepare pose-tracks)
;; NO MARKS IS THE OFF STATE, and that it needs no second code path is
;; the reason `snapped-frame` falls back to the slot's own default rather
;; than being asked whether it should. Off is the cadence alone, which is
;; what ships today, so it is also the DEFAULT: an opts map that says
;; nothing gets the behaviour it got before the snap existed.
;;
;; `:snap` IS ASKED ABOUT THIS SYMBOL, not the resolver, because the marks
;; are the FACE'S: the closure cuts are stored on its own nodes, so every
;; placement of one face has the same ones and a per-placement answer would
;; be a setting with nothing in it. A set of symbol ids is the usual
;; argument, and any predicate on one will do.
;;
;; One option, and it is the snap's alone. The plate side will never want
;; one — its proposal is materialised into `:trace :frames` rather than
;; computed on the render path — so this is not half of a pair.
marks (when (and snap (snap (:id sym)))
(pose/marks nodes (:frames sym) store))
traces (prepared-traces nodes) traces (prepared-traces nodes)
ord (order nodes) ord (order nodes)
rank (draw-rank nodes ord) rank (draw-rank nodes ord)
@ -556,11 +610,11 @@
;; eval-into having to report it — and it covers groups, which are ;; eval-into having to report it — and it covers groups, which are
;; placed but emit no op, and which are exactly what an underlay rides. ;; placed but emit no op, and which are exactly what an underlay rides.
placed (volatile! {}) placed (volatile! {})
ctx {:read (fn [id path c lf] ctx {:read (fn [id path c lf plf]
(when-let [cursor (get-in cursors [id path])] (when-let [cursor (get-in cursors [id path])]
(ch/sample! cursor (ch/sample! cursor
(base-channel-frame choices traces nodes (base-channel-frame choices marks traces
id c lf) nodes id c lf plf)
lf))) lf)))
:palette palette :palette palette
:mat-for (fn [id] (get mats id)) :mat-for (fn [id] (get mats id))
@ -568,15 +622,18 @@
:pinv-for (fn [id] (get pinvs id)) :pinv-for (fn [id] (get pinvs id))
:buf-for (fn [id _n] (get bufs id)) :buf-for (fn [id _n] (get bufs id))
:scratch scratch} :scratch scratch}
step (fn [f] step (fn [f pre]
(vreset! placed {}) (vreset! placed {})
(eval-into ctx nodes ord rank f))] (eval-into ctx nodes ord rank f pre))]
(reify (reify
IFn IFn
(-invoke [_ f] (step f)) ;; One frame and no interval is one native frame per slot — see `eval-frame`.
(-invoke [_ f] (step f (dec f)))
(-invoke [_ f pre] (step f pre))
IResolver IResolver
(world-of [_ id] (:m (get @placed id))) (world-of [_ id] (:m (get @placed id)))
(frame-of [_ id] (:f (get @placed id)))))) (frame-of [_ id] (:f (get @placed id)))
(pre-frame-of [_ id] (:pre (get @placed id))))))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------

View file

@ -144,7 +144,8 @@
footage showing rather than with a switch to be found first. A take or a scene footage showing rather than with a switch to be found first. A take or a scene
is the picture itself, and a reference drawn over one would read as part of it, is the picture itself, and a reference drawn over one would read as part of it,
so nothing is switched on for those. Either way it is switched by hand so nothing is switched on for those. Either way it is switched by hand
afterwards, from the bar above the stage or from a face's own timeline row." afterwards, from the inspector's footage section or from a face's own timeline
row."
[clip sid on] [clip sid on]
(cond-> (set on) (traceable? clip sid) (conj sid))) (cond-> (set on) (traceable? clip sid) (conj sid)))

View file

@ -334,9 +334,9 @@
(rf/reg-event-db (rf/reg-event-db
::trace-faces ::trace-faces
;; Every face the open symbol has, from the bar above the stage: switched on ;; Every face the open symbol has, from the inspector's footage section:
;; unless they all already are, which is the one gesture a person wants when ;; switched on unless they all already are, which is the one gesture a person
;; there is exactly one face and when there are five. ;; wants when there is exactly one face and when there are five.
(fn [db [_ faces]] (fn [db [_ faces]]
(let [faces (set faces) (let [faces (set faces)
on (set (get-in db [:ui :trace :faces]))] on (set (get-in db [:ui :trace :faces]))]
@ -347,6 +347,20 @@
::trace-opacity ::trace-opacity
(fn [db [_ opacity]] (assoc-in db [:ui :trace :opacity] opacity))) (fn [db [_ opacity]] (assoc-in db [:ui :trace :opacity] opacity)))
(rf/reg-event-db
::smart-picking
;; THE EDITOR'S, not the document's. The setting in docs/frame-selection.md is
;; the document's and wants a leaf and a round trip before it can be written;
;; until then this switches what the stage shows so the snap can be looked at,
;; and an export is unaffected. Which is why it is not undoable and not synced.
;;
;; Keyed by FACE, as `[:ui :trace :faces]` is and for the same reason: what it
;; reads is stored on the face's own nodes, so it is the same answer wherever
;; that face is placed.
(fn [db [_ face on]]
(update-in db [:ui :smart] #(let [on? (boolean on)]
((if on? conj disj) (set %) face)))))
(defn- where-new-goes (defn- where-new-goes
"The row path, from the open symbol down, of the symbol a new thing goes into: "The row path, from the open symbol down, of the symbol a new thing goes into:
INSIDE the selected instance, or BESIDE any other selected node, or at the top INSIDE the selected instance, or BESIDE any other selected node, or at the top

View file

@ -26,6 +26,11 @@
(rf/reg-sub ::gesture (fn [db _] (get-in db [:ui :gesture]))) (rf/reg-sub ::gesture (fn [db _] (get-in db [:ui :gesture])))
(rf/reg-sub ::solo (fn [db _] (get-in db [:ui :solo (get-in db [:ui :open])]))) (rf/reg-sub ::solo (fn [db _] (get-in db [:ui :solo (get-in db [:ui :open])])))
(rf/reg-sub ::tracing (fn [db _] (get-in db [:ui :trace]))) (rf/reg-sub ::tracing (fn [db _] (get-in db [:ui :trace])))
;; The faces with smart frame picking on, as a PREVIEW switch. The setting
;; docs/frame-selection.md specifies is the document's and is not built; this is
;; the editor's own, so it moves what the stage shows and not what exports.
;; Per FACE, because the closure cuts it reads are stored on the face's nodes.
(rf/reg-sub ::smart (fn [db _] (set (get-in db [:ui :smart]))))
(rf/reg-sub (rf/reg-sub
::clip ::clip
@ -127,9 +132,10 @@
:<- [::open] :<- [::open]
:<- [::store] :<- [::store]
:<- [::palette] :<- [::palette]
(fn [[document sid store palette] _] :<- [::smart]
(fn [[document sid store palette smart] _]
(when (and document (clip/symbol document sid)) (when (and document (clip/symbol document sid))
(clip/resolver document sid store palette nil)))) (clip/resolver document sid store palette {:snap smart}))))
(rf/reg-sub (rf/reg-sub
::shown ::shown
@ -158,7 +164,8 @@
(resolve f))) (resolve f)))
symbol/IResolver symbol/IResolver
(world-of [_ path] (symbol/world-of resolve path)) (world-of [_ path] (symbol/world-of resolve path))
(frame-of [_ path] (symbol/frame-of resolve path))))))) (frame-of [_ path] (symbol/frame-of resolve path))
(pre-frame-of [_ path] (symbol/pre-frame-of resolve path)))))))
(rf/reg-sub (rf/reg-sub
::underlay ::underlay

View file

@ -13,16 +13,12 @@
polygon filled with index 0 is invisible against a stage cleared to index 0, so polygon filled with index 0 is invisible against a stage cleared to index 0, so
offering it as a fill is offering a shape that vanishes on creation. offering it as a fill is offering a shape that vanishes on creation.
The footage switch is here too, because it is the same kind of thing as the The footage switch is NOT here. It is a viewing aid rather than something you
tone and the tool: something you set before you draw and leave alone, in one set before you draw, so it is a section of the inspector — one that is there
place whatever is selected. In the inspector it was a section that appeared for whatever the open symbol has faces of, so it still does not come and go
only once the right row had been found — so the way to see the footage you are with the selection. See `ui/params`."
tracing depended on what you had clicked, which is not a thing anyone can be
expected to learn."
(:require [arthur.domain.palette :as pal] (:require [arthur.domain.palette :as pal]
[arthur.domain.trace :as trace]
[arthur.events.ui :as ui] [arthur.events.ui :as ui]
[arthur.subs.render :as render]
[arthur.subs.ui :as sub] [arthur.subs.ui :as sub]
[re-frame.core :as rf])) [re-frame.core :as rf]))
@ -43,31 +39,6 @@
:disabled (not pick) :disabled (not pick)
:on-click #(rf/dispatch [::ui/set-tone slot-tone])}])) :on-click #(rf/dispatch [::ui/set-tone slot-tone])}]))
(defn- tracing
"The footage under the faces the open symbol has, on or off and how strongly.
ONE SWITCH FOR THE FACES THAT ARE HERE. A face's footage is the face's, not a
placement's, so there is nothing to inherit and nothing to set twice; with
several faces in a take the box says how many are showing and switches the rest
on, and one face alone is switched from its own timeline row."
[]
(let [clip @(rf/subscribe [::render/clip])
open @(rf/subscribe [::render/open])
{:keys [faces opacity]} @(rf/subscribe [::render/tracing])
here (trace/traceable-faces clip open)
on (filterv (set faces) here)]
(when (seq here)
[:<>
[:label.dim {:title (str "show the footage these faces were traced from, over the "
"picture · a reference, never exported")}
[:input {:type "checkbox" :checked (= (count on) (count here))
:on-change #(rf/dispatch [::ui/trace-faces here])}]
(str " footage" (when (< 1 (count here)) (str " " (count on) "/" (count here))))]
[:input.trace-opacity
{:type "range" :min 0 :max 1 :step 0.05 :title "how strongly the footage draws"
:value (or opacity trace/opacity-default) :disabled (empty? on)
:on-change #(rf/dispatch [::ui/trace-opacity (js/parseFloat (.. % -target -value))])}]])))
(defn bar [] (defn bar []
(let [tone @(rf/subscribe [::sub/tone]) (let [tone @(rf/subscribe [::sub/tone])
tool @(rf/subscribe [::sub/tool]) tool @(rf/subscribe [::sub/tool])
@ -76,7 +47,6 @@
[:div.swatches (doall (map #(swatch % tone) (range slots)))] [:div.swatches (doall (map #(swatch % tone) (range slots)))]
[:span.dim (name tone)] [:span.dim (name tone)]
[:span {:style {:flex 1}}] [:span {:style {:flex 1}}]
[tracing]
(if (= :polygon tool) (if (= :polygon tool)
[:<> [:<>
[:span.dim (str (quot (count draft) 2) " points")] [:span.dim (str (quot (count draft) 2) " points")]

View file

@ -13,6 +13,7 @@
[arthur.domain.node :as node] [arthur.domain.node :as node]
[arthur.domain.paint :as paint] [arthur.domain.paint :as paint]
[arthur.domain.params :as params] [arthur.domain.params :as params]
[arthur.domain.pose :as pose]
[arthur.domain.trace :as trace] [arthur.domain.trace :as trace]
[arthur.events.history :as history] [arthur.events.history :as history]
[arthur.events.paint :as paint-events] [arthur.events.paint :as paint-events]
@ -356,6 +357,40 @@
[:div {:title why} (str (brief node) " · " [:div {:title why} (str (brief node) " · "
(str/join " " (map name channel)) " · " why)]))])]))))) (str/join " " (map name channel)) " · " why)]))])])))))
;; ---------------------------------------------------------------------------
;; the footage showing under the picture
(defn- footage-section
"The footage under the faces the open symbol has, on or off and how strongly.
`here` is those faces.
ONE SWITCH FOR THE FACES THAT ARE HERE. A face's footage is the face's, not a
placement's, so there is nothing to inherit and nothing to set twice; with
several faces in a take the box says how many are showing and switches the rest
on, and one face alone is switched from its own timeline row.
THE OPEN SYMBOL'S FACES AND NOT THE SELECTION'S, which is what lets this live in
the inspector at all: a viewing aid that appeared only once the right row had
been found would make the way to see the footage you are tracing depend on what
you had clicked. So the section is there whenever the picture on the stage has
any footage behind it, wherever the selection happens to be."
[here]
(let [{:keys [faces opacity]} @(rf/subscribe [::render/tracing])
on (filterv (set faces) here)]
[section "footage"
[:div.row
[:label.dim {:title (str "show the footage these faces were traced from, over the "
"picture \u00b7 a reference, never exported")}
[:input {:type "checkbox" :checked (= (count on) (count here))
:on-change #(rf/dispatch [::ui/trace-faces here])}]
(str " show" (when (< 1 (count here)) (str " " (count on) "/" (count here))))]]
[:div.row {:style {:margin-top "5px"}}
[:span.dim "opacity"]
[:input.trace-opacity
{:type "range" :min 0 :max 1 :step 0.05 :title "how strongly the footage draws"
:value (or opacity trace/opacity-default) :disabled (empty? on)
:on-change #(rf/dispatch [::ui/trace-opacity (js/parseFloat (.. % -target -value))])}]]]))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; tracing a face ;; tracing a face
;; ;;
@ -363,10 +398,15 @@
;; to an instance of it: which of its frames its drawings were made over, and ;; to an instance of it: which of its frames its drawings were made over, and
;; what its origin does between those frames. See `domain/trace`. ;; what its origin does between those frames. See `domain/trace`.
;; ;;
;; Whether the footage is SHOWING is deliberately not here. It is a viewing aid ;; `performance-section` below it is the face's too, for a third reason of the
;; and it belongs with the other things you look through rather than edit, on the ;; same kind: the closure cuts the preserve-snap reads are stored on the face's
;; bar above the stage — where it is in the same place whatever is selected, ;; own nodes, so every placement of one face has the same marks, and a
;; instead of appearing in the inspector only once the right row has been found. ;; per-placement switch would be a setting with nothing in it.
;;
;; Whether the footage is SHOWING is `footage-section` above, and not part of this
;; section. It is a viewing aid rather than a fact about a face, and it must not
;; come and go with the selection — so it is keyed to the OPEN symbol's faces,
;; which is a different question from the one this section answers.
(defn- trace-keys (defn- trace-keys
"The face's trace keys and origin. `frame` is the frame of the FACE that the "The face's trace keys and origin. `frame` is the frame of the FACE that the
@ -408,6 +448,52 @@
:on-click #(put (assoc t :origin o))} :on-click #(put (assoc t :origin o))}
label]))]])) label]))]]))
(defn- performance-section
"Smart frame picking for one face, and what it has to work with.
ONE SWITCH AND NO TOLERANCE. The output rate or the exposure setting has already
chosen how sparse the performance is; all the snap decides is WHICH native frame
an already-decided slot reads, so there is nothing here for a knob to mean. See
docs/frame-selection.md.
The groups and their counts stand in for the frame strip that document specifies,
which is not built. What a person needs before switching this on is whether the
take has any closures stored at all: a take whose mouth never shuts far enough to
cross `aperture-cut` is marked nowhere, and the switch would otherwise read as
broken rather than as having nothing to do.
EDITOR STATE, and the readout says so. The document setting wants a leaf and a
round trip of its own; until then this moves the stage and not an export."
[face]
(let [clip @(rf/subscribe [::render/clip])
store @(rf/subscribe [::render/store])
smart @(rf/subscribe [::render/smart])
sym (get-in clip [:symbols face])
marks (pose/marks (:nodes sym) (:frames sym) store)
on? (contains? smart face)]
[section (str "performance \u00b7 " (name face))
[:div.row {:style {:margin "5px 0"}}
[:label.dim {:title (str "let a closure the grid drops move the frame a slot "
"reads \u2014 backward, and only within that slot's own gap")}
[:input {:type "checkbox" :checked on?
:on-change #(rf/dispatch [::ui/smart-picking face (not on?)])}]
" smart frame picking"]]
(if (empty? marks)
[:div.row [:span.dim "no closures stored on this face \u2014 nothing to land on"]]
[:div.row
[:span.dim "marks"]
(doall
(for [[group fs] (sort-by (comp str key) marks)]
^{:key (str group)}
[:span.dim {:style {:margin-right "8px"}
:title (str "frames the stored cut calls shut: "
(str/join " " (take 24 fs))
(when (> (count fs) 24) " \u2026"))}
(str (if (vector? group) (str/join "/" (map name group)) (name group))
" " (count fs))]))])
[:div.row
[:span.dim "the stage only \u00b7 an export is unaffected until this is a saved setting"]]]))
(defn- tracing-section (defn- tracing-section
"`face` is the face these facts belong to, `faces` the faces placed inside it "`face` is the face these facts belong to, `faces` the faces placed inside it
to offer as somewhere to go next, and `path` the row path `faces` are under." to offer as somewhere to go next, and `path` the row path `faces` are under."
@ -566,6 +652,10 @@
placed (node/source (peek node)) placed (node/source (peek node))
face (or placed (when (trace/traceable? clip open) open)) face (or placed (when (trace/traceable? clip open) open))
faces (when face (trace/faces clip face)) faces (when face (trace/faces clip face))
;; Every face the open symbol has, which is what the footage switch is
;; about: the stage either has footage behind it or it has none, and that
;; does not depend on what is selected.
here (trace/traceable-faces clip open)
;; Where that face sits, as a row path from the open symbol, so the faces ;; Where that face sits, as a row path from the open symbol, so the faces
;; inside it can be selected by their own rows. A selection made on the ;; inside it can be selected by their own rows. A selection made on the
;; stage has no path and names a node directly in the open symbol; the ;; stage has no path and names a node directly in the open symbol; the
@ -578,7 +668,9 @@
(when node [node-section node]) (when node [node-section node])
(when node ^{:key (str (first node) "/" (second node))} (when node ^{:key (str (first node) "/" (second node))}
[correction-section node]) [correction-section node])
(when (seq here) [footage-section here])
(when (and face (or (trace/traceable? clip face) (seq faces))) (when (and face (or (trace/traceable? clip face) (seq faces)))
[tracing-section face faces path]) [tracing-section face faces path])
(when face ^{:key (str "perf/" face)} [performance-section face])
(when (= :symbol (first selection)) [symbol-section (second selection)]) (when (= :symbol (first selection)) [symbol-section (second selection)])
(when tracked? [tracking-section])]])) (when tracked? [tracking-section])]]))

View file

@ -478,8 +478,8 @@
(when node? [:span.kind (if via (str "· in " via) (str "·" (name node-kind)))]) (when node? [:span.kind (if via (str "· in " via) (str "·" (name node-kind)))])
;; A face's row is where its own footage is switched on, next to solo ;; A face's row is where its own footage is switched on, next to solo
;; because the two are the same kind of thing: what this row shows, here, ;; because the two are the same kind of thing: what this row shows, here,
;; now, and nothing the picture keeps. The bar above the stage does all of ;; now, and nothing the picture keeps. The inspector's footage section does
;; them at once; this is how one face out of a take is singled out. ;; all of them at once; this is how one face out of a take is singled out.
(when (contains? (:faces tracing) of) (when (contains? (:faces tracing) of)
[:button {:class (str "tl-trace" (when (contains? (:on tracing) of) " on")) [:button {:class (str "tl-trace" (when (contains? (:on tracing) of) " on"))
:title "show the footage this face was traced from" :title "show the footage this face was traced from"

View file

@ -68,3 +68,140 @@
(let [slow (assoc-in doc [:symbols :main :nodes :insert :playback :speed] 0.5) (let [slow (assoc-in doc [:symbols :main :nodes :insert :playback :speed] 0.5)
[track] (nest/audio-tracks slow :main)] [track] (nest/audio-tracks slow :main)]
(is (= 0.5 (* (get-in track [:time :rate]) (/ (:fps slow) (:fps track))))))))) (is (= 0.5 (* (get-in track [:time :rate]) (/ (:fps slow) (:fps track)))))))))
;; ---------------------------------------------------------------------------
;; the preserve-snap
;;
;; `footage` above is the cadence with no opinion about content: at 12 out of 30
;; it reads 0, 2, 5, 7, 10, 12, 15 … and the frames between those it shows to
;; nobody. `closure` is the same take with something in one of those gaps worth
;; seeing — docs/frame-selection.md's synthetic case, a mouth that shuts for ONE
;; native frame, 13, which no 12-from-30 slot ever samples.
(defn- sized
"A dense `[:geom :size]` over the whole take. The store holds 1..60, so a DRAWN
SIZE NAMES THE NATIVE FRAME it was read from: size = frame + 1. That is the
whole reason the fixture draws rects — the picture says which frame it is."
[]
{:animated? true :dense {:store "sizes" :offset 0 :stride 1 :frames 60}})
(def closure
"A head that holds still and a mouth that shuts for one native frame.
The cut is the one `flow/freeze` already stores and nothing new: `[:vis]` keys
on the interior, `:hold`, provenance `:roto/mouth-aperture`, hidden meaning
shut. The head's size is NOT `:pose-sampled?` and carries no `:pose-group`,
which is what makes it the control — a head reads the cadence whatever the
mouth does."
{:fps 30 :width 100 :height 100
:symbols
{:face
{:id :face :fps 30 :frames 60
:nodes
{:head {:id :head :kind :rect :z "a"
:channels {[:geom :size] (sized)
[:style :color] (ch/framed :brow)}}
:mouth-in {:id :mouth-in :kind :rect :parent :head :z "b"
:pose-group :mouth
:channels {[:geom :size] (assoc (sized) :pose-sampled? true)
[:style :color] (ch/framed :mouth-dark)
[:vis] (assoc (ch/keyed {0 true, 13 false, 14 true} :hold)
:pose-sampled? true
:generated {:by :roto/mouth-aperture})}}}}}})
(defn- drawn
"What each node draws at output frame `f`, as `node -> size`. A node missing
from the map was not drawn at all, which for the mouth interior IS the shut
mouth: `[:vis]` false takes the part off the frame."
[r f]
(into {} (map (juxt :node :size)) (r f)))
(deftest a-dropped-closure-reaches-exactly-one-output-frame
(let [doc (clip/set-fps closure 12)
n (clip/output-frames doc :face)
r (clip/resolver doc :face store pal/index-of {:snap #{:face}})
fs (mapv #(drawn r %) (range n))]
(is (= 24 n))
(is (not-any? #{13} (map #(cadence/frame % 12 30) (range n)))
"the cadence alone never samples the closure — that is the gap")
(testing "off is the cadence alone, and is what an opts map saying nothing gets"
(doseq [[what opts] [["no opts at all" nil]
["another face switched on" {:snap #{:somebody-else}}]]]
(let [off (clip/resolver doc :face store pal/index-of opts)]
(is (every? #(contains? (drawn off %) :mouth-in) (range n))
(str "with " what " the closure is never recovered"))
(is (= (mapv #(inc (cadence/frame % 12 30)) (range n))
(mapv #(:mouth-in (drawn off %)) (range n)))
(str "with " what " every slot reads what the grid says")))))
(is (= [6] (filterv #(not (contains? (nth fs %) :mouth-in)) (range n)))
"the shut mouth reaches exactly one output frame")
(is (= [1 3 6 8 11 13 nil 18] (mapv :mouth-in (take 8 fs)))
"slot 6 snaps back from 15 to 13; no other slot moves")
(is (= (mapv #(inc (cadence/frame % 12 30)) (range n)) (mapv :head fs))
"and the head reads what the cadence says, on every frame including 6")
(testing "through an instance, where the grid becomes native at the boundary"
(let [host (-> doc
(assoc-in [:symbols :stage] {:id :stage :fps 12 :frames 24 :nodes {}})
(clip/place-symbol store :stage :face 0 :cel nil))
hr (clip/resolver host :stage store pal/index-of {:snap #{:face}})
hfs (mapv #(drawn hr %) (range 24))]
(is (= [6] (filterv #(not (contains? (nth hfs %) [:cel :mouth-in])) (range 24))))
(is (= [1 3 6 8 11 13 nil 18] (mapv #(get % [:cel :mouth-in]) (take 8 hfs))))
(is (= (mapv #(inc (cadence/frame % 12 30)) (range 24))
(mapv #(get % [:cel :head]) hfs)))
(is (= (mapv #(inc (cadence/frame % 12 30)) (range 24))
(let [hostly (clip/resolver host :stage store pal/index-of
{:snap #{:stage}})]
(mapv #(get (drawn hostly %) [:cel :mouth-in]) (range 24))))
"the switch is the FACE's: naming the host that places it marks nothing")
(testing "a hand cut beats a snap, always"
(let [held (pose/put-cut host :stage :cel :mouth 0 0)
kr (clip/resolver held :stage store pal/index-of {:snap #{:face}})
kfs (mapv #(drawn kr %) (range 24))]
(is (= (vec (repeat 24 1)) (mapv #(get % [:cel :mouth-in]) kfs))
"the hand said hold frame 0, so the closure is never recovered")))))))
(deftest preserve-marks-are-the-frames-a-closure-cut-calls-shut
(let [nodes (get-in closure [:symbols :face :nodes])
vis (fn [ch] {:solo {:id :solo :kind :rect :pose-group :solo
:channels {[:vis] ch}}})]
(is (= {:mouth [13]} (pose/marks nodes 60 store))
"one mark, per pose group, for the one frame the cut calls shut")
(is (nil? (pose/marks (select-keys nodes [:head]) 60 store))
"a node with no [:vis] has no closure and gets no marks")
(is (nil? (pose/marks nodes nil store))
"a symbol with no length has no frames to mark")
(testing "a hidden feature is not a closure"
(is (nil? (pose/marks (vis (assoc (ch/keyed {0 true, 13 false, 14 true} :hold)
:generated {:by :pixels/teeth}))
60 store))
"the teeth being occluded says nothing about a mouth shutting")
(is (nil? (pose/marks (vis (ch/keyed {0 true, 13 false, 14 true} :hold)) 60 store))
"and a hand-keyed [:vis] with no provenance is not a cut either"))
(testing "an absent measurement is not a closure"
(is (nil? (pose/marks (vis (assoc (ch/keyed {} :hold)
:generated {:by :roto/blink}))
60 store))
"no value on a frame is not the same fact as shut on it"))))
(deftest a-slot-snaps-back-to-a-mark-and-never-forward
(let [m (pose/marks (get-in closure [:symbols :face :nodes]) 60 store)]
(is (= 13 (pose/snapped-frame m :mouth 12 15))
"slot 6, whose default is 15, reads 13")
(is (= 12 (pose/snapped-frame m :mouth 10 12))
"slot 5 does NOT reach forward from 12 to 13 — that would lead the cut")
(is (= 17 (pose/snapped-frame m :mouth 15 17))
"slot 7 cannot reach back past its own gap to a frame slot 6 showed")
(is (= 13 (pose/snapped-frame m :mouth 12 13))
"a mark that IS the default is simply the default")
(is (= 15 (pose/snapped-frame m :mouth 15 15))
"an empty interval — a slot reading what the one before it read — snaps nothing")
(is (= 15 (pose/snapped-frame m :other 12 15))
"marks are per group: another group's closure is not this one's")
(is (= 15 (pose/snapped-frame nil :mouth 12 15))
"and no marks at all is the cadence back again")
(testing "never later than the slot's own instant, over every grid and native pair"
(doseq [grid [8 12 24 30 60] native [12 24 30 60] f (range 90)]
(let [hi (cadence/frame f grid native)
lo (if (pos? f) (cadence/frame (dec f) grid native) -1)]
(is (<= (pose/snapped-frame m :mouth lo hi) (/ (* f native) grid))))))))

View file

@ -0,0 +1,213 @@
(ns arthur.domain.select-test
"The one-frame closure is the feature, so it is the first test in the file.
Both the cadence and a selection are sets of native frames read the same way —
hold the latest one at or before now — so they are compared through the same
reading function over the same native frames. Nothing here goes near a store,
a clip or an output grid: `select` is handed a vector of numbers."
(:require [cljs.test :refer [deftest is testing]]
[clojure.set :as set]
[arthur.domain.cadence :as cadence]
[arthur.domain.pose :as pose]
[arthur.domain.select :as select]))
;; A mouth over 30 native frames that is fully shut for exactly ONE of them.
;;
;; It is already nearly shut either side of frame 13 — the aperture wobbles
;; between 0.015 and 0.03 — and at 13 it reaches 0. That is the shape of the
;; case a walk cannot see on displacement alone: the closure moves the signal by
;; 0.015, less than any tolerance worth using on the talking either side of it,
;; while being the only frame that reads as a closed mouth.
(def aperture
[0.30 0.30 0.26 0.20 0.14 0.10 0.07 0.04 0.03 0.025 0.015 0.015
0.03 0.0 0.03
0.06 0.09 0.15 0.22 0.30 0.36 0.40 0.43 0.43 0.40 0.36 0.30 0.24 0.18 0.12])
(def closure 13)
(def tol 0.02)
(defn held
"The sample `signal` shows at native frame `f` when read through a selection of
native `frames`. This is the existing shared reading half, not a second one."
[signal frames f]
(nth signal (pose/held-frame (mapv #(vector % %) frames) f (first frames))))
(defn shut
"The frames on which `signal` read through the selection `frames` shows a fully
shut mouth. With `frames` nil it is the frames the mouth was MEASURED shut on,
which is what a selection is answerable to: the picture should show a closure
for exactly as long as it happened, and no longer."
[signal frames]
(filterv #(zero? (if frames (held signal frames %) (nth signal %)))
(range (count signal))))
(deftest a-one-frame-closure-survives-only-because-it-is-protected
(let [grid (mapv #(cadence/frame % 12 30) (range 12))
walk (select/propose 30 aperture {:tolerance tol})
kept (select/propose 30 aperture {:tolerance tol :protect :extrema})]
(testing "a uniform 12-from-30 cadence drops it"
(is (= [0 2 5 7 10 12 15 17 20 22 25 27] grid))
(is (not (some #{closure} grid)))
;; Not one of the twelve frames a cadence keeps is a shut mouth, and
;; holding over them never reaches one either: at the closure it is still
;; showing frame 12, which is open.
(is (= 0.03 (held aperture grid closure)))
(is (empty? (shut aperture grid))))
(testing "the walk alone drops it too, because displacement is all it sees"
(is (= [0 2 3 4 5 6 7 10 15 16 17 18 19 20 21 22 24 25 26 27 28 29] walk))
(is (not (some #{closure} walk)))
;; It holds frame 10 straight across the closure and on to 15.
(is (= 0.015 (held aperture walk closure)))
(is (empty? (shut aperture walk))))
(testing ":protect :extrema keeps it, for exactly as long as it happened"
(is (some #{closure} kept))
(is (zero? (held aperture kept closure)))
(is (= [closure] (shut aperture nil) (shut aperture kept)))
;; "At or before", so it does not show early either.
(is (= 0.015 (held aperture kept 12))))
(testing "the protected frame anchors the walk, so the reopening is kept too"
;; Frame 14 is the mouth coming back open. Without it the closure at 13
;; would hold across 14 and read twice as long as it was measured, which
;; is why protection is settled before the walk and not unioned onto it.
(is (= #{closure 14} (set/difference (set kept) (set walk))))
(is (= 0.03 (held aperture kept 14))))))
(deftest extrema-are-turns-worth-more-than-the-tolerance
;; One shape, two depths, one tolerance. Neither dip moves the signal far
;; enough for the walk to keep anything, so what separates them is prominence
;; alone — the tolerance doing its job rather than an accident of the data.
(testing "a shallow wobble is not a closure"
(let [s [0.015 0.025 0.01 0.01 0.025 0.015]]
(is (= [0] (select/propose 6 s {:tolerance tol})))
(is (= [0] (select/propose 6 s {:tolerance tol :protect :extrema})))))
(testing "a two-frame closure is one extremum, at the frame it begins"
;; The hold reads from the frame the closure begins on, so naming the second
;; frame of the run instead would show it late. Frame 4 is kept because the
;; protected frame 2 anchored the walk, and that is what makes the closure
;; the same two frames long on screen as it was measured.
(let [s [0.015 0.03 0.0 0.0 0.03 0.015]
kept (select/propose 6 s {:tolerance tol :protect :extrema})]
(is (= [0] (select/propose 6 s {:tolerance tol})))
(is (= [0 2 4] kept))
(is (= [2 3] (shut s nil) (shut s kept)))))
(testing "a monotone ramp has no extrema to protect"
(is (= (select/propose 8 [0 1 2 3 4 5 6 7] {:tolerance 2})
(select/propose 8 [0 1 2 3 4 5 6 7] {:tolerance 2 :protect :extrema}))))
(testing "the ends are not extrema; frame 0 is kept for being the start"
(is (= [0] (select/propose 3 [0.0 0.5 1.0] {:tolerance 2 :protect :extrema})))))
(deftest a-motionless-take-proposes-one-frame
(is (= [0] (select/propose 30 (vec (repeat 30 0.2)) {:tolerance tol})))
(is (= [0] (select/propose 30 (vec (repeat 30 0.2))
{:tolerance tol :protect :extrema})))
(is (= [0] (select/propose 30 (vec (repeat 30 [1 2 3 4])) {:tolerance tol})))
(is (= [] (select/propose 0 [] {:tolerance tol}))))
;; A head sliding right at 0.004 stage units a frame, as four transformed
;; corners. The tolerance is a distance a person can reason about: "the head has
;; moved this far".
(def slide
(mapv (fn [f] (let [x (* 0.004 f)]
[[(- x 0.5) -0.5] [(+ x 0.5) -0.5]
[(+ x 0.5) 0.5] [(- x 0.5) 0.5]]))
(range 10)))
(deftest distance-is-the-largest-change-over-components
(testing "landmark pairs and a bare number both answer without being declared"
(is (= [0 3 6 9] (select/propose 10 slide {:tolerance 0.01})))
(is (= (select/propose 10 slide {:tolerance 0.01})
(select/propose 10 (mapv flatten slide) {:tolerance 0.01}))))
(testing "one component moving is enough"
(is (= [0 2] (select/propose 3 [[0 0] [0 0.5] [0 1]] {:tolerance 0.6})))))
(deftest a-signal-of-two-shapes-is-a-reader-bug
;; Comparing the prefix two samples happen to share would let a reader that
;; drops a component on one frame read as no movement at all — a signal that is
;; quietly the wrong shape rather than one that says so.
(is (thrown? ExceptionInfo
(select/propose 3 [[0 0] [0 0 0] [0 0]] {:tolerance 0.01})))
(testing "an absent measurement is not a second shape"
(is (= [0] (select/propose 3 [[0 0] nil [0 0]] {:tolerance 0.01})))))
(deftest a-tolerance-that-is-not-a-number-fails-towards-the-cadence
;; A slider that has been cleared reads as NaN, and every comparison against
;; NaN is false. Taken at face value that proposes frame 0 alone and collapses
;; a whole performance to one pose. Nought proposes every frame that changes,
;; which is only the cadence back again: visible, and undone by moving the
;; slider somewhere.
(is (= (select/propose 30 aperture {:tolerance 0})
(select/propose 30 aperture {:tolerance js/NaN})
(select/propose 30 aperture {})))
(is (< 1 (count (select/propose 30 aperture {:tolerance js/NaN})))))
(deftest extrema-are-refused-on-a-multi-component-signal
;; The magnitude of a vector of landmarks has local maxima that mean nothing,
;; so asking for them is a mistake rather than a thing to silently ignore.
(is (thrown? ExceptionInfo
(select/propose 10 slide {:tolerance 0.01 :protect :extrema})))
(testing "protected frames are not: a plate selection is fed in this way"
;; 6 and 9 give way to 8: frame 5 is on screen from 5 onwards, so that is
;; where the head's next 0.01 of travel is measured from.
(is (= [0 3 6 9] (select/propose 10 slide {:tolerance 0.01})))
(is (= [0 3 5 8] (select/propose 10 slide {:tolerance 0.01 :protect [5]})))))
(deftest protect-carries-frames-and-extrema-through-one-input
;; Named frames and the signal's own extrema arrive by the ONE input, which is
;; what lets the plate selection constrain the performance later without
;; `propose` growing a second parameter for it.
(let [walk (select/propose 30 aperture {:tolerance tol})
kept (select/propose 30 aperture {:tolerance tol :protect #{:extrema 8 9}})]
(is (every? (set kept) [closure 8 9]))
(testing "and both kinds anchor the walk, so neither is a union"
;; 10 was kept only because the walk had drifted from 7 by then. Pinning 8
;; and 9 re-anchors it, and 10 is within tolerance of 9.
(is (some #{10} walk))
(is (not (some #{10} kept)))))
(testing "a frame outside the signal is not a frame"
(is (= [0 5] (select/propose 6 (vec (repeat 6 0.2))
{:tolerance tol :protect [5 6 30 -1]})))))
(deftest the-hand-outranks-the-proposal-in-both-directions
(is (= [0 12 30 47] (select/effective [0 12 30] #{47} #{})))
(is (= [0 12 47] (select/effective [0 12 30] #{47} #{30})))
(testing "a drop beats a proposal, always"
(is (= [0] (select/effective [0 4 8] #{} #{4 8}))))
(testing "a drop of a frame nobody proposed is harmless"
(is (= [0 4] (select/effective [0 4] #{} #{9}))))
(testing "something has to be on screen at the start"
(is (= [0] (select/effective [] #{} #{})))
(is (= [0 4] (select/effective [4] #{} #{0}))))
(testing "the result is a frame list in the shape the two sites already store"
(let [fs (select/effective [30 0 12] [47 12] #{})]
(is (vector? fs))
(is (= fs (vec (sort (distinct fs))))))))
(deftest re-proposing-at-another-tolerance-costs-nobody-their-closure
;; Step 4's test belongs with storage, but the half of it that is this
;; namespace's is assertable now: the proposal is a value, so holding a hand
;; decision across two of them is composition and not state.
(let [pinned #{closure} dropped #{2}
at (fn [t] (select/effective (select/propose 30 aperture {:tolerance t})
pinned dropped))]
(doseq [t [0.01 0.02 0.05 0.2]]
(is (some #{closure} (at t)) (str "closure survived tolerance " t))
(is (not (some #{2} (at t))) (str "drop survived tolerance " t)))
(is (not= (at 0.01) (at 0.2)))))
(deftest an-absent-measurement-is-not-a-change
;; A frame where the face was not found says nothing about the signal. It is
;; neither a frame worth keeping nor a reason to move the anchor.
(let [s (assoc (vec (repeat 10 0.2)) 4 nil 5 nil)]
(is (= [0] (select/propose 10 s {:tolerance tol})))
(is (= [0] (select/propose 10 s {:tolerance tol :protect :extrema}))))
(testing "a signal that starts absent begins where it begins"
(is (= [0 3] (select/propose 6 [nil nil nil 0.2 0.2 0.2] {:tolerance tol}))))
(testing "a measurement either side of a gap is not compared across it"
(is (= [0] (select/propose 5 [0.2 0.2 nil 0.2 0.2]
{:tolerance tol :protect :extrema}))))
(testing "a protected frame with no measurement is still kept"
;; A plate frame is a frame somebody draws on. The face not having been found
;; there is a different fact, and not a reason to drop the frame — but there
;; is nothing to anchor on either, so the anchor stays where it was.
(is (= [0 2] (select/propose 5 [0.2 0.2 nil 0.2 0.2]
{:tolerance tol :protect [2]})))))

View file

@ -723,9 +723,6 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); }
.tab:hover .close, .tab.on .close { visibility: visible; } .tab:hover .close, .tab.on .close { visibility: visible; }
.tab .close:hover { background: var(--hair); color: var(--fg); } .tab .close:hover { background: var(--hair); color: var(--fg); }
.trace-opacity { width: 64px; }
.palette-bar label.dim { display: inline-flex; align-items: center; gap: 2px; white-space: nowrap; }
.palette-bar { .palette-bar {
display: flex; display: flex;
align-items: center; align-items: center;
@ -817,6 +814,10 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); }
.section { border-bottom: 1px solid var(--hair); padding: 6px; } .section { border-bottom: 1px solid var(--hair); padding: 6px; }
.section:last-child { border-bottom: 0; } .section:last-child { border-bottom: 0; }
/* The footage slider shares its row with the word "opacity", so it takes what is
left rather than the full width the generic range rule gives it. */
.trace-opacity { flex: 1; min-width: 0; }
.section > h2 { .section > h2 {
margin: 0 0 5px; margin: 0 0 5px;
font: inherit; font: inherit;