miwaniza commited on
Commit
165c203
·
1 Parent(s): 7efe5ef

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +441 -0
app.py ADDED
@@ -0,0 +1,441 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+
3
+ # zoom_video_composer.py v0.2.1
4
+ # https://github.com/mwydmuch/ZoomVideoComposer
5
+
6
+ # Copyright (c) 2023 Marek Wydmuch
7
+
8
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
9
+ # of this software and associated documentation files (the "Software"), to deal
10
+ # in the Software without restriction, including without limitation the rights
11
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12
+ # copies of the Software, and to permit persons to whom the Software is
13
+ # furnished to do so, subject to the following conditions:
14
+
15
+ # The above copyright notice and this permission notice shall be included in all
16
+ # copies or substantial portions of the Software.
17
+
18
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24
+ # SOFTWARE.
25
+
26
+
27
+ import click
28
+ from PIL import Image
29
+ import os
30
+ import shutil
31
+ from hashlib import md5
32
+ from multiprocessing import cpu_count
33
+ from joblib import Parallel, delayed
34
+ from tqdm import trange
35
+ from math import log, ceil, pow, sin, cos, pi
36
+ from moviepy.video.io.ImageSequenceClip import ImageSequenceClip
37
+ from moviepy.editor import VideoFileClip, AudioFileClip
38
+ from concurrent.futures import ThreadPoolExecutor
39
+ import concurrent
40
+ from tqdm import tqdm
41
+
42
+
43
+ EASING_FUNCTIONS = {
44
+ "linear": lambda x: x,
45
+ "easeInSine": lambda x: 1 - cos((x * pi) / 2),
46
+ "easeOutSine": lambda x: sin((x * pi) / 2),
47
+ "easeInOutSine": lambda x: -(cos(pi * x) - 1) / 2,
48
+ "easeInQuad": lambda x: x * x,
49
+ "easeOutQuad": lambda x: 1 - (1 - x) * (1 - x),
50
+ "easeInOutQuad": lambda x: 2 * x * x if x < 0.5 else 1 - pow(-2 * x + 2, 2) / 2,
51
+ "easeInCubic": lambda x: x * x * x,
52
+ "easeOutCubic": lambda x: 1 - pow(1 - x, 3),
53
+ "easeInOutCubic": lambda x: 4 * x * x * x
54
+ if x < 0.5
55
+ else 1 - pow(-2 * x + 2, 3) / 2,
56
+ }
57
+ DEFAULT_EASING_KEY = "easeInOutSine"
58
+ DEFAULT_EASING_FUNCTION = EASING_FUNCTIONS[DEFAULT_EASING_KEY]
59
+
60
+ RESAMPLING_FUNCTIONS = {
61
+ "nearest": Image.Resampling.NEAREST,
62
+ "box": Image.Resampling.BOX,
63
+ "bilinear": Image.Resampling.BILINEAR,
64
+ "hamming": Image.Resampling.HAMMING,
65
+ "bicubic": Image.Resampling.BICUBIC,
66
+ "lanczos": Image.Resampling.LANCZOS,
67
+ }
68
+ DEFAULT_RESAMPLING_KEY = "lanczos"
69
+ DEFAULT_RESAMPLING_FUNCTION = RESAMPLING_FUNCTIONS[DEFAULT_RESAMPLING_KEY]
70
+
71
+
72
+ def zoom_crop(image, zoom, resampling_func=Image.Resampling.LANCZOS):
73
+ width, height = image.size
74
+ zoom_size = (int(width * zoom), int(height * zoom))
75
+ crop_box = (
76
+ (zoom_size[0] - width) / 2,
77
+ (zoom_size[1] - height) / 2,
78
+ (zoom_size[0] + width) / 2,
79
+ (zoom_size[1] + height) / 2,
80
+ )
81
+ return image.resize(zoom_size, resampling_func).crop(crop_box)
82
+
83
+
84
+ def resize_scale(image, scale, resampling_func=Image.Resampling.LANCZOS):
85
+ width, height = image.size
86
+ return image.resize((int(width * scale), int(height * scale)), resampling_func)
87
+
88
+
89
+ def zoom_in_log(easing_func, i, num_frames, num_images):
90
+ return (easing_func(i / (num_frames - 1))) * num_images
91
+
92
+
93
+ def zoom_out_log(easing_func, i, num_frames, num_images):
94
+ return (1 - easing_func(i / (num_frames - 1))) * num_images
95
+
96
+
97
+ def zoom_in(zoom, easing_func, i, num_frames, num_images):
98
+ return zoom ** zoom_in_log(easing_func, i, num_frames, num_images)
99
+
100
+
101
+ def zoom_out(zoom, easing_func, i, num_frames, num_images):
102
+ return zoom ** zoom_out_log(easing_func, i, num_frames, num_images)
103
+
104
+
105
+ def get_px_or_fraction(value, reference_value):
106
+ if value <= 1:
107
+ value = reference_value * value
108
+ return int(value)
109
+
110
+
111
+ @click.command()
112
+ @click.argument(
113
+ "image_paths",
114
+ nargs=-1,
115
+ type=click.Path(exists=True),
116
+ required=True,
117
+ )
118
+ @click.option(
119
+ "-a",
120
+ "--audio_path",
121
+ type=click.Path(exists=True, dir_okay=False),
122
+ default=None,
123
+ help="Audio file path that will be added to the video.",
124
+ )
125
+ @click.option(
126
+ "-z",
127
+ "--zoom",
128
+ type=float,
129
+ default=2.0,
130
+ help="Zoom factor/ratio between images.",
131
+ show_default=True,
132
+ )
133
+ @click.option(
134
+ "-d",
135
+ "--duration",
136
+ type=float,
137
+ default=10.0,
138
+ help="Duration of the video in seconds.",
139
+ show_default=True,
140
+ )
141
+ @click.option(
142
+ "-e",
143
+ "--easing",
144
+ type=click.Choice(list(EASING_FUNCTIONS.keys())),
145
+ default=DEFAULT_EASING_KEY,
146
+ help="Easing function.",
147
+ show_default=True,
148
+ )
149
+ @click.option(
150
+ "-r",
151
+ "--direction",
152
+ type=click.Choice(["in", "out", "inout", "outin"]),
153
+ default="out",
154
+ help="Zoom direction. Inout and outin combine both directions.",
155
+ show_default=True,
156
+ )
157
+ @click.option(
158
+ "-o",
159
+ "--output",
160
+ type=click.Path(),
161
+ default="output.mp4",
162
+ help="Output video file.",
163
+ show_default=True,
164
+ )
165
+ @click.option(
166
+ "-t",
167
+ "--threads",
168
+ type=int,
169
+ default=-1,
170
+ help="Number of threads to use to generate frames. Use values <= 0 for number of available threads on your machine minus the provided absolute value.",
171
+ show_default=True,
172
+ )
173
+ @click.option(
174
+ "--tmp-dir",
175
+ type=click.Path(),
176
+ default="tmp",
177
+ help="Temporary directory to store frames.",
178
+ show_default=True,
179
+ )
180
+ @click.option(
181
+ "-f",
182
+ "--fps",
183
+ type=int,
184
+ default=30,
185
+ help="Frames per second of the output video.",
186
+ show_default=True,
187
+ )
188
+ @click.option(
189
+ "-w",
190
+ "--width",
191
+ type=float,
192
+ default=1,
193
+ help="Width of the output video. Values > 1 are interpreted as specific sizes in pixels. Values <= 1 are interpreted as a fraction of the width of the first image.",
194
+ show_default=True,
195
+ )
196
+ @click.option(
197
+ "-h",
198
+ "--height",
199
+ type=float,
200
+ default=1,
201
+ help="Height of the output video. Values > 1 are interpreted as specific sizes in pixels. Values <= 1 are interpreted as a fraction of the height of the first image.",
202
+ show_default=True,
203
+ )
204
+ @click.option(
205
+ "-s",
206
+ "--resampling",
207
+ type=click.Choice(list(RESAMPLING_FUNCTIONS.keys())),
208
+ default=DEFAULT_RESAMPLING_KEY,
209
+ help="Resampling techique to use when resizing images.",
210
+ show_default=True,
211
+ )
212
+ @click.option(
213
+ "-m",
214
+ "--margin",
215
+ type=float,
216
+ default=0.05,
217
+ help="Size of the margin to cut from the edges of each image for better blending with the next/previous image. Values > 1 are interpreted as specific sizes in pixels. Values <= 1 are interpreted as a fraction of the smaller size of the first image.",
218
+ show_default=True,
219
+ )
220
+ @click.option(
221
+ "--keep-frames",
222
+ is_flag=True,
223
+ default=False,
224
+ help="Keep frames in the temporary directory. Otherwise, it will be deleted after the video is generated.",
225
+ show_default=True,
226
+ )
227
+ @click.option(
228
+ "--skip-video-generation",
229
+ is_flag=True,
230
+ default=False,
231
+ help="Skip video generation. Useful if you only want to generate the frames. This option will keep the temporary directory similar to --keep-frames flag.",
232
+ show_default=True,
233
+ )
234
+ @click.option(
235
+ "--reverse-images",
236
+ is_flag=True,
237
+ default=False,
238
+ help="Reverse the order of the images.",
239
+ show_default=True,
240
+ )
241
+ def zoom_video_composer(
242
+ image_paths,
243
+ audio_path=None,
244
+ zoom=2.0,
245
+ duration=10.0,
246
+ easing=DEFAULT_EASING_KEY,
247
+ direction="out",
248
+ output="output.mp4",
249
+ threads=-1,
250
+ tmp_dir="tmp",
251
+ fps=30,
252
+ width=1,
253
+ height=1,
254
+ resampling=DEFAULT_RESAMPLING_KEY,
255
+ margin=0.05,
256
+ keep_frames=False,
257
+ skip_video_generation=False,
258
+ reverse_images=False,
259
+ ):
260
+ """Compose a zoom video from multiple provided images."""
261
+
262
+ # Read images
263
+ _image_paths = []
264
+ for image_path in image_paths:
265
+ if os.path.isfile(image_path):
266
+ _image_paths.append(image_path)
267
+ elif os.path.isdir(image_path):
268
+ for subimage_path in sorted(os.listdir(image_path)):
269
+ _image_paths.append(os.path.join(image_path, subimage_path))
270
+ image_paths = _image_paths
271
+
272
+ images = []
273
+ click.echo(f"Reading {len(image_paths)} image files ...")
274
+ for image_path in image_paths:
275
+ if not image_path.lower().endswith((".png", ".jpg", ".jpeg", ".webp")):
276
+ click.echo(f"Unsupported file type: {image_path}, skipping")
277
+ continue
278
+
279
+ image = Image.open(image_path)
280
+ images.append(image)
281
+
282
+ if len(images) < 2:
283
+ raise ValueError("At least two images are required to create a zoom video")
284
+
285
+ # Setup some additional variables
286
+ easing_func = EASING_FUNCTIONS.get(easing, None)
287
+ if easing_func is None:
288
+ raise ValueError(f"Unsupported easing function: {easing}")
289
+
290
+ resampling_func = RESAMPLING_FUNCTIONS.get(resampling, None)
291
+ if resampling_func is None:
292
+ raise ValueError(f"Unsupported resampling function: {resampling}")
293
+
294
+ num_images = len(images) - 1
295
+ num_frames = int(duration * fps)
296
+ num_frames_half = int(num_frames / 2)
297
+ tmp_dir_hash = os.path.join(
298
+ tmp_dir, md5("".join(image_paths).encode("utf-8")).hexdigest()
299
+ )
300
+
301
+ width = get_px_or_fraction(width, images[0].width)
302
+ height = get_px_or_fraction(height, images[0].height)
303
+ margin = get_px_or_fraction(margin, min(images[0].width, images[0].height))
304
+
305
+ # Create tmp dir
306
+ if not os.path.exists(tmp_dir_hash):
307
+ click.echo(f"Creating temporary directory for frames: {tmp_dir} ...")
308
+ os.makedirs(tmp_dir_hash, exist_ok=True)
309
+
310
+ if direction in ["out", "outin"]:
311
+ images.reverse()
312
+
313
+ if reverse_images:
314
+ images.reverse()
315
+
316
+ # Blend images (take care of margins)
317
+ click.echo(f"Blending {len(images)} images ...")
318
+ for i in trange(1, num_images + 1):
319
+ inner_image = images[i]
320
+ outer_image = images[i - 1]
321
+ inner_image = inner_image.crop(
322
+ (margin, margin, inner_image.width - margin, inner_image.height - margin)
323
+ )
324
+
325
+ # Some coloring for debugging purposes
326
+ # debug_colors = ['red', 'green', 'blue', 'yellow', 'cyan', 'magenta']
327
+ # layer = Image.new('RGB', inner_image.size, debug_colors[i % 6])
328
+ # inner_image = Image.blend(inner_image, layer, 0.25)
329
+
330
+ image = zoom_crop(outer_image, zoom, resampling_func)
331
+ image.paste(inner_image, (margin, margin))
332
+ images[i] = image
333
+
334
+ # Save image for debugging purposes
335
+ # image_path = os.path.join(tmp_dir_hash, f"_blending_step_1_{i:06d}.png")
336
+ # image.save(image_path)
337
+
338
+ images_resized = [resize_scale(i, zoom, resampling_func) for i in images]
339
+ for i in trange(num_images, 0, -1):
340
+ inner_image = images_resized[i]
341
+ image = images_resized[i - 1]
342
+ inner_image = resize_scale(inner_image, 1.0 / zoom, resampling_func)
343
+
344
+ # Some coloring for debugging purposes
345
+ # debug_colors = ['red', 'green', 'blue', 'yellow', 'cyan', 'magenta']
346
+ # layer = Image.new('RGB', inner_image.size, debug_colors[i % 6])
347
+ # inner_image = Image.blend(inner_image, layer, 0.25)
348
+
349
+ image.paste(
350
+ inner_image,
351
+ (
352
+ int((image.width - inner_image.width) / 2),
353
+ int((image.height - inner_image.height) / 2),
354
+ ),
355
+ )
356
+ images_resized[i] = image
357
+
358
+ # Save image for debugging purposes
359
+ # image_path = os.path.join(tmp_dir_hash, f"_blending_step_2_{i:06d}.png")
360
+ # image.save(image_path)
361
+
362
+ images = images_resized
363
+
364
+ # Create frames
365
+ def process_frame(i): # to improve
366
+ if direction == "in":
367
+ current_zoom_log = zoom_in_log(easing_func, i, num_frames, num_images)
368
+ elif direction == "out":
369
+ current_zoom_log = zoom_out_log(easing_func, i, num_frames, num_images)
370
+ elif direction == "inout":
371
+ if i < num_frames_half:
372
+ current_zoom_log = zoom_in_log(
373
+ easing_func, i, num_frames_half, num_images
374
+ )
375
+ else:
376
+ current_zoom_log = zoom_out_log(
377
+ easing_func, i - num_frames_half, num_frames_half, num_images
378
+ )
379
+ elif direction == "outin":
380
+ if i < num_frames_half:
381
+ current_zoom_log = zoom_out_log(
382
+ easing_func, i, num_frames_half, num_images
383
+ )
384
+ else:
385
+ current_zoom_log = zoom_in_log(
386
+ easing_func, i - num_frames_half, num_frames_half, num_images
387
+ )
388
+ else:
389
+ raise ValueError(f"Unsupported direction: {direction}")
390
+
391
+ current_image_idx = ceil(current_zoom_log)
392
+ local_zoom = zoom ** (current_zoom_log - current_image_idx + 1)
393
+
394
+ if current_zoom_log == 0.0:
395
+ frame = images[0]
396
+ else:
397
+ frame = images[current_image_idx]
398
+ frame = zoom_crop(frame, local_zoom, resampling_func)
399
+
400
+ frame = frame.resize((width, height), resampling_func)
401
+ frame_path = os.path.join(tmp_dir_hash, f"{i:06d}.png")
402
+ frame.save(frame_path)
403
+
404
+ n_jobs = threads if threads > 0 else cpu_count() - threads
405
+ click.echo(f"Creating frames in {n_jobs} threads ...")
406
+
407
+ with ThreadPoolExecutor(max_workers=n_jobs) as executor:
408
+ futures = [executor.submit(process_frame, i) for i in range(num_frames)]
409
+ for _ in tqdm(concurrent.futures.as_completed(futures), total=num_frames):
410
+ pass
411
+
412
+ # Write video
413
+ click.echo(f"Writing video to: {output} ...")
414
+ image_files = [
415
+ os.path.join(tmp_dir_hash, f"{i:06d}.png") for i in range(num_frames)
416
+ ]
417
+ video_clip = ImageSequenceClip(image_files, fps=fps)
418
+ video_write_kwargs = {"codec": "libx264"}
419
+
420
+ # Add audio
421
+ if audio_path:
422
+ click.echo(f"Adding audio from: {audio_path} ...")
423
+ audio_clip = AudioFileClip(audio_path)
424
+ audio_clip = audio_clip.subclip(0, video_clip.end)
425
+ video_clip = video_clip.set_audio(audio_clip)
426
+ video_write_kwargs["audio_codec"] = "aac"
427
+
428
+ video_clip.write_videofile(output, **video_write_kwargs)
429
+
430
+ # Remove tmp dir
431
+ if not keep_frames and not skip_video_generation:
432
+ shutil.rmtree(tmp_dir_hash, ignore_errors=False, onerror=None)
433
+ if not os.listdir(tmp_dir):
434
+ click.echo(f"Removing empty temporary directory for frames: {tmp_dir} ...")
435
+ os.rmdir(tmp_dir)
436
+
437
+ click.echo("Done!")
438
+
439
+
440
+ if __name__ == "__main__":
441
+ zoom_video_composer()