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