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