@hyperframes/aws-lambda 0.6.20

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.
Files changed (43) hide show
  1. package/README.md +191 -0
  2. package/dist/cdk/HyperframesRenderStack.d.ts +65 -0
  3. package/dist/cdk/HyperframesRenderStack.d.ts.map +1 -0
  4. package/dist/cdk/index.d.ts +10 -0
  5. package/dist/cdk/index.d.ts.map +1 -0
  6. package/dist/cdk/index.js +263 -0
  7. package/dist/cdk/index.js.map +7 -0
  8. package/dist/chromium.d.ts +77 -0
  9. package/dist/chromium.d.ts.map +1 -0
  10. package/dist/events.d.ts +120 -0
  11. package/dist/events.d.ts.map +1 -0
  12. package/dist/formatExtension.d.ts +10 -0
  13. package/dist/formatExtension.d.ts.map +1 -0
  14. package/dist/handler.d.ts +42 -0
  15. package/dist/handler.d.ts.map +1 -0
  16. package/dist/handler.js +432 -0
  17. package/dist/handler.js.map +7 -0
  18. package/dist/index.d.ts +33 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +951 -0
  21. package/dist/index.js.map +7 -0
  22. package/dist/s3Transport.d.ts +55 -0
  23. package/dist/s3Transport.d.ts.map +1 -0
  24. package/dist/sdk/costAccounting.d.ts +51 -0
  25. package/dist/sdk/costAccounting.d.ts.map +1 -0
  26. package/dist/sdk/deploySite.d.ts +55 -0
  27. package/dist/sdk/deploySite.d.ts.map +1 -0
  28. package/dist/sdk/getRenderProgress.d.ts +72 -0
  29. package/dist/sdk/getRenderProgress.d.ts.map +1 -0
  30. package/dist/sdk/index.d.ts +16 -0
  31. package/dist/sdk/index.d.ts.map +1 -0
  32. package/dist/sdk/index.js +578 -0
  33. package/dist/sdk/index.js.map +7 -0
  34. package/dist/sdk/renderToLambda.d.ts +66 -0
  35. package/dist/sdk/renderToLambda.d.ts.map +1 -0
  36. package/dist/sdk/validateConfig.d.ts +35 -0
  37. package/dist/sdk/validateConfig.d.ts.map +1 -0
  38. package/package.json +84 -0
  39. package/scripts/_formatBytes.ts +15 -0
  40. package/scripts/build-zip.ts +480 -0
  41. package/scripts/probe-beginframe.dockerfile +61 -0
  42. package/scripts/probe-beginframe.ts +157 -0
  43. package/scripts/verify-zip-size.ts +83 -0
package/dist/index.js ADDED
@@ -0,0 +1,951 @@
1
+ // src/handler.ts
2
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2, mkdtempSync, readFileSync, rmSync as rmSync2, statSync as statSync2 } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { basename, join as join2 } from "node:path";
5
+ import { S3Client } from "@aws-sdk/client-s3";
6
+ import {
7
+ assemble,
8
+ plan,
9
+ renderChunk
10
+ } from "@hyperframes/producer/distributed";
11
+
12
+ // src/chromium.ts
13
+ import { existsSync } from "node:fs";
14
+ function resolveChromeSource() {
15
+ const raw = process.env.HYPERFRAMES_LAMBDA_CHROME_SOURCE?.toLowerCase();
16
+ if (raw === "chrome-headless-shell" || raw === "shell") return "chrome-headless-shell";
17
+ return "sparticuz";
18
+ }
19
+ async function resolveChromeExecutablePath() {
20
+ const source = resolveChromeSource();
21
+ if (source === "sparticuz") {
22
+ const mod = await loadSparticuzChromium();
23
+ return mod.executablePath();
24
+ }
25
+ const explicit = process.env.HYPERFRAMES_LAMBDA_CHROME_PATH;
26
+ if (!explicit) {
27
+ throw new Error(
28
+ "[chromium] HYPERFRAMES_LAMBDA_CHROME_SOURCE=chrome-headless-shell requires HYPERFRAMES_LAMBDA_CHROME_PATH to be set to the absolute path of the bundled binary."
29
+ );
30
+ }
31
+ if (!existsSync(explicit)) {
32
+ throw new Error(
33
+ `[chromium] HYPERFRAMES_LAMBDA_CHROME_PATH=${JSON.stringify(explicit)} does not exist`
34
+ );
35
+ }
36
+ return explicit;
37
+ }
38
+ async function resolveChromeArgs() {
39
+ if (resolveChromeSource() !== "sparticuz") return [];
40
+ const mod = await loadSparticuzChromium();
41
+ return mod.args;
42
+ }
43
+ var cachedSparticuz = null;
44
+ async function loadSparticuzChromium() {
45
+ if (cachedSparticuz) return cachedSparticuz;
46
+ const mod = await import("@sparticuz/chromium");
47
+ const resolved = "default" in mod ? mod.default : mod;
48
+ cachedSparticuz = resolved;
49
+ return resolved;
50
+ }
51
+
52
+ // src/formatExtension.ts
53
+ function formatExtension(format) {
54
+ switch (format) {
55
+ case "mp4":
56
+ return ".mp4";
57
+ case "mov":
58
+ return ".mov";
59
+ case "png-sequence":
60
+ return "";
61
+ default: {
62
+ const _exhaustive = format;
63
+ throw new Error(`[formatExtension] unsupported format: ${_exhaustive}`);
64
+ }
65
+ }
66
+ }
67
+
68
+ // src/s3Transport.ts
69
+ import {
70
+ createReadStream,
71
+ createWriteStream,
72
+ existsSync as existsSync2,
73
+ mkdirSync,
74
+ readdirSync,
75
+ rmSync,
76
+ statSync
77
+ } from "node:fs";
78
+ import { dirname, join } from "node:path";
79
+ import { pipeline } from "node:stream/promises";
80
+ import { GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
81
+ import * as tar from "tar";
82
+ function parseS3Uri(uri) {
83
+ if (!uri.startsWith("s3://")) {
84
+ throw new Error(`[s3Transport] expected s3:// URI, got: ${JSON.stringify(uri)}`);
85
+ }
86
+ const rest = uri.slice("s3://".length);
87
+ const slash = rest.indexOf("/");
88
+ if (slash === -1) {
89
+ throw new Error(`[s3Transport] missing key in s3 URI: ${JSON.stringify(uri)}`);
90
+ }
91
+ const bucket = rest.slice(0, slash);
92
+ const key = rest.slice(slash + 1);
93
+ if (!bucket || !key) {
94
+ throw new Error(`[s3Transport] empty bucket or key in s3 URI: ${JSON.stringify(uri)}`);
95
+ }
96
+ return { bucket, key };
97
+ }
98
+ function formatS3Uri(loc) {
99
+ return `s3://${loc.bucket}/${loc.key}`;
100
+ }
101
+ async function downloadS3ObjectToFile(client, uri, destPath) {
102
+ const { bucket, key } = parseS3Uri(uri);
103
+ const response = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
104
+ const body = response.Body;
105
+ if (!body) {
106
+ throw new Error(`[s3Transport] s3 GetObject returned empty body for ${uri}`);
107
+ }
108
+ mkdirSync(dirname(destPath), { recursive: true });
109
+ await pipeline(body, createWriteStream(destPath));
110
+ }
111
+ async function uploadFileToS3(client, localPath, uri, contentType) {
112
+ if (!existsSync2(localPath)) {
113
+ throw new Error(`[s3Transport] upload source missing: ${localPath}`);
114
+ }
115
+ const { bucket, key } = parseS3Uri(uri);
116
+ const size = statSync(localPath).size;
117
+ await client.send(
118
+ new PutObjectCommand({
119
+ Bucket: bucket,
120
+ Key: key,
121
+ Body: createReadStream(localPath),
122
+ ContentType: contentType,
123
+ ContentLength: size
124
+ })
125
+ );
126
+ }
127
+ async function tarDirectory(sourceDir, destTarball) {
128
+ if (!existsSync2(sourceDir) || !statSync(sourceDir).isDirectory()) {
129
+ throw new Error(`[s3Transport] tar source must be an existing directory: ${sourceDir}`);
130
+ }
131
+ mkdirSync(dirname(destTarball), { recursive: true });
132
+ await tar.create({ gzip: true, file: destTarball, cwd: sourceDir }, ["."]);
133
+ }
134
+ async function untarDirectory(tarballPath, destDir) {
135
+ if (!existsSync2(tarballPath)) {
136
+ throw new Error(`[s3Transport] tarball missing: ${tarballPath}`);
137
+ }
138
+ if (existsSync2(destDir)) {
139
+ rmSync(destDir, { recursive: true, force: true });
140
+ }
141
+ mkdirSync(destDir, { recursive: true });
142
+ await tar.extract({ file: tarballPath, cwd: destDir });
143
+ }
144
+
145
+ // src/handler.ts
146
+ var cachedS3Client = null;
147
+ function getS3Client() {
148
+ if (cachedS3Client) return cachedS3Client;
149
+ cachedS3Client = new S3Client({});
150
+ return cachedS3Client;
151
+ }
152
+ async function handler(event, deps) {
153
+ const unwrapped = unwrapEvent(event);
154
+ primeRuntimeEnv();
155
+ logEvent({ event: "handler_start", action: unwrapped.Action, input: summarizeEvent(unwrapped) });
156
+ try {
157
+ switch (unwrapped.Action) {
158
+ case "plan":
159
+ return await handlePlan(unwrapped, deps);
160
+ case "renderChunk":
161
+ return await handleRenderChunk(unwrapped, deps);
162
+ case "assemble":
163
+ return await handleAssemble(unwrapped, deps);
164
+ default: {
165
+ const _exhaustive = unwrapped;
166
+ throw new Error(
167
+ `[handler] unknown Action: ${JSON.stringify(
168
+ _exhaustive.Action
169
+ )}. Expected one of "plan", "renderChunk", "assemble".`
170
+ );
171
+ }
172
+ }
173
+ } catch (err) {
174
+ logEvent({
175
+ event: "handler_error",
176
+ action: unwrapped.Action,
177
+ message: err instanceof Error ? err.message : String(err),
178
+ name: err instanceof Error ? err.name : void 0
179
+ });
180
+ throw err;
181
+ }
182
+ }
183
+ var MAX_ENVELOPE_DEPTH = 4;
184
+ function unwrapEvent(event) {
185
+ let cursor = event;
186
+ for (let i = 0; i < MAX_ENVELOPE_DEPTH; i++) {
187
+ if (cursor && typeof cursor === "object") {
188
+ const obj = cursor;
189
+ if (typeof obj.Action === "string" && isLambdaAction(obj.Action)) {
190
+ return cursor;
191
+ }
192
+ if ("Payload" in obj) {
193
+ cursor = obj.Payload;
194
+ continue;
195
+ }
196
+ if ("Input" in obj) {
197
+ cursor = obj.Input;
198
+ continue;
199
+ }
200
+ }
201
+ break;
202
+ }
203
+ throw new Error(
204
+ `[handler] event has no recognised Action; unwrapped ${MAX_ENVELOPE_DEPTH} levels of Payload/Input without finding one.`
205
+ );
206
+ }
207
+ function isLambdaAction(value) {
208
+ return value === "plan" || value === "renderChunk" || value === "assemble";
209
+ }
210
+ function logEvent(payload) {
211
+ console.log(JSON.stringify(payload));
212
+ }
213
+ function summarizeEvent(event) {
214
+ switch (event.Action) {
215
+ case "plan":
216
+ return {
217
+ projectS3Uri: event.ProjectS3Uri,
218
+ planOutputS3Prefix: event.PlanOutputS3Prefix,
219
+ format: event.Config.format,
220
+ fps: event.Config.fps
221
+ };
222
+ case "renderChunk":
223
+ return {
224
+ planS3Uri: event.PlanS3Uri,
225
+ chunkIndex: event.ChunkIndex,
226
+ format: event.Format
227
+ };
228
+ case "assemble":
229
+ return {
230
+ planS3Uri: event.PlanS3Uri,
231
+ chunkCount: event.ChunkS3Uris.length,
232
+ hasAudio: event.AudioS3Uri !== null,
233
+ outputS3Uri: event.OutputS3Uri,
234
+ format: event.Format
235
+ };
236
+ }
237
+ }
238
+ var runtimeEnvPrimed = false;
239
+ function primeRuntimeEnv() {
240
+ if (runtimeEnvPrimed) return;
241
+ runtimeEnvPrimed = true;
242
+ const taskRoot = process.env.LAMBDA_TASK_ROOT ?? "/var/task";
243
+ const bin = join2(taskRoot, "bin");
244
+ if (existsSync3(bin)) {
245
+ process.env.PATH = `${bin}:${process.env.PATH ?? ""}`;
246
+ }
247
+ }
248
+ async function handlePlan(event, deps) {
249
+ const started = Date.now();
250
+ const s3 = deps?.s3 ?? getS3Client();
251
+ const primitive = deps?.primitives?.plan ?? plan;
252
+ const work = mkdtempSync(join2(deps?.tmpRoot ?? tmpdir(), "hf-lambda-plan-"));
253
+ const projectArchive = join2(work, "project.tar.gz");
254
+ const projectDir = join2(work, "project");
255
+ const planDir = join2(work, "plan");
256
+ try {
257
+ await downloadS3ObjectToFile(s3, event.ProjectS3Uri, projectArchive);
258
+ await untarDirectory(projectArchive, projectDir);
259
+ const config = {
260
+ ...event.Config
261
+ };
262
+ const result = await primitive(projectDir, config, planDir);
263
+ const planTar = join2(work, "plan.tar.gz");
264
+ await tarDirectory(planDir, planTar);
265
+ const planTarUri = `${trimTrailingSlash(event.PlanOutputS3Prefix)}/plan.tar.gz`;
266
+ const audioPath = join2(planDir, "audio.aac");
267
+ const hasAudio = existsSync3(audioPath) && statSync2(audioPath).size > 0;
268
+ const audioUri = hasAudio ? `${trimTrailingSlash(event.PlanOutputS3Prefix)}/audio.aac` : null;
269
+ await Promise.all([
270
+ uploadFileToS3(s3, planTar, planTarUri, "application/gzip"),
271
+ hasAudio && audioUri ? uploadFileToS3(s3, audioPath, audioUri, "audio/aac") : null
272
+ ]);
273
+ return {
274
+ Action: "plan",
275
+ PlanS3Uri: planTarUri,
276
+ PlanHash: result.planHash,
277
+ ChunkCount: result.chunkCount,
278
+ TotalFrames: result.totalFrames,
279
+ Fps: result.fps,
280
+ Width: result.width,
281
+ Height: result.height,
282
+ Format: result.format,
283
+ HasAudio: audioUri !== null,
284
+ AudioS3Uri: audioUri,
285
+ FfmpegVersion: result.ffmpegVersion,
286
+ ProducerVersion: result.producerVersion,
287
+ DurationMs: Date.now() - started
288
+ };
289
+ } finally {
290
+ cleanupDir(work);
291
+ }
292
+ }
293
+ async function handleRenderChunk(event, deps) {
294
+ const started = Date.now();
295
+ const s3 = deps?.s3 ?? getS3Client();
296
+ const primitive = deps?.primitives?.renderChunk ?? renderChunk;
297
+ if (!deps?.skipChromeResolution && !process.env.PRODUCER_HEADLESS_SHELL_PATH) {
298
+ const chromePath = await resolveChromeExecutablePath();
299
+ process.env.PRODUCER_HEADLESS_SHELL_PATH = chromePath;
300
+ }
301
+ const work = mkdtempSync(join2(deps?.tmpRoot ?? tmpdir(), "hf-lambda-chunk-"));
302
+ const planTar = join2(work, "plan.tar.gz");
303
+ const planDir = join2(work, "plan");
304
+ try {
305
+ await downloadS3ObjectToFile(s3, event.PlanS3Uri, planTar);
306
+ await untarDirectory(planTar, planDir);
307
+ verifyPlanHash(planDir, event.PlanHash);
308
+ const chunkOutputBase = join2(
309
+ work,
310
+ event.Format === "png-sequence" ? `chunk-${pad(event.ChunkIndex)}` : `chunk-${pad(event.ChunkIndex)}${formatExtension(event.Format)}`
311
+ );
312
+ const result = await primitive(planDir, event.ChunkIndex, chunkOutputBase);
313
+ const chunkUri = await uploadChunkOutput(
314
+ s3,
315
+ result,
316
+ event.ChunkOutputS3Prefix,
317
+ event.ChunkIndex
318
+ );
319
+ return {
320
+ Action: "renderChunk",
321
+ ChunkS3Uri: chunkUri,
322
+ ChunkIndex: event.ChunkIndex,
323
+ Sha256: result.sha256,
324
+ FramesEncoded: result.framesEncoded,
325
+ DurationMs: Date.now() - started
326
+ };
327
+ } finally {
328
+ cleanupDir(work);
329
+ }
330
+ }
331
+ async function uploadChunkOutput(s3, result, prefix, chunkIndex) {
332
+ const trimmed = trimTrailingSlash(prefix);
333
+ if (result.outputKind === "file") {
334
+ const ext = result.outputPath.slice(result.outputPath.lastIndexOf("."));
335
+ const uri2 = `${trimmed}/chunks/${pad(chunkIndex)}${ext}`;
336
+ await uploadFileToS3(s3, result.outputPath, uri2);
337
+ return uri2;
338
+ }
339
+ const tarball = `${result.outputPath}.tar.gz`;
340
+ await tarDirectory(result.outputPath, tarball);
341
+ const uri = `${trimmed}/chunks/${pad(chunkIndex)}.tar.gz`;
342
+ await uploadFileToS3(s3, tarball, uri, "application/gzip");
343
+ return uri;
344
+ }
345
+ async function handleAssemble(event, deps) {
346
+ const started = Date.now();
347
+ const s3 = deps?.s3 ?? getS3Client();
348
+ const primitive = deps?.primitives?.assemble ?? assemble;
349
+ const work = mkdtempSync(join2(deps?.tmpRoot ?? tmpdir(), "hf-lambda-assemble-"));
350
+ const planTar = join2(work, "plan.tar.gz");
351
+ const planDir = join2(work, "plan");
352
+ try {
353
+ await downloadS3ObjectToFile(s3, event.PlanS3Uri, planTar);
354
+ await untarDirectory(planTar, planDir);
355
+ const chunkPaths = await downloadChunkObjects(s3, event.ChunkS3Uris, work, event.Format);
356
+ let audioPath = null;
357
+ if (event.AudioS3Uri) {
358
+ audioPath = join2(planDir, "audio.aac");
359
+ await downloadS3ObjectToFile(s3, event.AudioS3Uri, audioPath);
360
+ }
361
+ const finalOutput = event.Format === "png-sequence" ? join2(work, "output-frames") : join2(work, `output${formatExtension(event.Format)}`);
362
+ const result = await primitive(planDir, chunkPaths, audioPath, finalOutput);
363
+ if (event.Format === "png-sequence") {
364
+ const tarball = `${finalOutput}.tar.gz`;
365
+ await tarDirectory(finalOutput, tarball);
366
+ await uploadFileToS3(s3, tarball, event.OutputS3Uri, "application/gzip");
367
+ } else {
368
+ await uploadFileToS3(s3, finalOutput, event.OutputS3Uri);
369
+ }
370
+ return {
371
+ Action: "assemble",
372
+ OutputS3Uri: event.OutputS3Uri,
373
+ FramesEncoded: result.framesEncoded,
374
+ FileSize: result.fileSize,
375
+ DurationMs: Date.now() - started
376
+ };
377
+ } finally {
378
+ cleanupDir(work);
379
+ }
380
+ }
381
+ async function downloadChunkObjects(s3, uris, workDir, format) {
382
+ const chunksDir = join2(workDir, "chunks");
383
+ mkdirSync2(chunksDir, { recursive: true });
384
+ const local = new Array(uris.length);
385
+ await Promise.all(
386
+ uris.map(async (uri, i) => {
387
+ if (!uri) {
388
+ throw new Error(`[handler] chunk URI at index ${i} is empty`);
389
+ }
390
+ const { key } = parseS3Uri(uri);
391
+ const localPath = join2(chunksDir, basename(key));
392
+ await downloadS3ObjectToFile(s3, uri, localPath);
393
+ if (format === "png-sequence") {
394
+ const dirPath = join2(chunksDir, `frames-${pad(i)}`);
395
+ await untarDirectory(localPath, dirPath);
396
+ local[i] = dirPath;
397
+ } else {
398
+ local[i] = localPath;
399
+ }
400
+ })
401
+ );
402
+ return local;
403
+ }
404
+ function pad(n) {
405
+ return n.toString().padStart(4, "0");
406
+ }
407
+ function trimTrailingSlash(prefix) {
408
+ return prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
409
+ }
410
+ function cleanupDir(dir) {
411
+ try {
412
+ rmSync2(dir, { recursive: true, force: true });
413
+ } catch {
414
+ }
415
+ }
416
+ function verifyPlanHash(planDir, expected) {
417
+ const planJsonPath = join2(planDir, "plan.json");
418
+ let parsed;
419
+ try {
420
+ parsed = JSON.parse(readFileSync(planJsonPath, "utf-8"));
421
+ } catch (err) {
422
+ const msg = err instanceof Error ? err.message : String(err);
423
+ const error = new Error(`PLAN_HASH_MISMATCH: failed to read ${planJsonPath}: ${msg}`);
424
+ error.name = "PLAN_HASH_MISMATCH";
425
+ throw error;
426
+ }
427
+ const actual = parsed.planHash;
428
+ if (typeof actual !== "string" || actual !== expected) {
429
+ const error = new Error(
430
+ `PLAN_HASH_MISMATCH: event PlanHash=${expected} did not match plan.json planHash=${String(actual)}`
431
+ );
432
+ error.name = "PLAN_HASH_MISMATCH";
433
+ throw error;
434
+ }
435
+ }
436
+
437
+ // src/sdk/deploySite.ts
438
+ import { mkdtempSync as mkdtempSync2, readdirSync as readdirSync2, readFileSync as readFileSync2, rmSync as rmSync3, statSync as statSync3 } from "node:fs";
439
+ import { createHash } from "node:crypto";
440
+ import { tmpdir as tmpdir2 } from "node:os";
441
+ import { join as join3, relative } from "node:path";
442
+ import { HeadObjectCommand, S3Client as S3Client2 } from "@aws-sdk/client-s3";
443
+ import { PLAN_PROJECT_DIR_SKIP_SEGMENTS } from "@hyperframes/producer/distributed";
444
+ async function deploySite(opts) {
445
+ if (!statSync3(opts.projectDir).isDirectory()) {
446
+ throw new Error(`[deploySite] projectDir is not a directory: ${opts.projectDir}`);
447
+ }
448
+ const siteId = opts.siteId ?? hashProjectDir(opts.projectDir);
449
+ const key = `sites/${siteId}/project.tar.gz`;
450
+ const projectS3Uri = formatS3Uri({ bucket: opts.bucketName, key });
451
+ const s3 = opts.s3 ?? new S3Client2({ region: opts.region });
452
+ const existing = await headObject(s3, opts.bucketName, key);
453
+ if (existing) {
454
+ return {
455
+ siteId,
456
+ bucketName: opts.bucketName,
457
+ projectS3Uri,
458
+ bytes: existing.bytes,
459
+ uploadedAt: existing.lastModified,
460
+ uploaded: false
461
+ };
462
+ }
463
+ const workdir = mkdtempSync2(join3(tmpdir2(), "hf-deploy-site-"));
464
+ try {
465
+ const tarball = join3(workdir, "project.tar.gz");
466
+ await tarDirectory(opts.projectDir, tarball);
467
+ const size = statSync3(tarball).size;
468
+ await uploadFileToS3(s3, tarball, projectS3Uri, "application/gzip");
469
+ return {
470
+ siteId,
471
+ bucketName: opts.bucketName,
472
+ projectS3Uri,
473
+ bytes: size,
474
+ uploadedAt: (/* @__PURE__ */ new Date()).toISOString(),
475
+ uploaded: true
476
+ };
477
+ } finally {
478
+ rmSync3(workdir, { recursive: true, force: true });
479
+ }
480
+ }
481
+ function hashProjectDir(projectDir) {
482
+ const hash = createHash("sha256");
483
+ const files = [];
484
+ function walk(dir, isRoot) {
485
+ for (const entry of readdirSync2(dir, { withFileTypes: true }).sort(
486
+ (a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0
487
+ )) {
488
+ if (isRoot && PLAN_PROJECT_DIR_SKIP_SEGMENTS.has(entry.name)) continue;
489
+ const full = join3(dir, entry.name);
490
+ if (entry.isDirectory()) walk(full, false);
491
+ else if (entry.isFile()) files.push(full);
492
+ }
493
+ }
494
+ walk(projectDir, true);
495
+ for (const file of files) {
496
+ const rel = relative(projectDir, file).replaceAll("\\", "/");
497
+ hash.update(rel);
498
+ hash.update("\0");
499
+ hash.update(readFileSync2(file));
500
+ }
501
+ return hash.digest("hex").slice(0, 16);
502
+ }
503
+ async function headObject(s3, bucket, key) {
504
+ try {
505
+ const res = await s3.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
506
+ return {
507
+ bytes: typeof res.ContentLength === "number" ? res.ContentLength : 0,
508
+ lastModified: res.LastModified instanceof Date ? res.LastModified.toISOString() : (/* @__PURE__ */ new Date()).toISOString()
509
+ };
510
+ } catch (err) {
511
+ const status = err.$metadata?.httpStatusCode;
512
+ if (status === 404) return null;
513
+ const name = err.name;
514
+ if (name === "NotFound" || name === "NoSuchKey") return null;
515
+ throw err;
516
+ }
517
+ }
518
+
519
+ // src/sdk/renderToLambda.ts
520
+ import { randomUUID } from "node:crypto";
521
+ import { SFNClient, StartExecutionCommand } from "@aws-sdk/client-sfn";
522
+
523
+ // src/sdk/validateConfig.ts
524
+ var InvalidConfigError = class extends Error {
525
+ name = "InvalidConfigError";
526
+ /** Dotted JSON-pointer-ish path to the offending field, e.g. `config.fps`. */
527
+ field;
528
+ constructor(field, message) {
529
+ super(`[validateConfig] ${field}: ${message}`);
530
+ this.field = field;
531
+ }
532
+ };
533
+ var ALLOWED_FPS = [24, 30, 60];
534
+ var ALLOWED_FORMATS = ["mp4", "mov", "png-sequence"];
535
+ var ALLOWED_CODECS = ["h264", "h265"];
536
+ var ALLOWED_QUALITIES = ["draft", "standard", "high"];
537
+ var ALLOWED_RUNTIME_CAPS = ["lambda", "temporal", "cloud-run-job", "k8s-job", "none"];
538
+ var ALLOWED_HDR_MODES = ["auto", "force-sdr"];
539
+ var MAX_DIMENSION = 7680;
540
+ var MIN_DIMENSION = 16;
541
+ var MAX_CHUNK_SIZE = 3600;
542
+ var MAX_PARALLEL_CHUNKS_CEILING = 256;
543
+ function validateDistributedRenderConfig(config) {
544
+ if (config === null || typeof config !== "object") {
545
+ throw new InvalidConfigError("config", "must be an object");
546
+ }
547
+ if (!ALLOWED_FPS.includes(config.fps)) {
548
+ throw new InvalidConfigError(
549
+ "config.fps",
550
+ `must be one of ${ALLOWED_FPS.join(", ")}; got ${String(config.fps)}`
551
+ );
552
+ }
553
+ validateIntDimension("config.width", config.width);
554
+ validateIntDimension("config.height", config.height);
555
+ if (!ALLOWED_FORMATS.includes(config.format)) {
556
+ throw new InvalidConfigError(
557
+ "config.format",
558
+ `must be one of ${ALLOWED_FORMATS.join(", ")}; got ${String(config.format)}`
559
+ );
560
+ }
561
+ if (config.codec !== void 0) {
562
+ if (config.format !== "mp4") {
563
+ throw new InvalidConfigError(
564
+ "config.codec",
565
+ `is only valid with format="mp4"; got format=${String(config.format)}`
566
+ );
567
+ }
568
+ if (!ALLOWED_CODECS.includes(config.codec)) {
569
+ throw new InvalidConfigError(
570
+ "config.codec",
571
+ `must be one of ${ALLOWED_CODECS.join(", ")}; got ${String(config.codec)}`
572
+ );
573
+ }
574
+ }
575
+ if (config.quality !== void 0 && !ALLOWED_QUALITIES.includes(config.quality)) {
576
+ throw new InvalidConfigError(
577
+ "config.quality",
578
+ `must be one of ${ALLOWED_QUALITIES.join(", ")}; got ${String(config.quality)}`
579
+ );
580
+ }
581
+ if (config.crf !== void 0 && config.bitrate !== void 0) {
582
+ throw new InvalidConfigError("config.crf", "is mutually exclusive with config.bitrate");
583
+ }
584
+ if (config.crf !== void 0 && (!Number.isInteger(config.crf) || config.crf < 0 || config.crf > 51)) {
585
+ throw new InvalidConfigError("config.crf", `must be an integer in [0, 51]; got ${config.crf}`);
586
+ }
587
+ if (config.bitrate !== void 0 && !/^\d+(\.\d+)?[kKmM]?$/.test(config.bitrate)) {
588
+ throw new InvalidConfigError(
589
+ "config.bitrate",
590
+ `must look like "10M" or "5000k"; got ${JSON.stringify(config.bitrate)}`
591
+ );
592
+ }
593
+ if (config.chunkSize !== void 0) {
594
+ if (!Number.isInteger(config.chunkSize) || config.chunkSize < 1) {
595
+ throw new InvalidConfigError(
596
+ "config.chunkSize",
597
+ `must be a positive integer; got ${config.chunkSize}`
598
+ );
599
+ }
600
+ if (config.chunkSize > MAX_CHUNK_SIZE) {
601
+ throw new InvalidConfigError(
602
+ "config.chunkSize",
603
+ // Lambda 15-min cap leaves no useful headroom past ~3600 frames
604
+ // at 4 fps capture-equivalent throughput; rejecting up front
605
+ // avoids a 14-minute Plan-state retry storm.
606
+ `must be \u2264 ${MAX_CHUNK_SIZE} (Lambda 15-min cap); got ${config.chunkSize}`
607
+ );
608
+ }
609
+ }
610
+ if (config.maxParallelChunks !== void 0) {
611
+ if (!Number.isInteger(config.maxParallelChunks) || config.maxParallelChunks < 1) {
612
+ throw new InvalidConfigError(
613
+ "config.maxParallelChunks",
614
+ `must be a positive integer; got ${config.maxParallelChunks}`
615
+ );
616
+ }
617
+ if (config.maxParallelChunks > MAX_PARALLEL_CHUNKS_CEILING) {
618
+ throw new InvalidConfigError(
619
+ "config.maxParallelChunks",
620
+ `must be \u2264 ${MAX_PARALLEL_CHUNKS_CEILING}; got ${config.maxParallelChunks}`
621
+ );
622
+ }
623
+ }
624
+ if (config.runtimeCap !== void 0 && !ALLOWED_RUNTIME_CAPS.includes(config.runtimeCap)) {
625
+ throw new InvalidConfigError(
626
+ "config.runtimeCap",
627
+ `must be one of ${ALLOWED_RUNTIME_CAPS.join(", ")}; got ${String(config.runtimeCap)}`
628
+ );
629
+ }
630
+ if (config.hdrMode !== void 0 && !ALLOWED_HDR_MODES.includes(config.hdrMode)) {
631
+ throw new InvalidConfigError(
632
+ "config.hdrMode",
633
+ `distributed mode supports only ${ALLOWED_HDR_MODES.join(", ")}; got ${String(config.hdrMode)}`
634
+ );
635
+ }
636
+ return config;
637
+ }
638
+ function validateIntDimension(field, value) {
639
+ if (typeof value !== "number" || !Number.isInteger(value)) {
640
+ throw new InvalidConfigError(field, `must be an integer; got ${String(value)}`);
641
+ }
642
+ if (value < MIN_DIMENSION || value > MAX_DIMENSION) {
643
+ throw new InvalidConfigError(
644
+ field,
645
+ `must be in [${MIN_DIMENSION}, ${MAX_DIMENSION}]; got ${value}`
646
+ );
647
+ }
648
+ if (value % 2 !== 0) {
649
+ throw new InvalidConfigError(field, `must be even (yuv420p constraint); got ${value}`);
650
+ }
651
+ }
652
+
653
+ // src/sdk/renderToLambda.ts
654
+ async function renderToLambda(opts) {
655
+ validateDistributedRenderConfig(opts.config);
656
+ if (!opts.bucketName) {
657
+ throw new Error("[renderToLambda] bucketName is required");
658
+ }
659
+ if (!opts.stateMachineArn) {
660
+ throw new Error("[renderToLambda] stateMachineArn is required");
661
+ }
662
+ if (!opts.siteHandle && !opts.projectDir) {
663
+ throw new Error("[renderToLambda] either siteHandle or projectDir must be supplied");
664
+ }
665
+ const executionName = opts.executionName ?? `hf-render-${randomUUID()}`;
666
+ const ext = formatExtension(opts.config.format);
667
+ const outputKey = opts.outputKey ?? `renders/${executionName}/output${ext}`;
668
+ const planOutputS3Prefix = formatS3Uri({
669
+ bucket: opts.bucketName,
670
+ key: `renders/${executionName}/`
671
+ });
672
+ const outputS3Uri = formatS3Uri({ bucket: opts.bucketName, key: outputKey });
673
+ const site = opts.siteHandle ?? await deploySite({
674
+ projectDir: opts.projectDir,
675
+ bucketName: opts.bucketName,
676
+ region: opts.region,
677
+ s3: opts.s3
678
+ });
679
+ const input = {
680
+ ProjectS3Uri: site.projectS3Uri,
681
+ PlanOutputS3Prefix: planOutputS3Prefix,
682
+ OutputS3Uri: outputS3Uri,
683
+ Config: opts.config
684
+ };
685
+ const sfn = opts.sfn ?? new SFNClient({ region: opts.region });
686
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
687
+ const response = await sfn.send(
688
+ new StartExecutionCommand({
689
+ stateMachineArn: opts.stateMachineArn,
690
+ name: executionName,
691
+ input: JSON.stringify(input)
692
+ })
693
+ );
694
+ if (!response.executionArn) {
695
+ throw new Error("[renderToLambda] StartExecution returned no executionArn");
696
+ }
697
+ return {
698
+ renderId: executionName,
699
+ executionArn: response.executionArn,
700
+ bucketName: opts.bucketName,
701
+ stateMachineArn: opts.stateMachineArn,
702
+ outputS3Uri,
703
+ projectS3Uri: site.projectS3Uri,
704
+ startedAt
705
+ };
706
+ }
707
+
708
+ // src/sdk/getRenderProgress.ts
709
+ import {
710
+ DescribeExecutionCommand,
711
+ GetExecutionHistoryCommand,
712
+ SFNClient as SFNClient2
713
+ } from "@aws-sdk/client-sfn";
714
+
715
+ // src/sdk/costAccounting.ts
716
+ var LAMBDA_USD_PER_GB_SECOND = 166667e-10;
717
+ var SFN_USD_PER_TRANSITION = 25e-6;
718
+ function computeRenderCost(lambdaInvocations, stateTransitions) {
719
+ let lambdaUsd = 0;
720
+ let anyEstimated = false;
721
+ for (const inv of lambdaInvocations) {
722
+ const gbSeconds = inv.memorySizeMb / 1024 * (inv.billedDurationMs / 1e3);
723
+ lambdaUsd += gbSeconds * LAMBDA_USD_PER_GB_SECOND;
724
+ if (inv.estimated) anyEstimated = true;
725
+ }
726
+ const stepFunctionsUsd = stateTransitions * SFN_USD_PER_TRANSITION;
727
+ const accruedSoFarUsd = roundUsd(lambdaUsd + stepFunctionsUsd);
728
+ return {
729
+ accruedSoFarUsd,
730
+ displayCost: formatUsd(accruedSoFarUsd),
731
+ breakdown: {
732
+ lambdaUsd: roundUsd(lambdaUsd),
733
+ stepFunctionsUsd: roundUsd(stepFunctionsUsd),
734
+ s3Estimate: "not-included",
735
+ estimated: anyEstimated
736
+ }
737
+ };
738
+ }
739
+ function roundUsd(usd) {
740
+ return Math.round(usd * 1e4) / 1e4;
741
+ }
742
+ function formatUsd(usd) {
743
+ return `$${usd.toFixed(4)}`;
744
+ }
745
+
746
+ // src/sdk/getRenderProgress.ts
747
+ var DEFAULT_MEMORY_MB = 10240;
748
+ async function getRenderProgress(opts) {
749
+ if (!opts.executionArn) {
750
+ throw new Error("[getRenderProgress] executionArn is required");
751
+ }
752
+ const sfn = opts.sfn ?? new SFNClient2({ region: opts.region });
753
+ const memoryMb = opts.defaultMemorySizeMb ?? DEFAULT_MEMORY_MB;
754
+ const describe = await sfn.send(
755
+ new DescribeExecutionCommand({ executionArn: opts.executionArn })
756
+ );
757
+ const status = describe.status ?? "RUNNING";
758
+ const startedAt = describe.startDate?.toISOString() ?? (/* @__PURE__ */ new Date(0)).toISOString();
759
+ const endedAt = describe.stopDate?.toISOString() ?? null;
760
+ const history = await loadFullHistory(sfn, opts.executionArn);
761
+ const summary = summarizeHistory(history, memoryMb);
762
+ const costs = computeRenderCost(summary.lambdaInvocations, summary.stateTransitions);
763
+ const overallProgress = computeOverallProgress({
764
+ status,
765
+ totalFrames: summary.totalFrames,
766
+ framesRendered: summary.framesRendered,
767
+ assembleComplete: summary.assembleComplete
768
+ });
769
+ return {
770
+ status,
771
+ overallProgress,
772
+ framesRendered: summary.framesRendered,
773
+ totalFrames: summary.totalFrames,
774
+ lambdasInvoked: summary.lambdasInvoked,
775
+ costs,
776
+ outputFile: summary.outputFile,
777
+ errors: summary.errors,
778
+ fatalErrorEncountered: isTerminalFailure(status),
779
+ startedAt,
780
+ endedAt
781
+ };
782
+ }
783
+ async function loadFullHistory(sfn, executionArn) {
784
+ const events = [];
785
+ let nextToken;
786
+ for (let page = 0; page < 50; page++) {
787
+ const res = await sfn.send(
788
+ new GetExecutionHistoryCommand({
789
+ executionArn,
790
+ maxResults: 1e3,
791
+ nextToken,
792
+ reverseOrder: false
793
+ })
794
+ );
795
+ if (res.events) events.push(...res.events);
796
+ nextToken = res.nextToken;
797
+ if (!nextToken) break;
798
+ }
799
+ return events;
800
+ }
801
+ function summarizeHistory(events, memoryMb) {
802
+ let framesRendered = 0;
803
+ let totalFrames = null;
804
+ let lambdasInvoked = 0;
805
+ let assembleComplete = false;
806
+ let outputFile = null;
807
+ let stateTransitions = 0;
808
+ const errors = [];
809
+ const lambdaInvocations = [];
810
+ let currentLambdaState = null;
811
+ for (const ev of events) {
812
+ switch (ev.type) {
813
+ case "TaskStateEntered":
814
+ case "MapStateEntered":
815
+ case "PassStateEntered":
816
+ case "ChoiceStateEntered":
817
+ case "SucceedStateEntered":
818
+ case "FailStateEntered":
819
+ case "WaitStateEntered":
820
+ case "ParallelStateEntered":
821
+ stateTransitions++;
822
+ currentLambdaState = ev.stateEnteredEventDetails?.name ?? currentLambdaState;
823
+ break;
824
+ case "LambdaFunctionScheduled":
825
+ lambdasInvoked++;
826
+ break;
827
+ case "LambdaFunctionSucceeded": {
828
+ const payload = parseJson(ev.lambdaFunctionSucceededEventDetails?.output);
829
+ const billedDurationMs = inferBilledMs(payload);
830
+ lambdaInvocations.push({
831
+ billedDurationMs,
832
+ memorySizeMb: memoryMb,
833
+ estimated: billedDurationMs === 0
834
+ });
835
+ if (payload && typeof payload === "object") {
836
+ const obj = payload;
837
+ if (typeof obj.TotalFrames === "number") totalFrames = obj.TotalFrames;
838
+ if (typeof obj.FramesEncoded === "number") {
839
+ if (currentLambdaState === "RenderChunk") {
840
+ framesRendered += obj.FramesEncoded;
841
+ }
842
+ }
843
+ }
844
+ break;
845
+ }
846
+ case "TaskStateExited":
847
+ case "MapStateExited":
848
+ if (ev.stateExitedEventDetails?.name === "Assemble") {
849
+ assembleComplete = true;
850
+ const exitPayload = parseJson(ev.stateExitedEventDetails?.output);
851
+ if (exitPayload && typeof exitPayload === "object") {
852
+ const obj = exitPayload;
853
+ const out = obj.Output;
854
+ const outputS3Uri = typeof out?.OutputS3Uri === "string" ? out.OutputS3Uri : null;
855
+ const bytes = typeof out?.FileSize === "number" ? out.FileSize : null;
856
+ outputFile = outputS3Uri ? { s3Uri: outputS3Uri, bytes } : outputFile;
857
+ }
858
+ }
859
+ break;
860
+ case "LambdaFunctionFailed":
861
+ errors.push({
862
+ state: currentLambdaState ?? "<unknown>",
863
+ error: ev.lambdaFunctionFailedEventDetails?.error ?? "UNKNOWN",
864
+ cause: ev.lambdaFunctionFailedEventDetails?.cause ?? ""
865
+ });
866
+ break;
867
+ case "ExecutionFailed":
868
+ errors.push({
869
+ state: "<execution>",
870
+ error: ev.executionFailedEventDetails?.error ?? "UNKNOWN",
871
+ cause: ev.executionFailedEventDetails?.cause ?? ""
872
+ });
873
+ break;
874
+ case "ExecutionAborted":
875
+ errors.push({
876
+ state: "<execution>",
877
+ error: ev.executionAbortedEventDetails?.error ?? "ABORTED",
878
+ cause: ev.executionAbortedEventDetails?.cause ?? ""
879
+ });
880
+ break;
881
+ case "ExecutionTimedOut":
882
+ errors.push({
883
+ state: "<execution>",
884
+ error: "TIMEOUT",
885
+ cause: ev.executionTimedOutEventDetails?.cause ?? ""
886
+ });
887
+ break;
888
+ default:
889
+ break;
890
+ }
891
+ }
892
+ return {
893
+ lambdaInvocations,
894
+ stateTransitions,
895
+ framesRendered,
896
+ totalFrames,
897
+ lambdasInvoked,
898
+ assembleComplete,
899
+ outputFile,
900
+ errors
901
+ };
902
+ }
903
+ function parseJson(s) {
904
+ if (!s) return null;
905
+ try {
906
+ return JSON.parse(s);
907
+ } catch {
908
+ return null;
909
+ }
910
+ }
911
+ function inferBilledMs(payload) {
912
+ if (!payload || typeof payload !== "object") return 0;
913
+ const obj = payload;
914
+ if (typeof obj.DurationMs === "number") return obj.DurationMs;
915
+ return 0;
916
+ }
917
+ function computeOverallProgress({
918
+ status,
919
+ totalFrames,
920
+ framesRendered,
921
+ assembleComplete
922
+ }) {
923
+ if (status === "SUCCEEDED") return 1;
924
+ if (assembleComplete) return 1;
925
+ if (totalFrames === null) return 0;
926
+ const chunkProgress = Math.min(1, framesRendered / totalFrames);
927
+ return 0.1 + 0.8 * chunkProgress;
928
+ }
929
+ function isTerminalFailure(status) {
930
+ return status === "FAILED" || status === "TIMED_OUT" || status === "ABORTED";
931
+ }
932
+ export {
933
+ InvalidConfigError,
934
+ computeRenderCost,
935
+ deploySite,
936
+ downloadS3ObjectToFile,
937
+ formatS3Uri,
938
+ getRenderProgress,
939
+ handler,
940
+ parseS3Uri,
941
+ renderToLambda,
942
+ resolveChromeArgs,
943
+ resolveChromeExecutablePath,
944
+ resolveChromeSource,
945
+ tarDirectory,
946
+ untarDirectory,
947
+ unwrapEvent,
948
+ uploadFileToS3,
949
+ validateDistributedRenderConfig
950
+ };
951
+ //# sourceMappingURL=index.js.map