Update core/visual_engine.py
Browse files- 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="
|
8 |
self.output_dir = output_dir
|
9 |
os.makedirs(self.output_dir, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
10 |
try:
|
11 |
-
|
12 |
-
|
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"
|
|
|
|
|
20 |
self.font = ImageFont.load_default()
|
21 |
-
|
|
|
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
|
30 |
-
#
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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=(
|
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 |
-
|
56 |
-
|
57 |
-
|
58 |
|
59 |
-
|
60 |
-
|
61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
62 |
lines.append(current_line.strip())
|
63 |
-
current_line = word + " "
|
64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
-
|
70 |
-
|
|
|
|
|
|
|
71 |
|
72 |
-
y_text = (size[1] -
|
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=(
|
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"
|
110 |
|
111 |
try:
|
112 |
-
clips = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
-
|
121 |
video_clip.write_videofile(
|
122 |
output_path,
|
123 |
fps=fps,
|
124 |
-
codec='libx264',
|
125 |
-
audio_codec='aac',
|
126 |
-
temp_audiofile='temp-audio.m4a', #
|
127 |
-
remove_temp=True,
|
128 |
threads=os.cpu_count() or 2, # Use available CPUs or default to 2
|
129 |
-
logger=
|
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 |
-
|
136 |
-
|
137 |
-
|
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
|