File size: 16,744 Bytes
49bc52e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f19d786
 
49bc52e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
ID-V2V — identity-preserving video-to-video (Eyeline Labs).

Faithful port of github.com/Eyeline-Labs/ID-V2V's reference inference path to a
single-GPU ZeroGPU Space:

  1. SAM3 promptable concept segmentation over the source clip ("person"),
     Secret Panda mask cleanup (per object + on the union).
  2. foreground-on-gray condition video  ->  the single VACE control stream.
  3. Wan2.1 I2V-14B DiT + VACE ControlNet (both from Eyeline-Labs/ID-V2V's
     finetuned idv2v.pth), with the stylized first frame as the I2V anchor and
     as the SVI anti-drift reference pad (ref_pad_num = -1).

Pipeline code is the authors' own diffsynth fork (vendored under ./diffsynth).
"""

import os

# Must precede any torch import.
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
os.environ.setdefault("HF_XET_HIGH_PERFORMANCE", "1")
os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")

import spaces  # noqa: E402  (before torch)

import tempfile  # noqa: E402
import time  # noqa: E402

import gradio as gr  # noqa: E402
import torch  # noqa: E402
from huggingface_hub import hf_hub_download  # noqa: E402
from PIL import Image  # noqa: E402

from diffsynth.pipelines.wan_video_new_multiVace_svi import (  # noqa: E402
    ModelConfig,
    WanVideoPipeline,
)
from idv2v_lib import (  # noqa: E402
    DEFAULT_NEGATIVE_PROMPT,
    center_crop_and_resize,
    foreground_on_gray,
    load_finetuned_dit_vace,
    load_source_frames,
    read_video_rgb,
    run_sam3_union_masks,
    save_video,
)

# --------------------------------------------------------------------------- #
# Fixed generation geometry (the repo's own scripts/infer.sh CLI default res).
# --------------------------------------------------------------------------- #
WIDTH, HEIGHT = 832, 480
REF_PAD_NUM = -1          # -1 = full SVI anti-drift padding (repo default)

DEF_FRAMES = 33
DEF_STRIDE = 2
DEF_STEPS = 20
DEF_CFG = 5.0
DEF_VACE = 1.0
DEF_SEED = 123
DEF_SAM = "person"

DTYPE = torch.bfloat16

# --------------------------------------------------------------------------- #
# Weights
# --------------------------------------------------------------------------- #
print("[idv2v] fetching weights...", flush=True)
_t0 = time.time()

T5_PATH = hf_hub_download("Wan-AI/Wan2.1-T2V-14B", "models_t5_umt5-xxl-enc-bf16.pth")
VAE_PATH = hf_hub_download("Wan-AI/Wan2.1-T2V-14B", "Wan2.1_VAE.pth")
for _f in ("special_tokens_map.json", "spiece.model", "tokenizer.json", "tokenizer_config.json"):
    _tok = hf_hub_download("Wan-AI/Wan2.1-T2V-14B", f"google/umt5-xxl/{_f}")
TOKENIZER_DIR = os.path.dirname(_tok)
CLIP_PATH = hf_hub_download(
    "Wan-AI/Wan2.1-I2V-14B-480P",
    "models_clip_open-clip-xlm-roberta-large-vit-huge-14.pth",
)
CKPT_PATH = hf_hub_download("Eyeline-Labs/ID-V2V", "idv2v.pth")
print(f"[idv2v] weights on disk in {time.time() - _t0:.0f}s", flush=True)

# --------------------------------------------------------------------------- #
# SAM3 (gated repo -> needs the HF_TOKEN space secret)
# --------------------------------------------------------------------------- #
from transformers import Sam3VideoModel, Sam3VideoProcessor  # noqa: E402

try:  # upstream typo: initializer_range annotated int but defaults to 0.02
    from transformers import Sam3TrackerVideoConfig

    Sam3TrackerVideoConfig.__dataclass_fields__["initializer_range"].type = float
except Exception:  # pragma: no cover
    pass

print("[idv2v] loading SAM3...", flush=True)
sam3_model = Sam3VideoModel.from_pretrained("facebook/sam3", dtype=DTYPE).eval().to("cuda")
sam3_processor = Sam3VideoProcessor.from_pretrained("facebook/sam3")

# --------------------------------------------------------------------------- #
# Wan base models (T5 / VAE / CLIP) then the finetuned DiT + VACE
# --------------------------------------------------------------------------- #
print("[idv2v] building WanVideoPipeline...", flush=True)
pipe = WanVideoPipeline.from_pretrained(
    torch_dtype=DTYPE,
    device="cuda",
    model_configs=[
        ModelConfig(path=T5_PATH, offload_device="cpu", offload_dtype=DTYPE),
        ModelConfig(path=VAE_PATH, offload_device="cpu", offload_dtype=DTYPE),
        ModelConfig(path=CLIP_PATH, offload_device="cpu", offload_dtype=DTYPE),
    ],
    tokenizer_config=ModelConfig(path=TOKENIZER_DIR),
    skip_download=True,
    redirect_common_files=False,
)

print("[idv2v] loading finetuned DiT + VACE from idv2v.pth...", flush=True)
load_finetuned_dit_vace(pipe, CKPT_PATH, torch_dtype=DTYPE, delete_checkpoint_after=True)
assert pipe.dit is not None and pipe.dit.has_image_input

# Everything resident on the GPU — no vram-management / CPU offload.
for _name in ("text_encoder", "vae", "image_encoder", "dit", "vace"):
    _m = getattr(pipe, _name, None)
    if _m is not None:
        _m.to("cuda")
        _m.eval()
pipe.device = "cuda"
print("[idv2v] pipeline ready", flush=True)


# --------------------------------------------------------------------------- #
# Duration estimate
# --------------------------------------------------------------------------- #
def _estimate_duration(
    source_video,
    stylized_first_frame,
    prompt,
    num_frames=DEF_FRAMES,
    frame_stride=DEF_STRIDE,
    num_inference_steps=DEF_STEPS,
    cfg_scale=DEF_CFG,
    vace_scale=DEF_VACE,
    seed=DEF_SEED,
    sam_prompt=DEF_SAM,
    progress=None,
):
    n = int(num_frames)
    latent_f = (n - 1) // 4 + 1
    tokens = latent_f * (HEIGHT // 16) * (WIDTH // 16)
    per_forward = 4.0e-4 * tokens
    forwards = int(num_inference_steps) * (2 if float(cfg_scale) != 1.0 else 1)
    overhead = 45.0 + 0.7 * n  # SAM3 + T5 + VAE encode/decode + mp4 write
    return int(min(1500, overhead + per_forward * forwards))


# --------------------------------------------------------------------------- #
# Inference
# --------------------------------------------------------------------------- #
@spaces.GPU(duration=_estimate_duration, size="xlarge")
def generate(
    source_video,
    stylized_first_frame,
    prompt,
    num_frames=DEF_FRAMES,
    frame_stride=DEF_STRIDE,
    num_inference_steps=DEF_STEPS,
    cfg_scale=DEF_CFG,
    vace_scale=DEF_VACE,
    seed=DEF_SEED,
    sam_prompt=DEF_SAM,
    progress=gr.Progress(track_tqdm=True),
):
    """Restyle or relight a video while preserving the identity of the people in it.

    Args:
        source_video (str): Path to the source video; supplies the motion.
        stylized_first_frame (PIL.Image.Image): The target look for frame 0 (e.g. an
            edited/restyled/relit version of the source's first frame).
        prompt (str): Text description of the desired output video.
        num_frames (int): Frames to generate (17, 33 or 49).
        frame_stride (int): Take every Nth source frame; output fps is divided to match.
        num_inference_steps (int): Denoising steps.
        cfg_scale (float): Classifier-free guidance scale (1.0 disables CFG, ~2x faster).
        vace_scale (float): Strength of the VACE motion/identity control stream.
        seed (int): Random seed.
        sam_prompt (str): SAM3 concept prompt used to segment the subject(s).

    Returns:
        tuple[str, str]: paths to the generated mp4 and to the VACE condition mp4.
    """
    if not source_video:
        raise gr.Error("Please provide a source video.")
    if stylized_first_frame is None:
        raise gr.Error("Please provide a stylized first frame.")
    if not (prompt or "").strip():
        raise gr.Error("Please provide a prompt describing the target video.")

    num_frames = int(num_frames)
    frame_stride = max(1, int(frame_stride))
    num_inference_steps = int(num_inference_steps)
    seed = int(seed)

    t_all = time.perf_counter()

    # ---- inputs -> 832x480 -------------------------------------------------
    src_frames, src_fps = load_source_frames(
        source_video, WIDTH, HEIGHT, num_frames, frame_stride
    )
    out_fps = max(1.0, src_fps / frame_stride)

    if isinstance(stylized_first_frame, str):
        ext = os.path.splitext(stylized_first_frame)[1].lower()
        if ext in {".mp4", ".mov", ".webm", ".mkv", ".avi"}:
            first = read_video_rgb(stylized_first_frame)[0][0]
        else:
            first = Image.open(stylized_first_frame).convert("RGB")
    else:
        first = stylized_first_frame.convert("RGB")
    input_image = center_crop_and_resize(first, WIDTH, HEIGHT)

    # ---- SAM3 -> foreground-on-gray VACE condition -------------------------
    t_seg = time.perf_counter()
    masks = run_sam3_union_masks(
        sam3_model,
        sam3_processor,
        src_frames,
        (sam_prompt or DEF_SAM).strip() or DEF_SAM,
        device="cuda",
        dtype=DTYPE,
    )
    condition = foreground_on_gray(src_frames, masks)
    covered = sum(1 for m in masks if m.any())
    print(f"[idv2v] SAM3 + cleanup {time.perf_counter() - t_seg:.1f}s "
          f"({covered}/{len(masks)} frames with a mask)", flush=True)

    white = Image.new("RGB", (WIDTH, HEIGHT), (255, 255, 255))
    mask_frames = [white] * num_frames

    # ---- generate ----------------------------------------------------------
    t_gen = time.perf_counter()
    frames = pipe(
        prompt=prompt.strip(),
        negative_prompt=DEFAULT_NEGATIVE_PROMPT,
        input_image=input_image,          # I2V anchor (frame 0)
        random_ref_frame=input_image,     # SVI anti-drift reference pad
        ref_pad_num=REF_PAD_NUM,
        vace_video=[condition],           # the single VACE control stream
        vace_video_mask=[mask_frames],    # fully reactive
        use_multi_control_vace=True,
        vace_scale=float(vace_scale),
        seed=seed,
        height=HEIGHT,
        width=WIDTH,
        num_frames=num_frames,
        cfg_scale=float(cfg_scale),
        num_inference_steps=num_inference_steps,
        tiled=False,
    )
    gen_s = time.perf_counter() - t_gen

    out_path = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False).name
    cond_path = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False).name
    save_video(frames[:num_frames], out_path, out_fps)
    save_video(condition, cond_path, out_fps)

    print(f"[idv2v] denoise {gen_s:.1f}s | total {time.perf_counter() - t_all:.1f}s "
          f"({num_frames}f, {num_inference_steps} steps, cfg {cfg_scale})", flush=True)
    return out_path, cond_path


# --------------------------------------------------------------------------- #
# UI
# --------------------------------------------------------------------------- #
CSS = """
#col-container { max-width: 1180px; margin: 0 auto; }
.dark .gradio-container { color: var(--body-text-color); }
"""

EXAMPLES = [
    [
        "examples/man_dancing/source.mp4",
        "examples/man_dancing/stylized_first_frame.png",
        "Against a dramatic, high-contrast industrial cityscape at dusk, a young man with "
        "dark hair and a beard, in a black hoodie with red panels, stands on a concrete "
        "ledge. Illuminated by warm orange glows from below and cool ambient light, he "
        "turns from off-camera to face the lens, his light eyes intense. Suddenly, he "
        "flexes into a double-bicep pose, shouting, his body bouncing with raw energy "
        "amidst billowing, orange-lit smoke.",
    ],
    [
        "examples/two_sitting_woman/source.mp4",
        "examples/two_sitting_woman/stylized_first_frame.png",
        "Bathed in cool, ethereal light within an overgrown, abandoned greenhouse, two "
        "young women share a joyful moment. The woman on the left, with dark, voluminous "
        "curls and a crisp white shirt, sits relaxed, initially beaming at the camera. Her "
        "companion, with reddish-brown curls and a wide, mid-laugh smile, gestures gently. "
        "The light filters through broken glass and lush vines, casting dappled shadows.",
    ],
    [
        "examples/music_band/source.mp4",
        "examples/music_band/stylized_first_frame.png",
        "A light-skinned woman with dreadlocks and a black beanie sings passionately into a "
        "microphone, her tattooed arms gesturing expressively as she sways with a joyful "
        "smile. Behind her, a bearded man with a topknot intensely plays a hand drum. The "
        "intimate home studio glows with dramatic, contrasting magenta and cool blue light "
        "illuminating the foreground figures, while warm amber hues emanate from sheer "
        "curtains and vintage amps in the background.",
    ],
]

with gr.Blocks() as demo:
    with gr.Column(elem_id="col-container"):
        gr.Markdown(
            """
# ID-V2V — identity-preserving video-to-video

Restyle / relight a video while keeping the people in it recognisable. Give it a
**source video** (whose motion is followed), a **stylized first frame** (which
defines the target look and identity), and a **prompt**.

SAM3 segments the people, the foreground-on-gray result drives a VACE ControlNet,
and the stylized frame anchors the Wan2.1 I2V-14B DiT (SVI anti-drift padding).

[model](https://huggingface.co/Eyeline-Labs/ID-V2V) ·
[code](https://github.com/Eyeline-Labs/ID-V2V) ·
[paper](https://huggingface.co/papers/2607.22830)
            """
        )

        with gr.Row():
            with gr.Column():
                source_video = gr.Video(label="Source video (motion reference)")
                stylized_first_frame = gr.Image(
                    label="Stylized first frame (target look + identity)",
                    type="pil",
                    height=240,
                )
                prompt = gr.Textbox(
                    label="Prompt",
                    lines=4,
                    placeholder="Describe the target video: subjects, action, lighting, setting…",
                )
                run = gr.Button("Generate", variant="primary")
            with gr.Column():
                out_video = gr.Video(label="Generated video", autoplay=True)
                cond_video = gr.Video(label="VACE condition (foreground-on-gray)")

        with gr.Accordion("Advanced settings", open=False):
            with gr.Row():
                num_frames = gr.Radio(
                    [17, 33, 49],
                    value=DEF_FRAMES,
                    label="Frames to generate",
                    info="Must be 4k+1 (Wan latent stride). More frames = longer clip and longer wait.",
                )
                frame_stride = gr.Slider(
                    1, 3, value=DEF_STRIDE, step=1,
                    label="Source frame stride",
                    info="2 samples every other source frame, so a short clip still covers "
                         "the whole action; output fps is divided to match. 1 = the repo's "
                         "native behaviour.",
                )
            with gr.Row():
                num_inference_steps = gr.Slider(
                    10, 30, value=DEF_STEPS, step=1, label="Inference steps",
                    info="The paper uses 30.",
                )
                cfg_scale = gr.Slider(
                    1.0, 8.0, value=DEF_CFG, step=0.5, label="CFG scale",
                    info="1.0 skips the negative pass and is ~2x faster (lower quality).",
                )
            with gr.Row():
                vace_scale = gr.Slider(
                    0.0, 1.5, value=DEF_VACE, step=0.05, label="VACE scale",
                    info="How strongly the segmented source drives motion.",
                )
                seed = gr.Number(value=DEF_SEED, precision=0, label="Seed")
            sam_prompt = gr.Textbox(
                value=DEF_SAM, label="SAM3 segmentation prompt",
                info="What to keep as foreground: 'person' (default), 'head', 'dog', …",
            )

        gr.Markdown(
            "Rendered at 832×480 (the repo's CLI default). The released checkpoint is a "
            "720p model, so 1280×720 is sharper but ~2.5× slower than fits in a single "
            "ZeroGPU slot; the authors' multi-clip chaining for longer videos is likewise "
            "out of scope here."
        )

        gr.Examples(
            examples=EXAMPLES,
            inputs=[source_video, stylized_first_frame, prompt],
            outputs=[out_video, cond_video],
            fn=generate,
            cache_examples=True,
            cache_mode="lazy",
        )

    run.click(
        fn=generate,
        inputs=[
            source_video, stylized_first_frame, prompt,
            num_frames, frame_stride, num_inference_steps,
            cfg_scale, vace_scale, seed, sam_prompt,
        ],
        outputs=[out_video, cond_video],
    )

if __name__ == "__main__":
    demo.queue().launch(theme=gr.themes.Citrus(), css=CSS, mcp_server=True)