The snap reads back into the slot's own gap, per pose group

Step 2 of docs/frame-selection.md, which f7e16e5 planned and left unbuilt: the
preserve-snap, and nothing of the plate side. `pose/snapped-frame` is the whole
rule — the latest mark in `(lo, hi]`, and `hi` when there is none — where `hi`
is the native frame the slot defaults to and `lo` is the one the slot before it
defaulted to. So the interval is exactly the frames this slot is the first to
cover, which are exactly the ones the grid shows to nobody: it recovers a
dropped frame out of its own gap, can never read a frame another slot already
showed, and cannot reach past `hi`.

Backward only. A closure at native 13 in a 12-from-30 output is recovered by the
slot whose default is 15, reading 13 — not by the slot at 12 reaching forward,
which would show the mouth shut 17ms before it did and break the no-lead
invariant `cadence_test` asserts over every grid and native pair. That test now
covers the snap too.

Seated as the DEFAULT pose that `pose/source-frame` reaches, so an explicit hand
cut beats a snap with nothing having to say so, and per pose group rather than
at the slot: snapping where the grid becomes native is one frame for the whole
picture, so a head would go two frames stale to fix one mouth. Groups exist only
in `symbol/base-channel-frame`, which is why the interval is threaded that far
down — `(:pre parent)` carried beside `(:f parent)` through the same time maps,
so an ancestor's exposure fold or retime is already in it.

The marks are the `[:vis]` cuts `flow/freeze` already stores, with their
thresholds and hysteresis already decided: no new signal, no new stored field.
A whitelist of `:roto/mouth-aperture` and `:roto/blink` and not a test for
`:generated`, because a skipped frame, a hidden feature and an absent
measurement are three different facts — snapping onto the frames a teeth contour
happened to be missing on is the cadence being dragged about by an absence.

Off is the default and needs no second code path: an opts map that says nothing
gets the behaviour it got before the snap existed. `:snap` is asked about the
SYMBOL, because the cuts are the face's own nodes' and every placement of one
face has the same ones.

The switch is `performance · <face>` in the inspector, and the readout says it
is the stage only. The document setting docs/frame-selection.md specifies wants
a leaf and a round trip of its own; until then this is `[:ui :smart]`, not
undoable, not synced, and unable to reach an export.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-10-01 11:34:43 -04:00
parent 340a8dbbd6
commit 6443366748
8 changed files with 481 additions and 45 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -13,6 +13,7 @@
[arthur.domain.node :as node]
[arthur.domain.paint :as paint]
[arthur.domain.params :as params]
[arthur.domain.pose :as pose]
[arthur.domain.trace :as trace]
[arthur.events.history :as history]
[arthur.events.paint :as paint-events]
@ -397,6 +398,11 @@
;; 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,
@ -442,6 +448,52 @@
:on-click #(put (assoc t :origin o))}
label]))]]))
(defn- performance-section
"Smart frame picking for one face, and what it has to work with.
ONE SWITCH AND NO TOLERANCE. The output rate or the exposure setting has already
chosen how sparse the performance is; all the snap decides is WHICH native frame
an already-decided slot reads, so there is nothing here for a knob to mean. See
docs/frame-selection.md.
The groups and their counts stand in for the frame strip that document specifies,
which is not built. What a person needs before switching this on is whether the
take has any closures stored at all: a take whose mouth never shuts far enough to
cross `aperture-cut` is marked nowhere, and the switch would otherwise read as
broken rather than as having nothing to do.
EDITOR STATE, and the readout says so. The document setting wants a leaf and a
round trip of its own; until then this moves the stage and not an export."
[face]
(let [clip @(rf/subscribe [::render/clip])
store @(rf/subscribe [::render/store])
smart @(rf/subscribe [::render/smart])
sym (get-in clip [:symbols face])
marks (pose/marks (:nodes sym) (:frames sym) store)
on? (contains? smart face)]
[section (str "performance \u00b7 " (name face))
[:div.row {:style {:margin "5px 0"}}
[:label.dim {:title (str "let a closure the grid drops move the frame a slot "
"reads \u2014 backward, and only within that slot's own gap")}
[:input {:type "checkbox" :checked on?
:on-change #(rf/dispatch [::ui/smart-picking face (not on?)])}]
" smart frame picking"]]
(if (empty? marks)
[:div.row [:span.dim "no closures stored on this face \u2014 nothing to land on"]]
[:div.row
[:span.dim "marks"]
(doall
(for [[group fs] (sort-by (comp str key) marks)]
^{:key (str group)}
[:span.dim {:style {:margin-right "8px"}
:title (str "frames the stored cut calls shut: "
(str/join " " (take 24 fs))
(when (> (count fs) 24) " \u2026"))}
(str (if (vector? group) (str/join "/" (map name group)) (name group))
" " (count fs))]))])
[:div.row
[:span.dim "the stage only \u00b7 an export is unaffected until this is a saved setting"]]]))
(defn- tracing-section
"`face` is the face these facts belong to, `faces` the faces placed inside it
to offer as somewhere to go next, and `path` the row path `faces` are under."
@ -619,5 +671,6 @@
(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])]]))

View file

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