mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
refactor(core): one owner for the distributed fps set (#5756)
The integer rates Lambda and Cloud Run accept were written out by hand in about 25 places across core, engine, producer, the adapters and the CLI. They now come from DISTRIBUTED_FPS / DistributedFps / isDistributedFps in @hyperframes/core. Engine config snapshots accept 25 like the rest.
This commit is contained in:
@@ -164,7 +164,7 @@ interface PlanLambdaResultBase {
|
||||
PlanHash: string;
|
||||
ChunkCount: number;
|
||||
TotalFrames: number;
|
||||
Fps: 24 | 25 | 30 | 60;
|
||||
Fps: SerializableDistributedRenderConfig["fps"];
|
||||
Width: number;
|
||||
Height: number;
|
||||
Format: DistributedFormat;
|
||||
|
||||
@@ -173,7 +173,7 @@ describe("renderToLambda", () => {
|
||||
projectDir,
|
||||
bucketName: "test-bucket",
|
||||
stateMachineArn: "arn:aws:states:us-east-1:1234:stateMachine:hf",
|
||||
config: { ...baseConfig, fps: 50 as 24 | 25 | 30 | 60 },
|
||||
config: { ...baseConfig, fps: 50 as never },
|
||||
sfn: asSFNClient(sfn),
|
||||
s3: asS3Client(s3),
|
||||
});
|
||||
|
||||
@@ -44,7 +44,7 @@ describe("validateDistributedRenderConfig", () => {
|
||||
["null config", null as unknown as SerializableDistributedRenderConfig, "config"],
|
||||
[
|
||||
"wrong fps",
|
||||
{ ...VALID, fps: 50 as 24 | 25 | 30 | 60 } satisfies SerializableDistributedRenderConfig,
|
||||
{ ...VALID, fps: 50 as never } satisfies SerializableDistributedRenderConfig,
|
||||
"config.fps",
|
||||
],
|
||||
[
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from "../utils/variables.js";
|
||||
import { normalizeErrorMessage } from "../utils/errorMessage.js";
|
||||
import { readAllowedCompositionFpsFromDir } from "../utils/compositionFps.js";
|
||||
import { DISTRIBUTED_FPS, type DistributedFps, isDistributedFps } from "@hyperframes/core";
|
||||
|
||||
export const examples: Example[] = [
|
||||
["Deploy the Cloud Run render stack", "hyperframes cloudrun deploy --project my-gcp-project"],
|
||||
@@ -167,7 +168,7 @@ export default defineCommand({
|
||||
"site-id": { type: "string", description: "Explicit site id (overrides content hash)" },
|
||||
width: { type: "string", description: "Render width in pixels" },
|
||||
height: { type: "string", description: "Render height in pixels" },
|
||||
fps: { type: "string", description: "Render fps (24 | 25 | 30 | 60)" },
|
||||
fps: { type: "string", description: `Render fps (${DISTRIBUTED_FPS.join(" | ")})` },
|
||||
format: { type: "string", description: "mp4 | mov | png-sequence | webm (default: mp4)" },
|
||||
codec: { type: "string", description: "h264 | h265 (mp4 only)" },
|
||||
quality: { type: "string", description: "draft | standard | high" },
|
||||
@@ -529,11 +530,12 @@ function resolveCloudRunFps(
|
||||
args: Record<string, unknown>,
|
||||
projectDir: string,
|
||||
command: "render" | "render-batch",
|
||||
): 24 | 25 | 30 | 60 {
|
||||
const fps =
|
||||
parseIntFlag(args.fps) ?? readAllowedCompositionFpsFromDir(projectDir, [24, 25, 30, 60]) ?? 30;
|
||||
if (fps === 24 || fps === 25 || fps === 30 || fps === 60) return fps;
|
||||
console.error(`[cloudrun ${command}] --fps must be 24, 25, 30, or 60; got ${fps}.`);
|
||||
): DistributedFps {
|
||||
const fps = parseIntFlag(args.fps) ?? readAllowedCompositionFpsFromDir(projectDir) ?? 30;
|
||||
if (isDistributedFps(fps)) return fps;
|
||||
console.error(
|
||||
`[cloudrun ${command}] --fps must be one of ${DISTRIBUTED_FPS.join(", ")}; got ${fps}.`,
|
||||
);
|
||||
failCommand();
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { failCommand } from "../utils/commandResult.js";
|
||||
|
||||
import { defineCommand } from "citty";
|
||||
import type { DistributedFormat } from "@hyperframes/aws-lambda/sdk";
|
||||
import { type CanvasResolution } from "@hyperframes/core";
|
||||
import { type CanvasResolution, DISTRIBUTED_FPS, isDistributedFps } from "@hyperframes/core";
|
||||
import { parseOutputResolutionFlag } from "../utils/parseOutputResolution.js";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { c } from "../ui/colors.js";
|
||||
@@ -126,7 +126,7 @@ export default defineCommand({
|
||||
description:
|
||||
"Output resolution preset that engages Chrome deviceScaleFactor supersampling. Accepts canonical names (landscape, landscape-4k, portrait, portrait-4k, square, square-4k) and aliases (1080p, 4k, uhd, hd). When set, the composition's authored data-width/data-height is supersampled to the target preset without changing the layout.",
|
||||
},
|
||||
fps: { type: "string", description: "Render fps (24 | 25 | 30 | 60)" },
|
||||
fps: { type: "string", description: `Render fps (${DISTRIBUTED_FPS.join(" | ")})` },
|
||||
format: { type: "string", description: "mp4 | mov | png-sequence | webm (default: mp4)" },
|
||||
codec: { type: "string", description: "h264 | h265 (mp4 only)" },
|
||||
quality: { type: "string", description: "draft | standard | high" },
|
||||
@@ -296,12 +296,11 @@ export default defineCommand({
|
||||
console.error("[lambda render] --width and --height are required.");
|
||||
failCommand();
|
||||
}
|
||||
const fpsRaw =
|
||||
parseIntFlag(args.fps) ??
|
||||
readAllowedCompositionFpsFromDir(projectDir, [24, 25, 30, 60]) ??
|
||||
30;
|
||||
if (fpsRaw !== 24 && fpsRaw !== 25 && fpsRaw !== 30 && fpsRaw !== 60) {
|
||||
console.error(`[lambda render] --fps must be 24, 25, 30, or 60; got ${fpsRaw}.`);
|
||||
const fpsRaw = parseIntFlag(args.fps) ?? readAllowedCompositionFpsFromDir(projectDir) ?? 30;
|
||||
if (!isDistributedFps(fpsRaw)) {
|
||||
console.error(
|
||||
`[lambda render] --fps must be one of ${DISTRIBUTED_FPS.join(", ")}; got ${fpsRaw}.`,
|
||||
);
|
||||
failCommand();
|
||||
}
|
||||
const { runRender } = await import("./lambda/render.js");
|
||||
@@ -353,12 +352,11 @@ export default defineCommand({
|
||||
console.error("[lambda render-batch] --width and --height are required.");
|
||||
failCommand();
|
||||
}
|
||||
const fpsRaw =
|
||||
parseIntFlag(args.fps) ??
|
||||
readAllowedCompositionFpsFromDir(projectDir, [24, 25, 30, 60]) ??
|
||||
30;
|
||||
if (fpsRaw !== 24 && fpsRaw !== 25 && fpsRaw !== 30 && fpsRaw !== 60) {
|
||||
console.error(`[lambda render-batch] --fps must be 24, 25, 30, or 60; got ${fpsRaw}.`);
|
||||
const fpsRaw = parseIntFlag(args.fps) ?? readAllowedCompositionFpsFromDir(projectDir) ?? 30;
|
||||
if (!isDistributedFps(fpsRaw)) {
|
||||
console.error(
|
||||
`[lambda render-batch] --fps must be one of ${DISTRIBUTED_FPS.join(", ")}; got ${fpsRaw}.`,
|
||||
);
|
||||
failCommand();
|
||||
}
|
||||
const { runRenderBatch } = await import("./lambda/render-batch.js");
|
||||
|
||||
@@ -29,7 +29,7 @@ import type {
|
||||
SerializableDistributedRenderConfig,
|
||||
SiteHandle,
|
||||
} from "@hyperframes/aws-lambda/sdk";
|
||||
import type { CanvasResolution } from "@hyperframes/core";
|
||||
import type { CanvasResolution, DistributedFps } from "@hyperframes/core";
|
||||
import { c } from "../../ui/colors.js";
|
||||
import { errorBox } from "../../ui/format.js";
|
||||
import {
|
||||
@@ -61,7 +61,7 @@ export interface RenderBatchArgs {
|
||||
*/
|
||||
siteId?: string;
|
||||
/** Composition config — fps/width/height/format required, rest optional. */
|
||||
fps: 24 | 25 | 30 | 60;
|
||||
fps: DistributedFps;
|
||||
width: number;
|
||||
height: number;
|
||||
/** See {@link RenderArgs.outputResolution}. */
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
DistributedFormat,
|
||||
SerializableDistributedRenderConfig,
|
||||
} from "@hyperframes/aws-lambda/sdk";
|
||||
import type { CanvasResolution } from "@hyperframes/core";
|
||||
import type { CanvasResolution, DistributedFps } from "@hyperframes/core";
|
||||
import { c } from "../../ui/colors.js";
|
||||
import {
|
||||
reportVariableIssues,
|
||||
@@ -32,7 +32,7 @@ export interface RenderArgs {
|
||||
stackName: string;
|
||||
siteId?: string;
|
||||
/** Composition config — fps/width/height/format required, rest optional. */
|
||||
fps: 24 | 25 | 30 | 60;
|
||||
fps: DistributedFps;
|
||||
width: number;
|
||||
height: number;
|
||||
/**
|
||||
|
||||
@@ -43,7 +43,6 @@ describe("readCompositionFps", () => {
|
||||
|
||||
describe("readAllowedCompositionFpsFromDir", () => {
|
||||
const projectDirs: string[] = [];
|
||||
const allowedCloudFps = [24, 30, 60] as const;
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of projectDirs.splice(0)) {
|
||||
@@ -63,7 +62,7 @@ describe("readAllowedCompositionFpsFromDir", () => {
|
||||
'<div data-composition-id="root" data-root="true" data-fps="60">x</div>',
|
||||
);
|
||||
|
||||
expect(readAllowedCompositionFpsFromDir(dir, allowedCloudFps)).toBe(60);
|
||||
expect(readAllowedCompositionFpsFromDir(dir)).toBe(60);
|
||||
});
|
||||
|
||||
it("uses cloud-allowed data-fps=24 as the default", () => {
|
||||
@@ -71,7 +70,15 @@ describe("readAllowedCompositionFpsFromDir", () => {
|
||||
'<div data-composition-id="root" data-root="true" data-fps="24">x</div>',
|
||||
);
|
||||
|
||||
expect(readAllowedCompositionFpsFromDir(dir, allowedCloudFps)).toBe(24);
|
||||
expect(readAllowedCompositionFpsFromDir(dir)).toBe(24);
|
||||
});
|
||||
|
||||
it("uses cloud-allowed data-fps=25 instead of falling back to 30", () => {
|
||||
const dir = makeProject(
|
||||
'<div data-composition-id="root" data-root="true" data-fps="25">x</div>',
|
||||
);
|
||||
|
||||
expect(readAllowedCompositionFpsFromDir(dir)).toBe(25);
|
||||
});
|
||||
|
||||
it("returns null for data-fps=48 so cloud callers keep their ?? 30 fallback", () => {
|
||||
@@ -79,7 +86,7 @@ describe("readAllowedCompositionFpsFromDir", () => {
|
||||
'<div data-composition-id="root" data-root="true" data-fps="48">x</div>',
|
||||
);
|
||||
|
||||
const declared = readAllowedCompositionFpsFromDir(dir, allowedCloudFps);
|
||||
const declared = readAllowedCompositionFpsFromDir(dir);
|
||||
expect(declared).toBeNull();
|
||||
expect(declared ?? 30).toBe(30);
|
||||
});
|
||||
@@ -89,19 +96,19 @@ describe("readAllowedCompositionFpsFromDir", () => {
|
||||
'<div data-composition-id="root" data-root="true" data-fps="30000/1001">x</div>',
|
||||
);
|
||||
|
||||
expect(readAllowedCompositionFpsFromDir(dir, allowedCloudFps)).toBeNull();
|
||||
expect(readAllowedCompositionFpsFromDir(dir)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when index.html has no data-fps", () => {
|
||||
const dir = makeProject('<div data-composition-id="root" data-root="true">x</div>');
|
||||
|
||||
expect(readAllowedCompositionFpsFromDir(dir, allowedCloudFps)).toBeNull();
|
||||
expect(readAllowedCompositionFpsFromDir(dir)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when index.html cannot be read", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hyperframes-composition-fps-missing-"));
|
||||
projectDirs.push(dir);
|
||||
|
||||
expect(readAllowedCompositionFpsFromDir(dir, allowedCloudFps)).toBeNull();
|
||||
expect(readAllowedCompositionFpsFromDir(dir)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { type DistributedFps, isDistributedFps } from "@hyperframes/core";
|
||||
import { parseHTML } from "linkedom";
|
||||
|
||||
/**
|
||||
@@ -35,17 +36,12 @@ export function readCompositionFps(html: string): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloud render backends (Lambda, Cloud Run) accept only an integer fps
|
||||
* from a small fixed allowed set (currently {24, 25, 30, 60}) — unlike local
|
||||
* `render`, they can't take an arbitrary/fractional data-fps. Reads
|
||||
* `<projectDir>/index.html` and returns its declared data-fps as a number
|
||||
* ONLY when it parses to an integer AND is a member of `allowed`;
|
||||
* Cloud render backends (Lambda, Cloud Run) accept only `DISTRIBUTED_FPS`;
|
||||
* unlike local `render`, they can't take an arbitrary/fractional data-fps.
|
||||
* Returns `<projectDir>/index.html`'s declared data-fps when it is a member,
|
||||
* otherwise `null` so the caller keeps its own existing default (30).
|
||||
*/
|
||||
export function readAllowedCompositionFpsFromDir(
|
||||
projectDir: string,
|
||||
allowed: readonly number[],
|
||||
): number | null {
|
||||
export function readAllowedCompositionFpsFromDir(projectDir: string): DistributedFps | null {
|
||||
let html: string;
|
||||
try {
|
||||
html = readFileSync(join(projectDir, "index.html"), "utf8");
|
||||
@@ -55,5 +51,5 @@ export function readAllowedCompositionFpsFromDir(
|
||||
const raw = readCompositionFps(html);
|
||||
if (raw == null) return null;
|
||||
const n = Number(raw);
|
||||
return Number.isInteger(n) && allowed.includes(n) ? n : null;
|
||||
return isDistributedFps(n) ? n : null;
|
||||
}
|
||||
|
||||
@@ -136,6 +136,14 @@ export function parseFpsWithDefault(input: string | number | undefined): FpsPars
|
||||
return parseFps(input);
|
||||
}
|
||||
|
||||
/** Integer rates the distributed renderer (Lambda, Cloud Run) accepts; fractional fps renders in-process only. */
|
||||
export const DISTRIBUTED_FPS = [24, 25, 30, 60] as const;
|
||||
export type DistributedFps = (typeof DISTRIBUTED_FPS)[number];
|
||||
|
||||
export function isDistributedFps(value: unknown): value is DistributedFps {
|
||||
return (DISTRIBUTED_FPS as readonly unknown[]).includes(value);
|
||||
}
|
||||
|
||||
/** Video orientation / aspect ratio. */
|
||||
export type Orientation = "16:9" | "9:16";
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ export type {
|
||||
CanvasResolution,
|
||||
Fps,
|
||||
FpsInput,
|
||||
DistributedFps,
|
||||
FpsParseResult,
|
||||
MediaFile,
|
||||
CompositionAPI,
|
||||
@@ -72,6 +73,8 @@ export {
|
||||
toFps,
|
||||
fpsToNumber,
|
||||
fpsToFfmpegArg,
|
||||
DISTRIBUTED_FPS,
|
||||
isDistributedFps,
|
||||
TIMELINE_COLORS,
|
||||
DEFAULT_DURATIONS,
|
||||
COMPOSITION_VARIABLE_TYPES,
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseFps, fpsToNumber, fpsToFfmpegArg } from "./core.types";
|
||||
import {
|
||||
DISTRIBUTED_FPS,
|
||||
fpsToFfmpegArg,
|
||||
fpsToNumber,
|
||||
isDistributedFps,
|
||||
parseFps,
|
||||
} from "./core.types";
|
||||
|
||||
describe("parseFps — integer forms", () => {
|
||||
it("parses integer string '30' as { num: 30, den: 1 }", () => {
|
||||
@@ -156,3 +162,16 @@ describe("fpsToNumber + fpsToFfmpegArg + frame interval math", () => {
|
||||
expect(fpsToFfmpegArg({ num: 60000, den: 1001 })).toBe("60000/1001");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDistributedFps", () => {
|
||||
it("accepts exactly the integer rates the distributed renderer supports", () => {
|
||||
expect(DISTRIBUTED_FPS).toEqual([24, 25, 30, 60]);
|
||||
for (const n of DISTRIBUTED_FPS) expect(isDistributedFps(n)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects other rates, fractions and non-numbers", () => {
|
||||
for (const v of [0, 23.976, 29.97, 48, 50, 120, "30", null, undefined]) {
|
||||
expect(isDistributedFps(v)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
resolveExtractCacheDir,
|
||||
defaultExtractCacheDir,
|
||||
EXTRACT_CACHE_DIR_DISABLED_ALIASES,
|
||||
validateEngineConfigSnapshot,
|
||||
} from "./config.js";
|
||||
import type { EngineConfig } from "./config.js";
|
||||
import { isLowMemorySystem } from "./services/systemMemory.js";
|
||||
@@ -996,3 +997,15 @@ describe("resolveExtractCacheDir", () => {
|
||||
expect(() => resolveExtractCacheDir()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateEngineConfigSnapshot fps", () => {
|
||||
it("accepts every distributed fps, including 25", () => {
|
||||
for (const fps of [24, 25, 30, 60] as const) {
|
||||
expect(() => validateEngineConfigSnapshot({ ...resolveConfig(), fps })).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an fps outside the distributed set", () => {
|
||||
expect(() => validateEngineConfigSnapshot({ ...resolveConfig(), fps: 50 })).toThrow(/fps/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import { tmpdir } from "node:os";
|
||||
import { DISTRIBUTED_FPS, type DistributedFps } from "@hyperframes/core";
|
||||
import { chromeMajorCeiling } from "./services/chromeHostCeiling.js";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
@@ -23,7 +24,7 @@ import { DEFAULT_VP9_CPU_USED, normalizeVp9CpuUsed } from "./services/vp9Options
|
||||
*/
|
||||
export interface EngineConfig {
|
||||
// ── Rendering ────────────────────────────────────────────────────────
|
||||
fps: 24 | 30 | 60;
|
||||
fps: DistributedFps;
|
||||
quality: "draft" | "standard" | "high";
|
||||
format: "jpeg" | "png";
|
||||
jpegQuality: number;
|
||||
@@ -378,7 +379,7 @@ const POSITIVE_NUMBER_ENGINE_CONFIG_FIELDS = [
|
||||
] as const;
|
||||
|
||||
const ENUM_ENGINE_CONFIG_FIELDS = {
|
||||
fps: [24, 30, 60],
|
||||
fps: DISTRIBUTED_FPS,
|
||||
quality: ["draft", "standard", "high"],
|
||||
format: ["jpeg", "png"],
|
||||
browserGpuMode: ["software", "hardware", "auto"],
|
||||
|
||||
@@ -178,7 +178,7 @@ interface PlanResultBodyBase {
|
||||
PlanHash: string;
|
||||
ChunkCount: number;
|
||||
TotalFrames: number;
|
||||
Fps: 24 | 25 | 30 | 60;
|
||||
Fps: SerializableDistributedRenderConfig["fps"];
|
||||
Width: number;
|
||||
Height: number;
|
||||
Format: DistributedFormat;
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
* expected to match even when they render identical output.
|
||||
*/
|
||||
|
||||
import type { DistributedFps } from "@hyperframes/core";
|
||||
|
||||
export type PlanParityProtocol = "v1" | "v2";
|
||||
|
||||
export interface PlanParityRenderConfig {
|
||||
fps: 24 | 25 | 30 | 60;
|
||||
fps: DistributedFps;
|
||||
width: number;
|
||||
height: number;
|
||||
format: "mp4";
|
||||
|
||||
@@ -98,7 +98,7 @@ describe("parsePlanParityArgs()", () => {
|
||||
parsePlanParityArgs(["node", "plan-parity", "--v1-plan-size-cap-bytes", "-1"]),
|
||||
).toThrow(/positive integer/);
|
||||
expect(() => parsePlanParityArgs(["node", "plan-parity", "--fps", "50"])).toThrow(
|
||||
/24, 25, 30, or 60/,
|
||||
/must be one of 24, 25, 30, 60/,
|
||||
);
|
||||
expect(() =>
|
||||
parsePlanParityArgs(["node", "plan-parity", "--duration-tolerance-seconds", "not-a-number"]),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import process from "node:process";
|
||||
import { DISTRIBUTED_FPS, isDistributedFps } from "@hyperframes/core";
|
||||
import { analyzePlanParityDriverResult } from "./plan-parity-analysis.js";
|
||||
import {
|
||||
comparePlanParityMeasurements,
|
||||
@@ -155,8 +156,8 @@ export function parsePlanParityArgs(argv: string[]): PlanParityCliOptions {
|
||||
const fixtureDir = resolve(args.get("fixture") ?? "fixtures/plan-parity-visual-audio");
|
||||
const artifactsDir = resolve(args.get("artifacts-dir") ?? ".debug/plan-protocol-parity");
|
||||
const fps = parsePositiveInteger("fps", args.get("fps")) ?? 30;
|
||||
if (fps !== 24 && fps !== 25 && fps !== 30 && fps !== 60) {
|
||||
throw new Error("plan parity: --fps must be 24, 25, 30, or 60");
|
||||
if (!isDistributedFps(fps)) {
|
||||
throw new Error(`plan parity: --fps must be one of ${DISTRIBUTED_FPS.join(", ")}`);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
* capture jitter, so the harness can't use it as a per-test gate.
|
||||
*
|
||||
* Not every fixture can run in distributed-simulated mode. Distributed mode
|
||||
* refuses HDR mp4, NTSC framerates, and non-{24,30,60} fps at plan time.
|
||||
* refuses HDR mp4, NTSC framerates, and fps outside DISTRIBUTED_FPS at plan time.
|
||||
* Fixtures that don't meet the constraints are skipped — the harness logs
|
||||
* the reason and the fixture is treated as "passed (skipped)" in
|
||||
* distributed-simulated mode.
|
||||
@@ -33,7 +33,12 @@
|
||||
|
||||
import { existsSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { Fps } from "@hyperframes/core";
|
||||
import {
|
||||
DISTRIBUTED_FPS,
|
||||
type DistributedFps,
|
||||
type Fps,
|
||||
isDistributedFps,
|
||||
} from "@hyperframes/core";
|
||||
import { assemble, plan, renderChunk } from "./distributed.js";
|
||||
import { PLAN_AUDIO_RELATIVE_PATH, type DistributedFormat } from "./services/distributed/shared.js";
|
||||
|
||||
@@ -70,8 +75,8 @@ export type DistributedSupportResult = { supported: true } | { supported: false;
|
||||
* Decide whether a fixture's `renderConfig` is one the distributed pipeline
|
||||
* can actually run. Two hard gates:
|
||||
*
|
||||
* - fps must be `{ num: 24|30|60, den: 1 }`. `DistributedRenderConfig.fps`
|
||||
* accepts only the three integer values, and rationals like
|
||||
* - fps must be `{ num: <DISTRIBUTED_FPS member>, den: 1 }`. `DistributedRenderConfig.fps`
|
||||
* accepts only those integer values, and rationals like
|
||||
* `{ num: 30000, den: 1001 }` (NTSC) trip the type system at the call
|
||||
* site. We surface this gate in code rather than only in TS so the
|
||||
* harness can skip the fixture cleanly instead of throwing.
|
||||
@@ -92,10 +97,10 @@ export function checkDistributedSupport(renderConfig: {
|
||||
};
|
||||
}
|
||||
const fpsNum = renderConfig.fps.num;
|
||||
if (fpsNum !== 24 && fpsNum !== 25 && fpsNum !== 30 && fpsNum !== 60) {
|
||||
if (!isDistributedFps(fpsNum)) {
|
||||
return {
|
||||
supported: false,
|
||||
reason: `fps ${fpsNum} not in {24, 25, 30, 60} (DistributedRenderConfig.fps is a closed set)`,
|
||||
reason: `fps ${fpsNum} not in {${DISTRIBUTED_FPS.join(", ")}} (DistributedRenderConfig.fps is a closed set)`,
|
||||
};
|
||||
}
|
||||
if (renderConfig.hdr === true) {
|
||||
@@ -120,7 +125,7 @@ export interface RunDistributedSimulatedInput {
|
||||
/** Where to write the assembled final mp4 / mov / png-sequence directory. */
|
||||
renderedOutputPath: string;
|
||||
/** From the fixture's renderConfig — must pass `checkDistributedSupport`. */
|
||||
fps: 24 | 25 | 30 | 60;
|
||||
fps: DistributedFps;
|
||||
format: DistributedFormat;
|
||||
/**
|
||||
* Codec for `format: "mp4"`. Defaults to `"h264"`; pass `"h265"` to
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* type-check pass.
|
||||
*/
|
||||
|
||||
import type { DistributedFps } from "@hyperframes/core";
|
||||
import type { DistributedFormat } from "./services/distributed/shared.js";
|
||||
|
||||
/** Inputs for {@link runLambdaLocalRender}. Same contract as `runDistributedSimulatedRender`. */
|
||||
@@ -18,7 +19,7 @@ export interface RunLambdaLocalInput {
|
||||
projectDir: string;
|
||||
tempRoot: string;
|
||||
renderedOutputPath: string;
|
||||
fps: 24 | 25 | 30 | 60;
|
||||
fps: DistributedFps;
|
||||
/**
|
||||
* Width/height from the fixture's renderConfig. Forwarded directly to
|
||||
* the Lambda event so this mode catches drift if the handler ever
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
compareAudioEnvelopes,
|
||||
computeAudioResidualRmsDb,
|
||||
} from "./utils/audioRegression.js";
|
||||
import { parseFps, fpsToNumber } from "@hyperframes/core";
|
||||
import { type DistributedFps, parseFps, fpsToNumber } from "@hyperframes/core";
|
||||
import {
|
||||
checkDistributedSupport,
|
||||
type HarnessMode,
|
||||
@@ -179,7 +179,7 @@ type TestResult = {
|
||||
passed: boolean;
|
||||
/**
|
||||
* Set when `--mode=distributed-simulated` skips a fixture that the
|
||||
* distributed pipeline can't run (HDR, NTSC fps, fps∉{24,30,60}).
|
||||
* distributed pipeline can't run (HDR, NTSC fps, fps outside DISTRIBUTED_FPS).
|
||||
* `passed` is `true` for skipped fixtures — skipping is a clean outcome,
|
||||
* not a failure — but the summary distinguishes them.
|
||||
*/
|
||||
@@ -1114,10 +1114,10 @@ async function runTestSuite(
|
||||
result.skipped = { reason: support.reason };
|
||||
return result;
|
||||
}
|
||||
// `checkDistributedSupport` already narrowed fps to {24,30,60}; the
|
||||
// `checkDistributedSupport` already narrowed fps to DISTRIBUTED_FPS; the
|
||||
// cast surfaces that guarantee to TS. webm is now distributed-
|
||||
// supported via closed-GOP concat-copy, so the format passes through.
|
||||
const fpsNum = suite.meta.renderConfig.fps.num as 24 | 25 | 30 | 60;
|
||||
const fpsNum = suite.meta.renderConfig.fps.num as DistributedFps;
|
||||
const distributedInput = {
|
||||
projectDir: tempSrcDir,
|
||||
tempRoot,
|
||||
|
||||
@@ -127,7 +127,7 @@ describe("shard planner fixture discovery", () => {
|
||||
|
||||
it("only assigns distributed mode to fixtures the harness can actually run that way", () => {
|
||||
// The blocking gap: `checkDistributedSupport` refuses HDR, non-integer fps,
|
||||
// and fps outside {24,30,60}, and the harness records a refusal as
|
||||
// and fps outside DISTRIBUTED_FPS, and the harness records a refusal as
|
||||
// `passed: true` with `skipped`. Skipping was safe while in-process also
|
||||
// ran the fixture. It is not safe now — these fixtures run in distributed
|
||||
// mode and nowhere else, so a later `hdr: true` or fps edit would turn
|
||||
|
||||
@@ -5,7 +5,13 @@ import {
|
||||
type EngineConfig,
|
||||
type VideoFrameFormat,
|
||||
} from "@hyperframes/engine";
|
||||
import { VALID_CANVAS_RESOLUTIONS, type CanvasResolution, type Fps } from "@hyperframes/core";
|
||||
import {
|
||||
VALID_CANVAS_RESOLUTIONS,
|
||||
type CanvasResolution,
|
||||
type DistributedFps,
|
||||
type Fps,
|
||||
isDistributedFps,
|
||||
} from "@hyperframes/core";
|
||||
import type { ProducerLogger } from "./logger.js";
|
||||
import type { RenderConfig } from "./services/renderOrchestrator.js";
|
||||
import type { DistributedRenderConfig } from "./services/distributed/plan.js";
|
||||
@@ -262,9 +268,8 @@ export function renderConfigFromRequest(
|
||||
};
|
||||
}
|
||||
|
||||
function distributedFps(fps: Fps): 24 | 25 | 30 | 60 {
|
||||
if (fps.den === 1 && (fps.num === 24 || fps.num === 25 || fps.num === 30 || fps.num === 60))
|
||||
return fps.num;
|
||||
function distributedFps(fps: Fps): DistributedFps {
|
||||
if (fps.den === 1 && isDistributedFps(fps.num)) return fps.num;
|
||||
throw new Error(`Distributed render does not support fps ${fps.num}/${fps.den}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { extname, isAbsolute, join, relative, sep } from "node:path";
|
||||
import { type CanvasResolution, fpsToNumber } from "@hyperframes/core";
|
||||
import { type CanvasResolution, type DistributedFps, fpsToNumber } from "@hyperframes/core";
|
||||
import {
|
||||
type EngineConfig,
|
||||
type VideoFrameFormat,
|
||||
@@ -103,7 +103,7 @@ import {
|
||||
*/
|
||||
export interface DistributedRenderConfig {
|
||||
/** Integer frame rate. Distributed renders only accept integer fps; the in-process renderer's `Fps` rational handles NTSC. */
|
||||
fps: 24 | 25 | 30 | 60;
|
||||
fps: DistributedFps;
|
||||
width: number;
|
||||
height: number;
|
||||
/**
|
||||
@@ -271,7 +271,7 @@ export interface LocalExecutionPlan {
|
||||
executionPlanHash: string;
|
||||
chunkCount: number;
|
||||
totalFrames: number;
|
||||
fps: 24 | 25 | 30 | 60;
|
||||
fps: DistributedFps;
|
||||
width: number;
|
||||
height: number;
|
||||
format: DistributedFormat;
|
||||
@@ -307,7 +307,7 @@ export interface PlanResult {
|
||||
planHash: string;
|
||||
chunkCount: number;
|
||||
totalFrames: number;
|
||||
fps: 24 | 25 | 30 | 60;
|
||||
fps: DistributedFps;
|
||||
width: number;
|
||||
height: number;
|
||||
format: DistributedFormat;
|
||||
|
||||
@@ -699,7 +699,7 @@ describe("Plan v2 manifest", () => {
|
||||
const root = tempPath("hf-plan-v2-fps-50-");
|
||||
const v1 = createV1Plan(root, { fpsNum: 50 });
|
||||
expect(() => createPlanV2FromV1(v1, join(root, "v2"))).toThrow(
|
||||
"dimensions.fpsNum must be 24, 25, 30, or 60",
|
||||
"dimensions.fpsNum must be one of 24, 25, 30, 60",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import { createHash } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
||||
import { DISTRIBUTED_FPS, type DistributedFps, isDistributedFps } from "@hyperframes/core";
|
||||
import { createFrameLookupTable, type ExtractedFrames } from "@hyperframes/engine";
|
||||
import { recomputePlanHashFromPlanDir, type ChunkSliceJson } from "../render/stages/freezePlan.js";
|
||||
import { canonicalJsonStringify, sha256Hex } from "../render/stages/planHash.js";
|
||||
@@ -92,7 +93,7 @@ export interface PlanV2Manifest {
|
||||
readonly sourcePlanV1Hash: string;
|
||||
readonly chunkCount: number;
|
||||
readonly totalFrames: number;
|
||||
readonly fps: 24 | 25 | 30 | 60;
|
||||
readonly fps: DistributedFps;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
readonly format: DistributedFormat;
|
||||
@@ -120,7 +121,7 @@ export interface PlanV2Result {
|
||||
readonly sourcePlanV1Hash: string;
|
||||
readonly chunkCount: number;
|
||||
readonly totalFrames: number;
|
||||
readonly fps: 24 | 25 | 30 | 60;
|
||||
readonly fps: DistributedFps;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
readonly format: DistributedFormat;
|
||||
@@ -765,14 +766,16 @@ function readNonNegativeInteger(value: unknown, field: string): number {
|
||||
return value;
|
||||
}
|
||||
|
||||
function readSupportedFps(value: unknown): 24 | 25 | 30 | 60 {
|
||||
if (value !== 24 && value !== 25 && value !== 30 && value !== 60) {
|
||||
throw new PlanV2IntegrityError("dimensions.fpsNum must be 24, 25, 30, or 60");
|
||||
function readSupportedFps(value: unknown): DistributedFps {
|
||||
if (!isDistributedFps(value)) {
|
||||
throw new PlanV2IntegrityError(
|
||||
`dimensions.fpsNum must be one of ${DISTRIBUTED_FPS.join(", ")}`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readExecutionPlanFps(dimensions: Record<string, unknown>): 24 | 25 | 30 | 60 {
|
||||
function readExecutionPlanFps(dimensions: Record<string, unknown>): DistributedFps {
|
||||
const fpsDen = readPositiveInteger(dimensions.fpsDen, "dimensions.fpsDen");
|
||||
if (fpsDen !== 1) {
|
||||
throw new PlanV2IntegrityError("dimensions.fpsDen must be 1 for plan v2");
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
isVideoFrameFormat,
|
||||
validateEngineConfigSnapshot,
|
||||
} from "@hyperframes/engine";
|
||||
import { DISTRIBUTED_FPS, isDistributedFps } from "@hyperframes/core";
|
||||
import { type DistributedFormat } from "./shared.js";
|
||||
import { type DistributedRenderConfig } from "./plan.js";
|
||||
|
||||
@@ -48,7 +49,6 @@ export class InvalidConfigError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
const ALLOWED_FPS = [24, 25, 30, 60] as const;
|
||||
const ALLOWED_FORMATS = [
|
||||
"mp4",
|
||||
"mov",
|
||||
@@ -80,10 +80,10 @@ export function validateDistributedRenderConfig(
|
||||
throw new InvalidConfigError("config", "must be an object");
|
||||
}
|
||||
|
||||
if (!ALLOWED_FPS.includes(config.fps as 24 | 25 | 30 | 60)) {
|
||||
if (!isDistributedFps(config.fps)) {
|
||||
throw new InvalidConfigError(
|
||||
"config.fps",
|
||||
`must be one of ${ALLOWED_FPS.join(", ")}; got ${String(config.fps)}`,
|
||||
`must be one of ${DISTRIBUTED_FPS.join(", ")}; got ${String(config.fps)}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ of these gates are **skipped** with a clear log line (and counted as
|
||||
passing in the summary):
|
||||
|
||||
- `fps.den !== 1` — distributed mode is integer-fps only (no NTSC).
|
||||
- `fps.num ∉ {24, 25, 30, 60}` — closed set per `DistributedRenderConfig`.
|
||||
- `fps.num` not in `DISTRIBUTED_FPS` (`@hyperframes/core`) — closed set per `DistributedRenderConfig`.
|
||||
- `format === "webm"` — `plan()` refuses webm.
|
||||
- `hdr === true` — distributed mode is SDR-only at v1.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user