diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts
index 5cf42e2fb3..c0b68ca4d7 100644
--- a/packages/core/src/runtime/init.test.ts
+++ b/packages/core/src/runtime/init.test.ts
@@ -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 =
+ `
` +
+ `
` +
+ `
` +
+ `
` +
+ `
`;
+ 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();
+ 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 =
+ ``;
+ 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 =
+ ``;
+ 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 =
+ ``;
+ 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
diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts
index 62d4894495..295c0a3548 100644
--- a/packages/core/src/runtime/init.ts
+++ b/packages/core/src/runtime/init.ts
@@ -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),
);
diff --git a/packages/core/src/runtime/timedClipHide.ts b/packages/core/src/runtime/timedClipHide.ts
index 38e14c35cf..37e91a4822 100644
--- a/packages/core/src/runtime/timedClipHide.ts
+++ b/packages/core/src/runtime/timedClipHide.ts
@@ -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}"]`;
diff --git a/packages/core/src/studioPreviewMark.ts b/packages/core/src/studioPreviewMark.ts
index 58dec41377..e885cd6aff 100644
--- a/packages/core/src/studioPreviewMark.ts
+++ b/packages/core/src/studioPreviewMark.ts
@@ -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,
+): void {
+ for (const [name, value] of attrs) el.setAttribute(name, value);
+ el.removeAttribute(RUNTIME_STAMPED_ATTR);
+}
diff --git a/packages/studio/src/hooks/timelineEditingHelpers.test.ts b/packages/studio/src/hooks/timelineEditingHelpers.test.ts
index a912e3e81b..5532dab252 100644
--- a/packages/studio/src/hooks/timelineEditingHelpers.test.ts
+++ b/packages/studio/src/hooks/timelineEditingHelpers.test.ts
@@ -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(`
+
+ `);
+ 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", () => {
diff --git a/packages/studio/src/hooks/timelineEditingHelpers.ts b/packages/studio/src/hooks/timelineEditingHelpers.ts
index dbfdd8fdfa..74f9b84d9a 100644
--- a/packages/studio/src/hooks/timelineEditingHelpers.ts
+++ b/packages/studio/src/hooks/timelineEditingHelpers.ts
@@ -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.
}
diff --git a/packages/studio/src/utils/authoredSource.test.ts b/packages/studio/src/utils/authoredSource.test.ts
index e4e6d26a52..ba2ac6445b 100644
--- a/packages/studio/src/utils/authoredSource.test.ts
+++ b/packages/studio/src/utils/authoredSource.test.ts
@@ -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 = `
`;
expect(liveMarkupWithoutPreviewMarks(live)).toBe('');
});
+
+ it("drops the runtime's stamp so copied markup cannot carry it into a project file", () => {
+ const live = document.createElement("div");
+ live.innerHTML = ``;
+ live.setAttribute(RUNTIME_STAMPED_ATTR, "1");
+ expect(liveMarkupWithoutPreviewMarks(live)).toBe(
+ '',
+ );
+ });
});