Merge pull request #665 from heygen-com/05-07-refactor_dedupe_resolution_presets_and_clean_up_4k_stack

refactor: dedupe resolution presets and clean up 4k stack
This commit is contained in:
James Russo
2026-05-07 11:47:46 -07:00
committed by GitHub
12 changed files with 129 additions and 123 deletions
+5 -28
View File
@@ -36,34 +36,11 @@ import { fetchRemoteTemplate } from "../templates/remote.js";
import { trackInitTemplate } from "../telemetry/events.js"; import { trackInitTemplate } from "../telemetry/events.js";
import { hasFFmpeg } from "../whisper/manager.js"; import { hasFFmpeg } from "../whisper/manager.js";
import { VERSION } from "../version.js"; import { VERSION } from "../version.js";
import { CANVAS_DIMENSIONS, type CanvasResolution } from "@hyperframes/core"; import {
CANVAS_DIMENSIONS,
const VALID_RESOLUTIONS: readonly CanvasResolution[] = [ normalizeResolutionFlag,
"landscape", type CanvasResolution,
"portrait", } from "@hyperframes/core";
"landscape-4k",
"portrait-4k",
] as const;
const RESOLUTION_ALIASES: Record<string, CanvasResolution> = {
"1080p": "landscape",
hd: "landscape",
"1080p-portrait": "portrait",
"portrait-1080p": "portrait",
"4k": "landscape-4k",
uhd: "landscape-4k",
"4k-portrait": "portrait-4k",
"portrait-4k": "portrait-4k",
};
function normalizeResolutionFlag(input: string | undefined): CanvasResolution | undefined {
if (!input) return undefined;
const lowered = input.toLowerCase();
if ((VALID_RESOLUTIONS as readonly string[]).includes(lowered)) {
return lowered as CanvasResolution;
}
return RESOLUTION_ALIASES[lowered];
}
interface VideoMeta { interface VideoMeta {
durationSeconds: number; durationSeconds: number;
+3 -33
View File
@@ -50,36 +50,11 @@ import {
extractCompositionMetadata, extractCompositionMetadata,
validateVariables, validateVariables,
formatVariableValidationIssue, formatVariableValidationIssue,
normalizeResolutionFlag,
type VariableValidationIssue, type VariableValidationIssue,
type CanvasResolution, type CanvasResolution,
} from "@hyperframes/core"; } from "@hyperframes/core";
const VALID_RENDER_RESOLUTIONS: readonly CanvasResolution[] = [
"landscape",
"portrait",
"landscape-4k",
"portrait-4k",
] as const;
const RENDER_RESOLUTION_ALIASES: Record<string, CanvasResolution> = {
"1080p": "landscape",
hd: "landscape",
"1080p-portrait": "portrait",
"portrait-1080p": "portrait",
"4k": "landscape-4k",
uhd: "landscape-4k",
"4k-portrait": "portrait-4k",
};
function normalizeRenderResolutionFlag(input: string | undefined): CanvasResolution | undefined {
if (!input) return undefined;
const lowered = input.toLowerCase();
if ((VALID_RENDER_RESOLUTIONS as readonly string[]).includes(lowered)) {
return lowered as CanvasResolution;
}
return RENDER_RESOLUTION_ALIASES[lowered];
}
const VALID_FPS = new Set([24, 30, 60]); const VALID_FPS = new Set([24, 30, 60]);
const VALID_QUALITY = new Set(["draft", "standard", "high"]); const VALID_QUALITY = new Set(["draft", "standard", "high"]);
const VALID_FORMAT = new Set(["mp4", "webm", "mov", "png-sequence"]); const VALID_FORMAT = new Set(["mp4", "webm", "mov", "png-sequence"]);
@@ -245,7 +220,7 @@ export default defineCommand({
// ── Validate resolution ──────────────────────────────────────────────── // ── Validate resolution ────────────────────────────────────────────────
let outputResolution: CanvasResolution | undefined; let outputResolution: CanvasResolution | undefined;
if (args.resolution !== undefined) { if (args.resolution !== undefined) {
outputResolution = normalizeRenderResolutionFlag(args.resolution); outputResolution = normalizeResolutionFlag(args.resolution);
if (!outputResolution) { if (!outputResolution) {
errorBox( errorBox(
"Invalid resolution", "Invalid resolution",
@@ -565,12 +540,7 @@ interface RenderOptions {
variables?: Record<string, unknown>; variables?: Record<string, unknown>;
entryFile?: string; entryFile?: string;
exitAfterComplete?: boolean; exitAfterComplete?: boolean;
/** /** Output resolution preset; see `resolveDeviceScaleFactor` for constraints. */
* Output resolution preset. When set, the orchestrator computes a Chrome
* deviceScaleFactor so the screenshot lands at the requested dimensions
* without changing the composition. See the producer's
* `resolveDeviceScaleFactor` for the integer-scale + aspect constraints.
*/
outputResolution?: CanvasResolution; outputResolution?: CanvasResolution;
} }
+37 -2
View File
@@ -19,8 +19,6 @@ export interface Asset {
export type TimelineElementType = "video" | "image" | "text" | "audio" | "composition"; export type TimelineElementType = "video" | "image" | "text" | "audio" | "composition";
export type MediaElementType = "video" | "image" | "audio"; export type MediaElementType = "video" | "image" | "audio";
export type CanvasResolution = "landscape" | "portrait" | "landscape-4k" | "portrait-4k";
export const CANVAS_DIMENSIONS = { export const CANVAS_DIMENSIONS = {
landscape: { width: 1920, height: 1080 }, landscape: { width: 1920, height: 1080 },
portrait: { width: 1080, height: 1920 }, portrait: { width: 1080, height: 1920 },
@@ -28,6 +26,43 @@ export const CANVAS_DIMENSIONS = {
"portrait-4k": { width: 2160, height: 3840 }, "portrait-4k": { width: 2160, height: 3840 },
} as const; } as const;
// Single source of truth: derive the type from the table so adding a preset
// extends the union automatically. Avoids the prior `as readonly CanvasResolution[]`
// cast on `VALID_CANVAS_RESOLUTIONS` quietly drifting if the table grew but
// the union didn't.
export type CanvasResolution = keyof typeof CANVAS_DIMENSIONS;
// `Object.keys` ordering matches insertion order in `CANVAS_DIMENSIONS` on
// every supported JS engine; tests pin the order in `index.test.ts`. Reorder
// the table above with care.
export const VALID_CANVAS_RESOLUTIONS = Object.keys(
CANVAS_DIMENSIONS,
) as readonly CanvasResolution[];
const RESOLUTION_ALIASES: Record<string, CanvasResolution> = {
"1080p": "landscape",
hd: "landscape",
"1080p-portrait": "portrait",
"portrait-1080p": "portrait",
"4k": "landscape-4k",
uhd: "landscape-4k",
"4k-portrait": "portrait-4k",
};
/**
* Map a user-facing resolution string (canonical name or alias) to a
* `CanvasResolution`. Returns undefined for unknown values so callers
* can produce their own "invalid" UX (CLI exit, route validation, etc.).
*/
export function normalizeResolutionFlag(input: string | undefined): CanvasResolution | undefined {
if (!input) return undefined;
const lowered = input.toLowerCase();
if ((VALID_CANVAS_RESOLUTIONS as readonly string[]).includes(lowered)) {
return lowered as CanvasResolution;
}
return RESOLUTION_ALIASES[lowered];
}
export interface TimelineElementBase { export interface TimelineElementBase {
id: string; id: string;
type: TimelineElementType; type: TimelineElementType;
+19
View File
@@ -12,6 +12,25 @@ describe("@hyperframes/core public API exports", () => {
expect(core.CANVAS_DIMENSIONS["portrait-4k"]).toEqual({ width: 2160, height: 3840 }); expect(core.CANVAS_DIMENSIONS["portrait-4k"]).toEqual({ width: 2160, height: 3840 });
}); });
it("exports VALID_CANVAS_RESOLUTIONS derived from CANVAS_DIMENSIONS", () => {
expect(core.VALID_CANVAS_RESOLUTIONS).toEqual([
"landscape",
"portrait",
"landscape-4k",
"portrait-4k",
]);
});
it("exports normalizeResolutionFlag with alias support", () => {
expect(core.normalizeResolutionFlag("4k")).toBe("landscape-4k");
expect(core.normalizeResolutionFlag("uhd")).toBe("landscape-4k");
expect(core.normalizeResolutionFlag("1080p")).toBe("landscape");
expect(core.normalizeResolutionFlag("landscape-4k")).toBe("landscape-4k");
expect(core.normalizeResolutionFlag("UHD")).toBe("landscape-4k");
expect(core.normalizeResolutionFlag("8k")).toBeUndefined();
expect(core.normalizeResolutionFlag(undefined)).toBeUndefined();
});
it("exports TIMELINE_COLORS", () => { it("exports TIMELINE_COLORS", () => {
expect(core.TIMELINE_COLORS).toBeDefined(); expect(core.TIMELINE_COLORS).toBeDefined();
expect(core.TIMELINE_COLORS.video).toBeDefined(); expect(core.TIMELINE_COLORS.video).toBeDefined();
+2
View File
@@ -36,6 +36,8 @@ export type {
export { export {
CANVAS_DIMENSIONS, CANVAS_DIMENSIONS,
VALID_CANVAS_RESOLUTIONS,
normalizeResolutionFlag,
TIMELINE_COLORS, TIMELINE_COLORS,
DEFAULT_DURATIONS, DEFAULT_DURATIONS,
COMPOSITION_VARIABLE_TYPES, COMPOSITION_VARIABLE_TYPES,
@@ -3,6 +3,9 @@ import { streamSSE } from "hono/streaming";
import { existsSync, readFileSync, mkdirSync, unlinkSync, readdirSync, statSync } from "node:fs"; import { existsSync, readFileSync, mkdirSync, unlinkSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import type { StudioApiAdapter, RenderJobState } from "../types.js"; import type { StudioApiAdapter, RenderJobState } from "../types.js";
import { VALID_CANVAS_RESOLUTIONS, type CanvasResolution } from "../../core.types.js";
const VALID_RESOLUTIONS = new Set<string>(VALID_CANVAS_RESOLUTIONS);
export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void { export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void {
// Scoped job store — not shared across createStudioApi() calls // Scoped job store — not shared across createStudioApi() calls
@@ -59,9 +62,8 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
const quality = ["draft", "standard", "high"].includes(body.quality ?? "") const quality = ["draft", "standard", "high"].includes(body.quality ?? "")
? (body.quality as string) ? (body.quality as string)
: "standard"; : "standard";
const VALID_RESOLUTIONS = new Set(["landscape", "portrait", "landscape-4k", "portrait-4k"]);
const outputResolution = VALID_RESOLUTIONS.has(body.resolution ?? "") const outputResolution = VALID_RESOLUTIONS.has(body.resolution ?? "")
? (body.resolution as "landscape" | "portrait" | "landscape-4k" | "portrait-4k") ? (body.resolution as CanvasResolution)
: undefined; : undefined;
const now = new Date(); const now = new Date();
+5 -5
View File
@@ -1,3 +1,5 @@
import type { CanvasResolution } from "../core.types.js";
/** Resolved info about a single project. */ /** Resolved info about a single project. */
export interface ResolvedProject { export interface ResolvedProject {
id: string; id: string;
@@ -65,12 +67,10 @@ export interface StudioApiAdapter {
quality: string; quality: string;
jobId: string; jobId: string;
/** /**
* Optional output resolution preset (e.g. "landscape-4k"). When set, the * Optional output resolution preset. See `resolveDeviceScaleFactor` in
* producer supersamples the composition via Chrome `deviceScaleFactor`. * the producer for the integer-scale + aspect + HDR constraints.
* The composition's authored dimensions are unchanged. See the
* `resolveDeviceScaleFactor` constraints in the producer.
*/ */
outputResolution?: "landscape" | "portrait" | "landscape-4k" | "portrait-4k"; outputResolution?: CanvasResolution;
}): RenderJobState; }): RenderJobState;
/** Optional: generate a JPEG thumbnail via Puppeteer or similar. */ /** Optional: generate a JPEG thumbnail via Puppeteer or similar. */
@@ -48,9 +48,13 @@ interface FrameSourceCache {
* (returning the URI directly to the caller). Without this guard, the * (returning the URI directly to the caller). Without this guard, the
* post-insert eviction loop would drop the entry we just inserted and the * post-insert eviction loop would drop the entry we just inserted and the
* cache would degrade into a CPU hot path every subsequent `get()` would * cache would degrade into a CPU hot path every subsequent `get()` would
* re-read from disk and re-base64 the same frame. The lost cache hit costs * re-read from disk and re-base64 the same frame.
* one re-read per access; pretending to cache and immediately evicting *
* costs one re-read per access *plus* the futile insert/evict bookkeeping. * **Invariant**: cached values MUST be strings whose `.length` equals the
* byte count we account for at insertion. We derive size on demand via
* `cache.get(key)?.length` rather than maintaining a parallel `Map<string, number>`.
* If you ever wrap the value (e.g. cache a Buffer or an object), the byte
* accounting silently breaks switch to a parallel size map first.
*/ */
function createFrameSourceCache( function createFrameSourceCache(
entryLimit: number, entryLimit: number,
@@ -58,7 +62,6 @@ function createFrameSourceCache(
frameSrcResolver?: (framePath: string) => string | null, frameSrcResolver?: (framePath: string) => string | null,
): FrameSourceCache { ): FrameSourceCache {
const cache = new Map<string, string>(); const cache = new Map<string, string>();
const sizes = new Map<string, number>();
const inFlight = new Map<string, Promise<string>>(); const inFlight = new Map<string, Promise<string>>();
let totalBytes = 0; let totalBytes = 0;
let evictions = 0; let evictions = 0;
@@ -67,10 +70,12 @@ function createFrameSourceCache(
function evictOldest(): void { function evictOldest(): void {
const oldestKey = cache.keys().next().value; const oldestKey = cache.keys().next().value;
if (!oldestKey) return; if (!oldestKey) return;
const size = sizes.get(oldestKey) ?? 0; // Snapshot the value before deleting so the byte-size derivation can't
// accidentally read post-delete (a future reorder would silently lose
// accounting and surface as `totalBytes` drifting out of sync).
const dropped = cache.get(oldestKey);
cache.delete(oldestKey); cache.delete(oldestKey);
sizes.delete(oldestKey); totalBytes = Math.max(0, totalBytes - (dropped?.length ?? 0));
totalBytes = Math.max(0, totalBytes - size);
evictions++; evictions++;
} }
@@ -82,23 +87,17 @@ function createFrameSourceCache(
oversizedRejections++; oversizedRejections++;
// Drop any stale prior version so the caller sees consistent state. // Drop any stale prior version so the caller sees consistent state.
if (cache.has(framePath)) { if (cache.has(framePath)) {
const prev = sizes.get(framePath) ?? 0; totalBytes = Math.max(0, totalBytes - (cache.get(framePath)?.length ?? 0));
cache.delete(framePath); cache.delete(framePath);
sizes.delete(framePath);
totalBytes = Math.max(0, totalBytes - prev);
} }
return dataUri; return dataUri;
} }
if (cache.has(framePath)) { if (cache.has(framePath)) {
const prev = sizes.get(framePath) ?? 0; totalBytes = Math.max(0, totalBytes - (cache.get(framePath)?.length ?? 0));
cache.delete(framePath); cache.delete(framePath);
sizes.delete(framePath);
totalBytes = Math.max(0, totalBytes - prev);
} }
const size = dataUri.length;
cache.set(framePath, dataUri); cache.set(framePath, dataUri);
sizes.set(framePath, size); totalBytes += dataUri.length;
totalBytes += size;
while ((cache.size > entryLimit || totalBytes > bytesLimit) && cache.size > 0) { while ((cache.size > entryLimit || totalBytes > bytesLimit) && cache.size > 0) {
evictOldest(); evictOldest();
} }
@@ -281,21 +281,10 @@ export interface RenderConfig {
*/ */
variables?: Record<string, unknown>; variables?: Record<string, unknown>;
/** /**
* Override the output resolution. The composition's intrinsic * Override the output resolution via Chrome `deviceScaleFactor` (DPR).
* `data-width` / `data-height` continue to drive page layout (Chrome * The composition's authored dimensions are unchanged. See
* viewport), and supersampling is achieved by setting Chrome's * {@link resolveDeviceScaleFactor} for the integer-scale, aspect, and
* `deviceScaleFactor` so the captured screenshot lands at the requested * HDR constraints.
* dimensions. Passing a 4K preset on a 1080p composition therefore
* produces a 4K output without rewriting any composition HTML.
*
* Constraint: the requested dimensions must be an integer multiple of
* the composition's intrinsic dimensions (so DPR is a clean integer).
* Non-integer scales are rejected with an explanatory error before any
* frames are captured.
*
* Not yet supported with HDR (the layered HDR compositor processes
* pixel buffers at composition dimensions and would need parallel
* scaling); the orchestrator errors when both are set.
*/ */
outputResolution?: CanvasResolution; outputResolution?: CanvasResolution;
} }
@@ -594,12 +583,11 @@ export function projectBrowserEndToCompositionTimeline(
* we can plumb a separate flag. * we can plumb a separate flag.
* *
* Throws on: * Throws on:
* - HDR + outputResolution combination (HDR layered compositor would * - HDR + outputResolution (HDR compositor processes raw pixel buffers
* need parallel scaling for its raw pixel buffers). * at composition dimensions and would need parallel scaling).
* - Non-integer scale (e.g. 720p composition, 4K output 3× height * - Aspect-ratio mismatch (e.g. landscape composition portrait-4k).
* but the width ratio is also 3× ; 1080p portrait 4K landscape * - Non-integer scale ratio.
* would mismatch). * - Downsampling (output dimensions smaller than composition).
* - Output dimensions smaller than composition dimensions.
*/ */
export function resolveDeviceScaleFactor(input: { export function resolveDeviceScaleFactor(input: {
compositionWidth: number; compositionWidth: number;
@@ -2144,13 +2132,15 @@ export async function executeRenderJob(
outputResolution: job.config.outputResolution, outputResolution: job.config.outputResolution,
hdrRequested: job.config.hdrMode === "force-hdr", hdrRequested: job.config.hdrMode === "force-hdr",
}); });
const outputWidth = width * deviceScaleFactor;
const outputHeight = height * deviceScaleFactor;
if (deviceScaleFactor > 1) { if (deviceScaleFactor > 1) {
log.info("Supersampling composition via deviceScaleFactor", { log.info("Supersampling composition via deviceScaleFactor", {
compositionWidth: width, compositionWidth: width,
compositionHeight: height, compositionHeight: height,
outputResolution: job.config.outputResolution, outputResolution: job.config.outputResolution,
outputWidth: width * deviceScaleFactor, outputWidth,
outputHeight: height * deviceScaleFactor, outputHeight,
deviceScaleFactor, deviceScaleFactor,
}); });
} }
@@ -4018,7 +4008,7 @@ export async function executeRenderJob(
chunkSizeFrames: enableChunkedEncode ? chunkedEncodeSize : null, chunkSizeFrames: enableChunkedEncode ? chunkedEncodeSize : null,
compositionDurationSeconds: composition.duration, compositionDurationSeconds: composition.duration,
totalFrames: totalFrames, totalFrames: totalFrames,
resolution: { width: width * deviceScaleFactor, height: height * deviceScaleFactor }, resolution: { width: outputWidth, height: outputHeight },
videoCount: composition.videos.length, videoCount: composition.videos.length,
audioCount: composition.audios.length, audioCount: composition.audios.length,
stages: perfStages, stages: perfStages,
+1 -1
View File
@@ -1685,7 +1685,7 @@ export function StudioApp() {
onDelete={renderQueue.deleteRender} onDelete={renderQueue.deleteRender}
onClearCompleted={renderQueue.clearCompleted} onClearCompleted={renderQueue.clearCompleted}
onStartRender={(format, quality, resolution) => onStartRender={(format, quality, resolution) =>
renderQueue.startRender(30, quality, format, resolution) renderQueue.startRender({ format, quality, resolution })
} }
isRendering={renderQueue.isRendering} isRendering={renderQueue.isRendering}
/> />
@@ -20,10 +20,10 @@ interface RenderQueueProps {
// silently missing dropdown entry. Order is fixed by the array below. // silently missing dropdown entry. Order is fixed by the array below.
const RESOLUTION_LABELS: Record<ResolutionPreset | "auto", { label: string; title: string }> = { const RESOLUTION_LABELS: Record<ResolutionPreset | "auto", { label: string; title: string }> = {
auto: { label: "Auto", title: "Render at the composition's authored resolution" }, auto: { label: "Auto", title: "Render at the composition's authored resolution" },
landscape: { label: "1080p", title: "1920×1080 landscape" }, landscape: { label: "1080p", title: "1920×1080 landscape" },
portrait: { label: "1080p ↕", title: "1080×1920 portrait" }, portrait: { label: "1080p ↕", title: "1080×1920 portrait" },
"landscape-4k": { "landscape-4k": {
label: "4K", label: "4K",
title: "3840×2160 — supersamples a 1080p composition via Chrome DPR. Slower, larger files.", title: "3840×2160 — supersamples a 1080p composition via Chrome DPR. Slower, larger files.",
}, },
"portrait-4k": { "portrait-4k": {
@@ -11,8 +11,20 @@ export interface RenderJob {
durationMs?: number; 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 (4 string literals tied to a stable enum).
export type ResolutionPreset = "landscape" | "portrait" | "landscape-4k" | "portrait-4k"; export type ResolutionPreset = "landscape" | "portrait" | "landscape-4k" | "portrait-4k";
export interface StartRenderOptions {
fps?: number;
quality?: "draft" | "standard" | "high";
format?: "mp4" | "webm" | "mov";
/** `"auto"` (default) renders at the composition's authored dimensions. */
resolution?: ResolutionPreset | "auto";
}
export function useRenderQueue(projectId: string | null) { export function useRenderQueue(projectId: string | null) {
const [jobs, setJobs] = useState<RenderJob[]>([]); const [jobs, setJobs] = useState<RenderJob[]>([]);
const eventSourceRef = useRef<EventSource | null>(null); const eventSourceRef = useRef<EventSource | null>(null);
@@ -61,24 +73,24 @@ export function useRenderQueue(projectId: string | null) {
// Start a render and track progress via SSE // Start a render and track progress via SSE
const startRender = useCallback( const startRender = useCallback(
async ( async (opts: StartRenderOptions = {}) => {
fps = 30,
quality: "draft" | "standard" | "high" = "standard",
format: "mp4" | "webm" | "mov" = "mp4",
resolution: ResolutionPreset | "auto" = "auto",
) => {
if (!projectId) return; if (!projectId) return;
const fps = opts.fps ?? 30;
const quality = opts.quality ?? "standard";
const format = opts.format ?? "mp4";
const resolution = opts.resolution;
const startTime = Date.now(); const startTime = Date.now();
// "auto" means "render at the composition's authored size" — omit the // "auto" / undefined means "render at the composition's authored size".
// field entirely so the producer's resolveDeviceScaleFactor returns 1. // Omit the field entirely — sending "auto" would trip the route's
// Sending the string "auto" would fail the route's validation set. // enum validation set.
const body: { fps: number; quality: string; format: string; resolution?: string } = { const body: { fps: number; quality: string; format: string; resolution?: string } = {
fps, fps,
quality, quality,
format, format,
}; };
if (resolution !== "auto") body.resolution = resolution; if (resolution && resolution !== "auto") body.resolution = resolution;
let res: Response; let res: Response;
try { try {
res = await fetch(`/api/projects/${projectId}/render`, { res = await fetch(`/api/projects/${projectId}/render`, {