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,328 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.metadata
4
+ import shutil
5
+ import subprocess
6
+ import tempfile
7
+ import uuid
8
+ from collections.abc import Callable
9
+ from contextlib import ExitStack
10
+ from pathlib import Path
11
+
12
+ from moviepy import VideoFileClip
13
+ from PIL import Image
14
+
15
+ from .engines.common import (
16
+ compose_scroll_frame,
17
+ compose_timeline_frame,
18
+ open_audio_clip,
19
+ prepare_scroll_frames,
20
+ prepare_timeline_frames,
21
+ resize_image,
22
+ write_video,
23
+ )
24
+ from .engines.pure_image import compose_graphic_text, compose_pure_image
25
+ from .errors import RenderError
26
+ from .hashing import file_sha256
27
+ from .plans import (
28
+ EngineVersions,
29
+ GraphicTextRenderPlan,
30
+ ImageRenderOutput,
31
+ PureImageRenderPlan,
32
+ RenderManifest,
33
+ RenderPlan,
34
+ ResolvedAsset,
35
+ ScrollBackgroundPlan,
36
+ ScrollRenderPlan,
37
+ TimelineRenderPlan,
38
+ VideoRenderOutput,
39
+ )
40
+ from .schemas import FirstFrameMode
41
+
42
+ LIBRARY_VERSION = "0.6.1"
43
+
44
+
45
+ def render_plan(
46
+ plan: RenderPlan,
47
+ output_path: str | Path,
48
+ *,
49
+ show_progress: bool = False,
50
+ ) -> RenderManifest:
51
+ destination = Path(output_path).expanduser().resolve()
52
+ if isinstance(plan, PureImageRenderPlan):
53
+ return _render_pure_image(plan, destination)
54
+ if isinstance(plan, GraphicTextRenderPlan):
55
+ return _render_graphic_text(plan, destination)
56
+ if destination.suffix.lower() != ".mp4":
57
+ raise RenderError(
58
+ "output path must use the .mp4 extension",
59
+ code="invalid_output_extension",
60
+ field_path="output_path",
61
+ )
62
+ destination.parent.mkdir(parents=True, exist_ok=True)
63
+ with tempfile.TemporaryDirectory(
64
+ prefix="creative-render-engine-"
65
+ ) as workspace_value:
66
+ workspace = Path(workspace_value)
67
+ with ExitStack() as stack:
68
+ if isinstance(plan, ScrollRenderPlan):
69
+ prepared = prepare_scroll_frames(plan, workspace, stack)
70
+ first_frame = _scroll_first_frame(plan, prepared)
71
+
72
+ def frame_function(timestamp: float):
73
+ if first_frame is not None and timestamp < 1 / plan.body.output.fps:
74
+ return first_frame.copy()
75
+ return compose_scroll_frame(plan, prepared, timestamp)
76
+
77
+ backgrounds = [
78
+ layer.asset
79
+ for layer in plan.body.layers
80
+ if isinstance(layer, ScrollBackgroundPlan)
81
+ ]
82
+ background_audio_source = next(
83
+ (asset for asset in backgrounds if asset.media_type == "video"),
84
+ _empty_background_asset(),
85
+ )
86
+ audio_clip = open_audio_clip(
87
+ plan.body.audio,
88
+ background_audio_source,
89
+ plan.body.duration_s,
90
+ stack,
91
+ )
92
+ assets = _scroll_assets(plan)
93
+ duration = plan.body.duration_s
94
+ output_spec = plan.body.output
95
+ elif isinstance(plan, TimelineRenderPlan):
96
+ background, block_images = prepare_timeline_frames(plan, stack)
97
+
98
+ def frame_function(timestamp: float):
99
+ return compose_timeline_frame(
100
+ plan,
101
+ background,
102
+ block_images,
103
+ timestamp,
104
+ )
105
+
106
+ audio_clip = open_audio_clip(
107
+ plan.body.audio,
108
+ plan.body.background,
109
+ plan.body.duration_s,
110
+ stack,
111
+ )
112
+ assets = _timeline_assets(plan)
113
+ duration = plan.body.duration_s
114
+ output_spec = plan.body.output
115
+ else:
116
+ raise RenderError("unsupported render plan", code="invalid_render_plan")
117
+ write_video(
118
+ destination,
119
+ frame_function,
120
+ duration,
121
+ output_spec.fps,
122
+ output_spec,
123
+ audio_clip,
124
+ show_progress=show_progress,
125
+ )
126
+ return _build_manifest(plan.plan_hash, destination, assets)
127
+
128
+
129
+ def _render_pure_image(plan: PureImageRenderPlan, destination: Path) -> RenderManifest:
130
+ return _render_png(
131
+ plan_hash=plan.plan_hash,
132
+ destination=destination,
133
+ compose=lambda: compose_pure_image(plan).convert("RGB"),
134
+ assets=(plan.body.image,),
135
+ failure_code="pure_image_render_failed",
136
+ )
137
+
138
+
139
+ def _render_graphic_text(
140
+ plan: GraphicTextRenderPlan,
141
+ destination: Path,
142
+ ) -> RenderManifest:
143
+ return _render_png(
144
+ plan_hash=plan.plan_hash,
145
+ destination=destination,
146
+ compose=lambda: compose_graphic_text(plan),
147
+ assets=(plan.body.image, plan.body.text.style.font.font),
148
+ failure_code="graphic_text_render_failed",
149
+ )
150
+
151
+
152
+ def _render_png(
153
+ *,
154
+ plan_hash: str,
155
+ destination: Path,
156
+ compose: Callable[[], Image.Image],
157
+ assets: tuple[ResolvedAsset, ...],
158
+ failure_code: str,
159
+ ) -> RenderManifest:
160
+ if destination.suffix.lower() != ".png":
161
+ raise RenderError(
162
+ "image output path must use the .png extension",
163
+ code="invalid_output_extension",
164
+ field_path="output_path",
165
+ )
166
+ destination.parent.mkdir(parents=True, exist_ok=True)
167
+ temporary = destination.with_name(f".{destination.name}.{uuid.uuid4().hex}.tmp")
168
+ try:
169
+ compose().save(temporary, format="PNG")
170
+ temporary.replace(destination)
171
+ except RenderError:
172
+ temporary.unlink(missing_ok=True)
173
+ raise
174
+ except Exception as exc:
175
+ temporary.unlink(missing_ok=True)
176
+ raise RenderError(
177
+ f"failed to render image: {destination}",
178
+ code=failure_code,
179
+ field_path="output_path",
180
+ ) from exc
181
+ try:
182
+ with Image.open(destination) as rendered:
183
+ rendered.load()
184
+ width, height = rendered.size
185
+ if rendered.format != "PNG":
186
+ raise ValueError("rendered image is not PNG")
187
+ except Exception as exc:
188
+ raise RenderError(
189
+ f"generated image cannot be inspected: {destination}",
190
+ code="output_inspection_failed",
191
+ ) from exc
192
+ return RenderManifest(
193
+ plan_hash=plan_hash,
194
+ output=ImageRenderOutput(
195
+ path=destination,
196
+ sha256=file_sha256(destination),
197
+ size_bytes=destination.stat().st_size,
198
+ width=width,
199
+ height=height,
200
+ ),
201
+ assets=assets,
202
+ engine_versions=_engine_versions(),
203
+ )
204
+
205
+
206
+ def _engine_versions() -> EngineVersions:
207
+ return EngineVersions(
208
+ creative_render_engine=LIBRARY_VERSION,
209
+ text_block_renderer=_version("text-block-renderer"),
210
+ moviepy=_version("moviepy"),
211
+ pillow=_version("pillow"),
212
+ ffmpeg=_ffmpeg_version(),
213
+ )
214
+
215
+
216
+ def _scroll_first_frame(plan: ScrollRenderPlan, prepared):
217
+ mode = plan.body.first_frame.mode
218
+ if mode == FirstFrameMode.COMPOSED:
219
+ return None
220
+ if mode == FirstFrameMode.HIDDEN_TEXT:
221
+ return compose_scroll_frame(plan, prepared, 0.0, hide_text=True)
222
+ asset = plan.body.first_frame.asset
223
+ if asset is None:
224
+ raise RenderError(
225
+ "first-frame asset is missing", code="missing_first_frame_asset"
226
+ )
227
+ from PIL import Image
228
+
229
+ try:
230
+ with Image.open(asset.path) as image:
231
+ return resize_image(
232
+ image.copy(),
233
+ (plan.body.canvas.width, plan.body.canvas.height),
234
+ "cover",
235
+ ).convert("RGB")
236
+ except Exception as exc:
237
+ raise RenderError(
238
+ f"failed to load first-frame asset: {asset.path}",
239
+ code="first_frame_decode_failed",
240
+ ) from exc
241
+
242
+
243
+ def _build_manifest(
244
+ plan_hash: str,
245
+ output_path: Path,
246
+ assets: tuple[ResolvedAsset, ...],
247
+ ) -> RenderManifest:
248
+ try:
249
+ with VideoFileClip(str(output_path), audio=False) as clip:
250
+ output = VideoRenderOutput(
251
+ path=output_path,
252
+ sha256=file_sha256(output_path),
253
+ size_bytes=output_path.stat().st_size,
254
+ width=int(clip.w),
255
+ height=int(clip.h),
256
+ duration_s=float(clip.duration),
257
+ fps=float(clip.fps),
258
+ )
259
+ except Exception as exc:
260
+ raise RenderError(
261
+ f"generated video cannot be inspected: {output_path}",
262
+ code="output_inspection_failed",
263
+ ) from exc
264
+ return RenderManifest(
265
+ plan_hash=plan_hash,
266
+ output=output,
267
+ assets=assets,
268
+ engine_versions=_engine_versions(),
269
+ )
270
+
271
+
272
+ def _scroll_assets(plan: ScrollRenderPlan) -> tuple[ResolvedAsset, ...]:
273
+ assets: dict[str, ResolvedAsset] = {}
274
+ for layer in plan.body.layers:
275
+ if isinstance(layer, ScrollBackgroundPlan):
276
+ assets[layer.asset.id] = layer.asset
277
+ elif hasattr(layer, "style"):
278
+ font = layer.style.font.font
279
+ assets[font.id] = font
280
+ if plan.body.audio.asset is not None:
281
+ assets[plan.body.audio.asset.id] = plan.body.audio.asset
282
+ if plan.body.first_frame.asset is not None:
283
+ assets[plan.body.first_frame.asset.id] = plan.body.first_frame.asset
284
+ return tuple(assets[key] for key in sorted(assets))
285
+
286
+
287
+ def _timeline_assets(plan: TimelineRenderPlan) -> tuple[ResolvedAsset, ...]:
288
+ assets = {
289
+ plan.body.background.id: plan.body.background,
290
+ plan.body.style.font.font.id: plan.body.style.font.font,
291
+ }
292
+ if plan.body.audio.asset is not None:
293
+ assets[plan.body.audio.asset.id] = plan.body.audio.asset
294
+ return tuple(assets[key] for key in sorted(assets))
295
+
296
+
297
+ def _empty_background_asset() -> ResolvedAsset:
298
+ return ResolvedAsset(
299
+ id="no-background-audio",
300
+ path=Path("."),
301
+ media_type="image",
302
+ sha256="0" * 64,
303
+ )
304
+
305
+
306
+ def _version(name: str) -> str:
307
+ try:
308
+ return importlib.metadata.version(name)
309
+ except importlib.metadata.PackageNotFoundError:
310
+ return "unknown"
311
+
312
+
313
+ def _ffmpeg_version() -> str | None:
314
+ executable = shutil.which("ffmpeg")
315
+ if executable is None:
316
+ return None
317
+ try:
318
+ completed = subprocess.run(
319
+ [executable, "-version"],
320
+ check=True,
321
+ capture_output=True,
322
+ text=True,
323
+ timeout=5,
324
+ )
325
+ except (OSError, subprocess.SubprocessError):
326
+ return None
327
+ first_line = completed.stdout.splitlines()[0] if completed.stdout else ""
328
+ return first_line or None