mgbam commited on
Commit
d1bb1cc
·
verified ·
1 Parent(s): cd8e0e1

Update core/visual_engine.py

Browse files
Files changed (1) hide show
  1. core/visual_engine.py +456 -340
core/visual_engine.py CHANGED
@@ -1,402 +1,518 @@
1
  # core/visual_engine.py
2
  from PIL import Image, ImageDraw, ImageFont, ImageOps
3
- # --- MONKEY PATCH FOR Image.ANTIALIAS ---
4
- try:
5
- if hasattr(Image, 'Resampling') and hasattr(Image.Resampling, 'LANCZOS'): # Pillow 9+
6
- if not hasattr(Image, 'ANTIALIAS'): Image.ANTIALIAS = Image.Resampling.LANCZOS
7
- elif hasattr(Image, 'LANCZOS'): # Pillow 8
8
- if not hasattr(Image, 'ANTIALIAS'): Image.ANTIALIAS = Image.LANCZOS
9
- elif not hasattr(Image, 'ANTIALIAS'):
10
- print("WARNING: Pillow version lacks common Resampling attributes or ANTIALIAS. Video effects might fail.")
11
- except Exception as e_mp: print(f"WARNING: ANTIALIAS monkey-patch error: {e_mp}")
12
- # --- END MONKEY PATCH ---
13
-
14
- from moviepy.editor import (ImageClip, VideoFileClip, concatenate_videoclips, TextClip,
15
- CompositeVideoClip, AudioFileClip)
16
- import moviepy.video.fx.all as vfx
17
  import numpy as np
18
  import os
19
- import openai
20
  import requests
21
  import io
22
  import time
23
  import random
24
  import logging
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  logger = logging.getLogger(__name__)
27
- logger.setLevel(logging.INFO)
 
28
 
29
- # --- ElevenLabs Client Import ---
30
- ELEVENLABS_CLIENT_IMPORTED = False; ElevenLabsAPIClient = None; Voice = None; VoiceSettings = None
 
 
 
31
  try:
32
  from elevenlabs.client import ElevenLabs as ImportedElevenLabsClient
33
  from elevenlabs import Voice as ImportedVoice, VoiceSettings as ImportedVoiceSettings
34
- ElevenLabsAPIClient = ImportedElevenLabsClient; Voice = ImportedVoice; VoiceSettings = ImportedVoiceSettings
35
- ELEVENLABS_CLIENT_IMPORTED = True; logger.info("ElevenLabs client components imported.")
36
- except Exception as e_eleven: logger.warning(f"ElevenLabs client import failed: {e_eleven}. Audio disabled.")
 
 
 
 
 
 
37
 
38
- # --- RunwayML Client Import (Placeholder) ---
39
- RUNWAYML_SDK_IMPORTED = False; RunwayMLClient = None
40
  try:
41
- logger.info("RunwayML SDK import is a placeholder.")
42
- except ImportError: logger.warning("RunwayML SDK (placeholder) not found. RunwayML disabled.")
43
- except Exception as e_runway_sdk: logger.warning(f"Error importing RunwayML SDK (placeholder): {e_runway_sdk}. RunwayML disabled.")
 
 
 
 
 
44
 
45
 
46
  class VisualEngine:
 
 
 
 
 
 
 
47
  def __init__(self, output_dir="temp_cinegen_media", default_elevenlabs_voice_id="Rachel"):
48
  self.output_dir = output_dir
49
  os.makedirs(self.output_dir, exist_ok=True)
50
- self.font_filename = "DejaVuSans-Bold.ttf"
 
51
  font_paths_to_try = [
52
- self.font_filename,
53
- f"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
54
- f"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
55
- f"/System/Library/Fonts/Supplemental/Arial.ttf", f"C:/Windows/Fonts/arial.ttf",
56
- f"/usr/local/share/fonts/truetype/mycustomfonts/arial.ttf"
 
57
  ]
58
- self.font_path_pil = next((p for p in font_paths_to_try if os.path.exists(p)), None)
59
- self.font_size_pil = 20
60
- self.video_overlay_font_size = 30
61
- self.video_overlay_font_color = 'white'
62
- self.video_overlay_font = 'DejaVu-Sans-Bold'
63
 
64
- try:
65
- self.font = ImageFont.truetype(self.font_path_pil, self.font_size_pil) if self.font_path_pil else ImageFont.load_default()
66
- if self.font_path_pil: logger.info(f"Pillow font loaded: {self.font_path_pil}.")
67
- else: logger.warning("Using default Pillow font."); self.font_size_pil = 10
68
- except IOError as e_font: logger.error(f"Pillow font loading IOError: {e_font}. Using default."); self.font = ImageFont.load_default(); self.font_size_pil = 10
 
 
 
 
 
 
 
69
 
 
70
  self.openai_api_key = None; self.USE_AI_IMAGE_GENERATION = False
71
- self.dalle_model = "dall-e-3"; self.image_size_dalle3 = "1792x1024"
72
- self.video_frame_size = (1280, 720)
73
- self.elevenlabs_api_key = None; self.USE_ELEVENLABS = False; self.elevenlabs_client = None
 
74
  self.elevenlabs_voice_id = default_elevenlabs_voice_id
75
- if VoiceSettings and ELEVENLABS_CLIENT_IMPORTED: self.elevenlabs_voice_settings = VoiceSettings(stability=0.60, similarity_boost=0.80, style=0.15, use_speaker_boost=True)
76
- else: self.elevenlabs_voice_settings = None
 
 
77
  self.pexels_api_key = None; self.USE_PEXELS = False
78
- self.runway_api_key = None; self.USE_RUNWAYML = False; self.runway_client = None
 
 
 
 
 
 
 
 
 
 
 
79
  logger.info("VisualEngine initialized.")
80
 
81
- def set_openai_api_key(self,k): self.openai_api_key=k; self.USE_AI_IMAGE_GENERATION=bool(k); logger.info(f"DALL-E ({self.dalle_model}) {'Ready.' if k else 'Disabled.'}")
82
- def set_elevenlabs_api_key(self,api_key, voice_id_from_secret=None):
83
- self.elevenlabs_api_key=api_key
 
 
 
 
84
  if voice_id_from_secret: self.elevenlabs_voice_id = voice_id_from_secret
85
- if api_key and ELEVENLABS_CLIENT_IMPORTED and ElevenLabsAPIClient:
86
- try: self.elevenlabs_client = ElevenLabsAPIClient(api_key=api_key); self.USE_ELEVENLABS=bool(self.elevenlabs_client); logger.info(f"ElevenLabs Client {'Ready' if self.USE_ELEVENLABS else 'Failed Init'} (Voice ID: {self.elevenlabs_voice_id}).")
87
- except Exception as e: logger.error(f"ElevenLabs client init error: {e}. Disabled.", exc_info=True); self.USE_ELEVENLABS=False
88
- else: self.USE_ELEVENLABS=False; logger.info("ElevenLabs Disabled (no key or SDK).")
89
- def set_pexels_api_key(self,k): self.pexels_api_key=k; self.USE_PEXELS=bool(k); logger.info(f"Pexels Search {'Ready.' if k else 'Disabled.'}")
90
- def set_runway_api_key(self, k):
91
- self.runway_api_key = k
92
- if k and RUNWAYML_SDK_IMPORTED and RunwayMLClient: # This SDK part is still hypothetical
93
- try: self.USE_RUNWAYML = True; logger.info(f"RunwayML Client (Placeholder SDK) {'Ready.' if self.USE_RUNWAYML else 'Failed Init.'}")
94
- except Exception as e: logger.error(f"RunwayML client (Placeholder SDK) init error: {e}. Disabled.", exc_info=True); self.USE_RUNWAYML = False
95
- elif k: self.USE_RUNWAYML = True; logger.info("RunwayML API Key set (direct API or placeholder).")
96
- else: self.USE_RUNWAYML = False; logger.info("RunwayML Disabled (no API key).")
97
-
98
- def _get_text_dimensions(self, text_content, font_obj):
99
- default_line_height = getattr(font_obj, 'size', self.font_size_pil)
100
- if not text_content: return 0, default_line_height
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  try:
102
- if hasattr(font_obj, 'getbbox'):
103
- bbox = font_obj.getbbox(text_content); width = bbox[2] - bbox[0]; height = bbox[3] - bbox[1]
104
- return width, height if height > 0 else default_line_height
105
- elif hasattr(font_obj, 'getsize'):
106
- width, height = font_obj.getsize(text_content)
107
- return width, height if height > 0 else default_line_height
108
- else: return int(len(text_content) * default_line_height * 0.6), int(default_line_height * 1.2)
109
- except Exception as e: logger.warning(f"Error in _get_text_dimensions for '{text_content[:20]}...': {e}"); return int(len(text_content) * self.font_size_pil * 0.6),int(self.font_size_pil * 1.2)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
 
111
  def _create_placeholder_image_content(self, text_description, filename, size=None):
 
112
  if size is None: size = self.video_frame_size
113
- img = Image.new('RGB', size, color=(20, 20, 40)); draw = ImageDraw.Draw(img)
114
- padding = 25; max_text_width = size[0] - (2 * padding); lines = []
115
- if not text_description: text_description = "(Placeholder: No text description provided)"
116
- words = text_description.split(); current_line = ""
117
- for word in words:
118
- test_line = current_line + word + " "; line_width_test, _ = self._get_text_dimensions(test_line.strip(), self.font)
119
- if line_width_test <= max_text_width: current_line = test_line
 
 
 
 
120
  else:
121
- if current_line.strip(): lines.append(current_line.strip())
122
- word_width, _ = self._get_text_dimensions(word, self.font)
123
- if word_width > max_text_width:
124
- avg_char_w = self._get_text_dimensions("A", self.font)[0] or 10
125
- chars_that_fit = int(max_text_width / avg_char_w) if avg_char_w > 0 else 10
126
- lines.append(word[:chars_that_fit-3] + "..." if len(word) > chars_that_fit else word)
127
- current_line = ""
128
- else: current_line = word + " "
129
- if current_line.strip(): lines.append(current_line.strip())
130
- if not lines and text_description:
131
- avg_char_w = self._get_text_dimensions("A", self.font)[0] or 10; chars_that_fit = int(max_text_width / avg_char_w) if avg_char_w > 0 else 10
132
- lines.append(text_description[:chars_that_fit-3] + "..." if len(text_description) > chars_that_fit else text_description)
133
- elif not lines: lines.append("(Placeholder Text Error)")
134
- _, single_line_height = self._get_text_dimensions("Ay", self.font); single_line_height = single_line_height if single_line_height > 0 else (self.font_size_pil + 2)
135
- line_spacing = 2; max_lines_to_display = min(len(lines), (size[1]-(2*padding))//(single_line_height+line_spacing)) if single_line_height > 0 else 1
136
- if max_lines_to_display <= 0: max_lines_to_display = 1
137
- total_text_block_height = max_lines_to_display * single_line_height + (max_lines_to_display-1)*line_spacing
138
- y_text_start = padding + (size[1]-(2*padding)-total_text_block_height)/2.0; current_y = y_text_start
139
- for i in range(max_lines_to_display):
140
- line_content = lines[i]; line_width_actual, _ = self._get_text_dimensions(line_content, self.font)
141
- x_text = max(padding, (size[0]-line_width_actual)/2.0)
142
- draw.text((x_text, current_y), line_content, font=self.font, fill=(200,200,180)); current_y += single_line_height + line_spacing
143
- if i==6 and max_lines_to_display > 7 and len(lines) > max_lines_to_display:
144
- ellipsis_width, _ = self._get_text_dimensions("...",self.font); x_ellipsis = max(padding, (size[0]-ellipsis_width)/2.0)
145
- draw.text((x_ellipsis, current_y), "...", font=self.font, fill=(200,200,180)); break
146
- filepath = os.path.join(self.output_dir, filename)
147
- try: img.save(filepath); return filepath
148
- except Exception as e: logger.error(f"Error saving placeholder image {filepath}: {e}", exc_info=True); return None
149
-
150
- def _search_pexels_image(self, query, output_filename_base):
151
  if not self.USE_PEXELS or not self.pexels_api_key: return None
152
- headers = {"Authorization": self.pexels_api_key}
153
- params = {"query": query, "per_page": 1, "orientation": "landscape", "size": "large2x"}
154
- base_name, _ = os.path.splitext(output_filename_base)
155
- pexels_filename = base_name + f"_pexels_{random.randint(1000,9999)}.jpg"
156
- filepath = os.path.join(self.output_dir, pexels_filename)
157
  try:
158
- logger.info(f"Pexels search: '{query}'")
159
- effective_query = " ".join(query.split()[:5])
160
- params["query"] = effective_query
161
- response = requests.get("https://api.pexels.com/v1/search", headers=headers, params=params, timeout=20)
162
- response.raise_for_status()
163
- data = response.json()
164
- if data.get("photos") and len(data["photos"]) > 0:
165
- photo_details = data["photos"][0]
166
- photo_url = photo_details["src"]["large2x"]
167
- logger.info(f"Downloading Pexels image from: {photo_url}")
168
- image_response = requests.get(photo_url, timeout=60)
169
- image_response.raise_for_status()
170
- img_data = Image.open(io.BytesIO(image_response.content))
171
- if img_data.mode != 'RGB':
172
- logger.debug(f"Pexels image mode is {img_data.mode}, converting to RGB.")
173
- img_data = img_data.convert('RGB')
174
- img_data.save(filepath)
175
- logger.info(f"Pexels image saved successfully: {filepath}")
176
- return filepath
177
- else:
178
- logger.info(f"No photos found on Pexels for query: '{effective_query}'")
179
- return None
180
- except requests.exceptions.RequestException as e_req: logger.error(f"Pexels request error for query '{query}': {e_req}", exc_info=True)
181
- except json.JSONDecodeError as e_json: logger.error(f"Pexels JSON decode error for query '{query}': {e_json}", exc_info=True)
182
- except Exception as e: logger.error(f"General Pexels error for query '{query}': {e}", exc_info=True)
183
- return None
184
-
185
- def _generate_video_clip_with_runwayml(self, pt, iip, sifnb, tds=5):
186
- if not self.USE_RUNWAYML or not self.runway_api_key: logger.warning("RunwayML disabled."); return None
187
- if not iip or not os.path.exists(iip): logger.error(f"Runway Gen-4 needs input image. Path invalid: {iip}"); return None
188
- runway_dur = 10 if tds > 7 else 5
189
- ovfn = sifnb.replace(".png", f"_runway_gen4_d{runway_dur}s.mp4") # sifnb should be base name
190
- ovfp = os.path.join(self.output_dir, ovfn)
191
- logger.info(f"Runway Gen-4 (Placeholder) img: {os.path.basename(iip)}, motion: '{pt[:100]}...', dur: {runway_dur}s")
192
- logger.warning("Using PLACEHOLDER video for Runway Gen-4.")
193
- img_clip=None; txt_c=None; final_ph_clip=None
194
  try:
195
- img_clip = ImageClip(iip).set_duration(runway_dur)
196
- txt = f"Runway Gen-4 Placeholder\nInput: {os.path.basename(iip)}\nMotion: {pt[:50]}..."
197
- txt_c = TextClip(txt, fontsize=24,color='white',font=self.video_overlay_font,bg_color='rgba(0,0,0,0.5)',size=(self.video_frame_size[0]*0.8,None),method='caption').set_duration(runway_dur).set_position('center')
198
- final_ph_clip = CompositeVideoClip([img_clip, txt_c], size=img_clip.size)
199
- final_ph_clip.write_videofile(ovfp,fps=24,codec='libx264',preset='ultrafast',logger=None,threads=2)
200
- logger.info(f"Runway Gen-4 placeholder video: {ovfp}"); return ovfp
201
- except Exception as e: logger.error(f"Runway Gen-4 placeholder error: {e}",exc_info=True); return None
202
- finally:
203
- if img_clip and hasattr(img_clip,'close'): img_clip.close()
204
- if txt_c and hasattr(txt_c,'close'): txt_c.close()
205
- if final_ph_clip and hasattr(final_ph_clip,'close'): final_ph_clip.close()
206
-
207
- def _create_placeholder_video_content(self, text_description, filename, duration=4, size=None): # Generic placeholder
208
- if size is None:
209
- size = self.video_frame_size
210
- filepath = os.path.join(self.output_dir, filename)
211
- txt_clip = None # Initialize for finally block
 
 
 
 
 
 
 
 
 
212
  try:
213
- txt_clip = TextClip(text_description,
214
- fontsize=50,
215
- color='white',
216
- font=self.video_overlay_font,
217
- bg_color='black',
218
- size=size,
219
- method='caption').set_duration(duration)
220
-
221
- txt_clip.write_videofile(filepath,
222
- fps=24,
223
- codec='libx264',
224
- preset='ultrafast',
225
- logger=None,
226
- threads=2)
227
- logger.info(f"Generic placeholder video created successfully: {filepath}")
228
- return filepath
229
- except Exception as e:
230
- logger.error(f"Failed to create generic placeholder video {filepath}: {e}", exc_info=True)
231
  return None
232
  finally:
233
- if txt_clip and hasattr(txt_clip, 'close'):
234
- try:
235
- txt_clip.close()
236
- except Exception as e_close:
237
- logger.warning(f"Error closing TextClip in _create_placeholder_video_content: {e_close}")
238
-
239
  def generate_scene_asset(self, image_generation_prompt_text, motion_prompt_text_for_video,
240
- scene_data, scene_identifier_filename_base,
241
- generate_as_video_clip=False, runway_target_duration=5):
242
- base_name = scene_identifier_filename_base
243
- asset_info = {'path': None, 'type': 'none', 'error': True, 'prompt_used': image_generation_prompt_text, 'error_message': 'Generation not attempted'}
244
- input_image_for_runway_path = None
245
- image_filename_for_base = base_name + "_base_image.png"
246
- temp_image_asset_info = {'error': True, 'prompt_used': image_generation_prompt_text, 'error_message': 'Base image generation not attempted'}
247
-
248
- if self.USE_AI_IMAGE_GENERATION and self.openai_api_key:
249
- max_r, att_n = 2, 0
250
- for att_n in range(max_r):
251
- try:
252
- img_fp_dalle = os.path.join(self.output_dir, image_filename_for_base)
253
- logger.info(f"Attempt {att_n+1} DALL-E (base img): {image_generation_prompt_text[:100]}...")
254
- cl = openai.OpenAI(api_key=self.openai_api_key, timeout=90.0)
255
- r = 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")
256
- iu = r.data[0].url; rp = getattr(r.data[0], 'revised_prompt', None)
257
- if rp: logger.info(f"DALL-E revised: {rp[:100]}...")
258
- ir = requests.get(iu, timeout=120); ir.raise_for_status()
259
- id_img = Image.open(io.BytesIO(ir.content));
260
- if id_img.mode != 'RGB': id_img = id_img.convert('RGB')
261
- id_img.save(img_fp_dalle); logger.info(f"DALL-E base image: {img_fp_dalle}");
262
- input_image_for_runway_path = img_fp_dalle
263
- temp_image_asset_info = {'path': img_fp_dalle, 'type': 'image', 'error': False, 'prompt_used': image_generation_prompt_text, 'revised_prompt': rp}
264
- break
265
- except openai.RateLimitError as e: logger.warning(f"OpenAI Rate Limit {att_n+1}: {e}. Retry..."); time.sleep(5*(att_n+1)); temp_image_asset_info['error_message']=str(e)
266
- except Exception as e: logger.error(f"DALL-E error: {e}", exc_info=True); temp_image_asset_info['error_message']=str(e); break
267
- if temp_image_asset_info['error']: logger.warning(f"DALL-E failed after {att_n+1} attempts for base image.")
268
 
269
- if temp_image_asset_info['error'] and self.USE_PEXELS:
270
- pqt = scene_data.get('pexels_search_query_감독', f"{scene_data.get('emotional_beat','')} {scene_data.get('setting_description','')}")
271
- pp = self._search_pexels_image(pqt, image_filename_for_base)
272
- if pp: input_image_for_runway_path = pp; temp_image_asset_info = {'path': pp, 'type': 'image', 'error': False, 'prompt_used': f"Pexels: {pqt}"}
273
- else: current_em = temp_image_asset_info.get('error_message',""); temp_image_asset_info['error_message']=(current_em + " Pexels failed.").strip()
274
-
275
- if temp_image_asset_info['error']:
276
- logger.warning("Base image (DALL-E/Pexels) failed. Placeholder base image.")
277
- ppt = temp_image_asset_info.get('prompt_used', image_generation_prompt_text)
278
- php = self._create_placeholder_image_content(f"[Base Img Placeholder] {ppt[:100]}...", image_filename_for_base) # Use image_filename_for_base
279
- if php: input_image_for_runway_path = php; temp_image_asset_info = {'path': php, 'type': 'image', 'error': False, 'prompt_used': ppt}
280
- else: current_em=temp_image_asset_info.get('error_message',"");temp_image_asset_info['error_message']=(current_em + " Base placeholder failed.").strip()
 
 
 
 
 
 
 
 
 
 
 
281
 
282
- if generate_as_video_clip:
283
- if self.USE_RUNWAYML and input_image_for_runway_path:
284
- video_path = self._generate_video_clip_with_runwayml(motion_prompt_text_for_video, input_image_for_runway_path, base_name, runway_target_duration)
285
- if video_path and os.path.exists(video_path):
286
- return {'path': video_path, 'type': 'video', 'error': False, 'prompt_used': motion_prompt_text_for_video, 'base_image_path': input_image_for_runway_path}
287
- else: asset_info = temp_image_asset_info; asset_info['error'] = True; asset_info['error_message'] = "RunwayML video gen failed; using base image."; asset_info['type'] = 'image'; return asset_info
288
- elif not self.USE_RUNWAYML: asset_info = temp_image_asset_info; asset_info['error_message'] = "RunwayML disabled; using base image."; asset_info['type'] = 'image'; return asset_info
289
- else: asset_info = temp_image_asset_info; asset_info['error_message'] = (asset_info.get('error_message',"") + " Base image failed, Runway video not attempted.").strip(); asset_info['type'] = 'image'; return asset_info
290
- else: return temp_image_asset_info
291
-
292
- def generate_narration_audio(self, ttn, ofn="narration_overall.mp3"):
293
- if not self.USE_ELEVENLABS or not self.elevenlabs_client or not ttn: logger.info("11L skip."); return None; afp=os.path.join(self.output_dir,ofn)
294
- try: logger.info(f"11L audio (Voice:{self.elevenlabs_voice_id}): {ttn[:70]}..."); asm=None
295
- if hasattr(self.elevenlabs_client,'text_to_speech')and hasattr(self.elevenlabs_client.text_to_speech,'stream'):asm=self.elevenlabs_client.text_to_speech.stream;logger.info("Using 11L .text_to_speech.stream()")
296
- elif hasattr(self.elevenlabs_client,'generate_stream'):asm=self.elevenlabs_client.generate_stream;logger.info("Using 11L .generate_stream()")
297
- elif hasattr(self.elevenlabs_client,'generate'):logger.info("Using 11L .generate()");vp=Voice(voice_id=str(self.elevenlabs_voice_id),settings=self.elevenlabs_voice_settings)if Voice and self.elevenlabs_voice_settings else str(self.elevenlabs_voice_id);ab=self.elevenlabs_client.generate(text=ttn,voice=vp,model="eleven_multilingual_v2");
298
- with open(afp,"wb")as f:f.write(ab);logger.info(f"11L audio (non-stream): {afp}");return afp
 
 
 
 
 
 
 
 
 
 
 
299
  else:logger.error("No 11L audio method.");return None
300
- if asm:vps={"voice_id":str(self.elevenlabs_voice_id)}
301
- if self.elevenlabs_voice_settings:
302
- if hasattr(self.elevenlabs_voice_settings,'model_dump'):vps["voice_settings"]=self.elevenlabs_voice_settings.model_dump()
303
- elif hasattr(self.elevenlabs_voice_settings,'dict'):vps["voice_settings"]=self.elevenlabs_voice_settings.dict()
304
- else:vps["voice_settings"]=self.elevenlabs_voice_settings
305
- adi=asm(text=ttn,model_id="eleven_multilingual_v2",**vps)
306
- with open(afp,"wb")as f:
307
- for c in adi:
308
- if c:f.write(c)
309
- logger.info(f"11L audio (stream): {afp}");return afp
310
- except Exception as e:logger.error(f"11L audio error: {e}",exc_info=True);return None
311
 
312
  def assemble_animatic_from_assets(self, asset_data_list, overall_narration_path=None, output_filename="final_video.mp4", fps=24):
 
 
 
 
 
 
313
  if not asset_data_list: logger.warning("No assets for animatic."); return None
314
- processed_clips = []; narration_clip = None; final_clip = None
315
- logger.info(f"Assembling from {len(asset_data_list)} assets. Frame: {self.video_frame_size}.")
316
-
317
- for i, asset_info in enumerate(asset_data_list):
318
- asset_path, asset_type, scene_dur = asset_info.get('path'), asset_info.get('type'), asset_info.get('duration', 4.5)
319
- scene_num, key_action = asset_info.get('scene_num', i + 1), asset_info.get('key_action', '')
320
- logger.info(f"S{scene_num}: Path='{asset_path}', Type='{asset_type}', Dur='{scene_dur}'s")
321
 
322
- if not (asset_path and os.path.exists(asset_path)): logger.warning(f"S{scene_num}: Not found '{asset_path}'. Skip."); continue
323
- if scene_dur <= 0: logger.warning(f"S{scene_num}: Invalid duration ({scene_dur}s). Skip."); continue
 
 
324
 
325
- current_scene_mvpy_clip = None
 
 
 
326
  try:
327
- if asset_type == 'image':
328
- pil_img = Image.open(asset_path); logger.debug(f"S{scene_num}: Loaded img. Mode:{pil_img.mode}, Size:{pil_img.size}")
329
- img_rgba = pil_img.convert('RGBA') if pil_img.mode != 'RGBA' else pil_img.copy()
330
- thumb = img_rgba.copy(); rf = Image.Resampling.LANCZOS if hasattr(Image.Resampling,'LANCZOS') else Image.BILINEAR; thumb.thumbnail(self.video_frame_size,rf)
331
- cv_rgba = Image.new('RGBA',self.video_frame_size,(0,0,0,0)); xo,yo=(self.video_frame_size[0]-thumb.width)//2,(self.video_frame_size[1]-thumb.height)//2
332
- cv_rgba.paste(thumb,(xo,yo),thumb)
333
- final_rgb_pil = Image.new("RGB",self.video_frame_size,(0,0,0)); final_rgb_pil.paste(cv_rgba,mask=cv_rgba.split()[3])
334
- dbg_path = os.path.join(self.output_dir,f"debug_PRE_NUMPY_S{scene_num}.png"); final_rgb_pil.save(dbg_path); logger.info(f"DEBUG: Saved PRE_NUMPY_S{scene_num} to {dbg_path}")
335
- frame_np = np.array(final_rgb_pil,dtype=np.uint8);
336
- if not frame_np.flags['C_CONTIGUOUS']: frame_np=np.ascontiguousarray(frame_np,dtype=np.uint8)
337
- logger.debug(f"S{scene_num}: NumPy for MoviePy. Shape:{frame_np.shape}, DType:{frame_np.dtype}, C-Contig:{frame_np.flags['C_CONTIGUOUS']}")
338
- if frame_np.size==0 or frame_np.ndim!=3 or frame_np.shape[2]!=3: logger.error(f"S{scene_num}: Invalid NumPy. Skip."); continue
339
- clip_base = ImageClip(frame_np,transparent=False).set_duration(scene_dur)
340
- mvpy_dbg_path=os.path.join(self.output_dir,f"debug_MOVIEPY_FRAME_S{scene_num}.png"); clip_base.save_frame(mvpy_dbg_path,t=0.1); logger.info(f"DEBUG: Saved MOVIEPY_FRAME_S{scene_num} to {mvpy_dbg_path}")
341
- clip_fx = clip_base
342
- try: es=random.uniform(1.03,1.08); clip_fx=clip_base.fx(vfx.resize,lambda t:1+(es-1)*(t/scene_dur) if scene_dur>0 else 1).set_position('center')
343
- except Exception as e: logger.error(f"S{scene_num} Ken Burns error: {e}",exc_info=False)
344
- current_scene_mvpy_clip = clip_fx
345
- elif asset_type == 'video':
346
- src_clip=None
 
 
347
  try:
348
- src_clip=VideoFileClip(asset_path,target_resolution=(self.video_frame_size[1],self.video_frame_size[0])if self.video_frame_size else None, audio=False)
349
- tmp_clip=src_clip
350
- if src_clip.duration!=scene_dur:
351
- if src_clip.duration>scene_dur:tmp_clip=src_clip.subclip(0,scene_dur)
352
  else:
353
- if scene_dur/src_clip.duration > 1.5 and src_clip.duration>0.1:tmp_clip=src_clip.loop(duration=scene_dur)
354
- else:tmp_clip=src_clip.set_duration(src_clip.duration);logger.info(f"S{scene_num} Video clip ({src_clip.duration:.2f}s) shorter than target ({scene_dur:.2f}s).")
355
- current_scene_mvpy_clip=tmp_clip.set_duration(scene_dur)
356
- if current_scene_mvpy_clip.size!=list(self.video_frame_size):current_scene_mvpy_clip=current_scene_mvpy_clip.resize(self.video_frame_size)
357
- except Exception as e:logger.error(f"S{scene_num} Video load error '{asset_path}':{e}",exc_info=True);continue
358
  finally:
359
- if src_clip and src_clip is not current_scene_mvpy_clip and hasattr(src_clip,'close'):src_clip.close()
360
- else: logger.warning(f"S{scene_num} Unknown asset type '{asset_type}'. Skip."); continue
361
- if current_scene_mvpy_clip and key_action:
 
362
  try:
363
- to_dur=min(current_scene_mvpy_clip.duration-0.5,current_scene_mvpy_clip.duration*0.8)if current_scene_mvpy_clip.duration>0.5 else current_scene_mvpy_clip.duration
364
- to_start=0.25
365
- txt_c=TextClip(f"Scene {scene_num}\n{key_action}",fontsize=self.video_overlay_font_size,color=self.video_overlay_font_color,font=self.video_overlay_font,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(to_dur).set_start(to_start).set_position(('center',0.92),relative=True)
366
- current_scene_mvpy_clip=CompositeVideoClip([current_scene_mvpy_clip,txt_c],size=self.video_frame_size,use_bgclip=True)
367
- except Exception as e:logger.error(f"S{scene_num} TextClip error:{e}. No text.",exc_info=True)
368
- if current_scene_mvpy_clip:processed_clips.append(current_scene_mvpy_clip);logger.info(f"S{scene_num} Processed. Dur:{current_scene_mvpy_clip.duration:.2f}s.")
369
- except Exception as e:logger.error(f"MAJOR Error S{scene_num} ({asset_path}):{e}",exc_info=True)
 
 
370
  finally:
371
- if current_scene_mvpy_clip and hasattr(current_scene_mvpy_clip,'close'):
372
- try: current_scene_mvpy_clip.close()
373
- except: pass
374
 
375
- if not processed_clips:logger.warning("No clips processed. Abort.");return None
376
- td=0.75
377
  try:
378
- logger.info(f"Concatenating {len(processed_clips)} clips.");
379
- if len(processed_clips)>1:final_clip=concatenate_videoclips(processed_clips,padding=-td if td>0 else 0,method="compose")
380
- elif processed_clips:final_clip=processed_clips[0]
381
- if not final_clip:logger.error("Concatenation failed.");return None
382
- logger.info(f"Concatenated dur:{final_clip.duration:.2f}s")
383
- if td>0 and final_clip.duration>0:
384
- if final_clip.duration>td*2:final_clip=final_clip.fx(vfx.fadein,td).fx(vfx.fadeout,td)
385
- else:final_clip=final_clip.fx(vfx.fadein,min(td,final_clip.duration/2.0))
386
- if overall_narration_path and os.path.exists(overall_narration_path) and final_clip.duration>0:
387
- try:narration_clip=AudioFileClip(overall_narration_path);final_clip=final_clip.set_audio(narration_clip);logger.info("Narration added.")
388
- except Exception as e:logger.error(f"Narration add error:{e}",exc_info=True)
389
- elif final_clip.duration<=0:logger.warning("Video no duration. No audio.")
390
- if final_clip and final_clip.duration>0:
391
- op=os.path.join(self.output_dir,output_filename);logger.info(f"Writing video:{op} (Dur:{final_clip.duration:.2f}s)")
392
- final_clip.write_videofile(op,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"])
393
- logger.info(f"Video created:{op}");return op
394
- else:logger.error("Final clip invalid. No write.");return None
395
- except Exception as e:logger.error(f"Video write error:{e}",exc_info=True);return None
396
  finally:
397
- logger.debug("Closing all MoviePy clips in `assemble_animatic_from_assets` finally block.")
398
- clips_to_close = processed_clips + ([narration_clip] if narration_clip else []) + ([final_clip] if final_clip else [])
399
- for clip_obj in clips_to_close:
400
- if clip_obj and hasattr(clip_obj, 'close'):
401
- try: clip_obj.close()
402
- except Exception as e_close: logger.warning(f"Ignoring error while closing a clip: {e_close}")
 
1
  # core/visual_engine.py
2
  from PIL import Image, ImageDraw, ImageFont, ImageOps
3
+ import base64
4
+ import mimetypes
 
 
 
 
 
 
 
 
 
 
 
 
5
  import numpy as np
6
  import os
7
+ import openai # Ensure this is OpenAI v1.x.x+
8
  import requests
9
  import io
10
  import time
11
  import random
12
  import logging
13
 
14
+ # --- MoviePy Imports ---
15
+ from moviepy.editor import (ImageClip, VideoFileClip, concatenate_videoclips, TextClip,
16
+ CompositeVideoClip, AudioFileClip)
17
+ import moviepy.video.fx.all as vfx
18
+
19
+ # --- MONKEY PATCH for Pillow/MoviePy compatibility ---
20
+ try:
21
+ if hasattr(Image, 'Resampling') and hasattr(Image.Resampling, 'LANCZOS'): # Pillow 9+
22
+ if not hasattr(Image, 'ANTIALIAS'): Image.ANTIALIAS = Image.Resampling.LANCZOS
23
+ elif hasattr(Image, 'LANCZOS'): # Pillow 8
24
+ if not hasattr(Image, 'ANTIALIAS'): Image.ANTIALIAS = Image.LANCZOS
25
+ elif not hasattr(Image, 'ANTIALIAS'):
26
+ print("WARNING: Pillow version lacks common Resampling attributes or ANTIALIAS. MoviePy effects might fail or look different.")
27
+ except Exception as e_monkey_patch:
28
+ print(f"WARNING: An unexpected error occurred during Pillow ANTIALIAS monkey-patch: {e_monkey_patch}")
29
+
30
  logger = logging.getLogger(__name__)
31
+ # Set a default level; can be overridden by the main app's logging config
32
+ # logger.setLevel(logging.DEBUG) # Uncomment for very verbose output during development
33
 
34
+ # --- External Service Client Imports ---
35
+ ELEVENLABS_CLIENT_IMPORTED = False
36
+ ElevenLabsAPIClient = None # Class placeholder
37
+ Voice = None # Class placeholder
38
+ VoiceSettings = None # Class placeholder
39
  try:
40
  from elevenlabs.client import ElevenLabs as ImportedElevenLabsClient
41
  from elevenlabs import Voice as ImportedVoice, VoiceSettings as ImportedVoiceSettings
42
+ ElevenLabsAPIClient = ImportedElevenLabsClient
43
+ Voice = ImportedVoice
44
+ VoiceSettings = ImportedVoiceSettings
45
+ ELEVENLABS_CLIENT_IMPORTED = True
46
+ logger.info("ElevenLabs client components (SDK v1.x.x pattern) imported successfully.")
47
+ except ImportError:
48
+ logger.warning("ElevenLabs SDK not found (expected 'pip install elevenlabs>=1.0.0'). Audio generation will be disabled.")
49
+ except Exception as e_eleven_import_general:
50
+ logger.warning(f"General error importing ElevenLabs client components: {e_eleven_import_general}. Audio generation disabled.")
51
 
52
+ RUNWAYML_SDK_IMPORTED = False
53
+ RunwayMLAPIClientClass = None # Storing the class itself
54
  try:
55
+ from runwayml import RunwayML as ImportedRunwayMLAPIClientClass # Actual SDK import
56
+ RunwayMLAPIClientClass = ImportedRunwayMLAPIClientClass
57
+ RUNWAYML_SDK_IMPORTED = True
58
+ logger.info("RunwayML SDK (runwayml) imported successfully.")
59
+ except ImportError:
60
+ logger.warning("RunwayML SDK not found (pip install runwayml). RunwayML video generation will be disabled.")
61
+ except Exception as e_runway_sdk_import_general:
62
+ logger.warning(f"General error importing RunwayML SDK: {e_runway_sdk_import_general}. RunwayML features disabled.")
63
 
64
 
65
  class VisualEngine:
66
+ DEFAULT_FONT_SIZE_PIL = 10
67
+ PREFERRED_FONT_SIZE_PIL = 20
68
+ VIDEO_OVERLAY_FONT_SIZE = 30
69
+ VIDEO_OVERLAY_FONT_COLOR = 'white'
70
+ DEFAULT_MOVIEPY_FONT = 'DejaVu-Sans-Bold' # Common ImageMagick font name
71
+ PREFERRED_MOVIEPY_FONT = 'Liberation-Sans-Bold'
72
+
73
  def __init__(self, output_dir="temp_cinegen_media", default_elevenlabs_voice_id="Rachel"):
74
  self.output_dir = output_dir
75
  os.makedirs(self.output_dir, exist_ok=True)
76
+
77
+ self.font_filename_pil_preference = "DejaVuSans-Bold.ttf" # More standard Linux font
78
  font_paths_to_try = [
79
+ self.font_filename_pil_preference,
80
+ f"/usr/share/fonts/truetype/dejavu/{self.font_filename_pil_preference}",
81
+ f"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", # Alternative
82
+ f"/System/Library/Fonts/Supplemental/Arial.ttf",
83
+ f"C:/Windows/Fonts/arial.ttf",
84
+ f"/usr/local/share/fonts/truetype/mycustomfonts/arial.ttf" # Previous custom
85
  ]
86
+ self.resolved_font_path_pil = next((p for p in font_paths_to_try if os.path.exists(p)), None)
87
+
88
+ self.active_font_pil = ImageFont.load_default() # Fallback default
89
+ self.active_font_size_pil = self.DEFAULT_FONT_SIZE_PIL
90
+ self.active_moviepy_font_name = self.DEFAULT_MOVIEPY_FONT
91
 
92
+ if self.resolved_font_path_pil:
93
+ try:
94
+ self.active_font_pil = ImageFont.truetype(self.resolved_font_path_pil, self.PREFERRED_FONT_SIZE_PIL)
95
+ self.active_font_size_pil = self.PREFERRED_FONT_SIZE_PIL
96
+ logger.info(f"Pillow font loaded: {self.resolved_font_path_pil} at size {self.active_font_size_pil}.")
97
+ # Determine MoviePy font based on loaded PIL font for consistency
98
+ if "dejavu" in self.resolved_font_path_pil.lower(): self.active_moviepy_font_name = 'DejaVu-Sans-Bold'
99
+ elif "liberation" in self.resolved_font_path_pil.lower(): self.active_moviepy_font_name = 'Liberation-Sans-Bold'
100
+ except IOError as e_font_load_io:
101
+ logger.error(f"Pillow font loading IOError for '{self.resolved_font_path_pil}': {e_font_load_io}. Using default font.")
102
+ else:
103
+ logger.warning("Preferred Pillow font not found in predefined paths. Using default font.")
104
 
105
+ # Service API keys and flags
106
  self.openai_api_key = None; self.USE_AI_IMAGE_GENERATION = False
107
+ self.dalle_model = "dall-e-3"; self.image_size_dalle3 = "1792x1024" # DALL-E 3 landscape
108
+ self.video_frame_size = (1280, 720) # HD 16:9
109
+
110
+ self.elevenlabs_api_key = None; self.USE_ELEVENLABS = False; self.elevenlabs_client_instance = None
111
  self.elevenlabs_voice_id = default_elevenlabs_voice_id
112
+ if VoiceSettings and ELEVENLABS_CLIENT_IMPORTED:
113
+ self.elevenlabs_voice_settings_obj = VoiceSettings(stability=0.60, similarity_boost=0.80, style=0.15, use_speaker_boost=True)
114
+ else: self.elevenlabs_voice_settings_obj = None
115
+
116
  self.pexels_api_key = None; self.USE_PEXELS = False
117
+ self.runway_api_key = None; self.USE_RUNWAYML = False; self.runway_ml_sdk_client_instance = None # Instance of RunwayML()
118
+
119
+ # Attempt to initialize RunwayML client if SDK is present and RUNWAYML_API_SECRET env var might be set
120
+ if RUNWAYML_SDK_IMPORTED and RunwayMLAPIClientClass and os.getenv("RUNWAYML_API_SECRET"):
121
+ try:
122
+ self.runway_ml_sdk_client_instance = RunwayMLAPIClientClass() # SDK uses env var by default
123
+ self.USE_RUNWAYML = True # Assume enabled if client initializes without error
124
+ logger.info("RunwayML Client initialized using RUNWAYML_API_SECRET environment variable at startup.")
125
+ except Exception as e_runway_init_at_startup:
126
+ logger.error(f"Initial RunwayML client initialization failed (env var RUNWAYML_API_SECRET might be invalid or SDK issue): {e_runway_init_at_startup}")
127
+ self.USE_RUNWAYML = False # Ensure it's disabled if init fails
128
+
129
  logger.info("VisualEngine initialized.")
130
 
131
+ # --- API Key Setter Methods ---
132
+ def set_openai_api_key(self, api_key_value):
133
+ self.openai_api_key = api_key_value; self.USE_AI_IMAGE_GENERATION = bool(api_key_value)
134
+ logger.info(f"DALL-E ({self.dalle_model}) service status: {'Ready' if self.USE_AI_IMAGE_GENERATION else 'Disabled (no API key)'}")
135
+
136
+ def set_elevenlabs_api_key(self, api_key_value, voice_id_from_secret=None):
137
+ self.elevenlabs_api_key = api_key_value
138
  if voice_id_from_secret: self.elevenlabs_voice_id = voice_id_from_secret
139
+
140
+ if api_key_value and ELEVENLABS_CLIENT_IMPORTED and ElevenLabsAPIClient:
141
+ try:
142
+ self.elevenlabs_client_instance = ElevenLabsAPIClient(api_key=api_key_value) # Pass key directly
143
+ self.USE_ELEVENLABS = bool(self.elevenlabs_client_instance)
144
+ logger.info(f"ElevenLabs Client service status: {'Ready' if self.USE_ELEVENLABS else 'Failed Initialization'} (Using Voice ID: {self.elevenlabs_voice_id})")
145
+ except Exception as e_11l_init:
146
+ logger.error(f"ElevenLabs client initialization error: {e_11l_init}. Service Disabled.", exc_info=True)
147
+ self.USE_ELEVENLABS = False; self.elevenlabs_client_instance = None
148
+ else:
149
+ self.USE_ELEVENLABS = False
150
+ logger.info(f"ElevenLabs Service Disabled (API key not provided or SDK import issue).")
151
+
152
+ def set_pexels_api_key(self, api_key_value):
153
+ self.pexels_api_key = api_key_value; self.USE_PEXELS = bool(api_key_value)
154
+ logger.info(f"Pexels Search service status: {'Ready' if self.USE_PEXELS else 'Disabled (no API key)'}")
155
+
156
+ def set_runway_api_key(self, api_key_value):
157
+ self.runway_api_key = api_key_value # Store the key itself
158
+ if api_key_value:
159
+ if RUNWAYML_SDK_IMPORTED and RunwayMLAPIClientClass:
160
+ if not self.runway_ml_sdk_client_instance: # If not already initialized (e.g., by env var at startup)
161
+ try:
162
+ # The RunwayML Python SDK expects the API key via the RUNWAYML_API_SECRET env var.
163
+ original_env_secret = os.getenv("RUNWAYML_API_SECRET")
164
+ if not original_env_secret: # If env var not set, set it temporarily from passed key
165
+ logger.info("Temporarily setting RUNWAYML_API_SECRET from provided key for SDK client initialization.")
166
+ os.environ["RUNWAYML_API_SECRET"] = api_key_value
167
+
168
+ self.runway_ml_sdk_client_instance = RunwayMLAPIClientClass()
169
+ self.USE_RUNWAYML = True # Mark service as usable if client initializes
170
+ logger.info("RunwayML Client initialized successfully using provided API key (via env var).")
171
+
172
+ if not original_env_secret: # Clean up: remove env var if we set it
173
+ del os.environ["RUNWAYML_API_SECRET"]
174
+ logger.info("Cleared temporary RUNWAYML_API_SECRET environment variable.")
175
+ except Exception as e_runway_client_setkey_init:
176
+ logger.error(f"RunwayML Client initialization via set_runway_api_key failed: {e_runway_client_setkey_init}", exc_info=True)
177
+ self.USE_RUNWAYML = False; self.runway_ml_sdk_client_instance = None # Ensure it's disabled
178
+ else: # Client was already initialized
179
+ self.USE_RUNWAYML = True # Service is usable
180
+ logger.info("RunwayML Client was already initialized (likely from environment variable). API key stored.")
181
+ else: # SDK not imported
182
+ logger.warning("RunwayML SDK not imported. API key has been stored, but the current integration relies on the SDK. Service effectively disabled.")
183
+ self.USE_RUNWAYML = False # Can't use without SDK if that's the implemented path
184
+ else: # No API key provided
185
+ self.USE_RUNWAYML = False; self.runway_ml_sdk_client_instance = None
186
+ logger.info("RunwayML Service Disabled (no API key provided to set_runway_api_key).")
187
+
188
+ # --- Helper Methods (_image_to_data_uri, _map_resolution_to_runway_ratio, etc.) ---
189
+ def _image_to_data_uri(self, image_path):
190
  try:
191
+ mime_type, _ = mimetypes.guess_type(image_path)
192
+ if not mime_type:
193
+ ext = os.path.splitext(image_path)[1].lower()
194
+ mime_map = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp"}
195
+ mime_type = mime_map.get(ext, "application/octet-stream")
196
+ if mime_type == "application/octet-stream": logger.warning(f"Could not determine MIME type for {image_path}, using default.")
197
+ with open(image_path, "rb") as image_file: encoded_string = base64.b64encode(image_file.read()).decode('utf-8')
198
+ data_uri = f"data:{mime_type};base64,{encoded_string}"
199
+ logger.debug(f"Generated data URI for {os.path.basename(image_path)} (first 100 chars): {data_uri[:100]}...")
200
+ return data_uri
201
+ except FileNotFoundError: logger.error(f"Image file not found at {image_path} for data URI conversion."); return None
202
+ except Exception as e: logger.error(f"Error converting image {image_path} to data URI: {e}", exc_info=True); return None
203
+
204
+ def _map_resolution_to_runway_ratio(self, width, height):
205
+ ratio_str = f"{width}:{height}"
206
+ supported_ratios_gen4 = ["1280:720", "720:1280", "1104:832", "832:1104", "960:960", "1584:672"]
207
+ if ratio_str in supported_ratios_gen4: return ratio_str
208
+ logger.warning(f"Resolution {ratio_str} not directly in Gen-4 supported list. Defaulting to 1280:720 for RunwayML.")
209
+ return "1280:720"
210
+
211
+ def _get_text_dimensions(self, text_content, font_object_pil):
212
+ default_h = getattr(font_object_pil, 'size', self.active_font_size_pil)
213
+ if not text_content: return 0, default_h
214
+ try:
215
+ 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
216
+ elif hasattr(font_object_pil,'getsize'): w,h=font_object_pil.getsize(text_content); return w, h if h > 0 else default_h
217
+ else: return int(len(text_content)*default_h*0.6),int(default_h*1.2) # Basic estimate
218
+ 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)
219
 
220
  def _create_placeholder_image_content(self, text_description, filename, size=None):
221
+ # <<< THIS IS THE CORRECTED VERSION OF THIS METHOD >>>
222
  if size is None: size = self.video_frame_size
223
+ img = Image.new('RGB', size, color=(20, 20, 40)); d = ImageDraw.Draw(img); padding = 25
224
+ max_w = size[0] - (2 * padding); lines_for_placeholder = [] # Renamed to avoid conflict
225
+ if not text_description: text_description = "(Placeholder Image)"
226
+ words_list = text_description.split(); current_line_buffer = "" # Renamed
227
+ for word_idx, word_item in enumerate(words_list): # Renamed
228
+ prospective_add = word_item + (" " if word_idx < len(words_list) - 1 else "")
229
+ test_line_candidate = current_line_buffer + prospective_add
230
+ current_w_text, _ = self._get_text_dimensions(test_line_candidate, self.active_font_pil)
231
+ if current_w_text == 0 and test_line_candidate.strip(): current_w_text = len(test_line_candidate) * (self.active_font_size_pil * 0.6)
232
+
233
+ if current_w_text <= max_w: current_line_buffer = test_line_candidate
234
  else:
235
+ if current_line_buffer.strip(): lines_for_placeholder.append(current_line_buffer.strip())
236
+ current_line_buffer = prospective_add
237
+ if current_line_buffer.strip(): lines_for_placeholder.append(current_line_buffer.strip())
238
+
239
+ if not lines_for_placeholder and text_description:
240
+ 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)
241
+ chars_per_line_est = int(max_w / avg_char_w_est) if avg_char_w_est > 0 else 20
242
+ lines_for_placeholder.append(text_description[:chars_per_line_est] + ("..." if len(text_description) > chars_per_line_est else ""))
243
+ elif not lines_for_placeholder: lines_for_placeholder.append("(Placeholder Error)")
244
+
245
+ _, 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
246
+ 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)
247
+
248
+ y_p = padding + (size[1] - (2 * padding) - max_l * (single_h + 2)) / 2.0
249
+ for i_line in range(max_l): # Renamed
250
+ line_txt_content = lines_for_placeholder[i_line]; line_w_val, _ = self._get_text_dimensions(line_txt_content, self.active_font_pil)
251
+ if line_w_val == 0 and line_txt_content.strip(): line_w_val = len(line_txt_content) * (self.active_font_size_pil * 0.6)
252
+ x_p = (size[0] - line_w_val) / 2.0
253
+ try: d.text((x_p, y_p), line_txt_content, font=self.active_font_pil, fill=(200, 200, 180))
254
+ except Exception as e_draw_txt: logger.error(f"Pillow d.text error: {e_draw_txt} for '{line_txt_content}'")
255
+ y_p += single_h + 2
256
+ if i_line == 6 and max_l > 7:
257
+ try: d.text((x_p, y_p), "...", font=self.active_font_pil, fill=(200, 200, 180))
258
+ except Exception as e_elps: logger.error(f"Pillow d.text ellipsis error: {e_elps}"); break
259
+ filepath_placeholder = os.path.join(self.output_dir, filename) # Renamed
260
+ try: img.save(filepath_placeholder); return filepath_placeholder
261
+ except Exception as e_save_ph: logger.error(f"Saving placeholder image '{filepath_placeholder}' error: {e_save_ph}", exc_info=True); return None
262
+
263
+ def _search_pexels_image(self, query_str, output_fn_base):
264
+ # (Corrected from previous response)
265
  if not self.USE_PEXELS or not self.pexels_api_key: return None
266
+ http_headers = {"Authorization": self.pexels_api_key}
267
+ http_params = {"query": query_str, "per_page": 1, "orientation": "landscape", "size": "large2x"}
268
+ base_name_px, _ = os.path.splitext(output_fn_base)
269
+ pexels_fn_str = base_name_px + f"_pexels_{random.randint(1000,9999)}.jpg"
270
+ file_path_px = os.path.join(self.output_dir, pexels_fn_str)
271
  try:
272
+ logger.info(f"Pexels: Searching for '{query_str}'")
273
+ eff_query_px = " ".join(query_str.split()[:5])
274
+ http_params["query"] = eff_query_px
275
+ response_px = requests.get("https://api.pexels.com/v1/search", headers=http_headers, params=http_params, timeout=20)
276
+ response_px.raise_for_status()
277
+ data_px = response_px.json()
278
+ if data_px.get("photos") and len(data_px["photos"]) > 0:
279
+ photo_details_px = data_px["photos"][0]
280
+ photo_url_px = photo_details_px.get("src", {}).get("large2x")
281
+ if not photo_url_px: logger.warning(f"Pexels: 'large2x' URL missing for '{eff_query_px}'. Details: {photo_details_px}"); return None
282
+ image_response_px = requests.get(photo_url_px, timeout=60); image_response_px.raise_for_status()
283
+ img_pil_data_px = Image.open(io.BytesIO(image_response_px.content))
284
+ if img_pil_data_px.mode != 'RGB': img_pil_data_px = img_pil_data_px.convert('RGB')
285
+ img_pil_data_px.save(file_path_px); logger.info(f"Pexels: Image saved to {file_path_px}"); return file_path_px
286
+ else: logger.info(f"Pexels: No photos for '{eff_query_px}'."); return None
287
+ except requests.exceptions.RequestException as e_req_px: logger.error(f"Pexels: RequestException for '{query_str}': {e_req_px}", exc_info=False); return None
288
+ except Exception as e_px_gen: logger.error(f"Pexels: General error for '{query_str}': {e_px_gen}", exc_info=True); return None
289
+
290
+ def _generate_video_clip_with_runwayml(self, text_prompt_for_motion, input_image_path, scene_identifier_filename_base, target_duration_seconds=5):
291
+ # (Updated RunwayML integration from before)
292
+ 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
293
+ 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
294
+ image_data_uri_str = self._image_to_data_uri(input_image_path) # Renamed
295
+ if not image_data_uri_str: return None
296
+ runway_dur = 10 if target_duration_seconds >= 8 else 5
297
+ runway_ratio = self._map_resolution_to_runway_ratio(self.video_frame_size[0], self.video_frame_size[1])
298
+ 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
299
+ output_vid_fp = os.path.join(self.output_dir, output_vid_fn) # Renamed
300
+ 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}'")
 
 
 
 
 
 
 
301
  try:
302
+ 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
303
+ task_id_runway = task_submitted_runway.id; logger.info(f"Runway Gen-4 task ID: {task_id_runway}. Polling...") # Renamed
304
+ poll_sec=10; max_poll_count=36; poll_start_time = time.time() # Renamed
305
+ while time.time() - poll_start_time < max_poll_count * poll_sec:
306
+ time.sleep(poll_sec); task_details_runway = self.runway_ml_sdk_client_instance.tasks.retrieve(id=task_id_runway) # Renamed
307
+ logger.info(f"Runway task {task_id_runway} status: {task_details_runway.status}")
308
+ if task_details_runway.status == 'SUCCEEDED':
309
+ 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
310
+ 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
311
+ logger.info(f"Runway task {task_id_runway} SUCCEEDED. Downloading: {output_url_runway}")
312
+ video_resp_get = requests.get(output_url_runway, stream=True, timeout=300); video_resp_get.raise_for_status() # Renamed
313
+ with open(output_vid_fp,'wb') as f_vid: # Renamed
314
+ for chunk_data in video_resp_get.iter_content(chunk_size=8192): f_vid.write(chunk_data) # Renamed
315
+ logger.info(f"Runway Gen-4 video saved: {output_vid_fp}"); return output_vid_fp
316
+ elif task_details_runway.status in ['FAILED','ABORTED','ERROR']:
317
+ err_msg_runway = getattr(task_details_runway,'error_message',None) or getattr(getattr(task_details_runway,'output',None),'error',"Unknown Runway error.") # Renamed
318
+ logger.error(f"Runway task {task_id_runway} status: {task_details_runway.status}. Error: {err_msg_runway}"); return None
319
+ logger.warning(f"Runway task {task_id_runway} timed out."); return None
320
+ except AttributeError as ae_sdk: logger.error(f"RunwayML SDK AttrError: {ae_sdk}. SDK/methods changed?", exc_info=True); return None # Renamed
321
+ except Exception as e_runway_gen: logger.error(f"Runway Gen-4 API error: {e_runway_gen}", exc_info=True); return None # Renamed
322
+
323
+ def _create_placeholder_video_content(self, text_desc_ph, filename_ph, duration_ph=4, size_ph=None): # Renamed variables
324
+ # <<< THIS IS THE CORRECTED METHOD >>>
325
+ if size_ph is None: size_ph = self.video_frame_size
326
+ filepath_ph = os.path.join(self.output_dir, filename_ph)
327
+ text_clip_ph = None # Initialize
328
  try:
329
+ text_clip_ph = TextClip(text_desc_ph, fontsize=50, color='white', font=self.video_overlay_font,
330
+ bg_color='black', size=size_ph, method='caption').set_duration(duration_ph)
331
+ text_clip_ph.write_videofile(filepath_ph, fps=24, codec='libx264', preset='ultrafast', logger=None, threads=2)
332
+ logger.info(f"Generic placeholder video created: {filepath_ph}")
333
+ return filepath_ph
334
+ except Exception as e_ph_vid: # Specific exception variable
335
+ logger.error(f"Failed to create generic placeholder video '{filepath_ph}': {e_ph_vid}", exc_info=True)
 
 
 
 
 
 
 
 
 
 
 
336
  return None
337
  finally:
338
+ if text_clip_ph and hasattr(text_clip_ph, 'close'):
339
+ text_clip_ph.close()
340
+
341
+ # --- generate_scene_asset (Main asset generation logic) ---
 
 
342
  def generate_scene_asset(self, image_generation_prompt_text, motion_prompt_text_for_video,
343
+ scene_data_dict, scene_identifier_fn_base, # Renamed
344
+ generate_as_video_clip_flag=False, runway_target_dur_val=5): # Renamed
345
+ # (Logic mostly as before, ensuring base image is robustly generated first)
346
+ base_name_asset, _ = os.path.splitext(scene_identifier_fn_base) # Renamed
347
+ asset_info_result = {'path': None, 'type': 'none', 'error': True, 'prompt_used': image_generation_prompt_text, 'error_message': 'Asset generation init failed'} # Renamed
348
+ path_for_input_image_runway = None # Renamed
349
+ fn_for_base_image = base_name_asset + ("_base_for_video.png" if generate_as_video_clip_flag else ".png") # Renamed
350
+ fp_for_base_image = os.path.join(self.output_dir, fn_for_base_image) # Renamed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
351
 
352
+ if self.USE_AI_IMAGE_GENERATION and self.openai_api_key:
353
+ # (DALL-E logic with corrected try/except from previous response)
354
+ max_r_dalle, att_n_dalle = 2,0;
355
+ for att_n_dalle in range(max_r_dalle):
356
+ att_c_dalle = att_n_dalle + 1 # Renamed
357
+ try:
358
+ logger.info(f"Att {att_c_dalle} DALL-E (base img): {image_generation_prompt_text[:70]}...");
359
+ oai_cl = openai.OpenAI(api_key=self.openai_api_key,timeout=90.0) # Renamed
360
+ 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
361
+ oai_iu = oai_r.data[0].url; oai_rp = getattr(oai_r.data[0],'revised_prompt',None) # Renamed
362
+ if oai_rp: logger.info(f"DALL-E revised: {oai_rp[:70]}...")
363
+ oai_ir = requests.get(oai_iu,timeout=120); oai_ir.raise_for_status() # Renamed
364
+ oai_id = Image.open(io.BytesIO(oai_ir.content)); # Renamed
365
+ if oai_id.mode!='RGB': oai_id=oai_id.convert('RGB')
366
+ oai_id.save(fp_for_base_image); logger.info(f"DALL-E base img saved: {fp_for_base_image}")
367
+ path_for_input_image_runway=fp_for_base_image
368
+ asset_info_result={'path':fp_for_base_image,'type':'image','error':False,'prompt_used':image_generation_prompt_text,'revised_prompt':oai_rp}
369
+ break
370
+ 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
371
+ 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
372
+ 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
373
+ 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
374
+ if asset_info_result['error']: logger.warning(f"DALL-E failed after {att_c_dalle} attempts for base img.")
375
 
376
+ if asset_info_result['error'] and self.USE_PEXELS:
377
+ 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
378
+ 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}"}
379
+ else:current_em_px=asset_info_result.get('error_message',"");asset_info_result['error_message']=(current_em_px+" Pexels failed for base.").strip() # Renamed
380
+
381
+ if asset_info_result['error']:
382
+ 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
383
+ if ph_p:path_for_input_image_runway=ph_p;asset_info_result={'path':ph_p,'type':'image','error':False,'prompt_used':ph_ppt}
384
+ else:current_em_ph=asset_info_result.get('error_message',"");asset_info_result['error_message']=(current_em_ph+" Base placeholder failed.").strip() # Renamed
385
+
386
+ if generate_as_video_clip_flag:
387
+ 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
388
+ if self.USE_RUNWAYML:
389
+ 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
390
+ 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}
391
+ 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
392
+ 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
393
+ return asset_info_result
394
+
395
+ def generate_narration_audio(self, text_to_narrate, output_filename="narration_overall.mp3"):
396
+ # (Keep as before)
397
+ if not self.USE_ELEVENLABS or not self.elevenlabs_client_instance or not text_to_narrate: logger.info("11L skip."); return None # Check instance
398
+ audio_fp_11l=os.path.join(self.output_dir,output_filename) # Renamed
399
+ try: logger.info(f"11L audio (Voice:{self.elevenlabs_voice_id}): {text_to_narrate[:70]}..."); audio_sm_11l=None # Renamed
400
+ 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()")
401
+ elif hasattr(self.elevenlabs_client_instance,'generate_stream'):audio_sm_11l=self.elevenlabs_client_instance.generate_stream;logger.info("Using 11L .generate_stream()")
402
+ 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
403
+ 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
404
  else:logger.error("No 11L audio method.");return None
405
+ if audio_sm_11l:eleven_vps={"voice_id":str(self.elevenlabs_voice_id)} # Renamed
406
+ if self.elevenlabs_voice_settings_obj:
407
+ if hasattr(self.elevenlabs_voice_settings_obj,'model_dump'):eleven_vps["voice_settings"]=self.elevenlabs_voice_settings_obj.model_dump()
408
+ elif hasattr(self.elevenlabs_voice_settings_obj,'dict'):eleven_vps["voice_settings"]=self.elevenlabs_voice_settings_obj.dict()
409
+ else:eleven_vps["voice_settings"]=self.elevenlabs_voice_settings_obj
410
+ eleven_adi=audio_sm_11l(text=text_to_narrate,model_id="eleven_multilingual_v2",**eleven_vps) # Renamed
411
+ with open(audio_fp_11l,"wb")as f_11l_stream: # Renamed
412
+ for chunk_11l in eleven_adi: # Renamed
413
+ if chunk_11l:f_11l_stream.write(chunk_11l)
414
+ logger.info(f"11L audio (stream): {audio_fp_11l}");return audio_fp_11l
415
+ except Exception as e_11labs_audio:logger.error(f"11L audio error: {e_11labs_audio}",exc_info=True);return None # Renamed
416
 
417
  def assemble_animatic_from_assets(self, asset_data_list, overall_narration_path=None, output_filename="final_video.mp4", fps=24):
418
+ # (Keep as in the version with robust image processing, C-contiguous array, debug saves, and pix_fmt)
419
+ # ... (This extensive method is assumed to be largely correct from the previous iteration focusing on blank video issues)
420
+ # ... (The core of that robust version should be retained here) ...
421
+ # For brevity, I'm not re-pasting the entire assemble_animatic_from_assets if it was correct before this syntax error hunt.
422
+ # If it also needs review, let me know. The key is that the *input* (asset_data_list)
423
+ # from the corrected generate_scene_asset is now more reliable.
424
  if not asset_data_list: logger.warning("No assets for animatic."); return None
425
+ processed_moviepy_clips_list = []; narration_audio_clip_mvpy = None; final_video_output_clip = None # Renamed variables
426
+ logger.info(f"Assembling from {len(asset_data_list)} assets. Target Frame: {self.video_frame_size}.")
 
 
 
 
 
427
 
428
+ for i_asset, asset_info_item_loop in enumerate(asset_data_list): # Renamed loop variables
429
+ 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)
430
+ num_of_scene, action_in_key = asset_info_item_loop.get('scene_num', i_asset + 1), asset_info_item_loop.get('key_action', '')
431
+ logger.info(f"S{num_of_scene}: Path='{path_of_asset}', Type='{type_of_asset}', Dur='{duration_for_scene}'s")
432
 
433
+ 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
434
+ if duration_for_scene <= 0: logger.warning(f"S{num_of_scene}: Invalid duration ({duration_for_scene}s). Skip."); continue
435
+
436
+ active_scene_clip = None # Clip for this iteration
437
  try:
438
+ if type_of_asset == 'image':
439
+ # (Robust image processing from previous version, including debug saves)
440
+ 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}")
441
+ converted_img_rgba = opened_pil_img.convert('RGBA') if opened_pil_img.mode != 'RGBA' else opened_pil_img.copy()
442
+ 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)
443
+ 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
444
+ rgba_canvas.paste(thumbnailed_img,(pos_x,pos_y),thumbnailed_img)
445
+ 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])
446
+ 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}")
447
+ numpy_frame_arr = np.array(final_rgb_img_pil,dtype=np.uint8);
448
+ if not numpy_frame_arr.flags['C_CONTIGUOUS']: numpy_frame_arr=np.ascontiguousarray(numpy_frame_arr,dtype=np.uint8)
449
+ 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']}")
450
+ 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
451
+ base_image_clip = ImageClip(numpy_frame_arr,transparent=False).set_duration(duration_for_scene)
452
+ 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}")
453
+ fx_image_clip = base_image_clip
454
+ 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')
455
+ except Exception as e_kb_fx: logger.error(f"S{num_of_scene} Ken Burns error: {e_kb_fx}",exc_info=False)
456
+ active_scene_clip = fx_image_clip
457
+ elif type_of_asset == 'video':
458
+ # (Video processing logic from previous version)
459
+ source_video_clip_obj=None
460
  try:
461
+ 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)
462
+ temp_video_clip_obj_loop=source_video_clip_obj
463
+ if source_video_clip_obj.duration!=duration_for_scene:
464
+ if source_video_clip_obj.duration>duration_for_scene:temp_video_clip_obj_loop=source_video_clip_obj.subclip(0,duration_for_scene)
465
  else:
466
+ 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)
467
+ 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).")
468
+ active_scene_clip=temp_video_clip_obj_loop.set_duration(duration_for_scene)
469
+ if active_scene_clip.size!=list(self.video_frame_size):active_scene_clip=active_scene_clip.resize(self.video_frame_size)
470
+ 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
471
  finally:
472
+ 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()
473
+ else: logger.warning(f"S{num_of_scene} Unknown asset type '{type_of_asset}'. Skip."); continue
474
+
475
+ if active_scene_clip and action_in_key: # Text Overlay
476
  try:
477
+ 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
478
+ start_text_overlay=0.25
479
+ if dur_text_overlay > 0:
480
+ 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)
481
+ active_scene_clip=CompositeVideoClip([active_scene_clip,text_clip_for_overlay],size=self.video_frame_size,use_bgclip=True)
482
+ else: logger.warning(f"S{num_of_scene}: Text overlay duration zero. Skip text.")
483
+ except Exception as e_txt_comp:logger.error(f"S{num_of_scene} TextClip error:{e_txt_comp}. No text.",exc_info=True)
484
+ 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.")
485
+ 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)
486
  finally:
487
+ if active_scene_clip and hasattr(active_scene_clip,'close'):
488
+ try: active_scene_clip.close()
489
+ except: pass # Ignore errors during cleanup
490
 
491
+ if not processed_moviepy_clips_list:logger.warning("No clips processed for animatic. Aborting.");return None
492
+ transition_duration_val=0.75 # Renamed
493
  try:
494
+ logger.info(f"Concatenating {len(processed_moviepy_clips_list)} clips for final animatic.");
495
+ 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")
496
+ elif processed_moviepy_clips_list:final_video_output_clip=processed_moviepy_clips_list[0]
497
+ if not final_video_output_clip:logger.error("Concatenation resulted in a None clip. Aborting.");return None
498
+ logger.info(f"Concatenated animatic duration:{final_video_output_clip.duration:.2f}s")
499
+ if transition_duration_val>0 and final_video_output_clip.duration>0:
500
+ 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)
501
+ else:final_video_output_clip=final_video_output_clip.fx(vfx.fadein,min(transition_duration_val,final_video_output_clip.duration/2.0))
502
+ if overall_narration_path and os.path.exists(overall_narration_path) and final_video_output_clip.duration>0:
503
+ 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.")
504
+ except Exception as e_narr_add:logger.error(f"Error adding narration to animatic:{e_narr_add}",exc_info=True)
505
+ elif final_video_output_clip.duration<=0:logger.warning("Animatic has no duration. Audio not added.")
506
+ if final_video_output_clip and final_video_output_clip.duration>0:
507
+ 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
508
+ 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"])
509
+ logger.info(f"Animatic video created successfully:{final_output_path_str}");return final_output_path_str
510
+ else:logger.error("Final animatic clip is invalid or has zero duration. Cannot write video file.");return None
511
+ 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
512
  finally:
513
+ logger.debug("Closing all MoviePy clips in `assemble_animatic_from_assets` main finally block.")
514
+ 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
515
+ for clip_item_to_close in clips_for_final_closure: # Renamed
516
+ if clip_item_to_close and hasattr(clip_item_to_close, 'close'):
517
+ try: clip_item_to_close.close()
518
+ 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}")