fix(runtime): immediateRender for set tweens + array timeline normalization (#1692)

* chore(studio): remove all console.* calls from studio package

* chore(studio): address review — remove dead stubs, restore consent notice

- Delete empty if-blocks left after console removal (snapTargetCollection,
  Player asset-poll, useTimelineSyncCallbacks 5s probe, useGestureRecording
  dev guard + now-unused isDevBuild) and the stale "surface in dev" comment.
- Drop the dangling no-console pragma + dead duplicate-id branch in sourcePatcher.
- Restore the one-time telemetry consent disclosure in showNoticeOnce (kept
  behind a pragma — it is a user-facing notice, not debug noise).
- Remove the missed timelineIcons console.warn while preserving the
  `tag || "div"` null-safety fallback.
- Route caption auto-save failures (a data-loss path) through telemetry
  instead of swallowing silently.
- Restore the accidentally-clobbered css-var-fonts output.mp4 fixture.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* 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

* fix(runtime): address review — restore perf gate, debug surface, scrub restore

- Restore the #1651 skipForInjectedVideo gate in media.ts that was dropped on
  restack — avoids ~2400 wasted per-tick seeks on video-heavy renders.
- Restore the console.debug body + docstring bullet of swallow() in
  diagnostics.ts: the __hfDebug opt-in debug surface had been gutted to an
  empty if-block.
- Rebind: after the progress-cycle set() kick, seek to state.currentTime via
  totalTime() instead of snapping to 0, so a rebind after scrub / soft-reload
  restore keeps the playhead.
- Array __timelines normalization + data-start default now resolve the root
  via a shared findRootCompositionEl() that honors data-root="true" first
  (matches resolveRootCompositionElement, which now delegates to it).
- Ease-only keyframe update leaves a primitive (non-object) keyframe value
  untouched instead of wiping it to {}; add a preservation unit test.
- Document the boundDuration<=0 progress(1) kick + restore the STATIC-case
  comment in gsapRuntimeBridge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Miguel Ángel
2026-06-24 17:53:02 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent adb40321d6
commit 6987447a75
6 changed files with 133 additions and 34 deletions
@@ -1792,6 +1792,19 @@ describe("keyframe mutations", () => {
expect(kf100.properties.y).toBe(50);
});
it("updateKeyframeInScript — ease-only update preserves existing properties", () => {
// Per-keyframe ease editing passes empty properties + an ease. The existing
// property bag must survive (don't wipe x/opacity when only the ease changes).
const id = getAnimId(KF_SCRIPT);
const updated = updateKeyframeInScript(KF_SCRIPT, id, 100, {}, "power2.inOut");
const kf100 = parseGsapScript(updated).animations[0].keyframes!.keyframes.find(
(k) => k.percentage === 100,
)!;
expect(kf100.ease).toBe("power2.inOut");
expect(kf100.properties.x).toBe(200);
expect(kf100.properties.opacity).toBe(1);
});
// Array-form keyframes (`keyframes: [{x,y}, …]`) carry no percentages — GSAP
// distributes them evenly. The motion-path overlay drags/adds by percentage,
// which used to no-op on array-authored tweens (#puck-b / #shuttle).
+34 -5
View File
@@ -1243,13 +1243,17 @@ function applyEaseUpdate(varsArg: AstNode, ease: string): void {
}
}
function applyUpdatesToCall(call: TweenCallInfo, updates: Partial<GsapAnimation>): void {
function applyUpdatesToCall(
call: TweenCallInfo,
updates: Partial<GsapAnimation> & { easeEach?: string },
): void {
if (updates.properties) reconcileEditableProperties(call.varsArg, updates.properties);
if (updates.fromProperties && call.method === "fromTo" && call.fromArg) {
reconcileEditableProperties(call.fromArg, updates.fromProperties);
}
if (updates.duration !== undefined) setVarsKey(call.varsArg, "duration", updates.duration);
if (updates.ease !== undefined) applyEaseUpdate(call.varsArg, updates.ease);
if (updates.easeEach !== undefined) applyEaseUpdate(call.varsArg, updates.easeEach);
else if (updates.ease !== undefined) applyEaseUpdate(call.varsArg, updates.ease);
if (updates.position !== undefined) {
const posIdx = call.method === "fromTo" ? 3 : 2;
call.node.arguments[posIdx] = parseExpr(valueToCode(updates.position));
@@ -1282,10 +1286,13 @@ function insertAfterAnchor(parsed: ParsedGsapAst, newStatement: AstNode): void {
function buildTweenStatementCode(timelineVar: string, anim: Omit<GsapAnimation, "id">): string {
const selector = JSON.stringify(anim.targetSelector);
const props: Record<string, number | string> = { ...anim.properties };
// `set` is instantaneous — GSAP ignores duration on it, so don't emit one.
if (anim.method !== "set" && anim.duration !== undefined) props.duration = anim.duration;
if (anim.ease) props.ease = anim.ease;
const entries = Object.entries(props).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
// immediateRender forces GSAP to apply the set when added to the timeline,
// not on the first seek — without it, tl.set at position 0 on a paused
// timeline is invisible until the playhead moves past 0.
if (anim.method === "set") entries.push("immediateRender: true");
if (anim.extras) {
for (const [k, v] of Object.entries(anim.extras)) {
entries.push(`${safeKey(k)}: ${valueToCode(v as number | string)}`);
@@ -1308,7 +1315,7 @@ function buildTweenStatementCode(timelineVar: string, anim: Omit<GsapAnimation,
export function updateAnimationInScript(
script: string,
animationId: string,
updates: Partial<GsapAnimation>,
updates: Partial<GsapAnimation> & { easeEach?: string },
): string {
let parsed: ParsedGsapAst;
try {
@@ -1437,6 +1444,7 @@ export function addAnimationWithKeyframesToScript(
auto?: boolean;
}>,
ease?: string,
easeEach?: string,
): { script: string; id: string } {
let parsed: ParsedGsapAst;
try {
@@ -1450,7 +1458,7 @@ export function addAnimationWithKeyframesToScript(
}
const selector = JSON.stringify(targetSelector);
const kfCode = buildKeyframeObjectCode(keyframes);
const kfCode = buildKeyframeObjectCode(keyframes, easeEach ? { easeEach } : undefined);
const varEntries = [`keyframes: ${kfCode}`, `duration: ${valueToCode(duration)}`];
if (ease) varEntries.push(`ease: ${JSON.stringify(ease)}`);
const posCode = valueToCode(position);
@@ -2216,6 +2224,27 @@ export function updateKeyframeInScript(
const match = findKeyframePropByPct(kfNode, percentage);
if (!match) return script;
if (Object.keys(properties).length === 0 && ease) {
// Ease-only update: preserve existing properties, just add/replace ease
const existing = match.prop.value;
if (existing?.type === "ObjectExpression") {
const props = (existing.properties ?? []) as AstNode[];
const easeIdx = props.findIndex(
(p: AstNode) => isObjectProperty(p) && propKeyName(p) === "ease",
);
const easeNode = parseExpr(`({ ease: ${JSON.stringify(ease)} })`).properties[0];
if (easeIdx >= 0) {
props[easeIdx] = easeNode;
} else {
props.push(easeNode);
}
return recast.print(loc.parsed.ast).code;
}
// Non-object keyframe value (primitive shorthand, e.g. "50%": "0.5"): there
// is no property bag to merge the ease into. Rebuilding from empty
// `properties` would wipe the primitive — leave the keyframe untouched.
return script;
}
match.prop.value = buildKeyframeValueNode(properties, ease);
return recast.print(loc.parsed.ast).code;
}
+7 -8
View File
@@ -299,7 +299,7 @@ function findInsertionPoint(parsed: ParsedGsapAcornForWrite): number | null {
export function updateAnimationInScript(
script: string,
animationId: string,
updates: Partial<GsapAnimation>,
updates: Partial<GsapAnimation> & { easeEach?: string },
): string {
if (!Object.keys(updates).length) return script;
const parsed = parseGsapScriptAcornForWrite(script);
@@ -324,13 +324,11 @@ export function updateAnimationInScript(
if (updates.duration !== undefined) {
upsertProp(ms, call.varsArg, "duration", updates.duration);
}
if (updates.ease !== undefined) {
// For a keyframe tween, easing lives at keyframes.easeEach (per-keyframe),
// not a top-level ease. Writing top-level ease would leave the per-keyframe
// easing unchanged — the user's edit would silently do nothing.
const easeValue = updates.easeEach ?? updates.ease;
if (easeValue !== undefined) {
const kfNode = keyframesObjectNode(call.varsArg);
if (kfNode) upsertProp(ms, kfNode, "easeEach", updates.ease);
else upsertProp(ms, call.varsArg, "ease", updates.ease);
if (kfNode) upsertProp(ms, kfNode, "easeEach", easeValue);
else upsertProp(ms, call.varsArg, "ease", easeValue);
}
if (updates.extras) {
for (const [key, value] of Object.entries(updates.extras)) {
@@ -1338,6 +1336,7 @@ export function addAnimationWithKeyframesToScript(
auto?: boolean;
}>,
ease?: string,
easeEach?: string,
): { script: string; id: string } {
const parsed = parseGsapScriptAcornForWrite(script);
if (!parsed) return { script, id: "" };
@@ -1345,7 +1344,7 @@ export function addAnimationWithKeyframesToScript(
if (insertionPoint === null) return { script, id: "" };
const sorted = [...keyframes].sort((a, b) => a.percentage - b.percentage);
const kfObjCode = buildKeyframeObjectCode(sorted);
const kfObjCode = buildKeyframeObjectCode(sorted, easeEach);
const varParts = [`keyframes: ${kfObjCode}`, `duration: ${valueToCode(duration)}`];
if (ease) varParts.push(`ease: ${JSON.stringify(ease)}`);
const stmtCode = `${parsed.timelineVar}.to(${JSON.stringify(targetSelector)}, { ${varParts.join(", ")} }, ${valueToCode(position)});`;
+65 -19
View File
@@ -72,6 +72,46 @@ export function initSandboxRuntimeModular(): void {
}
window.__timelines = window.__timelines || {};
// Resolve the root composition element with the same priority the rest of
// the runtime uses (explicit `data-root` marker first, then the topmost
// non-nested composition, then first in DOM order). Defined here so the
// array-normalization + data-start defaults below pick the same root the
// closure-based `resolveRootCompositionElement` does on multi-comp pages.
const findRootCompositionEl = (): HTMLElement | null => {
const explicitRoot = document.querySelector('[data-composition-id][data-root="true"]');
if (explicitRoot instanceof HTMLElement) return explicitRoot;
const nodes = Array.from(document.querySelectorAll("[data-composition-id]")) as HTMLElement[];
return (
nodes.find((node) => !node.parentElement?.closest("[data-composition-id]")) ??
nodes[0] ??
null
);
};
// Agents often write `window.__timelines = [tl]` (array) instead of the
// keyed-by-composition-id object the runtime expects. Normalize at init so
// the rest of the pipeline can assume a Record<string, timeline>.
if (Array.isArray(window.__timelines)) {
const arr = window.__timelines as unknown[];
const rootId = findRootCompositionEl()?.getAttribute("data-composition-id") ?? "root";
const normalized: Record<string, unknown> = {};
if (arr.length === 1) {
normalized[rootId] = arr[0];
} else {
for (let i = 0; i < arr.length; i++) normalized[`tl-${i}`] = arr[i];
}
(window as Record<string, unknown>).__timelines = normalized;
}
// Agents sometimes omit data-start on the root composition element. The
// runtime skips timed-visibility for elements without it, making clips
// invisible and timelines non-seekable. Default to 0 for the root.
const rootComp = findRootCompositionEl();
if (rootComp && !rootComp.hasAttribute("data-start")) {
rootComp.setAttribute("data-start", "0");
}
const registerRuntimeCleanup = (callback: () => void) => {
runtimeCleanupCallbacks.push(callback);
};
@@ -218,23 +258,7 @@ export function initSandboxRuntimeModular(): void {
return `${parsed}px`;
};
const resolveRootCompositionElement = (): HTMLElement | null => {
// 1. Explicit root marker takes priority
const explicitRoot = document.querySelector('[data-composition-id][data-root="true"]');
if (explicitRoot instanceof HTMLElement) {
return explicitRoot;
}
// 3. Topmost composition element (not nested inside another)
const compositionNodes = Array.from(
document.querySelectorAll("[data-composition-id]"),
) as HTMLElement[];
if (compositionNodes.length === 0) return null;
return (
compositionNodes.find((node) => !node.parentElement?.closest("[data-composition-id]")) ??
compositionNodes[0] ??
null
);
};
const resolveRootCompositionElement = (): HTMLElement | null => findRootCompositionEl();
const applyCompositionSizing = () => {
const rootEl = resolveRootCompositionElement();
@@ -1003,16 +1027,38 @@ export function initSandboxRuntimeModular(): void {
state.capturedTimeline.timeScale(state.playbackRate);
}
const boundDuration = getSafeTimelineDurationSeconds(state.capturedTimeline, 0);
if (boundDuration <= 0) {
// No resolvable duration (e.g. a set()-only timeline, or one whose
// duration isn't known yet). Kick GSAP off the creation position so the
// set() renders. For a finite-but-zero timeline progress(1) === progress(0);
// for an infinite-repeat timeline this lands on the first iteration's end
// frame, which is the best we can do without a known cycle length.
if (typeof state.capturedTimeline.progress === "function") {
state.capturedTimeline.progress(1, true);
state.capturedTimeline.progress(0, false);
state.capturedTimeline.pause();
}
}
if (boundDuration > 0) {
try {
clock.setDuration(boundDuration);
} catch {
// clock not yet initialized — duration will be set during TransportClock setup
}
state.capturedTimeline.pause();
const seekTime = Math.max(0, state.currentTime || 0);
if (typeof state.capturedTimeline.totalTime === "function") {
// GSAP won't render tl.set() at position 0 when the paused timeline
// starts there — play/pause/seek/totalTime are all no-ops at the
// creation position. Force the set to render by cycling progress past
// 0 (when the timeline implements it), then seek to the prior playhead
// (state.currentTime) so a rebind after a user scrub or soft-reload
// restore doesn't snap back to 0.
if (typeof state.capturedTimeline.progress === "function") {
state.capturedTimeline.progress(0.0001, true);
}
const seekTime = Math.max(0, state.currentTime || 0);
state.capturedTimeline.totalTime(seekTime, false);
state.capturedTimeline.pause();
}
// GSAP bakes the CSS `translate` into style.transform on seek.
@@ -443,6 +443,14 @@ export function endManualOffsetDragMembers(members: ManualOffsetDragMember[]): v
member.element.removeAttribute("data-hf-drag-initial-offset-y");
member.element.removeAttribute("data-hf-drag-gsap-base-x");
member.element.removeAttribute("data-hf-drag-gsap-base-y");
// Clear the draft's `translate: none` so the soft reload starts clean —
// otherwise button-less pointermoves after the reload compute deltas
// from a stale base and fling the element off-screen (#1673).
// Do NOT clearProps:"transform" — that nukes the committed GSAP position
// and causes a visual snap-back before the soft reload re-applies it.
if (member.element.style.getPropertyValue("translate") === "none") {
member.element.style.removeProperty("translate");
}
resumeGsapTimelines(member.element);
}
}
@@ -239,7 +239,9 @@ export async function tryGsapDragIntercept(
// `tl.set("#el",{x,y})`, not a keyframe conversion: re-nudge an existing set in
// place (idempotent), else add a new one. This also covers the stale-cache
// phantom — committing a set is correct because the element genuinely has no live motion.
if (!hasNonHoldTweenForElement(iframe, selector)) {
const hasNonHold = hasNonHoldTweenForElement(iframe, selector);
if (!hasNonHold) {
const existingSet =
posAnim && posAnim.method === "set" && posAnim.targetSelector === selector
? posAnim
@@ -251,7 +253,9 @@ export async function tryGsapDragIntercept(
return true;
}
if (!posAnim) return false;
if (!posAnim) {
return false;
}
// Verify the anim ID is still valid in the current file. The React-state
// `animations` list can lag behind the file after a prior mutation changed