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,846 @@
1
+ """Pydantic payload schemas for common generation commands.
2
+
3
+ These models validate CLI parameters before a request is sent to the API:
4
+ media sources, model limits, enum values, and numeric ranges.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Annotated, Any, Literal
10
+
11
+ from pydantic import (
12
+ BaseModel,
13
+ ConfigDict,
14
+ Discriminator,
15
+ Field,
16
+ model_validator,
17
+ )
18
+
19
+
20
+ ImageSize = Literal[
21
+ "square",
22
+ "portrait_3_2",
23
+ "portrait_4_3",
24
+ "portrait_16_9",
25
+ "landscape_3_2",
26
+ "landscape_4_3",
27
+ "landscape_16_9",
28
+ ]
29
+ ImageStyle = Literal[
30
+ "instagram",
31
+ "transparent",
32
+ "replication",
33
+ "basic",
34
+ ]
35
+ ImageResolution = Literal["1k", "2k", "3k", "4k"]
36
+ ImageGenerateModel = Literal[
37
+ "zimage",
38
+ "wan22",
39
+ "wan2.7",
40
+ "wan2.7-pro",
41
+ "flux2",
42
+ "grok",
43
+ "grok_quality",
44
+ "gemini-3.1-flash",
45
+ "gemini-3-pro",
46
+ "seedream-5-pro",
47
+ "seedream-5",
48
+ "gpt-image-2-low",
49
+ "gpt-image-2-medium",
50
+ "gpt-image-2-high",
51
+ ]
52
+ ImageEditModel = Literal[
53
+ "flux2",
54
+ "qwen",
55
+ "wan2.7",
56
+ "wan2.7-pro",
57
+ "grok",
58
+ "grok_quality",
59
+ "gemini-3.1-flash",
60
+ "gemini-3-pro",
61
+ "seedream-5-pro",
62
+ "seedream-5",
63
+ "gpt-image-2-low",
64
+ "gpt-image-2-medium",
65
+ "gpt-image-2-high",
66
+ ]
67
+
68
+ _GEMINI_IMAGE_MODELS = {"gemini-3.1-flash", "gemini-3-pro"}
69
+ _WAN_IMAGE_MODELS = {"wan2.7", "wan2.7-pro"}
70
+ _SEEDREAM_IMAGE_MODELS = {"seedream-5-pro", "seedream-5"}
71
+ _GPT_IMAGE_2_MODELS = {
72
+ "gpt-image-2-low",
73
+ "gpt-image-2-medium",
74
+ "gpt-image-2-high",
75
+ }
76
+ _IMAGE_EDIT_MODEL_MAX_IMAGES = {
77
+ "flux2": 5,
78
+ "qwen": 3,
79
+ "wan2.7": 9,
80
+ "wan2.7-pro": 9,
81
+ "grok": 3,
82
+ "grok_quality": 3,
83
+ "gemini-3.1-flash": 5,
84
+ "gemini-3-pro": 5,
85
+ "seedream-5-pro": 10,
86
+ "seedream-5": 14,
87
+ "gpt-image-2-low": 5,
88
+ "gpt-image-2-medium": 5,
89
+ "gpt-image-2-high": 5,
90
+ }
91
+ ImageEditMedia = Annotated[
92
+ list[str],
93
+ Field(min_length=1, max_length=max(_IMAGE_EDIT_MODEL_MAX_IMAGES.values())),
94
+ ]
95
+
96
+
97
+ class TaskResponse(BaseModel):
98
+ """Standard API response when an async task is created."""
99
+
100
+ task_id: str
101
+ status: str = "PENDING"
102
+ result: Any | None = None
103
+ queue: int | None = None
104
+ error: str | None = None
105
+
106
+
107
+ class ImageGeneratePayload(BaseModel):
108
+ """Payload for image generation."""
109
+
110
+ prompt: str
111
+ model: ImageGenerateModel = "zimage"
112
+ image_size: ImageSize = "square"
113
+ style: ImageStyle | None = None
114
+ resolution: ImageResolution = "1k"
115
+ seed: int = -1
116
+ image_url: str | None = None
117
+ callback_url: str | None = None
118
+
119
+ @model_validator(mode="after")
120
+ def validate_model_constraints(self) -> "ImageGeneratePayload":
121
+ """Validate provider-specific styles, inputs, and resolutions."""
122
+
123
+ style = self.style
124
+ if style is not None:
125
+ expected_models = {
126
+ "basic": "wan22",
127
+ "instagram": "wan22",
128
+ "replication": "wan22",
129
+ "transparent": "flux2",
130
+ }
131
+ if self.model != expected_models[style]:
132
+ raise ValueError(
133
+ f"Model {self.model} is not compatible with style {style}"
134
+ )
135
+ if style == "replication" and not self.image_url:
136
+ raise ValueError(f"{style} style requires --image")
137
+
138
+ if self.model in {"grok", "grok_quality"} and self.resolution not in {
139
+ "1k",
140
+ "2k",
141
+ }:
142
+ raise ValueError(
143
+ f"resolution {self.resolution} is not supported by model {self.model}"
144
+ )
145
+ if self.model in _WAN_IMAGE_MODELS:
146
+ if not 1 <= len(self.prompt) <= 5000:
147
+ raise ValueError(
148
+ "Wan 2.7 prompt must contain between 1 and 5000 characters"
149
+ )
150
+ if not -1 <= self.seed <= 2_147_483_647:
151
+ raise ValueError("Wan 2.7 seed must be -1 or between 0 and 2147483647")
152
+ if self.image_url is not None:
153
+ raise ValueError(
154
+ "image is not supported for Wan 2.7 image generate; use image edit"
155
+ )
156
+ if "resolution" not in self.model_fields_set:
157
+ self.resolution = "2k"
158
+ supported_resolutions = {"1k", "2k"}
159
+ if self.model == "wan2.7-pro":
160
+ supported_resolutions.add("4k")
161
+ if self.resolution not in supported_resolutions:
162
+ raise ValueError(
163
+ f"resolution {self.resolution} is not supported by model "
164
+ f"{self.model}"
165
+ )
166
+ if self.model in _GEMINI_IMAGE_MODELS and self.resolution not in {
167
+ "1k",
168
+ "2k",
169
+ "4k",
170
+ }:
171
+ raise ValueError(
172
+ f"resolution {self.resolution} is not supported by model {self.model}"
173
+ )
174
+ if self.model in _GPT_IMAGE_2_MODELS:
175
+ if self.image_url is not None:
176
+ raise ValueError(
177
+ "image is not supported for GPT Image 2 image generate; "
178
+ "use image edit"
179
+ )
180
+ if self.resolution not in {"1k", "2k", "3k"}:
181
+ raise ValueError(
182
+ f"resolution {self.resolution} is not supported by model "
183
+ f"{self.model}"
184
+ )
185
+ if self.model in _SEEDREAM_IMAGE_MODELS:
186
+ if self.image_url is not None:
187
+ raise ValueError(
188
+ "image is not supported for Seedream image generate; use image edit"
189
+ )
190
+ if "resolution" not in self.model_fields_set:
191
+ self.resolution = "2k"
192
+ supported_resolutions_by_model = {
193
+ "seedream-5-pro": {"1k", "2k"},
194
+ "seedream-5": {"2k", "3k", "4k"},
195
+ }
196
+ if self.resolution not in supported_resolutions_by_model[self.model]:
197
+ raise ValueError(
198
+ f"resolution {self.resolution} is not supported by model "
199
+ f"{self.model}"
200
+ )
201
+ return self
202
+
203
+
204
+ class ImageEditPayload(BaseModel):
205
+ """Payload for image editing."""
206
+
207
+ prompt: str
208
+ image_urls: ImageEditMedia
209
+ model: ImageEditModel = "flux2"
210
+ image_size: ImageSize | None = None
211
+ resolution: ImageResolution = "1k"
212
+ seed: int = -1
213
+ callback_url: str | None = None
214
+
215
+ @model_validator(mode="after")
216
+ def validate_model_limits(self) -> "ImageEditPayload":
217
+ """Validate image count limits for the selected model."""
218
+
219
+ max_images = _IMAGE_EDIT_MODEL_MAX_IMAGES.get(self.model)
220
+ if max_images is None:
221
+ raise ValueError(f"image edit model {self.model} has no configured limit")
222
+ if len(self.image_urls) > max_images:
223
+ raise ValueError(f"{self.model} supports a maximum of {max_images} images")
224
+
225
+ if self.model in {"grok", "grok_quality"} and self.resolution not in {
226
+ "1k",
227
+ "2k",
228
+ }:
229
+ raise ValueError(
230
+ f"resolution {self.resolution} is not supported by model {self.model}"
231
+ )
232
+ if self.model in _WAN_IMAGE_MODELS:
233
+ if not 1 <= len(self.prompt) <= 5000:
234
+ raise ValueError(
235
+ "Wan 2.7 prompt must contain between 1 and 5000 characters"
236
+ )
237
+ if not -1 <= self.seed <= 2_147_483_647:
238
+ raise ValueError("Wan 2.7 seed must be -1 or between 0 and 2147483647")
239
+ if "resolution" not in self.model_fields_set:
240
+ self.resolution = "2k"
241
+ if self.resolution not in {"1k", "2k"}:
242
+ raise ValueError(
243
+ f"resolution {self.resolution} is not supported by model "
244
+ f"{self.model}"
245
+ )
246
+ if self.model in _GEMINI_IMAGE_MODELS and self.resolution not in {
247
+ "1k",
248
+ "2k",
249
+ "4k",
250
+ }:
251
+ raise ValueError(
252
+ f"resolution {self.resolution} is not supported by model {self.model}"
253
+ )
254
+ if self.model in _SEEDREAM_IMAGE_MODELS:
255
+ if "resolution" not in self.model_fields_set:
256
+ self.resolution = "2k"
257
+ supported = {
258
+ "seedream-5-pro": {"1k", "2k"},
259
+ "seedream-5": {"2k", "3k", "4k"},
260
+ }
261
+ if self.resolution not in supported[self.model]:
262
+ raise ValueError(
263
+ f"resolution {self.resolution} is not supported by model "
264
+ f"{self.model}"
265
+ )
266
+ if self.model in _GPT_IMAGE_2_MODELS:
267
+ if self.resolution not in {"1k", "2k", "3k"}:
268
+ raise ValueError(
269
+ f"resolution {self.resolution} is not supported by model "
270
+ f"{self.model}"
271
+ )
272
+ if self.image_size is None:
273
+ self.image_size = "square"
274
+ return self
275
+
276
+
277
+ CommonVideoResolution = Literal["360p", "480p", "720p"]
278
+ CommonVideoAspectRatio = Literal["16:9", "9:16", "1:1"]
279
+ Seedance2VideoResolution = Literal["480p", "720p", "1080p", "4k"]
280
+ Seedance2VideoAspectRatio = Literal["16:9", "4:3", "1:1", "3:4", "9:16", "21:9"]
281
+ Wan27VideoResolution = Literal["720p", "1080p"]
282
+ Wan27VideoAspectRatio = Literal["16:9", "9:16", "1:1", "4:3", "3:4"]
283
+ VeoVideoResolution = Literal["720p", "1080p", "4k"]
284
+ VeoVideoAspectRatio = Literal["16:9", "9:16"]
285
+ Aleph2AspectRatio = Literal["16:9", "4:3", "3:2", "1:1", "2:3", "3:4", "9:16", "21:9"]
286
+ VideoGenerateModel = Literal[
287
+ "wan22",
288
+ "ltx23",
289
+ "grok",
290
+ "grok15",
291
+ "veo-3.1",
292
+ "veo-3.1-fast",
293
+ "seedance2",
294
+ "seedance2-mini",
295
+ "kling-2.6",
296
+ "kling-3",
297
+ "kling-3-turbo",
298
+ "kling-3-omni",
299
+ "wan2.7",
300
+ ]
301
+ VideoEditModel = Literal[
302
+ "seedance2",
303
+ "seedance2-mini",
304
+ "kling-3-omni",
305
+ "wan2.7",
306
+ "aleph2",
307
+ ]
308
+ VideoGenerateResolution = Literal["360p", "480p", "720p", "1080p", "4k"]
309
+ VideoEditResolution = Literal["480p", "720p", "1080p", "4k"]
310
+ VideoGenerateAspectRatio = Literal[
311
+ "16:9",
312
+ "4:3",
313
+ "1:1",
314
+ "3:4",
315
+ "9:16",
316
+ "21:9",
317
+ ]
318
+ VideoEditAspectRatio = Aleph2AspectRatio
319
+ VideoGenerateDuration = Annotated[int, Field(ge=1, le=15)]
320
+ VideoEditDuration = Annotated[int, Field(ge=2, le=15)]
321
+ PublicFigureThreshold = Literal["auto", "low"]
322
+
323
+
324
+ class _StrictVideoPayload(BaseModel):
325
+ """Reject fields that do not belong to the selected API model."""
326
+
327
+ model_config = ConfigDict(extra="forbid")
328
+
329
+
330
+ class WAN22VideoGenRequest(_StrictVideoPayload):
331
+ prompt: str
332
+ model: Literal["wan22"] = "wan22"
333
+ duration: int = Field(ge=1, le=10)
334
+ resolution: Literal["360p", "480p"] = "480p"
335
+ aspect_ratio: CommonVideoAspectRatio = "16:9"
336
+ seed: int = -1
337
+ lora_high_url: str | None = None
338
+ lora_low_url: str | None = None
339
+ callback_url: str | None = None
340
+
341
+ @model_validator(mode="after")
342
+ def validate_lora_urls(self) -> "WAN22VideoGenRequest":
343
+ if bool(self.lora_high_url) != bool(self.lora_low_url):
344
+ raise ValueError(
345
+ "Both lora_high_url and lora_low_url must be provided together"
346
+ )
347
+ return self
348
+
349
+
350
+ class LTX23VideoGenRequest(_StrictVideoPayload):
351
+ prompt: str
352
+ model: Literal["ltx23"] = "ltx23"
353
+ duration: int = Field(ge=1, le=10)
354
+ resolution: CommonVideoResolution = "720p"
355
+ aspect_ratio: CommonVideoAspectRatio = "16:9"
356
+ image_url: str | None = None
357
+ image_last_url: str | None = None
358
+ seed: int = -1
359
+ callback_url: str | None = None
360
+
361
+
362
+ class GrokVideoGenRequest(_StrictVideoPayload):
363
+ prompt: str
364
+ model: Literal["grok"] = "grok"
365
+ duration: int = Field(ge=1, le=15)
366
+ resolution: Literal["480p", "720p"] = "480p"
367
+ aspect_ratio: CommonVideoAspectRatio = "16:9"
368
+ image_url: str | None = None
369
+ callback_url: str | None = None
370
+
371
+
372
+ class Grok15VideoGenRequest(_StrictVideoPayload):
373
+ prompt: str
374
+ model: Literal["grok15"] = "grok15"
375
+ duration: int = Field(ge=1, le=15)
376
+ resolution: Literal["480p", "720p"] = "480p"
377
+ aspect_ratio: CommonVideoAspectRatio = "16:9"
378
+ image_url: str
379
+ callback_url: str | None = None
380
+
381
+
382
+ class VeoVideoGenRequest(_StrictVideoPayload):
383
+ prompt: str = Field(min_length=1)
384
+ model: Literal["veo-3.1", "veo-3.1-fast"]
385
+ duration: Literal[4, 6, 8] = 8
386
+ resolution: VeoVideoResolution = "720p"
387
+ aspect_ratio: VeoVideoAspectRatio = "16:9"
388
+ image_url: str | None = None
389
+ image_last_url: str | None = None
390
+ reference_image_urls: list[str] = Field(default_factory=list, max_length=3)
391
+ callback_url: str | None = None
392
+
393
+ @model_validator(mode="after")
394
+ def validate_media_and_duration(self) -> "VeoVideoGenRequest":
395
+ for value in (self.image_url, self.image_last_url):
396
+ if value is not None and not value.startswith(
397
+ ("http://", "https://", "data:image/")
398
+ ):
399
+ raise ValueError(
400
+ "Veo frame input must be an HTTP(S) URL or image data URI"
401
+ )
402
+ for value in self.reference_image_urls:
403
+ if not value.startswith(("http://", "https://", "data:image/")):
404
+ raise ValueError(
405
+ "Veo reference image must be an HTTP(S) URL or image data URI"
406
+ )
407
+ if self.resolution != "720p" and self.duration != 8:
408
+ raise ValueError("Veo 1080p and 4k generation requires duration 8")
409
+ if self.image_last_url and not self.image_url:
410
+ raise ValueError("Veo image_last_url requires image_url")
411
+ if self.reference_image_urls and (self.image_url or self.image_last_url):
412
+ raise ValueError(
413
+ "Veo reference_image_urls cannot be combined with frame images"
414
+ )
415
+ if self.reference_image_urls and self.duration != 8:
416
+ raise ValueError("Veo reference images require duration 8")
417
+ return self
418
+
419
+
420
+ class Seedance2VideoGenRequest(_StrictVideoPayload):
421
+ prompt: str
422
+ model: Literal["seedance2", "seedance2-mini"] = "seedance2"
423
+ duration: int = Field(default=5, ge=4, le=15)
424
+ resolution: Seedance2VideoResolution = "720p"
425
+ aspect_ratio: Seedance2VideoAspectRatio = "16:9"
426
+ generate_audio: bool = True
427
+ callback_url: str | None = None
428
+
429
+ @model_validator(mode="after")
430
+ def validate_model_resolution(self) -> "Seedance2VideoGenRequest":
431
+ if self.model == "seedance2-mini" and self.resolution not in {
432
+ "480p",
433
+ "720p",
434
+ }:
435
+ raise ValueError("seedance2-mini supports only 480p and 720p")
436
+ return self
437
+
438
+
439
+ class KlingV26VideoGenRequest(_StrictVideoPayload):
440
+ prompt: str
441
+ model: Literal["kling-2.6"] = "kling-2.6"
442
+ duration: Literal[5, 10] = 5
443
+ resolution: Literal["720p", "1080p"] = "1080p"
444
+ aspect_ratio: CommonVideoAspectRatio = "16:9"
445
+ generate_audio: bool = True
446
+ callback_url: str | None = None
447
+
448
+ @model_validator(mode="after")
449
+ def validate_audio_resolution(self) -> "KlingV26VideoGenRequest":
450
+ if self.generate_audio and self.resolution != "1080p":
451
+ raise ValueError("kling-2.6 audio generation is supported only at 1080p")
452
+ return self
453
+
454
+
455
+ class KlingV3VideoGenRequest(_StrictVideoPayload):
456
+ prompt: str
457
+ model: Literal["kling-3"] = "kling-3"
458
+ duration: int = Field(default=5, ge=3, le=15)
459
+ resolution: Literal["720p", "1080p", "4k"] = "1080p"
460
+ aspect_ratio: CommonVideoAspectRatio = "16:9"
461
+ generate_audio: bool = True
462
+ callback_url: str | None = None
463
+
464
+
465
+ class KlingV3TurboVideoGenRequest(_StrictVideoPayload):
466
+ prompt: str
467
+ model: Literal["kling-3-turbo"] = "kling-3-turbo"
468
+ duration: int = Field(default=5, ge=3, le=15)
469
+ resolution: Literal["720p", "1080p"] = "720p"
470
+ aspect_ratio: CommonVideoAspectRatio = "16:9"
471
+ generate_audio: Literal[False] = False
472
+ callback_url: str | None = None
473
+
474
+
475
+ class KlingV3OmniVideoGenRequest(_StrictVideoPayload):
476
+ prompt: str
477
+ model: Literal["kling-3-omni"] = "kling-3-omni"
478
+ duration: int = Field(default=5, ge=3, le=15)
479
+ resolution: Literal["720p", "1080p", "4k"] = "1080p"
480
+ aspect_ratio: CommonVideoAspectRatio = "16:9"
481
+ generate_audio: bool = True
482
+ callback_url: str | None = None
483
+
484
+
485
+ class Wan27VideoGenRequest(_StrictVideoPayload):
486
+ prompt: str = Field(min_length=1, max_length=5000)
487
+ model: Literal["wan2.7"] = "wan2.7"
488
+ duration: int = Field(default=5, ge=2, le=15)
489
+ resolution: Wan27VideoResolution = "720p"
490
+ aspect_ratio: Wan27VideoAspectRatio = "16:9"
491
+ image_url: str | None = None
492
+ image_last_url: str | None = None
493
+ reference_image_urls: list[str] = Field(default_factory=list, max_length=5)
494
+ reference_video_urls: list[str] = Field(default_factory=list, max_length=3)
495
+ seed: int = Field(default=-1, ge=-1, le=2_147_483_647)
496
+ callback_url: str | None = None
497
+
498
+ @model_validator(mode="after")
499
+ def validate_media(self) -> "Wan27VideoGenRequest":
500
+ for value in (self.image_url, self.image_last_url):
501
+ if value is not None and not value.startswith(
502
+ ("http://", "https://", "data:image/")
503
+ ):
504
+ raise ValueError(
505
+ "Wan 2.7 frame input must be an HTTP(S) URL or image data URI"
506
+ )
507
+ for value in self.reference_image_urls:
508
+ if not value.startswith(("http://", "https://", "data:image/")):
509
+ raise ValueError(
510
+ "Wan 2.7 reference image must be an HTTP(S) URL or image data URI"
511
+ )
512
+ for value in self.reference_video_urls:
513
+ if not value.startswith(
514
+ ("http://", "https://", "data:video/mp4;", "data:video/quicktime;")
515
+ ):
516
+ raise ValueError(
517
+ "Wan 2.7 reference video must be an HTTP(S) URL or MP4/MOV data URI"
518
+ )
519
+ if self.image_last_url and not self.image_url:
520
+ raise ValueError("image_last_url requires image_url")
521
+ if (self.image_url or self.image_last_url) and (
522
+ self.reference_image_urls or self.reference_video_urls
523
+ ):
524
+ raise ValueError(
525
+ "Wan 2.7 frame inputs cannot be combined with reference inputs"
526
+ )
527
+ reference_count = len(self.reference_image_urls) + len(
528
+ self.reference_video_urls
529
+ )
530
+ if reference_count > 5:
531
+ raise ValueError(
532
+ "Wan 2.7 supports at most 5 reference images and videos in total"
533
+ )
534
+ if self.reference_video_urls and self.duration > 10:
535
+ raise ValueError(
536
+ "Wan 2.7 requests with reference videos support up to 10 seconds"
537
+ )
538
+ return self
539
+
540
+
541
+ VideoGenerateRequest = Annotated[
542
+ WAN22VideoGenRequest
543
+ | LTX23VideoGenRequest
544
+ | GrokVideoGenRequest
545
+ | Grok15VideoGenRequest
546
+ | VeoVideoGenRequest
547
+ | Seedance2VideoGenRequest
548
+ | KlingV26VideoGenRequest
549
+ | KlingV3VideoGenRequest
550
+ | KlingV3TurboVideoGenRequest
551
+ | KlingV3OmniVideoGenRequest
552
+ | Wan27VideoGenRequest,
553
+ Discriminator("model"),
554
+ ]
555
+
556
+
557
+ class VideoEditPayload(_StrictVideoPayload):
558
+ """JSON request body for Seedance ``video_edit`` operations."""
559
+
560
+ prompt: str
561
+ model: Literal["seedance2", "seedance2-mini"] = "seedance2"
562
+ duration: int = Field(default=5, ge=4, le=15)
563
+ resolution: Literal["480p", "720p", "1080p", "4k"] = "720p"
564
+ aspect_ratio: Literal["16:9", "4:3", "1:1", "3:4", "9:16", "21:9"] = "16:9"
565
+ generate_audio: bool = True
566
+ callback_url: str | None = None
567
+ content: list[dict[str, Any]] = Field(min_length=1)
568
+
569
+ @model_validator(mode="after")
570
+ def validate_model_resolution(self) -> "VideoEditPayload":
571
+ if self.model == "seedance2-mini" and self.resolution not in {
572
+ "480p",
573
+ "720p",
574
+ }:
575
+ raise ValueError("seedance2-mini supports only 480p and 720p")
576
+ return self
577
+
578
+ @model_validator(mode="after")
579
+ def validate_content(self) -> "VideoEditPayload":
580
+ """Mirror the documented structural validation before making a request."""
581
+
582
+ video_count = 0
583
+ has_visual = False
584
+ has_audio = False
585
+ for item in self.content:
586
+ item_type = item.get("type")
587
+ if item_type == "text":
588
+ if not isinstance(item.get("text"), str) or not item["text"]:
589
+ raise ValueError("text content item requires text")
590
+ continue
591
+ if item_type not in {"image_url", "video_url", "audio_url"}:
592
+ raise ValueError("content item type is not supported")
593
+ field = item_type
594
+ media = item.get(field)
595
+ if not isinstance(media, dict) or not isinstance(media.get("url"), str):
596
+ raise ValueError(f"{field} content item requires a URL")
597
+ url = media["url"]
598
+ expected_role = f"reference_{item_type.removesuffix('_url')}"
599
+ if item.get("role") != expected_role:
600
+ raise ValueError(f"{field} content item has an invalid role")
601
+ if item_type == "image_url" and not url.startswith(
602
+ ("http://", "https://", "data:image/")
603
+ ):
604
+ raise ValueError("image_url must be an HTTP(S) URL or image data URI")
605
+ if item_type == "video_url":
606
+ if not url.startswith(
607
+ ("http://", "https://", "data:video/mp4;", "data:video/quicktime;")
608
+ ):
609
+ raise ValueError(
610
+ "video_url must be an HTTP(S) URL or MP4/MOV data URI"
611
+ )
612
+ video_count += 1
613
+ has_visual = True
614
+ if item_type == "audio_url":
615
+ if not url.startswith(("http://", "https://", "data:audio/")):
616
+ raise ValueError(
617
+ "audio_url must be an HTTP(S) URL or audio data URI"
618
+ )
619
+ has_audio = True
620
+ if item_type == "image_url":
621
+ has_visual = True
622
+ if video_count > 1:
623
+ raise ValueError("content supports at most one video_url item")
624
+ if not has_visual and not has_audio:
625
+ raise ValueError(
626
+ "video_edit requires at least one image_url, video_url, "
627
+ "or audio_url item"
628
+ )
629
+ if has_audio and not has_visual:
630
+ raise ValueError(
631
+ "audio_url requires at least one image_url or video_url item"
632
+ )
633
+ return self
634
+
635
+
636
+ class KlingV3OmniVideoEditPayload(_StrictVideoPayload):
637
+ """JSON request body for Kling 3 Omni video-to-video editing."""
638
+
639
+ prompt: str
640
+ model: Literal["kling-3-omni"] = "kling-3-omni"
641
+ duration: int = Field(default=5, ge=3, le=10)
642
+ resolution: Literal["720p", "1080p", "4k"] = "1080p"
643
+ aspect_ratio: CommonVideoAspectRatio = "16:9"
644
+ generate_audio: Literal[False] = False
645
+ callback_url: str | None = None
646
+ content: list[dict[str, Any]] = Field(min_length=1)
647
+
648
+ @model_validator(mode="after")
649
+ def validate_content(self) -> "KlingV3OmniVideoEditPayload":
650
+ """Require the provider's single video reference and content shapes."""
651
+
652
+ video_count = 0
653
+ for item in self.content:
654
+ item_type = item.get("type")
655
+ if item_type == "text":
656
+ if not isinstance(item.get("text"), str) or not item["text"]:
657
+ raise ValueError("text content item requires text")
658
+ continue
659
+ if item_type not in {"image_url", "video_url"}:
660
+ raise ValueError("content item type is not supported")
661
+ media = item.get(item_type)
662
+ if not isinstance(media, dict) or not isinstance(media.get("url"), str):
663
+ raise ValueError(f"{item_type} content item requires a URL")
664
+ url = media["url"]
665
+ if item_type == "image_url" and not url.startswith(
666
+ ("http://", "https://", "data:image/")
667
+ ):
668
+ raise ValueError("image_url must be an HTTP(S) URL or image data URI")
669
+ if item_type == "video_url":
670
+ if not url.startswith(
671
+ ("http://", "https://", "data:video/mp4;", "data:video/quicktime;")
672
+ ):
673
+ raise ValueError(
674
+ "video_url must be an HTTP(S) URL or MP4/MOV data URI"
675
+ )
676
+ video_count += 1
677
+ if video_count != 1:
678
+ raise ValueError(
679
+ "kling-3-omni video edit requires exactly one video reference"
680
+ )
681
+ if self.resolution == "4k":
682
+ raise ValueError(
683
+ "kling-3-omni video edit does not support 4k with video input"
684
+ )
685
+ return self
686
+
687
+
688
+ class Wan27VideoEditPayload(_StrictVideoPayload):
689
+ """JSON request body for Wan 2.7 video editing."""
690
+
691
+ prompt: str = Field(min_length=1, max_length=5000)
692
+ model: Literal["wan2.7"] = "wan2.7"
693
+ duration: int = Field(default=5, ge=2, le=10)
694
+ resolution: Wan27VideoResolution = "720p"
695
+ aspect_ratio: Wan27VideoAspectRatio = "16:9"
696
+ video_url: str
697
+ reference_image_urls: list[str] = Field(default_factory=list, max_length=3)
698
+ seed: int = Field(default=-1, ge=-1, le=2_147_483_647)
699
+ callback_url: str | None = None
700
+
701
+ @model_validator(mode="after")
702
+ def validate_media(self) -> "Wan27VideoEditPayload":
703
+ if not self.video_url.startswith(
704
+ ("http://", "https://", "data:video/mp4;", "data:video/quicktime;")
705
+ ):
706
+ raise ValueError("Wan 2.7 video must be an HTTP(S) URL or MP4/MOV data URI")
707
+ for value in self.reference_image_urls:
708
+ if not value.startswith(("http://", "https://", "data:image/")):
709
+ raise ValueError(
710
+ "Wan 2.7 reference image must be an HTTP(S) URL or image data URI"
711
+ )
712
+ return self
713
+
714
+
715
+ class Aleph2KeyframeRange(BaseModel):
716
+ start_seconds: int = Field(ge=0)
717
+ end_seconds: int = Field(gt=0)
718
+
719
+ @model_validator(mode="after")
720
+ def validate_range(self) -> "Aleph2KeyframeRange":
721
+ if self.end_seconds <= self.start_seconds:
722
+ raise ValueError("end_seconds must be greater than start_seconds")
723
+ return self
724
+
725
+
726
+ class Aleph2Keyframe(BaseModel):
727
+ image_url: str
728
+ seconds: float | None = Field(default=None, ge=0, le=30)
729
+ at: float | None = Field(default=None, ge=0, le=1)
730
+ range: Aleph2KeyframeRange | None = None
731
+
732
+ @model_validator(mode="after")
733
+ def validate_keyframe(self) -> "Aleph2Keyframe":
734
+ if not self.image_url.startswith(("https://", "data:image/")):
735
+ raise ValueError(
736
+ "Aleph 2 keyframe image_url must use HTTPS or an image data URI"
737
+ )
738
+ if self.image_url.startswith("data:") and len(self.image_url) > 5 * 1024 * 1024:
739
+ raise ValueError("Aleph 2 keyframe data URI exceeds 5 MB")
740
+ if len(self.image_url) > 2048 and not self.image_url.startswith("data:"):
741
+ raise ValueError("Aleph 2 keyframe image_url exceeds 2048 characters")
742
+ if (self.seconds is None) == (self.at is None):
743
+ raise ValueError("Aleph 2 keyframe requires exactly one of seconds or at")
744
+ return self
745
+
746
+
747
+ class Aleph2VideoEditPayload(_StrictVideoPayload):
748
+ model: Literal["aleph2"] = "aleph2"
749
+ video_url: str
750
+ prompt: str | None = Field(default=None, min_length=1)
751
+ keyframes: list[Aleph2Keyframe] = Field(default_factory=list, max_length=5)
752
+ seed: int = Field(default=-1, ge=-1, le=4_294_967_295)
753
+ target_aspect_ratio: Aleph2AspectRatio | None = None
754
+ public_figure_threshold: PublicFigureThreshold | None = None
755
+ callback_url: str | None = None
756
+
757
+ @model_validator(mode="after")
758
+ def validate_request(self) -> "Aleph2VideoEditPayload":
759
+ if not self.video_url.startswith(
760
+ ("http://", "https://", "data:video/mp4;", "data:video/quicktime;")
761
+ ):
762
+ raise ValueError("Aleph 2 video must be an HTTP(S) URL or MP4/MOV data URI")
763
+ if self.prompt is not None:
764
+ code_units = len(self.prompt.encode("utf-16-le")) // 2
765
+ if code_units > 1000:
766
+ raise ValueError(
767
+ "Aleph 2 prompt must not exceed 1000 UTF-16 code units"
768
+ )
769
+ ranged = [keyframe.range is not None for keyframe in self.keyframes]
770
+ if ranged and any(ranged) and not all(ranged):
771
+ raise ValueError("All Aleph 2 keyframes must either set range or omit it")
772
+ return self
773
+
774
+
775
+ VideoEditRequest = Annotated[
776
+ VideoEditPayload
777
+ | KlingV3OmniVideoEditPayload
778
+ | Wan27VideoEditPayload
779
+ | Aleph2VideoEditPayload,
780
+ Discriminator("model"),
781
+ ]
782
+
783
+
784
+ class ImageUpscalePayload(BaseModel):
785
+ """Payload for image upscale from URL or another task result."""
786
+
787
+ image_url: str | None = None
788
+ image_from_task_id: str | None = None
789
+ model: str = "seedvr2"
790
+ callback_url: str | None = None
791
+
792
+ @model_validator(mode="after")
793
+ def validate_source(self) -> "ImageUpscalePayload":
794
+ """Require exactly one source: --image or --task-id."""
795
+
796
+ if bool(self.image_url) == bool(self.image_from_task_id):
797
+ raise ValueError("Provide exactly one of --image or --task-id")
798
+ return self
799
+
800
+
801
+ VideoUpscaleResolution = Literal["720p", "1080p", "1440p"]
802
+ LipsyncVideoResolution = Literal["360p", "480p"]
803
+ LipsyncImageModel = Literal["inftalk", "ltx23"]
804
+ LipsyncImageResolution = Literal["360p", "480p", "720p"]
805
+
806
+
807
+ class VideoUpscalePayload(BaseModel):
808
+ """Payload for video upscale from URL or another task result."""
809
+
810
+ video_url: str | None = None
811
+ video_from_task_id: str | None = None
812
+ resolution: VideoUpscaleResolution = Field(
813
+ default="1080p",
814
+ description="Output resolution; 1440p accepts videos up to 20 seconds.",
815
+ )
816
+
817
+ @model_validator(mode="after")
818
+ def validate_source(self) -> "VideoUpscalePayload":
819
+ """Require exactly one source: --video or --task-id."""
820
+
821
+ if bool(self.video_url) == bool(self.video_from_task_id):
822
+ raise ValueError("Provide exactly one of --video or --task-id")
823
+ return self
824
+
825
+
826
+ class LipsyncVideoPayload(BaseModel):
827
+ """Payload for lipsync from source video and audio."""
828
+
829
+ video_url: str
830
+ audio_url: str
831
+ resolution: LipsyncVideoResolution = "480p"
832
+ prompt: str | None = None
833
+ seed: int = -1
834
+ callback_url: str | None = None
835
+
836
+
837
+ class LipsyncImagePayload(BaseModel):
838
+ """Payload for lipsync from a still image and audio."""
839
+
840
+ image_url: str
841
+ audio_url: str
842
+ model: LipsyncImageModel = "inftalk"
843
+ resolution: LipsyncImageResolution = "480p"
844
+ prompt: str | None = None
845
+ seed: int = -1
846
+ callback_url: str | None = None