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 - } -}