mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
fix(cli): align render output naming and add WebM support to studioServer (#109)
## Summary - CLI render: use timestamped filenames (`project_date_time.ext`) matching the studio's naming convention, preventing overwrites of previous renders - studioServer: read `fps`/`quality`/`format` from POST body instead of hardcoding `fps:30`/`quality:standard`/`mp4` - studioServer: use timestamped job IDs matching the studio pattern - studioServer: fix download endpoint to serve correct content-type for WebM ## Test plan - [x] `hyperframes render --format webm` outputs timestamped WebM file - [x] `hyperframes render` outputs timestamped MP4 (no overwrite) - [x] Studio embedded server (`hyperframes dev`) renders with correct format when selected in UI - [x] Download endpoint serves correct MIME type for WebM renders
This commit is contained in:
@@ -118,9 +118,12 @@ Examples:
|
||||
// ── Resolve output path ───────────────────────────────────────────────
|
||||
const rendersDir = resolve("renders");
|
||||
const ext = format === "webm" ? ".webm" : ".mp4";
|
||||
const now = new Date();
|
||||
const datePart = now.toISOString().slice(0, 10);
|
||||
const timePart = now.toTimeString().slice(0, 8).replace(/:/g, "-");
|
||||
const outputPath = args.output
|
||||
? resolve(args.output)
|
||||
: join(rendersDir, `${project.name}${ext}`);
|
||||
: join(rendersDir, `${project.name}_${datePart}_${timePart}${ext}`);
|
||||
|
||||
// Ensure output directory exists
|
||||
mkdirSync(dirname(outputPath), { recursive: true });
|
||||
|
||||
@@ -7,7 +7,15 @@
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { streamSSE } from "hono/streaming";
|
||||
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import {
|
||||
existsSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
mkdirSync,
|
||||
unlinkSync,
|
||||
} from "node:fs";
|
||||
import { resolve, join, sep, basename, dirname, extname } from "node:path";
|
||||
import { createProjectWatcher, type ProjectWatcher } from "./fileWatcher.js";
|
||||
|
||||
@@ -224,6 +232,35 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
return c.json({ id: projectId, files });
|
||||
});
|
||||
|
||||
// ── API: lint ───────────────────────────────────────────────────────
|
||||
app.get("/api/projects/:id/lint", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
if (id !== projectId) return c.json({ error: "not found" }, 404);
|
||||
try {
|
||||
const { lintHyperframeHtml } = await import("@hyperframes/core/lint");
|
||||
const htmlFiles = walkDir(projectDir).filter((f: string) => f.endsWith(".html"));
|
||||
const allFindings: Array<{
|
||||
severity: string;
|
||||
message: string;
|
||||
file?: string;
|
||||
fixHint?: string;
|
||||
}> = [];
|
||||
for (const file of htmlFiles) {
|
||||
const content = readFileSync(join(projectDir, file), "utf-8");
|
||||
const result = lintHyperframeHtml(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);
|
||||
}
|
||||
});
|
||||
|
||||
// ── API: preview — bundled composition ────────────────────────────────
|
||||
app.get("/api/projects/:id/preview", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
@@ -326,17 +363,32 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
// In-memory job store for active renders
|
||||
const renderJobs = new Map<
|
||||
string,
|
||||
{ status: string; progress: number; error?: string; outputPath?: string }
|
||||
{ status: string; progress: number; stage?: string; error?: string; outputPath?: string }
|
||||
>();
|
||||
|
||||
app.post("/api/projects/:id/render", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
if (id !== projectId) return c.json({ error: "not found" }, 404);
|
||||
|
||||
const jobId = Math.random().toString(36).slice(2, 10);
|
||||
const body = (await c.req.json().catch(() => ({}))) as {
|
||||
fps?: number;
|
||||
quality?: string;
|
||||
format?: string;
|
||||
};
|
||||
const format = body.format === "webm" ? "webm" : "mp4";
|
||||
const fps: 24 | 30 | 60 = body.fps === 24 || body.fps === 60 ? body.fps : 30;
|
||||
const quality = ["draft", "standard", "high"].includes(body.quality ?? "")
|
||||
? (body.quality as "draft" | "standard" | "high")
|
||||
: "standard";
|
||||
|
||||
const now = new Date();
|
||||
const datePart = now.toISOString().slice(0, 10);
|
||||
const timePart = now.toTimeString().slice(0, 8).replace(/:/g, "-");
|
||||
const jobId = `${projectId}_${datePart}_${timePart}`;
|
||||
const outputDir = join(projectDir, "renders");
|
||||
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
|
||||
const outputPath = join(outputDir, `${projectId}.mp4`);
|
||||
const ext = format === "webm" ? ".webm" : ".mp4";
|
||||
const outputPath = join(outputDir, `${jobId}${ext}`);
|
||||
|
||||
renderJobs.set(jobId, { status: "rendering", progress: 0, outputPath });
|
||||
|
||||
@@ -356,15 +408,14 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
// Continue without — acquireBrowser will try its own resolution
|
||||
}
|
||||
|
||||
const { trackRenderComplete } = await import("../telemetry/events.js");
|
||||
const { bytesToMb } = await import("../telemetry/system.js");
|
||||
const { freemem } = await import("node:os");
|
||||
|
||||
const job = createRenderJob({ fps: 30, quality: "standard" });
|
||||
const job = createRenderJob({ fps, quality, format });
|
||||
const startTime = Date.now();
|
||||
const onProgress = (j: { progress: number }) => {
|
||||
const onProgress = (j: { progress: number; currentStage?: string }) => {
|
||||
const entry = renderJobs.get(jobId);
|
||||
if (entry) entry.progress = j.progress;
|
||||
if (entry) {
|
||||
entry.progress = j.progress;
|
||||
if (j.currentStage) entry.stage = j.currentStage;
|
||||
}
|
||||
};
|
||||
await executeRenderJob(job, projectDir, outputPath, onProgress);
|
||||
const entry = renderJobs.get(jobId);
|
||||
@@ -372,53 +423,23 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
entry.status = "complete";
|
||||
entry.progress = 100;
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startTime;
|
||||
const perf = job.perfSummary;
|
||||
const compositionDurationMs = perf
|
||||
? Math.round(perf.compositionDurationSeconds * 1000)
|
||||
: undefined;
|
||||
trackRenderComplete({
|
||||
durationMs: elapsed,
|
||||
fps: 30,
|
||||
quality: "standard",
|
||||
workers: perf?.workers ?? 1,
|
||||
docker: false,
|
||||
gpu: false,
|
||||
compositionDurationMs,
|
||||
compositionWidth: perf?.resolution.width,
|
||||
compositionHeight: perf?.resolution.height,
|
||||
totalFrames: perf?.totalFrames,
|
||||
speedRatio:
|
||||
compositionDurationMs && compositionDurationMs > 0 && elapsed > 0
|
||||
? Math.round((compositionDurationMs / elapsed) * 100) / 100
|
||||
: undefined,
|
||||
captureAvgMs: perf?.captureAvgMs,
|
||||
capturePeakMs: perf?.capturePeakMs,
|
||||
peakMemoryMb: bytesToMb(process.memoryUsage.rss()),
|
||||
memoryFreeMb: bytesToMb(freemem()),
|
||||
});
|
||||
const metaPath = outputPath.replace(/\.(mp4|webm)$/, ".meta.json");
|
||||
writeFileSync(
|
||||
metaPath,
|
||||
JSON.stringify({ status: "complete", durationMs: Date.now() - startTime }),
|
||||
);
|
||||
} catch (err) {
|
||||
try {
|
||||
const { trackRenderError } = await import("../telemetry/events.js");
|
||||
const { bytesToMb } = await import("../telemetry/system.js");
|
||||
const { freemem } = await import("node:os");
|
||||
trackRenderError({
|
||||
fps: 30,
|
||||
quality: "standard",
|
||||
docker: false,
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
peakMemoryMb: bytesToMb(process.memoryUsage.rss()),
|
||||
memoryFreeMb: bytesToMb(freemem()),
|
||||
});
|
||||
} catch {
|
||||
// Telemetry must never break the studio
|
||||
}
|
||||
const entry = renderJobs.get(jobId);
|
||||
if (entry) {
|
||||
entry.status = "failed";
|
||||
entry.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
try {
|
||||
const metaPath = outputPath.replace(/\.(mp4|webm)$/, ".meta.json");
|
||||
writeFileSync(metaPath, JSON.stringify({ status: "failed" }));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -439,6 +460,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
data: JSON.stringify({
|
||||
progress: current.progress,
|
||||
status: current.status,
|
||||
stage: current.stage,
|
||||
error: current.error,
|
||||
}),
|
||||
});
|
||||
@@ -454,15 +476,67 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
if (!job?.outputPath || !existsSync(job.outputPath)) {
|
||||
return c.json({ error: "not found" }, 404);
|
||||
}
|
||||
const isWebm = job.outputPath.endsWith(".webm");
|
||||
const contentType = isWebm ? "video/webm" : "video/mp4";
|
||||
const filename = job.outputPath.split("/").pop() ?? `${projectId}.mp4`;
|
||||
const content = readFileSync(job.outputPath);
|
||||
return new Response(content, {
|
||||
headers: {
|
||||
"Content-Type": "video/mp4",
|
||||
"Content-Disposition": `attachment; filename="${projectId}.mp4"`,
|
||||
"Content-Type": contentType,
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ── API: renders listing ──────────────────────────────────────────────
|
||||
app.get("/api/projects/:id/renders", (c) => {
|
||||
const id = c.req.param("id");
|
||||
if (id !== projectId) return c.json({ error: "not found" }, 404);
|
||||
const rendersDir = join(projectDir, "renders");
|
||||
if (!existsSync(rendersDir)) return c.json({ renders: [] });
|
||||
const files = readdirSync(rendersDir)
|
||||
.filter((f: string) => f.endsWith(".mp4") || f.endsWith(".webm"))
|
||||
.map((f: string) => {
|
||||
const fp = join(rendersDir, f);
|
||||
const stat = statSync(fp);
|
||||
const rid = f.replace(/\.(mp4|webm)$/, "");
|
||||
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: { createdAt: number }, b: { createdAt: number }) => b.createdAt - a.createdAt);
|
||||
return c.json({ renders: files });
|
||||
});
|
||||
|
||||
// ── API: delete render ───────────────────────────────────────────────
|
||||
app.delete("/api/render/:jobId", (c) => {
|
||||
const { jobId } = c.req.param();
|
||||
const rendersDir = join(projectDir, "renders");
|
||||
for (const ext of [".mp4", ".webm", ".meta.json"]) {
|
||||
const fp = join(rendersDir, `${jobId}${ext}`);
|
||||
if (existsSync(fp)) unlinkSync(fp);
|
||||
}
|
||||
renderJobs.delete(jobId);
|
||||
return c.json({ deleted: true });
|
||||
});
|
||||
|
||||
// ── Studio SPA static files ───────────────────────────────────────────
|
||||
app.get("/assets/*", (c) => {
|
||||
const filePath = resolve(studioDir, c.req.path.slice(1)); // strip leading /
|
||||
|
||||
@@ -66,6 +66,7 @@ interface RenderInput {
|
||||
outputPath?: string | null;
|
||||
fps: 24 | 30 | 60;
|
||||
quality: "draft" | "standard" | "high";
|
||||
format?: "mp4" | "webm";
|
||||
workers?: number;
|
||||
useGpu: boolean;
|
||||
debug: boolean;
|
||||
@@ -97,7 +98,12 @@ function parseRenderOptions(body: Record<string, unknown>): Omit<RenderInput, "p
|
||||
? body.entryFile.trim()
|
||||
: undefined;
|
||||
|
||||
return { outputPath, fps, quality, workers, useGpu, debug, entryFile };
|
||||
const format = (["mp4", "webm"].includes(body.format as string) ? body.format : undefined) as
|
||||
| "mp4"
|
||||
| "webm"
|
||||
| undefined;
|
||||
|
||||
return { outputPath, fps, quality, workers, useGpu, debug, entryFile, format };
|
||||
}
|
||||
|
||||
async function prepareRenderBody(
|
||||
@@ -321,6 +327,7 @@ export function createRenderHandlers(options: HandlerOptions = {}): RenderHandle
|
||||
const job = createRenderJob({
|
||||
fps: input.fps,
|
||||
quality: input.quality,
|
||||
format: input.format,
|
||||
workers: input.workers,
|
||||
useGpu: input.useGpu,
|
||||
debug: input.debug,
|
||||
@@ -433,6 +440,7 @@ export function createRenderHandlers(options: HandlerOptions = {}): RenderHandle
|
||||
const job = createRenderJob({
|
||||
fps: input.fps,
|
||||
quality: input.quality,
|
||||
format: input.format,
|
||||
workers: input.workers,
|
||||
useGpu: input.useGpu,
|
||||
debug: input.debug,
|
||||
|
||||
@@ -27,13 +27,23 @@ export function useRenderQueue(projectId: string | null) {
|
||||
const existing = new Set(prev.map((j) => j.id));
|
||||
const fromServer: RenderJob[] = data.renders
|
||||
.filter((r: { id: string }) => !existing.has(r.id))
|
||||
.map((r: { id: string; filename: string; createdAt: number; size: number }) => ({
|
||||
id: r.id,
|
||||
status: "complete" as const,
|
||||
progress: 100,
|
||||
filename: r.filename,
|
||||
createdAt: r.createdAt,
|
||||
}));
|
||||
.map(
|
||||
(r: {
|
||||
id: string;
|
||||
filename: string;
|
||||
createdAt: number;
|
||||
size: number;
|
||||
status?: string;
|
||||
durationMs?: number;
|
||||
}) => ({
|
||||
id: r.id,
|
||||
status: (r.status === "failed" ? "failed" : "complete") as "complete" | "failed",
|
||||
progress: 100,
|
||||
filename: r.filename,
|
||||
createdAt: r.createdAt,
|
||||
durationMs: r.durationMs,
|
||||
}),
|
||||
);
|
||||
return [...prev, ...fromServer];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -139,6 +139,7 @@ function devProjectApi(): Plugin {
|
||||
}
|
||||
const outputPath = join(outputDir, `${jobId}.mp4`);
|
||||
// Store job state — referenced by the SSE progress endpoint and the fetch callback below
|
||||
const startTime = Date.now();
|
||||
const _jobState = { id: jobId, status: "rendering", progress: 0, outputPath };
|
||||
renderJobs.set(jobId, _jobState);
|
||||
|
||||
@@ -186,17 +187,37 @@ function devProjectApi(): Plugin {
|
||||
if (evt.type === "complete") {
|
||||
_jobState.status = "complete";
|
||||
_jobState.outputPath = evt.outputPath || outputPath;
|
||||
const metaPath = outputPath.replace(/\.(mp4|webm)$/, ".meta.json");
|
||||
writeFileSync(
|
||||
metaPath,
|
||||
JSON.stringify({ status: "complete", durationMs: Date.now() - startTime }),
|
||||
);
|
||||
}
|
||||
if (evt.type === "error") {
|
||||
_jobState.status = "failed";
|
||||
const metaPath = outputPath.replace(/\.(mp4|webm)$/, ".meta.json");
|
||||
writeFileSync(metaPath, JSON.stringify({ status: "failed" }));
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
if (_jobState.status === "rendering") _jobState.status = "complete";
|
||||
if (_jobState.status === "rendering") {
|
||||
_jobState.status = "complete";
|
||||
const metaPath = outputPath.replace(/\.(mp4|webm)$/, ".meta.json");
|
||||
writeFileSync(
|
||||
metaPath,
|
||||
JSON.stringify({ status: "complete", durationMs: Date.now() - startTime }),
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
_jobState.status = "failed";
|
||||
const metaPath = outputPath.replace(/\.(mp4|webm)$/, ".meta.json");
|
||||
try {
|
||||
writeFileSync(metaPath, JSON.stringify({ status: "failed" }));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
@@ -284,11 +305,27 @@ function devProjectApi(): Plugin {
|
||||
.map((f: string) => {
|
||||
const fp = join(rendersDir, f);
|
||||
const stat = statSync(fp);
|
||||
const id = f.replace(/\.(mp4|webm)$/, "");
|
||||
// Read meta.json for status (failed renders persist as small files)
|
||||
const metaPath = join(rendersDir, `${id}.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 corrupt meta */
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: f.replace(/\.(mp4|webm)$/, ""),
|
||||
id,
|
||||
filename: f,
|
||||
size: stat.size,
|
||||
createdAt: stat.mtimeMs,
|
||||
status,
|
||||
durationMs,
|
||||
};
|
||||
})
|
||||
.sort(
|
||||
@@ -305,7 +342,7 @@ function devProjectApi(): Plugin {
|
||||
if (deleteRenderMatch) {
|
||||
const jobId = deleteRenderMatch[1];
|
||||
const rendersDir = resolve(dataDir, "../renders");
|
||||
for (const ext of [".mp4", ".webm"]) {
|
||||
for (const ext of [".mp4", ".webm", ".meta.json"]) {
|
||||
const fp = join(rendersDir, `${jobId}${ext}`);
|
||||
if (existsSync(fp)) unlinkSync(fp);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user