Video_to_video_WAN / firered_engine.py
kulkas2pintu's picture
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
Raw
History Blame Contribute Delete
11.9 kB
"""
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.
@spaces.GPU(duration=70, size='xlarge')
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.)
@spaces.GPU(duration=_edit_video_duration, size='xlarge')
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