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 +65 -6
- package/package.json +1 -1
- package/tryinfer_client.py +293 -0
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
|
-
|
|
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', '
|
|
203
|
-
|
|
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
|
@@ -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()
|