mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 07:09:59 +00:00
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:
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user