File size: 17,436 Bytes
287c9ca 4c2220b 8583908 9840152 9d84ba9 990e23e 4c2220b 200c5c4 f13d4b2 4c2220b f13d4b2 4c2220b f13d4b2 287c9ca 4c2220b 754c854 4c2220b 754c854 f13d4b2 f02ab98 754c854 4c2220b 200c5c4 754c854 200c5c4 f02ab98 200c5c4 09d5c67 4c2220b f13d4b2 4c2220b f13d4b2 4c2220b 200c5c4 4c2220b f13d4b2 4c2220b 50c620f 4c2220b 5e4272a 4c2220b 29c2122 4c2220b 200c5c4 4c2220b f13d4b2 754c854 9840152 200c5c4 f02ab98 4c2220b 9840152 4c2220b f13d4b2 4c2220b 9840152 b97795f 754c854 4c2220b 09d5c67 4c2220b 9d84ba9 200c5c4 f02ab98 4c2220b 5e4272a 200c5c4 4c2220b f13d4b2 200c5c4 4c2220b 200c5c4 f13d4b2 200c5c4 29c2122 4c2220b f13d4b2 200c5c4 f13d4b2 9840152 4c2220b 754c854 f13d4b2 200c5c4 4c2220b 200c5c4 4c2220b 754c854 4c2220b 754c854 200c5c4 4c2220b f13d4b2 200c5c4 f13d4b2 4c2220b 9840152 4c2220b 200c5c4 4c2220b 754c854 8583908 9d84ba9 754c854 8583908 4c2220b 29c2122 4c2220b 200c5c4 4c2220b 754c854 4c2220b 9d84ba9 4c2220b 754c854 9840152 9d84ba9 200c5c4 b97795f 200c5c4 4c2220b 200c5c4 4c2220b 200c5c4 4c2220b 200c5c4 754c854 f13d4b2 5e4272a |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 |
# core/visual_engine.py
# ... (imports remain the same as the last full version) ...
from PIL import Image, ImageDraw, ImageFont, ImageOps
from moviepy.editor import (ImageClip, concatenate_videoclips, TextClip,
CompositeVideoClip, AudioFileClip)
import moviepy.video.fx.all as vfx
import numpy as np
import os, openai, requests, io, time, random, subprocess, logging
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
ELEVENLABS_CLIENT_IMPORTED = False
ElevenLabsAPIClient = None; Voice = None; VoiceSettings = None
try:
from elevenlabs.client import ElevenLabs as ImportedElevenLabsClient
from elevenlabs import Voice as ImportedVoice, VoiceSettings as ImportedVoiceSettings
ElevenLabsAPIClient = ImportedElevenLabsClient; Voice = ImportedVoice; VoiceSettings = ImportedVoiceSettings
ELEVENLABS_CLIENT_IMPORTED = True; logger.info("Successfully imported ElevenLabs client components.")
except ImportError as e: logger.warning(f"ElevenLabs client import failed: {e}. Audio disabled.")
except Exception as e: logger.warning(f"General ElevenLabs import error: {e}. Audio disabled.")
class VisualEngine:
def __init__(self, output_dir="temp_cinegen_media", default_elevenlabs_voice_id="Rachel"): # Added default
self.output_dir = output_dir; os.makedirs(self.output_dir, exist_ok=True)
# ... (font setup, OpenAI, Pexels init - same as previous) ...
self.font_filename="arial.ttf"; self.font_path_in_container=f"/usr/local/share/fonts/truetype/mycustomfonts/{self.font_filename}"
self.font_size_pil=20; self.video_overlay_font_size=30; self.video_overlay_font_color='white'; self.video_overlay_font='Liberation-Sans-Bold'
try: self.font = ImageFont.truetype(self.font_path_in_container, self.font_size_pil); # logger.info(f"Placeholder font: {self.font_path_in_container}.")
except IOError: logger.warning(f"Placeholder font '{self.font_path_in_container}' fail. Default."); self.font = ImageFont.load_default(); self.font_size_pil = 10
self.openai_api_key = None; self.USE_AI_IMAGE_GENERATION = False
self.dalle_model = "dall-e-3"; self.image_size_dalle3 = "1792x1024"; self.video_frame_size = (1280, 720)
self.elevenlabs_api_key = None; self.USE_ELEVENLABS = False
self.elevenlabs_client = None
self.elevenlabs_voice_id = default_elevenlabs_voice_id # Use passed default or "Rachel"
if VoiceSettings and ELEVENLABS_CLIENT_IMPORTED:
self.elevenlabs_voice_settings = VoiceSettings(stability=0.60, similarity_boost=0.80, style=0.15, use_speaker_boost=True)
else: self.elevenlabs_voice_settings = None
self.pexels_api_key = None; self.USE_PEXELS = False
logger.info("VisualEngine initialized.")
def set_openai_api_key(self,k): # Same
self.openai_api_key=k; self.USE_AI_IMAGE_GENERATION=bool(k)
logger.info(f"DALL-E ({self.dalle_model}) {'Ready.' if k else 'Disabled.'}")
def set_elevenlabs_api_key(self, api_key, voice_id_from_secret=None): # Modified to accept voice_id
self.elevenlabs_api_key=api_key
if voice_id_from_secret: # Prioritize voice ID from secret if provided
self.elevenlabs_voice_id = voice_id_from_secret
logger.info(f"ElevenLabs Voice ID set from secret/config: {self.elevenlabs_voice_id}")
if api_key and ELEVENLABS_CLIENT_IMPORTED and ElevenLabsAPIClient:
try:
self.elevenlabs_client = ElevenLabsAPIClient(api_key=api_key)
if self.elevenlabs_client: self.USE_ELEVENLABS=True; logger.info(f"ElevenLabs Client Ready (Voice: {self.elevenlabs_voice_id}).")
else: self.USE_ELEVENLABS=False; logger.warning("ElevenLabs client is None post-init.")
except Exception as e:
logger.error(f"Error initializing ElevenLabs client: {e}. Disabled.", exc_info=True);
self.USE_ELEVENLABS=False; self.elevenlabs_client = None
else:
self.USE_ELEVENLABS=False; self.elevenlabs_client = None
if not (ELEVENLABS_CLIENT_IMPORTED and ElevenLabsAPIClient):pass
else: logger.info("ElevenLabs API Key not provided. Disabled.")
def set_pexels_api_key(self,k): # Same
self.pexels_api_key=k; self.USE_PEXELS=bool(k)
logger.info(f"Pexels Search {'Ready.' if k else 'Disabled.'}")
# _get_text_dimensions, _create_placeholder_image_content, _search_pexels_image, generate_image_visual
# remain the same as the last full version.
def _get_text_dimensions(self,t,f): # Same
if not t: return 0,self.font_size_pil
try:
if hasattr(f,'getbbox'): bb=f.getbbox(t);w=bb[2]-bb[0];h=bb[3]-bb[1];return w, h if h > 0 else self.font_size_pil
elif hasattr(f,'getsize'): w,h=f.getsize(t);return w, h if h > 0 else self.font_size_pil
else: return int(len(t)*self.font_size_pil*0.6),int(self.font_size_pil*1.2 if self.font_size_pil*1.2>0 else self.font_size_pil)
except: return int(len(t)*self.font_size_pil*0.6),int(self.font_size_pil*1.2)
def _create_placeholder_image_content(self,td,fn,s=None): # Same
if s is None: s = self.video_frame_size
img=Image.new('RGB',s,color=(20,20,40));d=ImageDraw.Draw(img);p=25;max_w=s[0]-(2*p);ls=[];
if not td: td="(Placeholder: No prompt text)"
ws=td.split();cl=""
for w in ws:
tl=cl+w+" ";
if self._get_text_dimensions(tl,self.font)[0] <= max_w: cl=tl
else:
if cl: ls.append(cl.strip()); cl=w+" "
if cl: ls.append(cl.strip())
if not ls: ls.append("(Text error or too long for placeholder)")
_,single_line_h=self._get_text_dimensions("Ay",self.font); single_line_h = single_line_h if single_line_h > 0 else self.font_size_pil + 2
max_ls=min(len(ls),(s[1]-(2*p))//(single_line_h+2))
yt=p + (s[1]-(2*p) - max_ls*(single_line_h+2))/2.0
for i in range(max_ls):
line=ls[i];line_w,_=self._get_text_dimensions(line,self.font);xt=(s[0]-line_w)/2.0
d.text((xt,yt),line,font=self.font,fill=(200,200,180));yt+=single_line_h+2
if i==6 and max_ls > 7: d.text((xt,yt),"...",font=self.font,fill=(200,200,180));break
fp=os.path.join(self.output_dir,fn);
try:img.save(fp);return fp
except Exception as e:logger.error(f"Saving placeholder image {fp}: {e}", exc_info=True);return None
def _search_pexels_image(self, query, output_filename_base): # Same
if not self.USE_PEXELS or not self.pexels_api_key: return None
headers = {"Authorization": self.pexels_api_key}; params = {"query": query, "per_page": 1, "orientation": "landscape", "size": "large"}
pexels_filename = output_filename_base.replace(".png", f"_pexels_{random.randint(1000,9999)}.jpg")
fp = os.path.join(self.output_dir, pexels_filename)
try:
logger.info(f"Searching Pexels for: '{query}'"); eff_q = " ".join(query.split()[:5]); params["query"] = eff_q
r = requests.get("https://api.pexels.com/v1/search", headers=headers, params=params, timeout=20)
r.raise_for_status(); data = r.json()
if data.get("photos") and len(data["photos"]) > 0:
url = data["photos"][0]["src"]["large2x"]
img_r = requests.get(url, timeout=60); img_r.raise_for_status()
img_d = Image.open(io.BytesIO(img_r.content))
if img_d.mode != 'RGB': img_d = img_d.convert('RGB')
img_d.save(fp); logger.info(f"Pexels image saved: {fp}"); return fp
else: logger.info(f"No photos on Pexels for: '{eff_q}'")
except Exception as e: logger.error(f"Pexels error for '{query}': {e}", exc_info=True)
return None
def generate_image_visual(self, image_prompt_text, scene_data, scene_identifier_filename): # Same
fp = os.path.join(self.output_dir, scene_identifier_filename)
if self.USE_AI_IMAGE_GENERATION and self.openai_api_key:
retries = 2
for attempt in range(retries):
try:
logger.info(f"Attempt {attempt+1}: DALL-E ({self.dalle_model}) for: {image_prompt_text[:100]}...")
client = openai.OpenAI(api_key=self.openai_api_key, timeout=90.0)
resp = client.images.generate(model=self.dalle_model, prompt=image_prompt_text, n=1, size=self.image_size_dalle3, quality="hd", response_format="url", style="vivid")
img_url = resp.data[0].url; rev_prompt = getattr(resp.data[0], 'revised_prompt', None)
if rev_prompt: logger.info(f"DALL-E 3 revised_prompt: {rev_prompt[:100]}...")
img_resp = requests.get(img_url, timeout=120); img_resp.raise_for_status()
img_d = Image.open(io.BytesIO(img_resp.content));
if img_d.mode != 'RGB': img_d = img_d.convert('RGB')
img_d.save(fp); logger.info(f"AI Image (DALL-E) saved: {fp}"); return fp
except openai.RateLimitError as e:
logger.warning(f"OpenAI Rate Limit: {e}. Retrying after {5*(attempt+1)}s..."); time.sleep(5 * (attempt + 1))
if attempt == retries - 1: logger.error("Max retries for RateLimitError."); break
else: continue
except openai.APIError as e: logger.error(f"OpenAI API Error: {e}"); break
except requests.exceptions.RequestException as e: logger.error(f"Requests Error (DALL-E download): {e}"); break
except Exception as e: logger.error(f"Generic error (DALL-E gen): {e}", exc_info=True); break
logger.warning("DALL-E failed. Trying Pexels fallback...")
pexels_q_text = scene_data.get('pexels_search_query_๊ฐ๋
', f"{scene_data.get('emotional_beat','')} {scene_data.get('setting_description','')}")
pexels_p = self._search_pexels_image(pexels_q_text, scene_identifier_filename)
if pexels_p: return pexels_p
logger.warning("Pexels also failed/disabled. Using placeholder.")
return self._create_placeholder_image_content(f"[AI/Pexels Failed] {image_prompt_text[:100]}...", scene_identifier_filename)
else:
return self._create_placeholder_image_content(image_prompt_text, scene_identifier_filename)
def generate_narration_audio(self, text_to_narrate, output_filename="narration_overall.mp3"): # Uses self.elevenlabs_voice_id
if not self.USE_ELEVENLABS or not self.elevenlabs_client or not text_to_narrate:
logger.info("ElevenLabs conditions not met. Skipping audio generation.")
return None
audio_filepath = os.path.join(self.output_dir, output_filename)
try:
# self.elevenlabs_voice_id is now set during set_elevenlabs_api_key or by UI
logger.info(f"Generating ElevenLabs audio (Voice ID: {self.elevenlabs_voice_id}) for: {text_to_narrate[:70]}...")
if hasattr(self.elevenlabs_client, 'text_to_speech') and hasattr(self.elevenlabs_client.text_to_speech, 'stream'):
logger.info("Using elevenlabs_client.text_to_speech.stream()")
audio_data_iterator = self.elevenlabs_client.text_to_speech.stream(
text=text_to_narrate,
voice_id=str(self.elevenlabs_voice_id), # Ensure voice_id is a string
model_id="eleven_multilingual_v2",
)
elif hasattr(self.elevenlabs_client, 'generate') and Voice: # Fallback, ensure Voice is imported
logger.info("Using elevenlabs_client.generate() with Voice object.")
voice_param = Voice(voice_id=str(self.elevenlabs_voice_id), settings=self.elevenlabs_voice_settings if self.elevenlabs_voice_settings else None)
audio_data_iterator = self.elevenlabs_client.generate(
text=text_to_narrate, voice=voice_param, model="eleven_multilingual_v2")
else:
logger.error("No recognized audio generation method on ElevenLabs client."); return None
with open(audio_filepath, "wb") as f:
for chunk in audio_data_iterator:
if chunk: f.write(chunk)
logger.info(f"ElevenLabs audio saved: {audio_filepath}")
return audio_filepath
except AttributeError as ae: logger.error(f"AttributeError with ElevenLabs client: {ae}.", exc_info=True)
except Exception as e: logger.error(f"Error generating ElevenLabs audio: {e}", exc_info=True)
return None
def create_video_from_images(self, image_data_list, overall_narration_path=None, output_filename="final_video.mp4", fps=24, duration_per_image=4.5): # Same as previous
if not image_data_list: logger.warning("No image data for video."); return None
processed_clips=[]; narration_audio_clip=None; final_video_clip_obj=None
logger.info(f"Preparing {len(image_data_list)} clips. Target frame: {self.video_frame_size}. Duration/img: {duration_per_image}s.")
for i, data in enumerate(image_data_list):
img_path, scene_num, key_action = data.get('path'), data.get('scene_num', i+1), data.get('key_action', '')
if not (img_path and os.path.exists(img_path)): logger.warning(f"Img not found: {img_path}"); continue
try:
pil_img = Image.open(img_path);
if pil_img.mode != 'RGB': pil_img = pil_img.convert('RGB')
img_copy = pil_img.copy(); img_copy.thumbnail(self.video_frame_size, Image.Resampling.LANCZOS)
canvas = Image.new('RGB', self.video_frame_size, (random.randint(0,5), random.randint(0,5), random.randint(0,5)))
xo,yo=(self.video_frame_size[0]-img_copy.width)//2, (self.video_frame_size[1]-img_copy.height)//2
canvas.paste(img_copy, (xo,yo)); frame_np = np.array(canvas)
img_clip_base = ImageClip(frame_np).set_duration(duration_per_image)
# img_clip = img_clip_base # Test: No Ken Burns if ANTIALIAS error persists
end_scale = random.uniform(1.03, 1.08); img_clip = img_clip_base.fx(vfx.resize, lambda t: 1+(end_scale-1)*(t/duration_per_image)); img_clip = img_clip.set_position('center')
if key_action:
txt_clip = TextClip(f"Scene {scene_num}\n{key_action}", fontsize=self.video_overlay_font_size, color=self.video_overlay_font_color, font=self.video_overlay_font,
bg_color='rgba(10,10,20,0.8)', method='caption', align='West', size=(self.video_frame_size[0]*0.9, None), kerning=-1, stroke_color='black', stroke_width=1.5
).set_duration(duration_per_image-1.0).set_start(0.5).set_position(('center',0.92),relative=True)
final_scene_clip = CompositeVideoClip([img_clip, txt_clip], size=self.video_frame_size, use_bgclip=True, bg_color=(0,0,0))
else: final_scene_clip = img_clip
processed_clips.append(final_scene_clip)
except Exception as e: logger.error(f"Creating video clip for {img_path}: {e}", exc_info=True)
if not processed_clips: logger.warning("No clips processed for video."); return None
transition = 0.75
try:
final_video_clip_obj = concatenate_videoclips(processed_clips, padding=-transition, method="compose")
if final_video_clip_obj.duration > transition*2: final_video_clip_obj = final_video_clip_obj.fx(vfx.fadein, transition).fx(vfx.fadeout, transition)
if overall_narration_path and os.path.exists(overall_narration_path):
try:
narration_audio_clip = AudioFileClip(overall_narration_path)
if narration_audio_clip.duration < final_video_clip_obj.duration: final_video_clip_obj = final_video_clip_obj.subclip(0, narration_audio_clip.duration)
final_video_clip_obj = final_video_clip_obj.set_audio(narration_audio_clip); logger.info("Overall narration added.")
except Exception as e: logger.error(f"Adding overall narration: {e}", exc_info=True)
output_path = os.path.join(self.output_dir, output_filename); logger.info(f"Writing final video to: {output_path}")
final_video_clip_obj.write_videofile(output_path, fps=fps, codec='libx264', preset='medium', audio_codec='aac',
temp_audiofile=os.path.join(self.output_dir, f'temp-audio-{os.urandom(4).hex()}.m4a'),
remove_temp=True, threads=os.cpu_count() or 2, logger='bar', bitrate="5000k")
logger.info(f"Video successfully created: {output_path}"); return output_path
except Exception as e: logger.error(f"Writing video file: {e}", exc_info=True); return None
finally:
for c_item in processed_clips:
if hasattr(c_item, 'close'): c_item.close()
if narration_audio_clip and hasattr(narration_audio_clip, 'close'): narration_audio_clip.close()
if final_video_clip_obj and hasattr(final_video_clip_obj, 'close'): final_video_clip_obj.close() |