fix(skills): count proxy-driver tweens in the animation map (#3301)

enumerateTweens dropped every tween whose targets() held no Element:

    if (!targets.length) return;

That silently deleted the proxy-driver idiom — tween a plain object, apply the
motion inside onUpdate — which is real, visible animation. The consequence was
not just a missing row: computeDensity counted zero active tweens across the
tween's span, so findDeadZones reported animating time as DEAD, telling agents
to add motion to a stretch that already had it.

A target-less tween is now kept when a driver reaches it, marked
driver:"onUpdate". The driver can be the tween's own onUpdate, or the
TIMELINE's — the WebGL/uniform idiom is gsap.timeline({ onUpdate: renderFrame })
over children that tween plain uniform objects and carry no onUpdate of their
own, so walk() threads a `driven` flag beside parentOffset.

The discriminator is what keeps this from trading one false reading for
another. A bare `tl.to({}, { duration: D })` spacer produces nothing, and every
preset caption skin ends with exactly such a full-span anchor; counting those
would mask genuine dead zones. So a tween's own onUpdate is proof of work by
itself (a repaint loop need not animate a property), while an inherited driver
additionally requires the tween to change something.

There is no element to select or measure for a driver tween:

  * selectorHint is null rather than a placeholder — it feeds
    document.querySelector, so it must be absent, not unmatchable;
  * bbox sampling is skipped; the report shows "(onUpdate driver)";
  * computeFlags guards its geometry flags on bboxes.length, since [].every()
    is vacuously true and would report an unmeasured tween as both degenerate
    and invisible;
  * describeTween says the motion is applied in JS and no geometry was measured;
  * the per-element analyses (buildElementLifecycles, detectStaggers) run over
    element-backed tweens only, so drivers cannot collapse into one pseudo-
    element or invent a stagger. Density, dead zones and the timeline still
    count them — those are per-span, which is what a driver has.

Verified end to end: a 4s composition with an element tween over 0-1s and a
proxy driver over 2-4s went from "1/1 tweens, dead zones: 1.5-4s" to "2/2
tweens" with no dead zone.
This commit is contained in:
Miguel Ángel
2026-08-17 22:00:17 -04:00
committed by GitHub
parent a41da86517
commit 0d874adc68
3 changed files with 215 additions and 12 deletions
+1 -1
View File
@@ -22,7 +22,7 @@
"files": 17 "files": 17
}, },
"hyperframes-animation": { "hyperframes-animation": {
"hash": "2ce5ca7dbf361e27", "hash": "4418a24093c4b1c1",
"files": 121 "files": 121
}, },
"hyperframes-audio": { "hyperframes-audio": {
@@ -104,7 +104,11 @@ try {
(_, k) => +(tw.start + ((k + 0.5) / FRAMES) * (tw.end - tw.start)).toFixed(3), (_, k) => +(tw.start + ((k + 0.5) / FRAMES) * (tw.end - tw.start)).toFixed(3),
); );
const bboxes = await sampleTweenBboxes(session.page, tw.selectorHint, times); // No selector means no element to measure (an onUpdate driver). Sampling anyway
// would hand querySelector an unmatchable string.
const bboxes = tw.selectorHint
? await sampleTweenBboxes(session.page, tw.selectorHint, times)
: [];
const animProps = tw.props.filter( const animProps = tw.props.filter(
(p) => !["parent", "overwrite", "immediateRender", "startAt", "runBackwards"].includes(p), (p) => !["parent", "overwrite", "immediateRender", "startAt", "runBackwards"].includes(p),
@@ -114,7 +118,8 @@ try {
report.tweens.push({ report.tweens.push({
index: i + 1, index: i + 1,
selector: tw.selectorHint, selector: tw.selectorHint ?? "(onUpdate driver)",
driver: tw.driver,
targets: tw.targetCount, targets: tw.targetCount,
props: animProps, props: animProps,
start: +tw.start.toFixed(3), start: +tw.start.toFixed(3),
@@ -138,8 +143,14 @@ try {
// ── Composition-level analysis ── // ── Composition-level analysis ──
report.choreography = buildTimeline(report.tweens, duration); report.choreography = buildTimeline(report.tweens, duration);
report.density = computeDensity(report.tweens, duration); report.density = computeDensity(report.tweens, duration);
report.staggers = detectStaggers(report.tweens); // Staggers and lifecycles are per-ELEMENT, and a driver tween has none. Keyed on
report.elements = buildElementLifecycles(report.tweens); // tw.selector they would collapse every driver in the composition into one
// "(onUpdate driver)" pseudo-element with null geometry, and let three same-duration
// drivers read as a stagger no element performs. Density, dead zones and the timeline
// still count them — those are per-SPAN, which is what a driver does have.
const elementTweens = report.tweens.filter((tw) => tw.driver !== "onUpdate");
report.staggers = detectStaggers(elementTweens);
report.elements = buildElementLifecycles(elementTweens);
report.deadZones = findDeadZones(report.density, duration); report.deadZones = findDeadZones(report.density, duration);
report.snapshots = await captureSnapshots(session, report.tweens, duration); report.snapshots = await captureSnapshots(session, report.tweens, duration);
@@ -184,17 +195,21 @@ async function enumerateTweens(session) {
return cls ? `${el.tagName.toLowerCase()}.${cls}` : el.tagName.toLowerCase(); return cls ? `${el.tagName.toLowerCase()}.${cls}` : el.tagName.toLowerCase();
}; };
const walk = (node, parentOffset = 0) => { const walk = (node, parentOffset = 0, parentDriven = false) => {
if (!node) return; if (!node) return;
if (typeof node.getChildren === "function") { if (typeof node.getChildren === "function") {
const offset = parentOffset + (node.startTime?.() ?? 0); const offset = parentOffset + (node.startTime?.() ?? 0);
// A TIMELINE can own the driver instead of the tween. The WebGL/uniform idiom is
// gsap.timeline({ onUpdate: renderFrame }) over children that tween plain uniform
// objects; those children carry no onUpdate of their own, so the driver has to
// reach them from above or their motion reads as a dead zone all the same.
const driven = parentDriven || typeof node.vars?.onUpdate === "function";
for (const child of node.getChildren(true, true, true)) { for (const child of node.getChildren(true, true, true)) {
walk(child, offset); walk(child, offset, driven);
} }
return; return;
} }
const targets = (node.targets?.() ?? []).filter((t) => t instanceof Element); const targets = (node.targets?.() ?? []).filter((t) => t instanceof Element);
if (!targets.length) return;
const vars = node.vars ?? {}; const vars = node.vars ?? {};
const props = Object.keys(vars).filter( const props = Object.keys(vars).filter(
(k) => (k) =>
@@ -210,10 +225,28 @@ async function enumerateTweens(session) {
"stagger", "stagger",
].includes(k), ].includes(k),
); );
// The proxy-driver idiom tweens a plain object and applies the motion in onUpdate,
// so targets() holds no Element. Dropping those tweens hid real motion from the
// map: computeDensity saw zero active tweens over their span and findDeadZones
// reported it as dead. There is no element to select or measure here, but the span
// is real, so keep the tween and mark why it carries no geometry.
//
// Under an inherited driver the tween must also CHANGE something. Its own onUpdate is
// proof of work by itself (a repaint loop need not animate a property), but a parent's
// is not: a bare `tl.to({}, { duration: D })` spacer inside a driven timeline advances
// the playhead without altering any value, so counting it would mask a genuine dead
// zone — the exact false positive the tween-local rule was careful to avoid.
const isProxyDriver =
targets.length === 0 &&
(typeof vars.onUpdate === "function" || (parentDriven && props.length > 0));
if (!targets.length && !isProxyDriver) return;
const start = parentOffset + (node.startTime?.() ?? 0); const start = parentOffset + (node.startTime?.() ?? 0);
const end = start + (node.duration?.() ?? 0); const end = start + (node.duration?.() ?? 0);
results.push({ results.push({
selectorHint: selectorOf(targets[0]) ?? "(unknown)", // null, not a placeholder string: this feeds document.querySelector downstream,
// so it must be absent rather than unmatchable.
selectorHint: isProxyDriver ? null : (selectorOf(targets[0]) ?? "(unknown)"),
driver: isProxyDriver ? "onUpdate" : "target",
targetCount: targets.length, targetCount: targets.length,
props, props,
start, start,
@@ -234,7 +267,15 @@ function describeTween(tw, props, bboxes, flags) {
const dur = (tw.end - tw.start).toFixed(2); const dur = (tw.end - tw.start).toFixed(2);
const parts = []; const parts = [];
parts.push(`${tw.selectorHint} animates ${props.join("+")} over ${dur}s (${tw.ease})`); if (tw.selectorHint) {
parts.push(`${tw.selectorHint} animates ${props.join("+")} over ${dur}s (${tw.ease})`);
} else {
// An onUpdate driver: the span and props are known, the affected element is not.
parts.push(
`an onUpdate driver animates ${props.join("+")} over ${dur}s (${tw.ease}) — ` +
`motion is applied in JS, so no element geometry was measured`,
);
}
// Movement // Movement
const first = bboxes[0]; const first = bboxes[0];
@@ -299,7 +340,10 @@ function computeFlags(tw, bboxes, { width, height }) {
const flags = []; const flags = [];
const dur = tw.end - tw.start; const dur = tw.end - tw.start;
if (bboxes.every((b) => b.w === 0 || b.h === 0)) flags.push("degenerate"); // No samples at all (an onUpdate driver has no element to measure) is not evidence of
// a degenerate or invisible box — `[].every()` is vacuously true, so guard the
// geometry-derived flags. The pacing flags below read only start/end and still apply.
if (bboxes.length && bboxes.every((b) => b.w === 0 || b.h === 0)) flags.push("degenerate");
const anyOffscreen = bboxes.some( const anyOffscreen = bboxes.some(
(b) => (b) =>
@@ -314,7 +358,10 @@ function computeFlags(tw, bboxes, { width, height }) {
); );
if (anyOffscreen) flags.push("offscreen"); if (anyOffscreen) flags.push("offscreen");
if (bboxes.every((b) => b.opacity !== undefined && b.opacity < 0.01 && b.visible)) { if (
bboxes.length &&
bboxes.every((b) => b.opacity !== undefined && b.opacity < 0.01 && b.visible)
) {
flags.push("invisible"); flags.push("invisible");
} }
@@ -286,3 +286,159 @@ describe("transient-init retry", () => {
} }
}); });
}); });
// ── Proxy-driver tweens (the false dead-zone fix) ───────────────────────────
// The proxy-driver idiom tweens a plain object and applies the motion inside
// onUpdate, so the tween's targets() holds no Element. The map used to drop those
// tweens outright, which meant computeDensity counted zero active tweens over their
// span and findDeadZones reported real motion as a dead zone.
//
// The fake producer hands animation-map a session whose page.evaluate runs the
// callback in this process, against a stubbed window/document. That exercises the real
// enumerateTweens/computeDensity/findDeadZones code without a browser.
const FAKE_PROXY_DRIVER_ENV = [
"globalThis.Element = class Element {};",
"const mover = new globalThis.Element();",
'mover.id = "mover";',
"mover.classList = [];",
// 0-1s: an ordinary element tween.
"const elementTween = {",
" targets: () => [mover],",
' vars: { x: 900, duration: 1, ease: "power2.out" },',
" startTime: () => 0,",
" duration: () => 1,",
"};",
// 2-4s: a proxy driver. Real motion, no Element target.
"const proxyTween = {",
" targets: () => [{ v: 0 }],",
' vars: { v: 100, duration: 2, ease: "none", onUpdate() {} },',
" startTime: () => 2,",
" duration: () => 2,",
"};",
// 2-4s as well: a bare spacer with no onUpdate. Produces nothing, must stay dropped,
// otherwise every full-span anchor tween would mask genuine dead zones.
"const spacerTween = {",
" targets: () => [{}],",
" vars: { duration: 2 },",
" startTime: () => 2,",
" duration: () => 2,",
"};",
"const timeline = {",
" getChildren: () => [elementTween, proxyTween, spacerTween],",
" startTime: () => 0,",
" duration: () => 4,",
" seek() {},",
"};",
"globalThis.window = { __timelines: { main: timeline } };",
"globalThis.document = { querySelector: () => null, querySelectorAll: () => [] };",
"globalThis.getComputedStyle = () => ({",
' opacity: "1",',
' visibility: "visible",',
' display: "block",',
"});",
'export async function createFileServer() { return { url: "http://test", close() {} }; }',
"export async function createCaptureSession() {",
" return { page: { evaluate: async (fn, arg) => fn(arg) } };",
"}",
"export async function closeCaptureSession() {}",
"export async function initializeSession() {}",
"export async function getCompositionDuration() { return 4; }",
].join("\n");
// The WebGL/uniform shape, e.g. skills/music-to-video/references/templates/
// held-message-living-field: the TIMELINE carries onUpdate: renderFrame and its children
// tween plain uniform objects. No child has an onUpdate of its own, so a tween-local
// discriminator misses all of them and the whole composition reads as one dead zone.
const FAKE_PARENT_DRIVER_ENV = [
"globalThis.Element = class Element {};",
"const uniformTween = {",
" targets: () => [{ value: 0 }],",
' vars: { value: 12, duration: 12, ease: "none" },',
" startTime: () => 0,",
" duration: () => 12,",
"};",
// Same driven timeline, but this one alters nothing — the repaint it triggers is
// identical frame to frame, so it must NOT count as motion.
"const spacerTween = {",
" targets: () => [{}],",
" vars: { duration: 12 },",
" startTime: () => 0,",
" duration: () => 12,",
"};",
"const timeline = {",
" vars: { onUpdate() {} },",
" getChildren: () => [uniformTween, spacerTween],",
" startTime: () => 0,",
" duration: () => 12,",
" seek() {},",
"};",
"globalThis.window = { __timelines: { main: timeline } };",
"globalThis.document = { querySelector: () => null, querySelectorAll: () => [] };",
"globalThis.getComputedStyle = () => ({",
' opacity: "1",',
' visibility: "visible",',
' display: "block",',
"});",
'export async function createFileServer() { return { url: "http://test", close() {} }; }',
"export async function createCaptureSession() {",
" return { page: { evaluate: async (fn, arg) => fn(arg) } };",
"}",
"export async function closeCaptureSession() {}",
"export async function initializeSession() {}",
"export async function getCompositionDuration() { return 12; }",
].join("\n");
describe("proxy-driver tweens", () => {
it("counts an onUpdate driver's span instead of reporting it as a dead zone", () => {
const root = mkdtempSync(join(tmpdir(), "hyperframes-skill-proxy-test-"));
try {
const compositionDir = writeFakeEnv(root, FAKE_PROXY_DRIVER_ENV);
const output = runHelper(HELPERS[0], root, compositionDir);
const report = JSON.parse(readFileSync(join(root, "out", "animation-map.json"), "utf8"));
const drivers = report.tweens.filter((tw) => tw.driver === "onUpdate");
assert.equal(drivers.length, 1, `expected one onUpdate driver in:\n${output}`);
assert.equal(drivers[0].start, 2);
assert.equal(drivers[0].end, 4);
assert.equal(drivers[0].targets, 0);
assert.deepEqual(drivers[0].bboxes, [], "there is no element to measure");
// `[].every()` is vacuously true, so unmeasured must not read as degenerate/invisible.
assert.deepEqual(drivers[0].flags, []);
assert.deepEqual(report.deadZones, [], "2-4s is animating, not dead");
// The bare spacer stays out — only the element tween and the driver are mapped.
assert.equal(report.tweens.length, 2);
// Per-ELEMENT analyses must not adopt the driver as a pseudo-element.
assert.deepEqual(Object.keys(report.elements), ["#mover"]);
assert.deepEqual(report.staggers, []);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it("inherits a driver the TIMELINE owns, without counting a spacer under it", () => {
const root = mkdtempSync(join(tmpdir(), "hyperframes-skill-parent-driver-test-"));
try {
const compositionDir = writeFakeEnv(root, FAKE_PARENT_DRIVER_ENV);
const output = runHelper(HELPERS[0], root, compositionDir);
const report = JSON.parse(readFileSync(join(root, "out", "animation-map.json"), "utf8"));
const drivers = report.tweens.filter((tw) => tw.driver === "onUpdate");
assert.equal(drivers.length, 1, `expected one inherited driver in:\n${output}`);
assert.deepEqual(drivers[0].props, ["value"]);
assert.equal(drivers[0].start, 0);
assert.equal(drivers[0].end, 12);
assert.deepEqual(report.deadZones, [], "the uniform tween animates the whole span");
// Nothing element-backed here at all, so both per-element analyses stay empty.
assert.deepEqual(report.elements, {});
assert.deepEqual(report.staggers, []);
// The spacer changes no value, so the parent's onUpdate repaints an identical frame.
// Counting it would mask a real dead zone.
assert.equal(report.tweens.length, 1);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});