Spaces:
Running on Zero
Running on Zero
| # -*- coding: utf-8 -*- | |
| import csv | |
| import gc | |
| import os | |
| import random | |
| import shutil | |
| import subprocess | |
| import sys | |
| import uuid | |
| from pathlib import Path | |
| import gradio as gr | |
| import spaces | |
| import torch | |
| from huggingface_hub import snapshot_download | |
| ROOT = Path(__file__).resolve().parent | |
| SOURCE_DIR = ROOT / "HunyuanVideo-Avatar" | |
| WEIGHTS_DIR = ROOT / "weights" | |
| OUTPUT_DIR = ROOT / "outputs" | |
| SOURCE_REPO = "https://github.com/Tencent-Hunyuan/HunyuanVideo-Avatar.git" | |
| MODEL_REPO = "tencent/HunyuanVideo-Avatar" | |
| FPS = 25 | |
| FRAME_OPTIONS = { | |
| "约 2 秒(49 帧,推荐首测)": 49, | |
| "约 3 秒(73 帧)": 73, | |
| "约 4 秒(97 帧)": 97, | |
| "约 5 秒(129 帧,官方配置)": 129, | |
| } | |
| os.environ.setdefault("GRADIO_SSR_MODE", "0") | |
| os.environ.setdefault("GRADIO_ANALYTICS_ENABLED", "False") | |
| os.environ.setdefault("TOKENIZERS_PARALLELISM", "false") | |
| os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True") | |
| os.environ.setdefault("HF_HUB_ENABLE_HF_TRANSFER", "1") | |
| def ensure_source(): | |
| if not (SOURCE_DIR / "hymm_sp" / "sample_gpu_poor.py").is_file(): | |
| print("[SETUP] Cloning official HunyuanVideo-Avatar source...", flush=True) | |
| subprocess.run( | |
| ["git", "clone", "--depth", "1", SOURCE_REPO, str(SOURCE_DIR)], | |
| check=True, | |
| ) | |
| # The official `--infer-min` path hard-codes 129 frames. Make it honor the | |
| # requested evaluation length so a Space can run short, lower-cost tests. | |
| sample_file = SOURCE_DIR / "hymm_sp" / "sample_gpu_poor.py" | |
| source = sample_file.read_text(encoding="utf-8") | |
| patched = source.replace( | |
| 'batch["audio_len"][0] = 129', | |
| 'batch["audio_len"][0] = args.sample_n_frames', | |
| ) | |
| if patched != source: | |
| sample_file.write_text(patched, encoding="utf-8") | |
| print("[SETUP] Patched infer-min to honor --sample-n-frames", flush=True) | |
| ensure_source() | |
| OUTPUT_DIR.mkdir(parents=True, exist_ok=True) | |
| MODEL_PATTERNS = [ | |
| "ckpts/config.json", | |
| "ckpts/det_align/detface.pt", | |
| "ckpts/hunyuan-video-t2v-720p/transformers/mp_rank_00_model_states_fp8.pt", | |
| "ckpts/hunyuan-video-t2v-720p/transformers/mp_rank_00_model_states_fp8_map.pt", | |
| "ckpts/hunyuan-video-t2v-720p/vae/config.json", | |
| "ckpts/hunyuan-video-t2v-720p/vae/pytorch_model.pt", | |
| "ckpts/llava_llama_image/*.json", | |
| "ckpts/llava_llama_image/*.safetensors", | |
| "ckpts/llava_llama_image/*.model", | |
| "ckpts/text_encoder_2/config.json", | |
| "ckpts/text_encoder_2/model.safetensors", | |
| "ckpts/text_encoder_2/*.json", | |
| "ckpts/text_encoder_2/*.txt", | |
| "ckpts/whisper-tiny/config.json", | |
| "ckpts/whisper-tiny/model.safetensors", | |
| "ckpts/whisper-tiny/preprocessor_config.json", | |
| ] | |
| def ensure_weights(): | |
| checkpoint = ( | |
| WEIGHTS_DIR | |
| / "ckpts/hunyuan-video-t2v-720p/transformers/mp_rank_00_model_states_fp8.pt" | |
| ) | |
| if checkpoint.is_file(): | |
| print(f"[MODEL CACHE] FP8 checkpoint ready: {checkpoint}", flush=True) | |
| return checkpoint | |
| WEIGHTS_DIR.mkdir(parents=True, exist_ok=True) | |
| print( | |
| "[MODEL DOWNLOAD START] repo=tencent/HunyuanVideo-Avatar, " | |
| "profile=FP8-minimal, expected_size≈45GB", | |
| flush=True, | |
| ) | |
| snapshot_download( | |
| repo_id=MODEL_REPO, | |
| local_dir=WEIGHTS_DIR, | |
| allow_patterns=MODEL_PATTERNS, | |
| ) | |
| if not checkpoint.is_file(): | |
| raise RuntimeError("FP8 checkpoint download did not complete") | |
| print(f"[MODEL DOWNLOAD DONE] checkpoint={checkpoint}", flush=True) | |
| return checkpoint | |
| def estimate_gpu_duration(_image, _audio, _prompt, frame_profile, *_args): | |
| frames = FRAME_OPTIONS.get(frame_profile, 49) | |
| # Includes first-run model download and CPU-offloaded inference. | |
| return max(900, min(3600, 1200 + frames * 16)) | |
| def normalize_media(image_path, audio_path, work_dir): | |
| image_target = work_dir / "character.png" | |
| audio_target = work_dir / "speech.wav" | |
| shutil.copy2(image_path, image_target) | |
| subprocess.run( | |
| [ | |
| "ffmpeg", "-y", "-i", str(audio_path), "-ac", "1", "-ar", "16000", | |
| "-c:a", "pcm_s16le", str(audio_target), | |
| ], | |
| check=True, | |
| stdout=subprocess.DEVNULL, | |
| stderr=subprocess.PIPE, | |
| ) | |
| return image_target, audio_target | |
| def generate(image, audio, prompt, frame_profile, seed, progress=gr.Progress()): | |
| if not image: | |
| raise gr.Error("请上传一张角色图片") | |
| if not audio: | |
| raise gr.Error("请上传驱动语音") | |
| frames = FRAME_OPTIONS.get(frame_profile, 49) | |
| actual_seed = random.randint(0, 2**31 - 1) if int(seed) < 0 else int(seed) | |
| job_id = uuid.uuid4().hex | |
| job_dir = OUTPUT_DIR / job_id | |
| result_dir = job_dir / "result" | |
| job_dir.mkdir(parents=True, exist_ok=True) | |
| result_dir.mkdir(parents=True, exist_ok=True) | |
| try: | |
| progress(0.02, desc="检查并下载官方 FP8 模型(首次约 45GB)...") | |
| checkpoint = ensure_weights() | |
| progress(0.12, desc="预处理图片和音频...") | |
| image_path, audio_path = normalize_media(image, audio, job_dir) | |
| meta_path = job_dir / "input.csv" | |
| safe_prompt = (prompt or "A cartoon character speaks to the camera.").strip() | |
| with meta_path.open("w", encoding="utf-8-sig", newline="") as handle: | |
| writer = csv.DictWriter( | |
| handle, | |
| fieldnames=["videoid", "image", "audio", "prompt", "fps"], | |
| ) | |
| writer.writeheader() | |
| writer.writerow( | |
| { | |
| "videoid": job_id, | |
| "image": str(image_path), | |
| "audio": str(audio_path), | |
| "prompt": safe_prompt, | |
| "fps": FPS, | |
| } | |
| ) | |
| command = [ | |
| sys.executable, | |
| str(SOURCE_DIR / "hymm_sp" / "sample_gpu_poor.py"), | |
| "--input", str(meta_path), | |
| "--ckpt", str(checkpoint), | |
| "--sample-n-frames", str(frames), | |
| "--seed", str(actual_seed), | |
| "--image-size", "704", | |
| "--cfg-scale", "7.5", | |
| "--infer-steps", "50", | |
| "--use-deepcache", "1", | |
| "--flow-shift-eval-video", "5.0", | |
| "--save-path", str(result_dir), | |
| "--use-fp8", | |
| "--cpu-offload", | |
| "--infer-min", | |
| ] | |
| env = os.environ.copy() | |
| env.update( | |
| { | |
| "MODEL_BASE": str(WEIGHTS_DIR), | |
| "CPU_OFFLOAD": "1", | |
| "DISABLE_SP": "1", | |
| "PYTHONPATH": str(SOURCE_DIR), | |
| } | |
| ) | |
| print("[INFERENCE START] " + " ".join(command), flush=True) | |
| progress(0.18, desc="加载 FP8 模型并生成视频,可能需要较长时间...") | |
| process = subprocess.run( | |
| command, | |
| cwd=SOURCE_DIR, | |
| env=env, | |
| text=True, | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.STDOUT, | |
| timeout=3500, | |
| ) | |
| print(process.stdout, flush=True) | |
| if process.returncode != 0: | |
| tail = "\n".join(process.stdout.splitlines()[-25:]) | |
| raise RuntimeError(f"官方推理进程退出码 {process.returncode}\n{tail}") | |
| output = result_dir / f"{job_id}_audio.mp4" | |
| if not output.is_file(): | |
| candidates = sorted(result_dir.glob("*_audio.mp4")) | |
| if not candidates: | |
| raise RuntimeError("推理完成,但没有找到带音频的 MP4 输出") | |
| output = candidates[-1] | |
| progress(1.0, desc="生成完成") | |
| info = ( | |
| f"完成:{frames} 帧 / {FPS}fps(约 {frames / FPS:.1f} 秒)," | |
| f"704px,50 steps,FP8 + CPU offload。" | |
| ) | |
| return str(output), actual_seed, info | |
| except subprocess.TimeoutExpired as exc: | |
| raise gr.Error("生成超过 ZeroGPU 最长执行时间,请改用 49 帧重试") from exc | |
| except gr.Error: | |
| raise | |
| except Exception as exc: | |
| print(f"[ERROR] {exc}", flush=True) | |
| raise gr.Error(f"生成失败:{exc}") from exc | |
| finally: | |
| gc.collect() | |
| if torch.cuda.is_available(): | |
| torch.cuda.empty_cache() | |
| with gr.Blocks(title="HunyuanVideo-Avatar 卡通数字人测试") as demo: | |
| gr.Markdown( | |
| """ | |
| # HunyuanVideo-Avatar 卡通数字人测试 | |
| 上传一张卡通/3D/拟人角色图片和一段语音,评估角色一致性、口型、表情和身体动作。 | |
| **首次运行需要下载约 45GB 官方权重。建议先选 49 帧短片。** | |
| """ | |
| ) | |
| with gr.Row(): | |
| with gr.Column(): | |
| image_input = gr.Image(label="角色图片", type="filepath") | |
| audio_input = gr.Audio(label="驱动语音", type="filepath") | |
| prompt_input = gr.Textbox( | |
| label="英文画面描述", | |
| value="A cute cartoon character speaks naturally to the camera with subtle gestures.", | |
| lines=3, | |
| info="描述角色、构图、背景和期望动作;不要写与原图冲突的外观。", | |
| ) | |
| frame_input = gr.Dropdown( | |
| choices=list(FRAME_OPTIONS), | |
| value=list(FRAME_OPTIONS)[0], | |
| label="测试时长", | |
| ) | |
| seed_input = gr.Number(label="随机种子(-1 为随机)", value=-1, precision=0) | |
| generate_button = gr.Button("生成测试视频", variant="primary") | |
| with gr.Column(): | |
| video_output = gr.Video(label="生成结果") | |
| seed_output = gr.Number(label="实际种子", precision=0) | |
| info_output = gr.Textbox(label="生成信息") | |
| gr.Markdown( | |
| """ | |
| ### 素材建议 | |
| - 单个角色、脸部清晰;正面或轻微侧脸更稳定。 | |
| - 半身或全身卡通图都可测试,避免文字、水印和多人画面。 | |
| - 语音尽量清晰、无背景音乐;当前测试输出最多约 5 秒。 | |
| - 官方模型很大,ZeroGPU 冷启动与 CPU offload 都会显著增加等待时间。 | |
| """ | |
| ) | |
| generate_button.click( | |
| fn=generate, | |
| inputs=[image_input, audio_input, prompt_input, frame_input, seed_input], | |
| outputs=[video_output, seed_output, info_output], | |
| api_name="generate", | |
| ) | |
| if __name__ == "__main__": | |
| demo.queue(default_concurrency_limit=1, max_size=8).launch( | |
| server_name="0.0.0.0", | |
| server_port=7860, | |
| show_error=True, | |
| ) | |