diff --git a/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx b/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx index 3a564bcb0..b475e686c 100644 --- a/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx +++ b/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx @@ -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 = ({ // 字体加载状态(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 = ({ // 使用 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 = ({ // 更新 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(() => { diff --git a/apps/web/src/pages/generate/components/title/TitlePreviewCanvas.tsx b/apps/web/src/pages/generate/components/title/TitlePreviewCanvas.tsx index 357474d02..ccb2389ad 100644 --- a/apps/web/src/pages/generate/components/title/TitlePreviewCanvas.tsx +++ b/apps/web/src/pages/generate/components/title/TitlePreviewCanvas.tsx @@ -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 = ({ // 字体加载状态 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 = ({ 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 = ({ // 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 diff --git a/apps/web/src/pages/generate/utils/drawTitleOnCanvas.ts b/apps/web/src/pages/generate/utils/drawTitleOnCanvas.ts index 62f845ac9..8338ebf1a 100644 --- a/apps/web/src/pages/generate/utils/drawTitleOnCanvas.ts +++ b/apps/web/src/pages/generate/utils/drawTitleOnCanvas.ts @@ -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)