File size: 10,603 Bytes
b653e58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
# -*- 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


@spaces.GPU(size="xlarge", duration=estimate_gpu_duration)
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,
    )