creative-render-engine 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- creative_render_engine/__init__.py +72 -0
- creative_render_engine/api.py +79 -0
- creative_render_engine/compiler.py +1428 -0
- creative_render_engine/engines/__init__.py +0 -0
- creative_render_engine/engines/common.py +484 -0
- creative_render_engine/engines/pure_image.py +99 -0
- creative_render_engine/errors.py +44 -0
- creative_render_engine/hashing.py +30 -0
- creative_render_engine/output_paths.py +75 -0
- creative_render_engine/plans.py +310 -0
- creative_render_engine/preview.py +203 -0
- creative_render_engine/py.typed +0 -0
- creative_render_engine/render.py +328 -0
- creative_render_engine/schemas.py +763 -0
- creative_render_engine/settings.py +31 -0
- creative_render_engine-0.7.0.dist-info/METADATA +59 -0
- creative_render_engine-0.7.0.dist-info/RECORD +19 -0
- creative_render_engine-0.7.0.dist-info/WHEEL +5 -0
- creative_render_engine-0.7.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|