feat(studio): prompt to install FFmpeg before Export, not after (#3314)

Exporting without FFmpeg installed used to show "Server error (503). Check
the terminal for details." The server already knew the exact cause and sent
a per-platform install command in the response body; Studio discarded that
body and printed the status code. The user found out only after the
composition was finished.

Studio now asks the dev server on load whether this machine can encode, and
the Renders panel shows the cause plus a copyable install command when it
cannot, with a Recheck that avoids restarting Studio.

- New GET /api/environment/ffmpeg calls runEnvironmentChecks() with every
  optional check off, which is exactly the FFmpeg and ffprobe pair `doctor`
  runs, so Studio and the CLI cannot disagree. Only a passing result is
  cached.
- The refusal lives in startRender, not in a button. Studio renders from
  three places (the panel's Export, the header's, and each composition card
  in the sidebar), so a per-button check would leave the others free to
  queue a render that cannot finish. The header and sidebar controls reveal
  the prompt rather than going dead.
- A null probe result means "no answer", not "missing", so an older or
  unreachable dev server cannot lock a working setup.
- Failed render responses now surface the server's { error, hint }.
- getFFmpegInstallCommand() is the single owner of platform-to-command, with
  the prose hint derived from it. Windows gains a winget command and keeps
  the manual download route.

Accessibility: the prompt's explanatory line measured 2.2:1 on the card's
amber background against a 4.5:1 minimum, because the panel's usual grey for
secondary text does not survive the tint. Now 6.6:1. Keyboard focus was
invisible on all three controls and now matches the panel's focus ring.

Also folds in cleanups the repo's gates required: the Renders tab moves out
of StudioRightPanel (it was at the 600-line cap and every field it needed was
already on the shell context), StudioContextInput stops keeping a second copy
of the renderQueue shape, and the server tests share one temp-project helper.
This commit is contained in:
Miguel Ángel
2026-08-17 21:00:27 -04:00
committed by GitHub
parent ea7c48f372
commit 5058236eda
23 changed files with 1043 additions and 121 deletions
+61 -30
View File
@@ -4,8 +4,29 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { loadHyperframeRuntimeSource } from "@hyperframes/core";
import { loadRuntimeSource } from "./runtimeSource.js";
import { findFFmpeg, findFFprobe } from "../browser/ffmpeg.js";
import { createStudioServer, type StudioServer } from "./studioServer.js";
// Every server-backed describe below wants the same two things: a throwaway
// project directory, and a server whose watcher is closed afterwards. Three
// copies of that got out of step, so it lives here once.
const dirs: string[] = [];
let server: StudioServer | undefined;
function tmpProject(): string {
const dir = mkdtempSync(join(tmpdir(), "hf-studio-server-test-"));
dirs.push(dir);
return dir;
}
afterEach(() => {
server?.watcher.close();
server = undefined;
delete process.env.HYPERFRAMES_FFMPEG_PATH;
delete process.env.HYPERFRAMES_FFPROBE_PATH;
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
describe("loadRuntimeSource", () => {
it("loads runtime source from the published core entrypoint", async () => {
await expect(loadRuntimeSource()).resolves.toBe(loadHyperframeRuntimeSource());
@@ -23,21 +44,6 @@ describe("Studio thumbnail GPU capture plumbing", () => {
});
describe("createStudioServer autoProxy plumbing", () => {
const dirs: string[] = [];
let server: StudioServer | undefined;
function tmpProject(): string {
const dir = mkdtempSync(join(tmpdir(), "hf-studio-server-test-"));
dirs.push(dir);
return dir;
}
afterEach(() => {
server?.watcher.close();
server = undefined;
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
it("hyperframes.json media.autoProxy=false flows through to the adapter", () => {
const projectDir = tmpProject();
writeFileSync(
@@ -79,21 +85,6 @@ describe("createStudioServer autoProxy plumbing", () => {
});
describe("host guarding on identity-bearing responses", () => {
const dirs: string[] = [];
let server: StudioServer | undefined;
function tmpProject(): string {
const dir = mkdtempSync(join(tmpdir(), "hf-studio-host-test-"));
dirs.push(dir);
return dir;
}
afterEach(() => {
server?.watcher.close();
server = undefined;
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
// NOTE: the SPA-injection branch itself is covered in telemetryIdentity.test.ts
// via buildStudioHeadScriptsForHost. It cannot be asserted here: this route
// only reaches the injection branch when packages/studio/dist is built,
@@ -120,3 +111,43 @@ describe("host guarding on identity-bearing responses", () => {
expect(Object.keys((await res.json()) as object)).toEqual(["distinctId"]);
});
});
// Studio asks this before it offers Export, so a machine without an encoder
// gets an install command up front instead of a 503 after the work is done.
describe("FFmpeg environment endpoint", () => {
it("reports the cause and a pasteable command when FFmpeg is unusable", async () => {
// A configured-but-missing override is the one "no FFmpeg" state a test can
// force on a machine that does have FFmpeg installed.
process.env.HYPERFRAMES_FFMPEG_PATH = join(tmpdir(), "hf-missing-ffmpeg");
server = createStudioServer({ projectDir: tmpProject() });
const res = await server.app.request("/api/environment/ffmpeg");
expect(res.status).toBe(200);
const body = (await res.json()) as {
ok: boolean;
title?: string;
detail?: string;
command?: string;
};
expect(body.ok).toBe(false);
expect(body.title).toContain("not found");
expect(body.detail).toBeTruthy();
// Undefined only on platforms with no one-line install; CI runs none.
expect(body.command).toBeTruthy();
});
// Needs a real FFmpeg: the check runs `-version` on whatever it resolves, so
// a stand-in binary would only prove the stand-in works. Skipped rather than
// faked on machines without one.
it.skipIf(!findFFmpeg() || !findFFprobe())(
"answers a plain ok when both binaries resolve",
async () => {
server = createStudioServer({ projectDir: tmpProject() });
const res = await server.app.request("/api/environment/ffmpeg");
expect(await res.json()).toEqual({ ok: true });
},
);
});
+36
View File
@@ -772,6 +772,42 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
});
});
// ── Encoder availability, asked before Export is offered ────────────────
// The render route below already refuses without FFmpeg, but discovering at
// export time that the encoder was never installed is the worst possible
// moment: the user has already built the whole composition. Studio asks here
// when the Render panel opens so it can say so up front, with the same
// per-platform install command `doctor` prints.
//
// Only a passing result is cached. A user who reads the prompt, installs
// FFmpeg and hits Recheck has to get a fresh answer, or the fix they just
// applied is invisible until they restart Studio.
let ffmpegReady = false;
app.get("/api/environment/ffmpeg", async (c) => {
if (ffmpegReady) return c.json({ ok: true });
const [{ runEnvironmentChecks }, { getFFmpegInstallCommand }] = await Promise.all([
import("../browser/preflight.js"),
import("../browser/ffmpeg.js"),
]);
// With every optional check off this is exactly the FFmpeg and ffprobe
// pair — the same two `doctor` runs. ffprobe matters on its own: it ships
// with FFmpeg but is a separate binary, and a project with any media asset
// fails at probe time without it.
const { outcomes } = await runEnvironmentChecks();
const failed = outcomes.find((outcome) => !outcome.ok);
if (!failed) {
ffmpegReady = true;
return c.json({ ok: true });
}
return c.json({
ok: false,
title: failed.title ?? `${failed.name} not found`,
detail: failed.detail,
hint: failed.hint,
command: getFFmpegInstallCommand(),
});
});
// ── Pre-flight checks for render ────────────────────────────────────────
// Intercept render requests before they reach the shared API so we can
// fail fast with an actionable hint instead of burning through the entire