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.
@@ -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
- # Statuses that mean the image is blocked / unusable as an input.
284
- BLOCKED_STATUSES = {"nsfw", "rejected", "failed", "blocked"}
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
- if status in BLOCKED_STATUSES:
340
- raise ImageBlocked(status)
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.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
- if (msg.latest && msg.latest !== VERSION) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "storyboard-bridge",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
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": {