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.
- package/index.mjs +63 -25
- 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 =
|
|
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
|
-
|
|
111
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
|
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',
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
411
|
+
deliver({ type: 'fileResult', id: msg.id, ...r });
|
|
376
412
|
} catch (e) {
|
|
377
|
-
|
|
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
|
-
|
|
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