mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(studio): add resolution selector to render export bar
This commit is contained in:
@@ -1684,7 +1684,9 @@ export function StudioApp() {
|
||||
projectId={projectId}
|
||||
onDelete={renderQueue.deleteRender}
|
||||
onClearCompleted={renderQueue.clearCompleted}
|
||||
onStartRender={(format, quality) => renderQueue.startRender(30, quality, format)}
|
||||
onStartRender={(format, quality, resolution) =>
|
||||
renderQueue.startRender(30, quality, format, resolution)
|
||||
}
|
||||
isRendering={renderQueue.isRendering}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,16 +1,36 @@
|
||||
import { memo, useState, useRef, useEffect } from "react";
|
||||
import { RenderQueueItem } from "./RenderQueueItem";
|
||||
import type { RenderJob } from "./useRenderQueue";
|
||||
import type { RenderJob, ResolutionPreset } from "./useRenderQueue";
|
||||
|
||||
interface RenderQueueProps {
|
||||
jobs: RenderJob[];
|
||||
projectId: string;
|
||||
onDelete: (jobId: string) => void;
|
||||
onClearCompleted: () => void;
|
||||
onStartRender: (format: "mp4" | "webm" | "mov", quality: "draft" | "standard" | "high") => void;
|
||||
onStartRender: (
|
||||
format: "mp4" | "webm" | "mov",
|
||||
quality: "draft" | "standard" | "high",
|
||||
resolution: ResolutionPreset | "auto",
|
||||
) => void;
|
||||
isRendering: boolean;
|
||||
}
|
||||
|
||||
const RESOLUTION_OPTIONS: { value: ResolutionPreset | "auto"; label: string; title: string }[] = [
|
||||
{ value: "auto", label: "Auto", title: "Render at the composition's authored resolution" },
|
||||
{ value: "landscape", label: "1080p", title: "1920×1080 landscape" },
|
||||
{ value: "portrait", label: "1080p ↕", title: "1080×1920 portrait" },
|
||||
{
|
||||
value: "landscape-4k",
|
||||
label: "4K",
|
||||
title: "3840×2160 — supersamples a 1080p composition via Chrome DPR. Slower, larger files.",
|
||||
},
|
||||
{
|
||||
value: "portrait-4k",
|
||||
label: "4K ↕",
|
||||
title: "2160×3840 — supersamples a 1080p portrait composition via Chrome DPR.",
|
||||
},
|
||||
];
|
||||
|
||||
const FORMAT_INFO: Record<"mp4" | "webm" | "mov", { label: string; desc: string }> = {
|
||||
mp4: { label: "MP4", desc: "Best for general use. Smallest file, universal playback." },
|
||||
mov: {
|
||||
@@ -91,11 +111,16 @@ function FormatExportButton({
|
||||
onStartRender,
|
||||
isRendering,
|
||||
}: {
|
||||
onStartRender: (format: "mp4" | "webm" | "mov", quality: "draft" | "standard" | "high") => void;
|
||||
onStartRender: (
|
||||
format: "mp4" | "webm" | "mov",
|
||||
quality: "draft" | "standard" | "high",
|
||||
resolution: ResolutionPreset | "auto",
|
||||
) => void;
|
||||
isRendering: boolean;
|
||||
}) {
|
||||
const [format, setFormat] = useState<"mp4" | "webm" | "mov">("mp4");
|
||||
const [quality, setQuality] = useState<"draft" | "standard" | "high">("standard");
|
||||
const [resolution, setResolution] = useState<ResolutionPreset | "auto">("auto");
|
||||
|
||||
// MOV (ProRes) is a fixed-quality codec — quality selector has no effect.
|
||||
const showQuality = format !== "mov";
|
||||
@@ -103,13 +128,26 @@ function FormatExportButton({
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<FormatInfoTooltip format={format} />
|
||||
<select
|
||||
value={resolution}
|
||||
onChange={(e) => setResolution(e.target.value as ResolutionPreset | "auto")}
|
||||
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}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{showQuality && (
|
||||
<select
|
||||
value={quality}
|
||||
onChange={(e) => setQuality(e.target.value as "draft" | "standard" | "high")}
|
||||
disabled={isRendering}
|
||||
title={QUALITY_OPTIONS.find((q) => q.value === quality)?.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"
|
||||
className="h-5 px-1 text-[10px] bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
|
||||
>
|
||||
{QUALITY_OPTIONS.map((q) => (
|
||||
<option key={q.value} value={q.value} title={q.title}>
|
||||
@@ -122,14 +160,14 @@ function FormatExportButton({
|
||||
value={format}
|
||||
onChange={(e) => setFormat(e.target.value as "mp4" | "webm" | "mov")}
|
||||
disabled={isRendering}
|
||||
className={`h-5 px-1 text-[10px] bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50 ${showQuality ? "" : "rounded-l"}`}
|
||||
className="h-5 px-1 text-[10px] bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
|
||||
>
|
||||
<option value="mp4">MP4</option>
|
||||
<option value="mov">MOV</option>
|
||||
<option value="webm">WebM</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => onStartRender(format, quality)}
|
||||
onClick={() => onStartRender(format, quality, resolution)}
|
||||
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"
|
||||
>
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface RenderJob {
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
export type ResolutionPreset = "landscape" | "portrait" | "landscape-4k" | "portrait-4k";
|
||||
|
||||
export function useRenderQueue(projectId: string | null) {
|
||||
const [jobs, setJobs] = useState<RenderJob[]>([]);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
@@ -63,16 +65,26 @@ export function useRenderQueue(projectId: string | null) {
|
||||
fps = 30,
|
||||
quality: "draft" | "standard" | "high" = "standard",
|
||||
format: "mp4" | "webm" | "mov" = "mp4",
|
||||
resolution: ResolutionPreset | "auto" = "auto",
|
||||
) => {
|
||||
if (!projectId) return;
|
||||
|
||||
const startTime = Date.now();
|
||||
// "auto" means "render at the composition's authored size" — omit the
|
||||
// field entirely so the producer's resolveDeviceScaleFactor returns 1.
|
||||
// Sending the string "auto" would fail the route's validation set.
|
||||
const body: { fps: number; quality: string; format: string; resolution?: string } = {
|
||||
fps,
|
||||
quality,
|
||||
format,
|
||||
};
|
||||
if (resolution !== "auto") body.resolution = resolution;
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`/api/projects/${projectId}/render`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fps, quality, format }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch {
|
||||
const failedJob: RenderJob = {
|
||||
|
||||
Reference in New Issue
Block a user