diff --git a/apps/web/src/pages/ai-avatar/AiAvatar.css b/apps/web/src/pages/ai-avatar/AiAvatar.css
index 7e5e1fbe9..2701d830f 100644
--- a/apps/web/src/pages/ai-avatar/AiAvatar.css
+++ b/apps/web/src/pages/ai-avatar/AiAvatar.css
@@ -552,7 +552,7 @@
max-width: 240px;
aspect-ratio: 9/16;
background: #f0f0f5;
- border-radius: 8px;
+ border-radius: 12px;
overflow: hidden;
display: flex;
align-items: center;
@@ -564,8 +564,10 @@
.aa-cover-preview img {
width: 100%;
height: 100%;
+ aspect-ratio: 9/16;
object-fit: cover;
display: block;
+ border-radius: 12px;
}
.aa-cover-preview__placeholder {
@@ -573,6 +575,19 @@
color: #8c8ca1;
}
+.aa-cover-preview__loading {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(0, 0, 0, 0.45);
+ color: #fff;
+ font-size: 13px;
+ backdrop-filter: blur(4px);
+ -webkit-backdrop-filter: blur(4px);
+}
+
.aa-cover-actions {
display: flex;
gap: 8px;
diff --git a/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx b/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx
index 8196c3d53..8ca9a999d 100644
--- a/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx
+++ b/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx
@@ -441,6 +441,7 @@ const AiAvatarPage: React.FC = () => {
state.setCoverConfig((prev) => ({ ...prev, ...partial }))
}
diff --git a/apps/web/src/pages/ai-avatar/api/aiAvatar.ts b/apps/web/src/pages/ai-avatar/api/aiAvatar.ts
index 13b9acf1b..ec1ef06e4 100644
--- a/apps/web/src/pages/ai-avatar/api/aiAvatar.ts
+++ b/apps/web/src/pages/ai-avatar/api/aiAvatar.ts
@@ -85,7 +85,8 @@ export const submitRender = async (data: {
}
export const getRenderJob = async (jobId: string): Promise => {
- const response = await apiClient.get(`/ai-avatar/render/${jobId}`)
+ // 渲染任务走多步 ffmpeg 合成,可能耗时较久,轮询超时设 60s
+ const response = await apiClient.get(`/ai-avatar/render/${jobId}`, { timeout: 60000 })
return response.data
}
diff --git a/apps/web/src/pages/ai-avatar/components/PanelCoverAndGenerate.tsx b/apps/web/src/pages/ai-avatar/components/PanelCoverAndGenerate.tsx
index ea9961802..278e01400 100644
--- a/apps/web/src/pages/ai-avatar/components/PanelCoverAndGenerate.tsx
+++ b/apps/web/src/pages/ai-avatar/components/PanelCoverAndGenerate.tsx
@@ -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) => void
resolution: string
onResolutionChange: (r: string) => void
@@ -47,8 +48,19 @@ const LIPSYNC_STATUS_LABEL: Record = {
failed: { text: "失败", cls: "aa-status-badge--failed" },
}
+/** 字体名 → CSS font-family 映射(与后端 drawtext 对齐) */
+const FONT_FAMILY_MAP: Record = {
+ 思源黑体: "'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 = ({
coverConfig,
+ titleConfig,
onCoverConfigChange,
resolution,
onResolutionChange,
@@ -86,15 +98,85 @@ const PanelCoverAndGenerate: React.FC = ({
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(() => {
+ 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 (
{/* 封面预览(竖屏 9:16) */}
- {coverConfig.thumbnail_url ? (
-

+ {hasCoverImage ? (
+

) : (
暂无封面
)}
+ {/* 智能封面加载遮罩 */}
+ {smartCoverLoading &&
⏳ 智能选帧中…
}
+ {/* 标题文字叠加层(实时预览,仅前端视觉参考,最终由后端 ffmpeg drawtext 叠加) */}
+ {showTitleOverlay && (
+
+ {titleConfig.title}
+
+ )}