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 && ( + + )}