From 5058236eda13cc31536e39bd37cf7eb4f9a99025 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 17 Aug 2026 21:00:27 -0400 Subject: [PATCH] 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. --- packages/cli/src/browser/ffmpeg.test.ts | 72 ++++++++++ packages/cli/src/browser/ffmpeg.ts | 34 ++++- packages/cli/src/browser/preflight.ts | 4 +- packages/cli/src/server/studioServer.test.ts | 91 ++++++++---- packages/cli/src/server/studioServer.ts | 36 +++++ .../studio/src/components/StudioHeader.tsx | 13 +- .../src/components/StudioLeftSidebar.tsx | 15 +- .../src/components/StudioRightPanel.tsx | 35 +---- .../renders/FfmpegRequiredNotice.test.tsx | 105 ++++++++++++++ .../renders/FfmpegRequiredNotice.tsx | 133 ++++++++++++++++++ .../components/renders/RenderQueue.test.tsx | 84 ++++++++++- .../src/components/renders/RenderQueue.tsx | 38 ++++- .../components/renders/RenderQueuePanel.tsx | 56 ++++++++ .../renders/renderQueueTestHarness.tsx | 74 ++++++++++ .../components/renders/serverError.test.ts | 50 +++++++ .../src/components/renders/serverError.ts | 21 +++ .../src/components/renders/useFfmpegStatus.ts | 108 ++++++++++++++ .../src/components/renders/useRenderQueue.ts | 44 +++++- .../renders/useRenderQueueFfmpegGate.test.tsx | 79 +++++++++++ .../renders/useRenderQueueTelemetry.test.tsx | 44 ++---- .../studio/src/contexts/StudioContext.tsx | 7 + .../studio/src/hooks/useStudioContextValue.ts | 16 +-- scripts/studio-runtime-smoke.mjs | 5 + 23 files changed, 1043 insertions(+), 121 deletions(-) create mode 100644 packages/studio/src/components/renders/FfmpegRequiredNotice.test.tsx create mode 100644 packages/studio/src/components/renders/FfmpegRequiredNotice.tsx create mode 100644 packages/studio/src/components/renders/RenderQueuePanel.tsx create mode 100644 packages/studio/src/components/renders/renderQueueTestHarness.tsx create mode 100644 packages/studio/src/components/renders/serverError.test.ts create mode 100644 packages/studio/src/components/renders/serverError.ts create mode 100644 packages/studio/src/components/renders/useFfmpegStatus.ts create mode 100644 packages/studio/src/components/renders/useRenderQueueFfmpegGate.test.tsx diff --git a/packages/cli/src/browser/ffmpeg.test.ts b/packages/cli/src/browser/ffmpeg.test.ts index 0ffe39a77..056c6fefc 100644 --- a/packages/cli/src/browser/ffmpeg.test.ts +++ b/packages/cli/src/browser/ffmpeg.test.ts @@ -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()), + detectLinuxDistro: () => ({ family: linuxFamily, isWsl: false, prettyName: null }), +})); + const mockExecFile = vi.mocked(execFileSync); 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 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"); + }); +}); diff --git a/packages/cli/src/browser/ffmpeg.ts b/packages/cli/src/browser/ffmpeg.ts index bcc51a863..7c27d33e4 100644 --- a/packages/cli/src/browser/ffmpeg.ts +++ b/packages/cli/src/browser/ffmpeg.ts @@ -43,7 +43,19 @@ 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"; @@ -51,11 +63,27 @@ export function getFFmpegInstallHint(): string { // 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; 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; +} diff --git a/packages/cli/src/browser/preflight.ts b/packages/cli/src/browser/preflight.ts index e2306034f..831353841 100644 --- a/packages/cli/src/browser/preflight.ts +++ b/packages/cli/src/browser/preflight.ts @@ -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(), }; } diff --git a/packages/cli/src/server/studioServer.test.ts b/packages/cli/src/server/studioServer.test.ts index 0000a8e9c..9a18a9f33 100644 --- a/packages/cli/src/server/studioServer.test.ts +++ b/packages/cli/src/server/studioServer.test.ts @@ -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 }); + }, + ); +}); diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index 9d9e83e05..fd37e88ca 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -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 diff --git a/packages/studio/src/components/StudioHeader.tsx b/packages/studio/src/components/StudioHeader.tsx index 0e9855f82..b5980eee0 100644 --- a/packages/studio/src/components/StudioHeader.tsx +++ b/packages/studio/src/components/StudioHeader.tsx @@ -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 (
@@ -382,7 +383,11 @@ export function StudioHeader({ @@ -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" diff --git a/packages/studio/src/components/StudioLeftSidebar.tsx b/packages/studio/src/components/StudioLeftSidebar.tsx index 0cb90a5fc..b97e995ec 100644 --- a/packages/studio/src/components/StudioLeftSidebar.tsx +++ b/packages/studio/src/components/StudioLeftSidebar.tsx @@ -43,6 +43,8 @@ export function StudioLeftSidebar({ handlePanelResizeStart, handlePanelResizeMove, handlePanelResizeEnd, + setRightPanelTab, + setRightCollapsed, } = usePanelLayoutContext(); const { projectId, renderQueue, waitForPendingDomEditSaves } = useStudioShellContext(); const { @@ -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) { diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index a62bb9e1c..2df688d9c 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -6,11 +6,10 @@ import { PropertyPanel } from "./editor/PropertyPanel"; import { LayersPanel } from "./editor/LayersPanel"; import { CaptionPropertyPanel } from "../captions/components/CaptionPropertyPanel"; import { BlockParamsPanel } from "./editor/BlockParamsPanel"; -import { RenderQueue } from "./renders/RenderQueue"; +import { RenderQueuePanel } from "./renders/RenderQueuePanel"; import { SlideshowPanel } from "./panels/SlideshowPanel"; import { VariablesPanel } from "./panels/VariablesPanel"; import { PanelTabButton } from "./PanelTabButton"; -import { usePreviewVariablesStore } from "../hooks/previewVariablesStore"; import type { RenderJob } from "./renders/useRenderQueue"; import { STUDIO_FLAT_INSPECTOR_ENABLED } from "./editor/manualEditingAvailability"; import { useSlideshowPersist } from "../hooks/useSlideshowPersist"; @@ -66,7 +65,6 @@ export function StudioRightPanel({ projectId, activeCompPath, showToast, - compositionDimensions, waitForPendingDomEditSaves, renderQueue, } = useStudioShellContext(); @@ -385,36 +383,7 @@ export function StudioRightPanel({ ); - const renderQueuePanel = ( - { - 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} - /> - ); + const renderQueuePanel = ; return ( <> diff --git a/packages/studio/src/components/renders/FfmpegRequiredNotice.test.tsx b/packages/studio/src/components/renders/FfmpegRequiredNotice.test.tsx new file mode 100644 index 000000000..e47b60fbe --- /dev/null +++ b/packages/studio/src/components/renders/FfmpegRequiredNotice.test.tsx @@ -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( + , + ); + }); +} + +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."); + }); +}); diff --git a/packages/studio/src/components/renders/FfmpegRequiredNotice.tsx b/packages/studio/src/components/renders/FfmpegRequiredNotice.tsx new file mode 100644 index 000000000..b040534b9 --- /dev/null +++ b/packages/studio/src/components/renders/FfmpegRequiredNotice.tsx @@ -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>(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 ( +
+
+ + {status.title ?? "FFmpeg not found"} + + {/* 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. */} + + {status.detail ?? "FFmpeg is required to encode video."} + +
+ + {status.command ? ( +
+ + {status.command} + + {/* Fixed width so swapping the label to "Copied" cannot shift the + command block sideways under the pointer. */} + +
+ ) : ( + status.hint && ( + + {status.hint} + + ) + )} + +
+ + + Other install options + + {/* Last in the row and only ever appended, so appearing and vanishing + moves nothing that sits before it. */} + + {recheckFailed ? "Still not found" : ""} + +
+
+ ); +}); diff --git a/packages/studio/src/components/renders/RenderQueue.test.tsx b/packages/studio/src/components/renders/RenderQueue.test.tsx index a714193fc..dee603c96 100644 --- a/packages/studio/src/components/renders/RenderQueue.test.tsx +++ b/packages/studio/src/components/renders/RenderQueue.test.tsx @@ -2,13 +2,24 @@ import { act } from "react"; 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 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 }); let root: Root | null = null; +beforeEach(() => { + ffmpegStatus = { ok: true }; + recheck.mockClear(); +}); + afterEach(() => { if (root) act(() => root?.unmount()); root = null; @@ -29,6 +40,9 @@ function mountRenderQueue(onStartRender: ReturnType) { onStartRender={onStartRender} isRendering={false} 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); }); }); + +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); + }); +}); diff --git a/packages/studio/src/components/renders/RenderQueue.tsx b/packages/studio/src/components/renders/RenderQueue.tsx index 1c7adc9d8..ceb26f37e 100644 --- a/packages/studio/src/components/renders/RenderQueue.tsx +++ b/packages/studio/src/components/renders/RenderQueue.tsx @@ -2,6 +2,8 @@ import { memo, useState, useRef, useEffect, useLayoutEffect, useId } from "react import { createPortal } from "react-dom"; import { CANVAS_DIMENSIONS } from "@hyperframes/parsers"; import { RenderQueueItem } from "./RenderQueueItem"; +import { FfmpegRequiredNotice } from "./FfmpegRequiredNotice"; +import type { FfmpegStatus } from "./useFfmpegStatus"; import { Button } from "../ui/Button"; import { resolveFloatingPanelPosition, type FloatingPosition } from "../editor/floatingPanel"; import type { RenderJob, ResolutionPreset } from "./useRenderQueue"; @@ -41,6 +43,13 @@ interface RenderQueueProps { * a 1080p or 4K scale. `null` falls back to landscape (legacy default). */ 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, @@ -266,11 +275,17 @@ function FormatExportButton({ isRendering, compositionDimensions, lastRenderDurationMs, + ffmpeg, + ffmpegChecking, + onRecheckFfmpeg, }: { onStartRender: StartRenderHandler; isRendering: boolean; compositionDimensions?: CompositionDimensions | null; lastRenderDurationMs?: number; + ffmpeg: FfmpegStatus | null; + ffmpegChecking: boolean; + onRecheckFfmpeg: () => void; }) { const persisted = getPersistedRenderSettings(); const [format, setFormat] = useState<"mp4" | "webm" | "mov">(persisted.format); @@ -278,6 +293,12 @@ function FormatExportButton({ const [resolution, setResolution] = useState("auto"); 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. const showQuality = format !== "mov"; @@ -286,6 +307,13 @@ function FormatExportButton({ return (
+ {missingFfmpeg && ( + + )}
@@ -369,10 +397,12 @@ function FormatExportButton({ variant="primary" size="md" loading={isRendering} + disabled={missingFfmpeg !== null} + title={missingFfmpeg ? "Install FFmpeg to export. See the note above." : undefined} onClick={() => { // loading already disables the button; this guard also stops a // double-click in the same frame from enqueueing two renders. - if (isRendering) return; + if (isRendering || missingFfmpeg) return; const outputResolution = resolveResolution(resolution, compositionDimensions); trackStudioEvent("render_start", { format, quality, resolution: outputResolution, fps }); void onStartRender(format, quality, outputResolution, fps); @@ -403,6 +433,9 @@ export const RenderQueue = memo(function RenderQueue({ actionError, onDismissActionError, compositionDimensions, + ffmpeg, + ffmpegChecking, + onRecheckFfmpeg, }: RenderQueueProps) { const listRef = useRef(null); @@ -427,6 +460,9 @@ export const RenderQueue = memo(function RenderQueue({ isRendering={isRendering} compositionDimensions={compositionDimensions} lastRenderDurationMs={lastRenderDurationMs} + ffmpeg={ffmpeg} + ffmpegChecking={ffmpegChecking} + onRecheckFfmpeg={onRecheckFfmpeg} />
diff --git a/packages/studio/src/components/renders/RenderQueuePanel.tsx b/packages/studio/src/components/renders/RenderQueuePanel.tsx new file mode 100644 index 000000000..6c07e6d90 --- /dev/null +++ b/packages/studio/src/components/renders/RenderQueuePanel.tsx @@ -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 ( + { + 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} + /> + ); +}); diff --git a/packages/studio/src/components/renders/renderQueueTestHarness.tsx b/packages/studio/src/components/renders/renderQueueTestHarness.tsx new file mode 100644 index 000000000..460fe15a3 --- /dev/null +++ b/packages/studio/src/components/renders/renderQueueTestHarness.tsx @@ -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; +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 { + 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): 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(); + }); + return { + api: () => { + if (!current) throw new Error("useRenderQueue harness did not mount"); + return current; + }, + unmount: () => act(() => root.unmount()), + }; +} diff --git a/packages/studio/src/components/renders/serverError.test.ts b/packages/studio/src/components/renders/serverError.test.ts new file mode 100644 index 000000000..40e3516da --- /dev/null +++ b/packages/studio/src/components/renders/serverError.test.ts @@ -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("502 Bad Gateway", { 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.", + ); + }); +}); diff --git a/packages/studio/src/components/renders/serverError.ts b/packages/studio/src/components/renders/serverError.ts new file mode 100644 index 000000000..9b376ddc8 --- /dev/null +++ b/packages/studio/src/components/renders/serverError.ts @@ -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 { + 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.`; +} diff --git a/packages/studio/src/components/renders/useFfmpegStatus.ts b/packages/studio/src/components/renders/useFfmpegStatus.ts new file mode 100644 index 000000000..94af08515 --- /dev/null +++ b/packages/studio/src/components/renders/useFfmpegStatus.ts @@ -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; + if (typeof ok !== "boolean") return null; + return { + ok, + title: asText(title), + detail: asText(detail), + hint: asText(hint), + command: asText(command), + }; +} + +async function probe(): Promise { + 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(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 }; +} diff --git a/packages/studio/src/components/renders/useRenderQueue.ts b/packages/studio/src/components/renders/useRenderQueue.ts index 381863508..d4af7c61c 100644 --- a/packages/studio/src/components/renders/useRenderQueue.ts +++ b/packages/studio/src/components/renders/useRenderQueue.ts @@ -4,6 +4,8 @@ import { trackStudioRenderStart } from "../../telemetry/events"; import { getAnonymousId } from "../../telemetry/config"; import { browserTelemetryAllowed } from "../../telemetry/policy"; import { generateId } from "../../utils/generateId"; +import { readServerError } from "./serverError"; +import { ffmpegInstallMessage, useFfmpegStatus } from "./useFfmpegStatus"; import { requestStudioFeedback, type FeedbackContext } from "../feedback/feedbackTrigger"; export interface RenderJob { @@ -71,6 +73,17 @@ export function useRenderQueue(projectId: string | null) { const [loadError, setLoadError] = useState(null); // Failure of a user action (delete/cancel), surfaced inline in the panel. const [actionError, setActionError] = useState(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(null); const activeJobRef = useRef(null); // 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 async (opts: StartRenderOptions = {}) => { 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 quality = opts.quality ?? "standard"; @@ -237,7 +267,7 @@ export function useRenderQueue(projectId: string | null) { id: generateId(), status: "failed", progress: 0, - error: `Server error (${res.status}). Check the terminal for details.`, + error: await readServerError(res), filename: "Export failed", createdAt: startTime, }; @@ -307,7 +337,7 @@ export function useRenderQueue(projectId: string | null) { return jobId; }, - [projectId, closeActiveEventSource, addSessionJob], + [projectId, closeActiveEventSource, addSessionJob, ffmpeg, ffmpegMissing], ); // Cancel an in-flight render. The job row stays (as "cancelled") so the @@ -431,6 +461,12 @@ export function useRenderQueue(projectId: string | null) { cancelRender, clearCompleted, startRender: startRender as (options: unknown) => Promise, + // Every Export control reads these, so no caller has to decide for + // itself whether this machine can encode. + ffmpeg, + ffmpegMissing, + ffmpegChecking, + recheckFfmpeg, }), [ jobs, @@ -443,6 +479,10 @@ export function useRenderQueue(projectId: string | null) { cancelRender, clearCompleted, startRender, + ffmpeg, + ffmpegMissing, + ffmpegChecking, + recheckFfmpeg, ], ); } diff --git a/packages/studio/src/components/renders/useRenderQueueFfmpegGate.test.tsx b/packages/studio/src/components/renders/useRenderQueueFfmpegGate.test.tsx new file mode 100644 index 000000000..3bca83c1b --- /dev/null +++ b/packages/studio/src/components/renders/useRenderQueueFfmpegGate.test.tsx @@ -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()), + 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 | null = null; +let fetchMock: ReturnType; + +beforeEach(() => { + ffmpegStatus = { ok: true }; + fetchMock = stubRenderFetch(); +}); + +afterEach(() => { + queue?.unmount(); + queue = null; + document.body.innerHTML = ""; + vi.unstubAllGlobals(); +}); + +async function start(): Promise> { + 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); + }); +}); diff --git a/packages/studio/src/components/renders/useRenderQueueTelemetry.test.tsx b/packages/studio/src/components/renders/useRenderQueueTelemetry.test.tsx index c1625ad90..f303200ee 100644 --- a/packages/studio/src/components/renders/useRenderQueueTelemetry.test.tsx +++ b/packages/studio/src/components/renders/useRenderQueueTelemetry.test.tsx @@ -8,8 +8,8 @@ // correctly. import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mountRenderQueue, renderPosts, stubRenderFetch } from "./renderQueueTestHarness"; const policyState = { allowed: true }; const mintCalls = vi.fn(() => "browser-user-123"); @@ -26,45 +26,17 @@ vi.mock("../../telemetry/events", () => ({ const { useRenderQueue } = await import("./useRenderQueue"); -Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); - -let root: Root | null = null; +let queue: ReturnType | null = null; /** Body of the POST the hook makes when a render is started. */ async function startRenderBody(): Promise> { - const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => - 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 {} - }, - ); - - let api: ReturnType | 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(); - }); + const fetchMock = stubRenderFetch(); + queue = mountRenderQueue(useRenderQueue); 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; if (body === undefined || body === null) throw new Error("hook made no POST with a body"); return JSON.parse(String(body)) as Record; @@ -76,8 +48,8 @@ beforeEach(() => { }); afterEach(() => { - if (root) act(() => root?.unmount()); - root = null; + queue?.unmount(); + queue = null; document.body.innerHTML = ""; vi.unstubAllGlobals(); }); diff --git a/packages/studio/src/contexts/StudioContext.tsx b/packages/studio/src/contexts/StudioContext.tsx index df471b33d..5de449ffa 100644 --- a/packages/studio/src/contexts/StudioContext.tsx +++ b/packages/studio/src/contexts/StudioContext.tsx @@ -1,6 +1,7 @@ import { createContext, useContext, useMemo, type ReactNode } from "react"; import type { TimelineElement } from "../player"; import type { CompositionDimensions } from "../components/renders/RenderQueue"; +import type { FfmpegStatus } from "../components/renders/useFfmpegStatus"; export interface StudioShellValue { projectId: string; @@ -27,6 +28,12 @@ export interface StudioShellValue { cancelRender: (jobId: string) => void; clearCompleted: () => void; startRender: (options: unknown) => Promise; + /** 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; waitForPendingDomEditSaves: () => Promise; diff --git a/packages/studio/src/hooks/useStudioContextValue.ts b/packages/studio/src/hooks/useStudioContextValue.ts index 24beddb76..9553d82d0 100644 --- a/packages/studio/src/hooks/useStudioContextValue.ts +++ b/packages/studio/src/hooks/useStudioContextValue.ts @@ -20,18 +20,10 @@ interface StudioContextInput { editHistory: { canUndo: boolean; canRedo: boolean; undoLabel: string; redoLabel: string }; handleUndo: StudioContextValue["handleUndo"]; handleRedo: StudioContextValue["handleRedo"]; - renderQueue: { - jobs: unknown[]; - isRendering: boolean; - loadError: string | null; - actionError: string | null; - dismissActionError: () => void; - reloadRenders: () => void; - deleteRender: (id: string) => void; - cancelRender: (id: string) => void; - clearCompleted: () => void; - startRender: (options: unknown) => Promise; - }; + // Was a second copy of the same shape, which meant every field added to the + // context had to be added here too or the build broke. Same idiom as the + // fields around it: the context type owns it. + renderQueue: StudioContextValue["renderQueue"]; compositionDimensions: { width: number; height: number } | null; waitForPendingDomEditSaves: () => Promise; handlePreviewIframeRef: (iframe: HTMLIFrameElement | null) => void; diff --git a/scripts/studio-runtime-smoke.mjs b/scripts/studio-runtime-smoke.mjs index 1a8f0e480..f1bb14d0f 100644 --- a/scripts/studio-runtime-smoke.mjs +++ b/scripts/studio-runtime-smoke.mjs @@ -57,6 +57,11 @@ const GET_RESPONSES = new Map([ ["/api/fonts", json({ fonts: [] })], ["/api/fonts/google", json({ fonts: [] })], ["/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([ [`${PROJECT_PATH}/selection`, json({ ok: true, selection: null, updatedAt: null })],