vrex-flow-engine 0.3.3__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.3 → 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.3 → vrex_flow_engine-0.3.4}/flow_engine/bridge/flow_rpc.py +51 -8
  4. {vrex_flow_engine-0.3.3 → vrex_flow_engine-0.3.4}/flow_engine/bridge/flow_sdk.py +44 -5
  5. {vrex_flow_engine-0.3.3 → vrex_flow_engine-0.3.4}/flow_engine/catalog.py +6 -3
  6. {vrex_flow_engine-0.3.3 → vrex_flow_engine-0.3.4}/flow_engine/jobs.py +1 -0
  7. {vrex_flow_engine-0.3.3 → vrex_flow_engine-0.3.4}/flow_engine/openai/videos.py +46 -22
  8. {vrex_flow_engine-0.3.3 → vrex_flow_engine-0.3.4}/pyproject.toml +1 -1
  9. vrex_flow_engine-0.3.4/tests/test_catalog_resolution.py +81 -0
  10. vrex_flow_engine-0.3.4/tests/test_flow_rpc.py +302 -0
  11. vrex_flow_engine-0.3.4/tests/test_rpc_method_map.py +48 -0
  12. vrex_flow_engine-0.3.4/tests/test_video_mode_selection.py +36 -0
  13. {vrex_flow_engine-0.3.3 → vrex_flow_engine-0.3.4}/vrex_flow_engine.egg-info/PKG-INFO +1 -1
  14. {vrex_flow_engine-0.3.3 → vrex_flow_engine-0.3.4}/vrex_flow_engine.egg-info/SOURCES.txt +5 -0
  15. {vrex_flow_engine-0.3.3 → vrex_flow_engine-0.3.4}/PYPI_README.md +0 -0
  16. {vrex_flow_engine-0.3.3 → vrex_flow_engine-0.3.4}/flow_engine/__init__.py +0 -0
  17. {vrex_flow_engine-0.3.3 → vrex_flow_engine-0.3.4}/flow_engine/bridge/__init__.py +0 -0
  18. {vrex_flow_engine-0.3.3 → vrex_flow_engine-0.3.4}/flow_engine/bridge/flow_client.py +0 -0
  19. {vrex_flow_engine-0.3.3 → vrex_flow_engine-0.3.4}/flow_engine/bridge/ws_server.py +0 -0
  20. {vrex_flow_engine-0.3.3 → vrex_flow_engine-0.3.4}/flow_engine/cli/__init__.py +0 -0
  21. {vrex_flow_engine-0.3.3 → vrex_flow_engine-0.3.4}/flow_engine/cli/app.py +0 -0
  22. {vrex_flow_engine-0.3.3 → vrex_flow_engine-0.3.4}/flow_engine/cli/config.py +0 -0
  23. {vrex_flow_engine-0.3.3 → vrex_flow_engine-0.3.4}/flow_engine/cli/dashboard.py +0 -0
  24. {vrex_flow_engine-0.3.3 → vrex_flow_engine-0.3.4}/flow_engine/cli/health.py +0 -0
  25. {vrex_flow_engine-0.3.3 → vrex_flow_engine-0.3.4}/flow_engine/cli/processes.py +0 -0
  26. {vrex_flow_engine-0.3.3 → vrex_flow_engine-0.3.4}/flow_engine/cli/supervisor.py +0 -0
  27. {vrex_flow_engine-0.3.3 → vrex_flow_engine-0.3.4}/flow_engine/config.py +0 -0
  28. {vrex_flow_engine-0.3.3 → vrex_flow_engine-0.3.4}/flow_engine/ingest.py +0 -0
  29. {vrex_flow_engine-0.3.3 → vrex_flow_engine-0.3.4}/flow_engine/job_store.py +0 -0
  30. {vrex_flow_engine-0.3.3 → vrex_flow_engine-0.3.4}/flow_engine/main.py +0 -0
  31. {vrex_flow_engine-0.3.3 → vrex_flow_engine-0.3.4}/flow_engine/media.py +0 -0
  32. {vrex_flow_engine-0.3.3 → vrex_flow_engine-0.3.4}/flow_engine/media_store.py +0 -0
  33. {vrex_flow_engine-0.3.3 → vrex_flow_engine-0.3.4}/flow_engine/openai/__init__.py +0 -0
  34. {vrex_flow_engine-0.3.3 → vrex_flow_engine-0.3.4}/flow_engine/openai/_util.py +0 -0
  35. {vrex_flow_engine-0.3.3 → vrex_flow_engine-0.3.4}/flow_engine/openai/images.py +0 -0
  36. {vrex_flow_engine-0.3.3 → vrex_flow_engine-0.3.4}/flow_engine/openai/models.py +0 -0
  37. {vrex_flow_engine-0.3.3 → vrex_flow_engine-0.3.4}/flow_engine/openai/uploads.py +0 -0
  38. {vrex_flow_engine-0.3.3 → vrex_flow_engine-0.3.4}/flow_engine/pool.py +0 -0
  39. {vrex_flow_engine-0.3.3 → vrex_flow_engine-0.3.4}/flow_engine/posthog_client.py +0 -0
  40. {vrex_flow_engine-0.3.3 → vrex_flow_engine-0.3.4}/flow_engine/session.py +0 -0
  41. {vrex_flow_engine-0.3.3 → vrex_flow_engine-0.3.4}/flow_engine/video_context.py +0 -0
  42. {vrex_flow_engine-0.3.3 → vrex_flow_engine-0.3.4}/flow_engine/video_context_store.py +0 -0
  43. {vrex_flow_engine-0.3.3 → vrex_flow_engine-0.3.4}/setup.cfg +0 -0
  44. {vrex_flow_engine-0.3.3 → vrex_flow_engine-0.3.4}/vrex_flow_engine.egg-info/dependency_links.txt +0 -0
  45. {vrex_flow_engine-0.3.3 → vrex_flow_engine-0.3.4}/vrex_flow_engine.egg-info/entry_points.txt +0 -0
  46. {vrex_flow_engine-0.3.3 → vrex_flow_engine-0.3.4}/vrex_flow_engine.egg-info/requires.txt +0 -0
  47. {vrex_flow_engine-0.3.3 → 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.3
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
+ ```
@@ -30,6 +30,7 @@ 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"
@@ -38,7 +39,6 @@ RPC_GET_MEDIA = "as29s"
38
39
  # rotation check covers them and porting them later starts from a known id).
39
40
  RPC_GENERATE_VIDEO_EXTEND = "fZytfe"
40
41
  RPC_GENERATE_VIDEO_EDIT = "jIps6"
41
- RPC_GENERATE_VIDEO_UPSAMPLE = "p0UkFb"
42
42
 
43
43
  # Backend method path behind each id, as declared in Flow's JS bundle
44
44
  # (``new _.kx("nzlxg", …, "/VideoFxService.GetCredits")``). The ids are Wiz
@@ -77,6 +77,13 @@ TOOL_PINHOLE = 22
77
77
  CAPTCHA_APPLICATION_WEB = 1
78
78
  # Trailing int in the mediaGenerationContext ``[batchId, 2]`` pair.
79
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
80
87
  # Where the extension writes the solved reCAPTCHA token. A clientContext is
81
88
  # ``[null, 22, null×3, projectId, null×4, [token, 1]]`` so the token sits at
82
89
  # ``[10][0]`` of wherever the context lives in a payload.
@@ -238,12 +245,38 @@ def video_request_item_r2v(
238
245
  ]
239
246
 
240
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
+
241
267
  def generate_video_payload(
242
- 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,
243
272
  ) -> list[Any]:
244
- """Body shared by the three video RPCs: ``[items, clientContext,
245
- [batchId, 2]]``. Token goes at ``GENERATE_VIDEO_CAPTCHA_PATH``."""
246
- 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]
247
280
 
248
281
 
249
282
  def image_request_item(
@@ -383,17 +416,27 @@ def parse_media_entry(entry: Any) -> dict[str, Any]:
383
416
  def parse_generate_video(data: Any) -> dict[str, Any]:
384
417
  """``YhhmEf`` → ``{credits, workflows:[{name, primary_media_id}], media}``.
385
418
 
386
- ``data[2]`` lists the new workflows as ``[workflowId, null, null,
419
+ ``data[2]`` lists the workflows as ``[workflowId, null, null,
387
420
  [title, createTime, null, null, mediaId, batchId, updateTime], projectId]``;
388
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.
389
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
+ }
390
434
  workflows: list[dict[str, str]] = []
391
435
  for wf in _list(_at(data, 2)):
392
436
  wf_id = _str(_at(wf, 0))
393
- media_id = _str(_at(wf, 3, 4))
437
+ media_id = new_media_by_workflow.get(wf_id) or _str(_at(wf, 3, 4))
394
438
  if wf_id and media_id:
395
439
  workflows.append({"name": wf_id, "primary_media_id": media_id})
396
- media = [parse_media_entry(m) for m in _list(_at(data, 3))]
397
440
  if not workflows:
398
441
  # Fall back to the media rows, which also carry their workflow id.
399
442
  for m in media:
@@ -5,10 +5,11 @@ and the job debug endpoint can inspect Flow's payload when a shape drifts.
5
5
 
6
6
  Wired from flow.google.com captures (2026-09): project list/create, model
7
7
  catalog, credits/tier, preset voices, image upload (inline) and resumable
8
- video upload, image generation (sync), text-/image-/reference-to-video and
9
- frames-to-video (interpolation), media status polling and signed URL
10
- resolution. Extend and edit still need their batchexecute payload captured —
11
- the videos route answers 501 for them instead of guessing array positions.
8
+ video upload, image generation (sync), text-/image-/reference-to-video,
9
+ frames-to-video (interpolation), 1080p upsampling of a generated clip, media
10
+ status polling and signed URL resolution. Extend and edit still need their
11
+ batchexecute payload captured — the videos route answers 501 for them instead
12
+ of guessing array positions.
12
13
  """
13
14
  from __future__ import annotations
14
15
 
@@ -264,13 +265,14 @@ class FlowSDK:
264
265
  items: list[list[Any]],
265
266
  project_id: str,
266
267
  scene_id: Optional[str] = None,
268
+ with_context_flag: bool = True,
267
269
  ) -> dict[str, Any]:
268
270
  """Submit video items and shape the reply as ``{raw, operation_names,
269
271
  workflows, credits}`` (``operation_names`` are the workflow ids, each
270
272
  paired in ``workflows`` with the media id to poll) or ``{raw, error}``."""
271
273
  resp = await self._client.rpc(
272
274
  rpc_id,
273
- flow_rpc.generate_video_payload(items, project_id),
275
+ flow_rpc.generate_video_payload(items, project_id, with_context_flag=with_context_flag),
274
276
  captcha_action=CAPTCHA_VIDEO,
275
277
  captcha_path=flow_rpc.GENERATE_VIDEO_CAPTCHA_PATH,
276
278
  source_path=flow_rpc.project_source_path(project_id),
@@ -421,6 +423,43 @@ class FlowSDK:
421
423
  items = [flow_rpc.video_request_item_r2v(prompt, model_key, aspect_enum, refs, seed=seed)]
422
424
  return await self._dispatch_video(flow_rpc.RPC_GENERATE_VIDEO_R2V, items, project_id, scene_id)
423
425
 
426
+ async def gen_video_upsample(
427
+ self,
428
+ source_media_id: str,
429
+ project_id: str,
430
+ aspect_ratio: str = "VIDEO_ASPECT_RATIO_LANDSCAPE",
431
+ paygate_tier: Optional[int] = None,
432
+ workflow_id: Optional[str] = None,
433
+ scene_id: Optional[str] = None,
434
+ model_key: Optional[str] = None,
435
+ ) -> dict[str, Any]:
436
+ """Upsample a generated clip to 1080p. The item names the source
437
+ clip's workflow; when the caller does not know it, it is read from
438
+ the clip's media entry (one get-media RPC). No prompt is sent — the
439
+ source clip is the whole input."""
440
+ if paygate_tier is None:
441
+ raise ValueError("paygate_tier is required — resolve the account first")
442
+ aspect_enum = flow_rpc.VIDEO_ASPECT_ENUM.get(aspect_ratio)
443
+ if aspect_enum is None:
444
+ return {"raw": None, "error": f"upsample_aspect_unsupported_{aspect_ratio}"}
445
+ if not isinstance(source_media_id, str) or not source_media_id:
446
+ return {"raw": None, "error": "missing_source_media_id"}
447
+ if not workflow_id:
448
+ detail = await self.get_media(source_media_id, project_id)
449
+ if detail.get("error"):
450
+ return {"raw": detail.get("raw"), "error": detail["error"]}
451
+ workflow_id = (detail.get("media") or {}).get("workflow_id")
452
+ if not workflow_id:
453
+ return {"raw": detail.get("raw"), "error": "upsample_source_has_no_workflow"}
454
+ items = [flow_rpc.video_request_item_upsample(
455
+ source_media_id, workflow_id, aspect_enum, scene_id=scene_id,
456
+ model_key=model_key or flow_rpc.VIDEO_UPSAMPLER_1080P_KEY,
457
+ )]
458
+ return await self._dispatch_video(
459
+ flow_rpc.RPC_GENERATE_VIDEO_UPSAMPLE, items, project_id, scene_id,
460
+ with_context_flag=False,
461
+ )
462
+
424
463
  # ── polling ────────────────────────────────────────────────────────────
425
464
  async def check_async(
426
465
  self,
@@ -33,9 +33,12 @@ _CACHE_FILE = STORAGE_DIR / "catalog.json"
33
33
  _CACHE_VERSION = 2
34
34
 
35
35
  # Modes whose batchexecute payload has been captured and verified. Other modes
36
- # (extension, edit, upsample) still resolve a key so the error can name it,
37
- # but resolve() reports them as not dispatchable and the route answers 501.
38
- _DISPATCHABLE_MODES: frozenset[str] = frozenset({"t2v", "i2v", "r2v", "interpolation"})
36
+ # (extension, edit) still resolve a key so the error can name it, but
37
+ # resolve() reports them as not dispatchable and the route answers 501.
38
+ # Upsample is dispatchable but the catalog RPC does not list the upsampler
39
+ # keys, so the videos route uses ``flow_rpc.VIDEO_UPSAMPLER_1080P_KEY``
40
+ # directly instead of resolving it here.
41
+ _DISPATCHABLE_MODES: frozenset[str] = frozenset({"t2v", "i2v", "r2v", "interpolation", "upsample"})
39
42
 
40
43
 
41
44
  class _Catalog:
@@ -61,6 +61,7 @@ _DISPATCH = {
61
61
  "i2v": "gen_video",
62
62
  "r2v": "gen_video_r2v",
63
63
  "interpolation": "gen_video_interpolation",
64
+ "upsample": "gen_video_upsample",
64
65
  }
65
66
 
66
67