storyboard-bridge 0.6.0 → 0.7.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/index.mjs CHANGED
@@ -40,6 +40,8 @@ const VERSION = createRequire(import.meta.url)('./package.json').version;
40
40
  // in-flight Higgsfield render children, keyed by job id — so a `higgsfield-cancel` can kill the right one
41
41
  // (the backend sends it when the user cancels, freeing the single render slot instead of leaving it running).
42
42
  const hfChildren = new Map();
43
+ // in-flight tryinfer.com render children, keyed by job id — so a `tryinfer-cancel` kills the right one.
44
+ const tfChildren = new Map();
43
45
  // in-flight official-CLI render children (the `higgsfield-cli` credit-billed path — separate from the CDP one).
44
46
  const hfCliChildren = new Map();
45
47
 
@@ -60,6 +62,8 @@ const IS_WIN = process.platform === 'win32';
60
62
  // debug tab + python + the Higgsfield login). Opt in with --higgsfield; the backend routes everyone's
61
63
  // Higgsfield jobs to a bridge that advertises this.
62
64
  const HIGGSFIELD = process.argv.includes('--higgsfield') || process.env.STORYBOARD_HIGGSFIELD === '1';
65
+ // --infer → this machine is the project's tryinfer.com Seedance 2.0 host (its logged-in Chrome debug tab).
66
+ const INFER = process.argv.includes('--infer') || process.env.STORYBOARD_INFER === '1';
63
67
  // Auto-confirm Higgsfield's copyright/likeness "rights verification" gate on a rendered clip (otherwise a
64
68
  // gated clip stalls until timeout). ON by default — it asserts you own the rights to YOUR generated content.
65
69
  // Opt out with --no-own-rights or STORYBOARD_OWN_RIGHTS=0.
@@ -69,6 +73,7 @@ const PYTHON_BIN = arg('python-bin', process.env.PYTHON_BIN || (IS_WIN ? 'python
69
73
  const HF_CLI = arg('higgsfield-cli-bin', process.env.HIGGSFIELD_CLI_BIN || 'higgsfield');
70
74
  const CDP_HTTP = arg('cdp', process.env.CDP_HTTP || 'http://localhost:9222'); // the debug Chrome endpoint
71
75
  const HF_SCRIPT = join(dirname(fileURLToPath(import.meta.url)), 'higgsfield_client.py');
76
+ const TF_SCRIPT = join(dirname(fileURLToPath(import.meta.url)), 'tryinfer_client.py');
72
77
  // claude supports `--output-format json` (wraps as {result}); gemini's CLI (<=0.1.x) does NOT — it
73
78
  // prints raw text, and passing the flag makes it dump --help. Newer gemini supports it (wraps as
74
79
  // {response}) — opt in with GEMINI_JSON=1. extractText() handles wrapped OR raw either way.
@@ -193,15 +198,19 @@ const YTDLP_VIDEO_EXTS = ['mp4', 'webm', 'mov', 'm4v', 'mkv', 'ogv'];
193
198
  // cap what we ship back over the socket — base64 inflates ~1.37×, and the ws frame limit is 100 MiB, so
194
199
  // 60 MB of video → ~82 MB frame, safely under. Short social clips are far smaller; big files → download+upload.
195
200
  const YTDLP_MAX_BYTES = 60 * 1024 * 1024;
196
- async function runYtdlp(url) {
201
+ // Cap RESOLUTION, not filesize. X/HLS only report size after probing, so `--max-filesize` ABORTS m3u8
202
+ // streams ("File is larger than max-filesize") before downloading at all. Picking best mp4 <=1080p keeps
203
+ // the file small enough to relay (a clip's 1440p variant can be ~100 MB, its 720p ~17 MB); the post-download
204
+ // byte guard below is the real backstop. Overridable per job via `format`.
205
+ const YTDLP_FMT = 'b[ext=mp4][height<=1080]/b[height<=1080]/bv*[height<=1080][ext=mp4]+ba/b[ext=mp4]/b';
206
+ async function runYtdlp(url, format) {
197
207
  if (!/^https?:\/\//i.test(String(url || ''))) throw new Error('provide an http(s) URL');
198
208
  const dir = await mkdtemp(join(tmpdir(), 'sb-ytdlp-'));
199
209
  try {
200
210
  const args = [
201
211
  '--no-playlist', '--no-progress', '--no-warnings', '--restrict-filenames',
202
- '-f', 'b[ext=mp4]/bv*[ext=mp4]+ba/b', '--merge-output-format', 'mp4',
203
- // keep clips reasonable for the socket round-trip
204
- '--max-filesize', String(YTDLP_MAX_BYTES),
212
+ '-f', (typeof format === 'string' && format.trim()) ? format.trim() : YTDLP_FMT,
213
+ '--merge-output-format', 'mp4',
205
214
  '-o', join(dir, '%(title).80B.%(ext)s'), String(url),
206
215
  ];
207
216
  await new Promise((res, rej) => {
@@ -302,6 +311,50 @@ async function handleHiggsfield(msg, ws) {
302
311
  }
303
312
  }
304
313
 
314
+ // ---- tryinfer.com i2v: run the local Python client (CDP → logged-in Chrome debug tab) and stream its
315
+ // NDJSON progress/done/error events back. Reference images arrive as PUBLIC URLs (tryinfer fetches them),
316
+ // so — unlike Higgsfield — there's no image upload / temp files: we just pass --image-url per ref. ----
317
+ function handleTryinfer(msg, ws) {
318
+ const id = msg.id;
319
+ const p = msg.params || {};
320
+ try {
321
+ const args = ['-u', TF_SCRIPT, '--emit-json'];
322
+ if (p.resumeTaskId) {
323
+ args.push('--resume-task', String(p.resumeTaskId)); // poll an existing task, no submit
324
+ } else {
325
+ args.push('--prompt', String(p.prompt ?? ''),
326
+ '--duration', String(Math.max(1, Math.round(p.durationSec || 5))),
327
+ '--aspect', String(p.aspectRatio || '1:1'),
328
+ '--resolution', String(p.resolution || '1080p'));
329
+ if (p.audio === false) args.push('--no-audio');
330
+ for (const u of (Array.isArray(p.imageUrls) ? p.imageUrls : [])) args.push('--image-url', String(u));
331
+ }
332
+ const child = spawn(PYTHON_BIN, args, { env: { ...process.env, CDP_HTTP }, stdio: ['ignore', 'pipe', 'pipe'] });
333
+ tfChildren.set(id, child);
334
+ let buf = '', sawDone = false, stderrTail = '';
335
+ child.stdout.on('data', (d) => {
336
+ buf += d.toString();
337
+ let nl;
338
+ while ((nl = buf.indexOf('\n')) >= 0) {
339
+ const line = buf.slice(0, nl).trim();
340
+ buf = buf.slice(nl + 1);
341
+ if (!line) continue;
342
+ let ev; try { ev = JSON.parse(line); } catch { continue; }
343
+ if (ev.event === 'done') sawDone = true;
344
+ ws.send(JSON.stringify({ type: 'tryinferEvent', id, ...ev }));
345
+ }
346
+ });
347
+ child.stderr.on('data', (d) => { stderrTail = (stderrTail + d.toString()).slice(-8000); });
348
+ child.on('error', (e) => { tfChildren.delete(id); ws.send(JSON.stringify({ type: 'tryinferEvent', id, event: 'error', reason: 'spawn', detail: `${PYTHON_BIN}: ${e.message}` })); });
349
+ child.on('close', (code) => {
350
+ tfChildren.delete(id);
351
+ if (!sawDone && code !== 0) ws.send(JSON.stringify({ type: 'tryinferEvent', id, event: 'error', reason: 'exit', detail: `python exited ${code}: ${stderrTail}` }));
352
+ });
353
+ } catch (e) {
354
+ ws.send(JSON.stringify({ type: 'tryinferEvent', id, event: 'error', reason: 'bridge', detail: String(e?.message ?? e) }));
355
+ }
356
+ }
357
+
305
358
  // Pull the result video URL out of the CLI's `--json` output. The exact schema isn't publicly documented,
306
359
  // so scan defensively: each line (the CLI may print logs then a final JSON object), then known fields, then
307
360
  // any media URL. On a miss the caller returns the raw tail in the error so we can lock the field fast.
@@ -420,7 +473,7 @@ function connect() {
420
473
  gemini: await cliAvailable(BIN_FOR.gemini),
421
474
  };
422
475
  const hfCli = await cliAvailable(HF_CLI); // official `higgsfield` CLI present + runnable → can serve credit-billed renders
423
- ws.send(JSON.stringify({ type: 'hello', version: VERSION, provider: PROVIDER, providers, files: !!FILES_ROOT, higgsfield: HIGGSFIELD, higgsfieldCli: hfCli, inflight: [...inflightJobs] }));
476
+ ws.send(JSON.stringify({ type: 'hello', version: VERSION, provider: PROVIDER, providers, files: !!FILES_ROOT, higgsfield: HIGGSFIELD, higgsfieldCli: hfCli, infer: INFER, inflight: [...inflightJobs] }));
424
477
  log(`connected to ${url} — serving jobs with: ${PROVIDER} (${BIN})${FILES_ROOT ? `; files → ${FILES_ROOT}` : ''}${HIGGSFIELD ? `; HIGGSFIELD HOST (python: ${PYTHON_BIN}, cdp: ${CDP_HTTP})` : ''}${hfCli ? `; HIGGSFIELD CLI (${HF_CLI})` : ''}`);
425
478
  if (!providers[PROVIDER]) {
426
479
  log(`WARNING: '${BIN}' was not found / not runnable. Install it and log in, or pass --provider/--*_BIN. Jobs will fail until then.`);
@@ -467,7 +520,7 @@ function connect() {
467
520
  }
468
521
  } else if (msg.type === 'ytdlp' && msg.id) {
469
522
  log(`ytdlp ${msg.id} → ${String(msg.url || '').slice(0, 80)}`);
470
- runYtdlp(msg.url).then(
523
+ runYtdlp(msg.url, msg.format).then(
471
524
  (r) => { deliver({ type: 'ytdlpResult', id: msg.id, data: r.data, ext: r.ext, title: r.title }); log(`ytdlp ${msg.id} ✓ ${r.ext} (${Math.round(r.data.length * 0.75 / 1024)}KB)`); },
472
525
  (e) => { deliver({ type: 'ytdlpError', id: msg.id, message: String(e?.message ?? e) }); log(`ytdlp ${msg.id} ✗ ${e?.message ?? e}`); },
473
526
  );
@@ -483,6 +536,12 @@ function connect() {
483
536
  } else if (msg.type === 'higgsfield-cli-cancel' && msg.id) {
484
537
  const child = hfCliChildren.get(msg.id);
485
538
  if (child) { log(`higgsfield-cli ${msg.id} → cancel`); try { child.kill('SIGTERM'); } catch { /* already gone */ } hfCliChildren.delete(msg.id); }
539
+ } else if (msg.type === 'tryinfer' && msg.id) {
540
+ if (!INFER) ws.send(JSON.stringify({ type: 'tryinferEvent', id: msg.id, event: 'error', reason: 'disabled', detail: 'this bridge was not started with --infer' }));
541
+ else { log(`tryinfer ${msg.id} → running`); handleTryinfer(msg, ws); }
542
+ } else if (msg.type === 'tryinfer-cancel' && msg.id) {
543
+ const child = tfChildren.get(msg.id);
544
+ if (child) { log(`tryinfer ${msg.id} → cancel`); try { child.kill('SIGTERM'); } catch { /* already gone */ } tfChildren.delete(msg.id); }
486
545
  }
487
546
  });
488
547
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "storyboard-bridge",
3
- "version": "0.6.0",
3
+ "version": "0.7.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": {
@@ -0,0 +1,293 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ tryinfer.com (Infer) Seedance 2.0 reference-to-video, driven through your ALREADY-LOGGED-IN Chrome tab
4
+ over the DevTools Protocol — the same in-page-fetch trick as higgsfield_client.py. Every /api call runs
5
+ as fetch() INSIDE the real tab, so tryinfer's Clerk SESSION COOKIE rides along automatically (its API is
6
+ cookie-authed — no bearer, no token minting).
7
+
8
+ We pass reference_image_urls as ARBITRARY PUBLIC URLs (verified: tryinfer fetches them), so there is NO
9
+ upload step — the backend publishes each board to a public R2 URL and hands us the URL.
10
+
11
+ Flow (from bridge/tryinfer_capture.json):
12
+ POST /api/create/generation-tasks {args:{model,capability,input:{reference_image_urls,prompt,
13
+ duration_seconds,aspect_ratio,resolution,audio}}, group:{…}}
14
+ -> {id, status:"PENDING"}
15
+ GET /api/create/generation-tasks/{id} -> status PENDING|RUNNING|SUCCEEDED|FAILED
16
+ GET /api/create/generation-tasks/{id}/result -> {output:{video_url, width, height, …}}
17
+
18
+ Two run modes:
19
+ • --emit-json : machine mode for the desktop bridge — NDJSON events on stdout, human logs on stderr.
20
+ • (default) : standalone test — human logs on stderr, final JSON on stdout.
21
+
22
+ Deps (same Windows Python as the Higgsfield bridge): websocket-client
23
+ Usage (standalone test):
24
+ python tryinfer_client.py --prompt "a slow cinematic push-in" [--image-url URL] [--duration 5] \
25
+ [--aspect 1:1] [--resolution 1080p] [--no-audio]
26
+ """
27
+ import argparse
28
+ import json
29
+ import sys
30
+ import time
31
+ import urllib.request
32
+ import uuid
33
+
34
+ try:
35
+ import websocket # websocket-client
36
+ except ImportError:
37
+ sys.exit("Missing dep. Install with: pip install websocket-client")
38
+
39
+
40
+ def log(*a):
41
+ print(*a, file=sys.stderr, flush=True)
42
+
43
+
44
+ # NDJSON progress protocol (only active in --emit-json). The backend's TryinferProvider parses these to
45
+ # drive the node's live status: {"event":"progress","phase":…}, {"event":"done","taskId","videoUrl"}, error.
46
+ _JSON_OUT = None
47
+
48
+
49
+ def event(**fields):
50
+ if _JSON_OUT is not None:
51
+ _JSON_OUT.write(json.dumps(fields) + "\n")
52
+ _JSON_OUT.flush()
53
+
54
+
55
+ def http_json(url):
56
+ with urllib.request.urlopen(url, timeout=10) as r:
57
+ return json.loads(r.read().decode())
58
+
59
+
60
+ # ---- minimal synchronous CDP client (same shape as higgsfield_client.py) ----
61
+ class CDP:
62
+ def __init__(self, ws_url):
63
+ self.ws = websocket.create_connection(ws_url, max_size=None, timeout=30)
64
+ self._id = 0
65
+
66
+ def send(self, method, params=None, session_id=None):
67
+ self._id += 1
68
+ msg = {"id": self._id, "method": method, "params": params or {}}
69
+ if session_id:
70
+ msg["sessionId"] = session_id
71
+ self.ws.send(json.dumps(msg))
72
+ return self._id
73
+
74
+ def wait_for(self, msg_id, timeout=30):
75
+ deadline = time.time() + timeout
76
+ while time.time() < deadline:
77
+ self.ws.settimeout(max(0.1, deadline - time.time()))
78
+ try:
79
+ msg = json.loads(self.ws.recv())
80
+ except websocket.WebSocketTimeoutException:
81
+ break
82
+ if msg.get("id") == msg_id:
83
+ if "error" in msg:
84
+ raise RuntimeError(f"CDP error for {msg_id}: {msg['error']}")
85
+ return msg.get("result", {})
86
+ raise TimeoutError(f"Timed out waiting for CDP response id={msg_id}")
87
+
88
+ def call(self, method, params=None, session_id=None, timeout=30):
89
+ return self.wait_for(self.send(method, params, session_id), timeout)
90
+
91
+ def close(self):
92
+ try:
93
+ self.ws.close()
94
+ except Exception:
95
+ pass
96
+
97
+
98
+ class PageResponse:
99
+ def __init__(self, status, text):
100
+ self.status_code = status
101
+ self.text = text
102
+
103
+ def json(self):
104
+ return json.loads(self.text)
105
+
106
+
107
+ def _fetch_expr(method, url, body):
108
+ """fetch() in the page context. credentials:'include' → the Clerk session cookie rides along
109
+ (tryinfer's API is cookie-authed, so no Authorization bearer is needed, unlike Higgsfield)."""
110
+ parts = [
111
+ "(async()=>{try{",
112
+ f"const o={{method:{json.dumps(method)},headers:{{}},credentials:'include'}};",
113
+ ]
114
+ if body is not None:
115
+ parts.append("o.headers['content-type']='application/json';")
116
+ parts.append(f"o.body=JSON.stringify({json.dumps(body)});")
117
+ parts += [
118
+ f"const r=await fetch({json.dumps(url)},o);",
119
+ "const x=await r.text();",
120
+ "return JSON.stringify({status:r.status,body:x});",
121
+ "}catch(e){return JSON.stringify({status:0,error:String((e&&e.message)||e)});}})()",
122
+ ]
123
+ return "".join(parts)
124
+
125
+
126
+ class BrowserSession:
127
+ def __init__(self, cdp_http, match):
128
+ ver = http_json(f"{cdp_http}/json/version")
129
+ log(f"Connected to {ver.get('Browser')}")
130
+ tid = url = None
131
+ for t in http_json(f"{cdp_http}/json/list"):
132
+ if t.get("type") == "page" and match in (t.get("url") or ""):
133
+ tid, url = t["id"], t["url"]
134
+ break
135
+ if not tid:
136
+ raise RuntimeError(f"No open tab whose URL contains '{match}'. Open tryinfer.com and log in.")
137
+ log(f"Routing API calls through tab: {url}")
138
+ self.cdp = CDP(ver["webSocketDebuggerUrl"])
139
+ self.session_id = self.cdp.call("Target.attachToTarget", {"targetId": tid, "flatten": True})["sessionId"]
140
+ self.cdp.call("Runtime.enable", session_id=self.session_id)
141
+
142
+ def request(self, method, url, body=None, timeout=120, retries=3):
143
+ last = None
144
+ for attempt in range(retries):
145
+ res = self.cdp.call(
146
+ "Runtime.evaluate",
147
+ {"expression": _fetch_expr(method, url, body), "awaitPromise": True, "returnByValue": True},
148
+ session_id=self.session_id, timeout=timeout)
149
+ val = res.get("result", {}).get("value")
150
+ if val is None:
151
+ last = f"evaluate failed: {res.get('exceptionDetails')}"
152
+ else:
153
+ data = json.loads(val)
154
+ if data.get("status") == 0:
155
+ last = f"in-page fetch error: {data.get('error')}"
156
+ else:
157
+ return PageResponse(data["status"], data["body"])
158
+ if attempt < retries - 1:
159
+ log(f" transient request error, retrying… ({last})")
160
+ time.sleep(3)
161
+ raise RuntimeError(last)
162
+
163
+ def close(self):
164
+ try:
165
+ self.cdp.call("Target.detachFromTarget", {"sessionId": self.session_id}, timeout=5)
166
+ except Exception:
167
+ pass
168
+ self.cdp.close()
169
+
170
+
171
+ API = "https://tryinfer.com/api"
172
+ RATIO_WORD = {"1:1": "square", "16:9": "landscape", "9:16": "portrait", "4:3": "landscape", "3:4": "portrait"}
173
+ DONE = {"SUCCEEDED", "COMPLETED"}
174
+ FAILED = {"FAILED", "ERROR", "CANCELLED", "CANCELED"}
175
+
176
+
177
+ def submit(session, prompt, image_urls, duration, aspect, resolution, audio):
178
+ """POST the generation task; reference_image_urls = the public URLs we pass. Returns the task id."""
179
+ body = {
180
+ "args": {
181
+ "model": "seedance-2.0-pro",
182
+ "capability": "reference-to-video",
183
+ "input": {
184
+ "reference_image_urls": image_urls,
185
+ "prompt": prompt,
186
+ "duration_seconds": duration,
187
+ "aspect_ratio": aspect,
188
+ "resolution": resolution,
189
+ "audio": audio,
190
+ },
191
+ },
192
+ "group": {
193
+ "groupId": str(uuid.uuid4()),
194
+ "position": 0,
195
+ "meta": {
196
+ "prompt": prompt, "medium": "video", "ratio": RATIO_WORD.get(aspect, "square"),
197
+ "kind": "reference", "referenceImageUrls": image_urls, "referenceRequestIds": [],
198
+ },
199
+ },
200
+ }
201
+ r = session.request("POST", f"{API}/create/generation-tasks", body=body)
202
+ if r.status_code not in (200, 201, 202):
203
+ raise RuntimeError(f"submit failed ({r.status_code}): {r.text[:800]}")
204
+ tid = r.json().get("id")
205
+ if not tid:
206
+ raise RuntimeError(f"submit returned no task id: {r.text[:400]}")
207
+ return tid
208
+
209
+
210
+ def poll(session, task_id, on_status=None, timeout=1800, interval=3):
211
+ """Poll the task to a terminal state. Fires on_status(status) on each change. Returns final task JSON."""
212
+ deadline = time.time() + timeout
213
+ last = None
214
+ while time.time() < deadline:
215
+ j = session.request("GET", f"{API}/create/generation-tasks/{task_id}").json()
216
+ st = j.get("status")
217
+ if st != last:
218
+ last = st
219
+ if on_status:
220
+ on_status(st)
221
+ if st in DONE:
222
+ return j
223
+ if st in FAILED:
224
+ err = j.get("error")
225
+ raise RuntimeError(f"task {st}: {json.dumps(err) if err else 'no detail'}")
226
+ time.sleep(interval)
227
+ raise TimeoutError(f"task {task_id} did not finish within {timeout}s (last status {last})")
228
+
229
+
230
+ def get_result(session, task_id):
231
+ """GET the result; return (video_url, output_dict). Raises on moderation block / missing url."""
232
+ j = session.request("GET", f"{API}/create/generation-tasks/{task_id}/result").json()
233
+ mod = j.get("moderation_status")
234
+ if mod and mod != "allowed":
235
+ raise RuntimeError(f"moderation blocked: {mod}")
236
+ out = j.get("output") or {}
237
+ url = out.get("video_url")
238
+ if not url:
239
+ raise RuntimeError(f"no video_url in result: {json.dumps(j)[:800]}")
240
+ return url, out
241
+
242
+
243
+ def main():
244
+ ap = argparse.ArgumentParser()
245
+ ap.add_argument("--prompt", default="")
246
+ ap.add_argument("--image-url", action="append", default=[],
247
+ help="public reference image URL; repeat for ordered refs. Default (test) is gstatic's sample JPEG.")
248
+ ap.add_argument("--duration", type=int, default=5)
249
+ ap.add_argument("--aspect", default="1:1")
250
+ ap.add_argument("--resolution", default="1080p")
251
+ ap.add_argument("--no-audio", action="store_true")
252
+ ap.add_argument("--resume-task", default=None, help="skip submit; just poll+result an existing task id")
253
+ ap.add_argument("--emit-json", action="store_true", help="NDJSON events on stdout, human logs on stderr")
254
+ ap.add_argument("--match", default="tryinfer", help="substring of the logged-in tab's URL")
255
+ ap.add_argument("--host", default="localhost")
256
+ ap.add_argument("--port", type=int, default=9222)
257
+ args = ap.parse_args()
258
+
259
+ if args.emit_json:
260
+ global _JSON_OUT
261
+ _JSON_OUT = sys.stdout
262
+ sys.stdout = sys.stderr # any stray print() can't corrupt the event stream
263
+
264
+ image_urls = args.image_url or ["https://www.gstatic.com/webp/gallery/1.jpg"] # default = permissive test image
265
+ sess = BrowserSession(f"http://{args.host}:{args.port}", args.match)
266
+ try:
267
+ if args.resume_task:
268
+ task_id = args.resume_task
269
+ log(f"resuming task {task_id}")
270
+ event(event="progress", phase="rendering", taskId=task_id)
271
+ else:
272
+ task_id = submit(sess, args.prompt, image_urls, args.duration, args.aspect, args.resolution, not args.no_audio)
273
+ log(f"submitted task {task_id}")
274
+ event(event="progress", phase="submitted", taskId=task_id)
275
+ poll(sess, task_id, on_status=lambda st: (log(f" status={st}"), event(event="progress", phase=st.lower(), taskId=task_id)))
276
+ video_url, out = get_result(sess, task_id)
277
+ except Exception as e:
278
+ event(event="error", reason="tryinfer", detail=str(e))
279
+ log(f"\n❌ {e}")
280
+ sys.exit(1)
281
+ finally:
282
+ sess.close()
283
+
284
+ event(event="done", taskId=task_id, videoUrl=video_url,
285
+ width=out.get("width"), height=out.get("height"), duration=out.get("duration_seconds"))
286
+ log(f"\n✅ {out.get('width')}x{out.get('height')} {out.get('duration_seconds')}s")
287
+ if not args.emit_json:
288
+ print(json.dumps({"ok": True, "video_url": video_url, "width": out.get("width"),
289
+ "height": out.get("height"), "duration": out.get("duration_seconds")}))
290
+
291
+
292
+ if __name__ == "__main__":
293
+ main()