storyboard-bridge 0.3.7 → 0.4.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 +122 -10
- package/package.json +1 -1
package/index.mjs
CHANGED
|
@@ -40,6 +40,8 @@ const VERSION = createRequire(import.meta.url)('./package.json').version;
|
|
|
40
40
|
// in-flight Higgsfield render children, keyed by job id — so a `higgsfield-cancel` can kill the right one
|
|
41
41
|
// (the backend sends it when the user cancels, freeing the single render slot instead of leaving it running).
|
|
42
42
|
const hfChildren = new Map();
|
|
43
|
+
// in-flight official-CLI render children (the `higgsfield-cli` credit-billed path — separate from the CDP one).
|
|
44
|
+
const hfCliChildren = new Map();
|
|
43
45
|
|
|
44
46
|
// ---- config ----
|
|
45
47
|
const arg = (name, fallback) => {
|
|
@@ -63,6 +65,8 @@ const HIGGSFIELD = process.argv.includes('--higgsfield') || process.env.STORYBOA
|
|
|
63
65
|
// Opt out with --no-own-rights or STORYBOARD_OWN_RIGHTS=0.
|
|
64
66
|
const OWN_RIGHTS = !(process.argv.includes('--no-own-rights') || process.env.STORYBOARD_OWN_RIGHTS === '0');
|
|
65
67
|
const PYTHON_BIN = arg('python-bin', process.env.PYTHON_BIN || (IS_WIN ? 'python.exe' : 'python3'));
|
|
68
|
+
// the official Higgsfield CLI (credit-billed i2v). Available iff installed + `higgsfield auth login` done.
|
|
69
|
+
const HF_CLI = arg('higgsfield-cli-bin', process.env.HIGGSFIELD_CLI_BIN || 'higgsfield');
|
|
66
70
|
const CDP_HTTP = arg('cdp', process.env.CDP_HTTP || 'http://localhost:9222'); // the debug Chrome endpoint
|
|
67
71
|
const HF_SCRIPT = join(dirname(fileURLToPath(import.meta.url)), 'higgsfield_client.py');
|
|
68
72
|
// claude supports `--output-format json` (wraps as {result}); gemini's CLI (<=0.1.x) does NOT — it
|
|
@@ -229,12 +233,12 @@ async function handleHiggsfield(msg, ws) {
|
|
|
229
233
|
ws.send(JSON.stringify({ type: 'hfEvent', id, ...ev })); // forward progress/verified/done/error
|
|
230
234
|
}
|
|
231
235
|
});
|
|
232
|
-
child.stderr.on('data', (d) => { stderrTail = (stderrTail + d.toString()).slice(-
|
|
236
|
+
child.stderr.on('data', (d) => { stderrTail = (stderrTail + d.toString()).slice(-8000); });
|
|
233
237
|
child.on('error', (e) => { hfChildren.delete(id); cleanup(); ws.send(JSON.stringify({ type: 'hfEvent', id, event: 'error', reason: 'spawn', detail: `${PYTHON_BIN}: ${e.message}` })); });
|
|
234
238
|
child.on('close', (code) => {
|
|
235
239
|
hfChildren.delete(id);
|
|
236
240
|
cleanup();
|
|
237
|
-
if (!sawDone && code !== 0) ws.send(JSON.stringify({ type: 'hfEvent', id, event: 'error', reason: 'exit', detail: `python exited ${code}: ${stderrTail
|
|
241
|
+
if (!sawDone && code !== 0) ws.send(JSON.stringify({ type: 'hfEvent', id, event: 'error', reason: 'exit', detail: `python exited ${code}: ${stderrTail}` }));
|
|
238
242
|
});
|
|
239
243
|
} catch (e) {
|
|
240
244
|
cleanup();
|
|
@@ -242,6 +246,104 @@ async function handleHiggsfield(msg, ws) {
|
|
|
242
246
|
}
|
|
243
247
|
}
|
|
244
248
|
|
|
249
|
+
// Pull the result video URL out of the CLI's `--json` output. The exact schema isn't publicly documented,
|
|
250
|
+
// so scan defensively: each line (the CLI may print logs then a final JSON object), then known fields, then
|
|
251
|
+
// any media URL. On a miss the caller returns the raw tail in the error so we can lock the field fast.
|
|
252
|
+
function extractCliVideoUrl(out) {
|
|
253
|
+
const fromObj = (o) => {
|
|
254
|
+
if (!o || typeof o !== 'object') return null;
|
|
255
|
+
if (Array.isArray(o)) { for (const r of o) { const u = fromObj(r); if (u) return u; } return null; } // `generate create --json` returns [{ result_url }, …]
|
|
256
|
+
for (const k of ['result_url', 'output_url', 'video_url', 'url', 'resultUrl', 'outputUrl']) {
|
|
257
|
+
if (typeof o[k] === 'string' && /^https?:\/\//.test(o[k])) return o[k];
|
|
258
|
+
}
|
|
259
|
+
for (const arr of [o.results, o.outputs, o.assets]) {
|
|
260
|
+
if (Array.isArray(arr)) for (const r of arr) {
|
|
261
|
+
if (typeof r === 'string' && /^https?:\/\//.test(r)) return r;
|
|
262
|
+
const u = fromObj(r); if (u) return u;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return fromObj(o.result) || fromObj(o.job) || fromObj(o.data) || null;
|
|
266
|
+
};
|
|
267
|
+
const lines = out.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
268
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
269
|
+
try { const u = fromObj(JSON.parse(lines[i])); if (u) return u; } catch { /* not json */ }
|
|
270
|
+
}
|
|
271
|
+
try { const u = fromObj(JSON.parse(out)); if (u) return u; } catch { /* not one json blob */ }
|
|
272
|
+
const m = out.match(/https?:\/\/[^\s"']+\.(?:mp4|mov|webm|m4v)/i);
|
|
273
|
+
return m ? m[0] : null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Run a CREDIT-BILLED Seedance render via the official `higgsfield` CLI (separate from handleHiggsfield's CDP
|
|
277
|
+
// path). The CLI blocks until done (--wait) and prints the result as JSON; we parse the URL and reply 'done'.
|
|
278
|
+
async function handleHiggsfieldCli(msg, ws) {
|
|
279
|
+
const id = msg.id;
|
|
280
|
+
const p = msg.params || {};
|
|
281
|
+
const tmp = [];
|
|
282
|
+
const cleanup = () => { for (const f of tmp) unlink(f).catch(() => {}); };
|
|
283
|
+
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.
|
|
292
|
+
const runHf = (a) => new Promise((resolve, reject) => {
|
|
293
|
+
const child = spawn(HF_CLI, a, { shell: IS_WIN, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
294
|
+
hfCliChildren.set(id, child);
|
|
295
|
+
let out = '', errTail = '';
|
|
296
|
+
child.stdout.on('data', (d) => { out += d.toString(); });
|
|
297
|
+
child.stderr.on('data', (d) => { errTail = (errTail + d.toString()).slice(-8000); });
|
|
298
|
+
child.on('error', (e) => reject(new Error(`${HF_CLI}: ${e.message}`)));
|
|
299
|
+
child.on('close', (code) => (code !== 0 ? reject(new Error(`higgsfield CLI exited ${code}: ${(errTail || out).slice(-8000)}`)) : resolve(out)));
|
|
300
|
+
});
|
|
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
|
+
}
|
|
307
|
+
const promptStr = String(p.prompt ?? '');
|
|
308
|
+
const args = ['generate', 'create', String(p.slug || 'seedance_2_0'),
|
|
309
|
+
'--prompt', promptStr.startsWith('@') ? ' ' + promptStr : promptStr, // leading '@' = "read from file" to the CLI; a space neutralizes it
|
|
310
|
+
'--duration', String(Math.max(1, Math.round(p.durationSec || 5))),
|
|
311
|
+
'--resolution', String(p.resolution || '720p'),
|
|
312
|
+
'--aspect_ratio', String(p.aspectRatio || '16:9'),
|
|
313
|
+
'--json', '--wait'];
|
|
314
|
+
if (p.mode) args.push('--mode', String(p.mode));
|
|
315
|
+
if (medias.length) args.push('--medias', JSON.stringify(medias));
|
|
316
|
+
const out = await runHf(args);
|
|
317
|
+
hfCliChildren.delete(id);
|
|
318
|
+
cleanup();
|
|
319
|
+
const url = extractCliVideoUrl(out);
|
|
320
|
+
if (url) ws.send(JSON.stringify({ type: 'hfCliEvent', id, event: 'done', videoUrl: url }));
|
|
321
|
+
else ws.send(JSON.stringify({ type: 'hfCliEvent', id, event: 'error', reason: 'parse', detail: `no result URL in CLI output: ${out.slice(-8000)}` }));
|
|
322
|
+
} catch (e) {
|
|
323
|
+
hfCliChildren.delete(id);
|
|
324
|
+
cleanup();
|
|
325
|
+
ws.send(JSON.stringify({ type: 'hfCliEvent', id, event: 'error', reason: 'bridge', detail: String(e?.message ?? e) }));
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ---- disconnect-proof result delivery ----------------------------------------
|
|
330
|
+
// claude -p runs independently of the socket, so a WS drop mid-job must NOT lose the answer. Job results go
|
|
331
|
+
// through deliver(): sent on the live socket if open, else QUEUED in an outbox and flushed when we reconnect.
|
|
332
|
+
// The backend keeps the job pending across the drop and resolves it by id on whichever socket the result
|
|
333
|
+
// arrives on — so a deploy/network blip no longer turns a real answer into a mock fallback.
|
|
334
|
+
let activeWs = null;
|
|
335
|
+
const outbox = []; // JSON frame strings that couldn't be sent because the socket was down
|
|
336
|
+
function deliver(frame) {
|
|
337
|
+
const s = JSON.stringify(frame);
|
|
338
|
+
if (activeWs && activeWs.readyState === 1 /* WebSocket.OPEN */) { try { activeWs.send(s); return; } catch { /* fall through → queue */ } }
|
|
339
|
+
outbox.push(s);
|
|
340
|
+
}
|
|
341
|
+
function flushOutbox() {
|
|
342
|
+
if (!activeWs || activeWs.readyState !== 1) return;
|
|
343
|
+
while (outbox.length) { try { activeWs.send(outbox.shift()); } catch { break; } }
|
|
344
|
+
if (outbox.length === 0) return;
|
|
345
|
+
}
|
|
346
|
+
|
|
245
347
|
// ---- connection (reconnects forever with backoff) ----
|
|
246
348
|
let backoff = 1000;
|
|
247
349
|
function connect() {
|
|
@@ -250,12 +352,15 @@ function connect() {
|
|
|
250
352
|
|
|
251
353
|
ws.on('open', async () => {
|
|
252
354
|
backoff = 1000;
|
|
355
|
+
activeWs = ws; // this socket is now the delivery channel
|
|
356
|
+
flushOutbox(); // re-send any results that completed while we were disconnected
|
|
253
357
|
const providers = {
|
|
254
358
|
claude: await cliAvailable(BIN_FOR.claude),
|
|
255
359
|
gemini: await cliAvailable(BIN_FOR.gemini),
|
|
256
360
|
};
|
|
257
|
-
|
|
258
|
-
|
|
361
|
+
const hfCli = await cliAvailable(HF_CLI); // official `higgsfield` CLI present + runnable → can serve credit-billed renders
|
|
362
|
+
ws.send(JSON.stringify({ type: 'hello', version: VERSION, provider: PROVIDER, providers, files: !!FILES_ROOT, higgsfield: HIGGSFIELD, higgsfieldCli: hfCli }));
|
|
363
|
+
log(`connected to ${url} — serving jobs with: ${PROVIDER} (${BIN})${FILES_ROOT ? `; files → ${FILES_ROOT}` : ''}${HIGGSFIELD ? `; HIGGSFIELD HOST (python: ${PYTHON_BIN}, cdp: ${CDP_HTTP})` : ''}${hfCli ? `; HIGGSFIELD CLI (${HF_CLI})` : ''}`);
|
|
259
364
|
if (!providers[PROVIDER]) {
|
|
260
365
|
log(`WARNING: '${BIN}' was not found / not runnable. Install it and log in, or pass --provider/--*_BIN. Jobs will fail until then.`);
|
|
261
366
|
}
|
|
@@ -279,18 +384,18 @@ function connect() {
|
|
|
279
384
|
const t0 = Date.now();
|
|
280
385
|
try {
|
|
281
386
|
const text = await runJob(String(msg.prompt ?? ''), Array.isArray(msg.images) ? msg.images : null);
|
|
282
|
-
|
|
283
|
-
log(`job ${msg.id} ✓ (${Date.now() - t0}ms)`);
|
|
387
|
+
deliver({ type: 'result', id: msg.id, text }); // survives a mid-job reconnect (queued + flushed)
|
|
388
|
+
log(`job ${msg.id} ✓ (${Date.now() - t0}ms)${activeWs?.readyState === 1 ? '' : ' [queued — offline]'}`);
|
|
284
389
|
} catch (e) {
|
|
285
|
-
|
|
390
|
+
deliver({ type: 'error', id: msg.id, message: String(e?.message ?? e) });
|
|
286
391
|
log(`job ${msg.id} ✗ ${e?.message ?? e}`);
|
|
287
392
|
}
|
|
288
393
|
} else if (msg.type === 'file' && msg.id) {
|
|
289
394
|
try {
|
|
290
395
|
const r = await handleFile(msg);
|
|
291
|
-
|
|
396
|
+
deliver({ type: 'fileResult', id: msg.id, ...r });
|
|
292
397
|
} catch (e) {
|
|
293
|
-
|
|
398
|
+
deliver({ type: 'fileError', id: msg.id, message: String(e?.message ?? e) });
|
|
294
399
|
log(`file ${msg.op} ${msg.id} ✗ ${e?.message ?? e}`);
|
|
295
400
|
}
|
|
296
401
|
} else if (msg.type === 'higgsfield' && msg.id) {
|
|
@@ -300,11 +405,18 @@ function connect() {
|
|
|
300
405
|
// user cancelled → stop this render so the single account slot frees (the python client honours SIGTERM)
|
|
301
406
|
const child = hfChildren.get(msg.id);
|
|
302
407
|
if (child) { log(`higgsfield ${msg.id} → cancel`); try { child.kill('SIGTERM'); } catch { /* already gone */ } hfChildren.delete(msg.id); }
|
|
408
|
+
} else if (msg.type === 'higgsfield-cli' && msg.id) {
|
|
409
|
+
log(`higgsfield-cli ${msg.id} → running`); handleHiggsfieldCli(msg, ws);
|
|
410
|
+
} else if (msg.type === 'higgsfield-cli-cancel' && msg.id) {
|
|
411
|
+
const child = hfCliChildren.get(msg.id);
|
|
412
|
+
if (child) { log(`higgsfield-cli ${msg.id} → cancel`); try { child.kill('SIGTERM'); } catch { /* already gone */ } hfCliChildren.delete(msg.id); }
|
|
303
413
|
}
|
|
304
414
|
});
|
|
305
415
|
|
|
306
416
|
ws.on('close', () => {
|
|
307
|
-
|
|
417
|
+
if (activeWs === ws) activeWs = null; // stop sending on the dead socket; results queue until we reconnect
|
|
418
|
+
const queued = outbox.length ? ` (${outbox.length} result(s) queued for re-delivery)` : '';
|
|
419
|
+
log(`disconnected — retrying in ${Math.round(backoff / 1000)}s${queued}`);
|
|
308
420
|
setTimeout(connect, backoff);
|
|
309
421
|
backoff = Math.min(backoff * 2, 30000);
|
|
310
422
|
});
|
package/package.json
CHANGED