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,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
|