Files
xiaoxia-saas/packages/config/base.py
T
saas-backend-agent 105ab54059 feat(gpu): 接入 P4000 NVENC 硬件编码加速
- packages/config/base.py: 新增 GPU_ENCODE_* 配置项(开关/endpoint/relay/secret/编码参数/fallback)
- packages/shared/gpu_encoder.py: 新增 GpuEncoderClient,封装 health 探测 + mezzanine 上传 OSS + P4000 nvenc 编码 + relay 回传下载,失败抛 GpuEncodeError 触发 CPU 降级
- apps/api/app/api/routes/gpu_relay.py: 新增 internal PUT/GET/DELETE /api/v1/internal/gpu-relay/{key}(token 鉴权),P4000 PUT 编码结果,worker GET 下载
- apps/api/app/api/router.py: 注册 gpu_relay_router
- apps/worker/video_processing/unified_render_service.py: _execute_ffmpeg 和 _render_pass_through 尝试 GPU 路径:CPU ultrafast mezzanine → P4000 nvenc → 输出到最终路径;任何失败自动回退到原 CPU libx264 路径
- apps/worker/worker_app/tasks/_startup.py: worker_ready 时探测 P4000 健康并打日志
- tests/unit/test_gpu_encoder.py: GpuEncoderClient 单测(health/sync 调用/失败/fallback/relay URL)

架构:
- 输入:CPU 输出 libx264 ultrafast mezzanine → 上传 OSS 临时前缀 → P4000 签名 URL 下载
- 输出:P4000 PUT → 宿主机 nginx(tailscale:80)→ API /api/ 反代 → gpu_relay 路由落盘到 generated/gpu_relay/
- 回传:worker 通过 docker 网络 http://xiaoxia-api-staging:8000 GET 下载最终 mp4 到 output_path
- 降级:GPU 任何环节异常(health/上传/编码/回传/下载)→ 原 CPU 路径继续执行,不影响成片
2026-09-26 20:43:21 +08:00

238 lines
12 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""统一配置基类 — 所有服务共享的基础配置。
数据库/Redis/OSS/Celery/AI服务等通用配置统一定义在此。
API 和 Worker 各自的 Settings 类继承本类,只追加服务特有字段。
单例模式和 env 文件加载逻辑也统一在这里实现。
"""
import os
from typing import Optional, TypeVar
from pydantic import AliasChoices, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
T = TypeVar("T", bound=BaseSettings)
class SharedSettings(BaseSettings):
"""所有服务共享的基础配置。
API 和 Worker 都继承本类,确保:
1. 数据库/Redis/OSS/Celery 等核心配置默认值一致
2. 环境变量命名统一(snake_case,pydantic-settings 自动兼容大写)
3. env 文件加载逻辑只实现一次
"""
# ── 环境 ──────────────────────────────────────────────────────────────
environment: str = "development"
debug: bool = True
auto_create_schema: bool = False
# ── 数据库 ────────────────────────────────────────────────────────────
database_url: str = "postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas"
database_pool_size: int = 20
database_max_overflow: int = 10 # pool_size(20) + max_overflow(10) = 最大30连接
database_pool_timeout: int = 30
database_pool_recycle: int = 3600
# 测试用:使用 SQLite 内存数据库(CI 环境无需 PostgreSQL)
use_in_memory_db: bool = False
# ── Redis ────────────────────────────────────────────────────────────
redis_url: str = "redis://localhost:6379/0"
# ── Celery ───────────────────────────────────────────────────────────
celery_broker_url: str = "redis://localhost:6379/0"
celery_result_backend: str = "redis://localhost:6379/1"
# ── OSS 阿里云 ──────────────────────────────────────────────────────
oss_endpoint: str = "oss-cn-hangzhou.aliyuncs.com"
oss_access_key_id: str = ""
oss_access_key_secret: str = ""
oss_bucket_name: str = "xiaoxia-autocut"
oss_direct_upload_max_mb: int = 2000
oss_direct_upload_expire_seconds: int = 900
# ── CosyVoice (阿里云百炼语音合成) ───────────────────────────────────
cosyvoice_api_key: str = ""
cosyvoice_base_url: str = "https://dashscope.aliyuncs.com/api/v1"
cosyvoice_model: str = "cosyvoice-v3-flash"
cosyvoice_voice: str = "longxiaochun_v3" # 默认音色(v3 系列系统音色带 _v3 后缀)
cosyvoice_sample_rate: int = 22050
cosyvoice_format: str = "mp3" # 输出格式:mp3/wav/pcm
# 音色克隆模型名(固定为 voice-enrollment)
cosyvoice_clone_model: str = "voice-enrollment"
# ── 豆包大模型(火山引擎方舟) ────────────────────────────────────────
doubao_api_key: str = ""
doubao_model: str = "doubao-seed-1-6-250615"
doubao_base_url: str = "https://ark.cn-beijing.volces.com/api/v3"
doubao_timeout: int = 30
doubao_max_retries: int = 2
doubao_vision_model: str = "doubao-1-5-vision-pro-250915"
doubao_embedding_model: str = "doubao-embedding-large-text-240915"
# ── MediaKit (火山引擎 AI 媒体工具) ──────────────────────────────────
mediakit_api_key: str = ""
mediakit_base_url: str = "https://mediakit.cn-beijing.volces.com/api/v1"
mediakit_timeout: int = 60
# ── 积分/会员系统 (#1895) ────────────────────────────────────────────
# 积分系统总开关(产品要求 #1895:暂停积分系统但保留全部代码/表/接口)。
# - false(默认):所有 AI 功能(生成视频/口型/数字人/AI标题/TTS/克隆音色…)
# 对全部登录用户免费放行,不扣积分、不做余额拦截;积分余额/流水/会员
# 状态等查询接口保持可用,但数据不再变动。
# - 未来恢复:只需设置环境变量 ENABLE_CREDIT_SYSTEM=true。
# 旧开关 POINTS_ENABLED 仍保留作为兼容别名(两者任一为 true 即启用)。
# 主开关(推荐环境变量名 ENABLE_CREDIT_SYSTEM)
credits_enabled: bool = Field(
default=False,
validation_alias=AliasChoices("ENABLE_CREDIT_SYSTEM", "credits_enabled"),
)
# 旧开关兼容(POINTS_ENABLED);两者任一为 true 即启用
points_enabled_compat: bool = Field(
default=False,
validation_alias=AliasChoices("POINTS_ENABLED", "points_enabled_compat"),
)
@property
def points_enabled(self) -> bool:
"""旧代码/测试使用的属性名,等价于积分系统总开关(兼容别名)。"""
return bool(self.credits_enabled or self.points_enabled_compat)
@points_enabled.setter
def points_enabled(self, value: bool) -> None:
# 支持旧测试/代码 ``settings.points_enabled = True`` 的写法
self.credits_enabled = bool(value)
self.points_enabled_compat = False
# ── GPU MuseTalk 反向轮询 Worker ────────────────────────────────────
# Worker 用这个长期 Token 鉴权(不是用户 JWT)。多 Worker 共用同一个 Token;
# worker_id 用于区分具体机器。生产必须配置;development 留空会跳过校验。
gpu_worker_token: str = ""
# GPU 任务超时(秒):processing 状态超过此时长(以任务心跳为准)才回退
# pending / failed。#1970:RTX2060 6G 推理 720p 长视频需 5 分钟以上,300→900。
# Worker 推理期间每 30s 通过 /gpu/register(task_id=...) 续心跳,
# 只有真正超时或 Worker 明确上报 failed 才会回退。
gpu_task_timeout_seconds: int = 900
# 结果预签名 URL 有效期(秒)
gpu_result_url_expires: int = 3600
# 输入预签名 URL 有效期(秒,需留出 Worker 下载时间)
gpu_input_url_expires: int = 3600
# 业务侧是否启用 GPU 口型同步(开关);关或无可用 Worker 时回退 MediaKit 云端
use_gpu_lipsync: bool = False
# 业务侧轮询 GPU 任务结果的间隔(秒)
gpu_lipsync_poll_interval: float = 5.0
# 业务侧等待 GPU 任务结果的总超时(秒);超时后回退 MediaKit。
# 应小于等于 gpu_task_timeout_seconds(默认900s)+ 冗余,留足 Worker 下载/上传时间。
gpu_lipsync_wait_timeout: int = 1200
# 判断 Worker 可用的心跳新鲜度窗口(秒)—— last_heartbeat_at 在窗口内视为在线
gpu_worker_stale_seconds: int = 300
# ── P4000 NVENC 硬件编码 ────────────────────────────────────────────
# GPU 编码总开关;关闭或 endpoint 为空时始终走本机 CPU libx264
enable_gpu_encode: bool = Field(
default=False,
validation_alias=AliasChoices("ENABLE_GPU_ENCODE", "enable_gpu_encode"),
)
# P4000 编码节点地址(Tailscale 内网),例如 http://100.105.75.67:8900
gpu_encode_endpoint: str = Field(
default="",
validation_alias=AliasChoices("GPU_ENCODE_ENDPOINT", "gpu_encode_endpoint"),
)
# GPU 回传临时文件走公网/内网 nginx(/gpu-relay/ 已加 location);
# 形如 http://100.69.73.60/gpu-relay (不带尾斜杠)
gpu_encode_relay_base_url: str = Field(
default="",
validation_alias=AliasChoices("GPU_ENCODE_RELAY_BASE_URL", "gpu_encode_relay_base_url"),
)
# 同步调用超时(秒):含编码+上传回传,5 分钟足够短视频
gpu_encode_sync_timeout: int = 300
# 异步轮询总超时(秒):长视频走 async + 轮询
gpu_encode_async_timeout: int = 1800
# 轮询间隔(秒)
gpu_encode_poll_interval: float = 3.0
# 启动探测超时(秒)
gpu_encode_health_timeout: float = 3.0
# NVENC 默认编码参数(可被调用方覆盖)
gpu_encode_vcodec: str = "h264_nvenc"
gpu_encode_preset: str = "p4" # NVENC preset: p1(最快)~p7(最好),p4 为均衡
gpu_encode_crf: int = 23
gpu_encode_bitrate: str = "" # 空则用 crf;非空则用 -b:v 模式
# GPU 编码失败时是否自动降级到 CPU(默认 True);设为 False 可在 CI/测试中暴露错误
gpu_encode_fallback_cpu: bool = Field(
default=True,
validation_alias=AliasChoices("GPU_ENCODE_FALLBACK_CPU", "gpu_encode_fallback_cpu"),
)
# P4000 → relay 回传鉴权 token(query 参数 token=xxx)。
# 生产环境必须设置;未设置且非 production 时自动生成随机值(写日志方便排查)。
gpu_encode_relay_secret: str = Field(
default="",
validation_alias=AliasChoices("GPU_ENCODE_RELAY_SECRET", "gpu_encode_relay_secret"),
)
# GPU 中间片在 OSS 的临时前缀(worker 上传 mezzanine 供 P4000 下载)
gpu_encode_oss_tmp_prefix: str = Field(
default="tmp/gpu-mezzanine/",
validation_alias=AliasChoices("GPU_ENCODE_OSS_TMP_PREFIX", "gpu_encode_oss_tmp_prefix"),
)
# relay 写入目录(相对于 generated-files 根目录)
gpu_encode_relay_dir: str = Field(
default="gpu_relay",
validation_alias=AliasChoices("GPU_ENCODE_RELAY_DIR", "gpu_encode_relay_dir"),
)
# relay 文件保留时间(秒),worker 下载完成后会主动删除,此为兜底清理 TTL
gpu_encode_relay_ttl: int = 3600
@property
def effective_database_url(self) -> str:
"""返回实际使用的数据库 URL。
当 USE_IN_MEMORY_DB=True 时返回 SQLite 内存 URL,否则返回 database_url。
"""
if self.use_in_memory_db:
return "sqlite:///./test.db"
return self.database_url
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
# ── 统一单例管理 ────────────────────────────────────────────────────────
# 所有 Settings 类的单例缓存都在这里,消除每处各自实现的重复代码
_settings_cache: dict[str, BaseSettings] = {}
def _get_env_file() -> str:
"""根据 APP_ENV 决定读取哪个 env 文件。"""
env = os.getenv("APP_ENV", "development")
env_file = f".env.{env}" if env != "development" else ".env"
return env_file if os.path.exists(env_file) else ".env"
def get_cached_settings(settings_class: type[T], cache_key: Optional[str] = None) -> T:
"""统一的 Settings 单例获取函数。
所有服务都通过这个函数获取配置,消除重复的单例实现。
按类名缓存,同一类只初始化一次。
"""
key = cache_key or settings_class.__name__
if key not in _settings_cache:
env_file = _get_env_file()
_settings_cache[key] = settings_class(_env_file=env_file)
return _settings_cache[key] # type: ignore[return-value]
def reload_settings_cache() -> None:
"""清空配置缓存,下次获取时重新加载。测试用。"""
_settings_cache.clear()
def get_shared_settings() -> SharedSettings:
"""获取共享配置单例(统一入口)。"""
return get_cached_settings(SharedSettings)