kiwimatecoder 0.1.0__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.
- kiwimatecoder/__init__.py +8 -0
- kiwimatecoder/__main__.py +12 -0
- kiwimatecoder/acp/__init__.py +13 -0
- kiwimatecoder/acp/protocol.py +115 -0
- kiwimatecoder/acp/server.py +823 -0
- kiwimatecoder/acp/stdio.py +32 -0
- kiwimatecoder/agent.py +1162 -0
- kiwimatecoder/ai.py +68 -0
- kiwimatecoder/audit.py +65 -0
- kiwimatecoder/browser.py +261 -0
- kiwimatecoder/catalog.py +445 -0
- kiwimatecoder/checkpoints.py +103 -0
- kiwimatecoder/client.py +820 -0
- kiwimatecoder/clipboard.py +99 -0
- kiwimatecoder/commands.py +6757 -0
- kiwimatecoder/config.py +5335 -0
- kiwimatecoder/diagnostics.py +295 -0
- kiwimatecoder/documents.py +299 -0
- kiwimatecoder/evals/__init__.py +39 -0
- kiwimatecoder/evals/cases.py +230 -0
- kiwimatecoder/evals/render.py +46 -0
- kiwimatecoder/evals/runner.py +319 -0
- kiwimatecoder/events.py +95 -0
- kiwimatecoder/forge.py +172 -0
- kiwimatecoder/git.py +164 -0
- kiwimatecoder/headless.py +255 -0
- kiwimatecoder/hooks.py +170 -0
- kiwimatecoder/hunks.py +260 -0
- kiwimatecoder/i18n.py +368 -0
- kiwimatecoder/images.py +195 -0
- kiwimatecoder/index/__init__.py +14 -0
- kiwimatecoder/index/builder.py +376 -0
- kiwimatecoder/index/embeddings.py +151 -0
- kiwimatecoder/index/search.py +247 -0
- kiwimatecoder/index/store.py +251 -0
- kiwimatecoder/instructions.py +83 -0
- kiwimatecoder/jobs.py +594 -0
- kiwimatecoder/lsp/__init__.py +56 -0
- kiwimatecoder/lsp/client.py +548 -0
- kiwimatecoder/lsp/manager.py +353 -0
- kiwimatecoder/lsp/protocol.py +79 -0
- kiwimatecoder/main.py +4258 -0
- kiwimatecoder/mcp/__init__.py +45 -0
- kiwimatecoder/mcp/client.py +385 -0
- kiwimatecoder/mcp/errors.py +7 -0
- kiwimatecoder/mcp/manager.py +253 -0
- kiwimatecoder/mcp/protocol.py +132 -0
- kiwimatecoder/media.py +904 -0
- kiwimatecoder/memory.py +141 -0
- kiwimatecoder/network.py +112 -0
- kiwimatecoder/notify.py +139 -0
- kiwimatecoder/permissions.py +171 -0
- kiwimatecoder/pickers.py +417 -0
- kiwimatecoder/plugins.py +245 -0
- kiwimatecoder/pricing.py +167 -0
- kiwimatecoder/prompts.py +213 -0
- kiwimatecoder/providers.py +342 -0
- kiwimatecoder/redaction.py +66 -0
- kiwimatecoder/remote.py +294 -0
- kiwimatecoder/repl.py +1388 -0
- kiwimatecoder/routing.py +65 -0
- kiwimatecoder/sandbox.py +169 -0
- kiwimatecoder/sdk.py +146 -0
- kiwimatecoder/session.py +574 -0
- kiwimatecoder/share.py +386 -0
- kiwimatecoder/shell.py +660 -0
- kiwimatecoder/skills.py +138 -0
- kiwimatecoder/sync.py +513 -0
- kiwimatecoder/team.py +249 -0
- kiwimatecoder/telemetry.py +392 -0
- kiwimatecoder/templates.py +108 -0
- kiwimatecoder/tools/__init__.py +190 -0
- kiwimatecoder/tools/ask.py +62 -0
- kiwimatecoder/tools/base.py +69 -0
- kiwimatecoder/tools/browser.py +261 -0
- kiwimatecoder/tools/edit_file.py +143 -0
- kiwimatecoder/tools/forge.py +207 -0
- kiwimatecoder/tools/git.py +158 -0
- kiwimatecoder/tools/image.py +300 -0
- kiwimatecoder/tools/io.py +346 -0
- kiwimatecoder/tools/list_dir.py +67 -0
- kiwimatecoder/tools/lsp.py +254 -0
- kiwimatecoder/tools/memory.py +115 -0
- kiwimatecoder/tools/paths.py +234 -0
- kiwimatecoder/tools/read_file.py +113 -0
- kiwimatecoder/tools/registry.py +91 -0
- kiwimatecoder/tools/run_bash.py +189 -0
- kiwimatecoder/tools/search.py +224 -0
- kiwimatecoder/tools/selection.py +108 -0
- kiwimatecoder/tools/shell.py +158 -0
- kiwimatecoder/tools/skill.py +46 -0
- kiwimatecoder/tools/task.py +61 -0
- kiwimatecoder/tools/todo.py +77 -0
- kiwimatecoder/tools/web.py +146 -0
- kiwimatecoder/tools/write_file.py +83 -0
- kiwimatecoder/ui.py +189 -0
- kiwimatecoder/updater.py +502 -0
- kiwimatecoder/web.py +499 -0
- kiwimatecoder-0.1.0.dist-info/METADATA +2016 -0
- kiwimatecoder-0.1.0.dist-info/RECORD +104 -0
- kiwimatecoder-0.1.0.dist-info/WHEEL +5 -0
- kiwimatecoder-0.1.0.dist-info/entry_points.txt +2 -0
- kiwimatecoder-0.1.0.dist-info/licenses/LICENSE +21 -0
- kiwimatecoder-0.1.0.dist-info/top_level.txt +1 -0
kiwimatecoder/media.py
ADDED
|
@@ -0,0 +1,904 @@
|
|
|
1
|
+
"""Image and video generation through provider media APIs.
|
|
2
|
+
|
|
3
|
+
Generation is opt-in (``media.enabled``) and approval-gated when driven by the
|
|
4
|
+
``generate_image`` / ``generate_video`` tools, because a call costs money.
|
|
5
|
+
Requests reuse the model provider registry for auth
|
|
6
|
+
(``key_header``/``key_prefix``/``extra_headers``), API versioning
|
|
7
|
+
(``api_version``), and network transport (proxy/CA/offline) so media endpoints
|
|
8
|
+
behave exactly like chat endpoints.
|
|
9
|
+
|
|
10
|
+
Images
|
|
11
|
+
``POST <base>/images/generations`` (OpenAI-compatible) or ``POST
|
|
12
|
+
<base>/images`` (OpenRouter). Responses carry either ``data[0].b64_json`` or
|
|
13
|
+
a ``data[0].url`` that is downloaded with a bounded size. The decoded image
|
|
14
|
+
is written atomically under the workspace at
|
|
15
|
+
``<output_dir>/<YYYYmmdd_HHMMSS>-<slug>.png``; callers attach the result to
|
|
16
|
+
``session.pending_images`` best-effort via :func:`attach_result`.
|
|
17
|
+
|
|
18
|
+
Video
|
|
19
|
+
Video generation is an asynchronous job: ``POST <base>/videos`` returns a
|
|
20
|
+
job id, ``GET <base>/videos/<id>`` is polled until it completes, and the
|
|
21
|
+
finished file is streamed to ``<output_dir>/<stamp>-<slug>.mp4`` with a
|
|
22
|
+
bounded size. A job that is still running when we stop waiting (timeout or
|
|
23
|
+
Ctrl+C) keeps running -- and billing -- on the provider, so its id is
|
|
24
|
+
surfaced and :func:`resume_video` can pick it up again.
|
|
25
|
+
|
|
26
|
+
Two wire dialects are spoken, chosen from the provider's host: OpenRouter's
|
|
27
|
+
(``duration``/``resolution``/``frame_images``, ``unsigned_urls`` on
|
|
28
|
+
completion) and the OpenAI Videos API's (``seconds``/``size``, multipart
|
|
29
|
+
form, ``/videos/<id>/content``).
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import base64
|
|
35
|
+
import datetime
|
|
36
|
+
import json
|
|
37
|
+
import os
|
|
38
|
+
import re
|
|
39
|
+
import time
|
|
40
|
+
from collections.abc import Callable
|
|
41
|
+
from dataclasses import dataclass
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
from typing import TYPE_CHECKING, Any
|
|
44
|
+
from urllib.parse import urlparse
|
|
45
|
+
|
|
46
|
+
import httpx
|
|
47
|
+
|
|
48
|
+
from kiwimatecoder import config, images, network
|
|
49
|
+
from kiwimatecoder.redaction import redact
|
|
50
|
+
from kiwimatecoder.web import is_local_address
|
|
51
|
+
|
|
52
|
+
if TYPE_CHECKING:
|
|
53
|
+
from kiwimatecoder.providers import ProviderConfig
|
|
54
|
+
from kiwimatecoder.session import Session
|
|
55
|
+
|
|
56
|
+
REQUEST_TIMEOUT = 120.0
|
|
57
|
+
MAX_IMAGE_BYTES = 20 * 1024 * 1024
|
|
58
|
+
MAX_VIDEO_BYTES = 500 * 1024 * 1024
|
|
59
|
+
VIDEO_POLL_INTERVAL = 10.0
|
|
60
|
+
VIDEO_MAX_WAIT = 15 * 60.0
|
|
61
|
+
# Consecutive failed status polls tolerated before giving up on a job.
|
|
62
|
+
_VIDEO_POLL_RETRIES = 3
|
|
63
|
+
_SLUG_MAX = 40
|
|
64
|
+
_JOB_ID_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,128}$")
|
|
65
|
+
_VIDEO_DONE = {"completed", "succeeded"}
|
|
66
|
+
_VIDEO_FAILED = {"failed", "cancelled", "canceled", "expired", "error"}
|
|
67
|
+
IMAGE_SUFFIXES = (".png", ".jpg", ".jpeg", ".webp")
|
|
68
|
+
VIDEO_SUFFIXES = (".mp4", ".webm", ".mov")
|
|
69
|
+
_DISABLED_MESSAGE = (
|
|
70
|
+
"Media generation is disabled. Enable it with `/config media enable on` "
|
|
71
|
+
"(or `kiwimatecoder config media enable on`)."
|
|
72
|
+
)
|
|
73
|
+
NO_IMAGE_MODEL_MESSAGE = (
|
|
74
|
+
"No image model chosen. Pick one with `/image model` "
|
|
75
|
+
"(or `kiwimatecoder config media model <id>`)."
|
|
76
|
+
)
|
|
77
|
+
NO_VIDEO_MODEL_MESSAGE = (
|
|
78
|
+
"No video model chosen. Pick one with `/video model` "
|
|
79
|
+
"(or `kiwimatecoder config media video-model <id>`)."
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class MediaError(RuntimeError):
|
|
84
|
+
"""Raised when media cannot be generated; the message is user-facing."""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class VideoJobError(MediaError):
|
|
88
|
+
"""A video job exists on the provider but we have no file for it yet.
|
|
89
|
+
|
|
90
|
+
Raised when we stopped waiting (timeout, lost connection) or could not fetch
|
|
91
|
+
the finished file. Carries ``job_id`` so the caller can offer
|
|
92
|
+
:func:`resume_video`.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __init__(self, message: str, job_id: str) -> None:
|
|
96
|
+
super().__init__(message)
|
|
97
|
+
self.job_id = job_id
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class MediaResult:
|
|
102
|
+
"""One generated image written to disk."""
|
|
103
|
+
|
|
104
|
+
path: Path
|
|
105
|
+
revised_prompt: str | None
|
|
106
|
+
model: str
|
|
107
|
+
bytes: int
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class VideoResult:
|
|
112
|
+
"""One generated video written to disk."""
|
|
113
|
+
|
|
114
|
+
path: Path
|
|
115
|
+
job_id: str
|
|
116
|
+
model: str
|
|
117
|
+
bytes: int
|
|
118
|
+
cost: float | None = None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True)
|
|
122
|
+
class VideoProgress:
|
|
123
|
+
"""A progress tick passed to the ``progress`` callback while polling."""
|
|
124
|
+
|
|
125
|
+
job_id: str
|
|
126
|
+
status: str
|
|
127
|
+
elapsed: float
|
|
128
|
+
percent: int | None = None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
ProgressCallback = Callable[[VideoProgress], None]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def format_bytes(count: int) -> str:
|
|
135
|
+
"""Human-readable size, e.g. ``1.4 MB``."""
|
|
136
|
+
size = float(count)
|
|
137
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
138
|
+
if size < 1024 or unit == "GB":
|
|
139
|
+
return f"{int(size)} {unit}" if unit == "B" else f"{size:.1f} {unit}"
|
|
140
|
+
size /= 1024
|
|
141
|
+
return f"{count} B" # pragma: no cover - loop always returns
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def format_models(settings: dict[str, Any]) -> list[str]:
|
|
145
|
+
"""One plain-text line per provider that has a chosen image or video model."""
|
|
146
|
+
lines: list[str] = []
|
|
147
|
+
for provider_id, kinds in sorted(settings.get("models", {}).items()):
|
|
148
|
+
image = kinds.get("image") or "none chosen"
|
|
149
|
+
video = kinds.get("video") or "none chosen"
|
|
150
|
+
lines.append(f"{provider_id}: image {image}, video {video}")
|
|
151
|
+
return lines
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _client(timeout: float) -> httpx.Client:
|
|
155
|
+
"""Build an httpx client honoring proxy/CA settings (tests patch this)."""
|
|
156
|
+
return httpx.Client(timeout=timeout, **network.current_options())
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _sleep(seconds: float) -> None:
|
|
160
|
+
"""Indirection so tests can poll without waiting."""
|
|
161
|
+
time.sleep(seconds)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _host(url: str) -> str:
|
|
165
|
+
return (urlparse(url).hostname or "").lower()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _is_openrouter(provider: ProviderConfig) -> bool:
|
|
169
|
+
host = _host(provider.base_url)
|
|
170
|
+
return host == "openrouter.ai" or host.endswith(".openrouter.ai")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _headers(
|
|
174
|
+
provider: ProviderConfig, key: str | None, *, json_body: bool = True
|
|
175
|
+
) -> dict[str, str]:
|
|
176
|
+
headers = {"Content-Type": "application/json"} if json_body else {}
|
|
177
|
+
if key:
|
|
178
|
+
if provider.compat == "anthropic":
|
|
179
|
+
headers["anthropic-version"] = "2023-06-01"
|
|
180
|
+
headers["x-api-key"] = key
|
|
181
|
+
else:
|
|
182
|
+
headers[provider.key_header] = f"{provider.key_prefix}{key}"
|
|
183
|
+
headers.update(provider.extra_headers)
|
|
184
|
+
return headers
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _payload(model: str, prompt: str, size: str) -> dict[str, Any]:
|
|
188
|
+
payload: dict[str, Any] = {
|
|
189
|
+
"model": model,
|
|
190
|
+
"prompt": prompt,
|
|
191
|
+
"size": size,
|
|
192
|
+
"n": 1,
|
|
193
|
+
}
|
|
194
|
+
# DALL·E defaults to a URL response; gpt-image models always return b64.
|
|
195
|
+
if "dall-e" in model.lower():
|
|
196
|
+
payload["response_format"] = "b64_json"
|
|
197
|
+
return payload
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _slug(prompt: str) -> str:
|
|
201
|
+
cleaned = re.sub(r"[^a-z0-9]+", "-", prompt.lower()).strip("-")
|
|
202
|
+
cleaned = cleaned[:_SLUG_MAX].strip("-")
|
|
203
|
+
return cleaned or "image"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _atomic_write_bytes(path: Path, data: bytes) -> None:
|
|
207
|
+
tmp = path.with_name(path.name + ".kiwi.tmp")
|
|
208
|
+
try:
|
|
209
|
+
tmp.write_bytes(data)
|
|
210
|
+
os.replace(tmp, path)
|
|
211
|
+
except OSError:
|
|
212
|
+
try:
|
|
213
|
+
tmp.unlink()
|
|
214
|
+
except OSError:
|
|
215
|
+
pass
|
|
216
|
+
raise
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _output_dir(workspace_root: Path | str, output_dir: str) -> Path:
|
|
220
|
+
"""Resolve the media directory, refusing anything outside the workspace."""
|
|
221
|
+
root = Path(workspace_root).resolve()
|
|
222
|
+
target_dir = (root / output_dir).resolve()
|
|
223
|
+
if target_dir != root and not target_dir.is_relative_to(root):
|
|
224
|
+
raise MediaError(
|
|
225
|
+
f"Media output directory '{output_dir}' is outside the workspace root."
|
|
226
|
+
)
|
|
227
|
+
return target_dir
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _reserve_path(
|
|
231
|
+
workspace_root: Path | str, output_dir: str, label: str, suffix: str
|
|
232
|
+
) -> Path:
|
|
233
|
+
"""Create the output directory and return a free ``<stamp>-<slug><suffix>``."""
|
|
234
|
+
target_dir = _output_dir(workspace_root, output_dir)
|
|
235
|
+
try:
|
|
236
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
237
|
+
except OSError as exc:
|
|
238
|
+
raise MediaError(f"Could not create media directory '{target_dir}': {exc}") from exc
|
|
239
|
+
stamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
240
|
+
slug = _slug(label)
|
|
241
|
+
path = target_dir / f"{stamp}-{slug}{suffix}"
|
|
242
|
+
counter = 1
|
|
243
|
+
while path.exists():
|
|
244
|
+
counter += 1
|
|
245
|
+
path = target_dir / f"{stamp}-{slug}-{counter}{suffix}"
|
|
246
|
+
return path
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _write_image(workspace_root: Path | str, output_dir: str, prompt: str, data: bytes) -> Path:
|
|
250
|
+
path = _reserve_path(workspace_root, output_dir, prompt, ".png")
|
|
251
|
+
try:
|
|
252
|
+
_atomic_write_bytes(path, data)
|
|
253
|
+
except OSError as exc:
|
|
254
|
+
raise MediaError(f"Could not save the generated image to '{path}': {exc}") from exc
|
|
255
|
+
return path
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _guard_download(url: str, what: str) -> None:
|
|
259
|
+
"""Offline and local-address guards shared by image and video downloads."""
|
|
260
|
+
if config.offline_enabled():
|
|
261
|
+
raise MediaError(network.offline_message(what))
|
|
262
|
+
if is_local_address(url) and not config.get_web()["allow_local"]:
|
|
263
|
+
raise MediaError(
|
|
264
|
+
f"Refusing to download the {what.split()[0]} from a local or private "
|
|
265
|
+
"address. Enable it with `/config web allow-local on`."
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _download_image(url: str) -> bytes:
|
|
270
|
+
"""Download an image URL with a bounded size and local/offline guards."""
|
|
271
|
+
_guard_download(url, "image download")
|
|
272
|
+
try:
|
|
273
|
+
with _client(REQUEST_TIMEOUT) as client:
|
|
274
|
+
with client.stream("GET", url) as response:
|
|
275
|
+
if response.status_code != 200:
|
|
276
|
+
raise MediaError(
|
|
277
|
+
f"Image download returned HTTP {response.status_code}."
|
|
278
|
+
)
|
|
279
|
+
data = bytearray()
|
|
280
|
+
for chunk in response.iter_bytes():
|
|
281
|
+
data.extend(chunk)
|
|
282
|
+
if len(data) > MAX_IMAGE_BYTES:
|
|
283
|
+
raise MediaError(
|
|
284
|
+
"Image download exceeded the "
|
|
285
|
+
f"{MAX_IMAGE_BYTES:,}-byte limit."
|
|
286
|
+
)
|
|
287
|
+
except httpx.HTTPError as exc:
|
|
288
|
+
raise MediaError(
|
|
289
|
+
f"Image download failed: {exc.__class__.__name__}."
|
|
290
|
+
) from exc
|
|
291
|
+
return bytes(data)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _decode_b64(payload: str) -> bytes:
|
|
295
|
+
try:
|
|
296
|
+
return base64.b64decode(payload, validate=False)
|
|
297
|
+
except (ValueError, TypeError) as exc:
|
|
298
|
+
raise MediaError("The provider returned an invalid base64 image.") from exc
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _first_image(response: httpx.Response, provider_name: str) -> dict[str, Any]:
|
|
302
|
+
try:
|
|
303
|
+
body = response.json()
|
|
304
|
+
except (json.JSONDecodeError, ValueError) as exc:
|
|
305
|
+
raise MediaError(
|
|
306
|
+
f"{provider_name} returned a non-JSON image response."
|
|
307
|
+
) from exc
|
|
308
|
+
if not isinstance(body, dict):
|
|
309
|
+
raise MediaError(f"{provider_name} returned an unexpected image response.")
|
|
310
|
+
data = body.get("data")
|
|
311
|
+
if not isinstance(data, list) or not data or not isinstance(data[0], dict):
|
|
312
|
+
error = body.get("error")
|
|
313
|
+
if error:
|
|
314
|
+
raise MediaError(f"{provider_name} image error: {redact(str(error))[:500]}")
|
|
315
|
+
raise MediaError(f"{provider_name} returned no image data.")
|
|
316
|
+
return data[0]
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _resolve_provider(
|
|
320
|
+
provider_id: str | None, cfg: dict[str, Any] | None, what: str
|
|
321
|
+
) -> tuple[dict[str, Any], str, ProviderConfig, str | None]:
|
|
322
|
+
"""Common preflight: enabled, known provider, online, and an API key.
|
|
323
|
+
|
|
324
|
+
Returns ``(settings, provider_id, provider, key)``. Every failure is a
|
|
325
|
+
:class:`MediaError` whose message says how to fix it.
|
|
326
|
+
"""
|
|
327
|
+
settings = config.get_media(cfg)
|
|
328
|
+
if not settings["enabled"]:
|
|
329
|
+
raise MediaError(_DISABLED_MESSAGE)
|
|
330
|
+
|
|
331
|
+
resolved_provider = str(provider_id or settings["provider"]).strip()
|
|
332
|
+
try:
|
|
333
|
+
provider = config.get_provider_config(resolved_provider, cfg)
|
|
334
|
+
except KeyError as exc:
|
|
335
|
+
raise MediaError(str(exc) or f"Unknown provider '{resolved_provider}'.") from exc
|
|
336
|
+
|
|
337
|
+
if config.offline_enabled(cfg):
|
|
338
|
+
raise MediaError(network.offline_message(what))
|
|
339
|
+
|
|
340
|
+
key = config.get_key(resolved_provider)
|
|
341
|
+
if not key and provider.needs_key:
|
|
342
|
+
raise MediaError(
|
|
343
|
+
f"No API key for {provider.name}. Set one with "
|
|
344
|
+
f"`config key set {provider.id} <KEY>` or the "
|
|
345
|
+
f"{provider.key_env} environment variable."
|
|
346
|
+
)
|
|
347
|
+
return settings, resolved_provider, provider, key
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def readiness(*, video: bool = False, cfg: dict[str, Any] | None = None) -> list[str]:
|
|
351
|
+
"""Why generation would fail right now; an empty list means ready."""
|
|
352
|
+
problems: list[str] = []
|
|
353
|
+
try:
|
|
354
|
+
settings, *_ = _resolve_provider(
|
|
355
|
+
None, cfg, "video generation" if video else "image generation"
|
|
356
|
+
)
|
|
357
|
+
except MediaError as exc:
|
|
358
|
+
problems.append(str(exc))
|
|
359
|
+
settings = config.get_media(cfg)
|
|
360
|
+
kind = "video" if video else "image"
|
|
361
|
+
if not config.get_media_model(settings["provider"], kind, cfg):
|
|
362
|
+
problems.append(NO_VIDEO_MODEL_MESSAGE if video else NO_IMAGE_MODEL_MESSAGE)
|
|
363
|
+
return problems
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def list_outputs(
|
|
367
|
+
session: Session, limit: int = 10, cfg: dict[str, Any] | None = None
|
|
368
|
+
) -> list[Path]:
|
|
369
|
+
"""Most recent generated images and videos, newest first."""
|
|
370
|
+
settings = config.get_media(cfg)
|
|
371
|
+
try:
|
|
372
|
+
directory = _output_dir(session.workspace_root, str(settings["output_dir"]))
|
|
373
|
+
except MediaError:
|
|
374
|
+
return []
|
|
375
|
+
if not directory.is_dir():
|
|
376
|
+
return []
|
|
377
|
+
found: list[tuple[float, Path]] = []
|
|
378
|
+
try:
|
|
379
|
+
for entry in directory.iterdir():
|
|
380
|
+
if entry.suffix.lower() in IMAGE_SUFFIXES + VIDEO_SUFFIXES and entry.is_file():
|
|
381
|
+
try:
|
|
382
|
+
found.append((entry.stat().st_mtime, entry))
|
|
383
|
+
except OSError:
|
|
384
|
+
continue
|
|
385
|
+
except OSError:
|
|
386
|
+
return []
|
|
387
|
+
found.sort(key=lambda item: (item[0], item[1].name), reverse=True)
|
|
388
|
+
return [path for _, path in found[: max(limit, 0)]]
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def generate_image(
|
|
392
|
+
prompt: str,
|
|
393
|
+
*,
|
|
394
|
+
session: Session,
|
|
395
|
+
size: str | None = None,
|
|
396
|
+
model: str | None = None,
|
|
397
|
+
provider_id: str | None = None,
|
|
398
|
+
cfg: dict[str, Any] | None = None,
|
|
399
|
+
) -> MediaResult:
|
|
400
|
+
"""Generate one image and return where it was saved.
|
|
401
|
+
|
|
402
|
+
Raises :class:`MediaError` with a user-facing message for every failure;
|
|
403
|
+
raw httpx errors are never leaked. The caller decides whether to attach the
|
|
404
|
+
result to the conversation (see :func:`attach_result`).
|
|
405
|
+
"""
|
|
406
|
+
text = str(prompt or "").strip()
|
|
407
|
+
if not text:
|
|
408
|
+
raise MediaError("A prompt is required to generate an image.")
|
|
409
|
+
|
|
410
|
+
settings, resolved_provider, provider, key = _resolve_provider(
|
|
411
|
+
provider_id, cfg, "image generation"
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
chosen_model = str(
|
|
415
|
+
model or config.get_media_model(resolved_provider, "image", cfg)
|
|
416
|
+
).strip()
|
|
417
|
+
if not chosen_model:
|
|
418
|
+
raise MediaError(NO_IMAGE_MODEL_MESSAGE)
|
|
419
|
+
chosen_size = str(size or settings["size"]).strip()
|
|
420
|
+
if not config.MEDIA_SIZE_RE.match(chosen_size):
|
|
421
|
+
raise MediaError("Image size must look like WxH, e.g. 1024x1024.")
|
|
422
|
+
|
|
423
|
+
route = "images" if _is_openrouter(provider) else "images/generations"
|
|
424
|
+
url = provider.versioned_url(f"{provider.base_url.rstrip('/')}/{route}")
|
|
425
|
+
payload = _payload(chosen_model, text, chosen_size)
|
|
426
|
+
try:
|
|
427
|
+
with _client(REQUEST_TIMEOUT) as client:
|
|
428
|
+
response = client.post(url, json=payload, headers=_headers(provider, key))
|
|
429
|
+
except httpx.HTTPError as exc:
|
|
430
|
+
raise MediaError(
|
|
431
|
+
f"{provider.name} image request failed: {exc.__class__.__name__}."
|
|
432
|
+
) from exc
|
|
433
|
+
|
|
434
|
+
if response.status_code != 200:
|
|
435
|
+
body = response.text[:500]
|
|
436
|
+
raise MediaError(
|
|
437
|
+
f"{provider.name} image generation returned HTTP "
|
|
438
|
+
f"{response.status_code}: {redact(body)}"
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
item = _first_image(response, provider.name)
|
|
442
|
+
revised = item.get("revised_prompt")
|
|
443
|
+
revised_prompt = str(revised) if revised else None
|
|
444
|
+
|
|
445
|
+
b64_payload = item.get("b64_json")
|
|
446
|
+
if isinstance(b64_payload, str) and b64_payload:
|
|
447
|
+
data = _decode_b64(b64_payload)
|
|
448
|
+
else:
|
|
449
|
+
image_url = item.get("url")
|
|
450
|
+
if not isinstance(image_url, str) or not image_url:
|
|
451
|
+
raise MediaError(
|
|
452
|
+
"The provider response contained neither b64_json nor url."
|
|
453
|
+
)
|
|
454
|
+
data = _download_image(image_url)
|
|
455
|
+
if not data:
|
|
456
|
+
raise MediaError("The provider returned an empty image.")
|
|
457
|
+
|
|
458
|
+
path = _write_image(
|
|
459
|
+
session.workspace_root, str(settings["output_dir"]), text, data
|
|
460
|
+
)
|
|
461
|
+
return MediaResult(
|
|
462
|
+
path=path,
|
|
463
|
+
revised_prompt=revised_prompt,
|
|
464
|
+
model=chosen_model,
|
|
465
|
+
bytes=len(data),
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def attach_result(result: MediaResult, session: Session) -> str:
|
|
470
|
+
"""Best-effort attach a generated image; returns a note ("" on success).
|
|
471
|
+
|
|
472
|
+
Honors the vision size/count limits. A failure to attach never fails the
|
|
473
|
+
generation itself -- the caller has the path either way.
|
|
474
|
+
"""
|
|
475
|
+
try:
|
|
476
|
+
vision = config.get_vision()
|
|
477
|
+
limit = int(vision["max_images_per_turn"])
|
|
478
|
+
if len(session.pending_images) >= limit:
|
|
479
|
+
return (
|
|
480
|
+
f"Not attached: {limit} image(s) already pending. Send them "
|
|
481
|
+
"first, then generate again."
|
|
482
|
+
)
|
|
483
|
+
entry = images.encode_image(result.path, int(vision["max_image_bytes"]))
|
|
484
|
+
except (ValueError, OSError) as exc:
|
|
485
|
+
return f"Not attached: {exc}"
|
|
486
|
+
session.pending_images.append(entry)
|
|
487
|
+
return ""
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
# ---------------------------------------------------------------------------
|
|
491
|
+
# Video
|
|
492
|
+
# ---------------------------------------------------------------------------
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def normalize_video_size(size: str) -> str:
|
|
496
|
+
"""Canonical casing for a video size: ``720p``/``1080p`` and ``2K``/``4K``."""
|
|
497
|
+
text = size.strip()
|
|
498
|
+
if "x" in text.lower():
|
|
499
|
+
return text.lower()
|
|
500
|
+
return text.lower() if text.lower().endswith("p") else text.upper()
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _video_payload(
|
|
504
|
+
provider: ProviderConfig,
|
|
505
|
+
model: str,
|
|
506
|
+
prompt: str,
|
|
507
|
+
duration: int,
|
|
508
|
+
size: str,
|
|
509
|
+
first_frame_url: str | None,
|
|
510
|
+
) -> dict[str, Any]:
|
|
511
|
+
if _is_openrouter(provider):
|
|
512
|
+
payload: dict[str, Any] = {
|
|
513
|
+
"model": model,
|
|
514
|
+
"prompt": prompt,
|
|
515
|
+
"duration": duration,
|
|
516
|
+
}
|
|
517
|
+
if size:
|
|
518
|
+
payload["size" if "x" in size else "resolution"] = size
|
|
519
|
+
if first_frame_url:
|
|
520
|
+
payload["frame_images"] = [
|
|
521
|
+
{
|
|
522
|
+
"type": "image_url",
|
|
523
|
+
"image_url": {"url": first_frame_url},
|
|
524
|
+
"frame_type": "first_frame",
|
|
525
|
+
}
|
|
526
|
+
]
|
|
527
|
+
return payload
|
|
528
|
+
if first_frame_url:
|
|
529
|
+
raise MediaError(
|
|
530
|
+
f"{provider.name} video generation does not take a first-frame image "
|
|
531
|
+
"here. Use an OpenRouter video model for image-to-video."
|
|
532
|
+
)
|
|
533
|
+
payload = {"model": model, "prompt": prompt, "seconds": str(duration)}
|
|
534
|
+
if size:
|
|
535
|
+
if "x" not in size:
|
|
536
|
+
raise MediaError(
|
|
537
|
+
f"{provider.name} needs an exact video size such as 1280x720, "
|
|
538
|
+
f"not '{size}'."
|
|
539
|
+
)
|
|
540
|
+
payload["size"] = size
|
|
541
|
+
return payload
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _json_object(response: httpx.Response, provider_name: str) -> dict[str, Any]:
|
|
545
|
+
try:
|
|
546
|
+
body = response.json()
|
|
547
|
+
except (json.JSONDecodeError, ValueError) as exc:
|
|
548
|
+
raise MediaError(f"{provider_name} returned a non-JSON video response.") from exc
|
|
549
|
+
if not isinstance(body, dict):
|
|
550
|
+
raise MediaError(f"{provider_name} returned an unexpected video response.")
|
|
551
|
+
return body
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _error_text(body: dict[str, Any]) -> str:
|
|
555
|
+
error = body.get("error")
|
|
556
|
+
if isinstance(error, dict):
|
|
557
|
+
error = error.get("message") or error
|
|
558
|
+
return redact(str(error or "no reason given"))[:500]
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _poll_url(provider: ProviderConfig, job_id: str, advertised: object) -> str:
|
|
562
|
+
"""Where to poll: the provider's ``polling_url`` only if it stays on-host."""
|
|
563
|
+
base = provider.base_url.rstrip("/")
|
|
564
|
+
fallback = provider.versioned_url(f"{base}/videos/{job_id}")
|
|
565
|
+
if isinstance(advertised, str) and advertised:
|
|
566
|
+
if _host(advertised) == _host(provider.base_url):
|
|
567
|
+
return advertised
|
|
568
|
+
return fallback
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _download_video(
|
|
572
|
+
url: str,
|
|
573
|
+
dest: Path,
|
|
574
|
+
headers: dict[str, str],
|
|
575
|
+
*,
|
|
576
|
+
send_auth: bool,
|
|
577
|
+
) -> tuple[int, bytes]:
|
|
578
|
+
"""Stream a video to ``dest`` (atomically) with a size bound.
|
|
579
|
+
|
|
580
|
+
Returns ``(bytes written, first 16 bytes)``; the head is used to pick the
|
|
581
|
+
file extension without re-reading a large file.
|
|
582
|
+
"""
|
|
583
|
+
_guard_download(url, "video download")
|
|
584
|
+
tmp = dest.with_name(dest.name + ".kiwi.tmp")
|
|
585
|
+
total = 0
|
|
586
|
+
head = b""
|
|
587
|
+
try:
|
|
588
|
+
with _client(REQUEST_TIMEOUT) as client:
|
|
589
|
+
with client.stream("GET", url, headers=headers if send_auth else {}) as response:
|
|
590
|
+
if response.status_code != 200:
|
|
591
|
+
raise MediaError(
|
|
592
|
+
f"Video download returned HTTP {response.status_code}."
|
|
593
|
+
)
|
|
594
|
+
with tmp.open("wb") as handle:
|
|
595
|
+
for chunk in response.iter_bytes():
|
|
596
|
+
if len(head) < 16:
|
|
597
|
+
head = (head + chunk)[:16]
|
|
598
|
+
total += len(chunk)
|
|
599
|
+
if total > MAX_VIDEO_BYTES:
|
|
600
|
+
raise MediaError(
|
|
601
|
+
"Video download exceeded the "
|
|
602
|
+
f"{MAX_VIDEO_BYTES:,}-byte limit."
|
|
603
|
+
)
|
|
604
|
+
handle.write(chunk)
|
|
605
|
+
if total == 0:
|
|
606
|
+
raise MediaError("The provider returned an empty video.")
|
|
607
|
+
if not _looks_like_video(head):
|
|
608
|
+
raise MediaError("The provider returned a file that is not a video.")
|
|
609
|
+
os.replace(tmp, dest)
|
|
610
|
+
except httpx.HTTPError as exc:
|
|
611
|
+
raise MediaError(f"Video download failed: {exc.__class__.__name__}.") from exc
|
|
612
|
+
except OSError as exc:
|
|
613
|
+
raise MediaError(f"Could not save the generated video to '{dest}': {exc}") from exc
|
|
614
|
+
finally:
|
|
615
|
+
try:
|
|
616
|
+
tmp.unlink()
|
|
617
|
+
except OSError:
|
|
618
|
+
pass
|
|
619
|
+
return total, head
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _looks_like_video(head: bytes) -> bool:
|
|
623
|
+
"""MP4/MOV (``ftyp`` box) or WebM/Matroska (EBML header)."""
|
|
624
|
+
return head[4:8] == b"ftyp" or head[:4] == b"\x1a\x45\xdf\xa3"
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _video_suffix(head: bytes) -> str:
|
|
628
|
+
return ".webm" if head[:4] == b"\x1a\x45\xdf\xa3" else ".mp4"
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _await_video(
|
|
632
|
+
provider: ProviderConfig,
|
|
633
|
+
key: str | None,
|
|
634
|
+
job_id: str,
|
|
635
|
+
poll_url: str,
|
|
636
|
+
*,
|
|
637
|
+
progress: ProgressCallback | None,
|
|
638
|
+
poll_interval: float,
|
|
639
|
+
max_wait: float,
|
|
640
|
+
started: float,
|
|
641
|
+
) -> dict[str, Any]:
|
|
642
|
+
"""Poll a job until it completes; returns the final status body."""
|
|
643
|
+
headers = _headers(provider, key, json_body=False)
|
|
644
|
+
failures = 0
|
|
645
|
+
while True:
|
|
646
|
+
status = "unknown"
|
|
647
|
+
percent: int | None = None
|
|
648
|
+
body: dict[str, Any] | None = None
|
|
649
|
+
try:
|
|
650
|
+
with _client(REQUEST_TIMEOUT) as client:
|
|
651
|
+
response = client.get(poll_url, headers=headers)
|
|
652
|
+
except httpx.HTTPError:
|
|
653
|
+
response = None
|
|
654
|
+
if response is not None and response.status_code in (401, 403, 404):
|
|
655
|
+
raise MediaError(
|
|
656
|
+
f"{provider.name} video status check for job {job_id} returned "
|
|
657
|
+
f"HTTP {response.status_code}: {redact(response.text[:300])}"
|
|
658
|
+
)
|
|
659
|
+
if response is not None and response.status_code < 400:
|
|
660
|
+
body = _json_object(response, provider.name)
|
|
661
|
+
|
|
662
|
+
if body is None:
|
|
663
|
+
# Network blip or a 5xx/429: the job is fine, keep polling a while.
|
|
664
|
+
failures += 1
|
|
665
|
+
if failures > _VIDEO_POLL_RETRIES:
|
|
666
|
+
raise VideoJobError(
|
|
667
|
+
f"Lost contact with {provider.name} while the video was "
|
|
668
|
+
f"rendering. Job {job_id} may still finish; resume it with "
|
|
669
|
+
f"`/video resume {job_id}`.",
|
|
670
|
+
job_id,
|
|
671
|
+
)
|
|
672
|
+
else:
|
|
673
|
+
failures = 0
|
|
674
|
+
status = str(body.get("status") or "unknown").lower()
|
|
675
|
+
raw_progress = body.get("progress")
|
|
676
|
+
if isinstance(raw_progress, (int, float)) and not isinstance(raw_progress, bool):
|
|
677
|
+
percent = max(0, min(100, int(raw_progress)))
|
|
678
|
+
if status in _VIDEO_DONE:
|
|
679
|
+
return body
|
|
680
|
+
if status in _VIDEO_FAILED:
|
|
681
|
+
raise MediaError(
|
|
682
|
+
f"{provider.name} video generation {status}: {_error_text(body)}"
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
elapsed = time.monotonic() - started
|
|
686
|
+
if progress is not None:
|
|
687
|
+
try:
|
|
688
|
+
progress(VideoProgress(job_id, status, elapsed, percent))
|
|
689
|
+
except Exception: # noqa: BLE001 - a display glitch must not kill the job
|
|
690
|
+
pass
|
|
691
|
+
if elapsed >= max_wait:
|
|
692
|
+
raise VideoJobError(
|
|
693
|
+
f"Still rendering after {int(elapsed // 60)} min. The job keeps "
|
|
694
|
+
f"running (and billing) on {provider.name}; resume it later with "
|
|
695
|
+
f"`/video resume {job_id}`.",
|
|
696
|
+
job_id,
|
|
697
|
+
)
|
|
698
|
+
_sleep(poll_interval)
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _finish_video(
|
|
702
|
+
provider: ProviderConfig,
|
|
703
|
+
key: str | None,
|
|
704
|
+
settings: dict[str, Any],
|
|
705
|
+
session: Session,
|
|
706
|
+
job_id: str,
|
|
707
|
+
advertised_poll_url: object,
|
|
708
|
+
label: str,
|
|
709
|
+
model: str,
|
|
710
|
+
*,
|
|
711
|
+
progress: ProgressCallback | None,
|
|
712
|
+
poll_interval: float | None,
|
|
713
|
+
max_wait: float | None,
|
|
714
|
+
) -> VideoResult:
|
|
715
|
+
interval = VIDEO_POLL_INTERVAL if poll_interval is None else poll_interval
|
|
716
|
+
limit = VIDEO_MAX_WAIT if max_wait is None else max_wait
|
|
717
|
+
final = _await_video(
|
|
718
|
+
provider,
|
|
719
|
+
key,
|
|
720
|
+
job_id,
|
|
721
|
+
_poll_url(provider, job_id, advertised_poll_url),
|
|
722
|
+
progress=progress,
|
|
723
|
+
poll_interval=interval,
|
|
724
|
+
max_wait=limit,
|
|
725
|
+
started=time.monotonic(),
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
base = provider.base_url.rstrip("/")
|
|
729
|
+
content_url = provider.versioned_url(f"{base}/videos/{job_id}/content")
|
|
730
|
+
urls = final.get("unsigned_urls")
|
|
731
|
+
download_url = content_url
|
|
732
|
+
if isinstance(urls, list) and urls and isinstance(urls[0], str) and urls[0]:
|
|
733
|
+
download_url = urls[0]
|
|
734
|
+
# Only the provider's own host ever sees the API key.
|
|
735
|
+
send_auth = _host(download_url) == _host(provider.base_url)
|
|
736
|
+
|
|
737
|
+
dest = _reserve_path(
|
|
738
|
+
session.workspace_root, str(settings["output_dir"]), label, ".mp4"
|
|
739
|
+
)
|
|
740
|
+
try:
|
|
741
|
+
size, head = _download_video(
|
|
742
|
+
download_url,
|
|
743
|
+
dest,
|
|
744
|
+
_headers(provider, key, json_body=False),
|
|
745
|
+
send_auth=send_auth,
|
|
746
|
+
)
|
|
747
|
+
except MediaError as exc:
|
|
748
|
+
raise VideoJobError(
|
|
749
|
+
f"{exc} Job {job_id} finished on {provider.name}; once the problem "
|
|
750
|
+
f"is fixed, fetch it with `/video resume {job_id}`.",
|
|
751
|
+
job_id,
|
|
752
|
+
) from exc
|
|
753
|
+
if _video_suffix(head) != dest.suffix:
|
|
754
|
+
renamed = dest.with_suffix(_video_suffix(head))
|
|
755
|
+
if not renamed.exists():
|
|
756
|
+
os.replace(dest, renamed)
|
|
757
|
+
dest = renamed
|
|
758
|
+
|
|
759
|
+
cost: float | None = None
|
|
760
|
+
usage = final.get("usage")
|
|
761
|
+
if isinstance(usage, dict):
|
|
762
|
+
raw_cost = usage.get("cost")
|
|
763
|
+
if isinstance(raw_cost, (int, float)) and not isinstance(raw_cost, bool):
|
|
764
|
+
cost = float(raw_cost)
|
|
765
|
+
return VideoResult(path=dest, job_id=job_id, model=model, bytes=size, cost=cost)
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def generate_video(
|
|
769
|
+
prompt: str,
|
|
770
|
+
*,
|
|
771
|
+
session: Session,
|
|
772
|
+
model: str | None = None,
|
|
773
|
+
duration: int | None = None,
|
|
774
|
+
size: str | None = None,
|
|
775
|
+
first_frame: Path | str | None = None,
|
|
776
|
+
provider_id: str | None = None,
|
|
777
|
+
cfg: dict[str, Any] | None = None,
|
|
778
|
+
progress: ProgressCallback | None = None,
|
|
779
|
+
poll_interval: float | None = None,
|
|
780
|
+
max_wait: float | None = None,
|
|
781
|
+
) -> VideoResult:
|
|
782
|
+
"""Submit a video job, wait for it, and save the result.
|
|
783
|
+
|
|
784
|
+
``first_frame`` is an already-validated workspace image to animate
|
|
785
|
+
(image-to-video). Raises :class:`MediaError` for every failure and
|
|
786
|
+
:class:`VideoJobError` -- carrying the job id -- when the job is still
|
|
787
|
+
running after ``max_wait`` seconds or the file could not be fetched yet.
|
|
788
|
+
"""
|
|
789
|
+
text = str(prompt or "").strip()
|
|
790
|
+
if not text:
|
|
791
|
+
raise MediaError("A prompt is required to generate a video.")
|
|
792
|
+
|
|
793
|
+
settings, resolved_provider, provider, key = _resolve_provider(
|
|
794
|
+
provider_id, cfg, "video generation"
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
chosen_model = str(
|
|
798
|
+
model or config.get_media_model(resolved_provider, "video", cfg)
|
|
799
|
+
).strip()
|
|
800
|
+
if not chosen_model:
|
|
801
|
+
raise MediaError(NO_VIDEO_MODEL_MESSAGE)
|
|
802
|
+
seconds = settings["video_duration"] if duration is None else duration
|
|
803
|
+
if not isinstance(seconds, int) or isinstance(seconds, bool) or not (
|
|
804
|
+
config.VIDEO_DURATION_MIN <= seconds <= config.VIDEO_DURATION_MAX
|
|
805
|
+
):
|
|
806
|
+
raise MediaError(
|
|
807
|
+
"Video duration must be whole seconds between "
|
|
808
|
+
f"{config.VIDEO_DURATION_MIN} and {config.VIDEO_DURATION_MAX}."
|
|
809
|
+
)
|
|
810
|
+
chosen_size = str(size or settings["video_size"]).strip()
|
|
811
|
+
if chosen_size and not config.VIDEO_SIZE_RE.match(chosen_size):
|
|
812
|
+
raise MediaError("Video size must be WxH (e.g. 1280x720) or a tier like 720p.")
|
|
813
|
+
chosen_size = normalize_video_size(chosen_size) if chosen_size else ""
|
|
814
|
+
|
|
815
|
+
frame_url: str | None = None
|
|
816
|
+
if first_frame is not None:
|
|
817
|
+
try:
|
|
818
|
+
vision = config.get_vision()
|
|
819
|
+
entry = images.encode_image(Path(first_frame), int(vision["max_image_bytes"]))
|
|
820
|
+
except (ValueError, OSError) as exc:
|
|
821
|
+
raise MediaError(f"Cannot use the first-frame image: {exc}") from exc
|
|
822
|
+
frame_url = f"data:{entry['media_type']};base64,{entry['data']}"
|
|
823
|
+
|
|
824
|
+
payload = _video_payload(provider, chosen_model, text, seconds, chosen_size, frame_url)
|
|
825
|
+
url = provider.versioned_url(f"{provider.base_url.rstrip('/')}/videos")
|
|
826
|
+
started = time.monotonic()
|
|
827
|
+
try:
|
|
828
|
+
with _client(REQUEST_TIMEOUT) as client:
|
|
829
|
+
if _is_openrouter(provider):
|
|
830
|
+
response = client.post(url, json=payload, headers=_headers(provider, key))
|
|
831
|
+
else:
|
|
832
|
+
# The OpenAI Videos API takes a multipart form, not JSON.
|
|
833
|
+
response = client.post(
|
|
834
|
+
url,
|
|
835
|
+
files={name: (None, str(value)) for name, value in payload.items()},
|
|
836
|
+
headers=_headers(provider, key, json_body=False),
|
|
837
|
+
)
|
|
838
|
+
except httpx.HTTPError as exc:
|
|
839
|
+
raise MediaError(
|
|
840
|
+
f"{provider.name} video request failed: {exc.__class__.__name__}."
|
|
841
|
+
) from exc
|
|
842
|
+
if response.status_code not in (200, 201, 202):
|
|
843
|
+
raise MediaError(
|
|
844
|
+
f"{provider.name} video generation returned HTTP "
|
|
845
|
+
f"{response.status_code}: {redact(response.text[:500])}"
|
|
846
|
+
)
|
|
847
|
+
body = _json_object(response, provider.name)
|
|
848
|
+
job_id = str(body.get("id") or "").strip()
|
|
849
|
+
if not _JOB_ID_RE.match(job_id):
|
|
850
|
+
raise MediaError(f"{provider.name} did not return a usable video job id.")
|
|
851
|
+
status = str(body.get("status") or "pending").lower()
|
|
852
|
+
if status in _VIDEO_FAILED:
|
|
853
|
+
raise MediaError(f"{provider.name} video generation {status}: {_error_text(body)}")
|
|
854
|
+
|
|
855
|
+
if progress is not None:
|
|
856
|
+
try:
|
|
857
|
+
progress(VideoProgress(job_id, "submitted", time.monotonic() - started, None))
|
|
858
|
+
except Exception: # noqa: BLE001
|
|
859
|
+
pass
|
|
860
|
+
return _finish_video(
|
|
861
|
+
provider,
|
|
862
|
+
key,
|
|
863
|
+
settings,
|
|
864
|
+
session,
|
|
865
|
+
job_id,
|
|
866
|
+
body.get("polling_url"),
|
|
867
|
+
f"video {text}",
|
|
868
|
+
chosen_model,
|
|
869
|
+
progress=progress,
|
|
870
|
+
poll_interval=poll_interval,
|
|
871
|
+
max_wait=max_wait,
|
|
872
|
+
)
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def resume_video(
|
|
876
|
+
job_id: str,
|
|
877
|
+
*,
|
|
878
|
+
session: Session,
|
|
879
|
+
provider_id: str | None = None,
|
|
880
|
+
cfg: dict[str, Any] | None = None,
|
|
881
|
+
progress: ProgressCallback | None = None,
|
|
882
|
+
poll_interval: float | None = None,
|
|
883
|
+
max_wait: float | None = None,
|
|
884
|
+
) -> VideoResult:
|
|
885
|
+
"""Pick up a video job that was still running when we stopped waiting."""
|
|
886
|
+
cleaned = str(job_id or "").strip()
|
|
887
|
+
if not _JOB_ID_RE.match(cleaned):
|
|
888
|
+
raise MediaError("A valid video job id is required to resume a job.")
|
|
889
|
+
settings, resolved_provider, provider, key = _resolve_provider(
|
|
890
|
+
provider_id, cfg, "video generation"
|
|
891
|
+
)
|
|
892
|
+
return _finish_video(
|
|
893
|
+
provider,
|
|
894
|
+
key,
|
|
895
|
+
settings,
|
|
896
|
+
session,
|
|
897
|
+
cleaned,
|
|
898
|
+
None,
|
|
899
|
+
f"video {cleaned}",
|
|
900
|
+
config.get_media_model(resolved_provider, "video", cfg) or "resumed job",
|
|
901
|
+
progress=progress,
|
|
902
|
+
poll_interval=poll_interval,
|
|
903
|
+
max_wait=max_wait,
|
|
904
|
+
)
|