Revert "feat: Persist Studio manual edits via manifest (#593)"

This reverts commit d0abe90a82.
This commit is contained in:
Miguel Ángel
2026-05-04 09:41:36 -07:00
parent 8d83d4f132
commit 26b8e2a985
82 changed files with 711 additions and 15345 deletions
@@ -255,11 +255,9 @@ describe("bundleToSingleHtml", () => {
const host = document.querySelector("#scene-host");
expect(host?.getAttribute("data-composition-id")).toBe("scene");
expect(host?.getAttribute("data-composition-file")).toBe("compositions/scene.html");
expect(host?.getAttribute("data-start")).toBe("intro");
expect(host?.getAttribute("data-width")).toBe("1920");
expect(host?.querySelector(".title")?.textContent).toBe("Scene");
expect(host?.querySelector(".title")?.closest("[data-composition-file]")).toBe(host);
expect(
Array.from(host?.children ?? []).some(
(child) => child.getAttribute("data-composition-id") === "scene",
+1 -18
View File
@@ -7,11 +7,7 @@ import {
parseHTMLContent,
stripEmbeddedRuntimeScripts,
} from "./htmlDocument";
import {
rewriteAssetPaths,
rewriteCssAssetUrls,
rewriteInlineStyleAssetUrls,
} from "./rewriteSubCompPaths";
import { rewriteAssetPaths, rewriteCssAssetUrls } from "./rewriteSubCompPaths";
import { scopeCssToComposition, wrapScopedCompositionScript } from "./compositionScoping";
import { validateHyperframeHtmlContract } from "./staticGuard";
@@ -505,31 +501,18 @@ export async function bundleToSingleHtml(
el.setAttribute(attr, val);
},
);
const styledEls = innerRoot
? innerRoot.querySelectorAll("[style]")
: contentDoc.querySelectorAll("[style]");
rewriteInlineStyleAssetUrls(
styledEls,
src,
(el: Element) => el.getAttribute("style"),
(el: Element, val: string) => {
el.setAttribute("style", val);
},
);
if (innerRoot) {
const innerW = innerRoot.getAttribute("data-width");
const innerH = innerRoot.getAttribute("data-height");
if (innerW && !hostEl.getAttribute("data-width")) hostEl.setAttribute("data-width", innerW);
if (innerH && !hostEl.getAttribute("data-height")) hostEl.setAttribute("data-height", innerH);
innerRoot.setAttribute("data-composition-file", src);
for (const child of [...innerRoot.querySelectorAll("style, script")]) child.remove();
hostEl.innerHTML = compId ? innerRoot.innerHTML || "" : innerRoot.outerHTML || "";
} else {
for (const child of [...contentDoc.querySelectorAll("style, script")]) child.remove();
hostEl.innerHTML = contentDoc.body.innerHTML || "";
}
hostEl.setAttribute("data-composition-file", src);
hostEl.removeAttribute("data-composition-src");
}
@@ -1,9 +1,5 @@
import { describe, expect, it } from "vitest";
import {
rewriteAssetPath,
rewriteCssAssetUrls,
rewriteInlineStyleAssetUrls,
} from "./rewriteSubCompPaths.js";
import { rewriteAssetPath, rewriteCssAssetUrls } from "./rewriteSubCompPaths.js";
describe("rewriteAssetPath", () => {
it("rewrites `../` against the sub-composition dir", () => {
@@ -40,19 +36,4 @@ describe("rewriteAssetPath", () => {
expect(out).not.toMatch(/\\/);
expect(out).not.toMatch(/:\\/);
});
it("rewrites CSS urls inside inline style attributes", () => {
const elements = [{ style: `background-image: url("../cover.png")` }];
rewriteInlineStyleAssetUrls(
elements,
"compositions/scene.html",
(el) => el.style,
(el, value) => {
el.style = value;
},
);
expect(elements[0]?.style).toBe(`background-image: url("cover.png")`);
});
});
@@ -96,28 +96,6 @@ export function rewriteAssetPaths<T>(
}
}
/**
* Rewrite CSS url(...) references inside inline style attributes.
*/
export function rewriteInlineStyleAssetUrls<T>(
elements: Iterable<T>,
compSrcPath: string,
getStyle: (el: T) => string | null | undefined,
setStyle: (el: T, value: string) => void,
): void {
const compDir = dirname(compSrcPath);
if (!compDir || compDir === ".") return;
for (const el of elements) {
const style = getStyle(el);
if (!style) continue;
const rewritten = rewriteCssAssetUrls(style, compSrcPath);
if (rewritten !== style) {
setStyle(el, rewritten);
}
}
}
/**
* Rewrite CSS url(...) references in a sub-composition's inline styles so
* ../foo.woff2 remains valid after the CSS is hoisted into the root document.
@@ -50,28 +50,6 @@ describe("caption rules", () => {
expect(finding).toBeUndefined();
});
it("does not warn for generic GSAP opacity exits in non-caption loops", () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080">
<script>
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
var sceneCaption = document.querySelector("#scene-caption");
CARDS.forEach(function(group, gi) {
var groupEl = document.createElement("div");
groupEl.id = "card-" + gi;
tl.to(groupEl, { opacity: 0, duration: 0.12 }, 2);
});
window.__timelines["main"] = tl;
</script>
</div>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "caption_exit_missing_hard_kill");
expect(finding).toBeUndefined();
});
it("warns when caption group has nowrap without max-width", () => {
const html = `
<html><body>
+1 -2
View File
@@ -12,8 +12,7 @@ export const captionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]>
content,
);
const hasCaptionLoop =
/forEach|\.forEach\s*\(/.test(content) &&
/karaoke|caption[-_]?(?:group|word|line|block)|cg-/.test(content);
/forEach|\.forEach\s*\(/.test(content) && /createElement|caption|group|cg-/.test(content);
if (hasCaptionLoop && hasExitTween && !hasHardKill) {
findings.push({
code: "caption_exit_missing_hard_kill",
@@ -7,7 +7,6 @@ import { registerLintRoutes } from "./routes/lint.js";
import { registerRenderRoutes } from "./routes/render.js";
import { registerThumbnailRoutes } from "./routes/thumbnail.js";
import { registerWaveformRoutes } from "./routes/waveform.js";
import { registerFontRoutes } from "./routes/fonts.js";
/**
* Create a Hono sub-app with all studio API routes.
@@ -25,7 +24,6 @@ export function createStudioApi(adapter: StudioApiAdapter): Hono {
registerRenderRoutes(api, adapter);
registerThumbnailRoutes(api, adapter);
registerWaveformRoutes(api, adapter);
registerFontRoutes(api);
return api;
}
@@ -1,382 +0,0 @@
import { describe, expect, it } from "vitest";
import { Window } from "happy-dom";
import { createStudioManualEditsRenderBodyScript } from "./manualEditsRenderScript";
function runScript(
window: Window,
script: string,
getComputedStyle: typeof window.getComputedStyle = window.getComputedStyle.bind(window),
timers: {
setInterval?: typeof globalThis.setInterval;
clearInterval?: typeof globalThis.clearInterval;
} = {},
): void {
const execute = new Function(
"window",
"document",
"HTMLElement",
"getComputedStyle",
"setInterval",
"clearInterval",
script,
);
execute(
window,
window.document,
window.HTMLElement,
getComputedStyle,
timers.setInterval ??
(((callback: TimerHandler) => {
void callback;
return 0 as never;
}) as typeof globalThis.setInterval),
timers.clearInterval ?? globalThis.clearInterval,
);
}
describe("createStudioManualEditsRenderBodyScript", () => {
it("returns null for an empty manifest", () => {
expect(createStudioManualEditsRenderBodyScript("")).toBeNull();
});
it("applies manual edits and reapplies them after render seeks", () => {
const window = new Window();
window.document.body.innerHTML = '<div id="card" style="width: 20px; height: 20px"></div>';
const card = window.document.getElementById("card");
if (!(card instanceof window.HTMLElement)) {
throw new Error("card fixture missing");
}
let seekCalls = 0;
(
window as unknown as {
__hf: { seek: (time: number) => void };
}
).__hf = {
seek: () => {
seekCalls += 1;
card.style.removeProperty("translate");
},
};
const script = createStudioManualEditsRenderBodyScript(
JSON.stringify({
version: 1,
edits: [
{
kind: "path-offset",
target: { sourceFile: "index.html", id: "card" },
x: 12,
y: 24,
},
{
kind: "box-size",
target: { sourceFile: "index.html", id: "card" },
width: 120,
height: 64,
},
{
kind: "rotation",
target: { sourceFile: "index.html", id: "card" },
angle: 15,
},
],
}),
);
if (!script) throw new Error("script fixture missing");
const computedStyle = (element: Element) =>
({
display: element === card ? "block" : "block",
flexDirection: "row",
}) as CSSStyleDeclaration;
const intervalCallbacks: Array<() => void> = [];
runScript(window, script, computedStyle, {
setInterval: ((callback: TimerHandler) => {
if (typeof callback === "function") intervalCallbacks.push(callback as () => void);
return 0 as never;
}) as typeof globalThis.setInterval,
});
expect(card.style.getPropertyValue("translate")).toContain("--hf-studio-offset-x");
expect(card.style.getPropertyValue("width")).toBe("120px");
expect(card.style.getPropertyValue("height")).toBe("64px");
expect(card.style.getPropertyValue("rotate")).toContain("--hf-studio-rotation");
expect(card.style.getPropertyValue("transform-origin")).toBe("center center");
(
window as unknown as {
__hf: { seek: (time: number) => void };
}
).__hf.seek(1);
expect(seekCalls).toBe(1);
expect(card.style.getPropertyValue("translate")).toContain("--hf-studio-offset-x");
(
window as unknown as {
__hf: { seek: (time: number) => void };
}
).__hf.seek = () => {
card.style.removeProperty("rotate");
};
intervalCallbacks.forEach((callback) => callback());
(
window as unknown as {
__hf: { seek: (time: number) => void };
}
).__hf.seek(2);
expect(card.style.getPropertyValue("rotate")).toContain("--hf-studio-rotation");
(
window as unknown as {
__player: { renderSeek: (time: number) => void };
}
).__player = {
renderSeek: () => {
card.style.removeProperty("rotate");
},
};
intervalCallbacks.forEach((callback) => callback());
(
window as unknown as {
__player: { renderSeek: (time: number) => void };
}
).__player.renderSeek(3);
expect(card.style.getPropertyValue("rotate")).toContain("--hf-studio-rotation");
});
it("applies render edits to the matching source file target", () => {
const window = new Window();
window.document.body.innerHTML = `
<div data-composition-id="root">
<div id="card"></div>
<div data-composition-id="nested" data-composition-file="scenes/nested.html">
<div id="card"></div>
</div>
</div>
`;
const cards = Array.from(window.document.getElementsByTagName("*")).filter(
(element): element is HTMLElement =>
element instanceof window.HTMLElement && element.id === "card",
);
const rootCard = cards[0];
const nestedCard = cards[1];
if (!rootCard || !nestedCard) {
throw new Error("source-scoped render fixture missing");
}
const script = createStudioManualEditsRenderBodyScript(
JSON.stringify({
version: 1,
edits: [
{
kind: "rotation",
target: { sourceFile: "scenes/nested.html", id: "card" },
angle: 21,
},
],
}),
);
if (!script) throw new Error("script fixture missing");
runScript(window, script);
expect(rootCard.style.getPropertyValue("rotate")).toBe("");
expect(nestedCard.style.getPropertyValue("rotate")).toContain("--hf-studio-rotation");
});
it("applies render edits inside composition-file hosts without composition ids", () => {
const window = new Window();
window.document.body.innerHTML = `
<div data-composition-id="root">
<div id="card"></div>
<div data-composition-file="scenes/anonymous.html">
<div id="card"></div>
</div>
</div>
`;
const cards = Array.from(window.document.getElementsByTagName("*")).filter(
(element): element is HTMLElement =>
element instanceof window.HTMLElement && element.id === "card",
);
const rootCard = cards[0];
const nestedCard = cards[1];
if (!rootCard || !nestedCard) {
throw new Error("anonymous composition render fixture missing");
}
const script = createStudioManualEditsRenderBodyScript(
JSON.stringify({
version: 1,
edits: [
{
kind: "path-offset",
target: { sourceFile: "scenes/anonymous.html", id: "card" },
x: 12,
y: 24,
},
],
}),
);
if (!script) throw new Error("script fixture missing");
runScript(window, script);
expect(rootCard.style.getPropertyValue("translate")).toBe("");
expect(nestedCard.style.getPropertyValue("translate")).toContain("--hf-studio-offset-x");
});
it("uses the active composition path as the unscoped document fallback", () => {
const window = new Window();
window.document.body.innerHTML = `<div id="card"></div>`;
const card = window.document.getElementById("card");
if (!(card instanceof window.HTMLElement)) {
throw new Error("card fixture missing");
}
const script = createStudioManualEditsRenderBodyScript(
JSON.stringify({
version: 1,
edits: [
{
kind: "path-offset",
target: { sourceFile: "compositions/scene-2.html", id: "card" },
x: 12,
y: 24,
},
],
}),
{ activeCompositionPath: "compositions/scene-2.html" },
);
if (!script) throw new Error("script fixture missing");
runScript(window, script);
expect(card.style.getPropertyValue("translate")).toContain("--hf-studio-offset-x");
});
it("preserves computed transform longhands as render edit bases", () => {
const window = new Window();
window.document.body.innerHTML = `<div id="card"></div>`;
const card = window.document.getElementById("card");
if (!(card instanceof window.HTMLElement)) {
throw new Error("card fixture missing");
}
const script = createStudioManualEditsRenderBodyScript(
JSON.stringify({
version: 1,
edits: [
{
kind: "path-offset",
target: { sourceFile: "index.html", id: "card" },
x: 12,
y: 24,
},
{
kind: "rotation",
target: { sourceFile: "index.html", id: "card" },
angle: 15,
},
],
}),
);
if (!script) throw new Error("script fixture missing");
const computedStyle = (element: Element) =>
({
getPropertyValue: (property: string) => {
if (element !== card) return "";
if (property === "translate") return "10px 20px";
if (property === "rotate") return "8deg";
return "";
},
}) as CSSStyleDeclaration;
runScript(window, script, computedStyle);
expect(card.style.getPropertyValue("translate")).toContain("calc(10px +");
expect(card.style.getPropertyValue("translate")).toContain("calc(20px +");
expect(card.style.getPropertyValue("rotate")).toContain("8deg");
expect(card.style.getPropertyValue("rotate")).toContain("--hf-studio-rotation");
expect(card.style.getPropertyValue("transform-origin")).toBe("center center");
});
it("does not compound stale studio variables during render reapply", () => {
const window = new Window();
window.document.body.innerHTML = `
<div id="card" style="
translate: var(--hf-studio-offset-x, 0px) var(--hf-studio-offset-y, 0px);
rotate: var(--hf-studio-rotation, 0deg);
"></div>
`;
const card = window.document.getElementById("card");
if (!(card instanceof window.HTMLElement)) {
throw new Error("card fixture missing");
}
const script = createStudioManualEditsRenderBodyScript(
JSON.stringify({
version: 1,
edits: [
{
kind: "path-offset",
target: { sourceFile: "index.html", id: "card" },
x: 12,
y: 24,
},
{
kind: "rotation",
target: { sourceFile: "index.html", id: "card" },
angle: 15,
},
],
}),
);
if (!script) throw new Error("script fixture missing");
runScript(window, script);
expect(card.style.getPropertyValue("translate")).toBe(
"var(--hf-studio-offset-x, 0px) var(--hf-studio-offset-y, 0px)",
);
expect(card.style.getPropertyValue("rotate")).toBe("var(--hf-studio-rotation, 0deg)");
});
it("exposes a render reapply hook for thumbnails after layout settles", () => {
const window = new Window();
window.document.body.innerHTML = `<div id="card"></div>`;
const card = window.document.getElementById("card");
if (!(card instanceof window.HTMLElement)) {
throw new Error("card fixture missing");
}
const script = createStudioManualEditsRenderBodyScript(
JSON.stringify({
version: 1,
edits: [
{
kind: "path-offset",
target: { sourceFile: "index.html", id: "card" },
x: 12,
y: 24,
},
],
}),
);
if (!script) throw new Error("script fixture missing");
runScript(window, script);
card.style.removeProperty("translate");
(
window as unknown as {
__hfStudioManualEditsApply?: () => number;
}
).__hfStudioManualEditsApply?.();
expect(card.style.getPropertyValue("translate")).toContain("--hf-studio-offset-x");
});
});
@@ -1,369 +0,0 @@
export interface StudioManualEditsRenderScriptOptions {
activeCompositionPath?: string | null;
}
export function createStudioManualEditsRenderBodyScript(
manifestContent: string,
options: StudioManualEditsRenderScriptOptions = {},
): string | null {
if (!manifestContent.trim()) return null;
return `(${studioManualEditsRenderRuntime.toString()})(${JSON.stringify(manifestContent)}, ${JSON.stringify(options.activeCompositionPath ?? null)});`;
}
function studioManualEditsRenderRuntime(
manifestContent: string,
activeCompositionPath: string | null,
): void {
const OFFSET_X_PROP = "--hf-studio-offset-x";
const OFFSET_Y_PROP = "--hf-studio-offset-y";
const WIDTH_PROP = "--hf-studio-width";
const HEIGHT_PROP = "--hf-studio-height";
const ROTATION_PROP = "--hf-studio-rotation";
const PATH_OFFSET_ATTR = "data-hf-studio-path-offset";
const BOX_SIZE_ATTR = "data-hf-studio-box-size";
const ROTATION_ATTR = "data-hf-studio-rotation";
const ORIGINAL_TRANSLATE_ATTR = "data-hf-studio-original-translate";
const ORIGINAL_ROTATE_ATTR = "data-hf-studio-original-rotate";
const WRAPPED_SEEK_PROP = "__hfStudioManualEditsWrapped";
const ROTATION_TRANSFORM_ORIGIN = "center center";
const finiteNumber = (value: unknown): number | null =>
typeof value === "number" && Number.isFinite(value) ? value : null;
const objectRecord = (value: unknown): Record<string, unknown> | null =>
value && typeof value === "object" ? (value as Record<string, unknown>) : null;
const runtimeWindow = window as Window & {
__hf?: { seek?: (time: number) => unknown };
__hfStudioManualEditsApply?: () => number;
__player?: { renderSeek?: (time: number) => unknown };
};
const parsedManifest = (() => {
try {
return objectRecord(JSON.parse(manifestContent));
} catch {
return null;
}
})();
const manifestEdits = Array.isArray(parsedManifest?.edits) ? parsedManifest.edits : [];
if (manifestEdits.length === 0) return;
const sourceFileForElement = (element: HTMLElement): string => {
let current: HTMLElement | null = element;
while (current) {
const sourceFile =
current.getAttribute("data-composition-file") ??
current.getAttribute("data-composition-src");
if (sourceFile) return sourceFile;
current = current.parentElement;
}
return activeCompositionPath ?? "index.html";
};
const elementMatchesSourceFile = (element: HTMLElement, sourceFile: string): boolean =>
sourceFileForElement(element) === sourceFile;
const styleUsesStudioOffset = (value: string): boolean =>
value.includes(OFFSET_X_PROP) || value.includes(OFFSET_Y_PROP);
const styleUsesStudioRotation = (value: string): boolean => value.includes(ROTATION_PROP);
const splitTopLevelWhitespace = (value: string): string[] => {
const parts: string[] = [];
let depth = 0;
let current = "";
for (const char of value.trim()) {
if (char === "(") depth += 1;
if (char === ")") depth = Math.max(0, depth - 1);
if (/\s/.test(char) && depth === 0) {
if (current) parts.push(current);
current = "";
} else {
current += char;
}
}
if (current) parts.push(current);
return parts;
};
const composeTranslate = (element: HTMLElement, x: string, y: string): string => {
const original = element.getAttribute(ORIGINAL_TRANSLATE_ATTR)?.trim();
if (!original || original === "none") return `${x} ${y}`;
const parts = splitTopLevelWhitespace(original);
if (parts.length === 1) return `calc(${parts[0]} + ${x}) ${y}`;
if (parts.length === 2) return `calc(${parts[0]} + ${x}) calc(${parts[1]} + ${y})`;
if (parts.length === 3) {
return `calc(${parts[0]} + ${x}) calc(${parts[1]} + ${y}) ${parts[2]}`;
}
return `${x} ${y}`;
};
const readStyleOrComputed = (element: HTMLElement, property: string): string => {
try {
return (
element.style.getPropertyValue(property) ||
getComputedStyle(element).getPropertyValue(property)
);
} catch {
return element.style.getPropertyValue(property);
}
};
const readTransformLonghandBase = (
element: HTMLElement,
property: "translate" | "rotate",
): string => {
const value = readStyleOrComputed(element, property).trim();
return value === "none" ? "" : value;
};
const preparePathOffsetBase = (element: HTMLElement): void => {
const currentTranslate = readTransformLonghandBase(element, "translate");
const hasMarker = element.hasAttribute(PATH_OFFSET_ATTR);
const wasResetByAnimation = !styleUsesStudioOffset(currentTranslate);
if (!hasMarker) {
element.setAttribute(ORIGINAL_TRANSLATE_ATTR, wasResetByAnimation ? currentTranslate : "");
} else if (wasResetByAnimation) {
element.setAttribute(ORIGINAL_TRANSLATE_ATTR, currentTranslate);
}
};
const prepareRotationBase = (element: HTMLElement): void => {
const currentRotate = readTransformLonghandBase(element, "rotate");
const hasMarker = element.hasAttribute(ROTATION_ATTR);
const wasResetByAnimation = !styleUsesStudioRotation(currentRotate);
if (!hasMarker) {
element.setAttribute(ORIGINAL_ROTATE_ATTR, wasResetByAnimation ? currentRotate : "");
} else if (wasResetByAnimation) {
element.setAttribute(ORIGINAL_ROTATE_ATTR, currentRotate);
}
};
const querySelectorCandidates = (selector: string): HTMLElement[] => {
const isCandidate = (element: Element): element is HTMLElement =>
element instanceof HTMLElement;
const className = selector.match(/^\.([A-Za-z0-9_-]+)$/)?.[1];
if (className) {
return Array.from(document.getElementsByTagName("*")).filter(
(element): element is HTMLElement =>
isCandidate(element) && element.classList.contains(className),
);
}
if (/^[A-Za-z][A-Za-z0-9-]*$/.test(selector)) {
return Array.from(document.getElementsByTagName(selector)).filter(isCandidate);
}
return Array.from(document.querySelectorAll(selector)).filter(isCandidate);
};
const resolveTarget = (edit: Record<string, unknown>): HTMLElement | null => {
const targetRecord = objectRecord(edit.target);
if (!targetRecord) return null;
const sourceFile = typeof targetRecord.sourceFile === "string" ? targetRecord.sourceFile : "";
if (!sourceFile) return null;
const id = typeof targetRecord.id === "string" ? targetRecord.id : "";
if (id) {
const byId = document.getElementById(id);
if (byId instanceof HTMLElement && elementMatchesSourceFile(byId, sourceFile)) return byId;
const matchesById = [
document.documentElement,
...Array.from(document.getElementsByTagName("*")),
].filter(
(element): element is HTMLElement =>
element instanceof HTMLElement &&
element.id === id &&
elementMatchesSourceFile(element, sourceFile),
);
if (matchesById[0]) return matchesById[0];
}
const selector = typeof targetRecord.selector === "string" ? targetRecord.selector : "";
if (!selector) return null;
try {
const matches = querySelectorCandidates(selector).filter((element) =>
elementMatchesSourceFile(element, sourceFile),
);
const selectorIndex = finiteNumber(targetRecord.selectorIndex) ?? 0;
return matches[Math.max(0, Math.floor(selectorIndex))] ?? null;
} catch {
return null;
}
};
const roundRotationAngle = (angle: number): number => Math.round(angle * 10) / 10;
const isSimpleRotateAngle = (value: string): boolean =>
/^-?(?:\d+(?:\.\d+)?|\.\d+)(?:deg|rad|turn|grad)$/.test(value.trim());
const composeRotation = (element: HTMLElement, rotationValue: string): string => {
const original = element.getAttribute(ORIGINAL_ROTATE_ATTR)?.trim();
if (!original || original === "none" || !isSimpleRotateAngle(original)) {
return rotationValue;
}
return `calc(${original} + ${rotationValue})`;
};
const applyPathOffset = (element: HTMLElement, edit: Record<string, unknown>): void => {
const x = finiteNumber(edit.x);
const y = finiteNumber(edit.y);
if (x == null || y == null) return;
preparePathOffsetBase(element);
element.setAttribute(PATH_OFFSET_ATTR, "true");
element.style.setProperty(OFFSET_X_PROP, `${Math.round(x)}px`);
element.style.setProperty(OFFSET_Y_PROP, `${Math.round(y)}px`);
element.style.setProperty(
"translate",
composeTranslate(element, `var(${OFFSET_X_PROP}, 0px)`, `var(${OFFSET_Y_PROP}, 0px)`),
);
};
const readParentFlexBasisPixels = (
element: HTMLElement,
size: { width: number; height: number },
): number | null => {
const parent = element.parentElement;
if (!parent) return null;
const styles = getComputedStyle(parent);
if (styles.display !== "flex" && styles.display !== "inline-flex") return null;
return Math.round(
Math.max(1, styles.flexDirection.startsWith("column") ? size.height : size.width),
);
};
const applyBoxSize = (element: HTMLElement, edit: Record<string, unknown>): void => {
const width = finiteNumber(edit.width);
const height = finiteNumber(edit.height);
if (width == null || height == null || width <= 0 || height <= 0) return;
const rounded = {
width: Math.round(Math.max(1, width)),
height: Math.round(Math.max(1, height)),
};
element.setAttribute(BOX_SIZE_ATTR, "true");
element.style.setProperty(WIDTH_PROP, `${rounded.width}px`);
element.style.setProperty(HEIGHT_PROP, `${rounded.height}px`);
element.style.setProperty("box-sizing", "border-box");
element.style.setProperty("width", `${rounded.width}px`);
element.style.setProperty("height", `${rounded.height}px`);
element.style.setProperty("min-width", "0px");
element.style.setProperty("min-height", "0px");
element.style.setProperty("max-width", "none");
element.style.setProperty("max-height", "none");
const flexBasis = readParentFlexBasisPixels(element, rounded);
if (flexBasis != null) {
element.style.setProperty("flex-basis", `${flexBasis}px`);
element.style.setProperty("flex-grow", "0");
element.style.setProperty("flex-shrink", "0");
}
if (getComputedStyle(element).display === "inline") {
element.style.setProperty("display", "inline-block");
}
};
const applyRotation = (element: HTMLElement, edit: Record<string, unknown>): void => {
const angle = finiteNumber(edit.angle);
if (angle == null) return;
prepareRotationBase(element);
element.setAttribute(ROTATION_ATTR, "true");
element.style.setProperty(ROTATION_PROP, `${roundRotationAngle(angle)}deg`);
element.style.setProperty("transform-origin", ROTATION_TRANSFORM_ORIGIN);
element.style.setProperty("rotate", composeRotation(element, `var(${ROTATION_PROP}, 0deg)`));
};
const applyManifest = (): number => {
let applied = 0;
for (const edit of manifestEdits) {
const editRecord = objectRecord(edit);
if (!editRecord) continue;
const element = resolveTarget(editRecord);
if (!element) continue;
if (editRecord.kind === "path-offset") applyPathOffset(element, editRecord);
if (editRecord.kind === "box-size") applyBoxSize(element, editRecord);
if (editRecord.kind === "rotation") applyRotation(element, editRecord);
applied += 1;
}
return applied;
};
runtimeWindow.__hfStudioManualEditsApply = applyManifest;
const markWrapped = (fn: (time: number) => unknown): void => {
try {
Object.defineProperty(fn, WRAPPED_SEEK_PROP, {
configurable: false,
enumerable: false,
value: true,
});
} catch {
try {
(fn as unknown as Record<string, unknown>)[WRAPPED_SEEK_PROP] = true;
} catch {
// Ignore non-extensible functions.
}
}
};
const isWrapped = (fn: (time: number) => unknown): boolean =>
Boolean((fn as unknown as Record<string, unknown>)[WRAPPED_SEEK_PROP]);
const wrapFunction = (
get: () => ((time: number) => unknown) | undefined,
set: (fn: (time: number) => unknown) => void,
): boolean => {
const fn = get();
if (!fn) return false;
const seek = fn as (time: number) => unknown;
if (isWrapped(seek)) {
applyManifest();
return true;
}
const wrappedSeek = function (this: unknown, time: number): unknown {
const result = seek.call(this, time);
applyManifest();
return result;
};
markWrapped(wrappedSeek);
set(wrappedSeek);
applyManifest();
return true;
};
const wrapSeekFunctions = (): boolean => {
const wrappedHfSeek = wrapFunction(
() => runtimeWindow.__hf?.seek,
(fn) => {
if (runtimeWindow.__hf) runtimeWindow.__hf.seek = fn;
},
);
const wrappedPlayerRenderSeek = wrapFunction(
() => runtimeWindow.__player?.renderSeek,
(fn) => {
if (runtimeWindow.__player) runtimeWindow.__player.renderSeek = fn;
},
);
return wrappedHfSeek || wrappedPlayerRenderSeek;
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => applyManifest(), { once: true });
} else {
applyManifest();
}
wrapSeekFunctions();
let remainingSeekWrapAttempts = 120;
const seekWrapInterval = setInterval(() => {
wrapSeekFunctions();
remainingSeekWrapAttempts -= 1;
if (remainingSeekWrapAttempts <= 0) clearInterval(seekWrapInterval);
}, 50);
}
@@ -5,15 +5,8 @@ export interface ScreenshotClip {
height: number;
}
export function getElementScreenshotClip(
selector: string,
selectorIndex?: number,
): ScreenshotClip | undefined {
const matches = Array.from(document.querySelectorAll(selector)).filter(
(el): el is HTMLElement => el instanceof HTMLElement,
);
const safeIndex = Math.max(0, Math.min(matches.length - 1, Math.floor(selectorIndex ?? 0)));
const el = matches[safeIndex] ?? null;
export function getElementScreenshotClip(selector: string): ScreenshotClip | undefined {
const el = document.querySelector(selector);
if (!(el instanceof HTMLElement)) return undefined;
const rect = el.getBoundingClientRect();
if (rect.width < 4 || rect.height < 4) return undefined;
@@ -23,7 +23,6 @@ describe("buildSubCompositionHtml", () => {
"compositions/hero.html": `<template id="hero-template">
<div data-composition-id="hero" data-width="1920" data-height="1080">
<img src="../logo.png" alt="Logo" />
<div style="background-image: url('../poster.png')"></div>
<style>
@font-face {
font-family: "Brand Sans";
@@ -43,10 +42,8 @@ describe("buildSubCompositionHtml", () => {
expect(html).toContain('<base href="/api/projects/demo/preview/">');
expect(html).toContain('src="logo.png"');
expect(html).toContain("background-image: url('poster.png')");
expect(html).toContain('url("fonts/brand.woff2")');
expect(html).not.toContain('src="../logo.png"');
expect(html).not.toContain("url('../poster.png')");
expect(html).not.toContain('url("../fonts/brand.woff2")');
});
});
@@ -1,11 +1,7 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { parseHTML } from "linkedom";
import {
rewriteAssetPaths,
rewriteCssAssetUrls,
rewriteInlineStyleAssetUrls,
} from "../../compiler/rewriteSubCompPaths.js";
import { rewriteAssetPaths, rewriteCssAssetUrls } from "../../compiler/rewriteSubCompPaths.js";
/**
* Build a standalone HTML page for a sub-composition.
@@ -40,14 +36,6 @@ export function buildSubCompositionHtml(
el.setAttribute(attr, value);
},
);
rewriteInlineStyleAssetUrls(
contentDoc.querySelectorAll("[style]"),
compPath,
(el: Element) => el.getAttribute("style"),
(el: Element, value: string) => {
el.setAttribute("style", value);
},
);
for (const styleEl of contentDoc.querySelectorAll("style")) {
styleEl.textContent = rewriteCssAssetUrls(styleEl.textContent || "", compPath);
}
-4
View File
@@ -4,7 +4,3 @@ export { isSafePath, walkDir } from "./helpers/safePath.js";
export { getMimeType, MIME_TYPES } from "./helpers/mime.js";
export { buildSubCompositionHtml } from "./helpers/subComposition.js";
export { getElementScreenshotClip, type ScreenshotClip } from "./helpers/screenshotClip.js";
export {
createStudioManualEditsRenderBodyScript,
type StudioManualEditsRenderScriptOptions,
} from "./helpers/manualEditsRenderScript.js";
@@ -1,247 +0,0 @@
import { execFileSync } from "node:child_process";
import { existsSync, readdirSync, statSync } from "node:fs";
import { homedir, platform } from "node:os";
import { join } from "node:path";
import type { Hono } from "hono";
const FONT_EXT_RE = /\.(otf|ttf|ttc|woff2?)$/i;
const MAX_FONT_RESULTS = 2000;
const GOOGLE_FONTS_METADATA_URL = "https://fonts.google.com/metadata/fonts";
const GOOGLE_FONTS_FETCH_TIMEOUT_MS = 3000;
let cachedFonts: string[] | null = null;
let cachedGoogleFonts: string[] | null = null;
const STYLE_SUFFIXES = new Set([
"black",
"bold",
"book",
"condensed",
"demi",
"demibold",
"display",
"extra",
"extrabold",
"hairline",
"heavy",
"italic",
"light",
"medium",
"normal",
"regular",
"roman",
"semibold",
"thin",
"ultra",
"ultralight",
]);
const GOOGLE_FONT_FALLBACKS = [
"Inter",
"Roboto",
"Open Sans",
"Montserrat",
"Poppins",
"Lato",
"Oswald",
"Raleway",
"Nunito",
"Playfair Display",
"Merriweather",
"Source Sans 3",
"Source Serif 4",
"Source Code Pro",
"DM Sans",
"Space Grotesk",
"Space Mono",
"Bebas Neue",
"Outfit",
"JetBrains Mono",
];
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function fontDirectories(): string[] {
const home = homedir();
if (platform() === "darwin") {
return [
join(home, "Library", "Fonts"),
"/Library/Fonts",
"/System/Library/Fonts",
"/System/Library/Fonts/Supplemental",
];
}
if (platform() === "win32") {
return [join(process.env.WINDIR || "C:\\Windows", "Fonts")];
}
return [
join(home, ".fonts"),
join(home, ".local", "share", "fonts"),
"/usr/local/share/fonts",
"/usr/share/fonts",
];
}
function toFamilyName(fileName: string): string | null {
const withoutExt = fileName.replace(FONT_EXT_RE, "");
if (!withoutExt || withoutExt.startsWith(".")) return null;
const spaced = withoutExt
.replace(/[_-]+/g, " ")
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
.replace(/\s+/g, " ")
.trim();
const words = spaced.split(" ").filter(Boolean);
while (words.length > 1 && STYLE_SUFFIXES.has((words.at(-1) ?? "").toLowerCase())) {
words.pop();
}
const family = words.join(" ").trim();
return family.length >= 2 ? family : null;
}
function collectMacSystemProfilerFonts(): string[] {
if (platform() !== "darwin") return [];
let parsed: unknown;
try {
const raw = execFileSync("system_profiler", ["SPFontsDataType", "-json"], {
encoding: "utf8",
maxBuffer: 12 * 1024 * 1024,
timeout: 5000,
});
parsed = JSON.parse(raw);
} catch {
return [];
}
if (!isRecord(parsed) || !Array.isArray(parsed.SPFontsDataType)) return [];
const fonts: string[] = [];
for (const fontEntry of parsed.SPFontsDataType) {
if (!isRecord(fontEntry)) continue;
const typefaces = fontEntry.typefaces;
if (!Array.isArray(typefaces)) continue;
for (const typeface of typefaces) {
if (!isRecord(typeface)) continue;
const family = typeface.family;
const fullName = typeface.fullname;
const name = typeface._name;
if (typeof family === "string" && family.trim()) {
fonts.push(family.trim());
} else if (typeof fullName === "string" && fullName.trim()) {
fonts.push(fullName.trim());
} else if (typeof name === "string" && name.trim()) {
fonts.push(name.trim());
}
}
}
return fonts;
}
function collectFontsFromDir(dir: string, depth = 0): string[] {
if (!existsSync(dir) || depth > 2) return [];
const fonts: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
fonts.push(...collectFontsFromDir(fullPath, depth + 1));
continue;
}
if (!entry.isFile() || !FONT_EXT_RE.test(entry.name)) continue;
try {
if (!statSync(fullPath).isFile()) continue;
} catch {
continue;
}
const family = toFamilyName(entry.name);
if (family) fonts.push(family);
}
return fonts;
}
function listInstalledFontFamilies(): string[] {
if (cachedFonts) return cachedFonts;
const families = new Set<string>();
for (const family of collectMacSystemProfilerFonts()) {
families.add(family);
if (families.size >= MAX_FONT_RESULTS) break;
}
for (const dir of fontDirectories()) {
for (const family of collectFontsFromDir(dir)) {
families.add(family);
if (families.size >= MAX_FONT_RESULTS) break;
}
if (families.size >= MAX_FONT_RESULTS) break;
}
cachedFonts = Array.from(families).sort((a, b) => a.localeCompare(b));
return cachedFonts;
}
function parseGoogleFontMetadata(value: unknown): string[] {
if (!isRecord(value) || !Array.isArray(value.familyMetadataList)) return [];
const families: string[] = [];
for (const entry of value.familyMetadataList) {
if (!isRecord(entry) || typeof entry.family !== "string") continue;
families.push(entry.family);
}
return families;
}
function stripGoogleJsonGuard(raw: string): string {
const prefix = ")]}'";
if (!raw.startsWith(prefix)) return raw;
let index = prefix.length;
while (
index < raw.length &&
(raw[index] === " " ||
raw[index] === "\n" ||
raw[index] === "\r" ||
raw[index] === "\t" ||
raw[index] === "\f")
) {
index += 1;
}
return raw.slice(index);
}
async function listGoogleFontFamilies(): Promise<string[]> {
if (cachedGoogleFonts) return cachedGoogleFonts;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), GOOGLE_FONTS_FETCH_TIMEOUT_MS);
try {
const response = await fetch(GOOGLE_FONTS_METADATA_URL, { signal: controller.signal });
if (!response.ok) {
cachedGoogleFonts = GOOGLE_FONT_FALLBACKS;
return cachedGoogleFonts;
}
const raw = await response.text();
const jsonText = stripGoogleJsonGuard(raw);
const families = parseGoogleFontMetadata(JSON.parse(jsonText));
cachedGoogleFonts = families.length > 0 ? families : GOOGLE_FONT_FALLBACKS;
} catch {
cachedGoogleFonts = GOOGLE_FONT_FALLBACKS;
} finally {
clearTimeout(timer);
}
return cachedGoogleFonts;
}
export function registerFontRoutes(api: Hono): void {
api.get("/fonts", (c) => c.json({ fonts: listInstalledFontFamilies() }));
api.get("/fonts/google", async (c) => c.json({ fonts: await listGoogleFontFamilies() }));
}
@@ -25,6 +25,6 @@ export function registerProjectRoutes(api: Hono, adapter: StudioApiAdapter): voi
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
const files = walkDir(project.dir);
return c.json({ id: project.id, dir: project.dir, title: project.title, files });
return c.json({ id: project.id, files });
});
}
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { Hono } from "hono";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { registerThumbnailRoutes } from "./thumbnail";
@@ -94,84 +94,4 @@ describe("registerThumbnailRoutes", () => {
}),
);
});
it("forwards selector occurrence indexes to thumbnail generation", async () => {
const adapter = createAdapter();
const app = new Hono();
registerThumbnailRoutes(app, adapter);
const response = await app.request(
"http://localhost/projects/demo/thumbnail/index.html?t=1.2&selector=.card&selectorIndex=2",
);
expect(response.status).toBe(200);
expect(adapter.generateThumbnail).toHaveBeenCalledWith(
expect.objectContaining({
selector: ".card",
selectorIndex: 2,
}),
);
});
it("keeps url thumbnail versions separated in the disk cache", async () => {
const adapter = createAdapter();
const app = new Hono();
registerThumbnailRoutes(app, adapter);
await app.request("http://localhost/projects/demo/thumbnail/index.html?t=2&v=old");
await app.request("http://localhost/projects/demo/thumbnail/index.html?t=2&v=old");
await app.request("http://localhost/projects/demo/thumbnail/index.html?t=2&v=new");
expect(adapter.generateThumbnail).toHaveBeenCalledTimes(2);
});
it("keeps changed composition dimensions separated in the disk cache", async () => {
const adapter = createAdapter();
const project = await adapter.resolveProject("demo");
if (!project) throw new Error("missing project");
const app = new Hono();
registerThumbnailRoutes(app, adapter);
const indexPath = join(project.dir, "index.html");
writeFileSync(indexPath, `<div data-composition-id="main" data-width="640" data-height="360">`);
await app.request("http://localhost/projects/demo/thumbnail/index.html?t=2&v=test");
writeFileSync(
indexPath,
`<div data-composition-id="main" data-width="1280" data-height="720">`,
);
await app.request("http://localhost/projects/demo/thumbnail/index.html?t=2&v=test");
expect(adapter.generateThumbnail).toHaveBeenCalledTimes(2);
expect(adapter.generateThumbnail).toHaveBeenLastCalledWith(
expect.objectContaining({
width: 1280,
height: 720,
}),
);
});
it("keeps changed studio manual edits separated in the disk cache", async () => {
const adapter = createAdapter();
const project = await adapter.resolveProject("demo");
if (!project) throw new Error("missing project");
const app = new Hono();
registerThumbnailRoutes(app, adapter);
const indexPath = join(project.dir, "index.html");
writeFileSync(indexPath, `<div data-composition-id="main" data-width="640" data-height="360">`);
const manualEditsDir = join(project.dir, ".hyperframes");
mkdirSync(manualEditsDir, { recursive: true });
const manualEditsPath = join(manualEditsDir, "studio-manual-edits.json");
writeFileSync(manualEditsPath, `{"version":1,"edits":[]}`);
await app.request("http://localhost/projects/demo/thumbnail/index.html?t=2&v=test");
writeFileSync(
manualEditsPath,
`{"version":1,"edits":[{"kind":"rotation","target":{"sourceFile":"index.html","id":"card"},"angle":30}]}`,
);
await app.request("http://localhost/projects/demo/thumbnail/index.html?t=2&v=test");
expect(adapter.generateThumbnail).toHaveBeenCalledTimes(2);
});
});
@@ -1,11 +1,9 @@
import type { Hono } from "hono";
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from "node:fs";
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { createHash } from "node:crypto";
import type { StudioApiAdapter } from "../types.js";
const THUMBNAIL_CACHE_VERSION = "v4";
const STUDIO_MANUAL_EDITS_PATH = ".hyperframes/studio-manual-edits.json";
const THUMBNAIL_CACHE_VERSION = "v3";
export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): void {
api.get("/projects/:id/thumbnail/*", async (c) => {
@@ -29,19 +27,13 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v
const selector = url.searchParams.get("selector") || undefined;
const format = url.searchParams.get("format") === "png" ? "png" : "jpeg";
const contentType = format === "png" ? "image/png" : "image/jpeg";
const rawSelectorIndex = Number.parseInt(url.searchParams.get("selectorIndex") || "0", 10);
const selectorIndex =
Number.isFinite(rawSelectorIndex) && rawSelectorIndex > 0 ? rawSelectorIndex : undefined;
const urlVersion = url.searchParams.get("v") || "";
// Determine composition dimensions from HTML
let compW = vpWidth || 1920;
let compH = vpHeight || 1080;
let sourceMtime = 0;
if (!vpWidth) {
const htmlFile = join(project.dir, compPath);
if (existsSync(htmlFile)) {
sourceMtime = Math.round(statSync(htmlFile).mtimeMs);
const html = readFileSync(htmlFile, "utf-8");
const wMatch = html.match(/data-width=["'](\d+)["']/);
const hMatch = html.match(/data-height=["'](\d+)["']/);
@@ -49,13 +41,6 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v
if (hMatch?.[1]) compH = parseInt(hMatch[1]);
}
}
const manualEditsFile = join(project.dir, STUDIO_MANUAL_EDITS_PATH);
let manualEditsKey = "";
if (existsSync(manualEditsFile)) {
const manualEditsContent = readFileSync(manualEditsFile, "utf-8");
manualEditsKey = `_${createHash("sha1").update(manualEditsContent).digest("hex").slice(0, 16)}`;
sourceMtime = Math.max(sourceMtime, Math.round(statSync(manualEditsFile).mtimeMs));
}
const previewUrl =
compPath === "index.html"
@@ -65,12 +50,9 @@ 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}`
? `_${selector.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 80)}`
: "";
const urlVersionKey = urlVersion
? `_${urlVersion.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 32)}`
: "";
const cacheKey = `${THUMBNAIL_CACHE_VERSION}${urlVersionKey}${manualEditsKey}_${format}_${compPath.replace(/\//g, "_")}_${compW}x${compH}_${sourceMtime}_${seekTime.toFixed(2)}${selectorKey}.${format === "png" ? "png" : "jpg"}`;
const cacheKey = `${THUMBNAIL_CACHE_VERSION}_${format}_${compPath.replace(/\//g, "_")}_${seekTime.toFixed(2)}${selectorKey}.${format === "png" ? "png" : "jpg"}`;
const cachePath = join(cacheDir, cacheKey);
if (existsSync(cachePath)) {
return new Response(new Uint8Array(readFileSync(cachePath)), {
@@ -88,7 +70,6 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v
previewUrl,
selector,
format,
selectorIndex,
});
if (!buffer) {
return c.json({ error: "Thumbnail generation returned null" }, 500);
-1
View File
@@ -73,7 +73,6 @@ export interface StudioApiAdapter {
previewUrl: string;
selector?: string;
format?: "jpeg" | "png";
selectorIndex?: number;
}) => Promise<Buffer | null>;
/** Optional: resolve session ID to project (multi-project mode). */