@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
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Shared mounting/stubbing for the useRenderQueue test files. Both of them
|
|
2
|
+
// need the same three things — a fetch that answers the render POST, an inert
|
|
3
|
+
// EventSource, and a component that exposes the hook's value — and keeping two
|
|
4
|
+
// copies in step is not worth the lines.
|
|
5
|
+
|
|
6
|
+
import { act } from "react";
|
|
7
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
8
|
+
import { vi } from "vitest";
|
|
9
|
+
import type { useRenderQueue } from "./useRenderQueue";
|
|
10
|
+
|
|
11
|
+
export type RenderQueueApi = ReturnType<typeof useRenderQueue>;
|
|
12
|
+
type UseRenderQueue = typeof useRenderQueue;
|
|
13
|
+
|
|
14
|
+
// Part of the harness contract: importing it puts React in act() mode, so no
|
|
15
|
+
// test file has to remember to.
|
|
16
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
17
|
+
|
|
18
|
+
/** Answers the render POST with a job id, and the history GET with nothing. */
|
|
19
|
+
export function stubRenderFetch(): ReturnType<typeof vi.fn> {
|
|
20
|
+
const fetchMock = vi.fn(async () =>
|
|
21
|
+
Promise.resolve(
|
|
22
|
+
new Response(JSON.stringify({ jobId: "j1", status: "rendering" }), {
|
|
23
|
+
status: 200,
|
|
24
|
+
headers: { "content-type": "application/json" },
|
|
25
|
+
}),
|
|
26
|
+
),
|
|
27
|
+
);
|
|
28
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
29
|
+
vi.stubGlobal(
|
|
30
|
+
"EventSource",
|
|
31
|
+
class {
|
|
32
|
+
close(): void {}
|
|
33
|
+
addEventListener(): void {}
|
|
34
|
+
},
|
|
35
|
+
);
|
|
36
|
+
return fetchMock;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Render POSTs only, so assertions ignore the history GET the hook makes. */
|
|
40
|
+
export function renderPosts(fetchMock: ReturnType<typeof vi.fn>): unknown[] {
|
|
41
|
+
return fetchMock.mock.calls.filter(
|
|
42
|
+
([, init]) => (init as RequestInit | undefined)?.method === "POST",
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface MountedQueue {
|
|
47
|
+
/** The hook's current value. Throws if the harness never mounted. */
|
|
48
|
+
api: () => RenderQueueApi;
|
|
49
|
+
unmount: () => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function mountRenderQueue(
|
|
53
|
+
useRenderQueueHook: UseRenderQueue,
|
|
54
|
+
projectId = "demo",
|
|
55
|
+
): MountedQueue {
|
|
56
|
+
let current: RenderQueueApi | null = null;
|
|
57
|
+
function Harness(): null {
|
|
58
|
+
current = useRenderQueueHook(projectId);
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const host = document.createElement("div");
|
|
62
|
+
document.body.append(host);
|
|
63
|
+
const root: Root = createRoot(host);
|
|
64
|
+
act(() => {
|
|
65
|
+
root.render(<Harness />);
|
|
66
|
+
});
|
|
67
|
+
return {
|
|
68
|
+
api: () => {
|
|
69
|
+
if (!current) throw new Error("useRenderQueue harness did not mount");
|
|
70
|
+
return current;
|
|
71
|
+
},
|
|
72
|
+
unmount: () => act(() => root.unmount()),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { readServerError } from "./serverError";
|
|
3
|
+
|
|
4
|
+
function jsonResponse(body: unknown, status: number): Response {
|
|
5
|
+
return new Response(JSON.stringify(body), {
|
|
6
|
+
status,
|
|
7
|
+
headers: { "Content-Type": "application/json" },
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe("readServerError", () => {
|
|
12
|
+
// The regression this whole file exists for: the render route's 503 carries
|
|
13
|
+
// "FFmpeg not found" plus the install command, and Studio used to show the
|
|
14
|
+
// user "Server error (503)" instead.
|
|
15
|
+
it("prefers the server's cause and remediation over the status code", async () => {
|
|
16
|
+
const res = jsonResponse({ error: "FFmpeg not found", hint: "brew install ffmpeg" }, 503);
|
|
17
|
+
|
|
18
|
+
await expect(readServerError(res)).resolves.toBe("FFmpeg not found. brew install ffmpeg");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("uses the cause alone when the server sends no hint", async () => {
|
|
22
|
+
const res = jsonResponse({ error: "FFmpeg not found" }, 503);
|
|
23
|
+
|
|
24
|
+
await expect(readServerError(res)).resolves.toBe("FFmpeg not found");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("falls back to the status code when the body is not JSON", async () => {
|
|
28
|
+
const res = new Response("<html>502 Bad Gateway</html>", { status: 502 });
|
|
29
|
+
|
|
30
|
+
await expect(readServerError(res)).resolves.toBe(
|
|
31
|
+
"Server error (502). Check the terminal for details.",
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("falls back to the status code when the JSON carries no error string", async () => {
|
|
36
|
+
const res = jsonResponse({ hint: "brew install ffmpeg" }, 500);
|
|
37
|
+
|
|
38
|
+
await expect(readServerError(res)).resolves.toBe(
|
|
39
|
+
"Server error (500). Check the terminal for details.",
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("ignores a non-string error rather than rendering it as an object", async () => {
|
|
44
|
+
const res = jsonResponse({ error: { code: 17 } }, 500);
|
|
45
|
+
|
|
46
|
+
await expect(readServerError(res)).resolves.toBe(
|
|
47
|
+
"Server error (500). Check the terminal for details.",
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The render route answers a refusal with `{ error, hint }` naming the exact
|
|
3
|
+
* cause and how to fix it. Studio used to print the bare status code and drop
|
|
4
|
+
* that body, which is why the most common Studio export failure reached users
|
|
5
|
+
* as "Server error (503)" with no mention of the missing FFmpeg the server had
|
|
6
|
+
* already diagnosed. The status code is the fallback now, not the message.
|
|
7
|
+
*/
|
|
8
|
+
export async function readServerError(res: Response): Promise<string> {
|
|
9
|
+
try {
|
|
10
|
+
const body: unknown = await res.json();
|
|
11
|
+
if (typeof body === "object" && body !== null) {
|
|
12
|
+
const { error, hint } = body as { error?: unknown; hint?: unknown };
|
|
13
|
+
if (typeof error === "string" && error) {
|
|
14
|
+
return typeof hint === "string" && hint ? `${error}. ${hint}` : error;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
} catch {
|
|
18
|
+
// Not JSON, or the body was already consumed — fall through to the status.
|
|
19
|
+
}
|
|
20
|
+
return `Server error (${res.status}). Check the terminal for details.`;
|
|
21
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What the dev server knows about this machine's FFmpeg, as reported by
|
|
5
|
+
* `GET /api/environment/ffmpeg`. Studio asks when the Render panel opens so a
|
|
6
|
+
* missing encoder is a prompt with an install command, not a failed export an
|
|
7
|
+
* hour into the work.
|
|
8
|
+
*/
|
|
9
|
+
export interface FfmpegStatus {
|
|
10
|
+
ok: boolean;
|
|
11
|
+
/** Short headline, e.g. "FFmpeg not found". Absent when ok. */
|
|
12
|
+
title?: string;
|
|
13
|
+
/** Why it cannot run, in the server's words. Absent when ok. */
|
|
14
|
+
detail?: string;
|
|
15
|
+
/** Prose remediation, including the manual route where one exists. */
|
|
16
|
+
hint?: string;
|
|
17
|
+
/** A single pasteable install command, when this platform has one. */
|
|
18
|
+
command?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `null` means "no answer", NOT "missing". An unreachable or older dev server
|
|
23
|
+
* is not evidence that FFmpeg is absent, and blocking Export on a failed probe
|
|
24
|
+
* would lock out people whose setup is fine. Unknown always fails open.
|
|
25
|
+
*/
|
|
26
|
+
type ProbeResult = FfmpegStatus | null;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* One line naming the problem and the fix, for the render row Studio writes
|
|
30
|
+
* when it refuses to start. Prefers the command over the prose hint: the row
|
|
31
|
+
* is narrow, and the command is the part the user acts on.
|
|
32
|
+
*/
|
|
33
|
+
export function ffmpegInstallMessage(status: FfmpegStatus | null): string {
|
|
34
|
+
const title = status?.title ?? "FFmpeg not found";
|
|
35
|
+
const remedy = status?.command ?? status?.hint;
|
|
36
|
+
return remedy ? `${title}. Install it with: ${remedy}` : `${title}. Install FFmpeg to export.`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The Render panel unmounts on every right-panel tab switch, and each miss
|
|
40
|
+
// re-probes the filesystem server-side. Remember the answer for the tab's life.
|
|
41
|
+
let cached: ProbeResult = null;
|
|
42
|
+
|
|
43
|
+
const asText = (value: unknown): string | undefined =>
|
|
44
|
+
typeof value === "string" && value ? value : undefined;
|
|
45
|
+
|
|
46
|
+
/** Parses the endpoint's answer, or `null` for anything that is not one. */
|
|
47
|
+
function parseStatus(body: unknown): ProbeResult {
|
|
48
|
+
if (typeof body !== "object" || body === null) return null;
|
|
49
|
+
const { ok, title, detail, hint, command } = body as Record<string, unknown>;
|
|
50
|
+
if (typeof ok !== "boolean") return null;
|
|
51
|
+
return {
|
|
52
|
+
ok,
|
|
53
|
+
title: asText(title),
|
|
54
|
+
detail: asText(detail),
|
|
55
|
+
hint: asText(hint),
|
|
56
|
+
command: asText(command),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function probe(): Promise<ProbeResult> {
|
|
61
|
+
try {
|
|
62
|
+
const res = await fetch("/api/environment/ffmpeg");
|
|
63
|
+
return res.ok ? parseStatus(await res.json()) : null;
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function useFfmpegStatus(): {
|
|
70
|
+
status: ProbeResult;
|
|
71
|
+
checking: boolean;
|
|
72
|
+
recheck: () => void;
|
|
73
|
+
} {
|
|
74
|
+
const [status, setStatus] = useState<ProbeResult>(cached);
|
|
75
|
+
const [checking, setChecking] = useState(cached === null);
|
|
76
|
+
const mounted = useRef(true);
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
mounted.current = true;
|
|
80
|
+
return () => {
|
|
81
|
+
mounted.current = false;
|
|
82
|
+
};
|
|
83
|
+
}, []);
|
|
84
|
+
|
|
85
|
+
const run = useCallback(async () => {
|
|
86
|
+
setChecking(true);
|
|
87
|
+
const next = await probe();
|
|
88
|
+
cached = next;
|
|
89
|
+
if (!mounted.current) return;
|
|
90
|
+
setStatus(next);
|
|
91
|
+
setChecking(false);
|
|
92
|
+
}, []);
|
|
93
|
+
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (cached !== null) return;
|
|
96
|
+
void run();
|
|
97
|
+
}, [run]);
|
|
98
|
+
|
|
99
|
+
// Someone who just installed FFmpeg should not have to restart Studio to
|
|
100
|
+
// prove it. Drops the cache so the server re-probes rather than replaying
|
|
101
|
+
// the stale "not found" it already cached against.
|
|
102
|
+
const recheck = useCallback(() => {
|
|
103
|
+
cached = null;
|
|
104
|
+
void run();
|
|
105
|
+
}, [run]);
|
|
106
|
+
|
|
107
|
+
return { status, checking, recheck };
|
|
108
|
+
}
|
|
@@ -4,6 +4,8 @@ import { trackStudioRenderStart } from "../../telemetry/events";
|
|
|
4
4
|
import { getAnonymousId } from "../../telemetry/config";
|
|
5
5
|
import { browserTelemetryAllowed } from "../../telemetry/policy";
|
|
6
6
|
import { generateId } from "../../utils/generateId";
|
|
7
|
+
import { readServerError } from "./serverError";
|
|
8
|
+
import { ffmpegInstallMessage, useFfmpegStatus } from "./useFfmpegStatus";
|
|
7
9
|
import { requestStudioFeedback, type FeedbackContext } from "../feedback/feedbackTrigger";
|
|
8
10
|
|
|
9
11
|
export interface RenderJob {
|
|
@@ -71,6 +73,17 @@ export function useRenderQueue(projectId: string | null) {
|
|
|
71
73
|
const [loadError, setLoadError] = useState<string | null>(null);
|
|
72
74
|
// Failure of a user action (delete/cancel), surfaced inline in the panel.
|
|
73
75
|
const [actionError, setActionError] = useState<string | null>(null);
|
|
76
|
+
// Owned here rather than in the panel: Studio renders from three places —
|
|
77
|
+
// the panel's Export button, the header's, and each composition card in the
|
|
78
|
+
// left sidebar — and a check living in one of them leaves the rest free to
|
|
79
|
+
// start a render this machine cannot finish. Every caller routes through
|
|
80
|
+
// `startRender`, so that is where the refusal belongs. Call sites still
|
|
81
|
+
// read `ffmpegMissing` to put the prompt on screen, because a refusal the
|
|
82
|
+
// user cannot see reads as a broken button.
|
|
83
|
+
const { status: ffmpeg, checking: ffmpegChecking, recheck: recheckFfmpeg } = useFfmpegStatus();
|
|
84
|
+
// A null status means the probe gave no answer (older server, failed
|
|
85
|
+
// request), which is not evidence of a missing encoder. Unknown fails open.
|
|
86
|
+
const ffmpegMissing = ffmpeg !== null && !ffmpeg.ok;
|
|
74
87
|
const eventSourceRef = useRef<EventSource | null>(null);
|
|
75
88
|
const activeJobRef = useRef<string | null>(null);
|
|
76
89
|
// Renders started in THIS tab, mapped to the settings they ran with.
|
|
@@ -150,6 +163,23 @@ export function useRenderQueue(projectId: string | null) {
|
|
|
150
163
|
// fallow-ignore-next-line complexity
|
|
151
164
|
async (opts: StartRenderOptions = {}) => {
|
|
152
165
|
if (!projectId) return;
|
|
166
|
+
// The server would answer this with a 503 anyway. Refusing here keeps
|
|
167
|
+
// the reason and the fix in the message, and keeps a control that
|
|
168
|
+
// forgot to disable itself from producing a mystery failure.
|
|
169
|
+
if (ffmpegMissing) {
|
|
170
|
+
addSessionJob(
|
|
171
|
+
{
|
|
172
|
+
id: generateId(),
|
|
173
|
+
status: "failed",
|
|
174
|
+
progress: 0,
|
|
175
|
+
error: ffmpegInstallMessage(ffmpeg),
|
|
176
|
+
filename: "Export blocked",
|
|
177
|
+
createdAt: Date.now(),
|
|
178
|
+
},
|
|
179
|
+
{},
|
|
180
|
+
);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
153
183
|
|
|
154
184
|
const fps = opts.fps ?? 30;
|
|
155
185
|
const quality = opts.quality ?? "standard";
|
|
@@ -237,7 +267,7 @@ export function useRenderQueue(projectId: string | null) {
|
|
|
237
267
|
id: generateId(),
|
|
238
268
|
status: "failed",
|
|
239
269
|
progress: 0,
|
|
240
|
-
error:
|
|
270
|
+
error: await readServerError(res),
|
|
241
271
|
filename: "Export failed",
|
|
242
272
|
createdAt: startTime,
|
|
243
273
|
};
|
|
@@ -307,7 +337,7 @@ export function useRenderQueue(projectId: string | null) {
|
|
|
307
337
|
|
|
308
338
|
return jobId;
|
|
309
339
|
},
|
|
310
|
-
[projectId, closeActiveEventSource, addSessionJob],
|
|
340
|
+
[projectId, closeActiveEventSource, addSessionJob, ffmpeg, ffmpegMissing],
|
|
311
341
|
);
|
|
312
342
|
|
|
313
343
|
// Cancel an in-flight render. The job row stays (as "cancelled") so the
|
|
@@ -431,6 +461,12 @@ export function useRenderQueue(projectId: string | null) {
|
|
|
431
461
|
cancelRender,
|
|
432
462
|
clearCompleted,
|
|
433
463
|
startRender: startRender as (options: unknown) => Promise<void>,
|
|
464
|
+
// Every Export control reads these, so no caller has to decide for
|
|
465
|
+
// itself whether this machine can encode.
|
|
466
|
+
ffmpeg,
|
|
467
|
+
ffmpegMissing,
|
|
468
|
+
ffmpegChecking,
|
|
469
|
+
recheckFfmpeg,
|
|
434
470
|
}),
|
|
435
471
|
[
|
|
436
472
|
jobs,
|
|
@@ -443,6 +479,10 @@ export function useRenderQueue(projectId: string | null) {
|
|
|
443
479
|
cancelRender,
|
|
444
480
|
clearCompleted,
|
|
445
481
|
startRender,
|
|
482
|
+
ffmpeg,
|
|
483
|
+
ffmpegMissing,
|
|
484
|
+
ffmpegChecking,
|
|
485
|
+
recheckFfmpeg,
|
|
446
486
|
],
|
|
447
487
|
);
|
|
448
488
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
// Studio has three render entry points: the Renders panel's Export button, the
|
|
4
|
+
// header's, and the Render control on every composition card in the left
|
|
5
|
+
// sidebar. The last two call startRender directly. A check that lives in one
|
|
6
|
+
// button leaves every other caller free to queue a render this machine cannot
|
|
7
|
+
// finish, so the refusal lives in startRender, which all of them route through
|
|
8
|
+
// — including any fourth caller nobody has written yet.
|
|
9
|
+
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
11
|
+
import type { FfmpegStatus } from "./useFfmpegStatus";
|
|
12
|
+
import { mountRenderQueue, renderPosts, stubRenderFetch } from "./renderQueueTestHarness";
|
|
13
|
+
|
|
14
|
+
let ffmpegStatus: FfmpegStatus | null = { ok: true };
|
|
15
|
+
|
|
16
|
+
vi.mock("./useFfmpegStatus", async (importOriginal) => ({
|
|
17
|
+
...(await importOriginal<typeof import("./useFfmpegStatus")>()),
|
|
18
|
+
useFfmpegStatus: () => ({ status: ffmpegStatus, checking: false, recheck: vi.fn() }),
|
|
19
|
+
}));
|
|
20
|
+
// Only the analytics call is stubbed. The identity and policy modules read
|
|
21
|
+
// localStorage, which happy-dom provides, so faking them would only be faking.
|
|
22
|
+
vi.mock("../../telemetry/events", () => ({ trackStudioRenderStart: vi.fn() }));
|
|
23
|
+
|
|
24
|
+
const { useRenderQueue } = await import("./useRenderQueue");
|
|
25
|
+
|
|
26
|
+
let queue: ReturnType<typeof mountRenderQueue> | null = null;
|
|
27
|
+
let fetchMock: ReturnType<typeof stubRenderFetch>;
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
ffmpegStatus = { ok: true };
|
|
31
|
+
fetchMock = stubRenderFetch();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
queue?.unmount();
|
|
36
|
+
queue = null;
|
|
37
|
+
document.body.innerHTML = "";
|
|
38
|
+
vi.unstubAllGlobals();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
async function start(): Promise<ReturnType<typeof mountRenderQueue>> {
|
|
42
|
+
queue = mountRenderQueue(useRenderQueue);
|
|
43
|
+
const { act } = await import("react");
|
|
44
|
+
await act(async () => {
|
|
45
|
+
await queue?.api().startRender({});
|
|
46
|
+
});
|
|
47
|
+
return queue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe("useRenderQueue FFmpeg gate", () => {
|
|
51
|
+
it("refuses to start a render, from any caller, when FFmpeg is missing", async () => {
|
|
52
|
+
ffmpegStatus = { ok: false, title: "FFmpeg not found", command: "brew install ffmpeg" };
|
|
53
|
+
|
|
54
|
+
const mounted = await start();
|
|
55
|
+
|
|
56
|
+
expect(renderPosts(fetchMock)).toHaveLength(0);
|
|
57
|
+
const job = mounted.api().jobs.at(-1);
|
|
58
|
+
expect(job?.status).toBe("failed");
|
|
59
|
+
// The point of refusing here rather than letting the server 503: the row
|
|
60
|
+
// carries the fix, not a status code.
|
|
61
|
+
expect(job?.error).toBe("FFmpeg not found. Install it with: brew install ffmpeg");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("starts the render when FFmpeg is present", async () => {
|
|
65
|
+
await start();
|
|
66
|
+
|
|
67
|
+
expect(renderPosts(fetchMock)).toHaveLength(1);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("starts the render when the probe gave no answer", async () => {
|
|
71
|
+
// An unreachable or older dev server must not block a working setup.
|
|
72
|
+
ffmpegStatus = null;
|
|
73
|
+
|
|
74
|
+
const mounted = await start();
|
|
75
|
+
|
|
76
|
+
expect(renderPosts(fetchMock)).toHaveLength(1);
|
|
77
|
+
expect(mounted.api().ffmpegMissing).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
// correctly.
|
|
9
9
|
|
|
10
10
|
import { act } from "react";
|
|
11
|
-
import { createRoot, type Root } from "react-dom/client";
|
|
12
11
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
12
|
+
import { mountRenderQueue, renderPosts, stubRenderFetch } from "./renderQueueTestHarness";
|
|
13
13
|
|
|
14
14
|
const policyState = { allowed: true };
|
|
15
15
|
const mintCalls = vi.fn(() => "browser-user-123");
|
|
@@ -26,45 +26,17 @@ vi.mock("../../telemetry/events", () => ({
|
|
|
26
26
|
|
|
27
27
|
const { useRenderQueue } = await import("./useRenderQueue");
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
let root: Root | null = null;
|
|
29
|
+
let queue: ReturnType<typeof mountRenderQueue> | null = null;
|
|
32
30
|
|
|
33
31
|
/** Body of the POST the hook makes when a render is started. */
|
|
34
32
|
async function startRenderBody(): Promise<Record<string, unknown>> {
|
|
35
|
-
const fetchMock =
|
|
36
|
-
|
|
37
|
-
new Response(JSON.stringify({ jobId: "j1", status: "rendering" }), {
|
|
38
|
-
status: 200,
|
|
39
|
-
headers: { "content-type": "application/json" },
|
|
40
|
-
}),
|
|
41
|
-
),
|
|
42
|
-
);
|
|
43
|
-
vi.stubGlobal("fetch", fetchMock);
|
|
44
|
-
vi.stubGlobal(
|
|
45
|
-
"EventSource",
|
|
46
|
-
class {
|
|
47
|
-
close(): void {}
|
|
48
|
-
addEventListener(): void {}
|
|
49
|
-
},
|
|
50
|
-
);
|
|
51
|
-
|
|
52
|
-
let api: ReturnType<typeof useRenderQueue> | null = null;
|
|
53
|
-
function Harness(): null {
|
|
54
|
-
api = useRenderQueue("demo");
|
|
55
|
-
return null;
|
|
56
|
-
}
|
|
57
|
-
const host = document.createElement("div");
|
|
58
|
-
document.body.append(host);
|
|
59
|
-
root = createRoot(host);
|
|
60
|
-
act(() => {
|
|
61
|
-
root?.render(<Harness />);
|
|
62
|
-
});
|
|
33
|
+
const fetchMock = stubRenderFetch();
|
|
34
|
+
queue = mountRenderQueue(useRenderQueue);
|
|
63
35
|
await act(async () => {
|
|
64
|
-
await api
|
|
36
|
+
await queue?.api().startRender({ fps: 30, quality: "standard", format: "mp4" });
|
|
65
37
|
});
|
|
66
38
|
|
|
67
|
-
const post = fetchMock
|
|
39
|
+
const [post] = renderPosts(fetchMock) as [undefined | [string, RequestInit]];
|
|
68
40
|
const body = post?.[1]?.body;
|
|
69
41
|
if (body === undefined || body === null) throw new Error("hook made no POST with a body");
|
|
70
42
|
return JSON.parse(String(body)) as Record<string, unknown>;
|
|
@@ -76,8 +48,8 @@ beforeEach(() => {
|
|
|
76
48
|
});
|
|
77
49
|
|
|
78
50
|
afterEach(() => {
|
|
79
|
-
|
|
80
|
-
|
|
51
|
+
queue?.unmount();
|
|
52
|
+
queue = null;
|
|
81
53
|
document.body.innerHTML = "";
|
|
82
54
|
vi.unstubAllGlobals();
|
|
83
55
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
|
2
2
|
import type { TimelineElement } from "../player";
|
|
3
3
|
import type { CompositionDimensions } from "../components/renders/RenderQueue";
|
|
4
|
+
import type { FfmpegStatus } from "../components/renders/useFfmpegStatus";
|
|
4
5
|
|
|
5
6
|
export interface StudioShellValue {
|
|
6
7
|
projectId: string;
|
|
@@ -27,6 +28,12 @@ export interface StudioShellValue {
|
|
|
27
28
|
cancelRender: (jobId: string) => void;
|
|
28
29
|
clearCompleted: () => void;
|
|
29
30
|
startRender: (options: unknown) => Promise<void>;
|
|
31
|
+
/** Encoder availability. `null` means "no answer", not "missing". */
|
|
32
|
+
ffmpeg: FfmpegStatus | null;
|
|
33
|
+
/** True only when the server positively reported no usable FFmpeg. */
|
|
34
|
+
ffmpegMissing: boolean;
|
|
35
|
+
ffmpegChecking: boolean;
|
|
36
|
+
recheckFfmpeg: () => void;
|
|
30
37
|
};
|
|
31
38
|
compositionDimensions: CompositionDimensions | null;
|
|
32
39
|
waitForPendingDomEditSaves: () => Promise<void>;
|
|
@@ -20,18 +20,10 @@ interface StudioContextInput {
|
|
|
20
20
|
editHistory: { canUndo: boolean; canRedo: boolean; undoLabel: string; redoLabel: string };
|
|
21
21
|
handleUndo: StudioContextValue["handleUndo"];
|
|
22
22
|
handleRedo: StudioContextValue["handleRedo"];
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
actionError: string | null;
|
|
28
|
-
dismissActionError: () => void;
|
|
29
|
-
reloadRenders: () => void;
|
|
30
|
-
deleteRender: (id: string) => void;
|
|
31
|
-
cancelRender: (id: string) => void;
|
|
32
|
-
clearCompleted: () => void;
|
|
33
|
-
startRender: (options: unknown) => Promise<void>;
|
|
34
|
-
};
|
|
23
|
+
// Was a second copy of the same shape, which meant every field added to the
|
|
24
|
+
// context had to be added here too or the build broke. Same idiom as the
|
|
25
|
+
// fields around it: the context type owns it.
|
|
26
|
+
renderQueue: StudioContextValue["renderQueue"];
|
|
35
27
|
compositionDimensions: { width: number; height: number } | null;
|
|
36
28
|
waitForPendingDomEditSaves: () => Promise<void>;
|
|
37
29
|
handlePreviewIframeRef: (iframe: HTMLIFrameElement | null) => void;
|