From f7e16e5ef4272342d3e515dff1cd57c5182016c8 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 1 Oct 2026 10:21:35 -0400 Subject: [PATCH] The snap is per group, and the slot interval has to reach it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulls the preserve-snap forward to step 2: it is the half with no UI and nothing proposing today, and it needs nothing from the plate side but a fold that can land last. Makes the rule exact, because the direction is easy to get backwards and the draft was vague about it. Slot k reads the latest preserved frame in (d(k-1), d(k)], else d(k). The closure at native 13 in a 12-from-30 output is recovered by slot 6, whose default is 15, reading 13 — NOT by slot 5 reaching forward from 12, which would show it 17ms before the mouth shut and break the invariant cadence_test already asserts. Records where it goes, which is the part that was understated as "about thirty lines". The slot interval exists only at clip.cljs:261 and group identity exists only at symbol.cljs:399, so the interval has to be threaded down: two internal signatures, not a drop-in. The rule's seat is the `(js/Math.floor lf)` default `base-channel-frame` hands `pose/source-frame`, which leaves an explicit hand cut beating a snap, as manual precedence requires. And records the shortcut not to take: snapping at clip.cljs:261 needs no threading and is wrong, because one native frame per output frame means the whole picture reads 13 instead of 15 — a 67ms stale head to fix the mouth, fighting the trace selection's own opinion about which head frame to show. Co-Authored-By: Claude Opus 5 --- docs/frame-selection.md | 99 +++++++++++++++++++++++++++++++---------- 1 file changed, 75 insertions(+), 24 deletions(-) diff --git a/docs/frame-selection.md b/docs/frame-selection.md index bf70ecf..9faf0f5 100644 --- a/docs/frame-selection.md +++ b/docs/frame-selection.md @@ -197,19 +197,34 @@ the swap untouched. ## The performance selection: a preserve-snap on the grid -Not built. About thirty lines, plus a prepare step. +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. The change is: when a slot's -pick has a *preserved* frame within the span of native frames that slot is -responsible for, read the preserved frame instead. +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: -**Snap backward only, never forward.** `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. Snapping forward -would show a closure before the mouth shut, which is a lead — a different control, -applied for a different reason, after this one. A backward snap shows the closure -up to a frame or two late, which is the same lateness a hold already has. +> **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 @@ -226,11 +241,40 @@ 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. A `[:vis]` channel is keys, not dense, so reading it -per node per frame would be cheap — but the snap also needs the marks as a set -with a span lookup, and building that per frame is the one thing -[animation-model.md](animation-model.md) and the render-path note above both -forbid. +`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 @@ -368,29 +412,36 @@ Render it in two sections: 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 plate signal reader.** `trace/head-signal`: make `trace/measured-local` +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. -3. **Storage for the plate selection.** `:policy`/`:keep`/`:drop` beside +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. -4. **Re-suggest preserves hand decisions.** Propose at one tolerance, pin a frame, +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. -5. **The preserve-snap.** The mark set from the stored `[:vis]` cuts, built in a - prepare step; the backward-only snap in the grid pick; plate frames folded into - the same mark set. Independent of steps 2–4 and cheaper than any of them, so it - can be pulled forward if the performance side is wanted sooner — the only thing - it needs from the plate side is the fold, which can land last. -6. **The shared UI component**, then its two mountings. +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