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
This commit is contained in:
Miguel Ángel
2026-10-03 00:22:49 +00:00
committed by GitHub
parent 852782bbb0
commit ada4e0d289
26 changed files with 463 additions and 85 deletions
+19 -1
View File
@@ -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();
+15 -13
View File
@@ -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) {
+6
View File
@@ -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",
+10
View File
@@ -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"
@@ -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 = `
<div id="stage">
<div id="title" style="opacity: 1 !important">Title</div>
<svg id="star"></svg>
<div id="waves"><div id="frame" style="visibility: visible">.:-=</div></div>
</div>
<div id="outside" style="--alpha: 1; opacity: var(--alpha, 0|1)"></div>`;
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);
});
});
@@ -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 = [];
}
+5 -1
View File
@@ -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,
@@ -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();
@@ -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)}`
@@ -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 {
@@ -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;
@@ -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 = `<h1 id="title">New words</h1>`;
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 = `<p data-hf-id="caption-a">New</p>`;
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 = `
<div data-composition-id="a" data-composition-file="a.html"><p class="caption">A</p></div>
<div data-composition-id="b" data-composition-file="b.html"><p class="caption">B new</p></div>`;
usePlayerStore
.getState()
.setElements([
row("a.html#a-caption", ".caption", "a.html"),
row("b.html#b-caption", ".caption", "b.html"),
]);
refreshTimelineRowText(document.querySelectorAll<HTMLElement>(".caption")[1]!);
expect(usePlayerStore.getState().elements.map((element) => element.text?.value)).toEqual([
"Old",
"B new",
]);
});
});
@@ -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) });
}
@@ -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 });
@@ -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" });
@@ -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);
@@ -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)");
});
});
@@ -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 (
<div
className="absolute inset-0 flex items-center overflow-hidden px-2"
style={{ background: textClipBackground(text) }}
>
<span
className="whitespace-nowrap text-[15px] leading-none"
style={{ fontFamily: text.fontFamily, fontWeight: text.fontWeight, color: text.color }}
>
{text.value}
</span>
</div>
);
}
@@ -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);
});
});
@@ -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)
);
});
}
@@ -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(`<div id="layer-00" class="clip layer"></div>`);
expect(getTimelineElementSelector(el)).toBe("#layer-00");
});
it("reads media metadata off a foreign <video>", () => {
const el = foreignNode(`<video src="a.mp4" data-source-duration="12"></video>`);
expect(resolveMediaElement(el)).toBe(el);
const entry = { id: "v", tag: "div", start: 0, duration: 4, track: 0 } as TimelineElement;
applyMediaMetadataFromElement(entry, el);
expect(entry.sourceDuration).toBe(12);
});
});
describe("readTimelineText", () => {
function layer(html: string): Element {
document.body.innerHTML = html;
return document.body.firstElementChild!;
}
it("reads a text layer's words, collapsed to one line, with its font and colour", () => {
const text = readTimelineText(
layer(`<h1 style="font-family: Georgia; font-weight: 700; color: rgb(255, 0, 0)">
Ship <span>it</span>
</h1>`),
);
expect(text).toMatchObject({ value: "Ship it", fontWeight: "700", color: "rgb(255, 0, 0)" });
expect(text?.fontFamily).toContain("Georgia");
});
it("leaves a layer that holds anything but text, or no words, to its picture", () => {
expect(
readTimelineText(layer(`<div><div>Title</div><div><i></i></div></div>`)),
).toBeUndefined();
expect(readTimelineText(layer(`<div class="glyph"></div>`))).toBeUndefined();
expect(readTimelineText(layer(`<section>Words</section>`))).toBeUndefined();
});
it("keeps a layer that paints an image as a picture, and carries a flat background colour", () => {
expect(
readTimelineText(
layer(`<div style="background-image: linear-gradient(red, blue)">L01</div>`),
),
).toBeUndefined();
expect(
readTimelineText(layer(`<div style="background-color: rgb(230, 57, 70)">Go</div>`)),
).toMatchObject({
value: "Go",
background: "rgb(230, 57, 70)",
});
expect(readTimelineText(layer(`<div>Go</div>`))?.background).toBeUndefined();
expect(
readTimelineText(layer(`<div style="background-color: rgba(255, 255, 255, 0.01)">Go</div>`))
?.background,
).toBeUndefined();
});
it("tells a text change apart from an identical re-read", () => {
const text = { value: "Old", color: "rgb(0, 0, 0)" };
expect(sameTimelineText(text, { ...text })).toBe(true);
expect(sameTimelineText(text, { ...text, value: "New" })).toBe(false);
expect(sameTimelineText(text, undefined)).toBe(false);
});
});
@@ -8,11 +8,13 @@
*/
import type { TimelineElement } from "../store/playerStore";
import { readTimelineText } from "./timelineText";
import type { ClipManifestClip } from "./playbackTypes";
import { isFinitePositive } from "./playbackAdapter";
import { getSourceScopedSelectorIndex } from "../../utils/sourceScopedSelectorIndex";
import { HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
import { readElementFades } from "@hyperframes/core/audio-fade";
import { isHtmlElement, isImageElement, isMediaElement } from "@hyperframes/core/runtime/dom-realm";
import { elementVolume } from "./storedVolume";
import {
type AttrReader,
@@ -143,11 +145,6 @@ export function furthestClipEndFromSource(source: string): number {
// DOM element type guards
// ---------------------------------------------------------------------------
function isHtmlElement(el: Element): el is HTMLElement {
const HtmlElementCtor = el.ownerDocument.defaultView?.HTMLElement ?? globalThis.HTMLElement;
return typeof HtmlElementCtor !== "undefined" && el instanceof HtmlElementCtor;
}
function isCompositionHost(el: Element): boolean {
return (
el.hasAttribute("data-composition-id") ||
@@ -157,16 +154,11 @@ function isCompositionHost(el: Element): boolean {
}
export function resolveMediaElement(el: Element): HTMLMediaElement | HTMLImageElement | null {
const win = el.ownerDocument.defaultView ?? window;
const MediaElementCtor = win.HTMLMediaElement ?? globalThis.HTMLMediaElement;
const ImageElementCtor = win.HTMLImageElement ?? globalThis.HTMLImageElement;
if (el instanceof MediaElementCtor || el instanceof ImageElementCtor) return el;
if (isMediaElement(el) || isImageElement(el)) return el;
// A composition's media belongs to its own timeline, not the clip's: its length would cap a trim.
if (isCompositionHost(el)) return null;
const candidate = el.querySelector("video, audio, img");
return candidate instanceof MediaElementCtor || candidate instanceof ImageElementCtor
? candidate
: null;
return isMediaElement(candidate) || isImageElement(candidate) ? candidate : null;
}
/** The in-point as playback reads it, and the attribute holding it; empty when neither is authored. */
@@ -255,6 +247,7 @@ function applyFadeMetadataFromElement(entry: TimelineElement, el: Element): void
}
export function applyMediaMetadataFromElement(entry: TimelineElement, el: Element): void {
setOptional(entry, "text", readTimelineText(el));
applyPlaybackMetadataFromElement(entry, el);
applyAudioMetadataFromElement(entry, el);
applyFadeMetadataFromElement(entry, el);
@@ -266,9 +259,7 @@ export function applyMediaMetadataFromElement(entry: TimelineElement, el: Elemen
const src = mediaEl.getAttribute("src");
if (src) entry.src = src;
const win = mediaEl.ownerDocument.defaultView ?? window;
const MediaElementCtor = win.HTMLMediaElement ?? globalThis.HTMLMediaElement;
if (typeof MediaElementCtor === "undefined" || !(mediaEl instanceof MediaElementCtor)) return;
if (!isMediaElement(mediaEl)) return;
const sourceDurationAttr =
el.getAttribute("data-source-duration") ?? mediaEl.getAttribute("data-source-duration");
@@ -0,0 +1,40 @@
import { isHtmlElement } from "@hyperframes/core/runtime/dom-realm";
import { parseCssColor } from "../../components/editor/colorValue";
import type { TimelineText } from "../store/timelineElement";
import {
getCuratedComputedStyles,
isEditableTextLeaf,
isTextBearingTag,
} from "../../components/editor/domEditingDom";
/** A layer that paints only text and a flat colour (a text tag whose children are all text leaves,
* no background image): its words and look. Anything painted beyond that keeps its picture. */
export function readTimelineText(el: Element): TimelineText | undefined {
if (!isHtmlElement(el) || !isTextBearingTag(el.localName)) return undefined;
const children = Array.from(el.children);
if (!children.every((child) => isHtmlElement(child) && isEditableTextLeaf(child)))
return undefined;
const value = el.textContent?.replace(/\s+/g, " ").trim();
if (!value) return undefined;
const style = getCuratedComputedStyles(el);
if ((style["background-image"] ?? "none") !== "none") return undefined;
const background = style["background-color"];
return {
value,
fontFamily: style["font-family"],
fontWeight: style["font-weight"],
color: style.color,
background: background && parseCssColor(background)?.alpha === 1 ? background : undefined,
};
}
export function sameTimelineText(a?: TimelineText, b?: TimelineText): boolean {
if (!a || !b) return a === b;
return (
a.value === b.value &&
a.fontFamily === b.fontFamily &&
a.fontWeight === b.fontWeight &&
a.color === b.color &&
a.background === b.background
);
}
@@ -95,6 +95,17 @@ export interface TimelineElement {
authoredStartIsMasterTime?: boolean;
/** Legacy marker for an inline sub-composition child; current rows never set it. */
expandedHostKey?: string;
/** A text layer's words and look, which its row draws live instead of a captured picture. */
text?: TimelineText;
}
export interface TimelineText {
value: string;
fontFamily?: string;
fontWeight?: string;
color?: string;
/** The layer's own opaque background colour, when it paints one. */
background?: string;
}
type RowClock = Pick<
@@ -156,6 +167,7 @@ export type TimelineElementPatch = Partial<
| "audioGroupHidden"
| "audioGroupFxChain"
| "audioGroupAutomation"
| "text"
>
>;
+3
View File
@@ -146,6 +146,9 @@
transparent 100%
);
--timeline-thumbnail-label-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
/* A text clip's strip behind its live words: dark for light text, light for dark text. */
--timeline-text-clip-dark-bg: #1c2028;
--timeline-text-clip-light-bg: #e9e6df;
--timeline-waveform-bar-rgb: 245, 237, 255;
--timeline-waveform-muted-rgb: 236, 238, 242;
--timeline-waveform-baseline-rgb: 226, 216, 238;
+8 -40
View File
@@ -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<import("puppeteer-core").Browser | nu
}
}
interface ScreenshotClip {
x: number;
y: number;
width: number;
height: number;
}
async function applyStudioRenderBodyScriptsToThumbnailPage(
page: import("puppeteer-core").Page,
projectDir: string,
@@ -235,37 +232,6 @@ async function prepareThumbnailPage(
await reapplyStudioRenderBodyScriptsToThumbnailPage(page);
}
async function resolveScreenshotClip(
page: import("puppeteer-core").Page,
selector: string | undefined,
selectorIndex: number | undefined,
): Promise<ScreenshotClip | undefined> {
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)
: undefined;
if (opts.signal.aborted) return null;
return await captureThumbnail(page, opts.format, clip);
} catch (error) {