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,1428 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
|
|
8
|
+
from moviepy import VideoFileClip
|
|
9
|
+
from PIL import Image, ImageOps
|
|
10
|
+
from pydantic import TypeAdapter, ValidationError
|
|
11
|
+
from textblockrenderer import (
|
|
12
|
+
FontSpec,
|
|
13
|
+
LayoutEngine,
|
|
14
|
+
RenderConstraint,
|
|
15
|
+
RenderStyle,
|
|
16
|
+
SplitConfig,
|
|
17
|
+
TextMeasurer,
|
|
18
|
+
build_overlapping_timeline,
|
|
19
|
+
get_wpm_for_language,
|
|
20
|
+
measure_text_image,
|
|
21
|
+
split_html_to_colored_blocks,
|
|
22
|
+
)
|
|
23
|
+
from textblockrenderer import (
|
|
24
|
+
TrackMode as RendererTrackMode,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
from .errors import AssetError, CompileError, ContractError
|
|
28
|
+
from .hashing import file_sha256, stable_sha256
|
|
29
|
+
from .plans import (
|
|
30
|
+
GraphicTextMeasurement,
|
|
31
|
+
GraphicTextRenderPlan,
|
|
32
|
+
GraphicTextRenderPlanBody,
|
|
33
|
+
PureImageRenderPlan,
|
|
34
|
+
PureImageRenderPlanBody,
|
|
35
|
+
RenderPlan,
|
|
36
|
+
ResolvedAsset,
|
|
37
|
+
ResolvedAudioSpec,
|
|
38
|
+
ResolvedFirstFrameSpec,
|
|
39
|
+
ResolvedFontStyle,
|
|
40
|
+
ResolvedMaskStyle,
|
|
41
|
+
ResolvedTextStyle,
|
|
42
|
+
ScrollBackgroundPlan,
|
|
43
|
+
ScrollLongTextPlan,
|
|
44
|
+
ScrollRenderPlan,
|
|
45
|
+
ScrollRenderPlanBody,
|
|
46
|
+
ScrollTextPlan,
|
|
47
|
+
TimelineBlockPlan,
|
|
48
|
+
TimelineBlockTimingPlan,
|
|
49
|
+
TimelineFramePlan,
|
|
50
|
+
TimelineRenderPlan,
|
|
51
|
+
TimelineRenderPlanBody,
|
|
52
|
+
TimelineWordPlan,
|
|
53
|
+
)
|
|
54
|
+
from .schemas import (
|
|
55
|
+
AssetMediaType,
|
|
56
|
+
AudioSpec,
|
|
57
|
+
BackgroundLayer,
|
|
58
|
+
FirstFrameSpec,
|
|
59
|
+
FontAsset,
|
|
60
|
+
FontSizeScale,
|
|
61
|
+
GraphicTextRenderTask,
|
|
62
|
+
LocalAsset,
|
|
63
|
+
Point,
|
|
64
|
+
PureImageRenderTask,
|
|
65
|
+
RenderTask,
|
|
66
|
+
ScrollRenderTask,
|
|
67
|
+
ScrollTextLayer,
|
|
68
|
+
Size,
|
|
69
|
+
TextStyle,
|
|
70
|
+
TimelineRenderTask,
|
|
71
|
+
TimelineSubtitleBlock,
|
|
72
|
+
TimelineSubtitleConfig,
|
|
73
|
+
TimelineSubtitleFrame,
|
|
74
|
+
TimelineSubtitleFrameItem,
|
|
75
|
+
TimelineTemplate,
|
|
76
|
+
WpmSubtitleGenerationOptions,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
_FONT_SIZE_SCALE_ADAPTER: TypeAdapter[float] = TypeAdapter(FontSizeScale)
|
|
80
|
+
_RENDER_TASK_ADAPTER: TypeAdapter[RenderTask] = TypeAdapter(RenderTask)
|
|
81
|
+
|
|
82
|
+
_IMAGE_EXTENSIONS = {".bmp", ".gif", ".jpeg", ".jpg", ".png", ".tif", ".tiff", ".webp"}
|
|
83
|
+
_VIDEO_EXTENSIONS = {".avi", ".m4v", ".mkv", ".mov", ".mp4", ".webm", ".wmv"}
|
|
84
|
+
_AUDIO_EXTENSIONS = {".aac", ".flac", ".m4a", ".mp3", ".ogg", ".wav", ".wma"}
|
|
85
|
+
_FONT_EXTENSIONS = {".otf", ".ttc", ".ttf", ".woff", ".woff2"}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def validate_render_task(value: RenderTask | dict[str, Any]) -> RenderTask:
|
|
89
|
+
if isinstance(
|
|
90
|
+
value,
|
|
91
|
+
(
|
|
92
|
+
PureImageRenderTask,
|
|
93
|
+
GraphicTextRenderTask,
|
|
94
|
+
ScrollRenderTask,
|
|
95
|
+
TimelineRenderTask,
|
|
96
|
+
),
|
|
97
|
+
):
|
|
98
|
+
return value
|
|
99
|
+
try:
|
|
100
|
+
return _RENDER_TASK_ADAPTER.validate_python(value)
|
|
101
|
+
except ValidationError as exc:
|
|
102
|
+
raise ContractError(
|
|
103
|
+
"render task validation failed",
|
|
104
|
+
code="invalid_render_task",
|
|
105
|
+
details={"errors": exc.errors(include_url=False)},
|
|
106
|
+
) from exc
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def compile_task(value: RenderTask | dict[str, Any]) -> RenderPlan:
|
|
110
|
+
task = validate_render_task(value)
|
|
111
|
+
if isinstance(task, PureImageRenderTask):
|
|
112
|
+
return _compile_pure_image(task)
|
|
113
|
+
if isinstance(task, GraphicTextRenderTask):
|
|
114
|
+
return _compile_graphic_text(task)
|
|
115
|
+
if isinstance(task, ScrollRenderTask):
|
|
116
|
+
return _compile_scroll(task)
|
|
117
|
+
return _compile_timeline(task)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def measure_graphic_text_task(
|
|
121
|
+
value: GraphicTextRenderTask | dict[str, Any],
|
|
122
|
+
) -> GraphicTextMeasurement:
|
|
123
|
+
task = validate_render_task(value)
|
|
124
|
+
if not isinstance(task, GraphicTextRenderTask):
|
|
125
|
+
raise ContractError(
|
|
126
|
+
"graphic-text measurement requires a graphic_text task",
|
|
127
|
+
code="invalid_graphic_text_task",
|
|
128
|
+
field_path="kind",
|
|
129
|
+
)
|
|
130
|
+
_, measurement = _resolve_graphic_text(task)
|
|
131
|
+
return measurement
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _compile_pure_image(task: PureImageRenderTask) -> PureImageRenderPlan:
|
|
135
|
+
layer = task.template.layer
|
|
136
|
+
binding = task.bindings.image
|
|
137
|
+
if binding.slot != layer.slot:
|
|
138
|
+
raise CompileError(
|
|
139
|
+
f"pure-image binding slot {binding.slot!r} does not match template slot {layer.slot!r}",
|
|
140
|
+
code="slot_mismatch",
|
|
141
|
+
field_path="bindings.image.slot",
|
|
142
|
+
)
|
|
143
|
+
image = _resolve_pure_image_asset(binding.asset, "bindings.image.asset")
|
|
144
|
+
body = PureImageRenderPlanBody(
|
|
145
|
+
canvas=task.template.canvas,
|
|
146
|
+
layer=layer,
|
|
147
|
+
image=image,
|
|
148
|
+
output=task.output,
|
|
149
|
+
asset_ids=(image.id,),
|
|
150
|
+
)
|
|
151
|
+
plan_hash = stable_sha256(
|
|
152
|
+
{
|
|
153
|
+
"schema_version": task.schema_version,
|
|
154
|
+
"kind": task.kind,
|
|
155
|
+
"template_id": task.template.template_id,
|
|
156
|
+
"template_revision": task.template.revision,
|
|
157
|
+
"body": _portable_plan_payload(body.model_dump(mode="json")),
|
|
158
|
+
}
|
|
159
|
+
)
|
|
160
|
+
return PureImageRenderPlan(
|
|
161
|
+
template_id=task.template.template_id,
|
|
162
|
+
template_revision=task.template.revision,
|
|
163
|
+
plan_hash=plan_hash,
|
|
164
|
+
body=body,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _compile_graphic_text(task: GraphicTextRenderTask) -> GraphicTextRenderPlan:
|
|
169
|
+
style, measurement = _resolve_graphic_text(task)
|
|
170
|
+
if not measurement.fits:
|
|
171
|
+
raise CompileError(
|
|
172
|
+
f"graphic-text content exceeds its box by {measurement.overflow_character_count} characters",
|
|
173
|
+
code="graphic_text_overflow",
|
|
174
|
+
field_path="bindings.text.html",
|
|
175
|
+
details=measurement.model_dump(mode="json"),
|
|
176
|
+
)
|
|
177
|
+
image = _resolve_pure_image_asset(
|
|
178
|
+
task.bindings.image.asset,
|
|
179
|
+
"bindings.image.asset",
|
|
180
|
+
)
|
|
181
|
+
text_layer = task.template.text_layer
|
|
182
|
+
body = GraphicTextRenderPlanBody(
|
|
183
|
+
canvas=task.template.canvas,
|
|
184
|
+
background_color=task.template.background_color,
|
|
185
|
+
image_layer=task.template.image_layer,
|
|
186
|
+
image=image,
|
|
187
|
+
text=ScrollTextPlan(
|
|
188
|
+
id=text_layer.id,
|
|
189
|
+
z_index=text_layer.z_index,
|
|
190
|
+
box=text_layer.box,
|
|
191
|
+
opacity=text_layer.opacity,
|
|
192
|
+
html=task.bindings.text.html,
|
|
193
|
+
language=task.bindings.text.language,
|
|
194
|
+
style=style,
|
|
195
|
+
),
|
|
196
|
+
measurement=measurement,
|
|
197
|
+
output=task.output,
|
|
198
|
+
asset_ids=(image.id, style.font.font.id),
|
|
199
|
+
)
|
|
200
|
+
plan_hash = stable_sha256(
|
|
201
|
+
{
|
|
202
|
+
"schema_version": task.schema_version,
|
|
203
|
+
"kind": task.kind,
|
|
204
|
+
"template_id": task.template.template_id,
|
|
205
|
+
"template_revision": task.template.revision,
|
|
206
|
+
"body": _portable_plan_payload(body.model_dump(mode="json")),
|
|
207
|
+
}
|
|
208
|
+
)
|
|
209
|
+
return GraphicTextRenderPlan(
|
|
210
|
+
template_id=task.template.template_id,
|
|
211
|
+
template_revision=task.template.revision,
|
|
212
|
+
plan_hash=plan_hash,
|
|
213
|
+
body=body,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _resolve_graphic_text(
|
|
218
|
+
task: GraphicTextRenderTask,
|
|
219
|
+
) -> tuple[ResolvedTextStyle, GraphicTextMeasurement]:
|
|
220
|
+
image_layer = task.template.image_layer
|
|
221
|
+
text_layer = task.template.text_layer
|
|
222
|
+
if task.bindings.image.slot != image_layer.slot:
|
|
223
|
+
raise CompileError(
|
|
224
|
+
"graphic-text image binding slot does not match the template",
|
|
225
|
+
code="slot_mismatch",
|
|
226
|
+
field_path="bindings.image.slot",
|
|
227
|
+
)
|
|
228
|
+
if task.bindings.text.slot != text_layer.slot:
|
|
229
|
+
raise CompileError(
|
|
230
|
+
"graphic-text text binding slot does not match the template",
|
|
231
|
+
code="slot_mismatch",
|
|
232
|
+
field_path="bindings.text.slot",
|
|
233
|
+
)
|
|
234
|
+
style = _resolve_text_style(
|
|
235
|
+
text_layer.style,
|
|
236
|
+
"template.text_layer.style",
|
|
237
|
+
font_size_scale=task.bindings.text.font_size_scale,
|
|
238
|
+
font_size_scale_field_path="bindings.text.font_size_scale",
|
|
239
|
+
)
|
|
240
|
+
font_spec, constraint, split_config, renderer_style = _renderer_models(
|
|
241
|
+
style,
|
|
242
|
+
language=task.bindings.text.language,
|
|
243
|
+
max_width=text_layer.box.size.width,
|
|
244
|
+
max_height=text_layer.box.size.height,
|
|
245
|
+
preferred_height=text_layer.box.size.height,
|
|
246
|
+
)
|
|
247
|
+
block = measure_text_image(
|
|
248
|
+
html_text=task.bindings.text.html,
|
|
249
|
+
font_spec=font_spec,
|
|
250
|
+
constraint=constraint,
|
|
251
|
+
adjust="WIDTH",
|
|
252
|
+
vertical_align=style.vertical_alignment.value,
|
|
253
|
+
split_config=split_config,
|
|
254
|
+
style=renderer_style,
|
|
255
|
+
base_font_size=18,
|
|
256
|
+
)
|
|
257
|
+
fit_line_count = _fit_line_count(
|
|
258
|
+
tuple(block.line_heights),
|
|
259
|
+
line_spacing=style.font.line_spacing,
|
|
260
|
+
max_height=text_layer.box.size.height - style.padding.y * 2,
|
|
261
|
+
)
|
|
262
|
+
overflow_character_count = sum(
|
|
263
|
+
_renderer_line_character_count(line) for line in block.lines[fit_line_count:]
|
|
264
|
+
)
|
|
265
|
+
image_height = int(block.image_height or block.height)
|
|
266
|
+
remaining_height_px = text_layer.box.size.height - image_height
|
|
267
|
+
measurement = GraphicTextMeasurement(
|
|
268
|
+
plain_text=block.plain_text,
|
|
269
|
+
image_width=int(block.image_width or block.width),
|
|
270
|
+
image_height=image_height,
|
|
271
|
+
line_count=len(block.lines),
|
|
272
|
+
fit_line_count=fit_line_count,
|
|
273
|
+
overflow_character_count=overflow_character_count,
|
|
274
|
+
remaining_height_px=remaining_height_px,
|
|
275
|
+
fill_ratio=round(image_height / text_layer.box.size.height, 6),
|
|
276
|
+
fits=image_height <= text_layer.box.size.height,
|
|
277
|
+
)
|
|
278
|
+
return style, measurement
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _fit_line_count(
|
|
282
|
+
line_heights: tuple[int, ...],
|
|
283
|
+
*,
|
|
284
|
+
line_spacing: int,
|
|
285
|
+
max_height: int,
|
|
286
|
+
) -> int:
|
|
287
|
+
used = 0
|
|
288
|
+
for index, line_height in enumerate(line_heights):
|
|
289
|
+
candidate = used + (line_spacing if index else 0) + line_height
|
|
290
|
+
if candidate > max_height:
|
|
291
|
+
return index
|
|
292
|
+
used = candidate
|
|
293
|
+
return len(line_heights)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _renderer_line_character_count(line: list[Any]) -> int:
|
|
297
|
+
return sum(
|
|
298
|
+
len(str(word.word)) + (1 if bool(getattr(word, "leading_space", False)) else 0)
|
|
299
|
+
for word in line
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
_SCROLL_BUSINESS_MINIMUM_DURATION_S = 60.0
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _compile_scroll(task: ScrollRenderTask) -> ScrollRenderPlan:
|
|
307
|
+
background_bindings = {
|
|
308
|
+
binding.slot: binding for binding in task.bindings.backgrounds
|
|
309
|
+
}
|
|
310
|
+
text_bindings = {binding.slot: binding for binding in task.bindings.texts}
|
|
311
|
+
template_slots = {layer.slot for layer in task.template.layers}
|
|
312
|
+
provided_slots = set(background_bindings) | set(text_bindings)
|
|
313
|
+
unknown_slots = sorted(provided_slots - template_slots)
|
|
314
|
+
if unknown_slots:
|
|
315
|
+
raise CompileError(
|
|
316
|
+
f"bindings reference unknown slots: {unknown_slots}",
|
|
317
|
+
code="unknown_slots",
|
|
318
|
+
field_path="bindings",
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
resolved_layers: list[
|
|
322
|
+
ScrollBackgroundPlan | ScrollTextPlan | ScrollLongTextPlan
|
|
323
|
+
] = []
|
|
324
|
+
asset_map: dict[str, ResolvedAsset] = {}
|
|
325
|
+
for layer in sorted(task.template.layers, key=lambda item: item.z_index):
|
|
326
|
+
if isinstance(layer, BackgroundLayer):
|
|
327
|
+
background_binding = background_bindings.get(layer.slot)
|
|
328
|
+
if background_binding is None:
|
|
329
|
+
if layer.required:
|
|
330
|
+
raise CompileError(
|
|
331
|
+
f"missing required background binding for slot {layer.slot}",
|
|
332
|
+
code="missing_binding",
|
|
333
|
+
field_path=f"bindings.backgrounds.{layer.slot}",
|
|
334
|
+
)
|
|
335
|
+
continue
|
|
336
|
+
asset = _resolve_local_asset(
|
|
337
|
+
background_binding.asset,
|
|
338
|
+
f"bindings.backgrounds.{layer.slot}.asset",
|
|
339
|
+
)
|
|
340
|
+
asset_map[asset.id] = asset
|
|
341
|
+
resolved_layers.append(
|
|
342
|
+
ScrollBackgroundPlan(
|
|
343
|
+
id=layer.id,
|
|
344
|
+
z_index=layer.z_index,
|
|
345
|
+
box=layer.box,
|
|
346
|
+
opacity=layer.opacity,
|
|
347
|
+
crop=layer.crop,
|
|
348
|
+
asset=asset,
|
|
349
|
+
)
|
|
350
|
+
)
|
|
351
|
+
continue
|
|
352
|
+
|
|
353
|
+
text_binding = text_bindings.get(layer.slot)
|
|
354
|
+
if text_binding is None:
|
|
355
|
+
if layer.required:
|
|
356
|
+
raise CompileError(
|
|
357
|
+
f"missing required text binding for slot {layer.slot}",
|
|
358
|
+
code="missing_binding",
|
|
359
|
+
field_path=f"bindings.texts.{layer.slot}",
|
|
360
|
+
)
|
|
361
|
+
continue
|
|
362
|
+
style = _resolve_text_style(
|
|
363
|
+
layer.style,
|
|
364
|
+
f"template.layers.{layer.id}.style",
|
|
365
|
+
font_size_scale=text_binding.font_size_scale,
|
|
366
|
+
font_size_scale_field_path=f"bindings.texts.{layer.slot}.font_size_scale",
|
|
367
|
+
)
|
|
368
|
+
asset_map[style.font.font.id] = style.font.font
|
|
369
|
+
if isinstance(layer, ScrollTextLayer):
|
|
370
|
+
estimated_size = _measure_long_text(
|
|
371
|
+
text_binding.html,
|
|
372
|
+
text_binding.language,
|
|
373
|
+
layer.viewport.size.width,
|
|
374
|
+
style,
|
|
375
|
+
)
|
|
376
|
+
start_y = layer.viewport.size.height - layer.start_offset_y_px
|
|
377
|
+
end_y = -estimated_size.height
|
|
378
|
+
scroll_distance = float(start_y - end_y)
|
|
379
|
+
if scroll_distance <= 0:
|
|
380
|
+
raise CompileError(
|
|
381
|
+
"scroll start position must precede the fully-hidden end position",
|
|
382
|
+
code="invalid_scroll_distance",
|
|
383
|
+
field_path=f"template.layers.{layer.id}.start_offset_y_px",
|
|
384
|
+
)
|
|
385
|
+
scroll_duration = scroll_distance / layer.scroll_speed_px_s
|
|
386
|
+
resolved_layers.append(
|
|
387
|
+
ScrollLongTextPlan(
|
|
388
|
+
id=layer.id,
|
|
389
|
+
z_index=layer.z_index,
|
|
390
|
+
viewport=layer.viewport,
|
|
391
|
+
opacity=layer.opacity,
|
|
392
|
+
html=text_binding.html,
|
|
393
|
+
language=text_binding.language,
|
|
394
|
+
style=style,
|
|
395
|
+
fade_length_px=layer.fade_length_px,
|
|
396
|
+
scroll_speed_px_s=layer.scroll_speed_px_s,
|
|
397
|
+
start_offset_y_px=layer.start_offset_y_px,
|
|
398
|
+
start_y_px=start_y,
|
|
399
|
+
end_y_px=end_y,
|
|
400
|
+
begin_hold_s=layer.begin_hold_s,
|
|
401
|
+
end_hold_s=layer.end_hold_s,
|
|
402
|
+
estimated_image_size=estimated_size,
|
|
403
|
+
scroll_distance_px=scroll_distance,
|
|
404
|
+
scroll_duration_s=scroll_duration,
|
|
405
|
+
)
|
|
406
|
+
)
|
|
407
|
+
else:
|
|
408
|
+
resolved_layers.append(
|
|
409
|
+
ScrollTextPlan(
|
|
410
|
+
id=layer.id,
|
|
411
|
+
z_index=layer.z_index,
|
|
412
|
+
box=layer.box,
|
|
413
|
+
opacity=layer.opacity,
|
|
414
|
+
html=text_binding.html,
|
|
415
|
+
language=text_binding.language,
|
|
416
|
+
style=style,
|
|
417
|
+
)
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
audio = _resolve_audio(task.audio, "audio")
|
|
421
|
+
if audio.asset is not None:
|
|
422
|
+
asset_map[audio.asset.id] = audio.asset
|
|
423
|
+
first_frame = _resolve_first_frame(task.first_frame, "first_frame")
|
|
424
|
+
if first_frame.asset is not None:
|
|
425
|
+
asset_map[first_frame.asset.id] = first_frame.asset
|
|
426
|
+
|
|
427
|
+
scroll_layer = next(
|
|
428
|
+
(layer for layer in resolved_layers if isinstance(layer, ScrollLongTextPlan)),
|
|
429
|
+
None,
|
|
430
|
+
)
|
|
431
|
+
computed_duration = (
|
|
432
|
+
scroll_layer.begin_hold_s
|
|
433
|
+
+ scroll_layer.scroll_duration_s
|
|
434
|
+
+ scroll_layer.end_hold_s
|
|
435
|
+
if scroll_layer is not None
|
|
436
|
+
else 0.0
|
|
437
|
+
)
|
|
438
|
+
if scroll_layer is not None:
|
|
439
|
+
duration = max(computed_duration, _SCROLL_BUSINESS_MINIMUM_DURATION_S)
|
|
440
|
+
else:
|
|
441
|
+
duration = task.output.minimum_duration_s
|
|
442
|
+
if duration <= 0:
|
|
443
|
+
raise CompileError(
|
|
444
|
+
"scroll task duration must be positive",
|
|
445
|
+
code="invalid_duration",
|
|
446
|
+
field_path="output.minimum_duration_s",
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
body = ScrollRenderPlanBody(
|
|
450
|
+
canvas=task.template.canvas,
|
|
451
|
+
layers=tuple(resolved_layers),
|
|
452
|
+
audio=audio,
|
|
453
|
+
first_frame=first_frame,
|
|
454
|
+
output=task.output,
|
|
455
|
+
duration_s=round(duration, 6),
|
|
456
|
+
asset_ids=tuple(sorted(asset_map)),
|
|
457
|
+
)
|
|
458
|
+
hash_body = _portable_plan_payload(body.model_dump(mode="json"))
|
|
459
|
+
plan_hash = stable_sha256(
|
|
460
|
+
{
|
|
461
|
+
"schema_version": task.schema_version,
|
|
462
|
+
"kind": task.kind,
|
|
463
|
+
"template_id": task.template.template_id,
|
|
464
|
+
"template_revision": task.template.revision,
|
|
465
|
+
"body": hash_body,
|
|
466
|
+
}
|
|
467
|
+
)
|
|
468
|
+
return ScrollRenderPlan(
|
|
469
|
+
template_id=task.template.template_id,
|
|
470
|
+
template_revision=task.template.revision,
|
|
471
|
+
plan_hash=plan_hash,
|
|
472
|
+
body=body,
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def generate_timeline_subtitles(
|
|
477
|
+
*,
|
|
478
|
+
template: TimelineTemplate | dict[str, Any],
|
|
479
|
+
html: str,
|
|
480
|
+
language: str,
|
|
481
|
+
font_size_scale: float = 1.0,
|
|
482
|
+
options: WpmSubtitleGenerationOptions | dict[str, Any] | None = None,
|
|
483
|
+
) -> TimelineSubtitleConfig:
|
|
484
|
+
try:
|
|
485
|
+
resolved_template = (
|
|
486
|
+
template
|
|
487
|
+
if isinstance(template, TimelineTemplate)
|
|
488
|
+
else TimelineTemplate.model_validate(template)
|
|
489
|
+
)
|
|
490
|
+
resolved_font_size_scale = _FONT_SIZE_SCALE_ADAPTER.validate_python(
|
|
491
|
+
font_size_scale
|
|
492
|
+
)
|
|
493
|
+
resolved_options = (
|
|
494
|
+
options
|
|
495
|
+
if isinstance(options, WpmSubtitleGenerationOptions)
|
|
496
|
+
else WpmSubtitleGenerationOptions.model_validate(options or {})
|
|
497
|
+
)
|
|
498
|
+
except ValidationError as exc:
|
|
499
|
+
raise ContractError(
|
|
500
|
+
"timeline generation input validation failed",
|
|
501
|
+
code="invalid_timeline_generation_input",
|
|
502
|
+
details={"errors": exc.errors(include_url=False)},
|
|
503
|
+
) from exc
|
|
504
|
+
normalized_language = language.strip().upper()
|
|
505
|
+
if not 2 <= len(normalized_language) <= 16:
|
|
506
|
+
raise ContractError(
|
|
507
|
+
"language must contain between 2 and 16 characters",
|
|
508
|
+
code="invalid_timeline_generation_input",
|
|
509
|
+
field_path="language",
|
|
510
|
+
)
|
|
511
|
+
if not re.sub(r"<[^>]+>|\s| ", "", html, flags=re.IGNORECASE):
|
|
512
|
+
raise ContractError(
|
|
513
|
+
"html must contain visible text",
|
|
514
|
+
code="invalid_timeline_generation_input",
|
|
515
|
+
field_path="html",
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
style = _resolve_text_style(
|
|
519
|
+
resolved_template.subtitle.style,
|
|
520
|
+
"template.subtitle.style",
|
|
521
|
+
font_size_scale=resolved_font_size_scale,
|
|
522
|
+
font_size_scale_field_path="font_size_scale",
|
|
523
|
+
)
|
|
524
|
+
font_spec, constraint, _, renderer_style = _renderer_models(
|
|
525
|
+
style,
|
|
526
|
+
language=normalized_language,
|
|
527
|
+
max_width=resolved_template.subtitle.box.size.width,
|
|
528
|
+
max_height=resolved_template.canvas.height,
|
|
529
|
+
preferred_height=resolved_template.subtitle.box.size.height,
|
|
530
|
+
min_words_per_block=resolved_options.min_words_per_block,
|
|
531
|
+
)
|
|
532
|
+
wpm = resolved_options.wpm or get_wpm_for_language(normalized_language)
|
|
533
|
+
try:
|
|
534
|
+
renderer_frames = build_overlapping_timeline(
|
|
535
|
+
html_text=html,
|
|
536
|
+
font_spec=font_spec,
|
|
537
|
+
constraint=constraint,
|
|
538
|
+
split_config=SplitConfig(
|
|
539
|
+
min_words_per_block=resolved_options.min_words_per_block,
|
|
540
|
+
language=normalized_language,
|
|
541
|
+
),
|
|
542
|
+
wpm=wpm,
|
|
543
|
+
style=renderer_style,
|
|
544
|
+
base_font_size=resolved_template.subtitle.base_font_size,
|
|
545
|
+
spacing=resolved_template.subtitle.layout.spacing_px,
|
|
546
|
+
max_blocks_per_frame=resolved_options.max_blocks_per_frame,
|
|
547
|
+
track_mode=RendererTrackMode(resolved_options.track_mode.value),
|
|
548
|
+
)
|
|
549
|
+
except Exception as exc:
|
|
550
|
+
raise CompileError(
|
|
551
|
+
"failed to generate timeline subtitles",
|
|
552
|
+
code="timeline_generation_failed",
|
|
553
|
+
field_path="html",
|
|
554
|
+
) from exc
|
|
555
|
+
if not renderer_frames:
|
|
556
|
+
raise CompileError(
|
|
557
|
+
"timeline generation produced no subtitle blocks",
|
|
558
|
+
code="empty_timeline",
|
|
559
|
+
field_path="html",
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
block_ids: dict[int, str] = {}
|
|
563
|
+
blocks: list[TimelineSubtitleBlock] = []
|
|
564
|
+
frames: list[TimelineSubtitleFrame] = []
|
|
565
|
+
for frame_index, renderer_frame in enumerate(renderer_frames):
|
|
566
|
+
items: list[TimelineSubtitleFrameItem] = []
|
|
567
|
+
for slot_index, timing in enumerate(renderer_frame.block_timings):
|
|
568
|
+
block_key = id(timing.block)
|
|
569
|
+
block_id = block_ids.get(block_key)
|
|
570
|
+
if block_id is None:
|
|
571
|
+
block_id = f"block-{len(block_ids) + 1:04d}"
|
|
572
|
+
block_ids[block_key] = block_id
|
|
573
|
+
blocks.append(
|
|
574
|
+
TimelineSubtitleBlock(
|
|
575
|
+
block_id=block_id, html=timing.block.html_text
|
|
576
|
+
)
|
|
577
|
+
)
|
|
578
|
+
items.append(
|
|
579
|
+
TimelineSubtitleFrameItem(
|
|
580
|
+
block_id=block_id,
|
|
581
|
+
slot_index=slot_index,
|
|
582
|
+
start_time_s=round(float(timing.start_time), 6),
|
|
583
|
+
end_time_s=round(float(timing.end_time), 6),
|
|
584
|
+
)
|
|
585
|
+
)
|
|
586
|
+
frames.append(
|
|
587
|
+
TimelineSubtitleFrame(
|
|
588
|
+
frame_id=f"frame-{frame_index + 1:04d}",
|
|
589
|
+
items=tuple(items),
|
|
590
|
+
)
|
|
591
|
+
)
|
|
592
|
+
return TimelineSubtitleConfig(
|
|
593
|
+
language=normalized_language,
|
|
594
|
+
font_size_scale=resolved_font_size_scale,
|
|
595
|
+
blocks=tuple(blocks),
|
|
596
|
+
frames=tuple(frames),
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _compile_timeline(task: TimelineRenderTask) -> TimelineRenderPlan:
|
|
601
|
+
if task.bindings.subtitles.schema_version == "2.0":
|
|
602
|
+
return _compile_composed_timeline(task)
|
|
603
|
+
|
|
604
|
+
background = _resolve_local_asset(task.bindings.background, "bindings.background")
|
|
605
|
+
subtitle_template = task.template.subtitle
|
|
606
|
+
subtitle_config = task.bindings.subtitles
|
|
607
|
+
style = _resolve_text_style(
|
|
608
|
+
subtitle_template.style,
|
|
609
|
+
"template.subtitle.style",
|
|
610
|
+
font_size_scale=subtitle_config.font_size_scale,
|
|
611
|
+
font_size_scale_field_path="bindings.subtitles.font_size_scale",
|
|
612
|
+
)
|
|
613
|
+
audio = _resolve_audio(task.audio, "audio")
|
|
614
|
+
font_spec, constraint, split_config, renderer_style = _renderer_models(
|
|
615
|
+
style,
|
|
616
|
+
language=subtitle_config.language,
|
|
617
|
+
max_width=subtitle_template.box.size.width,
|
|
618
|
+
max_height=task.template.canvas.height,
|
|
619
|
+
preferred_height=subtitle_template.box.size.height,
|
|
620
|
+
)
|
|
621
|
+
measurer = TextMeasurer(font_spec=font_spec, stroke_size=style.font.stroke_size)
|
|
622
|
+
layout_engine = LayoutEngine(constraint, renderer_style, measurer)
|
|
623
|
+
effective_constraint = layout_engine.compute_effective_constraint()
|
|
624
|
+
|
|
625
|
+
blocks: list[TimelineBlockPlan] = []
|
|
626
|
+
block_plan_by_id: dict[str, TimelineBlockPlan] = {}
|
|
627
|
+
for block_index, configured_block in enumerate(subtitle_config.blocks):
|
|
628
|
+
field_path = f"bindings.subtitles.blocks.{block_index}.html"
|
|
629
|
+
try:
|
|
630
|
+
renderer_blocks = split_html_to_colored_blocks(
|
|
631
|
+
configured_block.html,
|
|
632
|
+
measurer,
|
|
633
|
+
effective_constraint,
|
|
634
|
+
split_config,
|
|
635
|
+
base_font_size=subtitle_template.base_font_size,
|
|
636
|
+
)
|
|
637
|
+
except Exception as exc:
|
|
638
|
+
raise CompileError(
|
|
639
|
+
"subtitle block cannot fit within the canvas",
|
|
640
|
+
code="subtitle_layout_overflow",
|
|
641
|
+
field_path=field_path,
|
|
642
|
+
details={"block_id": configured_block.block_id},
|
|
643
|
+
) from exc
|
|
644
|
+
if len(renderer_blocks) != 1:
|
|
645
|
+
raise CompileError(
|
|
646
|
+
"subtitle block must remain one final renderable block",
|
|
647
|
+
code="subtitle_layout_overflow",
|
|
648
|
+
field_path=field_path,
|
|
649
|
+
details={
|
|
650
|
+
"block_id": configured_block.block_id,
|
|
651
|
+
"rendered_block_count": len(renderer_blocks),
|
|
652
|
+
},
|
|
653
|
+
)
|
|
654
|
+
renderer_block = renderer_blocks[0]
|
|
655
|
+
text_layout = layout_engine.compute_text_layout(
|
|
656
|
+
renderer_block,
|
|
657
|
+
align=renderer_style.alignment,
|
|
658
|
+
)
|
|
659
|
+
width = int(text_layout.image_width)
|
|
660
|
+
height = int(text_layout.image_height)
|
|
661
|
+
renderer_block.image_width = width
|
|
662
|
+
renderer_block.image_height = height
|
|
663
|
+
if width <= 0 or height <= 0:
|
|
664
|
+
raise CompileError(
|
|
665
|
+
"timeline block has invalid dimensions",
|
|
666
|
+
code="invalid_block_dimensions",
|
|
667
|
+
field_path=field_path,
|
|
668
|
+
details={
|
|
669
|
+
"block_id": configured_block.block_id,
|
|
670
|
+
"width": width,
|
|
671
|
+
"height": height,
|
|
672
|
+
},
|
|
673
|
+
)
|
|
674
|
+
if width > task.template.canvas.width or height > task.template.canvas.height:
|
|
675
|
+
raise CompileError(
|
|
676
|
+
"subtitle block cannot fit within the canvas",
|
|
677
|
+
code="subtitle_layout_overflow",
|
|
678
|
+
field_path=field_path,
|
|
679
|
+
details={
|
|
680
|
+
"block_id": configured_block.block_id,
|
|
681
|
+
"required": {"width": width, "height": height},
|
|
682
|
+
"available": task.template.canvas.model_dump(mode="json"),
|
|
683
|
+
},
|
|
684
|
+
)
|
|
685
|
+
words = tuple(
|
|
686
|
+
TimelineWordPlan(
|
|
687
|
+
word=word.word,
|
|
688
|
+
color=word.color,
|
|
689
|
+
font_size=word.font_size,
|
|
690
|
+
force_newline=word.force_newline,
|
|
691
|
+
leading_space=word.leading_space,
|
|
692
|
+
glue_to_previous=word.glue_to_previous,
|
|
693
|
+
)
|
|
694
|
+
for word in renderer_block.colored_words
|
|
695
|
+
)
|
|
696
|
+
block_plan = TimelineBlockPlan(
|
|
697
|
+
block_id=configured_block.block_id,
|
|
698
|
+
plain_text=renderer_block.plain_text,
|
|
699
|
+
colored_words=words,
|
|
700
|
+
renderer_block=renderer_block.model_dump(mode="json"),
|
|
701
|
+
size=Size(width=width, height=height),
|
|
702
|
+
)
|
|
703
|
+
blocks.append(block_plan)
|
|
704
|
+
block_plan_by_id[configured_block.block_id] = block_plan
|
|
705
|
+
|
|
706
|
+
plan_frames: list[TimelineFramePlan] = []
|
|
707
|
+
spacing_px = subtitle_template.layout.spacing_px
|
|
708
|
+
for frame_index, configured_frame in enumerate(subtitle_config.frames):
|
|
709
|
+
ordered_items = sorted(configured_frame.items, key=lambda item: item.slot_index)
|
|
710
|
+
frame_blocks = [block_plan_by_id[item.block_id] for item in ordered_items]
|
|
711
|
+
total_height = sum(block.size.height for block in frame_blocks)
|
|
712
|
+
if len(frame_blocks) > 1:
|
|
713
|
+
total_height += spacing_px * (len(frame_blocks) - 1)
|
|
714
|
+
if total_height > task.template.canvas.height:
|
|
715
|
+
raise CompileError(
|
|
716
|
+
"subtitle frame cannot fit within the canvas",
|
|
717
|
+
code="subtitle_layout_overflow",
|
|
718
|
+
field_path=f"bindings.subtitles.frames.{frame_index}",
|
|
719
|
+
details={
|
|
720
|
+
"frame_id": configured_frame.frame_id,
|
|
721
|
+
"required": {"height": total_height},
|
|
722
|
+
"available": {"height": task.template.canvas.height},
|
|
723
|
+
},
|
|
724
|
+
)
|
|
725
|
+
current_y = (
|
|
726
|
+
subtitle_template.box.position.y
|
|
727
|
+
+ (subtitle_template.box.size.height - total_height) // 2
|
|
728
|
+
)
|
|
729
|
+
current_y = min(max(current_y, 0), task.template.canvas.height - total_height)
|
|
730
|
+
frame_timings: list[TimelineBlockTimingPlan] = []
|
|
731
|
+
for item, block in zip(ordered_items, frame_blocks, strict=True):
|
|
732
|
+
block_x = (task.template.canvas.width - block.size.width) // 2
|
|
733
|
+
frame_timings.append(
|
|
734
|
+
TimelineBlockTimingPlan(
|
|
735
|
+
block_id=item.block_id,
|
|
736
|
+
position=Point(x=block_x, y=current_y),
|
|
737
|
+
start_time_s=round(item.start_time_s, 6),
|
|
738
|
+
end_time_s=round(item.end_time_s, 6),
|
|
739
|
+
)
|
|
740
|
+
)
|
|
741
|
+
current_y += block.size.height + spacing_px
|
|
742
|
+
plan_frames.append(
|
|
743
|
+
TimelineFramePlan(
|
|
744
|
+
frame_id=configured_frame.frame_id,
|
|
745
|
+
frame_index=frame_index,
|
|
746
|
+
start_time_s=round(min(item.start_time_s for item in ordered_items), 6),
|
|
747
|
+
end_time_s=round(max(item.end_time_s for item in ordered_items), 6),
|
|
748
|
+
timings=tuple(frame_timings),
|
|
749
|
+
)
|
|
750
|
+
)
|
|
751
|
+
|
|
752
|
+
duration = max(
|
|
753
|
+
item.end_time_s for frame in subtitle_config.frames for item in frame.items
|
|
754
|
+
)
|
|
755
|
+
if duration <= 0:
|
|
756
|
+
raise CompileError(
|
|
757
|
+
"timeline duration must be positive", code="invalid_duration"
|
|
758
|
+
)
|
|
759
|
+
asset_map = {background.id: background, style.font.font.id: style.font.font}
|
|
760
|
+
if audio.asset is not None:
|
|
761
|
+
asset_map[audio.asset.id] = audio.asset
|
|
762
|
+
body = TimelineRenderPlanBody(
|
|
763
|
+
canvas=task.template.canvas,
|
|
764
|
+
subtitle_box=subtitle_template.box,
|
|
765
|
+
style=style,
|
|
766
|
+
language=subtitle_config.language,
|
|
767
|
+
spacing_px=spacing_px,
|
|
768
|
+
base_font_size=subtitle_template.base_font_size,
|
|
769
|
+
background=background,
|
|
770
|
+
audio=audio,
|
|
771
|
+
output=task.output,
|
|
772
|
+
duration_s=round(duration, 6),
|
|
773
|
+
blocks=tuple(blocks),
|
|
774
|
+
frames=tuple(plan_frames),
|
|
775
|
+
asset_ids=tuple(sorted(asset_map)),
|
|
776
|
+
)
|
|
777
|
+
hash_body = _portable_plan_payload(body.model_dump(mode="json"))
|
|
778
|
+
plan_hash = stable_sha256(
|
|
779
|
+
{
|
|
780
|
+
"schema_version": task.schema_version,
|
|
781
|
+
"kind": task.kind,
|
|
782
|
+
"template_id": task.template.template_id,
|
|
783
|
+
"template_revision": task.template.revision,
|
|
784
|
+
"body": hash_body,
|
|
785
|
+
}
|
|
786
|
+
)
|
|
787
|
+
return TimelineRenderPlan(
|
|
788
|
+
template_id=task.template.template_id,
|
|
789
|
+
template_revision=task.template.revision,
|
|
790
|
+
plan_hash=plan_hash,
|
|
791
|
+
body=body,
|
|
792
|
+
)
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def _compile_composed_timeline(task: TimelineRenderTask) -> TimelineRenderPlan:
|
|
796
|
+
"""Compile the v2 per-frame subtitle composition without changing v1 behavior."""
|
|
797
|
+
background = _resolve_local_asset(task.bindings.background, "bindings.background")
|
|
798
|
+
subtitle_template = task.template.subtitle
|
|
799
|
+
subtitle_config = task.bindings.subtitles
|
|
800
|
+
style = _resolve_text_style(
|
|
801
|
+
subtitle_template.style,
|
|
802
|
+
"template.subtitle.style",
|
|
803
|
+
font_size_scale=subtitle_config.font_size_scale,
|
|
804
|
+
font_size_scale_field_path="bindings.subtitles.font_size_scale",
|
|
805
|
+
)
|
|
806
|
+
audio = _resolve_audio(task.audio, "audio")
|
|
807
|
+
box = subtitle_template.box
|
|
808
|
+
safe_x = max(box.position.x, 0)
|
|
809
|
+
safe_y = max(box.position.y, 0)
|
|
810
|
+
safe_right = min(box.position.x + box.size.width, task.template.canvas.width)
|
|
811
|
+
safe_bottom = min(box.position.y + box.size.height, task.template.canvas.height)
|
|
812
|
+
safe_width = safe_right - safe_x
|
|
813
|
+
safe_height = safe_bottom - safe_y
|
|
814
|
+
if safe_width <= 0 or safe_height <= 0:
|
|
815
|
+
raise CompileError(
|
|
816
|
+
"subtitle box has no usable canvas area",
|
|
817
|
+
code="subtitle_layout_overflow",
|
|
818
|
+
field_path="template.subtitle.box",
|
|
819
|
+
details={
|
|
820
|
+
"language": subtitle_config.language,
|
|
821
|
+
"required": {"width": 1, "height": 1},
|
|
822
|
+
"available": {"width": safe_width, "height": safe_height},
|
|
823
|
+
},
|
|
824
|
+
)
|
|
825
|
+
max_blocks_per_frame = subtitle_template.max_blocks_per_frame
|
|
826
|
+
if max_blocks_per_frame is None:
|
|
827
|
+
raise CompileError(
|
|
828
|
+
"composed timeline requires a frozen max_blocks_per_frame",
|
|
829
|
+
code="invalid_timeline_composition",
|
|
830
|
+
field_path="template.subtitle.max_blocks_per_frame",
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
spacing_px = subtitle_template.layout.spacing_px
|
|
834
|
+
configured_blocks = {block.block_id: block for block in subtitle_config.blocks}
|
|
835
|
+
block_plans: list[TimelineBlockPlan] = []
|
|
836
|
+
|
|
837
|
+
def measure_block(
|
|
838
|
+
*,
|
|
839
|
+
frame_id: str,
|
|
840
|
+
block_id: str,
|
|
841
|
+
max_width: int,
|
|
842
|
+
max_height: int,
|
|
843
|
+
) -> TimelineBlockPlan:
|
|
844
|
+
if max_width <= 0 or max_height <= 0:
|
|
845
|
+
raise CompileError(
|
|
846
|
+
"subtitle layout region must have positive dimensions",
|
|
847
|
+
code="subtitle_layout_overflow",
|
|
848
|
+
field_path=f"bindings.subtitles.blocks.{block_id}.html",
|
|
849
|
+
details={
|
|
850
|
+
"language": subtitle_config.language,
|
|
851
|
+
"frame_id": frame_id,
|
|
852
|
+
"block_ids": [block_id],
|
|
853
|
+
"required": {"width": 1, "height": 1},
|
|
854
|
+
"available": {"width": max_width, "height": max_height},
|
|
855
|
+
},
|
|
856
|
+
)
|
|
857
|
+
configured_block = configured_blocks[block_id]
|
|
858
|
+
font_spec, constraint, split_config, renderer_style = _renderer_models(
|
|
859
|
+
style,
|
|
860
|
+
language=subtitle_config.language,
|
|
861
|
+
max_width=max_width,
|
|
862
|
+
max_height=max_height,
|
|
863
|
+
preferred_height=max_height,
|
|
864
|
+
)
|
|
865
|
+
measurer = TextMeasurer(font_spec=font_spec, stroke_size=style.font.stroke_size)
|
|
866
|
+
layout_engine = LayoutEngine(constraint, renderer_style, measurer)
|
|
867
|
+
effective_constraint = layout_engine.compute_effective_constraint()
|
|
868
|
+
try:
|
|
869
|
+
renderer_blocks = split_html_to_colored_blocks(
|
|
870
|
+
configured_block.html,
|
|
871
|
+
measurer,
|
|
872
|
+
effective_constraint,
|
|
873
|
+
split_config,
|
|
874
|
+
base_font_size=subtitle_template.base_font_size,
|
|
875
|
+
)
|
|
876
|
+
except Exception as exc:
|
|
877
|
+
raise CompileError(
|
|
878
|
+
"subtitle block cannot fit within its layout region",
|
|
879
|
+
code="subtitle_layout_overflow",
|
|
880
|
+
field_path=f"bindings.subtitles.blocks.{block_id}.html",
|
|
881
|
+
details={
|
|
882
|
+
"language": subtitle_config.language,
|
|
883
|
+
"frame_id": frame_id,
|
|
884
|
+
"block_ids": [block_id],
|
|
885
|
+
"available": {"width": max_width, "height": max_height},
|
|
886
|
+
},
|
|
887
|
+
) from exc
|
|
888
|
+
if len(renderer_blocks) != 1:
|
|
889
|
+
measured = [
|
|
890
|
+
layout_engine.compute_text_layout(block, align=renderer_style.alignment)
|
|
891
|
+
for block in renderer_blocks
|
|
892
|
+
]
|
|
893
|
+
raise CompileError(
|
|
894
|
+
"subtitle block must remain one renderable block",
|
|
895
|
+
code="subtitle_layout_overflow",
|
|
896
|
+
field_path=f"bindings.subtitles.blocks.{block_id}.html",
|
|
897
|
+
details={
|
|
898
|
+
"language": subtitle_config.language,
|
|
899
|
+
"frame_id": frame_id,
|
|
900
|
+
"block_ids": [block_id],
|
|
901
|
+
"required": {
|
|
902
|
+
"width": max((layout.image_width for layout in measured), default=0),
|
|
903
|
+
"height": sum(layout.image_height for layout in measured),
|
|
904
|
+
},
|
|
905
|
+
"available": {"width": max_width, "height": max_height},
|
|
906
|
+
"rendered_block_count": len(renderer_blocks),
|
|
907
|
+
},
|
|
908
|
+
)
|
|
909
|
+
renderer_block = renderer_blocks[0]
|
|
910
|
+
text_layout = layout_engine.compute_text_layout(
|
|
911
|
+
renderer_block,
|
|
912
|
+
align=renderer_style.alignment,
|
|
913
|
+
)
|
|
914
|
+
width = int(text_layout.image_width)
|
|
915
|
+
height = int(text_layout.image_height)
|
|
916
|
+
if width <= 0 or height <= 0 or width > max_width or height > max_height:
|
|
917
|
+
raise CompileError(
|
|
918
|
+
"subtitle block cannot fit within its layout region",
|
|
919
|
+
code="subtitle_layout_overflow",
|
|
920
|
+
field_path=f"bindings.subtitles.blocks.{block_id}.html",
|
|
921
|
+
details={
|
|
922
|
+
"language": subtitle_config.language,
|
|
923
|
+
"frame_id": frame_id,
|
|
924
|
+
"block_ids": [block_id],
|
|
925
|
+
"required": {"width": width, "height": height},
|
|
926
|
+
"available": {"width": max_width, "height": max_height},
|
|
927
|
+
},
|
|
928
|
+
)
|
|
929
|
+
words = tuple(
|
|
930
|
+
TimelineWordPlan(
|
|
931
|
+
word=word.word,
|
|
932
|
+
color=word.color,
|
|
933
|
+
font_size=word.font_size,
|
|
934
|
+
force_newline=word.force_newline,
|
|
935
|
+
leading_space=word.leading_space,
|
|
936
|
+
glue_to_previous=word.glue_to_previous,
|
|
937
|
+
)
|
|
938
|
+
for word in renderer_block.colored_words
|
|
939
|
+
)
|
|
940
|
+
return TimelineBlockPlan(
|
|
941
|
+
block_id=block_id,
|
|
942
|
+
plain_text=renderer_block.plain_text,
|
|
943
|
+
colored_words=words,
|
|
944
|
+
renderer_block=renderer_block.model_dump(mode="json"),
|
|
945
|
+
size=Size(width=width, height=height),
|
|
946
|
+
)
|
|
947
|
+
|
|
948
|
+
plan_frames: list[TimelineFramePlan] = []
|
|
949
|
+
previous_end = 0.0
|
|
950
|
+
for frame_index, configured_frame in enumerate(subtitle_config.frames):
|
|
951
|
+
items = sorted(configured_frame.items, key=lambda item: item.slot_index)
|
|
952
|
+
frame_path = f"bindings.subtitles.frames.{frame_index}"
|
|
953
|
+
layout = configured_frame.layout
|
|
954
|
+
reveal = configured_frame.reveal
|
|
955
|
+
if layout is None or reveal is None: # Schema v2 has already rejected this.
|
|
956
|
+
raise CompileError(
|
|
957
|
+
"subtitle frame requires layout and reveal",
|
|
958
|
+
code="invalid_timeline_composition",
|
|
959
|
+
field_path=frame_path,
|
|
960
|
+
)
|
|
961
|
+
starts = [item.start_time_s for item in items]
|
|
962
|
+
ends = [item.end_time_s for item in items]
|
|
963
|
+
if len(set(ends)) != 1:
|
|
964
|
+
raise CompileError(
|
|
965
|
+
"subtitle frame items must share an end time",
|
|
966
|
+
code="invalid_timeline_composition",
|
|
967
|
+
field_path=frame_path,
|
|
968
|
+
)
|
|
969
|
+
if reveal.value == "together" and len(set(starts)) != 1:
|
|
970
|
+
raise CompileError(
|
|
971
|
+
"together frame items must share a start time",
|
|
972
|
+
code="invalid_timeline_composition",
|
|
973
|
+
field_path=frame_path,
|
|
974
|
+
)
|
|
975
|
+
if reveal.value == "progressive" and any(
|
|
976
|
+
starts[index] <= starts[index - 1] for index in range(1, len(starts))
|
|
977
|
+
):
|
|
978
|
+
raise CompileError(
|
|
979
|
+
"progressive frame items must start strictly in slot order",
|
|
980
|
+
code="invalid_timeline_composition",
|
|
981
|
+
field_path=frame_path,
|
|
982
|
+
)
|
|
983
|
+
frame_start = min(starts)
|
|
984
|
+
frame_end = max(ends)
|
|
985
|
+
if frame_start != previous_end:
|
|
986
|
+
raise CompileError(
|
|
987
|
+
"subtitle frames must start at zero and remain continuous",
|
|
988
|
+
code="invalid_timeline_composition",
|
|
989
|
+
field_path=frame_path,
|
|
990
|
+
)
|
|
991
|
+
previous_end = frame_end
|
|
992
|
+
if len(items) > max_blocks_per_frame:
|
|
993
|
+
raise CompileError(
|
|
994
|
+
"subtitle frame exceeds the frozen template block limit",
|
|
995
|
+
code="invalid_timeline_composition",
|
|
996
|
+
field_path=frame_path,
|
|
997
|
+
details={
|
|
998
|
+
"language": subtitle_config.language,
|
|
999
|
+
"frame_id": configured_frame.frame_id,
|
|
1000
|
+
"block_ids": [item.block_id for item in items],
|
|
1001
|
+
"required": {"block_count": len(items)},
|
|
1002
|
+
"available": {"block_count": max_blocks_per_frame},
|
|
1003
|
+
},
|
|
1004
|
+
)
|
|
1005
|
+
|
|
1006
|
+
if layout.value == "stack":
|
|
1007
|
+
frame_blocks = [
|
|
1008
|
+
measure_block(
|
|
1009
|
+
frame_id=configured_frame.frame_id,
|
|
1010
|
+
block_id=item.block_id,
|
|
1011
|
+
max_width=safe_width,
|
|
1012
|
+
max_height=safe_height,
|
|
1013
|
+
)
|
|
1014
|
+
for item in items
|
|
1015
|
+
]
|
|
1016
|
+
total_height = sum(block.size.height for block in frame_blocks)
|
|
1017
|
+
total_height += spacing_px * (len(frame_blocks) - 1)
|
|
1018
|
+
if total_height > safe_height:
|
|
1019
|
+
raise CompileError(
|
|
1020
|
+
"stack subtitle frame cannot fit within subtitle box",
|
|
1021
|
+
code="subtitle_layout_overflow",
|
|
1022
|
+
field_path=frame_path,
|
|
1023
|
+
details={
|
|
1024
|
+
"language": subtitle_config.language,
|
|
1025
|
+
"frame_id": configured_frame.frame_id,
|
|
1026
|
+
"block_ids": [item.block_id for item in items],
|
|
1027
|
+
"required": {
|
|
1028
|
+
"width": max(block.size.width for block in frame_blocks),
|
|
1029
|
+
"height": total_height,
|
|
1030
|
+
},
|
|
1031
|
+
"available": {"width": safe_width, "height": safe_height},
|
|
1032
|
+
},
|
|
1033
|
+
)
|
|
1034
|
+
positions = [
|
|
1035
|
+
Point(
|
|
1036
|
+
x=safe_x + (safe_width - block.size.width) // 2,
|
|
1037
|
+
y=safe_y
|
|
1038
|
+
+ (safe_height - total_height) // 2
|
|
1039
|
+
+ sum(
|
|
1040
|
+
previous.size.height + spacing_px
|
|
1041
|
+
for previous in frame_blocks[:block_index]
|
|
1042
|
+
),
|
|
1043
|
+
)
|
|
1044
|
+
for block_index, block in enumerate(frame_blocks)
|
|
1045
|
+
]
|
|
1046
|
+
else: # columns
|
|
1047
|
+
left_width = (safe_width - spacing_px) // 2
|
|
1048
|
+
right_width = safe_width - spacing_px - left_width
|
|
1049
|
+
frame_blocks = [
|
|
1050
|
+
measure_block(
|
|
1051
|
+
frame_id=configured_frame.frame_id,
|
|
1052
|
+
block_id=items[0].block_id,
|
|
1053
|
+
max_width=left_width,
|
|
1054
|
+
max_height=safe_height,
|
|
1055
|
+
),
|
|
1056
|
+
measure_block(
|
|
1057
|
+
frame_id=configured_frame.frame_id,
|
|
1058
|
+
block_id=items[1].block_id,
|
|
1059
|
+
max_width=right_width,
|
|
1060
|
+
max_height=safe_height,
|
|
1061
|
+
),
|
|
1062
|
+
]
|
|
1063
|
+
top = safe_y + (safe_height - max(block.size.height for block in frame_blocks)) // 2
|
|
1064
|
+
positions = [
|
|
1065
|
+
Point(x=safe_x + (left_width - frame_blocks[0].size.width) // 2, y=top),
|
|
1066
|
+
Point(
|
|
1067
|
+
x=safe_x
|
|
1068
|
+
+ left_width
|
|
1069
|
+
+ spacing_px
|
|
1070
|
+
+ (right_width - frame_blocks[1].size.width) // 2,
|
|
1071
|
+
y=top,
|
|
1072
|
+
),
|
|
1073
|
+
]
|
|
1074
|
+
frame_timings: list[TimelineBlockTimingPlan] = []
|
|
1075
|
+
for item, block, position in zip(items, frame_blocks, positions, strict=True):
|
|
1076
|
+
block_plans.append(block)
|
|
1077
|
+
frame_timings.append(
|
|
1078
|
+
TimelineBlockTimingPlan(
|
|
1079
|
+
block_id=item.block_id,
|
|
1080
|
+
position=position,
|
|
1081
|
+
start_time_s=round(item.start_time_s, 6),
|
|
1082
|
+
end_time_s=round(item.end_time_s, 6),
|
|
1083
|
+
)
|
|
1084
|
+
)
|
|
1085
|
+
plan_frames.append(
|
|
1086
|
+
TimelineFramePlan(
|
|
1087
|
+
frame_id=configured_frame.frame_id,
|
|
1088
|
+
frame_index=frame_index,
|
|
1089
|
+
start_time_s=round(frame_start, 6),
|
|
1090
|
+
end_time_s=round(frame_end, 6),
|
|
1091
|
+
timings=tuple(frame_timings),
|
|
1092
|
+
)
|
|
1093
|
+
)
|
|
1094
|
+
|
|
1095
|
+
duration = max(frame.end_time_s for frame in plan_frames)
|
|
1096
|
+
asset_map = {background.id: background, style.font.font.id: style.font.font}
|
|
1097
|
+
if audio.asset is not None:
|
|
1098
|
+
asset_map[audio.asset.id] = audio.asset
|
|
1099
|
+
body = TimelineRenderPlanBody(
|
|
1100
|
+
canvas=task.template.canvas,
|
|
1101
|
+
subtitle_box=subtitle_template.box,
|
|
1102
|
+
style=style,
|
|
1103
|
+
language=subtitle_config.language,
|
|
1104
|
+
spacing_px=spacing_px,
|
|
1105
|
+
base_font_size=subtitle_template.base_font_size,
|
|
1106
|
+
background=background,
|
|
1107
|
+
audio=audio,
|
|
1108
|
+
output=task.output,
|
|
1109
|
+
duration_s=round(duration, 6),
|
|
1110
|
+
blocks=tuple(block_plans),
|
|
1111
|
+
frames=tuple(plan_frames),
|
|
1112
|
+
asset_ids=tuple(sorted(asset_map)),
|
|
1113
|
+
)
|
|
1114
|
+
hash_body = _portable_plan_payload(body.model_dump(mode="json"))
|
|
1115
|
+
return TimelineRenderPlan(
|
|
1116
|
+
template_id=task.template.template_id,
|
|
1117
|
+
template_revision=task.template.revision,
|
|
1118
|
+
plan_hash=stable_sha256(
|
|
1119
|
+
{
|
|
1120
|
+
"schema_version": task.schema_version,
|
|
1121
|
+
"kind": "timeline_video",
|
|
1122
|
+
"template_id": task.template.template_id,
|
|
1123
|
+
"template_revision": task.template.revision,
|
|
1124
|
+
"body": hash_body,
|
|
1125
|
+
}
|
|
1126
|
+
),
|
|
1127
|
+
body=body,
|
|
1128
|
+
)
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
def _resolve_audio(value: AudioSpec, field_path: str) -> ResolvedAudioSpec:
|
|
1132
|
+
asset = (
|
|
1133
|
+
_resolve_local_asset(value.asset, f"{field_path}.asset")
|
|
1134
|
+
if value.asset is not None
|
|
1135
|
+
else None
|
|
1136
|
+
)
|
|
1137
|
+
return ResolvedAudioSpec(
|
|
1138
|
+
mode=value.mode,
|
|
1139
|
+
asset=asset,
|
|
1140
|
+
loop=value.loop,
|
|
1141
|
+
fade_in_s=value.fade_in_s,
|
|
1142
|
+
fade_out_s=value.fade_out_s,
|
|
1143
|
+
volume=value.volume,
|
|
1144
|
+
)
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
def _resolve_first_frame(
|
|
1148
|
+
value: FirstFrameSpec, field_path: str
|
|
1149
|
+
) -> ResolvedFirstFrameSpec:
|
|
1150
|
+
asset = (
|
|
1151
|
+
_resolve_local_asset(value.asset, f"{field_path}.asset")
|
|
1152
|
+
if value.asset is not None
|
|
1153
|
+
else None
|
|
1154
|
+
)
|
|
1155
|
+
return ResolvedFirstFrameSpec(mode=value.mode, asset=asset)
|
|
1156
|
+
|
|
1157
|
+
|
|
1158
|
+
def _resolve_text_style(
|
|
1159
|
+
value: TextStyle,
|
|
1160
|
+
field_path: str,
|
|
1161
|
+
*,
|
|
1162
|
+
font_size_scale: float = 1.0,
|
|
1163
|
+
font_size_scale_field_path: str,
|
|
1164
|
+
) -> ResolvedTextStyle:
|
|
1165
|
+
font = _resolve_font(value.font.font, f"{field_path}.font.font")
|
|
1166
|
+
effective_font_size = _effective_font_size(
|
|
1167
|
+
value.font.font_size,
|
|
1168
|
+
font_size_scale,
|
|
1169
|
+
font_size_scale_field_path,
|
|
1170
|
+
)
|
|
1171
|
+
return ResolvedTextStyle(
|
|
1172
|
+
font=ResolvedFontStyle(
|
|
1173
|
+
font=font,
|
|
1174
|
+
font_size=effective_font_size,
|
|
1175
|
+
line_spacing=value.font.line_spacing,
|
|
1176
|
+
color=value.font.color,
|
|
1177
|
+
alignment=value.font.alignment,
|
|
1178
|
+
stroke_size=value.font.stroke_size,
|
|
1179
|
+
stroke_color=value.font.stroke_color,
|
|
1180
|
+
),
|
|
1181
|
+
mask=ResolvedMaskStyle(
|
|
1182
|
+
mode=value.mask.mode,
|
|
1183
|
+
color=value.mask.color,
|
|
1184
|
+
offset=value.mask.offset,
|
|
1185
|
+
corner_radius=value.mask.corner_radius,
|
|
1186
|
+
),
|
|
1187
|
+
padding=value.padding,
|
|
1188
|
+
adjust=value.adjust,
|
|
1189
|
+
vertical_alignment=value.vertical_alignment,
|
|
1190
|
+
min_words_per_block=value.min_words_per_block,
|
|
1191
|
+
)
|
|
1192
|
+
|
|
1193
|
+
|
|
1194
|
+
def _effective_font_size(font_size: int, scale: float, field_path: str) -> int:
|
|
1195
|
+
try:
|
|
1196
|
+
normalized_scale = float(scale)
|
|
1197
|
+
except (TypeError, ValueError) as exc:
|
|
1198
|
+
raise ContractError(
|
|
1199
|
+
"font_size_scale must be a finite positive number",
|
|
1200
|
+
code="invalid_font_size_scale",
|
|
1201
|
+
field_path=field_path,
|
|
1202
|
+
) from exc
|
|
1203
|
+
if not math.isfinite(normalized_scale) or normalized_scale <= 0:
|
|
1204
|
+
raise ContractError(
|
|
1205
|
+
"font_size_scale must be a finite positive number",
|
|
1206
|
+
code="invalid_font_size_scale",
|
|
1207
|
+
field_path=field_path,
|
|
1208
|
+
)
|
|
1209
|
+
effective_font_size = math.floor(font_size * normalized_scale)
|
|
1210
|
+
if effective_font_size < 1:
|
|
1211
|
+
raise ContractError(
|
|
1212
|
+
"font_size_scale produces a font size below 1px",
|
|
1213
|
+
code="invalid_font_size_scale",
|
|
1214
|
+
field_path=field_path,
|
|
1215
|
+
details={
|
|
1216
|
+
"font_size": font_size,
|
|
1217
|
+
"font_size_scale": normalized_scale,
|
|
1218
|
+
},
|
|
1219
|
+
)
|
|
1220
|
+
return effective_font_size
|
|
1221
|
+
|
|
1222
|
+
|
|
1223
|
+
def _resolve_font(value: FontAsset, field_path: str) -> ResolvedAsset:
|
|
1224
|
+
local = LocalAsset(
|
|
1225
|
+
id=value.id,
|
|
1226
|
+
path=value.path,
|
|
1227
|
+
media_type=AssetMediaType.FONT,
|
|
1228
|
+
sha256=value.sha256,
|
|
1229
|
+
)
|
|
1230
|
+
return _resolve_local_asset(local, field_path)
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
def _resolve_pure_image_asset(value: LocalAsset, field_path: str) -> ResolvedAsset:
|
|
1234
|
+
resolved = _resolve_local_asset(value, field_path)
|
|
1235
|
+
try:
|
|
1236
|
+
with Image.open(resolved.path) as image:
|
|
1237
|
+
if (
|
|
1238
|
+
bool(getattr(image, "is_animated", False))
|
|
1239
|
+
or int(getattr(image, "n_frames", 1)) > 1
|
|
1240
|
+
):
|
|
1241
|
+
raise AssetError(
|
|
1242
|
+
"animated images are not supported for pure-image rendering",
|
|
1243
|
+
code="animated_image_not_supported",
|
|
1244
|
+
field_path=f"{field_path}.path",
|
|
1245
|
+
)
|
|
1246
|
+
oriented_value = ImageOps.exif_transpose(image)
|
|
1247
|
+
if oriented_value is None:
|
|
1248
|
+
raise ValueError("image orientation failed")
|
|
1249
|
+
oriented = cast(Image.Image, oriented_value)
|
|
1250
|
+
oriented.load()
|
|
1251
|
+
size = Size(width=oriented.width, height=oriented.height)
|
|
1252
|
+
except AssetError:
|
|
1253
|
+
raise
|
|
1254
|
+
except Exception as exc:
|
|
1255
|
+
raise AssetError(
|
|
1256
|
+
f"pure-image asset cannot be decoded: {resolved.path}",
|
|
1257
|
+
code="asset_decode_failed",
|
|
1258
|
+
field_path=f"{field_path}.path",
|
|
1259
|
+
) from exc
|
|
1260
|
+
return resolved.model_copy(update={"size": size})
|
|
1261
|
+
|
|
1262
|
+
|
|
1263
|
+
def _resolve_local_asset(value: LocalAsset | None, field_path: str) -> ResolvedAsset:
|
|
1264
|
+
if value is None:
|
|
1265
|
+
raise AssetError(
|
|
1266
|
+
"asset is required", code="missing_asset", field_path=field_path
|
|
1267
|
+
)
|
|
1268
|
+
path = value.path.expanduser().resolve()
|
|
1269
|
+
if not path.is_file():
|
|
1270
|
+
raise AssetError(
|
|
1271
|
+
f"asset file does not exist: {path}",
|
|
1272
|
+
code="asset_not_found",
|
|
1273
|
+
field_path=f"{field_path}.path",
|
|
1274
|
+
)
|
|
1275
|
+
_validate_extension(path, value.media_type, field_path)
|
|
1276
|
+
actual_sha256 = file_sha256(path)
|
|
1277
|
+
if value.sha256 is not None and value.sha256 != actual_sha256:
|
|
1278
|
+
raise AssetError(
|
|
1279
|
+
"asset sha256 does not match file content",
|
|
1280
|
+
code="asset_hash_mismatch",
|
|
1281
|
+
field_path=f"{field_path}.sha256",
|
|
1282
|
+
)
|
|
1283
|
+
size: Size | None = None
|
|
1284
|
+
duration: float | None = None
|
|
1285
|
+
try:
|
|
1286
|
+
if value.media_type == AssetMediaType.IMAGE:
|
|
1287
|
+
with Image.open(path) as image:
|
|
1288
|
+
image.verify()
|
|
1289
|
+
with Image.open(path) as image:
|
|
1290
|
+
size = Size(width=image.width, height=image.height)
|
|
1291
|
+
elif value.media_type == AssetMediaType.VIDEO:
|
|
1292
|
+
with VideoFileClip(str(path)) as clip:
|
|
1293
|
+
size = Size(width=int(clip.w), height=int(clip.h))
|
|
1294
|
+
duration = float(clip.duration)
|
|
1295
|
+
elif value.media_type == AssetMediaType.FONT:
|
|
1296
|
+
FontSpec(font_path=str(path), font_size=16)
|
|
1297
|
+
except Exception as exc:
|
|
1298
|
+
raise AssetError(
|
|
1299
|
+
f"asset cannot be decoded as {value.media_type.value}: {path}",
|
|
1300
|
+
code="asset_decode_failed",
|
|
1301
|
+
field_path=f"{field_path}.path",
|
|
1302
|
+
) from exc
|
|
1303
|
+
return ResolvedAsset(
|
|
1304
|
+
id=value.id,
|
|
1305
|
+
path=path,
|
|
1306
|
+
media_type=value.media_type.value,
|
|
1307
|
+
sha256=actual_sha256,
|
|
1308
|
+
size=size,
|
|
1309
|
+
duration_s=duration,
|
|
1310
|
+
)
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
def _portable_plan_payload(value: Any) -> Any:
|
|
1314
|
+
if isinstance(value, dict):
|
|
1315
|
+
return {
|
|
1316
|
+
key: _portable_plan_payload(item)
|
|
1317
|
+
for key, item in value.items()
|
|
1318
|
+
if key != "path"
|
|
1319
|
+
}
|
|
1320
|
+
if isinstance(value, list):
|
|
1321
|
+
return [_portable_plan_payload(item) for item in value]
|
|
1322
|
+
if isinstance(value, tuple):
|
|
1323
|
+
return tuple(_portable_plan_payload(item) for item in value)
|
|
1324
|
+
return value
|
|
1325
|
+
|
|
1326
|
+
|
|
1327
|
+
def _validate_extension(
|
|
1328
|
+
path: Path, media_type: AssetMediaType, field_path: str
|
|
1329
|
+
) -> None:
|
|
1330
|
+
allowed = {
|
|
1331
|
+
AssetMediaType.IMAGE: _IMAGE_EXTENSIONS,
|
|
1332
|
+
AssetMediaType.VIDEO: _VIDEO_EXTENSIONS,
|
|
1333
|
+
AssetMediaType.AUDIO: _AUDIO_EXTENSIONS,
|
|
1334
|
+
AssetMediaType.FONT: _FONT_EXTENSIONS,
|
|
1335
|
+
}[media_type]
|
|
1336
|
+
if path.suffix.lower() not in allowed:
|
|
1337
|
+
raise AssetError(
|
|
1338
|
+
f"unsupported {media_type.value} extension: {path.suffix}",
|
|
1339
|
+
code="unsupported_asset_extension",
|
|
1340
|
+
field_path=f"{field_path}.path",
|
|
1341
|
+
)
|
|
1342
|
+
|
|
1343
|
+
|
|
1344
|
+
def _measure_long_text(
|
|
1345
|
+
html: str, language: str, max_width: int, style: ResolvedTextStyle
|
|
1346
|
+
):
|
|
1347
|
+
font_spec, constraint, split_config, renderer_style = _renderer_models(
|
|
1348
|
+
style,
|
|
1349
|
+
language=language,
|
|
1350
|
+
max_width=max_width,
|
|
1351
|
+
max_height=0,
|
|
1352
|
+
preferred_height=0,
|
|
1353
|
+
)
|
|
1354
|
+
measurer = TextMeasurer(font_spec=font_spec, stroke_size=style.font.stroke_size)
|
|
1355
|
+
layout_engine = LayoutEngine(constraint, renderer_style, measurer)
|
|
1356
|
+
effective_constraint = layout_engine.compute_effective_constraint()
|
|
1357
|
+
try:
|
|
1358
|
+
blocks = split_html_to_colored_blocks(
|
|
1359
|
+
html,
|
|
1360
|
+
measurer,
|
|
1361
|
+
effective_constraint,
|
|
1362
|
+
split_config,
|
|
1363
|
+
base_font_size=18,
|
|
1364
|
+
)
|
|
1365
|
+
if not blocks:
|
|
1366
|
+
raise CompileError(
|
|
1367
|
+
"scrolling text produced no renderable block",
|
|
1368
|
+
code="empty_scroll_text",
|
|
1369
|
+
)
|
|
1370
|
+
block = blocks[0]
|
|
1371
|
+
layout = layout_engine.compute_text_layout(
|
|
1372
|
+
block,
|
|
1373
|
+
align=style.font.alignment.value,
|
|
1374
|
+
)
|
|
1375
|
+
return Size(
|
|
1376
|
+
width=int(layout.image_width),
|
|
1377
|
+
height=int(layout.image_height),
|
|
1378
|
+
)
|
|
1379
|
+
except CompileError:
|
|
1380
|
+
raise
|
|
1381
|
+
except Exception as exc:
|
|
1382
|
+
raise CompileError(
|
|
1383
|
+
"failed to measure scrolling text",
|
|
1384
|
+
code="scroll_text_measure_failed",
|
|
1385
|
+
) from exc
|
|
1386
|
+
|
|
1387
|
+
|
|
1388
|
+
def _renderer_models(
|
|
1389
|
+
style: ResolvedTextStyle,
|
|
1390
|
+
*,
|
|
1391
|
+
language: str,
|
|
1392
|
+
max_width: int,
|
|
1393
|
+
max_height: int,
|
|
1394
|
+
preferred_height: int,
|
|
1395
|
+
min_words_per_block: int | None = None,
|
|
1396
|
+
):
|
|
1397
|
+
font_spec = FontSpec(
|
|
1398
|
+
font_path=str(style.font.font.path),
|
|
1399
|
+
font_size=style.font.font_size,
|
|
1400
|
+
line_spacing=style.font.line_spacing,
|
|
1401
|
+
)
|
|
1402
|
+
constraint = RenderConstraint(
|
|
1403
|
+
max_width=max_width,
|
|
1404
|
+
max_height=max_height,
|
|
1405
|
+
preferred_height=preferred_height,
|
|
1406
|
+
min_width=0,
|
|
1407
|
+
)
|
|
1408
|
+
split_config = SplitConfig(
|
|
1409
|
+
min_words_per_block=(
|
|
1410
|
+
style.min_words_per_block
|
|
1411
|
+
if min_words_per_block is None
|
|
1412
|
+
else min_words_per_block
|
|
1413
|
+
),
|
|
1414
|
+
language=language.upper(),
|
|
1415
|
+
)
|
|
1416
|
+
renderer_style = RenderStyle(
|
|
1417
|
+
text_color=style.font.color,
|
|
1418
|
+
bg_color=None,
|
|
1419
|
+
alignment=style.font.alignment.value,
|
|
1420
|
+
padding=(style.padding.x, style.padding.y),
|
|
1421
|
+
mask_mode=style.mask.mode,
|
|
1422
|
+
mask_color=style.mask.color,
|
|
1423
|
+
mask_offset=(style.mask.offset.x, style.mask.offset.y),
|
|
1424
|
+
corner_radius=style.mask.corner_radius,
|
|
1425
|
+
stroke_size=style.font.stroke_size,
|
|
1426
|
+
stroke_color=style.font.stroke_color,
|
|
1427
|
+
)
|
|
1428
|
+
return font_spec, constraint, split_config, renderer_style
|