mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-14 21:29:34 +00:00
Merge pull request #715 from heygen-com/studio/render-resolution-auto-orientation
feat(studio): collapse render resolution dropdown to Auto/1080p/4K + add square preset
This commit is contained in:
@@ -630,7 +630,7 @@ export default defineCommand({
|
||||
resolution: {
|
||||
type: "string",
|
||||
description:
|
||||
"Canvas resolution preset: landscape (1920x1080), portrait (1080x1920), landscape-4k (3840x2160), portrait-4k (2160x3840). Aliases: 1080p, 4k, uhd. Default: keep template dimensions (typically 1920x1080).",
|
||||
"Canvas resolution preset: landscape (1920x1080), portrait (1080x1920), landscape-4k (3840x2160), portrait-4k (2160x3840), square (1080x1080), square-4k (2160x2160). Aliases: 1080p, 4k, uhd. Default: keep template dimensions (typically 1920x1080).",
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
@@ -670,7 +670,7 @@ export default defineCommand({
|
||||
console.error(
|
||||
c.error(
|
||||
`Invalid --resolution: "${args.resolution}". ` +
|
||||
`Use one of: landscape, portrait, landscape-4k, portrait-4k (or aliases 1080p, 4k, uhd).`,
|
||||
`Use one of: landscape, portrait, landscape-4k, portrait-4k, square, square-4k (or aliases 1080p, 4k, uhd).`,
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
|
||||
@@ -220,7 +220,7 @@ export default defineCommand({
|
||||
resolution: {
|
||||
type: "string",
|
||||
description:
|
||||
"Output resolution preset: landscape (1920x1080), portrait (1080x1920), landscape-4k (3840x2160), portrait-4k (2160x3840). Aliases: 1080p, 4k, uhd. The composition is unchanged — Chrome renders at higher DPR (deviceScaleFactor) so the captured screenshot lands at the requested dimensions. Aspect ratio must match the composition; the scale must be an integer multiple. Not yet supported with --hdr.",
|
||||
"Output resolution preset: landscape (1920x1080), portrait (1080x1920), landscape-4k (3840x2160), portrait-4k (2160x3840), square (1080x1080), square-4k (2160x2160). Aliases: 1080p, 4k, uhd. The composition is unchanged — Chrome renders at higher DPR (deviceScaleFactor) so the captured screenshot lands at the requested dimensions. Aspect ratio must match the composition; the scale must be an integer multiple. Not yet supported with --hdr.",
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
@@ -263,7 +263,7 @@ export default defineCommand({
|
||||
if (!outputResolution) {
|
||||
errorBox(
|
||||
"Invalid resolution",
|
||||
`Got "${args.resolution}". Must be one of: landscape, portrait, landscape-4k, portrait-4k (or aliases 1080p, 4k, uhd).`,
|
||||
`Got "${args.resolution}". Must be one of: landscape, portrait, landscape-4k, portrait-4k, square, square-4k (or aliases 1080p, 4k, uhd).`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -149,6 +149,8 @@ export const CANVAS_DIMENSIONS = {
|
||||
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 },
|
||||
} as const;
|
||||
|
||||
// Single source of truth: derive the type from the table so adding a preset
|
||||
@@ -172,6 +174,9 @@ const RESOLUTION_ALIASES: Record<string, CanvasResolution> = {
|
||||
"4k": "landscape-4k",
|
||||
uhd: "landscape-4k",
|
||||
"4k-portrait": "portrait-4k",
|
||||
"1080p-square": "square",
|
||||
"square-1080p": "square",
|
||||
"4k-square": "square-4k",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,6 +10,8 @@ describe("@hyperframes/core public API exports", () => {
|
||||
expect(core.CANVAS_DIMENSIONS.portrait).toEqual({ width: 1080, height: 1920 });
|
||||
expect(core.CANVAS_DIMENSIONS["landscape-4k"]).toEqual({ width: 3840, height: 2160 });
|
||||
expect(core.CANVAS_DIMENSIONS["portrait-4k"]).toEqual({ width: 2160, height: 3840 });
|
||||
expect(core.CANVAS_DIMENSIONS.square).toEqual({ width: 1080, height: 1080 });
|
||||
expect(core.CANVAS_DIMENSIONS["square-4k"]).toEqual({ width: 2160, height: 2160 });
|
||||
});
|
||||
|
||||
it("exports VALID_CANVAS_RESOLUTIONS derived from CANVAS_DIMENSIONS", () => {
|
||||
@@ -18,6 +20,8 @@ describe("@hyperframes/core public API exports", () => {
|
||||
"portrait",
|
||||
"landscape-4k",
|
||||
"portrait-4k",
|
||||
"square",
|
||||
"square-4k",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -27,6 +31,10 @@ describe("@hyperframes/core public API exports", () => {
|
||||
expect(core.normalizeResolutionFlag("1080p")).toBe("landscape");
|
||||
expect(core.normalizeResolutionFlag("landscape-4k")).toBe("landscape-4k");
|
||||
expect(core.normalizeResolutionFlag("UHD")).toBe("landscape-4k");
|
||||
expect(core.normalizeResolutionFlag("square")).toBe("square");
|
||||
expect(core.normalizeResolutionFlag("square-4k")).toBe("square-4k");
|
||||
expect(core.normalizeResolutionFlag("1080p-square")).toBe("square");
|
||||
expect(core.normalizeResolutionFlag("4k-square")).toBe("square-4k");
|
||||
expect(core.normalizeResolutionFlag("8k")).toBeUndefined();
|
||||
expect(core.normalizeResolutionFlag(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -275,10 +275,7 @@ describe("parseHtml", () => {
|
||||
expect(result.resolution).toBe("landscape");
|
||||
});
|
||||
|
||||
it("classifies square compositions as portrait by convention", () => {
|
||||
// 1080×1080 has no obvious orientation. The parser collapses the tie to
|
||||
// portrait — same bias the prior `w > h ? landscape : portrait` ternary
|
||||
// had. Pinning so a future refactor doesn't silently flip it.
|
||||
it("infers square resolution from equal width/height", () => {
|
||||
const html = `
|
||||
<html data-composition-width="1080" data-composition-height="1080">
|
||||
<body>
|
||||
@@ -290,7 +287,22 @@ describe("parseHtml", () => {
|
||||
`;
|
||||
const result = parseHtml(html);
|
||||
|
||||
expect(result.resolution).toBe("portrait");
|
||||
expect(result.resolution).toBe("square");
|
||||
});
|
||||
|
||||
it("infers square-4k from equal width/height ≥ 2160", () => {
|
||||
const html = `
|
||||
<html data-composition-width="2160" data-composition-height="2160">
|
||||
<body>
|
||||
<div id="stage">
|
||||
<div id="text1" data-start="0" data-end="5"><div>Hello</div></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const result = parseHtml(html);
|
||||
|
||||
expect(result.resolution).toBe("square-4k");
|
||||
});
|
||||
|
||||
it("extracts x, y, scale, opacity from data attributes", () => {
|
||||
|
||||
@@ -124,7 +124,9 @@ function parseResolutionFromHtml(doc: Document): CanvasResolution | null {
|
||||
resolutionAttr === "landscape" ||
|
||||
resolutionAttr === "portrait" ||
|
||||
resolutionAttr === "landscape-4k" ||
|
||||
resolutionAttr === "portrait-4k"
|
||||
resolutionAttr === "portrait-4k" ||
|
||||
resolutionAttr === "square" ||
|
||||
resolutionAttr === "square-4k"
|
||||
) {
|
||||
return resolutionAttr;
|
||||
}
|
||||
@@ -143,17 +145,17 @@ function parseResolutionFromHtml(doc: Document): CanvasResolution | null {
|
||||
}
|
||||
|
||||
function resolveResolutionFromDimensions(width: number, height: number): CanvasResolution {
|
||||
// `width === height` (square) falls into the portrait branch by convention —
|
||||
// the same bias the previous `w > h ? landscape : portrait` ternary used.
|
||||
// Square compositions are rare; pick portrait-as-default so we don't surprise
|
||||
// the existing call sites that depend on this behavior.
|
||||
const isLandscape = width > height;
|
||||
const longSide = Math.max(width, height);
|
||||
// UHD cutoff is the long side of `landscape-4k` / `portrait-4k` (3840). A
|
||||
// looser threshold (e.g. ≥ 2560) would silently misclassify QHD/1440p
|
||||
// (2560×1440) as 4K, which is the wrong default for a common authoring
|
||||
// resolution closer to 1080p than to UHD. Authors who genuinely want the
|
||||
// 4K preset can still set `data-resolution="landscape-4k"` explicitly.
|
||||
// UHD cutoff is the long side of the 4K presets (3840 for `landscape-4k` /
|
||||
// `portrait-4k`, 2160 for `square-4k`). A looser threshold (e.g. ≥ 2560)
|
||||
// would silently misclassify QHD/1440p (2560×1440) as 4K, which is the
|
||||
// wrong default for a common authoring resolution closer to 1080p than to
|
||||
// UHD. Authors who genuinely want the 4K preset can still set
|
||||
// `data-resolution="..."` explicitly.
|
||||
if (width === height) {
|
||||
return longSide >= 2160 ? "square-4k" : "square";
|
||||
}
|
||||
const isLandscape = width > height;
|
||||
const isUhd = longSide >= 3840;
|
||||
if (isLandscape) return isUhd ? "landscape-4k" : "landscape";
|
||||
return isUhd ? "portrait-4k" : "portrait";
|
||||
|
||||
@@ -839,4 +839,37 @@ describe("resolveDeviceScaleFactor", () => {
|
||||
}),
|
||||
).toThrow(/aspect ratio|non-integer/);
|
||||
});
|
||||
|
||||
it("returns 1 for a square comp matching the square preset", () => {
|
||||
expect(
|
||||
resolveDeviceScaleFactor({
|
||||
...defaults,
|
||||
compositionWidth: 1080,
|
||||
compositionHeight: 1080,
|
||||
outputResolution: "square",
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("returns 2 for square 1080 → square-4k", () => {
|
||||
expect(
|
||||
resolveDeviceScaleFactor({
|
||||
...defaults,
|
||||
compositionWidth: 1080,
|
||||
compositionHeight: 1080,
|
||||
outputResolution: "square-4k",
|
||||
}),
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it("rejects landscape preset on a square composition", () => {
|
||||
expect(() =>
|
||||
resolveDeviceScaleFactor({
|
||||
...defaults,
|
||||
compositionWidth: 1080,
|
||||
compositionHeight: 1080,
|
||||
outputResolution: "landscape",
|
||||
}),
|
||||
).toThrow(/aspect ratio/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useMountEffect } from "./hooks/useMountEffect";
|
||||
import { NLELayout } from "./components/nle/NLELayout";
|
||||
import { SourceEditor } from "./components/editor/SourceEditor";
|
||||
import { LeftSidebar } from "./components/sidebar/LeftSidebar";
|
||||
import { RenderQueue } from "./components/renders/RenderQueue";
|
||||
import { RenderQueue, type CompositionDimensions } from "./components/renders/RenderQueue";
|
||||
import { useRenderQueue } from "./components/renders/useRenderQueue";
|
||||
import { CompositionThumbnail, VideoThumbnail, liveTime, usePlayerStore } from "./player";
|
||||
import { AudioWaveform } from "./player/components/AudioWaveform";
|
||||
@@ -276,6 +276,28 @@ export function StudioApp() {
|
||||
setRightCollapsed(!captionHasSelection);
|
||||
}
|
||||
}, [captionHasSelection, captionEditMode]);
|
||||
|
||||
// Track the active composition's authored dimensions so the render
|
||||
// dropdown can derive landscape vs portrait. The runtime emits
|
||||
// `stage-size` after `applyCompositionSizing` resolves the authoritative
|
||||
// dims, so we use that instead of re-parsing the iframe DOM.
|
||||
const [compositionDimensions, setCompositionDimensions] = useState<CompositionDimensions | null>(
|
||||
null,
|
||||
);
|
||||
useMountEffect(() => {
|
||||
const handleMessage = (e: MessageEvent) => {
|
||||
const data = e.data;
|
||||
if (data?.source !== "hf-preview" || data?.type !== "stage-size") return;
|
||||
const { width, height } = data as { width: number; height: number };
|
||||
if (!(width > 0) || !(height > 0)) return;
|
||||
setCompositionDimensions((prev) =>
|
||||
prev && prev.width === width && prev.height === height ? prev : { width, height },
|
||||
);
|
||||
};
|
||||
window.addEventListener("message", handleMessage);
|
||||
return () => window.removeEventListener("message", handleMessage);
|
||||
});
|
||||
|
||||
const [globalDragOver, setGlobalDragOver] = useState(false);
|
||||
const [appToast, setAppToast] = useState<AppToast | null>(null);
|
||||
const [timelineVisible, setTimelineVisible] = useState(true);
|
||||
@@ -1687,6 +1709,7 @@ export function StudioApp() {
|
||||
onStartRender={(format, quality, resolution) =>
|
||||
renderQueue.startRender({ format, quality, resolution })
|
||||
}
|
||||
compositionDimensions={compositionDimensions}
|
||||
isRendering={renderQueue.isRendering}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,11 @@ import { memo, useState, useRef, useEffect } from "react";
|
||||
import { RenderQueueItem } from "./RenderQueueItem";
|
||||
import type { RenderJob, ResolutionPreset } from "./useRenderQueue";
|
||||
|
||||
export interface CompositionDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface RenderQueueProps {
|
||||
jobs: RenderJob[];
|
||||
projectId: string;
|
||||
@@ -13,37 +18,97 @@ interface RenderQueueProps {
|
||||
resolution: ResolutionPreset | "auto",
|
||||
) => void;
|
||||
isRendering: boolean;
|
||||
/**
|
||||
* Authored dimensions of the active composition. Used to pick the
|
||||
* matching preset (landscape / portrait / square) when the user selects
|
||||
* a 1080p or 4K scale. `null` falls back to landscape (legacy default).
|
||||
*/
|
||||
compositionDimensions?: CompositionDimensions | null;
|
||||
}
|
||||
|
||||
// Indexing the table by `ResolutionPreset | "auto"` makes adding a new preset
|
||||
// to `core.types` (e.g. an 8K row) a TypeScript error here instead of a
|
||||
// silently missing dropdown entry. Order is fixed by the array below.
|
||||
const RESOLUTION_LABELS: Record<ResolutionPreset | "auto", { label: string; title: string }> = {
|
||||
auto: { label: "Auto", title: "Render at the composition's authored resolution" },
|
||||
landscape: { label: "1080p ↔", title: "1920×1080 landscape" },
|
||||
portrait: { label: "1080p ↕", title: "1080×1920 portrait" },
|
||||
"landscape-4k": {
|
||||
label: "4K ↔",
|
||||
title: "3840×2160 — supersamples a 1080p composition via Chrome DPR. Slower, larger files.",
|
||||
},
|
||||
"portrait-4k": {
|
||||
label: "4K ↕",
|
||||
title: "2160×3840 — supersamples a 1080p portrait composition via Chrome DPR.",
|
||||
},
|
||||
// Orientation is derived from the composition's authored aspect ratio,
|
||||
// not chosen by the user — picking "1080p portrait" for a landscape comp
|
||||
// would just produce a wrong-aspect render.
|
||||
type RenderScale = "auto" | "1080p" | "4k";
|
||||
|
||||
const SCALE_OPTION_ORDER: RenderScale[] = ["auto", "1080p", "4k"];
|
||||
|
||||
const SCALE_LABEL: Record<RenderScale, string> = {
|
||||
auto: "Auto",
|
||||
"1080p": "1080p",
|
||||
"4k": "4K",
|
||||
};
|
||||
|
||||
const RESOLUTION_OPTION_ORDER: (ResolutionPreset | "auto")[] = [
|
||||
"auto",
|
||||
"landscape",
|
||||
"portrait",
|
||||
"landscape-4k",
|
||||
"portrait-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 },
|
||||
};
|
||||
|
||||
const RESOLUTION_OPTIONS = RESOLUTION_OPTION_ORDER.map((value) => ({
|
||||
value,
|
||||
...RESOLUTION_LABELS[value],
|
||||
}));
|
||||
type CompAspect = "landscape" | "portrait" | "square";
|
||||
|
||||
function compAspect(dims: CompositionDimensions | null | undefined): CompAspect {
|
||||
// Missing dims fall through to landscape (legacy default — "landscape" was
|
||||
// the first preset). Studio shows resolved dims inline, so the user can see
|
||||
// when this fallback is in effect.
|
||||
if (dims == null) return "landscape";
|
||||
if (dims.width === dims.height) return "square";
|
||||
return dims.height > dims.width ? "portrait" : "landscape";
|
||||
}
|
||||
|
||||
function resolveResolution(
|
||||
scale: RenderScale,
|
||||
dims: CompositionDimensions | null | undefined,
|
||||
): ResolutionPreset | "auto" {
|
||||
if (scale === "auto") return "auto";
|
||||
const aspect = compAspect(dims);
|
||||
if (scale === "1080p") return aspect;
|
||||
return aspect === "landscape"
|
||||
? "landscape-4k"
|
||||
: aspect === "portrait"
|
||||
? "portrait-4k"
|
||||
: "square-4k";
|
||||
}
|
||||
|
||||
function resolvedDimensions(
|
||||
scale: RenderScale,
|
||||
dims: CompositionDimensions | null | undefined,
|
||||
): CompositionDimensions | null {
|
||||
if (scale === "auto") return dims ?? null;
|
||||
const preset = resolveResolution(scale, dims);
|
||||
return preset === "auto" ? null : CANVAS_DIMENSIONS[preset];
|
||||
}
|
||||
|
||||
// Mirrors the producer's resolveDeviceScaleFactor validation
|
||||
// (renderOrchestrator.ts:608): the chosen preset must match the comp's aspect
|
||||
// ratio exactly (cross-multiplied), can't downsample, and must be an integer
|
||||
// scale factor. Without this guard the user can pick a preset that throws at
|
||||
// render time — e.g. 1080p on a 1080×1080 square or 1080p on a 1280×720 comp
|
||||
// (1.5× isn't integer).
|
||||
function scaleApplies(scale: RenderScale, dims: CompositionDimensions | null | undefined): boolean {
|
||||
if (scale === "auto" || dims == null) return true;
|
||||
const preset = resolveResolution(scale, dims);
|
||||
if (preset === "auto") return true;
|
||||
const target = CANVAS_DIMENSIONS[preset];
|
||||
if (target.width * dims.height !== target.height * dims.width) return false;
|
||||
if (target.width < dims.width) return false;
|
||||
return Number.isInteger(target.width / dims.width);
|
||||
}
|
||||
|
||||
function scaleOptionLabel(
|
||||
scale: RenderScale,
|
||||
dims: CompositionDimensions | null | undefined,
|
||||
): string {
|
||||
const resolved = resolvedDimensions(scale, dims);
|
||||
return resolved
|
||||
? `${SCALE_LABEL[scale]} · ${resolved.width}×${resolved.height}`
|
||||
: SCALE_LABEL[scale];
|
||||
}
|
||||
|
||||
const FORMAT_INFO: Record<"mp4" | "webm" | "mov", { label: string; desc: string }> = {
|
||||
mp4: { label: "MP4", desc: "Best for general use. Smallest file, universal playback." },
|
||||
@@ -124,6 +189,7 @@ const QUALITY_OPTIONS: {
|
||||
function FormatExportButton({
|
||||
onStartRender,
|
||||
isRendering,
|
||||
compositionDimensions,
|
||||
}: {
|
||||
onStartRender: (
|
||||
format: "mp4" | "webm" | "mov",
|
||||
@@ -131,10 +197,16 @@ function FormatExportButton({
|
||||
resolution: ResolutionPreset | "auto",
|
||||
) => void;
|
||||
isRendering: boolean;
|
||||
compositionDimensions?: CompositionDimensions | null;
|
||||
}) {
|
||||
const [format, setFormat] = useState<"mp4" | "webm" | "mov">("mp4");
|
||||
const [quality, setQuality] = useState<"draft" | "standard" | "high">("standard");
|
||||
const [resolution, setResolution] = useState<ResolutionPreset | "auto">("auto");
|
||||
const [scale, setScale] = useState<RenderScale>("auto");
|
||||
|
||||
// If the user previously picked 1080p / 4K for a 16:9 comp and then switches
|
||||
// to a square (or any non-matching) comp, fall back to "auto" without
|
||||
// discarding their preference — switching back to 16:9 re-applies it.
|
||||
const effectiveScale: RenderScale = scaleApplies(scale, compositionDimensions) ? scale : "auto";
|
||||
|
||||
// MOV (ProRes) is a fixed-quality codec — quality selector has no effect.
|
||||
const showQuality = format !== "mov";
|
||||
@@ -147,15 +219,14 @@ function FormatExportButton({
|
||||
(feature-flag, etc.), move `rounded-l` to whichever element ends up
|
||||
leftmost. */}
|
||||
<select
|
||||
value={resolution}
|
||||
onChange={(e) => setResolution(e.target.value as ResolutionPreset | "auto")}
|
||||
value={effectiveScale}
|
||||
onChange={(e) => setScale(e.target.value as RenderScale)}
|
||||
disabled={isRendering}
|
||||
title={RESOLUTION_OPTIONS.find((r) => r.value === resolution)?.title}
|
||||
className="h-5 px-1 text-[10px] rounded-l bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
|
||||
>
|
||||
{RESOLUTION_OPTIONS.map((r) => (
|
||||
<option key={r.value} value={r.value} title={r.title}>
|
||||
{r.label}
|
||||
{SCALE_OPTION_ORDER.map((value) => (
|
||||
<option key={value} value={value} disabled={!scaleApplies(value, compositionDimensions)}>
|
||||
{scaleOptionLabel(value, compositionDimensions)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -185,7 +256,9 @@ function FormatExportButton({
|
||||
<option value="webm">WebM</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => onStartRender(format, quality, resolution)}
|
||||
onClick={() =>
|
||||
onStartRender(format, quality, resolveResolution(effectiveScale, compositionDimensions))
|
||||
}
|
||||
disabled={isRendering}
|
||||
className="flex items-center gap-1 px-2 py-0.5 text-[10px] font-semibold rounded-r bg-studio-accent text-[#09090B] hover:brightness-110 transition-colors disabled:opacity-50"
|
||||
>
|
||||
@@ -202,6 +275,7 @@ export const RenderQueue = memo(function RenderQueue({
|
||||
onClearCompleted,
|
||||
onStartRender,
|
||||
isRendering,
|
||||
compositionDimensions,
|
||||
}: RenderQueueProps) {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -228,7 +302,11 @@ export const RenderQueue = memo(function RenderQueue({
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
<FormatExportButton onStartRender={onStartRender} isRendering={isRendering} />
|
||||
<FormatExportButton
|
||||
onStartRender={onStartRender}
|
||||
isRendering={isRendering}
|
||||
compositionDimensions={compositionDimensions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -14,8 +14,14 @@ export interface RenderJob {
|
||||
// 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 (4 string literals tied to a stable enum).
|
||||
export type ResolutionPreset = "landscape" | "portrait" | "landscape-4k" | "portrait-4k";
|
||||
// low (string literals tied to a stable enum).
|
||||
export type ResolutionPreset =
|
||||
| "landscape"
|
||||
| "portrait"
|
||||
| "landscape-4k"
|
||||
| "portrait-4k"
|
||||
| "square"
|
||||
| "square-4k";
|
||||
|
||||
export interface StartRenderOptions {
|
||||
fps?: number;
|
||||
|
||||
@@ -28,20 +28,37 @@ async function getSharedBrowser(): Promise<import("puppeteer-core").Browser | nu
|
||||
if (_browser?.connected) return _browser;
|
||||
if (_browserLaunchPromise) return _browserLaunchPromise;
|
||||
_browserLaunchPromise = (async () => {
|
||||
const puppeteer = await import("puppeteer-core");
|
||||
const executablePath = [
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/usr/bin/google-chrome",
|
||||
"/usr/bin/chromium-browser",
|
||||
].find((p) => existsSync(p));
|
||||
if (!executablePath) return null;
|
||||
_browser = await puppeteer.default.launch({
|
||||
headless: true,
|
||||
executablePath,
|
||||
args: ["--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"],
|
||||
});
|
||||
_browserLaunchPromise = null;
|
||||
return _browser;
|
||||
try {
|
||||
const puppeteer = await import("puppeteer-core");
|
||||
const executablePath = [
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/usr/bin/google-chrome",
|
||||
"/usr/bin/chromium-browser",
|
||||
].find((p) => existsSync(p));
|
||||
if (!executablePath) return null;
|
||||
_browser = await puppeteer.default.launch({
|
||||
headless: true,
|
||||
executablePath,
|
||||
// 10s is enough for any healthy local launch; the default 30s lets a
|
||||
// wedged handshake stall every pending thumbnail before failing.
|
||||
timeout: 10_000,
|
||||
args: ["--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"],
|
||||
});
|
||||
return _browser;
|
||||
} catch (err) {
|
||||
// Without this guard, a launch failure (timeout, missing libs, etc.)
|
||||
// surfaces as an unhandled rejection through puppeteer's internal RxJS
|
||||
// chain and crashes the Vite dev server. Log + degrade gracefully —
|
||||
// the thumbnail route returns a 500 and the rest of the studio keeps
|
||||
// working.
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.warn(`[Studio] puppeteer launch failed — thumbnails disabled: ${msg}`);
|
||||
return null;
|
||||
} finally {
|
||||
// Reset on every outcome so a transient failure doesn't poison the
|
||||
// singleton: subsequent thumbnail requests can retry the launch.
|
||||
_browserLaunchPromise = null;
|
||||
}
|
||||
})();
|
||||
return _browserLaunchPromise;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user