Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 5 additions & 28 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
36 changes: 3 additions & 33 deletions packages/cli/src/commands/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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;
}

Expand Down
39 changes: 37 additions & 2 deletions packages/core/src/core.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,50 @@ 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 },
"landscape-4k": { width: 3840, height: 2160 },
"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<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;
Expand Down
19 changes: 19 additions & 0 deletions packages/core/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export type {

export {
CANVAS_DIMENSIONS,
VALID_CANVAS_RESOLUTIONS,
normalizeResolutionFlag,
TIMELINE_COLORS,
DEFAULT_DURATIONS,
COMPOSITION_VARIABLE_TYPES,
Expand Down
6 changes: 4 additions & 2 deletions packages/core/src/studio-api/routes/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down
10 changes: 5 additions & 5 deletions packages/core/src/studio-api/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { CanvasResolution } from "../core.types.js";

/** Resolved info about a single project. */
export interface ResolvedProject {
id: string;
Expand Down Expand Up @@ -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. */
Expand Down
31 changes: 15 additions & 16 deletions packages/engine/src/services/videoFrameInjector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,17 +48,20 @@ 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,
bytesLimit: number,
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;
Expand All @@ -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++;
}

Expand All @@ -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();
}
Expand Down
38 changes: 14 additions & 24 deletions packages/producer/src/services/renderOrchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
});
}
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
/>
Expand Down
4 changes: 2 additions & 2 deletions packages/studio/src/components/renders/RenderQueue.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Loading
Loading