mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user