diff --git a/packages/cli/src/server/studioServer.test.ts b/packages/cli/src/server/studioServer.test.ts
index bd9bea61526..3af11315fee 100644
--- a/packages/cli/src/server/studioServer.test.ts
+++ b/packages/cli/src/server/studioServer.test.ts
@@ -504,6 +504,10 @@ describe("createStudioServer shutdown", () => {
});
describe("Studio thumbnail capture", () => {
+ // The thumbnail browser lease is module-wide; without a shutdown the next test inherits this one's fake.
+ afterEach(async () => {
+ await server?.shutdown();
+ });
function fakePageBrowser(onEvaluate = () => {}) {
const screenshot = vi.fn(async () => Buffer.from("jpeg"));
const evaluate = vi.fn(async () => onEvaluate());
@@ -522,7 +526,7 @@ describe("Studio thumbnail capture", () => {
browser: { connected: true, newPage: async () => page, on: () => {} },
release: async () => {},
});
- return { screenshot };
+ return { screenshot, evaluate };
}
const opts = (dir: string, signal = new AbortController().signal) => ({
project: { id: "demo", dir, title: "demo" },
@@ -552,6 +556,20 @@ describe("Studio thumbnail capture", () => {
expect(screenshot).toHaveBeenCalledTimes(1);
});
+ it("undoes a row's isolation after its screenshot, since the page is reused", async () => {
+ const { screenshot, evaluate } = fakePageBrowser();
+ const dir = tmpProject();
+ server = createStudioServer({ projectDir: dir });
+ await server.adapter.generateThumbnail?.({ ...opts(dir), selector: "#title" });
+ const clears = evaluate.mock.calls.flatMap((call, i) =>
+ ((call as unknown[])[0] as { name?: string }).name === "clearElementScreenshotIsolation"
+ ? [evaluate.mock.invocationCallOrder[i]!]
+ : [],
+ );
+ expect(clears).toHaveLength(1);
+ expect(clears[0]).toBeGreaterThan(screenshot.mock.invocationCallOrder[0]!);
+ });
+
it("reuses the cached project signature instead of walking the project per thumbnail", async () => {
fakePageBrowser();
const dir = tmpProject();
diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts
index de80551dbff..7a40bae3acd 100644
--- a/packages/cli/src/server/studioServer.ts
+++ b/packages/cli/src/server/studioServer.ts
@@ -63,7 +63,10 @@ import {
historyCache,
} from "@hyperframes/studio-server";
import { resolveAutoProxy } from "../utils/projectConfig.js";
-import { getElementScreenshotClip } from "@hyperframes/studio-server/screenshot-clip";
+import {
+ clearElementScreenshotIsolation,
+ getElementScreenshotClip,
+} from "@hyperframes/studio-server/screenshot-clip";
import type { ScreenshotClip } from "@hyperframes/studio-server/screenshot-clip";
import type { RenderJob } from "@hyperframes/producer";
import { isWithinProjectRoot } from "@hyperframes/parsers/asset-resolution";
@@ -737,19 +740,18 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
await new Promise((r) => setTimeout(r, 200));
await reapplyStudioManualEditsToThumbnailPage(page);
if (opts.signal.aborted) return null;
- let clip: ScreenshotClip | undefined;
- if (opts.selector) {
- clip = await page.evaluate(
- getElementScreenshotClip,
- opts.selector,
- opts.selectorIndex,
- );
+ try {
+ const clip: ScreenshotClip | undefined = opts.selector
+ ? await page.evaluate(getElementScreenshotClip, opts.selector, opts.selectorIndex)
+ : undefined;
+ return (await page.screenshot(
+ opts.format === "png"
+ ? { type: "png", ...(clip ? { clip } : {}) }
+ : { type: "jpeg", quality: 80, ...(clip ? { clip } : {}) },
+ )) as Buffer;
+ } finally {
+ if (opts.selector) await page.evaluate(clearElementScreenshotIsolation);
}
- return (await page.screenshot(
- opts.format === "png"
- ? { type: "png", ...(clip ? { clip } : {}) }
- : { type: "jpeg", quality: 80, ...(clip ? { clip } : {}) },
- )) as Buffer;
},
);
} catch (err) {
diff --git a/packages/core/package-subpaths.json b/packages/core/package-subpaths.json
index da3dfb8adf5..4edb3454f69 100644
--- a/packages/core/package-subpaths.json
+++ b/packages/core/package-subpaths.json
@@ -284,6 +284,12 @@
"types": "./dist/runtime/clipTree.d.ts",
"environments": ["browser", "bun", "node"]
},
+ "./runtime/dom-realm": {
+ "source": "./src/runtime/domRealm.ts",
+ "runtime": "./dist/runtime/domRealm.js",
+ "types": "./dist/runtime/domRealm.d.ts",
+ "environments": ["browser", "bun", "node"]
+ },
"./runtime/custom-ease": {
"source": "./src/runtime/customEase.ts",
"runtime": "./dist/runtime/customEase.js",
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-server/src/helpers/screenshotClip.test.ts b/packages/studio-server/src/helpers/screenshotClip.test.ts
index 6ea7d3580f1..6d68ba01b4e 100644
--- a/packages/studio-server/src/helpers/screenshotClip.test.ts
+++ b/packages/studio-server/src/helpers/screenshotClip.test.ts
@@ -1,8 +1,9 @@
import { afterEach, describe, expect, it } from "vitest";
-import { getElementScreenshotClip } from "./screenshotClip";
+import { clearElementScreenshotIsolation, getElementScreenshotClip } from "./screenshotClip";
afterEach(() => {
document.body.innerHTML = "";
+ document.head.innerHTML = "";
});
describe("getElementScreenshotClip", () => {
@@ -51,4 +52,34 @@ describe("getElementScreenshotClip", () => {
expect(clip?.width).toBeGreaterThan(0);
expect(clip?.height).toBeGreaterThan(0);
});
+
+ it("fades every sibling on the element's ancestor path and nothing else", () => {
+ document.body.innerHTML = `
+
+ `;
+ const outside = document.getElementById("outside")!.style.getPropertyValue("opacity");
+ const hidden = (id: string) => getComputedStyle(document.getElementById(id)!).opacity === "0";
+
+ getElementScreenshotClip("#waves");
+ expect(["title", "star", "outside"].every(hidden)).toBe(true);
+ expect(["stage", "waves", "frame"].some(hidden)).toBe(false);
+
+ clearElementScreenshotIsolation();
+ getElementScreenshotClip("#title");
+ expect(["waves", "star", "outside"].every(hidden)).toBe(true);
+ expect(["stage", "title"].some(hidden)).toBe(false);
+
+ clearElementScreenshotIsolation();
+ const title = document.getElementById("title")!.style;
+ expect([title.getPropertyValue("opacity"), title.getPropertyPriority("opacity")]).toEqual([
+ "1",
+ "important",
+ ]);
+ expect(document.getElementById("waves")!.style.getPropertyValue("opacity")).toBe("");
+ expect(document.getElementById("outside")!.style.getPropertyValue("opacity")).toBe(outside);
+ });
});
diff --git a/packages/studio-server/src/helpers/screenshotClip.ts b/packages/studio-server/src/helpers/screenshotClip.ts
index 36f244245c4..f54c8c17092 100644
--- a/packages/studio-server/src/helpers/screenshotClip.ts
+++ b/packages/studio-server/src/helpers/screenshotClip.ts
@@ -5,6 +5,7 @@ export interface ScreenshotClip {
height: number;
}
+// Serialised by page.evaluate, so self-contained; fades everything but the element's own branch.
export function getElementScreenshotClip(
selector: string,
selectorIndex?: number,
@@ -25,6 +26,17 @@ 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; each prior inline opacity is kept for clearElementScreenshotIsolation.
+ const page = window as Window & { __hfThumbnailFaded?: [CSSStyleDeclaration, string, string][] };
+ const faded = (page.__hfThumbnailFaded ??= []);
+ for (let node: Element = el; node.parentElement; node = node.parentElement) {
+ for (const sibling of Array.from(node.parentElement.children)) {
+ const style = (sibling as HTMLElement).style;
+ if (sibling === node || !style) continue;
+ faded.push([style, style.getPropertyValue("opacity"), style.getPropertyPriority("opacity")]);
+ style.setProperty("opacity", "0", "important");
+ }
+ }
const rect = el.getBoundingClientRect();
if (rect.width < 4 || rect.height < 4) return undefined;
const pad = 8;
@@ -39,3 +51,12 @@ export function getElementScreenshotClip(
height: Math.max(1, Math.min(rect.height + pad * 2, maxHeight)),
};
}
+
+export function clearElementScreenshotIsolation(): void {
+ const page = window as Window & { __hfThumbnailFaded?: [CSSStyleDeclaration, string, string][] };
+ for (const [style, value, priority] of (page.__hfThumbnailFaded ?? []).reverse()) {
+ if (value) style.setProperty("opacity", value, priority);
+ else style.removeProperty("opacity");
+ }
+ page.__hfThumbnailFaded = [];
+}
diff --git a/packages/studio-server/src/index.ts b/packages/studio-server/src/index.ts
index 93c1022ad3f..1efa3324972 100644
--- a/packages/studio-server/src/index.ts
+++ b/packages/studio-server/src/index.ts
@@ -47,7 +47,11 @@ export {
type FileWriteReceipt,
} from "./helpers/fileVersion.js";
export { buildSubCompositionHtml } from "./helpers/subComposition.js";
-export { getElementScreenshotClip, type ScreenshotClip } from "./helpers/screenshotClip.js";
+export {
+ clearElementScreenshotIsolation,
+ getElementScreenshotClip,
+ type ScreenshotClip,
+} from "./helpers/screenshotClip.js";
export {
thumbnailDeviceScaleFactor,
type ThumbnailOutputDimensions,
diff --git a/packages/studio-server/src/routes/thumbnail.test.ts b/packages/studio-server/src/routes/thumbnail.test.ts
index 038524b2318..9e457a099ab 100644
--- a/packages/studio-server/src/routes/thumbnail.test.ts
+++ b/packages/studio-server/src/routes/thumbnail.test.ts
@@ -107,6 +107,22 @@ describe("registerThumbnailRoutes", () => {
);
});
+ it("keeps selectors that differ only in punctuation apart in the cache", async () => {
+ const adapter = createAdapter();
+ const app = new Hono();
+ registerThumbnailRoutes(app, adapter);
+
+ for (const selector of ["%23a%5C.b", "%23a_b", "%23a%5C.b"]) {
+ await app.request(
+ `http://localhost/projects/demo/thumbnail/index.html?t=1.2&selector=${selector}`,
+ );
+ }
+
+ expect(vi.mocked(adapter.generateThumbnail!).mock.calls.map(([opts]) => opts.selector)).toEqual(
+ ["#a\\.b", "#a_b"],
+ );
+ });
+
it("maps square authored dimensions across jpeg output modes", async () => {
const adapter = createAdapter();
const app = new Hono();
diff --git a/packages/studio-server/src/routes/thumbnail.ts b/packages/studio-server/src/routes/thumbnail.ts
index 45e9bc9312f..ef3d1b8c4c9 100644
--- a/packages/studio-server/src/routes/thumbnail.ts
+++ b/packages/studio-server/src/routes/thumbnail.ts
@@ -29,7 +29,7 @@ import {
import { proxyActivityMark } from "../helpers/proxyTranscoder.js";
import { PREVIEW_CAPTURE_PARAM } from "./preview.js";
-const THUMBNAIL_CACHE_VERSION = "v4";
+const THUMBNAIL_CACHE_VERSION = "v5";
const THUMBNAIL_MAX_OUTPUT_WIDTH = 240;
const THUMBNAIL_MAX_OUTPUT_HEIGHT = 135;
const THUMBNAIL_CACHE_MAX_BYTES = 512 * 1024 * 1024;
@@ -193,7 +193,7 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v
// Cache
const cacheDir = join(project.dir, ".thumbnails");
const selectorKey = selector
- ? `_${selector.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 80)}_${selectorIndex ?? 0}`
+ ? `_${createHash("sha1").update(selector).digest("hex").slice(0, 16)}_${selectorIndex ?? 0}`
: "";
const urlVersionKey = urlVersion
? `_${urlVersion.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 32)}`
diff --git a/packages/studio/src/components/editor/domEditingDom.ts b/packages/studio/src/components/editor/domEditingDom.ts
index e5657d6f177..2b69b77fb2f 100644
--- a/packages/studio/src/components/editor/domEditingDom.ts
+++ b/packages/studio/src/components/editor/domEditingDom.ts
@@ -30,6 +30,10 @@ export function isTextBearingTag(tagName: string): boolean {
return ["div", "span", "p", "strong", "h1", "h2", "h3", "h4", "h5", "h6"].includes(tagName);
}
+export function isEditableTextLeaf(el: HTMLElement): boolean {
+ return isTextBearingTag(el.tagName.toLowerCase()) && el.children.length === 0;
+}
+
/** Does this node render AT ALL, ignoring what it inherits? Sole owner of the
* rule; the walk below only decides which nodes to ask it about. */
function elementRendersItself(win: Window, el: HTMLElement): boolean {
diff --git a/packages/studio/src/components/editor/domEditingLayers.ts b/packages/studio/src/components/editor/domEditingLayers.ts
index acc24b7c38a..9bca9d611d3 100644
--- a/packages/studio/src/components/editor/domEditingLayers.ts
+++ b/packages/studio/src/components/editor/domEditingLayers.ts
@@ -23,8 +23,8 @@ import {
getInlineStyles,
getSelectorIndex,
getSourceFileForElement,
+ isEditableTextLeaf,
isHtmlElement,
- isTextBearingTag,
} from "./domEditingDom";
import {
findElementForSelection,
@@ -35,10 +35,6 @@ import { isCompositionRootLayer } from "./domEditingRootLayer";
import { withSelectorIndexPass } from "../../utils/sourceScopedSelectorIndex";
import { type DomEditLayerWalkCache, readDomEditLayerWalkEntry } from "./domEditLayerWalkCache";
-export function isEditableTextLeaf(el: HTMLElement): boolean {
- return isTextBearingTag(el.tagName.toLowerCase()) && el.children.length === 0;
-}
-
function sameTagChildIndex(el: HTMLElement): number {
let index = 0;
let sibling = el.previousElementSibling;
diff --git a/packages/studio/src/hooks/refreshTimelineRowText.test.ts b/packages/studio/src/hooks/refreshTimelineRowText.test.ts
new file mode 100644
index 00000000000..5c7f59e70cc
--- /dev/null
+++ b/packages/studio/src/hooks/refreshTimelineRowText.test.ts
@@ -0,0 +1,65 @@
+// @vitest-environment happy-dom
+import { afterEach, describe, expect, it } from "vitest";
+import { usePlayerStore, type TimelineElement } from "../player/store/playerStore";
+import { refreshTimelineRowText } from "./refreshTimelineRowText";
+
+afterEach(() => {
+ usePlayerStore.getState().reset();
+ document.body.innerHTML = "";
+});
+
+const row = (key: string, selector: string, sourceFile?: string): TimelineElement => ({
+ id: key.split("#").pop()!,
+ key,
+ tag: "p",
+ start: 0,
+ duration: 2,
+ track: 0,
+ selector,
+ selectorIndex: 0,
+ sourceFile,
+ text: { value: "Old" },
+});
+
+describe("refreshTimelineRowText", () => {
+ it("gives the edited layer's row, addressed by its store key, its new words", () => {
+ document.body.innerHTML = `New words
`;
+ usePlayerStore.getState().setElements([row("index.html#title", "#title")]);
+
+ refreshTimelineRowText(document.getElementById("title")!);
+
+ expect(usePlayerStore.getState().elements[0]?.text?.value).toBe("New words");
+ });
+
+ it("finds a row known only by its data-hf-id", () => {
+ document.body.innerHTML = `New
`;
+ usePlayerStore
+ .getState()
+ .setElements([
+ { ...row("index.html#caption-a", ""), selector: undefined, hfId: "caption-a" },
+ ]);
+
+ refreshTimelineRowText(document.querySelector("p")!);
+
+ expect(usePlayerStore.getState().elements[0]?.text?.value).toBe("New");
+ });
+
+ it("finds the row in the edited layer's own composition when two share a selector", () => {
+ document.body.innerHTML = `
+
+ `;
+ usePlayerStore
+ .getState()
+ .setElements([
+ row("a.html#a-caption", ".caption", "a.html"),
+ row("b.html#b-caption", ".caption", "b.html"),
+ ]);
+
+ refreshTimelineRowText(document.querySelectorAll(".caption")[1]!);
+
+ expect(usePlayerStore.getState().elements.map((element) => element.text?.value)).toEqual([
+ "Old",
+ "B new",
+ ]);
+ });
+});
diff --git a/packages/studio/src/hooks/refreshTimelineRowText.ts b/packages/studio/src/hooks/refreshTimelineRowText.ts
new file mode 100644
index 00000000000..afe3668bc18
--- /dev/null
+++ b/packages/studio/src/hooks/refreshTimelineRowText.ts
@@ -0,0 +1,29 @@
+import {
+ getTimelineElementIdentity,
+ getTimelineElementSelector,
+ getTimelineElementSelectorIndex,
+ getTimelineElementSourceFile,
+} from "../player/lib/timelineElementHelpers";
+import { readTimelineText } from "../player/lib/timelineText";
+import { usePlayerStore } from "../player/store/playerStore";
+
+/** A text commit skips the preview reload, so the edited layer's row re-reads its words here. */
+export function refreshTimelineRowText(el: HTMLElement): void {
+ const sourceFile = getTimelineElementSourceFile(el);
+ const hfId = el.getAttribute("data-hf-id");
+ const selector = getTimelineElementSelector(el);
+ const selectorIndex = selector
+ ? (getTimelineElementSelectorIndex(el.ownerDocument, el, selector) ?? 0)
+ : 0;
+ const { elements, updateElement } = usePlayerStore.getState();
+ const row = elements.find(
+ (candidate) =>
+ candidate.sourceFile === sourceFile &&
+ (hfId
+ ? candidate.hfId === hfId
+ : !!selector &&
+ candidate.selector === selector &&
+ (candidate.selectorIndex ?? 0) === selectorIndex),
+ );
+ if (row) updateElement(getTimelineElementIdentity(row), { text: readTimelineText(el) });
+}
diff --git a/packages/studio/src/hooks/useDomEditTextCommits.ts b/packages/studio/src/hooks/useDomEditTextCommits.ts
index 21e26c3ca23..317bd57cd85 100644
--- a/packages/studio/src/hooks/useDomEditTextCommits.ts
+++ b/packages/studio/src/hooks/useDomEditTextCommits.ts
@@ -1,4 +1,5 @@
import { useCallback, useRef } from "react";
+import { refreshTimelineRowText } from "./refreshTimelineRowText";
import { normalizeDomEditStyleValue } from "../utils/studioHelpers";
import { injectPreviewGoogleFont, injectPreviewImportedFont } from "../utils/studioFontHelpers";
import {
@@ -63,6 +64,7 @@ async function resyncDomTextSelectionFromPreview(
if (!doc) return;
const refreshed = findElementForSelection(doc, selection, activeCompPath);
if (!refreshed) return;
+ refreshTimelineRowText(refreshed);
const nextSelection = await buildDomSelectionFromTarget(refreshed);
if (!nextSelection) return;
applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
diff --git a/packages/studio/src/hooks/useRenderClipContent.test.ts b/packages/studio/src/hooks/useRenderClipContent.test.ts
index 9528406fb0e..5146a0c9872 100644
--- a/packages/studio/src/hooks/useRenderClipContent.test.ts
+++ b/packages/studio/src/hooks/useRenderClipContent.test.ts
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, it } from "vitest";
import { CompositionThumbnail, VideoThumbnail } from "../player";
import { AudioWaveform } from "../player/components/AudioWaveform";
import { AudibleVideoClipContent } from "../player/components/AudibleVideoClipContent";
+import { TextClipContent } from "../player/components/TextClipContent";
import { ClipPeakMarks } from "../player/components/ClipPeakMarks";
function unwrapPeakMarks(node: ReactNode): ReactNode {
@@ -280,6 +281,23 @@ 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("draws a text layer's own words instead of capturing them", () => {
+ usePlayerStore.setState({ thumbnailMode: "adaptive" });
+ const content = renderClipContent(
+ { id: "title", tag: "h1", start: 0, duration: 12, track: 0, text: { value: "Ship it" } },
+ null,
+ );
+ expect(isValidElement(content) && content.type).toBe(TextClipContent);
+ });
+
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..5dc25c92d37 100644
--- a/packages/studio/src/hooks/useRenderClipContent.ts
+++ b/packages/studio/src/hooks/useRenderClipContent.ts
@@ -12,6 +12,7 @@ import type { TimelineClipRenderContext } from "../player/components/TimelineTyp
import { audioPillFlags } from "../player/components/audioClipLink";
import { AudioWaveform, rendersWaveform } from "../player/components/AudioWaveform";
import { ImageThumbnail } from "../player/components/ImageThumbnail";
+import { TextClipContent } from "../player/components/TextClipContent";
import { AudibleVideoClipContent } from "../player/components/AudibleVideoClipContent";
import { ClipPeakMarks } from "../player/components/ClipPeakMarks";
import { clipPeaksUrl, clipSourceWindow } from "../player/components/clipPeakMap";
@@ -184,6 +185,8 @@ export function useRenderClipContent({
return renderAudioClip(el, pid, sessionEpoch, style.label, context, elements);
}
+ if (el.text) return createElement(TextClipContent, { text: el.text });
+
// When drilled into a composition, render all inner elements via
// CompositionThumbnail at their start time — most accurate visual.
if (activePreviewUrl && el.duration > 0) {
@@ -207,11 +210,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/src/player/components/TextClipContent.test.ts b/packages/studio/src/player/components/TextClipContent.test.ts
new file mode 100644
index 00000000000..99b8719f098
--- /dev/null
+++ b/packages/studio/src/player/components/TextClipContent.test.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it } from "vitest";
+import { textClipBackground } from "./TextClipContent";
+
+describe("textClipBackground", () => {
+ it("puts dark text on a light strip and light text on a dark one", () => {
+ expect(textClipBackground({ value: "a", color: "rgb(16, 20, 24)" })).toBe(
+ "var(--timeline-text-clip-light-bg)",
+ );
+ expect(textClipBackground({ value: "a", color: "rgb(255, 255, 255)" })).toBe(
+ "var(--timeline-text-clip-dark-bg)",
+ );
+ });
+
+ it("uses the layer's own background when it paints one", () => {
+ expect(
+ textClipBackground({ value: "a", color: "rgb(16, 20, 24)", background: "rgb(230, 57, 70)" }),
+ ).toBe("rgb(230, 57, 70)");
+ });
+});
diff --git a/packages/studio/src/player/components/TextClipContent.tsx b/packages/studio/src/player/components/TextClipContent.tsx
new file mode 100644
index 00000000000..1ca8d3c58de
--- /dev/null
+++ b/packages/studio/src/player/components/TextClipContent.tsx
@@ -0,0 +1,27 @@
+import { parseCssColor } from "../../components/editor/colorValue";
+import type { TimelineText } from "../store/timelineElement";
+
+/** The layer's own background, or a strip the text reads on: light behind dark text, dark otherwise. */
+export function textClipBackground(text: TimelineText): string {
+ if (text.background) return text.background;
+ const color = parseCssColor(text.color ?? "");
+ const light = color ? 0.2126 * color.red + 0.7152 * color.green + 0.0722 * color.blue : 255;
+ return light < 128 ? "var(--timeline-text-clip-light-bg)" : "var(--timeline-text-clip-dark-bg)";
+}
+
+/** A text layer's row: its own words in its own font and colour, live, on one clipped line. */
+export function TextClipContent({ text }: { text: TimelineText }) {
+ return (
+
+
+ {text.value}
+
+
+ );
+}
diff --git a/packages/studio/src/player/hooks/timelinePlayerSync.test.ts b/packages/studio/src/player/hooks/timelinePlayerSync.test.ts
index aa70c5b503e..200f8eb10e7 100644
--- a/packages/studio/src/player/hooks/timelinePlayerSync.test.ts
+++ b/packages/studio/src/player/hooks/timelinePlayerSync.test.ts
@@ -31,4 +31,10 @@ describe("timelineElementsChanged", () => {
expect(timelineElementsChanged([grouped], [clip])).toBe(true);
expect(timelineElementsChanged([grouped], [{ ...grouped }])).toBe(false);
});
+
+ it("sees a change to a text row's words, but not an identical re-read", () => {
+ const row = { id: "t", tag: "h1", start: 0, duration: 2, track: 0, text: { value: "Old" } };
+ expect(timelineElementsChanged([row], [{ ...row, text: { value: "Old" } }])).toBe(false);
+ expect(timelineElementsChanged([row], [{ ...row, text: { value: "New" } }])).toBe(true);
+ });
});
diff --git a/packages/studio/src/player/hooks/timelinePlayerSync.ts b/packages/studio/src/player/hooks/timelinePlayerSync.ts
index 071a1579afc..c9788d020f5 100644
--- a/packages/studio/src/player/hooks/timelinePlayerSync.ts
+++ b/packages/studio/src/player/hooks/timelinePlayerSync.ts
@@ -1,4 +1,5 @@
import type { TimelineElement } from "../store/playerStore";
+import { sameTimelineText } from "../lib/timelineText";
const RENDERED_FIELDS: readonly (keyof TimelineElement)[] = [
"id",
@@ -28,6 +29,10 @@ export function timelineElementsChanged(
if (next.length !== previous.length) return true;
return next.some((element, index) => {
const prior = previous[index];
- return !prior || RENDERED_FIELDS.some((key) => element[key] !== prior[key]);
+ return (
+ !prior ||
+ RENDERED_FIELDS.some((key) => element[key] !== prior[key]) ||
+ !sameTimelineText(element.text, prior.text)
+ );
});
}
diff --git a/packages/studio/src/player/lib/timelineElementHelpers.test.ts b/packages/studio/src/player/lib/timelineElementHelpers.test.ts
index b8753fd15d6..b90217f6dc5 100644
--- a/packages/studio/src/player/lib/timelineElementHelpers.test.ts
+++ b/packages/studio/src/player/lib/timelineElementHelpers.test.ts
@@ -1,5 +1,13 @@
+// @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";
+import { readTimelineText, sameTimelineText } from "./timelineText";
describe("isVideoAudible — the compiler's data-has-audio rule", () => {
it("explicit data-has-audio wins", () => {
@@ -14,3 +22,81 @@ 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