@hyperframes/aws-lambda 0.8.172 → 0.8.174

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/cdk/index.js CHANGED
@@ -3,6 +3,7 @@ import { dirname, resolve } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { Duration, RemovalPolicy, Size } from "aws-cdk-lib";
5
5
  import * as cloudwatch from "aws-cdk-lib/aws-cloudwatch";
6
+ import * as iam from "aws-cdk-lib/aws-iam";
6
7
  import * as lambda from "aws-cdk-lib/aws-lambda";
7
8
  import * as logs from "aws-cdk-lib/aws-logs";
8
9
  import * as s3 from "aws-cdk-lib/aws-s3";
@@ -59,10 +60,14 @@ var HyperframesRenderStack = class extends Construct {
59
60
  NODE_OPTIONS: "--enable-source-maps",
60
61
  TMPDIR: "/tmp",
61
62
  HYPERFRAMES_LAMBDA_CHROME_SOURCE: chromeSource,
62
- HYPERFRAMES_RENDER_BUCKET: this.bucket.bucketName
63
+ HYPERFRAMES_RENDER_BUCKET: this.bucket.bucketName,
64
+ HYPERFRAMES_LAMBDA_RESERVED_CONCURRENCY: String(props.reservedConcurrency ?? -1)
63
65
  }
64
66
  });
65
67
  this.bucket.grantReadWrite(this.renderFunction);
68
+ this.renderFunction.addToRolePolicy(
69
+ new iam.PolicyStatement({ actions: ["lambda:GetAccountSettings"], resources: ["*"] })
70
+ );
66
71
  const stateMachineLogGroup = new logs.LogGroup(this, "RenderStateMachineLogGroup", {
67
72
  logGroupName: `/aws/states/${projectName}-render`,
68
73
  retention: logs.RetentionDays.ONE_MONTH,
@@ -143,6 +148,7 @@ var HyperframesRenderStack = class extends Construct {
143
148
  "PlanV2IntegrityError",
144
149
  "PLAN_ARTIFACT_DIGEST_MISMATCH",
145
150
  "FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
151
+ "CHUNK_EXCEEDS_WORKER_TIMEOUT",
146
152
  "ChromeBinaryUnavailableError"
147
153
  ];
148
154
  const NON_RETRYABLE_CHUNK = [
@@ -174,6 +180,14 @@ var HyperframesRenderStack = class extends Construct {
174
180
  "PLAN_ARTIFACT_DIGEST_MISMATCH",
175
181
  "ChromeBinaryUnavailableError"
176
182
  ];
183
+ const THROTTLED_RETRY = {
184
+ errors: ["Lambda.TooManyRequestsException"],
185
+ interval: Duration.seconds(5),
186
+ maxAttempts: 10,
187
+ backoffRate: 2,
188
+ maxDelay: Duration.seconds(60),
189
+ jitterStrategy: sfn.JitterType.FULL
190
+ };
177
191
  const plan = new tasks.LambdaInvoke(this, "Plan", {
178
192
  lambdaFunction: this.renderFunction,
179
193
  payload: sfn.TaskInput.fromObject({
@@ -188,6 +202,7 @@ var HyperframesRenderStack = class extends Construct {
188
202
  "PlanS3Uri.$": "$.Payload.PlanS3Uri",
189
203
  "PlanHash.$": "$.Payload.PlanHash",
190
204
  "ChunkCount.$": "$.Payload.ChunkCount",
205
+ "MaxChunkConcurrency.$": "$.Payload.MaxChunkConcurrency",
191
206
  "Format.$": "$.Payload.Format",
192
207
  "HasAudio.$": "$.Payload.HasAudio",
193
208
  "AudioS3Uri.$": "$.Payload.AudioS3Uri"
@@ -198,6 +213,7 @@ var HyperframesRenderStack = class extends Construct {
198
213
  errors: NON_RETRYABLE_PLAN,
199
214
  maxAttempts: 0
200
215
  });
216
+ plan.addRetry(THROTTLED_RETRY);
201
217
  plan.addRetry({
202
218
  errors: ["States.ALL"],
203
219
  interval: Duration.seconds(2),
@@ -220,12 +236,14 @@ var HyperframesRenderStack = class extends Construct {
220
236
  "PlanV2ArtifactS3Prefix.$": "$.Payload.PlanV2ArtifactS3Prefix",
221
237
  "PlanHash.$": "$.Payload.PlanHash",
222
238
  "ChunkCount.$": "$.Payload.ChunkCount",
239
+ "MaxChunkConcurrency.$": "$.Payload.MaxChunkConcurrency",
223
240
  "Format.$": "$.Payload.Format",
224
241
  "HasAudio.$": "$.Payload.HasAudio"
225
242
  },
226
243
  resultPath: "$.Plan"
227
244
  });
228
245
  planV2.addRetry({ errors: NON_RETRYABLE_PLAN, maxAttempts: 0 });
246
+ planV2.addRetry(THROTTLED_RETRY);
229
247
  planV2.addRetry({
230
248
  errors: ["States.ALL"],
231
249
  interval: Duration.seconds(2),
@@ -257,13 +275,16 @@ var HyperframesRenderStack = class extends Construct {
257
275
  resultSelector: {
258
276
  "ChunkS3Uri.$": "$.Payload.ChunkS3Uri",
259
277
  "ChunkIndex.$": "$.Payload.ChunkIndex",
260
- "Sha256.$": "$.Payload.Sha256"
278
+ "Sha256.$": "$.Payload.Sha256",
279
+ "FramesEncoded.$": "$.Payload.FramesEncoded",
280
+ "DurationMs.$": "$.Payload.DurationMs"
261
281
  }
262
282
  });
263
283
  renderChunkTask.addRetry({
264
284
  errors: NON_RETRYABLE_CHUNK,
265
285
  maxAttempts: 0
266
286
  });
287
+ renderChunkTask.addRetry(THROTTLED_RETRY);
267
288
  renderChunkTask.addRetry({
268
289
  errors: ["States.ALL"],
269
290
  interval: Duration.seconds(2),
@@ -271,7 +292,10 @@ var HyperframesRenderStack = class extends Construct {
271
292
  backoffRate: 2,
272
293
  maxDelay: Duration.seconds(60)
273
294
  });
274
- const renderChunks = new sfn.Map(this, "RenderChunks", {
295
+ const renderChunks = new sfn.DistributedMap(this, "RenderChunks", {
296
+ label: "RenderChunks",
297
+ // Standard-type children: express ones stop at 5 minutes and a chunk may run the Lambda's full 15.
298
+ mapExecutionType: sfn.StateMachineType.STANDARD,
275
299
  itemsPath: "$.Iterator.ChunkIndexes",
276
300
  itemSelector: {
277
301
  "ChunkIndex.$": "$$.Map.Item.Value",
@@ -280,7 +304,8 @@ var HyperframesRenderStack = class extends Construct {
280
304
  "ChunkOutputS3Prefix.$": "$.PlanOutputS3Prefix",
281
305
  "Format.$": "$.Plan.Format"
282
306
  },
283
- maxConcurrencyPath: "$.Plan.ChunkCount",
307
+ // Chunks past the plan's concurrency queue here instead of being throttled by Lambda.
308
+ maxConcurrencyPath: "$.Plan.MaxChunkConcurrency",
284
309
  resultPath: "$.Chunks"
285
310
  });
286
311
  renderChunks.itemProcessor(renderChunkTask);
@@ -306,6 +331,7 @@ var HyperframesRenderStack = class extends Construct {
306
331
  errors: NON_RETRYABLE_ASSEMBLE,
307
332
  maxAttempts: 0
308
333
  });
334
+ assemble.addRetry(THROTTLED_RETRY);
309
335
  assemble.addRetry({
310
336
  errors: ["States.ALL"],
311
337
  interval: Duration.seconds(2),
@@ -328,10 +354,13 @@ var HyperframesRenderStack = class extends Construct {
328
354
  resultSelector: {
329
355
  "ChunkS3Uri.$": "$.Payload.ChunkS3Uri",
330
356
  "ChunkIndex.$": "$.Payload.ChunkIndex",
331
- "Sha256.$": "$.Payload.Sha256"
357
+ "Sha256.$": "$.Payload.Sha256",
358
+ "FramesEncoded.$": "$.Payload.FramesEncoded",
359
+ "DurationMs.$": "$.Payload.DurationMs"
332
360
  }
333
361
  });
334
362
  renderChunkV2Task.addRetry({ errors: NON_RETRYABLE_CHUNK, maxAttempts: 0 });
363
+ renderChunkV2Task.addRetry(THROTTLED_RETRY);
335
364
  renderChunkV2Task.addRetry({
336
365
  errors: ["States.ALL"],
337
366
  interval: Duration.seconds(2),
@@ -339,7 +368,9 @@ var HyperframesRenderStack = class extends Construct {
339
368
  backoffRate: 2,
340
369
  maxDelay: Duration.seconds(60)
341
370
  });
342
- const renderChunksV2 = new sfn.Map(this, "RenderChunksV2", {
371
+ const renderChunksV2 = new sfn.DistributedMap(this, "RenderChunksV2", {
372
+ label: "RenderChunksV2",
373
+ mapExecutionType: sfn.StateMachineType.STANDARD,
343
374
  itemsPath: "$.Iterator.ChunkIndexes",
344
375
  itemSelector: {
345
376
  "ChunkIndex.$": "$$.Map.Item.Value",
@@ -349,7 +380,7 @@ var HyperframesRenderStack = class extends Construct {
349
380
  "ChunkOutputS3Prefix.$": "$.PlanOutputS3Prefix",
350
381
  "Format.$": "$.Plan.Format"
351
382
  },
352
- maxConcurrencyPath: "$.Plan.ChunkCount",
383
+ maxConcurrencyPath: "$.Plan.MaxChunkConcurrency",
353
384
  resultPath: "$.Chunks"
354
385
  });
355
386
  renderChunksV2.itemProcessor(renderChunkV2Task);
@@ -374,6 +405,7 @@ var HyperframesRenderStack = class extends Construct {
374
405
  resultPath: "$.Output"
375
406
  });
376
407
  assembleV2.addRetry({ errors: NON_RETRYABLE_ASSEMBLE, maxAttempts: 0 });
408
+ assembleV2.addRetry(THROTTLED_RETRY);
377
409
  assembleV2.addRetry({
378
410
  errors: ["States.ALL"],
379
411
  interval: Duration.seconds(2),
package/dist/events.d.ts CHANGED
@@ -140,6 +140,7 @@ interface PlanLambdaResultBase {
140
140
  Action: "plan";
141
141
  PlanHash: string;
142
142
  ChunkCount: number;
143
+ MaxChunkConcurrency: number;
143
144
  TotalFrames: number;
144
145
  Fps: SerializableDistributedRenderConfig["fps"];
145
146
  Width: number;
package/dist/handler.d.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  * primitives. The handler is thin glue: parse event → S3 download → call
11
11
  * primitive → S3 upload → return small JSON result.
12
12
  */
13
+ import { LambdaClient } from "@aws-sdk/client-lambda";
13
14
  import { S3Client } from "@aws-sdk/client-s3";
14
15
  import { assemble, type ChunkRenderer, plan, planV2WithPublisher } from "@hyperframes/producer/distributed";
15
16
  import type { AssembleEvent, LambdaEvent, LambdaResult, PlanEvent, RenderChunkEvent } from "./events.js";
@@ -22,6 +23,8 @@ import type { AssembleEvent, LambdaEvent, LambdaResult, PlanEvent, RenderChunkEv
22
23
  */
23
24
  export interface HandlerDeps {
24
25
  s3?: S3Client;
26
+ /** Reads the account's concurrency when the render does not set one. */
27
+ lambda?: LambdaClient;
25
28
  primitives?: {
26
29
  plan: typeof plan;
27
30
  planV2WithPublisher?: typeof planV2WithPublisher;
package/dist/handler.js CHANGED
@@ -2,9 +2,12 @@
2
2
  import { existsSync as existsSync3, mkdirSync as mkdirSync3, mkdtempSync as mkdtempSync2, readFileSync, rmSync as rmSync3, statSync as statSync3 } from "node:fs";
3
3
  import { tmpdir as tmpdir2 } from "node:os";
4
4
  import { basename, join as join2 } from "node:path";
5
+ import { GetAccountSettingsCommand, LambdaClient } from "@aws-sdk/client-lambda";
5
6
  import { S3Client } from "@aws-sdk/client-s3";
6
7
  import {
7
8
  assemble,
9
+ chunksAreStitchedMp4,
10
+ DEFAULT_MAX_PARALLEL_CHUNKS,
8
11
  listPlanV2ArtifactsForTarget,
9
12
  materializePlanV2Target,
10
13
  plan,
@@ -77,16 +80,16 @@ async function loadSparticuzChromium() {
77
80
  return resolved;
78
81
  }
79
82
 
80
- // src/formatExtension.ts
81
- var FORMAT_EXTENSIONS = {
82
- mp4: ".mp4",
83
- mov: ".mov",
84
- webm: ".webm",
85
- "png-sequence": ""
86
- };
87
- function formatExtension(format) {
88
- return FORMAT_EXTENSIONS[format];
89
- }
83
+ // src/s3Stitch.ts
84
+ import {
85
+ AbortMultipartUploadCommand,
86
+ CompleteMultipartUploadCommand,
87
+ CreateMultipartUploadCommand,
88
+ GetObjectCommand as GetObjectCommand2,
89
+ UploadPartCommand,
90
+ UploadPartCopyCommand
91
+ } from "@aws-sdk/client-s3";
92
+ import { buildStitchedHeader, readMp4Layout } from "@hyperframes/producer/distributed";
90
93
 
91
94
  // src/s3Transport.ts
92
95
  import {
@@ -262,6 +265,135 @@ async function untarDirectory(tarballPath, destDir) {
262
265
  await tar.extract({ file: tarballPath, cwd: destDir });
263
266
  }
264
267
 
268
+ // src/s3Stitch.ts
269
+ var MIN_PART_BYTES = 5 * 1024 * 1024;
270
+ var segmentSize = (s) => "bytes" in s ? s.bytes.length : s.end - s.start;
271
+ function planParts(segments, min = MIN_PART_BYTES) {
272
+ const parts = [];
273
+ let pending = [];
274
+ let pendingSize = 0;
275
+ const flush = () => {
276
+ if (pending.length) parts.push({ upload: pending });
277
+ pending = [];
278
+ pendingSize = 0;
279
+ };
280
+ for (const segment of segments) {
281
+ const borrow = pendingSize ? min - pendingSize : 0;
282
+ if ("uri" in segment && segmentSize(segment) - borrow >= min) {
283
+ if (borrow) pending.push({ ...segment, end: segment.start + borrow });
284
+ flush();
285
+ parts.push({ copy: { ...segment, start: segment.start + borrow } });
286
+ continue;
287
+ }
288
+ pending.push(segment);
289
+ pendingSize += segmentSize(segment);
290
+ if (pendingSize >= min) flush();
291
+ }
292
+ flush();
293
+ return parts;
294
+ }
295
+ async function getRange(s3, uri, start, end) {
296
+ const { bucket, key } = parseS3Uri(uri);
297
+ const response = await s3.send(
298
+ new GetObjectCommand2({ Bucket: bucket, Key: key, Range: `bytes=${start}-${end - 1}` })
299
+ );
300
+ if (!response.Body) throw new Error(`[s3Stitch] empty body for ${uri}`);
301
+ return Buffer.from(await response.Body.transformToByteArray());
302
+ }
303
+ async function sendPart(s3, part, partNumber, target) {
304
+ if ("copy" in part) {
305
+ const { uri, start, end } = part.copy;
306
+ const source = parseS3Uri(uri);
307
+ const result2 = await s3.send(
308
+ new UploadPartCopyCommand({
309
+ ...target,
310
+ PartNumber: partNumber,
311
+ CopySource: encodeURI(`${source.bucket}/${source.key}`),
312
+ CopySourceRange: `bytes=${start}-${end - 1}`
313
+ })
314
+ );
315
+ return result2.CopyPartResult?.ETag;
316
+ }
317
+ const body = await Promise.all(
318
+ part.upload.map((s) => "bytes" in s ? s.bytes : getRange(s3, s.uri, s.start, s.end))
319
+ );
320
+ const result = await s3.send(
321
+ new UploadPartCommand({ ...target, PartNumber: partNumber, Body: Buffer.concat(body) })
322
+ );
323
+ return result.ETag;
324
+ }
325
+ async function sendParts(s3, parts, target) {
326
+ const settled = await Promise.allSettled(
327
+ parts.map(async (part, index) => {
328
+ const etag = await sendPart(s3, part, index + 1, target);
329
+ if (!etag) throw new Error(`[s3Stitch] part ${index + 1} returned no ETag`);
330
+ return etag;
331
+ })
332
+ );
333
+ const failure = settled.find(
334
+ (result) => result.status === "rejected"
335
+ );
336
+ if (failure) throw failure.reason;
337
+ return settled.map((result) => result.value);
338
+ }
339
+ async function uploadParts(s3, parts, destUri, contentType) {
340
+ const { bucket, key } = parseS3Uri(destUri);
341
+ const created = await s3.send(
342
+ new CreateMultipartUploadCommand({ Bucket: bucket, Key: key, ContentType: contentType })
343
+ );
344
+ if (!created.UploadId) throw new Error(`[s3Stitch] no upload id for ${destUri}`);
345
+ const target = { Bucket: bucket, Key: key, UploadId: created.UploadId };
346
+ try {
347
+ const etags = await sendParts(s3, parts, target);
348
+ await s3.send(
349
+ new CompleteMultipartUploadCommand({
350
+ ...target,
351
+ MultipartUpload: { Parts: etags.map((ETag, index) => ({ ETag, PartNumber: index + 1 })) }
352
+ })
353
+ );
354
+ } catch (error) {
355
+ await s3.send(new AbortMultipartUploadCommand(target)).catch((abortError) => {
356
+ console.error(
357
+ `[s3Stitch] abort failed for ${destUri}; parts remain until a lifecycle rule removes them`,
358
+ abortError
359
+ );
360
+ });
361
+ throw error;
362
+ }
363
+ }
364
+ async function stitchMp4InS3(s3, chunkUris, destUri, minPartBytes = MIN_PART_BYTES) {
365
+ const layouts = await Promise.all(
366
+ chunkUris.map((uri) => readMp4Layout((start, end) => getRange(s3, uri, start, end)))
367
+ );
368
+ const segments = [
369
+ { bytes: buildStitchedHeader(layouts) },
370
+ ...layouts.map((layout, i) => ({
371
+ uri: chunkUris[i],
372
+ start: layout.payloadStart,
373
+ end: layout.payloadEnd
374
+ }))
375
+ ];
376
+ const parts = planParts(segments, minPartBytes);
377
+ await uploadParts(s3, parts, destUri, "video/mp4");
378
+ const uploaded = parts.flatMap((part) => "upload" in part ? part.upload : []);
379
+ return {
380
+ fileSize: segments.reduce((total, s) => total + segmentSize(s), 0),
381
+ parts: parts.length,
382
+ downloadedBytes: uploaded.reduce((total, s) => total + ("uri" in s ? segmentSize(s) : 0), 0)
383
+ };
384
+ }
385
+
386
+ // src/formatExtension.ts
387
+ var FORMAT_EXTENSIONS = {
388
+ mp4: ".mp4",
389
+ mov: ".mov",
390
+ webm: ".webm",
391
+ "png-sequence": ""
392
+ };
393
+ function formatExtension(format) {
394
+ return FORMAT_EXTENSIONS[format];
395
+ }
396
+
265
397
  // src/s3PlanV2Publisher.ts
266
398
  import { createHash as createHash2 } from "node:crypto";
267
399
  import { mkdirSync as mkdirSync2, mkdtempSync, rmSync as rmSync2, statSync as statSync2, writeFileSync } from "node:fs";
@@ -515,19 +647,49 @@ function primeRuntimeEnv() {
515
647
  process.env.PATH = `${bin}:${process.env.PATH ?? ""}`;
516
648
  }
517
649
  }
518
- function planConfig(event, deps) {
650
+ var ACCOUNT_CONCURRENCY_SHARE = 0.9;
651
+ async function resolveWorkerConcurrency(event, deps) {
652
+ if (event.Config.workerConcurrency !== void 0) return event.Config.workerConcurrency;
653
+ try {
654
+ return await functionConcurrency(deps?.lambda);
655
+ } catch (err) {
656
+ logEvent({
657
+ event: "worker_concurrency",
658
+ level: "WARN",
659
+ source: "fallback",
660
+ concurrency: DEFAULT_MAX_PARALLEL_CHUNKS,
661
+ message: `Could not read the account's Lambda concurrency (${String(err)}); planning for ${DEFAULT_MAX_PARALLEL_CHUNKS} chunks at once. Allow lambda:GetAccountSettings on the function role, or set the concurrency explicitly.`
662
+ });
663
+ return DEFAULT_MAX_PARALLEL_CHUNKS;
664
+ }
665
+ }
666
+ async function functionConcurrency(lambda = new LambdaClient({})) {
667
+ const reserved = Number(process.env.HYPERFRAMES_LAMBDA_RESERVED_CONCURRENCY);
668
+ if (reserved > 0) return reserved;
669
+ const { AccountLimit } = await lambda.send(new GetAccountSettingsCommand({}));
670
+ const unreserved = AccountLimit?.UnreservedConcurrentExecutions;
671
+ if (unreserved === void 0) throw new Error("no UnreservedConcurrentExecutions in the reply");
672
+ const concurrency = Math.max(1, Math.floor(unreserved * ACCOUNT_CONCURRENCY_SHARE));
673
+ logEvent({ event: "worker_concurrency", source: "account", unreserved, concurrency });
674
+ return concurrency;
675
+ }
676
+ async function planConfig(event, deps) {
677
+ const config = {
678
+ ...event.Config,
679
+ workerConcurrency: await resolveWorkerConcurrency(event, deps)
680
+ };
519
681
  const remainingMs = deps?.getRemainingTimeInMillis?.();
520
- if (event.Config.workerTimeoutSeconds !== void 0 || remainingMs === void 0) {
521
- return { ...event.Config };
682
+ if (config.workerTimeoutSeconds === void 0 && remainingMs !== void 0) {
683
+ config.workerTimeoutSeconds = Math.ceil(remainingMs / 1e3);
522
684
  }
523
- return { ...event.Config, workerTimeoutSeconds: Math.ceil(remainingMs / 1e3) };
685
+ return config;
524
686
  }
525
687
  async function handlePlan(event, deps) {
526
688
  if (event.PlanProtocol !== "v1") {
527
689
  return handlePlanV2(event, deps);
528
690
  }
529
691
  const started = Date.now();
530
- const config = planConfig(event, deps);
692
+ const config = await planConfig(event, deps);
531
693
  const s3 = deps?.s3 ?? getS3Client();
532
694
  const primitive = deps?.primitives?.plan ?? plan;
533
695
  if (!deps?.skipChromeResolution && !process.env.PRODUCER_HEADLESS_SHELL_PATH) {
@@ -541,7 +703,9 @@ async function handlePlan(event, deps) {
541
703
  try {
542
704
  await downloadS3ObjectToFile(s3, event.ProjectS3Uri, projectArchive);
543
705
  await untarDirectory(projectArchive, projectDir);
544
- const result = await primitive(projectDir, config, planDir);
706
+ const result = await primitive(projectDir, config, planDir, {
707
+ chunksCarryAudio: true
708
+ });
545
709
  const planTar = join2(work, "plan.tar.gz");
546
710
  await tarDirectory(planDir, planTar);
547
711
  const planTarUri = `${trimTrailingSlash2(event.PlanOutputS3Prefix)}/plan.tar.gz`;
@@ -557,6 +721,7 @@ async function handlePlan(event, deps) {
557
721
  PlanS3Uri: planTarUri,
558
722
  PlanHash: result.planHash,
559
723
  ChunkCount: result.chunkCount,
724
+ MaxChunkConcurrency: Math.min(config.workerConcurrency, result.chunkCount),
560
725
  TotalFrames: result.totalFrames,
561
726
  Fps: result.fps,
562
727
  Width: result.width,
@@ -574,7 +739,7 @@ async function handlePlan(event, deps) {
574
739
  }
575
740
  async function handlePlanV2(event, deps) {
576
741
  const started = Date.now();
577
- const config = planConfig(event, deps);
742
+ const config = await planConfig(event, deps);
578
743
  const s3 = deps?.s3 ?? getS3Client();
579
744
  const primitive = deps?.primitives?.planV2WithPublisher ?? planV2WithPublisher;
580
745
  if (!deps?.skipChromeResolution && !process.env.PRODUCER_HEADLESS_SHELL_PATH) {
@@ -596,7 +761,8 @@ async function handlePlanV2(event, deps) {
596
761
  temporaryRoot: work
597
762
  });
598
763
  const manifest = await primitive(projectDir, config, publisher, {
599
- stagingParentDir: work
764
+ stagingParentDir: work,
765
+ chunksCarryAudio: true
600
766
  });
601
767
  return {
602
768
  Action: "plan",
@@ -605,6 +771,7 @@ async function handlePlanV2(event, deps) {
605
771
  PlanV2ArtifactS3Prefix: publisher.artifactPrefix,
606
772
  PlanHash: manifest.planHash,
607
773
  ChunkCount: manifest.chunkCount,
774
+ MaxChunkConcurrency: Math.min(config.workerConcurrency, manifest.chunkCount),
608
775
  TotalFrames: manifest.totalFrames,
609
776
  Fps: manifest.fps,
610
777
  Width: manifest.width,
@@ -671,7 +838,7 @@ async function handleRenderChunkV2(event, deps) {
671
838
  }
672
839
  const work = mkdtempSync2(join2(deps?.tmpRoot ?? tmpdir2(), "hf-lambda-chunk-v2-"));
673
840
  try {
674
- const planDir = await downloadAndMaterializePlanV2(
841
+ const { planDir } = await downloadAndMaterializePlanV2(
675
842
  s3,
676
843
  event,
677
844
  { role: "chunk", chunkIndex: event.ChunkIndex },
@@ -762,7 +929,15 @@ async function handleAssembleV2(event, deps) {
762
929
  const primitive = deps?.primitives?.assemble ?? assemble;
763
930
  const work = mkdtempSync2(join2(deps?.tmpRoot ?? tmpdir2(), "hf-lambda-assemble-v2-"));
764
931
  try {
765
- const planDir = await downloadAndMaterializePlanV2(s3, event, { role: "assembler" }, work);
932
+ const { planDir, manifest } = await downloadAndMaterializePlanV2(
933
+ s3,
934
+ event,
935
+ { role: "assembler" },
936
+ work
937
+ );
938
+ if (chunksAreStitchedMp4(planDir, event.Cfr === true)) {
939
+ return await stitchChunksInS3(s3, event, manifest, started);
940
+ }
766
941
  const audioPath = resolvePlanAudioPath(planDir);
767
942
  const chunksStarted = Date.now();
768
943
  const chunkPaths = await downloadChunkObjects(s3, event.ChunkS3Uris, work, event.Format);
@@ -797,6 +972,30 @@ async function handleAssembleV2(event, deps) {
797
972
  cleanupDir(work);
798
973
  }
799
974
  }
975
+ async function stitchChunksInS3(s3, event, plan2, started) {
976
+ if (event.ChunkS3Uris.length !== plan2.chunkCount) {
977
+ throw new Error(
978
+ `[handler] ${event.ChunkS3Uris.length} chunk URIs for a plan of ${plan2.chunkCount} chunks`
979
+ );
980
+ }
981
+ const stitchStarted = Date.now();
982
+ const stitched = await stitchMp4InS3(s3, event.ChunkS3Uris, event.OutputS3Uri);
983
+ logEvent({
984
+ event: "assemble_stages",
985
+ mode: "s3-stitch",
986
+ planMs: stitchStarted - started,
987
+ stitchMs: Date.now() - stitchStarted,
988
+ parts: stitched.parts,
989
+ downloadedBytes: stitched.downloadedBytes
990
+ });
991
+ return {
992
+ Action: "assemble",
993
+ OutputS3Uri: event.OutputS3Uri,
994
+ FramesEncoded: plan2.totalFrames,
995
+ FileSize: stitched.fileSize,
996
+ DurationMs: Date.now() - started
997
+ };
998
+ }
800
999
  async function downloadAndMaterializePlanV2(s3, event, target, work) {
801
1000
  const transportDir = join2(work, "plan-v2");
802
1001
  mkdirSync3(transportDir, { recursive: true });
@@ -814,7 +1013,7 @@ async function downloadAndMaterializePlanV2(s3, event, target, work) {
814
1013
  });
815
1014
  const planDir = join2(work, "plan");
816
1015
  materializePlanV2Target(transportDir, target, planDir);
817
- return planDir;
1016
+ return { planDir, manifest };
818
1017
  }
819
1018
  async function downloadPlanV2Artifact(s3, artifactPrefix, planV2Dir, artifact) {
820
1019
  await downloadS3ObjectToFileVerified(
package/dist/index.js CHANGED
@@ -2,9 +2,12 @@
2
2
  import { existsSync as existsSync3, mkdirSync as mkdirSync3, mkdtempSync as mkdtempSync2, readFileSync, rmSync as rmSync3, statSync as statSync3 } from "node:fs";
3
3
  import { tmpdir as tmpdir2 } from "node:os";
4
4
  import { basename, join as join2 } from "node:path";
5
+ import { GetAccountSettingsCommand, LambdaClient } from "@aws-sdk/client-lambda";
5
6
  import { S3Client } from "@aws-sdk/client-s3";
6
7
  import {
7
8
  assemble,
9
+ chunksAreStitchedMp4,
10
+ DEFAULT_MAX_PARALLEL_CHUNKS,
8
11
  listPlanV2ArtifactsForTarget,
9
12
  materializePlanV2Target,
10
13
  plan,
@@ -82,16 +85,16 @@ async function loadSparticuzChromium() {
82
85
  return resolved;
83
86
  }
84
87
 
85
- // src/formatExtension.ts
86
- var FORMAT_EXTENSIONS = {
87
- mp4: ".mp4",
88
- mov: ".mov",
89
- webm: ".webm",
90
- "png-sequence": ""
91
- };
92
- function formatExtension(format) {
93
- return FORMAT_EXTENSIONS[format];
94
- }
88
+ // src/s3Stitch.ts
89
+ import {
90
+ AbortMultipartUploadCommand,
91
+ CompleteMultipartUploadCommand,
92
+ CreateMultipartUploadCommand,
93
+ GetObjectCommand as GetObjectCommand2,
94
+ UploadPartCommand,
95
+ UploadPartCopyCommand
96
+ } from "@aws-sdk/client-s3";
97
+ import { buildStitchedHeader, readMp4Layout } from "@hyperframes/producer/distributed";
95
98
 
96
99
  // src/s3Transport.ts
97
100
  import {
@@ -270,6 +273,135 @@ async function untarDirectory(tarballPath, destDir) {
270
273
  await tar.extract({ file: tarballPath, cwd: destDir });
271
274
  }
272
275
 
276
+ // src/s3Stitch.ts
277
+ var MIN_PART_BYTES = 5 * 1024 * 1024;
278
+ var segmentSize = (s) => "bytes" in s ? s.bytes.length : s.end - s.start;
279
+ function planParts(segments, min = MIN_PART_BYTES) {
280
+ const parts = [];
281
+ let pending = [];
282
+ let pendingSize = 0;
283
+ const flush = () => {
284
+ if (pending.length) parts.push({ upload: pending });
285
+ pending = [];
286
+ pendingSize = 0;
287
+ };
288
+ for (const segment of segments) {
289
+ const borrow = pendingSize ? min - pendingSize : 0;
290
+ if ("uri" in segment && segmentSize(segment) - borrow >= min) {
291
+ if (borrow) pending.push({ ...segment, end: segment.start + borrow });
292
+ flush();
293
+ parts.push({ copy: { ...segment, start: segment.start + borrow } });
294
+ continue;
295
+ }
296
+ pending.push(segment);
297
+ pendingSize += segmentSize(segment);
298
+ if (pendingSize >= min) flush();
299
+ }
300
+ flush();
301
+ return parts;
302
+ }
303
+ async function getRange(s3, uri, start, end) {
304
+ const { bucket, key } = parseS3Uri(uri);
305
+ const response = await s3.send(
306
+ new GetObjectCommand2({ Bucket: bucket, Key: key, Range: `bytes=${start}-${end - 1}` })
307
+ );
308
+ if (!response.Body) throw new Error(`[s3Stitch] empty body for ${uri}`);
309
+ return Buffer.from(await response.Body.transformToByteArray());
310
+ }
311
+ async function sendPart(s3, part, partNumber, target) {
312
+ if ("copy" in part) {
313
+ const { uri, start, end } = part.copy;
314
+ const source = parseS3Uri(uri);
315
+ const result2 = await s3.send(
316
+ new UploadPartCopyCommand({
317
+ ...target,
318
+ PartNumber: partNumber,
319
+ CopySource: encodeURI(`${source.bucket}/${source.key}`),
320
+ CopySourceRange: `bytes=${start}-${end - 1}`
321
+ })
322
+ );
323
+ return result2.CopyPartResult?.ETag;
324
+ }
325
+ const body = await Promise.all(
326
+ part.upload.map((s) => "bytes" in s ? s.bytes : getRange(s3, s.uri, s.start, s.end))
327
+ );
328
+ const result = await s3.send(
329
+ new UploadPartCommand({ ...target, PartNumber: partNumber, Body: Buffer.concat(body) })
330
+ );
331
+ return result.ETag;
332
+ }
333
+ async function sendParts(s3, parts, target) {
334
+ const settled = await Promise.allSettled(
335
+ parts.map(async (part, index) => {
336
+ const etag = await sendPart(s3, part, index + 1, target);
337
+ if (!etag) throw new Error(`[s3Stitch] part ${index + 1} returned no ETag`);
338
+ return etag;
339
+ })
340
+ );
341
+ const failure = settled.find(
342
+ (result) => result.status === "rejected"
343
+ );
344
+ if (failure) throw failure.reason;
345
+ return settled.map((result) => result.value);
346
+ }
347
+ async function uploadParts(s3, parts, destUri, contentType) {
348
+ const { bucket, key } = parseS3Uri(destUri);
349
+ const created = await s3.send(
350
+ new CreateMultipartUploadCommand({ Bucket: bucket, Key: key, ContentType: contentType })
351
+ );
352
+ if (!created.UploadId) throw new Error(`[s3Stitch] no upload id for ${destUri}`);
353
+ const target = { Bucket: bucket, Key: key, UploadId: created.UploadId };
354
+ try {
355
+ const etags = await sendParts(s3, parts, target);
356
+ await s3.send(
357
+ new CompleteMultipartUploadCommand({
358
+ ...target,
359
+ MultipartUpload: { Parts: etags.map((ETag, index) => ({ ETag, PartNumber: index + 1 })) }
360
+ })
361
+ );
362
+ } catch (error) {
363
+ await s3.send(new AbortMultipartUploadCommand(target)).catch((abortError) => {
364
+ console.error(
365
+ `[s3Stitch] abort failed for ${destUri}; parts remain until a lifecycle rule removes them`,
366
+ abortError
367
+ );
368
+ });
369
+ throw error;
370
+ }
371
+ }
372
+ async function stitchMp4InS3(s3, chunkUris, destUri, minPartBytes = MIN_PART_BYTES) {
373
+ const layouts = await Promise.all(
374
+ chunkUris.map((uri) => readMp4Layout((start, end) => getRange(s3, uri, start, end)))
375
+ );
376
+ const segments = [
377
+ { bytes: buildStitchedHeader(layouts) },
378
+ ...layouts.map((layout, i) => ({
379
+ uri: chunkUris[i],
380
+ start: layout.payloadStart,
381
+ end: layout.payloadEnd
382
+ }))
383
+ ];
384
+ const parts = planParts(segments, minPartBytes);
385
+ await uploadParts(s3, parts, destUri, "video/mp4");
386
+ const uploaded = parts.flatMap((part) => "upload" in part ? part.upload : []);
387
+ return {
388
+ fileSize: segments.reduce((total, s) => total + segmentSize(s), 0),
389
+ parts: parts.length,
390
+ downloadedBytes: uploaded.reduce((total, s) => total + ("uri" in s ? segmentSize(s) : 0), 0)
391
+ };
392
+ }
393
+
394
+ // src/formatExtension.ts
395
+ var FORMAT_EXTENSIONS = {
396
+ mp4: ".mp4",
397
+ mov: ".mov",
398
+ webm: ".webm",
399
+ "png-sequence": ""
400
+ };
401
+ function formatExtension(format) {
402
+ return FORMAT_EXTENSIONS[format];
403
+ }
404
+
273
405
  // src/s3PlanV2Publisher.ts
274
406
  import { createHash as createHash2 } from "node:crypto";
275
407
  import { mkdirSync as mkdirSync2, mkdtempSync, rmSync as rmSync2, statSync as statSync2, writeFileSync } from "node:fs";
@@ -523,19 +655,49 @@ function primeRuntimeEnv() {
523
655
  process.env.PATH = `${bin}:${process.env.PATH ?? ""}`;
524
656
  }
525
657
  }
526
- function planConfig(event, deps) {
658
+ var ACCOUNT_CONCURRENCY_SHARE = 0.9;
659
+ async function resolveWorkerConcurrency(event, deps) {
660
+ if (event.Config.workerConcurrency !== void 0) return event.Config.workerConcurrency;
661
+ try {
662
+ return await functionConcurrency(deps?.lambda);
663
+ } catch (err) {
664
+ logEvent({
665
+ event: "worker_concurrency",
666
+ level: "WARN",
667
+ source: "fallback",
668
+ concurrency: DEFAULT_MAX_PARALLEL_CHUNKS,
669
+ message: `Could not read the account's Lambda concurrency (${String(err)}); planning for ${DEFAULT_MAX_PARALLEL_CHUNKS} chunks at once. Allow lambda:GetAccountSettings on the function role, or set the concurrency explicitly.`
670
+ });
671
+ return DEFAULT_MAX_PARALLEL_CHUNKS;
672
+ }
673
+ }
674
+ async function functionConcurrency(lambda = new LambdaClient({})) {
675
+ const reserved = Number(process.env.HYPERFRAMES_LAMBDA_RESERVED_CONCURRENCY);
676
+ if (reserved > 0) return reserved;
677
+ const { AccountLimit } = await lambda.send(new GetAccountSettingsCommand({}));
678
+ const unreserved = AccountLimit?.UnreservedConcurrentExecutions;
679
+ if (unreserved === void 0) throw new Error("no UnreservedConcurrentExecutions in the reply");
680
+ const concurrency = Math.max(1, Math.floor(unreserved * ACCOUNT_CONCURRENCY_SHARE));
681
+ logEvent({ event: "worker_concurrency", source: "account", unreserved, concurrency });
682
+ return concurrency;
683
+ }
684
+ async function planConfig(event, deps) {
685
+ const config = {
686
+ ...event.Config,
687
+ workerConcurrency: await resolveWorkerConcurrency(event, deps)
688
+ };
527
689
  const remainingMs = deps?.getRemainingTimeInMillis?.();
528
- if (event.Config.workerTimeoutSeconds !== void 0 || remainingMs === void 0) {
529
- return { ...event.Config };
690
+ if (config.workerTimeoutSeconds === void 0 && remainingMs !== void 0) {
691
+ config.workerTimeoutSeconds = Math.ceil(remainingMs / 1e3);
530
692
  }
531
- return { ...event.Config, workerTimeoutSeconds: Math.ceil(remainingMs / 1e3) };
693
+ return config;
532
694
  }
533
695
  async function handlePlan(event, deps) {
534
696
  if (event.PlanProtocol !== "v1") {
535
697
  return handlePlanV2(event, deps);
536
698
  }
537
699
  const started = Date.now();
538
- const config = planConfig(event, deps);
700
+ const config = await planConfig(event, deps);
539
701
  const s3 = deps?.s3 ?? getS3Client();
540
702
  const primitive = deps?.primitives?.plan ?? plan;
541
703
  if (!deps?.skipChromeResolution && !process.env.PRODUCER_HEADLESS_SHELL_PATH) {
@@ -549,7 +711,9 @@ async function handlePlan(event, deps) {
549
711
  try {
550
712
  await downloadS3ObjectToFile(s3, event.ProjectS3Uri, projectArchive);
551
713
  await untarDirectory(projectArchive, projectDir);
552
- const result = await primitive(projectDir, config, planDir);
714
+ const result = await primitive(projectDir, config, planDir, {
715
+ chunksCarryAudio: true
716
+ });
553
717
  const planTar = join2(work, "plan.tar.gz");
554
718
  await tarDirectory(planDir, planTar);
555
719
  const planTarUri = `${trimTrailingSlash2(event.PlanOutputS3Prefix)}/plan.tar.gz`;
@@ -565,6 +729,7 @@ async function handlePlan(event, deps) {
565
729
  PlanS3Uri: planTarUri,
566
730
  PlanHash: result.planHash,
567
731
  ChunkCount: result.chunkCount,
732
+ MaxChunkConcurrency: Math.min(config.workerConcurrency, result.chunkCount),
568
733
  TotalFrames: result.totalFrames,
569
734
  Fps: result.fps,
570
735
  Width: result.width,
@@ -582,7 +747,7 @@ async function handlePlan(event, deps) {
582
747
  }
583
748
  async function handlePlanV2(event, deps) {
584
749
  const started = Date.now();
585
- const config = planConfig(event, deps);
750
+ const config = await planConfig(event, deps);
586
751
  const s3 = deps?.s3 ?? getS3Client();
587
752
  const primitive = deps?.primitives?.planV2WithPublisher ?? planV2WithPublisher;
588
753
  if (!deps?.skipChromeResolution && !process.env.PRODUCER_HEADLESS_SHELL_PATH) {
@@ -604,7 +769,8 @@ async function handlePlanV2(event, deps) {
604
769
  temporaryRoot: work
605
770
  });
606
771
  const manifest = await primitive(projectDir, config, publisher, {
607
- stagingParentDir: work
772
+ stagingParentDir: work,
773
+ chunksCarryAudio: true
608
774
  });
609
775
  return {
610
776
  Action: "plan",
@@ -613,6 +779,7 @@ async function handlePlanV2(event, deps) {
613
779
  PlanV2ArtifactS3Prefix: publisher.artifactPrefix,
614
780
  PlanHash: manifest.planHash,
615
781
  ChunkCount: manifest.chunkCount,
782
+ MaxChunkConcurrency: Math.min(config.workerConcurrency, manifest.chunkCount),
616
783
  TotalFrames: manifest.totalFrames,
617
784
  Fps: manifest.fps,
618
785
  Width: manifest.width,
@@ -679,7 +846,7 @@ async function handleRenderChunkV2(event, deps) {
679
846
  }
680
847
  const work = mkdtempSync2(join2(deps?.tmpRoot ?? tmpdir2(), "hf-lambda-chunk-v2-"));
681
848
  try {
682
- const planDir = await downloadAndMaterializePlanV2(
849
+ const { planDir } = await downloadAndMaterializePlanV2(
683
850
  s3,
684
851
  event,
685
852
  { role: "chunk", chunkIndex: event.ChunkIndex },
@@ -770,7 +937,15 @@ async function handleAssembleV2(event, deps) {
770
937
  const primitive = deps?.primitives?.assemble ?? assemble;
771
938
  const work = mkdtempSync2(join2(deps?.tmpRoot ?? tmpdir2(), "hf-lambda-assemble-v2-"));
772
939
  try {
773
- const planDir = await downloadAndMaterializePlanV2(s3, event, { role: "assembler" }, work);
940
+ const { planDir, manifest } = await downloadAndMaterializePlanV2(
941
+ s3,
942
+ event,
943
+ { role: "assembler" },
944
+ work
945
+ );
946
+ if (chunksAreStitchedMp4(planDir, event.Cfr === true)) {
947
+ return await stitchChunksInS3(s3, event, manifest, started);
948
+ }
774
949
  const audioPath = resolvePlanAudioPath(planDir);
775
950
  const chunksStarted = Date.now();
776
951
  const chunkPaths = await downloadChunkObjects(s3, event.ChunkS3Uris, work, event.Format);
@@ -805,6 +980,30 @@ async function handleAssembleV2(event, deps) {
805
980
  cleanupDir(work);
806
981
  }
807
982
  }
983
+ async function stitchChunksInS3(s3, event, plan2, started) {
984
+ if (event.ChunkS3Uris.length !== plan2.chunkCount) {
985
+ throw new Error(
986
+ `[handler] ${event.ChunkS3Uris.length} chunk URIs for a plan of ${plan2.chunkCount} chunks`
987
+ );
988
+ }
989
+ const stitchStarted = Date.now();
990
+ const stitched = await stitchMp4InS3(s3, event.ChunkS3Uris, event.OutputS3Uri);
991
+ logEvent({
992
+ event: "assemble_stages",
993
+ mode: "s3-stitch",
994
+ planMs: stitchStarted - started,
995
+ stitchMs: Date.now() - stitchStarted,
996
+ parts: stitched.parts,
997
+ downloadedBytes: stitched.downloadedBytes
998
+ });
999
+ return {
1000
+ Action: "assemble",
1001
+ OutputS3Uri: event.OutputS3Uri,
1002
+ FramesEncoded: plan2.totalFrames,
1003
+ FileSize: stitched.fileSize,
1004
+ DurationMs: Date.now() - started
1005
+ };
1006
+ }
808
1007
  async function downloadAndMaterializePlanV2(s3, event, target, work) {
809
1008
  const transportDir = join2(work, "plan-v2");
810
1009
  mkdirSync3(transportDir, { recursive: true });
@@ -822,7 +1021,7 @@ async function downloadAndMaterializePlanV2(s3, event, target, work) {
822
1021
  });
823
1022
  const planDir = join2(work, "plan");
824
1023
  materializePlanV2Target(transportDir, target, planDir);
825
- return planDir;
1024
+ return { planDir, manifest };
826
1025
  }
827
1026
  async function downloadPlanV2Artifact(s3, artifactPrefix, planV2Dir, artifact) {
828
1027
  await downloadS3ObjectToFileVerified(
@@ -1103,7 +1302,9 @@ async function renderToLambda(opts) {
1103
1302
  // src/sdk/getRenderProgress.ts
1104
1303
  import {
1105
1304
  DescribeExecutionCommand,
1305
+ DescribeMapRunCommand,
1106
1306
  GetExecutionHistoryCommand,
1307
+ ListExecutionsCommand,
1107
1308
  SFNClient as SFNClient2
1108
1309
  } from "@aws-sdk/client-sfn";
1109
1310
 
@@ -1140,11 +1341,14 @@ function formatUsd(usd) {
1140
1341
 
1141
1342
  // src/sdk/getRenderProgress.ts
1142
1343
  var DEFAULT_MEMORY_MB = 10240;
1344
+ function createProgressSfnClient(config = {}) {
1345
+ return new SFNClient2({ maxAttempts: 8, ...config });
1346
+ }
1143
1347
  async function getRenderProgress(opts) {
1144
1348
  if (!opts.executionArn) {
1145
1349
  throw new Error("[getRenderProgress] executionArn is required");
1146
1350
  }
1147
- const sfn = opts.sfn ?? new SFNClient2({ region: opts.region });
1351
+ const sfn = opts.sfn ?? createProgressSfnClient({ region: opts.region });
1148
1352
  const memoryMb = opts.defaultMemorySizeMb ?? DEFAULT_MEMORY_MB;
1149
1353
  const describe = await sfn.send(
1150
1354
  new DescribeExecutionCommand({ executionArn: opts.executionArn })
@@ -1154,6 +1358,15 @@ async function getRenderProgress(opts) {
1154
1358
  const endedAt = describe.stopDate?.toISOString() ?? null;
1155
1359
  const history = await loadFullHistory(sfn, opts.executionArn);
1156
1360
  const summary = summarizeHistory(history, memoryMb);
1361
+ const mapRun = findChunkMapRun(history);
1362
+ const warnings = [];
1363
+ if (mapRun?.exited) addChunkResults(summary, mapRun.output, memoryMb);
1364
+ else if (mapRun) {
1365
+ await addRunningMapRun(sfn, mapRun.arn, summary, memoryMb).catch((err) => {
1366
+ if (err?.name !== "AccessDeniedException") throw err;
1367
+ warnings.push(MAP_RUN_ACCESS_DENIED);
1368
+ });
1369
+ }
1157
1370
  const costs = computeRenderCost(summary.lambdaInvocations, summary.stateTransitions);
1158
1371
  const overallProgress = computeOverallProgress({
1159
1372
  status,
@@ -1171,6 +1384,7 @@ async function getRenderProgress(opts) {
1171
1384
  outputFile: summary.outputFile,
1172
1385
  errors: summary.errors,
1173
1386
  fatalErrorEncountered: isTerminalFailure(status),
1387
+ warnings,
1174
1388
  startedAt,
1175
1389
  endedAt
1176
1390
  };
@@ -1193,6 +1407,62 @@ async function loadFullHistory(sfn, executionArn) {
1193
1407
  }
1194
1408
  return events;
1195
1409
  }
1410
+ var FAILED_CHUNK_HISTORIES_READ = 5;
1411
+ var MAP_RUN_ACCESS_DENIED = "Chunk progress is hidden until the chunks finish: this caller lacks states:DescribeMapRun. Re-apply the policy from `hyperframes lambda policies user`.";
1412
+ function findChunkMapRun(events) {
1413
+ let run = null;
1414
+ for (const ev of events) {
1415
+ const arn = ev.mapRunStartedEventDetails?.mapRunArn;
1416
+ if (arn) run = { arn, exited: false, output: void 0 };
1417
+ const exit = ev.stateExitedEventDetails;
1418
+ if (run && ev.type === "MapStateExited" && isRenderChunksMapState(exit?.name)) {
1419
+ run = { ...run, exited: true, output: exit?.output };
1420
+ }
1421
+ }
1422
+ return run;
1423
+ }
1424
+ function addChunkResults(summary, mapOutput, memoryMb) {
1425
+ const chunks = parseJson(mapOutput)?.Chunks;
1426
+ for (const chunk of Array.isArray(chunks) ? chunks : []) {
1427
+ summary.lambdasInvoked++;
1428
+ summary.stateTransitions++;
1429
+ const billedDurationMs = inferBilledMs(chunk);
1430
+ summary.lambdaInvocations.push({
1431
+ billedDurationMs,
1432
+ memorySizeMb: memoryMb,
1433
+ estimated: billedDurationMs === 0
1434
+ });
1435
+ applyPayloadFrameCounts(chunk, "RenderChunk", (delta) => {
1436
+ summary.framesRendered += delta;
1437
+ });
1438
+ }
1439
+ }
1440
+ async function addRunningMapRun(sfn, mapRunArn, summary, memoryMb) {
1441
+ const res = await sfn.send(new DescribeMapRunCommand({ mapRunArn }));
1442
+ const { total = 0, pending = 0, succeeded = 0, failed = 0 } = res.itemCounts ?? {};
1443
+ summary.lambdasInvoked += total - pending;
1444
+ summary.stateTransitions += total - pending;
1445
+ if (summary.totalFrames !== null && total > 0) {
1446
+ summary.framesRendered += Math.floor(summary.totalFrames * succeeded / total);
1447
+ }
1448
+ if (failed > 0) summary.errors.push(...await failedChunkErrors(sfn, mapRunArn, memoryMb));
1449
+ }
1450
+ async function failedChunkErrors(sfn, mapRunArn, memoryMb) {
1451
+ const failed = await sfn.send(
1452
+ new ListExecutionsCommand({
1453
+ mapRunArn,
1454
+ statusFilter: "FAILED",
1455
+ maxResults: FAILED_CHUNK_HISTORIES_READ
1456
+ })
1457
+ );
1458
+ const errors = [];
1459
+ for (const child of failed.executions ?? []) {
1460
+ if (!child.executionArn) continue;
1461
+ const childHistory = await loadFullHistory(sfn, child.executionArn);
1462
+ errors.push(...summarizeHistory(childHistory, memoryMb).errors);
1463
+ }
1464
+ return errors;
1465
+ }
1196
1466
  function summarizeHistory(events, memoryMb) {
1197
1467
  let framesRendered = 0;
1198
1468
  let totalFrames = null;
@@ -1351,6 +1621,9 @@ function applyPayloadFrameCounts(payload, currentLambdaState, bump) {
1351
1621
  function isRenderChunkState(name) {
1352
1622
  return name === "RenderChunk" || name === "RenderChunkV2";
1353
1623
  }
1624
+ function isRenderChunksMapState(name) {
1625
+ return name === "RenderChunks" || name === "RenderChunksV2";
1626
+ }
1354
1627
  function isAssembleState(name) {
1355
1628
  return name === "Assemble" || name === "AssembleV2";
1356
1629
  }
@@ -0,0 +1,26 @@
1
+ import { type S3Client } from "@aws-sdk/client-s3";
2
+ /** Bytes `[start, end)` of an S3 object. */
3
+ interface RangeSegment {
4
+ uri: string;
5
+ start: number;
6
+ end: number;
7
+ }
8
+ type Segment = {
9
+ bytes: Buffer;
10
+ } | RangeSegment;
11
+ export type Part = {
12
+ copy: RangeSegment;
13
+ } | {
14
+ upload: Segment[];
15
+ };
16
+ export declare function planParts(segments: readonly Segment[], min?: number): Part[];
17
+ /** Writes `parts` in order to `destUri`; a failure aborts the upload so no parts linger. */
18
+ export declare function uploadParts(s3: S3Client, parts: readonly Part[], destUri: string, contentType: string): Promise<void>;
19
+ export interface StitchResult {
20
+ fileSize: number;
21
+ parts: number;
22
+ downloadedBytes: number;
23
+ }
24
+ /** One faststart mp4 at `destUri` from the stitched mp4 chunks at `chunkUris`, in order. */
25
+ export declare function stitchMp4InS3(s3: S3Client, chunkUris: readonly string[], destUri: string, minPartBytes?: number): Promise<StitchResult>;
26
+ export {};
@@ -2,10 +2,10 @@
2
2
  * `getRenderProgress` — read-only progress + cost snapshot for a single
3
3
  * render started by {@link renderToLambda}.
4
4
  *
5
- * Pulls one `DescribeExecution` + one `GetExecutionHistory` per call. The
6
- * history is paginated server-side; the helper loops until exhausted so a
7
- * 1,000-event Step Functions execution still produces a single
8
- * `RenderProgress` snapshot.
5
+ * Pulls one `DescribeExecution` + one `GetExecutionHistory` per call. While
6
+ * the chunk Distributed Map runs, one `DescribeMapRun` adds its item counts;
7
+ * once it exits, chunk frames and cost come from the Map's output. Failed
8
+ * chunks' histories are read only when the counts show a failure.
9
9
  *
10
10
  * Progress math:
11
11
  * - 0 before Plan completes (no frame count is known yet)
@@ -19,7 +19,7 @@
19
19
  * `events.ts`, so the parser doesn't need to know anything beyond
20
20
  * "JSON.parse this string and grab two fields."
21
21
  */
22
- import { SFNClient } from "@aws-sdk/client-sfn";
22
+ import { SFNClient, type SFNClientConfig } from "@aws-sdk/client-sfn";
23
23
  import { type RenderCost } from "./costAccounting.js";
24
24
  /** Options for {@link getRenderProgress}. */
25
25
  export interface GetRenderProgressOptions {
@@ -64,8 +64,12 @@ export interface RenderProgress {
64
64
  errors: RenderError[];
65
65
  /** `true` once the execution has terminated in a non-`SUCCEEDED` state. */
66
66
  fatalErrorEncountered: boolean;
67
+ /** Problems reading progress that leave the render itself untouched, such as a missing IAM permission. */
68
+ warnings: string[];
67
69
  startedAt: string;
68
70
  endedAt: string | null;
69
71
  }
72
+ /** Rides out `ThrottlingException` with the SDK's backoff instead of throwing after the default 3 tries. */
73
+ export declare function createProgressSfnClient(config?: SFNClientConfig): SFNClient;
70
74
  /** Pull a current progress snapshot for one render. */
71
75
  export declare function getRenderProgress(opts: GetRenderProgressOptions): Promise<RenderProgress>;
package/dist/sdk/index.js CHANGED
@@ -227,7 +227,9 @@ async function renderToLambda(opts) {
227
227
  // src/sdk/getRenderProgress.ts
228
228
  import {
229
229
  DescribeExecutionCommand,
230
+ DescribeMapRunCommand,
230
231
  GetExecutionHistoryCommand,
232
+ ListExecutionsCommand,
231
233
  SFNClient as SFNClient2
232
234
  } from "@aws-sdk/client-sfn";
233
235
 
@@ -264,11 +266,14 @@ function formatUsd(usd) {
264
266
 
265
267
  // src/sdk/getRenderProgress.ts
266
268
  var DEFAULT_MEMORY_MB = 10240;
269
+ function createProgressSfnClient(config = {}) {
270
+ return new SFNClient2({ maxAttempts: 8, ...config });
271
+ }
267
272
  async function getRenderProgress(opts) {
268
273
  if (!opts.executionArn) {
269
274
  throw new Error("[getRenderProgress] executionArn is required");
270
275
  }
271
- const sfn = opts.sfn ?? new SFNClient2({ region: opts.region });
276
+ const sfn = opts.sfn ?? createProgressSfnClient({ region: opts.region });
272
277
  const memoryMb = opts.defaultMemorySizeMb ?? DEFAULT_MEMORY_MB;
273
278
  const describe = await sfn.send(
274
279
  new DescribeExecutionCommand({ executionArn: opts.executionArn })
@@ -278,6 +283,15 @@ async function getRenderProgress(opts) {
278
283
  const endedAt = describe.stopDate?.toISOString() ?? null;
279
284
  const history = await loadFullHistory(sfn, opts.executionArn);
280
285
  const summary = summarizeHistory(history, memoryMb);
286
+ const mapRun = findChunkMapRun(history);
287
+ const warnings = [];
288
+ if (mapRun?.exited) addChunkResults(summary, mapRun.output, memoryMb);
289
+ else if (mapRun) {
290
+ await addRunningMapRun(sfn, mapRun.arn, summary, memoryMb).catch((err) => {
291
+ if (err?.name !== "AccessDeniedException") throw err;
292
+ warnings.push(MAP_RUN_ACCESS_DENIED);
293
+ });
294
+ }
281
295
  const costs = computeRenderCost(summary.lambdaInvocations, summary.stateTransitions);
282
296
  const overallProgress = computeOverallProgress({
283
297
  status,
@@ -295,6 +309,7 @@ async function getRenderProgress(opts) {
295
309
  outputFile: summary.outputFile,
296
310
  errors: summary.errors,
297
311
  fatalErrorEncountered: isTerminalFailure(status),
312
+ warnings,
298
313
  startedAt,
299
314
  endedAt
300
315
  };
@@ -317,6 +332,62 @@ async function loadFullHistory(sfn, executionArn) {
317
332
  }
318
333
  return events;
319
334
  }
335
+ var FAILED_CHUNK_HISTORIES_READ = 5;
336
+ var MAP_RUN_ACCESS_DENIED = "Chunk progress is hidden until the chunks finish: this caller lacks states:DescribeMapRun. Re-apply the policy from `hyperframes lambda policies user`.";
337
+ function findChunkMapRun(events) {
338
+ let run = null;
339
+ for (const ev of events) {
340
+ const arn = ev.mapRunStartedEventDetails?.mapRunArn;
341
+ if (arn) run = { arn, exited: false, output: void 0 };
342
+ const exit = ev.stateExitedEventDetails;
343
+ if (run && ev.type === "MapStateExited" && isRenderChunksMapState(exit?.name)) {
344
+ run = { ...run, exited: true, output: exit?.output };
345
+ }
346
+ }
347
+ return run;
348
+ }
349
+ function addChunkResults(summary, mapOutput, memoryMb) {
350
+ const chunks = parseJson(mapOutput)?.Chunks;
351
+ for (const chunk of Array.isArray(chunks) ? chunks : []) {
352
+ summary.lambdasInvoked++;
353
+ summary.stateTransitions++;
354
+ const billedDurationMs = inferBilledMs(chunk);
355
+ summary.lambdaInvocations.push({
356
+ billedDurationMs,
357
+ memorySizeMb: memoryMb,
358
+ estimated: billedDurationMs === 0
359
+ });
360
+ applyPayloadFrameCounts(chunk, "RenderChunk", (delta) => {
361
+ summary.framesRendered += delta;
362
+ });
363
+ }
364
+ }
365
+ async function addRunningMapRun(sfn, mapRunArn, summary, memoryMb) {
366
+ const res = await sfn.send(new DescribeMapRunCommand({ mapRunArn }));
367
+ const { total = 0, pending = 0, succeeded = 0, failed = 0 } = res.itemCounts ?? {};
368
+ summary.lambdasInvoked += total - pending;
369
+ summary.stateTransitions += total - pending;
370
+ if (summary.totalFrames !== null && total > 0) {
371
+ summary.framesRendered += Math.floor(summary.totalFrames * succeeded / total);
372
+ }
373
+ if (failed > 0) summary.errors.push(...await failedChunkErrors(sfn, mapRunArn, memoryMb));
374
+ }
375
+ async function failedChunkErrors(sfn, mapRunArn, memoryMb) {
376
+ const failed = await sfn.send(
377
+ new ListExecutionsCommand({
378
+ mapRunArn,
379
+ statusFilter: "FAILED",
380
+ maxResults: FAILED_CHUNK_HISTORIES_READ
381
+ })
382
+ );
383
+ const errors = [];
384
+ for (const child of failed.executions ?? []) {
385
+ if (!child.executionArn) continue;
386
+ const childHistory = await loadFullHistory(sfn, child.executionArn);
387
+ errors.push(...summarizeHistory(childHistory, memoryMb).errors);
388
+ }
389
+ return errors;
390
+ }
320
391
  function summarizeHistory(events, memoryMb) {
321
392
  let framesRendered = 0;
322
393
  let totalFrames = null;
@@ -475,6 +546,9 @@ function applyPayloadFrameCounts(payload, currentLambdaState, bump) {
475
546
  function isRenderChunkState(name) {
476
547
  return name === "RenderChunk" || name === "RenderChunkV2";
477
548
  }
549
+ function isRenderChunksMapState(name) {
550
+ return name === "RenderChunks" || name === "RenderChunksV2";
551
+ }
478
552
  function isAssembleState(name) {
479
553
  return name === "Assemble" || name === "AssembleV2";
480
554
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/aws-lambda",
3
- "version": "0.8.172",
3
+ "version": "0.8.174",
4
4
  "description": "AWS Lambda adapter for HyperFrames distributed rendering — handler, client-side SDK, and CDK construct.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -38,6 +38,7 @@
38
38
  "registry": "https://registry.npmjs.org/"
39
39
  },
40
40
  "dependencies": {
41
+ "@aws-sdk/client-lambda": "^3.1146.0",
41
42
  "@aws-sdk/client-s3": "^3.1146.0",
42
43
  "@aws-sdk/client-sfn": "^3.1146.0",
43
44
  "@sparticuz/chromium": "148.0.0",
@@ -45,7 +46,7 @@
45
46
  "ffprobe-static": "^3.1.0",
46
47
  "puppeteer-core": "^25.8.0",
47
48
  "tar": "^7.4.3",
48
- "@hyperframes/producer": "^0.8.172"
49
+ "@hyperframes/producer": "^0.8.174"
49
50
  },
50
51
  "devDependencies": {
51
52
  "@types/aws-lambda": "^8.10.146",
@@ -228,6 +228,7 @@ async function bundleHandler(stagingDir: string): Promise<void> {
228
228
  "puppeteer",
229
229
  // AWS SDK v3 is pre-installed in the Lambda Node 22 runtime; mark
230
230
  // external so we don't double-bundle 3+ MiB of SDK.
231
+ "@aws-sdk/client-lambda",
231
232
  "@aws-sdk/client-s3",
232
233
  ],
233
234
  plugins: [workspaceAliasPlugin],