Spaces:
Sleeping
Sleeping
| """ | |
| ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| 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.""" | |
| 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.""" | |
| 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] | |
| def transition_fade(f1: np.ndarray, f2: np.ndarray, alpha: float) -> np.ndarray: | |
| return cv2.addWeighted(f1, 1 - alpha, f2, alpha, 0) | |
| 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 | |
| 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.""" | |
| 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() | |
| 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.""" | |
| 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 "" | |
| 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 |