fix: Canvas预览与后端ASS渲染坐标系对齐,消除标题预览不一致 #1401

Closed
xiaoxia wants to merge 1 commits from fix/canvas-video-resolution-match into develop
3 changed files with 122 additions and 50 deletions
@@ -1,21 +1,22 @@
/**
* 右侧预览视频面板
* Step4 生成预览后常驻显示预览视频
* Step5+ 用 Canvas 绘制标题预览(替代 CSS overlay,与 ASS 渲染行为一致)
* Step4+ 显示预览视频面板
* Step4: Canvas 绘制标题预览叠加(以视频分辨率绘制,与后端 ASS 一致)
* Step5+: 仅显示后端生成的预览视频(标题已由 FFmpeg 烧录)
*
* 设计说明:标题预览仅在有视频时显示(叠加在视频画面上方)。
* 无视频状态(idle/loading/error)下不再单独显示标题预览,这是有意为之的设计简化。
*
* Canvas 居中修复说明
* Canvas 的 CSS 位置和尺寸直接匹配视频实际渲染区域(通过 getBoundingClientRect
* 绘制坐标系基于 Canvas 自身尺寸,x = w/2 即可实现水平居中,
* 避免容器与视频尺寸不一致时浏览器拉伸 Canvas 导致居中偏移
* Canvas 坐标一致性
* Canvas 内部以目标视频分辨率(由 videoRatio 推算)绘制
* CSS 缩放至视频显示区域大小,确保 font_size / margin / position
* 与后端 ASS 渲染完全对齐
*/
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"
import { drawTitleOnCanvas, calculateVideoDimensions } from "../utils/drawTitleOnCanvas"
import TitlePreviewCanvas from "./title/TitlePreviewCanvas"
interface PreviewVideoPanelProps {
@@ -62,7 +63,10 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
// 字体加载状态(ref 供 draw 回调同步读取,无需 state 避免触发不必要的重渲染)
const fontLoadedRef = useRef(false)
/** 在 video canvas 上绘制标题 */
// 根据视频比例计算目标渲染分辨率(与后端一致)
const { width: videoWidth, height: videoHeight } = calculateVideoDimensions(videoRatio || "16:9")
/** 在 video canvas 上绘制标题(以视频分辨率绘制) */
const drawVideoTitle = useCallback(() => {
// 通过 ref 读取字体状态,避免 fontLoaded 进入依赖数组
if (!fontLoadedRef.current) return
@@ -77,15 +81,15 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
// 使用 video 元素的 getBoundingClientRect 获取实际渲染尺寸和位置
const videoEl = videoRef.current
let drawW = containerRect.width
let drawH = containerRect.height
let displayW = containerRect.width
let displayH = containerRect.height
let offsetX = 0
let offsetY = 0
if (videoEl && videoEl.clientWidth > 0 && videoEl.clientHeight > 0) {
const videoRect = videoEl.getBoundingClientRect()
drawW = videoRect.width
drawH = videoRect.height
displayW = videoRect.width
displayH = videoRect.height
offsetX = videoRect.left - containerRect.left
offsetY = videoRect.top - containerRect.top
}
@@ -93,21 +97,23 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
// 更新 Canvas CSS 位置和尺寸,使其与视频实际渲染区域完全对齐
canvas.style.left = `${offsetX}px`
canvas.style.top = `${offsetY}px`
canvas.style.width = `${drawW}px`
canvas.style.height = `${drawH}px`
canvas.style.width = `${displayW}px`
canvas.style.height = `${displayH}px`
// 绘制时,坐标系基于 Canvas 自身尺寸,无需额外偏移
// 以视频分辨率作为绘制坐标系(与后端 ASS 一致)
drawTitleOnCanvas(
ctx,
drawW,
drawH,
displayW,
displayH,
titleText || "",
titleSettings,
40,
titleSettings.position,
60,
videoWidth,
videoHeight,
)
}, [titleText, titleSettings])
}, [titleText, titleSettings, videoWidth, videoHeight])
// video 模式:ResizeObserver 监听容器尺寸变化 → 重绘
useEffect(() => {
@@ -5,10 +5,15 @@
* 位置、描边、阴影等样式的实际渲染效果(所见即所得)。
*
* 使用共享的 drawTitleOnCanvas 工具函数,与 PreviewVideoPanel 行为一致。
*
* 坐标一致性:
* Canvas 内部以目标视频分辨率(如 720×1280)绘制,CSS 缩放至容器大小显示。
* 这样 font_size / paddingX / topOffset 与后端 ASS 渲染完全对齐,
* 预览效果与最终输出视频 100% 一致。
*/
import React, { useRef, useEffect } from "react"
import type { TitleSettings } from "../../types"
import { drawTitleOnCanvas } from "../../utils/drawTitleOnCanvas"
import { drawTitleOnCanvas, calculateVideoDimensions } from "../../utils/drawTitleOnCanvas"
interface TitlePreviewCanvasProps {
/** 标题文字 */
@@ -40,7 +45,10 @@ const TitlePreviewCanvas: React.FC<TitlePreviewCanvasProps> = ({
// 字体加载状态
const fontLoadedRef = useRef(false)
/** 在 Canvas 上绘制标题 */
// 根据视频比例计算目标渲染分辨率(与后端一致)
const { width: videoWidth, height: videoHeight } = calculateVideoDimensions(videoRatio)
/** 在 Canvas 上绘制标题(以视频分辨率绘制,CSS 缩放显示) */
const draw = () => {
const canvas = canvasRef.current
const container = containerRef.current
@@ -51,14 +59,23 @@ const TitlePreviewCanvas: React.FC<TitlePreviewCanvasProps> = ({
const rect = container.getBoundingClientRect()
if (rect.width <= 0 || rect.height <= 0) return
const w = rect.width
const h = rect.height
// CSS 尺寸 = 容器尺寸(浏览器自动缩放 canvas 位图到 CSS 尺寸)
canvas.style.width = `${rect.width}px`
canvas.style.height = `${rect.height}px`
// 更新 Canvas CSS 尺寸匹配容器
canvas.style.width = `${w}px`
canvas.style.height = `${h}px`
drawTitleOnCanvas(ctx, w, h, titleText, titleSettings, 24, titleSettings.position, 40)
// 以视频分辨率作为绘制坐标系(font_size / padding / offset 与后端 ASS 一致)
drawTitleOnCanvas(
ctx,
rect.width,
rect.height,
titleText,
titleSettings,
24,
titleSettings.position,
40,
videoWidth,
videoHeight,
)
}
// 字体加载:确保 measureText 使用正确字体
@@ -118,7 +135,7 @@ const TitlePreviewCanvas: React.FC<TitlePreviewCanvasProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps -- draw uses refs, stable by design
}, [titleText, titleSettings])
// ResizeObserver 监听容器尺寸变化
// ResizeObserver 监听容器尺寸变化 → 更新 CSS 尺寸并重绘
useEffect(() => {
const container = containerRef.current
if (!container) return
@@ -3,9 +3,36 @@
*
* 供 PreviewVideoPanel(预览视频标题叠加)和 TitlePreviewCanvas(标题设置实时预览)共用。
* 绘制行为与 ASS 字幕引擎一致:逐字换行、居中、描边/阴影。
*
* 坐标一致性:
* 当提供 videoWidth/videoHeight 时,绘制坐标系匹配后端 ASS 渲染的视频分辨率,
* 确保 font_size、margin、position 等参数在 Canvas 和 FFmpeg 中视觉比例完全一致。
*/
import type { TitleSettings } from "../types"
/**
* 根据视频比例计算目标渲染分辨率。
* 基准:长边 1280px,短边按比例计算(与后端默认输出分辨率对齐)。
*
* @example
* calculateVideoDimensions("16:9") → { width: 1280, height: 720 }
* calculateVideoDimensions("9:16") → { width: 720, height: 1280 }
* calculateVideoDimensions("1:1") → { width: 1280, height: 1280 }
*/
export function calculateVideoDimensions(ratio: string): { width: number; height: number } {
const parts = (ratio || "16:9").split(":")
const rw = parseInt(parts[0]) || 16
const rh = parseInt(parts[1]) || 9
const longSide = 1280
const shortSide = Math.round((longSide * Math.min(rw, rh)) / Math.max(rw, rh))
if (rw >= rh) {
return { width: longSide, height: shortSide }
}
return { width: shortSide, height: longSide }
}
/**
* 将文本按 maxWidth 逐字换行,返回行数组。
* 与 ASS 字幕引擎的逐字换行行为一致。
@@ -30,13 +57,15 @@ export function wrapText(ctx: CanvasRenderingContext2D, text: string, maxWidth:
* 在 canvas 上绘制标题文字(含描边/阴影/多行居中)
*
* @param ctx canvas 上下文
* @param w canvas CSS 宽度
* @param h canvas CSS 高度
* @param w 绘制区域宽度(CSS 像素,用于计算位置和布局)
* @param h 绘制区域高度(CSS 像素)
* @param text 标题文字
* @param settings 标题样式
* @param paddingX 左右边距(px),与 ASS 的 MarginL/MarginR 对应
* @param paddingX 左右边距(与 w/h 同坐标系),与 ASS 的 MarginL/MarginR 对应
* @param position "top" | "center" | "bottom"
* @param topOffset 顶部/底部偏移量
* @param topOffset 顶部/底部偏移量(与 w/h 同坐标系)
* @param videoWidth 目标视频渲染宽度(可选,提供后绘制坐标系匹配后端 ASS 分辨率)
* @param videoHeight 目标视频渲染高度(可选)
*/
export function drawTitleOnCanvas(
ctx: CanvasRenderingContext2D,
@@ -47,22 +76,40 @@ export function drawTitleOnCanvas(
paddingX: number,
position: string,
topOffset: number,
videoWidth?: number,
videoHeight?: 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)
// 如果提供了视频分辨率,在该坐标系下绘制(与后端 ASS 一致
const useVideoCoords = videoWidth && videoHeight && videoWidth > 0 && videoHeight > 0
const drawW = useVideoCoords ? videoWidth : w
const drawH = useVideoCoords ? videoHeight : h
// 设置 canvas 物理像素尺寸
if (useVideoCoords) {
// 视频坐标系:内部位图 = 视频分辨率 × DPR
ctx.canvas.width = Math.round(drawW * dpr)
ctx.canvas.height = Math.round(drawH * dpr)
ctx.scale(dpr, dpr)
} else {
// 兼容模式:内部位图 = CSS 尺寸 × DPR
ctx.canvas.width = Math.round(w * dpr)
ctx.canvas.height = Math.round(h * dpr)
ctx.scale(dpr, dpr)
}
// 清除
ctx.clearRect(0, 0, w, h)
ctx.clearRect(0, 0, drawW, drawH)
// 可用宽度 = 总宽 - 左右边距
const availableWidth = w - paddingX * 2
// 视频坐标系下 paddingX 需按视频宽度等比缩放(调用方传入的 paddingX 基于 CSS 尺寸)
const scaledPaddingX = useVideoCoords ? (paddingX * drawW) / w : paddingX
const availableWidth = drawW - scaledPaddingX * 2
if (availableWidth <= 0) return
// 字体设置
// 字体设置(视频坐标系下 fontSize 直接对应 ASS 的 font_size
const fontSize = Math.round(Math.min(settings.size, 36))
const fontWeight = settings.bold ? "bold" : "normal"
const fontStyle = settings.italic ? "italic" : "normal"
@@ -75,41 +122,43 @@ export function drawTitleOnCanvas(
const lineHeight = fontSize * 1.4
// 描边 & 阴影
// 描边 & 阴影(视频坐标系下按视频尺寸等比缩放)
const strokeScale = useVideoCoords ? drawW / w : 1
if (settings.stroke) {
ctx.strokeStyle = "rgba(0,0,0,0.6)"
ctx.lineWidth = 2
ctx.lineWidth = 2 * strokeScale
ctx.lineJoin = "round"
}
if (settings.shadow) {
ctx.shadowColor = "rgba(0,0,0,0.7)"
ctx.shadowBlur = 4
ctx.shadowOffsetX = 2
ctx.shadowOffsetY = 2
ctx.shadowBlur = 4 * strokeScale
ctx.shadowOffsetX = 2 * strokeScale
ctx.shadowOffsetY = 2 * strokeScale
}
// 换行
const displayText = text && text.trim() ? text : "请选择或输入标题"
const lines = wrapText(ctx, displayText, availableWidth)
// 起始 Y:根据 position 计算
// 起始 Y:根据 position 计算scaled topOffset
const scaledTopOffset = useVideoCoords ? (topOffset * drawH) / h : topOffset
const totalTextHeight = lines.length * lineHeight
let startY: number
switch (position) {
case "top":
startY = topOffset
startY = scaledTopOffset
break
case "center":
startY = (h - totalTextHeight) / 2 + lineHeight / 2
startY = (drawH - totalTextHeight) / 2 + lineHeight / 2
break
case "bottom":
default:
startY = h - topOffset - totalTextHeight + lineHeight / 2
startY = drawH - scaledTopOffset - totalTextHeight + lineHeight / 2
break
}
// 居中 x = w/2
const x = w / 2
// 居中 x = drawW / 2
const x = drawW / 2
lines.forEach((line, i) => {
const y = startY + i * lineHeight
if (settings.stroke) ctx.strokeText(line, x, y)