# 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