Compare commits

..

2 Commits

Author SHA1 Message Date
xiaoxia a3e885a595 fix(generate): 标题模板切换为内联大卡片网格模式 + 撤销错误Modal方案
- GeneratePage: enableTemplates 从 false 改为 true,启用 TitleStylePanel 的 ttv3 大卡片模板网格模式
- Step4TitleSettings: 从 develop 恢复原版(直接渲染 TitleStylePanel,enableTemplates=true 时展示大卡片网格+编辑/复制/删除+系统标签+紫色高亮选中+右上角「新建模板」按钮+TitleTemplateEditor编辑弹窗),撤销上一版错误的 TitleStyleModal 弹窗入口方案
- 删除上一版错误新增的 TitleStyleModal.tsx / TitleStyleModal.css(错误方向:选样式走弹窗;正确方向:大卡片网格内嵌,仅编辑/新建时弹窗)
- 保留:Step6 封面本地上传 OSS 直传修复 + handleFinish blob: URL 防御兜底(这部分是 P0 正确修复)

Closes: 标题样式应该是内联大卡片网格而非弹窗选样式(9/23 已做对,PR#2054改字体时enableTemplates被置为false导致UI退化为旧预设+参数Tab)
2026-09-27 11:41:05 +08:00
xiaoxia 41a0724184 fix(generate): Step6 本地上传封面走 OSS + 标题样式恢复 Modal 弹窗模式
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m30s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m37s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m36s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m42s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m56s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 2m5s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m54s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 4m18s
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 3m3s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 30s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 30s
AI Code Review / AI Code Review (pull_request) Successful in 6m29s
- Step6CoverSettings: 单视频本地上传封面时,使用 uploadAssetDirect 实际上传到图片素材库 OSS,拿到真实 URL 再 set;上传期间按钮 loading;原 useEffect 把 setOnUploadFile 置为 null 改为注册 uploadLocalCover 处理函数
- GeneratePage.handleFinish: 增加 blob: URL 兜底过滤(blob: URL 是浏览器本地临时地址,后端无法下载,此时不传 cover_url 让后端回退自动截帧封面,避免 400 保存失败);custom_title 空串不传 undefined
- 新增 TitleStyleModal 组件:标题样式改为弹窗模式(参考封面模板/字幕模板 Modal),内置「爆款预设」+「自定义」两个 Tab,底部取消/保存按钮,保存才提交草稿到父组件
- Step4TitleSettings: 移除内联 TitleStylePanel,改为「标题样式」按钮 + Modal 触发入口

Closes: Step6「完成」按钮保存失败 + 标题设置内联铺参数回归
2026-09-27 11:12:54 +08:00
37 changed files with 779 additions and 2714 deletions
+2 -21
View File
@@ -79,33 +79,14 @@ CELERY_BROKER_URL=redis://localhost:6379/0
CELERY_RESULT_BACKEND=redis://localhost:6379/1
# ==================== Worker 配置(#2073 队列分流) ====================
#
# 容器内跑三个独立进程:beat(只发定时任务)+ generation worker(实时高优)
# + transcode worker(后台批量/清理)。三个进程的并发与开关独立配置。
# ==================== Worker 配置 ====================
# Worker 进程名称
WORKER_NAME=xiaoxia-saas-worker
# 总并发参考(兼容旧变量):
# - 若 GENERATION_CONCURRENCY 与 TRANSCODE_CONCURRENCY 都未显式设置,
# entrypoint 会按此总数对半分配(gen=ceil(total/2), trans=剩余,各至少 1);
# - 任一个 *_CONCURRENCY 显式设置后,按显式值生效,忽略此变量对应部分。
# Worker 并发数(同时执行的任务数)
WORKER_CONCURRENCY=4
# Generation worker 并发数(用户实时任务:视频生成/TTS/音色克隆/lipsync/数字人)
# 实时链路对延迟敏感,建议 2C 以上机器设为 2;高负载场景可加到 4。
GENERATION_CONCURRENCY=2
# Transcode worker 并发数(后台批量:素材入库转码/AI 分类打标/质量评分/查重/批量下载)
# 后台任务可排队,独立伸缩;素材入库量大时可加到 4。
TRANSCODE_CONCURRENCY=2
# 是否在本容器启动 celery beat 进程(默认 1)。
# 默认 beat 与 worker 同容器部署;若要独立 beat 容器部署,worker 容器设为 0、
# beat 容器单独跑 `celery -A worker_app.celery_app beat` 并设 BEAT_ENABLED=1。
BEAT_ENABLED=1
# 每个子进程最多处理多少任务后重启(防止内存泄漏)
WORKER_MAX_TASKS_PER_CHILD=1000
+5 -14
View File
@@ -1238,13 +1238,12 @@ jobs:
ACR_PASSWORD: "${{ secrets.ACR_PASSWORD }}"
run: |
set -eux
# Staging 业务机 = 116.62.226.203(公网 sshd 端口 22)。
# 47.98.113.167 现为生产机(sshd 端口 22222),不承载 staging 容器。
# CI job 在隔离容器网络内执行,127.0.0.1 会指向 job 容器自身而失败,
# Staging 业务机 = 47.98.113.167(公网 sshd 端口 22222;内网 VPC 10.0.0.2:22)。
# CI runner 已迁移到独立 CI 机器、job 在隔离容器网络内执行,127.0.0.1 会指向 job 容器自身而失败,
# 故默认目标必须是 staging 业务机;仍可通过 secrets 覆盖。
staging_host="${STAGING_SSH_HOST:-116.62.226.203}"
staging_host="${STAGING_SSH_HOST:-47.98.113.167}"
staging_user="${STAGING_SSH_USER:-root}"
staging_port="${STAGING_SSH_PORT:-22}"
staging_port="${STAGING_SSH_PORT:-22222}"
echo "Host: $staging_host"
echo "Port: $staging_port"
@@ -1302,16 +1301,8 @@ jobs:
"${staging_user}@${staging_host}:/var/lib/xiaoxia-saas-staging/configs/douyin_cookies.txt"
echo "✅ Douyin cookies uploaded"
# 上传 infra/docker 配置到服务器(compose 单一事实来源)
echo "Uploading infra/docker configs to staging server..."
ssh -p "$staging_port" -i "$key_path" -o StrictHostKeyChecking=no "${staging_user}@${staging_host}" \
"mkdir -p /var/lib/xiaoxia-saas-staging/infra/docker"
scp -P "$staging_port" -i "$key_path" -o StrictHostKeyChecking=no infra/docker/compose.yml \
"${staging_user}@${staging_host}:/var/lib/xiaoxia-saas-staging/infra/docker/compose.yml"
echo "✅ infra/docker/compose.yml uploaded"
# 通过环境变量传递凭证,避免命令行引号转义问题
cat scripts/ci_staging_deploy.sh | ssh -p "$staging_port" -i "$key_path" -o StrictHostKeyChecking=no "${staging_user}@${staging_host}" "IMAGE_TAG=${GITHUB_SHA} ACR_USERNAME=${ACR_USERNAME} ACR_PASSWORD=${ACR_PASSWORD} COMPOSE_SYNC=0 sh"
cat scripts/ci_staging_deploy.sh | ssh -p "$staging_port" -i "$key_path" -o StrictHostKeyChecking=no "${staging_user}@${staging_host}" "IMAGE_TAG=${GITHUB_SHA} ACR_USERNAME=${ACR_USERNAME} ACR_PASSWORD=${ACR_PASSWORD} sh"
# 清理 CI runner 上的渲染文件
rm -f .env.rendered
+69 -111
View File
@@ -1,15 +1,14 @@
"""GPU 编码回传 relay 端点。
两个用途:
1. 结果回传(原):P4000 编码完成后通过 HTTP PUT 把结果 mp4 写到 /{key};Worker 用同 URL GET 回本地。
2. Mezzanine 中转(新):Worker 先把 CPU ultrafast 编码出的 mezzanine 通过 PUT 到 /mezzanine/{key},
P4000 通过 Tailscale 内网直接 GET 下载,跳过公网 OSS 中转,节省 18-20s 固定延迟。
编码完成后 DELETE 清理。
P4000 编码完成后通过 HTTP PUT 把结果 mp4 写到这里;Worker 在发起 GPU 请求时携带
带签名(token + 随机 key)的 URL,等待 P4000 写入后用同 URL 把文件 GET 回本地。
安全:
- 生产环境必须配置 GPU_ENCODE_RELAY_SECRET;token=xxx 查询参数必须匹配。
- key 为随机 hex,无法被枚举。
- 写入/读取后 worker 会调用 DELETE 主动清理;文件落地在 generated-files/gpu_relay/。
- 写入/读取后 worker 会调用 DELETE 主动清理;文件落地在 generated-files/gpu_relay/,
跟 generated-files 同卷,nginx 已对 generated-files 做静态挂载,但 gpu_relay/ 子目录
通过本接口走鉴权,不直接暴露为静态目录(文件名随机 + token 保护双重保险)。
"""
from __future__ import annotations
@@ -39,19 +38,15 @@ def _relay_dir() -> Path:
return p
def _mezzanine_dir() -> Path:
p = _relay_dir() / "mezzanine"
p.mkdir(parents=True, exist_ok=True)
return p
def _secret() -> str:
global _DEFAULT_SECRET_LOGGED
secret = (os.getenv("GPU_ENCODE_RELAY_SECRET", "") or "").strip()
if not secret:
env = (os.getenv("APP_ENV", os.getenv("ENV", "development"))).lower()
if env in ("production", "prod"):
# Production: raise so deployment fails fast
raise RuntimeError("GPU_ENCODE_RELAY_SECRET must be set in production")
# Dev: ephemeral random secret, log once
secret = os.environ.setdefault("GPU_ENCODE_RELAY_SECRET", secrets.token_urlsafe(32))
if not _DEFAULT_SECRET_LOGGED:
logger.warning(
@@ -77,8 +72,33 @@ def _check_token(tok: Optional[str]) -> None:
raise HTTPException(status_code=401, detail="unauthorized")
async def _atomic_write(request: Request, dst: Path, log_prefix: str, key_for_log: str) -> int:
"""通用原子写入(流式 → .part → replace)。返回字节数。"""
# ── Worker 侧:生成一个一次性 PUT URL ───────────────────────────────────
def build_relay_put_url(base_url: str, key: str, secret: str) -> str:
"""给 P4000 用的 PUT URL(含 token)。"""
return f"{base_url.rstrip('/')}/api/v1/internal/gpu-relay/{key}?token={secret}"
def build_relay_get_url(base_url: str, key: str, secret: str) -> str:
"""Worker 取回结果用的 GET URL。"""
return build_relay_put_url(base_url, key, secret)
def generate_key() -> str:
return uuid.uuid4().hex
# ── HTTP endpoints ──────────────────────────────────────────────────────
@router.put("/{key}")
async def put_object(
key: str,
request: Request,
token: Optional[str] = Query(None),
):
_check_token(token)
safe = _safe_key(key)
dst = _relay_dir() / safe
tmp = dst.with_suffix(dst.suffix + ".part")
size = 0
t0 = time.time()
@@ -94,22 +114,40 @@ async def _atomic_write(request: Request, dst: Path, log_prefix: str, key_for_lo
tmp.unlink()
except OSError:
pass
logger.exception("[gpu-relay] %s PUT failed key=%s", log_prefix, key_for_log)
logger.exception("[gpu-relay] PUT failed key=%s", safe)
raise HTTPException(status_code=500, detail=f"write failed: {e}") from e
logger.info(
"[gpu-relay] %s PUT key=%s size=%d took=%.2fs",
log_prefix, key_for_log, size, time.time() - t0,
"[gpu-relay] PUT key=%s size=%d took=%.2fs",
safe, size, time.time() - t0,
)
return size
return {"ok": True, "key": safe, "size": size}
def _file_response(path: Path, download_name: str) -> FileResponse:
@router.get("/{key}")
async def get_object(
key: str,
token: Optional[str] = Query(None),
):
_check_token(token)
safe = _safe_key(key)
path = _relay_dir() / safe
if not path.exists():
raise HTTPException(status_code=404, detail="not found")
return FileResponse(path=path, media_type="video/mp4", filename=f"{download_name}.mp4")
return FileResponse(
path=path,
media_type="video/mp4",
filename=f"{safe}.mp4",
)
def _head_response(path: Path) -> Response:
@router.head("/{key}")
async def head_object(
key: str,
token: Optional[str] = Query(None),
):
_check_token(token)
safe = _safe_key(key)
path = _relay_dir() / safe
if not path.exists():
return Response(status_code=404)
return Response(
@@ -119,97 +157,17 @@ def _head_response(path: Path) -> Response:
)
def _safe_delete(path: Path, err_detail: str) -> dict:
@router.delete("/{key}")
async def delete_object(
key: str,
token: Optional[str] = Query(None),
):
_check_token(token)
safe = _safe_key(key)
path = _relay_dir() / safe
try:
if path.exists():
path.unlink()
except OSError as e:
raise HTTPException(status_code=500, detail=f"{err_detail}: {e}") from e
return {"ok": True}
# ── Worker 侧 URL 构造 ─────────────────────────────────────────────────
def build_relay_put_url(base_url: str, key: str, secret: str) -> str:
"""给 P4000 回传结果用的 PUT URL(外部/Tailscale 可达)。"""
return f"{base_url.rstrip('/')}/api/v1/internal/gpu-relay/{key}?token={secret}"
def build_relay_get_url(base_url: str, key: str, secret: str) -> str:
"""Worker 取回结果用的 GET URL。"""
return build_relay_put_url(base_url, key, secret)
def build_mezzanine_put_url(base_url: str, key: str, secret: str) -> str:
"""Worker 上传 mezzanine 用的 PUT URL(Docker 内网或 Tailscale)。"""
return f"{base_url.rstrip('/')}/api/v1/internal/gpu-relay/mezzanine/{key}?token={secret}"
def build_mezzanine_get_url(base_url: str, key: str, secret: str) -> str:
"""P4000 下载 mezzanine 用的 GET URL(必须是 P4000 可达地址,通常是 Tailscale host:8092)。"""
return build_mezzanine_put_url(base_url, key, secret)
def generate_key() -> str:
return uuid.uuid4().hex
# ── 编码结果:PUT/GET/HEAD/DELETE /{key} ──────────────────────────────
@router.put("/{key}")
async def put_object(key: str, request: Request, token: Optional[str] = Query(None)):
_check_token(token)
safe = _safe_key(key)
size = await _atomic_write(request, _relay_dir() / safe, "result", safe)
return {"ok": True, "key": safe, "size": size}
@router.get("/{key}")
async def get_object(key: str, token: Optional[str] = Query(None)):
_check_token(token)
safe = _safe_key(key)
return _file_response(_relay_dir() / safe, safe)
@router.head("/{key}")
async def head_object(key: str, token: Optional[str] = Query(None)):
_check_token(token)
safe = _safe_key(key)
return _head_response(_relay_dir() / safe)
@router.delete("/{key}")
async def delete_object(key: str, token: Optional[str] = Query(None)):
_check_token(token)
safe = _safe_key(key)
return _safe_delete(_relay_dir() / safe, "delete failed")
# ── Mezzanine 中转:PUT/GET/HEAD/DELETE /mezzanine/{key} ─────────────
# Worker 上传 mezzanine 用;P4000 通过 Tailscale 直接 GET 下载。
@router.put("/mezzanine/{key}")
async def put_mezzanine(key: str, request: Request, token: Optional[str] = Query(None)):
_check_token(token)
safe = _safe_key(key)
dst = _mezzanine_dir() / f"{safe}.mp4"
size = await _atomic_write(request, dst, "mezzanine", safe)
return {"ok": True, "key": safe, "size": size}
@router.get("/mezzanine/{key}")
async def get_mezzanine(key: str, token: Optional[str] = Query(None)):
_check_token(token)
safe = _safe_key(key)
return _file_response(_mezzanine_dir() / f"{safe}.mp4", f"{safe}-mezzanine")
@router.head("/mezzanine/{key}")
async def head_mezzanine(key: str, token: Optional[str] = Query(None)):
_check_token(token)
safe = _safe_key(key)
return _head_response(_mezzanine_dir() / f"{safe}.mp4")
@router.delete("/mezzanine/{key}")
async def delete_mezzanine(key: str, token: Optional[str] = Query(None)):
_check_token(token)
safe = _safe_key(key)
return _safe_delete(_mezzanine_dir() / f"{safe}.mp4", "mezzanine delete failed")
raise HTTPException(status_code=500, detail=f"delete failed: {e}") from e
return {"ok": True, "key": safe}
@@ -1,271 +0,0 @@
/**
* 标题迷你 Canvas 预览(#2001)
*
* 渲染一张指定宽度的小 Canvas 预览标题效果,用于:
* - 预设卡片缩略图
* - 样式面板顶部的实时预览
*
* 与 titleCanvas.ts 渲染逻辑保持一致,但:
* - 固定分辨率(width × 宽高比约 2:1)
* - 不调用 ffmpeg,只做视觉预览
* - 支持背景色块、描边宽度/颜色、阴影参数化、行距、自动换行
*/
import React, { useEffect, useRef } from "react"
import type { TitleStyleSettings } from "@/components/title/settings"
import { getFontFamily } from "@/components/title/constants"
interface Props {
settings: TitleStyleSettings
width?: number
sampleText?: string
/** 背景(预览用,默认深色渐变模拟视频底),transparent=true 时忽略 */
background?: string
/** 高度(可选,默认按 portrait 选比例) */
height?: number
/** 透明背景(卡片/编辑器预览叠加在图片上时使用) */
transparent?: boolean
/** 纵向竖屏预览(9:16),true 时 aspect=16/9 适配手机视频比例 */
portrait?: boolean
}
/** 按 maxCharsPerLine 自动换行 */
function wrapLines(text: string, maxChars: number): string[] {
const manual = text
.split(/[//\n]/)
.map((l) => l.trim())
.filter(Boolean)
if (!maxChars || maxChars <= 0) return manual
const out: string[] = []
for (const line of manual) {
if (line.length <= maxChars) {
out.push(line)
continue
}
let cur = ""
for (const ch of line) {
cur += ch
if (cur.length >= maxChars) {
out.push(cur)
cur = ""
}
}
if (cur) out.push(cur)
}
return out
}
const TitleMiniPreview: React.FC<Props> = ({
settings,
width = 200,
sampleText,
background = "linear-gradient(135deg,#1f2937,#111827)",
height,
transparent = false,
portrait = false,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null)
const h = height ?? Math.round(width * (portrait ? 16 / 9 : 1 / 1.8))
const text = (sampleText || "预览标题").trim() || "预览标题"
useEffect(() => {
let cancelled = false
const draw = () => {
if (cancelled) return
const cvs = canvasRef.current
if (!cvs) return
const dpr = window.devicePixelRatio || 1
cvs.width = width * dpr
cvs.height = h * dpr
cvs.style.width = `${width}px`
cvs.style.height = `${h}px`
const ctx = cvs.getContext("2d")
if (!ctx) return
ctx.scale(dpr, dpr)
ctx.clearRect(0, 0, width, h)
// 背景(transparent 时跳过,用于叠加在图片上)
if (!transparent) {
ctx.fillStyle = "#111827"
ctx.fillRect(0, 0, width, h)
}
// 分辨率缩放:以 360 宽为基准(对应 720p 的一半),与外层 previewScale/previewR 保持一致
const r = previewR
// 字体
const size = r(settings.size)
const ff = getFontFamily(settings.font)
const parts: string[] = []
if (settings.italic) parts.push("italic")
if (settings.bold) parts.push("bold")
parts.push(`${size}px`, ff)
ctx.font = parts.join(" ")
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.fillStyle = settings.color
ctx.lineJoin = "round"
// 阴影
const shadowEnabled = !!settings.shadow
const prevShadow = {
c: ctx.shadowColor,
b: ctx.shadowBlur,
ox: ctx.shadowOffsetX,
oy: ctx.shadowOffsetY,
}
if (shadowEnabled) {
ctx.shadowColor = settings.shadowColor ?? "rgba(0,0,0,0.8)"
ctx.shadowBlur = r(settings.shadowBlur ?? 4)
ctx.shadowOffsetX = r(settings.shadowOffsetX ?? 2)
ctx.shadowOffsetY = r(settings.shadowOffsetY ?? 2)
}
// 换行
const lines = wrapLines(text, settings.maxCharsPerLine ?? 0)
const lineH = size * (settings.lineHeight ?? 1.2)
const totalH = lines.length * lineH
let startY: number
if (settings.position === "top") {
startY = size / 2 + r(settings.marginTop ?? 24)
} else if (settings.position === "center") {
startY = h / 2 - totalH / 2 + size / 2
} else {
// bottom
const botMargin = portrait ? r(24) : r(16)
startY = h - totalH - botMargin + size / 2
}
let centerX = width / 2
if (settings.position === "custom" && settings.posX != null) {
centerX = (settings.posX / 100) * width
}
// 背景块
if (settings.bgEnabled) {
const pad = r(settings.bgPadding ?? 12)
const rad = r(settings.bgRadius ?? 8)
let maxLineW = 0
for (const l of lines) {
const m = ctx.measureText(l)
if (m.width > maxLineW) maxLineW = m.width
}
const bw = maxLineW + pad * 2
const bh = totalH + pad * 2
const bx = centerX - bw / 2
const by = startY - size / 2 - pad + (size - lineH) / 2
ctx.shadowColor = "rgba(0,0,0,0)"
ctx.shadowBlur = 0
ctx.fillStyle = settings.bgColor ?? "rgba(0,0,0,0.5)"
roundRect(ctx, bx, by, bw, bh, rad)
ctx.fill()
// 关键修复:画完背景块后必须把 fillStyle 重置为文字颜色,
// 否则后续 fillText 会用 bgColor 填充文字,导致「文字看不见只剩色块」
ctx.fillStyle = settings.color
// 恢复阴影
if (shadowEnabled) {
ctx.shadowColor = settings.shadowColor ?? "rgba(0,0,0,0.8)"
ctx.shadowBlur = r(settings.shadowBlur ?? 4)
ctx.shadowOffsetX = r(settings.shadowOffsetX ?? 2)
ctx.shadowOffsetY = r(settings.shadowOffsetY ?? 2)
}
}
// 描边(先画,再画填充)
const strokeEnabled = !!settings.stroke && (settings.strokeWidth ?? 0) > 0
lines.forEach((line, i) => {
const y = startY + i * lineH
if (strokeEnabled) {
ctx.shadowColor = "rgba(0,0,0,0)"
ctx.shadowBlur = 0
ctx.lineWidth = r(settings.strokeWidth ?? 4)
ctx.strokeStyle = settings.strokeColor ?? "#000000"
ctx.strokeText(line, centerX, y)
// 恢复阴影
if (shadowEnabled) {
ctx.shadowColor = settings.shadowColor ?? "rgba(0,0,0,0.8)"
ctx.shadowBlur = r(settings.shadowBlur ?? 4)
ctx.shadowOffsetX = r(settings.shadowOffsetX ?? 2)
ctx.shadowOffsetY = r(settings.shadowOffsetY ?? 2)
}
}
ctx.fillText(line, centerX, y)
})
// 恢复
ctx.shadowColor = prevShadow.c
ctx.shadowBlur = prevShadow.b
ctx.shadowOffsetX = prevShadow.ox
ctx.shadowOffsetY = prevShadow.oy
}
// 计算当前字号(draw() 内部同样逻辑,抽出来供 fontString 复用)
const previewScale = width / 360
const previewR = (v: number) => Math.round(v * previewScale)
const buildFontString = () => {
const size = previewR(settings.size)
const ff = getFontFamily(settings.font)
const parts: string[] = []
if (settings.italic) parts.push("italic")
if (settings.bold) parts.push("bold")
parts.push(`${size}px`, ff)
return parts.join(" ")
}
// Web Font 加载保障:
// 1) 等 document.fonts.ready(CSS @font-face 首次可用)
// 2) 显式 FontFaceSet.load(fontString, text) 触发浏览器真正下载并加载
// 当前字体到 Canvas 可用,避免首次绘制用 fallback 字体画出错字/色块
const doDrawWhenReady = async () => {
try {
if (typeof document !== "undefined" && document.fonts) {
await document.fonts.ready
try {
await document.fonts.load(buildFontString(), text)
} catch {
/* ignore */
}
}
} finally {
if (!cancelled) draw()
}
}
doDrawWhenReady()
return () => {
cancelled = true
}
}, [settings, width, h, text, transparent, portrait, background])
return (
<canvas
ref={canvasRef}
style={{
borderRadius: 6,
display: "block",
maxWidth: "100%",
background: transparent ? "transparent" : background,
}}
/>
)
}
function roundRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
r: number,
) {
const rr = Math.min(r, w / 2, h / 2)
ctx.beginPath()
ctx.moveTo(x + rr, y)
ctx.lineTo(x + w - rr, y)
ctx.quadraticCurveTo(x + w, y, x + w, y + rr)
ctx.lineTo(x + w, y + h - rr)
ctx.quadraticCurveTo(x + w, y + h, x + w - rr, y + h)
ctx.lineTo(x + rr, y + h)
ctx.quadraticCurveTo(x, y + h, x, y + h - rr)
ctx.lineTo(x, y + rr)
ctx.quadraticCurveTo(x, y, x + rr, y)
ctx.closePath()
}
export default TitleMiniPreview
@@ -1,458 +0,0 @@
/* ============================================================
TitleStylePanel 标题样式面板 — 独立共用样式(#1809 ⑦)
从 generate.css 抽取的标题样式区块,供「智能剪辑」与「AI数字人」
两个页面共用。AI数字人页面不引入 generate.css,直接由
TitleStylePanel.tsx import 本文件,保证 24 个 T 预设格子的网格布局、
配色描边、选中态与智能剪辑页面完全一致。
注意:本文件规则与 generate.css 中同名规则一一对应、取值相同;
智能剪辑页面两处同时存在时同优先级同值,不改变其原有呈现。
============================================================ */
/* ── 区块容器 ── */
.xx-title-style-section {
margin-top: 22px;
padding-top: 20px;
border-top: 1px solid var(--border-light);
}
.xx-section-subtitle {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 16px;
}
.xx-title-style-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
margin-bottom: 14px;
}
.xx-half-field {
margin-bottom: 0;
}
.xx-field-label-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.xx-field-label-row label {
margin-bottom: 0;
}
.xx-field-value {
font-size: 13px;
font-weight: 600;
color: var(--primary-color);
}
/* ── 共用表单字段(位置/字体下拉) ── */
.xx-title-style-section .xx-form-field {
margin-bottom: 14px;
}
.xx-title-style-section .xx-form-field:last-child {
margin-bottom: 0;
}
.xx-title-style-section .xx-form-field label {
display: block;
font-weight: 600;
margin-bottom: 8px;
font-size: 13px;
color: var(--text-primary);
}
.xx-title-style-section .xx-form-field select,
.xx-title-style-section .xx-form-field input {
width: 100%;
height: 44px;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-primary);
padding: 0 14px;
font-size: 14px;
outline: 0;
transition: 0.15s ease;
color: var(--text-primary);
}
.xx-title-style-section .xx-form-field select:focus,
.xx-title-style-section .xx-form-field input:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
}
/* ── 字号滑块 ── */
.xx-slider {
width: 100%;
height: 6px;
-webkit-appearance: none;
appearance: none;
background: var(--border-color);
border-radius: 3px;
outline: none;
cursor: pointer;
}
.xx-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
background: var(--primary-color);
border-radius: 50%;
cursor: pointer;
box-shadow: 0 2px 6px rgba(79, 70, 229, 0.3);
}
.xx-slider::-moz-range-thumb {
width: 18px;
height: 18px;
background: var(--primary-color);
border-radius: 50%;
cursor: pointer;
border: none;
box-shadow: 0 2px 6px rgba(79, 70, 229, 0.3);
}
/* ── 标题预设卡片网格(24 个 T 格子) ── */
.xx-title-presets-grid {
display: grid;
grid-template-columns: repeat(6, 52px);
gap: 1px;
}
.xx-title-preset-card {
display: flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
padding: 0;
background: #404040;
border: 2px solid transparent;
border-radius: 8px;
cursor: pointer;
transition: all 0.15s;
}
.xx-title-preset-card:hover {
border-color: #666;
background: #4d4d4d;
}
.xx-title-preset-card.active {
border-color: #409eff;
background: #4d4d4d;
}
.xx-title-preset-preview-text {
font-size: 32px;
line-height: 1;
user-select: none;
}
/* ── 样式按钮组(加粗/斜体/描边/阴影) ── */
.xx-style-btns {
display: flex;
gap: 8px;
}
.xx-style-btn {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-primary);
cursor: pointer;
font-size: 15px;
color: var(--text-secondary);
transition: all 0.15s;
}
.xx-style-btn:hover {
border-color: var(--primary-300);
color: var(--primary-color);
}
.xx-style-btn.active {
background: var(--primary-color);
border-color: var(--primary-color);
color: #fff;
}
/* ============================================================
#2001 爆款标题样式面板升级 — 新增样式(ts- 前缀)
============================================================ */
.ts-panel {
position: relative;
}
/* 预览 */
.ts-preview-wrap {
margin-bottom: 14px;
display: flex;
justify-content: center;
padding: 10px;
background: #0f172a;
border-radius: 8px;
}
/* 表单字段 */
.ts-form-field {
margin-bottom: 12px;
}
.ts-form-field label {
display: block;
font-weight: 600;
margin-bottom: 6px;
font-size: 12px;
color: var(--text-primary, #1f2937);
}
.ts-field-label-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.ts-field-value {
font-size: 12px;
font-weight: 600;
color: var(--primary-color, #7c3aed);
}
.ts-row-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.ts-half {
margin-bottom: 0;
}
.ts-select {
width: 100%;
height: 34px;
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 6px;
background: var(--bg-primary, #fff);
padding: 0 10px;
font-size: 13px;
outline: 0;
color: var(--text-primary, #1f2937);
}
.ts-select:focus {
border-color: var(--primary-color, #7c3aed);
box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.1);
}
.ts-input {
width: 100%;
height: 34px;
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 6px;
padding: 0 10px;
font-size: 13px;
outline: 0;
}
.ts-slider {
width: 100%;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: #e5e7eb;
border-radius: 2px;
outline: none;
}
.ts-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #7c3aed;
cursor: pointer;
border: 2px solid #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.ts-slider::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: #7c3aed;
cursor: pointer;
border: 2px solid #fff;
}
/* 样式按钮 B/I/S/☁ */
.ts-style-btns {
display: flex;
gap: 6px;
}
.ts-style-btn {
width: 34px;
height: 34px;
border-radius: 6px;
border: 1px solid #e5e7eb;
background: #fff;
cursor: pointer;
font-size: 14px;
transition: 0.15s;
color: #374151;
display: inline-flex;
align-items: center;
justify-content: center;
}
.ts-style-btn:hover {
border-color: #7c3aed;
color: #7c3aed;
}
.ts-style-btn.active {
background: #faf5ff;
color: #6d28d9;
border-color: #7c3aed;
font-weight: 700;
}
/* 色板 */
.ts-color-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.ts-color-swatch {
width: 24px;
height: 24px;
border-radius: 4px;
border: 2px solid #fff;
box-shadow: 0 0 0 1px #e5e7eb;
cursor: pointer;
padding: 0;
transition: 0.15s;
}
.ts-color-swatch:hover {
transform: scale(1.1);
}
.ts-color-swatch.active {
box-shadow: 0 0 0 2px #7c3aed;
transform: scale(1.1);
}
.ts-color-custom {
background: repeating-conic-gradient(#ccc 0% 25%, #fff 0% 50%) 50%/8px 8px;
color: #666;
font-size: 14px;
line-height: 20px;
}
.ts-color-native {
width: 0;
height: 0;
border: 0;
padding: 0;
}
/* 预设网格 10个 - 5列 */
.ts-presets-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 6px;
}
.ts-preset-card {
border: 1px solid #e5e7eb;
border-radius: 6px;
background: #fff;
padding: 4px;
cursor: pointer;
transition: 0.15s;
display: flex;
flex-direction: column;
gap: 4px;
}
.ts-preset-card:hover {
border-color: #7c3aed;
}
.ts-preset-card.active {
border-color: #7c3aed;
background: #faf5ff;
box-shadow: 0 0 0 1px #7c3aed;
}
.ts-preset-preview {
height: 34px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 4px;
background: #0f172a;
}
.ts-preset-preview canvas {
max-width: 100%;
max-height: 100%;
}
.ts-preset-meta {
display: flex;
align-items: center;
gap: 2px;
font-size: 10px;
color: #4b5563;
justify-content: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 0 2px 2px;
}
.ts-preset-emoji {
font-size: 11px;
}
.ts-preset-label {
overflow: hidden;
text-overflow: ellipsis;
}
.ts-toggle-row label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
margin-bottom: 10px;
}
.ts-toggle-row input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: #7c3aed;
}
/* Tabs 紧凑样式 */
.xx-title-style-section .ant-tabs-nav {
margin-bottom: 10px;
}
.xx-title-style-section .ant-tabs-tab {
font-size: 12px !important;
padding: 6px 8px !important;
}
/* 标题模板入口按钮(#2003) */
.ts-template-btn {
border: none;
background: transparent;
color: var(--primary-color, #7c3aed);
font-size: 12px;
cursor: pointer;
padding: 2px 0;
font-weight: 500;
}
.ts-template-btn:hover {
text-decoration: underline;
}
@@ -1,445 +0,0 @@
/**
* 标题样式参数 Tab 面板(共享组件)
*
* 包含:基础/描边/阴影/背景/排版/封面 共 6 个 Tab
* 仅负责 UI 渲染和参数 patch 回调,不维护 state、不调 API
*/
import React, { useState } from "react"
import { Tabs } from "antd"
import type { TitleStyleSettings } from "./settings"
import {
FONT_OPTIONS,
TITLE_COLOR_PALETTE,
STROKE_COLOR_PALETTE,
BG_COLOR_PALETTE,
} from "./constants"
export interface PositionOption {
value: string
label: string
}
export interface FontOption {
value: string
label: string
family: string
tag?: "hot" | "new"
}
export interface TitleStyleParamsTabProps {
settings: TitleStyleSettings
onUpdatePosition: (p: string) => void
onUpdateFont: (f: string) => void
onUpdateSize: (v: number) => void
onToggleBold: () => void
onToggleItalic: () => void
onToggleStroke: () => void
onToggleShadow: () => void
onUpdatePatch: (patch: Partial<TitleStyleSettings>) => void
positionOptions: PositionOption[]
fontOptions?: FontOption[]
/** 是否显示「封面」Tab(独立封面标题开关) */
showCoverToggle?: boolean
/** 封面独立标题开关状态 */
coverEnabled?: boolean
/** 封面开关变化 */
onToggleCover?: (enabled: boolean) => void
}
/* ── Slider 行 ── */
const SliderRow: React.FC<{
label: string
value: number
min: number
max: number
step?: number
unit?: string
onChange: (v: number) => void
}> = ({ label, value, min, max, step = 1, unit = "px", onChange }) => (
<div className="ts-form-field">
<div className="ts-field-label-row">
<label>{label}</label>
<span className="ts-field-value">
{value}
{unit}
</span>
</div>
<input
type="range"
className="ts-slider"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
/>
</div>
)
/* ── 色板 ── */
const ColorPicker: React.FC<{
label?: string
value: string
palette: string[]
onChange: (c: string) => void
}> = ({ label, value, palette, onChange }) => {
const [customOpen, setCustomOpen] = useState(false)
return (
<div className="ts-form-field">
{label && <label>{label}</label>}
<div className="ts-color-row">
{palette.map((c) => (
<button
key={c}
type="button"
className={`ts-color-swatch${value.toLowerCase() === c.toLowerCase() ? " active" : ""}`}
style={{ background: c }}
onClick={() => onChange(c)}
title={c}
/>
))}
<button
type="button"
className="ts-color-swatch ts-color-custom"
onClick={() => setCustomOpen((v) => !v)}
title="自定义颜色"
>
+
</button>
<input
type="color"
className="ts-color-native"
value={value.startsWith("rgba") ? "#000000" : value}
onChange={(e) => {
onChange(e.target.value)
setCustomOpen(false)
}}
style={{
opacity: customOpen ? 1 : 0,
position: customOpen ? "static" : "absolute",
pointerEvents: customOpen ? "auto" : "none",
width: customOpen ? 28 : 0,
height: customOpen ? 28 : 0,
border: "none",
padding: 0,
cursor: "pointer",
background: "transparent",
}}
/>
</div>
<div style={{ fontSize: 11, color: "#9ca3af", marginTop: 2 }}>
当前:<code style={{ fontSize: 11 }}>{value}</code>
</div>
</div>
)
}
const TitleStyleParamsTab: React.FC<TitleStyleParamsTabProps> = ({
settings,
onUpdatePosition,
onUpdateFont,
onUpdateSize,
onToggleBold,
onToggleItalic,
onToggleStroke,
onToggleShadow,
onUpdatePatch,
positionOptions,
fontOptions = FONT_OPTIONS,
showCoverToggle = false,
coverEnabled = false,
onToggleCover,
}) => {
const upd = onUpdatePatch
return (
<Tabs
size="small"
defaultActiveKey="basic"
items={[
{
key: "basic",
label: "基础",
children: (
<>
<div className="ts-row-2">
<div className="ts-form-field ts-half">
<label>位置</label>
<select
className="ts-select"
value={settings.position}
onChange={(e) => onUpdatePosition(e.target.value)}
>
{positionOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<div className="ts-form-field ts-half">
<label>字体</label>
<select
className="ts-select"
value={settings.font}
onChange={(e) => onUpdateFont(e.target.value)}
>
{fontOptions.map((f) => (
<option key={f.value} value={f.value}>
{f.tag === "hot" ? "🔥 " : f.tag === "new" ? "🆕 " : ""}
{f.label}
</option>
))}
</select>
</div>
</div>
<SliderRow
label="字号"
value={settings.size}
min={16}
max={120}
onChange={onUpdateSize}
/>
<div className="ts-form-field">
<label>样式</label>
<div className="ts-style-btns">
<button
type="button"
className={`ts-style-btn${settings.bold ? " active" : ""}`}
onClick={onToggleBold}
>
<b>B</b>
</button>
<button
type="button"
className={`ts-style-btn${settings.italic ? " active" : ""}`}
onClick={onToggleItalic}
>
<i>I</i>
</button>
<button
type="button"
className={`ts-style-btn${settings.stroke ? " active" : ""}`}
onClick={() => {
onToggleStroke()
if (!settings.stroke && (settings.strokeWidth ?? 0) < 2)
upd({ strokeWidth: 4 })
}}
title="描边"
>
S
</button>
<button
type="button"
className={`ts-style-btn${settings.shadow ? " active" : ""}`}
onClick={() => {
onToggleShadow()
if (!settings.shadow) {
upd({
shadowOffsetX: 2,
shadowOffsetY: 2,
shadowBlur: 4,
shadowColor: "rgba(0,0,0,0.8)",
})
}
}}
title="阴影"
>
☁
</button>
</div>
</div>
<ColorPicker
label="字色"
value={settings.color}
palette={TITLE_COLOR_PALETTE}
onChange={(c) => upd({ color: c })}
/>
</>
),
},
{
key: "stroke",
label: "描边",
children: (
<>
<div className="ts-toggle-row">
<label>
<input type="checkbox" checked={settings.stroke} onChange={onToggleStroke} />
启用描边
</label>
</div>
{settings.stroke && (
<>
<SliderRow
label="描边宽度"
value={settings.strokeWidth ?? 4}
min={0}
max={20}
onChange={(v) => upd({ strokeWidth: v })}
/>
<ColorPicker
label="描边颜色"
value={settings.strokeColor ?? "#000000"}
palette={STROKE_COLOR_PALETTE}
onChange={(c) => upd({ strokeColor: c })}
/>
</>
)}
</>
),
},
{
key: "shadow",
label: "阴影",
children: (
<>
<div className="ts-toggle-row">
<label>
<input type="checkbox" checked={settings.shadow} onChange={onToggleShadow} />
启用阴影
</label>
</div>
{settings.shadow && (
<>
<SliderRow
label="X偏移"
value={settings.shadowOffsetX ?? 2}
min={-20}
max={20}
onChange={(v) => upd({ shadowOffsetX: v })}
/>
<SliderRow
label="Y偏移"
value={settings.shadowOffsetY ?? 2}
min={-20}
max={20}
onChange={(v) => upd({ shadowOffsetY: v })}
/>
<SliderRow
label="模糊半径"
value={settings.shadowBlur ?? 4}
min={0}
max={30}
onChange={(v) => upd({ shadowBlur: v })}
/>
<div className="ts-form-field">
<label>阴影颜色</label>
<input
type="text"
className="ts-input"
value={settings.shadowColor ?? "rgba(0,0,0,0.8)"}
onChange={(e) => upd({ shadowColor: e.target.value })}
placeholder="rgba(0,0,0,0.8)"
/>
</div>
</>
)}
</>
),
},
{
key: "bg",
label: "背景",
children: (
<>
<div className="ts-toggle-row">
<label>
<input
type="checkbox"
checked={settings.bgEnabled}
onChange={() => upd({ bgEnabled: !settings.bgEnabled })}
/>
启用背景色块
</label>
</div>
{settings.bgEnabled && (
<>
<ColorPicker
label="背景颜色(含透明度)"
value={settings.bgColor}
palette={BG_COLOR_PALETTE}
onChange={(c) => upd({ bgColor: c })}
/>
<SliderRow
label="内边距"
value={settings.bgPadding}
min={0}
max={40}
onChange={(v) => upd({ bgPadding: v })}
/>
<SliderRow
label="圆角"
value={settings.bgRadius}
min={0}
max={30}
onChange={(v) => upd({ bgRadius: v })}
/>
</>
)}
</>
),
},
{
key: "layout",
label: "排版",
children: (
<>
<SliderRow
label="每行最大字符数"
value={settings.maxCharsPerLine ?? 0}
min={0}
max={20}
unit=""
onChange={(v) => upd({ maxCharsPerLine: v })}
/>
<div
className="ts-form-field"
style={{ fontSize: 11, color: "#9ca3af", marginTop: -4 }}
>
0 = 不自动换行(按 / 手动分行)
</div>
<SliderRow
label="行距倍数"
value={Math.round((settings.lineHeight ?? 1.2) * 100) / 100}
min={1}
max={2}
step={0.05}
unit=""
onChange={(v) => upd({ lineHeight: Number(v.toFixed(2)) })}
/>
<SliderRow
label="顶部边距"
value={settings.marginTop ?? 24}
min={0}
max={200}
onChange={(v) => upd({ marginTop: v })}
/>
</>
),
},
...(showCoverToggle
? [
{
key: "cover",
label: "封面",
children: (
<div className="ts-toggle-row">
<label>
<input
type="checkbox"
checked={coverEnabled}
onChange={(e) => onToggleCover?.(e.target.checked)}
/>
封面使用独立标题样式
</label>
</div>
),
},
]
: []),
]}
/>
)
}
export default TitleStyleParamsTab
@@ -1,30 +1,29 @@
/**
* 标题模板编辑器(公共组件)
* 标题模板编辑器(v3 重构)
*
* - Modal 弹窗 860px 宽
* - 左侧:300px 竖屏预览区(图片背景+暗角+透明 Canvas 叠字)+ 模板名称输入
* - 右侧:参数 Tab 面板(基础/描边/阴影/背景/排版),复用 TitleStyleParamsTab
* - 左侧:300px 竖屏预览区(图片背景+暗色渐变遮罩+透明 Canvas 叠字)+ 模板名称输入框
* - 右侧:参数 Tab 面板(基础/描边/阴影/背景/排版),复用 TitleStylePanel 的 paramsOnly 模式
* - 底部:取消 / 保存模板 按钮
* - 内置模板编辑时保存会创建副本(带"副本"逻辑由 onSave 的调用方处理)
* - 内置模板编辑时保存会创建副本(带"副本"逻辑由 handleSave 处理)
*/
import React, { useEffect, useMemo, useState } from "react"
import { Modal, Button, Input, message } from "antd"
import type { TitleStyleSettings } from "./settings"
import { DEFAULT_TITLE_STYLE_SETTINGS } from "./settings"
import TitleStylePanel from "../../pages/generate/components/title/TitleStylePanel"
import TitleMiniPreview from "../../pages/generate/components/title/TitleMiniPreview"
import { POSITION_OPTIONS } from "../../pages/generate/constants"
import { FONT_OPTIONS } from "./constants"
import type { TitleSettings } from "../../pages/generate/types"
import { DEFAULT_TITLE_SETTINGS_FULL } from "../../pages/generate/types"
import { titleStyleConfigToCamel, camelToTitleStyleConfig } from "./utils"
import type { TitleTemplate } from "./template-types"
import type { TitleStyleConfig } from "./types"
import { POSITION_OPTIONS } from "./position-options"
import { FONT_OPTIONS } from "./constants"
import TitleMiniPreview from "./TitleMiniPreview"
import TitleStyleParamsTab from "./TitleStyleParamsTab"
import "./TitleTemplate.css"
import "./TitleStylePanel.css"
interface Props {
open: boolean
template: TitleTemplate
onClose: () => void
/** 用户点击保存:将编辑结果回调给父组件(父组件统一做 CRUD,避免双 hook 实例不同步) */
onSave: (data: { name: string; emoji: string; style: Partial<TitleStyleConfig> }) => void
}
@@ -32,9 +31,10 @@ interface Props {
const EDITOR_BG = "/title-templates/portrait1.jpg"
const TitleTemplateEditor: React.FC<Props> = ({ open, template, onClose, onSave }) => {
const [settings, setSettings] = useState<TitleStyleSettings>(() => ({
...DEFAULT_TITLE_STYLE_SETTINGS,
const [settings, setSettings] = useState<TitleSettings>(() => ({
...DEFAULT_TITLE_SETTINGS_FULL,
...titleStyleConfigToCamel(template.style || {}),
title: "预览标题文字",
}))
const [formName, setFormName] = useState(template.name || "")
const [formEmoji, setFormEmoji] = useState(template.emoji || "✨")
@@ -43,15 +43,16 @@ const TitleTemplateEditor: React.FC<Props> = ({ open, template, onClose, onSave
useEffect(() => {
if (open) {
setSettings({
...DEFAULT_TITLE_STYLE_SETTINGS,
...DEFAULT_TITLE_SETTINGS_FULL,
...titleStyleConfigToCamel(template.style || {}),
title: "预览标题文字",
})
setFormName(template.name || "")
setFormEmoji(template.emoji || "✨")
}
}, [open, template])
const upd = (patch: Partial<TitleStyleSettings>) => setSettings((s) => ({ ...s, ...patch }))
const upd = (patch: Partial<TitleSettings>) => setSettings((s) => ({ ...s, ...patch }))
const handleSave = () => {
const name = formName.trim()
@@ -68,11 +69,11 @@ const TitleTemplateEditor: React.FC<Props> = ({ open, template, onClose, onSave
}
}
// 编辑器预览 settings:竖屏宽度 200px,字号按比例缩放
const previewSettings = useMemo<TitleStyleSettings>(
() => ({ ...settings, size: Math.round(settings.size * 0.55) }),
[settings],
)
// 编辑器内的预览用 settings:字号适配竖屏
const previewSettings = useMemo<TitleSettings>(() => {
// 竖屏宽度 200px,按比例缩放字号,让预览看起来协调
return { ...settings, size: Math.round(settings.size * 0.55) }
}, [settings])
return (
<Modal
@@ -139,7 +140,7 @@ const TitleTemplateEditor: React.FC<Props> = ({ open, template, onClose, onSave
</div>
{/* 右侧:参数 Tab */}
<div className="ttv3-editor-right">
<TitleStyleParamsTab
<TitleStylePanel
settings={settings}
onUpdatePosition={(p) => upd({ position: p, posX: null, posY: null })}
onUpdateFont={(f) => upd({ font: f })}
@@ -154,9 +155,15 @@ const TitleTemplateEditor: React.FC<Props> = ({ open, template, onClose, onSave
})
}
onToggleShadow={() => upd({ shadow: !settings.shadow })}
onUpdatePatch={upd}
positionOptions={POSITION_OPTIONS}
fontOptions={FONT_OPTIONS}
onApplyPreset={() => {
/* 编辑器内不使用系统预设快捷键 */
}}
onUpdateStyle={(patch) => upd(patch)}
activePreset={null}
titlePresets={[]}
POSITION_OPTIONS={POSITION_OPTIONS}
FONT_OPTIONS={FONT_OPTIONS}
paramsOnly
/>
</div>
</div>
@@ -1,343 +0,0 @@
/**
* 标题模板选择器 — 大卡片网格(共享组件)
*
* 渲染「我的模板」+「系统模板」两个分组的 3:4 竖版大圆角卡片:
* - 卡片上半:示例背景图 + vignette 暗角 + 透明 Canvas 大字预览
* - 卡片下半:emoji + 名称 + 系统/我的标签 + 始终可见的编辑/复制/导出/删除按钮
* - 选中紫色边框;右上角「新建模板」按钮;点编辑/新建弹 TitleTemplateEditor
*
* Props 通用化,不耦合业务 state。
*/
import React, { useCallback, useMemo, useState } from "react"
import { Button, message, Popconfirm } from "antd"
import {
PlusOutlined,
EditOutlined,
CopyOutlined,
DeleteOutlined,
ExportOutlined,
CheckOutlined,
} from "@ant-design/icons"
import type { TitleTemplate } from "./template-types"
import type { TitleStyleSettings } from "./settings"
import { DEFAULT_TITLE_STYLE_SETTINGS } from "./settings"
import {
titleStyleConfigToCamel,
camelToTitleStyleConfig,
templateToPreviewSettings,
} from "./utils"
import { useTitleTemplates } from "./useTitleTemplates"
import TitleMiniPreview from "./TitleMiniPreview"
import TitleTemplateEditor from "./TitleTemplateEditor"
import "./TitleTemplate.css"
import "./TitleStylePanel.css"
export interface TitleTemplateSelectorProps {
/** 当前选中模板 id(受控) */
value?: string | null
/** 选中模板时回调(templateId, fullStyleSettings, template) */
onChange?: (templateId: string, style: TitleStyleSettings, template: TitleTemplate) => void
/** 是否显示编辑器入口(新建/编辑按钮),默认 true */
showEditor?: boolean
/** 显示哪些分组,默认全部 */
categories?: Array<"system" | "custom">
/** 使用场景标识(仅作 data-attr,不影响样式) */
context?: string
}
/* ── 卡片预览背景图池(按 index 轮换) ── */
const PREVIEW_BG_IMAGES = [
"/title-templates/portrait1.jpg",
"/title-templates/portrait2.jpg",
"/title-templates/scene1.jpg",
]
/* ── 预览容器:用 ref 测量宽度后再渲染透明 Canvas,保证文字清晰 ── */
const FillPreview: React.FC<{
settings: TitleStyleSettings
sampleText: string
portrait?: boolean
}> = ({ settings, sampleText, portrait }) => {
const [w, setW] = useState(0)
// 首次挂载后测量一次
const setRef = useCallback((el: HTMLDivElement | null) => {
if (el) setW(Math.floor(el.clientWidth))
}, [])
return (
<div ref={setRef} className="tt-fill-canvas-wrap">
{w > 0 && (
<TitleMiniPreview
settings={settings}
width={w}
sampleText={sampleText}
transparent
portrait={portrait}
/>
)}
</div>
)
}
const TitleTemplateSelector: React.FC<TitleTemplateSelectorProps> = ({
value,
onChange,
showEditor = true,
categories = ["system", "custom"],
context,
}) => {
const {
templates,
createTemplate,
duplicateTemplate,
updateTemplate,
deleteTemplate,
exportTemplate,
} = useTitleTemplates()
const [editingTemplate, setEditingTemplate] = useState<TitleTemplate | null>(null)
const [editorOpen, setEditorOpen] = useState(false)
const grouped = useMemo(
() => ({
builtin: templates.filter((t) => t.isBuiltin),
custom: templates.filter((t) => !t.isBuiltin),
}),
[templates],
)
const showSys = categories.includes("system")
const showMine = categories.includes("custom")
/* ── 选中模板:合成完整 TitleStyleSettings 回调给父组件 ── */
const handleSelectTemplate = useCallback(
(tpl: TitleTemplate) => {
const full: TitleStyleSettings = {
...DEFAULT_TITLE_STYLE_SETTINGS,
...titleStyleConfigToCamel(tpl.style),
}
onChange?.(tpl.id, full, tpl)
},
[onChange],
)
const handleRequestCreate = useCallback(() => {
// 新建:以当前选中模板样式为起点,否则用默认样式
let base: TitleStyleSettings = DEFAULT_TITLE_STYLE_SETTINGS
if (value) {
const sel = templates.find((t) => t.id === value)
if (sel) {
base = { ...DEFAULT_TITLE_STYLE_SETTINGS, ...titleStyleConfigToCamel(sel.style) }
}
}
const draft: TitleTemplate = {
id: "",
name: "我的标题模板",
emoji: "✨",
isBuiltin: false,
style: camelToTitleStyleConfig({
...base,
position: base.position === "custom" ? "bottom" : base.position,
}),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
setEditingTemplate(draft)
setEditorOpen(true)
}, [value, templates])
const handleRequestEdit = useCallback((tpl: TitleTemplate) => {
setEditingTemplate(tpl)
setEditorOpen(true)
}, [])
const handleDuplicate = useCallback(
(t: TitleTemplate) => {
const dup = duplicateTemplate(t.id)
if (dup) message.success(`已复制:${dup.name}`)
},
[duplicateTemplate],
)
const handleDelete = useCallback(
(t: TitleTemplate) => {
deleteTemplate(t.id)
message.success("已删除模板")
},
[deleteTemplate],
)
const handleExport = useCallback(
(t: TitleTemplate) => {
const json = exportTemplate(t.id)
if (!json) return
const blob = new Blob([json], { type: "application/json" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = `${t.name}.title-template.json`
a.click()
URL.revokeObjectURL(url)
},
[exportTemplate],
)
const handleEditorSave = useCallback(
(data: { name: string; emoji: string; style: Partial<import("./types").TitleStyleConfig> }) => {
if (!editingTemplate) return
let saved: TitleTemplate
if (editingTemplate.isBuiltin || !editingTemplate.id) {
saved = createTemplate({ name: data.name, emoji: data.emoji, style: data.style })
} else {
updateTemplate(editingTemplate.id, {
name: data.name,
emoji: data.emoji,
style: data.style,
})
saved = {
...editingTemplate,
name: data.name,
emoji: data.emoji,
style: data.style,
updatedAt: new Date().toISOString(),
}
}
setEditorOpen(false)
setEditingTemplate(null)
message.success(`已保存:${data.name}`)
handleSelectTemplate(saved)
},
[editingTemplate, createTemplate, updateTemplate, handleSelectTemplate],
)
/* ── 渲染单张大卡片 ── */
const renderCard = (t: TitleTemplate, idx: number, section: "mine" | "sys") => {
const isSelected = value === t.id
const bgIdx = idx % PREVIEW_BG_IMAGES.length
const bgImg = PREVIEW_BG_IMAGES[bgIdx]
const preview = templateToPreviewSettings(t, 42)
return (
<div
key={t.id}
className={`ttv3-card${isSelected ? " selected" : ""}`}
onClick={() => handleSelectTemplate(t)}
data-context={context}
>
<div className="ttv3-preview">
<img className="ttv3-bg" src={bgImg} alt="" />
<div className="ttv3-vignette" />
<FillPreview settings={preview} sampleText="预览标题文字" portrait />
<span className={`ttv3-badge ttv3-badge--${section}`}>
{section === "sys" ? "系统" : "我的"}
</span>
<span className={`ttv3-check${isSelected ? " on" : ""}`}>
{isSelected && <CheckOutlined />}
</span>
</div>
<div className="ttv3-footer">
<div className="ttv3-name-row">
<span className="ttv3-emoji">{t.emoji || "✨"}</span>
<span className="ttv3-name" title={t.name}>
{t.name}
</span>
<span className={`ttv3-tag ttv3-tag--${section}`}>
{section === "sys" ? "系统" : "我的"}
</span>
</div>
{showEditor && (
<div className="ttv3-actions" onClick={(e) => e.stopPropagation()}>
<button
type="button"
className="ttv3-act ttv3-act--primary"
disabled={t.isBuiltin}
onClick={() => handleRequestEdit(t)}
title={t.isBuiltin ? "系统模板不可编辑,点击复制后可编辑" : "编辑"}
>
<EditOutlined /> 编辑
</button>
<button
type="button"
className="ttv3-act"
onClick={() => handleDuplicate(t)}
title="复制"
>
<CopyOutlined /> 复制
</button>
<button
type="button"
className="ttv3-act"
onClick={() => handleExport(t)}
title="导出"
>
<ExportOutlined /> 导出
</button>
<Popconfirm title="删除该模板?" onConfirm={() => handleDelete(t)}>
<button
type="button"
className="ttv3-act ttv3-act--danger"
disabled={t.isBuiltin}
title={t.isBuiltin ? "系统模板不可删除" : "删除"}
>
<DeleteOutlined /> 删除
</button>
</Popconfirm>
</div>
)}
</div>
</div>
)
}
return (
<div className="xx-title-style-section ttv3-panel">
<div className="ttv3-header">
<span className="ttv3-title">标题模板</span>
{showEditor && (
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
onClick={handleRequestCreate}
className="ttv3-new-btn"
>
新建模板
</Button>
)}
</div>
{showMine && (
<div className="ttv3-section">
<div className="ttv3-section-label">我的模板</div>
{grouped.custom.length === 0 ? (
<div className="ttv3-empty">
<div className="ttv3-empty-icon">✨</div>
<div className="ttv3-empty-text">还没有自定义模板,点右上角「新建模板」创建</div>
</div>
) : (
<div className="ttv3-grid">
{grouped.custom.map((t, i) => renderCard(t, i, "mine"))}
</div>
)}
</div>
)}
{showSys && (
<div className="ttv3-section">
<div className="ttv3-section-label">系统模板</div>
<div className="ttv3-grid">{grouped.builtin.map((t, i) => renderCard(t, i, "sys"))}</div>
</div>
)}
{showEditor && editorOpen && editingTemplate && (
<TitleTemplateEditor
open={editorOpen}
template={editingTemplate}
onClose={() => {
setEditorOpen(false)
setEditingTemplate(null)
}}
onSave={handleEditorSave}
/>
)}
</div>
)
}
export default TitleTemplateSelector
-19
View File
@@ -1,19 +0,0 @@
/**
* 公共标题模板/样式组件统一导出
*
* 任何页面需要标题样式配置/模板选择/模板编辑,从这里 import,
* 不要直接 import pages/generate/components/title/* 下的内部组件。
*/
export { default as TitleTemplateSelector } from "./TitleTemplateSelector"
export { default as TitleTemplateEditor } from "./TitleTemplateEditor"
export { default as TitleStyleParamsTab } from "./TitleStyleParamsTab"
export { default as TitleMiniPreview } from "./TitleMiniPreview"
export { useTitleTemplates } from "./useTitleTemplates"
export * from "./constants"
export * from "./types"
export * from "./template-types"
export * from "./settings"
export * from "./utils"
export { POSITION_OPTIONS } from "./position-options"
export type { PositionOption, FontOption, TitleStyleParamsTabProps } from "./TitleStyleParamsTab"
export type { TitleTemplateSelectorProps } from "./TitleTemplateSelector"
@@ -1,14 +0,0 @@
/**
* 标题位置选项(公共常量)
*/
export interface PositionOption {
value: string
label: string
}
export const POSITION_OPTIONS: PositionOption[] = [
{ value: "top", label: "顶部" },
{ value: "center", label: "居中" },
{ value: "bottom", label: "底部" },
{ value: "custom", label: "自定义" },
]
-64
View File
@@ -1,64 +0,0 @@
/**
* 标题样式设置 — 公共 camelCase 类型与默认值
*
* 本文件是 @/components/title 公共包的唯一样式类型出口,不依赖任何业务页面(generate/ai-avatar)的私有类型。
* - 字段与后端 snake_case TitleStyleConfig 一一对应(camelCase 版本)
* - DEFAULT_TITLE_STYLE_SETTINGS 用于组件内部补全默认值
* - aiAutoSelect / title / coverTitle 等业务状态不在本类型中——它们属于页面业务 state
*/
import type { TitleLineOverride } from "./types"
export interface TitleStyleSettings {
position: string
font: string
size: number
bold: boolean
italic: boolean
stroke: boolean
shadow: boolean
color: string
posX: number | null
posY: number | null
lineHeight: number
marginTop: number
maxCharsPerLine: number
strokeWidth: number
strokeColor: string
shadowOffsetX: number
shadowOffsetY: number
shadowBlur: number
shadowColor: string
bgEnabled: boolean
bgColor: string
bgPadding: number
bgRadius: number
lineOverrides: TitleLineOverride[]
}
/** 公共默认样式(经典白字黑描边) */
export const DEFAULT_TITLE_STYLE_SETTINGS: TitleStyleSettings = {
position: "bottom",
font: "思源黑体",
size: 56,
bold: true,
italic: false,
stroke: true,
shadow: false,
color: "#ffffff",
posX: null,
posY: null,
lineHeight: 1.2,
marginTop: 24,
maxCharsPerLine: 10,
strokeWidth: 5,
strokeColor: "#000000",
shadowOffsetX: 2,
shadowOffsetY: 2,
shadowBlur: 4,
shadowColor: "rgba(0,0,0,0.8)",
bgEnabled: false,
bgColor: "rgba(0,0,0,0.5)",
bgPadding: 12,
bgRadius: 8,
lineOverrides: [],
}
+22 -23
View File
@@ -1,25 +1,24 @@
/**
* 标题样式工具(#2001 / 模板系统 #2003)
*
* - snake_case TitleStyleConfig <-> camelCase TitleStyleSettings 互转
* - snake_case TitleStyleConfig ↔ camelCase TitleSettings 互转
* - preset 归一化预览(修复"标题"两字大小不一)
* - template -> preview settings 转换
*/
import type { TitleStyleConfig } from "./types"
import type { TitleStyleSettings } from "./settings"
import { DEFAULT_TITLE_STYLE_SETTINGS } from "./settings"
import type { TitleSettings } from "../../pages/generate/types"
import { TITLE_PRESETS } from "./constants"
import { DEFAULT_TITLE_SETTINGS_FULL } from "../../pages/generate/types"
import type { TitleTemplate } from "./template-types"
/** snake_case TitleStyleConfig -> camelCase TitleStyleSettings(仅覆盖已知字段) */
export function titleStyleConfigToCamel(s: Partial<TitleStyleConfig>): Partial<TitleStyleSettings> {
const out: Partial<TitleStyleSettings> = {}
/** snake_case TitleStyleConfig → camelCase TitleSettings(仅覆盖已知字段) */
export function titleStyleConfigToCamel(s: Partial<TitleStyleConfig>): Partial<TitleSettings> {
const out: Partial<TitleSettings> = {}
if (s.font != null) out.font = s.font
if (s.size != null) out.size = s.size
if (s.color != null) out.color = s.color
if (s.bold != null) out.bold = s.bold
if (s.italic != null) out.italic = s.italic
if (s.position != null) out.position = s.position
if (s.position != null) out.position = s.position as TitleSettings["position"]
if (s.pos_x != null) out.posX = s.pos_x
if (s.pos_y != null) out.posY = s.pos_y
if (s.line_height != null) out.lineHeight = s.line_height
@@ -41,8 +40,8 @@ export function titleStyleConfigToCamel(s: Partial<TitleStyleConfig>): Partial<T
return out
}
/** camelCase TitleStyleSettings patch -> snake_case TitleStyleConfig patch */
export function camelToTitleStyleConfig(p: Partial<TitleStyleSettings>): Partial<TitleStyleConfig> {
/** camelCase TitleSettings patch → snake_case TitleStyleConfig patch */
export function camelToTitleStyleConfig(p: Partial<TitleSettings>): Partial<TitleStyleConfig> {
const out: Partial<TitleStyleConfig> = {}
if (p.font != null) out.font = p.font
if (p.size != null) out.size = p.size
@@ -72,15 +71,15 @@ export function camelToTitleStyleConfig(p: Partial<TitleStyleSettings>): Partial
}
/**
* 把 preset style(snake_case)归一化为固定字号的 TitleStyleSettings,
* 把 preset style(snake_case)归一化为固定字号的 TitleSettings,
* 用于"预设卡片"缩略预览——所有卡片视觉上"标题"两字大小一致,便于辨识。
* 描边/阴影/背景padding 按 fixedSize / 原始 size 比例缩放,避免粗描边爆框。
*/
export function buildPresetPreviewSettings(
base: TitleStyleSettings,
base: TitleSettings,
presetKey: string,
fixedSize = 56,
): TitleStyleSettings {
): TitleSettings {
const preset = TITLE_PRESETS.find((p) => p.key === presetKey)
if (!preset) return base
const origSize = preset.style.size ?? fixedSize
@@ -88,25 +87,25 @@ export function buildPresetPreviewSettings(
const scale = (v: number | undefined, fallback: number): number =>
v != null ? Math.round(v * ratio) : fallback
return {
...DEFAULT_TITLE_STYLE_SETTINGS,
...base,
...titleStyleConfigToCamel(preset.style),
size: fixedSize,
strokeWidth: scale(preset.style.stroke_width, base.strokeWidth),
shadowOffsetX: scale(preset.style.shadow_offset_x, base.shadowOffsetX),
shadowOffsetY: scale(preset.style.shadow_offset_y, base.shadowOffsetY),
shadowBlur: scale(preset.style.shadow_blur, base.shadowBlur),
bgPadding: scale(preset.style.bg_padding, base.bgPadding),
strokeWidth: scale(preset.style.stroke_width, base.strokeWidth) ?? base.strokeWidth,
shadowOffsetX: scale(preset.style.shadow_offset_x, base.shadowOffsetX) ?? base.shadowOffsetX,
shadowOffsetY: scale(preset.style.shadow_offset_y, base.shadowOffsetY) ?? base.shadowOffsetY,
shadowBlur: scale(preset.style.shadow_blur, base.shadowBlur) ?? base.shadowBlur,
bgPadding: scale(preset.style.bg_padding, base.bgPadding) ?? base.bgPadding,
lineOverrides: [],
}
}
/**
* 把 TitleTemplate 渲染为完整 TitleStyleSettings(带默认值),用于卡片预览。
* 把 TitleTemplate 渲染为完整 TitleSettings(带默认值),用于卡片预览。
* 与模板选择器中保持一致,抽出共用。
*/
export function templateToPreviewSettings(t: TitleTemplate, fixedSize = 48): TitleStyleSettings {
const base: TitleStyleSettings = {
...DEFAULT_TITLE_STYLE_SETTINGS,
export function templateToPreviewSettings(t: TitleTemplate, fixedSize = 48): TitleSettings {
const base: TitleSettings = {
...DEFAULT_TITLE_SETTINGS_FULL,
...titleStyleConfigToCamel(t.style),
}
// 预览时用固定字号保证所有卡片字大小一致;描边/阴影/padding按比例缩放
@@ -412,7 +412,7 @@ const PanelTitleConfig: React.FC<PanelTitleConfigProps> = ({ titleConfig, onUpda
onUpdateStyle={handleUpdateStyle}
showCoverToggle
previewWidth={280}
enableTemplates={true}
enableTemplates={false}
selectedTemplateId={selectedTemplateId}
onApplyTemplate={handleApplyTemplate}
activePreset={activePreset}
@@ -1 +1,271 @@
export { default } from "@/components/title/TitleMiniPreview"
/**
* 标题迷你 Canvas 预览(#2001)
*
* 渲染一张指定宽度的小 Canvas 预览标题效果,用于:
* - 预设卡片缩略图
* - 样式面板顶部的实时预览
*
* 与 titleCanvas.ts 渲染逻辑保持一致,但:
* - 固定分辨率(width × 宽高比约 2:1)
* - 不调用 ffmpeg,只做视觉预览
* - 支持背景色块、描边宽度/颜色、阴影参数化、行距、自动换行
*/
import React, { useEffect, useRef } from "react"
import type { TitleSettings } from "../../types"
import { getFontFamily } from "@/components/title/constants"
interface Props {
settings: TitleSettings
width?: number
sampleText?: string
/** 背景(预览用,默认深色渐变模拟视频底),transparent=true 时忽略 */
background?: string
/** 高度(可选,默认按 portrait 选比例) */
height?: number
/** 透明背景(卡片/编辑器预览叠加在图片上时使用) */
transparent?: boolean
/** 纵向竖屏预览(9:16),true 时 aspect=16/9 适配手机视频比例 */
portrait?: boolean
}
/** 按 maxCharsPerLine 自动换行 */
function wrapLines(text: string, maxChars: number): string[] {
const manual = text
.split(/[//\n]/)
.map((l) => l.trim())
.filter(Boolean)
if (!maxChars || maxChars <= 0) return manual
const out: string[] = []
for (const line of manual) {
if (line.length <= maxChars) {
out.push(line)
continue
}
let cur = ""
for (const ch of line) {
cur += ch
if (cur.length >= maxChars) {
out.push(cur)
cur = ""
}
}
if (cur) out.push(cur)
}
return out
}
const TitleMiniPreview: React.FC<Props> = ({
settings,
width = 200,
sampleText,
background = "linear-gradient(135deg,#1f2937,#111827)",
height,
transparent = false,
portrait = false,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null)
const h = height ?? Math.round(width * (portrait ? 16 / 9 : 1 / 1.8))
const text = (sampleText || settings.title || "预览标题").trim() || "预览标题"
useEffect(() => {
let cancelled = false
const draw = () => {
if (cancelled) return
const cvs = canvasRef.current
if (!cvs) return
const dpr = window.devicePixelRatio || 1
cvs.width = width * dpr
cvs.height = h * dpr
cvs.style.width = `${width}px`
cvs.style.height = `${h}px`
const ctx = cvs.getContext("2d")
if (!ctx) return
ctx.scale(dpr, dpr)
ctx.clearRect(0, 0, width, h)
// 背景(transparent 时跳过,用于叠加在图片上)
if (!transparent) {
ctx.fillStyle = "#111827"
ctx.fillRect(0, 0, width, h)
}
// 分辨率缩放:以 360 宽为基准(对应 720p 的一半),与外层 previewScale/previewR 保持一致
const r = previewR
// 字体
const size = r(settings.size)
const ff = getFontFamily(settings.font)
const parts: string[] = []
if (settings.italic) parts.push("italic")
if (settings.bold) parts.push("bold")
parts.push(`${size}px`, ff)
ctx.font = parts.join(" ")
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.fillStyle = settings.color
ctx.lineJoin = "round"
// 阴影
const shadowEnabled = !!settings.shadow
const prevShadow = {
c: ctx.shadowColor,
b: ctx.shadowBlur,
ox: ctx.shadowOffsetX,
oy: ctx.shadowOffsetY,
}
if (shadowEnabled) {
ctx.shadowColor = settings.shadowColor ?? "rgba(0,0,0,0.8)"
ctx.shadowBlur = r(settings.shadowBlur ?? 4)
ctx.shadowOffsetX = r(settings.shadowOffsetX ?? 2)
ctx.shadowOffsetY = r(settings.shadowOffsetY ?? 2)
}
// 换行
const lines = wrapLines(text, settings.maxCharsPerLine ?? 0)
const lineH = size * (settings.lineHeight ?? 1.2)
const totalH = lines.length * lineH
let startY: number
if (settings.position === "top") {
startY = size / 2 + r(settings.marginTop ?? 24)
} else if (settings.position === "center") {
startY = h / 2 - totalH / 2 + size / 2
} else {
// bottom
const botMargin = portrait ? r(24) : r(16)
startY = h - totalH - botMargin + size / 2
}
let centerX = width / 2
if (settings.position === "custom" && settings.posX != null) {
centerX = (settings.posX / 100) * width
}
// 背景块
if (settings.bgEnabled) {
const pad = r(settings.bgPadding ?? 12)
const rad = r(settings.bgRadius ?? 8)
let maxLineW = 0
for (const l of lines) {
const m = ctx.measureText(l)
if (m.width > maxLineW) maxLineW = m.width
}
const bw = maxLineW + pad * 2
const bh = totalH + pad * 2
const bx = centerX - bw / 2
const by = startY - size / 2 - pad + (size - lineH) / 2
ctx.shadowColor = "rgba(0,0,0,0)"
ctx.shadowBlur = 0
ctx.fillStyle = settings.bgColor ?? "rgba(0,0,0,0.5)"
roundRect(ctx, bx, by, bw, bh, rad)
ctx.fill()
// 关键修复:画完背景块后必须把 fillStyle 重置为文字颜色,
// 否则后续 fillText 会用 bgColor 填充文字,导致「文字看不见只剩色块」
ctx.fillStyle = settings.color
// 恢复阴影
if (shadowEnabled) {
ctx.shadowColor = settings.shadowColor ?? "rgba(0,0,0,0.8)"
ctx.shadowBlur = r(settings.shadowBlur ?? 4)
ctx.shadowOffsetX = r(settings.shadowOffsetX ?? 2)
ctx.shadowOffsetY = r(settings.shadowOffsetY ?? 2)
}
}
// 描边(先画,再画填充)
const strokeEnabled = !!settings.stroke && (settings.strokeWidth ?? 0) > 0
lines.forEach((line, i) => {
const y = startY + i * lineH
if (strokeEnabled) {
ctx.shadowColor = "rgba(0,0,0,0)"
ctx.shadowBlur = 0
ctx.lineWidth = r(settings.strokeWidth ?? 4)
ctx.strokeStyle = settings.strokeColor ?? "#000000"
ctx.strokeText(line, centerX, y)
// 恢复阴影
if (shadowEnabled) {
ctx.shadowColor = settings.shadowColor ?? "rgba(0,0,0,0.8)"
ctx.shadowBlur = r(settings.shadowBlur ?? 4)
ctx.shadowOffsetX = r(settings.shadowOffsetX ?? 2)
ctx.shadowOffsetY = r(settings.shadowOffsetY ?? 2)
}
}
ctx.fillText(line, centerX, y)
})
// 恢复
ctx.shadowColor = prevShadow.c
ctx.shadowBlur = prevShadow.b
ctx.shadowOffsetX = prevShadow.ox
ctx.shadowOffsetY = prevShadow.oy
}
// 计算当前字号(draw() 内部同样逻辑,抽出来供 fontString 复用)
const previewScale = width / 360
const previewR = (v: number) => Math.round(v * previewScale)
const buildFontString = () => {
const size = previewR(settings.size)
const ff = getFontFamily(settings.font)
const parts: string[] = []
if (settings.italic) parts.push("italic")
if (settings.bold) parts.push("bold")
parts.push(`${size}px`, ff)
return parts.join(" ")
}
// Web Font 加载保障:
// 1) 等 document.fonts.ready(CSS @font-face 首次可用)
// 2) 显式 FontFaceSet.load(fontString, text) 触发浏览器真正下载并加载
// 当前字体到 Canvas 可用,避免首次绘制用 fallback 字体画出错字/色块
const doDrawWhenReady = async () => {
try {
if (typeof document !== "undefined" && document.fonts) {
await document.fonts.ready
try {
await document.fonts.load(buildFontString(), text)
} catch {
/* ignore */
}
}
} finally {
if (!cancelled) draw()
}
}
doDrawWhenReady()
return () => {
cancelled = true
}
}, [settings, width, h, text, transparent, portrait, background])
return (
<canvas
ref={canvasRef}
style={{
borderRadius: 6,
display: "block",
maxWidth: "100%",
background: transparent ? "transparent" : background,
}}
/>
)
}
function roundRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
r: number,
) {
const rr = Math.min(r, w / 2, h / 2)
ctx.beginPath()
ctx.moveTo(x + rr, y)
ctx.lineTo(x + w - rr, y)
ctx.quadraticCurveTo(x + w, y, x + w, y + rr)
ctx.lineTo(x + w, y + h - rr)
ctx.quadraticCurveTo(x + w, y + h, x + w - rr, y + h)
ctx.lineTo(x + rr, y + h)
ctx.quadraticCurveTo(x, y + h, x, y + h - rr)
ctx.lineTo(x, y + rr)
ctx.quadraticCurveTo(x, y, x + rr, y)
ctx.closePath()
}
export default TitleMiniPreview
@@ -31,7 +31,7 @@ import {
} from "@/components/title/constants"
import { buildPresetPreviewSettings } from "@/components/title/utils"
import TitleMiniPreview from "@/components/title/TitleMiniPreview"
import TitleMiniPreview from "./TitleMiniPreview"
import TitleTemplateEditor from "@/components/title/TitleTemplateEditor"
import { useTitleTemplates } from "@/components/title/useTitleTemplates"
import {
@@ -177,7 +177,7 @@ const ColorPicker: React.FC<{
/* ── 卡片预览:用 ref 测量容器宽度后再渲染透明 Canvas,保证文字清晰 ── */
const FillPreview: React.FC<{
settings: import("@/components/title/settings").TitleStyleSettings
settings: TitleSettings
sampleText: string
portrait?: boolean
}> = ({ settings, sampleText, portrait }) => {
+7 -3
View File
@@ -46,9 +46,13 @@ export const CLIP_COUNT_STEP = 1
export const MAX_PREVIEW_COUNT = 10
export const MIN_PREVIEW_COUNT = 1
/* ── 标题位置选项(统一从公共层重导出) ── */
export { POSITION_OPTIONS } from "@/components/title/position-options"
export type { PositionOption } from "@/components/title/position-options"
/* ── 标题位置选项 ── */
export const POSITION_OPTIONS = [
{ value: "top", label: "顶部" },
{ value: "center", label: "居中" },
{ value: "bottom", label: "底部" },
{ value: "custom", label: "自定义" },
]
/* ── 标题字体:统一使用公共层定义(#2001) ── */
export { getFontFamily } from "@/components/title/constants"
View File
@@ -1,112 +0,0 @@
"""一次性脚本:对历史 quality_score 缺失的视频素材重新打分。
背景(#2073):镜像 97ad0ae2 时期 calculate_quality_score / classify_from_analysis
返回 str 而非 AssetClassification 枚举,导致 calculate_asset_quality 连续报
"'str' object has no attribute 'value'",大量视频素材的 quality_score 卡在 NULL。
镜像 8abdeb95 已修复枚举 bug,但历史失败记录不会自动重跑。本脚本扫描全表,
把 quality_score IS NULL 的视频素材重新投递到 worker.calculate_asset_quality 任务。
使用方式(在 worker 容器内执行):
cd /app/apps/worker
# 干跑,只打印会重跑多少条,不发任务
python -m scripts.backfill_asset_quality --dry-run
# 正式执行
python -m scripts.backfill_asset_quality
# 只重跑最近 N 天的
python -m scripts.backfill_asset_quality --since-days 30
# 限流:每投递一批 sleep 几秒,避免瞬间打爆 transcode 队列
python -m scripts.backfill_asset_quality --batch-size 50 --sleep 2
也可以直接在 staging 机器上 exec 进容器:
docker exec -e PYTHONPATH=/app:/app/apps/api:/app/packages xiaoxia-worker-staging \
python -m scripts.backfill_asset_quality --dry-run
"""
from __future__ import annotations
import argparse
# 保证可以以 python -m scripts.xxx 在容器 /app/apps/worker 下执行
# 也兼容在 repo 根目录下执行(注入路径)
import os
import sys
import time
from datetime import UTC, datetime, timedelta
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
_WORKER_DIR = os.path.dirname(_SCRIPT_DIR) # apps/worker
_APPS_DIR = os.path.dirname(_WORKER_DIR) # apps
_REPO_ROOT = os.path.dirname(_APPS_DIR) # repo root
for p in (_REPO_ROOT, os.path.join(_REPO_ROOT, "apps", "api"), _REPO_ROOT):
if p not in sys.path:
sys.path.insert(0, p)
def main() -> int:
parser = argparse.ArgumentParser(description="补打历史视频素材 quality_score")
parser.add_argument("--dry-run", action="store_true", help="只统计数量,不投递任务")
parser.add_argument("--since-days", type=int, default=0, help="只处理最近 N 天上传的素材(0=全部)")
parser.add_argument("--batch-size", type=int, default=50, help="每批投递数量,默认 50")
parser.add_argument("--sleep", type=float, default=1.0, help="批次之间 sleep 秒数,默认 1s")
parser.add_argument("--queue", type=str, default="transcode", help="投递队列(默认 transcode)")
args = parser.parse_args()
# 延迟 import,避免在 dry-run 时依赖完整 DB 环境
from worker_app.celery_app import celery_app
from worker_app.db import SessionLocal
from packages.adapters.sqlalchemy_impl.models import AssetModel
db = SessionLocal()
try:
q = db.query(AssetModel).filter(
AssetModel.file_type == "video",
AssetModel.quality_score.is_(None),
)
if args.since_days > 0:
cutoff = datetime.now(UTC) - timedelta(days=args.since_days)
q = q.filter(AssetModel.created_at >= cutoff)
# 先 count 打印
total = q.count()
print(
f"[backfill] 待重跑 quality_score 的视频素材: {total} 条"
f"{' (dry-run,不投递)' if args.dry_run else ''}"
f"{' (最近 ' + str(args.since_days) + ' 天)' if args.since_days > 0 else ''}",
flush=True,
)
if total == 0 or args.dry_run:
return 0
# 分批投递
submitted = 0
batch = 0
offset = 0
while True:
assets = q.order_by(AssetModel.created_at.desc()).offset(offset).limit(args.batch_size).all()
if not assets:
break
batch += 1
for a in assets:
try:
celery_app.send_task(
"worker.calculate_asset_quality",
args=[a.id],
queue=args.queue,
)
submitted += 1
except Exception as e: # noqa: BLE001
print(f"[backfill] 投递失败 asset_id={a.id}: {e}", flush=True)
print(f"[backfill] batch {batch}: 已累计投递 {submitted}/{total}", flush=True)
offset += len(assets)
if args.sleep > 0 and offset < total:
time.sleep(args.sleep)
print(f"[backfill] 完成,共投递 {submitted} 条任务到 {args.queue} 队列", flush=True)
return 0
finally:
db.close()
if __name__ == "__main__":
sys.exit(main())
@@ -337,24 +337,3 @@ def resolve_asset_ids_to_paths(
if local_path:
result[aid] = local_path
return result
def delete_from_oss(storage_key_or_url: str) -> bool:
"""从 OSS 删除对象(best-effort 清理临时文件,失败不抛异常)。
Args:
storage_key_or_url: 存储键或完整 URL
Returns:
True 删除成功,False 删除失败或未配置。
"""
bucket = oss_bucket()
if bucket is None:
return False
try:
key = normalize_storage_key(storage_key_or_url)
bucket.delete_object(key)
return True
except Exception:
logger.exception("删除OSS对象失败: %s", storage_key_or_url[:80])
return False
@@ -221,7 +221,7 @@ def _extract_frames_via_mediakit(
"""
import uuid
from video_processing.oss_helpers import get_signed_download_url, upload_to_oss
from video_processing.oss_helpers import upload_to_oss
from packages.shared.mediakit_client import get_mediakit_client
@@ -230,17 +230,14 @@ def _extract_frames_via_mediakit(
logger.info("[thumbnail] MediaKit 未配置,跳过智能抽帧")
return None
video_storage_key: str = ""
# 1. 上传视频到 OSS,并生成预签名下载 URL(bucket 私有读,公网 URL 会 403)
# 1. 上传视频到 OSS 获取 URL
try:
video_storage_key = f"temp/{plan_id}/{uuid.uuid4().hex[:8]}_{Path(video_path).name}"
public_url = upload_to_oss(video_path, video_storage_key)
if not public_url:
video_url = upload_to_oss(video_path, video_storage_key)
if not video_url:
logger.warning("[thumbnail] 视频上传 OSS 失败,无法使用 MediaKit")
return None
# MediaKit 从公网拉取视频,必须使用预签名 URL;签名 1h 足够完成抽帧
video_url = get_signed_download_url(video_storage_key, expires_seconds=3600) or public_url
logger.info("[thumbnail] 视频已上传 OSS 并生成签名 URL: key=%s", video_storage_key[:80])
logger.info("[thumbnail] 视频已上传 OSS: %s", video_url[:80])
except Exception as e:
logger.warning("[thumbnail] 视频上传 OSS 异常: %s,降级到 ffmpeg", e)
return None
@@ -18,7 +18,6 @@ from worker_app.celery_app import celery_app
from worker_app.db import SessionLocal
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
from packages.domain.classification import ClassificationStatus
from packages.shared.storage import get_shared_storage_service
logger = get_task_logger(__name__)
@@ -97,7 +96,7 @@ def calculate_asset_quality_task(self, asset_id: str) -> dict:
confidence = 1.0
existing_meta["classification"] = classification
existing_meta["classification_confidence"] = confidence
asset.classification_status = ClassificationStatus.COMPLETED
asset.classification_status = "completed"
asset.metadata = existing_meta
logger.info(
"[quality_score] asset=%s 自动分类完成: category=%s confidence=%.2f",
@@ -111,8 +110,6 @@ def calculate_asset_quality_task(self, asset_id: str) -> dict:
asset_id,
cls_err,
)
# 分类失败显式标记 FAILED,避免停留在 PENDING 被反复重试
asset.classification_status = ClassificationStatus.FAILED
asset_repo.update(asset)
db.commit()
+10 -5
View File
@@ -1051,15 +1051,20 @@ def generate_video(self, task_id: str) -> dict:
)
if _meta_model:
meta = dict(_meta_model.extra_meta or {})
# #2024/P0 finalize-400: 直接展开 _precompute_render_metadata 返回的
# 完整 dict(含 file_url/fingerprint_dict/fingerprint_chunks/is_duplicate
# /duplicate_of/...),避免手写字段白名单漏传字段导致 finalize 读不到数据。
meta["rendered_output"] = {
**dict(rendered_output or {}),
# file_url/duration 由外层调用方拿到的实际上传结果,优先覆盖预计算值
"file_url": file_url,
"file_size": file_size,
"duration": duration,
"width": rendered_output.get("width", 1280),
"height": rendered_output.get("height", 720),
"fps": rendered_output.get("fps", 25.0),
"name": rendered_output.get("name", ""),
"thumbnail_url": rendered_output.get("thumbnail_url", ""),
"mode": rendered_output.get("mode", editing_mode.value),
"fingerprint_dict": rendered_output.get("fingerprint_dict"),
"batch_id": batch_id,
"project_id": project_id,
"user_id": user_id,
}
_meta_model.extra_meta = meta
_finalize_meta_session.commit()
-15
View File
@@ -280,18 +280,3 @@ USE_GPU_LIPSYNC=true
GPU_LIPSYNC_POLL_INTERVAL=5
GPU_LIPSYNC_WAIT_TIMEOUT=1200
GPU_WORKER_STALE_SECONDS=300
# ==================== P4000 NVENC 硬件编码(GPU mezzanine relay)====================
# 注意:这些值必须写死在模板里(不是 CI Secret),否则每次 CI 重新渲染 .env 都会被丢弃,
# 导致 staging 发版后 GPU 编码静默降级到 CPU(P0 防复发)。
ENABLE_GPU_ENCODE=true
GPU_ENCODE_ENDPOINT=http://100.105.75.67:8900
GPU_ENCODE_RELAY_BASE_URL=http://100.125.116.43:8092
GPU_ENCODE_RELAY_INTERNAL_BASE_URL=http://xiaoxia-api-staging:8000
GPU_ENCODE_RELAY_SECRET=0e1a8f0626438564a8b3fa92f3f2aac29e3c69bc02f2f85c
GPU_ENCODE_VCODEC=h264_nvenc
GPU_ENCODE_PRESET=p4
GPU_ENCODE_CRF=23
GPU_ENCODE_FALLBACK_CPU=true
GPU_ENCODE_MEZZANINE_TRANSPORT=oss
GPU_ENCODE_OSS_TMP_PREFIX=tmp/gpu-mezzanine/
+72 -44
View File
@@ -10,25 +10,20 @@
# API_IMAGE - API 镜像名称 (默认: xiaoxia-saas-api:dev)
# WORKER_IMAGE - Worker 镜像名称 (默认: xiaoxia-saas-worker:dev)
# WEB_IMAGE - Web 镜像名称 (默认: xiaoxia-saas-web:dev)
# WEB_DOCKERFILE - Web Dockerfile 路径
# WEB_NGINX_CONF - Nginx 配置文件路径
# API_PORT - API 端口映射 (staging: 8000, production: 8001)
# WEB_PORT - Web 端口映射 (staging: 3001, production: 3002)
# GENERATED_FILES_HOST_DIR - 生成文件的主机目录
# GENERATION_CONCURRENCY - Generation worker 并发(用户实时任务,默认 2)
# TRANSCODE_CONCURRENCY - Transcode worker 并发(后台/转码/AI,默认 2)
# WORKER_CONCURRENCY - Worker 并发数 (默认: 4)
# WORKER_MAX_TASKS_PER_CHILD - Worker 每个子进程最大任务数 (默认: 100)
# BEAT_ENABLED - 容器内启动 celery beat(默认 1;独立 beat 容器部署设为 0)
# WORKER_CONCURRENCY - 兼容旧变量:未显式设置上面两个并发时按此总数分配
#
# 重要:
# 重要:
# - 生产环境不要挂载 web-dist volume,否则会导致 403
# - 确保环境隔离网络已创建: docker network create xiaoxia-net-${ENV}
# - ENV=staging -> xiaoxia-net-staging
# - ENV=production -> xiaoxia-net-production
# - ENV=staging → xiaoxia-net-staging
# - ENV=production → xiaoxia-net-production
#
# #2073 队列分流:worker 容器内跑三个独立进程——beat(只发定时任务)、
# generation worker(只消费 generation 队列,实时高优)、transcode worker(消费
# transcode + celery 队列,后台任务)。beat 不再嵌入 generation worker,
# 不占实时任务槽位;TRANSCODE_CONCURRENCY 独立伸缩,不再依赖 WORKER_CONCURRENCY 差值。
# ===========================================
# 日志轮转配置(所有服务共享)
@@ -45,39 +40,53 @@ services:
# =========================================
api:
image: ${API_IMAGE:-xiaoxia-saas-api:dev}
# 不在生产环境构建镜像,使用预构建的镜像
# build:
# context: ../..
# dockerfile: infra/docker/api.Dockerfile
container_name: xiaoxia-api-${ENV:-staging}
restart: unless-stopped
stop_grace_period: 30s
stop_signal: SIGTERM
# 环境变量文件(包含数据库密码等敏感信息)
env_file:
- ../../.env
environment:
APP_ENV: ${APP_ENV:-staging}
GENERATED_FILES_DIR: /app/generated
GENERATED_FILES_URL_PREFIX: /generated-files
PUBLIC_API_BASE_URL: ${PUBLIC_API_BASE_URL:-https://api.xiaoxiajianji.com}
# 端口映射
# Staging: 8000 -> 8000
# Production: 8001 -> 8000
ports:
- "127.0.0.1:${API_PORT:-8000}:8000"
# 共享生成文件目录 + 抖音 cookies 等运行时配置
volumes:
- generated-files:/app/generated
- ../../deploy/configs:/app/configs:ro
networks:
- xiaoxia-net
# 健康检查配置
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
logging: *default-logging
# =========================================
# 资源限制建议(生产环境建议启用)
# =========================================
deploy:
resources:
limits:
@@ -88,48 +97,39 @@ services:
memory: 512M
# =========================================
# Worker 服务(#2073 队列分流:beat + generation + transcode 同容器三进程)
# Worker 服务(Celery 任务队列)
# =========================================
# 三个进程独立启动,任一退出则容器整体退出由 docker restart 拉起;
# 各自的并发与资源占用通过环境变量控制:
# - generation:GENERATION_CONCURRENCY(默认 2),消费 generation 队列
# - transcode: TRANSCODE_CONCURRENCY(默认 2),消费 transcode,celery 队列
# - beat: 不消费任务,只发定时任务到 celery 默认队列
worker:
image: ${WORKER_IMAGE:-xiaoxia-saas-worker:dev}
container_name: xiaoxia-worker-${ENV:-staging}
restart: unless-stopped
# 长任务(ingest HEVC 转码最长 30min、生成硬超时 11min)给足优雅关闭窗口
stop_grace_period: 300s
stop_signal: SIGTERM
env_file:
- ../../.env
environment:
APP_ENV: ${APP_ENV:-staging}
# 兼容旧变量:若两个 *_CONCURRENCY 均未显式设置,entrypoint 会按此总数分配
WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-4}
WORKER_MAX_TASKS_PER_CHILD: ${WORKER_MAX_TASKS_PER_CHILD:-100}
# #2073 队列独立伸缩:generation 默认 2,transcode 默认 2(不再差值计算)
# #1714 队列隔离:generation 队列独占 worker(默认并发 2),其余并发给转码
GENERATION_CONCURRENCY: ${GENERATION_CONCURRENCY:-2}
TRANSCODE_CONCURRENCY: ${TRANSCODE_CONCURRENCY:-2}
# beat 默认在本容器启动;独立 beat 容器部署时设为 0
BEAT_ENABLED: ${BEAT_ENABLED:-1}
GENERATED_FILES_DIR: /app/generated
GENERATED_FILES_URL_PREFIX: /generated-files
PUBLIC_API_BASE_URL: ${PUBLIC_API_BASE_URL:-https://api.xiaoxiajianji.com}
volumes:
- generated-files:/app/generated
networks:
- xiaoxia-net
# 健康检查:至少有一个 celery worker 进程在跑(beat 本身不作为存活依据)
# 健康检查配置
# 注:容器内无 pgrep/ps,扫描 /proc 所有进程的 cmdline 查找 celery 进程
healthcheck:
test: ["CMD-SHELL", "grep -q 'celery.*worker' /proc/[0-9]*/cmdline 2>/dev/null || exit 1"]
test: ["CMD-SHELL", "grep -lq celery /proc/[0-9]*/cmdline 2>/dev/null || exit 1"]
interval: 30s
timeout: 10s
retries: 3
@@ -137,8 +137,12 @@ services:
logging: *default-logging
# 资源限制:容器总资源 = gen + trans + beat,按 2+2 并发场景建议 4C8G;
# 后续如需独立扩容/重启,可拆为 worker-generation / worker-transcode / worker-beat 三个 service。
# =========================================
# 资源限制建议(生产环境建议启用)
# =========================================
# 注意: Worker 需要处理视频,建议分配更多资源
# #1714 队列隔离后容器内运行 generation + transcode 两个 worker 进程,
# 总并发 = WORKER_CONCURRENCY(默认 4),4C8G 以上确保视频渲染不 OOM
deploy:
resources:
limits:
@@ -153,21 +157,35 @@ services:
# =========================================
web:
image: ${WEB_IMAGE:-xiaoxia-saas-web:dev}
# 不在生产环境构建镜像,使用 web-artifact.Dockerfile
# build:
# context: ../..
# dockerfile: ${WEB_DOCKERFILE:-infra/docker/web.Dockerfile}
# args:
# (NGINX_CONF no longer needed - all configs baked into image)
container_name: xiaoxia-web-${ENV:-staging}
restart: unless-stopped
# 端口映射
# Staging: 3001 -> 80
# Production: 3002 -> 80 (通过 Nginx 反向代理)
ports:
- "127.0.0.1:${WEB_PORT:-3001}:80"
networks:
- xiaoxia-net
# =========================================
# Nginx 配置运行时覆盖(双保险:entrypoint 也按 APP_ENV 选择配置)
# 确保容器使用正确环境的 nginx 配置,即使镜像构建时使用了默认配置
# 注意: 只覆盖 /etc/nginx/conf.d/default.conf,不挂载 /usr/share/nginx/html
# =========================================
environment:
- APP_ENV=${ENV:-staging}
volumes:
- ./nginx-${ENV:-staging}.conf:/etc/nginx/conf.d/default.conf:ro
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:80"]
interval: 30s
@@ -176,6 +194,9 @@ services:
logging: *default-logging
# =========================================
# 资源限制建议
# =========================================
deploy:
resources:
limits:
@@ -191,6 +212,9 @@ volumes:
driver_opts:
type: none
o: bind
# 重要: 确保主机目录存在且有正确权限
# Staging: /var/lib/xiaoxia-saas-staging/generated
# Production: /var/lib/xiaoxia-saas-production/generated
device: ${GENERATED_FILES_HOST_DIR:?GENERATED_FILES_HOST_DIR must be set in .env}
# ===========================================
@@ -199,4 +223,8 @@ volumes:
networks:
xiaoxia-net:
external: true
# 网络名根据 ENV 变量区分,实现 staging/production 环境隔离
# staging: xiaoxia-net-staging
# production: xiaoxia-net-production
name: xiaoxia-net-${ENV:-staging}
+29 -67
View File
@@ -1,77 +1,48 @@
#!/bin/bash
# Worker 启动脚本 — #1714 + #2073 队列分流
# Worker 启动脚本 — #1714 队列隔离
#
# 容器内启动三个独立进程(任一退出则整体退出由 docker restart 拉起):
# 1. beat:celery beat 调度器,不消费任何任务,只发定时任务到 celery 默认队列
# 2. generation-worker:独占消费 generation 队列(用户实时任务,高优先级)
# 3. transcode-worker:消费 transcode + celery 默认队列(后台/清理任务)
# 部署约束:worker 容器单实例(replicas=1),容器内启动两个 celery 进程:
# 1. generation-worker:独占消费 generation 队列(用户视频生成,高优先级),
# 内嵌 celery beat(-B),定时清理任务只在一个进程里跑,避免重复执行;
# 2. transcode-worker:消费 transcode + celery 默认队列(素材转码/分类/查重/
# 配音/下载等后台任务)。
# 转码队列积压时,generation 队列仍有独立 worker 立即领取视频生成任务。
#
# 环境变量:
# WORKER_CONCURRENCY 总并发槽参考(默认 4);生成 worker 并发默认 2,
# 可用 GENERATION_CONCURRENCY 覆盖
# GENERATION_CONCURRENCY generation worker 并发(默认 2)
# TRANSCODE_CONCURRENCY transcode worker 并发(默认 2)
# TRANSCODE_CONCURRENCY transcode worker 并发(默认 = WORKER_CONCURRENCY - 2,最小 1)
# WORKER_MAX_TASKS_PER_CHILD 每个子进程最大任务数(默认 100)
# WORKER_CONCURRENCY 兼容旧变量:若未显式设置 GENERATION_CONCURRENCY /
# TRANSCODE_CONCURRENCY,则按比例分配(gen=ceil(total*1/2),
# trans=剩余,各至少 1);已显式设置时忽略此变量。
# BEAT_ENABLED 是否在本容器内启动 beat 进程(默认 1);
# 若独立 beat 容器部署设为 0。
set -e
CONCURRENCY="${WORKER_CONCURRENCY:-4}"
MAX_TASKS="${WORKER_MAX_TASKS_PER_CHILD:-100}"
# ── 并发计算:显式 env 优先;否则从 WORKER_CONCURRENCY 按比例推导 ──
if [ -n "$GENERATION_CONCURRENCY" ]; then
GEN_CONCURRENCY="$GENERATION_CONCURRENCY"
else
TOTAL="${WORKER_CONCURRENCY:-4}"
GEN_CONCURRENCY=$(( (TOTAL + 1) / 2 ))
if [ "$GEN_CONCURRENCY" -lt 1 ]; then GEN_CONCURRENCY=1; fi
fi
if [ -n "$TRANSCODE_CONCURRENCY" ]; then
TRANS_CONCURRENCY="$TRANSCODE_CONCURRENCY"
else
if [ -n "$WORKER_CONCURRENCY" ] && [ -z "$GENERATION_CONCURRENCY" ]; then
# 两个都没显式设置,按 WORKER_CONCURRENCY 分配剩余
TOTAL="$WORKER_CONCURRENCY"
TRANS_CONCURRENCY=$(( TOTAL - GEN_CONCURRENCY ))
if [ "$TRANS_CONCURRENCY" -lt 1 ]; then TRANS_CONCURRENCY=1; fi
else
# 默认 2(#2073:独立伸缩,不再依赖 WORKER_CONCURRENCY 差值)
TRANS_CONCURRENCY=2
GEN_CONCURRENCY="${GENERATION_CONCURRENCY:-2}"
if [ -z "$TRANSCODE_CONCURRENCY" ]; then
TRANS_CONCURRENCY=$((CONCURRENCY - GEN_CONCURRENCY))
if [ "$TRANS_CONCURRENCY" -lt 1 ]; then
TRANS_CONCURRENCY=1
fi
else
TRANS_CONCURRENCY="$TRANSCODE_CONCURRENCY"
fi
BEAT_ENABLED="${BEAT_ENABLED:-1}"
PIDS=()
# ── 1. Beat 调度器(独立进程,不消费任务)──
if [ "$BEAT_ENABLED" = "1" ] || [ "$BEAT_ENABLED" = "true" ]; then
echo "Starting beat scheduler (schedule file=/tmp/celerybeat-schedule)"
celery \
-A worker_app.celery_app \
beat \
--loglevel=info \
-s /tmp/celerybeat-schedule &
PIDS+=($!)
fi
# ── 2. Generation worker(实时高优队列)──
echo "Starting generation worker (queue=generation, concurrency=$GEN_CONCURRENCY)"
echo "Starting generation worker (queue=generation, concurrency=$GEN_CONCURRENCY, beat embedded)"
celery \
-A worker_app.celery_app \
worker \
--loglevel=info \
"-B" \
-s /tmp/celerybeat-schedule \
-Q generation \
"--concurrency=${GEN_CONCURRENCY}" \
"--max-tasks-per-child=${MAX_TASKS}" \
-n generation@%h &
PIDS+=($!)
GEN_PID=${PIDS[1]:-${PIDS[0]}}
GEN_PID=$!
# ── 3. Transcode worker(后台 + 清理队列)──
echo "Starting transcode worker (queues=transcode,celery, concurrency=$TRANS_CONCURRENCY)"
celery \
-A worker_app.celery_app \
@@ -81,22 +52,13 @@ celery \
"--concurrency=${TRANS_CONCURRENCY}" \
"--max-tasks-per-child=${MAX_TASKS}" \
-n transcode@%h &
PIDS+=($!)
TRANS_PID=${PIDS[2]:-${PIDS[1]}}
TRANS_PID=$!
# 任一进程退出则终止其他进程,让容器整体重启
cleanup() {
echo "Shutting down all celery processes..."
for pid in "${PIDS[@]}"; do
kill -TERM "$pid" 2>/dev/null || true
done
}
trap cleanup TERM INT
# 任一进程退出则终止另一个,让容器整体重启(restart: unless-stopped)
trap 'echo "Shutting down workers..."; kill -TERM $GEN_PID $TRANS_PID 2>/dev/null || true' TERM INT
# wait -n 等待任意一个子进程退出(bash 4.3+)
# 容器镜像基础为 python:3.11-slim,bash 版本满足
wait -n "${PIDS[@]}"
wait -n $GEN_PID $TRANS_PID
EXIT_CODE=$?
echo "One celery process exited (code=$EXIT_CODE), stopping the rest..."
cleanup
exit "$EXIT_CODE"
echo "One worker exited (code=$EXIT_CODE), stopping the other..."
kill -TERM $GEN_PID $TRANS_PID 2>/dev/null || true
exit $EXIT_CODE
@@ -128,12 +128,8 @@ class SQLAlchemyAssetRepository:
height=asset.height,
fps=asset.fps,
codec=asset.codec,
status=(asset.status.value if hasattr(asset.status, "value") else str(asset.status)),
classification_status=(
asset.classification_status.value
if hasattr(asset.classification_status, "value")
else str(asset.classification_status)
),
status=asset.status.value,
classification_status=asset.classification_status.value,
classification_result=(json.dumps(asset.metadata) if asset.metadata else None),
quality_score=asset.quality_score,
uploaded_by_user_id=asset.uploaded_by_user_id or "system",
@@ -146,7 +142,7 @@ class SQLAlchemyAssetRepository:
self.session.flush()
self._sync_asset_tags(asset.id, asset.tag_ids)
# Issue #1776: 自动维护素材库计数(同事务内原子更新)
if asset.library_id and (getattr(asset.status, "value", str(asset.status)) != "deleted"):
if asset.library_id and asset.status.value != "deleted":
from sqlalchemy import func
self.session.query(AssetLibraryModel).filter(AssetLibraryModel.id == asset.library_id).update(
@@ -172,12 +168,8 @@ class SQLAlchemyAssetRepository:
model.height = asset.height
model.fps = asset.fps
model.codec = asset.codec
model.status = asset.status.value if hasattr(asset.status, "value") else str(asset.status)
model.classification_status = (
asset.classification_status.value
if hasattr(asset.classification_status, "value")
else str(asset.classification_status)
)
model.status = asset.status.value
model.classification_status = asset.classification_status.value
model.classification_result = json.dumps(asset.metadata) if asset.metadata else None
model.quality_score = asset.quality_score
model.uploaded_by_user_id = asset.uploaded_by_user_id or model.uploaded_by_user_id
@@ -43,7 +43,6 @@ def _to_domain(model: GenerationTaskModel) -> GenerationTask:
output_height=getattr(model, "output_height", 720) or 720,
cover_url=getattr(model, "cover_url", "") or "",
title_config=dict(getattr(model, "title_config", {}) or {}),
extra_meta=dict(getattr(model, "extra_meta", {}) or {}),
logs=model.logs or "[]",
created_at=model.created_at,
updated_at=model.updated_at,
@@ -89,7 +88,6 @@ class SQLAlchemyGenerationTaskRepository:
output_height=task.output_height,
cover_url=task.cover_url or "",
title_config=dict(task.title_config) if task.title_config else {},
extra_meta=dict(task.extra_meta) if task.extra_meta else {},
logs=task.logs,
created_at=task.created_at,
updated_at=task.updated_at,
@@ -324,7 +322,6 @@ class SQLAlchemyGenerationTaskRepository:
model.output_height = task.output_height
model.cover_url = task.cover_url or ""
model.title_config = dict(task.title_config) if task.title_config else {}
model.extra_meta = dict(task.extra_meta) if task.extra_meta else {}
model.logs = task.logs
self.session.commit()
return task
@@ -52,18 +52,6 @@ class RenderedOutput:
def from_dict(cls, data: dict[str, Any]) -> "RenderedOutput":
if not isinstance(data, dict):
raise ValueError("rendered_output must be a dict")
# fingerprint_chunks 历史上有两种位置:
# 1) 顶层 ``fingerprint_chunks``(由 compute_render_fingerprint_and_dedup 直接返回)
# 2) 嵌套在 ``fingerprint_dict["chunks"]``(VideoFingerprint.to_dict() 序列化的结构)
# 顶层优先;顶层为空时回退到嵌套位置,兼容旧数据。
fp_dict = data.get("fingerprint_dict") or {}
chunks_raw = data.get("fingerprint_chunks")
if not chunks_raw and isinstance(fp_dict, dict):
chunks_raw = fp_dict.get("chunks")
# md5 同样可能在顶层或嵌套在 fingerprint_dict 内(历史数据兼容)
md5_value = data.get("video_fingerprint_md5")
if not md5_value and isinstance(fp_dict, dict):
md5_value = fp_dict.get("md5")
return cls(
file_url=str(data.get("file_url") or ""),
file_size=int(data.get("file_size") or 0),
@@ -77,14 +65,14 @@ class RenderedOutput:
batch_id=str(data.get("batch_id") or ""),
project_id=str(data.get("project_id") or ""),
user_id=str(data.get("user_id") or ""),
fingerprint_dict=fp_dict or None,
fingerprint_chunks=chunks_raw if isinstance(chunks_raw, list) else None,
fingerprint_dict=data.get("fingerprint_dict"),
fingerprint_chunks=data.get("fingerprint_chunks"),
is_duplicate=bool(data.get("is_duplicate", False)),
duplicate_of=data.get("duplicate_of"),
duplicate_rate=_safe_float(data.get("duplicate_rate")),
match_count=_safe_int(data.get("match_count")),
visual_similarity=_safe_float(data.get("visual_similarity")),
video_fingerprint_md5=str(md5_value or ""),
video_fingerprint_md5=str(data.get("video_fingerprint_md5") or ""),
)
+1 -6
View File
@@ -177,12 +177,7 @@ class SharedSettings(BaseSettings):
default="",
validation_alias=AliasChoices("GPU_ENCODE_RELAY_SECRET", "gpu_encode_relay_secret"),
)
# Mezzanine 传输方式:relay=走Tailscale/Docker内网relay PUT(推荐,省公网OSS往返18-20s);oss=走旧公网OSS路径
gpu_encode_mezzanine_transport: str = Field(
default="relay",
validation_alias=AliasChoices("GPU_ENCODE_MEZZANINE_TRANSPORT", "gpu_encode_mezzanine_transport"),
)
# GPU 中间片在 OSS 的临时前缀(mezzanine_transport=oss 时或 relay 失败 fallback 时使用)
# 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"),
+15 -45
View File
@@ -1,14 +1,14 @@
"""Celery 队列定义与路由配置(API / Worker 共享)。
#1714 + #2073 队列分流:用户同步等待的实时任务路由到 `generation` 高优队列,
由专用 generation worker 独占消费;素材入库/转码/AI 分析/查重等后台批量任务路由
到 `transcode` 队列;beat 定时清理等轻量维护任务走默认 `celery` 队列。
transcode / celery 队列积压时,generation 队列仍能被立即领取,不阻塞用户实时链路。
#1714 队列隔离:用户等待的视频生成任务路由到高优先级 `generation` 队列,
由专用 worker 进程独占消费;素材入库/转码等后台批量任务路由到 `transcode`
队列;其余杂项任务走默认 `celery` 队列。转码队列积压时,视频生成任务
仍能被 generation worker 立即领取执行,不会排队。
队列说明:
- generation: 用户同步等待的实时任务(视频生成、TTS、音色克隆、lipsync、AI 数字人、人声/背景提取)
- transcode: 后台批量/异步任务(素材入库转码、AI 分类打标、质量评分、原子切片、查重、批量下载/缩略图)
- celery: beat 定时巡检/清理等轻量维护任务(极短、低优、不占业务槽)
- generation: 用户提交的视频生成/预览渲染(延迟敏感,资源消耗大)
- transcode: 素材入库(HEVC 转码)、AI 分类、素材查重(批量、可排队)
- celery(默认): 配音、语音、下载缩略图、定时清理等杂项
"""
from __future__ import annotations
@@ -20,9 +20,8 @@ QUEUE_GENERATION = "generation"
QUEUE_TRANSCODE = "transcode"
QUEUE_DEFAULT = "celery"
# 三个消费组各自消费的队列列表(顺序即优先级:高优队列排在前面)
WORKER_QUEUES_GENERATION = (QUEUE_GENERATION,)
WORKER_QUEUES_TRANSCODE = (QUEUE_TRANSCODE, QUEUE_DEFAULT)
# Worker 消费的队列列表(顺序即优先级:高优队列排在前面)
WORKER_QUEUES = (QUEUE_GENERATION, QUEUE_TRANSCODE, QUEUE_DEFAULT)
# 队列声明:持久化队列,broker 重启不丢消息
task_queues = (
@@ -32,45 +31,15 @@ task_queues = (
)
# ── 任务路由表:task name → 队列 ──
# 键支持 celery 标准通配符。所有生产端(API send_task / worker 内 send_task)
# 未显式指定 queue 时按此表路由;漏配会走默认队列 celery,被 transcode worker 消费。
# 新增实时任务务必在此表显式路由到 generation,避免落到后台队列排队。
# 键支持 celery 标准通配符。
task_routes = {
# ── 高优先级:用户同步等待的实时链路 ──
# 视频生成(主链路)
# 高优先级:用户等待的视频生成
"worker.generate_video": {"queue": QUEUE_GENERATION},
# TTS 合成 / 片段合成(配音页、视频生成配乐/TTS 链路)
"worker.process_tts_synthesis": {"queue": QUEUE_GENERATION},
"worker.process_tts_segment_synthesis": {"queue": QUEUE_GENERATION},
# 音色克隆(用户主动上传样本等待克隆完成)
"worker.process_voice_clone": {"queue": QUEUE_GENERATION},
# 人声/背景提取(音色克隆前置步骤,用户同步等待)
"worker.extract_voice": {"queue": QUEUE_GENERATION},
"worker.extract_background": {"queue": QUEUE_GENERATION},
# AI 数字人渲染(用户主动触发,等待成片)
"ai_avatar_render.execute": {"queue": QUEUE_GENERATION},
# GPU MuseTalk 口型同步(用户等成片,链路子任务全部走 generation 避免跨队列阻塞)
"lipsync_gpu_process_async": {"queue": QUEUE_GENERATION},
"lipsync_tts.synthesize_and_submit": {"queue": QUEUE_GENERATION},
"lipsync_tts.poll_mediakit_status": {"queue": QUEUE_GENERATION},
"lipsync_tts.persist_output_video": {"queue": QUEUE_GENERATION},
# ── 后台批量:素材入库/转码 + AI 分析/打标 + 查重,积压不影响生成 ──
# 后台批量:素材入库/转码 + AI 分类 + 素材查重,积压不影响生成
"worker.ingest_asset": {"queue": QUEUE_TRANSCODE},
"worker.classify_asset": {"queue": QUEUE_TRANSCODE},
"worker.calculate_asset_quality": {"queue": QUEUE_TRANSCODE},
"worker.generate_atom_clips": {"queue": QUEUE_TRANSCODE},
"worker.tag_atom_clip": {"queue": QUEUE_TRANSCODE},
"worker.backfill_atom_clip_tags": {"queue": QUEUE_TRANSCODE},
"worker.process_duplication_check": {"queue": QUEUE_TRANSCODE},
"worker.check_duplicate": {"queue": QUEUE_TRANSCODE},
"worker.batch_download_videos": {"queue": QUEUE_TRANSCODE},
"worker.batch_generate_thumbnails": {"queue": QUEUE_TRANSCODE},
# ── beat 定时清理/巡检任务走默认 celery 队列(由 transcode worker 消费)──
# 未在此表显式列出的 cleanup 任务会落到默认队列 celery,不占 generation 槽位。
"worker.cleanup_stale_pending_tasks": {"queue": QUEUE_DEFAULT},
"worker.cleanup_stale_running_tasks": {"queue": QUEUE_DEFAULT},
"worker.cleanup_stale_ingest_jobs": {"queue": QUEUE_DEFAULT},
"worker.cleanup_stale_voice_clones": {"queue": QUEUE_DEFAULT},
}
# 生成任务的预取数:渲染是长任务,预取 1 避免任务被某个 worker 占住不调度
@@ -78,10 +47,11 @@ GENERATION_WORKER_PREFETCH_MULTIPLIER = 1
def apply_queue_settings(app) -> None:
"""把队列分流配置应用到 Celery app(API 生产端与 Worker 消费端都要调用)。
"""把队列隔离配置应用到 Celery app(API 生产端与 Worker 消费端都要调用)。
配置 task_queues / task_routes / task_default_queue。生产端靠 task_routes
把消息投递到对应队列;消费端靠启动参数 -Q 控制自己消费哪些队列(entrypoint)。
把消息投递到对应队列;消费端靠 task_queues 声明自己消费哪些队列
(实际消费集由启动参数 -Q 控制)。
"""
app.conf.task_queues = task_queues
app.conf.task_routes = task_routes
+3 -3
View File
@@ -25,9 +25,9 @@ FFPROBE_BIN: str = shutil.which("ffprobe") or "ffprobe"
DEFAULT_FFMPEG_TIMEOUT = 1800
# ── 编码参数(集中配置,支持环境变量覆盖)────────────────────────────────────
# preset 默认 veryfast,相比 fast 再提速 ~40%(4 核 Xeon 60s 720p: 32s→20s),CRF=23 画质可接受
# 可通过环境变量 FFMPEG_ENCODE_PRESET 覆盖(如 fast/medium 追求质量,ultrafast 追求极致速度)
FFMPEG_ENCODE_PRESET: str = os.environ.get("FFMPEG_ENCODE_PRESET", "veryfast")
# preset 从 medium → fast,渲染速度提升 30%+,画质几乎无损(CRF 相同时 PSNR 差异 <0.1dB)
# 可通过环境变量 FFMPEG_ENCODE_PRESET 覆盖(如 ultrafast 追求极致速度,veryslow 追求极致压缩)
FFMPEG_ENCODE_PRESET: str = os.environ.get("FFMPEG_ENCODE_PRESET", "fast")
# CRF 保持 23(libx264 默认质量),可通过 FFMPEG_ENCODE_CRF 覆盖
FFMPEG_ENCODE_CRF: str = os.environ.get("FFMPEG_ENCODE_CRF", "23")
# 编码线程数:0 = 自动检测 CPU 核心数,充分利用多核
+49 -218
View File
@@ -2,26 +2,17 @@
完整链路(encode_video_file):
1. CPU 滤镜已在本地生成 mezzanine 中间片(libx264 ultrafast)
2. 通过 HTTP PUT 把 mezzanine 上传到 relay(走 Tailscale/Docker 内网,~1s 完成)
- 失败则 fallback 到 OSS 上传(旧路径,兼容没有 :8092 内网可达的环境)
2. 上传 mezzanine 到 OSS 临时前缀,拿到签名 GET URL
3. 生成 relay 一次性 key,构造两个带 token 的 URL:
- put_url:给 P4000 回传结果,走 relay_base_url(Tailscale host:8092)
- put_url:给 P4000 回传结果,走 relay_base_url(外部可达,通常是 host:port 经 nginx)
- get/del_url:worker 自己下载+清理用,走 relay_internal_base_url(Docker DNS 直连 API)
4. 【冷启动防护】距上次成功通信 >60s 时,先 GET /health 预热 Tailscale 链路(短超时快速失败)
5. POST P4000 /api/render/sync:inputs={"in.mp4": "<mezzanine-get-url>"}, output_url="<put_url>"
4. POST P4000 /api/render/sync:inputs={"in.mp4": "<oss-signed-url>"}, output_url="<put_url>"
ffmpeg_args: -i in.mp4 [-vf <vf>] -c:v h264_nvenc ... -an/-c:a aac -f mp4 pipe:1
- 首字节用短超时(默认20s),避免链路卡死空等上百秒;首字节到达后放宽到 ffmpeg_timeout+60s
6. P4000 从 relay GET mezzanine → h264_nvenc 编码 → PUT 最终 mp4 到 put_url
7. 本客户端通过 get_url(Docker 内网)下载最终文件到 output_path,然后 DELETE 清理
8. 删除 relay 上的 mezzanine 临时文件(以及 OSS fallback 的 key)
5. P4000 编码完成后 PUT 最终 mp4 到 put_url,API 服务落盘到 /app/generated/gpu_relay/<key>
6. 本客户端通过 get_url(Docker 内网)下载最终文件到 output_path,然后 DELETE 清理
7. 删除 OSS 临时 mezzanine
任何环节失败抛 GpuEncodeError,调用方应 fallback 到 CPU libx264。
冷启动/链路卡顿背景(2026-09-27 实测):P4000 与 staging 之间走 Tailscale,长时间空闲
(>7h)后首次请求曾出现 150s 延迟才真正开始下载 mezzanine,期间 ffmpeg 尚未启动、GPU 空闲。
根因在服务端/网络层(可能是 Tailscale DERP 打洞或 httpx 连接池重建),本客户端通过
pre_warm + 首字节短超时做兜底:预热打通链路 + 20s 内收不到首字节就快速失败让 CPU fallback,
不再让用户等满 150s+。
"""
from __future__ import annotations
@@ -67,17 +58,8 @@ class GpuEncoderClient:
relay_base_url: str,
*,
relay_internal_base_url: str = "",
# Mezzanine 上传:默认走 relay(Tailscale/Docker 内网);设为 "oss" 强制走旧 OSS 路径
mezzanine_transport: str = "relay",
sync_timeout: int = 300,
health_timeout: float = 3.0,
# 提交编码任务前先发一次 /health 预热 Tailscale 链路,避免长时间空闲后首次请求
# 因 DERP 打洞/NAT 映射过期/Tailscale 连接重建而阻塞上百秒。
pre_warm: bool = True,
# POST 首次响应超时:P4000 已收到请求后应该在数秒内开始下载 inputs;
# 如果超过这个值还没收到任何响应字节,说明链路/服务卡住,快速失败让调用方 fallback CPU。
# 注意:ffmpeg 编码本身靠 body.timeout 控制(300s),不应该被这个超时影响。
post_first_byte_timeout: float = 20.0,
vcodec: str = "h264_nvenc",
preset: str = "p4",
crf: int = 23,
@@ -92,49 +74,30 @@ class GpuEncoderClient:
self.relay_internal_base_url = (
relay_internal_base_url.rstrip("/") if relay_internal_base_url else self.relay_base_url
)
self.mezzanine_transport = mezzanine_transport.lower() # "relay" | "oss"
self.sync_timeout = sync_timeout
self.health_timeout = health_timeout
self.pre_warm = pre_warm
self.post_first_byte_timeout = post_first_byte_timeout
self.vcodec = vcodec
self.preset = preset
self.crf = crf
self.bitrate = bitrate
self._relay_secret = relay_secret
self.oss_tmp_prefix = oss_tmp_prefix.rstrip("/") + "/" if oss_tmp_prefix else "tmp/gpu-mezzanine/"
# 上次与 P4000 成功通信的时间戳(用于判断是否需要 pre_warm 预热)
self._last_ok_ts: float = 0.0
RELAY_PATH_PREFIX = "/api/v1/internal/gpu-relay"
# ------------------------------------------------------------------
# URL builders
# ------------------------------------------------------------------
def _relay_url_from_base(self, base_url: str, path: str, key: str, secret: str) -> str:
return f"{base_url}{self.RELAY_PATH_PREFIX}{path}/{key}?token={urllib.parse.quote(secret, safe='')}"
def _relay_url_from_base(self, base_url: str, key: str, secret: str) -> str:
return f"{base_url}{self.RELAY_PATH_PREFIX}/{key}?token={urllib.parse.quote(secret, safe='')}"
def _relay_result_url(self, base_url: str, key: str, secret: str) -> str:
return self._relay_url_from_base(base_url, "", key, secret)
def _relay_put_url(self, key: str, secret: str) -> str:
"""给 P4000 回传结果用的 URL(外部可达)。"""
return self._relay_url_from_base(self.relay_base_url, key, secret)
def _relay_mezz_url(self, base_url: str, key: str, secret: str) -> str:
return self._relay_url_from_base(base_url, "/mezzanine", key, secret)
def _result_put_url(self, key: str, secret: str) -> str:
"""P4000 回传编码结果 PUT URL(外部/Tailscale 可达)。"""
return self._relay_result_url(self.relay_base_url, key, secret)
def _result_get_url(self, key: str, secret: str) -> str:
"""Worker 下载最终结果 GET URL(Docker 内网)。"""
return self._relay_result_url(self.relay_internal_base_url, key, secret)
def _mezz_put_url(self, key: str, secret: str) -> str:
"""Worker 上传 mezzanine PUT URL(Docker 内网,快)。"""
return self._relay_mezz_url(self.relay_internal_base_url, key, secret)
def _mezz_get_url_for_p4000(self, key: str, secret: str) -> str:
"""P4000 下载 mezzanine GET URL(必须是 P4000 可达地址,Tailscale host:8092)。"""
return self._relay_mezz_url(self.relay_base_url, key, secret)
def _relay_internal_url(self, key: str, secret: str) -> str:
"""Worker 自己 GET/DELETE 用的 URL(Docker 内网)。"""
return self._relay_url_from_base(self.relay_internal_base_url, key, secret)
# ------------------------------------------------------------------
# Health
@@ -147,16 +110,13 @@ class GpuEncoderClient:
except (urllib.error.URLError, socket.timeout, TimeoutError, json.JSONDecodeError, ConnectionError) as e:
return GpuHealth(healthy=False, error=f"health probe failed: {e}")
try:
h = GpuHealth(
return GpuHealth(
healthy=data.get("status") == "healthy",
worker=str(data.get("worker", "")),
gpu_name=(data.get("gpu") or {}).get("name", ""),
nvenc_h264=bool((data.get("nvenc") or {}).get("h264_nvenc")),
nvenc_hevc=bool((data.get("nvenc") or {}).get("hevc_nvenc")),
)
if h.healthy:
self._last_ok_ts = time.time()
return h
except Exception as e: # noqa: BLE001
return GpuHealth(healthy=False, error=f"malformed health response: {e}")
@@ -174,8 +134,8 @@ class GpuEncoderClient:
) -> dict[str, Any]:
"""把 mezzanine(CPU 滤镜已完成)交给 P4000 NVENC 编码,结果写到 output_path。
传输:默认通过 relay PUT/GET(Tailscale 内网,省去公网 OSS 往返 18-20s);
若 relay PUT 失败且配置可用,自动 fallback 到 OSS。
extra_video_args: -i 之后、-c:v 之前插入的 ffmpeg 参数(如分辨率/帧率调整)。
audio_args: 音频编码参数(如 ["-c:a","aac","-b:a","128k"]);None 表示 -an 无音频。
"""
if not mezzanine_path.exists():
raise GpuEncodeError(f"mezzanine file not found: {mezzanine_path}")
@@ -185,53 +145,19 @@ class GpuEncoderClient:
timeout = timeout or self.sync_timeout
t_total = time.time()
oss_key: Optional[str] = None
mezz_key: Optional[str] = None
result_key: Optional[str] = None
input_url: str = ""
used_transport = self.mezzanine_transport
relay_key: Optional[str] = None
try:
# 1. upload mezzanine → OSS
input_url, oss_key = self._upload_mezzanine(mezzanine_path)
logger.debug("[gpu-encoder] mezzanine uploaded: oss_key=%s", oss_key)
# 2. prepare relay URLs (PUT 走外部 URL 给 P4000;GET/DELETE 走内部 Docker 网络)
relay_key = uuid.uuid4().hex
secret = self._get_relay_secret()
# 1. 上传 mezzanine 到 relay(或 OSS fallback)
mezz_size = mezzanine_path.stat().st_size
if used_transport == "relay":
mezz_key = uuid.uuid4().hex
mezz_put = self._mezz_put_url(mezz_key, secret)
mezz_get_for_p4000 = self._mezz_get_url_for_p4000(mezz_key, secret)
t_up = time.time()
try:
self._upload_file_put(mezz_put, mezzanine_path, "video/mp4")
input_url = mezz_get_for_p4000
logger.info(
"[gpu-encoder] mezzanine uploaded to relay: key=%s size=%d took=%.2fs",
mezz_key,
mezz_size,
time.time() - t_up,
)
except (GpuEncodeError, OSError, urllib.error.URLError) as e:
logger.warning(
"[gpu-encoder] relay mezz upload failed (%s), fallback to OSS",
e,
)
used_transport = "oss"
# relay 上传失败的部分文件 best-effort 清理
if mezz_key:
try:
self._relay_delete(self._relay_mezz_url(self.relay_internal_base_url, mezz_key, secret))
except Exception: # noqa: BLE001
pass
mezz_key = None
if used_transport == "oss" or not input_url:
input_url, oss_key = self._upload_mezzanine_to_oss(mezzanine_path)
logger.debug("[gpu-encoder] mezzanine uploaded to OSS: oss_key=%s", oss_key)
# 2. prepare result relay URLs
result_key = uuid.uuid4().hex
put_url = self._result_put_url(result_key, secret)
get_url = self._result_get_url(result_key, secret)
del_result_url = get_url
put_url = self._relay_put_url(relay_key, secret)
get_url = self._relay_internal_url(relay_key, secret)
del_url = get_url # 内部 URL,DELETE method
# 3. build ffmpeg args
ffmpeg_args = ["-y", "-i", "in.mp4"]
@@ -249,65 +175,50 @@ class GpuEncoderClient:
ffmpeg_args.append("-an")
ffmpeg_args.extend(["-f", "mp4", "pipe:1"])
# 4. pre-warm then call P4000 sync render
self._warm_up_if_needed()
# 4. call P4000 sync render
body = {
"inputs": {"in.mp4": input_url},
"ffmpeg_args": ffmpeg_args,
"output_url": put_url,
"timeout": int(timeout),
}
job = self._post_sync(body)
self._last_ok_ts = time.time()
job = self._post_sync(body, mezzanine_path=mezzanine_path)
logger.info(
"[gpu-encoder] P4000 done: job_id=%s rc=%s size=%s dur=%ss transport=%s",
"[gpu-encoder] P4000 done: job_id=%s rc=%s size=%s dur=%ss",
job.get("job_id"),
job.get("ffmpeg_rc"),
job.get("size"),
job.get("duration"),
used_transport,
)
# 5. download result from relay to output_path
output_path.parent.mkdir(parents=True, exist_ok=True)
size = self._download_to_file(get_url, output_path)
# 6. cleanup relay result
self._relay_delete(del_result_url)
# 6. cleanup relay
self._relay_delete(del_url)
logger.info(
"[gpu-encoder] encode ok: %s → %s (%d bytes) total=%.2fs transport=%s",
"[gpu-encoder] encode ok: %s → %s (%d bytes) total=%.2fs",
mezzanine_path.name,
output_path.name,
size,
time.time() - t_total,
used_transport,
)
return {
"job": job,
"output_size": size,
"output_path": str(output_path),
"transport": used_transport,
}
return {"job": job, "output_size": size, "output_path": str(output_path)}
except GpuEncodeError:
raise
except Exception as e: # noqa: BLE001
raise GpuEncodeError(f"unexpected: {e}") from e
finally:
# cleanup relay mezzanine (best-effort)
if mezz_key:
try:
secret = self._get_relay_secret()
self._relay_delete(self._relay_mezz_url(self.relay_internal_base_url, mezz_key, secret))
except Exception as e: # noqa: BLE001
logger.warning("[gpu-encoder] failed to delete relay mezzanine %s: %s", mezz_key, e)
# cleanup OSS mezzanine (best-effort)
if oss_key:
try:
self._delete_oss(oss_key)
except Exception as e: # noqa: BLE001
logger.warning("[gpu-encoder] failed to delete OSS mezzanine %s: %s", oss_key, e)
# relay cleanup also best-effort (done above after download)
# ------------------------------------------------------------------
# Internal helpers
@@ -315,46 +226,19 @@ class GpuEncoderClient:
def _get_relay_secret(self) -> str:
if self._relay_secret:
return self._relay_secret
# read from env (same var API server uses)
env = (os.getenv("APP_ENV", os.getenv("ENV", "development"))).lower()
secret = (os.getenv("GPU_ENCODE_RELAY_SECRET", "") or "").strip()
if not secret:
if env in ("production", "prod"):
raise GpuEncodeError("GPU_ENCODE_RELAY_SECRET must be set in production")
# dev: fail - worker should always have a secret explicitly set (or same ephemeral won't match)
raise GpuEncodeError("GPU_ENCODE_RELAY_SECRET not set")
return secret
def _warm_up_if_needed(self) -> None:
"""POST 前预热:如果距上次成功通信超过 idle 阈值,先打 /health 打通 Tailscale 链路。
背景:Tailscale 在长时间空闲(几小时)后,到对端的直连 NAT 映射可能过期,
首次请求会走 DERP 中继打洞;极少数情况下打洞/重连会卡住上百秒(曾观测到 150s 延迟)。
预热请求本身走短超时快速失败,不会阻塞主流程;预热成功后再发 POST。
"""
if not self.pre_warm:
return
idle = time.time() - self._last_ok_ts
# 空闲超过 60s 才预热(正常流水线里相邻任务间隔通常 <10s,没必要每次都打)
if idle < 60:
return
url = f"{self.endpoint}/health"
t0 = time.time()
try:
with urllib.request.urlopen(url, timeout=min(self.health_timeout, 3.0)) as resp:
resp.read()
self._last_ok_ts = time.time()
logger.debug("[gpu-encoder] pre-warm ok: took=%.2fs idle=%.0fs", time.time() - t0, idle)
except (urllib.error.URLError, socket.timeout, TimeoutError, ConnectionError, OSError) as e:
# 预热失败不致命——主 POST 会带自己的超时,再失败就抛 GpuEncodeError 让调用方 fallback
logger.warning("[gpu-encoder] pre-warm probe failed (will try POST anyway): %s", e)
def _post_sync(self, body: dict[str, Any]) -> dict[str, Any]:
def _post_sync(self, body: dict[str, Any], *, mezzanine_path: Path) -> dict[str, Any]:
url = f"{self.endpoint}/api/render/sync"
ffmpeg_timeout = body.get("timeout", self.sync_timeout)
# 连接 + 首字节用短超时(防链路卡死数百秒);首字节到达后给 ffmpeg 留足编码+上传时间
# Python urllib 的 timeout 是整个请求总超时,所以用"两段式":
# 阶段1:先 read(1) 拿首字节,用短超时;
# 阶段2:再 read() 读完整 body,用 ffmpeg_timeout+60。
connect_timeout = min(max(self.post_first_byte_timeout, 5.0), 30.0)
req_timeout = body.get("timeout", self.sync_timeout) + 60
payload = json.dumps(body).encode("utf-8")
req = urllib.request.Request(
url,
@@ -363,45 +247,14 @@ class GpuEncoderClient:
method="POST",
)
t0 = time.time()
first_byte_ok = False
resp = None
try:
resp = urllib.request.urlopen(req, timeout=connect_timeout)
# 读首字节 —— 如果 P4000/链路卡死,这里会在 connect_timeout 内抛超时
first_chunk = resp.read(1)
first_byte_ok = True
logger.debug(
"[gpu-encoder] P4000 first byte in %.2fs (connect_timeout=%.1fs)",
time.time() - t0,
connect_timeout,
)
# 剩余用长超时(给底层socket放宽时限;如果是mock/不支持,则跳过)
try:
resp.fp._sock.settimeout(ffmpeg_timeout + 60)
except (AttributeError, OSError):
pass
rest = resp.read()
raw = (first_chunk + rest).decode("utf-8")
resp.close()
resp = None
with urllib.request.urlopen(req, timeout=req_timeout) as resp:
raw = resp.read().decode("utf-8")
except urllib.error.HTTPError as e:
detail = e.read().decode("utf-8", errors="replace")[:1000]
raise GpuEncodeError(f"P4000 HTTP {e.code}: {detail}") from e
except (urllib.error.URLError, socket.timeout, TimeoutError, ConnectionError, OSError) as e:
waited = time.time() - t0
hint = "first-byte" if not first_byte_ok else "ffmpeg/upload"
# 统一以 "connection error" 开头,便于上层 fallback 逻辑用关键词识别;
# 末尾再附带具体错误(timed out / refused ...)供排障
raise GpuEncodeError(
f"P4000 {hint} connection error after {waited:.1f}s "
f"(connect_timeout={connect_timeout:.0f}s, ffmpeg_timeout={ffmpeg_timeout}s): {e}"
) from e
finally:
if resp is not None:
try:
resp.close()
except Exception:
pass
except (urllib.error.URLError, socket.timeout, TimeoutError, ConnectionError) as e:
raise GpuEncodeError(f"P4000 connection error: {e}") from e
try:
result = json.loads(raw)
except json.JSONDecodeError as e:
@@ -414,6 +267,8 @@ class GpuEncoderClient:
if status != "completed" or ffmpeg_rc != 0:
err = result.get("message") or result.get("error") or "unknown"
raise GpuEncodeError(f"P4000 job failed: status={status} rc={ffmpeg_rc} err={err!s:.500}")
# P4000 has a known bug where uploaded=true even on PUT SSL failure;
# we will verify by downloading, so don't hard-fail here but log
if not uploaded:
logger.warning("[gpu-encoder] P4000 reports uploaded=false (will verify via download)")
result["_roundtrip"] = dt
@@ -454,33 +309,10 @@ class GpuEncoderClient:
except Exception as e: # noqa: BLE001
logger.debug("[gpu-encoder] relay cleanup delete failed: %s", e)
def _upload_file_put(self, url: str, path: Path, content_type: str) -> None:
"""HTTP PUT 流式上传文件到指定 URL(mezzanine 上传到 relay 用,Tailscale/Docker 内网)。"""
file_size = path.stat().st_size
# 使用生成器/文件对象流式上传,避免一次性载入大 mezzanine 文件到内存
with open(path, "rb") as f:
req = urllib.request.Request(
url,
data=f, # 文件对象支持read(),urllib会流式发送(但需要Content-Length)
method="PUT",
headers={
"Content-Type": content_type,
"Content-Length": str(file_size),
},
)
# 超时:按 ~20MB/s 内网速度估算 + 30s 保底
put_timeout = max(60, int(file_size / (20 * 1024 * 1024)) + 30)
with urllib.request.urlopen(req, timeout=put_timeout) as resp:
if resp.status not in (200, 201, 204):
body = resp.read().decode("utf-8", errors="replace")[:500]
raise GpuEncodeError(f"relay PUT failed: HTTP {resp.status} {body}")
resp.read()
# ------------------------------------------------------------------
# OSS helpers (fallback)
# OSS helpers (optional - storage may not be available in all envs)
# ------------------------------------------------------------------
def _upload_mezzanine_to_oss(self, path: Path) -> tuple[str, str]:
def _upload_mezzanine(self, path: Path) -> tuple[str, str]:
"""Upload mezzanine to OSS tmp prefix, return (signed_get_url, oss_key)."""
try:
from packages.shared.storage import get_storage_service
@@ -494,6 +326,7 @@ class GpuEncoderClient:
storage.upload_file(str(path), key, content_type="video/mp4")
except Exception as e: # noqa: BLE001
raise GpuEncodeError(f"failed to upload mezzanine to OSS: {e}") from e
# Generate signed GET URL (1h expiry)
signed = storage.get_download_url(key, expires_seconds=3600)
return signed, key
@@ -532,11 +365,8 @@ def _build_client_from_settings() -> Optional[GpuEncoderClient]:
endpoint=endpoint,
relay_base_url=relay,
relay_internal_base_url=relay_internal,
mezzanine_transport=getattr(settings, "gpu_encode_mezzanine_transport", "relay") or "relay",
sync_timeout=getattr(settings, "gpu_encode_sync_timeout", 300),
health_timeout=getattr(settings, "gpu_encode_health_timeout", 3.0),
pre_warm=getattr(settings, "gpu_encode_pre_warm", True),
post_first_byte_timeout=getattr(settings, "gpu_encode_post_first_byte_timeout", 20.0),
vcodec=getattr(settings, "gpu_encode_vcodec", "h264_nvenc"),
preset=getattr(settings, "gpu_encode_preset", "p4"),
crf=getattr(settings, "gpu_encode_crf", 23),
@@ -565,5 +395,6 @@ def reset_gpu_encoder_for_tests() -> None:
_default_client_initialized = False
# Convenience
def is_gpu_encode_enabled() -> bool:
return get_gpu_encoder() is not None
+115 -138
View File
@@ -1,7 +1,6 @@
#!/bin/sh
# ===========================================
# Staging 部署脚本(SSH 模式,并行优化版)
# worker 已收敛到 infra/docker/compose.yml 单一事实来源;api/web 暂保留 docker run。
# ===========================================
set -eu
@@ -49,11 +48,6 @@ GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-staging/generated}"
LEGACY_ASSETS_DIR="${LEGACY_ASSETS_DIR:-/var/lib/xiaoxia-saas-staging/legacy-assets}"
NGINX_CONF_FILE="${NGINX_CONF_FILE:-/var/lib/xiaoxia-saas-staging/nginx-staging.conf}"
COOKIES_FILE="${COOKIES_FILE:-/var/lib/xiaoxia-saas-staging/configs/douyin_cookies.txt}"
INFRA_DOCKER_DIR="${INFRA_DOCKER_DIR:-/var/lib/xiaoxia-saas-staging/infra/docker}"
COMPOSE_PROJECT="${COMPOSE_PROJECT:-xiaoxia-staging}"
COMPOSE_ENV_VALUE="${COMPOSE_ENV_VALUE:-staging}"
# COMPOSE_SYNC: CI workflow 已通过 scp 把 infra/docker/compose.yml 上传到服务器时设为 0 跳过同步
COMPOSE_SYNC="${COMPOSE_SYNC:-1}"
SKIP_MIGRATION="${SKIP_MIGRATION:-false}"
SKIP_ROLLBACK="${SKIP_ROLLBACK:-false}"
@@ -63,6 +57,8 @@ if [ -z "$IMAGE_TAG" ]; then
exit 1
fi
# .env 文件由 CI 从模板 + Secrets 渲染后通过 SCP 上传到服务器
# 如果文件不存在,说明 CI 渲染步骤失败或未执行
if [ ! -f "$ENV_FILE" ]; then
echo "ERROR: $ENV_FILE 不存在。CI 应先在 render_env 步骤渲染并上传此文件"
exit 1
@@ -71,7 +67,7 @@ echo "✅ .env file found: $ENV_FILE ($(wc -l < "$ENV_FILE") lines)"
mkdir -p "$GENERATED_DIR"
mkdir -p "$LEGACY_ASSETS_DIR"
mkdir -p "$(dirname "$COOKIES_FILE")"
mkdir -p "$INFRA_DOCKER_DIR"
# 抖音 cookies 文件:CI workflow 已通过 scp 上传;如果不存在(非 CI 环境)则创建占位
if [ ! -f "$COOKIES_FILE" ] || [ "$(wc -c < "$COOKIES_FILE" 2>/dev/null || echo 0)" -lt 200 ]; then
printf '# Netscape HTTP Cookie File\n# 抖音 cookies 占位(CI 应通过 scp 上传真实 cookies)\n' > "$COOKIES_FILE"
echo "WARNING: Douyin cookies not found or too small at $COOKIES_FILE (extraction will 503)"
@@ -80,6 +76,7 @@ else
fi
# ── 写入 Staging Nginx 配置 ──
# 运行时覆盖 nginx 配置,确保 upstream 指向正确的 staging 网络
echo "Writing staging nginx config..."
cat > "$NGINX_CONF_FILE" << 'NGINX_EOF'
server {
@@ -125,26 +122,8 @@ server {
NGINX_EOF
echo "✅ Nginx config written: $NGINX_CONF_FILE"
# ── 确认 infra/docker/compose.yml 存在 ──
# CI workflow 在执行本脚本前已通过 scp 把 infra/docker/compose.yml 上传到 $INFRA_DOCKER_DIR
# (workflow 里做:scp infra/docker/compose.yml <host>:$INFRA_DOCKER_DIR/compose.yml)。
# 这里只做存在性检查 + nginx 软链;不再 curl 私有仓库(SSH 环境无 Gitea token)。
COMPOSE_FILE_PATH="$INFRA_DOCKER_DIR/compose.yml"
if [ ! -f "$COMPOSE_FILE_PATH" ]; then
echo "ERROR: $COMPOSE_FILE_PATH 不存在。CI workflow 应先 scp infra/docker/compose.yml 到服务器"
exit 1
fi
echo "✅ compose.yml ready: $COMPOSE_FILE_PATH ($(wc -l < "$COMPOSE_FILE_PATH") lines)"
ln -sf "$NGINX_CONF_FILE" "$INFRA_DOCKER_DIR/nginx-${COMPOSE_ENV_VALUE}.conf" 2>/dev/null || true
# 封装 docker compose 调用:统一 --env-file(compose 默认只读取 project 目录下的 .env,
# 我们的 .env 在 $INFRA_DOCKER_DIR/../../.env,必须显式传入才能读到 GENERATED_FILES_HOST_DIR 等变量)
compose() {
(cd "$INFRA_DOCKER_DIR" && docker compose --env-file "$ENV_FILE" -p "$COMPOSE_PROJECT" "$@")
}
echo "==========================================="
echo " Staging 部署 - $IMAGE_TAG"
echo " Staging 部署 - $IMAGE_TAG (并行优化版)"
echo "==========================================="
echo "Recording current image versions for rollback..."
@@ -165,7 +144,6 @@ for c in xiaoxia-api-staging xiaoxia-worker-staging xiaoxia-web-staging; do
fi
done
# ── 回滚函数 ──
rollback() {
echo ""
echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
@@ -179,37 +157,9 @@ rollback() {
fi
echo "Stopping new containers..."
docker rm -f xiaoxia-api-staging xiaoxia-web-staging 2>/dev/null || true
if [ -n "$PREV_WORKER_IMAGE" ]; then
echo "Rolling back Worker to: $PREV_WORKER_IMAGE (via compose)"
compose up -d --no-deps worker 2>&1 || echo "WARN: compose rollback failed, fallback to docker run"
# 镜像通过 env 注入:compose 默认读 WORKER_IMAGE(未设则用 :dev),这里用临时 env 覆盖
if ! docker inspect xiaoxia-worker-staging >/dev/null 2>&1; then
echo "Fallback: docker run previous worker image"
docker run -d \
--name xiaoxia-worker-staging \
--env-file "$ENV_FILE" \
--network "xiaoxia-net-${COMPOSE_ENV_VALUE}" \
-e APP_ENV="$COMPOSE_ENV_VALUE" \
-e APP_VERSION="$(echo "$PREV_WORKER_IMAGE" | grep -oE '[^:]+$')" \
-e WORKER_MAX_TASKS_PER_CHILD=100 \
-e GENERATION_CONCURRENCY="${GENERATION_CONCURRENCY:-2}" \
-e TRANSCODE_CONCURRENCY="${TRANSCODE_CONCURRENCY:-2}" \
-e BEAT_ENABLED=1 \
-e GENERATED_FILES_DIR=/app/generated \
-e GENERATED_FILES_URL_PREFIX=/generated-files \
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
-v "$GENERATED_DIR:/app/generated" \
--restart unless-stopped \
--health-cmd "grep -q 'celery.*worker' /proc/[0-9]*/cmdline 2>/dev/null || exit 1" \
--health-interval 30s \
--health-timeout 10s \
--health-retries 3 \
--health-start-period 40s \
--log-driver json-file --log-opt max-size=50m --log-opt max-file=3 \
"$PREV_WORKER_IMAGE" || true
fi
fi
docker rm -f xiaoxia-api-staging 2>/dev/null || true
docker rm -f xiaoxia-worker-staging 2>/dev/null || true
docker rm -f xiaoxia-web-staging 2>/dev/null || true
LOG_OPTS="--log-driver json-file --log-opt max-size=50m --log-opt max-file=3"
@@ -218,10 +168,10 @@ rollback() {
docker run -d \
--name xiaoxia-api-staging \
--env-file "$ENV_FILE" \
--network "xiaoxia-net-${COMPOSE_ENV_VALUE}" \
--network xiaoxia-net-staging \
-p 127.0.0.1:8000:8000 \
-e APP_ENV="$COMPOSE_ENV_VALUE" \
-e APP_VERSION="$(echo "$PREV_API_IMAGE" | grep -oE '[^:]+$')" \
-e APP_ENV=staging \
-e APP_VERSION="$(echo $PREV_API_IMAGE | grep -oE '[^:]+$')" \
-e GENERATED_FILES_DIR=/app/generated \
-e GENERATED_FILES_URL_PREFIX=/generated-files \
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
@@ -233,7 +183,31 @@ rollback() {
--health-retries 3 \
--health-start-period 40s \
$LOG_OPTS \
"$PREV_API_IMAGE" || true
"$PREV_API_IMAGE" &
fi
if [ -n "$PREV_WORKER_IMAGE" ]; then
echo "Rolling back Worker to: $PREV_WORKER_IMAGE"
docker run -d \
--name xiaoxia-worker-staging \
--env-file "$ENV_FILE" \
--network xiaoxia-net-staging \
-e APP_ENV=staging \
-e APP_VERSION="$(echo $PREV_WORKER_IMAGE | grep -oE '[^:]+$')" \
-e WORKER_CONCURRENCY=1 \
-e WORKER_MAX_TASKS_PER_CHILD=100 \
-e GENERATED_FILES_DIR=/app/generated \
-e GENERATED_FILES_URL_PREFIX=/generated-files \
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
-v "$GENERATED_DIR:/app/generated" \
--restart unless-stopped \
--health-cmd "grep -lq celery /proc/[0-9]*/cmdline 2>/dev/null || exit 1" \
--health-interval 30s \
--health-timeout 10s \
--health-retries 3 \
--health-start-period 30s \
$LOG_OPTS \
"$PREV_WORKER_IMAGE" &
fi
if [ -n "$PREV_WEB_IMAGE" ]; then
@@ -244,7 +218,7 @@ rollback() {
fi
docker run -d \
--name xiaoxia-web-staging \
--network "xiaoxia-net-${COMPOSE_ENV_VALUE}" \
--network xiaoxia-net-staging \
-p 127.0.0.1:3001:80 \
--restart unless-stopped \
$LEGACY_VOLUME \
@@ -254,25 +228,27 @@ rollback() {
--health-timeout 5s \
--health-retries 3 \
$LOG_OPTS \
"$PREV_WEB_IMAGE" || true
"$PREV_WEB_IMAGE" &
fi
sleep 3
wait
echo "Waiting for rolled-back API to become healthy..."
i=0
while [ "$i" -lt 40 ]; do
if curl -sf --max-time 5 http://127.0.0.1:8000/health >/dev/null 2>&1; then
echo "Rolled-back API is healthy!"
break
if [ -n "$PREV_API_IMAGE" ]; then
echo "Waiting for rolled-back API to become healthy..."
i=0
while [ "$i" -lt 40 ]; do
if curl -sf --max-time 5 http://127.0.0.1:8000/health >/dev/null 2>&1; then
echo "Rolled-back API is healthy!"
break
fi
i=$((i + 1))
echo " Waiting... ($i/40)"
sleep 3
done
if [ "$i" -ge 40 ]; then
echo "WARN: Rolled-back API did not become healthy within 120s"
docker logs --tail 30 xiaoxia-api-staging
fi
i=$((i + 1))
echo " Waiting... ($i/40)"
sleep 3
done
if [ "$i" -ge 40 ]; then
echo "WARN: Rolled-back API did not become healthy within 120s"
docker logs --tail 30 xiaoxia-api-staging 2>/dev/null || true
fi
echo ""
@@ -284,7 +260,7 @@ rollback() {
echo "Previous Web: ${PREV_WEB_IMAGE:-none}"
echo ""
echo "部署失败,已自动回滚到上一版本"
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep staging || true
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep staging
exit 1
}
@@ -296,6 +272,10 @@ if [ -n "$REGISTRY_TOKEN" ]; then
retry_docker_login
fi
# ---- 并行 Pull 三个镜像 ----
# 注意:这里必须使用 IMAGE_TAG(commit SHA)做确定性部署,不要改成 :dev。
# :dev 是 floating tag,可能被并发构建覆盖,导致部署版本不可重现、回滚混乱。
# Watchtower 可监听 :dev 做非关键路径的自动同步;正式部署/回滚一律锚定 SHA。
REGISTRY_API="${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}"
REGISTRY_WORKER="${REGISTRY}/xiaoxia-saas-worker:${IMAGE_TAG}"
REGISTRY_WEB="${REGISTRY}/xiaoxia-saas-web:${IMAGE_TAG}"
@@ -324,6 +304,7 @@ for svc in api worker web; do
elif grep -qE "Digest:|Status: Downloaded" "$PULL_LOG_DIR/$svc.log" 2>/dev/null; then
echo " OK $svc"
else
# 检查docker pull返回值不直接,用镜像是否存在来判断
img_var="REGISTRY_$(echo $svc | tr '[:lower:]' '[:upper:]')"
img_val=$(eval echo "\$$img_var")
if docker image inspect "$img_val" >/dev/null 2>&1; then
@@ -346,7 +327,7 @@ fi
echo "All images pulled."
# ====== 镜像内容校验 ======
# ====== 镜像内容校验(CI 加固 - 防止静默部署损坏/过期镜像) ======
echo ""
echo "=========================================="
echo " 镜像内容校验"
@@ -355,6 +336,7 @@ echo "=========================================="
VERIFY_FAILED=0
DEPLOY_MANIFEST="${GENERATED_DIR}/deploy-manifest.json"
# 读取上次部署的 manifest(用于对比)
PREV_MANIFEST=""
if [ -f "$DEPLOY_MANIFEST" ]; then
PREV_MANIFEST=$(cat "$DEPLOY_MANIFEST")
@@ -366,12 +348,14 @@ for svc in api worker web; do
img_var="REGISTRY_$(echo $svc | tr '[:lower:]' '[:upper:]')"
img_val=$(eval echo "\$$img_var")
# 1. 检查镜像是否存在
if ! docker image inspect "$img_val" >/dev/null 2>&1; then
echo " ❌ $svc: 镜像不存在 ($img_val)"
VERIFY_FAILED=$((VERIFY_FAILED + 1))
continue
fi
# 2. 检查 layers 有效性
LAYER_COUNT=$(docker inspect --format='{{len .RootFS.Layers}}' "$img_val" 2>/dev/null || echo "0")
if [ "$LAYER_COUNT" -eq 0 ]; then
echo " ❌ $svc: 镜像无有效 layers ($img_val)"
@@ -379,12 +363,14 @@ for svc in api worker web; do
continue
fi
# 3. 获取 digest 和创建时间
IMG_ID=$(docker inspect --format='{{.Id}}' "$img_val")
IMG_CREATED=$(docker inspect --format='{{.Created}}' "$img_val")
IMG_SIZE=$(docker inspect --format='{{.Size}}' "$img_val")
echo " ✅ $svc: ${LAYER_COUNT} layers, size=${IMG_SIZE}, created=${IMG_CREATED}"
echo " id: $IMG_ID"
# 4. 对比上次部署
CHANGED="unchanged"
if [ -n "$PREV_MANIFEST" ]; then
PREV_ID=$(echo "$PREV_MANIFEST" | grep "\"${svc}_id\"" | sed 's/.*: *"\(.*\)".*/\1/' 2>/dev/null || echo "")
@@ -412,6 +398,7 @@ if [ "$VERIFY_FAILED" -gt 0 ]; then
exit 1
fi
# 写入新 manifest
cat > "$DEPLOY_MANIFEST" <<MANIFEST_EOF
{
"deployed_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
@@ -457,14 +444,14 @@ for c in xiaoxia-postgres-staging xiaoxia-redis-staging; do
fi
done
docker network create "xiaoxia-net-${COMPOSE_ENV_VALUE}" 2>/dev/null || true
docker network create xiaoxia-net-staging 2>/dev/null || true
if [ "$SKIP_MIGRATION" != "true" ]; then
echo "Running database migrations..."
docker run --rm \
--env-file "$ENV_FILE" \
--network "xiaoxia-net-${COMPOSE_ENV_VALUE}" \
-e APP_ENV="$COMPOSE_ENV_VALUE" \
--network xiaoxia-net-staging \
-e APP_ENV=staging \
"$REGISTRY_API" sh -c "cd /app && alembic upgrade head" || {
echo "ERROR: Database migration failed"
exit 1
@@ -475,28 +462,29 @@ else
fi
echo "Stopping old containers..."
docker stop -t 10 xiaoxia-web-staging 2>/dev/null || true
docker stop -t 30 xiaoxia-api-staging 2>/dev/null || true
# 优雅关闭:先 stop(发 SIGTERM,等待),再 rm
# Worker 需要更长时间(视频任务最长可能5分钟)
docker stop -t 120 xiaoxia-worker-staging 2>/dev/null || true
docker rm -f xiaoxia-api-staging xiaoxia-web-staging 2>/dev/null || true
docker rm -f xiaoxia-worker-staging 2>/dev/null || true
docker stop -t 30 xiaoxia-api-staging 2>/dev/null || true
docker stop -t 10 xiaoxia-web-staging 2>/dev/null || true
docker rm xiaoxia-worker-staging xiaoxia-api-staging xiaoxia-web-staging 2>/dev/null || true
LOG_OPTS="--log-driver json-file --log-opt max-size=50m --log-opt max-file=3"
echo "Starting all containers..."
# ---- 并行启动三个容器 ----
echo "Starting all containers (parallel)..."
LEGACY_VOLUME=""
if [ -d "$LEGACY_ASSETS_DIR" ] && [ "$(ls -A "$LEGACY_ASSETS_DIR" 2>/dev/null)" ]; then
LEGACY_VOLUME="-v ${LEGACY_ASSETS_DIR}:/usr/share/nginx/html/assets-legacy/assets:ro"
fi
# ── API: 暂保留 docker run(TODO: 后续收敛到 compose)──
docker run -d \
--name xiaoxia-api-staging \
--env-file "$ENV_FILE" \
--network "xiaoxia-net-${COMPOSE_ENV_VALUE}" \
--network xiaoxia-net-staging \
-p 127.0.0.1:8000:8000 \
-e APP_ENV="$COMPOSE_ENV_VALUE" \
-e APP_ENV=staging \
-e APP_VERSION="$IMAGE_TAG" \
-e GENERATED_FILES_DIR=/app/generated \
-e GENERATED_FILES_URL_PREFIX=/generated-files \
@@ -514,18 +502,31 @@ docker run -d \
"$REGISTRY_API" &
PID_API_START=$!
# ── Worker: 通过 compose 启动(单一事实来源)──
# compose.yml 定义:三进程(beat+generation+transcode)、独立并发、BEAT_ENABLED、
# healthcheck 匹配 'celery.*worker'(不把 beat 算活)、资源限制 4C/8G。
# WORKER_IMAGE 通过环境变量覆盖镜像 tag(compose.yml 默认 :dev)。
echo "Starting worker via docker compose (from $INFRA_DOCKER_DIR)..."
WORKER_IMAGE="$REGISTRY_WORKER" APP_VERSION="$IMAGE_TAG" compose up -d --no-deps worker &
docker run -d \
--name xiaoxia-worker-staging \
--env-file "$ENV_FILE" \
--network xiaoxia-net-staging \
-e APP_ENV=staging \
-e APP_VERSION="$IMAGE_TAG" \
-e WORKER_CONCURRENCY=1 \
-e WORKER_MAX_TASKS_PER_CHILD=100 \
-e GENERATED_FILES_DIR=/app/generated \
-e GENERATED_FILES_URL_PREFIX=/generated-files \
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
-v "$GENERATED_DIR:/app/generated" \
--restart unless-stopped \
--health-cmd "grep -lq celery /proc/[0-9]*/cmdline 2>/dev/null || exit 1" \
--health-interval 30s \
--health-timeout 10s \
--health-retries 3 \
--health-start-period 30s \
$LOG_OPTS \
"$REGISTRY_WORKER" &
PID_WORKER_START=$!
# ── Web: 暂保留 docker run(TODO: 后续收敛到 compose)──
docker run -d \
--name xiaoxia-web-staging \
--network "xiaoxia-net-${COMPOSE_ENV_VALUE}" \
--network xiaoxia-net-staging \
-p 127.0.0.1:3001:80 \
--restart unless-stopped \
$LEGACY_VOLUME \
@@ -562,8 +563,9 @@ if [ "$START_FAILED" -gt 0 ]; then
rollback
fi
# ---- 并行等待 API 和 Web 健康 ----
echo ""
echo "Waiting for all services health (parallel)..."
echo "Waiting for API + Web health (parallel)..."
HEALTH_LOG_DIR="/tmp/staging-health-$$"
mkdir -p "$HEALTH_LOG_DIR"
@@ -598,60 +600,36 @@ PID_API_HEALTH=$!
) > "$HEALTH_LOG_DIR/web.log" 2>&1 &
PID_WEB_HEALTH=$!
(
i=0
while [ "$i" -lt 20 ]; do
hc=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' xiaoxia-worker-staging 2>/dev/null || echo "missing")
if [ "$hc" = "healthy" ]; then
echo "Worker healthy after $((i * 3))s"
exit 0
fi
if [ "$hc" = "unhealthy" ]; then
echo "Worker UNHEALTHY after $((i * 3))s"
docker logs --tail 30 xiaoxia-worker-staging 2>/dev/null || true
exit 1
fi
i=$((i + 1))
sleep 3
done
echo "Worker health unknown after 60s (last: $hc)"
exit 1
) > "$HEALTH_LOG_DIR/worker.log" 2>&1 &
PID_WORKER_HEALTH=$!
set +e
wait $PID_API_HEALTH
API_EXIT=$?
wait $PID_WEB_HEALTH
WEB_EXIT=$?
wait $PID_WORKER_HEALTH
WORKER_EXIT=$?
set -e
echo ""
echo "健康检查结果:"
API_OK=0
WEB_OK=0
if [ "$API_EXIT" -eq 0 ]; then
echo " OK API: $(cat "$HEALTH_LOG_DIR/api.log")"
echo " OK API: $(cat "$HEALTH_LOG_DIR/api.log")"
API_OK=1
else
echo " FAIL API: 120s未就绪"
docker logs --tail 50 xiaoxia-api-staging 2>/dev/null || true
echo " FAIL API: 120s未就绪"
docker logs --tail 50 xiaoxia-api-staging
fi
if [ "$WEB_EXIT" -eq 0 ]; then
echo " OK Web: $(cat "$HEALTH_LOG_DIR/web.log")"
echo " OK Web: $(cat "$HEALTH_LOG_DIR/web.log")"
WEB_OK=1
else
echo " FAIL Web: 30s未就绪"
docker logs --tail 30 xiaoxia-web-staging 2>/dev/null || true
fi
if [ "$WORKER_EXIT" -eq 0 ]; then
echo " OK Worker: $(cat "$HEALTH_LOG_DIR/worker.log")"
else
echo " FAIL Worker: $(cat "$HEALTH_LOG_DIR/worker.log")"
docker logs --tail 50 xiaoxia-worker-staging 2>/dev/null || true
echo " FAIL Web: 30s未就绪"
docker logs --tail 30 xiaoxia-web-staging
fi
rm -rf "$HEALTH_LOG_DIR"
if [ "$API_EXIT" -ne 0 ] || [ "$WEB_EXIT" -ne 0 ] || [ "$WORKER_EXIT" -ne 0 ]; then
if [ "$API_OK" -eq 0 ] || [ "$WEB_OK" -eq 0 ]; then
echo ""
echo "ERROR: 健康检查失败"
rollback
@@ -662,9 +640,8 @@ docker image prune -af --filter "until=168h" 2>/dev/null || true
docker builder prune -af --filter "until=168h" 2>/dev/null || true
echo ""
echo "=== Staging deployment complete ==="
echo "=== Staging deployment complete (并行优化版) ==="
echo "API: http://127.0.0.1:8000"
echo "Web: http://127.0.0.1:3001"
echo "Worker: managed by docker compose (project=$COMPOSE_PROJECT)"
echo "Version: $IMAGE_TAG"
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep staging
+4 -4
View File
@@ -17,9 +17,9 @@
# SKIP_NOTIFY - 跳过通知 (true/false, 默认 false)
# CI_NOTIFY_WEBHOOK - 通知 Webhook URL
#
# STAGING_SSH_HOST - Staging 服务器 SSH 地址 (默认 116.62.226.203(staging业务机公网IP))
# STAGING_SSH_HOST - Staging 服务器 SSH 地址 (默认 47.98.113.167,CI runner 在独立 CI 机器)
# STAGING_SSH_USER - SSH 用户名 (默认 root)
# STAGING_SSH_PORT - SSH 端口 (默认 22)
# STAGING_SSH_PORT - SSH 端口 (默认 22222,公网;内网 VNC 用 10.0.0.2:22)
# STAGING_SSH_KEY - SSH 私钥内容
# REGISTRY_TOKEN - Registry Token(回滚时拉取旧镜像需要)
#
@@ -40,9 +40,9 @@ HEALTH_CHECK_TIMEOUT="${HEALTH_CHECK_TIMEOUT:-120}"
SKIP_ROLLBACK="${SKIP_ROLLBACK:-false}"
SKIP_NOTIFY="${SKIP_NOTIFY:-false}"
STAGING_SSH_HOST="${STAGING_SSH_HOST:-116.62.226.203}"
STAGING_SSH_HOST="${STAGING_SSH_HOST:-47.98.113.167}"
STAGING_SSH_USER="${STAGING_SSH_USER:-root}"
STAGING_SSH_PORT="${STAGING_SSH_PORT:-22}"
STAGING_SSH_PORT="${STAGING_SSH_PORT:-22222}"
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
REGISTRY_USER="${REGISTRY_USER:-xiaoxia}"
+5 -5
View File
@@ -2,7 +2,7 @@
验证点:
1. UnifiedRenderService 不再有 is_preview 参数
2. 所有渲染统一使用 veryfast preset + CRF 23(#2063 优化:fast→veryfast)
2. 所有渲染统一使用 fast preset + CRF 23(#1758 优化:medium→fast)
3. RenderAdapter 统一执行校验和缩略图生成
4. generation.py 并行下载逻辑(保留)
"""
@@ -51,7 +51,7 @@ class TestUnifiedRenderServiceNoPreviewParam:
class TestUnifiedFFmpegPreset:
"""所有渲染统一使用 veryfast preset + CRF 23(#2063 渲染加速优化)。"""
"""所有渲染统一使用 fast preset + CRF 23(#1758 渲染加速优化)。"""
def _make_clip(self):
from video_processing.unified_render_service import ResolvedClip
@@ -71,7 +71,7 @@ class TestUnifiedFFmpegPreset:
)
@patch("video_processing.unified_render_service.run_ffmpeg")
def test_execute_ffmpeg_uses_veryfast_crf23(self, mock_run):
def test_execute_ffmpeg_uses_fast_crf23(self, mock_run):
from video_processing.unified_render_service import (
RenderLayer,
UnifiedRenderService,
@@ -100,9 +100,9 @@ class TestUnifiedFFmpegPreset:
mock_run.assert_called_once()
cmd = mock_run.call_args[0][0]
# Check preset is veryfast (#2063: changed from fast to veryfast for 38% speedup)
# Check preset is fast (#1758: changed from medium to fast for rendering speed)
preset_idx = cmd.index("-preset")
assert cmd[preset_idx + 1] == "veryfast", f"Expected veryfast, got {cmd[preset_idx + 1]}"
assert cmd[preset_idx + 1] == "fast", f"Expected fast, got {cmd[preset_idx + 1]}"
# Check crf is 23 (no conditional)
crf_idx = cmd.index("-crf")
+10 -10
View File
@@ -4,7 +4,7 @@
1. 集中编码常量正确定义,支持环境变量覆盖
2. 所有渲染路径(_execute_ffmpeg / _render_pass_through / normalize_video / processor)
使用统一的编码参数
3. preset 从 medium → veryfast(#2063 优化 fast→veryfast,再提速 38%)
3. preset 从 medium → fast,确保渲染速度提升
4. threads=0 自动检测 CPU 核心数
"""
@@ -27,7 +27,7 @@ class TestEncodingConstants:
"""默认 preset 应为 fast(非 medium),确保速度提升."""
from shared.ffmpeg_utils import FFMPEG_ENCODE_PRESET
assert FFMPEG_ENCODE_PRESET == "veryfast"
assert FFMPEG_ENCODE_PRESET == "fast"
def test_default_crf_is_23(self):
"""默认 CRF 保持 23,画质不变."""
@@ -96,7 +96,7 @@ class TestWorkerReExport:
"""worker ffmpeg_utils 应 re-export FFMPEG_ENCODE_PRESET."""
from video_processing.ffmpeg_utils import FFMPEG_ENCODE_PRESET
assert FFMPEG_ENCODE_PRESET == "veryfast"
assert FFMPEG_ENCODE_PRESET == "fast"
def test_worker_reexports_crf(self):
"""worker ffmpeg_utils 应 re-export FFMPEG_ENCODE_CRF."""
@@ -142,11 +142,11 @@ class TestExecuteFfmpegEncoding:
return captured_cmd
def test_execute_uses_veryfast_preset(self):
"""_execute_ffmpeg 应使用 veryfast preset."""
def test_execute_uses_fast_preset(self):
"""_execute_ffmpeg 应使用 fast preset."""
cmd = self._get_execute_command()
idx = cmd.index("-preset")
assert cmd[idx + 1] == "veryfast"
assert cmd[idx + 1] == "fast"
def test_execute_uses_crf_23(self):
"""_execute_ffmpeg 应使用 CRF 23."""
@@ -172,8 +172,8 @@ class TestExecuteFfmpegEncoding:
class TestRenderPassThroughEncoding:
"""测试 _render_pass_through 方法使用正确的编码参数."""
def test_passthrough_command_contains_veryfast_preset(self):
"""_render_pass_through 命令应包含 veryfast preset."""
def test_passthrough_command_contains_fast_preset(self):
"""_render_pass_through 命令应包含 fast preset."""
# 通过源码检查确认参数已替换
import inspect
@@ -200,8 +200,8 @@ class TestRenderPassThroughEncoding:
class TestNormalizeVideoEncoding:
"""测试 normalize_video 使用正确的编码参数."""
def test_normalize_uses_veryfast_preset(self):
"""normalize_video 应使用 veryfast preset."""
def test_normalize_uses_fast_preset(self):
"""normalize_video 应使用 fast preset."""
import inspect
from video_processing.ffmpeg_utils import normalize_video
+41 -163
View File
@@ -38,7 +38,6 @@ def client():
endpoint="http://gpu.example.com:8900",
relay_base_url="http://api.example.com",
relay_internal_base_url="http://api-internal:8000",
mezzanine_transport="oss", # 旧测试只 mock OSS 上传,走 OSS 路径
sync_timeout=60,
health_timeout=2,
relay_secret="test-secret",
@@ -120,6 +119,7 @@ class TestPostSync:
"output_url": "http://relay/k?token=s",
"timeout": 30,
},
mezzanine_path=Path("/tmp/fake.mp4"),
)
assert res["status"] == "completed" and res["ffmpeg_rc"] == 0
req = m.call_args[0][0]
@@ -129,7 +129,9 @@ class TestPostSync:
body = {"status": "failed", "ffmpeg_rc": 1, "message": "Invalid data"}
with mock.patch("urllib.request.urlopen", return_value=_fake_response(body=body)):
with pytest.raises(GpuEncodeError, match="rc=1"):
client._post_sync({"inputs": {}, "ffmpeg_args": [], "output_url": "", "timeout": 10})
client._post_sync(
{"inputs": {}, "ffmpeg_args": [], "output_url": "", "timeout": 10}, mezzanine_path=Path("/tmp/x")
)
def test_http_4xx_raises(self, client):
err = urllib.error.HTTPError(
@@ -137,80 +139,65 @@ class TestPostSync:
)
with mock.patch("urllib.request.urlopen", side_effect=err):
with pytest.raises(GpuEncodeError, match="HTTP 422"):
client._post_sync({"inputs": {}, "ffmpeg_args": [], "output_url": "", "timeout": 10})
client._post_sync(
{"inputs": {}, "ffmpeg_args": [], "output_url": "", "timeout": 10}, mezzanine_path=Path("/tmp/x")
)
def test_connection_error_raises(self, client):
with mock.patch("urllib.request.urlopen", side_effect=urllib.error.URLError("conn refused")):
with pytest.raises(GpuEncodeError, match="connection error"):
client._post_sync({"inputs": {}, "ffmpeg_args": [], "output_url": "", "timeout": 10})
client._post_sync(
{"inputs": {}, "ffmpeg_args": [], "output_url": "", "timeout": 10}, mezzanine_path=Path("/tmp/x")
)
def test_timeout_error_raises(self, client):
with mock.patch("urllib.request.urlopen", side_effect=socket.timeout("timed out")):
with pytest.raises(GpuEncodeError, match="connection error"):
client._post_sync({"inputs": {}, "ffmpeg_args": [], "output_url": "", "timeout": 10})
client._post_sync(
{"inputs": {}, "ffmpeg_args": [], "output_url": "", "timeout": 10}, mezzanine_path=Path("/tmp/x")
)
def test_bad_json_raises(self, client):
with mock.patch("urllib.request.urlopen", return_value=_fake_response(body=b"not-json")):
with pytest.raises(GpuEncodeError, match="bad JSON"):
client._post_sync({"inputs": {}, "ffmpeg_args": [], "output_url": "", "timeout": 10})
client._post_sync(
{"inputs": {}, "ffmpeg_args": [], "output_url": "", "timeout": 10}, mezzanine_path=Path("/tmp/x")
)
def test_uploaded_false_logs_warning_but_succeeds(self, client, caplog):
body = {"status": "completed", "ffmpeg_rc": 0, "uploaded": False, "job_id": "j"}
with mock.patch("urllib.request.urlopen", return_value=_fake_response(body=body)), caplog.at_level("WARNING"):
res = client._post_sync({"inputs": {}, "ffmpeg_args": [], "output_url": "", "timeout": 10})
res = client._post_sync(
{"inputs": {}, "ffmpeg_args": [], "output_url": "", "timeout": 10}, mezzanine_path=Path("/tmp/x")
)
assert res["status"] == "completed"
assert "uploaded=false" in caplog.text
# ── Relay URL builders ──────────────────────────────────────────────
class TestRelayUrl:
def test_result_put_url_uses_external_base(self, client):
"""P4000 回传编码结果的 PUT URL 应走外部 base(Tailscale 可达)。"""
url = client._result_put_url("abc123", "secret!")
def test_put_url_uses_external_base(self, client):
url = client._relay_put_url("abc123", "secret!")
assert "abc123" in url
assert "token=secret%21" in url
assert url.startswith("http://api.example.com/api/v1/internal/gpu-relay/")
assert "/mezzanine/" not in url
def test_result_get_url_uses_internal_base(self, client):
"""Worker 下载结果使用 internal base(Docker DNS 直连)。"""
url = client._result_get_url("abc123", "s")
def test_internal_url_uses_internal_base(self, client):
url = client._relay_internal_url("abc123", "s")
assert url.startswith("http://api-internal:8000/api/v1/internal/gpu-relay/abc123")
assert "/mezzanine/" not in url
def test_result_get_url_falls_back_to_external_when_not_set(self):
c = GpuEncoderClient(
endpoint="http://gpu",
relay_base_url="http://api.example.com",
relay_secret="s",
mezzanine_transport="oss",
)
put = c._result_put_url("k", "s")
get = c._result_get_url("k", "s")
def test_internal_url_falls_back_to_external_when_not_set(self):
c = GpuEncoderClient(endpoint="http://gpu", relay_base_url="http://api.example.com", relay_secret="s")
put = c._relay_put_url("k", "s")
internal = c._relay_internal_url("k", "s")
assert put.startswith("http://api.example.com/")
assert get == put
assert internal == put
def test_encode_uses_different_put_and_get_urls(self, client):
put_url = client._result_put_url("k", "test-secret")
get_url = client._result_get_url("k", "test-secret")
put_url = client._relay_put_url("k", "test-secret")
get_url = client._relay_internal_url("k", "test-secret")
assert "api.example.com" in put_url and "api-internal:8000" in get_url and put_url != get_url
def test_mezz_put_url_uses_internal_base(self, client):
"""Worker 上传 mezzanine 的 PUT URL 应走 internal base(Docker 内网快)。"""
url = client._mezz_put_url("m1", "test-secret")
assert url.startswith("http://api-internal:8000/api/v1/internal/gpu-relay/mezzanine/m1")
def test_mezz_get_url_for_p4000_uses_external_base(self, client):
"""P4000 下载 mezzanine 的 GET URL 必须是 P4000 可达的(Tailscale 外部 base)。"""
url = client._mezz_get_url_for_p4000("m1", "test-secret")
assert url.startswith("http://api.example.com/api/v1/internal/gpu-relay/mezzanine/m1")
def test_mezz_put_get_use_different_bases(self, client):
"""worker 上传 mezzanine 用 internal,P4000 下载 mezzanine 用 external。"""
put = client._mezz_put_url("m", "test-secret")
get = client._mezz_get_url_for_p4000("m", "test-secret")
assert "api-internal:8000" in put and "api.example.com" in get and put != get
# ── _get_relay_secret ──────────────────────────────────────────────
class TestGetRelaySecret:
@@ -279,7 +266,7 @@ class TestEncodeMezzanine:
mezz.write_bytes(b"M" * 100)
out = tmp_path / "out" / "final.mp4"
with (
mock.patch.object(client, "_upload_mezzanine_to_oss", return_value=("http://oss/signed", "osskey1")),
mock.patch.object(client, "_upload_mezzanine", return_value=("http://oss/signed", "osskey1")),
mock.patch.object(
client,
"_post_sync",
@@ -311,7 +298,7 @@ class TestEncodeMezzanine:
mezz.write_bytes(b"M")
out = tmp_path / "o.mp4"
with (
mock.patch.object(client, "_upload_mezzanine_to_oss", return_value=("http://oss/u", "k")),
mock.patch.object(client, "_upload_mezzanine", return_value=("http://oss/u", "k")),
mock.patch.object(
client,
"_post_sync",
@@ -338,7 +325,7 @@ class TestEncodeMezzanine:
mezz.write_bytes(b"x")
out = tmp_path / "o.mp4"
with (
mock.patch.object(c, "_upload_mezzanine_to_oss", return_value=("http://oss/u", "k")),
mock.patch.object(c, "_upload_mezzanine", return_value=("http://oss/u", "k")),
mock.patch.object(
c, "_post_sync", return_value={"status": "completed", "ffmpeg_rc": 0, "uploaded": True, "job_id": "j"}
) as m_post,
@@ -367,7 +354,7 @@ class TestEncodeMezzanine:
mezz = tmp_path / "m.mp4"
mezz.write_bytes(b"x")
with (
mock.patch.object(client, "_upload_mezzanine_to_oss", return_value=("http://oss/u", "k")),
mock.patch.object(client, "_upload_mezzanine", return_value=("http://oss/u", "k")),
mock.patch.object(client, "_post_sync", side_effect=RuntimeError("boom")),
mock.patch.object(client, "_delete_oss"),
):
@@ -378,7 +365,7 @@ class TestEncodeMezzanine:
mezz = tmp_path / "m.mp4"
mezz.write_bytes(b"x")
with (
mock.patch.object(client, "_upload_mezzanine_to_oss", return_value=("http://oss/u", "k")),
mock.patch.object(client, "_upload_mezzanine", return_value=("http://oss/u", "k")),
mock.patch.object(client, "_post_sync", side_effect=GpuEncodeError("direct fail")),
mock.patch.object(client, "_delete_oss"),
):
@@ -389,7 +376,7 @@ class TestEncodeMezzanine:
mezz = tmp_path / "m.mp4"
mezz.write_bytes(b"x")
with (
mock.patch.object(client, "_upload_mezzanine_to_oss", return_value=("http://oss/u", "ossk")),
mock.patch.object(client, "_upload_mezzanine", return_value=("http://oss/u", "ossk")),
mock.patch.object(client, "_post_sync", side_effect=GpuEncodeError("enc fail")),
mock.patch.object(client, "_delete_oss", side_effect=Exception("oss down")) as m_ossdel,
caplog.at_level("WARNING"),
@@ -429,7 +416,7 @@ class TestOssHelpers:
with mock.patch("builtins.__import__", side_effect=fake_import):
with pytest.raises(GpuEncodeError, match="storage service unavailable"):
client._upload_mezzanine_to_oss(mezz)
client._upload_mezzanine(mezz)
finally:
if saved is not None:
sys.modules["packages.shared.storage"] = saved
@@ -441,7 +428,7 @@ class TestOssHelpers:
fake_mod.get_storage_service.return_value = None
with mock.patch.dict("sys.modules", {"packages.shared.storage": fake_mod}):
with pytest.raises(GpuEncodeError, match="OSS storage not configured"):
client._upload_mezzanine_to_oss(mezz)
client._upload_mezzanine(mezz)
def test_upload_bucket_none(self, client, tmp_path):
mezz = tmp_path / "m.mp4"
@@ -452,7 +439,7 @@ class TestOssHelpers:
fake_mod.get_storage_service.return_value = svc
with mock.patch.dict("sys.modules", {"packages.shared.storage": fake_mod}):
with pytest.raises(GpuEncodeError, match="OSS storage not configured"):
client._upload_mezzanine_to_oss(mezz)
client._upload_mezzanine(mezz)
def test_upload_failure_raises(self, client, tmp_path):
mezz = tmp_path / "m.mp4"
@@ -464,7 +451,7 @@ class TestOssHelpers:
fake_mod.get_storage_service.return_value = svc
with mock.patch.dict("sys.modules", {"packages.shared.storage": fake_mod}):
with pytest.raises(GpuEncodeError, match="failed to upload mezzanine"):
client._upload_mezzanine_to_oss(mezz)
client._upload_mezzanine(mezz)
def test_upload_success(self, client, tmp_path):
mezz = tmp_path / "m.mp4"
@@ -475,7 +462,7 @@ class TestOssHelpers:
fake_mod = mock.MagicMock()
fake_mod.get_storage_service.return_value = svc
with mock.patch.dict("sys.modules", {"packages.shared.storage": fake_mod}):
url, key = client._upload_mezzanine_to_oss(mezz)
url, key = client._upload_mezzanine(mezz)
assert url.startswith("https://oss/signed")
assert key.startswith("tmp/gpu-mezzanine/") and key.endswith(".mp4")
svc.upload_file.assert_called_once()
@@ -506,113 +493,6 @@ class TestOssHelpers:
svc.delete_file.assert_called_once_with("k")
# ── encode_mezzanine_to_output: relay transport (default path) ──────
class TestEncodeMezzanineRelay:
def test_relay_happy_path_uploads_to_relay(self, tmp_path):
"""默认 relay 模式:worker PUT mezzanine 到 internal relay;P4000 GET 用 external base。"""
c = GpuEncoderClient(
endpoint="http://gpu.example.com:8900",
relay_base_url="http://api.example.com",
relay_internal_base_url="http://api-internal:8000",
mezzanine_transport="relay",
sync_timeout=60,
relay_secret="test-secret",
)
mezz = tmp_path / "mezz.mp4"
mezz.write_bytes(b"X" * 200)
out = tmp_path / "out.mp4"
with (
mock.patch.object(c, "_upload_file_put") as m_put,
mock.patch.object(
c,
"_post_sync",
return_value={
"job_id": "j",
"status": "completed",
"ffmpeg_rc": 0,
"uploaded": True,
"size": 100,
"duration": 1.0,
},
) as m_post,
mock.patch.object(c, "_download_to_file", return_value=100),
mock.patch.object(c, "_relay_delete") as m_del,
mock.patch.object(c, "_delete_oss") as m_ossdel,
):
result = c.encode_mezzanine_to_output(mezz, out)
assert result["transport"] == "relay"
# _upload_file_put called once (mezz uploaded to relay)
m_put.assert_called_once()
put_url = m_put.call_args[0][0]
assert "/api/v1/internal/gpu-relay/mezzanine/" in put_url
assert "api-internal:8000" in put_url # worker uses internal
# P4000 body.inputs["in.mp4"] must use external base (P4000 reachable)
body = m_post.call_args[0][0]
assert body["inputs"]["in.mp4"].startswith("http://api.example.com/api/v1/internal/gpu-relay/mezzanine/")
# No OSS involved
m_ossdel.assert_not_called()
# cleanup: one result delete + one mezz delete
assert m_del.call_count == 2
def test_relay_put_failure_falls_back_to_oss(self, tmp_path):
"""relay PUT 失败时应自动 fallback 到 OSS,且 transport 标记为 oss。"""
c = GpuEncoderClient(
endpoint="http://gpu.example.com:8900",
relay_base_url="http://api.example.com",
relay_internal_base_url="http://api-internal:8000",
mezzanine_transport="relay",
sync_timeout=60,
relay_secret="test-secret",
)
mezz = tmp_path / "mezz.mp4"
mezz.write_bytes(b"X")
out = tmp_path / "out.mp4"
with (
mock.patch.object(c, "_upload_file_put", side_effect=GpuEncodeError("relay 500")),
mock.patch.object(c, "_upload_mezzanine_to_oss", return_value=("https://oss/signed", "ossk")),
mock.patch.object(
c,
"_post_sync",
return_value={"job_id": "j", "status": "completed", "ffmpeg_rc": 0, "uploaded": True},
) as m_post,
mock.patch.object(c, "_download_to_file", return_value=50),
mock.patch.object(c, "_relay_delete"),
mock.patch.object(c, "_delete_oss") as m_ossdel,
):
result = c.encode_mezzanine_to_output(mezz, out)
assert result["transport"] == "oss"
body = m_post.call_args[0][0]
assert body["inputs"]["in.mp4"] == "https://oss/signed"
m_ossdel.assert_called_once_with("ossk")
def test_upload_file_put_streaming_sends_content_length(self, client, tmp_path):
"""_upload_file_put 应用 Content-Length 头发送文件。"""
f = tmp_path / "x.mp4"
f.write_bytes(b"ABCDEFGH") # 8 bytes
captured = {}
def fake_urlopen(req, timeout=None):
captured["method"] = req.get_method()
captured["cl"] = req.get_header("Content-length")
captured["ct"] = req.get_header("Content-type")
captured["data"] = req.data.read()
return _fake_response(status=200, body=b"")
with mock.patch("urllib.request.urlopen", side_effect=fake_urlopen):
client._upload_file_put("http://relay/m?token=s", f, "video/mp4")
assert captured["method"] == "PUT"
assert captured["cl"] == "8"
assert captured["ct"] == "video/mp4"
assert captured["data"] == b"ABCDEFGH"
def test_upload_file_put_non_2xx_raises(self, client, tmp_path):
f = tmp_path / "x.mp4"
f.write_bytes(b"x")
with mock.patch("urllib.request.urlopen", return_value=_fake_response(status=500, body=b"err")):
with pytest.raises(GpuEncodeError, match="relay PUT failed"):
client._upload_file_put("http://relay/m", f, "video/mp4")
# ── Singleton / factory ────────────────────────────────────────────
class TestSingletonFactory:
def test_build_client_import_error_returns_none(self):
@@ -669,7 +549,6 @@ class TestSingletonFactory:
s.gpu_encode_bitrate = ""
s.gpu_encode_relay_secret = "s"
s.gpu_encode_oss_tmp_prefix = "tmp/x/"
s.gpu_encode_mezzanine_transport = "relay"
fake_mod = mock.MagicMock()
fake_mod.get_shared_settings.return_value = s
with mock.patch.dict("sys.modules", {"packages.config": fake_mod}):
@@ -678,7 +557,6 @@ class TestSingletonFactory:
assert c.relay_base_url == "http://api"
assert c.relay_internal_base_url == "http://api-int:8000"
assert c.preset == "p7" and c.crf == 20
assert c.mezzanine_transport == "relay"
def test_get_gpu_encoder_init_failure_returns_none(self, caplog):
with (