@hyperframes/aws-lambda 0.8.186 → 0.8.189
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/_chunks/{chunk-SGNVVPIS.js → chunk-CKQ6SAJX.js} +46 -29
- package/dist/_chunks/{chunk-5FLGGUWE.js → chunk-EG2WRNUL.js} +34 -25
- package/dist/_chunks/{chunk-QCYPNODV.js → chunk-ROTCAPRH.js} +6 -2
- package/dist/handler.js +2 -2
- package/dist/index.js +3 -3
- package/dist/sdk/costAccounting.d.ts +9 -0
- package/dist/sdk/getRenderProgress.d.ts +3 -1
- package/dist/sdk/index.d.ts +2 -1
- package/dist/sdk/index.js +197 -7
- package/dist/sdk/prebuiltHandler.d.ts +6 -2
- package/dist/sdk/pushImageToEcr.d.ts +40 -0
- package/package.json +5 -2
- package/prebuilt/handler-zip.json +4 -4
- package/prebuilt/template.yaml +101 -20
- package/prebuilt/video-function.yaml +108 -0
- package/prebuilt/video-image.json +6 -0
- package/scripts/prebuilt-template.mjs +8 -3
- package/scripts/pin-handler-zip.ts +0 -48
|
@@ -32,7 +32,7 @@ var MAX_SINGLE_PUT_BYTES = 5 * 1024 ** 3;
|
|
|
32
32
|
var MAX_PARTS = 1e4;
|
|
33
33
|
var MIN_FILE_PART_BYTES = 64 * 1024 * 1024;
|
|
34
34
|
var FILE_PARTS_IN_FLIGHT = 8;
|
|
35
|
-
var
|
|
35
|
+
var UPLOAD_ATTEMPTS = 3;
|
|
36
36
|
function parseS3Uri(uri) {
|
|
37
37
|
if (!uri.startsWith("s3://")) {
|
|
38
38
|
throw new Error(`[s3Transport] expected s3:// URI, got: ${JSON.stringify(uri)}`);
|
|
@@ -111,14 +111,18 @@ async function uploadFileToS3(client, localPath, uri, contentType) {
|
|
|
111
111
|
});
|
|
112
112
|
return;
|
|
113
113
|
}
|
|
114
|
-
await
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
114
|
+
await sendFileBody(
|
|
115
|
+
localPath,
|
|
116
|
+
void 0,
|
|
117
|
+
(body) => client.send(
|
|
118
|
+
new PutObjectCommand({
|
|
119
|
+
Bucket: bucket,
|
|
120
|
+
Key: key,
|
|
121
|
+
Body: body,
|
|
122
|
+
ContentType: contentType,
|
|
123
|
+
ContentLength: size
|
|
124
|
+
})
|
|
125
|
+
)
|
|
122
126
|
);
|
|
123
127
|
}
|
|
124
128
|
async function uploadContentAddressedFileToS3(client, localPath, uri, expectedSha256, contentType) {
|
|
@@ -152,9 +156,10 @@ async function putImmutableFile(client, localPath, uri, expectedSha256, contentT
|
|
|
152
156
|
}
|
|
153
157
|
}
|
|
154
158
|
async function putImmutableBody(client, localPath, bucket, key, size, expectedSha256, contentType) {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
159
|
+
await sendFileBody(
|
|
160
|
+
localPath,
|
|
161
|
+
void 0,
|
|
162
|
+
(body) => client.send(
|
|
158
163
|
new PutObjectCommand({
|
|
159
164
|
Bucket: bucket,
|
|
160
165
|
Key: key,
|
|
@@ -165,10 +170,8 @@ async function putImmutableBody(client, localPath, bucket, key, size, expectedSh
|
|
|
165
170
|
ChecksumSHA256: Buffer.from(expectedSha256, "hex").toString("base64"),
|
|
166
171
|
IfNoneMatch: "*"
|
|
167
172
|
})
|
|
168
|
-
)
|
|
169
|
-
|
|
170
|
-
body.destroy();
|
|
171
|
-
}
|
|
173
|
+
)
|
|
174
|
+
);
|
|
172
175
|
}
|
|
173
176
|
async function multipartUpload(client, create2, sendParts, complete = {}) {
|
|
174
177
|
const created = await client.send(new CreateMultipartUploadCommand(create2));
|
|
@@ -255,22 +258,29 @@ async function hashRange(path, { start, end }, whole) {
|
|
|
255
258
|
return part.digest("base64");
|
|
256
259
|
}
|
|
257
260
|
async function sendFilePart(client, target, partNumber, path, { start, end }, checksum) {
|
|
261
|
+
const result = await sendFileBody(
|
|
262
|
+
path,
|
|
263
|
+
{ start, end },
|
|
264
|
+
(body) => client.send(
|
|
265
|
+
new UploadPartCommand({
|
|
266
|
+
...target,
|
|
267
|
+
PartNumber: partNumber,
|
|
268
|
+
Body: body,
|
|
269
|
+
ContentLength: end - start,
|
|
270
|
+
ChecksumSHA256: checksum
|
|
271
|
+
})
|
|
272
|
+
)
|
|
273
|
+
);
|
|
274
|
+
if (!result.ETag) throw new Error(`[s3Transport] part ${partNumber} returned no ETag`);
|
|
275
|
+
return { ETag: result.ETag, PartNumber: partNumber, ChecksumSHA256: checksum };
|
|
276
|
+
}
|
|
277
|
+
async function sendFileBody(path, range, send) {
|
|
258
278
|
for (let attempt = 1; ; attempt++) {
|
|
259
|
-
const body = createReadStream(path, { start, end: end - 1 });
|
|
279
|
+
const body = createReadStream(path, range && { start: range.start, end: range.end - 1 });
|
|
260
280
|
try {
|
|
261
|
-
|
|
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 };
|
|
281
|
+
return await send(body);
|
|
272
282
|
} catch (error) {
|
|
273
|
-
if (attempt >=
|
|
283
|
+
if (attempt >= UPLOAD_ATTEMPTS || !isTransientS3Error(error)) throw error;
|
|
274
284
|
} finally {
|
|
275
285
|
body.destroy();
|
|
276
286
|
}
|
|
@@ -420,6 +430,13 @@ function isS3PreconditionFailed(error) {
|
|
|
420
430
|
const metadata = isRecord(error.$metadata) ? error.$metadata : void 0;
|
|
421
431
|
return error.name === "PreconditionFailed" || metadata?.httpStatusCode === 412;
|
|
422
432
|
}
|
|
433
|
+
var TRANSIENT_S3_ERRORS = /* @__PURE__ */ new Set(["TimeoutError", "RequestTimeout", "RequestTimeoutException"]);
|
|
434
|
+
function isTransientS3Error(error) {
|
|
435
|
+
if (!isRecord(error)) return false;
|
|
436
|
+
if (typeof error.name === "string" && TRANSIENT_S3_ERRORS.has(error.name)) return true;
|
|
437
|
+
const status = isRecord(error.$metadata) ? error.$metadata.httpStatusCode : void 0;
|
|
438
|
+
return typeof status !== "number" || status === 429 || status >= 500;
|
|
439
|
+
}
|
|
423
440
|
function isRecord(value) {
|
|
424
441
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
425
442
|
}
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
siteManifestUri,
|
|
17
17
|
siteTarballUri,
|
|
18
18
|
uploadContentAddressedFileToS3
|
|
19
|
-
} from "./chunk-
|
|
19
|
+
} from "./chunk-CKQ6SAJX.js";
|
|
20
20
|
import {
|
|
21
21
|
CONTENT_STORE_RETENTION_DAYS
|
|
22
22
|
} from "./chunk-YS3KY3C5.js";
|
|
@@ -234,14 +234,14 @@ async function assertStackReadsSites(opts) {
|
|
|
234
234
|
}
|
|
235
235
|
|
|
236
236
|
// src/sdk/costAccounting.ts
|
|
237
|
-
var LAMBDA_USD_PER_GB_SECOND = 166667e-10;
|
|
237
|
+
var LAMBDA_USD_PER_GB_SECOND = { x86_64: 166667e-10, arm64: 133334e-10 };
|
|
238
238
|
var SFN_USD_PER_TRANSITION = 25e-6;
|
|
239
239
|
function computeRenderCost(lambdaInvocations, stateTransitions) {
|
|
240
240
|
let lambdaUsd = 0;
|
|
241
241
|
let anyEstimated = false;
|
|
242
242
|
for (const inv of lambdaInvocations) {
|
|
243
243
|
const gbSeconds = inv.memorySizeMb / 1024 * (inv.billedDurationMs / 1e3);
|
|
244
|
-
lambdaUsd += gbSeconds * LAMBDA_USD_PER_GB_SECOND;
|
|
244
|
+
lambdaUsd += gbSeconds * LAMBDA_USD_PER_GB_SECOND[inv.architecture ?? "x86_64"];
|
|
245
245
|
if (inv.estimated) anyEstimated = true;
|
|
246
246
|
}
|
|
247
247
|
const stepFunctionsUsd = stateTransitions * SFN_USD_PER_TRANSITION;
|
|
@@ -289,10 +289,10 @@ async function getRenderProgress(opts) {
|
|
|
289
289
|
const startedAt = describe.startDate?.toISOString() ?? (/* @__PURE__ */ new Date(0)).toISOString();
|
|
290
290
|
const endedAt = describe.stopDate?.toISOString() ?? null;
|
|
291
291
|
const history = await loadFullHistory(sfn, opts.executionArn);
|
|
292
|
-
const summary = summarizeHistory(history, memoryMb);
|
|
292
|
+
const summary = summarizeHistory(history, memoryMb, opts.videoArchitecture);
|
|
293
293
|
const mapRun = findChunkMapRun(history);
|
|
294
294
|
const warnings = [];
|
|
295
|
-
if (mapRun?.exited) addChunkResults(summary, mapRun.output, memoryMb);
|
|
295
|
+
if (mapRun?.exited) addChunkResults(summary, mapRun.output, memoryMb, opts.videoArchitecture);
|
|
296
296
|
else if (mapRun) {
|
|
297
297
|
await addRunningMapRun(sfn, mapRun.arn, summary, memoryMb).catch((err) => {
|
|
298
298
|
if (err?.name !== "AccessDeniedException") throw err;
|
|
@@ -353,17 +353,12 @@ function findChunkMapRun(events) {
|
|
|
353
353
|
}
|
|
354
354
|
return run;
|
|
355
355
|
}
|
|
356
|
-
function addChunkResults(summary, mapOutput, memoryMb) {
|
|
356
|
+
function addChunkResults(summary, mapOutput, memoryMb, architecture) {
|
|
357
357
|
const chunks = parseJson(mapOutput)?.Chunks;
|
|
358
358
|
for (const chunk of Array.isArray(chunks) ? chunks : []) {
|
|
359
359
|
summary.lambdasInvoked++;
|
|
360
360
|
summary.stateTransitions++;
|
|
361
|
-
|
|
362
|
-
summary.lambdaInvocations.push({
|
|
363
|
-
billedDurationMs,
|
|
364
|
-
memorySizeMb: memoryMb,
|
|
365
|
-
estimated: billedDurationMs === 0
|
|
366
|
-
});
|
|
361
|
+
summary.lambdaInvocations.push(billedInvocation(chunk, memoryMb, architecture));
|
|
367
362
|
applyPayloadFrameCounts(chunk, "RenderChunk", (delta) => {
|
|
368
363
|
summary.framesRendered += delta;
|
|
369
364
|
});
|
|
@@ -395,7 +390,7 @@ async function failedChunkErrors(sfn, mapRunArn, memoryMb) {
|
|
|
395
390
|
}
|
|
396
391
|
return errors;
|
|
397
392
|
}
|
|
398
|
-
function summarizeHistory(events, memoryMb) {
|
|
393
|
+
function summarizeHistory(events, memoryMb, videoArchitecture) {
|
|
399
394
|
let framesRendered = 0;
|
|
400
395
|
let totalFrames = null;
|
|
401
396
|
let lambdasInvoked = 0;
|
|
@@ -432,12 +427,13 @@ function summarizeHistory(events, memoryMb) {
|
|
|
432
427
|
if (ev.taskSucceededEventDetails?.resourceType !== "lambda") break;
|
|
433
428
|
const wrapped = parseJson(ev.taskSucceededEventDetails?.output);
|
|
434
429
|
const payload = unwrapLambdaPayload(wrapped);
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
430
|
+
lambdaInvocations.push(
|
|
431
|
+
billedInvocation(
|
|
432
|
+
payload,
|
|
433
|
+
memoryMb,
|
|
434
|
+
stateArchitecture(currentLambdaState, videoArchitecture)
|
|
435
|
+
)
|
|
436
|
+
);
|
|
441
437
|
applyPayloadFrameCounts(payload, currentLambdaState, (delta) => {
|
|
442
438
|
framesRendered += delta;
|
|
443
439
|
});
|
|
@@ -449,12 +445,13 @@ function summarizeHistory(events, memoryMb) {
|
|
|
449
445
|
}
|
|
450
446
|
case "LambdaFunctionSucceeded": {
|
|
451
447
|
const payload = parseJson(ev.lambdaFunctionSucceededEventDetails?.output);
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
448
|
+
lambdaInvocations.push(
|
|
449
|
+
billedInvocation(
|
|
450
|
+
payload,
|
|
451
|
+
memoryMb,
|
|
452
|
+
stateArchitecture(currentLambdaState, videoArchitecture)
|
|
453
|
+
)
|
|
454
|
+
);
|
|
458
455
|
applyPayloadFrameCounts(payload, currentLambdaState, (delta) => {
|
|
459
456
|
framesRendered += delta;
|
|
460
457
|
});
|
|
@@ -559,6 +556,18 @@ function isRenderChunksMapState(name) {
|
|
|
559
556
|
function isAssembleState(name) {
|
|
560
557
|
return name === "Assemble" || name === "AssembleV2";
|
|
561
558
|
}
|
|
559
|
+
function billedInvocation(payload, memoryMb, architecture) {
|
|
560
|
+
const billedDurationMs = inferBilledMs(payload);
|
|
561
|
+
return {
|
|
562
|
+
billedDurationMs,
|
|
563
|
+
memorySizeMb: memoryMb,
|
|
564
|
+
estimated: billedDurationMs === 0,
|
|
565
|
+
architecture
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
function stateArchitecture(state, videoArchitecture) {
|
|
569
|
+
return state === "Plan" || state === "PlanV2" || isRenderChunkState(state) ? videoArchitecture : void 0;
|
|
570
|
+
}
|
|
562
571
|
function inferBilledMs(payload) {
|
|
563
572
|
if (!payload || typeof payload !== "object") return 0;
|
|
564
573
|
const obj = payload;
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
untarDirectory,
|
|
16
16
|
uploadContentAddressedFileToS3,
|
|
17
17
|
uploadFileToS3
|
|
18
|
-
} from "./chunk-
|
|
18
|
+
} from "./chunk-CKQ6SAJX.js";
|
|
19
19
|
import {
|
|
20
20
|
CONTENT_STORE_PREFIX
|
|
21
21
|
} from "./chunk-YS3KY3C5.js";
|
|
@@ -844,7 +844,11 @@ async function handleAssembleV2(event, deps) {
|
|
|
844
844
|
if (chunksAreStitchedMp4(planDir, event.Cfr === true)) {
|
|
845
845
|
return await stitchChunksInS3(s3, event, manifest, started);
|
|
846
846
|
}
|
|
847
|
-
|
|
847
|
+
let audioPath = resolvePlanAudioPath(planDir);
|
|
848
|
+
if (event.AudioS3Uri !== null) {
|
|
849
|
+
audioPath = join2(planDir, PLAN_AUDIO_RELATIVE_PATH);
|
|
850
|
+
await downloadS3ObjectToFile(s3, event.AudioS3Uri, audioPath);
|
|
851
|
+
}
|
|
848
852
|
const chunksStarted = Date.now();
|
|
849
853
|
const chunkPaths = await downloadChunkObjects(s3, event.ChunkS3Uris, work, event.Format);
|
|
850
854
|
const chunksMs = Date.now() - chunksStarted;
|
package/dist/handler.js
CHANGED
package/dist/index.js
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
resolveChromeExecutablePath,
|
|
7
7
|
resolveChromeSource,
|
|
8
8
|
unwrapEvent
|
|
9
|
-
} from "./_chunks/chunk-
|
|
9
|
+
} from "./_chunks/chunk-ROTCAPRH.js";
|
|
10
10
|
import {
|
|
11
11
|
InvalidConfigError,
|
|
12
12
|
computeRenderCost,
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
getRenderProgress,
|
|
15
15
|
renderToLambda,
|
|
16
16
|
validateDistributedRenderConfig
|
|
17
|
-
} from "./_chunks/chunk-
|
|
17
|
+
} from "./_chunks/chunk-EG2WRNUL.js";
|
|
18
18
|
import {
|
|
19
19
|
downloadS3ObjectToFile,
|
|
20
20
|
downloadS3ObjectToFileVerified,
|
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
untarDirectory,
|
|
25
25
|
uploadContentAddressedFileToS3,
|
|
26
26
|
uploadFileToS3
|
|
27
|
-
} from "./_chunks/chunk-
|
|
27
|
+
} from "./_chunks/chunk-CKQ6SAJX.js";
|
|
28
28
|
import "./_chunks/chunk-YS3KY3C5.js";
|
|
29
29
|
export {
|
|
30
30
|
ChromeBinaryUnavailableError,
|
|
@@ -16,6 +16,12 @@
|
|
|
16
16
|
* see the dollar value but downstream automation can also read the
|
|
17
17
|
* raw number.
|
|
18
18
|
*/
|
|
19
|
+
/** On-demand Lambda price, us-east-1: USD per GB-second, by function architecture. */
|
|
20
|
+
declare const LAMBDA_USD_PER_GB_SECOND: {
|
|
21
|
+
readonly x86_64: 0.0000166667;
|
|
22
|
+
readonly arm64: 0.0000133334;
|
|
23
|
+
};
|
|
24
|
+
export type LambdaArchitecture = keyof typeof LAMBDA_USD_PER_GB_SECOND;
|
|
19
25
|
/** Raw history event subset the cost calc cares about. Caller filters from `getExecutionHistory`. */
|
|
20
26
|
export interface BilledLambdaInvocation {
|
|
21
27
|
/** Millis of Lambda billed duration. Carried on `TaskSucceeded`/`TaskFailed` events. */
|
|
@@ -24,6 +30,8 @@ export interface BilledLambdaInvocation {
|
|
|
24
30
|
memorySizeMb: number;
|
|
25
31
|
/** `true` if the event payload did NOT carry a billed duration and we fell back to `Duration` or a constant. */
|
|
26
32
|
estimated: boolean;
|
|
33
|
+
/** Architecture of the invoked function; x86_64 when omitted. */
|
|
34
|
+
architecture?: LambdaArchitecture;
|
|
27
35
|
}
|
|
28
36
|
/** Result of {@link computeRenderCost}. */
|
|
29
37
|
export interface RenderCost {
|
|
@@ -48,3 +56,4 @@ export interface RenderCost {
|
|
|
48
56
|
* workflows. Express workflows price differently and are out of scope.
|
|
49
57
|
*/
|
|
50
58
|
export declare function computeRenderCost(lambdaInvocations: BilledLambdaInvocation[], stateTransitions: number): RenderCost;
|
|
59
|
+
export {};
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* "JSON.parse this string and grab two fields."
|
|
21
21
|
*/
|
|
22
22
|
import { SFNClient, type SFNClientConfig } from "@aws-sdk/client-sfn";
|
|
23
|
-
import { type RenderCost } from "./costAccounting.js";
|
|
23
|
+
import { type LambdaArchitecture, type RenderCost } from "./costAccounting.js";
|
|
24
24
|
/** Options for {@link getRenderProgress}. */
|
|
25
25
|
export interface GetRenderProgressOptions {
|
|
26
26
|
/** Execution ARN from a {@link renderToLambda} call. */
|
|
@@ -31,6 +31,8 @@ export interface GetRenderProgressOptions {
|
|
|
31
31
|
* `LambdaMemoryMb` parameter the stack was deployed with.
|
|
32
32
|
*/
|
|
33
33
|
defaultMemorySizeMb?: number;
|
|
34
|
+
/** Architecture of the stack's video function, which runs the plan and the chunks: `arm64` when it has one. */
|
|
35
|
+
videoArchitecture?: LambdaArchitecture;
|
|
34
36
|
region?: string;
|
|
35
37
|
/** Test injection seam. */
|
|
36
38
|
sfn?: SFNClient;
|
package/dist/sdk/index.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ export { assertStackReadsSites, renderToLambda, type RenderHandle, type RenderTo
|
|
|
12
12
|
export { getRenderProgress, type GetRenderProgressOptions, type RenderError, type RenderProgress, type RenderStatus, } from "./getRenderProgress.js";
|
|
13
13
|
export { type BilledLambdaInvocation, computeRenderCost, type RenderCost, } from "./costAccounting.js";
|
|
14
14
|
export { InvalidConfigError, MAX_STEP_FUNCTIONS_INPUT_BYTES, validateDistributedRenderConfig, validateStepFunctionsInputSize, validateVariablesPayload, } from "./validateConfig.js";
|
|
15
|
-
export { type PrebuiltHandler, prebuiltHandler } from "./prebuiltHandler.js";
|
|
15
|
+
export { type PrebuiltHandler, prebuiltHandler, prebuiltVideoImage, type ReleaseAssetManifest, } from "./prebuiltHandler.js";
|
|
16
|
+
export { ensureEcrRepository, type EnsureEcrRepositoryOptions, type PushedImage, pushImageToEcr, type PushImageToEcrOptions, } from "./pushImageToEcr.js";
|
|
16
17
|
export type { SerializableDistributedRenderConfig } from "../events.js";
|
|
17
18
|
export type { DistributedFormat } from "../formatExtension.js";
|
package/dist/sdk/index.js
CHANGED
|
@@ -10,8 +10,10 @@ import {
|
|
|
10
10
|
validateDistributedRenderConfig,
|
|
11
11
|
validateStepFunctionsInputSize,
|
|
12
12
|
validateVariablesPayload
|
|
13
|
-
} from "../_chunks/chunk-
|
|
14
|
-
import
|
|
13
|
+
} from "../_chunks/chunk-EG2WRNUL.js";
|
|
14
|
+
import {
|
|
15
|
+
sha256File
|
|
16
|
+
} from "../_chunks/chunk-CKQ6SAJX.js";
|
|
15
17
|
import "../_chunks/chunk-YS3KY3C5.js";
|
|
16
18
|
|
|
17
19
|
// src/sdk/prebuiltHandler.ts
|
|
@@ -20,6 +22,7 @@ import { dirname, join } from "node:path";
|
|
|
20
22
|
import { fileURLToPath } from "node:url";
|
|
21
23
|
var PREBUILT_DIR = "prebuilt";
|
|
22
24
|
var HANDLER_MANIFEST_FILE = "handler-zip.json";
|
|
25
|
+
var VIDEO_IMAGE_MANIFEST_FILE = "video-image.json";
|
|
23
26
|
function adapterPackageRoot() {
|
|
24
27
|
const from = fileURLToPath(import.meta.url);
|
|
25
28
|
for (let dir = dirname(from); dir !== dirname(dir); dir = dirname(dir)) {
|
|
@@ -29,12 +32,196 @@ function adapterPackageRoot() {
|
|
|
29
32
|
}
|
|
30
33
|
throw new Error(`[aws-lambda] no @hyperframes/aws-lambda package.json above ${from}`);
|
|
31
34
|
}
|
|
35
|
+
function readManifest(root, file) {
|
|
36
|
+
const path = join(root, PREBUILT_DIR, file);
|
|
37
|
+
if (!existsSync(path)) return void 0;
|
|
38
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
39
|
+
}
|
|
32
40
|
function prebuiltHandler(root = adapterPackageRoot()) {
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
return
|
|
41
|
+
const manifest = readManifest(root, HANDLER_MANIFEST_FILE);
|
|
42
|
+
return manifest && { ...manifest, templatePath: join(root, PREBUILT_DIR, "template.yaml") };
|
|
43
|
+
}
|
|
44
|
+
function prebuiltVideoImage(root = adapterPackageRoot()) {
|
|
45
|
+
return readManifest(root, VIDEO_IMAGE_MANIFEST_FILE);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// src/sdk/pushImageToEcr.ts
|
|
49
|
+
import { open, readFile, stat } from "node:fs/promises";
|
|
50
|
+
import { join as join2 } from "node:path";
|
|
51
|
+
import {
|
|
52
|
+
BatchCheckLayerAvailabilityCommand,
|
|
53
|
+
CompleteLayerUploadCommand,
|
|
54
|
+
CreateRepositoryCommand,
|
|
55
|
+
DescribeRepositoriesCommand,
|
|
56
|
+
ECRClient,
|
|
57
|
+
InitiateLayerUploadCommand,
|
|
58
|
+
ListTagsForResourceCommand,
|
|
59
|
+
PutImageCommand,
|
|
60
|
+
UploadLayerPartCommand
|
|
61
|
+
} from "@aws-sdk/client-ecr";
|
|
62
|
+
var MANIFEST_MEDIA_TYPES = /* @__PURE__ */ new Set([
|
|
63
|
+
"application/vnd.oci.image.manifest.v1+json",
|
|
64
|
+
"application/vnd.docker.distribution.manifest.v2+json"
|
|
65
|
+
]);
|
|
66
|
+
var CHECK_BATCH_SIZE = 100;
|
|
67
|
+
async function ensureEcrRepository(opts) {
|
|
68
|
+
const { repositoryName, tags } = opts;
|
|
69
|
+
const ecr = opts.ecr ?? new ECRClient({ region: opts.region });
|
|
70
|
+
try {
|
|
71
|
+
const created = await ecr.send(new CreateRepositoryCommand({ repositoryName, tags }));
|
|
72
|
+
return { repositoryUri: repositoryUriOf(created.repository, repositoryName) };
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (error.name !== "RepositoryAlreadyExistsException") throw error;
|
|
75
|
+
}
|
|
76
|
+
const found = await ecr.send(
|
|
77
|
+
new DescribeRepositoriesCommand({ repositoryNames: [repositoryName] })
|
|
78
|
+
);
|
|
79
|
+
const repository = found.repositories?.[0];
|
|
80
|
+
if (tags?.length) {
|
|
81
|
+
const listed = await ecr.send(
|
|
82
|
+
new ListTagsForResourceCommand({ resourceArn: repository?.repositoryArn })
|
|
83
|
+
);
|
|
84
|
+
const has = new Map((listed.tags ?? []).map((tag) => [tag.Key, tag.Value]));
|
|
85
|
+
const missing = tags.filter((tag) => has.get(tag.Key) !== tag.Value);
|
|
86
|
+
if (missing.length > 0) {
|
|
87
|
+
const wanted = missing.map((tag) => `${tag.Key}=${tag.Value}`).join(", ");
|
|
88
|
+
throw new Error(
|
|
89
|
+
`[ensureEcrRepository] ${repositoryName} already exists without ${wanted}; refusing to adopt a repository this caller did not create`
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { repositoryUri: repositoryUriOf(repository, repositoryName) };
|
|
94
|
+
}
|
|
95
|
+
function repositoryUriOf(repository, repositoryName) {
|
|
96
|
+
if (!repository?.repositoryUri) {
|
|
97
|
+
throw new Error(`[ensureEcrRepository] ECR returned no repositoryUri for ${repositoryName}`);
|
|
98
|
+
}
|
|
99
|
+
return repository.repositoryUri;
|
|
100
|
+
}
|
|
101
|
+
async function pushImageToEcr(opts) {
|
|
102
|
+
const { layoutDir, repositoryName, log = () => {
|
|
103
|
+
} } = opts;
|
|
104
|
+
const ecr = opts.ecr ?? new ECRClient({ region: opts.region });
|
|
105
|
+
const manifestDescriptor = await readManifestDescriptor(layoutDir);
|
|
106
|
+
await verifyBlob(layoutDir, manifestDescriptor);
|
|
107
|
+
const manifestText = await readFile(blobPath(layoutDir, manifestDescriptor.digest), "utf-8");
|
|
108
|
+
const manifest = JSON.parse(manifestText);
|
|
109
|
+
if (!manifest.config || !Array.isArray(manifest.layers)) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`[pushImageToEcr] manifest ${manifestDescriptor.digest} has no config or layers`
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
const blobs = [manifest.config, ...manifest.layers];
|
|
115
|
+
for (const blob of blobs) await verifyBlob(layoutDir, blob);
|
|
116
|
+
const config = JSON.parse(
|
|
117
|
+
await readFile(blobPath(layoutDir, manifest.config.digest), "utf-8")
|
|
118
|
+
);
|
|
119
|
+
if (!config.architecture || !config.os) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`[pushImageToEcr] image config ${manifest.config.digest} names no architecture/os; build the image for one platform`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
const missing = await unavailableDigests(ecr, repositoryName, blobs);
|
|
125
|
+
for (const blob of blobs) {
|
|
126
|
+
if (!missing.delete(blob.digest)) continue;
|
|
127
|
+
log(`uploading ${blob.digest} (${blob.size} bytes)`);
|
|
128
|
+
await uploadBlob(ecr, repositoryName, blobPath(layoutDir, blob.digest), blob);
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
await ecr.send(
|
|
132
|
+
new PutImageCommand({
|
|
133
|
+
repositoryName,
|
|
134
|
+
imageManifest: manifestText,
|
|
135
|
+
imageManifestMediaType: manifestDescriptor.mediaType,
|
|
136
|
+
imageTag: opts.tag,
|
|
137
|
+
imageDigest: manifestDescriptor.digest
|
|
138
|
+
})
|
|
139
|
+
);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (error.name !== "ImageAlreadyExistsException") throw error;
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
imageUri: `${opts.repositoryUri}@${manifestDescriptor.digest}`,
|
|
145
|
+
digest: manifestDescriptor.digest,
|
|
146
|
+
architecture: config.architecture,
|
|
147
|
+
os: config.os
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
async function readManifestDescriptor(layoutDir) {
|
|
151
|
+
const index = JSON.parse(await readFile(join2(layoutDir, "index.json"), "utf-8"));
|
|
152
|
+
const manifests = index.manifests ?? [];
|
|
153
|
+
const [only] = manifests;
|
|
154
|
+
if (manifests.length !== 1 || !only || !MANIFEST_MEDIA_TYPES.has(only.mediaType)) {
|
|
155
|
+
const found = manifests.map((m) => m.mediaType).join(", ") || "none";
|
|
156
|
+
throw new Error(
|
|
157
|
+
`[pushImageToEcr] ${layoutDir}/index.json must list exactly one single-platform image manifest; found ${manifests.length} (${found}). Build one platform with --provenance=false --sbom=false.`
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
return only;
|
|
161
|
+
}
|
|
162
|
+
function blobPath(layoutDir, digest) {
|
|
163
|
+
const hex = /^sha256:([a-f0-9]{64})$/.exec(digest)?.[1];
|
|
164
|
+
if (!hex) throw new Error(`[pushImageToEcr] unsupported blob digest ${JSON.stringify(digest)}`);
|
|
165
|
+
return join2(layoutDir, "blobs", "sha256", hex);
|
|
166
|
+
}
|
|
167
|
+
async function verifyBlob(layoutDir, blob) {
|
|
168
|
+
const path = blobPath(layoutDir, blob.digest);
|
|
169
|
+
const size = (await stat(path)).size;
|
|
170
|
+
if (size !== blob.size || `sha256:${await sha256File(path)}` !== blob.digest) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`[pushImageToEcr] blob ${blob.digest} (${size} bytes, expected ${blob.size}) does not match its descriptor; the image layout is corrupt`
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
async function unavailableDigests(ecr, repositoryName, blobs) {
|
|
177
|
+
const missing = new Set(blobs.map((blob) => blob.digest));
|
|
178
|
+
const digests = [...missing];
|
|
179
|
+
for (let i = 0; i < digests.length; i += CHECK_BATCH_SIZE) {
|
|
180
|
+
const { layers = [] } = await ecr.send(
|
|
181
|
+
new BatchCheckLayerAvailabilityCommand({
|
|
182
|
+
repositoryName,
|
|
183
|
+
layerDigests: digests.slice(i, i + CHECK_BATCH_SIZE)
|
|
184
|
+
})
|
|
185
|
+
);
|
|
186
|
+
for (const layer of layers) {
|
|
187
|
+
if (layer.layerAvailability === "AVAILABLE" && layer.layerDigest) {
|
|
188
|
+
missing.delete(layer.layerDigest);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return missing;
|
|
193
|
+
}
|
|
194
|
+
async function uploadBlob(ecr, repositoryName, path, blob) {
|
|
195
|
+
const { uploadId, partSize } = await ecr.send(new InitiateLayerUploadCommand({ repositoryName }));
|
|
196
|
+
if (!uploadId || !partSize) {
|
|
197
|
+
throw new Error(`[pushImageToEcr] InitiateLayerUpload returned no uploadId/partSize`);
|
|
198
|
+
}
|
|
199
|
+
const file = await open(path);
|
|
200
|
+
try {
|
|
201
|
+
for (let first = 0; first < blob.size; first += partSize) {
|
|
202
|
+
const part = Buffer.alloc(Math.min(partSize, blob.size - first));
|
|
203
|
+
const { bytesRead } = await file.read(part, 0, part.length, first);
|
|
204
|
+
if (bytesRead !== part.length) throw new Error(`[pushImageToEcr] ${path} changed on disk`);
|
|
205
|
+
await ecr.send(
|
|
206
|
+
new UploadLayerPartCommand({
|
|
207
|
+
repositoryName,
|
|
208
|
+
uploadId,
|
|
209
|
+
partFirstByte: first,
|
|
210
|
+
partLastByte: first + part.length - 1,
|
|
211
|
+
layerPartBlob: part
|
|
212
|
+
})
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
} finally {
|
|
216
|
+
await file.close();
|
|
217
|
+
}
|
|
218
|
+
try {
|
|
219
|
+
await ecr.send(
|
|
220
|
+
new CompleteLayerUploadCommand({ repositoryName, uploadId, layerDigests: [blob.digest] })
|
|
221
|
+
);
|
|
222
|
+
} catch (error) {
|
|
223
|
+
if (error.name !== "LayerAlreadyExistsException") throw error;
|
|
224
|
+
}
|
|
38
225
|
}
|
|
39
226
|
export {
|
|
40
227
|
InvalidConfigError,
|
|
@@ -42,8 +229,11 @@ export {
|
|
|
42
229
|
assertStackReadsSites,
|
|
43
230
|
computeRenderCost,
|
|
44
231
|
deploySite,
|
|
232
|
+
ensureEcrRepository,
|
|
45
233
|
getRenderProgress,
|
|
46
234
|
prebuiltHandler,
|
|
235
|
+
prebuiltVideoImage,
|
|
236
|
+
pushImageToEcr,
|
|
47
237
|
renderToLambda,
|
|
48
238
|
siteHandleFromId,
|
|
49
239
|
validateDistributedRenderConfig,
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
export declare const HANDLER_ZIP_ASSET = "hyperframes-lambda-handler.zip";
|
|
2
2
|
export declare const PREBUILT_DIR = "prebuilt";
|
|
3
3
|
export declare const HANDLER_MANIFEST_FILE = "handler-zip.json";
|
|
4
|
-
export
|
|
4
|
+
export declare const VIDEO_IMAGE_ASSET = "hyperframes-lambda-video-arm64.oci.tar";
|
|
5
|
+
export declare const VIDEO_IMAGE_MANIFEST_FILE = "video-image.json";
|
|
6
|
+
export interface ReleaseAssetManifest {
|
|
5
7
|
version: string;
|
|
6
8
|
url: string;
|
|
7
9
|
sha256: string;
|
|
8
10
|
bytes: number;
|
|
9
11
|
}
|
|
10
|
-
export interface PrebuiltHandler extends
|
|
12
|
+
export interface PrebuiltHandler extends ReleaseAssetManifest {
|
|
11
13
|
/** SAM template whose `CodeUri` is `handler.zip` in the same directory. */
|
|
12
14
|
templatePath: string;
|
|
13
15
|
}
|
|
@@ -15,3 +17,5 @@ export interface PrebuiltHandler extends HandlerZipManifest {
|
|
|
15
17
|
export declare function adapterPackageRoot(): string;
|
|
16
18
|
/** The pinned handler for this install, or undefined when this build carries none. */
|
|
17
19
|
export declare function prebuiltHandler(root?: string): PrebuiltHandler | undefined;
|
|
20
|
+
/** The pinned arm64 video function image (an OCI layout tarball), or undefined when none is pinned. */
|
|
21
|
+
export declare function prebuiltVideoImage(root?: string): ReleaseAssetManifest | undefined;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Push a prebuilt single-platform OCI image layout into ECR through the ECR API alone,
|
|
3
|
+
* so deploying the container-image function needs neither Docker nor registry HTTP calls.
|
|
4
|
+
*/
|
|
5
|
+
import { ECRClient, type Tag } from "@aws-sdk/client-ecr";
|
|
6
|
+
/** Options for {@link ensureEcrRepository}. */
|
|
7
|
+
export interface EnsureEcrRepositoryOptions {
|
|
8
|
+
ecr?: ECRClient;
|
|
9
|
+
region?: string;
|
|
10
|
+
repositoryName: string;
|
|
11
|
+
/** Set on create. An existing repository is adopted only if it already carries every one. */
|
|
12
|
+
tags?: Tag[];
|
|
13
|
+
}
|
|
14
|
+
/** Create the repository, or adopt an existing one that carries `tags`. */
|
|
15
|
+
export declare function ensureEcrRepository(opts: EnsureEcrRepositoryOptions): Promise<{
|
|
16
|
+
repositoryUri: string;
|
|
17
|
+
}>;
|
|
18
|
+
/** Options for {@link pushImageToEcr}. */
|
|
19
|
+
export interface PushImageToEcrOptions {
|
|
20
|
+
ecr?: ECRClient;
|
|
21
|
+
region?: string;
|
|
22
|
+
/** OCI image layout directory: `index.json` plus `blobs/sha256/<hex>`. */
|
|
23
|
+
layoutDir: string;
|
|
24
|
+
repositoryName: string;
|
|
25
|
+
/** From {@link ensureEcrRepository}. */
|
|
26
|
+
repositoryUri: string;
|
|
27
|
+
tag: string;
|
|
28
|
+
log?: (message: string) => void;
|
|
29
|
+
}
|
|
30
|
+
/** Result of {@link pushImageToEcr}. */
|
|
31
|
+
export interface PushedImage {
|
|
32
|
+
/** `<repositoryUri>@sha256:<manifest digest>`, pinned to exactly the pushed bytes. */
|
|
33
|
+
imageUri: string;
|
|
34
|
+
digest: string;
|
|
35
|
+
/** From the image config, so callers can assert the platform they expect. */
|
|
36
|
+
architecture: string;
|
|
37
|
+
os: string;
|
|
38
|
+
}
|
|
39
|
+
/** Upload the layout's missing blobs, then put its manifest under `tag`. Safe to re-run. */
|
|
40
|
+
export declare function pushImageToEcr(opts: PushImageToEcrOptions): Promise<PushedImage>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/aws-lambda",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.189",
|
|
4
4
|
"description": "AWS Lambda adapter for HyperFrames distributed rendering — handler, client-side SDK, and CDK construct.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
"prebuilt/",
|
|
13
13
|
"scripts/",
|
|
14
14
|
"!scripts/*.test.ts",
|
|
15
|
+
"!scripts/pin-handler-zip.ts",
|
|
16
|
+
"!scripts/stage-arm64-image.ts",
|
|
15
17
|
"README.md"
|
|
16
18
|
],
|
|
17
19
|
"type": "module",
|
|
@@ -40,6 +42,7 @@
|
|
|
40
42
|
"registry": "https://registry.npmjs.org/"
|
|
41
43
|
},
|
|
42
44
|
"dependencies": {
|
|
45
|
+
"@aws-sdk/client-ecr": "^3.1146.0",
|
|
43
46
|
"@aws-sdk/client-lambda": "^3.1146.0",
|
|
44
47
|
"@aws-sdk/client-s3": "^3.1146.0",
|
|
45
48
|
"@aws-sdk/client-sfn": "^3.1146.0",
|
|
@@ -49,7 +52,7 @@
|
|
|
49
52
|
"ffprobe-static": "^3.1.0",
|
|
50
53
|
"puppeteer-core": "^25.8.0",
|
|
51
54
|
"tar": "^7.4.3",
|
|
52
|
-
"@hyperframes/producer": "^0.8.
|
|
55
|
+
"@hyperframes/producer": "^0.8.189"
|
|
53
56
|
},
|
|
54
57
|
"devDependencies": {
|
|
55
58
|
"@types/aws-lambda": "^8.10.146",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.8.
|
|
3
|
-
"url": "https://github.com/heygen-com/hyperframes/releases/download/v0.8.
|
|
4
|
-
"sha256": "
|
|
5
|
-
"bytes":
|
|
2
|
+
"version": "0.8.189",
|
|
3
|
+
"url": "https://github.com/heygen-com/hyperframes/releases/download/v0.8.189/hyperframes-lambda-handler.zip",
|
|
4
|
+
"sha256": "95078b7e47160ad95141a051ae8bf772909229c3d626eea59061ddcd5cfb764f",
|
|
5
|
+
"bytes": 129250008
|
|
6
6
|
}
|
package/prebuilt/template.yaml
CHANGED
|
@@ -3,7 +3,7 @@ Transform: AWS::Serverless-2016-10-31
|
|
|
3
3
|
Description: >-
|
|
4
4
|
HyperFrames distributed rendering — Step Functions standard workflow with
|
|
5
5
|
one Lambda function handling Plan, RenderChunk (fan-out via Map state),
|
|
6
|
-
and Assemble. One S3 bucket, alarms for runaway concurrency, Lambda
|
|
6
|
+
and Assemble, or Plan and RenderChunk on an optional arm64 image. One S3 bucket, alarms for runaway concurrency, Lambda
|
|
7
7
|
errors, and Step Functions execution failures.
|
|
8
8
|
|
|
9
9
|
Built from the handler ZIP at packages/aws-lambda/dist/handler.zip.
|
|
@@ -64,29 +64,29 @@ Parameters:
|
|
|
64
64
|
chunks than expected; an alarm at 10× the typical workload
|
|
65
65
|
protects against billing surprises.
|
|
66
66
|
|
|
67
|
+
VideoImageUri:
|
|
68
|
+
Type: String
|
|
69
|
+
Default: ""
|
|
70
|
+
Description: >-
|
|
71
|
+
Optional arm64 image (`<ECR repository URI>@sha256:<digest>`) that runs the
|
|
72
|
+
plan and renders the chunks. Audio and assemble stay on the x86 render
|
|
73
|
+
function. Empty: one x86 function does it all.
|
|
74
|
+
|
|
67
75
|
Conditions:
|
|
68
76
|
HasReservedConcurrency: !Not [!Equals [!Ref ReservedConcurrency, -1]]
|
|
77
|
+
HasVideoFunction: !Not [!Equals [!Ref VideoImageUri, ""]]
|
|
69
78
|
|
|
70
79
|
Globals:
|
|
71
80
|
Function:
|
|
72
|
-
Runtime: nodejs22.x
|
|
73
81
|
MemorySize: !Ref LambdaMemoryMb
|
|
74
82
|
Timeout: !Ref LambdaTimeoutSec
|
|
75
|
-
# x86_64 is required for @sparticuz/chromium — its prebuilt
|
|
76
|
-
# Chromium ships x86_64-only. Adopters who switch to a custom
|
|
77
|
-
# ARM-built chrome-headless-shell can change this to `arm64`, but
|
|
78
|
-
# the default ZIP build will fail to launch on Graviton.
|
|
79
|
-
Architectures: [x86_64]
|
|
80
83
|
# Lambda function-level X-Ray tracing. The state machine already
|
|
81
84
|
# has Tracing.Enabled: true; without this, X-Ray traces would
|
|
82
85
|
# terminate at the Step Functions → Lambda boundary instead of
|
|
83
86
|
# following into per-function spans.
|
|
84
87
|
Tracing: Active
|
|
85
|
-
# Cost-allocation tags
|
|
86
|
-
#
|
|
87
|
-
# only one today, but the contract is portable to multi-function
|
|
88
|
-
# variants. Bucket + state-machine carry the same tags resource-
|
|
89
|
-
# locally because Globals only covers functions.
|
|
88
|
+
# Cost-allocation tags for the render function. Bucket, state machine and
|
|
89
|
+
# video-function.yaml carry the same tags themselves; Globals covers only SAM functions.
|
|
90
90
|
Tags:
|
|
91
91
|
Project: !Ref ProjectName
|
|
92
92
|
HyperFramesComponent: lambda-renderer
|
|
@@ -149,14 +149,18 @@ Resources:
|
|
|
149
149
|
Description: >-
|
|
150
150
|
HyperFrames distributed render handler. Dispatches on event.Action.
|
|
151
151
|
Handler: handler.handler
|
|
152
|
+
Runtime: nodejs22.x
|
|
153
|
+
# The ZIP's @sparticuz/chromium ships x86_64 Chrome only; video-function.yaml runs chunks on arm64.
|
|
154
|
+
Architectures: [x86_64]
|
|
152
155
|
# Local path is resolved by `sam build` + `sam deploy --resolve-s3`
|
|
153
156
|
# (or `--s3-bucket`); the resulting CodeUri rewrites to s3://.
|
|
154
157
|
CodeUri: handler.zip
|
|
155
158
|
PackageType: Zip
|
|
159
|
+
# With a video function the chunks run there, so the reservation moves with them.
|
|
156
160
|
ReservedConcurrentExecutions: !If
|
|
157
|
-
-
|
|
158
|
-
- !Ref ReservedConcurrency
|
|
161
|
+
- HasVideoFunction
|
|
159
162
|
- !Ref AWS::NoValue
|
|
163
|
+
- !If [HasReservedConcurrency, !Ref ReservedConcurrency, !Ref AWS::NoValue]
|
|
160
164
|
EphemeralStorage:
|
|
161
165
|
Size: 10240
|
|
162
166
|
Environment:
|
|
@@ -184,6 +188,27 @@ Resources:
|
|
|
184
188
|
# CreateExportTask). Reference templates shouldn't leak overbroad
|
|
185
189
|
# IAM into adopters' accounts.
|
|
186
190
|
|
|
191
|
+
# ── Optional arm64 chunk renderer: the same handler as a container image ──
|
|
192
|
+
# Nested: `sam deploy` demands an image repository for any image function in this template.
|
|
193
|
+
VideoFunctionStack:
|
|
194
|
+
Type: AWS::CloudFormation::Stack
|
|
195
|
+
Condition: HasVideoFunction
|
|
196
|
+
Properties:
|
|
197
|
+
TemplateURL: video-function.yaml
|
|
198
|
+
Parameters:
|
|
199
|
+
ProjectName: !Ref ProjectName
|
|
200
|
+
ImageUri: !Ref VideoImageUri
|
|
201
|
+
RenderBucket: !Ref RenderBucket
|
|
202
|
+
MemorySize: !Ref LambdaMemoryMb
|
|
203
|
+
Timeout: !Ref LambdaTimeoutSec
|
|
204
|
+
ReservedConcurrency: !Ref ReservedConcurrency
|
|
205
|
+
ChromeSource: !Ref ChromeSource
|
|
206
|
+
Tags:
|
|
207
|
+
- Key: Project
|
|
208
|
+
Value: !Ref ProjectName
|
|
209
|
+
- Key: HyperFramesComponent
|
|
210
|
+
Value: lambda-renderer
|
|
211
|
+
|
|
187
212
|
# ── CloudWatch log group for the state machine ──────────────────────────
|
|
188
213
|
# SAM doesn't auto-create one when `LoggingConfiguration` is set, so we
|
|
189
214
|
# define it explicitly — that way the IAM grant on the state-machine
|
|
@@ -249,7 +274,13 @@ Resources:
|
|
|
249
274
|
Type: Task
|
|
250
275
|
Resource: arn:aws:states:::lambda:invoke
|
|
251
276
|
Parameters:
|
|
252
|
-
|
|
277
|
+
# Chunks must run the ffmpeg build the plan records, so the plan runs where they do.
|
|
278
|
+
FunctionName:
|
|
279
|
+
!If [
|
|
280
|
+
HasVideoFunction,
|
|
281
|
+
!GetAtt VideoFunctionStack.Outputs.FunctionArn,
|
|
282
|
+
!GetAtt RenderFunction.Arn,
|
|
283
|
+
]
|
|
253
284
|
Payload:
|
|
254
285
|
Action: plan
|
|
255
286
|
PlanProtocol: v1
|
|
@@ -308,7 +339,12 @@ Resources:
|
|
|
308
339
|
Type: Task
|
|
309
340
|
Resource: arn:aws:states:::lambda:invoke
|
|
310
341
|
Parameters:
|
|
311
|
-
FunctionName:
|
|
342
|
+
FunctionName:
|
|
343
|
+
!If [
|
|
344
|
+
HasVideoFunction,
|
|
345
|
+
!GetAtt VideoFunctionStack.Outputs.FunctionArn,
|
|
346
|
+
!GetAtt RenderFunction.Arn,
|
|
347
|
+
]
|
|
312
348
|
Payload:
|
|
313
349
|
Action: plan
|
|
314
350
|
PlanProtocol: v2
|
|
@@ -424,7 +460,12 @@ Resources:
|
|
|
424
460
|
Type: Task
|
|
425
461
|
Resource: arn:aws:states:::lambda:invoke
|
|
426
462
|
Parameters:
|
|
427
|
-
FunctionName:
|
|
463
|
+
FunctionName:
|
|
464
|
+
!If [
|
|
465
|
+
HasVideoFunction,
|
|
466
|
+
!GetAtt VideoFunctionStack.Outputs.FunctionArn,
|
|
467
|
+
!GetAtt RenderFunction.Arn,
|
|
468
|
+
]
|
|
428
469
|
Payload:
|
|
429
470
|
Action: renderChunk
|
|
430
471
|
PlanProtocol: v1
|
|
@@ -542,7 +583,12 @@ Resources:
|
|
|
542
583
|
Type: Task
|
|
543
584
|
Resource: arn:aws:states:::lambda:invoke
|
|
544
585
|
Parameters:
|
|
545
|
-
FunctionName:
|
|
586
|
+
FunctionName:
|
|
587
|
+
!If [
|
|
588
|
+
HasVideoFunction,
|
|
589
|
+
!GetAtt VideoFunctionStack.Outputs.FunctionArn,
|
|
590
|
+
!GetAtt RenderFunction.Arn,
|
|
591
|
+
]
|
|
546
592
|
Payload:
|
|
547
593
|
Action: renderChunk
|
|
548
594
|
PlanProtocol: v2
|
|
@@ -725,7 +771,13 @@ Resources:
|
|
|
725
771
|
Statement:
|
|
726
772
|
- Effect: Allow
|
|
727
773
|
Action: lambda:InvokeFunction
|
|
728
|
-
Resource:
|
|
774
|
+
Resource:
|
|
775
|
+
- !GetAtt RenderFunction.Arn
|
|
776
|
+
- !If [
|
|
777
|
+
HasVideoFunction,
|
|
778
|
+
!GetAtt VideoFunctionStack.Outputs.FunctionArn,
|
|
779
|
+
!Ref AWS::NoValue,
|
|
780
|
+
]
|
|
729
781
|
# A Distributed Map runs each chunk as a child execution of this state machine.
|
|
730
782
|
# ARNs built by name: !Ref RenderStateMachine here would be a dependency cycle.
|
|
731
783
|
- PolicyName: RunDistributedMapChildren
|
|
@@ -780,7 +832,12 @@ Resources:
|
|
|
780
832
|
MetricName: Invocations
|
|
781
833
|
Dimensions:
|
|
782
834
|
- Name: FunctionName
|
|
783
|
-
Value:
|
|
835
|
+
Value:
|
|
836
|
+
!If [
|
|
837
|
+
HasVideoFunction,
|
|
838
|
+
!GetAtt VideoFunctionStack.Outputs.FunctionName,
|
|
839
|
+
!Ref RenderFunction,
|
|
840
|
+
]
|
|
784
841
|
Statistic: Sum
|
|
785
842
|
Period: 3600
|
|
786
843
|
EvaluationPeriods: 1
|
|
@@ -813,6 +870,25 @@ Resources:
|
|
|
813
870
|
ComparisonOperator: GreaterThanOrEqualToThreshold
|
|
814
871
|
TreatMissingData: notBreaching
|
|
815
872
|
|
|
873
|
+
VideoFunctionErrorsAlarm:
|
|
874
|
+
Type: AWS::CloudWatch::Alarm
|
|
875
|
+
Condition: HasVideoFunction
|
|
876
|
+
Properties:
|
|
877
|
+
AlarmName: !Sub "${ProjectName}-video-function-errors"
|
|
878
|
+
AlarmDescription: >-
|
|
879
|
+
Fires if the arm64 chunk Lambda reports any errors in a 5-minute window.
|
|
880
|
+
Namespace: AWS/Lambda
|
|
881
|
+
MetricName: Errors
|
|
882
|
+
Dimensions:
|
|
883
|
+
- Name: FunctionName
|
|
884
|
+
Value: !GetAtt VideoFunctionStack.Outputs.FunctionName
|
|
885
|
+
Statistic: Sum
|
|
886
|
+
Period: 300
|
|
887
|
+
EvaluationPeriods: 1
|
|
888
|
+
Threshold: 1
|
|
889
|
+
ComparisonOperator: GreaterThanOrEqualToThreshold
|
|
890
|
+
TreatMissingData: notBreaching
|
|
891
|
+
|
|
816
892
|
# ── CloudWatch alarm: Step Functions execution failures ─────────────────
|
|
817
893
|
# Fires when a state-machine execution reaches a terminal failure
|
|
818
894
|
# state (typed non-retryable, retry-exhausted, or top-level timeout).
|
|
@@ -853,6 +929,11 @@ Outputs:
|
|
|
853
929
|
Export:
|
|
854
930
|
Name: !Sub "${AWS::StackName}-RenderFunctionArn"
|
|
855
931
|
|
|
932
|
+
VideoFunctionArn:
|
|
933
|
+
Condition: HasVideoFunction
|
|
934
|
+
Description: ARN of the arm64 chunk-rendering function.
|
|
935
|
+
Value: !GetAtt VideoFunctionStack.Outputs.FunctionArn
|
|
936
|
+
|
|
856
937
|
RenderStateMachineArn:
|
|
857
938
|
Description: ARN of the Step Functions state machine. Pass to `aws stepfunctions start-execution`.
|
|
858
939
|
Value: !Ref RenderStateMachine
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
AWSTemplateFormatVersion: "2010-09-09"
|
|
2
|
+
Description: >-
|
|
3
|
+
HyperFrames arm64 video renderer: the render handler as a container image, invoked for plan and
|
|
4
|
+
renderChunk. Nested in template.yaml when VideoImageUri is set; plain CloudFormation, because `sam deploy`
|
|
5
|
+
demands an image repository for any top-level image function, even one whose Condition is false.
|
|
6
|
+
|
|
7
|
+
Parameters:
|
|
8
|
+
ProjectName:
|
|
9
|
+
Type: String
|
|
10
|
+
ImageUri:
|
|
11
|
+
Type: String
|
|
12
|
+
RenderBucket:
|
|
13
|
+
Type: String
|
|
14
|
+
MemorySize:
|
|
15
|
+
Type: Number
|
|
16
|
+
Timeout:
|
|
17
|
+
Type: Number
|
|
18
|
+
ReservedConcurrency:
|
|
19
|
+
Type: Number
|
|
20
|
+
ChromeSource:
|
|
21
|
+
Type: String
|
|
22
|
+
|
|
23
|
+
Conditions:
|
|
24
|
+
HasReservedConcurrency: !Not [!Equals [!Ref ReservedConcurrency, -1]]
|
|
25
|
+
|
|
26
|
+
Resources:
|
|
27
|
+
Role:
|
|
28
|
+
Type: AWS::IAM::Role
|
|
29
|
+
Properties:
|
|
30
|
+
AssumeRolePolicyDocument:
|
|
31
|
+
Version: "2012-10-17"
|
|
32
|
+
Statement:
|
|
33
|
+
- Effect: Allow
|
|
34
|
+
Principal:
|
|
35
|
+
Service: lambda.amazonaws.com
|
|
36
|
+
Action: sts:AssumeRole
|
|
37
|
+
ManagedPolicyArns:
|
|
38
|
+
- !Sub "arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
|
|
39
|
+
- !Sub "arn:${AWS::Partition}:iam::aws:policy/AWSXRayDaemonWriteAccess"
|
|
40
|
+
# The render function's S3CrudPolicy plus AbortMultipartUpload, spelled out.
|
|
41
|
+
Policies:
|
|
42
|
+
- PolicyName: render-bucket
|
|
43
|
+
PolicyDocument:
|
|
44
|
+
Version: "2012-10-17"
|
|
45
|
+
Statement:
|
|
46
|
+
- Effect: Allow
|
|
47
|
+
Action:
|
|
48
|
+
- s3:GetObject
|
|
49
|
+
- s3:ListBucket
|
|
50
|
+
- s3:GetBucketLocation
|
|
51
|
+
- s3:GetObjectVersion
|
|
52
|
+
- s3:PutObject
|
|
53
|
+
- s3:PutObjectAcl
|
|
54
|
+
- s3:GetLifecycleConfiguration
|
|
55
|
+
- s3:PutLifecycleConfiguration
|
|
56
|
+
- s3:DeleteObject
|
|
57
|
+
- s3:AbortMultipartUpload
|
|
58
|
+
Resource:
|
|
59
|
+
- !Sub "arn:${AWS::Partition}:s3:::${RenderBucket}"
|
|
60
|
+
- !Sub "arn:${AWS::Partition}:s3:::${RenderBucket}/*"
|
|
61
|
+
# The plan sizes an unreserved render to the account's concurrency.
|
|
62
|
+
- Effect: Allow
|
|
63
|
+
Action: lambda:GetAccountSettings
|
|
64
|
+
Resource: "*"
|
|
65
|
+
Tags:
|
|
66
|
+
- Key: Project
|
|
67
|
+
Value: !Ref ProjectName
|
|
68
|
+
- Key: HyperFramesComponent
|
|
69
|
+
Value: lambda-renderer
|
|
70
|
+
|
|
71
|
+
Function:
|
|
72
|
+
Type: AWS::Lambda::Function
|
|
73
|
+
Properties:
|
|
74
|
+
FunctionName: !Sub "${ProjectName}-video"
|
|
75
|
+
Description: HyperFrames distributed render handler on arm64, invoked for plan and renderChunk.
|
|
76
|
+
PackageType: Image
|
|
77
|
+
Code:
|
|
78
|
+
ImageUri: !Ref ImageUri
|
|
79
|
+
Architectures: [arm64]
|
|
80
|
+
Role: !GetAtt Role.Arn
|
|
81
|
+
MemorySize: !Ref MemorySize
|
|
82
|
+
Timeout: !Ref Timeout
|
|
83
|
+
ReservedConcurrentExecutions: !If
|
|
84
|
+
- HasReservedConcurrency
|
|
85
|
+
- !Ref ReservedConcurrency
|
|
86
|
+
- !Ref AWS::NoValue
|
|
87
|
+
EphemeralStorage:
|
|
88
|
+
Size: 10240
|
|
89
|
+
TracingConfig:
|
|
90
|
+
Mode: Active
|
|
91
|
+
Environment:
|
|
92
|
+
Variables:
|
|
93
|
+
NODE_OPTIONS: "--enable-source-maps"
|
|
94
|
+
HYPERFRAMES_LAMBDA_CHROME_SOURCE: !Ref ChromeSource
|
|
95
|
+
TMPDIR: /tmp
|
|
96
|
+
HYPERFRAMES_RENDER_BUCKET: !Ref RenderBucket
|
|
97
|
+
HYPERFRAMES_LAMBDA_RESERVED_CONCURRENCY: !Ref ReservedConcurrency
|
|
98
|
+
Tags:
|
|
99
|
+
- Key: Project
|
|
100
|
+
Value: !Ref ProjectName
|
|
101
|
+
- Key: HyperFramesComponent
|
|
102
|
+
Value: lambda-renderer
|
|
103
|
+
|
|
104
|
+
Outputs:
|
|
105
|
+
FunctionArn:
|
|
106
|
+
Value: !GetAtt Function.Arn
|
|
107
|
+
FunctionName:
|
|
108
|
+
Value: !Ref Function
|
|
@@ -1,11 +1,15 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
|
|
4
4
|
const CHECKOUT_CODE_URI = "CodeUri: ../../packages/aws-lambda/dist/handler.zip";
|
|
5
5
|
|
|
6
|
-
/**
|
|
6
|
+
/**
|
|
7
|
+
* Writes `<outDir>/template.yaml`, the example SAM template pointed at a `handler.zip` beside it, and
|
|
8
|
+
* the `video-function.yaml` it nests.
|
|
9
|
+
*/
|
|
7
10
|
export function writePrebuiltTemplate(repoRoot, outDir) {
|
|
8
|
-
const
|
|
11
|
+
const examples = join(repoRoot, "examples", "aws-lambda");
|
|
12
|
+
const template = readFileSync(join(examples, "template.yaml"), "utf-8");
|
|
9
13
|
if (template.split(CHECKOUT_CODE_URI).length !== 2) {
|
|
10
14
|
throw new Error(
|
|
11
15
|
`[prebuilt-template] expected exactly one "${CHECKOUT_CODE_URI}" in the template`,
|
|
@@ -16,4 +20,5 @@ export function writePrebuiltTemplate(repoRoot, outDir) {
|
|
|
16
20
|
join(outDir, "template.yaml"),
|
|
17
21
|
template.replace(CHECKOUT_CODE_URI, "CodeUri: handler.zip"),
|
|
18
22
|
);
|
|
23
|
+
copyFileSync(join(examples, "video-function.yaml"), join(outDir, "video-function.yaml"));
|
|
19
24
|
}
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bun
|
|
2
|
-
// Release step: writes the handler ZIP's pin (asset URL + sha256) and the SAM template into the
|
|
3
|
-
// package. A retried release run reuses the ZIP already on the release.
|
|
4
|
-
import { createHash } from "node:crypto";
|
|
5
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
-
import { dirname, join, resolve } from "node:path";
|
|
7
|
-
import { fileURLToPath } from "node:url";
|
|
8
|
-
import {
|
|
9
|
-
HANDLER_MANIFEST_FILE,
|
|
10
|
-
HANDLER_ZIP_ASSET,
|
|
11
|
-
type HandlerZipManifest,
|
|
12
|
-
PREBUILT_DIR,
|
|
13
|
-
} from "../src/sdk/prebuiltHandler.js";
|
|
14
|
-
import { writePrebuiltTemplate } from "./prebuilt-template.mjs";
|
|
15
|
-
|
|
16
|
-
export function pinHandlerZip(repoRoot: string): HandlerZipManifest {
|
|
17
|
-
const pkgDir = join(repoRoot, "packages", "aws-lambda");
|
|
18
|
-
const pkg = JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf-8")) as {
|
|
19
|
-
version: string;
|
|
20
|
-
repository: { url: string };
|
|
21
|
-
};
|
|
22
|
-
const asset = join(repoRoot, "dist", HANDLER_ZIP_ASSET);
|
|
23
|
-
const built = join(pkgDir, "dist", "handler.zip");
|
|
24
|
-
if (existsSync(built)) {
|
|
25
|
-
mkdirSync(dirname(asset), { recursive: true });
|
|
26
|
-
renameSync(built, asset);
|
|
27
|
-
rmSync(`${built}.manifest.json`, { force: true });
|
|
28
|
-
}
|
|
29
|
-
const bytes = readFileSync(asset);
|
|
30
|
-
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
31
|
-
writeFileSync(asset.replace(/\.zip$/, ".sha256"), `${sha256} ${HANDLER_ZIP_ASSET}\n`);
|
|
32
|
-
|
|
33
|
-
const outDir = join(pkgDir, PREBUILT_DIR);
|
|
34
|
-
writePrebuiltTemplate(repoRoot, outDir);
|
|
35
|
-
const manifest: HandlerZipManifest = {
|
|
36
|
-
version: pkg.version,
|
|
37
|
-
url: `${pkg.repository.url}/releases/download/v${pkg.version}/${HANDLER_ZIP_ASSET}`,
|
|
38
|
-
sha256,
|
|
39
|
-
bytes: bytes.length,
|
|
40
|
-
};
|
|
41
|
-
writeFileSync(join(outDir, HANDLER_MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
42
|
-
return manifest;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
if (import.meta.main) {
|
|
46
|
-
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
|
|
47
|
-
console.log(JSON.stringify(pinHandlerZip(repoRoot), null, 2));
|
|
48
|
-
}
|