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.
@@ -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