mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
refactor: dedupe resolution presets and clean up 4k stack
This commit is contained in:
@@ -36,34 +36,11 @@ import { fetchRemoteTemplate } from "../templates/remote.js";
|
||||
import { trackInitTemplate } from "../telemetry/events.js";
|
||||
import { hasFFmpeg } from "../whisper/manager.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { CANVAS_DIMENSIONS, type CanvasResolution } from "@hyperframes/core";
|
||||
|
||||
const VALID_RESOLUTIONS: readonly CanvasResolution[] = [
|
||||
"landscape",
|
||||
"portrait",
|
||||
"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];
|
||||
}
|
||||
import {
|
||||
CANVAS_DIMENSIONS,
|
||||
normalizeResolutionFlag,
|
||||
type CanvasResolution,
|
||||
} from "@hyperframes/core";
|
||||
|
||||
interface VideoMeta {
|
||||
durationSeconds: number;
|
||||
|
||||
@@ -50,36 +50,11 @@ import {
|
||||
extractCompositionMetadata,
|
||||
validateVariables,
|
||||
formatVariableValidationIssue,
|
||||
normalizeResolutionFlag,
|
||||
type VariableValidationIssue,
|
||||
type CanvasResolution,
|
||||
} 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_QUALITY = new Set(["draft", "standard", "high"]);
|
||||
const VALID_FORMAT = new Set(["mp4", "webm", "mov", "png-sequence"]);
|
||||
@@ -245,7 +220,7 @@ export default defineCommand({
|
||||
// ── Validate resolution ────────────────────────────────────────────────
|
||||
let outputResolution: CanvasResolution | undefined;
|
||||
if (args.resolution !== undefined) {
|
||||
outputResolution = normalizeRenderResolutionFlag(args.resolution);
|
||||
outputResolution = normalizeResolutionFlag(args.resolution);
|
||||
if (!outputResolution) {
|
||||
errorBox(
|
||||
"Invalid resolution",
|
||||
@@ -565,12 +540,7 @@ interface RenderOptions {
|
||||
variables?: Record<string, unknown>;
|
||||
entryFile?: string;
|
||||
exitAfterComplete?: boolean;
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Output resolution preset; see `resolveDeviceScaleFactor` for constraints. */
|
||||
outputResolution?: CanvasResolution;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,34 @@ export const CANVAS_DIMENSIONS = {
|
||||
"portrait-4k": { width: 2160, height: 3840 },
|
||||
} as const;
|
||||
|
||||
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 {
|
||||
id: string;
|
||||
type: TimelineElementType;
|
||||
|
||||
@@ -12,6 +12,25 @@ describe("@hyperframes/core public API exports", () => {
|
||||
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", () => {
|
||||
expect(core.TIMELINE_COLORS).toBeDefined();
|
||||
expect(core.TIMELINE_COLORS.video).toBeDefined();
|
||||
|
||||
@@ -36,6 +36,8 @@ export type {
|
||||
|
||||
export {
|
||||
CANVAS_DIMENSIONS,
|
||||
VALID_CANVAS_RESOLUTIONS,
|
||||
normalizeResolutionFlag,
|
||||
TIMELINE_COLORS,
|
||||
DEFAULT_DURATIONS,
|
||||
COMPOSITION_VARIABLE_TYPES,
|
||||
|
||||
@@ -3,6 +3,9 @@ import { streamSSE } from "hono/streaming";
|
||||
import { existsSync, readFileSync, mkdirSync, unlinkSync, readdirSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
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 {
|
||||
// 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 ?? "")
|
||||
? (body.quality as string)
|
||||
: "standard";
|
||||
const VALID_RESOLUTIONS = new Set(["landscape", "portrait", "landscape-4k", "portrait-4k"]);
|
||||
const outputResolution = VALID_RESOLUTIONS.has(body.resolution ?? "")
|
||||
? (body.resolution as "landscape" | "portrait" | "landscape-4k" | "portrait-4k")
|
||||
? (body.resolution as CanvasResolution)
|
||||
: undefined;
|
||||
|
||||
const now = new Date();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { CanvasResolution } from "../core.types.js";
|
||||
|
||||
/** Resolved info about a single project. */
|
||||
export interface ResolvedProject {
|
||||
id: string;
|
||||
@@ -65,12 +67,10 @@ export interface StudioApiAdapter {
|
||||
quality: string;
|
||||
jobId: string;
|
||||
/**
|
||||
* Optional output resolution preset (e.g. "landscape-4k"). When set, the
|
||||
* producer supersamples the composition via Chrome `deviceScaleFactor`.
|
||||
* The composition's authored dimensions are unchanged. See the
|
||||
* `resolveDeviceScaleFactor` constraints in the producer.
|
||||
* Optional output resolution preset. See `resolveDeviceScaleFactor` in
|
||||
* the producer for the integer-scale + aspect + HDR constraints.
|
||||
*/
|
||||
outputResolution?: "landscape" | "portrait" | "landscape-4k" | "portrait-4k";
|
||||
outputResolution?: CanvasResolution;
|
||||
}): RenderJobState;
|
||||
|
||||
/** 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
|
||||
* 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
|
||||
* re-read from disk and re-base64 the same frame. The lost cache hit costs
|
||||
* one re-read per access; pretending to cache and immediately evicting
|
||||
* costs one re-read per access *plus* the futile insert/evict bookkeeping.
|
||||
* re-read from disk and re-base64 the same frame.
|
||||
*
|
||||
* **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(
|
||||
entryLimit: number,
|
||||
@@ -58,7 +62,6 @@ function createFrameSourceCache(
|
||||
frameSrcResolver?: (framePath: string) => string | null,
|
||||
): FrameSourceCache {
|
||||
const cache = new Map<string, string>();
|
||||
const sizes = new Map<string, number>();
|
||||
const inFlight = new Map<string, Promise<string>>();
|
||||
let totalBytes = 0;
|
||||
let evictions = 0;
|
||||
@@ -67,10 +70,8 @@ function createFrameSourceCache(
|
||||
function evictOldest(): void {
|
||||
const oldestKey = cache.keys().next().value;
|
||||
if (!oldestKey) return;
|
||||
const size = sizes.get(oldestKey) ?? 0;
|
||||
totalBytes = Math.max(0, totalBytes - (cache.get(oldestKey)?.length ?? 0));
|
||||
cache.delete(oldestKey);
|
||||
sizes.delete(oldestKey);
|
||||
totalBytes = Math.max(0, totalBytes - size);
|
||||
evictions++;
|
||||
}
|
||||
|
||||
@@ -82,23 +83,17 @@ function createFrameSourceCache(
|
||||
oversizedRejections++;
|
||||
// Drop any stale prior version so the caller sees consistent state.
|
||||
if (cache.has(framePath)) {
|
||||
const prev = sizes.get(framePath) ?? 0;
|
||||
totalBytes = Math.max(0, totalBytes - (cache.get(framePath)?.length ?? 0));
|
||||
cache.delete(framePath);
|
||||
sizes.delete(framePath);
|
||||
totalBytes = Math.max(0, totalBytes - prev);
|
||||
}
|
||||
return dataUri;
|
||||
}
|
||||
if (cache.has(framePath)) {
|
||||
const prev = sizes.get(framePath) ?? 0;
|
||||
totalBytes = Math.max(0, totalBytes - (cache.get(framePath)?.length ?? 0));
|
||||
cache.delete(framePath);
|
||||
sizes.delete(framePath);
|
||||
totalBytes = Math.max(0, totalBytes - prev);
|
||||
}
|
||||
const size = dataUri.length;
|
||||
cache.set(framePath, dataUri);
|
||||
sizes.set(framePath, size);
|
||||
totalBytes += size;
|
||||
totalBytes += dataUri.length;
|
||||
while ((cache.size > entryLimit || totalBytes > bytesLimit) && cache.size > 0) {
|
||||
evictOldest();
|
||||
}
|
||||
|
||||
@@ -281,21 +281,10 @@ export interface RenderConfig {
|
||||
*/
|
||||
variables?: Record<string, unknown>;
|
||||
/**
|
||||
* Override the output resolution. The composition's intrinsic
|
||||
* `data-width` / `data-height` continue to drive page layout (Chrome
|
||||
* viewport), and supersampling is achieved by setting Chrome's
|
||||
* `deviceScaleFactor` so the captured screenshot lands at the requested
|
||||
* 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.
|
||||
* Override the output resolution via Chrome `deviceScaleFactor` (DPR).
|
||||
* The composition's authored dimensions are unchanged. See
|
||||
* {@link resolveDeviceScaleFactor} for the integer-scale, aspect, and
|
||||
* HDR constraints.
|
||||
*/
|
||||
outputResolution?: CanvasResolution;
|
||||
}
|
||||
@@ -594,12 +583,11 @@ export function projectBrowserEndToCompositionTimeline(
|
||||
* we can plumb a separate flag.
|
||||
*
|
||||
* Throws on:
|
||||
* - HDR + outputResolution combination (HDR layered compositor would
|
||||
* need parallel scaling for its raw pixel buffers).
|
||||
* - Non-integer scale (e.g. 720p composition, 4K output → 3× height
|
||||
* but the width ratio is also 3× ✓; 1080p portrait → 4K landscape
|
||||
* would mismatch).
|
||||
* - Output dimensions smaller than composition dimensions.
|
||||
* - HDR + outputResolution (HDR compositor processes raw pixel buffers
|
||||
* at composition dimensions and would need parallel scaling).
|
||||
* - Aspect-ratio mismatch (e.g. landscape composition → portrait-4k).
|
||||
* - Non-integer scale ratio.
|
||||
* - Downsampling (output dimensions smaller than composition).
|
||||
*/
|
||||
export function resolveDeviceScaleFactor(input: {
|
||||
compositionWidth: number;
|
||||
@@ -2144,13 +2132,15 @@ export async function executeRenderJob(
|
||||
outputResolution: job.config.outputResolution,
|
||||
hdrRequested: job.config.hdrMode === "force-hdr",
|
||||
});
|
||||
const outputWidth = width * deviceScaleFactor;
|
||||
const outputHeight = height * deviceScaleFactor;
|
||||
if (deviceScaleFactor > 1) {
|
||||
log.info("Supersampling composition via deviceScaleFactor", {
|
||||
compositionWidth: width,
|
||||
compositionHeight: height,
|
||||
outputResolution: job.config.outputResolution,
|
||||
outputWidth: width * deviceScaleFactor,
|
||||
outputHeight: height * deviceScaleFactor,
|
||||
outputWidth,
|
||||
outputHeight,
|
||||
deviceScaleFactor,
|
||||
});
|
||||
}
|
||||
@@ -4018,7 +4008,7 @@ export async function executeRenderJob(
|
||||
chunkSizeFrames: enableChunkedEncode ? chunkedEncodeSize : null,
|
||||
compositionDurationSeconds: composition.duration,
|
||||
totalFrames: totalFrames,
|
||||
resolution: { width: width * deviceScaleFactor, height: height * deviceScaleFactor },
|
||||
resolution: { width: outputWidth, height: outputHeight },
|
||||
videoCount: composition.videos.length,
|
||||
audioCount: composition.audios.length,
|
||||
stages: perfStages,
|
||||
|
||||
@@ -1685,7 +1685,7 @@ export function StudioApp() {
|
||||
onDelete={renderQueue.deleteRender}
|
||||
onClearCompleted={renderQueue.clearCompleted}
|
||||
onStartRender={(format, quality, resolution) =>
|
||||
renderQueue.startRender(30, quality, format, resolution)
|
||||
renderQueue.startRender({ format, quality, resolution })
|
||||
}
|
||||
isRendering={renderQueue.isRendering}
|
||||
/>
|
||||
|
||||
@@ -20,10 +20,10 @@ interface RenderQueueProps {
|
||||
// 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" },
|
||||
landscape: { label: "1080p ↔", title: "1920×1080 landscape" },
|
||||
portrait: { label: "1080p ↕", title: "1080×1920 portrait" },
|
||||
"landscape-4k": {
|
||||
label: "4K",
|
||||
label: "4K ↔",
|
||||
title: "3840×2160 — supersamples a 1080p composition via Chrome DPR. Slower, larger files.",
|
||||
},
|
||||
"portrait-4k": {
|
||||
|
||||
@@ -11,8 +11,20 @@ export interface RenderJob {
|
||||
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 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) {
|
||||
const [jobs, setJobs] = useState<RenderJob[]>([]);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
@@ -61,24 +73,24 @@ export function useRenderQueue(projectId: string | null) {
|
||||
|
||||
// Start a render and track progress via SSE
|
||||
const startRender = useCallback(
|
||||
async (
|
||||
fps = 30,
|
||||
quality: "draft" | "standard" | "high" = "standard",
|
||||
format: "mp4" | "webm" | "mov" = "mp4",
|
||||
resolution: ResolutionPreset | "auto" = "auto",
|
||||
) => {
|
||||
async (opts: StartRenderOptions = {}) => {
|
||||
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();
|
||||
// "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.
|
||||
// "auto" / undefined means "render at the composition's authored size".
|
||||
// Omit the field entirely — sending "auto" would trip the route's
|
||||
// enum validation set.
|
||||
const body: { fps: number; quality: string; format: string; resolution?: string } = {
|
||||
fps,
|
||||
quality,
|
||||
format,
|
||||
};
|
||||
if (resolution !== "auto") body.resolution = resolution;
|
||||
if (resolution && resolution !== "auto") body.resolution = resolution;
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`/api/projects/${projectId}/render`, {
|
||||
|
||||
Reference in New Issue
Block a user