vrex-flow-engine 0.2.0__py3-none-any.whl
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.
- flow_engine/__init__.py +4 -0
- flow_engine/bridge/__init__.py +6 -0
- flow_engine/bridge/flow_client.py +444 -0
- flow_engine/bridge/flow_sdk.py +1743 -0
- flow_engine/bridge/ws_server.py +84 -0
- flow_engine/catalog.py +332 -0
- flow_engine/cli/__init__.py +1 -0
- flow_engine/cli/app.py +149 -0
- flow_engine/cli/config.py +81 -0
- flow_engine/cli/dashboard.py +125 -0
- flow_engine/cli/health.py +40 -0
- flow_engine/cli/processes.py +109 -0
- flow_engine/cli/supervisor.py +184 -0
- flow_engine/config.py +43 -0
- flow_engine/ingest.py +172 -0
- flow_engine/job_store.py +28 -0
- flow_engine/jobs.py +245 -0
- flow_engine/main.py +187 -0
- flow_engine/media.py +227 -0
- flow_engine/media_store.py +202 -0
- flow_engine/openai/__init__.py +1 -0
- flow_engine/openai/_util.py +59 -0
- flow_engine/openai/images.py +134 -0
- flow_engine/openai/models.py +38 -0
- flow_engine/openai/uploads.py +70 -0
- flow_engine/openai/videos.py +289 -0
- flow_engine/pool.py +138 -0
- flow_engine/posthog_client.py +117 -0
- flow_engine/session.py +89 -0
- flow_engine/video_context.py +77 -0
- flow_engine/video_context_store.py +49 -0
- vrex_flow_engine-0.2.0.dist-info/METADATA +62 -0
- vrex_flow_engine-0.2.0.dist-info/RECORD +36 -0
- vrex_flow_engine-0.2.0.dist-info/WHEEL +5 -0
- vrex_flow_engine-0.2.0.dist-info/entry_points.txt +3 -0
- vrex_flow_engine-0.2.0.dist-info/top_level.txt +1 -0
flow_engine/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""The extension bridge: WS server + Flow client + Flow SDK.
|
|
2
|
+
|
|
3
|
+
These three modules are lifted near-verbatim from flowboard's agent. They are
|
|
4
|
+
intentionally free of any database / board dependency so they drop straight
|
|
5
|
+
into a standalone proxy. The only edits vs. the originals are the import paths.
|
|
6
|
+
"""
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
"""Bridge to the Chrome MV3 extension over WebSocket.
|
|
2
|
+
|
|
3
|
+
Ported + trimmed from flowkit (https://github.com/crisng95/flowkit).
|
|
4
|
+
|
|
5
|
+
Control flow:
|
|
6
|
+
1. Extension opens WS to :9223.
|
|
7
|
+
2. Agent sends ``{type:"callback_secret", secret}`` immediately.
|
|
8
|
+
3. When the agent wants to make an authenticated call against Google Flow /
|
|
9
|
+
aisandbox-pa, it calls ``flow_client.api_request(url, method, headers, body)``
|
|
10
|
+
which sends ``{id, method:"api_request", params}`` over WS and awaits a future.
|
|
11
|
+
4. The extension performs ``fetch(url, Authorization: Bearer <token>)`` inside
|
|
12
|
+
the user's browser session and POSTs the response to
|
|
13
|
+
``/api/ext/callback`` with ``X-Callback-Secret``.
|
|
14
|
+
5. That HTTP handler resolves the pending future by id.
|
|
15
|
+
6. WS-side inbound messages from the extension (``token_captured``,
|
|
16
|
+
``extension_ready``, ``pong``, ``status``) update our stats.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import secrets
|
|
24
|
+
import time
|
|
25
|
+
import uuid
|
|
26
|
+
from typing import Any, Optional
|
|
27
|
+
|
|
28
|
+
import httpx
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Google Flow's public API key — appears verbatim in every aisandbox-pa
|
|
34
|
+
# request URL Flow web emits. Not a secret; documented here so we don't
|
|
35
|
+
# need to plumb it through from the extension on every call.
|
|
36
|
+
_FLOW_API_KEY = "AIzaSyBtrm0o5ab1c-Ec8ZuLcGt3oJAA5VWt3pY"
|
|
37
|
+
_FLOW_CREDITS_URL = "https://aisandbox-pa.googleapis.com/v1/credits"
|
|
38
|
+
# Minimum gap between paygate-tier refreshes when the same Bearer token
|
|
39
|
+
# is re-delivered. Tier rarely changes; 60 s is fine for AccountPanel
|
|
40
|
+
# freshness and tames the credits-fetch storm an old extension can
|
|
41
|
+
# induce by re-emitting `token_captured` on every outbound request.
|
|
42
|
+
_TIER_REFRESH_MIN_INTERVAL_S = 60.0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class FlowClient:
|
|
46
|
+
"""Bridge client. One instance per pool entry (one per Chrome profile)."""
|
|
47
|
+
|
|
48
|
+
DEFAULT_TIMEOUT = 180.0 # seconds
|
|
49
|
+
|
|
50
|
+
def __init__(self, semaphore: Optional[asyncio.Semaphore] = None) -> None:
|
|
51
|
+
self._ws: Optional[Any] = None
|
|
52
|
+
self._pending: dict[str, asyncio.Future] = {}
|
|
53
|
+
self._callback_secret: str = secrets.token_urlsafe(32)
|
|
54
|
+
# Semaphore limits concurrent in-flight WS calls. Pool passes its own;
|
|
55
|
+
# standalone use (tests, single-tenant) gets a default Semaphore(4).
|
|
56
|
+
# in_flight is tracked on the owning PoolInstance, not here — we
|
|
57
|
+
# accept an optional callback pair to increment/decrement it cleanly.
|
|
58
|
+
from flow_engine.config import POOL_MAX_CONCURRENCY
|
|
59
|
+
self._semaphore: asyncio.Semaphore = semaphore or asyncio.Semaphore(
|
|
60
|
+
POOL_MAX_CONCURRENCY
|
|
61
|
+
)
|
|
62
|
+
# Callbacks set by PoolInstance so _send can update in_flight without
|
|
63
|
+
# importing pool (avoids circular deps). Both are no-ops by default.
|
|
64
|
+
self._on_send_start: Any = lambda: None
|
|
65
|
+
self._on_send_end: Any = lambda: None
|
|
66
|
+
|
|
67
|
+
self._token_captured_at: Optional[float] = None
|
|
68
|
+
self._flow_key_present: bool = False
|
|
69
|
+
# Cached Bearer token for server-side fetches against
|
|
70
|
+
# aisandbox-pa (e.g. /v1/credits for paygate tier resolution).
|
|
71
|
+
# In-memory only; cleared on extension disconnect. NOT logged
|
|
72
|
+
# anywhere — see fetch_paygate_tier() for the only consumer.
|
|
73
|
+
self._flow_key: Optional[str] = None
|
|
74
|
+
# Last time we hit /v1/credits — guards against the extension
|
|
75
|
+
# emitting `token_captured` on every outbound aisandbox-pa
|
|
76
|
+
# request (polls fire dozens per minute during video gen). The
|
|
77
|
+
# extension was patched to only emit on rotation, but we keep
|
|
78
|
+
# this dedupe so older installs don't spam the credits endpoint.
|
|
79
|
+
self._last_tier_fetch_at: Optional[float] = None
|
|
80
|
+
self._last_logged_key: Optional[str] = None
|
|
81
|
+
# Profile pushed by the extension after it resolves the Bearer
|
|
82
|
+
# token via Google's userinfo endpoint. Stays in-memory only —
|
|
83
|
+
# if the agent restarts the extension will replay it on the
|
|
84
|
+
# next WS reconnect.
|
|
85
|
+
self._user_info: Optional[dict] = None
|
|
86
|
+
# Paygate tier authoritative from /v1/credits + sku for display.
|
|
87
|
+
self._paygate_tier: Optional[str] = None
|
|
88
|
+
self._sku: Optional[str] = None # e.g. "WS_ULTRA" / "WS_PRO"
|
|
89
|
+
self._credits: Optional[int] = None
|
|
90
|
+
self._request_count = 0
|
|
91
|
+
self._success_count = 0
|
|
92
|
+
self._failed_count = 0
|
|
93
|
+
self._last_error: Optional[str] = None
|
|
94
|
+
|
|
95
|
+
# ── connection ─────────────────────────────────────────────────────────
|
|
96
|
+
@property
|
|
97
|
+
def connected(self) -> bool:
|
|
98
|
+
return self._ws is not None
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def callback_secret(self) -> str:
|
|
102
|
+
return self._callback_secret
|
|
103
|
+
|
|
104
|
+
def set_extension(self, ws: Any) -> None:
|
|
105
|
+
self._ws = ws
|
|
106
|
+
|
|
107
|
+
def clear_extension(self) -> None:
|
|
108
|
+
self._ws = None
|
|
109
|
+
self._flow_key_present = False
|
|
110
|
+
self._flow_key = None
|
|
111
|
+
# Drop the cached identity + tier — next reconnect will replay.
|
|
112
|
+
self._user_info = None
|
|
113
|
+
self._paygate_tier = None
|
|
114
|
+
self._sku = None
|
|
115
|
+
self._credits = None
|
|
116
|
+
for fut in self._pending.values():
|
|
117
|
+
if not fut.done():
|
|
118
|
+
fut.set_exception(ConnectionError("extension_disconnected"))
|
|
119
|
+
self._pending.clear()
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def user_info(self) -> Optional[dict]:
|
|
123
|
+
return self._user_info
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def paygate_tier(self) -> Optional[str]:
|
|
127
|
+
return self._paygate_tier
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def sku(self) -> Optional[str]:
|
|
131
|
+
return self._sku
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def credits(self) -> Optional[int]:
|
|
135
|
+
return self._credits
|
|
136
|
+
|
|
137
|
+
async def fetch_paygate_tier(self) -> bool:
|
|
138
|
+
"""Authoritative paygate tier resolution via the official Flow
|
|
139
|
+
/v1/credits endpoint. Replaces the passive request-body sniffer
|
|
140
|
+
as the primary path.
|
|
141
|
+
|
|
142
|
+
Triggered automatically when `handle_message` receives a
|
|
143
|
+
`token_captured` message (extension just captured a fresh
|
|
144
|
+
Bearer token), and on demand via /api/auth/scan when the
|
|
145
|
+
cache is cold but the WS is open.
|
|
146
|
+
|
|
147
|
+
Returns True on success (tier cached), False otherwise. Failure
|
|
148
|
+
modes:
|
|
149
|
+
- No Bearer token cached (extension hasn't pushed one yet)
|
|
150
|
+
- HTTP 4xx (token expired / revoked)
|
|
151
|
+
- HTTP 5xx / network (transient — caller can retry)
|
|
152
|
+
- Response missing `userPaygateTier` (Flow API contract change)
|
|
153
|
+
|
|
154
|
+
IMPORTANT: never log the Bearer token. The error path captures
|
|
155
|
+
only the HTTP status / response shape, never headers.
|
|
156
|
+
"""
|
|
157
|
+
if not self._flow_key:
|
|
158
|
+
return False
|
|
159
|
+
try:
|
|
160
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
161
|
+
resp = await client.get(
|
|
162
|
+
_FLOW_CREDITS_URL,
|
|
163
|
+
params={"key": _FLOW_API_KEY},
|
|
164
|
+
headers={
|
|
165
|
+
"authorization": f"Bearer {self._flow_key}",
|
|
166
|
+
"origin": "https://labs.google",
|
|
167
|
+
"referer": "https://labs.google/",
|
|
168
|
+
},
|
|
169
|
+
)
|
|
170
|
+
except httpx.HTTPError as exc:
|
|
171
|
+
logger.warning("fetch_paygate_tier transport error: %s", exc)
|
|
172
|
+
return False
|
|
173
|
+
if resp.status_code != 200:
|
|
174
|
+
logger.warning(
|
|
175
|
+
"fetch_paygate_tier returned HTTP %s (token may be expired)",
|
|
176
|
+
resp.status_code,
|
|
177
|
+
)
|
|
178
|
+
return False
|
|
179
|
+
try:
|
|
180
|
+
data = resp.json()
|
|
181
|
+
except Exception: # noqa: BLE001
|
|
182
|
+
logger.warning("fetch_paygate_tier: response was not JSON")
|
|
183
|
+
return False
|
|
184
|
+
tier = data.get("userPaygateTier")
|
|
185
|
+
if tier not in ("PAYGATE_TIER_ONE", "PAYGATE_TIER_TWO"):
|
|
186
|
+
logger.warning(
|
|
187
|
+
"fetch_paygate_tier: response missing userPaygateTier (got %r)",
|
|
188
|
+
tier,
|
|
189
|
+
)
|
|
190
|
+
return False
|
|
191
|
+
self._paygate_tier = tier
|
|
192
|
+
sku = data.get("sku")
|
|
193
|
+
if isinstance(sku, str):
|
|
194
|
+
self._sku = sku
|
|
195
|
+
credits_val = data.get("credits")
|
|
196
|
+
if isinstance(credits_val, int):
|
|
197
|
+
self._credits = credits_val
|
|
198
|
+
logger.info(
|
|
199
|
+
"fetch_paygate_tier resolved tier=%s sku=%s credits=%s",
|
|
200
|
+
tier, self._sku, self._credits,
|
|
201
|
+
)
|
|
202
|
+
return True
|
|
203
|
+
|
|
204
|
+
# ── inbound handling ───────────────────────────────────────────────────
|
|
205
|
+
async def handle_message(self, data: dict) -> None:
|
|
206
|
+
t = data.get("type")
|
|
207
|
+
if t == "extension_ready":
|
|
208
|
+
self._flow_key_present = bool(data.get("flowKeyPresent"))
|
|
209
|
+
logger.info("extension_ready flowKeyPresent=%s", self._flow_key_present)
|
|
210
|
+
return
|
|
211
|
+
if t == "token_captured":
|
|
212
|
+
self._flow_key_present = True
|
|
213
|
+
self._token_captured_at = time.time()
|
|
214
|
+
flow_key = data.get("flowKey")
|
|
215
|
+
if isinstance(flow_key, str) and flow_key:
|
|
216
|
+
key_changed = flow_key != self._flow_key
|
|
217
|
+
self._flow_key = flow_key
|
|
218
|
+
# Defensive dedupe — see _last_tier_fetch_at field comment.
|
|
219
|
+
# Skip the log + credits refetch when the token hasn't
|
|
220
|
+
# rotated and we already fetched within the rate-limit
|
|
221
|
+
# window. Without this, an older extension re-sending the
|
|
222
|
+
# same token on every poll trips one /v1/credits per poll.
|
|
223
|
+
now = time.time()
|
|
224
|
+
last = self._last_tier_fetch_at or 0.0
|
|
225
|
+
if key_changed or (now - last) > _TIER_REFRESH_MIN_INTERVAL_S:
|
|
226
|
+
if flow_key != self._last_logged_key:
|
|
227
|
+
logger.info("token_captured (len=%d)", len(flow_key))
|
|
228
|
+
self._last_logged_key = flow_key
|
|
229
|
+
self._last_tier_fetch_at = now
|
|
230
|
+
# Authoritative tier resolution — fetch /v1/credits in
|
|
231
|
+
# the background so the AccountPanel sees a real tier
|
|
232
|
+
# within an HTTP RTT instead of waiting for the user's
|
|
233
|
+
# Flow tab to emit a request the passive sniffer can
|
|
234
|
+
# see. Don't await: WS handler must stay responsive.
|
|
235
|
+
asyncio.create_task(self.fetch_paygate_tier())
|
|
236
|
+
return
|
|
237
|
+
if t == "user_info":
|
|
238
|
+
info = data.get("userInfo")
|
|
239
|
+
if isinstance(info, dict):
|
|
240
|
+
# Whitelist on intake — Google's userinfo response can
|
|
241
|
+
# carry id / locale / hd / given_name / family_name etc.
|
|
242
|
+
# The /api/auth/me route filters on output, but caching
|
|
243
|
+
# the full dict here means any future surface that
|
|
244
|
+
# returns flow_client.user_info directly leaks PII.
|
|
245
|
+
# Clamp at the door instead.
|
|
246
|
+
allowed = ("email", "name", "picture", "verified_email")
|
|
247
|
+
self._user_info = {k: info[k] for k in allowed if k in info}
|
|
248
|
+
logger.info(
|
|
249
|
+
"user_info captured for %s",
|
|
250
|
+
self._user_info.get("email") or "<no email>",
|
|
251
|
+
)
|
|
252
|
+
return
|
|
253
|
+
if t == "pong":
|
|
254
|
+
return
|
|
255
|
+
# Inbound response (legacy path; production flow uses HTTP callback)
|
|
256
|
+
req_id = data.get("id")
|
|
257
|
+
if req_id and req_id in self._pending:
|
|
258
|
+
self._resolve(req_id, data)
|
|
259
|
+
|
|
260
|
+
def resolve_callback(self, data: dict) -> bool:
|
|
261
|
+
"""Called by the HTTP callback endpoint after validating the secret.
|
|
262
|
+
|
|
263
|
+
Returns True if a pending future matched.
|
|
264
|
+
"""
|
|
265
|
+
req_id = data.get("id")
|
|
266
|
+
if not req_id or req_id not in self._pending:
|
|
267
|
+
return False
|
|
268
|
+
self._resolve(req_id, data)
|
|
269
|
+
return True
|
|
270
|
+
|
|
271
|
+
def _resolve(self, req_id: str, data: dict) -> None:
|
|
272
|
+
fut = self._pending.pop(req_id, None)
|
|
273
|
+
if not fut or fut.done():
|
|
274
|
+
return
|
|
275
|
+
# Count as failure if (a) an explicit `error` field is set OR
|
|
276
|
+
# (b) the HTTP status is a 4xx/5xx. Otherwise success.
|
|
277
|
+
status = data.get("status")
|
|
278
|
+
http_error = isinstance(status, int) and status >= 400
|
|
279
|
+
explicit_error = bool(data.get("error"))
|
|
280
|
+
if http_error or explicit_error:
|
|
281
|
+
self._failed_count += 1
|
|
282
|
+
msg = data.get("error") or f"API_{status}"
|
|
283
|
+
self._last_error = str(msg)[:200]
|
|
284
|
+
fut.set_result(data)
|
|
285
|
+
else:
|
|
286
|
+
self._success_count += 1
|
|
287
|
+
fut.set_result(data)
|
|
288
|
+
|
|
289
|
+
# ── outbound ──────────────────────────────────────────────────────────
|
|
290
|
+
async def notify(self, message: dict) -> bool:
|
|
291
|
+
"""Fire-and-forget WS push to the extension. Returns False when the
|
|
292
|
+
extension isn't connected so callers can surface a meaningful
|
|
293
|
+
diagnostic instead of silently losing the message.
|
|
294
|
+
|
|
295
|
+
Used by the logout flow (tell extension to clear its in-memory
|
|
296
|
+
token + cached userinfo) and the scan flow (ask extension to
|
|
297
|
+
re-fetch userinfo when the agent has a connection but the cache
|
|
298
|
+
is empty).
|
|
299
|
+
"""
|
|
300
|
+
if not self.connected or self._ws is None:
|
|
301
|
+
return False
|
|
302
|
+
try:
|
|
303
|
+
await self._ws.send(json.dumps(message))
|
|
304
|
+
return True
|
|
305
|
+
except Exception as exc: # noqa: BLE001
|
|
306
|
+
logger.warning("notify failed: %s", exc)
|
|
307
|
+
return False
|
|
308
|
+
|
|
309
|
+
async def _send(self, method: str, params: dict, timeout: Optional[float] = None) -> dict:
|
|
310
|
+
if not self.connected:
|
|
311
|
+
return {"error": "extension_disconnected"}
|
|
312
|
+
|
|
313
|
+
# Acquire the per-instance concurrency semaphore before sending.
|
|
314
|
+
# Excess callers queue here — no 503, just back-pressure.
|
|
315
|
+
async with self._semaphore:
|
|
316
|
+
self._on_send_start()
|
|
317
|
+
try:
|
|
318
|
+
return await self._send_inner(method, params, timeout)
|
|
319
|
+
finally:
|
|
320
|
+
self._on_send_end()
|
|
321
|
+
|
|
322
|
+
async def _send_inner(
|
|
323
|
+
self, method: str, params: dict, timeout: Optional[float] = None
|
|
324
|
+
) -> dict:
|
|
325
|
+
req_id = str(uuid.uuid4())
|
|
326
|
+
fut: asyncio.Future = asyncio.get_running_loop().create_future()
|
|
327
|
+
self._pending[req_id] = fut
|
|
328
|
+
self._request_count += 1
|
|
329
|
+
|
|
330
|
+
payload = {"id": req_id, "method": method, "params": params}
|
|
331
|
+
try:
|
|
332
|
+
await self._ws.send(json.dumps(payload))
|
|
333
|
+
return await asyncio.wait_for(fut, timeout=timeout or self.DEFAULT_TIMEOUT)
|
|
334
|
+
except asyncio.TimeoutError:
|
|
335
|
+
self._pending.pop(req_id, None)
|
|
336
|
+
self._failed_count += 1
|
|
337
|
+
self._last_error = "timeout"
|
|
338
|
+
return {"error": "timeout"}
|
|
339
|
+
except ConnectionError as exc:
|
|
340
|
+
self._pending.pop(req_id, None)
|
|
341
|
+
self._failed_count += 1
|
|
342
|
+
self._last_error = str(exc)
|
|
343
|
+
return {"error": str(exc)}
|
|
344
|
+
except Exception as exc: # noqa: BLE001
|
|
345
|
+
self._pending.pop(req_id, None)
|
|
346
|
+
self._failed_count += 1
|
|
347
|
+
self._last_error = str(exc)
|
|
348
|
+
return {"error": str(exc)}
|
|
349
|
+
|
|
350
|
+
async def api_request(
|
|
351
|
+
self,
|
|
352
|
+
url: str,
|
|
353
|
+
method: str = "POST",
|
|
354
|
+
headers: Optional[dict] = None,
|
|
355
|
+
body: Any = None,
|
|
356
|
+
captcha_action: Optional[str] = None,
|
|
357
|
+
timeout: Optional[float] = None,
|
|
358
|
+
) -> dict:
|
|
359
|
+
"""Proxy an HTTP call against aisandbox-pa.googleapis.com through the
|
|
360
|
+
extension's browser session. If ``captcha_action`` is set, the
|
|
361
|
+
extension solves reCAPTCHA on an active Flow tab before firing the
|
|
362
|
+
fetch and injects the token into the body's recaptchaContext fields.
|
|
363
|
+
"""
|
|
364
|
+
params: dict[str, Any] = {
|
|
365
|
+
"url": url,
|
|
366
|
+
"method": method,
|
|
367
|
+
"headers": headers or {},
|
|
368
|
+
"body": body,
|
|
369
|
+
}
|
|
370
|
+
if captcha_action:
|
|
371
|
+
params["captchaAction"] = captcha_action
|
|
372
|
+
return await self._send("api_request", params, timeout=timeout)
|
|
373
|
+
|
|
374
|
+
async def trpc_request(
|
|
375
|
+
self,
|
|
376
|
+
url: str,
|
|
377
|
+
method: str = "POST",
|
|
378
|
+
headers: Optional[dict] = None,
|
|
379
|
+
body: Any = None,
|
|
380
|
+
timeout: Optional[float] = 30.0,
|
|
381
|
+
) -> dict:
|
|
382
|
+
"""Proxy a TRPC call against labs.google through the extension.
|
|
383
|
+
|
|
384
|
+
No captcha; just Bearer auth passthrough on a `credentials: include`
|
|
385
|
+
fetch. Used for metadata calls like ``project.createProject``.
|
|
386
|
+
"""
|
|
387
|
+
return await self._send(
|
|
388
|
+
"trpc_request",
|
|
389
|
+
{"url": url, "method": method, "headers": headers or {}, "body": body},
|
|
390
|
+
timeout=timeout,
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
async def upload_video(
|
|
394
|
+
self,
|
|
395
|
+
project_id: str,
|
|
396
|
+
file_name: str,
|
|
397
|
+
content_type: str,
|
|
398
|
+
data_base64: str,
|
|
399
|
+
timeout: Optional[float] = None,
|
|
400
|
+
) -> dict:
|
|
401
|
+
"""Run a resumable video upload through the extension's browser session.
|
|
402
|
+
|
|
403
|
+
The extension performs the two-step handshake (zero-body POST to start
|
|
404
|
+
the session, then a binary PUT of the bytes) against
|
|
405
|
+
labs.google/fx/api/upload-video with the user's cookies, and returns
|
|
406
|
+
Flow's final ``{mediaServerId, ...}`` payload as ``data``. Video bytes
|
|
407
|
+
are passed as base64 over the WS since JSON can't carry binary.
|
|
408
|
+
"""
|
|
409
|
+
return await self._send(
|
|
410
|
+
"upload_video",
|
|
411
|
+
{
|
|
412
|
+
"projectId": project_id,
|
|
413
|
+
"fileName": file_name,
|
|
414
|
+
"contentType": content_type,
|
|
415
|
+
"dataBase64": data_base64,
|
|
416
|
+
},
|
|
417
|
+
# Resumable upload of a multi-MB clip can take a while; give it the
|
|
418
|
+
# full default budget rather than the short TRPC timeout.
|
|
419
|
+
timeout=timeout or self.DEFAULT_TIMEOUT,
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
# ── observability ─────────────────────────────────────────────────────
|
|
423
|
+
@property
|
|
424
|
+
def ws_stats(self) -> dict:
|
|
425
|
+
token_age = (
|
|
426
|
+
int(time.time() - self._token_captured_at)
|
|
427
|
+
if self._token_captured_at is not None
|
|
428
|
+
else None
|
|
429
|
+
)
|
|
430
|
+
return {
|
|
431
|
+
"connected": self.connected,
|
|
432
|
+
"flow_key_present": self._flow_key_present,
|
|
433
|
+
"token_age_s": token_age,
|
|
434
|
+
"pending": len(self._pending),
|
|
435
|
+
"request_count": self._request_count,
|
|
436
|
+
"success_count": self._success_count,
|
|
437
|
+
"failed_count": self._failed_count,
|
|
438
|
+
"last_error": self._last_error,
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
# The module-level singleton has been removed. FlowClient instances are now
|
|
443
|
+
# managed exclusively by pool.PoolRegistry (one per registered account_id).
|
|
444
|
+
# Import `pool` from flow_engine.pool and call pool.get_by_api_key() instead.
|