diff --git a/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx b/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx index d02fd88a3..8de11fbf7 100644 --- a/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx +++ b/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx @@ -2,6 +2,9 @@ * 右侧预览视频面板 * Step3 生成预览后常驻显示预览视频 * Step4+ 用 Canvas 绘制标题预览(替代 CSS overlay,与 ASS 渲染行为一致) + * + * 设计说明:标题预览仅在有视频时显示(叠加在视频画面上方)。 + * 无视频状态(idle/loading/error)下不再单独显示标题预览,这是有意为之的设计简化。 */ import React, { useRef, useEffect, useCallback } from "react" import { PlayCircleOutlined, LoadingOutlined } from "@ant-design/icons" @@ -53,9 +56,8 @@ function wrapText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number) * @param paddingX 左右边距(px),与 ASS 的 MarginL/MarginR 对应 * @param position "top" | "center" | "bottom" * @param topOffset 顶部/底部偏移量 - * @param fontSizeScale 字号缩放(no-video 模式下缩小) - * @param offsetX 绘制区域左边缘相对于 canvas 的偏移(用于 video 居中时补偿 offsetLeft) - * @param offsetY 绘制区域上边缘相对于 canvas 的偏移(用于 video 居中时补偿 offsetTop) + * @param offsetX 绘制区域左边缘相对于 canvas 的偏移(用于 video 居中时补偿) + * @param offsetY 绘制区域上边缘相对于 canvas 的偏移(用于 video 居中时补偿) */ function drawTitleOnCanvas( ctx: CanvasRenderingContext2D, @@ -66,7 +68,6 @@ function drawTitleOnCanvas( paddingX: number, position: string, topOffset: number, - fontSizeScale = 1, offsetX = 0, offsetY = 0, ) { @@ -87,7 +88,7 @@ function drawTitleOnCanvas( if (availableWidth <= 0) return // 字体设置 - const fontSize = Math.round(Math.min(settings.size * fontSizeScale, 36)) + 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}"` @@ -170,9 +171,6 @@ export const PreviewVideoPanel: React.FC = ({ const canvasRef = useRef(null) const containerRef = useRef(null) const videoRef = useRef(null) - // no-video 模式 refs - const noVideoCanvasRef = useRef(null) - const noVideoContainerRef = useRef(null) // 字体加载状态(ref 供 draw 回调同步读取,无需 state 避免触发不必要的重渲染) const fontLoadedRef = useRef(false) @@ -187,16 +185,22 @@ export const PreviewVideoPanel: React.FC = ({ const ctx = canvas.getContext("2d") if (!ctx) return - const rect = container.getBoundingClientRect() - if (rect.width <= 0 || rect.height <= 0) return + const containerRect = container.getBoundingClientRect() + if (containerRect.width <= 0 || containerRect.height <= 0) return - // 优先使用 video 元素实际渲染尺寸,确保标题居中于视频画面 + // 使用 video 元素的 getBoundingClientRect 获取实际渲染尺寸和位置 const videoEl = videoRef.current - let drawW = rect.width - let drawH = rect.height + let drawW = containerRect.width + let drawH = containerRect.height + let offsetX = 0 + let offsetY = 0 + if (videoEl && videoEl.clientWidth > 0 && videoEl.clientHeight > 0) { - drawW = videoEl.clientWidth - drawH = videoEl.clientHeight + const videoRect = videoEl.getBoundingClientRect() + drawW = videoRect.width + drawH = videoRect.height + offsetX = videoRect.left - containerRect.left + offsetY = videoRect.top - containerRect.top } drawTitleOnCanvas( @@ -208,35 +212,8 @@ export const PreviewVideoPanel: React.FC = ({ 40, titleSettings.position, 60, - 1, - videoEl ? videoEl.offsetLeft : 0, - videoEl ? videoEl.offsetTop : 0, - ) - }, [titleText, titleSettings]) - - /** 在 no-video canvas 上绘制标题 */ - const drawNoVideoTitle = useCallback(() => { - if (!fontLoadedRef.current) return - const canvas = noVideoCanvasRef.current - const container = noVideoContainerRef.current - if (!canvas || !container || !titleSettings) return - const ctx = canvas.getContext("2d") - if (!ctx) return - - const rect = container.getBoundingClientRect() - if (rect.width <= 0 || rect.height <= 0) return - - // no-video 容器较窄,字号缩小 + 减少边距 - drawTitleOnCanvas( - ctx, - rect.width, - rect.height, - titleText || "", - titleSettings, - 20, - titleSettings.position, - 30, - 0.75, + offsetX, + offsetY, ) }, [titleText, titleSettings]) @@ -255,21 +232,6 @@ export const PreviewVideoPanel: React.FC = ({ return () => observer.disconnect() }, [showTitlePreview, hasPreview, drawVideoTitle]) - // no-video 模式:ResizeObserver 监听容器尺寸变化 → 重绘 - useEffect(() => { - if (!showTitlePreview || hasPreview || isError) return - const container = noVideoContainerRef.current - if (!container) return - - const observer = new ResizeObserver(() => { - drawNoVideoTitle() - }) - observer.observe(container) - requestAnimationFrame(drawNoVideoTitle) - - return () => observer.disconnect() - }, [showTitlePreview, hasPreview, isError, drawNoVideoTitle]) - // 字体加载检测:字体变更时重新检测,确保 measureText 使用正确字体 useEffect(() => { if (!showTitlePreview || !titleSettings) { @@ -291,7 +253,6 @@ export const PreviewVideoPanel: React.FC = ({ requestAnimationFrame(() => { if (!cancelled) { drawVideoTitle() - drawNoVideoTitle() } }) } @@ -311,7 +272,7 @@ export const PreviewVideoPanel: React.FC = ({ return () => { cancelled = true } - }, [showTitlePreview, titleSettings, drawVideoTitle, drawNoVideoTitle]) + }, [showTitlePreview, titleSettings, drawVideoTitle]) // video 加载完成后重绘 const handleVideoLoaded = useCallback(() => { @@ -399,31 +360,6 @@ export const PreviewVideoPanel: React.FC = ({ )} - {/* 无视频时的标题预览(idle/loading 状态下显示,error 状态下隐藏) */} - {!hasPreview && !isError && showTitlePreview && ( -
- -
- )} - {/* 预览信息 */} {hasPreview && previewResult && (
diff --git a/apps/web/src/pages/generate/components/material/ManualMaterialList.tsx b/apps/web/src/pages/generate/components/material/ManualMaterialList.tsx index 3fa20a904..a067e1098 100644 --- a/apps/web/src/pages/generate/components/material/ManualMaterialList.tsx +++ b/apps/web/src/pages/generate/components/material/ManualMaterialList.tsx @@ -1,7 +1,7 @@ /** * 手动选择素材列表 */ -import React from "react" +import React, { useRef, useCallback } from "react" import { Typography } from "antd" import type { AssetItem } from "@/api/assets" @@ -20,6 +20,33 @@ const ManualMaterialList: React.FC = ({ selectedMaterials, onToggle, }) => { + // 追踪当前正在播放的视频元素,确保同时只有一个视频播放 + const activeVideoRef = useRef(null) + + const handleVideoMouseEnter = useCallback((e: React.MouseEvent) => { + const video = e.currentTarget + // 暂停之前正在播放的视频(检查是否仍在 DOM 中) + if ( + activeVideoRef.current && + activeVideoRef.current !== video && + document.body.contains(activeVideoRef.current) + ) { + activeVideoRef.current.pause() + activeVideoRef.current.currentTime = 0 + } + activeVideoRef.current = video + video.play().catch(() => {}) + }, []) + + const handleVideoMouseLeave = useCallback((e: React.MouseEvent) => { + const video = e.currentTarget + video.pause() + video.currentTime = 0 + if (activeVideoRef.current === video) { + activeVideoRef.current = null + } + }, []) + return (
{materialsLoading ? ( @@ -33,8 +60,6 @@ const ManualMaterialList: React.FC = ({ {materials.items.map((m) => { const checked = selectedMaterials.includes(m.id) const isVideo = m.mime_type?.startsWith("video/") ?? false - // thumbnail_url 是静态截图,可直接用于 - // file_url 是视频/音频文件本身, 无法渲染,不能作为 src 回退 const thumbSrc = m.thumbnail_url || undefined return (