From ba20af489c40ba03ee42274e4823ecd1cf1a9bb3 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 20 Oct 2026 13:42:01 -0400 Subject: [PATCH 1/8] perf(core): Safari stops re-serializing inline styles on every GSAP write in a playing film --- bun.lock | 1 + packages/core/package.json | 10 +++ packages/core/src/runtime/clipReveal.ts | 10 +++ packages/core/src/runtime/entry.test.ts | 2 + packages/core/src/runtime/init.test.ts | 5 +- .../core/src/runtime/timedClipHide.test.ts | 75 +++++++++++++++++++ packages/core/src/runtime/timedClipHide.ts | 15 +++- packages/core/tsconfig.json | 1 + packages/shader-transitions/package.json | 1 + .../src/capture.clone.test.ts | 12 +++ packages/shader-transitions/src/capture.ts | 3 + packages/shader-transitions/tsup.config.ts | 2 +- .../editor/useLayerRevealOverride.test.ts | 28 +++++++ .../editor/useLayerRevealOverride.ts | 19 +++-- .../src/hooks/useGestureRecording.test.tsx | 42 +++++++++++ .../studio/src/hooks/useGestureRecording.ts | 6 ++ 16 files changed, 219 insertions(+), 13 deletions(-) create mode 100644 packages/core/src/runtime/clipReveal.ts create mode 100644 packages/core/src/runtime/timedClipHide.test.ts diff --git a/bun.lock b/bun.lock index d04c3aca454..4209b7d6dee 100644 --- a/bun.lock +++ b/bun.lock @@ -337,6 +337,7 @@ "html2canvas": "^1.4.1", }, "devDependencies": { + "@hyperframes/core": "workspace:*", "tsup": "^8.0.0", "typescript": "^5.0.0", "vitest": "^4.1.11", diff --git a/packages/core/package.json b/packages/core/package.json index 28acc7b34fd..92e9216a83f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -369,6 +369,12 @@ "import": "./src/runtime/clipWindow.ts", "types": "./src/runtime/clipWindow.ts" }, + "./runtime/clip-reveal": { + "bun": "./src/runtime/clipReveal.ts", + "node": "./dist/runtime/clipReveal.js", + "import": "./src/runtime/clipReveal.ts", + "types": "./src/runtime/clipReveal.ts" + }, "./runtime/stacking-context": { "bun": "./src/runtime/stackingContext.ts", "node": "./dist/runtime/stackingContext.js", @@ -783,6 +789,10 @@ "import": "./dist/runtime/clipWindow.js", "types": "./dist/runtime/clipWindow.d.ts" }, + "./runtime/clip-reveal": { + "import": "./dist/runtime/clipReveal.js", + "types": "./dist/runtime/clipReveal.d.ts" + }, "./runtime/stacking-context": { "import": "./dist/runtime/stackingContext.js", "types": "./dist/runtime/stackingContext.d.ts" diff --git a/packages/core/src/runtime/clipReveal.ts b/packages/core/src/runtime/clipReveal.ts new file mode 100644 index 00000000000..9e62d31afbf --- /dev/null +++ b/packages/core/src/runtime/clipReveal.ts @@ -0,0 +1,10 @@ +// The visibility pass's mark on a timed clip it hides. The runtime's injected rules hide the clip's +// contents by this mark alone, so whatever forces a hidden clip visible lifts it through here. +export const CLIP_HIDDEN_ATTR = "data-hf-clip-hidden"; + +/** Lifts the hidden-clip mark so a clip forced visible shows its contents; the call it returns puts it back. */ +export function liftClipHiddenMark(el: Element): () => void { + if (!el.hasAttribute(CLIP_HIDDEN_ATTR)) return () => {}; + el.removeAttribute(CLIP_HIDDEN_ATTR); + return () => el.setAttribute(CLIP_HIDDEN_ATTR, ""); +} diff --git a/packages/core/src/runtime/entry.test.ts b/packages/core/src/runtime/entry.test.ts index d54f98505f7..a224551b68f 100644 --- a/packages/core/src/runtime/entry.test.ts +++ b/packages/core/src/runtime/entry.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { HF_COLOR_GRADING_ATTR, serializeHfColorGrading } from "../colorGrading"; import { STUDIO_PREVIEW_LAZY_ATTR, STUDIO_PREVIEW_MARK_META } from "../studioPreviewMark"; +import { liftClipHiddenMark } from "./clipReveal"; import type { RuntimeTimelineLike } from "./types"; function pausedTimeline(duration: number): RuntimeTimelineLike { @@ -226,6 +227,7 @@ describe("runtime entry", () => { window.__player?.seek(0); // How Studio's layer reveal shows a hidden clip. later.style.visibility = "visible"; + liftClipHiddenMark(later); expect(imageSkipped(later)).toEqual([false]); }); diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index c13c0e68b0f..dcc84001f44 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -568,9 +568,10 @@ describe("initSandboxRuntimeModular", () => { window.__player?.seek(1); const copy = document.getElementById("scene")!.cloneNode(true) as HTMLElement; document.body.append(copy); - // As showSceneAndClips forces a captured scene and its clips visible. - copy.style.setProperty("visibility", "visible", "important"); expect(copy.hasAttribute("data-hf-clip-hidden")).toBe(true); + // As showSceneAndClips forces a captured scene and its clips visible and drops the mark. + copy.style.setProperty("visibility", "visible", "important"); + copy.removeAttribute("data-hf-clip-hidden"); const sheet = document.querySelector( "style[data-hf-hidden-clip-contents]", )!.sheet!; diff --git a/packages/core/src/runtime/timedClipHide.test.ts b/packages/core/src/runtime/timedClipHide.test.ts new file mode 100644 index 00000000000..863357ccbb8 --- /dev/null +++ b/packages/core/src/runtime/timedClipHide.test.ts @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { STUDIO_PREVIEW_MARK_META } from "../studioPreviewMark"; +import { liftClipHiddenMark } from "./clipReveal"; +import { + CLIP_HIDDEN_ATTR, + hideTimedClipsUntilFirstPass, + revealTimedClipsAfterFirstPass, + showHiddenClips, +} from "./timedClipHide"; + +const injectedSelectors = () => + Array.from(document.head.querySelectorAll("style")).flatMap((style) => + Array.from(style.sheet!.cssRules).map((rule) => (rule as CSSStyleRule).selectorText), + ); + +afterEach(() => { + revealTimedClipsAfterFirstPass(); + document.head.innerHTML = ""; + document.body.innerHTML = ""; +}); + +describe("hideTimedClipsUntilFirstPass", () => { + it("injects no rule that matches an element's inline style, in playback or Studio preview", () => { + document.head.append( + Object.assign(document.createElement("meta"), { name: STUDIO_PREVIEW_MARK_META }), + ); + hideTimedClipsUntilFirstPass(); + const selectors = injectedSelectors(); + expect(selectors.length).toBeGreaterThan(2); + expect(selectors.filter((selector) => /\[style\b/.test(selector))).toEqual([]); + }); + + it("hides a hidden clip's contents by the runtime's mark alone", () => { + hideTimedClipsUntilFirstPass(); + document.body.innerHTML = `

Later

`; + expect(getComputedStyle(document.querySelector("p")!).visibility).toBe("hidden"); + }); +}); + +describe("showHiddenClips", () => { + it("lifts the mark while the clips show and puts it back with their inline hide", () => { + hideTimedClipsUntilFirstPass(); + document.body.innerHTML = `

Later

`; + const clip = document.querySelector("[data-start]")!; + + const hideAgain = showHiddenClips(); + expect([clip.hasAttribute(CLIP_HIDDEN_ATTR), clip.style.visibility]).toEqual([ + false, + "visible", + ]); + hideAgain(); + expect([clip.hasAttribute(CLIP_HIDDEN_ATTR), clip.style.visibility]).toEqual([true, "hidden"]); + }); +}); + +describe("liftClipHiddenMark", () => { + it("lifts a clip's mark and puts it back, and leaves an unmarked element alone", () => { + document.body.innerHTML = `
`; + const marked = document.getElementById("marked")!; + const plain = document.getElementById("plain")!; + + const putMarkedBack = liftClipHiddenMark(marked); + const putPlainBack = liftClipHiddenMark(plain); + expect([marked.hasAttribute(CLIP_HIDDEN_ATTR), plain.hasAttribute(CLIP_HIDDEN_ATTR)]).toEqual([ + false, + false, + ]); + putMarkedBack(); + putPlainBack(); + expect([marked.hasAttribute(CLIP_HIDDEN_ATTR), plain.hasAttribute(CLIP_HIDDEN_ATTR)]).toEqual([ + true, + false, + ]); + }); +}); diff --git a/packages/core/src/runtime/timedClipHide.ts b/packages/core/src/runtime/timedClipHide.ts index d0027a0d56e..3258e9912a7 100644 --- a/packages/core/src/runtime/timedClipHide.ts +++ b/packages/core/src/runtime/timedClipHide.ts @@ -1,3 +1,4 @@ +import { CLIP_HIDDEN_ATTR, liftClipHiddenMark } from "./clipReveal"; import { RUNTIME_STAMPED_ATTR, STUDIO_PREVIEW_MARK_META, @@ -12,20 +13,26 @@ const HIDE_UNTIL_FIRST_PASS = "[data-start]:not(video, audio, img) { visibility: hidden !important; }"; const PREVIEW_HIDE_UNTIL_FIRST_PASS = 'img[loading="lazy"] { display: none !important; }'; const SKIP_ATTR = "data-hf-skip-hidden-images"; -export const SKIPPED_CLIP = `[data-start]:not(video, audio, img, [${STUDIO_PREVIEW_UPCOMING_ATTR}], [${RUNTIME_STAMPED_ATTR}])[style*="visibility: hidden"]`; +// Injected rules match the visibility pass's mark, never inline style: a [style] selector above other +// elements makes WebKit re-serialize an element's whole inline style on every write, as GSAP does per frame. +export { CLIP_HIDDEN_ATTR }; +export const SKIPPED_CLIP = `[data-start]:not(video, audio, img, [${STUDIO_PREVIEW_UPCOMING_ATTR}], [${RUNTIME_STAMPED_ATTR}])[${CLIP_HIDDEN_ATTR}]`; const SKIP_HIDDEN_IMAGES = `${SKIPPED_CLIP} img { display: none !important; }`; const STUDIO_PREVIEW_MARK = `meta[name="${STUDIO_PREVIEW_MARK_META}"]`; -export const CLIP_HIDDEN_ATTR = "data-hf-clip-hidden"; const CONTENTS_ATTR = "data-hf-hidden-clip-contents"; -const HIDDEN_CLIP = `[${CLIP_HIDDEN_ATTR}][style*="visibility: hidden"]`; +const HIDDEN_CLIP = `[${CLIP_HIDDEN_ATTR}]`; const HIDE_HIDDEN_CLIP_CONTENTS = `${HIDDEN_CLIP} * { visibility: hidden !important; }`; const HIDE_SVG_GENERATED_FILTER_OUTPUT = `svg${HIDDEN_CLIP}, svg${HIDDEN_CLIP} *, ${HIDDEN_CLIP} svg, ${HIDDEN_CLIP} svg * { filter: none !important; }`; export function showHiddenClips(): () => void { const hidden = Array.from(document.querySelectorAll(`[${CLIP_HIDDEN_ATTR}]`)); - for (const el of hidden) el.style.visibility = "visible"; + const putMarksBack = hidden.map((el) => { + el.style.visibility = "visible"; + return liftClipHiddenMark(el); + }); return () => { for (const el of hidden) el.style.visibility = "hidden"; + for (const putMarkBack of putMarksBack) putMarkBack(); }; } diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 26db66a0807..306f5fbc588 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -13,6 +13,7 @@ }, "files": [ "src/runtime/clipTree.ts", + "src/runtime/clipReveal.ts", "src/runtime/compositionLength.ts", "src/runtime/customEase.ts", "src/runtime/mediaVolumeEnvelope.ts", diff --git a/packages/shader-transitions/package.json b/packages/shader-transitions/package.json index 161df585f82..5075874611d 100644 --- a/packages/shader-transitions/package.json +++ b/packages/shader-transitions/package.json @@ -44,6 +44,7 @@ "html2canvas": "^1.4.1" }, "devDependencies": { + "@hyperframes/core": "workspace:*", "tsup": "^8.0.0", "typescript": "^5.0.0", "vitest": "^4.1.11" diff --git a/packages/shader-transitions/src/capture.clone.test.ts b/packages/shader-transitions/src/capture.clone.test.ts index 4cbb1081879..0e206d87d74 100644 --- a/packages/shader-transitions/src/capture.clone.test.ts +++ b/packages/shader-transitions/src/capture.clone.test.ts @@ -17,6 +17,18 @@ describe("forceSceneVisibleInClone", () => { "visible", ]); }); + + it("shows the contents of a scene the runtime marked hidden", () => { + document.head.innerHTML = + ""; + document.body.innerHTML = + ''; + const scene = document.getElementById("scene") as HTMLElement; + + forceSceneVisibleInClone(scene, document); + + expect(getComputedStyle(scene.querySelector("p") as HTMLElement).visibility).toBe("visible"); + }); }); describe("captureScene with html-in-canvas", () => { diff --git a/packages/shader-transitions/src/capture.ts b/packages/shader-transitions/src/capture.ts index 1a5af0a6a3d..b5892eb0e1a 100644 --- a/packages/shader-transitions/src/capture.ts +++ b/packages/shader-transitions/src/capture.ts @@ -1,3 +1,4 @@ +import { liftClipHiddenMark } from "@hyperframes/core/runtime/clip-reveal"; import html2canvas from "html2canvas"; import { DEFAULT_WIDTH, DEFAULT_HEIGHT } from "./webgl.js"; @@ -52,8 +53,10 @@ export interface CaptureSceneOptions { function showSceneAndClips(scene: HTMLElement): void { scene.style.opacity = "1"; scene.style.setProperty("visibility", "visible", "important"); + liftClipHiddenMark(scene); scene.querySelectorAll("[data-start]").forEach((el) => { el.style.setProperty("visibility", "visible", "important"); + liftClipHiddenMark(el); }); } diff --git a/packages/shader-transitions/tsup.config.ts b/packages/shader-transitions/tsup.config.ts index 593667575de..4fcc0830f68 100644 --- a/packages/shader-transitions/tsup.config.ts +++ b/packages/shader-transitions/tsup.config.ts @@ -4,7 +4,7 @@ export default defineConfig({ entry: ["src/index.ts"], format: ["esm", "cjs", "iife"], globalName: "HyperShader", - noExternal: ["html2canvas"], + noExternal: ["html2canvas", "@hyperframes/core"], dts: true, clean: true, minify: true, diff --git a/packages/studio/src/components/editor/useLayerRevealOverride.test.ts b/packages/studio/src/components/editor/useLayerRevealOverride.test.ts index 52208d67003..080ce0afec3 100644 --- a/packages/studio/src/components/editor/useLayerRevealOverride.test.ts +++ b/packages/studio/src/components/editor/useLayerRevealOverride.test.ts @@ -18,6 +18,7 @@ import { readTimelineElementZIndex, } from "../../player/lib/timelineElementHelpers"; import { installReactActEnvironment } from "../../hooks/domSelectionTestHarness"; +import { CLIP_HIDDEN_ATTR } from "@hyperframes/core/runtime/clip-reveal"; installReactActEnvironment(); @@ -149,6 +150,33 @@ describe("useLayerRevealOverride — delayed reveal ownership", () => { act(() => root.unmount()); }); + it("lifts the runtime's hidden-clip mark with the visibility it forces, and puts both back", () => { + const host = document.createElement("div"); + const clip = makeEl("2", "absolute"); + clip.style.visibility = "hidden"; + clip.setAttribute(CLIP_HIDDEN_ATTR, ""); + document.body.appendChild(host); + const root = createRoot(host); + let scheduleReveal: ((element: HTMLElement, delayMs: number) => void) | undefined; + + function Harness({ isPlaying }: { isPlaying: boolean }) { + ({ scheduleReveal } = useLayerRevealOverride({ isPlaying, selectedElement: clip })); + return null; + } + + act(() => root.render(React.createElement(Harness, { isPlaying: false }))); + act(() => scheduleReveal!(clip, 0)); + act(() => vi.advanceTimersByTime(0)); + expect([clip.style.visibility, clip.hasAttribute(CLIP_HIDDEN_ATTR)]).toEqual([ + "visible", + false, + ]); + + act(() => root.render(React.createElement(Harness, { isPlaying: true }))); + expect([clip.style.visibility, clip.hasAttribute(CLIP_HIDDEN_ATTR)]).toEqual(["hidden", true]); + act(() => root.unmount()); + }); + it("revalidates the current selection before a delayed reveal runs", () => { const host = document.createElement("div"); const selected = makeEl("2", "absolute"); diff --git a/packages/studio/src/components/editor/useLayerRevealOverride.ts b/packages/studio/src/components/editor/useLayerRevealOverride.ts index 792d7ca39d6..9d9b8f9b87c 100644 --- a/packages/studio/src/components/editor/useLayerRevealOverride.ts +++ b/packages/studio/src/components/editor/useLayerRevealOverride.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef } from "react"; +import { liftClipHiddenMark } from "@hyperframes/core/runtime/clip-reveal"; import { LAYER_REVEAL_PRIOR_POSITION_ATTR, LAYER_REVEAL_PRIOR_Z_ATTR, @@ -15,6 +16,8 @@ interface RevealedNode { priors: { display: string; visibility: string; opacity: string }; /** Values THIS override wrote — restore only while they are still in place. */ applied: { display?: string; visibility?: string; opacity?: string }; + /** Puts back the runtime's hidden-clip mark this override lifted with the visibility. */ + putMarkBack?: () => void; } interface RevealLift { @@ -158,10 +161,11 @@ function restoreIfOurs( property: "display" | "visibility" | "opacity", applied: string | undefined, prior: string, -): void { - if (applied == null) return; - if (el.style.getPropertyValue(property) !== applied) return; +): boolean { + if (applied == null) return false; + if (el.style.getPropertyValue(property) !== applied) return false; restoreInline(el, property, prior); + return true; } /** What hides this node at the current frame, per computed style. */ @@ -187,6 +191,7 @@ function revealNode( opacity: el.style.opacity, }; const applied: RevealedNode["applied"] = {}; + let putMarkBack: RevealedNode["putMarkBack"]; if (needs.display) { // Prefer whatever the stylesheet says once the inline hide is lifted; // only force block when the sheet itself hides it. @@ -197,12 +202,13 @@ function revealNode( if (needs.visibility) { el.style.visibility = "visible"; applied.visibility = "visible"; + putMarkBack = liftClipHiddenMark(el); } if (needs.opacity) { el.style.opacity = "1"; applied.opacity = "1"; } - return { element: el, priors, applied }; + return { element: el, priors, applied, putMarkBack }; } /** Walk `element` → body, force-revealing every hiding node; returns the touched nodes. */ @@ -317,10 +323,11 @@ export function useLayerRevealOverride({ const state = stateRef.current; stateRef.current = null; if (!state) return; - for (const { element, priors, applied } of state.nodes) { + for (const { element, priors, applied, putMarkBack } of state.nodes) { if (!element.isConnected) continue; restoreIfOurs(element, "display", applied.display, priors.display); - restoreIfOurs(element, "visibility", applied.visibility, priors.visibility); + if (restoreIfOurs(element, "visibility", applied.visibility, priors.visibility)) + putMarkBack?.(); restoreIfOurs(element, "opacity", applied.opacity, priors.opacity); } if (state.lift && state.base.isConnected) restoreLiftedElement(state.base, state.lift); diff --git a/packages/studio/src/hooks/useGestureRecording.test.tsx b/packages/studio/src/hooks/useGestureRecording.test.tsx index 5ce382c0201..6d4102541ca 100644 --- a/packages/studio/src/hooks/useGestureRecording.test.tsx +++ b/packages/studio/src/hooks/useGestureRecording.test.tsx @@ -4,6 +4,7 @@ import React, { act } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { mountReactHarness } from "./domSelectionTestHarness"; import { useGestureRecording, type GestureSample } from "./useGestureRecording"; +import { CLIP_HIDDEN_ATTR } from "@hyperframes/core/runtime/clip-reveal"; Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true); @@ -65,6 +66,47 @@ describe("useGestureRecording", () => { act(() => root.unmount()); }); + it("lifts the runtime's hidden-clip mark while previewing a hidden clip and puts it back on release", () => { + const animationFrames: FrameRequestCallback[] = []; + vi.stubGlobal( + "requestAnimationFrame", + vi.fn((callback: FrameRequestCallback) => animationFrames.push(callback)), + ); + vi.stubGlobal("cancelAnimationFrame", vi.fn()); + const iframe = document.createElement("iframe"); + document.body.append(iframe); + const element = iframe.contentDocument!.createElement("div"); + element.id = "card"; + element.style.visibility = "hidden"; + element.setAttribute(CLIP_HIDDEN_ATTR, ""); + iframe.contentDocument!.body.append(element); + Reflect.set(iframe.contentWindow!, "gsap", { getProperty: vi.fn(() => 0), set: vi.fn() }); + Reflect.set(iframe.contentWindow!, "__timelines", { + main: { duration: () => 5, seek: vi.fn() }, + }); + Reflect.set(iframe.contentWindow!, "__player", { getTime: () => 1 }); + + let recording: ReturnType | null = null; + function Harness() { + recording = useGestureRecording(); + return null; + } + const root = mountReactHarness(); + act(() => recording?.startRecording(element, iframe, 4)); + document.dispatchEvent(new MouseEvent("pointermove", { bubbles: true, clientX: 10 })); + act(() => animationFrames.shift()?.(1)); + expect([element.style.visibility, element.hasAttribute(CLIP_HIDDEN_ATTR)]).toEqual([ + "visible", + false, + ]); + + act(() => root.unmount()); + expect([element.style.visibility, element.hasAttribute(CLIP_HIDDEN_ATTR)]).toEqual([ + "hidden", + true, + ]); + }); + it("releases the preview runtime when unmounted during a recording", () => { const cancelAnimationFrame = vi.fn(); vi.stubGlobal( diff --git a/packages/studio/src/hooks/useGestureRecording.ts b/packages/studio/src/hooks/useGestureRecording.ts index 1bba90974fb..4a288406f9b 100644 --- a/packages/studio/src/hooks/useGestureRecording.ts +++ b/packages/studio/src/hooks/useGestureRecording.ts @@ -1,3 +1,4 @@ +import { liftClipHiddenMark } from "@hyperframes/core/runtime/clip-reveal"; import { useCallback, useEffect, useRef, useState } from "react"; import { usePlayerStore, liveTime } from "../player/store/playerStore"; import { frameToSeconds, secondsToFrame } from "../player/lib/time"; @@ -36,6 +37,8 @@ interface GsapRuntime { maxSeekTime: number; savedVisibility: string; savedTranslate: string; + /** Puts back the runtime's hidden-clip mark the preview lifted when it forced the element visible. */ + putMarkBack: (() => void) | null; } // --------------------------------------------------------------------------- @@ -107,6 +110,7 @@ function connectGsapRuntime( elementEndTime != null && elementEndTime < tlDuration ? elementEndTime : tlDuration, savedVisibility: element.style.visibility, savedTranslate: element.style.getPropertyValue("translate"), + putMarkBack: null, }; } } catch { @@ -125,6 +129,7 @@ function applyRuntimePreview( runtime.element.style.setProperty("translate", "none"); runtime.gsap.set(runtime.element, { ...properties }); runtime.element.style.visibility = "visible"; + runtime.putMarkBack ??= liftClipHiddenMark(runtime.element); liveTime.notify(seekTime); usePlayerStore.getState().setCurrentTime(seekTime); } @@ -251,6 +256,7 @@ function releaseRuntimePreview(r: RecordingRefs): void { if (!runtime) return; const { element, savedVisibility, savedTranslate } = runtime; element.style.visibility = savedVisibility; + runtime.putMarkBack?.(); element.style.setProperty("translate", savedTranslate || ""); try { runtime.gsap.set(runtime.element, { From 11c461b2b6a21a17233b4a63d9cc86da84f23358 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 20 Oct 2026 14:53:55 -0400 Subject: [PATCH 2/8] perf(core): declare the clip-reveal subpath in its descriptor and cut added comment lines --- packages/core/package-subpaths.json | 6 ++++++ packages/core/src/runtime/clipReveal.ts | 8 ++++---- packages/core/src/runtime/timedClipHide.ts | 2 -- .../src/components/editor/useLayerRevealOverride.ts | 1 - packages/studio/src/hooks/useGestureRecording.ts | 1 - 5 files changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/core/package-subpaths.json b/packages/core/package-subpaths.json index cc40df35443..80d871823ef 100644 --- a/packages/core/package-subpaths.json +++ b/packages/core/package-subpaths.json @@ -362,6 +362,12 @@ "types": "./dist/runtime/clipWindow.d.ts", "environments": ["browser", "bun", "node"] }, + "./runtime/clip-reveal": { + "source": "./src/runtime/clipReveal.ts", + "runtime": "./dist/runtime/clipReveal.js", + "types": "./dist/runtime/clipReveal.d.ts", + "environments": ["browser", "bun", "node"] + }, "./runtime/stacking-context": { "source": "./src/runtime/stackingContext.ts", "runtime": "./dist/runtime/stackingContext.js", diff --git a/packages/core/src/runtime/clipReveal.ts b/packages/core/src/runtime/clipReveal.ts index 9e62d31afbf..d1bb626bcd7 100644 --- a/packages/core/src/runtime/clipReveal.ts +++ b/packages/core/src/runtime/clipReveal.ts @@ -1,10 +1,10 @@ -// The visibility pass's mark on a timed clip it hides. The runtime's injected rules hide the clip's -// contents by this mark alone, so whatever forces a hidden clip visible lifts it through here. +/** The visibility pass's mark on a timed clip it hides; the runtime's rules hide the clip's contents by it. */ export const CLIP_HIDDEN_ATTR = "data-hf-clip-hidden"; -/** Lifts the hidden-clip mark so a clip forced visible shows its contents; the call it returns puts it back. */ export function liftClipHiddenMark(el: Element): () => void { if (!el.hasAttribute(CLIP_HIDDEN_ATTR)) return () => {}; el.removeAttribute(CLIP_HIDDEN_ATTR); - return () => el.setAttribute(CLIP_HIDDEN_ATTR, ""); + return function putClipHiddenMarkBack() { + el.setAttribute(CLIP_HIDDEN_ATTR, ""); + }; } diff --git a/packages/core/src/runtime/timedClipHide.ts b/packages/core/src/runtime/timedClipHide.ts index 3258e9912a7..38ff6625dde 100644 --- a/packages/core/src/runtime/timedClipHide.ts +++ b/packages/core/src/runtime/timedClipHide.ts @@ -13,8 +13,6 @@ const HIDE_UNTIL_FIRST_PASS = "[data-start]:not(video, audio, img) { visibility: hidden !important; }"; const PREVIEW_HIDE_UNTIL_FIRST_PASS = 'img[loading="lazy"] { display: none !important; }'; const SKIP_ATTR = "data-hf-skip-hidden-images"; -// Injected rules match the visibility pass's mark, never inline style: a [style] selector above other -// elements makes WebKit re-serialize an element's whole inline style on every write, as GSAP does per frame. export { CLIP_HIDDEN_ATTR }; export const SKIPPED_CLIP = `[data-start]:not(video, audio, img, [${STUDIO_PREVIEW_UPCOMING_ATTR}], [${RUNTIME_STAMPED_ATTR}])[${CLIP_HIDDEN_ATTR}]`; const SKIP_HIDDEN_IMAGES = `${SKIPPED_CLIP} img { display: none !important; }`; diff --git a/packages/studio/src/components/editor/useLayerRevealOverride.ts b/packages/studio/src/components/editor/useLayerRevealOverride.ts index 9d9b8f9b87c..f25e55e97aa 100644 --- a/packages/studio/src/components/editor/useLayerRevealOverride.ts +++ b/packages/studio/src/components/editor/useLayerRevealOverride.ts @@ -16,7 +16,6 @@ interface RevealedNode { priors: { display: string; visibility: string; opacity: string }; /** Values THIS override wrote — restore only while they are still in place. */ applied: { display?: string; visibility?: string; opacity?: string }; - /** Puts back the runtime's hidden-clip mark this override lifted with the visibility. */ putMarkBack?: () => void; } diff --git a/packages/studio/src/hooks/useGestureRecording.ts b/packages/studio/src/hooks/useGestureRecording.ts index 4a288406f9b..24e61d60e15 100644 --- a/packages/studio/src/hooks/useGestureRecording.ts +++ b/packages/studio/src/hooks/useGestureRecording.ts @@ -37,7 +37,6 @@ interface GsapRuntime { maxSeekTime: number; savedVisibility: string; savedTranslate: string; - /** Puts back the runtime's hidden-clip mark the preview lifted when it forced the element visible. */ putMarkBack: (() => void) | null; } From 222fb8329d116bb121c29b589857c4d7c54014fd Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 20 Oct 2026 17:37:08 -0400 Subject: [PATCH 3/8] fix(shader-transitions): shader transitions across a scene boundary keep the scene's contents Every revealer now goes through one core helper that shows the clip and lifts the runtime's hidden mark together; the page-side compositor's scene copies were still carrying the mark. The contents rule keeps its old weight. --- .../cli/src/commands/layout-audit.browser.js | 2 +- packages/core/src/runtime/clipReveal.ts | 4 +- packages/core/src/runtime/entry.test.ts | 15 +++-- .../core/src/runtime/timedClipHide.test.ts | 27 ++++++--- packages/core/src/runtime/timedClipHide.ts | 10 ++-- packages/shader-transitions/src/capture.ts | 10 +--- .../src/engineModePageComposite.test.ts | 56 +++++++++++++++++-- .../src/engineModePageComposite.ts | 5 +- .../editor/useLayerRevealOverride.ts | 5 +- .../studio/src/hooks/useGestureRecording.ts | 6 +- 10 files changed, 96 insertions(+), 44 deletions(-) diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index ca1873a0ad4..54dbada194b 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -713,7 +713,7 @@ return colorAlpha(getComputedStyle(element).color) >= 0.35; } - const RUNTIME_HIDDEN_CLIP = '[data-hf-clip-hidden][style*="visibility: hidden"]'; + const RUNTIME_HIDDEN_CLIP = "[data-hf-clip-hidden]"; // Core's timedClipHide rule hides these clips' whole subtrees: skipping them bounds work by what is on screen. function elementsOnScreenScenes(root) { diff --git a/packages/core/src/runtime/clipReveal.ts b/packages/core/src/runtime/clipReveal.ts index d1bb626bcd7..654b6434b1f 100644 --- a/packages/core/src/runtime/clipReveal.ts +++ b/packages/core/src/runtime/clipReveal.ts @@ -1,7 +1,9 @@ /** The visibility pass's mark on a timed clip it hides; the runtime's rules hide the clip's contents by it. */ export const CLIP_HIDDEN_ATTR = "data-hf-clip-hidden"; -export function liftClipHiddenMark(el: Element): () => void { +/** Shows a clip the visibility pass hid, contents included; returns a function that puts the mark back. */ +export function revealClip(el: HTMLElement, priority = ""): () => void { + el.style.setProperty("visibility", "visible", priority); if (!el.hasAttribute(CLIP_HIDDEN_ATTR)) return () => {}; el.removeAttribute(CLIP_HIDDEN_ATTR); return function putClipHiddenMarkBack() { diff --git a/packages/core/src/runtime/entry.test.ts b/packages/core/src/runtime/entry.test.ts index a224551b68f..a7f15817966 100644 --- a/packages/core/src/runtime/entry.test.ts +++ b/packages/core/src/runtime/entry.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { HF_COLOR_GRADING_ATTR, serializeHfColorGrading } from "../colorGrading"; import { STUDIO_PREVIEW_LAZY_ATTR, STUDIO_PREVIEW_MARK_META } from "../studioPreviewMark"; -import { liftClipHiddenMark } from "./clipReveal"; +import { revealClip } from "./clipReveal"; import type { RuntimeTimelineLike } from "./types"; function pausedTimeline(duration: number): RuntimeTimelineLike { @@ -226,8 +226,7 @@ describe("runtime entry", () => { expect(imageSkipped(later, brief)).toEqual([false, false]); window.__player?.seek(0); // How Studio's layer reveal shows a hidden clip. - later.style.visibility = "visible"; - liftClipHiddenMark(later); + revealClip(later); expect(imageSkipped(later)).toEqual([false]); }); @@ -376,11 +375,11 @@ describe("runtime entry", () => { const loads: HTMLMediaElement[] = []; const spyLoad = () => { loads.length = 0; - vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation( - function (this: HTMLMediaElement) { - loads.push(this); - }, - ); + vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(function ( + this: HTMLMediaElement, + ) { + loads.push(this); + }); }; const loadsOf = (...els: HTMLMediaElement[]) => els.map((el) => loads.filter((loaded) => loaded === el).length); diff --git a/packages/core/src/runtime/timedClipHide.test.ts b/packages/core/src/runtime/timedClipHide.test.ts index 863357ccbb8..c1f4c29e151 100644 --- a/packages/core/src/runtime/timedClipHide.test.ts +++ b/packages/core/src/runtime/timedClipHide.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from "vitest"; import { STUDIO_PREVIEW_MARK_META } from "../studioPreviewMark"; -import { liftClipHiddenMark } from "./clipReveal"; +import { revealClip } from "./clipReveal"; import { CLIP_HIDDEN_ATTR, hideTimedClipsUntilFirstPass, @@ -32,7 +32,14 @@ describe("hideTimedClipsUntilFirstPass", () => { it("hides a hidden clip's contents by the runtime's mark alone", () => { hideTimedClipsUntilFirstPass(); - document.body.innerHTML = `

Later

`; + document.body.innerHTML = `

Later

`; + expect(getComputedStyle(document.querySelector("p")!).visibility).toBe("hidden"); + }); + + it("keeps a hidden clip's contents hidden over an author's important visibility rule", () => { + document.head.innerHTML = ""; + hideTimedClipsUntilFirstPass(); + document.body.innerHTML = `

Later

`; expect(getComputedStyle(document.querySelector("p")!).visibility).toBe("hidden"); }); }); @@ -53,16 +60,20 @@ describe("showHiddenClips", () => { }); }); -describe("liftClipHiddenMark", () => { - it("lifts a clip's mark and puts it back, and leaves an unmarked element alone", () => { - document.body.innerHTML = `
`; +describe("revealClip", () => { + it("shows a clip and lifts its mark until put back, and leaves an unmarked element unmarked", () => { + document.body.innerHTML = `
`; const marked = document.getElementById("marked")!; const plain = document.getElementById("plain")!; - const putMarkedBack = liftClipHiddenMark(marked); - const putPlainBack = liftClipHiddenMark(plain); - expect([marked.hasAttribute(CLIP_HIDDEN_ATTR), plain.hasAttribute(CLIP_HIDDEN_ATTR)]).toEqual([ + const putMarkedBack = revealClip(marked, "important"); + const putPlainBack = revealClip(plain); + expect([marked.style.cssText, marked.hasAttribute(CLIP_HIDDEN_ATTR)]).toEqual([ + "visibility: visible !important;", false, + ]); + expect([plain.style.visibility, plain.hasAttribute(CLIP_HIDDEN_ATTR)]).toEqual([ + "visible", false, ]); putMarkedBack(); diff --git a/packages/core/src/runtime/timedClipHide.ts b/packages/core/src/runtime/timedClipHide.ts index 38ff6625dde..f685a882614 100644 --- a/packages/core/src/runtime/timedClipHide.ts +++ b/packages/core/src/runtime/timedClipHide.ts @@ -1,4 +1,4 @@ -import { CLIP_HIDDEN_ATTR, liftClipHiddenMark } from "./clipReveal"; +import { CLIP_HIDDEN_ATTR, revealClip } from "./clipReveal"; import { RUNTIME_STAMPED_ATTR, STUDIO_PREVIEW_MARK_META, @@ -18,16 +18,14 @@ export const SKIPPED_CLIP = `[data-start]:not(video, audio, img, [${STUDIO_PREVI const SKIP_HIDDEN_IMAGES = `${SKIPPED_CLIP} img { display: none !important; }`; const STUDIO_PREVIEW_MARK = `meta[name="${STUDIO_PREVIEW_MARK_META}"]`; const CONTENTS_ATTR = "data-hf-hidden-clip-contents"; -const HIDDEN_CLIP = `[${CLIP_HIDDEN_ATTR}]`; +// Doubled to keep the weight the rule had when it also matched the inline hide. +const HIDDEN_CLIP = `[${CLIP_HIDDEN_ATTR}][${CLIP_HIDDEN_ATTR}]`; const HIDE_HIDDEN_CLIP_CONTENTS = `${HIDDEN_CLIP} * { visibility: hidden !important; }`; const HIDE_SVG_GENERATED_FILTER_OUTPUT = `svg${HIDDEN_CLIP}, svg${HIDDEN_CLIP} *, ${HIDDEN_CLIP} svg, ${HIDDEN_CLIP} svg * { filter: none !important; }`; export function showHiddenClips(): () => void { const hidden = Array.from(document.querySelectorAll(`[${CLIP_HIDDEN_ATTR}]`)); - const putMarksBack = hidden.map((el) => { - el.style.visibility = "visible"; - return liftClipHiddenMark(el); - }); + const putMarksBack = hidden.map((el) => revealClip(el)); return () => { for (const el of hidden) el.style.visibility = "hidden"; for (const putMarkBack of putMarksBack) putMarkBack(); diff --git a/packages/shader-transitions/src/capture.ts b/packages/shader-transitions/src/capture.ts index b5892eb0e1a..d89c9ff5686 100644 --- a/packages/shader-transitions/src/capture.ts +++ b/packages/shader-transitions/src/capture.ts @@ -1,4 +1,4 @@ -import { liftClipHiddenMark } from "@hyperframes/core/runtime/clip-reveal"; +import { revealClip } from "@hyperframes/core/runtime/clip-reveal"; import html2canvas from "html2canvas"; import { DEFAULT_WIDTH, DEFAULT_HEIGHT } from "./webgl.js"; @@ -52,12 +52,8 @@ export interface CaptureSceneOptions { function showSceneAndClips(scene: HTMLElement): void { scene.style.opacity = "1"; - scene.style.setProperty("visibility", "visible", "important"); - liftClipHiddenMark(scene); - scene.querySelectorAll("[data-start]").forEach((el) => { - el.style.setProperty("visibility", "visible", "important"); - liftClipHiddenMark(el); - }); + revealClip(scene, "important"); + scene.querySelectorAll("[data-start]").forEach((el) => revealClip(el, "important")); } export function forceSceneVisibleInClone(source: HTMLElement, cloneDoc: Document): void { diff --git a/packages/shader-transitions/src/engineModePageComposite.test.ts b/packages/shader-transitions/src/engineModePageComposite.test.ts index e19287a247c..c817dabc14c 100644 --- a/packages/shader-transitions/src/engineModePageComposite.test.ts +++ b/packages/shader-transitions/src/engineModePageComposite.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { CLIP_HIDDEN_ATTR } from "@hyperframes/core/runtime/clip-reveal"; import { installPageSideCompositor, isPageSideCompositingSupported, @@ -201,7 +202,12 @@ describe("page-side compositor scene copies", () => { } class FakeEl { - style: Record = {}; + style: Record = Object.defineProperty({}, "setProperty", { + value(this: Record, name: string, value: string) { + this[name] = value; + }, + }); + attributes = new Set(); children: FakeEl[] = []; parentElement: FakeEl | null = null; animations: FakeAnimation[] = []; @@ -212,6 +218,15 @@ describe("page-side compositor scene copies", () => { getAttribute() { return null; } + hasAttribute(name: string) { + return this.attributes.has(name); + } + setAttribute(name: string) { + this.attributes.add(name); + } + removeAttribute(name: string) { + this.attributes.delete(name); + } get key(): string { return (this.copyOf ?? this).id.replace(/^scene-.*/, "scene"); } @@ -229,6 +244,7 @@ describe("page-side compositor scene copies", () => { } cloneNode(deep: boolean) { const copy = new FakeEl(`${this.id}-copy`, this); + copy.attributes = new Set(this.attributes); copy.animations = (CSS_ANIMATIONS[this.key] ?? []).map( (spec) => new FakeAnimation(spec, copy), ); @@ -240,12 +256,18 @@ describe("page-side compositor scene copies", () => { child.parentElement = this; return child; } - querySelectorAll() { - return []; + querySelectorAll(selector: string): FakeEl[] { + if (selector !== "[data-start]") return []; + return this.children.flatMap((c) => [ + ...(c.hasAttribute("data-start") ? [c] : []), + ...c.querySelectorAll(selector), + ]); } } - function installTransparentInsetFilm(opts: { failDraw?: boolean; underBody?: boolean } = {}) { + function installTransparentInsetFilm( + opts: { failDraw?: boolean; underBody?: boolean; markIncoming?: boolean } = {}, + ) { const calls: Array<{ canvas: number; op: string; args: unknown[] }> = []; const gl = new Proxy(fakeWebGl(), { get: (target, key) => { @@ -301,6 +323,16 @@ describe("page-side compositor scene copies", () => { ["scene-a", parent.appendChild(new FakeEl("scene-a"))], ["scene-b", parent.appendChild(new FakeEl("scene-b"))], ]); + if (opts.markIncoming) { + // The incoming scene before its own start, as the runtime's visibility pass leaves it. + const incoming = scenes.get("scene-b")!; + const title = incoming.appendChild(new FakeEl("title")); + for (const el of [incoming, title]) { + el.setAttribute("data-start"); + el.setAttribute(CLIP_HIDDEN_ATTR); + el.style.visibility = "hidden"; + } + } let startPolling: (() => void) | undefined; const hf = { seek: vi.fn() }; const win: Record = { @@ -335,7 +367,7 @@ describe("page-side compositor scene copies", () => { return (win.__hf_page_composite_resolve as () => boolean)(); }; const overlay = () => canvases.find((c) => c.id === PAGE_COMPOSITOR_CANVAS_ID)!; - return { calls, composite, overlay }; + return { calls, composite, overlay, scenes }; } it("stages each scene copy inside unaltered ancestor copies in a full-frame box", async () => { @@ -360,6 +392,20 @@ describe("page-side compositor scene copies", () => { } }); + it("lifts the runtime's hidden mark from a marked scene's copy and its timed clips", async () => { + const { calls, composite, scenes } = installTransparentInsetFilm({ markIncoming: true }); + expect(await composite(1.2)).toBe(true); + const frame = calls.filter((c) => c.op === "drawElementImage")[1]?.args[0] as FakeEl; + const scene = frame.children[0]!.children[0]!.children[0]!; + const title = scene.children[0]!; + expect([scene, title].map((el) => [el.copyOf?.id, el.hasAttribute(CLIP_HIDDEN_ATTR)])).toEqual([ + ["scene-b", false], + ["title", false], + ]); + expect([scene.style.visibility, title.style.visibility]).toEqual(["visible", "visible"]); + expect(scenes.get("scene-b")!.hasAttribute(CLIP_HIDDEN_ATTR)).toBe(true); + }); + it("holds copied CSS animations at the live time and cancels ones finished live", async () => { const { calls, composite } = installTransparentInsetFilm(); await composite(1.2); diff --git a/packages/shader-transitions/src/engineModePageComposite.ts b/packages/shader-transitions/src/engineModePageComposite.ts index 7c1ef2c6b77..e2acf105f81 100644 --- a/packages/shader-transitions/src/engineModePageComposite.ts +++ b/packages/shader-transitions/src/engineModePageComposite.ts @@ -28,6 +28,7 @@ * drawElementImage) without depending on requestAnimationFrame for paint. */ +import { revealClip } from "@hyperframes/core/runtime/clip-reveal"; import { createContext, setupQuad, @@ -295,10 +296,10 @@ export function installPageSideCompositor(options: PageCompositorInstallOptions) ] as const) { const clone = live.cloneNode(true) as HTMLElement; clone.style.opacity = "1"; - clone.style.visibility = "visible"; + revealClip(clone); clone.querySelectorAll("[data-start]").forEach((el) => { el.style.opacity = "1"; - el.style.visibility = "visible"; + revealClip(el); }); const { root, liveRoot, pairs } = stageWithAncestors(live, clone); // A full-frame box gives the copies a sized containing block to lay out against. diff --git a/packages/studio/src/components/editor/useLayerRevealOverride.ts b/packages/studio/src/components/editor/useLayerRevealOverride.ts index f25e55e97aa..bdd917e3ec2 100644 --- a/packages/studio/src/components/editor/useLayerRevealOverride.ts +++ b/packages/studio/src/components/editor/useLayerRevealOverride.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef } from "react"; -import { liftClipHiddenMark } from "@hyperframes/core/runtime/clip-reveal"; +import { revealClip } from "@hyperframes/core/runtime/clip-reveal"; import { LAYER_REVEAL_PRIOR_POSITION_ATTR, LAYER_REVEAL_PRIOR_Z_ATTR, @@ -199,9 +199,8 @@ function revealNode( applied.display = el.style.display; } if (needs.visibility) { - el.style.visibility = "visible"; + putMarkBack = revealClip(el); applied.visibility = "visible"; - putMarkBack = liftClipHiddenMark(el); } if (needs.opacity) { el.style.opacity = "1"; diff --git a/packages/studio/src/hooks/useGestureRecording.ts b/packages/studio/src/hooks/useGestureRecording.ts index 24e61d60e15..e3251ccd583 100644 --- a/packages/studio/src/hooks/useGestureRecording.ts +++ b/packages/studio/src/hooks/useGestureRecording.ts @@ -1,4 +1,4 @@ -import { liftClipHiddenMark } from "@hyperframes/core/runtime/clip-reveal"; +import { revealClip } from "@hyperframes/core/runtime/clip-reveal"; import { useCallback, useEffect, useRef, useState } from "react"; import { usePlayerStore, liveTime } from "../player/store/playerStore"; import { frameToSeconds, secondsToFrame } from "../player/lib/time"; @@ -127,8 +127,8 @@ function applyRuntimePreview( runtime.timeline.seek(seekTime); runtime.element.style.setProperty("translate", "none"); runtime.gsap.set(runtime.element, { ...properties }); - runtime.element.style.visibility = "visible"; - runtime.putMarkBack ??= liftClipHiddenMark(runtime.element); + const putMarkBack = revealClip(runtime.element); + runtime.putMarkBack ??= putMarkBack; liveTime.notify(seekTime); usePlayerStore.getState().setCurrentTime(seekTime); } From b137ae0f676e9323eb65c48a4f043aa824932864 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 20 Oct 2026 17:42:02 -0400 Subject: [PATCH 4/8] fix(core): films on an older shader-transitions build keep their transition scenes Published shader builds reveal scenes by inline style without lifting the hidden mark. When one is on the page, the runtime keeps the inline-style rules; builds that lift the mark declare it on load and keep the fast rules. --- packages/core/src/runtime/clipReveal.ts | 18 +++++++ packages/core/src/runtime/entry.ts | 3 +- .../core/src/runtime/timedClipHide.test.ts | 49 +++++++++++++++++++ packages/core/src/runtime/timedClipHide.ts | 34 ++++++++----- packages/shader-transitions/src/index.test.ts | 8 +++ packages/shader-transitions/src/index.ts | 4 ++ 6 files changed, 104 insertions(+), 12 deletions(-) create mode 100644 packages/shader-transitions/src/index.test.ts diff --git a/packages/core/src/runtime/clipReveal.ts b/packages/core/src/runtime/clipReveal.ts index 654b6434b1f..75f1bb50ce6 100644 --- a/packages/core/src/runtime/clipReveal.ts +++ b/packages/core/src/runtime/clipReveal.ts @@ -1,6 +1,12 @@ /** The visibility pass's mark on a timed clip it hides; the runtime's rules hide the clip's contents by it. */ export const CLIP_HIDDEN_ATTR = "data-hf-clip-hidden"; +type ShaderWindow = Window & { + HyperShader?: unknown; + __hf?: { transitions?: unknown }; + __hfClipRevealAware?: boolean; +}; + /** Shows a clip the visibility pass hid, contents included; returns a function that puts the mark back. */ export function revealClip(el: HTMLElement, priority = ""): () => void { el.style.setProperty("visibility", "visible", priority); @@ -10,3 +16,15 @@ export function revealClip(el: HTMLElement, priority = ""): () => void { el.setAttribute(CLIP_HIDDEN_ATTR, ""); }; } + +/** Called by shader-transitions builds on load: their revealers go through revealClip. */ +export function declareClipRevealAware(): void { + if (typeof window !== "undefined") (window as ShaderWindow).__hfClipRevealAware = true; +} + +/** A shader-transitions build is on the page whose revealers predate revealClip. */ +export function olderShaderBuildOnPage(): boolean { + const win = window as ShaderWindow; + const shaderOnPage = win.HyperShader !== undefined || win.__hf?.transitions !== undefined; + return shaderOnPage && win.__hfClipRevealAware !== true; +} diff --git a/packages/core/src/runtime/entry.ts b/packages/core/src/runtime/entry.ts index 6f34c13baf0..67a34d0cf06 100644 --- a/packages/core/src/runtime/entry.ts +++ b/packages/core/src/runtime/entry.ts @@ -7,7 +7,7 @@ import { } from "./init"; import { installAuthoredOpacityCapture } from "./colorGrading"; import { deferMediaUntilDue } from "./preloadMedia"; -import { hideTimedClipsUntilFirstPass } from "./timedClipHide"; +import { hideTimedClipsUntilFirstPass, keepInlineHideRulesForOlderShaders } from "./timedClipHide"; import { fitTextFontSize } from "../text/fitTextFontSize"; import { pretext } from "../text/pretext"; import { assetUrl } from "./assetUrl"; @@ -69,6 +69,7 @@ function bootstrapHyperframeRuntime(): void { return; } win.__hyperframeRuntimeBootstrapped = true; + keepInlineHideRulesForOlderShaders(); initSandboxRuntimeModular(); } diff --git a/packages/core/src/runtime/timedClipHide.test.ts b/packages/core/src/runtime/timedClipHide.test.ts index c1f4c29e151..188dd38ab65 100644 --- a/packages/core/src/runtime/timedClipHide.test.ts +++ b/packages/core/src/runtime/timedClipHide.test.ts @@ -4,6 +4,7 @@ import { revealClip } from "./clipReveal"; import { CLIP_HIDDEN_ATTR, hideTimedClipsUntilFirstPass, + keepInlineHideRulesForOlderShaders, revealTimedClipsAfterFirstPass, showHiddenClips, } from "./timedClipHide"; @@ -13,7 +14,13 @@ const injectedSelectors = () => Array.from(style.sheet!.cssRules).map((rule) => (rule as CSSStyleRule).selectorText), ); +type ShaderWindow = Window & { HyperShader?: unknown; __hf?: object; __hfClipRevealAware?: boolean }; + afterEach(() => { + const win = window as ShaderWindow; + delete win.HyperShader; + delete win.__hf; + delete win.__hfClipRevealAware; revealTimedClipsAfterFirstPass(); document.head.innerHTML = ""; document.body.innerHTML = ""; @@ -44,6 +51,48 @@ describe("hideTimedClipsUntilFirstPass", () => { }); }); +describe("keepInlineHideRulesForOlderShaders", () => { + // How a shader build that predates revealClip leaves a scene it reveals for capture. + const revealedByInlineStyleOnly = `

Title

`; + const previewWith = (win: Partial) => { + Object.assign(window, win); + document.head.append( + Object.assign(document.createElement("meta"), { name: STUDIO_PREVIEW_MARK_META }), + ); + hideTimedClipsUntilFirstPass(); + keepInlineHideRulesForOlderShaders(); + revealTimedClipsAfterFirstPass(); + document.body.innerHTML = `${revealedByInlineStyleOnly}

Later

`; + const [revealed, hidden] = Array.from(document.querySelectorAll("p")); + return { + revealed: getComputedStyle(revealed!).visibility, + revealedImage: getComputedStyle(document.querySelector("img")!).display, + hidden: getComputedStyle(hidden!).visibility, + }; + }; + + it("shows a scene an older shader build revealed, found by its global or its transitions", () => { + for (const older of [{ HyperShader: {} }, { __hf: { transitions: [] } }]) { + expect(previewWith(older)).toEqual({ + revealed: "visible", + revealedImage: "inline", + hidden: "hidden", + }); + delete (window as ShaderWindow).HyperShader; + document.head.innerHTML = ""; + } + }); + + it("keeps the mark-only rules for a shader build that declares it lifts the mark", () => { + expect(previewWith({ HyperShader: {}, __hfClipRevealAware: true })).toEqual({ + revealed: "hidden", + revealedImage: "none", + hidden: "hidden", + }); + expect(injectedSelectors().filter((selector) => /\[style\b/.test(selector))).toEqual([]); + }); +}); + describe("showHiddenClips", () => { it("lifts the mark while the clips show and puts it back with their inline hide", () => { hideTimedClipsUntilFirstPass(); diff --git a/packages/core/src/runtime/timedClipHide.ts b/packages/core/src/runtime/timedClipHide.ts index f685a882614..55ff9d82209 100644 --- a/packages/core/src/runtime/timedClipHide.ts +++ b/packages/core/src/runtime/timedClipHide.ts @@ -1,4 +1,4 @@ -import { CLIP_HIDDEN_ATTR, revealClip } from "./clipReveal"; +import { CLIP_HIDDEN_ATTR, olderShaderBuildOnPage, revealClip } from "./clipReveal"; import { RUNTIME_STAMPED_ATTR, STUDIO_PREVIEW_MARK_META, @@ -14,14 +14,20 @@ const HIDE_UNTIL_FIRST_PASS = const PREVIEW_HIDE_UNTIL_FIRST_PASS = 'img[loading="lazy"] { display: none !important; }'; const SKIP_ATTR = "data-hf-skip-hidden-images"; export { CLIP_HIDDEN_ATTR }; -export const SKIPPED_CLIP = `[data-start]:not(video, audio, img, [${STUDIO_PREVIEW_UPCOMING_ATTR}], [${RUNTIME_STAMPED_ATTR}])[${CLIP_HIDDEN_ATTR}]`; -const SKIP_HIDDEN_IMAGES = `${SKIPPED_CLIP} img { display: none !important; }`; +const SKIPPABLE_CLIP = `[data-start]:not(video, audio, img, [${STUDIO_PREVIEW_UPCOMING_ATTR}], [${RUNTIME_STAMPED_ATTR}])`; +export const SKIPPED_CLIP = `${SKIPPABLE_CLIP}[${CLIP_HIDDEN_ATTR}]`; const STUDIO_PREVIEW_MARK = `meta[name="${STUDIO_PREVIEW_MARK_META}"]`; const CONTENTS_ATTR = "data-hf-hidden-clip-contents"; // Doubled to keep the weight the rule had when it also matched the inline hide. const HIDDEN_CLIP = `[${CLIP_HIDDEN_ATTR}][${CLIP_HIDDEN_ATTR}]`; -const HIDE_HIDDEN_CLIP_CONTENTS = `${HIDDEN_CLIP} * { visibility: hidden !important; }`; -const HIDE_SVG_GENERATED_FILTER_OUTPUT = `svg${HIDDEN_CLIP}, svg${HIDDEN_CLIP} *, ${HIDDEN_CLIP} svg, ${HIDDEN_CLIP} svg * { filter: none !important; }`; +// Compatibility path for shader-transitions builds that reveal clips by inline style alone (see +// keepInlineHideRulesForOlderShaders); the [style] selector is what costs WebKit a re-serialization per write. +const HIDDEN_CLIP_BY_INLINE_STYLE = `[${CLIP_HIDDEN_ATTR}][style*="visibility: hidden"]`; +const skipHiddenImages = (clip: string) => + `${SKIPPABLE_CLIP}${clip} img { display: none !important; }`; +const hideHiddenClipContents = (clip: string) => + `${clip} * { visibility: hidden !important; } ` + + `svg${clip}, svg${clip} *, ${clip} svg, ${clip} svg * { filter: none !important; }`; export function showHiddenClips(): () => void { const hidden = Array.from(document.querySelectorAll(`[${CLIP_HIDDEN_ATTR}]`)); @@ -54,13 +60,10 @@ export function hideTimedClipsUntilFirstPass(): void { const parent = document.head ?? document.documentElement; if (!parent) return; const preview = document.querySelector(STUDIO_PREVIEW_MARK) !== null; - if (preview && !skipsHiddenImages()) appendStyle(parent, SKIP_ATTR, SKIP_HIDDEN_IMAGES); + if (preview && !skipsHiddenImages()) + appendStyle(parent, SKIP_ATTR, skipHiddenImages(HIDDEN_CLIP)); if (!document.querySelector(`style[${CONTENTS_ATTR}]`)) - appendStyle( - parent, - CONTENTS_ATTR, - `${HIDE_HIDDEN_CLIP_CONTENTS} ${HIDE_SVG_GENERATED_FILTER_OUTPUT}`, - ); + appendStyle(parent, CONTENTS_ATTR, hideHiddenClipContents(HIDDEN_CLIP)); const win = window as FirstPassWindow; // A runtime that already initialised may never run another pass to lift a new rule. if (win.__hfFirstPassHidden || win.__hyperframeRuntimeBootstrapped) return; @@ -79,3 +82,12 @@ export function revealTimedClipsAfterFirstPass(): boolean { for (const style of document.querySelectorAll(`style[${HIDE_ATTR}]`)) style.remove(); return true; } + +/** Run once composition scripts have loaded. Removable once published builds older than revealClip are retired. */ +export function keepInlineHideRulesForOlderShaders(): void { + if (!olderShaderBuildOnPage()) return; + const skip = document.querySelector(`style[${SKIP_ATTR}]`); + if (skip) skip.textContent = skipHiddenImages(HIDDEN_CLIP_BY_INLINE_STYLE); + const contents = document.querySelector(`style[${CONTENTS_ATTR}]`); + if (contents) contents.textContent = hideHiddenClipContents(HIDDEN_CLIP_BY_INLINE_STYLE); +} diff --git a/packages/shader-transitions/src/index.test.ts b/packages/shader-transitions/src/index.test.ts new file mode 100644 index 00000000000..6173f9cca6e --- /dev/null +++ b/packages/shader-transitions/src/index.test.ts @@ -0,0 +1,8 @@ +// @vitest-environment jsdom +import { expect, it } from "vitest"; +import { olderShaderBuildOnPage } from "@hyperframes/core/runtime/clip-reveal"; + +it("declares on load that its revealers lift the runtime's hidden mark", async () => { + (window as Window & { HyperShader?: unknown }).HyperShader = await import("./index.js"); + expect(olderShaderBuildOnPage()).toBe(false); +}); diff --git a/packages/shader-transitions/src/index.ts b/packages/shader-transitions/src/index.ts index 6d0cc7d18e7..539016f5aaa 100644 --- a/packages/shader-transitions/src/index.ts +++ b/packages/shader-transitions/src/index.ts @@ -1,3 +1,7 @@ +import { declareClipRevealAware } from "@hyperframes/core/runtime/clip-reveal"; + +declareClipRevealAware(); + export { init, type HyperShaderConfig, type TransitionConfig } from "./hyper-shader.js"; export { isHtmlInCanvasCaptureSupported } from "./capture.js"; export { SHADER_NAMES, type ShaderName } from "./shaders/registry.js"; From 6284f1049464d584825269241b1a50f73e4ba334 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 20 Oct 2026 18:21:34 -0400 Subject: [PATCH 5/8] test(core): pin the hidden-clip contents rule's weight on its selector --- packages/core/src/runtime/timedClipHide.test.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/core/src/runtime/timedClipHide.test.ts b/packages/core/src/runtime/timedClipHide.test.ts index 188dd38ab65..1846ad6b26e 100644 --- a/packages/core/src/runtime/timedClipHide.test.ts +++ b/packages/core/src/runtime/timedClipHide.test.ts @@ -14,7 +14,11 @@ const injectedSelectors = () => Array.from(style.sheet!.cssRules).map((rule) => (rule as CSSStyleRule).selectorText), ); -type ShaderWindow = Window & { HyperShader?: unknown; __hf?: object; __hfClipRevealAware?: boolean }; +type ShaderWindow = Window & { + HyperShader?: unknown; + __hf?: object; + __hfClipRevealAware?: boolean; +}; afterEach(() => { const win = window as ShaderWindow; @@ -43,11 +47,10 @@ describe("hideTimedClipsUntilFirstPass", () => { expect(getComputedStyle(document.querySelector("p")!).visibility).toBe("hidden"); }); - it("keeps a hidden clip's contents hidden over an author's important visibility rule", () => { - document.head.innerHTML = ""; + // jsdom resolves !important ties by order, not weight, so the weight is asserted on the selector. + it("keeps the contents rule at the weight of two attribute selectors", () => { hideTimedClipsUntilFirstPass(); - document.body.innerHTML = `

Later

`; - expect(getComputedStyle(document.querySelector("p")!).visibility).toBe("hidden"); + expect(injectedSelectors()).toContain(`[${CLIP_HIDDEN_ATTR}][${CLIP_HIDDEN_ATTR}] *`); }); }); From 4ad801a2a926efa22a80522a2f89d329a9cb73cb Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 20 Oct 2026 18:24:38 -0400 Subject: [PATCH 6/8] style(core): format the reveal test and move hidden-clip rule notes into names --- packages/core/src/runtime/entry.test.ts | 10 +++++----- packages/core/src/runtime/timedClipHide.ts | 10 +++------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/packages/core/src/runtime/entry.test.ts b/packages/core/src/runtime/entry.test.ts index a7f15817966..1afba545cf0 100644 --- a/packages/core/src/runtime/entry.test.ts +++ b/packages/core/src/runtime/entry.test.ts @@ -375,11 +375,11 @@ describe("runtime entry", () => { const loads: HTMLMediaElement[] = []; const spyLoad = () => { loads.length = 0; - vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(function ( - this: HTMLMediaElement, - ) { - loads.push(this); - }); + vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation( + function (this: HTMLMediaElement) { + loads.push(this); + }, + ); }; const loadsOf = (...els: HTMLMediaElement[]) => els.map((el) => loads.filter((loaded) => loaded === el).length); diff --git a/packages/core/src/runtime/timedClipHide.ts b/packages/core/src/runtime/timedClipHide.ts index 55ff9d82209..a7f56adade9 100644 --- a/packages/core/src/runtime/timedClipHide.ts +++ b/packages/core/src/runtime/timedClipHide.ts @@ -18,10 +18,7 @@ const SKIPPABLE_CLIP = `[data-start]:not(video, audio, img, [${STUDIO_PREVIEW_UP export const SKIPPED_CLIP = `${SKIPPABLE_CLIP}[${CLIP_HIDDEN_ATTR}]`; const STUDIO_PREVIEW_MARK = `meta[name="${STUDIO_PREVIEW_MARK_META}"]`; const CONTENTS_ATTR = "data-hf-hidden-clip-contents"; -// Doubled to keep the weight the rule had when it also matched the inline hide. -const HIDDEN_CLIP = `[${CLIP_HIDDEN_ATTR}][${CLIP_HIDDEN_ATTR}]`; -// Compatibility path for shader-transitions builds that reveal clips by inline style alone (see -// keepInlineHideRulesForOlderShaders); the [style] selector is what costs WebKit a re-serialization per write. +const HIDDEN_CLIP_DOUBLED_FOR_WEIGHT = `[${CLIP_HIDDEN_ATTR}][${CLIP_HIDDEN_ATTR}]`; const HIDDEN_CLIP_BY_INLINE_STYLE = `[${CLIP_HIDDEN_ATTR}][style*="visibility: hidden"]`; const skipHiddenImages = (clip: string) => `${SKIPPABLE_CLIP}${clip} img { display: none !important; }`; @@ -61,9 +58,9 @@ export function hideTimedClipsUntilFirstPass(): void { if (!parent) return; const preview = document.querySelector(STUDIO_PREVIEW_MARK) !== null; if (preview && !skipsHiddenImages()) - appendStyle(parent, SKIP_ATTR, skipHiddenImages(HIDDEN_CLIP)); + appendStyle(parent, SKIP_ATTR, skipHiddenImages(HIDDEN_CLIP_DOUBLED_FOR_WEIGHT)); if (!document.querySelector(`style[${CONTENTS_ATTR}]`)) - appendStyle(parent, CONTENTS_ATTR, hideHiddenClipContents(HIDDEN_CLIP)); + appendStyle(parent, CONTENTS_ATTR, hideHiddenClipContents(HIDDEN_CLIP_DOUBLED_FOR_WEIGHT)); const win = window as FirstPassWindow; // A runtime that already initialised may never run another pass to lift a new rule. if (win.__hfFirstPassHidden || win.__hyperframeRuntimeBootstrapped) return; @@ -83,7 +80,6 @@ export function revealTimedClipsAfterFirstPass(): boolean { return true; } -/** Run once composition scripts have loaded. Removable once published builds older than revealClip are retired. */ export function keepInlineHideRulesForOlderShaders(): void { if (!olderShaderBuildOnPage()) return; const skip = document.querySelector(`style[${SKIP_ATTR}]`); From cc3d1d6136286dba278816de1774de58ed7c5ba2 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 23 Oct 2026 23:30:33 -0400 Subject: [PATCH 7/8] test(producer): pin hidden-clip behaviours in real Chrome under the mark-keyed rules --- .../src/services/coreRuntimeBrowser.test.ts | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) diff --git a/packages/producer/src/services/coreRuntimeBrowser.test.ts b/packages/producer/src/services/coreRuntimeBrowser.test.ts index b0c1f77d47c..2d2200616c3 100644 --- a/packages/producer/src/services/coreRuntimeBrowser.test.ts +++ b/packages/producer/src/services/coreRuntimeBrowser.test.ts @@ -400,6 +400,189 @@ describe("core runtime browser contract", () => { 30_000, ); + const openFilm = async (body: string, head = "", script = "") => { + const filmPage = await browser.newPage(); + await filmPage.setViewport({ width: 320, height: 180 }); + await filmPage.setContent(`${head} + +
${body}
`); + if (script) { + await filmPage.addScriptTag({ + path: resolve(import.meta.dirname, "../../../core/node_modules/gsap/dist/gsap.min.js"), + }); + await filmPage.addScriptTag({ content: script }); + } + await filmPage.addScriptTag({ content: readFileSync(RUNTIME_PATH, "utf8") }); + await filmPage.waitForFunction(() => window.__playerReady && window.__renderReady); + return filmPage; + }; + const PREVIEW_HEAD = ``; + const redPixels = (filmPage: Page) => + filmPage.screenshot({ encoding: "base64" }).then((shot) => + filmPage.evaluate(async (png) => { + const image = new Image(); + image.src = `data:image/png;base64,${png}`; + await image.decode(); + const context = new OffscreenCanvas(image.width, image.height).getContext("2d")!; + context.drawImage(image, 0, 0); + const { data } = context.getImageData(0, 0, image.width, image.height); + let red = 0; + for (let i = 0; i < data.length; i += 4) if (data[i]! > 200) red++; + return red; + }, shot), + ); + + it.each([ + ["a render", ""], + ["a preview", PREVIEW_HEAD], + ])( + "restyles only the element an inline style write lands on in %s", + async (_, head) => { + const filmPage = await openFilm( + `
+
${"".repeat(400)}
`, + head, + ); + try { + await filmPage.evaluate(() => window.__player!.renderSeek!(1)); + await filmPage.tracing.start({ categories: ["devtools.timeline"] }); + await filmPage.evaluate(() => { + const scene = document.getElementById("scene")!; + for (let frame = 1; frame <= 5; frame++) { + scene.style.transform = `translateX(${frame}px)`; + getComputedStyle(scene).transform; + } + }); + const trace = JSON.parse(Buffer.from((await filmPage.tracing.stop())!).toString()) as { + traceEvents: { name: string; args?: { elementCount?: number } }[]; + }; + const restyled = trace.traceEvents + .filter((event) => event.name === "UpdateLayoutTree") + .map((event) => event.args?.elementCount ?? 0); + expect(restyled.length).toBeGreaterThanOrEqual(5); + expect(Math.max(...restyled)).toBeLessThan(10); + } finally { + await filmPage.close(); + } + }, + 30_000, + ); + + it("paints nothing of a hidden scene, even a child or SVG shape set visible inline", async () => { + const filmPage = await openFilm( + `
+
+
+ +
+
`, + ); + try { + const redPixelsAt = async (time: number) => { + await filmPage.evaluate((t) => { + window.__player!.renderSeek!(t); + // What a rewound GSAP set writes on a scene's contents. + for (const id of ["child", "shape"]) + document.getElementById(id)!.style.visibility = "visible"; + }, time); + return redPixels(filmPage); + }; + expect(await redPixelsAt(1.5)).toBe(0); + expect(await redPixelsAt(2.5)).toBe(320 * 180); + } finally { + await filmPage.close(); + } + }, 30_000); + + it.each([ + ["an older shader build", 320 * 180, "window.HyperShader = {};"], + ["a current shader build", 0, "window.HyperShader = {}; window.__hfClipRevealAware = true;"], + ])( + "with %s on the page, a hidden scene's copy forced visible inline paints %i red pixels", + async (_, red, shaderBuild) => { + const filmPage = await openFilm( + `
+
+
`, + "", + shaderBuild, + ); + try { + await filmPage.evaluate(() => { + window.__player!.renderSeek!(1); + // How shader-transitions builds before revealClip show a scene's copy. + const copy = document.getElementById("scene")!.cloneNode(true) as HTMLElement; + copy.style.setProperty("visibility", "visible", "important"); + document.getElementById("scene")!.after(copy); + }); + expect(await redPixels(filmPage)).toBe(red); + } finally { + await filmPage.close(); + } + }, + 30_000, + ); + + it("leaves a clip's contents to an engine layer mask that hides its host", async () => { + const filmPage = await openFilm( + `
`, + ); + try { + const logo = await filmPage.evaluate(() => { + window.__player!.renderSeek!(1); + document.getElementById("host")!.style.setProperty("visibility", "hidden", "important"); + return getComputedStyle(document.getElementById("logo")!).visibility; + }); + expect(logo).toBe("visible"); + } finally { + await filmPage.close(); + } + }, 30_000); + + it("skips a preview's images in hidden scenes not due soon, until the scene shows", async () => { + const filmPage = await openFilm( + `
+
`, + PREVIEW_HEAD, + ); + try { + const skipped = await filmPage.evaluate(() => { + const skip = (id: string) => + getComputedStyle(document.querySelector(`#${id} img`)!).display === "none"; + window.__player!.seek(0); + const before = [skip("soon"), skip("later")]; + window.__player!.seek(2.95); + return [...before, skip("later")]; + }); + expect(skipped).toEqual([false, true, false]); + } finally { + await filmPage.close(); + } + }, 30_000); + + it.each(["seek", "renderSeek"] as const)( + "shows a hidden scene's child again after a %s skipped past the GSAP set that hides it", + async (seek) => { + const filmPage = await openFilm( + `
`, + "", + `window.__timelines = {root: gsap.timeline({paused: true}).set("#words", {visibility: "hidden"}, 1.9)};`, + ); + try { + const words = await filmPage.evaluate((method) => { + window.__player![method]!(2.5); + window.__player![method]!(1.5); + return getComputedStyle(document.getElementById("words")!).visibility; + }, seek); + expect(words).toBe("visible"); + } finally { + await filmPage.close(); + } + }, + 30_000, + ); + it("ends a later scene's from-opacity fade at its authored opacity when frames step into it", async () => { const fadePage = await browser.newPage(); try { From 4ad552824b93a1b4f38257fdd523009e4467a70b Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 23 Oct 2026 23:54:06 -0400 Subject: [PATCH 8/8] test(producer): read the forced restyle's result so lint accepts the restyle test --- packages/producer/src/services/coreRuntimeBrowser.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/producer/src/services/coreRuntimeBrowser.test.ts b/packages/producer/src/services/coreRuntimeBrowser.test.ts index 2d2200616c3..0d785ab15dd 100644 --- a/packages/producer/src/services/coreRuntimeBrowser.test.ts +++ b/packages/producer/src/services/coreRuntimeBrowser.test.ts @@ -447,12 +447,14 @@ describe("core runtime browser contract", () => { try { await filmPage.evaluate(() => window.__player!.renderSeek!(1)); await filmPage.tracing.start({ categories: ["devtools.timeline"] }); - await filmPage.evaluate(() => { + const transforms = await filmPage.evaluate(() => { const scene = document.getElementById("scene")!; + const read: string[] = []; for (let frame = 1; frame <= 5; frame++) { scene.style.transform = `translateX(${frame}px)`; - getComputedStyle(scene).transform; + read.push(getComputedStyle(scene).transform); } + return read; }); const trace = JSON.parse(Buffer.from((await filmPage.tracing.stop())!).toString()) as { traceEvents: { name: string; args?: { elementCount?: number } }[]; @@ -460,6 +462,7 @@ describe("core runtime browser contract", () => { const restyled = trace.traceEvents .filter((event) => event.name === "UpdateLayoutTree") .map((event) => event.args?.elementCount ?? 0); + expect(transforms.at(-1)).toBe("matrix(1, 0, 0, 1, 5, 0)"); expect(restyled.length).toBeGreaterThanOrEqual(5); expect(Math.max(...restyled)).toBeLessThan(10); } finally {