storyboard-bridge 0.2.0 → 0.3.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.
@@ -0,0 +1,553 @@
1
+ """
2
+ Higgsfield unofficial client.
3
+
4
+ Drives an already-open, logged-in higgsfield.ai tab in a Chrome started with
5
+ --remote-debugging-port=9222, over the Chrome DevTools Protocol.
6
+
7
+ WHY this design (transport = the real browser, not impersonation):
8
+ - The Higgsfield API (fnf.higgsfield.ai) is behind DataDome. A TLS-impersonating
9
+ HTTP client (curl_cffi) works briefly, but DataDome risk-scores it and starts
10
+ returning 403 captcha challenges after sustained use. So instead we run the API
11
+ calls AS in-page fetch() via CDP (BrowserSession): the request originates from
12
+ the real logged-in tab, carrying genuine Chrome TLS + the JS-earned (HttpOnly)
13
+ datadome cookie, so DataDome treats it like the app's own calls. CORS is fine
14
+ because we borrow the higgsfield.ai origin the server already whitelists.
15
+ - The bearer is minted inline per request via window.Clerk.session.getToken().
16
+ - Only the presigned cloudfront PUT (image upload) and the public video download
17
+ stay on curl_cffi — neither touches the DataDome-protected origin.
18
+
19
+ RUN (from WSL, using Windows Python so localhost:9222 is reachable natively):
20
+ python.exe "$(wslpath -w scripts/higgsfield/higgsfield_client.py)"
21
+ or just: bash scripts/higgsfield/run.sh
22
+
23
+ Deps (Windows Python): curl_cffi websocket-client
24
+ """
25
+
26
+ import argparse
27
+ import json
28
+ import os
29
+ import sys
30
+ import time
31
+
32
+ import websocket # websocket-client
33
+ from curl_cffi import requests as cffi_requests
34
+
35
+ # Windows consoles default to cp1252 and choke on the emoji in our logs.
36
+ for _s in (sys.stdout, sys.stderr):
37
+ try:
38
+ _s.reconfigure(encoding="utf-8")
39
+ except Exception:
40
+ pass
41
+
42
+ # --emit-json mode: machine-readable NDJSON progress on the REAL stdout, while all
43
+ # the human print()s get redirected to stderr (set up in main). The backend
44
+ # HiggsfieldProvider parses these events to drive the node's live status.
45
+ _JSON_OUT = None
46
+
47
+
48
+ def event(**fields):
49
+ if _JSON_OUT is not None:
50
+ _JSON_OUT.write(json.dumps(fields) + "\n")
51
+ _JSON_OUT.flush()
52
+
53
+
54
+ class ImageBlocked(Exception):
55
+ """Verification returned a deterministic block (e.g. nsfw) — do NOT retry."""
56
+ def __init__(self, status):
57
+ super().__init__(f"blocked: {status}")
58
+ self.status = status
59
+
60
+
61
+ class VerifyTimeout(Exception):
62
+ """ip_check didn't finish within the per-attempt window — retry is worthwhile."""
63
+
64
+ # ==========================================
65
+ # CONFIG
66
+ # ==========================================
67
+ CDP_HTTP = os.environ.get("CDP_HTTP", "http://localhost:9222")
68
+ GEN_BASE = "https://fnf.higgsfield.ai"
69
+ IMPERSONATE = "chrome131" # match a recent real Chrome TLS fingerprint
70
+ OUTPUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "output")
71
+
72
+ # Defaults (overridable via CLI)
73
+ DEFAULT_PROMPT = "create a video of the character in the character sheet slightly animated movement naturally"
74
+ DEFAULT_IMAGE_ID = "d6c378a2-5db4-44ed-b3ca-c5cb3c14b968"
75
+ DEFAULT_IMAGE_URL = "https://d2ol7oe51mr4n9.cloudfront.net/user_3F4DMevtcYoMWqVsIglYT2yjLIz/d6c378a2-5db4-44ed-b3ca-c5cb3c14b968.png"
76
+
77
+
78
+ # ==========================================
79
+ # Minimal synchronous CDP client
80
+ # ==========================================
81
+ class CDP:
82
+ def __init__(self, ws_url):
83
+ self.ws = websocket.create_connection(ws_url, max_size=None, timeout=30)
84
+ self._id = 0
85
+
86
+ def send(self, method, params=None, session_id=None):
87
+ self._id += 1
88
+ msg = {"id": self._id, "method": method, "params": params or {}}
89
+ if session_id:
90
+ msg["sessionId"] = session_id
91
+ self.ws.send(json.dumps(msg))
92
+ return self._id
93
+
94
+ def wait_for(self, msg_id, timeout=30):
95
+ """Read frames until we see the response for msg_id; ignore everything else."""
96
+ deadline = time.time() + timeout
97
+ while time.time() < deadline:
98
+ self.ws.settimeout(max(0.1, deadline - time.time()))
99
+ try:
100
+ msg = json.loads(self.ws.recv())
101
+ except websocket.WebSocketTimeoutException:
102
+ break
103
+ if msg.get("id") == msg_id:
104
+ if "error" in msg:
105
+ raise RuntimeError(f"CDP error for {msg_id}: {msg['error']}")
106
+ return msg.get("result", {})
107
+ raise TimeoutError(f"Timed out waiting for CDP response id={msg_id}")
108
+
109
+ def call(self, method, params=None, session_id=None, timeout=30):
110
+ return self.wait_for(self.send(method, params, session_id), timeout)
111
+
112
+ def wait_event(self, method, timeout):
113
+ """Block until a given CDP event arrives (or timeout). Returns True/False."""
114
+ deadline = time.time() + timeout
115
+ while time.time() < deadline:
116
+ self.ws.settimeout(max(0.1, deadline - time.time()))
117
+ try:
118
+ msg = json.loads(self.ws.recv())
119
+ except websocket.WebSocketTimeoutException:
120
+ return False
121
+ if msg.get("method") == method:
122
+ return True
123
+ return False
124
+
125
+ def close(self):
126
+ try:
127
+ self.ws.close()
128
+ except Exception:
129
+ pass
130
+
131
+
132
+ def find_higgsfield_tab():
133
+ """Return the targetId of an already-open, healthy higgsfield tab, or None.
134
+
135
+ We do NOT spawn a fresh tab: loading higgsfield in a CDP-created tab trips
136
+ DataDome and crashes the renderer ('Render process gone'). An existing tab
137
+ is already past DataDome and authenticated, so we attach to it instead.
138
+ """
139
+ for t in cffi_requests.get(f"{CDP_HTTP}/json/list", timeout=10).json():
140
+ if t.get("type") == "page" and "higgsfield.ai" in (t.get("url") or ""):
141
+ return t["id"], t["url"]
142
+ return None, None
143
+
144
+
145
+ def get_fresh_tokens():
146
+ ver = cffi_requests.get(f"{CDP_HTTP}/json/version", timeout=10).json()
147
+ print(f"🔌 Connected to {ver['Browser']}")
148
+
149
+ target_id, url = find_higgsfield_tab()
150
+ if not target_id:
151
+ raise RuntimeError(
152
+ "No open higgsfield.ai tab found.\n"
153
+ " Open https://higgsfield.ai/ai/video in the debug Chrome and log in,\n"
154
+ " then re-run. (A freshly-spawned tab gets killed by DataDome.)")
155
+ print(f"📎 Attaching to existing tab: {url}")
156
+
157
+ cdp = CDP(ver["webSocketDebuggerUrl"])
158
+ try:
159
+ session_id = cdp.call("Target.attachToTarget",
160
+ {"targetId": target_id, "flatten": True})["sessionId"]
161
+ cdp.call("Runtime.enable", session_id=session_id)
162
+
163
+ # Mint a fresh bearer directly from Clerk — deterministic, no UI clicks.
164
+ mint = ("(async()=>{try{return (window.Clerk&&window.Clerk.session)"
165
+ "?await window.Clerk.session.getToken():null;}catch(e){return null;}})()")
166
+ res = cdp.call("Runtime.evaluate",
167
+ {"expression": mint, "awaitPromise": True, "returnByValue": True},
168
+ session_id=session_id)
169
+ jwt = res.get("result", {}).get("value")
170
+ if not jwt:
171
+ raise RuntimeError("Clerk token unavailable (is this tab logged in?)")
172
+ print("✅ Bearer minted via Clerk.")
173
+
174
+ # Pull FULL cookie jar (incl. HttpOnly datadome) via CDP.
175
+ cookies = cdp.call("Network.getCookies",
176
+ {"urls": [f"{GEN_BASE}/", "https://higgsfield.ai/"]},
177
+ session_id=session_id).get("cookies", [])
178
+ seen = {c["name"]: c["value"] for c in cookies}
179
+ cookie_header = "; ".join(f"{k}={v}" for k, v in seen.items())
180
+ datadome = seen.get("datadome")
181
+ print(f"🍪 {len(seen)} cookies (datadome={'yes' if datadome else 'no'})")
182
+
183
+ return f"Bearer {jwt}", datadome, cookie_header
184
+ finally:
185
+ # Detach only — never close the user's real tab.
186
+ try:
187
+ cdp.call("Target.detachFromTarget", {"sessionId": session_id}, timeout=5)
188
+ except Exception:
189
+ pass
190
+ cdp.close()
191
+
192
+
193
+ class PageResponse:
194
+ """Minimal requests-like wrapper over an in-page fetch result."""
195
+ def __init__(self, status, text):
196
+ self.status_code = status
197
+ self.text = text
198
+
199
+ def json(self):
200
+ return json.loads(self.text)
201
+
202
+
203
+ def _fetch_expr(method, url, body):
204
+ """Build the JS that runs fetch() in the page context and returns {status, body}.
205
+ Auth bearer is minted inline via Clerk; the datadome cookie + real Chrome TLS
206
+ ride along automatically because the request originates from the real tab."""
207
+ parts = [
208
+ "(async()=>{try{",
209
+ # Clerk can be briefly undefined right after a tab nav/reload — wait for it.
210
+ "let _n=0;while(!(window.Clerk&&window.Clerk.session)&&_n++<40){await new Promise(r=>setTimeout(r,250));}",
211
+ "const t=await window.Clerk.session.getToken();",
212
+ f"const o={{method:{json.dumps(method)},headers:{{authorization:'Bearer '+t}},credentials:'include'}};",
213
+ ]
214
+ if body is not None:
215
+ parts.append("o.headers['content-type']='application/json';")
216
+ parts.append(f"o.body=JSON.stringify({json.dumps(body)});")
217
+ parts += [
218
+ f"const r=await fetch({json.dumps(url)},o);",
219
+ "const x=await r.text();",
220
+ "return JSON.stringify({status:r.status,body:x});",
221
+ "}catch(e){return JSON.stringify({status:0,error:String((e&&e.message)||e)});}})()",
222
+ ]
223
+ return "".join(parts)
224
+
225
+
226
+ class BrowserSession:
227
+ """Routes every higgsfield API call through the real logged-in Chrome tab via
228
+ CDP `fetch` — so DataDome sees genuine browser requests (real TLS + the JS-earned
229
+ datadome cookie) and never challenges them. Replaces the curl_cffi/TLS-impersonation
230
+ + token/cookie-harvesting transport, which DataDome eventually risk-scores and 403s.
231
+
232
+ The cloudfront presigned PUT (no DataDome) and the public video download stay on
233
+ curl_cffi — they don't touch the protected fnf.higgsfield.ai origin."""
234
+
235
+ def __init__(self):
236
+ ver = cffi_requests.get(f"{CDP_HTTP}/json/version", timeout=10).json()
237
+ print(f"🔌 Connected to {ver['Browser']}")
238
+ tid, url = find_higgsfield_tab()
239
+ if not tid:
240
+ raise RuntimeError(
241
+ "No open higgsfield.ai tab found.\n"
242
+ " Open https://higgsfield.ai/ai/video in the debug Chrome and log in.")
243
+ print(f"📎 Routing API calls through tab: {url}")
244
+ self.cdp = CDP(ver["webSocketDebuggerUrl"])
245
+ self.session_id = self.cdp.call(
246
+ "Target.attachToTarget", {"targetId": tid, "flatten": True})["sessionId"]
247
+ self.cdp.call("Runtime.enable", session_id=self.session_id)
248
+
249
+ def request(self, method, url, body=None, timeout=120, retries=3):
250
+ # Retry transient blips (tab reload, Clerk not ready, CDP hiccup) so a
251
+ # single bad evaluate doesn't kill a long poll loop.
252
+ last = None
253
+ for attempt in range(retries):
254
+ res = self.cdp.call(
255
+ "Runtime.evaluate",
256
+ {"expression": _fetch_expr(method, url, body),
257
+ "awaitPromise": True, "returnByValue": True},
258
+ session_id=self.session_id, timeout=timeout)
259
+ val = res.get("result", {}).get("value")
260
+ if val is None:
261
+ last = f"evaluate failed: {res.get('exceptionDetails')}"
262
+ else:
263
+ data = json.loads(val)
264
+ if data.get("status") == 0:
265
+ last = f"in-page fetch error: {data.get('error')}"
266
+ else:
267
+ return PageResponse(data["status"], data["body"])
268
+ if attempt < retries - 1:
269
+ print(f" ↻ transient request error, retrying… ({last})")
270
+ time.sleep(3)
271
+ raise RuntimeError(last)
272
+
273
+ def close(self):
274
+ try:
275
+ self.cdp.call("Target.detachFromTarget", {"sessionId": self.session_id}, timeout=5)
276
+ except Exception:
277
+ pass
278
+ self.cdp.close()
279
+
280
+
281
+ MIME_BY_EXT = {".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
282
+ ".webp": "image/webp"}
283
+ # Statuses that mean the image is blocked / unusable as an input.
284
+ BLOCKED_STATUSES = {"nsfw", "rejected", "failed", "blocked"}
285
+
286
+
287
+ VERIFY_TIMEOUT_S = 90 # per-attempt ceiling on the NSFW/IP check before retrying
288
+
289
+
290
+ def upload_image(session, path, surface="seedance_2", verify_timeout=VERIFY_TIMEOUT_S):
291
+ """Upload a local image to Higgsfield and wait for the NSFW/IP check (one attempt).
292
+
293
+ Reproduces the UI flow:
294
+ 1. POST /media/batch -> reserve id + presigned upload_url
295
+ 2. PUT <upload_url> -> raw bytes to cloudfront (presigned, no auth)
296
+ 3. POST /media/{id}/upload -> trigger NSFW + IP checks
297
+ 4. GET /media/{id} -> poll until ip_check_finished
298
+ Returns the final media object, or raises ImageBlocked / VerifyTimeout.
299
+ """
300
+ ext = os.path.splitext(path)[1].lower()
301
+ mime = MIME_BY_EXT.get(ext)
302
+ if not mime:
303
+ raise RuntimeError(f"Unsupported image type '{ext}' (use {list(MIME_BY_EXT)})")
304
+ with open(path, "rb") as f:
305
+ body = f.read()
306
+ print(f"⬆️ Uploading {os.path.basename(path)} ({len(body)} bytes, {mime})…")
307
+ event(event="progress", phase="uploading")
308
+
309
+ # 1. reserve
310
+ r = session.request("POST", f"{GEN_BASE}/media/batch",
311
+ body={"mimetypes": [mime], "source": "user_upload",
312
+ "surface": surface, "force_ip_check": True})
313
+ if r.status_code != 200:
314
+ raise RuntimeError(f"/media/batch failed ({r.status_code}): {r.text[:500]}")
315
+ m = r.json()[0]
316
+ mid, url, upload_url = m["id"], m["url"], m["upload_url"]
317
+ ctype = m.get("content_type", mime)
318
+
319
+ # 2. presigned PUT — bare request, NOT session (no auth/cookies; only the
320
+ # signed Content-Type + host headers may be sent).
321
+ put = cffi_requests.put(upload_url, data=body, headers={"content-type": ctype},
322
+ impersonate=IMPERSONATE)
323
+ if put.status_code not in (200, 204):
324
+ raise RuntimeError(f"presigned PUT failed ({put.status_code}): {put.text[:500]}")
325
+
326
+ # 3. notify -> kicks off checks
327
+ session.request("POST", f"{GEN_BASE}/media/{mid}/upload",
328
+ body={"filename": os.path.basename(path), "force_nsfw_check": True,
329
+ "force_ip_check": True, "surface": surface})
330
+
331
+ # 4. poll until verification finishes. The poll response omits `url`, so
332
+ # carry the cloudfront url from the batch step into the returned object.
333
+ print("🔎 Verifying (NSFW / IP check)…")
334
+ event(event="progress", phase="verifying")
335
+ deadline = time.time() + verify_timeout
336
+ while True:
337
+ media = session.request("GET", f"{GEN_BASE}/media/{mid}").json()
338
+ status = media.get("status")
339
+ if status in BLOCKED_STATUSES:
340
+ raise ImageBlocked(status)
341
+ if media.get("ip_check_finished"):
342
+ media["url"] = url
343
+ print(f"✅ Verified: id={mid} status={status} "
344
+ f"face={media.get('is_face_detected')}")
345
+ return media
346
+ if time.time() > deadline:
347
+ raise VerifyTimeout(f"ip_check not finished in {verify_timeout}s")
348
+ time.sleep(2)
349
+
350
+
351
+ def upload_with_retry(session, path, surface="seedance_2", attempts=3):
352
+ """Upload + verify, retrying transient verify timeouts up to `attempts` times.
353
+ A deterministic block (nsfw) is NOT retried — same bytes give the same verdict."""
354
+ for attempt in range(1, attempts + 1):
355
+ print(f"📤 Upload attempt {attempt}/{attempts}…")
356
+ event(event="progress", phase="uploading", attempt=attempt, max=attempts)
357
+ try:
358
+ return upload_image(session, path, surface=surface)
359
+ except ImageBlocked as b:
360
+ event(event="error", reason="nsfw", status=b.status, fatal=True)
361
+ raise SystemExit(f"Image blocked by verification (status={b.status})")
362
+ except VerifyTimeout as t:
363
+ print(f" ⏱ verify timed out ({t}); retrying…")
364
+ event(event="progress", phase="verify-retry", attempt=attempt, max=attempts)
365
+ event(event="error", reason="verify-timeout", fatal=True)
366
+ raise SystemExit(f"Verification did not finish after {attempts} attempts")
367
+
368
+
369
+ def media_data(media):
370
+ """Build the generation-payload `data` block from a media object."""
371
+ finished = bool(media.get("ip_check_finished", True))
372
+ face = bool(media.get("is_face_detected", False))
373
+ status = media.get("status", "uploaded")
374
+ return {
375
+ "id": media["id"], "type": "media_input", "url": media["url"],
376
+ "status": status, "ip_check_finished": finished, "is_face_detected": face,
377
+ "ipCheckFinished": finished, "isFaceDetected": face, "ipStatus": status,
378
+ }
379
+
380
+
381
+ # (resolution, aspect) -> (width, height) for the seedance payload.
382
+ def _dims(resolution, aspect):
383
+ short = 480 if str(resolution).startswith("480") else 720
384
+ long = round(short * 16 / 9)
385
+ return (long, short) if aspect == "16:9" else (short, long)
386
+
387
+
388
+ GEN_SLOT_BACKOFF_S = 20 # poll interval while the single job slot is busy
389
+
390
+
391
+ def generate(session, prompt, medias, duration=8, resolution="720p", aspect="16:9",
392
+ gen_wait=480):
393
+ """`medias` is an ORDERED list of verified media objects (1+ reference images).
394
+ Order is preserved into the payload's medias[] — that's the @imageN / ImgN order."""
395
+ if isinstance(medias, dict): # tolerate a single media object
396
+ medias = [medias]
397
+ url = f"{GEN_BASE}/jobs/v2/seedance_unlimited"
398
+ width, height = _dims(resolution, aspect)
399
+ payload = {
400
+ "params": {
401
+ "prompt": prompt,
402
+ "duration": int(duration),
403
+ "aspect_ratio": aspect,
404
+ "resolution": resolution,
405
+ "bitrate_mode": "high",
406
+ "generate_audio": True,
407
+ "width": width,
408
+ "height": height,
409
+ "model": "seedance_unlimited",
410
+ "medias": [{"role": "image", "data": media_data(m)} for m in medias],
411
+ },
412
+ "use_unlim": True,
413
+ "use_free_gens": False,
414
+ }
415
+ print("\n🎬 Starting generation…")
416
+ # Higgsfield allows 1 concurrent job. The backend FIFO queue serializes OUR
417
+ # jobs, but a job started directly in the browser UI also holds that slot —
418
+ # so on a 429 we wait it out (up to gen_wait) instead of failing.
419
+ waited = 0
420
+ while True:
421
+ r = session.request("POST", url, body=payload)
422
+ if r.status_code == 200:
423
+ break
424
+ if r.status_code == 429 and waited < gen_wait:
425
+ print(f" ⏳ job slot busy (429); waiting {GEN_SLOT_BACKOFF_S}s ({waited}/{gen_wait})…")
426
+ event(event="progress", phase=f"Waiting for slot ({waited}s)")
427
+ time.sleep(GEN_SLOT_BACKOFF_S)
428
+ waited += GEN_SLOT_BACKOFF_S
429
+ continue
430
+ print(f"❌ Generation request failed ({r.status_code}):\n{r.text[:1000]}")
431
+ reason = "rate-limited" if r.status_code == 429 else "generate-failed"
432
+ event(event="error", reason=reason, status=r.status_code, detail=r.text[:300], fatal=True)
433
+ sys.exit(1)
434
+ job_id = r.json()["job_sets"][0]["jobs"][0]["id"]
435
+ print(f"✅ Job ID: {job_id}")
436
+ event(event="progress", phase="rendering", jobId=job_id)
437
+ return job_id
438
+
439
+
440
+ def poll_and_download(session, job_id, download=True):
441
+ """Poll the job to completion. Returns (video_url, local_path|None).
442
+ Emits a 'done' event (with the cloudfront url) for --emit-json consumers."""
443
+ status_url = f"{GEN_BASE}/jobs/{job_id}/status"
444
+ details_url = f"{GEN_BASE}/jobs/{job_id}"
445
+ print("⏳ Rendering…")
446
+ while True:
447
+ r = session.request("GET", status_url)
448
+ status = r.json().get("status")
449
+ print(f" status: {status}")
450
+ if status == "completed":
451
+ details = session.request("GET", details_url).json()
452
+ video_url = details["results"]["raw"]["url"]
453
+ print(f"🎬 {video_url}")
454
+ path = None
455
+ if download:
456
+ os.makedirs(OUTPUT_DIR, exist_ok=True)
457
+ path = os.path.join(OUTPUT_DIR, f"higgsfield_{job_id}.mp4")
458
+ data = cffi_requests.get(video_url, impersonate=IMPERSONATE).content
459
+ with open(path, "wb") as f:
460
+ f.write(data)
461
+ print(f"🎉 Saved: {path}")
462
+ event(event="done", jobId=job_id, videoUrl=video_url, file=path)
463
+ return video_url, path
464
+ if status in ("failed", "error", "cancelled", "nsfw", "moderated", "rejected"):
465
+ print(f"❌ Generation {status}")
466
+ event(event="error", reason=f"render-{status}", jobId=job_id, fatal=True)
467
+ return None, None
468
+ time.sleep(5)
469
+
470
+
471
+ def main():
472
+ ap = argparse.ArgumentParser()
473
+ ap.add_argument("--prompt", default=DEFAULT_PROMPT)
474
+ ap.add_argument("--image-file", action="append", default=[],
475
+ help="Local image to upload + verify, then use. Repeat for ordered refs.")
476
+ ap.add_argument("--image-id", default=DEFAULT_IMAGE_ID,
477
+ help="Use an already-uploaded media id (ignored if --image-file).")
478
+ ap.add_argument("--image-url", default=DEFAULT_IMAGE_URL)
479
+ ap.add_argument("--surface", default="seedance_2")
480
+ ap.add_argument("--duration", type=int, default=8)
481
+ ap.add_argument("--resolution", default="720p")
482
+ ap.add_argument("--aspect", default="16:9")
483
+ ap.add_argument("--retries", type=int, default=3, help="Upload+verify attempts.")
484
+ ap.add_argument("--gen-wait", type=int, default=480,
485
+ help="Max seconds to wait out a busy job slot (429) before failing.")
486
+ ap.add_argument("--resume-job", default=None,
487
+ help="Skip upload/generate; just poll + download an existing Higgsfield job id.")
488
+ ap.add_argument("--media-json", default=None,
489
+ help="JSON list of already-verified media objects; skip upload, go to generate.")
490
+ ap.add_argument("--no-download", action="store_true",
491
+ help="Don't save the mp4 locally; just report the video URL.")
492
+ ap.add_argument("--emit-json", action="store_true",
493
+ help="Emit NDJSON progress on stdout (human logs go to stderr).")
494
+ ap.add_argument("--capture-only", action="store_true",
495
+ help="Only harvest + print tokens, don't generate.")
496
+ ap.add_argument("--upload-only", action="store_true",
497
+ help="Upload + verify --image-file, print the media object, stop.")
498
+ args = ap.parse_args()
499
+
500
+ # In --emit-json mode, machine events go to the real stdout; every human print()
501
+ # is redirected to stderr so the two streams never interleave.
502
+ if args.emit_json:
503
+ global _JSON_OUT
504
+ _JSON_OUT = sys.stdout
505
+ sys.stdout = sys.stderr
506
+
507
+ if args.capture_only:
508
+ auth, datadome, cookies = get_fresh_tokens()
509
+ print("\n--- CAPTURED ---")
510
+ print("auth:", auth[:40], "…")
511
+ print("datadome:", datadome)
512
+ print("cookie len:", len(cookies))
513
+ return
514
+
515
+ session = BrowserSession()
516
+
517
+ # Resume: a job was already submitted (id persisted); just poll it to completion.
518
+ if args.resume_job:
519
+ print(f"↩️ Resuming job {args.resume_job}")
520
+ event(event="progress", phase="rendering", jobId=args.resume_job)
521
+ poll_and_download(session, args.resume_job, download=not args.no_download)
522
+ return
523
+
524
+ if args.media_json:
525
+ # Resume after a restart: media already uploaded + verified — skip straight to generate.
526
+ medias = json.loads(args.media_json)
527
+ print(f"↩️ Using {len(medias)} pre-verified media (skip upload)")
528
+ elif args.image_file:
529
+ # Upload + verify each ref IN ORDER → ordered medias[] (= @imageN / ImgN order).
530
+ medias = []
531
+ for n, f in enumerate(args.image_file, 1):
532
+ print(f"📎 Reference {n}/{len(args.image_file)}: {os.path.basename(f)}")
533
+ medias.append(upload_with_retry(session, f, surface=args.surface,
534
+ attempts=args.retries))
535
+ # Checkpoint: refs are verified → let the backend persist them so a restart
536
+ # mid-render can resume without re-uploading.
537
+ event(event="verified", medias=medias)
538
+ else:
539
+ medias = [{"id": args.image_id, "url": args.image_url, "status": "uploaded",
540
+ "ip_check_finished": True, "is_face_detected": False}]
541
+
542
+ if args.upload_only:
543
+ print("\n--- MEDIA ---")
544
+ print(json.dumps(medias, indent=2))
545
+ return
546
+
547
+ job_id = generate(session, args.prompt, medias, duration=args.duration,
548
+ resolution=args.resolution, aspect=args.aspect, gen_wait=args.gen_wait)
549
+ poll_and_download(session, job_id, download=not args.no_download)
550
+
551
+
552
+ if __name__ == "__main__":
553
+ main()
package/index.mjs CHANGED
@@ -18,13 +18,21 @@
18
18
  // OneDrive folder). Enables local storage for a hosted backend.
19
19
  // Native path for YOUR OS — Windows: C:\Users\you\OneDrive\…
20
20
  // · WSL: /mnt/c/Users/you/OneDrive/… · macOS: /Users/you/OneDrive/…
21
+ // --higgsfield STORYBOARD_HIGGSFIELD make THIS machine the project's Higgsfield i2v host (runs
22
+ // the Python/CDP client against a logged-in Chrome debug tab).
23
+ // All users' Higgsfield jobs route here. Needs python + a Chrome
24
+ // started with --remote-debugging-port=9222 + the Higgsfield login.
25
+ // --python-bin <p> PYTHON_BIN python binary (default python.exe on Windows, else python3)
26
+ // --cdp <url> CDP_HTTP the debug-Chrome endpoint (default http://localhost:9222)
21
27
  // -- CLAUDE_BIN/GEMINI_BIN path to the CLI binary if not on PATH
22
28
  import { spawn, execFile } from 'node:child_process';
23
29
  import { writeFile, readFile, unlink, stat, mkdir } from 'node:fs/promises';
24
30
  import { join, resolve, dirname, sep } from 'node:path';
31
+ import { fileURLToPath } from 'node:url';
32
+ import { tmpdir } from 'node:os';
25
33
  import { WebSocket } from 'ws';
26
34
 
27
- const VERSION = '0.2.0';
35
+ const VERSION = '0.3.0';
28
36
 
29
37
  // ---- config ----
30
38
  const arg = (name, fallback) => {
@@ -39,6 +47,13 @@ const BIN_FOR = { claude: process.env.CLAUDE_BIN || 'claude', gemini: process.en
39
47
  const BIN = BIN_FOR[PROVIDER] || BIN_FOR.claude;
40
48
  const FILES_ROOT = arg('files', process.env.STORYBOARD_FILES || ''); // '' = this bridge does no storage
41
49
  const IS_WIN = process.platform === 'win32';
50
+ // Higgsfield host: this machine runs Higgsfield i2v for the WHOLE project (needs a logged-in Chrome
51
+ // debug tab + python + the Higgsfield login). Opt in with --higgsfield; the backend routes everyone's
52
+ // Higgsfield jobs to a bridge that advertises this.
53
+ const HIGGSFIELD = process.argv.includes('--higgsfield') || process.env.STORYBOARD_HIGGSFIELD === '1';
54
+ const PYTHON_BIN = arg('python-bin', process.env.PYTHON_BIN || (IS_WIN ? 'python.exe' : 'python3'));
55
+ const CDP_HTTP = arg('cdp', process.env.CDP_HTTP || 'http://localhost:9222'); // the debug Chrome endpoint
56
+ const HF_SCRIPT = join(dirname(fileURLToPath(import.meta.url)), 'higgsfield_client.py');
42
57
  // claude supports `--output-format json` (wraps as {result}); gemini's CLI (<=0.1.x) does NOT — it
43
58
  // prints raw text, and passing the flag makes it dump --help. Newer gemini supports it (wraps as
44
59
  // {response}) — opt in with GEMINI_JSON=1. extractText() handles wrapped OR raw either way.
@@ -124,6 +139,58 @@ async function handleFile(msg) {
124
139
  throw new Error(`unknown file op: ${msg.op}`);
125
140
  }
126
141
 
142
+ // ---- Higgsfield i2v: run the local Python client (CDP → logged-in Chrome debug tab) and stream its
143
+ // NDJSON progress/verified/done/error events back. Input images arrive as base64 (so it doesn't depend
144
+ // on OneDrive sync), get written to temp files, and are passed as --image-file. ----
145
+ async function handleHiggsfield(msg, ws) {
146
+ const id = msg.id;
147
+ const p = msg.params || {};
148
+ const tmp = [];
149
+ const cleanup = () => { for (const f of tmp) unlink(f).catch(() => {}); };
150
+ try {
151
+ const images = Array.isArray(p.images) ? p.images : [];
152
+ for (let i = 0; i < images.length; i++) {
153
+ const f = join(tmpdir(), `sbhf_${id}_${i}.png`);
154
+ await writeFile(f, Buffer.from(String(images[i]), 'base64'));
155
+ tmp.push(f);
156
+ }
157
+ const args = ['-u', HF_SCRIPT, '--emit-json', '--no-download'];
158
+ if (p.resumeJobId) {
159
+ args.push('--resume-job', String(p.resumeJobId)); // poll an existing job, no upload
160
+ } else {
161
+ args.push('--prompt', String(p.prompt ?? ''),
162
+ '--duration', String(Math.max(1, Math.round(p.durationSec || 5))),
163
+ '--resolution', String(p.resolution || '720p'),
164
+ '--aspect', String(p.aspectRatio || '16:9'));
165
+ if (p.mediaJson) args.push('--media-json', String(p.mediaJson)); // skip upload, use verified media
166
+ else for (const f of tmp) args.push('--image-file', f);
167
+ }
168
+ const child = spawn(PYTHON_BIN, args, { env: { ...process.env, CDP_HTTP }, stdio: ['ignore', 'pipe', 'pipe'] });
169
+ let buf = '', sawDone = false, stderrTail = '';
170
+ child.stdout.on('data', (d) => {
171
+ buf += d.toString();
172
+ let nl;
173
+ while ((nl = buf.indexOf('\n')) >= 0) {
174
+ const line = buf.slice(0, nl).trim();
175
+ buf = buf.slice(nl + 1);
176
+ if (!line) continue;
177
+ let ev; try { ev = JSON.parse(line); } catch { continue; }
178
+ if (ev.event === 'done') sawDone = true;
179
+ ws.send(JSON.stringify({ type: 'hfEvent', id, ...ev })); // forward progress/verified/done/error
180
+ }
181
+ });
182
+ child.stderr.on('data', (d) => { stderrTail = (stderrTail + d.toString()).slice(-2000); });
183
+ child.on('error', (e) => { cleanup(); ws.send(JSON.stringify({ type: 'hfEvent', id, event: 'error', reason: 'spawn', detail: `${PYTHON_BIN}: ${e.message}` })); });
184
+ child.on('close', (code) => {
185
+ cleanup();
186
+ if (!sawDone && code !== 0) ws.send(JSON.stringify({ type: 'hfEvent', id, event: 'error', reason: 'exit', detail: `python exited ${code}: ${stderrTail.slice(-300)}` }));
187
+ });
188
+ } catch (e) {
189
+ cleanup();
190
+ ws.send(JSON.stringify({ type: 'hfEvent', id, event: 'error', reason: 'bridge', detail: String(e?.message ?? e) }));
191
+ }
192
+ }
193
+
127
194
  // ---- connection (reconnects forever with backoff) ----
128
195
  let backoff = 1000;
129
196
  function connect() {
@@ -136,8 +203,8 @@ function connect() {
136
203
  claude: await cliAvailable(BIN_FOR.claude),
137
204
  gemini: await cliAvailable(BIN_FOR.gemini),
138
205
  };
139
- ws.send(JSON.stringify({ type: 'hello', version: VERSION, provider: PROVIDER, providers, files: !!FILES_ROOT }));
140
- log(`connected to ${url} — serving jobs with: ${PROVIDER} (${BIN})${FILES_ROOT ? `; files → ${FILES_ROOT}` : ''}`);
206
+ ws.send(JSON.stringify({ type: 'hello', version: VERSION, provider: PROVIDER, providers, files: !!FILES_ROOT, higgsfield: HIGGSFIELD }));
207
+ log(`connected to ${url} — serving jobs with: ${PROVIDER} (${BIN})${FILES_ROOT ? `; files → ${FILES_ROOT}` : ''}${HIGGSFIELD ? `; HIGGSFIELD HOST (python: ${PYTHON_BIN}, cdp: ${CDP_HTTP})` : ''}`);
141
208
  if (!providers[PROVIDER]) {
142
209
  log(`WARNING: '${BIN}' was not found / not runnable. Install it and log in, or pass --provider/--*_BIN. Jobs will fail until then.`);
143
210
  }
@@ -172,6 +239,9 @@ function connect() {
172
239
  ws.send(JSON.stringify({ type: 'fileError', id: msg.id, message: String(e?.message ?? e) }));
173
240
  log(`file ${msg.op} ${msg.id} ✗ ${e?.message ?? e}`);
174
241
  }
242
+ } else if (msg.type === 'higgsfield' && msg.id) {
243
+ if (!HIGGSFIELD) ws.send(JSON.stringify({ type: 'hfEvent', id: msg.id, event: 'error', reason: 'disabled', detail: 'this bridge was not started with --higgsfield' }));
244
+ else { log(`higgsfield ${msg.id} → running`); handleHiggsfield(msg, ws); }
175
245
  }
176
246
  });
177
247
 
@@ -190,5 +260,5 @@ function connect() {
190
260
  });
191
261
  }
192
262
 
193
- log(`Storyboard bridge v${VERSION} → ${SERVER} (provider: ${PROVIDER}; ${TOKEN ? `paired to token ${TOKEN.slice(0, 6)}…` : 'SHARED — serves all sessions'}; storage: ${FILES_ROOT ? FILES_ROOT : 'off (no --files)'})`);
263
+ log(`Storyboard bridge v${VERSION} → ${SERVER} (provider: ${PROVIDER}; ${TOKEN ? `paired to token ${TOKEN.slice(0, 6)}…` : 'SHARED — serves all sessions'}; storage: ${FILES_ROOT ? FILES_ROOT : 'off (no --files)'}; higgsfield: ${HIGGSFIELD ? 'HOST' : 'off'})`);
194
264
  connect();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "storyboard-bridge",
3
- "version": "0.2.0",
3
+ "version": "0.3.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": {