Skip to content
Merged
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
72 changes: 72 additions & 0 deletions packages/cli/src/browser/ffmpeg.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ import { findFFmpeg, findFFprobe } from "./ffmpeg.js";
// wrapper tests below resolve via env overrides and need the real `existsSync`.
vi.mock("node:child_process", () => ({ execFileSync: vi.fn(), execSync: vi.fn() }));

// Only the distro probe is faked; `ffmpegInstallCommand` stays real so the
// linux cases below assert the string a user would actually be handed.
let linuxFamily = "debian";
vi.mock("./linuxDeps.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./linuxDeps.js")>()),
detectLinuxDistro: () => ({ family: linuxFamily, isWsl: false, prettyName: null }),
}));

const mockExecFile = vi.mocked(execFileSync);

afterEach(() => {
Expand Down Expand Up @@ -71,3 +79,67 @@ describe("resolveH264EncoderMode", () => {
);
});
});

// Studio renders the command behind a copy button, so "is there a command at
// all" has to be a typed answer rather than a guess made by pattern-matching
// the prose hint. The hint is derived from the command, so they move together.
describe("getFFmpegInstallCommand / getFFmpegInstallHint", () => {
const realPlatform = process.platform;

function setPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, "platform", { value: platform, configurable: true });
}

afterEach(() => {
setPlatform(realPlatform);
});

it("gives macOS a pasteable command and uses it verbatim as the hint", async () => {
setPlatform("darwin");
const { getFFmpegInstallCommand, getFFmpegInstallHint } = await import("./ffmpeg.js");

expect(getFFmpegInstallCommand()).toBe("brew install ffmpeg");
expect(getFFmpegInstallHint()).toBe("brew install ffmpeg");
});

it("gives Windows a winget command and keeps the manual route in the hint", async () => {
setPlatform("win32");
const { getFFmpegInstallCommand, getFFmpegInstallHint } = await import("./ffmpeg.js");

const command = getFFmpegInstallCommand();
expect(command).toBe("winget install --id Gyan.FFmpeg -e");
// Machines predating winget still need somewhere to go.
expect(getFFmpegInstallHint()).toContain(command);
expect(getFFmpegInstallHint()).toContain("https://ffmpeg.org/download.html");
});

it("reports no command on a platform without one, and still hints", async () => {
setPlatform("sunos");
const { getFFmpegInstallCommand, getFFmpegInstallHint } = await import("./ffmpeg.js");

expect(getFFmpegInstallCommand()).toBeUndefined();
expect(getFFmpegInstallHint()).toBe("https://ffmpeg.org/download.html");
});

it("gives a recognised linux distro its package-manager command", async () => {
setPlatform("linux");
linuxFamily = "debian";
const { getFFmpegInstallCommand } = await import("./ffmpeg.js");

expect(getFFmpegInstallCommand()).toBe("sudo apt-get update && sudo apt-get install -y ffmpeg");
});

// The reason the command and the hint are separate functions at all. On an
// unrecognised distro `ffmpegInstallCommand` returns a sentence, and Studio
// renders whatever comes back inside a <code> block behind a Copy button —
// so a command must be absent here, not prose. Without this the guard that
// makes that true is unpinned, and deleting it keeps every other test green.
it("reports no command on an unrecognised distro, and hints in prose", async () => {
setPlatform("linux");
linuxFamily = "unknown";
const { getFFmpegInstallCommand, getFFmpegInstallHint } = await import("./ffmpeg.js");

expect(getFFmpegInstallCommand()).toBeUndefined();
expect(getFFmpegInstallHint()).toContain("distro package manager");
});
});
34 changes: 31 additions & 3 deletions packages/cli/src/browser/ffmpeg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,19 +43,47 @@ export function findFFprobe(): string | undefined {
return findFfBinary("ffprobe", { configuredMustExist: true });
}

export function getFFmpegInstallHint(): string {
const FFMPEG_DOWNLOAD_URL = "https://ffmpeg.org/download.html";

/**
* The one command that installs FFmpeg on this machine, or `undefined` when
* the platform has no single command worth pasting.
*
* Separate from `getFFmpegInstallHint` because Studio renders this behind a
* copy button, and a copy button over prose ("download the build from ... and
* add its bin/ directory to PATH") copies something that is not a command.
* This is the only place that maps a platform to an install command; the hint
* below is derived from it.
*/
export function getFFmpegInstallCommand(): string | undefined {
switch (process.platform) {
case "darwin":
return "brew install ffmpeg";
case "linux": {
// Distro-aware so WSL/Fedora/Arch/Alpine users get a command that
// actually works instead of a Debian-only `apt` line.
const distro = detectLinuxDistro();
if (distro.family === "unknown") return undefined;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This guard is the thing standing between the Copy button and a sentence of prose, and nothing tests it — ffmpeg.test.ts sets darwin, win32 and sunos, never linux.

Remove this line and the function returns ffmpegInstallCommand("unknown") = "Install ffmpeg (which includes ffprobe) via your distro package manager, then re-run." (linuxDeps.ts:199-201), which FfmpegRequiredNotice.tsx renders inside <code> behind Copy — precisely the failure the doc comment above says the command/hint split exists to prevent. Whole suite stays green.

One it with setPlatform("linux") and a stubbed detectLinuxDistro closes it.

return ffmpegInstallCommand(distro.family);
}
// winget ships with Windows 10 1809+ and Windows 11. Machines without it
// still get the manual download route from the hint below.
case "win32":
return "Download the 64-bit Windows build from https://ffmpeg.org/download.html#build-windows and add its bin/ directory to PATH.";
return "winget install --id Gyan.FFmpeg -e";
default:
return "https://ffmpeg.org/download.html";
return undefined;
}
}

export function getFFmpegInstallHint(): string {
const command = getFFmpegInstallCommand();
// Guarding on `command`, not the platform alone: the function above is the
// sole owner of platform-to-command, so the day win32 stops returning one
// this would otherwise interpolate "undefined, or download the ...".
if (command && process.platform === "win32") {
return `${command}, or download the 64-bit build from ${FFMPEG_DOWNLOAD_URL}#build-windows and add its bin/ directory to PATH.`;
}
if (command) return command;
if (process.platform === "linux") return ffmpegInstallCommand("unknown");
return FFMPEG_DOWNLOAD_URL;
}
4 changes: 3 additions & 1 deletion packages/cli/src/browser/preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,9 @@ function checkFFmpeg(): EnvironmentCheckOutcome {
ok: false,
level: "error",
title: "FFmpeg not found",
detail: "FFmpeg is required to encode video. The render cannot proceed without it.",
// Second sentence dropped: "the render cannot proceed" is already said by
// the error this accompanies, and in Studio by the disabled Export button.
detail: "FFmpeg is required to encode video.",
hint: getFFmpegInstallHint(),
};
}
Expand Down
91 changes: 61 additions & 30 deletions packages/cli/src/server/studioServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -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 changes: 36 additions & 0 deletions packages/cli/src/server/studioServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion packages/studio/src/components/StudioHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ export function StudioHeader({
// the shareable Studio URL, so a dead click would rewrite a link.
const { effectiveRightCollapsed, setRightCollapsed, setRightPanelTab } = usePanelLayoutContext();
const isRendering = renderQueue.isRendering;
const ffmpegMissing = renderQueue.ffmpegMissing;

return (
<div className="flex items-center justify-between h-10 px-3 bg-neutral-900 border-b border-neutral-800 flex-shrink-0">
Expand Down Expand Up @@ -382,7 +383,11 @@ export function StudioHeader({
</Tooltip>
<Tooltip
label={
isRendering ? "A render is already in progress" : "Render and export this composition"
ffmpegMissing
? "FFmpeg is not installed. Opens the Renders panel with the install command."
: isRendering
? "A render is already in progress"
: "Render and export this composition"
}
side="bottom"
>
Expand All @@ -393,6 +398,12 @@ export function StudioHeader({
if (isRendering) return;
setRightPanelTab("renders");
setRightCollapsed(false);
// Without an encoder this render cannot finish, so the click
// delivers the user to the prompt that fixes it instead of
// queueing a job that exists only to fail. Disabling the button
// would leave them staring at a dead control with no route to
// the explanation.
if (ffmpegMissing) return;
onExport?.();
}}
className="h-7 flex items-center gap-1.5 px-3 rounded-md text-[11px] font-semibold bg-studio-accent text-[#09090B] enabled:hover:brightness-110 transition-[filter,transform] enabled:active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed"
Expand Down
15 changes: 14 additions & 1 deletion packages/studio/src/components/StudioLeftSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export function StudioLeftSidebar({
handlePanelResizeStart,
handlePanelResizeMove,
handlePanelResizeEnd,
setRightPanelTab,
setRightCollapsed,
} = usePanelLayoutContext();
const { projectId, renderQueue, waitForPendingDomEditSaves } = useStudioShellContext();
const {
Expand All @@ -64,11 +66,22 @@ export function StudioLeftSidebar({

const handleRenderComposition = useCallback(
async (comp: string) => {
// startRender refuses without an encoder, so nothing unfinishable gets
// queued either way. What it cannot do from here is show the reason:
// its refusal lands as a row in the Renders panel, which may be
// collapsed or on another tab, so the click would look like nothing
// happened. Same move the header makes: put the prompt in front of the
// user, then stop.
if (renderQueue.ffmpegMissing) {
setRightPanelTab("renders");
setRightCollapsed(false);
return;
}
await waitForPendingDomEditSaves();
const { format, quality, fps } = getPersistedRenderSettings();
await renderQueue.startRender({ composition: comp, format, quality, fps });
},
[renderQueue, waitForPendingDomEditSaves],
[renderQueue, waitForPendingDomEditSaves, setRightPanelTab, setRightCollapsed],
);

if (effectiveLeftCollapsed) {
Expand Down
Loading
Loading