diff --git a/.agents/skills/cut-the-curve/SKILL.md b/.agents/skills/cut-the-curve/SKILL.md index c69e03b3f45..357af5568ed 100644 --- a/.agents/skills/cut-the-curve/SKILL.md +++ b/.agents/skills/cut-the-curve/SKILL.md @@ -105,6 +105,9 @@ Mechanics: - **Mirrored eases:** exit `power4.in` + entry `power4.out`, same distance and duration — the two halves of one `power4.inOut`, so velocity matches exactly at the cut. + `seam-gate` measures something slightly different: the average velocity over ~0.1s of + whole frames on each side of the cut. Mirrored halves read ≈1.0 there only when the cut + sits on the frame grid (a multiple of 1/fps). - **The fade trick:** exit opacity completes at ~25–30% of its travel (fade ≈ 0.18–0.3s vs motion 0.3–0.34s); entry ignites at ~0.35 opacity mid-path. Time the last fading element to die right at the cut — a gap where nothing moves reads as dead air. diff --git a/.agents/skills/motion-doctrine/references/seam-gate.md b/.agents/skills/motion-doctrine/references/seam-gate.md index 39fcb11dc85..3fb4c859b87 100644 --- a/.agents/skills/motion-doctrine/references/seam-gate.md +++ b/.agents/skills/motion-doctrine/references/seam-gate.md @@ -64,7 +64,10 @@ Lives at the project root. One row per seam; this is the vector ledger as data. } ``` -- `cut` — seconds on the master clock, the frame the incoming side ignites. +- `cut` — seconds on the master clock, the frame the incoming side ignites. Keep it on + the frame grid (a multiple of 1/fps, with the ledger `fps` matching the composition): + the player floors every seek to a frame, so an off-grid cut is measured around the + nearest frame and reported as `cut-grid`. - `type` — `"cut"` (default; full vector checks), `"match-cut"` / `"morph"` (carrier-continuity + overlap only; motion may start AT the boundary). - `axis` — `"x"`, `"y"`, or `"z"` (z = scale). `dir` — sign of motion: @@ -88,10 +91,18 @@ Lives at the project root. One row per seam; this is the vector ledger as data. | `zero-overlap` | rule 6 — one side visible per frame, never both | | `z-sign-scan` | rule 7 — incoming scene's own entrances don't fight the Z sign | | `carrier-*` | rules 3/4 — carrier rect continuity, ancestor scale included | +| `cut-grid` (WARN) | `cut` sits between frames; checks ran around the nearest frame | Velocities are measured on `getBoundingClientRect` (center for x/y, width-ratio for z), so ancestor wrapper transforms are automatically included. +`speed-match` compares **average** velocities over whole frames, not the instantaneous +velocity at the cut: exit from cut−0.1s to cut−1f, entry from cut+1f to cut+0.1s. The two +windows mirror each other, so a mirrored ease pair (same ease family, distance and +duration) reads ≈1.0. The stamped defaults (x/y `power3.in` 12% · 0.34s → +`power4.out` 10% · 0.42s; z 0.21s → `expo.out` 0.5s) read ≈0.8 on x/y and ≈1.1 on z. +Lengthening an entry lowers its ratio. + `__player.seek()` resolves asynchronously, so the gate awaits each seek before it reads. If every seam fails the same way (`exit-visible op 0.00`, `exit-moving 0 px/s`) and `probe` finds no movers, the reads landed before the seek did and the gate measured t=0, not the film. diff --git a/.agents/skills/motion-doctrine/scripts/seam-gate.mjs b/.agents/skills/motion-doctrine/scripts/seam-gate.mjs index 0d7b0a0d685..b55dda7639d 100644 --- a/.agents/skills/motion-doctrine/scripts/seam-gate.mjs +++ b/.agents/skills/motion-doctrine/scripts/seam-gate.mjs @@ -366,8 +366,7 @@ const fmtV = (v, axis) => async function verify() { const ledgerPath = flag("ledger", "ledger.json"); const ledger = JSON.parse(readFileSync(ledgerPath, "utf8")); - const fps = ledger.fps || FPS, - dt = 1 / fps; + const fps = ledger.fps || FPS; const compUrl = await ensureServer(); const { evalJs } = await openPage(compUrl); const results = []; @@ -377,10 +376,17 @@ async function verify() { const add = (check, status, detail) => rows.push({ check, status, detail }); const cut = seam.cut; const type = seam.type || "cut"; - const tA1 = Math.max(0, cut - 0.1), - tA2 = Math.max(0, cut - dt); - const tB1 = cut + dt, - tB2 = cut + 0.1; + // The player floors every seek to the frame grid, so sampling at cut ± k/fps for an + // off-grid cut shifts both exit samples up to a frame early and the first entry sample + // onto the cut itself, under-reading exit and over-reading entry. Sample whole frames + // placed symmetrically around the nearest frame instead. + const cutFrame = Math.round(cut * fps); + const offGrid = Math.abs(cut * fps - cutFrame) > 1e-3; + const win = Math.max(1, Math.round(0.1 * fps)); + const tA1 = Math.max(0, cutFrame - win) / fps, + tA2 = Math.max(0, cutFrame - 1) / fps; + const tB1 = (cutFrame + 1) / fps, + tB2 = (cutFrame + win) / fps; const sels = [ seam.exit?.selector, seam.entry?.selector, @@ -390,6 +396,12 @@ async function verify() { const S = {}; for (const t of [tA1, tA2, tB1, tB2]) S[t] = await evalJs(`__seamGate.sample(${t}, ${JSON.stringify([...new Set(sels)])})`, true); + if (offGrid) + add( + "cut-grid", + "WARN", + `cut ${cut}s is between frames at ${fps} fps; measured around ${(cutFrame / fps).toFixed(3)}s — put cuts on the frame grid`, + ); if (type === "cut") { const ex = seam.exit, diff --git a/.claude/skills/cut-the-curve/SKILL.md b/.claude/skills/cut-the-curve/SKILL.md index c69e03b3f45..357af5568ed 100644 --- a/.claude/skills/cut-the-curve/SKILL.md +++ b/.claude/skills/cut-the-curve/SKILL.md @@ -105,6 +105,9 @@ Mechanics: - **Mirrored eases:** exit `power4.in` + entry `power4.out`, same distance and duration — the two halves of one `power4.inOut`, so velocity matches exactly at the cut. + `seam-gate` measures something slightly different: the average velocity over ~0.1s of + whole frames on each side of the cut. Mirrored halves read ≈1.0 there only when the cut + sits on the frame grid (a multiple of 1/fps). - **The fade trick:** exit opacity completes at ~25–30% of its travel (fade ≈ 0.18–0.3s vs motion 0.3–0.34s); entry ignites at ~0.35 opacity mid-path. Time the last fading element to die right at the cut — a gap where nothing moves reads as dead air. diff --git a/.claude/skills/motion-doctrine/references/seam-gate.md b/.claude/skills/motion-doctrine/references/seam-gate.md index 39fcb11dc85..3fb4c859b87 100644 --- a/.claude/skills/motion-doctrine/references/seam-gate.md +++ b/.claude/skills/motion-doctrine/references/seam-gate.md @@ -64,7 +64,10 @@ Lives at the project root. One row per seam; this is the vector ledger as data. } ``` -- `cut` — seconds on the master clock, the frame the incoming side ignites. +- `cut` — seconds on the master clock, the frame the incoming side ignites. Keep it on + the frame grid (a multiple of 1/fps, with the ledger `fps` matching the composition): + the player floors every seek to a frame, so an off-grid cut is measured around the + nearest frame and reported as `cut-grid`. - `type` — `"cut"` (default; full vector checks), `"match-cut"` / `"morph"` (carrier-continuity + overlap only; motion may start AT the boundary). - `axis` — `"x"`, `"y"`, or `"z"` (z = scale). `dir` — sign of motion: @@ -88,10 +91,18 @@ Lives at the project root. One row per seam; this is the vector ledger as data. | `zero-overlap` | rule 6 — one side visible per frame, never both | | `z-sign-scan` | rule 7 — incoming scene's own entrances don't fight the Z sign | | `carrier-*` | rules 3/4 — carrier rect continuity, ancestor scale included | +| `cut-grid` (WARN) | `cut` sits between frames; checks ran around the nearest frame | Velocities are measured on `getBoundingClientRect` (center for x/y, width-ratio for z), so ancestor wrapper transforms are automatically included. +`speed-match` compares **average** velocities over whole frames, not the instantaneous +velocity at the cut: exit from cut−0.1s to cut−1f, entry from cut+1f to cut+0.1s. The two +windows mirror each other, so a mirrored ease pair (same ease family, distance and +duration) reads ≈1.0. The stamped defaults (x/y `power3.in` 12% · 0.34s → +`power4.out` 10% · 0.42s; z 0.21s → `expo.out` 0.5s) read ≈0.8 on x/y and ≈1.1 on z. +Lengthening an entry lowers its ratio. + `__player.seek()` resolves asynchronously, so the gate awaits each seek before it reads. If every seam fails the same way (`exit-visible op 0.00`, `exit-moving 0 px/s`) and `probe` finds no movers, the reads landed before the seek did and the gate measured t=0, not the film. diff --git a/.claude/skills/motion-doctrine/scripts/seam-gate.mjs b/.claude/skills/motion-doctrine/scripts/seam-gate.mjs index 0d7b0a0d685..b55dda7639d 100644 --- a/.claude/skills/motion-doctrine/scripts/seam-gate.mjs +++ b/.claude/skills/motion-doctrine/scripts/seam-gate.mjs @@ -366,8 +366,7 @@ const fmtV = (v, axis) => async function verify() { const ledgerPath = flag("ledger", "ledger.json"); const ledger = JSON.parse(readFileSync(ledgerPath, "utf8")); - const fps = ledger.fps || FPS, - dt = 1 / fps; + const fps = ledger.fps || FPS; const compUrl = await ensureServer(); const { evalJs } = await openPage(compUrl); const results = []; @@ -377,10 +376,17 @@ async function verify() { const add = (check, status, detail) => rows.push({ check, status, detail }); const cut = seam.cut; const type = seam.type || "cut"; - const tA1 = Math.max(0, cut - 0.1), - tA2 = Math.max(0, cut - dt); - const tB1 = cut + dt, - tB2 = cut + 0.1; + // The player floors every seek to the frame grid, so sampling at cut ± k/fps for an + // off-grid cut shifts both exit samples up to a frame early and the first entry sample + // onto the cut itself, under-reading exit and over-reading entry. Sample whole frames + // placed symmetrically around the nearest frame instead. + const cutFrame = Math.round(cut * fps); + const offGrid = Math.abs(cut * fps - cutFrame) > 1e-3; + const win = Math.max(1, Math.round(0.1 * fps)); + const tA1 = Math.max(0, cutFrame - win) / fps, + tA2 = Math.max(0, cutFrame - 1) / fps; + const tB1 = (cutFrame + 1) / fps, + tB2 = (cutFrame + win) / fps; const sels = [ seam.exit?.selector, seam.entry?.selector, @@ -390,6 +396,12 @@ async function verify() { const S = {}; for (const t of [tA1, tA2, tB1, tB2]) S[t] = await evalJs(`__seamGate.sample(${t}, ${JSON.stringify([...new Set(sels)])})`, true); + if (offGrid) + add( + "cut-grid", + "WARN", + `cut ${cut}s is between frames at ${fps} fps; measured around ${(cutFrame / fps).toFixed(3)}s — put cuts on the frame grid`, + ); if (type === "cut") { const ex = seam.exit,