feat(motion): choreography engines + two renderer root causes fixed (#543)

The release cuts read as static screen recordings: one 6s cursor glide
that then vanished, a locked-off camera, metronomic flat card fades. And
two renderer defects were silently wrecking every cut:

- @hyperframes/producer floated (^0.7.36, no lockfile — image builds get
  whatever is latest): 0.7.60 fails EVERY render with "Cannot access 'rt'
  before initialization". Pinned 0.7.36 exact and committed
  package-lock.json so sidecar builds are reproducible.
- the producer's per-clip visibility scheduler runs on a clock that lags
  ~50% behind the encoded timeline on a 40s cut — the final frame showed
  the authored ~18s state, so the tail scenes (bell card, toast, outro)
  were silently missing from the MP4. This, not authoring, is why
  rendered cuts kept losing their late scenes. Fix: clip windows are for
  structural layers only (hero, panel frame, status); every beat rides
  base-hidden styles + delayed CSS animations, which run on the true
  clock. Verified frame-by-frame: all four cards, stats, toast, and
  outro now land exactly on schedule in both cuts.

Craft, made reusable in the kit instead of per-composition heroics:

- kit.js choreographCursor: data-waypoints="t x y [click]; ..." generates
  a multi-leg eased path with fade in/out, an idle-hand sway, and click
  rings + glyph press dips — the cursor behaves like a hand, never pops
  in, freezes, or blinks out
- kit.js choreographCamera: data-shots="t x y scale; ..." — push-ins
  toward each beat's focal point, pull-backs for reveals, settle to end
- release-0.25.0 both cuts re-choreographed: the cursor is the CEO's
  hand (settle on the intake while it types, ONE submit click, witness
  each card completing, acknowledge the toast, exit off-frame); the
  camera lives on every beat; springy card entrances replace flat fades
- motion/README.md gains 'Cinematography & rhythm' (shot-list-first, no
  locked-off camera, verify motion with frame PAIRS) + the clip-window
  rule; kit/README.md documents both engines; the dev video prompt block
  carries the craft bar

Verified end to end through the real sidecar: both cuts render green,
32-frame strips read visually — cursor travels and clicks on schedule,
camera moves, every scene present. motion pnpm test 15/15.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-17 03:58:08 +02:00
committed by GitHub
co-authored by Renn F
parent e9ca7d4036
commit 3e801697f7
10 changed files with 3935 additions and 45 deletions
+2 -1
View File
@@ -18,7 +18,8 @@ Every class is namespaced `pk-`. See `compositions/panel-demo/` for a full worke
- **`pk-pill`** — a status pill: `<span class="pk-pill pk-pill--progress">in progress</span>`. Variants: `--progress`, `--review`, `--approved`, `--completed`. To swap one pill for another (e.g. progress -> completed), stack two pills at the same spot inside a `position: relative` `pk-card__status` and give the outgoing one `pk-pill--swap-out` (fades in then out) and the incoming one `pk-pill--swap-in` (fades in and stays), each with its own `--pk-pill-enter-delay` / `--pk-pill-exit-delay` (composition-absolute seconds — the same convention `data-start` uses).
- **`pk-toast`** — a notification card that slides in from a corner: `pk-toast__icon` + `pk-toast__body` (`pk-toast__title`, `pk-toast__text`). Position via `--pk-toast-bottom` (right-anchored).
- **`pk-type`** / **`pk-caret`** — character-by-character typing reveal. Write the target element's final text directly in the HTML, then call `window.PanelKit.typeText(el, { delay, stagger })` (from `kit.js`) once, synchronously, before rendering starts — it splits the text into `pk-type__char` spans with staggered `animation-delay`s (works with any font, no per-character width math). `delay` is the composition-absolute second the first character appears; `stagger` defaults to 0.045s/char. Add a `<span class="pk-caret"></span>` next to it for the blinking caret.
- **`pk-cursor`** — a small CSS-shape cursor (`pk-cursor__glyph`) that glides along a straight path and pulses (`pk-cursor__ring`) on arrival. Position via `--pk-cursor-x0/y0/x1/y1` (content-box-relative pixels) and timing via `--pk-cursor-delay` (glide start) / `--pk-click-delay` (pulse start), all composition-absolute seconds.
- **`pk-cursor`** — a small CSS-shape cursor (`pk-cursor__glyph`). Preferred: give it `data-waypoints="t x y [click]; t x y; ..."` (composition-absolute seconds, content-box px) and call `PanelKit.choreographAllCursors()` in the composition's inline script — kit.js generates a multi-leg eased path, a fade-in/out (never pop in or blink out), an idle-hand sway between legs, and a click ring + glyph press dip at every waypoint flagged `click`. The click should land ~0.2s BEFORE the thing it visually triggers. Legacy single-glide via `--pk-cursor-x0/y0/x1/y1` + `--pk-cursor-delay`/`--pk-click-delay` still works for a one-move cameo.
- **`pk-camera`** — a full-stage wrapper driven by `data-shots="t x y scale; ..."` + `PanelKit.choreographAllCameras()`: eased camera moves (push-ins toward the beat's focal point, pull-backs for reveals). Wrap the whole `pk-frame` in one; everything inside, cursor included, rides the move. Keep it subtle — `scale <= 1.08`, translate <= ~160px — and end settled at identity.
## Offline constraints (same as every composition)
+22
View File
@@ -404,6 +404,28 @@ html, body {
clip-path: polygon(0 0, 0 70%, 22% 55%, 38% 88%, 52% 82%, 36% 50%, 62% 50%);
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.45));
}
/* Idle-hand micro-drift between path legs (kit.js wraps the glyph in this
when choreographing data-waypoints); a pointer frozen pixel-still while
the scene moves reads as a rendering glitch. */
.pk-cursor__sway {
animation: pk-cursor-sway 3.2s ease-in-out infinite alternate;
}
@keyframes pk-cursor-sway {
from { transform: translate(0, 0); }
to { transform: translate(2.5px, 1.5px); }
}
/* ---------------------------------------------------------------------- *
* pk-camera a full-stage wrapper kit.js drives through data-shots
* (see choreographCamera). Wrap the scene (e.g. the whole .pk-frame) in
* one of these; everything inside, cursor included, rides the move.
* ---------------------------------------------------------------------- */
.pk-camera {
position: absolute;
inset: 0;
will-change: transform;
transform-origin: 50% 42%;
}
.pk-cursor__ring {
position: absolute;
left: -14px;
+155 -1
View File
@@ -32,5 +32,159 @@
}
}
window.PanelKit = { typeText: typeText };
var pathCounter = 0;
// Choreographs a .pk-cursor along data-waypoints: "t x y [click]; ..." —
// composition-absolute seconds, content-box px. Injects generated
// @keyframes so the cursor TRAVELS through every waypoint with per-leg
// easing, fades in at the first stop and out at the last, sways idly
// like a resting hand, and pulses a click ring (plus a glyph press dip)
// at each waypoint flagged `click`. The single-glide --pk-cursor-x0/x1
// API stays untouched for cursors without data-waypoints — this engine
// only takes over when the attribute is present.
function choreographCursor(el) {
if (!el || !el.getAttribute) return;
var raw = el.getAttribute("data-waypoints");
if (!raw) return;
var points = [];
raw.split(";").forEach(function (chunk) {
var parts = chunk.trim().split(/\s+/);
if (parts.length < 3) return;
points.push({
t: parseFloat(parts[0]),
x: parseFloat(parts[1]),
y: parseFloat(parts[2]),
click: parts[3] === "click"
});
});
if (points.length < 2) return;
var t0 = points[0].t;
var span = points[points.length - 1].t - t0;
if (!(span > 0)) return;
var name = "pk-cursor-path-" + (++pathCounter);
var pct = function (t) {
return Math.min(100, Math.max(0, ((t - t0) / span) * 100)).toFixed(3);
};
var css = "@keyframes " + name + " {\n";
points.forEach(function (p, i) {
css += " " + pct(p.t) + "% { transform: translate(" + p.x + "px, " + p.y + "px);";
if (i < points.length - 1) {
css += " animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);";
}
css += " }\n";
});
css += "}\n";
// Fade in over the first 0.4s, out over the last 0.6s — a cursor that
// pops in or blinks out of existence reads as a bug, not a hand.
css +=
"@keyframes " + name + "-fade {\n" +
" 0% { opacity: 0; }\n" +
" " + pct(t0 + 0.4) + "% { opacity: 1; }\n" +
" " + pct(t0 + span - 0.6) + "% { opacity: 1; }\n" +
" 100% { opacity: 0; }\n}\n";
var clicks = points.filter(function (p) { return p.click; });
if (clicks.length) {
// Press dip on the glyph, one dip per click, expressed as stops on a
// single generated keyframes rule spanning the whole path.
css += "@keyframes " + name + "-press {\n 0% { transform: scale(1); }\n";
clicks.forEach(function (p) {
css +=
" " + pct(p.t - 0.12) + "% { transform: scale(1); }\n" +
" " + pct(p.t) + "% { transform: scale(0.85); }\n" +
" " + pct(p.t + 0.22) + "% { transform: scale(1); }\n";
});
css += " 100% { transform: scale(1); }\n}\n";
}
var style = document.createElement("style");
style.textContent = css;
document.head.appendChild(style);
// Sway wrapper: the path moves the cursor, the wrapper breathes — a
// perfectly still pointer between legs reads as a freeze-frame.
var glyph = el.querySelector(".pk-cursor__glyph");
if (glyph) {
var sway = document.createElement("div");
sway.className = "pk-cursor__sway";
glyph.parentNode.insertBefore(sway, glyph);
sway.appendChild(glyph);
if (clicks.length) {
glyph.style.animation = name + "-press " + span + "s linear " + t0 + "s both";
}
}
clicks.forEach(function (p) {
var ring = document.createElement("div");
ring.className = "pk-cursor__ring";
ring.style.setProperty("--pk-click-delay", p.t + "s");
el.appendChild(ring);
});
el.style.animation =
name + " " + span + "s linear " + t0 + "s both, " +
name + "-fade " + span + "s linear " + t0 + "s both";
}
function choreographAllCursors() {
var els = document.querySelectorAll(".pk-cursor[data-waypoints]");
for (var i = 0; i < els.length; i++) choreographCursor(els[i]);
}
// Camera moves on a .pk-camera wrapper via data-shots: "t x y scale; ..."
// — composition-absolute seconds, a translate in px and a zoom factor per
// shot, eased leg by leg like the cursor path. A locked-off camera for a
// whole clip is the single biggest "screen recording, not a video" tell;
// push toward what matters each beat, pull wide for reveals, settle to
// end. Keep it subtle: scale <= 1.08, translate <= ~160px.
function choreographCamera(el) {
if (!el || !el.getAttribute) return;
var raw = el.getAttribute("data-shots");
if (!raw) return;
var shots = [];
raw.split(";").forEach(function (chunk) {
var parts = chunk.trim().split(/\s+/);
if (parts.length < 4) return;
shots.push({
t: parseFloat(parts[0]),
x: parseFloat(parts[1]),
y: parseFloat(parts[2]),
s: parseFloat(parts[3])
});
});
if (shots.length < 2) return;
var t0 = shots[0].t;
var span = shots[shots.length - 1].t - t0;
if (!(span > 0)) return;
var name = "pk-camera-shots-" + (++pathCounter);
var css = "@keyframes " + name + " {\n";
shots.forEach(function (p, i) {
var pct = Math.min(100, Math.max(0, ((p.t - t0) / span) * 100)).toFixed(3);
css += " " + pct + "% { transform: translate(" + p.x + "px, " + p.y + "px) scale(" + p.s + ");";
if (i < shots.length - 1) {
css += " animation-timing-function: cubic-bezier(0.45, 0, 0.2, 1);";
}
css += " }\n";
});
css += "}\n";
var style = document.createElement("style");
style.textContent = css;
document.head.appendChild(style);
el.style.animation = name + " " + span + "s linear " + t0 + "s both";
}
function choreographAllCameras() {
var els = document.querySelectorAll(".pk-camera[data-shots]");
for (var i = 0; i < els.length; i++) choreographCamera(els[i]);
}
window.PanelKit = {
typeText: typeText,
choreographCursor: choreographCursor,
choreographAllCursors: choreographAllCursors,
choreographCamera: choreographCamera,
choreographAllCameras: choreographAllCameras
};
})();