storyforge 0.8.1 → 0.9.0

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.
@@ -135,10 +135,10 @@ var require_p_limit = __commonJS({
135
135
  // src/bridge-poller.ts
136
136
  import { spawn as spawn2, spawnSync } from "child_process";
137
137
  import * as crypto from "crypto";
138
- import * as fs11 from "fs";
138
+ import * as fs15 from "fs";
139
139
 
140
140
  // ../pipeline/src/clip-render/render-chunk.ts
141
- import * as fs10 from "fs";
141
+ import * as fs13 from "fs";
142
142
  import * as path11 from "path";
143
143
 
144
144
  // ../../node_modules/zod/v3/external.js
@@ -619,8 +619,8 @@ function getErrorMap() {
619
619
 
620
620
  // ../../node_modules/zod/v3/helpers/parseUtil.js
621
621
  var makeIssue = (params) => {
622
- const { data, path: path12, errorMaps, issueData } = params;
623
- const fullPath = [...path12, ...issueData.path || []];
622
+ const { data, path: path13, errorMaps, issueData } = params;
623
+ const fullPath = [...path13, ...issueData.path || []];
624
624
  const fullIssue = {
625
625
  ...issueData,
626
626
  path: fullPath
@@ -736,11 +736,11 @@ var errorUtil;
736
736
 
737
737
  // ../../node_modules/zod/v3/types.js
738
738
  var ParseInputLazyPath = class {
739
- constructor(parent, value, path12, key) {
739
+ constructor(parent, value, path13, key) {
740
740
  this._cachedPath = [];
741
741
  this.parent = parent;
742
742
  this.data = value;
743
- this._path = path12;
743
+ this._path = path13;
744
744
  this._key = key;
745
745
  }
746
746
  get path() {
@@ -5999,11 +5999,11 @@ var renderRemotionHtmlCanvas = async () => {
5999
5999
  import * as fs7 from "fs";
6000
6000
  import * as path8 from "path";
6001
6001
  async function searchAndDownloadPexels(query, desiredDuration, outputPath, signal) {
6002
- const apiKey = process.env.PEXELS_API_KEY;
6003
- if (!apiKey) throw new Error("stock+remotion: PEXELS_API_KEY not set");
6002
+ const apiKey2 = process.env.PEXELS_API_KEY;
6003
+ if (!apiKey2) throw new Error("stock+remotion: PEXELS_API_KEY not set");
6004
6004
  const url = `https://api.pexels.com/videos/search?query=${encodeURIComponent(query)}&per_page=5&size=medium`;
6005
6005
  const resp = await fetch(url, {
6006
- headers: { Authorization: apiKey },
6006
+ headers: { Authorization: apiKey2 },
6007
6007
  signal
6008
6008
  });
6009
6009
  if (!resp.ok) {
@@ -6158,6 +6158,343 @@ var renderDocumentaryV2 = async (shot, ctx) => {
6158
6158
  });
6159
6159
  };
6160
6160
 
6161
+ // ../pipeline/src/clip-render/engines/cloud/lambda.ts
6162
+ import * as fs9 from "fs";
6163
+ var DEFAULT_REGION = "us-east-1";
6164
+ function readLambdaCfg() {
6165
+ const functionName = process.env.REMOTION_LAMBDA_FUNCTION_NAME;
6166
+ const serveUrl = process.env.REMOTION_LAMBDA_SERVE_URL;
6167
+ if (!functionName) {
6168
+ throw new Error(
6169
+ "lambda engine: REMOTION_LAMBDA_FUNCTION_NAME not set. Deploy via `npx remotion lambda functions deploy`."
6170
+ );
6171
+ }
6172
+ if (!serveUrl) {
6173
+ throw new Error(
6174
+ "lambda engine: REMOTION_LAMBDA_SERVE_URL not set. Deploy via `npx remotion lambda sites create`."
6175
+ );
6176
+ }
6177
+ const region = process.env.REMOTION_LAMBDA_REGION ?? process.env.AWS_REGION ?? DEFAULT_REGION;
6178
+ return { functionName, serveUrl, region };
6179
+ }
6180
+ var renderDocumentaryV2Lambda = async (shot, ctx) => {
6181
+ if (ctx.signal?.aborted) throw new Error("aborted");
6182
+ if (!shot.prompt) {
6183
+ throw new Error(
6184
+ `lambda: shot.prompt must be CompositionV2Data JSON or a path (shot ${shot.id})`
6185
+ );
6186
+ }
6187
+ ctx.progress.emit({ phase: "preparing", shotId: shot.id });
6188
+ let data;
6189
+ if (shot.prompt.trimStart().startsWith("{")) {
6190
+ data = JSON.parse(shot.prompt);
6191
+ } else {
6192
+ if (!fs9.existsSync(shot.prompt)) {
6193
+ throw new Error(`lambda: CompositionV2Data file not found: ${shot.prompt}`);
6194
+ }
6195
+ data = JSON.parse(fs9.readFileSync(shot.prompt, "utf8"));
6196
+ }
6197
+ const cfg = readLambdaCfg();
6198
+ let renderMediaOnLambda;
6199
+ let getRenderProgress;
6200
+ try {
6201
+ const mod = await import("./client-5FQUWSRI.js");
6202
+ renderMediaOnLambda = mod.renderMediaOnLambda;
6203
+ getRenderProgress = mod.getRenderProgress;
6204
+ } catch (err) {
6205
+ throw new Error(
6206
+ `lambda: @remotion/lambda is not installed. Run \`npm i -w @forge/remotion @remotion/lambda@4.0.457\`. Original: ${err.message}`
6207
+ );
6208
+ }
6209
+ ctx.progress.emit({
6210
+ phase: "rendering",
6211
+ shotId: shot.id,
6212
+ framesRendered: 0,
6213
+ totalFrames: data.totalDurationFrames
6214
+ });
6215
+ const startedAt = Date.now();
6216
+ const compId = ctx.aspect === "9:16" ? "DocumentaryV2Vertical" : "DocumentaryV2";
6217
+ const { renderId, bucketName } = await renderMediaOnLambda({
6218
+ region: cfg.region,
6219
+ functionName: cfg.functionName,
6220
+ serveUrl: cfg.serveUrl,
6221
+ composition: compId,
6222
+ inputProps: { compositionData: data },
6223
+ codec: "h264",
6224
+ imageFormat: "jpeg",
6225
+ crf: 18,
6226
+ privacy: "public",
6227
+ maxRetries: 1
6228
+ // framesPerLambda omitted — Remotion auto-tunes for cost vs speed
6229
+ });
6230
+ const HARD_DEADLINE_MS = 10 * 6e4;
6231
+ const POLL_INTERVAL_MS2 = 2e3;
6232
+ let lastFramesRendered = 0;
6233
+ let outKey = null;
6234
+ let totalCost = 0;
6235
+ while (Date.now() - startedAt < HARD_DEADLINE_MS) {
6236
+ if (ctx.signal?.aborted) throw new Error("aborted");
6237
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS2));
6238
+ const progress = await getRenderProgress({
6239
+ renderId,
6240
+ bucketName,
6241
+ functionName: cfg.functionName,
6242
+ region: cfg.region
6243
+ });
6244
+ if (progress.fatalErrorEncountered) {
6245
+ const errMsg = progress.errors?.[0]?.message ?? "fatal error";
6246
+ throw new Error(`lambda render failed: ${errMsg}`);
6247
+ }
6248
+ if (typeof progress.framesRendered === "number" && progress.framesRendered > lastFramesRendered) {
6249
+ lastFramesRendered = progress.framesRendered;
6250
+ const totalFrames = data.totalDurationFrames;
6251
+ ctx.progress.emit({
6252
+ phase: "rendering",
6253
+ shotId: shot.id,
6254
+ framesRendered: lastFramesRendered,
6255
+ totalFrames
6256
+ });
6257
+ }
6258
+ if (typeof progress.costs?.accruedSoFar === "number") {
6259
+ totalCost = progress.costs.accruedSoFar;
6260
+ }
6261
+ if (progress.done) {
6262
+ outKey = progress.outKey ?? null;
6263
+ break;
6264
+ }
6265
+ }
6266
+ if (!outKey) {
6267
+ throw new Error(`lambda render timed out after ${HARD_DEADLINE_MS / 1e3}s`);
6268
+ }
6269
+ ctx.progress.emit({ phase: "compositing", shotId: shot.id, percent: 90 });
6270
+ const s3Url = `https://s3.${cfg.region}.amazonaws.com/${bucketName}/${outKey}`;
6271
+ const resp = await fetch(s3Url, { signal: ctx.signal });
6272
+ if (!resp.ok) {
6273
+ throw new Error(`lambda: failed to fetch rendered MP4 from S3 (${resp.status}): ${s3Url}`);
6274
+ }
6275
+ const buf = Buffer.from(await resp.arrayBuffer());
6276
+ fs9.writeFileSync(ctx.sceneOutPath, buf);
6277
+ if (totalCost > 0) {
6278
+ ctx.progress.emit({
6279
+ phase: "compositing",
6280
+ shotId: shot.id,
6281
+ percent: 95,
6282
+ warnings: [`lambda cost: $${totalCost.toFixed(4)}`]
6283
+ });
6284
+ }
6285
+ };
6286
+
6287
+ // ../pipeline/src/clip-render/engines/cloud/runpod-manim.ts
6288
+ import * as fs10 from "fs";
6289
+
6290
+ // ../pipeline/src/clip-render/engines/cloud/runpod-client.ts
6291
+ var RUNPOD_BASE = "https://api.runpod.ai/v2";
6292
+ function apiKey() {
6293
+ const key = process.env.RUNPOD_API_KEY;
6294
+ if (!key) {
6295
+ throw new Error("RUNPOD_API_KEY not set in env");
6296
+ }
6297
+ return key;
6298
+ }
6299
+ async function submitJob(endpointId, payload, signal) {
6300
+ const url = `${RUNPOD_BASE}/${endpointId}/run`;
6301
+ const resp = await fetch(url, {
6302
+ method: "POST",
6303
+ headers: {
6304
+ Authorization: `Bearer ${apiKey()}`,
6305
+ "Content-Type": "application/json"
6306
+ },
6307
+ body: JSON.stringify(payload),
6308
+ signal
6309
+ });
6310
+ if (!resp.ok) {
6311
+ const tail = (await resp.text().catch(() => "")).slice(0, 300);
6312
+ throw new Error(`runpod submit failed (${resp.status}): ${tail}`);
6313
+ }
6314
+ const body = await resp.json();
6315
+ if (!body.id) throw new Error("runpod submit: response missing id");
6316
+ return { jobId: body.id };
6317
+ }
6318
+ async function getJobStatus(endpointId, jobId, signal) {
6319
+ const url = `${RUNPOD_BASE}/${endpointId}/status/${jobId}`;
6320
+ const resp = await fetch(url, {
6321
+ method: "GET",
6322
+ headers: { Authorization: `Bearer ${apiKey()}` },
6323
+ signal
6324
+ });
6325
+ if (!resp.ok) {
6326
+ throw new Error(`runpod status fetch failed (${resp.status})`);
6327
+ }
6328
+ return await resp.json();
6329
+ }
6330
+ async function cancelJob(endpointId, jobId) {
6331
+ await fetch(`${RUNPOD_BASE}/${endpointId}/cancel/${jobId}`, {
6332
+ method: "POST",
6333
+ headers: { Authorization: `Bearer ${apiKey()}` }
6334
+ });
6335
+ }
6336
+ async function runJob(endpointId, payload, opts = {}) {
6337
+ const timeoutMs = opts.timeoutMs ?? 10 * 6e4;
6338
+ const pollIntervalMs = opts.pollIntervalMs ?? 2e3;
6339
+ const { jobId } = await submitJob(endpointId, payload, opts.signal);
6340
+ const startedAt = Date.now();
6341
+ while (Date.now() - startedAt < timeoutMs) {
6342
+ if (opts.signal?.aborted) {
6343
+ void cancelJob(endpointId, jobId).catch(() => {
6344
+ });
6345
+ throw new Error("runpod: aborted");
6346
+ }
6347
+ await new Promise((r) => setTimeout(r, pollIntervalMs));
6348
+ const status = await getJobStatus(endpointId, jobId, opts.signal);
6349
+ opts.onPoll?.(status);
6350
+ if (status.status === "COMPLETED") return status;
6351
+ if (status.status === "FAILED" || status.status === "CANCELLED" || status.status === "TIMED_OUT") {
6352
+ throw new Error(`runpod job ${status.status.toLowerCase()}: ${status.error ?? "no detail"}`);
6353
+ }
6354
+ }
6355
+ void cancelJob(endpointId, jobId).catch(() => {
6356
+ });
6357
+ throw new Error(`runpod job timed out after ${timeoutMs / 1e3}s (jobId=${jobId})`);
6358
+ }
6359
+ function decodeBase64Mp4(output) {
6360
+ if (!output || typeof output !== "object") {
6361
+ throw new Error("runpod: job output is not an object");
6362
+ }
6363
+ const o = output;
6364
+ if (o.error) {
6365
+ const tail = o.stderr ? `
6366
+ stderr: ${o.stderr.slice(-400)}` : "";
6367
+ throw new Error(`runpod worker: ${o.error}${tail}`);
6368
+ }
6369
+ if (typeof o.video_b64 !== "string" || o.video_b64.length === 0) {
6370
+ throw new Error("runpod: job output missing video_b64");
6371
+ }
6372
+ const buf = Buffer.from(o.video_b64, "base64");
6373
+ if (typeof o.size === "number" && o.size !== buf.length) {
6374
+ throw new Error(`runpod: decoded size mismatch (expected ${o.size}, got ${buf.length})`);
6375
+ }
6376
+ if (buf.length === 0) {
6377
+ throw new Error("runpod: decoded video_b64 is empty");
6378
+ }
6379
+ return buf;
6380
+ }
6381
+
6382
+ // ../pipeline/src/clip-render/engines/cloud/runpod-manim.ts
6383
+ var renderManimRunPod = async (shot, ctx) => {
6384
+ if (ctx.signal?.aborted) throw new Error("aborted");
6385
+ if (!shot.manimCode || shot.manimCode.trim().length === 0) {
6386
+ throw new Error(`runpod-manim: shot.manimCode required for shot ${shot.id}`);
6387
+ }
6388
+ const endpointId = process.env.RUNPOD_MANIM_ENDPOINT_ID;
6389
+ if (!endpointId) {
6390
+ throw new Error(
6391
+ "runpod-manim: RUNPOD_MANIM_ENDPOINT_ID not set. Deploy via `cd apps/web/lib/runpod && python -m runpod_flash deploy flash-manim.py`."
6392
+ );
6393
+ }
6394
+ ctx.progress.emit({ phase: "preparing", shotId: shot.id });
6395
+ const status = await runJob(
6396
+ endpointId,
6397
+ {
6398
+ // Flash unpacks `input` via **kwargs into the handler. Our handler
6399
+ // signature is `async def render_manim_scene(input_data: dict)`, so
6400
+ // the payload must be nested under `input_data`.
6401
+ input: {
6402
+ input_data: {
6403
+ python_source: shot.manimCode,
6404
+ scene_class: "GeneratedScene",
6405
+ fps: ctx.fps,
6406
+ quality: "high"
6407
+ // manim --quality h => 1080p60; we'll downsample at concat
6408
+ }
6409
+ },
6410
+ executionTimeout: 600
6411
+ },
6412
+ {
6413
+ timeoutMs: 12 * 6e4,
6414
+ pollIntervalMs: 3e3,
6415
+ signal: ctx.signal,
6416
+ onPoll: (s) => {
6417
+ const phase = s.status === "IN_QUEUE" ? "preparing" : "rendering";
6418
+ ctx.progress.emit({
6419
+ phase,
6420
+ shotId: shot.id,
6421
+ warnings: s.delayTime ? [`runpod queue: ${(s.delayTime / 1e3).toFixed(1)}s`] : void 0
6422
+ });
6423
+ }
6424
+ }
6425
+ );
6426
+ ctx.progress.emit({ phase: "compositing", shotId: shot.id, percent: 90 });
6427
+ const buf = decodeBase64Mp4(status.output);
6428
+ fs10.writeFileSync(ctx.sceneOutPath, buf);
6429
+ if (status.executionTime) {
6430
+ const cost = status.executionTime / 1e3 * 19e-5;
6431
+ ctx.progress.emit({
6432
+ phase: "compositing",
6433
+ shotId: shot.id,
6434
+ percent: 95,
6435
+ warnings: [`runpod-manim cost: $${cost.toFixed(4)}`]
6436
+ });
6437
+ }
6438
+ };
6439
+
6440
+ // ../pipeline/src/clip-render/engines/cloud/runpod-hyperframes.ts
6441
+ import * as fs11 from "fs";
6442
+ var renderHyperframesRunPod = async (shot, ctx) => {
6443
+ if (ctx.signal?.aborted) throw new Error("aborted");
6444
+ if (!shot.hyperframesHtml || shot.hyperframesHtml.trim().length === 0) {
6445
+ throw new Error(`runpod-hyperframes: shot.hyperframesHtml required for shot ${shot.id}`);
6446
+ }
6447
+ const endpointId = process.env.RUNPOD_HYPERFRAMES_ENDPOINT_ID;
6448
+ if (!endpointId) {
6449
+ throw new Error(
6450
+ "runpod-hyperframes: RUNPOD_HYPERFRAMES_ENDPOINT_ID not set. Deploy via `cd apps/web/lib/runpod && python -m runpod_flash deploy flash-hyperframes.py`."
6451
+ );
6452
+ }
6453
+ ctx.progress.emit({ phase: "preparing", shotId: shot.id });
6454
+ const dims = ctx.aspect === "9:16" ? { width: 1080, height: 1920 } : { width: 1920, height: 1080 };
6455
+ const status = await runJob(
6456
+ endpointId,
6457
+ {
6458
+ // Flash unpacks `input` via **kwargs into the handler. Our handler
6459
+ // signature is `async def render_hyperframes_html(input_data: dict)`,
6460
+ // so the payload must be nested under `input_data`.
6461
+ input: {
6462
+ input_data: {
6463
+ html: shot.hyperframesHtml,
6464
+ durationSec: shot.durationSec,
6465
+ fps: ctx.fps,
6466
+ width: dims.width,
6467
+ height: dims.height
6468
+ }
6469
+ },
6470
+ executionTimeout: 600
6471
+ },
6472
+ {
6473
+ timeoutMs: 12 * 6e4,
6474
+ pollIntervalMs: 3e3,
6475
+ signal: ctx.signal,
6476
+ onPoll: (s) => {
6477
+ ctx.progress.emit({
6478
+ phase: s.status === "IN_QUEUE" ? "preparing" : "rendering",
6479
+ shotId: shot.id
6480
+ });
6481
+ }
6482
+ }
6483
+ );
6484
+ ctx.progress.emit({ phase: "compositing", shotId: shot.id, percent: 90 });
6485
+ const buf = decodeBase64Mp4(status.output);
6486
+ fs11.writeFileSync(ctx.sceneOutPath, buf);
6487
+ if (status.executionTime) {
6488
+ const cost = status.executionTime / 1e3 * 19e-5;
6489
+ ctx.progress.emit({
6490
+ phase: "compositing",
6491
+ shotId: shot.id,
6492
+ percent: 95,
6493
+ warnings: [`runpod-hyperframes cost: $${cost.toFixed(4)}`]
6494
+ });
6495
+ }
6496
+ };
6497
+
6161
6498
  // ../pipeline/src/clip-render/engines/index.ts
6162
6499
  var ENGINE_ADAPTERS = {
6163
6500
  "gemini+remotion": renderGeminiRemotion,
@@ -6170,6 +6507,24 @@ var ENGINE_ADAPTERS = {
6170
6507
  "remotion+htmlcanvas": renderRemotionHtmlCanvas,
6171
6508
  "documentary-v2": renderDocumentaryV2
6172
6509
  };
6510
+ var CLOUD_ENGINE_ADAPTERS = {
6511
+ "documentary-v2": renderDocumentaryV2Lambda,
6512
+ "manim": renderManimRunPod,
6513
+ // manim+remotion + gemini+manim+remotion: planner emits the manim leg
6514
+ // as a separate shot, so the cloud manim adapter handles them indirectly.
6515
+ "manim+remotion": renderManimRunPod,
6516
+ "gemini+manim+remotion": renderManimRunPod,
6517
+ "hyperframes": renderHyperframesRunPod
6518
+ };
6519
+ function pickAdapter(engine, provider = "local") {
6520
+ if (provider === "cloud") {
6521
+ const cloud = CLOUD_ENGINE_ADAPTERS[engine];
6522
+ if (cloud) return { adapter: cloud, resolvedProvider: "cloud" };
6523
+ }
6524
+ const local = ENGINE_ADAPTERS[engine];
6525
+ if (!local) throw new Error(`no adapter registered for engine "${engine}"`);
6526
+ return { adapter: local, resolvedProvider: "local" };
6527
+ }
6173
6528
  var MANIM_ENGINES = /* @__PURE__ */ new Set([
6174
6529
  "manim",
6175
6530
  "manim+remotion",
@@ -6180,7 +6535,7 @@ function isManimEngine(engine) {
6180
6535
  }
6181
6536
 
6182
6537
  // ../pipeline/src/clip-render/fs-layout.ts
6183
- import * as fs9 from "fs";
6538
+ import * as fs12 from "fs";
6184
6539
  import * as path10 from "path";
6185
6540
  function resolveProjectRoot(projectSlug, callerOutputDir) {
6186
6541
  if (callerOutputDir && callerOutputDir.length > 0) {
@@ -6222,13 +6577,13 @@ function ensureChunkDirs(paths) {
6222
6577
  paths.remotionDir,
6223
6578
  paths.hyperframesDir
6224
6579
  ]) {
6225
- fs9.mkdirSync(dir, { recursive: true });
6580
+ fs12.mkdirSync(dir, { recursive: true });
6226
6581
  }
6227
6582
  }
6228
6583
  function appendProgress(progressLogPath, event) {
6229
6584
  try {
6230
- fs9.mkdirSync(path10.dirname(progressLogPath), { recursive: true });
6231
- fs9.appendFileSync(progressLogPath, JSON.stringify(event) + "\n");
6585
+ fs12.mkdirSync(path10.dirname(progressLogPath), { recursive: true });
6586
+ fs12.appendFileSync(progressLogPath, JSON.stringify(event) + "\n");
6232
6587
  } catch {
6233
6588
  }
6234
6589
  }
@@ -6334,7 +6689,7 @@ async function renderChunk(spec, opts = {}) {
6334
6689
  const concatTarget = path11.join(paths.tmpDir, `${spec.chunkId}_video.mp4`);
6335
6690
  await concatScenes(scenePaths, concatTarget, { signal: opts.signal });
6336
6691
  if (opts.signal?.aborted) throw new Error("aborted");
6337
- const audioAvailable = !!spec.audioPath && fs10.existsSync(spec.audioPath);
6692
+ const audioAvailable = !!spec.audioPath && fs13.existsSync(spec.audioPath);
6338
6693
  if (audioAvailable) {
6339
6694
  tracker.emit({ phase: "muxing", percent: 95 });
6340
6695
  await muxAudio(concatTarget, spec.audioPath, paths.finalClipPath, {
@@ -6346,7 +6701,7 @@ async function renderChunk(spec, opts = {}) {
6346
6701
  } else {
6347
6702
  warnings.push("no audioPath in spec \u2014 wrote silent clip");
6348
6703
  }
6349
- fs10.copyFileSync(concatTarget, paths.finalClipPath);
6704
+ fs13.copyFileSync(concatTarget, paths.finalClipPath);
6350
6705
  }
6351
6706
  tracker.emit({ phase: "done", percent: 100 });
6352
6707
  const renderTimeMs2 = (opts.now ?? Date.now)() - startedAt;
@@ -6400,9 +6755,17 @@ function abortedResult(spec, engine, fallbackDepth, startedAt, now) {
6400
6755
  };
6401
6756
  }
6402
6757
  async function renderWithEngine(spec, engine, paths, fps, tracker, opts) {
6403
- const adapter = ENGINE_ADAPTERS[engine];
6404
- if (!adapter) throw new Error(`no adapter registered for engine "${engine}"`);
6405
- tracker.emit({ phase: "preparing", percent: 5 });
6758
+ const provider = spec.provider ?? "local";
6759
+ const { adapter, resolvedProvider } = pickAdapter(engine, provider);
6760
+ const providerWarnings = [];
6761
+ if (provider === "cloud" && resolvedProvider === "local") {
6762
+ providerWarnings.push(`engine "${engine}" has no cloud variant \u2014 fell back to local`);
6763
+ }
6764
+ tracker.emit({
6765
+ phase: "preparing",
6766
+ percent: 5,
6767
+ warnings: providerWarnings.length > 0 ? providerWarnings : void 0
6768
+ });
6406
6769
  const shots = spec.shots.length > 0 ? spec.shots : [implicitShot(spec)];
6407
6770
  for (let i = 0; i < shots.length; i++) {
6408
6771
  if (opts.signal?.aborted) throw new Error("aborted");
@@ -6418,7 +6781,7 @@ async function renderWithEngine(spec, engine, paths, fps, tracker, opts) {
6418
6781
  progress: tracker,
6419
6782
  resolvedEngine: engine
6420
6783
  };
6421
- fs10.mkdirSync(path11.dirname(ctx.sceneOutPath), { recursive: true });
6784
+ fs13.mkdirSync(path11.dirname(ctx.sceneOutPath), { recursive: true });
6422
6785
  const release = opts.acquireManimSlot ? await opts.acquireManimSlot() : void 0;
6423
6786
  try {
6424
6787
  tracker.emit({
@@ -6430,7 +6793,7 @@ async function renderWithEngine(spec, engine, paths, fps, tracker, opts) {
6430
6793
  } finally {
6431
6794
  release?.();
6432
6795
  }
6433
- if (!fs10.existsSync(ctx.sceneOutPath)) {
6796
+ if (!fs13.existsSync(ctx.sceneOutPath)) {
6434
6797
  throw new Error(
6435
6798
  `engine ${engine} produced no output at ${ctx.sceneOutPath} for shot ${shot.id}`
6436
6799
  );
@@ -6449,6 +6812,49 @@ function implicitShot(spec) {
6449
6812
  // ../pipeline/src/clip-render/orchestrator.ts
6450
6813
  var import_p_limit = __toESM(require_p_limit());
6451
6814
  import * as os from "os";
6815
+
6816
+ // ../pipeline/src/clip-render/manifest.ts
6817
+ import * as fs14 from "fs";
6818
+ import * as path12 from "path";
6819
+ function buildLocalManifestStub(specs, results, now = () => /* @__PURE__ */ new Date()) {
6820
+ const specByChunk = new Map(specs.map((s) => [s.chunkId, s]));
6821
+ const entries = [];
6822
+ for (const r of results) {
6823
+ if (!r.ok || !r.outputPath) continue;
6824
+ const spec = specByChunk.get(r.chunkId);
6825
+ if (!spec) continue;
6826
+ let size = 0;
6827
+ try {
6828
+ const stat = fs14.statSync(r.outputPath);
6829
+ if (stat.isFile()) size = stat.size;
6830
+ } catch {
6831
+ continue;
6832
+ }
6833
+ if (size === 0) continue;
6834
+ entries.push({
6835
+ kind: "clips",
6836
+ chunkId: r.chunkId,
6837
+ ext: "mp4",
6838
+ size,
6839
+ localPath: r.outputPath,
6840
+ aspect: spec.aspect
6841
+ });
6842
+ }
6843
+ return { writtenAt: now().toISOString(), version: 1, entries };
6844
+ }
6845
+ function localManifestStubPath(root) {
6846
+ return path12.join(root, "manifest.local.json");
6847
+ }
6848
+ function writeLocalManifestStub(root, stub) {
6849
+ fs14.mkdirSync(root, { recursive: true });
6850
+ const target = localManifestStubPath(root);
6851
+ const tmp = `${target}.tmp-${process.pid}`;
6852
+ fs14.writeFileSync(tmp, JSON.stringify(stub, null, 2));
6853
+ fs14.renameSync(tmp, target);
6854
+ return target;
6855
+ }
6856
+
6857
+ // ../pipeline/src/clip-render/orchestrator.ts
6452
6858
  function resolveConcurrency(requested) {
6453
6859
  if (typeof requested === "number" && requested > 0) {
6454
6860
  return Math.max(1, Math.floor(requested));
@@ -6535,6 +6941,16 @@ async function renderChunksParallel(specs, options = {}) {
6535
6941
  }
6536
6942
  const totalRenderTimeMs = Date.now() - startedAt;
6537
6943
  const ok = results.every((r) => r?.ok);
6944
+ if (specs.length > 0) {
6945
+ const stub = buildLocalManifestStub(specs, results);
6946
+ if (stub.entries.length > 0) {
6947
+ try {
6948
+ const root = resolveProjectRoot(specs[0].projectSlug, specs[0].outputDir);
6949
+ writeLocalManifestStub(root, stub);
6950
+ } catch {
6951
+ }
6952
+ }
6953
+ }
6538
6954
  return { ok, results, totalRenderTimeMs, observedPeakConcurrency };
6539
6955
  }
6540
6956
  function abortedSkeleton(spec) {
@@ -6614,8 +7030,8 @@ var BridgePoller = class {
6614
7030
  if (!/^[a-z][a-z0-9-]*$/i.test(name)) return { available: false, path: null };
6615
7031
  const r = spawnSync("which", [name], { encoding: "utf-8", timeout: 2e3 });
6616
7032
  if (r.status !== 0) return { available: false, path: null };
6617
- const path12 = (r.stdout ?? "").trim();
6618
- return { available: !!path12, path: path12 || null };
7033
+ const path13 = (r.stdout ?? "").trim();
7034
+ return { available: !!path13, path: path13 || null };
6619
7035
  }
6620
7036
  async heartbeat() {
6621
7037
  if (this.stopped) return;
@@ -6858,6 +7274,41 @@ var BridgePoller = class {
6858
7274
  }
6859
7275
  const okCount = result.results.filter((r) => r.ok).length;
6860
7276
  const uploadedCount = uploadOutcomes.filter((u) => u.ok).length;
7277
+ const artifacts = [];
7278
+ for (let i = 0; i < uploadOutcomes.length; i++) {
7279
+ const u = uploadOutcomes[i];
7280
+ const r = result.results.find((x) => x.chunkId === u.chunkId);
7281
+ const spec = specByChunk.get(u.chunkId);
7282
+ if (!u.ok || !u.storjKey || !r || !spec) continue;
7283
+ let size = 0;
7284
+ try {
7285
+ const fsm = await import("fs/promises");
7286
+ const stat = await fsm.stat(r.outputPath);
7287
+ size = stat.size;
7288
+ } catch {
7289
+ }
7290
+ artifacts.push({
7291
+ kind: "clips",
7292
+ chunkId: u.chunkId,
7293
+ ext: "mp4",
7294
+ size,
7295
+ storjKey: u.storjKey,
7296
+ aspect: spec.aspect
7297
+ });
7298
+ }
7299
+ let manifestUploadOk = false;
7300
+ let manifestUploadError;
7301
+ if (artifacts.length > 0) {
7302
+ const manifest = {
7303
+ slug: payload.specs[0]?.projectSlug ?? "",
7304
+ version: 1,
7305
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
7306
+ artifacts
7307
+ };
7308
+ const m = await this.uploadManifest(jobId, payload.projectId, manifest);
7309
+ manifestUploadOk = m.ok;
7310
+ manifestUploadError = m.error;
7311
+ }
6861
7312
  const summary = {
6862
7313
  kind: "render-clips",
6863
7314
  ok: result.ok && uploadedCount === okCount,
@@ -6866,6 +7317,8 @@ var BridgePoller = class {
6866
7317
  failed: result.results.length - okCount,
6867
7318
  uploaded: uploadedCount,
6868
7319
  uploadFailed: uploadOutcomes.filter((u) => !u.ok).length,
7320
+ manifestUploadOk,
7321
+ manifestUploadError,
6869
7322
  totalRenderTimeMs: result.totalRenderTimeMs,
6870
7323
  observedPeakConcurrency: result.observedPeakConcurrency
6871
7324
  };
@@ -6885,12 +7338,12 @@ var BridgePoller = class {
6885
7338
  */
6886
7339
  async uploadClipToStorj(bridgeJobId, projectId, spec, result) {
6887
7340
  try {
6888
- const fs12 = await import("fs/promises");
6889
- const stat = await fs12.stat(result.outputPath);
7341
+ const fs16 = await import("fs/promises");
7342
+ const stat = await fs16.stat(result.outputPath);
6890
7343
  if (!stat.isFile() || stat.size === 0) {
6891
7344
  return { ok: false, error: `local clip empty or missing at ${result.outputPath}` };
6892
7345
  }
6893
- const buf = await fs12.readFile(result.outputPath);
7346
+ const buf = await fs16.readFile(result.outputPath);
6894
7347
  const durationFrames = Math.max(1, Math.round(result.durationSec * 30));
6895
7348
  const form = new FormData();
6896
7349
  form.append("bridgeJobId", bridgeJobId);
@@ -6918,6 +7371,34 @@ var BridgePoller = class {
6918
7371
  return { ok: false, error: err.message };
6919
7372
  }
6920
7373
  }
7374
+ /**
7375
+ * POST the assembled ProjectManifest to /api/cli-bridge/upload-manifest.
7376
+ * Called once per render-clips batch after every clip uploads. The
7377
+ * server validates tenancy, stamps slug + updatedAt, and writes the
7378
+ * canonical manifest.json to Storj.
7379
+ */
7380
+ async uploadManifest(bridgeJobId, projectId, manifest) {
7381
+ try {
7382
+ const resp = await fetch(`${this.baseUrl}/api/cli-bridge/upload-manifest`, {
7383
+ method: "POST",
7384
+ headers: {
7385
+ Authorization: `Bearer ${this.token}`,
7386
+ "Content-Type": "application/json"
7387
+ },
7388
+ body: JSON.stringify({ bridgeJobId, projectId, manifest }),
7389
+ signal: AbortSignal.timeout(6e4)
7390
+ });
7391
+ const text = await resp.text().catch(() => "");
7392
+ if (!resp.ok) {
7393
+ return { ok: false, error: `HTTP ${resp.status}: ${text.slice(0, 300)}` };
7394
+ }
7395
+ const parsed = text ? JSON.parse(text) : {};
7396
+ log.info(`[bridge] manifest uploaded -> ${parsed.storjKey ?? "(no key)"}`);
7397
+ return { ok: true, storjKey: parsed.storjKey };
7398
+ } catch (err) {
7399
+ return { ok: false, error: err.message };
7400
+ }
7401
+ }
6921
7402
  /** POST a single RenderProgress event to /api/render-progress. */
6922
7403
  async postRenderProgress(bridgeJobId, projectId, event) {
6923
7404
  try {
@@ -6955,9 +7436,9 @@ var BridgePoller = class {
6955
7436
  error: `stitch-final job requires @forge/pipeline/stitch (not installed): ${err.message}`
6956
7437
  };
6957
7438
  }
6958
- const path12 = await import("path");
6959
- const projectRoot = path12.resolve(process.cwd(), "forge-renders", payload.projectSlug);
6960
- const clipsDir = path12.join(projectRoot, "clips");
7439
+ const path13 = await import("path");
7440
+ const projectRoot = path13.resolve(process.cwd(), "forge-renders", payload.projectSlug);
7441
+ const clipsDir = path13.join(projectRoot, "clips");
6961
7442
  const publish = async (phase, eventPayload) => {
6962
7443
  try {
6963
7444
  await fetch(`${this.baseUrl}/api/cli-bridge/stitch-event`, {
@@ -6983,7 +7464,7 @@ var BridgePoller = class {
6983
7464
  projectSlug: payload.projectSlug,
6984
7465
  clipsDir,
6985
7466
  chunkOrder: payload.chunkOrder ?? [],
6986
- masterAudioPath: path12.isAbsolute(masterAudioRel) ? masterAudioRel : path12.join(projectRoot, masterAudioRel),
7467
+ masterAudioPath: path13.isAbsolute(masterAudioRel) ? masterAudioRel : path13.join(projectRoot, masterAudioRel),
6987
7468
  outputDir: projectRoot,
6988
7469
  aspect: payload.aspect,
6989
7470
  skipTransitions: payload.skipTransitions,
@@ -7167,10 +7648,10 @@ function collectImagesFromCodexStdout(stdout, maxCount) {
7167
7648
  for (const p of paths) {
7168
7649
  if (images.length >= maxCount) break;
7169
7650
  try {
7170
- const stat = fs11.statSync(p);
7651
+ const stat = fs15.statSync(p);
7171
7652
  if (!stat.isFile() || stat.size <= 0) continue;
7172
7653
  images.push({
7173
- base64: fs11.readFileSync(p).toString("base64"),
7654
+ base64: fs15.readFileSync(p).toString("base64"),
7174
7655
  mimeType: mimeTypeForPath(p),
7175
7656
  model: "codex-cli:imagegen"
7176
7657
  });