storyboard-bridge 0.7.3 → 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.
package/index.mjs CHANGED
@@ -319,6 +319,9 @@ function handleTryinfer(msg, ws) {
319
319
  const p = msg.params || {};
320
320
  try {
321
321
  const args = ['-u', TF_SCRIPT, '--emit-json'];
322
+ // Pin this render to the worker the backend leased. Applies to RESUMES too: a tryinfer task is only
323
+ // visible to the session that created it, so polling it from another context returns nothing.
324
+ if (p.contextId) args.push('--context', String(p.contextId));
322
325
  if (p.resumeTaskId) {
323
326
  args.push('--resume-task', String(p.resumeTaskId)); // poll an existing task, no submit
324
327
  } else {
@@ -327,6 +330,9 @@ function handleTryinfer(msg, ws) {
327
330
  '--duration', String(Math.max(1, Math.round(p.durationSec || 5))),
328
331
  '--aspect', String(p.aspectRatio || '1:1'),
329
332
  '--resolution', String(p.resolution || '1080p'));
333
+ // tryinfer-side model name ('seedance-2.5', 'seedance-2.0-pro', 'seedream-5.0-pro'). Older backends
334
+ // don't send it — then the client keeps its capability-aware default, so both directions stay compatible.
335
+ if (p.model) args.push('--model', String(p.model));
330
336
  if (p.audio === false) args.push('--no-audio');
331
337
  if (p.numImages && p.numImages > 1) args.push('--num', String(p.numImages)); // Seedream image batch (2x)
332
338
  for (const u of (Array.isArray(p.imageUrls) ? p.imageUrls : [])) args.push('--image-url', String(u));
@@ -477,7 +483,7 @@ function connect() {
477
483
  gemini: await cliAvailable(BIN_FOR.gemini),
478
484
  };
479
485
  const hfCli = await cliAvailable(HF_CLI); // official `higgsfield` CLI present + runnable → can serve credit-billed renders
480
- ws.send(JSON.stringify({ type: 'hello', version: VERSION, provider: PROVIDER, providers, files: !!FILES_ROOT, higgsfield: HIGGSFIELD, higgsfieldCli: hfCli, infer: INFER, inflight: [...inflightJobs] }));
486
+ ws.send(JSON.stringify({ type: 'hello', version: VERSION, provider: PROVIDER, providers, files: !!FILES_ROOT, higgsfield: HIGGSFIELD, higgsfieldCli: hfCli, infer: INFER, tfWorkers: true, inflight: [...inflightJobs] }));
481
487
  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})` : ''}`);
482
488
  if (!providers[PROVIDER]) {
483
489
  log(`WARNING: '${BIN}' was not found / not runnable. Install it and log in, or pass --provider/--*_BIN. Jobs will fail until then.`);
@@ -543,6 +549,22 @@ function connect() {
543
549
  } else if (msg.type === 'tryinfer' && msg.id) {
544
550
  if (!INFER) ws.send(JSON.stringify({ type: 'tryinferEvent', id: msg.id, event: 'error', reason: 'disabled', detail: 'this bridge was not started with --infer' }));
545
551
  else { log(`tryinfer ${msg.id} → running`); handleTryinfer(msg, ws); }
552
+ } else if (msg.type === 'tryinfer-workers' && msg.id) {
553
+ // Pool discovery: report every open tryinfer tab with its browserContextId. Cheap + read-only, so
554
+ // the backend can call it before each lease and track a pool whose membership keeps changing.
555
+ if (!INFER) ws.send(JSON.stringify({ type: 'tryinferWorkers', id: msg.id, workers: [], error: 'this bridge was not started with --infer' }));
556
+ else {
557
+ const child = spawn(PYTHON_BIN, ['-u', TF_SCRIPT, '--list-workers'], { env: { ...process.env, CDP_HTTP }, stdio: ['ignore', 'pipe', 'pipe'] });
558
+ let out = '', err = '';
559
+ child.stdout.on('data', (d) => { out += d.toString(); });
560
+ child.stderr.on('data', (d) => { err = (err + d.toString()).slice(-2000); });
561
+ child.on('error', (e) => ws.send(JSON.stringify({ type: 'tryinferWorkers', id: msg.id, workers: [], error: `${PYTHON_BIN}: ${e.message}` })));
562
+ child.on('close', () => {
563
+ let parsed = null;
564
+ try { parsed = JSON.parse(out.trim().split('\n').filter(Boolean).pop() || 'null'); } catch { /* fall through */ }
565
+ ws.send(JSON.stringify({ type: 'tryinferWorkers', id: msg.id, workers: parsed?.workers ?? [], ...(parsed ? {} : { error: err || 'no worker list from the client' }) }));
566
+ });
567
+ }
546
568
  } else if (msg.type === 'tryinfer-cancel' && msg.id) {
547
569
  const child = tfChildren.get(msg.id);
548
570
  if (child) { log(`tryinfer ${msg.id} → cancel`); try { child.kill('SIGTERM'); } catch { /* already gone */ } tfChildren.delete(msg.id); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "storyboard-bridge",
3
- "version": "0.7.3",
3
+ "version": "0.9.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": {
@@ -128,33 +128,88 @@ def _fetch_expr(method, url, body):
128
128
  _CTX_DEAD = ("navigated", "context", "closed", "-32000", "detached", "Session with given id")
129
129
 
130
130
 
131
+ # ---- worker discovery ----
132
+ # Every logged-in tryinfer tab is an interchangeable WORKER. A worker is identified by its
133
+ # browserContextId — the isolated session (own cookies), which is what a separate Chrome profile or a temp
134
+ # context actually is. We deliberately do NOT identify workers by account/email: the pool is anonymous and
135
+ # its membership changes as profiles/contexts come and go.
136
+ #
137
+ # NOTE the gotcha this exists to avoid: /json/list is the easy endpoint but does NOT carry
138
+ # browserContextId, so it cannot tell two tryinfer tabs apart. Target.getTargets does.
139
+ def list_workers(cdp_http, match="tryinfer"):
140
+ """[{contextId, targetId, url, title}] — one entry per open tryinfer tab, newest Chrome state."""
141
+ ver = http_json(f"{cdp_http}/json/version")
142
+ cdp = CDP(ver["webSocketDebuggerUrl"])
143
+ try:
144
+ infos = cdp.call("Target.getTargets", timeout=15).get("targetInfos", [])
145
+ finally:
146
+ cdp.close()
147
+ out = []
148
+ for t in infos:
149
+ if t.get("type") == "page" and match in (t.get("url") or ""):
150
+ out.append({"contextId": t.get("browserContextId"), "targetId": t.get("targetId"),
151
+ "url": t.get("url"), "title": t.get("title")})
152
+ return out
153
+
154
+
131
155
  class BrowserSession:
132
- def __init__(self, cdp_http, match):
156
+ def __init__(self, cdp_http, match, context=None):
133
157
  self.cdp_http = cdp_http
134
158
  self.match = match
159
+ # when set, attach ONLY to a tab inside this browser context — that's how the backend pins a job to
160
+ # the worker it leased. Without it we keep the legacy first-match-by-URL behaviour (single worker).
161
+ self.context = context
135
162
  ver = http_json(f"{cdp_http}/json/version")
136
163
  log(f"Connected to {ver.get('Browser')}")
137
164
  self.browser_ws = ver["webSocketDebuggerUrl"]
138
165
  self.cdp = None
139
166
  self._attach()
140
167
 
168
+ def _resolve_tab(self):
169
+ """(targetId, url) of the tab to drive. Context-pinned when --context was given, else first URL match."""
170
+ if self.context:
171
+ for w in list_workers(self.cdp_http, self.match):
172
+ if w["contextId"] == self.context:
173
+ return w["targetId"], w["url"]
174
+ # the context is gone (a temp context dies with Chrome, a profile window can be closed) — say so
175
+ # precisely, so the backend can drop that worker from the pool instead of guessing.
176
+ raise RuntimeError(f"WORKER_GONE: no tryinfer tab in browser context {self.context}")
177
+ for t in http_json(f"{self.cdp_http}/json/list"):
178
+ if t.get("type") == "page" and self.match in (t.get("url") or ""):
179
+ return t["id"], t["url"]
180
+ return None, None
181
+
141
182
  def _attach(self):
142
183
  """(Re)resolve the matching tab and attach a fresh Runtime session. Safe to call repeatedly — used
143
184
  both at startup and to recover after the Studio tab navigates mid-poll (which kills the context)."""
144
185
  if self.cdp:
145
186
  try: self.cdp.close()
146
187
  except Exception: pass
147
- tid = url = None
148
- for t in http_json(f"{self.cdp_http}/json/list"):
149
- if t.get("type") == "page" and self.match in (t.get("url") or ""):
150
- tid, url = t["id"], t["url"]
151
- break
188
+ tid, url = self._resolve_tab()
152
189
  if not tid:
153
190
  raise RuntimeError(f"No open tab whose URL contains '{self.match}'. Open tryinfer.com and log in.")
154
- self.cdp = CDP(self.browser_ws)
155
- self.session_id = self.cdp.call("Target.attachToTarget", {"targetId": tid, "flatten": True})["sessionId"]
156
- self.cdp.call("Runtime.enable", session_id=self.session_id)
157
- log(f"Routing API calls through tab: {url}")
191
+ # A HOST tab left in the background gets DISCARDED/frozen by Chrome (no live renderer). Attaching still
192
+ # works, but Runtime.enable then hangs waiting for a renderer → "Timed out waiting for CDP response".
193
+ # Activate (foreground/wake) the tab first, and retry attach+enable a couple times so a just-woken tab
194
+ # gets a moment to spin its renderer back up.
195
+ last = None
196
+ for attempt in range(3):
197
+ self.cdp = CDP(self.browser_ws)
198
+ try:
199
+ try: self.cdp.call("Target.activateTarget", {"targetId": tid}, timeout=10) # wake a discarded tab
200
+ except Exception: pass
201
+ self.session_id = self.cdp.call("Target.attachToTarget", {"targetId": tid, "flatten": True}, timeout=15)["sessionId"]
202
+ self.cdp.call("Runtime.enable", session_id=self.session_id, timeout=20)
203
+ log(f"Routing API calls through tab: {url}")
204
+ return
205
+ except Exception as e:
206
+ last = e
207
+ try: self.cdp.close()
208
+ except Exception: pass
209
+ if attempt < 2:
210
+ log(f" CDP attach stalled (tab may be waking) — retrying… ({e})")
211
+ time.sleep(2)
212
+ raise RuntimeError(f"couldn't attach to the tryinfer tab — is it awake + logged in? ({last})")
158
213
 
159
214
  def request(self, method, url, body=None, timeout=120, retries=4):
160
215
  last = None
@@ -302,10 +357,22 @@ def main():
302
357
  ap.add_argument("--resume-task", default=None, help="skip submit; just poll+result an existing task id")
303
358
  ap.add_argument("--emit-json", action="store_true", help="NDJSON events on stdout, human logs on stderr")
304
359
  ap.add_argument("--match", default="tryinfer", help="substring of the logged-in tab's URL")
360
+ ap.add_argument("--context", default=None,
361
+ help="pin to the tryinfer tab in this browserContextId (a pool worker). Omit = first URL match.")
362
+ ap.add_argument("--list-workers", action="store_true",
363
+ help="print [{contextId,targetId,url,title}] for every open tryinfer tab and exit (discovery)")
305
364
  ap.add_argument("--host", default="localhost")
306
365
  ap.add_argument("--port", type=int, default=9222)
307
366
  args = ap.parse_args()
308
367
 
368
+ # discovery mode: no render, no tab attach — just report the pool as Chrome currently sees it.
369
+ if args.list_workers:
370
+ try:
371
+ print(json.dumps({"workers": list_workers(f"http://{args.host}:{args.port}", args.match)}))
372
+ except Exception as e:
373
+ print(json.dumps({"workers": [], "error": str(e)}))
374
+ return
375
+
309
376
  if args.emit_json:
310
377
  global _JSON_OUT
311
378
  _JSON_OUT = sys.stdout
@@ -318,7 +385,7 @@ def main():
318
385
  try:
319
386
  # inside the try so a connect/tab failure ("No open tab … Open tryinfer.com and log in") surfaces as a
320
387
  # clean error EVENT the node can show — not a raw Python traceback dumped to the UI.
321
- sess = BrowserSession(f"http://{args.host}:{args.port}", args.match)
388
+ sess = BrowserSession(f"http://{args.host}:{args.port}", args.match, context=args.context)
322
389
  if args.resume_task:
323
390
  task_id = args.resume_task
324
391
  log(f"resuming task {task_id}")