fix: 删除no-video标题预览 + 视频素材可播放预览 + Canvas标题居中修复 #1248

Merged
xiaoxia merged 7 commits from feat/no-video-cleanup-and-centering-fix into develop 2026-08-06 17:44:08 +08:00
2 changed files with 100 additions and 96 deletions
@@ -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<PreviewVideoPanelProps> = ({
const canvasRef = useRef<HTMLCanvasElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const videoRef = useRef<HTMLVideoElement>(null)
// no-video 模式 refs
const noVideoCanvasRef = useRef<HTMLCanvasElement>(null)
const noVideoContainerRef = useRef<HTMLDivElement>(null)
// 字体加载状态(ref 供 draw 回调同步读取,无需 state 避免触发不必要的重渲染)
const fontLoadedRef = useRef(false)
@@ -187,16 +185,22 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
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<PreviewVideoPanelProps> = ({
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<PreviewVideoPanelProps> = ({
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<PreviewVideoPanelProps> = ({
requestAnimationFrame(() => {
if (!cancelled) {
drawVideoTitle()
drawNoVideoTitle()
}
})
}
@@ -311,7 +272,7 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
return () => {
cancelled = true
}
}, [showTitlePreview, titleSettings, drawVideoTitle, drawNoVideoTitle])
}, [showTitlePreview, titleSettings, drawVideoTitle])
// video 加载完成后重绘
const handleVideoLoaded = useCallback(() => {
@@ -399,31 +360,6 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
</div>
)}
{/* 无视频时的标题预览(idle/loading 状态下显示,error 状态下隐藏) */}
{!hasPreview && !isError && showTitlePreview && (
<div
ref={noVideoContainerRef}
style={{
background: "rgba(0,0,0,0.3)",
borderRadius: 8,
minHeight: 64,
marginTop: 12,
position: "relative",
}}
>
<canvas
ref={noVideoCanvasRef}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
}}
/>
</div>
)}
{/* 预览信息 */}
{hasPreview && previewResult && (
<div className="xx-preview-info">
@@ -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<ManualMaterialListProps> = ({
selectedMaterials,
onToggle,
}) => {
// 追踪当前正在播放的视频元素,确保同时只有一个视频播放
const activeVideoRef = useRef<HTMLVideoElement | null>(null)
const handleVideoMouseEnter = useCallback((e: React.MouseEvent<HTMLVideoElement>) => {
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<HTMLVideoElement>) => {
const video = e.currentTarget
video.pause()
video.currentTime = 0
if (activeVideoRef.current === video) {
activeVideoRef.current = null
}
}, [])
return (
<div style={{ marginTop: 14 }}>
{materialsLoading ? (
@@ -33,8 +60,6 @@ const ManualMaterialList: React.FC<ManualMaterialListProps> = ({
{materials.items.map((m) => {
const checked = selectedMaterials.includes(m.id)
const isVideo = m.mime_type?.startsWith("video/") ?? false
// thumbnail_url 是静态截图,可直接用于 <img>
// file_url 是视频/音频文件本身,<img> 无法渲染,不能作为 src 回退
const thumbSrc = m.thumbnail_url || undefined
return (
<label
@@ -57,7 +82,10 @@ const ManualMaterialList: React.FC<ManualMaterialListProps> = ({
type="checkbox"
checked={checked}
onChange={() => onToggle(m.id)}
style={{ accentColor: "var(--primary-color, #4f46e5)", flexShrink: 0 }}
style={{
accentColor: "var(--primary-color, #4f46e5)",
flexShrink: 0,
}}
/>
{/* 缩略图预览 48×48 */}
<div
@@ -73,7 +101,24 @@ const ManualMaterialList: React.FC<ManualMaterialListProps> = ({
justifyContent: "center",
}}
>
{thumbSrc ? (
{isVideo && m.file_url ? (
<video
src={m.file_url}
poster={m.thumbnail_url || undefined}
muted
loop
playsInline
preload="none"
onMouseEnter={handleVideoMouseEnter}
onMouseLeave={handleVideoMouseLeave}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
cursor: "pointer",
}}
/>
) : thumbSrc ? (
<img
src={thumbSrc}
alt={m.name}
@@ -90,14 +135,37 @@ const ManualMaterialList: React.FC<ManualMaterialListProps> = ({
}}
/>
) : null}
{!thumbSrc && (
<span style={{ fontSize: 20, opacity: 0.5, display: "flex" }}>
{isVideo ? "🎬" : "🎵"}
{!thumbSrc && !isVideo && (
<span
style={{
fontSize: 20,
opacity: 0.5,
display: "flex",
}}
>
🎵
</span>
)}
{!thumbSrc && isVideo && !m.file_url && (
<span
style={{
fontSize: 20,
opacity: 0.5,
display: "flex",
}}
>
🎬
</span>
)}
{/* img onError 时显示的 fallback(初始隐藏) */}
{thumbSrc && (
<span style={{ fontSize: 20, opacity: 0.5, display: "none" }}>
{thumbSrc && !(isVideo && m.file_url) && (
<span
style={{
fontSize: 20,
opacity: 0.5,
display: "none",
}}
>
{isVideo ? "🎬" : "🎵"}
</span>
)}