mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
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:
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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`, {
|
||||||
|
|||||||
Reference in New Issue
Block a user