File size: 45,574 Bytes
e22eb13 e0b9b11 d1bb1cc 62838f2 d1bb1cc 62838f2 b1b7840 d1bb1cc e22eb13 d1bb1cc e22eb13 d1bb1cc f13d4b2 5089920 f13d4b2 d1bb1cc b1b7840 d1bb1cc 5089920 d1bb1cc 4c2220b f13d4b2 287c9ca d1bb1cc 62838f2 e0b9b11 d1bb1cc 55ef0ff d1bb1cc 55ef0ff d1bb1cc b1b7840 d1bb1cc 55ef0ff d1bb1cc cd8e0e1 d1bb1cc 55ef0ff d1bb1cc cd8e0e1 d1bb1cc 200c5c4 09d5c67 d1bb1cc cd8e0e1 d1bb1cc 55ef0ff d1bb1cc 55ef0ff d1bb1cc cd8e0e1 d1bb1cc 55ef0ff d1bb1cc cd8e0e1 d1bb1cc b1b7840 d1bb1cc 62838f2 d1bb1cc b1b7840 d1bb1cc b1b7840 5089920 d1bb1cc b6860f8 d1bb1cc cd8e0e1 d1bb1cc cd8e0e1 d1bb1cc cd8e0e1 d1bb1cc b1b7840 b6860f8 d1bb1cc cd8e0e1 d1bb1cc 610a011 d1bb1cc 610a011 d1bb1cc 8583908 d1bb1cc 3313da9 d1bb1cc cb93f9c d1bb1cc 59af6e7 d1bb1cc 59af6e7 d1bb1cc cb93f9c d1bb1cc b1b7840 d1bb1cc b97795f d1bb1cc 754c854 d1bb1cc |
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 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 |
# core/visual_engine.py
from PIL import Image, ImageDraw, ImageFont, ImageOps
import base64
import mimetypes
import numpy as np
import os
import openai # Ensure this is OpenAI v1.x.x+
import requests
import io
import time
import random
import logging
# --- MoviePy Imports ---
from moviepy.editor import (ImageClip, VideoFileClip, concatenate_videoclips, TextClip,
CompositeVideoClip, AudioFileClip)
import moviepy.video.fx.all as vfx
# --- MONKEY PATCH for Pillow/MoviePy compatibility ---
try:
if hasattr(Image, 'Resampling') and hasattr(Image.Resampling, 'LANCZOS'): # Pillow 9+
if not hasattr(Image, 'ANTIALIAS'): Image.ANTIALIAS = Image.Resampling.LANCZOS
elif hasattr(Image, 'LANCZOS'): # Pillow 8
if not hasattr(Image, 'ANTIALIAS'): Image.ANTIALIAS = Image.LANCZOS
elif not hasattr(Image, 'ANTIALIAS'):
print("WARNING: Pillow version lacks common Resampling attributes or ANTIALIAS. MoviePy effects might fail or look different.")
except Exception as e_monkey_patch:
print(f"WARNING: An unexpected error occurred during Pillow ANTIALIAS monkey-patch: {e_monkey_patch}")
logger = logging.getLogger(__name__)
# Set a default level; can be overridden by the main app's logging config
# logger.setLevel(logging.DEBUG) # Uncomment for very verbose output during development
# --- External Service Client Imports ---
ELEVENLABS_CLIENT_IMPORTED = False
ElevenLabsAPIClient = None # Class placeholder
Voice = None # Class placeholder
VoiceSettings = None # Class placeholder
try:
from elevenlabs.client import ElevenLabs as ImportedElevenLabsClient
from elevenlabs import Voice as ImportedVoice, VoiceSettings as ImportedVoiceSettings
ElevenLabsAPIClient = ImportedElevenLabsClient
Voice = ImportedVoice
VoiceSettings = ImportedVoiceSettings
ELEVENLABS_CLIENT_IMPORTED = True
logger.info("ElevenLabs client components (SDK v1.x.x pattern) imported successfully.")
except ImportError:
logger.warning("ElevenLabs SDK not found (expected 'pip install elevenlabs>=1.0.0'). Audio generation will be disabled.")
except Exception as e_eleven_import_general:
logger.warning(f"General error importing ElevenLabs client components: {e_eleven_import_general}. Audio generation disabled.")
RUNWAYML_SDK_IMPORTED = False
RunwayMLAPIClientClass = None # Storing the class itself
try:
from runwayml import RunwayML as ImportedRunwayMLAPIClientClass # Actual SDK import
RunwayMLAPIClientClass = ImportedRunwayMLAPIClientClass
RUNWAYML_SDK_IMPORTED = True
logger.info("RunwayML SDK (runwayml) imported successfully.")
except ImportError:
logger.warning("RunwayML SDK not found (pip install runwayml). RunwayML video generation will be disabled.")
except Exception as e_runway_sdk_import_general:
logger.warning(f"General error importing RunwayML SDK: {e_runway_sdk_import_general}. RunwayML features disabled.")
class VisualEngine:
DEFAULT_FONT_SIZE_PIL = 10
PREFERRED_FONT_SIZE_PIL = 20
VIDEO_OVERLAY_FONT_SIZE = 30
VIDEO_OVERLAY_FONT_COLOR = 'white'
DEFAULT_MOVIEPY_FONT = 'DejaVu-Sans-Bold' # Common ImageMagick font name
PREFERRED_MOVIEPY_FONT = 'Liberation-Sans-Bold'
def __init__(self, output_dir="temp_cinegen_media", default_elevenlabs_voice_id="Rachel"):
self.output_dir = output_dir
os.makedirs(self.output_dir, exist_ok=True)
self.font_filename_pil_preference = "DejaVuSans-Bold.ttf" # More standard Linux font
font_paths_to_try = [
self.font_filename_pil_preference,
f"/usr/share/fonts/truetype/dejavu/{self.font_filename_pil_preference}",
f"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", # Alternative
f"/System/Library/Fonts/Supplemental/Arial.ttf",
f"C:/Windows/Fonts/arial.ttf",
f"/usr/local/share/fonts/truetype/mycustomfonts/arial.ttf" # Previous custom
]
self.resolved_font_path_pil = next((p for p in font_paths_to_try if os.path.exists(p)), None)
self.active_font_pil = ImageFont.load_default() # Fallback default
self.active_font_size_pil = self.DEFAULT_FONT_SIZE_PIL
self.active_moviepy_font_name = self.DEFAULT_MOVIEPY_FONT
if self.resolved_font_path_pil:
try:
self.active_font_pil = ImageFont.truetype(self.resolved_font_path_pil, self.PREFERRED_FONT_SIZE_PIL)
self.active_font_size_pil = self.PREFERRED_FONT_SIZE_PIL
logger.info(f"Pillow font loaded: {self.resolved_font_path_pil} at size {self.active_font_size_pil}.")
# Determine MoviePy font based on loaded PIL font for consistency
if "dejavu" in self.resolved_font_path_pil.lower(): self.active_moviepy_font_name = 'DejaVu-Sans-Bold'
elif "liberation" in self.resolved_font_path_pil.lower(): self.active_moviepy_font_name = 'Liberation-Sans-Bold'
except IOError as e_font_load_io:
logger.error(f"Pillow font loading IOError for '{self.resolved_font_path_pil}': {e_font_load_io}. Using default font.")
else:
logger.warning("Preferred Pillow font not found in predefined paths. Using default font.")
# Service API keys and flags
self.openai_api_key = None; self.USE_AI_IMAGE_GENERATION = False
self.dalle_model = "dall-e-3"; self.image_size_dalle3 = "1792x1024" # DALL-E 3 landscape
self.video_frame_size = (1280, 720) # HD 16:9
self.elevenlabs_api_key = None; self.USE_ELEVENLABS = False; self.elevenlabs_client_instance = None
self.elevenlabs_voice_id = default_elevenlabs_voice_id
if VoiceSettings and ELEVENLABS_CLIENT_IMPORTED:
self.elevenlabs_voice_settings_obj = VoiceSettings(stability=0.60, similarity_boost=0.80, style=0.15, use_speaker_boost=True)
else: self.elevenlabs_voice_settings_obj = None
self.pexels_api_key = None; self.USE_PEXELS = False
self.runway_api_key = None; self.USE_RUNWAYML = False; self.runway_ml_sdk_client_instance = None # Instance of RunwayML()
# Attempt to initialize RunwayML client if SDK is present and RUNWAYML_API_SECRET env var might be set
if RUNWAYML_SDK_IMPORTED and RunwayMLAPIClientClass and os.getenv("RUNWAYML_API_SECRET"):
try:
self.runway_ml_sdk_client_instance = RunwayMLAPIClientClass() # SDK uses env var by default
self.USE_RUNWAYML = True # Assume enabled if client initializes without error
logger.info("RunwayML Client initialized using RUNWAYML_API_SECRET environment variable at startup.")
except Exception as e_runway_init_at_startup:
logger.error(f"Initial RunwayML client initialization failed (env var RUNWAYML_API_SECRET might be invalid or SDK issue): {e_runway_init_at_startup}")
self.USE_RUNWAYML = False # Ensure it's disabled if init fails
logger.info("VisualEngine initialized.")
# --- API Key Setter Methods ---
def set_openai_api_key(self, api_key_value):
self.openai_api_key = api_key_value; self.USE_AI_IMAGE_GENERATION = bool(api_key_value)
logger.info(f"DALL-E ({self.dalle_model}) service status: {'Ready' if self.USE_AI_IMAGE_GENERATION else 'Disabled (no API key)'}")
def set_elevenlabs_api_key(self, api_key_value, voice_id_from_secret=None):
self.elevenlabs_api_key = api_key_value
if voice_id_from_secret: self.elevenlabs_voice_id = voice_id_from_secret
if api_key_value and ELEVENLABS_CLIENT_IMPORTED and ElevenLabsAPIClient:
try:
self.elevenlabs_client_instance = ElevenLabsAPIClient(api_key=api_key_value) # Pass key directly
self.USE_ELEVENLABS = bool(self.elevenlabs_client_instance)
logger.info(f"ElevenLabs Client service status: {'Ready' if self.USE_ELEVENLABS else 'Failed Initialization'} (Using Voice ID: {self.elevenlabs_voice_id})")
except Exception as e_11l_init:
logger.error(f"ElevenLabs client initialization error: {e_11l_init}. Service Disabled.", exc_info=True)
self.USE_ELEVENLABS = False; self.elevenlabs_client_instance = None
else:
self.USE_ELEVENLABS = False
logger.info(f"ElevenLabs Service Disabled (API key not provided or SDK import issue).")
def set_pexels_api_key(self, api_key_value):
self.pexels_api_key = api_key_value; self.USE_PEXELS = bool(api_key_value)
logger.info(f"Pexels Search service status: {'Ready' if self.USE_PEXELS else 'Disabled (no API key)'}")
def set_runway_api_key(self, api_key_value):
self.runway_api_key = api_key_value # Store the key itself
if api_key_value:
if RUNWAYML_SDK_IMPORTED and RunwayMLAPIClientClass:
if not self.runway_ml_sdk_client_instance: # If not already initialized (e.g., by env var at startup)
try:
# The RunwayML Python SDK expects the API key via the RUNWAYML_API_SECRET env var.
original_env_secret = os.getenv("RUNWAYML_API_SECRET")
if not original_env_secret: # If env var not set, set it temporarily from passed key
logger.info("Temporarily setting RUNWAYML_API_SECRET from provided key for SDK client initialization.")
os.environ["RUNWAYML_API_SECRET"] = api_key_value
self.runway_ml_sdk_client_instance = RunwayMLAPIClientClass()
self.USE_RUNWAYML = True # Mark service as usable if client initializes
logger.info("RunwayML Client initialized successfully using provided API key (via env var).")
if not original_env_secret: # Clean up: remove env var if we set it
del os.environ["RUNWAYML_API_SECRET"]
logger.info("Cleared temporary RUNWAYML_API_SECRET environment variable.")
except Exception as e_runway_client_setkey_init:
logger.error(f"RunwayML Client initialization via set_runway_api_key failed: {e_runway_client_setkey_init}", exc_info=True)
self.USE_RUNWAYML = False; self.runway_ml_sdk_client_instance = None # Ensure it's disabled
else: # Client was already initialized
self.USE_RUNWAYML = True # Service is usable
logger.info("RunwayML Client was already initialized (likely from environment variable). API key stored.")
else: # SDK not imported
logger.warning("RunwayML SDK not imported. API key has been stored, but the current integration relies on the SDK. Service effectively disabled.")
self.USE_RUNWAYML = False # Can't use without SDK if that's the implemented path
else: # No API key provided
self.USE_RUNWAYML = False; self.runway_ml_sdk_client_instance = None
logger.info("RunwayML Service Disabled (no API key provided to set_runway_api_key).")
# --- Helper Methods (_image_to_data_uri, _map_resolution_to_runway_ratio, etc.) ---
def _image_to_data_uri(self, image_path):
try:
mime_type, _ = mimetypes.guess_type(image_path)
if not mime_type:
ext = os.path.splitext(image_path)[1].lower()
mime_map = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp"}
mime_type = mime_map.get(ext, "application/octet-stream")
if mime_type == "application/octet-stream": logger.warning(f"Could not determine MIME type for {image_path}, using default.")
with open(image_path, "rb") as image_file: encoded_string = base64.b64encode(image_file.read()).decode('utf-8')
data_uri = f"data:{mime_type};base64,{encoded_string}"
logger.debug(f"Generated data URI for {os.path.basename(image_path)} (first 100 chars): {data_uri[:100]}...")
return data_uri
except FileNotFoundError: logger.error(f"Image file not found at {image_path} for data URI conversion."); return None
except Exception as e: logger.error(f"Error converting image {image_path} to data URI: {e}", exc_info=True); return None
def _map_resolution_to_runway_ratio(self, width, height):
ratio_str = f"{width}:{height}"
supported_ratios_gen4 = ["1280:720", "720:1280", "1104:832", "832:1104", "960:960", "1584:672"]
if ratio_str in supported_ratios_gen4: return ratio_str
logger.warning(f"Resolution {ratio_str} not directly in Gen-4 supported list. Defaulting to 1280:720 for RunwayML.")
return "1280:720"
def _get_text_dimensions(self, text_content, font_object_pil):
default_h = getattr(font_object_pil, 'size', self.active_font_size_pil)
if not text_content: return 0, default_h
try:
if hasattr(font_object_pil,'getbbox'): bbox=font_object_pil.getbbox(text_content);w=bbox[2]-bbox[0];h=bbox[3]-bbox[1]; return w, h if h > 0 else default_h
elif hasattr(font_object_pil,'getsize'): w,h=font_object_pil.getsize(text_content); return w, h if h > 0 else default_h
else: return int(len(text_content)*default_h*0.6),int(default_h*1.2) # Basic estimate
except Exception as e_getdim: logger.warning(f"Error in _get_text_dimensions: {e_getdim}"); return int(len(text_content)*self.active_font_size_pil*0.6),int(self.active_font_size_pil*1.2)
def _create_placeholder_image_content(self, text_description, filename, size=None):
# <<< THIS IS THE CORRECTED VERSION OF THIS METHOD >>>
if size is None: size = self.video_frame_size
img = Image.new('RGB', size, color=(20, 20, 40)); d = ImageDraw.Draw(img); padding = 25
max_w = size[0] - (2 * padding); lines_for_placeholder = [] # Renamed to avoid conflict
if not text_description: text_description = "(Placeholder Image)"
words_list = text_description.split(); current_line_buffer = "" # Renamed
for word_idx, word_item in enumerate(words_list): # Renamed
prospective_add = word_item + (" " if word_idx < len(words_list) - 1 else "")
test_line_candidate = current_line_buffer + prospective_add
current_w_text, _ = self._get_text_dimensions(test_line_candidate, self.active_font_pil)
if current_w_text == 0 and test_line_candidate.strip(): current_w_text = len(test_line_candidate) * (self.active_font_size_pil * 0.6)
if current_w_text <= max_w: current_line_buffer = test_line_candidate
else:
if current_line_buffer.strip(): lines_for_placeholder.append(current_line_buffer.strip())
current_line_buffer = prospective_add
if current_line_buffer.strip(): lines_for_placeholder.append(current_line_buffer.strip())
if not lines_for_placeholder and text_description:
avg_char_w_est, _ = self._get_text_dimensions("W", self.active_font_pil); avg_char_w_est = avg_char_w_est or (self.active_font_size_pil * 0.6)
chars_per_line_est = int(max_w / avg_char_w_est) if avg_char_w_est > 0 else 20
lines_for_placeholder.append(text_description[:chars_per_line_est] + ("..." if len(text_description) > chars_per_line_est else ""))
elif not lines_for_placeholder: lines_for_placeholder.append("(Placeholder Error)")
_, single_h = self._get_text_dimensions("Ay", self.active_font_pil); single_h = single_h if single_h > 0 else self.active_font_size_pil + 2
max_l = min(len(lines_for_placeholder), (size[1] - (2 * padding)) // (single_h + 2)) if single_h > 0 else 1; max_l = max(1, max_l)
y_p = padding + (size[1] - (2 * padding) - max_l * (single_h + 2)) / 2.0
for i_line in range(max_l): # Renamed
line_txt_content = lines_for_placeholder[i_line]; line_w_val, _ = self._get_text_dimensions(line_txt_content, self.active_font_pil)
if line_w_val == 0 and line_txt_content.strip(): line_w_val = len(line_txt_content) * (self.active_font_size_pil * 0.6)
x_p = (size[0] - line_w_val) / 2.0
try: d.text((x_p, y_p), line_txt_content, font=self.active_font_pil, fill=(200, 200, 180))
except Exception as e_draw_txt: logger.error(f"Pillow d.text error: {e_draw_txt} for '{line_txt_content}'")
y_p += single_h + 2
if i_line == 6 and max_l > 7:
try: d.text((x_p, y_p), "...", font=self.active_font_pil, fill=(200, 200, 180))
except Exception as e_elps: logger.error(f"Pillow d.text ellipsis error: {e_elps}"); break
filepath_placeholder = os.path.join(self.output_dir, filename) # Renamed
try: img.save(filepath_placeholder); return filepath_placeholder
except Exception as e_save_ph: logger.error(f"Saving placeholder image '{filepath_placeholder}' error: {e_save_ph}", exc_info=True); return None
def _search_pexels_image(self, query_str, output_fn_base):
# (Corrected from previous response)
if not self.USE_PEXELS or not self.pexels_api_key: return None
http_headers = {"Authorization": self.pexels_api_key}
http_params = {"query": query_str, "per_page": 1, "orientation": "landscape", "size": "large2x"}
base_name_px, _ = os.path.splitext(output_fn_base)
pexels_fn_str = base_name_px + f"_pexels_{random.randint(1000,9999)}.jpg"
file_path_px = os.path.join(self.output_dir, pexels_fn_str)
try:
logger.info(f"Pexels: Searching for '{query_str}'")
eff_query_px = " ".join(query_str.split()[:5])
http_params["query"] = eff_query_px
response_px = requests.get("https://api.pexels.com/v1/search", headers=http_headers, params=http_params, timeout=20)
response_px.raise_for_status()
data_px = response_px.json()
if data_px.get("photos") and len(data_px["photos"]) > 0:
photo_details_px = data_px["photos"][0]
photo_url_px = photo_details_px.get("src", {}).get("large2x")
if not photo_url_px: logger.warning(f"Pexels: 'large2x' URL missing for '{eff_query_px}'. Details: {photo_details_px}"); return None
image_response_px = requests.get(photo_url_px, timeout=60); image_response_px.raise_for_status()
img_pil_data_px = Image.open(io.BytesIO(image_response_px.content))
if img_pil_data_px.mode != 'RGB': img_pil_data_px = img_pil_data_px.convert('RGB')
img_pil_data_px.save(file_path_px); logger.info(f"Pexels: Image saved to {file_path_px}"); return file_path_px
else: logger.info(f"Pexels: No photos for '{eff_query_px}'."); return None
except requests.exceptions.RequestException as e_req_px: logger.error(f"Pexels: RequestException for '{query_str}': {e_req_px}", exc_info=False); return None
except Exception as e_px_gen: logger.error(f"Pexels: General error for '{query_str}': {e_px_gen}", exc_info=True); return None
def _generate_video_clip_with_runwayml(self, text_prompt_for_motion, input_image_path, scene_identifier_filename_base, target_duration_seconds=5):
# (Updated RunwayML integration from before)
if not self.USE_RUNWAYML or not self.runway_ml_sdk_client_instance: logger.warning("RunwayML not enabled/client not init. Skip video."); return None
if not input_image_path or not os.path.exists(input_image_path): logger.error(f"Runway Gen-4 needs input image. Path invalid: {input_image_path}"); return None
image_data_uri_str = self._image_to_data_uri(input_image_path) # Renamed
if not image_data_uri_str: return None
runway_dur = 10 if target_duration_seconds >= 8 else 5
runway_ratio = self._map_resolution_to_runway_ratio(self.video_frame_size[0], self.video_frame_size[1])
base_name_for_runway_vid, _ = os.path.splitext(scene_identifier_filename_base); output_vid_fn = base_name_for_runway_vid + f"_runway_gen4_d{runway_dur}s.mp4" # Renamed
output_vid_fp = os.path.join(self.output_dir, output_vid_fn) # Renamed
logger.info(f"Runway Gen-4 task: motion='{text_prompt_for_motion[:100]}...', img='{os.path.basename(input_image_path)}', dur={runway_dur}s, ratio='{runway_ratio}'")
try:
task_submitted_runway = self.runway_ml_sdk_client_instance.image_to_video.create(model='gen4_turbo', prompt_image=image_data_uri_str, prompt_text=text_prompt_for_motion, duration=runway_dur, ratio=runway_ratio) # Renamed
task_id_runway = task_submitted_runway.id; logger.info(f"Runway Gen-4 task ID: {task_id_runway}. Polling...") # Renamed
poll_sec=10; max_poll_count=36; poll_start_time = time.time() # Renamed
while time.time() - poll_start_time < max_poll_count * poll_sec:
time.sleep(poll_sec); task_details_runway = self.runway_ml_sdk_client_instance.tasks.retrieve(id=task_id_runway) # Renamed
logger.info(f"Runway task {task_id_runway} status: {task_details_runway.status}")
if task_details_runway.status == 'SUCCEEDED':
output_url_runway = getattr(getattr(task_details_runway,'output',None),'url',None) or (getattr(task_details_runway,'artifacts',None) and task_details_runway.artifacts[0].url if task_details_runway.artifacts and hasattr(task_details_runway.artifacts[0],'url') else None) or (getattr(task_details_runway,'artifacts',None) and task_details_runway.artifacts[0].download_url if task_details_runway.artifacts and hasattr(task_details_runway.artifacts[0],'download_url') else None) # Renamed
if not output_url_runway: logger.error(f"Runway task {task_id_runway} SUCCEEDED, but no output URL. Details: {vars(task_details_runway) if hasattr(task_details_runway,'__dict__') else task_details_runway}"); return None
logger.info(f"Runway task {task_id_runway} SUCCEEDED. Downloading: {output_url_runway}")
video_resp_get = requests.get(output_url_runway, stream=True, timeout=300); video_resp_get.raise_for_status() # Renamed
with open(output_vid_fp,'wb') as f_vid: # Renamed
for chunk_data in video_resp_get.iter_content(chunk_size=8192): f_vid.write(chunk_data) # Renamed
logger.info(f"Runway Gen-4 video saved: {output_vid_fp}"); return output_vid_fp
elif task_details_runway.status in ['FAILED','ABORTED','ERROR']:
err_msg_runway = getattr(task_details_runway,'error_message',None) or getattr(getattr(task_details_runway,'output',None),'error',"Unknown Runway error.") # Renamed
logger.error(f"Runway task {task_id_runway} status: {task_details_runway.status}. Error: {err_msg_runway}"); return None
logger.warning(f"Runway task {task_id_runway} timed out."); return None
except AttributeError as ae_sdk: logger.error(f"RunwayML SDK AttrError: {ae_sdk}. SDK/methods changed?", exc_info=True); return None # Renamed
except Exception as e_runway_gen: logger.error(f"Runway Gen-4 API error: {e_runway_gen}", exc_info=True); return None # Renamed
def _create_placeholder_video_content(self, text_desc_ph, filename_ph, duration_ph=4, size_ph=None): # Renamed variables
# <<< THIS IS THE CORRECTED METHOD >>>
if size_ph is None: size_ph = self.video_frame_size
filepath_ph = os.path.join(self.output_dir, filename_ph)
text_clip_ph = None # Initialize
try:
text_clip_ph = TextClip(text_desc_ph, fontsize=50, color='white', font=self.video_overlay_font,
bg_color='black', size=size_ph, method='caption').set_duration(duration_ph)
text_clip_ph.write_videofile(filepath_ph, fps=24, codec='libx264', preset='ultrafast', logger=None, threads=2)
logger.info(f"Generic placeholder video created: {filepath_ph}")
return filepath_ph
except Exception as e_ph_vid: # Specific exception variable
logger.error(f"Failed to create generic placeholder video '{filepath_ph}': {e_ph_vid}", exc_info=True)
return None
finally:
if text_clip_ph and hasattr(text_clip_ph, 'close'):
text_clip_ph.close()
# --- generate_scene_asset (Main asset generation logic) ---
def generate_scene_asset(self, image_generation_prompt_text, motion_prompt_text_for_video,
scene_data_dict, scene_identifier_fn_base, # Renamed
generate_as_video_clip_flag=False, runway_target_dur_val=5): # Renamed
# (Logic mostly as before, ensuring base image is robustly generated first)
base_name_asset, _ = os.path.splitext(scene_identifier_fn_base) # Renamed
asset_info_result = {'path': None, 'type': 'none', 'error': True, 'prompt_used': image_generation_prompt_text, 'error_message': 'Asset generation init failed'} # Renamed
path_for_input_image_runway = None # Renamed
fn_for_base_image = base_name_asset + ("_base_for_video.png" if generate_as_video_clip_flag else ".png") # Renamed
fp_for_base_image = os.path.join(self.output_dir, fn_for_base_image) # Renamed
if self.USE_AI_IMAGE_GENERATION and self.openai_api_key:
# (DALL-E logic with corrected try/except from previous response)
max_r_dalle, att_n_dalle = 2,0;
for att_n_dalle in range(max_r_dalle):
att_c_dalle = att_n_dalle + 1 # Renamed
try:
logger.info(f"Att {att_c_dalle} DALL-E (base img): {image_generation_prompt_text[:70]}...");
oai_cl = openai.OpenAI(api_key=self.openai_api_key,timeout=90.0) # Renamed
oai_r = oai_cl.images.generate(model=self.dalle_model,prompt=image_generation_prompt_text,n=1,size=self.image_size_dalle3,quality="hd",response_format="url",style="vivid") # Renamed
oai_iu = oai_r.data[0].url; oai_rp = getattr(oai_r.data[0],'revised_prompt',None) # Renamed
if oai_rp: logger.info(f"DALL-E revised: {oai_rp[:70]}...")
oai_ir = requests.get(oai_iu,timeout=120); oai_ir.raise_for_status() # Renamed
oai_id = Image.open(io.BytesIO(oai_ir.content)); # Renamed
if oai_id.mode!='RGB': oai_id=oai_id.convert('RGB')
oai_id.save(fp_for_base_image); logger.info(f"DALL-E base img saved: {fp_for_base_image}")
path_for_input_image_runway=fp_for_base_image
asset_info_result={'path':fp_for_base_image,'type':'image','error':False,'prompt_used':image_generation_prompt_text,'revised_prompt':oai_rp}
break
except openai.RateLimitError as e_oai_rl: logger.warning(f"OpenAI RateLimit Att {att_c_dalle}:{e_oai_rl}.Retry...");time.sleep(5*att_c_dalle);asset_info_result['error_message']=str(e_oai_rl) # Renamed
except openai.APIError as e_oai_api: logger.error(f"OpenAI APIError Att {att_c_dalle}:{e_oai_api}");asset_info_result['error_message']=str(e_oai_api);break # Renamed
except requests.exceptions.RequestException as e_oai_req: logger.error(f"Requests Err DALL-E Att {att_c_dalle}:{e_oai_req}");asset_info_result['error_message']=str(e_oai_req);break # Renamed
except Exception as e_oai_gen: logger.error(f"General DALL-E Err Att {att_c_dalle}:{e_oai_gen}",exc_info=True);asset_info_result['error_message']=str(e_oai_gen);break # Renamed
if asset_info_result['error']: logger.warning(f"DALL-E failed after {att_c_dalle} attempts for base img.")
if asset_info_result['error'] and self.USE_PEXELS:
logger.info("Trying Pexels for base img.");px_qt=scene_data_dict.get('pexels_search_query_๊ฐ๋
',f"{scene_data_dict.get('emotional_beat','')} {scene_data_dict.get('setting_description','')}");px_pp=self._search_pexels_image(px_qt,fn_for_base_image); # Renamed variables
if px_pp:path_for_input_image_runway=px_pp;asset_info_result={'path':px_pp,'type':'image','error':False,'prompt_used':f"Pexels:{px_qt}"}
else:current_em_px=asset_info_result.get('error_message',"");asset_info_result['error_message']=(current_em_px+" Pexels failed for base.").strip() # Renamed
if asset_info_result['error']:
logger.warning("Base img (DALL-E/Pexels) failed. Using placeholder.");ph_ppt=asset_info_result.get('prompt_used',image_generation_prompt_text);ph_p=self._create_placeholder_image_content(f"[Base Placeholder]{ph_ppt[:70]}...",fn_for_base_image); # Renamed variables
if ph_p:path_for_input_image_runway=ph_p;asset_info_result={'path':ph_p,'type':'image','error':False,'prompt_used':ph_ppt}
else:current_em_ph=asset_info_result.get('error_message',"");asset_info_result['error_message']=(current_em_ph+" Base placeholder failed.").strip() # Renamed
if generate_as_video_clip_flag:
if not path_for_input_image_runway:logger.error("RunwayML video: base img failed.");asset_info_result['error']=True;asset_info_result['error_message']=(asset_info_result.get('error_message',"")+" Base img miss, Runway abort.").strip();asset_info_result['type']='none';return asset_info_result
if self.USE_RUNWAYML:
runway_video_p=self._generate_video_clip_with_runwayml(motion_prompt_text_for_video,path_for_input_image_runway,base_name_asset,runway_target_dur_val) # Renamed
if runway_video_p and os.path.exists(runway_video_p):asset_info_result={'path':runway_video_p,'type':'video','error':False,'prompt_used':motion_prompt_text_for_video,'base_image_path':path_for_input_image_runway}
else:logger.warning(f"RunwayML video failed for {base_name_asset}. Fallback to base img.");asset_info_result['error']=True;asset_info_result['error_message']=(asset_info_result.get('error_message',"Base img ok.")+" RunwayML video fail; use base img.").strip();asset_info_result['path']=path_for_input_image_runway;asset_info_result['type']='image';asset_info_result['prompt_used']=image_generation_prompt_text
else:logger.warning("RunwayML selected but disabled. Use base img.");asset_info_result['error']=True;asset_info_result['error_message']=(asset_info_result.get('error_message',"Base img ok.")+" RunwayML disabled; use base img.").strip();asset_info_result['path']=path_for_input_image_runway;asset_info_result['type']='image';asset_info_result['prompt_used']=image_generation_prompt_text
return asset_info_result
def generate_narration_audio(self, text_to_narrate, output_filename="narration_overall.mp3"):
# (Keep as before)
if not self.USE_ELEVENLABS or not self.elevenlabs_client_instance or not text_to_narrate: logger.info("11L skip."); return None # Check instance
audio_fp_11l=os.path.join(self.output_dir,output_filename) # Renamed
try: logger.info(f"11L audio (Voice:{self.elevenlabs_voice_id}): {text_to_narrate[:70]}..."); audio_sm_11l=None # Renamed
if hasattr(self.elevenlabs_client_instance,'text_to_speech')and hasattr(self.elevenlabs_client_instance.text_to_speech,'stream'):audio_sm_11l=self.elevenlabs_client_instance.text_to_speech.stream;logger.info("Using 11L .text_to_speech.stream()")
elif hasattr(self.elevenlabs_client_instance,'generate_stream'):audio_sm_11l=self.elevenlabs_client_instance.generate_stream;logger.info("Using 11L .generate_stream()")
elif hasattr(self.elevenlabs_client_instance,'generate'):logger.info("Using 11L .generate()");eleven_vp=Voice(voice_id=str(self.elevenlabs_voice_id),settings=self.elevenlabs_voice_settings_obj)if Voice and self.elevenlabs_voice_settings_obj else str(self.elevenlabs_voice_id);eleven_ab=self.elevenlabs_client_instance.generate(text=text_to_narrate,voice=eleven_vp,model="eleven_multilingual_v2"); # Renamed
with open(audio_fp_11l,"wb")as f_11l:f_11l.write(eleven_ab);logger.info(f"11L audio (non-stream): {audio_fp_11l}");return audio_fp_11l # Renamed
else:logger.error("No 11L audio method.");return None
if audio_sm_11l:eleven_vps={"voice_id":str(self.elevenlabs_voice_id)} # Renamed
if self.elevenlabs_voice_settings_obj:
if hasattr(self.elevenlabs_voice_settings_obj,'model_dump'):eleven_vps["voice_settings"]=self.elevenlabs_voice_settings_obj.model_dump()
elif hasattr(self.elevenlabs_voice_settings_obj,'dict'):eleven_vps["voice_settings"]=self.elevenlabs_voice_settings_obj.dict()
else:eleven_vps["voice_settings"]=self.elevenlabs_voice_settings_obj
eleven_adi=audio_sm_11l(text=text_to_narrate,model_id="eleven_multilingual_v2",**eleven_vps) # Renamed
with open(audio_fp_11l,"wb")as f_11l_stream: # Renamed
for chunk_11l in eleven_adi: # Renamed
if chunk_11l:f_11l_stream.write(chunk_11l)
logger.info(f"11L audio (stream): {audio_fp_11l}");return audio_fp_11l
except Exception as e_11labs_audio:logger.error(f"11L audio error: {e_11labs_audio}",exc_info=True);return None # Renamed
def assemble_animatic_from_assets(self, asset_data_list, overall_narration_path=None, output_filename="final_video.mp4", fps=24):
# (Keep as in the version with robust image processing, C-contiguous array, debug saves, and pix_fmt)
# ... (This extensive method is assumed to be largely correct from the previous iteration focusing on blank video issues)
# ... (The core of that robust version should be retained here) ...
# For brevity, I'm not re-pasting the entire assemble_animatic_from_assets if it was correct before this syntax error hunt.
# If it also needs review, let me know. The key is that the *input* (asset_data_list)
# from the corrected generate_scene_asset is now more reliable.
if not asset_data_list: logger.warning("No assets for animatic."); return None
processed_moviepy_clips_list = []; narration_audio_clip_mvpy = None; final_video_output_clip = None # Renamed variables
logger.info(f"Assembling from {len(asset_data_list)} assets. Target Frame: {self.video_frame_size}.")
for i_asset, asset_info_item_loop in enumerate(asset_data_list): # Renamed loop variables
path_of_asset, type_of_asset, duration_for_scene = asset_info_item_loop.get('path'), asset_info_item_loop.get('type'), asset_info_item_loop.get('duration', 4.5)
num_of_scene, action_in_key = asset_info_item_loop.get('scene_num', i_asset + 1), asset_info_item_loop.get('key_action', '')
logger.info(f"S{num_of_scene}: Path='{path_of_asset}', Type='{type_of_asset}', Dur='{duration_for_scene}'s")
if not (path_of_asset and os.path.exists(path_of_asset)): logger.warning(f"S{num_of_scene}: Not found '{path_of_asset}'. Skip."); continue
if duration_for_scene <= 0: logger.warning(f"S{num_of_scene}: Invalid duration ({duration_for_scene}s). Skip."); continue
active_scene_clip = None # Clip for this iteration
try:
if type_of_asset == 'image':
# (Robust image processing from previous version, including debug saves)
opened_pil_img = Image.open(path_of_asset); logger.debug(f"S{num_of_scene}: Loaded img. Mode:{opened_pil_img.mode}, Size:{opened_pil_img.size}")
converted_img_rgba = opened_pil_img.convert('RGBA') if opened_pil_img.mode != 'RGBA' else opened_pil_img.copy()
thumbnailed_img = converted_img_rgba.copy(); resample_f = Image.Resampling.LANCZOS if hasattr(Image.Resampling,'LANCZOS') else Image.BILINEAR; thumbnailed_img.thumbnail(self.video_frame_size,resample_f)
rgba_canvas = Image.new('RGBA',self.video_frame_size,(0,0,0,0)); pos_x,pos_y=(self.video_frame_size[0]-thumbnailed_img.width)//2,(self.video_frame_size[1]-thumbnailed_img.height)//2
rgba_canvas.paste(thumbnailed_img,(pos_x,pos_y),thumbnailed_img)
final_rgb_img_pil = Image.new("RGB",self.video_frame_size,(0,0,0)); final_rgb_img_pil.paste(rgba_canvas,mask=rgba_canvas.split()[3])
debug_path_img_pre_numpy = os.path.join(self.output_dir,f"debug_PRE_NUMPY_S{num_of_scene}.png"); final_rgb_img_pil.save(debug_path_img_pre_numpy); logger.info(f"DEBUG: Saved PRE_NUMPY_S{num_of_scene} to {debug_path_img_pre_numpy}")
numpy_frame_arr = np.array(final_rgb_img_pil,dtype=np.uint8);
if not numpy_frame_arr.flags['C_CONTIGUOUS']: numpy_frame_arr=np.ascontiguousarray(numpy_frame_arr,dtype=np.uint8)
logger.debug(f"S{num_of_scene}: NumPy for MoviePy. Shape:{numpy_frame_arr.shape}, DType:{numpy_frame_arr.dtype}, C-Contig:{numpy_frame_arr.flags['C_CONTIGUOUS']}")
if numpy_frame_arr.size==0 or numpy_frame_arr.ndim!=3 or numpy_frame_arr.shape[2]!=3: logger.error(f"S{num_of_scene}: Invalid NumPy array for MoviePy. Skip."); continue
base_image_clip = ImageClip(numpy_frame_arr,transparent=False).set_duration(duration_for_scene)
debug_path_moviepy_frame=os.path.join(self.output_dir,f"debug_MOVIEPY_FRAME_S{num_of_scene}.png"); base_image_clip.save_frame(debug_path_moviepy_frame,t=0.1); logger.info(f"DEBUG: Saved MOVIEPY_FRAME_S{num_of_scene} to {debug_path_moviepy_frame}")
fx_image_clip = base_image_clip
try: scale_end_kb=random.uniform(1.03,1.08); fx_image_clip=base_image_clip.fx(vfx.resize,lambda t_val:1+(scale_end_kb-1)*(t_val/duration_for_scene) if duration_for_scene>0 else 1).set_position('center')
except Exception as e_kb_fx: logger.error(f"S{num_of_scene} Ken Burns error: {e_kb_fx}",exc_info=False)
active_scene_clip = fx_image_clip
elif type_of_asset == 'video':
# (Video processing logic from previous version)
source_video_clip_obj=None
try:
source_video_clip_obj=VideoFileClip(path_of_asset,target_resolution=(self.video_frame_size[1],self.video_frame_size[0])if self.video_frame_size else None, audio=False)
temp_video_clip_obj_loop=source_video_clip_obj
if source_video_clip_obj.duration!=duration_for_scene:
if source_video_clip_obj.duration>duration_for_scene:temp_video_clip_obj_loop=source_video_clip_obj.subclip(0,duration_for_scene)
else:
if duration_for_scene/source_video_clip_obj.duration > 1.5 and source_video_clip_obj.duration>0.1:temp_video_clip_obj_loop=source_video_clip_obj.loop(duration=duration_for_scene)
else:temp_video_clip_obj_loop=source_video_clip_obj.set_duration(source_video_clip_obj.duration);logger.info(f"S{num_of_scene} Video clip ({source_video_clip_obj.duration:.2f}s) shorter than target ({duration_for_scene:.2f}s).")
active_scene_clip=temp_video_clip_obj_loop.set_duration(duration_for_scene)
if active_scene_clip.size!=list(self.video_frame_size):active_scene_clip=active_scene_clip.resize(self.video_frame_size)
except Exception as e_vid_load_loop:logger.error(f"S{num_of_scene} Video load error '{path_of_asset}':{e_vid_load_loop}",exc_info=True);continue
finally:
if source_video_clip_obj and source_video_clip_obj is not active_scene_clip and hasattr(source_video_clip_obj,'close'):source_video_clip_obj.close()
else: logger.warning(f"S{num_of_scene} Unknown asset type '{type_of_asset}'. Skip."); continue
if active_scene_clip and action_in_key: # Text Overlay
try:
dur_text_overlay=min(active_scene_clip.duration-0.5,active_scene_clip.duration*0.8)if active_scene_clip.duration>0.5 else active_scene_clip.duration
start_text_overlay=0.25
if dur_text_overlay > 0:
text_clip_for_overlay=TextClip(f"Scene {num_of_scene}\n{action_in_key}",fontsize=self.VIDEO_OVERLAY_FONT_SIZE,color=self.VIDEO_OVERLAY_FONT_COLOR,font=self.active_moviepy_font_name,bg_color='rgba(10,10,20,0.7)',method='caption',align='West',size=(self.video_frame_size[0]*0.9,None),kerning=-1,stroke_color='black',stroke_width=1.5).set_duration(dur_text_overlay).set_start(start_text_overlay).set_position(('center',0.92),relative=True)
active_scene_clip=CompositeVideoClip([active_scene_clip,text_clip_for_overlay],size=self.video_frame_size,use_bgclip=True)
else: logger.warning(f"S{num_of_scene}: Text overlay duration zero. Skip text.")
except Exception as e_txt_comp:logger.error(f"S{num_of_scene} TextClip error:{e_txt_comp}. No text.",exc_info=True)
if active_scene_clip:processed_moviepy_clips_list.append(active_scene_clip);logger.info(f"S{num_of_scene} Processed. Dur:{active_scene_clip.duration:.2f}s.")
except Exception as e_asset_loop_main:logger.error(f"MAJOR Error processing asset for S{num_of_scene} ({path_of_asset}):{e_asset_loop_main}",exc_info=True)
finally:
if active_scene_clip and hasattr(active_scene_clip,'close'):
try: active_scene_clip.close()
except: pass # Ignore errors during cleanup
if not processed_moviepy_clips_list:logger.warning("No clips processed for animatic. Aborting.");return None
transition_duration_val=0.75 # Renamed
try:
logger.info(f"Concatenating {len(processed_moviepy_clips_list)} clips for final animatic.");
if len(processed_moviepy_clips_list)>1:final_video_output_clip=concatenate_videoclips(processed_moviepy_clips_list,padding=-transition_duration_val if transition_duration_val>0 else 0,method="compose")
elif processed_moviepy_clips_list:final_video_output_clip=processed_moviepy_clips_list[0]
if not final_video_output_clip:logger.error("Concatenation resulted in a None clip. Aborting.");return None
logger.info(f"Concatenated animatic duration:{final_video_output_clip.duration:.2f}s")
if transition_duration_val>0 and final_video_output_clip.duration>0:
if final_video_output_clip.duration>transition_duration_val*2:final_video_output_clip=final_video_output_clip.fx(vfx.fadein,transition_duration_val).fx(vfx.fadeout,transition_duration_val)
else:final_video_output_clip=final_video_output_clip.fx(vfx.fadein,min(transition_duration_val,final_video_output_clip.duration/2.0))
if overall_narration_path and os.path.exists(overall_narration_path) and final_video_output_clip.duration>0:
try:narration_audio_clip_mvpy=AudioFileClip(overall_narration_path);final_video_output_clip=final_video_output_clip.set_audio(narration_audio_clip_mvpy);logger.info("Overall narration added to animatic.")
except Exception as e_narr_add:logger.error(f"Error adding narration to animatic:{e_narr_add}",exc_info=True)
elif final_video_output_clip.duration<=0:logger.warning("Animatic has no duration. Audio not added.")
if final_video_output_clip and final_video_output_clip.duration>0:
final_output_path_str=os.path.join(self.output_dir,output_filename);logger.info(f"Writing final animatic video to:{final_output_path_str} (Duration:{final_video_output_clip.duration:.2f}s)") # Renamed
final_video_output_clip.write_videofile(final_output_path_str,fps=fps,codec='libx264',preset='medium',audio_codec='aac',temp_audiofile=os.path.join(self.output_dir,f'temp-audio-{os.urandom(4).hex()}.m4a'),remove_temp=True,threads=os.cpu_count()or 2,logger='bar',bitrate="5000k",ffmpeg_params=["-pix_fmt", "yuv420p"])
logger.info(f"Animatic video created successfully:{final_output_path_str}");return final_output_path_str
else:logger.error("Final animatic clip is invalid or has zero duration. Cannot write video file.");return None
except Exception as e_vid_write_final:logger.error(f"Error during final animatic video file writing or composition:{e_vid_write_final}",exc_info=True);return None # Renamed
finally:
logger.debug("Closing all MoviePy clips in `assemble_animatic_from_assets` main finally block.")
clips_for_final_closure = processed_moviepy_clips_list + ([narration_audio_clip_mvpy] if narration_audio_clip_mvpy else []) + ([final_video_output_clip] if final_video_output_clip else []) # Renamed
for clip_item_to_close in clips_for_final_closure: # Renamed
if clip_item_to_close and hasattr(clip_item_to_close, 'close'):
try: clip_item_to_close.close()
except Exception as e_final_clip_close: logger.warning(f"Ignoring error while closing a MoviePy clip: {type(clip_item_to_close).__name__} - {e_final_clip_close}") |