Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion packages/cli/src/server/studioServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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" },
Expand Down Expand Up @@ -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();
Expand Down
28 changes: 15 additions & 13 deletions packages/cli/src/server/studioServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down
6 changes: 6 additions & 0 deletions packages/core/package-subpaths.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand Down
33 changes: 32 additions & 1 deletion packages/studio-server/src/helpers/screenshotClip.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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);
});
});
21 changes: 21 additions & 0 deletions packages/studio-server/src/helpers/screenshotClip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -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 = [];
}
6 changes: 5 additions & 1 deletion packages/studio-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 16 additions & 0 deletions packages/studio-server/src/routes/thumbnail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
4 changes: 2 additions & 2 deletions packages/studio-server/src/routes/thumbnail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)}`
Expand Down
4 changes: 4 additions & 0 deletions packages/studio/src/components/editor/domEditingDom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 1 addition & 5 deletions packages/studio/src/components/editor/domEditingLayers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ import {
getInlineStyles,
getSelectorIndex,
getSourceFileForElement,
isEditableTextLeaf,
isHtmlElement,
isTextBearingTag,
} from "./domEditingDom";
import {
findElementForSelection,
Expand All @@ -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;
Expand Down
65 changes: 65 additions & 0 deletions packages/studio/src/hooks/refreshTimelineRowText.test.ts
Original file line number Diff line number Diff line change
@@ -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",
]);
});
});
29 changes: 29 additions & 0 deletions packages/studio/src/hooks/refreshTimelineRowText.ts
Original file line number Diff line number Diff line change
@@ -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) });
}
Loading
Loading