From 3cc4f63c559c9c9cb1edd8f827e7f790fda8ef26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 2 Oct 2026 11:54:23 -0400 Subject: [PATCH 01/12] fix(studio): show each timeline row its own layer instead of a grey bar A row got a frame only if it spanned under 92% of the film and its id did not look like a backdrop; every other row drew a plain grey bar. Every element row now gets its frame, and the capture fades the row element's siblings first, so the frame shows that layer and not the whole film. Studio's dev server and the CLI share one capture helper; Studio's own copy is gone. --- packages/cli/src/server/studioServer.ts | 1 + .../src/helpers/screenshotClip.test.ts | 18 +++++++ .../src/helpers/screenshotClip.ts | 12 +++++ .../src/hooks/useRenderClipContent.test.ts | 8 ++++ .../studio/src/hooks/useRenderClipContent.ts | 6 +-- packages/studio/vite.browser.ts | 48 ++++--------------- 6 files changed, 48 insertions(+), 45 deletions(-) diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index de80551dbff..54d6e05bf35 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -743,6 +743,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { getElementScreenshotClip, opts.selector, opts.selectorIndex, + true, ); } return (await page.screenshot( diff --git a/packages/studio-server/src/helpers/screenshotClip.test.ts b/packages/studio-server/src/helpers/screenshotClip.test.ts index 6ea7d3580f1..90e75f58729 100644 --- a/packages/studio-server/src/helpers/screenshotClip.test.ts +++ b/packages/studio-server/src/helpers/screenshotClip.test.ts @@ -51,4 +51,22 @@ describe("getElementScreenshotClip", () => { expect(clip?.width).toBeGreaterThan(0); expect(clip?.height).toBeGreaterThan(0); }); + + it("with isolate, fades every sibling on the element's ancestor path and nothing else", () => { + document.body.innerHTML = ` +
+
Title
+ +
.:-=
+
+
`; + const opacity = (id: string) => document.getElementById(id)!.style.getPropertyValue("opacity"); + + getElementScreenshotClip("#waves"); + expect(["title", "star", "outside"].map(opacity)).toEqual(["", "", ""]); + + getElementScreenshotClip("#waves", 0, true); + expect(["title", "star", "outside"].map(opacity)).toEqual(["0", "0", "0"]); + expect(["stage", "waves", "frame"].map(opacity)).toEqual(["", "", ""]); + }); }); diff --git a/packages/studio-server/src/helpers/screenshotClip.ts b/packages/studio-server/src/helpers/screenshotClip.ts index 36f244245c4..9294ff9972d 100644 --- a/packages/studio-server/src/helpers/screenshotClip.ts +++ b/packages/studio-server/src/helpers/screenshotClip.ts @@ -5,9 +5,14 @@ export interface ScreenshotClip { height: number; } +/** + * Runs inside the page (serialised by page.evaluate), so it stays self-contained. With `isolate`, + * everything but the element, its subtree and its ancestors is faded out first. + */ export function getElementScreenshotClip( selector: string, selectorIndex?: number, + isolate = false, ): ScreenshotClip | undefined { // Guard against invalid CSS selectors (e.g. `#0` — a digit-leading id from // user HTML that upstream producers forgot to CSS.escape). querySelectorAll @@ -25,6 +30,13 @@ export function getElementScreenshotClip( const safeIndex = Math.max(0, Math.min(matches.length - 1, Math.floor(selectorIndex ?? 0))); const el = matches[safeIndex] ?? null; if (!(el instanceof HTMLElement)) return undefined; + // Opacity, not visibility: a descendant cannot paint through a faded ancestor. + for (let node: HTMLElement = el; isolate && node.parentElement; node = node.parentElement) { + for (const sibling of Array.from(node.parentElement.children)) { + if (sibling !== node && (sibling instanceof HTMLElement || sibling instanceof SVGElement)) + sibling.style.setProperty("opacity", "0", "important"); + } + } const rect = el.getBoundingClientRect(); if (rect.width < 4 || rect.height < 4) return undefined; const pad = 8; diff --git a/packages/studio/src/hooks/useRenderClipContent.test.ts b/packages/studio/src/hooks/useRenderClipContent.test.ts index 9528406fb0e..6ac84751f0b 100644 --- a/packages/studio/src/hooks/useRenderClipContent.test.ts +++ b/packages/studio/src/hooks/useRenderClipContent.test.ts @@ -280,6 +280,14 @@ describe("useRenderClipContent", () => { expect(isValidElement(content) && content.type).toBe(AudibleVideoClipContent); }); + it("gives a layer spanning the whole film its own frame, whatever its id", () => { + usePlayerStore.setState({ thumbnailMode: "adaptive" }); + for (const id of ["waves", "background-glyphs"]) { + const content = renderClipContent({ id, tag: "div", start: 0, duration: 12, track: 0 }, null); + expect(isValidElement(content) && content.type).toBe(CompositionThumbnail); + } + }); + it("passes empty labels to thumbnail content so TimelineClip owns clip names", () => { usePlayerStore.setState({ thumbnailMode: "adaptive" }); diff --git a/packages/studio/src/hooks/useRenderClipContent.ts b/packages/studio/src/hooks/useRenderClipContent.ts index b677c531a4c..6f5b123543f 100644 --- a/packages/studio/src/hooks/useRenderClipContent.ts +++ b/packages/studio/src/hooks/useRenderClipContent.ts @@ -207,11 +207,7 @@ export function useRenderClipContent({ }); } - const htmlPreviewEligible = - el.duration > 0 && - effectiveTimelineDuration > 0 && - el.duration < effectiveTimelineDuration * 0.92 && - !/(backdrop|background|overlay|scrim|mask)/i.test(el.id); + const htmlPreviewEligible = el.duration > 0 && effectiveTimelineDuration > 0; if ((el.tag === "video" || el.tag === "img") && el.src) { const mediaSrc = resolveMediaPreviewUrl(el.src, pid, window.location.origin); diff --git a/packages/studio/vite.browser.ts b/packages/studio/vite.browser.ts index 2711bb307bc..56d5a573528 100644 --- a/packages/studio/vite.browser.ts +++ b/packages/studio/vite.browser.ts @@ -4,7 +4,11 @@ import { existsSync, readdirSync } from "node:fs"; import { execFileSync } from "node:child_process"; import { homedir } from "node:os"; import { join, win32 as pathWin32 } from "node:path"; -import { thumbnailDeviceScaleFactor } from "@hyperframes/studio-server"; +import { + getElementScreenshotClip, + thumbnailDeviceScaleFactor, + type ScreenshotClip, +} from "@hyperframes/studio-server"; import { createStudioDevRenderBodyScripts } from "./vite.studioMotion"; import { seekThumbnailPreview } from "./vite.thumbnail"; @@ -162,13 +166,6 @@ async function getSharedBrowser(): Promise { - if (!selector) return undefined; - return page.evaluate( - (targetSelector: string, targetIndex: number | undefined) => { - const matches = Array.from(document.querySelectorAll(targetSelector)).filter( - (element): element is HTMLElement => element instanceof HTMLElement, - ); - const safeIndex = Math.max(0, Math.min(matches.length - 1, Math.floor(targetIndex ?? 0))); - const element = matches[safeIndex] ?? null; - if (!(element instanceof HTMLElement)) return undefined; - const rect = element.getBoundingClientRect(); - if (rect.width < 4 || rect.height < 4) return undefined; - const padding = 8; - const x = Math.max(0, rect.left - padding); - const y = Math.max(0, rect.top - padding); - return { - x, - y, - width: Math.max(1, Math.min(rect.width + padding * 2, window.innerWidth - x)), - height: Math.max(1, Math.min(rect.height + padding * 2, window.innerHeight - y)), - }; - }, - selector, - selectorIndex, - ); -} - async function captureThumbnail( page: import("puppeteer-core").Page, format: GenerateThumbnailOptions["format"], @@ -291,7 +257,9 @@ export async function generateThumbnail(opts: GenerateThumbnailOptions): Promise page = await sharedBrowser.newPage(); if (opts.signal.aborted) return null; await prepareThumbnailPage(page, opts); - const clip = await resolveScreenshotClip(page, opts.selector, opts.selectorIndex); + const clip = opts.selector + ? await page.evaluate(getElementScreenshotClip, opts.selector, opts.selectorIndex, true) + : undefined; if (opts.signal.aborted) return null; return await captureThumbnail(page, opts.format, clip); } catch (error) { From 007c79be0dc82d134dc5ac524c2670c24a2c5c74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 2 Oct 2026 15:00:12 -0400 Subject: [PATCH 02/12] fix(studio): read timeline rows from any realm so each keeps its selector --- packages/core/package.json | 10 ++++++ .../player/lib/timelineElementHelpers.test.ts | 36 ++++++++++++++++++- .../src/player/lib/timelineElementHelpers.ts | 19 +++------- 3 files changed, 49 insertions(+), 16 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 180a287bea5..263a7de334d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -293,6 +293,12 @@ "import": "./src/runtime/clipTree.ts", "types": "./src/runtime/clipTree.ts" }, + "./runtime/dom-realm": { + "bun": "./src/runtime/domRealm.ts", + "node": "./dist/runtime/domRealm.js", + "import": "./src/runtime/domRealm.ts", + "types": "./src/runtime/domRealm.ts" + }, "./runtime/custom-ease": { "bun": "./src/runtime/customEase.ts", "node": "./dist/runtime/customEase.js", @@ -667,6 +673,10 @@ "import": "./dist/runtime/clipTree.js", "types": "./dist/runtime/clipTree.d.ts" }, + "./runtime/dom-realm": { + "import": "./dist/runtime/domRealm.js", + "types": "./dist/runtime/domRealm.d.ts" + }, "./runtime/custom-ease": { "import": "./dist/runtime/customEase.js", "types": "./dist/runtime/customEase.d.ts" diff --git a/packages/studio/src/player/lib/timelineElementHelpers.test.ts b/packages/studio/src/player/lib/timelineElementHelpers.test.ts index b8753fd15d6..91635e27cc5 100644 --- a/packages/studio/src/player/lib/timelineElementHelpers.test.ts +++ b/packages/studio/src/player/lib/timelineElementHelpers.test.ts @@ -1,5 +1,12 @@ +// @vitest-environment happy-dom import { describe, expect, it } from "vitest"; -import { isVideoAudible } from "./timelineElementHelpers"; +import type { TimelineElement } from "../store/playerStore"; +import { + applyMediaMetadataFromElement, + getTimelineElementSelector, + isVideoAudible, + resolveMediaElement, +} from "./timelineElementHelpers"; describe("isVideoAudible — the compiler's data-has-audio rule", () => { it("explicit data-has-audio wins", () => { @@ -14,3 +21,30 @@ describe("isVideoAudible — the compiler's data-has-audio rule", () => { expect(isVideoAudible({ tag: "img", hasAudioAttr: null, muted: false })).toBe(false); }); }); + +describe("preview nodes built in another realm", () => { + // The Studio preview's body carries the editor window's prototypes, so its + // nodes fail `instanceof` against their own window's HTMLElement. + function foreignNode(html: string): Element { + const holder = document.createElement("div"); + holder.innerHTML = html; + const node = holder.firstElementChild!; + document.body.appendChild(node); + Object.setPrototypeOf(node, Object.create(Element.prototype)); + expect(node instanceof HTMLElement).toBe(false); + return node; + } + + it("gives a row with an id its id selector", () => { + const el = foreignNode(`
`); + expect(getTimelineElementSelector(el)).toBe("#layer-00"); + }); + + it("reads media metadata off a foreign