feat: 素材列表缩略图预览 + Canvas标题居中修复 #1247
@@ -46,14 +46,16 @@ function wrapText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number)
|
||||
/**
|
||||
* 在 canvas 上绘制标题文字(含描边/阴影/多行居中)
|
||||
* @param ctx canvas 上下文
|
||||
* @param canvasWidth canvas CSS 宽度
|
||||
* @param canvasHeight canvas CSS 高度
|
||||
* @param canvasWidth canvas CSS 宽度(绘制区域宽度)
|
||||
* @param canvasHeight canvas CSS 高度(绘制区域高度)
|
||||
* @param text 标题文字
|
||||
* @param settings 标题样式
|
||||
* @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)
|
||||
*/
|
||||
function drawTitleOnCanvas(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
@@ -65,6 +67,8 @@ function drawTitleOnCanvas(
|
||||
position: string,
|
||||
topOffset: number,
|
||||
fontSizeScale = 1,
|
||||
offsetX = 0,
|
||||
offsetY = 0,
|
||||
) {
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const w = canvasWidth
|
||||
@@ -128,10 +132,10 @@ function drawTitleOnCanvas(
|
||||
break
|
||||
}
|
||||
|
||||
// 逐行绘制
|
||||
const x = w / 2
|
||||
// 逐行绘制(offsetX/offsetY 补偿 video 在容器内的居中偏移)
|
||||
const x = offsetX + w / 2
|
||||
lines.forEach((line, i) => {
|
||||
const y = startY + i * lineHeight
|
||||
const y = offsetY + startY + i * lineHeight
|
||||
if (settings.stroke) ctx.strokeText(line, x, y)
|
||||
ctx.fillText(line, x, y)
|
||||
})
|
||||
@@ -162,15 +166,21 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
const isError = previewStatus === "error"
|
||||
const showTitlePreview = !!titleSettings
|
||||
|
||||
// video 模式 canvas ref
|
||||
// video 模式 refs
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
// no-video 模式 canvas ref
|
||||
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)
|
||||
|
||||
/** 在 video canvas 上绘制标题 */
|
||||
const drawVideoTitle = useCallback(() => {
|
||||
// 通过 ref 读取字体状态,避免 fontLoaded 进入依赖数组
|
||||
if (!fontLoadedRef.current) return
|
||||
const canvas = canvasRef.current
|
||||
const container = containerRef.current
|
||||
if (!canvas || !container || !titleSettings) return
|
||||
@@ -180,20 +190,33 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
const rect = container.getBoundingClientRect()
|
||||
if (rect.width <= 0 || rect.height <= 0) return
|
||||
|
||||
// 优先使用 video 元素实际渲染尺寸,确保标题居中于视频画面
|
||||
const videoEl = videoRef.current
|
||||
let drawW = rect.width
|
||||
let drawH = rect.height
|
||||
if (videoEl && videoEl.clientWidth > 0 && videoEl.clientHeight > 0) {
|
||||
drawW = videoEl.clientWidth
|
||||
drawH = videoEl.clientHeight
|
||||
}
|
||||
|
||||
drawTitleOnCanvas(
|
||||
ctx,
|
||||
rect.width,
|
||||
rect.height,
|
||||
drawW,
|
||||
drawH,
|
||||
titleText || "",
|
||||
titleSettings,
|
||||
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
|
||||
@@ -227,7 +250,6 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
drawVideoTitle()
|
||||
})
|
||||
observer.observe(container)
|
||||
// 初始绘制(延迟一帧等容器渲染完成)
|
||||
requestAnimationFrame(drawVideoTitle)
|
||||
|
||||
return () => observer.disconnect()
|
||||
@@ -248,6 +270,49 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
return () => observer.disconnect()
|
||||
}, [showTitlePreview, hasPreview, isError, drawNoVideoTitle])
|
||||
|
||||
// 字体加载检测:字体变更时重新检测,确保 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()
|
||||
drawNoVideoTitle()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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, drawNoVideoTitle])
|
||||
|
||||
// video 加载完成后重绘
|
||||
const handleVideoLoaded = useCallback(() => {
|
||||
if (showTitlePreview) {
|
||||
@@ -310,6 +375,7 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
<div ref={containerRef} style={{ position: "relative" }}>
|
||||
<div className="xx-preview-video">
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={previewResult.videoUrl}
|
||||
controls
|
||||
preload="metadata"
|
||||
|
||||
@@ -32,6 +32,10 @@ const ManualMaterialList: React.FC<ManualMaterialListProps> = ({
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{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
|
||||
key={m.id}
|
||||
@@ -53,13 +57,60 @@ const ManualMaterialList: React.FC<ManualMaterialListProps> = ({
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => onToggle(m.id)}
|
||||
style={{ accentColor: "var(--primary-color, #4f46e5)" }}
|
||||
style={{ accentColor: "var(--primary-color, #4f46e5)", flexShrink: 0 }}
|
||||
/>
|
||||
{/* 缩略图预览 48×48 */}
|
||||
<div
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 6,
|
||||
overflow: "hidden",
|
||||
background: "#e2e8f0",
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{thumbSrc ? (
|
||||
<img
|
||||
src={thumbSrc}
|
||||
alt={m.name}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = "none"
|
||||
const fallback = target.nextElementSibling as HTMLElement | null
|
||||
if (fallback) fallback.style.display = "flex"
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{!thumbSrc && (
|
||||
<span style={{ fontSize: 20, opacity: 0.5, display: "flex" }}>
|
||||
{isVideo ? "🎬" : "🎵"}
|
||||
</span>
|
||||
)}
|
||||
{/* img onError 时显示的 fallback(初始隐藏) */}
|
||||
{thumbSrc && (
|
||||
<span style={{ fontSize: 20, opacity: 0.5, display: "none" }}>
|
||||
{isVideo ? "🎬" : "🎵"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-primary)",
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{m.name}
|
||||
@@ -68,9 +119,10 @@ const ManualMaterialList: React.FC<ManualMaterialListProps> = ({
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-tertiary, #94a3b8)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{m.mime_type.split("/")[1].toUpperCase()}
|
||||
{m.mime_type?.split("/")?.[1]?.toUpperCase() ?? "FILE"}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user