diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index d31cad686..443368619 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -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 = { - "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; diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 4da323eb6..56536dc8e 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -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 = { - "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; 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; } diff --git a/packages/core/src/core.types.ts b/packages/core/src/core.types.ts index d46da4a6b..c3d069d31 100644 --- a/packages/core/src/core.types.ts +++ b/packages/core/src/core.types.ts @@ -19,8 +19,6 @@ export interface Asset { export type TimelineElementType = "video" | "image" | "text" | "audio" | "composition"; export type MediaElementType = "video" | "image" | "audio"; -export type CanvasResolution = "landscape" | "portrait" | "landscape-4k" | "portrait-4k"; - export const CANVAS_DIMENSIONS = { landscape: { width: 1920, height: 1080 }, portrait: { width: 1080, height: 1920 }, @@ -28,6 +26,43 @@ export const CANVAS_DIMENSIONS = { "portrait-4k": { width: 2160, height: 3840 }, } 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 = { + "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; diff --git a/packages/core/src/index.test.ts b/packages/core/src/index.test.ts index 0c0bf94be..bae8a15aa 100644 --- a/packages/core/src/index.test.ts +++ b/packages/core/src/index.test.ts @@ -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(); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8a0906552..f0706eaff 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -36,6 +36,8 @@ export type { export { CANVAS_DIMENSIONS, + VALID_CANVAS_RESOLUTIONS, + normalizeResolutionFlag, TIMELINE_COLORS, DEFAULT_DURATIONS, COMPOSITION_VARIABLE_TYPES, diff --git a/packages/core/src/studio-api/routes/render.ts b/packages/core/src/studio-api/routes/render.ts index a5370975c..7e5eb041c 100644 --- a/packages/core/src/studio-api/routes/render.ts +++ b/packages/core/src/studio-api/routes/render.ts @@ -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(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(); diff --git a/packages/core/src/studio-api/types.ts b/packages/core/src/studio-api/types.ts index 0a3f44579..c37e8bda6 100644 --- a/packages/core/src/studio-api/types.ts +++ b/packages/core/src/studio-api/types.ts @@ -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. */ diff --git a/packages/engine/src/services/videoFrameInjector.ts b/packages/engine/src/services/videoFrameInjector.ts index b928decf9..187a08f19 100644 --- a/packages/engine/src/services/videoFrameInjector.ts +++ b/packages/engine/src/services/videoFrameInjector.ts @@ -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`. + * 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(); - const sizes = new Map(); const inFlight = new Map>(); let totalBytes = 0; let evictions = 0; @@ -67,10 +70,12 @@ function createFrameSourceCache( function evictOldest(): void { const oldestKey = cache.keys().next().value; 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); - sizes.delete(oldestKey); - totalBytes = Math.max(0, totalBytes - size); + totalBytes = Math.max(0, totalBytes - (dropped?.length ?? 0)); evictions++; } @@ -82,23 +87,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(); } diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index cdc3c705a..2bff02c9d 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -281,21 +281,10 @@ export interface RenderConfig { */ variables?: Record; /** - * 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, diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index ba6f03d6b..e7e0553ea 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -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} /> diff --git a/packages/studio/src/components/renders/RenderQueue.tsx b/packages/studio/src/components/renders/RenderQueue.tsx index 4e59caa5b..8cca8edef 100644 --- a/packages/studio/src/components/renders/RenderQueue.tsx +++ b/packages/studio/src/components/renders/RenderQueue.tsx @@ -20,10 +20,10 @@ interface RenderQueueProps { // silently missing dropdown entry. Order is fixed by the array below. const RESOLUTION_LABELS: Record = { 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": { diff --git a/packages/studio/src/components/renders/useRenderQueue.ts b/packages/studio/src/components/renders/useRenderQueue.ts index 48ec430de..d3c0aa446 100644 --- a/packages/studio/src/components/renders/useRenderQueue.ts +++ b/packages/studio/src/components/renders/useRenderQueue.ts @@ -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([]); const eventSourceRef = useRef(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`, {