@hyperframes/aws-lambda 0.7.70 → 0.7.72

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/handler.js CHANGED
@@ -5,7 +5,11 @@ import { basename, join } from "node:path";
5
5
  import { S3Client } from "@aws-sdk/client-s3";
6
6
  import {
7
7
  assemble,
8
+ listPlanV2ArtifactsForTarget,
9
+ materializePlanV2Target,
8
10
  plan,
11
+ planV2,
12
+ readPlanV2Manifest,
9
13
  renderChunk
10
14
  } from "@hyperframes/producer/distributed";
11
15
 
@@ -90,9 +94,14 @@ import {
90
94
  rmSync,
91
95
  statSync
92
96
  } from "node:fs";
97
+ import { createHash } from "node:crypto";
93
98
  import { dirname } from "node:path";
94
99
  import { pipeline } from "node:stream/promises";
95
- import { GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
100
+ import {
101
+ GetObjectCommand,
102
+ HeadObjectCommand,
103
+ PutObjectCommand
104
+ } from "@aws-sdk/client-s3";
96
105
  import * as tar from "tar";
97
106
  function parseS3Uri(uri) {
98
107
  if (!uri.startsWith("s3://")) {
@@ -120,6 +129,19 @@ async function downloadS3ObjectToFile(client, uri, destPath) {
120
129
  mkdirSync(dirname(destPath), { recursive: true });
121
130
  await pipeline(body, createWriteStream(destPath));
122
131
  }
132
+ async function downloadS3ObjectToFileVerified(client, uri, destPath, expectedSha256) {
133
+ assertSha256(expectedSha256);
134
+ await downloadS3ObjectToFile(client, uri, destPath);
135
+ const actual = await sha256File(destPath);
136
+ if (actual !== expectedSha256) {
137
+ rmSync(destPath, { force: true });
138
+ const error = new Error(
139
+ `[s3Transport] PLAN_ARTIFACT_DIGEST_MISMATCH: ${uri} expected ${expectedSha256}, got ${actual}`
140
+ );
141
+ error.name = "PLAN_ARTIFACT_DIGEST_MISMATCH";
142
+ throw error;
143
+ }
144
+ }
123
145
  async function uploadFileToS3(client, localPath, uri, contentType) {
124
146
  if (!existsSync2(localPath)) {
125
147
  throw new Error(`[s3Transport] upload source missing: ${localPath}`);
@@ -136,6 +158,68 @@ async function uploadFileToS3(client, localPath, uri, contentType) {
136
158
  })
137
159
  );
138
160
  }
161
+ async function uploadContentAddressedFileToS3(client, localPath, uri, expectedSha256, contentType) {
162
+ assertSha256(expectedSha256);
163
+ if (!existsSync2(localPath)) {
164
+ throw new Error(`[s3Transport] upload source missing: ${localPath}`);
165
+ }
166
+ const actualSha256 = await sha256File(localPath);
167
+ if (actualSha256 !== expectedSha256) {
168
+ const error = new Error(
169
+ `[s3Transport] PLAN_ARTIFACT_DIGEST_MISMATCH: local artifact ${localPath} expected ${expectedSha256}, got ${actualSha256}`
170
+ );
171
+ error.name = "PLAN_ARTIFACT_DIGEST_MISMATCH";
172
+ throw error;
173
+ }
174
+ const { bucket, key } = parseS3Uri(uri);
175
+ const size = statSync(localPath).size;
176
+ try {
177
+ const existing = await client.send(
178
+ new HeadObjectCommand({ Bucket: bucket, Key: key, ChecksumMode: "ENABLED" })
179
+ );
180
+ if (existing.ContentLength === size && existing.Metadata?.sha256 === expectedSha256) {
181
+ return "reused";
182
+ }
183
+ const error = new Error(
184
+ `[s3Transport] PLAN_ARTIFACT_DIGEST_MISMATCH: immutable object ${uri} already exists with different digest metadata or size`
185
+ );
186
+ error.name = "PLAN_ARTIFACT_DIGEST_MISMATCH";
187
+ throw error;
188
+ } catch (error) {
189
+ if (!isS3NotFound(error)) throw error;
190
+ }
191
+ await client.send(
192
+ new PutObjectCommand({
193
+ Bucket: bucket,
194
+ Key: key,
195
+ Body: createReadStream(localPath),
196
+ ContentType: contentType,
197
+ ContentLength: size,
198
+ Metadata: { sha256: expectedSha256 },
199
+ ChecksumSHA256: Buffer.from(expectedSha256, "hex").toString("base64")
200
+ })
201
+ );
202
+ return "uploaded";
203
+ }
204
+ async function sha256File(path) {
205
+ const hash = createHash("sha256");
206
+ for await (const chunk of createReadStream(path)) {
207
+ hash.update(chunk);
208
+ }
209
+ return hash.digest("hex");
210
+ }
211
+ function assertSha256(value) {
212
+ if (!/^[a-f0-9]{64}$/.test(value)) {
213
+ throw new Error(
214
+ `[s3Transport] expected lowercase SHA-256 digest, got ${JSON.stringify(value)}`
215
+ );
216
+ }
217
+ }
218
+ function isS3NotFound(error) {
219
+ if (!error || typeof error !== "object") return false;
220
+ const candidate = error;
221
+ return candidate.name === "NotFound" || candidate.name === "NoSuchKey" || candidate.$metadata?.httpStatusCode === 404;
222
+ }
139
223
  async function tarDirectory(sourceDir, destTarball) {
140
224
  if (!existsSync2(sourceDir) || !statSync(sourceDir).isDirectory()) {
141
225
  throw new Error(`[s3Transport] tar source must be an existing directory: ${sourceDir}`);
@@ -184,15 +268,24 @@ async function handler(event, deps) {
184
268
  }
185
269
  }
186
270
  } catch (err) {
271
+ normalizeTerminalErrorName(err);
187
272
  logEvent({
188
273
  event: "handler_error",
189
274
  action: unwrapped.Action,
275
+ input: summarizeEvent(unwrapped),
190
276
  message: err instanceof Error ? err.message : String(err),
191
277
  name: err instanceof Error ? err.name : void 0
192
278
  });
193
279
  throw err;
194
280
  }
195
281
  }
282
+ function normalizeTerminalErrorName(error) {
283
+ if (!error || typeof error !== "object") return;
284
+ const candidate = error;
285
+ if (candidate.code === "PLAN_PROTOCOL_UNSUPPORTED" || candidate.code === "PLAN_TOO_LARGE" || candidate.code === "PLAN_V2_INTEGRITY_UNRECOVERABLE") {
286
+ candidate.name = candidate.code;
287
+ }
288
+ }
196
289
  var MAX_ENVELOPE_DEPTH = 4;
197
290
  function unwrapEvent(event) {
198
291
  let cursor = event;
@@ -229,18 +322,21 @@ function summarizeEvent(event) {
229
322
  return {
230
323
  projectS3Uri: event.ProjectS3Uri,
231
324
  planOutputS3Prefix: event.PlanOutputS3Prefix,
325
+ planProtocol: event.PlanProtocol ?? "v1",
232
326
  format: event.Config.format,
233
327
  fps: event.Config.fps
234
328
  };
235
329
  case "renderChunk":
236
330
  return {
237
- planS3Uri: event.PlanS3Uri,
331
+ planProtocol: event.PlanProtocol ?? "v1",
332
+ ...event.PlanProtocol === "v2" ? { planV2ManifestS3Uri: event.PlanV2ManifestS3Uri } : { planS3Uri: event.PlanS3Uri },
238
333
  chunkIndex: event.ChunkIndex,
239
334
  format: event.Format
240
335
  };
241
336
  case "assemble":
242
337
  return {
243
- planS3Uri: event.PlanS3Uri,
338
+ planProtocol: event.PlanProtocol ?? "v1",
339
+ ...event.PlanProtocol === "v2" ? { planV2ManifestS3Uri: event.PlanV2ManifestS3Uri } : { planS3Uri: event.PlanS3Uri },
244
340
  chunkCount: event.ChunkS3Uris.length,
245
341
  hasAudio: event.AudioS3Uri !== null,
246
342
  outputS3Uri: event.OutputS3Uri,
@@ -259,6 +355,9 @@ function primeRuntimeEnv() {
259
355
  }
260
356
  }
261
357
  async function handlePlan(event, deps) {
358
+ if (event.PlanProtocol === "v2") {
359
+ return handlePlanV2(event, deps);
360
+ }
262
361
  const started = Date.now();
263
362
  const s3 = deps?.s3 ?? getS3Client();
264
363
  const primitive = deps?.primitives?.plan ?? plan;
@@ -307,7 +406,73 @@ async function handlePlan(event, deps) {
307
406
  cleanupDir(work);
308
407
  }
309
408
  }
409
+ async function handlePlanV2(event, deps) {
410
+ const started = Date.now();
411
+ const s3 = deps?.s3 ?? getS3Client();
412
+ const primitive = deps?.primitives?.planV2 ?? planV2;
413
+ if (!deps?.skipChromeResolution && !process.env.PRODUCER_HEADLESS_SHELL_PATH) {
414
+ process.env.PRODUCER_HEADLESS_SHELL_PATH = await resolveChromeExecutablePath();
415
+ }
416
+ const work = mkdtempSync(join(deps?.tmpRoot ?? tmpdir(), "hf-lambda-plan-v2-"));
417
+ const projectArchive = join(work, "project.tar.gz");
418
+ const projectDir = join(work, "project");
419
+ const planV2Dir = join(work, "plan-v2");
420
+ try {
421
+ await downloadS3ObjectToFile(s3, event.ProjectS3Uri, projectArchive);
422
+ await untarDirectory(projectArchive, projectDir);
423
+ const result = await primitive(projectDir, { ...event.Config }, planV2Dir);
424
+ const manifest = readPlanV2Manifest(planV2Dir);
425
+ if (manifest.planHash !== result.planHash) {
426
+ throwPlanHashMismatch(result.planHash, manifest.planHash);
427
+ }
428
+ const outputPrefix = `${trimTrailingSlash(event.PlanOutputS3Prefix)}/v2`;
429
+ const artifactPrefix = `${outputPrefix}/artifacts/sha256`;
430
+ const uniqueArtifacts = [
431
+ ...new Map(manifest.artifacts.map((artifact) => [artifact.sha256, artifact])).values()
432
+ ];
433
+ await mapConcurrent(uniqueArtifacts, 16, async (artifact) => {
434
+ const localPath = planV2BlobPath(planV2Dir, artifact.sha256);
435
+ await uploadContentAddressedFileToS3(
436
+ s3,
437
+ localPath,
438
+ planV2BlobUri(artifactPrefix, artifact.sha256),
439
+ artifact.sha256
440
+ );
441
+ });
442
+ const manifestUri = `${outputPrefix}/manifest.json`;
443
+ await uploadContentAddressedFileToS3(
444
+ s3,
445
+ result.manifestPath,
446
+ manifestUri,
447
+ await sha256File(result.manifestPath),
448
+ "application/json"
449
+ );
450
+ return {
451
+ Action: "plan",
452
+ PlanProtocol: "v2",
453
+ PlanV2ManifestS3Uri: manifestUri,
454
+ PlanV2ArtifactS3Prefix: artifactPrefix,
455
+ PlanHash: result.planHash,
456
+ ChunkCount: result.chunkCount,
457
+ TotalFrames: result.totalFrames,
458
+ Fps: result.fps,
459
+ Width: result.width,
460
+ Height: result.height,
461
+ Format: result.format,
462
+ HasAudio: manifest.artifacts.some((artifact) => artifact.path === "audio.aac"),
463
+ AudioS3Uri: null,
464
+ FfmpegVersion: result.ffmpegVersion,
465
+ ProducerVersion: result.producerVersion,
466
+ DurationMs: Date.now() - started
467
+ };
468
+ } finally {
469
+ cleanupDir(work);
470
+ }
471
+ }
310
472
  async function handleRenderChunk(event, deps) {
473
+ if (event.PlanProtocol === "v2") {
474
+ return handleRenderChunkV2(event, deps);
475
+ }
311
476
  const started = Date.now();
312
477
  const s3 = deps?.s3 ?? getS3Client();
313
478
  const primitive = deps?.primitives?.renderChunk ?? renderChunk;
@@ -345,6 +510,44 @@ async function handleRenderChunk(event, deps) {
345
510
  cleanupDir(work);
346
511
  }
347
512
  }
513
+ async function handleRenderChunkV2(event, deps) {
514
+ const started = Date.now();
515
+ const s3 = deps?.s3 ?? getS3Client();
516
+ const primitive = deps?.primitives?.renderChunk ?? renderChunk;
517
+ if (!deps?.skipChromeResolution && !process.env.PRODUCER_HEADLESS_SHELL_PATH) {
518
+ process.env.PRODUCER_HEADLESS_SHELL_PATH = await resolveChromeExecutablePath();
519
+ }
520
+ const work = mkdtempSync(join(deps?.tmpRoot ?? tmpdir(), "hf-lambda-chunk-v2-"));
521
+ try {
522
+ const planDir = await downloadAndMaterializePlanV2(
523
+ s3,
524
+ event,
525
+ { role: "chunk", chunkIndex: event.ChunkIndex },
526
+ work
527
+ );
528
+ const chunkOutputBase = join(
529
+ work,
530
+ event.Format === "png-sequence" ? `chunk-${pad(event.ChunkIndex)}` : `chunk-${pad(event.ChunkIndex)}${formatExtension(event.Format)}`
531
+ );
532
+ const result = await primitive(planDir, event.ChunkIndex, chunkOutputBase);
533
+ const chunkUri = await uploadChunkOutput(
534
+ s3,
535
+ result,
536
+ event.ChunkOutputS3Prefix,
537
+ event.ChunkIndex
538
+ );
539
+ return {
540
+ Action: "renderChunk",
541
+ ChunkS3Uri: chunkUri,
542
+ ChunkIndex: event.ChunkIndex,
543
+ Sha256: result.sha256,
544
+ FramesEncoded: result.framesEncoded,
545
+ DurationMs: Date.now() - started
546
+ };
547
+ } finally {
548
+ cleanupDir(work);
549
+ }
550
+ }
348
551
  async function uploadChunkOutput(s3, result, prefix, chunkIndex) {
349
552
  const trimmed = trimTrailingSlash(prefix);
350
553
  if (result.outputKind === "file") {
@@ -360,6 +563,9 @@ async function uploadChunkOutput(s3, result, prefix, chunkIndex) {
360
563
  return uri;
361
564
  }
362
565
  async function handleAssemble(event, deps) {
566
+ if (event.PlanProtocol === "v2") {
567
+ return handleAssembleV2(event, deps);
568
+ }
363
569
  const started = Date.now();
364
570
  const s3 = deps?.s3 ?? getS3Client();
365
571
  const primitive = deps?.primitives?.assemble ?? assemble;
@@ -397,6 +603,87 @@ async function handleAssemble(event, deps) {
397
603
  cleanupDir(work);
398
604
  }
399
605
  }
606
+ async function handleAssembleV2(event, deps) {
607
+ const started = Date.now();
608
+ const s3 = deps?.s3 ?? getS3Client();
609
+ const primitive = deps?.primitives?.assemble ?? assemble;
610
+ const work = mkdtempSync(join(deps?.tmpRoot ?? tmpdir(), "hf-lambda-assemble-v2-"));
611
+ try {
612
+ const planDir = await downloadAndMaterializePlanV2(s3, event, { role: "assembler" }, work);
613
+ const audioPath = existsSync3(join(planDir, "audio.aac")) ? join(planDir, "audio.aac") : null;
614
+ const chunkPaths = await downloadChunkObjects(s3, event.ChunkS3Uris, work, event.Format);
615
+ const finalOutput = event.Format === "png-sequence" ? join(work, "output-frames") : join(work, `output${formatExtension(event.Format)}`);
616
+ const result = await primitive(planDir, chunkPaths, audioPath, finalOutput, {
617
+ cfr: event.Cfr === true
618
+ });
619
+ if (event.Format === "png-sequence") {
620
+ const tarball = `${finalOutput}.tar.gz`;
621
+ await tarDirectory(finalOutput, tarball);
622
+ await uploadFileToS3(s3, tarball, event.OutputS3Uri, "application/gzip");
623
+ } else {
624
+ await uploadFileToS3(s3, finalOutput, event.OutputS3Uri);
625
+ }
626
+ return {
627
+ Action: "assemble",
628
+ OutputS3Uri: event.OutputS3Uri,
629
+ FramesEncoded: result.framesEncoded,
630
+ FileSize: result.fileSize,
631
+ DurationMs: Date.now() - started
632
+ };
633
+ } finally {
634
+ cleanupDir(work);
635
+ }
636
+ }
637
+ async function downloadAndMaterializePlanV2(s3, event, target, work) {
638
+ const transportDir = join(work, "plan-v2");
639
+ mkdirSync2(transportDir, { recursive: true });
640
+ await downloadS3ObjectToFile(s3, event.PlanV2ManifestS3Uri, join(transportDir, "plan.json"));
641
+ const manifest = readPlanV2Manifest(transportDir);
642
+ if (manifest.planHash !== event.PlanHash) {
643
+ throwPlanHashMismatch(event.PlanHash, manifest.planHash);
644
+ }
645
+ const artifacts = listPlanV2ArtifactsForTarget(manifest, target);
646
+ const uniqueArtifacts = [
647
+ ...new Map(artifacts.map((artifact) => [artifact.sha256, artifact])).values()
648
+ ];
649
+ await mapConcurrent(uniqueArtifacts, 16, async (artifact) => {
650
+ await downloadPlanV2Artifact(s3, event.PlanV2ArtifactS3Prefix, transportDir, artifact);
651
+ });
652
+ const planDir = join(work, "plan");
653
+ materializePlanV2Target(transportDir, target, planDir);
654
+ return planDir;
655
+ }
656
+ async function downloadPlanV2Artifact(s3, artifactPrefix, planV2Dir, artifact) {
657
+ await downloadS3ObjectToFileVerified(
658
+ s3,
659
+ planV2BlobUri(artifactPrefix, artifact.sha256),
660
+ planV2BlobPath(planV2Dir, artifact.sha256),
661
+ artifact.sha256
662
+ );
663
+ }
664
+ function planV2BlobPath(planV2Dir, digest) {
665
+ return join(planV2Dir, "artifacts", "sha256", digest.slice(0, 2), digest);
666
+ }
667
+ function planV2BlobUri(prefix, digest) {
668
+ return `${trimTrailingSlash(prefix)}/${digest.slice(0, 2)}/${digest}`;
669
+ }
670
+ function throwPlanHashMismatch(expected, actual) {
671
+ const error = new Error(
672
+ `PLAN_HASH_MISMATCH: event PlanHash=${expected} did not match v2 manifest planHash=${actual}`
673
+ );
674
+ error.name = "PLAN_HASH_MISMATCH";
675
+ throw error;
676
+ }
677
+ async function mapConcurrent(values, concurrency, fn) {
678
+ let cursor = 0;
679
+ async function worker() {
680
+ while (cursor < values.length) {
681
+ const index = cursor++;
682
+ await fn(values[index]);
683
+ }
684
+ }
685
+ await Promise.all(Array.from({ length: Math.min(concurrency, values.length) }, () => worker()));
686
+ }
400
687
  async function downloadChunkObjects(s3, uris, workDir, format) {
401
688
  const chunksDir = join(workDir, "chunks");
402
689
  mkdirSync2(chunksDir, { recursive: true });
@@ -425,11 +712,14 @@ function getEventS3Uris(event) {
425
712
  case "plan":
426
713
  return [event.ProjectS3Uri, event.PlanOutputS3Prefix];
427
714
  case "renderChunk":
428
- return [event.PlanS3Uri, event.ChunkOutputS3Prefix];
715
+ return event.PlanProtocol === "v2" ? [event.PlanV2ManifestS3Uri, event.PlanV2ArtifactS3Prefix, event.ChunkOutputS3Prefix] : [event.PlanS3Uri, event.ChunkOutputS3Prefix];
429
716
  case "assemble":
430
- return [event.PlanS3Uri, ...event.ChunkS3Uris, event.OutputS3Uri, event.AudioS3Uri].filter(
431
- (u) => u != null
432
- );
717
+ return [
718
+ ...event.PlanProtocol === "v2" ? [event.PlanV2ManifestS3Uri, event.PlanV2ArtifactS3Prefix] : [event.PlanS3Uri],
719
+ ...event.ChunkS3Uris,
720
+ event.OutputS3Uri,
721
+ event.AudioS3Uri
722
+ ].filter((u) => u != null);
433
723
  }
434
724
  }
435
725
  function validateEventS3Uris(event) {