fix(studio): honor selected render resolution (#2876)

This commit is contained in:
Miguel Ángel
2026-07-29 13:20:47 +02:00
committed by GitHub
parent 0d42d65525
commit f1655b9302
5 changed files with 146 additions and 39 deletions
@@ -0,0 +1,62 @@
// @vitest-environment happy-dom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { RenderQueue } from "./RenderQueue";
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
let root: Root | null = null;
afterEach(() => {
if (root) act(() => root?.unmount());
root = null;
document.body.innerHTML = "";
});
function mountRenderQueue(onStartRender: ReturnType<typeof vi.fn>) {
const host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
act(() => {
root?.render(
<RenderQueue
jobs={[]}
projectId="demo"
onDelete={vi.fn()}
onClearCompleted={vi.fn()}
onStartRender={onStartRender}
isRendering={false}
compositionDimensions={{ width: 1920, height: 1080 }}
/>,
);
});
return host;
}
describe("RenderQueue resolution submission", () => {
it("submits the canonical landscape 4K preset selected by the user", () => {
const onStartRender = vi.fn();
const host = mountRenderQueue(onStartRender);
const resolutionSelect = [...host.querySelectorAll("select")].find((select) =>
[...select.options].some((option) => option.textContent?.startsWith("4K")),
);
if (!resolutionSelect) throw new Error("resolution selector did not render");
act(() => {
resolutionSelect.value = "4k";
resolutionSelect.dispatchEvent(new Event("change", { bubbles: true }));
});
const exportButton = [...host.querySelectorAll("button")].find(
(button) => button.textContent === "Export",
);
if (!exportButton) throw new Error("export button did not render");
act(() => {
exportButton.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onStartRender).toHaveBeenCalledWith("mp4", "standard", "landscape-4k", 30);
});
});
@@ -1,4 +1,5 @@
import { memo, useState, useRef, useEffect, useId } from "react";
import { CANVAS_DIMENSIONS } from "@hyperframes/parsers";
import { RenderQueueItem } from "./RenderQueueItem";
import { Button } from "../ui/Button";
import type { RenderJob, ResolutionPreset } from "./useRenderQueue";
@@ -53,17 +54,6 @@ const SCALE_LABEL: Record<RenderScale, string> = {
"4k": "4K",
};
// Mirrors `CANVAS_DIMENSIONS` in @hyperframes/core. Studio can't import from
// the core barrel (it transitively pulls in node:fs) and the values are stable.
const CANVAS_DIMENSIONS: Record<ResolutionPreset, CompositionDimensions> = {
landscape: { width: 1920, height: 1080 },
portrait: { width: 1080, height: 1920 },
"landscape-4k": { width: 3840, height: 2160 },
"portrait-4k": { width: 2160, height: 3840 },
square: { width: 1080, height: 1080 },
"square-4k": { width: 2160, height: 2160 },
};
type CompAspect = "landscape" | "portrait" | "square";
function compAspect(dims: CompositionDimensions | null | undefined): CompAspect {
@@ -254,7 +244,7 @@ function FormatExportButton({
const persisted = getPersistedRenderSettings();
const [format, setFormat] = useState<"mp4" | "webm" | "mov">(persisted.format);
const [quality, setQuality] = useState<"draft" | "standard" | "high">(persisted.quality);
const [resolution, setResolution] = useState<ResolutionPreset | "auto">("auto");
const [resolution, setResolution] = useState<RenderScale>("auto");
const [fps, setFps] = useState<24 | 30 | 60>(persisted.fps);
// MOV (ProRes) is a fixed-quality codec — quality selector has no effect.
@@ -290,7 +280,7 @@ function FormatExportButton({
<span className="text-[10px] text-panel-text-4">Resolution</span>
<select
value={resolution}
onChange={(e) => setResolution(e.target.value as ResolutionPreset | "auto")}
onChange={(e) => setResolution(e.target.value as RenderScale)}
disabled={isRendering}
className={selectCls}
>
@@ -352,8 +342,9 @@ function FormatExportButton({
// loading already disables the button; this guard also stops a
// double-click in the same frame from enqueueing two renders.
if (isRendering) return;
trackStudioEvent("render_start", { format, quality, resolution, fps });
void onStartRender(format, quality, resolution, fps);
const outputResolution = resolveResolution(resolution, compositionDimensions);
trackStudioEvent("render_start", { format, quality, resolution: outputResolution, fps });
void onStartRender(format, quality, outputResolution, fps);
}}
className="w-full text-[11px] font-semibold"
>
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import type { CanvasResolution } from "@hyperframes/parsers";
import { trackStudioRenderStart } from "../../telemetry/events";
import { getAnonymousId } from "../../telemetry/config";
import { generateId } from "../../utils/generateId";
@@ -14,17 +15,10 @@ export interface RenderJob {
durationMs?: number;
}
// Mirrors `CanvasResolution` from @hyperframes/core. Kept local because
// studio's tsconfig doesn't include node types, and the core barrel
// transitively pulls in modules with `node:fs` imports. Drift risk is
// low (6 string literals kept in sync manually with CANVAS_DIMENSIONS).
export type ResolutionPreset =
| "landscape"
| "portrait"
| "landscape-4k"
| "portrait-4k"
| "square"
| "square-4k";
// The CLI consumes this same source through @hyperframes/core's re-export.
// Importing from the browser-safe parsers package avoids the core barrel's
// Node-only transitive modules without duplicating the preset union in Studio.
export type ResolutionPreset = CanvasResolution;
export interface StartRenderOptions {
fps?: number;