Adnan commited on
Commit
5422357
·
verified ·
1 Parent(s): 7a30217

Update video_assembler.py

Browse files
Files changed (1) hide show
  1. video_assembler.py +74 -84
video_assembler.py CHANGED
@@ -9,30 +9,37 @@ import tempfile
9
  import numpy as np
10
  from PIL import Image, ImageDraw, ImageFont
11
  from typing import List, Dict, Optional, Tuple
12
- import imageio
13
 
14
 
15
  class VideoAssembler:
16
  """Assembles frames into a polished timelapse video."""
17
 
18
  def __init__(self):
19
- self.font = self._load_font()
 
 
 
 
 
20
 
21
- @staticmethod
22
- def _load_font(size: int = 32) -> ImageFont.FreeTypeFont:
23
- """Load a font, with fallbacks."""
24
  font_paths = [
25
  "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
 
26
  "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
27
  "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
28
- "C:/Windows/Fonts/arial.ttf",
29
  ]
30
  for path in font_paths:
31
- try:
32
- return ImageFont.truetype(path, size)
33
- except (IOError, OSError):
34
- continue
35
- return ImageFont.load_default()
 
 
 
 
 
 
36
 
37
  def add_text_overlay(
38
  self,
@@ -48,15 +55,16 @@ class VideoAssembler:
48
  img = image.copy().convert("RGBA")
49
  overlay = Image.new("RGBA", img.size, (0, 0, 0, 0))
50
  draw = ImageDraw.Draw(overlay)
51
-
52
  font = self._load_font(font_size)
53
 
54
- # Calculate text size
55
- bbox = draw.textbbox((0, 0), text, font=font)
56
- text_w = bbox[2] - bbox[0]
57
- text_h = bbox[3] - bbox[1]
 
 
 
58
 
59
- # Calculate position
60
  img_w, img_h = img.size
61
  if position == "top":
62
  x = (img_w - text_w) // 2
@@ -70,18 +78,15 @@ class VideoAssembler:
70
  elif position == "top-right":
71
  x = img_w - text_w - margin
72
  y = margin
73
- else: # center
74
  x = (img_w - text_w) // 2
75
  y = (img_h - text_h) // 2
76
 
77
- # Draw background rectangle
78
  padding = 8
79
  draw.rectangle(
80
  [x - padding, y - padding, x + text_w + padding, y + text_h + padding],
81
  fill=bg_color,
82
  )
83
-
84
- # Draw text
85
  draw.text((x, y), text, fill=text_color, font=font)
86
 
87
  result = Image.alpha_composite(img, overlay)
@@ -100,11 +105,10 @@ class VideoAssembler:
100
  draw = ImageDraw.Draw(img)
101
  w, h = img.size
102
 
103
- # Background bar
104
  draw.rectangle([0, h - bar_height, w, h], fill=bg_color)
105
- # Progress bar
106
- bar_width = int(w * progress)
107
- draw.rectangle([0, h - bar_height, bar_width, h], fill=bar_color)
108
 
109
  return img
110
 
@@ -118,34 +122,33 @@ class VideoAssembler:
118
  add_progress: bool = True,
119
  add_bookend_labels: bool = True,
120
  ) -> List[np.ndarray]:
121
- """
122
- Prepare frames for video export.
123
- Each panel image is repeated for hold_seconds duration.
124
- """
125
  frames_per_panel = max(1, int(fps * hold_seconds))
126
  all_frames = []
127
 
128
  for i, img in enumerate(images):
129
  frame = img.copy()
130
 
131
- # Add text label
132
  if add_labels and labels and i < len(labels) and labels[i]:
133
- frame = self.add_text_overlay(frame, labels[i], position="top-left", font_size=24)
 
 
134
 
135
- # Add BEFORE / AFTER labels
136
  if add_bookend_labels:
137
  if i == 0:
138
- frame = self.add_text_overlay(frame, "BEFORE", position="bottom", font_size=40)
 
 
139
  elif i == len(images) - 1:
140
- frame = self.add_text_overlay(frame, "AFTER", position="bottom", font_size=40)
 
 
141
 
142
- # Add progress bar
143
  if add_progress:
144
  progress = (i + 1) / len(images)
145
  frame = self.add_progress_bar(frame, progress)
146
 
147
- # Repeat frame for hold duration
148
- frame_array = np.array(frame)
149
  for _ in range(frames_per_panel):
150
  all_frames.append(frame_array)
151
 
@@ -164,16 +167,17 @@ class VideoAssembler:
164
  fade_in_frames: int = 15,
165
  fade_out_frames: int = 15,
166
  ) -> str:
167
- """
168
- Create a complete video from panel images.
169
-
170
- Returns: path to the output video file.
171
- """
172
  if output_path is None:
173
- output_path = os.path.join(tempfile.gettempdir(), "timelapse_output.mp4")
 
 
174
 
175
  frames = self.prepare_frames(
176
- images, fps, hold_seconds, add_labels, labels, add_progress, add_bookend_labels
 
177
  )
178
 
179
  if not frames:
@@ -181,16 +185,16 @@ class VideoAssembler:
181
 
182
  # Apply fade in
183
  for i in range(min(fade_in_frames, len(frames))):
184
- alpha = i / fade_in_frames
185
  frames[i] = (frames[i].astype(np.float32) * alpha).astype(np.uint8)
186
 
187
  # Apply fade out
188
  for i in range(min(fade_out_frames, len(frames))):
189
  idx = len(frames) - 1 - i
190
- alpha = i / fade_out_frames
191
  frames[idx] = (frames[idx].astype(np.float32) * alpha).astype(np.uint8)
192
 
193
- # Write video using imageio
194
  writer = imageio.get_writer(
195
  output_path,
196
  fps=fps,
@@ -212,9 +216,9 @@ class VideoAssembler:
212
  audio_path: str,
213
  output_path: Optional[str] = None,
214
  ) -> str:
215
- """Add background audio/music to the video using moviepy."""
216
  if output_path is None:
217
- output_path = video_path.replace(".mp4", "_with_audio.mp4")
218
 
219
  try:
220
  from moviepy.editor import VideoFileClip, AudioFileClip
@@ -222,57 +226,44 @@ class VideoAssembler:
222
  video = VideoFileClip(video_path)
223
  audio = AudioFileClip(audio_path)
224
 
225
- # Loop or trim audio to match video duration
226
  if audio.duration < video.duration:
227
- # Loop audio
228
  from moviepy.editor import concatenate_audioclips
229
- loops_needed = int(video.duration / audio.duration) + 1
230
- audio = concatenate_audioclips([audio] * loops_needed)
231
 
232
  audio = audio.subclip(0, video.duration)
233
-
234
- # Fade audio in and out
235
- audio = audio.audio_fadein(2).audio_fadeout(2)
236
  final = video.set_audio(audio)
237
- final.write_videofile(output_path, codec="libx264", audio_codec="aac", logger=None)
238
-
 
 
239
  video.close()
240
- audio.close()
241
  return output_path
242
 
243
- except ImportError:
244
- print("moviepy not available for audio mixing. Returning video without audio.")
245
- return video_path
246
  except Exception as e:
247
- print(f"Audio mixing failed: {e}. Returning video without audio.")
248
  return video_path
249
 
250
  def create_comparison_image(
251
  self,
252
  first: Image.Image,
253
  last: Image.Image,
254
- orientation: str = "horizontal",
255
  gap: int = 4,
256
  ) -> Image.Image:
257
- """Create a side-by-side or top-bottom comparison image."""
258
  w, h = first.size
259
-
260
- if orientation == "horizontal":
261
- comp = Image.new("RGB", (w * 2 + gap, h), (30, 30, 30))
262
- comp.paste(first, (0, 0))
263
- comp.paste(last, (w + gap, 0))
264
-
265
- # Add labels
266
- comp = self.add_text_overlay(
267
- comp, "BEFORE", position="top-left", font_size=28
268
- )
269
- draw = ImageDraw.Draw(comp)
270
- font = self._load_font(28)
271
  draw.text((w + gap + 10, 10), "AFTER", fill=(255, 255, 255), font=font)
272
- else:
273
- comp = Image.new("RGB", (w, h * 2 + gap), (30, 30, 30))
274
- comp.paste(first, (0, 0))
275
- comp.paste(last, (0, h + gap))
276
 
277
  return comp
278
 
@@ -283,17 +274,16 @@ class VideoAssembler:
283
  duration_per_frame: int = 500,
284
  loop: int = 0,
285
  ) -> str:
286
- """Create an animated GIF from the panels."""
287
  if output_path is None:
288
  output_path = os.path.join(tempfile.gettempdir(), "timelapse.gif")
289
 
290
- # Resize for GIF (smaller file size)
291
  max_size = (480, 480)
292
  resized = []
293
  for img in images:
294
  r = img.copy()
295
  r.thumbnail(max_size, Image.LANCZOS)
296
- resized.append(r)
297
 
298
  resized[0].save(
299
  output_path,
 
9
  import numpy as np
10
  from PIL import Image, ImageDraw, ImageFont
11
  from typing import List, Dict, Optional, Tuple
 
12
 
13
 
14
  class VideoAssembler:
15
  """Assembles frames into a polished timelapse video."""
16
 
17
  def __init__(self):
18
+ self.font_cache = {}
19
+
20
+ def _load_font(self, size: int = 32) -> ImageFont.FreeTypeFont:
21
+ """Load a font with caching and fallbacks."""
22
+ if size in self.font_cache:
23
+ return self.font_cache[size]
24
 
 
 
 
25
  font_paths = [
26
  "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
27
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
28
  "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
29
  "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
 
30
  ]
31
  for path in font_paths:
32
+ if os.path.exists(path):
33
+ try:
34
+ font = ImageFont.truetype(path, size)
35
+ self.font_cache[size] = font
36
+ return font
37
+ except Exception:
38
+ continue
39
+
40
+ font = ImageFont.load_default()
41
+ self.font_cache[size] = font
42
+ return font
43
 
44
  def add_text_overlay(
45
  self,
 
55
  img = image.copy().convert("RGBA")
56
  overlay = Image.new("RGBA", img.size, (0, 0, 0, 0))
57
  draw = ImageDraw.Draw(overlay)
 
58
  font = self._load_font(font_size)
59
 
60
+ try:
61
+ bbox = draw.textbbox((0, 0), text, font=font)
62
+ text_w = bbox[2] - bbox[0]
63
+ text_h = bbox[3] - bbox[1]
64
+ except Exception:
65
+ text_w = len(text) * font_size // 2
66
+ text_h = font_size
67
 
 
68
  img_w, img_h = img.size
69
  if position == "top":
70
  x = (img_w - text_w) // 2
 
78
  elif position == "top-right":
79
  x = img_w - text_w - margin
80
  y = margin
81
+ else:
82
  x = (img_w - text_w) // 2
83
  y = (img_h - text_h) // 2
84
 
 
85
  padding = 8
86
  draw.rectangle(
87
  [x - padding, y - padding, x + text_w + padding, y + text_h + padding],
88
  fill=bg_color,
89
  )
 
 
90
  draw.text((x, y), text, fill=text_color, font=font)
91
 
92
  result = Image.alpha_composite(img, overlay)
 
105
  draw = ImageDraw.Draw(img)
106
  w, h = img.size
107
 
 
108
  draw.rectangle([0, h - bar_height, w, h], fill=bg_color)
109
+ bar_width = int(w * min(progress, 1.0))
110
+ if bar_width > 0:
111
+ draw.rectangle([0, h - bar_height, bar_width, h], fill=bar_color)
112
 
113
  return img
114
 
 
122
  add_progress: bool = True,
123
  add_bookend_labels: bool = True,
124
  ) -> List[np.ndarray]:
125
+ """Prepare frames for video export."""
 
 
 
126
  frames_per_panel = max(1, int(fps * hold_seconds))
127
  all_frames = []
128
 
129
  for i, img in enumerate(images):
130
  frame = img.copy()
131
 
 
132
  if add_labels and labels and i < len(labels) and labels[i]:
133
+ frame = self.add_text_overlay(
134
+ frame, labels[i], position="top-left", font_size=24
135
+ )
136
 
 
137
  if add_bookend_labels:
138
  if i == 0:
139
+ frame = self.add_text_overlay(
140
+ frame, "BEFORE", position="bottom", font_size=40
141
+ )
142
  elif i == len(images) - 1:
143
+ frame = self.add_text_overlay(
144
+ frame, "AFTER", position="bottom", font_size=40
145
+ )
146
 
 
147
  if add_progress:
148
  progress = (i + 1) / len(images)
149
  frame = self.add_progress_bar(frame, progress)
150
 
151
+ frame_array = np.array(frame.convert("RGB"))
 
152
  for _ in range(frames_per_panel):
153
  all_frames.append(frame_array)
154
 
 
167
  fade_in_frames: int = 15,
168
  fade_out_frames: int = 15,
169
  ) -> str:
170
+ """Create a complete video from panel images."""
171
+ import imageio
172
+
 
 
173
  if output_path is None:
174
+ output_path = os.path.join(
175
+ tempfile.gettempdir(), "timelapse_output.mp4"
176
+ )
177
 
178
  frames = self.prepare_frames(
179
+ images, fps, hold_seconds, add_labels, labels,
180
+ add_progress, add_bookend_labels,
181
  )
182
 
183
  if not frames:
 
185
 
186
  # Apply fade in
187
  for i in range(min(fade_in_frames, len(frames))):
188
+ alpha = i / max(fade_in_frames, 1)
189
  frames[i] = (frames[i].astype(np.float32) * alpha).astype(np.uint8)
190
 
191
  # Apply fade out
192
  for i in range(min(fade_out_frames, len(frames))):
193
  idx = len(frames) - 1 - i
194
+ alpha = i / max(fade_out_frames, 1)
195
  frames[idx] = (frames[idx].astype(np.float32) * alpha).astype(np.uint8)
196
 
197
+ # Write video
198
  writer = imageio.get_writer(
199
  output_path,
200
  fps=fps,
 
216
  audio_path: str,
217
  output_path: Optional[str] = None,
218
  ) -> str:
219
+ """Add background audio to the video using moviepy."""
220
  if output_path is None:
221
+ output_path = video_path.replace(".mp4", "_audio.mp4")
222
 
223
  try:
224
  from moviepy.editor import VideoFileClip, AudioFileClip
 
226
  video = VideoFileClip(video_path)
227
  audio = AudioFileClip(audio_path)
228
 
 
229
  if audio.duration < video.duration:
 
230
  from moviepy.editor import concatenate_audioclips
231
+ loops = int(video.duration / audio.duration) + 1
232
+ audio = concatenate_audioclips([audio] * loops)
233
 
234
  audio = audio.subclip(0, video.duration)
 
 
 
235
  final = video.set_audio(audio)
236
+ final.write_videofile(
237
+ output_path, codec="libx264",
238
+ audio_codec="aac", logger=None,
239
+ )
240
  video.close()
 
241
  return output_path
242
 
 
 
 
243
  except Exception as e:
244
+ print(f"Audio mixing failed: {e}")
245
  return video_path
246
 
247
  def create_comparison_image(
248
  self,
249
  first: Image.Image,
250
  last: Image.Image,
 
251
  gap: int = 4,
252
  ) -> Image.Image:
253
+ """Create side-by-side comparison image."""
254
  w, h = first.size
255
+ comp = Image.new("RGB", (w * 2 + gap, h), (30, 30, 30))
256
+ comp.paste(first.resize((w, h), Image.LANCZOS), (0, 0))
257
+ comp.paste(last.resize((w, h), Image.LANCZOS), (w + gap, 0))
258
+
259
+ comp = self.add_text_overlay(comp, "BEFORE", position="top-left", font_size=28)
260
+ # Add AFTER on right side
261
+ draw = ImageDraw.Draw(comp)
262
+ font = self._load_font(28)
263
+ try:
 
 
 
264
  draw.text((w + gap + 10, 10), "AFTER", fill=(255, 255, 255), font=font)
265
+ except Exception:
266
+ pass
 
 
267
 
268
  return comp
269
 
 
274
  duration_per_frame: int = 500,
275
  loop: int = 0,
276
  ) -> str:
277
+ """Create an animated GIF."""
278
  if output_path is None:
279
  output_path = os.path.join(tempfile.gettempdir(), "timelapse.gif")
280
 
 
281
  max_size = (480, 480)
282
  resized = []
283
  for img in images:
284
  r = img.copy()
285
  r.thumbnail(max_size, Image.LANCZOS)
286
+ resized.append(r.convert("RGB"))
287
 
288
  resized[0].save(
289
  output_path,