@hyperframes/studio 0.8.0 → 0.8.1
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.
- package/dist/assets/hyperframes-player-CZd-Aooq.js +459 -0
- package/dist/assets/{index-CNMWti50.js → index-BUkSmrkr.js} +1 -1
- package/dist/assets/{index-qKEWQDBH.js → index-CCc35ru7.js} +1 -1
- package/dist/assets/{index-C8UQR5cB.js → index-CEuEI4NE.js} +180 -180
- package/dist/assets/index-DcF4s1PG.css +1 -0
- package/dist/index.html +2 -2
- package/dist/index.js +1721 -1487
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/StudioHeader.tsx +12 -1
- package/src/components/StudioLeftSidebar.tsx +14 -1
- package/src/components/StudioRightPanel.tsx +2 -33
- package/src/components/renders/FfmpegRequiredNotice.test.tsx +105 -0
- package/src/components/renders/FfmpegRequiredNotice.tsx +133 -0
- package/src/components/renders/RenderQueue.test.tsx +83 -1
- package/src/components/renders/RenderQueue.tsx +37 -1
- package/src/components/renders/RenderQueuePanel.tsx +56 -0
- package/src/components/renders/renderQueueTestHarness.tsx +74 -0
- package/src/components/renders/serverError.test.ts +50 -0
- package/src/components/renders/serverError.ts +21 -0
- package/src/components/renders/useFfmpegStatus.ts +108 -0
- package/src/components/renders/useRenderQueue.ts +42 -2
- package/src/components/renders/useRenderQueueFfmpegGate.test.tsx +79 -0
- package/src/components/renders/useRenderQueueTelemetry.test.tsx +8 -36
- package/src/contexts/StudioContext.tsx +7 -0
- package/src/hooks/useStudioContextValue.ts +4 -12
- package/dist/assets/hyperframes-player-BShTvtlv.js +0 -459
- 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.
|
|
3
|
+
"version": "0.8.1",
|
|
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.
|
|
51
|
-
"@hyperframes/
|
|
52
|
-
"@hyperframes/
|
|
53
|
-
"@hyperframes/
|
|
54
|
-
"@hyperframes/
|
|
50
|
+
"@hyperframes/core": "0.8.1",
|
|
51
|
+
"@hyperframes/sdk": "0.8.1",
|
|
52
|
+
"@hyperframes/parsers": "0.8.1",
|
|
53
|
+
"@hyperframes/player": "0.8.1",
|
|
54
|
+
"@hyperframes/studio-server": "0.8.1"
|
|
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.
|
|
71
|
+
"@hyperframes/producer": "0.8.1"
|
|
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
|
-
|
|
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 {
|
|
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
|
+
});
|