Skip to content

Commit a652a25

Browse files
committed
fix(studio): export the composition the user has selected
The header's Export button started renders with no options at all, so the request carried no `composition` and the server fell back to index.html. Selecting a sub-composition in the Comps panel showed its canvas and timeline but exported the root file instead. Studio starts renders from three controls, and the render target was owned by each of them separately: the Renders panel resolved it, the header omitted it, the sidebar's per-composition button named one explicitly. Give it one owner in `startRender`, which all three route through, defaulting to the active composition and leaving an explicit argument to win. Fixes #3549
1 parent bddc9e9 commit a652a25

6 files changed

Lines changed: 112 additions & 28 deletions

File tree

packages/studio/src/App.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ export function StudioApp() {
8484
const activeCompPathRef = useRef(activeCompPath);
8585
activeCompPathRef.current = activeCompPath;
8686
const leftSidebarRef = useRef<LeftSidebarHandle>(null);
87-
const renderQueue = useRenderQueue(projectId);
87+
const renderQueue = useRenderQueue(projectId, activeCompPathRef);
8888
const captionEditMode = useCaptionStore((s) => s.isEditMode);
8989
const captionHasSelection = useCaptionStore((s) => s.selectedSegmentIds.size > 0);
9090
const captionSync = useCaptionSync(projectId);

packages/studio/src/components/renders/RenderQueuePanel.tsx

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,8 @@ import { usePreviewVariablesStore } from "../../hooks/previewVariablesStore";
1212
* without giving anything a second reader.
1313
*/
1414
export const RenderQueuePanel = memo(function RenderQueuePanel() {
15-
const {
16-
projectId,
17-
activeCompPath,
18-
compositionDimensions,
19-
waitForPendingDomEditSaves,
20-
renderQueue,
21-
} = useStudioShellContext();
15+
const { projectId, compositionDimensions, waitForPendingDomEditSaves, renderQueue } =
16+
useStudioShellContext();
2217

2318
return (
2419
<RenderQueue
@@ -36,14 +31,12 @@ export const RenderQueuePanel = memo(function RenderQueuePanel() {
3631
onRecheckFfmpeg={renderQueue.recheckFfmpeg}
3732
onStartRender={async (format, quality, resolution, fps) => {
3833
await waitForPendingDomEditSaves();
39-
const composition =
40-
activeCompPath && activeCompPath !== "index.html" ? activeCompPath : undefined;
34+
// No `composition`: startRender targets the active one by default.
4135
await renderQueue.startRender({
4236
fps,
4337
quality,
4438
format,
4539
resolution,
46-
composition,
4740
// Render what the user is previewing: active variable overrides
4841
// from the Variables panel ride along (undefined = defaults).
4942
variables: usePreviewVariablesStore.getState().values ?? undefined,

packages/studio/src/components/renders/renderQueueTestHarness.tsx

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,39 @@ export interface MountedQueue {
4949
unmount: () => void;
5050
}
5151

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+
5276
export function mountRenderQueue(
5377
useRenderQueueHook: UseRenderQueue,
5478
projectId = "demo",
79+
activeCompPath: string | null = null,
5580
): MountedQueue {
5681
let current: RenderQueueApi | null = null;
82+
const activeCompPathRef = { current: activeCompPath };
5783
function Harness(): null {
58-
current = useRenderQueueHook(projectId);
84+
current = useRenderQueueHook(projectId, activeCompPathRef);
5985
return null;
6086
}
6187
const host = document.createElement("div");

packages/studio/src/components/renders/useRenderQueue.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,11 @@ export interface StartRenderOptions {
3030
format?: "mp4" | "webm" | "mov";
3131
/** `"auto"` (default) renders at the composition's authored dimensions. */
3232
resolution?: ResolutionPreset | "auto";
33-
/** Render a specific composition file instead of index.html. */
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+
*/
3438
composition?: string;
3539
/**
3640
* Composition-variable overrides ({variableId: value}), forwarded to the
@@ -66,7 +70,13 @@ function writeHiddenIds(projectId: string, ids: Set<string>): void {
6670
}
6771
}
6872

69-
export function useRenderQueue(projectId: string | null) {
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+
) {
7080
const [jobs, setJobs] = useState<RenderJob[]>([]);
7181
// History fetch failure — distinguished from "no renders yet" so the panel
7282
// never shows a false empty state.
@@ -185,7 +195,13 @@ export function useRenderQueue(projectId: string | null) {
185195
const quality = opts.quality ?? "standard";
186196
const format = opts.format ?? "mp4";
187197
const resolution = opts.resolution;
188-
const composition = opts.composition;
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;
189205

190206
trackStudioRenderStart({
191207
fps,
@@ -344,7 +360,7 @@ export function useRenderQueue(projectId: string | null) {
344360

345361
return jobId;
346362
},
347-
[projectId, closeActiveEventSource, addSessionJob, ffmpeg, ffmpegMissing],
363+
[projectId, activeCompPathRef, closeActiveEventSource, addSessionJob, ffmpeg, ffmpegMissing],
348364
);
349365

350366
// Cancel an in-flight render. The job row stays (as "cancelled") so the
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
});

packages/studio/src/components/renders/useRenderQueueTelemetry.test.tsx

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,8 @@
77
// install id rather than the user's, which is worse than attributing it
88
// correctly.
99

10-
import { act } from "react";
1110
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
12-
import { mountRenderQueue, renderPosts, stubRenderFetch } from "./renderQueueTestHarness";
11+
import { startRenderAndReadBody, type MountedQueue } from "./renderQueueTestHarness";
1312

1413
const policyState = { allowed: true };
1514
const mintCalls = vi.fn(() => "browser-user-123");
@@ -26,20 +25,15 @@ vi.mock("../../telemetry/events", () => ({
2625

2726
const { useRenderQueue } = await import("./useRenderQueue");
2827

29-
let queue: ReturnType<typeof mountRenderQueue> | null = null;
28+
let queue: MountedQueue | null = null;
3029

3130
/** Body of the POST the hook makes when a render is started. */
3231
async function startRenderBody(): Promise<Record<string, unknown>> {
33-
const fetchMock = stubRenderFetch();
34-
queue = mountRenderQueue(useRenderQueue);
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" },
3734
});
38-
39-
const [post] = renderPosts(fetchMock) as [undefined | [string, RequestInit]];
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;
4337
}
4438

4539
beforeEach(() => {

0 commit comments

Comments
 (0)