File size: 35,007 Bytes
e793773
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c0e7e4d
e793773
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c0e7e4d
 
 
 
 
 
 
 
e793773
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c0e7e4d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e793773
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3c6897
 
 
c0e7e4d
 
 
 
 
e793773
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c0e7e4d
e793773
c0e7e4d
 
 
 
 
 
e793773
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c0e7e4d
 
e793773
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c0e7e4d
 
 
 
 
 
 
 
 
 
a7a8de0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e793773
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c0e7e4d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a7a8de0
 
 
 
 
 
 
 
 
 
 
 
 
 
e793773
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c0e7e4d
 
 
a7a8de0
 
 
 
e793773
 
 
a7a8de0
 
 
 
 
 
 
 
e793773
a7a8de0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c0e7e4d
e793773
 
 
 
 
a7a8de0
e793773
 
c3c6897
 
a7a8de0
 
c3c6897
e793773
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a7a8de0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e793773
 
 
 
 
 
 
 
 
 
 
 
 
a7a8de0
 
 
e793773
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
"""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 transformers import AutoProcessor, Qwen2VLForConditionalGeneration  # noqa: E402

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"
# A small vision-language model used to auto-write a caption when the user leaves the
# scene description blank — SCoPE always needs a caption for the content it should draw.
CAPTIONER_REPO = "Qwen/Qwen2-VL-2B-Instruct"
CAPTION_INSTRUCTION = (
    "Write one vivid paragraph describing this image for a video generation model. "
    "Cover the main subjects, the setting, materials and colours, and the lighting. "
    "Do not mention the camera, camera motion, shot type, or the word 'image'."
)
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"),
    ("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()


# --------------------------------------------------------------------------------------
# Optional prompt writer — Qwen2-VL captions the first frame when no prompt is given.
# Downloaded and held on CPU at build time; only lifted onto the GPU for the few seconds
# it is actually needed, then evicted so it never competes with the two 14B experts.
# --------------------------------------------------------------------------------------


def build_captioner() -> tuple[Qwen2VLForConditionalGeneration, AutoProcessor]:
    started = time.time()
    processor = AutoProcessor.from_pretrained(
        CAPTIONER_REPO, min_pixels=256 * 28 * 28, max_pixels=768 * 28 * 28
    )
    model = Qwen2VLForConditionalGeneration.from_pretrained(
        CAPTIONER_REPO, torch_dtype=torch.bfloat16
    )
    model.requires_grad_(False)
    model.eval()
    print(f"[SCoPE] caption model ready in {time.time() - started:.0f}s", flush=True)
    return model, processor


CAPTIONER, CAPTION_PROCESSOR = build_captioner()


def autocaption(image: Image.Image) -> str:
    """Describe `image` with Qwen2-VL so a promptless request still has scene content."""
    messages = [
        {
            "role": "user",
            "content": [{"type": "image"}, {"type": "text", "text": CAPTION_INSTRUCTION}],
        }
    ]
    text = CAPTION_PROCESSOR.apply_chat_template(
        messages, tokenize=False, add_generation_prompt=True
    )
    inputs = CAPTION_PROCESSOR(text=[text], images=[image], return_tensors="pt")
    CAPTIONER.to(DEVICE)
    try:
        inputs = inputs.to(DEVICE)
        with torch.inference_mode():
            generated = CAPTIONER.generate(**inputs, max_new_tokens=220, do_sample=False)
        trimmed = generated[:, inputs["input_ids"].shape[1] :]
        caption = CAPTION_PROCESSOR.batch_decode(
            trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=True
        )[0].strip()
    finally:
        CAPTIONER.to("cpu")
        torch.cuda.empty_cache()
    return caption


# --------------------------------------------------------------------------------------
# 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.
    budget = 11.0 + 14.1 * int(steps)
    if not (prompt or "").strip():
        # Lifting Qwen2-VL onto the GPU and captioning the frame adds a fixed overhead.
        budget += 12.0
    return int(math.ceil(1.15 * budget))


@spaces.GPU(duration=estimate_duration)
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()
    autocaptioned = False
    if not prompt:
        # SCoPE needs a caption for the content, so write one from the frame instead of
        # failing on an empty prompt.
        prompt = autocaption(first_frame)
        autocaptioned = True
        if not prompt:
            raise gr.Error("Could not caption the image automatically — please add a description.")

    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"
    )
    if autocaptioned:
        status += f"\n\n*Auto-generated prompt (Qwen2-VL):* {prompt}"
    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" / "istock-motorbike-rice-field.jpg"),
        "A serene rural landscape in soft early-morning light with long shadows. A dirt road "
        "winds through a lush green area; on the left, dense clusters of tall trees including palms "
        "and a few small houses with red roofs, and on the right a vast expanse of green rice "
        "paddies neatly divided into sections. A motorcycle travels along the dirt road, flanked by "
        "utility poles with wires running along them. The overall atmosphere is peaceful and "
        "idyllic, with greenery and natural elements dominating the scene.",
        "orbit_left",
    ],
    [
        str(HERE / "examples" / "omni-greek-square.jpg"),
        "A lively ancient Greek market square with stone-paved pathways, wooden crates, and "
        "fabric-draped stalls full of clay pottery. The setting features classical architecture, "
        "including a prominent colonnaded building on the left and a vast cityscape stretching into "
        "the distance. The clear weather and bright sunlight enhance the vividness of the scene, "
        "creating a dynamic and immersive atmosphere.",
        "crane_up_fwd",
    ],
    [
        str(HERE / "examples" / "istock-country-road.jpg"),
        "A white car driving on a winding road surrounded by lush green grass, seen from an aerial "
        "perspective. The road is narrow and curves gently through the landscape. The grass on "
        "either side is vibrant and well-maintained, with some patches of darker green. The overall "
        "scene is serene and picturesque, with the car travelling through the greenery.",
        "dolly_in",
    ],
    [
        str(HERE / "examples" / "istock-skier.jpg"),
        "A snowy mountain landscape under a clear blue sky. A person is skiing down a well-groomed "
        "slope, leaving tracks in the snow. The skier is dressed in dark clothing and is using ski "
        "poles for balance. In the background, there are snow-covered mountains with rocky "
        "outcrops. A ski lift with red support towers is visible to the left, and a small building "
        "is seen at the bottom of the slope.",
        "orbit_left",
    ],
    [
        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",
    ],
]

# Scenes that contain a person / character. Kept in their own group so the showcase above
# stays character-free, per the release curation.
CHARACTER_EXAMPLES = [
    [
        str(HERE / "examples" / "omni-misty-forest.jpg"),
        "A character in red armor and a straw hat progresses along a forest path, their steps "
        "deliberate as they navigate over stones and through patches of grass. The environment is "
        "a misty forest with ancient stone structures on the left and moss-covered cliffs on the "
        "right. The dense fog and surrounding greenery contribute to a mysterious ambiance.",
        "orbit_left",
    ],
    [
        str(HERE / "examples" / "omni-horse-trail.jpg"),
        "A character dressed in dark attire rides a white horse steadily along a rugged dirt path "
        "that meanders through rocky terrain interspersed with patches of grass and shrubs. To the "
        "left, a wooden fence lines the trail. In the distance, the landscape opens up to reveal "
        "rolling hills covered in vegetation and distant mountains under a bright sky.",
        "crane_up_fwd",
    ],
]

# A compact image-only strip for the left rail — every showcase frame, in the same order.
# Clicking a thumbnail loads its image, prompt and camera move into the input boxes.
QUICK_PICKS = CHARACTER_EXAMPLES + EXAMPLES
QUICK_PICK_GALLERY = [
    (row[0], Path(row[0]).stem.replace("istock-", "").replace("ai-", "").replace("omni-", "").replace("-", " "))
    for row in QUICK_PICKS
]


def load_quick_pick(event: gr.SelectData) -> tuple[str, str, str]:
    image, prompt, trajectory = QUICK_PICKS[event.index]
    return image, prompt, trajectory


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, pick a camera move, and (optionally) describe the scene — you get
            an 81-frame, 832x480, 16 fps clip that follows that trajectory. Leave the description
            blank and a small vision-language model writes one from your image automatically.

            *To fit a ZeroGPU slot this Space uses a fast few-step, fp8 setting, which can
            slightly soften quality; for the highest-fidelity results you're welcome to use the
            full [reference code](https://github.com/TencentARC/SCoPE).*
            """
        )
        with gr.Row():
            with gr.Column(scale=1, min_width=110):
                quick_picks = gr.Gallery(
                    value=QUICK_PICK_GALLERY,
                    label="Quick picks — click to load",
                    columns=1,
                    height=600,
                    object_fit="cover",
                    allow_preview=False,
                )
            with gr.Column(scale=5):
                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… (leave empty to auto-caption it)",
                            lines=4,
                            info="Optional — if left blank, Qwen2-VL writes a caption from your image.",
                        )
                        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=16,
                    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; "
                        "gains past ~8 steps are marginal for the distilled LoRA."
                    ),
                )
                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=CHARACTER_EXAMPLES,
            inputs=[image_input, prompt_input, trajectory_input],
            outputs=[video_output, seed_input, status_output],
            fn=generate,
            cache_examples=True,
            cache_mode="lazy",
            label="Camera control with people in the scene — click a row to load it",
            examples_per_page=8,
        )
        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="More examples — click a row to load its image, prompt and camera move",
            examples_per_page=8,
        )

        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.
            """
        )

    quick_picks.select(
        load_quick_pick, None, [image_input, prompt_input, trajectory_input], show_progress="hidden"
    )
    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)