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
|
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()
|