diff --git a/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx b/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx index 6f3f037e3..009bb11bc 100644 --- a/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx +++ b/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx @@ -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 = ({ previewStatus, @@ -75,6 +162,99 @@ export const PreviewVideoPanel: React.FC = ({ const isError = previewStatus === "error" const showTitlePreview = !!titleSettings + // video 模式 canvas ref + const canvasRef = useRef(null) + const containerRef = useRef(null) + // no-video 模式 canvas ref + const noVideoCanvasRef = useRef(null) + const noVideoContainerRef = useRef(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 (
@@ -125,29 +305,30 @@ export const PreviewVideoPanel: React.FC = ({
)} - {/* 预览成功 + 标题叠加 */} + {/* 预览成功 + Canvas 标题叠加 */} {hasPreview && ( -
+
-
- {/* 标题叠加在视频画面上 */} {showTitlePreview && ( -
-
- {titleText && titleText.trim() ? titleText : "请选择或输入标题"} -
-
+ /> )}
)} @@ -155,19 +336,25 @@ export const PreviewVideoPanel: React.FC = ({ {/* 无视频时的标题预览(idle/loading 状态下显示,error 状态下隐藏) */} {!hasPreview && !isError && showTitlePreview && (
-
- {titleText && titleText.trim() ? titleText : "请选择或输入标题"} -
+
)}