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

@ -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))))))))