feat(studio): collapse render resolution dropdown to Auto / 1080p / 4K

Orientation is a property of the composition, not a user choice — the
backend's portrait/landscape presets are tied to the comp's authored
aspect ratio. Letting users pick "1080p portrait" for a landscape
composition just produces a wrong-aspect render.

The dropdown now exposes three scale choices (Auto / 1080p / 4K) and
maps to the correct portrait/landscape preset based on the active
composition's data-width / data-height. Native <select title> tooltips
are unreliable across browsers, so the resolved dimensions render
inline in each option label (e.g. "1080p · 1920×1080") — always
visible, no hover needed.

App.tsx tracks the active comp's dimensions by listening for the
existing hf-preview state/timeline postMessages (same source the
caption-detection logic uses) and passes them to RenderQueue. The
useRenderQueue / backend contract is unchanged: RenderQueue still emits
"landscape" | "portrait" | "landscape-4k" | "portrait-4k" | "auto".
This commit is contained in:
James
2026-05-11 15:54:08 +00:00
parent 553688c996
commit b22252ee8b
2 changed files with 120 additions and 36 deletions
+37
View File
@@ -276,6 +276,42 @@ export function StudioApp() {
setRightCollapsed(!captionHasSelection);
}
}, [captionHasSelection, captionEditMode]);
// Track the active composition's authored dimensions so the render
// dropdown can derive landscape vs portrait without asking the user.
// The runtime fires "state"/"timeline" messages after compositions load.
const [compositionDimensions, setCompositionDimensions] = useState<{
width: number;
height: number;
} | null>(null);
useMountEffect(() => {
const readDimensions = () => {
const iframe = previewIframeRef.current;
let doc: Document | null = null;
try {
doc = iframe?.contentDocument ?? null;
} catch {
return;
}
if (!doc) return;
const root = doc.querySelector("[data-composition-id]");
const w = parseInt(root?.getAttribute("data-width") ?? "", 10);
const h = parseInt(root?.getAttribute("data-height") ?? "", 10);
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return;
setCompositionDimensions((prev) =>
prev && prev.width === w && prev.height === h ? prev : { width: w, height: h },
);
};
const handleMessage = (e: MessageEvent) => {
const data = e.data;
if (data?.source === "hf-preview" && (data?.type === "state" || data?.type === "timeline")) {
readDimensions();
}
};
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 +1723,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,71 @@ interface RenderQueueProps {
resolution: ResolutionPreset | "auto",
) => void;
isRendering: boolean;
/**
* Authored dimensions of the active composition, used to derive
* landscape vs portrait when the user picks 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.",
},
};
// User-facing render scale. Orientation is derived from the composition's
// authored aspect ratio at render time, so the user never picks an
// orientation that mismatches their comp.
type RenderScale = "auto" | "1080p" | "4k";
const RESOLUTION_OPTION_ORDER: (ResolutionPreset | "auto")[] = [
"auto",
"landscape",
"portrait",
"landscape-4k",
"portrait-4k",
];
const SCALE_OPTION_ORDER: RenderScale[] = ["auto", "1080p", "4k"];
const RESOLUTION_OPTIONS = RESOLUTION_OPTION_ORDER.map((value) => ({
value,
...RESOLUTION_LABELS[value],
}));
function isPortraitComp(dims: CompositionDimensions | null | undefined): boolean {
// Squares and missing dims fall through to landscape — matches the legacy
// default ("landscape" was the first preset). The auto option exists for
// users who want exact authored dimensions.
return dims != null && dims.height > dims.width;
}
function resolveResolution(
scale: RenderScale,
dims: CompositionDimensions | null | undefined,
): ResolutionPreset | "auto" {
if (scale === "auto") return "auto";
const portrait = isPortraitComp(dims);
if (scale === "1080p") return portrait ? "portrait" : "landscape";
return portrait ? "portrait-4k" : "landscape-4k";
}
function scaleLabel(scale: RenderScale): string {
if (scale === "auto") return "Auto";
if (scale === "1080p") return "1080p";
return "4K";
}
// Resolved output dimensions for a given scale + composition. Mirrors
// `CANVAS_DIMENSIONS` in core for the 1080p / 4K presets; `auto` echoes the
// composition's authored dims so the user can see exactly what they'll get.
function resolvedDimensions(
scale: RenderScale,
dims: CompositionDimensions | null | undefined,
): CompositionDimensions | null {
if (scale === "auto") return dims ?? null;
const portrait = isPortraitComp(dims);
if (scale === "1080p") {
return portrait ? { width: 1080, height: 1920 } : { width: 1920, height: 1080 };
}
return portrait ? { width: 2160, height: 3840 } : { width: 3840, height: 2160 };
}
function formatDims(dims: CompositionDimensions | null): string {
if (!dims) return "?";
return `${dims.width}×${dims.height}`;
}
function scaleOptionLabel(
scale: RenderScale,
dims: CompositionDimensions | null | undefined,
): string {
const resolved = resolvedDimensions(scale, dims);
return resolved ? `${scaleLabel(scale)} · ${formatDims(resolved)}` : scaleLabel(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 +163,7 @@ const QUALITY_OPTIONS: {
function FormatExportButton({
onStartRender,
isRendering,
compositionDimensions,
}: {
onStartRender: (
format: "mp4" | "webm" | "mov",
@@ -131,10 +171,11 @@ 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");
// MOV (ProRes) is a fixed-quality codec — quality selector has no effect.
const showQuality = format !== "mov";
@@ -147,15 +188,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={scale}
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}>
{scaleOptionLabel(value, compositionDimensions)}
</option>
))}
</select>
@@ -185,7 +225,9 @@ function FormatExportButton({
<option value="webm">WebM</option>
</select>
<button
onClick={() => onStartRender(format, quality, resolution)}
onClick={() =>
onStartRender(format, quality, resolveResolution(scale, 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 +244,7 @@ export const RenderQueue = memo(function RenderQueue({
onClearCompleted,
onStartRender,
isRendering,
compositionDimensions,
}: RenderQueueProps) {
const listRef = useRef<HTMLDivElement>(null);
@@ -228,7 +271,11 @@ export const RenderQueue = memo(function RenderQueue({
Clear
</button>
)}
<FormatExportButton onStartRender={onStartRender} isRendering={isRendering} />
<FormatExportButton
onStartRender={onStartRender}
isRendering={isRendering}
compositionDimensions={compositionDimensions}
/>
</div>
</div>