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,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|&nbsp;", "", 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