Mustafa-albakkar commited on
Commit
baebae4
·
verified ·
1 Parent(s): 25ac81b

Upload app_video_agent_fixed.py

Browse files
Files changed (1) hide show
  1. app_video_agent_fixed.py +1022 -0
app_video_agent_fixed.py ADDED
@@ -0,0 +1,1022 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # === FILE: app.py (Video Agent - FIXED: نص ثابت على شاشة متحركة) ===
2
+ #
3
+ # IMPORTANT: For proper text rendering, make sure Roboto-Bold.ttf font is installed
4
+ # Install with: apt-get install -y fonts-roboto
5
+ # Or download from: https://fonts.google.com/specimen/Roboto
6
+ #
7
+ # If font is not available, the system will fall back to Arial-Bold
8
+ #
9
+
10
+ import os
11
+ import io
12
+ import json
13
+ import base64
14
+ import logging
15
+ import random
16
+ from typing import Optional, Dict, Any, Tuple, List
17
+ from datetime import datetime
18
+ import tempfile
19
+
20
+ import gradio as gr
21
+ import numpy as np
22
+ from PIL import Image, ImageDraw, ImageFont
23
+
24
+ # Fix for Pillow 10.0.0+ compatibility with MoviePy
25
+ if not hasattr(Image, 'ANTIALIAS'):
26
+ Image.ANTIALIAS = Image.LANCZOS
27
+
28
+ # استيراد مكتبات معالجة الصوت والفيديو
29
+ from kokoro_engine import KokoroEngine
30
+
31
+ KOKORO_AVAILABLE = True
32
+
33
+ try:
34
+ from moviepy.editor import ImageClip, AudioFileClip, CompositeVideoClip, TextClip
35
+ MOVIEPY_AVAILABLE = True
36
+ except ImportError:
37
+ MOVIEPY_AVAILABLE = False
38
+ logging.warning("⚠️ MoviePy not available. Install with: pip install moviepy")
39
+
40
+ # ---------------- Logging Setup ----------------
41
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
42
+ log = logging.getLogger("video_agent")
43
+
44
+ # ---------------- Environment Variables ----------------
45
+ VIDEO_HISTORY_DIR = os.getenv("VIDEO_HISTORY_DIR", "video_history")
46
+ MAX_HISTORY_COUNT = int(os.getenv("MAX_HISTORY_COUNT", "10"))
47
+ VOICE_STATE_FILE = os.getenv("VOICE_STATE_FILE", "voice_rotation_state.json")
48
+
49
+ # ---------------- Kokoro Voices List ----------------
50
+ KOKORO_VOICES = [
51
+ # British Female
52
+ "bf_alice",
53
+ "bf_emma",
54
+ "bf_isabella",
55
+ "bf_lily",
56
+ # American Female
57
+ "af_alloy",
58
+ "af_aoede",
59
+ "af_bella",
60
+ "af_heart",
61
+ "af_jessica",
62
+ "af_kore",
63
+ "af_nicole",
64
+ "af_nova",
65
+ "af_river",
66
+ "af_sarah",
67
+ "af_sky",
68
+ # British Male
69
+ "bm_daniel",
70
+ "bm_fable",
71
+ "bm_george",
72
+ "bm_lewis",
73
+ # American Male
74
+ "am_adam",
75
+ "am_echo",
76
+ "am_eric",
77
+ "am_fenrir",
78
+ "am_liam",
79
+ "am_michael",
80
+ "am_onyx",
81
+ "am_puck"
82
+ ]
83
+
84
+ # ---------------- Initialization Check ----------------
85
+ IS_SERVICE_READY = True
86
+
87
+ # ---------------- Voice Rotation Manager ----------------
88
+ class VoiceRotationManager:
89
+ """إدارة تدوير الأصوات بشكل دوري"""
90
+
91
+ def __init__(self, state_file: str, voices: List[str]):
92
+ self.state_file = state_file
93
+ self.voices = voices
94
+ self.current_index = 0
95
+ self.load_state()
96
+
97
+ def load_state(self):
98
+ """تحميل حالة التدوير من الملف"""
99
+ if os.path.exists(self.state_file):
100
+ try:
101
+ with open(self.state_file, "r") as f:
102
+ data = json.load(f)
103
+ self.current_index = data.get("current_voice_index", 0)
104
+ # التأكد من أن المؤشر في النطاق الصحيح
105
+ if self.current_index >= len(self.voices):
106
+ self.current_index = 0
107
+ log.info(f"Voice rotation state loaded: index={self.current_index}")
108
+ except Exception as e:
109
+ log.warning(f"Could not load voice rotation state: {e}")
110
+ self.current_index = 0
111
+ else:
112
+ log.info("No voice rotation state file found, starting from index 0")
113
+
114
+ def save_state(self):
115
+ """حفظ حالة التدوير في الملف"""
116
+ try:
117
+ with open(self.state_file, "w") as f:
118
+ json.dump({"current_voice_index": self.current_index}, f)
119
+ log.info(f"Voice rotation state saved: index={self.current_index}")
120
+ except Exception as e:
121
+ log.error(f"Failed to save voice rotation state: {e}")
122
+
123
+ def get_next_voice(self) -> str:
124
+ """الحصول على الصوت التالي والانتقال للصوت الذي يليه"""
125
+ voice = self.voices[self.current_index]
126
+ log.info(f"Selected voice: {voice} (index: {self.current_index}/{len(self.voices)-1})")
127
+
128
+ # الانتقال للصوت التالي
129
+ self.current_index = (self.current_index + 1) % len(self.voices)
130
+ self.save_state()
131
+
132
+ return voice
133
+
134
+ def get_current_voice(self) -> str:
135
+ """الحصول على الصوت الحالي بدون تغيير المؤشر"""
136
+ return self.voices[self.current_index]
137
+
138
+ def reset(self):
139
+ """إعادة تعيين التدوير إلى البداية"""
140
+ self.current_index = 0
141
+ self.save_state()
142
+ log.info("Voice rotation reset to index 0")
143
+
144
+ # ---------------- Motion Effects Functions ----------------
145
+ def apply_zoom_in_effect(clip, duration):
146
+ """تأثير التكبير التدريجي - من 100% إلى 120%"""
147
+ w, h = clip.size
148
+
149
+ def effect(gf, t):
150
+ frame = gf(t)
151
+ progress = min(t / duration, 1.0)
152
+ zoom_factor = 1.0 + (progress * 0.2)
153
+
154
+ new_w = int(w * zoom_factor)
155
+ new_h = int(h * zoom_factor)
156
+
157
+ from PIL import Image as PILImage
158
+ img = PILImage.fromarray(frame.astype('uint8'))
159
+ img_resized = img.resize((new_w, new_h), PILImage.LANCZOS)
160
+
161
+ left = (new_w - w) // 2
162
+ top = (new_h - h) // 2
163
+ img_cropped = img_resized.crop((left, top, left + w, top + h))
164
+
165
+ return np.array(img_cropped)
166
+
167
+ return clip.fl(effect)
168
+
169
+ def apply_zoom_out_effect(clip, duration):
170
+ """تأثير التصغير التدريجي - من 120% إلى 100%"""
171
+ w, h = clip.size
172
+
173
+ def effect(gf, t):
174
+ frame = gf(t)
175
+ progress = min(t / duration, 1.0)
176
+ zoom_factor = 1.2 - (progress * 0.2)
177
+
178
+ new_w = int(w * zoom_factor)
179
+ new_h = int(h * zoom_factor)
180
+
181
+ from PIL import Image as PILImage
182
+ img = PILImage.fromarray(frame.astype('uint8'))
183
+ img_resized = img.resize((new_w, new_h), PILImage.LANCZOS)
184
+
185
+ left = (new_w - w) // 2
186
+ top = (new_h - h) // 2
187
+ img_cropped = img_resized.crop((left, top, left + w, top + h))
188
+
189
+ return np.array(img_cropped)
190
+
191
+ return clip.fl(effect)
192
+
193
+ def apply_pan_right_effect(clip, duration):
194
+ """تأثير الانسحاب لليمين"""
195
+ w, h = clip.size
196
+
197
+ def effect(gf, t):
198
+ frame = gf(t)
199
+ progress = min(t / duration, 1.0)
200
+
201
+ zoom_factor = 1.2
202
+ new_w = int(w * zoom_factor)
203
+ new_h = int(h * zoom_factor)
204
+
205
+ from PIL import Image as PILImage
206
+ img = PILImage.fromarray(frame.astype('uint8'))
207
+ img_resized = img.resize((new_w, new_h), PILImage.LANCZOS)
208
+
209
+ max_offset = (new_w - w) // 2
210
+ left = int(max_offset * (1 - progress))
211
+ top = (new_h - h) // 2
212
+
213
+ img_cropped = img_resized.crop((left, top, left + w, top + h))
214
+
215
+ return np.array(img_cropped)
216
+
217
+ return clip.fl(effect)
218
+
219
+ def apply_pan_left_effect(clip, duration):
220
+ """تأثير الانسحاب لليسار"""
221
+ w, h = clip.size
222
+
223
+ def effect(gf, t):
224
+ frame = gf(t)
225
+ progress = min(t / duration, 1.0)
226
+
227
+ zoom_factor = 1.2
228
+ new_w = int(w * zoom_factor)
229
+ new_h = int(h * zoom_factor)
230
+
231
+ from PIL import Image as PILImage
232
+ img = PILImage.fromarray(frame.astype('uint8'))
233
+ img_resized = img.resize((new_w, new_h), PILImage.LANCZOS)
234
+
235
+ max_offset = (new_w - w) // 2
236
+ left = int(max_offset * progress)
237
+ top = (new_h - h) // 2
238
+
239
+ img_cropped = img_resized.crop((left, top, left + w, top + h))
240
+
241
+ return np.array(img_cropped)
242
+
243
+ return clip.fl(effect)
244
+
245
+ def apply_pan_down_effect(clip, duration):
246
+ """تأثير الانسحاب للأسفل"""
247
+ w, h = clip.size
248
+
249
+ def effect(gf, t):
250
+ frame = gf(t)
251
+ progress = min(t / duration, 1.0)
252
+
253
+ zoom_factor = 1.2
254
+ new_w = int(w * zoom_factor)
255
+ new_h = int(h * zoom_factor)
256
+
257
+ from PIL import Image as PILImage
258
+ img = PILImage.fromarray(frame.astype('uint8'))
259
+ img_resized = img.resize((new_w, new_h), PILImage.LANCZOS)
260
+
261
+ max_offset = (new_h - h) // 2
262
+ left = (new_w - w) // 2
263
+ top = int(max_offset * (1 - progress))
264
+
265
+ img_cropped = img_resized.crop((left, top, left + w, top + h))
266
+
267
+ return np.array(img_cropped)
268
+
269
+ return clip.fl(effect)
270
+
271
+ def apply_pan_up_effect(clip, duration):
272
+ """تأثير الانسحاب للأعلى"""
273
+ w, h = clip.size
274
+
275
+ def effect(gf, t):
276
+ frame = gf(t)
277
+ progress = min(t / duration, 1.0)
278
+
279
+ zoom_factor = 1.2
280
+ new_w = int(w * zoom_factor)
281
+ new_h = int(h * zoom_factor)
282
+
283
+ from PIL import Image as PILImage
284
+ img = PILImage.fromarray(frame.astype('uint8'))
285
+ img_resized = img.resize((new_w, new_h), PILImage.LANCZOS)
286
+
287
+ max_offset = (new_h - h) // 2
288
+ left = (new_w - w) // 2
289
+ top = int(max_offset * progress)
290
+
291
+ img_cropped = img_resized.crop((left, top, left + w, top + h))
292
+
293
+ return np.array(img_cropped)
294
+
295
+ return clip.fl(effect)
296
+
297
+ def apply_ken_burns_effect(clip, duration):
298
+ """تأثير Ken Burns - تكبير وحركة قطرية"""
299
+ w, h = clip.size
300
+
301
+ def effect(gf, t):
302
+ frame = gf(t)
303
+ progress = min(t / duration, 1.0)
304
+
305
+ zoom_factor = 1.0 + (progress * 0.3)
306
+ new_w = int(w * zoom_factor)
307
+ new_h = int(h * zoom_factor)
308
+
309
+ from PIL import Image as PILImage
310
+ img = PILImage.fromarray(frame.astype('uint8'))
311
+ img_resized = img.resize((new_w, new_h), PILImage.LANCZOS)
312
+
313
+ max_offset_w = (new_w - w) // 2
314
+ max_offset_h = (new_h - h) // 2
315
+
316
+ left = int(max_offset_w * (1 - progress * 0.5))
317
+ top = int(max_offset_h * (1 - progress * 0.5))
318
+
319
+ img_cropped = img_resized.crop((left, top, left + w, top + h))
320
+
321
+ return np.array(img_cropped)
322
+
323
+ return clip.fl(effect)
324
+
325
+ def get_random_motion_effect():
326
+ """اختيار تأثير حركة عشوائي"""
327
+ effects = [
328
+ ('zoom_in', apply_zoom_in_effect),
329
+ ('zoom_out', apply_zoom_out_effect),
330
+ ('pan_right', apply_pan_right_effect),
331
+ ('pan_left', apply_pan_left_effect),
332
+ ('pan_down', apply_pan_down_effect),
333
+ ('pan_up', apply_pan_up_effect),
334
+ ('ken_burns', apply_ken_burns_effect)
335
+ ]
336
+
337
+ effect_name, effect_func = random.choice(effects)
338
+ log.info(f"Selected motion effect: {effect_name}")
339
+ return effect_name, effect_func
340
+
341
+ # ---------------- FIXED: Text Overlay Function ----------------
342
+ def create_text_overlay(text: str, video_size: Tuple[int, int], duration: float) -> Optional[ImageClip]:
343
+ """
344
+ إنشاء طبقة نص ثابتة شفافة فوق الفيديو.
345
+ النص يبقى في نفس الموضع بالنسبة للشاشة، بينما الصورة تتحرك خلفه.
346
+
347
+ Args:
348
+ text: النص المراد عرضه
349
+ video_size: حجم الفيديو (width, height)
350
+ duration: المدة الكلية
351
+
352
+ Returns:
353
+ ImageClip شفاف مع النص أو None
354
+ """
355
+ # تنظيف النص من الرموز الخاصة
356
+ clean_text = text.replace("...", "").replace("—", "-").strip()
357
+
358
+ if not clean_text:
359
+ log.warning("Empty text after cleaning, skipping text overlay")
360
+ return None
361
+
362
+ log.info(f"Creating fixed text overlay: '{clean_text[:50]}...'")
363
+
364
+ try:
365
+ width, height = video_size
366
+
367
+ # إنشاء صورة شفافة بالكامل (RGBA)
368
+ img = Image.new('RGBA', (width, height), (0, 0, 0, 0))
369
+ draw = ImageDraw.Draw(img)
370
+
371
+ # حساب حجم الخط
372
+ fontsize = int(width * 0.06)
373
+ stroke_width = max(2, int(fontsize / 20))
374
+
375
+ # تحميل الخط
376
+ font = None
377
+ for font_name in ["Roboto-Bold.ttf", "DejaVuSans-Bold.ttf", "Arial.ttf", "LiberationSans-Bold.ttf"]:
378
+ try:
379
+ font = ImageFont.truetype(font_name, fontsize)
380
+ log.info(f"✅ Font loaded: {font_name}")
381
+ break
382
+ except:
383
+ pass
384
+
385
+ if not font:
386
+ font = ImageFont.load_default()
387
+ log.warning("⚠️ Using default font")
388
+
389
+ # تقسيم النص إلى أسطر
390
+ def wrap_text(text, font, max_width):
391
+ lines = []
392
+ words = text.split()
393
+ while words:
394
+ line = ''
395
+ while words and draw.textlength(line + words[0] + ' ', font=font) < max_width:
396
+ line += (words.pop(0) + ' ')
397
+ if not line and words:
398
+ line = words.pop(0)
399
+ lines.append(line.strip())
400
+ return lines
401
+
402
+ wrapped_lines = wrap_text(clean_text, font, width * 0.85)
403
+
404
+ # حساب الموضع الثابت في منتصف الشاشة
405
+ line_height = fontsize + 10
406
+ total_height = len(wrapped_lines) * line_height
407
+
408
+ # موضع ثابت في منتصف الشاشة
409
+ start_y = (height - total_height) // 2
410
+
411
+ # رسم كل سطر في موضع ثابت
412
+ current_y = start_y
413
+
414
+ for line in wrapped_lines:
415
+ # حساب عرض السطر
416
+ bbox = draw.textbbox((0, 0), line, font=font)
417
+ line_width = bbox[2] - bbox[0]
418
+ line_x = (width - line_width) // 2
419
+
420
+ # رسم النص مع الحدود (موضع ثابت)
421
+ draw.text(
422
+ (line_x, current_y),
423
+ line,
424
+ font=font,
425
+ fill=(255, 255, 255, 255), # أبيض بالكامل
426
+ stroke_width=stroke_width,
427
+ stroke_fill=(0, 0, 0, 255) # حدود سوداء
428
+ )
429
+
430
+ current_y += line_height
431
+
432
+ # تحويل إلى numpy array مع الحفاظ على الشفافية
433
+ img_array = np.array(img)
434
+
435
+ # إنشاء ImageClip من الصورة الشفافة
436
+ text_clip = ImageClip(img_array, duration=duration, ismask=False, transparent=True)
437
+
438
+ log.info(f"✅ Fixed text overlay created successfully (transparent layer)")
439
+ return text_clip
440
+
441
+ except Exception as e:
442
+ log.error(f"❌ Failed to create text overlay: {e}")
443
+ import traceback
444
+ traceback.print_exc()
445
+ return None
446
+
447
+ # ---------------- Video Agent Class ----------------
448
+ class VideoAgent:
449
+ def __init__(self):
450
+ self.log = logging.getLogger("video_agent")
451
+ self.history = []
452
+ self._setup_history_dir()
453
+
454
+ # تهيئة مدير تدوير الأصوات
455
+ self.voice_manager = VoiceRotationManager(VOICE_STATE_FILE, KOKORO_VOICES)
456
+
457
+ # تهيئة نموذج Kokoro TTS
458
+ self.tts = None
459
+ self.kokoro_available = False
460
+
461
+ self.log.info("Initializing Kokoro TTS (ONNX - NeuML model)...")
462
+
463
+ try:
464
+ self.tts = KokoroEngine()
465
+ self.kokoro_available = True
466
+ self.log.info("✅ Kokoro-ONNX initialized successfully (NeuML model)")
467
+ except Exception as e:
468
+ self.tts = None
469
+ self.kokoro_available = False
470
+ self.log.error("❌ Kokoro TTS initialization failed")
471
+ self.log.error(str(e))
472
+ import traceback
473
+ traceback.print_exc()
474
+
475
+ def _setup_history_dir(self):
476
+ """إنشاء مجلد السجل إذا لم يكن موجوداً"""
477
+ if not os.path.exists(VIDEO_HISTORY_DIR):
478
+ os.makedirs(VIDEO_HISTORY_DIR)
479
+ self.log.info(f"Created video history directory: {VIDEO_HISTORY_DIR}")
480
+
481
+ def generate_audio(self, text: str, output_path: str, voice: Optional[str] = None, speed: float = 0.8) -> Tuple[bool, str]:
482
+ """توليد ملف صوتي من النص باستخدام Kokoro TTS."""
483
+ if not self.kokoro_available or self.tts is None:
484
+ self.log.error("Kokoro TTS not available.")
485
+ return False, ""
486
+
487
+ try:
488
+ if voice is None:
489
+ voice = self.voice_manager.get_next_voice()
490
+ else:
491
+ if voice not in KOKORO_VOICES:
492
+ self.log.warning(f"Voice '{voice}' not found, using next voice from rotation")
493
+ voice = self.voice_manager.get_next_voice()
494
+
495
+ self.log.info(f"Generating audio with voice: {voice}, speed: {speed}")
496
+ self.log.info(f"Text: {text[:50]}...")
497
+
498
+ # تعيين الصوت
499
+ self.tts.set_voice(voice)
500
+
501
+ # توليد الصوت
502
+ audio_data = self.tts.synthesize(text, speed=speed)
503
+
504
+ # حفظ الملف
505
+ import scipy.io.wavfile as wavfile
506
+ sample_rate = 24000
507
+
508
+ # التأكد من أن البيانات في النطاق الصحيح لـ int16
509
+ audio_int16 = np.clip(audio_data * 32767, -32768, 32767).astype(np.int16)
510
+ wavfile.write(output_path, sample_rate, audio_int16)
511
+
512
+ self.log.info(f"✅ Audio generated successfully: {output_path}")
513
+ return True, voice
514
+
515
+ except Exception as e:
516
+ self.log.error(f"Audio generation failed: {e}")
517
+ import traceback
518
+ traceback.print_exc()
519
+ return False, ""
520
+
521
+ def create_video(self, image_bytes: bytes, audio_path: str, output_path: str, display_text: str) -> bool:
522
+ """
523
+ إنشاء فيديو من صورة وملف صوتي مع نص ثابت على الشاشة.
524
+
525
+ Args:
526
+ image_bytes: بيانات الصورة
527
+ audio_path: مسار الملف الصوتي
528
+ output_path: مسار حفظ الفيديو
529
+ display_text: النص المراد عرضه (ثابت على الشاشة)
530
+ """
531
+ if not MOVIEPY_AVAILABLE:
532
+ self.log.error("MoviePy not available.")
533
+ return False
534
+
535
+ audio = None
536
+ video = None
537
+ base_clip = None
538
+ animated_clip = None
539
+ text_clip = None
540
+
541
+ try:
542
+ image = Image.open(io.BytesIO(image_bytes))
543
+ # تحويل إلى RGB إذا لزم الأمر
544
+ if image.mode != 'RGB':
545
+ image = image.convert('RGB')
546
+ img_array = np.array(image)
547
+
548
+ if not os.path.exists(audio_path):
549
+ raise FileNotFoundError(f"Audio file not found: {audio_path}")
550
+
551
+ self.log.info(f"Loading audio from: {audio_path}")
552
+ audio = AudioFileClip(audio_path)
553
+ audio_duration = audio.duration
554
+
555
+ self.log.info(f"Creating video with duration: {audio_duration:.2f}s")
556
+
557
+ # اختيار التأثير
558
+ effect_name, effect_func = get_random_motion_effect()
559
+ self.log.info(f"Applying effect: {effect_name}")
560
+
561
+ # إنشاء clip أساسي
562
+ base_clip = ImageClip(img_array, duration=audio_duration)
563
+
564
+ # تطبيق التأثير على الصورة
565
+ animated_clip = effect_func(base_clip, audio_duration)
566
+
567
+ # إنشاء طبقة نص ثابتة شفافة
568
+ self.log.info(f"Creating fixed text overlay for: '{display_text[:50]}...'")
569
+ text_clip = create_text_overlay(display_text, animated_clip.size, audio_duration)
570
+
571
+ if text_clip:
572
+ # دمج الصورة المتحركة مع النص الثابت
573
+ self.log.info("Compositing video: moving image + fixed text overlay...")
574
+ video = CompositeVideoClip([animated_clip, text_clip])
575
+ self.log.info("✅ Text overlay composited successfully (fixed position)")
576
+ else:
577
+ self.log.warning("⚠️ Text overlay creation failed, using video without text")
578
+ video = animated_clip
579
+
580
+ # إضافة الصوت
581
+ video = video.set_audio(audio)
582
+
583
+ # كتابة الفيديو
584
+ self.log.info(f"Writing video to: {output_path}")
585
+ video.write_videofile(
586
+ output_path,
587
+ fps=24,
588
+ codec='libx264',
589
+ audio_codec='aac',
590
+ temp_audiofile='temp-audio.m4a',
591
+ remove_temp=True,
592
+ verbose=False,
593
+ logger=None,
594
+ preset='ultrafast',
595
+ threads=4
596
+ )
597
+
598
+ self.log.info(f"✅ Video created successfully with {effect_name} effect + fixed text: {output_path}")
599
+ return True
600
+
601
+ except Exception as e:
602
+ self.log.error(f"Video creation failed: {e}")
603
+ import traceback
604
+ traceback.print_exc()
605
+ return False
606
+
607
+ finally:
608
+ # تنظيف الموارد
609
+ try:
610
+ if text_clip is not None:
611
+ text_clip.close()
612
+ self.log.debug("Text clip closed")
613
+ except Exception as e:
614
+ self.log.warning(f"Error closing text clip: {e}")
615
+
616
+ try:
617
+ if animated_clip is not None:
618
+ animated_clip.close()
619
+ self.log.debug("Animated clip closed")
620
+ except Exception as e:
621
+ self.log.warning(f"Error closing animated clip: {e}")
622
+
623
+ try:
624
+ if base_clip is not None:
625
+ base_clip.close()
626
+ self.log.debug("Base clip closed")
627
+ except Exception as e:
628
+ self.log.warning(f"Error closing base clip: {e}")
629
+
630
+ try:
631
+ if audio is not None:
632
+ audio.close()
633
+ self.log.debug("Audio closed")
634
+ except Exception as e:
635
+ self.log.warning(f"Error closing audio: {e}")
636
+
637
+ try:
638
+ if video is not None:
639
+ video.close()
640
+ self.log.debug("Video closed")
641
+ except Exception as e:
642
+ self.log.warning(f"Error closing video: {e}")
643
+
644
+ def process_request_custom(
645
+ self,
646
+ image_base64: str,
647
+ tts_text: str,
648
+ voice: Optional[str] = None,
649
+ speed: float = 0.8
650
+ ) -> Dict[str, Any]:
651
+ """
652
+ معالجة طلب إنشاء فيديو مع نص صوتي مخصص.
653
+
654
+ Args:
655
+ image_base64: الصورة (بدون نص مكتوب عليها)
656
+ tts_text: النص الصوتي المخصص من حقل tts_kokoro
657
+ voice: الصوت (اختياري)
658
+ speed: السرعة
659
+ """
660
+ if not self.kokoro_available or self.tts is None:
661
+ raise RuntimeError("Kokoro TTS not available")
662
+
663
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
664
+ safe_text = tts_text[:30].replace(" ", "_").replace("/", "_").strip()
665
+
666
+ temp_dir = tempfile.gettempdir()
667
+ audio_path = os.path.join(temp_dir, f"audio_{timestamp}.wav")
668
+ video_filename = f"{timestamp}_{safe_text}.mp4"
669
+ video_path = os.path.join(VIDEO_HISTORY_DIR, video_filename)
670
+
671
+ try:
672
+ image_bytes = base64.b64decode(image_base64)
673
+
674
+ # توليد الصوت
675
+ success, voice_used = self.generate_audio(tts_text, audio_path, voice, speed)
676
+ if not success:
677
+ raise RuntimeError("Failed to generate audio.")
678
+
679
+ # إنشاء الفيديو مع النص الثابت
680
+ if not self.create_video(image_bytes, audio_path, video_path, tts_text):
681
+ raise RuntimeError("Failed to create video.")
682
+
683
+ with open(video_path, "rb") as f:
684
+ video_bytes = f.read()
685
+ video_base64 = base64.b64encode(video_bytes).decode('utf-8')
686
+
687
+ entry = {
688
+ "timestamp": timestamp,
689
+ "tts_text": tts_text,
690
+ "voice": voice_used,
691
+ "video_path": video_path,
692
+ "duration": self._get_video_duration(video_path)
693
+ }
694
+
695
+ self.history.insert(0, entry)
696
+ self.history = self.history[:MAX_HISTORY_COUNT]
697
+
698
+ if os.path.exists(audio_path):
699
+ os.remove(audio_path)
700
+
701
+ self.log.info(f"✅ Video processing completed: {video_filename}")
702
+
703
+ return {
704
+ "video_base64": video_base64,
705
+ "video_path": video_path,
706
+ "tts_text": tts_text,
707
+ "voice_used": voice_used,
708
+ "status": "success",
709
+ "message": f"Video generated successfully with voice: {voice_used} at speed: {speed}"
710
+ }
711
+
712
+ except Exception as e:
713
+ self.log.error(f"Video processing failed: {e}")
714
+ if os.path.exists(audio_path):
715
+ os.remove(audio_path)
716
+ raise RuntimeError(f"Video generation failed: {str(e)}")
717
+
718
+ def _get_video_duration(self, video_path: str) -> float:
719
+ """الحصول على مدة الفيديو بالثواني"""
720
+ try:
721
+ if MOVIEPY_AVAILABLE:
722
+ from moviepy.editor import VideoFileClip
723
+ clip = VideoFileClip(video_path)
724
+ duration = clip.duration
725
+ clip.close()
726
+ return duration
727
+ except Exception as e:
728
+ self.log.warning(f"Could not get video duration: {e}")
729
+ return 0.0
730
+
731
+ def get_history(self) -> List[Dict[str, Any]]:
732
+ """الحصول على سجل الفيديوهات"""
733
+ return self.history
734
+
735
+ def get_voice_rotation_info(self) -> Dict[str, Any]:
736
+ """الحصول على معلومات حالة تدوير الأصوات"""
737
+ return {
738
+ "current_voice": self.voice_manager.get_current_voice(),
739
+ "current_index": self.voice_manager.current_index,
740
+ "total_voices": len(KOKORO_VOICES),
741
+ "all_voices": KOKORO_VOICES
742
+ }
743
+
744
+ # ---------------- Global Agent Instance ----------------
745
+ agent = VideoAgent()
746
+
747
+ if IS_SERVICE_READY:
748
+ if agent.tts is not None:
749
+ current_voice = agent.voice_manager.get_current_voice()
750
+ STATUS_MESSAGE = f"✅ Video Agent ready | Voice: {current_voice} | Speed: 0.8 | Text: FIXED (stays on screen) | Image moves beneath"
751
+ else:
752
+ STATUS_MESSAGE = "⚠️ Video Agent ready but Kokoro-ONNX failed to initialize."
753
+ else:
754
+ STATUS_MESSAGE = "❌ Service not ready"
755
+
756
+ # ---------------- Gradio Functions ----------------
757
+
758
+ def gradio_generate_video_custom(
759
+ image_input,
760
+ tts_text: str,
761
+ voice_override: str,
762
+ speed: float
763
+ ) -> Tuple[Optional[str], str, str]:
764
+ """دالة Gradio للواجهة التفاعلية مع نص مخصص."""
765
+ if not IS_SERVICE_READY:
766
+ return None, f"❌ Service not ready: {STATUS_MESSAGE}", ""
767
+
768
+ if not image_input:
769
+ return None, "❌ Please provide an image.", ""
770
+
771
+ if not tts_text:
772
+ return None, "❌ Please provide TTS text.", ""
773
+
774
+ try:
775
+ if isinstance(image_input, str):
776
+ with open(image_input, "rb") as f:
777
+ image_bytes = f.read()
778
+ else:
779
+ buffered = io.BytesIO()
780
+ image_input.save(buffered, format="JPEG")
781
+ image_bytes = buffered.getvalue()
782
+
783
+ image_base64 = base64.b64encode(image_bytes).decode('utf-8')
784
+ voice = None if voice_override == "Auto" or not voice_override else voice_override
785
+
786
+ result = agent.process_request_custom(
787
+ image_base64=image_base64,
788
+ tts_text=tts_text,
789
+ voice=voice,
790
+ speed=speed
791
+ )
792
+
793
+ video_path = result["video_path"]
794
+ voice_used = result["voice_used"]
795
+
796
+ voice_info = agent.get_voice_rotation_info()
797
+ next_voice = voice_info["current_voice"]
798
+ current_index = voice_info["current_index"]
799
+ total_voices = voice_info["total_voices"]
800
+
801
+ status_msg = (
802
+ f"✅ {result['message']}\n\n"
803
+ f"**TTS Text:** {result['tts_text'][:100]}...\n"
804
+ f"**Duration:** {result.get('duration', 0):.2f}s\n"
805
+ f"**Effect:** Moving image + fixed text overlay"
806
+ )
807
+
808
+ voice_rotation_msg = (
809
+ f"**Voice Used:** {voice_used}\n"
810
+ f"**Next Voice:** {next_voice}\n"
811
+ f"**Progress:** {current_index}/{total_voices}"
812
+ )
813
+
814
+ return video_path, status_msg, voice_rotation_msg
815
+
816
+ except Exception as e:
817
+ log.error(f"Video generation failed: {e}")
818
+ import traceback
819
+ traceback.print_exc()
820
+ return None, f"❌ Error: {str(e)}", ""
821
+
822
+ def gradio_api_endpoint_custom(
823
+ image_base64: str,
824
+ tts_text: str,
825
+ voice: Optional[str] = None,
826
+ speed: float = 0.8
827
+ ) -> Dict[str, Any]:
828
+ """نقطة النهاية للـ API مع نص مخصص (مع معاملات اختيارية)"""
829
+ if not IS_SERVICE_READY:
830
+ raise RuntimeError(f"Service not ready: {STATUS_MESSAGE}")
831
+
832
+ log.info(f"API request received for TTS text: {tts_text[:50]}... with speed: {speed}")
833
+
834
+ return agent.process_request_custom(
835
+ image_base64=image_base64,
836
+ tts_text=tts_text,
837
+ voice=voice,
838
+ speed=speed
839
+ )
840
+
841
+ def gradio_api_simple(
842
+ image_base64: str,
843
+ tts_text: str
844
+ ) -> Dict[str, Any]:
845
+ """
846
+ نقطة نهاية API مبسطة للاتصال من وكيل النشر.
847
+ تستقبل فقط الصورة والنص الصوتي مع استخدام القيم الافتراضية للصوت والسرعة.
848
+ """
849
+ if not IS_SERVICE_READY:
850
+ raise RuntimeError(f"Service not ready: {STATUS_MESSAGE}")
851
+
852
+ log.info(f"🎬 Simple API request received for TTS text: {tts_text[:50]}...")
853
+
854
+ return agent.process_request_custom(
855
+ image_base64=image_base64,
856
+ tts_text=tts_text,
857
+ voice=None, # استخدام التدوير التلقائي
858
+ speed=0.8 # السرعة الافتراضية
859
+ )
860
+
861
+ def format_history_for_gallery() -> List[Tuple[str, str]]:
862
+ """تنسيق السجل لعرضه في Gallery"""
863
+ formatted = []
864
+ for entry in agent.get_history():
865
+ if entry.get("video_path") and os.path.exists(entry["video_path"]):
866
+ caption = (
867
+ f'{entry["tts_text"][:80]}...\n'
868
+ f'Voice: {entry.get("voice", "N/A")} | Duration: {entry.get("duration", 0):.1f}s'
869
+ )
870
+ formatted.append((entry["video_path"], caption))
871
+ return formatted
872
+
873
+ def gradio_refresh_history():
874
+ return format_history_for_gallery()
875
+
876
+ def gradio_reset_voice_rotation():
877
+ agent.voice_manager.reset()
878
+ voice_info = agent.get_voice_rotation_info()
879
+ return f"✅ Voice rotation reset! Next voice: {voice_info['current_voice']}"
880
+
881
+ def gradio_get_voice_info():
882
+ voice_info = agent.get_voice_rotation_info()
883
+ return (
884
+ f"**Current Voice:** {voice_info['current_voice']}\n"
885
+ f"**Index:** {voice_info['current_index']}/{voice_info['total_voices']}\n"
886
+ f"**Total Voices:** {len(voice_info['all_voices'])}"
887
+ )
888
+
889
+ # ---------------- Gradio Interface ----------------
890
+
891
+ with gr.Blocks(title="Video Agent - Fixed Text Overlay") as demo:
892
+ gr.Markdown("# 🎬 Video Agent - FIXED: Text Stays on Screen")
893
+ gr.Markdown(f"**Status:** {STATUS_MESSAGE}")
894
+
895
+ if IS_SERVICE_READY:
896
+ gr.Markdown(f"**Features:** {len(KOKORO_VOICES)} voices | Automatic rotation | **Fixed text overlay** | Moving image background")
897
+ gr.Markdown("✅ **FIXED:** *Text stays in fixed position on screen while image moves beneath it*")
898
+
899
+ gr.Markdown("---")
900
+
901
+ with gr.Tab("Generate Video"):
902
+ with gr.Row():
903
+ with gr.Column(scale=1):
904
+ image_input = gr.Image(label="Upload Image (without text)", type="pil")
905
+ tts_text_input = gr.Textbox(
906
+ label="TTS Text (will appear as fixed overlay)",
907
+ lines=4,
908
+ placeholder="Enter the text to display on screen..."
909
+ )
910
+
911
+ voice_dropdown = gr.Dropdown(
912
+ choices=["Auto"] + KOKORO_VOICES,
913
+ value="Auto",
914
+ label="Voice (Auto = rotation)"
915
+ )
916
+
917
+ speed_slider = gr.Slider(
918
+ minimum=0.5,
919
+ maximum=2.0,
920
+ value=0.8,
921
+ step=0.1,
922
+ label="Speech Speed"
923
+ )
924
+
925
+ generate_btn = gr.Button("🎬 Generate Video", variant="primary")
926
+ status_output = gr.Textbox(label="Status", lines=4)
927
+ voice_rotation_output = gr.Textbox(label="Voice Info", lines=3)
928
+
929
+ with gr.Column(scale=1):
930
+ video_output = gr.Video(label="Generated Video (Fixed Text + Moving Image)")
931
+
932
+ generate_btn.click(
933
+ fn=gradio_generate_video_custom,
934
+ inputs=[image_input, tts_text_input, voice_dropdown, speed_slider],
935
+ outputs=[video_output, status_output, voice_rotation_output]
936
+ )
937
+
938
+ with gr.Tab("History"):
939
+ refresh_btn = gr.Button("🔄 Refresh")
940
+ history_gallery = gr.Gallery(label="Recent Videos", columns=2)
941
+ refresh_btn.click(fn=gradio_refresh_history, outputs=[history_gallery])
942
+
943
+ with gr.Tab("Voice Management"):
944
+ with gr.Row():
945
+ voice_info_btn = gr.Button("📊 Get Info")
946
+ reset_btn = gr.Button("🔄 Reset Rotation")
947
+
948
+ voice_mgmt_output = gr.Textbox(label="Voice Info", lines=5)
949
+
950
+ voice_info_btn.click(fn=gradio_get_voice_info, outputs=[voice_mgmt_output])
951
+ reset_btn.click(fn=gradio_reset_voice_rotation, outputs=[voice_mgmt_output])
952
+
953
+ gr.Markdown(f"### {len(KOKORO_VOICES)} Voices Available")
954
+ gr.Markdown("**British Female (4):** " + ", ".join([v for v in KOKORO_VOICES if v.startswith("bf_")]))
955
+ gr.Markdown("**American Female (11):** " + ", ".join([v for v in KOKORO_VOICES if v.startswith("af_")]))
956
+ gr.Markdown("**British Male (4):** " + ", ".join([v for v in KOKORO_VOICES if v.startswith("bm_")]))
957
+ gr.Markdown("**American Male (8):** " + ", ".join([v for v in KOKORO_VOICES if v.startswith("am_")]))
958
+
959
+ with gr.Tab("API Endpoints") as api_tab:
960
+ gr.Markdown("### 🔌 API Endpoints for External Integration")
961
+ gr.Markdown("Use these endpoints to integrate with other agents (e.g., Publisher Agent)")
962
+
963
+ gr.Markdown("---")
964
+ gr.Markdown("#### 📡 Endpoint 1: Simple API (Recommended for Publisher Agent)")
965
+ gr.Markdown("- **API Name**: `generate_video_custom`")
966
+ gr.Markdown("- **Parameters**: `image_base64` (str), `tts_text` (str)")
967
+ gr.Markdown("- **Returns**: JSON with video_base64, video_path, status")
968
+ gr.Markdown("- **Auto-settings**: Voice rotation enabled, Speed = 0.8")
969
+
970
+ gr.Markdown("---")
971
+ gr.Markdown("#### 📡 Endpoint 2: Full API (Advanced)")
972
+ gr.Markdown("- **API Name**: `generate_video_full`")
973
+ gr.Markdown("- **Parameters**: `image_base64` (str), `tts_text` (str), `voice` (str, optional), `speed` (float, optional)")
974
+ gr.Markdown("- **Returns**: JSON with video_base64, video_path, status")
975
+
976
+ # ✅ CRITICAL FIX: Define API endpoints as separate gr.Interface outside Blocks
977
+ # This is the CORRECT way to expose API endpoints in Gradio
978
+
979
+ # API Endpoint 1: Simple API (for Publisher Agent)
980
+ simple_api = gr.Interface(
981
+ fn=gradio_api_simple,
982
+ inputs=[
983
+ gr.Textbox(label="image_base64", placeholder="Base64 encoded image without text overlay"),
984
+ gr.Textbox(label="tts_text", placeholder="Text for TTS audio synthesis")
985
+ ],
986
+ outputs=gr.JSON(label="API Response"),
987
+ title="Simple Video API",
988
+ description="Generate video with automatic voice rotation and speed=0.8",
989
+ api_name="generate_video_custom"
990
+ )
991
+
992
+ # API Endpoint 2: Full API (with all options)
993
+ def gradio_api_full_wrapper(image_base64: str, tts_text: str, voice: str = "Auto", speed: float = 0.8):
994
+ """Wrapper to handle optional parameters"""
995
+ voice_to_use = None if voice == "Auto" or voice == "" else voice
996
+ return gradio_api_endpoint_custom(image_base64, tts_text, voice_to_use, speed)
997
+
998
+ full_api = gr.Interface(
999
+ fn=gradio_api_full_wrapper,
1000
+ inputs=[
1001
+ gr.Textbox(label="image_base64", placeholder="Base64 encoded image"),
1002
+ gr.Textbox(label="tts_text", placeholder="Text for TTS"),
1003
+ gr.Dropdown(choices=["Auto"] + KOKORO_VOICES, value="Auto", label="voice"),
1004
+ gr.Slider(minimum=0.5, maximum=2.0, value=0.8, step=0.1, label="speed")
1005
+ ],
1006
+ outputs=gr.JSON(label="API Response"),
1007
+ title="Full Video API",
1008
+ description="Generate video with custom voice and speed settings",
1009
+ api_name="generate_video_full"
1010
+ )
1011
+
1012
+ # Mount both APIs into the main demo using TabbedInterface
1013
+ combined_demo = gr.TabbedInterface(
1014
+ [demo, simple_api, full_api],
1015
+ ["Main Interface", "API: Simple", "API: Full"],
1016
+ title="🎬 Video Agent with API Endpoints"
1017
+ )
1018
+
1019
+ if __name__ == "__main__":
1020
+ PORT = int(os.getenv("PORT", "7860"))
1021
+ log.info(f"Starting Video Agent with FIXED API endpoints (gr.Interface)...")
1022
+ combined_demo.launch(server_name="0.0.0.0", server_port=PORT)