fix: Canvas预览与后端ASS渲染坐标系对齐,消除标题预览不一致 #1401
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user