eaed4b4cbf
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web 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 / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / Check push changed paths (push) Successful in 7s
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 / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / 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 / PR Build API Image (pull_request) Successful in 30s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 24s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 30s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m45s
CI/CD Pipeline / Build Staging API Image (push) Successful in 33s
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 / 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 / CI Gate (pull_request) Successful in 3s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (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 / Build Staging Web Image (push) Successful in 33s
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
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
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m7s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 3m48s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m58s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m13s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m28s
CI/CD Pipeline / Validate - Style (push) Successful in 4m34s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m17s
AI Code Review / AI Code Review (pull_request) Successful in 7m3s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m0s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m42s
CI/CD Pipeline / Validate - Security (push) Successful in 10m29s
CI/CD Pipeline / Unit Tests (push) Successful in 10m36s
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 / 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
231 lines
6.9 KiB
TypeScript
231 lines
6.9 KiB
TypeScript
/**
|
||
* 标题迷你 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(() => {
|
||
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 的一半)
|
||
const scale = width / 360
|
||
const r = (v: number) => Math.round(v * scale)
|
||
|
||
// 字体
|
||
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()
|
||
// 恢复阴影
|
||
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
|
||
}, [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
|