storyboard-bridge 0.3.3 → 0.3.5
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 +50 -7
- package/index.mjs +32 -3
- package/package.json +1 -1
package/higgsfield_client.py
CHANGED
|
@@ -280,8 +280,10 @@ class BrowserSession:
|
|
|
280
280
|
|
|
281
281
|
MIME_BY_EXT = {".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
|
|
282
282
|
".webp": "image/webp"}
|
|
283
|
-
#
|
|
284
|
-
|
|
283
|
+
# A FINAL verdict in this set means the image is blocked. It is only valid once the NSFW/IP check has
|
|
284
|
+
# FINISHED (ip_check_finished=True) — reading `status` mid-check returns a not-yet-settled value, which is
|
|
285
|
+
# what falsely flagged images Higgsfield ultimately verifies as clean. See the verify loop below.
|
|
286
|
+
BLOCK_STATUSES = {"nsfw", "rejected", "failed", "blocked"}
|
|
285
287
|
|
|
286
288
|
|
|
287
289
|
VERIFY_TIMEOUT_S = 90 # per-attempt ceiling on the NSFW/IP check before retrying
|
|
@@ -336,9 +338,13 @@ def upload_image(session, path, surface="seedance_2", verify_timeout=VERIFY_TIME
|
|
|
336
338
|
while True:
|
|
337
339
|
media = session.request("GET", f"{GEN_BASE}/media/{mid}").json()
|
|
338
340
|
status = media.get("status")
|
|
339
|
-
|
|
340
|
-
|
|
341
|
+
# ONLY judge the verdict once the check has FINISHED. A mid-check `status` is not the result —
|
|
342
|
+
# judging it early is exactly what flagged images Higgsfield then verifies as clean. So wait for
|
|
343
|
+
# ip_check_finished, THEN read the final status: block if it's a real block verdict, else proceed.
|
|
341
344
|
if media.get("ip_check_finished"):
|
|
345
|
+
if status in BLOCK_STATUSES:
|
|
346
|
+
print(f"⛔ id={mid} FINAL verdict={status} | media={json.dumps(media)}") # dump for diagnosis
|
|
347
|
+
raise ImageBlocked(status)
|
|
342
348
|
media["url"] = url
|
|
343
349
|
print(f"✅ Verified: id={mid} status={status} "
|
|
344
350
|
f"face={media.get('is_face_detected')}")
|
|
@@ -437,16 +443,34 @@ def generate(session, prompt, medias, duration=8, resolution="720p", aspect="16:
|
|
|
437
443
|
return job_id
|
|
438
444
|
|
|
439
445
|
|
|
440
|
-
def poll_and_download(session, job_id, download=True):
|
|
446
|
+
def poll_and_download(session, job_id, download=True, own_rights=False):
|
|
441
447
|
"""Poll the job to completion. Returns (video_url, local_path|None).
|
|
442
448
|
Emits a 'done' event (with the cloudfront url) for --emit-json consumers."""
|
|
443
449
|
status_url = f"{GEN_BASE}/jobs/{job_id}/status"
|
|
444
450
|
details_url = f"{GEN_BASE}/jobs/{job_id}"
|
|
451
|
+
confirm_url = f"{GEN_BASE}/jobs/{job_id}/seedance/confirm-rights"
|
|
452
|
+
rights_confirmed = False
|
|
445
453
|
print("⏳ Rendering…")
|
|
446
454
|
while True:
|
|
447
455
|
r = session.request("GET", status_url)
|
|
448
456
|
status = r.json().get("status")
|
|
449
457
|
print(f" status: {status}")
|
|
458
|
+
# Rights gate: the clip RENDERED, but Higgsfield flagged copyright/likeness content and holds it behind
|
|
459
|
+
# "I own rights to this content" — status stays 'ip_detected' (it NEVER becomes 'completed' on its own,
|
|
460
|
+
# so without this we'd just poll until timeout). With explicit opt-in we fire the same PUT the UI button
|
|
461
|
+
# does, which flips the job to 'completed'; otherwise we fail clearly so the user knows why.
|
|
462
|
+
if status == "ip_detected":
|
|
463
|
+
if not own_rights:
|
|
464
|
+
print("⛔ rights verification required — output flagged copyright/likeness. Pass --own-rights to auto-confirm.")
|
|
465
|
+
event(event="error", reason="rights-required", jobId=job_id, fatal=True)
|
|
466
|
+
return None, None
|
|
467
|
+
if not rights_confirmed:
|
|
468
|
+
print("⚖️ asserting content rights (I own rights to this content)…")
|
|
469
|
+
session.request("PUT", confirm_url) # exact call the UI button fires → flips status to completed
|
|
470
|
+
rights_confirmed = True
|
|
471
|
+
event(event="progress", phase="rights-confirmed")
|
|
472
|
+
time.sleep(2)
|
|
473
|
+
continue
|
|
450
474
|
if status == "completed":
|
|
451
475
|
details = session.request("GET", details_url).json()
|
|
452
476
|
video_url = details["results"]["raw"]["url"]
|
|
@@ -462,6 +486,22 @@ def poll_and_download(session, job_id, download=True):
|
|
|
462
486
|
event(event="done", jobId=job_id, videoUrl=video_url, file=path)
|
|
463
487
|
return video_url, path
|
|
464
488
|
if status in ("failed", "error", "cancelled", "nsfw", "moderated", "rejected"):
|
|
489
|
+
# a moderation flag can land even though the render PRODUCED a clip — grab it if so rather than
|
|
490
|
+
# discarding a finished video over a soft flag. Only fail when there's genuinely no output.
|
|
491
|
+
details = session.request("GET", details_url).json()
|
|
492
|
+
video_url = (((details.get("results") or {}).get("raw")) or {}).get("url")
|
|
493
|
+
if video_url:
|
|
494
|
+
print(f"🎬 (flagged {status}, but a clip rendered) {video_url}")
|
|
495
|
+
path = None
|
|
496
|
+
if download:
|
|
497
|
+
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
|
498
|
+
path = os.path.join(OUTPUT_DIR, f"higgsfield_{job_id}.mp4")
|
|
499
|
+
data = cffi_requests.get(video_url, impersonate=IMPERSONATE).content
|
|
500
|
+
with open(path, "wb") as f:
|
|
501
|
+
f.write(data)
|
|
502
|
+
print(f"🎉 Saved: {path}")
|
|
503
|
+
event(event="done", jobId=job_id, videoUrl=video_url, file=path)
|
|
504
|
+
return video_url, path
|
|
465
505
|
print(f"❌ Generation {status}")
|
|
466
506
|
event(event="error", reason=f"render-{status}", jobId=job_id, fatal=True)
|
|
467
507
|
return None, None
|
|
@@ -489,6 +529,9 @@ def main():
|
|
|
489
529
|
help="JSON list of already-verified media objects; skip upload, go to generate.")
|
|
490
530
|
ap.add_argument("--no-download", action="store_true",
|
|
491
531
|
help="Don't save the mp4 locally; just report the video URL.")
|
|
532
|
+
ap.add_argument("--own-rights", action="store_true",
|
|
533
|
+
help="Auto-confirm Higgsfield's copyright/likeness rights gate on a rendered output "
|
|
534
|
+
"(asserts you own the rights to the content). Without it, a gated output fails.")
|
|
492
535
|
ap.add_argument("--emit-json", action="store_true",
|
|
493
536
|
help="Emit NDJSON progress on stdout (human logs go to stderr).")
|
|
494
537
|
ap.add_argument("--capture-only", action="store_true",
|
|
@@ -518,7 +561,7 @@ def main():
|
|
|
518
561
|
if args.resume_job:
|
|
519
562
|
print(f"↩️ Resuming job {args.resume_job}")
|
|
520
563
|
event(event="progress", phase="rendering", jobId=args.resume_job)
|
|
521
|
-
poll_and_download(session, args.resume_job, download=not args.no_download)
|
|
564
|
+
poll_and_download(session, args.resume_job, download=not args.no_download, own_rights=args.own_rights)
|
|
522
565
|
return
|
|
523
566
|
|
|
524
567
|
if args.media_json:
|
|
@@ -546,7 +589,7 @@ def main():
|
|
|
546
589
|
|
|
547
590
|
job_id = generate(session, args.prompt, medias, duration=args.duration,
|
|
548
591
|
resolution=args.resolution, aspect=args.aspect, gen_wait=args.gen_wait)
|
|
549
|
-
poll_and_download(session, job_id, download=not args.no_download)
|
|
592
|
+
poll_and_download(session, job_id, download=not args.no_download, own_rights=args.own_rights)
|
|
550
593
|
|
|
551
594
|
|
|
552
595
|
if __name__ == "__main__":
|
package/index.mjs
CHANGED
|
@@ -32,7 +32,11 @@ import { fileURLToPath } from 'node:url';
|
|
|
32
32
|
import { tmpdir } from 'node:os';
|
|
33
33
|
import { WebSocket } from 'ws';
|
|
34
34
|
|
|
35
|
-
const VERSION = '0.3.
|
|
35
|
+
const VERSION = '0.3.5';
|
|
36
|
+
|
|
37
|
+
// in-flight Higgsfield render children, keyed by job id — so a `higgsfield-cancel` can kill the right one
|
|
38
|
+
// (the backend sends it when the user cancels, freeing the single render slot instead of leaving it running).
|
|
39
|
+
const hfChildren = new Map();
|
|
36
40
|
|
|
37
41
|
// ---- config ----
|
|
38
42
|
const arg = (name, fallback) => {
|
|
@@ -51,6 +55,9 @@ const IS_WIN = process.platform === 'win32';
|
|
|
51
55
|
// debug tab + python + the Higgsfield login). Opt in with --higgsfield; the backend routes everyone's
|
|
52
56
|
// Higgsfield jobs to a bridge that advertises this.
|
|
53
57
|
const HIGGSFIELD = process.argv.includes('--higgsfield') || process.env.STORYBOARD_HIGGSFIELD === '1';
|
|
58
|
+
// Auto-confirm Higgsfield's copyright/likeness "rights verification" gate on a rendered clip (a gated clip
|
|
59
|
+
// otherwise stalls until timeout). Asserts you own the rights — explicit opt-in only.
|
|
60
|
+
const OWN_RIGHTS = process.argv.includes('--own-rights') || process.env.STORYBOARD_OWN_RIGHTS === '1';
|
|
54
61
|
const PYTHON_BIN = arg('python-bin', process.env.PYTHON_BIN || (IS_WIN ? 'python.exe' : 'python3'));
|
|
55
62
|
const CDP_HTTP = arg('cdp', process.env.CDP_HTTP || 'http://localhost:9222'); // the debug Chrome endpoint
|
|
56
63
|
const HF_SCRIPT = join(dirname(fileURLToPath(import.meta.url)), 'higgsfield_client.py');
|
|
@@ -62,6 +69,18 @@ const JOB_TIMEOUT_MS = 300_000;
|
|
|
62
69
|
|
|
63
70
|
const log = (m) => console.log(`[bridge ${new Date().toISOString().slice(11, 19)}] ${m}`);
|
|
64
71
|
|
|
72
|
+
// Is version `a` strictly older than `b`? (numeric x.y.z compare; missing parts = 0.) Used so the
|
|
73
|
+
// update nag fires ONLY when this bridge is behind the backend's advertised latest, never when ahead.
|
|
74
|
+
function isOlderVersion(a, b) {
|
|
75
|
+
const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0);
|
|
76
|
+
const pb = String(b).split('.').map((n) => parseInt(n, 10) || 0);
|
|
77
|
+
for (let i = 0; i < 3; i++) {
|
|
78
|
+
if ((pa[i] || 0) < (pb[i] || 0)) return true;
|
|
79
|
+
if ((pa[i] || 0) > (pb[i] || 0)) return false;
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
65
84
|
function wsUrl(httpUrl) {
|
|
66
85
|
const u = new URL(httpUrl);
|
|
67
86
|
u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
@@ -180,6 +199,7 @@ async function handleHiggsfield(msg, ws) {
|
|
|
180
199
|
tmp.push(f);
|
|
181
200
|
}
|
|
182
201
|
const args = ['-u', HF_SCRIPT, '--emit-json', '--no-download'];
|
|
202
|
+
if (OWN_RIGHTS) args.push('--own-rights'); // auto-confirm the copyright/likeness rights gate
|
|
183
203
|
if (p.resumeJobId) {
|
|
184
204
|
args.push('--resume-job', String(p.resumeJobId)); // poll an existing job, no upload
|
|
185
205
|
} else {
|
|
@@ -191,6 +211,7 @@ async function handleHiggsfield(msg, ws) {
|
|
|
191
211
|
else for (const f of tmp) args.push('--image-file', f);
|
|
192
212
|
}
|
|
193
213
|
const child = spawn(PYTHON_BIN, args, { env: { ...process.env, CDP_HTTP }, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
214
|
+
hfChildren.set(id, child);
|
|
194
215
|
let buf = '', sawDone = false, stderrTail = '';
|
|
195
216
|
child.stdout.on('data', (d) => {
|
|
196
217
|
buf += d.toString();
|
|
@@ -205,8 +226,9 @@ async function handleHiggsfield(msg, ws) {
|
|
|
205
226
|
}
|
|
206
227
|
});
|
|
207
228
|
child.stderr.on('data', (d) => { stderrTail = (stderrTail + d.toString()).slice(-2000); });
|
|
208
|
-
child.on('error', (e) => { cleanup(); ws.send(JSON.stringify({ type: 'hfEvent', id, event: 'error', reason: 'spawn', detail: `${PYTHON_BIN}: ${e.message}` })); });
|
|
229
|
+
child.on('error', (e) => { hfChildren.delete(id); cleanup(); ws.send(JSON.stringify({ type: 'hfEvent', id, event: 'error', reason: 'spawn', detail: `${PYTHON_BIN}: ${e.message}` })); });
|
|
209
230
|
child.on('close', (code) => {
|
|
231
|
+
hfChildren.delete(id);
|
|
210
232
|
cleanup();
|
|
211
233
|
if (!sawDone && code !== 0) ws.send(JSON.stringify({ type: 'hfEvent', id, event: 'error', reason: 'exit', detail: `python exited ${code}: ${stderrTail.slice(-300)}` }));
|
|
212
234
|
});
|
|
@@ -243,7 +265,10 @@ function connect() {
|
|
|
243
265
|
return;
|
|
244
266
|
}
|
|
245
267
|
if (msg.type === 'welcome') {
|
|
246
|
-
|
|
268
|
+
// Only nag when we're genuinely OLDER than what the backend advertises — a plain `!==` also fired
|
|
269
|
+
// when this bridge was NEWER than the backend's advertised version (e.g. mid-rollout, before the
|
|
270
|
+
// backend redeploys), wrongly telling the user to "update" to an older build.
|
|
271
|
+
if (msg.latest && isOlderVersion(VERSION, msg.latest)) {
|
|
247
272
|
log(`UPDATE AVAILABLE: bridge v${msg.latest} (you have v${VERSION}). Update with: npm i -g storyboard-bridge (or just re-run via npx).`);
|
|
248
273
|
}
|
|
249
274
|
} else if (msg.type === 'job' && msg.id) {
|
|
@@ -267,6 +292,10 @@ function connect() {
|
|
|
267
292
|
} else if (msg.type === 'higgsfield' && msg.id) {
|
|
268
293
|
if (!HIGGSFIELD) ws.send(JSON.stringify({ type: 'hfEvent', id: msg.id, event: 'error', reason: 'disabled', detail: 'this bridge was not started with --higgsfield' }));
|
|
269
294
|
else { log(`higgsfield ${msg.id} → running`); handleHiggsfield(msg, ws); }
|
|
295
|
+
} else if (msg.type === 'higgsfield-cancel' && msg.id) {
|
|
296
|
+
// user cancelled → stop this render so the single account slot frees (the python client honours SIGTERM)
|
|
297
|
+
const child = hfChildren.get(msg.id);
|
|
298
|
+
if (child) { log(`higgsfield ${msg.id} → cancel`); try { child.kill('SIGTERM'); } catch { /* already gone */ } hfChildren.delete(msg.id); }
|
|
270
299
|
}
|
|
271
300
|
});
|
|
272
301
|
|
package/package.json
CHANGED