quotevideogen2 / app.py
kavehtaheri's picture
Update app.py
bbbb9a3 verified
import gradio as gr
import moviepy.editor as mp
from moviepy.video.fx.all import crop # For zoom effect if needed for crop
from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageOps
import numpy as np
import os
import gdown
import requests
from urllib.parse import urlparse # Used in user's download_from_google_drive
import tempfile
import re
import time
import traceback
INSTA_ONEAPI_KEY=os.environ.get('INSTAONEAPIKEY')
# --- Helper Functions (Prioritizing from your pasted-text.txt) ---
def download_from_google_drive(url):
"""Download file from Google Drive URL with improved parsing (from user's file)"""
try:
if not url or not isinstance(url, str):
raise ValueError("Invalid URL provided")
print(f"Processing Google Drive URL: {url}")
url = url.strip()
if 'drive.google.com' in url:
patterns = [
r'/file/d/([a-zA-Z0-9-_]+)',
r'id=([a-zA-Z0-9-_]+)',
r'/d/([a-zA-Z0-9-_]+)',
r'file/d/([a-zA-Z0-9-_]+)/view',
r'open\?id=([a-zA-Z0-9-_]+)'
]
file_id = None
for pattern in patterns:
match = re.search(pattern, url)
if match:
file_id = match.group(1)
break
if not file_id:
if '/file/d/' in url:
try:
file_id = url.split('/file/d/')[1].split('/')[0]
except IndexError: pass
elif 'id=' in url:
try:
file_id = url.split('id=')[1].split('&')[0]
except IndexError: pass
if not file_id:
raise ValueError("Could not extract file ID from Google Drive URL")
print(f"Extracted file ID: {file_id}")
download_url = f"https://drive.google.com/uc?export=download&id={file_id}"
# Using NamedTemporaryFile for safer temp file creation
temp_file = tempfile.NamedTemporaryFile(suffix='.mp4', delete=False)
output_path = temp_file.name
temp_file.close() # Close it so gdown can write to it
try:
gdown.download(download_url, output_path, quiet=False)
if os.path.exists(output_path) and os.path.getsize(output_path) > 0:
print(f"Successfully downloaded from Google Drive to {output_path}")
return output_path
else:
# Try alternative download method
print("Initial gdown failed or file empty, trying alternative.")
if os.path.exists(output_path): os.remove(output_path) # Clean up before retry
gdown.download(f"https://drive.google.com/file/d/{file_id}/view?usp=sharing",
output_path, quiet=False, fuzzy=True)
if os.path.exists(output_path) and os.path.getsize(output_path) > 0:
print(f"Successfully downloaded (alternative) from Google Drive to {output_path}")
return output_path
else:
raise Exception("Alternative gdown download also failed or resulted in empty file.")
except Exception as e:
print(f"gdown failed for {url}: {e}")
# Fallback to direct requests
try:
print(f"Attempting direct requests fallback for {download_url}")
response = requests.get(download_url, stream=True, timeout=60)
response.raise_for_status()
with open(output_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
if os.path.exists(output_path) and os.path.getsize(output_path) > 0:
print(f"Successfully downloaded (direct requests) to {output_path}")
return output_path
else:
raise Exception("Direct requests download failed or resulted in empty file.")
except Exception as e2:
print(f"Direct download also failed for {url}: {e2}")
if os.path.exists(output_path): os.remove(output_path)
raise e2 # Re-raise the error from direct download
else: # Assumed direct URL if not Google Drive
print(f"Processing as direct URL: {url}")
temp_file = tempfile.NamedTemporaryFile(suffix='.mp4', delete=False)
output_path = temp_file.name
temp_file.close()
response = requests.get(url, stream=True, timeout=60)
response.raise_for_status()
with open(output_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
if os.path.exists(output_path) and os.path.getsize(output_path) > 0:
print(f"Successfully downloaded (direct URL) to {output_path}")
return output_path
else:
if os.path.exists(output_path): os.remove(output_path)
raise Exception("Direct URL download failed or resulted in empty file.")
except Exception as e:
print(f"Error downloading file from URL '{url}': {e}")
traceback.print_exc()
# Clean up temp file if it exists and error occurred
if 'output_path' in locals() and os.path.exists(output_path):
try: os.remove(output_path)
except: pass
return None
def download_audio_file(url):
"""Download audio file from URL (from user's file)"""
try:
if not url or not url.strip():
return None
url = url.strip()
print(f"Downloading audio from: {url}")
# Determine file extension
parsed_url = urlparse(url)
path = parsed_url.path
base, ext = os.path.splitext(path)
if ext.lower() in ['.mp3', '.wav', '.m4a', '.aac']:
suffix = ext
else:
# Check Content-Type header if no obvious extension
try:
head_resp = requests.head(url, timeout=10, allow_redirects=True)
content_type = head_resp.headers.get('content-type', '').lower()
if 'audio/mpeg' in content_type: suffix = '.mp3'
elif 'audio/wav' in content_type: suffix = '.wav'
elif 'audio/aac' in content_type: suffix = '.aac'
elif 'audio/mp4' in content_type: suffix = '.m4a' # Often for AAC in MP4 container
else: suffix = '.mp3' # Default
except Exception:
suffix = '.mp3' # Default on error
temp_audio_file = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
output_path = temp_audio_file.name
temp_audio_file.close()
response = requests.get(url, stream=True, timeout=60)
response.raise_for_status()
with open(output_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
if os.path.exists(output_path) and os.path.getsize(output_path) > 0:
print(f"Successfully downloaded audio to {output_path}")
return output_path
else:
if os.path.exists(output_path): os.remove(output_path)
print(f"Failed to download audio or file is empty: {url}")
return None
except Exception as e:
print(f"Error downloading audio from '{url}': {e}")
traceback.print_exc()
if 'output_path' in locals() and os.path.exists(output_path):
try: os.remove(output_path)
except: pass
return None
def hex_to_rgb(hex_color):
"""Convert hex color to RGB tuple (from user's file)"""
hex_color = hex_color.lstrip('#')
if len(hex_color) == 3: # Handle short hex like #RGB
hex_color = "".join([c*2 for c in hex_color])
if len(hex_color) == 6:
return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
print(f"Warning: Invalid hex color format '{hex_color}'. Defaulting to white.")
return (255, 255, 255) # Default to white
def calculate_font_size(text, max_width, max_height, custom_size=None):
"""Calculate optimal font size based on text length and video dimensions (from user's file)"""
if custom_size and custom_size > 0:
return int(custom_size) # Ensure integer
if not text: return 24 # Default for empty text
char_count = len(text)
if char_count < 50:
base_size = max_width // 15
elif char_count < 100:
base_size = max_width // 20
elif char_count < 200:
base_size = max_width // 25
else:
base_size = max_width // 30
font_size = max(24, min(base_size, max_height // 8)) # Ensure min 24, max relative to height
return int(font_size)
def detect_rtl_text(text):
"""Detect if text contains RTL characters (Arabic, Persian, Hebrew, etc.) (from user's file)"""
if not text: return False
rtl_chars_specific = { # Specific common Persian/Arabic letters for quick check
'ا', 'ب', 'پ', 'ت', 'ث', 'ج', 'چ', 'ح', 'خ', 'د', 'ذ', 'ر', 'ز', 'ژ', 'س', 'ش',
'ص', 'ض', 'ط', 'ظ', 'ع', 'غ', 'ف', 'ق', 'ک', 'گ', 'ل', 'م', 'ن', 'و', 'ه', 'ی',
'ء', 'آ', 'اً', 'ة', 'ی'
}
# General Unicode ranges for RTL scripts
# Arabic: U+0600 to U+06FF
# Hebrew: U+0590 to U+05FF
# Syriac: U+0700 to U+074F
# Thaana: U+0780 to U+07BF
# N'Ko: U+07C0 to U+07FF
# Arabic Supplement: U+0750 to U+077F
# Arabic Extended-A: U+08A0 to U+08FF
for char in text:
if char in rtl_chars_specific:
return True
if '\u0600' <= char <= '\u06FF' or \
'\u0590' <= char <= '\u05FF' or \
'\u0700' <= char <= '\u074F' or \
'\u0780' <= char <= '\u07BF' or \
'\u07C0' <= char <= '\u07FF' or \
'\u0750' <= char <= '\u077F' or \
'\u08A0' <= char <= '\u08FF':
return True
return False
def create_glow_effect(img, glow_radius=5, glow_color=(255, 255, 255, 100)):
"""Create glow effect for text (from user's file, img is PIL RGBA)"""
# Create glow layer. glow_color is expected to be RGBA.
base_for_glow = Image.new('RGBA', img.size, (0,0,0,0))
# Create a mask from the text's alpha channel
alpha_mask = img.getchannel('A')
# Apply the glow color to the areas defined by the alpha mask
colored_text_shape = Image.new('RGBA', img.size, glow_color)
base_for_glow.paste(colored_text_shape, mask=alpha_mask)
# Blur this colored shape multiple times
glow_effect_layer = base_for_glow
for i in range(max(1, glow_radius // 2)): # Apply blur progressively or multiple times
glow_effect_layer = glow_effect_layer.filter(ImageFilter.GaussianBlur(radius=2)) # Smaller radius, more iterations
if glow_radius > 1: # Apply a final larger blur
glow_effect_layer = glow_effect_layer.filter(ImageFilter.GaussianBlur(radius=glow_radius / 2.0))
# Composite the blurred glow behind the original image
# Image.alpha_composite expects background first, then foreground
final_image_with_glow = Image.alpha_composite(glow_effect_layer, img)
return final_image_with_glow
def insta_oneapi(url, api_key):
"""Downloads video from Instagram using one-api.ir"""
shortcode_match = re.search(r"(?:instagram\.com|instagr\.am)\/(?:p|reel|reels|tv)\/([a-zA-Z0-9_-]+)", url)
if shortcode_match:
shortcode = shortcode_match.group(1)
else:
parts = url.strip("/").split("/")
shortcode = parts[-1] if parts and parts[-1] not in ["", "feed"] else "" # handle cases like "username/feed/"
if not shortcode and len(parts) > 1 and parts[-2] in ["p", "reel", "reels", "tv"]: # a bit more robust for just ID
shortcode = parts[-1]
print(f"Extracted shortcode: '{shortcode}' from URL: {url}")
if not shortcode:
print("Error: Could not extract shortcode from Instagram URL.")
return None
api_url = f"https://api.one-api.ir/instagram/v1/post/?shortcode={shortcode}"
headers = {"one-api-token": api_key, "Content-Type": "application/json"}
print(f"Requesting Instagram post info from: {api_url}")
try:
response = requests.get(api_url, headers=headers, timeout=20)
print(f"Instagram API Response Status: {response.status_code}")
response.raise_for_status()
result = response.json()
if result.get("status") != 200 or "result" not in result:
print(f"API Error: Status {result.get('status')} - {result.get('message', 'Unknown API error')}")
print(f"Full API response: {result}")
return None
media_items = result.get("result", {}).get("media", [])
if not media_items:
print("Error: 'media' not found in API response or is empty.")
print(f"Full API response: {result}")
return None
video_download_url = None
for item in media_items:
if item.get("type") != "image" and item.get("url"):
video_download_url = item["url"]
break
if not video_download_url and media_items and media_items[0].get("url"):
video_download_url = media_items[0]["url"] # Fallback to first item's URL
if not video_download_url:
print("Error: No suitable media URL found in API response.")
print(f"Full API response: {result}")
return None
print(f"Video download URL obtained: {video_download_url}")
print(f"Waiting for 2 seconds before downloading video...") # Reduced wait time
time.sleep(2)
response_video = requests.get(video_download_url, stream=True, timeout=60)
response_video.raise_for_status()
temp_vid_file = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False)
output_filename = temp_vid_file.name
temp_vid_file.close()
with open(output_filename, 'wb') as file:
for chunk in response_video.iter_content(chunk_size=8192*4):
if chunk: file.write(chunk)
if os.path.exists(output_filename) and os.path.getsize(output_filename) > 0:
print(f"Downloaded Instagram video successfully to {output_filename}")
return output_filename
else:
if os.path.exists(output_filename): os.remove(output_filename)
print(f"Failed to download Instagram video or file is empty: {video_download_url}")
return None
except requests.exceptions.Timeout:
print(f"Timeout error communicating with Instagram API or downloading video for URL: {url}")
return None
except requests.exceptions.RequestException as e:
print(f"RequestException with Instagram API or download for {url}: {e}")
return None
except KeyError as e:
print(f"Error: Could not find expected key '{e}' in API response for {url}.")
if 'result' in locals(): print(f"Full API response: {result}")
return None
except Exception as e:
print(f"An unexpected error occurred in insta_oneapi for {url}: {e}")
traceback.print_exc()
return None
def create_advanced_text_clip(text, font_size, video_size, position_key, duration, start_time=1,
font_path="Dima Shekasteh.ttf", text_color_hex="#FFFFFF",
effect_type="fade_in", apply_glow_effect=False, custom_position=None,
line_spacing_ratio=0.3, text_align="center", glow_color_hex=None, glow_radius_ratio=0.1):
"""Create advanced animated text clip using user's create_glow_effect if enabled"""
width, height = video_size
# Ensure font_size is an integer
font_size = int(font_size)
img = Image.new('RGBA', (width, height), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
try:
font = ImageFont.truetype(font_path, font_size)
except IOError:
print(f"Warning: Font '{font_path}' not found. Trying system fallbacks.")
fallback_fonts = ["Arial.ttf", "DejaVuSans.ttf", "tahoma.ttf", "B Nazanin.ttf", "XB Niloofar.ttf", "/System/Library/Fonts/Supplemental/GeezaPro.ttf", "/usr/share/fonts/truetype/noto/NotoNaskhArabic-Regular.ttf"]
font_loaded = False
for f_path in fallback_fonts:
try:
font = ImageFont.truetype(f_path, font_size)
font_loaded = True; print(f"Using fallback font: {f_path}"); break
except IOError: continue
if not font_loaded: print("Warning: No suitable fallback font found. Using default PIL font."); font = ImageFont.load_default()
lines = text.split('\n')
is_rtl = detect_rtl_text(text)
# Determine effective alignment for RTL text
effective_align = text_align
if is_rtl:
if text_align == "center": effective_align = "right" # Common preference for centered RTL
elif text_align == "left": effective_align = "right" # Force right for RTL if left is chosen
text_color_rgb_pil = hex_to_rgb(text_color_hex)
text_color_rgba_pil = text_color_rgb_pil + (255,) # Full opacity for text
line_heights = []
line_widths = []
for line in lines:
try:
bbox = draw.textbbox((0,0), line, font=font, direction="rtl" if is_rtl else None, features=["raqm"] if is_rtl else None)
line_w = bbox[2] - bbox[0]
line_h = bbox[3] - bbox[1]
except Exception: # Fallback for older PIL or issues
(line_w, line_h) = draw.textsize(line, font=font)
line_widths.append(line_w)
line_heights.append(line_h)
dynamic_line_spacing = int(font_size * line_spacing_ratio)
total_text_height = sum(line_heights) + (len(lines) - 1) * dynamic_line_spacing
max_line_width = max(line_widths) if line_widths else 0
if custom_position:
base_x, base_y = custom_position
else: # Automatic positioning based on position_key
margin_v_factor = 0.10; margin_h_factor = 0.05
if position_key == 'center': base_x = (width - max_line_width) // 2; base_y = (height - total_text_height) // 2
elif position_key == 'bottom': base_x = (width - max_line_width) // 2; base_y = height - total_text_height - int(height * margin_v_factor)
elif position_key == 'top': base_x = (width - max_line_width) // 2; base_y = int(height * margin_v_factor)
elif position_key == 'right': base_x = width - max_line_width - int(width*margin_h_factor); base_y = (height - total_text_height) // 2
elif position_key == 'left': base_x = int(width*margin_h_factor); base_y = (height - total_text_height) // 2
else: base_x = (width - max_line_width) // 2; base_y = (height - total_text_height) // 2 # Default center
current_y = base_y
for i, line in enumerate(lines):
line_w_current = line_widths[i]
if effective_align == "center": line_x = base_x + (max_line_width - line_w_current) // 2
elif effective_align == "right": line_x = base_x + (max_line_width - line_w_current)
else: line_x = base_x # Left align
shadow_offset = max(1, font_size // 30)
shadow_color = (0, 0, 0, 100) # Semi-transparent black shadow
draw.text((line_x + shadow_offset, current_y + shadow_offset), line, font=font, fill=shadow_color, direction="rtl" if is_rtl else None, features=["raqm"] if is_rtl else None)
draw.text((line_x, current_y), line, font=font, fill=text_color_rgba_pil, direction="rtl" if is_rtl else None, features=["raqm"] if is_rtl else None)
current_y += line_heights[i] + dynamic_line_spacing
if apply_glow_effect:
glow_rgb = hex_to_rgb(glow_color_hex if glow_color_hex else text_color_hex)
glow_rgba_for_effect = glow_rgb + (100,) # Use fixed alpha 100 for glow, as in user's example
final_glow_radius = max(3, int(font_size * glow_radius_ratio))
img = create_glow_effect(img, glow_radius=final_glow_radius, glow_color=glow_rgba_for_effect)
img_array = np.array(img)
img_clip = mp.ImageClip(img_array).set_duration(duration).set_start(start_time)
if effect_type == "fade_in": img_clip = img_clip.fadein(min(1.0, duration / 3.0))
elif effect_type == "fade_out": img_clip = img_clip.fadeout(min(1.0, duration / 3.0))
elif effect_type == "fade_in_out":
fade_dur = min(1.0, duration / 4.0)
img_clip = img_clip.fadein(fade_dur).fadeout(fade_dur)
elif effect_type == "slide_up":
# Basic slide up: final_pos needs to be defined by how ImageClip positions itself.
# This is tricky without knowing the clip's size relative to video_size if it's not full screen.
# Assuming the `img` is the size of `video_size`, and text is drawn onto it.
# The position set by set_position below acts on the whole `img_clip`.
img_clip = img_clip.set_position(lambda t: ('center', height - (height - base_y + total_text_height/2) * (t / min(1.0, duration * 0.3))))
img_clip = img_clip.fadein(0.5)
return img_clip
def create_divider_line(width, line_type="simple", color_rgba=(255, 255, 255, 150), thickness=2, margin_ratio=0.1):
"""Creates a PIL Image of a divider line."""
line_width_actual = int(width * (1 - 2 * margin_ratio))
img_height = thickness + 10 # Padding
img = Image.new('RGBA', (width, img_height), (0,0,0,0))
draw = ImageDraw.Draw(img)
start_x = int(width * margin_ratio)
end_x = int(width * (1 - margin_ratio))
y_pos = img_height // 2
if line_type == "simple":
draw.line([(start_x, y_pos), (end_x, y_pos)], fill=color_rgba, width=thickness)
# Add dotted/dashed if needed, similar to previous versions
return img
def create_decorative_frame(width, height, frame_type="simple", color_rgba=(255, 255, 255, 100), thickness=5, margin_percent=0.02):
img = Image.new('RGBA', (width, height), (0,0,0,0))
draw = ImageDraw.Draw(img)
margin_px = int(min(width, height) * margin_percent)
if frame_type == "simple":
draw.rectangle([(margin_px, margin_px), (width - margin_px -1, height - margin_px-1)], outline=color_rgba, width=thickness)
return img
def create_background_overlay(video_size, overlay_type="vignette", opacity=0.3, color_hex="#000000"):
width, height = video_size
img = Image.new('RGBA', (width, height), (0,0,0,0))
overlay_rgb = hex_to_rgb(color_hex)
alpha_int = int(opacity * 255)
if overlay_type == "vignette":
center_x, center_y = width / 2, height / 2
max_dist = np.sqrt(center_x**2 + center_y**2) if center_x > 0 and center_y > 0 else 1
pixels = img.load()
for x_coord in range(width):
for y_coord in range(height):
dist = np.sqrt((x_coord - center_x)**2 + (y_coord - center_y)**2)
vignette_alpha = int(alpha_int * (dist / max_dist)**1.5)
vignette_alpha = min(alpha_int, vignette_alpha)
vignette_base_color = (0,0,0) # Vignettes are typically blackish
pixels[x_coord, y_coord] = vignette_base_color + (vignette_alpha,)
elif overlay_type == "solid_color":
draw = ImageDraw.Draw(img)
draw.rectangle([(0,0), (width, height)], fill=overlay_rgb + (alpha_int,))
return img
# --- Main Video Processing Function ---
def process_video(video_url, music_url, poem_verse, poet_name, username,
auto_font_size_enabled, poem_font_size_manual, poet_font_size_manual, username_font_size_manual,
poem_color, poet_color, username_color,
poem_effect, poet_effect, username_effect,
enable_glow_effect, glow_color_main, # Single glow color for all text if enabled
add_divider_line, divider_type_style,
add_frame_overlay, frame_type_style,
background_overlay_type, overlay_opacity_value, overlay_color_value,
poem_text_position, poet_original_position, username_text_position,
progress=gr.Progress(track_tqdm=True)):
progress(0, desc="Initializing...")
# Temp file storage
downloaded_video_path = None
downloaded_audio_path = None
final_output_path = None # This will be the path returned
# Lists for resource cleanup
clips_to_close = []
files_to_delete = []
try:
# --- Input Validation (Basic) ---
if not video_url: return "Error: Video URL is required.", None
if not poem_verse: return "Error: Poem verse is required.", None
if not poet_name: return "Error: Poet name is required.", None
if not username: return "Error: Username is required.", None
progress(0.1, desc="Downloading video...")
if "instagram.com" in video_url or "instagr.am" in video_url:
INSTA_API_KEY =os.environ.get('INSTAONEAPIKEY')
if not INSTA_API_KEY or INSTA_API_KEY == "YOUR_API_KEY_HERE": # Basic check
return "Error: Instagram API Key (INSTA_ONEAPI_KEY) not configured in environment.", None
downloaded_video_path = insta_oneapi(video_url, INSTA_API_KEY)
elif "drive.google.com" in video_url:
downloaded_video_path = download_from_google_drive(video_url)
else: # Assume direct link
downloaded_video_path = download_from_google_drive(video_url) # User's GDrive func also handles direct links
if not downloaded_video_path or not os.path.exists(downloaded_video_path):
return f"Error: Failed to download video from {video_url}. Check URL and logs.", None
files_to_delete.append(downloaded_video_path)
progress(0.2, desc="Loading video...")
video = mp.VideoFileClip(downloaded_video_path)
clips_to_close.append(video)
video_duration = video.duration
if video_duration is None or video_duration <= 0:
return "Error: Video has invalid duration.", None
video_w, video_h = video.w, video.h
# --- Audio Processing ---
processed_audio_clip = None
if music_url and music_url.strip():
progress(0.25, desc="Downloading audio...")
downloaded_audio_path = download_audio_file(music_url)
if downloaded_audio_path and os.path.exists(downloaded_audio_path):
files_to_delete.append(downloaded_audio_path)
progress(0.3, desc="Processing audio...")
try:
audio_clip_temp = mp.AudioFileClip(downloaded_audio_path)
clips_to_close.append(audio_clip_temp)
if audio_clip_temp.duration > video_duration:
processed_audio_clip = audio_clip_temp.subclip(0, video_duration)
elif audio_clip_temp.duration < video_duration:
if audio_clip_temp.duration > 0:
num_loops = int(np.ceil(video_duration / audio_clip_temp.duration))
looped_clips = [audio_clip_temp] * num_loops
concatenated_audio = mp.concatenate_audioclips(looped_clips)
clips_to_close.append(concatenated_audio)
processed_audio_clip = concatenated_audio.subclip(0, video_duration)
else: processed_audio_clip = video.audio # Original if downloaded audio is silent
else:
processed_audio_clip = audio_clip_temp
# If processed_audio_clip is a subclip or new clip, original audio_clip_temp might not be needed further by this var
if processed_audio_clip != audio_clip_temp and audio_clip_temp not in clips_to_close:
clips_to_close.append(audio_clip_temp) # Ensure it's closed if different
elif processed_audio_clip == audio_clip_temp and audio_clip_temp in clips_to_close:
clips_to_close.remove(audio_clip_temp) # Avoid double closing if assigned directly
clips_to_close.append(processed_audio_clip)
except Exception as e:
print(f"Audio processing failed: {e}. Using video's original audio.")
traceback.print_exc()
processed_audio_clip = video.audio # Fallback
else:
print("Audio download failed or no audio URL. Using video's original audio.")
processed_audio_clip = video.audio
else:
processed_audio_clip = video.audio
progress(0.4, desc="Calculating font sizes...")
# Use user's calculate_font_size function
poem_fs = calculate_font_size(poem_verse, video_w, video_h, custom_size=None if auto_font_size_enabled else poem_font_size_manual)
poet_fs = calculate_font_size(poet_name, video_w, video_h, custom_size=None if auto_font_size_enabled else poet_font_size_manual)
username_text_for_calc = f"@{username}"
username_fs = calculate_font_size(username_text_for_calc, video_w, video_h, custom_size=None if auto_font_size_enabled else username_font_size_manual)
print(f"Font sizes: Poem={poem_fs}, Poet={poet_fs}, Username={username_fs}")
# --- Create Text & Decorative Clips ---
text_and_deco_clips = [] # To be composited over the main video
progress(0.45, desc="Creating username overlay...")
# Username typically at top or bottom, less dynamic start time
username_clip = create_advanced_text_clip(
username_text_for_calc, username_fs, (video_w, video_h), username_text_position, video_duration - 0.5, start_time=0.25,
text_color_hex=username_color, effect_type=username_effect, apply_glow_effect=enable_glow_effect, glow_color_hex=glow_color_main,
text_align="center" # Usernames usually centered or simple alignment
)
text_and_deco_clips.append(username_clip)
progress(0.5, desc="Creating poem verse overlay...")
poem_start_time = 0.5; poem_duration = video_duration - 1.0
verse_clip = create_advanced_text_clip(
poem_verse, poem_fs, (video_w, video_h), poem_text_position, poem_duration, start_time=poem_start_time,
text_color_hex=poem_color, effect_type=poem_effect, apply_glow_effect=enable_glow_effect, glow_color_hex=glow_color_main,
text_align="right" if detect_rtl_text(poem_verse) else "center"
)
text_and_deco_clips.append(verse_clip)
# Create poet clip first, its position might be adjusted by divider
poet_start_time = poem_start_time + 0.5 # Slightly after poem
poet_duration = poem_duration - 0.5
poet_text_content = f"- {poet_name}"
# Create poet clip (initial position)
poet_clip = create_advanced_text_clip(
poet_text_content, poet_fs, (video_w, video_h), poet_original_position, poet_duration, start_time=poet_start_time,
text_color_hex=poet_color, effect_type=poet_effect, apply_glow_effect=enable_glow_effect, glow_color_hex=glow_color_main,
text_align="right" if detect_rtl_text(poet_name) else "center"
)
if add_divider_line:
progress(0.55, desc="Adding divider line...")
divider_color_rgba = hex_to_rgb(poet_color) + (200,) # Use poet color, semi-transparent
divider_thickness = max(2, int(poet_fs * 0.08))
divider_margin_ratio = 0.15
divider_pil_img = create_divider_line(video_w, divider_type_style, divider_color_rgba, divider_thickness, divider_margin_ratio)
divider_img_h_pil = divider_pil_img.height
divider_clip = mp.ImageClip(np.array(divider_pil_img)).set_duration(poet_duration).set_start(poet_start_time).fadein(0.5)
# Position divider: Let's place it a bit above the default "bottom" area or poet's original spot.
# Example: 75% down the screen, or above poet's original position.
# For this version, let's try a fixed relative position for simplicity if poet_original_position is bottom.
if poet_original_position == 'bottom':
# Place divider above typical bottom text area
divider_y_center_target_abs = video_h * 0.78
else: # If poet is 'center', place divider below poem or fixed.
divider_y_center_target_abs = video_h * 0.70 # A general lower-middle position
divider_y_top_for_moviepy = divider_y_center_target_abs - (divider_img_h_pil / 2.0)
divider_clip = divider_clip.set_position(('center', divider_y_top_for_moviepy))
text_and_deco_clips.append(divider_clip)
# --- Reposition poet name to be centered horizontally, just below the divider ---
# Get actual height of the poet text from a temporary render for precise placement
temp_poet_draw_img = Image.new('RGBA', (1,1)) # Minimal image for textbbox
temp_poet_font = ImageFont.truetype(poet_clip.text_options['font'], int(poet_fs)) # Use actual font and size
poet_bbox = ImageDraw.Draw(temp_poet_draw_img).textbbox((0,0), poet_text_content, font=temp_poet_font, direction="rtl" if detect_rtl_text(poet_name) else None)
actual_poet_text_height = poet_bbox[3] - poet_bbox[1] if poet_bbox else poet_fs
poet_gap_below_divider = int(poet_fs * 0.2) # Small gap
poet_y_top_target_abs = divider_y_top_for_moviepy + divider_img_h_pil + poet_gap_below_divider
# Create new poet_clip with this specific custom_position (top-left of text block)
# To center it horizontally, we need its width. Max line width is already calculated in create_advanced_text_clip.
# We can pass custom_position = (x_centered, poet_y_top_target_abs)
# This is tricky because create_advanced_text_clip calculates its own x.
# Alternative: use set_position on the existing poet_clip.
# poet_clip.w is the width of the *entire image clip*, not just text.
# For 'center', Y means center of the clip. If clip height is full video height, this is not text center.
# The easiest is to re-create poet_clip using custom_position focusing on Y, and let create_advanced_text_clip handle X centering.
# Or, set position as ('center', Y_for_center_of_text_block)
# Let's set the position of the *existing* poet_clip (which is full video canvas size with text drawn on it)
# The 'y' in .set_position(('center', y)) refers to the y of the center of the clip.
# If poet_clip is full video height, its center is video_h/2.
# We want the center of the *text block* to be at a certain Y.
# poet_y_center_of_text_target = poet_y_top_target_abs + actual_poet_text_height / 2
# We need to find where to place the full-canvas clip so its internal text appears there.
# This means adjusting the original base_y inside create_advanced_text_clip.
# The current `poet_clip` was made with position_key. We just need to adjust its Y.
poet_clip = poet_clip.set_position(('center', poet_y_top_target_abs + actual_poet_text_height / 2 - poet_clip.h / 2))
# The above is complicated if poet_clip.h is video_h.
# Simpler: create poet_clip with custom_position Y and let internal logic center X.
# The poet_clip is already made. Let's just try setting its Y position.
# We need the Y for the *top* of the poet_clip, assuming text is drawn near top if custom_pos.
# Let's try setting the top of the poet_clip (which is an ImageClip of video_size)
# such that the text *within it* appears where we want.
# This requires knowing where create_advanced_text_clip placed text if custom_pos is used.
# Re-create poet_clip for precise custom Y positioning, allowing its internal X centering to work.
# We need to calculate the X for the text block if we give a custom_position=(x,y)
# The current create_advanced_text_clip uses position_key OR custom_position.
# For this case, we'll set the Y position using moviepy's set_position on the already created clip.
# Assume poet_clip (the transparent canvas) will be positioned such that its drawn text is correct.
# The (x,y) in set_position is top-left of the clip.
# We want the text's top at `poet_y_top_target_abs`.
# The poet_clip is an ImageClip, likely full video height. Its text is drawn at some `base_y` within it.
# This is hard. Let's simplify: position the poet_clip (canvas) such that the center of its text block is at the desired Y.
# We want the TOP of the poet TEXT to be at `poet_y_top_target_abs`.
# The `poet_clip` already has the text rendered on it. Its `pos` can be set.
# The text is at some `y_offset_within_clip` from the top of `poet_clip`.
# We want `y_of_poet_clip_top + y_offset_within_clip = poet_y_top_target_abs`.
# This internal y_offset is `base_y` from `create_advanced_text_clip`.
# This is too complex. Let's use a simpler MoviePy positioning:
# Position the poet_clip (which is a full canvas with text drawn) such that its *center*
# is at `divider_y_top_for_moviepy + divider_img_h_pil + poet_gap_below_divider + actual_poet_text_height / 2`.
# This sets the *center of the text block* correctly.
desired_y_for_center_of_poet_text_block = divider_y_top_for_moviepy + divider_img_h_pil + poet_gap_below_divider + (actual_poet_text_height / 2.0)
poet_clip = poet_clip.set_position(('center', desired_y_for_center_of_poet_text_block))
text_and_deco_clips.append(poet_clip) # Add the (potentially re-positioned) poet_clip
if add_frame_overlay:
progress(0.6, desc="Adding frame...")
frame_color_rgba = hex_to_rgb(poem_color) + (120,) # Use poem color, semi-transparent
frame_thickness = max(3, video_w // 250)
frame_img = create_decorative_frame(video_w, video_h, frame_type_style, frame_color_rgba, frame_thickness)
frame_clip = mp.ImageClip(np.array(frame_img)).set_duration(video_duration).set_start(0).fadein(0.5)
text_and_deco_clips.insert(0, frame_clip) # Frame at the bottom of overlays
if background_overlay_type != "none" and background_overlay_type:
progress(0.65, desc="Adding background overlay...")
overlay_img = create_background_overlay((video_w, video_h), background_overlay_type, overlay_opacity_value, overlay_color_value)
overlay_clip = mp.ImageClip(np.array(overlay_img)).set_duration(video_duration)
insert_idx = 1 if add_frame_overlay else 0 # Behind text, above video (and frame if exists)
text_and_deco_clips.insert(insert_idx, overlay_clip)
progress(0.7, desc="Applying video effects...")
# Enhanced Zoom/Pan Effect
zoom_factor_max = 1.03 # Subtle zoom
pan_amplitude = max(5, video_w // 200) # Subtle pan
def zoom_pan_effect(get_frame, t):
frame = get_frame(t)
pil_img = Image.fromarray(frame)
orig_w, orig_h = pil_img.size
current_zoom = 1 + (zoom_factor_max - 1) * (0.5 * (1 - np.cos(np.pi * t / video_duration)))
new_w, new_h = int(orig_w * current_zoom), int(orig_h * current_zoom)
resized_pil = pil_img.resize((new_w, new_h), Image.Resampling.LANCZOS)
angle = 2 * np.pi * t / (video_duration / 1.5) # Pan cycle 1.5 times
pan_x = int(pan_amplitude * np.sin(angle * 0.6))
pan_y = int(pan_amplitude * np.cos(angle))
crop_x = (new_w - orig_w) // 2 + pan_x; crop_y = (new_h - orig_h) // 2 + pan_y
crop_x = max(0, min(crop_x, new_w - orig_w)); crop_y = max(0, min(crop_y, new_h - orig_h))
cropped_pil = resized_pil.crop((crop_x, crop_y, crop_x + orig_w, crop_y + orig_h))
return np.array(cropped_pil)
video_with_anim_effects = video.fl(zoom_pan_effect)
progress(0.8, desc="Compositing final video...")
# Add all text and decor clips to the list of clips to close
for tc in text_and_deco_clips: clips_to_close.append(tc)
final_composite = mp.CompositeVideoClip([video_with_anim_effects] + text_and_deco_clips, size=(video_w, video_h))
clips_to_close.append(final_composite)
if processed_audio_clip:
final_composite = final_composite.set_audio(processed_audio_clip)
progress(0.9, desc="Rendering final video...")
temp_final_output = tempfile.NamedTemporaryFile(suffix='.mp4', delete=False)
final_output_path = temp_final_output.name
temp_final_output.close()
# No need to add final_output_path to files_to_delete, it's the return value.
output_fps = video.fps if video.fps and video.fps > 0 else 24
output_fps = min(output_fps, 30) # Cap FPS
final_composite.write_videofile(
final_output_path,
codec='libx264',
audio_codec='aac',
fps=output_fps,
preset='medium',
threads=os.cpu_count(), # Use available cores
ffmpeg_params=['-crf', '23', '-pix_fmt', 'yuv420p'] # CRF 23 good balance
)
progress(1.0, desc="Process complete!")
return final_output_path, f"Video processed successfully: {os.path.basename(final_output_path)}"
except Exception as e:
print(f"An error occurred in process_video: {e}")
traceback.print_exc()
# Ensure temp output file is cleaned if error occurs before return
if final_output_path and os.path.exists(final_output_path):
try: os.remove(final_output_path)
except: pass
return None, f"Error: {str(e)}. Check console for details."
finally:
print("--- Cleaning up resources ---")
for clip in clips_to_close:
try:
if clip: clip.close()
except Exception as e_clean:
print(f"Error closing a clip: {e_clean}")
for file_path in files_to_delete:
if file_path and os.path.exists(file_path):
try:
os.remove(file_path)
print(f"Cleaned up temporary file: {file_path}")
except Exception as e_clean:
print(f"Error cleaning up temp file {file_path}: {e_clean}")
print("--- Cleanup finished ---")
# --- Gradio Interface Definition ---
with gr.Blocks(theme=gr.themes.Soft()) as iface:
gr.Markdown("# Persian Poetry Video Creator ✨")
gr.Markdown("Overlay Persian poetry onto videos with Instagram/Google Drive/Direct URL support. Ensure `INSTA_ONEAPI_KEY` is set for Instagram.")
with gr.Row():
with gr.Column(scale=2):
video_url = gr.Textbox(label="Video URL (Instagram, Google Drive, Direct Link)", placeholder="e.g., https://www.instagram.com/p/...")
music_url = gr.Textbox(label="Background Music URL (Direct Link, Optional)", placeholder="e.g., https://example.com/music.mp3")
poem_verse = gr.TextArea(label="Poem Verse (use '\\n' for new lines)", lines=3, placeholder="مثال:\nاین قافله عمر عجب میگذرد\nدریاب دمی که با طرب میگذرد")
poet_name = gr.Textbox(label="Poet Name", placeholder="e.g., خیام")
username = gr.Textbox(label="Your Username (will be displayed with @)", placeholder="e.g., persian_poetry_lover")
with gr.Column(scale=1):
gr.Markdown("### Output")
output_video = gr.Video(label="Processed Video")
status_message = gr.Textbox(label="Status", interactive=False)
process_button = gr.Button("Create Video", variant="primary")
with gr.Accordion("⚙️ Customization Options", open=False):
gr.Markdown("#### Font Settings")
with gr.Row():
auto_font_size_enabled = gr.Checkbox(label="Auto Adjust Font Sizes", value=True)
poem_font_size_manual = gr.Number(label="Poem Font Size (if Auto off)", value=60, minimum=10, maximum=200, step=1)
with gr.Row():
poet_font_size_manual = gr.Number(label="Poet Font Size (if Auto off)", value=40, minimum=10, maximum=150, step=1)
username_font_size_manual = gr.Number(label="Username Font Size (if Auto off)", value=30, minimum=10, maximum=100, step=1)
gr.Markdown("#### Text Colors")
with gr.Row():
poem_color = gr.ColorPicker(label="Poem Color", value="#FFFFFF")
poet_color = gr.ColorPicker(label="Poet Color", value="#E0E0E0") # Slightly off-white for poet
username_color = gr.ColorPicker(label="Username Color", value="#B0B0B0") # Greyish for username
gr.Markdown("#### Text Effects & Styling")
with gr.Row():
poem_effect = gr.Radio(["none", "fade_in", "fade_out", "fade_in_out", "slide_up"], label="Poem Animation", value="fade_in")
poet_effect = gr.Radio(["none", "fade_in", "fade_out", "fade_in_out"], label="Poet Animation", value="fade_in") # Slide up for poet might conflict with divider
username_effect = gr.Radio(["none", "fade_in", "fade_out", "fade_in_out"], label="Username Animation", value="fade_in")
with gr.Row():
enable_glow_effect = gr.Checkbox(label="Enable Text Glow Effect", value=True)
glow_color_main = gr.ColorPicker(label="Glow Color (if enabled)", value="#FFFFFF", info="Applied to all text if glow is on")
gr.Markdown("#### Decorative Elements")
with gr.Row():
add_divider_line = gr.Checkbox(label="Add Divider Line (below poem, above poet)", value=True)
divider_type_style = gr.Radio(["simple"], label="Divider Type", value="simple", info="More types can be added.") # Kept simple for now
with gr.Row():
add_frame_overlay = gr.Checkbox(label="Add Decorative Frame", value=False)
frame_type_style = gr.Radio(["simple"], label="Frame Type", value="simple", info="More types can be added.")
gr.Markdown("#### Background Overlay")
with gr.Row():
background_overlay_type = gr.Radio(["none", "vignette", "solid_color"], label="Background Overlay Type", value="vignette")
overlay_opacity_value = gr.Slider(minimum=0.0, maximum=1.0, step=0.05, label="Overlay Opacity", value=0.3)
overlay_color_value = gr.ColorPicker(label="Solid Overlay Color", value="#000000", info="Used if 'solid_color' overlay is chosen")
gr.Markdown("#### Text Positioning (Advanced)")
with gr.Row():
poem_text_position = gr.Radio(["center", "top", "bottom"], label="Poem Base Position", value="center")
poet_original_position = gr.Radio(["bottom", "center"], label="Poet Original Anchor (influences divider if active)", value="bottom")
username_text_position = gr.Radio(["top", "bottom"], label="Username Position", value="top")
# Connect button to function
process_button.click(
fn=process_video,
inputs=[
video_url, music_url, poem_verse, poet_name, username,
auto_font_size_enabled, poem_font_size_manual, poet_font_size_manual, username_font_size_manual,
poem_color, poet_color, username_color,
poem_effect, poet_effect, username_effect,
enable_glow_effect, glow_color_main,
add_divider_line, divider_type_style,
add_frame_overlay, frame_type_style,
background_overlay_type, overlay_opacity_value, overlay_color_value,
poem_text_position, poet_original_position, username_text_position
],
outputs=[output_video, status_message]
)
if __name__ == "__main__":
iface.launch(debug=True) # Launch the Gradio app