Files
hyperframes/packages/studio/src/components/renders/RenderQueue.test.tsx
T
Miguel Ángel 5058236eda 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.
2026-08-17 21:00:27 -04:00

145 lines
4.8 KiB
TypeScript

// @vitest-environment happy-dom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
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;
document.body.innerHTML = "";
});
function mountRenderQueue(onStartRender: ReturnType<typeof vi.fn>) {
const host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
act(() => {
root?.render(
<RenderQueue
jobs={[]}
projectId="demo"
onDelete={vi.fn()}
onClearCompleted={vi.fn()}
onStartRender={onStartRender}
isRendering={false}
compositionDimensions={{ width: 1920, height: 1080 }}
ffmpeg={ffmpegStatus}
ffmpegChecking={false}
onRecheckFfmpeg={recheck}
/>,
);
});
return host;
}
describe("RenderQueue resolution submission", () => {
it("submits the canonical landscape 4K preset selected by the user", () => {
const onStartRender = vi.fn();
const host = mountRenderQueue(onStartRender);
const resolutionSelect = [...host.querySelectorAll("select")].find((select) =>
[...select.options].some((option) => option.textContent?.startsWith("4K")),
);
if (!resolutionSelect) throw new Error("resolution selector did not render");
act(() => {
resolutionSelect.value = "4k";
resolutionSelect.dispatchEvent(new Event("change", { bubbles: true }));
});
const exportButton = [...host.querySelectorAll("button")].find(
(button) => button.textContent === "Export",
);
if (!exportButton) throw new Error("export button did not render");
act(() => {
exportButton.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
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);
});
});