diff --git a/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx b/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx index 009bb11bc..d02fd88a3 100644 --- a/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx +++ b/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx @@ -46,14 +46,16 @@ 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 对应 * @param position "top" | "center" | "bottom" * @param topOffset 顶部/底部偏移量 * @param fontSizeScale 字号缩放(no-video 模式下缩小) + * @param offsetX 绘制区域左边缘相对于 canvas 的偏移(用于 video 居中时补偿 offsetLeft) + * @param offsetY 绘制区域上边缘相对于 canvas 的偏移(用于 video 居中时补偿 offsetTop) */ function drawTitleOnCanvas( ctx: CanvasRenderingContext2D, @@ -65,6 +67,8 @@ function drawTitleOnCanvas( position: string, topOffset: number, fontSizeScale = 1, + offsetX = 0, + offsetY = 0, ) { const dpr = window.devicePixelRatio || 1 const w = canvasWidth @@ -128,10 +132,10 @@ function drawTitleOnCanvas( break } - // 逐行绘制 - const x = w / 2 + // 逐行绘制(offsetX/offsetY 补偿 video 在容器内的居中偏移) + const x = offsetX + w / 2 lines.forEach((line, i) => { - const y = startY + i * lineHeight + const y = offsetY + startY + i * lineHeight if (settings.stroke) ctx.strokeText(line, x, y) ctx.fillText(line, x, y) }) @@ -162,15 +166,21 @@ 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) + // 字体加载状态(ref 供 draw 回调同步读取,无需 state 避免触发不必要的重渲染) + const fontLoadedRef = useRef(false) + /** 在 video canvas 上绘制标题 */ const drawVideoTitle = useCallback(() => { + // 通过 ref 读取字体状态,避免 fontLoaded 进入依赖数组 + if (!fontLoadedRef.current) return const canvas = canvasRef.current const container = containerRef.current if (!canvas || !container || !titleSettings) return @@ -180,20 +190,33 @@ export const PreviewVideoPanel: React.FC = ({ const rect = container.getBoundingClientRect() if (rect.width <= 0 || rect.height <= 0) return + // 优先使用 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, + 1, + videoEl ? videoEl.offsetLeft : 0, + videoEl ? videoEl.offsetTop : 0, ) }, [titleText, titleSettings]) /** 在 no-video canvas 上绘制标题 */ const drawNoVideoTitle = useCallback(() => { + if (!fontLoadedRef.current) return const canvas = noVideoCanvasRef.current const container = noVideoContainerRef.current if (!canvas || !container || !titleSettings) return @@ -227,7 +250,6 @@ export const PreviewVideoPanel: React.FC = ({ drawVideoTitle() }) observer.observe(container) - // 初始绘制(延迟一帧等容器渲染完成) requestAnimationFrame(drawVideoTitle) return () => observer.disconnect() @@ -248,6 +270,49 @@ export const PreviewVideoPanel: React.FC = ({ return () => observer.disconnect() }, [showTitlePreview, hasPreview, isError, drawNoVideoTitle]) + // 字体加载检测:字体变更时重新检测,确保 measureText 使用正确字体 + useEffect(() => { + if (!showTitlePreview || !titleSettings) { + fontLoadedRef.current = false + return + } + 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 + // ref 已同步更新,显式触发重绘(draw 内部通过 ref 检查字体状态) + requestAnimationFrame(() => { + if (!cancelled) { + drawVideoTitle() + drawNoVideoTitle() + } + }) + } + + if (document.fonts.check(fontSpec)) { + onFontReady() + return + } + + document.fonts + .load(fontSpec) + .then(() => onFontReady()) + .catch(() => { + document.fonts.ready.then(() => onFontReady()) + }) + + return () => { + cancelled = true + } + }, [showTitlePreview, titleSettings, drawVideoTitle, drawNoVideoTitle]) + // video 加载完成后重绘 const handleVideoLoaded = useCallback(() => { if (showTitlePreview) { @@ -310,6 +375,7 @@ export const PreviewVideoPanel: React.FC = ({