From 4f02d3a04eb23dedf2c25dbd61340d62f5dce1ca Mon Sep 17 00:00:00 2001 From: x Date: Sun, 20 Sep 2026 21:29:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20#2001=20=E7=88=86=E6=AC=BE?= =?UTF-8?q?=E6=A0=87=E9=A2=98=E6=A0=B7=E5=BC=8F=E9=85=8D=E7=BD=AE=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E5=8D=87=E7=BA=A7=20+=20ai-avatar=20=E9=85=8D?= =?UTF-8?q?=E9=9F=B3=E9=9D=A2=E6=9D=BF=E6=B8=85=E7=90=86emotion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增描边宽色/阴影参数/行距/上边距/maxCharsPerLine/背景色块/逐行/封面独立配置 - 4款爆款字体 + 抖音爆款黄预设 - 共享 TitleStylePanel + TitleMiniPreview Canvas预览 - 全链路适配(Canvas/contract/useGenerateVideo/useBatchCovers/usePlanConfigLoader) - CreateGenerationTaskRequest/GenerateCoverTitleConfig 类型扩展 - ai-avatar: 删除情绪下拉框,tts-preview补传style,lipsync去emotion参数 - normalizeEmotion 工具函数保留 --- apps/web/src/api/generation/cover.ts | 15 +- apps/web/src/api/tasks/types.ts | 32 +- apps/web/src/components/title/constants.ts | 361 +++++++++++ apps/web/src/components/title/types.ts | 118 ++++ apps/web/src/pages/ai-avatar/AiAvatarPage.tsx | 24 +- .../components/PanelLipsyncPreview.tsx | 138 ++-- .../ai-avatar/components/PanelTitleConfig.tsx | 280 ++++++++- .../components/PanelVoiceSelector.tsx | 29 +- .../src/pages/ai-avatar/hooks/useAiAvatar.ts | 4 - apps/web/src/pages/ai-avatar/types.ts | 55 +- .../web/src/pages/ai-avatar/utils/contract.ts | 89 ++- .../src/pages/ai-avatar/utils/titleCanvas.ts | 287 ++++++--- apps/web/src/pages/generate/GeneratePage.tsx | 1 + .../components/GenerateStepContent.tsx | 10 +- .../components/Step4TitleSettings.tsx | 15 +- .../components/title/TitleMiniPreview.tsx | 221 +++++++ .../components/title/TitleStylePanel.css | 252 ++++++++ .../components/title/TitleStylePanel.tsx | 589 +++++++++++++++--- apps/web/src/pages/generate/constants.ts | 21 +- .../pages/generate/hooks/useBatchCovers.ts | 48 +- .../hooks/useGenerateFormState/index.ts | 15 + .../usePlanConfigLoader.ts | 183 +++++- .../pages/generate/hooks/useGenerateVideo.ts | 87 ++- .../useStep4Title/useTitleStyleUpdaters.ts | 39 +- apps/web/src/pages/generate/types.ts | 108 +++- 25 files changed, 2644 insertions(+), 377 deletions(-) create mode 100644 apps/web/src/components/title/constants.ts create mode 100644 apps/web/src/components/title/types.ts create mode 100644 apps/web/src/pages/generate/components/title/TitleMiniPreview.tsx diff --git a/apps/web/src/api/generation/cover.ts b/apps/web/src/api/generation/cover.ts index 8bf308d1e..7c1d98bf6 100644 --- a/apps/web/src/api/generation/cover.ts +++ b/apps/web/src/api/generation/cover.ts @@ -7,8 +7,19 @@ export interface GenerateCoverTitleConfig { font_color?: string position?: string bold?: boolean - stroke?: boolean - shadow?: boolean + italic?: boolean + stroke?: boolean | { enabled?: boolean; width?: number; color?: string } + shadow?: + | boolean + | { enabled?: boolean; offset_x?: number; offset_y?: number; blur?: number; color?: string } + line_height?: number + margin_top?: number + max_chars_per_line?: number + background?: { enabled?: boolean; color?: string; padding?: number; radius?: number } + line_overrides?: Array> + cover_title_config?: Record + pos_x?: number + pos_y?: number } export interface GenerateCoverRequest { diff --git a/apps/web/src/api/tasks/types.ts b/apps/web/src/api/tasks/types.ts index 685d7fea1..7f3cf916e 100644 --- a/apps/web/src/api/tasks/types.ts +++ b/apps/web/src/api/tasks/types.ts @@ -81,7 +81,7 @@ export interface CreateGenerationTaskRequest { tts_voice_source?: "preset" | "clone" /** #1970:智能降重开关(默认 true) */ dedup_enabled?: boolean - /** 标题烧录配置 */ + /** 标题烧录配置(#2001 扩展:描边/阴影参数/行距/自动换行/背景/逐行/封面) */ title_config?: { text?: string font?: string @@ -89,8 +89,34 @@ export interface CreateGenerationTaskRequest { font_color?: string position?: string bold?: boolean - stroke?: boolean - shadow?: boolean + italic?: boolean + stroke?: boolean | { enabled?: boolean; width?: number; color?: string } + shadow?: + | boolean + | { + enabled?: boolean + offset_x?: number + offset_y?: number + blur?: number + color?: string + } + line_height?: number + margin_top?: number + max_chars_per_line?: number + background?: { enabled?: boolean; color?: string; padding?: number; radius?: number } + line_overrides?: Array<{ + line_index: number + text?: string + size?: number + color?: string + bold?: boolean + italic?: boolean + stroke?: boolean + highlights?: Array<{ word: string; color?: string; bold?: boolean; scale?: number }> + }> + cover_title_config?: Record + pos_x?: number + pos_y?: number } /** 关联的草稿 ID(编辑流程数据链路用) */ source_edit_plan_id?: string diff --git a/apps/web/src/components/title/constants.ts b/apps/web/src/components/title/constants.ts new file mode 100644 index 000000000..0ef4c46c4 --- /dev/null +++ b/apps/web/src/components/title/constants.ts @@ -0,0 +1,361 @@ +/** + * 标题样式相关常量(#2001) + * - 字体列表(新增4款爆款字体) + * - 色板(常用标题字色/描边色/背景色) + * - 预设样式方案(10 个,含抖音爆款黄) + */ +import type { TitleStyleConfig } from "./types" + +/* ── 字体选项(#2001:新增优设标题黑/阿里普惠体Bold/抖音美好体/思源黑体Heavy) ── */ +export interface FontOption { + value: string + label: string + /** CSS font-family 栈 */ + family: string + /** 爆款/常用标签 */ + tag?: "hot" | "new" +} + +export const FONT_OPTIONS: FontOption[] = [ + { + value: "优设标题黑", + label: "优设标题黑", + family: + '"YouShe Title Black","YouSheBiaoTiHei","Source Han Sans SC Heavy","Noto Sans SC","PingFang SC",sans-serif', + tag: "hot", + }, + { + value: "阿里普惠体Bold", + label: "阿里普惠体Bold", + family: + '"Alibaba PuHuiTi Bold","Alibaba PuHuiTi","Source Han Sans SC","PingFang SC",sans-serif', + tag: "hot", + }, + { + value: "抖音美好体", + label: "抖音美好体", + family: '"Douyin Sans","DouyinSans","Source Han Sans SC","PingFang SC",sans-serif', + tag: "hot", + }, + { + value: "思源黑体Heavy", + label: "思源黑体Heavy", + family: + '"Source Han Sans SC Heavy","Noto Sans SC","Source Han Sans CN Heavy","PingFang SC",sans-serif', + tag: "new", + }, + { + value: "思源黑体", + label: "思源黑体", + family: '"Source Han Sans SC","Noto Sans SC","PingFang SC","Microsoft YaHei",sans-serif', + }, + { + value: "思源宋体", + label: "思源宋体", + family: '"Source Han Serif SC","Noto Serif SC","Songti SC","SimSun",serif', + }, + { + value: "苹方", + label: "苹方", + family: '"PingFang SC",-apple-system,"Helvetica Neue",sans-serif', + }, + { + value: "微软雅黑", + label: "微软雅黑", + family: '"Microsoft YaHei","PingFang SC",sans-serif', + }, + { + value: "楷体", + label: "楷体", + family: '"KaiTi","STKaiti","DFKai-SB",serif', + }, +] + +/** 根据中文名取 font-family 栈(找不到回退思源黑体) */ +export function getFontFamily(font: string): string { + const f = FONT_OPTIONS.find((x) => x.value === font) + if (f) return f.family + return FONT_OPTIONS[4].family // 思源黑体 +} + +/* ── 色板 ── */ +/** 标题字色(常用爆款色) */ +export const TITLE_COLOR_PALETTE: string[] = [ + "#ffffff", + "#000000", + "#ffd700", // 抖音黄 + "#ff2d55", // 抖音红 + "#ff4081", + "#00e5ff", + "#d4a843", + "#ffa500", + "#52c41a", + "#1890ff", + "#7c3aed", + "#ff6b35", +] + +/** 描边色(黑/白/灰为主) */ +export const STROKE_COLOR_PALETTE: string[] = [ + "#000000", + "#ffffff", + "#333333", + "#555555", + "#8b0000", + "#001f3f", +] + +/** 背景色(带透明度) */ +export const BG_COLOR_PALETTE: string[] = [ + "rgba(0,0,0,0.5)", + "rgba(0,0,0,0.7)", + "rgba(0,0,0,0.3)", + "rgba(255,215,0,0.9)", + "rgba(255,45,85,0.85)", + "rgba(124,58,237,0.85)", + "rgba(24,144,255,0.85)", + "rgba(82,196,26,0.85)", +] + +/* ── 预设样式方案(10 个,含抖音爆款黄) ── */ +export interface TitlePreset { + key: string + label: string + emoji: string + /** 应用时覆盖到 TitleStyleConfig 的字段(其他字段保持当前值) */ + style: Partial +} + +const BASE: Partial = { + line_overrides: [], + cover_title_config: null, +} + +export const TITLE_PRESETS: TitlePreset[] = [ + { + key: "douyin_hot", + label: "抖音爆款黄", + emoji: "🔥", + style: { + ...BASE, + font: "优设标题黑", + size: 80, + color: "#ffd700", + bold: true, + italic: false, + stroke: true, + stroke_width: 8, + stroke_color: "#000000", + shadow: true, + shadow_offset_x: 3, + shadow_offset_y: 3, + shadow_blur: 6, + shadow_color: "rgba(0,0,0,0.6)", + bg_enabled: false, + line_height: 1.25, + max_chars_per_line: 8, + }, + }, + { + key: "classic_white", + label: "经典白字黑描边", + emoji: "⚪", + style: { + ...BASE, + font: "思源黑体Heavy", + size: 56, + color: "#ffffff", + bold: true, + italic: false, + stroke: true, + stroke_width: 5, + stroke_color: "#000000", + shadow: false, + bg_enabled: false, + line_height: 1.2, + max_chars_per_line: 10, + }, + }, + { + key: "red_bold", + label: "醒目红字", + emoji: "🔴", + style: { + ...BASE, + font: "优设标题黑", + size: 72, + color: "#ff2d55", + bold: true, + italic: false, + stroke: true, + stroke_width: 6, + stroke_color: "#ffffff", + shadow: true, + shadow_offset_x: 2, + shadow_offset_y: 2, + shadow_blur: 5, + shadow_color: "rgba(0,0,0,0.5)", + bg_enabled: false, + line_height: 1.2, + max_chars_per_line: 9, + }, + }, + { + key: "black_gold", + label: "黑金质感", + emoji: "🟡", + style: { + ...BASE, + font: "思源宋体", + size: 52, + color: "#d4a843", + bold: true, + italic: false, + stroke: false, + shadow: true, + shadow_offset_x: 2, + shadow_offset_y: 2, + shadow_blur: 8, + shadow_color: "rgba(0,0,0,0.8)", + bg_enabled: false, + line_height: 1.25, + max_chars_per_line: 10, + }, + }, + { + key: "neon_blue", + label: "霓虹发光", + emoji: "💙", + style: { + ...BASE, + font: "阿里普惠体Bold", + size: 60, + color: "#00e5ff", + bold: true, + italic: false, + stroke: false, + shadow: true, + shadow_offset_x: 0, + shadow_offset_y: 0, + shadow_blur: 16, + shadow_color: "#00e5ff", + bg_enabled: false, + line_height: 1.2, + max_chars_per_line: 10, + }, + }, + { + key: "bg_black", + label: "黑底白字", + emoji: "⬛", + style: { + ...BASE, + font: "思源黑体Heavy", + size: 52, + color: "#ffffff", + bold: true, + italic: false, + stroke: false, + shadow: false, + bg_enabled: true, + bg_color: "rgba(0,0,0,0.7)", + bg_padding: 16, + bg_radius: 8, + line_height: 1.3, + max_chars_per_line: 10, + }, + }, + { + key: "bg_yellow", + label: "黄底黑字", + emoji: "🟨", + style: { + ...BASE, + font: "抖音美好体", + size: 56, + color: "#000000", + bold: true, + italic: false, + stroke: false, + shadow: false, + bg_enabled: true, + bg_color: "rgba(255,215,0,0.95)", + bg_padding: 14, + bg_radius: 6, + line_height: 1.2, + max_chars_per_line: 9, + }, + }, + { + key: "sweet_pink", + label: "温柔甜美粉", + emoji: "🌸", + style: { + ...BASE, + font: "阿里普惠体Bold", + size: 50, + color: "#ff4081", + bold: false, + italic: false, + stroke: true, + stroke_width: 4, + stroke_color: "#ffffff", + shadow: true, + shadow_offset_x: 2, + shadow_offset_y: 2, + shadow_blur: 4, + shadow_color: "rgba(255,64,129,0.4)", + bg_enabled: false, + line_height: 1.3, + max_chars_per_line: 11, + }, + }, + { + key: "business_dark", + label: "商务深色", + emoji: "💼", + style: { + ...BASE, + font: "思源黑体", + size: 44, + color: "#ffffff", + bold: false, + italic: false, + stroke: false, + shadow: true, + shadow_offset_x: 1, + shadow_offset_y: 1, + shadow_blur: 3, + shadow_color: "rgba(0,0,0,0.8)", + bg_enabled: true, + bg_color: "rgba(24,144,255,0.85)", + bg_padding: 12, + bg_radius: 4, + line_height: 1.3, + max_chars_per_line: 12, + }, + }, + { + key: "minimal_clean", + label: "极简无描边", + emoji: "✨", + style: { + ...BASE, + font: "苹方", + size: 48, + color: "#ffffff", + bold: true, + italic: false, + stroke: false, + shadow: false, + bg_enabled: false, + line_height: 1.3, + max_chars_per_line: 10, + }, + }, +] + +/** 根据 key 获取预设 */ +export function getTitlePreset(key: string): TitlePreset | undefined { + return TITLE_PRESETS.find((p) => p.key === key) +} diff --git a/apps/web/src/components/title/types.ts b/apps/web/src/components/title/types.ts new file mode 100644 index 000000000..098e9d275 --- /dev/null +++ b/apps/web/src/components/title/types.ts @@ -0,0 +1,118 @@ +/** + * 共享标题样式配置类型(#2001 爆款标题样式配置面板升级) + * + * 设计原则: + * 1. 向后兼容:保留旧的 bold/stroke/shadow 布尔字段,新增细粒度字段 + * (stroke_width/stroke_color/shadow_offset_x-y-blur-color/bg_enabled-color-padding-radius/line_height/margin_top/max_chars_per_line)。 + * 2. 后端契约:字段名使用 snake_case,与 title_config dict 直接对齐。 + * 3. line_overrides 支持逐行覆盖(选中某行单独设置颜色/字号/关键词高亮/加粗/斜体)。 + * 4. cover_title_config 为封面独立标题样式,null 表示封面沿用主标题样式。 + */ + +/** 关键词高亮配置 */ +export interface TitleKeywordHighlight { + /** 要高亮的词 */ + word: string + /** 高亮颜色(可选,默认主色反转) */ + color?: string + /** 是否加粗(默认 true) */ + bold?: boolean + /** 额外字号放大倍数(1.0=不变,1.3=放大 30%) */ + scale?: number +} + +/** 单行覆盖配置 */ +export interface TitleLineOverride { + /** 行索引(0-based,按 / 或自动换行后的行序) */ + line_index: number + /** 覆盖后的文字(可选,默认沿用原行) */ + text?: string + /** 覆盖字号(可选) */ + size?: number + /** 覆盖字色(可选) */ + color?: string + /** 覆盖加粗(可选) */ + bold?: boolean + /** 覆盖斜体(可选) */ + italic?: boolean + /** 覆盖描边开关(可选) */ + stroke?: boolean + /** 关键词高亮列表 */ + highlights?: TitleKeywordHighlight[] +} + +/** + * 标题样式配置(不含 title 文字本身,不含 auto_subtitle)。 + * + * cover_title_config 使用 Partial> 递归避免无限类型。 + */ +export interface TitleStyleConfig { + /* ── 基础 ── */ + font: string + size: number + color: string + bold: boolean + italic: boolean + position: "top" | "center" | "bottom" | "custom" + pos_x?: number + pos_y?: number + + /* ── 排版(P0) ── */ + /** 行距倍数(默认 1.2) */ + line_height: number + /** 顶部边距(position=top 时距画面顶部距离,px @720p,默认 24) */ + margin_top: number + /** 每行最大字符数(4-20,超出自动换行;0=不自动换行,使用 / 手动分行) */ + max_chars_per_line: number + + /* ── 描边参数化(P0) ── */ + stroke: boolean + stroke_width: number + stroke_color: string + + /* ── 阴影参数化(P1) ── */ + shadow: boolean + shadow_offset_x: number + shadow_offset_y: number + shadow_blur: number + shadow_color: string + + /* ── 背景色块(P1) ── */ + bg_enabled: boolean + bg_color: string + bg_padding: number + bg_radius: number + + /* ── 逐行独立样式(P1) ── */ + line_overrides: TitleLineOverride[] + + /* ── 封面独立标题配置(P1):null=沿用主标题样式 ── */ + cover_title_config: null | Partial> +} + +/** 默认样式(经典白字黑描边,保持老版本观感) */ +export const DEFAULT_TITLE_STYLE: TitleStyleConfig = { + font: "思源黑体", + size: 48, + color: "#ffffff", + bold: true, + italic: false, + position: "bottom", + line_height: 1.2, + margin_top: 24, + max_chars_per_line: 0, + stroke: true, + stroke_width: 4, + stroke_color: "#000000", + shadow: false, + shadow_offset_x: 2, + shadow_offset_y: 2, + shadow_blur: 4, + shadow_color: "rgba(0,0,0,0.8)", + bg_enabled: false, + bg_color: "rgba(0,0,0,0.5)", + bg_padding: 12, + bg_radius: 8, + line_overrides: [], + cover_title_config: null, +} diff --git a/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx b/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx index 5698549fa..2e2b3e293 100644 --- a/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx +++ b/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx @@ -30,11 +30,7 @@ import { } from "./api/aiAvatar" import { getOrCreateDefaultProject } from "@/api/projects" import type { RenderJob, SentenceTiming } from "./types" -import { - normalizeEmotion, - buildTitleConfigPayload, - buildCoverConfigPayload, -} from "./utils/contract" +import { buildTitleConfigPayload, buildCoverConfigPayload } from "./utils/contract" import { renderTitleToPngDataUrl, getVideoResolution } from "./utils/titleCanvas" /** 面板折叠状态 */ @@ -94,7 +90,7 @@ const AiAvatarPage: React.FC = () => { state.resetTtsPreview() } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [state.scriptText, state.selectedVoice?.voice_id, state.speed, state.emotion, state.style]) + }, [state.scriptText, state.selectedVoice?.voice_id, state.speed, state.style]) const _clearTtsProgressTimer = useCallback(() => { if (ttsProgressTimerRef.current) { @@ -148,7 +144,7 @@ const AiAvatarPage: React.FC = () => { voice_id: state.selectedVoice!.voice_id, script_text: state.scriptText, speed: state.speed, - emotion: normalizeEmotion(state.emotion), + style: state.style, }) _clearTtsProgressTimer() setTtsProgress(100) @@ -175,14 +171,7 @@ const AiAvatarPage: React.FC = () => { }) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - state.selectedVideo, - state.selectedVoice, - state.scriptText, - state.speed, - state.emotion, - state.style, - ]) + }, [state.selectedVideo, state.selectedVoice, state.scriptText, state.speed, state.style]) const handleRetryTts = useCallback(() => { handleGenerateTts() @@ -261,7 +250,6 @@ const AiAvatarPage: React.FC = () => { script_text: state.scriptText, video_url: videoUrl, speed: state.speed, - emotion: normalizeEmotion(state.emotion), style: state.style, } } @@ -306,7 +294,7 @@ const AiAvatarPage: React.FC = () => { state.selectedVoice, state.scriptText, state.speed, - state.emotion, + state.style, state.ttsPreview, ]) @@ -607,8 +595,6 @@ const AiAvatarPage: React.FC = () => { onVoiceSourceChange={state.setVoiceSource} selectedVoice={state.selectedVoice} onSelectVoice={state.setSelectedVoice} - emotion={state.emotion} - onEmotionChange={state.setEmotion} style={state.style} onStyleChange={state.setStyle} speed={state.speed} diff --git a/apps/web/src/pages/ai-avatar/components/PanelLipsyncPreview.tsx b/apps/web/src/pages/ai-avatar/components/PanelLipsyncPreview.tsx index c7d0ba19c..98cbd04ae 100755 --- a/apps/web/src/pages/ai-avatar/components/PanelLipsyncPreview.tsx +++ b/apps/web/src/pages/ai-avatar/components/PanelLipsyncPreview.tsx @@ -29,15 +29,7 @@ function formatTime(seconds: number): string { return `${m}:${s.toString().padStart(2, "0")}` } -/** 字体名 → CSS font-family 映射(与 titleCanvas 字体链对齐) */ -const FONT_FAMILY_MAP: Record = { - 思源黑体: - "'Noto Sans CJK SC', 'Source Han Sans CN', '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["思源黑体"] +import { getFontFamily as getFontFamilyByKey } from "@/components/title/constants" export function PanelLipsyncPreview({ lipsyncJob, @@ -83,63 +75,105 @@ export function PanelLipsyncPreview({ const previewScale = containerWidth > 0 ? containerWidth / 720 : 0.35 const ps = useCallback((v: number) => Math.round(v * previewScale * 100) / 100, [previewScale]) - /** 标题叠加样式(字号/padding/描边/阴影均按 previewScale 缩放,保持与成片视觉一致) */ - const titleOverlayStyle: React.CSSProperties | null = + /** 标题叠加样式(新字段全支持:描边宽色/阴影参数化/背景块/行距/顶部边距/自动换行) */ + const titleOverlayData = titleConfig?.title && containerWidth > 0 ? (() => { + const c = titleConfig as AiAvatarTitleConfig & { + stroke_width?: number + stroke_color?: string + shadow_offset_x?: number + shadow_offset_y?: number + shadow_blur?: number + shadow_color?: string + line_height?: number + margin_top?: number + max_chars_per_line?: number + bg_enabled?: boolean + bg_color?: string + bg_padding?: number + bg_radius?: number + cover_title_config?: Record | null + line_overrides?: unknown[] + } const baseSize = titleConfig.size || 48 const fontSize = ps(baseSize) - // 描边宽度基准 ≈ size * 0.06,最小 1.5px @720p - const strokeW = Math.max(ps(1.5), +(baseSize * 0.06 * previewScale).toFixed(2)) - // 阴影按比例缩放 - const shadowBlur = ps(4) - const shadowOffsetY = ps(2) - // padding / top 边距按比例(基准 8px 对应预览小窗,成片基准 16px,这里 8px 对应约 0.33 缩放) - const padV = ps(16) * 0.5 // ≈ 8px in ~240px container - const padH = ps(24) * 0.5 - + const strokeW = c.stroke ? ps(c.stroke_width ?? 4) : 0 + const strokeC = c.stroke_color || "#000000" + const shBlur = ps(c.shadow_blur ?? 4) + const shOffX = ps(c.shadow_offset_x ?? 2) + const shOffY = ps(c.shadow_offset_y ?? 2) + const shColor = c.shadow_color || "rgba(0,0,0,0.8)" + const lh = c.line_height ?? 1.2 + const mTop = ps(c.margin_top ?? 24) + const bgPad = ps(c.bg_padding ?? 12) + const bgR = ps(c.bg_radius ?? 8) + const maxChars = c.max_chars_per_line ?? 0 + const rawText = titleConfig.title || "" + const lines = (() => { + const manual = rawText + .split(/[//]/) + .map((l) => l.trim()) + .filter(Boolean) + if (!maxChars || maxChars <= 0) return manual + const out: string[] = [] + manual.forEach((seg) => { + for (let i = 0; i < seg.length; i += maxChars) out.push(seg.slice(i, i + maxChars)) + }) + return out + })() + const padV = ps(16) * 0.5 + const textShadow = titleConfig.shadow + ? `${shOffX}px ${shOffY}px ${shBlur}px ${shColor}` + : undefined const style: React.CSSProperties = { position: "absolute", color: titleConfig.color || "#ffffff", - fontFamily: getFontFamily(titleConfig.font || "思源黑体"), + fontFamily: getFontFamilyByKey(titleConfig.font || "source_sans_sc"), fontSize: `${fontSize}px`, fontWeight: titleConfig.bold ? 700 : 400, fontStyle: titleConfig.italic ? "italic" : "normal", textAlign: "center", - width: "90%", - lineHeight: 1.2, - padding: `${ps(4)}px ${padH}px`, - textShadow: titleConfig.shadow - ? `0 ${shadowOffsetY}px ${shadowBlur}px rgba(0,0,0,0.8), 0 0 ${ps(2)}px rgba(0,0,0,0.5)` - : undefined, - WebkitTextStroke: titleConfig.stroke ? `${strokeW}px #000` : undefined, - boxSizing: "border-box", - wordBreak: "break-word", + lineHeight: lh, + WebkitTextStroke: + titleConfig.stroke && strokeW > 0 ? `${strokeW}px ${strokeC}` : undefined, + paintOrder: "stroke fill", + textShadow, whiteSpace: "pre-wrap", + padding: c.bg_enabled ? `${bgPad}px ${bgPad}px` : 0, + background: c.bg_enabled ? c.bg_color || "rgba(0,0,0,0.5)" : "transparent", + borderRadius: c.bg_enabled ? `${bgR}px` : 0, + boxSizing: "border-box", + display: "inline-block", + maxWidth: "94%", + } + const wrap: React.CSSProperties = { + position: "absolute", + left: "50%", + width: "100%", + display: "flex", + justifyContent: "center", + pointerEvents: onTitlePositionChange ? "auto" : "none", } - if ( titleConfig.position === "custom" && titleConfig.pos_x != null && titleConfig.pos_y != null ) { - style.left = `${titleConfig.pos_x}%` - style.top = `${titleConfig.pos_y}%` - style.transform = "translateX(-50%) translateY(-50%)" + wrap.left = `${titleConfig.pos_x}%` + wrap.top = `${titleConfig.pos_y}%` + wrap.transform = "translate(-50%, -50%)" } else if (titleConfig.position === "top") { - style.left = "50%" - style.top = padV - style.transform = "translateX(-50%)" + wrap.top = `${padV + mTop}px` + wrap.transform = "translateX(-50%)" } else if (titleConfig.position === "bottom") { - style.left = "50%" - style.bottom = padV - style.transform = "translateX(-50%)" + wrap.bottom = `${padV}px` + wrap.transform = "translateX(-50%)" } else { - style.left = "50%" - style.top = "50%" - style.transform = "translateX(-50%) translateY(-50%)" + wrap.top = "50%" + wrap.transform = "translate(-50%, -50%)" } - return style + return { style, wrap, lines } })() : null @@ -252,25 +286,23 @@ export function PanelLipsyncPreview({ {isDone && lipsyncJob?.output_video_url ? (
diff --git a/apps/web/src/pages/ai-avatar/components/PanelTitleConfig.tsx b/apps/web/src/pages/ai-avatar/components/PanelTitleConfig.tsx index 170b80a5a..9a6ff5bda 100644 --- a/apps/web/src/pages/ai-avatar/components/PanelTitleConfig.tsx +++ b/apps/web/src/pages/ai-avatar/components/PanelTitleConfig.tsx @@ -13,7 +13,8 @@ import TitleStylePanel from "@/pages/generate/components/title/TitleStylePanel" import TitleLibraryAutoComplete from "@/pages/generate/components/title/TitleLibraryAutoComplete" import type { TitleOption } from "@/pages/generate/components/title/TitleLibraryAutoComplete" import type { TitleSettings } from "@/pages/generate/types" -import { POSITION_OPTIONS, FONT_OPTIONS, TITLE_PRESETS } from "@/pages/generate/constants" +import { POSITION_OPTIONS } from "@/pages/generate/constants" +import { FONT_OPTIONS, TITLE_PRESETS } from "@/components/title/constants" import type { AiAvatarTitleConfig } from "../types" // #1894: 标题数据源切换到文案库,取 script.title 作为候选 import { getScripts } from "@/api/scripts" @@ -49,9 +50,60 @@ const PanelTitleConfig: React.FC = ({ titleConfig, onUpda .catch(() => setTitleOptions([])) }, []) - /** AiAvatarTitleConfig → TitleSettings(补齐 aiAutoSelect / 自由坐标字段) */ - const titleSettings: TitleSettings = useMemo( - () => ({ + /** AiAvatarTitleConfig (snake_case) → TitleSettings (camelCase) */ + const titleSettings: TitleSettings = useMemo(() => { + const c = titleConfig as AiAvatarTitleConfig & { + stroke_width?: number + stroke_color?: string + shadow_offset_x?: number + shadow_offset_y?: number + shadow_blur?: number + shadow_color?: string + line_height?: number + margin_top?: number + max_chars_per_line?: number + bg_enabled?: boolean + bg_color?: string + bg_padding?: number + bg_radius?: number + cover_title_config?: { + title?: string + font?: string + size?: number + font_size?: number + color?: string + font_color?: string + bold?: boolean + italic?: boolean + position?: string + stroke?: { enabled: boolean; width?: number; color?: string } | boolean + stroke_width?: number + stroke_color?: string + shadow?: + | { + enabled: boolean + offset_x?: number + offset_y?: number + blur?: number + color?: string + } + | boolean + shadow_offset_x?: number + shadow_offset_y?: number + shadow_blur?: number + shadow_color?: string + background?: { enabled: boolean; color?: string; padding?: number; radius?: number } + bg_enabled?: boolean + bg_color?: string + bg_padding?: number + bg_radius?: number + line_height?: number + margin_top?: number + max_chars_per_line?: number + } | null + line_overrides?: unknown[] + } + return { aiAutoSelect: false, title: titleConfig.title, position: titleConfig.position, @@ -64,24 +116,219 @@ const PanelTitleConfig: React.FC = ({ titleConfig, onUpda color: titleConfig.color, posX: null, posY: null, - }), - [titleConfig], - ) + lineHeight: c.line_height ?? 1.2, + marginTop: c.margin_top ?? 24, + maxCharsPerLine: c.max_chars_per_line ?? 0, + strokeWidth: c.stroke_width ?? 4, + strokeColor: c.stroke_color ?? "#000000", + shadowOffsetX: c.shadow_offset_x ?? 2, + shadowOffsetY: c.shadow_offset_y ?? 2, + shadowBlur: c.shadow_blur ?? 4, + shadowColor: c.shadow_color ?? "rgba(0,0,0,0.8)", + bgEnabled: !!c.bg_enabled, + bgColor: c.bg_color ?? "rgba(0,0,0,0.5)", + bgPadding: c.bg_padding ?? 12, + bgRadius: c.bg_radius ?? 8, + lineOverrides: Array.isArray(c.line_overrides) ? c.line_overrides : [], + coverTitle: (() => { + const ct = c.cover_title_config as + | null + | (AiAvatarTitleConfig & { + font_size?: number + font_color?: string + stroke?: { enabled?: boolean; width?: number; color?: string } | boolean + stroke_width?: number + stroke_color?: string + shadow?: + | { + enabled?: boolean + offset_x?: number + offset_y?: number + blur?: number + color?: string + } + | boolean + shadow_offset_x?: number + shadow_offset_y?: number + shadow_blur?: number + shadow_color?: string + background?: { enabled?: boolean; color?: string; padding?: number; radius?: number } + bg_enabled?: boolean + bg_color?: string + bg_padding?: number + bg_radius?: number + }) + if (!ct) return null + const ctStroke = ct.stroke as + { enabled?: boolean; width?: number; color?: string } | boolean | undefined + const ctShadow = ct.shadow as + | { + enabled?: boolean + offset_x?: number + offset_y?: number + blur?: number + color?: string + } + | boolean + | undefined + const ctBg = ct.background as + { enabled?: boolean; color?: string; padding?: number; radius?: number } | undefined + return { + title: ct.title, + font: ct.font, + size: ct.font_size ?? ct.size, + color: ct.font_color ?? ct.color, + bold: ct.bold, + italic: ct.italic, + position: ct.position, + stroke: + typeof ctStroke === "object" && ctStroke ? ctStroke.enabled !== false : !!ctStroke, + strokeWidth: + (typeof ctStroke === "object" && ctStroke ? ctStroke.width : undefined) ?? + ct.stroke_width ?? + 4, + strokeColor: + (typeof ctStroke === "object" && ctStroke ? ctStroke.color : undefined) ?? + ct.stroke_color ?? + "#000000", + shadow: + typeof ctShadow === "object" && ctShadow ? ctShadow.enabled !== false : !!ctShadow, + shadowOffsetX: + (typeof ctShadow === "object" && ctShadow ? ctShadow.offset_x : undefined) ?? + ct.shadow_offset_x ?? + 2, + shadowOffsetY: + (typeof ctShadow === "object" && ctShadow ? ctShadow.offset_y : undefined) ?? + ct.shadow_offset_y ?? + 2, + shadowBlur: + (typeof ctShadow === "object" && ctShadow ? ctShadow.blur : undefined) ?? + ct.shadow_blur ?? + 4, + shadowColor: + (typeof ctShadow === "object" && ctShadow ? ctShadow.color : undefined) ?? + ct.shadow_color ?? + "rgba(0,0,0,0.8)", + bgEnabled: ctBg?.enabled ?? !!ct.bg_enabled, + bgColor: ctBg?.color ?? ct.bg_color ?? "rgba(0,0,0,0.5)", + bgPadding: ctBg?.padding ?? ct.bg_padding ?? 12, + bgRadius: ctBg?.radius ?? ct.bg_radius ?? 8, + } + })(), + } + }, [titleConfig]) - /** 应用预设:与智能剪辑一致,只覆盖 color/bold/italic/stroke/shadow,不改变字号 */ + /** 应用预设:覆盖新细粒度字段(颜色/描边/阴影/字号/字体等) */ const handleApplyPreset = (presetKey: string) => { const preset = TITLE_PRESETS.find((p) => p.key === presetKey) if (!preset) return setActivePreset(presetKey) + const st = preset.style || {} onUpdate({ - color: preset.style.color, - bold: preset.style.bold, - italic: preset.style.italic, - stroke: preset.style.stroke, - shadow: preset.style.shadow, + font: st.font, + size: st.size, + color: st.color, + bold: st.bold, + italic: st.italic, + stroke: st.stroke, + stroke_width: st.stroke_width, + stroke_color: st.stroke_color, + shadow: st.shadow, + shadow_offset_x: st.shadow_offset_x, + shadow_offset_y: st.shadow_offset_y, + shadow_blur: st.shadow_blur, + shadow_color: st.shadow_color, + bg_enabled: st.bg_enabled, + bg_color: st.bg_color, + bg_padding: st.bg_padding, + bg_radius: st.bg_radius, + line_overrides: [], + cover_title_config: null, }) } + /** 字段 patch 透传:TitleStylePanel 的 onUpdateStyle(camelCase → snake_case) */ + const handleUpdateStyle = (patch: Partial) => { + const snake: Record = {} + const map: Record = { + lineHeight: "line_height", + marginTop: "margin_top", + maxCharsPerLine: "max_chars_per_line", + strokeWidth: "stroke_width", + strokeColor: "stroke_color", + shadowOffsetX: "shadow_offset_x", + shadowOffsetY: "shadow_offset_y", + shadowBlur: "shadow_blur", + shadowColor: "shadow_color", + bgEnabled: "bg_enabled", + bgColor: "bg_color", + bgPadding: "bg_padding", + bgRadius: "bg_radius", + lineOverrides: "line_overrides", + coverTitle: "cover_title_config", + } + Object.entries(patch).forEach(([k, v]) => { + if (k === "coverTitle" && v && typeof v === "object") { + const ct = v as { + title?: string + font?: string + size?: number + color?: string + bold?: boolean + italic?: boolean + position?: string + stroke?: boolean + strokeWidth?: number + strokeColor?: string + shadow?: boolean + shadowOffsetX?: number + shadowOffsetY?: number + shadowBlur?: number + shadowColor?: string + bgEnabled?: boolean + bgColor?: string + bgPadding?: number + bgRadius?: number + lineHeight?: number + marginTop?: number + maxCharsPerLine?: number + } + snake.cover_title_config = { + title: ct.title, + font: ct.font, + font_size: ct.size, + font_color: ct.color, + bold: ct.bold, + italic: ct.italic, + position: ct.position, + stroke: ct.stroke + ? { enabled: true, width: ct.strokeWidth ?? 4, color: ct.strokeColor ?? "#000" } + : { enabled: false }, + shadow: ct.shadow + ? { + enabled: true, + offset_x: ct.shadowOffsetX ?? 2, + offset_y: ct.shadowOffsetY ?? 2, + blur: ct.shadowBlur ?? 4, + color: ct.shadowColor ?? "rgba(0,0,0,0.8)", + } + : { enabled: false }, + background: ct.bgEnabled + ? { enabled: true, color: ct.bgColor, padding: ct.bgPadding, radius: ct.bgRadius } + : { enabled: false }, + line_height: ct.lineHeight, + margin_top: ct.marginTop, + max_chars_per_line: ct.maxCharsPerLine, + } + } else if (map[k]) { + snake[map[k]] = v + } else { + snake[k] = v + } + }) + onUpdate(snake) + } + return (
{/* 主标题输入 — TextArea 多行 + 标题库选择 */} @@ -125,8 +372,13 @@ const PanelTitleConfig: React.FC = ({ titleConfig, onUpda onToggleStroke={() => onUpdate({ stroke: !titleConfig.stroke })} onToggleShadow={() => onUpdate({ shadow: !titleConfig.shadow })} onApplyPreset={handleApplyPreset} + onUpdateStyle={handleUpdateStyle} + showCoverToggle + previewWidth={280} activePreset={activePreset} - titlePresets={TITLE_PRESETS} + titlePresets={ + TITLE_PRESETS as unknown as React.ComponentProps["titlePresets"] + } POSITION_OPTIONS={POSITION_OPTIONS} FONT_OPTIONS={FONT_OPTIONS} /> diff --git a/apps/web/src/pages/ai-avatar/components/PanelVoiceSelector.tsx b/apps/web/src/pages/ai-avatar/components/PanelVoiceSelector.tsx index 7bb1f2eb9..3c9ebf42a 100644 --- a/apps/web/src/pages/ai-avatar/components/PanelVoiceSelector.tsx +++ b/apps/web/src/pages/ai-avatar/components/PanelVoiceSelector.tsx @@ -1,20 +1,17 @@ /** * AI数字人 — 配音库面板(面板3) - * 音色来源切换(系统预设 / 我的音色)、音色选择与试听、情绪/语速/语言参数 + * 音色来源切换(系统预设 / 我的音色)、音色选择与试听、风格/语速/语言参数 */ import { useEffect, useRef, useState } from "react" import { message } from "antd" import { fetchVoices } from "@/api/voices/voices" import { previewTts } from "@/api/tts" -import { normalizeEmotion } from "../utils/contract" import TtsStyleSelector from "@/components/voice/TtsStyleSelector" import type { TtsStyle } from "@/api/tts/styles" import type { UnifiedVoiceItem } from "@/api/voices/types" import { type VoiceSource, - type VoiceEmotion, type VoiceLanguage, - VOICE_EMOTION_OPTIONS, PRESET_VOICE_LANGUAGE_OPTIONS, CLONE_VOICE_LANGUAGE_OPTIONS, } from "../types" @@ -24,8 +21,6 @@ interface PanelVoiceSelectorProps { onVoiceSourceChange: (source: VoiceSource) => void selectedVoice: UnifiedVoiceItem | null onSelectVoice: (voice: UnifiedVoiceItem) => void - emotion: VoiceEmotion - onEmotionChange: (e: VoiceEmotion) => void style: TtsStyle onStyleChange: (s: TtsStyle) => void speed: number @@ -39,8 +34,6 @@ export function PanelVoiceSelector({ onVoiceSourceChange, selectedVoice, onSelectVoice, - emotion, - onEmotionChange, style, onStyleChange, speed, @@ -152,14 +145,12 @@ export function PanelVoiceSelector({ return } const targetId = voice.voice_clone_profile_id || voice.id - // DEBUG: 打印请求参数,帮助定位 /tts/preview 失败原因 setPreviewingId(voice.id) try { const res = await previewTts({ text: VOICE_PREVIEW_TEXT, voice_id: targetId, speed: speed, // 透传用户选择的语速(#1822) - emotion: normalizeEmotion(emotion), // 情绪中文→英文枚举 style, }) if (!res.audio_url) { @@ -171,7 +162,6 @@ export function PanelVoiceSelector({ playAudioUrl(voice.id, res.audio_url) } catch (err) { setPreviewingId(null) - // DEBUG: 打印详细错误信息 console.error("[AI数字人-克隆试听] previewTts 失败:", { status: (err as { response?: { status?: number } })?.response?.status, data: (err as { response?: { data?: unknown } })?.response?.data, @@ -280,23 +270,6 @@ export function PanelVoiceSelector({ {/* 配音参数 */}
-
- - -