Compare commits
4 Commits
afa2027a00
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
954767b3b2 | ||
|
|
d990207cec | ||
|
|
1e355e6138 | ||
|
|
7635e5e766 |
@@ -9,3 +9,4 @@ logs/
|
|||||||
.git/
|
.git/
|
||||||
.gitignore
|
.gitignore
|
||||||
test/
|
test/
|
||||||
|
build/
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -19,3 +19,8 @@ logs/
|
|||||||
|
|
||||||
# 测试数据与脚本(本地测试用,不入库)
|
# 测试数据与脚本(本地测试用,不入库)
|
||||||
/test/
|
/test/
|
||||||
|
|
||||||
|
# 导出的 Docker 镜像 tar(太大,不入库)
|
||||||
|
*.tar
|
||||||
|
|
||||||
|
build/
|
||||||
@@ -100,7 +100,9 @@ VOLUME ["/data", "/models"]
|
|||||||
# "Unable to load libcudnn_ops.so.9"。放在 final/dev 而非 deps,避免 ENV 变化
|
# "Unable to load libcudnn_ops.so.9"。放在 final/dev 而非 deps,避免 ENV 变化
|
||||||
# 导致 deps 的 apt/pip 层缓存失效。CPU 镜像无此目录,路径被忽略不影响。
|
# 导致 deps 的 apt/pip 层缓存失效。CPU 镜像无此目录,路径被忽略不影响。
|
||||||
ENV CONFIG_PATH=/app/config.yaml \
|
ENV CONFIG_PATH=/app/config.yaml \
|
||||||
LD_LIBRARY_PATH=/usr/local/lib/python3.12/site-packages/nvidia/cudnn/lib:/usr/local/lib/python3.12/dist-packages/nvidia/cudnn/lib:/usr/local/lib/python3.12/site-packages/nvidia/cublas/lib:/usr/local/lib/python3.12/dist-packages/nvidia/cublas/lib
|
LD_LIBRARY_PATH=/usr/local/lib/python3.12/site-packages/nvidia/cudnn/lib:/usr/local/lib/python3.12/dist-packages/nvidia/cudnn/lib:/usr/local/lib/python3.12/site-packages/nvidia/cublas/lib:/usr/local/lib/python3.12/dist-packages/nvidia/cublas/lib \
|
||||||
|
HF_HUB_OFFLINE=1 \
|
||||||
|
TRANSFORMERS_OFFLINE=1
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||||
@@ -118,7 +120,9 @@ COPY config.example.yaml /app/config.example.yaml
|
|||||||
# 全部走 volume,镜像本身无状态、无敏感数据
|
# 全部走 volume,镜像本身无状态、无敏感数据
|
||||||
VOLUME ["/data", "/models"]
|
VOLUME ["/data", "/models"]
|
||||||
ENV CONFIG_PATH=/app/config.yaml \
|
ENV CONFIG_PATH=/app/config.yaml \
|
||||||
LD_LIBRARY_PATH=/usr/local/lib/python3.12/site-packages/nvidia/cudnn/lib:/usr/local/lib/python3.12/dist-packages/nvidia/cudnn/lib:/usr/local/lib/python3.12/site-packages/nvidia/cublas/lib:/usr/local/lib/python3.12/dist-packages/nvidia/cublas/lib
|
LD_LIBRARY_PATH=/usr/local/lib/python3.12/site-packages/nvidia/cudnn/lib:/usr/local/lib/python3.12/dist-packages/nvidia/cudnn/lib:/usr/local/lib/python3.12/site-packages/nvidia/cublas/lib:/usr/local/lib/python3.12/dist-packages/nvidia/cublas/lib \
|
||||||
|
HF_HUB_OFFLINE=1 \
|
||||||
|
TRANSFORMERS_OFFLINE=1
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|||||||
61
README.md
61
README.md
@@ -29,11 +29,14 @@ AUDIO2TEXT_VARIANT=gpu ./setup.sh
|
|||||||
- **双语字幕**:英文在上、中文在下,亦可单独下载英文 / 中文字幕
|
- **双语字幕**:英文在上、中文在下,亦可单独下载英文 / 中文字幕
|
||||||
- **faster-whisper 转写**:词级时间戳,断句精确(取首末词时间戳)
|
- **faster-whisper 转写**:词级时间戳,断句精确(取首末词时间戳)
|
||||||
- **NLLB-200 英译中**:ASR 与翻译模型不共驻,翻译时独占显存跑大 batch
|
- **NLLB-200 英译中**:ASR 与翻译模型不共驻,翻译时独占显存跑大 batch
|
||||||
- **任务状态机**:`queued → uploading → extracting → transcribing → segmenting → translating → done`
|
- **设置页**:运行时调整 batch_size / beam_size,保存后对后续任务生效(DB 持久化)
|
||||||
|
- **任务管理**:删除已完成/失败任务及其产物,进度条按批次细分
|
||||||
|
- **任务状态机**:`queued -> uploading -> extracting -> transcribing -> segmenting -> translating -> done`
|
||||||
- **实时日志页**:按级别分层(debug=详细子步骤 / info=阶段转换 / error=完整 traceback)
|
- **实时日志页**:按级别分层(debug=详细子步骤 / info=阶段转换 / error=完整 traceback)
|
||||||
- **定时缓存清理**:任务产物默认保留 7 天,超期连同 DB 记录一并删除
|
- **定时缓存清理**:任务产物默认保留 7 天,超期连同 DB 记录一并删除
|
||||||
- **SQLite 持久化**(自包含,无需外部 DB)
|
- **SQLite 持久化**(自包含,无需外部 DB)
|
||||||
- `/docs`(Swagger UI)受 Basic Auth 保护
|
- **离线运行**:模型缓存就位后完全离线,无需访问 HuggingFace
|
||||||
|
- `/docs`(Swagger UI)公开访问
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -108,6 +111,58 @@ upload_router ──► upload_service ──► UploadSession(SQLite) + 分片
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### CPU 开发环境(2GB 内存即可)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./setup.sh # 构建镜像 + 生成 config.yaml
|
||||||
|
./start.sh # 启动容器(端口 8000)
|
||||||
|
```
|
||||||
|
|
||||||
|
### GPU 生产环境(需 NVIDIA GPU + nvidia runtime)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AUDIO2TEXT_VARIANT=gpu ./setup.sh
|
||||||
|
./start.sh # 自动检测 GPU 镜像 + nvidia-smi,端口 8001
|
||||||
|
```
|
||||||
|
|
||||||
|
启动后打开 `http://127.0.0.1:8000/`,拖入视频即可。详细部署流程、模型选型、CPU↔GPU 切换
|
||||||
|
见 [部署指南](./docs/DEPLOYMENT.md)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 离线部署(导入预构建镜像)
|
||||||
|
|
||||||
|
已有 `audio2text-gpu.tar` 镜像文件时,新机器无需构建,直接导入即可启动(仍需 NVIDIA 驱动 +
|
||||||
|
nvidia container runtime + 模型缓存 `./models`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 导入镜像
|
||||||
|
docker load -i audio2text-gpu.tar
|
||||||
|
|
||||||
|
# 2. 准备配置 + 数据目录
|
||||||
|
mkdir -p data-gpu/uploads data-gpu/.work data-gpu/outputs models
|
||||||
|
|
||||||
|
# 3. 启动容器(config.gpu.yaml 需自行准备,或从项目仓库取)
|
||||||
|
docker run -d --name audio2text-gpu \
|
||||||
|
--gpus all \
|
||||||
|
-p 8001:8000 \
|
||||||
|
-v "$(pwd)/data-gpu:/data" \
|
||||||
|
-v "$(pwd)/models:/models" \
|
||||||
|
-v "$(pwd)/config.gpu.yaml:/app/config.yaml:ro" \
|
||||||
|
--restart unless-stopped \
|
||||||
|
audio2text:gpu
|
||||||
|
```
|
||||||
|
|
||||||
|
> **模型缓存**:`./models` 目录需包含 Whisper `large-v3-turbo` + NLLB `distilled-1.3B` 权重
|
||||||
|
> (约 5.5GB)。首次部署时从源机器拷贝 `models/` 目录,或联网用 `prefetch_models.sh` 预拉。
|
||||||
|
> 镜像内置 `HF_HUB_OFFLINE=1`,模型就位后完全离线运行,无需访问 HuggingFace。
|
||||||
|
|
||||||
|
详细步骤见 [Docker 说明 - 导入预构建镜像](./docs/DOCKER.md#导入预构建镜像离线部署)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 入口
|
## 入口
|
||||||
|
|
||||||
| 入口 | 地址 |
|
| 入口 | 地址 |
|
||||||
@@ -115,7 +170,7 @@ upload_router ──► upload_service ──► UploadSession(SQLite) + 分片
|
|||||||
| 主页(上传 + 最近任务) | `http://127.0.0.1:8000/` |
|
| 主页(上传 + 最近任务) | `http://127.0.0.1:8000/` |
|
||||||
| 历史任务 | `http://127.0.0.1:8000/history` |
|
| 历史任务 | `http://127.0.0.1:8000/history` |
|
||||||
| 实时日志 | `http://127.0.0.1:8000/logs` |
|
| 实时日志 | `http://127.0.0.1:8000/logs` |
|
||||||
| API 文档 | `http://127.0.0.1:8000/docs`(Basic Auth) |
|
| API 文档 | `http://127.0.0.1:8000/docs` |
|
||||||
| 健康检查 | `http://127.0.0.1:8000/health` |
|
| 健康检查 | `http://127.0.0.1:8000/health` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
102
app/config.py
102
app/config.py
@@ -128,13 +128,111 @@ def _load_yaml(path: Path) -> dict:
|
|||||||
return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 运行时覆盖 ----------------
|
||||||
|
# 允许通过设置页修改的配置项(点分路径 -> 类型)。config.yaml 是只读挂载,
|
||||||
|
# 改它需重启容器;运行时覆盖存 DB,进程重启后自动加载,无需重建镜像。
|
||||||
|
# 设置页保存时调 save_setting() 写 DB + 清 lru_cache,下次 get_settings() 生效。
|
||||||
|
_applying_overrides = False # 防递归标志:_apply_overrides 内部 DB 初始化会回调 get_settings()
|
||||||
|
_OVERIDEABLE_FIELDS: dict[str, type] = {
|
||||||
|
"asr.batch_size": int,
|
||||||
|
"asr.beam_size": int,
|
||||||
|
"translation.batch_size": int,
|
||||||
|
"translation.sort_by_length": bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_overrides(settings: Settings) -> Settings:
|
||||||
|
"""从 DB 读取覆盖值并应用到 Settings 对象。
|
||||||
|
|
||||||
|
在 lru_cache 的 get_settings() 内部调用,保证缓存的对象已含覆盖。
|
||||||
|
DB 还没初始化时(首次 import)静默跳过,用 YAML 原值。
|
||||||
|
|
||||||
|
注意:get_session_local() -> get_engine() -> _db_path() -> get_settings()
|
||||||
|
会形成递归。用 _applying_overrides 标志阻断:递归调用直接返回当前 settings
|
||||||
|
(此时 DB 路径只需 work_dir,无覆盖也无妨)。
|
||||||
|
"""
|
||||||
|
global _applying_overrides
|
||||||
|
if _applying_overrides:
|
||||||
|
return settings # 递归调用(_db_path 触发),直接返回 YAML 原值
|
||||||
|
_applying_overrides = True
|
||||||
|
try:
|
||||||
|
from .database import get_session_local
|
||||||
|
from .models.setting import Setting
|
||||||
|
import json
|
||||||
|
db = get_session_local()()
|
||||||
|
try:
|
||||||
|
rows = db.query(Setting).all()
|
||||||
|
overrides = {r.key: r.value for r in rows}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
for key, type_ in _OVERIDEABLE_FIELDS.items():
|
||||||
|
if key not in overrides:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
val = json.loads(overrides[key])
|
||||||
|
val = type_(val)
|
||||||
|
except (json.JSONDecodeError, ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
_set_nested(settings, key, val)
|
||||||
|
except Exception:
|
||||||
|
# DB 未就绪(首次 import 时 database.py 可能还在初始化)-> 跳过,用 YAML 原值
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
_applying_overrides = False
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
def _set_nested(settings: Settings, key: str, val) -> None:
|
||||||
|
"""按点分路径设置嵌套属性,如 'asr.batch_size' -> settings.asr.batch_size"""
|
||||||
|
parts = key.split(".")
|
||||||
|
obj = settings
|
||||||
|
for p in parts[:-1]:
|
||||||
|
obj = getattr(obj, p)
|
||||||
|
setattr(obj, parts[-1], val)
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
|
"""读取 config.yaml + 应用 DB 覆盖,返回完整 Settings。
|
||||||
|
|
||||||
|
结果被 lru_cache 缓存。修改设置后调 reload_settings() 清缓存,
|
||||||
|
下次调用返回含新值的 Settings。
|
||||||
|
"""
|
||||||
path = Path(os.getenv("CONFIG_PATH", str(DEFAULT_CONFIG_PATH)))
|
path = Path(os.getenv("CONFIG_PATH", str(DEFAULT_CONFIG_PATH)))
|
||||||
return Settings.model_validate(_load_yaml(path))
|
settings = Settings.model_validate(_load_yaml(path))
|
||||||
|
return _apply_overrides(settings)
|
||||||
|
|
||||||
|
|
||||||
def reload_settings() -> Settings:
|
def reload_settings() -> Settings:
|
||||||
"""清缓存并重新读取,供脚本与测试使用。"""
|
"""清缓存并重新读取(含 DB 覆盖),供设置页保存后调用。"""
|
||||||
get_settings.cache_clear()
|
get_settings.cache_clear()
|
||||||
return get_settings()
|
return get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
def save_setting(key: str, value) -> None:
|
||||||
|
"""保存单个配置项覆盖到 DB + 清 lru_cache。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: 点分路径,必须在 _OVERIDEABLE_FIELDS 中
|
||||||
|
value: 要保存的值(自动 JSON 编码)
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
if key not in _OVERIDEABLE_FIELDS:
|
||||||
|
raise ValueError(f"不允许修改的配置项:{key}")
|
||||||
|
from .database import get_session_local
|
||||||
|
from .models.setting import Setting
|
||||||
|
type_ = _OVERIDEABLE_FIELDS[key]
|
||||||
|
encoded = json.dumps(type_(value))
|
||||||
|
db = get_session_local()()
|
||||||
|
try:
|
||||||
|
row = db.get(Setting, key)
|
||||||
|
if row is None:
|
||||||
|
row = Setting(key=key, value=encoded)
|
||||||
|
db.add(row)
|
||||||
|
else:
|
||||||
|
row.value = encoded
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
# 清缓存,让后续 get_settings() 读到新值
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"""路由聚合:导出各 controller 的 router,供 main.py include。"""
|
"""路由聚合:导出各 controller 的 router,供 main.py include。"""
|
||||||
|
|
||||||
from .log_router import router as log_router
|
from .log_router import router as log_router
|
||||||
|
from .settings_router import router as settings_router
|
||||||
from .task_router import router as task_router
|
from .task_router import router as task_router
|
||||||
from .upload_router import router as upload_router
|
from .upload_router import router as upload_router
|
||||||
|
|
||||||
__all__ = ["log_router", "task_router", "upload_router"]
|
__all__ = ["log_router", "settings_router", "task_router", "upload_router"]
|
||||||
|
|||||||
92
app/controllers/settings_router.py
Normal file
92
app/controllers/settings_router.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"""设置路由:查询/修改运行时可调参数。
|
||||||
|
|
||||||
|
config.yaml 是只读挂载,改它需重启容器。本路由把部分参数(batch_size 等)
|
||||||
|
存到 DB 的 setting 表,通过 config.save_setting() + reload_settings() 实现
|
||||||
|
运行时热更新:保存后清 lru_cache,后续任务读到新值。
|
||||||
|
|
||||||
|
当前可调项与 config._OVERIDEABLE_FIELDS 对齐。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from ..config import _OVERIDEABLE_FIELDS, get_settings, save_setting
|
||||||
|
|
||||||
|
logger = logging.getLogger("audio2text.settings")
|
||||||
|
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 响应 / 请求 DTO ----------------
|
||||||
|
|
||||||
|
class SettingsResponse(BaseModel):
|
||||||
|
"""当前生效的设置值(YAML 基础 + DB 覆盖后的合并值)。"""
|
||||||
|
asr_batch_size: int
|
||||||
|
asr_beam_size: int
|
||||||
|
translation_batch_size: int
|
||||||
|
translation_sort_by_length: bool
|
||||||
|
# 不可改但展示的只读信息
|
||||||
|
asr_model: str
|
||||||
|
asr_device: str
|
||||||
|
asr_compute_type: str
|
||||||
|
translation_model: str
|
||||||
|
translation_device: str
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsUpdate(BaseModel):
|
||||||
|
"""设置更新请求:只传要改的字段,未传的保持不变。"""
|
||||||
|
asr_batch_size: int | None = Field(default=None, ge=1, le=128)
|
||||||
|
asr_beam_size: int | None = Field(default=None, ge=1, le=10)
|
||||||
|
translation_batch_size: int | None = Field(default=None, ge=1, le=256)
|
||||||
|
translation_sort_by_length: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 字段映射:DTO 字段名 -> config 点分路径 ----------------
|
||||||
|
|
||||||
|
_FIELD_MAP: dict[str, str] = {
|
||||||
|
"asr_batch_size": "asr.batch_size",
|
||||||
|
"asr_beam_size": "asr.beam_size",
|
||||||
|
"translation_batch_size": "translation.batch_size",
|
||||||
|
"translation_sort_by_length": "translation.sort_by_length",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 接口 ----------------
|
||||||
|
|
||||||
|
@router.get("", summary="查询当前生效的设置")
|
||||||
|
def get_current_settings() -> SettingsResponse:
|
||||||
|
"""返回当前生效的设置(YAML 基础 + DB 覆盖合并后的值)。"""
|
||||||
|
s = get_settings()
|
||||||
|
return SettingsResponse(
|
||||||
|
asr_batch_size=s.asr.batch_size,
|
||||||
|
asr_beam_size=s.asr.beam_size,
|
||||||
|
translation_batch_size=s.translation.batch_size,
|
||||||
|
translation_sort_by_length=s.translation.sort_by_length,
|
||||||
|
asr_model=s.asr.model,
|
||||||
|
asr_device=s.asr.device,
|
||||||
|
asr_compute_type=s.asr.compute_type,
|
||||||
|
translation_model=s.translation.model,
|
||||||
|
translation_device=s.translation.device,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("", summary="更新设置(保存后对后续任务生效)")
|
||||||
|
def update_settings(req: SettingsUpdate) -> dict:
|
||||||
|
"""保存修改的设置项到 DB,清配置缓存。
|
||||||
|
|
||||||
|
只处理请求中非 None 的字段。保存后立即生效(后续任务读到新值),
|
||||||
|
已在跑的任务不受影响(任务在各阶段开始时读配置)。
|
||||||
|
"""
|
||||||
|
changed: dict = {}
|
||||||
|
for field, path in _FIELD_MAP.items():
|
||||||
|
val = getattr(req, field)
|
||||||
|
if val is not None:
|
||||||
|
save_setting(path, val)
|
||||||
|
changed[field] = val
|
||||||
|
logger.info("设置已更新:%s = %s(对后续任务生效)", path, val)
|
||||||
|
if not changed:
|
||||||
|
return {"status": "no_change", "changed": {}}
|
||||||
|
return {"status": "saved", "changed": changed}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
"""任务路由:列表 / 状态 / 下载字幕。"""
|
"""任务路由:列表 / 状态 / 下载字幕 / 删除。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import shutil
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -11,10 +13,11 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from ..config import get_settings
|
from ..config import get_settings
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..models.task import Task, STATUS_UPLOADING
|
from ..models.task import Task, STATUS_UPLOADING, STATUS_DONE, STATUS_FAILED
|
||||||
from ..models.upload_session import UploadSession
|
from ..models.upload_session import UploadSession
|
||||||
from ..schemas.task import TaskListResponse, TaskResponse
|
from ..schemas.task import TaskListResponse, TaskResponse
|
||||||
|
|
||||||
|
logger = logging.getLogger("audio2text.tasks")
|
||||||
router = APIRouter(prefix="/api/tasks", tags=["task"])
|
router = APIRouter(prefix="/api/tasks", tags=["task"])
|
||||||
|
|
||||||
|
|
||||||
@@ -129,3 +132,47 @@ def download_subtitle(
|
|||||||
media_type="application/x-subrip",
|
media_type="application/x-subrip",
|
||||||
filename=download_name,
|
filename=download_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{task_id}", summary="删除任务(仅允许已完成/失败)")
|
||||||
|
def delete_task(task_id: int, db: Session = Depends(get_db)) -> dict:
|
||||||
|
"""删除任务及其产物(字幕 / 中间音频 / 保留的原始视频)+ DB 记录。
|
||||||
|
|
||||||
|
仅允许删除已完成(done)或失败(failed)的任务,进行中的任务不可删。
|
||||||
|
"""
|
||||||
|
task = db.get(Task, task_id)
|
||||||
|
if task is None:
|
||||||
|
raise HTTPException(404, f"任务不存在:{task_id}")
|
||||||
|
if task.status not in (STATUS_DONE, STATUS_FAILED):
|
||||||
|
raise HTTPException(409, f"任务进行中,无法删除(当前状态:{task.status})")
|
||||||
|
|
||||||
|
s = get_settings()
|
||||||
|
deleted: list[str] = []
|
||||||
|
|
||||||
|
# 删字幕输出目录
|
||||||
|
out_dir = s.output_dir() / f"task_{task.id}"
|
||||||
|
if out_dir.is_dir():
|
||||||
|
shutil.rmtree(out_dir, ignore_errors=True)
|
||||||
|
deleted.append("outputs")
|
||||||
|
|
||||||
|
# 删中间音频
|
||||||
|
if task.wav_path:
|
||||||
|
wav = Path(task.wav_path)
|
||||||
|
if wav.is_file():
|
||||||
|
wav.unlink(missing_ok=True)
|
||||||
|
deleted.append("audio")
|
||||||
|
|
||||||
|
# 删保留的原始视频
|
||||||
|
if task.source_path:
|
||||||
|
src = s.upload_dir() / task.source_path
|
||||||
|
if src.is_file():
|
||||||
|
src.unlink(missing_ok=True)
|
||||||
|
deleted.append("video")
|
||||||
|
|
||||||
|
# 删 DB 记录(先删关联的 UploadSession,再删 Task)
|
||||||
|
db.query(UploadSession).filter(UploadSession.task_id == task.id).delete()
|
||||||
|
db.delete(task)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info("删除任务 %d(%s):%s", task_id, task.filename, ", ".join(deleted) or "无产物")
|
||||||
|
return {"status": "deleted", "task_id": task_id, "cleaned": deleted}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ def init_db_schema() -> None:
|
|||||||
"""
|
"""
|
||||||
from .models.task import Task # noqa: F401
|
from .models.task import Task # noqa: F401
|
||||||
from .models.upload_session import UploadSession # noqa: F401
|
from .models.upload_session import UploadSession # noqa: F401
|
||||||
|
from .models.setting import Setting # noqa: F401
|
||||||
|
|
||||||
engine = get_engine()
|
engine = get_engine()
|
||||||
Base.metadata.create_all(engine)
|
Base.metadata.create_all(engine)
|
||||||
|
|||||||
19
app/main.py
19
app/main.py
@@ -19,21 +19,21 @@ import logging
|
|||||||
import threading
|
import threading
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import Depends, FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
|
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .config import get_settings
|
from .config import get_settings
|
||||||
from .controllers import log_router, task_router, upload_router
|
from .controllers import log_router, settings_router, task_router, upload_router
|
||||||
from .database import get_db, init_db_schema
|
from .database import get_db, init_db_schema
|
||||||
from .security import require_docs_auth
|
|
||||||
from .services.cache_cleaner import purge_expired_cache, run_forever as run_cache_cleaner
|
from .services.cache_cleaner import purge_expired_cache, run_forever as run_cache_cleaner
|
||||||
from .services.log_buffer import init_log_buffer
|
from .services.log_buffer import init_log_buffer
|
||||||
from .services.reaper import reap_stale_sessions
|
from .services.reaper import reap_stale_sessions
|
||||||
from .views.history_html import render as render_history_html
|
from .views.history_html import render as render_history_html
|
||||||
from .views.home_html import render as render_home_html
|
from .views.home_html import render as render_home_html
|
||||||
from .views.logs_html import render as render_logs_html
|
from .views.logs_html import render as render_logs_html
|
||||||
|
from .views.settings_html import render as render_settings_html
|
||||||
|
|
||||||
# 日志分层:
|
# 日志分层:
|
||||||
# - audio2text logger 始终设 DEBUG,确保所有记录(含子步骤)都能产生。
|
# - audio2text logger 始终设 DEBUG,确保所有记录(含子步骤)都能产生。
|
||||||
@@ -126,20 +126,21 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(upload_router)
|
app.include_router(upload_router)
|
||||||
app.include_router(task_router)
|
app.include_router(task_router)
|
||||||
app.include_router(log_router)
|
app.include_router(log_router)
|
||||||
|
app.include_router(settings_router)
|
||||||
|
|
||||||
# 受 Basic Auth 保护的文档接口
|
# 文档接口(无认证,直接公开)
|
||||||
@app.get("/openapi.json")
|
@app.get("/openapi.json")
|
||||||
def protected_openapi(_: str = Depends(require_docs_auth)) -> JSONResponse:
|
def openapi_endpoint() -> JSONResponse:
|
||||||
return JSONResponse(app.openapi())
|
return JSONResponse(app.openapi())
|
||||||
|
|
||||||
@app.get("/docs")
|
@app.get("/docs")
|
||||||
def protected_docs(_: str = Depends(require_docs_auth)):
|
def docs_endpoint():
|
||||||
return get_swagger_ui_html(
|
return get_swagger_ui_html(
|
||||||
openapi_url="/openapi.json", title="audio2text docs", swagger_favicon_url=""
|
openapi_url="/openapi.json", title="audio2text docs", swagger_favicon_url=""
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.get("/redoc")
|
@app.get("/redoc")
|
||||||
def protected_redoc(_: str = Depends(require_docs_auth)):
|
def redoc_endpoint():
|
||||||
return get_redoc_html(
|
return get_redoc_html(
|
||||||
openapi_url="/openapi.json", title="audio2text docs", redoc_favicon_url=""
|
openapi_url="/openapi.json", title="audio2text docs", redoc_favicon_url=""
|
||||||
)
|
)
|
||||||
@@ -187,6 +188,10 @@ def create_app() -> FastAPI:
|
|||||||
def logs_page() -> HTMLResponse:
|
def logs_page() -> HTMLResponse:
|
||||||
return HTMLResponse(render_logs_html())
|
return HTMLResponse(render_logs_html())
|
||||||
|
|
||||||
|
@app.get("/settings", response_class=HTMLResponse)
|
||||||
|
def settings_page() -> HTMLResponse:
|
||||||
|
return HTMLResponse(render_settings_html())
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
36
app/models/setting.py
Normal file
36
app/models/setting.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"""运行时设置覆盖(键值存储)。
|
||||||
|
|
||||||
|
config.yaml 是只读挂载(镜像内不含配置),改完需重启容器才生效。
|
||||||
|
本表持久化用户在「设置页」修改的参数,进程重启后自动加载,
|
||||||
|
无需改 config.yaml 或重建镜像。
|
||||||
|
|
||||||
|
当前支持的键见 _ALLOWED_KEYS(settings_router 维护),值为 JSON 字符串。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import String, DateTime, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from ..database import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class Setting(Base):
|
||||||
|
"""单个配置项的覆盖值(key = 'asr.batch_size' 之类的点分路径)。"""
|
||||||
|
|
||||||
|
__tablename__ = "setting"
|
||||||
|
|
||||||
|
key: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||||
|
value: Mapped[str] = mapped_column(Text) # JSON 编码的值
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, default=_now, onupdate=_now,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"Setting(key={self.key!r}, value={self.value!r})"
|
||||||
@@ -19,6 +19,7 @@ _NAV_ITEMS = [
|
|||||||
("/", "主页", "home"),
|
("/", "主页", "home"),
|
||||||
("/history", "历史", "history"),
|
("/history", "历史", "history"),
|
||||||
("/logs", "日志", "logs"),
|
("/logs", "日志", "logs"),
|
||||||
|
("/settings", "设置", "settings"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,12 @@ function renderTable(tasks) {{
|
|||||||
}} else if (task.status === "failed") {{
|
}} else if (task.status === "failed") {{
|
||||||
action = `<span class="err-tip" title="${{escapeHtml(task.error || "")}}">查看错误</span>`;
|
action = `<span class="err-tip" title="${{escapeHtml(task.error || "")}}">查看错误</span>`;
|
||||||
}} else {{
|
}} else {{
|
||||||
action = `<span class="muted">—</span>`;
|
action = `<span class="muted">-</span>`;
|
||||||
|
}}
|
||||||
|
// done/failed 且非上传中:加删除按钮
|
||||||
|
const canDelete = (task.status === "done" || task.status === "failed") && !task.is_upload;
|
||||||
|
if (canDelete) {{
|
||||||
|
action += ` <button class="btn-sm" onclick="deleteTask(${{task.id}})">删除</button>`;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
let progress;
|
let progress;
|
||||||
@@ -117,6 +122,17 @@ function renderPagination() {{
|
|||||||
paginationEl.innerHTML = html;
|
paginationEl.innerHTML = html;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
async function deleteTask(taskId) {{
|
||||||
|
if (!confirm("确认删除任务 #" + taskId + "?字幕和中间文件将被清除。")) return;
|
||||||
|
try {{
|
||||||
|
const r = await fetch("/api/tasks/" + taskId, {{ method: "DELETE" }});
|
||||||
|
if (!r.ok) {{ alert("删除失败:" + await r.text()); return; }}
|
||||||
|
load(currentOffset);
|
||||||
|
}} catch (e) {{
|
||||||
|
alert("删除失败:" + e);
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
|
||||||
load(0);
|
load(0);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,12 @@ function renderTaskInner(task) {{
|
|||||||
try {{ created = fmtDateTime24(new Date(task.created_at + "Z")); }}
|
try {{ created = fmtDateTime24(new Date(task.created_at + "Z")); }}
|
||||||
catch (e) {{ created = task.created_at; }}
|
catch (e) {{ created = task.created_at; }}
|
||||||
|
|
||||||
|
// 删除按钮:仅 done/failed 且非上传中任务显示
|
||||||
|
const canDelete = (task.status === "done" || task.status === "failed") && !task.is_upload;
|
||||||
|
const delBtn = canDelete
|
||||||
|
? `<button class="btn-sm del-btn" onclick="deleteTask(${{task.id}}, this)">删除</button>`
|
||||||
|
: "";
|
||||||
|
|
||||||
let body;
|
let body;
|
||||||
if (task.status === "done") {{
|
if (task.status === "done") {{
|
||||||
body = `<div class="task-meta"><b>完成</b> · ${{downloadLinks(task.id)}}</div>`;
|
body = `<div class="task-meta"><b>完成</b> · ${{downloadLinks(task.id)}}</div>`;
|
||||||
@@ -91,11 +97,30 @@ function renderTaskInner(task) {{
|
|||||||
<div class="task-head">
|
<div class="task-head">
|
||||||
<span class="fname">${{task.is_upload ? "" : "#" + task.id + " "}}${{escapeHtml(task.filename)}}</span>
|
<span class="fname">${{task.is_upload ? "" : "#" + task.id + " "}}${{escapeHtml(task.filename)}}</span>
|
||||||
<span class="fstate ${{stateClass}}">${{label}}</span>
|
<span class="fstate ${{stateClass}}">${{label}}</span>
|
||||||
|
${{delBtn}}
|
||||||
</div>
|
</div>
|
||||||
${{body}}
|
${{body}}
|
||||||
<div class="task-time">${{created}}</div>`;
|
<div class="task-time">${{created}}</div>`;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
async function deleteTask(taskId, btn) {{
|
||||||
|
if (!confirm("确认删除任务 #" + taskId + "?字幕和中间文件将被清除。")) return;
|
||||||
|
btn.disabled = true;
|
||||||
|
try {{
|
||||||
|
const r = await fetch("/api/tasks/" + taskId, {{ method: "DELETE" }});
|
||||||
|
if (!r.ok) {{
|
||||||
|
const err = await r.text();
|
||||||
|
alert("删除失败:" + err);
|
||||||
|
btn.disabled = false;
|
||||||
|
return;
|
||||||
|
}}
|
||||||
|
refreshList();
|
||||||
|
}} catch (e) {{
|
||||||
|
alert("删除失败:" + e);
|
||||||
|
btn.disabled = false;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
|
||||||
async function pollTask(task) {{
|
async function pollTask(task) {{
|
||||||
// 上传会话:轮询 upload status 接口;Task:轮询 task 接口
|
// 上传会话:轮询 upload status 接口;Task:轮询 task 接口
|
||||||
const isUpload = task.is_upload === true && task.upload_id;
|
const isUpload = task.is_upload === true && task.upload_id;
|
||||||
|
|||||||
182
app/views/settings_html.py
Normal file
182
app/views/settings_html.py
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
"""设置页:调整批处理大小等运行时参数,保存后对后续任务生效。
|
||||||
|
|
||||||
|
页面结构:表单展示当前生效值(GET /api/settings),用户修改后点保存(PUT /api/settings),
|
||||||
|
保存到 DB 并清配置缓存,后续任务读到新值。已在跑的任务不受影响。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ._shared import render_page
|
||||||
|
|
||||||
|
|
||||||
|
# 页面专属 CSS
|
||||||
|
_PAGE_CSS = """
|
||||||
|
.field-group { margin: 1em 0; }
|
||||||
|
.field-group h2 { margin-bottom: 0.3em; }
|
||||||
|
.field-row {
|
||||||
|
display: flex; align-items: center; gap: 0.8em;
|
||||||
|
padding: 0.6em 0; border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.field-row:last-child { border-bottom: none; }
|
||||||
|
.field-label { font-weight: 600; min-width: 200px; }
|
||||||
|
.field-desc { color: var(--muted); font-size: 0.82em; flex: 1; }
|
||||||
|
.field-input { width: 80px; }
|
||||||
|
.field-input[type="number"] {
|
||||||
|
padding: 0.3em 0.5em; border: 1px solid var(--border); border-radius: 4px;
|
||||||
|
background: var(--card-bg); color: var(--fg); font-size: 0.92em; text-align: center;
|
||||||
|
}
|
||||||
|
.field-input[type="checkbox"] { width: auto; transform: scale(1.3); }
|
||||||
|
.readonly-info {
|
||||||
|
display: grid; grid-template-columns: 1fr 1fr; gap: 0.5em 1.5em;
|
||||||
|
margin: 1em 0; padding: 0.8em 1em; background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border); border-radius: 8px; font-size: 0.88em;
|
||||||
|
}
|
||||||
|
.readonly-info .kv { display: flex; gap: 0.5em; }
|
||||||
|
.readonly-info .k { color: var(--muted); min-width: 90px; }
|
||||||
|
.save-bar { display: flex; align-items: center; gap: 1em; margin-top: 1.2em; }
|
||||||
|
.save-msg { font-size: 0.88em; }
|
||||||
|
.save-msg.ok { color: var(--success); }
|
||||||
|
.save-msg.err { color: var(--error); }
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 页面专属 JS(f-string,花括号需 {{ }})
|
||||||
|
_PAGE_JS = """
|
||||||
|
let originalValues = {};
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/settings");
|
||||||
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||||
|
const s = await r.json();
|
||||||
|
document.getElementById("asr_batch_size").value = s.asr_batch_size;
|
||||||
|
document.getElementById("asr_beam_size").value = s.asr_beam_size;
|
||||||
|
document.getElementById("translation_batch_size").value = s.translation_batch_size;
|
||||||
|
document.getElementById("translation_sort_by_length").checked = s.translation_sort_by_length;
|
||||||
|
// 只读信息
|
||||||
|
document.getElementById("ro_asr_model").textContent = s.asr_model;
|
||||||
|
document.getElementById("ro_asr_device").textContent = s.asr_device;
|
||||||
|
document.getElementById("ro_asr_compute_type").textContent = s.asr_compute_type;
|
||||||
|
document.getElementById("ro_translation_model").textContent = s.translation_model;
|
||||||
|
document.getElementById("ro_translation_device").textContent = s.translation_device;
|
||||||
|
// 记录原始值用于检测是否有变更
|
||||||
|
originalValues = {
|
||||||
|
asr_batch_size: s.asr_batch_size,
|
||||||
|
asr_beam_size: s.asr_beam_size,
|
||||||
|
translation_batch_size: s.translation_batch_size,
|
||||||
|
translation_sort_by_length: s.translation_sort_by_length,
|
||||||
|
};
|
||||||
|
setMsg("", "");
|
||||||
|
} catch (e) {
|
||||||
|
setMsg("加载失败:" + escapeHtml(String(e.message || e)), "err");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectChanges() {
|
||||||
|
const body = {};
|
||||||
|
const cur = {
|
||||||
|
asr_batch_size: parseInt(document.getElementById("asr_batch_size").value, 10),
|
||||||
|
asr_beam_size: parseInt(document.getElementById("asr_beam_size").value, 10),
|
||||||
|
translation_batch_size: parseInt(document.getElementById("translation_batch_size").value, 10),
|
||||||
|
translation_sort_by_length: document.getElementById("translation_sort_by_length").checked,
|
||||||
|
};
|
||||||
|
for (const [k, v] of Object.entries(cur)) {
|
||||||
|
if (v !== originalValues[k]) body[k] = v;
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSettings() {
|
||||||
|
const changes = collectChanges();
|
||||||
|
if (Object.keys(changes).length === 0) {
|
||||||
|
setMsg("没有变更", "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const btn = document.getElementById("save-btn");
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = "保存中...";
|
||||||
|
setMsg("正在保存...", "");
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/settings", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: JSON.stringify(changes),
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error("HTTP " + r.status + " " + await r.text());
|
||||||
|
const resp = await r.json();
|
||||||
|
setMsg("已保存:" + Object.keys(resp.changed).join(", ") + "(对后续任务生效)", "ok");
|
||||||
|
await loadSettings(); // 重新加载确认
|
||||||
|
} catch (e) {
|
||||||
|
setMsg("保存失败:" + escapeHtml(String(e.message || e)), "err");
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = "保存设置";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMsg(text, cls) {
|
||||||
|
const el = document.getElementById("save-msg");
|
||||||
|
el.textContent = text;
|
||||||
|
el.className = "save-msg" + (cls ? " " + cls : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSettings();
|
||||||
|
"""
|
||||||
|
|
||||||
|
_BODY = """
|
||||||
|
<h1>设置</h1>
|
||||||
|
<p class="sub">调整批处理大小等运行时参数。保存后对<strong>后续任务</strong>生效,已在运行的任务不受影响。</p>
|
||||||
|
|
||||||
|
<div class="field-group">
|
||||||
|
<h2>语音识别(ASR)</h2>
|
||||||
|
<div class="field-row">
|
||||||
|
<span class="field-label">batch_size</span>
|
||||||
|
<input type="number" id="asr_batch_size" class="field-input" min="1" max="128" value="16">
|
||||||
|
<span class="field-desc">批量解码的音频块数。增大可拉长单次 GPU 解码、提升利用率,但显存占用增加</span>
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<span class="field-label">beam_size</span>
|
||||||
|
<input type="number" id="asr_beam_size" class="field-input" min="1" max="10" value="5">
|
||||||
|
<span class="field-desc">beam search 宽度。GPU turbo 建议 2(加速、质量损失小),CPU 建议 5</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group">
|
||||||
|
<h2>翻译(NLLB)</h2>
|
||||||
|
<div class="field-row">
|
||||||
|
<span class="field-label">batch_size</span>
|
||||||
|
<input type="number" id="translation_batch_size" class="field-input" min="1" max="256" value="32">
|
||||||
|
<span class="field-desc">翻译批量大小。显存独占时可用大 batch 填充 GPU</span>
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<span class="field-label">sort_by_length</span>
|
||||||
|
<input type="checkbox" id="translation_sort_by_length" class="field-input">
|
||||||
|
<span class="field-desc">按句子长度排序后分批,减少批内 padding 浪费(GPU 收益大)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="save-bar">
|
||||||
|
<button class="btn" id="save-btn" onclick="saveSettings()">保存设置</button>
|
||||||
|
<span id="save-msg" class="save-msg"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group">
|
||||||
|
<h2>设备信息(只读)</h2>
|
||||||
|
<div class="readonly-info">
|
||||||
|
<div class="kv"><span class="k">ASR 模型</span><span id="ro_asr_model"></span></div>
|
||||||
|
<div class="kv"><span class="k">ASR 设备</span><span id="ro_asr_device"></span></div>
|
||||||
|
<div class="kv"><span class="k">计算精度</span><span id="ro_asr_compute_type"></span></div>
|
||||||
|
<div class="kv"><span class="k">翻译模型</span><span id="ro_translation_model"></span></div>
|
||||||
|
<div class="kv"><span class="k">翻译设备</span><span id="ro_translation_device"></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def render() -> str:
|
||||||
|
return render_page(
|
||||||
|
title="audio2text - 设置",
|
||||||
|
nav_active="settings",
|
||||||
|
body=_BODY,
|
||||||
|
page_js=_PAGE_JS,
|
||||||
|
page_css=_PAGE_CSS,
|
||||||
|
)
|
||||||
@@ -94,6 +94,94 @@ docker compose --profile cpu up -d --build # CPU 生产
|
|||||||
docker compose --profile gpu up -d --build # GPU 生产(需 nvidia runtime)
|
docker compose --profile gpu up -d --build # GPU 生产(需 nvidia runtime)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 导入预构建镜像(离线部署)
|
||||||
|
|
||||||
|
当目标机器无法访问 Docker Hub(或构建太慢)时,可在已构建好镜像的机器上导出 tar,
|
||||||
|
拷到新机器导入,跳过整个构建过程。
|
||||||
|
|
||||||
|
#### 前置要求(新机器)
|
||||||
|
|
||||||
|
- **NVIDIA GPU 驱动**(宿主机)
|
||||||
|
- **nvidia container runtime**(`nvidia-container-toolkit`)
|
||||||
|
- Docker
|
||||||
|
- `config.gpu.yaml` 配置文件(从项目仓库取,或自行编写)
|
||||||
|
- 模型缓存 `./models` 目录(约 5.5GB,从源机器拷贝或联网预拉)
|
||||||
|
|
||||||
|
验证 GPU 可用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nvidia-smi # 宿主能看到 GPU
|
||||||
|
docker run --rm --gpus all nvidia/cuda:12.1.0-runtime-ubuntu22.04 nvidia-smi
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 步骤 1:源机器导出镜像
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在已构建好 audio2text:gpu 镜像的机器上
|
||||||
|
docker save -o audio2text-gpu.tar audio2text:gpu
|
||||||
|
ls -lh audio2text-gpu.tar # ~4.3GB
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 步骤 2:拷贝到新机器
|
||||||
|
|
||||||
|
需要拷贝的文件:
|
||||||
|
|
||||||
|
| 文件/目录 | 大小 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `audio2text-gpu.tar` | ~4.3GB | Docker 镜像(含 ffmpeg + torch + faster-whisper + transformers + app 代码) |
|
||||||
|
| `config.gpu.yaml` | <1KB | GPU 配置文件 |
|
||||||
|
| `models/` | ~5.5GB | 模型缓存(Whisper large-v3-turbo + NLLB distilled-1.3B 权重) |
|
||||||
|
|
||||||
|
> `models/` 可不拷贝,新机器联网时用 `prefetch_models.sh` 预拉。但离线部署必须拷贝。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 用 scp / rsync / U盘 等方式拷贝
|
||||||
|
scp audio2text-gpu.tar config.gpu.yaml user@newhost:~/audio2text/
|
||||||
|
rsync -avP models/ user@newhost:~/audio2text/models/
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 步骤 3:新机器导入并启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/audio2text
|
||||||
|
|
||||||
|
# 1. 导入镜像
|
||||||
|
docker load -i audio2text-gpu.tar
|
||||||
|
# 输出:Loaded image: audio2text:gpu
|
||||||
|
|
||||||
|
# 2. 准备数据目录
|
||||||
|
mkdir -p data-gpu/uploads data-gpu/.work data-gpu/outputs
|
||||||
|
|
||||||
|
# 3. 启动容器
|
||||||
|
docker run -d --name audio2text-gpu \
|
||||||
|
--gpus all \
|
||||||
|
-p 8001:8000 \
|
||||||
|
-v "$(pwd)/data-gpu:/data" \
|
||||||
|
-v "$(pwd)/models:/models" \
|
||||||
|
-v "$(pwd)/config.gpu.yaml:/app/config.yaml:ro" \
|
||||||
|
--restart unless-stopped \
|
||||||
|
audio2text:gpu
|
||||||
|
|
||||||
|
# 4. 验证
|
||||||
|
curl -s http://127.0.0.1:8001/health | python -m json.tool
|
||||||
|
# 应见 cuda_available=true, gpu="NVIDIA GeForce RTX 3090"
|
||||||
|
```
|
||||||
|
|
||||||
|
打开 `http://127.0.0.1:8001/` 即可使用。
|
||||||
|
|
||||||
|
#### 离线运行说明
|
||||||
|
|
||||||
|
镜像内置 `HF_HUB_OFFLINE=1` + `TRANSFORMERS_OFFLINE=1` 环境变量,模型缓存就位后
|
||||||
|
**完全离线运行**,不会尝试访问 HuggingFace。这避免了离线环境下 transformers
|
||||||
|
pipeline 因网络请求超时导致的翻译失败。
|
||||||
|
|
||||||
|
#### 后续更新代码
|
||||||
|
|
||||||
|
导入的镜像包含导出时的 app 代码。如需更新代码,有两个选择:
|
||||||
|
|
||||||
|
1. **重新构建**:把项目代码拷到新机器,`docker build --build-arg VARIANT=gpu -t audio2text:gpu .`
|
||||||
|
2. **挂载源码**(临时调试):启动时加 `-v "$(pwd)/app:/app/app"` 覆盖镜像内代码
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 缓存分层与删除边界
|
## 缓存分层与删除边界
|
||||||
|
|||||||
Reference in New Issue
Block a user