diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 6b23e67524..6ab9b79b0a 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -84,7 +84,7 @@ export function StudioApp() { const activeCompPathRef = useRef(activeCompPath); activeCompPathRef.current = activeCompPath; const leftSidebarRef = useRef(null); - const renderQueue = useRenderQueue(projectId); + const renderQueue = useRenderQueue(projectId, activeCompPathRef); const captionEditMode = useCaptionStore((s) => s.isEditMode); const captionHasSelection = useCaptionStore((s) => s.selectedSegmentIds.size > 0); const captionSync = useCaptionSync(projectId); diff --git a/packages/studio/src/components/renders/RenderQueuePanel.tsx b/packages/studio/src/components/renders/RenderQueuePanel.tsx index 6c07e6d907..e4a9df6512 100644 --- a/packages/studio/src/components/renders/RenderQueuePanel.tsx +++ b/packages/studio/src/components/renders/RenderQueuePanel.tsx @@ -12,13 +12,8 @@ import { usePreviewVariablesStore } from "../../hooks/previewVariablesStore"; * without giving anything a second reader. */ export const RenderQueuePanel = memo(function RenderQueuePanel() { - const { - projectId, - activeCompPath, - compositionDimensions, - waitForPendingDomEditSaves, - renderQueue, - } = useStudioShellContext(); + const { projectId, compositionDimensions, waitForPendingDomEditSaves, renderQueue } = + useStudioShellContext(); return ( { await waitForPendingDomEditSaves(); - const composition = - activeCompPath && activeCompPath !== "index.html" ? activeCompPath : undefined; + // No `composition`: startRender targets the active one by default. await renderQueue.startRender({ fps, quality, format, resolution, - composition, // Render what the user is previewing: active variable overrides // from the Variables panel ride along (undefined = defaults). variables: usePreviewVariablesStore.getState().values ?? undefined, diff --git a/packages/studio/src/components/renders/renderQueueTestHarness.tsx b/packages/studio/src/components/renders/renderQueueTestHarness.tsx index 460fe15a3e..21344788e2 100644 --- a/packages/studio/src/components/renders/renderQueueTestHarness.tsx +++ b/packages/studio/src/components/renders/renderQueueTestHarness.tsx @@ -49,13 +49,39 @@ export interface MountedQueue { unmount: () => void; } +/** + * Mounts the hook, starts one render, and returns the body of the POST it + * made — the only place Studio states what to render and who to attribute it + * to, so it is what the tests around it assert on. The caller keeps the + * returned queue to unmount it. + */ +export async function startRenderAndReadBody( + useRenderQueueHook: UseRenderQueue, + { + activeCompPath = null, + opts, + }: { activeCompPath?: string | null; opts?: Parameters[0] } = {}, +): Promise<{ body: Record; queue: MountedQueue }> { + const fetchMock = stubRenderFetch(); + const queue = mountRenderQueue(useRenderQueueHook, "demo", activeCompPath); + await act(async () => { + await queue.api().startRender(opts); + }); + const [post] = renderPosts(fetchMock) as [undefined | [string, RequestInit]]; + const body = post?.[1]?.body; + if (body === undefined || body === null) throw new Error("hook made no POST with a body"); + return { body: JSON.parse(String(body)) as Record, queue }; +} + export function mountRenderQueue( useRenderQueueHook: UseRenderQueue, projectId = "demo", + activeCompPath: string | null = null, ): MountedQueue { let current: RenderQueueApi | null = null; + const activeCompPathRef = { current: activeCompPath }; function Harness(): null { - current = useRenderQueueHook(projectId); + current = useRenderQueueHook(projectId, activeCompPathRef); return null; } const host = document.createElement("div"); diff --git a/packages/studio/src/components/renders/useRenderQueue.ts b/packages/studio/src/components/renders/useRenderQueue.ts index 44a0775a89..15d1348803 100644 --- a/packages/studio/src/components/renders/useRenderQueue.ts +++ b/packages/studio/src/components/renders/useRenderQueue.ts @@ -30,7 +30,11 @@ export interface StartRenderOptions { format?: "mp4" | "webm" | "mov"; /** `"auto"` (default) renders at the composition's authored dimensions. */ resolution?: ResolutionPreset | "auto"; - /** Render a specific composition file instead of index.html. */ + /** + * Render a specific composition file. Omit it to render the composition the + * user currently has open — only the sidebar's per-composition Render button + * names one, because it renders a card the user is not looking at. + */ composition?: string; /** * Composition-variable overrides ({variableId: value}), forwarded to the @@ -66,7 +70,13 @@ function writeHiddenIds(projectId: string, ids: Set): void { } } -export function useRenderQueue(projectId: string | null) { +export function useRenderQueue( + projectId: string | null, + // A ref, not the value: the render target has to be read at click time, and + // threading the value through would rebuild every callback below on each + // composition switch. + activeCompPathRef: { current: string | null }, +) { const [jobs, setJobs] = useState([]); // History fetch failure — distinguished from "no renders yet" so the panel // never shows a false empty state. @@ -185,7 +195,13 @@ export function useRenderQueue(projectId: string | null) { const quality = opts.quality ?? "standard"; const format = opts.format ?? "mp4"; const resolution = opts.resolution; - const composition = opts.composition; + // Which composition a render targets belongs here, with the same + // argument the FFmpeg gate above makes: Studio starts renders from three + // controls, and a default living in one of them leaves the others + // exporting a file the user is not looking at. The header's Export + // passed no options at all, so every render it started went to + // index.html no matter which composition was selected (#3549). + const composition = opts.composition ?? activeCompPathRef.current ?? undefined; trackStudioRenderStart({ fps, @@ -344,7 +360,7 @@ export function useRenderQueue(projectId: string | null) { return jobId; }, - [projectId, closeActiveEventSource, addSessionJob, ffmpeg, ffmpegMissing], + [projectId, activeCompPathRef, closeActiveEventSource, addSessionJob, ffmpeg, ffmpegMissing], ); // Cancel an in-flight render. The job row stays (as "cancelled") so the diff --git a/packages/studio/src/components/renders/useRenderQueueComposition.test.tsx b/packages/studio/src/components/renders/useRenderQueueComposition.test.tsx new file mode 100644 index 0000000000..be69800941 --- /dev/null +++ b/packages/studio/src/components/renders/useRenderQueueComposition.test.tsx @@ -0,0 +1,55 @@ +// @vitest-environment happy-dom + +// The render POST is the only place Studio says WHICH file to render. When it +// says nothing the server falls back to index.html, so a caller that forgets +// the field does not fail — it silently exports the wrong video (#3549). The +// default therefore lives in startRender, which every control routes through. + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { startRenderAndReadBody, type MountedQueue } from "./renderQueueTestHarness"; + +vi.mock("../../telemetry/policy", () => ({ browserTelemetryAllowed: () => false })); +vi.mock("../../telemetry/config", () => ({ getAnonymousId: () => "unused" })); +vi.mock("../../telemetry/events", () => ({ trackStudioRenderStart: vi.fn() })); + +const { useRenderQueue } = await import("./useRenderQueue"); + +let queue: MountedQueue | null = null; + +/** Body of the render POST, started with `opts` while `activeCompPath` is open. */ +async function renderBody( + activeCompPath: string | null, + opts?: Parameters["startRender"]>[0], +): Promise> { + const started = await startRenderAndReadBody(useRenderQueue, { activeCompPath, opts }); + queue = started.queue; + return started.body; +} + +afterEach(() => { + queue?.unmount(); + queue = null; + document.body.innerHTML = ""; + vi.unstubAllGlobals(); +}); + +describe("render target composition", () => { + it("renders the composition the user has selected when the caller names none", async () => { + // The header's Export button: no options at all. + const body = await renderBody("parts/part-1.html", undefined); + expect(body["composition"]).toBe("parts/part-1.html"); + }); + + it("keeps the caller's composition when one is named", async () => { + // The sidebar's per-composition Render button renders a card the user is + // not looking at, so its argument must win over the active composition. + const body = await renderBody("parts/part-1.html", { composition: "parts/part-4.html" }); + expect(body["composition"]).toBe("parts/part-4.html"); + }); + + it("omits the composition when nothing is selected", async () => { + // Master view. The server's index.html fallback is the right answer here. + const body = await renderBody(null, { format: "mp4" }); + expect(body["composition"]).toBeUndefined(); + }); +}); diff --git a/packages/studio/src/components/renders/useRenderQueueTelemetry.test.tsx b/packages/studio/src/components/renders/useRenderQueueTelemetry.test.tsx index f303200ee9..22d445ce72 100644 --- a/packages/studio/src/components/renders/useRenderQueueTelemetry.test.tsx +++ b/packages/studio/src/components/renders/useRenderQueueTelemetry.test.tsx @@ -7,9 +7,8 @@ // install id rather than the user's, which is worse than attributing it // correctly. -import { act } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { mountRenderQueue, renderPosts, stubRenderFetch } from "./renderQueueTestHarness"; +import { startRenderAndReadBody, type MountedQueue } from "./renderQueueTestHarness"; const policyState = { allowed: true }; const mintCalls = vi.fn(() => "browser-user-123"); @@ -26,20 +25,15 @@ vi.mock("../../telemetry/events", () => ({ const { useRenderQueue } = await import("./useRenderQueue"); -let queue: ReturnType | null = null; +let queue: MountedQueue | null = null; /** Body of the POST the hook makes when a render is started. */ async function startRenderBody(): Promise> { - const fetchMock = stubRenderFetch(); - queue = mountRenderQueue(useRenderQueue); - await act(async () => { - await queue?.api().startRender({ fps: 30, quality: "standard", format: "mp4" }); + const started = await startRenderAndReadBody(useRenderQueue, { + opts: { fps: 30, quality: "standard", format: "mp4" }, }); - - const [post] = renderPosts(fetchMock) as [undefined | [string, RequestInit]]; - const body = post?.[1]?.body; - if (body === undefined || body === null) throw new Error("hook made no POST with a body"); - return JSON.parse(String(body)) as Record; + queue = started.queue; + return started.body; } beforeEach(() => {