mgbam commited on
Commit
50c620f
·
verified ·
1 Parent(s): 111ba62

Update core/visual_engine.py

Browse files
Files changed (1) hide show
  1. core/visual_engine.py +107 -54
core/visual_engine.py CHANGED
@@ -4,21 +4,27 @@ from moviepy.editor import ImageClip, concatenate_videoclips
4
  import os
5
 
6
  class VisualEngine:
7
- def __init__(self, output_dir="generated_media"):
8
  self.output_dir = output_dir
9
  os.makedirs(self.output_dir, exist_ok=True)
 
 
 
 
 
 
 
 
10
  try:
11
- # Try to load Arial font. Ensure it's installed in your Docker image.
12
- # Common paths: "arial.ttf", "/usr/share/fonts/truetype/msttcorefonts/Arial.ttf"
13
- # If using a custom font, ensure you COPY it into the Docker image and provide the correct path.
14
- self.font_path = "arial.ttf" # This assumes Arial is in a standard font path or current dir
15
- self.font_size_pil = 24 # For Pillow text drawing
16
- self.font = ImageFont.truetype(self.font_path, self.font_size_pil)
17
- print(f"Successfully loaded font: {self.font_path} with size {self.font_size_pil}")
18
  except IOError:
19
- print(f"Warning: Could not load font '{self.font_path}'. Falling back to default font.")
 
 
20
  self.font = ImageFont.load_default()
21
- self.font_size_pil = 10 # Default font is smaller, adjust size for calculations
 
22
 
23
 
24
  def _get_text_dimensions(self, text_content, font_obj):
@@ -26,62 +32,97 @@ class VisualEngine:
26
  Gets the width and height of a single line of text with the given font.
27
  Returns (width, height).
28
  """
29
- if hasattr(font_obj, 'getbbox'): # For newer Pillow versions (>=8.0.0)
30
- # getbbox returns (left, top, right, bottom)
31
- bbox = font_obj.getbbox(text_content)
32
- width = bbox[2] - bbox[0]
33
- height = bbox[3] - bbox[1]
34
- return width, height
35
- elif hasattr(font_obj, 'getsize'): # For older Pillow versions
36
- return font_obj.getsize(text_content)
37
- else: # Fallback for very basic font objects (like default font)
38
- # Estimate: average char width * num_chars, fixed height
39
- # This is a rough estimate.
 
 
 
 
 
 
 
 
 
 
40
  avg_char_width = self.font_size_pil * 0.6
41
  height_estimate = self.font_size_pil * 1.2
42
- return int(len(text_content) * avg_char_width), int(height_estimate)
43
 
44
 
45
  def create_placeholder_image(self, text_description, filename, size=(1280, 720)):
46
- img = Image.new('RGB', size, color=(73, 109, 137)) # Blueish background
47
  draw = ImageDraw.Draw(img)
48
 
49
  padding = 40
50
  max_text_width = size[0] - (2 * padding)
51
  lines = []
 
 
 
 
52
  words = text_description.split()
53
  current_line = ""
54
 
55
- for word in words:
56
- test_line = current_line + word + " "
57
- line_width, _ = self._get_text_dimensions(test_line.strip(), self.font)
58
 
59
- if line_width <= max_text_width:
60
- current_line = test_line
61
- else:
 
 
 
 
 
 
62
  lines.append(current_line.strip())
63
- current_line = word + " "
64
- lines.append(current_line.strip()) # Add the last line
 
 
 
 
 
 
 
 
 
 
 
 
65
 
66
  # Calculate starting y position to center the text block
67
- # Use the height from the first line as an estimate for line height
68
  _, single_line_height = self._get_text_dimensions("Tg", self.font) # Get height of a typical line
69
- line_spacing_factor = 1.2 # Adjust for spacing between lines
70
- total_text_height = len(lines) * single_line_height * line_spacing_factor
 
 
 
71
 
72
- y_text = (size[1] - total_text_height) / 2
73
  if y_text < padding: # Ensure text doesn't start too high if too much text
74
- y_text = padding
75
 
76
  for line in lines:
77
  line_width, _ = self._get_text_dimensions(line, self.font)
78
- x_text = (size[0] - line_width) / 2
 
 
79
 
80
- # Corrected ImageDraw.text() call with explicit keyword arguments
81
  draw.text(
82
  xy=(x_text, y_text),
83
  text=line,
84
- fill=(255, 255, 0), # Yellow text
85
  font=self.font
86
  )
87
  y_text += single_line_height * line_spacing_factor
@@ -89,7 +130,7 @@ class VisualEngine:
89
  filepath = os.path.join(self.output_dir, filename)
90
  try:
91
  img.save(filepath)
92
- print(f"Placeholder image saved: {filepath}")
93
  except Exception as e:
94
  print(f"Error saving image {filepath}: {e}")
95
  return None
@@ -103,37 +144,49 @@ class VisualEngine:
103
 
104
  valid_image_paths = [p for p in image_paths if p and os.path.exists(p)]
105
  if not valid_image_paths:
106
- print("No valid image paths found to create video.")
107
  return None
108
 
109
- print(f"Creating video from images: {valid_image_paths}")
110
 
111
  try:
112
- clips = [ImageClip(m).set_duration(duration_per_image) for m in valid_image_paths]
 
 
 
 
 
 
 
113
  if not clips:
114
- print("Could not create ImageClips from the provided image paths.")
115
  return None
116
 
117
  video_clip = concatenate_videoclips(clips, method="compose")
118
  output_path = os.path.join(self.output_dir, output_filename)
119
 
120
- # Using recommended parameters for write_videofile, especially in headless environments
121
  video_clip.write_videofile(
122
  output_path,
123
  fps=fps,
124
- codec='libx264', # A common and good codec
125
- audio_codec='aac', # If you ever add audio
126
- temp_audiofile='temp-audio.m4a', # For temporary audio processing
127
- remove_temp=True, # Clean up temporary audio file
128
  threads=os.cpu_count() or 2, # Use available CPUs or default to 2
129
- logger=None # Suppress verbose ffmpeg output, use 'bar' for progress
130
  )
 
 
 
 
 
 
131
  print(f"Video successfully created: {output_path}")
132
  return output_path
133
  except Exception as e:
134
  print(f"Error creating video: {e}")
135
- # Print more details if it's an OSError, which can happen with ffmpeg issues
136
- if isinstance(e, OSError):
137
- print("OSError during video creation. This often indicates an issue with ffmpeg.")
138
- print("Ensure ffmpeg is correctly installed and accessible in the environment PATH.")
139
  return None
 
4
  import os
5
 
6
  class VisualEngine:
7
+ def __init__(self, output_dir="temp_generated_media"): # Changed default to avoid confusion with local dev
8
  self.output_dir = output_dir
9
  os.makedirs(self.output_dir, exist_ok=True)
10
+
11
+ # --- Font Setup ---
12
+ # This path should match where you COPY the font in your Dockerfile.
13
+ # Example: COPY assets/fonts/arial.ttf /usr/local/share/fonts/truetype/mycustomfonts/arial.ttf
14
+ self.font_filename = "arial.ttf" # Or "DejaVuSans.ttf" or your chosen font
15
+ self.font_path_in_container = f"/usr/local/share/fonts/truetype/mycustomfonts/{self.font_filename}"
16
+ self.font_size_pil = 24
17
+
18
  try:
19
+ self.font = ImageFont.truetype(self.font_path_in_container, self.font_size_pil)
20
+ print(f"Successfully loaded font: {self.font_path_in_container} with size {self.font_size_pil}")
 
 
 
 
 
21
  except IOError:
22
+ print(f"Error: Could not load font from '{self.font_path_in_container}'. "
23
+ f"Ensure the font file '{self.font_filename}' is in 'assets/fonts/' "
24
+ f"and the Dockerfile correctly copies it. Falling back to default font.")
25
  self.font = ImageFont.load_default()
26
+ # Adjust size if using default font, as it's typically smaller and metrics differ
27
+ self.font_size_pil = 11 # Default font is often ~10px, this is an estimate for line height
28
 
29
 
30
  def _get_text_dimensions(self, text_content, font_obj):
 
32
  Gets the width and height of a single line of text with the given font.
33
  Returns (width, height).
34
  """
35
+ if text_content == "" or text_content is None: # Handle empty string
36
+ return 0, self.font_size_pil # Return 0 width, default height
37
+
38
+ try:
39
+ if hasattr(font_obj, 'getbbox'): # For newer Pillow versions (>=8.0.0)
40
+ # getbbox returns (left, top, right, bottom)
41
+ bbox = font_obj.getbbox(text_content)
42
+ width = bbox[2] - bbox[0]
43
+ height = bbox[3] - bbox[1]
44
+ # If height is 0 for some reason (e.g. empty string was passed and not caught), use font_size_pil
45
+ return width, height if height > 0 else self.font_size_pil
46
+ elif hasattr(font_obj, 'getsize'): # For older Pillow versions
47
+ width, height = font_obj.getsize(text_content)
48
+ return width, height if height > 0 else self.font_size_pil
49
+ else: # Fallback for very basic font objects (like default font after failure)
50
+ avg_char_width = self.font_size_pil * 0.6 # Rough estimate
51
+ height_estimate = self.font_size_pil * 1.2
52
+ return int(len(text_content) * avg_char_width), int(height_estimate if height_estimate > 0 else self.font_size_pil)
53
+ except Exception as e:
54
+ print(f"Warning: Error getting text dimensions for '{text_content}': {e}. Using estimates.")
55
+ # Fallback estimates if any error occurs
56
  avg_char_width = self.font_size_pil * 0.6
57
  height_estimate = self.font_size_pil * 1.2
58
+ return int(len(text_content) * avg_char_width), int(height_estimate if height_estimate > 0 else self.font_size_pil)
59
 
60
 
61
  def create_placeholder_image(self, text_description, filename, size=(1280, 720)):
62
+ img = Image.new('RGB', size, color=(30, 30, 60)) # Darker blueish background
63
  draw = ImageDraw.Draw(img)
64
 
65
  padding = 40
66
  max_text_width = size[0] - (2 * padding)
67
  lines = []
68
+
69
+ if not text_description: # Handle case where text_description might be None or empty
70
+ text_description = "(No description provided)"
71
+
72
  words = text_description.split()
73
  current_line = ""
74
 
75
+ if not self.font: # Should not happen if __init__ fallback works, but as a safeguard
76
+ print("Error: Font object is not initialized in create_placeholder_image. Cannot draw text.")
77
+ return None
78
 
79
+ for word in words:
80
+ test_line_candidate = current_line + word + " "
81
+ line_width, _ = self._get_text_dimensions(test_line_candidate.strip(), self.font)
82
+
83
+ if line_width <= max_text_width and current_line != "": # If it fits and current_line is not empty
84
+ current_line = test_line_candidate
85
+ elif line_width <= max_text_width and current_line == "": # First word, and it fits
86
+ current_line = test_line_candidate
87
+ elif current_line != "": # If it doesn't fit and current_line is not empty, finalize current_line
88
  lines.append(current_line.strip())
89
+ current_line = word + " " # Start new line with current word
90
+ else: # Word itself is too long for a line
91
+ # Simple truncation for very long words (can be improved with character-level wrapping)
92
+ temp_word = word
93
+ while self._get_text_dimensions(temp_word, self.font)[0] > max_text_width and len(temp_word) > 0:
94
+ temp_word = temp_word[:-1]
95
+ lines.append(temp_word)
96
+ current_line = "" # Reset current line
97
+
98
+ if current_line.strip(): # Add the last line if it has content
99
+ lines.append(current_line.strip())
100
+
101
+ if not lines: # If after all that, lines is empty (e.g. only very long words that got truncated to nothing)
102
+ lines.append("(Text too long or error)")
103
 
104
  # Calculate starting y position to center the text block
 
105
  _, single_line_height = self._get_text_dimensions("Tg", self.font) # Get height of a typical line
106
+ if single_line_height == 0: # Safety for failed get_text_dimensions
107
+ single_line_height = self.font_size_pil
108
+
109
+ line_spacing_factor = 1.3 # Adjust for spacing between lines
110
+ estimated_line_block_height = len(lines) * single_line_height * line_spacing_factor
111
 
112
+ y_text = (size[1] - estimated_line_block_height) / 2.0
113
  if y_text < padding: # Ensure text doesn't start too high if too much text
114
+ y_text = float(padding)
115
 
116
  for line in lines:
117
  line_width, _ = self._get_text_dimensions(line, self.font)
118
+ x_text = (size[0] - line_width) / 2.0
119
+ if x_text < padding: # Ensure text doesn't start too left
120
+ x_text = float(padding)
121
 
 
122
  draw.text(
123
  xy=(x_text, y_text),
124
  text=line,
125
+ fill=(220, 220, 150), # Lighter Yellow text
126
  font=self.font
127
  )
128
  y_text += single_line_height * line_spacing_factor
 
130
  filepath = os.path.join(self.output_dir, filename)
131
  try:
132
  img.save(filepath)
133
+ # print(f"Placeholder image saved: {filepath}") # Can be noisy, uncomment for debugging
134
  except Exception as e:
135
  print(f"Error saving image {filepath}: {e}")
136
  return None
 
144
 
145
  valid_image_paths = [p for p in image_paths if p and os.path.exists(p)]
146
  if not valid_image_paths:
147
+ print("No valid image paths found to create video. Ensure images were generated and paths are correct.")
148
  return None
149
 
150
+ print(f"Attempting to create video from {len(valid_image_paths)} images: {valid_image_paths}")
151
 
152
  try:
153
+ clips = []
154
+ for m_path in valid_image_paths:
155
+ try:
156
+ clip = ImageClip(m_path).set_duration(duration_per_image)
157
+ clips.append(clip)
158
+ except Exception as e_clip:
159
+ print(f"Error creating ImageClip for {m_path}: {e_clip}. Skipping this image.")
160
+
161
  if not clips:
162
+ print("Could not create any ImageClips from the provided image paths.")
163
  return None
164
 
165
  video_clip = concatenate_videoclips(clips, method="compose")
166
  output_path = os.path.join(self.output_dir, output_filename)
167
 
168
+ print(f"Writing video to: {output_path}")
169
  video_clip.write_videofile(
170
  output_path,
171
  fps=fps,
172
+ codec='libx264',
173
+ audio_codec='aac', # Required even if no audio, or can cause issues
174
+ temp_audiofile=os.path.join(self.output_dir, 'temp-audio.m4a'), # Ensure this path is writable
175
+ remove_temp=True,
176
  threads=os.cpu_count() or 2, # Use available CPUs or default to 2
177
+ logger='bar' # Use 'bar' for progress, None to suppress totally
178
  )
179
+ # Close clips to release file handles, especially important on some OS / Docker
180
+ for clip_to_close in clips:
181
+ clip_to_close.close()
182
+ if hasattr(video_clip, 'close'):
183
+ video_clip.close()
184
+
185
  print(f"Video successfully created: {output_path}")
186
  return output_path
187
  except Exception as e:
188
  print(f"Error creating video: {e}")
189
+ if isinstance(e, OSError): # Or check for specific ffmpeg error messages
190
+ print("OSError during video creation. This often indicates an issue with ffmpeg, "
191
+ "its installation, or permissions to write temporary files.")
 
192
  return None