diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx
index 717f87b92..cd881a9db 100644
--- a/apps/web/src/pages/generate/GeneratePage.tsx
+++ b/apps/web/src/pages/generate/GeneratePage.tsx
@@ -12,10 +12,7 @@
import React, { useState, useMemo } from "react"
import { Modal, message } from "antd"
import { useNavigate } from "react-router-dom"
-import { useQuery } from "@tanstack/react-query"
import type { VoiceClone } from "@/api/voice-clone"
-import { getAssetsByKind } from "@/api/assets/assets"
-import type { AssetItem } from "@/api/assets/types"
import { useCloneProgress } from "@/hooks/useCloneProgress"
import CloneModal from "@/components/voice/CloneModal"
import GenerateHeader from "./components/GenerateHeader"
@@ -74,20 +71,6 @@ const GeneratePage: React.FC = () => {
setPreviewModalOpen,
} = formState
- /* ── 查询视频素材,用于 Step4 标题预览背景 ── */
- const { data: videoAssets = [] } = useQuery({
- queryKey: ["generate-video-assets"],
- queryFn: () => getAssetsByKind("video", { limit: 50 }),
- })
-
- // 获取第一个选中素材的 URL
- const sourceVideoUrl = useMemo(() => {
- const firstId = selectedMaterials[0]
- if (!firstId) return undefined
- const asset = videoAssets.find((a: AssetItem) => a.id === firstId)
- return asset?.file_url
- }, [selectedMaterials, videoAssets])
-
/* ── 克隆声音 ── */
const { clones: clonedVoices, addClone, hasProcessing } = useCloneProgress()
@@ -252,7 +235,7 @@ const GeneratePage: React.FC = () => {
{/* ════ 右侧:预览 + 生成结果 ════ */}
- {/* 预览视频面板(Step4+ 显示,Step4 显示标题预览叠加,Step5+ 仅视频) */}
+ {/* 预览视频面板(Step4+ 显示) */}
{currentStep >= 4 && (
{
progress={step5Preview.progress}
videoRatio={videoRatio}
onRegenerate={step5Preview.regeneratePreview}
- titleText={titleSettings.title}
- titleSettings={titleSettings}
- showTitlePreview={currentStep === 4}
- sourceVideoUrl={sourceVideoUrl}
/>
)}
-
- {/* 正式生成结果(Step6+ 才显示) */}
{currentStep >= 6 && (
void
- /** 标题文字 */
- titleText?: string
- /** 标题样式设置 */
- titleSettings?: TitleSettings
- /** Step4 标题预览模式 */
- showTitlePreview?: boolean
- /** 素材视频 URL(用于 Step4 标题预览背景) */
- sourceVideoUrl?: string
}
/* ── 组件 ── */
@@ -45,172 +30,21 @@ export const PreviewVideoPanel: React.FC = ({
progress,
videoRatio,
onRegenerate,
- titleText,
- titleSettings,
- showTitlePreview,
- sourceVideoUrl,
}) => {
const hasPreview = previewStatus === "ready" && previewResult
const isLoading = previewStatus === "pending" || previewStatus === "generating"
const isError = previewStatus === "error"
const videoAspectStyle = { aspectRatio: (videoRatio || "16:9").replace(":", "/") }
- // video 模式 refs
- const canvasRef = useRef(null)
- const containerRef = useRef(null)
- const videoRef = useRef(null)
-
- // 字体加载状态(ref 供 draw 回调同步读取,无需 state 避免触发不必要的重渲染)
- const fontLoadedRef = useRef(false)
-
- // 根据视频比例计算目标渲染分辨率(与后端一致)
- const { width: videoWidth, height: videoHeight } = calculateVideoDimensions(videoRatio || "16:9")
-
- /** 在 video canvas 上绘制标题(以视频分辨率绘制) */
- const drawVideoTitle = useCallback(() => {
- // 通过 ref 读取字体状态,避免 fontLoaded 进入依赖数组
- if (!fontLoadedRef.current) return
- const canvas = canvasRef.current
- const container = containerRef.current
- if (!canvas || !container || !titleSettings) return
- const ctx = canvas.getContext("2d")
- if (!ctx) return
-
- const containerRect = container.getBoundingClientRect()
- if (containerRect.width <= 0 || containerRect.height <= 0) return
-
- // 使用 video 元素的 getBoundingClientRect 获取实际渲染尺寸和位置
- const videoEl = videoRef.current
- 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()
- displayW = videoRect.width
- displayH = videoRect.height
- offsetX = videoRect.left - containerRect.left
- offsetY = videoRect.top - containerRect.top
- }
-
- // 更新 Canvas CSS 位置和尺寸,使其与视频实际渲染区域完全对齐
- canvas.style.left = `${offsetX}px`
- canvas.style.top = `${offsetY}px`
- canvas.style.width = `${displayW}px`
- canvas.style.height = `${displayH}px`
-
- // 以视频分辨率作为绘制坐标系(与后端 ASS 一致)
- drawTitleOnCanvas(
- ctx,
- displayW,
- displayH,
- titleText || "",
- titleSettings,
- 40,
- titleSettings.position,
- 60,
- videoWidth,
- videoHeight,
- )
- }, [titleText, titleSettings, videoWidth, videoHeight])
-
- // video 模式:ResizeObserver 监听容器尺寸变化 → 重绘
- useEffect(() => {
- if (!showTitlePreview || !hasPreview) return
- const container = containerRef.current
- if (!container) return
-
- const observer = new ResizeObserver(() => {
- drawVideoTitle()
- })
- observer.observe(container)
- requestAnimationFrame(drawVideoTitle)
-
- return () => observer.disconnect()
- }, [showTitlePreview, hasPreview, drawVideoTitle])
-
- // 字体加载检测:字体变更时重新检测,确保 measureText 使用正确字体
- useEffect(() => {
- if (!showTitlePreview || !titleSettings) {
- fontLoadedRef.current = false
- return
- }
- 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
- // ref 已同步更新,显式触发重绘(draw 内部通过 ref 检查字体状态)
- requestAnimationFrame(() => {
- if (!cancelled) {
- drawVideoTitle()
- }
- })
- }
-
- if (document.fonts.check(fontSpec)) {
- onFontReady()
- return
- }
-
- document.fonts
- .load(fontSpec)
- .then(() => onFontReady())
- .catch(() => {
- document.fonts.ready.then(() => onFontReady())
- })
-
- return () => {
- cancelled = true
- }
- }, [showTitlePreview, titleSettings, drawVideoTitle])
-
- // video 加载完成后重绘
- const handleVideoLoaded = useCallback(() => {
- if (showTitlePreview) {
- requestAnimationFrame(drawVideoTitle)
- }
- }, [showTitlePreview, drawVideoTitle])
-
return (
-
{showTitlePreview && !hasPreview ? "标题预览" : "预览视频"}
- {hasPreview && !showTitlePreview && 480p 预览版}
+ 预览视频
+ {hasPreview && 480p 预览版}
- {/* Step4 标题预览模式 */}
- {showTitlePreview && titleSettings && titleText && (
-
-
-
- )}
-
- {/* Step4 但无标题时的空状态 */}
- {showTitlePreview && (!titleText || !titleSettings) && (
-
-
-
请输入标题
-
在左侧设置标题后,这里会实时预览效果
-
- )}
-
- {/* 空状态:还没生成预览(非 Step4 模式) */}
- {!showTitlePreview && previewStatus === "idle" && (
+ {/* 空状态:还没生成预览 */}
+ {previewStatus === "idle" && (
)}
- {/* 生成中(非 Step4 模式) */}
- {!showTitlePreview && isLoading && (
+ {/* 生成中 */}
+ {isLoading && (
@@ -237,8 +71,8 @@ export const PreviewVideoPanel: React.FC
= ({
)}
- {/* 生成失败(非 Step4 模式) */}
- {!showTitlePreview && isError && (
+ {/* 生成失败 */}
+ {isError && (
预览生成失败
@@ -252,54 +86,32 @@ export const PreviewVideoPanel: React.FC
= ({
)}
- {/* 预览成功 + Canvas 标题叠加 */}
+ {/* 预览成功 */}
{hasPreview && (
-
+ <>
-
+
- {showTitlePreview && (
-
- )}
-
- )}
-
- {/* 预览信息(非 Step4 模式) */}
- {!showTitlePreview && hasPreview && previewResult && (
-
-
-
时长
-
- {(typeof previewResult.duration === "number" ? previewResult.duration : 0).toFixed(1)}{" "}
- 秒
-
+
+
+ 时长
+
+ {(typeof previewResult.duration === "number" ? previewResult.duration : 0).toFixed(
+ 1,
+ )}{" "}
+ 秒
+
+
+
+ 片段数
+ {previewResult.clipCount} 段
+
+
+ 比例
+ {videoRatio}
+
-
- 片段数
- {previewResult.clipCount} 段
-
-
- 比例
- {videoRatio}
-
-
+ >
)}
)
diff --git a/apps/web/src/pages/generate/components/title/TitlePreviewCanvas.tsx b/apps/web/src/pages/generate/components/title/TitlePreviewCanvas.tsx
deleted file mode 100644
index ccb2389ad..000000000
--- a/apps/web/src/pages/generate/components/title/TitlePreviewCanvas.tsx
+++ /dev/null
@@ -1,209 +0,0 @@
-/**
- * 标题实时预览 Canvas 组件
- *
- * 在 Step4 标题设置面板中嵌入,让用户实时看到标题文字、字体、大小、颜色、
- * 位置、描边、阴影等样式的实际渲染效果(所见即所得)。
- *
- * 使用共享的 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, calculateVideoDimensions } from "../../utils/drawTitleOnCanvas"
-
-interface TitlePreviewCanvasProps {
- /** 标题文字 */
- titleText: string
- /** 标题样式设置 */
- titleSettings: TitleSettings
- /** 视频比例,默认 "9:16"(竖屏) */
- videoRatio?: string
- /** 素材视频 URL(作为背景显示) */
- sourceVideoUrl?: string
-}
-
-/**
- * 解析 videoRatio 字符串为 aspect-ratio CSS 值
- */
-function parseAspect(ratio: string): string {
- return (ratio || "9:16").replace(":", "/")
-}
-
-const TitlePreviewCanvas: React.FC
= ({
- titleText,
- titleSettings,
- videoRatio = "9:16",
- sourceVideoUrl,
-}) => {
- const containerRef = useRef(null)
- const canvasRef = useRef(null)
-
- // 字体加载状态
- const fontLoadedRef = useRef(false)
-
- // 根据视频比例计算目标渲染分辨率(与后端一致)
- const { width: videoWidth, height: videoHeight } = calculateVideoDimensions(videoRatio)
-
- /** 在 Canvas 上绘制标题(以视频分辨率绘制,CSS 缩放显示) */
- 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
-
- // CSS 尺寸 = 容器尺寸(浏览器自动缩放 canvas 位图到 CSS 尺寸)
- canvas.style.width = `${rect.width}px`
- canvas.style.height = `${rect.height}px`
-
- // 以视频分辨率作为绘制坐标系(font_size / padding / offset 与后端 ASS 一致)
- drawTitleOnCanvas(
- ctx,
- rect.width,
- rect.height,
- titleText,
- titleSettings,
- 24,
- titleSettings.position,
- 40,
- videoWidth,
- videoHeight,
- )
- }
-
- // 字体加载:确保 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) {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- FontFaceSet.add() exists at runtime
- ;(document.fonts as any).add(fontFace)
- onFontReady()
- }
- })
- .catch(() => {
- // 字体加载失败,用默认字体继续
- onFontReady()
- })
- } catch {
- // FontFace 不可用,直接绘制
- onFontReady()
- }
-
- // 同时检查 document.fonts 是否已有该字体
- if (document.fonts.check(fontSpec)) {
- onFontReady()
- return
- }
-
- return () => {
- cancelled = true
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps -- draw uses refs, stable by design
- }, [titleSettings.font, titleSettings.size, titleSettings.bold, titleSettings.italic])
-
- // props 变化时重绘
- useEffect(() => {
- requestAnimationFrame(draw)
- // eslint-disable-next-line react-hooks/exhaustive-deps -- draw uses refs, stable by design
- }, [titleText, titleSettings])
-
- // ResizeObserver 监听容器尺寸变化 → 更新 CSS 尺寸并重绘
- useEffect(() => {
- const container = containerRef.current
- if (!container) return
-
- const observer = new ResizeObserver(() => {
- requestAnimationFrame(draw)
- })
- observer.observe(container)
-
- return () => observer.disconnect()
- // eslint-disable-next-line react-hooks/exhaustive-deps -- draw uses refs, stable by design
- }, [])
-
- return (
-
-
- 预览效果
-
-
- {sourceVideoUrl && (
-
- )}
-
-
-
- )
-}
-
-export default TitlePreviewCanvas
diff --git a/apps/web/src/pages/generate/utils/drawTitleOnCanvas.ts b/apps/web/src/pages/generate/utils/drawTitleOnCanvas.ts
deleted file mode 100644
index 8338ebf1a..000000000
--- a/apps/web/src/pages/generate/utils/drawTitleOnCanvas.ts
+++ /dev/null
@@ -1,175 +0,0 @@
-/**
- * Canvas 标题绘制工具函数(共享模块)
- *
- * 供 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 字幕引擎的逐字换行行为一致。
- */
-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 绘制区域宽度(CSS 像素,用于计算位置和布局)
- * @param h 绘制区域高度(CSS 像素)
- * @param text 标题文字
- * @param settings 标题样式
- * @param paddingX 左右边距(与 w/h 同坐标系),与 ASS 的 MarginL/MarginR 对应
- * @param position "top" | "center" | "bottom"
- * @param topOffset 顶部/底部偏移量(与 w/h 同坐标系)
- * @param videoWidth 目标视频渲染宽度(可选,提供后绘制坐标系匹配后端 ASS 分辨率)
- * @param videoHeight 目标视频渲染高度(可选)
- */
-export function drawTitleOnCanvas(
- ctx: CanvasRenderingContext2D,
- w: number,
- h: number,
- text: string,
- settings: TitleSettings,
- paddingX: number,
- position: string,
- topOffset: number,
- videoWidth?: number,
- videoHeight?: number,
-) {
- const dpr = window.devicePixelRatio || 1
-
- // 如果提供了视频分辨率,在该坐标系下绘制(与后端 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, drawW, drawH)
-
- // 可用宽度 = 总宽 - 左右边距
- // 视频坐标系下 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"
- ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px "${settings.font}"`
-
- // 文字属性
- ctx.textAlign = "center"
- ctx.textBaseline = "middle"
- ctx.fillStyle = settings.color
-
- 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 * strokeScale
- ctx.lineJoin = "round"
- }
- if (settings.shadow) {
- ctx.shadowColor = "rgba(0,0,0,0.7)"
- 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 计算(scaled topOffset)
- const scaledTopOffset = useVideoCoords ? (topOffset * drawH) / h : topOffset
- const totalTextHeight = lines.length * lineHeight
- let startY: number
- switch (position) {
- case "top":
- startY = scaledTopOffset
- break
- case "center":
- startY = (drawH - totalTextHeight) / 2 + lineHeight / 2
- break
- case "bottom":
- default:
- startY = drawH - scaledTopOffset - totalTextHeight + lineHeight / 2
- break
- }
-
- // 居中 x = drawW / 2
- const x = drawW / 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
- }
-}