Compare commits
6 commits
95cf2598ba
...
6443366748
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6443366748 | ||
|
|
340a8dbbd6 | ||
|
|
f7e16e5ef4 | ||
|
|
cc42155ffa | ||
|
|
02069e88f0 | ||
|
|
14673385d4 |
17 changed files with 1456 additions and 96 deletions
|
|
@ -1 +0,0 @@
|
|||
Subproject commit e369ad9462ed1816a39b1a0f5c79055a2c013f4f
|
||||
504
docs/frame-selection.md
Normal file
504
docs/frame-selection.md
Normal 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.
|
||||
|
|
@ -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
|
||||
without deleting its neighboring measurements. Those choices stay in native
|
||||
frames when output fps changes. Automatic content-aware frame selection is not
|
||||
implemented. An event between output frames appears on the next output frame;
|
||||
it cannot create an extra frame in a 12fps output.
|
||||
implemented; [frame-selection.md](frame-selection.md) is how it should be. An
|
||||
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
|
||||
picture floors or holds. Frame-rate units cancel before Web Audio playbackRate
|
||||
|
|
|
|||
|
|
@ -218,9 +218,10 @@
|
|||
;; there used to be. Their resolvers still hold the frame
|
||||
;; before whenever they were not on.
|
||||
entered (volatile! {})
|
||||
step (fn [f]
|
||||
step (fn [f pre]
|
||||
(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 []
|
||||
(mapcat
|
||||
(fn [id]
|
||||
|
|
@ -228,6 +229,7 @@
|
|||
(if (= :instance (:kind n))
|
||||
(let [m (symbol/world-of own id)
|
||||
local (symbol/frame-of own id)
|
||||
prior (symbol/pre-frame-of own id)
|
||||
length (frames clip (node/source n))
|
||||
shown (when (and m (number? local))
|
||||
(placed-frame clip sid n local))
|
||||
|
|
@ -235,13 +237,28 @@
|
|||
(if (and frame (<= 0 frame) (< frame length))
|
||||
(do (vswap! entered assoc id (:symbol shown))
|
||||
(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]))))
|
||||
ids))))]
|
||||
(reify
|
||||
IFn
|
||||
(-invoke [_ f] (step f))
|
||||
(-invoke [_ f] (step f (dec f)))
|
||||
(-invoke [_ f pre] (step f pre))
|
||||
symbol/IResolver
|
||||
(world-of [_ [id & more]]
|
||||
(if more
|
||||
|
|
@ -254,14 +271,35 @@
|
|||
(if more
|
||||
(when (contains? @entered id)
|
||||
(symbol/frame-of (get children [id (get @entered id)]) (vec more)))
|
||||
(symbol/frame-of own id))))))]
|
||||
(let [r (build sid [] nil)]
|
||||
(symbol/frame-of own id)))
|
||||
(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
|
||||
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
|
||||
(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
|
||||
"The middle of everything symbol `sid` draws, over all its frames, in its own
|
||||
|
|
|
|||
|
|
@ -2,8 +2,18 @@
|
|||
"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;
|
||||
the value is the frozen pose to read. Skipped source frames remain available."
|
||||
(:require [arthur.domain.node :as node]))
|
||||
the value is the frozen pose to read. Skipped source frames remain available.
|
||||
|
||||
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
|
||||
"Sort pose tracks once when building a resolver."
|
||||
|
|
@ -13,6 +23,125 @@
|
|||
[group (vec (sort-by first entries))]))
|
||||
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
|
||||
"Last value keyed at or before f, or default before the first key."
|
||||
[entries f default-frame]
|
||||
|
|
|
|||
|
|
@ -13,9 +13,15 @@
|
|||
back to positions with indexOf would silently pick the wrong slot if a table
|
||||
ever repeated an id.
|
||||
|
||||
For even n this naturally lands on the cardinal positions (corners and lip
|
||||
centres) of a 20-point ring. Fixed indices, never adaptive decimation: the
|
||||
vertex at slot k means the same thing on every frame of the shot."
|
||||
Fixed indices, never adaptive decimation: the 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]
|
||||
(mapv (fn [k] (mod (js/Math.round (/ (* k len) n)) len)) (range n)))
|
||||
|
||||
|
|
|
|||
191
frontend/src/arthur/domain/select.cljs
Normal file
191
frontend/src/arthur/domain/select.cljs
Normal 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))
|
||||
|
|
@ -321,13 +321,20 @@
|
|||
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
|
||||
presence is tracked per channel rather than per node."
|
||||
[{:keys [read mat-for pinv-for scratch]} n parent f]
|
||||
(let [pf (if parent (:f parent) f)]
|
||||
[{:keys [read mat-for pinv-for scratch]} n parent f pre]
|
||||
(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)
|
||||
(let [id (:id n)
|
||||
chs (node/channels n)
|
||||
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-let [[pos rot scl skw anc] (xform-at rd)]
|
||||
;; dest aliases `local` here, which mul! allows: it reads both
|
||||
|
|
@ -335,6 +342,7 @@
|
|||
(let [m (node/local! (mat-for id) pos rot scl skw anc)]
|
||||
{:m (node/world! m (:m parent) (pinv-for id) m scratch)
|
||||
:f lf
|
||||
:pre plf
|
||||
:rd rd})))))))
|
||||
|
||||
(defn- emit
|
||||
|
|
@ -398,19 +406,32 @@
|
|||
|
||||
(defn- base-channel-frame
|
||||
"A trace selects the measured frames its node reads; marked channels read
|
||||
instance pose choices."
|
||||
[choices traces nodes id c lf]
|
||||
instance pose choices, and the preserve-snap where nobody has made one.
|
||||
|
||||
`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
|
||||
(contains? traces id)
|
||||
(trace/held-frame (get traces id) lf)
|
||||
|
||||
(:pose-sampled? c)
|
||||
(let [group (or (:pose-group (get nodes id)) id)]
|
||||
(pose/source-frame choices
|
||||
(if (contains? choices [:node id])
|
||||
[:node id]
|
||||
(or (:pose-group (get nodes id)) id))
|
||||
(if (contains? choices [:node id]) [:node id] group)
|
||||
lf
|
||||
(js/Math.floor lf))
|
||||
;; THE SNAP IS THE DEFAULT POSE. `source-frame` reaches a
|
||||
;; 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)))
|
||||
|
||||
|
|
@ -432,7 +453,7 @@
|
|||
:pinv-for (fn [id] -> Float64Array|nil) its parent-inverse
|
||||
:buf-for (fn [id n-points] -> Float64Array)
|
||||
:scratch one spare 6-element matrix"
|
||||
[ctx nodes ord rank f]
|
||||
[ctx nodes ord rank f pre]
|
||||
(-> (reduce
|
||||
(fn [{:keys [placed ops] :as acc} id]
|
||||
(let [n (get nodes id)
|
||||
|
|
@ -443,7 +464,7 @@
|
|||
;; lookup instead of a subtree walk.
|
||||
(if (and pid (nil? parent))
|
||||
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))
|
||||
op (emit ctx n p {:node id :stencil (:stencil n)})]
|
||||
(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
|
||||
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."
|
||||
[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)
|
||||
choices (pose/prepare pose-tracks)
|
||||
marks (when (and snap (snap (:id sym)))
|
||||
(pose/marks nodes (:frames sym) store))
|
||||
traces (prepared-traces nodes)
|
||||
ord (order nodes)]
|
||||
(eval-into {:read (fn [id path c lf]
|
||||
(ch/value-at c (base-channel-frame choices traces nodes
|
||||
id c lf)
|
||||
(eval-into {:read (fn [id path c lf plf]
|
||||
(ch/value-at c (base-channel-frame choices marks traces
|
||||
nodes id c lf plf)
|
||||
lf store))
|
||||
:palette palette
|
||||
:mat-for (fn [_id] (node/mat))
|
||||
:pinv-for (fn [id] (node/pinv (get nodes id)))
|
||||
:buf-for (fn [_id n] (js/Float64Array. (* 2 n)))
|
||||
:scratch (node/mat)}
|
||||
nodes ord (draw-rank nodes ord) f)))
|
||||
nodes ord (draw-rank nodes ord) f pre))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; the playback path
|
||||
|
|
@ -511,7 +541,14 @@
|
|||
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
|
||||
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
|
||||
"(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
|
||||
these call sites — which is what the arity ladder that used to be here was
|
||||
standing in for."
|
||||
[sym store palette {:keys [pose-tracks]}]
|
||||
[sym store palette {:keys [pose-tracks snap]}]
|
||||
(let [nodes (nodes-of sym)
|
||||
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)
|
||||
ord (order nodes)
|
||||
rank (draw-rank nodes ord)
|
||||
|
|
@ -556,11 +610,11 @@
|
|||
;; 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 (volatile! {})
|
||||
ctx {:read (fn [id path c lf]
|
||||
ctx {:read (fn [id path c lf plf]
|
||||
(when-let [cursor (get-in cursors [id path])]
|
||||
(ch/sample! cursor
|
||||
(base-channel-frame choices traces nodes
|
||||
id c lf)
|
||||
(base-channel-frame choices marks traces
|
||||
nodes id c lf plf)
|
||||
lf)))
|
||||
:palette palette
|
||||
:mat-for (fn [id] (get mats id))
|
||||
|
|
@ -568,15 +622,18 @@
|
|||
:pinv-for (fn [id] (get pinvs id))
|
||||
:buf-for (fn [id _n] (get bufs id))
|
||||
:scratch scratch}
|
||||
step (fn [f]
|
||||
step (fn [f pre]
|
||||
(vreset! placed {})
|
||||
(eval-into ctx nodes ord rank f))]
|
||||
(eval-into ctx nodes ord rank f pre))]
|
||||
(reify
|
||||
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
|
||||
(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))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -144,7 +144,8 @@
|
|||
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,
|
||||
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]
|
||||
(cond-> (set on) (traceable? clip sid) (conj sid)))
|
||||
|
||||
|
|
|
|||
|
|
@ -334,9 +334,9 @@
|
|||
|
||||
(rf/reg-event-db
|
||||
::trace-faces
|
||||
;; Every face the open symbol has, from the bar above the stage: switched on
|
||||
;; unless they all already are, which is the one gesture a person wants when
|
||||
;; there is exactly one face and when there are five.
|
||||
;; Every face the open symbol has, from the inspector's footage section:
|
||||
;; switched on unless they all already are, which is the one gesture a person
|
||||
;; wants when there is exactly one face and when there are five.
|
||||
(fn [db [_ faces]]
|
||||
(let [faces (set faces)
|
||||
on (set (get-in db [:ui :trace :faces]))]
|
||||
|
|
@ -347,6 +347,20 @@
|
|||
::trace-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
|
||||
"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
|
||||
|
|
|
|||
|
|
@ -26,6 +26,11 @@
|
|||
(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 ::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
|
||||
::clip
|
||||
|
|
@ -127,9 +132,10 @@
|
|||
:<- [::open]
|
||||
:<- [::store]
|
||||
:<- [::palette]
|
||||
(fn [[document sid store palette] _]
|
||||
:<- [::smart]
|
||||
(fn [[document sid store palette smart] _]
|
||||
(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
|
||||
::shown
|
||||
|
|
@ -158,7 +164,8 @@
|
|||
(resolve f)))
|
||||
symbol/IResolver
|
||||
(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
|
||||
::underlay
|
||||
|
|
|
|||
|
|
@ -13,16 +13,12 @@
|
|||
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.
|
||||
|
||||
The footage switch is here too, because it is the same kind of thing as the
|
||||
tone and the tool: something you set before you draw and leave alone, in one
|
||||
place whatever is selected. In the inspector it was a section that appeared
|
||||
only once the right row had been found — so the way to see the footage you are
|
||||
tracing depended on what you had clicked, which is not a thing anyone can be
|
||||
expected to learn."
|
||||
The footage switch is NOT here. It is a viewing aid rather than something you
|
||||
set before you draw, so it is a section of the inspector — one that is there
|
||||
for whatever the open symbol has faces of, so it still does not come and go
|
||||
with the selection. See `ui/params`."
|
||||
(:require [arthur.domain.palette :as pal]
|
||||
[arthur.domain.trace :as trace]
|
||||
[arthur.events.ui :as ui]
|
||||
[arthur.subs.render :as render]
|
||||
[arthur.subs.ui :as sub]
|
||||
[re-frame.core :as rf]))
|
||||
|
||||
|
|
@ -43,31 +39,6 @@
|
|||
:disabled (not pick)
|
||||
: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 []
|
||||
(let [tone @(rf/subscribe [::sub/tone])
|
||||
tool @(rf/subscribe [::sub/tool])
|
||||
|
|
@ -76,7 +47,6 @@
|
|||
[:div.swatches (doall (map #(swatch % tone) (range slots)))]
|
||||
[:span.dim (name tone)]
|
||||
[:span {:style {:flex 1}}]
|
||||
[tracing]
|
||||
(if (= :polygon tool)
|
||||
[:<>
|
||||
[:span.dim (str (quot (count draft) 2) " points")]
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
[arthur.domain.node :as node]
|
||||
[arthur.domain.paint :as paint]
|
||||
[arthur.domain.params :as params]
|
||||
[arthur.domain.pose :as pose]
|
||||
[arthur.domain.trace :as trace]
|
||||
[arthur.events.history :as history]
|
||||
[arthur.events.paint :as paint-events]
|
||||
|
|
@ -356,6 +357,40 @@
|
|||
[:div {:title why} (str (brief node) " · "
|
||||
(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
|
||||
;;
|
||||
|
|
@ -363,10 +398,15 @@
|
|||
;; 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`.
|
||||
;;
|
||||
;; Whether the footage is SHOWING is deliberately not here. It is a viewing aid
|
||||
;; and it belongs with the other things you look through rather than edit, on the
|
||||
;; bar above the stage — where it is in the same place whatever is selected,
|
||||
;; instead of appearing in the inspector only once the right row has been found.
|
||||
;; `performance-section` below it is the face's too, for a third reason of the
|
||||
;; same kind: the closure cuts the preserve-snap reads are stored on the face's
|
||||
;; own nodes, so every placement of one face has the same marks, and a
|
||||
;; 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
|
||||
"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))}
|
||||
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
|
||||
"`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."
|
||||
|
|
@ -566,6 +652,10 @@
|
|||
placed (node/source (peek node))
|
||||
face (or placed (when (trace/traceable? clip open) open))
|
||||
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
|
||||
;; 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
|
||||
|
|
@ -578,7 +668,9 @@
|
|||
(when node [node-section node])
|
||||
(when node ^{:key (str (first node) "/" (second node))}
|
||||
[correction-section node])
|
||||
(when (seq here) [footage-section here])
|
||||
(when (and face (or (trace/traceable? clip face) (seq faces)))
|
||||
[tracing-section face faces path])
|
||||
(when face ^{:key (str "perf/" face)} [performance-section face])
|
||||
(when (= :symbol (first selection)) [symbol-section (second selection)])
|
||||
(when tracked? [tracking-section])]]))
|
||||
|
|
|
|||
|
|
@ -478,8 +478,8 @@
|
|||
(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
|
||||
;; 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
|
||||
;; them at once; this is how one face out of a take is singled out.
|
||||
;; now, and nothing the picture keeps. The inspector's footage section does
|
||||
;; all of them at once; this is how one face out of a take is singled out.
|
||||
(when (contains? (:faces tracing) of)
|
||||
[:button {:class (str "tl-trace" (when (contains? (:on tracing) of) " on"))
|
||||
:title "show the footage this face was traced from"
|
||||
|
|
|
|||
|
|
@ -68,3 +68,140 @@
|
|||
(let [slow (assoc-in doc [:symbols :main :nodes :insert :playback :speed] 0.5)
|
||||
[track] (nest/audio-tracks slow :main)]
|
||||
(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))))))))
|
||||
|
|
|
|||
213
frontend/test/arthur/domain/select_test.cljs
Normal file
213
frontend/test/arthur/domain/select_test.cljs
Normal 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]})))))
|
||||
|
|
@ -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 .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 {
|
||||
display: flex;
|
||||
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: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 {
|
||||
margin: 0 0 5px;
|
||||
font: inherit;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue