everypixel-cli 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.
- everypixel_cli/__init__.py +1 -0
- everypixel_cli/__main__.py +5 -0
- everypixel_cli/application/__init__.py +10 -0
- everypixel_cli/application/models.py +59 -0
- everypixel_cli/application/serialization.py +20 -0
- everypixel_cli/application/services.py +1376 -0
- everypixel_cli/cli.py +1520 -0
- everypixel_cli/client.py +159 -0
- everypixel_cli/config.py +221 -0
- everypixel_cli/errors.py +262 -0
- everypixel_cli/files.py +298 -0
- everypixel_cli/mcp_server.py +828 -0
- everypixel_cli/openapi.py +338 -0
- everypixel_cli/output.py +159 -0
- everypixel_cli/resources/__init__.py +1 -0
- everypixel_cli/resources/openapi.json +5402 -0
- everypixel_cli/schemas.py +846 -0
- everypixel_cli-0.1.0.dist-info/METADATA +454 -0
- everypixel_cli-0.1.0.dist-info/RECORD +22 -0
- everypixel_cli-0.1.0.dist-info/WHEEL +4 -0
- everypixel_cli-0.1.0.dist-info/entry_points.txt +3 -0
- everypixel_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1376 @@
|
|
|
1
|
+
"""Application services for API operations, tasks, downloads, and media payloads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Callable, Mapping
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Protocol
|
|
11
|
+
|
|
12
|
+
from pydantic import TypeAdapter, ValidationError
|
|
13
|
+
|
|
14
|
+
from ..errors import (
|
|
15
|
+
APIResponseError,
|
|
16
|
+
FileReadError,
|
|
17
|
+
InputParsingError,
|
|
18
|
+
TaskFailedError,
|
|
19
|
+
TaskPollingError,
|
|
20
|
+
ValidationCLIError,
|
|
21
|
+
)
|
|
22
|
+
from ..files import (
|
|
23
|
+
download_urls,
|
|
24
|
+
is_url,
|
|
25
|
+
media_value,
|
|
26
|
+
result_downloads,
|
|
27
|
+
save_inline_result,
|
|
28
|
+
)
|
|
29
|
+
from ..openapi import (
|
|
30
|
+
find_operation,
|
|
31
|
+
load_schema,
|
|
32
|
+
operation_help,
|
|
33
|
+
validate_payload_against_operation,
|
|
34
|
+
)
|
|
35
|
+
from ..schemas import (
|
|
36
|
+
ImageEditPayload,
|
|
37
|
+
ImageGeneratePayload,
|
|
38
|
+
ImageUpscalePayload,
|
|
39
|
+
LipsyncImagePayload,
|
|
40
|
+
LipsyncVideoPayload,
|
|
41
|
+
TaskResponse,
|
|
42
|
+
VideoEditRequest,
|
|
43
|
+
VideoGenerateRequest,
|
|
44
|
+
VideoUpscalePayload,
|
|
45
|
+
)
|
|
46
|
+
from .models import (
|
|
47
|
+
ExecutionOptions,
|
|
48
|
+
OperationCancelled,
|
|
49
|
+
OperationRequest,
|
|
50
|
+
OperationResult,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_VIDEO_GENERATE_ADAPTER: TypeAdapter[VideoGenerateRequest] = TypeAdapter(
|
|
55
|
+
VideoGenerateRequest
|
|
56
|
+
)
|
|
57
|
+
_VIDEO_EDIT_ADAPTER: TypeAdapter[VideoEditRequest] = TypeAdapter(VideoEditRequest)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class EverypixelClientProtocol(Protocol):
|
|
61
|
+
"""Small infrastructure seam used by application services."""
|
|
62
|
+
|
|
63
|
+
base_url: str
|
|
64
|
+
|
|
65
|
+
def request(
|
|
66
|
+
self,
|
|
67
|
+
method: str,
|
|
68
|
+
path: str,
|
|
69
|
+
*,
|
|
70
|
+
json: dict[str, Any] | None = None,
|
|
71
|
+
params: dict[str, Any] | None = None,
|
|
72
|
+
files: dict[str, Any] | None = None,
|
|
73
|
+
auth_required: bool = True,
|
|
74
|
+
) -> Any: ...
|
|
75
|
+
|
|
76
|
+
def get_status(self, task_id: str) -> Any: ...
|
|
77
|
+
|
|
78
|
+
def check_auth(self) -> None: ...
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def raise_for_task_failure(payload: dict[str, Any], *, fallback_task_id: str) -> None:
|
|
82
|
+
"""Raise a typed error when a status response reached ``FAILURE``."""
|
|
83
|
+
|
|
84
|
+
if payload.get("status") != "FAILURE":
|
|
85
|
+
return
|
|
86
|
+
task_id = payload.get("task_id")
|
|
87
|
+
resolved_task_id = (
|
|
88
|
+
task_id if isinstance(task_id, str) and task_id else fallback_task_id
|
|
89
|
+
)
|
|
90
|
+
message, details = extract_task_failure(payload)
|
|
91
|
+
raise TaskFailedError(
|
|
92
|
+
message,
|
|
93
|
+
task_id=resolved_task_id,
|
|
94
|
+
details=details,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def extract_task_failure(payload: dict[str, Any]) -> tuple[str, dict[str, Any]]:
|
|
99
|
+
"""Extract a safe error message and structured diagnostics from task data."""
|
|
100
|
+
|
|
101
|
+
error = payload.get("error")
|
|
102
|
+
details: dict[str, Any] = {}
|
|
103
|
+
message = extract_failure_message(error)
|
|
104
|
+
if not message:
|
|
105
|
+
for key in ("message", "detail", "reason"):
|
|
106
|
+
message = extract_failure_message(payload.get(key))
|
|
107
|
+
if message:
|
|
108
|
+
break
|
|
109
|
+
|
|
110
|
+
code = extract_failure_code(error)
|
|
111
|
+
if not code:
|
|
112
|
+
for key in ("error_code", "code"):
|
|
113
|
+
value = payload.get(key)
|
|
114
|
+
if isinstance(value, str) and value:
|
|
115
|
+
code = value
|
|
116
|
+
break
|
|
117
|
+
if code:
|
|
118
|
+
details["api_error_code"] = code
|
|
119
|
+
|
|
120
|
+
if isinstance(error, dict):
|
|
121
|
+
error_details = error.get("details") or error.get("metadata")
|
|
122
|
+
if isinstance(error_details, (dict, list, str, int, float, bool)):
|
|
123
|
+
details["api_details"] = error_details
|
|
124
|
+
else:
|
|
125
|
+
for key in ("details", "metadata"):
|
|
126
|
+
value = payload.get(key)
|
|
127
|
+
if isinstance(value, (dict, list, str, int, float, bool)):
|
|
128
|
+
details["api_details"] = value
|
|
129
|
+
break
|
|
130
|
+
return message or "Task failed", details
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def extract_failure_message(value: Any) -> str | None:
|
|
134
|
+
"""Find a human-readable failure text without stringifying whole payloads."""
|
|
135
|
+
|
|
136
|
+
if isinstance(value, str) and value.strip():
|
|
137
|
+
return value.strip()
|
|
138
|
+
if isinstance(value, dict):
|
|
139
|
+
for key in ("message", "detail", "reason", "error"):
|
|
140
|
+
message = extract_failure_message(value.get(key))
|
|
141
|
+
if message:
|
|
142
|
+
return message
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def extract_failure_code(value: Any) -> str | None:
|
|
147
|
+
"""Find an optional stable API error code in a structured failure."""
|
|
148
|
+
|
|
149
|
+
if not isinstance(value, dict):
|
|
150
|
+
return None
|
|
151
|
+
for key in ("code", "error_code", "type"):
|
|
152
|
+
candidate = value.get(key)
|
|
153
|
+
if isinstance(candidate, str) and candidate:
|
|
154
|
+
return candidate
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class DownloadService:
|
|
159
|
+
"""Save completed task results without presentation concerns."""
|
|
160
|
+
|
|
161
|
+
def save(
|
|
162
|
+
self,
|
|
163
|
+
task: Mapping[str, Any],
|
|
164
|
+
directory: Path,
|
|
165
|
+
*,
|
|
166
|
+
fallback_extension: str,
|
|
167
|
+
fallback_task_id: str,
|
|
168
|
+
cancel_check: Callable[[], None] | None = None,
|
|
169
|
+
) -> tuple[Path, ...]:
|
|
170
|
+
result = task.get("result")
|
|
171
|
+
task_id = task.get("task_id")
|
|
172
|
+
resolved_task_id = (
|
|
173
|
+
task_id if isinstance(task_id, str) and task_id else fallback_task_id
|
|
174
|
+
)
|
|
175
|
+
downloads = result_downloads(result, fallback_ext=fallback_extension)
|
|
176
|
+
if downloads:
|
|
177
|
+
paths = download_urls(
|
|
178
|
+
downloads,
|
|
179
|
+
directory,
|
|
180
|
+
fallback_ext=fallback_extension,
|
|
181
|
+
task_id=resolved_task_id,
|
|
182
|
+
cancel_check=cancel_check,
|
|
183
|
+
)
|
|
184
|
+
else:
|
|
185
|
+
paths = save_inline_result(
|
|
186
|
+
result,
|
|
187
|
+
directory,
|
|
188
|
+
task_id=resolved_task_id,
|
|
189
|
+
fallback_ext=fallback_extension,
|
|
190
|
+
cancel_check=cancel_check,
|
|
191
|
+
)
|
|
192
|
+
return tuple(Path(path) for path in paths)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class TaskService:
|
|
196
|
+
"""Interpret and wait for task lifecycle states."""
|
|
197
|
+
|
|
198
|
+
def __init__(self, client: EverypixelClientProtocol) -> None:
|
|
199
|
+
self.client = client
|
|
200
|
+
|
|
201
|
+
def status(self, task_id: str) -> dict[str, Any]:
|
|
202
|
+
payload = self.client.get_status(task_id)
|
|
203
|
+
if not isinstance(payload, Mapping):
|
|
204
|
+
raise APIResponseError("API returned invalid task status response")
|
|
205
|
+
payload = dict(payload)
|
|
206
|
+
raise_for_task_failure(payload, fallback_task_id=task_id)
|
|
207
|
+
return payload
|
|
208
|
+
|
|
209
|
+
def wait(self, task_id: str, options: ExecutionOptions) -> dict[str, Any]:
|
|
210
|
+
started = time.monotonic()
|
|
211
|
+
while True:
|
|
212
|
+
options.check_cancelled()
|
|
213
|
+
payload = self.status(task_id)
|
|
214
|
+
options.check_cancelled()
|
|
215
|
+
if payload.get("status") == "SUCCESS":
|
|
216
|
+
payload["elapsed_sec"] = round(time.monotonic() - started, 2)
|
|
217
|
+
return payload
|
|
218
|
+
if time.monotonic() - started >= options.timeout:
|
|
219
|
+
raise TaskPollingError(
|
|
220
|
+
f"Timed out waiting for task {task_id}",
|
|
221
|
+
code="timeout",
|
|
222
|
+
details=payload,
|
|
223
|
+
)
|
|
224
|
+
if options.cancel_event is None:
|
|
225
|
+
time.sleep(options.poll_interval)
|
|
226
|
+
elif options.cancel_event.wait(options.poll_interval):
|
|
227
|
+
raise OperationCancelled
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class OperationService:
|
|
231
|
+
"""Execute sync and async operations through one typed pipeline."""
|
|
232
|
+
|
|
233
|
+
def __init__(
|
|
234
|
+
self,
|
|
235
|
+
client: EverypixelClientProtocol,
|
|
236
|
+
*,
|
|
237
|
+
download_service: DownloadService | None = None,
|
|
238
|
+
) -> None:
|
|
239
|
+
self.client = client
|
|
240
|
+
self.tasks = TaskService(client)
|
|
241
|
+
self.downloads = download_service or DownloadService()
|
|
242
|
+
|
|
243
|
+
def execute(self, request: OperationRequest) -> OperationResult:
|
|
244
|
+
method = request.method.upper()
|
|
245
|
+
payload = dict(request.payload)
|
|
246
|
+
response = self.client.request(
|
|
247
|
+
method,
|
|
248
|
+
request.endpoint,
|
|
249
|
+
json=payload if method != "GET" else None,
|
|
250
|
+
params=payload if method == "GET" else None,
|
|
251
|
+
)
|
|
252
|
+
if not isinstance(response, Mapping) or "task_id" not in response:
|
|
253
|
+
return OperationResult(value=response)
|
|
254
|
+
try:
|
|
255
|
+
created = TaskResponse.model_validate(response).model_dump(
|
|
256
|
+
exclude_none=True
|
|
257
|
+
)
|
|
258
|
+
except ValidationError as exc:
|
|
259
|
+
raise TaskPollingError(
|
|
260
|
+
"API response does not contain a valid task ID"
|
|
261
|
+
) from exc
|
|
262
|
+
if not request.execution.wait_for_result:
|
|
263
|
+
return OperationResult(value=created, task=created)
|
|
264
|
+
task_id = str(created["task_id"])
|
|
265
|
+
completed = self.tasks.wait(task_id, request.execution)
|
|
266
|
+
saved_files: tuple[Path, ...] = ()
|
|
267
|
+
if request.execution.download_directory is not None:
|
|
268
|
+
saved_files = self.downloads.save(
|
|
269
|
+
completed,
|
|
270
|
+
request.execution.download_directory,
|
|
271
|
+
fallback_extension=request.fallback_extension,
|
|
272
|
+
fallback_task_id=task_id,
|
|
273
|
+
cancel_check=request.execution.check_cancelled,
|
|
274
|
+
)
|
|
275
|
+
return OperationResult(value=completed, task=completed, saved_files=saved_files)
|
|
276
|
+
|
|
277
|
+
def status(
|
|
278
|
+
self,
|
|
279
|
+
task_id: str,
|
|
280
|
+
options: ExecutionOptions,
|
|
281
|
+
*,
|
|
282
|
+
fallback_extension: str = ".bin",
|
|
283
|
+
) -> OperationResult:
|
|
284
|
+
task = (
|
|
285
|
+
self.tasks.wait(task_id, options)
|
|
286
|
+
if options.wait_for_result
|
|
287
|
+
else self.tasks.status(task_id)
|
|
288
|
+
)
|
|
289
|
+
saved_files: tuple[Path, ...] = ()
|
|
290
|
+
if options.download_directory is not None:
|
|
291
|
+
saved_files = self.downloads.save(
|
|
292
|
+
task,
|
|
293
|
+
options.download_directory,
|
|
294
|
+
fallback_extension=fallback_extension,
|
|
295
|
+
fallback_task_id=task_id,
|
|
296
|
+
cancel_check=options.check_cancelled,
|
|
297
|
+
)
|
|
298
|
+
return OperationResult(value=task, task=task, saved_files=saved_files)
|
|
299
|
+
|
|
300
|
+
def wait(self, task_id: str, options: ExecutionOptions) -> OperationResult:
|
|
301
|
+
task = self.tasks.wait(task_id, options)
|
|
302
|
+
saved_files: tuple[Path, ...] = ()
|
|
303
|
+
if options.download_directory is not None:
|
|
304
|
+
saved_files = self.downloads.save(
|
|
305
|
+
task,
|
|
306
|
+
options.download_directory,
|
|
307
|
+
fallback_extension=".bin",
|
|
308
|
+
fallback_task_id=task_id,
|
|
309
|
+
cancel_check=options.check_cancelled,
|
|
310
|
+
)
|
|
311
|
+
return OperationResult(value=task, task=task, saved_files=saved_files)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def execution_options(
|
|
315
|
+
*, wait: bool, download_directory: Path | None, timeout: float, poll_interval: float
|
|
316
|
+
) -> ExecutionOptions:
|
|
317
|
+
"""Build options once so download-implies-wait lives outside the CLI."""
|
|
318
|
+
|
|
319
|
+
return ExecutionOptions(
|
|
320
|
+
wait=wait,
|
|
321
|
+
download_directory=download_directory,
|
|
322
|
+
timeout=timeout,
|
|
323
|
+
poll_interval=poll_interval,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def fallback_extension_for_endpoint(path: str) -> str:
|
|
328
|
+
"""Infer the existing conservative extension fallback from an endpoint path."""
|
|
329
|
+
|
|
330
|
+
lowered = path.lower()
|
|
331
|
+
if "transcribe" in lowered or "asr" in lowered:
|
|
332
|
+
return ".txt"
|
|
333
|
+
if "video" in lowered or "lipsync" in lowered:
|
|
334
|
+
return ".mp4"
|
|
335
|
+
if "tts" in lowered or "speech" in lowered or "audio" in lowered:
|
|
336
|
+
return ".mp3"
|
|
337
|
+
if "image" in lowered:
|
|
338
|
+
return ".png"
|
|
339
|
+
return ".bin"
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
@dataclass(frozen=True)
|
|
343
|
+
class _VideoEditInputs:
|
|
344
|
+
prompt: str | None
|
|
345
|
+
model: str
|
|
346
|
+
images: list[str]
|
|
347
|
+
video: str | None
|
|
348
|
+
audio: str | None
|
|
349
|
+
duration: int | None
|
|
350
|
+
resolution: str | None
|
|
351
|
+
aspect_ratio: str | None
|
|
352
|
+
seed: int | None
|
|
353
|
+
generate_audio: bool | None
|
|
354
|
+
callback_url: str | None
|
|
355
|
+
keyframes: list[str]
|
|
356
|
+
public_figure_threshold: str | None
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _validated_video_edit_payload(values: Mapping[str, Any]) -> dict[str, Any]:
|
|
360
|
+
validated = _VIDEO_EDIT_ADAPTER.validate_python(
|
|
361
|
+
{key: value for key, value in values.items() if value is not None}
|
|
362
|
+
)
|
|
363
|
+
return validated.model_dump(mode="json", exclude_none=True)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _reject_video_edit_options(model: str, options: Mapping[str, Any]) -> None:
|
|
367
|
+
provided = [
|
|
368
|
+
f"--{name.replace('_', '-')}"
|
|
369
|
+
for name, value in options.items()
|
|
370
|
+
if value is not None and value != []
|
|
371
|
+
]
|
|
372
|
+
if provided:
|
|
373
|
+
joined = ", ".join(provided)
|
|
374
|
+
raise ValidationCLIError(f"{model} video edit does not support {joined}")
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _build_content_video_edit_payload(
|
|
378
|
+
inputs: _VideoEditInputs, *, include_roles: bool
|
|
379
|
+
) -> dict[str, Any]:
|
|
380
|
+
content: list[dict[str, Any]] = [{"type": "text", "text": inputs.prompt}]
|
|
381
|
+
for value in inputs.images:
|
|
382
|
+
item = {
|
|
383
|
+
"type": "image_url",
|
|
384
|
+
"image_url": {"url": media_value(value)},
|
|
385
|
+
}
|
|
386
|
+
if include_roles:
|
|
387
|
+
item["role"] = "reference_image"
|
|
388
|
+
content.append(item)
|
|
389
|
+
if inputs.video:
|
|
390
|
+
item = {
|
|
391
|
+
"type": "video_url",
|
|
392
|
+
"video_url": {"url": media_value(inputs.video)},
|
|
393
|
+
}
|
|
394
|
+
if include_roles:
|
|
395
|
+
item["role"] = "reference_video"
|
|
396
|
+
content.append(item)
|
|
397
|
+
if inputs.audio:
|
|
398
|
+
item = {
|
|
399
|
+
"type": "audio_url",
|
|
400
|
+
"audio_url": {"url": media_value(inputs.audio)},
|
|
401
|
+
}
|
|
402
|
+
if include_roles:
|
|
403
|
+
item["role"] = "reference_audio"
|
|
404
|
+
content.append(item)
|
|
405
|
+
return _validated_video_edit_payload(
|
|
406
|
+
{
|
|
407
|
+
"prompt": inputs.prompt,
|
|
408
|
+
"model": inputs.model,
|
|
409
|
+
"duration": inputs.duration,
|
|
410
|
+
"resolution": inputs.resolution,
|
|
411
|
+
"aspect_ratio": inputs.aspect_ratio,
|
|
412
|
+
"generate_audio": inputs.generate_audio,
|
|
413
|
+
"callback_url": inputs.callback_url,
|
|
414
|
+
"content": content,
|
|
415
|
+
}
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _build_seedance_video_edit_payload(
|
|
420
|
+
inputs: _VideoEditInputs,
|
|
421
|
+
) -> dict[str, Any]:
|
|
422
|
+
_reject_video_edit_options(
|
|
423
|
+
inputs.model,
|
|
424
|
+
{
|
|
425
|
+
"seed": inputs.seed,
|
|
426
|
+
"keyframe": inputs.keyframes,
|
|
427
|
+
"public_figure_threshold": inputs.public_figure_threshold,
|
|
428
|
+
},
|
|
429
|
+
)
|
|
430
|
+
return _build_content_video_edit_payload(inputs, include_roles=True)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _build_kling_video_edit_payload(inputs: _VideoEditInputs) -> dict[str, Any]:
|
|
434
|
+
_reject_video_edit_options(
|
|
435
|
+
inputs.model,
|
|
436
|
+
{
|
|
437
|
+
"audio": inputs.audio,
|
|
438
|
+
"seed": inputs.seed,
|
|
439
|
+
"keyframe": inputs.keyframes,
|
|
440
|
+
"public_figure_threshold": inputs.public_figure_threshold,
|
|
441
|
+
},
|
|
442
|
+
)
|
|
443
|
+
return _build_content_video_edit_payload(inputs, include_roles=False)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _build_wan_video_edit_payload(inputs: _VideoEditInputs) -> dict[str, Any]:
|
|
447
|
+
_reject_video_edit_options(
|
|
448
|
+
inputs.model,
|
|
449
|
+
{
|
|
450
|
+
"audio": inputs.audio,
|
|
451
|
+
"generate_audio": inputs.generate_audio,
|
|
452
|
+
"keyframe": inputs.keyframes,
|
|
453
|
+
"public_figure_threshold": inputs.public_figure_threshold,
|
|
454
|
+
},
|
|
455
|
+
)
|
|
456
|
+
return _validated_video_edit_payload(
|
|
457
|
+
{
|
|
458
|
+
"prompt": inputs.prompt,
|
|
459
|
+
"model": inputs.model,
|
|
460
|
+
"duration": inputs.duration,
|
|
461
|
+
"resolution": inputs.resolution,
|
|
462
|
+
"aspect_ratio": inputs.aspect_ratio,
|
|
463
|
+
"video_url": media_value(inputs.video) if inputs.video else None,
|
|
464
|
+
"reference_image_urls": [media_value(value) for value in inputs.images],
|
|
465
|
+
"seed": inputs.seed,
|
|
466
|
+
"callback_url": inputs.callback_url,
|
|
467
|
+
}
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _parse_aleph_keyframe(value: str) -> dict[str, Any]:
|
|
472
|
+
try:
|
|
473
|
+
keyframe = json.loads(value)
|
|
474
|
+
except json.JSONDecodeError as exc:
|
|
475
|
+
raise InputParsingError("Unable to parse Aleph 2 keyframe JSON") from exc
|
|
476
|
+
if not isinstance(keyframe, dict):
|
|
477
|
+
raise InputParsingError("Aleph 2 keyframe must be a JSON object")
|
|
478
|
+
image_url = keyframe.get("image_url")
|
|
479
|
+
if isinstance(image_url, str):
|
|
480
|
+
keyframe["image_url"] = media_value(image_url)
|
|
481
|
+
return keyframe
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _build_aleph_video_edit_payload(inputs: _VideoEditInputs) -> dict[str, Any]:
|
|
485
|
+
_reject_video_edit_options(
|
|
486
|
+
inputs.model,
|
|
487
|
+
{
|
|
488
|
+
"image": inputs.images,
|
|
489
|
+
"audio": inputs.audio,
|
|
490
|
+
"duration": inputs.duration,
|
|
491
|
+
"resolution": inputs.resolution,
|
|
492
|
+
"generate_audio": inputs.generate_audio,
|
|
493
|
+
},
|
|
494
|
+
)
|
|
495
|
+
return _validated_video_edit_payload(
|
|
496
|
+
{
|
|
497
|
+
"prompt": inputs.prompt,
|
|
498
|
+
"model": inputs.model,
|
|
499
|
+
"video_url": media_value(inputs.video) if inputs.video else None,
|
|
500
|
+
"keyframes": [_parse_aleph_keyframe(value) for value in inputs.keyframes],
|
|
501
|
+
"seed": inputs.seed,
|
|
502
|
+
"target_aspect_ratio": inputs.aspect_ratio,
|
|
503
|
+
"public_figure_threshold": inputs.public_figure_threshold,
|
|
504
|
+
"callback_url": inputs.callback_url,
|
|
505
|
+
}
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
_VIDEO_EDIT_BUILDERS: dict[str, Callable[[_VideoEditInputs], dict[str, Any]]] = {
|
|
510
|
+
"seedance2": _build_seedance_video_edit_payload,
|
|
511
|
+
"seedance2-mini": _build_seedance_video_edit_payload,
|
|
512
|
+
"kling-3-omni": _build_kling_video_edit_payload,
|
|
513
|
+
"wan2.7": _build_wan_video_edit_payload,
|
|
514
|
+
"aleph2": _build_aleph_video_edit_payload,
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def build_video_edit_payload(
|
|
519
|
+
*,
|
|
520
|
+
prompt: str | None,
|
|
521
|
+
images: list[str],
|
|
522
|
+
video: str | None,
|
|
523
|
+
audio: str | None,
|
|
524
|
+
duration: int | None,
|
|
525
|
+
resolution: str | None,
|
|
526
|
+
aspect_ratio: str | None,
|
|
527
|
+
generate_audio: bool | None,
|
|
528
|
+
callback_url: str | None,
|
|
529
|
+
model: str = "seedance2",
|
|
530
|
+
seed: int | None = None,
|
|
531
|
+
keyframes: list[str] | None = None,
|
|
532
|
+
public_figure_threshold: str | None = None,
|
|
533
|
+
) -> dict[str, Any]:
|
|
534
|
+
"""Build a model-specific JSON ``video_edit`` payload."""
|
|
535
|
+
|
|
536
|
+
inputs = _VideoEditInputs(
|
|
537
|
+
prompt=prompt,
|
|
538
|
+
model=model,
|
|
539
|
+
images=images,
|
|
540
|
+
video=video,
|
|
541
|
+
audio=audio,
|
|
542
|
+
duration=duration,
|
|
543
|
+
resolution=resolution,
|
|
544
|
+
aspect_ratio=aspect_ratio,
|
|
545
|
+
seed=seed,
|
|
546
|
+
generate_audio=generate_audio,
|
|
547
|
+
callback_url=callback_url,
|
|
548
|
+
keyframes=keyframes or [],
|
|
549
|
+
public_figure_threshold=public_figure_threshold,
|
|
550
|
+
)
|
|
551
|
+
builder = _VIDEO_EDIT_BUILDERS.get(model)
|
|
552
|
+
if builder is None:
|
|
553
|
+
raise ValidationCLIError(
|
|
554
|
+
"Unsupported video edit model",
|
|
555
|
+
details={"model": model},
|
|
556
|
+
)
|
|
557
|
+
return builder(inputs)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def build_image_generate_payload(**values: Any) -> dict[str, Any]:
|
|
561
|
+
"""Validate and build image generation payloads including local media."""
|
|
562
|
+
|
|
563
|
+
image = values.pop("image", None)
|
|
564
|
+
return ImageGeneratePayload(
|
|
565
|
+
**{key: value for key, value in values.items() if value is not None},
|
|
566
|
+
image_url=media_value(image) if image else None,
|
|
567
|
+
).model_dump(exclude_none=True)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def build_image_edit_payload(**values: Any) -> dict[str, Any]:
|
|
571
|
+
"""Validate image edit payloads and encode every local source image."""
|
|
572
|
+
|
|
573
|
+
images = values.pop("images")
|
|
574
|
+
return ImageEditPayload(
|
|
575
|
+
**{key: value for key, value in values.items() if value is not None},
|
|
576
|
+
image_urls=[media_value(item) for item in images],
|
|
577
|
+
).model_dump(exclude_none=True)
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def build_image_upscale_payload(**values: Any) -> dict[str, Any]:
|
|
581
|
+
"""Build an image upscale request from one URL/file/task source."""
|
|
582
|
+
|
|
583
|
+
image = values.pop("image", None)
|
|
584
|
+
return ImageUpscalePayload(
|
|
585
|
+
**values,
|
|
586
|
+
image_url=media_value(image) if image else None,
|
|
587
|
+
).model_dump(exclude_none=True)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def build_video_generate_payload(**values: Any) -> dict[str, Any]:
|
|
591
|
+
"""Build text/image video generation payloads with typed model validation."""
|
|
592
|
+
|
|
593
|
+
image = values.pop("image", None)
|
|
594
|
+
last_image = values.pop("last_image", None)
|
|
595
|
+
reference_images = values.pop("reference_images", [])
|
|
596
|
+
reference_videos = values.pop("reference_videos", [])
|
|
597
|
+
if values.get("duration") is None:
|
|
598
|
+
model = values.get("model")
|
|
599
|
+
required_duration_defaults = {
|
|
600
|
+
"wan22": 5,
|
|
601
|
+
"ltx23": 5,
|
|
602
|
+
"grok": 5,
|
|
603
|
+
"grok15": 5,
|
|
604
|
+
}
|
|
605
|
+
values["duration"] = (
|
|
606
|
+
required_duration_defaults.get(model) if isinstance(model, str) else None
|
|
607
|
+
)
|
|
608
|
+
payload = {
|
|
609
|
+
**values,
|
|
610
|
+
"image_url": media_value(image) if image else None,
|
|
611
|
+
"image_last_url": media_value(last_image) if last_image else None,
|
|
612
|
+
}
|
|
613
|
+
if reference_images:
|
|
614
|
+
payload["reference_image_urls"] = [
|
|
615
|
+
media_value(value) for value in reference_images
|
|
616
|
+
]
|
|
617
|
+
if reference_videos:
|
|
618
|
+
payload["reference_video_urls"] = [
|
|
619
|
+
media_value(value) for value in reference_videos
|
|
620
|
+
]
|
|
621
|
+
validated = _VIDEO_GENERATE_ADAPTER.validate_python(
|
|
622
|
+
{key: value for key, value in payload.items() if value is not None}
|
|
623
|
+
)
|
|
624
|
+
return validated.model_dump(mode="json", exclude_none=True)
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def build_video_upscale_payload(**values: Any) -> dict[str, Any]:
|
|
628
|
+
"""Build a video upscale request."""
|
|
629
|
+
|
|
630
|
+
video = values.pop("video", None)
|
|
631
|
+
return VideoUpscalePayload(
|
|
632
|
+
**values,
|
|
633
|
+
video_url=media_value(video) if video else None,
|
|
634
|
+
).model_dump(exclude_none=True)
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def build_lipsync_video_payload(**values: Any) -> dict[str, Any]:
|
|
638
|
+
"""Build a lipsync video request."""
|
|
639
|
+
|
|
640
|
+
video = values.pop("video")
|
|
641
|
+
audio = values.pop("audio")
|
|
642
|
+
return LipsyncVideoPayload(
|
|
643
|
+
**values, video_url=media_value(video), audio_url=media_value(audio)
|
|
644
|
+
).model_dump(exclude_none=True)
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def build_lipsync_image_payload(**values: Any) -> dict[str, Any]:
|
|
648
|
+
"""Build a lipsync image request."""
|
|
649
|
+
|
|
650
|
+
image = values.pop("image")
|
|
651
|
+
audio = values.pop("audio")
|
|
652
|
+
return LipsyncImagePayload(
|
|
653
|
+
**values, image_url=media_value(image), audio_url=media_value(audio)
|
|
654
|
+
).model_dump(exclude_none=True)
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def build_media_payload(
|
|
658
|
+
*, media_key: str, media: str, values: Mapping[str, Any]
|
|
659
|
+
) -> dict[str, Any]:
|
|
660
|
+
"""Build simple JSON endpoints that accept one local or remote media value."""
|
|
661
|
+
|
|
662
|
+
return {media_key: media_value(media), **dict(values)}
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def build_image_angles_payload(**values: Any) -> dict[str, Any]:
|
|
666
|
+
"""Build the image angle request and omit only absent optional values."""
|
|
667
|
+
|
|
668
|
+
image = values.pop("image")
|
|
669
|
+
return {
|
|
670
|
+
"image_url": media_value(image),
|
|
671
|
+
**{key: value for key, value in values.items() if value is not None},
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def build_image_colors_payload(*, image: str, reference: str) -> dict[str, Any]:
|
|
676
|
+
"""Build the two-image color transfer payload."""
|
|
677
|
+
|
|
678
|
+
return {
|
|
679
|
+
"image_url": media_value(image),
|
|
680
|
+
"image_reference_url": media_value(reference),
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def read_text_input(text: str | None, text_file: Path | None) -> str:
|
|
685
|
+
"""Read a text argument in the application layer."""
|
|
686
|
+
|
|
687
|
+
if text_file:
|
|
688
|
+
try:
|
|
689
|
+
return text_file.read_text(encoding="utf-8")
|
|
690
|
+
except OSError as exc:
|
|
691
|
+
raise FileReadError(
|
|
692
|
+
"Unable to read text input", details={"path": str(text_file)}
|
|
693
|
+
) from exc
|
|
694
|
+
if text is None:
|
|
695
|
+
raise ValidationCLIError("--text or --text-file is required")
|
|
696
|
+
return text
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def build_tts_create_payload(**values: Any) -> dict[str, Any]:
|
|
700
|
+
"""Build a text-to-speech creation request."""
|
|
701
|
+
|
|
702
|
+
text = values.pop("text")
|
|
703
|
+
text_file = values.pop("text_file")
|
|
704
|
+
return {"text": read_text_input(text, text_file), **values}
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def build_tts_voice_payload(**values: Any) -> dict[str, Any]:
|
|
708
|
+
"""Build a character voice text-to-speech request."""
|
|
709
|
+
|
|
710
|
+
text = values.pop("text")
|
|
711
|
+
text_file = values.pop("text_file")
|
|
712
|
+
return {"text": read_text_input(text, text_file), **values}
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def build_tts_clone_payload(**values: Any) -> dict[str, Any]:
|
|
716
|
+
"""Build a cloned-voice text-to-speech request."""
|
|
717
|
+
|
|
718
|
+
audio = values.pop("audio")
|
|
719
|
+
text = values.pop("text")
|
|
720
|
+
text_file = values.pop("text_file")
|
|
721
|
+
return {
|
|
722
|
+
"audio_url": media_value(audio),
|
|
723
|
+
"text": read_text_input(text, text_file),
|
|
724
|
+
**values,
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
@dataclass
|
|
729
|
+
class ApplicationServices:
|
|
730
|
+
"""Small composition root usable by CLI and future MCP handlers."""
|
|
731
|
+
|
|
732
|
+
_operations: OperationService
|
|
733
|
+
|
|
734
|
+
@classmethod
|
|
735
|
+
def with_client(cls, client: EverypixelClientProtocol) -> "ApplicationServices":
|
|
736
|
+
return cls(_operations=OperationService(client))
|
|
737
|
+
|
|
738
|
+
def close(self) -> None:
|
|
739
|
+
"""Close owned transport resources when the client exposes a lifecycle."""
|
|
740
|
+
|
|
741
|
+
close = getattr(self._operations.client, "close", None)
|
|
742
|
+
if callable(close):
|
|
743
|
+
close()
|
|
744
|
+
|
|
745
|
+
def _execute_async(
|
|
746
|
+
self,
|
|
747
|
+
*,
|
|
748
|
+
endpoint: str,
|
|
749
|
+
payload: Mapping[str, Any],
|
|
750
|
+
execution: ExecutionOptions,
|
|
751
|
+
fallback_extension: str,
|
|
752
|
+
) -> OperationResult:
|
|
753
|
+
return self._operations.execute(
|
|
754
|
+
OperationRequest(
|
|
755
|
+
endpoint=endpoint,
|
|
756
|
+
payload=payload,
|
|
757
|
+
execution=execution,
|
|
758
|
+
fallback_extension=fallback_extension,
|
|
759
|
+
)
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
def execute_image_generate(
|
|
763
|
+
self,
|
|
764
|
+
*,
|
|
765
|
+
prompt: str,
|
|
766
|
+
model: str,
|
|
767
|
+
image_size: str,
|
|
768
|
+
style: str | None,
|
|
769
|
+
image: str | None,
|
|
770
|
+
resolution: str | None,
|
|
771
|
+
seed: int,
|
|
772
|
+
callback_url: str | None,
|
|
773
|
+
execution: ExecutionOptions,
|
|
774
|
+
) -> OperationResult:
|
|
775
|
+
payload = build_image_generate_payload(
|
|
776
|
+
prompt=prompt,
|
|
777
|
+
model=model,
|
|
778
|
+
image_size=image_size,
|
|
779
|
+
style=style,
|
|
780
|
+
image=image,
|
|
781
|
+
resolution=resolution,
|
|
782
|
+
seed=seed,
|
|
783
|
+
callback_url=callback_url,
|
|
784
|
+
)
|
|
785
|
+
return self._execute_async(
|
|
786
|
+
endpoint="/v1/image_generate",
|
|
787
|
+
payload=payload,
|
|
788
|
+
execution=execution,
|
|
789
|
+
fallback_extension=".png",
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
def execute_image_edit(
|
|
793
|
+
self,
|
|
794
|
+
*,
|
|
795
|
+
prompt: str,
|
|
796
|
+
images: list[str],
|
|
797
|
+
model: str,
|
|
798
|
+
image_size: str | None,
|
|
799
|
+
resolution: str | None,
|
|
800
|
+
seed: int,
|
|
801
|
+
callback_url: str | None,
|
|
802
|
+
execution: ExecutionOptions,
|
|
803
|
+
) -> OperationResult:
|
|
804
|
+
payload = build_image_edit_payload(
|
|
805
|
+
prompt=prompt,
|
|
806
|
+
images=images,
|
|
807
|
+
model=model,
|
|
808
|
+
image_size=image_size,
|
|
809
|
+
resolution=resolution,
|
|
810
|
+
seed=seed,
|
|
811
|
+
callback_url=callback_url,
|
|
812
|
+
)
|
|
813
|
+
return self._execute_async(
|
|
814
|
+
endpoint="/v1/image_edit",
|
|
815
|
+
payload=payload,
|
|
816
|
+
execution=execution,
|
|
817
|
+
fallback_extension=".png",
|
|
818
|
+
)
|
|
819
|
+
|
|
820
|
+
def execute_image_upscale(
|
|
821
|
+
self,
|
|
822
|
+
*,
|
|
823
|
+
image: str | None,
|
|
824
|
+
task_id: str | None,
|
|
825
|
+
model: str,
|
|
826
|
+
callback_url: str | None,
|
|
827
|
+
execution: ExecutionOptions,
|
|
828
|
+
) -> OperationResult:
|
|
829
|
+
payload = build_image_upscale_payload(
|
|
830
|
+
image=image,
|
|
831
|
+
image_from_task_id=task_id,
|
|
832
|
+
model=model,
|
|
833
|
+
callback_url=callback_url,
|
|
834
|
+
)
|
|
835
|
+
return self._execute_async(
|
|
836
|
+
endpoint="/v1/image_upscale",
|
|
837
|
+
payload=payload,
|
|
838
|
+
execution=execution,
|
|
839
|
+
fallback_extension=".jpg",
|
|
840
|
+
)
|
|
841
|
+
|
|
842
|
+
def execute_image_angles(
|
|
843
|
+
self,
|
|
844
|
+
*,
|
|
845
|
+
image: str,
|
|
846
|
+
azimuth: str,
|
|
847
|
+
elevation: str,
|
|
848
|
+
distance: str,
|
|
849
|
+
prompt: str | None,
|
|
850
|
+
execution: ExecutionOptions,
|
|
851
|
+
) -> OperationResult:
|
|
852
|
+
payload = build_image_angles_payload(
|
|
853
|
+
image=image,
|
|
854
|
+
azimuth=azimuth,
|
|
855
|
+
elevation=elevation,
|
|
856
|
+
distance=distance,
|
|
857
|
+
prompt=prompt,
|
|
858
|
+
)
|
|
859
|
+
return self._execute_async(
|
|
860
|
+
endpoint="/v1/image_edit_angles",
|
|
861
|
+
payload=payload,
|
|
862
|
+
execution=execution,
|
|
863
|
+
fallback_extension=".png",
|
|
864
|
+
)
|
|
865
|
+
|
|
866
|
+
def execute_image_colors(
|
|
867
|
+
self,
|
|
868
|
+
*,
|
|
869
|
+
image: str,
|
|
870
|
+
reference: str,
|
|
871
|
+
execution: ExecutionOptions,
|
|
872
|
+
) -> OperationResult:
|
|
873
|
+
return self._execute_async(
|
|
874
|
+
endpoint="/v1/image_edit_colors",
|
|
875
|
+
payload=build_image_colors_payload(image=image, reference=reference),
|
|
876
|
+
execution=execution,
|
|
877
|
+
fallback_extension=".png",
|
|
878
|
+
)
|
|
879
|
+
|
|
880
|
+
def execute_video_generate(
|
|
881
|
+
self,
|
|
882
|
+
*,
|
|
883
|
+
prompt: str,
|
|
884
|
+
model: str,
|
|
885
|
+
duration: int | None,
|
|
886
|
+
resolution: str | None,
|
|
887
|
+
aspect_ratio: str | None,
|
|
888
|
+
lora_high_url: str | None = None,
|
|
889
|
+
lora_low_url: str | None = None,
|
|
890
|
+
reference_images: list[str] | None = None,
|
|
891
|
+
reference_videos: list[str] | None = None,
|
|
892
|
+
image: str | None = None,
|
|
893
|
+
last_image: str | None = None,
|
|
894
|
+
seed: int | None = None,
|
|
895
|
+
generate_audio: bool | None = None,
|
|
896
|
+
callback_url: str | None = None,
|
|
897
|
+
execution: ExecutionOptions,
|
|
898
|
+
) -> OperationResult:
|
|
899
|
+
payload = build_video_generate_payload(
|
|
900
|
+
prompt=prompt,
|
|
901
|
+
model=model,
|
|
902
|
+
duration=duration,
|
|
903
|
+
resolution=resolution,
|
|
904
|
+
aspect_ratio=aspect_ratio,
|
|
905
|
+
lora_high_url=lora_high_url,
|
|
906
|
+
lora_low_url=lora_low_url,
|
|
907
|
+
reference_images=reference_images or [],
|
|
908
|
+
reference_videos=reference_videos or [],
|
|
909
|
+
image=image,
|
|
910
|
+
last_image=last_image,
|
|
911
|
+
seed=seed,
|
|
912
|
+
generate_audio=generate_audio,
|
|
913
|
+
callback_url=callback_url,
|
|
914
|
+
)
|
|
915
|
+
return self._execute_async(
|
|
916
|
+
endpoint="/v1/video_generate",
|
|
917
|
+
payload=payload,
|
|
918
|
+
execution=execution,
|
|
919
|
+
fallback_extension=".mp4",
|
|
920
|
+
)
|
|
921
|
+
|
|
922
|
+
def execute_video_edit(
|
|
923
|
+
self,
|
|
924
|
+
*,
|
|
925
|
+
prompt: str | None,
|
|
926
|
+
model: str,
|
|
927
|
+
images: list[str],
|
|
928
|
+
video: str | None,
|
|
929
|
+
audio: str | None,
|
|
930
|
+
duration: int | None,
|
|
931
|
+
resolution: str | None,
|
|
932
|
+
aspect_ratio: str | None,
|
|
933
|
+
seed: int | None,
|
|
934
|
+
generate_audio: bool | None,
|
|
935
|
+
callback_url: str | None,
|
|
936
|
+
keyframes: list[str],
|
|
937
|
+
public_figure_threshold: str | None,
|
|
938
|
+
execution: ExecutionOptions,
|
|
939
|
+
) -> OperationResult:
|
|
940
|
+
payload = build_video_edit_payload(
|
|
941
|
+
prompt=prompt,
|
|
942
|
+
model=model,
|
|
943
|
+
images=images,
|
|
944
|
+
video=video,
|
|
945
|
+
audio=audio,
|
|
946
|
+
duration=duration,
|
|
947
|
+
resolution=resolution,
|
|
948
|
+
aspect_ratio=aspect_ratio,
|
|
949
|
+
seed=seed,
|
|
950
|
+
generate_audio=generate_audio,
|
|
951
|
+
callback_url=callback_url,
|
|
952
|
+
keyframes=keyframes,
|
|
953
|
+
public_figure_threshold=public_figure_threshold,
|
|
954
|
+
)
|
|
955
|
+
return self._execute_async(
|
|
956
|
+
endpoint="/v1/video_edit",
|
|
957
|
+
payload=payload,
|
|
958
|
+
execution=execution,
|
|
959
|
+
fallback_extension=".mp4",
|
|
960
|
+
)
|
|
961
|
+
|
|
962
|
+
def execute_video_upscale(
|
|
963
|
+
self,
|
|
964
|
+
*,
|
|
965
|
+
video: str | None,
|
|
966
|
+
task_id: str | None,
|
|
967
|
+
resolution: str,
|
|
968
|
+
execution: ExecutionOptions,
|
|
969
|
+
) -> OperationResult:
|
|
970
|
+
payload = build_video_upscale_payload(
|
|
971
|
+
video=video,
|
|
972
|
+
video_from_task_id=task_id,
|
|
973
|
+
resolution=resolution,
|
|
974
|
+
)
|
|
975
|
+
return self._execute_async(
|
|
976
|
+
endpoint="/v1/video_upscale",
|
|
977
|
+
payload=payload,
|
|
978
|
+
execution=execution,
|
|
979
|
+
fallback_extension=".mp4",
|
|
980
|
+
)
|
|
981
|
+
|
|
982
|
+
def execute_lipsync_video(
|
|
983
|
+
self,
|
|
984
|
+
*,
|
|
985
|
+
video: str,
|
|
986
|
+
audio: str,
|
|
987
|
+
resolution: str,
|
|
988
|
+
prompt: str | None,
|
|
989
|
+
seed: int,
|
|
990
|
+
callback_url: str | None,
|
|
991
|
+
execution: ExecutionOptions,
|
|
992
|
+
) -> OperationResult:
|
|
993
|
+
payload = build_lipsync_video_payload(
|
|
994
|
+
video=video,
|
|
995
|
+
audio=audio,
|
|
996
|
+
resolution=resolution,
|
|
997
|
+
prompt=prompt,
|
|
998
|
+
seed=seed,
|
|
999
|
+
callback_url=callback_url,
|
|
1000
|
+
)
|
|
1001
|
+
return self._execute_async(
|
|
1002
|
+
endpoint="/v1/video_lipsync",
|
|
1003
|
+
payload=payload,
|
|
1004
|
+
execution=execution,
|
|
1005
|
+
fallback_extension=".mp4",
|
|
1006
|
+
)
|
|
1007
|
+
|
|
1008
|
+
def execute_lipsync_image(
|
|
1009
|
+
self,
|
|
1010
|
+
*,
|
|
1011
|
+
image: str,
|
|
1012
|
+
audio: str,
|
|
1013
|
+
model: str,
|
|
1014
|
+
resolution: str,
|
|
1015
|
+
prompt: str | None,
|
|
1016
|
+
seed: int,
|
|
1017
|
+
callback_url: str | None,
|
|
1018
|
+
execution: ExecutionOptions,
|
|
1019
|
+
) -> OperationResult:
|
|
1020
|
+
payload = build_lipsync_image_payload(
|
|
1021
|
+
image=image,
|
|
1022
|
+
audio=audio,
|
|
1023
|
+
model=model,
|
|
1024
|
+
resolution=resolution,
|
|
1025
|
+
prompt=prompt,
|
|
1026
|
+
seed=seed,
|
|
1027
|
+
callback_url=callback_url,
|
|
1028
|
+
)
|
|
1029
|
+
return self._execute_async(
|
|
1030
|
+
endpoint="/v1/image_lipsync",
|
|
1031
|
+
payload=payload,
|
|
1032
|
+
execution=execution,
|
|
1033
|
+
fallback_extension=".mp4",
|
|
1034
|
+
)
|
|
1035
|
+
|
|
1036
|
+
def execute_audio_transcribe(
|
|
1037
|
+
self,
|
|
1038
|
+
*,
|
|
1039
|
+
audio: str,
|
|
1040
|
+
language: str,
|
|
1041
|
+
hints: str,
|
|
1042
|
+
denoise: bool,
|
|
1043
|
+
execution: ExecutionOptions,
|
|
1044
|
+
) -> OperationResult:
|
|
1045
|
+
payload = build_media_payload(
|
|
1046
|
+
media_key="audio_url",
|
|
1047
|
+
media=audio,
|
|
1048
|
+
values={"language": language, "hints": hints, "denoise": denoise},
|
|
1049
|
+
)
|
|
1050
|
+
return self._execute_async(
|
|
1051
|
+
endpoint="/v1/transcribe",
|
|
1052
|
+
payload=payload,
|
|
1053
|
+
execution=execution,
|
|
1054
|
+
fallback_extension=".txt",
|
|
1055
|
+
)
|
|
1056
|
+
|
|
1057
|
+
def execute_tts_create(
|
|
1058
|
+
self,
|
|
1059
|
+
*,
|
|
1060
|
+
text: str | None,
|
|
1061
|
+
text_file: Path | None,
|
|
1062
|
+
speaker: str,
|
|
1063
|
+
style: str,
|
|
1064
|
+
language: str,
|
|
1065
|
+
prompt: str,
|
|
1066
|
+
seed: int,
|
|
1067
|
+
execution: ExecutionOptions,
|
|
1068
|
+
) -> OperationResult:
|
|
1069
|
+
payload = build_tts_create_payload(
|
|
1070
|
+
text=text,
|
|
1071
|
+
text_file=text_file,
|
|
1072
|
+
speaker=speaker,
|
|
1073
|
+
style=style,
|
|
1074
|
+
language=language,
|
|
1075
|
+
prompt=prompt,
|
|
1076
|
+
seed=seed,
|
|
1077
|
+
)
|
|
1078
|
+
return self._execute_async(
|
|
1079
|
+
endpoint="/v1/tts_create",
|
|
1080
|
+
payload=payload,
|
|
1081
|
+
execution=execution,
|
|
1082
|
+
fallback_extension=".mp3",
|
|
1083
|
+
)
|
|
1084
|
+
|
|
1085
|
+
def execute_tts_clone(
|
|
1086
|
+
self,
|
|
1087
|
+
*,
|
|
1088
|
+
audio: str,
|
|
1089
|
+
text: str | None,
|
|
1090
|
+
text_file: Path | None,
|
|
1091
|
+
language: str,
|
|
1092
|
+
seed: int,
|
|
1093
|
+
execution: ExecutionOptions,
|
|
1094
|
+
) -> OperationResult:
|
|
1095
|
+
payload = build_tts_clone_payload(
|
|
1096
|
+
audio=audio,
|
|
1097
|
+
text=text,
|
|
1098
|
+
text_file=text_file,
|
|
1099
|
+
language=language,
|
|
1100
|
+
seed=seed,
|
|
1101
|
+
)
|
|
1102
|
+
return self._execute_async(
|
|
1103
|
+
endpoint="/v1/tts_clone",
|
|
1104
|
+
payload=payload,
|
|
1105
|
+
execution=execution,
|
|
1106
|
+
fallback_extension=".mp3",
|
|
1107
|
+
)
|
|
1108
|
+
|
|
1109
|
+
def execute_tts_voice(
|
|
1110
|
+
self,
|
|
1111
|
+
*,
|
|
1112
|
+
text: str | None,
|
|
1113
|
+
text_file: Path | None,
|
|
1114
|
+
character: str,
|
|
1115
|
+
style: str,
|
|
1116
|
+
language: str,
|
|
1117
|
+
prompt: str,
|
|
1118
|
+
seed: int,
|
|
1119
|
+
execution: ExecutionOptions,
|
|
1120
|
+
) -> OperationResult:
|
|
1121
|
+
payload = build_tts_voice_payload(
|
|
1122
|
+
text=text,
|
|
1123
|
+
text_file=text_file,
|
|
1124
|
+
character=character,
|
|
1125
|
+
style=style,
|
|
1126
|
+
language=language,
|
|
1127
|
+
prompt=prompt,
|
|
1128
|
+
seed=seed,
|
|
1129
|
+
)
|
|
1130
|
+
return self._execute_async(
|
|
1131
|
+
endpoint="/v1/tts_voice",
|
|
1132
|
+
payload=payload,
|
|
1133
|
+
execution=execution,
|
|
1134
|
+
fallback_extension=".mp3",
|
|
1135
|
+
)
|
|
1136
|
+
|
|
1137
|
+
def get_task_status(
|
|
1138
|
+
self, *, task_id: str, execution: ExecutionOptions
|
|
1139
|
+
) -> OperationResult:
|
|
1140
|
+
return self._operations.status(task_id, execution)
|
|
1141
|
+
|
|
1142
|
+
def wait_for_task(
|
|
1143
|
+
self, *, task_id: str, execution: ExecutionOptions
|
|
1144
|
+
) -> OperationResult:
|
|
1145
|
+
return self._operations.wait(task_id, execution)
|
|
1146
|
+
|
|
1147
|
+
def execute_generic(
|
|
1148
|
+
self,
|
|
1149
|
+
*,
|
|
1150
|
+
endpoint: str,
|
|
1151
|
+
payload: dict[str, Any],
|
|
1152
|
+
method: str | None,
|
|
1153
|
+
execution: ExecutionOptions,
|
|
1154
|
+
dry_run: bool = False,
|
|
1155
|
+
help_schema: bool = False,
|
|
1156
|
+
client_id_present: bool = False,
|
|
1157
|
+
schema_loader: Callable[
|
|
1158
|
+
[EverypixelClientProtocol], tuple[dict[str, Any], str]
|
|
1159
|
+
] = load_schema,
|
|
1160
|
+
) -> OperationResult:
|
|
1161
|
+
schema_source: str | None = None
|
|
1162
|
+
resolved_method = method.upper() if method else None
|
|
1163
|
+
operation = None
|
|
1164
|
+
if not endpoint.startswith("/"):
|
|
1165
|
+
schema, schema_source = schema_loader(self._operations.client)
|
|
1166
|
+
operation = find_operation(schema, endpoint, resolved_method)
|
|
1167
|
+
path = endpoint if endpoint.startswith("/") else f"/v1/{endpoint}"
|
|
1168
|
+
if operation:
|
|
1169
|
+
path = operation.path
|
|
1170
|
+
resolved_method = operation.method
|
|
1171
|
+
resolved_method = resolved_method or "POST"
|
|
1172
|
+
if path == "/v1/video_edit":
|
|
1173
|
+
normalize_video_edit_content(payload)
|
|
1174
|
+
if help_schema:
|
|
1175
|
+
return OperationResult(
|
|
1176
|
+
value=operation_help(
|
|
1177
|
+
operation,
|
|
1178
|
+
endpoint=endpoint,
|
|
1179
|
+
method=resolved_method,
|
|
1180
|
+
path=path,
|
|
1181
|
+
schema_source=schema_source,
|
|
1182
|
+
)
|
|
1183
|
+
)
|
|
1184
|
+
if path == "/v1/video_edit":
|
|
1185
|
+
# Generic callers may send forward-compatible fields that the local
|
|
1186
|
+
# specialized model does not know yet; validate known semantics
|
|
1187
|
+
# without rewriting or narrowing their payload.
|
|
1188
|
+
_VIDEO_EDIT_ADAPTER.validate_python(payload, extra="ignore")
|
|
1189
|
+
validate_payload_against_operation(operation, payload)
|
|
1190
|
+
if dry_run:
|
|
1191
|
+
return OperationResult(
|
|
1192
|
+
value={
|
|
1193
|
+
"method": resolved_method,
|
|
1194
|
+
"path": path,
|
|
1195
|
+
"body": payload,
|
|
1196
|
+
"headers": {
|
|
1197
|
+
"Authorization": "Basic ***" if client_id_present else None
|
|
1198
|
+
},
|
|
1199
|
+
"schema_source": schema_source,
|
|
1200
|
+
}
|
|
1201
|
+
)
|
|
1202
|
+
return self._operations.execute(
|
|
1203
|
+
OperationRequest(
|
|
1204
|
+
endpoint=path,
|
|
1205
|
+
method=resolved_method,
|
|
1206
|
+
payload=payload,
|
|
1207
|
+
execution=execution,
|
|
1208
|
+
fallback_extension=fallback_extension_for_endpoint(path),
|
|
1209
|
+
)
|
|
1210
|
+
)
|
|
1211
|
+
|
|
1212
|
+
def _execute_classic(
|
|
1213
|
+
self, *, path: str, media: str, params: Mapping[str, Any] | None = None
|
|
1214
|
+
) -> OperationResult:
|
|
1215
|
+
"""Execute URL or upload based classic media endpoints."""
|
|
1216
|
+
|
|
1217
|
+
method, request_params, local_file = build_classic_request(
|
|
1218
|
+
media=media, params=params
|
|
1219
|
+
)
|
|
1220
|
+
if local_file is None:
|
|
1221
|
+
return OperationResult(
|
|
1222
|
+
value=self._operations.client.request(
|
|
1223
|
+
method, path, params=request_params
|
|
1224
|
+
)
|
|
1225
|
+
)
|
|
1226
|
+
try:
|
|
1227
|
+
with local_file.open("rb") as file_handle:
|
|
1228
|
+
response = self._operations.client.request(
|
|
1229
|
+
method,
|
|
1230
|
+
path,
|
|
1231
|
+
params=request_params,
|
|
1232
|
+
files={"data": (local_file.name, file_handle)},
|
|
1233
|
+
)
|
|
1234
|
+
except OSError as exc:
|
|
1235
|
+
raise FileReadError(
|
|
1236
|
+
"Unable to read input file", details={"path": str(local_file)}
|
|
1237
|
+
) from exc
|
|
1238
|
+
return OperationResult(value=response)
|
|
1239
|
+
|
|
1240
|
+
def execute_keywords(
|
|
1241
|
+
self,
|
|
1242
|
+
*,
|
|
1243
|
+
image: str,
|
|
1244
|
+
lang: str,
|
|
1245
|
+
num_keywords: int | None,
|
|
1246
|
+
colors: bool,
|
|
1247
|
+
) -> OperationResult:
|
|
1248
|
+
return self._execute_classic(
|
|
1249
|
+
path="/v1/keywords",
|
|
1250
|
+
media=image,
|
|
1251
|
+
params={"lang": lang, "num_keywords": num_keywords, "colors": colors},
|
|
1252
|
+
)
|
|
1253
|
+
|
|
1254
|
+
def execute_quality(self, *, image: str) -> OperationResult:
|
|
1255
|
+
return self._execute_classic(path="/v1/quality", media=image)
|
|
1256
|
+
|
|
1257
|
+
def execute_quality_ugc(self, *, image: str) -> OperationResult:
|
|
1258
|
+
return self._execute_classic(path="/v1/quality_ugc", media=image)
|
|
1259
|
+
|
|
1260
|
+
def execute_faces(self, *, image: str) -> OperationResult:
|
|
1261
|
+
return self._execute_classic(path="/v1/faces", media=image)
|
|
1262
|
+
|
|
1263
|
+
def execute_captioning(self, *, image: str) -> OperationResult:
|
|
1264
|
+
return self._execute_classic(path="/v1/image_captioning", media=image)
|
|
1265
|
+
|
|
1266
|
+
def execute_video_keywords(self, *, video: str) -> OperationResult:
|
|
1267
|
+
return self._execute_classic(path="/v1/video_keywords", media=video)
|
|
1268
|
+
|
|
1269
|
+
def check_auth(self) -> OperationResult:
|
|
1270
|
+
"""Run an authentication check without a CLI dependency."""
|
|
1271
|
+
|
|
1272
|
+
self._operations.client.check_auth()
|
|
1273
|
+
return OperationResult(value={"status": "ok"})
|
|
1274
|
+
|
|
1275
|
+
def openapi(self) -> OperationResult:
|
|
1276
|
+
"""Read the selected OpenAPI schema through the infrastructure seam."""
|
|
1277
|
+
|
|
1278
|
+
return OperationResult(value=load_schema(self._operations.client)[0])
|
|
1279
|
+
|
|
1280
|
+
def refresh_openapi(self) -> tuple[OperationResult, Path]:
|
|
1281
|
+
"""Refresh the OpenAPI schema cache through the infrastructure seam."""
|
|
1282
|
+
|
|
1283
|
+
from ..openapi import refresh_schema
|
|
1284
|
+
|
|
1285
|
+
schema, path = refresh_schema(self._operations.client)
|
|
1286
|
+
return OperationResult(value=schema), path
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
def parse_generic_payload(
|
|
1290
|
+
*, prompt: str | None, items: list[str], input_file: Path | None
|
|
1291
|
+
) -> dict[str, Any]:
|
|
1292
|
+
"""Parse generic CLI input without depending on Typer or rendering."""
|
|
1293
|
+
|
|
1294
|
+
payload: dict[str, Any] = {}
|
|
1295
|
+
if input_file:
|
|
1296
|
+
try:
|
|
1297
|
+
loaded = json.loads(input_file.read_text(encoding="utf-8"))
|
|
1298
|
+
except OSError as exc:
|
|
1299
|
+
raise FileReadError(
|
|
1300
|
+
"Unable to read input file", details={"path": str(input_file)}
|
|
1301
|
+
) from exc
|
|
1302
|
+
except json.JSONDecodeError as exc:
|
|
1303
|
+
raise InputParsingError(
|
|
1304
|
+
"Unable to parse JSON input", details={"path": str(input_file)}
|
|
1305
|
+
) from exc
|
|
1306
|
+
if not isinstance(loaded, dict):
|
|
1307
|
+
raise InputParsingError(
|
|
1308
|
+
"JSON input must contain an object", details={"path": str(input_file)}
|
|
1309
|
+
)
|
|
1310
|
+
payload.update(loaded)
|
|
1311
|
+
for raw in items:
|
|
1312
|
+
key, separator, value = raw.partition("=")
|
|
1313
|
+
if not key or not separator:
|
|
1314
|
+
raise InputParsingError("Invalid input item", details={"input": raw})
|
|
1315
|
+
try:
|
|
1316
|
+
payload[key] = json.loads(value)
|
|
1317
|
+
except ValueError:
|
|
1318
|
+
payload[key] = value
|
|
1319
|
+
if prompt is not None:
|
|
1320
|
+
payload["prompt"] = prompt
|
|
1321
|
+
return payload
|
|
1322
|
+
|
|
1323
|
+
|
|
1324
|
+
def normalize_video_edit_content(payload: dict[str, Any]) -> None:
|
|
1325
|
+
"""Encode local generic ``video_edit`` references as data URIs."""
|
|
1326
|
+
|
|
1327
|
+
video_url = payload.get("video_url")
|
|
1328
|
+
if isinstance(video_url, str):
|
|
1329
|
+
payload["video_url"] = media_value(video_url)
|
|
1330
|
+
reference_image_urls = payload.get("reference_image_urls")
|
|
1331
|
+
if isinstance(reference_image_urls, list):
|
|
1332
|
+
payload["reference_image_urls"] = [
|
|
1333
|
+
media_value(value) if isinstance(value, str) else value
|
|
1334
|
+
for value in reference_image_urls
|
|
1335
|
+
]
|
|
1336
|
+
keyframes = payload.get("keyframes")
|
|
1337
|
+
if isinstance(keyframes, list):
|
|
1338
|
+
for keyframe in keyframes:
|
|
1339
|
+
if not isinstance(keyframe, dict):
|
|
1340
|
+
continue
|
|
1341
|
+
image_url = keyframe.get("image_url")
|
|
1342
|
+
if isinstance(image_url, str):
|
|
1343
|
+
keyframe["image_url"] = media_value(image_url)
|
|
1344
|
+
|
|
1345
|
+
content = payload.get("content")
|
|
1346
|
+
if not isinstance(content, list):
|
|
1347
|
+
return
|
|
1348
|
+
for item in content:
|
|
1349
|
+
if not isinstance(item, dict):
|
|
1350
|
+
continue
|
|
1351
|
+
media_key = item.get("type")
|
|
1352
|
+
if media_key not in {"image_url", "video_url", "audio_url"}:
|
|
1353
|
+
continue
|
|
1354
|
+
media = item.get(media_key)
|
|
1355
|
+
if isinstance(media, dict) and isinstance(media.get("url"), str):
|
|
1356
|
+
media["url"] = media_value(media["url"])
|
|
1357
|
+
|
|
1358
|
+
|
|
1359
|
+
def build_classic_request(
|
|
1360
|
+
*, media: str, params: Mapping[str, Any] | None = None
|
|
1361
|
+
) -> tuple[str, dict[str, Any], Path | None]:
|
|
1362
|
+
"""Prepare a classic media operation while keeping file I/O out of CLI."""
|
|
1363
|
+
|
|
1364
|
+
clean_params = {
|
|
1365
|
+
key: value for key, value in (params or {}).items() if value is not None
|
|
1366
|
+
}
|
|
1367
|
+
if is_url(media):
|
|
1368
|
+
return "GET", {"url": media, **clean_params}, None
|
|
1369
|
+
target = Path(media)
|
|
1370
|
+
if not target.is_file():
|
|
1371
|
+
raise FileReadError(
|
|
1372
|
+
"Unable to read input file",
|
|
1373
|
+
code="file_not_found",
|
|
1374
|
+
details={"path": str(target)},
|
|
1375
|
+
)
|
|
1376
|
+
return "POST", clean_params, target
|