mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
fix(studio): honor selected render resolution (#2876)
This commit is contained in:
@@ -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 { memo, useState, useRef, useEffect, useId } from "react";
|
||||||
|
import { CANVAS_DIMENSIONS } from "@hyperframes/parsers";
|
||||||
import { RenderQueueItem } from "./RenderQueueItem";
|
import { RenderQueueItem } from "./RenderQueueItem";
|
||||||
import { Button } from "../ui/Button";
|
import { Button } from "../ui/Button";
|
||||||
import type { RenderJob, ResolutionPreset } from "./useRenderQueue";
|
import type { RenderJob, ResolutionPreset } from "./useRenderQueue";
|
||||||
@@ -53,17 +54,6 @@ const SCALE_LABEL: Record<RenderScale, string> = {
|
|||||||
"4k": "4K",
|
"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";
|
type CompAspect = "landscape" | "portrait" | "square";
|
||||||
|
|
||||||
function compAspect(dims: CompositionDimensions | null | undefined): CompAspect {
|
function compAspect(dims: CompositionDimensions | null | undefined): CompAspect {
|
||||||
@@ -254,7 +244,7 @@ function FormatExportButton({
|
|||||||
const persisted = getPersistedRenderSettings();
|
const persisted = getPersistedRenderSettings();
|
||||||
const [format, setFormat] = useState<"mp4" | "webm" | "mov">(persisted.format);
|
const [format, setFormat] = useState<"mp4" | "webm" | "mov">(persisted.format);
|
||||||
const [quality, setQuality] = useState<"draft" | "standard" | "high">(persisted.quality);
|
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);
|
const [fps, setFps] = useState<24 | 30 | 60>(persisted.fps);
|
||||||
|
|
||||||
// MOV (ProRes) is a fixed-quality codec — quality selector has no effect.
|
// 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>
|
<span className="text-[10px] text-panel-text-4">Resolution</span>
|
||||||
<select
|
<select
|
||||||
value={resolution}
|
value={resolution}
|
||||||
onChange={(e) => setResolution(e.target.value as ResolutionPreset | "auto")}
|
onChange={(e) => setResolution(e.target.value as RenderScale)}
|
||||||
disabled={isRendering}
|
disabled={isRendering}
|
||||||
className={selectCls}
|
className={selectCls}
|
||||||
>
|
>
|
||||||
@@ -352,8 +342,9 @@ function FormatExportButton({
|
|||||||
// loading already disables the button; this guard also stops a
|
// loading already disables the button; this guard also stops a
|
||||||
// double-click in the same frame from enqueueing two renders.
|
// double-click in the same frame from enqueueing two renders.
|
||||||
if (isRendering) return;
|
if (isRendering) return;
|
||||||
trackStudioEvent("render_start", { format, quality, resolution, fps });
|
const outputResolution = resolveResolution(resolution, compositionDimensions);
|
||||||
void onStartRender(format, quality, resolution, fps);
|
trackStudioEvent("render_start", { format, quality, resolution: outputResolution, fps });
|
||||||
|
void onStartRender(format, quality, outputResolution, fps);
|
||||||
}}
|
}}
|
||||||
className="w-full text-[11px] font-semibold"
|
className="w-full text-[11px] font-semibold"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||||
|
import type { CanvasResolution } from "@hyperframes/parsers";
|
||||||
import { trackStudioRenderStart } from "../../telemetry/events";
|
import { trackStudioRenderStart } from "../../telemetry/events";
|
||||||
import { getAnonymousId } from "../../telemetry/config";
|
import { getAnonymousId } from "../../telemetry/config";
|
||||||
import { generateId } from "../../utils/generateId";
|
import { generateId } from "../../utils/generateId";
|
||||||
@@ -14,17 +15,10 @@ export interface RenderJob {
|
|||||||
durationMs?: number;
|
durationMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mirrors `CanvasResolution` from @hyperframes/core. Kept local because
|
// The CLI consumes this same source through @hyperframes/core's re-export.
|
||||||
// studio's tsconfig doesn't include node types, and the core barrel
|
// Importing from the browser-safe parsers package avoids the core barrel's
|
||||||
// transitively pulls in modules with `node:fs` imports. Drift risk is
|
// Node-only transitive modules without duplicating the preset union in Studio.
|
||||||
// low (6 string literals kept in sync manually with CANVAS_DIMENSIONS).
|
export type ResolutionPreset = CanvasResolution;
|
||||||
export type ResolutionPreset =
|
|
||||||
| "landscape"
|
|
||||||
| "portrait"
|
|
||||||
| "landscape-4k"
|
|
||||||
| "portrait-4k"
|
|
||||||
| "square"
|
|
||||||
| "square-4k";
|
|
||||||
|
|
||||||
export interface StartRenderOptions {
|
export interface StartRenderOptions {
|
||||||
fps?: number;
|
fps?: number;
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const launch = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("puppeteer-core", () => ({
|
||||||
|
default: { launch },
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("generateThumbnail", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
launch.mockReset();
|
||||||
|
launch.mockRejectedValue(new Error("browser launch failed"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("contains browser launch failures and retries them on the next request", async () => {
|
||||||
|
const { generateThumbnail } = await import("./vite.browser");
|
||||||
|
const options = {
|
||||||
|
project: { dir: "/tmp/hyperframes-thumbnail-test" },
|
||||||
|
compPath: "index.html",
|
||||||
|
seekTime: 0.5,
|
||||||
|
previewUrl: "http://localhost/preview",
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
format: "jpeg" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(generateThumbnail(options)).resolves.toBeNull();
|
||||||
|
await expect(generateThumbnail({ ...options, seekTime: 1 })).resolves.toBeNull();
|
||||||
|
|
||||||
|
expect(launch).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findSystemChrome", () => {
|
||||||
|
it("uses the CLI browser override before legacy producer and system paths", async () => {
|
||||||
|
const { findSystemChrome } = await import("./vite.browser");
|
||||||
|
const pathExists = vi.fn(() => true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
findSystemChrome(
|
||||||
|
{
|
||||||
|
HYPERFRAMES_BROWSER_PATH: "/custom/browser",
|
||||||
|
PRODUCER_HEADLESS_SHELL_PATH: "/legacy/browser",
|
||||||
|
},
|
||||||
|
pathExists,
|
||||||
|
),
|
||||||
|
).toBe("/custom/browser");
|
||||||
|
expect(pathExists).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,12 +20,22 @@ const CHROME_PATHS = [
|
|||||||
"/usr/bin/chromium-browser",
|
"/usr/bin/chromium-browser",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Resolve the same explicit browser overrides used by the CLI before system paths. */
|
||||||
|
export function findSystemChrome(
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
pathExists: (path: string) => boolean = existsSync,
|
||||||
|
): string | undefined {
|
||||||
|
const override = env["HYPERFRAMES_BROWSER_PATH"] ?? env["PRODUCER_HEADLESS_SHELL_PATH"];
|
||||||
|
if (override && pathExists(override)) return override;
|
||||||
|
return CHROME_PATHS.find((path) => pathExists(path));
|
||||||
|
}
|
||||||
|
|
||||||
async function getSharedBrowser(): Promise<import("puppeteer-core").Browser | null> {
|
async function getSharedBrowser(): Promise<import("puppeteer-core").Browser | null> {
|
||||||
if (_browser?.connected) return _browser;
|
if (_browser?.connected) return _browser;
|
||||||
if (_browserLaunchPromise) return _browserLaunchPromise;
|
if (_browserLaunchPromise) return _browserLaunchPromise;
|
||||||
_browserLaunchPromise = (async () => {
|
const launchPromise = (async () => {
|
||||||
const puppeteer = await import("puppeteer-core");
|
const puppeteer = await import("puppeteer-core");
|
||||||
const executablePath = CHROME_PATHS.find((p) => existsSync(p));
|
const executablePath = findSystemChrome();
|
||||||
if (!executablePath) return null;
|
if (!executablePath) return null;
|
||||||
_browser = await puppeteer.default.launch({
|
_browser = await puppeteer.default.launch({
|
||||||
headless: true,
|
headless: true,
|
||||||
@@ -40,15 +50,14 @@ async function getSharedBrowser(): Promise<import("puppeteer-core").Browser | nu
|
|||||||
"--enable-unsafe-swiftshader",
|
"--enable-unsafe-swiftshader",
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
_browserLaunchPromise = null;
|
|
||||||
return _browser;
|
return _browser;
|
||||||
})();
|
})();
|
||||||
return _browserLaunchPromise;
|
_browserLaunchPromise = launchPromise;
|
||||||
}
|
try {
|
||||||
|
return await launchPromise;
|
||||||
/** The system Chrome executable path (undefined if not found). */
|
} finally {
|
||||||
export function findSystemChrome(): string | undefined {
|
if (_browserLaunchPromise === launchPromise) _browserLaunchPromise = null;
|
||||||
return CHROME_PATHS.find((p) => existsSync(p));
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// In-flight thumbnail dedup
|
// In-flight thumbnail dedup
|
||||||
@@ -121,10 +130,10 @@ export async function generateThumbnail(opts: GenerateThumbnailOptions): Promise
|
|||||||
let bufferPromise = _thumbnailInflight.get(cacheKey);
|
let bufferPromise = _thumbnailInflight.get(cacheKey);
|
||||||
if (!bufferPromise) {
|
if (!bufferPromise) {
|
||||||
bufferPromise = (async () => {
|
bufferPromise = (async () => {
|
||||||
const browser = await getSharedBrowser();
|
let page: Awaited<ReturnType<import("puppeteer-core").Browser["newPage"]>> | null = null;
|
||||||
if (!browser) return null;
|
|
||||||
let page: Awaited<ReturnType<typeof browser.newPage>> | null = null;
|
|
||||||
try {
|
try {
|
||||||
|
const browser = await getSharedBrowser();
|
||||||
|
if (!browser) return null;
|
||||||
page = await browser.newPage();
|
page = await browser.newPage();
|
||||||
await page.setViewport({
|
await page.setViewport({
|
||||||
width: opts.width,
|
width: opts.width,
|
||||||
@@ -196,7 +205,8 @@ export async function generateThumbnail(opts: GenerateThumbnailOptions): Promise
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
_thumbnailInflight.set(cacheKey, bufferPromise);
|
_thumbnailInflight.set(cacheKey, bufferPromise);
|
||||||
bufferPromise.finally(() => _thumbnailInflight.delete(cacheKey));
|
const clearInflight = () => _thumbnailInflight.delete(cacheKey);
|
||||||
|
bufferPromise.then(clearInflight, clearInflight);
|
||||||
}
|
}
|
||||||
return bufferPromise;
|
return bufferPromise;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user