vrex-flow-engine 0.3.2__tar.gz → 0.3.4__tar.gz

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.
Files changed (47) hide show
  1. {vrex_flow_engine-0.3.2 → vrex_flow_engine-0.3.4}/PKG-INFO +1 -1
  2. vrex_flow_engine-0.3.4/README.md +373 -0
  3. {vrex_flow_engine-0.3.2 → vrex_flow_engine-0.3.4}/flow_engine/bridge/flow_client.py +48 -2
  4. {vrex_flow_engine-0.3.2 → vrex_flow_engine-0.3.4}/flow_engine/bridge/flow_rpc.py +82 -7
  5. {vrex_flow_engine-0.3.2 → vrex_flow_engine-0.3.4}/flow_engine/bridge/flow_sdk.py +44 -5
  6. {vrex_flow_engine-0.3.2 → vrex_flow_engine-0.3.4}/flow_engine/catalog.py +6 -3
  7. {vrex_flow_engine-0.3.2 → vrex_flow_engine-0.3.4}/flow_engine/jobs.py +1 -0
  8. {vrex_flow_engine-0.3.2 → vrex_flow_engine-0.3.4}/flow_engine/openai/videos.py +46 -22
  9. {vrex_flow_engine-0.3.2 → vrex_flow_engine-0.3.4}/flow_engine/pool.py +3 -0
  10. {vrex_flow_engine-0.3.2 → vrex_flow_engine-0.3.4}/pyproject.toml +1 -1
  11. vrex_flow_engine-0.3.4/tests/test_catalog_resolution.py +81 -0
  12. vrex_flow_engine-0.3.4/tests/test_flow_rpc.py +302 -0
  13. vrex_flow_engine-0.3.4/tests/test_rpc_method_map.py +48 -0
  14. vrex_flow_engine-0.3.4/tests/test_video_mode_selection.py +36 -0
  15. {vrex_flow_engine-0.3.2 → vrex_flow_engine-0.3.4}/vrex_flow_engine.egg-info/PKG-INFO +1 -1
  16. {vrex_flow_engine-0.3.2 → vrex_flow_engine-0.3.4}/vrex_flow_engine.egg-info/SOURCES.txt +5 -0
  17. {vrex_flow_engine-0.3.2 → vrex_flow_engine-0.3.4}/PYPI_README.md +0 -0
  18. {vrex_flow_engine-0.3.2 → vrex_flow_engine-0.3.4}/flow_engine/__init__.py +0 -0
  19. {vrex_flow_engine-0.3.2 → vrex_flow_engine-0.3.4}/flow_engine/bridge/__init__.py +0 -0
  20. {vrex_flow_engine-0.3.2 → vrex_flow_engine-0.3.4}/flow_engine/bridge/ws_server.py +0 -0
  21. {vrex_flow_engine-0.3.2 → vrex_flow_engine-0.3.4}/flow_engine/cli/__init__.py +0 -0
  22. {vrex_flow_engine-0.3.2 → vrex_flow_engine-0.3.4}/flow_engine/cli/app.py +0 -0
  23. {vrex_flow_engine-0.3.2 → vrex_flow_engine-0.3.4}/flow_engine/cli/config.py +0 -0
  24. {vrex_flow_engine-0.3.2 → vrex_flow_engine-0.3.4}/flow_engine/cli/dashboard.py +0 -0
  25. {vrex_flow_engine-0.3.2 → vrex_flow_engine-0.3.4}/flow_engine/cli/health.py +0 -0
  26. {vrex_flow_engine-0.3.2 → vrex_flow_engine-0.3.4}/flow_engine/cli/processes.py +0 -0
  27. {vrex_flow_engine-0.3.2 → vrex_flow_engine-0.3.4}/flow_engine/cli/supervisor.py +0 -0
  28. {vrex_flow_engine-0.3.2 → vrex_flow_engine-0.3.4}/flow_engine/config.py +0 -0
  29. {vrex_flow_engine-0.3.2 → vrex_flow_engine-0.3.4}/flow_engine/ingest.py +0 -0
  30. {vrex_flow_engine-0.3.2 → vrex_flow_engine-0.3.4}/flow_engine/job_store.py +0 -0
  31. {vrex_flow_engine-0.3.2 → vrex_flow_engine-0.3.4}/flow_engine/main.py +0 -0
  32. {vrex_flow_engine-0.3.2 → vrex_flow_engine-0.3.4}/flow_engine/media.py +0 -0
  33. {vrex_flow_engine-0.3.2 → vrex_flow_engine-0.3.4}/flow_engine/media_store.py +0 -0
  34. {vrex_flow_engine-0.3.2 → vrex_flow_engine-0.3.4}/flow_engine/openai/__init__.py +0 -0
  35. {vrex_flow_engine-0.3.2 → vrex_flow_engine-0.3.4}/flow_engine/openai/_util.py +0 -0
  36. {vrex_flow_engine-0.3.2 → vrex_flow_engine-0.3.4}/flow_engine/openai/images.py +0 -0
  37. {vrex_flow_engine-0.3.2 → vrex_flow_engine-0.3.4}/flow_engine/openai/models.py +0 -0
  38. {vrex_flow_engine-0.3.2 → vrex_flow_engine-0.3.4}/flow_engine/openai/uploads.py +0 -0
  39. {vrex_flow_engine-0.3.2 → vrex_flow_engine-0.3.4}/flow_engine/posthog_client.py +0 -0
  40. {vrex_flow_engine-0.3.2 → vrex_flow_engine-0.3.4}/flow_engine/session.py +0 -0
  41. {vrex_flow_engine-0.3.2 → vrex_flow_engine-0.3.4}/flow_engine/video_context.py +0 -0
  42. {vrex_flow_engine-0.3.2 → vrex_flow_engine-0.3.4}/flow_engine/video_context_store.py +0 -0
  43. {vrex_flow_engine-0.3.2 → vrex_flow_engine-0.3.4}/setup.cfg +0 -0
  44. {vrex_flow_engine-0.3.2 → vrex_flow_engine-0.3.4}/vrex_flow_engine.egg-info/dependency_links.txt +0 -0
  45. {vrex_flow_engine-0.3.2 → vrex_flow_engine-0.3.4}/vrex_flow_engine.egg-info/entry_points.txt +0 -0
  46. {vrex_flow_engine-0.3.2 → vrex_flow_engine-0.3.4}/vrex_flow_engine.egg-info/requires.txt +0 -0
  47. {vrex_flow_engine-0.3.2 → vrex_flow_engine-0.3.4}/vrex_flow_engine.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vrex-flow-engine
3
- Version: 0.3.2
3
+ Version: 0.3.4
4
4
  Summary: Launcher + supervisor and OpenAI-compatible image/video generation service (Vrex Flow Engine).
5
5
  Author: Vrex
6
6
  License: Proprietary
@@ -0,0 +1,373 @@
1
+ # flow-engine
2
+
3
+ Host a **Google Flow browser-extension instance** and expose **OpenAI-compatible
4
+ image/video generation endpoints** backed by Google Flow (flow.google.com).
5
+
6
+ flow-engine drives a Chrome extension that borrows your signed-in Flow session
7
+ (Google cookies + the page's XSRF token + reCAPTCHA solving) and proxies Flow's
8
+ private `batchexecute` RPCs, mapping the results onto familiar OpenAI
9
+ request/response shapes. PyPI package `vrex-flow-engine`, import module
10
+ `flow_engine`, console commands `vrex-flow-engine` / `flow-engine` (subcommands:
11
+ `start`, `serve`, `setup`, `doctor`).
12
+
13
+ > **Transport status (2026-09).** Flow moved from the Next.js app at
14
+ > `labs.google/fx/tools/flow` (Bearer `ya29` tokens against
15
+ > `aisandbox-pa.googleapis.com`) to an Angular app at `flow.google.com` whose
16
+ > only backend is `/_/AiSandboxAngularFrontend/data/batchexecute`. The old
17
+ > transport now fails with *"Request had invalid authentication credentials"*.
18
+ > flow-engine speaks the new protocol for everything the recorded sessions
19
+ > exercised (images, uploads, text/image/reference-to-video); the few modes
20
+ > whose payload has not been captured yet answer **HTTP 501** — see "Porting
21
+ > more Flow features" below.
22
+
23
+ ```
24
+ OpenAI client ──HTTP :8101──> flow-engine ──WS :9223──> Chrome extension ──fetch──> Google Flow
25
+ (this repo) (extension/, in your browser)
26
+ ```
27
+
28
+ In production the HTTP surface is published at `https://flow.getvrex.com`
29
+ (a `cloudflared` tunnel → `127.0.0.1:8101`); the Next.js app reaches it via
30
+ `FLOW_ENGINE_URL`. See "Expose to production" below.
31
+
32
+ ## Endpoints
33
+
34
+ - `POST /v1/videos/generations` + `GET /v1/videos/{id}` — async video job + poll
35
+ (Veo 3.1 lite, low-priority queue): text-to-video, image-to-video
36
+ (`start_images`), reference-to-video (`ref_images`), frames-to-video
37
+ (`end_images` / `loop`) and 1080p upsample of a generated clip
38
+ (`mode: "upsample"` + `source_media_id`). `mode: extend|edit` and r2v
39
+ `reference_audio` answer `501` until captured.
40
+ - `GET /v1/videos/{id}/debug` — raw `{dispatch, last_poll}` Google payloads for a job (troubleshooting).
41
+ - `POST /v1/images/generations` — synchronous image (Nano Banana 2), optional `ref_images`.
42
+ - `POST /v1/flow/uploads` — image **or video** bytes → Flow media id (routes by mime).
43
+ - `GET /v1/models` — model catalog (cached on disk, refresh with `?refresh=1`);
44
+ each mode reports `dispatchable`.
45
+ - `GET /v1/audio/voices` — Flow's preset voices (read from the default project).
46
+ - `GET /media/{id}` — cached generated bytes at a stable URL.
47
+ - `GET /api/health` — liveness + extension-connected status (ungated).
48
+
49
+ `/v1/*` and `/api/pool/status` require `Authorization: Bearer <FLOW_ENGINE_API_KEY>`.
50
+ `/api/health` and `/media/{id}` are ungated.
51
+
52
+ ## Requirements
53
+
54
+ - Python 3.10+
55
+ - Google Chrome signed in to **flow.google.com** with a Flow tab open, and the
56
+ extension in `extension/` loaded (chrome://extensions → Load unpacked).
57
+ - A Flow account (Pro or Ultra — the paygate tier is read from your session;
58
+ the pinned `veo_3_1_lite_low_priority` family is only enabled on Ultra).
59
+ - The engine generates into a project titled **`flow_engine`** — found by
60
+ title, created on first use when missing; `FLOW_DEFAULT_PROJECT_ID` pins one.
61
+ - `cloudflared` (only for exposing to production).
62
+
63
+ > The extension's `background.js` hardcodes `ws://127.0.0.1:9223` and
64
+ > `http://127.0.0.1:8101/api/ext/callback`, so flow-engine defaults to those
65
+ > ports. It therefore **cannot run alongside the legacy flowboard agent on
66
+ > defaults** — change both the env vars and the extension URLs to run both.
67
+
68
+ ## Quickstart — one command
69
+
70
+ `flow-engine start` is a supervisor: it stores your secrets, launches the engine
71
+ **and** the Cloudflare tunnel as child processes, restarts either on crash, and
72
+ renders a live status dashboard. The package is published to PyPI as
73
+ **`vrex-flow-engine`**. Run it with [uv](https://docs.astral.sh/uv/) (the Python
74
+ equivalent of `npx` — no manual venv):
75
+
76
+ ```bash
77
+ uvx vrex-flow-engine start
78
+ ```
79
+
80
+ `uvx` ships with `uv`. If you get `uvx: command not found`, install uv first
81
+ (then re-run the line above):
82
+
83
+ ```bash
84
+ curl -LsSf https://astral.sh/uv/install.sh | sh # then: exec $SHELL (adds ~/.local/bin to PATH)
85
+ # or on macOS: brew install uv
86
+ ```
87
+
88
+ No uv? It's a normal PyPI package — `pipx run vrex-flow-engine start`, or
89
+ `pip install --user vrex-flow-engine && flow-engine start`.
90
+
91
+ First run prompts for the **engine API key** (must match the value the Next.js
92
+ app sends — `FLOW_ENGINE_API_KEY` on Vrex Multimodal / `.env.prd`) and the
93
+ **Cloudflare tunnel token** (dashboard → Zero Trust → Networks → Tunnels → your
94
+ tunnel → "run a connector"). Both are saved to `~/.vrex-flow/config.json` (0600)
95
+ so later runs need no input. Then it brings up the engine + tunnel and shows:
96
+
97
+ ```
98
+ 🎬 Vrex Flow Engine ● running uptime 3m02s
99
+ ┌ engine ──────────────┐ ┌ tunnel ───────────────┐
100
+ │ status ● UP │ │ status ● connected │
101
+ │ extension ● connected│ │ public flow.getvrex.com│
102
+ │ instances 1 │ │ → 127.0.0.1:8101 │
103
+ └──────────────────────┘ └───────────────────────┘
104
+ ```
105
+
106
+ The one thing the CLI can't automate: open a `https://flow.google.com` tab in
107
+ Chrome with the `extension/` loaded and signed into a Flow Pro/Ultra account.
108
+ Until you do, the dashboard shows `extension ● OPEN A FLOW TAB`. Ctrl-C stops
109
+ both processes cleanly.
110
+
111
+ ### CLI commands
112
+
113
+ | Command | What it does |
114
+ |---|---|
115
+ | `flow-engine start` | Supervise engine + tunnel + live dashboard (the one-liner). `--no-tunnel` runs the engine only; `--tunnel-config <path>` runs a named tunnel from a cloudflared config.yml instead of a token; `--non-interactive` fails instead of prompting. |
116
+ | `flow-engine setup` | (Re)store the engine key + tunnel token in `~/.vrex-flow/config.json`. |
117
+ | `flow-engine doctor` | Check prerequisites (python, cloudflared, config) and probe a running engine. |
118
+ | `flow-engine serve` | Run just the engine in the foreground (for systemd / your own supervisor). |
119
+
120
+ Secrets can also come from env (`FLOW_ENGINE_API_KEY`, `CLOUDFLARE_TUNNEL_TOKEN`)
121
+ or flags (`--engine-key`, `--tunnel-token`) — flags > env > stored config.
122
+
123
+ ## Run manually (without the CLI)
124
+
125
+ The engine reads config from the environment directly — **no `.env` auto-load**.
126
+ `FLOW_ENGINE_API_KEY` is **required**; the server refuses to boot without it.
127
+
128
+ ```bash
129
+ cd flow-engine
130
+ python -m venv .venv && source .venv/bin/activate
131
+ pip install -e .
132
+ export FLOW_ENGINE_API_KEY=<shared-secret> # must match the caller's key
133
+ flow-engine serve # serves :8101 (HTTP) + :9223 (WS)
134
+ # equivalent: python -m flow_engine.main
135
+ ```
136
+
137
+ Load `extension/` in Chrome, open a `https://flow.google.com` tab, then check:
138
+
139
+ ```bash
140
+ curl localhost:8101/api/health
141
+ # {"ok":true,"extension_connected":true,"instances":1}
142
+ curl localhost:8101/api/pool/status -H "authorization: Bearer $FLOW_ENGINE_API_KEY"
143
+ # {"instances":[{"account_id":"acct-001","connected":true,"session_present":true,
144
+ # "email":"…","paygate_tier":3,"credits":25050,"in_flight":0,"capacity":4}]}
145
+ ```
146
+
147
+ `extension_connected:false` means no extension is bridged yet — reload the
148
+ extension. `session_present:false` / `paygate_tier:null` means the extension is
149
+ bridged but Chrome is not signed in to flow.google.com (a generation request
150
+ answers `503 account_unresolved`).
151
+
152
+ ### Tests
153
+
154
+ ```bash
155
+ cd flow-engine
156
+ uv run --with pytest python -m pytest tests -q # parsers, catalog resolution
157
+ node --test tests/batchexecute-helpers.test.mjs # extension batchexecute helpers
158
+ ```
159
+
160
+ ## Expose to production (Cloudflare Tunnel)
161
+
162
+ The public origin `https://flow.getvrex.com` is a `cloudflared` tunnel to the
163
+ local `:8101`. **`flow-engine start` runs this tunnel for you** (via the stored
164
+ token), so normally you don't touch `cloudflared` directly. To run it standalone
165
+ — e.g. the `serve` path — start the tunnel as a separate process on the same
166
+ machine:
167
+
168
+ ```bash
169
+ cloudflared tunnel run --token <your-tunnel-token> # flow.getvrex.com → 127.0.0.1:8101
170
+ # or, with a named-tunnel config file:
171
+ cloudflared tunnel run --config cloudflared.yml
172
+ ```
173
+
174
+ If `https://flow.getvrex.com` returns **HTTP 530 / `error code: 1033`**, the
175
+ tunnel is down (Cloudflare has no active connection to the origin) — restart it
176
+ (or `flow-engine start`). A `502`/`1016` instead means the tunnel is up but the
177
+ engine on `:8101` is not — restart the engine.
178
+
179
+ ## Examples
180
+
181
+ Image (`model` must be `NANO_BANANA_2`, or omit to force it; `size` picks the
182
+ aspect — `1024x1024` square, `1280x720` landscape, `720x1280` portrait):
183
+
184
+ ```bash
185
+ curl localhost:8101/v1/images/generations \
186
+ -H "authorization: Bearer $FLOW_ENGINE_API_KEY" \
187
+ -H 'content-type: application/json' \
188
+ -d '{"prompt": "a lighthouse at dusk", "n": 2, "size": "1280x720"}'
189
+ # {"created":…,"data":[{"url":"http://127.0.0.1:8101/media/<id>"}, …]}
190
+ ```
191
+
192
+ Text-to-video (`model` must be `veo_3_1_lite_low_priority`):
193
+
194
+ ```bash
195
+ curl localhost:8101/v1/videos/generations \
196
+ -H "authorization: Bearer $FLOW_ENGINE_API_KEY" \
197
+ -H 'content-type: application/json' \
198
+ -d '{"prompt": "a cat playing", "model": "veo_3_1_lite_low_priority", "size": "1280x720"}'
199
+ # {"id":"video_…","status":"in_progress","mode":"t2v","model_key":"veo_3_1_t2v_lite_low_priority",…}
200
+ curl localhost:8101/v1/videos/video_… \
201
+ -H "authorization: Bearer $FLOW_ENGINE_API_KEY" # poll until status == "completed"
202
+ ```
203
+
204
+ Image-to-video — pass the start frame inline; flow-engine uploads it for you.
205
+ Reference-to-video ("Ingredients", up to 3 images) works the same way with
206
+ `ref_images` / `ref_media_ids`:
207
+
208
+ ```bash
209
+ curl localhost:8101/v1/videos/generations \
210
+ -H "authorization: Bearer $FLOW_ENGINE_API_KEY" \
211
+ -H 'content-type: application/json' -d "{
212
+ \"prompt\": \"the subject turns to camera\", \"model\": \"veo_3_1_lite_low_priority\",
213
+ \"start_images\": [\"$(base64 < frame.png)\"], \"size\": \"1280x720\"
214
+ }"
215
+ ```
216
+
217
+ End frames (`end_images` / `end_media_ids`) turn i2v into frames-to-video —
218
+ motion between two stills. `loop: true` reuses the start frame as the end
219
+ frame, which yields a clip finishing on the frame it began with, so it tiles
220
+ seamlessly behind narration:
221
+
222
+ ```bash
223
+ curl localhost:8101/v1/videos/generations \
224
+ -H "authorization: Bearer $FLOW_ENGINE_API_KEY" \
225
+ -H 'content-type: application/json' \
226
+ -d '{"prompt": "subtle calm motion", "model": "veo_3_1_lite_low_priority",
227
+ "start_media_ids": ["<still media id>"], "loop": true, "size": "1280x720"}'
228
+ # {"id":"video_…","status":"in_progress","mode":"interpolation",…}
229
+ ```
230
+
231
+ `start_images` / `ref_images` / `end_images` accept a data URL, an `http(s)`
232
+ URL, or bare base64. Already have a Flow media id (e.g. from `/v1/flow/uploads`)? Pass it in
233
+ `start_media_ids` / `ref_media_ids` instead — the two forms merge. Uploading a
234
+ reference **video** works via `/v1/flow/uploads -F file=@clip.mp4`, which
235
+ returns a `"kind":"video"` media id (Flow registers it as PENDING and flips it
236
+ to SUCCESSFUL a few seconds later).
237
+
238
+ The completed job carries stable `/media/{id}` URLs; the bytes are fetched from
239
+ Flow's short-lived signed CDN link once and cached locally.
240
+
241
+ A finished clip can be upsampled to 1080p. No prompt is needed; `size` only
242
+ tells Flow the clip's orientation (landscape by default). The job's media id
243
+ is the source id with `_upsampled` appended, and Flow charges credits for it
244
+ (25050 on the capturing account, where the 720p generations were free):
245
+
246
+ ```bash
247
+ curl localhost:8101/v1/videos/generations \
248
+ -H "authorization: Bearer $FLOW_ENGINE_API_KEY" \
249
+ -H 'content-type: application/json' \
250
+ -d '{"mode": "upsample", "model": "veo_3_1_lite_low_priority",
251
+ "source_media_id": "<clip media id>", "size": "1280x720"}'
252
+ # {"id":"video_…","status":"in_progress","mode":"upsample","model_key":"veo_3_1_upsampler_1080p",…}
253
+ ```
254
+
255
+ The upsample item names the source clip's workflow. The engine fills it from
256
+ its own registry when it generated the clip in this run, else it reads it from
257
+ Flow's media entry; pass `workflow_id` to skip that lookup.
258
+
259
+ Not yet available on flow.google.com (each answers **`501 Not Implemented`**
260
+ naming the missing capture; the request contract is unchanged):
261
+ `mode: "extend"`, `mode: "edit"`, r2v `reference_audio`.
262
+
263
+ ## Porting more Flow features
264
+
265
+ Everything flow-engine knows about the flow.google.com wire format lives in
266
+ `flow_engine/bridge/flow_rpc.py` (RPC ids, payload builders, response parsers)
267
+ and was derived from recorded web sessions. To add a feature:
268
+
269
+ 1. In Chrome DevTools (Network, filter `batchexecute`) perform the action once
270
+ in the Flow web app — e.g. generate an image, upload a reference image, run
271
+ image-to-video — and export the HAR.
272
+ 2. For each new call note the `rpcids` query param, the decoded `f.req`
273
+ payload (a positional JSON array), and the `wrb.fr` response row.
274
+ 3. Add the RPC id + a payload builder + a parser to `flow_rpc.py` with a test
275
+ in `tests/test_flow_rpc.py` against the recorded response, then wire it in
276
+ `flow_sdk.py` and lift the mode out of the `501` guard (`SUPPORTED_MODES`
277
+ in `openai/videos.py`, `_DISPATCHABLE_MODES` in `catalog.py`, the image /
278
+ upload routes).
279
+
280
+ ### RPC ids can rotate — the extension re-discovers them
281
+
282
+ The ids are Wiz RPC hashes; Google may change them in a future frontend
283
+ build. Flow's JS declares every RPC with a descriptor that carries the stable
284
+ backend method path next to the id (`new _.kx("nzlxg", …,
285
+ "/VideoFxService.GetCredits")`), so the extension rebuilds a `method → id`
286
+ map from the public bundle (base script + every lazy module, ~45 MB, fetched
287
+ once per build label and cached in `chrome.storage`). Every `rpc_request`
288
+ names its method (`flow_rpc.RPC_METHOD_BY_ID`); the extension calls the
289
+ discovered id when it differs from the one the engine sent, and pushes the
290
+ map to the engine, which logs a warning and lists the drift in
291
+ `/api/pool/status` (`flow_build`, `rpc_map_size`, `rpc_ids_rotated`). A
292
+ rotation therefore keeps working unattended; update the constants in
293
+ `flow_rpc.py` when convenient. Payload *shapes* are not covered — a method
294
+ whose request proto changes still needs a fresh capture.
295
+
296
+ Known RPC ids so far: `YhhmEf` / `eb1hJf` / `MZZa6b` / `nprQif` generate
297
+ video (t2v / i2v / r2v / frames-to-video; reCAPTCHA token at
298
+ `payload[1][10][0]`), `p0UkFb` upsample a clip to 1080p (same context, batch
299
+ id without the trailing `2`; the model key `veo_3_1_upsampler_1080p` is not in
300
+ the catalog RPC), `fZytfe` / `jIps6` extend / edit (ids known, payloads not),
301
+ `ogiZ0b` generate
302
+ image (sync, token in both clientContext copies), `maseQ` upload image (bytes
303
+ inline), `jwpduf` batch media status, `as29s` media detail with signed URLs,
304
+ `UpteDb` list projects (page token at index 2), `jHPbke` create project,
305
+ `Zzl0ze` project contents (+ preset voices), `HTrJv` model catalog, `nzlxg`
306
+ credits + paygate tier. Video upload is not an RPC: a cookie-authed resumable
307
+ upload against `/upload/v1/flow/upload/video/<projectId>` (see
308
+ `extension/background.js` → `handleUploadRequest`).
309
+
310
+ One open question: the reCAPTCHA *action* name the web client uses for image
311
+ uploads is not visible in a capture (reCAPTCHA bodies are opaque). The engine
312
+ sends `IMAGE_UPLOAD`; if Flow rejects uploads with a captcha error, override
313
+ it with `FLOW_UPLOAD_CAPTCHA_ACTION`.
314
+
315
+ ## Configuration
316
+
317
+ These configure the **engine** process (read from its environment). The
318
+ `flow-engine start` launcher injects `FLOW_ENGINE_API_KEY` + ports for you from
319
+ `~/.vrex-flow/config.json`; set these directly only for the manual `serve` path.
320
+ All optional except the API key. Port/host/storage vars keep the `FLOWPROXY_`
321
+ prefix for extension compatibility; the API key and pool size use the
322
+ `FLOW_ENGINE_` / `FLOW_POOL_` names.
323
+
324
+ | Env var | Default | Purpose |
325
+ |---|---|---|
326
+ | `FLOW_ENGINE_API_KEY` | — (**required**) | Bearer key gating `/v1/*`. Legacy `FLOWPROXY_API_KEY` still accepted. |
327
+ | `FLOWPROXY_HTTP_PORT` | `8101` | HTTP surface (OpenAI endpoints + `/media` + callback). |
328
+ | `FLOWPROXY_WS_HOST` | `127.0.0.1` | Extension WS bind — **must be loopback** (unauthenticated by design). |
329
+ | `FLOWPROXY_EXT_WS_PORT` | `9223` | Extension WebSocket port. |
330
+ | `FLOWPROXY_PUBLIC_BASE_URL` | `http://127.0.0.1:8101` | Base used to build absolute `/media` URLs returned to clients. |
331
+ | `FLOW_POOL_CONCURRENCY` | `4` | Max concurrent in-flight Flow calls per instance (back-pressure, no 503). |
332
+ | `FLOWPROXY_STORAGE` | `./storage` | Local byte cache dir. |
333
+ | `FLOWPROXY_CATALOG_TTL` | `86400` | Model-catalog cache TTL (seconds). |
334
+ | `FLOWPROXY_PROJECT_TITLE` | `flow_engine` | Title of the Flow project to generate into (found or created). |
335
+ | `FLOW_DEFAULT_PROJECT_ID` | — | Pin the Flow project id instead of looking it up by title. |
336
+ | `FLOW_UPLOAD_CAPTCHA_ACTION` | `IMAGE_UPLOAD` | reCAPTCHA action solved before an image upload (see "Porting more Flow features"). |
337
+ | `VIDEO_POLL_MAX_CYCLES` / `VIDEO_POLL_INTERVAL_S` | `72` / `10` | Video poll budget (default 12 min). |
338
+
339
+ ## Layout
340
+
341
+ ```
342
+ extension/ Chrome MV3 bridge (open a flow.google.com tab);
343
+ batchexecute.js = shared request/response helpers
344
+ flow_engine/
345
+ bridge/ WS server + flow_client (transport) + flow_rpc (wire
346
+ format: ids, builders, parsers) + flow_sdk (high level)
347
+ media.py DB-free media cache (registry + on-disk bytes)
348
+ jobs.py in-memory job store + async video poller
349
+ session.py readiness gate + default project + pool seam
350
+ catalog.py model catalog (Flow catalog RPC), disk-cached
351
+ openai/ /v1/images, /v1/videos, /v1/flow/uploads, /v1/models
352
+ main.py FastAPI app, /api/ext/callback, /media/{id}, lifespan
353
+ cli/ launcher: app (argparse), supervisor, dashboard,
354
+ processes, health, config (~/.vrex-flow)
355
+ ```
356
+
357
+ ## Notes & caveats
358
+
359
+ - **Single account.** One extension instance = one Google account = one tier.
360
+ Multi-tenant needs a browser-instance pool routed by API key — the
361
+ `session.get_bridge(api_key)` indirection is where that goes.
362
+ - **Live Flow tab required.** Every dispatch solves an enterprise reCAPTCHA in
363
+ an open Flow tab; hosting needs a headful/virtual-display Chrome kept warm.
364
+ Polling and metadata calls only need the cookies + XSRF token, which the
365
+ extension reads from the flow.google.com page shell and refreshes every
366
+ 30 min (and on the first rejected call).
367
+ - **Signed URLs expire.** flow-engine caches bytes locally and serves stable
368
+ `/media/{id}` URLs.
369
+ - **Localhost WS is unauthenticated** by design — never bind it to a network
370
+ interface. Gate the HTTP `/v1/*` surface with `FLOW_ENGINE_API_KEY` when exposed.
371
+ - **No `.env` auto-load.** The process reads `os.environ` directly — export vars
372
+ in the shell (or a wrapper/launchd unit), don't rely on a `.env` file.
373
+ ```
@@ -12,8 +12,11 @@ Control flow:
12
12
  separate ``upload_request`` command (resumable upload, not batchexecute).
13
13
  4. That HTTP handler resolves the pending future by id.
14
14
  5. Inbound WS messages (``extension_ready``, ``session_captured``,
15
- ``session_lost``, ``pong``) update per-instance state. A fresh session
16
- triggers an account refresh (credits + paygate tier) through the bridge.
15
+ ``session_lost``, ``rpc_map``, ``pong``) update per-instance state. A fresh
16
+ session triggers an account refresh (credits + paygate tier) through the
17
+ bridge. ``rpc_map`` carries the ``method → rpcId`` table the extension
18
+ rebuilt from Flow's JS bundle; every ``rpc_request`` names its method so
19
+ the extension can substitute a rotated id.
17
20
  """
18
21
  from __future__ import annotations
19
22
 
@@ -61,6 +64,12 @@ class FlowClient:
61
64
  self._paygate_tier: Optional[int] = None
62
65
  self._credits: Optional[int] = None
63
66
  self._plan_code: Optional[int] = None
67
+ # RPC-id discovery pushed by the extension: Flow build label, the
68
+ # method → id map it read from the bundle, and the ids that differ
69
+ # from flow_rpc's constants (empty = everything still matches).
70
+ self._flow_build: Optional[str] = None
71
+ self._rpc_map_size: int = 0
72
+ self._rpc_ids_rotated: dict[str, dict[str, str]] = {}
64
73
  self._request_count = 0
65
74
  self._success_count = 0
66
75
  self._failed_count = 0
@@ -109,6 +118,18 @@ class FlowClient:
109
118
  def session_present(self) -> bool:
110
119
  return self._session_present
111
120
 
121
+ @property
122
+ def flow_build(self) -> Optional[str]:
123
+ return self._flow_build
124
+
125
+ @property
126
+ def rpc_map_size(self) -> int:
127
+ return self._rpc_map_size
128
+
129
+ @property
130
+ def rpc_ids_rotated(self) -> dict[str, dict[str, str]]:
131
+ return dict(self._rpc_ids_rotated)
132
+
112
133
  async def refresh_account(self) -> bool:
113
134
  """Resolve credits + paygate tier via the credits RPC. Returns True when
114
135
  the tier is cached. Runs through the extension, so it needs a live
@@ -153,6 +174,28 @@ class FlowClient:
153
174
  logger.warning("session_lost: %s", data.get("reason"))
154
175
  self._clear_session()
155
176
  return
177
+ if t == "rpc_map":
178
+ self._flow_build = data.get("build") if isinstance(data.get("build"), str) else None
179
+ discovered = data.get("map") if isinstance(data.get("map"), dict) else {}
180
+ self._rpc_map_size = len(discovered)
181
+ rotated: dict[str, dict[str, str]] = {}
182
+ for method, expected in flow_rpc.RPC_ID_BY_METHOD.items():
183
+ actual = discovered.get(method)
184
+ if isinstance(actual, str) and actual and actual != expected:
185
+ rotated[method] = {"expected": expected, "actual": actual}
186
+ self._rpc_ids_rotated = rotated
187
+ if rotated:
188
+ logger.warning(
189
+ "Flow build %s rotated %d rpc id(s); the extension resolves them by "
190
+ "method name, update flow_rpc.py when convenient: %s",
191
+ self._flow_build, len(rotated), rotated,
192
+ )
193
+ else:
194
+ logger.info(
195
+ "rpc_map from Flow build %s: %d methods, all known ids match",
196
+ self._flow_build, self._rpc_map_size,
197
+ )
198
+ return
156
199
  if t == "pong":
157
200
  return
158
201
  # Inbound response (legacy path; production flow uses HTTP callback)
@@ -248,6 +291,9 @@ class FlowClient:
248
291
  ``captcha_path`` is the single-path shorthand).
249
292
  """
250
293
  params: dict[str, Any] = {"rpcId": rpc_id, "payload": payload}
294
+ method = flow_rpc.RPC_METHOD_BY_ID.get(rpc_id)
295
+ if method:
296
+ params["method"] = method
251
297
  if captcha_action:
252
298
  params["captchaAction"] = captcha_action
253
299
  params["captchaPaths"] = captcha_paths or ([captcha_path] if captcha_path else [])
@@ -30,10 +30,42 @@ RPC_GENERATE_VIDEO_T2V = "YhhmEf"
30
30
  RPC_GENERATE_VIDEO_I2V = "eb1hJf"
31
31
  RPC_GENERATE_VIDEO_R2V = "MZZa6b"
32
32
  RPC_GENERATE_VIDEO_INTERPOLATION = "nprQif"
33
+ RPC_GENERATE_VIDEO_UPSAMPLE = "p0UkFb"
33
34
  RPC_GENERATE_VIDEO = RPC_GENERATE_VIDEO_T2V # historical alias
34
35
  RPC_CHECK_MEDIA = "jwpduf"
35
36
  RPC_GET_MEDIA = "as29s"
36
37
 
38
+ # Known ids for methods whose payload has NOT been captured yet (kept so the
39
+ # rotation check covers them and porting them later starts from a known id).
40
+ RPC_GENERATE_VIDEO_EXTEND = "fZytfe"
41
+ RPC_GENERATE_VIDEO_EDIT = "jIps6"
42
+
43
+ # Backend method path behind each id, as declared in Flow's JS bundle
44
+ # (``new _.kx("nzlxg", …, "/VideoFxService.GetCredits")``). The ids are Wiz
45
+ # hashes Google may rotate; the method names are the stable anchor. The
46
+ # extension rebuilds ``method → id`` from the live bundle on every build
47
+ # change and resolves calls by method, so the ids here are the fallback.
48
+ RPC_METHOD_BY_ID: dict[str, str] = {
49
+ RPC_LIST_PROJECTS: "FlowService.GetProjects",
50
+ RPC_CREATE_PROJECT: "AiSandbox.CreateProject",
51
+ RPC_PROJECT_INFO: "AiSandbox.GetProject",
52
+ RPC_PROJECT_CONTENTS: "FlowService.GetProjectContents",
53
+ RPC_MODEL_CATALOG: "FlowService.GetModels",
54
+ RPC_ACCOUNT_CREDITS: "VideoFxService.GetCredits",
55
+ RPC_UPLOAD_IMAGE: "FlowService.UploadImage",
56
+ RPC_GENERATE_IMAGE: "FlowService.BatchGenerateImages",
57
+ RPC_GENERATE_VIDEO_T2V: "VideoFxService.BatchAsyncGenerateVideoText",
58
+ RPC_GENERATE_VIDEO_I2V: "VideoFxService.BatchAsyncGenerateVideoStartImage",
59
+ RPC_GENERATE_VIDEO_R2V: "VideoFxService.BatchAsyncGenerateVideoReferenceImages",
60
+ RPC_GENERATE_VIDEO_INTERPOLATION: "VideoFxService.BatchAsyncGenerateVideoStartAndEndImage",
61
+ RPC_GENERATE_VIDEO_EXTEND: "VideoFxService.BatchAsyncGenerateVideoExtendVideo",
62
+ RPC_GENERATE_VIDEO_EDIT: "VideoFxService.BatchAsyncGenerateVideoEditVideo",
63
+ RPC_GENERATE_VIDEO_UPSAMPLE: "VideoFxService.BatchAsyncGenerateVideoUpsampleVideo",
64
+ RPC_CHECK_MEDIA: "VideoFxService.BatchCheckAsyncVideoGenerationStatus",
65
+ RPC_GET_MEDIA: "FlowService.GetMedia",
66
+ }
67
+ RPC_ID_BY_METHOD: dict[str, str] = {m: i for i, m in RPC_METHOD_BY_ID.items()}
68
+
37
69
  # Video upload is NOT a batchexecute call: a cookie-authenticated resumable
38
70
  # upload (``x-goog-upload-*`` headers) against this path, project id appended.
39
71
  UPLOAD_VIDEO_PATH = "/upload/v1/flow/upload/video/{project_id}"
@@ -45,6 +77,13 @@ TOOL_PINHOLE = 22
45
77
  CAPTCHA_APPLICATION_WEB = 1
46
78
  # Trailing int in the mediaGenerationContext ``[batchId, 2]`` pair.
47
79
  GENERATION_CONTEXT_FLAG = 2
80
+ # The 1080p upsampler's model key. Flow's catalog RPC does not list the video
81
+ # upsamplers (they are not a family the account picks a tier variant of), so
82
+ # the key comes from the capture, not from ``catalog.resolve_video``.
83
+ VIDEO_UPSAMPLER_1080P_KEY = "veo_3_1_upsampler_1080p"
84
+ # Second int in an upsample item (index 6). Observed as 2 alongside a landscape
85
+ # source; its meaning is not visible in the capture.
86
+ UPSAMPLE_ITEM_FLAG = 2
48
87
  # Where the extension writes the solved reCAPTCHA token. A clientContext is
49
88
  # ``[null, 22, null×3, projectId, null×4, [token, 1]]`` so the token sits at
50
89
  # ``[10][0]`` of wherever the context lives in a payload.
@@ -206,12 +245,38 @@ def video_request_item_r2v(
206
245
  ]
207
246
 
208
247
 
248
+ def video_request_item_upsample(
249
+ source_media_id: str,
250
+ workflow_id: str,
251
+ aspect_enum: int,
252
+ scene_id: Optional[str] = None,
253
+ model_key: str = VIDEO_UPSAMPLER_1080P_KEY,
254
+ ) -> list[Any]:
255
+ """Upsample item (``p0UkFb``): ``[[null, sourceMediaId], null, aspect,
256
+ null, [null, workflowId, null×2, sceneId], null, 2, null×24, modelKey]``.
257
+ The web client sends the source clip's own workflow id — the one the
258
+ generate / get-media responses carry — and a fresh scene id. The result is
259
+ a new media whose id is the source id with ``_upsampled`` appended."""
260
+ return [
261
+ [None, source_media_id], None, aspect_enum, None,
262
+ [None, workflow_id, None, None, scene_id or uuid_upper()],
263
+ None, UPSAMPLE_ITEM_FLAG, *([None] * 24), model_key,
264
+ ]
265
+
266
+
209
267
  def generate_video_payload(
210
- items: list[list[Any]], project_id: str, batch_id: Optional[str] = None
268
+ items: list[list[Any]],
269
+ project_id: str,
270
+ batch_id: Optional[str] = None,
271
+ with_context_flag: bool = True,
211
272
  ) -> list[Any]:
212
- """Body shared by the three video RPCs: ``[items, clientContext,
213
- [batchId, 2]]``. Token goes at ``GENERATE_VIDEO_CAPTCHA_PATH``."""
214
- return [items, client_context(project_id), [batch_id or uuid_upper(), GENERATION_CONTEXT_FLAG]]
273
+ """Body shared by the video RPCs: ``[items, clientContext, [batchId, 2]]``.
274
+ Token goes at ``GENERATE_VIDEO_CAPTCHA_PATH``. The upsample RPC sends the
275
+ batch id alone (``[batchId]``) — pass ``with_context_flag=False``."""
276
+ context = [batch_id or uuid_upper()]
277
+ if with_context_flag:
278
+ context.append(GENERATION_CONTEXT_FLAG)
279
+ return [items, client_context(project_id), context]
215
280
 
216
281
 
217
282
  def image_request_item(
@@ -351,17 +416,27 @@ def parse_media_entry(entry: Any) -> dict[str, Any]:
351
416
  def parse_generate_video(data: Any) -> dict[str, Any]:
352
417
  """``YhhmEf`` → ``{credits, workflows:[{name, primary_media_id}], media}``.
353
418
 
354
- ``data[2]`` lists the new workflows as ``[workflowId, null, null,
419
+ ``data[2]`` lists the workflows as ``[workflowId, null, null,
355
420
  [title, createTime, null, null, mediaId, batchId, updateTime], projectId]``;
356
421
  ``data[3]`` lists the media rows (see ``parse_media_entry``).
422
+
423
+ The media to poll is taken from the media row that names the workflow,
424
+ not from the workflow's own ``mediaId`` slot: for a fresh generation the
425
+ two agree, but a derived generation (upsample) reuses the source clip's
426
+ workflow, whose slot still points at the source. Polling that would
427
+ report the 720p clip as the finished 1080p one.
357
428
  """
429
+ media = [parse_media_entry(m) for m in _list(_at(data, 3))]
430
+ new_media_by_workflow = {
431
+ m["workflow_id"]: m["media_id"]
432
+ for m in media if m.get("workflow_id") and m.get("media_id")
433
+ }
358
434
  workflows: list[dict[str, str]] = []
359
435
  for wf in _list(_at(data, 2)):
360
436
  wf_id = _str(_at(wf, 0))
361
- media_id = _str(_at(wf, 3, 4))
437
+ media_id = new_media_by_workflow.get(wf_id) or _str(_at(wf, 3, 4))
362
438
  if wf_id and media_id:
363
439
  workflows.append({"name": wf_id, "primary_media_id": media_id})
364
- media = [parse_media_entry(m) for m in _list(_at(data, 3))]
365
440
  if not workflows:
366
441
  # Fall back to the media rows, which also carry their workflow id.
367
442
  for m in media: