Initial lip-sync service with command backend

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