feat: 素材列表缩略图预览 + Canvas标题居中修复 #1247

Merged
auto-approve-bot merged 13 commits from feat/thumbnail-preview-and-canvas-fix into develop 2026-08-06 11:31:00 +08:00
2 changed files with 130 additions and 12 deletions
@@ -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>
)