@hyperframes/studio 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/dist/assets/hyperframes-player-BTo132Bc.js +459 -0
  2. package/dist/assets/{index-CNMWti50.js → index-A4oM8HYD.js} +1 -1
  3. package/dist/assets/{index-C8UQR5cB.js → index-BsU5eQo4.js} +180 -180
  4. package/dist/assets/{index-qKEWQDBH.js → index-CEkoC1xI.js} +1 -1
  5. package/dist/assets/index-DcF4s1PG.css +1 -0
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +1721 -1487
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/components/StudioHeader.tsx +12 -1
  11. package/src/components/StudioLeftSidebar.tsx +14 -1
  12. package/src/components/StudioRightPanel.tsx +2 -33
  13. package/src/components/renders/FfmpegRequiredNotice.test.tsx +105 -0
  14. package/src/components/renders/FfmpegRequiredNotice.tsx +133 -0
  15. package/src/components/renders/RenderQueue.test.tsx +83 -1
  16. package/src/components/renders/RenderQueue.tsx +37 -1
  17. package/src/components/renders/RenderQueuePanel.tsx +56 -0
  18. package/src/components/renders/renderQueueTestHarness.tsx +74 -0
  19. package/src/components/renders/serverError.test.ts +50 -0
  20. package/src/components/renders/serverError.ts +21 -0
  21. package/src/components/renders/useFfmpegStatus.ts +108 -0
  22. package/src/components/renders/useRenderQueue.ts +42 -2
  23. package/src/components/renders/useRenderQueueFfmpegGate.test.tsx +79 -0
  24. package/src/components/renders/useRenderQueueTelemetry.test.tsx +8 -36
  25. package/src/contexts/StudioContext.tsx +7 -0
  26. package/src/hooks/useStudioContextValue.ts +4 -12
  27. package/dist/assets/hyperframes-player-BShTvtlv.js +0 -459
  28. package/dist/assets/index-8lDGbNjx.css +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -47,11 +47,11 @@
47
47
  "gsap": "^3.13.0",
48
48
  "marked": "^14.1.4",
49
49
  "mediabunny": "^1.45.3",
50
- "@hyperframes/core": "0.8.0",
51
- "@hyperframes/player": "0.8.0",
52
- "@hyperframes/studio-server": "0.8.0",
53
- "@hyperframes/parsers": "0.8.0",
54
- "@hyperframes/sdk": "0.8.0"
50
+ "@hyperframes/core": "0.8.2",
51
+ "@hyperframes/player": "0.8.2",
52
+ "@hyperframes/parsers": "0.8.2",
53
+ "@hyperframes/sdk": "0.8.2",
54
+ "@hyperframes/studio-server": "0.8.2"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/react": "19",
@@ -68,7 +68,7 @@
68
68
  "vite": "^6.4.2",
69
69
  "vitest": "^3.2.4",
70
70
  "zustand": "^5.0.0",
71
- "@hyperframes/producer": "0.8.0"
71
+ "@hyperframes/producer": "0.8.2"
72
72
  },
73
73
  "peerDependencies": {
74
74
  "react": "19",
@@ -229,6 +229,7 @@ export function StudioHeader({
229
229
  // the shareable Studio URL, so a dead click would rewrite a link.
230
230
  const { effectiveRightCollapsed, setRightCollapsed, setRightPanelTab } = usePanelLayoutContext();
231
231
  const isRendering = renderQueue.isRendering;
232
+ const ffmpegMissing = renderQueue.ffmpegMissing;
232
233
 
233
234
  return (
234
235
  <div className="flex items-center justify-between h-10 px-3 bg-neutral-900 border-b border-neutral-800 flex-shrink-0">
@@ -382,7 +383,11 @@ export function StudioHeader({
382
383
  </Tooltip>
383
384
  <Tooltip
384
385
  label={
385
- isRendering ? "A render is already in progress" : "Render and export this composition"
386
+ ffmpegMissing
387
+ ? "FFmpeg is not installed. Opens the Renders panel with the install command."
388
+ : isRendering
389
+ ? "A render is already in progress"
390
+ : "Render and export this composition"
386
391
  }
387
392
  side="bottom"
388
393
  >
@@ -393,6 +398,12 @@ export function StudioHeader({
393
398
  if (isRendering) return;
394
399
  setRightPanelTab("renders");
395
400
  setRightCollapsed(false);
401
+ // Without an encoder this render cannot finish, so the click
402
+ // delivers the user to the prompt that fixes it instead of
403
+ // queueing a job that exists only to fail. Disabling the button
404
+ // would leave them staring at a dead control with no route to
405
+ // the explanation.
406
+ if (ffmpegMissing) return;
396
407
  onExport?.();
397
408
  }}
398
409
  className="h-7 flex items-center gap-1.5 px-3 rounded-md text-[11px] font-semibold bg-studio-accent text-[#09090B] enabled:hover:brightness-110 transition-[filter,transform] enabled:active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed"
@@ -43,6 +43,8 @@ export function StudioLeftSidebar({
43
43
  handlePanelResizeStart,
44
44
  handlePanelResizeMove,
45
45
  handlePanelResizeEnd,
46
+ setRightPanelTab,
47
+ setRightCollapsed,
46
48
  } = usePanelLayoutContext();
47
49
  const { projectId, renderQueue, waitForPendingDomEditSaves } = useStudioShellContext();
48
50
  const {
@@ -64,11 +66,22 @@ export function StudioLeftSidebar({
64
66
 
65
67
  const handleRenderComposition = useCallback(
66
68
  async (comp: string) => {
69
+ // startRender refuses without an encoder, so nothing unfinishable gets
70
+ // queued either way. What it cannot do from here is show the reason:
71
+ // its refusal lands as a row in the Renders panel, which may be
72
+ // collapsed or on another tab, so the click would look like nothing
73
+ // happened. Same move the header makes: put the prompt in front of the
74
+ // user, then stop.
75
+ if (renderQueue.ffmpegMissing) {
76
+ setRightPanelTab("renders");
77
+ setRightCollapsed(false);
78
+ return;
79
+ }
67
80
  await waitForPendingDomEditSaves();
68
81
  const { format, quality, fps } = getPersistedRenderSettings();
69
82
  await renderQueue.startRender({ composition: comp, format, quality, fps });
70
83
  },
71
- [renderQueue, waitForPendingDomEditSaves],
84
+ [renderQueue, waitForPendingDomEditSaves, setRightPanelTab, setRightCollapsed],
72
85
  );
73
86
 
74
87
  if (effectiveLeftCollapsed) {
@@ -6,11 +6,10 @@ import { PropertyPanel } from "./editor/PropertyPanel";
6
6
  import { LayersPanel } from "./editor/LayersPanel";
7
7
  import { CaptionPropertyPanel } from "../captions/components/CaptionPropertyPanel";
8
8
  import { BlockParamsPanel } from "./editor/BlockParamsPanel";
9
- import { RenderQueue } from "./renders/RenderQueue";
9
+ import { RenderQueuePanel } from "./renders/RenderQueuePanel";
10
10
  import { SlideshowPanel } from "./panels/SlideshowPanel";
11
11
  import { VariablesPanel } from "./panels/VariablesPanel";
12
12
  import { PanelTabButton } from "./PanelTabButton";
13
- import { usePreviewVariablesStore } from "../hooks/previewVariablesStore";
14
13
  import type { RenderJob } from "./renders/useRenderQueue";
15
14
  import { STUDIO_FLAT_INSPECTOR_ENABLED } from "./editor/manualEditingAvailability";
16
15
  import { useSlideshowPersist } from "../hooks/useSlideshowPersist";
@@ -66,7 +65,6 @@ export function StudioRightPanel({
66
65
  projectId,
67
66
  activeCompPath,
68
67
  showToast,
69
- compositionDimensions,
70
68
  waitForPendingDomEditSaves,
71
69
  renderQueue,
72
70
  } = useStudioShellContext();
@@ -385,36 +383,7 @@ export function StudioRightPanel({
385
383
  </DesignPanelPromoteProvider>
386
384
  );
387
385
 
388
- const renderQueuePanel = (
389
- <RenderQueue
390
- jobs={renderJobs}
391
- projectId={projectId}
392
- onDelete={renderQueue.deleteRender}
393
- onCancel={renderQueue.cancelRender}
394
- loadError={renderQueue.loadError}
395
- onRetryLoad={renderQueue.reloadRenders}
396
- actionError={renderQueue.actionError}
397
- onDismissActionError={renderQueue.dismissActionError}
398
- onClearCompleted={renderQueue.clearCompleted}
399
- onStartRender={async (format, quality, resolution, fps) => {
400
- await waitForPendingDomEditSaves();
401
- const composition =
402
- activeCompPath && activeCompPath !== "index.html" ? activeCompPath : undefined;
403
- await renderQueue.startRender({
404
- fps,
405
- quality,
406
- format,
407
- resolution,
408
- composition,
409
- // Render what the user is previewing: active variable overrides
410
- // from the Variables panel ride along (undefined = defaults).
411
- variables: usePreviewVariablesStore.getState().values ?? undefined,
412
- });
413
- }}
414
- compositionDimensions={compositionDimensions}
415
- isRendering={renderQueue.isRendering}
416
- />
417
- );
386
+ const renderQueuePanel = <RenderQueuePanel />;
418
387
 
419
388
  return (
420
389
  <>
@@ -0,0 +1,105 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ // The card's job is to be actionable at the exact moment someone is stuck.
4
+ // The case worth pinning is a recheck that finds nothing: it changes no other
5
+ // pixel on screen, so without an explicit cue the button reads as broken.
6
+
7
+ import { act } from "react";
8
+ import { createRoot, type Root } from "react-dom/client";
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
10
+ import { FfmpegRequiredNotice } from "./FfmpegRequiredNotice";
11
+ import type { FfmpegStatus } from "./useFfmpegStatus";
12
+
13
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
14
+
15
+ const MISSING: FfmpegStatus = {
16
+ ok: false,
17
+ title: "FFmpeg not found",
18
+ detail: "FFmpeg is required to encode video.",
19
+ hint: "brew install ffmpeg",
20
+ command: "brew install ffmpeg",
21
+ };
22
+
23
+ let root: Root | null = null;
24
+ let host: HTMLElement;
25
+
26
+ beforeEach(() => {
27
+ vi.useFakeTimers();
28
+ host = document.createElement("div");
29
+ document.body.append(host);
30
+ root = createRoot(host);
31
+ });
32
+
33
+ afterEach(() => {
34
+ if (root) act(() => root?.unmount());
35
+ root = null;
36
+ document.body.innerHTML = "";
37
+ vi.useRealTimers();
38
+ });
39
+
40
+ function render(props: {
41
+ status?: FfmpegStatus;
42
+ checking?: boolean;
43
+ onRecheck?: () => void;
44
+ }): void {
45
+ act(() => {
46
+ root?.render(
47
+ <FfmpegRequiredNotice
48
+ status={props.status ?? MISSING}
49
+ checking={props.checking ?? false}
50
+ onRecheck={props.onRecheck ?? vi.fn()}
51
+ />,
52
+ );
53
+ });
54
+ }
55
+
56
+ describe("FfmpegRequiredNotice", () => {
57
+ it("leads with the command, not the apology", () => {
58
+ render({});
59
+
60
+ expect(host.querySelector("code")?.textContent).toBe("brew install ffmpeg");
61
+ expect(host.textContent).toContain("FFmpeg not found");
62
+ });
63
+
64
+ it("says so when a recheck still finds nothing", () => {
65
+ render({ checking: false });
66
+ expect(host.textContent).not.toContain("Still not found");
67
+
68
+ render({ checking: true });
69
+ expect(host.textContent).toContain("Checking…");
70
+
71
+ render({ checking: false });
72
+ expect(host.textContent).toContain("Still not found");
73
+ });
74
+
75
+ it("retires the cue so it cannot be mistaken for the card's steady state", () => {
76
+ render({ checking: false });
77
+ render({ checking: true });
78
+ render({ checking: false });
79
+ expect(host.textContent).toContain("Still not found");
80
+
81
+ act(() => {
82
+ vi.advanceTimersByTime(2000);
83
+ });
84
+
85
+ expect(host.textContent).not.toContain("Still not found");
86
+ });
87
+
88
+ it("shows no cue before the user has ever asked for a recheck", () => {
89
+ render({ checking: false });
90
+ act(() => {
91
+ vi.advanceTimersByTime(2000);
92
+ });
93
+
94
+ expect(host.textContent).not.toContain("Still not found");
95
+ });
96
+
97
+ it("falls back to the prose hint on a platform with no single command", () => {
98
+ render({
99
+ status: { ok: false, title: "FFmpeg not found", hint: "See the download page." },
100
+ });
101
+
102
+ expect(host.querySelector("code")).toBeNull();
103
+ expect(host.textContent).toContain("See the download page.");
104
+ });
105
+ });
@@ -0,0 +1,133 @@
1
+ import { memo, useEffect, useRef, useState } from "react";
2
+ import { copyTextToClipboard } from "../../utils/clipboard";
3
+ import type { FfmpegStatus } from "./useFfmpegStatus";
4
+
5
+ const DOWNLOAD_URL = "https://ffmpeg.org/download.html";
6
+ const CUE_MS = 1600;
7
+
8
+ // Matches the focus treatment every other button in this panel uses. A control
9
+ // the keyboard can reach but not see focus on is unusable without a mouse.
10
+ const FOCUS_RING =
11
+ "outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-studio-accent";
12
+
13
+ /**
14
+ * Shown above Export when the dev server reports no usable FFmpeg.
15
+ *
16
+ * This exists because the failure it replaces was the single most reported
17
+ * Studio problem: the user built a composition, pressed Export, and got
18
+ * "Server error (503)". The encoder had never been installed, the server knew
19
+ * that, and nothing said so. Saying it before the work starts, with a command
20
+ * they can paste, is the whole point, so the command is the loudest element
21
+ * here and not the apology.
22
+ */
23
+ export const FfmpegRequiredNotice = memo(function FfmpegRequiredNotice({
24
+ status,
25
+ checking,
26
+ onRecheck,
27
+ }: {
28
+ status: FfmpegStatus;
29
+ checking: boolean;
30
+ onRecheck: () => void;
31
+ }) {
32
+ const [copied, setCopied] = useState(false);
33
+ // A recheck that finds nothing changes no other pixel on screen, so without
34
+ // this the button reads as broken at the exact moment the user is most
35
+ // unsure. Motion alone would not do: the result has to survive being missed.
36
+ const [recheckFailed, setRecheckFailed] = useState(false);
37
+ const wasChecking = useRef(false);
38
+ const cueTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
39
+
40
+ useEffect(() => {
41
+ // Still mounted after a check finished means the answer was "still no":
42
+ // a success unmounts this card entirely.
43
+ if (wasChecking.current && !checking) setRecheckFailed(true);
44
+ wasChecking.current = checking;
45
+ }, [checking]);
46
+
47
+ useEffect(() => {
48
+ if (!recheckFailed) return;
49
+ cueTimer.current = setTimeout(() => setRecheckFailed(false), CUE_MS);
50
+ return () => clearTimeout(cueTimer.current);
51
+ }, [recheckFailed]);
52
+
53
+ useEffect(() => () => clearTimeout(cueTimer.current), []);
54
+
55
+ const copy = async (command: string) => {
56
+ const ok = await copyTextToClipboard(command);
57
+ if (!ok) return;
58
+ setCopied(true);
59
+ window.setTimeout(() => setCopied(false), CUE_MS);
60
+ };
61
+
62
+ // Concentric: outer radius (12) minus padding (10) equals the inner radius
63
+ // (2). Mismatched nesting here is what makes a card look subtly wrong.
64
+ return (
65
+ <div
66
+ role="alert"
67
+ className="flex flex-col gap-2 rounded-xl border border-amber-500/30 bg-amber-500/10 p-2.5"
68
+ >
69
+ <div className="flex flex-col gap-0.5">
70
+ <span className="text-[11px] font-semibold text-amber-300">
71
+ {status.title ?? "FFmpeg not found"}
72
+ </span>
73
+ {/* text-2, not the panel's usual text-4 for secondary copy: the amber
74
+ wash lifts the background, and text-4 measures 2.2:1 on it against
75
+ the 4.5:1 minimum. This is the line that explains why Export is
76
+ off, so it has to be readable. text-2 measures 6.6:1. */}
77
+ <span className="text-[10px] leading-snug text-pretty text-panel-text-2">
78
+ {status.detail ?? "FFmpeg is required to encode video."}
79
+ </span>
80
+ </div>
81
+
82
+ {status.command ? (
83
+ <div className="flex items-center gap-1.5">
84
+ <code className="flex-1 overflow-x-auto whitespace-nowrap rounded-sm bg-black/40 px-2 py-1 text-[10px] text-panel-text-2">
85
+ {status.command}
86
+ </code>
87
+ {/* Fixed width so swapping the label to "Copied" cannot shift the
88
+ command block sideways under the pointer. */}
89
+ <button
90
+ type="button"
91
+ onClick={() => void copy(status.command ?? "")}
92
+ className={`h-6 w-14 flex-shrink-0 rounded-sm border border-amber-500/30 text-[10px] font-medium text-amber-200 transition-colors hover:bg-amber-500/20 active:scale-[0.98] ${FOCUS_RING}`}
93
+ >
94
+ {copied ? "Copied" : "Copy"}
95
+ </button>
96
+ </div>
97
+ ) : (
98
+ status.hint && (
99
+ <span className="text-[10px] leading-snug text-pretty text-panel-text-2">
100
+ {status.hint}
101
+ </span>
102
+ )
103
+ )}
104
+
105
+ <div className="flex items-center gap-3">
106
+ <button
107
+ type="button"
108
+ onClick={onRecheck}
109
+ disabled={checking}
110
+ className={`rounded-sm py-0.5 text-[10px] font-medium text-amber-200 underline-offset-2 transition-colors hover:underline disabled:opacity-50 ${FOCUS_RING}`}
111
+ >
112
+ {checking ? "Checking…" : "Recheck"}
113
+ </button>
114
+ <a
115
+ href={DOWNLOAD_URL}
116
+ target="_blank"
117
+ rel="noreferrer"
118
+ className={`rounded-sm py-0.5 text-[10px] text-panel-text-2 underline-offset-2 transition-colors hover:text-panel-text-0 hover:underline ${FOCUS_RING}`}
119
+ >
120
+ Other install options
121
+ </a>
122
+ {/* Last in the row and only ever appended, so appearing and vanishing
123
+ moves nothing that sits before it. */}
124
+ <span
125
+ aria-live="polite"
126
+ className="ml-auto text-[10px] text-panel-text-2 transition-opacity"
127
+ >
128
+ {recheckFailed ? "Still not found" : ""}
129
+ </span>
130
+ </div>
131
+ </div>
132
+ );
133
+ });
@@ -2,13 +2,24 @@
2
2
 
3
3
  import { act } from "react";
4
4
  import { createRoot, type Root } from "react-dom/client";
5
- import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
6
  import { RenderQueue } from "./RenderQueue";
7
+ import type { FfmpegStatus } from "./useFfmpegStatus";
8
+
9
+ // Encoder availability arrives as a prop (useRenderQueue owns the probe), so
10
+ // each case just states the environment it is about.
11
+ let ffmpegStatus: FfmpegStatus | null = { ok: true };
12
+ const recheck = vi.fn();
7
13
 
8
14
  Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
9
15
 
10
16
  let root: Root | null = null;
11
17
 
18
+ beforeEach(() => {
19
+ ffmpegStatus = { ok: true };
20
+ recheck.mockClear();
21
+ });
22
+
12
23
  afterEach(() => {
13
24
  if (root) act(() => root?.unmount());
14
25
  root = null;
@@ -29,6 +40,9 @@ function mountRenderQueue(onStartRender: ReturnType<typeof vi.fn>) {
29
40
  onStartRender={onStartRender}
30
41
  isRendering={false}
31
42
  compositionDimensions={{ width: 1920, height: 1080 }}
43
+ ffmpeg={ffmpegStatus}
44
+ ffmpegChecking={false}
45
+ onRecheckFfmpeg={recheck}
32
46
  />,
33
47
  );
34
48
  });
@@ -60,3 +74,71 @@ describe("RenderQueue resolution submission", () => {
60
74
  expect(onStartRender).toHaveBeenCalledWith("mp4", "standard", "landscape-4k", 30);
61
75
  });
62
76
  });
77
+
78
+ function exportButtonIn(host: HTMLElement): HTMLButtonElement {
79
+ const button = [...host.querySelectorAll("button")].find((b) => b.textContent === "Export");
80
+ if (!button) throw new Error("export button did not render");
81
+ return button;
82
+ }
83
+
84
+ describe("RenderQueue FFmpeg gate", () => {
85
+ it("refuses Export and shows the install command when the server reports no FFmpeg", () => {
86
+ ffmpegStatus = {
87
+ ok: false,
88
+ title: "FFmpeg not found",
89
+ detail: "FFmpeg is required to encode video.",
90
+ hint: "brew install ffmpeg",
91
+ command: "brew install ffmpeg",
92
+ };
93
+ const onStartRender = vi.fn();
94
+ const host = mountRenderQueue(onStartRender);
95
+
96
+ expect(host.textContent).toContain("FFmpeg not found");
97
+ expect(host.querySelector("code")?.textContent).toBe("brew install ffmpeg");
98
+
99
+ const exportButton = exportButtonIn(host);
100
+ expect(exportButton.disabled).toBe(true);
101
+ act(() => {
102
+ exportButton.dispatchEvent(new MouseEvent("click", { bubbles: true }));
103
+ });
104
+ expect(onStartRender).not.toHaveBeenCalled();
105
+ });
106
+
107
+ it("offers a recheck so installing FFmpeg does not require restarting Studio", () => {
108
+ ffmpegStatus = { ok: false, title: "FFmpeg not found", command: "brew install ffmpeg" };
109
+ const host = mountRenderQueue(vi.fn());
110
+
111
+ const recheckButton = [...host.querySelectorAll("button")].find(
112
+ (b) => b.textContent === "Recheck",
113
+ );
114
+ if (!recheckButton) throw new Error("recheck button did not render");
115
+ act(() => {
116
+ recheckButton.dispatchEvent(new MouseEvent("click", { bubbles: true }));
117
+ });
118
+
119
+ expect(recheck).toHaveBeenCalledTimes(1);
120
+ });
121
+
122
+ // An unreachable or older dev server answers nothing. Treating "no answer"
123
+ // as "not installed" would lock Export for setups that render fine.
124
+ it("leaves Export usable when the probe returns no answer", () => {
125
+ ffmpegStatus = null;
126
+ const onStartRender = vi.fn();
127
+ const host = mountRenderQueue(onStartRender);
128
+
129
+ expect(host.textContent).not.toContain("FFmpeg not found");
130
+ const exportButton = exportButtonIn(host);
131
+ expect(exportButton.disabled).toBe(false);
132
+ act(() => {
133
+ exportButton.dispatchEvent(new MouseEvent("click", { bubbles: true }));
134
+ });
135
+ expect(onStartRender).toHaveBeenCalledTimes(1);
136
+ });
137
+
138
+ it("says nothing when FFmpeg is present", () => {
139
+ const host = mountRenderQueue(vi.fn());
140
+
141
+ expect(host.textContent).not.toContain("FFmpeg not found");
142
+ expect(exportButtonIn(host).disabled).toBe(false);
143
+ });
144
+ });
@@ -2,6 +2,8 @@ import { memo, useState, useRef, useEffect, useLayoutEffect, useId } from "react
2
2
  import { createPortal } from "react-dom";
3
3
  import { CANVAS_DIMENSIONS } from "@hyperframes/parsers";
4
4
  import { RenderQueueItem } from "./RenderQueueItem";
5
+ import { FfmpegRequiredNotice } from "./FfmpegRequiredNotice";
6
+ import type { FfmpegStatus } from "./useFfmpegStatus";
5
7
  import { Button } from "../ui/Button";
6
8
  import { resolveFloatingPanelPosition, type FloatingPosition } from "../editor/floatingPanel";
7
9
  import type { RenderJob, ResolutionPreset } from "./useRenderQueue";
@@ -41,6 +43,13 @@ interface RenderQueueProps {
41
43
  * a 1080p or 4K scale. `null` falls back to landscape (legacy default).
42
44
  */
43
45
  compositionDimensions?: CompositionDimensions | null;
46
+ /**
47
+ * Encoder availability, owned by useRenderQueue so the panel's Export button
48
+ * and the header's agree. `null` means "no answer", not "missing".
49
+ */
50
+ ffmpeg: FfmpegStatus | null;
51
+ ffmpegChecking: boolean;
52
+ onRecheckFfmpeg: () => void;
44
53
  }
45
54
 
46
55
  // Orientation is derived from the composition's authored aspect ratio,
@@ -266,11 +275,17 @@ function FormatExportButton({
266
275
  isRendering,
267
276
  compositionDimensions,
268
277
  lastRenderDurationMs,
278
+ ffmpeg,
279
+ ffmpegChecking,
280
+ onRecheckFfmpeg,
269
281
  }: {
270
282
  onStartRender: StartRenderHandler;
271
283
  isRendering: boolean;
272
284
  compositionDimensions?: CompositionDimensions | null;
273
285
  lastRenderDurationMs?: number;
286
+ ffmpeg: FfmpegStatus | null;
287
+ ffmpegChecking: boolean;
288
+ onRecheckFfmpeg: () => void;
274
289
  }) {
275
290
  const persisted = getPersistedRenderSettings();
276
291
  const [format, setFormat] = useState<"mp4" | "webm" | "mov">(persisted.format);
@@ -278,6 +293,12 @@ function FormatExportButton({
278
293
  const [resolution, setResolution] = useState<RenderScale>("auto");
279
294
  const [fps, setFps] = useState<24 | 30 | 60>(persisted.fps);
280
295
 
296
+ // Only a definite "not installed" blocks Export. A null status means the
297
+ // probe gave no answer, and refusing to export on no answer would break
298
+ // setups that are perfectly fine. Holding the narrowed value rather than a
299
+ // boolean keeps the notice from re-testing what this line already decided.
300
+ const missingFfmpeg = ffmpeg && !ffmpeg.ok ? ffmpeg : null;
301
+
281
302
  // MOV (ProRes) is a fixed-quality codec — quality selector has no effect.
282
303
  const showQuality = format !== "mov";
283
304
 
@@ -286,6 +307,13 @@ function FormatExportButton({
286
307
 
287
308
  return (
288
309
  <div className="flex flex-col gap-3">
310
+ {missingFfmpeg && (
311
+ <FfmpegRequiredNotice
312
+ status={missingFfmpeg}
313
+ checking={ffmpegChecking}
314
+ onRecheck={onRecheckFfmpeg}
315
+ />
316
+ )}
289
317
  <div className="grid grid-cols-2 gap-2">
290
318
  <div className="flex flex-col gap-1">
291
319
  <div className="flex items-center gap-1">
@@ -369,10 +397,12 @@ function FormatExportButton({
369
397
  variant="primary"
370
398
  size="md"
371
399
  loading={isRendering}
400
+ disabled={missingFfmpeg !== null}
401
+ title={missingFfmpeg ? "Install FFmpeg to export. See the note above." : undefined}
372
402
  onClick={() => {
373
403
  // loading already disables the button; this guard also stops a
374
404
  // double-click in the same frame from enqueueing two renders.
375
- if (isRendering) return;
405
+ if (isRendering || missingFfmpeg) return;
376
406
  const outputResolution = resolveResolution(resolution, compositionDimensions);
377
407
  trackStudioEvent("render_start", { format, quality, resolution: outputResolution, fps });
378
408
  void onStartRender(format, quality, outputResolution, fps);
@@ -403,6 +433,9 @@ export const RenderQueue = memo(function RenderQueue({
403
433
  actionError,
404
434
  onDismissActionError,
405
435
  compositionDimensions,
436
+ ffmpeg,
437
+ ffmpegChecking,
438
+ onRecheckFfmpeg,
406
439
  }: RenderQueueProps) {
407
440
  const listRef = useRef<HTMLDivElement>(null);
408
441
 
@@ -427,6 +460,9 @@ export const RenderQueue = memo(function RenderQueue({
427
460
  isRendering={isRendering}
428
461
  compositionDimensions={compositionDimensions}
429
462
  lastRenderDurationMs={lastRenderDurationMs}
463
+ ffmpeg={ffmpeg}
464
+ ffmpegChecking={ffmpegChecking}
465
+ onRecheckFfmpeg={onRecheckFfmpeg}
430
466
  />
431
467
  </div>
432
468
 
@@ -0,0 +1,56 @@
1
+ import { memo } from "react";
2
+ import { RenderQueue } from "./RenderQueue";
3
+ import type { RenderJob } from "./useRenderQueue";
4
+ import { useStudioShellContext } from "../../contexts/StudioContext";
5
+ import { usePreviewVariablesStore } from "../../hooks/previewVariablesStore";
6
+
7
+ /**
8
+ * The Renders tab, wired to the shell context.
9
+ *
10
+ * Split out of StudioRightPanel because every field it needs already lives in
11
+ * that context, so routing them through the panel only made the panel longer
12
+ * without giving anything a second reader.
13
+ */
14
+ export const RenderQueuePanel = memo(function RenderQueuePanel() {
15
+ const {
16
+ projectId,
17
+ activeCompPath,
18
+ compositionDimensions,
19
+ waitForPendingDomEditSaves,
20
+ renderQueue,
21
+ } = useStudioShellContext();
22
+
23
+ return (
24
+ <RenderQueue
25
+ jobs={renderQueue.jobs as RenderJob[]}
26
+ projectId={projectId}
27
+ onDelete={renderQueue.deleteRender}
28
+ onCancel={renderQueue.cancelRender}
29
+ loadError={renderQueue.loadError}
30
+ onRetryLoad={renderQueue.reloadRenders}
31
+ actionError={renderQueue.actionError}
32
+ onDismissActionError={renderQueue.dismissActionError}
33
+ onClearCompleted={renderQueue.clearCompleted}
34
+ ffmpeg={renderQueue.ffmpeg}
35
+ ffmpegChecking={renderQueue.ffmpegChecking}
36
+ onRecheckFfmpeg={renderQueue.recheckFfmpeg}
37
+ onStartRender={async (format, quality, resolution, fps) => {
38
+ await waitForPendingDomEditSaves();
39
+ const composition =
40
+ activeCompPath && activeCompPath !== "index.html" ? activeCompPath : undefined;
41
+ await renderQueue.startRender({
42
+ fps,
43
+ quality,
44
+ format,
45
+ resolution,
46
+ composition,
47
+ // Render what the user is previewing: active variable overrides
48
+ // from the Variables panel ride along (undefined = defaults).
49
+ variables: usePreviewVariablesStore.getState().values ?? undefined,
50
+ });
51
+ }}
52
+ compositionDimensions={compositionDimensions}
53
+ isRendering={renderQueue.isRendering}
54
+ />
55
+ );
56
+ });