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:
Miguel Ángel
2026-03-28 21:49:10 +01:00
committed by GitHub
parent 40bd159103
commit 95d7dc0623
5 changed files with 198 additions and 66 deletions
+4 -1
View File
@@ -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 });
+128 -54
View File
@@ -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 /
+9 -1
View File
@@ -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];
});
}
+40 -3
View File
@@ -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);
}