refactor: dedupe resolution presets and clean up 4k stack

This commit is contained in:
James
2026-05-07 16:58:26 +00:00
parent bc02b8810c
commit 2b2281d490
12 changed files with 116 additions and 121 deletions
+5 -28
View File
@@ -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;
+3 -33
View File
@@ -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
View File
@@ -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;
+19
View File
@@ -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();
+2
View File
@@ -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();
+5 -5
View File
@@ -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,
+1 -1
View File
@@ -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`, {