mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +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:
@@ -104,7 +104,11 @@ try {
|
||||
(_, 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(
|
||||
(p) => !["parent", "overwrite", "immediateRender", "startAt", "runBackwards"].includes(p),
|
||||
@@ -114,7 +118,8 @@ try {
|
||||
|
||||
report.tweens.push({
|
||||
index: i + 1,
|
||||
selector: tw.selectorHint,
|
||||
selector: tw.selectorHint ?? "(onUpdate driver)",
|
||||
driver: tw.driver,
|
||||
targets: tw.targetCount,
|
||||
props: animProps,
|
||||
start: +tw.start.toFixed(3),
|
||||
@@ -138,8 +143,14 @@ try {
|
||||
// ── Composition-level analysis ──
|
||||
report.choreography = buildTimeline(report.tweens, duration);
|
||||
report.density = computeDensity(report.tweens, duration);
|
||||
report.staggers = detectStaggers(report.tweens);
|
||||
report.elements = buildElementLifecycles(report.tweens);
|
||||
// Staggers and lifecycles are per-ELEMENT, and a driver tween has none. Keyed on
|
||||
// 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.snapshots = await captureSnapshots(session, report.tweens, duration);
|
||||
|
||||
@@ -184,17 +195,21 @@ async function enumerateTweens(session) {
|
||||
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 (typeof node.getChildren === "function") {
|
||||
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)) {
|
||||
walk(child, offset);
|
||||
walk(child, offset, driven);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const targets = (node.targets?.() ?? []).filter((t) => t instanceof Element);
|
||||
if (!targets.length) return;
|
||||
const vars = node.vars ?? {};
|
||||
const props = Object.keys(vars).filter(
|
||||
(k) =>
|
||||
@@ -210,10 +225,28 @@ async function enumerateTweens(session) {
|
||||
"stagger",
|
||||
].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 end = start + (node.duration?.() ?? 0);
|
||||
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,
|
||||
props,
|
||||
start,
|
||||
@@ -234,7 +267,15 @@ function describeTween(tw, props, bboxes, flags) {
|
||||
const dur = (tw.end - tw.start).toFixed(2);
|
||||
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
|
||||
const first = bboxes[0];
|
||||
@@ -299,7 +340,10 @@ function computeFlags(tw, bboxes, { width, height }) {
|
||||
const flags = [];
|
||||
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(
|
||||
(b) =>
|
||||
@@ -314,7 +358,10 @@ function computeFlags(tw, bboxes, { width, height }) {
|
||||
);
|
||||
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");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user