feat: 用 Canvas 替换 CSS overlay 实现标题预览 #1246

Merged
xiaoxia merged 2 commits from feat/title-preview-canvas into develop 2026-08-05 19:04:18 +08:00
@@ -1,9 +1,9 @@
/**
* 右侧预览视频面板
* Step3 生成预览后常驻显示预览视频
* Step4+ 显示标题实时预览
* Step4+ 用 Canvas 绘制标题预览(替代 CSS overlay,与 ASS 渲染行为一致)
*/
import React from "react"
import React, { useRef, useEffect, useCallback } from "react"
import { PlayCircleOutlined, LoadingOutlined } from "@ant-design/icons"
import type { PreviewResult, PreviewStatus } from "../hooks/useStep3Preview"
import type { TitleSettings } from "../types"
@@ -21,44 +21,131 @@ interface PreviewVideoPanelProps {
titleSettings?: TitleSettings
}
/** 根据 position 返回 absolute 定位样式(叠加在视频上) */
const positionOverlayStyle = (position: string): React.CSSProperties => {
/* ── Canvas 绘制工具函数 ── */
/**
* 将文本按 maxWidth 逐字换行,返回行数组。
* 与 ASS 字幕引擎的逐字换行行为一致。
*/
function wrapText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] {
const lines: string[] = []
let currentLine = ""
for (const char of text) {
const testLine = currentLine + char
if (ctx.measureText(testLine).width > maxWidth && currentLine) {
lines.push(currentLine)
currentLine = char
} else {
currentLine = testLine
}
}
if (currentLine) lines.push(currentLine)
return lines
}
/**
* 在 canvas 上绘制标题文字(含描边/阴影/多行居中)
* @param ctx canvas 上下文
* @param canvasWidth canvas CSS 宽度
* @param canvasHeight canvas CSS 高度
* @param text 标题文字
* @param settings 标题样式
* @param paddingX 左右边距(px),与 ASS 的 MarginL/MarginR 对应
* @param position "top" | "center" | "bottom"
* @param topOffset 顶部/底部偏移量
* @param fontSizeScale 字号缩放(no-video 模式下缩小)
*/
function drawTitleOnCanvas(
ctx: CanvasRenderingContext2D,
canvasWidth: number,
canvasHeight: number,
text: string,
settings: TitleSettings,
paddingX: number,
position: string,
topOffset: number,
fontSizeScale = 1,
) {
const dpr = window.devicePixelRatio || 1
const w = canvasWidth
const h = canvasHeight
// 设置 canvas 物理像素尺寸(高清屏适配)
ctx.canvas.width = Math.round(w * dpr)
ctx.canvas.height = Math.round(h * dpr)
ctx.scale(dpr, dpr)
// 清除
ctx.clearRect(0, 0, w, h)
// 可用宽度 = 总宽 - 左右边距
const availableWidth = w - paddingX * 2
if (availableWidth <= 0) return
// 字体设置
const fontSize = Math.round(Math.min(settings.size * fontSizeScale, 36))
const fontWeight = settings.bold ? "bold" : "normal"
const fontStyle = settings.italic ? "italic" : "normal"
ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px "${settings.font}"`
// 文字属性
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.fillStyle = settings.color
const lineHeight = fontSize * 1.4
// 描边 & 阴影
if (settings.stroke) {
ctx.strokeStyle = "rgba(0,0,0,0.6)"
ctx.lineWidth = 2
ctx.lineJoin = "round"
}
if (settings.shadow) {
ctx.shadowColor = "rgba(0,0,0,0.7)"
ctx.shadowBlur = 4
ctx.shadowOffsetX = 2
ctx.shadowOffsetY = 2
}
// 换行
const displayText = text && text.trim() ? text : "请选择或输入标题"
const lines = wrapText(ctx, displayText, availableWidth)
// 起始 Y:根据 position 计算
const totalTextHeight = lines.length * lineHeight
let startY: number
switch (position) {
case "top":
return { top: 16, left: 0, right: 0 }
startY = topOffset
break
case "center":
return { top: "50%", left: 0, right: 0, transform: "translateY(-50%)" }
startY = (h - totalTextHeight) / 2 + lineHeight / 2
break
case "bottom":
default:
return { bottom: 56, left: 0, right: 0 } // 56px = 给视频控制条留空间
startY = h - topOffset - totalTextHeight + lineHeight / 2
break
}
// 逐行绘制
const x = w / 2
lines.forEach((line, i) => {
const y = startY + i * lineHeight
if (settings.stroke) ctx.strokeText(line, x, y)
ctx.fillText(line, x, y)
})
// 重置 shadow(避免影响后续绘制)
if (settings.shadow) {
ctx.shadowColor = "transparent"
ctx.shadowBlur = 0
ctx.shadowOffsetX = 0
ctx.shadowOffsetY = 0
}
}
/** 根据 TitleSettings 生成文字样式 */
const buildTitleStyle = (settings: TitleSettings): React.CSSProperties => {
const style: React.CSSProperties = {
fontFamily: settings.font,
fontSize: Math.min(settings.size, 36), // 预览区域限制最大字号
color: settings.color,
fontWeight: settings.bold ? 700 : 400,
fontStyle: settings.italic ? "italic" : "normal",
textAlign: "center",
lineHeight: 1.4,
wordBreak: "break-word",
overflowWrap: "break-word",
padding: "0 12px",
display: "block",
width: "100%",
boxSizing: "border-box",
}
if (settings.stroke) {
style.WebkitTextStroke = "1px rgba(0,0,0,0.6)"
}
if (settings.shadow) {
style.textShadow = "2px 2px 4px rgba(0,0,0,0.7)"
}
return style
}
/* ── 组件 ── */
export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
previewStatus,
@@ -75,6 +162,99 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
const isError = previewStatus === "error"
const showTitlePreview = !!titleSettings
// video 模式 canvas ref
const canvasRef = useRef<HTMLCanvasElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
// no-video 模式 canvas ref
const noVideoCanvasRef = useRef<HTMLCanvasElement>(null)
const noVideoContainerRef = useRef<HTMLDivElement>(null)
/** 在 video canvas 上绘制标题 */
const drawVideoTitle = useCallback(() => {
const canvas = canvasRef.current
const container = containerRef.current
if (!canvas || !container || !titleSettings) return
const ctx = canvas.getContext("2d")
if (!ctx) return
const rect = container.getBoundingClientRect()
if (rect.width <= 0 || rect.height <= 0) return
drawTitleOnCanvas(
ctx,
rect.width,
rect.height,
titleText || "",
titleSettings,
40,
titleSettings.position,
60,
)
}, [titleText, titleSettings])
/** 在 no-video canvas 上绘制标题 */
const drawNoVideoTitle = useCallback(() => {
const canvas = noVideoCanvasRef.current
const container = noVideoContainerRef.current
if (!canvas || !container || !titleSettings) return
const ctx = canvas.getContext("2d")
if (!ctx) return
const rect = container.getBoundingClientRect()
if (rect.width <= 0 || rect.height <= 0) return
// no-video 容器较窄,字号缩小 + 减少边距
drawTitleOnCanvas(
ctx,
rect.width,
rect.height,
titleText || "",
titleSettings,
20,
titleSettings.position,
30,
0.75,
)
}, [titleText, titleSettings])
// video 模式:ResizeObserver 监听容器尺寸变化 → 重绘
useEffect(() => {
if (!showTitlePreview || !hasPreview) return
const container = containerRef.current
if (!container) return
const observer = new ResizeObserver(() => {
drawVideoTitle()
})
observer.observe(container)
// 初始绘制(延迟一帧等容器渲染完成)
requestAnimationFrame(drawVideoTitle)
return () => observer.disconnect()
}, [showTitlePreview, hasPreview, drawVideoTitle])
// no-video 模式:ResizeObserver 监听容器尺寸变化 → 重绘
useEffect(() => {
if (!showTitlePreview || hasPreview || isError) return
const container = noVideoContainerRef.current
if (!container) return
const observer = new ResizeObserver(() => {
drawNoVideoTitle()
})
observer.observe(container)
requestAnimationFrame(drawNoVideoTitle)
return () => observer.disconnect()
}, [showTitlePreview, hasPreview, isError, drawNoVideoTitle])
// video 加载完成后重绘
const handleVideoLoaded = useCallback(() => {
if (showTitlePreview) {
requestAnimationFrame(drawVideoTitle)
}
}, [showTitlePreview, drawVideoTitle])
return (
<div className="xx-generate-preview">
<div className="xx-preview-header">
@@ -125,29 +305,30 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
</div>
)}
{/* 预览成功 + 标题叠加 */}
{/* 预览成功 + Canvas 标题叠加 */}
{hasPreview && (
<div style={{ position: "relative" }}>
<div ref={containerRef} style={{ position: "relative" }}>
<div className="xx-preview-video">
<video src={previewResult.videoUrl} controls preload="metadata" />
<video
src={previewResult.videoUrl}
controls
preload="metadata"
onLoadedMetadata={handleVideoLoaded}
/>
</div>
{/* 标题叠加在视频画面上 */}
{showTitlePreview && (
<div
<canvas
ref={canvasRef}
style={{
position: "absolute",
...positionOverlayStyle(titleSettings!.position),
top: 0,
left: 0,
width: "100%",
display: "flex",
justifyContent: "center",
height: "100%",
zIndex: 1,
pointerEvents: "none",
}}
>
<div style={buildTitleStyle(titleSettings!)}>
{titleText && titleText.trim() ? titleText : "请选择或输入标题"}
</div>
</div>
/>
)}
</div>
)}
@@ -155,19 +336,25 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
{/* 无视频时的标题预览(idle/loading 状态下显示,error 状态下隐藏) */}
{!hasPreview && !isError && showTitlePreview && (
<div
ref={noVideoContainerRef}
style={{
background: "rgba(0,0,0,0.3)",
borderRadius: 8,
minHeight: 64,
marginTop: 12,
display: "flex",
alignItems: "center",
justifyContent: "center",
position: "relative",
}}
>
<div style={buildTitleStyle(titleSettings!)}>
{titleText && titleText.trim() ? titleText : "请选择或输入标题"}
</div>
<canvas
ref={noVideoCanvasRef}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
}}
/>
</div>
)}