fix: Canvas标题居中修复+字体加载等待
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 47s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 57s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m11s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m27s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m43s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 54s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m4s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m23s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m38s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Failing after 989h9m23s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 989h9m24s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 989h10m19s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 989h10m19s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 989h10m21s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 989h10m23s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 989h10m23s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 989h10m23s
CI/CD Pipeline / PR Build API Image (pull_request) Failing after 989h42m41s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 989h43m37s

- 使用video元素实际渲染尺寸(clientWidth/clientHeight)替代容器尺寸
- 添加document.fonts.ready等待字体加载后再绘制
- 字体加载前跳过绘制避免measureText用fallback字体计算
This commit is contained in:
2026-08-06 10:16:41 +08:00
parent 75e42002b5
commit 69ccb7f15d
@@ -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<PreviewVideoPanelProps> = ({
const isError = previewStatus === "error"
const showTitlePreview = !!titleSettings
// video 模式 canvas ref
// video 模式 refs
const canvasRef = useRef<HTMLCanvasElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
// no-video 模式 canvas ref
const videoRef = useRef<HTMLVideoElement>(null)
// no-video 模式 refs
const noVideoCanvasRef = useRef<HTMLCanvasElement>(null)
const noVideoContainerRef = useRef<HTMLDivElement>(null)
// 字体加载状态
const [fontLoaded, setFontLoaded] = useState(false)
/** 在 video canvas 上绘制标题 */
const drawVideoTitle = useCallback(() => {
const canvas = canvasRef.current
@@ -180,17 +184,30 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
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<PreviewVideoPanelProps> = ({
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<PreviewVideoPanelProps> = ({
30,
0.75,
)
}, [titleText, titleSettings])
}, [titleText, titleSettings, fontLoaded])
// video 模式:ResizeObserver 监听容器尺寸变化 → 重绘
useEffect(() => {
@@ -227,7 +247,6 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
drawVideoTitle()
})
observer.observe(container)
// 初始绘制(延迟一帧等容器渲染完成)
requestAnimationFrame(drawVideoTitle)
return () => observer.disconnect()
@@ -248,6 +267,22 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
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<PreviewVideoPanelProps> = ({
</div>
</div>
<div className="xx-preview-progress-bar-wrap">
<div className="xx-preview-progress-fill" style={{ width: `${progress}%` }} />
<div
className="xx-preview-progress-fill"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)}
@@ -310,6 +348,7 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
<div ref={containerRef} style={{ position: "relative" }}>
<div className="xx-preview-video">
<video
ref={videoRef}
src={previewResult.videoUrl}
controls
preload="metadata"