objectionengine2 1.0.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.
- objectionengine2/MovieKit.py +615 -0
- objectionengine2/__init__.py +3 -0
- objectionengine2/ace_attorney_scene.py +1718 -0
- objectionengine2/beans/comment.py +22 -0
- objectionengine2/beans/text.py +9 -0
- objectionengine2/cache.py +18 -0
- objectionengine2/composers/compose_gavel_slam.py +72 -0
- objectionengine2/composers/compose_verdict.py +56 -0
- objectionengine2/font_constants.py +37 -0
- objectionengine2/font_tools.py +98 -0
- objectionengine2/gavel_slam.py +80 -0
- objectionengine2/judge_verdict.py +179 -0
- objectionengine2/loading.py +253 -0
- objectionengine2/make_movie.py +133 -0
- objectionengine2/math_helpers.py +22 -0
- objectionengine2/parse_tags.py +213 -0
- objectionengine2/renderer.py +1 -0
- objectionengine2/tag_macros.py +25 -0
- objectionengine2/testimony_indicator.py +113 -0
- objectionengine2/utils.py +94 -0
- objectionengine2/xml_parser.py +279 -0
- objectionengine2-1.0.0.dist-info/METADATA +101 -0
- objectionengine2-1.0.0.dist-info/RECORD +25 -0
- objectionengine2-1.0.0.dist-info/WHEEL +4 -0
- objectionengine2-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
from PIL import Image, ImageDraw, ImageFont, ImageOps, ImageFile
|
|
2
|
+
import ffmpeg
|
|
3
|
+
from .math_helpers import lerp
|
|
4
|
+
from typing import Callable, Union
|
|
5
|
+
from os import getenv, replace
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from tempfile import TemporaryDirectory
|
|
8
|
+
from time import time_ns
|
|
9
|
+
from pydub import AudioSegment
|
|
10
|
+
import cv2
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
# NOTE: This fixes a weird issue
|
|
14
|
+
# (https://github.com/Meorge/objection_engine/issues/1)
|
|
15
|
+
# HOWEVER according to StackOverflow it might also cause some images to
|
|
16
|
+
# be black. So, if you're trying to load images and that happens, maybe that's
|
|
17
|
+
# why???
|
|
18
|
+
ImageFile.LOAD_TRUNCATED_IMAGES = True
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Scene:
|
|
22
|
+
width: int = 0
|
|
23
|
+
height: int = 0
|
|
24
|
+
__root: "SceneObject" = None
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
width: int = 0,
|
|
29
|
+
height: int = 0,
|
|
30
|
+
resolution_scale: float = 1.0,
|
|
31
|
+
root: "SceneObject" = None,
|
|
32
|
+
):
|
|
33
|
+
self.width = width
|
|
34
|
+
self.height = height
|
|
35
|
+
self.__done = False
|
|
36
|
+
self.resolution_scale = resolution_scale
|
|
37
|
+
self.set_root(root)
|
|
38
|
+
|
|
39
|
+
def set_root(self, root: "SceneObject"):
|
|
40
|
+
if self.__root is not None:
|
|
41
|
+
self.__root.__parent = None
|
|
42
|
+
self.__root = root
|
|
43
|
+
if self.__root is not None:
|
|
44
|
+
self.__root.make_root(self)
|
|
45
|
+
|
|
46
|
+
def render(self, video_writer: cv2.VideoWriter):
|
|
47
|
+
img = Image.new("RGBA", (self.width, self.height))
|
|
48
|
+
ctx = ImageDraw.ImageDraw(img)
|
|
49
|
+
|
|
50
|
+
all_objects: list[SceneObject] = sorted(
|
|
51
|
+
self.__root.get_self_and_children_as_flat_list(), key=lambda obj: obj.z
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
for object in all_objects:
|
|
55
|
+
if object.get_absolute_visibility():
|
|
56
|
+
object.render(img, ctx)
|
|
57
|
+
|
|
58
|
+
cv2_frame = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)
|
|
59
|
+
|
|
60
|
+
if self.resolution_scale != 1.0:
|
|
61
|
+
cv2_frame = cv2.resize(
|
|
62
|
+
cv2_frame,
|
|
63
|
+
(0, 0),
|
|
64
|
+
fx=self.resolution_scale,
|
|
65
|
+
fy=self.resolution_scale,
|
|
66
|
+
interpolation=cv2.INTER_NEAREST,
|
|
67
|
+
)
|
|
68
|
+
video_writer.write(cv2_frame)
|
|
69
|
+
|
|
70
|
+
def update(self, delta: float):
|
|
71
|
+
for object in self.__root.get_self_and_children_as_flat_list():
|
|
72
|
+
object.update(delta)
|
|
73
|
+
|
|
74
|
+
def set_animation_done(self):
|
|
75
|
+
self.__done = True
|
|
76
|
+
|
|
77
|
+
def is_animation_done(self):
|
|
78
|
+
return self.__done
|
|
79
|
+
|
|
80
|
+
def receive_message(self, data):
|
|
81
|
+
...
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class SceneObject:
|
|
85
|
+
x: int = 0
|
|
86
|
+
y: int = 0
|
|
87
|
+
z: int = 0
|
|
88
|
+
name: str = ""
|
|
89
|
+
visible: bool = True
|
|
90
|
+
__children: list["SceneObject"] = []
|
|
91
|
+
__parent: Union["SceneObject", Scene] = None
|
|
92
|
+
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
parent: "SceneObject" = None,
|
|
96
|
+
name: str = "",
|
|
97
|
+
pos: tuple[int, int, int] = (0, 0, 0),
|
|
98
|
+
):
|
|
99
|
+
self.x, self.y, self.z = pos
|
|
100
|
+
self.name = name
|
|
101
|
+
self.__children = []
|
|
102
|
+
|
|
103
|
+
if isinstance(parent, Scene):
|
|
104
|
+
parent.set_root(self)
|
|
105
|
+
|
|
106
|
+
elif isinstance(parent, SceneObject) and parent is not None:
|
|
107
|
+
parent.add_child(self)
|
|
108
|
+
|
|
109
|
+
def __repr__(self) -> str:
|
|
110
|
+
return type(self).__name__ + f' "{self.name}" ({self.x}, {self.y}, {self.z})'
|
|
111
|
+
|
|
112
|
+
def render(self, img: Image.Image, ctx: ImageDraw.ImageDraw):
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
def update(self, delta):
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
def get_x(self) -> int:
|
|
119
|
+
return self.x
|
|
120
|
+
|
|
121
|
+
def get_y(self) -> int:
|
|
122
|
+
return self.y
|
|
123
|
+
|
|
124
|
+
def set_x(self, x: int):
|
|
125
|
+
self.x = int(x)
|
|
126
|
+
|
|
127
|
+
def set_y(self, y: int):
|
|
128
|
+
self.y = int(y)
|
|
129
|
+
|
|
130
|
+
def show(self):
|
|
131
|
+
self.visible = True
|
|
132
|
+
|
|
133
|
+
def hide(self):
|
|
134
|
+
self.visible = False
|
|
135
|
+
|
|
136
|
+
def make_root(self, scene: Scene):
|
|
137
|
+
self.__parent = scene
|
|
138
|
+
|
|
139
|
+
def add_child(self, new_child: "SceneObject"):
|
|
140
|
+
self.__children.append(new_child)
|
|
141
|
+
if new_child.__parent is not None:
|
|
142
|
+
new_child.__parent.__children.remove(new_child)
|
|
143
|
+
new_child.__parent = self
|
|
144
|
+
|
|
145
|
+
def get_scene(self) -> Scene:
|
|
146
|
+
p = self
|
|
147
|
+
while True:
|
|
148
|
+
p = p.__parent
|
|
149
|
+
if isinstance(p, Scene):
|
|
150
|
+
return p
|
|
151
|
+
elif p is None:
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
def get_absolute_position(self) -> tuple[int, int, int]:
|
|
155
|
+
p = self
|
|
156
|
+
x_out = 0
|
|
157
|
+
y_out = 0
|
|
158
|
+
z_out = 0
|
|
159
|
+
while p is not None and not isinstance(p, Scene):
|
|
160
|
+
x_out += p.x
|
|
161
|
+
y_out += p.y
|
|
162
|
+
z_out += p.z
|
|
163
|
+
p = p.__parent
|
|
164
|
+
return (x_out, y_out, z_out)
|
|
165
|
+
|
|
166
|
+
def get_absolute_visibility(self) -> bool:
|
|
167
|
+
p = self
|
|
168
|
+
while p is not None and not isinstance(p, Scene):
|
|
169
|
+
if not p.visible:
|
|
170
|
+
return False
|
|
171
|
+
p = p.__parent
|
|
172
|
+
return True
|
|
173
|
+
|
|
174
|
+
def print_hierarchy(self):
|
|
175
|
+
self.__internal_print_hierarchy(0)
|
|
176
|
+
|
|
177
|
+
def __internal_print_hierarchy(self, i: int = 0):
|
|
178
|
+
print(("\t" * i) + str(self))
|
|
179
|
+
for child in self.__children:
|
|
180
|
+
child.__internal_print_hierarchy(i + 1)
|
|
181
|
+
|
|
182
|
+
def get_self_and_children_as_flat_list(self) -> list["SceneObject"]:
|
|
183
|
+
nodes: list["SceneObject"] = []
|
|
184
|
+
|
|
185
|
+
def f(c):
|
|
186
|
+
for ch in c.__children:
|
|
187
|
+
nodes.append(ch)
|
|
188
|
+
f(ch)
|
|
189
|
+
|
|
190
|
+
f(self)
|
|
191
|
+
return nodes
|
|
192
|
+
|
|
193
|
+
def emit_message(self, data):
|
|
194
|
+
if isinstance(self.__parent, SceneObject):
|
|
195
|
+
self.__parent.emit_message(data)
|
|
196
|
+
elif isinstance(self.__parent, Scene):
|
|
197
|
+
self.__parent.receive_message(data)
|
|
198
|
+
else:
|
|
199
|
+
print(
|
|
200
|
+
f"Error in emit_audio - parent of {self} is type {type(self.__parent)}"
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class ImageObject(SceneObject):
|
|
205
|
+
filepath: str = ""
|
|
206
|
+
t: float = 0.0
|
|
207
|
+
|
|
208
|
+
_width: int = None
|
|
209
|
+
_height: int = None
|
|
210
|
+
|
|
211
|
+
image_data: Union[Image.Image, list[tuple[Image.Image, float]]] = []
|
|
212
|
+
|
|
213
|
+
current_frame: Image = None
|
|
214
|
+
callbacks: dict = {}
|
|
215
|
+
|
|
216
|
+
def __init__(
|
|
217
|
+
self,
|
|
218
|
+
parent: "SceneObject" = None,
|
|
219
|
+
name: str = "",
|
|
220
|
+
pos: tuple[int, int, int] = (0, 0, 0),
|
|
221
|
+
width: int = None,
|
|
222
|
+
height: int = None,
|
|
223
|
+
flip_x: bool = False,
|
|
224
|
+
flip_y: bool = False,
|
|
225
|
+
filepath: str = None,
|
|
226
|
+
):
|
|
227
|
+
super().__init__(parent, name, pos)
|
|
228
|
+
self._width = width
|
|
229
|
+
self._height = height
|
|
230
|
+
self.flip_x = flip_x
|
|
231
|
+
self.flip_y = flip_y
|
|
232
|
+
self.current_frame = None
|
|
233
|
+
self.resized_image_data = None
|
|
234
|
+
self.set_filepath(filepath)
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def width(self) -> int:
|
|
238
|
+
return self._width
|
|
239
|
+
|
|
240
|
+
@width.setter
|
|
241
|
+
def width(self, value: int):
|
|
242
|
+
self._width = value
|
|
243
|
+
self.update_image()
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def height(self) -> int:
|
|
247
|
+
return self._height
|
|
248
|
+
|
|
249
|
+
@height.setter
|
|
250
|
+
def height(self, value: int):
|
|
251
|
+
self._height = value
|
|
252
|
+
self.update_image()
|
|
253
|
+
|
|
254
|
+
def update_image(self):
|
|
255
|
+
"""
|
|
256
|
+
Create the newly scaled version of the currently loaded image
|
|
257
|
+
or image sequence.
|
|
258
|
+
|
|
259
|
+
Note: Currently this is called whenever either the height or width
|
|
260
|
+
is changed. As a result, it might become inefficient if for whatever
|
|
261
|
+
reason, the user is doing a bunch of height/width changes within a
|
|
262
|
+
single update call.
|
|
263
|
+
"""
|
|
264
|
+
self.resized_image_data = None
|
|
265
|
+
if self.image_data is None:
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
if isinstance(self.image_data, Image.Image):
|
|
269
|
+
self.resized_image_data: Image.Image
|
|
270
|
+
w = self.image_data.width if self._width is None else self._width
|
|
271
|
+
h = self.image_data.height if self._height is None else self._height
|
|
272
|
+
self.resized_image_data = (
|
|
273
|
+
self.image_data
|
|
274
|
+
if (self._width is None and self._height is None)
|
|
275
|
+
or (
|
|
276
|
+
self._width == self.image_data.width
|
|
277
|
+
and self._height == self.image_data.height
|
|
278
|
+
)
|
|
279
|
+
else self.image_data.resize((w, h))
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
elif isinstance(self.image_data, list):
|
|
283
|
+
self.resized_image_data = []
|
|
284
|
+
for img, max_time in self.image_data:
|
|
285
|
+
w = img.width if self._width is None else self._width
|
|
286
|
+
h = img.height if self._height is None else self._height
|
|
287
|
+
|
|
288
|
+
self.resized_image_data.append(
|
|
289
|
+
(
|
|
290
|
+
(
|
|
291
|
+
img
|
|
292
|
+
if (self._width is None and self._height is None)
|
|
293
|
+
or (self._width == img.width and self._height == img.height)
|
|
294
|
+
else img.resize((w, h))
|
|
295
|
+
),
|
|
296
|
+
max_time,
|
|
297
|
+
)
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
else:
|
|
301
|
+
print(
|
|
302
|
+
f"self.image_data is of type {type(self.image_data)}, which isn't expected"
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
def update(self, delta):
|
|
306
|
+
t_before = self.t
|
|
307
|
+
self.t += delta
|
|
308
|
+
for time, callback in self.callbacks.items():
|
|
309
|
+
if t_before < time and self.t >= time and callback is not None:
|
|
310
|
+
callback()
|
|
311
|
+
|
|
312
|
+
def set_filepath(self, filepath: str, callbacks: dict = None):
|
|
313
|
+
self.callbacks = callbacks if callbacks is not None else {}
|
|
314
|
+
self.t = 0.0
|
|
315
|
+
self.filepath = filepath
|
|
316
|
+
if self.filepath is None:
|
|
317
|
+
self.image_data = None
|
|
318
|
+
self.resized_image_data = None
|
|
319
|
+
return
|
|
320
|
+
with Image.open(self.filepath) as my_img:
|
|
321
|
+
try:
|
|
322
|
+
is_animated = my_img.is_animated
|
|
323
|
+
except AttributeError:
|
|
324
|
+
is_animated = False
|
|
325
|
+
|
|
326
|
+
if is_animated:
|
|
327
|
+
self.image_data = []
|
|
328
|
+
time_so_far = 0.0
|
|
329
|
+
for frame_no in range(my_img.n_frames):
|
|
330
|
+
my_img.seek(frame_no)
|
|
331
|
+
time_so_far += my_img.info["duration"] / 1000
|
|
332
|
+
self.image_data.append((my_img.convert("RGBA"), time_so_far))
|
|
333
|
+
self.image_duration = time_so_far
|
|
334
|
+
else:
|
|
335
|
+
self.image_data = my_img.convert("RGBA")
|
|
336
|
+
self.image_duration = None
|
|
337
|
+
|
|
338
|
+
self.update_image()
|
|
339
|
+
|
|
340
|
+
def get_current_frame(self) -> Image.Image:
|
|
341
|
+
t = self.t % self.image_duration
|
|
342
|
+
for image, max_time in self.resized_image_data:
|
|
343
|
+
if max_time > t:
|
|
344
|
+
return image
|
|
345
|
+
return None
|
|
346
|
+
|
|
347
|
+
def render(self, img: Image.Image, ctx: ImageDraw.ImageDraw):
|
|
348
|
+
if self.resized_image_data is None:
|
|
349
|
+
return
|
|
350
|
+
x, y, _ = self.get_absolute_position()
|
|
351
|
+
box = (x, y)
|
|
352
|
+
|
|
353
|
+
# Resize image to expected bounds
|
|
354
|
+
if isinstance(self.resized_image_data, Image.Image):
|
|
355
|
+
w = self.resized_image_data.width if self._width is None else self._width
|
|
356
|
+
h = self.resized_image_data.height if self._height is None else self._height
|
|
357
|
+
to_render = self.resized_image_data
|
|
358
|
+
elif isinstance(self.resized_image_data, list):
|
|
359
|
+
current_frame = self.get_current_frame()
|
|
360
|
+
w = current_frame.width if self._width is None else self._width
|
|
361
|
+
h = current_frame.height if self._height is None else self._height
|
|
362
|
+
to_render = current_frame
|
|
363
|
+
|
|
364
|
+
# If this image is entirely off-screen we don't need to render it!
|
|
365
|
+
left = x
|
|
366
|
+
right = x + w
|
|
367
|
+
top = y
|
|
368
|
+
bottom = y + h
|
|
369
|
+
|
|
370
|
+
scene_left = 0
|
|
371
|
+
scene_right = self.get_scene().width
|
|
372
|
+
scene_top = 0
|
|
373
|
+
scene_bottom = self.get_scene().height
|
|
374
|
+
|
|
375
|
+
if (
|
|
376
|
+
(right < scene_left)
|
|
377
|
+
or (left > scene_right)
|
|
378
|
+
or (bottom < scene_top)
|
|
379
|
+
or (top > scene_bottom)
|
|
380
|
+
):
|
|
381
|
+
return
|
|
382
|
+
|
|
383
|
+
# Flip images if necessary
|
|
384
|
+
if self.flip_x:
|
|
385
|
+
to_render = ImageOps.mirror(to_render)
|
|
386
|
+
if self.flip_y:
|
|
387
|
+
to_render = ImageOps.flip(to_render)
|
|
388
|
+
|
|
389
|
+
img.paste(to_render, box, mask=to_render)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
class SimpleTextObject(SceneObject):
|
|
393
|
+
def __init__(
|
|
394
|
+
self,
|
|
395
|
+
parent: "SceneObject" = None,
|
|
396
|
+
name: str = "",
|
|
397
|
+
pos: tuple[int, int, int] = (0, 0, 0),
|
|
398
|
+
text: str = "",
|
|
399
|
+
font: ImageFont.FreeTypeFont = None,
|
|
400
|
+
):
|
|
401
|
+
super().__init__(parent, name, pos)
|
|
402
|
+
self.text = text
|
|
403
|
+
self.font = font
|
|
404
|
+
|
|
405
|
+
def get_width(self):
|
|
406
|
+
if self.font is not None:
|
|
407
|
+
return self.font.getlength(self.text)
|
|
408
|
+
|
|
409
|
+
def render(self, img: Image.Image, ctx: ImageDraw.ImageDraw):
|
|
410
|
+
x, y, _ = self.get_absolute_position()
|
|
411
|
+
|
|
412
|
+
args = {"xy": (x, y), "text": self.text, "fill": (255, 255, 255)}
|
|
413
|
+
|
|
414
|
+
if self.font is not None:
|
|
415
|
+
args["font"] = self.font
|
|
416
|
+
ctx.text(**args)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
class Sequencer:
|
|
420
|
+
actions: list["SequenceAction"] = []
|
|
421
|
+
|
|
422
|
+
def run_action(self, action: "SequenceAction"):
|
|
423
|
+
self.actions.append(action)
|
|
424
|
+
action.sequencer = self
|
|
425
|
+
|
|
426
|
+
def update(self, delta):
|
|
427
|
+
self.actions = [action for action in self.actions if not action.completed]
|
|
428
|
+
for action in self.actions:
|
|
429
|
+
action.update(delta)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
class SequenceAction:
|
|
433
|
+
sequencer: Sequencer
|
|
434
|
+
completed: bool = False
|
|
435
|
+
|
|
436
|
+
def start(self):
|
|
437
|
+
...
|
|
438
|
+
|
|
439
|
+
def update(self, delta):
|
|
440
|
+
...
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
class MoveSceneObjectAction(SequenceAction):
|
|
444
|
+
target_value: tuple[int, int] = (0, 0)
|
|
445
|
+
duration: float = 0.0
|
|
446
|
+
|
|
447
|
+
scene_object: SceneObject = None
|
|
448
|
+
ease_function = lambda _, x: x
|
|
449
|
+
|
|
450
|
+
on_complete = None
|
|
451
|
+
|
|
452
|
+
time_passed: float = 0.0
|
|
453
|
+
current_value: float = 0
|
|
454
|
+
|
|
455
|
+
def __init__(
|
|
456
|
+
self,
|
|
457
|
+
target_value: tuple[int, int],
|
|
458
|
+
duration: float,
|
|
459
|
+
scene_object: SceneObject = None,
|
|
460
|
+
ease_function: Callable[[float], float] = None,
|
|
461
|
+
on_complete_function: Callable[[], None] = None,
|
|
462
|
+
):
|
|
463
|
+
self.target_value = target_value
|
|
464
|
+
self.duration = duration
|
|
465
|
+
self.scene_object = scene_object
|
|
466
|
+
if ease_function is not None:
|
|
467
|
+
self.ease_function = ease_function
|
|
468
|
+
self.on_complete = on_complete_function
|
|
469
|
+
|
|
470
|
+
def update(self, delta):
|
|
471
|
+
if self.time_passed == 0:
|
|
472
|
+
self.initial_value = (self.scene_object.x, self.scene_object.y)
|
|
473
|
+
|
|
474
|
+
self.time_passed += delta
|
|
475
|
+
percent_complete = self.time_passed / self.duration
|
|
476
|
+
|
|
477
|
+
percent_complete_eased = self.ease_function(percent_complete)
|
|
478
|
+
new_x = lerp(
|
|
479
|
+
self.initial_value[0], self.target_value[0], percent_complete_eased
|
|
480
|
+
)
|
|
481
|
+
new_y = lerp(
|
|
482
|
+
self.initial_value[1], self.target_value[1], percent_complete_eased
|
|
483
|
+
)
|
|
484
|
+
self.scene_object.set_x(new_x)
|
|
485
|
+
self.scene_object.set_y(new_y)
|
|
486
|
+
|
|
487
|
+
if percent_complete_eased >= 1.0 and not self.completed:
|
|
488
|
+
self.completed = True
|
|
489
|
+
if self.on_complete is not None:
|
|
490
|
+
self.on_complete()
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
class Director:
|
|
494
|
+
def __init__(self, scene: Scene = None, fps: float = 30, callbacks: dict = None):
|
|
495
|
+
self.sequencer = Sequencer()
|
|
496
|
+
self.scene = scene
|
|
497
|
+
self.fps = fps
|
|
498
|
+
self.audio_commands: list[dict] = []
|
|
499
|
+
self.time = 0.0
|
|
500
|
+
self.callbacks = {} if callbacks is None else callbacks
|
|
501
|
+
|
|
502
|
+
def update(self, delta: float):
|
|
503
|
+
...
|
|
504
|
+
|
|
505
|
+
def render_audio(
|
|
506
|
+
self, overall_duration, output_location, volume_adjustment: float = 0.0
|
|
507
|
+
):
|
|
508
|
+
duration_ms = int(overall_duration * 1000)
|
|
509
|
+
base_track = AudioSegment.silent(duration=duration_ms)
|
|
510
|
+
|
|
511
|
+
for i, audio in enumerate(self.audio_commands):
|
|
512
|
+
path = audio["path"]
|
|
513
|
+
offset = int(audio.get("offset", 0.0) * 1000)
|
|
514
|
+
loop_type = audio.get("loop_type", "no_loop")
|
|
515
|
+
loop_delay = int(audio.get("loop_delay", 0) * 1000)
|
|
516
|
+
end_time = audio.get("end", overall_duration)
|
|
517
|
+
|
|
518
|
+
# Segment with silence afterwards
|
|
519
|
+
new_segment = AudioSegment.from_file(path)
|
|
520
|
+
silence = AudioSegment.silent(duration=loop_delay)
|
|
521
|
+
new_segment = new_segment + silence
|
|
522
|
+
|
|
523
|
+
# The segment should be this long
|
|
524
|
+
duration_of_total_segment = max(0, min(int(end_time * 1000), duration_ms) - offset)
|
|
525
|
+
|
|
526
|
+
if loop_type != "no_loop" and len(new_segment) == 0:
|
|
527
|
+
raise ValueError(f"Cannot loop an empty audio clip: {path}")
|
|
528
|
+
|
|
529
|
+
if loop_type == "no_loop":
|
|
530
|
+
base_track = base_track.overlay(new_segment, offset)
|
|
531
|
+
elif loop_type == "loop_complete_only":
|
|
532
|
+
# Keep adding instances of this sound until we can't anymore
|
|
533
|
+
looped_segment = AudioSegment.empty()
|
|
534
|
+
while len(looped_segment) <= duration_of_total_segment:
|
|
535
|
+
looped_segment += new_segment
|
|
536
|
+
|
|
537
|
+
base_track = base_track.overlay(looped_segment, offset)
|
|
538
|
+
|
|
539
|
+
elif loop_type == "loop_until_truncated":
|
|
540
|
+
looped_segment = AudioSegment.silent(duration=duration_of_total_segment)
|
|
541
|
+
looped_segment = looped_segment.overlay(new_segment, loop=True)
|
|
542
|
+
base_track = base_track.overlay(looped_segment, offset)
|
|
543
|
+
|
|
544
|
+
if "on_audio_composite_progress" in self.callbacks:
|
|
545
|
+
self.callbacks["on_audio_composite_progress"](
|
|
546
|
+
i, len(self.audio_commands), audio
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
base_track += volume_adjustment
|
|
550
|
+
base_track.export(output_location, bitrate="312k").close()
|
|
551
|
+
|
|
552
|
+
def render_movie(
|
|
553
|
+
self,
|
|
554
|
+
output_filename: str = None,
|
|
555
|
+
volume_adjustment: float = 0.0,
|
|
556
|
+
resolution_scale: float = 1.0,
|
|
557
|
+
):
|
|
558
|
+
self.time = 0.0
|
|
559
|
+
self.is_done = False
|
|
560
|
+
self.scene.resolution_scale = resolution_scale
|
|
561
|
+
|
|
562
|
+
frame = 0
|
|
563
|
+
output_path = Path(output_filename or f"output-{time_ns()}.mp4")
|
|
564
|
+
if not output_path.parent.is_dir():
|
|
565
|
+
raise FileNotFoundError(f"Output directory does not exist: {output_path.parent}")
|
|
566
|
+
|
|
567
|
+
direct_h264 = getenv("OE_DIRECT_H264_ENCODING", "false").lower() == "true"
|
|
568
|
+
fourcc = cv2.VideoWriter_fourcc(*("h264" if direct_h264 else "mp4v"))
|
|
569
|
+
frame_size = (
|
|
570
|
+
int(self.scene.width * resolution_scale),
|
|
571
|
+
int(self.scene.height * resolution_scale),
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
with TemporaryDirectory(prefix=".objection-", dir=output_path.parent) as temp_dir:
|
|
575
|
+
temp_path = Path(temp_dir)
|
|
576
|
+
temp_video_name = str(temp_path / "video.mp4")
|
|
577
|
+
temp_audio_name = str(temp_path / "audio.mp3")
|
|
578
|
+
staged_output_name = str(temp_path / "final.mp4")
|
|
579
|
+
|
|
580
|
+
video_writer = cv2.VideoWriter(temp_video_name, fourcc, self.fps, frame_size)
|
|
581
|
+
if not video_writer.isOpened():
|
|
582
|
+
video_writer.release()
|
|
583
|
+
raise RuntimeError(f"OpenCV could not open a video writer for {frame_size} at {self.fps} FPS")
|
|
584
|
+
try:
|
|
585
|
+
while not self.is_done:
|
|
586
|
+
self.update(1 / self.fps)
|
|
587
|
+
self.sequencer.update(1 / self.fps)
|
|
588
|
+
self.scene.update(1 / self.fps)
|
|
589
|
+
self.scene.render(video_writer)
|
|
590
|
+
self.time += 1 / self.fps
|
|
591
|
+
frame += 1
|
|
592
|
+
finally:
|
|
593
|
+
video_writer.release()
|
|
594
|
+
|
|
595
|
+
self.render_audio(frame / self.fps, temp_audio_name, volume_adjustment)
|
|
596
|
+
|
|
597
|
+
video_stream = ffmpeg.input(temp_video_name)
|
|
598
|
+
audio_stream = ffmpeg.input(temp_audio_name)
|
|
599
|
+
stream = ffmpeg.output(
|
|
600
|
+
video_stream.video,
|
|
601
|
+
audio_stream.audio,
|
|
602
|
+
staged_output_name,
|
|
603
|
+
vcodec="copy" if direct_h264 else "libx264",
|
|
604
|
+
acodec="aac",
|
|
605
|
+
pix_fmt="yuv420p",
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
if "on_ffmpeg_started" in self.callbacks:
|
|
609
|
+
self.callbacks["on_ffmpeg_started"]()
|
|
610
|
+
|
|
611
|
+
ffmpeg.run(stream, capture_stderr=True, quiet=True, overwrite_output=True)
|
|
612
|
+
replace(staged_output_name, output_path)
|
|
613
|
+
|
|
614
|
+
if "on_ffmpeg_finished" in self.callbacks:
|
|
615
|
+
self.callbacks["on_ffmpeg_finished"]()
|