mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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}"]`;
|
||||
|
||||
|
||||
@@ -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>',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user