Spaces:
Runtime error
Runtime error
Antigravity
fix: restore 15s frame limit and 3-tier adaptive tiling for long movie sequential generation
a790c1f | import sys | |
| from pathlib import Path | |
| import tempfile | |
| import subprocess | |
| import torch | |
| import torch.nn.functional as F | |
| import torchaudio | |
| import os | |
| from typing import Any | |
| import time | |
| from contextlib import contextmanager | |
| import gradio as gr | |
| import json | |
| import logging | |
| import socket | |
| import numpy as np | |
| import random | |
| from PIL import Image, ImageOps | |
| APP_ROOT = Path(__file__).resolve().parent | |
| MODEL_DIR = APP_ROOT / "hf_models" | |
| MODEL_DIR.mkdir(parents=True, exist_ok=True) | |
| APP_LOG_LEVEL = os.getenv("APP_LOG_LEVEL", "INFO").upper() | |
| REPLICA_ID = os.getenv("SPACE_REPLICA_ID") or socket.gethostname() | |
| class JsonFormatter(logging.Formatter): | |
| def format(self, record): | |
| payload = { | |
| "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(record.created)), | |
| "level": record.levelname, | |
| "logger": record.name, | |
| "msg": record.getMessage(), | |
| "replica_id": REPLICA_ID, | |
| } | |
| extra_fields = getattr(record, "extra_fields", None) | |
| if isinstance(extra_fields, dict): | |
| payload.update(extra_fields) | |
| if record.exc_info: | |
| payload["exc_info"] = self.formatException(record.exc_info) | |
| return json.dumps(payload, ensure_ascii=False) | |
| logger = logging.getLogger("ltx_space") | |
| logger.setLevel(APP_LOG_LEVEL) | |
| logger.propagate = False | |
| handler = logging.StreamHandler(sys.stdout) | |
| handler.setFormatter(JsonFormatter()) | |
| logger.handlers.clear() | |
| logger.addHandler(handler) | |
| def log_event(message, **fields): | |
| logger.info(message, extra={"extra_fields": fields}) | |
| def log_error(message, **fields): | |
| logger.exception(message, extra={"extra_fields": fields}) | |
| def timer(name: str): | |
| start = time.time() | |
| log_event(f"{name}...") | |
| yield | |
| log_event(f" -> {name} completed in {time.time() - start:.2f} sec") | |
| current_dir = Path(__file__).parent | |
| sys.path.insert(0, str(current_dir / "packages" / "ltx-pipelines" / "src")) | |
| sys.path.insert(0, str(current_dir / "packages" / "ltx-core" / "src")) | |
| import spaces | |
| from huggingface_hub import hf_hub_download, snapshot_download | |
| from ltx_pipelines.distilled import DistilledPipeline | |
| from ltx_core.model.video_vae import TilingConfig | |
| from ltx_pipelines.utils.constants import ( | |
| DEFAULT_SEED, DEFAULT_1_STAGE_HEIGHT, DEFAULT_1_STAGE_WIDTH | |
| ) | |
| from ltx_pipelines.utils import ModelLedger | |
| from ltx_pipelines.utils.helpers import generate_enhanced_prompt | |
| from ltx_core.loader.primitives import LoraPathStrengthAndSDOps | |
| from ltx_core.loader.sd_ops import LTXV_LORA_COMFY_RENAMING_MAP | |
| MAX_SEED = np.iinfo(np.int32).max | |
| MAX_SAFE_FRAMES = 265 # 264 + 1 (multiple of 8 + 1), ~14.7 s @ 18 fps | |
| with timer("Downloading and Loading Models"): | |
| checkpoint_path = hf_hub_download(repo_id="Lightricks/LTX-2", filename="ltx-2-19b-dev.safetensors", local_dir=MODEL_DIR) | |
| gemma_path = snapshot_download(repo_id="unsloth/gemma-3-12b-it-qat-bnb-4bit", local_dir=MODEL_DIR) | |
| upsampler_path = hf_hub_download(repo_id="Lightricks/LTX-2", filename="ltx-2-spatial-upscaler-x2-1.0.safetensors", local_dir=MODEL_DIR) | |
| lora_path = hf_hub_download(repo_id="Lightricks/LTX-2", filename="ltx-2-19b-distilled-lora-384.safetensors", local_dir=MODEL_DIR) | |
| pipeline = DistilledPipeline( | |
| device=torch.device("cuda"), | |
| checkpoint_path=checkpoint_path, | |
| spatial_upsampler_path=upsampler_path, | |
| gemma_root=gemma_path, | |
| loras=[LoraPathStrengthAndSDOps(path=lora_path, strength=0.6, sd_ops=LTXV_LORA_COMFY_RENAMING_MAP)], | |
| fp8transformer=False, | |
| local_files_only=False | |
| ) | |
| text_encoder = pipeline.model_ledger.text_encoder() | |
| text_encoder._init_image_processor() | |
| pipeline._video_encoder = pipeline.model_ledger.video_encoder() | |
| pipeline._transformer = pipeline.model_ledger.transformer() | |
| def extract_last_frame(video_path): | |
| import cv2 | |
| cap = cv2.VideoCapture(video_path) | |
| total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) | |
| cap.set(cv2.CAP_PROP_POS_FRAMES, max(0, total_frames - 1)) | |
| ret, frame = cap.read() | |
| cap.release() | |
| if ret: | |
| frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) | |
| return Image.fromarray(frame_rgb) | |
| return None | |
| GENERATION_FPS = 18.0 | |
| def compute_num_frames(duration: float) -> int: | |
| raw = round((duration * GENERATION_FPS) / 8) * 8 + 1 | |
| raw = max(raw, 9) | |
| raw = min(raw, MAX_SAFE_FRAMES) | |
| return raw | |
| def get_tiling_config(num_frames: int, height: int, width: int) -> TilingConfig: | |
| if num_frames > 176: | |
| return TilingConfig( | |
| spatial_tile_height=256, | |
| spatial_tile_width=256, | |
| temporal_tile_size=32, | |
| ) | |
| if num_frames > 97: | |
| return TilingConfig( | |
| spatial_tile_height=384, | |
| spatial_tile_width=384, | |
| temporal_tile_size=48, | |
| ) | |
| return TilingConfig.default() | |
| def generate_screenplay_gemma(prompt_text, image_path=None): | |
| char_desc = "" | |
| if image_path: | |
| char_msg = [ | |
| {"role": "user", "content": [{"type": "image"}, {"type": "text", "text": "Describe the main character in this image in extreme detail (hair, clothes, age, features) in one short paragraph, so I can recreate them in other scenes. Output only the description and nothing else."}]} | |
| ] | |
| img = Image.open(image_path).convert("RGB") | |
| inputs = text_encoder.processor(text=text_encoder.processor.tokenizer.apply_chat_template(char_msg, tokenize=False, add_generation_prompt=True), images=img, return_tensors="pt").to("cuda") | |
| with torch.inference_mode(): | |
| outputs = text_encoder.model.generate(**inputs, max_new_tokens=256, do_sample=False) | |
| char_desc = text_encoder.processor.tokenizer.decode(outputs[0][inputs.input_ids.shape[1]:], skip_special_tokens=True).strip() | |
| system_instructions = ( | |
| "You are a professional screenwriter. Write a coherent, chronologically sequential screenplay divided into exactly 8 distinct scenes based on the user's topic. " | |
| "Each scene's prompt must start with a transition phrase like 'Continuing from the previous frame, [character description] ...' to maintain smooth sequence continuity. " | |
| "Each scene must have a detailed visual description (1 paragraph) that can be used directly as a text-to-video generation prompt. " | |
| "If a character description is provided, you MUST include and adapt that exact character description in every single scene's prompt to ensure absolute physical consistency. " | |
| "Output ONLY a raw, valid JSON list of objects, where each object has 'scene' (integer, 1 to 8) and 'prompt' (string) fields. " | |
| "Do NOT write any introduction, markdown, codeblocks, explanations, or trailing text. Just the raw JSON." | |
| ) | |
| user_content = f"Topic: {prompt_text}" | |
| if char_desc: | |
| user_content += f"\n\nCharacter Description to use in all scenes: {char_desc}" | |
| messages = [ | |
| {"role": "system", "content": system_instructions}, | |
| {"role": "user", "content": user_content} | |
| ] | |
| prompt = text_encoder.processor.tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True) | |
| inputs = text_encoder.processor(text=prompt, return_tensors="pt").to("cuda") | |
| with torch.inference_mode(): | |
| outputs = text_encoder.model.generate(**inputs, max_new_tokens=1024, do_sample=True, temperature=0.7) | |
| raw_response = text_encoder.processor.tokenizer.decode(outputs[0][inputs.input_ids.shape[1]:], skip_special_tokens=True).strip() | |
| return raw_response, char_desc | |
| def generate_single_scene_gpu(prompt, ref_image_path, num_frames, height, width, seed): | |
| """ | |
| Generate ONE scene in its own GPU allocation (duration=180 s each). | |
| Returns (clip_bytes, last_frame_image_or_None) so the caller never | |
| needs to touch a file after the GPU slot is released. | |
| """ | |
| import gc | |
| output_path = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False).name | |
| imgs = [] | |
| if ref_image_path: | |
| try: | |
| proc = ImageOps.fit( | |
| Image.open(ref_image_path), (width, height), | |
| method=Image.LANCZOS, centering=(0.5, 0.5) | |
| ) | |
| tmp_img = tempfile.NamedTemporaryFile(suffix=".png", delete=False).name | |
| proc.save(tmp_img) | |
| imgs.append((tmp_img, 0, 1.0)) | |
| except Exception as e: | |
| log_error(f"Error cropping reference image: {e}") | |
| imgs.append((ref_image_path, 0, 1.0)) | |
| tiling = get_tiling_config(num_frames, height, width) | |
| clip_bytes = None | |
| last_frame_img = None | |
| try: | |
| text_encoder.to("cuda") | |
| with torch.inference_mode(): | |
| v, a, _ = text_encoder(prompt) | |
| v_ctx = v.to("cuda", non_blocking=True) | |
| a_ctx = a.to("cuda", non_blocking=True) | |
| with torch.inference_mode(): | |
| pipeline( | |
| prompt=prompt, | |
| output_path=output_path, | |
| seed=seed, | |
| height=height, | |
| width=width, | |
| num_frames=num_frames, | |
| frame_rate=GENERATION_FPS, | |
| images=imgs, | |
| video_context=v_ctx, | |
| audio_context=a_ctx, | |
| input_waveform=None, | |
| input_waveform_sample_rate=None, | |
| tiling_config=tiling, | |
| ) | |
| # Read bytes and last frame BEFORE cleanup so files still exist | |
| with open(output_path, "rb") as f: | |
| clip_bytes = f.read() | |
| # Extract last frame for continuity while the file is still on disk | |
| last_frame_img = extract_last_frame(output_path) | |
| finally: | |
| # Delete temp video file | |
| try: | |
| os.remove(output_path) | |
| except Exception: | |
| pass | |
| # Release VRAM | |
| gc.collect() | |
| torch.cuda.empty_cache() | |
| if torch.cuda.is_available(): | |
| torch.cuda.synchronize() | |
| return clip_bytes, last_frame_img | |
| def generate_all_scenes(scenes_list, character_image, duration, height, width, seed): | |
| """ | |
| Orchestrator: calls generate_single_scene_gpu for each scene sequentially. | |
| Each scene gets its own @spaces.GPU slot → no timeout accumulation, | |
| VRAM is fully released between scenes. | |
| Continuity chain: | |
| Scene 1 → ref = character_image (or None) | |
| Scene N → ref = PIL Image of the last frame from Scene N-1 | |
| (returned by generate_single_scene_gpu before cleanup) | |
| """ | |
| import gc | |
| clips_bytes = [] | |
| current_ref = character_image # file path string or None | |
| num_frames = compute_num_frames(duration) | |
| log_event( | |
| f"Generating {len(scenes_list)} scenes | " | |
| f"requested={duration}s | actual={((num_frames-1)/GENERATION_FPS):.1f}s | " | |
| f"num_frames={num_frames} | res={width}x{height}" | |
| ) | |
| for i, prompt in enumerate(scenes_list): | |
| ref_label = 'last_frame' if (i > 0 and current_ref != character_image) else 'character_img' | |
| log_event(f"Scene {i+1}/{len(scenes_list)} | ref={ref_label} | frames={num_frames}") | |
| try: | |
| clip_bytes, last_frame_img = generate_single_scene_gpu( | |
| prompt=prompt, | |
| ref_image_path=current_ref, | |
| num_frames=num_frames, | |
| height=height, | |
| width=width, | |
| seed=seed + i, | |
| ) | |
| if clip_bytes: | |
| clips_bytes.append(clip_bytes) | |
| # ── Continuity ──────────────────────────────────────────────────── | |
| # last_frame_img is a PIL Image returned from inside the GPU function | |
| # (extracted BEFORE file cleanup), so it's always valid. | |
| if last_frame_img is not None: | |
| ref_path = tempfile.NamedTemporaryFile(suffix=".png", delete=False).name | |
| last_frame_img.save(ref_path) | |
| log_event(f"Scene {i+1}: last frame saved → ref for scene {i+2}") | |
| current_ref = ref_path | |
| else: | |
| # Keep previous ref; never reset to character_image mid-sequence | |
| log_event(f"Scene {i+1}: no last frame returned, keeping previous ref") | |
| except Exception as e: | |
| log_error(f"Error generating scene {i+1}: {e}") | |
| # current_ref unchanged → reuse last good frame for next scene | |
| gc.collect() | |
| return clips_bytes | |
| def concatenate_videos(video_paths): | |
| output_path = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False).name | |
| cmd = ["ffmpeg", "-y"] | |
| for vp in video_paths: | |
| cmd.extend(["-i", vp]) | |
| num_files = len(video_paths) | |
| filter_str = "".join([f"[{i}:v]fps=18,format=yuv420p,scale=768:512,setsar=1,setpts=PTS-STARTPTS[v{i}];" for i in range(num_files)]) | |
| filter_str += "".join([f"[v{i}]" for i in range(num_files)]) | |
| filter_str += f"concat=n={num_files}:v=1:a=0[outv]" | |
| cmd.extend([ | |
| "-filter_complex", filter_str, | |
| "-map", "[outv]", | |
| "-c:v", "libx264", "-pix_fmt", "yuv420p", "-r", "18", "-vsync", "cfr", output_path | |
| ]) | |
| subprocess.run(cmd, check=True) | |
| return output_path | |
| def generate_movie(story_prompt, character_image, duration_per_scene, height, width, seed, randomize_seed, progress=gr.Progress()): | |
| curr_seed = random.randint(0, MAX_SEED) if randomize_seed else int(seed) | |
| capped_frames = compute_num_frames(duration_per_scene) | |
| actual_duration = (capped_frames - 1) / GENERATION_FPS | |
| duration_note = "" | |
| if actual_duration < duration_per_scene - 0.5: | |
| duration_note = f" (ajustado a {actual_duration:.1f}s por límite de VRAM)" | |
| progress(0, desc="Generando guion con Gemma-3...") | |
| yield [f"Generando guion con Gemma-3...{duration_note}", "", None, None, None, None, None, None, None, None, None] | |
| try: | |
| raw_script, char_desc = generate_screenplay_gemma(story_prompt, character_image) | |
| except Exception as e: | |
| yield [f"Error al generar el guion: {str(e)}", "", None, None, None, None, None, None, None, None, None] | |
| return | |
| clean_res = raw_script | |
| if "```" in clean_res: | |
| clean_res = clean_res.split("```")[1] | |
| if clean_res.startswith("json"): | |
| clean_res = clean_res[4:] | |
| clean_res = clean_res.strip() | |
| try: | |
| scenes = json.loads(clean_res) | |
| except Exception as e: | |
| yield ["Error al decodificar JSON del guion. Intentando recuperar...", "", None, None, None, None, None, None, None, None, None] | |
| scenes = [] | |
| import re | |
| matches = re.findall(r'\{\s*"scene"\s*:\s*\d+\s*,\s*"prompt"\s*:\s*"[^"]*"\s*\}', clean_res) | |
| for m in matches: | |
| try: | |
| scenes.append(json.loads(m)) | |
| except: | |
| pass | |
| if not scenes: | |
| yield ["No se pudo recuperar el guion estructurado.", "", None, None, None, None, None, None, None, None, None] | |
| return | |
| script_display = "" | |
| if char_desc: | |
| script_display += f"Personaje detectado:\n{char_desc}\n\n" | |
| script_display += "Guion cinematográfico:\n" | |
| for s in scenes: | |
| script_display += f"Escena {s['scene']}: {s['prompt']}\n\n" | |
| progress(0.1, desc=f"Guion generado. Renderizando {len(scenes[:8])} escenas ({actual_duration:.1f}s c/u)...") | |
| yield [f"Guion generado. Generando escenas en GPU (cada escena en su propia sesión GPU)...{duration_note}", script_display, None, None, None, None, None, None, None, None, None] | |
| scenes_list = [s["prompt"] for s in scenes[:8]] | |
| try: | |
| clips_bytes_list = generate_all_scenes(scenes_list, character_image, duration_per_scene, height, width, curr_seed) | |
| except Exception as e: | |
| yield [f"Error al generar escenas en GPU: {str(e)}", script_display, None, None, None, None, None, None, None, None, None] | |
| return | |
| video_clips = [] | |
| ui_videos = [None] * 8 | |
| for i, clip_bytes in enumerate(clips_bytes_list): | |
| clip_path = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False).name | |
| with open(clip_path, "wb") as f: | |
| f.write(clip_bytes) | |
| video_clips.append(clip_path) | |
| ui_videos[i] = clip_path | |
| if not video_clips: | |
| yield ["Error: No se pudo generar ninguna escena.", script_display, None] + ui_videos | |
| return | |
| progress(0.9, desc="Concatenando todas las escenas en la película final...") | |
| yield ["Concatenando todas las escenas en la película final...", script_display, None] + ui_videos | |
| try: | |
| final_movie = concatenate_videos(video_clips) | |
| progress(1.0, desc="¡Película generada con éxito!") | |
| yield [f"¡Película generada con éxito! ({len(video_clips)} escenas × {actual_duration:.1f}s c/u)", script_display, final_movie] + ui_videos | |
| except Exception as e: | |
| yield [f"Error al concatenar videos: {str(e)}", script_display, None] + ui_videos | |
| with gr.Blocks(title="CinemAI 🎬🎥") as demo: | |
| gr.Markdown("# CinemAI 🎬🎥") | |
| with gr.Row(): | |
| with gr.Column(): | |
| story_prompt = gr.Textbox(label="Idea de la Película / Historia", value="Un cuento de terror sobre una casa embrujada donde un joven explorador con chaqueta roja busca respuestas.", lines=4) | |
| img_in = gr.Image(label="Imagen de Referencia del Personaje", type="filepath", height=256) | |
| with gr.Row(): | |
| dur_ui = gr.Slider(5.0, 30.0, 10.0, step=1.0, label="Duración por Escena (Segundos)") | |
| res_ui = gr.Radio(["16:9", "1:1", "9:16"], value="16:9", label="Relación de Aspecto") | |
| with gr.Accordion("Configuración Avanzada", open=False): | |
| seed = gr.Slider(0, MAX_SEED, DEFAULT_SEED, step=1, label="Semilla") | |
| random_seed = gr.Checkbox(label="Semilla Aleatoria", value=True) | |
| gen_btn = gr.Button("🎬 Generar Película Completa", variant="primary") | |
| with gr.Column(): | |
| status_box = gr.Textbox(label="Estado de la Generación", interactive=False) | |
| video_out = gr.Video(label="Película Final Generada (Preview)", autoplay=True, height=512) | |
| script_out = gr.Textbox(label="Guion Cinematográfico de Gemma-3", lines=15, interactive=False) | |
| with gr.Accordion("📥 Descargar Escenas Individuales (Respaldo)", open=False): | |
| gr.Markdown("Si la concatenación del video final presenta problemas de duración en tu navegador, aquí puedes previsualizar y descargar cada escena por separado.") | |
| scene_videos = [] | |
| with gr.Row(): | |
| for idx in range(8): | |
| if idx == 4: | |
| gr.HTML("<div style='flex-basis: 100%; height: 0;'></div>") | |
| with gr.Column(min_width=200): | |
| scene_videos.append(gr.Video(label=f"Escena {idx+1}", interactive=False, height=180)) | |
| w_s, h_s = gr.State(768), gr.State(512) | |
| res_ui.change(lambda r: (768, 512) if r=="16:9" else ((512, 512) if r=="1:1" else (512, 768)), res_ui, [w_s, h_s]) | |
| gen_btn.click( | |
| generate_movie, | |
| inputs=[story_prompt, img_in, dur_ui, h_s, w_s, seed, random_seed], | |
| outputs=[status_box, script_out, video_out] + scene_videos | |
| ) | |
| if __name__ == "__main__": | |
| demo.launch(ssr_mode=False, mcp_server=True) |