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.
File without changes
@@ -0,0 +1,484 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+ import tempfile
6
+ from collections.abc import Callable
7
+ from contextlib import ExitStack
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import numpy as np
12
+ from moviepy import AudioFileClip, VideoFileClip, afx
13
+ from PIL import Image, ImageChops, ImageDraw
14
+ from proglog import ProgressBarLogger
15
+ from textblockrenderer import (
16
+ ColoredSubtitleBlock,
17
+ FontSpec,
18
+ RenderConstraint,
19
+ RenderStyle,
20
+ SplitConfig,
21
+ TextMeasurer,
22
+ render_colored_block,
23
+ render_text_image,
24
+ text_to_long_image,
25
+ )
26
+
27
+ from ..errors import RenderError
28
+ from ..plans import (
29
+ ResolvedAsset,
30
+ ResolvedAudioSpec,
31
+ ResolvedTextStyle,
32
+ ScrollBackgroundPlan,
33
+ ScrollLongTextPlan,
34
+ ScrollRenderPlan,
35
+ ScrollTextPlan,
36
+ TimelineBlockPlan,
37
+ TimelineRenderPlan,
38
+ )
39
+ from ..schemas import AudioMode, CropMode
40
+
41
+ # pyright: reportMissingImports=false, reportArgumentType=false, reportAttributeAccessIssue=false
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+ _VIDEO_PROGRESS_INTERVAL_FRAMES = 100
46
+
47
+
48
+ class _FrameIntervalProgressLogger(ProgressBarLogger):
49
+ def __init__(self) -> None:
50
+ super().__init__(logged_bars=None)
51
+ self._next_frame_report = _VIDEO_PROGRESS_INTERVAL_FRAMES
52
+
53
+ def callback(self, **changes: Any) -> None:
54
+ message = str(changes.get("message") or "").rstrip()
55
+ if message:
56
+ logger.info(message)
57
+
58
+ def bars_callback(
59
+ self,
60
+ bar: str,
61
+ attr: str,
62
+ value: int,
63
+ old_value: int | None = None,
64
+ ) -> None:
65
+ if bar != "frame_index" or attr != "index" or value <= 0:
66
+ return
67
+ if old_value is not None and value < old_value:
68
+ self._next_frame_report = _VIDEO_PROGRESS_INTERVAL_FRAMES
69
+ while value >= self._next_frame_report:
70
+ logger.info(
71
+ "MoviePy - frame_index: %s/%s",
72
+ self._next_frame_report,
73
+ self.bars[bar]["total"],
74
+ )
75
+ self._next_frame_report += _VIDEO_PROGRESS_INTERVAL_FRAMES
76
+
77
+
78
+ FrameProvider = Callable[[float], Image.Image]
79
+
80
+
81
+ def resize_image(image: Image.Image, target_size: tuple[int, int], mode: CropMode | str) -> Image.Image:
82
+ mode_value = CropMode(mode)
83
+ target_width, target_height = target_size
84
+ source = image.convert("RGBA")
85
+ if mode_value == CropMode.STRETCH:
86
+ return source.resize((target_width, target_height), Image.Resampling.LANCZOS)
87
+ source_ratio = source.width / source.height
88
+ target_ratio = target_width / target_height
89
+ if mode_value == CropMode.COVER:
90
+ if source_ratio > target_ratio:
91
+ new_height = target_height
92
+ new_width = int(source.width * (new_height / source.height))
93
+ else:
94
+ new_width = target_width
95
+ new_height = int(source.height * (new_width / source.width))
96
+ resized = source.resize((new_width, new_height), Image.Resampling.LANCZOS)
97
+ left = (new_width - target_width) // 2
98
+ top = (new_height - target_height) // 2
99
+ return resized.crop((left, top, left + target_width, top + target_height))
100
+ if source_ratio > target_ratio:
101
+ new_width = target_width
102
+ new_height = int(source.height * (new_width / source.width))
103
+ else:
104
+ new_height = target_height
105
+ new_width = int(source.width * (new_height / source.height))
106
+ resized = source.resize((new_width, new_height), Image.Resampling.LANCZOS)
107
+ canvas = Image.new("RGBA", (target_width, target_height), (0, 0, 0, 0))
108
+ canvas.alpha_composite(resized, ((target_width - new_width) // 2, (target_height - new_height) // 2))
109
+ return canvas
110
+
111
+
112
+ def apply_opacity(image: Image.Image, opacity: int) -> Image.Image:
113
+ result = image.convert("RGBA")
114
+ if opacity >= 255:
115
+ return result
116
+ red, green, blue, alpha = result.split()
117
+ adjusted_alpha = alpha.point(lambda value: int(value * opacity / 255))
118
+ return Image.merge("RGBA", (red, green, blue, adjusted_alpha))
119
+
120
+
121
+ def create_scroll_mask(width: int, height: int, fade_length: int) -> Image.Image:
122
+ if fade_length <= 0:
123
+ return Image.new("L", (width, height), 255)
124
+ mask = Image.new("L", (width, height), 255)
125
+ draw = ImageDraw.Draw(mask)
126
+ for offset in range(fade_length):
127
+ top_alpha = int(offset / fade_length * 255)
128
+ bottom_alpha = int((fade_length - offset) / fade_length * 255)
129
+ draw.line((0, offset, width, offset), fill=top_alpha)
130
+ y = height - fade_length + offset
131
+ draw.line((0, y, width, y), fill=bottom_alpha)
132
+ return mask
133
+
134
+
135
+ def render_text_plan(layer: ScrollTextPlan, output_path: Path) -> Image.Image:
136
+ style = layer.style
137
+ try:
138
+ render_text_image(
139
+ html_text=layer.html,
140
+ font_spec=_font_spec(style),
141
+ constraint=RenderConstraint(
142
+ max_width=layer.box.size.width,
143
+ max_height=layer.box.size.height,
144
+ min_width=layer.box.size.width if style.adjust.value == "WIDTH" else 0,
145
+ ),
146
+ output_path=str(output_path),
147
+ adjust=style.adjust.value,
148
+ vertical_align=style.vertical_alignment.value,
149
+ split_config=_split_config(style, layer.language),
150
+ style=_render_style(style),
151
+ base_font_size=18,
152
+ )
153
+ with Image.open(output_path) as image:
154
+ return image.convert("RGBA").copy()
155
+ except Exception as exc:
156
+ raise RenderError(
157
+ f"failed to render text layer {layer.id}",
158
+ code="text_render_failed",
159
+ field_path=f"layers.{layer.id}",
160
+ ) from exc
161
+
162
+
163
+ def render_long_text_plan(layer: ScrollLongTextPlan, output_path: Path) -> Image.Image:
164
+ style = layer.style
165
+ try:
166
+ text_to_long_image(
167
+ html_text=layer.html,
168
+ font_spec=_font_spec(style),
169
+ constraint=RenderConstraint(max_width=layer.viewport.size.width, max_height=0, min_width=0),
170
+ output_path=str(output_path),
171
+ split_config=_split_config(style, layer.language),
172
+ style=_render_style(style),
173
+ base_font_size=18,
174
+ )
175
+ with Image.open(output_path) as image:
176
+ return image.convert("RGBA").copy()
177
+ except Exception as exc:
178
+ raise RenderError(
179
+ f"failed to render scrolling text layer {layer.id}",
180
+ code="scroll_text_render_failed",
181
+ field_path=f"layers.{layer.id}",
182
+ ) from exc
183
+
184
+
185
+ def render_timeline_block(block: TimelineBlockPlan, plan: TimelineRenderPlan) -> Image.Image:
186
+ style = plan.body.style
187
+ colored_block = ColoredSubtitleBlock.model_validate(block.renderer_block)
188
+ try:
189
+ return render_colored_block(
190
+ block=colored_block,
191
+ measurer=TextMeasurer(_font_spec(style), stroke_size=style.font.stroke_size),
192
+ default_color=style.font.color,
193
+ align=style.font.alignment.value,
194
+ padding=(style.padding.x, style.padding.y),
195
+ mask_offset=(style.mask.offset.x, style.mask.offset.y),
196
+ mask_mode=style.mask.mode,
197
+ mask_color=style.mask.color,
198
+ mask_radius=style.mask.corner_radius,
199
+ stroke_size=style.font.stroke_size,
200
+ stroke_color=style.font.stroke_color,
201
+ target_width=block.size.width,
202
+ target_height=block.size.height,
203
+ ).convert("RGBA")
204
+ except Exception as exc:
205
+ raise RenderError(
206
+ f"failed to render timeline block {block.block_id}",
207
+ code="timeline_block_render_failed",
208
+ field_path=f"blocks.{block.block_id}",
209
+ ) from exc
210
+
211
+
212
+ def prepare_scroll_frames(plan: ScrollRenderPlan, workspace: Path, stack: ExitStack):
213
+ workspace.mkdir(parents=True, exist_ok=True)
214
+ prepared: dict[str, Image.Image | FrameProvider] = {}
215
+ for layer in plan.body.layers:
216
+ if isinstance(layer, ScrollBackgroundPlan):
217
+ prepared[layer.id] = _scroll_asset_provider(layer.asset, layer.box.size.model_dump(), layer.crop, stack)
218
+ elif isinstance(layer, ScrollTextPlan):
219
+ prepared[layer.id] = render_text_plan(layer, workspace / f"{layer.id}.png")
220
+ elif isinstance(layer, ScrollLongTextPlan):
221
+ prepared[layer.id] = render_long_text_plan(layer, workspace / f"{layer.id}.png")
222
+ return prepared
223
+
224
+
225
+ def compose_scroll_frame(
226
+ plan: ScrollRenderPlan,
227
+ prepared: dict[str, Image.Image | FrameProvider],
228
+ timestamp_s: float,
229
+ *,
230
+ hide_text: bool = False,
231
+ ) -> Image.Image:
232
+ canvas = Image.new(
233
+ "RGB",
234
+ (plan.body.canvas.width, plan.body.canvas.height),
235
+ (0, 0, 0, 255),
236
+ )
237
+ frame_index = int(timestamp_s * plan.body.output.fps)
238
+ frame_time_s = frame_index / plan.body.output.fps
239
+ for layer in plan.body.layers:
240
+ if hide_text and isinstance(layer, (ScrollTextPlan, ScrollLongTextPlan)):
241
+ continue
242
+ value = prepared[layer.id]
243
+ image = value(frame_time_s) if callable(value) else value.copy()
244
+ if isinstance(layer, ScrollLongTextPlan):
245
+ viewport = layer.viewport
246
+ visible = Image.new("RGBA", (viewport.size.width, viewport.size.height), (0, 0, 0, 0))
247
+ scroll_y = _scroll_y(layer, frame_time_s)
248
+ x = (viewport.size.width - image.width) // 2
249
+ if scroll_y + image.height > 0 and scroll_y < viewport.size.height:
250
+ visible.paste(image, (x, scroll_y))
251
+ original_alpha = visible.getchannel("A")
252
+ visible.putalpha(
253
+ ImageChops.multiply(
254
+ original_alpha,
255
+ create_scroll_mask(viewport.size.width, viewport.size.height, layer.fade_length_px),
256
+ )
257
+ )
258
+ canvas.paste(visible, (viewport.position.x, viewport.position.y), visible)
259
+ continue
260
+ position = (layer.box.position.x, layer.box.position.y)
261
+ if isinstance(layer, ScrollTextPlan):
262
+ x_offset = (layer.box.size.width - image.width) // 2
263
+ if layer.style.vertical_alignment.value == "top":
264
+ y_offset = 0
265
+ elif layer.style.vertical_alignment.value == "bottom":
266
+ y_offset = layer.box.size.height - image.height
267
+ else:
268
+ y_offset = (layer.box.size.height - image.height) // 2
269
+ position = (position[0] + x_offset, position[1] + y_offset)
270
+ if layer.opacity < 255:
271
+ image = apply_opacity(image, layer.opacity)
272
+ canvas.paste(image, position, image)
273
+ elif image.mode == "RGBA":
274
+ canvas.paste(image, position, image)
275
+ else:
276
+ canvas.paste(image, position)
277
+ return canvas
278
+
279
+
280
+ def prepare_timeline_frames(plan: TimelineRenderPlan, stack: ExitStack):
281
+ background = _timeline_asset_provider(
282
+ plan.body.background,
283
+ plan.body.canvas.model_dump(),
284
+ stack,
285
+ )
286
+ block_images = {block.block_id: render_timeline_block(block, plan) for block in plan.body.blocks}
287
+ return background, block_images
288
+
289
+
290
+ def compose_timeline_frame(
291
+ plan: TimelineRenderPlan,
292
+ background: Image.Image | FrameProvider,
293
+ block_images: dict[str, Image.Image],
294
+ timestamp_s: float,
295
+ ) -> Image.Image:
296
+ value = background(timestamp_s) if callable(background) else background.copy()
297
+ frame = value.convert("RGB")
298
+ for item in plan.body.frames:
299
+ for timing in item.timings:
300
+ if timing.start_time_s <= timestamp_s < timing.end_time_s:
301
+ block_image = block_images[timing.block_id]
302
+ frame.paste(block_image, (timing.position.x, timing.position.y), block_image)
303
+ return frame
304
+
305
+
306
+ def open_audio_clip(spec: ResolvedAudioSpec, background: ResolvedAsset, duration: float, stack: ExitStack):
307
+ clip = None
308
+ if spec.mode == AudioMode.USE_ASSET and spec.asset is not None:
309
+ clip = stack.enter_context(AudioFileClip(str(spec.asset.path)))
310
+ elif spec.mode == AudioMode.USE_BACKGROUND and background.media_type == "video":
311
+ clip = stack.enter_context(AudioFileClip(str(background.path)))
312
+ if clip is None:
313
+ return None
314
+ if spec.loop and clip.duration < duration:
315
+ clip = clip.with_effects([afx.AudioLoop(duration=duration)])
316
+ elif clip.duration >= duration:
317
+ clip = clip.subclipped(0, duration)
318
+ effects = []
319
+ if spec.volume != 1.0:
320
+ effects.append(afx.MultiplyVolume(factor=spec.volume))
321
+ if spec.fade_in_s:
322
+ effects.append(afx.AudioFadeIn(duration=spec.fade_in_s))
323
+ if spec.fade_out_s:
324
+ effects.append(afx.AudioFadeOut(duration=spec.fade_out_s))
325
+ return clip.with_effects(effects) if effects else clip
326
+
327
+
328
+ def write_video(
329
+ output_path: Path,
330
+ frame_function,
331
+ duration: float,
332
+ fps: int,
333
+ output_spec,
334
+ audio_clip,
335
+ *,
336
+ show_progress: bool = False,
337
+ ) -> None:
338
+ from moviepy import VideoClip
339
+
340
+ output_path.parent.mkdir(parents=True, exist_ok=True)
341
+ temp_audio_path = Path(tempfile.gettempdir()) / f"creative-render-engine-{os.getpid()}-{output_path.stem}.m4a"
342
+ clip = VideoClip(lambda timestamp: np.array(frame_function(timestamp)), duration=duration)
343
+ clip.fps = fps
344
+ if audio_clip is not None:
345
+ clip.audio = audio_clip
346
+ try:
347
+ clip.write_videofile(
348
+ str(output_path),
349
+ fps=fps,
350
+ codec=output_spec.codec,
351
+ audio_codec=output_spec.audio_codec,
352
+ temp_audiofile=str(temp_audio_path),
353
+ remove_temp=True,
354
+ threads=output_spec.threads,
355
+ preset=output_spec.preset,
356
+ ffmpeg_params=[
357
+ "-crf",
358
+ str(output_spec.crf),
359
+ "-metadata",
360
+ f"comment={output_spec.metadata_comment}",
361
+ ],
362
+ logger=_FrameIntervalProgressLogger() if show_progress else None,
363
+ )
364
+ except Exception as exc:
365
+ raise RenderError(
366
+ f"failed to write video: {output_path}",
367
+ code="video_write_failed",
368
+ ) from exc
369
+ finally:
370
+ clip.close()
371
+ if temp_audio_path.exists():
372
+ temp_audio_path.unlink()
373
+
374
+
375
+ def _scroll_asset_provider(
376
+ asset: ResolvedAsset,
377
+ raw_size: dict[str, int],
378
+ crop: CropMode,
379
+ stack: ExitStack,
380
+ ):
381
+ target_size = (int(raw_size["width"]), int(raw_size["height"]))
382
+ if asset.media_type == "image":
383
+ with Image.open(asset.path) as image:
384
+ return resize_image(image.copy(), target_size, crop)
385
+ target_width, target_height = target_size
386
+ if crop == CropMode.COVER:
387
+ clip = stack.enter_context(
388
+ VideoFileClip(
389
+ str(asset.path),
390
+ target_resolution=(target_width, None),
391
+ resize_algorithm="fast_bilinear",
392
+ audio=False,
393
+ )
394
+ )
395
+ clip = clip.cropped(
396
+ width=target_width,
397
+ height=target_height,
398
+ x_center=target_width // 2,
399
+ y_center=clip.size[1] // 2,
400
+ )
401
+
402
+ def cover_provider(timestamp_s: float) -> Image.Image:
403
+ timestamp = timestamp_s % clip.duration if clip.duration else 0.0
404
+ return Image.fromarray(clip.get_frame(timestamp))
405
+
406
+ return cover_provider
407
+
408
+ clip = stack.enter_context(VideoFileClip(str(asset.path), audio=False))
409
+
410
+ def fitted_provider(timestamp_s: float) -> Image.Image:
411
+ timestamp = timestamp_s % clip.duration if clip.duration else 0.0
412
+ frame = Image.fromarray(clip.get_frame(timestamp))
413
+ return resize_image(frame, target_size, crop)
414
+
415
+ return fitted_provider
416
+
417
+
418
+ def _timeline_asset_provider(
419
+ asset: ResolvedAsset,
420
+ raw_size: dict[str, int],
421
+ stack: ExitStack,
422
+ ):
423
+ target_width = int(raw_size["width"])
424
+ target_height = int(raw_size["height"])
425
+ if asset.media_type == "image":
426
+ with Image.open(asset.path) as image:
427
+ return resize_image(image.copy(), (target_width, target_height), CropMode.COVER)
428
+ clip = stack.enter_context(VideoFileClip(str(asset.path), audio=False))
429
+ original_width, original_height = clip.size
430
+ source_ratio = original_width / original_height
431
+ target_ratio = target_width / target_height
432
+ scale_factor = target_height / original_height if source_ratio > target_ratio else target_width / original_width
433
+ new_width = int(original_width * scale_factor)
434
+ new_height = int(original_height * scale_factor)
435
+ clip = clip.resized((new_width, new_height)).cropped(
436
+ width=target_width,
437
+ height=target_height,
438
+ x_center=new_width // 2,
439
+ y_center=new_height // 2,
440
+ )
441
+
442
+ def provider(timestamp_s: float) -> Image.Image:
443
+ timestamp = timestamp_s % clip.duration if clip.duration else 0.0
444
+ return Image.fromarray(clip.get_frame(timestamp))
445
+
446
+ return provider
447
+
448
+
449
+ def _scroll_y(layer: ScrollLongTextPlan, timestamp_s: float) -> int:
450
+ if timestamp_s < layer.begin_hold_s:
451
+ return layer.start_y_px
452
+ if timestamp_s < layer.begin_hold_s + layer.scroll_duration_s:
453
+ current_y = layer.start_y_px - (
454
+ timestamp_s - layer.begin_hold_s
455
+ ) * layer.scroll_speed_px_s
456
+ return max(layer.end_y_px, int(current_y))
457
+ return layer.end_y_px
458
+
459
+
460
+ def _font_spec(style: ResolvedTextStyle) -> FontSpec:
461
+ return FontSpec(
462
+ font_path=str(style.font.font.path),
463
+ font_size=style.font.font_size,
464
+ line_spacing=style.font.line_spacing,
465
+ )
466
+
467
+
468
+ def _split_config(style: ResolvedTextStyle, language: str) -> SplitConfig:
469
+ return SplitConfig(min_words_per_block=style.min_words_per_block, language=language.upper())
470
+
471
+
472
+ def _render_style(style: ResolvedTextStyle) -> RenderStyle:
473
+ return RenderStyle(
474
+ text_color=style.font.color,
475
+ bg_color=None,
476
+ alignment=style.font.alignment.value,
477
+ padding=(style.padding.x, style.padding.y),
478
+ mask_mode=style.mask.mode,
479
+ mask_color=style.mask.color,
480
+ mask_offset=(style.mask.offset.x, style.mask.offset.y),
481
+ corner_radius=style.mask.corner_radius,
482
+ stroke_size=style.font.stroke_size,
483
+ stroke_color=style.font.stroke_color,
484
+ )
@@ -0,0 +1,99 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import tempfile
5
+ from io import BytesIO
6
+ from pathlib import Path
7
+ from typing import cast
8
+
9
+ from PIL import Image, ImageColor, ImageOps
10
+
11
+ from ..errors import RenderError
12
+ from ..plans import GraphicTextRenderPlan, PureImageRenderPlan, ResolvedAsset
13
+ from .common import render_text_plan, resize_image
14
+
15
+
16
+ def _load_static_image(asset: ResolvedAsset) -> Image.Image:
17
+ content = asset.path.read_bytes()
18
+ if hashlib.sha256(content).hexdigest() != asset.sha256:
19
+ raise RenderError(
20
+ "static image sha256 changed after compile",
21
+ code="asset_hash_mismatch",
22
+ field_path="body.image.sha256",
23
+ )
24
+ with Image.open(BytesIO(content)) as image:
25
+ if (
26
+ bool(getattr(image, "is_animated", False))
27
+ or getattr(image, "n_frames", 1) > 1
28
+ ):
29
+ raise RenderError(
30
+ "animated images are not supported for static image rendering",
31
+ code="animated_image_not_supported",
32
+ field_path="body.image.path",
33
+ )
34
+ oriented_value = ImageOps.exif_transpose(image)
35
+ if oriented_value is None:
36
+ raise ValueError("image orientation failed")
37
+ oriented = cast(Image.Image, oriented_value)
38
+ oriented.load()
39
+ return oriented.copy()
40
+
41
+
42
+ def compose_pure_image(plan: PureImageRenderPlan) -> Image.Image:
43
+ try:
44
+ return resize_image(
45
+ _load_static_image(plan.body.image),
46
+ (plan.body.canvas.width, plan.body.canvas.height),
47
+ plan.body.layer.fit,
48
+ )
49
+ except RenderError:
50
+ raise
51
+ except Exception as exc:
52
+ raise RenderError(
53
+ f"failed to compose pure image: {plan.body.image.path}",
54
+ code="pure_image_compose_failed",
55
+ field_path="body.image.path",
56
+ ) from exc
57
+
58
+
59
+ def compose_graphic_text(plan: GraphicTextRenderPlan) -> Image.Image:
60
+ try:
61
+ canvas = Image.new(
62
+ "RGBA",
63
+ (plan.body.canvas.width, plan.body.canvas.height),
64
+ ImageColor.getcolor(plan.body.background_color, "RGBA"),
65
+ )
66
+ image_layer = plan.body.image_layer
67
+ image = resize_image(
68
+ _load_static_image(plan.body.image),
69
+ (image_layer.box.size.width, image_layer.box.size.height),
70
+ image_layer.fit,
71
+ )
72
+ canvas.paste(
73
+ image,
74
+ (image_layer.box.position.x, image_layer.box.position.y),
75
+ image if image.mode == "RGBA" else None,
76
+ )
77
+ with tempfile.TemporaryDirectory(
78
+ prefix="creative-render-engine-graphic-text-"
79
+ ) as value:
80
+ text = render_text_plan(plan.body.text, Path(value) / "text.png")
81
+ text_layer = plan.body.text
82
+ x = text_layer.box.position.x + (text_layer.box.size.width - text.width) // 2
83
+ if text_layer.style.vertical_alignment.value == "top":
84
+ y_offset = 0
85
+ elif text_layer.style.vertical_alignment.value == "bottom":
86
+ y_offset = text_layer.box.size.height - text.height
87
+ else:
88
+ y_offset = (text_layer.box.size.height - text.height) // 2
89
+ y = text_layer.box.position.y + y_offset
90
+ canvas.paste(text, (x, y), text)
91
+ return canvas
92
+ except RenderError:
93
+ raise
94
+ except Exception as exc:
95
+ raise RenderError(
96
+ f"failed to compose graphic text: {plan.body.image.path}",
97
+ code="graphic_text_compose_failed",
98
+ field_path="body.image.path",
99
+ ) from exc
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from typing import Any
5
+
6
+
7
+ class CreativeRenderEngineError(Exception):
8
+ """Base error with a stable machine-readable code and optional field path."""
9
+
10
+ default_code = "creative_render_engine_error"
11
+
12
+ def __init__(
13
+ self,
14
+ message: str,
15
+ *,
16
+ code: str | None = None,
17
+ field_path: str | None = None,
18
+ details: Mapping[str, Any] | None = None,
19
+ ) -> None:
20
+ self.code = code or self.default_code
21
+ self.field_path = field_path
22
+ self.details = dict(details or {})
23
+ prefix = f"{field_path}: " if field_path else ""
24
+ super().__init__(f"{self.code}: {prefix}{message}")
25
+
26
+
27
+ class ContractError(CreativeRenderEngineError):
28
+ default_code = "contract_error"
29
+
30
+
31
+ class ConfigurationError(CreativeRenderEngineError):
32
+ default_code = "configuration_error"
33
+
34
+
35
+ class AssetError(CreativeRenderEngineError):
36
+ default_code = "asset_error"
37
+
38
+
39
+ class CompileError(CreativeRenderEngineError):
40
+ default_code = "compile_error"
41
+
42
+
43
+ class RenderError(CreativeRenderEngineError):
44
+ default_code = "render_error"
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from pydantic import BaseModel
9
+
10
+
11
+ def file_sha256(path: Path) -> str:
12
+ digest = hashlib.sha256()
13
+ with path.open("rb") as source:
14
+ for chunk in iter(lambda: source.read(1024 * 1024), b""):
15
+ digest.update(chunk)
16
+ return digest.hexdigest()
17
+
18
+
19
+ def canonical_json_bytes(value: BaseModel | dict[str, Any]) -> bytes:
20
+ payload = value.model_dump(mode="json") if isinstance(value, BaseModel) else value
21
+ return json.dumps(
22
+ payload,
23
+ ensure_ascii=False,
24
+ sort_keys=True,
25
+ separators=(",", ":"),
26
+ ).encode("utf-8")
27
+
28
+
29
+ def stable_sha256(value: BaseModel | dict[str, Any]) -> str:
30
+ return hashlib.sha256(canonical_json_bytes(value)).hexdigest()