Spaces:
Running on Zero
Running on Zero
| """SCoPE — camera-trajectory controlled image-to-video (Wan2.2-I2V-A14B + SCoPE). | |
| Faithful port of TencentARC/SCoPE's reference inference path (scope/inference.py, | |
| scope/weights.py, the vendored DiffSynth `wan_video_panshot` pipeline) onto ZeroGPU. | |
| Deviations from the reference are forced by the 48 GB / ~2 min ZeroGPU budget and are | |
| listed in the README: fp8 weight quantization, the Wan2.2-Lightning 4-step distillation | |
| LoRA with cfg_scale = 1.0, and shard-streamed weight loading. | |
| """ | |
| from __future__ import annotations | |
| import os | |
| os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True") | |
| os.environ.setdefault("HF_HUB_ENABLE_HF_TRANSFER", "1") | |
| os.environ.setdefault("TOKENIZERS_PARALLELISM", "false") | |
| import spaces # noqa: E402 — must precede torch so the CUDA emulation patch applies | |
| import gc # noqa: E402 | |
| import json # noqa: E402 | |
| import math # noqa: E402 | |
| import random # noqa: E402 | |
| import tempfile # noqa: E402 | |
| import time # noqa: E402 | |
| from io import BytesIO # noqa: E402 | |
| from pathlib import Path # noqa: E402 | |
| import gradio as gr # noqa: E402 | |
| import matplotlib # noqa: E402 | |
| matplotlib.use("Agg") | |
| import matplotlib.pyplot as plt # noqa: E402 | |
| import numpy as np # noqa: E402 | |
| import torch # noqa: E402 | |
| from huggingface_hub import hf_hub_download # noqa: E402 | |
| from PIL import Image # noqa: E402 | |
| from safetensors import safe_open # noqa: E402 | |
| from torchao.quantization import ( # noqa: E402 | |
| Float8DynamicActivationFloat8WeightConfig, | |
| Int8WeightOnlyConfig, | |
| quantize_, | |
| ) | |
| from diffsynth.data.video import save_video # noqa: E402 | |
| from diffsynth.models import ModelManager # noqa: E402 | |
| from diffsynth.models.utils import init_weights_on_device # noqa: E402 | |
| from diffsynth.models.wan_video_dit import WanModel # noqa: E402 | |
| from scope.config import InferenceConfig # noqa: E402 | |
| from scope.pipeline import SCoPEPipeline # noqa: E402 | |
| from scope.weights import _DIT_CONFIG, _install_scope_architecture # noqa: E402 | |
| # -------------------------------------------------------------------------------------- | |
| # Constants | |
| # -------------------------------------------------------------------------------------- | |
| HERE = Path(__file__).resolve().parent | |
| REPO_ID = "TencentARC/SCoPE" | |
| LORA_REPO = "lightx2v/Wan2.2-Lightning" | |
| LORA_SUBDIR = "Wan2.2-I2V-A14B-4steps-lora-rank64-Seko-V1" | |
| WORK_DIR = Path(os.environ.get("SCOPE_WEIGHT_DIR", "/tmp/scope-weights")) | |
| DEVICE = "cuda" | |
| CFG = InferenceConfig() # 480x832, 81 frames, fps 16, sigma_shift 5.0, boundary 0.9 | |
| HEIGHT, WIDTH, NUM_FRAMES, FPS = CFG.height, CFG.width, CFG.num_frames, CFG.fps | |
| # The reference `x_fov` for every AI-generated showcase case in examples/manifest.json. | |
| DEFAULT_FOV_DEG = round(math.degrees(1.4078388214111328), 1) # 80.7 deg | |
| MAX_SEED = np.iinfo(np.int32).max | |
| NEGATIVE_PROMPT = (HERE / "configs" / "negative_prompt.txt").read_text(encoding="utf-8").strip() | |
| # Camera presets. Every .npy is [81, 3, 4] OpenCV camera-to-world, already expressed | |
| # relative to frame 0 (frame 0 is the identity pose), matching what SCoPE was trained on. | |
| PRESETS: list[tuple[str, str]] = [ | |
| ("Dolly in — push straight into the scene", "dolly_in"), | |
| ("Dolly out — pull straight back", "dolly_out"), | |
| ("Truck left — slide sideways to the left", "truck_left"), | |
| ("Truck right — slide sideways to the right", "truck_right"), | |
| ("Pan right — rotate in place, no translation", "pan_right"), | |
| ("Orbit left — arc around the subject", "orbit_left"), | |
| ("Crane up + forward — rise while pushing in", "crane_up_fwd"), | |
| ("Snake forward — weaving push-in", "snake_fwd"), | |
| ("Grand tour — long sweeping traversal (bold)", "grand_tour"), | |
| ("Push + sweep — drive in, then sweep across (bold)", "push_sweep"), | |
| ("Wide orbit — large arc around the scene (bold)", "wide_orbit"), | |
| ("Spiral climb — rising corkscrew (bold)", "spiral_climb"), | |
| ("Spiral rise — steep rising turn (bold)", "greek_spiral_rise"), | |
| ("Crane arc — lift and curve (bold)", "crane_arc"), | |
| ("Flyover left — fly past on the left (bold)", "flyover_left"), | |
| ("S-curve reveal — weave and reveal (bold)", "s_curve_reveal"), | |
| ("Pull back + rise — retreat and lift (bold)", "pullback_rise"), | |
| ] | |
| PRESET_LABELS = {value: label for label, value in PRESETS} | |
| def load_trajectory(name: str, motion_scale: float = 1.0) -> np.ndarray: | |
| """Load a [81, 3, 4] camera-to-world preset and optionally rescale its translation.""" | |
| path = HERE / "trajectories" / f"{name}.npy" | |
| if not path.is_file(): | |
| raise gr.Error(f"Unknown camera trajectory: {name}") | |
| poses = np.load(path).astype(np.float32) | |
| if poses.shape != (NUM_FRAMES, 3, 4): | |
| raise gr.Error(f"Malformed trajectory {name}: {poses.shape}") | |
| poses = poses.copy() | |
| poses[:, :3, 3] *= float(motion_scale) | |
| return poses | |
| # -------------------------------------------------------------------------------------- | |
| # Weight loading — streamed shard by shard so peak disk stays ~1 shard (the full | |
| # TencentARC/SCoPE package is 71 GB, well over a Space's ephemeral disk). | |
| # -------------------------------------------------------------------------------------- | |
| FP8_CONFIG = Float8DynamicActivationFloat8WeightConfig() | |
| def _quant_filter(module: torch.nn.Module, fqn: str) -> bool: | |
| """fp8 the big projections only; SCoPE's tiny Plucker/gate MLPs stay bf16.""" | |
| return ( | |
| isinstance(module, torch.nn.Linear) | |
| and "plucker_pe" not in fqn | |
| and module.in_features >= 512 | |
| and module.out_features >= 512 | |
| ) | |
| def _download(filename: str, repo_id: str = REPO_ID) -> Path: | |
| return Path(hf_hub_download(repo_id, filename, local_dir=str(WORK_DIR))) | |
| def _load_lightning_lora(expert: str) -> dict[str, tuple[torch.Tensor, torch.Tensor, float]]: | |
| """Read the Wan2.2-Lightning 4-step LoRA for one expert as {param_name: (down, up, scale)}.""" | |
| path = _download(f"{LORA_SUBDIR}/{expert}.safetensors", repo_id=LORA_REPO) | |
| table: dict[str, tuple[torch.Tensor, torch.Tensor, float]] = {} | |
| with safe_open(str(path), framework="pt", device="cpu") as handle: | |
| for key in handle.keys(): | |
| if not key.endswith(".lora_down.weight"): | |
| continue | |
| stem = key[: -len(".lora_down.weight")] | |
| down = handle.get_tensor(key).clone() | |
| up = handle.get_tensor(f"{stem}.lora_up.weight").clone() | |
| alpha = float(handle.get_tensor(f"{stem}.alpha")) | |
| target = stem.replace("diffusion_model.", "", 1) + ".weight" | |
| table[target] = (down, up, alpha / down.shape[0]) | |
| path.unlink(missing_ok=True) | |
| print(f"[SCoPE] Lightning LoRA ({expert}): {len(table)} fused projections", flush=True) | |
| return table | |
| def _fuse_lora(module: torch.nn.Module, table: dict, prefix: str) -> int: | |
| fused = 0 | |
| for name, param in module.named_parameters(recurse=True): | |
| entry = table.pop(f"{prefix}{name}", None) | |
| if entry is None: | |
| continue | |
| down, up, scale = entry | |
| delta = torch.mm(up.float(), down.float()).mul_(scale) | |
| param.data = (param.data.float() + delta).to(torch.bfloat16) | |
| del delta, down, up | |
| fused += 1 | |
| return fused | |
| def _block_materialized(block: torch.nn.Module) -> bool: | |
| tensors = list(block.parameters(recurse=True)) + list(block.buffers(recurse=True)) | |
| return all(not tensor.is_meta for tensor in tensors) | |
| def _finalize_block(block: torch.nn.Module, index: int, table: dict, is_low_expert: bool) -> None: | |
| _fuse_lora(block, table, f"blocks.{index}.") | |
| encoding = block.self_attn.plucker_pe | |
| q_out = encoding.eq[2] if encoding.use_mlp else encoding.eq | |
| nonzero = int(torch.count_nonzero(q_out.weight)) | |
| if is_low_expert and nonzero != 0: | |
| raise RuntimeError(f"low-noise expert block {index} is not a zero-delta SCoPE model") | |
| if not is_low_expert and nonzero == 0: | |
| raise RuntimeError(f"high-noise expert block {index} has no SCoPE weights") | |
| block.requires_grad_(False) | |
| block.to(DEVICE) | |
| quantize_(block, FP8_CONFIG, filter_fn=_quant_filter) | |
| def _stream_expert(model: WanModel, subfolder: str, is_low_expert: bool) -> None: | |
| """Materialise one 29.7 GB expert: download -> assign -> delete -> fuse -> fp8.""" | |
| lora_table = _load_lightning_lora("low_noise_model" if is_low_expert else "high_noise_model") | |
| index_path = _download(f"{subfolder}/diffusion_pytorch_model.safetensors.index.json") | |
| weight_map = json.loads(index_path.read_text(encoding="utf-8"))["weight_map"] | |
| shards = list(dict.fromkeys(weight_map.values())) | |
| expected = set(model.state_dict()) | |
| loaded: set[str] = set() | |
| pending = set(range(len(model.blocks))) | |
| for position, shard in enumerate(shards, start=1): | |
| started = time.time() | |
| shard_path = _download(f"{subfolder}/{shard}") | |
| tensors: dict[str, torch.Tensor] = {} | |
| with safe_open(str(shard_path), framework="pt", device="cpu") as handle: | |
| for key in handle.keys(): | |
| # clone(): safetensors hands back mmap views, and the file is deleted below. | |
| tensors[key] = handle.get_tensor(key).clone() | |
| unexpected = set(tensors) - expected | |
| if unexpected: | |
| raise RuntimeError(f"unexpected keys in {shard}: {sorted(unexpected)[:5]}") | |
| model.load_state_dict(tensors, strict=False, assign=True) | |
| loaded.update(tensors) | |
| del tensors | |
| shard_path.unlink(missing_ok=True) | |
| gc.collect() | |
| for index in sorted(pending): | |
| if _block_materialized(model.blocks[index]): | |
| _finalize_block(model.blocks[index], index, lora_table, is_low_expert) | |
| pending.discard(index) | |
| gc.collect() | |
| print( | |
| f"[SCoPE] {subfolder}: shard {position}/{len(shards)} in " | |
| f"{time.time() - started:.0f}s, {len(model.blocks) - len(pending)}" | |
| f"/{len(model.blocks)} blocks quantised", | |
| flush=True, | |
| ) | |
| missing = expected - loaded | |
| if missing: | |
| raise RuntimeError(f"incomplete {subfolder}: {sorted(missing)[:5]}") | |
| if pending: | |
| raise RuntimeError(f"{subfolder}: blocks never materialised: {sorted(pending)[:5]}") | |
| if lora_table: | |
| raise RuntimeError(f"unused Lightning LoRA keys: {sorted(lora_table)[:5]}") | |
| # Everything outside `blocks` (patch/text/time embeddings, head) is small. | |
| for name, child in model.named_children(): | |
| if name == "blocks": | |
| continue | |
| child.requires_grad_(False) | |
| child.to(DEVICE) | |
| quantize_(child, FP8_CONFIG, filter_fn=_quant_filter) | |
| for _, param in model.named_parameters(recurse=False): | |
| param.data = param.data.to(DEVICE) | |
| leftover = [name for name, p in model.named_parameters() if p.is_meta] | |
| if leftover: | |
| raise RuntimeError(f"unmaterialised parameters: {leftover[:5]}") | |
| gc.collect() | |
| def build_pipeline() -> SCoPEPipeline: | |
| total = time.time() | |
| WORK_DIR.mkdir(parents=True, exist_ok=True) | |
| pipe = SCoPEPipeline(device="cpu", torch_dtype=torch.bfloat16) | |
| with init_weights_on_device(): | |
| pipe.dit = WanModel(**_DIT_CONFIG) | |
| pipe.dit2 = WanModel(**_DIT_CONFIG) | |
| _install_scope_architecture(pipe, CFG) | |
| # T5 + VAE first: the .pth loader is not mmap-based, so get its 11 GB peak out of | |
| # the way before the experts occupy RAM. | |
| for filename in ( | |
| "google/umt5-xxl/spiece.model", | |
| "google/umt5-xxl/special_tokens_map.json", | |
| "google/umt5-xxl/tokenizer.json", | |
| "google/umt5-xxl/tokenizer_config.json", | |
| ): | |
| _download(filename) | |
| manager = ModelManager(torch_dtype=torch.bfloat16, device=DEVICE) | |
| for filename in ("models_t5_umt5-xxl-enc-bf16.pth", "Wan2.1_VAE.pth"): | |
| path = _download(filename) | |
| manager.load_model(str(path)) | |
| path.unlink(missing_ok=True) | |
| gc.collect() | |
| pipe.text_encoder = manager.fetch_model("wan_video_text_encoder") | |
| pipe.vae = manager.fetch_model("wan_video_vae") | |
| if pipe.text_encoder is None or pipe.vae is None: | |
| raise RuntimeError("the SCoPE package must ship both the T5 encoder and the VAE") | |
| pipe.text_encoder.requires_grad_(False) | |
| pipe.vae.requires_grad_(False) | |
| quantize_(pipe.text_encoder, Int8WeightOnlyConfig()) | |
| gc.collect() | |
| pipe.prompter.fetch_models(pipe.text_encoder) | |
| pipe.prompter.fetch_tokenizer(str(WORK_DIR / "google" / "umt5-xxl")) | |
| _stream_expert(pipe.dit, "high_noise_model", is_low_expert=False) | |
| _stream_expert(pipe.dit2, "low_noise_model", is_low_expert=True) | |
| pipe.height_division_factor = pipe.vae.upsampling_factor * 2 | |
| pipe.width_division_factor = pipe.vae.upsampling_factor * 2 | |
| pipe.switch_DiT_boundary = CFG.switch_dit_boundary | |
| pipe.device = DEVICE | |
| pipe.eval() | |
| gc.collect() | |
| print(f"[SCoPE] pipeline ready in {time.time() - total:.0f}s", flush=True) | |
| return pipe | |
| PIPE = build_pipeline() | |
| # -------------------------------------------------------------------------------------- | |
| # Inference | |
| # -------------------------------------------------------------------------------------- | |
| def prepare_image(path: str | None) -> Image.Image: | |
| if not path: | |
| raise gr.Error("Please provide an input image — it becomes the first video frame.") | |
| image = Image.open(path).convert("RGB") | |
| target = WIDTH / HEIGHT | |
| width, height = image.size | |
| if abs(width / height - target) > 1e-3: | |
| # Centre-crop to 16:9 first so non-16:9 uploads are not squashed. | |
| if width / height > target: | |
| crop = int(round(height * target)) | |
| left = (width - crop) // 2 | |
| image = image.crop((left, 0, left + crop, height)) | |
| else: | |
| crop = int(round(width / target)) | |
| top = (height - crop) // 2 | |
| image = image.crop((0, top, width, top + crop)) | |
| return image.resize((WIDTH, HEIGHT), Image.Resampling.LANCZOS) | |
| def estimate_duration( | |
| image=None, | |
| prompt="", | |
| trajectory="dolly_in", | |
| steps=4, | |
| motion_scale=1.0, | |
| fov_degrees=DEFAULT_FOV_DEG, | |
| seed=42, | |
| randomize_seed=True, | |
| *args, | |
| **kwargs, | |
| ): | |
| # Measured on ZeroGPU (fp8 experts, 832x480x81): 4 steps -> 67.5s, 8 steps -> 124s, | |
| # i.e. ~14.1s per sampling step over ~11s of fixed text-encode/VAE cost. Keep a ~15% | |
| # margin and nothing more, so a default 4-step run stays inside the free 120s quota. | |
| return int(math.ceil(1.15 * (11.0 + 14.1 * int(steps)))) | |
| def generate( | |
| image=None, | |
| prompt="", | |
| trajectory="dolly_in", | |
| steps=4, | |
| motion_scale=1.0, | |
| fov_degrees=DEFAULT_FOV_DEG, | |
| seed=42, | |
| randomize_seed=True, | |
| progress=gr.Progress(track_tqdm=True), | |
| ): | |
| first_frame = prepare_image(image) | |
| prompt = (prompt or "").strip() | |
| if not prompt: | |
| raise gr.Error("Please describe the scene — SCoPE needs a caption for the content.") | |
| used_seed = random.randint(0, MAX_SEED) if randomize_seed else int(seed) | |
| poses = load_trajectory(trajectory, motion_scale) | |
| camera = { | |
| "pose": torch.from_numpy(poses)[None].to(device=DEVICE, dtype=PIPE.torch_dtype), | |
| "x_fov": torch.tensor( | |
| [math.radians(float(fov_degrees))], device=DEVICE, dtype=PIPE.torch_dtype | |
| ), | |
| "xi": torch.tensor([0.0], device=DEVICE, dtype=PIPE.torch_dtype), | |
| } | |
| started = time.time() | |
| with torch.inference_mode(), torch.autocast( | |
| device_type="cuda", dtype=torch.bfloat16, enabled=True | |
| ): | |
| frames = PIPE( | |
| prompt=prompt, | |
| negative_prompt=NEGATIVE_PROMPT, | |
| input_image=first_frame, | |
| camera_control_panshot=camera, | |
| seed=used_seed, | |
| height=HEIGHT, | |
| width=WIDTH, | |
| num_frames=NUM_FRAMES, | |
| num_inference_steps=int(steps), | |
| sigma_shift=CFG.sigma_shift, | |
| cfg_scale=1.0, # distilled 4-step LoRA is guidance-free | |
| camera_cfg_scale=1.0, | |
| switch_DiT_boundary=CFG.switch_dit_boundary, | |
| lock_first_frame=False, | |
| tiled=False, | |
| ) | |
| elapsed = time.time() - started | |
| output = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) | |
| output.close() | |
| save_video(frames, output.name, fps=FPS, quality=9) | |
| status = ( | |
| f"{int(steps)} steps · seed {used_seed} · {PRESET_LABELS.get(trajectory, trajectory)} " | |
| f"· motion x{motion_scale:g} · {elapsed:.0f}s" | |
| ) | |
| return output.name, used_seed, status | |
| # -------------------------------------------------------------------------------------- | |
| # Camera path preview (CPU only) | |
| # -------------------------------------------------------------------------------------- | |
| def preview_path(trajectory: str, motion_scale: float) -> Image.Image: | |
| poses = load_trajectory(trajectory, motion_scale) | |
| # OpenCV camera axes are (right, down, forward); plot as (right, forward, up). | |
| xs, ys, zs = poses[:, 0, 3], poses[:, 2, 3], -poses[:, 1, 3] | |
| us, vs, ws = poses[:, 0, 2], poses[:, 2, 2], -poses[:, 1, 2] | |
| # Equal aspect on every axis (so the shape of the move is honest) but centred on the | |
| # path itself rather than the origin, so short moves still fill the frame. | |
| stacked = np.stack([xs, ys, zs]) | |
| half = max(float((stacked.max(axis=1) - stacked.min(axis=1)).max()) * 0.65, 0.3) | |
| centre = (stacked.max(axis=1) + stacked.min(axis=1)) / 2.0 | |
| figure = plt.figure(figsize=(4.4, 3.6), dpi=150) | |
| axes = figure.add_subplot(111, projection="3d") | |
| axes.plot(xs, ys, zs, color="#2563eb", linewidth=2) | |
| axes.scatter([xs[0]], [ys[0]], [zs[0]], color="#16a34a", s=30, label="start") | |
| axes.scatter([xs[-1]], [ys[-1]], [zs[-1]], color="#dc2626", s=30, label="end") | |
| step = 8 | |
| axes.quiver( | |
| xs[::step], ys[::step], zs[::step], | |
| us[::step], vs[::step], ws[::step], | |
| length=half * 0.45, normalize=True, color="#94a3b8", linewidth=0.9, | |
| arrow_length_ratio=0.35, label="sightline", | |
| ) | |
| axes.set_xlim(centre[0] - half, centre[0] + half) | |
| axes.set_ylim(centre[1] - half, centre[1] + half) | |
| axes.set_zlim(centre[2] - half, centre[2] + half) | |
| axes.set_box_aspect((1.0, 1.0, 1.0)) | |
| axes.set_xlabel("right", fontsize=7, labelpad=-8) | |
| axes.set_ylabel("forward", fontsize=7, labelpad=-8) | |
| axes.set_zlabel("up", fontsize=7, labelpad=-8) | |
| axes.set_xticklabels([]) | |
| axes.set_yticklabels([]) | |
| axes.set_zticklabels([]) | |
| axes.tick_params(length=0, pad=-2) | |
| axes.set_title(PRESET_LABELS.get(trajectory, trajectory).split(" — ")[0], fontsize=9) | |
| axes.legend(fontsize=7, loc="upper left", frameon=False) | |
| figure.subplots_adjust(left=0.0, right=1.0, top=1.0, bottom=0.0) | |
| buffer = BytesIO() | |
| figure.savefig(buffer, format="png", bbox_inches="tight") | |
| plt.close(figure) | |
| buffer.seek(0) | |
| return Image.open(buffer).convert("RGB") | |
| # -------------------------------------------------------------------------------------- | |
| # UI | |
| # -------------------------------------------------------------------------------------- | |
| EXAMPLES = [ | |
| [ | |
| str(HERE / "examples" / "ai-airmountains.jpg"), | |
| "A vast sky filled with multiple floating islands of different sizes, suspended above a " | |
| "dense cloud layer. Each island has distinct terrain such as cliffs, forests, stone ruins, " | |
| "and grassy plateaus. Large waterfalls fall from the edges of islands into the clouds " | |
| "below, creating vertical movement through space. Bright daylight above the cloud sea with " | |
| "soft volumetric haze. Cinematic fantasy realism, natural lighting, subtle atmospheric " | |
| "scattering.", | |
| "grand_tour", | |
| ], | |
| [ | |
| str(HERE / "examples" / "ai-valley.jpg"), | |
| "A wide alpine valley surrounded by tall snow-covered mountains. In the center, a calm " | |
| "lake reflects the sky and surrounding peaks. The valley floor contains open grasslands, " | |
| "scattered pine forests, rocky slopes, and small villages connected by winding dirt roads. " | |
| "A river flows from the mountains through the valley into the lake. Soft morning sunlight " | |
| "with atmospheric haze in the far mountains. Realistic natural environment, subtle " | |
| "cinematic tone, physically based rendering.", | |
| "crane_arc", | |
| ], | |
| [ | |
| str(HERE / "examples" / "ai-middleages.jpg"), | |
| "A vast medieval valley with rolling green hills and a winding river flowing through the " | |
| "landscape. A stone bridge connects two small villages built along the riverbanks, with " | |
| "wooden houses, farms, and scattered windmills. In the distance, a large stone castle sits " | |
| "on top of a hill surrounded by forests, with mountain ranges extending far into the " | |
| "horizon. Soft daylight with mild shadows and natural atmospheric perspective. Unreal " | |
| "Engine 5 style, realistic rendering, subtle cinematic lighting.", | |
| "push_sweep", | |
| ], | |
| ] | |
| CSS = """ | |
| #col-container { margin: 0 auto; max-width: 1180px; } | |
| .dark .gradio-container { color: var(--body-text-color); } | |
| """ | |
| with gr.Blocks() as demo: | |
| with gr.Column(elem_id="col-container"): | |
| gr.Markdown( | |
| """ | |
| # SCoPE — steer the camera through a still image | |
| [SCoPE](https://huggingface.co/TencentARC/SCoPE) retrofits | |
| **Wan2.2-I2V-A14B** with *Sightline-Coordinate Positional Encoding*: Plücker camera | |
| rays are normalised, gated and injected straight into the DiT's self-attention | |
| queries/keys, so a real 3D camera path drives the generated shot. | |
| Drop in an image, describe the scene, and pick a camera move — you get an | |
| 81-frame, 832x480, 16 fps clip that follows that trajectory. | |
| """ | |
| ) | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| image_input = gr.Image( | |
| label="First frame", type="filepath", height=300, sources=["upload", "clipboard"] | |
| ) | |
| prompt_input = gr.Textbox( | |
| label="Scene description", | |
| placeholder="Describe what is in the image and what should happen…", | |
| lines=4, | |
| ) | |
| trajectory_input = gr.Dropdown( | |
| label="Camera move", | |
| choices=PRESETS, | |
| value="dolly_in", | |
| ) | |
| run_button = gr.Button("Generate video", variant="primary") | |
| with gr.Column(scale=1): | |
| video_output = gr.Video( | |
| label="Generated video", autoplay=True, loop=True, height=300 | |
| ) | |
| path_preview = gr.Image( | |
| label="Camera path (start green, end red)", | |
| height=280, | |
| interactive=False, | |
| ) | |
| status_output = gr.Markdown() | |
| with gr.Accordion("Advanced settings", open=False): | |
| with gr.Row(): | |
| steps_input = gr.Slider( | |
| label="Sampling steps", | |
| minimum=4, | |
| maximum=8, | |
| step=1, | |
| value=4, | |
| info=( | |
| "The distillation LoRA is trained for 4 steps (2 high-noise + " | |
| "2 low-noise) — about 68s. Each extra step adds ~14s of GPU time." | |
| ), | |
| ) | |
| motion_input = gr.Slider( | |
| label="Camera motion scale", | |
| minimum=0.25, | |
| maximum=2.0, | |
| step=0.05, | |
| value=1.0, | |
| info="Multiplies the preset's translation. 1.0 is the authored path.", | |
| ) | |
| with gr.Row(): | |
| fov_input = gr.Slider( | |
| label="Horizontal field of view (degrees)", | |
| minimum=40.0, | |
| maximum=110.0, | |
| step=0.1, | |
| value=DEFAULT_FOV_DEG, | |
| info="Camera intrinsics used to build the Plücker rays.", | |
| ) | |
| seed_input = gr.Slider( | |
| label="Seed", minimum=0, maximum=MAX_SEED, step=1, value=CFG.seed | |
| ) | |
| randomize_input = gr.Checkbox(label="Randomize seed", value=True) | |
| gr.Examples( | |
| examples=EXAMPLES, | |
| inputs=[image_input, prompt_input, trajectory_input], | |
| outputs=[video_output, seed_input, status_output], | |
| fn=generate, | |
| cache_examples=True, | |
| cache_mode="lazy", | |
| label="Examples (AI-generated showcase scenes from the SCoPE release)", | |
| ) | |
| gr.Markdown( | |
| """ | |
| **Notes** · Camera paths are OpenCV camera-to-world matrices `[81, 3, 4]` relative to | |
| the first frame, exactly the format SCoPE was trained on — the presets are taken from | |
| the release's own `examples/` trajectory set. Non-16:9 uploads are centre-cropped. | |
| To keep a run inside a ZeroGPU slot this Space serves both 14B experts in **fp8** and | |
| samples with the **Wan2.2-Lightning 4-step** distillation LoRA at `cfg_scale = 1.0` | |
| instead of the paper's 40 steps at `cfg_scale = 3.5`; expect slightly softer detail | |
| than the official samples. | |
| """ | |
| ) | |
| preview_inputs = [trajectory_input, motion_input] | |
| trajectory_input.change(preview_path, preview_inputs, path_preview, show_progress="hidden") | |
| motion_input.change(preview_path, preview_inputs, path_preview, show_progress="hidden") | |
| demo.load(preview_path, preview_inputs, path_preview, show_progress="hidden") | |
| gr.on( | |
| triggers=[run_button.click, prompt_input.submit], | |
| fn=generate, | |
| inputs=[ | |
| image_input, | |
| prompt_input, | |
| trajectory_input, | |
| steps_input, | |
| motion_input, | |
| fov_input, | |
| seed_input, | |
| randomize_input, | |
| ], | |
| outputs=[video_output, seed_input, status_output], | |
| ) | |
| if __name__ == "__main__": | |
| demo.launch(theme=gr.themes.Citrus(), css=CSS, mcp_server=True) | |