gflow-cli 0.2.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
flow_cli/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """flow-cli — unofficial CLI for Google Flow."""
2
+
3
+ __version__ = "0.2.0a1"
flow_cli/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allows `python -m flow_cli ...` invocation."""
2
+
3
+ from flow_cli.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,18 @@
1
+ """Low-level REST client for Flow's private aisandbox-pa API."""
2
+
3
+ from flow_cli.api.client import FlowApiClient, FlowApiError
4
+ from flow_cli.api.dto import AssetInfo, ProjectInfo, VideoOperation, VideoStatus
5
+ from flow_cli.api.video import Aspect, GenerateVideoRequest, Mode, Tier
6
+
7
+ __all__ = [
8
+ "Aspect",
9
+ "AssetInfo",
10
+ "FlowApiClient",
11
+ "FlowApiError",
12
+ "GenerateVideoRequest",
13
+ "Mode",
14
+ "ProjectInfo",
15
+ "Tier",
16
+ "VideoOperation",
17
+ "VideoStatus",
18
+ ]
flow_cli/api/client.py ADDED
@@ -0,0 +1,246 @@
1
+ """FlowApiClient — typed wrapper around Flow's private REST surface.
2
+
3
+ Architecture: the client manages its own Playwright persistent-context
4
+ lifecycle (async context manager). All HTTP goes through `page.request`
5
+ so Google's session cookies attach automatically — no manual bearer-token
6
+ extraction.
7
+
8
+ The video-generation route requires a fresh reCAPTCHA token per call;
9
+ that piece lives in `flow_cli.api.recaptcha` and `generate_video()` (added
10
+ in a later commit). For now this client implements the four routes that
11
+ DON'T need reCAPTCHA: createProject, uploadImage, checkStatus, download.
12
+
13
+ Usage:
14
+ async with FlowApiClient(profile_dir) as client:
15
+ project = await client.create_project()
16
+ asset = await client.upload_image(project.project_id, Path("hero.png"))
17
+ ...
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import base64
23
+ import json
24
+ import logging
25
+ import secrets
26
+ import time
27
+ import uuid
28
+ from datetime import datetime
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ from playwright.async_api import BrowserContext, Page, Playwright, async_playwright
33
+
34
+ from flow_cli.api import routes
35
+ from flow_cli.api.dto import AssetInfo, ProjectInfo, VideoOperation, VideoStatus
36
+ from flow_cli.api.recaptcha import TokenMinter
37
+ from flow_cli.api.video import GenerateVideoRequest, build_generate_body
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+
42
+ class FlowApiError(RuntimeError):
43
+ """Raised when a Flow API call returns a non-2xx response."""
44
+
45
+ def __init__(self, status: int, body: str, *, route: str):
46
+ self.status = status
47
+ self.body = body
48
+ self.route = route
49
+ super().__init__(f"Flow API {route} -> HTTP {status}: {body[:200]}")
50
+
51
+
52
+ class FlowApiClient:
53
+ """Async context-managed client for Flow's REST surface.
54
+
55
+ Holds a Playwright persistent context and a single page (used as the HTTP
56
+ transport via `page.request`). Auth = whatever cookies the profile dir
57
+ has from a prior `gflow auth login`.
58
+ """
59
+
60
+ def __init__(self, profile_dir: Path, *, headless: bool = True):
61
+ self.profile_dir = profile_dir
62
+ self.headless = headless
63
+ self._pw: Playwright | None = None
64
+ self._context: BrowserContext | None = None
65
+ self._page: Page | None = None
66
+
67
+ # --- lifecycle --------------------------------------------------------
68
+
69
+ async def __aenter__(self) -> FlowApiClient:
70
+ self._pw = await async_playwright().start()
71
+ self._context = await self._pw.chromium.launch_persistent_context(
72
+ user_data_dir=str(self.profile_dir),
73
+ headless=self.headless,
74
+ viewport={"width": 1280, "height": 720},
75
+ locale="en-US",
76
+ extra_http_headers={"Accept-Language": "en-US,en;q=0.9"},
77
+ )
78
+ self._page = (
79
+ self._context.pages[0] if self._context.pages else await self._context.new_page()
80
+ )
81
+ # Bootstrap navigation so cookies + JS context are loaded before any
82
+ # API call. Many endpoints 401 if you POST cold without an active page.
83
+ await self._page.goto(
84
+ routes.EDITOR_BOOTSTRAP_URL, wait_until="domcontentloaded", timeout=60_000
85
+ )
86
+ return self
87
+
88
+ async def __aexit__(self, *exc: object) -> None:
89
+ if self._context:
90
+ try:
91
+ await self._context.close()
92
+ except Exception:
93
+ pass
94
+ if self._pw:
95
+ try:
96
+ await self._pw.stop()
97
+ except Exception:
98
+ pass
99
+
100
+ @property
101
+ def page(self) -> Page:
102
+ if self._page is None:
103
+ raise RuntimeError("FlowApiClient not entered — use `async with`")
104
+ return self._page
105
+
106
+ # --- private HTTP helpers --------------------------------------------
107
+
108
+ async def _post_json(
109
+ self, url: str, body: dict[str, Any], *, content_type: str = "text/plain;charset=UTF-8"
110
+ ) -> Any:
111
+ """POST a JSON body. aisandbox-pa requires text/plain content-type
112
+ (not application/json) — see samples/captured/*.json. The tRPC
113
+ host on labs.google accepts standard application/json."""
114
+ body_str = json.dumps(body)
115
+ logger.debug("POST %s body=%s", url, body_str[:300])
116
+ resp = await self.page.request.post(
117
+ url,
118
+ data=body_str,
119
+ headers={"content-type": content_type},
120
+ )
121
+ text = await resp.text()
122
+ if resp.status >= 400:
123
+ raise FlowApiError(resp.status, text, route=url)
124
+ try:
125
+ return json.loads(text)
126
+ except json.JSONDecodeError as e:
127
+ raise FlowApiError(resp.status, f"non-JSON response: {text[:200]}", route=url) from e
128
+
129
+ async def _patch_json(self, url: str, body: dict[str, Any]) -> Any:
130
+ body_str = json.dumps(body)
131
+ logger.debug("PATCH %s body=%s", url, body_str[:300])
132
+ resp = await self.page.request.patch(
133
+ url,
134
+ data=body_str,
135
+ headers={"content-type": "text/plain;charset=UTF-8"},
136
+ )
137
+ text = await resp.text()
138
+ if resp.status >= 400:
139
+ raise FlowApiError(resp.status, text, route=url)
140
+ try:
141
+ return json.loads(text) if text else {}
142
+ except json.JSONDecodeError:
143
+ return {}
144
+
145
+ # --- public API -------------------------------------------------------
146
+
147
+ async def create_project(self, title: str | None = None) -> ProjectInfo:
148
+ """Bootstrap a fresh Flow project. Title defaults to a timestamp.
149
+
150
+ Maps to `POST .../trpc/project.createProject`.
151
+ """
152
+ title = title or _default_project_title()
153
+ body = {"json": {"projectTitle": title, "toolName": "PINHOLE"}}
154
+ data = await self._post_json(routes.CREATE_PROJECT, body, content_type="application/json")
155
+ return ProjectInfo.from_create_response(data)
156
+
157
+ async def upload_image(self, project_id: str, image_path: Path) -> AssetInfo:
158
+ """Upload an image into a Flow project's library.
159
+
160
+ Maps to `POST /v1/flow/uploadImage`. Image bytes go in base64.
161
+ Returns the asset UUID + dimensions Flow inferred.
162
+ """
163
+ if not image_path.exists():
164
+ raise FileNotFoundError(image_path)
165
+ b64 = base64.b64encode(image_path.read_bytes()).decode()
166
+ body = {
167
+ "clientContext": {"projectId": project_id, "tool": "PINHOLE"},
168
+ "imageBytes": b64,
169
+ }
170
+ data = await self._post_json(routes.UPLOAD_IMAGE, body)
171
+ return AssetInfo.from_upload_response(data)
172
+
173
+ async def get_video_status(self, project_id: str, media_names: list[str]) -> list[VideoStatus]:
174
+ """Poll the status of one or more in-flight video generations.
175
+
176
+ Maps to `POST /v1/video:batchCheckAsyncVideoGenerationStatus`.
177
+ """
178
+ body = {"media": [{"name": n, "projectId": project_id} for n in media_names]}
179
+ data = await self._post_json(routes.CHECK_VIDEO_STATUS, body)
180
+ return [VideoStatus.from_check_status_item(it) for it in data.get("media", [])]
181
+
182
+ async def download(self, name_or_url: str, out_path: Path) -> Path:
183
+ """Download an asset (image or video) to `out_path`. Returns out_path."""
184
+ url = (
185
+ name_or_url
186
+ if name_or_url.startswith("http")
187
+ else routes.media_download_url(name_or_url)
188
+ )
189
+ out_path.parent.mkdir(parents=True, exist_ok=True)
190
+ resp = await self.page.request.get(url, max_redirects=5, timeout=120_000)
191
+ if resp.status >= 400:
192
+ raise FlowApiError(resp.status, await resp.text(), route=url)
193
+ out_path.write_bytes(await resp.body())
194
+ return out_path
195
+
196
+ async def archive_workflow(self, workflow_id: str, project_id: str) -> None:
197
+ """Soft-delete (archive) a workflow — used by clear-library tooling.
198
+
199
+ Maps to `PATCH /v1/flowWorkflows/{id}` with `metadata.archived=true`.
200
+ """
201
+ url = f"{routes.ARCHIVE_WORKFLOW_BASE}/{workflow_id}"
202
+ body = {
203
+ "workflow": {
204
+ "name": workflow_id,
205
+ "projectId": project_id,
206
+ "metadata": {"archived": True},
207
+ },
208
+ "updateMask": "metadata.archived",
209
+ }
210
+ await self._patch_json(url, body)
211
+
212
+ async def generate_video(
213
+ self,
214
+ *,
215
+ project_id: str,
216
+ req: GenerateVideoRequest,
217
+ seed: int | None = None,
218
+ recaptcha_action: str = "videoGeneration",
219
+ batch_id: str | None = None,
220
+ ) -> VideoOperation:
221
+ """Enqueue a Veo video generation. Returns the operation reference.
222
+
223
+ Mints a fresh reCAPTCHA token via the live page session before
224
+ submitting. Caller polls completion via `get_video_status`.
225
+ """
226
+ minter = TokenMinter(self.page)
227
+ token = await minter.mint(recaptcha_action)
228
+ body = build_generate_body(
229
+ req,
230
+ project_id=project_id,
231
+ recaptcha_token=token,
232
+ batch_id=batch_id or _new_batch_id(),
233
+ seed=seed if seed is not None else secrets.randbelow(2**31),
234
+ session_id=f";{int(time.time() * 1000)}",
235
+ )
236
+ data = await self._post_json(routes.GENERATE_VIDEO, body)
237
+ return VideoOperation.from_generate_response(data)
238
+
239
+
240
+ def _default_project_title() -> str:
241
+ return datetime.now().strftime("flow-cli %b %d, %I:%M %p")
242
+
243
+
244
+ def _new_batch_id() -> str:
245
+ """Generate a fresh batch ID for the mediaGenerationContext."""
246
+ return str(uuid.uuid4())
flow_cli/api/dto.py ADDED
@@ -0,0 +1,137 @@
1
+ """Typed DTOs for Flow API requests/responses.
2
+
3
+ All frozen dataclasses — once constructed, instances are immutable and
4
+ hashable. Parsers (`*.from_response`) defensively read JSON dicts and
5
+ raise `ValueError` on missing/malformed fields rather than letting
6
+ KeyErrors leak.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from typing import Any
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class ProjectInfo:
17
+ """A Flow project — owns assets, jobs, library entries."""
18
+
19
+ project_id: str
20
+ title: str
21
+
22
+ @classmethod
23
+ def from_create_response(cls, data: dict[str, Any]) -> ProjectInfo:
24
+ """Parse `POST .../project.createProject` JSON.
25
+
26
+ Wire shape:
27
+ {result: {data: {json: {result: {projectId, projectInfo: {projectTitle}}}}}}
28
+ """
29
+ try:
30
+ inner = data["result"]["data"]["json"]["result"]
31
+ return cls(
32
+ project_id=inner["projectId"],
33
+ title=inner["projectInfo"]["projectTitle"],
34
+ )
35
+ except (KeyError, TypeError) as e:
36
+ raise ValueError(f"unexpected createProject response shape: {e}") from e
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class AssetInfo:
41
+ """A media asset (image or video) registered in a Flow project."""
42
+
43
+ name: str # asset UUID
44
+ project_id: str
45
+ workflow_id: str
46
+ display_name: str
47
+ width: int
48
+ height: int
49
+
50
+ @classmethod
51
+ def from_upload_response(cls, data: dict[str, Any]) -> AssetInfo:
52
+ """Parse `POST /v1/flow/uploadImage` JSON.
53
+
54
+ Wire shape:
55
+ {media: {name, projectId, workflowId, image: {dimensions: {width, height}}, ...},
56
+ workflow: {metadata: {displayName}, ...}}
57
+ """
58
+ try:
59
+ media = data["media"]
60
+ dims = media.get("image", {}).get("dimensions", {})
61
+ return cls(
62
+ name=media["name"],
63
+ project_id=media["projectId"],
64
+ workflow_id=media["workflowId"],
65
+ display_name=data.get("workflow", {}).get("metadata", {}).get("displayName", ""),
66
+ width=int(dims.get("width", 0)),
67
+ height=int(dims.get("height", 0)),
68
+ )
69
+ except (KeyError, TypeError) as e:
70
+ raise ValueError(f"unexpected uploadImage response shape: {e}") from e
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class VideoStatus:
75
+ """Snapshot of one in-flight video generation."""
76
+
77
+ media_name: str # asset UUID, used to track the job
78
+ project_id: str
79
+ status: str # MEDIA_GENERATION_STATUS_PENDING|RUNNING|COMPLETED|FAILED
80
+ operation_name: str | None # set once generation has started
81
+ workflow_id: str | None
82
+
83
+ @property
84
+ def is_terminal(self) -> bool:
85
+ return self.status in (
86
+ "MEDIA_GENERATION_STATUS_COMPLETED",
87
+ "MEDIA_GENERATION_STATUS_FAILED",
88
+ )
89
+
90
+ @property
91
+ def succeeded(self) -> bool:
92
+ return self.status == "MEDIA_GENERATION_STATUS_COMPLETED"
93
+
94
+ @classmethod
95
+ def from_check_status_item(cls, item: dict[str, Any]) -> VideoStatus:
96
+ """Parse one element of the `media[]` array in the check-status response."""
97
+ try:
98
+ return cls(
99
+ media_name=item["name"],
100
+ project_id=item["projectId"],
101
+ status=item["mediaMetadata"]["mediaStatus"]["mediaGenerationStatus"],
102
+ operation_name=item.get("video", {}).get("operation", {}).get("name"),
103
+ workflow_id=item.get("workflowId"),
104
+ )
105
+ except (KeyError, TypeError) as e:
106
+ raise ValueError(f"unexpected check-status item shape: {e}") from e
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class VideoOperation:
111
+ """Reference returned when a generation request is enqueued."""
112
+
113
+ media_name: str # asset UUID — pass to get_video_status() to poll
114
+ project_id: str
115
+ operation_name: str
116
+ workflow_id: str
117
+
118
+ @classmethod
119
+ def from_generate_response(cls, data: dict[str, Any]) -> VideoOperation:
120
+ """Parse `POST /v1/video:batchAsyncGenerateVideoText` JSON.
121
+
122
+ Wire shape (one operation):
123
+ {operations: [{operation: {name}, ...}],
124
+ media: [{name, projectId, workflowId, ...}],
125
+ workflows: [{name, projectId, ...}]}
126
+ """
127
+ try:
128
+ op = data["operations"][0]["operation"]["name"]
129
+ media = data["media"][0]
130
+ return cls(
131
+ media_name=media["name"],
132
+ project_id=media["projectId"],
133
+ operation_name=op,
134
+ workflow_id=media["workflowId"],
135
+ )
136
+ except (KeyError, IndexError, TypeError) as e:
137
+ raise ValueError(f"unexpected generateVideo response shape: {e}") from e
@@ -0,0 +1,107 @@
1
+ """reCAPTCHA Enterprise token minting via Playwright page.evaluate.
2
+
3
+ The site key is discovered from the page source (loaded by the persistent
4
+ context's bootstrap navigation in `FlowApiClient.__aenter__`). Tokens are
5
+ single-use, ~2 min expiry — minted per `generate_video()` call.
6
+
7
+ `TokenMinter` caches the discovered site key for the lifetime of one
8
+ FlowApiClient session.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any, Protocol
14
+
15
+
16
+ class _PageLike(Protocol):
17
+ """Minimal subset of playwright.async_api.Page we need.
18
+
19
+ Defined as a Protocol so tests can pass mocks without importing Playwright.
20
+ """
21
+
22
+ async def evaluate(self, expression: str, arg: Any = None) -> Any: ...
23
+
24
+
25
+ class RecaptchaError(RuntimeError):
26
+ """Raised when reCAPTCHA token minting fails (script missing,
27
+ Google refused to mint, etc.)."""
28
+
29
+
30
+ _DISCOVER_SITE_KEY_JS = """
31
+ () => {
32
+ const scripts = document.querySelectorAll('script[src*="recaptcha/enterprise.js"]');
33
+ for (const s of scripts) {
34
+ const m = (s.getAttribute('src') || '').match(/[?&]render=([^&]+)/);
35
+ if (m) return m[1];
36
+ }
37
+ return null;
38
+ }
39
+ """
40
+
41
+ _EXECUTE_JS = """
42
+ async ([siteKey, action]) => {
43
+ return await new Promise((resolve, reject) => {
44
+ if (typeof grecaptcha === 'undefined' || !grecaptcha.enterprise) {
45
+ return reject(new Error('grecaptcha.enterprise not loaded'));
46
+ }
47
+ grecaptcha.enterprise.ready(() => {
48
+ grecaptcha.enterprise
49
+ .execute(siteKey, { action })
50
+ .then(resolve)
51
+ .catch(reject);
52
+ });
53
+ });
54
+ }
55
+ """
56
+
57
+
58
+ async def discover_site_key(page: _PageLike) -> str:
59
+ """Read the reCAPTCHA Enterprise site key from the loaded page.
60
+
61
+ Raises `RecaptchaError` if the recaptcha/enterprise.js script tag is
62
+ missing or doesn't carry a `render=<key>` query param.
63
+ """
64
+ key = await page.evaluate(_DISCOVER_SITE_KEY_JS)
65
+ if not isinstance(key, str) or not key:
66
+ raise RecaptchaError(
67
+ "Could not discover reCAPTCHA site key from the page. "
68
+ "The Flow editor page may have failed to load, or the reCAPTCHA "
69
+ "script tag layout has changed."
70
+ )
71
+ return key
72
+
73
+
74
+ class TokenMinter:
75
+ """Mint reCAPTCHA tokens. Caches the site key for the session."""
76
+
77
+ def __init__(self, page: _PageLike):
78
+ self._page = page
79
+ self._site_key: str | None = None
80
+
81
+ async def site_key(self) -> str:
82
+ if self._site_key is None:
83
+ self._site_key = await discover_site_key(self._page)
84
+ return self._site_key
85
+
86
+ async def mint(self, action: str) -> str:
87
+ """Mint a fresh reCAPTCHA Enterprise token for the given action.
88
+
89
+ Tokens are single-use and expire in ~2 minutes — call this immediately
90
+ before the API request that consumes the token.
91
+ """
92
+ site_key = await self.site_key()
93
+ try:
94
+ token = await self._page.evaluate(_EXECUTE_JS, [site_key, action])
95
+ except Exception as exc:
96
+ raise RecaptchaError(
97
+ f"reCAPTCHA evaluate failed for action={action!r}: {exc}. "
98
+ "Likely causes: grecaptcha not loaded, page navigated away, "
99
+ "or Playwright timeout. Try FLOW_CLI_HEADLESS=false."
100
+ ) from exc
101
+ if not isinstance(token, str) or not token:
102
+ raise RecaptchaError(
103
+ f"reCAPTCHA returned an empty token for action={action!r}. "
104
+ "Likely causes: headless detection by Google, or the page "
105
+ "navigated away before mint. Try FLOW_CLI_HEADLESS=false."
106
+ )
107
+ return token
flow_cli/api/routes.py ADDED
@@ -0,0 +1,37 @@
1
+ """URL constants for every Flow REST route flow-cli touches.
2
+
3
+ Two hosts are involved:
4
+ * aisandbox-pa.googleapis.com — the actual Flow API surface
5
+ * labs.google/fx/api/trpc — tRPC routes for project lifecycle
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ FLOW_API_BASE = "https://aisandbox-pa.googleapis.com/v1"
11
+ LABS_TRPC_BASE = "https://labs.google/fx/api/trpc"
12
+ LABS_BASE = "https://labs.google"
13
+
14
+ # Asset / media
15
+ UPLOAD_IMAGE = f"{FLOW_API_BASE}/flow/uploadImage"
16
+
17
+ # Video generation (reCAPTCHA-required for GENERATE_VIDEO)
18
+ GENERATE_VIDEO = f"{FLOW_API_BASE}/video:batchAsyncGenerateVideoText"
19
+ CHECK_VIDEO_STATUS = f"{FLOW_API_BASE}/video:batchCheckAsyncVideoGenerationStatus"
20
+
21
+ # Workflow management
22
+ ARCHIVE_WORKFLOW_BASE = f"{FLOW_API_BASE}/flowWorkflows" # + /{workflow_id}
23
+
24
+ # Project lifecycle (tRPC, different host)
25
+ CREATE_PROJECT = f"{LABS_TRPC_BASE}/project.createProject"
26
+
27
+
28
+ # Media download — public-style URL that 302s to a signed Cloud Storage URL.
29
+ # Cookies still required.
30
+ def media_download_url(name: str) -> str:
31
+ """Build the redirect URL for an asset name (UUID)."""
32
+ return f"{LABS_BASE}/fx/api/trpc/media.getMediaUrlRedirect?name={name}"
33
+
34
+
35
+ # Bootstrap URL — the Flow editor page. The persistent context navigates here
36
+ # once before making API calls so Google's cookies + reCAPTCHA JS are loaded.
37
+ EDITOR_BOOTSTRAP_URL = "https://labs.google/fx/tools/flow?hl=en"
flow_cli/api/video.py ADDED
@@ -0,0 +1,112 @@
1
+ """Pure value objects and body builders for video generation.
2
+
3
+ No I/O lives in this module — `FlowApiClient.generate_video()` calls
4
+ `build_generate_body()` and POSTs the result.
5
+
6
+ `model_key()` encodes Flow's wire format for the `videoModelKey` field
7
+ (e.g. `veo_3_1_t2v_fast_portrait`) — discovered from sample
8
+ `samples/captured/02_batchAsyncGenerateVideoText.json`.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+ from enum import StrEnum
15
+ from typing import Any
16
+
17
+
18
+ class Mode(StrEnum):
19
+ T2V = "t2v"
20
+ I2V = "i2v"
21
+
22
+
23
+ class Tier(StrEnum):
24
+ FAST = "fast"
25
+ QUALITY = "quality"
26
+
27
+
28
+ class Aspect(StrEnum):
29
+ PORTRAIT = "portrait"
30
+ LANDSCAPE = "landscape"
31
+ SQUARE = "square"
32
+
33
+ def wire(self) -> str:
34
+ return f"VIDEO_ASPECT_RATIO_{self.value.upper()}"
35
+
36
+ @classmethod
37
+ def from_cli(cls, value: str) -> Aspect:
38
+ mapping = {"9:16": cls.PORTRAIT, "16:9": cls.LANDSCAPE, "1:1": cls.SQUARE}
39
+ if value not in mapping:
40
+ raise ValueError(f"Unsupported aspect ratio {value!r}; choose from {sorted(mapping)}")
41
+ return mapping[value]
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class GenerateVideoRequest:
46
+ """Inputs for ONE video generation. T2V if start_asset_uuid is None, else I2V."""
47
+
48
+ prompt: str
49
+ aspect: Aspect = Aspect.PORTRAIT
50
+ tier: Tier = Tier.FAST
51
+ start_asset_uuid: str | None = None
52
+
53
+ @property
54
+ def mode(self) -> Mode:
55
+ return Mode.I2V if self.start_asset_uuid else Mode.T2V
56
+
57
+
58
+ # Wire-format constants discovered from samples/captured/02_batchAsyncGenerateVideoText.json
59
+ _AUDIO_FAILURE_PREF = "BLOCK_SILENCED_VIDEOS"
60
+ _CLIENT_TOOL = "PINHOLE"
61
+ _PAYGATE_TIER = "PAYGATE_TIER_ONE"
62
+ _RECAPTCHA_APP_TYPE = "RECAPTCHA_APPLICATION_TYPE_WEB"
63
+
64
+
65
+ def model_key(mode: Mode, tier: Tier, aspect: Aspect) -> str:
66
+ """Compose Flow's `videoModelKey` wire string."""
67
+ return f"veo_3_1_{mode.value}_{tier.value}_{aspect.value}"
68
+
69
+
70
+ def build_generate_body(
71
+ req: GenerateVideoRequest,
72
+ *,
73
+ project_id: str,
74
+ recaptcha_token: str,
75
+ batch_id: str,
76
+ seed: int,
77
+ session_id: str,
78
+ ) -> dict[str, Any]:
79
+ """Build the JSON body for `POST /v1/video:batchAsyncGenerateVideoText`.
80
+
81
+ Shape mirrors `samples/captured/02_batchAsyncGenerateVideoText.json` —
82
+ every field there is required by the server.
83
+ """
84
+ image_input: dict[str, Any] = (
85
+ {"imageInput": {"mediaId": req.start_asset_uuid}} if req.start_asset_uuid else {}
86
+ )
87
+ request: dict[str, Any] = {
88
+ "aspectRatio": req.aspect.wire(),
89
+ "textInput": {"structuredPrompt": {"parts": [{"text": req.prompt}]}},
90
+ "videoModelKey": model_key(req.mode, req.tier, req.aspect),
91
+ "metadata": {},
92
+ "seed": seed,
93
+ **image_input,
94
+ }
95
+ return {
96
+ "mediaGenerationContext": {
97
+ "batchId": batch_id,
98
+ "audioFailurePreference": _AUDIO_FAILURE_PREF,
99
+ },
100
+ "clientContext": {
101
+ "projectId": project_id,
102
+ "tool": _CLIENT_TOOL,
103
+ "userPaygateTier": _PAYGATE_TIER,
104
+ "sessionId": session_id,
105
+ "recaptchaContext": {
106
+ "token": recaptcha_token,
107
+ "applicationType": _RECAPTCHA_APP_TYPE,
108
+ },
109
+ },
110
+ "requests": [request],
111
+ "useV2ModelConfig": True,
112
+ }