Spaces:
Paused
Paused
Switch apply-step from Wan-Animate to Wan2.1-VACE masked inpaint: regenerates ONLY the clothing region, copies face/hair/skin/motion/background from the source at original sharpness. UI: video + prompt -> video. VACE on 'large' (49 frames @6fps, ~265 quota incl remote FireRed) fits a FREE account. Boot probes reference_images + pack size. Removed dead wan_engine helpers; stopped purging the VACE model cache.
b95bfc8 verified | """ | |
| FireRed local engine β mirrors the working parent Space | |
| kulkas2pintu/FireRed-Image-Edit-1.0-Fast. | |
| The pipeline is loaded at MODULE IMPORT (top level), exactly like the parent, | |
| so ZeroGPU's `spaces` library virtualizes the CUDA placement. Loading it | |
| lazily inside the @spaces.GPU worker instead triggers: | |
| RuntimeError: NVML_SUCCESS == r INTERNAL ASSERT FAILED (CUDACachingAllocator) | |
| which is why the previous version always errored on the FireRed step. | |
| """ | |
| import os | |
| import gc | |
| import random | |
| import numpy as np | |
| import torch | |
| from PIL import Image | |
| import spaces # type: ignore | |
| MAX_SEED = np.iinfo(np.int32).max | |
| LANCZOS = getattr(Image, "Resampling", Image).LANCZOS | |
| device = torch.device("cuda" if torch.cuda.is_available() else "cpu") | |
| dtype = torch.bfloat16 | |
| print("[firered_engine] torch:", torch.__version__, "Β· device:", device) | |
| # NOTE: no prefetch snapshot_download here. from_pretrained (below) pulls only | |
| # the files it actually needs; a full-repo prefetch of the "AIO" Qwen repo | |
| # duplicates/expands storage and blew the Space's 150 GB disk limit. | |
| from diffusers import FlowMatchEulerDiscreteScheduler # noqa: F401 | |
| from firered import ( | |
| QwenImageEditPlusPipeline, | |
| QwenImageTransformer2DModel, | |
| QwenDoubleStreamAttnProcessorFA3, | |
| ) | |
| # Load once, at import β identical to the parent Space's proven pattern. | |
| pipe = QwenImageEditPlusPipeline.from_pretrained( | |
| "FireRedTeam/FireRed-Image-Edit-1.1", | |
| transformer=QwenImageTransformer2DModel.from_pretrained( | |
| "prithivMLmods/Qwen-Image-Edit-Rapid-AIO-V19", | |
| torch_dtype=dtype, | |
| device_map="cuda", | |
| ), | |
| torch_dtype=dtype, | |
| ).to(device) | |
| # FP8-quantize the transformer. FireRed AND the resident Wan-Animate pipeline are | |
| # both kept resident, and ZeroGPU packs ALL resident tensors to disk at startup; | |
| # in bf16 that pack overflowed the ~150 GB disk ("No space left on device" in | |
| # torch.pack). FP8 ~halves FireRed's footprint so both fit. The transformer is | |
| # already on-device (device_map="cuda"), so quantize in place β no later .to() | |
| # (torchao Float8Tensor can't be device-transferred). Non-fatal if unavailable. | |
| try: | |
| from torchao.quantization import ( | |
| quantize_ as _q8, Float8DynamicActivationFloat8WeightConfig as _F8, | |
| ) | |
| _q8(pipe.transformer, _F8()) | |
| print("[firered_engine] FP8 quantized transformer.") | |
| except Exception as _q8e: | |
| print(f"[firered_engine] FP8 quantization skipped: {_q8e}") | |
| # NOTE: the FA3 attention processor is intentionally NOT enabled here. | |
| # In this combined Space flash-attn IS installed (for Wan), so the FA3 kernels | |
| # would actually run β and on the ZeroGPU Blackwell GPU they hard-kill the | |
| # worker ("GPU task aborted", no Python traceback). The parent FireRed Space | |
| # has no flash-attn, so it silently falls back to default attention and works; | |
| # we do the same explicitly here for reliability. | |
| # (QwenDoubleStreamAttnProcessorFA3 imported above is left unused on purpose.) | |
| def _resize_to_qwen(image_pil: Image.Image, max_dim: int = 1024): | |
| """Round to multiples of 8, cap at 1024 on the longest side.""" | |
| w, h = image_pil.size | |
| if w > h: | |
| nw = max_dim | |
| nh = int(nw * h / w) | |
| else: | |
| nh = max_dim | |
| nw = int(nh * w / h) | |
| return (nw // 8) * 8, (nh // 8) * 8 | |
| # duration=70: the model is RESIDENT (loaded at boot), so a single-image edit at | |
| # 1024px / 4 steps only takes ~30-50s of actual GPU time. The old 200 (β400s | |
| # after the xlarge Γ2) both wasted quota and exceeded ZeroGPU's per-call max, | |
| # so every run was rejected with "requested GPU duration (400s) is larger than | |
| # the maximum allowed". 70 β ~140s reserved, comfortably under the cap. | |
| def firered_edit(image_pil, prompt, seed=0, randomize_seed=True, | |
| guidance_scale=1.0, steps=4): | |
| """ | |
| Edit an image with FireRed/Qwen-Image-Edit-Rapid. | |
| Returns (result_pil, seed_used). | |
| """ | |
| gc.collect() | |
| if torch.cuda.is_available(): | |
| torch.cuda.empty_cache() | |
| if image_pil is None: | |
| raise ValueError("image is required.") | |
| if not prompt or not str(prompt).strip(): | |
| raise ValueError("prompt is required.") | |
| if image_pil.mode != "RGB": | |
| if image_pil.mode in ("RGBA", "LA"): | |
| bg = Image.new("RGB", image_pil.size, (255, 255, 255)) | |
| mask = image_pil.split()[-1] if "A" in image_pil.mode else None | |
| bg.paste(image_pil, mask=mask) | |
| image_pil = bg | |
| else: | |
| image_pil = image_pil.convert("RGB") | |
| if randomize_seed: | |
| seed = random.randint(0, MAX_SEED) | |
| generator = torch.Generator(device=device).manual_seed(int(seed)) | |
| width, height = _resize_to_qwen(image_pil, max_dim=1024) | |
| negative_prompt = ( | |
| "worst quality, low quality, bad anatomy, bad hands, text, error, " | |
| "missing fingers, extra digit, fewer digits, cropped, jpeg artifacts, " | |
| "signature, watermark, username, blurry" | |
| ) | |
| try: | |
| result_image = pipe( | |
| image=[image_pil], | |
| prompt=prompt, | |
| negative_prompt=negative_prompt, | |
| height=height, | |
| width=width, | |
| num_inference_steps=int(steps), | |
| generator=generator, | |
| true_cfg_scale=float(guidance_scale), | |
| ).images[0] | |
| finally: | |
| gc.collect() | |
| if torch.cuda.is_available(): | |
| torch.cuda.empty_cache() | |
| return result_image, int(seed) | |
| # βββ Video (frame-by-frame) clothing edit βββββββββββββββββββββββββββββββββββ | |
| _NEG = ( | |
| "worst quality, low quality, bad anatomy, bad hands, text, error, " | |
| "missing fingers, extra digit, fewer digits, cropped, jpeg artifacts, " | |
| "signature, watermark, username, blurry" | |
| ) | |
| _ANCHOR = ( | |
| " Keep the new clothing identical in color, texture, pattern and style to " | |
| "the reference image. Keep the person's face, hair, body, pose and the " | |
| "background completely unchanged." | |
| ) | |
| def _edit_video_duration(frames_dir, out_dir, prompt, n_frames, seed=0, | |
| steps=4, guidance_scale=1.0, multiplier=1, progress=None): | |
| # Model is RESIDENT + FP8, so no packing/reload cost here β just ~7 s/keyframe | |
| # edit + fast RIFE. Kept well under ZeroGPU's per-call ceiling: with xlarge the | |
| # effective limit is ~290 (duration Γ2), so duration must stay β²145. At ~10 | |
| # keyframes this lands ~130 (β ~260 reserved). The app caps keyframes to match. | |
| return int(min(140, 40 + int(n_frames) * 9)) | |
| # size='xlarge' is REQUIRED β but NOT because of FireRed alone. `spaces` packs | |
| # ALL resident CUDA tensors process-wide into ONE GLOBAL pack (zero/torch/ | |
| # patching.py: cuda_aliases + tensor_packs are module globals) and worker_init | |
| # moves the WHOLE pack into EVERY worker, for every decorated function. | |
| # Post-FP8 FireRed is ~37.3 GB and WOULD fit `large`; FireRed + the resident Wan | |
| # pipeline is ~62 GB (57.7 GiB) vs a ~47.5 GiB `large` partition and HF's own | |
| # xlarge_threshold of 42.0 GiB. Forcing size='large' does not fail at schedule | |
| # time β it OOMs inside worker_init and surfaces as a bare "GPU task aborted". | |
| # (The old "~58 GB" figure was FireRed's PRE-FP8 size, so the conclusion was | |
| # right for the wrong reason. De-residenting FireRed does NOT help: its worker | |
| # still force-moves Wan's pack and cannot free it.) | |
| def firered_edit_video(frames_dir, out_dir, prompt, n_frames, seed=0, | |
| steps=4, guidance_scale=1.0, multiplier=1, progress=None): | |
| """ | |
| Edit the clothing on every extracted keyframe in `frames_dir` with the same | |
| prompt, RIFE-interpolate the result `multiplier`x for smooth motion, and | |
| write the final frames (frame_00000.png β¦) to `out_dir`. | |
| Frame 0 is edited from the prompt alone and becomes the appearance | |
| "anchor"; every later frame is edited with [frame, anchor] so the new outfit | |
| stays temporally consistent. All edits are forced to the first frame's size | |
| so they reassemble/interpolate cleanly. Returns `out_dir`. | |
| """ | |
| import glob as _glob | |
| gc.collect() | |
| if torch.cuda.is_available(): | |
| torch.cuda.empty_cache() | |
| os.makedirs(out_dir, exist_ok=True) | |
| frame_paths = sorted(_glob.glob(os.path.join(frames_dir, "*.png"))) | |
| out_size = None | |
| edited = [] | |
| # Appended to every prompt so Qwen keeps the original composition instead of | |
| # zooming/recomposing (which was destroying the framing on close-up video). | |
| # Prompt for the FIRST keyframe (edited from the prompt alone β the anchor). | |
| frame_prompt = (prompt.rstrip(". ") + | |
| ". Keep the exact same camera framing, zoom level, crop and " | |
| "composition as the input image; do not zoom in, and keep " | |
| "the background unchanged.") | |
| # Prompt for EVERY LATER keyframe: dress the person in this frame (image 1) in | |
| # the SAME outfit as the anchor (image 2), so the clothing is identical across | |
| # frames. Editing frames independently (the old behaviour) made every frame | |
| # come out differently β only the first looked right; this anchors them. | |
| anchored_prompt = ( | |
| prompt.rstrip(". ") + | |
| ". Put the person in the first image into the exact same outfit as the " | |
| "person in the second image β identical color, texture, pattern and " | |
| "style. Keep the first image's face, hair, body, pose, camera framing and " | |
| "background completely unchanged; do not zoom or recompose.") | |
| anchor_pil = None | |
| for fp in frame_paths: | |
| img = Image.open(fp).convert("RGB") | |
| # Full resolution (1024 px). Qwen needs ~1 MP to preserve composition; | |
| # at low res it recomposes/zooms, which broke the framing. | |
| width, height = _resize_to_qwen(img, max_dim=1024) | |
| generator = torch.Generator(device=device).manual_seed(int(seed)) | |
| if anchor_pil is None: | |
| # First keyframe β prompt only; its result becomes the outfit anchor. | |
| res = pipe( | |
| image=[img], | |
| prompt=frame_prompt, | |
| negative_prompt=_NEG, | |
| height=height, width=width, | |
| num_inference_steps=int(steps), | |
| generator=generator, | |
| true_cfg_scale=float(guidance_scale), | |
| ).images[0] | |
| anchor_pil = res | |
| else: | |
| # Later keyframes β [this frame, anchor] so the outfit matches frame 1. | |
| res = pipe( | |
| image=[img, anchor_pil], | |
| prompt=anchored_prompt, | |
| negative_prompt=_NEG, | |
| height=height, width=width, | |
| num_inference_steps=int(steps), | |
| generator=generator, | |
| true_cfg_scale=float(guidance_scale), | |
| ).images[0] | |
| if out_size is None: | |
| out_size = res.size | |
| elif res.size != out_size: | |
| res = res.resize(out_size, LANCZOS) | |
| edited.append(np.asarray(res).astype(np.float32) / 255.0) | |
| # RIFE-interpolate the edited keyframes up to a smoother fps (fast on GPU). | |
| if int(multiplier) > 1 and len(edited) >= 2: | |
| try: | |
| import rife_interp | |
| frames_out = rife_interp.interpolate_bits( | |
| np.stack(edited, axis=0), multiplier=int(multiplier)) | |
| except Exception as e: | |
| print(f"[firered] RIFE interpolation failed, keeping keyframes: {e}") | |
| frames_out = edited | |
| else: | |
| frames_out = edited | |
| for i, f in enumerate(frames_out): | |
| arr = (np.clip(f, 0.0, 1.0) * 255.0).astype(np.uint8) | |
| Image.fromarray(arr).save(os.path.join(out_dir, f"frame_{i:05d}.png")) | |
| gc.collect() | |
| if torch.cuda.is_available(): | |
| torch.cuda.empty_cache() | |
| return out_dir | |