from __future__ import annotations import shutil import subprocess import tempfile import hashlib import os from pathlib import Path import numpy as np import torch from PIL import Image from .rife_source import prepare_rife_source def _prepare_rife_checkpoint() -> Path: prepare_rife_source() import vfi_utils vfi_utils.config["ckpts_path"] = os.environ.get("RIFE_CHECKPOINT_ROOT", "/tmp/rife-checkpoints") checkpoint = Path(vfi_utils.load_file_from_github_release("rife", "rife49.pth")) if checkpoint.stat().st_size != 21_345_274: raise RuntimeError("Dimensione inattesa del checkpoint RIFE rife49.pth.") digest = hashlib.sha256(checkpoint.read_bytes()).hexdigest() if digest != "e55fd00f3cc184e3c65961f4bb827a9da022e78eed36b055242c0ac30000d533": raise RuntimeError("SHA-256 inatteso del checkpoint RIFE rife49.pth.") return checkpoint def prepare_rife_model() -> None: """Materialize the small seam model once during global bootstrap.""" prepare_rife_source() from comfy.model_management import get_torch_device from vfi_models import rife as rife_module from vfi_models.rife.rife_arch import IFNet cache_key = ("rife49.pth", "float32", False) if cache_key in rife_module._model_cache: return checkpoint = _prepare_rife_checkpoint() model = IFNet(arch_ver="4.7") state = torch.load(checkpoint, map_location="cpu", weights_only=True) model.load_state_dict(state, strict=True) del state rife_module._model_cache[cache_key] = model.eval().requires_grad_(False).to(get_torch_device()) def close_rife_model() -> None: prepare_rife_source() from vfi_models import rife as rife_module rife_module._model_cache.clear() def apply_rife_seam(frames: torch.Tensor, seam_frames: int = 4) -> torch.Tensor: """Replace the cyclic seam with RIFE intermediates without changing length.""" seam_frames = int(seam_frames) if seam_frames <= 0 or frames.shape[0] < seam_frames + 2: return frames prepare_rife_model() from vfi_models.rife import RIFE_VFI tail_count = max(1, seam_frames // 2) head_count = max(1, seam_frames - tail_count) anchors = torch.stack((frames[-tail_count - 1], frames[head_count])).float() interpolated = RIFE_VFI().vfi( ckpt_name="rife49.pth", frames=anchors, clear_cache_after_n_frames=1, multiplier=seam_frames + 1, fast_mode=True, ensemble=False, scale_factor=1.0, keep_output_on_device=True, )[0][1:-1] if interpolated.shape[0] != seam_frames: raise RuntimeError( f"RIFE ha restituito {interpolated.shape[0]} frame intermedi; attesi {seam_frames}." ) result = frames.clone() result[-tail_count:] = interpolated[:tail_count].to(result) result[:head_count] = interpolated[tail_count:].to(result) return result def save_frame_bundle(frames: torch.Tensor) -> str: frames = frames.detach().cpu().float().clamp(0.0, 1.0) bundle = tempfile.NamedTemporaryFile(prefix="wan-loop-", suffix=".npz", delete=False) bundle.close() np.savez_compressed(bundle.name, frames=(frames.numpy() * 255.0).round().astype(np.uint8)) return bundle.name def _crossfade(frames: np.ndarray) -> np.ndarray: if len(frames) < 2: return frames result = frames.copy() # Preserve the initial conditioning frame; soften only the final boundary. result[-1] = np.rint( result[-1].astype(np.float32) * 0.5 + result[0].astype(np.float32) * 0.5 ).clip(0, 255).astype(np.uint8) return result OUTPUT_FORMATS = { "mkv": { "suffix": ".mkv", "encoder_args": [ "-c:v", "libsvtav1", "-preset", "6", "-crf", "45", "-pix_fmt", "yuv420p", ], }, "mp4": { "suffix": ".mp4", "encoder_args": [ "-c:v", "libx264", "-preset", "slow", "-crf", "28", "-tune", "film", "-pix_fmt", "yuv420p", "-movflags", "+faststart", ], }, } def _encode_frame_sequence(work: Path, ffmpeg: str, fps: int, output_format: str) -> str: output_format = str(output_format).strip().lower() if output_format not in OUTPUT_FORMATS: raise ValueError(f"Unsupported output format: {output_format!r}") format_spec = OUTPUT_FORMATS[output_format] output_handle = tempfile.NamedTemporaryFile( prefix="wan-loop-", suffix=format_spec["suffix"], delete=False ) output = Path(output_handle.name) output_handle.close() try: command = [ ffmpeg, "-hide_banner", "-loglevel", "error", "-y", "-framerate", str(int(fps)), "-i", str(work / "frame_%05d.png"), *format_spec["encoder_args"], str(output), ] subprocess.run(command, check=True) return str(output) except Exception: output.unlink(missing_ok=True) raise def _encode_bundle( bundle_path: str, fps: int, output_formats: tuple[str, ...], crossfade: bool, ) -> dict[str, str]: source = Path(bundle_path) if not source.is_file(): raise FileNotFoundError(f"Bundle frame non trovato: {source}") normalized_formats = tuple(dict.fromkeys(str(item).strip().lower() for item in output_formats)) if not normalized_formats or any(item not in OUTPUT_FORMATS for item in normalized_formats): raise ValueError(f"Unsupported output formats: {normalized_formats!r}") ffmpeg = shutil.which("ffmpeg") if not ffmpeg: raise RuntimeError("ffmpeg non รจ installato nello Space.") work = Path(tempfile.mkdtemp(prefix="wan-loop-frames-")) outputs: dict[str, str] = {} try: with np.load(source) as data: frames = data["frames"] if crossfade: frames = _crossfade(frames) for index, frame in enumerate(frames): Image.fromarray(frame, mode="RGB").save(work / f"frame_{index:05d}.png") for output_format in normalized_formats: outputs[output_format] = _encode_frame_sequence(work, ffmpeg, fps, output_format) return outputs except Exception: for output in outputs.values(): Path(output).unlink(missing_ok=True) raise finally: source.unlink(missing_ok=True) shutil.rmtree(work, ignore_errors=True) def encode_video( bundle_path: str, fps: int, output_format: str = "mkv", crossfade: bool = True, ) -> str: output_format = str(output_format).strip().lower() return _encode_bundle(bundle_path, fps, (output_format,), crossfade)[output_format] def encode_video_with_preview( bundle_path: str, fps: int, output_format: str = "mkv", crossfade: bool = True, ) -> tuple[str, str]: """Return browser-compatible MP4 preview and the selected download.""" output_format = str(output_format).strip().lower() formats = (output_format,) if output_format == "mp4" else (output_format, "mp4") outputs = _encode_bundle(bundle_path, fps, formats, crossfade) return outputs["mp4"], outputs[output_format] def encode_mp4(bundle_path: str, fps: int, crossfade: bool = True) -> str: """Compatibility wrapper for callers that explicitly require MP4.""" return encode_video(bundle_path, fps=fps, output_format="mp4", crossfade=crossfade)