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 +23 -1
- package/package.json +1 -1
- package/tryinfer_client.py +78 -11
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
package/tryinfer_client.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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}")
|