From ada4e0d28957d1065c5d0079a29bdaffc4308413 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 3 Oct 2026 00:22:49 +0000 Subject: [PATCH] fix(studio): show each timeline row its own layer instead of a flat bar (#4917) * 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. * fix(studio): read timeline rows from any realm so each keeps its selector * refactor(studio-server): always isolate the element in a thumbnail clip * fix(cli): undo a thumbnail's isolation before its page takes the next capture * chore(core): generate the dom-realm export and trim thumbnail clip comments * fix(studio-server): restore sibling opacity after a clip, hash selectors in cache keys * fix(studio-server): keep faded siblings in a page-owned list so restore is exact * feat(studio): draw a text layer row as its own live text instead of a captured picture * refactor(studio): move timeline text reading into its own module * fix(studio): keep text rows readable and current, and leave painted layers as pictures * fix(studio): refresh a canvas-edited text row by its own identity and source file * fix(studio): refresh a canvas-edited text row known only by its data-hf-id --- packages/cli/src/server/studioServer.test.ts | 20 ++++- packages/cli/src/server/studioServer.ts | 28 +++--- packages/core/package-subpaths.json | 6 ++ packages/core/package.json | 10 +++ .../src/helpers/screenshotClip.test.ts | 33 ++++++- .../src/helpers/screenshotClip.ts | 21 +++++ packages/studio-server/src/index.ts | 6 +- .../src/routes/thumbnail.test.ts | 16 ++++ .../studio-server/src/routes/thumbnail.ts | 4 +- .../src/components/editor/domEditingDom.ts | 4 + .../src/components/editor/domEditingLayers.ts | 6 +- .../src/hooks/refreshTimelineRowText.test.ts | 65 ++++++++++++++ .../src/hooks/refreshTimelineRowText.ts | 29 ++++++ .../studio/src/hooks/useDomEditTextCommits.ts | 2 + .../src/hooks/useRenderClipContent.test.ts | 18 ++++ .../studio/src/hooks/useRenderClipContent.ts | 9 +- .../player/components/TextClipContent.test.ts | 19 ++++ .../src/player/components/TextClipContent.tsx | 27 ++++++ .../player/hooks/timelinePlayerSync.test.ts | 6 ++ .../src/player/hooks/timelinePlayerSync.ts | 7 +- .../player/lib/timelineElementHelpers.test.ts | 88 ++++++++++++++++++- .../src/player/lib/timelineElementHelpers.ts | 21 ++--- .../studio/src/player/lib/timelineText.ts | 40 +++++++++ .../src/player/store/timelineElement.ts | 12 +++ packages/studio/src/styles/theme.css | 3 + packages/studio/vite.browser.ts | 48 ++-------- 26 files changed, 463 insertions(+), 85 deletions(-) create mode 100644 packages/studio/src/hooks/refreshTimelineRowText.test.ts create mode 100644 packages/studio/src/hooks/refreshTimelineRowText.ts create mode 100644 packages/studio/src/player/components/TextClipContent.test.ts create mode 100644 packages/studio/src/player/components/TextClipContent.tsx create mode 100644 packages/studio/src/player/lib/timelineText.ts diff --git a/packages/cli/src/server/studioServer.test.ts b/packages/cli/src/server/studioServer.test.ts index bd9bea6152..3af11315fe 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 de80551dbf..7a40bae3ac 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 da3dfb8adf..4edb3454f6 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 74c69fa13b..f1f7567513 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 6ea7d3580f..6d68ba01b4 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 36f244245c..f54c8c1709 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 93c1022ad3..1efa332497 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 038524b231..9e457a099a 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 45e9bc9312..ef3d1b8c4c 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 e5657d6f17..2b69b77fb2 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 acc24b7c38..9bca9d611d 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 0000000000..5c7f59e70c --- /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 0000000000..afe3668bc1 --- /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 19110e843e..6c8344e0a3 100644 --- a/packages/studio/src/hooks/useDomEditTextCommits.ts +++ b/packages/studio/src/hooks/useDomEditTextCommits.ts @@ -1,5 +1,6 @@ import { trackPreviewFeatureUsed } from "../utils/previewFeatureUsage"; import { useCallback, useRef } from "react"; +import { refreshTimelineRowText } from "./refreshTimelineRowText"; import { normalizeDomEditStyleValue } from "../utils/studioHelpers"; import { injectPreviewGoogleFont, injectPreviewImportedFont } from "../utils/studioFontHelpers"; import { @@ -64,6 +65,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 9528406fb0..5146a0c987 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 b677c531a4..5dc25c92d3 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 0000000000..99b8719f09 --- /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 0000000000..1ca8d3c58d --- /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 aa70c5b503..200f8eb10e 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 071a1579af..c9788d020f 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 b8753fd15d..b90217f6dc 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