fix(core): an element its script hides stays hidden while studio preview plays (#5458)

* fix(core): an element its script hides stays hidden while studio preview plays

* fix(core): a stamped element re-timed in the timeline hides outside its new window

Studio timing edits drop the runtime-stamped mark, and a script-hidden stamped
container no longer holds back its images.

* fix(core): a preview-only stamp copied into a project no longer changes the render

Only the Studio preview honours the stamp, copied live markup drops it, and a
lane-only move makes a stamped element authored like the save does.
This commit is contained in:
Miguel Ángel
2026-10-11 16:45:42 +00:00
committed by GitHub
parent b211fc7cb8
commit 2fbaef6abb
7 changed files with 213 additions and 9 deletions
+154
View File
@@ -2,6 +2,12 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { readFileSync } from "node:fs";
import { initSandboxRuntimeModular, installFlatGsapTransforms } from "./init";
import {
hideTimedClipsUntilFirstPass,
revealTimedClipsAfterFirstPass,
SKIPPED_CLIP,
} from "./timedClipHide";
import { applyAuthoredTiming, STUDIO_PREVIEW_MARK_META } from "../studioPreviewMark";
import { collectRuntimeTimelinePayload } from "./timeline";
import { TYPEGPU_PRESENT_HEARTBEAT_MS } from "./adapters/typegpu";
import { WebAudioTransport } from "./webAudioTransport";
@@ -360,6 +366,154 @@ describe("initSandboxRuntimeModular", () => {
expect(caption.getAttribute("data-start")).toBe("0");
});
describe("visibility of runtime-stamped elements in Studio preview", () => {
afterEach(() => vi.restoreAllMocks());
// A 10 s film: a video clip, an untimed full-frame backdrop the composition's per-frame script
// shows only from 6 s to 8 s, and an untimed badge it never touches. Driven by real GSAP.
const mountFilm = () => {
const raf = createManualRaf();
vi.spyOn(performance, "now").mockImplementation(() => raf.now());
window.requestAnimationFrame =
raf.requestAnimationFrame as typeof window.requestAnimationFrame;
window.cancelAnimationFrame = raf.cancelAnimationFrame as typeof window.cancelAnimationFrame;
document.body.innerHTML =
`<div id="root" data-composition-id="main" data-root="true" data-start="0" data-duration="10" data-width="1080" data-height="1920">` +
`<video id="aroll" class="clip" data-start="0" data-duration="10" muted></video>` +
`<div id="plate" style="visibility: hidden"></div>` +
`<div id="badge"></div>` +
`</div>`;
const video = document.querySelector("video")!;
video.play = vi.fn(() => Promise.resolve());
video.pause = vi.fn();
video.load = vi.fn();
const plate = document.getElementById("plate")!;
const tl = gsap.timeline({ paused: true });
const frame = { t: 0 };
tl.to(frame, {
t: 10,
duration: 10,
ease: "none",
modifiers: {
t: (value: number) => {
plate.style.visibility = value >= 6 && value < 8 ? "visible" : "hidden";
return value;
},
},
});
window.__timelines = { main: tl as unknown as RuntimeTimelineLike };
withStudioIframe(() => initSandboxRuntimeModular());
return { raf, plate, badge: document.getElementById("badge")! };
};
it("keeps a stamped backdrop its script hides hidden on every playing tick", () => {
const { raf, plate } = mountFilm();
expect(plate.getAttribute("data-hf-autostamped")).toBe("1");
window.__player?.seek(1);
window.__player?.play();
const seen = new Set<string>();
for (let frame = 0; frame < 120; frame++) {
raf.step(16);
seen.add(plate.style.visibility);
}
expect(window.__player?.getTime()).toBeGreaterThan(2.5);
expect(window.__player?.getTime()).toBeLessThan(6);
expect([...seen]).toEqual(["hidden"]);
});
it("still shows a stamped element its author never hides", () => {
const { raf, badge } = mountFilm();
expect(badge.getAttribute("data-hf-autostamped")).toBe("1");
window.__player?.seek(1);
window.__player?.play();
for (let frame = 0; frame < 30; frame++) raf.step(16);
expect(badge.style.visibility).not.toBe("hidden");
expect(getComputedStyle(badge).visibility).toBe("visible");
});
it("hides an authored clip inside a hidden authored clip under a stamped wrapper", () => {
document.body.innerHTML =
`<div data-composition-id="main" data-root="true" data-start="0" data-duration="10" data-width="1080" data-height="1920">` +
`<div id="wall"><div id="scene" data-start="0" data-duration="2">` +
`<div id="card" data-start="0" data-duration="10"></div></div></div>` +
`</div>`;
window.__timelines = { main: createMockTimeline(10) };
withStudioIframe(() => initSandboxRuntimeModular());
const wall = document.getElementById("wall")!;
const scene = document.getElementById("scene")!;
const card = document.getElementById("card")!;
expect(wall.getAttribute("data-hf-autostamped")).toBe("1");
window.__player?.renderSeek(1);
expect([scene.style.visibility, card.style.visibility]).toEqual(["visible", "visible"]);
window.__player?.renderSeek(5);
expect([scene.style.visibility, card.style.visibility]).toEqual(["hidden", "hidden"]);
});
it("hides a stamped element Studio re-times outside its new window", () => {
document.body.innerHTML =
`<div data-composition-id="main" data-root="true" data-start="0" data-duration="10" data-width="1080" data-height="1920">` +
`<div id="title" style="position: absolute"></div>` +
`</div>`;
window.__timelines = { main: createMockTimeline(10) };
withStudioIframe(() => initSandboxRuntimeModular());
const title = document.getElementById("title")!;
expect(title.getAttribute("data-hf-autostamped")).toBe("1");
applyAuthoredTiming(title, [
["data-start", "4"],
["data-duration", "2"],
]);
(window as Window & { __hfForceTimelineRebind?: () => void }).__hfForceTimelineRebind?.();
const at = (time: number) => {
window.__player?.renderSeek(time);
return title.style.visibility;
};
expect([at(1), at(5), at(8)]).toEqual(["hidden", "visible", "hidden"]);
});
it("times a clip by its window in a render even when its source carries the stamp", () => {
document.body.innerHTML =
`<div data-composition-id="main" data-root="true" data-start="0" data-duration="10" data-width="1080" data-height="1920">` +
`<div id="title" style="position: absolute" data-start="5" data-duration="2" data-hf-autostamped="1"></div>` +
`</div>`;
window.__timelines = { main: createMockTimeline(10) };
initSandboxRuntimeModular();
const title = document.getElementById("title")!;
const at = (time: number) => {
window.__player?.renderSeek(time);
return title.style.visibility;
};
expect([at(1), at(6), at(9)]).toEqual(["hidden", "visible", "hidden"]);
});
it("does not hold a paused seek on images in a stamped container its script hides", () => {
const mark = Object.assign(document.createElement("meta"), {
name: STUDIO_PREVIEW_MARK_META,
});
document.head.append(mark);
try {
hideTimedClipsUntilFirstPass();
const { plate } = mountFilm();
plate.append(Object.assign(document.createElement("img"), { src: "backdrop.png" }));
expect(plate.matches(SKIPPED_CLIP)).toBe(false);
expect(window.__player?.seek(1)).toBeUndefined();
} finally {
mark.remove();
revealTimedClipsAfterFirstPass();
document.head.querySelector("style[data-hf-skip-hidden-images]")?.remove();
}
});
});
/**
* GH#4001: a root edited to portrait dims whose scaffolded `html, body` CSS
* is left at the old landscape size renders successfully with everything
+11 -6
View File
@@ -9,6 +9,7 @@ import { isInClipWindow } from "./clipWindow";
import { revealTimedClipsAfterFirstPass, SKIPPED_CLIP, skipsHiddenImages } from "./timedClipHide";
import {
PREVIEW_ONLY_ATTRS,
RUNTIME_STAMPED_ATTR,
STUDIO_PREVIEW_LAZY_ATTR,
STUDIO_PREVIEW_UPCOMING_ATTR,
} from "../studioPreviewMark";
@@ -409,6 +410,7 @@ function loadPreviewImagesIn(root: Element): void {
export function initSandboxRuntimeModular(): void {
const state = createRuntimeState();
const stampsForStudio = window.parent !== window;
authoredMediaObserver?.disconnect();
authoredMediaObserver = null;
if (readSceneParts(document)) recordAuthoredMedia(document.documentElement);
@@ -834,7 +836,7 @@ export function initSandboxRuntimeModular(): void {
// `justify-content: space-between` clusters in the top-left. Leave them in
// flow so the preview matches the rendered video, which never stamps
// (production renders run as the top-level page, not in an iframe).
return !el.hasAttribute("data-hf-autostamped");
return !el.hasAttribute(RUNTIME_STAMPED_ATTR);
});
const displayNoneLiftedToMeasureShown = clips
.filter((el) => el.style.getPropertyValue("display") === "none")
@@ -2012,7 +2014,7 @@ export function initSandboxRuntimeModular(): void {
// them so the Studio timeline can discover individual animated elements.
// Only when embedded in an iframe (Studio preview) — production renders
// run as the top-level page and must not mutate element timing.
if (window.parent !== window) {
if (stampsForStudio) {
const rootComp = resolveRootCompositionElement();
const rootDuration = boundDuration > 0 ? boundDuration : 0;
const dur = String(rootDuration > 0 ? rootDuration : 1);
@@ -2058,7 +2060,7 @@ export function initSandboxRuntimeModular(): void {
target.setAttribute("data-duration", dur);
// Mark as runtime-stamped so applyClipLayout leaves it in document
// flow instead of treating it as an authored overlay clip.
target.setAttribute("data-hf-autostamped", "1");
target.setAttribute(RUNTIME_STAMPED_ATTR, "1");
}
}
} catch {
@@ -2083,7 +2085,7 @@ export function initSandboxRuntimeModular(): void {
el.setAttribute("data-duration", dur);
// Mark as runtime-stamped so applyClipLayout leaves it in document
// flow instead of treating it as an authored overlay clip.
el.setAttribute("data-hf-autostamped", "1");
el.setAttribute(RUNTIME_STAMPED_ATTR, "1");
}
}
}
@@ -2818,7 +2820,8 @@ export function initSandboxRuntimeModular(): void {
const isVisibleNow =
visibleAt(rawNode, currentTime) &&
clipChainVisibleAt(rawNode.parentElement, currentTime, visibleAt, rootComp);
rawNode.style.visibility = isVisibleNow ? "visible" : "hidden";
const isRuntimeStamped = stampsForStudio && rawNode.hasAttribute(RUNTIME_STAMPED_ATTR);
if (!isRuntimeStamped) rawNode.style.visibility = isVisibleNow ? "visible" : "hidden";
const upcoming =
hiddenImagesSkipped && !isVisibleNow && dueSoon(rawNode, visibleAt, currentTime);
if (upcoming && !rawNode.hasAttribute(STUDIO_PREVIEW_UPCOMING_ATTR))
@@ -2877,7 +2880,9 @@ export function initSandboxRuntimeModular(): void {
const visibleAt = timedVisibilityAt();
return Array.from(document.images).filter(
(img) =>
img.closest('[data-start][style*="visibility: hidden"]') !== null &&
img.closest(
`[data-start]:not([${RUNTIME_STAMPED_ATTR}])[style*="visibility: hidden"]`,
) !== null &&
(img.closest(SKIPPED_CLIP) !== null || !img.complete) &&
clipChainVisibleAt(img, time, visibleAt, rootComp),
);
+6 -2
View File
@@ -1,4 +1,8 @@
import { STUDIO_PREVIEW_MARK_META, STUDIO_PREVIEW_UPCOMING_ATTR } from "../studioPreviewMark";
import {
RUNTIME_STAMPED_ATTR,
STUDIO_PREVIEW_MARK_META,
STUDIO_PREVIEW_UPCOMING_ATTR,
} from "../studioPreviewMark";
// Hides timed non-media clips from script evaluation until the first visibility pass decides them;
// before it, a paused page painted every clip at once. Media is left to init's media pass.
@@ -8,7 +12,7 @@ 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}])[style*="visibility: hidden"]`;
export const SKIPPED_CLIP = `[data-start]:not(video, audio, img, [${STUDIO_PREVIEW_UPCOMING_ATTR}], [${RUNTIME_STAMPED_ATTR}])[style*="visibility: hidden"]`;
const SKIP_HIDDEN_IMAGES = `${SKIPPED_CLIP} img { display: none !important; }`;
const STUDIO_PREVIEW_MARK = `meta[name="${STUDIO_PREVIEW_MARK_META}"]`;
+10
View File
@@ -3,8 +3,18 @@ export const STUDIO_PREVIEW_UPCOMING_ATTR = "data-hf-upcoming";
export const STUDIO_PREVIEW_LAZY_ATTR = "data-hf-preview-lazy";
export const STUDIO_PREVIEW_ERRORS = "__hfPreviewErrors";
export const PREVIEW_RASTER_ATTR = "data-hf-preview-raster";
export const RUNTIME_STAMPED_ATTR = "data-hf-autostamped";
export const PREVIEW_ONLY_ATTRS = [
STUDIO_PREVIEW_UPCOMING_ATTR,
STUDIO_PREVIEW_LAZY_ATTR,
PREVIEW_RASTER_ATTR,
RUNTIME_STAMPED_ATTR,
] as const;
export function applyAuthoredTiming(
el: Element,
attrs: ReadonlyArray<readonly [name: string, value: string]>,
): void {
for (const [name, value] of attrs) el.setAttribute(name, value);
el.removeAttribute(RUNTIME_STAMPED_ATTR);
}
@@ -218,6 +218,26 @@ describe("patchIframeDomTiming", () => {
expect([...cards!].map((card) => card.dataset.start)).toEqual(["1", "9"]);
});
it("makes a runtime-stamped element authored on any timing edit, a lane move included", () => {
const iframe = makeIframeWith(`
<div data-composition-id="root">
<div id="title" data-start="0" data-duration="10" data-hf-autostamped="1"></div>
<div id="badge" data-start="0" data-duration="10" data-hf-autostamped="1"></div>
</div>
`);
const doc = iframe.contentDocument!;
const at = (id: string) => el({ id, tag: "div", domId: id, sourceFile: "index.html" });
patchIframeDomTiming(iframe, at("title"), [
["data-start", "4"],
["data-duration", "2"],
]);
patchIframeDomTiming(iframe, at("badge"), [["data-track-index", "3"]]);
expect(doc.getElementById("title")?.hasAttribute("data-hf-autostamped")).toBe(false);
expect(doc.getElementById("badge")?.hasAttribute("data-hf-autostamped")).toBe(false);
});
});
describe("extendRootDurationIfNeeded", () => {
@@ -1,4 +1,5 @@
import { isHtmlElement } from "@hyperframes/core/runtime/dom-realm";
import { applyAuthoredTiming } from "@hyperframes/core/studio-preview-mark";
import { type TimelineElement, usePlayerStore } from "../player/store/playerStore";
import { toAuthoredStart } from "../player/store/timelineElement";
import {
@@ -180,7 +181,7 @@ export function patchIframeDomTiming(
try {
const el = findTimelineElementInIframe(iframe, element, activeCompositionPath);
if (!el) return;
for (const [name, value] of attrs) el.setAttribute(name, value);
applyAuthoredTiming(el, attrs);
} catch {
// Cross-origin or mid-navigation — file save is enqueued; iframe patch is best-effort.
}
@@ -2,6 +2,7 @@
import { describe, expect, it } from "vitest";
import {
PREVIEW_RASTER_ATTR,
RUNTIME_STAMPED_ATTR,
STUDIO_PREVIEW_LAZY_ATTR,
} from "@hyperframes/core/studio-preview-mark";
import { liveMarkupWithoutPreviewMarks } from "./authoredSource";
@@ -22,4 +23,13 @@ describe("liveMarkupWithoutPreviewMarks", () => {
live.innerHTML = `<img src="a.png" loading="lazy" ${STUDIO_PREVIEW_LAZY_ATTR}>`;
expect(liveMarkupWithoutPreviewMarks(live)).toBe('<div><img src="a.png"></div>');
});
it("drops the runtime's stamp so copied markup cannot carry it into a project file", () => {
const live = document.createElement("div");
live.innerHTML = `<div id="title" data-start="0" data-duration="10" ${RUNTIME_STAMPED_ATTR}="1"></div>`;
live.setAttribute(RUNTIME_STAMPED_ATTR, "1");
expect(liveMarkupWithoutPreviewMarks(live)).toBe(
'<div><div id="title" data-start="0" data-duration="10"></div></div>',
);
});
});