testdeep123 commited on
Commit
b888295
·
verified ·
1 Parent(s): 864c351

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +683 -253
app.py CHANGED
@@ -12,9 +12,9 @@ import re
12
  import requests
13
  import io
14
  import shutil
15
- from urllib.parse import quote
16
  import numpy as np
17
- from bs4 import BeautifulSoup # Keep import in case needed elsewhere, but not for search
18
  import base64
19
  from gtts import gTTS
20
  import gradio as gr
@@ -33,7 +33,6 @@ from pydub import AudioSegment
33
  from pydub.generators import Sine
34
 
35
  # ---------------- Global Configuration ---------------- #
36
- # --- API Keys (Replace with your actual keys) ---
37
  PEXELS_API_KEY = 'BhJqbcdm9Vi90KqzXKAhnEHGsuFNv4irXuOjWtT761U49lRzo03qBGna'
38
  OPENROUTER_API_KEY = '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'
39
  OPENROUTER_MODEL = "mistralai/mistral-small-3.1-24b-instruct:free"
@@ -71,8 +70,7 @@ except Exception as e:
71
 
72
  def generate_script(user_input):
73
  """Generate documentary script using OpenRouter API."""
74
- # --- Retain previous generate_script function ---
75
- # (No changes needed here based on the request)
76
  headers = {
77
  'Authorization': f'Bearer {OPENROUTER_API_KEY}',
78
  'Content-Type': 'application/json',
@@ -144,8 +142,7 @@ Now generate the script based on: {user_input}
144
 
145
  def parse_script(script_text):
146
  """Parse the generated script into segments."""
147
- # --- Retain previous parse_script function ---
148
- # (No changes needed here based on the request)
149
  segments = []
150
  current_title = None
151
  current_narration = ""
@@ -178,219 +175,414 @@ def parse_script(script_text):
178
  print(f"Parsed {len(segments)} segments from script.")
179
  return segments
180
 
181
- # --- MODIFIED: search_pexels Function ---
182
- def search_pexels(query, api_key, search_type="videos"):
183
- """Search Pexels for videos or images with improved error handling."""
184
- if not api_key or api_key == 'YOUR_PEXELS_API_KEY':
185
- print(f"Pexels API key not provided or is default. Skipping Pexels {search_type} search.")
 
186
  return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
 
188
- base_url = f"https://api.pexels.com/{search_type}/search"
189
- headers = {'Authorization': api_key}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  params = {"query": query, "per_page": 15, "orientation": "landscape"}
191
- if search_type == "videos":
192
- params["size"] = "medium" # Request medium or large
193
 
194
  max_retries = 3
195
- retry_delay = 2 # Slightly longer initial delay
196
- timeout_duration = 20 # Increased timeout
197
 
198
- print(f"Searching Pexels {search_type} for '{query}'...")
199
 
200
  for attempt in range(max_retries):
201
  try:
202
- response = requests.get(base_url, headers=headers, params=params, timeout=timeout_duration)
203
-
204
- # Check for specific HTTP errors before raising general exception
205
- if response.status_code == 401:
206
- print(f"Pexels API Error: Unauthorized (401). Check your API Key.")
207
- return None # Don't retry on auth errors
208
- if response.status_code == 429:
209
- print(f"Pexels API Error: Rate limit hit (429) (attempt {attempt+1}/{max_retries}). Waiting {retry_delay*2}s...")
210
- time.sleep(retry_delay * 2) # Wait longer for rate limits
211
- retry_delay *= 2
212
- continue # Go to next attempt
213
- if response.status_code == 522:
214
- print(f"Pexels API Error: Connection Timed Out (522) between Cloudflare and Pexels server (attempt {attempt+1}/{max_retries}). Retrying in {retry_delay}s...")
215
- # This error is external, retrying might help if temporary
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
216
  time.sleep(retry_delay)
217
  retry_delay *= 2
218
- continue # Go to next attempt
219
-
220
- response.raise_for_status() # Raise exceptions for other 4xx/5xx errors
221
-
222
- data = response.json()
223
- items = data.get(search_type, [])
224
-
225
- if not items:
226
- print(f"No Pexels {search_type} found for query: {query}")
227
- return None # No results found
228
-
229
- # --- Filtering logic remains the same ---
230
- valid_items = []
231
- if search_type == "videos":
232
- for video in items:
233
- hd_link = next((f['link'] for f in video.get('video_files', []) if f.get('quality') == 'hd' and f.get('width', 0) >= 1080), None)
234
- large_link = next((f['link'] for f in video.get('video_files', []) if f.get('quality') == 'large' and f.get('width', 0) >= 1080), None)
235
- medium_link = next((f['link'] for f in video.get('video_files', []) if f.get('quality') == 'medium'), None)
236
- link = hd_link or large_link or medium_link
237
- if link: valid_items.append(link)
238
- else: # images
239
- for photo in items:
240
- link = photo.get("src", {}).get("large2x") or photo.get("src", {}).get("original")
241
- if link: valid_items.append(link)
242
-
243
- if valid_items:
244
- print(f"Found {len(valid_items)} suitable Pexels {search_type} for '{query}'. Selecting one.")
245
- return random.choice(valid_items)
246
  else:
247
- print(f"No suitable quality Pexels {search_type} found for query: {query}")
248
- return None
 
 
 
 
 
249
 
250
  except requests.exceptions.Timeout:
251
- print(f"Pexels API request timed out after {timeout_duration}s (attempt {attempt+1}/{max_retries}). Retrying in {retry_delay}s...")
252
  time.sleep(retry_delay)
253
  retry_delay *= 2
254
  except requests.exceptions.RequestException as e:
255
- print(f"Pexels API request error (attempt {attempt+1}/{max_retries}): {e}")
256
- # Don't retry on general request errors unless specifically handled above
257
- time.sleep(retry_delay)
258
- retry_delay *= 2 # Still increase delay for next attempt if retrying
259
- except Exception as e:
260
- print(f"Unexpected error during Pexels search: {e}")
261
- break # Stop retrying on unexpected python errors
262
 
263
- print(f"Pexels {search_type} search failed for '{query}' after {max_retries} attempts.")
264
  return None
265
 
266
- # --- REMOVED: search_google_images Function ---
267
- # def search_google_images(query):
268
- # # ... function content removed ...
269
- # pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
270
 
271
- def download_media(media_url, filename_prefix, target_folder):
272
- """Download media (image or video) from a URL."""
273
- # --- Retain previous download_media function ---
274
- # (No changes needed here based on the request, it handles Pexels URLs)
275
  try:
276
  headers = {"User-Agent": USER_AGENT}
277
- response = requests.get(media_url, headers=headers, stream=True, timeout=30)
 
278
  response.raise_for_status()
279
- content_type = response.headers.get('content-type', '').lower()
280
- file_extension = ".jpg" # Default
281
- if 'video' in content_type: file_extension = ".mp4"
282
- elif 'image/jpeg' in content_type: file_extension = ".jpg"
283
- elif 'image/png' in content_type: file_extension = ".png"
284
- elif 'image/webp' in content_type: file_extension = ".webp"
285
- else: # Guess from URL
286
- media_url_lower = media_url.lower()
287
- if '.mp4' in media_url_lower: file_extension = ".mp4"
288
- elif '.mov' in media_url_lower: file_extension = ".mov"
289
- elif '.jpg' in media_url_lower or '.jpeg' in media_url_lower: file_extension = ".jpg"
290
- elif '.png' in media_url_lower: file_extension = ".png"
291
- elif '.webp' in media_url_lower: file_extension = ".webp"
292
-
293
- filename = os.path.join(target_folder, f"{filename_prefix}{file_extension}")
294
  with open(filename, 'wb') as f:
295
- for chunk in response.iter_content(chunk_size=8192): f.write(chunk)
296
- print(f"Media downloaded successfully to: {filename}")
297
 
298
- if file_extension in [".jpg", ".png", ".webp"]:
299
- try:
300
- img = Image.open(filename)
301
- img.verify()
302
- img.close()
303
- img = Image.open(filename)
304
- if img.mode != 'RGB':
305
- print(f"Converting image {filename} to RGB.")
 
 
 
 
 
 
306
  rgb_img = img.convert('RGB')
307
- jpg_filename = os.path.join(target_folder, f"{filename_prefix}.jpg")
308
- rgb_img.save(jpg_filename, "JPEG")
 
309
  rgb_img.close()
310
  img.close()
311
- if filename != jpg_filename: os.remove(filename)
312
- return jpg_filename
313
- else: img.close()
314
- except Exception as e_validate:
315
- print(f"Downloaded file {filename} is not a valid image or conversion failed: {e_validate}")
316
- if os.path.exists(filename): os.remove(filename)
317
- return None
318
- return filename
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
319
  except requests.exceptions.RequestException as e_download:
320
- print(f"Media download error from {media_url}: {e_download}")
 
321
  return None
322
- except Exception as e_general:
323
- print(f"General error during media download/processing: {e_general}")
324
  return None
325
 
326
- # --- MODIFIED: generate_media Function ---
327
- def generate_media(prompt):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
328
  """
329
- Find and download a visual asset (video or image) based on the prompt.
330
- Prioritizes Pexels Video, then Pexels Image. NO Google Image fallback.
331
- Uses a single generic Pexels image search as the final fallback.
332
  """
333
- safe_prompt = re.sub(r'[^\w\s-]', '', prompt).strip().replace(' ', '_')
334
- # Limit length of prompt in filename to avoid issues
335
- safe_prompt = safe_prompt[:50]
336
- filename_prefix = f"{safe_prompt}_{int(time.time())}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
337
 
338
- # 1. Try Pexels Video (if probability met)
339
  if random.random() < video_clip_probability:
340
- video_url = search_pexels(prompt, PEXELS_API_KEY, search_type="videos")
 
 
341
  if video_url:
342
- downloaded_path = download_media(video_url, filename_prefix + "_vid", TEMP_FOLDER)
343
- if downloaded_path:
344
- print(f"Using Pexels video for '{prompt}'")
345
- return {"path": downloaded_path, "asset_type": "video"}
 
 
346
  else:
347
- print(f"Pexels video search failed or no suitable video found for '{prompt}'. Trying image...")
 
 
348
 
349
- # 2. Try Pexels Image
350
- image_url = search_pexels(prompt, PEXELS_API_KEY, search_type="photos")
 
 
 
351
  if image_url:
352
- downloaded_path = download_media(image_url, filename_prefix + "_img", TEMP_FOLDER)
353
- if downloaded_path:
354
- print(f"Using Pexels image for '{prompt}'")
355
- return {"path": downloaded_path, "asset_type": "image"}
 
 
356
  else:
357
- print(f"Pexels image search failed for '{prompt}'.")
358
-
359
- # --- REMOVED GOOGLE IMAGE SEARCH ---
360
- # print(f"Falling back to Google Image search for: {prompt}")
361
- # google_image_url = search_google_images(prompt)
362
- # ... (removed logic) ...
363
-
364
- # 3. Absolute Fallback: Generic Pexels Image Search
365
- # Only use this if the specific prompt searches failed.
366
- # Avoid searching for problematic terms like "Subscribe CTA".
367
- fallback_terms = ["technology", "abstract", "nature", "background"]
368
- # Don't use fallback for prompts that are clearly instructions/CTAs
369
  if "subscribe" not in prompt.lower() and "cta" not in prompt.lower():
370
- fallback_term = random.choice(fallback_terms)
371
- print(f"All specific searches failed for '{prompt}'. Using Pexels fallback term: '{fallback_term}'")
372
- fallback_url = search_pexels(fallback_term, PEXELS_API_KEY, search_type="photos")
 
 
373
  if fallback_url:
374
- downloaded_path = download_media(fallback_url, filename_prefix + "_fallback", TEMP_FOLDER)
375
- if downloaded_path:
376
- print(f"Using Pexels fallback image '{fallback_term}' for '{prompt}'")
377
- return {"path": downloaded_path, "asset_type": "image"}
378
  else:
379
- print(f"Pexels fallback image download failed for term '{fallback_term}'.")
380
  else:
381
- print(f"Pexels fallback image search failed for term '{fallback_term}'.")
382
  else:
383
- print(f"Skipping fallback search for instructional prompt: '{prompt}'")
 
384
 
 
 
 
385
 
386
- # 4. Final Failure
387
- print(f"FATAL: Could not retrieve any suitable media for prompt: '{prompt}' after all attempts.")
388
- return None # Indicate failure
389
 
390
 
391
  def generate_tts(text, voice_id, speed):
392
  """Generate TTS audio using Kokoro, falling back to gTTS."""
393
- # --- Retain previous generate_tts function ---
394
  safe_text_prefix = re.sub(r'[^\w\s-]', '', text[:20]).strip().replace(' ', '_')
395
  output_filename = os.path.join(TEMP_FOLDER, f"tts_{safe_text_prefix}_{voice_id}.wav")
396
  if pipeline:
@@ -421,10 +613,17 @@ def generate_tts(text, voice_id, speed):
421
  wav_path = output_filename
422
  tts.save(mp3_path)
423
  audio = AudioSegment.from_mp3(mp3_path)
 
 
424
  audio.export(wav_path, format="wav")
425
  os.remove(mp3_path)
426
  print(f"gTTS audio saved and converted to {wav_path}")
427
- return wav_path
 
 
 
 
 
428
  except ImportError:
429
  print("Error: gTTS or pydub might not be installed. Cannot use gTTS fallback.")
430
  return None
@@ -434,27 +633,57 @@ def generate_tts(text, voice_id, speed):
434
 
435
  def apply_kenburns_effect(clip, target_resolution, duration):
436
  """Apply a randomized Ken Burns effect (zoom/pan) to an image clip."""
437
- # --- Retain previous apply_kenburns_effect function ---
438
  target_w, target_h = target_resolution
439
- img_w, img_h = clip.size
 
 
 
 
 
 
440
  scale_factor = 1.2
441
- scaled_w, scaled_h = img_w * scale_factor, img_h * scale_factor
442
- if scaled_w / scaled_h > target_w / target_h:
 
 
 
 
 
443
  final_h = target_h * scale_factor
444
- final_w = final_h * (img_w / img_h)
445
  else:
446
  final_w = target_w * scale_factor
447
- final_h = final_w * (img_h / img_w)
 
448
  final_w, final_h = int(final_w), int(final_h)
 
 
 
 
449
  try:
450
- pil_img = Image.fromarray(clip.get_frame(0))
451
- resized_pil = pil_img.resize((final_w, final_h), Image.Resampling.LANCZOS)
452
- resized_clip = ImageClip(np.array(resized_pil)).set_duration(duration)
453
- except Exception as e:
454
- print(f"Warning: Error during high-quality resize for Ken Burns, using MoviePy default: {e}")
455
  resized_clip = clip.resize(newsize=(final_w, final_h)).set_duration(duration)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
456
  max_move_x = final_w - target_w
457
  max_move_y = final_h - target_h
 
 
 
 
458
  effect = random.choice(['zoom_in', 'zoom_out', 'pan_lr', 'pan_rl', 'pan_td', 'pan_dt'])
459
  if effect == 'zoom_in': zoom_start, zoom_end = 1.0, scale_factor; x_start, x_end = max_move_x / 2, max_move_x / 2; y_start, y_end = max_move_y / 2, max_move_y / 2
460
  elif effect == 'zoom_out': zoom_start, zoom_end = scale_factor, 1.0; x_start, x_end = max_move_x / 2, max_move_x / 2; y_start, y_end = max_move_y / 2, max_move_y / 2
@@ -462,54 +691,170 @@ def apply_kenburns_effect(clip, target_resolution, duration):
462
  elif effect == 'pan_rl': zoom_start, zoom_end = scale_factor, scale_factor; x_start, x_end = max_move_x, 0; y_start, y_end = max_move_y / 2, max_move_y / 2
463
  elif effect == 'pan_td': zoom_start, zoom_end = scale_factor, scale_factor; x_start, x_end = max_move_x / 2, max_move_x / 2; y_start, y_end = 0, max_move_y
464
  else: zoom_start, zoom_end = scale_factor, scale_factor; x_start, x_end = max_move_x / 2, max_move_x / 2; y_start, y_end = max_move_y, 0
 
465
  def make_frame(t):
466
- interp = t / duration if duration else 0
 
 
467
  current_zoom = zoom_start + (zoom_end - zoom_start) * interp
468
  current_x = x_start + (x_end - x_start) * interp
469
  current_y = y_start + (y_end - y_start) * interp
470
- crop_w = target_w / (current_zoom / scale_factor); crop_h = target_h / (current_zoom / scale_factor)
 
 
 
 
 
 
471
  crop_w = max(1, int(crop_w)); crop_h = max(1, int(crop_h))
 
 
472
  x1 = current_x; y1 = current_y
473
  x1 = max(0, min(x1, final_w - crop_w)); y1 = max(0, min(y1, final_h - crop_h))
 
 
474
  frame = resized_clip.get_frame(t)
475
- cropped_frame = frame[int(y1):int(y1 + crop_h), int(x1):int(x1 + crop_w)]
476
- final_frame = cv2.resize(cropped_frame, (target_w, target_h), interpolation=cv2.INTER_LANCZOS4)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
477
  return final_frame
478
- return resized_clip.fl(make_frame, apply_to=['mask'])
 
 
 
 
 
 
 
 
479
 
480
  def resize_to_fill(clip, target_resolution):
481
  """Resize and crop a video clip to fill the target resolution."""
482
- # --- Retain previous resize_to_fill function ---
483
  target_w, target_h = target_resolution
484
- target_aspect = target_w / target_h
485
- if clip.w / clip.h > target_aspect: resized_clip = clip.resize(height=target_h)
486
- else: resized_clip = clip.resize(width=target_w)
487
- crop_x = max(0, (resized_clip.w - target_w) / 2)
488
- crop_y = max(0, (resized_clip.h - target_h) / 2)
489
- cropped_clip = resized_clip.crop(x1=crop_x, y1=crop_y, width=target_w, height=target_h)
490
- return cropped_clip
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
491
 
492
  def add_background_music(video_clip, music_file_path, volume):
493
  """Add background music, looping if necessary."""
494
- # --- Retain previous add_background_music function ---
495
  if not music_file_path or not os.path.exists(music_file_path):
496
  print("No background music file found or provided. Skipping.")
497
  return video_clip
498
  try:
499
  print(f"Adding background music from: {music_file_path}")
500
  bg_music = AudioFileClip(music_file_path)
 
 
 
 
 
 
 
 
 
 
501
  if bg_music.duration > video_clip.duration: bg_music = bg_music.subclip(0, video_clip.duration)
502
  elif bg_music.duration < video_clip.duration:
503
  loops_needed = math.ceil(video_clip.duration / bg_music.duration)
504
- bg_music = concatenate_audioclips([bg_music] * loops_needed)
505
- bg_music = bg_music.subclip(0, video_clip.duration)
 
 
 
 
 
 
506
  bg_music = bg_music.volumex(volume)
507
- # Check if video_clip has audio before composing
508
  if video_clip.audio:
509
- final_audio = CompositeAudioClip([video_clip.audio, bg_music])
 
 
 
 
 
 
 
 
 
 
 
 
510
  else:
511
- # If original clip has no audio, just use the background music
512
- final_audio = bg_music
513
  video_clip = video_clip.set_audio(final_audio)
514
  print("Background music added successfully.")
515
  return video_clip
@@ -519,39 +864,65 @@ def add_background_music(video_clip, music_file_path, volume):
519
 
520
  def create_segment_clip(media_info, tts_path, narration_text):
521
  """Create a single video segment (clip) with visuals, audio, and subtitles."""
522
- # --- Retain previous create_segment_clip function ---
523
  try:
524
  media_path = media_info['path']
525
  asset_type = media_info['asset_type']
526
  print(f"Creating clip segment: Type={asset_type}, Media={os.path.basename(media_path)}")
527
- if not os.path.exists(tts_path): print(f"Error: TTS file not found: {tts_path}"); return None
 
528
  audio_clip = AudioFileClip(tts_path)
 
 
529
  segment_duration = audio_clip.duration + 0.3
 
530
  if asset_type == "video":
531
- if not os.path.exists(media_path): print(f"Error: Video file not found: {media_path}"); return None
532
- video_clip = VideoFileClip(media_path)
533
- if video_clip.duration < segment_duration:
534
- loops = math.ceil(segment_duration / video_clip.duration)
535
- try:
536
- # Handle potential zero duration clips during looping
537
- if video_clip.duration > 0:
538
- video_clip = concatenate_videoclips([video_clip] * loops)
539
- else:
540
- print(f"Warning: Video clip has zero duration, cannot loop: {media_path}")
541
- # Create a short black clip instead? Or fail? Let's fail for now.
542
- return None
543
- except Exception as loop_err:
544
- print(f"Error looping video {media_path}: {loop_err}")
545
- return None # Fail segment if looping fails
546
-
547
- video_clip = video_clip.subclip(0, segment_duration)
548
- visual_clip = resize_to_fill(video_clip, TARGET_RESOLUTION)
 
 
 
 
 
 
 
 
 
 
 
 
549
  elif asset_type == "image":
550
- if not os.path.exists(media_path): print(f"Error: Image file not found: {media_path}"); return None
551
- img_clip = ImageClip(media_path).set_duration(segment_duration)
552
- visual_clip = apply_kenburns_effect(img_clip, TARGET_RESOLUTION, segment_duration)
553
- visual_clip = visual_clip.resize(newsize=TARGET_RESOLUTION)
 
 
 
 
 
 
 
 
 
554
  else: print(f"Error: Unknown asset type: {asset_type}"); return None
 
555
  visual_clip = visual_clip.fadein(0.15).fadeout(0.15)
556
  subtitle_clips = []
557
  if USE_CAPTIONS and narration_text:
@@ -566,35 +937,54 @@ def create_segment_clip(media_info, tts_path, narration_text):
566
  num_chunks = len(chunks); chunk_duration = audio_clip.duration / num_chunks
567
  start_time = 0.1
568
  for i, chunk_text in enumerate(chunks):
569
- end_time = min(start_time + chunk_duration, segment_duration - 0.1)
 
 
 
570
  try:
571
  txt_clip = TextClip(txt=chunk_text, fontsize=font_size, font=caption_font, color=caption_style_text_color,
572
  bg_color=caption_style_bg_color, method='label', align='center',
573
  size=(TARGET_RESOLUTION[0] * 0.8, None))
574
  txt_clip = txt_clip.set_position(('center', TARGET_RESOLUTION[1] * 0.80))
575
- txt_clip = txt_clip.set_start(start_time).set_duration(max(0.1, end_time - start_time)) # Ensure non-zero duration
576
  subtitle_clips.append(txt_clip)
577
- start_time = end_time
578
  except Exception as txt_err:
579
  print(f"ERROR creating TextClip for '{chunk_text}': {txt_err}. Skipping subtitle chunk.")
580
- # If one subtitle fails, continue without it
581
 
582
- final_clip = CompositeVideoClip([visual_clip] + subtitle_clips) if subtitle_clips else visual_clip
583
- final_clip = final_clip.set_audio(audio_clip.set_start(0.15))
 
 
 
 
584
  print(f"Clip segment created successfully. Duration: {final_clip.duration:.2f}s")
 
 
 
 
 
 
 
 
 
585
  return final_clip
586
  except Exception as e:
587
  print(f"Error creating clip segment: {e}")
588
  import traceback
589
  traceback.print_exc()
 
 
 
 
590
  return None
591
 
 
592
  # ---------------- Main Video Generation Function ---------------- #
593
 
594
  def generate_full_video(user_input, resolution_choice, caption_choice, music_file_info):
595
  """Main function orchestrating the video generation process."""
596
- # --- Retain most of previous generate_full_video function ---
597
- # (Ensure it handles None from generate_media correctly)
598
  global TARGET_RESOLUTION, TEMP_FOLDER, USE_CAPTIONS
599
  print("\n--- Starting Video Generation ---"); start_time = time.time()
600
  if resolution_choice == "Short (9:16)": TARGET_RESOLUTION = (1080, 1920); print("Resolution set to: Short (1080x1920)")
@@ -605,6 +995,8 @@ def generate_full_video(user_input, resolution_choice, caption_choice, music_fil
605
  if music_file_info is not None:
606
  try:
607
  music_file_path = os.path.join(TEMP_FOLDER, "background_music.mp3")
 
 
608
  shutil.copy(music_file_info.name, music_file_path)
609
  print(f"Background music copied to: {music_file_path}")
610
  except Exception as e: print(f"Error handling uploaded music file: {e}"); music_file_path = None
@@ -624,57 +1016,84 @@ def generate_full_video(user_input, resolution_choice, caption_choice, music_fil
624
  print(f" Prompt: {segment['prompt']}")
625
  print(f" Narration: {segment['narration']}")
626
 
627
- media_info = generate_media(segment['prompt'])
628
- # --- Crucial Check ---
629
  if not media_info:
630
  print(f"Warning: Failed to get media for segment {i+1} ('{segment['prompt']}'). Skipping this segment.")
631
- continue # Skip segment if media generation failed
632
 
633
  tts_path = generate_tts(segment['narration'], selected_voice, voice_speed)
634
  if not tts_path:
635
  print(f"Warning: Failed to generate TTS for segment {i+1}. Skipping segment.")
636
  if media_info and os.path.exists(media_info['path']):
637
  try: os.remove(media_info['path']); print(f"Cleaned up unused media: {media_info['path']}")
638
- except OSError: pass
639
  continue
640
 
 
641
  clip = create_segment_clip(media_info, tts_path, segment['narration'])
642
  if clip:
643
  segment_clips.append(clip)
644
  else:
645
  print(f"Warning: Failed to create video clip for segment {i+1}. Skipping.")
 
646
  if media_info and os.path.exists(media_info['path']):
647
  try: os.remove(media_info['path']); print(f"Cleaned up media for failed clip: {media_info['path']}")
648
- except OSError: pass
649
  if tts_path and os.path.exists(tts_path):
650
  try: os.remove(tts_path); print(f"Cleaned up TTS for failed clip: {tts_path}")
651
- except OSError: pass
 
652
 
653
  if not segment_clips:
654
  print("ERROR: No video clips were successfully created. Aborting.")
655
- shutil.rmtree(TEMP_FOLDER)
 
656
  return None, "Error: Failed to create any video segments. Check logs for media/TTS issues."
657
 
658
  print("\nStep 4: Concatenating video segments...");
 
659
  try:
660
- # Filter out potential None values just in case, although the loop should prevent them
661
  valid_clips = [c for c in segment_clips if c is not None]
662
- if not valid_clips:
663
- raise ValueError("No valid clips remained after processing.")
664
  final_video = concatenate_videoclips(valid_clips, method="compose")
665
  print("Segments concatenated successfully.")
 
 
 
 
666
  except Exception as e:
667
- print(f"ERROR: Failed to concatenate video clips: {e}"); shutil.rmtree(TEMP_FOLDER); return None, f"Error: Concatenation failed: {e}"
 
 
 
 
 
 
 
 
 
 
 
 
 
668
 
669
- print("\nStep 5: Adding background music..."); final_video = add_background_music(final_video, music_file_path, bg_music_volume)
670
 
671
  print(f"\nStep 6: Exporting final video to '{OUTPUT_VIDEO_FILENAME}'..."); export_success = False
672
- try:
673
- final_video.write_videofile(OUTPUT_VIDEO_FILENAME, codec='libx264', audio_codec='aac', fps=fps, preset=preset, threads=4, logger='bar')
674
- print(f"Final video saved successfully as {OUTPUT_VIDEO_FILENAME}")
675
- export_success = True
676
- except Exception as e:
677
- print(f"ERROR: Failed to write final video file: {e}"); import traceback; traceback.print_exc()
 
 
 
 
 
 
 
 
678
 
679
  print("\nStep 7: Cleaning up temporary files...");
680
  try: shutil.rmtree(TEMP_FOLDER); print(f"Temporary folder {TEMP_FOLDER} removed.")
@@ -683,11 +1102,12 @@ def generate_full_video(user_input, resolution_choice, caption_choice, music_fil
683
  end_time = time.time(); total_time = end_time - start_time
684
  print(f"\n--- Video Generation Finished ---"); print(f"Total time: {total_time:.2f} seconds")
685
  if export_success: return OUTPUT_VIDEO_FILENAME, f"Video generation complete! Time: {total_time:.2f}s"
 
686
  else: return None, f"Error: Video export failed. Check logs. Time: {total_time:.2f}s"
687
 
688
 
689
  # ---------------- Gradio Interface Definition ---------------- #
690
- # --- Retain previous Gradio Interface code ---
691
  VOICE_CHOICES = {
692
  'Emma (US Female)': 'af_heart', 'Bella (US Female)': 'af_bella', 'Nicole (US Female)': 'af_nicole',
693
  'Sarah (US Female)': 'af_sarah', 'Michael (US Male)': 'am_michael', 'Eric (US Male)': 'am_eric',
@@ -696,8 +1116,18 @@ VOICE_CHOICES = {
696
  }
697
  def gradio_interface_handler(user_prompt, resolution, captions, bg_music, voice_name, video_prob, music_vol, video_fps, export_preset, tts_speed, caption_size):
698
  print("\n--- Received Request from Gradio ---")
699
- print(f"Prompt: {user_prompt[:50]}...") # Print inputs for debugging
700
- # ... (print other inputs) ...
 
 
 
 
 
 
 
 
 
 
701
  global selected_voice, voice_speed, font_size, video_clip_probability, bg_music_volume, fps, preset
702
  selected_voice = VOICE_CHOICES.get(voice_name, 'af_heart')
703
  voice_speed = tts_speed; font_size = caption_size; video_clip_probability = video_prob / 100.0
@@ -745,4 +1175,4 @@ if __name__ == "__main__":
745
  print("!!! Please replace 'YOUR_PEXELS_API_KEY' and !!!")
746
  print("!!! 'YOUR_OPENROUTER_API_KEY' with your actual keys. !!!")
747
  print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n")
748
- iface.launch(share=True, debug=True)
 
12
  import requests
13
  import io
14
  import shutil
15
+ from urllib.parse import quote # Needed for search_google_images
16
  import numpy as np
17
+ from bs4 import BeautifulSoup # Needed for search_google_images
18
  import base64
19
  from gtts import gTTS
20
  import gradio as gr
 
33
  from pydub.generators import Sine
34
 
35
  # ---------------- Global Configuration ---------------- #
 
36
  PEXELS_API_KEY = 'BhJqbcdm9Vi90KqzXKAhnEHGsuFNv4irXuOjWtT761U49lRzo03qBGna'
37
  OPENROUTER_API_KEY = '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'
38
  OPENROUTER_MODEL = "mistralai/mistral-small-3.1-24b-instruct:free"
 
70
 
71
  def generate_script(user_input):
72
  """Generate documentary script using OpenRouter API."""
73
+ # (Retained from previous versions)
 
74
  headers = {
75
  'Authorization': f'Bearer {OPENROUTER_API_KEY}',
76
  'Content-Type': 'application/json',
 
142
 
143
  def parse_script(script_text):
144
  """Parse the generated script into segments."""
145
+ # (Retained from previous versions)
 
146
  segments = []
147
  current_title = None
148
  current_narration = ""
 
175
  print(f"Parsed {len(segments)} segments from script.")
176
  return segments
177
 
178
+ # --- Start: User Provided Functions ---
179
+
180
+ def search_pexels_videos(query, pexels_api_key):
181
+ """Search for a video on Pexels by query and return a random HD video."""
182
+ if not pexels_api_key or pexels_api_key == 'YOUR_PEXELS_API_KEY':
183
+ print(f"Pexels API key not provided or is default. Skipping Pexels video search.")
184
  return None
185
+ headers = {'Authorization': pexels_api_key}
186
+ base_url = "https://api.pexels.com/videos/search"
187
+ num_pages = 3
188
+ videos_per_page = 15
189
+
190
+ max_retries = 3
191
+ retry_delay = 1
192
+ timeout_duration = 15 # Increased timeout slightly
193
+
194
+ search_query = query
195
+ all_videos = []
196
+ print(f"Searching Pexels videos for '{query}' (up to {num_pages} pages)...")
197
+
198
+ for page in range(1, num_pages + 1):
199
+ print(f" Pexels Video Search: Page {page}")
200
+ for attempt in range(max_retries):
201
+ try:
202
+ params = {"query": search_query, "per_page": videos_per_page, "page": page, "orientation": "landscape"}
203
+ response = requests.get(base_url, headers=headers, params=params, timeout=timeout_duration)
204
+
205
+ if response.status_code == 200:
206
+ data = response.json()
207
+ videos = data.get("videos", [])
208
+
209
+ if not videos:
210
+ print(f" No videos found on page {page} for '{query}'.")
211
+ # Don't break inner loop immediately, maybe next page has results
212
+ # Break outer loop if no videos found on *this* page attempt
213
+ break # Break attempt loop for this page
214
+
215
+ found_on_page = 0
216
+ for video in videos:
217
+ video_files = video.get("video_files", [])
218
+ # Prioritize HD, then large, then medium
219
+ hd_link = next((f['link'] for f in video_files if f.get('quality') == 'hd' and f.get('width', 0) >= 1080), None)
220
+ large_link = next((f['link'] for f in video_files if f.get('quality') == 'large' and f.get('width', 0) >= 1080), None)
221
+ medium_link = next((f['link'] for f in video_files if f.get('quality') == 'medium'), None) # Fallback if no HD/Large
222
+ link_to_add = hd_link or large_link or medium_link
223
+
224
+ if link_to_add:
225
+ all_videos.append(link_to_add)
226
+ found_on_page += 1
227
+ # Don't break inner loop, collect all suitable videos from the page
228
 
229
+ print(f" Found {found_on_page} suitable videos on page {page}.")
230
+ break # Break attempt loop successfully after processing page
231
+
232
+ elif response.status_code == 401:
233
+ print(f" Pexels API Error: Unauthorized (401). Check your API Key.")
234
+ return None # Stop searching if key is bad
235
+ elif response.status_code == 429:
236
+ print(f" Rate limit hit (attempt {attempt+1}/{max_retries}). Retrying in {retry_delay} seconds...")
237
+ time.sleep(retry_delay)
238
+ retry_delay *= 2
239
+ elif response.status_code == 522:
240
+ print(f" Pexels API Error: Connection Timed Out (522) (attempt {attempt+1}/{max_retries}). Retrying in {retry_delay} seconds...")
241
+ time.sleep(retry_delay)
242
+ retry_delay *= 2
243
+ else:
244
+ print(f" Error fetching videos: {response.status_code} {response.text}")
245
+ if attempt < max_retries - 1:
246
+ print(f" Retrying in {retry_delay} seconds...")
247
+ time.sleep(retry_delay)
248
+ retry_delay *= 2
249
+ else:
250
+ print(f" Max retries reached for page {page}.")
251
+ break # Break attempt loop for this page after max retries
252
+
253
+ except requests.exceptions.Timeout:
254
+ print(f" Request timed out (attempt {attempt+1}/{max_retries}). Retrying in {retry_delay} seconds...")
255
+ time.sleep(retry_delay)
256
+ retry_delay *= 2
257
+ except requests.exceptions.RequestException as e:
258
+ print(f" Request exception: {e}")
259
+ if attempt < max_retries - 1:
260
+ print(f" Retrying in {retry_delay} seconds...")
261
+ time.sleep(retry_delay)
262
+ retry_delay *= 2
263
+ else:
264
+ print(f" Max retries reached for page {page} due to request exception.")
265
+ break # Break attempt loop for this page
266
+
267
+ # Reset retry delay for the next page
268
+ retry_delay = 1
269
+
270
+ if all_videos:
271
+ random_video = random.choice(all_videos)
272
+ print(f"Selected random video from {len(all_videos)} suitable videos found across pages.")
273
+ return random_video
274
+ else:
275
+ print(f"No suitable videos found for '{query}' after searching {num_pages} pages.")
276
+ return None
277
+
278
+ def search_pexels_images(query, pexels_api_key):
279
+ """Search for an image on Pexels by query."""
280
+ if not pexels_api_key or pexels_api_key == 'YOUR_PEXELS_API_KEY':
281
+ print(f"Pexels API key not provided or is default. Skipping Pexels image search.")
282
+ return None
283
+ headers = {'Authorization': pexels_api_key}
284
+ url = "https://api.pexels.com/v1/search"
285
+ # Fetch more results to increase chance of finding good ones
286
  params = {"query": query, "per_page": 15, "orientation": "landscape"}
 
 
287
 
288
  max_retries = 3
289
+ retry_delay = 1
290
+ timeout_duration = 15 # Increased timeout slightly
291
 
292
+ print(f"Searching Pexels images for '{query}'...")
293
 
294
  for attempt in range(max_retries):
295
  try:
296
+ response = requests.get(url, headers=headers, params=params, timeout=timeout_duration)
297
+
298
+ if response.status_code == 200:
299
+ data = response.json()
300
+ photos = data.get("photos", [])
301
+ if photos:
302
+ # Select from all returned photos, preferring larger sizes
303
+ valid_photos = []
304
+ for photo in photos:
305
+ # Prefer large2x or original, fallback to large
306
+ large2x_url = photo.get("src", {}).get("large2x")
307
+ original_url = photo.get("src", {}).get("original")
308
+ large_url = photo.get("src", {}).get("large")
309
+ img_url = large2x_url or original_url or large_url
310
+ if img_url:
311
+ valid_photos.append(img_url)
312
+
313
+ if valid_photos:
314
+ selected_photo = random.choice(valid_photos)
315
+ print(f"Selected random image from {len(valid_photos)} suitable images found.")
316
+ return selected_photo
317
+ else:
318
+ print(f"No suitable image URLs found in results for query: {query}")
319
+ return None # Found photos but no usable URLs
320
+ else:
321
+ print(f"No images found for query: {query}")
322
+ return None # API returned empty 'photos' list
323
+
324
+ elif response.status_code == 401:
325
+ print(f" Pexels API Error: Unauthorized (401). Check your API Key.")
326
+ return None # Stop searching if key is bad
327
+ elif response.status_code == 429:
328
+ print(f" Rate limit hit (attempt {attempt+1}/{max_retries}). Retrying in {retry_delay} seconds...")
329
+ time.sleep(retry_delay)
330
+ retry_delay *= 2
331
+ elif response.status_code == 522:
332
+ print(f" Pexels API Error: Connection Timed Out (522) (attempt {attempt+1}/{max_retries}). Retrying in {retry_delay} seconds...")
333
  time.sleep(retry_delay)
334
  retry_delay *= 2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
335
  else:
336
+ print(f" Error fetching images: {response.status_code} {response.text}")
337
+ if attempt < max_retries - 1:
338
+ print(f" Retrying in {retry_delay} seconds...")
339
+ time.sleep(retry_delay)
340
+ retry_delay *= 2
341
+ else:
342
+ break # Max retries for other errors
343
 
344
  except requests.exceptions.Timeout:
345
+ print(f" Request timed out (attempt {attempt+1}/{max_retries}). Retrying in {retry_delay} seconds...")
346
  time.sleep(retry_delay)
347
  retry_delay *= 2
348
  except requests.exceptions.RequestException as e:
349
+ print(f" Request exception: {e}")
350
+ if attempt < max_retries - 1:
351
+ print(f" Retrying in {retry_delay} seconds...")
352
+ time.sleep(retry_delay)
353
+ retry_delay *= 2
354
+ else:
355
+ break # Max retries after request exception
356
 
357
+ print(f"No Pexels images found for query: {query} after all attempts")
358
  return None
359
 
360
+ # Added back search_google_images as it's used in the provided generate_media
361
+ def search_google_images(query):
362
+ """Search for images on Google Images (use sparingly and ethically)."""
363
+ print(f"Attempting Google Image search for: {query} (Use with caution)")
364
+ try:
365
+ search_url = f"https://www.google.com/search?q={quote(query)}&tbm=isch&safe=active"
366
+ headers = {"User-Agent": USER_AGENT}
367
+ response = requests.get(search_url, headers=headers, timeout=10)
368
+ response.raise_for_status()
369
+ soup = BeautifulSoup(response.text, "html.parser")
370
+
371
+ img_tags = soup.find_all("img")
372
+ image_urls = []
373
+ for img in img_tags:
374
+ src = img.get("data-src") or img.get("src")
375
+ if src and src.startswith("http") and not "gstatic" in src and not src.startswith("data:image"):
376
+ if any(ext in src.lower() for ext in ['.jpg', '.jpeg', '.png', '.webp']):
377
+ image_urls.append(src)
378
+
379
+ if image_urls:
380
+ print(f"Found {len(image_urls)} potential Google Images for '{query}'.")
381
+ # Select randomly from top 10 potential URLs
382
+ return random.choice(image_urls[:min(len(image_urls), 10)])
383
+ else:
384
+ print(f"No suitable Google Images found for query: {query}")
385
+ return None
386
+ except requests.exceptions.RequestException as e:
387
+ print(f"Error during Google Images search request: {e}")
388
+ return None
389
+ except Exception as e:
390
+ print(f"Error parsing Google Images search results: {e}")
391
+ return None
392
+
393
 
394
+ def download_image(image_url, filename):
395
+ """Download an image from a URL to a local file with enhanced error handling."""
 
 
396
  try:
397
  headers = {"User-Agent": USER_AGENT}
398
+ print(f"Downloading image from: {image_url} to {filename}")
399
+ response = requests.get(image_url, headers=headers, stream=True, timeout=20) # Increased timeout
400
  response.raise_for_status()
401
+
402
+ # Ensure the target directory exists
403
+ os.makedirs(os.path.dirname(filename), exist_ok=True)
404
+
 
 
 
 
 
 
 
 
 
 
 
405
  with open(filename, 'wb') as f:
406
+ for chunk in response.iter_content(chunk_size=8192):
407
+ f.write(chunk)
408
 
409
+ print(f" Image downloaded successfully to: {filename}")
410
+
411
+ # Validate and convert image
412
+ try:
413
+ img = Image.open(filename)
414
+ img.verify() # Check if it's a valid image file format
415
+ img.close() # Close file handle after verify
416
+
417
+ # Re-open to check mode and convert if needed
418
+ img = Image.open(filename)
419
+ if img.mode != 'RGB':
420
+ print(f" Converting image {os.path.basename(filename)} to RGB.")
421
+ # Ensure conversion doesn't create an empty file on error
422
+ try:
423
  rgb_img = img.convert('RGB')
424
+ # Save to a temporary file first, then replace original
425
+ temp_filename = filename + ".tmp.jpg"
426
+ rgb_img.save(temp_filename, "JPEG")
427
  rgb_img.close()
428
  img.close()
429
+ os.replace(temp_filename, filename) # Atomic replace if possible
430
+ print(f" Image successfully converted and saved as {os.path.basename(filename)}")
431
+ except Exception as e_convert:
432
+ print(f" Error converting image to RGB: {e_convert}")
433
+ img.close() # Close original image handle
434
+ if os.path.exists(filename): os.remove(filename) # Remove partially converted/original
435
+ if os.path.exists(temp_filename): os.remove(temp_filename) # Clean up temp
436
+ return None
437
+ else:
438
+ img.close() # Close if already RGB
439
+ print(f" Image {os.path.basename(filename)} validated (already RGB).")
440
+
441
+ # Final check if file exists after processing
442
+ if os.path.exists(filename) and os.path.getsize(filename) > 0:
443
+ return filename
444
+ else:
445
+ print(f" Image file {os.path.basename(filename)} missing or empty after processing.")
446
+ return None
447
+
448
+ except (IOError, SyntaxError, Image.UnidentifiedImageError) as e_validate:
449
+ print(f" Downloaded file {os.path.basename(filename)} is not a valid image or corrupted: {e_validate}")
450
+ if os.path.exists(filename): os.remove(filename)
451
+ return None
452
+ except Exception as e_general_process:
453
+ print(f" Unexpected error during image processing: {e_general_process}")
454
+ if os.path.exists(filename): os.remove(filename)
455
+ return None
456
+
457
+
458
  except requests.exceptions.RequestException as e_download:
459
+ print(f" Image download error: {e_download}")
460
+ # Don't remove file here, might not exist or be partial
461
  return None
462
+ except Exception as e_general_download:
463
+ print(f" General error during image download request: {e_general_download}")
464
  return None
465
 
466
+ def download_video(video_url, filename):
467
+ """Download a video from a URL to a local file."""
468
+ try:
469
+ headers = {"User-Agent": USER_AGENT} # Add User-Agent
470
+ print(f"Downloading video from: {video_url} to {filename}")
471
+ response = requests.get(video_url, headers=headers, stream=True, timeout=60) # Longer timeout for videos
472
+ response.raise_for_status()
473
+
474
+ # Ensure the target directory exists
475
+ os.makedirs(os.path.dirname(filename), exist_ok=True)
476
+
477
+ with open(filename, 'wb') as f:
478
+ for chunk in response.iter_content(chunk_size=1024*1024): # Larger chunk size for videos
479
+ f.write(chunk)
480
+ print(f" Video downloaded successfully to: {filename}")
481
+ # Basic check: file exists and has size > 0
482
+ if os.path.exists(filename) and os.path.getsize(filename) > 0:
483
+ return filename
484
+ else:
485
+ print(f" Video file {os.path.basename(filename)} missing or empty after download.")
486
+ if os.path.exists(filename): os.remove(filename) # Clean up empty file
487
+ return None
488
+ except requests.exceptions.RequestException as e:
489
+ print(f"Video download error: {e}")
490
+ # Don't remove file here, might not exist or be partial
491
+ return None
492
+ except Exception as e_general:
493
+ print(f"General error during video download: {e_general}")
494
+ return None
495
+
496
+ def generate_media(prompt, user_image=None, current_index=0, total_segments=1):
497
  """
498
+ Generate a visual asset using user-provided functions.
499
+ Checks for "news", tries Pexels video/image, falls back to generic Pexels image.
 
500
  """
501
+ # Sanitize prompt for filename
502
+ safe_prompt_base = re.sub(r'[^\w\s-]', '', prompt).strip().replace(' ', '_')
503
+ safe_prompt_base = safe_prompt_base[:50] # Limit length
504
+ # Add timestamp for uniqueness within the temp folder
505
+ safe_prompt = f"{safe_prompt_base}_{int(time.time())}"
506
+
507
+ # --- News Check (Uses Google Images) ---
508
+ if "news" in prompt.lower():
509
+ print(f"News-related query detected: '{prompt}'. Trying Google Images...")
510
+ image_file = os.path.join(TEMP_FOLDER, f"{safe_prompt}_news.jpg")
511
+ image_url = search_google_images(prompt) # Use the added back function
512
+ if image_url:
513
+ downloaded_image = download_image(image_url, image_file)
514
+ if downloaded_image:
515
+ print(f" Using Google Image news asset: {os.path.basename(downloaded_image)}")
516
+ return {"path": downloaded_image, "asset_type": "image"}
517
+ else:
518
+ print(f" Google Image download failed for: {image_url}")
519
+ else:
520
+ print(f" Google Images search failed for prompt: '{prompt}'. Proceeding with Pexels.")
521
+ # Fall through to Pexels if Google fails
522
 
523
+ # --- Pexels Video Attempt ---
524
  if random.random() < video_clip_probability:
525
+ print(f"Attempting Pexels video search for: '{prompt}'")
526
+ video_file = os.path.join(TEMP_FOLDER, f"{safe_prompt}_video.mp4")
527
+ video_url = search_pexels_videos(prompt, PEXELS_API_KEY)
528
  if video_url:
529
+ downloaded_video = download_video(video_url, video_file)
530
+ if downloaded_video:
531
+ print(f" Using Pexels video asset: {os.path.basename(downloaded_video)}")
532
+ return {"path": downloaded_video, "asset_type": "video"}
533
+ else:
534
+ print(f" Pexels video download failed for: {video_url}")
535
  else:
536
+ print(f" Pexels video search yielded no results for '{prompt}'.")
537
+ else:
538
+ print(f"Skipping Pexels video search based on probability for '{prompt}'.")
539
 
540
+
541
+ # --- Pexels Image Attempt ---
542
+ print(f"Attempting Pexels image search for: '{prompt}'")
543
+ image_file = os.path.join(TEMP_FOLDER, f"{safe_prompt}_image.jpg")
544
+ image_url = search_pexels_images(prompt, PEXELS_API_KEY)
545
  if image_url:
546
+ downloaded_image = download_image(image_url, image_file)
547
+ if downloaded_image:
548
+ print(f" Using Pexels image asset: {os.path.basename(downloaded_image)}")
549
+ return {"path": downloaded_image, "asset_type": "image"}
550
+ else:
551
+ print(f" Pexels image download failed for: {image_url}")
552
  else:
553
+ print(f" Pexels image search yielded no results for '{prompt}'.")
554
+
555
+ # --- Fallback Pexels Image Attempt ---
556
+ # Avoid fallback for instructional prompts
 
 
 
 
 
 
 
 
557
  if "subscribe" not in prompt.lower() and "cta" not in prompt.lower():
558
+ fallback_terms = ["technology", "abstract", "nature", "background", "texture"]
559
+ term = random.choice(fallback_terms)
560
+ print(f"All specific searches failed for '{prompt}'. Trying Pexels fallback term: '{term}'")
561
+ fallback_file = os.path.join(TEMP_FOLDER, f"{safe_prompt}_fallback_{term}.jpg")
562
+ fallback_url = search_pexels_images(term, PEXELS_API_KEY)
563
  if fallback_url:
564
+ downloaded_fallback = download_image(fallback_url, fallback_file)
565
+ if downloaded_fallback:
566
+ print(f" Using Pexels fallback image asset: {os.path.basename(downloaded_fallback)}")
567
+ return {"path": downloaded_fallback, "asset_type": "image"}
568
  else:
569
+ print(f" Pexels fallback image download failed for term: '{term}'")
570
  else:
571
+ print(f" Pexels fallback image search failed for term: '{term}'")
572
  else:
573
+ print(f"Skipping fallback search for instructional prompt: '{prompt}'")
574
+
575
 
576
+ # --- Final Failure ---
577
+ print(f"FATAL: Failed to generate any visual asset for prompt: '{prompt}'")
578
+ return None
579
 
580
+ # --- End: User Provided Functions ---
 
 
581
 
582
 
583
  def generate_tts(text, voice_id, speed):
584
  """Generate TTS audio using Kokoro, falling back to gTTS."""
585
+ # (Retained from previous versions)
586
  safe_text_prefix = re.sub(r'[^\w\s-]', '', text[:20]).strip().replace(' ', '_')
587
  output_filename = os.path.join(TEMP_FOLDER, f"tts_{safe_text_prefix}_{voice_id}.wav")
588
  if pipeline:
 
613
  wav_path = output_filename
614
  tts.save(mp3_path)
615
  audio = AudioSegment.from_mp3(mp3_path)
616
+ # Ensure target directory exists for export
617
+ os.makedirs(os.path.dirname(wav_path), exist_ok=True)
618
  audio.export(wav_path, format="wav")
619
  os.remove(mp3_path)
620
  print(f"gTTS audio saved and converted to {wav_path}")
621
+ # Check if file exists and has size
622
+ if os.path.exists(wav_path) and os.path.getsize(wav_path) > 0:
623
+ return wav_path
624
+ else:
625
+ print(f"Error: gTTS output file missing or empty: {wav_path}")
626
+ return None
627
  except ImportError:
628
  print("Error: gTTS or pydub might not be installed. Cannot use gTTS fallback.")
629
  return None
 
633
 
634
  def apply_kenburns_effect(clip, target_resolution, duration):
635
  """Apply a randomized Ken Burns effect (zoom/pan) to an image clip."""
636
+ # (Retained from previous versions)
637
  target_w, target_h = target_resolution
638
+ try: # Add try-except around accessing clip properties
639
+ img_w, img_h = clip.size
640
+ except Exception as e:
641
+ print(f"Error accessing image clip size: {e}")
642
+ # Cannot apply effect if size is unknown, return original clip resized
643
+ return clip.resize(newsize=target_resolution).set_duration(duration)
644
+
645
  scale_factor = 1.2
646
+ # Handle potential division by zero if img_h is 0
647
+ if img_h == 0: img_h = 1
648
+ img_aspect = img_w / img_h
649
+ target_aspect = target_w / target_h
650
+
651
+ # Resize logic
652
+ if img_aspect > target_aspect:
653
  final_h = target_h * scale_factor
654
+ final_w = final_h * img_aspect
655
  else:
656
  final_w = target_w * scale_factor
657
+ final_h = final_w / img_aspect # Use img_aspect here
658
+
659
  final_w, final_h = int(final_w), int(final_h)
660
+ # Ensure final dimensions are not zero
661
+ if final_w <= 0: final_w = 1
662
+ if final_h <= 0: final_h = 1
663
+
664
  try:
665
+ # Use MoviePy's resize for ImageClip directly before applying fl
 
 
 
 
666
  resized_clip = clip.resize(newsize=(final_w, final_h)).set_duration(duration)
667
+ # pil_img = Image.fromarray(clip.get_frame(0)) # Avoid get_frame here
668
+ # resized_pil = pil_img.resize((final_w, final_h), Image.Resampling.LANCZOS)
669
+ # resized_clip = ImageClip(np.array(resized_pil)).set_duration(duration)
670
+ except Exception as e:
671
+ print(f"Warning: Error during resize for Ken Burns, using MoviePy default: {e}")
672
+ try:
673
+ # Fallback resize
674
+ resized_clip = clip.resize(newsize=(final_w, final_h)).set_duration(duration)
675
+ except Exception as resize_err:
676
+ print(f"FATAL error during fallback resize for Ken Burns: {resize_err}")
677
+ # Return original clip resized to target as last resort
678
+ return clip.resize(newsize=target_resolution).set_duration(duration)
679
+
680
+
681
  max_move_x = final_w - target_w
682
  max_move_y = final_h - target_h
683
+ # Ensure max_move is not negative if resizing failed slightly
684
+ max_move_x = max(0, max_move_x)
685
+ max_move_y = max(0, max_move_y)
686
+
687
  effect = random.choice(['zoom_in', 'zoom_out', 'pan_lr', 'pan_rl', 'pan_td', 'pan_dt'])
688
  if effect == 'zoom_in': zoom_start, zoom_end = 1.0, scale_factor; x_start, x_end = max_move_x / 2, max_move_x / 2; y_start, y_end = max_move_y / 2, max_move_y / 2
689
  elif effect == 'zoom_out': zoom_start, zoom_end = scale_factor, 1.0; x_start, x_end = max_move_x / 2, max_move_x / 2; y_start, y_end = max_move_y / 2, max_move_y / 2
 
691
  elif effect == 'pan_rl': zoom_start, zoom_end = scale_factor, scale_factor; x_start, x_end = max_move_x, 0; y_start, y_end = max_move_y / 2, max_move_y / 2
692
  elif effect == 'pan_td': zoom_start, zoom_end = scale_factor, scale_factor; x_start, x_end = max_move_x / 2, max_move_x / 2; y_start, y_end = 0, max_move_y
693
  else: zoom_start, zoom_end = scale_factor, scale_factor; x_start, x_end = max_move_x / 2, max_move_x / 2; y_start, y_end = max_move_y, 0
694
+
695
  def make_frame(t):
696
+ # Protect against duration being zero
697
+ interp = t / duration if duration > 0 else 0
698
+
699
  current_zoom = zoom_start + (zoom_end - zoom_start) * interp
700
  current_x = x_start + (x_end - x_start) * interp
701
  current_y = y_start + (y_end - y_start) * interp
702
+
703
+ # Avoid division by zero for zoom
704
+ if current_zoom <= 0: current_zoom = 1e-6 # Small positive number
705
+
706
+ # Calculate crop box dimensions based on current zoom relative to the base scale_factor
707
+ crop_w = target_w / (current_zoom / scale_factor)
708
+ crop_h = target_h / (current_zoom / scale_factor)
709
  crop_w = max(1, int(crop_w)); crop_h = max(1, int(crop_h))
710
+
711
+ # Calculate top-left corner (x1, y1)
712
  x1 = current_x; y1 = current_y
713
  x1 = max(0, min(x1, final_w - crop_w)); y1 = max(0, min(y1, final_h - crop_h))
714
+
715
+ # Get frame from the *resized* clip
716
  frame = resized_clip.get_frame(t)
717
+
718
+ # Crop using numpy slicing
719
+ try:
720
+ # Ensure indices are integers and within bounds
721
+ y1_int, y2_int = int(y1), int(y1 + crop_h)
722
+ x1_int, x2_int = int(x1), int(x1 + crop_w)
723
+ # Clamp coordinates to frame dimensions BEFORE slicing
724
+ y1_int = max(0, min(y1_int, frame.shape[0] - 1))
725
+ y2_int = max(y1_int + 1, min(y2_int, frame.shape[0])) # Ensure y2 > y1
726
+ x1_int = max(0, min(x1_int, frame.shape[1] - 1))
727
+ x2_int = max(x1_int + 1, min(x2_int, frame.shape[1])) # Ensure x2 > x1
728
+
729
+ # Check if dimensions are valid after clamping
730
+ if y2_int <= y1_int or x2_int <= x1_int:
731
+ print(f"Warning: Invalid crop dimensions after clamping ({y1_int}:{y2_int}, {x1_int}:{x2_int}). Returning uncropped frame.")
732
+ # Resize original frame to target as fallback for this frame
733
+ return cv2.resize(frame, (target_w, target_h), interpolation=cv2.INTER_AREA)
734
+
735
+
736
+ cropped_frame = frame[y1_int:y2_int, x1_int:x2_int]
737
+
738
+ # Check if cropped frame is empty
739
+ if cropped_frame.size == 0:
740
+ print(f"Warning: Cropped frame is empty ({y1_int}:{y2_int}, {x1_int}:{x2_int}). Returning uncropped frame.")
741
+ return cv2.resize(frame, (target_w, target_h), interpolation=cv2.INTER_AREA)
742
+
743
+
744
+ # Resize cropped frame to target
745
+ final_frame = cv2.resize(cropped_frame, (target_w, target_h), interpolation=cv2.INTER_LANCZOS4)
746
+ except IndexError as ie:
747
+ print(f"Error during frame cropping/resizing: {ie}. Frame shape: {frame.shape}, Crop: y={y1_int}:{y2_int}, x={x1_int}:{x2_int}")
748
+ # Fallback: return the original frame resized
749
+ final_frame = cv2.resize(frame, (target_w, target_h), interpolation=cv2.INTER_AREA)
750
+ except Exception as crop_resize_err:
751
+ print(f"Unexpected error during frame cropping/resizing: {crop_resize_err}")
752
+ final_frame = cv2.resize(frame, (target_w, target_h), interpolation=cv2.INTER_AREA)
753
+
754
+
755
  return final_frame
756
+
757
+ # Apply the transformation using fl
758
+ try:
759
+ return resized_clip.fl(make_frame, apply_to=['mask'])
760
+ except Exception as fl_err:
761
+ print(f"Error applying Ken Burns effect via fl: {fl_err}")
762
+ # Return the resized clip without the effect as fallback
763
+ return resized_clip.resize(newsize=target_resolution)
764
+
765
 
766
  def resize_to_fill(clip, target_resolution):
767
  """Resize and crop a video clip to fill the target resolution."""
768
+ # (Retained from previous versions)
769
  target_w, target_h = target_resolution
770
+ try:
771
+ # Ensure clip dimensions are valid
772
+ if clip.w <= 0 or clip.h <= 0:
773
+ print(f"Warning: Invalid clip dimensions ({clip.w}x{clip.h}). Cannot resize/crop.")
774
+ # Return a black clip of target size? Or fail? Let's return black.
775
+ from moviepy.editor import ColorClip
776
+ return ColorClip(size=target_resolution, color=(0,0,0), duration=clip.duration)
777
+
778
+ target_aspect = target_w / target_h
779
+ clip_aspect = clip.w / clip.h
780
+
781
+ if clip_aspect > target_aspect: resized_clip = clip.resize(height=target_h)
782
+ else: resized_clip = clip.resize(width=target_w)
783
+
784
+ # Ensure resized dimensions are valid
785
+ if resized_clip.w <= 0 or resized_clip.h <= 0:
786
+ print(f"Warning: Invalid resized clip dimensions ({resized_clip.w}x{resized_clip.h}).")
787
+ from moviepy.editor import ColorClip
788
+ return ColorClip(size=target_resolution, color=(0,0,0), duration=clip.duration)
789
+
790
+
791
+ crop_x = max(0, (resized_clip.w - target_w) / 2)
792
+ crop_y = max(0, (resized_clip.h - target_h) / 2)
793
+
794
+ # Use integer coordinates for cropping
795
+ cropped_clip = resized_clip.crop(x1=int(crop_x), y1=int(crop_y), width=target_w, height=target_h)
796
+ return cropped_clip
797
+ except Exception as e:
798
+ print(f"Error during resize_to_fill: {e}")
799
+ # Fallback: try simple resize to target resolution
800
+ try:
801
+ return clip.resize(newsize=target_resolution)
802
+ except Exception as fallback_e:
803
+ print(f"Fallback resize also failed: {fallback_e}")
804
+ # Last resort: return black clip
805
+ from moviepy.editor import ColorClip
806
+ return ColorClip(size=target_resolution, color=(0,0,0), duration=clip.duration)
807
+
808
 
809
  def add_background_music(video_clip, music_file_path, volume):
810
  """Add background music, looping if necessary."""
811
+ # (Retained from previous versions)
812
  if not music_file_path or not os.path.exists(music_file_path):
813
  print("No background music file found or provided. Skipping.")
814
  return video_clip
815
  try:
816
  print(f"Adding background music from: {music_file_path}")
817
  bg_music = AudioFileClip(music_file_path)
818
+ # Check for valid duration
819
+ if not bg_music or bg_music.duration is None or bg_music.duration <= 0:
820
+ print("Warning: Background music file is invalid or has zero duration. Skipping.")
821
+ return video_clip
822
+ if not video_clip or video_clip.duration is None or video_clip.duration <=0:
823
+ print("Warning: Video clip has invalid duration. Cannot add music.")
824
+ # Return original clip without audio modification
825
+ return video_clip
826
+
827
+
828
  if bg_music.duration > video_clip.duration: bg_music = bg_music.subclip(0, video_clip.duration)
829
  elif bg_music.duration < video_clip.duration:
830
  loops_needed = math.ceil(video_clip.duration / bg_music.duration)
831
+ # Ensure bg_music can be concatenated
832
+ try:
833
+ bg_music = concatenate_audioclips([bg_music] * loops_needed)
834
+ bg_music = bg_music.subclip(0, video_clip.duration)
835
+ except Exception as concat_err:
836
+ print(f"Error looping background music: {concat_err}. Skipping music.")
837
+ return video_clip # Return original video if looping fails
838
+
839
  bg_music = bg_music.volumex(volume)
840
+
841
  if video_clip.audio:
842
+ # Ensure video audio is valid
843
+ if video_clip.audio.duration is None or video_clip.audio.duration <= 0:
844
+ print("Warning: Video clip audio has invalid duration. Replacing with background music.")
845
+ final_audio = bg_music
846
+ else:
847
+ # Make durations match exactly before composing
848
+ try:
849
+ video_audio_adjusted = video_clip.audio.set_duration(video_clip.duration)
850
+ bg_music_adjusted = bg_music.set_duration(video_clip.duration)
851
+ final_audio = CompositeAudioClip([video_audio_adjusted, bg_music_adjusted])
852
+ except Exception as audio_comp_err:
853
+ print(f"Error composing audio clips: {audio_comp_err}. Skipping music.")
854
+ return video_clip # Return original video
855
  else:
856
+ final_audio = bg_music # If original clip has no audio
857
+
858
  video_clip = video_clip.set_audio(final_audio)
859
  print("Background music added successfully.")
860
  return video_clip
 
864
 
865
  def create_segment_clip(media_info, tts_path, narration_text):
866
  """Create a single video segment (clip) with visuals, audio, and subtitles."""
867
+ # (Retained from previous versions, with minor stability checks)
868
  try:
869
  media_path = media_info['path']
870
  asset_type = media_info['asset_type']
871
  print(f"Creating clip segment: Type={asset_type}, Media={os.path.basename(media_path)}")
872
+ if not os.path.exists(tts_path) or os.path.getsize(tts_path) == 0:
873
+ print(f"Error: TTS file missing or empty: {tts_path}"); return None
874
  audio_clip = AudioFileClip(tts_path)
875
+ if audio_clip.duration is None or audio_clip.duration <= 0:
876
+ print(f"Error: Audio clip has invalid duration: {tts_path}"); return None
877
  segment_duration = audio_clip.duration + 0.3
878
+
879
  if asset_type == "video":
880
+ if not os.path.exists(media_path) or os.path.getsize(media_path) == 0:
881
+ print(f"Error: Video file missing or empty: {media_path}"); return None
882
+ try:
883
+ video_clip = VideoFileClip(media_path)
884
+ if video_clip.duration is None or video_clip.duration <= 0:
885
+ print(f"Error: Video clip has invalid duration: {media_path}")
886
+ video_clip.close() # Close the clip
887
+ return None
888
+ if video_clip.duration < segment_duration:
889
+ if video_clip.duration > 0:
890
+ loops = math.ceil(segment_duration / video_clip.duration)
891
+ video_clip_looped = concatenate_videoclips([video_clip] * loops)
892
+ video_clip.close() # Close original after looping
893
+ video_clip = video_clip_looped
894
+ else: # Should have been caught above, but double check
895
+ print(f"Error: Cannot loop zero-duration video: {media_path}")
896
+ video_clip.close(); return None
897
+
898
+ # Ensure subclip duration is valid
899
+ video_clip_final = video_clip.subclip(0, min(segment_duration, video_clip.duration))
900
+ video_clip.close() # Close intermediate clip if looped
901
+ video_clip = video_clip_final
902
+
903
+ visual_clip = resize_to_fill(video_clip, TARGET_RESOLUTION)
904
+ video_clip.close() # Close after resize_to_fill finishes
905
+
906
+ except Exception as video_load_err:
907
+ print(f"Error loading or processing video {media_path}: {video_load_err}")
908
+ return None
909
+
910
  elif asset_type == "image":
911
+ if not os.path.exists(media_path) or os.path.getsize(media_path) == 0:
912
+ print(f"Error: Image file missing or empty: {media_path}"); return None
913
+ try:
914
+ # Load image clip
915
+ img_clip = ImageClip(media_path).set_duration(segment_duration)
916
+ # Apply Ken Burns
917
+ visual_clip = apply_kenburns_effect(img_clip, TARGET_RESOLUTION, segment_duration)
918
+ # Ensure final size - Ken Burns should handle this, but double check
919
+ visual_clip = visual_clip.resize(newsize=TARGET_RESOLUTION)
920
+ # Close the base image clip resource if possible (ImageClip doesn't have explicit close)
921
+ except Exception as img_load_err:
922
+ print(f"Error loading or processing image {media_path}: {img_load_err}")
923
+ return None
924
  else: print(f"Error: Unknown asset type: {asset_type}"); return None
925
+
926
  visual_clip = visual_clip.fadein(0.15).fadeout(0.15)
927
  subtitle_clips = []
928
  if USE_CAPTIONS and narration_text:
 
937
  num_chunks = len(chunks); chunk_duration = audio_clip.duration / num_chunks
938
  start_time = 0.1
939
  for i, chunk_text in enumerate(chunks):
940
+ # Ensure chunk end time doesn't exceed visual clip duration
941
+ chunk_end_time = min(start_time + chunk_duration, visual_clip.duration - 0.05) # Leave tiny buffer
942
+ actual_chunk_duration = max(0.1, chunk_end_time - start_time) # Min duration 0.1s
943
+
944
  try:
945
  txt_clip = TextClip(txt=chunk_text, fontsize=font_size, font=caption_font, color=caption_style_text_color,
946
  bg_color=caption_style_bg_color, method='label', align='center',
947
  size=(TARGET_RESOLUTION[0] * 0.8, None))
948
  txt_clip = txt_clip.set_position(('center', TARGET_RESOLUTION[1] * 0.80))
949
+ txt_clip = txt_clip.set_start(start_time).set_duration(actual_chunk_duration)
950
  subtitle_clips.append(txt_clip)
951
+ start_time = chunk_end_time # Next chunk starts where the last one ended
952
  except Exception as txt_err:
953
  print(f"ERROR creating TextClip for '{chunk_text}': {txt_err}. Skipping subtitle chunk.")
 
954
 
955
+ final_clip = CompositeVideoClip([visual_clip] + subtitle_clips, size=TARGET_RESOLUTION) if subtitle_clips else visual_clip
956
+ # Set audio with slight offset
957
+ final_clip = final_clip.set_audio(audio_clip.set_start(0.15).set_duration(audio_clip.duration))
958
+ # Ensure final clip duration matches segment duration closely
959
+ final_clip = final_clip.set_duration(segment_duration)
960
+
961
  print(f"Clip segment created successfully. Duration: {final_clip.duration:.2f}s")
962
+ # Explicitly close audio clip resource
963
+ audio_clip.close()
964
+ # Close visual clip resources if they have close methods (VideoClips do)
965
+ if hasattr(visual_clip, 'close'):
966
+ visual_clip.close()
967
+ for sub in subtitle_clips:
968
+ if hasattr(sub, 'close'): # TextClips might not have close
969
+ sub.close()
970
+
971
  return final_clip
972
  except Exception as e:
973
  print(f"Error creating clip segment: {e}")
974
  import traceback
975
  traceback.print_exc()
976
+ # Clean up resources if possible on error
977
+ if 'audio_clip' in locals() and hasattr(audio_clip, 'close'): audio_clip.close()
978
+ if 'visual_clip' in locals() and hasattr(visual_clip, 'close'): visual_clip.close()
979
+ if 'video_clip' in locals() and hasattr(video_clip, 'close'): video_clip.close()
980
  return None
981
 
982
+
983
  # ---------------- Main Video Generation Function ---------------- #
984
 
985
  def generate_full_video(user_input, resolution_choice, caption_choice, music_file_info):
986
  """Main function orchestrating the video generation process."""
987
+ # (Retained from previous versions - logic relies on the new generate_media)
 
988
  global TARGET_RESOLUTION, TEMP_FOLDER, USE_CAPTIONS
989
  print("\n--- Starting Video Generation ---"); start_time = time.time()
990
  if resolution_choice == "Short (9:16)": TARGET_RESOLUTION = (1080, 1920); print("Resolution set to: Short (1080x1920)")
 
995
  if music_file_info is not None:
996
  try:
997
  music_file_path = os.path.join(TEMP_FOLDER, "background_music.mp3")
998
+ # Ensure temp folder exists before copying
999
+ os.makedirs(TEMP_FOLDER, exist_ok=True)
1000
  shutil.copy(music_file_info.name, music_file_path)
1001
  print(f"Background music copied to: {music_file_path}")
1002
  except Exception as e: print(f"Error handling uploaded music file: {e}"); music_file_path = None
 
1016
  print(f" Prompt: {segment['prompt']}")
1017
  print(f" Narration: {segment['narration']}")
1018
 
1019
+ media_info = generate_media(segment['prompt']) # Using user's generate_media
 
1020
  if not media_info:
1021
  print(f"Warning: Failed to get media for segment {i+1} ('{segment['prompt']}'). Skipping this segment.")
1022
+ continue
1023
 
1024
  tts_path = generate_tts(segment['narration'], selected_voice, voice_speed)
1025
  if not tts_path:
1026
  print(f"Warning: Failed to generate TTS for segment {i+1}. Skipping segment.")
1027
  if media_info and os.path.exists(media_info['path']):
1028
  try: os.remove(media_info['path']); print(f"Cleaned up unused media: {media_info['path']}")
1029
+ except OSError as e: print(f"Error removing unused media {media_info['path']}: {e}")
1030
  continue
1031
 
1032
+ # Pass current_index=i to create_segment if needed by that function (currently not)
1033
  clip = create_segment_clip(media_info, tts_path, segment['narration'])
1034
  if clip:
1035
  segment_clips.append(clip)
1036
  else:
1037
  print(f"Warning: Failed to create video clip for segment {i+1}. Skipping.")
1038
+ # Clean up files for this failed segment
1039
  if media_info and os.path.exists(media_info['path']):
1040
  try: os.remove(media_info['path']); print(f"Cleaned up media for failed clip: {media_info['path']}")
1041
+ except OSError as e: print(f"Error removing media for failed clip {media_info['path']}: {e}")
1042
  if tts_path and os.path.exists(tts_path):
1043
  try: os.remove(tts_path); print(f"Cleaned up TTS for failed clip: {tts_path}")
1044
+ except OSError as e: print(f"Error removing TTS for failed clip {tts_path}: {e}")
1045
+
1046
 
1047
  if not segment_clips:
1048
  print("ERROR: No video clips were successfully created. Aborting.")
1049
+ try: shutil.rmtree(TEMP_FOLDER)
1050
+ except Exception as e: print(f"Error removing temp folder during abort: {e}")
1051
  return None, "Error: Failed to create any video segments. Check logs for media/TTS issues."
1052
 
1053
  print("\nStep 4: Concatenating video segments...");
1054
+ final_video = None # Initialize to None
1055
  try:
 
1056
  valid_clips = [c for c in segment_clips if c is not None]
1057
+ if not valid_clips: raise ValueError("No valid clips remained after processing.")
1058
+ # Concatenate
1059
  final_video = concatenate_videoclips(valid_clips, method="compose")
1060
  print("Segments concatenated successfully.")
1061
+ # Close individual clips after concatenation
1062
+ for clip in valid_clips:
1063
+ if hasattr(clip, 'close'): clip.close()
1064
+
1065
  except Exception as e:
1066
+ print(f"ERROR: Failed to concatenate video clips: {e}");
1067
+ # Clean up individual clips if concatenation failed
1068
+ for clip in segment_clips:
1069
+ if clip and hasattr(clip, 'close'): clip.close()
1070
+ try: shutil.rmtree(TEMP_FOLDER)
1071
+ except Exception as e_clean: print(f"Error removing temp folder after concat error: {e_clean}")
1072
+ return None, f"Error: Concatenation failed: {e}"
1073
+
1074
+
1075
+ print("\nStep 5: Adding background music...");
1076
+ if final_video: # Only add music if concatenation was successful
1077
+ final_video = add_background_music(final_video, music_file_path, bg_music_volume)
1078
+ else:
1079
+ print("Skipping background music as concatenation failed.")
1080
 
 
1081
 
1082
  print(f"\nStep 6: Exporting final video to '{OUTPUT_VIDEO_FILENAME}'..."); export_success = False
1083
+ if final_video: # Only export if we have a final video object
1084
+ try:
1085
+ final_video.write_videofile(OUTPUT_VIDEO_FILENAME, codec='libx264', audio_codec='aac', fps=fps, preset=preset, threads=4, logger='bar', ffmpeg_params=["-vsync", "vfr"]) # Added vsync param
1086
+ print(f"Final video saved successfully as {OUTPUT_VIDEO_FILENAME}")
1087
+ export_success = True
1088
+ except Exception as e:
1089
+ print(f"ERROR: Failed to write final video file: {e}"); import traceback; traceback.print_exc()
1090
+ finally:
1091
+ # Ensure final video resources are closed after export
1092
+ if hasattr(final_video, 'close'): final_video.close()
1093
+ print("Closed final video resources.")
1094
+ else:
1095
+ print("Skipping export as final video generation failed.")
1096
+
1097
 
1098
  print("\nStep 7: Cleaning up temporary files...");
1099
  try: shutil.rmtree(TEMP_FOLDER); print(f"Temporary folder {TEMP_FOLDER} removed.")
 
1102
  end_time = time.time(); total_time = end_time - start_time
1103
  print(f"\n--- Video Generation Finished ---"); print(f"Total time: {total_time:.2f} seconds")
1104
  if export_success: return OUTPUT_VIDEO_FILENAME, f"Video generation complete! Time: {total_time:.2f}s"
1105
+ elif not final_video: return None, f"Error: Video generation failed before export. Check logs. Time: {total_time:.2f}s"
1106
  else: return None, f"Error: Video export failed. Check logs. Time: {total_time:.2f}s"
1107
 
1108
 
1109
  # ---------------- Gradio Interface Definition ---------------- #
1110
+ # (Retained from previous versions)
1111
  VOICE_CHOICES = {
1112
  'Emma (US Female)': 'af_heart', 'Bella (US Female)': 'af_bella', 'Nicole (US Female)': 'af_nicole',
1113
  'Sarah (US Female)': 'af_sarah', 'Michael (US Male)': 'am_michael', 'Eric (US Male)': 'am_eric',
 
1116
  }
1117
  def gradio_interface_handler(user_prompt, resolution, captions, bg_music, voice_name, video_prob, music_vol, video_fps, export_preset, tts_speed, caption_size):
1118
  print("\n--- Received Request from Gradio ---")
1119
+ print(f"Prompt: {user_prompt[:50]}...")
1120
+ print(f"Resolution: {resolution}")
1121
+ print(f"Captions: {captions}")
1122
+ print(f"Music File: {'Provided' if bg_music else 'None'}")
1123
+ print(f"Voice: {voice_name}")
1124
+ print(f"Video Probability: {video_prob}%")
1125
+ print(f"Music Volume: {music_vol}")
1126
+ print(f"FPS: {video_fps}")
1127
+ print(f"Preset: {export_preset}")
1128
+ print(f"TTS Speed: {tts_speed}")
1129
+ print(f"Caption Size: {caption_size}")
1130
+
1131
  global selected_voice, voice_speed, font_size, video_clip_probability, bg_music_volume, fps, preset
1132
  selected_voice = VOICE_CHOICES.get(voice_name, 'af_heart')
1133
  voice_speed = tts_speed; font_size = caption_size; video_clip_probability = video_prob / 100.0
 
1175
  print("!!! Please replace 'YOUR_PEXELS_API_KEY' and !!!")
1176
  print("!!! 'YOUR_OPENROUTER_API_KEY' with your actual keys. !!!")
1177
  print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n")
1178
+ iface.launch(share=True, debug=True) # Share=True for public link, Debug=True for more logs