fix: 删除no-video标题预览 + 视频素材可播放预览 + Canvas标题居中修复 #1248
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user