From 69ccb7f15d44549b2028d1bcd74c9f70b3fa88cc Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 6 Aug 2026 10:16:41 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20Canvas=E6=A0=87=E9=A2=98=E5=B1=85?= =?UTF-8?q?=E4=B8=AD=E4=BF=AE=E5=A4=8D+=E5=AD=97=E4=BD=93=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E7=AD=89=E5=BE=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 使用video元素实际渲染尺寸(clientWidth/clientHeight)替代容器尺寸 - 添加document.fonts.ready等待字体加载后再绘制 - 字体加载前跳过绘制避免measureText用fallback字体计算 --- .../generate/components/PreviewVideoPanel.tsx | 63 +++++++++++++++---- 1 file changed, 51 insertions(+), 12 deletions(-) diff --git a/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx b/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx index 009bb11bc..4dd701a95 100644 --- a/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx +++ b/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx @@ -3,7 +3,7 @@ * Step3 生成预览后常驻显示预览视频 * Step4+ 用 Canvas 绘制标题预览(替代 CSS overlay,与 ASS 渲染行为一致) */ -import React, { useRef, useEffect, useCallback } from "react" +import React, { useRef, useEffect, useCallback, useState } from "react" import { PlayCircleOutlined, LoadingOutlined } from "@ant-design/icons" import type { PreviewResult, PreviewStatus } from "../hooks/useStep3Preview" import type { TitleSettings } from "../types" @@ -46,8 +46,8 @@ function wrapText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number) /** * 在 canvas 上绘制标题文字(含描边/阴影/多行居中) * @param ctx canvas 上下文 - * @param canvasWidth canvas CSS 宽度 - * @param canvasHeight canvas CSS 高度 + * @param canvasWidth canvas CSS 宽度(绘制区域宽度) + * @param canvasHeight canvas CSS 高度(绘制区域高度) * @param text 标题文字 * @param settings 标题样式 * @param paddingX 左右边距(px),与 ASS 的 MarginL/MarginR 对应 @@ -128,7 +128,7 @@ function drawTitleOnCanvas( break } - // 逐行绘制 + // 逐行绘制(水平居中于绘制区域) const x = w / 2 lines.forEach((line, i) => { const y = startY + i * lineHeight @@ -162,13 +162,17 @@ export const PreviewVideoPanel: React.FC = ({ const isError = previewStatus === "error" const showTitlePreview = !!titleSettings - // video 模式 canvas ref + // video 模式 refs const canvasRef = useRef(null) const containerRef = useRef(null) - // no-video 模式 canvas ref + const videoRef = useRef(null) + // no-video 模式 refs const noVideoCanvasRef = useRef(null) const noVideoContainerRef = useRef(null) + // 字体加载状态 + const [fontLoaded, setFontLoaded] = useState(false) + /** 在 video canvas 上绘制标题 */ const drawVideoTitle = useCallback(() => { const canvas = canvasRef.current @@ -180,17 +184,30 @@ export const PreviewVideoPanel: React.FC = ({ const rect = container.getBoundingClientRect() if (rect.width <= 0 || rect.height <= 0) return + // 字体加载前跳过(等 fontLoaded 触发重绘) + if (!fontLoaded) return + + // 优先使用 video 元素实际渲染尺寸,确保标题居中于视频画面 + // 而非容器尺寸(容器可能比 video 元素更宽/更高) + const videoEl = videoRef.current + let drawW = rect.width + let drawH = rect.height + if (videoEl && videoEl.clientWidth > 0 && videoEl.clientHeight > 0) { + drawW = videoEl.clientWidth + drawH = videoEl.clientHeight + } + drawTitleOnCanvas( ctx, - rect.width, - rect.height, + drawW, + drawH, titleText || "", titleSettings, 40, titleSettings.position, 60, ) - }, [titleText, titleSettings]) + }, [titleText, titleSettings, fontLoaded]) /** 在 no-video canvas 上绘制标题 */ const drawNoVideoTitle = useCallback(() => { @@ -203,6 +220,9 @@ export const PreviewVideoPanel: React.FC = ({ const rect = container.getBoundingClientRect() if (rect.width <= 0 || rect.height <= 0) return + // 字体加载前跳过 + if (!fontLoaded) return + // no-video 容器较窄,字号缩小 + 减少边距 drawTitleOnCanvas( ctx, @@ -215,7 +235,7 @@ export const PreviewVideoPanel: React.FC = ({ 30, 0.75, ) - }, [titleText, titleSettings]) + }, [titleText, titleSettings, fontLoaded]) // video 模式:ResizeObserver 监听容器尺寸变化 → 重绘 useEffect(() => { @@ -227,7 +247,6 @@ export const PreviewVideoPanel: React.FC = ({ drawVideoTitle() }) observer.observe(container) - // 初始绘制(延迟一帧等容器渲染完成) requestAnimationFrame(drawVideoTitle) return () => observer.disconnect() @@ -248,6 +267,22 @@ export const PreviewVideoPanel: React.FC = ({ return () => observer.disconnect() }, [showTitlePreview, hasPreview, isError, drawNoVideoTitle]) + // 字体加载完成后重绘(Canvas measureText 依赖字体已加载) + useEffect(() => { + if (!showTitlePreview) return + let cancelled = false + document.fonts.ready.then(() => { + if (cancelled) return + // 短暂延迟确保字体完全可用 + setTimeout(() => { + if (!cancelled) setFontLoaded(true) + }, 50) + }) + return () => { + cancelled = true + } + }, [showTitlePreview]) + // video 加载完成后重绘 const handleVideoLoaded = useCallback(() => { if (showTitlePreview) { @@ -285,7 +320,10 @@ export const PreviewVideoPanel: React.FC = ({
-
+
)} @@ -310,6 +348,7 @@ export const PreviewVideoPanel: React.FC = ({