From 8c6a222c38d175ac07b5d7c460aa5839506241c8 Mon Sep 17 00:00:00 2001 From: cat-shark Date: Sat, 20 Jun 2026 17:16:11 +0800 Subject: [PATCH] Initial lip-sync service with command backend --- .env.example | 28 +++ .gitignore | 10 + .python-version | 1 + AGENTS.md | 36 ++++ PROJECT_SPEC.md | 99 +++++++++ README.md | 94 +++++++++ TASKS.md | 55 +++++ docs/aizhinengti-integration.md | 63 ++++++ docs/architecture.md | 35 ++++ docs/backend-adapter-guide.md | 120 +++++++++++ docs/model-selection.md | 47 +++++ docs/protocol.md | 21 ++ docs/testing-and-acceptance.md | 75 +++++++ outputs/.gitkeep | 1 + pyproject.toml | 22 ++ scripts/start.ps1 | 27 +++ scripts/test-client.ps1 | 35 ++++ src/digital_human_lipsync_service/__init__.py | 3 + src/digital_human_lipsync_service/__main__.py | 4 + src/digital_human_lipsync_service/app.py | 59 ++++++ src/digital_human_lipsync_service/backends.py | 197 ++++++++++++++++++ src/digital_human_lipsync_service/config.py | 61 ++++++ tests/README.md | 22 ++ tests/test_backends.py | 110 ++++++++++ work/.gitkeep | 1 + 25 files changed, 1226 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 AGENTS.md create mode 100644 PROJECT_SPEC.md create mode 100644 README.md create mode 100644 TASKS.md create mode 100644 docs/aizhinengti-integration.md create mode 100644 docs/architecture.md create mode 100644 docs/backend-adapter-guide.md create mode 100644 docs/model-selection.md create mode 100644 docs/protocol.md create mode 100644 docs/testing-and-acceptance.md create mode 100644 outputs/.gitkeep create mode 100644 pyproject.toml create mode 100644 scripts/start.ps1 create mode 100644 scripts/test-client.ps1 create mode 100644 src/digital_human_lipsync_service/__init__.py create mode 100644 src/digital_human_lipsync_service/__main__.py create mode 100644 src/digital_human_lipsync_service/app.py create mode 100644 src/digital_human_lipsync_service/backends.py create mode 100644 src/digital_human_lipsync_service/config.py create mode 100644 tests/README.md create mode 100644 tests/test_backends.py create mode 100644 work/.gitkeep diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4fa0fc2 --- /dev/null +++ b/.env.example @@ -0,0 +1,28 @@ +# 服务地址 +LIPSYNC_HOST=0.0.0.0 +LIPSYNC_PORT=7860 +LIPSYNC_SHARE=false + +# 默认后端只把音频封装进视频,用于验证项目接入;不做真实口型同步。 +# 可选:ffmpeg_mux, command +LIPSYNC_BACKEND=ffmpeg_mux + +# FFmpeg 可执行文件;如果已加入 PATH,保持 ffmpeg 即可。 +LIPSYNC_FFMPEG=ffmpeg + +# 输出与临时目录 +LIPSYNC_OUTPUT_DIR=./outputs +LIPSYNC_WORK_DIR=./work + +# command 后端:接入你自己部署好的 Wav2Lip / MuseTalk / LstmSync 推理脚本。 +# LIPSYNC_COMMAND_CWD 建议设为模型仓库根目录,便于模型脚本加载相对路径资源。 +# LIPSYNC_COMMAND_TIMEOUT_SECONDS=0 表示不限时;真实模型建议按视频长度和显卡性能设置较大值。 +# 可用变量:{audio} {video} {output} {workdir} {cwd} +# 已转义变量:{audio_q} {video_q} {output_q} {workdir_q} {cwd_q} +# 示例:python D:\models\Wav2Lip\inference.py --checkpoint_path D:\models\Wav2Lip\checkpoints\wav2lip_gan.pth --face {video_q} --audio {audio_q} --outfile {output_q} +LIPSYNC_COMMAND_CWD=./work +LIPSYNC_COMMAND_TIMEOUT_SECONDS=0 +LIPSYNC_COMMAND_TEMPLATE= + +# Gradio 队列并发数;真实 GPU 推理建议保持 1。 +LIPSYNC_CONCURRENCY_LIMIT=1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..280dcbf --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.venv/ +__pycache__/ +*.py[cod] +.env +outputs/* +!outputs/.gitkeep +work/* +!work/.gitkeep +*.log +.DS_Store diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..c8cfe39 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3c1b1e6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,36 @@ +# AGENTS.md + +## 项目目标 + +这个项目要实现一个可被 `aIzhinengti` 数字人 `自主算力机` 模式调用的音频驱动视频口型同步服务。 + +必须长期保持兼容: + +- Gradio 服务地址示例:`http://127.0.0.1:7860/` +- API name:`/process_single` +- 输入参数名:`audio_file`、`video_file` +- 返回值:单个 `gr.Video` 输出,Gradio client 侧应能拿到包含 `video` 路径的数据 + +## 开发边界 + +- 不要在未得到用户明确许可前下载模型权重或大文件。 +- 不要把模型权重提交到项目中;只写下载/放置说明。 +- 默认 `ffmpeg_mux` 后端只用于联调,不代表真实口型同步效果。 +- 真实推理应通过 `command` 后端或新增专用后端接入 Wav2Lip、MuseTalk、LstmSync 等模型。 +- 保持项目可独立运行,不反向依赖 `aIzhinengti` 源码。 + +## 代码风格 + +- Python 目标版本:3.10+。 +- 依赖管理使用 `uv`,以 `pyproject.toml` / `uv.lock` 为准,不再新增 `requirements.txt`。 +- 保持入口简单:`python -m digital_human_lipsync_service`。 +- 后端适配逻辑放在 `src/digital_human_lipsync_service/backends.py` 或后续拆分到 `src/digital_human_lipsync_service/backends/`。 +- 配置优先用环境变量;新增变量同步更新 `.env.example` 和文档。 +- 错误要包含可定位信息,但不要泄露密钥。 + +## 验收优先级 + +1. `aIzhinengti` 的 `scripts/digital_human_process.py` 能调用成功。 +2. 服务能在 Windows 本机和局域网 GPU 机器启动。 +3. `command` 后端可稳定调用真实模型并产出 mp4。 +4. 日志、临时文件、输出文件可控且可清理。 diff --git a/PROJECT_SPEC.md b/PROJECT_SPEC.md new file mode 100644 index 0000000..8a086a9 --- /dev/null +++ b/PROJECT_SPEC.md @@ -0,0 +1,99 @@ +# 项目规格:Digital Human Lip-Sync Service + +## 1. 背景 + +`aIzhinengti` 的数字人 `自主算力机` 模式并不是大语言模型调用。它会把: + +- 一段音频文件 +- 一段数字人/真人形象视频 + +提交给一个 Gradio 服务,通过 `/process_single` 生成口型同步后的视频。 + +本项目就是这个服务的独立实现与适配层。 + +## 2. 目标 + +实现一个本地或远程可部署的音频驱动视频口型同步服务,满足: + +- 可被 `gradio_client.Client(api_url).predict(..., api_name="/process_single")` 调用。 +- 可先用 `ffmpeg_mux` 验证链路。 +- 可通过 `command` 后端接入真实模型。 +- 未来可扩展为专用 Python 后端,例如 `wav2lip`、`musetalk`、`lstmsync`。 + +## 3. 非目标 + +- 本项目不内置模型权重。 +- 本项目不负责训练模型。 +- 本项目不负责 CompShare 云主机启停 API。 +- 本项目不负责文本生成、TTS、ASR、字幕排版等上游/下游能力。 + +## 4. 输入输出契约 + +### 输入 + +- `audio_file`:音频文件路径,常见格式 `wav`、`mp3`、`m4a`。 +- `video_file`:视频文件或 Gradio 视频对象,常见格式 `mp4`、`mov`。 + +### 输出 + +输出一个可被 Gradio `Video` 组件处理的视频结果。主项目期望 Gradio client 侧可以读取到: + +```json +{ + "video": "D:/path/to/output.mp4", + "subtitles": null +} +``` + +## 5. 后端模式 + +### `ffmpeg_mux` + +当前默认模式。只把上传音频封装到原视频中,用于验证主项目到服务的调用链路,不做真实口型同步。 + +### `command` + +核心开发目标。用命令模板调用外部真实模型推理脚本: + +```powershell +$env:LIPSYNC_BACKEND = "command" +$env:LIPSYNC_COMMAND_TEMPLATE = 'python D:\models\Wav2Lip\inference.py --checkpoint_path D:\models\Wav2Lip\checkpoints\wav2lip_gan.pth --face "{video}" --audio "{audio}" --outfile "{output}"' +``` + +可用变量: + +- `{audio}`:服务收到并规范化后的音频路径 +- `{video}`:服务收到并规范化后的视频路径 +- `{output}`:模型必须写入的输出 mp4 路径 +- `{workdir}`:临时工作目录 + +### 专用后端 + +后续可以新增: + +- `wav2lip` +- `musetalk` +- `lstmsync` + +专用后端应复用统一输入输出契约。 + +## 6. 部署约束 + +- Windows 优先。 +- NVIDIA GPU 场景优先。 +- 服务端口默认 `7860`。 +- 局域网访问时使用 `LIPSYNC_HOST=0.0.0.0`。 +- 公网暴露时必须自行增加访问控制、反向代理或防火墙限制。 + +## 7. 成功定义 + +最小成功: + +- `scripts/test-client.ps1` 能调用服务,返回可播放视频。 +- `aIzhinengti/scripts/digital_human_process.py` 能调用服务,返回 `success: true`。 + +真实成功: + +- 接入真实模型后,输出视频口型与音频同步。 +- 同一输入重复运行稳定生成结果。 +- 错误时返回明确原因,不让主项目卡死。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..918eecf --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# Digital Human Lip-Sync Service + +这是给 `aIzhinengti` 数字人 `自主算力机` 模式使用的独立服务项目。它提供 Gradio `/process_single` 接口,输入一段音频和一段人物视频,输出生成后的视频。 + +> 当前项目不内置或下载大模型权重。默认 `ffmpeg_mux` 后端只用于验证接入链路:把音频封装进原视频,不做真实口型同步。真实效果需要接入 Wav2Lip、MuseTalk、LstmSync 等模型推理命令。 + +## 开发接力入口 + +如果你在新窗口继续开发,建议按这个顺序看: + +1. `AGENTS.md`:给开发 agent 的项目规则。 +2. `PROJECT_SPEC.md`:项目目标、接口契约、成功标准。 +3. `TASKS.md`:分阶段任务清单。 +4. `docs/backend-adapter-guide.md`:如何接真实口型同步模型。 +5. `docs/aizhinengti-integration.md`:如何接回主项目。 +6. `docs/testing-and-acceptance.md`:测试和验收标准。 +7. `docs/model-selection.md`:模型选型建议。 + +## 接口兼容性 + +主项目调用方式等价于: + +```python +from gradio_client import Client, handle_file + +client = Client("http://127.0.0.1:7860/") +result = client.predict( + audio_file=handle_file("C:/test/audio.wav"), + video_file={"video": handle_file("C:/test/avatar.mp4")}, + api_name="/process_single", +) +``` + +期望返回: + +```json +{"video": "生成后的视频文件路径", "subtitles": null} +``` + +## 快速启动 + +先安装 `uv`:https://docs.astral.sh/uv/getting-started/installation/ + +```powershell +cd D:\WYF-project\digital-human-lipsync-service +uv sync +.\scripts\start.ps1 +``` + +启动后访问:`http://127.0.0.1:7860/` + +也可以直接运行模块: + +```powershell +uv run python -m digital_human_lipsync_service +``` + +## 接入 aIzhinengti + +1. 打开数字人页面设置。 +2. `数字人模型` 选择 `自主算力机`。 +3. `数字人API地址` 填 `http://127.0.0.1:7860/` 或局域网 GPU 机器地址。 +4. 如果不用 CompShare,只需手动启动本服务;服务器 ID 可先填一个占位值用于通过界面校验。 + +## 接入真实口型同步模型 + +设置 `command` 后端,让服务调用你自己的推理脚本: + +```powershell +$env:LIPSYNC_BACKEND = "command" +$env:LIPSYNC_COMMAND_CWD = "D:\models\Wav2Lip" +$env:LIPSYNC_COMMAND_TIMEOUT_SECONDS = "1800" +$env:LIPSYNC_COMMAND_TEMPLATE = 'python inference.py --checkpoint_path D:\models\Wav2Lip\checkpoints\wav2lip_gan.pth --face {video_q} --audio {audio_q} --outfile {output_q}' +.\scripts\start.ps1 -Backend command +``` + +可用模板变量: + +- `{audio}`:上传后的音频文件路径 +- `{video}`:上传后的人物视频路径 +- `{output}`:服务要求模型写入的输出视频路径 +- `{workdir}`:临时工作目录 +- `{cwd}`:命令执行目录,来自 `LIPSYNC_COMMAND_CWD` +- `{audio_q}` / `{video_q}` / `{output_q}` / `{workdir_q}` / `{cwd_q}`:适合直接放进命令行的已转义路径,Windows 路径含空格时建议优先使用 + +`command` 后端会校验模型命令退出码、输出文件是否存在且非空。失败时错误信息会包含退出码、耗时、命令和 `stderr` 尾部,方便定位 CUDA、ffmpeg、模型路径或人脸检测问题。 + +## 验证 + +```powershell +.\scripts\test-client.ps1 -Audio C:\test\audio.wav -Video C:\test\avatar.mp4 +``` + +如果返回里有 `video` 字段,主项目就基本能拿到生成结果。 diff --git a/TASKS.md b/TASKS.md new file mode 100644 index 0000000..f14badf --- /dev/null +++ b/TASKS.md @@ -0,0 +1,55 @@ +# 开发任务清单 + +## Phase 0:联调基线 + +- [x] 创建独立 Gradio 服务项目。 +- [x] 提供 `/process_single` 接口。 +- [x] 提供 `ffmpeg_mux` 链路验证后端。 +- [x] 提供 PowerShell 启动脚本。 +- [x] 提供 Gradio client 测试脚本。 +- [ ] 在本机安装依赖并启动服务。 +- [ ] 使用一段真实音频和视频跑通 `scripts/test-client.ps1`。 +- [ ] 用 `aIzhinengti/scripts/digital_human_process.py` 跑通主项目调用链路。 + +## Phase 1:真实模型接入 + +- [ ] 选择第一版真实后端:建议先做 Wav2Lip 或 MuseTalk。 +- [ ] 准备模型目录,但不提交权重。 +- [x] 写好 `LIPSYNC_COMMAND_TEMPLATE` 示例。 +- [ ] 确认模型推理脚本可以消费 `{audio}`、`{video}`、`{output}`。 +- [ ] 将输出统一为 mp4。 +- [x] 在失败时把 stderr 末尾写进错误信息。 +- [ ] 记录一次成功推理的命令、耗时、显存占用。 + +## Phase 2:专用后端封装 + +- [ ] 将 `backends.py` 拆分为后端包,例如 `backends/command.py`、`backends/ffmpeg.py`。 +- [ ] 新增后端注册表,避免 `if/else` 持续膨胀。 +- [ ] 为 Wav2Lip/MuseTalk/LstmSync 添加专用配置项。 +- [ ] 支持模型预热或健康检查。 +- [ ] 支持并发限制和队列状态说明。 + +## Phase 3:工程化 + +- [ ] 增加结构化日志。 +- [ ] 增加输出目录清理策略。 +- [ ] 增加临时工作目录清理策略。 +- [ ] 增加 `GET /health` 或 Gradio 旁路健康检查服务。 +- [ ] 增加端口占用提示。 +- [ ] 增加 Windows 防火墙/局域网部署说明。 + +## Phase 4:测试与验收 + +- [ ] 增加单元测试:输入 payload 规范化。 +- [ ] 增加单元测试:后端命令模板渲染。 +- [ ] 增加集成测试:`copy_video` 或 `ffmpeg_mux`。 +- [ ] 增加主项目兼容性测试说明。 +- [ ] 建立一套测试素材路径约定,但不提交大视频。 + +## Phase 5:可选增强 + +- [ ] 增加字幕输出。 +- [ ] 增加脸部检测失败诊断。 +- [ ] 增加音画同步偏移参数。 +- [ ] 增加分辨率/码率/批大小参数。 +- [ ] 增加 Web UI 中的高级参数面板。 diff --git a/docs/aizhinengti-integration.md b/docs/aizhinengti-integration.md new file mode 100644 index 0000000..d00f686 --- /dev/null +++ b/docs/aizhinengti-integration.md @@ -0,0 +1,63 @@ +# aIzhinengti 接入说明 + +## 1. 主项目调用点 + +主项目会通过 `scripts/digital_human_process.py` 调用 Gradio: + +```python +client = Client(api_url) +result = client.predict( + audio_file=handle_file(audio_file), + video_file={"video": handle_file(video_file)}, + api_name="/process_single", +) +``` + +所以本服务必须保持 `/process_single` 接口可用。 + +## 2. 主项目中如何填写 + +在 `aIzhinengti` 数字人页面: + +1. 打开设置。 +2. `数字人模型` 选择 `自主算力机`。 +3. `数字人API地址` 填本服务地址,例如 `http://127.0.0.1:7860/`。 +4. 如果不用 CompShare 启停机器,服务器 ID 可以先填占位值用于通过界面校验。 +5. 手动启动本服务,不依赖主项目启动按钮。 + +## 3. 本项目侧验证 + +先启动服务: + +```powershell +cd D:\WYF-project\digital-human-lipsync-service +.\scripts\start.ps1 +``` + +再用测试客户端: + +```powershell +.\scripts\test-client.ps1 -Audio C:\test\audio.wav -Video C:\test\avatar.mp4 +``` + +## 4. 主项目脚本验证 + +也可以直接用主项目脚本验证: + +```powershell +cd D:\WYF-project\aIzhinengti +python .\scripts\digital_human_process.py http://127.0.0.1:7860/ C:\test\audio.wav C:\test\avatar.mp4 +``` + +期望返回: + +```json +{ + "success": true, + "videoPath": "..." +} +``` + +## 5. 接口不要随意改 + +不要把 `/process_single` 改成其它名字;不要改输入参数名;不要改成多个输出组件。主项目脚本当前按固定协议解析。 diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..a873599 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,35 @@ +# 架构说明 + +## 数据流 + +```mermaid +flowchart LR + A["aIzhinengti 数字人任务"] --> B["scripts/digital_human_process.py"] + B --> C["Gradio Client"] + C --> D["本项目 /process_single"] + D --> E{"LIPSYNC_BACKEND"} + E --> F["ffmpeg_mux 链路验证"] + E --> G["command 外部模型命令"] + E --> H["未来专用模型后端"] + F --> I["outputs/*.mp4"] + G --> I + H --> I + I --> B + B --> A +``` + +## 模块职责 + +- `config.py`:从环境变量读取配置,创建输出/临时目录。 +- `app.py`:创建 Gradio UI 和 `/process_single` API。 +- `backends.py`:规范化输入文件,调用不同后端,返回输出视频路径。 +- `scripts/start.ps1`:Windows 启动入口。 +- `scripts/test-client.ps1`:模拟主项目 Gradio client 调用。 + +## 当前实现状态 + +当前已经具备最小可运行服务骨架,但真实口型同步能力还没有接入。默认后端只做音频封装,便于先验证主项目能不能访问服务。 + +## 推荐演进 + +第一阶段不要急着改 Gradio 接口,先保持 `/process_single` 稳定。真实模型接入优先放到 `command` 后端,跑通后再封装专用 Python 后端。 diff --git a/docs/backend-adapter-guide.md b/docs/backend-adapter-guide.md new file mode 100644 index 0000000..8e6e999 --- /dev/null +++ b/docs/backend-adapter-guide.md @@ -0,0 +1,120 @@ +# 后端适配指南 + +## 1. 后端必须满足的条件 + +无论接入哪个模型,最终都要满足: + +- 输入:一个音频文件路径、一个视频文件路径。 +- 输出:一个 mp4 文件路径。 +- 失败:抛出异常或返回非零退出码,错误信息要能定位原因。 + +## 2. `command` 后端接入方式 + +`command` 后端是最推荐的第一步,因为它不要求立刻改 Python 代码。只要你的模型有命令行推理脚本,就可以接入。 + +示例: + +```powershell +$env:LIPSYNC_BACKEND = "command" +$env:LIPSYNC_COMMAND_CWD = "D:\models\Wav2Lip" +$env:LIPSYNC_COMMAND_TIMEOUT_SECONDS = "1800" +$env:LIPSYNC_COMMAND_TEMPLATE = 'python inference.py --checkpoint_path D:\models\Wav2Lip\checkpoints\wav2lip_gan.pth --face {video_q} --audio {audio_q} --outfile {output_q}' +.\scripts\start.ps1 -Backend command +``` + +模板变量: + +- `{audio}`:音频路径 +- `{video}`:视频路径 +- `{output}`:输出路径,模型必须写到这里 +- `{workdir}`:临时工作目录 +- `{cwd}`:命令执行目录,来自 `LIPSYNC_COMMAND_CWD` +- `{audio_q}` / `{video_q}` / `{output_q}` / `{workdir_q}` / `{cwd_q}`:已按当前系统 shell 转义的路径,推荐在命令模板中使用 + +## 3. Wav2Lip 接入提示 + +适合快速验证,生态资料多。 + +典型命令形态: + +```powershell +python D:\models\Wav2Lip\inference.py ` + --checkpoint_path D:\models\Wav2Lip\checkpoints\wav2lip_gan.pth ` + --face {video_q} ` + --audio {audio_q} ` + --outfile {output_q} +``` + +注意点: + +- 原始 Wav2Lip 对高清视频和复杂脸部姿态效果有限。 +- 需要确认 ffmpeg 可用。 +- 输出路径必须是 `{output}`,否则服务会认为失败。 +- 如果模型脚本依赖相对路径资源,把 `LIPSYNC_COMMAND_CWD` 设为模型仓库根目录。 +- 真实推理耗时较长时,设置 `LIPSYNC_COMMAND_TIMEOUT_SECONDS`;`0` 表示不限时。 + +## 3.1 命令后端失败诊断 + +`command` 后端会检查: + +- 模板变量是否存在。 +- `LIPSYNC_COMMAND_CWD` 是否是有效目录。 +- 命令退出码是否为 0。 +- `{output}` 指向的 mp4 是否存在且非空。 + +失败时会返回命令、退出码、耗时、stdout 尾部和 stderr 尾部。错误信息会隐藏常见 token、secret、password 字段,但不要把密钥写进命令模板;需要密钥时优先让模型脚本自己从环境变量读取。 + +## 4. MuseTalk 接入提示 + +效果通常比传统 Wav2Lip 更好,但环境更重。 + +你需要先在模型仓库中跑通官方 demo,再把官方命令改造成模板。重点确认: + +- 是否支持直接传入任意视频。 +- 是否需要预处理 avatar。 +- 输出是否能指定到 `{output}`。 +- 是否需要长驻服务或预热。 + +## 5. LstmSync 接入提示 + +主项目已有 `resources/lstmsync` 配置,说明它是项目期望的本地数字人口型同步方案之一。可选路线: + +- 直接复用主项目 LstmSync 资源包作为独立服务后端。 +- 或把 LstmSync 封成命令行脚本,再通过 `command` 后端调用。 + +需要确认: + +- 模型权重位置。 +- Python/CUDA/PyTorch 版本。 +- 输入参数名和输出路径参数。 +- 是否支持 `256m`、`256o`、`384m` 等权重选择。 + +## 6. 新增专用后端建议 + +当 `command` 后端跑通后,可以把逻辑固化成专用后端: + +```python +class Backend: + name = "wav2lip" + + def process(self, audio_path, video_path, output_path, settings): + ... + return output_path +``` + +专用后端需要补充: + +- 环境变量配置。 +- 模型路径检查。 +- 预热逻辑。 +- 明确的异常类型。 +- 最小单元测试。 + +## 7. 常见失败原因 + +- CUDA / PyTorch 版本不匹配。 +- ffmpeg 不在 PATH。 +- 视频里没有可检测的人脸。 +- 输入视频分辨率过高导致显存不足。 +- 模型脚本输出到了其它路径,没有写入 `{output}`。 +- Gradio 端口被占用。 diff --git a/docs/model-selection.md b/docs/model-selection.md new file mode 100644 index 0000000..3a7c0f9 --- /dev/null +++ b/docs/model-selection.md @@ -0,0 +1,47 @@ +# 模型选型备忘 + +## 1. 数字人模型是什么 + +这里的“数字人”不是大语言模型,也不是单纯图片处理模型。它属于: + +- 音频驱动人脸动画 +- 口型同步 +- 视频生成 / 视频重绘 +- 计算机视觉 + 音频特征处理 + +输入是人物视频和语音,输出是口型跟随语音的新视频。 + +## 2. 推荐路线 + +### 快速验证:Wav2Lip / Wav2Lip-HQ + +优点:资料多、命令行方式成熟、适合先跑通。 + +缺点:高清和复杂动作效果有限。 + +### 效果优先:MuseTalk + +优点:现代方案,效果潜力更好。 + +缺点:部署更重,需要更认真处理依赖和预处理。 + +### 项目一致性:LstmSync + +优点:主项目已有 `resources/lstmsync` 配置,功能定位最贴近。 + +缺点:需要确认权重、环境和推理脚本是否完整可迁移。 + +## 3. 硬件建议 + +- 最低尝试:RTX 3060 12GB。 +- 更稳妥:16GB+ 显存。 +- 长视频或高清:建议 24GB+ 显存,或做切片处理。 + +## 4. 不建议第一版做的事 + +- 不要一开始就训练模型。 +- 不要一开始就做多模型动态切换 UI。 +- 不要一开始就接公网鉴权和计费。 +- 不要把模型权重放进 Git。 + +先跑通一个真实模型,再做工程化。 diff --git a/docs/protocol.md b/docs/protocol.md new file mode 100644 index 0000000..5687dae --- /dev/null +++ b/docs/protocol.md @@ -0,0 +1,21 @@ +# Protocol + +## Gradio endpoint + +- API name: `/process_single` +- Audio input parameter: `audio_file` +- Video input parameter: `video_file` +- Output component: single `gr.Video` + +The function returns a video object compatible with Gradio client responses: + +```json +{ + "video": "D:/path/to/result.mp4", + "subtitles": null +} +``` + +## Notes + +The upstream desktop app reads `result["video"]` from `scripts/digital_human_process.py`, so this service intentionally exposes one video output instead of multiple outputs. Adding extra Gradio outputs would change `client.predict` into a tuple and break compatibility. diff --git a/docs/testing-and-acceptance.md b/docs/testing-and-acceptance.md new file mode 100644 index 0000000..96a6b4e --- /dev/null +++ b/docs/testing-and-acceptance.md @@ -0,0 +1,75 @@ +# 测试与验收清单 + +## 1. 本地语法检查 + +```powershell +uv run python -m compileall src +``` + +## 1.1 单元测试 + +```powershell +uv run python -m unittest discover -s tests +``` + +## 2. 服务启动检查 + +```powershell +.\scripts\start.ps1 +``` + +浏览器打开:`http://127.0.0.1:7860/` + +验收:页面能打开,上传音频和视频后能产出视频。 + +## 3. Gradio Client 检查 + +```powershell +.\scripts\test-client.ps1 -Audio C:\test\audio.wav -Video C:\test\avatar.mp4 +``` + +验收:输出包含 `video` 字段,并且路径对应文件存在。 + +## 4. 主项目兼容检查 + +```powershell +cd D:\WYF-project\aIzhinengti +python .\scripts\digital_human_process.py http://127.0.0.1:7860/ C:\test\audio.wav C:\test\avatar.mp4 +``` + +验收:返回 `success: true`。 + +## 5. 真实模型效果验收 + +真实模型接入后,至少用三组样例: + +- 正脸短视频 + 10 秒音频。 +- 半身人物视频 + 30 秒音频。 +- 有轻微转头的视频 + 15 秒音频。 + +检查项: + +- 口型与音频基本同步。 +- 输出视频时长接近音频时长或按预期截断。 +- 输出画面无明显崩脸、黑屏、花屏。 +- 失败时错误能定位到模型、依赖、显存或输入素材问题。 + +## 6. 性能记录 + +每次更换模型或参数,记录: + +- GPU 型号和显存。 +- 输入视频分辨率和时长。 +- 音频时长。 +- 推理耗时。 +- 峰值显存。 +- 输出文件大小。 + +## 7. 发布前检查 + +- [ ] README 已更新。 +- [ ] `.env.example` 已更新。 +- [ ] 没有提交模型权重。 +- [ ] 没有提交大测试视频。 +- [ ] 新增环境变量有说明。 +- [ ] 主项目脚本验证通过。 diff --git a/outputs/.gitkeep b/outputs/.gitkeep new file mode 100644 index 0000000..e02abfc --- /dev/null +++ b/outputs/.gitkeep @@ -0,0 +1 @@ + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f9e4134 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,22 @@ +[project] +name = "digital-human-lipsync-service" +version = "0.1.0" +description = "Gradio-compatible audio-driven digital human lip-sync service adapter" +requires-python = ">=3.10" +dependencies = [ + "gradio>=4.44,<6", + "gradio-client>=1.3,<2", +] + +[project.scripts] +digital-human-lipsync-service = "digital_human_lipsync_service.app:main" + +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.uv] +package = true diff --git a/scripts/start.ps1 b/scripts/start.ps1 new file mode 100644 index 0000000..7cbea3a --- /dev/null +++ b/scripts/start.ps1 @@ -0,0 +1,27 @@ +param( + [int]$Port = 7860, + [string]$HostName = "0.0.0.0", + [ValidateSet("ffmpeg_mux", "command", "copy_video")] + [string]$Backend = "ffmpeg_mux", + [switch]$Install +) + +$ErrorActionPreference = "Stop" +$Root = Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..") +Set-Location $Root + +$Uv = Get-Command uv -ErrorAction SilentlyContinue +if (-not $Uv) { + throw "uv is required. Install it first: https://docs.astral.sh/uv/getting-started/installation/" +} + +if ($Install) { + & $Uv.Source sync +} + +$env:LIPSYNC_HOST = $HostName +$env:LIPSYNC_PORT = "$Port" +$env:LIPSYNC_BACKEND = $Backend +$env:PYTHONPATH = (Join-Path $Root "src") + +& $Uv.Source run python -m digital_human_lipsync_service diff --git a/scripts/test-client.ps1 b/scripts/test-client.ps1 new file mode 100644 index 0000000..825205b --- /dev/null +++ b/scripts/test-client.ps1 @@ -0,0 +1,35 @@ +param( + [string]$ApiUrl = "http://127.0.0.1:7860/", + [Parameter(Mandatory = $true)] + [string]$Audio, + [Parameter(Mandatory = $true)] + [string]$Video +) + +$ErrorActionPreference = "Stop" +$Root = Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..") +$Python = Join-Path $Root ".venv\Scripts\python.exe" +if (!(Test-Path -LiteralPath $Python)) { + $Python = "py" +} + +$code = @" +import json +import sys +from gradio_client import Client, handle_file + +api_url, audio, video = sys.argv[1:4] +client = Client(api_url) +result = client.predict( + audio_file=handle_file(audio), + video_file={"video": handle_file(video)}, + api_name="/process_single", +) +print(json.dumps(result, ensure_ascii=False, indent=2)) +"@ + +if ($Python -eq "py") { + py -3 -c $code $ApiUrl $Audio $Video +} else { + & $Python -c $code $ApiUrl $Audio $Video +} diff --git a/src/digital_human_lipsync_service/__init__.py b/src/digital_human_lipsync_service/__init__.py new file mode 100644 index 0000000..fedae91 --- /dev/null +++ b/src/digital_human_lipsync_service/__init__.py @@ -0,0 +1,3 @@ +"""Digital human lip-sync service adapter.""" + +__version__ = "0.1.0" diff --git a/src/digital_human_lipsync_service/__main__.py b/src/digital_human_lipsync_service/__main__.py new file mode 100644 index 0000000..31f1640 --- /dev/null +++ b/src/digital_human_lipsync_service/__main__.py @@ -0,0 +1,4 @@ +from .app import main + +if __name__ == "__main__": + main() diff --git a/src/digital_human_lipsync_service/app.py b/src/digital_human_lipsync_service/app.py new file mode 100644 index 0000000..1db5ab6 --- /dev/null +++ b/src/digital_human_lipsync_service/app.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import traceback +from pathlib import Path +from typing import Any + +import gradio as gr + +from .backends import process_lipsync +from .config import Settings + + +def build_demo(settings: Settings) -> gr.Blocks: + def process_single(audio_file: Any, video_file: Any) -> dict[str, Any]: + try: + output_path = process_lipsync(settings, audio_file, video_file) + return { + "video": str(Path(output_path).resolve()), + "subtitles": None, + } + except Exception as exc: # Gradio should return a structured failure to the desktop app. + traceback.print_exc() + raise gr.Error(str(exc)) from exc + + with gr.Blocks(title="Digital Human Lip-Sync Service") as demo: + gr.Markdown( + "# Digital Human Lip-Sync Service\n" + "Compatible with `aIzhinengti` 自主算力机 `/process_single` calls." + ) + with gr.Row(): + audio_input = gr.Audio(type="filepath", label="audio_file") + video_input = gr.Video(label="video_file") + output_video = gr.Video(label="video") + process_button = gr.Button("Process", variant="primary") + + process_button.click( + fn=process_single, + inputs=[audio_input, video_input], + outputs=[output_video], + api_name="process_single", + ) + + return demo + + +def main() -> None: + settings = Settings.from_env() + demo = build_demo(settings) + demo.queue(default_concurrency_limit=settings.concurrency_limit) + demo.launch( + server_name=settings.host, + server_port=settings.port, + share=settings.share, + allowed_paths=[str(settings.output_dir), str(settings.work_dir)], + ) + + +if __name__ == "__main__": + main() diff --git a/src/digital_human_lipsync_service/backends.py b/src/digital_human_lipsync_service/backends.py new file mode 100644 index 0000000..89f6d72 --- /dev/null +++ b/src/digital_human_lipsync_service/backends.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +import json +import os +import re +import shlex +import shutil +import subprocess +import time +import uuid +from pathlib import Path +from typing import Any + +from .config import Settings + + +class LipSyncError(RuntimeError): + """Raised when lip-sync processing fails.""" + + +SECRET_PATTERN = re.compile( + r"(?i)(api[_-]?key|access[_-]?token|secret|password|token)(\s*[:=]\s*)([^\s\"']+)" +) + + +def _tail_text(value: str | bytes | None, limit: int) -> str: + if value is None: + return "" + if isinstance(value, bytes): + value = value.decode(errors="replace") + return _redact(value[-limit:]) + + +def _redact(value: str) -> str: + return SECRET_PATTERN.sub(r"\1\2***", value) + + +def quote_for_shell(path: Path | str) -> str: + text = str(path) + if os.name == "nt": + return subprocess.list2cmdline([text]) + return shlex.quote(text) + + +def render_command_template(settings: Settings, audio_path: Path, video_path: Path, output_path: Path) -> str: + values = { + "audio": str(audio_path), + "video": str(video_path), + "output": str(output_path), + "workdir": str(settings.work_dir), + "cwd": str(settings.command_cwd), + "audio_q": quote_for_shell(audio_path), + "video_q": quote_for_shell(video_path), + "output_q": quote_for_shell(output_path), + "workdir_q": quote_for_shell(settings.work_dir), + "cwd_q": quote_for_shell(settings.command_cwd), + } + try: + return settings.command_template.format(**values) + except KeyError as exc: + available = ", ".join(sorted(values)) + raise LipSyncError(f"Unknown LIPSYNC_COMMAND_TEMPLATE variable {{{exc.args[0]}}}. Available: {available}") from exc + except ValueError as exc: + raise LipSyncError(f"Invalid LIPSYNC_COMMAND_TEMPLATE format: {exc}") from exc + + +def normalize_video_path(video_file: Any) -> Path: + if isinstance(video_file, (str, Path)): + return Path(video_file) + + if isinstance(video_file, dict): + for key in ("video", "path", "name"): + value = video_file.get(key) + if value: + return normalize_video_path(value) + + if isinstance(video_file, (list, tuple)) and video_file: + return normalize_video_path(video_file[0]) + + raise LipSyncError(f"Unsupported video_file payload: {json.dumps(video_file, ensure_ascii=False, default=str)[:500]}") + + +def normalize_audio_path(audio_file: Any) -> Path: + if isinstance(audio_file, (str, Path)): + return Path(audio_file) + + if isinstance(audio_file, dict): + for key in ("audio", "path", "name"): + value = audio_file.get(key) + if value: + return normalize_audio_path(value) + + raise LipSyncError(f"Unsupported audio_file payload: {json.dumps(audio_file, ensure_ascii=False, default=str)[:500]}") + + +def ensure_existing_file(path: Path, label: str) -> Path: + resolved = path.expanduser().resolve() + if not resolved.exists() or not resolved.is_file(): + raise LipSyncError(f"{label} does not exist: {resolved}") + return resolved + + +def make_output_path(settings: Settings) -> Path: + timestamp = time.strftime("%Y%m%d-%H%M%S") + suffix = uuid.uuid4().hex[:8] + return settings.output_dir / f"lipsync-{timestamp}-{suffix}.mp4" + + +def run_ffmpeg_mux(settings: Settings, audio_path: Path, video_path: Path, output_path: Path) -> Path: + command = [ + settings.ffmpeg, + "-y", + "-i", + str(video_path), + "-i", + str(audio_path), + "-map", + "0:v:0", + "-map", + "1:a:0", + "-c:v", + "copy", + "-c:a", + "aac", + "-shortest", + str(output_path), + ] + completed = subprocess.run(command, capture_output=True, text=True) + if completed.returncode != 0: + raise LipSyncError( + "ffmpeg_mux failed. " + f"stdout={completed.stdout[-1000:]} stderr={completed.stderr[-2000:]}" + ) + return output_path + + +def run_command_backend(settings: Settings, audio_path: Path, video_path: Path, output_path: Path) -> Path: + if not settings.command_template: + raise LipSyncError("LIPSYNC_COMMAND_TEMPLATE is required when LIPSYNC_BACKEND=command") + + if not settings.command_cwd.exists() or not settings.command_cwd.is_dir(): + raise LipSyncError(f"LIPSYNC_COMMAND_CWD does not exist or is not a directory: {settings.command_cwd}") + + command = render_command_template(settings, audio_path, video_path, output_path) + timeout = settings.command_timeout_seconds if settings.command_timeout_seconds > 0 else None + started_at = time.monotonic() + try: + completed = subprocess.run( + command, + shell=True, + capture_output=True, + text=True, + cwd=str(settings.command_cwd), + timeout=timeout, + ) + except subprocess.TimeoutExpired as exc: + elapsed = time.monotonic() - started_at + raise LipSyncError( + "command backend timed out. " + f"timeout_seconds={timeout} elapsed_seconds={elapsed:.1f} command={_redact(command)} " + f"stdout_tail={_tail_text(exc.stdout, 1000)} stderr_tail={_tail_text(exc.stderr, 2000)}" + ) from exc + + elapsed = time.monotonic() - started_at + if completed.returncode != 0: + raise LipSyncError( + "command backend failed. " + f"exit_code={completed.returncode} elapsed_seconds={elapsed:.1f} command={_redact(command)} " + f"stdout_tail={_tail_text(completed.stdout, 1000)} stderr_tail={_tail_text(completed.stderr, 2000)}" + ) + if not output_path.exists() or not output_path.is_file(): + raise LipSyncError( + "command backend finished but output file was not created. " + f"elapsed_seconds={elapsed:.1f} output={output_path} " + f"stdout_tail={_tail_text(completed.stdout, 1000)} stderr_tail={_tail_text(completed.stderr, 2000)}" + ) + if output_path.stat().st_size <= 0: + raise LipSyncError(f"command backend created an empty output file: {output_path}") + return output_path + + +def process_lipsync(settings: Settings, audio_file: Any, video_file: Any) -> Path: + audio_path = ensure_existing_file(normalize_audio_path(audio_file), "audio_file") + video_path = ensure_existing_file(normalize_video_path(video_file), "video_file") + output_path = make_output_path(settings) + + if settings.backend == "ffmpeg_mux": + return run_ffmpeg_mux(settings, audio_path, video_path, output_path) + + if settings.backend == "command": + return run_command_backend(settings, audio_path, video_path, output_path) + + if settings.backend == "copy_video": + shutil.copy2(video_path, output_path) + return output_path + + raise LipSyncError(f"Unsupported LIPSYNC_BACKEND: {settings.backend}") diff --git a/src/digital_human_lipsync_service/config.py b/src/digital_human_lipsync_service/config.py new file mode 100644 index 0000000..485d685 --- /dev/null +++ b/src/digital_human_lipsync_service/config.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + + +def _bool_from_env(name: str, default: bool = False) -> bool: + value = os.getenv(name) + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "y", "on"} + + +def _int_from_env(name: str, default: int) -> int: + value = os.getenv(name) + if value is None or not value.strip(): + return default + return int(value) + + +def _path_from_env(name: str, default: str) -> Path: + value = os.getenv(name, default).strip() or default + return Path(value).expanduser().resolve() + + +@dataclass(frozen=True) +class Settings: + host: str + port: int + share: bool + backend: str + ffmpeg: str + output_dir: Path + work_dir: Path + command_cwd: Path + command_template: str + command_timeout_seconds: int + concurrency_limit: int + + @classmethod + def from_env(cls) -> "Settings": + output_dir = _path_from_env("LIPSYNC_OUTPUT_DIR", "./outputs") + work_dir = _path_from_env("LIPSYNC_WORK_DIR", "./work") + command_cwd = _path_from_env("LIPSYNC_COMMAND_CWD", str(work_dir)) + output_dir.mkdir(parents=True, exist_ok=True) + work_dir.mkdir(parents=True, exist_ok=True) + + return cls( + host=os.getenv("LIPSYNC_HOST", "0.0.0.0"), + port=_int_from_env("LIPSYNC_PORT", 7860), + share=_bool_from_env("LIPSYNC_SHARE", False), + backend=os.getenv("LIPSYNC_BACKEND", "ffmpeg_mux").strip().lower() or "ffmpeg_mux", + ffmpeg=os.getenv("LIPSYNC_FFMPEG", "ffmpeg").strip() or "ffmpeg", + output_dir=output_dir, + work_dir=work_dir, + command_cwd=command_cwd, + command_template=os.getenv("LIPSYNC_COMMAND_TEMPLATE", "").strip(), + command_timeout_seconds=_int_from_env("LIPSYNC_COMMAND_TIMEOUT_SECONDS", 0), + concurrency_limit=_int_from_env("LIPSYNC_CONCURRENCY_LIMIT", 1), + ) diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..d17fa62 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,22 @@ +# Tests + +当前使用 Python 标准库 `unittest`,无需额外测试依赖。 + +```powershell +uv run python -m unittest discover -s tests +``` + +已覆盖: + +1. `command` 后端模板渲染。 +2. 未知模板变量报错。 +3. 非零退出码的 stderr 尾部诊断与敏感字段脱敏。 +4. `command` 后端成功产出非空输出文件。 + +后续建议增加: + +1. `normalize_audio_path` / `normalize_video_path` payload 解析测试。 +2. `copy_video` 或 `ffmpeg_mux` 集成测试。 +3. Gradio `/process_single` 兼容测试。 + +不要提交大音频/视频素材。测试素材可放在本机 `C:\test` 或通过环境变量指定。 diff --git a/tests/test_backends.py b/tests/test_backends.py new file mode 100644 index 0000000..bb3fc25 --- /dev/null +++ b/tests/test_backends.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import sys +import tempfile +import unittest +from pathlib import Path + +from digital_human_lipsync_service.backends import ( + LipSyncError, + quote_for_shell, + render_command_template, + run_command_backend, +) +from digital_human_lipsync_service.config import Settings + + +def make_settings(tmp_path: Path, command_template: str) -> Settings: + output_dir = tmp_path / "outputs" + work_dir = tmp_path / "work" + output_dir.mkdir() + work_dir.mkdir() + return Settings( + host="127.0.0.1", + port=7860, + share=False, + backend="command", + ffmpeg="ffmpeg", + output_dir=output_dir, + work_dir=work_dir, + command_cwd=work_dir, + command_template=command_template, + command_timeout_seconds=10, + concurrency_limit=1, + ) + + +class CommandBackendTests(unittest.TestCase): + def test_render_command_template_supports_raw_and_quoted_paths(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + settings = make_settings(root, "model --audio {audio} --face {video_q} --out {output_q} --cwd {cwd}") + audio = root / "input audio.wav" + video = root / "input video.mp4" + output = root / "out video.mp4" + + command = render_command_template(settings, audio, video, output) + + self.assertIn(str(audio), command) + self.assertIn("input video.mp4", command) + self.assertIn("out video.mp4", command) + self.assertIn(str(settings.command_cwd), command) + + def test_render_command_template_rejects_unknown_variable(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + settings = make_settings(root, "model --bad {missing}") + + with self.assertRaisesRegex(LipSyncError, "Unknown LIPSYNC_COMMAND_TEMPLATE variable"): + render_command_template(settings, root / "a.wav", root / "v.mp4", root / "o.mp4") + + def test_run_command_backend_reports_exit_code_and_stderr_tail(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + script = root / "fail_backend.py" + script.write_text( + "import sys\n" + "sys.stderr.write('api_key=super-secret\\nmodel failed near frame 12\\n')\n" + "raise SystemExit(7)\n", + encoding="utf-8", + ) + settings = make_settings(root, f"{quote_for_shell(sys.executable)} {quote_for_shell(script)}") + audio = root / "a.wav" + video = root / "v.mp4" + output = root / "out.mp4" + audio.write_bytes(b"audio") + video.write_bytes(b"video") + + with self.assertRaises(LipSyncError) as context: + run_command_backend(settings, audio, video, output) + + message = str(context.exception) + self.assertIn("exit_code=7", message) + self.assertIn("model failed near frame 12", message) + self.assertIn("api_key=***", message) + self.assertNotIn("super-secret", message) + + def test_run_command_backend_accepts_non_empty_output_file(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + script = root / "ok_backend.py" + script.write_text( + "import pathlib, sys\n" + "pathlib.Path(sys.argv[1]).write_bytes(b'fake mp4')\n", + encoding="utf-8", + ) + settings = make_settings(root, f"{quote_for_shell(sys.executable)} {quote_for_shell(script)} {{output_q}}") + audio = root / "a.wav" + video = root / "v.mp4" + output = root / "out.mp4" + audio.write_bytes(b"audio") + video.write_bytes(b"video") + + result = run_command_backend(settings, audio, video, output) + + self.assertEqual(output, result) + self.assertGreater(output.stat().st_size, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/work/.gitkeep b/work/.gitkeep new file mode 100644 index 0000000..e02abfc --- /dev/null +++ b/work/.gitkeep @@ -0,0 +1 @@ +