storyforge 0.9.0 → 0.10.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.
@@ -7330,11 +7330,18 @@ var BridgePoller = class {
7330
7330
  }
7331
7331
  }
7332
7332
  /**
7333
- * POST a rendered clip to /api/cli-bridge/upload-clip as multipart
7334
- * form data. The endpoint uploads the binary to Storj, inserts a
7335
- * `clips` table row, and returns the storj_key. Best-effort a
7336
- * failed upload is recorded as a warning and the chunk's progress
7337
- * event reports phase='error' but doesn't take down the whole batch.
7333
+ * Upload a rendered clip via signed-URL flow:
7334
+ * 1. POST /api/cli-bridge/upload-url { signedUrl, storjKey, version }
7335
+ * 2. PUT file directly to Storj via signedUrl (bypasses Vercel
7336
+ * lifts the body-size ceiling, MP4s of any size go through)
7337
+ * 3. POST /api/cli-bridge/upload-finalize inserts clips row,
7338
+ * regenerates manifest, returns { clipId }
7339
+ *
7340
+ * The legacy multipart path at /api/cli-bridge/upload-clip is still
7341
+ * deployed for back-compat with storyforge < 0.10.0 in the wild,
7342
+ * but new clients prefer this. Best-effort failure semantics —
7343
+ * an upload error is logged + reported per-chunk but doesn't take
7344
+ * down the whole render batch.
7338
7345
  */
7339
7346
  async uploadClipToStorj(bridgeJobId, projectId, spec, result) {
7340
7347
  try {
@@ -7343,30 +7350,63 @@ var BridgePoller = class {
7343
7350
  if (!stat.isFile() || stat.size === 0) {
7344
7351
  return { ok: false, error: `local clip empty or missing at ${result.outputPath}` };
7345
7352
  }
7346
- const buf = await fs16.readFile(result.outputPath);
7347
7353
  const durationFrames = Math.max(1, Math.round(result.durationSec * 30));
7348
- const form = new FormData();
7349
- form.append("bridgeJobId", bridgeJobId);
7350
- form.append("chunkId", result.chunkId);
7351
- form.append("engine", result.engine);
7352
- form.append("aspect", spec.aspect);
7353
- form.append("durationSec", String(result.durationSec));
7354
- form.append("durationFrames", String(durationFrames));
7355
- form.append("file", new Blob([new Uint8Array(buf)], { type: "video/mp4" }), `${result.chunkId}.mp4`);
7356
- const resp = await fetch(`${this.baseUrl}/api/cli-bridge/upload-clip`, {
7354
+ const mintResp = await fetch(`${this.baseUrl}/api/cli-bridge/upload-url`, {
7357
7355
  method: "POST",
7358
- headers: { Authorization: `Bearer ${this.token}` },
7359
- body: form,
7360
- signal: AbortSignal.timeout(5 * 6e4)
7356
+ headers: {
7357
+ Authorization: `Bearer ${this.token}`,
7358
+ "Content-Type": "application/json"
7359
+ },
7360
+ body: JSON.stringify({
7361
+ bridgeJobId,
7362
+ chunkId: result.chunkId,
7363
+ aspect: spec.aspect,
7364
+ contentType: "video/mp4"
7365
+ }),
7366
+ signal: AbortSignal.timeout(6e4)
7361
7367
  });
7362
- const text = await resp.text().catch(() => "");
7363
- if (!resp.ok) {
7364
- return { ok: false, error: `HTTP ${resp.status}: ${text.slice(0, 300)}` };
7368
+ const mintText = await mintResp.text().catch(() => "");
7369
+ if (!mintResp.ok) {
7370
+ return { ok: false, error: `mint HTTP ${mintResp.status}: ${mintText.slice(0, 300)}` };
7365
7371
  }
7366
- const parsed = text ? JSON.parse(text) : {};
7367
- log.info(`[bridge] uploaded ${result.chunkId.slice(0, 8)} -> ${parsed.storjKey ?? "(no key)"}`);
7372
+ const mint = JSON.parse(mintText);
7373
+ const buf = await fs16.readFile(result.outputPath);
7374
+ const putResp = await fetch(mint.signedUrl, {
7375
+ method: "PUT",
7376
+ headers: { "Content-Type": "video/mp4" },
7377
+ body: new Uint8Array(buf),
7378
+ signal: AbortSignal.timeout(15 * 6e4)
7379
+ });
7380
+ if (!putResp.ok) {
7381
+ const putText = await putResp.text().catch(() => "");
7382
+ return { ok: false, error: `storj PUT HTTP ${putResp.status}: ${putText.slice(0, 200)}` };
7383
+ }
7384
+ const finResp = await fetch(`${this.baseUrl}/api/cli-bridge/upload-finalize`, {
7385
+ method: "POST",
7386
+ headers: {
7387
+ Authorization: `Bearer ${this.token}`,
7388
+ "Content-Type": "application/json"
7389
+ },
7390
+ body: JSON.stringify({
7391
+ bridgeJobId,
7392
+ chunkId: result.chunkId,
7393
+ aspect: spec.aspect,
7394
+ version: mint.version,
7395
+ storjKey: mint.storjKey,
7396
+ engine: result.engine,
7397
+ durationSec: result.durationSec,
7398
+ durationFrames,
7399
+ size: stat.size
7400
+ }),
7401
+ signal: AbortSignal.timeout(6e4)
7402
+ });
7403
+ const finText = await finResp.text().catch(() => "");
7404
+ if (!finResp.ok) {
7405
+ return { ok: false, storjKey: mint.storjKey, error: `finalize HTTP ${finResp.status}: ${finText.slice(0, 300)}` };
7406
+ }
7407
+ log.info(`[bridge] uploaded ${result.chunkId.slice(0, 8)} -> ${mint.storjKey} (v${mint.version}, ${(stat.size / 1024 / 1024).toFixed(1)}M)`);
7368
7408
  void projectId;
7369
- return { ok: true, storjKey: parsed.storjKey };
7409
+ return { ok: true, storjKey: mint.storjKey };
7370
7410
  } catch (err) {
7371
7411
  return { ok: false, error: err.message };
7372
7412
  }
package/dist/index.js CHANGED
@@ -547,7 +547,7 @@ var exec = promisify(execCb);
547
547
  var PORT = 4444;
548
548
  function runCliPipingStdin(cmd, args, stdinData, opts = {}) {
549
549
  const maxBytes = (opts.maxBufferMB ?? 16) * 1024 * 1024;
550
- return new Promise((resolve5, reject) => {
550
+ return new Promise((resolve6, reject) => {
551
551
  const proc = spawn(cmd, args, { stdio: ["pipe", "pipe", "pipe"] });
552
552
  let stdout = "";
553
553
  let stderr = "";
@@ -580,7 +580,7 @@ function runCliPipingStdin(cmd, args, stdinData, opts = {}) {
580
580
  });
581
581
  proc.on("close", (code) => {
582
582
  if (timer) clearTimeout(timer);
583
- resolve5({ stdout, stderr, code });
583
+ resolve6({ stdout, stderr, code });
584
584
  });
585
585
  proc.stdin.end(stdinData);
586
586
  });
@@ -1501,9 +1501,9 @@ Return ONLY the complete updated TSX. No markdown fences, no explanation.`;
1501
1501
  }
1502
1502
  const boundary = boundaryM[1];
1503
1503
  const chunks = [];
1504
- await new Promise((resolve5, reject) => {
1504
+ await new Promise((resolve6, reject) => {
1505
1505
  req.on("data", (c) => chunks.push(Buffer.from(c)));
1506
- req.on("end", resolve5);
1506
+ req.on("end", resolve6);
1507
1507
  req.on("error", reject);
1508
1508
  });
1509
1509
  const body = Buffer.concat(chunks);
@@ -1615,7 +1615,7 @@ Return ONLY the complete updated TSX. No markdown fences, no explanation.`;
1615
1615
  return "0.0.0";
1616
1616
  })();
1617
1617
  void (async () => {
1618
- const { BridgePoller } = await import("./bridge-poller-DNGPP5MA.js");
1618
+ const { BridgePoller } = await import("./bridge-poller-G6LO7JFZ.js");
1619
1619
  const poller = new BridgePoller({ baseUrl: bridgeUrl, token: bridgeToken, clientVersion: `storyforge ${pkgVersion}` });
1620
1620
  poller.start();
1621
1621
  })();
@@ -1640,10 +1640,10 @@ Return ONLY the complete updated TSX. No markdown fences, no explanation.`;
1640
1640
  import * as readline from "readline";
1641
1641
  function prompt(question) {
1642
1642
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1643
- return new Promise((resolve5) => {
1643
+ return new Promise((resolve6) => {
1644
1644
  rl.question(question, (answer) => {
1645
1645
  rl.close();
1646
- resolve5(answer.trim());
1646
+ resolve6(answer.trim());
1647
1647
  });
1648
1648
  });
1649
1649
  }
@@ -1765,7 +1765,7 @@ async function checkPython() {
1765
1765
  };
1766
1766
  }
1767
1767
  async function checkManim() {
1768
- const probe = await new Promise((resolve5) => {
1768
+ const probe = await new Promise((resolve6) => {
1769
1769
  const p = spawn2("python3", ["-m", "manim", "--version"], { stdio: ["ignore", "pipe", "pipe"] });
1770
1770
  let stdout = "";
1771
1771
  let stderr = "";
@@ -1775,18 +1775,18 @@ async function checkManim() {
1775
1775
  p.stderr.on("data", (d) => {
1776
1776
  stderr += d.toString("utf8");
1777
1777
  });
1778
- p.on("error", () => resolve5({ installed: false, version: null }));
1778
+ p.on("error", () => resolve6({ installed: false, version: null }));
1779
1779
  p.on("close", (code) => {
1780
1780
  if (code === 0) {
1781
1781
  const m = (stdout + stderr).match(/Manim Community v(\S+)/);
1782
- resolve5({ installed: true, version: m?.[1] ?? null });
1782
+ resolve6({ installed: true, version: m?.[1] ?? null });
1783
1783
  } else {
1784
- resolve5({ installed: false, version: null });
1784
+ resolve6({ installed: false, version: null });
1785
1785
  }
1786
1786
  });
1787
1787
  setTimeout(() => {
1788
1788
  p.kill();
1789
- resolve5({ installed: false, version: null });
1789
+ resolve6({ installed: false, version: null });
1790
1790
  }, 5e3);
1791
1791
  });
1792
1792
  return {
@@ -1915,11 +1915,11 @@ async function ask(rl, question) {
1915
1915
  return ans === "" || ans === "y" || ans === "yes";
1916
1916
  }
1917
1917
  function runStep(argv) {
1918
- return new Promise((resolve5) => {
1918
+ return new Promise((resolve6) => {
1919
1919
  const [cmd, ...args] = argv;
1920
1920
  const proc = spawn3(cmd, args, { stdio: ["inherit", "inherit", "inherit"] });
1921
- proc.on("error", (err) => resolve5({ ok: false, tail: err.message }));
1922
- proc.on("close", (code) => resolve5({ ok: code === 0 }));
1921
+ proc.on("error", (err) => resolve6({ ok: false, tail: err.message }));
1922
+ proc.on("close", (code) => resolve6({ ok: code === 0 }));
1923
1923
  });
1924
1924
  }
1925
1925
  async function installRenderersCommand(opts = {}) {
@@ -2191,7 +2191,7 @@ async function ffmpegConcat(inputs, outPath) {
2191
2191
  const listBody = inputs.map((p) => `file '${p.replace(/'/g, "'\\''")}'`).join("\n") + "\n";
2192
2192
  fs5.writeFileSync(listPath, listBody);
2193
2193
  fs5.mkdirSync(path5.dirname(outPath), { recursive: true });
2194
- await new Promise((resolve5, reject) => {
2194
+ await new Promise((resolve6, reject) => {
2195
2195
  const proc = spawn4("ffmpeg", [
2196
2196
  "-y",
2197
2197
  "-f",
@@ -2212,7 +2212,7 @@ async function ffmpegConcat(inputs, outPath) {
2212
2212
  fs5.rmSync(tmpDir, { recursive: true, force: true });
2213
2213
  } catch {
2214
2214
  }
2215
- if (code === 0) resolve5();
2215
+ if (code === 0) resolve6();
2216
2216
  else reject(new Error(`ffmpeg exited ${code}`));
2217
2217
  });
2218
2218
  });
@@ -2293,23 +2293,58 @@ async function uploadClip(slug, diff, baseUrl, token) {
2293
2293
  const stat = await fs6.promises.stat(absPath);
2294
2294
  const durationSec = Math.max(1, Math.round(stat.size / 25e4));
2295
2295
  const durationFrames = durationSec * 30;
2296
- const form = new FormData();
2297
- form.append("chunkId", artifact.chunkId);
2298
- form.append("aspect", artifact.aspect ?? "16:9");
2299
- form.append("engine", "remotion");
2300
- form.append("durationSec", String(durationSec));
2301
- form.append("durationFrames", String(durationFrames));
2302
- form.append("file", new Blob([new Uint8Array(buf)], { type: "video/mp4" }), `${artifact.chunkId}.mp4`);
2303
- const resp = await fetch(`${baseUrl}/api/projects/${encodeURIComponent(slug)}/push-clip`, {
2296
+ const aspect = artifact.aspect ?? "16:9";
2297
+ const mint = await mintUploadUrl(slug, baseUrl, token, {
2298
+ kind: "clips",
2299
+ chunkId: artifact.chunkId,
2300
+ aspect,
2301
+ contentType: "video/mp4"
2302
+ });
2303
+ await putToStorj(mint.signedUrl, buf, "video/mp4");
2304
+ const fin = await postFinalize(slug, baseUrl, token, {
2305
+ kind: "clips",
2306
+ storjKey: mint.storjKey,
2307
+ chunkId: artifact.chunkId,
2308
+ aspect,
2309
+ version: mint.version,
2310
+ engine: "remotion",
2311
+ durationSec,
2312
+ durationFrames
2313
+ });
2314
+ return { storjKey: mint.storjKey, clipId: fin.clipId, version: mint.version };
2315
+ }
2316
+ async function mintUploadUrl(slug, baseUrl, token, args) {
2317
+ const resp = await fetch(`${baseUrl}/api/projects/${encodeURIComponent(slug)}/upload-url`, {
2304
2318
  method: "POST",
2305
- headers: { Authorization: `Bearer ${token}` },
2306
- body: form,
2307
- signal: AbortSignal.timeout(5 * 6e4)
2319
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
2320
+ body: JSON.stringify(args),
2321
+ signal: AbortSignal.timeout(6e4)
2308
2322
  });
2309
2323
  const text = await resp.text().catch(() => "");
2324
+ if (!resp.ok) throw new Error(`mint-url HTTP ${resp.status}: ${text.slice(0, 300)}`);
2325
+ return JSON.parse(text);
2326
+ }
2327
+ async function putToStorj(signedUrl, body, contentType) {
2328
+ const resp = await fetch(signedUrl, {
2329
+ method: "PUT",
2330
+ headers: { "Content-Type": contentType },
2331
+ body: new Uint8Array(body),
2332
+ signal: AbortSignal.timeout(15 * 6e4)
2333
+ });
2310
2334
  if (!resp.ok) {
2311
- throw new Error(`HTTP ${resp.status}: ${text.slice(0, 300)}`);
2335
+ const text = await resp.text().catch(() => "");
2336
+ throw new Error(`storj PUT HTTP ${resp.status}: ${text.slice(0, 200)}`);
2312
2337
  }
2338
+ }
2339
+ async function postFinalize(slug, baseUrl, token, args) {
2340
+ const resp = await fetch(`${baseUrl}/api/projects/${encodeURIComponent(slug)}/upload-finalize`, {
2341
+ method: "POST",
2342
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
2343
+ body: JSON.stringify(args),
2344
+ signal: AbortSignal.timeout(6e4)
2345
+ });
2346
+ const text = await resp.text().catch(() => "");
2347
+ if (!resp.ok) throw new Error(`finalize HTTP ${resp.status}: ${text.slice(0, 300)}`);
2313
2348
  return JSON.parse(text);
2314
2349
  }
2315
2350
  function resolveBridgeToken(explicit) {
@@ -2326,13 +2361,82 @@ function humanBytes(n) {
2326
2361
  }
2327
2362
  async function confirm(prompt2) {
2328
2363
  const rl = readline3.createInterface({ input: process.stdin, output: process.stdout });
2329
- const answer = await new Promise((resolve5) => rl.question(prompt2, resolve5));
2364
+ const answer = await new Promise((resolve6) => rl.question(prompt2, resolve6));
2330
2365
  rl.close();
2331
2366
  return /^y(es)?$/i.test(answer.trim());
2332
2367
  }
2333
2368
 
2369
+ // src/commands/push-hook.ts
2370
+ import * as fs7 from "fs";
2371
+ import * as path7 from "path";
2372
+ var ALLOWED_EXTS = /* @__PURE__ */ new Set([".mp4", ".webm", ".mov"]);
2373
+ async function pushHookCommand(opts) {
2374
+ const token = resolveBridgeToken2(opts.token);
2375
+ if (!token) {
2376
+ throw new Error(
2377
+ "push-hook requires a bridge token. Run `storyforge login`, set BRIDGE_TOKEN, or pass --token <bearer>."
2378
+ );
2379
+ }
2380
+ const baseUrl = resolveBaseUrl(opts.baseUrl);
2381
+ if (opts.files.length === 0) throw new Error("no files to upload");
2382
+ log.info(`push-hook \xB7 slug=${opts.slug}`);
2383
+ log.info(`api \xB7 ${baseUrl}`);
2384
+ log.info(`files \xB7 ${opts.files.length}`);
2385
+ let succeeded = 0;
2386
+ let failed = 0;
2387
+ for (const filePath of opts.files) {
2388
+ const absPath = path7.resolve(filePath);
2389
+ if (!fs7.existsSync(absPath)) {
2390
+ log.warn(` \u2717 ${filePath} \u2014 not found`);
2391
+ failed += 1;
2392
+ continue;
2393
+ }
2394
+ const ext = path7.extname(absPath).toLowerCase();
2395
+ if (!ALLOWED_EXTS.has(ext)) {
2396
+ log.warn(` \u2717 ${filePath} \u2014 unsupported extension ${ext}`);
2397
+ failed += 1;
2398
+ continue;
2399
+ }
2400
+ const filename = path7.basename(absPath);
2401
+ const stat = fs7.statSync(absPath);
2402
+ const sizeMB = (stat.size / 1024 / 1024).toFixed(1);
2403
+ try {
2404
+ const buf = fs7.readFileSync(absPath);
2405
+ const contentType = ext === ".webm" ? "video/webm" : ext === ".mov" ? "video/quicktime" : "video/mp4";
2406
+ const mint = await mintUploadUrl(opts.slug, baseUrl, token, {
2407
+ kind: "hooks",
2408
+ filename,
2409
+ contentType
2410
+ });
2411
+ log.info(` \u2191 ${filename} (${sizeMB}M) \u279C ${mint.storjKey}`);
2412
+ await putToStorj(mint.signedUrl, buf, contentType);
2413
+ await postFinalize(opts.slug, baseUrl, token, {
2414
+ kind: "hooks",
2415
+ storjKey: mint.storjKey,
2416
+ filename
2417
+ });
2418
+ log.success(` \u2713 uploaded`);
2419
+ succeeded += 1;
2420
+ } catch (err) {
2421
+ log.warn(` \u2717 ${err.message}`);
2422
+ failed += 1;
2423
+ }
2424
+ }
2425
+ log.info(`done \xB7 ${succeeded} uploaded \xB7 ${failed} failed`);
2426
+ if (failed > 0 && succeeded === 0) {
2427
+ throw new Error("all hooks failed to upload");
2428
+ }
2429
+ }
2430
+ function resolveBridgeToken2(explicit) {
2431
+ if (explicit) return explicit;
2432
+ if (process.env.FORGE_BRIDGE_TOKEN) return process.env.FORGE_BRIDGE_TOKEN;
2433
+ if (process.env.BRIDGE_TOKEN) return process.env.BRIDGE_TOKEN;
2434
+ loadDotEnv();
2435
+ return process.env.FORGE_BRIDGE_TOKEN ?? process.env.BRIDGE_TOKEN ?? null;
2436
+ }
2437
+
2334
2438
  // src/index.ts
2335
- var VERSION = "0.9.0";
2439
+ var VERSION = "0.10.0";
2336
2440
  var HELP = `
2337
2441
  storyforge \u2014 local bridge for the Forge video production web app
2338
2442
 
@@ -2340,6 +2444,8 @@ Usage:
2340
2444
  storyforge Auto-link current folder, start bridge, open browser (default)
2341
2445
  storyforge render <slug> Pull a published project and render it locally to ./out.mp4
2342
2446
  storyforge push <slug> Upload locally-edited clips back to Storj
2447
+ storyforge push-hook <slug> <file> [<file>...]
2448
+ Upload standalone hook MP4(s) to a project on Storj
2343
2449
  storyforge login Log in to forge.algo-thinker.com
2344
2450
  storyforge doctor Probe local renderers (ffmpeg / python / manim / hyperframes)
2345
2451
  storyforge install-renderers Install missing renderers (interactive, asks per-tool)
@@ -2415,6 +2521,31 @@ async function main() {
2415
2521
  await installRenderersCommand({ yes });
2416
2522
  return;
2417
2523
  }
2524
+ if (firstArg === "push-hook") {
2525
+ const slug = args[1];
2526
+ if (!slug || slug.startsWith("--")) {
2527
+ console.error("storyforge push-hook: missing slug. Usage: storyforge push-hook <slug> <file> [<file>...]");
2528
+ process.exit(1);
2529
+ }
2530
+ const files = [];
2531
+ let i = 2;
2532
+ while (i < args.length && !args[i].startsWith("--")) {
2533
+ files.push(args[i]);
2534
+ i += 1;
2535
+ }
2536
+ if (files.length === 0) {
2537
+ console.error("storyforge push-hook: no files supplied. Usage: storyforge push-hook <slug> <file> [<file>...]");
2538
+ process.exit(1);
2539
+ }
2540
+ const opts2 = parseArgs(args.slice(i));
2541
+ await pushHookCommand({
2542
+ slug,
2543
+ files,
2544
+ baseUrl: typeof opts2["base-url"] === "string" ? opts2["base-url"] : void 0,
2545
+ token: typeof opts2.token === "string" ? opts2.token : void 0
2546
+ });
2547
+ return;
2548
+ }
2418
2549
  if (firstArg === "push") {
2419
2550
  const slug = args[1];
2420
2551
  if (!slug || slug.startsWith("--")) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "storyforge",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "StoryForge — local bridge for the Forge video production web app. Parallel clip-render orchestrator (Remotion 4 + Manim + HyperFrames + ffmpeg) + final video stitcher + dependency doctor.",
5
5
  "type": "module",
6
6
  "bin": {