Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 97ad0ae2e5 | |||
| 59c05148ab | |||
| a00031e100 | |||
| 87ec400a1b | |||
| 2d3cb13707 | |||
| 28cbe2a207 | |||
| e4723bfb1b |
@@ -1238,12 +1238,13 @@ jobs:
|
||||
ACR_PASSWORD: "${{ secrets.ACR_PASSWORD }}"
|
||||
run: |
|
||||
set -eux
|
||||
# Staging 业务机 = 47.98.113.167(公网 sshd 端口 22222;内网 VPC 10.0.0.2:22)。
|
||||
# CI runner 已迁移到独立 CI 机器、job 在隔离容器网络内执行,127.0.0.1 会指向 job 容器自身而失败,
|
||||
# Staging 业务机 = 116.62.226.203(公网 sshd 端口 22)。
|
||||
# 47.98.113.167 现为生产机(sshd 端口 22222),不承载 staging 容器。
|
||||
# CI job 在隔离容器网络内执行,127.0.0.1 会指向 job 容器自身而失败,
|
||||
# 故默认目标必须是 staging 业务机;仍可通过 secrets 覆盖。
|
||||
staging_host="${STAGING_SSH_HOST:-47.98.113.167}"
|
||||
staging_host="${STAGING_SSH_HOST:-116.62.226.203}"
|
||||
staging_user="${STAGING_SSH_USER:-root}"
|
||||
staging_port="${STAGING_SSH_PORT:-22222}"
|
||||
staging_port="${STAGING_SSH_PORT:-22}"
|
||||
echo "Host: $staging_host"
|
||||
echo "Port: $staging_port"
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
"""GPU 编码回传 relay 端点。
|
||||
|
||||
P4000 编码完成后通过 HTTP PUT 把结果 mp4 写到这里;Worker 在发起 GPU 请求时携带
|
||||
带签名(token + 随机 key)的 URL,等待 P4000 写入后用同 URL 把文件 GET 回本地。
|
||||
两个用途:
|
||||
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 清理。
|
||||
|
||||
安全:
|
||||
- 生产环境必须配置 GPU_ENCODE_RELAY_SECRET;token=xxx 查询参数必须匹配。
|
||||
- key 为随机 hex,无法被枚举。
|
||||
- 写入/读取后 worker 会调用 DELETE 主动清理;文件落地在 generated-files/gpu_relay/,
|
||||
跟 generated-files 同卷,nginx 已对 generated-files 做静态挂载,但 gpu_relay/ 子目录
|
||||
通过本接口走鉴权,不直接暴露为静态目录(文件名随机 + token 保护双重保险)。
|
||||
- 写入/读取后 worker 会调用 DELETE 主动清理;文件落地在 generated-files/gpu_relay/。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -38,15 +39,19 @@ 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(
|
||||
@@ -72,33 +77,8 @@ def _check_token(tok: Optional[str]) -> None:
|
||||
raise HTTPException(status_code=401, detail="unauthorized")
|
||||
|
||||
|
||||
# ── 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
|
||||
async def _atomic_write(request: Request, dst: Path, log_prefix: str, key_for_log: str) -> int:
|
||||
"""通用原子写入(流式 → .part → replace)。返回字节数。"""
|
||||
tmp = dst.with_suffix(dst.suffix + ".part")
|
||||
size = 0
|
||||
t0 = time.time()
|
||||
@@ -114,40 +94,22 @@ async def put_object(
|
||||
tmp.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
logger.exception("[gpu-relay] PUT failed key=%s", safe)
|
||||
logger.exception("[gpu-relay] %s PUT failed key=%s", log_prefix, key_for_log)
|
||||
raise HTTPException(status_code=500, detail=f"write failed: {e}") from e
|
||||
logger.info(
|
||||
"[gpu-relay] PUT key=%s size=%d took=%.2fs",
|
||||
safe, size, time.time() - t0,
|
||||
"[gpu-relay] %s PUT key=%s size=%d took=%.2fs",
|
||||
log_prefix, key_for_log, size, time.time() - t0,
|
||||
)
|
||||
return {"ok": True, "key": safe, "size": size}
|
||||
return size
|
||||
|
||||
|
||||
@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
|
||||
def _file_response(path: Path, download_name: str) -> FileResponse:
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
return FileResponse(
|
||||
path=path,
|
||||
media_type="video/mp4",
|
||||
filename=f"{safe}.mp4",
|
||||
)
|
||||
return FileResponse(path=path, media_type="video/mp4", filename=f"{download_name}.mp4")
|
||||
|
||||
|
||||
@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
|
||||
def _head_response(path: Path) -> Response:
|
||||
if not path.exists():
|
||||
return Response(status_code=404)
|
||||
return Response(
|
||||
@@ -157,17 +119,97 @@ async def head_object(
|
||||
)
|
||||
|
||||
|
||||
@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
|
||||
def _safe_delete(path: Path, err_detail: str) -> dict:
|
||||
try:
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
except OSError as e:
|
||||
raise HTTPException(status_code=500, detail=f"delete failed: {e}") from e
|
||||
return {"ok": True, "key": safe}
|
||||
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")
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* 标题迷你 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
|
||||
@@ -0,0 +1,458 @@
|
||||
/* ============================================================
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
/**
|
||||
* 标题样式参数 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,29 +1,30 @@
|
||||
/**
|
||||
* 标题模板编辑器(v3 重构)
|
||||
* 标题模板编辑器(公共组件)
|
||||
*
|
||||
* - Modal 弹窗 860px 宽
|
||||
* - 左侧:300px 竖屏预览区(图片背景+暗色渐变遮罩+透明 Canvas 叠字)+ 模板名称输入框
|
||||
* - 右侧:参数 Tab 面板(基础/描边/阴影/背景/排版),复用 TitleStylePanel 的 paramsOnly 模式
|
||||
* - 左侧:300px 竖屏预览区(图片背景+暗角+透明 Canvas 叠字)+ 模板名称输入
|
||||
* - 右侧:参数 Tab 面板(基础/描边/阴影/背景/排版),复用 TitleStyleParamsTab
|
||||
* - 底部:取消 / 保存模板 按钮
|
||||
* - 内置模板编辑时保存会创建副本(带"副本"逻辑由 handleSave 处理)
|
||||
* - 内置模板编辑时保存会创建副本(带"副本"逻辑由 onSave 的调用方处理)
|
||||
*/
|
||||
import React, { useEffect, useMemo, useState } from "react"
|
||||
import { Modal, Button, Input, message } from "antd"
|
||||
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 type { TitleStyleSettings } from "./settings"
|
||||
import { DEFAULT_TITLE_STYLE_SETTINGS } from "./settings"
|
||||
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
|
||||
}
|
||||
|
||||
@@ -31,10 +32,9 @@ interface Props {
|
||||
const EDITOR_BG = "/title-templates/portrait1.jpg"
|
||||
|
||||
const TitleTemplateEditor: React.FC<Props> = ({ open, template, onClose, onSave }) => {
|
||||
const [settings, setSettings] = useState<TitleSettings>(() => ({
|
||||
...DEFAULT_TITLE_SETTINGS_FULL,
|
||||
const [settings, setSettings] = useState<TitleStyleSettings>(() => ({
|
||||
...DEFAULT_TITLE_STYLE_SETTINGS,
|
||||
...titleStyleConfigToCamel(template.style || {}),
|
||||
title: "预览标题文字",
|
||||
}))
|
||||
const [formName, setFormName] = useState(template.name || "")
|
||||
const [formEmoji, setFormEmoji] = useState(template.emoji || "✨")
|
||||
@@ -43,16 +43,15 @@ const TitleTemplateEditor: React.FC<Props> = ({ open, template, onClose, onSave
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSettings({
|
||||
...DEFAULT_TITLE_SETTINGS_FULL,
|
||||
...DEFAULT_TITLE_STYLE_SETTINGS,
|
||||
...titleStyleConfigToCamel(template.style || {}),
|
||||
title: "预览标题文字",
|
||||
})
|
||||
setFormName(template.name || "")
|
||||
setFormEmoji(template.emoji || "✨")
|
||||
}
|
||||
}, [open, template])
|
||||
|
||||
const upd = (patch: Partial<TitleSettings>) => setSettings((s) => ({ ...s, ...patch }))
|
||||
const upd = (patch: Partial<TitleStyleSettings>) => setSettings((s) => ({ ...s, ...patch }))
|
||||
|
||||
const handleSave = () => {
|
||||
const name = formName.trim()
|
||||
@@ -69,11 +68,11 @@ const TitleTemplateEditor: React.FC<Props> = ({ open, template, onClose, onSave
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑器内的预览用 settings:字号适配竖屏
|
||||
const previewSettings = useMemo<TitleSettings>(() => {
|
||||
// 竖屏宽度 200px,按比例缩放字号,让预览看起来协调
|
||||
return { ...settings, size: Math.round(settings.size * 0.55) }
|
||||
}, [settings])
|
||||
// 编辑器预览 settings:竖屏宽度 200px,字号按比例缩放
|
||||
const previewSettings = useMemo<TitleStyleSettings>(
|
||||
() => ({ ...settings, size: Math.round(settings.size * 0.55) }),
|
||||
[settings],
|
||||
)
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -140,7 +139,7 @@ const TitleTemplateEditor: React.FC<Props> = ({ open, template, onClose, onSave
|
||||
</div>
|
||||
{/* 右侧:参数 Tab */}
|
||||
<div className="ttv3-editor-right">
|
||||
<TitleStylePanel
|
||||
<TitleStyleParamsTab
|
||||
settings={settings}
|
||||
onUpdatePosition={(p) => upd({ position: p, posX: null, posY: null })}
|
||||
onUpdateFont={(f) => upd({ font: f })}
|
||||
@@ -155,15 +154,9 @@ const TitleTemplateEditor: React.FC<Props> = ({ open, template, onClose, onSave
|
||||
})
|
||||
}
|
||||
onToggleShadow={() => upd({ shadow: !settings.shadow })}
|
||||
onApplyPreset={() => {
|
||||
/* 编辑器内不使用系统预设快捷键 */
|
||||
}}
|
||||
onUpdateStyle={(patch) => upd(patch)}
|
||||
activePreset={null}
|
||||
titlePresets={[]}
|
||||
POSITION_OPTIONS={POSITION_OPTIONS}
|
||||
FONT_OPTIONS={FONT_OPTIONS}
|
||||
paramsOnly
|
||||
onUpdatePatch={upd}
|
||||
positionOptions={POSITION_OPTIONS}
|
||||
fontOptions={FONT_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* 标题模板选择器 — 大卡片网格(共享组件)
|
||||
*
|
||||
* 渲染「我的模板」+「系统模板」两个分组的 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
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* 公共标题模板/样式组件统一导出
|
||||
*
|
||||
* 任何页面需要标题样式配置/模板选择/模板编辑,从这里 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"
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 标题位置选项(公共常量)
|
||||
*/
|
||||
export interface PositionOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export const POSITION_OPTIONS: PositionOption[] = [
|
||||
{ value: "top", label: "顶部" },
|
||||
{ value: "center", label: "居中" },
|
||||
{ value: "bottom", label: "底部" },
|
||||
{ value: "custom", label: "自定义" },
|
||||
]
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 标题样式设置 — 公共 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: [],
|
||||
}
|
||||
@@ -1,24 +1,25 @@
|
||||
/**
|
||||
* 标题样式工具(#2001 / 模板系统 #2003)
|
||||
*
|
||||
* - snake_case TitleStyleConfig ↔ camelCase TitleSettings 互转
|
||||
* - snake_case TitleStyleConfig <-> camelCase TitleStyleSettings 互转
|
||||
* - preset 归一化预览(修复"标题"两字大小不一)
|
||||
* - template -> preview settings 转换
|
||||
*/
|
||||
import type { TitleStyleConfig } from "./types"
|
||||
import type { TitleSettings } from "../../pages/generate/types"
|
||||
import type { TitleStyleSettings } from "./settings"
|
||||
import { DEFAULT_TITLE_STYLE_SETTINGS } from "./settings"
|
||||
import { TITLE_PRESETS } from "./constants"
|
||||
import { DEFAULT_TITLE_SETTINGS_FULL } from "../../pages/generate/types"
|
||||
import type { TitleTemplate } from "./template-types"
|
||||
|
||||
/** snake_case TitleStyleConfig → camelCase TitleSettings(仅覆盖已知字段) */
|
||||
export function titleStyleConfigToCamel(s: Partial<TitleStyleConfig>): Partial<TitleSettings> {
|
||||
const out: Partial<TitleSettings> = {}
|
||||
/** snake_case TitleStyleConfig -> camelCase TitleStyleSettings(仅覆盖已知字段) */
|
||||
export function titleStyleConfigToCamel(s: Partial<TitleStyleConfig>): Partial<TitleStyleSettings> {
|
||||
const out: Partial<TitleStyleSettings> = {}
|
||||
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 as TitleSettings["position"]
|
||||
if (s.position != null) out.position = s.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
|
||||
@@ -40,8 +41,8 @@ export function titleStyleConfigToCamel(s: Partial<TitleStyleConfig>): Partial<T
|
||||
return out
|
||||
}
|
||||
|
||||
/** camelCase TitleSettings patch → snake_case TitleStyleConfig patch */
|
||||
export function camelToTitleStyleConfig(p: Partial<TitleSettings>): Partial<TitleStyleConfig> {
|
||||
/** camelCase TitleStyleSettings patch -> snake_case TitleStyleConfig patch */
|
||||
export function camelToTitleStyleConfig(p: Partial<TitleStyleSettings>): Partial<TitleStyleConfig> {
|
||||
const out: Partial<TitleStyleConfig> = {}
|
||||
if (p.font != null) out.font = p.font
|
||||
if (p.size != null) out.size = p.size
|
||||
@@ -71,15 +72,15 @@ export function camelToTitleStyleConfig(p: Partial<TitleSettings>): Partial<Titl
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 preset style(snake_case)归一化为固定字号的 TitleSettings,
|
||||
* 把 preset style(snake_case)归一化为固定字号的 TitleStyleSettings,
|
||||
* 用于"预设卡片"缩略预览——所有卡片视觉上"标题"两字大小一致,便于辨识。
|
||||
* 描边/阴影/背景padding 按 fixedSize / 原始 size 比例缩放,避免粗描边爆框。
|
||||
*/
|
||||
export function buildPresetPreviewSettings(
|
||||
base: TitleSettings,
|
||||
base: TitleStyleSettings,
|
||||
presetKey: string,
|
||||
fixedSize = 56,
|
||||
): TitleSettings {
|
||||
): TitleStyleSettings {
|
||||
const preset = TITLE_PRESETS.find((p) => p.key === presetKey)
|
||||
if (!preset) return base
|
||||
const origSize = preset.style.size ?? fixedSize
|
||||
@@ -87,25 +88,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) ?? 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,
|
||||
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),
|
||||
lineOverrides: [],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 TitleTemplate 渲染为完整 TitleSettings(带默认值),用于卡片预览。
|
||||
* 与模板选择器中保持一致,抽出共用。
|
||||
* 把 TitleTemplate 渲染为完整 TitleStyleSettings(带默认值),用于卡片预览。
|
||||
*/
|
||||
export function templateToPreviewSettings(t: TitleTemplate, fixedSize = 48): TitleSettings {
|
||||
const base: TitleSettings = {
|
||||
...DEFAULT_TITLE_SETTINGS_FULL,
|
||||
export function templateToPreviewSettings(t: TitleTemplate, fixedSize = 48): TitleStyleSettings {
|
||||
const base: TitleStyleSettings = {
|
||||
...DEFAULT_TITLE_STYLE_SETTINGS,
|
||||
...titleStyleConfigToCamel(t.style),
|
||||
}
|
||||
// 预览时用固定字号保证所有卡片字大小一致;描边/阴影/padding按比例缩放
|
||||
|
||||
@@ -412,7 +412,7 @@ const PanelTitleConfig: React.FC<PanelTitleConfigProps> = ({ titleConfig, onUpda
|
||||
onUpdateStyle={handleUpdateStyle}
|
||||
showCoverToggle
|
||||
previewWidth={280}
|
||||
enableTemplates={false}
|
||||
enableTemplates={true}
|
||||
selectedTemplateId={selectedTemplateId}
|
||||
onApplyTemplate={handleApplyTemplate}
|
||||
activePreset={activePreset}
|
||||
|
||||
@@ -137,8 +137,8 @@ const GeneratePage: React.FC = () => {
|
||||
}
|
||||
}, [selectedVoice, isBatch, voiceModePerVideo, setVoiceLibraryIds])
|
||||
|
||||
/* ── 标题面板模式:默认展示样式参数(false);需要大卡片模板网格时再切 true ── */
|
||||
const enableTemplates = false
|
||||
/* ── 标题面板模式:true = 内联大卡片模板网格(默认),false = 旧预设+参数 Tab ── */
|
||||
const enableTemplates = true
|
||||
|
||||
/* ── Step5 保存中状态 ── */
|
||||
const [finishing, setFinishing] = useState(false)
|
||||
|
||||
@@ -6,12 +6,13 @@
|
||||
* - 批量:N 个独立标题输入框(AutoComplete 支持标题库选择)
|
||||
* - 标题样式(字体/颜色/位置/大小/粗斜描边/预设):全局统一
|
||||
*/
|
||||
import React, { useMemo, useState } from "react"
|
||||
import React, { useMemo } from "react"
|
||||
import type { TitleSettings } from "../types"
|
||||
import { POSITION_OPTIONS } from "../constants"
|
||||
import { FONT_OPTIONS } from "@/components/title/constants"
|
||||
import { useStep4Title } from "../hooks/useStep4Title"
|
||||
import TitleLibraryAutoComplete from "./title/TitleLibraryAutoComplete"
|
||||
import TitleStyleModal from "./title/TitleStyleModal"
|
||||
import Button from "@/components/ui/Button"
|
||||
import TitleStylePanel from "./title/TitleStylePanel"
|
||||
import type { TitleTemplate } from "@/components/title/template-types"
|
||||
|
||||
interface Step4TitleSettingsProps {
|
||||
@@ -57,6 +58,13 @@ interface Step4TitleSettingsProps {
|
||||
const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
const t = useStep4Title(props)
|
||||
const {
|
||||
onUpdatePosition,
|
||||
onUpdateFont,
|
||||
onUpdateSize,
|
||||
onToggleBold,
|
||||
onToggleItalic,
|
||||
onToggleStroke,
|
||||
onToggleShadow,
|
||||
onApplyPreset,
|
||||
onUpdateStyle,
|
||||
activePreset,
|
||||
@@ -64,12 +72,14 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
previewCount = 1,
|
||||
previewTitles,
|
||||
onPreviewTitlesChange,
|
||||
enableTemplates,
|
||||
selectedTemplateId,
|
||||
onApplyTemplate,
|
||||
onConfirmGenerate,
|
||||
generating,
|
||||
selectedCount = 1,
|
||||
} = props
|
||||
|
||||
const [showTitleStyleModal, setShowTitleStyleModal] = useState(false)
|
||||
const isBatch = previewCount > 1
|
||||
|
||||
/** 更新单个变体标题;变体0同步写回 titleSettings.title(全局样式面板/草稿/TTS 链路依赖) */
|
||||
@@ -174,30 +184,27 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 标题样式:点击打开弹窗(参考字幕模板/封面模板 Modal 模式) */}
|
||||
<TitleStyleModal
|
||||
open={showTitleStyleModal}
|
||||
onClose={() => setShowTitleStyleModal(false)}
|
||||
titleText={previewTitles?.[0] ?? t.titleSettings.title}
|
||||
{/* 标题样式面板(全局共用) */}
|
||||
<TitleStylePanel
|
||||
settings={t.titleSettings}
|
||||
onUpdatePosition={onUpdatePosition}
|
||||
onUpdateFont={onUpdateFont}
|
||||
onUpdateSize={onUpdateSize}
|
||||
onToggleBold={onToggleBold}
|
||||
onToggleItalic={onToggleItalic}
|
||||
onToggleStroke={onToggleStroke}
|
||||
onToggleShadow={onToggleShadow}
|
||||
onApplyPreset={onApplyPreset}
|
||||
onUpdateStyle={onUpdateStyle}
|
||||
showCoverToggle
|
||||
activePreset={activePreset}
|
||||
onSave={(nextSettings, nextPreset) => {
|
||||
onUpdateStyle?.(nextSettings)
|
||||
if (nextPreset) onApplyPreset(nextPreset)
|
||||
}}
|
||||
titlePresets={titlePresets}
|
||||
POSITION_OPTIONS={POSITION_OPTIONS}
|
||||
FONT_OPTIONS={FONT_OPTIONS}
|
||||
enableTemplates={enableTemplates}
|
||||
selectedTemplateId={selectedTemplateId}
|
||||
onApplyTemplate={onApplyTemplate}
|
||||
/>
|
||||
<div className="xx-form-field">
|
||||
<label>标题样式</label>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
onClick={() => setShowTitleStyleModal(true)}
|
||||
style={{ justifyContent: "flex-start", textAlign: "left" }}
|
||||
>
|
||||
{activePreset
|
||||
? titlePresets.find((p) => p.key === activePreset)?.label || "自定义样式"
|
||||
: "点击选择标题样式(预设 / 字体 / 颜色 / 描边 / 阴影)"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,271 +1 @@
|
||||
/**
|
||||
* 标题迷你 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
|
||||
export { default } from "@/components/title/TitleMiniPreview"
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
.tsm-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tsm-preview {
|
||||
width: 100%;
|
||||
background: #111;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tsm-presets-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
max-height: 440px;
|
||||
overflow-y: auto;
|
||||
padding: 4px 2px 8px;
|
||||
}
|
||||
|
||||
.tsm-preset-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
padding: 8px 6px 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
.tsm-preset-card:hover {
|
||||
border-color: #c4b5fd;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 10px rgba(124, 58, 237, 0.12);
|
||||
}
|
||||
.tsm-preset-card.active {
|
||||
border-color: #7c3aed;
|
||||
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.18);
|
||||
}
|
||||
|
||||
.tsm-preset-preview {
|
||||
width: 100%;
|
||||
aspect-ratio: 9 / 16;
|
||||
background: #1a1a1a;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tsm-preset-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: #374151;
|
||||
}
|
||||
.tsm-preset-emoji {
|
||||
font-size: 14px;
|
||||
}
|
||||
.tsm-preset-label {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100px;
|
||||
}
|
||||
|
||||
.tsm-custom-panel {
|
||||
max-height: 440px;
|
||||
overflow-y: auto;
|
||||
padding: 4px 2px 8px;
|
||||
}
|
||||
|
||||
.tsm-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 16px;
|
||||
margin-top: 16px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
/**
|
||||
* 标题样式弹窗(Step3/智能剪辑用)
|
||||
*
|
||||
* 交互:
|
||||
* - 点击"标题样式"按钮/卡片打开
|
||||
* - 默认 Tab「预设」:10 个爆款预设卡片网格(Canvas 实时预览),点击选中
|
||||
* - Tab「自定义」:详细参数(位置/字体/字号/粗斜/描边/阴影/颜色/背景)
|
||||
* - 底部「取消」「保存」按钮:保存才会把临时编辑的 settings 应用到父组件
|
||||
*
|
||||
* 设计参考:CoverSettingsModal / 字幕模板选择弹窗
|
||||
*/
|
||||
import React, { useEffect, useState } from "react"
|
||||
import { Tabs } from "antd"
|
||||
import Modal from "@/components/ui/Modal"
|
||||
import Button from "@/components/ui/Button"
|
||||
import type { TitleSettings } from "../../types"
|
||||
import { POSITION_OPTIONS } from "../../constants"
|
||||
import { FONT_OPTIONS } from "@/components/title/constants"
|
||||
import TitleStylePanel from "./TitleStylePanel"
|
||||
import TitleMiniPreview from "./TitleMiniPreview"
|
||||
import { titleStyleConfigToCamel } from "@/components/title/utils"
|
||||
import { TITLE_PRESETS as TITLE_PRESETS_DATA } from "@/components/title/constants"
|
||||
import "./TitleStyleModal.css"
|
||||
|
||||
interface TitleStyleModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
/** 当前标题文字(用于预览) */
|
||||
titleText?: string
|
||||
/** 当前生效的标题样式 */
|
||||
settings: TitleSettings
|
||||
/** 保存回调:把编辑后的 settings 回传父组件 */
|
||||
onSave: (settings: TitleSettings, presetKey: string | null) => void
|
||||
/** 当前选中的爆款预设 key(用于高亮) */
|
||||
activePreset?: string | null
|
||||
}
|
||||
|
||||
/** 把预设应用到 base settings(参考 useTitleStyleUpdaters.applyPreset 逻辑) */
|
||||
function applyPresetToSettings(base: TitleSettings, presetKey: string): TitleSettings {
|
||||
const preset = TITLE_PRESETS_DATA.find((p) => p.key === presetKey)
|
||||
if (!preset) return base
|
||||
return {
|
||||
...base,
|
||||
...(titleStyleConfigToCamel(preset.style) as Partial<TitleSettings>),
|
||||
lineOverrides: [],
|
||||
}
|
||||
}
|
||||
|
||||
const TitleStyleModal: React.FC<TitleStyleModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
titleText = "标题",
|
||||
settings,
|
||||
onSave,
|
||||
activePreset: externalActivePreset = null,
|
||||
}) => {
|
||||
// 弹窗内维护一份草稿 settings,点保存才提交到父组件;取消/关闭丢弃
|
||||
const [draft, setDraft] = useState<TitleSettings>(settings)
|
||||
const [activePreset, setActivePreset] = useState<string | null>(externalActivePreset)
|
||||
|
||||
// 每次打开弹窗,用父组件最新 settings 重置草稿
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setDraft(settings)
|
||||
setActivePreset(externalActivePreset)
|
||||
}
|
||||
}, [open, settings, externalActivePreset])
|
||||
|
||||
const handleApplyPreset = (presetKey: string) => {
|
||||
setActivePreset(presetKey)
|
||||
const next = applyPresetToSettings(draft, presetKey)
|
||||
setDraft(next)
|
||||
}
|
||||
|
||||
const handleUpdateStyle = (patch: Partial<TitleSettings>) => {
|
||||
setDraft((prev) => ({ ...prev, ...patch }))
|
||||
// 手动调了参数,清除预设高亮(因为不再严格匹配某个预设)
|
||||
setActivePreset(null)
|
||||
}
|
||||
|
||||
const handleUpdatePosition = (position: string) =>
|
||||
handleUpdateStyle({ position: position as TitleSettings["position"] })
|
||||
const handleUpdateFont = (font: string) => handleUpdateStyle({ font })
|
||||
const handleUpdateSize = (size: number) => handleUpdateStyle({ size })
|
||||
const handleToggleBold = () => handleUpdateStyle({ bold: !draft.bold })
|
||||
const handleToggleItalic = () => handleUpdateStyle({ italic: !draft.italic })
|
||||
const handleToggleStroke = () => handleUpdateStyle({ stroke: !draft.stroke })
|
||||
const handleToggleShadow = () => handleUpdateStyle({ shadow: !draft.shadow })
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(draft, activePreset)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onCancel={onClose} title="标题样式" width={720} footer={null} destroyOnClose>
|
||||
<div className="tsm-body">
|
||||
{/* 顶部预览 */}
|
||||
<div className="tsm-preview">
|
||||
<TitleMiniPreview
|
||||
settings={draft}
|
||||
width={640}
|
||||
sampleText={titleText || "标题"}
|
||||
transparent={false}
|
||||
portrait
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
defaultActiveKey="presets"
|
||||
size="small"
|
||||
items={[
|
||||
{
|
||||
key: "presets",
|
||||
label: "爆款预设",
|
||||
children: (
|
||||
<div className="tsm-presets-grid">
|
||||
{TITLE_PRESETS_DATA.map((p) => {
|
||||
const previewSettings = applyPresetToSettings(draft, p.key)
|
||||
const isActive = activePreset === p.key
|
||||
return (
|
||||
<button
|
||||
key={p.key}
|
||||
type="button"
|
||||
className={`tsm-preset-card${isActive ? " active" : ""}`}
|
||||
onClick={() => handleApplyPreset(p.key)}
|
||||
title={p.label}
|
||||
>
|
||||
<div className="tsm-preset-preview">
|
||||
<TitleMiniPreview
|
||||
settings={previewSettings}
|
||||
width={140}
|
||||
sampleText="标题"
|
||||
/>
|
||||
</div>
|
||||
<div className="tsm-preset-meta">
|
||||
<span className="tsm-preset-emoji">{p.emoji}</span>
|
||||
<span className="tsm-preset-label">{p.label}</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "custom",
|
||||
label: "自定义",
|
||||
children: (
|
||||
<div className="tsm-custom-panel">
|
||||
<TitleStylePanel
|
||||
settings={draft}
|
||||
onUpdatePosition={handleUpdatePosition}
|
||||
onUpdateFont={handleUpdateFont}
|
||||
onUpdateSize={handleUpdateSize}
|
||||
onToggleBold={handleToggleBold}
|
||||
onToggleItalic={handleToggleItalic}
|
||||
onToggleStroke={handleToggleStroke}
|
||||
onToggleShadow={handleToggleShadow}
|
||||
onApplyPreset={handleApplyPreset}
|
||||
onUpdateStyle={handleUpdateStyle}
|
||||
activePreset={activePreset}
|
||||
titlePresets={[]}
|
||||
POSITION_OPTIONS={POSITION_OPTIONS}
|
||||
FONT_OPTIONS={FONT_OPTIONS}
|
||||
paramsOnly
|
||||
hideEditor
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 底部按钮栏 */}
|
||||
<div className="tsm-footer">
|
||||
<Button buttonType="ghost" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button buttonType="primary" onClick={handleSave}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default TitleStyleModal
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
} from "@/components/title/constants"
|
||||
import { buildPresetPreviewSettings } from "@/components/title/utils"
|
||||
|
||||
import TitleMiniPreview from "./TitleMiniPreview"
|
||||
import TitleMiniPreview from "@/components/title/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: TitleSettings
|
||||
settings: import("@/components/title/settings").TitleStyleSettings
|
||||
sampleText: string
|
||||
portrait?: boolean
|
||||
}> = ({ settings, sampleText, portrait }) => {
|
||||
|
||||
@@ -46,13 +46,9 @@ export const CLIP_COUNT_STEP = 1
|
||||
export const MAX_PREVIEW_COUNT = 10
|
||||
export const MIN_PREVIEW_COUNT = 1
|
||||
|
||||
/* ── 标题位置选项 ── */
|
||||
export const POSITION_OPTIONS = [
|
||||
{ value: "top", label: "顶部" },
|
||||
{ value: "center", label: "居中" },
|
||||
{ value: "bottom", label: "底部" },
|
||||
{ value: "custom", label: "自定义" },
|
||||
]
|
||||
/* ── 标题位置选项(统一从公共层重导出) ── */
|
||||
export { POSITION_OPTIONS } from "@/components/title/position-options"
|
||||
export type { PositionOption } from "@/components/title/position-options"
|
||||
|
||||
/* ── 标题字体:统一使用公共层定义(#2001) ── */
|
||||
export { getFontFamily } from "@/components/title/constants"
|
||||
|
||||
@@ -337,3 +337,24 @@ 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 upload_to_oss
|
||||
from video_processing.oss_helpers import get_signed_download_url, upload_to_oss
|
||||
|
||||
from packages.shared.mediakit_client import get_mediakit_client
|
||||
|
||||
@@ -230,14 +230,17 @@ def _extract_frames_via_mediakit(
|
||||
logger.info("[thumbnail] MediaKit 未配置,跳过智能抽帧")
|
||||
return None
|
||||
|
||||
# 1. 上传视频到 OSS 获取 URL
|
||||
video_storage_key: str = ""
|
||||
# 1. 上传视频到 OSS,并生成预签名下载 URL(bucket 私有读,公网 URL 会 403)
|
||||
try:
|
||||
video_storage_key = f"temp/{plan_id}/{uuid.uuid4().hex[:8]}_{Path(video_path).name}"
|
||||
video_url = upload_to_oss(video_path, video_storage_key)
|
||||
if not video_url:
|
||||
public_url = upload_to_oss(video_path, video_storage_key)
|
||||
if not public_url:
|
||||
logger.warning("[thumbnail] 视频上传 OSS 失败,无法使用 MediaKit")
|
||||
return None
|
||||
logger.info("[thumbnail] 视频已上传 OSS: %s", video_url[:80])
|
||||
# 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])
|
||||
except Exception as e:
|
||||
logger.warning("[thumbnail] 视频上传 OSS 异常: %s,降级到 ffmpeg", e)
|
||||
return None
|
||||
|
||||
@@ -1051,20 +1051,15 @@ 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()
|
||||
|
||||
@@ -280,3 +280,18 @@ 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=relay
|
||||
GPU_ENCODE_OSS_TMP_PREFIX=tmp/gpu-mezzanine/
|
||||
|
||||
@@ -43,6 +43,7 @@ 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,
|
||||
@@ -88,6 +89,7 @@ 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,
|
||||
@@ -322,6 +324,7 @@ 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,6 +52,18 @@ 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),
|
||||
@@ -65,14 +77,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=data.get("fingerprint_dict"),
|
||||
fingerprint_chunks=data.get("fingerprint_chunks"),
|
||||
fingerprint_dict=fp_dict or None,
|
||||
fingerprint_chunks=chunks_raw if isinstance(chunks_raw, list) else None,
|
||||
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(data.get("video_fingerprint_md5") or ""),
|
||||
video_fingerprint_md5=str(md5_value or ""),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -177,7 +177,12 @@ class SharedSettings(BaseSettings):
|
||||
default="",
|
||||
validation_alias=AliasChoices("GPU_ENCODE_RELAY_SECRET", "gpu_encode_relay_secret"),
|
||||
)
|
||||
# GPU 中间片在 OSS 的临时前缀(worker 上传 mezzanine 供 P4000 下载)
|
||||
# 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_encode_oss_tmp_prefix: str = Field(
|
||||
default="tmp/gpu-mezzanine/",
|
||||
validation_alias=AliasChoices("GPU_ENCODE_OSS_TMP_PREFIX", "gpu_encode_oss_tmp_prefix"),
|
||||
|
||||
+124
-41
@@ -2,15 +2,16 @@
|
||||
|
||||
完整链路(encode_video_file):
|
||||
1. CPU 滤镜已在本地生成 mezzanine 中间片(libx264 ultrafast)
|
||||
2. 上传 mezzanine 到 OSS 临时前缀,拿到签名 GET URL
|
||||
2. 通过 HTTP PUT 把 mezzanine 上传到 relay(走 Tailscale/Docker 内网,~1s 完成)
|
||||
- 失败则 fallback 到 OSS 上传(旧路径,兼容没有 :8092 内网可达的环境)
|
||||
3. 生成 relay 一次性 key,构造两个带 token 的 URL:
|
||||
- put_url:给 P4000 回传结果,走 relay_base_url(外部可达,通常是 host:port 经 nginx)
|
||||
- put_url:给 P4000 回传结果,走 relay_base_url(Tailscale host:8092)
|
||||
- get/del_url:worker 自己下载+清理用,走 relay_internal_base_url(Docker DNS 直连 API)
|
||||
4. POST P4000 /api/render/sync:inputs={"in.mp4": "<oss-signed-url>"}, output_url="<put_url>"
|
||||
4. POST P4000 /api/render/sync:inputs={"in.mp4": "<mezzanine-get-url>"}, output_url="<put_url>"
|
||||
ffmpeg_args: -i in.mp4 [-vf <vf>] -c:v h264_nvenc ... -an/-c:a aac -f mp4 pipe:1
|
||||
5. P4000 编码完成后 PUT 最终 mp4 到 put_url,API 服务落盘到 /app/generated/gpu_relay/<key>
|
||||
5. P4000 从 relay GET mezzanine → h264_nvenc 编码 → PUT 最终 mp4 到 put_url
|
||||
6. 本客户端通过 get_url(Docker 内网)下载最终文件到 output_path,然后 DELETE 清理
|
||||
7. 删除 OSS 临时 mezzanine
|
||||
7. 删除 relay 上的 mezzanine 临时文件(以及 OSS fallback 的 key)
|
||||
|
||||
任何环节失败抛 GpuEncodeError,调用方应 fallback 到 CPU libx264。
|
||||
"""
|
||||
@@ -58,6 +59,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,
|
||||
vcodec: str = "h264_nvenc",
|
||||
@@ -74,6 +77,7 @@ 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.vcodec = vcodec
|
||||
@@ -88,16 +92,30 @@ class GpuEncoderClient:
|
||||
# ------------------------------------------------------------------
|
||||
# URL builders
|
||||
# ------------------------------------------------------------------
|
||||
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_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_put_url(self, key: str, secret: str) -> str:
|
||||
"""给 P4000 回传结果用的 URL(外部可达)。"""
|
||||
return self._relay_url_from_base(self.relay_base_url, key, secret)
|
||||
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_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)
|
||||
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)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Health
|
||||
@@ -134,8 +152,8 @@ class GpuEncoderClient:
|
||||
) -> dict[str, Any]:
|
||||
"""把 mezzanine(CPU 滤镜已完成)交给 P4000 NVENC 编码,结果写到 output_path。
|
||||
|
||||
extra_video_args: -i 之后、-c:v 之前插入的 ffmpeg 参数(如分辨率/帧率调整)。
|
||||
audio_args: 音频编码参数(如 ["-c:a","aac","-b:a","128k"]);None 表示 -an 无音频。
|
||||
传输:默认通过 relay PUT/GET(Tailscale 内网,省去公网 OSS 往返 18-20s);
|
||||
若 relay PUT 失败且配置可用,自动 fallback 到 OSS。
|
||||
"""
|
||||
if not mezzanine_path.exists():
|
||||
raise GpuEncodeError(f"mezzanine file not found: {mezzanine_path}")
|
||||
@@ -145,19 +163,53 @@ class GpuEncoderClient:
|
||||
timeout = timeout or self.sync_timeout
|
||||
t_total = time.time()
|
||||
oss_key: Optional[str] = None
|
||||
relay_key: Optional[str] = None
|
||||
mezz_key: Optional[str] = None
|
||||
result_key: Optional[str] = None
|
||||
input_url: str = ""
|
||||
used_transport = self.mezzanine_transport
|
||||
|
||||
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()
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
# 3. build ffmpeg args
|
||||
ffmpeg_args = ["-y", "-i", "in.mp4"]
|
||||
@@ -182,43 +234,56 @@ class GpuEncoderClient:
|
||||
"output_url": put_url,
|
||||
"timeout": int(timeout),
|
||||
}
|
||||
job = self._post_sync(body, mezzanine_path=mezzanine_path)
|
||||
job = self._post_sync(body)
|
||||
logger.info(
|
||||
"[gpu-encoder] P4000 done: job_id=%s rc=%s size=%s dur=%ss",
|
||||
"[gpu-encoder] P4000 done: job_id=%s rc=%s size=%s dur=%ss transport=%s",
|
||||
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
|
||||
self._relay_delete(del_url)
|
||||
# 6. cleanup relay result
|
||||
self._relay_delete(del_result_url)
|
||||
|
||||
logger.info(
|
||||
"[gpu-encoder] encode ok: %s → %s (%d bytes) total=%.2fs",
|
||||
"[gpu-encoder] encode ok: %s → %s (%d bytes) total=%.2fs transport=%s",
|
||||
mezzanine_path.name,
|
||||
output_path.name,
|
||||
size,
|
||||
time.time() - t_total,
|
||||
used_transport,
|
||||
)
|
||||
return {"job": job, "output_size": size, "output_path": str(output_path)}
|
||||
return {
|
||||
"job": job,
|
||||
"output_size": size,
|
||||
"output_path": str(output_path),
|
||||
"transport": used_transport,
|
||||
}
|
||||
|
||||
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
|
||||
@@ -226,17 +291,15 @@ 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 _post_sync(self, body: dict[str, Any], *, mezzanine_path: Path) -> dict[str, Any]:
|
||||
def _post_sync(self, body: dict[str, Any]) -> dict[str, Any]:
|
||||
url = f"{self.endpoint}/api/render/sync"
|
||||
req_timeout = body.get("timeout", self.sync_timeout) + 60
|
||||
payload = json.dumps(body).encode("utf-8")
|
||||
@@ -267,8 +330,6 @@ 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
|
||||
@@ -309,10 +370,33 @@ 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 (optional - storage may not be available in all envs)
|
||||
# OSS helpers (fallback)
|
||||
# ------------------------------------------------------------------
|
||||
def _upload_mezzanine(self, path: Path) -> tuple[str, str]:
|
||||
def _upload_mezzanine_to_oss(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
|
||||
@@ -326,7 +410,6 @@ 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
|
||||
|
||||
@@ -365,6 +448,7 @@ 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),
|
||||
vcodec=getattr(settings, "gpu_encode_vcodec", "h264_nvenc"),
|
||||
@@ -395,6 +479,5 @@ 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
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
# SKIP_NOTIFY - 跳过通知 (true/false, 默认 false)
|
||||
# CI_NOTIFY_WEBHOOK - 通知 Webhook URL
|
||||
#
|
||||
# STAGING_SSH_HOST - Staging 服务器 SSH 地址 (默认 47.98.113.167,CI runner 在独立 CI 机器)
|
||||
# STAGING_SSH_HOST - Staging 服务器 SSH 地址 (默认 116.62.226.203(staging业务机公网IP))
|
||||
# STAGING_SSH_USER - SSH 用户名 (默认 root)
|
||||
# STAGING_SSH_PORT - SSH 端口 (默认 22222,公网;内网 VNC 用 10.0.0.2:22)
|
||||
# STAGING_SSH_PORT - SSH 端口 (默认 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:-47.98.113.167}"
|
||||
STAGING_SSH_HOST="${STAGING_SSH_HOST:-116.62.226.203}"
|
||||
STAGING_SSH_USER="${STAGING_SSH_USER:-root}"
|
||||
STAGING_SSH_PORT="${STAGING_SSH_PORT:-22222}"
|
||||
STAGING_SSH_PORT="${STAGING_SSH_PORT:-22}"
|
||||
|
||||
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
|
||||
REGISTRY_USER="${REGISTRY_USER:-xiaoxia}"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
验证点:
|
||||
1. UnifiedRenderService 不再有 is_preview 参数
|
||||
2. 所有渲染统一使用 fast preset + CRF 23(#1758 优化:medium→fast)
|
||||
2. 所有渲染统一使用 veryfast preset + CRF 23(#2063 优化:fast→veryfast)
|
||||
3. RenderAdapter 统一执行校验和缩略图生成
|
||||
4. generation.py 并行下载逻辑(保留)
|
||||
"""
|
||||
@@ -51,7 +51,7 @@ class TestUnifiedRenderServiceNoPreviewParam:
|
||||
|
||||
|
||||
class TestUnifiedFFmpegPreset:
|
||||
"""所有渲染统一使用 fast preset + CRF 23(#1758 渲染加速优化)。"""
|
||||
"""所有渲染统一使用 veryfast preset + CRF 23(#2063 渲染加速优化)。"""
|
||||
|
||||
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_fast_crf23(self, mock_run):
|
||||
def test_execute_ffmpeg_uses_veryfast_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 fast (#1758: changed from medium to fast for rendering speed)
|
||||
# Check preset is veryfast (#2063: changed from fast to veryfast for 38% speedup)
|
||||
preset_idx = cmd.index("-preset")
|
||||
assert cmd[preset_idx + 1] == "fast", f"Expected fast, got {cmd[preset_idx + 1]}"
|
||||
assert cmd[preset_idx + 1] == "veryfast", f"Expected veryfast, got {cmd[preset_idx + 1]}"
|
||||
|
||||
# Check crf is 23 (no conditional)
|
||||
crf_idx = cmd.index("-crf")
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
1. 集中编码常量正确定义,支持环境变量覆盖
|
||||
2. 所有渲染路径(_execute_ffmpeg / _render_pass_through / normalize_video / processor)
|
||||
使用统一的编码参数
|
||||
3. preset 从 medium → fast,确保渲染速度提升
|
||||
3. preset 从 medium → veryfast(#2063 优化 fast→veryfast,再提速 38%)
|
||||
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 == "fast"
|
||||
assert FFMPEG_ENCODE_PRESET == "veryfast"
|
||||
|
||||
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 == "fast"
|
||||
assert FFMPEG_ENCODE_PRESET == "veryfast"
|
||||
|
||||
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_fast_preset(self):
|
||||
"""_execute_ffmpeg 应使用 fast preset."""
|
||||
def test_execute_uses_veryfast_preset(self):
|
||||
"""_execute_ffmpeg 应使用 veryfast preset."""
|
||||
cmd = self._get_execute_command()
|
||||
idx = cmd.index("-preset")
|
||||
assert cmd[idx + 1] == "fast"
|
||||
assert cmd[idx + 1] == "veryfast"
|
||||
|
||||
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_fast_preset(self):
|
||||
"""_render_pass_through 命令应包含 fast preset."""
|
||||
def test_passthrough_command_contains_veryfast_preset(self):
|
||||
"""_render_pass_through 命令应包含 veryfast preset."""
|
||||
# 通过源码检查确认参数已替换
|
||||
import inspect
|
||||
|
||||
@@ -200,8 +200,8 @@ class TestRenderPassThroughEncoding:
|
||||
class TestNormalizeVideoEncoding:
|
||||
"""测试 normalize_video 使用正确的编码参数."""
|
||||
|
||||
def test_normalize_uses_fast_preset(self):
|
||||
"""normalize_video 应使用 fast preset."""
|
||||
def test_normalize_uses_veryfast_preset(self):
|
||||
"""normalize_video 应使用 veryfast preset."""
|
||||
import inspect
|
||||
|
||||
from video_processing.ffmpeg_utils import normalize_video
|
||||
|
||||
+163
-41
@@ -38,6 +38,7 @@ 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",
|
||||
@@ -119,7 +120,6 @@ 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,9 +129,7 @@ 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}, mezzanine_path=Path("/tmp/x")
|
||||
)
|
||||
client._post_sync({"inputs": {}, "ffmpeg_args": [], "output_url": "", "timeout": 10})
|
||||
|
||||
def test_http_4xx_raises(self, client):
|
||||
err = urllib.error.HTTPError(
|
||||
@@ -139,65 +137,80 @@ 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}, mezzanine_path=Path("/tmp/x")
|
||||
)
|
||||
client._post_sync({"inputs": {}, "ffmpeg_args": [], "output_url": "", "timeout": 10})
|
||||
|
||||
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}, mezzanine_path=Path("/tmp/x")
|
||||
)
|
||||
client._post_sync({"inputs": {}, "ffmpeg_args": [], "output_url": "", "timeout": 10})
|
||||
|
||||
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}, mezzanine_path=Path("/tmp/x")
|
||||
)
|
||||
client._post_sync({"inputs": {}, "ffmpeg_args": [], "output_url": "", "timeout": 10})
|
||||
|
||||
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}, mezzanine_path=Path("/tmp/x")
|
||||
)
|
||||
client._post_sync({"inputs": {}, "ffmpeg_args": [], "output_url": "", "timeout": 10})
|
||||
|
||||
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}, mezzanine_path=Path("/tmp/x")
|
||||
)
|
||||
res = client._post_sync({"inputs": {}, "ffmpeg_args": [], "output_url": "", "timeout": 10})
|
||||
assert res["status"] == "completed"
|
||||
assert "uploaded=false" in caplog.text
|
||||
|
||||
|
||||
# ── Relay URL builders ──────────────────────────────────────────────
|
||||
class TestRelayUrl:
|
||||
def test_put_url_uses_external_base(self, client):
|
||||
url = client._relay_put_url("abc123", "secret!")
|
||||
def test_result_put_url_uses_external_base(self, client):
|
||||
"""P4000 回传编码结果的 PUT URL 应走外部 base(Tailscale 可达)。"""
|
||||
url = client._result_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_internal_url_uses_internal_base(self, client):
|
||||
url = client._relay_internal_url("abc123", "s")
|
||||
def test_result_get_url_uses_internal_base(self, client):
|
||||
"""Worker 下载结果使用 internal base(Docker DNS 直连)。"""
|
||||
url = client._result_get_url("abc123", "s")
|
||||
assert url.startswith("http://api-internal:8000/api/v1/internal/gpu-relay/abc123")
|
||||
assert "/mezzanine/" not in url
|
||||
|
||||
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")
|
||||
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")
|
||||
assert put.startswith("http://api.example.com/")
|
||||
assert internal == put
|
||||
assert get == put
|
||||
|
||||
def test_encode_uses_different_put_and_get_urls(self, client):
|
||||
put_url = client._relay_put_url("k", "test-secret")
|
||||
get_url = client._relay_internal_url("k", "test-secret")
|
||||
put_url = client._result_put_url("k", "test-secret")
|
||||
get_url = client._result_get_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:
|
||||
@@ -266,7 +279,7 @@ class TestEncodeMezzanine:
|
||||
mezz.write_bytes(b"M" * 100)
|
||||
out = tmp_path / "out" / "final.mp4"
|
||||
with (
|
||||
mock.patch.object(client, "_upload_mezzanine", return_value=("http://oss/signed", "osskey1")),
|
||||
mock.patch.object(client, "_upload_mezzanine_to_oss", return_value=("http://oss/signed", "osskey1")),
|
||||
mock.patch.object(
|
||||
client,
|
||||
"_post_sync",
|
||||
@@ -298,7 +311,7 @@ class TestEncodeMezzanine:
|
||||
mezz.write_bytes(b"M")
|
||||
out = tmp_path / "o.mp4"
|
||||
with (
|
||||
mock.patch.object(client, "_upload_mezzanine", return_value=("http://oss/u", "k")),
|
||||
mock.patch.object(client, "_upload_mezzanine_to_oss", return_value=("http://oss/u", "k")),
|
||||
mock.patch.object(
|
||||
client,
|
||||
"_post_sync",
|
||||
@@ -325,7 +338,7 @@ class TestEncodeMezzanine:
|
||||
mezz.write_bytes(b"x")
|
||||
out = tmp_path / "o.mp4"
|
||||
with (
|
||||
mock.patch.object(c, "_upload_mezzanine", return_value=("http://oss/u", "k")),
|
||||
mock.patch.object(c, "_upload_mezzanine_to_oss", 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,
|
||||
@@ -354,7 +367,7 @@ class TestEncodeMezzanine:
|
||||
mezz = tmp_path / "m.mp4"
|
||||
mezz.write_bytes(b"x")
|
||||
with (
|
||||
mock.patch.object(client, "_upload_mezzanine", return_value=("http://oss/u", "k")),
|
||||
mock.patch.object(client, "_upload_mezzanine_to_oss", return_value=("http://oss/u", "k")),
|
||||
mock.patch.object(client, "_post_sync", side_effect=RuntimeError("boom")),
|
||||
mock.patch.object(client, "_delete_oss"),
|
||||
):
|
||||
@@ -365,7 +378,7 @@ class TestEncodeMezzanine:
|
||||
mezz = tmp_path / "m.mp4"
|
||||
mezz.write_bytes(b"x")
|
||||
with (
|
||||
mock.patch.object(client, "_upload_mezzanine", return_value=("http://oss/u", "k")),
|
||||
mock.patch.object(client, "_upload_mezzanine_to_oss", return_value=("http://oss/u", "k")),
|
||||
mock.patch.object(client, "_post_sync", side_effect=GpuEncodeError("direct fail")),
|
||||
mock.patch.object(client, "_delete_oss"),
|
||||
):
|
||||
@@ -376,7 +389,7 @@ class TestEncodeMezzanine:
|
||||
mezz = tmp_path / "m.mp4"
|
||||
mezz.write_bytes(b"x")
|
||||
with (
|
||||
mock.patch.object(client, "_upload_mezzanine", return_value=("http://oss/u", "ossk")),
|
||||
mock.patch.object(client, "_upload_mezzanine_to_oss", 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"),
|
||||
@@ -416,7 +429,7 @@ class TestOssHelpers:
|
||||
|
||||
with mock.patch("builtins.__import__", side_effect=fake_import):
|
||||
with pytest.raises(GpuEncodeError, match="storage service unavailable"):
|
||||
client._upload_mezzanine(mezz)
|
||||
client._upload_mezzanine_to_oss(mezz)
|
||||
finally:
|
||||
if saved is not None:
|
||||
sys.modules["packages.shared.storage"] = saved
|
||||
@@ -428,7 +441,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(mezz)
|
||||
client._upload_mezzanine_to_oss(mezz)
|
||||
|
||||
def test_upload_bucket_none(self, client, tmp_path):
|
||||
mezz = tmp_path / "m.mp4"
|
||||
@@ -439,7 +452,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(mezz)
|
||||
client._upload_mezzanine_to_oss(mezz)
|
||||
|
||||
def test_upload_failure_raises(self, client, tmp_path):
|
||||
mezz = tmp_path / "m.mp4"
|
||||
@@ -451,7 +464,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(mezz)
|
||||
client._upload_mezzanine_to_oss(mezz)
|
||||
|
||||
def test_upload_success(self, client, tmp_path):
|
||||
mezz = tmp_path / "m.mp4"
|
||||
@@ -462,7 +475,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(mezz)
|
||||
url, key = client._upload_mezzanine_to_oss(mezz)
|
||||
assert url.startswith("https://oss/signed")
|
||||
assert key.startswith("tmp/gpu-mezzanine/") and key.endswith(".mp4")
|
||||
svc.upload_file.assert_called_once()
|
||||
@@ -493,6 +506,113 @@ 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):
|
||||
@@ -549,6 +669,7 @@ 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}):
|
||||
@@ -557,6 +678,7 @@ 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 (
|
||||
|
||||
Reference in New Issue
Block a user