@hyperframes/studio 0.8.18 → 0.8.19
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-DzRNJZAz.js → hyperframes-player-BA-QO9CQ.js} +1 -1
- package/dist/assets/{index-F-PUkOVc.js → index-Bjd2hioj.js} +93 -93
- package/dist/assets/{index-B4qse6wy.js → index-Dd0fsIIL.js} +1 -1
- package/dist/assets/{index-SGl0bb71.js → index-DxccOrkZ.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +5 -13
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +1 -1
- package/src/components/renders/RenderQueuePanel.tsx +3 -10
- package/src/components/renders/renderQueueTestHarness.tsx +27 -1
- package/src/components/renders/useRenderQueue.ts +20 -4
- package/src/components/renders/useRenderQueueComposition.test.tsx +55 -0
- package/src/components/renders/useRenderQueueTelemetry.test.tsx +6 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.19",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -48,11 +48,11 @@
|
|
|
48
48
|
"gsap": "^3.13.0",
|
|
49
49
|
"marked": "^14.1.4",
|
|
50
50
|
"mediabunny": "^1.45.3",
|
|
51
|
-
"@hyperframes/core": "0.8.
|
|
52
|
-
"@hyperframes/parsers": "0.8.
|
|
53
|
-
"@hyperframes/player": "0.8.
|
|
54
|
-
"@hyperframes/sdk": "0.8.
|
|
55
|
-
"@hyperframes/studio-server": "0.8.
|
|
51
|
+
"@hyperframes/core": "0.8.19",
|
|
52
|
+
"@hyperframes/parsers": "0.8.19",
|
|
53
|
+
"@hyperframes/player": "0.8.19",
|
|
54
|
+
"@hyperframes/sdk": "0.8.19",
|
|
55
|
+
"@hyperframes/studio-server": "0.8.19"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
58
|
"@types/react": "19",
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
"vite": "^6.4.2",
|
|
70
70
|
"vitest": "^3.2.4",
|
|
71
71
|
"zustand": "^5.0.0",
|
|
72
|
-
"@hyperframes/producer": "0.8.
|
|
72
|
+
"@hyperframes/producer": "0.8.19"
|
|
73
73
|
},
|
|
74
74
|
"peerDependencies": {
|
|
75
75
|
"react": "19",
|
package/src/App.tsx
CHANGED
|
@@ -84,7 +84,7 @@ export function StudioApp() {
|
|
|
84
84
|
const activeCompPathRef = useRef(activeCompPath);
|
|
85
85
|
activeCompPathRef.current = activeCompPath;
|
|
86
86
|
const leftSidebarRef = useRef<LeftSidebarHandle>(null);
|
|
87
|
-
const renderQueue = useRenderQueue(projectId);
|
|
87
|
+
const renderQueue = useRenderQueue(projectId, activeCompPathRef);
|
|
88
88
|
const captionEditMode = useCaptionStore((s) => s.isEditMode);
|
|
89
89
|
const captionHasSelection = useCaptionStore((s) => s.selectedSegmentIds.size > 0);
|
|
90
90
|
const captionSync = useCaptionSync(projectId);
|
|
@@ -12,13 +12,8 @@ import { usePreviewVariablesStore } from "../../hooks/previewVariablesStore";
|
|
|
12
12
|
* without giving anything a second reader.
|
|
13
13
|
*/
|
|
14
14
|
export const RenderQueuePanel = memo(function RenderQueuePanel() {
|
|
15
|
-
const {
|
|
16
|
-
|
|
17
|
-
activeCompPath,
|
|
18
|
-
compositionDimensions,
|
|
19
|
-
waitForPendingDomEditSaves,
|
|
20
|
-
renderQueue,
|
|
21
|
-
} = useStudioShellContext();
|
|
15
|
+
const { projectId, compositionDimensions, waitForPendingDomEditSaves, renderQueue } =
|
|
16
|
+
useStudioShellContext();
|
|
22
17
|
|
|
23
18
|
return (
|
|
24
19
|
<RenderQueue
|
|
@@ -36,14 +31,12 @@ export const RenderQueuePanel = memo(function RenderQueuePanel() {
|
|
|
36
31
|
onRecheckFfmpeg={renderQueue.recheckFfmpeg}
|
|
37
32
|
onStartRender={async (format, quality, resolution, fps) => {
|
|
38
33
|
await waitForPendingDomEditSaves();
|
|
39
|
-
|
|
40
|
-
activeCompPath && activeCompPath !== "index.html" ? activeCompPath : undefined;
|
|
34
|
+
// No `composition`: startRender targets the active one by default.
|
|
41
35
|
await renderQueue.startRender({
|
|
42
36
|
fps,
|
|
43
37
|
quality,
|
|
44
38
|
format,
|
|
45
39
|
resolution,
|
|
46
|
-
composition,
|
|
47
40
|
// Render what the user is previewing: active variable overrides
|
|
48
41
|
// from the Variables panel ride along (undefined = defaults).
|
|
49
42
|
variables: usePreviewVariablesStore.getState().values ?? undefined,
|
|
@@ -49,13 +49,39 @@ export interface MountedQueue {
|
|
|
49
49
|
unmount: () => void;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Mounts the hook, starts one render, and returns the body of the POST it
|
|
54
|
+
* made — the only place Studio states what to render and who to attribute it
|
|
55
|
+
* to, so it is what the tests around it assert on. The caller keeps the
|
|
56
|
+
* returned queue to unmount it.
|
|
57
|
+
*/
|
|
58
|
+
export async function startRenderAndReadBody(
|
|
59
|
+
useRenderQueueHook: UseRenderQueue,
|
|
60
|
+
{
|
|
61
|
+
activeCompPath = null,
|
|
62
|
+
opts,
|
|
63
|
+
}: { activeCompPath?: string | null; opts?: Parameters<RenderQueueApi["startRender"]>[0] } = {},
|
|
64
|
+
): Promise<{ body: Record<string, unknown>; queue: MountedQueue }> {
|
|
65
|
+
const fetchMock = stubRenderFetch();
|
|
66
|
+
const queue = mountRenderQueue(useRenderQueueHook, "demo", activeCompPath);
|
|
67
|
+
await act(async () => {
|
|
68
|
+
await queue.api().startRender(opts);
|
|
69
|
+
});
|
|
70
|
+
const [post] = renderPosts(fetchMock) as [undefined | [string, RequestInit]];
|
|
71
|
+
const body = post?.[1]?.body;
|
|
72
|
+
if (body === undefined || body === null) throw new Error("hook made no POST with a body");
|
|
73
|
+
return { body: JSON.parse(String(body)) as Record<string, unknown>, queue };
|
|
74
|
+
}
|
|
75
|
+
|
|
52
76
|
export function mountRenderQueue(
|
|
53
77
|
useRenderQueueHook: UseRenderQueue,
|
|
54
78
|
projectId = "demo",
|
|
79
|
+
activeCompPath: string | null = null,
|
|
55
80
|
): MountedQueue {
|
|
56
81
|
let current: RenderQueueApi | null = null;
|
|
82
|
+
const activeCompPathRef = { current: activeCompPath };
|
|
57
83
|
function Harness(): null {
|
|
58
|
-
current = useRenderQueueHook(projectId);
|
|
84
|
+
current = useRenderQueueHook(projectId, activeCompPathRef);
|
|
59
85
|
return null;
|
|
60
86
|
}
|
|
61
87
|
const host = document.createElement("div");
|
|
@@ -30,7 +30,11 @@ export interface StartRenderOptions {
|
|
|
30
30
|
format?: "mp4" | "webm" | "mov";
|
|
31
31
|
/** `"auto"` (default) renders at the composition's authored dimensions. */
|
|
32
32
|
resolution?: ResolutionPreset | "auto";
|
|
33
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* Render a specific composition file. Omit it to render the composition the
|
|
35
|
+
* user currently has open — only the sidebar's per-composition Render button
|
|
36
|
+
* names one, because it renders a card the user is not looking at.
|
|
37
|
+
*/
|
|
34
38
|
composition?: string;
|
|
35
39
|
/**
|
|
36
40
|
* Composition-variable overrides ({variableId: value}), forwarded to the
|
|
@@ -66,7 +70,13 @@ function writeHiddenIds(projectId: string, ids: Set<string>): void {
|
|
|
66
70
|
}
|
|
67
71
|
}
|
|
68
72
|
|
|
69
|
-
export function useRenderQueue(
|
|
73
|
+
export function useRenderQueue(
|
|
74
|
+
projectId: string | null,
|
|
75
|
+
// A ref, not the value: the render target has to be read at click time, and
|
|
76
|
+
// threading the value through would rebuild every callback below on each
|
|
77
|
+
// composition switch.
|
|
78
|
+
activeCompPathRef: { current: string | null },
|
|
79
|
+
) {
|
|
70
80
|
const [jobs, setJobs] = useState<RenderJob[]>([]);
|
|
71
81
|
// History fetch failure — distinguished from "no renders yet" so the panel
|
|
72
82
|
// never shows a false empty state.
|
|
@@ -185,7 +195,13 @@ export function useRenderQueue(projectId: string | null) {
|
|
|
185
195
|
const quality = opts.quality ?? "standard";
|
|
186
196
|
const format = opts.format ?? "mp4";
|
|
187
197
|
const resolution = opts.resolution;
|
|
188
|
-
|
|
198
|
+
// Which composition a render targets belongs here, with the same
|
|
199
|
+
// argument the FFmpeg gate above makes: Studio starts renders from three
|
|
200
|
+
// controls, and a default living in one of them leaves the others
|
|
201
|
+
// exporting a file the user is not looking at. The header's Export
|
|
202
|
+
// passed no options at all, so every render it started went to
|
|
203
|
+
// index.html no matter which composition was selected (#3549).
|
|
204
|
+
const composition = opts.composition ?? activeCompPathRef.current ?? undefined;
|
|
189
205
|
|
|
190
206
|
trackStudioRenderStart({
|
|
191
207
|
fps,
|
|
@@ -344,7 +360,7 @@ export function useRenderQueue(projectId: string | null) {
|
|
|
344
360
|
|
|
345
361
|
return jobId;
|
|
346
362
|
},
|
|
347
|
-
[projectId, closeActiveEventSource, addSessionJob, ffmpeg, ffmpegMissing],
|
|
363
|
+
[projectId, activeCompPathRef, closeActiveEventSource, addSessionJob, ffmpeg, ffmpegMissing],
|
|
348
364
|
);
|
|
349
365
|
|
|
350
366
|
// Cancel an in-flight render. The job row stays (as "cancelled") so the
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
// The render POST is the only place Studio says WHICH file to render. When it
|
|
4
|
+
// says nothing the server falls back to index.html, so a caller that forgets
|
|
5
|
+
// the field does not fail — it silently exports the wrong video (#3549). The
|
|
6
|
+
// default therefore lives in startRender, which every control routes through.
|
|
7
|
+
|
|
8
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
9
|
+
import { startRenderAndReadBody, type MountedQueue } from "./renderQueueTestHarness";
|
|
10
|
+
|
|
11
|
+
vi.mock("../../telemetry/policy", () => ({ browserTelemetryAllowed: () => false }));
|
|
12
|
+
vi.mock("../../telemetry/config", () => ({ getAnonymousId: () => "unused" }));
|
|
13
|
+
vi.mock("../../telemetry/events", () => ({ trackStudioRenderStart: vi.fn() }));
|
|
14
|
+
|
|
15
|
+
const { useRenderQueue } = await import("./useRenderQueue");
|
|
16
|
+
|
|
17
|
+
let queue: MountedQueue | null = null;
|
|
18
|
+
|
|
19
|
+
/** Body of the render POST, started with `opts` while `activeCompPath` is open. */
|
|
20
|
+
async function renderBody(
|
|
21
|
+
activeCompPath: string | null,
|
|
22
|
+
opts?: Parameters<ReturnType<typeof useRenderQueue>["startRender"]>[0],
|
|
23
|
+
): Promise<Record<string, unknown>> {
|
|
24
|
+
const started = await startRenderAndReadBody(useRenderQueue, { activeCompPath, opts });
|
|
25
|
+
queue = started.queue;
|
|
26
|
+
return started.body;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
queue?.unmount();
|
|
31
|
+
queue = null;
|
|
32
|
+
document.body.innerHTML = "";
|
|
33
|
+
vi.unstubAllGlobals();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("render target composition", () => {
|
|
37
|
+
it("renders the composition the user has selected when the caller names none", async () => {
|
|
38
|
+
// The header's Export button: no options at all.
|
|
39
|
+
const body = await renderBody("parts/part-1.html", undefined);
|
|
40
|
+
expect(body["composition"]).toBe("parts/part-1.html");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("keeps the caller's composition when one is named", async () => {
|
|
44
|
+
// The sidebar's per-composition Render button renders a card the user is
|
|
45
|
+
// not looking at, so its argument must win over the active composition.
|
|
46
|
+
const body = await renderBody("parts/part-1.html", { composition: "parts/part-4.html" });
|
|
47
|
+
expect(body["composition"]).toBe("parts/part-4.html");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("omits the composition when nothing is selected", async () => {
|
|
51
|
+
// Master view. The server's index.html fallback is the right answer here.
|
|
52
|
+
const body = await renderBody(null, { format: "mp4" });
|
|
53
|
+
expect(body["composition"]).toBeUndefined();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -7,9 +7,8 @@
|
|
|
7
7
|
// install id rather than the user's, which is worse than attributing it
|
|
8
8
|
// correctly.
|
|
9
9
|
|
|
10
|
-
import { act } from "react";
|
|
11
10
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
12
|
-
import {
|
|
11
|
+
import { startRenderAndReadBody, type MountedQueue } from "./renderQueueTestHarness";
|
|
13
12
|
|
|
14
13
|
const policyState = { allowed: true };
|
|
15
14
|
const mintCalls = vi.fn(() => "browser-user-123");
|
|
@@ -26,20 +25,15 @@ vi.mock("../../telemetry/events", () => ({
|
|
|
26
25
|
|
|
27
26
|
const { useRenderQueue } = await import("./useRenderQueue");
|
|
28
27
|
|
|
29
|
-
let queue:
|
|
28
|
+
let queue: MountedQueue | null = null;
|
|
30
29
|
|
|
31
30
|
/** Body of the POST the hook makes when a render is started. */
|
|
32
31
|
async function startRenderBody(): Promise<Record<string, unknown>> {
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
await act(async () => {
|
|
36
|
-
await queue?.api().startRender({ fps: 30, quality: "standard", format: "mp4" });
|
|
32
|
+
const started = await startRenderAndReadBody(useRenderQueue, {
|
|
33
|
+
opts: { fps: 30, quality: "standard", format: "mp4" },
|
|
37
34
|
});
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const body = post?.[1]?.body;
|
|
41
|
-
if (body === undefined || body === null) throw new Error("hook made no POST with a body");
|
|
42
|
-
return JSON.parse(String(body)) as Record<string, unknown>;
|
|
35
|
+
queue = started.queue;
|
|
36
|
+
return started.body;
|
|
43
37
|
}
|
|
44
38
|
|
|
45
39
|
beforeEach(() => {
|