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 +3 -0
- flow_cli/__main__.py +6 -0
- flow_cli/api/__init__.py +18 -0
- flow_cli/api/client.py +246 -0
- flow_cli/api/dto.py +137 -0
- flow_cli/api/recaptcha.py +107 -0
- flow_cli/api/routes.py +37 -0
- flow_cli/api/video.py +112 -0
- flow_cli/auth.py +87 -0
- flow_cli/cli.py +184 -0
- flow_cli/cli_video.py +322 -0
- flow_cli/config.py +116 -0
- flow_cli/manifest.py +58 -0
- flow_cli/paths.py +82 -0
- flow_cli/profile_store.py +226 -0
- gflow_cli-0.2.0a1.dist-info/METADATA +404 -0
- gflow_cli-0.2.0a1.dist-info/RECORD +20 -0
- gflow_cli-0.2.0a1.dist-info/WHEEL +4 -0
- gflow_cli-0.2.0a1.dist-info/entry_points.txt +3 -0
- gflow_cli-0.2.0a1.dist-info/licenses/LICENSE +21 -0
flow_cli/__init__.py
ADDED
flow_cli/__main__.py
ADDED
flow_cli/api/__init__.py
ADDED
|
@@ -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
|
+
}
|