@hyperframes/gcp-cloud-run 0.7.70 → 0.7.72

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/index.js CHANGED
@@ -8,7 +8,11 @@ import { Storage } from "@google-cloud/storage";
8
8
  import { Hono } from "hono";
9
9
  import {
10
10
  assemble,
11
+ listPlanV2ArtifactsForTarget,
12
+ materializePlanV2Target,
11
13
  plan,
14
+ planV2,
15
+ readPlanV2Manifest,
12
16
  renderChunk
13
17
  } from "@hyperframes/producer/distributed";
14
18
 
@@ -74,7 +78,15 @@ function formatExtension(format) {
74
78
  }
75
79
 
76
80
  // src/gcsTransport.ts
77
- import { createWriteStream, existsSync as existsSync2, mkdirSync, rmSync, statSync } from "node:fs";
81
+ import {
82
+ createReadStream,
83
+ createWriteStream,
84
+ existsSync as existsSync2,
85
+ mkdirSync,
86
+ rmSync,
87
+ statSync
88
+ } from "node:fs";
89
+ import { createHash } from "node:crypto";
78
90
  import { dirname } from "node:path";
79
91
  import { pipeline } from "node:stream/promises";
80
92
  import * as tar from "tar";
@@ -103,6 +115,19 @@ async function downloadGcsObjectToFile(storage, uri, destPath) {
103
115
  const file = storage.bucket(bucket).file(key);
104
116
  await pipeline(file.createReadStream(), createWriteStream(destPath));
105
117
  }
118
+ async function downloadGcsObjectToFileVerified(storage, uri, destPath, expectedSha256) {
119
+ assertSha256(expectedSha256);
120
+ await downloadGcsObjectToFile(storage, uri, destPath);
121
+ const actual = await sha256File(destPath);
122
+ if (actual !== expectedSha256) {
123
+ rmSync(destPath, { force: true });
124
+ const error = new Error(
125
+ `[gcsTransport] PLAN_ARTIFACT_DIGEST_MISMATCH: ${uri} expected ${expectedSha256}, got ${actual}`
126
+ );
127
+ error.name = "PLAN_ARTIFACT_DIGEST_MISMATCH";
128
+ throw error;
129
+ }
130
+ }
106
131
  async function uploadFileToGcs(storage, localPath, uri, contentType) {
107
132
  if (!existsSync2(localPath)) {
108
133
  throw new Error(`[gcsTransport] upload source missing: ${localPath}`);
@@ -117,6 +142,73 @@ async function uploadFileToGcs(storage, localPath, uri, contentType) {
117
142
  contentType
118
143
  });
119
144
  }
145
+ async function uploadContentAddressedFileToGcs(storage, localPath, uri, expectedSha256, contentType) {
146
+ assertSha256(expectedSha256);
147
+ if (!existsSync2(localPath)) {
148
+ throw new Error(`[gcsTransport] upload source missing: ${localPath}`);
149
+ }
150
+ const actualSha256 = await sha256File(localPath);
151
+ if (actualSha256 !== expectedSha256) {
152
+ throwDigestMismatch(
153
+ `local artifact ${localPath} expected ${expectedSha256}, got ${actualSha256}`
154
+ );
155
+ }
156
+ const { bucket, key } = parseGcsUri(uri);
157
+ const bucketHandle = storage.bucket(bucket);
158
+ const file = bucketHandle.file(key);
159
+ const size = statSync(localPath).size;
160
+ if (await isReusableContentAddressedObject(file, uri, size, expectedSha256)) {
161
+ return "reused";
162
+ }
163
+ try {
164
+ await bucketHandle.upload(localPath, {
165
+ destination: key,
166
+ contentType,
167
+ metadata: { metadata: { sha256: expectedSha256 } },
168
+ preconditionOpts: { ifGenerationMatch: 0 }
169
+ });
170
+ return "uploaded";
171
+ } catch (error) {
172
+ if (isGcsPreconditionFailed(error) && await isReusableContentAddressedObject(file, uri, size, expectedSha256)) {
173
+ return "reused";
174
+ }
175
+ throw error;
176
+ }
177
+ }
178
+ async function isReusableContentAddressedObject(file, uri, expectedSize, expectedSha256) {
179
+ const [exists] = await file.exists();
180
+ if (!exists) return false;
181
+ const [metadata] = await file.getMetadata();
182
+ if (Number(metadata.size) === expectedSize && metadata.metadata?.sha256 === expectedSha256) {
183
+ return true;
184
+ }
185
+ throwDigestMismatch(
186
+ `immutable object ${uri} already exists with different digest metadata or size`
187
+ );
188
+ }
189
+ async function sha256File(path) {
190
+ const hash = createHash("sha256");
191
+ for await (const chunk of createReadStream(path)) {
192
+ hash.update(chunk);
193
+ }
194
+ return hash.digest("hex");
195
+ }
196
+ function assertSha256(value) {
197
+ if (!/^[a-f0-9]{64}$/.test(value)) {
198
+ throw new Error(
199
+ `[gcsTransport] expected lowercase SHA-256 digest, got ${JSON.stringify(value)}`
200
+ );
201
+ }
202
+ }
203
+ function throwDigestMismatch(detail) {
204
+ const error = new Error(`[gcsTransport] PLAN_ARTIFACT_DIGEST_MISMATCH: ${detail}`);
205
+ error.name = "PLAN_ARTIFACT_DIGEST_MISMATCH";
206
+ throw error;
207
+ }
208
+ function isGcsPreconditionFailed(error) {
209
+ if (!error || typeof error !== "object") return false;
210
+ return error.code === 412;
211
+ }
120
212
  async function tarDirectory(sourceDir, destTarball) {
121
213
  if (!existsSync2(sourceDir) || !statSync(sourceDir).isDirectory()) {
122
214
  throw new Error(`[gcsTransport] tar source must be an existing directory: ${sourceDir}`);
@@ -144,6 +236,7 @@ function getStorage() {
144
236
  }
145
237
  async function dispatch(event, deps) {
146
238
  const unwrapped = unwrapEvent(event);
239
+ validatePlanProtocolShape(unwrapped);
147
240
  validateEventGcsUris(unwrapped);
148
241
  logEvent({ event: "handler_start", action: unwrapped.Action, input: summarizeEvent(unwrapped) });
149
242
  try {
@@ -164,15 +257,52 @@ async function dispatch(event, deps) {
164
257
  }
165
258
  }
166
259
  } catch (err) {
260
+ normalizeTerminalErrorName(err);
167
261
  logEvent({
168
262
  event: "handler_error",
169
263
  action: unwrapped.Action,
264
+ input: summarizeEvent(unwrapped),
170
265
  message: err instanceof Error ? err.message : String(err),
171
266
  name: err instanceof Error ? err.name : void 0
172
267
  });
173
268
  throw err;
174
269
  }
175
270
  }
271
+ function validatePlanProtocolShape(event) {
272
+ const raw = event;
273
+ const protocol = raw.PlanProtocol;
274
+ if (protocol !== void 0 && protocol !== "v1" && protocol !== "v2") {
275
+ const error = new Error(
276
+ `[handler] unsupported PlanProtocol ${JSON.stringify(protocol)}; expected "v1", "v2", or absent`
277
+ );
278
+ error.name = "PLAN_PROTOCOL_UNSUPPORTED";
279
+ throw error;
280
+ }
281
+ if (event.Action === "plan") return;
282
+ const hasV1Locator = typeof raw.PlanGcsUri === "string";
283
+ const hasV2Manifest = typeof raw.PlanV2ManifestGcsUri === "string";
284
+ const hasV2Prefix = typeof raw.PlanV2ArtifactGcsPrefix === "string";
285
+ const valid = protocol === "v2" ? !hasV1Locator && hasV2Manifest && hasV2Prefix : hasV1Locator && !hasV2Manifest && !hasV2Prefix;
286
+ if (!valid) {
287
+ const error = new Error(
288
+ `[handler] ${protocol === "v2" ? "v2" : "v1"} ${event.Action} event has mixed or missing plan locators`
289
+ );
290
+ error.name = "PLAN_PROTOCOL_UNSUPPORTED";
291
+ throw error;
292
+ }
293
+ if (protocol === "v2" && event.Action === "assemble" && event.AudioGcsUri !== null) {
294
+ const error = new Error("[handler] v2 assemble audio must be materialized from the manifest");
295
+ error.name = "PLAN_PROTOCOL_UNSUPPORTED";
296
+ throw error;
297
+ }
298
+ }
299
+ function normalizeTerminalErrorName(error) {
300
+ if (!error || typeof error !== "object") return;
301
+ const candidate = error;
302
+ if (candidate.code === "PLAN_PROTOCOL_UNSUPPORTED" || candidate.code === "PLAN_TOO_LARGE" || candidate.code === "PLAN_V2_INTEGRITY_UNRECOVERABLE") {
303
+ candidate.name = candidate.code;
304
+ }
305
+ }
176
306
  var MAX_ENVELOPE_DEPTH = 4;
177
307
  function unwrapEvent(event) {
178
308
  let cursor = event;
@@ -209,18 +339,21 @@ function summarizeEvent(event) {
209
339
  return {
210
340
  projectGcsUri: event.ProjectGcsUri,
211
341
  planOutputGcsPrefix: event.PlanOutputGcsPrefix,
342
+ planProtocol: event.PlanProtocol ?? "v1",
212
343
  format: event.Config.format,
213
344
  fps: event.Config.fps
214
345
  };
215
346
  case "renderChunk":
216
347
  return {
217
- planGcsUri: event.PlanGcsUri,
348
+ planProtocol: event.PlanProtocol ?? "v1",
349
+ ...event.PlanProtocol === "v2" ? { planV2ManifestGcsUri: event.PlanV2ManifestGcsUri } : { planGcsUri: event.PlanGcsUri },
218
350
  chunkIndex: event.ChunkIndex,
219
351
  format: event.Format
220
352
  };
221
353
  case "assemble":
222
354
  return {
223
- planGcsUri: event.PlanGcsUri,
355
+ planProtocol: event.PlanProtocol ?? "v1",
356
+ ...event.PlanProtocol === "v2" ? { planV2ManifestGcsUri: event.PlanV2ManifestGcsUri } : { planGcsUri: event.PlanGcsUri },
224
357
  chunkCount: event.ChunkGcsUris.length,
225
358
  hasAudio: event.AudioGcsUri !== null,
226
359
  outputGcsUri: event.OutputGcsUri,
@@ -234,6 +367,9 @@ function primeChrome(deps) {
234
367
  process.env.PRODUCER_HEADLESS_SHELL_PATH = resolveChromeExecutablePath();
235
368
  }
236
369
  async function handlePlan(event, deps) {
370
+ if (event.PlanProtocol === "v2") {
371
+ return handlePlanV2(event, deps);
372
+ }
237
373
  const started = Date.now();
238
374
  const storage = deps?.storage ?? getStorage();
239
375
  const primitive = deps?.primitives?.plan ?? plan;
@@ -275,7 +411,70 @@ async function handlePlan(event, deps) {
275
411
  cleanupDir(work);
276
412
  }
277
413
  }
414
+ async function handlePlanV2(event, deps) {
415
+ const started = Date.now();
416
+ const storage = deps?.storage ?? getStorage();
417
+ const primitive = deps?.primitives?.planV2 ?? planV2;
418
+ primeChrome(deps);
419
+ const work = mkdtempSync(join(deps?.tmpRoot ?? tmpdir(), "hf-cr-plan-v2-"));
420
+ const projectArchive = join(work, "project.tar.gz");
421
+ const projectDir = join(work, "project");
422
+ const planV2Dir = join(work, "plan-v2");
423
+ try {
424
+ await downloadGcsObjectToFile(storage, event.ProjectGcsUri, projectArchive);
425
+ await untarDirectory(projectArchive, projectDir);
426
+ const result = await primitive(projectDir, { ...event.Config }, planV2Dir);
427
+ const manifest = readPlanV2Manifest(planV2Dir);
428
+ if (manifest.planHash !== result.planHash) {
429
+ throwPlanHashMismatch(result.planHash, manifest.planHash);
430
+ }
431
+ const outputPrefix = `${trimTrailingSlash(event.PlanOutputGcsPrefix)}/v2`;
432
+ const artifactPrefix = `${outputPrefix}/artifacts/sha256`;
433
+ const uniqueArtifacts = [
434
+ ...new Map(manifest.artifacts.map((artifact) => [artifact.sha256, artifact])).values()
435
+ ];
436
+ await mapConcurrent(uniqueArtifacts, 16, async (artifact) => {
437
+ await uploadContentAddressedFileToGcs(
438
+ storage,
439
+ planV2BlobPath(planV2Dir, artifact.sha256),
440
+ planV2BlobUri(artifactPrefix, artifact.sha256),
441
+ artifact.sha256
442
+ );
443
+ });
444
+ const manifestUri = `${outputPrefix}/manifest.json`;
445
+ await uploadContentAddressedFileToGcs(
446
+ storage,
447
+ result.manifestPath,
448
+ manifestUri,
449
+ await sha256File(result.manifestPath),
450
+ "application/json"
451
+ );
452
+ return {
453
+ Action: "plan",
454
+ PlanProtocol: "v2",
455
+ PlanV2ManifestGcsUri: manifestUri,
456
+ PlanV2ArtifactGcsPrefix: artifactPrefix,
457
+ PlanHash: result.planHash,
458
+ ChunkCount: result.chunkCount,
459
+ TotalFrames: result.totalFrames,
460
+ Fps: result.fps,
461
+ Width: result.width,
462
+ Height: result.height,
463
+ Format: result.format,
464
+ HasAudio: manifest.artifacts.some((artifact) => artifact.path === "audio.aac"),
465
+ AudioGcsUri: null,
466
+ FfmpegVersion: result.ffmpegVersion,
467
+ ProducerVersion: result.producerVersion,
468
+ DurationMs: Date.now() - started
469
+ };
470
+ } finally {
471
+ cleanupDir(work);
472
+ }
473
+ }
278
474
  async function handleRenderChunk(event, deps) {
475
+ if (event.PlanProtocol === "v2") {
476
+ return handleRenderChunkV2(event, deps);
477
+ }
279
478
  const started = Date.now();
280
479
  const storage = deps?.storage ?? getStorage();
281
480
  const primitive = deps?.primitives?.renderChunk ?? renderChunk;
@@ -310,6 +509,42 @@ async function handleRenderChunk(event, deps) {
310
509
  cleanupDir(work);
311
510
  }
312
511
  }
512
+ async function handleRenderChunkV2(event, deps) {
513
+ const started = Date.now();
514
+ const storage = deps?.storage ?? getStorage();
515
+ const primitive = deps?.primitives?.renderChunk ?? renderChunk;
516
+ primeChrome(deps);
517
+ const work = mkdtempSync(join(deps?.tmpRoot ?? tmpdir(), "hf-cr-chunk-v2-"));
518
+ try {
519
+ const planDir = await downloadAndMaterializePlanV2(
520
+ storage,
521
+ event,
522
+ { role: "chunk", chunkIndex: event.ChunkIndex },
523
+ work
524
+ );
525
+ const chunkOutputBase = join(
526
+ work,
527
+ event.Format === "png-sequence" ? `chunk-${pad(event.ChunkIndex)}` : `chunk-${pad(event.ChunkIndex)}${formatExtension(event.Format)}`
528
+ );
529
+ const result = await primitive(planDir, event.ChunkIndex, chunkOutputBase);
530
+ const chunkUri = await uploadChunkOutput(
531
+ storage,
532
+ result,
533
+ event.ChunkOutputGcsPrefix,
534
+ event.ChunkIndex
535
+ );
536
+ return {
537
+ Action: "renderChunk",
538
+ ChunkGcsUri: chunkUri,
539
+ ChunkIndex: event.ChunkIndex,
540
+ Sha256: result.sha256,
541
+ FramesEncoded: result.framesEncoded,
542
+ DurationMs: Date.now() - started
543
+ };
544
+ } finally {
545
+ cleanupDir(work);
546
+ }
547
+ }
313
548
  async function uploadChunkOutput(storage, result, prefix, chunkIndex) {
314
549
  const trimmed = trimTrailingSlash(prefix);
315
550
  if (result.outputKind === "file") {
@@ -325,6 +560,9 @@ async function uploadChunkOutput(storage, result, prefix, chunkIndex) {
325
560
  return uri;
326
561
  }
327
562
  async function handleAssemble(event, deps) {
563
+ if (event.PlanProtocol === "v2") {
564
+ return handleAssembleV2(event, deps);
565
+ }
328
566
  const started = Date.now();
329
567
  const storage = deps?.storage ?? getStorage();
330
568
  const primitive = deps?.primitives?.assemble ?? assemble;
@@ -365,6 +603,91 @@ async function handleAssemble(event, deps) {
365
603
  cleanupDir(work);
366
604
  }
367
605
  }
606
+ async function handleAssembleV2(event, deps) {
607
+ const started = Date.now();
608
+ const storage = deps?.storage ?? getStorage();
609
+ const primitive = deps?.primitives?.assemble ?? assemble;
610
+ const work = mkdtempSync(join(deps?.tmpRoot ?? tmpdir(), "hf-cr-assemble-v2-"));
611
+ try {
612
+ const planDir = await downloadAndMaterializePlanV2(storage, event, { role: "assembler" }, work);
613
+ const audioPath = existsSync3(join(planDir, "audio.aac")) ? join(planDir, "audio.aac") : null;
614
+ const chunkPaths = await downloadChunkObjects(storage, event.ChunkGcsUris, work, event.Format);
615
+ const finalOutput = event.Format === "png-sequence" ? join(work, "output-frames") : join(work, `output${formatExtension(event.Format)}`);
616
+ const result = await primitive(planDir, chunkPaths, audioPath, finalOutput, {
617
+ cfr: event.Cfr === true
618
+ });
619
+ if (event.Format === "png-sequence") {
620
+ const tarball = `${finalOutput}.tar.gz`;
621
+ await tarDirectory(finalOutput, tarball);
622
+ await uploadFileToGcs(storage, tarball, event.OutputGcsUri, "application/gzip");
623
+ } else {
624
+ await uploadFileToGcs(storage, finalOutput, event.OutputGcsUri);
625
+ }
626
+ return {
627
+ Action: "assemble",
628
+ OutputGcsUri: event.OutputGcsUri,
629
+ FramesEncoded: result.framesEncoded,
630
+ FileSize: result.fileSize,
631
+ DurationMs: Date.now() - started
632
+ };
633
+ } finally {
634
+ cleanupDir(work);
635
+ }
636
+ }
637
+ async function downloadAndMaterializePlanV2(storage, event, target, work) {
638
+ const transportDir = join(work, "plan-v2");
639
+ mkdirSync2(transportDir, { recursive: true });
640
+ await downloadGcsObjectToFile(
641
+ storage,
642
+ event.PlanV2ManifestGcsUri,
643
+ join(transportDir, "plan.json")
644
+ );
645
+ const manifest = readPlanV2Manifest(transportDir);
646
+ if (manifest.planHash !== event.PlanHash) {
647
+ throwPlanHashMismatch(event.PlanHash, manifest.planHash);
648
+ }
649
+ const artifacts = listPlanV2ArtifactsForTarget(manifest, target);
650
+ const uniqueArtifacts = [
651
+ ...new Map(artifacts.map((artifact) => [artifact.sha256, artifact])).values()
652
+ ];
653
+ await mapConcurrent(uniqueArtifacts, 16, async (artifact) => {
654
+ await downloadPlanV2Artifact(storage, event.PlanV2ArtifactGcsPrefix, transportDir, artifact);
655
+ });
656
+ const planDir = join(work, "plan");
657
+ materializePlanV2Target(transportDir, target, planDir);
658
+ return planDir;
659
+ }
660
+ async function downloadPlanV2Artifact(storage, artifactPrefix, planV2Dir, artifact) {
661
+ await downloadGcsObjectToFileVerified(
662
+ storage,
663
+ planV2BlobUri(artifactPrefix, artifact.sha256),
664
+ planV2BlobPath(planV2Dir, artifact.sha256),
665
+ artifact.sha256
666
+ );
667
+ }
668
+ function planV2BlobPath(planV2Dir, digest) {
669
+ return join(planV2Dir, "artifacts", "sha256", digest.slice(0, 2), digest);
670
+ }
671
+ function planV2BlobUri(prefix, digest) {
672
+ return `${trimTrailingSlash(prefix)}/${digest.slice(0, 2)}/${digest}`;
673
+ }
674
+ function throwPlanHashMismatch(expected, actual) {
675
+ const error = new Error(
676
+ `PLAN_HASH_MISMATCH: event PlanHash=${expected} did not match v2 manifest planHash=${actual}`
677
+ );
678
+ error.name = "PLAN_HASH_MISMATCH";
679
+ throw error;
680
+ }
681
+ async function mapConcurrent(values, concurrency, fn) {
682
+ let cursor = 0;
683
+ async function worker() {
684
+ while (cursor < values.length) {
685
+ const index = cursor++;
686
+ await fn(values[index]);
687
+ }
688
+ }
689
+ await Promise.all(Array.from({ length: Math.min(concurrency, values.length) }, () => worker()));
690
+ }
368
691
  async function downloadChunkObjects(storage, uris, workDir, format) {
369
692
  const chunksDir = join(workDir, "chunks");
370
693
  mkdirSync2(chunksDir, { recursive: true });
@@ -393,10 +716,10 @@ function getEventGcsUris(event) {
393
716
  case "plan":
394
717
  return [event.ProjectGcsUri, event.PlanOutputGcsPrefix];
395
718
  case "renderChunk":
396
- return [event.PlanGcsUri, event.ChunkOutputGcsPrefix];
719
+ return event.PlanProtocol === "v2" ? [event.PlanV2ManifestGcsUri, event.PlanV2ArtifactGcsPrefix, event.ChunkOutputGcsPrefix] : [event.PlanGcsUri, event.ChunkOutputGcsPrefix];
397
720
  case "assemble":
398
721
  return [
399
- event.PlanGcsUri,
722
+ ...event.PlanProtocol === "v2" ? [event.PlanV2ManifestGcsUri, event.PlanV2ArtifactGcsPrefix] : [event.PlanGcsUri],
400
723
  ...event.ChunkGcsUris,
401
724
  event.OutputGcsUri,
402
725
  event.AudioGcsUri
@@ -465,12 +788,17 @@ var NON_RETRYABLE_ERROR_NAMES = /* @__PURE__ */ new Set([
465
788
  // Handler-boundary guards.
466
789
  "GCS_URI_NOT_ALLOWED",
467
790
  "PLAN_HASH_MISMATCH",
791
+ "PLAN_ARTIFACT_DIGEST_MISMATCH",
792
+ "PLAN_PROTOCOL_UNSUPPORTED",
793
+ "PLAN_V2_INTEGRITY_UNRECOVERABLE",
468
794
  // Producer error class names (`.name`) + their string code aliases — the
469
795
  // class sets `.name` to the class name but wraps a `code`; cover both so a
470
796
  // raw-code throw is caught too. Mirrors the AWS state machine's
471
797
  // non-retryable list.
472
798
  "FormatNotSupportedInDistributedError",
473
799
  "PlanTooLargeError",
800
+ "PlanProtocolUnsupportedError",
801
+ "PlanV2IntegrityError",
474
802
  "RenderChunkValidationError",
475
803
  "FFMPEG_VERSION_MISMATCH",
476
804
  "FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
@@ -640,7 +968,8 @@ async function renderToCloudRun(opts) {
640
968
  PlanOutputGcsPrefix: planOutputGcsPrefix,
641
969
  OutputGcsUri: outputGcsUri,
642
970
  ServiceUrl: opts.serviceUrl,
643
- Config: opts.config
971
+ Config: opts.config,
972
+ PlanProtocol: opts.planProtocol ?? "v1"
644
973
  };
645
974
  validateWorkflowsInputSize(argument);
646
975
  const executions = opts.executions ?? await defaultExecutionsClient();
@@ -847,16 +1176,19 @@ export {
847
1176
  deploySite,
848
1177
  dispatch,
849
1178
  downloadGcsObjectToFile,
1179
+ downloadGcsObjectToFileVerified,
850
1180
  formatGcsUri,
851
1181
  getRenderProgress,
852
1182
  getTerraformModuleDir,
853
1183
  parseGcsUri,
854
1184
  renderToCloudRun,
855
1185
  resolveChromeExecutablePath,
1186
+ sha256File,
856
1187
  startServer,
857
1188
  tarDirectory,
858
1189
  untarDirectory,
859
1190
  unwrapEvent,
1191
+ uploadContentAddressedFileToGcs,
860
1192
  uploadFileToGcs,
861
1193
  validateDistributedRenderConfig
862
1194
  };