fix(studio): address review findings on graded-element editing

Review follow-ups (both reviewers, all findings):

- resize captures scope to the resize group: convert-to-keyframes
  resolvedFromValues and the whole-offset backfill pass the group filter,
  so an opacity-touching intro tween can't ride into a converted scale
  tween (the rotation fix's contract, now uniform across intercepts)
- commitStaticSet resolves every group's target set BEFORE committing and
  coalesces groups landing on the same legacy mixed set into one commit —
  the second commit can no longer chase a stale group-derived id
- installAuthoredOpacityCapture also stamps an element the moment it GAINS
  data-color-grading at runtime (attributeFilter), not just at insertion
- both writer twins now share the same emitted-set dedupe shape
- applySoftReload's positional tail becomes a SoftReloadOptions object
- readAllAnimatedProperties builds the group-filtered key set immutably
  instead of deleting from the set mid-iteration
- applyAuthoredInlineOpacity documents the priority-lossy round-trip
- the marquee hit-test reads activeCompositionPathRef like its neighbors

New tests: resize intercept (scale route + group filter + non-uniform
longhands), after-write-HTML / stamp / empty-stamp opacity restore, the
no-op-commit-with-missed-instant-patch soft-reload contract, and the
runtime-gained-grading stamp.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-11 15:18:49 -04:00
parent 67cfae2587
commit 5d1cafff82
13 changed files with 367 additions and 88 deletions
+8 -1
View File
@@ -21,7 +21,14 @@ export function readStampedAuthoredOpacity(element: AttributeReader): string | n
return element.getAttribute(COLOR_GRADING_AUTHORED_OPACITY_ATTR);
}
/** Write an authored inline opacity back: "" removes the property, a value sets it. */
/**
* Write an authored inline opacity back: "" removes the property, a value sets
* it. Priority-lossy by design: the capture reads `style.opacity` (value only)
* and the write sets no priority, so an authored `opacity: X !important`
* round-trips as `opacity: X`. The only `!important` opacity in the pipeline
* is the color-grading runtime hide — a transient this contract exists to
* discard — and authored compositions don't `!important` their opacity.
*/
export function applyAuthoredInlineOpacity(style: CSSStyleDeclaration, authored: string): void {
if (authored === "") style.removeProperty("opacity");
else style.setProperty("opacity", authored);
@@ -107,7 +107,7 @@ describe("applySoftReload", () => {
// async commit resolves. The rebuilt timeline must re-seek to the caller's
// value, not the iframe's possibly-stale one.
const { iframe, contentWindow } = buildMockIframe();
const result = applySoftReload(iframe, SCRIPT_TEXT, undefined, 0);
const result = applySoftReload(iframe, SCRIPT_TEXT, { currentTimeOverride: 0 });
expect(result).toBe("applied");
expect(contentWindow.__player.seek).toHaveBeenCalledWith(0);
});
@@ -244,7 +244,7 @@ describe("applySoftReload", () => {
(iframe.contentDocument as unknown as { head: unknown }).head = head;
const onAsyncFailure = vi.fn();
const result = applySoftReload(iframe, MOTION_PATH_SCRIPT_TEXT, onAsyncFailure);
const result = applySoftReload(iframe, MOTION_PATH_SCRIPT_TEXT, { onAsyncFailure });
// Optimistically "applied" (script will run once the plugin loads) — and the
// script has NOT executed yet, so the timeline isn't rebound synchronously.
@@ -363,3 +363,87 @@ describe("ensureMotionPathPluginLoaded", () => {
expect(appendedScripts).toHaveLength(2);
});
});
// The authored-opacity restore: before the script re-runs (and its tweens
// re-capture bounds), every animated element's inline opacity must be put back
// to its AUTHORED value — from the after-write file HTML when provided, else
// from the parse-time stamp. Otherwise a runtime transient (the color-grading
// hide's 0, a mid-flight tween value) becomes a permanent tween bound.
describe("applySoftReload authored-opacity restore", () => {
function buildIframeWithTarget(el: HTMLElement, overrides: Record<string, unknown> = {}) {
const scriptEl = document.createElement("script");
scriptEl.textContent =
'const tl = gsap.timeline({ paused: true }); tl.to("#box", { opacity: 0.5 });';
const tl = {
kill: vi.fn(),
pause: vi.fn(),
getChildren: () => [{ targets: () => [el] }],
};
const contentWindow = {
gsap: { timeline: vi.fn(), set: vi.fn() },
__hfForceTimelineRebind: vi.fn(),
__timelines: { root: tl } as Record<string, unknown>,
__player: { getTime: () => 2.0, seek: vi.fn() },
__hfStudioManualEditsApply: vi.fn(),
...overrides,
};
const container = document.createElement("div");
container.appendChild(scriptEl);
// Intercept only POST-SETUP appends: simulate the re-run script
// repopulating __timelines (as in buildMockIframe).
const realAppendChild = container.appendChild.bind(container);
container.appendChild = <T extends Node>(node: T): T => {
const result = realAppendChild(node);
if (node instanceof HTMLScriptElement && node.textContent?.includes("gsap.timeline")) {
contentWindow.__timelines.root = { kill: vi.fn(), pause: vi.fn() };
}
return result;
};
const contentDocument = {
querySelectorAll: (sel: string) => (sel === "script:not([src])" ? [scriptEl] : []),
createElement: (tag: string) => document.createElement(tag),
body: container,
head: document.createElement("div"),
};
return { iframe: { contentWindow, contentDocument } as unknown as HTMLIFrameElement };
}
/** Run one restore cycle over `el` and return the final inline opacity. */
function restoreOpacity(el: HTMLElement, authoredHtml?: string): string {
const { iframe } = buildIframeWithTarget(el);
expect(applySoftReload(iframe, SCRIPT_TEXT, authoredHtml ? { authoredHtml } : {})).toBe(
"applied",
);
return el.style.getPropertyValue("opacity");
}
it("restores opacity from the after-write HTML (matched by data-hf-id)", () => {
const el = document.createElement("img");
el.setAttribute("data-hf-id", "hf-1");
el.style.setProperty("opacity", "0", "important"); // the grading hide
const opacity = restoreOpacity(
el,
'<html><body><img data-hf-id="hf-1" style="opacity: 0.98"></body></html>',
);
expect(opacity).toBe("0.98");
expect(el.style.getPropertyPriority("opacity")).toBe("");
});
it("falls back to the parse-time stamp when no after-write HTML is given", () => {
const el = document.createElement("img");
el.setAttribute("data-hf-authored-opacity", "0.75");
el.style.opacity = "0.123"; // mid-flight tween transient
expect(restoreOpacity(el)).toBe("0.75");
});
it("an empty stamp (authored none) removes the inline opacity", () => {
const el = document.createElement("img");
el.setAttribute("data-hf-authored-opacity", "");
el.style.opacity = "0";
expect(restoreOpacity(el)).toBe("");
});
});
+11 -3
View File
@@ -174,13 +174,21 @@ export type SoftReloadResult = "applied" | "verify-failed" | "cannot-soft-reload
* caller should perform a full reload to recover. It never fires on the
* synchronous paths.
*/
export interface SoftReloadOptions {
/** Escalation for async plugin-load failures (e.g. MotionPath CDN error). */
onAsyncFailure?: () => void;
/** Seek target for the rebuilt timeline; defaults to the iframe player time. */
currentTimeOverride?: number;
/** After-write file HTML — the primary source for authored-opacity restore. */
authoredHtml?: string;
}
export function applySoftReload(
iframe: HTMLIFrameElement | null,
scriptText: string,
onAsyncFailure?: () => void,
currentTimeOverride?: number,
authoredHtml?: string,
options: SoftReloadOptions = {},
): SoftReloadResult {
const { onAsyncFailure, currentTimeOverride, authoredHtml } = options;
if (!iframe || !scriptText) return "cannot-soft-reload";
const win = iframe.contentWindow as IframeWindow | null;