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
+72
View File
@@ -8,6 +8,14 @@ import { findFFmpeg, findFFprobe } from "./ffmpeg.js";
// wrapper tests below resolve via env overrides and need the real `existsSync`. // wrapper tests below resolve via env overrides and need the real `existsSync`.
vi.mock("node:child_process", () => ({ execFileSync: vi.fn(), execSync: vi.fn() })); 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); const mockExecFile = vi.mocked(execFileSync);
afterEach(() => { afterEach(() => {
@@ -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");
});
});
+31 -3
View File
@@ -43,7 +43,19 @@ export function findFFprobe(): string | undefined {
return findFfBinary("ffprobe", { configuredMustExist: true }); 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) { switch (process.platform) {
case "darwin": case "darwin":
return "brew install ffmpeg"; return "brew install ffmpeg";
@@ -51,11 +63,27 @@ export function getFFmpegInstallHint(): string {
// Distro-aware so WSL/Fedora/Arch/Alpine users get a command that // Distro-aware so WSL/Fedora/Arch/Alpine users get a command that
// actually works instead of a Debian-only `apt` line. // actually works instead of a Debian-only `apt` line.
const distro = detectLinuxDistro(); const distro = detectLinuxDistro();
if (distro.family === "unknown") return undefined;
return ffmpegInstallCommand(distro.family); 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": 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: 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;
}
+3 -1
View File
@@ -111,7 +111,9 @@ function checkFFmpeg(): EnvironmentCheckOutcome {
ok: false, ok: false,
level: "error", level: "error",
title: "FFmpeg not found", 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(), hint: getFFmpegInstallHint(),
}; };
} }
+61 -30
View File
@@ -4,8 +4,29 @@ import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { loadHyperframeRuntimeSource } from "@hyperframes/core"; import { loadHyperframeRuntimeSource } from "@hyperframes/core";
import { loadRuntimeSource } from "./runtimeSource.js"; import { loadRuntimeSource } from "./runtimeSource.js";
import { findFFmpeg, findFFprobe } from "../browser/ffmpeg.js";
import { createStudioServer, type StudioServer } from "./studioServer.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", () => { describe("loadRuntimeSource", () => {
it("loads runtime source from the published core entrypoint", async () => { it("loads runtime source from the published core entrypoint", async () => {
await expect(loadRuntimeSource()).resolves.toBe(loadHyperframeRuntimeSource()); await expect(loadRuntimeSource()).resolves.toBe(loadHyperframeRuntimeSource());
@@ -23,21 +44,6 @@ describe("Studio thumbnail GPU capture plumbing", () => {
}); });
describe("createStudioServer autoProxy 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", () => { it("hyperframes.json media.autoProxy=false flows through to the adapter", () => {
const projectDir = tmpProject(); const projectDir = tmpProject();
writeFileSync( writeFileSync(
@@ -79,21 +85,6 @@ describe("createStudioServer autoProxy plumbing", () => {
}); });
describe("host guarding on identity-bearing responses", () => { 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 // NOTE: the SPA-injection branch itself is covered in telemetryIdentity.test.ts
// via buildStudioHeadScriptsForHost. It cannot be asserted here: this route // via buildStudioHeadScriptsForHost. It cannot be asserted here: this route
// only reaches the injection branch when packages/studio/dist is built, // 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"]); 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 ──────────────────────────────────────── // ── Pre-flight checks for render ────────────────────────────────────────
// Intercept render requests before they reach the shared API so we can // Intercept render requests before they reach the shared API so we can
// fail fast with an actionable hint instead of burning through the entire // fail fast with an actionable hint instead of burning through the entire
@@ -229,6 +229,7 @@ export function StudioHeader({
// the shareable Studio URL, so a dead click would rewrite a link. // the shareable Studio URL, so a dead click would rewrite a link.
const { effectiveRightCollapsed, setRightCollapsed, setRightPanelTab } = usePanelLayoutContext(); const { effectiveRightCollapsed, setRightCollapsed, setRightPanelTab } = usePanelLayoutContext();
const isRendering = renderQueue.isRendering; const isRendering = renderQueue.isRendering;
const ffmpegMissing = renderQueue.ffmpegMissing;
return ( return (
<div className="flex items-center justify-between h-10 px-3 bg-neutral-900 border-b border-neutral-800 flex-shrink-0"> <div className="flex items-center justify-between h-10 px-3 bg-neutral-900 border-b border-neutral-800 flex-shrink-0">
@@ -382,7 +383,11 @@ export function StudioHeader({
</Tooltip> </Tooltip>
<Tooltip <Tooltip
label={ 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" side="bottom"
> >
@@ -393,6 +398,12 @@ export function StudioHeader({
if (isRendering) return; if (isRendering) return;
setRightPanelTab("renders"); setRightPanelTab("renders");
setRightCollapsed(false); 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?.(); 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" 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"
@@ -43,6 +43,8 @@ export function StudioLeftSidebar({
handlePanelResizeStart, handlePanelResizeStart,
handlePanelResizeMove, handlePanelResizeMove,
handlePanelResizeEnd, handlePanelResizeEnd,
setRightPanelTab,
setRightCollapsed,
} = usePanelLayoutContext(); } = usePanelLayoutContext();
const { projectId, renderQueue, waitForPendingDomEditSaves } = useStudioShellContext(); const { projectId, renderQueue, waitForPendingDomEditSaves } = useStudioShellContext();
const { const {
@@ -64,11 +66,22 @@ export function StudioLeftSidebar({
const handleRenderComposition = useCallback( const handleRenderComposition = useCallback(
async (comp: string) => { 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(); await waitForPendingDomEditSaves();
const { format, quality, fps } = getPersistedRenderSettings(); const { format, quality, fps } = getPersistedRenderSettings();
await renderQueue.startRender({ composition: comp, format, quality, fps }); await renderQueue.startRender({ composition: comp, format, quality, fps });
}, },
[renderQueue, waitForPendingDomEditSaves], [renderQueue, waitForPendingDomEditSaves, setRightPanelTab, setRightCollapsed],
); );
if (effectiveLeftCollapsed) { if (effectiveLeftCollapsed) {
@@ -6,11 +6,10 @@ import { PropertyPanel } from "./editor/PropertyPanel";
import { LayersPanel } from "./editor/LayersPanel"; import { LayersPanel } from "./editor/LayersPanel";
import { CaptionPropertyPanel } from "../captions/components/CaptionPropertyPanel"; import { CaptionPropertyPanel } from "../captions/components/CaptionPropertyPanel";
import { BlockParamsPanel } from "./editor/BlockParamsPanel"; import { BlockParamsPanel } from "./editor/BlockParamsPanel";
import { RenderQueue } from "./renders/RenderQueue"; import { RenderQueuePanel } from "./renders/RenderQueuePanel";
import { SlideshowPanel } from "./panels/SlideshowPanel"; import { SlideshowPanel } from "./panels/SlideshowPanel";
import { VariablesPanel } from "./panels/VariablesPanel"; import { VariablesPanel } from "./panels/VariablesPanel";
import { PanelTabButton } from "./PanelTabButton"; import { PanelTabButton } from "./PanelTabButton";
import { usePreviewVariablesStore } from "../hooks/previewVariablesStore";
import type { RenderJob } from "./renders/useRenderQueue"; import type { RenderJob } from "./renders/useRenderQueue";
import { STUDIO_FLAT_INSPECTOR_ENABLED } from "./editor/manualEditingAvailability"; import { STUDIO_FLAT_INSPECTOR_ENABLED } from "./editor/manualEditingAvailability";
import { useSlideshowPersist } from "../hooks/useSlideshowPersist"; import { useSlideshowPersist } from "../hooks/useSlideshowPersist";
@@ -66,7 +65,6 @@ export function StudioRightPanel({
projectId, projectId,
activeCompPath, activeCompPath,
showToast, showToast,
compositionDimensions,
waitForPendingDomEditSaves, waitForPendingDomEditSaves,
renderQueue, renderQueue,
} = useStudioShellContext(); } = useStudioShellContext();
@@ -385,36 +383,7 @@ export function StudioRightPanel({
</DesignPanelPromoteProvider> </DesignPanelPromoteProvider>
); );
const renderQueuePanel = ( const renderQueuePanel = <RenderQueuePanel />;
<RenderQueue
jobs={renderJobs}
projectId={projectId}
onDelete={renderQueue.deleteRender}
onCancel={renderQueue.cancelRender}
loadError={renderQueue.loadError}
onRetryLoad={renderQueue.reloadRenders}
actionError={renderQueue.actionError}
onDismissActionError={renderQueue.dismissActionError}
onClearCompleted={renderQueue.clearCompleted}
onStartRender={async (format, quality, resolution, fps) => {
await waitForPendingDomEditSaves();
const composition =
activeCompPath && activeCompPath !== "index.html" ? activeCompPath : undefined;
await renderQueue.startRender({
fps,
quality,
format,
resolution,
composition,
// Render what the user is previewing: active variable overrides
// from the Variables panel ride along (undefined = defaults).
variables: usePreviewVariablesStore.getState().values ?? undefined,
});
}}
compositionDimensions={compositionDimensions}
isRendering={renderQueue.isRendering}
/>
);
return ( return (
<> <>
@@ -0,0 +1,105 @@
// @vitest-environment happy-dom
// The card's job is to be actionable at the exact moment someone is stuck.
// The case worth pinning is a recheck that finds nothing: it changes no other
// pixel on screen, so without an explicit cue the button reads as broken.
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { FfmpegRequiredNotice } from "./FfmpegRequiredNotice";
import type { FfmpegStatus } from "./useFfmpegStatus";
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const MISSING: FfmpegStatus = {
ok: false,
title: "FFmpeg not found",
detail: "FFmpeg is required to encode video.",
hint: "brew install ffmpeg",
command: "brew install ffmpeg",
};
let root: Root | null = null;
let host: HTMLElement;
beforeEach(() => {
vi.useFakeTimers();
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
});
afterEach(() => {
if (root) act(() => root?.unmount());
root = null;
document.body.innerHTML = "";
vi.useRealTimers();
});
function render(props: {
status?: FfmpegStatus;
checking?: boolean;
onRecheck?: () => void;
}): void {
act(() => {
root?.render(
<FfmpegRequiredNotice
status={props.status ?? MISSING}
checking={props.checking ?? false}
onRecheck={props.onRecheck ?? vi.fn()}
/>,
);
});
}
describe("FfmpegRequiredNotice", () => {
it("leads with the command, not the apology", () => {
render({});
expect(host.querySelector("code")?.textContent).toBe("brew install ffmpeg");
expect(host.textContent).toContain("FFmpeg not found");
});
it("says so when a recheck still finds nothing", () => {
render({ checking: false });
expect(host.textContent).not.toContain("Still not found");
render({ checking: true });
expect(host.textContent).toContain("Checking…");
render({ checking: false });
expect(host.textContent).toContain("Still not found");
});
it("retires the cue so it cannot be mistaken for the card's steady state", () => {
render({ checking: false });
render({ checking: true });
render({ checking: false });
expect(host.textContent).toContain("Still not found");
act(() => {
vi.advanceTimersByTime(2000);
});
expect(host.textContent).not.toContain("Still not found");
});
it("shows no cue before the user has ever asked for a recheck", () => {
render({ checking: false });
act(() => {
vi.advanceTimersByTime(2000);
});
expect(host.textContent).not.toContain("Still not found");
});
it("falls back to the prose hint on a platform with no single command", () => {
render({
status: { ok: false, title: "FFmpeg not found", hint: "See the download page." },
});
expect(host.querySelector("code")).toBeNull();
expect(host.textContent).toContain("See the download page.");
});
});
@@ -0,0 +1,133 @@
import { memo, useEffect, useRef, useState } from "react";
import { copyTextToClipboard } from "../../utils/clipboard";
import type { FfmpegStatus } from "./useFfmpegStatus";
const DOWNLOAD_URL = "https://ffmpeg.org/download.html";
const CUE_MS = 1600;
// Matches the focus treatment every other button in this panel uses. A control
// the keyboard can reach but not see focus on is unusable without a mouse.
const FOCUS_RING =
"outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-studio-accent";
/**
* Shown above Export when the dev server reports no usable FFmpeg.
*
* This exists because the failure it replaces was the single most reported
* Studio problem: the user built a composition, pressed Export, and got
* "Server error (503)". The encoder had never been installed, the server knew
* that, and nothing said so. Saying it before the work starts, with a command
* they can paste, is the whole point, so the command is the loudest element
* here and not the apology.
*/
export const FfmpegRequiredNotice = memo(function FfmpegRequiredNotice({
status,
checking,
onRecheck,
}: {
status: FfmpegStatus;
checking: boolean;
onRecheck: () => void;
}) {
const [copied, setCopied] = useState(false);
// A recheck that finds nothing changes no other pixel on screen, so without
// this the button reads as broken at the exact moment the user is most
// unsure. Motion alone would not do: the result has to survive being missed.
const [recheckFailed, setRecheckFailed] = useState(false);
const wasChecking = useRef(false);
const cueTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
// Still mounted after a check finished means the answer was "still no":
// a success unmounts this card entirely.
if (wasChecking.current && !checking) setRecheckFailed(true);
wasChecking.current = checking;
}, [checking]);
useEffect(() => {
if (!recheckFailed) return;
cueTimer.current = setTimeout(() => setRecheckFailed(false), CUE_MS);
return () => clearTimeout(cueTimer.current);
}, [recheckFailed]);
useEffect(() => () => clearTimeout(cueTimer.current), []);
const copy = async (command: string) => {
const ok = await copyTextToClipboard(command);
if (!ok) return;
setCopied(true);
window.setTimeout(() => setCopied(false), CUE_MS);
};
// Concentric: outer radius (12) minus padding (10) equals the inner radius
// (2). Mismatched nesting here is what makes a card look subtly wrong.
return (
<div
role="alert"
className="flex flex-col gap-2 rounded-xl border border-amber-500/30 bg-amber-500/10 p-2.5"
>
<div className="flex flex-col gap-0.5">
<span className="text-[11px] font-semibold text-amber-300">
{status.title ?? "FFmpeg not found"}
</span>
{/* text-2, not the panel's usual text-4 for secondary copy: the amber
wash lifts the background, and text-4 measures 2.2:1 on it against
the 4.5:1 minimum. This is the line that explains why Export is
off, so it has to be readable. text-2 measures 6.6:1. */}
<span className="text-[10px] leading-snug text-pretty text-panel-text-2">
{status.detail ?? "FFmpeg is required to encode video."}
</span>
</div>
{status.command ? (
<div className="flex items-center gap-1.5">
<code className="flex-1 overflow-x-auto whitespace-nowrap rounded-sm bg-black/40 px-2 py-1 text-[10px] text-panel-text-2">
{status.command}
</code>
{/* Fixed width so swapping the label to "Copied" cannot shift the
command block sideways under the pointer. */}
<button
type="button"
onClick={() => void copy(status.command ?? "")}
className={`h-6 w-14 flex-shrink-0 rounded-sm border border-amber-500/30 text-[10px] font-medium text-amber-200 transition-colors hover:bg-amber-500/20 active:scale-[0.98] ${FOCUS_RING}`}
>
{copied ? "Copied" : "Copy"}
</button>
</div>
) : (
status.hint && (
<span className="text-[10px] leading-snug text-pretty text-panel-text-2">
{status.hint}
</span>
)
)}
<div className="flex items-center gap-3">
<button
type="button"
onClick={onRecheck}
disabled={checking}
className={`rounded-sm py-0.5 text-[10px] font-medium text-amber-200 underline-offset-2 transition-colors hover:underline disabled:opacity-50 ${FOCUS_RING}`}
>
{checking ? "Checking…" : "Recheck"}
</button>
<a
href={DOWNLOAD_URL}
target="_blank"
rel="noreferrer"
className={`rounded-sm py-0.5 text-[10px] text-panel-text-2 underline-offset-2 transition-colors hover:text-panel-text-0 hover:underline ${FOCUS_RING}`}
>
Other install options
</a>
{/* Last in the row and only ever appended, so appearing and vanishing
moves nothing that sits before it. */}
<span
aria-live="polite"
className="ml-auto text-[10px] text-panel-text-2 transition-opacity"
>
{recheckFailed ? "Still not found" : ""}
</span>
</div>
</div>
);
});
@@ -2,13 +2,24 @@
import { act } from "react"; import { act } from "react";
import { createRoot, type Root } from "react-dom/client"; import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RenderQueue } from "./RenderQueue"; import { RenderQueue } from "./RenderQueue";
import type { FfmpegStatus } from "./useFfmpegStatus";
// Encoder availability arrives as a prop (useRenderQueue owns the probe), so
// each case just states the environment it is about.
let ffmpegStatus: FfmpegStatus | null = { ok: true };
const recheck = vi.fn();
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
let root: Root | null = null; let root: Root | null = null;
beforeEach(() => {
ffmpegStatus = { ok: true };
recheck.mockClear();
});
afterEach(() => { afterEach(() => {
if (root) act(() => root?.unmount()); if (root) act(() => root?.unmount());
root = null; root = null;
@@ -29,6 +40,9 @@ function mountRenderQueue(onStartRender: ReturnType<typeof vi.fn>) {
onStartRender={onStartRender} onStartRender={onStartRender}
isRendering={false} isRendering={false}
compositionDimensions={{ width: 1920, height: 1080 }} compositionDimensions={{ width: 1920, height: 1080 }}
ffmpeg={ffmpegStatus}
ffmpegChecking={false}
onRecheckFfmpeg={recheck}
/>, />,
); );
}); });
@@ -60,3 +74,71 @@ describe("RenderQueue resolution submission", () => {
expect(onStartRender).toHaveBeenCalledWith("mp4", "standard", "landscape-4k", 30); expect(onStartRender).toHaveBeenCalledWith("mp4", "standard", "landscape-4k", 30);
}); });
}); });
function exportButtonIn(host: HTMLElement): HTMLButtonElement {
const button = [...host.querySelectorAll("button")].find((b) => b.textContent === "Export");
if (!button) throw new Error("export button did not render");
return button;
}
describe("RenderQueue FFmpeg gate", () => {
it("refuses Export and shows the install command when the server reports no FFmpeg", () => {
ffmpegStatus = {
ok: false,
title: "FFmpeg not found",
detail: "FFmpeg is required to encode video.",
hint: "brew install ffmpeg",
command: "brew install ffmpeg",
};
const onStartRender = vi.fn();
const host = mountRenderQueue(onStartRender);
expect(host.textContent).toContain("FFmpeg not found");
expect(host.querySelector("code")?.textContent).toBe("brew install ffmpeg");
const exportButton = exportButtonIn(host);
expect(exportButton.disabled).toBe(true);
act(() => {
exportButton.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onStartRender).not.toHaveBeenCalled();
});
it("offers a recheck so installing FFmpeg does not require restarting Studio", () => {
ffmpegStatus = { ok: false, title: "FFmpeg not found", command: "brew install ffmpeg" };
const host = mountRenderQueue(vi.fn());
const recheckButton = [...host.querySelectorAll("button")].find(
(b) => b.textContent === "Recheck",
);
if (!recheckButton) throw new Error("recheck button did not render");
act(() => {
recheckButton.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(recheck).toHaveBeenCalledTimes(1);
});
// An unreachable or older dev server answers nothing. Treating "no answer"
// as "not installed" would lock Export for setups that render fine.
it("leaves Export usable when the probe returns no answer", () => {
ffmpegStatus = null;
const onStartRender = vi.fn();
const host = mountRenderQueue(onStartRender);
expect(host.textContent).not.toContain("FFmpeg not found");
const exportButton = exportButtonIn(host);
expect(exportButton.disabled).toBe(false);
act(() => {
exportButton.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onStartRender).toHaveBeenCalledTimes(1);
});
it("says nothing when FFmpeg is present", () => {
const host = mountRenderQueue(vi.fn());
expect(host.textContent).not.toContain("FFmpeg not found");
expect(exportButtonIn(host).disabled).toBe(false);
});
});
@@ -2,6 +2,8 @@ import { memo, useState, useRef, useEffect, useLayoutEffect, useId } from "react
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { CANVAS_DIMENSIONS } from "@hyperframes/parsers"; import { CANVAS_DIMENSIONS } from "@hyperframes/parsers";
import { RenderQueueItem } from "./RenderQueueItem"; import { RenderQueueItem } from "./RenderQueueItem";
import { FfmpegRequiredNotice } from "./FfmpegRequiredNotice";
import type { FfmpegStatus } from "./useFfmpegStatus";
import { Button } from "../ui/Button"; import { Button } from "../ui/Button";
import { resolveFloatingPanelPosition, type FloatingPosition } from "../editor/floatingPanel"; import { resolveFloatingPanelPosition, type FloatingPosition } from "../editor/floatingPanel";
import type { RenderJob, ResolutionPreset } from "./useRenderQueue"; import type { RenderJob, ResolutionPreset } from "./useRenderQueue";
@@ -41,6 +43,13 @@ interface RenderQueueProps {
* a 1080p or 4K scale. `null` falls back to landscape (legacy default). * a 1080p or 4K scale. `null` falls back to landscape (legacy default).
*/ */
compositionDimensions?: CompositionDimensions | null; compositionDimensions?: CompositionDimensions | null;
/**
* Encoder availability, owned by useRenderQueue so the panel's Export button
* and the header's agree. `null` means "no answer", not "missing".
*/
ffmpeg: FfmpegStatus | null;
ffmpegChecking: boolean;
onRecheckFfmpeg: () => void;
} }
// Orientation is derived from the composition's authored aspect ratio, // Orientation is derived from the composition's authored aspect ratio,
@@ -266,11 +275,17 @@ function FormatExportButton({
isRendering, isRendering,
compositionDimensions, compositionDimensions,
lastRenderDurationMs, lastRenderDurationMs,
ffmpeg,
ffmpegChecking,
onRecheckFfmpeg,
}: { }: {
onStartRender: StartRenderHandler; onStartRender: StartRenderHandler;
isRendering: boolean; isRendering: boolean;
compositionDimensions?: CompositionDimensions | null; compositionDimensions?: CompositionDimensions | null;
lastRenderDurationMs?: number; lastRenderDurationMs?: number;
ffmpeg: FfmpegStatus | null;
ffmpegChecking: boolean;
onRecheckFfmpeg: () => void;
}) { }) {
const persisted = getPersistedRenderSettings(); const persisted = getPersistedRenderSettings();
const [format, setFormat] = useState<"mp4" | "webm" | "mov">(persisted.format); const [format, setFormat] = useState<"mp4" | "webm" | "mov">(persisted.format);
@@ -278,6 +293,12 @@ function FormatExportButton({
const [resolution, setResolution] = useState<RenderScale>("auto"); const [resolution, setResolution] = useState<RenderScale>("auto");
const [fps, setFps] = useState<24 | 30 | 60>(persisted.fps); const [fps, setFps] = useState<24 | 30 | 60>(persisted.fps);
// Only a definite "not installed" blocks Export. A null status means the
// probe gave no answer, and refusing to export on no answer would break
// setups that are perfectly fine. Holding the narrowed value rather than a
// boolean keeps the notice from re-testing what this line already decided.
const missingFfmpeg = ffmpeg && !ffmpeg.ok ? ffmpeg : null;
// MOV (ProRes) is a fixed-quality codec — quality selector has no effect. // MOV (ProRes) is a fixed-quality codec — quality selector has no effect.
const showQuality = format !== "mov"; const showQuality = format !== "mov";
@@ -286,6 +307,13 @@ function FormatExportButton({
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{missingFfmpeg && (
<FfmpegRequiredNotice
status={missingFfmpeg}
checking={ffmpegChecking}
onRecheck={onRecheckFfmpeg}
/>
)}
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@@ -369,10 +397,12 @@ function FormatExportButton({
variant="primary" variant="primary"
size="md" size="md"
loading={isRendering} loading={isRendering}
disabled={missingFfmpeg !== null}
title={missingFfmpeg ? "Install FFmpeg to export. See the note above." : undefined}
onClick={() => { onClick={() => {
// loading already disables the button; this guard also stops a // loading already disables the button; this guard also stops a
// double-click in the same frame from enqueueing two renders. // double-click in the same frame from enqueueing two renders.
if (isRendering) return; if (isRendering || missingFfmpeg) return;
const outputResolution = resolveResolution(resolution, compositionDimensions); const outputResolution = resolveResolution(resolution, compositionDimensions);
trackStudioEvent("render_start", { format, quality, resolution: outputResolution, fps }); trackStudioEvent("render_start", { format, quality, resolution: outputResolution, fps });
void onStartRender(format, quality, outputResolution, fps); void onStartRender(format, quality, outputResolution, fps);
@@ -403,6 +433,9 @@ export const RenderQueue = memo(function RenderQueue({
actionError, actionError,
onDismissActionError, onDismissActionError,
compositionDimensions, compositionDimensions,
ffmpeg,
ffmpegChecking,
onRecheckFfmpeg,
}: RenderQueueProps) { }: RenderQueueProps) {
const listRef = useRef<HTMLDivElement>(null); const listRef = useRef<HTMLDivElement>(null);
@@ -427,6 +460,9 @@ export const RenderQueue = memo(function RenderQueue({
isRendering={isRendering} isRendering={isRendering}
compositionDimensions={compositionDimensions} compositionDimensions={compositionDimensions}
lastRenderDurationMs={lastRenderDurationMs} lastRenderDurationMs={lastRenderDurationMs}
ffmpeg={ffmpeg}
ffmpegChecking={ffmpegChecking}
onRecheckFfmpeg={onRecheckFfmpeg}
/> />
</div> </div>
@@ -0,0 +1,56 @@
import { memo } from "react";
import { RenderQueue } from "./RenderQueue";
import type { RenderJob } from "./useRenderQueue";
import { useStudioShellContext } from "../../contexts/StudioContext";
import { usePreviewVariablesStore } from "../../hooks/previewVariablesStore";
/**
* The Renders tab, wired to the shell context.
*
* Split out of StudioRightPanel because every field it needs already lives in
* that context, so routing them through the panel only made the panel longer
* without giving anything a second reader.
*/
export const RenderQueuePanel = memo(function RenderQueuePanel() {
const {
projectId,
activeCompPath,
compositionDimensions,
waitForPendingDomEditSaves,
renderQueue,
} = useStudioShellContext();
return (
<RenderQueue
jobs={renderQueue.jobs as RenderJob[]}
projectId={projectId}
onDelete={renderQueue.deleteRender}
onCancel={renderQueue.cancelRender}
loadError={renderQueue.loadError}
onRetryLoad={renderQueue.reloadRenders}
actionError={renderQueue.actionError}
onDismissActionError={renderQueue.dismissActionError}
onClearCompleted={renderQueue.clearCompleted}
ffmpeg={renderQueue.ffmpeg}
ffmpegChecking={renderQueue.ffmpegChecking}
onRecheckFfmpeg={renderQueue.recheckFfmpeg}
onStartRender={async (format, quality, resolution, fps) => {
await waitForPendingDomEditSaves();
const composition =
activeCompPath && activeCompPath !== "index.html" ? activeCompPath : undefined;
await renderQueue.startRender({
fps,
quality,
format,
resolution,
composition,
// Render what the user is previewing: active variable overrides
// from the Variables panel ride along (undefined = defaults).
variables: usePreviewVariablesStore.getState().values ?? undefined,
});
}}
compositionDimensions={compositionDimensions}
isRendering={renderQueue.isRendering}
/>
);
});
@@ -0,0 +1,74 @@
// Shared mounting/stubbing for the useRenderQueue test files. Both of them
// need the same three things — a fetch that answers the render POST, an inert
// EventSource, and a component that exposes the hook's value — and keeping two
// copies in step is not worth the lines.
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { vi } from "vitest";
import type { useRenderQueue } from "./useRenderQueue";
export type RenderQueueApi = ReturnType<typeof useRenderQueue>;
type UseRenderQueue = typeof useRenderQueue;
// Part of the harness contract: importing it puts React in act() mode, so no
// test file has to remember to.
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
/** Answers the render POST with a job id, and the history GET with nothing. */
export function stubRenderFetch(): ReturnType<typeof vi.fn> {
const fetchMock = vi.fn(async () =>
Promise.resolve(
new Response(JSON.stringify({ jobId: "j1", status: "rendering" }), {
status: 200,
headers: { "content-type": "application/json" },
}),
),
);
vi.stubGlobal("fetch", fetchMock);
vi.stubGlobal(
"EventSource",
class {
close(): void {}
addEventListener(): void {}
},
);
return fetchMock;
}
/** Render POSTs only, so assertions ignore the history GET the hook makes. */
export function renderPosts(fetchMock: ReturnType<typeof vi.fn>): unknown[] {
return fetchMock.mock.calls.filter(
([, init]) => (init as RequestInit | undefined)?.method === "POST",
);
}
export interface MountedQueue {
/** The hook's current value. Throws if the harness never mounted. */
api: () => RenderQueueApi;
unmount: () => void;
}
export function mountRenderQueue(
useRenderQueueHook: UseRenderQueue,
projectId = "demo",
): MountedQueue {
let current: RenderQueueApi | null = null;
function Harness(): null {
current = useRenderQueueHook(projectId);
return null;
}
const host = document.createElement("div");
document.body.append(host);
const root: Root = createRoot(host);
act(() => {
root.render(<Harness />);
});
return {
api: () => {
if (!current) throw new Error("useRenderQueue harness did not mount");
return current;
},
unmount: () => act(() => root.unmount()),
};
}
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { readServerError } from "./serverError";
function jsonResponse(body: unknown, status: number): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
describe("readServerError", () => {
// The regression this whole file exists for: the render route's 503 carries
// "FFmpeg not found" plus the install command, and Studio used to show the
// user "Server error (503)" instead.
it("prefers the server's cause and remediation over the status code", async () => {
const res = jsonResponse({ error: "FFmpeg not found", hint: "brew install ffmpeg" }, 503);
await expect(readServerError(res)).resolves.toBe("FFmpeg not found. brew install ffmpeg");
});
it("uses the cause alone when the server sends no hint", async () => {
const res = jsonResponse({ error: "FFmpeg not found" }, 503);
await expect(readServerError(res)).resolves.toBe("FFmpeg not found");
});
it("falls back to the status code when the body is not JSON", async () => {
const res = new Response("<html>502 Bad Gateway</html>", { status: 502 });
await expect(readServerError(res)).resolves.toBe(
"Server error (502). Check the terminal for details.",
);
});
it("falls back to the status code when the JSON carries no error string", async () => {
const res = jsonResponse({ hint: "brew install ffmpeg" }, 500);
await expect(readServerError(res)).resolves.toBe(
"Server error (500). Check the terminal for details.",
);
});
it("ignores a non-string error rather than rendering it as an object", async () => {
const res = jsonResponse({ error: { code: 17 } }, 500);
await expect(readServerError(res)).resolves.toBe(
"Server error (500). Check the terminal for details.",
);
});
});
@@ -0,0 +1,21 @@
/**
* The render route answers a refusal with `{ error, hint }` naming the exact
* cause and how to fix it. Studio used to print the bare status code and drop
* that body, which is why the most common Studio export failure reached users
* as "Server error (503)" with no mention of the missing FFmpeg the server had
* already diagnosed. The status code is the fallback now, not the message.
*/
export async function readServerError(res: Response): Promise<string> {
try {
const body: unknown = await res.json();
if (typeof body === "object" && body !== null) {
const { error, hint } = body as { error?: unknown; hint?: unknown };
if (typeof error === "string" && error) {
return typeof hint === "string" && hint ? `${error}. ${hint}` : error;
}
}
} catch {
// Not JSON, or the body was already consumed — fall through to the status.
}
return `Server error (${res.status}). Check the terminal for details.`;
}
@@ -0,0 +1,108 @@
import { useCallback, useEffect, useRef, useState } from "react";
/**
* What the dev server knows about this machine's FFmpeg, as reported by
* `GET /api/environment/ffmpeg`. Studio asks when the Render panel opens so a
* missing encoder is a prompt with an install command, not a failed export an
* hour into the work.
*/
export interface FfmpegStatus {
ok: boolean;
/** Short headline, e.g. "FFmpeg not found". Absent when ok. */
title?: string;
/** Why it cannot run, in the server's words. Absent when ok. */
detail?: string;
/** Prose remediation, including the manual route where one exists. */
hint?: string;
/** A single pasteable install command, when this platform has one. */
command?: string;
}
/**
* `null` means "no answer", NOT "missing". An unreachable or older dev server
* is not evidence that FFmpeg is absent, and blocking Export on a failed probe
* would lock out people whose setup is fine. Unknown always fails open.
*/
type ProbeResult = FfmpegStatus | null;
/**
* One line naming the problem and the fix, for the render row Studio writes
* when it refuses to start. Prefers the command over the prose hint: the row
* is narrow, and the command is the part the user acts on.
*/
export function ffmpegInstallMessage(status: FfmpegStatus | null): string {
const title = status?.title ?? "FFmpeg not found";
const remedy = status?.command ?? status?.hint;
return remedy ? `${title}. Install it with: ${remedy}` : `${title}. Install FFmpeg to export.`;
}
// The Render panel unmounts on every right-panel tab switch, and each miss
// re-probes the filesystem server-side. Remember the answer for the tab's life.
let cached: ProbeResult = null;
const asText = (value: unknown): string | undefined =>
typeof value === "string" && value ? value : undefined;
/** Parses the endpoint's answer, or `null` for anything that is not one. */
function parseStatus(body: unknown): ProbeResult {
if (typeof body !== "object" || body === null) return null;
const { ok, title, detail, hint, command } = body as Record<string, unknown>;
if (typeof ok !== "boolean") return null;
return {
ok,
title: asText(title),
detail: asText(detail),
hint: asText(hint),
command: asText(command),
};
}
async function probe(): Promise<ProbeResult> {
try {
const res = await fetch("/api/environment/ffmpeg");
return res.ok ? parseStatus(await res.json()) : null;
} catch {
return null;
}
}
export function useFfmpegStatus(): {
status: ProbeResult;
checking: boolean;
recheck: () => void;
} {
const [status, setStatus] = useState<ProbeResult>(cached);
const [checking, setChecking] = useState(cached === null);
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const run = useCallback(async () => {
setChecking(true);
const next = await probe();
cached = next;
if (!mounted.current) return;
setStatus(next);
setChecking(false);
}, []);
useEffect(() => {
if (cached !== null) return;
void run();
}, [run]);
// Someone who just installed FFmpeg should not have to restart Studio to
// prove it. Drops the cache so the server re-probes rather than replaying
// the stale "not found" it already cached against.
const recheck = useCallback(() => {
cached = null;
void run();
}, [run]);
return { status, checking, recheck };
}
@@ -4,6 +4,8 @@ import { trackStudioRenderStart } from "../../telemetry/events";
import { getAnonymousId } from "../../telemetry/config"; import { getAnonymousId } from "../../telemetry/config";
import { browserTelemetryAllowed } from "../../telemetry/policy"; import { browserTelemetryAllowed } from "../../telemetry/policy";
import { generateId } from "../../utils/generateId"; import { generateId } from "../../utils/generateId";
import { readServerError } from "./serverError";
import { ffmpegInstallMessage, useFfmpegStatus } from "./useFfmpegStatus";
import { requestStudioFeedback, type FeedbackContext } from "../feedback/feedbackTrigger"; import { requestStudioFeedback, type FeedbackContext } from "../feedback/feedbackTrigger";
export interface RenderJob { export interface RenderJob {
@@ -71,6 +73,17 @@ export function useRenderQueue(projectId: string | null) {
const [loadError, setLoadError] = useState<string | null>(null); const [loadError, setLoadError] = useState<string | null>(null);
// Failure of a user action (delete/cancel), surfaced inline in the panel. // Failure of a user action (delete/cancel), surfaced inline in the panel.
const [actionError, setActionError] = useState<string | null>(null); const [actionError, setActionError] = useState<string | null>(null);
// Owned here rather than in the panel: Studio renders from three places —
// the panel's Export button, the header's, and each composition card in the
// left sidebar — and a check living in one of them leaves the rest free to
// start a render this machine cannot finish. Every caller routes through
// `startRender`, so that is where the refusal belongs. Call sites still
// read `ffmpegMissing` to put the prompt on screen, because a refusal the
// user cannot see reads as a broken button.
const { status: ffmpeg, checking: ffmpegChecking, recheck: recheckFfmpeg } = useFfmpegStatus();
// A null status means the probe gave no answer (older server, failed
// request), which is not evidence of a missing encoder. Unknown fails open.
const ffmpegMissing = ffmpeg !== null && !ffmpeg.ok;
const eventSourceRef = useRef<EventSource | null>(null); const eventSourceRef = useRef<EventSource | null>(null);
const activeJobRef = useRef<string | null>(null); const activeJobRef = useRef<string | null>(null);
// Renders started in THIS tab, mapped to the settings they ran with. // Renders started in THIS tab, mapped to the settings they ran with.
@@ -150,6 +163,23 @@ export function useRenderQueue(projectId: string | null) {
// fallow-ignore-next-line complexity // fallow-ignore-next-line complexity
async (opts: StartRenderOptions = {}) => { async (opts: StartRenderOptions = {}) => {
if (!projectId) return; if (!projectId) return;
// The server would answer this with a 503 anyway. Refusing here keeps
// the reason and the fix in the message, and keeps a control that
// forgot to disable itself from producing a mystery failure.
if (ffmpegMissing) {
addSessionJob(
{
id: generateId(),
status: "failed",
progress: 0,
error: ffmpegInstallMessage(ffmpeg),
filename: "Export blocked",
createdAt: Date.now(),
},
{},
);
return;
}
const fps = opts.fps ?? 30; const fps = opts.fps ?? 30;
const quality = opts.quality ?? "standard"; const quality = opts.quality ?? "standard";
@@ -237,7 +267,7 @@ export function useRenderQueue(projectId: string | null) {
id: generateId(), id: generateId(),
status: "failed", status: "failed",
progress: 0, progress: 0,
error: `Server error (${res.status}). Check the terminal for details.`, error: await readServerError(res),
filename: "Export failed", filename: "Export failed",
createdAt: startTime, createdAt: startTime,
}; };
@@ -307,7 +337,7 @@ export function useRenderQueue(projectId: string | null) {
return jobId; return jobId;
}, },
[projectId, closeActiveEventSource, addSessionJob], [projectId, closeActiveEventSource, addSessionJob, ffmpeg, ffmpegMissing],
); );
// Cancel an in-flight render. The job row stays (as "cancelled") so the // Cancel an in-flight render. The job row stays (as "cancelled") so the
@@ -431,6 +461,12 @@ export function useRenderQueue(projectId: string | null) {
cancelRender, cancelRender,
clearCompleted, clearCompleted,
startRender: startRender as (options: unknown) => Promise<void>, startRender: startRender as (options: unknown) => Promise<void>,
// Every Export control reads these, so no caller has to decide for
// itself whether this machine can encode.
ffmpeg,
ffmpegMissing,
ffmpegChecking,
recheckFfmpeg,
}), }),
[ [
jobs, jobs,
@@ -443,6 +479,10 @@ export function useRenderQueue(projectId: string | null) {
cancelRender, cancelRender,
clearCompleted, clearCompleted,
startRender, startRender,
ffmpeg,
ffmpegMissing,
ffmpegChecking,
recheckFfmpeg,
], ],
); );
} }
@@ -0,0 +1,79 @@
// @vitest-environment happy-dom
// Studio has three render entry points: the Renders panel's Export button, the
// header's, and the Render control on every composition card in the left
// sidebar. The last two call startRender directly. A check that lives in one
// button leaves every other caller free to queue a render this machine cannot
// finish, so the refusal lives in startRender, which all of them route through
// — including any fourth caller nobody has written yet.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { FfmpegStatus } from "./useFfmpegStatus";
import { mountRenderQueue, renderPosts, stubRenderFetch } from "./renderQueueTestHarness";
let ffmpegStatus: FfmpegStatus | null = { ok: true };
vi.mock("./useFfmpegStatus", async (importOriginal) => ({
...(await importOriginal<typeof import("./useFfmpegStatus")>()),
useFfmpegStatus: () => ({ status: ffmpegStatus, checking: false, recheck: vi.fn() }),
}));
// Only the analytics call is stubbed. The identity and policy modules read
// localStorage, which happy-dom provides, so faking them would only be faking.
vi.mock("../../telemetry/events", () => ({ trackStudioRenderStart: vi.fn() }));
const { useRenderQueue } = await import("./useRenderQueue");
let queue: ReturnType<typeof mountRenderQueue> | null = null;
let fetchMock: ReturnType<typeof stubRenderFetch>;
beforeEach(() => {
ffmpegStatus = { ok: true };
fetchMock = stubRenderFetch();
});
afterEach(() => {
queue?.unmount();
queue = null;
document.body.innerHTML = "";
vi.unstubAllGlobals();
});
async function start(): Promise<ReturnType<typeof mountRenderQueue>> {
queue = mountRenderQueue(useRenderQueue);
const { act } = await import("react");
await act(async () => {
await queue?.api().startRender({});
});
return queue;
}
describe("useRenderQueue FFmpeg gate", () => {
it("refuses to start a render, from any caller, when FFmpeg is missing", async () => {
ffmpegStatus = { ok: false, title: "FFmpeg not found", command: "brew install ffmpeg" };
const mounted = await start();
expect(renderPosts(fetchMock)).toHaveLength(0);
const job = mounted.api().jobs.at(-1);
expect(job?.status).toBe("failed");
// The point of refusing here rather than letting the server 503: the row
// carries the fix, not a status code.
expect(job?.error).toBe("FFmpeg not found. Install it with: brew install ffmpeg");
});
it("starts the render when FFmpeg is present", async () => {
await start();
expect(renderPosts(fetchMock)).toHaveLength(1);
});
it("starts the render when the probe gave no answer", async () => {
// An unreachable or older dev server must not block a working setup.
ffmpegStatus = null;
const mounted = await start();
expect(renderPosts(fetchMock)).toHaveLength(1);
expect(mounted.api().ffmpegMissing).toBe(false);
});
});
@@ -8,8 +8,8 @@
// correctly. // correctly.
import { act } from "react"; import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mountRenderQueue, renderPosts, stubRenderFetch } from "./renderQueueTestHarness";
const policyState = { allowed: true }; const policyState = { allowed: true };
const mintCalls = vi.fn(() => "browser-user-123"); const mintCalls = vi.fn(() => "browser-user-123");
@@ -26,45 +26,17 @@ vi.mock("../../telemetry/events", () => ({
const { useRenderQueue } = await import("./useRenderQueue"); const { useRenderQueue } = await import("./useRenderQueue");
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); let queue: ReturnType<typeof mountRenderQueue> | null = null;
let root: Root | null = null;
/** Body of the POST the hook makes when a render is started. */ /** Body of the POST the hook makes when a render is started. */
async function startRenderBody(): Promise<Record<string, unknown>> { async function startRenderBody(): Promise<Record<string, unknown>> {
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => const fetchMock = stubRenderFetch();
Promise.resolve( queue = mountRenderQueue(useRenderQueue);
new Response(JSON.stringify({ jobId: "j1", status: "rendering" }), {
status: 200,
headers: { "content-type": "application/json" },
}),
),
);
vi.stubGlobal("fetch", fetchMock);
vi.stubGlobal(
"EventSource",
class {
close(): void {}
addEventListener(): void {}
},
);
let api: ReturnType<typeof useRenderQueue> | null = null;
function Harness(): null {
api = useRenderQueue("demo");
return null;
}
const host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
act(() => {
root?.render(<Harness />);
});
await act(async () => { await act(async () => {
await api?.startRender({ fps: 30, quality: "standard", format: "mp4" }); await queue?.api().startRender({ fps: 30, quality: "standard", format: "mp4" });
}); });
const post = fetchMock.mock.calls.find(([, init]) => init?.method === "POST"); const [post] = renderPosts(fetchMock) as [undefined | [string, RequestInit]];
const body = post?.[1]?.body; const body = post?.[1]?.body;
if (body === undefined || body === null) throw new Error("hook made no POST with a body"); if (body === undefined || body === null) throw new Error("hook made no POST with a body");
return JSON.parse(String(body)) as Record<string, unknown>; return JSON.parse(String(body)) as Record<string, unknown>;
@@ -76,8 +48,8 @@ beforeEach(() => {
}); });
afterEach(() => { afterEach(() => {
if (root) act(() => root?.unmount()); queue?.unmount();
root = null; queue = null;
document.body.innerHTML = ""; document.body.innerHTML = "";
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
@@ -1,6 +1,7 @@
import { createContext, useContext, useMemo, type ReactNode } from "react"; import { createContext, useContext, useMemo, type ReactNode } from "react";
import type { TimelineElement } from "../player"; import type { TimelineElement } from "../player";
import type { CompositionDimensions } from "../components/renders/RenderQueue"; import type { CompositionDimensions } from "../components/renders/RenderQueue";
import type { FfmpegStatus } from "../components/renders/useFfmpegStatus";
export interface StudioShellValue { export interface StudioShellValue {
projectId: string; projectId: string;
@@ -27,6 +28,12 @@ export interface StudioShellValue {
cancelRender: (jobId: string) => void; cancelRender: (jobId: string) => void;
clearCompleted: () => void; clearCompleted: () => void;
startRender: (options: unknown) => Promise<void>; startRender: (options: unknown) => Promise<void>;
/** Encoder availability. `null` means "no answer", not "missing". */
ffmpeg: FfmpegStatus | null;
/** True only when the server positively reported no usable FFmpeg. */
ffmpegMissing: boolean;
ffmpegChecking: boolean;
recheckFfmpeg: () => void;
}; };
compositionDimensions: CompositionDimensions | null; compositionDimensions: CompositionDimensions | null;
waitForPendingDomEditSaves: () => Promise<void>; waitForPendingDomEditSaves: () => Promise<void>;
@@ -20,18 +20,10 @@ interface StudioContextInput {
editHistory: { canUndo: boolean; canRedo: boolean; undoLabel: string; redoLabel: string }; editHistory: { canUndo: boolean; canRedo: boolean; undoLabel: string; redoLabel: string };
handleUndo: StudioContextValue["handleUndo"]; handleUndo: StudioContextValue["handleUndo"];
handleRedo: StudioContextValue["handleRedo"]; handleRedo: StudioContextValue["handleRedo"];
renderQueue: { // Was a second copy of the same shape, which meant every field added to the
jobs: unknown[]; // context had to be added here too or the build broke. Same idiom as the
isRendering: boolean; // fields around it: the context type owns it.
loadError: string | null; renderQueue: StudioContextValue["renderQueue"];
actionError: string | null;
dismissActionError: () => void;
reloadRenders: () => void;
deleteRender: (id: string) => void;
cancelRender: (id: string) => void;
clearCompleted: () => void;
startRender: (options: unknown) => Promise<void>;
};
compositionDimensions: { width: number; height: number } | null; compositionDimensions: { width: number; height: number } | null;
waitForPendingDomEditSaves: () => Promise<void>; waitForPendingDomEditSaves: () => Promise<void>;
handlePreviewIframeRef: (iframe: HTMLIFrameElement | null) => void; handlePreviewIframeRef: (iframe: HTMLIFrameElement | null) => void;
+5
View File
@@ -57,6 +57,11 @@ const GET_RESPONSES = new Map([
["/api/fonts", json({ fonts: [] })], ["/api/fonts", json({ fonts: [] })],
["/api/fonts/google", json({ fonts: [] })], ["/api/fonts/google", json({ fonts: [] })],
["/api/assets/global", json({ assets: [] })], ["/api/assets/global", json({ assets: [] })],
// Studio asks this on load so it can warn before Export instead of failing
// at encode time. A usable encoder is the case this smoke run wants: the
// interesting assertion is that the shell mounts clean, not that a blocking
// notice renders. The notice has its own tests.
["/api/environment/ffmpeg", json({ ok: true })],
]); ]);
const MUTATION_RESPONSES = new Map([ const MUTATION_RESPONSES = new Map([
[`${PROJECT_PATH}/selection`, json({ ok: true, selection: null, updatedAt: null })], [`${PROJECT_PATH}/selection`, json({ ok: true, selection: null, updatedAt: null })],