mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(core): a render keeps a fromTo's from-values when a frame lands just after its start (#5506)
* fix(core): a render keeps a fromTo's from-values when a frame lands just after its start The frame redraw steps to 1 ms below the frame and back. When a tween starts inside that 1 ms, the step down reverts its from-values and the step up does not reapply them, so a wipe bar whose opacity comes only from its from-vars was never drawn. Reapply them for every tween whose start the step crossed, as #5125 does for a tween starting exactly on the frame. Adds the gsap-redraw-start-and-overlap producer fixture, which also pins that a slow drift resumes after a short move on the same property, as in playback. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(core): keep the redraw's comment share and schedule the new fixture Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
1b60a40eff
commit
51e0323af1
@@ -482,3 +482,52 @@ describe("re-rendering leaves the rest as the seek left it", () => {
|
||||
expect(timeline.duration()).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gsap adapter just after a tween's start", () => {
|
||||
// Starts 0.5 ms before a frame, so the redraw's step down to 1 ms below that frame crosses its start.
|
||||
it("keeps a fromTo's from-only opacity on every frame of a sweep that a later set hides", () => {
|
||||
const bar = document.body.appendChild(document.createElement("div"));
|
||||
bar.style.opacity = "0";
|
||||
const timeline = gsap.timeline({ paused: true });
|
||||
timeline.fromTo(
|
||||
bar,
|
||||
{ x: 1920, opacity: 1 },
|
||||
{ x: -320, duration: 0.34, ease: "power2.inOut", immediateRender: false },
|
||||
0.4995,
|
||||
);
|
||||
timeline.set(bar, { opacity: 0 }, 0.85);
|
||||
timeline.set({}, {}, 2);
|
||||
const adapter = createGsapAdapter({
|
||||
getTimeline: () => timeline as unknown as RuntimeTimelineLike,
|
||||
});
|
||||
const shown: string[] = [];
|
||||
for (let frame = 0; frame <= 60; frame++) {
|
||||
adapter.seek({ time: frame / 30 });
|
||||
if (frame / 30 > 0.49 && frame / 30 < 0.84) shown.push(bar.style.opacity);
|
||||
}
|
||||
expect(new Set(shown)).toEqual(new Set(["1"]));
|
||||
expect(bar.style.opacity).toBe("0");
|
||||
});
|
||||
|
||||
// A slow drift with a short move on the same property inside it: the drift resumes after the move, as in playback.
|
||||
it("renders overlapping tweens on one property as playback does", () => {
|
||||
const film = () => {
|
||||
const box = { y: 0 };
|
||||
const timeline = gsap.timeline({ paused: true });
|
||||
timeline.to(box, { y: 100, duration: 4, ease: "none" }, 0);
|
||||
timeline.to(box, { y: 300, duration: 0.5, ease: "none" }, 1);
|
||||
timeline.set({}, {}, 5);
|
||||
return { box, timeline };
|
||||
};
|
||||
const played = film();
|
||||
const seeked = film();
|
||||
const adapter = createGsapAdapter({
|
||||
getTimeline: () => seeked.timeline as unknown as RuntimeTimelineLike,
|
||||
});
|
||||
for (let frame = 0; frame <= 150; frame++) {
|
||||
played.timeline.totalTime(frame / 30);
|
||||
adapter.seek({ time: frame / 30 });
|
||||
expect(seeked.box.y, `frame ${frame}`).toBeCloseTo(played.box.y, 6);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ type GsapAdapterDeps = {
|
||||
|
||||
/**
|
||||
* Re-renders a timeline already at `t`, silently, from just below (above at 0) so same-time steps apply in authored
|
||||
* order. That step skips a keyframed tween at its start and undoes a tween's from-values there, so both are redone.
|
||||
* order. That step skips a keyframed tween at its start and undoes from-values of tweens starting within it: redo both.
|
||||
*/
|
||||
export function rerenderGsapTimelineAt(
|
||||
timeline: {
|
||||
@@ -32,8 +32,9 @@ export function rerenderGsapTimelineAt(
|
||||
);
|
||||
for (const [child] of skipped) child._ts = 0;
|
||||
try {
|
||||
timeline.totalTime(t >= 0.001 ? t - 0.001 : t + 0.001, true);
|
||||
primeTweensStartingAt(children, t);
|
||||
const below = t >= REDRAW_STEP;
|
||||
timeline.totalTime(below ? t - REDRAW_STEP : t + REDRAW_STEP, true);
|
||||
primeTweensStartingAt(children, t, below ? REDRAW_STEP : 0);
|
||||
timeline.totalTime(t, true);
|
||||
} finally {
|
||||
for (const [child, timeScale] of skipped) child._ts = timeScale;
|
||||
@@ -105,6 +106,8 @@ type GsapAnimation = {
|
||||
|
||||
const BELOW_GSAP_TIME_RESOLUTION = 2e-8;
|
||||
|
||||
const REDRAW_STEP = 0.001;
|
||||
|
||||
const playsForward = (value: unknown): value is GsapAnimation => {
|
||||
const animation = value as GsapAnimation | null;
|
||||
return (
|
||||
@@ -126,14 +129,24 @@ function primedAtItsStart(tween: GsapAnimation): boolean {
|
||||
return Boolean(tween.vars?.keyframes || tween._startAt);
|
||||
}
|
||||
|
||||
function primeTweensStartingAt(children: unknown[], time: number): void {
|
||||
function primeTweensStartingAt(
|
||||
children: unknown[],
|
||||
time: number,
|
||||
stepBelowInTheirTime: number,
|
||||
): void {
|
||||
for (const child of children.filter(playsForward)) {
|
||||
const local = (time - child.startTime()) * child.timeScale();
|
||||
if (Math.abs(local) < 1e-9 && primedAtItsStart(child)) continue;
|
||||
const nested = child.getChildren ? child : child.timeline;
|
||||
if (nested?.getChildren && local > 0 && local <= child.totalDuration()) {
|
||||
primeTweensStartingAt(nested.getChildren(false, true, true), local);
|
||||
}
|
||||
if (local > 0) primeStartedTween(child, local, stepBelowInTheirTime * child.timeScale());
|
||||
}
|
||||
}
|
||||
|
||||
function primeStartedTween(child: GsapAnimation, local: number, stepBelow: number): void {
|
||||
if (local < stepBelow && child._startAt)
|
||||
child._startAt.render(BELOW_GSAP_TIME_RESOLUTION, true, true);
|
||||
const nested = child.getChildren ? child : child.timeline;
|
||||
if (nested?.getChildren && local <= child.totalDuration()) {
|
||||
primeTweensStartingAt(nested.getChildren(false, true, true), local, stepBelow);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "gsap-redraw-start-and-overlap",
|
||||
"description": "Regression guard for the per-frame GSAP redraw. A wipe bar's fromTo starts 0.7 ms before a frame and only its from-vars make it visible: it must be drawn on every frame of its sweep. A slow drift with a short move on the same property inside it must resume after the move, as in playback.",
|
||||
"tags": ["regression", "render-compat", "gsap"],
|
||||
"minPsnr": 30,
|
||||
"maxFrameFailures": 0,
|
||||
"minAudioCorrelation": 0,
|
||||
"maxAudioLagWindows": 1,
|
||||
"renderConfig": {
|
||||
"fps": 30,
|
||||
"workers": 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:26b283172c09db23296a6c56ad874777128f717f4c0fdc04f929d3f71c780233
|
||||
size 250278
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5f2dd349ba5649286def84013001f6045d1d80279a333682f76bed188331d569
|
||||
size 51844
|
||||
@@ -0,0 +1,88 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=1920, height=1080">
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
background: #c9cfdc;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
#main {
|
||||
position: relative;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
background: #c9cfdc;
|
||||
}
|
||||
|
||||
.clip {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
#word {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 380px;
|
||||
width: 1920px;
|
||||
text-align: center;
|
||||
font-size: 240px;
|
||||
line-height: 1;
|
||||
font-weight: 900;
|
||||
color: #06081a;
|
||||
}
|
||||
|
||||
#bar {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 260px;
|
||||
height: 1080px;
|
||||
background: #d8ff4a;
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="main"
|
||||
data-composition-id="gsap-redraw-start-and-overlap"
|
||||
data-width="1920"
|
||||
data-height="1080"
|
||||
data-start="0"
|
||||
data-duration="2.5"
|
||||
>
|
||||
<div id="scene" class="clip" data-start="0" data-duration="2.5">
|
||||
<div id="word">DRIFT</div>
|
||||
<div id="bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
// A slow drift with a short move inside it: after the move ends at 1.3 s the drift resumes, as in playback.
|
||||
tl.to("#word", { y: 200, duration: 2.5, ease: "none" }, 0);
|
||||
tl.to("#word", { y: -250, duration: 0.5, ease: "none" }, 0.8);
|
||||
// Starts 0.7 ms before frame 45 (1.5 s); only the from-vars make the bar visible.
|
||||
tl.fromTo(
|
||||
"#bar",
|
||||
{ x: -260, opacity: 1 },
|
||||
{ x: 1980, duration: 0.4, ease: "none", immediateRender: false },
|
||||
1.4993
|
||||
);
|
||||
tl.set("#bar", { opacity: 0 }, 1.95);
|
||||
|
||||
window.__timelines["gsap-redraw-start-and-overlap"] = tl;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -21,6 +21,7 @@
|
||||
"dogs-captions": 524,
|
||||
"font-variant-numeric": 7,
|
||||
"gsap-letters-render-compat": 9,
|
||||
"gsap-redraw-start-and-overlap": 6,
|
||||
"hdr-hlg-regression": 125,
|
||||
"hdr-regression": 207,
|
||||
"iframe-render-compat": 11,
|
||||
|
||||
Reference in New Issue
Block a user