From 2fbaef6abb5207b6f9ef3ba0d94320a6b082fc95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 11 Oct 2026 16:45:42 +0000 Subject: [PATCH] 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. --- packages/core/src/runtime/init.test.ts | 154 ++++++++++++++++++ packages/core/src/runtime/init.ts | 17 +- packages/core/src/runtime/timedClipHide.ts | 8 +- packages/core/src/studioPreviewMark.ts | 10 ++ .../src/hooks/timelineEditingHelpers.test.ts | 20 +++ .../src/hooks/timelineEditingHelpers.ts | 3 +- .../studio/src/utils/authoredSource.test.ts | 10 ++ 7 files changed, 213 insertions(+), 9 deletions(-) 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( + '
', + ); + }); });