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.
- package/higgsfield_client.py +553 -0
- package/index.mjs +74 -4
- package/package.json +1 -1
|
@@ -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.
|
|
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