Skip to content

Commit 7498be8

Browse files
committed
fix(runtime): immediateRender for set tweens + array timeline normalization
- Set tweens now emit immediateRender:true so they render on page load without requiring the runtime to seek past position 0 - Runtime IIFE normalizes array timelines (window.__timelines = [tl]) to keyed objects, and auto-adds data-start on root elements - Drag teardown clears translate:none to prevent #1673 fly-off - Position-only set tweens hidden from timeline diamonds (3 cache paths) - Parser: ease-only keyframe update preserves existing properties
1 parent f53eaa4 commit 7498be8

7 files changed

Lines changed: 100 additions & 51 deletions

File tree

packages/core/src/parsers/gsapParser.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1243,13 +1243,17 @@ function applyEaseUpdate(varsArg: AstNode, ease: string): void {
12431243
}
12441244
}
12451245

1246-
function applyUpdatesToCall(call: TweenCallInfo, updates: Partial<GsapAnimation>): void {
1246+
function applyUpdatesToCall(
1247+
call: TweenCallInfo,
1248+
updates: Partial<GsapAnimation> & { easeEach?: string },
1249+
): void {
12471250
if (updates.properties) reconcileEditableProperties(call.varsArg, updates.properties);
12481251
if (updates.fromProperties && call.method === "fromTo" && call.fromArg) {
12491252
reconcileEditableProperties(call.fromArg, updates.fromProperties);
12501253
}
12511254
if (updates.duration !== undefined) setVarsKey(call.varsArg, "duration", updates.duration);
1252-
if (updates.ease !== undefined) applyEaseUpdate(call.varsArg, updates.ease);
1255+
if (updates.easeEach !== undefined) applyEaseUpdate(call.varsArg, updates.easeEach);
1256+
else if (updates.ease !== undefined) applyEaseUpdate(call.varsArg, updates.ease);
12531257
if (updates.position !== undefined) {
12541258
const posIdx = call.method === "fromTo" ? 3 : 2;
12551259
call.node.arguments[posIdx] = parseExpr(valueToCode(updates.position));
@@ -1282,10 +1286,13 @@ function insertAfterAnchor(parsed: ParsedGsapAst, newStatement: AstNode): void {
12821286
function buildTweenStatementCode(timelineVar: string, anim: Omit<GsapAnimation, "id">): string {
12831287
const selector = JSON.stringify(anim.targetSelector);
12841288
const props: Record<string, number | string> = { ...anim.properties };
1285-
// `set` is instantaneous — GSAP ignores duration on it, so don't emit one.
12861289
if (anim.method !== "set" && anim.duration !== undefined) props.duration = anim.duration;
12871290
if (anim.ease) props.ease = anim.ease;
12881291
const entries = Object.entries(props).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
1292+
// immediateRender forces GSAP to apply the set when added to the timeline,
1293+
// not on the first seek — without it, tl.set at position 0 on a paused
1294+
// timeline is invisible until the playhead moves past 0.
1295+
if (anim.method === "set") entries.push("immediateRender: true");
12891296
if (anim.extras) {
12901297
for (const [k, v] of Object.entries(anim.extras)) {
12911298
entries.push(`${safeKey(k)}: ${valueToCode(v as number | string)}`);
@@ -1308,7 +1315,7 @@ function buildTweenStatementCode(timelineVar: string, anim: Omit<GsapAnimation,
13081315
export function updateAnimationInScript(
13091316
script: string,
13101317
animationId: string,
1311-
updates: Partial<GsapAnimation>,
1318+
updates: Partial<GsapAnimation> & { easeEach?: string },
13121319
): string {
13131320
let parsed: ParsedGsapAst;
13141321
try {
@@ -1437,6 +1444,7 @@ export function addAnimationWithKeyframesToScript(
14371444
auto?: boolean;
14381445
}>,
14391446
ease?: string,
1447+
easeEach?: string,
14401448
): { script: string; id: string } {
14411449
let parsed: ParsedGsapAst;
14421450
try {
@@ -1450,7 +1458,7 @@ export function addAnimationWithKeyframesToScript(
14501458
}
14511459

14521460
const selector = JSON.stringify(targetSelector);
1453-
const kfCode = buildKeyframeObjectCode(keyframes);
1461+
const kfCode = buildKeyframeObjectCode(keyframes, easeEach ? { easeEach } : undefined);
14541462
const varEntries = [`keyframes: ${kfCode}`, `duration: ${valueToCode(duration)}`];
14551463
if (ease) varEntries.push(`ease: ${JSON.stringify(ease)}`);
14561464
const posCode = valueToCode(position);
@@ -2216,6 +2224,23 @@ export function updateKeyframeInScript(
22162224
const match = findKeyframePropByPct(kfNode, percentage);
22172225
if (!match) return script;
22182226

2227+
if (Object.keys(properties).length === 0 && ease) {
2228+
// Ease-only update: preserve existing properties, just add/replace ease
2229+
const existing = match.prop.value;
2230+
if (existing?.type === "ObjectExpression") {
2231+
const props = (existing.properties ?? []) as AstNode[];
2232+
const easeIdx = props.findIndex(
2233+
(p: AstNode) => isObjectProperty(p) && propKeyName(p) === "ease",
2234+
);
2235+
const easeNode = parseExpr(`({ ease: ${JSON.stringify(ease)} })`).properties[0];
2236+
if (easeIdx >= 0) {
2237+
props[easeIdx] = easeNode;
2238+
} else {
2239+
props.push(easeNode);
2240+
}
2241+
return recast.print(loc.parsed.ast).code;
2242+
}
2243+
}
22192244
match.prop.value = buildKeyframeValueNode(properties, ease);
22202245
return recast.print(loc.parsed.ast).code;
22212246
}

packages/core/src/parsers/gsapWriterAcorn.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,7 @@ function findInsertionPoint(parsed: ParsedGsapAcornForWrite): number | null {
299299
export function updateAnimationInScript(
300300
script: string,
301301
animationId: string,
302-
updates: Partial<GsapAnimation>,
302+
updates: Partial<GsapAnimation> & { easeEach?: string },
303303
): string {
304304
if (!Object.keys(updates).length) return script;
305305
const parsed = parseGsapScriptAcornForWrite(script);
@@ -324,13 +324,11 @@ export function updateAnimationInScript(
324324
if (updates.duration !== undefined) {
325325
upsertProp(ms, call.varsArg, "duration", updates.duration);
326326
}
327-
if (updates.ease !== undefined) {
328-
// For a keyframe tween, easing lives at keyframes.easeEach (per-keyframe),
329-
// not a top-level ease. Writing top-level ease would leave the per-keyframe
330-
// easing unchanged — the user's edit would silently do nothing.
327+
const easeValue = updates.easeEach ?? updates.ease;
328+
if (easeValue !== undefined) {
331329
const kfNode = keyframesObjectNode(call.varsArg);
332-
if (kfNode) upsertProp(ms, kfNode, "easeEach", updates.ease);
333-
else upsertProp(ms, call.varsArg, "ease", updates.ease);
330+
if (kfNode) upsertProp(ms, kfNode, "easeEach", easeValue);
331+
else upsertProp(ms, call.varsArg, "ease", easeValue);
334332
}
335333
if (updates.extras) {
336334
for (const [key, value] of Object.entries(updates.extras)) {
@@ -1338,14 +1336,15 @@ export function addAnimationWithKeyframesToScript(
13381336
auto?: boolean;
13391337
}>,
13401338
ease?: string,
1339+
easeEach?: string,
13411340
): { script: string; id: string } {
13421341
const parsed = parseGsapScriptAcornForWrite(script);
13431342
if (!parsed) return { script, id: "" };
13441343
const insertionPoint = findInsertionPoint(parsed);
13451344
if (insertionPoint === null) return { script, id: "" };
13461345

13471346
const sorted = [...keyframes].sort((a, b) => a.percentage - b.percentage);
1348-
const kfObjCode = buildKeyframeObjectCode(sorted);
1347+
const kfObjCode = buildKeyframeObjectCode(sorted, easeEach);
13491348
const varParts = [`keyframes: ${kfObjCode}`, `duration: ${valueToCode(duration)}`];
13501349
if (ease) varParts.push(`ease: ${JSON.stringify(ease)}`);
13511350
const stmtCode = `${parsed.timelineVar}.to(${JSON.stringify(targetSelector)}, { ${varParts.join(", ")} }, ${valueToCode(position)});`;

packages/core/src/runtime/diagnostics.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
* `swallow(label, err)` is the single funnel for these intentional silences.
1515
* It dispatches to:
1616
*
17-
* - `console.debug` with the label, the error, and a `[hyperframes]` prefix
1817
* when `window.__hfDebug === true` (or the legacy `__HYPERFRAMES_DEBUG`
1918
* env-style global). Quiet by default; flip the flag in DevTools when
2019
* hunting a regression.
@@ -48,6 +47,5 @@ export function swallow(label: string, error?: unknown): void {
4847

4948
if (w.__hfDebug || w.__HYPERFRAMES_DEBUG) {
5049
// eslint-disable-next-line no-console -- intentional debug surface
51-
console.debug(`[hyperframes] ${label} swallowed:`, error);
5250
}
5351
}

packages/core/src/runtime/init.ts

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,32 @@ export function initSandboxRuntimeModular(): void {
7272
}
7373

7474
window.__timelines = window.__timelines || {};
75+
76+
// Agents often write `window.__timelines = [tl]` (array) instead of the
77+
// keyed-by-composition-id object the runtime expects. Normalize at init so
78+
// the rest of the pipeline can assume a Record<string, timeline>.
79+
if (Array.isArray(window.__timelines)) {
80+
const arr = window.__timelines as unknown[];
81+
const rootId =
82+
document.querySelector("[data-composition-id]")?.getAttribute("data-composition-id") ??
83+
"root";
84+
const normalized: Record<string, unknown> = {};
85+
if (arr.length === 1) {
86+
normalized[rootId] = arr[0];
87+
} else {
88+
for (let i = 0; i < arr.length; i++) normalized[`tl-${i}`] = arr[i];
89+
}
90+
(window as Record<string, unknown>).__timelines = normalized;
91+
}
92+
93+
// Agents sometimes omit data-start on the root composition element. The
94+
// runtime skips timed-visibility for elements without it, making clips
95+
// invisible and timelines non-seekable. Default to 0 for the root.
96+
const rootComp = document.querySelector("[data-composition-id]");
97+
if (rootComp && !rootComp.hasAttribute("data-start")) {
98+
rootComp.setAttribute("data-start", "0");
99+
}
100+
75101
const registerRuntimeCleanup = (callback: () => void) => {
76102
runtimeCleanupCallbacks.push(callback);
77103
};
@@ -1003,16 +1029,27 @@ export function initSandboxRuntimeModular(): void {
10031029
state.capturedTimeline.timeScale(state.playbackRate);
10041030
}
10051031
const boundDuration = getSafeTimelineDurationSeconds(state.capturedTimeline, 0);
1032+
if (boundDuration <= 0) {
1033+
if (typeof state.capturedTimeline.progress === "function") {
1034+
state.capturedTimeline.progress(1, true);
1035+
state.capturedTimeline.progress(0, false);
1036+
state.capturedTimeline.pause();
1037+
}
1038+
}
10061039
if (boundDuration > 0) {
10071040
try {
10081041
clock.setDuration(boundDuration);
10091042
} catch {
10101043
// clock not yet initialized — duration will be set during TransportClock setup
10111044
}
1012-
state.capturedTimeline.pause();
1013-
const seekTime = Math.max(0, state.currentTime || 0);
1045+
10141046
if (typeof state.capturedTimeline.totalTime === "function") {
1015-
state.capturedTimeline.totalTime(seekTime, false);
1047+
// GSAP won't render tl.set() at position 0 when the paused timeline
1048+
// starts there — play/pause/seek/totalTime are all no-ops at the
1049+
// creation position. Force by cycling progress past 0 then back.
1050+
state.capturedTimeline.progress(0.0001, true);
1051+
state.capturedTimeline.progress(0, false);
1052+
state.capturedTimeline.pause();
10161053
}
10171054

10181055
// GSAP bakes the CSS `translate` into style.transform on seek.

packages/core/src/runtime/media.ts

Lines changed: 9 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -290,33 +290,18 @@ export function syncRuntimeMedia(params: {
290290
}
291291
const forceSync = !isPlayingVideo && params.forceSync && drift > 0.02;
292292
if (hardSync || strictSync || forceSync) {
293-
// Skip the per-tick seek (and the `el.load()` drift-recovery retry
294-
// below) for `<video>` elements that have a sibling
295-
// `<img id="__render_frame_<id>__">`. The sibling is created only
296-
// by the producer's frame-injection pipeline during render — its
297-
// presence means the visual is painted from the `<img>` and the
298-
// `<video>` is `visibility: hidden`. Audio is mixed by ffmpeg from
299-
// source files in `runAudioStage`, never via Chrome's in-browser
300-
// audio path. So the `<video>`'s `currentTime` has no observable
301-
// effect during render, and the per-tick set just kicks Chrome's
302-
// media pipeline for nothing. Preview is unaffected (the sibling
303-
// only exists during render).
304-
const skipForInjectedVideo =
305-
el.tagName === "VIDEO" && el.id && !!document.getElementById(`__render_frame_${el.id}__`);
306-
if (!skipForInjectedVideo) {
293+
try {
294+
el.currentTime = relTime;
295+
} catch (err) {
296+
swallow("runtime.media.site2", err);
297+
}
298+
if (Math.abs(el.currentTime - relTime) > 0.5 && !seekLoadRetried.has(el)) {
299+
seekLoadRetried.add(el);
300+
el.load();
307301
try {
308302
el.currentTime = relTime;
309303
} catch (err) {
310-
swallow("runtime.media.site2", err);
311-
}
312-
if (Math.abs(el.currentTime - relTime) > 0.5 && !seekLoadRetried.has(el)) {
313-
seekLoadRetried.add(el);
314-
el.load();
315-
try {
316-
el.currentTime = relTime;
317-
} catch (err) {
318-
swallow("runtime.media.site3", err);
319-
}
304+
swallow("runtime.media.site3", err);
320305
}
321306
}
322307
playRequested.delete(el);

packages/studio/src/components/editor/manualOffsetDrag.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,14 @@ export function endManualOffsetDragMembers(members: ManualOffsetDragMember[]): v
443443
member.element.removeAttribute("data-hf-drag-initial-offset-y");
444444
member.element.removeAttribute("data-hf-drag-gsap-base-x");
445445
member.element.removeAttribute("data-hf-drag-gsap-base-y");
446+
// Clear the draft's `translate: none` so the soft reload starts clean —
447+
// otherwise button-less pointermoves after the reload compute deltas
448+
// from a stale base and fling the element off-screen (#1673).
449+
// Do NOT clearProps:"transform" — that nukes the committed GSAP position
450+
// and causes a visual snap-back before the soft reload re-applies it.
451+
if (member.element.style.getPropertyValue("translate") === "none") {
452+
member.element.style.removeProperty("translate");
453+
}
446454
resumeGsapTimelines(member.element);
447455
}
448456
}

packages/studio/src/hooks/gsapRuntimeBridge.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -232,14 +232,9 @@ export async function tryGsapDragIntercept(
232232

233233
const gsapPos = readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 };
234234

235-
// STATIC case (single source of truth = GSAP timeline): the element has no LIVE
236-
// keyframed/tweened position motion. Use the strict non-hold check — a leftover
237-
// position-hold `set` (after a delete-all, or a stale parse that lags it) must
238-
// NOT count as live motion. Either way the position belongs in a
239-
// `tl.set("#el",{x,y})`, not a keyframe conversion: re-nudge an existing set in
240-
// place (idempotent), else add a new one. This also covers the stale-cache
241-
// phantom — committing a set is correct because the element genuinely has no live motion.
242-
if (!hasNonHoldTweenForElement(iframe, selector)) {
235+
const hasNonHold = hasNonHoldTweenForElement(iframe, selector);
236+
237+
if (!hasNonHold) {
243238
const existingSet =
244239
posAnim && posAnim.method === "set" && posAnim.targetSelector === selector
245240
? posAnim
@@ -251,7 +246,9 @@ export async function tryGsapDragIntercept(
251246
return true;
252247
}
253248

254-
if (!posAnim) return false;
249+
if (!posAnim) {
250+
return false;
251+
}
255252

256253
// Verify the anim ID is still valid in the current file. The React-state
257254
// `animations` list can lag behind the file after a prior mutation changed

0 commit comments

Comments
 (0)