CingenAI / core /visual_engine.py
mgbam's picture
Update core/visual_engine.py
50c620f verified
raw
history blame
9.74 kB
# 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