feat: 用 Canvas 替换 CSS overlay 实现标题预览 #1246
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user