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