Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions packages/cli/src/server/studioServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,32 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {

resolveProject: (id: string) => (id === projectId ? project : null),

async preflightCheck() {
const { findFFmpeg, getFFmpegInstallHint } = await import("../browser/ffmpeg.js");
const { findBrowser } = await import("../browser/manager.js");

if (!findFFmpeg()) {
const hint = getFFmpegInstallHint();
return {
error: "FFmpeg not found",
detail: `FFmpeg is required to render video but was not found in your PATH.\n\nInstall it:\n ${hint}`,
};
}

const browser = await findBrowser();
if (!browser) {
return {
error: "Chrome not found",
detail:
"A Chrome-based browser is required to render but none was found.\n\n" +
"Install the headless shell:\n npx @puppeteer/browsers install chrome-headless-shell\n\n" +
"Or run:\n hyperframes browser install",
};
}

return null;
},

async bundle(dir: string): Promise<string | null> {
try {
const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
Expand Down
62 changes: 62 additions & 0 deletions packages/core/src/studio-api/routes/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,68 @@ function buildApp(spy: ReturnType<typeof vi.fn>): { app: Hono; cleanup: () => vo
return { app, cleanup: () => rmSync(rendersDir, { recursive: true, force: true }) };
}

describe("POST /projects/:id/render — preflight check", () => {
it("returns 422 with error details when preflightCheck fails", async () => {
const spy = vi.fn();
const { adapter, rendersDir } = createAdapter(spy);
adapter.preflightCheck = async () => ({
error: "FFmpeg not found",
detail: "FFmpeg is required but was not found in your PATH.",
});
const app = new Hono();
registerRenderRoutes(app, adapter);
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(422);
const body = await res.json();
expect(body.error).toBe("FFmpeg not found");
expect(body.detail).toContain("FFmpeg is required");
expect(spy).not.toHaveBeenCalled();
} finally {
rmSync(rendersDir, { recursive: true, force: true });
}
});

it("proceeds normally when preflightCheck passes", async () => {
const spy = vi.fn();
const { adapter, rendersDir } = createAdapter(spy);
adapter.preflightCheck = async () => null;
const app = new Hono();
registerRenderRoutes(app, adapter);
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).toHaveBeenCalledOnce();
} finally {
rmSync(rendersDir, { recursive: true, force: true });
}
});

it("proceeds normally when preflightCheck is not provided", 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).toHaveBeenCalledOnce();
} finally {
cleanup();
}
});
});

describe("POST /projects/:id/render — outputResolution forwarding", () => {
it("forwards a valid resolution preset to the adapter", async () => {
const spy = vi.fn();
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/studio-api/routes/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,16 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
composition = body.composition;
}

// Preflight: verify external dependencies (FFmpeg, Chrome) are available
// before starting the render. Without this, the render fails deep in the
// pipeline with an opaque ENOENT or "browser not found" error.
if (adapter.preflightCheck) {
const preflight = await adapter.preflightCheck();
if (preflight) {
return c.json({ error: preflight.error, detail: preflight.detail }, 422);
}
}

const now = new Date();
const datePart = now.toISOString().slice(0, 10);
const timePart = now.toTimeString().slice(0, 8).replace(/:/g, "-");
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/studio-api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,4 +117,12 @@ export interface StudioApiAdapter {
project: ResolvedProject;
blockName: string;
}): Promise<{ written: string[]; block: RegistryItem }>;

/**
* Optional: run a preflight check before starting a render.
* Returns `null` when everything is ready, or an object with
* `error` (short label) and `detail` (actionable fix instructions)
* when a required dependency is missing.
*/
preflightCheck?(): Promise<{ error: string; detail: string } | null>;
}
13 changes: 12 additions & 1 deletion packages/studio/src/components/renders/useRenderQueue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,11 +136,22 @@ export function useRenderQueue(projectId: string | null) {
return;
}
if (!res.ok) {
let errorMessage = `Server error (${res.status}). Check the terminal for details.`;
try {
const body = await res.json();
if (body.detail) {
errorMessage = body.detail;
} else if (body.error) {
errorMessage = body.error;
}
} catch {
// Response wasn't JSON — keep the generic message
}
const failedJob: RenderJob = {
id: crypto.randomUUID(),
status: "failed",
progress: 0,
error: `Server error (${res.status}). Check the terminal for details.`,
error: errorMessage,
filename: "Export failed",
createdAt: startTime,
};
Expand Down
Loading