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