Compare commits

...

3 Commits

Author SHA1 Message Date
xiaoxia 97ad0ae2e5 fix(p0): 固化 GPU_ENCODE 到 .env.staging + MediaKit 抽帧 403 修复 (#2069)
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 5s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 13s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m28s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m24s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m5s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m35s
CI/CD Pipeline / Build Staging API Image (push) Successful in 25s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 35s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 34s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m51s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m8s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m2s
CI/CD Pipeline / Integration Tests (push) Successful in 3m39s
CI/CD Pipeline / Validate - Style (push) Successful in 4m40s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 4m22s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m20s
AI Code Review / AI Code Review (pull_request) Successful in 7m19s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m39s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m41s
CI/CD Pipeline / Unit Tests (push) Successful in 10m28s
CI/CD Pipeline / Validate - Security (push) Successful in 11m20s
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-27 21:42:18 +08:00
xiaoxia 59c05148ab fix(p0): finalize 400(extra_meta漏传)+ 查重失效(fingerprint_chunks漏存) (#2068)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 12s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 43s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 44s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 49s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 15s
CI/CD Pipeline / Build Staging API Image (push) Successful in 55s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 28s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 2m23s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m1s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m14s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m53s
CI/CD Pipeline / Integration Tests (push) Successful in 4m46s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m55s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 5m10s
CI/CD Pipeline / Validate - Style (push) Successful in 5m27s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m54s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m36s
AI Code Review / AI Code Review (pull_request) Successful in 6m54s
CI/CD Pipeline / Validate - Security (push) Successful in 10m48s
CI/CD Pipeline / Unit Tests (push) Successful in 11m47s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-27 20:47:12 +08:00
xiaoxia a00031e100 refactor(shared): 抽取标题模板为公共组件,智能剪辑/AI数字人复用 (#2067)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 14s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m17s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m46s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m16s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m25s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m24s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m11s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 4m8s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m35s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 4m59s
CI/CD Pipeline / Integration Tests (push) Successful in 5m4s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 59s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 6m43s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m59s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m27s
CI/CD Pipeline / Validate - Security (push) Successful in 9m4s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m10s
CI/CD Pipeline / PR Build API Image (pull_request) Failing after 10m10s
CI/CD Pipeline / CI Gate (pull_request) Failing after 3s
AI Code Review / AI Code Review (pull_request) Successful in 10m56s
CI/CD Pipeline / Unit Tests (push) Successful in 13m26s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-27 20:29:49 +08:00
19 changed files with 1736 additions and 353 deletions
@@ -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
+19
View File
@@ -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: "自定义" },
]
+64
View File
@@ -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: [],
}
+23 -22
View File
@@ -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}
@@ -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"
@@ -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 }) => {
+3 -7
View File
@@ -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
+5 -10
View File
@@ -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()
+15
View File
@@ -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 ""),
)