diff --git a/.claude/worktrees/unified-time-selection b/.claude/worktrees/unified-time-selection new file mode 160000 index 0000000..e369ad9 --- /dev/null +++ b/.claude/worktrees/unified-time-selection @@ -0,0 +1 @@ +Subproject commit e369ad9462ed1816a39b1a0f5c79055a2c013f4f diff --git a/docs/frame-selection.md b/docs/frame-selection.md deleted file mode 100644 index 9faf0f5..0000000 --- a/docs/frame-selection.md +++ /dev/null @@ -1,504 +0,0 @@ -# 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 · `** (`params.cljs:426`), beside the existing origin row, - with the tolerance slider and Suggest. -- **`performance · `** — 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. diff --git a/docs/time.md b/docs/time.md index 643dc17..8595aa6 100644 --- a/docs/time.md +++ b/docs/time.md @@ -23,9 +23,8 @@ 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; [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. +implemented. 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 diff --git a/frontend/src/arthur/domain/clip.cljs b/frontend/src/arthur/domain/clip.cljs index e4233a9..93b76a1 100644 --- a/frontend/src/arthur/domain/clip.cljs +++ b/frontend/src/arthur/domain/clip.cljs @@ -218,10 +218,9 @@ ;; there used to be. Their resolvers still hold the frame ;; before whenever they were not on. entered (volatile! {}) - step (fn [f pre] + step (fn [f] (vreset! entered {}) - (let [by-id (into {} (map (juxt :node identity)) - (own (js/Math.floor f) (js/Math.floor pre)))] + (let [by-id (into {} (map (juxt :node identity)) (own (js/Math.floor f)))] (into [] (mapcat (fn [id] @@ -229,7 +228,6 @@ (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)) @@ -237,28 +235,13 @@ (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 - ;; 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))))) + ((get children [id (:symbol shown)]) frame))) [])) (when-let [op (get by-id id)] [op])))) ids))))] (reify IFn - (-invoke [_ f] (step f (dec f))) - (-invoke [_ f pre] (step f pre)) + (-invoke [_ f] (step f)) symbol/IResolver (world-of [_ [id & more]] (if more @@ -271,35 +254,14 @@ (if more (when (contains? @entered id) (symbol/frame-of (get children [id (get @entered id)]) (vec more))) - (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)] + (symbol/frame-of own id))))))] + (let [r (build sid [] nil)] (reify IFn - (-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))) + (-invoke [_ f] (r (cadence/frame f (or (:grid-fps opts) (:fps clip)) (fps clip sid)))) symbol/IResolver (world-of [_ path] (symbol/world-of r path)) - (frame-of [_ path] (symbol/frame-of r path)) - (pre-frame-of [_ path] (symbol/pre-frame-of r path)))))) + (frame-of [_ path] (symbol/frame-of r path)))))) (defn center "The middle of everything symbol `sid` draws, over all its frames, in its own diff --git a/frontend/src/arthur/domain/pose.cljs b/frontend/src/arthur/domain/pose.cljs index 2b013b7..c868c14 100644 --- a/frontend/src/arthur/domain/pose.cljs +++ b/frontend/src/arthur/domain/pose.cljs @@ -2,18 +2,8 @@ "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. - - 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])) + the value is the frozen pose to read. Skipped source frames remain available." + (:require [arthur.domain.node :as node])) (defn prepare "Sort pose tracks once when building a resolver." @@ -23,125 +13,6 @@ [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] diff --git a/frontend/src/arthur/domain/ring.cljs b/frontend/src/arthur/domain/ring.cljs index 241b72f..57a1a50 100644 --- a/frontend/src/arthur/domain/ring.cljs +++ b/frontend/src/arthur/domain/ring.cljs @@ -13,15 +13,9 @@ back to positions with indexOf would silently pick the wrong slot if a table ever repeated an id. - 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." + 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." [len n] (mapv (fn [k] (mod (js/Math.round (/ (* k len) n)) len)) (range n))) diff --git a/frontend/src/arthur/domain/select.cljs b/frontend/src/arthur/domain/select.cljs deleted file mode 100644 index 7644f6f..0000000 --- a/frontend/src/arthur/domain/select.cljs +++ /dev/null @@ -1,191 +0,0 @@ -(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)) diff --git a/frontend/src/arthur/domain/symbol.cljs b/frontend/src/arthur/domain/symbol.cljs index 6a66160..b2242a4 100644 --- a/frontend/src/arthur/domain/symbol.cljs +++ b/frontend/src/arthur/domain/symbol.cljs @@ -321,29 +321,21 @@ 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 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)] + [{:keys [read mat-for pinv-for scratch]} n parent f] + (let [pf (if parent (:f parent) f)] (when (in-span? n pf) (let [id (:id n) chs (node/channels n) lf (node/local-frame n pf) - plf (node/local-frame n ppf) - rd (fn [path] (read id path (get chs path) lf plf))] + rd (fn [path] (read id path (get chs path) lf))] (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 ;; operands fully before writing either. (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}))))))) + {:m (node/world! m (:m parent) (pinv-for id) m scratch) + :f lf + :rd rd}))))))) (defn- emit "Emit geometry in the symbol's space. Rect sizes stay fractional until @@ -406,32 +398,19 @@ (defn- base-channel-frame "A trace selects the measured frames its node reads; marked channels read - 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] + instance pose choices." + [choices traces nodes id c lf] (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] group) - 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)))) + (pose/source-frame choices + (if (contains? choices [:node id]) + [:node id] + (or (:pose-group (get nodes id)) id)) + lf + (js/Math.floor lf)) :else (js/Math.floor lf))) @@ -453,7 +432,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 pre] + [ctx nodes ord rank f] (-> (reduce (fn [{:keys [placed ops] :as acc} id] (let [n (get nodes id) @@ -464,7 +443,7 @@ ;; lookup instead of a subtree walk. (if (and pid (nil? parent)) acc - (if-let [p (place ctx n parent f pre)] + (if-let [p (place ctx n parent f)] (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) @@ -485,31 +464,22 @@ 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 opts] (eval-frame sym f (dec f) store palette opts)) - ([sym f pre store palette {:keys [pose-tracks snap]}] + [sym f store palette {:keys [pose-tracks]}] (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 plf] - (ch/value-at c (base-channel-frame choices marks traces - nodes id c lf plf) + (eval-into {:read (fn [id path c lf] + (ch/value-at c (base-channel-frame choices traces nodes + id c lf) 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 pre)))) + nodes ord (draw-rank nodes ord) f))) ;; --------------------------------------------------------------------------- ;; the playback path @@ -541,14 +511,7 @@ 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.") - (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.")) + (frame-of [this id] "The placed node's local frame on the last resolve.")) (defn resolver "(fn [f] -> ops). Holds everything that does not change per frame. @@ -568,26 +531,9 @@ 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 snap]}] + [sym store palette {:keys [pose-tracks]}] (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) @@ -610,11 +556,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 plf] + ctx {:read (fn [id path c lf] (when-let [cursor (get-in cursors [id path])] (ch/sample! cursor - (base-channel-frame choices marks traces - nodes id c lf plf) + (base-channel-frame choices traces nodes + id c lf) lf))) :palette palette :mat-for (fn [id] (get mats id)) @@ -622,18 +568,15 @@ :pinv-for (fn [id] (get pinvs id)) :buf-for (fn [id _n] (get bufs id)) :scratch scratch} - step (fn [f pre] + step (fn [f] (vreset! placed {}) - (eval-into ctx nodes ord rank f pre))] + (eval-into ctx nodes ord rank f))] (reify IFn - ;; 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)) + (-invoke [_ f] (step f)) IResolver (world-of [_ id] (:m (get @placed id))) - (frame-of [_ id] (:f (get @placed id))) - (pre-frame-of [_ id] (:pre (get @placed id)))))) + (frame-of [_ id] (:f (get @placed id)))))) ;; --------------------------------------------------------------------------- diff --git a/frontend/src/arthur/domain/trace.cljs b/frontend/src/arthur/domain/trace.cljs index 37e2f51..5ea8018 100644 --- a/frontend/src/arthur/domain/trace.cljs +++ b/frontend/src/arthur/domain/trace.cljs @@ -144,8 +144,7 @@ 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 inspector's footage section or from a face's own timeline - row." + afterwards, from the bar above the stage or from a face's own timeline row." [clip sid on] (cond-> (set on) (traceable? clip sid) (conj sid))) diff --git a/frontend/src/arthur/events/ui.cljs b/frontend/src/arthur/events/ui.cljs index 462b985..bd96278 100644 --- a/frontend/src/arthur/events/ui.cljs +++ b/frontend/src/arthur/events/ui.cljs @@ -334,9 +334,9 @@ (rf/reg-event-db ::trace-faces - ;; 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. + ;; 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. (fn [db [_ faces]] (let [faces (set faces) on (set (get-in db [:ui :trace :faces]))] @@ -347,20 +347,6 @@ ::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 diff --git a/frontend/src/arthur/subs/render.cljs b/frontend/src/arthur/subs/render.cljs index 7f69a51..6a0e162 100644 --- a/frontend/src/arthur/subs/render.cljs +++ b/frontend/src/arthur/subs/render.cljs @@ -26,11 +26,6 @@ (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 @@ -132,10 +127,9 @@ :<- [::open] :<- [::store] :<- [::palette] - :<- [::smart] - (fn [[document sid store palette smart] _] + (fn [[document sid store palette] _] (when (and document (clip/symbol document sid)) - (clip/resolver document sid store palette {:snap smart})))) + (clip/resolver document sid store palette nil)))) (rf/reg-sub ::shown @@ -164,8 +158,7 @@ (resolve f))) symbol/IResolver (world-of [_ path] (symbol/world-of resolve path)) - (frame-of [_ path] (symbol/frame-of resolve path)) - (pre-frame-of [_ path] (symbol/pre-frame-of resolve path))))))) + (frame-of [_ path] (symbol/frame-of resolve path))))))) (rf/reg-sub ::underlay diff --git a/frontend/src/arthur/ui/palette.cljs b/frontend/src/arthur/ui/palette.cljs index 2e59458..dcf2f50 100644 --- a/frontend/src/arthur/ui/palette.cljs +++ b/frontend/src/arthur/ui/palette.cljs @@ -13,12 +13,16 @@ 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 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`." + 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." (: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])) @@ -39,6 +43,31 @@ :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]) @@ -47,6 +76,7 @@ [: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")] diff --git a/frontend/src/arthur/ui/params.cljs b/frontend/src/arthur/ui/params.cljs index 9bf21b1..f7e5bd2 100644 --- a/frontend/src/arthur/ui/params.cljs +++ b/frontend/src/arthur/ui/params.cljs @@ -13,7 +13,6 @@ [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] @@ -357,40 +356,6 @@ [: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 ;; @@ -398,15 +363,10 @@ ;; 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`. ;; -;; `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. +;; 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. (defn- trace-keys "The face's trace keys and origin. `frame` is the frame of the FACE that the @@ -448,52 +408,6 @@ :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." @@ -652,10 +566,6 @@ 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 @@ -668,9 +578,7 @@ (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])]])) diff --git a/frontend/src/arthur/ui/timeline.cljs b/frontend/src/arthur/ui/timeline.cljs index e154a06..f9dd5c0 100644 --- a/frontend/src/arthur/ui/timeline.cljs +++ b/frontend/src/arthur/ui/timeline.cljs @@ -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 inspector's footage section 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 bar above the stage 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" diff --git a/frontend/test/arthur/domain/cadence_test.cljs b/frontend/test/arthur/domain/cadence_test.cljs index 1da58ad..a16c942 100644 --- a/frontend/test/arthur/domain/cadence_test.cljs +++ b/frontend/test/arthur/domain/cadence_test.cljs @@ -68,140 +68,3 @@ (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)))))))) diff --git a/frontend/test/arthur/domain/select_test.cljs b/frontend/test/arthur/domain/select_test.cljs deleted file mode 100644 index 7fc1505..0000000 --- a/frontend/test/arthur/domain/select_test.cljs +++ /dev/null @@ -1,213 +0,0 @@ -(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]}))))) diff --git a/static/arthur/app.css b/static/arthur/app.css index bf85903..573477c 100644 --- a/static/arthur/app.css +++ b/static/arthur/app.css @@ -723,6 +723,9 @@ 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; @@ -814,10 +817,6 @@ 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;