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 = ` +
+
Title
+ +
.:-=
+
+
`; + 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 = ` +

A

+

B new

`; + 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