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.
- creative_render_engine/__init__.py +72 -0
- creative_render_engine/api.py +79 -0
- creative_render_engine/compiler.py +1428 -0
- creative_render_engine/engines/__init__.py +0 -0
- creative_render_engine/engines/common.py +484 -0
- creative_render_engine/engines/pure_image.py +99 -0
- creative_render_engine/errors.py +44 -0
- creative_render_engine/hashing.py +30 -0
- creative_render_engine/output_paths.py +75 -0
- creative_render_engine/plans.py +310 -0
- creative_render_engine/preview.py +203 -0
- creative_render_engine/py.typed +0 -0
- creative_render_engine/render.py +328 -0
- creative_render_engine/schemas.py +763 -0
- creative_render_engine/settings.py +31 -0
- creative_render_engine-0.7.0.dist-info/METADATA +59 -0
- creative_render_engine-0.7.0.dist-info/RECORD +19 -0
- creative_render_engine-0.7.0.dist-info/WHEEL +5 -0
- creative_render_engine-0.7.0.dist-info/top_level.txt +1 -0
|
@@ -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| ", "", 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| ", "", 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
|
+
]
|