| import gc |
| import random |
| import tempfile |
| from functools import lru_cache |
| from typing import List, Optional, Tuple |
|
|
| import gradio as gr |
| import numpy as np |
| import torch |
| from PIL import Image |
| from briarmbg import BriaRMBG |
| from diffusers import AutoencoderKLWan, UniPCMultistepScheduler, WanVACEPipeline |
| from diffusers.utils import export_to_video |
|
|
| MODEL_ID = "Wan-AI/Wan2.1-VACE-14B-diffusers" |
| RMBG_ID = "briaai/RMBG-1.4" |
| LORA_REPO = "vrgamedevgirl84/Wan14BT2VFusioniX" |
| LORA_FILE = "FusionX_LoRa/Phantom_Wan_14B_FusionX_LoRA.safetensors" |
| LORA_ADAPTER = "phantom" |
|
|
| DEVICE = "cuda" if torch.cuda.is_available() else "cpu" |
| PIPE_DTYPE = torch.bfloat16 if DEVICE == "cuda" else torch.float32 |
| VAE_DTYPE = torch.float32 |
| RMBG_DTYPE = torch.float32 |
|
|
| MOD_VALUE = 32 |
| DEFAULT_H_SLIDER_VALUE = 512 |
| DEFAULT_W_SLIDER_VALUE = 896 |
| NEW_FORMULA_MAX_AREA = 480.0 * 832.0 |
| SLIDER_MIN_H, SLIDER_MAX_H = 128, 896 |
| SLIDER_MIN_W, SLIDER_MAX_W = 128, 896 |
| MAX_SEED = np.iinfo(np.int32).max |
| FIXED_FPS = 16 |
| MIN_FRAMES_MODEL = 8 |
| MAX_FRAMES_MODEL = 81 |
|
|
| MODE_PROMPTS = { |
| "Reference": "the playful penguin picks up the green cat eye sunglasses and puts them on", |
| "First - Last Frame": "CG animation style, a small blue bird takes off from the ground, flapping its wings. The bird's feathers are delicate, with a unique pattern on its chest. The background shows a blue sky with white clouds under bright sunshine. The camera follows the bird upward, capturing its flight and the vastness of the sky from a close-up, low-angle perspective.", |
| "Random Transitions": "Various different characters appear and disappear in a fast transition video showcasing their unique features and personalities. The video is about showcasing different dance styles, with each character performing a distinct dance move. The background is a vibrant, colorful stage with dynamic lighting that changes with each dance style. The camera captures close-ups of the dancers' expressions and movements. Highly dynamic, fast-paced music video, with quick cuts and transitions between characters, cinematic, vibrant colors", |
| } |
|
|
| DEFAULT_NEGATIVE_PROMPT = ( |
| "Bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, " |
| "static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, " |
| "poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, " |
| "messy background, three legs, many people in the background, walking backwards, watermark, text, signature" |
| ) |
|
|
|
|
| def clear_cuda(): |
| gc.collect() |
| if torch.cuda.is_available(): |
| torch.cuda.empty_cache() |
| if hasattr(torch.cuda, "ipc_collect"): |
| torch.cuda.ipc_collect() |
|
|
|
|
| @lru_cache(maxsize=1) |
| def get_pipe() -> WanVACEPipeline: |
| vae = AutoencoderKLWan.from_pretrained(MODEL_ID, subfolder="vae", torch_dtype=VAE_DTYPE) |
| pipe = WanVACEPipeline.from_pretrained(MODEL_ID, vae=vae, torch_dtype=PIPE_DTYPE) |
| pipe.scheduler = UniPCMultistepScheduler.from_config(pipe.scheduler.config, flow_shift=2.0) |
| pipe.load_lora_weights(LORA_REPO, weight_name=LORA_FILE, adapter_name=LORA_ADAPTER) |
| try: |
| pipe.set_adapters([LORA_ADAPTER], adapter_weights=[1.0]) |
| except Exception: |
| pass |
| pipe = pipe.to(DEVICE) |
| return pipe |
|
|
|
|
| @lru_cache(maxsize=1) |
| def get_rmbg() -> BriaRMBG: |
| model = BriaRMBG.from_pretrained(RMBG_ID) |
| model = model.to(DEVICE, dtype=RMBG_DTYPE) |
| model.eval() |
| return model |
|
|
|
|
| def remove_alpha_channel(image: Image.Image) -> Image.Image: |
| if image.mode in ("RGBA", "LA"): |
| background = Image.new("RGB", image.size, (255, 255, 255)) |
| paste_source = image if image.mode == "RGBA" else image.convert("RGB") |
| background.paste(paste_source, mask=image.split()[-1]) |
| return background |
| if image.mode == "P": |
| if "transparency" in image.info: |
| image = image.convert("RGBA") |
| background = Image.new("RGB", image.size, (255, 255, 255)) |
| background.paste(image, mask=image.split()[-1]) |
| return background |
| return image.convert("RGB") |
| if image.mode != "RGB": |
| return image.convert("RGB") |
| return image |
|
|
|
|
| def numpy2pytorch(imgs: List[np.ndarray]) -> torch.Tensor: |
| h = torch.from_numpy(np.stack(imgs, axis=0)).float() / 127.0 - 1.0 |
| return h.movedim(-1, 1) |
|
|
|
|
| def resize_without_crop(image: np.ndarray, target_width: int, target_height: int) -> np.ndarray: |
| return np.array(Image.fromarray(image).resize((target_width, target_height), Image.LANCZOS)) |
|
|
|
|
| def run_rmbg(img: np.ndarray, sigma: float = 0.0) -> Tuple[np.ndarray, np.ndarray]: |
| model = get_rmbg() |
| h, w, c = img.shape |
| if c != 3: |
| raise ValueError("RMBG expects RGB input") |
| k = (256.0 / float(h * w)) ** 0.5 |
| feed = resize_without_crop(img, int(64 * round(w * k)), int(64 * round(h * k))) |
| feed = numpy2pytorch([feed]).to(device=DEVICE, dtype=RMBG_DTYPE) |
| with torch.inference_mode(): |
| alpha = model(feed)[0][0] |
| alpha = torch.nn.functional.interpolate(alpha, size=(h, w), mode="bilinear") |
| alpha = alpha.movedim(1, -1)[0].detach().float().cpu().numpy().clip(0, 1) |
| result = 127 + (img.astype(np.float32) - 127 + sigma) * alpha |
| return result.clip(0, 255).astype(np.uint8), alpha |
|
|
|
|
| def remove_background_from_image(image: Image.Image) -> Image.Image: |
| image = remove_alpha_channel(image) |
| result_array, alpha_mask = run_rmbg(np.array(image)) |
| result_image = Image.fromarray(result_array).convert("RGBA") |
| alpha_mask_2d = np.squeeze(alpha_mask) |
| if alpha_mask_2d.ndim > 2: |
| alpha_mask_2d = alpha_mask_2d[:, :, 0] |
| alpha_array = (alpha_mask_2d * 255).astype(np.uint8) |
| result_image.putalpha(Image.fromarray(alpha_array, "L")) |
| return result_image |
|
|
|
|
| def calculate_new_dimensions_wan( |
| pil_image: Image.Image, |
| mod_val: int, |
| calculation_max_area: float, |
| min_slider_h: int, |
| max_slider_h: int, |
| min_slider_w: int, |
| max_slider_w: int, |
| default_h: int, |
| default_w: int, |
| ) -> Tuple[int, int]: |
| orig_w, orig_h = pil_image.size |
| if orig_w <= 0 or orig_h <= 0: |
| return default_h, default_w |
| aspect_ratio = orig_h / orig_w |
| calc_h = round(np.sqrt(calculation_max_area * aspect_ratio)) |
| calc_w = round(np.sqrt(calculation_max_area / aspect_ratio)) |
| calc_h = max(mod_val, (calc_h // mod_val) * mod_val) |
| calc_w = max(mod_val, (calc_w // mod_val) * mod_val) |
| new_h = int(np.clip(calc_h, min_slider_h, (max_slider_h // mod_val) * mod_val)) |
| new_w = int(np.clip(calc_w, min_slider_w, (max_slider_w // mod_val) * mod_val)) |
| return new_h, new_w |
|
|
|
|
| def handle_gallery_upload_for_dims_wan(gallery_images, current_h_val, current_w_val): |
| if not gallery_images: |
| return gr.update(value=DEFAULT_H_SLIDER_VALUE), gr.update(value=DEFAULT_W_SLIDER_VALUE) |
| try: |
| first_image = gallery_images[0][0] if isinstance(gallery_images[0], (list, tuple)) else gallery_images[0] |
| first_image = remove_alpha_channel(first_image) |
| new_h, new_w = calculate_new_dimensions_wan( |
| first_image, |
| MOD_VALUE, |
| NEW_FORMULA_MAX_AREA, |
| SLIDER_MIN_H, |
| SLIDER_MAX_H, |
| SLIDER_MIN_W, |
| SLIDER_MAX_W, |
| DEFAULT_H_SLIDER_VALUE, |
| DEFAULT_W_SLIDER_VALUE, |
| ) |
| return gr.update(value=new_h), gr.update(value=new_w) |
| except Exception: |
| gr.Warning("Error attempting to calculate new dimensions") |
| return gr.update(value=DEFAULT_H_SLIDER_VALUE), gr.update(value=DEFAULT_W_SLIDER_VALUE) |
|
|
|
|
| def update_prompt_from_mode(mode: str) -> str: |
| return MODE_PROMPTS.get(mode, "") |
|
|
|
|
| def prepare_video_and_mask_ref2v(height: int, width: int, num_frames: int): |
| frames = [Image.new("RGB", (width, height), (128, 128, 128)) for _ in range(num_frames)] |
| mask_white = Image.new("L", (width, height), 255) |
| mask = [mask_white for _ in range(num_frames)] |
| return frames, mask |
|
|
|
|
| def prepare_video_and_mask_flf2v(first_img: Image.Image, last_img: Image.Image, height: int, width: int, num_frames: int): |
| first_img = remove_alpha_channel(first_img).resize((width, height)) |
| last_img = remove_alpha_channel(last_img).resize((width, height)) |
| frames = [first_img] |
| frames.extend([Image.new("RGB", (width, height), (128, 128, 128)) for _ in range(num_frames - 2)]) |
| frames.append(last_img) |
| mask_black = Image.new("L", (width, height), 0) |
| mask_white = Image.new("L", (width, height), 255) |
| mask = [mask_black] + [mask_white for _ in range(num_frames - 2)] + [mask_black] |
| return frames, mask |
|
|
|
|
| def calculate_random2v_frame_indices(num_images: int, num_frames: int) -> List[int]: |
| if num_images <= 0: |
| return [] |
| if num_images == 1: |
| return [num_frames // 2] |
| if num_images >= num_frames: |
| return list(range(num_frames)) |
| indices = [] |
| step = (num_frames - 1) / (num_images - 1) |
| for i in range(num_images): |
| frame_idx = min(int(round(i * step)), num_frames - 1) |
| indices.append(frame_idx) |
| seen = set() |
| unique_indices = [] |
| for idx in indices: |
| if idx not in seen: |
| seen.add(idx) |
| unique_indices.append(idx) |
| return unique_indices |
|
|
|
|
| def prepare_video_and_mask_random2v(images: List[Image.Image], frame_indices: List[int], height: int, width: int, num_frames: int): |
| clean_images = [remove_alpha_channel(img).resize((width, height)) for img in images] |
| frames = [Image.new("RGB", (width, height), (128, 128, 128)) for _ in range(num_frames)] |
| mask_black = Image.new("L", (width, height), 0) |
| mask_white = Image.new("L", (width, height), 255) |
| mask = [mask_white for _ in range(num_frames)] |
| for img, idx in zip(clean_images, frame_indices): |
| if idx >= num_frames: |
| raise ValueError(f"Frame index {idx} exceeds num_frames {num_frames}") |
| frames[idx] = img |
| mask[idx] = mask_black |
| return frames, mask |
|
|
|
|
| def normalize_gallery_images(gallery_images) -> List[Image.Image]: |
| images: List[Image.Image] = [] |
| for item in gallery_images or []: |
| if isinstance(item, (list, tuple)) and item: |
| image = item[0] |
| else: |
| image = item |
| if not isinstance(image, Image.Image): |
| raise gr.Error("Gallery contains an invalid image item.") |
| images.append(image) |
| return images |
|
|
|
|
| def generate_video( |
| gallery_images, |
| mode, |
| prompt, |
| height, |
| width, |
| negative_prompt=DEFAULT_NEGATIVE_PROMPT, |
| duration_seconds=2.0, |
| guidance_scale=1.0, |
| steps=6, |
| seed=42, |
| randomize_seed=False, |
| remove_bg=False, |
| progress=gr.Progress(track_tqdm=True), |
| ): |
| images = normalize_gallery_images(gallery_images) |
| if not images: |
| raise gr.Error("Please upload at least one image to the gallery.") |
|
|
| processed_images: List[Image.Image] = [] |
| for image in images: |
| if mode == "Reference" and remove_bg: |
| image = remove_background_from_image(image) |
| image = remove_alpha_channel(image) |
| processed_images.append(image) |
|
|
| if mode == "First - Last Frame": |
| if len(processed_images) < 2: |
| raise gr.Error(f"First - Last Frame mode requires at least 2 images, but only {len(processed_images)} were supplied.") |
| processed_images = processed_images[:2] |
|
|
| target_h = max(MOD_VALUE, (int(height) // MOD_VALUE) * MOD_VALUE) |
| target_w = max(MOD_VALUE, (int(width) // MOD_VALUE) * MOD_VALUE) |
| num_frames = int(np.clip(int(round(duration_seconds * FIXED_FPS)), MIN_FRAMES_MODEL, MAX_FRAMES_MODEL)) |
| current_seed = random.randint(0, MAX_SEED) if randomize_seed else int(seed) |
|
|
| if mode == "First - Last Frame": |
| frames, mask = prepare_video_and_mask_flf2v(processed_images[0], processed_images[1], target_h, target_w, num_frames) |
| reference_images = None |
| elif mode == "Reference": |
| frames, mask = prepare_video_and_mask_ref2v(target_h, target_w, num_frames) |
| reference_images = processed_images |
| else: |
| frame_indices = calculate_random2v_frame_indices(len(processed_images), num_frames) |
| frames, mask = prepare_video_and_mask_random2v(processed_images, frame_indices, target_h, target_w, num_frames) |
| reference_images = None |
|
|
| pipe = get_pipe() |
| clear_cuda() |
|
|
| generator_device = DEVICE if DEVICE == "cuda" else "cpu" |
| generator = torch.Generator(device=generator_device).manual_seed(current_seed) |
|
|
| with torch.inference_mode(): |
| output_frames_list = pipe( |
| video=frames, |
| mask=mask, |
| reference_images=reference_images, |
| prompt=prompt, |
| negative_prompt=negative_prompt, |
| height=target_h, |
| width=target_w, |
| num_frames=num_frames, |
| guidance_scale=float(guidance_scale), |
| num_inference_steps=int(steps), |
| generator=generator, |
| ).frames[0] |
|
|
| with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tmpfile: |
| video_path = tmpfile.name |
| export_to_video(output_frames_list, video_path, fps=FIXED_FPS) |
|
|
| clear_cuda() |
| return video_path, current_seed |
|
|
|
|
| control_modes = """ |
| **3 Control Modes Available:** |
| - **Reference** Generate a video incorporating elements from input reference images |
| - **First - Last Frame** Generate a video using first and last frame conditioning defined by input images |
| - **Random Transitions** Generate a video with intermediate transitions between multiple input images |
| """ |
|
|
|
|
| with gr.Blocks() as demo: |
| gr.Markdown("# Fast 6 step Wan 2.1 VACE (14B)") |
| gr.Markdown( |
| "Using [**Wan2.1-VACE-14B**](https://huggingface.co/Wan-AI/Wan2.1-VACE-14B-diffusers) + " |
| "[**FusionX Phantom LoRA**](https://huggingface.co/vrgamedevgirl84/Wan14BT2VFusioniX) for fast video generation with multiple conditions." |
| ) |
| gr.Markdown(control_modes) |
|
|
| with gr.Row(): |
| with gr.Column(): |
| with gr.Group(): |
| mode_radio = gr.Radio( |
| choices=["Reference", "First - Last Frame", "Random Transitions"], |
| value="Reference", |
| label="Control Mode", |
| ) |
| gallery_component = gr.Gallery( |
| label="Upload 1 or more images", |
| show_label=True, |
| elem_id="gallery", |
| columns=3, |
| rows=2, |
| object_fit="contain", |
| height="auto", |
| type="pil", |
| allow_preview=True, |
| ) |
| remove_bg_checkbox = gr.Checkbox( |
| label="Remove Background", |
| value=False, |
| visible=True, |
| info="Removes background from input images. Best used in Reference mode.", |
| ) |
| prompt_input = gr.Textbox(label="Prompt", value=MODE_PROMPTS["Reference"]) |
| duration_seconds_input = gr.Slider( |
| minimum=round(MIN_FRAMES_MODEL / FIXED_FPS, 1), |
| maximum=round(MAX_FRAMES_MODEL / FIXED_FPS, 1), |
| step=0.1, |
| value=2.3, |
| label="Duration (seconds)", |
| info=f"Clamped to model's {MIN_FRAMES_MODEL}-{MAX_FRAMES_MODEL} frames at {FIXED_FPS} fps.", |
| ) |
| with gr.Accordion("Advanced Settings", open=False): |
| negative_prompt_input = gr.Textbox(label="Negative Prompt", value=DEFAULT_NEGATIVE_PROMPT, lines=3) |
| seed_input = gr.Slider(label="Seed", minimum=0, maximum=MAX_SEED, step=1, value=42, interactive=True) |
| randomize_seed_checkbox = gr.Checkbox(label="Randomize seed", value=True, interactive=True) |
| with gr.Row(): |
| height_input = gr.Slider(minimum=SLIDER_MIN_H, maximum=SLIDER_MAX_H, step=MOD_VALUE, value=DEFAULT_H_SLIDER_VALUE, label=f"Output Height (multiple of {MOD_VALUE})") |
| width_input = gr.Slider(minimum=SLIDER_MIN_W, maximum=SLIDER_MAX_W, step=MOD_VALUE, value=DEFAULT_W_SLIDER_VALUE, label=f"Output Width (multiple of {MOD_VALUE})") |
| steps_slider = gr.Slider(minimum=1, maximum=10, step=1, value=6, label="Inference Steps") |
| guidance_scale_input = gr.Slider(minimum=0.0, maximum=5.0, step=0.5, value=1.0, label="Guidance Scale", visible=False) |
| generate_button = gr.Button("Generate Video", variant="primary") |
|
|
| with gr.Column(): |
| video_output = gr.Video(label="Generated Video", autoplay=True, interactive=False) |
|
|
| def update_bg_removal_visibility(mode: str): |
| return gr.update(visible=(mode == "Reference")) |
|
|
| mode_radio.change(fn=update_prompt_from_mode, inputs=[mode_radio], outputs=[prompt_input]) |
| mode_radio.change(fn=update_bg_removal_visibility, inputs=[mode_radio], outputs=[remove_bg_checkbox]) |
| gallery_component.change( |
| fn=handle_gallery_upload_for_dims_wan, |
| inputs=[gallery_component, height_input, width_input], |
| outputs=[height_input, width_input], |
| ) |
|
|
| ui_inputs = [ |
| gallery_component, |
| mode_radio, |
| prompt_input, |
| height_input, |
| width_input, |
| negative_prompt_input, |
| duration_seconds_input, |
| guidance_scale_input, |
| steps_slider, |
| seed_input, |
| randomize_seed_checkbox, |
| remove_bg_checkbox, |
| ] |
|
|
| generate_button.click(fn=generate_video, inputs=ui_inputs, outputs=[video_output, seed_input]) |
|
|
| if __name__ == "__main__": |
| demo.queue().launch(mcp_server=True) |