diff --git a/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx b/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx index 9eabf3a8a..65abecc01 100644 --- a/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx +++ b/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx @@ -15,6 +15,7 @@ import React, { useRef, useEffect, useCallback } from "react" import { PlayCircleOutlined, LoadingOutlined } from "@ant-design/icons" import type { PreviewResult, PreviewStatus } from "../hooks/useStep5Preview" import type { TitleSettings } from "../types" +import { drawTitleOnCanvas } from "../utils/drawTitleOnCanvas" interface PreviewVideoPanelProps { previewStatus: PreviewStatus @@ -29,130 +30,6 @@ interface PreviewVideoPanelProps { titleSettings?: TitleSettings } -/* ── 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 上绘制标题文字(含描边/阴影/多行居中) - * - * Canvas 已通过 CSS 定位到视频实际渲染位置, - * 坐标系基于 Canvas 自身尺寸,居中直接使用 w/2。 - * - * @param ctx canvas 上下文 - * @param w canvas CSS 宽度(= 视频渲染宽度) - * @param h canvas CSS 高度(= 视频渲染高度) - * @param text 标题文字 - * @param settings 标题样式 - * @param paddingX 左右边距(px),与 ASS 的 MarginL/MarginR 对应 - * @param position "top" | "center" | "bottom" - * @param topOffset 顶部/底部偏移量 - */ -function drawTitleOnCanvas( - ctx: CanvasRenderingContext2D, - w: number, - h: number, - text: string, - settings: TitleSettings, - paddingX: number, - position: string, - topOffset: number, -) { - const dpr = window.devicePixelRatio || 1 - - // 设置 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, 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": - startY = topOffset - break - case "center": - startY = (h - totalTextHeight) / 2 + lineHeight / 2 - break - case "bottom": - default: - startY = h - topOffset - totalTextHeight + lineHeight / 2 - break - } - - // 居中 x = w/2(Canvas 已定位到视频位置,无需额外偏移) - 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 - } -} - /* ── 组件 ── */ export const PreviewVideoPanel: React.FC = ({ diff --git a/apps/web/src/pages/generate/components/Step4TitleSettings.tsx b/apps/web/src/pages/generate/components/Step4TitleSettings.tsx index 698dbe5d0..c7bd31526 100644 --- a/apps/web/src/pages/generate/components/Step4TitleSettings.tsx +++ b/apps/web/src/pages/generate/components/Step4TitleSettings.tsx @@ -8,6 +8,7 @@ import type { TitleSettings } from "../types" import { useStep4Title } from "../hooks/useStep4Title" import AiTitleGenerator from "./title/AiTitleGenerator" import TitleStylePanel from "./title/TitleStylePanel" +import TitlePreviewCanvas from "./title/TitlePreviewCanvas" interface Step4TitleSettingsProps { titleSettings: TitleSettings @@ -111,6 +112,11 @@ const Step4TitleSettings: React.FC = (props) => { )} + {/* 标题实时预览 - 仅当有标题文字时显示 */} + {t.titleSettings.title && ( + + )} + {/* 样式面板始终可见,两种模式下都可调整标题展示样式 */} = ({ + titleText, + titleSettings, + videoRatio = "16:9", +}) => { + const containerRef = useRef(null) + const canvasRef = useRef(null) + + // 字体加载状态 + const fontLoadedRef = useRef(false) + + /** 在 Canvas 上绘制标题 */ + const draw = () => { + const canvas = canvasRef.current + const container = containerRef.current + if (!canvas || !container) return + const ctx = canvas.getContext("2d") + if (!ctx) return + + const rect = container.getBoundingClientRect() + if (rect.width <= 0 || rect.height <= 0) return + + const w = rect.width + const h = rect.height + + // 更新 Canvas CSS 尺寸匹配容器 + canvas.style.width = `${w}px` + canvas.style.height = `${h}px` + + drawTitleOnCanvas(ctx, w, h, titleText, titleSettings, 24, titleSettings.position, 40) + } + + // 字体加载:确保 measureText 使用正确字体 + useEffect(() => { + let cancelled = false + fontLoadedRef.current = false + + const fontWeight = titleSettings.bold ? "bold" : "" + const fontStyle = titleSettings.italic ? "italic" : "" + const fontSpec = + `${fontStyle} ${fontWeight} ${titleSettings.size}px "${titleSettings.font}"`.trim() + + const onFontReady = () => { + if (cancelled) return + fontLoadedRef.current = true + requestAnimationFrame(() => { + if (!cancelled) draw() + }) + } + + // 用 FontFace API 加载字体,失败则降级 + try { + const fontFace = new FontFace(titleSettings.font, `local("${titleSettings.font}")`) + fontFace + .load() + .then(() => { + if (!cancelled) { + ;(document.fonts as any).add(fontFace) + onFontReady() + } + }) + .catch(() => { + // 字体加载失败,用默认字体继续 + onFontReady() + }) + } catch { + // FontFace 不可用,直接绘制 + onFontReady() + } + + // 同时检查 document.fonts 是否已有该字体 + if (document.fonts.check(fontSpec)) { + onFontReady() + return + } + + return () => { + cancelled = true + } + }, [titleSettings.font, titleSettings.size, titleSettings.bold, titleSettings.italic]) + + // props 变化时重绘 + useEffect(() => { + requestAnimationFrame(draw) + }, [titleText, titleSettings]) + + // ResizeObserver 监听容器尺寸变化 + useEffect(() => { + const container = containerRef.current + if (!container) return + + const observer = new ResizeObserver(() => { + requestAnimationFrame(draw) + }) + observer.observe(container) + + return () => observer.disconnect() + }, []) + + return ( +
+
+ 预览效果 +
+
+ +
+
+ ) +} + +export default TitlePreviewCanvas diff --git a/apps/web/src/pages/generate/utils/drawTitleOnCanvas.ts b/apps/web/src/pages/generate/utils/drawTitleOnCanvas.ts new file mode 100644 index 000000000..62f845ac9 --- /dev/null +++ b/apps/web/src/pages/generate/utils/drawTitleOnCanvas.ts @@ -0,0 +1,126 @@ +/** + * Canvas 标题绘制工具函数(共享模块) + * + * 供 PreviewVideoPanel(预览视频标题叠加)和 TitlePreviewCanvas(标题设置实时预览)共用。 + * 绘制行为与 ASS 字幕引擎一致:逐字换行、居中、描边/阴影。 + */ +import type { TitleSettings } from "../types" + +/** + * 将文本按 maxWidth 逐字换行,返回行数组。 + * 与 ASS 字幕引擎的逐字换行行为一致。 + */ +export 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 w canvas CSS 宽度 + * @param h canvas CSS 高度 + * @param text 标题文字 + * @param settings 标题样式 + * @param paddingX 左右边距(px),与 ASS 的 MarginL/MarginR 对应 + * @param position "top" | "center" | "bottom" + * @param topOffset 顶部/底部偏移量 + */ +export function drawTitleOnCanvas( + ctx: CanvasRenderingContext2D, + w: number, + h: number, + text: string, + settings: TitleSettings, + paddingX: number, + position: string, + topOffset: number, +) { + const dpr = window.devicePixelRatio || 1 + + // 设置 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, 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": + startY = topOffset + break + case "center": + startY = (h - totalTextHeight) / 2 + lineHeight / 2 + break + case "bottom": + default: + startY = h - topOffset - totalTextHeight + lineHeight / 2 + break + } + + // 居中 x = w/2 + 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 + } +}