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:
@@ -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