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.
- {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/PKG-INFO +1 -1
- vrex_flow_engine-0.2.4/README.md +268 -0
- {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/__init__.py +1 -1
- {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/bridge/flow_client.py +15 -0
- {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/bridge/flow_sdk.py +148 -2
- {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/catalog.py +5 -3
- {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/cli/processes.py +3 -0
- {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/cli/supervisor.py +5 -1
- {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/jobs.py +3 -2
- {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/media.py +29 -1
- {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/openai/videos.py +33 -0
- {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/pyproject.toml +1 -1
- {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/vrex_flow_engine.egg-info/PKG-INFO +1 -1
- {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/vrex_flow_engine.egg-info/SOURCES.txt +1 -0
- {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/PYPI_README.md +0 -0
- {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/bridge/__init__.py +0 -0
- {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/bridge/ws_server.py +0 -0
- {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/cli/__init__.py +0 -0
- {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/cli/app.py +0 -0
- {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/cli/config.py +0 -0
- {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/cli/dashboard.py +0 -0
- {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/cli/health.py +0 -0
- {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/config.py +0 -0
- {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/ingest.py +0 -0
- {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/job_store.py +0 -0
- {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/main.py +0 -0
- {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/media_store.py +0 -0
- {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/openai/__init__.py +0 -0
- {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/openai/_util.py +0 -0
- {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/openai/images.py +0 -0
- {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/openai/models.py +0 -0
- {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/openai/uploads.py +0 -0
- {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/pool.py +0 -0
- {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/posthog_client.py +0 -0
- {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/session.py +0 -0
- {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/video_context.py +0 -0
- {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/flow_engine/video_context_store.py +0 -0
- {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/setup.cfg +0 -0
- {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/vrex_flow_engine.egg-info/dependency_links.txt +0 -0
- {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/vrex_flow_engine.egg-info/entry_points.txt +0 -0
- {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/vrex_flow_engine.egg-info/requires.txt +0 -0
- {vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/vrex_flow_engine.egg-info/top_level.txt +0 -0
|
@@ -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
|
+
```
|
|
@@ -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
|
|
1116
|
-
|
|
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).
|
|
40
|
-
#
|
|
41
|
-
#
|
|
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
|
|
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
|
-
|
|
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.
|
|
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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/vrex_flow_engine.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{vrex_flow_engine-0.2.2 → vrex_flow_engine-0.2.4}/vrex_flow_engine.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|