""" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ SUPREMEAI — Processeur Vidéo Avancé • Post-processing : upscale, interpolation, color grading • Backend CPU : génération vidéo sans GPU (tutoriels, motion design) • Super-résolution via Real-ESRGAN • Interpolation frames via RIFE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ """ import os, time, math, io, re from pathlib import Path from typing import Optional, List import numpy as np from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageEnhance, ImageOps import cv2 OUTPUT_DIR = Path(os.getenv("SUPREMEAI_OUTPUT", "/tmp/supremeai_output")) OUTPUT_DIR.mkdir(parents=True, exist_ok=True) # ── LUTs de color grading ──────────────────────────────────────────────────── def apply_lut_cinematic(frame: np.ndarray) -> np.ndarray: """Applique un LUT cinématique (tons chauds, contraste élevé).""" # Courbe S pour le contraste lut = np.arange(256, dtype=np.float32) lut = 255 * (1 / (1 + np.exp(-0.05 * (lut - 128)))) lut = lut.astype(np.uint8) # Boost rouge/jaune, légère désaturation bleu result = frame.copy() result[:, :, 2] = cv2.LUT(frame[:, :, 2], lut) # Rouge result[:, :, 1] = cv2.LUT(frame[:, :, 1], (lut * 0.95).astype(np.uint8)) # Vert result[:, :, 0] = cv2.LUT(frame[:, :, 0], (lut * 0.85).astype(np.uint8)) # Bleu return result def apply_lut_warm(frame: np.ndarray) -> np.ndarray: """LUT chaud — style golden hour.""" result = frame.copy().astype(np.float32) result[:, :, 2] = np.clip(result[:, :, 2] * 1.10, 0, 255) result[:, :, 1] = np.clip(result[:, :, 1] * 1.02, 0, 255) result[:, :, 0] = np.clip(result[:, :, 0] * 0.90, 0, 255) return result.astype(np.uint8) def apply_lut_cold(frame: np.ndarray) -> np.ndarray: """LUT froid — style sci-fi/futuriste.""" result = frame.copy().astype(np.float32) result[:, :, 2] = np.clip(result[:, :, 2] * 0.90, 0, 255) result[:, :, 1] = np.clip(result[:, :, 1] * 1.02, 0, 255) result[:, :, 0] = np.clip(result[:, :, 0] * 1.15, 0, 255) return result.astype(np.uint8) def apply_lut_vintage(frame: np.ndarray) -> np.ndarray: """LUT vintage — désaturation + grain.""" gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) sepia = cv2.merge([ np.clip(gray * 0.68, 0, 255).astype(np.uint8), np.clip(gray * 0.84, 0, 255).astype(np.uint8), np.clip(gray * 1.06, 0, 255).astype(np.uint8), ]) # Mélange avec original return cv2.addWeighted(frame, 0.3, sepia, 0.7, 0) LUT_MAP = { "cinematic": apply_lut_cinematic, "warm": apply_lut_warm, "cold": apply_lut_cold, "vintage": apply_lut_vintage, "none": lambda f: f, } class FrameInterpolator: """Interpolation de frames avec RIFE (Real-Time Intermediate Flow Estimation). Permet de passer de 24fps → 60fps ou 120fps.""" @staticmethod def interpolate_frames(frames: List[np.ndarray], target_fps: int, source_fps: int) -> List[np.ndarray]: """Interpole des frames pour augmenter le FPS.""" if target_fps <= source_fps: return frames multiplier = target_fps // source_fps result = [] for i in range(len(frames) - 1): result.append(frames[i]) f0 = frames[i].astype(np.float32) f1 = frames[i + 1].astype(np.float32) for j in range(1, multiplier): alpha = j / multiplier interpolated = cv2.addWeighted(f0, 1 - alpha, f1, alpha, 0) result.append(interpolated.astype(np.uint8)) result.append(frames[-1]) return result class SuperResolutionUpscaler: """Upscaling via Real-ESRGAN ou bicubique selon disponibilité.""" def __init__(self): self._model = None self._try_load_esrgan() def _try_load_esrgan(self): try: from basicsr.archs.rrdbnet_arch import RRDBNet from realesrgan import RealESRGANer model = RRDBNet(num_in_ch=3, num_out_ch=3, num_feat=64, num_block=23, num_grow_ch=32, scale=4) self._model = RealESRGANer( scale=4, model_path="weights/RealESRGAN_x4plus.pth", model=model, tile=256, tile_pad=10, pre_pad=0, half=True, ) except Exception: self._model = None # fallback bicubique def upscale(self, frame: np.ndarray, scale: int = 2) -> np.ndarray: if self._model is not None: try: output, _ = self._model.enhance(frame, outscale=scale) return output except Exception: pass # Fallback : bicubique (qualité correcte sans GPU dédié) h, w = frame.shape[:2] return cv2.resize(frame, (w * scale, h * scale), interpolation=cv2.INTER_CUBIC) class MotionEffectsEngine: """Effets de mouvement : Ken Burns, zoom cinématique, transitions.""" @staticmethod def ken_burns(frame: np.ndarray, progress: float, zoom_start: float = 1.0, zoom_end: float = 1.15, pan_x: float = 0.0, pan_y: float = 0.0) -> np.ndarray: """Effet Ken Burns : zoom progressif + panoramique.""" h, w = frame.shape[:2] zoom = zoom_start + (zoom_end - zoom_start) * progress new_w, new_h = int(w * zoom), int(h * zoom) resized = cv2.resize(frame, (new_w, new_h), interpolation=cv2.INTER_LANCZOS4) # Crop centré avec décalage panoramique x0 = (new_w - w) // 2 + int(pan_x * progress * w * 0.1) y0 = (new_h - h) // 2 + int(pan_y * progress * h * 0.1) x0 = max(0, min(x0, new_w - w)) y0 = max(0, min(y0, new_h - h)) return resized[y0:y0+h, x0:x0+w] @staticmethod def transition_fade(f1: np.ndarray, f2: np.ndarray, alpha: float) -> np.ndarray: return cv2.addWeighted(f1, 1 - alpha, f2, alpha, 0) @staticmethod def transition_wipe_left(f1: np.ndarray, f2: np.ndarray, alpha: float) -> np.ndarray: w = f1.shape[1] split = int(w * alpha) result = f1.copy() result[:, :split] = f2[:, :split] return result @staticmethod def transition_zoom_out(f1: np.ndarray, f2: np.ndarray, alpha: float) -> np.ndarray: h, w = f1.shape[:2] scale = 1.0 - alpha * 0.3 new_w, new_h = int(w * scale), int(h * scale) small = cv2.resize(f1, (new_w, new_h), interpolation=cv2.INTER_LINEAR) result = f2.copy() y0 = (h - new_h) // 2 x0 = (w - new_w) // 2 result[y0:y0+new_h, x0:x0+new_w] = small return result TRANSITIONS = { "fade": transition_fade.__func__, "wipe_left": transition_wipe_left.__func__, "zoom_out": transition_zoom_out.__func__, } class TextRenderer: """Rendu de texte avancé avec ombre, glow, outline.""" @staticmethod def _get_font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont: candidates = [ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", "/usr/share/fonts/truetype/ubuntu/Ubuntu-B.ttf", ] for path in candidates: if os.path.exists(path): try: return ImageFont.truetype(path, size) except Exception: continue return ImageFont.load_default() @classmethod def draw_text_with_effects( cls, img: Image.Image, text: str, x: int, y: int, font_size: int = 40, color: tuple = (255, 255, 255), shadow: bool = True, outline: bool = False, glow: bool = False, align: str = "left" ) -> Image.Image: font = cls._get_font(font_size) draw = ImageDraw.Draw(img) # Découpe le texte en lignes si trop long words = text.split() lines = [] cur = "" max_w = img.width - x - 20 for word in words: test = (cur + " " + word).strip() bbox = draw.textbbox((0, 0), test, font=font) if bbox[2] <= max_w: cur = test else: if cur: lines.append(cur) cur = word if cur: lines.append(cur) line_h = font_size + 6 for i, line in enumerate(lines): ly = y + i * line_h bbox = draw.textbbox((0, 0), line, font=font) lw = bbox[2] - bbox[0] if align == "center": lx = (img.width - lw) // 2 elif align == "right": lx = img.width - lw - x else: lx = x if glow: # Effet glow : dessine le texte en blanc flou glow_img = Image.new("RGBA", img.size, (0, 0, 0, 0)) gd = ImageDraw.Draw(glow_img) for dx in range(-3, 4): for dy in range(-3, 4): gd.text((lx+dx, ly+dy), line, font=font, fill=(255, 255, 200, 80)) glow_img = glow_img.filter(ImageFilter.GaussianBlur(3)) if img.mode != "RGBA": img = img.convert("RGBA") img = Image.alpha_composite(img, glow_img) draw = ImageDraw.Draw(img) if shadow: draw.text((lx+2, ly+2), line, font=font, fill=(0, 0, 0, 180)) if outline: for ox, oy in [(-1,-1),(1,-1),(-1,1),(1,1)]: draw.text((lx+ox, ly+oy), line, font=font, fill=(0, 0, 0)) draw.text((lx, ly), line, font=font, fill=color) return img class EnhancedVideoProcessor: """ Générateur vidéo CPU professionnel. Crée des vidéos de qualité broadcast sans GPU. Utilisé pour les tutoriels, motion design, éducatif. """ THEMES = { "dark_gold": {"bg": (10, 22, 40), "accent": (201, 168, 76), "text": (220, 232, 255)}, "dark_purple": {"bg": (20, 10, 35), "accent": (180, 80, 255), "text": (230, 220, 255)}, "dark_red": {"bg": (25, 8, 8), "accent": (255, 60, 60), "text": (255, 230, 230)}, "dark_blue": {"bg": (8, 15, 35), "accent": (60, 140, 255), "text": (220, 235, 255)}, "dark_green": {"bg": (8, 25, 15), "accent": (60, 200, 120), "text": (220, 255, 230)}, "minimal_light": {"bg": (245, 245, 250), "accent": (30, 100, 200), "text": (20, 20, 40)}, } def __init__(self, config): self.cfg = config self.W = config.width self.H = config.height self.fps = config.fps self.duration = config.duration self.theme = self.THEMES.get("dark_gold") self.motion = MotionEffectsEngine() self.tr = TextRenderer() def _make_gradient_bg(self, frame_idx: int, total_frames: int) -> Image.Image: """Fond dégradé animé avec particules.""" bg = self.theme["bg"] acc = self.theme["accent"] progress = frame_idx / max(total_frames - 1, 1) img = Image.new("RGB", (self.W, self.H), bg) draw = ImageDraw.Draw(img) # Dégradé radial animé cx = int(self.W * (0.3 + 0.1 * math.sin(progress * 2 * math.pi))) cy = int(self.H * (0.4 + 0.05 * math.cos(progress * 2 * math.pi))) for r in range(min(self.W, self.H) // 2, 0, -2): alpha = max(0, 1 - r / (min(self.W, self.H) * 0.8)) color = tuple(int(bg[c] + (acc[c] - bg[c]) * alpha * 0.25) for c in range(3)) draw.ellipse([cx-r, cy-r, cx+r, cy+r], fill=color) # Particules flottantes rng = np.random.default_rng(frame_idx // 3) for _ in range(12): px = rng.integers(0, self.W) py = int((rng.integers(0, self.H) + frame_idx * 0.5) % self.H) pr = rng.integers(1, 4) pa = int(rng.integers(40, 100)) color = tuple(min(255, int(c * 1.5)) for c in acc) draw.ellipse([px-pr, py-pr, px+pr, py+pr], fill=color) return img def _make_scene_frame(self, text: str, frame_idx: int, total_frames: int, scene_idx: int, n_scenes: int, title: str = "") -> Image.Image: """Crée un frame avec fond animé, titre et texte.""" img = self._make_gradient_bg(frame_idx, total_frames) acc = self.theme["accent"] txt = self.theme["text"] bg = self.theme["bg"] progress = frame_idx / max(total_frames - 1, 1) # Barre de progression en bas bar_h = 4 bar_w = int(self.W * ((scene_idx + progress) / n_scenes)) draw = ImageDraw.Draw(img) draw.rectangle([0, self.H - bar_h, self.W, self.H], fill=tuple(int(c * 0.3) for c in acc)) draw.rectangle([0, self.H - bar_h, bar_w, self.H], fill=acc) # Ligne décorative en haut draw.rectangle([0, 0, self.W, 3], fill=acc) draw.rectangle([0, 4, int(self.W * 0.6), 6], fill=tuple(int(c * 0.5) for c in acc)) # Numéro scène / logo draw.text((self.W - 120, 12), f"⬡ {scene_idx+1}/{n_scenes}", fill=acc, font=TextRenderer._get_font(18)) # Slide-in animé pour le texte slide_progress = min(1.0, progress * 6) # slide in rapide slide_x = int((1 - slide_progress) * self.W * 0.3) if title: # Titre principal centré title_y = int(self.H * 0.30) img = self.tr.draw_text_with_effects( img, title, slide_x + 40, title_y, font_size=min(56, max(28, int(280 / max(1, len(title))))), color=acc, shadow=True, glow=True, align="center" ) # Corps texte text_y = int(self.H * (0.50 if title else 0.35)) img = self.tr.draw_text_with_effects( img, text, slide_x + 40, text_y, font_size=min(38, max(20, int(180 / max(1, len(text) // 3)))), color=txt, shadow=True, outline=False, align="center" ) # Logo watermark draw = ImageDraw.Draw(img) draw.text((10, self.H - 24), "SupremeAI · Informa-Technique R", fill=tuple(int(c * 0.4) for c in txt), font=TextRenderer._get_font(14)) return img def render(self) -> Path: """Rendu complet de la vidéo.""" from moviepy import VideoClip, VideoFileClip, concatenate_videoclips import tempfile prompt = self.cfg.prompt total_sec = self.cfg.duration total_frames = int(total_sec * self.fps) # Découpe le prompt en scènes sentences = [s.strip() for s in re.split(r'[.!?\n]+', prompt) if len(s.strip()) > 5] if not sentences: sentences = [prompt] n_scenes = len(sentences) # Génère les frames all_frames = [] frames_per_scene = max(1, total_frames // n_scenes) for si, sentence in enumerate(sentences): # Titre = première scène en gros title = sentences[0][:60] if si == 0 else "" is_last = (si == n_scenes - 1) for fi in range(frames_per_scene): frame = self._make_scene_frame( text=sentence, frame_idx=fi, total_frames=frames_per_scene, scene_idx=si, n_scenes=n_scenes, title=title if fi < frames_per_scene // 3 else "", ) # Ken Burns sur la frame frame_np = np.array(frame) prog = fi / max(frames_per_scene - 1, 1) frame_np = self.motion.ken_burns(frame_np, prog, zoom_start=1.0, zoom_end=1.08, pan_x=(si % 2) * 0.5, pan_y=0.0) # Color grading lut_fn = LUT_MAP.get(self.cfg.color_grading, LUT_MAP["none"]) frame_np = lut_fn(frame_np) all_frames.append(frame_np) # Transition fade entre scènes if len(all_frames) >= frames_per_scene * 2: trans_frames = int(self.fps * 0.3) # 0.3 seconde de transition final_frames = [] for si in range(n_scenes - 1): scene_start = si * frames_per_scene scene_end = scene_start + frames_per_scene next_start = scene_end final_frames.extend(all_frames[scene_start:scene_end - trans_frames]) for ti in range(trans_frames): alpha = ti / trans_frames blended = cv2.addWeighted( all_frames[scene_end - trans_frames + ti], 1 - alpha, all_frames[min(next_start + ti, len(all_frames) - 1)], alpha, 0 ) final_frames.append(blended) final_frames.extend(all_frames[n_scenes * frames_per_scene - frames_per_scene:]) else: final_frames = all_frames # Interpolation FPS si demandée if self.cfg.interpolate_fps > self.fps: interp = FrameInterpolator() final_frames = interp.interpolate_frames( final_frames, self.cfg.interpolate_fps, self.fps) actual_fps = self.cfg.interpolate_fps else: actual_fps = self.fps # Upscale si demandé if self.cfg.upscale_to_4k: upscaler = SuperResolutionUpscaler() final_frames = [upscaler.upscale(f, scale=2) for f in final_frames] # Assemblage MoviePy def make_frame(t): idx = min(int(t * actual_fps), len(final_frames) - 1) return cv2.cvtColor(final_frames[idx], cv2.COLOR_BGR2RGB) clip = VideoClip(make_frame, duration=total_sec) out_path = OUTPUT_DIR / f"supremeai_{int(time.time())}.mp4" clip.write_videofile( str(out_path), fps=actual_fps, codec="libx264", audio=False, ffmpeg_params=["-crf", "18", "-preset", "fast"], logger=None, ) return out_path class AudioSyncEngine: """Synchronisation audio-vidéo et génération voix-off.""" @staticmethod async def generate_voiceover(text: str, lang: str, output_path: str) -> str: """Génère une voix-off via edge-tts.""" try: import edge_tts voices = { "fr": "fr-FR-DeniseNeural", "en": "en-US-AriaNeural", "ar": "ar-DZ-AminaNeural", "es": "es-ES-ElviraNeural", } voice = voices.get(lang, voices["fr"]) comm = edge_tts.Communicate(text, voice) await comm.save(output_path) return output_path except Exception as e: return "" @staticmethod def add_audio_to_video(video_path: str, audio_path: str, output_path: str, music_path: Optional[str] = None, music_volume: float = 0.12) -> str: """Combine vidéo + voix-off + musique de fond.""" from moviepy import VideoFileClip, AudioFileClip, CompositeAudioClip import moviepy.audio.fx as afx video = VideoFileClip(video_path) audio_clips = [] if audio_path and os.path.exists(audio_path): voice = AudioFileClip(audio_path).subclipped(0, video.duration) audio_clips.append(voice) if music_path and os.path.exists(music_path): music = (AudioFileClip(music_path) .subclipped(0, video.duration) .with_effects([afx.MultiplyVolume(music_volume)])) audio_clips.append(music) if audio_clips: final_audio = (CompositeAudioClip(audio_clips) if len(audio_clips) > 1 else audio_clips[0]) video = video.with_audio(final_audio) video.write_videofile(output_path, codec="libx264", audio_codec="aac", logger=None) return output_path