@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/index.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
 
@@ -95,9 +99,14 @@ import {
95
99
  rmSync,
96
100
  statSync
97
101
  } from "node:fs";
102
+ import { createHash } from "node:crypto";
98
103
  import { dirname } from "node:path";
99
104
  import { pipeline } from "node:stream/promises";
100
- import { GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
105
+ import {
106
+ GetObjectCommand,
107
+ HeadObjectCommand,
108
+ PutObjectCommand
109
+ } from "@aws-sdk/client-s3";
101
110
  import * as tar from "tar";
102
111
  function parseS3Uri(uri) {
103
112
  if (!uri.startsWith("s3://")) {
@@ -128,6 +137,19 @@ async function downloadS3ObjectToFile(client, uri, destPath) {
128
137
  mkdirSync(dirname(destPath), { recursive: true });
129
138
  await pipeline(body, createWriteStream(destPath));
130
139
  }
140
+ async function downloadS3ObjectToFileVerified(client, uri, destPath, expectedSha256) {
141
+ assertSha256(expectedSha256);
142
+ await downloadS3ObjectToFile(client, uri, destPath);
143
+ const actual = await sha256File(destPath);
144
+ if (actual !== expectedSha256) {
145
+ rmSync(destPath, { force: true });
146
+ const error = new Error(
147
+ `[s3Transport] PLAN_ARTIFACT_DIGEST_MISMATCH: ${uri} expected ${expectedSha256}, got ${actual}`
148
+ );
149
+ error.name = "PLAN_ARTIFACT_DIGEST_MISMATCH";
150
+ throw error;
151
+ }
152
+ }
131
153
  async function uploadFileToS3(client, localPath, uri, contentType) {
132
154
  if (!existsSync2(localPath)) {
133
155
  throw new Error(`[s3Transport] upload source missing: ${localPath}`);
@@ -144,6 +166,68 @@ async function uploadFileToS3(client, localPath, uri, contentType) {
144
166
  })
145
167
  );
146
168
  }
169
+ async function uploadContentAddressedFileToS3(client, localPath, uri, expectedSha256, contentType) {
170
+ assertSha256(expectedSha256);
171
+ if (!existsSync2(localPath)) {
172
+ throw new Error(`[s3Transport] upload source missing: ${localPath}`);
173
+ }
174
+ const actualSha256 = await sha256File(localPath);
175
+ if (actualSha256 !== expectedSha256) {
176
+ const error = new Error(
177
+ `[s3Transport] PLAN_ARTIFACT_DIGEST_MISMATCH: local artifact ${localPath} expected ${expectedSha256}, got ${actualSha256}`
178
+ );
179
+ error.name = "PLAN_ARTIFACT_DIGEST_MISMATCH";
180
+ throw error;
181
+ }
182
+ const { bucket, key } = parseS3Uri(uri);
183
+ const size = statSync(localPath).size;
184
+ try {
185
+ const existing = await client.send(
186
+ new HeadObjectCommand({ Bucket: bucket, Key: key, ChecksumMode: "ENABLED" })
187
+ );
188
+ if (existing.ContentLength === size && existing.Metadata?.sha256 === expectedSha256) {
189
+ return "reused";
190
+ }
191
+ const error = new Error(
192
+ `[s3Transport] PLAN_ARTIFACT_DIGEST_MISMATCH: immutable object ${uri} already exists with different digest metadata or size`
193
+ );
194
+ error.name = "PLAN_ARTIFACT_DIGEST_MISMATCH";
195
+ throw error;
196
+ } catch (error) {
197
+ if (!isS3NotFound(error)) throw error;
198
+ }
199
+ await client.send(
200
+ new PutObjectCommand({
201
+ Bucket: bucket,
202
+ Key: key,
203
+ Body: createReadStream(localPath),
204
+ ContentType: contentType,
205
+ ContentLength: size,
206
+ Metadata: { sha256: expectedSha256 },
207
+ ChecksumSHA256: Buffer.from(expectedSha256, "hex").toString("base64")
208
+ })
209
+ );
210
+ return "uploaded";
211
+ }
212
+ async function sha256File(path) {
213
+ const hash = createHash("sha256");
214
+ for await (const chunk of createReadStream(path)) {
215
+ hash.update(chunk);
216
+ }
217
+ return hash.digest("hex");
218
+ }
219
+ function assertSha256(value) {
220
+ if (!/^[a-f0-9]{64}$/.test(value)) {
221
+ throw new Error(
222
+ `[s3Transport] expected lowercase SHA-256 digest, got ${JSON.stringify(value)}`
223
+ );
224
+ }
225
+ }
226
+ function isS3NotFound(error) {
227
+ if (!error || typeof error !== "object") return false;
228
+ const candidate = error;
229
+ return candidate.name === "NotFound" || candidate.name === "NoSuchKey" || candidate.$metadata?.httpStatusCode === 404;
230
+ }
147
231
  async function tarDirectory(sourceDir, destTarball) {
148
232
  if (!existsSync2(sourceDir) || !statSync(sourceDir).isDirectory()) {
149
233
  throw new Error(`[s3Transport] tar source must be an existing directory: ${sourceDir}`);
@@ -192,15 +276,24 @@ async function handler(event, deps) {
192
276
  }
193
277
  }
194
278
  } catch (err) {
279
+ normalizeTerminalErrorName(err);
195
280
  logEvent({
196
281
  event: "handler_error",
197
282
  action: unwrapped.Action,
283
+ input: summarizeEvent(unwrapped),
198
284
  message: err instanceof Error ? err.message : String(err),
199
285
  name: err instanceof Error ? err.name : void 0
200
286
  });
201
287
  throw err;
202
288
  }
203
289
  }
290
+ function normalizeTerminalErrorName(error) {
291
+ if (!error || typeof error !== "object") return;
292
+ const candidate = error;
293
+ if (candidate.code === "PLAN_PROTOCOL_UNSUPPORTED" || candidate.code === "PLAN_TOO_LARGE" || candidate.code === "PLAN_V2_INTEGRITY_UNRECOVERABLE") {
294
+ candidate.name = candidate.code;
295
+ }
296
+ }
204
297
  var MAX_ENVELOPE_DEPTH = 4;
205
298
  function unwrapEvent(event) {
206
299
  let cursor = event;
@@ -237,18 +330,21 @@ function summarizeEvent(event) {
237
330
  return {
238
331
  projectS3Uri: event.ProjectS3Uri,
239
332
  planOutputS3Prefix: event.PlanOutputS3Prefix,
333
+ planProtocol: event.PlanProtocol ?? "v1",
240
334
  format: event.Config.format,
241
335
  fps: event.Config.fps
242
336
  };
243
337
  case "renderChunk":
244
338
  return {
245
- planS3Uri: event.PlanS3Uri,
339
+ planProtocol: event.PlanProtocol ?? "v1",
340
+ ...event.PlanProtocol === "v2" ? { planV2ManifestS3Uri: event.PlanV2ManifestS3Uri } : { planS3Uri: event.PlanS3Uri },
246
341
  chunkIndex: event.ChunkIndex,
247
342
  format: event.Format
248
343
  };
249
344
  case "assemble":
250
345
  return {
251
- planS3Uri: event.PlanS3Uri,
346
+ planProtocol: event.PlanProtocol ?? "v1",
347
+ ...event.PlanProtocol === "v2" ? { planV2ManifestS3Uri: event.PlanV2ManifestS3Uri } : { planS3Uri: event.PlanS3Uri },
252
348
  chunkCount: event.ChunkS3Uris.length,
253
349
  hasAudio: event.AudioS3Uri !== null,
254
350
  outputS3Uri: event.OutputS3Uri,
@@ -267,6 +363,9 @@ function primeRuntimeEnv() {
267
363
  }
268
364
  }
269
365
  async function handlePlan(event, deps) {
366
+ if (event.PlanProtocol === "v2") {
367
+ return handlePlanV2(event, deps);
368
+ }
270
369
  const started = Date.now();
271
370
  const s3 = deps?.s3 ?? getS3Client();
272
371
  const primitive = deps?.primitives?.plan ?? plan;
@@ -315,7 +414,73 @@ async function handlePlan(event, deps) {
315
414
  cleanupDir(work);
316
415
  }
317
416
  }
417
+ async function handlePlanV2(event, deps) {
418
+ const started = Date.now();
419
+ const s3 = deps?.s3 ?? getS3Client();
420
+ const primitive = deps?.primitives?.planV2 ?? planV2;
421
+ if (!deps?.skipChromeResolution && !process.env.PRODUCER_HEADLESS_SHELL_PATH) {
422
+ process.env.PRODUCER_HEADLESS_SHELL_PATH = await resolveChromeExecutablePath();
423
+ }
424
+ const work = mkdtempSync(join(deps?.tmpRoot ?? tmpdir(), "hf-lambda-plan-v2-"));
425
+ const projectArchive = join(work, "project.tar.gz");
426
+ const projectDir = join(work, "project");
427
+ const planV2Dir = join(work, "plan-v2");
428
+ try {
429
+ await downloadS3ObjectToFile(s3, event.ProjectS3Uri, projectArchive);
430
+ await untarDirectory(projectArchive, projectDir);
431
+ const result = await primitive(projectDir, { ...event.Config }, planV2Dir);
432
+ const manifest = readPlanV2Manifest(planV2Dir);
433
+ if (manifest.planHash !== result.planHash) {
434
+ throwPlanHashMismatch(result.planHash, manifest.planHash);
435
+ }
436
+ const outputPrefix = `${trimTrailingSlash(event.PlanOutputS3Prefix)}/v2`;
437
+ const artifactPrefix = `${outputPrefix}/artifacts/sha256`;
438
+ const uniqueArtifacts = [
439
+ ...new Map(manifest.artifacts.map((artifact) => [artifact.sha256, artifact])).values()
440
+ ];
441
+ await mapConcurrent(uniqueArtifacts, 16, async (artifact) => {
442
+ const localPath = planV2BlobPath(planV2Dir, artifact.sha256);
443
+ await uploadContentAddressedFileToS3(
444
+ s3,
445
+ localPath,
446
+ planV2BlobUri(artifactPrefix, artifact.sha256),
447
+ artifact.sha256
448
+ );
449
+ });
450
+ const manifestUri = `${outputPrefix}/manifest.json`;
451
+ await uploadContentAddressedFileToS3(
452
+ s3,
453
+ result.manifestPath,
454
+ manifestUri,
455
+ await sha256File(result.manifestPath),
456
+ "application/json"
457
+ );
458
+ return {
459
+ Action: "plan",
460
+ PlanProtocol: "v2",
461
+ PlanV2ManifestS3Uri: manifestUri,
462
+ PlanV2ArtifactS3Prefix: artifactPrefix,
463
+ PlanHash: result.planHash,
464
+ ChunkCount: result.chunkCount,
465
+ TotalFrames: result.totalFrames,
466
+ Fps: result.fps,
467
+ Width: result.width,
468
+ Height: result.height,
469
+ Format: result.format,
470
+ HasAudio: manifest.artifacts.some((artifact) => artifact.path === "audio.aac"),
471
+ AudioS3Uri: null,
472
+ FfmpegVersion: result.ffmpegVersion,
473
+ ProducerVersion: result.producerVersion,
474
+ DurationMs: Date.now() - started
475
+ };
476
+ } finally {
477
+ cleanupDir(work);
478
+ }
479
+ }
318
480
  async function handleRenderChunk(event, deps) {
481
+ if (event.PlanProtocol === "v2") {
482
+ return handleRenderChunkV2(event, deps);
483
+ }
319
484
  const started = Date.now();
320
485
  const s3 = deps?.s3 ?? getS3Client();
321
486
  const primitive = deps?.primitives?.renderChunk ?? renderChunk;
@@ -353,6 +518,44 @@ async function handleRenderChunk(event, deps) {
353
518
  cleanupDir(work);
354
519
  }
355
520
  }
521
+ async function handleRenderChunkV2(event, deps) {
522
+ const started = Date.now();
523
+ const s3 = deps?.s3 ?? getS3Client();
524
+ const primitive = deps?.primitives?.renderChunk ?? renderChunk;
525
+ if (!deps?.skipChromeResolution && !process.env.PRODUCER_HEADLESS_SHELL_PATH) {
526
+ process.env.PRODUCER_HEADLESS_SHELL_PATH = await resolveChromeExecutablePath();
527
+ }
528
+ const work = mkdtempSync(join(deps?.tmpRoot ?? tmpdir(), "hf-lambda-chunk-v2-"));
529
+ try {
530
+ const planDir = await downloadAndMaterializePlanV2(
531
+ s3,
532
+ event,
533
+ { role: "chunk", chunkIndex: event.ChunkIndex },
534
+ work
535
+ );
536
+ const chunkOutputBase = join(
537
+ work,
538
+ event.Format === "png-sequence" ? `chunk-${pad(event.ChunkIndex)}` : `chunk-${pad(event.ChunkIndex)}${formatExtension(event.Format)}`
539
+ );
540
+ const result = await primitive(planDir, event.ChunkIndex, chunkOutputBase);
541
+ const chunkUri = await uploadChunkOutput(
542
+ s3,
543
+ result,
544
+ event.ChunkOutputS3Prefix,
545
+ event.ChunkIndex
546
+ );
547
+ return {
548
+ Action: "renderChunk",
549
+ ChunkS3Uri: chunkUri,
550
+ ChunkIndex: event.ChunkIndex,
551
+ Sha256: result.sha256,
552
+ FramesEncoded: result.framesEncoded,
553
+ DurationMs: Date.now() - started
554
+ };
555
+ } finally {
556
+ cleanupDir(work);
557
+ }
558
+ }
356
559
  async function uploadChunkOutput(s3, result, prefix, chunkIndex) {
357
560
  const trimmed = trimTrailingSlash(prefix);
358
561
  if (result.outputKind === "file") {
@@ -368,6 +571,9 @@ async function uploadChunkOutput(s3, result, prefix, chunkIndex) {
368
571
  return uri;
369
572
  }
370
573
  async function handleAssemble(event, deps) {
574
+ if (event.PlanProtocol === "v2") {
575
+ return handleAssembleV2(event, deps);
576
+ }
371
577
  const started = Date.now();
372
578
  const s3 = deps?.s3 ?? getS3Client();
373
579
  const primitive = deps?.primitives?.assemble ?? assemble;
@@ -405,6 +611,87 @@ async function handleAssemble(event, deps) {
405
611
  cleanupDir(work);
406
612
  }
407
613
  }
614
+ async function handleAssembleV2(event, deps) {
615
+ const started = Date.now();
616
+ const s3 = deps?.s3 ?? getS3Client();
617
+ const primitive = deps?.primitives?.assemble ?? assemble;
618
+ const work = mkdtempSync(join(deps?.tmpRoot ?? tmpdir(), "hf-lambda-assemble-v2-"));
619
+ try {
620
+ const planDir = await downloadAndMaterializePlanV2(s3, event, { role: "assembler" }, work);
621
+ const audioPath = existsSync3(join(planDir, "audio.aac")) ? join(planDir, "audio.aac") : null;
622
+ const chunkPaths = await downloadChunkObjects(s3, event.ChunkS3Uris, work, event.Format);
623
+ const finalOutput = event.Format === "png-sequence" ? join(work, "output-frames") : join(work, `output${formatExtension(event.Format)}`);
624
+ const result = await primitive(planDir, chunkPaths, audioPath, finalOutput, {
625
+ cfr: event.Cfr === true
626
+ });
627
+ if (event.Format === "png-sequence") {
628
+ const tarball = `${finalOutput}.tar.gz`;
629
+ await tarDirectory(finalOutput, tarball);
630
+ await uploadFileToS3(s3, tarball, event.OutputS3Uri, "application/gzip");
631
+ } else {
632
+ await uploadFileToS3(s3, finalOutput, event.OutputS3Uri);
633
+ }
634
+ return {
635
+ Action: "assemble",
636
+ OutputS3Uri: event.OutputS3Uri,
637
+ FramesEncoded: result.framesEncoded,
638
+ FileSize: result.fileSize,
639
+ DurationMs: Date.now() - started
640
+ };
641
+ } finally {
642
+ cleanupDir(work);
643
+ }
644
+ }
645
+ async function downloadAndMaterializePlanV2(s3, event, target, work) {
646
+ const transportDir = join(work, "plan-v2");
647
+ mkdirSync2(transportDir, { recursive: true });
648
+ await downloadS3ObjectToFile(s3, event.PlanV2ManifestS3Uri, join(transportDir, "plan.json"));
649
+ const manifest = readPlanV2Manifest(transportDir);
650
+ if (manifest.planHash !== event.PlanHash) {
651
+ throwPlanHashMismatch(event.PlanHash, manifest.planHash);
652
+ }
653
+ const artifacts = listPlanV2ArtifactsForTarget(manifest, target);
654
+ const uniqueArtifacts = [
655
+ ...new Map(artifacts.map((artifact) => [artifact.sha256, artifact])).values()
656
+ ];
657
+ await mapConcurrent(uniqueArtifacts, 16, async (artifact) => {
658
+ await downloadPlanV2Artifact(s3, event.PlanV2ArtifactS3Prefix, transportDir, artifact);
659
+ });
660
+ const planDir = join(work, "plan");
661
+ materializePlanV2Target(transportDir, target, planDir);
662
+ return planDir;
663
+ }
664
+ async function downloadPlanV2Artifact(s3, artifactPrefix, planV2Dir, artifact) {
665
+ await downloadS3ObjectToFileVerified(
666
+ s3,
667
+ planV2BlobUri(artifactPrefix, artifact.sha256),
668
+ planV2BlobPath(planV2Dir, artifact.sha256),
669
+ artifact.sha256
670
+ );
671
+ }
672
+ function planV2BlobPath(planV2Dir, digest) {
673
+ return join(planV2Dir, "artifacts", "sha256", digest.slice(0, 2), digest);
674
+ }
675
+ function planV2BlobUri(prefix, digest) {
676
+ return `${trimTrailingSlash(prefix)}/${digest.slice(0, 2)}/${digest}`;
677
+ }
678
+ function throwPlanHashMismatch(expected, actual) {
679
+ const error = new Error(
680
+ `PLAN_HASH_MISMATCH: event PlanHash=${expected} did not match v2 manifest planHash=${actual}`
681
+ );
682
+ error.name = "PLAN_HASH_MISMATCH";
683
+ throw error;
684
+ }
685
+ async function mapConcurrent(values, concurrency, fn) {
686
+ let cursor = 0;
687
+ async function worker() {
688
+ while (cursor < values.length) {
689
+ const index = cursor++;
690
+ await fn(values[index]);
691
+ }
692
+ }
693
+ await Promise.all(Array.from({ length: Math.min(concurrency, values.length) }, () => worker()));
694
+ }
408
695
  async function downloadChunkObjects(s3, uris, workDir, format) {
409
696
  const chunksDir = join(workDir, "chunks");
410
697
  mkdirSync2(chunksDir, { recursive: true });
@@ -433,11 +720,14 @@ function getEventS3Uris(event) {
433
720
  case "plan":
434
721
  return [event.ProjectS3Uri, event.PlanOutputS3Prefix];
435
722
  case "renderChunk":
436
- return [event.PlanS3Uri, event.ChunkOutputS3Prefix];
723
+ return event.PlanProtocol === "v2" ? [event.PlanV2ManifestS3Uri, event.PlanV2ArtifactS3Prefix, event.ChunkOutputS3Prefix] : [event.PlanS3Uri, event.ChunkOutputS3Prefix];
437
724
  case "assemble":
438
- return [event.PlanS3Uri, ...event.ChunkS3Uris, event.OutputS3Uri, event.AudioS3Uri].filter(
439
- (u) => u != null
440
- );
725
+ return [
726
+ ...event.PlanProtocol === "v2" ? [event.PlanV2ManifestS3Uri, event.PlanV2ArtifactS3Prefix] : [event.PlanS3Uri],
727
+ ...event.ChunkS3Uris,
728
+ event.OutputS3Uri,
729
+ event.AudioS3Uri
730
+ ].filter((u) => u != null);
441
731
  }
442
732
  }
443
733
  function validateEventS3Uris(event) {
@@ -491,7 +781,7 @@ function verifyPlanHash(planDir, expected) {
491
781
  import { mkdtempSync as mkdtempSync2, rmSync as rmSync3, statSync as statSync3 } from "node:fs";
492
782
  import { tmpdir as tmpdir2 } from "node:os";
493
783
  import { join as join2 } from "node:path";
494
- import { HeadObjectCommand, S3Client as S3Client2 } from "@aws-sdk/client-s3";
784
+ import { HeadObjectCommand as HeadObjectCommand2, S3Client as S3Client2 } from "@aws-sdk/client-s3";
495
785
  import { hashProjectDir } from "@hyperframes/producer/distributed";
496
786
  async function deploySite(opts) {
497
787
  if (!statSync3(opts.projectDir).isDirectory()) {
@@ -532,7 +822,7 @@ async function deploySite(opts) {
532
822
  }
533
823
  async function headObject(s3, bucket, key) {
534
824
  try {
535
- const res = await s3.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
825
+ const res = await s3.send(new HeadObjectCommand2({ Bucket: bucket, Key: key }));
536
826
  return {
537
827
  bytes: typeof res.ContentLength === "number" ? res.ContentLength : 0,
538
828
  lastModified: res.LastModified instanceof Date ? res.LastModified.toISOString() : (/* @__PURE__ */ new Date()).toISOString()
@@ -614,7 +904,8 @@ async function renderToLambda(opts) {
614
904
  ProjectS3Uri: site.projectS3Uri,
615
905
  PlanOutputS3Prefix: planOutputS3Prefix,
616
906
  OutputS3Uri: outputS3Uri,
617
- Config: opts.config
907
+ Config: opts.config,
908
+ PlanProtocol: opts.planProtocol ?? "v1"
618
909
  };
619
910
  validateStepFunctionsInputSize(input);
620
911
  const sfn = opts.sfn ?? new SFNClient({ region: opts.region });
@@ -804,7 +1095,7 @@ function summarizeHistory(events, memoryMb) {
804
1095
  }
805
1096
  case "TaskStateExited":
806
1097
  case "MapStateExited":
807
- if (ev.stateExitedEventDetails?.name === "Assemble") {
1098
+ if (isAssembleState(ev.stateExitedEventDetails?.name)) {
808
1099
  assembleComplete = true;
809
1100
  const exitPayload = parseJson(ev.stateExitedEventDetails?.output);
810
1101
  if (exitPayload && typeof exitPayload === "object") {
@@ -883,11 +1174,17 @@ function unwrapLambdaPayload(payload) {
883
1174
  return payload;
884
1175
  }
885
1176
  function applyPayloadFrameCounts(payload, currentLambdaState, bump) {
886
- if (currentLambdaState !== "RenderChunk") return;
1177
+ if (!isRenderChunkState(currentLambdaState)) return;
887
1178
  if (!payload || typeof payload !== "object") return;
888
1179
  const obj = payload;
889
1180
  if (typeof obj.FramesEncoded === "number") bump(obj.FramesEncoded);
890
1181
  }
1182
+ function isRenderChunkState(name) {
1183
+ return name === "RenderChunk" || name === "RenderChunkV2";
1184
+ }
1185
+ function isAssembleState(name) {
1186
+ return name === "Assemble" || name === "AssembleV2";
1187
+ }
891
1188
  function inferBilledMs(payload) {
892
1189
  if (!payload || typeof payload !== "object") return 0;
893
1190
  const obj = payload;
@@ -915,6 +1212,7 @@ export {
915
1212
  computeRenderCost,
916
1213
  deploySite,
917
1214
  downloadS3ObjectToFile,
1215
+ downloadS3ObjectToFileVerified,
918
1216
  formatS3Uri,
919
1217
  getRenderProgress,
920
1218
  handler,
@@ -926,6 +1224,7 @@ export {
926
1224
  tarDirectory,
927
1225
  untarDirectory,
928
1226
  unwrapEvent,
1227
+ uploadContentAddressedFileToS3,
929
1228
  uploadFileToS3,
930
1229
  validateDistributedRenderConfig
931
1230
  };