mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
feat(aws-lambda): support plan protocol v2 (#2789)
* feat(aws-lambda): support plan protocol v2 * fix(aws-lambda): align SAM v2 terminal errors
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { normalizeFfprobeMetadata, parseCanonicalFrameHashes } from "./plan-parity-analysis.js";
|
||||
|
||||
describe("parseCanonicalFrameHashes()", () => {
|
||||
it("returns ordered SHA-256 hashes from ffmpeg framemd5 output", () => {
|
||||
const first = "a".repeat(64);
|
||||
const second = "b".repeat(64);
|
||||
expect(
|
||||
parseCanonicalFrameHashes(
|
||||
[
|
||||
"#format: frame checksums",
|
||||
"#hash: SHA256",
|
||||
`0, 0, 0, 1, 16, ${first}`,
|
||||
`0, 1, 1, 1, 16, ${second}`,
|
||||
"",
|
||||
].join("\n"),
|
||||
),
|
||||
).toEqual([first, second]);
|
||||
});
|
||||
|
||||
it("refuses malformed or empty output", () => {
|
||||
expect(() => parseCanonicalFrameHashes("# only comments")).toThrow(/no decoded/);
|
||||
expect(() => parseCanonicalFrameHashes("0, 0, not-a-sha")).toThrow(/unexpected/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeFfprobeMetadata()", () => {
|
||||
it("normalizes relevant video, audio, and duration fields", () => {
|
||||
expect(
|
||||
normalizeFfprobeMetadata({
|
||||
streams: [
|
||||
{
|
||||
codec_type: "video",
|
||||
codec_name: "h264",
|
||||
width: 320,
|
||||
height: 180,
|
||||
pix_fmt: "yuv420p",
|
||||
avg_frame_rate: "30/1",
|
||||
r_frame_rate: "30/1",
|
||||
nb_frames: "60",
|
||||
color_space: "bt709",
|
||||
color_transfer: "bt709",
|
||||
color_primaries: "bt709",
|
||||
},
|
||||
{
|
||||
codec_type: "audio",
|
||||
codec_name: "aac",
|
||||
sample_rate: "48000",
|
||||
channels: 2,
|
||||
channel_layout: "stereo",
|
||||
},
|
||||
],
|
||||
format: { duration: "2.000000" },
|
||||
}),
|
||||
).toEqual({
|
||||
video: {
|
||||
codecName: "h264",
|
||||
width: 320,
|
||||
height: 180,
|
||||
pixelFormat: "yuv420p",
|
||||
averageFrameRate: "30/1",
|
||||
realFrameRate: "30/1",
|
||||
frameCount: 60,
|
||||
colorSpace: "bt709",
|
||||
colorTransfer: "bt709",
|
||||
colorPrimaries: "bt709",
|
||||
},
|
||||
audio: {
|
||||
codecName: "aac",
|
||||
sampleRate: 48_000,
|
||||
channels: 2,
|
||||
channelLayout: "stereo",
|
||||
},
|
||||
durationSeconds: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses stream duration and maps N/A values to null", () => {
|
||||
expect(
|
||||
normalizeFfprobeMetadata({
|
||||
streams: [
|
||||
{
|
||||
codec_type: "video",
|
||||
codec_name: "rawvideo",
|
||||
width: 1,
|
||||
height: 1,
|
||||
nb_frames: "N/A",
|
||||
duration: "0.5",
|
||||
},
|
||||
],
|
||||
format: {},
|
||||
}),
|
||||
).toMatchObject({
|
||||
video: { frameCount: null },
|
||||
audio: null,
|
||||
durationSeconds: 0.5,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { createReadStream, readdirSync, statSync, type Dirent } from "node:fs";
|
||||
import { relative, resolve } from "node:path";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import type {
|
||||
PlanParityDriverResult,
|
||||
PlanParityMediaMeasurement,
|
||||
PlanParityMeasurement,
|
||||
PlanParityStreamMetadata,
|
||||
} from "./plan-parity-contract.js";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
function isRecord(value: unknown): value is JsonRecord {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | null {
|
||||
return typeof value === "string" && value.length > 0 && value !== "N/A" ? value : null;
|
||||
}
|
||||
|
||||
function numberValue(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value !== "string" || value.length === 0 || value === "N/A") return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function integerValue(value: unknown): number | null {
|
||||
const parsed = numberValue(value);
|
||||
return parsed === null || !Number.isInteger(parsed) ? null : parsed;
|
||||
}
|
||||
|
||||
function requireCommandSuccess(
|
||||
command: string,
|
||||
args: string[],
|
||||
maxBuffer = 64 * 1024 * 1024,
|
||||
): Buffer {
|
||||
const result = spawnSync(command, args, {
|
||||
encoding: "buffer",
|
||||
maxBuffer,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`${command} exited ${result.status}: ${result.stderr.toString("utf-8").trim()}`,
|
||||
);
|
||||
}
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
export function parseCanonicalFrameHashes(framemd5: string): string[] {
|
||||
const hashes: string[] = [];
|
||||
for (const line of framemd5.split(/\r?\n/u)) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.length === 0 || trimmed.startsWith("#")) continue;
|
||||
const columns = trimmed.split(",").map((column) => column.trim());
|
||||
const hash = columns.at(-1);
|
||||
if (!hash || !/^[a-f0-9]{64}$/iu.test(hash)) {
|
||||
throw new Error(`unexpected framemd5 row: ${line}`);
|
||||
}
|
||||
hashes.push(hash.toLowerCase());
|
||||
}
|
||||
if (hashes.length === 0) {
|
||||
throw new Error("ffmpeg produced no decoded video frame hashes");
|
||||
}
|
||||
return hashes;
|
||||
}
|
||||
|
||||
export function normalizeFfprobeMetadata(value: unknown): PlanParityStreamMetadata {
|
||||
if (!isRecord(value)) throw new Error("ffprobe output must be a JSON object");
|
||||
const rawStreams = value.streams;
|
||||
if (!Array.isArray(rawStreams)) throw new Error("ffprobe output has no streams array");
|
||||
const streams = rawStreams.filter(isRecord);
|
||||
const video = streams.find((stream) => stream.codec_type === "video");
|
||||
const audio = streams.find((stream) => stream.codec_type === "audio");
|
||||
const format = isRecord(value.format) ? value.format : {};
|
||||
const durationSeconds =
|
||||
numberValue(format.duration) ??
|
||||
Math.max(0, ...streams.map((stream) => numberValue(stream.duration) ?? 0));
|
||||
|
||||
return {
|
||||
video: video
|
||||
? {
|
||||
codecName: stringValue(video.codec_name),
|
||||
width: integerValue(video.width) ?? 0,
|
||||
height: integerValue(video.height) ?? 0,
|
||||
pixelFormat: stringValue(video.pix_fmt),
|
||||
averageFrameRate: stringValue(video.avg_frame_rate),
|
||||
realFrameRate: stringValue(video.r_frame_rate),
|
||||
frameCount: integerValue(video.nb_frames),
|
||||
colorSpace: stringValue(video.color_space),
|
||||
colorTransfer: stringValue(video.color_transfer),
|
||||
colorPrimaries: stringValue(video.color_primaries),
|
||||
}
|
||||
: null,
|
||||
audio: audio
|
||||
? {
|
||||
codecName: stringValue(audio.codec_name),
|
||||
sampleRate: integerValue(audio.sample_rate),
|
||||
channels: integerValue(audio.channels),
|
||||
channelLayout: stringValue(audio.channel_layout),
|
||||
}
|
||||
: null,
|
||||
durationSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
function probeMetadata(outputPath: string): PlanParityStreamMetadata {
|
||||
const bytes = requireCommandSuccess("ffprobe", [
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
[
|
||||
"format=duration",
|
||||
"stream=codec_type,codec_name,width,height,pix_fmt,avg_frame_rate,r_frame_rate,nb_frames",
|
||||
"stream=color_space,color_transfer,color_primaries,sample_rate,channels,channel_layout,duration",
|
||||
].join(":"),
|
||||
"-of",
|
||||
"json",
|
||||
outputPath,
|
||||
]);
|
||||
return normalizeFfprobeMetadata(JSON.parse(bytes.toString("utf-8")) as unknown);
|
||||
}
|
||||
|
||||
function canonicalFrameHashes(outputPath: string): string[] {
|
||||
const bytes = requireCommandSuccess(
|
||||
"ffmpeg",
|
||||
[
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-i",
|
||||
outputPath,
|
||||
"-map",
|
||||
"0:v:0",
|
||||
"-an",
|
||||
"-pix_fmt",
|
||||
"rgba",
|
||||
"-hash",
|
||||
"sha256",
|
||||
"-f",
|
||||
"framemd5",
|
||||
"-",
|
||||
],
|
||||
256 * 1024 * 1024,
|
||||
);
|
||||
return parseCanonicalFrameHashes(bytes.toString("utf-8"));
|
||||
}
|
||||
|
||||
async function hashFile(path: string): Promise<{ sha256: string; bytes: number }> {
|
||||
const hash = createHash("sha256");
|
||||
let bytes = 0;
|
||||
for await (const chunk of createReadStream(path)) {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
hash.update(buffer);
|
||||
bytes += buffer.length;
|
||||
}
|
||||
return { sha256: hash.digest("hex"), bytes };
|
||||
}
|
||||
|
||||
function walkFiles(root: string, current = root): Array<{ absolute: string; relative: string }> {
|
||||
const entries: Dirent[] = readdirSync(current, { withFileTypes: true }).sort((left, right) =>
|
||||
left.name.localeCompare(right.name),
|
||||
);
|
||||
const files: Array<{ absolute: string; relative: string }> = [];
|
||||
for (const entry of entries) {
|
||||
const absolute = resolve(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...walkFiles(root, absolute));
|
||||
} else if (entry.isFile()) {
|
||||
files.push({ absolute, relative: relative(root, absolute).replaceAll("\\", "/") });
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
async function hashPath(path: string): Promise<{ sha256: string; bytes: number }> {
|
||||
const stat = statSync(path);
|
||||
if (stat.isFile()) return hashFile(path);
|
||||
if (!stat.isDirectory()) throw new Error(`cannot hash non-file artifact ${path}`);
|
||||
|
||||
const hash = createHash("sha256");
|
||||
let bytes = 0;
|
||||
for (const file of walkFiles(path)) {
|
||||
const digest = await hashFile(file.absolute);
|
||||
hash.update(file.relative);
|
||||
hash.update("\0");
|
||||
hash.update(digest.sha256);
|
||||
hash.update("\0");
|
||||
bytes += digest.bytes;
|
||||
}
|
||||
return { sha256: hash.digest("hex"), bytes };
|
||||
}
|
||||
|
||||
async function canonicalPcmAudio(
|
||||
outputPath: string,
|
||||
): Promise<PlanParityMediaMeasurement["pcmAudio"]> {
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
const child = spawn(
|
||||
"ffmpeg",
|
||||
[
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-i",
|
||||
outputPath,
|
||||
"-map",
|
||||
"0:a:0",
|
||||
"-vn",
|
||||
"-ac",
|
||||
"2",
|
||||
"-ar",
|
||||
"48000",
|
||||
"-f",
|
||||
"s16le",
|
||||
"-",
|
||||
],
|
||||
{ stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
const hash = createHash("sha256");
|
||||
const stderr: Buffer[] = [];
|
||||
let bytes = 0;
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
hash.update(chunk);
|
||||
bytes += chunk.length;
|
||||
});
|
||||
child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk));
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
const message = Buffer.concat(stderr).toString("utf-8");
|
||||
// A video-only fixture is a valid parity input. ffmpeg uses this
|
||||
// wording when the optional audio map has no match.
|
||||
if (/matches no streams|does not contain any stream/iu.test(message)) {
|
||||
resolvePromise(null);
|
||||
return;
|
||||
}
|
||||
reject(new Error(`ffmpeg PCM decode exited ${code}: ${message.trim()}`));
|
||||
return;
|
||||
}
|
||||
if (bytes % 4 !== 0) {
|
||||
reject(new Error(`canonical stereo s16le byte count ${bytes} is not divisible by 4`));
|
||||
return;
|
||||
}
|
||||
resolvePromise({
|
||||
sha256: hash.digest("hex"),
|
||||
sampleCount: bytes / 4,
|
||||
bytes,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function analyzePlanParityDriverResult(
|
||||
driverName: string,
|
||||
result: PlanParityDriverResult,
|
||||
): Promise<PlanParityMeasurement> {
|
||||
const output = await hashPath(result.outputPath);
|
||||
const metadata = probeMetadata(result.outputPath);
|
||||
const [pcmAudio, frameSha256] = await Promise.all([
|
||||
metadata.audio ? canonicalPcmAudio(result.outputPath) : Promise.resolve(null),
|
||||
Promise.resolve(canonicalFrameHashes(result.outputPath)),
|
||||
]);
|
||||
|
||||
const chunks = [];
|
||||
for (const chunk of [...result.chunks].sort((left, right) => left.index - right.index)) {
|
||||
const digest = await hashPath(chunk.path);
|
||||
if (
|
||||
chunk.reportedSha256 !== undefined &&
|
||||
chunk.reportedSha256.toLowerCase() !== digest.sha256
|
||||
) {
|
||||
throw new Error(
|
||||
`chunk ${chunk.index} digest mismatch: adapter reported ${chunk.reportedSha256}, measured ${digest.sha256}`,
|
||||
);
|
||||
}
|
||||
chunks.push({
|
||||
index: chunk.index,
|
||||
sha256: digest.sha256,
|
||||
bytes: digest.bytes,
|
||||
reportedSha256: chunk.reportedSha256,
|
||||
});
|
||||
}
|
||||
const downloaded = result.transferBytes.downloaded;
|
||||
const uploaded = result.transferBytes.uploaded;
|
||||
|
||||
return {
|
||||
protocol: result.protocol,
|
||||
driver: driverName,
|
||||
media: {
|
||||
outputSha256: output.sha256,
|
||||
outputBytes: output.bytes,
|
||||
frameSha256,
|
||||
pcmAudio,
|
||||
metadata,
|
||||
},
|
||||
chunks,
|
||||
transferBytes: {
|
||||
downloaded,
|
||||
uploaded,
|
||||
total: downloaded + uploaded,
|
||||
},
|
||||
peakMaterializedBytes: result.peakMaterializedBytes,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
comparePlanParityMeasurements,
|
||||
type PlanParityMeasurement,
|
||||
type PlanParityProtocol,
|
||||
} from "./plan-parity-contract.js";
|
||||
|
||||
function measurement(
|
||||
protocol: PlanParityProtocol,
|
||||
overrides: {
|
||||
frames?: string[];
|
||||
pcmSha?: string;
|
||||
duration?: number;
|
||||
outputSha?: string;
|
||||
outputBytes?: number;
|
||||
chunkSha?: string;
|
||||
transferBytes?: number;
|
||||
peakBytes?: number;
|
||||
} = {},
|
||||
): PlanParityMeasurement {
|
||||
const transferBytes = overrides.transferBytes ?? 100;
|
||||
return {
|
||||
protocol,
|
||||
driver: "test",
|
||||
media: {
|
||||
outputSha256: overrides.outputSha ?? "encoded",
|
||||
outputBytes: overrides.outputBytes ?? 1000,
|
||||
frameSha256: overrides.frames ?? ["frame-0", "frame-1"],
|
||||
pcmAudio: {
|
||||
sha256: overrides.pcmSha ?? "pcm",
|
||||
sampleCount: 96_000,
|
||||
bytes: 384_000,
|
||||
},
|
||||
metadata: {
|
||||
video: {
|
||||
codecName: "h264",
|
||||
width: 320,
|
||||
height: 180,
|
||||
pixelFormat: "yuv420p",
|
||||
averageFrameRate: "30/1",
|
||||
realFrameRate: "30/1",
|
||||
frameCount: 60,
|
||||
colorSpace: "bt709",
|
||||
colorTransfer: "bt709",
|
||||
colorPrimaries: "bt709",
|
||||
},
|
||||
audio: {
|
||||
codecName: "aac",
|
||||
sampleRate: 48_000,
|
||||
channels: 2,
|
||||
channelLayout: "stereo",
|
||||
},
|
||||
durationSeconds: overrides.duration ?? 2,
|
||||
},
|
||||
},
|
||||
chunks: [
|
||||
{
|
||||
index: 0,
|
||||
sha256: overrides.chunkSha ?? "chunk",
|
||||
bytes: 500,
|
||||
},
|
||||
],
|
||||
transferBytes: {
|
||||
downloaded: Math.floor(transferBytes / 2),
|
||||
uploaded: Math.ceil(transferBytes / 2),
|
||||
total: transferBytes,
|
||||
},
|
||||
peakMaterializedBytes: overrides.peakBytes ?? 2000,
|
||||
};
|
||||
}
|
||||
|
||||
describe("comparePlanParityMeasurements()", () => {
|
||||
it("accepts semantic parity while only reporting encoded and transport differences", () => {
|
||||
const result = comparePlanParityMeasurements(
|
||||
measurement("v1", {
|
||||
outputSha: "container-v1",
|
||||
outputBytes: 1000,
|
||||
transferBytes: 10_000,
|
||||
peakBytes: 20_000,
|
||||
}),
|
||||
measurement("v2", {
|
||||
outputSha: "container-v2",
|
||||
outputBytes: 900,
|
||||
transferBytes: 5000,
|
||||
peakBytes: 6000,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.passed).toBe(true);
|
||||
expect(result.checks.find((entry) => entry.name === "encoded-output")?.detail).toContain(
|
||||
"(reported)",
|
||||
);
|
||||
expect(result.checks.find((entry) => entry.name === "transfer-bytes")?.detail).toContain(
|
||||
"v1=10000",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails on decoded frame, PCM, metadata, duration, or chunk drift", () => {
|
||||
const cases: Array<[string, PlanParityMeasurement]> = [
|
||||
["decoded-video-frames", measurement("v2", { frames: ["different"] })],
|
||||
["canonical-pcm-audio", measurement("v2", { pcmSha: "different" })],
|
||||
[
|
||||
"ffprobe-stream-metadata",
|
||||
{
|
||||
...measurement("v2"),
|
||||
media: {
|
||||
...measurement("v2").media,
|
||||
metadata: {
|
||||
...measurement("v2").media.metadata,
|
||||
video: {
|
||||
...measurement("v2").media.metadata.video!,
|
||||
width: 640,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
["ffprobe-duration", measurement("v2", { duration: 2.1 })],
|
||||
["chunk-hashes", measurement("v2", { chunkSha: "different" })],
|
||||
];
|
||||
|
||||
for (const [expectedFailure, v2] of cases) {
|
||||
const result = comparePlanParityMeasurements(measurement("v1"), v2);
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.checks.find((entry) => entry.name === expectedFailure)?.passed).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("can enforce encoded equality and v2 resource ceilings", () => {
|
||||
const result = comparePlanParityMeasurements(
|
||||
measurement("v1"),
|
||||
measurement("v2", {
|
||||
outputSha: "different",
|
||||
transferBytes: 101,
|
||||
peakBytes: 201,
|
||||
}),
|
||||
{
|
||||
requireEncodedOutputEquality: true,
|
||||
maxV2TransferBytes: 100,
|
||||
maxV2PeakMaterializedBytes: 200,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.checks.filter((entry) => !entry.passed).map((entry) => entry.name)).toEqual([
|
||||
"encoded-output",
|
||||
"transfer-bytes",
|
||||
"peak-materialized-working-set",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects reversed or same-protocol comparisons", () => {
|
||||
expect(() => comparePlanParityMeasurements(measurement("v2"), measurement("v1"))).toThrow(
|
||||
/ordered v1\/v2/,
|
||||
);
|
||||
expect(() => comparePlanParityMeasurements(measurement("v1"), measurement("v1"))).toThrow(
|
||||
/ordered v1\/v2/,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not expose or compare planHash", () => {
|
||||
const result = comparePlanParityMeasurements(measurement("v1"), measurement("v2"));
|
||||
expect(JSON.stringify(result)).not.toContain("planHash");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* Protocol-neutral contract for comparing distributed render plans.
|
||||
*
|
||||
* The comparator intentionally has no `planHash` field. v1 and v2 use
|
||||
* different artifact layouts and hash schemas, so their plan hashes are not
|
||||
* expected to match even when they render identical output.
|
||||
*/
|
||||
|
||||
export type PlanParityProtocol = "v1" | "v2";
|
||||
|
||||
export interface PlanParityRenderConfig {
|
||||
fps: 24 | 30 | 60;
|
||||
width: number;
|
||||
height: number;
|
||||
format: "mp4";
|
||||
chunkSize?: number;
|
||||
maxParallelChunks?: number;
|
||||
}
|
||||
|
||||
export interface PlanParityDriverInput {
|
||||
protocol: PlanParityProtocol;
|
||||
projectDir: string;
|
||||
outputDir: string;
|
||||
renderConfig: PlanParityRenderConfig;
|
||||
/**
|
||||
* Test-only plan limit. The Lambda-local driver forwards this to v1 as
|
||||
* `planDirSizeLimitBytes`; v2 ignores it because it does not materialize a
|
||||
* monolithic plan directory.
|
||||
*/
|
||||
planSizeCapBytes?: number;
|
||||
}
|
||||
|
||||
export interface PlanParityChunkArtifact {
|
||||
index: number;
|
||||
path: string;
|
||||
/** Adapter-reported digest, retained for diagnostics. */
|
||||
reportedSha256?: string;
|
||||
}
|
||||
|
||||
export interface PlanParityDriverResult {
|
||||
protocol: PlanParityProtocol;
|
||||
outputPath: string;
|
||||
chunks: PlanParityChunkArtifact[];
|
||||
transferBytes: {
|
||||
downloaded: number;
|
||||
uploaded: number;
|
||||
};
|
||||
/**
|
||||
* Maximum materialized bytes observed in the worker scratch directory.
|
||||
* This is distinct from S3 storage and from process RSS.
|
||||
*/
|
||||
peakMaterializedBytes: number;
|
||||
}
|
||||
|
||||
export interface PlanParityDriver {
|
||||
readonly name: string;
|
||||
render(input: PlanParityDriverInput): Promise<PlanParityDriverResult>;
|
||||
}
|
||||
|
||||
export interface PlanParityStreamMetadata {
|
||||
video: {
|
||||
codecName: string | null;
|
||||
width: number;
|
||||
height: number;
|
||||
pixelFormat: string | null;
|
||||
averageFrameRate: string | null;
|
||||
realFrameRate: string | null;
|
||||
frameCount: number | null;
|
||||
colorSpace: string | null;
|
||||
colorTransfer: string | null;
|
||||
colorPrimaries: string | null;
|
||||
} | null;
|
||||
audio: {
|
||||
codecName: string | null;
|
||||
sampleRate: number | null;
|
||||
channels: number | null;
|
||||
channelLayout: string | null;
|
||||
} | null;
|
||||
durationSeconds: number;
|
||||
}
|
||||
|
||||
export interface PlanParityMediaMeasurement {
|
||||
outputSha256: string;
|
||||
outputBytes: number;
|
||||
frameSha256: string[];
|
||||
pcmAudio: {
|
||||
sha256: string;
|
||||
/**
|
||||
* Interleaved audio frames after canonical decoding to signed 16-bit,
|
||||
* 48 kHz, stereo PCM. One sample frame contains two channel samples.
|
||||
*/
|
||||
sampleCount: number;
|
||||
bytes: number;
|
||||
} | null;
|
||||
metadata: PlanParityStreamMetadata;
|
||||
}
|
||||
|
||||
export interface PlanParityChunkMeasurement {
|
||||
index: number;
|
||||
sha256: string;
|
||||
bytes: number;
|
||||
reportedSha256?: string;
|
||||
}
|
||||
|
||||
export interface PlanParityMeasurement {
|
||||
protocol: PlanParityProtocol;
|
||||
driver: string;
|
||||
media: PlanParityMediaMeasurement;
|
||||
chunks: PlanParityChunkMeasurement[];
|
||||
transferBytes: {
|
||||
downloaded: number;
|
||||
uploaded: number;
|
||||
total: number;
|
||||
};
|
||||
peakMaterializedBytes: number;
|
||||
}
|
||||
|
||||
export interface PlanParityComparisonOptions {
|
||||
/** ffprobe duration tolerance. Defaults to 1 ms. */
|
||||
durationToleranceSeconds?: number;
|
||||
/**
|
||||
* Encoded containers can contain non-semantic metadata. Default false:
|
||||
* report output digest/size without requiring byte-identical containers.
|
||||
*/
|
||||
requireEncodedOutputEquality?: boolean;
|
||||
/** Optional ceiling applied independently to the v2 run. */
|
||||
maxV2TransferBytes?: number;
|
||||
/** Optional ceiling applied independently to the v2 run. */
|
||||
maxV2PeakMaterializedBytes?: number;
|
||||
}
|
||||
|
||||
export interface PlanParityCheck {
|
||||
name: string;
|
||||
passed: boolean;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface PlanParityComparison {
|
||||
passed: boolean;
|
||||
checks: PlanParityCheck[];
|
||||
v1: PlanParityMeasurement;
|
||||
v2: PlanParityMeasurement;
|
||||
}
|
||||
|
||||
function check(name: string, passed: boolean, detail: string): PlanParityCheck {
|
||||
return { name, passed, detail };
|
||||
}
|
||||
|
||||
function equalJson(left: unknown, right: unknown): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
function comparableMetadata(
|
||||
metadata: PlanParityStreamMetadata,
|
||||
): Omit<PlanParityStreamMetadata, "durationSeconds"> {
|
||||
return {
|
||||
video: metadata.video,
|
||||
audio: metadata.audio,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare semantic render output plus transport/resource measurements.
|
||||
*
|
||||
* `outputBytes`, transfer bytes, and working-set bytes are always surfaced.
|
||||
* They are not equality gates by default: v2 is expected to change artifact
|
||||
* packaging, and encoded containers may carry non-semantic differences.
|
||||
*/
|
||||
// This function is intentionally an exhaustive, flat contract checklist. Each
|
||||
// branch emits a distinct diagnostic needed to root-cause parity failures.
|
||||
// fallow-ignore-next-line complexity
|
||||
export function comparePlanParityMeasurements(
|
||||
v1: PlanParityMeasurement,
|
||||
v2: PlanParityMeasurement,
|
||||
options: PlanParityComparisonOptions = {},
|
||||
): PlanParityComparison {
|
||||
if (v1.protocol !== "v1" || v2.protocol !== "v2") {
|
||||
throw new Error(
|
||||
`plan parity requires ordered v1/v2 measurements (got ${v1.protocol}/${v2.protocol})`,
|
||||
);
|
||||
}
|
||||
|
||||
const durationToleranceSeconds = options.durationToleranceSeconds ?? 0.001;
|
||||
const durationDelta = Math.abs(
|
||||
v1.media.metadata.durationSeconds - v2.media.metadata.durationSeconds,
|
||||
);
|
||||
const checks: PlanParityCheck[] = [
|
||||
check(
|
||||
"decoded-video-frames",
|
||||
equalJson(v1.media.frameSha256, v2.media.frameSha256),
|
||||
`v1=${v1.media.frameSha256.length} frames, v2=${v2.media.frameSha256.length} frames`,
|
||||
),
|
||||
check(
|
||||
"canonical-pcm-audio",
|
||||
equalJson(v1.media.pcmAudio, v2.media.pcmAudio),
|
||||
v1.media.pcmAudio && v2.media.pcmAudio
|
||||
? `v1=${v1.media.pcmAudio.sampleCount} samples, v2=${v2.media.pcmAudio.sampleCount} samples`
|
||||
: `v1=${v1.media.pcmAudio ? "present" : "none"}, v2=${v2.media.pcmAudio ? "present" : "none"}`,
|
||||
),
|
||||
check(
|
||||
"ffprobe-stream-metadata",
|
||||
equalJson(comparableMetadata(v1.media.metadata), comparableMetadata(v2.media.metadata)),
|
||||
"normalized video/audio stream metadata",
|
||||
),
|
||||
check(
|
||||
"ffprobe-duration",
|
||||
durationDelta <= durationToleranceSeconds,
|
||||
`delta=${durationDelta.toFixed(6)}s, tolerance=${durationToleranceSeconds.toFixed(6)}s`,
|
||||
),
|
||||
check(
|
||||
"chunk-hashes",
|
||||
equalJson(
|
||||
v1.chunks.map(({ index, sha256, bytes }) => ({ index, sha256, bytes })),
|
||||
v2.chunks.map(({ index, sha256, bytes }) => ({ index, sha256, bytes })),
|
||||
),
|
||||
`v1=${v1.chunks.length} chunks/${v1.chunks.reduce((sum, chunk) => sum + chunk.bytes, 0)} bytes, ` +
|
||||
`v2=${v2.chunks.length} chunks/${v2.chunks.reduce((sum, chunk) => sum + chunk.bytes, 0)} bytes`,
|
||||
),
|
||||
check(
|
||||
"encoded-output",
|
||||
options.requireEncodedOutputEquality !== true ||
|
||||
(v1.media.outputSha256 === v2.media.outputSha256 &&
|
||||
v1.media.outputBytes === v2.media.outputBytes),
|
||||
`v1=${v1.media.outputBytes} bytes/${v1.media.outputSha256}, ` +
|
||||
`v2=${v2.media.outputBytes} bytes/${v2.media.outputSha256}` +
|
||||
(options.requireEncodedOutputEquality === true ? " (strict)" : " (reported)"),
|
||||
),
|
||||
check(
|
||||
"transfer-bytes",
|
||||
options.maxV2TransferBytes === undefined ||
|
||||
v2.transferBytes.total <= options.maxV2TransferBytes,
|
||||
`v1=${v1.transferBytes.total} bytes, v2=${v2.transferBytes.total} bytes` +
|
||||
(options.maxV2TransferBytes === undefined
|
||||
? " (reported)"
|
||||
: `, v2 limit=${options.maxV2TransferBytes}`),
|
||||
),
|
||||
check(
|
||||
"peak-materialized-working-set",
|
||||
options.maxV2PeakMaterializedBytes === undefined ||
|
||||
v2.peakMaterializedBytes <= options.maxV2PeakMaterializedBytes,
|
||||
`v1=${v1.peakMaterializedBytes} bytes, v2=${v2.peakMaterializedBytes} bytes` +
|
||||
(options.maxV2PeakMaterializedBytes === undefined
|
||||
? " (reported)"
|
||||
: `, v2 limit=${options.maxV2PeakMaterializedBytes}`),
|
||||
),
|
||||
];
|
||||
|
||||
return {
|
||||
passed: checks.every((entry) => entry.passed),
|
||||
checks,
|
||||
v1,
|
||||
v2,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import {
|
||||
generatePressureBytes,
|
||||
generateToneWav,
|
||||
preparePlanParityFixture,
|
||||
} from "./plan-parity-fixture.js";
|
||||
|
||||
const cleanup: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const path of cleanup.splice(0)) {
|
||||
rmSync(path, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("plan parity generated fixtures", () => {
|
||||
it("generates a valid deterministic mono PCM WAV", () => {
|
||||
const config = { durationSeconds: 0.25, frequencyHz: 440, sampleRate: 48_000 };
|
||||
const first = generateToneWav(config);
|
||||
const second = generateToneWav(config);
|
||||
expect(first.equals(second)).toBe(true);
|
||||
expect(first.subarray(0, 4).toString("ascii")).toBe("RIFF");
|
||||
expect(first.subarray(8, 12).toString("ascii")).toBe("WAVE");
|
||||
expect(first.readUInt32LE(24)).toBe(48_000);
|
||||
expect(first.readUInt32LE(40)).toBe(24_000);
|
||||
expect(first.length).toBe(24_044);
|
||||
});
|
||||
|
||||
it("generates deterministic size-pressure bytes that vary with the seed", () => {
|
||||
const first = generatePressureBytes(65_536, 123);
|
||||
const again = generatePressureBytes(65_536, 123);
|
||||
const other = generatePressureBytes(65_536, 124);
|
||||
expect(first.equals(again)).toBe(true);
|
||||
expect(first.equals(other)).toBe(false);
|
||||
expect(new Set(first).size).toBeGreaterThan(240);
|
||||
});
|
||||
|
||||
it("materializes the checked-in visual/audio fixture", () => {
|
||||
const target = mkdtempSync(join(tmpdir(), "hf-plan-parity-fixture-"));
|
||||
cleanup.push(target);
|
||||
preparePlanParityFixture(
|
||||
join(import.meta.dir, "..", "fixtures", "plan-parity-visual-audio"),
|
||||
target,
|
||||
);
|
||||
const tone = readFileSync(join(target, "assets", "tone.wav"));
|
||||
expect(tone.subarray(0, 4).toString("ascii")).toBe("RIFF");
|
||||
expect(readFileSync(join(target, "index.html"), "utf-8")).toContain("assets/tone.wav");
|
||||
});
|
||||
|
||||
it("materializes a small compression-resistant pressure payload", () => {
|
||||
const target = mkdtempSync(join(tmpdir(), "hf-plan-parity-pressure-"));
|
||||
cleanup.push(target);
|
||||
preparePlanParityFixture(
|
||||
join(import.meta.dir, "..", "fixtures", "plan-parity-size-pressure"),
|
||||
target,
|
||||
);
|
||||
const pressurePath = join(target, "unused-pressure.bin");
|
||||
expect(statSync(pressurePath).size).toBe(65_536);
|
||||
expect(createHash("sha256").update(readFileSync(pressurePath)).digest("hex")).toBe(
|
||||
"0e5f00e17597a73cf7a273d772456db0544959ed19a437954fd800a78447f468",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const FIXTURE_CONFIG_FILE = ".plan-parity-fixture.json";
|
||||
|
||||
interface GeneratedAudioConfig {
|
||||
path: string;
|
||||
durationSeconds: number;
|
||||
frequencyHz: number;
|
||||
sampleRate: number;
|
||||
}
|
||||
|
||||
interface GeneratedPressureFileConfig {
|
||||
path: string;
|
||||
bytes: number;
|
||||
seed: number;
|
||||
}
|
||||
|
||||
interface PlanParityFixtureConfig {
|
||||
generatedAudio?: GeneratedAudioConfig;
|
||||
generatedPressureFile?: GeneratedPressureFileConfig;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function positiveNumber(record: Record<string, unknown>, key: string): number {
|
||||
const value = record[key];
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
||||
throw new Error(`${FIXTURE_CONFIG_FILE}: ${key} must be a positive number`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function relativePath(record: Record<string, unknown>, key: string): string {
|
||||
const value = record[key];
|
||||
if (
|
||||
typeof value !== "string" ||
|
||||
value.length === 0 ||
|
||||
value.startsWith("/") ||
|
||||
value.split(/[\\/]/u).includes("..")
|
||||
) {
|
||||
throw new Error(`${FIXTURE_CONFIG_FILE}: ${key} must be a safe relative path`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseGeneratedAudio(value: unknown): GeneratedAudioConfig | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (!isRecord(value)) throw new Error(`${FIXTURE_CONFIG_FILE}: generatedAudio must be an object`);
|
||||
return {
|
||||
path: relativePath(value, "path"),
|
||||
durationSeconds: positiveNumber(value, "durationSeconds"),
|
||||
frequencyHz: positiveNumber(value, "frequencyHz"),
|
||||
sampleRate: positiveNumber(value, "sampleRate"),
|
||||
};
|
||||
}
|
||||
|
||||
function parsePressureFile(value: unknown): GeneratedPressureFileConfig | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (!isRecord(value)) {
|
||||
throw new Error(`${FIXTURE_CONFIG_FILE}: generatedPressureFile must be an object`);
|
||||
}
|
||||
const bytes = positiveNumber(value, "bytes");
|
||||
const seed = positiveNumber(value, "seed");
|
||||
if (!Number.isInteger(bytes) || !Number.isInteger(seed)) {
|
||||
throw new Error(`${FIXTURE_CONFIG_FILE}: pressure bytes and seed must be integers`);
|
||||
}
|
||||
return {
|
||||
path: relativePath(value, "path"),
|
||||
bytes,
|
||||
seed,
|
||||
};
|
||||
}
|
||||
|
||||
function readFixtureConfig(projectDir: string): PlanParityFixtureConfig {
|
||||
const configPath = join(projectDir, FIXTURE_CONFIG_FILE);
|
||||
if (!existsSync(configPath)) return {};
|
||||
const raw = JSON.parse(readFileSync(configPath, "utf-8")) as unknown;
|
||||
if (!isRecord(raw)) throw new Error(`${FIXTURE_CONFIG_FILE}: root must be an object`);
|
||||
return {
|
||||
generatedAudio: parseGeneratedAudio(raw.generatedAudio),
|
||||
generatedPressureFile: parsePressureFile(raw.generatedPressureFile),
|
||||
};
|
||||
}
|
||||
|
||||
function writeAscii(buffer: Buffer, offset: number, value: string): void {
|
||||
buffer.write(value, offset, value.length, "ascii");
|
||||
}
|
||||
|
||||
/** Generate a deterministic mono PCM WAV without relying on ffmpeg. */
|
||||
export function generateToneWav(config: Omit<GeneratedAudioConfig, "path">): Buffer {
|
||||
const sampleCount = Math.round(config.durationSeconds * config.sampleRate);
|
||||
const pcmBytes = sampleCount * 2;
|
||||
const wav = Buffer.alloc(44 + pcmBytes);
|
||||
writeAscii(wav, 0, "RIFF");
|
||||
wav.writeUInt32LE(36 + pcmBytes, 4);
|
||||
writeAscii(wav, 8, "WAVE");
|
||||
writeAscii(wav, 12, "fmt ");
|
||||
wav.writeUInt32LE(16, 16);
|
||||
wav.writeUInt16LE(1, 20);
|
||||
wav.writeUInt16LE(1, 22);
|
||||
wav.writeUInt32LE(config.sampleRate, 24);
|
||||
wav.writeUInt32LE(config.sampleRate * 2, 28);
|
||||
wav.writeUInt16LE(2, 32);
|
||||
wav.writeUInt16LE(16, 34);
|
||||
writeAscii(wav, 36, "data");
|
||||
wav.writeUInt32LE(pcmBytes, 40);
|
||||
for (let sample = 0; sample < sampleCount; sample += 1) {
|
||||
const phase = (2 * Math.PI * config.frequencyHz * sample) / config.sampleRate;
|
||||
const value = Math.round(Math.sin(phase) * 0.25 * 32767);
|
||||
wav.writeInt16LE(value, 44 + sample * 2);
|
||||
}
|
||||
return wav;
|
||||
}
|
||||
|
||||
/** Deterministic xorshift bytes that do not collapse into a tiny gzip. */
|
||||
export function generatePressureBytes(bytes: number, seed: number): Buffer {
|
||||
const output = Buffer.alloc(bytes);
|
||||
let state = seed >>> 0;
|
||||
for (let index = 0; index < bytes; index += 1) {
|
||||
state ^= state << 13;
|
||||
state ^= state >>> 17;
|
||||
state ^= state << 5;
|
||||
output[index] = state & 0xff;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a checked-in fixture into a driver-owned project directory and
|
||||
* materialize its generated binary assets. Source fixtures stay small and
|
||||
* reviewable; size-pressure tests can vary their v1 cap without allocating
|
||||
* multi-gigabyte files.
|
||||
*/
|
||||
export function preparePlanParityFixture(sourceDir: string, projectDir: string): void {
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
cpSync(sourceDir, projectDir, { recursive: true });
|
||||
const config = readFixtureConfig(projectDir);
|
||||
|
||||
if (config.generatedAudio) {
|
||||
const target = join(projectDir, config.generatedAudio.path);
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
writeFileSync(target, generateToneWav(config.generatedAudio));
|
||||
}
|
||||
if (config.generatedPressureFile) {
|
||||
const target = join(projectDir, config.generatedPressureFile.path);
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
writeFileSync(
|
||||
target,
|
||||
generatePressureBytes(config.generatedPressureFile.bytes, config.generatedPressureFile.seed),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import type { PlanParityDriver, PlanParityMeasurement } from "./plan-parity-contract.js";
|
||||
import {
|
||||
classifyPlanTooLargeProbeFailure,
|
||||
parsePlanParityArgs,
|
||||
runPlanV2SizePressure,
|
||||
} from "./plan-parity-harness.js";
|
||||
|
||||
const cleanup: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const target of cleanup.splice(0)) {
|
||||
rmSync(target, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function fakeV2Measurement(): PlanParityMeasurement {
|
||||
return {
|
||||
protocol: "v2",
|
||||
driver: "fake-lambda-local",
|
||||
media: {
|
||||
outputSha256: "output",
|
||||
outputBytes: 123,
|
||||
frameSha256: ["frame"],
|
||||
pcmAudio: null,
|
||||
metadata: {
|
||||
video: null,
|
||||
audio: null,
|
||||
durationSeconds: 1,
|
||||
},
|
||||
},
|
||||
chunks: [],
|
||||
transferBytes: {
|
||||
downloaded: 10,
|
||||
uploaded: 20,
|
||||
total: 30,
|
||||
},
|
||||
peakMaterializedBytes: 40,
|
||||
};
|
||||
}
|
||||
|
||||
describe("parsePlanParityArgs()", () => {
|
||||
it("defaults both explicit protocol runs to lambda-local", () => {
|
||||
const options = parsePlanParityArgs(["node", "plan-parity"]);
|
||||
expect(options.v1Target).toBe("lambda-local");
|
||||
expect(options.v2Target).toBe("lambda-local");
|
||||
expect(options.renderConfig).toMatchObject({
|
||||
fps: 30,
|
||||
width: 320,
|
||||
height: 180,
|
||||
format: "mp4",
|
||||
});
|
||||
expect(options.expectV1PlanTooLarge).toBe(false);
|
||||
});
|
||||
|
||||
it("parses resource gates and a low v1 size cap", () => {
|
||||
const options = parsePlanParityArgs([
|
||||
"node",
|
||||
"plan-parity",
|
||||
"--v1-plan-size-cap-bytes=32768",
|
||||
"--max-v2-transfer-bytes",
|
||||
"1048576",
|
||||
"--max-v2-peak-materialized-bytes",
|
||||
"524288",
|
||||
"--strict-encoded-output=false",
|
||||
"--chunk-size",
|
||||
"15",
|
||||
"--expect-v1-plan-too-large",
|
||||
]);
|
||||
expect(options.v1PlanSizeCapBytes).toBe(32_768);
|
||||
expect(options.comparison.maxV2TransferBytes).toBe(1_048_576);
|
||||
expect(options.comparison.maxV2PeakMaterializedBytes).toBe(524_288);
|
||||
expect(options.comparison.requireEncodedOutputEquality).toBe(false);
|
||||
expect(options.renderConfig.chunkSize).toBe(15);
|
||||
expect(options.expectV1PlanTooLarge).toBe(true);
|
||||
});
|
||||
|
||||
it("reserves an explicit deployed-AWS target grammar", () => {
|
||||
const options = parsePlanParityArgs([
|
||||
"node",
|
||||
"plan-parity",
|
||||
"--v1-target",
|
||||
"aws:hf-plan-v1-test",
|
||||
"--v2-target",
|
||||
"aws:hf-plan-v2-test",
|
||||
]);
|
||||
expect(options.v1Target).toBe("aws:hf-plan-v1-test");
|
||||
expect(options.v2Target).toBe("aws:hf-plan-v2-test");
|
||||
});
|
||||
|
||||
it("rejects bad targets and numeric flags", () => {
|
||||
expect(() => parsePlanParityArgs(["node", "plan-parity", "--v2-target", "production"])).toThrow(
|
||||
/lambda-local or aws/,
|
||||
);
|
||||
expect(() =>
|
||||
parsePlanParityArgs(["node", "plan-parity", "--v1-plan-size-cap-bytes", "-1"]),
|
||||
).toThrow(/positive integer/);
|
||||
expect(() => parsePlanParityArgs(["node", "plan-parity", "--fps", "25"])).toThrow(
|
||||
/24, 30, or 60/,
|
||||
);
|
||||
expect(() =>
|
||||
parsePlanParityArgs(["node", "plan-parity", "--duration-tolerance-seconds", "not-a-number"]),
|
||||
).toThrow(/non-negative finite number/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runPlanV2SizePressure()", () => {
|
||||
it("records typed v1 PLAN_TOO_LARGE and still completes explicit v2", async () => {
|
||||
const fixtureDir = mkdtempSync(join(tmpdir(), "hf-plan-pressure-fixture-"));
|
||||
const artifactsDir = mkdtempSync(join(tmpdir(), "hf-plan-pressure-report-"));
|
||||
cleanup.push(fixtureDir, artifactsDir);
|
||||
writeFileSync(join(fixtureDir, "index.html"), "<html></html>", "utf-8");
|
||||
const calls: string[] = [];
|
||||
const driver: PlanParityDriver = {
|
||||
name: "fake-lambda-local",
|
||||
async render(input) {
|
||||
calls.push(input.protocol);
|
||||
if (input.protocol === "v1") {
|
||||
const error = new Error("synthetic cap exceeded") as Error & {
|
||||
code: "PLAN_TOO_LARGE";
|
||||
sizeBytes: number;
|
||||
limitBytes: number;
|
||||
};
|
||||
error.code = "PLAN_TOO_LARGE";
|
||||
error.sizeBytes = 65_536;
|
||||
error.limitBytes = 32_768;
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
protocol: "v2",
|
||||
outputPath: join(input.outputDir, "output.mp4"),
|
||||
chunks: [],
|
||||
transferBytes: { downloaded: 10, uploaded: 20 },
|
||||
peakMaterializedBytes: 40,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const report = await runPlanV2SizePressure({
|
||||
fixtureDir,
|
||||
artifactsDir,
|
||||
driver,
|
||||
renderConfig: {
|
||||
fps: 30,
|
||||
width: 320,
|
||||
height: 180,
|
||||
format: "mp4",
|
||||
},
|
||||
v1PlanSizeCapBytes: 32_768,
|
||||
analyzeDriverResult: async () => fakeV2Measurement(),
|
||||
});
|
||||
|
||||
expect(calls).toEqual(["v1", "v2"]);
|
||||
expect(report.passed).toBe(true);
|
||||
expect(report.v1).toEqual({
|
||||
status: "expected-failure",
|
||||
code: "PLAN_TOO_LARGE",
|
||||
message: "synthetic cap exceeded",
|
||||
sizeBytes: 65_536,
|
||||
limitBytes: 32_768,
|
||||
});
|
||||
expect(report.v2.status).toBe("success");
|
||||
const written = JSON.parse(
|
||||
readFileSync(join(artifactsDir, "plan-too-large-v2-report.json"), "utf-8"),
|
||||
) as {
|
||||
passed: boolean;
|
||||
v1: { code: string };
|
||||
v2: { status: string };
|
||||
};
|
||||
expect(written).toMatchObject({
|
||||
passed: true,
|
||||
v1: { code: "PLAN_TOO_LARGE" },
|
||||
v2: { status: "success" },
|
||||
});
|
||||
});
|
||||
|
||||
it("distinguishes non-PLAN_TOO_LARGE failures", () => {
|
||||
const error = new Error("network down") as Error & { code: string };
|
||||
error.code = "S3_UNAVAILABLE";
|
||||
expect(classifyPlanTooLargeProbeFailure(error)).toEqual({
|
||||
status: "unexpected-failure",
|
||||
code: "S3_UNAVAILABLE",
|
||||
message: "network down",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,452 @@
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import process from "node:process";
|
||||
import { analyzePlanParityDriverResult } from "./plan-parity-analysis.js";
|
||||
import {
|
||||
comparePlanParityMeasurements,
|
||||
type PlanParityComparison,
|
||||
type PlanParityComparisonOptions,
|
||||
type PlanParityDriver,
|
||||
type PlanParityDriverResult,
|
||||
type PlanParityMeasurement,
|
||||
type PlanParityRenderConfig,
|
||||
} from "./plan-parity-contract.js";
|
||||
import { preparePlanParityFixture } from "./plan-parity-fixture.js";
|
||||
|
||||
export type PlanParityTarget = "lambda-local" | `aws:${string}`;
|
||||
|
||||
export interface RunPlanProtocolParityOptions {
|
||||
fixtureDir: string;
|
||||
artifactsDir: string;
|
||||
v1Driver: PlanParityDriver;
|
||||
v2Driver: PlanParityDriver;
|
||||
renderConfig: PlanParityRenderConfig;
|
||||
v1PlanSizeCapBytes?: number;
|
||||
comparison?: PlanParityComparisonOptions;
|
||||
}
|
||||
|
||||
export interface PlanParityCliOptions {
|
||||
fixtureDir: string;
|
||||
artifactsDir: string;
|
||||
v1Target: PlanParityTarget;
|
||||
v2Target: PlanParityTarget;
|
||||
renderConfig: PlanParityRenderConfig;
|
||||
v1PlanSizeCapBytes?: number;
|
||||
expectV1PlanTooLarge: boolean;
|
||||
comparison: PlanParityComparisonOptions;
|
||||
}
|
||||
|
||||
export interface RunPlanV2SizePressureOptions {
|
||||
fixtureDir: string;
|
||||
artifactsDir: string;
|
||||
driver: PlanParityDriver;
|
||||
renderConfig: PlanParityRenderConfig;
|
||||
v1PlanSizeCapBytes: number;
|
||||
/**
|
||||
* Test seam for the post-render media analyzer. Production callers use
|
||||
* the canonical ffmpeg/ffprobe analyzer.
|
||||
*/
|
||||
analyzeDriverResult?: (
|
||||
driverName: string,
|
||||
result: PlanParityDriverResult,
|
||||
) => Promise<PlanParityMeasurement>;
|
||||
}
|
||||
|
||||
export type PlanTooLargeProbeOutcome =
|
||||
| {
|
||||
status: "expected-failure";
|
||||
code: "PLAN_TOO_LARGE";
|
||||
message: string;
|
||||
sizeBytes: number | null;
|
||||
limitBytes: number | null;
|
||||
}
|
||||
| {
|
||||
status: "unexpected-success";
|
||||
message: string;
|
||||
}
|
||||
| {
|
||||
status: "unexpected-failure";
|
||||
code: string | null;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type PlanV2PressureOutcome =
|
||||
| {
|
||||
status: "success";
|
||||
measurement: PlanParityMeasurement;
|
||||
}
|
||||
| {
|
||||
status: "failure";
|
||||
message: string;
|
||||
};
|
||||
|
||||
export interface PlanV2SizePressureReport {
|
||||
passed: boolean;
|
||||
checks: Array<{
|
||||
name: "v1-plan-too-large" | "v2-render-success";
|
||||
passed: boolean;
|
||||
detail: string;
|
||||
}>;
|
||||
v1: PlanTooLargeProbeOutcome;
|
||||
v2: PlanV2PressureOutcome;
|
||||
}
|
||||
|
||||
function parsePositiveInteger(name: string, value: string | undefined): number | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
throw new Error(`plan parity: --${name} must be a positive integer`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseNonNegativeNumber(name: string, value: string | undefined): number | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
throw new Error(`plan parity: --${name} must be a non-negative finite number`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseBoolean(name: string, value: string | undefined): boolean {
|
||||
if (value === undefined || value === "true") return true;
|
||||
if (value === "false") return false;
|
||||
throw new Error(`plan parity: --${name} must be true or false`);
|
||||
}
|
||||
|
||||
function parseTarget(name: string, value: string | undefined): PlanParityTarget {
|
||||
const target = value ?? "lambda-local";
|
||||
if (target === "lambda-local") return target;
|
||||
if (target.startsWith("aws:") && target.length > "aws:".length) {
|
||||
return target as `aws:${string}`;
|
||||
}
|
||||
throw new Error(
|
||||
`plan parity: --${name} must be lambda-local or aws:<isolated-stack> (got ${JSON.stringify(target)})`,
|
||||
);
|
||||
}
|
||||
|
||||
function collectArgs(argv: string[]): Map<string, string> {
|
||||
const args = new Map<string, string>();
|
||||
for (let index = 2; index < argv.length; index += 1) {
|
||||
const token = argv[index];
|
||||
if (!token?.startsWith("--")) {
|
||||
throw new Error(`plan parity: unexpected positional argument ${JSON.stringify(token)}`);
|
||||
}
|
||||
const equals = token.indexOf("=");
|
||||
if (equals > 2) {
|
||||
args.set(token.slice(2, equals), token.slice(equals + 1));
|
||||
continue;
|
||||
}
|
||||
const key = token.slice(2);
|
||||
const next = argv[index + 1];
|
||||
if (!next || next.startsWith("--")) {
|
||||
args.set(key, "true");
|
||||
continue;
|
||||
}
|
||||
args.set(key, next);
|
||||
index += 1;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
export function parsePlanParityArgs(argv: string[]): PlanParityCliOptions {
|
||||
const args = collectArgs(argv);
|
||||
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 !== 30 && fps !== 60) {
|
||||
throw new Error("plan parity: --fps must be 24, 30, or 60");
|
||||
}
|
||||
|
||||
return {
|
||||
fixtureDir,
|
||||
artifactsDir,
|
||||
v1Target: parseTarget("v1-target", args.get("v1-target")),
|
||||
v2Target: parseTarget("v2-target", args.get("v2-target")),
|
||||
renderConfig: {
|
||||
fps,
|
||||
width: parsePositiveInteger("width", args.get("width")) ?? 320,
|
||||
height: parsePositiveInteger("height", args.get("height")) ?? 180,
|
||||
format: "mp4",
|
||||
chunkSize: parsePositiveInteger("chunk-size", args.get("chunk-size")),
|
||||
maxParallelChunks: parsePositiveInteger(
|
||||
"max-parallel-chunks",
|
||||
args.get("max-parallel-chunks"),
|
||||
),
|
||||
},
|
||||
v1PlanSizeCapBytes: parsePositiveInteger(
|
||||
"v1-plan-size-cap-bytes",
|
||||
args.get("v1-plan-size-cap-bytes"),
|
||||
),
|
||||
expectV1PlanTooLarge:
|
||||
args.get("expect-v1-plan-too-large") === undefined
|
||||
? false
|
||||
: parseBoolean("expect-v1-plan-too-large", args.get("expect-v1-plan-too-large")),
|
||||
comparison: {
|
||||
durationToleranceSeconds: parseNonNegativeNumber(
|
||||
"duration-tolerance-seconds",
|
||||
args.get("duration-tolerance-seconds"),
|
||||
),
|
||||
requireEncodedOutputEquality:
|
||||
args.get("strict-encoded-output") === undefined
|
||||
? false
|
||||
: parseBoolean("strict-encoded-output", args.get("strict-encoded-output")),
|
||||
maxV2TransferBytes: parsePositiveInteger(
|
||||
"max-v2-transfer-bytes",
|
||||
args.get("max-v2-transfer-bytes"),
|
||||
),
|
||||
maxV2PeakMaterializedBytes: parsePositiveInteger(
|
||||
"max-v2-peak-materialized-bytes",
|
||||
args.get("max-v2-peak-materialized-bytes"),
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function errorCode(error: unknown): string | null {
|
||||
if (isRecord(error) && typeof error.code === "string") {
|
||||
return error.code;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function errorNumber(error: unknown, field: "sizeBytes" | "limitBytes"): number | null {
|
||||
if (isRecord(error)) {
|
||||
const value = error[field];
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function classifyPlanTooLargeProbeFailure(error: unknown): PlanTooLargeProbeOutcome {
|
||||
const code = errorCode(error);
|
||||
if (code === "PLAN_TOO_LARGE") {
|
||||
return {
|
||||
status: "expected-failure",
|
||||
code,
|
||||
message: errorMessage(error),
|
||||
sizeBytes: errorNumber(error, "sizeBytes"),
|
||||
limitBytes: errorNumber(error, "limitBytes"),
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "unexpected-failure",
|
||||
code,
|
||||
message: errorMessage(error),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Prove the migration boundary directly:
|
||||
*
|
||||
* 1. explicit v1 with a deliberately low cap must fail PLAN_TOO_LARGE;
|
||||
* 2. explicit v2 must still run to completion for the same prepared fixture.
|
||||
*
|
||||
* Unlike semantic parity mode, this API does not compare v1/v2 output because
|
||||
* the expected v1 run never produces output. Its durable report records both
|
||||
* the typed failure and the fully analyzed v2 success.
|
||||
*/
|
||||
export async function runPlanV2SizePressure(
|
||||
options: RunPlanV2SizePressureOptions,
|
||||
): Promise<PlanV2SizePressureReport> {
|
||||
if (!existsSync(options.fixtureDir)) {
|
||||
throw new Error(`plan parity fixture does not exist: ${options.fixtureDir}`);
|
||||
}
|
||||
mkdirSync(options.artifactsDir, { recursive: true });
|
||||
const v1ProjectDir = resolve(options.artifactsDir, "v1-low-cap", "project");
|
||||
const v2ProjectDir = resolve(options.artifactsDir, "v2", "project");
|
||||
preparePlanParityFixture(options.fixtureDir, v1ProjectDir);
|
||||
preparePlanParityFixture(options.fixtureDir, v2ProjectDir);
|
||||
|
||||
let v1: PlanTooLargeProbeOutcome;
|
||||
try {
|
||||
await options.driver.render({
|
||||
protocol: "v1",
|
||||
projectDir: v1ProjectDir,
|
||||
outputDir: resolve(options.artifactsDir, "v1-low-cap", "run"),
|
||||
renderConfig: options.renderConfig,
|
||||
planSizeCapBytes: options.v1PlanSizeCapBytes,
|
||||
});
|
||||
v1 = {
|
||||
status: "unexpected-success",
|
||||
message: `v1 rendered despite plan cap ${options.v1PlanSizeCapBytes}`,
|
||||
};
|
||||
} catch (error) {
|
||||
v1 = classifyPlanTooLargeProbeFailure(error);
|
||||
}
|
||||
|
||||
let v2: PlanV2PressureOutcome;
|
||||
try {
|
||||
const result = await options.driver.render({
|
||||
protocol: "v2",
|
||||
projectDir: v2ProjectDir,
|
||||
outputDir: resolve(options.artifactsDir, "v2", "run"),
|
||||
renderConfig: options.renderConfig,
|
||||
});
|
||||
const analyze = options.analyzeDriverResult ?? analyzePlanParityDriverResult;
|
||||
v2 = {
|
||||
status: "success",
|
||||
measurement: await analyze(options.driver.name, result),
|
||||
};
|
||||
} catch (error) {
|
||||
v2 = {
|
||||
status: "failure",
|
||||
message: errorMessage(error),
|
||||
};
|
||||
}
|
||||
|
||||
const checks: PlanV2SizePressureReport["checks"] = [
|
||||
{
|
||||
name: "v1-plan-too-large",
|
||||
passed: v1.status === "expected-failure",
|
||||
detail:
|
||||
v1.status === "expected-failure"
|
||||
? `PLAN_TOO_LARGE size=${v1.sizeBytes ?? "unknown"} limit=${v1.limitBytes ?? "unknown"}`
|
||||
: v1.message,
|
||||
},
|
||||
{
|
||||
name: "v2-render-success",
|
||||
passed: v2.status === "success",
|
||||
detail:
|
||||
v2.status === "success"
|
||||
? `${v2.measurement.media.frameSha256.length} frames, ${v2.measurement.media.outputBytes} output bytes`
|
||||
: v2.message,
|
||||
},
|
||||
];
|
||||
const report: PlanV2SizePressureReport = {
|
||||
passed: checks.every((entry) => entry.passed),
|
||||
checks,
|
||||
v1,
|
||||
v2,
|
||||
};
|
||||
writeFileSync(
|
||||
resolve(options.artifactsDir, "plan-too-large-v2-report.json"),
|
||||
`${JSON.stringify(report, null, 2)}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
return report;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the same prepared project through explicit v1 and v2 drivers, analyze
|
||||
* their artifacts, and persist a machine-readable comparison report.
|
||||
*/
|
||||
export async function runPlanProtocolParity(
|
||||
options: RunPlanProtocolParityOptions,
|
||||
): Promise<PlanParityComparison> {
|
||||
if (!existsSync(options.fixtureDir)) {
|
||||
throw new Error(`plan parity fixture does not exist: ${options.fixtureDir}`);
|
||||
}
|
||||
mkdirSync(options.artifactsDir, { recursive: true });
|
||||
|
||||
const v1ProjectDir = resolve(options.artifactsDir, "v1", "project");
|
||||
const v2ProjectDir = resolve(options.artifactsDir, "v2", "project");
|
||||
preparePlanParityFixture(options.fixtureDir, v1ProjectDir);
|
||||
preparePlanParityFixture(options.fixtureDir, v2ProjectDir);
|
||||
|
||||
const v1Result = await options.v1Driver.render({
|
||||
protocol: "v1",
|
||||
projectDir: v1ProjectDir,
|
||||
outputDir: resolve(options.artifactsDir, "v1", "run"),
|
||||
renderConfig: options.renderConfig,
|
||||
planSizeCapBytes: options.v1PlanSizeCapBytes,
|
||||
});
|
||||
const v2Result = await options.v2Driver.render({
|
||||
protocol: "v2",
|
||||
projectDir: v2ProjectDir,
|
||||
outputDir: resolve(options.artifactsDir, "v2", "run"),
|
||||
renderConfig: options.renderConfig,
|
||||
});
|
||||
const [v1, v2] = await Promise.all([
|
||||
analyzePlanParityDriverResult(options.v1Driver.name, v1Result),
|
||||
analyzePlanParityDriverResult(options.v2Driver.name, v2Result),
|
||||
]);
|
||||
const comparison = comparePlanParityMeasurements(v1, v2, options.comparison);
|
||||
writeFileSync(
|
||||
resolve(options.artifactsDir, "comparison.json"),
|
||||
`${JSON.stringify(comparison, null, 2)}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
return comparison;
|
||||
}
|
||||
|
||||
interface LambdaLocalDriverModule {
|
||||
createLambdaLocalPlanParityDriver(): PlanParityDriver;
|
||||
}
|
||||
|
||||
function isLambdaLocalDriverModule(value: unknown): value is LambdaLocalDriverModule {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"createLambdaLocalPlanParityDriver" in value &&
|
||||
typeof value.createLambdaLocalPlanParityDriver === "function"
|
||||
);
|
||||
}
|
||||
|
||||
async function loadDriver(target: PlanParityTarget): Promise<PlanParityDriver> {
|
||||
if (target.startsWith("aws:")) {
|
||||
throw new Error(
|
||||
`plan parity target ${target} requires the deployed-AWS driver package; ` +
|
||||
"use the library API to inject that driver until it is installed",
|
||||
);
|
||||
}
|
||||
// Indirect path keeps producer's build from pulling @hyperframes/aws-lambda
|
||||
// into its declaration emit before that workspace package has built.
|
||||
const modulePath = "./plan-parity-lambda-local-driver.js";
|
||||
const loaded: unknown = await import(modulePath);
|
||||
if (!isLambdaLocalDriverModule(loaded)) {
|
||||
throw new Error("lambda-local parity driver module has an invalid shape");
|
||||
}
|
||||
return loaded.createLambdaLocalPlanParityDriver();
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const options = parsePlanParityArgs(process.argv);
|
||||
if (options.expectV1PlanTooLarge) {
|
||||
if (options.v1PlanSizeCapBytes === undefined) {
|
||||
throw new Error("plan parity: --expect-v1-plan-too-large requires --v1-plan-size-cap-bytes");
|
||||
}
|
||||
if (options.v1Target !== options.v2Target) {
|
||||
throw new Error(
|
||||
"plan parity: pressure mode requires the same target for its v1 probe and v2 proof",
|
||||
);
|
||||
}
|
||||
const report = await runPlanV2SizePressure({
|
||||
fixtureDir: options.fixtureDir,
|
||||
artifactsDir: options.artifactsDir,
|
||||
driver: await loadDriver(options.v1Target),
|
||||
renderConfig: options.renderConfig,
|
||||
v1PlanSizeCapBytes: options.v1PlanSizeCapBytes,
|
||||
});
|
||||
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||
if (!report.passed) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const comparison = await runPlanProtocolParity({
|
||||
fixtureDir: options.fixtureDir,
|
||||
artifactsDir: options.artifactsDir,
|
||||
v1Driver: await loadDriver(options.v1Target),
|
||||
v2Driver: await loadDriver(options.v2Target),
|
||||
renderConfig: options.renderConfig,
|
||||
v1PlanSizeCapBytes: options.v1PlanSizeCapBytes,
|
||||
comparison: options.comparison,
|
||||
});
|
||||
process.stdout.write(`${JSON.stringify(comparison, null, 2)}\n`);
|
||||
if (!comparison.passed) process.exitCode = 1;
|
||||
}
|
||||
|
||||
const isDirectRun =
|
||||
process.argv[1] !== undefined &&
|
||||
resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
|
||||
if (isDirectRun) {
|
||||
await main();
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// fallow-ignore-file unused-file
|
||||
// Loaded by a runtime-only dynamic import so producer declaration emit does
|
||||
// not eagerly resolve the AWS workspace package.
|
||||
/**
|
||||
* Lambda-local adapter for the generic v1/v2 parity runner.
|
||||
*
|
||||
* Kept separate from the protocol-neutral harness because it imports
|
||||
* `@hyperframes/aws-lambda`; producer's declaration emit runs before the
|
||||
* Lambda workspace package is built in some CI stages.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { PlanParityDriver } from "./plan-parity-contract.js";
|
||||
import { runLambdaLocalRender } from "./regression-harness-lambda-local.js";
|
||||
|
||||
export function createLambdaLocalPlanParityDriver(): PlanParityDriver {
|
||||
return {
|
||||
name: "lambda-local",
|
||||
async render(input) {
|
||||
if (existsSync(input.outputDir)) {
|
||||
rmSync(input.outputDir, { recursive: true, force: true });
|
||||
}
|
||||
mkdirSync(input.outputDir, { recursive: true });
|
||||
return runLambdaLocalRender({
|
||||
protocol: input.protocol,
|
||||
projectDir: input.projectDir,
|
||||
tempRoot: input.outputDir,
|
||||
renderedOutputPath: join(input.outputDir, "output.mp4"),
|
||||
fps: input.renderConfig.fps,
|
||||
width: input.renderConfig.width,
|
||||
height: input.renderConfig.height,
|
||||
format: input.renderConfig.format,
|
||||
chunkSize: input.renderConfig.chunkSize,
|
||||
maxParallelChunks: input.renderConfig.maxParallelChunks,
|
||||
planDirSizeLimitBytes: input.planSizeCapBytes,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import type { DistributedFormat } from "./services/distributed/shared.js";
|
||||
|
||||
/** Inputs for {@link runLambdaLocalRender}. Same contract as `runDistributedSimulatedRender`. */
|
||||
export interface RunLambdaLocalInput {
|
||||
/** Explicit plan transport. Omitted only for legacy regression callers, where it defaults to v1. */
|
||||
protocol?: "v1" | "v2";
|
||||
projectDir: string;
|
||||
tempRoot: string;
|
||||
renderedOutputPath: string;
|
||||
@@ -32,8 +34,25 @@ export interface RunLambdaLocalInput {
|
||||
codec?: "h264" | "h265";
|
||||
chunkSize?: number;
|
||||
maxParallelChunks?: number;
|
||||
/** Test-only low cap for exercising typed v1 PLAN_TOO_LARGE behavior. */
|
||||
planDirSizeLimitBytes?: number;
|
||||
variables?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface LambdaLocalRenderResult {
|
||||
protocol: "v1" | "v2";
|
||||
outputPath: string;
|
||||
chunks: Array<{
|
||||
index: number;
|
||||
path: string;
|
||||
reportedSha256: string;
|
||||
}>;
|
||||
transferBytes: {
|
||||
downloaded: number;
|
||||
uploaded: number;
|
||||
};
|
||||
peakMaterializedBytes: number;
|
||||
}
|
||||
|
||||
/** Public signature of the dynamically-loaded `runLambdaLocalRender`. */
|
||||
export type RunLambdaLocalRender = (input: RunLambdaLocalInput) => Promise<void>;
|
||||
export type RunLambdaLocalRender = (input: RunLambdaLocalInput) => Promise<LambdaLocalRenderResult>;
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
createWriteStream,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
@@ -37,7 +38,6 @@ import { downloadS3ObjectToFile, tarDirectory, untarDirectory } from "@hyperfram
|
||||
import { handler } from "@hyperframes/aws-lambda/handler";
|
||||
import type {
|
||||
AssembleEvent,
|
||||
AssembleLambdaResult,
|
||||
HandlerDeps,
|
||||
PlanEvent,
|
||||
PlanLambdaResult,
|
||||
@@ -46,8 +46,14 @@ import type {
|
||||
SerializableDistributedRenderConfig,
|
||||
} from "@hyperframes/aws-lambda";
|
||||
|
||||
export type { RunLambdaLocalInput } from "./regression-harness-lambda-local-types.js";
|
||||
import type { RunLambdaLocalInput } from "./regression-harness-lambda-local-types.js";
|
||||
export type {
|
||||
LambdaLocalRenderResult,
|
||||
RunLambdaLocalInput,
|
||||
} from "./regression-harness-lambda-local-types.js";
|
||||
import type {
|
||||
LambdaLocalRenderResult,
|
||||
RunLambdaLocalInput,
|
||||
} from "./regression-harness-lambda-local-types.js";
|
||||
|
||||
const FAKE_BUCKET = "harness-lambda-local";
|
||||
|
||||
@@ -60,7 +66,10 @@ function uri(key: string): string {
|
||||
* Run plan → renderChunk × N → assemble through the OSS handler with a
|
||||
* filesystem-backed fake S3. Output lands at `input.renderedOutputPath`.
|
||||
*/
|
||||
export async function runLambdaLocalRender(input: RunLambdaLocalInput): Promise<void> {
|
||||
export async function runLambdaLocalRender(
|
||||
input: RunLambdaLocalInput,
|
||||
): Promise<LambdaLocalRenderResult> {
|
||||
const protocol = input.protocol ?? "v1";
|
||||
const s3Root = join(input.tempRoot, "s3");
|
||||
mkdirSync(s3Root, { recursive: true });
|
||||
|
||||
@@ -81,7 +90,26 @@ export async function runLambdaLocalRender(input: RunLambdaLocalInput): Promise<
|
||||
skipChromeResolution: true,
|
||||
tmpRoot: join(input.tempRoot, "lambda-tmp"),
|
||||
};
|
||||
mkdirSync(deps.tmpRoot as string, { recursive: true });
|
||||
const lambdaTmpRoot = join(input.tempRoot, "lambda-tmp");
|
||||
mkdirSync(lambdaTmpRoot, { recursive: true });
|
||||
let peakObservedMaterializedBytes = measureDirectoryBytes(lambdaTmpRoot);
|
||||
const observe = async <Result>(operation: () => Promise<Result>): Promise<Result> => {
|
||||
const sample = (): void => {
|
||||
peakObservedMaterializedBytes = Math.max(
|
||||
peakObservedMaterializedBytes,
|
||||
measureDirectoryBytes(lambdaTmpRoot),
|
||||
);
|
||||
};
|
||||
sample();
|
||||
const timer = setInterval(sample, 2);
|
||||
timer.unref();
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
clearInterval(timer);
|
||||
sample();
|
||||
}
|
||||
};
|
||||
|
||||
const config: SerializableDistributedRenderConfig = {
|
||||
fps: input.fps,
|
||||
@@ -91,6 +119,7 @@ export async function runLambdaLocalRender(input: RunLambdaLocalInput): Promise<
|
||||
...(input.format === "mp4" && input.codec !== undefined ? { codec: input.codec } : {}),
|
||||
chunkSize: input.chunkSize,
|
||||
maxParallelChunks: input.maxParallelChunks,
|
||||
planDirSizeLimitBytes: input.planDirSizeLimitBytes,
|
||||
hdrMode: "force-sdr",
|
||||
// Forward `variables` through the event boundary so lambda-local mode
|
||||
// exercises the same variables-in-encoder.json path that real Lambda
|
||||
@@ -101,42 +130,95 @@ export async function runLambdaLocalRender(input: RunLambdaLocalInput): Promise<
|
||||
|
||||
// STEP A: plan
|
||||
const planPrefix = `renders/harness/${Date.now()}/`;
|
||||
const planEvent: PlanEvent = {
|
||||
Action: "plan",
|
||||
ProjectS3Uri: uri(projectKey),
|
||||
PlanOutputS3Prefix: uri(planPrefix),
|
||||
Config: config,
|
||||
};
|
||||
const planResult = (await handler(planEvent, deps)) as PlanLambdaResult;
|
||||
const planEvent: PlanEvent =
|
||||
protocol === "v2"
|
||||
? {
|
||||
Action: "plan",
|
||||
PlanProtocol: "v2",
|
||||
ProjectS3Uri: uri(projectKey),
|
||||
PlanOutputS3Prefix: uri(planPrefix),
|
||||
Config: config,
|
||||
}
|
||||
: {
|
||||
Action: "plan",
|
||||
PlanProtocol: "v1",
|
||||
ProjectS3Uri: uri(projectKey),
|
||||
PlanOutputS3Prefix: uri(planPrefix),
|
||||
Config: config,
|
||||
};
|
||||
const planResponse = await observe(() => handler(planEvent, deps));
|
||||
if (planResponse.Action !== "plan") {
|
||||
throw new Error(`lambda-local: plan action returned ${planResponse.Action}`);
|
||||
}
|
||||
const planResult: PlanLambdaResult = planResponse;
|
||||
|
||||
// STEP B: render every chunk through the handler.
|
||||
const chunkUris: string[] = [];
|
||||
const chunks: LambdaLocalRenderResult["chunks"] = [];
|
||||
for (let i = 0; i < planResult.ChunkCount; i++) {
|
||||
const chunkEvent: RenderChunkEvent = {
|
||||
Action: "renderChunk",
|
||||
PlanS3Uri: planResult.PlanS3Uri,
|
||||
const shared = {
|
||||
Action: "renderChunk" as const,
|
||||
PlanHash: planResult.PlanHash,
|
||||
ChunkIndex: i,
|
||||
ChunkOutputS3Prefix: uri(planPrefix),
|
||||
Format: input.format,
|
||||
};
|
||||
const chunkResult = (await handler(chunkEvent, deps)) as RenderChunkLambdaResult;
|
||||
const chunkEvent: RenderChunkEvent =
|
||||
protocol === "v2"
|
||||
? {
|
||||
...shared,
|
||||
PlanProtocol: "v2",
|
||||
PlanV2ManifestS3Uri: requireV2PlanResult(planResult).PlanV2ManifestS3Uri,
|
||||
PlanV2ArtifactS3Prefix: requireV2PlanResult(planResult).PlanV2ArtifactS3Prefix,
|
||||
}
|
||||
: {
|
||||
...shared,
|
||||
PlanProtocol: "v1",
|
||||
PlanS3Uri: requireV1PlanResult(planResult).PlanS3Uri,
|
||||
};
|
||||
const chunkResponse = await observe(() => handler(chunkEvent, deps));
|
||||
if (chunkResponse.Action !== "renderChunk") {
|
||||
throw new Error(`lambda-local: renderChunk action returned ${chunkResponse.Action}`);
|
||||
}
|
||||
const chunkResult: RenderChunkLambdaResult = chunkResponse;
|
||||
chunkUris.push(chunkResult.ChunkS3Uri);
|
||||
chunks.push({
|
||||
index: i,
|
||||
path: fakeS3Path(s3Root, chunkResult.ChunkS3Uri),
|
||||
reportedSha256: chunkResult.Sha256,
|
||||
});
|
||||
}
|
||||
|
||||
// STEP C: assemble
|
||||
const finalUri = uri(
|
||||
`${planPrefix}output${input.format === "png-sequence" ? ".tar.gz" : `.${input.format}`}`,
|
||||
);
|
||||
const assembleEvent: AssembleEvent = {
|
||||
Action: "assemble",
|
||||
PlanS3Uri: planResult.PlanS3Uri,
|
||||
const assembleShared = {
|
||||
Action: "assemble" as const,
|
||||
ChunkS3Uris: chunkUris,
|
||||
AudioS3Uri: planResult.AudioS3Uri,
|
||||
OutputS3Uri: finalUri,
|
||||
Format: input.format,
|
||||
};
|
||||
(await handler(assembleEvent, deps)) as AssembleLambdaResult;
|
||||
const assembleEvent: AssembleEvent =
|
||||
protocol === "v2"
|
||||
? {
|
||||
...assembleShared,
|
||||
PlanProtocol: "v2",
|
||||
PlanV2ManifestS3Uri: requireV2PlanResult(planResult).PlanV2ManifestS3Uri,
|
||||
PlanV2ArtifactS3Prefix: requireV2PlanResult(planResult).PlanV2ArtifactS3Prefix,
|
||||
PlanHash: planResult.PlanHash,
|
||||
}
|
||||
: {
|
||||
...assembleShared,
|
||||
PlanProtocol: "v1",
|
||||
PlanS3Uri: requireV1PlanResult(planResult).PlanS3Uri,
|
||||
};
|
||||
const assembleResponse = await observe(() => handler(assembleEvent, deps));
|
||||
if (assembleResponse.Action !== "assemble") {
|
||||
throw new Error(`lambda-local: assemble action returned ${assembleResponse.Action}`);
|
||||
}
|
||||
const transferBytes = fakeS3.transferBytes;
|
||||
|
||||
// Copy the final output from fake-S3 land back out to the path the
|
||||
// harness expects. For png-sequence, untar into the dir.
|
||||
@@ -152,6 +234,57 @@ export async function runLambdaLocalRender(input: RunLambdaLocalInput): Promise<
|
||||
input.renderedOutputPath,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
protocol,
|
||||
outputPath: input.renderedOutputPath,
|
||||
chunks,
|
||||
transferBytes,
|
||||
peakMaterializedBytes: peakObservedMaterializedBytes,
|
||||
};
|
||||
}
|
||||
|
||||
function fakeS3Path(s3Root: string, s3Uri: string): string {
|
||||
const prefix = `s3://${FAKE_BUCKET}/`;
|
||||
if (!s3Uri.startsWith(prefix)) {
|
||||
throw new Error(`lambda-local: unexpected fake S3 URI ${s3Uri}`);
|
||||
}
|
||||
return join(s3Root, s3Uri.slice(prefix.length));
|
||||
}
|
||||
|
||||
function requireV1PlanResult(
|
||||
result: PlanLambdaResult,
|
||||
): Extract<PlanLambdaResult, { PlanS3Uri: string }> {
|
||||
if (!("PlanS3Uri" in result)) {
|
||||
throw new Error("lambda-local: v1 plan returned v2 locators");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function requireV2PlanResult(
|
||||
result: PlanLambdaResult,
|
||||
): Extract<PlanLambdaResult, { PlanProtocol: "v2" }> {
|
||||
if (!("PlanProtocol" in result) || result.PlanProtocol !== "v2") {
|
||||
throw new Error("lambda-local: v2 plan did not return explicit v2 locators");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// The recursive walk is the measurement itself; extracting its two filesystem
|
||||
// branches would make this small test-harness utility harder to audit.
|
||||
// fallow-ignore-next-line complexity
|
||||
function measureDirectoryBytes(path: string): number {
|
||||
if (!existsSync(path)) return 0;
|
||||
let bytes = 0;
|
||||
for (const entry of readdirSync(path, { withFileTypes: true })) {
|
||||
const child = join(path, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
bytes += measureDirectoryBytes(child);
|
||||
} else if (entry.isFile()) {
|
||||
bytes += statSync(child).size;
|
||||
}
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -162,8 +295,19 @@ export async function runLambdaLocalRender(input: RunLambdaLocalInput): Promise<
|
||||
* without going through a real S3 endpoint.
|
||||
*/
|
||||
class FilesystemBackedFakeS3 {
|
||||
private downloadedBytes = 0;
|
||||
private uploadedBytes = 0;
|
||||
private readonly metadata = new Map<string, Record<string, string>>();
|
||||
|
||||
constructor(private readonly root: string) {}
|
||||
|
||||
get transferBytes(): { downloaded: number; uploaded: number } {
|
||||
return {
|
||||
downloaded: this.downloadedBytes,
|
||||
uploaded: this.uploadedBytes,
|
||||
};
|
||||
}
|
||||
|
||||
async send(command: unknown): Promise<unknown> {
|
||||
const cmdName = (command as { constructor: { name: string } }).constructor.name;
|
||||
const input = (command as { input: { Bucket: string; Key: string; Body?: unknown } }).input;
|
||||
@@ -180,6 +324,7 @@ class FilesystemBackedFakeS3 {
|
||||
throw err;
|
||||
}
|
||||
const bytes = readFileSync(fsPath);
|
||||
this.downloadedBytes += bytes.length;
|
||||
return { Body: Readable.from([bytes]) };
|
||||
}
|
||||
if (cmdName === "PutObjectCommand") {
|
||||
@@ -192,7 +337,11 @@ class FilesystemBackedFakeS3 {
|
||||
} else {
|
||||
throw new Error(`FakeS3: PutObject body shape not supported (${typeof body})`);
|
||||
}
|
||||
return { ETag: `"fake-${statSync(fsPath).size}"` };
|
||||
const size = statSync(fsPath).size;
|
||||
this.uploadedBytes += size;
|
||||
const metadata = (command as { input: { Metadata?: Record<string, string> } }).input.Metadata;
|
||||
if (metadata) this.metadata.set(input.Key, metadata);
|
||||
return { ETag: `"fake-${size}"` };
|
||||
}
|
||||
if (cmdName === "HeadObjectCommand") {
|
||||
if (!existsSync(fsPath)) {
|
||||
@@ -204,7 +353,11 @@ class FilesystemBackedFakeS3 {
|
||||
err.$metadata = { httpStatusCode: 404 };
|
||||
throw err;
|
||||
}
|
||||
return { ContentLength: statSync(fsPath).size, LastModified: new Date() };
|
||||
return {
|
||||
ContentLength: statSync(fsPath).size,
|
||||
LastModified: new Date(),
|
||||
Metadata: this.metadata.get(input.Key),
|
||||
};
|
||||
}
|
||||
throw new Error(`FakeS3: unexpected command ${cmdName}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user