@hyperframes/aws-lambda 0.8.180 → 0.8.183

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.
@@ -16,7 +16,7 @@ import {
16
16
  siteManifestUri,
17
17
  siteTarballUri,
18
18
  uploadContentAddressedFileToS3
19
- } from "./chunk-ZY5YIH5W.js";
19
+ } from "./chunk-SGNVVPIS.js";
20
20
  import {
21
21
  CONTENT_STORE_RETENTION_DAYS
22
22
  } from "./chunk-YS3KY3C5.js";
@@ -7,6 +7,7 @@ import {
7
7
  formatS3Uri,
8
8
  isSiteManifestUri,
9
9
  mapConcurrent,
10
+ multipartUpload,
10
11
  parseS3Uri,
11
12
  presignVerifiedS3GetUrl,
12
13
  putContentStoreFile,
@@ -14,7 +15,7 @@ import {
14
15
  untarDirectory,
15
16
  uploadContentAddressedFileToS3,
16
17
  uploadFileToS3
17
- } from "./chunk-ZY5YIH5W.js";
18
+ } from "./chunk-SGNVVPIS.js";
18
19
  import {
19
20
  CONTENT_STORE_PREFIX
20
21
  } from "./chunk-YS3KY3C5.js";
@@ -27,7 +28,10 @@ import { GetAccountSettingsCommand, LambdaClient } from "@aws-sdk/client-lambda"
27
28
  import { S3Client } from "@aws-sdk/client-s3";
28
29
  import {
29
30
  assemble,
31
+ buildPlanAudio,
30
32
  chunksAreStitchedMp4,
33
+ plansSeparateAudio,
34
+ stitchesSeparateAudio,
31
35
  DEFAULT_MAX_PARALLEL_CHUNKS,
32
36
  listPlanV2ArtifactsForTarget,
33
37
  materializePlanV2Target,
@@ -109,9 +113,6 @@ async function loadSparticuzChromium() {
109
113
 
110
114
  // src/s3Stitch.ts
111
115
  import {
112
- AbortMultipartUploadCommand,
113
- CompleteMultipartUploadCommand,
114
- CreateMultipartUploadCommand,
115
116
  GetObjectCommand,
116
117
  UploadPartCommand,
117
118
  UploadPartCopyCommand
@@ -185,41 +186,29 @@ async function sendParts(s3, parts, target) {
185
186
  (result) => result.status === "rejected"
186
187
  );
187
188
  if (failure) throw failure.reason;
188
- return settled.map((result) => result.value);
189
+ return settled.map((result, index) => ({
190
+ ETag: result.value,
191
+ PartNumber: index + 1
192
+ }));
189
193
  }
190
194
  async function uploadParts(s3, parts, destUri, contentType) {
191
195
  const { bucket, key } = parseS3Uri(destUri);
192
- const created = await s3.send(
193
- new CreateMultipartUploadCommand({ Bucket: bucket, Key: key, ContentType: contentType })
196
+ await multipartUpload(
197
+ s3,
198
+ { Bucket: bucket, Key: key, ContentType: contentType },
199
+ (target) => sendParts(s3, parts, target)
194
200
  );
195
- if (!created.UploadId) throw new Error(`[s3Stitch] no upload id for ${destUri}`);
196
- const target = { Bucket: bucket, Key: key, UploadId: created.UploadId };
197
- try {
198
- const etags = await sendParts(s3, parts, target);
199
- await s3.send(
200
- new CompleteMultipartUploadCommand({
201
- ...target,
202
- MultipartUpload: { Parts: etags.map((ETag, index) => ({ ETag, PartNumber: index + 1 })) }
203
- })
204
- );
205
- } catch (error) {
206
- await s3.send(new AbortMultipartUploadCommand(target)).catch((abortError) => {
207
- console.error(
208
- `[s3Stitch] abort failed for ${destUri}; parts remain until a lifecycle rule removes them`,
209
- abortError
210
- );
211
- });
212
- throw error;
213
- }
214
201
  }
215
- async function stitchMp4InS3(s3, chunkUris, destUri, minPartBytes = MIN_PART_BYTES) {
202
+ async function stitchMp4InS3(s3, chunkUris, destUri, { audioUri, minPartBytes = MIN_PART_BYTES } = {}) {
203
+ const sourceUris = audioUri ? [audioUri, ...chunkUris] : chunkUris;
216
204
  const layouts = await Promise.all(
217
- chunkUris.map((uri) => readMp4Layout((start, end) => getRange(s3, uri, start, end)))
205
+ sourceUris.map((uri) => readMp4Layout((start, end) => getRange(s3, uri, start, end)))
218
206
  );
207
+ const header = audioUri ? buildStitchedHeader(layouts.slice(1), layouts[0]) : buildStitchedHeader(layouts);
219
208
  const segments = [
220
- { bytes: buildStitchedHeader(layouts) },
209
+ { bytes: header },
221
210
  ...layouts.map((layout, i) => ({
222
- uri: chunkUris[i],
211
+ uri: sourceUris[i],
223
212
  start: layout.payloadStart,
224
213
  end: layout.payloadEnd
225
214
  }))
@@ -365,6 +354,8 @@ async function handler(event, deps) {
365
354
  switch (unwrapped.Action) {
366
355
  case "plan":
367
356
  return await handlePlan(unwrapped, deps);
357
+ case "audio":
358
+ return await handleAudio(unwrapped, deps);
368
359
  case "renderChunk":
369
360
  return await handleRenderChunk(unwrapped, deps);
370
361
  case "assemble":
@@ -422,7 +413,7 @@ function unwrapEvent(event) {
422
413
  );
423
414
  }
424
415
  function isLambdaAction(value) {
425
- return value === "plan" || value === "renderChunk" || value === "assemble";
416
+ return value === "plan" || value === "audio" || value === "renderChunk" || value === "assemble";
426
417
  }
427
418
  function validatePlanProtocolShape(event) {
428
419
  const raw = event;
@@ -434,7 +425,7 @@ function validatePlanProtocolShape(event) {
434
425
  error.name = "PLAN_PROTOCOL_UNSUPPORTED";
435
426
  throw error;
436
427
  }
437
- if (event.Action === "plan") return;
428
+ if (event.Action === "plan" || event.Action === "audio") return;
438
429
  const effectiveProtocol = protocol ?? "v2";
439
430
  const hasV1Locator = typeof raw.PlanS3Uri === "string";
440
431
  const hasV2Manifest = typeof raw.PlanV2ManifestS3Uri === "string";
@@ -447,11 +438,6 @@ function validatePlanProtocolShape(event) {
447
438
  error.name = "PLAN_PROTOCOL_UNSUPPORTED";
448
439
  throw error;
449
440
  }
450
- if (effectiveProtocol === "v2" && event.Action === "assemble" && event.AudioS3Uri !== null) {
451
- const error = new Error("[handler] v2 assemble audio must be materialized from the manifest");
452
- error.name = "PLAN_PROTOCOL_UNSUPPORTED";
453
- throw error;
454
- }
455
441
  }
456
442
  function logEvent(payload) {
457
443
  console.log(JSON.stringify(payload));
@@ -466,6 +452,13 @@ function summarizeEvent(event) {
466
452
  format: event.Config.format,
467
453
  fps: event.Config.fps
468
454
  };
455
+ case "audio":
456
+ return {
457
+ projectS3Uri: event.ProjectS3Uri,
458
+ planOutputS3Prefix: event.PlanOutputS3Prefix,
459
+ format: event.Config.format,
460
+ fps: event.Config.fps
461
+ };
469
462
  case "renderChunk":
470
463
  return {
471
464
  planProtocol: event.PlanProtocol ?? "v2",
@@ -539,10 +532,7 @@ async function handlePlan(event, deps) {
539
532
  const config = await planConfig(event, deps);
540
533
  const s3 = deps?.s3 ?? getS3Client();
541
534
  const primitive = deps?.primitives?.plan ?? plan;
542
- if (!deps?.skipChromeResolution && !process.env.PRODUCER_HEADLESS_SHELL_PATH) {
543
- const chromePath = await resolveChromeExecutablePath();
544
- process.env.PRODUCER_HEADLESS_SHELL_PATH = chromePath;
545
- }
535
+ await ensureChromePath(deps);
546
536
  const work = mkdtempSync2(join2(deps?.tmpRoot ?? tmpdir2(), "hf-lambda-plan-"));
547
537
  const planDir = join2(work, "plan");
548
538
  try {
@@ -601,9 +591,7 @@ async function handlePlanV2(event, deps) {
601
591
  const config = await planConfig(event, deps);
602
592
  const s3 = deps?.s3 ?? getS3Client();
603
593
  const primitive = deps?.primitives?.planV2WithPublisher ?? planV2WithPublisher;
604
- if (!deps?.skipChromeResolution && !process.env.PRODUCER_HEADLESS_SHELL_PATH) {
605
- process.env.PRODUCER_HEADLESS_SHELL_PATH = await resolveChromeExecutablePath();
606
- }
594
+ await ensureChromePath(deps);
607
595
  const work = mkdtempSync2(join2(deps?.tmpRoot ?? tmpdir2(), "hf-lambda-plan-v2-"));
608
596
  try {
609
597
  const { projectDir, remoteFiles } = await fetchProject(s3, event.ProjectS3Uri, work, true);
@@ -612,10 +600,12 @@ async function handlePlanV2(event, deps) {
612
600
  planOutputS3Prefix: event.PlanOutputS3Prefix,
613
601
  temporaryRoot: work
614
602
  });
603
+ const separateAudio = config.workerConcurrency > 1 && plansSeparateAudio(config, { deferAudio: true });
615
604
  const manifest = await primitive(projectDir, config, publisher, {
616
605
  stagingParentDir: work,
617
606
  remoteFiles,
618
- chunksCarryAudio: true
607
+ chunksCarryAudio: true,
608
+ deferAudio: separateAudio
619
609
  });
620
610
  return {
621
611
  Action: "plan",
@@ -624,7 +614,10 @@ async function handlePlanV2(event, deps) {
624
614
  PlanV2ArtifactS3Prefix: publisher.artifactPrefix,
625
615
  PlanHash: manifest.planHash,
626
616
  ChunkCount: manifest.chunkCount,
627
- MaxChunkConcurrency: Math.min(config.workerConcurrency, manifest.chunkCount),
617
+ MaxChunkConcurrency: Math.min(
618
+ config.workerConcurrency - (separateAudio ? 1 : 0),
619
+ manifest.chunkCount
620
+ ),
628
621
  TotalFrames: manifest.totalFrames,
629
622
  Fps: manifest.fps,
630
623
  Width: manifest.width,
@@ -632,6 +625,7 @@ async function handlePlanV2(event, deps) {
632
625
  Format: manifest.format,
633
626
  HasAudio: manifest.artifacts.some((artifact) => isPlanAudioArtifactPath(artifact.path)),
634
627
  AudioS3Uri: null,
628
+ SeparateAudio: separateAudio,
635
629
  FfmpegVersion: manifest.ffmpegVersion,
636
630
  ProducerVersion: manifest.producerVersion,
637
631
  DurationMs: Date.now() - started
@@ -640,6 +634,42 @@ async function handlePlanV2(event, deps) {
640
634
  cleanupDir(work);
641
635
  }
642
636
  }
637
+ async function ensureChromePath(deps) {
638
+ if (deps?.skipChromeResolution || process.env.PRODUCER_HEADLESS_SHELL_PATH) return;
639
+ process.env.PRODUCER_HEADLESS_SHELL_PATH = await resolveChromeExecutablePath();
640
+ }
641
+ async function handleAudio(event, deps) {
642
+ const started = Date.now();
643
+ const none = () => ({
644
+ Action: "audio",
645
+ HasAudio: false,
646
+ AudioS3Uri: null,
647
+ DurationMs: Date.now() - started
648
+ });
649
+ if (!plansSeparateAudio(event.Config, { deferAudio: true })) return none();
650
+ const s3 = deps?.s3 ?? getS3Client();
651
+ await ensureChromePath(deps);
652
+ const work = mkdtempSync2(join2(deps?.tmpRoot ?? tmpdir2(), "hf-lambda-audio-"));
653
+ try {
654
+ const { projectDir, remoteFiles } = await fetchProject(s3, event.ProjectS3Uri, work, true);
655
+ const audioPath = join2(work, "audio.m4a");
656
+ const build = deps?.primitives?.buildPlanAudio ?? buildPlanAudio;
657
+ const { hasAudio } = await build(projectDir, event.Config, join2(work, "plan"), audioPath, {
658
+ remoteFiles
659
+ });
660
+ if (!hasAudio) return none();
661
+ const audioUri = `${trimTrailingSlash2(event.PlanOutputS3Prefix)}/audio/${PLAN_AUDIO_RELATIVE_PATH}`;
662
+ await uploadFileToS3(s3, audioPath, audioUri, "audio/mp4");
663
+ return {
664
+ Action: "audio",
665
+ HasAudio: true,
666
+ AudioS3Uri: audioUri,
667
+ DurationMs: Date.now() - started
668
+ };
669
+ } finally {
670
+ cleanupDir(work);
671
+ }
672
+ }
643
673
  async function handleRenderChunk(event, deps) {
644
674
  if (event.PlanProtocol !== "v1") {
645
675
  return handleRenderChunkV2(event, deps);
@@ -647,10 +677,7 @@ async function handleRenderChunk(event, deps) {
647
677
  const started = Date.now();
648
678
  const s3 = deps?.s3 ?? getS3Client();
649
679
  const primitive = deps?.primitives?.renderChunk ?? renderChunk;
650
- if (!deps?.skipChromeResolution && !process.env.PRODUCER_HEADLESS_SHELL_PATH) {
651
- const chromePath = await resolveChromeExecutablePath();
652
- process.env.PRODUCER_HEADLESS_SHELL_PATH = chromePath;
653
- }
680
+ await ensureChromePath(deps);
654
681
  const work = mkdtempSync2(join2(deps?.tmpRoot ?? tmpdir2(), "hf-lambda-chunk-"));
655
682
  const planTar = join2(work, "plan.tar.gz");
656
683
  const planDir = join2(work, "plan");
@@ -686,9 +713,7 @@ async function handleRenderChunkV2(event, deps) {
686
713
  const started = Date.now();
687
714
  const s3 = deps?.s3 ?? getS3Client();
688
715
  const primitive = deps?.primitives?.renderChunk ?? renderChunk;
689
- if (!deps?.skipChromeResolution && !process.env.PRODUCER_HEADLESS_SHELL_PATH) {
690
- process.env.PRODUCER_HEADLESS_SHELL_PATH = await resolveChromeExecutablePath();
691
- }
716
+ await ensureChromePath(deps);
692
717
  const work = mkdtempSync2(join2(deps?.tmpRoot ?? tmpdir2(), "hf-lambda-chunk-v2-"));
693
718
  try {
694
719
  const { planDir } = await downloadAndMaterializePlanV2(
@@ -788,6 +813,11 @@ async function handleAssembleV2(event, deps) {
788
813
  { role: "assembler" },
789
814
  work
790
815
  );
816
+ if (event.AudioS3Uri !== null && !stitchesSeparateAudio(planDir)) {
817
+ const error = new Error("[handler] v2 assemble audio must be materialized from the manifest");
818
+ error.name = "PLAN_PROTOCOL_UNSUPPORTED";
819
+ throw error;
820
+ }
791
821
  if (chunksAreStitchedMp4(planDir, event.Cfr === true)) {
792
822
  return await stitchChunksInS3(s3, event, manifest, started);
793
823
  }
@@ -832,7 +862,9 @@ async function stitchChunksInS3(s3, event, plan2, started) {
832
862
  );
833
863
  }
834
864
  const stitchStarted = Date.now();
835
- const stitched = await stitchMp4InS3(s3, event.ChunkS3Uris, event.OutputS3Uri);
865
+ const stitched = await stitchMp4InS3(s3, event.ChunkS3Uris, event.OutputS3Uri, {
866
+ audioUri: event.AudioS3Uri ?? void 0
867
+ });
836
868
  logEvent({
837
869
  event: "assemble_stages",
838
870
  mode: "s3-stitch",
@@ -936,6 +968,7 @@ async function downloadChunkObjects(s3, uris, workDir, format) {
936
968
  function getEventS3Uris(event) {
937
969
  switch (event.Action) {
938
970
  case "plan":
971
+ case "audio":
939
972
  return [event.ProjectS3Uri, event.PlanOutputS3Prefix];
940
973
  case "renderChunk":
941
974
  return event.PlanProtocol !== "v1" ? [event.PlanV2ManifestS3Uri, event.PlanV2ArtifactS3Prefix, event.ChunkOutputS3Prefix] : [event.PlanS3Uri, event.ChunkOutputS3Prefix];
@@ -16,13 +16,23 @@ import { createHash } from "node:crypto";
16
16
  import { dirname } from "node:path";
17
17
  import { pipeline } from "node:stream/promises";
18
18
  import {
19
+ AbortMultipartUploadCommand,
20
+ CompleteMultipartUploadCommand,
19
21
  CopyObjectCommand,
22
+ CreateMultipartUploadCommand,
20
23
  GetObjectCommand,
21
24
  HeadObjectCommand,
22
- PutObjectCommand
25
+ PutObjectCommand,
26
+ UploadPartCommand,
27
+ UploadPartCopyCommand
23
28
  } from "@aws-sdk/client-s3";
24
29
  import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
25
30
  import * as tar from "tar";
31
+ var MAX_SINGLE_PUT_BYTES = 5 * 1024 ** 3;
32
+ var MAX_PARTS = 1e4;
33
+ var MIN_FILE_PART_BYTES = 64 * 1024 * 1024;
34
+ var FILE_PARTS_IN_FLIGHT = 8;
35
+ var PART_ATTEMPTS = 3;
26
36
  function parseS3Uri(uri) {
27
37
  if (!uri.startsWith("s3://")) {
28
38
  throw new Error(`[s3Transport] expected s3:// URI, got: ${JSON.stringify(uri)}`);
@@ -72,7 +82,8 @@ async function presignVerifiedS3GetUrl(client, uri, expectedSha256, expectedSize
72
82
  new HeadObjectCommand({ Bucket: bucket, Key: key, ChecksumMode: "ENABLED" })
73
83
  );
74
84
  const expectedChecksum = Buffer.from(expectedSha256, "hex").toString("base64");
75
- if (head.ChecksumSHA256 !== expectedChecksum || head.ContentLength !== expectedSize) {
85
+ const attested = head.ChecksumSHA256 === expectedChecksum || isDigestCheckedMultipart(head, expectedSha256);
86
+ if (!attested || head.ContentLength !== expectedSize) {
76
87
  const error = new Error(
77
88
  `[s3Transport] PLAN_ARTIFACT_DIGEST_MISMATCH: ${uri} expected ${expectedSha256} (${expectedSize} bytes), S3 holds checksum ${String(head.ChecksumSHA256)} (${String(head.ContentLength)} bytes)`
78
89
  );
@@ -83,12 +94,23 @@ async function presignVerifiedS3GetUrl(client, uri, expectedSha256, expectedSize
83
94
  expiresIn: expiresInSeconds
84
95
  });
85
96
  }
97
+ function isDigestCheckedMultipart(head, expectedSha256) {
98
+ return head.ChecksumType === "COMPOSITE" && (head.ContentLength ?? 0) > MAX_SINGLE_PUT_BYTES && head.Metadata?.sha256 === expectedSha256;
99
+ }
86
100
  async function uploadFileToS3(client, localPath, uri, contentType) {
87
101
  if (!existsSync(localPath)) {
88
102
  throw new Error(`[s3Transport] upload source missing: ${localPath}`);
89
103
  }
90
104
  const { bucket, key } = parseS3Uri(uri);
91
105
  const size = statSync(localPath).size;
106
+ if (size > MAX_SINGLE_PUT_BYTES) {
107
+ await uploadFileInParts(client, localPath, size, {
108
+ Bucket: bucket,
109
+ Key: key,
110
+ ContentType: contentType
111
+ });
112
+ return;
113
+ }
92
114
  await client.send(
93
115
  new PutObjectCommand({
94
116
  Bucket: bucket,
@@ -111,8 +133,25 @@ async function putImmutableFile(client, localPath, uri, expectedSha256, contentT
111
133
  const size = statSync(localPath).size;
112
134
  const existing = await inspectContentAddressedObject(client, bucket, key, size, expectedSha256);
113
135
  if (existing.state === "matching")
114
- return { outcome: "reused", lastModified: existing.lastModified };
136
+ return { outcome: "reused", lastModified: existing.lastModified, size };
115
137
  if (existing.state === "conflict") throwImmutableObjectConflict(uri);
138
+ try {
139
+ if (size > MAX_SINGLE_PUT_BYTES) {
140
+ const create2 = { Bucket: bucket, Key: key, ContentType: contentType };
141
+ await uploadFileInParts(client, localPath, size, create2, expectedSha256);
142
+ } else {
143
+ await putImmutableBody(client, localPath, bucket, key, size, expectedSha256, contentType);
144
+ }
145
+ return { outcome: "uploaded" };
146
+ } catch (error) {
147
+ if (!isS3PreconditionFailed(error)) throw error;
148
+ const raced = await inspectContentAddressedObject(client, bucket, key, size, expectedSha256);
149
+ if (raced.state === "matching") return { outcome: "reused" };
150
+ if (raced.state === "conflict") throwImmutableObjectConflict(uri);
151
+ throw error;
152
+ }
153
+ }
154
+ async function putImmutableBody(client, localPath, bucket, key, size, expectedSha256, contentType) {
116
155
  const body = createReadStream(localPath);
117
156
  try {
118
157
  await client.send(
@@ -124,23 +163,119 @@ async function putImmutableFile(client, localPath, uri, expectedSha256, contentT
124
163
  ContentLength: size,
125
164
  Metadata: { sha256: expectedSha256 },
126
165
  ChecksumSHA256: Buffer.from(expectedSha256, "hex").toString("base64"),
127
- // HEAD followed by an unconditional PUT can overwrite a conflicting
128
- // object published by a concurrent planner. Conditional create makes
129
- // immutable CAS and fixed-key manifest publication race-safe.
130
166
  IfNoneMatch: "*"
131
167
  })
132
168
  );
133
- return { outcome: "uploaded" };
134
- } catch (error) {
135
- if (!isS3PreconditionFailed(error)) throw error;
136
- const raced = await inspectContentAddressedObject(client, bucket, key, size, expectedSha256);
137
- if (raced.state === "matching") return { outcome: "reused" };
138
- if (raced.state === "conflict") throwImmutableObjectConflict(uri);
139
- throw error;
140
169
  } finally {
141
170
  body.destroy();
142
171
  }
143
172
  }
173
+ async function multipartUpload(client, create2, sendParts, complete = {}) {
174
+ const created = await client.send(new CreateMultipartUploadCommand(create2));
175
+ const uri = formatS3Uri({ bucket: create2.Bucket, key: create2.Key });
176
+ if (!created.UploadId) throw new Error(`[s3Transport] no multipart upload id for ${uri}`);
177
+ const target = { Bucket: create2.Bucket, Key: create2.Key, UploadId: created.UploadId };
178
+ try {
179
+ const parts = await sendParts(target);
180
+ await client.send(
181
+ new CompleteMultipartUploadCommand({
182
+ ...target,
183
+ MultipartUpload: { Parts: parts },
184
+ IfNoneMatch: complete.ifNoneMatch,
185
+ MpuObjectSize: complete.objectSize
186
+ })
187
+ );
188
+ } catch (error) {
189
+ await client.send(new AbortMultipartUploadCommand(target)).catch((abortError) => {
190
+ console.error(
191
+ `[s3Transport] abort failed for ${uri}; parts remain until a lifecycle rule removes them`,
192
+ abortError
193
+ );
194
+ });
195
+ throw error;
196
+ }
197
+ }
198
+ async function uploadFileInParts(client, localPath, size, create2, expectedSha256) {
199
+ const partBytes = Math.max(MIN_FILE_PART_BYTES, Math.ceil(size / MAX_PARTS));
200
+ const sendParts = async (target) => {
201
+ const whole = createHash("sha256");
202
+ const parts = [];
203
+ const inFlight = /* @__PURE__ */ new Set();
204
+ let failure;
205
+ try {
206
+ for (let start = 0; start < size && !failure; start += partBytes) {
207
+ if (inFlight.size >= FILE_PARTS_IN_FLIGHT) await Promise.race(inFlight);
208
+ const range = { start, end: Math.min(start + partBytes, size) };
209
+ const checksum = await hashRange(localPath, range, whole);
210
+ const sent = sendFilePart(client, target, parts.length + 1, localPath, range, checksum);
211
+ parts.push(sent);
212
+ const slot = sent.then(
213
+ () => void inFlight.delete(slot),
214
+ (error) => {
215
+ failure ??= { error };
216
+ inFlight.delete(slot);
217
+ }
218
+ );
219
+ inFlight.add(slot);
220
+ }
221
+ } finally {
222
+ await Promise.all(inFlight);
223
+ }
224
+ if (failure) throw failure.error;
225
+ const actual = whole.digest("hex");
226
+ if (expectedSha256 && actual !== expectedSha256) {
227
+ const error = new Error(
228
+ `[s3Transport] PLAN_ARTIFACT_DIGEST_MISMATCH: ${localPath} changed: expected ${expectedSha256}, read ${actual}`
229
+ );
230
+ error.name = "PLAN_ARTIFACT_DIGEST_MISMATCH";
231
+ throw error;
232
+ }
233
+ return Promise.all(parts);
234
+ };
235
+ await multipartUpload(
236
+ client,
237
+ {
238
+ ...create2,
239
+ Metadata: expectedSha256 ? { sha256: expectedSha256 } : void 0,
240
+ ChecksumAlgorithm: "SHA256"
241
+ },
242
+ sendParts,
243
+ { ifNoneMatch: expectedSha256 ? "*" : void 0, objectSize: size }
244
+ );
245
+ }
246
+ async function hashRange(path, { start, end }, whole) {
247
+ const part = createHash("sha256");
248
+ let read = 0;
249
+ for await (const chunk of createReadStream(path, { start, end: end - 1 })) {
250
+ whole.update(chunk);
251
+ part.update(chunk);
252
+ read += chunk.length;
253
+ }
254
+ if (read !== end - start) throw new Error(`[s3Transport] ${path} shrank while uploading`);
255
+ return part.digest("base64");
256
+ }
257
+ async function sendFilePart(client, target, partNumber, path, { start, end }, checksum) {
258
+ for (let attempt = 1; ; attempt++) {
259
+ const body = createReadStream(path, { start, end: end - 1 });
260
+ try {
261
+ const result = await client.send(
262
+ new UploadPartCommand({
263
+ ...target,
264
+ PartNumber: partNumber,
265
+ Body: body,
266
+ ContentLength: end - start,
267
+ ChecksumSHA256: checksum
268
+ })
269
+ );
270
+ if (!result.ETag) throw new Error(`[s3Transport] part ${partNumber} returned no ETag`);
271
+ return { ETag: result.ETag, PartNumber: partNumber, ChecksumSHA256: checksum };
272
+ } catch (error) {
273
+ if (attempt >= PART_ATTEMPTS) throw error;
274
+ } finally {
275
+ body.destroy();
276
+ }
277
+ }
278
+ }
144
279
  async function mapConcurrent(values, concurrency, fn) {
145
280
  let cursor = 0;
146
281
  async function worker() {
@@ -184,8 +319,8 @@ async function inspectContentAddressedObject(client, bucket, key, expectedSize,
184
319
  }
185
320
  async function putContentStoreFile(client, localPath, bucket, sha256) {
186
321
  const uri = contentStoreUri(bucket, sha256);
187
- const { outcome, lastModified } = await putImmutableFile(client, localPath, uri, sha256);
188
- if (lastModified) await refreshAgingObject(client, uri, sha256, lastModified);
322
+ const { outcome, lastModified, size } = await putImmutableFile(client, localPath, uri, sha256);
323
+ if (lastModified) await refreshAgingObject(client, uri, sha256, lastModified, size ?? 0);
189
324
  return outcome;
190
325
  }
191
326
  async function ensureContentStoreObject(client, bucket, sha256, sizeBytes) {
@@ -193,7 +328,9 @@ async function ensureContentStoreObject(client, bucket, sha256, sizeBytes) {
193
328
  const { key } = parseS3Uri(uri);
194
329
  const existing = await inspectContentAddressedObject(client, bucket, key, sizeBytes, sha256);
195
330
  if (existing.state === "matching") {
196
- if (existing.lastModified) await refreshAgingObject(client, uri, sha256, existing.lastModified);
331
+ if (existing.lastModified) {
332
+ await refreshAgingObject(client, uri, sha256, existing.lastModified, sizeBytes);
333
+ }
197
334
  return;
198
335
  }
199
336
  if (existing.state === "conflict") throwImmutableObjectConflict(uri);
@@ -209,10 +346,42 @@ async function presignS3GetUrl(client, uri, expiresInSeconds) {
209
346
  expiresIn: expiresInSeconds
210
347
  });
211
348
  }
212
- async function refreshAgingObject(client, uri, sha256, lastModified) {
349
+ async function refreshAgingObject(client, uri, sha256, lastModified, size) {
213
350
  const { bucket, key } = parseS3Uri(uri);
214
351
  const ageMs = Date.now() - lastModified.getTime();
215
352
  if (ageMs < CONTENT_STORE_REFRESH_AFTER_DAYS * 864e5) return;
353
+ if (size > MAX_SINGLE_PUT_BYTES) {
354
+ const create2 = {
355
+ Bucket: bucket,
356
+ Key: key,
357
+ Metadata: { sha256 },
358
+ ChecksumAlgorithm: "SHA256"
359
+ };
360
+ await multipartUpload(client, create2, async (target) => {
361
+ const parts = [];
362
+ for (let start = 0; start < size; start += MAX_SINGLE_PUT_BYTES) {
363
+ const end = Math.min(start + MAX_SINGLE_PUT_BYTES, size);
364
+ const PartNumber = parts.length + 1;
365
+ const { CopyPartResult } = await client.send(
366
+ new UploadPartCopyCommand({
367
+ ...target,
368
+ PartNumber,
369
+ CopySource: `${bucket}/${key}`,
370
+ CopySourceRange: `bytes=${start}-${end - 1}`
371
+ })
372
+ );
373
+ if (!CopyPartResult?.ETag)
374
+ throw new Error(`[s3Transport] copy part ${PartNumber} of ${uri} returned no ETag`);
375
+ parts.push({
376
+ ETag: CopyPartResult.ETag,
377
+ PartNumber,
378
+ ChecksumSHA256: CopyPartResult.ChecksumSHA256
379
+ });
380
+ }
381
+ return parts;
382
+ });
383
+ return;
384
+ }
216
385
  await client.send(
217
386
  new CopyObjectCommand({
218
387
  Bucket: bucket,
@@ -361,6 +530,7 @@ export {
361
530
  presignVerifiedS3GetUrl,
362
531
  uploadFileToS3,
363
532
  uploadContentAddressedFileToS3,
533
+ multipartUpload,
364
534
  mapConcurrent,
365
535
  sha256File,
366
536
  putContentStoreFile,
package/dist/cdk/index.js CHANGED
@@ -250,7 +250,8 @@ var HyperframesRenderStack = class extends Construct {
250
250
  "ChunkCount.$": "$.Payload.ChunkCount",
251
251
  "MaxChunkConcurrency.$": "$.Payload.MaxChunkConcurrency",
252
252
  "Format.$": "$.Payload.Format",
253
- "HasAudio.$": "$.Payload.HasAudio"
253
+ "HasAudio.$": "$.Payload.HasAudio",
254
+ "SeparateAudio.$": "$.Payload.SeparateAudio"
254
255
  },
255
256
  resultPath: "$.Plan"
256
257
  });
@@ -404,8 +405,8 @@ var HyperframesRenderStack = class extends Construct {
404
405
  "PlanV2ManifestS3Uri.$": "$.Plan.PlanV2ManifestS3Uri",
405
406
  "PlanV2ArtifactS3Prefix.$": "$.Plan.PlanV2ArtifactS3Prefix",
406
407
  "PlanHash.$": "$.Plan.PlanHash",
407
- "ChunkS3Uris.$": "$.Chunks[*].ChunkS3Uri",
408
- AudioS3Uri: sfn.JsonPath.stringToJson("null"),
408
+ "ChunkS3Uris.$": "$.Render.Chunks[*].ChunkS3Uri",
409
+ "AudioS3Uri.$": "$.Render.Audio.AudioS3Uri",
409
410
  "OutputS3Uri.$": "$.OutputS3Uri",
410
411
  "Format.$": "$.Plan.Format"
411
412
  }),
@@ -425,10 +426,40 @@ var HyperframesRenderStack = class extends Construct {
425
426
  backoffRate: 2,
426
427
  maxDelay: Duration.seconds(60)
427
428
  });
428
- const selectWorkerProtocol = new sfn.Choice(this, "SelectWorkerProtocol").when(
429
- sfn.Condition.stringEquals("$.Plan.PlanProtocol", "v2"),
430
- renderChunksV2.next(assembleV2)
431
- ).otherwise(renderChunks.next(assemble));
429
+ const audio = new tasks.LambdaInvoke(this, "Audio", {
430
+ lambdaFunction: this.renderFunction,
431
+ payload: sfn.TaskInput.fromObject({
432
+ Action: "audio",
433
+ "ProjectS3Uri.$": "$.ProjectS3Uri",
434
+ "PlanOutputS3Prefix.$": "$.PlanOutputS3Prefix",
435
+ "Config.$": "$.Config"
436
+ }),
437
+ resultSelector: {
438
+ "HasAudio.$": "$.Payload.HasAudio",
439
+ "AudioS3Uri.$": "$.Payload.AudioS3Uri"
440
+ },
441
+ resultPath: "$.Audio"
442
+ });
443
+ audio.addRetry({ errors: NON_RETRYABLE_PLAN, maxAttempts: 0 });
444
+ audio.addRetry(THROTTLED_RETRY);
445
+ audio.addRetry({
446
+ errors: ["States.ALL"],
447
+ interval: Duration.seconds(2),
448
+ maxAttempts: 4,
449
+ backoffRate: 2,
450
+ maxDelay: Duration.seconds(60)
451
+ });
452
+ const audioNeeded = new sfn.Choice(this, "AudioNeeded").when(sfn.Condition.booleanEquals("$.Plan.SeparateAudio", true), audio).otherwise(
453
+ new sfn.Pass(this, "NoAudio", {
454
+ result: sfn.Result.fromObject({ HasAudio: false, AudioS3Uri: null }),
455
+ resultPath: "$.Audio"
456
+ })
457
+ );
458
+ const renderV2 = new sfn.Parallel(this, "RenderV2", {
459
+ resultSelector: { "Chunks.$": "$[0].Chunks", "Audio.$": "$[1].Audio" },
460
+ resultPath: "$.Render"
461
+ }).branch(renderChunksV2).branch(audioNeeded);
462
+ const selectWorkerProtocol = new sfn.Choice(this, "SelectWorkerProtocol").when(sfn.Condition.stringEquals("$.Plan.PlanProtocol", "v2"), renderV2.next(assembleV2)).otherwise(renderChunks.next(assemble));
432
463
  const assertChunkCount = new sfn.Choice(this, "AssertChunkCount").when(sfn.Condition.numberGreaterThan("$.Plan.ChunkCount", 0), selectWorkerProtocol).otherwise(planProducedZero);
433
464
  plan.next(buildChunkList);
434
465
  planV2.next(buildChunkList);