vrex-flow-engine 0.2.2__tar.gz → 0.2.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 (42) hide show
  1. {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/PKG-INFO +1 -1
  2. vrex_flow_engine-0.2.4/README.md +268 -0
  3. {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/__init__.py +1 -1
  4. {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/bridge/flow_client.py +15 -0
  5. {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/bridge/flow_sdk.py +148 -2
  6. {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/catalog.py +5 -3
  7. {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/cli/processes.py +3 -0
  8. {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/cli/supervisor.py +5 -1
  9. {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/jobs.py +3 -2
  10. {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/media.py +29 -1
  11. {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/openai/videos.py +33 -0
  12. {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/pyproject.toml +1 -1
  13. {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/vrex_flow_engine.egg-info/PKG-INFO +1 -1
  14. {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/vrex_flow_engine.egg-info/SOURCES.txt +1 -0
  15. {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/PYPI_README.md +0 -0
  16. {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/bridge/__init__.py +0 -0
  17. {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/bridge/ws_server.py +0 -0
  18. {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/cli/__init__.py +0 -0
  19. {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/cli/app.py +0 -0
  20. {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/cli/config.py +0 -0
  21. {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/cli/dashboard.py +0 -0
  22. {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/cli/health.py +0 -0
  23. {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/config.py +0 -0
  24. {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/ingest.py +0 -0
  25. {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/job_store.py +0 -0
  26. {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/main.py +0 -0
  27. {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/media_store.py +0 -0
  28. {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/openai/__init__.py +0 -0
  29. {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/openai/_util.py +0 -0
  30. {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/openai/images.py +0 -0
  31. {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/openai/models.py +0 -0
  32. {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/openai/uploads.py +0 -0
  33. {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/pool.py +0 -0
  34. {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/posthog_client.py +0 -0
  35. {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/session.py +0 -0
  36. {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/video_context.py +0 -0
  37. {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/video_context_store.py +0 -0
  38. {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/setup.cfg +0 -0
  39. {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/vrex_flow_engine.egg-info/dependency_links.txt +0 -0
  40. {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/vrex_flow_engine.egg-info/entry_points.txt +0 -0
  41. {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/vrex_flow_engine.egg-info/requires.txt +0 -0
  42. {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.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.2.2
3
+ Version: 0.2.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,268 @@
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 (labs.google).
5
+
6
+ flow-engine drives a Chrome extension that borrows your authenticated Flow
7
+ session (token + cookies + reCAPTCHA solving) and proxies Flow's private
8
+ `aisandbox-pa.googleapis.com` calls, 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
+ ```
14
+ OpenAI client ──HTTP :8101──> flow-engine ──WS :9223──> Chrome extension ──fetch──> Google Flow
15
+ (this repo) (extension/, in your browser)
16
+ ```
17
+
18
+ In production the HTTP surface is published at `https://flow.getvrex.com`
19
+ (a `cloudflared` tunnel → `127.0.0.1:8101`); the Next.js app reaches it via
20
+ `FLOW_ENGINE_URL`. See "Expose to production" below.
21
+
22
+ ## Endpoints
23
+
24
+ - `POST /v1/images/generations` — synchronous image (Nano Banana 2).
25
+ - `POST /v1/videos/generations` + `GET /v1/videos/{id}` — async video job + poll (Veo 3.1 lite).
26
+ - `GET /v1/videos/{id}/debug` — raw `{dispatch, last_poll}` Google payloads for a job (troubleshooting).
27
+ - `POST /v1/flow/uploads` — image **or video** bytes → Flow media id (routes by mime).
28
+ - `GET /v1/models` — model catalog (cached on disk, refresh with `?refresh=1`).
29
+ - `GET /media/{id}` — cached generated bytes at a stable URL.
30
+ - `GET /api/health` — liveness + extension-connected status (ungated).
31
+
32
+ `/v1/*` and `/api/pool/status` require `Authorization: Bearer <FLOW_ENGINE_API_KEY>`.
33
+ `/api/health` and `/media/{id}` are ungated.
34
+
35
+ ## Requirements
36
+
37
+ - Python 3.10+
38
+ - Google Chrome signed in to **labs.google** with a Flow tab open, and the
39
+ extension in `extension/` loaded (chrome://extensions → Load unpacked).
40
+ - A Flow account (Pro or Ultra — the paygate tier is read from your session).
41
+ - `cloudflared` (only for exposing to production).
42
+
43
+ > The extension's `background.js` hardcodes `ws://127.0.0.1:9223` and
44
+ > `http://127.0.0.1:8101/api/ext/callback`, so flow-engine defaults to those
45
+ > ports. It therefore **cannot run alongside the legacy flowboard agent on
46
+ > defaults** — change both the env vars and the extension URLs to run both.
47
+
48
+ ## Quickstart — one command
49
+
50
+ `flow-engine start` is a supervisor: it stores your secrets, launches the engine
51
+ **and** the Cloudflare tunnel as child processes, restarts either on crash, and
52
+ renders a live status dashboard. The package is published to PyPI as
53
+ **`vrex-flow-engine`**. Run it with [uv](https://docs.astral.sh/uv/) (the Python
54
+ equivalent of `npx` — no manual venv):
55
+
56
+ ```bash
57
+ uvx vrex-flow-engine start
58
+ ```
59
+
60
+ `uvx` ships with `uv`. If you get `uvx: command not found`, install uv first
61
+ (then re-run the line above):
62
+
63
+ ```bash
64
+ curl -LsSf https://astral.sh/uv/install.sh | sh # then: exec $SHELL (adds ~/.local/bin to PATH)
65
+ # or on macOS: brew install uv
66
+ ```
67
+
68
+ No uv? It's a normal PyPI package — `pipx run vrex-flow-engine start`, or
69
+ `pip install --user vrex-flow-engine && flow-engine start`.
70
+
71
+ First run prompts for the **engine API key** (must match the value the Next.js
72
+ app sends — `FLOW_ENGINE_API_KEY` on Vrex Multimodal / `.env.prd`) and the
73
+ **Cloudflare tunnel token** (dashboard → Zero Trust → Networks → Tunnels → your
74
+ tunnel → "run a connector"). Both are saved to `~/.vrex-flow/config.json` (0600)
75
+ so later runs need no input. Then it brings up the engine + tunnel and shows:
76
+
77
+ ```
78
+ 🎬 Vrex Flow Engine ● running uptime 3m02s
79
+ ┌ engine ──────────────┐ ┌ tunnel ───────────────┐
80
+ │ status ● UP │ │ status ● connected │
81
+ │ extension ● connected│ │ public flow.getvrex.com│
82
+ │ instances 1 │ │ → 127.0.0.1:8101 │
83
+ └──────────────────────┘ └───────────────────────┘
84
+ ```
85
+
86
+ The one thing the CLI can't automate: open a `labs.google/fx/tools/flow` tab in
87
+ Chrome with the `extension/` loaded and signed into a Flow Pro/Ultra account.
88
+ Until you do, the dashboard shows `extension ● OPEN A FLOW TAB`. Ctrl-C stops
89
+ both processes cleanly.
90
+
91
+ ### CLI commands
92
+
93
+ | Command | What it does |
94
+ |---|---|
95
+ | `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. |
96
+ | `flow-engine setup` | (Re)store the engine key + tunnel token in `~/.vrex-flow/config.json`. |
97
+ | `flow-engine doctor` | Check prerequisites (python, cloudflared, config) and probe a running engine. |
98
+ | `flow-engine serve` | Run just the engine in the foreground (for systemd / your own supervisor). |
99
+
100
+ Secrets can also come from env (`FLOW_ENGINE_API_KEY`, `CLOUDFLARE_TUNNEL_TOKEN`)
101
+ or flags (`--engine-key`, `--tunnel-token`) — flags > env > stored config.
102
+
103
+ ## Run manually (without the CLI)
104
+
105
+ The engine reads config from the environment directly — **no `.env` auto-load**.
106
+ `FLOW_ENGINE_API_KEY` is **required**; the server refuses to boot without it.
107
+
108
+ ```bash
109
+ cd flow-engine
110
+ python -m venv .venv && source .venv/bin/activate
111
+ pip install -e .
112
+ export FLOW_ENGINE_API_KEY=<shared-secret> # must match the caller's key
113
+ flow-engine serve # serves :8101 (HTTP) + :9223 (WS)
114
+ # equivalent: python -m flow_engine.main
115
+ ```
116
+
117
+ Load `extension/` in Chrome, open a `labs.google/fx/tools/flow` tab, then check:
118
+
119
+ ```bash
120
+ curl localhost:8101/api/health
121
+ # {"ok":true,"extension_connected":true,"instances":1}
122
+ ```
123
+
124
+ `extension_connected:false` means no Flow tab/extension is bridged yet — open
125
+ the tab and reload the extension.
126
+
127
+ ## Expose to production (Cloudflare Tunnel)
128
+
129
+ The public origin `https://flow.getvrex.com` is a `cloudflared` tunnel to the
130
+ local `:8101`. **`flow-engine start` runs this tunnel for you** (via the stored
131
+ token), so normally you don't touch `cloudflared` directly. To run it standalone
132
+ — e.g. the `serve` path — start the tunnel as a separate process on the same
133
+ machine:
134
+
135
+ ```bash
136
+ cloudflared tunnel run --token <your-tunnel-token> # flow.getvrex.com → 127.0.0.1:8101
137
+ # or, with a named-tunnel config file:
138
+ cloudflared tunnel run --config cloudflared.yml
139
+ ```
140
+
141
+ If `https://flow.getvrex.com` returns **HTTP 530 / `error code: 1033`**, the
142
+ tunnel is down (Cloudflare has no active connection to the origin) — restart it
143
+ (or `flow-engine start`). A `502`/`1016` instead means the tunnel is up but the
144
+ engine on `:8101` is not — restart the engine.
145
+
146
+ ## Examples
147
+
148
+ Image (`model` must be `NANO_BANANA_2`, or omit to force it):
149
+
150
+ ```bash
151
+ curl localhost:8101/v1/images/generations \
152
+ -H "authorization: Bearer $FLOW_ENGINE_API_KEY" \
153
+ -H 'content-type: application/json' -d '{
154
+ "prompt": "a studio portrait, soft light", "n": 2,
155
+ "size": "1024x1024", "model": "NANO_BANANA_2"
156
+ }'
157
+ # {"created":..., "data":[{"url":"http://127.0.0.1:8101/media/<uuid>"}, ...]}
158
+ ```
159
+
160
+ Video (`model` must be `veo_3_1_lite_low_priority`). Text-to-video:
161
+
162
+ ```bash
163
+ JOB=$(curl -s localhost:8101/v1/videos/generations \
164
+ -H "authorization: Bearer $FLOW_ENGINE_API_KEY" \
165
+ -H 'content-type: application/json' -d '{
166
+ "prompt": "slow dolly in over a city at dusk",
167
+ "model": "veo_3_1_lite_low_priority", "seconds": 8, "size": "1280x720"
168
+ }' | jq -r .id)
169
+ curl -s localhost:8101/v1/videos/$JOB \
170
+ -H "authorization: Bearer $FLOW_ENGINE_API_KEY" # poll until status == "completed"
171
+ ```
172
+
173
+ Image-to-video — pass the start frame inline; flow-engine uploads it for you:
174
+
175
+ ```bash
176
+ curl localhost:8101/v1/videos/generations \
177
+ -H "authorization: Bearer $FLOW_ENGINE_API_KEY" \
178
+ -H 'content-type: application/json' -d "{
179
+ \"prompt\": \"the subject turns to camera\", \"model\": \"veo_3_1_lite_low_priority\",
180
+ \"start_images\": [\"$(base64 < frame.png)\"], \"size\": \"1280x720\"
181
+ }"
182
+ ```
183
+
184
+ `start_images` / `ref_images` accept a data URL, an `http(s)` URL, or bare
185
+ base64. Already have a Flow media id (e.g. from `/v1/flow/uploads`)? Pass it in
186
+ `start_media_ids` / `ref_media_ids` instead — the two forms merge. Uploading a
187
+ reference **video** (for v2v/edit/extend) works the same way via
188
+ `/v1/flow/uploads -F file=@clip.mp4`, which returns a `"kind":"video"` media id.
189
+
190
+ Extend or edit a clip you already generated — pass back the `media_id` you got
191
+ and flow-engine resolves the rest (scene / workflow / frame count) from what it
192
+ remembered this run:
193
+
194
+ ```bash
195
+ # extend (continue a Veo clip — only Veo-generated videos can be extended)
196
+ curl localhost:8101/v1/videos/generations \
197
+ -H "authorization: Bearer $FLOW_ENGINE_API_KEY" \
198
+ -H 'content-type: application/json' -d '{
199
+ "prompt": "a dog joins in", "mode": "extend",
200
+ "model": "veo_3_1_lite_low_priority", "source_media_id": "<prev media_id>"
201
+ }'
202
+
203
+ # edit (video-to-video rewrite)
204
+ curl localhost:8101/v1/videos/generations \
205
+ -H "authorization: Bearer $FLOW_ENGINE_API_KEY" \
206
+ -H 'content-type: application/json' -d '{
207
+ "prompt": "add a green ball", "mode": "edit",
208
+ "model": "veo_3_1_lite_low_priority", "source_media_id": "<prev media_id>",
209
+ "source_seconds": 8
210
+ }'
211
+ ```
212
+
213
+ For a source flow-engine didn't generate this run (e.g. after a restart), pass
214
+ `workflow_id` explicitly (extend resolves the scene from it; edit uses it
215
+ directly) — plus `source_seconds` for edit so the frame range matches. The
216
+ generation-context registry is in-memory, so it resets when the server restarts.
217
+
218
+ ## Configuration
219
+
220
+ These configure the **engine** process (read from its environment). The
221
+ `flow-engine start` launcher injects `FLOW_ENGINE_API_KEY` + ports for you from
222
+ `~/.vrex-flow/config.json`; set these directly only for the manual `serve` path.
223
+ All optional except the API key. Port/host/storage vars keep the `FLOWPROXY_`
224
+ prefix for extension compatibility; the API key and pool size use the
225
+ `FLOW_ENGINE_` / `FLOW_POOL_` names.
226
+
227
+ | Env var | Default | Purpose |
228
+ |---|---|---|
229
+ | `FLOW_ENGINE_API_KEY` | — (**required**) | Bearer key gating `/v1/*`. Legacy `FLOWPROXY_API_KEY` still accepted. |
230
+ | `FLOWPROXY_HTTP_PORT` | `8101` | HTTP surface (OpenAI endpoints + `/media` + callback). |
231
+ | `FLOWPROXY_WS_HOST` | `127.0.0.1` | Extension WS bind — **must be loopback** (unauthenticated by design). |
232
+ | `FLOWPROXY_EXT_WS_PORT` | `9223` | Extension WebSocket port. |
233
+ | `FLOWPROXY_PUBLIC_BASE_URL` | `http://127.0.0.1:8101` | Base used to build absolute `/media` URLs returned to clients. |
234
+ | `FLOW_POOL_CONCURRENCY` | `4` | Max concurrent in-flight Flow calls per instance (back-pressure, no 503). |
235
+ | `FLOWPROXY_STORAGE` | `./storage` | Local byte cache dir. |
236
+ | `FLOWPROXY_CATALOG_TTL` | `86400` | Model-catalog cache TTL (seconds). |
237
+ | `VIDEO_POLL_MAX_CYCLES` / `VIDEO_POLL_INTERVAL_S` | `72` / `10` | Video poll budget (default 12 min). |
238
+
239
+ ## Layout
240
+
241
+ ```
242
+ extension/ Chrome MV3 bridge (open a labs.google/fx/tools/flow tab)
243
+ flow_engine/
244
+ bridge/ WS server + flow_client + flow_sdk (Flow API port)
245
+ media.py DB-free media cache (registry + on-disk bytes)
246
+ jobs.py in-memory job store + async video poller
247
+ session.py readiness gate + default project + pool seam
248
+ catalog.py model catalog (flow.projectInitialData), disk-cached
249
+ openai/ /v1/images, /v1/videos, /v1/flow/uploads, /v1/models
250
+ main.py FastAPI app, /api/ext/callback, /media/{id}, lifespan
251
+ cli/ launcher: app (argparse), supervisor, dashboard,
252
+ processes, health, config (~/.vrex-flow)
253
+ ```
254
+
255
+ ## Notes & caveats
256
+
257
+ - **Single account.** One extension instance = one Google account = one tier.
258
+ Multi-tenant needs a browser-instance pool routed by API key — the
259
+ `session.get_bridge(api_key)` indirection is where that goes.
260
+ - **Live Flow tab required.** Every dispatch solves an enterprise reCAPTCHA in
261
+ an open Flow tab; hosting needs a headful/virtual-display Chrome kept warm.
262
+ - **Signed URLs expire.** flow-engine caches bytes locally and serves stable
263
+ `/media/{id}` URLs; workflow-mode video arrives as inline base64 MP4.
264
+ - **Localhost WS is unauthenticated** by design — never bind it to a network
265
+ interface. Gate the HTTP `/v1/*` surface with `FLOW_ENGINE_API_KEY` when exposed.
266
+ - **No `.env` auto-load.** The process reads `os.environ` directly — export vars
267
+ in the shell (or a wrapper/launchd unit), don't rely on a `.env` file.
268
+ ```
@@ -1,4 +1,4 @@
1
1
  """flow_engine — host a Flowboard browser-extension instance and expose
2
2
  OpenAI-compatible image/video generation endpoints over Google Flow."""
3
3
 
4
- __version__ = "0.0.1"
4
+ __version__ = "0.2.4"
@@ -397,6 +397,21 @@ class FlowClient:
397
397
  timeout=timeout,
398
398
  )
399
399
 
400
+ async def media_url_request(
401
+ self,
402
+ url: str,
403
+ timeout: Optional[float] = 60.0,
404
+ ) -> dict:
405
+ """Resolve a TRPC media-redirect URL to its final location.
406
+
407
+ ``media.getMediaUrlRedirect`` answers with a 302 to the actual asset,
408
+ which only the browser session can follow (cookie auth). The extension
409
+ follows it and reports back where it landed WITHOUT reading the body,
410
+ so a multi-megabyte clip never crosses the WebSocket — the engine then
411
+ downloads it server-side.
412
+ """
413
+ return await self._send("media_url_request", {"url": url}, timeout=timeout)
414
+
400
415
  async def upload_video(
401
416
  self,
402
417
  project_id: str,
@@ -42,6 +42,15 @@ VIDEO_T2V_URL = f"{FLOW_API_BASE}/v1/video:batchAsyncGenerateVideoText"
42
42
  # capture (2026-05). NOTE: only Veo-generated videos can be extended.
43
43
  VIDEO_EXTEND_URL = f"{FLOW_API_BASE}/v1/video:batchAsyncGenerateVideoExtendVideo"
44
44
  VIDEO_EDIT_URL = f"{FLOW_API_BASE}/v1/video:batchAsyncGenerateVideoEditVideo"
45
+ # Interpolation ("Frames to Video"): takes BOTH a startImage and an endImage and
46
+ # generates the motion between them. Passing the same media id as both frames is
47
+ # how you get a clip that ends where it began, i.e. one that loops seamlessly.
48
+ # Verified from a live labs.google capture (2026-08): Flow web dispatches
49
+ # `veo_3_1_interpolation_lite_low_priority` to this endpoint with each frame as
50
+ # `{mediaId, cropCoordinates}`. See gen_video_interpolation() for the body.
51
+ VIDEO_INTERPOLATION_URL = (
52
+ f"{FLOW_API_BASE}/v1/video:batchAsyncGenerateVideoStartAndEndImage"
53
+ )
45
54
  VIDEO_POLL_URL = f"{FLOW_API_BASE}/v1/video:batchCheckAsyncVideoGenerationStatus"
46
55
  UPLOAD_IMAGE_URL = f"{FLOW_API_BASE}/v1/flow/uploadImage"
47
56
  # Video upload is a resumable, cookie-authed flow against labs.google (NOT
@@ -53,6 +62,12 @@ UPLOAD_IMAGE_URL = f"{FLOW_API_BASE}/v1/flow/uploadImage"
53
62
  UPLOAD_VIDEO_START_URL = "https://labs.google/fx/api/upload-video?action=start"
54
63
  UPLOAD_VIDEO_UPLOAD_URL = "https://labs.google/fx/api/upload-video?action=upload"
55
64
  TRPC_UPDATE_VIDEO_OFFSET = "https://labs.google/fx/api/trpc/videoFx.updateVideoOffset"
65
+ # Resolves a media id to the asset itself via a 302. This is how the web client
66
+ # loads a finished clip: `batchCheckAsync` reports SUCCESSFUL but carries no
67
+ # fifeUrl for workflow-schema (Low Priority) video, and the old
68
+ # `GET /v1/media/<id>` byte probe now returns 400 INVALID_ARGUMENT for every id
69
+ # — including bogus ones — so it is gone rather than merely restricted.
70
+ TRPC_MEDIA_URL_REDIRECT = "https://labs.google/fx/api/trpc/media.getMediaUrlRedirect"
56
71
 
57
72
 
58
73
  # Veo 3.1 reference-to-video (r2v). Verified against a live labs.google web
@@ -601,6 +616,94 @@ class FlowSDK:
601
616
  out["workflows"] = workflows
602
617
  return out
603
618
 
619
+ # ── Veo 3.1 interpolation (start + end frame) ──────────────────────────
620
+ async def gen_video_interpolation(
621
+ self,
622
+ prompt: str,
623
+ project_id: str,
624
+ start_media_ids: list[str],
625
+ end_media_ids: Optional[list[str]] = None,
626
+ aspect_ratio: str = "VIDEO_ASPECT_RATIO_LANDSCAPE",
627
+ paygate_tier: Optional[str] = None,
628
+ scene_id: Optional[str] = None,
629
+ model_key: Optional[str] = None,
630
+ ) -> dict[str, Any]:
631
+ """Kick off interpolation operation(s) — motion between two frames.
632
+
633
+ One item is dispatched per start/end pair. ``end_media_ids`` defaults to
634
+ ``start_media_ids``, which produces a clip whose last frame matches its
635
+ first — the seamless-loop case. When both lists are given they are zipped
636
+ pairwise and must be the same length.
637
+
638
+ Same return contract as ``gen_video``: ``{raw, operation_names}`` on
639
+ success (plus ``workflows`` for the low-priority schema), ``{raw, error}``
640
+ otherwise.
641
+ """
642
+ if paygate_tier is None:
643
+ raise ValueError("paygate_tier is required")
644
+ if not model_key:
645
+ return {"raw": None, "error": "missing_model_key_for_interpolation"}
646
+
647
+ starts = [m for m in (start_media_ids or []) if isinstance(m, str) and m]
648
+ if not starts:
649
+ return {"raw": None, "error": "missing_start_media_id"}
650
+ ends = [m for m in (end_media_ids or []) if isinstance(m, str) and m] or list(starts)
651
+ if len(ends) != len(starts):
652
+ return {
653
+ "raw": None,
654
+ "error": (
655
+ f"start/end frame count mismatch: {len(starts)} start vs "
656
+ f"{len(ends)} end — pass equal-length lists or omit end frames"
657
+ ),
658
+ }
659
+
660
+ ts = int(time.time() * 1000)
661
+ ctx = _client_context(project_id, paygate_tier)
662
+ # Full-frame crop; Flow's web client sends the UI's letterbox crop here,
663
+ # but we always condition on the whole generated still.
664
+ crop = {"top": 0, "left": 0, "bottom": 1, "right": 1}
665
+ items: list[dict[str, Any]] = []
666
+ for i, (start_mid, end_mid) in enumerate(zip(starts, ends)):
667
+ item: dict[str, Any] = {
668
+ "aspectRatio": aspect_ratio,
669
+ # Distinct seed per item so Flow doesn't dedupe.
670
+ "seed": (ts + i * 9973) % 1_000_000,
671
+ "textInput": {"structuredPrompt": {"parts": [{"text": prompt}]}},
672
+ "videoModelKey": model_key,
673
+ "metadata": {"sceneId": scene_id} if scene_id else {},
674
+ "startImage": {"mediaId": start_mid, "cropCoordinates": dict(crop)},
675
+ "endImage": {"mediaId": end_mid, "cropCoordinates": dict(crop)},
676
+ }
677
+ items.append(item)
678
+ body = {
679
+ "clientContext": ctx,
680
+ "mediaGenerationContext": {"batchId": str(uuid.uuid4())},
681
+ "requests": items,
682
+ "useV2ModelConfig": True,
683
+ }
684
+
685
+ resp = await self._client.api_request(
686
+ url=VIDEO_INTERPOLATION_URL,
687
+ method="POST",
688
+ headers=dict(_API_HEADERS),
689
+ body=body,
690
+ captcha_action=CAPTCHA_VIDEO,
691
+ )
692
+ if isinstance(resp, dict) and resp.get("error"):
693
+ return {"raw": resp, "error": resp["error"]}
694
+ inner_err = _extract_inner_api_error(resp)
695
+ if inner_err:
696
+ return {"raw": resp, "error": inner_err}
697
+
698
+ op_names = extract_operation_names(resp)
699
+ if not op_names:
700
+ return {"raw": resp, "error": "no_operations_in_response"}
701
+ out: dict[str, Any] = {"raw": resp, "operation_names": op_names}
702
+ workflows = extract_video_workflows(resp)
703
+ if workflows:
704
+ out["workflows"] = workflows
705
+ return out
706
+
604
707
  # ── Veo 3.1 reference-to-video (r2v) ───────────────────────────────────
605
708
  async def gen_video_r2v(
606
709
  self,
@@ -1112,8 +1215,11 @@ class FlowSDK:
1112
1215
  continue
1113
1216
 
1114
1217
  # 2. SUCCESSFUL. Prefer a direct URL if the status payload carries
1115
- # one; otherwise fetch the encoded MP4 bytes.
1116
- url = _media_url_from_entry(m)
1218
+ # one; otherwise resolve it through the media redirect. The
1219
+ # encoded-bytes probe is kept last as a legacy fallback — Flow
1220
+ # stopped serving it, but it costs one call and can only help if
1221
+ # Google restores it.
1222
+ url = _media_url_from_entry(m) or await self.resolve_media_url(mid)
1117
1223
  if url:
1118
1224
  ops_summary.append({
1119
1225
  "name": name, "done": True,
@@ -1135,6 +1241,46 @@ class FlowSDK:
1135
1241
  })
1136
1242
  return ops_summary, raw_polls
1137
1243
 
1244
+ async def resolve_media_url(self, mid: str) -> Optional[str]:
1245
+ """Ask the browser session where a media id's asset actually lives.
1246
+
1247
+ Returns the resolved asset URL, or None while it is not yet servable
1248
+ (caller keeps polling). The URL is public/signed, so the engine fetches
1249
+ the bytes itself rather than routing them through the extension.
1250
+ """
1251
+ from urllib.parse import quote
1252
+
1253
+ try:
1254
+ resp = await self._client.media_url_request(
1255
+ f"{TRPC_MEDIA_URL_REDIRECT}?name={quote(mid)}"
1256
+ )
1257
+ except Exception as exc: # noqa: BLE001
1258
+ logger.warning("media url resolve error for %s: %s", mid[:8], exc)
1259
+ return None
1260
+ if not isinstance(resp, dict) or resp.get("error"):
1261
+ # An extension predating media_url_request never answers this
1262
+ # method, so the call times out — the one failure that looks
1263
+ # identical to "not ready yet". Name it so it is not mistaken for
1264
+ # a slow render.
1265
+ logger.warning(
1266
+ "media url resolve failed for %s: %s (if this repeats, reload the "
1267
+ "browser extension — media_url_request needs extension >= 0.2.0)",
1268
+ mid[:8], (resp or {}).get("error") if isinstance(resp, dict) else resp,
1269
+ )
1270
+ return None
1271
+ status = resp.get("status")
1272
+ if isinstance(status, int) and status >= 400:
1273
+ logger.warning("media url resolve HTTP %s for %s", status, mid[:8])
1274
+ return None
1275
+ url = (resp.get("data") or {}).get("url")
1276
+ if not isinstance(url, str) or not url:
1277
+ return None
1278
+ # The redirect landing page is the asset; if we came back pointing at
1279
+ # the TRPC route itself nothing was followed, so treat it as not ready.
1280
+ if url.startswith(TRPC_MEDIA_URL_REDIRECT):
1281
+ return None
1282
+ return url
1283
+
1138
1284
  async def _fetch_workflow_bytes(self, mid: str) -> Optional[dict[str, Any]]:
1139
1285
  """Fetch a finished workflow clip's encoded MP4 via ``GET /v1/media/<id>``.
1140
1286
 
@@ -26,6 +26,7 @@ from flow_engine.bridge.flow_sdk import (
26
26
  VIDEO_EDIT_URL,
27
27
  VIDEO_EXTEND_URL,
28
28
  VIDEO_I2V_URL,
29
+ VIDEO_INTERPOLATION_URL,
29
30
  VIDEO_R2V_URL,
30
31
  VIDEO_T2V_URL,
31
32
  FlowSDK,
@@ -36,13 +37,14 @@ logger = logging.getLogger(__name__)
36
37
 
37
38
  _CACHE_FILE = STORAGE_DIR / "catalog.json"
38
39
 
39
- # Endpoints we can actually dispatch today (body shapes verified). interpolation
40
- # / upsample still need a captured request before we wire them — resolve()
41
- # returns endpoint=None for those so callers 400 clearly.
40
+ # Endpoints we can actually dispatch today (body shapes verified). upsample
41
+ # still needs a captured request before we wire it — resolve() returns
42
+ # endpoint=None for that so callers 400 clearly.
42
43
  _MODE_ENDPOINTS: dict[str, str] = {
43
44
  "t2v": VIDEO_T2V_URL,
44
45
  "i2v": VIDEO_I2V_URL,
45
46
  "r2v": VIDEO_R2V_URL,
47
+ "interpolation": VIDEO_INTERPOLATION_URL,
46
48
  "extension": VIDEO_EXTEND_URL,
47
49
  "edit": VIDEO_EDIT_URL,
48
50
  }
@@ -29,6 +29,7 @@ class ManagedProcess:
29
29
  log_file: Optional[Path] = None,
30
30
  on_line: Optional[OnLine] = None,
31
31
  display: Optional[str] = None,
32
+ cwd: Optional[str] = None,
32
33
  ) -> None:
33
34
  self.name = name
34
35
  self.argv = list(argv)
@@ -37,6 +38,7 @@ class ManagedProcess:
37
38
  self._env = {**os.environ, **(env or {})}
38
39
  self._log_file = log_file
39
40
  self._on_line = on_line
41
+ self._cwd = cwd
40
42
 
41
43
  self._proc: Optional[subprocess.Popen] = None
42
44
  self._pump: Optional[threading.Thread] = None
@@ -52,6 +54,7 @@ class ManagedProcess:
52
54
  self._proc = subprocess.Popen(
53
55
  self.argv,
54
56
  env=self._env,
57
+ cwd=self._cwd,
55
58
  stdout=subprocess.PIPE,
56
59
  stderr=subprocess.STDOUT,
57
60
  text=True,
@@ -22,7 +22,7 @@ from typing import Optional
22
22
  from rich.console import Console
23
23
 
24
24
  from . import dashboard, health
25
- from .config import LOGS_DIR, FlowConfig, mask
25
+ from .config import CONFIG_DIR, LOGS_DIR, FlowConfig, mask
26
26
  from .processes import ManagedProcess
27
27
 
28
28
  console = Console()
@@ -92,6 +92,10 @@ class Supervisor:
92
92
  "engine",
93
93
  [sys.executable, "-m", "flow_engine.main"],
94
94
  env=env,
95
+ # Run from a package-free dir: `python -m` prepends cwd to sys.path, so
96
+ # launching from a checkout that contains a `flow_engine/` package would
97
+ # shadow the installed one and silently run stale code.
98
+ cwd=str(CONFIG_DIR),
95
99
  log_file=LOGS_DIR / "engine.log",
96
100
  on_line=self._on_line,
97
101
  display=f"{sys.executable} -m flow_engine.main",
@@ -58,6 +58,7 @@ _DISPATCH = {
58
58
  "t2v": "gen_video_t2v",
59
59
  "r2v": "gen_video_r2v",
60
60
  "i2v": "gen_video",
61
+ "interpolation": "gen_video_interpolation",
61
62
  "extension": "gen_video_extend",
62
63
  "edit": "gen_video_edit",
63
64
  }
@@ -84,8 +85,8 @@ def start_video_job(
84
85
  ) -> Job:
85
86
  """Create a job and kick off dispatch+poll in the background.
86
87
 
87
- ``mode`` ∈ {t2v, i2v, r2v, extension, edit} selects the FlowSDK dispatch
88
- method; ``dispatch_kwargs`` is forwarded to it.
88
+ ``mode`` ∈ {t2v, i2v, r2v, interpolation, extension, edit} selects the
89
+ FlowSDK dispatch method; ``dispatch_kwargs`` is forwarded to it.
89
90
  """
90
91
  job = _new_job("video", job_store)
91
92
  asyncio.create_task(
@@ -13,11 +13,13 @@ restarts or a horizontal pool.
13
13
  from __future__ import annotations
14
14
 
15
15
  import logging
16
+ import os
16
17
  import re
17
18
  import threading
18
19
  from dataclasses import dataclass
19
20
  from pathlib import Path
20
21
  from typing import Any, Optional
22
+ from urllib.parse import urlparse
21
23
 
22
24
  import httpx
23
25
 
@@ -36,6 +38,20 @@ _MEDIA_ID_RE = re.compile(r"^[0-9a-fA-F-]{1,64}$")
36
38
  # `data.media[].image.generatedImage.fifeUrl`.
37
39
  _ALLOWED_URL_PREFIXES: tuple[str, ...] = ("https://flow-content.google/",)
38
40
 
41
+ # Video takes a second route: `media.getMediaUrlRedirect` 302s to whichever
42
+ # Google host currently serves the asset, so we cannot pin one prefix. These
43
+ # URLs come from resolving our OWN redirect against labs.google — not from
44
+ # caller input — so the host check is defence in depth rather than the primary
45
+ # control. Extra hosts can be added via FLOW_MEDIA_ALLOWED_HOSTS (comma list).
46
+ _ALLOWED_HOST_SUFFIXES: tuple[str, ...] = tuple(
47
+ h.strip().lower()
48
+ for h in os.getenv(
49
+ "FLOW_MEDIA_ALLOWED_HOSTS",
50
+ "flow-content.google,googleusercontent.com,googleapis.com,ggpht.com",
51
+ ).split(",")
52
+ if h.strip()
53
+ )
54
+
39
55
  _EXT_BY_MIME = {
40
56
  "image/jpeg": ".jpg",
41
57
  "image/png": ".png",
@@ -60,7 +76,19 @@ _lock = threading.Lock()
60
76
 
61
77
 
62
78
  def _url_allowed(url: Any) -> bool:
63
- return isinstance(url, str) and any(url.startswith(p) for p in _ALLOWED_URL_PREFIXES)
79
+ if not isinstance(url, str) or not url:
80
+ return False
81
+ if any(url.startswith(p) for p in _ALLOWED_URL_PREFIXES):
82
+ return True
83
+ parsed = urlparse(url)
84
+ if parsed.scheme != "https" or not parsed.hostname:
85
+ return False
86
+ host = parsed.hostname.lower()
87
+ # Suffix match on a dot boundary so "evil-googleapis.com" cannot pass.
88
+ return any(
89
+ host == suffix or host.endswith(f".{suffix}")
90
+ for suffix in _ALLOWED_HOST_SUFFIXES
91
+ )
64
92
 
65
93
 
66
94
  def is_valid_media_id(media_id: str) -> bool:
@@ -11,8 +11,13 @@ returns HTTP 400.
11
11
 
12
12
  The generation MODE is inferred from the inputs you provide:
13
13
  - ``ref_media_ids`` / ``ref_images`` → reference-to-video (r2v, "Ingredients")
14
+ - start frames + end frames → interpolation (motion between two frames)
14
15
  - ``start_media_ids`` / ``start_images`` → image-to-video (i2v, "Frames")
15
16
  - neither → text-to-video (t2v, prompt only)
17
+
18
+ End frames come from ``end_media_ids`` / ``end_images``, or set ``loop: true`` to
19
+ reuse the start frame as the end frame — that yields a clip finishing on the frame
20
+ it began with, which tiles seamlessly for any duration.
16
21
  Optional ``reference_audio`` (voice ids from GET /v1/audio/voices) and
17
22
  ``seconds`` (4/6/8 where the family supports it) refine the request.
18
23
  """
@@ -45,6 +50,11 @@ class VideoRequest(BaseModel):
45
50
  start_media_ids: Optional[list[str]] = None # → i2v (Frames), pre-uploaded
46
51
  ref_images: Optional[list[str]] = None # → r2v, raw (uploaded for you)
47
52
  start_images: Optional[list[str]] = None # → i2v, raw (uploaded for you)
53
+ # end frames — presence upgrades i2v to interpolation (motion between two
54
+ # frames). Pass loop=true instead to reuse the start frame as the end frame.
55
+ end_media_ids: Optional[list[str]] = None # → interpolation, pre-uploaded
56
+ end_images: Optional[list[str]] = None # → interpolation, raw
57
+ loop: bool = False # end frame := start frame
48
58
  reference_audio: Optional[list[str]] = None # voice ids (r2v)
49
59
  project: Optional[str] = None
50
60
  # extend / edit — operate on a previously generated Veo video
@@ -186,6 +196,11 @@ async def create_video(
186
196
  ref_ids += await ingest.resolve_image_inputs(sdk, project_id, req.ref_images)
187
197
  start_ids = list(req.start_media_ids or [])
188
198
  start_ids += await ingest.resolve_image_inputs(sdk, project_id, req.start_images)
199
+ end_ids = list(req.end_media_ids or [])
200
+ end_ids += await ingest.resolve_image_inputs(sdk, project_id, req.end_images)
201
+ # `loop` is sugar for "end frame is the start frame" — the seamless-loop case.
202
+ if req.loop and start_ids and not end_ids:
203
+ end_ids = list(start_ids)
189
204
 
190
205
  explicit = (req.mode or "").lower().strip()
191
206
  if explicit in ("extend", "extension"):
@@ -194,11 +209,19 @@ async def create_video(
194
209
  mode = "edit"
195
210
  elif ref_ids:
196
211
  mode = "r2v"
212
+ elif start_ids and end_ids:
213
+ mode = "interpolation"
197
214
  elif start_ids:
198
215
  mode = "i2v"
199
216
  else:
200
217
  mode = "t2v"
201
218
 
219
+ if end_ids and not start_ids:
220
+ raise HTTPException(
221
+ status_code=400,
222
+ detail="end frames require start frames — pass start_images/start_media_ids",
223
+ )
224
+
202
225
  resolved = catalog.resolve_video(req.model, mode, aspect, req.seconds)
203
226
  if resolved.get("error"):
204
227
  raise HTTPException(status_code=400, detail=resolved["error"])
@@ -223,6 +246,16 @@ async def create_video(
223
246
  paygate_tier=tier,
224
247
  model_key=model_key,
225
248
  )
249
+ elif mode == "interpolation":
250
+ kwargs = dict(
251
+ prompt=req.prompt.strip(),
252
+ project_id=project_id,
253
+ start_media_ids=start_ids,
254
+ end_media_ids=end_ids,
255
+ aspect_ratio=aspect,
256
+ paygate_tier=tier,
257
+ model_key=model_key,
258
+ )
226
259
  elif mode == "extension":
227
260
  kwargs = await _extend_kwargs(
228
261
  sdk, req, project_id, tier, aspect, model_key, inst.video_ctx
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "vrex-flow-engine"
3
- version = "0.2.2"
3
+ version = "0.2.4"
4
4
  description = "Launcher + supervisor and OpenAI-compatible image/video generation service (Vrex Flow Engine)."
5
5
  # Neutral public description (the in-repo README.md carries the full internal docs).
6
6
  readme = "PYPI_README.md"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vrex-flow-engine
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Launcher + supervisor and OpenAI-compatible image/video generation service (Vrex Flow Engine).
5
5
  Author: Vrex
6
6
  License: Proprietary
@@ -1,4 +1,5 @@
1
1
  PYPI_README.md
2
+ README.md
2
3
  pyproject.toml
3
4
  flow_engine/__init__.py
4
5
  flow_engine/catalog.py