""" Visual Generation & Stock Media Module. Handles: 1. NvidiaLLM ComfyUI MiniMax H3 Text-to-Video generation 2. NvidiaLLM ComfyUI z-image-turbo / SDXL AI Image generation 3. Internet Stock Video & Stock Image downloaders (Wikimedia Commons, Pexels, Pixabay) 4. Dynamic Ken Burns Motion Effects (pan/zoom) in FFmpeg for still images 5. Intelligent visual curation and multi-tier fallback pipeline """ import os import re import time import json import zlib import random import logging import urllib.request import urllib.parse import subprocess import requests from typing import Dict, Any, List, Optional from .remote import RemotePC logger = logging.getLogger("YTFactory.Visual") # ─── ComfyUI Model Constants ──────────────────────────────────────────────── H3_UNET = "minimax_h3_fl2va_pruned_int8_convrot.safetensors" H3_CLIP = "qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors" H3_VAE = "minimax_h3_video_vae_fp16.safetensors" H3_FPS = 24 H3_MIN_LEN, H3_MAX_LEN = 124, 362 ZIMAGE_UNET = "z_image_turbo_bf16.safetensors" ZIMAGE_CLIP = "qwen_3_4b.safetensors" ZIMAGE_VAE = "ae.safetensors" SDXL_CKPT = "sd_xl_base_1.0.safetensors" def h3_snap_length(seconds: float) -> int: """Snap a duration in seconds to the MiniMax H3 17k+5 frame grid.""" n = max(H3_MIN_LEN, min(H3_MAX_LEN, int(round(seconds * H3_FPS)))) k = max(7, round((n - 5) / 17)) return 17 * k + 5 # ─── Stock Media Fetcher ──────────────────────────────────────────────────── class StockMediaFetcher: def __init__(self, pexels_key: Optional[str] = None, pixabay_key: Optional[str] = None): self.pexels_key = pexels_key self.pixabay_key = pixabay_key self.user_agent = "YouTubeFactory/2.0 (faceless-video-pipeline)" def _query_wikimedia(self, query: str, is_video: bool = False) -> Optional[str]: """Search Wikimedia Commons for high-resolution images or videos without API keys.""" try: clean_q = re.sub(r'[^A-Za-z0-9 ]', '', query).strip() encoded_q = urllib.parse.quote(clean_q) api_url = ( f"https://commons.wikimedia.org/w/api.php?action=query&generator=search" f"&gsrsearch={encoded_q}&gsrnamespace=6&gsrlimit=5&prop=imageinfo" f"&iiprop=url|mime|size&format=json" ) req = urllib.request.Request(api_url, headers={"User-Agent": self.user_agent}) with urllib.request.urlopen(req, timeout=10) as resp: data = json.loads(resp.read().decode("utf-8")) pages = data.get("query", {}).get("pages", {}) for _, page in pages.items(): infos = page.get("imageinfo", []) if not infos: continue url = infos[0].get("url", "") mime = infos[0].get("mime", "").lower() if is_video and ("video" in mime or url.endswith((".webm", ".mp4", ".ogv"))): return url if not is_video and ("image" in mime or url.endswith((".jpg", ".jpeg", ".png", ".webp"))): return url except Exception as e: logger.debug(f"Wikimedia search failed for '{query}': {e}") return None def _query_pexels_video(self, query: str) -> Optional[str]: """Search Pexels for free HD stock videos if API key is provided.""" if not self.pexels_key: return None try: clean_q = urllib.parse.quote(query) url = f"https://api.pexels.com/videos/search?query={clean_q}&per_page=5&orientation=landscape" headers = {"Authorization": self.pexels_key, "User-Agent": self.user_agent} req = urllib.request.Request(url, headers=headers) with urllib.request.urlopen(req, timeout=10) as resp: data = json.loads(resp.read().decode("utf-8")) videos = data.get("videos", []) for v in videos: files = v.get("video_files", []) # Prefer the smallest HD file (>=1280 wide) to keep downloads fast hd = [vf for vf in files if vf.get("width", 0) >= 1280 and vf.get("link")] if hd: return sorted(hd, key=lambda x: x["width"])[0]["link"] if files and files[0].get("link"): return files[0]["link"] except Exception as e: logger.debug(f"Pexels video query failed: {e}") return None def _query_pexels_photo(self, query: str) -> Optional[str]: """Search Pexels for free HD stock photos if API key is provided.""" if not self.pexels_key: return None try: clean_q = urllib.parse.quote(query) url = f"https://api.pexels.com/v1/search?query={clean_q}&per_page=5&orientation=landscape" headers = {"Authorization": self.pexels_key, "User-Agent": self.user_agent} req = urllib.request.Request(url, headers=headers) with urllib.request.urlopen(req, timeout=10) as resp: data = json.loads(resp.read().decode("utf-8")) photos = data.get("photos", []) if photos: return photos[0].get("src", {}).get("large2x") or photos[0].get("src", {}).get("large") except Exception as e: logger.debug(f"Pexels photo query failed: {e}") return None def _query_pixabay_video(self, query: str) -> Optional[str]: """Search Pixabay for free HD stock videos if API key is provided.""" if not self.pixabay_key: return None try: clean_q = urllib.parse.quote(query) url = f"https://pixabay.com/api/videos/?key={self.pixabay_key}&q={clean_q}&per_page=5&safesearch=true" req = urllib.request.Request(url, headers={"User-Agent": self.user_agent}) with urllib.request.urlopen(req, timeout=10) as resp: data = json.loads(resp.read().decode("utf-8")) for hit in data.get("hits", []): vids = hit.get("videos", {}) for quality in ("large", "medium", "small"): v = vids.get(quality) if v and v.get("url") and v.get("width", 0) >= 640: return v["url"] except Exception as e: logger.debug(f"Pixabay video query failed: {e}") return None def _query_pixabay_photo(self, query: str) -> Optional[str]: """Search Pixabay for free HD stock photos if API key is provided.""" if not self.pixabay_key: return None try: clean_q = urllib.parse.quote(query) url = (f"https://pixabay.com/api/?key={self.pixabay_key}&q={clean_q}" f"&image_type=photo&orientation=horizontal&per_page=5&safesearch=true") req = urllib.request.Request(url, headers={"User-Agent": self.user_agent}) with urllib.request.urlopen(req, timeout=10) as resp: data = json.loads(resp.read().decode("utf-8")) hits = data.get("hits", []) if hits: return hits[0].get("largeImageURL") or hits[0].get("webformatURL") except Exception as e: logger.debug(f"Pixabay photo query failed: {e}") return None def download_media(self, url: str, out_path: str) -> bool: """Download remote media with retry and validation.""" try: req = urllib.request.Request(url, headers={"User-Agent": self.user_agent}) with urllib.request.urlopen(req, timeout=30) as resp, open(out_path, "wb") as f: f.write(resp.read()) if os.path.exists(out_path) and os.path.getsize(out_path) > 5000: return True except Exception as e: logger.debug(f"Download failed from {url}: {e}") if os.path.exists(out_path): try: os.remove(out_path) except OSError: pass return False def search_and_download(self, query: str, out_path: str, prefer_video: bool = False) -> Optional[str]: """Search across stock engines and download asset.""" logger.info(f"[STOCK] Searching stock media for: '{query}' (video={prefer_video})...") if prefer_video: for label, fn in (("Pexels", self._query_pexels_video), ("Pixabay", self._query_pixabay_video), ("Wikimedia", lambda q: self._query_wikimedia(q, is_video=True))): v_url = fn(query) if v_url and self.download_media(v_url, out_path): logger.info(f"[STOCK] Downloaded {label} video for '{query}'") return out_path return None for label, fn in (("Pexels", self._query_pexels_photo), ("Pixabay", self._query_pixabay_photo), ("Wikimedia", lambda q: self._query_wikimedia(q, is_video=False))): p_url = fn(query) if p_url and self.download_media(p_url, out_path): logger.info(f"[STOCK] Downloaded {label} photo for '{query}'") return out_path logger.debug(f"[STOCK] No stock media found for: '{query}'") return None # ─── Ken Burns Motion Engine ──────────────────────────────────────────────── def apply_ken_burns_effect(img_path: str, out_mp4: str, duration_sec: float, motion_type: int = 0) -> str: """ Animate a still image into a cinematic 16:9 25fps video clip using FFmpeg pan & zoom motion. Provides 6 distinct cinematic camera movements: 0: Smooth Center Zoom-In 1: Smooth Center Zoom-Out 2: Diagonal Pan Left-Up with subtle zoom 3: Diagonal Pan Right-Down with subtle zoom 4: Slow Pan Right across center 5: Slow Pan Left across center """ total_frames = max(50, int(round(duration_sec * 25))) m_idx = motion_type % 6 if m_idx == 0: # Smooth Zoom-In (center) z_expr = "min(zoom+0.0012,1.25)" x_expr = "iw/2-(iw/zoom/2)" y_expr = "ih/2-(ih/zoom/2)" elif m_idx == 1: # Smooth Zoom-Out (center) z_expr = "if(eq(on,1),1.25,max(1.0,zoom-0.0012))" x_expr = "iw/2-(iw/zoom/2)" y_expr = "ih/2-(ih/zoom/2)" elif m_idx == 2: # Diagonal Pan Left-Up with subtle zoom z_expr = "1.20" x_expr = f"if(eq(on,1),iw-iw/zoom,max(0,x-(iw-iw/zoom)/{total_frames}))" y_expr = f"if(eq(on,1),ih-ih/zoom,max(0,y-(ih-ih/zoom)/{total_frames}))" elif m_idx == 3: # Diagonal Pan Right-Down with subtle zoom z_expr = "1.20" x_expr = f"if(eq(on,1),0,min(iw-iw/zoom,x+(iw-iw/zoom)/{total_frames}))" y_expr = f"if(eq(on,1),0,min(ih-ih/zoom,y+(ih-ih/zoom)/{total_frames}))" elif m_idx == 4: # Slow Pan Right across center z_expr = "1.18" x_expr = f"if(eq(on,1),0,min(iw-iw/zoom,x+(iw-iw/zoom)/{total_frames}))" y_expr = "ih/2-(ih/zoom/2)" else: # Slow Pan Left across center z_expr = "1.18" x_expr = f"if(eq(on,1),iw-iw/zoom,max(0,x-(iw-iw/zoom)/{total_frames}))" y_expr = "ih/2-(ih/zoom/2)" vf = ( f"scale=1920:1080:force_original_aspect_ratio=increase,crop=1920:1080," f"zoompan=z='{z_expr}':x='{x_expr}':y='{y_expr}':d={total_frames}:s=1280x720:fps=25," f"format=yuv420p" ) cmd = [ "ffmpeg", "-y", "-loop", "1", "-i", img_path, "-vf", vf, "-t", f"{duration_sec:.2f}", "-r", "25", "-c:v", "libx264", "-preset", "fast", "-crf", "22", "-pix_fmt", "yuv420p", "-an", out_mp4 ] r = subprocess.run(cmd, capture_output=True, text=True) if r.returncode != 0: logger.warning(f"Ken Burns animation fallback: {r.stderr[-200:]}") # Simple loop fallback fallback_vf = "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2:black,format=yuv420p" subprocess.run([ "ffmpeg", "-y", "-loop", "1", "-i", img_path, "-vf", fallback_vf, "-t", f"{duration_sec:.2f}", "-r", "25", "-c:v", "libx264", "-preset", "fast", "-crf", "22", "-pix_fmt", "yuv420p", "-an", out_mp4 ], check=True, capture_output=True) return out_mp4 def normalize_clip_to_duration(src_path: str, out_path: str, duration_sec: float) -> str: """ Normalize any video clip to exactly duration_sec at 1280x720 25fps. Loops short clips (-stream_loop) and trims long ones (-t) so every segment stays in sync with its narration. """ vf = ("scale=1280:720:force_original_aspect_ratio=decrease," "pad=1280:720:(ow-iw)/2:(oh-ih)/2:black,fps=25,format=yuv420p") r = subprocess.run([ "ffmpeg", "-y", "-stream_loop", "-1", "-i", src_path, "-vf", vf, "-t", f"{duration_sec:.2f}", "-r", "25", "-c:v", "libx264", "-preset", "fast", "-crf", "22", "-pix_fmt", "yuv420p", "-an", out_path ], capture_output=True, text=True) if r.returncode != 0: raise RuntimeError(f"clip normalization failed: {r.stderr[-200:]}") return out_path def apply_text_overlay(video_path: str, out_mp4: str, text: str, duration_sec: float) -> str: """ Render a broadcast-style lower-third text banner onto the video clip. Only active if text is non-empty. """ clean_text = re.sub(r"[^A-Za-z0-9 \-\?\!\,\:\'\.\/]", "", text).strip() if not clean_text: if video_path != out_mp4: import shutil shutil.copy(video_path, out_mp4) return out_mp4 font_paths = [ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf", "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", "/usr/share/fonts/truetype/freefont/FreeSansBold.ttf", ] font_file = None for fp in font_paths: if os.path.exists(fp): font_file = fp break font_param = f":fontfile='{font_file}'" if font_file else "" escaped_text = clean_text.replace("'", "'\\''").replace(":", "\\:") # Lower-third banner active between 0.8s and (duration - 0.8s) end_t = max(1.0, duration_sec - 0.8) enable_expr = f"between(t,0.8,{end_t:.2f})" vf = ( f"drawtext=text='{escaped_text}'{font_param}:fontcolor=white:fontsize=36:" f"box=1:boxcolor=black@0.65:boxborderw=14:x=60:y=h-th-60:enable='{enable_expr}'" ) cmd = [ "ffmpeg", "-y", "-i", video_path, "-vf", vf, "-c:v", "libx264", "-preset", "fast", "-crf", "22", "-pix_fmt", "yuv420p", "-an", out_mp4 ] r = subprocess.run(cmd, capture_output=True, text=True) if r.returncode != 0: logger.warning(f"Text overlay rendering fallback: {r.stderr[-200:]}") if video_path != out_mp4: import shutil shutil.copy(video_path, out_mp4) return out_mp4 # ─── Film Grain & Vintage Aging Filter ────────────────────────────────────── HISTORICAL_KEYWORDS = { "ancient", "archaeology", "archaeological", "history", "historical", "century", "b.c.", "bc", "a.d.", "ad", "victorian", "medieval", "ruins", "pharaoh", "temple", "pyramids", "past", "hunter gatherer", "hunter-gatherer", "gobeklitepe", "göbeklitepe", "stone age", "bronze age", "iron age", "1888", "1900", "1920", "1930", "1940", "1950", "1960", "1970", "empire", "dynasty", "columbus", "alexandria", "antiquity", "paleolithic", "neolithic", "civilization", "excavation", "fossil", "scrolls", "papyrus", "jack the ripper", "monument", "artifacts", "artifact", "bermuda", "atlantis", "mythology", "prehistoric", "dynastic", "tomb", "sarcophagus", "excavations", "relic", "relics", "megalith", "megalithic", "sanctuary" } def is_historical_scene(seg: Dict[str, Any], topic: str = "") -> bool: """Check if the segment depicts historical/past events based on flag or keywords.""" if seg.get("is_historical") is True: return True combined_text = ( f"{topic} {seg.get('chapter_title', '')} {seg.get('text', '')} " f"{seg.get('visual_prompt', '')} {seg.get('stock_query', '')}" ).lower() return any(kw in combined_text for kw in HISTORICAL_KEYWORDS) def apply_film_grain_overlay(video_path: str, out_mp4: str) -> str: """ Apply a dynamic 35mm film grain overlay with subtle vintage contrast & vignette to give historical footage an authentically aged cinematic look. """ vintage_vf = ( "noise=alls=14:allf=t+u," "eq=contrast=1.06:brightness=-0.01:saturation=0.88," "vignette=PI/5" ) cmd = [ "ffmpeg", "-y", "-i", video_path, "-vf", vintage_vf, "-c:v", "libx264", "-preset", "fast", "-crf", "22", "-pix_fmt", "yuv420p", "-an", out_mp4 ] r = subprocess.run(cmd, capture_output=True, text=True) if r.returncode != 0: logger.warning(f"Film grain filter fallback: {r.stderr[-200:]}") if video_path != out_mp4: import shutil shutil.copy(video_path, out_mp4) return out_mp4 # ─── ComfyUI Orchestration ───────────────────────────────────────────────── class VisualGen: def __init__(self, pcs: Dict[str, RemotePC], cfg: Any): self.pcs = pcs self.cfg = cfg self.stock_fetcher = StockMediaFetcher( pexels_key=getattr(cfg, "pexels_api_key", None), pixabay_key=getattr(cfg, "pixabay_api_key", None) ) def _h3_workflow(self, prompt: str, seed: int, width: int, height: int, length: int) -> Dict[str, Any]: return { "1": {"inputs": {"unet_name": H3_UNET, "weight_dtype": "default"}, "class_type": "UNETLoader"}, "2": {"inputs": {"model": ["1", 0], "shift_video": 12.0, "shift_audio": 3.0}, "class_type": "MiniMaxH3SigmaShift"}, "3": {"inputs": {"clip_name": H3_CLIP, "type": "minimax"}, "class_type": "CLIPLoader"}, "4": {"inputs": {"vae_name": H3_VAE}, "class_type": "VAELoader"}, "5": {"inputs": {"clip": ["3", 0], "vae": ["4", 0], "prompt": prompt, "width": width, "height": height, "length": length}, "class_type": "MiniMaxH3ImageToVideo"}, "6": {"inputs": {"text": "", "clip": ["3", 0]}, "class_type": "CLIPTextEncode"}, "7": {"inputs": {"seed": seed, "steps": 20, "cfg": 5.0, "sampler_name": "euler", "scheduler": "normal", "denoise": 1.0, "model": ["2", 0], "positive": ["5", 0], "negative": ["6", 0], "latent_image": ["5", 1]}, "class_type": "KSampler"}, "8": {"inputs": {"samples": ["7", 0], "vae": ["4", 0]}, "class_type": "VAEDecode"}, "9": {"inputs": {"images": ["8", 0], "fps": float(H3_FPS)}, "class_type": "CreateVideo"}, "10": {"inputs": {"video": ["9", 0], "filename_prefix": "yt_clip", "format": "auto", "codec": "auto"}, "class_type": "SaveVideo"}, } def _sdxl_workflow(self, prompt: str, seed: int) -> Dict[str, Any]: return { "1": {"inputs": {"ckpt_name": SDXL_CKPT}, "class_type": "CheckpointLoaderSimple"}, "2": {"inputs": {"text": prompt + ", 8k resolution, cinematic lighting, photorealistic, documentary b-roll", "clip": ["1", 1]}, "class_type": "CLIPTextEncode"}, "2b": {"inputs": {"text": "blurry, low quality, watermark, text, logo, ugly, deformed, oversaturated", "clip": ["1", 1]}, "class_type": "CLIPTextEncode"}, "3": {"inputs": {"width": 1280, "height": 720, "batch_size": 1}, "class_type": "EmptyLatentImage"}, "4": {"inputs": {"seed": seed, "steps": 22, "cfg": 7.0, "sampler_name": "euler", "scheduler": "normal", "denoise": 1.0, "model": ["1", 0], "positive": ["2", 0], "negative": ["2b", 0], "latent_image": ["3", 0]}, "class_type": "KSampler"}, "5": {"inputs": {"samples": ["4", 0], "vae": ["1", 2]}, "class_type": "VAEDecode"}, "6": {"inputs": {"filename_prefix": "yt_img", "images": ["5", 0]}, "class_type": "SaveImage"}, } def _submit(self, host: str, workflow: Dict[str, Any]) -> str: r = requests.post(f"http://{host}:8188/prompt", json={"prompt": workflow}, timeout=30) data = r.json() if r.status_code != 200 or "prompt_id" not in data: raise RuntimeError(f"ComfyUI rejected workflow: {json.dumps(data)[:300]}") return data["prompt_id"] def _cancel_job(self, host: str, pid: str): """Interrupt current execution and remove the job from the ComfyUI queue. Prevents client-side timeouts from leaving zombie jobs that block later prompts.""" try: requests.post(f"http://{host}:8188/interrupt", timeout=5) except Exception: pass try: requests.post(f"http://{host}:8188/queue", json={"delete": [pid]}, timeout=5) except Exception: pass def _wait_result(self, host: str, pid: str, timeout_s: int = 1800) -> Dict[str, Any]: deadline = time.time() + timeout_s while time.time() < deadline: time.sleep(3) try: h = requests.get(f"http://{host}:8188/history/{pid}", timeout=10).json() except Exception: continue if pid not in h: continue status = h[pid].get("status", {}) if status.get("status_str") == "error": msgs = h[pid].get("status", {}).get("messages", []) self._cancel_job(host, pid) raise RuntimeError(f"ComfyUI execution error: {json.dumps(msgs)[:300]}") if status.get("completed") or h[pid].get("outputs"): return h[pid].get("outputs", {}) self._cancel_job(host, pid) raise RuntimeError("ComfyUI generation timed out.") def _download_outputs(self, host: str, outputs: Dict[str, Any], out_path: str) -> str: for node_out in outputs.values(): for key in ("videos", "gifs", "images"): for item in node_out.get(key, []): params = {"filename": item["filename"], "type": item.get("type", "output")} if item.get("subfolder"): params["subfolder"] = item["subfolder"] d = requests.get(f"http://{host}:8188/view", params=params, timeout=120).content if len(d) >= 1024: with open(out_path, "wb") as f: f.write(d) return out_path raise RuntimeError("No downloadable asset returned by ComfyUI") # Maximum frames for a MiniMax H3 clip (~6.5s at 24fps). Longer clips take # 30+ min on the RTX 4060; clips are looped/trimmed to segment length anyway. H3_CLIP_CAP = 158 def _try_ai_video(self, pc, prompt, seed, duration_sec, raw_path, final_clip_path, idx): """MiniMax H3 text-to-video clip, normalized to the segment duration.""" h3_length = min(h3_snap_length(duration_sec), self.H3_CLIP_CAP) logger.info(f"[VISUAL] Segment {idx+1}: MiniMax H3 AI video ({h3_length} frames)...") wf = self._h3_workflow(prompt, seed, self.cfg.clip_width, self.cfg.clip_height, h3_length) pid = self._submit(pc.host, wf) outputs = self._wait_result(pc.host, pid, timeout_s=1200) self._download_outputs(pc.host, outputs, raw_path) return normalize_clip_to_duration(raw_path, final_clip_path, duration_sec) def _try_ai_image(self, pc, prompt, seed, duration_sec, raw_path, final_clip_path, idx): """SDXL AI image animated with Ken Burns motion.""" logger.info(f"[VISUAL] Segment {idx+1}: SDXL AI image + Ken Burns...") pid = self._submit(pc.host, self._sdxl_workflow(prompt, seed)) outputs = self._wait_result(pc.host, pid, timeout_s=300) self._download_outputs(pc.host, outputs, raw_path) return apply_ken_burns_effect(raw_path, final_clip_path, duration_sec, motion_type=idx) def _try_stock(self, query, duration_sec, raw_path, final_clip_path, idx, prefer_video): """Stock video (looped/trimmed) or stock image (Ken Burns).""" kind = "video" if prefer_video else "image" logger.info(f"[VISUAL] Segment {idx+1}: stock {kind} for '{query}'...") got = self.stock_fetcher.search_and_download(query, raw_path, prefer_video=prefer_video) if not got or not os.path.exists(got): raise RuntimeError(f"no stock {kind} found for '{query}'") if got.lower().endswith((".mp4", ".webm", ".mov")): return normalize_clip_to_duration(got, final_clip_path, duration_sec) return apply_ken_burns_effect(got, final_clip_path, duration_sec, motion_type=idx) def _generate_segment_visual(self, pc: RemotePC, seg: Dict[str, Any], idx: int, out_dir: str, duration_sec: float) -> str: """ Produce one visual for a segment as an MP4 of exactly duration_sec. "hybrid" rotates across 4 styles for maximum variety: AI video (MiniMax H3) -> stock video -> AI image (Ken Burns) -> stock image (Ken Burns) Each source falls back to the next one in the rotation on failure. """ prompt = (seg.get("visual_prompt") or seg.get("text", "")) + ", cinematic documentary style, 8k, detailed" stock_query = seg.get("stock_query") or seg.get("chapter_title") or seg.get("text", "")[:40] seed = zlib.crc32(prompt.encode()) % (2**31) final_clip_path = os.path.join(out_dir, f"clip_{idx:03d}.mp4") raw_video = os.path.join(out_dir, f"raw_vid_{idx:03d}.mp4") raw_img = os.path.join(out_dir, f"raw_img_{idx:03d}.jpg") strategy = getattr(self.cfg, "visual_strategy", "hybrid") attempts = { "ai_video": lambda: self._try_ai_video(pc, prompt, seed, duration_sec, raw_video, final_clip_path, idx), "stock_video": lambda: self._try_stock(stock_query, duration_sec, raw_video, final_clip_path, idx, prefer_video=True), "ai_image": lambda: self._try_ai_image(pc, prompt, seed, duration_sec, raw_img, final_clip_path, idx), "stock_image": lambda: self._try_stock(stock_query, duration_sec, raw_img, final_clip_path, idx, prefer_video=False), } if strategy == "all_ai": order = ["ai_video", "ai_image", "stock_video", "stock_image"] elif strategy == "stock_focused": order = ["stock_video", "stock_image", "ai_video", "ai_image"] else: # hybrid — rotate the starting source so all 4 styles appear base = ["ai_video", "stock_video", "ai_image", "stock_image"] order = base[idx % 4:] + base[:idx % 4] chosen_clip = None for source in order: try: chosen_clip = attempts[source]() break except Exception as e: logger.warning(f"[VISUAL] Segment {idx+1} source '{source}' failed: {e}") errors.append(f"{source}: {e}") if not chosen_clip: # Emergency color background — pipeline never dies on visuals logger.warning(f"[VISUAL] Segment {idx+1}: all sources failed, using color background") subprocess.run([ "ffmpeg", "-y", "-f", "lavfi", "-i", f"color=c=0x0a1128:s=1280x720:d={duration_sec:.2f}", "-r", "25", "-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", "-an", final_clip_path ], check=True, capture_output=True) chosen_clip = final_clip_path # Apply dynamic 35mm film grain filter overlay if scene depicts past/historical events if is_historical_scene(seg, topic=getattr(self.cfg, "topic", "")): aged_tmp = os.path.join(out_dir, f"aged_{idx:03d}.mp4") apply_film_grain_overlay(chosen_clip, aged_tmp) if os.path.exists(aged_tmp) and os.path.getsize(aged_tmp) > 1000: import shutil shutil.move(aged_tmp, chosen_clip) logger.info(f"[VISUAL] Segment {idx+1}: Applied authentic 35mm film grain & vintage aging overlay.") return chosen_clip def generate(self, script: Dict[str, Any], job_dir: str, segment_timings: Optional[List[Dict[str, Any]]] = None) -> Dict[str, List[str]]: """ Generate bilingual sequences of video clips matching each script segment. Returns: {"en": List[str], "es": List[str]} If on-screen text is present in a segment, generates distinct English and Spanish clips. """ segs = script.get("script_segments", []) logger.info(f"[VISUAL] Producing {len(segs)} video clips (bilingual text-matching enabled)...") pc = self.pcs.get("nvidiam") if not pc or not pc.check(8188): raise RuntimeError("NvidiaLLM ComfyUI is unreachable on port 8188") clips_dir = os.path.join(job_dir, "clips") os.makedirs(clips_dir, exist_ok=True) clips_en = [] clips_es = [] for i, seg in enumerate(segs): t0 = time.time() dur = segment_timings[i]["duration"] if segment_timings and i < len(segment_timings) else seg.get("duration_estimate", 18) base_clip_path = self._generate_segment_visual(pc, seg, i, clips_dir, dur) text_en = (seg.get("on_screen_text") or "").strip() text_es = (seg.get("on_screen_text_es") or text_en).strip() if text_en or text_es: # Segment has on-screen text: render separate English and Spanish video clips clip_en = os.path.join(clips_dir, f"clip_en_{i:03d}.mp4") clip_es = os.path.join(clips_dir, f"clip_es_{i:03d}.mp4") apply_text_overlay(base_clip_path, clip_en, text_en, dur) apply_text_overlay(base_clip_path, clip_es, text_es, dur) clips_en.append(clip_en) clips_es.append(clip_es) logger.info(f"[VISUAL] Clip {i+1}/{len(segs)} bilingual text applied: '{text_en}' (EN) / '{text_es}' (ES) in {time.time()-t0:.1f}s") else: # Clean visual clip without text is shared across both languages clips_en.append(base_clip_path) clips_es.append(base_clip_path) logger.info(f"[VISUAL] Clip {i+1}/{len(segs)} generated in {time.time()-t0:.1f}s -> {os.path.basename(base_clip_path)}") return {"en": clips_en, "es": clips_es}