storyboard-bridge 0.3.8 → 0.5.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.
Files changed (2) hide show
  1. package/index.mjs +63 -25
  2. package/package.json +1 -1
package/index.mjs CHANGED
@@ -73,7 +73,7 @@ const HF_SCRIPT = join(dirname(fileURLToPath(import.meta.url)), 'higgsfield_clie
73
73
  // prints raw text, and passing the flag makes it dump --help. Newer gemini supports it (wraps as
74
74
  // {response}) — opt in with GEMINI_JSON=1. extractText() handles wrapped OR raw either way.
75
75
  const USE_JSON = PROVIDER === 'claude' || process.env.GEMINI_JSON === '1';
76
- const JOB_TIMEOUT_MS = 300_000;
76
+ const JOB_TIMEOUT_MS = 900_000; // 15 min — kills a `claude -p` that runs this long (SIGTERM → "exited null")
77
77
 
78
78
  const log = (m) => console.log(`[bridge ${new Date().toISOString().slice(11, 19)}] ${m}`);
79
79
 
@@ -102,13 +102,25 @@ function runCli(bin, args, stdinInput, cwd) {
102
102
  return new Promise((resolve, reject) => {
103
103
  // On Windows the CLI is usually a `.cmd` shim (claude.cmd) that spawn() can't launch without a
104
104
  // shell; shell:true uses PATHEXT to resolve it. POSIX (Linux/WSL/macOS) spawns the binary directly.
105
+ const t0 = Date.now();
105
106
  const child = spawn(bin, args, { timeout: JOB_TIMEOUT_MS, shell: IS_WIN, cwd });
106
107
  let out = '';
107
108
  let err = '';
108
109
  child.stdout.on('data', (d) => (out += d));
109
110
  child.stderr.on('data', (d) => (err += d));
110
- child.on('error', reject);
111
- child.on('close', (code) => (code === 0 ? resolve(out) : reject(new Error(`${bin} exited ${code}: ${err.slice(0, 300)}`))));
111
+ // heartbeat: proves the job is alive and shows whether it's PRODUCING output or truly hung (stdout stuck at 0)
112
+ const hb = setInterval(() => log(` … ${bin} still running ${((Date.now() - t0) / 1000).toFixed(0)}s (stdout ${out.length}b, stderr ${err.length}b)`), 30_000);
113
+ child.on('error', (e) => { clearInterval(hb); reject(e); });
114
+ child.on('close', (code, signal) => {
115
+ clearInterval(hb);
116
+ const secs = ((Date.now() - t0) / 1000).toFixed(1);
117
+ if (code === 0) return resolve(out);
118
+ // code===null → killed by a SIGNAL (not a clean error). SIGTERM here = our JOB_TIMEOUT_MS hit; else OS/OOM.
119
+ const how = signal
120
+ ? `killed by ${signal}${signal === 'SIGTERM' ? ` — hit the ${JOB_TIMEOUT_MS / 1000}s job timeout (it was HANGING, not producing)` : ' (external kill — likely OS/out-of-memory)'}`
121
+ : `exited ${code}`;
122
+ reject(new Error(`${bin} ${how} after ${secs}s — produced ${out.length}b stdout; stderr: ${err.slice(0, 800).trim() || '(empty — nothing printed)'}`));
123
+ });
112
124
  // Feed the prompt via STDIN (not argv) — see runJob for why this matters on Windows.
113
125
  if (stdinInput != null) child.stdin.write(stdinInput);
114
126
  child.stdin.end();
@@ -281,14 +293,19 @@ async function handleHiggsfieldCli(msg, ws) {
281
293
  const tmp = [];
282
294
  const cleanup = () => { for (const f of tmp) unlink(f).catch(() => {}); };
283
295
  try {
284
- const images = Array.isArray(p.images) ? p.images : [];
285
- for (let i = 0; i < images.length; i++) {
286
- const f = join(tmpdir(), `sbhfcli_${id}_${i}.png`);
287
- await writeFile(f, Buffer.from(String(images[i]), 'base64'));
288
- tmp.push(f);
289
- }
290
- // Seedance multi-ref: upload each temp file → a medias[] array of role 'image', IN ORDER (= @imageN
291
- // order). The single --start-image/--image flags map everything to one "start_image" and reject >1.
296
+ // write the shipped bytes to temp files; the CLI AUTO-UPLOADS local paths given to its media flags,
297
+ // so we pass --image/--video directly (no manual `upload create`/--medias). Videos cap at 3 (Seedance).
298
+ const writeTmp = async (arr, ext) => {
299
+ const out = [];
300
+ for (let i = 0; i < arr.length; i++) {
301
+ const f = join(tmpdir(), `sbhfcli_${id}_${ext}${i}.${ext}`);
302
+ await writeFile(f, Buffer.from(String(arr[i]), 'base64'));
303
+ tmp.push(f); out.push(f);
304
+ }
305
+ return out;
306
+ };
307
+ const imageFiles = await writeTmp(Array.isArray(p.images) ? p.images : [], 'png');
308
+ const videoFiles = await writeTmp((Array.isArray(p.videos) ? p.videos : []).slice(0, 3), 'mp4');
292
309
  const runHf = (a) => new Promise((resolve, reject) => {
293
310
  const child = spawn(HF_CLI, a, { shell: IS_WIN, stdio: ['ignore', 'pipe', 'pipe'] });
294
311
  hfCliChildren.set(id, child);
@@ -298,20 +315,16 @@ async function handleHiggsfieldCli(msg, ws) {
298
315
  child.on('error', (e) => reject(new Error(`${HF_CLI}: ${e.message}`)));
299
316
  child.on('close', (code) => (code !== 0 ? reject(new Error(`higgsfield CLI exited ${code}: ${(errTail || out).slice(-8000)}`)) : resolve(out)));
300
317
  });
301
- const medias = [];
302
- for (const f of tmp) {
303
- const o = JSON.parse(await runHf(['upload', 'create', f, '--json']));
304
- if (!o?.id) throw new Error('higgsfield upload returned no id');
305
- medias.push({ role: 'image', data: { id: o.id, type: 'media_input' } }); // 'media_input' = uploaded file ('image' is rejected)
306
- }
318
+ const promptStr = String(p.prompt ?? '');
307
319
  const args = ['generate', 'create', String(p.slug || 'seedance_2_0'),
308
- '--prompt', String(p.prompt ?? ''),
320
+ '--prompt', promptStr.startsWith('@') ? ' ' + promptStr : promptStr, // leading '@' = "read from file" to the CLI; a space neutralizes it
309
321
  '--duration', String(Math.max(1, Math.round(p.durationSec || 5))),
310
322
  '--resolution', String(p.resolution || '720p'),
311
323
  '--aspect_ratio', String(p.aspectRatio || '16:9'),
312
324
  '--json', '--wait'];
313
325
  if (p.mode) args.push('--mode', String(p.mode));
314
- if (medias.length) args.push('--medias', JSON.stringify(medias));
326
+ for (const f of imageFiles) args.push('--image', f);
327
+ for (const f of videoFiles) args.push('--video', f);
315
328
  const out = await runHf(args);
316
329
  hfCliChildren.delete(id);
317
330
  cleanup();
@@ -325,6 +338,24 @@ async function handleHiggsfieldCli(msg, ws) {
325
338
  }
326
339
  }
327
340
 
341
+ // ---- disconnect-proof result delivery ----------------------------------------
342
+ // claude -p runs independently of the socket, so a WS drop mid-job must NOT lose the answer. Job results go
343
+ // through deliver(): sent on the live socket if open, else QUEUED in an outbox and flushed when we reconnect.
344
+ // The backend keeps the job pending across the drop and resolves it by id on whichever socket the result
345
+ // arrives on — so a deploy/network blip no longer turns a real answer into a mock fallback.
346
+ let activeWs = null;
347
+ const outbox = []; // JSON frame strings that couldn't be sent because the socket was down
348
+ function deliver(frame) {
349
+ const s = JSON.stringify(frame);
350
+ if (activeWs && activeWs.readyState === 1 /* WebSocket.OPEN */) { try { activeWs.send(s); return; } catch { /* fall through → queue */ } }
351
+ outbox.push(s);
352
+ }
353
+ function flushOutbox() {
354
+ if (!activeWs || activeWs.readyState !== 1) return;
355
+ while (outbox.length) { try { activeWs.send(outbox.shift()); } catch { break; } }
356
+ if (outbox.length === 0) return;
357
+ }
358
+
328
359
  // ---- connection (reconnects forever with backoff) ----
329
360
  let backoff = 1000;
330
361
  function connect() {
@@ -333,6 +364,8 @@ function connect() {
333
364
 
334
365
  ws.on('open', async () => {
335
366
  backoff = 1000;
367
+ activeWs = ws; // this socket is now the delivery channel
368
+ flushOutbox(); // re-send any results that completed while we were disconnected
336
369
  const providers = {
337
370
  claude: await cliAvailable(BIN_FOR.claude),
338
371
  gemini: await cliAvailable(BIN_FOR.gemini),
@@ -361,20 +394,23 @@ function connect() {
361
394
  }
362
395
  } else if (msg.type === 'job' && msg.id) {
363
396
  const t0 = Date.now();
397
+ const promptLen = String(msg.prompt ?? '').length;
398
+ const imgCount = Array.isArray(msg.images) ? msg.images.length : 0;
399
+ log(`job ${msg.id} → running (prompt ${(promptLen / 1000).toFixed(1)}k chars${imgCount ? `, ${imgCount} image${imgCount === 1 ? '' : 's'}` : ''})`);
364
400
  try {
365
401
  const text = await runJob(String(msg.prompt ?? ''), Array.isArray(msg.images) ? msg.images : null);
366
- ws.send(JSON.stringify({ type: 'result', id: msg.id, text }));
367
- log(`job ${msg.id} ✓ (${Date.now() - t0}ms)`);
402
+ deliver({ type: 'result', id: msg.id, text }); // survives a mid-job reconnect (queued + flushed)
403
+ log(`job ${msg.id} ✓ (${Date.now() - t0}ms)${activeWs?.readyState === 1 ? '' : ' [queued — offline]'}`);
368
404
  } catch (e) {
369
- ws.send(JSON.stringify({ type: 'error', id: msg.id, message: String(e?.message ?? e) }));
405
+ deliver({ type: 'error', id: msg.id, message: String(e?.message ?? e) });
370
406
  log(`job ${msg.id} ✗ ${e?.message ?? e}`);
371
407
  }
372
408
  } else if (msg.type === 'file' && msg.id) {
373
409
  try {
374
410
  const r = await handleFile(msg);
375
- ws.send(JSON.stringify({ type: 'fileResult', id: msg.id, ...r }));
411
+ deliver({ type: 'fileResult', id: msg.id, ...r });
376
412
  } catch (e) {
377
- ws.send(JSON.stringify({ type: 'fileError', id: msg.id, message: String(e?.message ?? e) }));
413
+ deliver({ type: 'fileError', id: msg.id, message: String(e?.message ?? e) });
378
414
  log(`file ${msg.op} ${msg.id} ✗ ${e?.message ?? e}`);
379
415
  }
380
416
  } else if (msg.type === 'higgsfield' && msg.id) {
@@ -393,7 +429,9 @@ function connect() {
393
429
  });
394
430
 
395
431
  ws.on('close', () => {
396
- log(`disconnected — retrying in ${Math.round(backoff / 1000)}s`);
432
+ if (activeWs === ws) activeWs = null; // stop sending on the dead socket; results queue until we reconnect
433
+ const queued = outbox.length ? ` (${outbox.length} result(s) queued for re-delivery)` : '';
434
+ log(`disconnected — retrying in ${Math.round(backoff / 1000)}s${queued}`);
397
435
  setTimeout(connect, backoff);
398
436
  backoff = Math.min(backoff * 2, 30000);
399
437
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "storyboard-bridge",
3
- "version": "0.3.8",
3
+ "version": "0.5.0",
4
4
  "description": "Desktop bridge that powers a hosted Storyboard AI webapp with your own local Claude Code / Gemini CLI login (no API key).",
5
5
  "type": "module",
6
6
  "bin": {