refactor: extract @hyperframes/studio-server from core (#1757)

* refactor: extract @hyperframes/studio-server package from core

Moves all studio-api routes, helpers, and Hono server wiring from
packages/core/src/studio-api/ into a new standalone packages/studio-server
package (@hyperframes/studio-server).

Core keeps thin re-export stubs at @hyperframes/core/studio-api and the
subpath helpers (screenshot-clip, draft-markers, etc.) for backward
compatibility. Consumer imports (cli studioServer, vite adapter/config,
producer htmlCompiler, studio manualEditsTypes) are updated to import from
@hyperframes/studio-server directly.

Also exports rewriteInlineStyleAssetUrls from @hyperframes/core root (was
in compiler/rewriteSubCompPaths.ts but not re-exported), required by
@hyperframes/studio-server/helpers/subComposition.

Removes postcss-selector-parser from @hyperframes/core dependencies (moved
to @hyperframes/studio-server which owns the routes that used it).

Depends on @hyperframes/parsers (PR #1755).

* fix(ci): add parsers+studio-server to Dockerfile and build before preview tests

* fix(ci): build @hyperframes/studio-server before Test and studio load smoke

Studio's vite.config.ts imports @hyperframes/studio-server, which resolves
via its "node" export condition to built dist. The Test and studio-load-smoke
jobs only built parsers + core, so esbuild's config load failed to resolve the
package entry. Build studio-server too.

* fix(studio): repoint sdkCutoverParity test import to studio-server

sourceMutation moved from core's studio-api to @hyperframes/studio-server;
the test still imported the deleted core path. This was masked while studio's
vite.config failed to load (couldn't resolve studio-server); now that the
config loads, the test runs and the stale import surfaced.
This commit is contained in:
Miguel Ángel
2026-06-27 01:24:01 -04:00
committed by GitHub
parent 98d0bdd73c
commit 7a4853dfe6
74 changed files with 2177 additions and 1149 deletions
@@ -0,0 +1,585 @@
import { afterEach, describe, expect, it } from "vitest";
import { Hono } from "hono";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { registerFileRoutes } from "./files";
import type { StudioApiAdapter } from "../types";
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
function createProjectDir(): string {
const projectDir = mkdtempSync(join(tmpdir(), "hf-files-test-"));
tempDirs.push(projectDir);
writeFileSync(join(projectDir, "index.html"), "<html><body>Preview</body></html>");
return projectDir;
}
function createAdapter(projectDir: string): StudioApiAdapter {
return {
listProjects: () => [],
resolveProject: async (id: string) => ({ id, dir: projectDir }),
bundle: async () => null,
lint: async () => ({ findings: [] }),
runtimeUrl: "/api/runtime.js",
rendersDir: () => "/tmp/renders",
startRender: () => ({
id: "job-1",
status: "rendering",
progress: 0,
outputPath: "/tmp/out.mp4",
}),
};
}
describe("registerFileRoutes", () => {
it("returns empty content for missing files when caller marks the read optional", async () => {
const projectDir = createProjectDir();
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const response = await app.request(
"http://localhost/projects/demo/files/missing-file.txt?optional=1",
);
const payload = (await response.json()) as { filename?: string; content?: string };
expect(response.status).toBe(200);
expect(payload.filename).toBe("missing-file.txt");
expect(payload.content).toBe("");
});
it("still returns 404 for other missing files", async () => {
const projectDir = createProjectDir();
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const response = await app.request("http://localhost/projects/demo/files/missing-file.txt");
expect(response.status).toBe(404);
});
it("backs up the previous file content before PUT overwrite", async () => {
const projectDir = createProjectDir();
writeFileSync(join(projectDir, "index.html"), "before");
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const response = await app.request("http://localhost/projects/demo/files/index.html", {
method: "PUT",
body: "after",
});
const payload = (await response.json()) as { path?: string; backupPath?: string };
expect(response.status).toBe(200);
expect(payload.path).toBe("index.html");
expect(payload.backupPath).toMatch(/^\.hyperframes\/backup\//);
expect(readFileSync(join(projectDir, payload.backupPath!), "utf-8")).toBe("before");
expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe("after");
});
it("backs up the previous file content before delete", async () => {
const projectDir = createProjectDir();
writeFileSync(join(projectDir, "index.html"), "before delete");
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const response = await app.request("http://localhost/projects/demo/files/index.html", {
method: "DELETE",
});
const payload = (await response.json()) as { backupPath?: string };
expect(response.status).toBe(200);
expect(payload.backupPath).toMatch(/^\.hyperframes\/backup\//);
expect(readFileSync(join(projectDir, payload.backupPath!), "utf-8")).toBe("before delete");
});
it("backs up the previous file content before structured DOM mutations", async () => {
const projectDir = createProjectDir();
writeFileSync(projectDir + "/index.html", '<div id="title">Before</div>');
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const response = await app.request(
"http://localhost/projects/demo/file-mutations/patch-element/index.html",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
target: { id: "title" },
operations: [{ type: "text-content", property: "textContent", value: "After" }],
}),
},
);
const payload = (await response.json()) as {
changed?: boolean;
path?: string;
backupPath?: string;
};
expect(response.status).toBe(200);
expect(payload.changed).toBe(true);
expect(payload.path).toBe("index.html");
expect(payload.backupPath).toMatch(/^\.hyperframes\/backup\//);
expect(readFileSync(join(projectDir, payload.backupPath!), "utf-8")).toBe(
'<div id="title">Before</div>',
);
expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toContain("After");
});
// A realistic sub-composition: markup + GSAP wrapped in a <template>, tweens
// targeting element variables resolved from querySelector, with interleaved
// gsap.set() calls. This is the shape every scaffolded composition uses.
const TEMPLATE_COMP = `<template id="scene-template">
<div id="scene" data-composition-id="scene" data-width="1920" data-height="1080" data-start="0" data-duration="3">
<div class="kicker">HELLO</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script>
(function () {
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
const root = document.querySelector('#scene');
const kicker = root.querySelector(".kicker");
gsap.set(kicker, { y: 16, opacity: 0 });
tl.to(kicker, { y: 0, opacity: 1, duration: 0.45, ease: "expo.out" }, 0.3);
window.__timelines["scene"] = tl;
})();
</script>
</template>`;
function writeComp(projectDir: string, name: string, html: string): void {
const dir = join(projectDir, "compositions");
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, name), html);
}
it("parses GSAP tweens from a <template>-wrapped sub-composition with variable targets", async () => {
const projectDir = createProjectDir();
writeComp(projectDir, "scene.html", TEMPLATE_COMP);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const response = await app.request(
"http://localhost/projects/demo/gsap-animations/compositions/scene.html",
);
const payload = (await response.json()) as {
animations: Array<{ id: string; targetSelector: string; properties: Record<string, number> }>;
};
expect(response.status).toBe(200);
expect(payload.animations).toHaveLength(1);
expect(payload.animations[0].targetSelector).toBe(".kicker");
});
// A composition with a fromTo tween — used by the fromProperties mutation tests.
const FROMTO_COMP = `<!DOCTYPE html><html><body data-duration="3">
<div id="box" data-start="0" data-duration="3" style="opacity:0"></div>
<script data-hyperframes-gsap>
const tl = gsap.timeline();
tl.fromTo("#box", { opacity: 0, x: -50 }, { opacity: 1, x: 0, duration: 1.5, ease: "power2.out" }, 0);
</script>
</body></html>`;
function writeHtml(projectDir: string, name: string, html: string): void {
writeFileSync(join(projectDir, name), html);
}
async function getFirstAnimation(
app: Hono,
file: string,
): Promise<{ id: string; method: string; fromProperties?: Record<string, number | string> }> {
const res = await app.request(`http://localhost/projects/demo/gsap-animations/${file}`);
const payload = (await res.json()) as {
animations: Array<{
id: string;
method: string;
fromProperties?: Record<string, number | string>;
}>;
};
return payload.animations[0];
}
it("update-from-property updates a fromTo start value in place", async () => {
const projectDir = createProjectDir();
writeHtml(projectDir, "comp.html", FROMTO_COMP);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const anim = await getFirstAnimation(app, "comp.html");
expect(anim.method).toBe("fromTo");
expect(anim.fromProperties?.opacity).toBe(0);
const res = await app.request("http://localhost/projects/demo/gsap-mutations/comp.html", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "update-from-property",
animationId: anim.id,
property: "opacity",
value: 0.2,
}),
});
const result = (await res.json()) as {
ok: boolean;
after: string;
parsed: { animations: Array<{ fromProperties?: Record<string, number | string> }> };
};
expect(res.status).toBe(200);
expect(result.ok).toBe(true);
expect(result.after).toContain("opacity: 0.2");
expect(result.parsed.animations[0].fromProperties?.opacity).toBe(0.2);
// x unchanged
expect(result.parsed.animations[0].fromProperties?.x).toBe(-50);
});
it("rejects serialized non-finite mutation values before writing source", async () => {
const projectDir = createProjectDir();
writeHtml(projectDir, "comp.html", FROMTO_COMP);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const anim = await getFirstAnimation(app, "comp.html");
const before = readFileSync(join(projectDir, "comp.html"), "utf-8");
const res = await app.request("http://localhost/projects/demo/gsap-mutations/comp.html", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "update-property",
animationId: anim.id,
property: "x",
value: Number.NaN,
}),
});
const payload = (await res.json()) as { error?: string; fields?: string[] };
expect(res.status).toBe(400);
expect(payload.error).toContain("unsafe values");
expect(payload.fields).toContain("body.value");
expect(readFileSync(join(projectDir, "comp.html"), "utf-8")).toBe(before);
});
it("rejects unsafe DOM patch metadata before writing source", async () => {
const projectDir = createProjectDir();
writeFileSync(join(projectDir, "index.html"), '<div id="title">Before</div>');
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const response = await app.request(
"http://localhost/projects/demo/file-mutations/patch-element/index.html",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
target: { id: "title", selectorIndex: Number.NaN },
operations: [{ type: "text-content", property: "textContent", value: "After" }],
}),
},
);
const payload = (await response.json()) as { error?: string; fields?: string[] };
expect(response.status).toBe(400);
expect(payload.error).toContain("unsafe values");
expect(payload.fields).toContain("body.target.selectorIndex");
expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe(
'<div id="title">Before</div>',
);
});
it("allows DOM patch null values used for explicit style removals", async () => {
const projectDir = createProjectDir();
writeFileSync(
join(projectDir, "index.html"),
'<div id="title" style="opacity: 1">Before</div>',
);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const response = await app.request(
"http://localhost/projects/demo/file-mutations/patch-element/index.html",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
target: { id: "title" },
operations: [{ type: "inline-style", property: "opacity", value: null }],
}),
},
);
const payload = (await response.json()) as { changed?: boolean; content?: string };
expect(response.status).toBe(200);
expect(payload.changed).toBe(true);
expect(payload.content).not.toContain("opacity");
});
it("update-from-property returns 400 for a non-fromTo animation", async () => {
const projectDir = createProjectDir();
const TO_COMP = `<!DOCTYPE html><html><body><script data-hyperframes-gsap>
const tl = gsap.timeline();
tl.to("#box", { opacity: 1, duration: 1 }, 0);
</script></body></html>`;
writeHtml(projectDir, "to.html", TO_COMP);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const anim = await getFirstAnimation(app, "to.html");
expect(anim.method).toBe("to");
const res = await app.request("http://localhost/projects/demo/gsap-mutations/to.html", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "update-from-property",
animationId: anim.id,
property: "opacity",
value: 0,
}),
});
expect(res.status).toBe(400);
});
it("add-from-property merges a new key into existing fromProperties", async () => {
const projectDir = createProjectDir();
writeHtml(projectDir, "comp.html", FROMTO_COMP);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const anim = await getFirstAnimation(app, "comp.html");
const res = await app.request("http://localhost/projects/demo/gsap-mutations/comp.html", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "add-from-property",
animationId: anim.id,
property: "scale",
defaultValue: 0.5,
}),
});
const result = (await res.json()) as {
ok: boolean;
parsed: { animations: Array<{ fromProperties?: Record<string, number | string> }> };
};
expect(res.status).toBe(200);
expect(result.ok).toBe(true);
// Existing keys preserved, new key added
const fp = result.parsed.animations[0].fromProperties ?? {};
expect(fp.opacity).toBe(0);
expect(fp.x).toBe(-50);
expect(fp.scale).toBe(0.5);
});
it("remove-from-property deletes one key, leaving others intact", async () => {
const projectDir = createProjectDir();
writeHtml(projectDir, "comp.html", FROMTO_COMP);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const anim = await getFirstAnimation(app, "comp.html");
const res = await app.request("http://localhost/projects/demo/gsap-mutations/comp.html", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "remove-from-property",
animationId: anim.id,
property: "x",
}),
});
const result = (await res.json()) as {
ok: boolean;
after: string;
parsed: { animations: Array<{ fromProperties?: Record<string, number | string> }> };
};
expect(res.status).toBe(200);
expect(result.ok).toBe(true);
const fp = result.parsed.animations[0].fromProperties ?? {};
expect(fp.x).toBeUndefined();
expect(fp.opacity).toBe(0); // untouched
});
it("remove-from-property returns 400 for a non-fromTo animation", async () => {
const projectDir = createProjectDir();
const TO_COMP = `<!DOCTYPE html><html><body><script data-hyperframes-gsap>
const tl = gsap.timeline();
tl.to("#box", { opacity: 1, duration: 1 }, 0);
</script></body></html>`;
writeHtml(projectDir, "to.html", TO_COMP);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const anim = await getFirstAnimation(app, "to.html");
const res = await app.request("http://localhost/projects/demo/gsap-mutations/to.html", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "remove-from-property",
animationId: anim.id,
property: "opacity",
}),
});
expect(res.status).toBe(400);
});
it("add mutation with fromTo method creates a fromTo tween with fromProperties", async () => {
const projectDir = createProjectDir();
const EMPTY_COMP = `<!DOCTYPE html><html><body><div id="el"></div><script data-hyperframes-gsap>
const tl = gsap.timeline();
</script></body></html>`;
writeHtml(projectDir, "empty.html", EMPTY_COMP);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const res = await app.request("http://localhost/projects/demo/gsap-mutations/empty.html", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "add",
targetSelector: "#el",
method: "fromTo",
position: 0,
duration: 0.5,
ease: "power2.out",
properties: { opacity: 1 },
fromProperties: { opacity: 0 },
}),
});
const result = (await res.json()) as {
ok: boolean;
parsed: {
animations: Array<{
method: string;
fromProperties?: Record<string, number | string>;
properties: Record<string, number | string>;
}>;
};
};
expect(res.status).toBe(200);
expect(result.ok).toBe(true);
const anim = result.parsed.animations[0];
expect(anim.method).toBe("fromTo");
expect(anim.fromProperties?.opacity).toBe(0);
expect(anim.properties.opacity).toBe(1);
});
it("add mutation returns 400 when fromProperties provided for non-fromTo method", async () => {
const projectDir = createProjectDir();
const EMPTY_COMP = `<!DOCTYPE html><html><body><div id="el"></div><script data-hyperframes-gsap>
const tl = gsap.timeline();
</script></body></html>`;
writeHtml(projectDir, "empty.html", EMPTY_COMP);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const res = await app.request("http://localhost/projects/demo/gsap-mutations/empty.html", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "add",
targetSelector: "#el",
method: "to",
position: 0,
duration: 0.5,
ease: "power2.out",
properties: { opacity: 1 },
fromProperties: { opacity: 0 },
}),
});
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toContain("fromProperties");
});
// A rotation-only keyframe set must strip the legacy studio rotation channel just
// as a position keyframe set strips the offset channel — otherwise --hf-studio-rotation
// double-applies on top of the new GSAP rotation tween.
it("replace-with-keyframes strips studio rotation edits for a rotation-only keyframe set", async () => {
const projectDir = createProjectDir();
const ROT_COMP = `<!DOCTYPE html><html><body data-duration="3">
<div id="box" data-start="0" data-duration="3" data-hf-studio-rotation="30" style="--hf-studio-rotation:30deg;rotate:30deg"></div>
<script data-hyperframes-gsap>
const tl = gsap.timeline();
tl.to("#box", { opacity: 1, duration: 1 }, 0);
</script>
</body></html>`;
writeHtml(projectDir, "rot.html", ROT_COMP);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const anim = await getFirstAnimation(app, "rot.html");
const res = await app.request("http://localhost/projects/demo/gsap-mutations/rot.html", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "replace-with-keyframes",
animationId: anim.id,
targetSelector: "#box",
position: 0,
duration: 1,
keyframes: [
{ percentage: 0, properties: { rotation: 0 } },
{ percentage: 100, properties: { rotation: 90 } },
],
}),
});
const result = (await res.json()) as { ok: boolean; after: string };
expect(res.status).toBe(200);
expect(result.ok).toBe(true);
expect(result.after).not.toContain("--hf-studio-rotation");
expect(result.after).not.toContain("data-hf-studio-rotation");
});
it("edits a template-wrapped tween in place, preserving gsap.set and the IIFE", async () => {
const projectDir = createProjectDir();
writeComp(projectDir, "scene.html", TEMPLATE_COMP);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const parseRes = await app.request(
"http://localhost/projects/demo/gsap-animations/compositions/scene.html",
);
const { animations } = (await parseRes.json()) as { animations: Array<{ id: string }> };
const animationId = animations[0].id;
const mutateRes = await app.request(
"http://localhost/projects/demo/gsap-mutations/compositions/scene.html",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "update-property",
animationId,
property: "opacity",
value: 0.5,
}),
},
);
const result = (await mutateRes.json()) as { ok: boolean; after: string };
expect(mutateRes.status).toBe(200);
expect(result.ok).toBe(true);
// Edit landed
expect(result.after).toContain("opacity: 0.5");
// Surrounding code preserved verbatim — the in-place AST edit didn't rewrite the block
expect(result.after).toContain("gsap.set(kicker, { y: 16, opacity: 0 })");
expect(result.after).toContain('const kicker = root.querySelector(".kicker")');
expect(result.after).toContain('window.__timelines["scene"] = tl;');
expect(result.after).toContain("(function () {");
// The variable target was not flattened to a string-literal selector
expect(result.after).toContain("tl.to(kicker,");
});
});
File diff suppressed because it is too large Load Diff
+172
View File
@@ -0,0 +1,172 @@
import { closeSync, constants, fstatSync, openSync, readSync } from "node:fs";
import type { Hono } from "hono";
import {
collectFontFileEntries,
fontDirectories,
getSystemProfilerFamilies,
locateSystemFont,
SYSTEM_FONT_SIZE_LIMIT,
} from "@hyperframes/core/fonts/system-locator";
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 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 collectFontsFromDir(dir: string): string[] {
return collectFontFileEntries(dir).map((e) => e.family);
}
function listInstalledFontFamilies(): string[] {
if (cachedFonts) return cachedFonts;
const families = new Set<string>();
for (const family of getSystemProfilerFamilies()) {
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() }));
// fallow-ignore-next-line complexity
api.get("/fonts/file", (c) => {
const family = c.req.query("family");
if (!family) return c.json({ error: "family parameter required" }, 400);
const located = locateSystemFont(family);
if (!located) return c.json({ error: "font not found" }, 404);
let fd: number;
try {
fd = openSync(located.path, constants.O_RDONLY | constants.O_NOFOLLOW);
} catch {
return c.json({ error: "font file not accessible" }, 404);
}
try {
const stat = fstatSync(fd);
if (stat.size > SYSTEM_FONT_SIZE_LIMIT) {
return c.json({ error: "font file too large" }, 413);
}
const buffer = Buffer.alloc(stat.size);
readSync(fd, buffer, 0, stat.size, 0);
const mimeType =
located.format === "otf"
? "font/otf"
: located.format === "woff2"
? "font/woff2"
: located.format === "woff"
? "font/woff"
: located.format === "ttc"
? "font/collection"
: "font/ttf";
const fileName = `${family.replace(/[^a-zA-Z0-9 -]/g, "")}.${located.format}`;
return new Response(buffer, {
headers: {
"Content-Type": mimeType,
"Content-Disposition": `attachment; filename="${fileName}"`,
},
});
} catch {
return c.json({ error: "failed to read font file" }, 500);
} finally {
closeSync(fd);
}
});
}
@@ -0,0 +1,61 @@
import { afterEach, describe, expect, it } from "vitest";
import { Hono } from "hono";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { registerLintRoutes } from "./lint";
import type { StudioApiAdapter } from "../types";
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
// Project layout for #1384: one real composition plus vendored example HTML
// inside a dot-directory that must not inflate the lint findings.
function createProjectDir(): string {
const projectDir = mkdtempSync(join(tmpdir(), "hf-lint-test-"));
tempDirs.push(projectDir);
writeFileSync(join(projectDir, "index.html"), "<html><body>real</body></html>");
mkdirSync(join(projectDir, ".hyperframes"));
writeFileSync(join(projectDir, ".hyperframes", "preset.html"), "<html><body>junk</body></html>");
return projectDir;
}
// Every linted file reports one finding, so the response reveals exactly
// which files were linted.
function createAdapter(projectDir: string): StudioApiAdapter {
return {
listProjects: () => [],
resolveProject: async (id: string) => ({ id, dir: projectDir }),
bundle: async () => null,
lint: async () => ({ findings: [{ severity: "warning", message: "finding" }] }),
runtimeUrl: "/api/runtime.js",
rendersDir: () => "/tmp/renders",
startRender: () => ({
id: "job-1",
status: "rendering",
progress: 0,
outputPath: "/tmp/out.mp4",
}),
};
}
describe("registerLintRoutes — dot-directory exclusion (#1384)", () => {
it("does not lint HTML inside dot-directories", async () => {
const projectDir = createProjectDir();
const app = new Hono();
registerLintRoutes(app, createAdapter(projectDir));
const response = await app.request("http://localhost/projects/demo/lint");
const payload = (await response.json()) as { findings?: Array<{ file?: string }> };
expect(response.status).toBe(200);
const lintedFiles = (payload.findings ?? []).map((f) => f.file);
expect(lintedFiles).toContain("index.html");
expect(lintedFiles).not.toContain(".hyperframes/preset.html");
});
});
+36
View File
@@ -0,0 +1,36 @@
import type { Hono } from "hono";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import type { StudioApiAdapter } from "../types.js";
import { isInHiddenOrVendorDir, walkDir } from "../helpers/safePath.js";
export function registerLintRoutes(api: Hono, adapter: StudioApiAdapter): void {
api.get("/projects/:id/lint", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
try {
const htmlFiles = walkDir(project.dir).filter(
(f) => f.endsWith(".html") && !isInHiddenOrVendorDir(f),
);
const allFindings: Array<{
severity: string;
message: string;
file?: string;
fixHint?: string;
}> = [];
for (const file of htmlFiles) {
const content = readFileSync(join(project.dir, file), "utf-8");
const result = await adapter.lint(content, { filePath: file });
if (result?.findings) {
for (const f of result.findings) {
allFindings.push({ ...f, file });
}
}
}
return c.json({ findings: allFindings });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return c.json({ error: `Lint failed: ${msg}` }, 500);
}
});
}
@@ -0,0 +1,444 @@
// fallow-ignore-file code-duplication
import { afterEach, describe, expect, it, vi } from "vitest";
import { Hono } from "hono";
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { registerPreviewRoutes } from "./preview";
import type { StudioApiAdapter } from "../types";
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
function createProjectDir(): string {
const projectDir = mkdtempSync(join(tmpdir(), "hf-preview-test-"));
tempDirs.push(projectDir);
writeFileSync(join(projectDir, "index.html"), "<html><head></head><body>Preview</body></html>");
return projectDir;
}
function createAdapter(
projectDir: string,
overrides: Partial<StudioApiAdapter> = {},
): StudioApiAdapter {
return {
listProjects: () => [],
resolveProject: async (id: string) => ({ id, dir: projectDir }),
bundle: async () => null,
lint: async () => ({ findings: [] }),
runtimeUrl: "/api/runtime.js",
rendersDir: () => "/tmp/renders",
startRender: () => ({
id: "job-1",
status: "rendering",
progress: 0,
outputPath: "/tmp/out.mp4",
}),
...overrides,
};
}
function tryCreateSymlink(target: string, path: string, type: "dir" | "file"): boolean {
try {
symlinkSync(target, path, type);
return true;
} catch {
return false;
}
}
async function getPreviewSignature(projectDir: string): Promise<string> {
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));
const response = await app.request("http://localhost/projects/demo/preview");
expect(response.status).toBe(200);
const html = await response.text();
const match = /<meta name="hyperframes-project-signature" content="([^"]+)">/.exec(html);
expect(match?.[1]).toBeTruthy();
return match![1]!;
}
describe("registerPreviewRoutes", () => {
it("injects Studio GSAP motion manifest runtime into project preview", async () => {
const projectDir = createProjectDir();
writeFileSync(
join(projectDir, "index.html"),
"<!doctype html><html><head></head><body><div id='card'></div></body></html>",
);
const manifestDir = join(projectDir, ".hyperframes");
mkdirSync(manifestDir, { recursive: true });
writeFileSync(
join(manifestDir, "studio-motion.json"),
`{"version":1,"motions":[{"kind":"gsap-motion","target":{"sourceFile":"index.html","id":"card"},"start":0,"duration":1,"ease":"power2.out","from":{"y":32},"to":{"y":0}}]}`,
);
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));
const response = await app.request("http://localhost/projects/demo/preview");
const html = await response.text();
expect(response.status).toBe(200);
expect(html).toContain("__hfStudioMotionApply");
expect(html).toContain("studio-motion");
expect(html).toContain("gsap@3.15.0/dist/gsap.min.js");
});
it("injects the GSAP CustomEase plugin when Studio motion uses a custom ease", async () => {
const projectDir = createProjectDir();
writeFileSync(
join(projectDir, "index.html"),
"<!doctype html><html><head></head><body><div id='card'></div></body></html>",
);
const manifestDir = join(projectDir, ".hyperframes");
mkdirSync(manifestDir, { recursive: true });
writeFileSync(
join(manifestDir, "studio-motion.json"),
`{"version":1,"motions":[{"kind":"gsap-motion","target":{"sourceFile":"index.html","id":"card"},"start":0,"duration":1,"ease":"studio-card-ease","customEase":{"id":"studio-card-ease","data":"M0,0 C0.18,0.9 0.32,1 1,1"},"from":{"y":32},"to":{"y":0}}]}`,
);
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));
const response = await app.request("http://localhost/projects/demo/preview");
const html = await response.text();
expect(response.status).toBe(200);
expect(html).toContain("gsap@3.15.0/dist/gsap.min.js");
expect(html).toContain("gsap@3.15.0/dist/CustomEase.min.js");
expect(html.indexOf("gsap.min.js")).toBeLessThan(html.indexOf("CustomEase.min.js"));
expect(html.indexOf("CustomEase.min.js")).toBeLessThan(html.indexOf("__hfStudioMotionApply"));
});
it("injects the GSAP MotionPathPlugin when the composition uses a motionPath", async () => {
const projectDir = createProjectDir();
writeFileSync(
join(projectDir, "index.html"),
`<!doctype html><html><head>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
</head><body><div id="card" class="clip"></div>
<script>
const tl = gsap.timeline({ paused: true });
tl.to("#card", { motionPath: { path: [{ x: 0, y: 0 }, { x: 100, y: 50 }] }, duration: 1 }, 0);
window.__timelines = { index: tl };
</script>
</body></html>`,
);
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));
const response = await app.request("http://localhost/projects/demo/preview");
const html = await response.text();
expect(response.status).toBe(200);
// Plugin version is derived from the composition's own gsap (gsap@3 here).
expect(html).toContain("gsap@3/dist/MotionPathPlugin.min.js");
// Plugin must load AFTER the core gsap script so it can register onto it.
expect(html.indexOf("gsap.min.js")).toBeLessThan(html.indexOf("MotionPathPlugin.min.js"));
});
it("does NOT inject MotionPathPlugin when the composition has no motionPath", async () => {
const projectDir = createProjectDir();
writeFileSync(
join(projectDir, "index.html"),
`<!doctype html><html><head>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
</head><body><div id="card" class="clip"></div>
<script>
const tl = gsap.timeline({ paused: true });
tl.to("#card", { x: 100, duration: 1 }, 0);
window.__timelines = { index: tl };
</script>
</body></html>`,
);
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));
const response = await app.request("http://localhost/projects/demo/preview");
const html = await response.text();
expect(response.status).toBe(200);
expect(html).not.toContain("MotionPathPlugin.min.js");
});
it("injects Studio GSAP motion runtime into sub-composition previews with the active source path", async () => {
const projectDir = createProjectDir();
mkdirSync(join(projectDir, "compositions"), { recursive: true });
writeFileSync(
join(projectDir, "index.html"),
"<!doctype html><html><head></head><body></body></html>",
);
writeFileSync(
join(projectDir, "compositions/scene.html"),
`<template><section id="card" data-composition-id="scene" data-width="1280" data-height="720"></section></template>`,
);
const manifestDir = join(projectDir, ".hyperframes");
mkdirSync(manifestDir, { recursive: true });
writeFileSync(
join(manifestDir, "studio-motion.json"),
`{"version":1,"motions":[{"kind":"gsap-motion","target":{"sourceFile":"compositions/scene.html","id":"card"},"start":0,"duration":1,"ease":"power2.out","from":{"y":32},"to":{"y":0}}]}`,
);
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));
const response = await app.request(
"http://localhost/projects/demo/preview/comp/compositions/scene.html",
);
const html = await response.text();
expect(response.status).toBe(200);
expect(html).toContain("__hfStudioMotionApply");
expect(html).toContain("compositions/scene.html");
});
it("applies adapter preview transforms to bundled root previews", async () => {
const projectDir = createProjectDir();
const app = new Hono();
registerPreviewRoutes(
app,
createAdapter(projectDir, {
bundle: async () => "<!doctype html><html><head></head><body>Preview</body></html>",
transformPreviewHtml: async ({ html, activeCompositionPath }) =>
html.replace(
"</head>",
`<meta name="preview-path" content="${activeCompositionPath}"></head>`,
),
}),
);
const response = await app.request("http://localhost/projects/demo/preview");
const html = await response.text();
expect(response.status).toBe(200);
expect(html).toContain('<meta name="preview-path" content="index.html">');
});
it("applies adapter preview transforms to sub-composition previews", async () => {
const projectDir = createProjectDir();
mkdirSync(join(projectDir, "compositions"), { recursive: true });
writeFileSync(
join(projectDir, "compositions/scene.html"),
`<template><section data-composition-id="scene" data-width="1280" data-height="720"></section></template>`,
);
const app = new Hono();
registerPreviewRoutes(
app,
createAdapter(projectDir, {
transformPreviewHtml: async ({ html, activeCompositionPath }) =>
html.replace(
"</head>",
`<meta name="preview-path" content="${activeCompositionPath}"></head>`,
),
}),
);
const response = await app.request(
"http://localhost/projects/demo/preview/comp/compositions/scene.html",
);
const html = await response.text();
expect(response.status).toBe(200);
expect(html).toContain('<meta name="preview-path" content="compositions/scene.html">');
});
it("applies adapter preview transforms when bundle() returns null (reads from disk)", async () => {
const projectDir = createProjectDir();
const app = new Hono();
registerPreviewRoutes(
app,
createAdapter(projectDir, {
// bundle: async () => null <-- default; falls back to reading index.html from disk
transformPreviewHtml: async ({ html, activeCompositionPath }) =>
html.replace(
"</head>",
`<meta name="preview-path" content="${activeCompositionPath}"></head>`,
),
}),
);
const response = await app.request("http://localhost/projects/demo/preview");
const html = await response.text();
expect(response.status).toBe(200);
expect(html).toContain('<meta name="preview-path" content="index.html">');
});
it("applies adapter preview transforms in the bundle error fallback path", async () => {
const projectDir = createProjectDir();
const app = new Hono();
registerPreviewRoutes(
app,
createAdapter(projectDir, {
bundle: async () => {
throw new Error("bundler unavailable");
},
transformPreviewHtml: async ({ html, activeCompositionPath }) =>
html.replace(
"</head>",
`<meta name="preview-path" content="${activeCompositionPath}"></head>`,
),
}),
);
const response = await app.request("http://localhost/projects/demo/preview");
const html = await response.text();
expect(response.status).toBe(200);
expect(html).toContain('<meta name="preview-path" content="index.html">');
});
it("falls back to original HTML when transformPreviewHtml throws", async () => {
const projectDir = createProjectDir();
const app = new Hono();
registerPreviewRoutes(
app,
createAdapter(projectDir, {
bundle: async () => "<!doctype html><html><head></head><body>Preview</body></html>",
transformPreviewHtml: async () => {
throw new Error("transform failed");
},
}),
);
const response = await app.request("http://localhost/projects/demo/preview");
const html = await response.text();
expect(response.status).toBe(200);
expect(html).toContain("Preview");
});
it("uses the adapter project signature when available", async () => {
const projectDir = createProjectDir();
const getProjectSignature = vi.fn(() => "cached-signature");
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir, { getProjectSignature }));
const response = await app.request("http://localhost/projects/demo/preview");
const html = await response.text();
expect(response.status).toBe(200);
expect(getProjectSignature).toHaveBeenCalledWith(projectDir);
expect(html).toContain(
'<meta name="hyperframes-project-signature" content="cached-signature">',
);
});
it("updates the preview signature after project text edits", async () => {
const projectDir = createProjectDir();
const file = join(projectDir, "scene.js");
writeFileSync(file, "export const label = 'first';");
const firstSignature = await getPreviewSignature(projectDir);
expect(await getPreviewSignature(projectDir)).toBe(firstSignature);
writeFileSync(file, "export const label = 'second with changed size';");
await expect(getPreviewSignature(projectDir)).resolves.not.toBe(firstSignature);
});
it("updates the preview signature after Studio manifest edits", async () => {
const projectDir = createProjectDir();
const manifestDir = join(projectDir, ".hyperframes");
mkdirSync(manifestDir, { recursive: true });
const motionFile = join(manifestDir, "studio-motion.json");
writeFileSync(motionFile, `{"version":1,"motions":[]}`);
const firstSignature = await getPreviewSignature(projectDir);
writeFileSync(
motionFile,
`{"version":1,"motions":[{"kind":"gsap-motion","target":{"sourceFile":"index.html","id":"card"},"start":0,"duration":1,"from":{"y":32},"to":{"y":0}}]}`,
);
await expect(getPreviewSignature(projectDir)).resolves.not.toBe(firstSignature);
});
it("skips symlinked files when creating the preview signature", async () => {
const projectDir = createProjectDir();
const firstSignature = await getPreviewSignature(projectDir);
const externalDir = mkdtempSync(join(tmpdir(), "hf-preview-external-"));
tempDirs.push(externalDir);
const externalFile = join(externalDir, "external.js");
writeFileSync(externalFile, "export const external = true;");
if (!tryCreateSymlink(externalFile, join(projectDir, "external.js"), "file")) return;
await expect(getPreviewSignature(projectDir)).resolves.toBe(firstSignature);
});
it("skips symlinked directories when creating the preview signature", async () => {
const projectDir = createProjectDir();
if (!tryCreateSymlink(projectDir, join(projectDir, "loop"), "dir")) return;
const signature = await getPreviewSignature(projectDir);
expect(signature).toMatch(/^[a-f0-9]{24}$/);
});
});
describe("hf-id surfacing in preview route", () => {
it("serves HTML with data-hf-id on body elements (R7 write-back)", async () => {
const projectDir = createProjectDir();
writeFileSync(
join(projectDir, "index.html"),
`<!doctype html><html><head></head><body><div class="card"><p>text</p></div></body></html>`,
);
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));
const res = await app.request("http://localhost/projects/demo/preview");
expect(res.status).toBe(200);
const html = await res.text();
const ids = html.match(/data-hf-id="hf-[a-z0-9]{4}"/g);
// div and p both tagged
expect(ids?.length).toBeGreaterThanOrEqual(2);
});
it("writes data-hf-id back to disk on first serve", async () => {
const { readFileSync } = await import("node:fs");
const projectDir = createProjectDir();
const indexPath = join(projectDir, "index.html");
writeFileSync(
indexPath,
`<!doctype html><html><head></head><body><div>hello</div></body></html>`,
);
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));
await app.request("http://localhost/projects/demo/preview");
const onDisk = readFileSync(indexPath, "utf-8");
expect(onDisk).toContain('data-hf-id="hf-');
});
it("bundle returning untagged HTML gets same ids as disk — content-hash is stable across mint contexts", async () => {
// Regression guard for bundle-vs-disk id divergence: if the bundler reads from
// a pre-write cache snapshot (no ids), ensureHfIds mints ids on the bundle output.
// Because ids are content-keyed (FNV1a of element content), the minted ids must
// equal the ids persisted to disk for the same source HTML — otherwise a
// drag-to-edit patch keyed by a wire-time id would fail to apply on disk.
const { readFileSync } = await import("node:fs");
const projectDir = createProjectDir();
const indexPath = join(projectDir, "index.html");
const sourceHtml = `<!doctype html><html><head></head><body><div class="card"><p>hello</p></div></body></html>`;
writeFileSync(indexPath, sourceHtml);
const app = new Hono();
// Bundler returns the same untagged source HTML (simulates stale cache read)
registerPreviewRoutes(app, createAdapter(projectDir, { bundle: async () => sourceHtml }));
const res = await app.request("http://localhost/projects/demo/preview");
expect(res.status).toBe(200);
const servedHtml = await res.text();
const diskHtml = readFileSync(indexPath, "utf-8");
// Extract ids from served HTML and disk HTML
const servedIds = [...servedHtml.matchAll(/data-hf-id="(hf-[a-z0-9]+)"/g)].map((m) => m[1]);
const diskIds = [...diskHtml.matchAll(/data-hf-id="(hf-[a-z0-9]+)"/g)].map((m) => m[1]);
expect(servedIds.length).toBeGreaterThanOrEqual(2);
expect(servedIds).toEqual(diskIds);
});
});
@@ -0,0 +1,421 @@
import type { Hono } from "hono";
import { existsSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { injectScriptsIntoHtml, stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler";
import type { StudioApiAdapter } from "../types.js";
import { resolveWithinProject } from "../helpers/safePath.js";
import { getMimeType } from "../helpers/mime.js";
import { buildSubCompositionHtml } from "../helpers/subComposition.js";
import { createProjectSignature } from "../helpers/projectSignature.js";
import {
createStudioMotionRenderBodyScript,
STUDIO_MOTION_PATH,
} from "../helpers/studioMotionRenderScript.js";
import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
import { persistHfIdsIfNeeded } from "../helpers/hfIdPersist.js";
const PROJECT_SIGNATURE_META = "hyperframes-project-signature";
const GSAP_CDN_VERSION = "3.15.0";
const GSAP_CDN_SCRIPT = `<script src="https://cdn.jsdelivr.net/npm/gsap@${GSAP_CDN_VERSION}/dist/gsap.min.js"></script>`;
const GSAP_CUSTOM_EASE_CDN_SCRIPT = `<script src="https://cdn.jsdelivr.net/npm/gsap@${GSAP_CDN_VERSION}/dist/CustomEase.min.js"></script>`;
const GSAP_MOTION_PATH_CDN_SCRIPT = `<script src="https://cdn.jsdelivr.net/npm/gsap@${GSAP_CDN_VERSION}/dist/MotionPathPlugin.min.js"></script>`;
function resolveProjectSignature(adapter: StudioApiAdapter, projectDir: string): string {
return adapter.getProjectSignature?.(projectDir) ?? createProjectSignature(projectDir);
}
function injectProjectSignature(html: string, signature: string): string {
const tag = `<meta name="${PROJECT_SIGNATURE_META}" content="${signature}">`;
if (html.includes(`name="${PROJECT_SIGNATURE_META}"`)) {
return html.replace(
new RegExp(`<meta\\s+name=["']${PROJECT_SIGNATURE_META}["'][^>]*>`, "i"),
tag,
);
}
if (html.includes("</head>")) return html.replace("</head>", `${tag}\n</head>`);
return `${tag}\n${html}`;
}
function readStudioMotionManifestContent(projectDir: string): string {
const manifestPath = join(projectDir, STUDIO_MOTION_PATH);
if (!existsSync(manifestPath)) return "";
try {
return readFileSync(manifestPath, "utf-8");
} catch {
return "";
}
}
function parseStudioMotionManifestContent(content: string): {
hasMotion: boolean;
hasCustomEase: boolean;
} {
try {
const parsed = JSON.parse(content) as { motions?: Array<{ customEase?: unknown }> };
const motions = Array.isArray(parsed.motions) ? parsed.motions : [];
return {
hasMotion: motions.length > 0,
hasCustomEase: motions.some((motion) => Boolean(motion?.customEase)),
};
} catch {
return { hasMotion: false, hasCustomEase: false };
}
}
function injectScriptTagIntoHead(html: string, scriptTag: string): string {
if (html.includes("</head>")) return html.replace("</head>", `${scriptTag}\n</head>`);
return `${scriptTag}\n${html}`;
}
function htmlHasGsap(html: string): boolean {
// Only match GSAP references outside <template> elements — scripts inside
// templates are inert when cloned and don't make GSAP globally available.
const outsideTemplates = html.replace(/<template\b[^>]*>[\s\S]*?<\/template>/gi, "");
return (
/<script\b[^>]*src=["'][^"']*gsap/i.test(outsideTemplates) ||
/\/\*\s*inlined:.*gsap/i.test(outsideTemplates) ||
/\b(GreenSock|_gsScope)\b/.test(outsideTemplates) ||
/\bgsap\.(config|defaults|registerPlugin|version)\b/.test(outsideTemplates)
);
}
function htmlHasCustomEase(html: string): boolean {
return (
/<script\b[^>]*src=["'][^"']*CustomEase/i.test(html) ||
/\bwindow\.CustomEase\b/.test(html) ||
/\bCustomEase\s*=\s*/.test(html)
);
}
// A composition that drives motion via GSAP's `motionPath` (e.g. a studio-created
// motion path written into the single-source timeline) needs MotionPathPlugin
// registered before the timeline first renders — otherwise the initial seek
// throws "Invalid property motionPath ... Missing plugin?". Detect it anywhere in
// the bundle (the plugin registers globally, so sub-composition usage counts too).
function htmlUsesMotionPath(html: string): boolean {
return /motionPath\s*[:{]/.test(html);
}
function htmlHasMotionPathPlugin(html: string): boolean {
return (
/<script\b[^>]*src=["'][^"']*MotionPathPlugin/i.test(html) ||
/\bwindow\.MotionPathPlugin\b/.test(html) ||
/\bMotionPathPlugin\s*=\s*/.test(html)
);
}
function injectMotionPathPluginIfNeeded(html: string): string {
if (!htmlUsesMotionPath(html) || htmlHasMotionPathPlugin(html)) return html;
// The plugin registers onto an already-loaded gsap, so it must come AFTER the
// core gsap script — which often lives at body-end, not <head>. Insert it
// directly after the gsap script tag; only fall back to <head> if none is found
// (e.g. gsap is inlined).
const gsapScript = /<script\b[^>]*\bsrc=["'][^"']*\/gsap(\.min)?\.js["'][^>]*>\s*<\/script>/i;
const match = html.match(gsapScript);
if (match) {
// Match the plugin version to the composition's own gsap so the plugin
// registers cleanly (a minor-version skew triggers a GSAP compatibility warning).
const version = match[0].match(/gsap@([\d.]+)/)?.[1] ?? GSAP_CDN_VERSION;
const pluginTag = `<script src="https://cdn.jsdelivr.net/npm/gsap@${version}/dist/MotionPathPlugin.min.js"></script>`;
const end = html.indexOf(match[0]) + match[0].length;
return html.slice(0, end) + "\n" + pluginTag + html.slice(end);
}
return injectScriptTagIntoHead(html, GSAP_MOTION_PATH_CDN_SCRIPT);
}
function injectStudioMotionDependencies(html: string, manifestContent: string): string {
const manifest = parseStudioMotionManifestContent(manifestContent);
if (!manifest.hasMotion) return html;
let next = html;
if (!htmlHasGsap(next)) next = injectScriptTagIntoHead(next, GSAP_CDN_SCRIPT);
if (manifest.hasCustomEase && !htmlHasCustomEase(next)) {
next = injectScriptTagIntoHead(next, GSAP_CUSTOM_EASE_CDN_SCRIPT);
}
return next;
}
function injectStudioMotionScript(
html: string,
projectDir: string,
activeCompositionPath: string,
): string {
const manifestContent = readStudioMotionManifestContent(projectDir);
const script = createStudioMotionRenderBodyScript(manifestContent, {
activeCompositionPath,
});
if (!script) return html;
return injectScriptsIntoHtml(
injectStudioMotionDependencies(html, manifestContent),
[],
[script],
false,
);
}
const GSAP_CDN_FALLBACK_SCRIPT = `<script data-hf-gsap-fallback>
(function(){
var cdnBase="https://cdn.jsdelivr.net/npm/gsap@${GSAP_CDN_VERSION}/dist/";
var loaded={};
function loadFallback(file){
if(loaded[file])return loaded[file];
return loaded[file]=new Promise(function(ok,fail){
var s=document.createElement("script");
s.src=cdnBase+file;s.onload=ok;s.onerror=fail;
document.head.appendChild(s);
});
}
document.addEventListener("error",function(e){
var t=e.target;
if(!t||t.tagName!=="SCRIPT"||!t.src)return;
var m=t.src.match(/gsap[^/]*\\/dist\\/(.+\\.js)/);
if(m)loadFallback(m[1]);
},true);
})();
</script>`;
function injectGsapCdnFallback(html: string): string {
if (html.includes("data-hf-gsap-fallback")) return html;
if (html.includes("<head>")) return html.replace("<head>", "<head>" + GSAP_CDN_FALLBACK_SCRIPT);
return GSAP_CDN_FALLBACK_SCRIPT + html;
}
function injectStudioPreviewAugmentations(
html: string,
adapter: StudioApiAdapter,
projectDir: string,
activeCompositionPath: string,
): string {
return injectStudioMotionScript(
injectMotionPathPluginIfNeeded(
injectGsapCdnFallback(
injectProjectSignature(html, resolveProjectSignature(adapter, projectDir)),
),
),
projectDir,
activeCompositionPath,
);
}
async function transformPreviewHtml(
html: string,
adapter: StudioApiAdapter,
project: { id: string; dir: string; title?: string; sessionId?: string },
activeCompositionPath: string,
): Promise<string> {
if (!adapter.transformPreviewHtml) return html;
try {
return await adapter.transformPreviewHtml({
html,
project,
activeCompositionPath,
});
} catch (err) {
console.warn("[Studio] preview transform failed, using original HTML:", err);
return html;
}
}
function resolveProjectMainHtml(
projectDir: string,
projectId: string,
): { html: string; compositionPath: string } | null {
const indexPath = join(projectDir, "index.html");
if (existsSync(indexPath)) {
return { html: readFileSync(indexPath, "utf-8"), compositionPath: "index.html" };
}
const blockHtmlPath = join(projectDir, `${projectId}.html`);
if (existsSync(blockHtmlPath)) {
return { html: readFileSync(blockHtmlPath, "utf-8"), compositionPath: `${projectId}.html` };
}
return null;
}
export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): void {
const previewCacheHeaders = (etag: string) => ({
"Cache-Control": "private, no-cache",
ETag: etag,
});
// Bundled composition preview
// fallow-ignore-next-line complexity
api.get("/projects/:id/preview", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
const signature = resolveProjectSignature(adapter, project.dir);
const etag = `"preview:${signature}"`;
const ifNoneMatch = c.req.header("If-None-Match");
if (ifNoneMatch === etag) {
return new Response(null, { status: 304, headers: previewCacheHeaders(etag) });
}
// Normalize + persist data-hf-id to disk before bundle reads it. Idempotent.
const diskMain = resolveProjectMainHtml(project.dir, project.id);
const normalizedDisk = diskMain
? persistHfIdsIfNeeded(join(project.dir, diskMain.compositionPath), diskMain.html)
: null;
try {
let bundled = await adapter.bundle(project.dir);
let mainCompositionPath = "index.html";
if (!bundled) {
if (!diskMain) return c.text("not found", 404);
// Disk HTML may carry a baked inline runtime from a prior export; strip
// it so the preview runtime injected below isn't double-loaded (the
// bundled path already strips via htmlBundler). Idempotent if absent.
bundled = stripEmbeddedRuntimeScripts(normalizedDisk ?? diskMain.html);
mainCompositionPath = diskMain.compositionPath;
}
// Inject runtime if not already present (check URL pattern and bundler attribute)
if (
!bundled.includes("hyperframe.runtime") &&
!bundled.includes("hyperframes-preview-runtime")
) {
const runtimeTag = `<script src="${adapter.runtimeUrl}"></script>`;
bundled = bundled.includes("</body>")
? bundled.replace("</body>", `${runtimeTag}\n</body>`)
: bundled + `\n${runtimeTag}`;
}
// Inject <base> for relative asset resolution
const baseHref = `/api/projects/${project.id}/preview/`;
if (!bundled.includes("<base")) {
bundled = bundled.replace(/<head>/i, `<head><base href="${baseHref}">`);
}
// ensureHfIds runs after transformPreviewHtml in case the adapter injected
// new elements. On the no-bundle path bundled=normalizedDisk (already tagged)
// so this is idempotent. On the bundled path the bundler may return untagged
// HTML (stale cache); because ids are content-keyed the minted ids will match
// the ids already written to disk by persistHfIdsIfNeeded above.
bundled = injectStudioPreviewAugmentations(
ensureHfIds(await transformPreviewHtml(bundled, adapter, project, mainCompositionPath)),
adapter,
project.dir,
mainCompositionPath,
);
return c.html(bundled, 200, previewCacheHeaders(etag));
} catch {
// Re-read disk on bundle failure so we serve the latest file content,
// not the pre-request snapshot that may have been saved over.
const fallback = resolveProjectMainHtml(project.dir, project.id);
if (fallback) {
const fallbackHtml = persistHfIdsIfNeeded(
join(project.dir, fallback.compositionPath),
fallback.html,
);
return c.html(
injectStudioPreviewAugmentations(
await transformPreviewHtml(fallbackHtml, adapter, project, fallback.compositionPath),
adapter,
project.dir,
fallback.compositionPath,
),
200,
previewCacheHeaders(etag),
);
}
return c.text("not found", 404);
}
});
// Sub-composition preview
api.get("/projects/:id/preview/comp/*", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
const signature = resolveProjectSignature(adapter, project.dir);
const compPath = decodeURIComponent(
c.req.path.replace(`/projects/${project.id}/preview/comp/`, "").split("?")[0] ?? "",
);
const compFile = resolveWithinProject(project.dir, compPath);
if (!compFile || !existsSync(compFile) || !statSync(compFile).isFile()) {
return c.text("not found", 404);
}
const etag = `"comp:${compPath}:${signature}"`;
const ifNoneMatch = c.req.header("If-None-Match");
if (ifNoneMatch === etag) {
return new Response(null, { status: 304, headers: previewCacheHeaders(etag) });
}
const baseHref = `/api/projects/${project.id}/preview/`;
let html = buildSubCompositionHtml(project.dir, compPath, adapter.runtimeUrl, baseHref);
if (!html) return c.text("not found", 404);
html = ensureHfIds(await transformPreviewHtml(html, adapter, project, compPath));
return c.html(
injectStudioPreviewAugmentations(html, adapter, project.dir, compPath),
200,
previewCacheHeaders(etag),
);
});
// Static asset serving (with range request support for audio/video seeking)
// fallow-ignore-next-line complexity
api.get("/projects/:id/preview/*", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
const subPath = decodeURIComponent(
c.req.path.replace(`/projects/${project.id}/preview/`, "").split("?")[0] ?? "",
);
const file = resolveWithinProject(project.dir, subPath);
if (!file) {
return c.text("not found", 404);
}
const stat = existsSync(file) ? statSync(file) : null;
if (!stat?.isFile()) {
return c.text("not found", 404);
}
const contentType = getMimeType(subPath);
const isText = /\.(html|css|js|json|svg|txt|md|cube)$/i.test(subPath);
const etag = `"${stat.mtimeMs.toString(36)}-${stat.size.toString(36)}"`;
const cacheHeaders: Record<string, string> = isText
? { "Cache-Control": "no-store" }
: { "Cache-Control": "private, max-age=3600, must-revalidate", ETag: etag };
if (!isText) {
const ifNoneMatch = c.req.header("If-None-Match");
if (ifNoneMatch === etag) {
return new Response(null, { status: 304, headers: cacheHeaders });
}
}
const buffer: Buffer = isText
? Buffer.from(readFileSync(file, "utf-8"), "utf-8")
: readFileSync(file);
const totalSize = buffer.length;
// Support byte-range requests so browsers can seek audio/video elements.
const rangeHeader = c.req.header("Range");
if (rangeHeader) {
const match = /bytes=(\d+)-(\d*)/.exec(rangeHeader);
if (match) {
const start = parseInt(match[1]!, 10);
const end = match[2] ? parseInt(match[2], 10) : totalSize - 1;
const safeEnd = Math.min(end, totalSize - 1);
const chunkSize = safeEnd - start + 1;
return new Response(new Uint8Array(buffer.slice(start, safeEnd + 1)), {
status: 206,
headers: {
...cacheHeaders,
"Content-Type": contentType,
"Content-Range": `bytes ${start}-${safeEnd}/${totalSize}`,
"Accept-Ranges": "bytes",
"Content-Length": String(chunkSize),
},
});
}
}
return new Response(new Uint8Array(buffer), {
headers: {
...cacheHeaders,
"Content-Type": contentType,
"Accept-Ranges": "bytes",
"Content-Length": String(totalSize),
},
});
});
}
@@ -0,0 +1,91 @@
import { afterEach, describe, expect, it } from "vitest";
import { Hono } from "hono";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { registerProjectRoutes } from "./projects";
import type { StudioApiAdapter } from "../types";
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
const COMPOSITION_HTML = '<html><body><div data-composition-id="main"></div></body></html>';
// Project layout for #1384: real compositions at the root and under
// compositions/, plus dot-directory content that exercises discovery gating
// and the file tree's backup-only hiding (#1366):
// - .cache/examples/ a vendored dot-dir. walkDir does NOT special-case
// it, so it stays listed in the file tree, but it is
// gated out of composition discovery (isInHiddenOrVendorDir).
// - .hyperframes/examples/ vendored under Studio's dir — also listed in the
// file tree, also gated out of discovery.
// - .hyperframes/backup/ Studio's internal snapshots — the only thing hidden
// from the file tree (walkDir's shouldIgnoreDir).
function createProjectDir(): string {
const projectDir = mkdtempSync(join(tmpdir(), "hf-projects-test-"));
tempDirs.push(projectDir);
writeFileSync(join(projectDir, "index.html"), COMPOSITION_HTML);
mkdirSync(join(projectDir, "compositions"));
writeFileSync(join(projectDir, "compositions", "scene.html"), COMPOSITION_HTML);
mkdirSync(join(projectDir, ".cache", "examples"), { recursive: true });
writeFileSync(join(projectDir, ".cache", "examples", "preset.html"), COMPOSITION_HTML);
mkdirSync(join(projectDir, ".hyperframes", "examples"), { recursive: true });
writeFileSync(join(projectDir, ".hyperframes", "examples", "preset.html"), COMPOSITION_HTML);
mkdirSync(join(projectDir, ".hyperframes", "backup"), { recursive: true });
writeFileSync(join(projectDir, ".hyperframes", "backup", "snapshot.html"), COMPOSITION_HTML);
return projectDir;
}
function createAdapter(projectDir: string): StudioApiAdapter {
return {
listProjects: () => [],
resolveProject: async (id: string) => ({ id, dir: projectDir }),
bundle: async () => null,
lint: async () => ({ findings: [] }),
runtimeUrl: "/api/runtime.js",
rendersDir: () => "/tmp/renders",
startRender: () => ({
id: "job-1",
status: "rendering",
progress: 0,
outputPath: "/tmp/out.mp4",
}),
};
}
describe("registerProjectRoutes — composition discovery (#1384)", () => {
it("excludes HTML inside dot-directories from compositions", async () => {
const projectDir = createProjectDir();
const app = new Hono();
registerProjectRoutes(app, createAdapter(projectDir));
const response = await app.request("http://localhost/projects/demo");
const payload = (await response.json()) as { compositions?: string[] };
expect(response.status).toBe(200);
expect(payload.compositions).toContain("index.html");
expect(payload.compositions).toContain("compositions/scene.html");
expect(payload.compositions).not.toContain(".cache/examples/preset.html");
expect(payload.compositions).not.toContain(".hyperframes/examples/preset.html");
});
it("lists vendored dot-directory files in the file tree but hides Studio backups", async () => {
const projectDir = createProjectDir();
const app = new Hono();
registerProjectRoutes(app, createAdapter(projectDir));
const response = await app.request("http://localhost/projects/demo");
const payload = (await response.json()) as { files?: string[] };
// Vendored dot-dirs stay browsable — discovery is gated, the file tree is not.
expect(payload.files).toContain(".cache/examples/preset.html");
expect(payload.files).toContain(".hyperframes/examples/preset.html");
// Only Studio's own backup snapshots are hidden from the tree (#1366).
expect(payload.files).not.toContain(".hyperframes/backup/snapshot.html");
});
});
@@ -0,0 +1,50 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import type { Hono } from "hono";
import type { StudioApiAdapter } from "../types.js";
import { isInHiddenOrVendorDir, walkDir } from "../helpers/safePath.js";
const COMPOSITION_ID_RE = /data-composition-id\s*=/;
async function filterCompositionFiles(projectDir: string, files: string[]): Promise<string[]> {
const htmlFiles = files.filter((f) => f.endsWith(".html") && !isInHiddenOrVendorDir(f));
const checks = await Promise.all(
htmlFiles.map(async (f) => {
try {
const content = await readFile(join(projectDir, f), "utf-8");
return COMPOSITION_ID_RE.test(content);
} catch {
return false;
}
}),
);
return htmlFiles.filter((_, i) => checks[i]);
}
export function registerProjectRoutes(api: Hono, adapter: StudioApiAdapter): void {
// List all projects
api.get("/projects", async (c) => {
const projects = await adapter.listProjects();
return c.json({ projects });
});
// Resolve session to project (multi-project mode)
api.get("/resolve-session/:sessionId", async (c) => {
if (!adapter.resolveSession) {
return c.json({ error: "not available" }, 404);
}
const { sessionId } = c.req.param();
const result = await adapter.resolveSession(sessionId);
if (!result) return c.json({ error: "Session not found" }, 404);
return c.json(result);
});
// Project file tree
api.get("/projects/:id", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
const files = walkDir(project.dir);
const compositions = await filterCompositionFiles(project.dir, files);
return c.json({ id: project.id, dir: project.dir, title: project.title, files, compositions });
});
}
@@ -0,0 +1,34 @@
import type { Hono } from "hono";
import type { StudioApiAdapter } from "../types.js";
export function registerRegistryRoutes(api: Hono, adapter: StudioApiAdapter): void {
api.get("/registry/blocks", async (c) => {
if (!adapter.listRegistryCatalog) {
return c.json({ error: "Registry not available" }, 501);
}
const items = await adapter.listRegistryCatalog();
return c.json(items);
});
// fallow-ignore-next-line complexity
api.post("/projects/:id/registry/install", async (c) => {
if (!adapter.installRegistryBlock) {
return c.json({ error: "Registry install not available" }, 501);
}
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "Project not found" }, 404);
const body = await c.req.json<{ blockName?: string }>().catch(() => null);
if (!body?.blockName) {
return c.json({ error: "blockName is required" }, 400);
}
try {
const result = await adapter.installRegistryBlock({ project, blockName: body.blockName });
return c.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : "Install failed";
return c.json({ error: message }, 500);
}
});
}
@@ -0,0 +1,490 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { Hono } from "hono";
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { VALID_CANVAS_RESOLUTIONS } from "@hyperframes/parsers";
import { registerRenderRoutes } from "./render";
import type { StudioApiAdapter } from "../types";
function createAdapter(
startRenderSpy: ReturnType<typeof vi.fn>,
rendersDir = mkdtempSync(join(tmpdir(), "hf-render-test-")),
): { adapter: StudioApiAdapter; rendersDir: string } {
const adapter: StudioApiAdapter = {
listProjects: () => [],
// Use a real, existing dir: isSafePath() canonicalizes the project dir with
// realpath and fails closed if it doesn't exist (real projects always do).
resolveProject: async (id: string) => ({ id, dir: tmpdir() }),
bundle: async () => null,
lint: async () => ({ findings: [] }),
runtimeUrl: "/api/runtime.js",
rendersDir: () => rendersDir,
startRender: (opts) => {
startRenderSpy(opts);
return {
id: opts.jobId,
status: "rendering",
progress: 0,
outputPath: opts.outputPath,
};
},
};
return { adapter, rendersDir };
}
function buildApp(spy: ReturnType<typeof vi.fn>): { app: Hono; cleanup: () => void } {
const { adapter, rendersDir } = createAdapter(spy);
const app = new Hono();
registerRenderRoutes(app, adapter);
return { app, cleanup: () => rmSync(rendersDir, { recursive: true, force: true }) };
}
describe("POST /projects/:id/render — outputResolution forwarding", () => {
it("forwards a valid resolution preset to the adapter", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
fps: 30,
quality: "high",
format: "mp4",
resolution: "landscape-4k",
}),
});
expect(res.status).toBe(200);
expect(spy).toHaveBeenCalledOnce();
const opts = spy.mock.calls[0][0];
expect(opts.outputResolution).toBe("landscape-4k");
} finally {
cleanup();
}
});
it("omits outputResolution when the request does not specify one", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4" }),
});
expect(res.status).toBe(200);
const opts = spy.mock.calls[0][0];
expect(opts.outputResolution).toBeUndefined();
} finally {
cleanup();
}
});
it("drops an invalid resolution string (defense-in-depth, not a 400)", async () => {
// The route is intentionally lenient on unknown enum values — the producer
// is the source of truth for validation and emits a clear error message.
// We just want to make sure garbage doesn't propagate as if it were valid.
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4", resolution: "8k" }),
});
expect(res.status).toBe(200);
const opts = spy.mock.calls[0][0];
expect(opts.outputResolution).toBeUndefined();
} finally {
cleanup();
}
});
it("accepts each canonical preset value", async () => {
for (const preset of VALID_CANVAS_RESOLUTIONS) {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4", resolution: preset }),
});
expect(spy.mock.calls[0][0].outputResolution).toBe(preset);
} finally {
cleanup();
}
}
});
});
describe("POST /projects/:id/render — composition forwarding", () => {
it("forwards a valid composition path to the adapter", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
fps: 30,
quality: "standard",
format: "mp4",
composition: "compositions/intro.html",
}),
});
expect(res.status).toBe(200);
expect(spy).toHaveBeenCalledOnce();
expect(spy.mock.calls[0][0].composition).toBe("compositions/intro.html");
} finally {
cleanup();
}
});
it("omits composition when not specified", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4" }),
});
expect(res.status).toBe(200);
expect(spy.mock.calls[0][0].composition).toBeUndefined();
} finally {
cleanup();
}
});
it("omits composition when empty string", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4", composition: "" }),
});
expect(res.status).toBe(200);
expect(spy.mock.calls[0][0].composition).toBeUndefined();
} finally {
cleanup();
}
});
it("rejects path-traversal attempts with 400", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
fps: 30,
quality: "standard",
format: "mp4",
composition: "../../../etc/passwd",
}),
});
expect(res.status).toBe(400);
expect(spy).not.toHaveBeenCalled();
} finally {
cleanup();
}
});
});
describe("POST /projects/:id/render — fps wire format", () => {
// The fps fraction-syntax feature accepts JSON `number` (integer fps) and
// JSON `string` (ffmpeg-style rational) on the wire, normalizing both to
// the structured Fps form before invoking the adapter.
it("forwards integer fps as { num, den: 1 }", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fps: 60, quality: "standard", format: "mp4" }),
});
expect(spy.mock.calls[0][0].fps).toEqual({ num: 60, den: 1 });
} finally {
cleanup();
}
});
it("parses '30000/1001' string body as exact NTSC", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fps: "30000/1001", quality: "standard", format: "mp4" }),
});
expect(spy.mock.calls[0][0].fps).toEqual({ num: 30000, den: 1001 });
} finally {
cleanup();
}
});
it("falls back to 30/1 for malformed fps values", async () => {
// Matches the lenient handling of `quality` and `resolution` in the same
// route — the producer surfaces a clearer downstream error if the value
// is genuinely unusable.
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fps: "abc", quality: "standard", format: "mp4" }),
});
expect(spy.mock.calls[0][0].fps).toEqual({ num: 30, den: 1 });
} finally {
cleanup();
}
});
it("falls back to 30/1 when fps is omitted", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ quality: "standard", format: "mp4" }),
});
expect(spy.mock.calls[0][0].fps).toEqual({ num: 30, den: 1 });
} finally {
cleanup();
}
});
});
describe("POST /projects/:id/render — composition path safety", () => {
const tmpDirs: string[] = [];
function buildAppWithProjectDir(spy: ReturnType<typeof vi.fn>): {
app: Hono;
projectDir: string;
} {
const projectDir = mkdtempSync(join(tmpdir(), "hf-render-proj-"));
const rendersDir = mkdtempSync(join(tmpdir(), "hf-render-out-"));
tmpDirs.push(projectDir, rendersDir);
const adapter: StudioApiAdapter = {
listProjects: () => [],
resolveProject: async (id: string) => ({ id, dir: projectDir }),
bundle: async () => null,
lint: async () => ({ findings: [] }),
runtimeUrl: "/api/runtime.js",
rendersDir: () => rendersDir,
startRender: (opts) => {
spy(opts);
return { id: opts.jobId, status: "rendering", progress: 0, outputPath: opts.outputPath };
},
};
const app = new Hono();
registerRenderRoutes(app, adapter);
return { app, projectDir };
}
afterEach(() => {
for (const d of tmpDirs) rmSync(d, { recursive: true, force: true });
tmpDirs.length = 0;
});
async function postComposition(app: Hono, composition: string): Promise<Response> {
return app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4", composition }),
});
}
// Mirror the repo convention (preview.test.ts): skip symlink cases on
// non-symlink-privileged Windows runners rather than crash the suite.
function tryCreateSymlink(target: string, path: string, type: "dir" | "file"): boolean {
try {
symlinkSync(target, path, type);
return true;
} catch {
return false;
}
}
it("accepts a composition path inside the project directory", async () => {
const spy = vi.fn();
const { app } = buildAppWithProjectDir(spy);
const res = await postComposition(app, "scenes/intro.html");
expect(res.status).toBe(200);
expect(spy).toHaveBeenCalledOnce();
});
it("rejects a `..` traversal in the composition path", async () => {
const spy = vi.fn();
const { app } = buildAppWithProjectDir(spy);
const res = await postComposition(app, "../../etc/passwd");
expect(res.status).toBe(400);
expect(spy).not.toHaveBeenCalled();
});
it("rejects a composition reached through an in-project symlink pointing outside the project", async () => {
const spy = vi.fn();
const { app, projectDir } = buildAppWithProjectDir(spy);
const external = mkdtempSync(join(tmpdir(), "hf-render-external-"));
tmpDirs.push(external);
writeFileSync(join(external, "secret.html"), "<html></html>");
if (!tryCreateSymlink(external, join(projectDir, "link"), "dir")) return;
const res = await postComposition(app, "link/secret.html");
expect(res.status).toBe(400);
expect(spy).not.toHaveBeenCalled();
});
it("allows a composition reached through an in-project symlink that stays inside the project", async () => {
const spy = vi.fn();
const { app, projectDir } = buildAppWithProjectDir(spy);
mkdirSync(join(projectDir, "real"));
writeFileSync(join(projectDir, "real", "scene.html"), "<html></html>");
if (!tryCreateSymlink(join(projectDir, "real"), join(projectDir, "alias"), "dir")) return;
const res = await postComposition(app, "alias/scene.html");
expect(res.status).toBe(200);
expect(spy).toHaveBeenCalledOnce();
});
});
describe("GET /projects/:id/renders/file/* — path safety", () => {
const tmpDirs: string[] = [];
function buildApp(): { app: Hono; rendersDir: string } {
const rendersDir = mkdtempSync(join(tmpdir(), "hf-renders-out-"));
tmpDirs.push(rendersDir);
const adapter: StudioApiAdapter = {
listProjects: () => [],
resolveProject: async (id: string) => ({ id, dir: tmpdir() }),
bundle: async () => null,
lint: async () => ({ findings: [] }),
runtimeUrl: "/api/runtime.js",
rendersDir: () => rendersDir,
startRender: (opts) => ({
id: opts.jobId,
status: "rendering",
progress: 0,
outputPath: opts.outputPath,
}),
};
const app = new Hono();
registerRenderRoutes(app, adapter);
return { app, rendersDir };
}
// Mirror the repo convention (preview.test.ts / composition tests above):
// skip symlink cases on non-symlink-privileged Windows runners.
function tryCreateSymlink(target: string, path: string, type: "dir" | "file"): boolean {
try {
symlinkSync(target, path, type);
return true;
} catch {
return false;
}
}
afterEach(() => {
for (const d of tmpDirs) rmSync(d, { recursive: true, force: true });
tmpDirs.length = 0;
});
it("serves a render file that lives inside rendersDir", async () => {
const { app, rendersDir } = buildApp();
writeFileSync(join(rendersDir, "demo.mp4"), "render-bytes");
const res = await app.request("http://localhost/projects/demo/renders/file/demo.mp4");
expect(res.status).toBe(200);
expect(await res.text()).toBe("render-bytes");
});
it("rejects a file reached through a symlink inside rendersDir pointing outside it", async () => {
const { app, rendersDir } = buildApp();
// A bare join()+readFileSync followed the symlink and leaked the target;
// the resolveWithinProject chokepoint canonicalizes with realpath first.
const external = mkdtempSync(join(tmpdir(), "hf-renders-external-"));
tmpDirs.push(external);
writeFileSync(join(external, "secret.txt"), "TOP-SECRET");
if (!tryCreateSymlink(join(external, "secret.txt"), join(rendersDir, "leak.txt"), "file"))
return;
const res = await app.request("http://localhost/projects/demo/renders/file/leak.txt");
expect(res.status).toBe(403);
expect(await res.text()).not.toContain("TOP-SECRET");
});
it("serves a render file reached through a symlink that stays inside rendersDir", async () => {
const { app, rendersDir } = buildApp();
mkdirSync(join(rendersDir, "nested"));
writeFileSync(join(rendersDir, "nested", "clip.mp4"), "nested-bytes");
if (!tryCreateSymlink(join(rendersDir, "nested"), join(rendersDir, "alias"), "dir")) return;
const res = await app.request("http://localhost/projects/demo/renders/file/alias/clip.mp4");
expect(res.status).toBe(200);
expect(await res.text()).toBe("nested-bytes");
});
});
describe("POST /projects/:id/render — telemetryDistinctId forwarding", () => {
it("forwards the browser telemetryDistinctId to the adapter as distinctId", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
fps: 30,
quality: "standard",
format: "mp4",
telemetryDistinctId: "browser-user-123",
}),
});
expect(res.status).toBe(200);
expect(spy.mock.calls[0][0].distinctId).toBe("browser-user-123");
} finally {
cleanup();
}
});
it("passes undefined when no telemetryDistinctId is sent (older clients)", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4" }),
});
expect(res.status).toBe(200);
expect(spy.mock.calls[0][0].distinctId).toBeUndefined();
} finally {
cleanup();
}
});
it("ignores a non-string telemetryDistinctId", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
fps: 30,
quality: "standard",
format: "mp4",
telemetryDistinctId: 42,
}),
});
expect(res.status).toBe(200);
expect(spy.mock.calls[0][0].distinctId).toBeUndefined();
} finally {
cleanup();
}
});
});
+294
View File
@@ -0,0 +1,294 @@
import type { Hono } from "hono";
import { streamSSE } from "hono/streaming";
import { existsSync, readFileSync, mkdirSync, unlinkSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import type { StudioApiAdapter, RenderJobState } from "../types.js";
import { VALID_CANVAS_RESOLUTIONS, type CanvasResolution } from "@hyperframes/parsers";
import { parseFps } from "@hyperframes/core";
import { resolveWithinProject } from "../helpers/safePath.js";
const VALID_RESOLUTIONS = new Set<string>(VALID_CANVAS_RESOLUTIONS);
export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void {
// Scoped job store — not shared across createStudioApi() calls
const renderJobs = new Map<string, RenderJobState & { createdAt: number }>();
// TTL cleanup for completed jobs (5 minutes)
const TTL_MS = 300_000;
const CLEANUP_INTERVAL_MS = 60_000;
let cleanupTimer: ReturnType<typeof setInterval> | null = null;
const cleanupEnabled = () =>
typeof process !== "undefined" &&
process.env.NODE_ENV !== "production" &&
!process.argv.includes("build");
const cleanupFinishedJobs = () => {
const now = Date.now();
for (const [key, job] of renderJobs) {
if ((job.status === "complete" || job.status === "failed") && now - job.createdAt > TTL_MS) {
renderJobs.delete(key);
}
}
if (renderJobs.size === 0 && cleanupTimer) {
clearInterval(cleanupTimer);
cleanupTimer = null;
}
};
const ensureCleanupTimer = () => {
if (cleanupTimer || !cleanupEnabled()) return;
cleanupTimer = setInterval(cleanupFinishedJobs, CLEANUP_INTERVAL_MS);
if (typeof cleanupTimer === "object" && "unref" in cleanupTimer) {
cleanupTimer.unref();
}
};
ensureCleanupTimer();
// Start a render
api.post("/projects/:id/render", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
const body = (await c.req.json().catch(() => ({}))) as {
// Polymorphic per design note in core.types.Fps:
// number → integer fps (e.g. 30)
// string → rational fps (e.g. "30000/1001" for NTSC 29.97)
// Decimals are rejected on purpose so the exact denominator stays
// unambiguous (29.97 ≠ 30000/1001 when ffmpeg consumes them).
fps?: number | string;
quality?: string;
format?: string;
resolution?: string;
composition?: string;
// Browser telemetry id, so the server-emitted render outcome is
// attributed to the user who triggered the render (joinable funnel).
telemetryDistinctId?: string;
};
const VALID_FORMATS = new Set(["mp4", "webm", "mov"]);
const FORMAT_EXT: Record<string, string> = { mp4: ".mp4", webm: ".webm", mov: ".mov" };
const format = VALID_FORMATS.has(body.format ?? "") ? (body.format as string) : "mp4";
// Default to 30 fps when unset or unparseable. The route stays lenient on
// invalid fps values (matching the lenient handling of `resolution` and
// `quality` already in this file) — the producer surfaces a clearer error
// message if the caller really did mean to fail loudly.
const fpsParse = body.fps === undefined ? null : parseFps(body.fps);
const fps = fpsParse && fpsParse.ok ? fpsParse.value : { num: 30, den: 1 };
const quality = ["draft", "standard", "high"].includes(body.quality ?? "")
? (body.quality as string)
: "standard";
const outputResolution = VALID_RESOLUTIONS.has(body.resolution ?? "")
? (body.resolution as CanvasResolution)
: undefined;
let composition: string | undefined;
if (typeof body.composition === "string" && body.composition.length > 0) {
// `body.composition` is attacker-controlled (from c.req.json()).
// resolveWithinProject dereferences symlinks, so an in-project symlink
// pointing outside the root can't smuggle the render target out.
if (!resolveWithinProject(project.dir, body.composition)) {
return c.json({ error: "composition path must be within the project directory" }, 400);
}
composition = body.composition;
}
// fallow-ignore-next-line code-duplication
const now = new Date();
const datePart = now.toISOString().slice(0, 10);
const timePart = now.toTimeString().slice(0, 8).replace(/:/g, "-");
const jobId = `${project.id}_${datePart}_${timePart}`;
const rendersDir = adapter.rendersDir(project);
if (!existsSync(rendersDir)) mkdirSync(rendersDir, { recursive: true });
const ext = FORMAT_EXT[format] ?? ".mp4";
const outputPath = join(rendersDir, `${jobId}${ext}`);
const jobState = adapter.startRender({
project,
outputPath,
format: format as "mp4" | "webm" | "mov",
fps,
quality,
jobId,
outputResolution,
composition,
distinctId:
typeof body.telemetryDistinctId === "string" ? body.telemetryDistinctId : undefined,
});
(jobState as RenderJobState & { createdAt: number }).createdAt = Date.now();
renderJobs.set(jobId, jobState as RenderJobState & { createdAt: number });
ensureCleanupTimer();
return c.json({ jobId, status: "rendering" });
});
// SSE progress stream
api.get("/render/:jobId/progress", (c) => {
const { jobId } = c.req.param();
const job = renderJobs.get(jobId);
if (!job) return c.json({ error: "not found" }, 404);
return streamSSE(c, async (stream) => {
while (true) {
const current = renderJobs.get(jobId);
if (!current) break;
await stream.writeSSE({
event: "progress",
data: JSON.stringify({
progress: current.progress,
status: current.status,
stage: current.stage,
error: current.error,
}),
});
if (current.status === "complete" || current.status === "failed") break;
await stream.sleep(500);
}
});
});
const RENDER_MIME: Record<string, string> = {
".mp4": "video/mp4",
".webm": "video/webm",
".mov": "video/quicktime",
};
const RENDER_EXTENSIONS = Object.keys(RENDER_MIME);
function renderContentType(filePath: string): string {
const ext = RENDER_EXTENSIONS.find((e) => filePath.endsWith(e));
return (ext && RENDER_MIME[ext]) ?? "video/mp4";
}
// Serve render inline (for in-browser playback — opens in a new tab)
api.get("/render/:jobId/view", (c) => {
const { jobId } = c.req.param();
const job = renderJobs.get(jobId);
if (!job?.outputPath || !existsSync(job.outputPath)) {
return c.json({ error: "not found" }, 404);
}
const contentType = renderContentType(job.outputPath);
const filename = job.outputPath.split("/").pop() ?? `render.mp4`;
const content = readFileSync(job.outputPath);
return new Response(content, {
headers: {
"Content-Type": contentType,
"Content-Disposition": `inline; filename="${filename}"`,
"Accept-Ranges": "bytes",
"Content-Length": String(content.length),
},
});
});
// Download render
api.get("/render/:jobId/download", (c) => {
const { jobId } = c.req.param();
const job = renderJobs.get(jobId);
if (!job?.outputPath || !existsSync(job.outputPath)) {
return c.json({ error: "not found" }, 404);
}
const contentType = renderContentType(job.outputPath);
const filename = job.outputPath.split("/").pop() ?? `render.mp4`;
const content = readFileSync(job.outputPath);
return new Response(content, {
headers: {
"Content-Type": contentType,
"Content-Disposition": `attachment; filename="${filename}"`,
},
});
});
// Delete render
api.delete("/render/:jobId", (c) => {
const { jobId } = c.req.param();
for (const [, state] of renderJobs) {
if (state.id === jobId && state.outputPath) {
const dir = state.outputPath.replace(/\/[^/]+$/, "");
for (const ext of [".mp4", ".webm", ".mov", ".meta.json"]) {
const fp = join(dir, `${jobId}${ext}`);
if (existsSync(fp)) unlinkSync(fp);
}
break;
}
}
renderJobs.delete(jobId);
return c.json({ deleted: true });
});
// Serve render file directly from disk (no in-memory map dependency)
api.get("/projects/:id/renders/file/*", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
const filename = c.req.path.split("/renders/file/")[1];
if (!filename) return c.json({ error: "missing filename" }, 400);
const rendersDir = adapter.rendersDir(project);
// Containment guard: the filename is attacker-controlled wildcard input, so
// route it through the same chokepoint every other project-scoped path uses.
// Literal `..` is collapsed upstream by the URL parser, but a bare join() +
// readFileSync still followed an in-rendersDir symlink pointing outside the
// dir; resolveWithinProject canonicalizes with realpath before serving.
const fp = resolveWithinProject(rendersDir, filename);
if (!fp) return c.json({ error: "forbidden" }, 403);
if (!existsSync(fp)) return c.json({ error: "not found" }, 404);
const contentType = renderContentType(fp);
const content = readFileSync(fp);
return new Response(content, {
headers: {
"Content-Type": contentType,
"Content-Disposition": `inline; filename="${filename}"`,
"Accept-Ranges": "bytes",
"Content-Length": String(content.length),
},
});
});
// List renders
api.get("/projects/:id/renders", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
const rendersDir = adapter.rendersDir(project);
if (!existsSync(rendersDir)) return c.json({ renders: [] });
const files = readdirSync(rendersDir)
.filter((f) => f.endsWith(".mp4") || f.endsWith(".webm") || f.endsWith(".mov"))
.map((f) => {
const fp = join(rendersDir, f);
const stat = statSync(fp);
const rid = f.replace(/\.(mp4|webm|mov)$/, "");
const metaPath = join(rendersDir, `${rid}.meta.json`);
let status: "complete" | "failed" = "complete";
let durationMs: number | undefined;
if (existsSync(metaPath)) {
try {
const meta = JSON.parse(readFileSync(metaPath, "utf-8"));
if (meta.status === "failed") status = "failed";
if (meta.durationMs) durationMs = meta.durationMs;
} catch {
/* ignore */
}
}
return {
id: rid,
filename: f,
size: stat.size,
createdAt: stat.mtimeMs,
status,
durationMs,
};
})
.sort((a, b) => b.createdAt - a.createdAt);
// Register on-disk renders that aren't in the current session's job map
// so they remain downloadable after a server restart.
for (const file of files) {
if (!renderJobs.has(file.id)) {
renderJobs.set(file.id, {
id: file.id,
status: file.status,
progress: 100,
outputPath: join(rendersDir, file.filename),
createdAt: file.createdAt,
} as RenderJobState & { createdAt: number });
}
}
return c.json({ renders: files });
});
}
@@ -0,0 +1,109 @@
import { afterEach, describe, expect, it } from "vitest";
import { Hono } from "hono";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { registerStoryboardRoutes } from "./storyboard.js";
import type { StudioApiAdapter } from "../types.js";
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
function makeProject(): string {
const dir = mkdtempSync(join(tmpdir(), "storyboard-route-"));
tempDirs.push(dir);
return dir;
}
function makeApp(projectDir: string): Hono {
const adapter = {
resolveProject: (id: string) => (id === "p" ? { id: "p", dir: projectDir } : null),
} as unknown as StudioApiAdapter;
const app = new Hono();
registerStoryboardRoutes(app, adapter);
return app;
}
/** Request the storyboard for project "p" and return status + parsed JSON body. */
async function getStoryboard(projectDir: string) {
const res = await makeApp(projectDir).request("/projects/p/storyboard");
return { status: res.status, body: await res.json() };
}
describe("GET /projects/:id/storyboard", () => {
it("returns exists:false with empty frames when STORYBOARD.md is absent", async () => {
const { status, body } = await getStoryboard(makeProject());
expect(status).toBe(200);
expect(body.exists).toBe(false);
expect(body.frames).toEqual([]);
});
it("404s for an unknown project", async () => {
const res = await makeApp(makeProject()).request("/projects/nope/storyboard");
expect(res.status).toBe(404);
});
it("parses the manifest and resolves frame src existence on disk", async () => {
const dir = makeProject();
mkdirSync(join(dir, "compositions", "frames"), { recursive: true });
writeFileSync(join(dir, "compositions", "frames", "01-hook.html"), "<div></div>");
writeFileSync(
join(dir, "STORYBOARD.md"),
`---
message: Hello world
---
## Frame 1 Hook
- status: built
- src: compositions/frames/01-hook.html
Opening line.
## Frame 2 Missing
- status: outline
- src: compositions/frames/02-missing.html
Not built yet.
`,
);
const { status, body } = await getStoryboard(dir);
expect(status).toBe(200);
expect(body.exists).toBe(true);
expect(body.globals.message).toBe("Hello world");
expect(body.frames).toHaveLength(2);
expect(body.frames[0]).toMatchObject({ title: "Hook", status: "built", srcExists: true });
expect(body.frames[1]).toMatchObject({ title: "Missing", status: "outline", srcExists: false });
});
it("surfaces the companion SCRIPT.md when present", async () => {
const dir = makeProject();
writeFileSync(join(dir, "STORYBOARD.md"), "## Frame 1\n\nHi.\n");
writeFileSync(join(dir, "SCRIPT.md"), "# Script\n\nLine 1.\n");
const { body } = await getStoryboard(dir);
expect(body.script).toMatchObject({ exists: true, path: "SCRIPT.md" });
expect(body.script.content).toContain("Line 1.");
});
it("reports script.exists=false when there is no SCRIPT.md", async () => {
const dir = makeProject();
writeFileSync(join(dir, "STORYBOARD.md"), "## Frame 1\n\nHi.\n");
const { body } = await getStoryboard(dir);
expect(body.script.exists).toBe(false);
});
it("does not resolve src paths that escape the project", async () => {
const dir = makeProject();
writeFileSync(
join(dir, "STORYBOARD.md"),
"## Frame 1\n- src: ../../etc/passwd\n\nEscape attempt.\n",
);
const { body } = await getStoryboard(dir);
expect(body.frames[0].srcExists).toBe(false);
});
});
@@ -0,0 +1,80 @@
import { existsSync, readFileSync } from "node:fs";
import type { Hono } from "hono";
import type { StudioApiAdapter } from "../types.js";
import { resolveWithinProject } from "../helpers/safePath.js";
import {
parseStoryboard,
SCRIPT_FILENAME,
STORYBOARD_FILENAME,
type StoryboardFrame,
} from "@hyperframes/core/storyboard";
/** A frame enriched with disk-resolution info the Studio needs to render tiles. */
interface ResolvedStoryboardFrame extends StoryboardFrame {
/** Whether `src` resolves to an existing file inside the project. */
srcExists: boolean;
}
function resolveFrames(projectDir: string, frames: StoryboardFrame[]): ResolvedStoryboardFrame[] {
return frames.map((frame) => {
let srcExists = false;
if (frame.src) {
const abs = resolveWithinProject(projectDir, frame.src);
srcExists = abs ? existsSync(abs) : false;
}
return { ...frame, srcExists };
});
}
/** Read the companion SCRIPT.md narration doc if it exists alongside the storyboard. */
function readScript(projectDir: string): { exists: boolean; path: string; content: string } {
const abs = resolveWithinProject(projectDir, SCRIPT_FILENAME);
if (abs && existsSync(abs)) {
try {
return { exists: true, path: SCRIPT_FILENAME, content: readFileSync(abs, "utf-8") };
} catch {
/* fall through to absent */
}
}
return { exists: false, path: SCRIPT_FILENAME, content: "" };
}
export function registerStoryboardRoutes(api: Hono, adapter: StudioApiAdapter): void {
// Parsed storyboard manifest for a project. Markdown (STORYBOARD.md) stays
// canonical on disk; this returns the derived, normalized structure. When the
// file is absent we return `exists: false` with empty frames rather than 404,
// so the Studio can render an opt-in empty state.
api.get("/projects/:id/storyboard", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
const abs = resolveWithinProject(project.dir, STORYBOARD_FILENAME);
if (!abs || !existsSync(abs)) {
return c.json({
exists: false,
path: STORYBOARD_FILENAME,
globals: { extra: {} },
frames: [],
warnings: [],
script: readScript(project.dir),
});
}
let source: string;
try {
source = readFileSync(abs, "utf-8");
} catch {
return c.json({ error: "failed to read storyboard" }, 500);
}
const manifest = parseStoryboard(source);
return c.json({
exists: true,
path: STORYBOARD_FILENAME,
globals: manifest.globals,
frames: resolveFrames(project.dir, manifest.frames),
warnings: manifest.warnings,
script: readScript(project.dir),
});
});
}
@@ -0,0 +1,201 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { Hono } from "hono";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { registerThumbnailRoutes } from "./thumbnail";
import type { StudioApiAdapter } from "../types";
const tempProjectDirs: string[] = [];
afterEach(() => {
for (const dir of tempProjectDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
function createAdapter(): StudioApiAdapter {
const projectDir = mkdtempSync(join(tmpdir(), "hf-thumbnail-test-"));
tempProjectDirs.push(projectDir);
return {
listProjects: () => [],
resolveProject: async (id: string) => ({ id, dir: projectDir }),
bundle: async () => null,
lint: async () => ({ findings: [] }),
runtimeUrl: "/api/runtime.js",
rendersDir: () => "/tmp/renders",
startRender: () => ({
id: "job-1",
status: "rendering",
progress: 0,
outputPath: "/tmp/out.mp4",
}),
generateThumbnail: vi.fn(async () => Buffer.from("thumb")),
};
}
describe("registerThumbnailRoutes", () => {
it("forwards selector queries 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=%23title-card",
);
expect(response.status).toBe(200);
expect(adapter.generateThumbnail).toHaveBeenCalledWith(
expect.objectContaining({
compPath: "index.html",
seekTime: 1.2,
selector: "#title-card",
format: "jpeg",
}),
);
});
it("forwards png capture requests and returns a png content type", async () => {
const adapter = createAdapter();
const app = new Hono();
registerThumbnailRoutes(app, adapter);
const response = await app.request(
"http://localhost/projects/demo/thumbnail/compositions%2Fintro.html?t=2&format=png",
);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("image/png");
expect(adapter.generateThumbnail).toHaveBeenCalledWith(
expect.objectContaining({
compPath: "compositions/intro.html",
seekTime: 2,
format: "png",
}),
);
});
it("preserves an explicit zero seek time", async () => {
const adapter = createAdapter();
const app = new Hono();
registerThumbnailRoutes(app, adapter);
const response = await app.request(
"http://localhost/projects/demo/thumbnail/index.html?t=0&format=png",
);
expect(response.status).toBe(200);
expect(adapter.generateThumbnail).toHaveBeenCalledWith(
expect.objectContaining({
compPath: "index.html",
seekTime: 0,
format: "png",
}),
);
});
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);
});
it("keeps changed studio motion 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 motionDir = join(project.dir, ".hyperframes");
mkdirSync(motionDir, { recursive: true });
const motionPath = join(motionDir, "studio-motion.json");
writeFileSync(motionPath, `{"version":1,"motions":[]}`);
await app.request("http://localhost/projects/demo/thumbnail/index.html?t=2&v=test");
writeFileSync(
motionPath,
`{"version":1,"motions":[{"kind":"gsap-motion","target":{"sourceFile":"index.html","id":"card"},"start":0,"duration":1,"ease":"power2.out","from":{"y":32},"to":{"y":0}}]}`,
);
await app.request("http://localhost/projects/demo/thumbnail/index.html?t=2&v=test");
expect(adapter.generateThumbnail).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,117 @@
import type { Hono } from "hono";
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { createHash } from "node:crypto";
import type { StudioApiAdapter } from "../types.js";
import { STUDIO_MANUAL_EDITS_PATH } from "../helpers/manualEditsRenderScript.js";
import { STUDIO_MOTION_PATH } from "../helpers/studioMotionRenderScript.js";
const THUMBNAIL_CACHE_VERSION = "v4";
export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): void {
api.get("/projects/:id/thumbnail/*", async (c) => {
if (!adapter.generateThumbnail) {
return c.json({ error: "Thumbnails not available" }, 501);
}
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
let compPath = decodeURIComponent(
c.req.path.replace(`/projects/${project.id}/thumbnail/`, "").split("?")[0] ?? "",
);
if (compPath && !compPath.includes(".")) compPath += ".html";
const url = new URL(c.req.url, `http://${c.req.header("host") || "localhost"}`);
const rawSeekTime = url.searchParams.get("t");
const parsedSeekTime = rawSeekTime == null ? Number.NaN : parseFloat(rawSeekTime);
const seekTime = Number.isFinite(parsedSeekTime) ? parsedSeekTime : 0.5;
const vpWidth = parseInt(url.searchParams.get("w") || "0") || 0;
const vpHeight = parseInt(url.searchParams.get("h") || "0") || 0;
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+)["']/);
if (wMatch?.[1]) compW = parseInt(wMatch[1]);
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 motionFile = join(project.dir, STUDIO_MOTION_PATH);
let motionKey = "";
if (existsSync(motionFile)) {
const motionContent = readFileSync(motionFile, "utf-8");
motionKey = `_${createHash("sha1").update(motionContent).digest("hex").slice(0, 16)}`;
sourceMtime = Math.max(sourceMtime, Math.round(statSync(motionFile).mtimeMs));
}
const previewUrl =
compPath === "index.html"
? `http://${c.req.header("host")}/api/projects/${project.id}/preview`
: `http://${c.req.header("host")}/api/projects/${project.id}/preview/comp/${compPath}`;
// Cache
const cacheDir = join(project.dir, ".thumbnails");
const selectorKey = selector
? `_${selector.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 80)}_${selectorIndex ?? 0}`
: "";
const urlVersionKey = urlVersion
? `_${urlVersion.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 32)}`
: "";
const cacheKey = `${THUMBNAIL_CACHE_VERSION}${urlVersionKey}${manualEditsKey}${motionKey}_${format}_${compPath.replace(/\//g, "_")}_${compW}x${compH}_${sourceMtime}_${seekTime.toFixed(2)}${selectorKey}.${format === "png" ? "png" : "jpg"}`;
const cachePath = join(cacheDir, cacheKey);
if (existsSync(cachePath)) {
return new Response(new Uint8Array(readFileSync(cachePath)), {
headers: { "Content-Type": contentType, "Cache-Control": "public, max-age=60" },
});
}
try {
const buffer = await adapter.generateThumbnail({
project,
compPath,
seekTime,
width: compW,
height: compH,
previewUrl,
selector,
format,
selectorIndex,
});
if (!buffer) {
return c.json(
{ error: "Thumbnail generation failed — Chrome browser may not be available" },
500,
);
}
if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });
writeFileSync(cachePath, buffer);
return new Response(new Uint8Array(buffer), {
headers: { "Content-Type": contentType, "Cache-Control": "public, max-age=60" },
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return c.json({ error: `Thumbnail generation failed: ${msg}` }, 500);
}
});
}
@@ -0,0 +1,46 @@
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import type { Hono } from "hono";
import type { StudioApiAdapter } from "../types.js";
import { decodeAudioPeaks, buildWaveformCacheKey } from "../helpers/waveform.js";
export function registerWaveformRoutes(api: Hono, adapter: StudioApiAdapter): void {
api.get("/projects/:id/waveform/*", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
const assetPath = decodeURIComponent(
c.req.path.replace(`/projects/${project.id}/waveform/`, "").split("?")[0] ?? "",
);
const audioPath = join(project.dir, assetPath);
if (!existsSync(audioPath)) return c.json({ error: "file not found" }, 404);
const cacheDir = join(project.dir, ".waveform-cache");
const cachePath = join(cacheDir, buildWaveformCacheKey(assetPath));
if (existsSync(cachePath)) {
try {
const peaks = JSON.parse(readFileSync(cachePath, "utf-8")) as number[];
return c.json({ peaks });
} catch {
// corrupt cache — regenerate
}
}
let peaks: number[];
try {
peaks = await decodeAudioPeaks(audioPath);
} catch {
return c.json({ error: "failed to decode audio" }, 500);
}
try {
mkdirSync(cacheDir, { recursive: true });
writeFileSync(cachePath, JSON.stringify(peaks));
} catch {
// cache write failure is non-fatal
}
return c.json({ peaks });
});
}