feat(ai-avatar): 封面预览实时标题叠加 + getRenderJob 60s 超时

This commit is contained in:
saas-frontend-agent
2026-09-11 14:49:58 +08:00
parent 97bda32097
commit f5a25c1a8d
@@ -1,17 +1,18 @@
/**
* AI数字人 — 面板5:封面 & 生成
* - 竖屏 9:16 封面预览(从视频截取 / 自定义上传)
* - 竖屏 9:16 封面预览(从视频截取 / 自定义上传)+ 标题文字实时叠加预览
* - 分辨率选择(720p / 1080p / 4K)
* - 配置汇总卡片(出镜视频/音色/文案/对口型/B-roll/标题/封面)
* - 渐变紫色生成按钮
*
* 注意:v3 已删除"画面插入模式",本面板不包含该选项。
*/
import React, { useRef } from "react"
import type { AiAvatarCoverConfig } from "../types"
import React, { useMemo, useRef } from "react"
import type { AiAvatarCoverConfig, AiAvatarTitleConfig } from "../types"
interface PanelCoverAndGenerateProps {
coverConfig: AiAvatarCoverConfig
titleConfig: AiAvatarTitleConfig
onCoverConfigChange: (partial: Partial<AiAvatarCoverConfig>) => void
resolution: string
onResolutionChange: (r: string) => void
@@ -47,8 +48,19 @@ const LIPSYNC_STATUS_LABEL: Record<string, { text: string; cls: string }> = {
failed: { text: "失败", cls: "aa-status-badge--failed" },
}
/** 字体名 → CSS font-family 映射(与后端 drawtext 对齐) */
const FONT_FAMILY_MAP: Record<string, string> = {
思源黑体: "'Noto Sans SC', 'Source Han Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif",
思源宋体: "'Noto Serif SC', 'Source Han Serif SC', 'SimSun', serif",
楷体: "KaiTi, 'STKaiti', serif",
黑体: "'Heiti SC', 'SimHei', 'Microsoft YaHei', sans-serif",
}
const getFontFamily = (font: string): string => FONT_FAMILY_MAP[font] || FONT_FAMILY_MAP["思源黑体"]
const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
coverConfig,
titleConfig,
onCoverConfigChange,
resolution,
onResolutionChange,
@@ -86,15 +98,85 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
const canGenerate = summary.lipsyncStatus === "completed" && !isGenerating
/** 封面图实际展示的 url:智能封面 > 自定义上传 > 空 */
const coverUrl =
coverConfig.smart_cover_url || coverConfig.thumbnail_url || coverConfig.upload_url
const hasCoverImage = Boolean(coverUrl)
/** 是否显示标题叠加层:有图、有文字、非加载中 */
const showTitleOverlay =
hasCoverImage && !smartCoverLoading && titleConfig.title.trim().length > 0
/** 计算标题叠加层的 inline 样式 */
const titleOverlayStyle = useMemo<React.CSSProperties>(() => {
const style: React.CSSProperties = {
position: "absolute",
left: "50%",
width: "90%",
transform: "translateX(-50%)",
textAlign: "center",
boxSizing: "border-box",
padding: "0 4px",
wordBreak: "break-word",
whiteSpace: "pre-wrap",
color: titleConfig.color || "#ffffff",
fontSize: `${titleConfig.size}px`,
fontFamily: getFontFamily(titleConfig.font),
fontWeight: titleConfig.bold ? "bold" : "normal",
fontStyle: titleConfig.italic ? "italic" : "normal",
lineHeight: 1.3,
pointerEvents: "none",
}
// 位置
const pos = titleConfig.position || "bottom"
if (pos === "top") {
style.top = "40px"
} else if (pos === "center") {
style.top = "50%"
style.transform = "translate(-50%, -50%)"
} else if (pos === "custom" && titleConfig.pos_x != null && titleConfig.pos_y != null) {
// pos_x/pos_y 是相对预览容器的百分比坐标
style.left = `${titleConfig.pos_x}%`
style.top = `${titleConfig.pos_y}%`
style.transform = "translate(-50%, -50%)"
} else {
style.bottom = "40px"
}
// 描边优先于阴影(二者互斥,与 drawtext 对齐)
if (titleConfig.stroke) {
// 描边宽度按字号估算,保证视觉一致
const strokeWidth = Math.max(1, Math.round(titleConfig.size / 18))
;(style as React.CSSProperties)["WebkitTextStroke"] = `${strokeWidth}px rgba(0,0,0,0.75)`
style.textShadow = "none"
} else if (titleConfig.shadow) {
style.textShadow = "0 2px 8px rgba(0,0,0,0.7), 0 0 2px rgba(0,0,0,0.5)"
} else {
// 默认给轻微阴影保证白字在亮背景可读
style.textShadow = "0 2px 6px rgba(0,0,0,0.6)"
}
return style
}, [titleConfig])
return (
<div className="aa-cover-generate">
{/* 封面预览(竖屏 9:16) */}
<div className="aa-cover-preview">
{coverConfig.thumbnail_url ? (
<img src={coverConfig.thumbnail_url} alt="封面预览" />
{hasCoverImage ? (
<img src={coverUrl!} alt="封面预览" draggable={false} />
) : (
<span className="aa-cover-preview__placeholder">暂无封面</span>
)}
{/* 智能封面加载遮罩 */}
{smartCoverLoading && <div className="aa-cover-preview__loading">⏳ 智能选帧中…</div>}
{/* 标题文字叠加层(实时预览,仅前端视觉参考,最终由后端 ffmpeg drawtext 叠加) */}
{showTitleOverlay && (
<div style={titleOverlayStyle} aria-hidden="true">
{titleConfig.title}
</div>
)}
</div>
<div className="aa-cover-actions">