diff --git a/packages/cli/src/browser/ffmpeg.test.ts b/packages/cli/src/browser/ffmpeg.test.ts index 0ffe39a770..056c6fefc2 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 bcc51a8631..7c27d33e4a 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 e2306034f6..831353841b 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 0000a8e9c0..9a18a9f334 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 9d9e83e054..fd37e88ca8 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 0e9855f82f..b5980eee05 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 0cb90a5fc7..b97e995ec5 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 31ac169dd0..25091c542c 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -3,11 +3,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, type StudioEditPersistenceProps } from "./panels/VariablesPanel"; import { PanelTabButton } from "./PanelTabButton"; -import { usePreviewVariablesStore } from "../hooks/previewVariablesStore"; import type { RenderJob } from "./renders/useRenderQueue"; import type { BlockParam } from "@hyperframes/core/registry"; import { STUDIO_FLAT_INSPECTOR_ENABLED } from "./editor/manualEditingAvailability"; @@ -108,7 +107,6 @@ export function StudioRightPanel({ projectId, activeCompPath, showToast, - compositionDimensions, waitForPendingDomEditSaves, renderQueue, } = useStudioShellContext(); @@ -427,36 +425,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 0000000000..e47b60fbea --- /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 0000000000..b040534b91 --- /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 a714193fc2..dee603c965 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 1c7adc9d87..ceb26f37ee 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 0000000000..6c07e6d907 --- /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 0000000000..460fe15a3e --- /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 0000000000..40e3516da7 --- /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 0000000000..9b376ddc82 --- /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 0000000000..94af085154 --- /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 381863508f..d4af7c61c5 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 0000000000..3bca83c1b5 --- /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 c1625ad90a..f303200ee9 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 df471b33d1..5de449ffa6 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 24beddb76e..9553d82d0c 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 1a8f0e4800..f1bb14d0fd 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 })],