Initial lip-sync service with command backend
This commit is contained in:
@@ -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
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.env
|
||||||
|
outputs/*
|
||||||
|
!outputs/.gitkeep
|
||||||
|
work/*
|
||||||
|
!work/.gitkeep
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.10
|
||||||
@@ -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. 日志、临时文件、输出文件可控且可清理。
|
||||||
@@ -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`。
|
||||||
|
|
||||||
|
真实成功:
|
||||||
|
|
||||||
|
- 接入真实模型后,输出视频口型与音频同步。
|
||||||
|
- 同一输入重复运行稳定生成结果。
|
||||||
|
- 错误时返回明确原因,不让主项目卡死。
|
||||||
@@ -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` 字段,主项目就基本能拿到生成结果。
|
||||||
@@ -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 中的高级参数面板。
|
||||||
@@ -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` 改成其它名字;不要改输入参数名;不要改成多个输出组件。主项目脚本当前按固定协议解析。
|
||||||
@@ -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 后端。
|
||||||
@@ -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 端口被占用。
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# 模型选型备忘
|
||||||
|
|
||||||
|
## 1. 数字人模型是什么
|
||||||
|
|
||||||
|
这里的“数字人”不是大语言模型,也不是单纯图片处理模型。它属于:
|
||||||
|
|
||||||
|
- 音频驱动人脸动画
|
||||||
|
- 口型同步
|
||||||
|
- 视频生成 / 视频重绘
|
||||||
|
- 计算机视觉 + 音频特征处理
|
||||||
|
|
||||||
|
输入是人物视频和语音,输出是口型跟随语音的新视频。
|
||||||
|
|
||||||
|
## 2. 推荐路线
|
||||||
|
|
||||||
|
### 快速验证:Wav2Lip / Wav2Lip-HQ
|
||||||
|
|
||||||
|
优点:资料多、命令行方式成熟、适合先跑通。
|
||||||
|
|
||||||
|
缺点:高清和复杂动作效果有限。
|
||||||
|
|
||||||
|
### 效果优先:MuseTalk
|
||||||
|
|
||||||
|
优点:现代方案,效果潜力更好。
|
||||||
|
|
||||||
|
缺点:部署更重,需要更认真处理依赖和预处理。
|
||||||
|
|
||||||
|
### 项目一致性:LstmSync
|
||||||
|
|
||||||
|
优点:主项目已有 `resources/lstmsync` 配置,功能定位最贴近。
|
||||||
|
|
||||||
|
缺点:需要确认权重、环境和推理脚本是否完整可迁移。
|
||||||
|
|
||||||
|
## 3. 硬件建议
|
||||||
|
|
||||||
|
- 最低尝试:RTX 3060 12GB。
|
||||||
|
- 更稳妥:16GB+ 显存。
|
||||||
|
- 长视频或高清:建议 24GB+ 显存,或做切片处理。
|
||||||
|
|
||||||
|
## 4. 不建议第一版做的事
|
||||||
|
|
||||||
|
- 不要一开始就训练模型。
|
||||||
|
- 不要一开始就做多模型动态切换 UI。
|
||||||
|
- 不要一开始就接公网鉴权和计费。
|
||||||
|
- 不要把模型权重放进 Git。
|
||||||
|
|
||||||
|
先跑通一个真实模型,再做工程化。
|
||||||
@@ -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.
|
||||||
@@ -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` 已更新。
|
||||||
|
- [ ] 没有提交模型权重。
|
||||||
|
- [ ] 没有提交大测试视频。
|
||||||
|
- [ ] 新增环境变量有说明。
|
||||||
|
- [ ] 主项目脚本验证通过。
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""Digital human lip-sync service adapter."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from .app import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -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()
|
||||||
@@ -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}")
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -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` 或通过环境变量指定。
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
Reference in New Issue
Block a user