Spaces:
Running on Zero
Running on Zero
| # -*- 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))) | |
| 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) | |