Compare commits

...

1 Commits

Author SHA1 Message Date
xiaoxia b1c5e5f55c feat: 标题设置实时预览Canvas + 绘制函数抽取共享模块
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 36s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m14s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 43s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 47s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m24s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m33s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m50s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m52s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m49s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 4m4s
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 4m5s
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 6s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 19s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Waiting to run
- 新建 utils/drawTitleOnCanvas.ts:抽取 wrapText/drawTitleOnCanvas 为共享模块
- PreviewVideoPanel.tsx 改为 import 共享模块,删除内部重复定义
- 新建 TitlePreviewCanvas 组件:深色渐变背景 + Canvas 实时绘制标题
  - useEffect 监听 props 变化 + ResizeObserver 监听容器尺寸
  - FontFace API 加载字体,失败降级默认字体
  - DPR 适配,paddingX=24/topOffset=40 与 ASS 一致
- Step4TitleSettings 集成:标题非空时在样式面板上方显示预览
- 纯前端重构,不涉及后端 API,不改 Step 流程
2026-08-15 17:36:46 +08:00
4 changed files with 299 additions and 124 deletions
@@ -15,6 +15,7 @@ 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"
interface PreviewVideoPanelProps {
previewStatus: PreviewStatus
@@ -29,130 +30,6 @@ interface PreviewVideoPanelProps {
titleSettings?: TitleSettings
}
/* ── Canvas 绘制工具函数 ── */
/**
* 将文本按 maxWidth 逐字换行,返回行数组。
* 与 ASS 字幕引擎的逐字换行行为一致。
*/
function wrapText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] {
const lines: string[] = []
let currentLine = ""
for (const char of text) {
const testLine = currentLine + char
if (ctx.measureText(testLine).width > maxWidth && currentLine) {
lines.push(currentLine)
currentLine = char
} else {
currentLine = testLine
}
}
if (currentLine) lines.push(currentLine)
return lines
}
/**
* 在 canvas 上绘制标题文字(含描边/阴影/多行居中)
*
* Canvas 已通过 CSS 定位到视频实际渲染位置,
* 坐标系基于 Canvas 自身尺寸,居中直接使用 w/2。
*
* @param ctx canvas 上下文
* @param w canvas CSS 宽度(= 视频渲染宽度)
* @param h canvas CSS 高度(= 视频渲染高度)
* @param text 标题文字
* @param settings 标题样式
* @param paddingX 左右边距(px),与 ASS 的 MarginL/MarginR 对应
* @param position "top" | "center" | "bottom"
* @param topOffset 顶部/底部偏移量
*/
function drawTitleOnCanvas(
ctx: CanvasRenderingContext2D,
w: number,
h: number,
text: string,
settings: TitleSettings,
paddingX: number,
position: string,
topOffset: 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)
// 清除
ctx.clearRect(0, 0, w, h)
// 可用宽度 = 总宽 - 左右边距
const availableWidth = w - paddingX * 2
if (availableWidth <= 0) return
// 字体设置
const fontSize = Math.round(Math.min(settings.size, 36))
const fontWeight = settings.bold ? "bold" : "normal"
const fontStyle = settings.italic ? "italic" : "normal"
ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px "${settings.font}"`
// 文字属性
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.fillStyle = settings.color
const lineHeight = fontSize * 1.4
// 描边 & 阴影
if (settings.stroke) {
ctx.strokeStyle = "rgba(0,0,0,0.6)"
ctx.lineWidth = 2
ctx.lineJoin = "round"
}
if (settings.shadow) {
ctx.shadowColor = "rgba(0,0,0,0.7)"
ctx.shadowBlur = 4
ctx.shadowOffsetX = 2
ctx.shadowOffsetY = 2
}
// 换行
const displayText = text && text.trim() ? text : "请选择或输入标题"
const lines = wrapText(ctx, displayText, availableWidth)
// 起始 Y:根据 position 计算
const totalTextHeight = lines.length * lineHeight
let startY: number
switch (position) {
case "top":
startY = topOffset
break
case "center":
startY = (h - totalTextHeight) / 2 + lineHeight / 2
break
case "bottom":
default:
startY = h - topOffset - totalTextHeight + lineHeight / 2
break
}
// 居中 x = w/2(Canvas 已定位到视频位置,无需额外偏移)
const x = w / 2
lines.forEach((line, i) => {
const y = startY + i * lineHeight
if (settings.stroke) ctx.strokeText(line, x, y)
ctx.fillText(line, x, y)
})
// 重置 shadow(避免影响后续绘制)
if (settings.shadow) {
ctx.shadowColor = "transparent"
ctx.shadowBlur = 0
ctx.shadowOffsetX = 0
ctx.shadowOffsetY = 0
}
}
/* ── 组件 ── */
export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
@@ -8,6 +8,7 @@ import type { TitleSettings } from "../types"
import { useStep4Title } from "../hooks/useStep4Title"
import AiTitleGenerator from "./title/AiTitleGenerator"
import TitleStylePanel from "./title/TitleStylePanel"
import TitlePreviewCanvas from "./title/TitlePreviewCanvas"
interface Step4TitleSettingsProps {
titleSettings: TitleSettings
@@ -111,6 +112,11 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
</>
)}
{/* 标题实时预览 - 仅当有标题文字时显示 */}
{t.titleSettings.title && (
<TitlePreviewCanvas titleText={t.titleSettings.title} titleSettings={t.titleSettings} />
)}
{/* 样式面板始终可见,两种模式下都可调整标题展示样式 */}
<TitleStylePanel
settings={t.titleSettings}
@@ -0,0 +1,166 @@
/**
* 标题实时预览 Canvas 组件
*
* 在 Step4 标题设置面板中嵌入,让用户实时看到标题文字、字体、大小、颜色、
* 位置、描边、阴影等样式的实际渲染效果(所见即所得)。
*
* 使用共享的 drawTitleOnCanvas 工具函数,与 PreviewVideoPanel 行为一致。
*/
import React, { useRef, useEffect } from "react"
import type { TitleSettings } from "../../types"
import { drawTitleOnCanvas } from "../../utils/drawTitleOnCanvas"
interface TitlePreviewCanvasProps {
/** 标题文字 */
titleText: string
/** 标题样式设置 */
titleSettings: TitleSettings
/** 视频比例,默认 "16:9" */
videoRatio?: string
}
/**
* 解析 videoRatio 字符串为 aspect-ratio CSS 值
*/
function parseAspect(ratio: string): string {
return (ratio || "16:9").replace(":", "/")
}
const TitlePreviewCanvas: React.FC<TitlePreviewCanvasProps> = ({
titleText,
titleSettings,
videoRatio = "16:9",
}) => {
const containerRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
// 字体加载状态
const fontLoadedRef = useRef(false)
/** 在 Canvas 上绘制标题 */
const draw = () => {
const canvas = canvasRef.current
const container = containerRef.current
if (!canvas || !container) return
const ctx = canvas.getContext("2d")
if (!ctx) return
const rect = container.getBoundingClientRect()
if (rect.width <= 0 || rect.height <= 0) return
const w = rect.width
const h = rect.height
// 更新 Canvas CSS 尺寸匹配容器
canvas.style.width = `${w}px`
canvas.style.height = `${h}px`
drawTitleOnCanvas(ctx, w, h, titleText, titleSettings, 24, titleSettings.position, 40)
}
// 字体加载:确保 measureText 使用正确字体
useEffect(() => {
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
requestAnimationFrame(() => {
if (!cancelled) draw()
})
}
// 用 FontFace API 加载字体,失败则降级
try {
const fontFace = new FontFace(titleSettings.font, `local("${titleSettings.font}")`)
fontFace
.load()
.then(() => {
if (!cancelled) {
;(document.fonts as any).add(fontFace)
onFontReady()
}
})
.catch(() => {
// 字体加载失败,用默认字体继续
onFontReady()
})
} catch {
// FontFace 不可用,直接绘制
onFontReady()
}
// 同时检查 document.fonts 是否已有该字体
if (document.fonts.check(fontSpec)) {
onFontReady()
return
}
return () => {
cancelled = true
}
}, [titleSettings.font, titleSettings.size, titleSettings.bold, titleSettings.italic])
// props 变化时重绘
useEffect(() => {
requestAnimationFrame(draw)
}, [titleText, titleSettings])
// ResizeObserver 监听容器尺寸变化
useEffect(() => {
const container = containerRef.current
if (!container) return
const observer = new ResizeObserver(() => {
requestAnimationFrame(draw)
})
observer.observe(container)
return () => observer.disconnect()
}, [])
return (
<div>
<div
style={{
fontSize: 12,
color: "var(--text-tertiary, #999)",
marginBottom: 6,
fontWeight: 500,
}}
>
</div>
<div
ref={containerRef}
style={{
width: "100%",
aspectRatio: parseAspect(videoRatio),
background: "linear-gradient(135deg, #1a1a2e, #16213e, #0f3460)",
borderRadius: 8,
overflow: "hidden",
position: "relative",
}}
>
<canvas
ref={canvasRef}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
}}
/>
</div>
</div>
)
}
export default TitlePreviewCanvas
@@ -0,0 +1,126 @@
/**
* Canvas 标题绘制工具函数(共享模块)
*
* 供 PreviewVideoPanel(预览视频标题叠加)和 TitlePreviewCanvas(标题设置实时预览)共用。
* 绘制行为与 ASS 字幕引擎一致:逐字换行、居中、描边/阴影。
*/
import type { TitleSettings } from "../types"
/**
* 将文本按 maxWidth 逐字换行,返回行数组。
* 与 ASS 字幕引擎的逐字换行行为一致。
*/
export function wrapText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] {
const lines: string[] = []
let currentLine = ""
for (const char of text) {
const testLine = currentLine + char
if (ctx.measureText(testLine).width > maxWidth && currentLine) {
lines.push(currentLine)
currentLine = char
} else {
currentLine = testLine
}
}
if (currentLine) lines.push(currentLine)
return lines
}
/**
* 在 canvas 上绘制标题文字(含描边/阴影/多行居中)
*
* @param ctx canvas 上下文
* @param w canvas CSS 宽度
* @param h canvas CSS 高度
* @param text 标题文字
* @param settings 标题样式
* @param paddingX 左右边距(px),与 ASS 的 MarginL/MarginR 对应
* @param position "top" | "center" | "bottom"
* @param topOffset 顶部/底部偏移量
*/
export function drawTitleOnCanvas(
ctx: CanvasRenderingContext2D,
w: number,
h: number,
text: string,
settings: TitleSettings,
paddingX: number,
position: string,
topOffset: 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)
// 清除
ctx.clearRect(0, 0, w, h)
// 可用宽度 = 总宽 - 左右边距
const availableWidth = w - paddingX * 2
if (availableWidth <= 0) return
// 字体设置
const fontSize = Math.round(Math.min(settings.size, 36))
const fontWeight = settings.bold ? "bold" : "normal"
const fontStyle = settings.italic ? "italic" : "normal"
ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px "${settings.font}"`
// 文字属性
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.fillStyle = settings.color
const lineHeight = fontSize * 1.4
// 描边 & 阴影
if (settings.stroke) {
ctx.strokeStyle = "rgba(0,0,0,0.6)"
ctx.lineWidth = 2
ctx.lineJoin = "round"
}
if (settings.shadow) {
ctx.shadowColor = "rgba(0,0,0,0.7)"
ctx.shadowBlur = 4
ctx.shadowOffsetX = 2
ctx.shadowOffsetY = 2
}
// 换行
const displayText = text && text.trim() ? text : "请选择或输入标题"
const lines = wrapText(ctx, displayText, availableWidth)
// 起始 Y:根据 position 计算
const totalTextHeight = lines.length * lineHeight
let startY: number
switch (position) {
case "top":
startY = topOffset
break
case "center":
startY = (h - totalTextHeight) / 2 + lineHeight / 2
break
case "bottom":
default:
startY = h - topOffset - totalTextHeight + lineHeight / 2
break
}
// 居中 x = w/2
const x = w / 2
lines.forEach((line, i) => {
const y = startY + i * lineHeight
if (settings.stroke) ctx.strokeText(line, x, y)
ctx.fillText(line, x, y)
})
// 重置 shadow(避免影响后续绘制)
if (settings.shadow) {
ctx.shadowColor = "transparent"
ctx.shadowBlur = 0
ctx.shadowOffsetX = 0
ctx.shadowOffsetY = 0
}
}