mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
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:
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user