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.
@@ -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"]()
@@ -0,0 +1,3 @@
1
+ from .beans import comment
2
+ from .renderer import render_comment_list
3
+ from .utils import is_music_available, get_all_music_available