mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +00:00
* feat: initial code port from hyperframes-internal Port all OSS-ready packages from the internal monorepo: - @hyperframes/core — shared types, HTML generation, GSAP utilities, runtime - @hyperframes/cli — CLI for creating, previewing, and rendering compositions - @hyperframes/engine — framework-agnostic rendering engine (BeginFrame + FFmpeg) - @hyperframes/producer — video rendering pipeline (Puppeteer + FFmpeg) - @hyperframes/ui-player — browser-based video player component - @hyperframes/studio — composition editor (React frontend + Hono backend) Includes regression test suite with Docker-based test harness. All HeyGen-internal references, deployment infrastructure, and proprietary assets have been removed. Package names migrated from @app/* to @hyperframes/*. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: scrub internal codenames and stale references from OSS port - Replace static.heygen.ai runtime URLs in test fixtures - Remove internal CDN publish script (publish-hyperframe-runtime.ts) - Replace sandbox-studio, sandbox-interceptor, __magicEditRuntime with neutral names (studio, hyperframe-runtime, __hyperframeRuntime) - Fix stale Vault API / localhost references in docs - Remove broken deprecated_studio link Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove remaining internal codenames and stale references - Delete stale producer README.md and PIPELINE.md (referenced nonexistent files) - Replace "Cerberus" codename with "HyperFrames" in test design reviews - Replace magic-edit postMessage identifiers with hf-preview/hf-parent - Rename debug-magic-edit-timeline.ts to debug-timeline.ts - Replace "Motion Cut" with "HyperFrames" in Timeline comments - Fix studio/CLI references to nonexistent archive package (use local data/projects/ dir, stub render proxy) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
176 lines
5.1 KiB
TypeScript
176 lines
5.1 KiB
TypeScript
/**
|
|
* File Server
|
|
*
|
|
* Lightweight HTTP server that serves a project directory to headless Chrome.
|
|
* Optionally injects scripts into index.html on-the-fly (e.g. runtime, bridge).
|
|
* Framework-agnostic — the caller decides what scripts to inject.
|
|
*/
|
|
|
|
import { Hono } from "hono";
|
|
import { serve } from "@hono/node-server";
|
|
import { readFileSync, existsSync, statSync } from "node:fs";
|
|
import { join, extname } from "node:path";
|
|
|
|
const MIME_TYPES: Record<string, string> = {
|
|
".html": "text/html; charset=utf-8",
|
|
".css": "text/css; charset=utf-8",
|
|
".js": "application/javascript; charset=utf-8",
|
|
".json": "application/json; charset=utf-8",
|
|
".png": "image/png",
|
|
".jpg": "image/jpeg",
|
|
".jpeg": "image/jpeg",
|
|
".gif": "image/gif",
|
|
".svg": "image/svg+xml",
|
|
".webp": "image/webp",
|
|
".mp4": "video/mp4",
|
|
".webm": "video/webm",
|
|
".mp3": "audio/mpeg",
|
|
".wav": "audio/wav",
|
|
".ogg": "audio/ogg",
|
|
".aac": "audio/aac",
|
|
".woff": "font/woff",
|
|
".woff2": "font/woff2",
|
|
".ttf": "font/ttf",
|
|
".otf": "font/otf",
|
|
};
|
|
|
|
function stripEmbeddedRuntimeScripts(html: string): string {
|
|
if (!html) return html;
|
|
const scriptRe = /<script\b[^>]*>[\s\S]*?<\/script>/gi;
|
|
const runtimeSrcMarkers = [
|
|
"hyperframe.runtime.iife.js",
|
|
"hyperframes-runtime.modular.inline.js",
|
|
"data-hyperframes-preview-runtime",
|
|
];
|
|
const runtimeInlineMarkers = [
|
|
"__hyperframeRuntimeBootstrapped",
|
|
"__hyperframeRuntime",
|
|
"__hyperframeRuntimeTeardown",
|
|
"window.__player =",
|
|
"window.__playerReady",
|
|
"window.__renderReady",
|
|
];
|
|
|
|
const shouldStrip = (block: string): boolean => {
|
|
const lowered = block.toLowerCase();
|
|
for (const marker of runtimeSrcMarkers) {
|
|
if (lowered.includes(marker.toLowerCase())) {
|
|
return true;
|
|
}
|
|
}
|
|
for (const marker of runtimeInlineMarkers) {
|
|
if (block.includes(marker)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
return html.replace(scriptRe, (block) => (shouldStrip(block) ? "" : block));
|
|
}
|
|
|
|
function injectScriptsIntoHtml(
|
|
html: string,
|
|
headScripts: string[],
|
|
bodyScripts: string[],
|
|
stripEmbedded: boolean,
|
|
): string {
|
|
if (stripEmbedded) {
|
|
html = stripEmbeddedRuntimeScripts(html);
|
|
}
|
|
|
|
if (headScripts.length > 0) {
|
|
const headTags = headScripts.map((src) => `<script>${src}</script>`).join("\n");
|
|
if (html.includes("</head>")) {
|
|
html = html.replace("</head>", () => `${headTags}\n</head>`);
|
|
} else if (html.includes("<body")) {
|
|
html = html.replace("<body", () => `${headTags}\n<body`);
|
|
} else {
|
|
html = headTags + "\n" + html;
|
|
}
|
|
}
|
|
|
|
if (bodyScripts.length > 0) {
|
|
const bodyTags = bodyScripts.map((src) => `<script>${src}</script>`).join("\n");
|
|
if (html.includes("</body>")) {
|
|
html = html.replace("</body>", () => `${bodyTags}\n</body>`);
|
|
} else {
|
|
html = html + "\n" + bodyTags;
|
|
}
|
|
}
|
|
|
|
return html;
|
|
}
|
|
|
|
export interface FileServerOptions {
|
|
projectDir: string;
|
|
compiledDir?: string;
|
|
port?: number;
|
|
/** Scripts injected into <head> of index.html. Default: none. */
|
|
headScripts?: string[];
|
|
/** Scripts injected before </body> of index.html. Default: none. */
|
|
bodyScripts?: string[];
|
|
/** Strip embedded runtime scripts from HTML before injection. Default: true. */
|
|
stripEmbeddedRuntime?: boolean;
|
|
}
|
|
|
|
export interface FileServerHandle {
|
|
url: string;
|
|
port: number;
|
|
close: () => void;
|
|
}
|
|
|
|
export function createFileServer(options: FileServerOptions): Promise<FileServerHandle> {
|
|
const { projectDir, compiledDir, port = 0, stripEmbeddedRuntime = true } = options;
|
|
|
|
const headScripts = options.headScripts ?? [];
|
|
const bodyScripts = options.bodyScripts ?? [];
|
|
|
|
const app = new Hono();
|
|
|
|
app.get("/*", (c) => {
|
|
let requestPath = c.req.path;
|
|
if (requestPath === "/") requestPath = "/index.html";
|
|
|
|
// Remove leading slash
|
|
const relativePath = requestPath.replace(/^\//, "");
|
|
const compiledPath = compiledDir ? join(compiledDir, relativePath) : null;
|
|
const hasCompiledFile = Boolean(compiledPath && existsSync(compiledPath) && statSync(compiledPath).isFile());
|
|
const filePath = hasCompiledFile ? (compiledPath as string) : join(projectDir, relativePath);
|
|
|
|
if (!existsSync(filePath) || !statSync(filePath).isFile()) {
|
|
return c.text("Not found", 404);
|
|
}
|
|
|
|
const ext = extname(filePath).toLowerCase();
|
|
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
|
|
|
if (ext === ".html") {
|
|
const rawHtml = readFileSync(filePath, "utf-8");
|
|
const html =
|
|
relativePath === "index.html"
|
|
? injectScriptsIntoHtml(rawHtml, headScripts, bodyScripts, stripEmbeddedRuntime)
|
|
: rawHtml;
|
|
return c.text(html, 200, { "Content-Type": contentType });
|
|
}
|
|
|
|
const content = readFileSync(filePath);
|
|
return new Response(content, {
|
|
status: 200,
|
|
headers: { "Content-Type": contentType },
|
|
});
|
|
});
|
|
|
|
return new Promise((resolve) => {
|
|
const server = serve({ fetch: app.fetch, port }, (info) => {
|
|
const actualPort = info.port;
|
|
const url = `http://localhost:${actualPort}`;
|
|
resolve({
|
|
url,
|
|
port: actualPort,
|
|
close: () => server.close(),
|
|
});
|
|
});
|
|
});
|
|
}
|