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,75 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+
7
+ from .errors import ConfigurationError, RenderError
8
+ from .settings import settings
9
+
10
+
11
+ def resolve_render_path(
12
+ output_path: str | Path | None,
13
+ *,
14
+ output_dir: str | Path | None,
15
+ output_filename: str | None,
16
+ kind: str,
17
+ ) -> Path:
18
+ root = settings.require_output_root()
19
+ if output_path is not None and (
20
+ output_dir is not None or output_filename is not None
21
+ ):
22
+ raise ConfigurationError(
23
+ "output_path cannot be combined with output_dir or output_filename",
24
+ code="conflicting_output_path_options",
25
+ )
26
+ if (output_dir is None) != (output_filename is None):
27
+ raise ConfigurationError(
28
+ "output_dir and output_filename must be provided together",
29
+ code="incomplete_output_path_options",
30
+ )
31
+ if output_path is not None:
32
+ destination = _under_root(root, output_path)
33
+ elif output_dir is not None and output_filename is not None:
34
+ directory = _under_root(root, output_dir)
35
+ destination = _under_root(root, directory / output_filename)
36
+ else:
37
+ suffix = ".png" if kind in {"pure_image", "graphic_text"} else ".mp4"
38
+ destination = _dated_root(root) / f"render-{uuid.uuid4().hex}{suffix}"
39
+ expected_suffix = ".png" if kind in {"pure_image", "graphic_text"} else ".mp4"
40
+ if destination.suffix.lower() != expected_suffix:
41
+ raise RenderError(
42
+ f"{kind} output path must use the {expected_suffix} extension",
43
+ code="invalid_output_extension",
44
+ field_path="output_path",
45
+ )
46
+ destination.parent.mkdir(parents=True, exist_ok=True)
47
+ return destination
48
+
49
+
50
+ def resolve_preview_dir(output_dir: str | Path | None) -> Path:
51
+ root = settings.require_output_root()
52
+ if output_dir is not None:
53
+ destination = _under_root(root, output_dir)
54
+ else:
55
+ destination = _dated_root(root) / f"preview-{uuid.uuid4().hex}"
56
+ destination.mkdir(parents=True, exist_ok=True)
57
+ return destination
58
+
59
+
60
+ def _dated_root(root: Path) -> Path:
61
+ return root / datetime.now(timezone.utc).strftime("%Y%m%d") # noqa: UP017
62
+
63
+
64
+ def _under_root(root: Path, value: str | Path) -> Path:
65
+ candidate = Path(value).expanduser()
66
+ destination = (
67
+ candidate.resolve() if candidate.is_absolute() else (root / candidate).resolve()
68
+ )
69
+ if not destination.is_relative_to(root):
70
+ raise ConfigurationError(
71
+ f"output path must remain under settings.output_root: {destination}",
72
+ code="output_path_outside_root",
73
+ field_path="output_path",
74
+ )
75
+ return destination
@@ -0,0 +1,310 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated, Literal, TypeAlias
5
+
6
+ from pydantic import ConfigDict, Field
7
+
8
+ from .schemas import (
9
+ AudioMode,
10
+ Box,
11
+ CropMode,
12
+ FirstFrameMode,
13
+ HorizontalAlignment,
14
+ ImageOutputSpec,
15
+ Point,
16
+ PureImageLayer,
17
+ Size,
18
+ StrictModel,
19
+ TextAdjustMode,
20
+ VerticalAlignment,
21
+ VideoOutputSpec,
22
+ )
23
+
24
+
25
+ class PlanModel(StrictModel):
26
+ model_config = ConfigDict(
27
+ extra="forbid", frozen=True, arbitrary_types_allowed=False
28
+ )
29
+
30
+
31
+ class ResolvedAsset(PlanModel):
32
+ id: str
33
+ path: Path
34
+ media_type: Literal["image", "video", "audio", "font"]
35
+ sha256: str
36
+ size: Size | None = None
37
+ duration_s: float | None = None
38
+
39
+
40
+ class PureImageRenderPlanBody(PlanModel):
41
+ canvas: Size
42
+ layer: PureImageLayer
43
+ image: ResolvedAsset
44
+ output: ImageOutputSpec
45
+ asset_ids: tuple[str, ...]
46
+
47
+
48
+ class GraphicTextMeasurement(PlanModel):
49
+ plain_text: str
50
+ image_width: int
51
+ image_height: int
52
+ line_count: int
53
+ fit_line_count: int
54
+ overflow_character_count: int
55
+ remaining_height_px: int
56
+ fill_ratio: float
57
+ fits: bool
58
+
59
+
60
+ class ResolvedFontStyle(PlanModel):
61
+ font: ResolvedAsset
62
+ font_size: int
63
+ line_spacing: int
64
+ color: str
65
+ alignment: HorizontalAlignment
66
+ stroke_size: int
67
+ stroke_color: str
68
+
69
+
70
+ class ResolvedMaskStyle(PlanModel):
71
+ mode: int
72
+ color: str
73
+ offset: Point
74
+ corner_radius: int
75
+
76
+
77
+ class ResolvedTextStyle(PlanModel):
78
+ font: ResolvedFontStyle
79
+ mask: ResolvedMaskStyle
80
+ padding: Point
81
+ adjust: TextAdjustMode
82
+ vertical_alignment: VerticalAlignment
83
+ min_words_per_block: int
84
+
85
+
86
+ class ResolvedAudioSpec(PlanModel):
87
+ mode: AudioMode
88
+ asset: ResolvedAsset | None
89
+ loop: bool
90
+ fade_in_s: float
91
+ fade_out_s: float
92
+ volume: float
93
+
94
+
95
+ class ResolvedFirstFrameSpec(PlanModel):
96
+ mode: FirstFrameMode
97
+ asset: ResolvedAsset | None
98
+
99
+
100
+ class ScrollBackgroundPlan(PlanModel):
101
+ type: Literal["background"] = "background"
102
+ id: str
103
+ z_index: int
104
+ box: Box
105
+ opacity: int
106
+ crop: CropMode
107
+ asset: ResolvedAsset
108
+
109
+
110
+ class ScrollTextPlan(PlanModel):
111
+ type: Literal["text"] = "text"
112
+ id: str
113
+ z_index: int
114
+ box: Box
115
+ opacity: int
116
+ html: str
117
+ language: str
118
+ style: ResolvedTextStyle
119
+
120
+
121
+ class GraphicTextRenderPlanBody(PlanModel):
122
+ canvas: Size
123
+ background_color: str
124
+ image_layer: PureImageLayer
125
+ image: ResolvedAsset
126
+ text: ScrollTextPlan
127
+ measurement: GraphicTextMeasurement
128
+ output: ImageOutputSpec
129
+ asset_ids: tuple[str, ...]
130
+
131
+
132
+ class ScrollLongTextPlan(PlanModel):
133
+ type: Literal["scroll_text"] = "scroll_text"
134
+ id: str
135
+ z_index: int
136
+ viewport: Box
137
+ opacity: int
138
+ html: str
139
+ language: str
140
+ style: ResolvedTextStyle
141
+ fade_length_px: int
142
+ scroll_speed_px_s: float
143
+ start_offset_y_px: int
144
+ start_y_px: int
145
+ end_y_px: int
146
+ begin_hold_s: float
147
+ end_hold_s: float
148
+ estimated_image_size: Size
149
+ scroll_distance_px: float
150
+ scroll_duration_s: float
151
+
152
+
153
+ ScrollPlanLayer: TypeAlias = Annotated[ # noqa: UP040
154
+ ScrollBackgroundPlan | ScrollTextPlan | ScrollLongTextPlan,
155
+ Field(discriminator="type"),
156
+ ]
157
+
158
+
159
+ class ScrollRenderPlanBody(PlanModel):
160
+ canvas: Size
161
+ layers: tuple[ScrollPlanLayer, ...]
162
+ audio: ResolvedAudioSpec
163
+ first_frame: ResolvedFirstFrameSpec
164
+ output: VideoOutputSpec
165
+ duration_s: float
166
+ asset_ids: tuple[str, ...]
167
+
168
+
169
+ class TimelineWordPlan(PlanModel):
170
+ word: str
171
+ color: str | None = None
172
+ font_size: int | None = None
173
+ force_newline: bool = False
174
+ leading_space: bool = False
175
+ glue_to_previous: bool = False
176
+
177
+
178
+ class TimelineBlockPlan(PlanModel):
179
+ block_id: str
180
+ plain_text: str
181
+ colored_words: tuple[TimelineWordPlan, ...]
182
+ renderer_block: dict
183
+ size: Size
184
+
185
+
186
+ class TimelineBlockTimingPlan(PlanModel):
187
+ block_id: str
188
+ position: Point
189
+ start_time_s: float
190
+ end_time_s: float
191
+
192
+
193
+ class TimelineFramePlan(PlanModel):
194
+ frame_id: str
195
+ frame_index: int
196
+ start_time_s: float
197
+ end_time_s: float
198
+ timings: tuple[TimelineBlockTimingPlan, ...]
199
+
200
+
201
+ class TimelineRenderPlanBody(PlanModel):
202
+ canvas: Size
203
+ subtitle_box: Box
204
+ style: ResolvedTextStyle
205
+ language: str
206
+ spacing_px: int
207
+ base_font_size: int
208
+ background: ResolvedAsset
209
+ audio: ResolvedAudioSpec
210
+ output: VideoOutputSpec
211
+ duration_s: float
212
+ blocks: tuple[TimelineBlockPlan, ...]
213
+ frames: tuple[TimelineFramePlan, ...]
214
+ asset_ids: tuple[str, ...]
215
+
216
+
217
+ class PureImageRenderPlan(PlanModel):
218
+ schema_version: Literal["1.0"] = "1.0"
219
+ kind: Literal["pure_image"] = "pure_image"
220
+ template_id: str
221
+ template_revision: int
222
+ plan_hash: str
223
+ body: PureImageRenderPlanBody
224
+
225
+
226
+ class GraphicTextRenderPlan(PlanModel):
227
+ schema_version: Literal["1.0"] = "1.0"
228
+ kind: Literal["graphic_text"] = "graphic_text"
229
+ template_id: str
230
+ template_revision: int
231
+ plan_hash: str
232
+ body: GraphicTextRenderPlanBody
233
+
234
+
235
+ class ScrollRenderPlan(PlanModel):
236
+ schema_version: Literal["1.0"] = "1.0"
237
+ kind: Literal["scroll_video"] = "scroll_video"
238
+ template_id: str
239
+ template_revision: int
240
+ plan_hash: str
241
+ body: ScrollRenderPlanBody
242
+
243
+
244
+ class TimelineRenderPlan(PlanModel):
245
+ schema_version: Literal["1.0"] = "1.0"
246
+ kind: Literal["timeline_video"] = "timeline_video"
247
+ template_id: str
248
+ template_revision: int
249
+ plan_hash: str
250
+ body: TimelineRenderPlanBody
251
+
252
+
253
+ RenderPlan: TypeAlias = Annotated[ # noqa: UP040
254
+ PureImageRenderPlan | GraphicTextRenderPlan | ScrollRenderPlan | TimelineRenderPlan,
255
+ Field(discriminator="kind"),
256
+ ]
257
+
258
+
259
+ class PreviewItem(PlanModel):
260
+ path: Path
261
+ timestamp_s: float
262
+ sha256: str
263
+ size_bytes: int
264
+ size: Size
265
+
266
+
267
+ class PreviewManifest(PlanModel):
268
+ plan_hash: str
269
+ items: tuple[PreviewItem, ...]
270
+
271
+
272
+ class VideoRenderOutput(PlanModel):
273
+ path: Path
274
+ sha256: str
275
+ size_bytes: int
276
+ width: int
277
+ height: int
278
+ duration_s: float
279
+ fps: float
280
+ media_type: Literal["video/mp4"] = "video/mp4"
281
+
282
+
283
+ class ImageRenderOutput(PlanModel):
284
+ path: Path
285
+ sha256: str
286
+ size_bytes: int
287
+ width: int
288
+ height: int
289
+ media_type: Literal["image/png"] = "image/png"
290
+
291
+
292
+ RenderOutput: TypeAlias = Annotated[ # noqa: UP040
293
+ VideoRenderOutput | ImageRenderOutput,
294
+ Field(discriminator="media_type"),
295
+ ]
296
+
297
+
298
+ class EngineVersions(PlanModel):
299
+ creative_render_engine: str
300
+ text_block_renderer: str
301
+ moviepy: str
302
+ pillow: str
303
+ ffmpeg: str | None = None
304
+
305
+
306
+ class RenderManifest(PlanModel):
307
+ plan_hash: str
308
+ output: RenderOutput
309
+ assets: tuple[ResolvedAsset, ...]
310
+ engine_versions: EngineVersions
@@ -0,0 +1,203 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable, Sequence
4
+ from contextlib import ExitStack, suppress
5
+ from itertools import pairwise
6
+ from pathlib import Path
7
+
8
+ from PIL import Image
9
+
10
+ from .engines.common import (
11
+ compose_scroll_frame,
12
+ compose_timeline_frame,
13
+ prepare_scroll_frames,
14
+ prepare_timeline_frames,
15
+ )
16
+ from .engines.pure_image import compose_graphic_text, compose_pure_image
17
+ from .errors import RenderError
18
+ from .hashing import file_sha256
19
+ from .plans import (
20
+ GraphicTextRenderPlan,
21
+ PreviewItem,
22
+ PreviewManifest,
23
+ PureImageRenderPlan,
24
+ RenderPlan,
25
+ ScrollLongTextPlan,
26
+ ScrollRenderPlan,
27
+ TimelineRenderPlan,
28
+ )
29
+ from .schemas import Size
30
+
31
+ MAX_TIMELINE_PREVIEW_FRAMES = 20
32
+
33
+
34
+ def preview_plan(
35
+ plan: RenderPlan,
36
+ output_dir: str | Path,
37
+ *,
38
+ timestamps: Sequence[float] | None = None,
39
+ ) -> PreviewManifest:
40
+ destination = Path(output_dir).expanduser().resolve()
41
+ destination.mkdir(parents=True, exist_ok=True)
42
+ workspace = destination / f".workspace-{plan.plan_hash[:12]}"
43
+ workspace.mkdir(parents=True, exist_ok=True)
44
+ items: list[PreviewItem] = []
45
+ try:
46
+ with ExitStack() as stack:
47
+ frame_function: Callable[[float], Image.Image]
48
+ if isinstance(plan, PureImageRenderPlan):
49
+ resolved_timestamps = _preview_timestamps(plan, timestamps)
50
+
51
+ def pure_image_frame(_value: float) -> Image.Image:
52
+ return compose_pure_image(plan)
53
+
54
+ frame_function = pure_image_frame
55
+ elif isinstance(plan, GraphicTextRenderPlan):
56
+ resolved_timestamps = _preview_timestamps(plan, timestamps)
57
+
58
+ def graphic_text_frame(_value: float) -> Image.Image:
59
+ return compose_graphic_text(plan)
60
+
61
+ frame_function = graphic_text_frame
62
+ elif isinstance(plan, ScrollRenderPlan):
63
+ prepared = prepare_scroll_frames(plan, workspace, stack)
64
+ resolved_timestamps = _preview_timestamps(plan, timestamps)
65
+
66
+ def scroll_frame(value: float) -> Image.Image:
67
+ return compose_scroll_frame(plan, prepared, value)
68
+
69
+ frame_function = scroll_frame
70
+ elif isinstance(plan, TimelineRenderPlan):
71
+ background, block_images = prepare_timeline_frames(plan, stack)
72
+ resolved_timestamps = _preview_timestamps(plan, timestamps)
73
+
74
+ def timeline_frame(value: float) -> Image.Image:
75
+ return compose_timeline_frame(
76
+ plan,
77
+ background,
78
+ block_images,
79
+ value,
80
+ )
81
+
82
+ frame_function = timeline_frame
83
+ else:
84
+ raise RenderError(
85
+ "unsupported preview plan", code="invalid_render_plan"
86
+ )
87
+ for index, timestamp in enumerate(resolved_timestamps):
88
+ output_path = destination / f"preview-{index:03d}-{timestamp:.3f}.jpg"
89
+ image = frame_function(timestamp)
90
+ image.convert("RGB").save(output_path, format="JPEG", quality=90)
91
+ items.append(
92
+ PreviewItem(
93
+ path=output_path,
94
+ timestamp_s=round(timestamp, 6),
95
+ sha256=file_sha256(output_path),
96
+ size_bytes=output_path.stat().st_size,
97
+ size=Size(width=image.width, height=image.height),
98
+ )
99
+ )
100
+ except RenderError:
101
+ raise
102
+ except Exception as exc:
103
+ raise RenderError(
104
+ "failed to generate preview frames",
105
+ code="preview_failed",
106
+ ) from exc
107
+ finally:
108
+ _remove_workspace(workspace)
109
+ if not items:
110
+ raise RenderError("preview produced no images", code="empty_preview")
111
+ return PreviewManifest(plan_hash=plan.plan_hash, items=tuple(items))
112
+
113
+
114
+ def _preview_timestamps(
115
+ plan: RenderPlan,
116
+ timestamps: Sequence[float] | None,
117
+ ) -> tuple[float, ...]:
118
+ if timestamps is None:
119
+ if isinstance(plan, (PureImageRenderPlan, GraphicTextRenderPlan)):
120
+ return (0.0,)
121
+ if isinstance(plan, ScrollRenderPlan):
122
+ return _scroll_timestamps(plan)
123
+ if isinstance(plan, TimelineRenderPlan):
124
+ return _timeline_timestamps(plan)
125
+ raise RenderError("unsupported preview plan", code="invalid_render_plan")
126
+ if not timestamps:
127
+ raise RenderError(
128
+ "explicit preview timestamps must not be empty",
129
+ code="invalid_preview_timestamps",
130
+ field_path="timestamps",
131
+ )
132
+ if isinstance(plan, (PureImageRenderPlan, GraphicTextRenderPlan)):
133
+ duration = 0.0
134
+ elif isinstance(plan, (ScrollRenderPlan, TimelineRenderPlan)):
135
+ duration = plan.body.duration_s
136
+ else:
137
+ raise RenderError("unsupported preview plan", code="invalid_render_plan")
138
+ values: list[float] = []
139
+ for index, raw_value in enumerate(timestamps):
140
+ try:
141
+ value = float(raw_value)
142
+ except (TypeError, ValueError) as exc:
143
+ raise RenderError(
144
+ "preview timestamp must be a finite number",
145
+ code="invalid_preview_timestamps",
146
+ field_path=f"timestamps.{index}",
147
+ ) from exc
148
+ if not 0 <= value <= duration:
149
+ raise RenderError(
150
+ f"preview timestamp must be within 0 and {duration}",
151
+ code="invalid_preview_timestamps",
152
+ field_path=f"timestamps.{index}",
153
+ )
154
+ values.append(round(value, 6))
155
+ return tuple(dict.fromkeys(values))
156
+
157
+
158
+ def _scroll_timestamps(plan: ScrollRenderPlan) -> tuple[float, ...]:
159
+ scroll_layer = next(
160
+ (layer for layer in plan.body.layers if isinstance(layer, ScrollLongTextPlan)),
161
+ None,
162
+ )
163
+ if scroll_layer is None:
164
+ return (0.0,)
165
+ movement_middle = scroll_layer.begin_hold_s + scroll_layer.scroll_duration_s / 2
166
+ end = max(0.0, plan.body.duration_s - min(0.001, plan.body.duration_s / 2))
167
+ return tuple(sorted({0.0, round(movement_middle, 6), round(end, 6)}))
168
+
169
+
170
+ def _timeline_timestamps(plan: TimelineRenderPlan) -> tuple[float, ...]:
171
+ time_points = sorted(
172
+ {
173
+ value
174
+ for frame in plan.body.frames
175
+ for timing in frame.timings
176
+ for value in (timing.start_time_s, timing.end_time_s)
177
+ }
178
+ )
179
+ values = [
180
+ (start + end) / 2
181
+ for start, end in pairwise(time_points)
182
+ if any(
183
+ timing.start_time_s <= start and timing.end_time_s >= end
184
+ for frame in plan.body.frames
185
+ for timing in frame.timings
186
+ )
187
+ ]
188
+ if len(values) <= MAX_TIMELINE_PREVIEW_FRAMES:
189
+ return tuple(values)
190
+ step = (len(values) - 1) / (MAX_TIMELINE_PREVIEW_FRAMES - 1)
191
+ return tuple(
192
+ values[round(index * step)] for index in range(MAX_TIMELINE_PREVIEW_FRAMES)
193
+ )
194
+
195
+
196
+ def _remove_workspace(path: Path) -> None:
197
+ if not path.exists():
198
+ return
199
+ for child in path.iterdir():
200
+ if child.is_file():
201
+ child.unlink()
202
+ with suppress(OSError):
203
+ path.rmdir()
File without changes