multimodalart's picture
multimodalart HF Staff
Upload folder using huggingface_hub
9003c90 verified
Raw
History Blame
8.84 kB
# -*- coding: utf-8 -*-
"""
Bernini-R-S2V Talking Head — image + speech audio -> lip-synced talking video.
Bernini-R-S2V (rzgar/Bernini-R-S2V) is a speech-driven video extension of
Bernini-R, built on Wan 2.2 S2V. It adds single-speaker audio conditioning so a
still portrait can be animated to speak/sing in sync with an audio track.
The Bernini finetune ships as ComfyUI single-file checkpoints (dual high/low
noise Wan 2.2 MoE) plus a wav2vec2 audio encoder. There is no diffusers-format
export of those exact weights, and the community diffusers Wan 2.2 S2V pipeline
uses a single-transformer S2V architecture. To give the same
image + audio -> talking-video experience self-hosted on ZeroGPU, this Space
runs the base Wan 2.2 S2V pipeline that Bernini-R-S2V is finetuned from,
via the community WanSpeechToVideoPipeline (diffusers PR #12258).
"""
import os
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
import math
import tempfile
import spaces
import torch
import gradio as gr
from PIL import Image
from diffusers import AutoencoderKLWan, WanSpeechToVideoPipeline
from diffusers.utils import export_to_video, load_audio
try:
from diffusers.utils import export_to_merged_video_audio
HAS_MERGE = True
except Exception:
HAS_MERGE = False
from transformers import Wav2Vec2ForCTC
MODEL_ID = "tolgacangoz/Wan2.2-S2V-14B-Diffusers"
# Wan default negative prompt (Chinese) — steers away from low quality / artifacts.
NEG = ("色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,最差质量,"
"低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,畸形的,"
"毁容的,形态畸形的肢体,手指融合,静止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走")
DEFAULT_PROMPT = "a person talking to the camera, natural expression, slight head movement"
# Load the pipeline eagerly at module scope so ZeroGPU packs the weights and
# streams them into VRAM on the first @spaces.GPU call (rather than paying the
# load cost inside every request).
print("Loading Wan 2.2 S2V pipeline (base weights for Bernini-R-S2V)...")
audio_encoder = Wav2Vec2ForCTC.from_pretrained(MODEL_ID, subfolder="audio_encoder", dtype=torch.float32)
vae = AutoencoderKLWan.from_pretrained(MODEL_ID, subfolder="vae", torch_dtype=torch.float32)
pipe = WanSpeechToVideoPipeline.from_pretrained(
MODEL_ID, vae=vae, audio_encoder=audio_encoder, torch_dtype=torch.bfloat16
)
pipe.to("cuda")
print("Pipeline ready.")
def _snap_size(img, target_area, divisor=64, max_side=1280):
w, h = img.size
scale = math.sqrt(target_area / (w * h))
nw = max(divisor, min(max_side, int(round(w * scale / divisor) * divisor)))
nh = max(divisor, min(max_side, int(round(h * scale / divisor) * divisor)))
return nh, nw
def _audio_seconds(audio_path):
try:
import soundfile as sf
info = sf.info(audio_path)
return info.frames / info.samplerate
except Exception:
return 6.0
def _estimate_duration(image_path, audio_path, prompt, negative_prompt, steps,
guidance, res_area, max_seconds, seed, *args, **kwargs):
# Video length grows with audio length (chunked at ~5s / chunk). Estimate
# GPU seconds from the (capped) audio duration and step count.
try:
secs = min(float(max_seconds), _audio_seconds(audio_path)) if audio_path else 5.0
except Exception:
secs = 5.0
n_chunks = max(1, math.ceil(secs / 5.0))
per_chunk = 8.0 * (int(steps) / 20.0) * (1.0 if res_area == "480p" else 2.0)
return int(min(700, 45 + n_chunks * (12 + per_chunk * 6)))
@spaces.GPU(duration=_estimate_duration)
def generate(image_path, audio_path, prompt=DEFAULT_PROMPT, negative_prompt=NEG,
steps=20, guidance=4.5, res_area="480p", max_seconds=6, seed=42,
progress=gr.Progress(track_tqdm=True)):
"""Generate a lip-synced talking-head video from a portrait image and speech audio.
Args:
image_path: Path to the reference portrait image (a clear front-facing face works best).
audio_path: Path to the speech audio file (mono, clear speech gives the best lip-sync).
prompt: Text prompt describing the scene / motion.
negative_prompt: Things to avoid in the generation.
steps: Number of denoising steps (higher = slower, more detailed).
guidance: Classifier-free guidance scale.
res_area: Target resolution, "480p" or "720p".
max_seconds: Cap on how many seconds of audio to animate (controls video length / runtime).
seed: Random seed for reproducibility.
Returns:
Path to the generated MP4 video with the audio track muxed in.
"""
if not image_path:
raise gr.Error("Please provide a portrait image first.")
if not audio_path:
raise gr.Error("Please provide a speech audio file first.")
image = Image.open(image_path).convert("RGB")
audio, sampling_rate = load_audio(audio_path)
# Trim overly long audio to keep generation within the GPU time budget.
max_seconds = float(max_seconds)
max_samples = int(max_seconds * sampling_rate)
if audio.shape[0] > max_samples:
audio = audio[:max_samples]
target = 480 * 832 if res_area == "480p" else 720 * 1280
h, w = _snap_size(image, target_area=target)
gen = torch.Generator(device="cuda").manual_seed(int(seed))
frames = pipe(
image=image,
audio=audio,
sampling_rate=sampling_rate,
prompt=(prompt or DEFAULT_PROMPT),
negative_prompt=(negative_prompt or NEG),
height=h,
width=w,
num_frames_per_chunk=80,
num_inference_steps=int(steps),
guidance_scale=float(guidance),
generator=gen,
).frames[0]
out = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False).name
export_to_video(frames, out, fps=16)
if HAS_MERGE:
try:
export_to_merged_video_audio(out, audio_path)
except Exception as e:
print("merge audio failed:", e)
return out
CSS = """
#col-container { max-width: 1150px; margin: 0 auto; }
.dark .gradio-container { color: var(--body-text-color); }
"""
with gr.Blocks(theme=gr.themes.Citrus(), css=CSS) as demo:
with gr.Column(elem_id="col-container"):
gr.Markdown(
"""
# 🎤 Bernini-R-S2V — Talking Head
Animate a **portrait + speech audio** into a **lip-synced talking video**.
Powered by [Bernini-R-S2V](https://huggingface.co/rzgar/Bernini-R-S2V) — a speech-driven
video extension of Bernini-R built on **Wan 2.2 S2V**. Give it a clear front-facing face
and a mono speech clip; keep audio to ~3–10 s for the best sync and fastest results.
"""
)
with gr.Row():
with gr.Column():
image = gr.Image(type="filepath", label="Portrait image (clear front-facing face)")
audio = gr.Audio(type="filepath", label="Speech audio (mono, clear speech)")
prompt = gr.Textbox(label="Prompt", value=DEFAULT_PROMPT)
with gr.Accordion("Advanced settings", open=False):
negative_prompt = gr.Textbox(label="Negative prompt", value=NEG, lines=2)
res_area = gr.Radio(["480p", "720p"], value="480p",
label="Resolution (720p is slower)")
steps = gr.Slider(8, 40, value=20, step=1, label="Steps")
guidance = gr.Slider(1.0, 8.0, value=4.5, step=0.5, label="Guidance scale")
max_seconds = gr.Slider(2, 12, value=6, step=1,
label="Max audio seconds to animate")
seed = gr.Number(value=42, label="Seed", precision=0)
btn = gr.Button("Generate talking video", variant="primary")
with gr.Column():
out_video = gr.Video(label="Result")
gr.Examples(
examples=[
["examples/man.jpg", "examples/speech_short.wav"],
["examples/woman.jpg", "examples/speech_med.wav"],
["examples/elder.jpg", "examples/speech_short.wav"],
],
inputs=[image, audio],
outputs=out_video,
fn=generate,
cache_examples=True,
cache_mode="lazy",
)
btn.click(
generate,
inputs=[image, audio, prompt, negative_prompt, steps, guidance, res_area, max_seconds, seed],
outputs=out_video,
api_name="generate",
)
if __name__ == "__main__":
demo.queue().launch(mcp_server=True)