fix(studio): stop tween re-inits from baking runtime opacity transients

Editing commits made elements vanish or dim permanently: invalidating the
whole timeline (or re-running the composition script on soft reload) made
GSAP re-capture tween bounds while runtime transients were live — the
grading hide's opacity 0, or a mid-flight tween value — so from()/to()
bounds got poisoned and the element rendered invisible from then on.

- patch only the edited tween in place, never timeline.invalidate()
- soft reload restores every animated element's authored inline opacity
  (after-write HTML first, parse-time stamp as fallback) before the script
  re-runs and re-captures
- a paired x/y commit whose second half is a no-op (changed=false) still
  applies its instant patch, so panel edits reflect without deselecting
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-11 03:34:21 -04:00
parent 3147c8e063
commit 5cc14c2221
7 changed files with 343 additions and 75 deletions
+35 -1
View File
@@ -13,6 +13,7 @@
* "Which tween" is resolved by the same all-timelines scan `readRuntimeKeyframes`
* uses (`resolveRuntimeTween`), so read and write agree on the target.
*/
import { applyAuthoredInlineOpacity, readStampedAuthoredOpacity } from "../utils/authoredOpacity";
import {
resolveRuntimeTween,
type RuntimeTween,
@@ -235,6 +236,34 @@ function seekToCurrent(iframe: HTMLIFrameElement, timeline: RuntimeTimeline): vo
player?.seek?.(Number.isFinite(currentTime) ? currentTime : 0);
}
/** Does this change touch the opacity channel (whose re-init reads inline style)? */
function changeTouchesOpacity(change: RuntimeTweenChange): boolean {
if (change.kind === "set" || change.kind === "global-set")
return change.props.opacity !== undefined;
if (change.kind === "keyframes") return change.keyframes.some((step) => "opacity" in step);
return "opacity" in change.props;
}
/**
* A tween re-initialization (invalidate, or kill+recreate for keyframe-rebuild)
* captures opacity from the element's CURRENT inline style — for a color-graded
* source (hidden with `opacity: 0 !important`) or a mid-flight tween that's a
* runtime transient, not the authored value, and the capture makes it permanent.
* Restore the runtime's parse-time authored capture (data-hf-authored-opacity)
* first; the re-seek after the patch re-renders the animated value anyway.
* Duck-typed (no instanceof): the targets live in the preview iframe's realm.
*/
function restoreAuthoredOpacityForCapture(tween: RuntimeTween): void {
const targets = typeof tween.targets === "function" ? tween.targets() : [];
for (const target of targets ?? []) {
const el = target as HTMLElement | null;
if (!el?.style || typeof el.getAttribute !== "function") continue;
const authored = readStampedAuthoredOpacity(el);
if (authored === null) continue;
applyAuthoredInlineOpacity(el.style, authored);
}
}
/** Apply `change` to the resolved tween. `true` if applied, `false` to soft-reload.
* `global-set` is handled before this (no tween) and never reaches here. */
function applyChange(tween: RuntimeTween, change: RuntimeTweenChange): boolean {
@@ -270,13 +299,18 @@ export function patchRuntimeTweenInPlace(
if (!resolved) return false;
const { tween, timeline } = resolved;
if (changeTouchesOpacity(change)) restoreAuthoredOpacityForCapture(tween);
if (!applyChange(tween, change)) return false;
// A rebuild already recreated the tween; set/keyframes mutate vars in place, so
// invalidate to make GSAP re-read them on the next render. Either way, re-seek.
// Invalidate ONLY the edited tween — never the whole timeline. A timeline-wide
// invalidate re-initializes every from() tween against the CURRENT inline
// styles, and the color-grading engine hides its source elements with
// `opacity: 0 !important` — so every graded element's from(opacity) re-captures
// 0 as its end value and animates 0→0 forever (all graded elements vanish).
if (change.kind !== "keyframe-rebuild") {
tween.invalidate?.();
timeline.invalidate?.();
}
seekToCurrent(iframe, timeline);
return true;