storyboard-bridge 0.3.4 → 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 +16 -2
- 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');
|
|
@@ -192,6 +199,7 @@ async function handleHiggsfield(msg, ws) {
|
|
|
192
199
|
tmp.push(f);
|
|
193
200
|
}
|
|
194
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
|
|
195
203
|
if (p.resumeJobId) {
|
|
196
204
|
args.push('--resume-job', String(p.resumeJobId)); // poll an existing job, no upload
|
|
197
205
|
} else {
|
|
@@ -203,6 +211,7 @@ async function handleHiggsfield(msg, ws) {
|
|
|
203
211
|
else for (const f of tmp) args.push('--image-file', f);
|
|
204
212
|
}
|
|
205
213
|
const child = spawn(PYTHON_BIN, args, { env: { ...process.env, CDP_HTTP }, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
214
|
+
hfChildren.set(id, child);
|
|
206
215
|
let buf = '', sawDone = false, stderrTail = '';
|
|
207
216
|
child.stdout.on('data', (d) => {
|
|
208
217
|
buf += d.toString();
|
|
@@ -217,8 +226,9 @@ async function handleHiggsfield(msg, ws) {
|
|
|
217
226
|
}
|
|
218
227
|
});
|
|
219
228
|
child.stderr.on('data', (d) => { stderrTail = (stderrTail + d.toString()).slice(-2000); });
|
|
220
|
-
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}` })); });
|
|
221
230
|
child.on('close', (code) => {
|
|
231
|
+
hfChildren.delete(id);
|
|
222
232
|
cleanup();
|
|
223
233
|
if (!sawDone && code !== 0) ws.send(JSON.stringify({ type: 'hfEvent', id, event: 'error', reason: 'exit', detail: `python exited ${code}: ${stderrTail.slice(-300)}` }));
|
|
224
234
|
});
|
|
@@ -282,6 +292,10 @@ function connect() {
|
|
|
282
292
|
} else if (msg.type === 'higgsfield' && msg.id) {
|
|
283
293
|
if (!HIGGSFIELD) ws.send(JSON.stringify({ type: 'hfEvent', id: msg.id, event: 'error', reason: 'disabled', detail: 'this bridge was not started with --higgsfield' }));
|
|
284
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); }
|
|
285
299
|
}
|
|
286
300
|
});
|
|
287
301
|
|
package/package.json
CHANGED