creative-render-engine 0.7.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,763 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from enum import Enum
5
+ from html.parser import HTMLParser
6
+ from pathlib import Path
7
+ from typing import Annotated, Any, Literal, TypeAlias
8
+
9
+ from pydantic import (
10
+ BaseModel,
11
+ ConfigDict,
12
+ Field,
13
+ PositiveFloat,
14
+ PositiveInt,
15
+ StrictFloat,
16
+ StringConstraints,
17
+ field_validator,
18
+ model_serializer,
19
+ model_validator,
20
+ )
21
+
22
+ SCHEMA_VERSION = "2.0"
23
+
24
+ Identifier = Annotated[
25
+ str,
26
+ StringConstraints(
27
+ strip_whitespace=True, min_length=1, pattern=r"^[A-Za-z0-9][A-Za-z0-9_.-]*$"
28
+ ),
29
+ ]
30
+ Color = Annotated[str, StringConstraints(pattern=r"^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$")]
31
+ FontSizeScale = Annotated[StrictFloat, Field(gt=0, allow_inf_nan=False)]
32
+
33
+
34
+ class StrictModel(BaseModel):
35
+ model_config = ConfigDict(extra="forbid", frozen=True)
36
+
37
+
38
+ class AssetMediaType(str, Enum):
39
+ IMAGE = "image"
40
+ VIDEO = "video"
41
+ AUDIO = "audio"
42
+ FONT = "font"
43
+
44
+
45
+ class CropMode(str, Enum):
46
+ COVER = "cover"
47
+ CONTAIN = "contain"
48
+ STRETCH = "stretch"
49
+
50
+
51
+ class HorizontalAlignment(str, Enum):
52
+ LEFT = "left"
53
+ CENTER = "center"
54
+ RIGHT = "right"
55
+
56
+
57
+ class VerticalAlignment(str, Enum):
58
+ TOP = "top"
59
+ MIDDLE = "middle"
60
+ BOTTOM = "bottom"
61
+
62
+
63
+ class TextAdjustMode(str, Enum):
64
+ WIDTH = "WIDTH"
65
+ HEIGHT_EXTEND = "HEIGHT_EXTEND"
66
+ ADAPT = "ADAPT"
67
+
68
+
69
+ class TrackMode(str, Enum):
70
+ SEQUENTIAL = "SEQUENTIAL"
71
+ SYNCHRONIZED = "SYNCHRONIZED"
72
+
73
+
74
+ class AudioMode(str, Enum):
75
+ NONE = "none"
76
+ USE_ASSET = "use_asset"
77
+ USE_BACKGROUND = "use_background"
78
+
79
+
80
+ class FirstFrameMode(str, Enum):
81
+ COMPOSED = "composed"
82
+ HIDDEN_TEXT = "hidden_text"
83
+ ASSET = "asset"
84
+
85
+
86
+ class Size(StrictModel):
87
+ width: PositiveInt
88
+ height: PositiveInt
89
+
90
+
91
+ class Point(StrictModel):
92
+ x: int
93
+ y: int
94
+
95
+
96
+ class Box(StrictModel):
97
+ position: Point
98
+ size: Size
99
+
100
+ def intersects(self, canvas: Size) -> bool:
101
+ return (
102
+ self.position.x < canvas.width
103
+ and self.position.y < canvas.height
104
+ and self.position.x + self.size.width > 0
105
+ and self.position.y + self.size.height > 0
106
+ )
107
+
108
+
109
+ class LocalAsset(StrictModel):
110
+ id: Identifier
111
+ path: Path
112
+ media_type: AssetMediaType
113
+ sha256: Annotated[str, StringConstraints(pattern=r"^[0-9A-Fa-f]{64}$")] | None = (
114
+ None
115
+ )
116
+
117
+ @field_validator("path")
118
+ @classmethod
119
+ def reject_empty_path(cls, value: Path) -> Path:
120
+ if not str(value).strip():
121
+ raise ValueError("path must not be empty")
122
+ return value
123
+
124
+ @field_validator("sha256")
125
+ @classmethod
126
+ def normalize_sha256(cls, value: str | None) -> str | None:
127
+ return value.lower() if value is not None else None
128
+
129
+
130
+ class PureImageLayer(StrictModel):
131
+ id: Identifier
132
+ slot: Identifier
133
+ z_index: Literal[1] = 1
134
+ box: Box
135
+ fit: Literal["cover"] = "cover"
136
+
137
+
138
+ class PureImageTemplate(StrictModel):
139
+ template_id: Identifier
140
+ revision: PositiveInt
141
+ canvas: Size
142
+ layer: PureImageLayer
143
+
144
+ @model_validator(mode="after")
145
+ def validate_full_canvas_layer(self) -> PureImageTemplate:
146
+ if (
147
+ self.layer.box.position != Point(x=0, y=0)
148
+ or self.layer.box.size != self.canvas
149
+ ):
150
+ raise ValueError("pure-image layer must cover the complete canvas")
151
+ return self
152
+
153
+
154
+ class PureImageBinding(StrictModel):
155
+ slot: Identifier
156
+ asset: LocalAsset
157
+
158
+ @model_validator(mode="after")
159
+ def validate_asset(self) -> PureImageBinding:
160
+ if self.asset.media_type != AssetMediaType.IMAGE:
161
+ raise ValueError("pure-image binding requires an image asset")
162
+ return self
163
+
164
+
165
+ class PureImageBindings(StrictModel):
166
+ image: PureImageBinding
167
+
168
+
169
+ class _GraphicTextHtmlParser(HTMLParser):
170
+ def __init__(self, allowed_colors: set[str]) -> None:
171
+ super().__init__(convert_charrefs=True)
172
+ self.allowed_colors = allowed_colors
173
+ self.stack: list[str] = []
174
+ self.root_count = 0
175
+
176
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
177
+ tag = tag.lower()
178
+ if tag not in {"p", "br", "span"}:
179
+ raise ValueError(f"graphic-text html tag is not allowed: {tag}")
180
+ if tag == "p":
181
+ if self.stack or self.root_count:
182
+ raise ValueError(
183
+ "graphic-text html must contain exactly one root p element"
184
+ )
185
+ if attrs:
186
+ raise ValueError("graphic-text p does not accept attributes")
187
+ self.root_count += 1
188
+ self.stack.append(tag)
189
+ return
190
+ if not self.stack or self.stack[0] != "p":
191
+ raise ValueError(
192
+ "graphic-text inline content must be inside the root p element"
193
+ )
194
+ if tag == "br":
195
+ if attrs:
196
+ raise ValueError("graphic-text br does not accept attributes")
197
+ return
198
+ if len(attrs) != 1 or attrs[0][0].lower() != "style" or attrs[0][1] is None:
199
+ raise ValueError("graphic-text span requires exactly one style attribute")
200
+ match = re.fullmatch(
201
+ r"\s*color\s*:\s*(#[0-9A-Fa-f]{6}(?:[0-9A-Fa-f]{2})?)\s*;?\s*",
202
+ attrs[0][1],
203
+ )
204
+ if match is None or match.group(1).upper() not in self.allowed_colors:
205
+ raise ValueError("graphic-text span color is not allowed")
206
+ self.stack.append(tag)
207
+
208
+ def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
209
+ self.handle_starttag(tag, attrs)
210
+ if tag.lower() != "br":
211
+ self.handle_endtag(tag)
212
+
213
+ def handle_endtag(self, tag: str) -> None:
214
+ tag = tag.lower()
215
+ if tag == "br" or not self.stack or self.stack[-1] != tag:
216
+ raise ValueError("graphic-text html tags are not balanced")
217
+ self.stack.pop()
218
+
219
+ def handle_data(self, data: str) -> None:
220
+ if data.strip() and (not self.stack or self.stack[0] != "p"):
221
+ raise ValueError("graphic-text text must be inside the root p element")
222
+
223
+ def handle_comment(self, data: str) -> None:
224
+ del data
225
+ raise ValueError("graphic-text html comments are not allowed")
226
+
227
+ def handle_decl(self, decl: str) -> None:
228
+ del decl
229
+ raise ValueError("graphic-text html declarations are not allowed")
230
+
231
+ def handle_pi(self, data: str) -> None:
232
+ del data
233
+ raise ValueError("graphic-text html processing instructions are not allowed")
234
+
235
+ def unknown_decl(self, data: str) -> None:
236
+ del data
237
+ raise ValueError("graphic-text html declarations are not allowed")
238
+
239
+ def validate_complete(self) -> None:
240
+ if self.root_count != 1 or self.stack:
241
+ raise ValueError(
242
+ "graphic-text html must contain one balanced root p element"
243
+ )
244
+
245
+
246
+ class GraphicTextRichTextPolicy(StrictModel):
247
+ allowed_tags: tuple[Literal["p", "br", "span"], ...]
248
+ allowed_span_styles: tuple[Literal["color"], ...]
249
+ allowed_colors: tuple[Color, ...]
250
+
251
+ @field_validator("allowed_colors")
252
+ @classmethod
253
+ def normalize_colors(cls, value: tuple[str, ...]) -> tuple[str, ...]:
254
+ return tuple(item.upper() for item in value)
255
+
256
+ @model_validator(mode="after")
257
+ def validate_policy(self) -> GraphicTextRichTextPolicy:
258
+ if self.allowed_tags != ("p", "br", "span"):
259
+ raise ValueError("graphic-text allowed_tags must be ('p', 'br', 'span')")
260
+ if self.allowed_span_styles != ("color",):
261
+ raise ValueError("graphic-text allowed_span_styles must be ('color',)")
262
+ if self.allowed_colors != ("#000000FF", "#FF0000FF"):
263
+ raise ValueError(
264
+ "graphic-text allowed_colors must be ('#000000FF', '#FF0000FF')"
265
+ )
266
+ return self
267
+
268
+
269
+ class FontAsset(StrictModel):
270
+ id: Identifier
271
+ path: Path
272
+ sha256: Annotated[str, StringConstraints(pattern=r"^[0-9A-Fa-f]{64}$")] | None = (
273
+ None
274
+ )
275
+
276
+ @field_validator("sha256")
277
+ @classmethod
278
+ def normalize_sha256(cls, value: str | None) -> str | None:
279
+ return value.lower() if value is not None else None
280
+
281
+
282
+ class FontStyle(StrictModel):
283
+ font: FontAsset
284
+ font_size: PositiveInt
285
+ line_spacing: int = 0
286
+ color: Color = "#FFFFFF"
287
+ alignment: HorizontalAlignment = HorizontalAlignment.CENTER
288
+ stroke_size: Annotated[int, Field(ge=0, le=32)] = 0
289
+ stroke_color: Color = "#000000"
290
+
291
+ @field_validator("color", "stroke_color")
292
+ @classmethod
293
+ def normalize_color(cls, value: str) -> str:
294
+ return value.upper()
295
+
296
+
297
+ class MaskStyle(StrictModel):
298
+ mode: Annotated[int, Field(ge=0, le=3)] = 0
299
+ color: Color = "#000000C8"
300
+ offset: Point = Point(x=0, y=0)
301
+ corner_radius: Annotated[int, Field(ge=0, le=512)] = 0
302
+
303
+ @field_validator("color")
304
+ @classmethod
305
+ def normalize_color(cls, value: str) -> str:
306
+ return value.upper()
307
+
308
+
309
+ class TextStyle(StrictModel):
310
+ font: FontStyle
311
+ mask: MaskStyle = MaskStyle()
312
+ padding: Point = Point(x=4, y=4)
313
+ adjust: TextAdjustMode = TextAdjustMode.WIDTH
314
+ vertical_alignment: VerticalAlignment = VerticalAlignment.MIDDLE
315
+ min_words_per_block: PositiveInt = 8
316
+
317
+ @model_validator(mode="after")
318
+ def validate_padding(self) -> TextStyle:
319
+ if self.padding.x < 0 or self.padding.y < 0:
320
+ raise ValueError("padding values must be non-negative")
321
+ return self
322
+
323
+
324
+ class LayerBase(StrictModel):
325
+ id: Identifier
326
+ z_index: int
327
+ opacity: Annotated[int, Field(ge=0, le=255)] = 255
328
+
329
+
330
+ class BoxLayerBase(LayerBase):
331
+ box: Box
332
+
333
+
334
+ class BackgroundLayer(BoxLayerBase):
335
+ type: Literal["background"] = "background"
336
+ slot: Identifier
337
+ crop: CropMode = CropMode.COVER
338
+ required: bool = True
339
+
340
+
341
+ class TextLayer(BoxLayerBase):
342
+ type: Literal["text"] = "text"
343
+ slot: Identifier
344
+ style: TextStyle
345
+ required: bool = True
346
+
347
+
348
+ class ScrollTextLayer(LayerBase):
349
+ type: Literal["scroll_text"] = "scroll_text"
350
+ slot: Identifier
351
+ viewport: Box
352
+ style: TextStyle
353
+ required: bool = True
354
+ fade_length_px: Annotated[int, Field(ge=0)] = 80
355
+ scroll_speed_px_s: PositiveFloat = 50.0
356
+ start_offset_y_px: int = 160
357
+ begin_hold_s: Annotated[float, Field(ge=0)] = 5.0
358
+ end_hold_s: Annotated[float, Field(ge=0)] = 5.0
359
+
360
+
361
+ ScrollLayer: TypeAlias = Annotated[ # noqa: UP040
362
+ BackgroundLayer | TextLayer | ScrollTextLayer,
363
+ Field(discriminator="type"),
364
+ ]
365
+
366
+
367
+ class ScrollTemplate(StrictModel):
368
+ template_id: Identifier
369
+ revision: PositiveInt
370
+ canvas: Size
371
+ layers: tuple[ScrollLayer, ...]
372
+
373
+ @model_validator(mode="after")
374
+ def validate_layers(self) -> ScrollTemplate:
375
+ if not self.layers:
376
+ raise ValueError("scroll template must contain at least one layer")
377
+ ids = [layer.id for layer in self.layers]
378
+ if len(ids) != len(set(ids)):
379
+ raise ValueError("scroll template layer ids must be unique")
380
+ slots = [layer.slot for layer in self.layers]
381
+ if len(slots) != len(set(slots)):
382
+ raise ValueError("scroll template slots must be unique")
383
+ if sum(isinstance(layer, ScrollTextLayer) for layer in self.layers) > 1:
384
+ raise ValueError("scroll template supports at most one scroll_text layer")
385
+ for layer in self.layers:
386
+ geometry = (
387
+ layer.viewport if isinstance(layer, ScrollTextLayer) else layer.box
388
+ )
389
+ if not geometry.intersects(self.canvas):
390
+ raise ValueError(f"layer {layer.id} does not intersect the canvas")
391
+ if (
392
+ isinstance(layer, ScrollTextLayer)
393
+ and layer.fade_length_px * 2 > layer.viewport.size.height
394
+ ):
395
+ raise ValueError(
396
+ f"scroll layer {layer.id} fade length exceeds its visible area"
397
+ )
398
+ return self
399
+
400
+
401
+ class BackgroundBinding(StrictModel):
402
+ slot: Identifier
403
+ asset: LocalAsset
404
+
405
+ @model_validator(mode="after")
406
+ def validate_asset(self) -> BackgroundBinding:
407
+ if self.asset.media_type not in {AssetMediaType.IMAGE, AssetMediaType.VIDEO}:
408
+ raise ValueError("background asset must be image or video")
409
+ return self
410
+
411
+
412
+ class TextBinding(StrictModel):
413
+ slot: Identifier
414
+ html: Annotated[str, StringConstraints(min_length=1)]
415
+ language: Annotated[
416
+ str, StringConstraints(strip_whitespace=True, min_length=2, max_length=16)
417
+ ]
418
+ font_size_scale: FontSizeScale = 1.0
419
+
420
+ @field_validator("html")
421
+ @classmethod
422
+ def reject_blank_html(cls, value: str) -> str:
423
+ if not re.sub(r"<[^>]+>|\s|&nbsp;", "", value, flags=re.IGNORECASE):
424
+ raise ValueError("html must contain visible text")
425
+ return value
426
+
427
+ @field_validator("language")
428
+ @classmethod
429
+ def normalize_language(cls, value: str) -> str:
430
+ return value.upper()
431
+
432
+
433
+ class ScrollBindings(StrictModel):
434
+ backgrounds: tuple[BackgroundBinding, ...] = ()
435
+ texts: tuple[TextBinding, ...] = ()
436
+
437
+ @model_validator(mode="after")
438
+ def validate_unique_bindings(self) -> ScrollBindings:
439
+ slots = [item.slot for item in self.backgrounds] + [
440
+ item.slot for item in self.texts
441
+ ]
442
+ if len(slots) != len(set(slots)):
443
+ raise ValueError("each scroll slot may be bound at most once")
444
+ return self
445
+
446
+
447
+ class AudioSpec(StrictModel):
448
+ mode: AudioMode = AudioMode.NONE
449
+ asset: LocalAsset | None = None
450
+ loop: bool = True
451
+ fade_in_s: Annotated[float, Field(ge=0)] = 1.0
452
+ fade_out_s: Annotated[float, Field(ge=0)] = 3.0
453
+ volume: Annotated[float, Field(gt=0, le=4)] = 1.0
454
+
455
+ @model_validator(mode="after")
456
+ def validate_asset(self) -> AudioSpec:
457
+ if self.mode == AudioMode.USE_ASSET:
458
+ if self.asset is None or self.asset.media_type != AssetMediaType.AUDIO:
459
+ raise ValueError("use_asset audio mode requires an audio asset")
460
+ elif self.asset is not None:
461
+ raise ValueError("audio asset is only valid for use_asset mode")
462
+ return self
463
+
464
+
465
+ class FirstFrameSpec(StrictModel):
466
+ mode: FirstFrameMode = FirstFrameMode.COMPOSED
467
+ asset: LocalAsset | None = None
468
+
469
+ @model_validator(mode="after")
470
+ def validate_asset(self) -> FirstFrameSpec:
471
+ if self.mode == FirstFrameMode.ASSET:
472
+ if self.asset is None or self.asset.media_type != AssetMediaType.IMAGE:
473
+ raise ValueError("asset first-frame mode requires an image asset")
474
+ elif self.asset is not None:
475
+ raise ValueError("first-frame asset is only valid for asset mode")
476
+ return self
477
+
478
+
479
+ class ImageOutputSpec(StrictModel):
480
+ format: Literal["png"] = "png"
481
+
482
+
483
+ class PureImageRenderTask(StrictModel):
484
+ schema_version: Literal["2.0"] = "2.0"
485
+ kind: Literal["pure_image"] = "pure_image"
486
+ template: PureImageTemplate
487
+ bindings: PureImageBindings
488
+ output: ImageOutputSpec = ImageOutputSpec()
489
+
490
+
491
+ class GraphicTextTemplate(StrictModel):
492
+ template_id: Identifier
493
+ revision: PositiveInt
494
+ canvas: Size
495
+ background_color: Color
496
+ image_layer: PureImageLayer
497
+ text_layer: TextLayer
498
+ rich_text: GraphicTextRichTextPolicy
499
+
500
+ @field_validator("background_color")
501
+ @classmethod
502
+ def normalize_background_color(cls, value: str) -> str:
503
+ return value.upper()
504
+
505
+ @model_validator(mode="after")
506
+ def validate_layers(self) -> GraphicTextTemplate:
507
+ layers = (self.image_layer, self.text_layer)
508
+ if self.image_layer.id == self.text_layer.id:
509
+ raise ValueError("graphic-text layer ids must be unique")
510
+ if self.image_layer.slot == self.text_layer.slot:
511
+ raise ValueError("graphic-text layer slots must be unique")
512
+ if self.image_layer.z_index >= self.text_layer.z_index:
513
+ raise ValueError("graphic-text image layer must be below the text layer")
514
+ if self.text_layer.style.adjust != TextAdjustMode.WIDTH:
515
+ raise ValueError("graphic-text text adjustment must be WIDTH")
516
+ for layer in layers:
517
+ box = layer.box
518
+ if (
519
+ box.position.x < 0
520
+ or box.position.y < 0
521
+ or box.position.x + box.size.width > self.canvas.width
522
+ or box.position.y + box.size.height > self.canvas.height
523
+ ):
524
+ raise ValueError(
525
+ f"graphic-text layer {layer.id} must remain inside the canvas"
526
+ )
527
+ return self
528
+
529
+
530
+ class GraphicTextBindings(StrictModel):
531
+ image: PureImageBinding
532
+ text: TextBinding
533
+
534
+
535
+ class GraphicTextRenderTask(StrictModel):
536
+ schema_version: Literal["2.0"] = "2.0"
537
+ kind: Literal["graphic_text"] = "graphic_text"
538
+ template: GraphicTextTemplate
539
+ bindings: GraphicTextBindings
540
+ output: ImageOutputSpec = ImageOutputSpec()
541
+
542
+ @model_validator(mode="after")
543
+ def validate_rich_text(self) -> GraphicTextRenderTask:
544
+ parser = _GraphicTextHtmlParser(set(self.template.rich_text.allowed_colors))
545
+ parser.feed(self.bindings.text.html)
546
+ parser.close()
547
+ parser.validate_complete()
548
+ return self
549
+
550
+
551
+ class VideoOutputSpec(StrictModel):
552
+ size: Size
553
+ fps: Annotated[int, Field(ge=1, le=120)] = 24
554
+ codec: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] = (
555
+ "libx264"
556
+ )
557
+ audio_codec: Annotated[
558
+ str, StringConstraints(strip_whitespace=True, min_length=1)
559
+ ] = "aac"
560
+ crf: Annotated[int, Field(ge=0, le=51)] = 28
561
+ preset: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] = (
562
+ "fast"
563
+ )
564
+ threads: Annotated[int, Field(ge=1, le=64)] = 6
565
+ metadata_comment: Annotated[str, StringConstraints(min_length=1)] = "orange"
566
+ minimum_duration_s: Annotated[float, Field(ge=0)] = 0.0
567
+
568
+
569
+ class ScrollRenderTask(StrictModel):
570
+ schema_version: Literal["2.0"] = "2.0"
571
+ kind: Literal["scroll_video"] = "scroll_video"
572
+ template: ScrollTemplate
573
+ bindings: ScrollBindings
574
+ audio: AudioSpec = AudioSpec()
575
+ first_frame: FirstFrameSpec = FirstFrameSpec()
576
+ output: VideoOutputSpec
577
+
578
+
579
+ class TimelineSubtitleLayout(StrictModel):
580
+ spacing_px: Annotated[int, Field(ge=0)] = 20
581
+
582
+
583
+ class TimelineSubtitleTemplate(StrictModel):
584
+ box: Box
585
+ style: TextStyle
586
+ layout: TimelineSubtitleLayout = TimelineSubtitleLayout()
587
+ base_font_size: PositiveInt = 18
588
+ max_blocks_per_frame: Annotated[int, Field(ge=1, le=3)] | None = None
589
+
590
+ @model_serializer(mode="wrap")
591
+ def serialize_template(self, handler: Any) -> dict[str, Any]:
592
+ result = handler(self)
593
+ if self.max_blocks_per_frame is None:
594
+ result.pop("max_blocks_per_frame", None)
595
+ return result
596
+
597
+
598
+ class TimelineTemplate(StrictModel):
599
+ template_id: Identifier
600
+ revision: PositiveInt
601
+ canvas: Size
602
+ subtitle: TimelineSubtitleTemplate
603
+
604
+ @model_validator(mode="after")
605
+ def validate_subtitle_box(self) -> TimelineTemplate:
606
+ if not self.subtitle.box.intersects(self.canvas):
607
+ raise ValueError("subtitle box does not intersect the canvas")
608
+ return self
609
+
610
+
611
+ class WpmSubtitleGenerationOptions(StrictModel):
612
+ wpm: PositiveFloat | None = None
613
+ min_words_per_block: PositiveInt = 8
614
+ max_blocks_per_frame: Annotated[int, Field(ge=0, le=20)] = 2
615
+ track_mode: TrackMode = TrackMode.SEQUENTIAL
616
+
617
+
618
+ class TimelineSubtitleBlock(StrictModel):
619
+ block_id: Identifier
620
+ html: Annotated[str, StringConstraints(min_length=1)]
621
+
622
+ @field_validator("html")
623
+ @classmethod
624
+ def reject_blank_html(cls, value: str) -> str:
625
+ if not re.sub(r"<[^>]+>|\s|&nbsp;", "", value, flags=re.IGNORECASE):
626
+ raise ValueError("html must contain visible text")
627
+ return value
628
+
629
+
630
+ class TimelineSubtitleFrameItem(StrictModel):
631
+ block_id: Identifier
632
+ slot_index: Annotated[int, Field(ge=0)]
633
+ start_time_s: Annotated[float, Field(ge=0, allow_inf_nan=False)]
634
+ end_time_s: Annotated[float, Field(gt=0, allow_inf_nan=False)]
635
+
636
+ @model_validator(mode="after")
637
+ def validate_time_range(self) -> TimelineSubtitleFrameItem:
638
+ if self.end_time_s <= self.start_time_s:
639
+ raise ValueError("end_time_s must be greater than start_time_s")
640
+ return self
641
+
642
+
643
+ class TimelineSubtitleFrameLayout(str, Enum):
644
+ STACK = "stack"
645
+ COLUMNS = "columns"
646
+
647
+
648
+ class TimelineSubtitleFrameReveal(str, Enum):
649
+ TOGETHER = "together"
650
+ PROGRESSIVE = "progressive"
651
+
652
+
653
+ class TimelineSubtitleFrame(StrictModel):
654
+ frame_id: Identifier
655
+ items: tuple[TimelineSubtitleFrameItem, ...]
656
+ layout: TimelineSubtitleFrameLayout | None = None
657
+ reveal: TimelineSubtitleFrameReveal | None = None
658
+
659
+ @model_serializer(mode="wrap")
660
+ def serialize_frame(self, handler: Any) -> dict[str, Any]:
661
+ result = handler(self)
662
+ if self.layout is None:
663
+ result.pop("layout", None)
664
+ if self.reveal is None:
665
+ result.pop("reveal", None)
666
+ return result
667
+
668
+ @model_validator(mode="after")
669
+ def validate_items(self) -> TimelineSubtitleFrame:
670
+ if not self.items:
671
+ raise ValueError("timeline subtitle frame must contain at least one item")
672
+ slot_indexes = [item.slot_index for item in self.items]
673
+ if len(slot_indexes) != len(set(slot_indexes)):
674
+ raise ValueError("timeline subtitle frame slot_index values must be unique")
675
+ if sorted(slot_indexes) != list(range(len(slot_indexes))):
676
+ raise ValueError("timeline subtitle frame slot_index values must start at zero and be contiguous")
677
+ return self
678
+
679
+
680
+ class TimelineSubtitleConfig(StrictModel):
681
+ schema_version: Literal["1.0", "2.0"] = "1.0"
682
+ language: Annotated[
683
+ str, StringConstraints(strip_whitespace=True, min_length=2, max_length=16)
684
+ ]
685
+ font_size_scale: FontSizeScale = 1.0
686
+ blocks: tuple[TimelineSubtitleBlock, ...]
687
+ frames: tuple[TimelineSubtitleFrame, ...]
688
+
689
+ @field_validator("language")
690
+ @classmethod
691
+ def normalize_language(cls, value: str) -> str:
692
+ return value.upper()
693
+
694
+ @model_validator(mode="after")
695
+ def validate_references(self) -> TimelineSubtitleConfig:
696
+ if not self.blocks:
697
+ raise ValueError("timeline subtitle config must contain at least one block")
698
+ if not self.frames:
699
+ raise ValueError("timeline subtitle config must contain at least one frame")
700
+ block_ids = [block.block_id for block in self.blocks]
701
+ if len(block_ids) != len(set(block_ids)):
702
+ raise ValueError("timeline subtitle block ids must be unique")
703
+ frame_ids = [frame.frame_id for frame in self.frames]
704
+ if len(frame_ids) != len(set(frame_ids)):
705
+ raise ValueError("timeline subtitle frame ids must be unique")
706
+ known_block_ids = set(block_ids)
707
+ referenced_block_ids = [
708
+ item.block_id for frame in self.frames for item in frame.items
709
+ ]
710
+ unknown_block_ids = sorted(set(referenced_block_ids) - known_block_ids)
711
+ if unknown_block_ids:
712
+ raise ValueError(
713
+ f"timeline frames reference unknown block ids: {unknown_block_ids}"
714
+ )
715
+ if self.schema_version == "1.0":
716
+ if any(frame.layout is not None or frame.reveal is not None for frame in self.frames):
717
+ raise ValueError("timeline subtitle config 1.0 does not support layout or reveal")
718
+ return self
719
+ if any(frame.layout is None or frame.reveal is None for frame in self.frames):
720
+ raise ValueError("timeline subtitle config 2.0 requires layout and reveal")
721
+ if len(referenced_block_ids) != len(set(referenced_block_ids)):
722
+ raise ValueError("timeline subtitle config 2.0 blocks must be used once")
723
+ if set(referenced_block_ids) != known_block_ids:
724
+ raise ValueError("timeline subtitle config 2.0 frames must use every block")
725
+ for frame in self.frames:
726
+ item_count = len(frame.items)
727
+ if frame.layout == TimelineSubtitleFrameLayout.STACK and not 1 <= item_count <= 3:
728
+ raise ValueError("stack frame must contain 1..3 blocks")
729
+ if (
730
+ frame.layout == TimelineSubtitleFrameLayout.COLUMNS
731
+ and item_count != 2
732
+ ):
733
+ raise ValueError("columns frame must contain 2 blocks")
734
+ return self
735
+
736
+
737
+ class TimelineBindings(StrictModel):
738
+ background: LocalAsset
739
+ subtitles: TimelineSubtitleConfig
740
+
741
+ @model_validator(mode="after")
742
+ def validate_background(self) -> TimelineBindings:
743
+ if self.background.media_type not in {
744
+ AssetMediaType.IMAGE,
745
+ AssetMediaType.VIDEO,
746
+ }:
747
+ raise ValueError("timeline background must be image or video")
748
+ return self
749
+
750
+
751
+ class TimelineRenderTask(StrictModel):
752
+ schema_version: Literal["2.0"] = "2.0"
753
+ kind: Literal["timeline_video"] = "timeline_video"
754
+ template: TimelineTemplate
755
+ bindings: TimelineBindings
756
+ audio: AudioSpec = AudioSpec()
757
+ output: VideoOutputSpec
758
+
759
+
760
+ RenderTask: TypeAlias = Annotated[ # noqa: UP040
761
+ PureImageRenderTask | GraphicTextRenderTask | ScrollRenderTask | TimelineRenderTask,
762
+ Field(discriminator="kind"),
763
+ ]