From b2601bc87161c92d5bb969a7fcd36614700b3e83 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?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0: - 描边宽度滑块 (0-20px) + 描边颜色选择器(色板+自定义) - 每行最大字符数滑块 (0-20)、行距/顶部边距滑块 - 新增4款爆款字体(优设标题黑/阿里普惠体Bold/抖音美好体/思源黑体Heavy) - 抖音爆款黄预设(优设标题黑/80px/#ffd700/bold/8px黑描边/带阴影) P1: - 阴影参数化(X/Y偏移+模糊半径+颜色) - 背景色块(颜色+透明度+内边距+圆角) - Canvas迷你预览(预设缩略+面板顶部WYSIWYG实时预览) - 标题封面独立配置入口(checkbox+默认填充主样式90%字号) - 10个预设含emoji标签+Canvas缩略图 - Tabs分组(基础/描边/阴影/背景/排版/封面) 契约与渲染: - title_config新增 stroke{enabled,width,color}/shadow{enabled,offset_x,offset_y,blur,color}/line_height/margin_top/max_chars_per_line/background{enabled,color,padding,radius}/line_overrides/cover_title_config - titleCanvas.ts 全字段渲染:自动换行/描边宽色/阴影参数/背景色块/逐行覆盖/关键词高亮 - contract.ts buildTitleConfigPayload 序列化全部新字段(snake_case) - useGenerateVideo/useBatchCovers/usePlanConfigLoader 全链路适配 - AiAvatarTitleConfig↔TitleSettings 双向映射(snake_case↔camelCase) - PanelLipsyncPreview CSS预览支持 WebkitTextStrokeWidth/Color、textShadow参数化、背景块、自动换行、行距 向后兼容:保留旧的bold/stroke/shadow布尔字段与旧props;旧草稿加载新字段自动填默认值。 --- apps/web/src/components/title/constants.ts | 361 +++++++++++ apps/web/src/components/title/types.ts | 118 ++++ .../components/PanelLipsyncPreview.tsx | 138 +++-- .../ai-avatar/components/PanelTitleConfig.tsx | 231 ++++++- 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 | 12 +- .../components/title/TitleMiniPreview.tsx | 221 +++++++ .../components/title/TitleStylePanel.css | 252 ++++++++ .../components/title/TitleStylePanel.tsx | 584 +++++++++++++++--- apps/web/src/pages/generate/constants.ts | 21 +- .../pages/generate/hooks/useBatchCovers.ts | 31 +- .../hooks/useGenerateFormState/index.ts | 15 + .../usePlanConfigLoader.ts | 182 +++++- .../pages/generate/hooks/useGenerateVideo.ts | 87 ++- .../useStep4Title/useTitleStyleUpdaters.ts | 39 +- apps/web/src/pages/generate/types.ts | 108 +++- 20 files changed, 2545 insertions(+), 297 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/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/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..729c0f70c 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,170 @@ 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: c.cover_title_config + ? { + title: c.cover_title_config.title, + font: c.cover_title_config.font, + size: c.cover_title_config.font_size ?? c.cover_title_config.size, + color: c.cover_title_config.font_color ?? c.cover_title_config.color, + bold: c.cover_title_config.bold, + italic: c.cover_title_config.italic, + position: c.cover_title_config.position, + stroke: c.cover_title_config.stroke?.enabled ?? !!c.cover_title_config.stroke, + strokeWidth: + c.cover_title_config.stroke?.width ?? c.cover_title_config.stroke_width ?? 4, + strokeColor: + c.cover_title_config.stroke?.color ?? c.cover_title_config.stroke_color ?? "#000000", + shadow: c.cover_title_config.shadow?.enabled ?? !!c.cover_title_config.shadow, + shadowOffsetX: + c.cover_title_config.shadow?.offset_x ?? c.cover_title_config.shadow_offset_x ?? 2, + shadowOffsetY: + c.cover_title_config.shadow?.offset_y ?? c.cover_title_config.shadow_offset_y ?? 2, + shadowBlur: c.cover_title_config.shadow?.blur ?? c.cover_title_config.shadow_blur ?? 4, + shadowColor: + c.cover_title_config.shadow?.color ?? + c.cover_title_config.shadow_color ?? + "rgba(0,0,0,0.8)", + bgEnabled: + c.cover_title_config.background?.enabled ?? !!c.cover_title_config.bg_enabled, + bgColor: + c.cover_title_config.background?.color ?? + c.cover_title_config.bg_color ?? + "rgba(0,0,0,0.5)", + bgPadding: + c.cover_title_config.background?.padding ?? c.cover_title_config.bg_padding ?? 12, + bgRadius: + c.cover_title_config.background?.radius ?? c.cover_title_config.bg_radius ?? 8, + } + : null, + } + }, [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 +323,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/types.ts b/apps/web/src/pages/ai-avatar/types.ts index ac13d76a8..e7c044a8d 100644 --- a/apps/web/src/pages/ai-avatar/types.ts +++ b/apps/web/src/pages/ai-avatar/types.ts @@ -100,7 +100,7 @@ export interface BRollSegment { pip_scale: number } -/* ── 标题配置 ── */ +/* ── 标题配置(#2001 升级:细粒度描边/阴影/背景/排版/逐行/封面独立标题) ── */ export interface AiAvatarTitleConfig { title: string position: string @@ -115,6 +115,42 @@ export interface AiAvatarTitleConfig { /** 自定义位置坐标(position=custom 时生效,百分比 0-100) */ pos_x?: number pos_y?: number + + /* ── 排版 ── */ + line_height: number + margin_top: number + max_chars_per_line: number + + /* ── 描边参数化 ── */ + stroke_width: number + stroke_color: string + + /* ── 阴影参数化 ── */ + shadow_offset_x: number + shadow_offset_y: number + shadow_blur: number + shadow_color: string + + /* ── 背景色块 ── */ + bg_enabled: boolean + bg_color: string + bg_padding: number + bg_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 }> + }> + + /* ── 封面独立标题(null=沿用主标题) ── */ + cover_title_config: null | Partial } /* ── 封面配置 ── */ @@ -149,12 +185,27 @@ export const DEFAULT_TITLE_CONFIG: AiAvatarTitleConfig = { size: 48, bold: true, italic: false, - stroke: false, + stroke: true, shadow: false, color: "#ffffff", auto_subtitle: true, pos_x: undefined, pos_y: undefined, + line_height: 1.2, + margin_top: 24, + max_chars_per_line: 0, + stroke_width: 4, + stroke_color: "#000000", + 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, } export const DEFAULT_COVER_CONFIG: AiAvatarCoverConfig = { diff --git a/apps/web/src/pages/ai-avatar/utils/contract.ts b/apps/web/src/pages/ai-avatar/utils/contract.ts index f660f07d6..31266caca 100644 --- a/apps/web/src/pages/ai-avatar/utils/contract.ts +++ b/apps/web/src/pages/ai-avatar/utils/contract.ts @@ -67,6 +67,37 @@ export function buildTitleConfigPayload( const text = (cfg.title || "").trim() if (!text) return {} const position = cfg.position || "bottom" + const anyCfg = cfg 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 + line_overrides?: unknown[] + cover_title_config?: Record | null + } + const strokeWidth = anyCfg.stroke_width != null ? anyCfg.stroke_width : 4 + const strokeColor = anyCfg.stroke_color || "#000000" + const shadowOffsetX = anyCfg.shadow_offset_x != null ? anyCfg.shadow_offset_x : 2 + const shadowOffsetY = anyCfg.shadow_offset_y != null ? anyCfg.shadow_offset_y : 2 + const shadowBlur = anyCfg.shadow_blur != null ? anyCfg.shadow_blur : 4 + const shadowColor = anyCfg.shadow_color || "rgba(0,0,0,0.8)" + const lineHeight = anyCfg.line_height != null ? anyCfg.line_height : 1.2 + const marginTop = anyCfg.margin_top != null ? anyCfg.margin_top : 24 + const maxCharsPerLine = anyCfg.max_chars_per_line ?? 0 + const bgEnabled = !!anyCfg.bg_enabled + const bgColor = anyCfg.bg_color || "rgba(0,0,0,0.5)" + const bgPadding = anyCfg.bg_padding != null ? anyCfg.bg_padding : 12 + const bgRadius = anyCfg.bg_radius != null ? anyCfg.bg_radius : 8 + const payload: Record = { text, enabled: true, @@ -75,16 +106,68 @@ export function buildTitleConfigPayload( font_color: cfg.color || "#ffffff", position, bold: !!cfg.bold, - stroke: cfg.stroke ? { enabled: true, width: 2, color: "#000000" } : { enabled: false }, - shadow: cfg.shadow - ? { enabled: true, color: "#000000", offset_x: 2, offset_y: 2 } + italic: !!cfg.italic, + stroke: cfg.stroke + ? { enabled: true, width: strokeWidth, color: strokeColor } : { enabled: false }, + shadow: cfg.shadow + ? { + enabled: true, + color: shadowColor, + offset_x: shadowOffsetX, + offset_y: shadowOffsetY, + blur: shadowBlur, + } + : { enabled: false }, + line_height: lineHeight, + margin_top: marginTop, + max_chars_per_line: maxCharsPerLine, + background: bgEnabled + ? { enabled: true, color: bgColor, padding: bgPadding, radius: bgRadius } + : { enabled: false }, + line_overrides: Array.isArray(anyCfg.line_overrides) ? anyCfg.line_overrides : [], } // 自定义坐标(custom 位置) if (position === "custom" && typeof cfg.pos_x === "number" && typeof cfg.pos_y === "number") { payload.pos_x = cfg.pos_x payload.pos_y = cfg.pos_y } + // 封面独立标题配置 + if (anyCfg.cover_title_config) { + const ctc = anyCfg.cover_title_config + payload.cover_title_config = { + title: ctc.title, + font: ctc.font, + font_size: ctc.size, + font_color: ctc.color, + position: ctc.position, + bold: ctc.bold, + italic: ctc.italic, + stroke: ctc.stroke + ? { enabled: true, width: ctc.stroke_width ?? 4, color: ctc.stroke_color ?? "#000000" } + : { enabled: false }, + shadow: ctc.shadow + ? { + enabled: true, + color: ctc.shadow_color ?? shadowColor, + offset_x: ctc.shadow_offset_x ?? 2, + offset_y: ctc.shadow_offset_y ?? 2, + blur: ctc.shadow_blur ?? 4, + } + : { enabled: false }, + line_height: ctc.line_height ?? lineHeight, + margin_top: ctc.margin_top ?? marginTop, + max_chars_per_line: ctc.max_chars_per_line ?? maxCharsPerLine, + background: ctc.bg_enabled + ? { + enabled: true, + color: ctc.bg_color ?? bgColor, + padding: ctc.bg_padding ?? bgPadding, + radius: ctc.bg_radius ?? bgRadius, + } + : { enabled: false }, + } + } // 前端 Canvas 渲染好的 PNG dataURL(所见即所得,后端优先 overlay 此图片图层) if (titleImageDataUrl) { payload.title_image_dataurl = titleImageDataUrl diff --git a/apps/web/src/pages/ai-avatar/utils/titleCanvas.ts b/apps/web/src/pages/ai-avatar/utils/titleCanvas.ts index cbdbc8426..9c4e03456 100644 --- a/apps/web/src/pages/ai-avatar/utils/titleCanvas.ts +++ b/apps/web/src/pages/ai-avatar/utils/titleCanvas.ts @@ -9,37 +9,78 @@ * 按 videoWidth / 720 得到 scale,所有长度类参数乘以 scale, * 保证 1080p / 4K 成片里标题视觉大小与预览一致。 */ +import { getFontFamily } from "@/components/title/constants" import type { AiAvatarTitleConfig } from "../types" export interface RenderTitlePngOptions { - /** 标题配置 */ titleConfig: AiAvatarTitleConfig - /** 视频宽度(像素),默认 720 */ videoWidth?: number - /** 视频高度(像素),默认 1280 */ videoHeight?: number + useCoverTitle?: boolean } -/** - * 将标题渲染为透明背景 PNG 的 dataURL(data:image/png;base64,...) - * Canvas 尺寸与视频一致,保证叠加时 1:1 像素对齐。 - * - * 标题为空时返回 null。 - */ -export function renderTitleToPngDataUrl(opts: RenderTitlePngOptions): string | null { - const { titleConfig, videoWidth = 720, videoHeight = 1280 } = opts - if (!titleConfig) return null - const rawTitle = (titleConfig.title || "").trim() - if (!rawTitle) return null - - // 按 / 或 / 分割为多行 - const lines = rawTitle +function autoWrapLines(rawTitle: string, maxCharsPerLine: number): string[] { + const manual = rawTitle .split(/[//]/) .map((l) => l.trim()) .filter((l) => l.length > 0) + if (!maxCharsPerLine || maxCharsPerLine <= 0) return manual + const out: string[] = [] + manual.forEach((seg) => { + for (let i = 0; i < seg.length; i += maxCharsPerLine) { + out.push(seg.slice(i, i + maxCharsPerLine)) + } + }) + return out +} + +export function renderTitleToPngDataUrl(opts: RenderTitlePngOptions): string | null { + const { titleConfig, videoWidth = 720, videoHeight = 1280, useCoverTitle } = opts + if (!titleConfig) return null + + type TitleCfgExt = 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 + 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?: Partial | null + pos_x?: number + pos_y?: number + } + const cfg: TitleCfgExt = + useCoverTitle && titleConfig.cover_title_config + ? ({ + ...(titleConfig as TitleCfgExt), + ...(titleConfig.cover_title_config as object), + } as TitleCfgExt) + : (titleConfig as TitleCfgExt) + + const rawTitle = (cfg.title || "").trim() + if (!rawTitle) return null + + const maxCharsPerLine = cfg.max_chars_per_line ?? 0 + const lines = autoWrapLines(rawTitle, maxCharsPerLine) if (lines.length === 0) return null - // 分辨率缩放系数:基准 720p,所有长度类参数乘以 scale const scale = videoWidth / 720 const r = (v: number) => Math.round(v * scale) @@ -49,86 +90,183 @@ export function renderTitleToPngDataUrl(opts: RenderTitlePngOptions): string | n const ctx = canvas.getContext("2d") if (!ctx) return null - const baseSize = Math.max(12, Math.round(titleConfig.size || 48)) + const baseSize = Math.max(12, Math.round(cfg.size || 48)) const size = r(baseSize) - const bold = !!titleConfig.bold - const italic = !!titleConfig.italic - const color = titleConfig.color || "#ffffff" - const stroke = !!titleConfig.stroke - const shadow = !!titleConfig.shadow + const bold = !!cfg.bold + const italic = !!cfg.italic + const color = cfg.color || "#ffffff" + const stroke = !!cfg.stroke + const shadow = !!cfg.shadow - // 字体族 fallback 链:优先中文字体 - const fontFamily = - '"Noto Sans CJK SC","Source Han Sans CN","PingFang SC","Microsoft YaHei",sans-serif' - const fontParts: string[] = [] - if (italic) fontParts.push("italic") - if (bold) fontParts.push("bold") - fontParts.push(`${size}px`, fontFamily) - ctx.font = fontParts.join(" ") + const strokeWidthBase = cfg.stroke_width != null ? cfg.stroke_width : 4 + const strokeColor = cfg.stroke_color || "#000000" + const shadowOffsetXBase = cfg.shadow_offset_x != null ? cfg.shadow_offset_x : 2 + const shadowOffsetYBase = cfg.shadow_offset_y != null ? cfg.shadow_offset_y : 2 + const shadowBlurBase = cfg.shadow_blur != null ? cfg.shadow_blur : 4 + const shadowColor = cfg.shadow_color || "rgba(0,0,0,0.8)" + const lineHeightScale = cfg.line_height != null ? cfg.line_height : 1.2 + const marginTopBase = cfg.margin_top != null ? cfg.margin_top : 24 + const bgEnabled = !!cfg.bg_enabled + const bgColor = cfg.bg_color || "rgba(0,0,0,0.5)" + const bgPaddingBase = cfg.bg_padding != null ? cfg.bg_padding : 12 + const bgRadiusBase = cfg.bg_radius != null ? cfg.bg_radius : 8 + + const fontKey = cfg.font || "思源黑体" + const fontFamily = getFontFamily(fontKey) + const setFont = (sz: number, bd: boolean, it: boolean) => { + const parts: string[] = [] + if (it) parts.push("italic") + if (bd) parts.push("bold") + parts.push(`${sz}px`, fontFamily) + ctx.font = parts.join(" ") + } + setFont(size, bold, italic) ctx.fillStyle = color ctx.textAlign = "center" ctx.textBaseline = "middle" - // 阴影(shadow=true 时开启)——按 scale 缩放 - if (shadow) { - ctx.shadowColor = "rgba(0,0,0,0.8)" - ctx.shadowBlur = r(4) - ctx.shadowOffsetX = 0 - ctx.shadowOffsetY = r(2) - } + const lineGap = size * lineHeightScale + const totalTextH = lines.length * lineGap - (lineGap - size) + let maxLineW = 0 + lines.forEach((l: string) => { + const m = ctx.measureText(l).width + if (m > maxLineW) maxLineW = m + }) - // 位置计算:与 PanelLipsyncPreview 的 CSS 对齐(按 scale 缩放 PAD) const PAD = r(16) let centerX = videoWidth / 2 - const position = titleConfig.position || "bottom" - const lineGap = size * 1.2 - const totalTextH = lines.length * lineGap - (lineGap - size) // 所有行的总高度 - // 文本块顶部 y(textBaseline=middle 时首行基线) + const position = cfg.position || "bottom" let firstLineY: number - if ( - position === "custom" && - typeof titleConfig.pos_x === "number" && - typeof titleConfig.pos_y === "number" - ) { - centerX = (Math.max(0, Math.min(100, titleConfig.pos_x)) / 100) * videoWidth - const centerY = (Math.max(0, Math.min(100, titleConfig.pos_y)) / 100) * videoHeight + if (position === "custom" && typeof cfg.pos_x === "number" && typeof cfg.pos_y === "number") { + centerX = (Math.max(0, Math.min(100, cfg.pos_x)) / 100) * videoWidth + const centerY = (Math.max(0, Math.min(100, cfg.pos_y)) / 100) * videoHeight firstLineY = centerY - totalTextH / 2 + size / 2 } else if (position === "top") { - // 顶部:y = size/2 + PAD - firstLineY = size / 2 + PAD + firstLineY = size / 2 + PAD + r(marginTopBase) } else if (position === "center") { firstLineY = videoHeight / 2 - totalTextH / 2 + size / 2 } else { - // bottom(默认) firstLineY = videoHeight - totalTextH - PAD + size / 2 } - // 描边参数:描边 lineWidth 按 scale 缩放(基准 size * 0.06,最小 2px @720p) - const doStroke = stroke - const strokeWidth = Math.max(r(2), Math.round(size * 0.06)) - // 逐行绘制 - lines.forEach((line, idx) => { + if (shadow) { + ctx.shadowColor = shadowColor + ctx.shadowBlur = r(shadowBlurBase) + ctx.shadowOffsetX = r(shadowOffsetXBase) + ctx.shadowOffsetY = r(shadowOffsetYBase) + } else { + ctx.shadowColor = "rgba(0,0,0,0)" + ctx.shadowBlur = 0 + ctx.shadowOffsetX = 0 + ctx.shadowOffsetY = 0 + } + + const bgPad = r(bgPaddingBase) + const bgR = r(bgRadiusBase) + const bgW = maxLineW + bgPad * 2 + const bgH = totalTextH + bgPad * 2 + const bgX = centerX - bgW / 2 + const bgY = firstLineY - size / 2 - bgPad + + if (bgEnabled) { + ctx.save() + ctx.shadowColor = "rgba(0,0,0,0)" + ctx.shadowBlur = 0 + ctx.shadowOffsetX = 0 + ctx.shadowOffsetY = 0 + ctx.fillStyle = bgColor + if ( + bgR > 0 && + ( + ctx as CanvasRenderingContext2D & { + roundRect?: (x: number, y: number, w: number, h: number, r: number) => void + } + ).roundRect + ) { + ;( + ctx as CanvasRenderingContext2D & { + roundRect?: (x: number, y: number, w: number, h: number, r: number) => void + } + ).roundRect(bgX, bgY, bgW, bgH, bgR) + ctx.fill() + } else { + ctx.fillRect(bgX, bgY, bgW, bgH) + } + ctx.restore() + } + + const sw = stroke ? Math.max(r(1), r(strokeWidthBase)) : 0 + const lineOverrides = cfg.line_overrides || [] + lines.forEach((line: string, idx: number) => { const y = firstLineY + idx * lineGap - if (doStroke) { - const prevShadowColor = ctx.shadowColor - const prevShadowBlur = ctx.shadowBlur - // 描边不要带阴影(避免黑色描边发虚) + const override = lineOverrides.find((lo) => lo.line_index === idx) + const lineSize = override?.size ? r(Math.max(12, Math.round(override.size))) : size + const lineColor = override?.color || color + const lineBold = override?.bold != null ? !!override.bold : bold + const lineItalic = override?.italic != null ? !!override.italic : italic + const lineStroke = override?.stroke != null ? !!override.stroke : stroke + + setFont(lineSize, lineBold, lineItalic) + ctx.fillStyle = lineColor + + if (shadow) { + ctx.shadowColor = shadowColor + ctx.shadowBlur = r(shadowBlurBase) + ctx.shadowOffsetX = r(shadowOffsetXBase) + ctx.shadowOffsetY = r(shadowOffsetYBase) + } else { ctx.shadowColor = "rgba(0,0,0,0)" ctx.shadowBlur = 0 - ctx.lineWidth = strokeWidth - ctx.strokeStyle = "#000000" + ctx.shadowOffsetX = 0 + ctx.shadowOffsetY = 0 + } + + const lineSw = override?.size + ? Math.max(r(1), Math.round(lineSize * (strokeWidthBase / baseSize))) + : sw + + if (lineStroke && lineSw > 0) { + ctx.save() + ctx.shadowColor = "rgba(0,0,0,0)" + ctx.shadowBlur = 0 + ctx.shadowOffsetX = 0 + ctx.shadowOffsetY = 0 + ctx.lineWidth = lineSw + ctx.strokeStyle = strokeColor ctx.lineJoin = "round" ctx.strokeText(line, centerX, y) - // 恢复阴影 - if (shadow) { - ctx.shadowColor = "rgba(0,0,0,0.8)" - ctx.shadowBlur = r(4) - } else { - ctx.shadowColor = prevShadowColor - ctx.shadowBlur = prevShadowBlur - } + ctx.restore() } + ctx.fillText(line, centerX, y) + + if (override?.highlights?.length) { + const fullW = ctx.measureText(line).width + const charW = line.length > 0 ? fullW / line.length : lineSize + override.highlights.forEach((hl) => { + if (!hl.word) return + const pos = line.indexOf(hl.word) + if (pos < 0) return + const hlX = centerX - fullW / 2 + pos * charW + (charW * hl.word.length) / 2 + const hlColor = hl.color || "#ffd700" + const hlScale = hl.scale || 1 + const hlSize = lineSize * hlScale + const hlBold = hl.bold != null ? !!hl.bold : true + ctx.save() + ctx.shadowColor = "rgba(0,0,0,0)" + ctx.shadowBlur = 0 + setFont(hlSize, hlBold, lineItalic) + ctx.fillStyle = hlColor + if (lineStroke && lineSw > 0) { + ctx.lineWidth = Math.max(r(1), Math.round(hlSize * (strokeWidthBase / baseSize))) + ctx.strokeStyle = strokeColor + ctx.lineJoin = "round" + ctx.strokeText(hl.word, hlX, y) + } + ctx.fillText(hl.word, hlX, y) + ctx.restore() + }) + } }) try { @@ -138,9 +276,6 @@ export function renderTitleToPngDataUrl(opts: RenderTitlePngOptions): string | n } } -/** - * 获取视频真实分辨率(HTMLVideoElement + loadedmetadata,超时 3 秒兜底 720×1280)。 - */ export function getVideoResolution( videoUrl: string, timeoutMs = 3000, diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index 76bea3dc8..decfc4106 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -640,6 +640,7 @@ const GeneratePage: React.FC = () => { onToggleStroke={styleUpdaters.toggleStroke} onToggleShadow={styleUpdaters.toggleShadow} onApplyPreset={styleUpdaters.applyPreset} + onUpdateStyle={styleUpdaters.updateStyle} activePreset={styleUpdaters.activePreset} titlePresets={styleUpdaters.titlePresets} bgm={bgm} diff --git a/apps/web/src/pages/generate/components/GenerateStepContent.tsx b/apps/web/src/pages/generate/components/GenerateStepContent.tsx index 6a331f980..abe451b89 100644 --- a/apps/web/src/pages/generate/components/GenerateStepContent.tsx +++ b/apps/web/src/pages/generate/components/GenerateStepContent.tsx @@ -51,8 +51,14 @@ export interface GenerateStepContentProps { onToggleStroke: () => void onToggleShadow: () => void onApplyPreset: (presetKey: string) => void + onUpdateStyle?: (patch: Partial) => void activePreset: string | null - titlePresets: { key: string; label: string; previewStyle: React.CSSProperties }[] + titlePresets: Array<{ + key: string + label: string + emoji?: string + style: Record + }> /* ── 封面 ── */ coverSettings: CoverConfig onCoverSettingsChange: (settings: CoverConfig) => void @@ -119,6 +125,7 @@ export const GenerateStepContent: React.FC = (props) = onToggleStroke, onToggleShadow, onApplyPreset, + onUpdateStyle, activePreset, titlePresets, coverSettings, @@ -191,6 +198,7 @@ export const GenerateStepContent: React.FC = (props) = onToggleStroke={onToggleStroke} onToggleShadow={onToggleShadow} onApplyPreset={onApplyPreset} + onUpdateStyle={onUpdateStyle} activePreset={activePreset} titlePresets={titlePresets} previewCount={previewCount} diff --git a/apps/web/src/pages/generate/components/Step4TitleSettings.tsx b/apps/web/src/pages/generate/components/Step4TitleSettings.tsx index 6f8500519..16c58906c 100644 --- a/apps/web/src/pages/generate/components/Step4TitleSettings.tsx +++ b/apps/web/src/pages/generate/components/Step4TitleSettings.tsx @@ -33,8 +33,15 @@ interface Step4TitleSettingsProps { onToggleStroke: () => void onToggleShadow: () => void onApplyPreset: (presetKey: string) => void + onUpdateStyle?: (patch: Partial) => void activePreset: string | null - titlePresets: { key: string; label: string; previewStyle: React.CSSProperties }[] + titlePresets: Array<{ + key: string + label: string + emoji?: string + style?: Record + previewStyle?: React.CSSProperties + }> /* ── 批量生成(#1677)── */ /** 生成数量 */ previewCount?: number @@ -84,6 +91,7 @@ const Step4TitleSettings: React.FC = (props) => { onToggleStroke, onToggleShadow, onApplyPreset, + onUpdateStyle, activePreset, titlePresets, previewCount = 1, @@ -285,6 +293,8 @@ const Step4TitleSettings: React.FC = (props) => { onToggleStroke={onToggleStroke} onToggleShadow={onToggleShadow} onApplyPreset={onApplyPreset} + onUpdateStyle={onUpdateStyle || t.updateStyle} + showCoverToggle activePreset={activePreset} titlePresets={titlePresets} POSITION_OPTIONS={POSITION_OPTIONS} diff --git a/apps/web/src/pages/generate/components/title/TitleMiniPreview.tsx b/apps/web/src/pages/generate/components/title/TitleMiniPreview.tsx new file mode 100644 index 000000000..f89c22744 --- /dev/null +++ b/apps/web/src/pages/generate/components/title/TitleMiniPreview.tsx @@ -0,0 +1,221 @@ +/** + * 标题迷你 Canvas 预览(#2001) + * + * 渲染一张指定宽度的小 Canvas 预览标题效果,用于: + * - 预设卡片缩略图 + * - 样式面板顶部的实时预览 + * + * 与 titleCanvas.ts 渲染逻辑保持一致,但: + * - 固定分辨率(width × 宽高比约 2:1) + * - 不调用 ffmpeg,只做视觉预览 + * - 支持背景色块、描边宽度/颜色、阴影参数化、行距、自动换行 + */ +import React, { useEffect, useRef } from "react" +import type { TitleSettings } from "../../types" +import { getFontFamily } from "../../constants" + +interface Props { + settings: TitleSettings + width?: number + sampleText?: string + /** 背景(预览用,默认深色渐变模拟视频底) */ + background?: string + /** 高度(可选,默认 width/2) */ + height?: number +} + +/** 按 maxCharsPerLine 自动换行 */ +function wrapLines(text: string, maxChars: number): string[] { + const manual = text + .split(/[//\n]/) + .map((l) => l.trim()) + .filter(Boolean) + if (!maxChars || maxChars <= 0) return manual + const out: string[] = [] + for (const line of manual) { + if (line.length <= maxChars) { + out.push(line) + continue + } + let cur = "" + for (const ch of line) { + cur += ch + if (cur.length >= maxChars) { + out.push(cur) + cur = "" + } + } + if (cur) out.push(cur) + } + return out +} + +const TitleMiniPreview: React.FC = ({ + settings, + width = 200, + sampleText, + background = "linear-gradient(135deg,#1f2937,#111827)", + height, +}) => { + const canvasRef = useRef(null) + const h = height ?? Math.round(width / 1.8) + const text = (sampleText || settings.title || "预览标题").trim() || "预览标题" + + useEffect(() => { + const cvs = canvasRef.current + if (!cvs) return + const dpr = window.devicePixelRatio || 1 + cvs.width = width * dpr + cvs.height = h * dpr + cvs.style.width = `${width}px` + cvs.style.height = `${h}px` + const ctx = cvs.getContext("2d") + if (!ctx) return + ctx.scale(dpr, dpr) + ctx.clearRect(0, 0, width, h) + + // 背景 + ctx.fillStyle = "#111827" + ctx.fillRect(0, 0, width, h) + + // 分辨率缩放:以 360 宽为基准(对应 720p 的一半) + const scale = width / 360 + const r = (v: number) => Math.round(v * scale) + + // 字体 + const size = r(settings.size) + const ff = getFontFamily(settings.font) + const parts: string[] = [] + if (settings.italic) parts.push("italic") + if (settings.bold) parts.push("bold") + parts.push(`${size}px`, ff) + ctx.font = parts.join(" ") + ctx.textAlign = "center" + ctx.textBaseline = "middle" + ctx.fillStyle = settings.color + ctx.lineJoin = "round" + + // 阴影 + const shadowEnabled = !!settings.shadow + const prevShadow = { + c: ctx.shadowColor, + b: ctx.shadowBlur, + ox: ctx.shadowOffsetX, + oy: ctx.shadowOffsetY, + } + if (shadowEnabled) { + ctx.shadowColor = settings.shadowColor ?? "rgba(0,0,0,0.8)" + ctx.shadowBlur = r(settings.shadowBlur ?? 4) + ctx.shadowOffsetX = r(settings.shadowOffsetX ?? 2) + ctx.shadowOffsetY = r(settings.shadowOffsetY ?? 2) + } + + // 换行 + const lines = wrapLines(text, settings.maxCharsPerLine ?? 0) + const lineH = size * (settings.lineHeight ?? 1.2) + const totalH = lines.length * lineH + let startY: number + if (settings.position === "top") { + startY = size / 2 + r(settings.marginTop ?? 24) + } else if (settings.position === "center") { + startY = h / 2 - totalH / 2 + size / 2 + } else { + // bottom + startY = h - totalH - r(16) + size / 2 + } + let centerX = width / 2 + if (settings.position === "custom" && settings.posX != null) { + centerX = (settings.posX / 100) * width + } + + // 背景块 + if (settings.bgEnabled) { + const pad = r(settings.bgPadding ?? 12) + const rad = r(settings.bgRadius ?? 8) + let maxLineW = 0 + for (const l of lines) { + const m = ctx.measureText(l) + if (m.width > maxLineW) maxLineW = m.width + } + const bw = maxLineW + pad * 2 + const bh = totalH + pad * 2 + const bx = centerX - bw / 2 + const by = startY - size / 2 - pad + (size - lineH) / 2 + ctx.shadowColor = "rgba(0,0,0,0)" + ctx.shadowBlur = 0 + ctx.fillStyle = settings.bgColor ?? "rgba(0,0,0,0.5)" + roundRect(ctx, bx, by, bw, bh, rad) + ctx.fill() + // 恢复阴影 + if (shadowEnabled) { + ctx.shadowColor = settings.shadowColor ?? "rgba(0,0,0,0.8)" + ctx.shadowBlur = r(settings.shadowBlur ?? 4) + ctx.shadowOffsetX = r(settings.shadowOffsetX ?? 2) + ctx.shadowOffsetY = r(settings.shadowOffsetY ?? 2) + } + } + + // 描边(先画,再画填充) + const strokeEnabled = !!settings.stroke && (settings.strokeWidth ?? 0) > 0 + lines.forEach((line, i) => { + const y = startY + i * lineH + if (strokeEnabled) { + ctx.shadowColor = "rgba(0,0,0,0)" + ctx.shadowBlur = 0 + ctx.lineWidth = r(settings.strokeWidth ?? 4) + ctx.strokeStyle = settings.strokeColor ?? "#000000" + ctx.strokeText(line, centerX, y) + // 恢复阴影 + if (shadowEnabled) { + ctx.shadowColor = settings.shadowColor ?? "rgba(0,0,0,0.8)" + ctx.shadowBlur = r(settings.shadowBlur ?? 4) + ctx.shadowOffsetX = r(settings.shadowOffsetX ?? 2) + ctx.shadowOffsetY = r(settings.shadowOffsetY ?? 2) + } + } + ctx.fillText(line, centerX, y) + }) + + // 恢复 + ctx.shadowColor = prevShadow.c + ctx.shadowBlur = prevShadow.b + ctx.shadowOffsetX = prevShadow.ox + ctx.shadowOffsetY = prevShadow.oy + }, [settings, width, h, text]) + + return ( + + ) +} + +function roundRect( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + w: number, + h: number, + r: number, +) { + const rr = Math.min(r, w / 2, h / 2) + ctx.beginPath() + ctx.moveTo(x + rr, y) + ctx.lineTo(x + w - rr, y) + ctx.quadraticCurveTo(x + w, y, x + w, y + rr) + ctx.lineTo(x + w, y + h - rr) + ctx.quadraticCurveTo(x + w, y + h, x + w - rr, y + h) + ctx.lineTo(x + rr, y + h) + ctx.quadraticCurveTo(x, y + h, x, y + h - rr) + ctx.lineTo(x, y + rr) + ctx.quadraticCurveTo(x, y, x + rr, y) + ctx.closePath() +} + +export default TitleMiniPreview diff --git a/apps/web/src/pages/generate/components/title/TitleStylePanel.css b/apps/web/src/pages/generate/components/title/TitleStylePanel.css index 30c642941..89940060d 100644 --- a/apps/web/src/pages/generate/components/title/TitleStylePanel.css +++ b/apps/web/src/pages/generate/components/title/TitleStylePanel.css @@ -190,3 +190,255 @@ border-color: var(--primary-color); color: #fff; } + +/* ============================================================ + #2001 爆款标题样式面板升级 — 新增样式(ts- 前缀) + ============================================================ */ + +.ts-panel { + position: relative; +} + +/* 预览 */ +.ts-preview-wrap { + margin-bottom: 14px; + display: flex; + justify-content: center; + padding: 10px; + background: #0f172a; + border-radius: 8px; +} + +/* 表单字段 */ +.ts-form-field { + margin-bottom: 12px; +} +.ts-form-field label { + display: block; + font-weight: 600; + margin-bottom: 6px; + font-size: 12px; + color: var(--text-primary, #1f2937); +} +.ts-field-label-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 6px; +} +.ts-field-value { + font-size: 12px; + font-weight: 600; + color: var(--primary-color, #7c3aed); +} +.ts-row-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} +.ts-half { + margin-bottom: 0; +} + +.ts-select { + width: 100%; + height: 34px; + border: 1px solid var(--border-color, #e5e7eb); + border-radius: 6px; + background: var(--bg-primary, #fff); + padding: 0 10px; + font-size: 13px; + outline: 0; + color: var(--text-primary, #1f2937); +} +.ts-select:focus { + border-color: var(--primary-color, #7c3aed); + box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.1); +} +.ts-input { + width: 100%; + height: 34px; + border: 1px solid var(--border-color, #e5e7eb); + border-radius: 6px; + padding: 0 10px; + font-size: 13px; + outline: 0; +} + +.ts-slider { + width: 100%; + height: 4px; + -webkit-appearance: none; + appearance: none; + background: #e5e7eb; + border-radius: 2px; + outline: none; +} +.ts-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: #7c3aed; + cursor: pointer; + border: 2px solid #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); +} +.ts-slider::-moz-range-thumb { + width: 16px; + height: 16px; + border-radius: 50%; + background: #7c3aed; + cursor: pointer; + border: 2px solid #fff; +} + +/* 样式按钮 B/I/S/☁ */ +.ts-style-btns { + display: flex; + gap: 6px; +} +.ts-style-btn { + width: 34px; + height: 34px; + border-radius: 6px; + border: 1px solid #e5e7eb; + background: #fff; + cursor: pointer; + font-size: 14px; + transition: 0.15s; + color: #374151; + display: inline-flex; + align-items: center; + justify-content: center; +} +.ts-style-btn:hover { + border-color: #7c3aed; + color: #7c3aed; +} +.ts-style-btn.active { + background: #faf5ff; + color: #6d28d9; + border-color: #7c3aed; + font-weight: 700; +} + +/* 色板 */ +.ts-color-row { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} +.ts-color-swatch { + width: 24px; + height: 24px; + border-radius: 4px; + border: 2px solid #fff; + box-shadow: 0 0 0 1px #e5e7eb; + cursor: pointer; + padding: 0; + transition: 0.15s; +} +.ts-color-swatch:hover { + transform: scale(1.1); +} +.ts-color-swatch.active { + box-shadow: 0 0 0 2px #7c3aed; + transform: scale(1.1); +} +.ts-color-custom { + background: repeating-conic-gradient(#ccc 0% 25%, #fff 0% 50%) 50%/8px 8px; + color: #666; + font-size: 14px; + line-height: 20px; +} +.ts-color-native { + width: 0; + height: 0; + border: 0; + padding: 0; +} + +/* 预设网格 10个 - 5列 */ +.ts-presets-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 6px; +} +.ts-preset-card { + border: 1px solid #e5e7eb; + border-radius: 6px; + background: #fff; + padding: 4px; + cursor: pointer; + transition: 0.15s; + display: flex; + flex-direction: column; + gap: 4px; +} +.ts-preset-card:hover { + border-color: #7c3aed; +} +.ts-preset-card.active { + border-color: #7c3aed; + background: #faf5ff; + box-shadow: 0 0 0 1px #7c3aed; +} +.ts-preset-preview { + height: 34px; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + border-radius: 4px; + background: #0f172a; +} +.ts-preset-preview canvas { + max-width: 100%; + max-height: 100%; +} +.ts-preset-meta { + display: flex; + align-items: center; + gap: 2px; + font-size: 10px; + color: #4b5563; + justify-content: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding: 0 2px 2px; +} +.ts-preset-emoji { + font-size: 11px; +} +.ts-preset-label { + overflow: hidden; + text-overflow: ellipsis; +} + +.ts-toggle-row label { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + margin-bottom: 10px; +} +.ts-toggle-row input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: #7c3aed; +} + +/* Tabs 紧凑样式 */ +.xx-title-style-section .ant-tabs-nav { + margin-bottom: 10px; +} +.xx-title-style-section .ant-tabs-tab { + font-size: 12px !important; + padding: 6px 8px !important; +} diff --git a/apps/web/src/pages/generate/components/title/TitleStylePanel.tsx b/apps/web/src/pages/generate/components/title/TitleStylePanel.tsx index 82acbe6f6..726693f2a 100644 --- a/apps/web/src/pages/generate/components/title/TitleStylePanel.tsx +++ b/apps/web/src/pages/generate/components/title/TitleStylePanel.tsx @@ -1,12 +1,26 @@ /** - * 标题样式设置区 - * 位置/字体/字号/样式按钮/预设 + * 标题样式设置面板(#2001 升级) + * + * P0:描边宽度滑块 / 描边颜色选择器 / 每行最大字符数 / 行距+顶部边距 / + * 4款爆款字体 / 抖音爆款黄预设 + * P1:阴影参数化 / 背景色块 / Canvas 实时迷你预览 / + * 封面独立标题配置入口 + * + * 向后兼容:旧的 onToggleBold/Italic/Stroke/Shadow/onUpdatePosition/onUpdateFont/ + * onUpdateSize/onApplyPreset props 全部保留;新增字段通过 onUpdateStyle 统一回写。 */ -import React from "react" +import React, { useState } from "react" +import { Tabs } from "antd" import type { TitleSettings } from "../../types" -import TitlePresetsGrid from "./TitlePresetsGrid" -// 标题样式面板共用样式(#1809 ⑦):智能剪辑与 AI数字人复用同一组件, -// 由组件自带样式,避免 AI数字人页面重复引入整个 generate.css +import { + FONT_OPTIONS as NEW_FONT_OPTIONS, + TITLE_PRESETS, + TITLE_COLOR_PALETTE, + STROKE_COLOR_PALETTE, + BG_COLOR_PALETTE, +} from "@/components/title/constants" + +import TitleMiniPreview from "./TitleMiniPreview" import "./TitleStylePanel.css" interface PositionOption { @@ -14,7 +28,7 @@ interface PositionOption { label: string } -interface TitlePresetItem { +interface LegacyPreset { key: string label: string previewStyle: React.CSSProperties @@ -22,6 +36,7 @@ interface TitlePresetItem { interface TitleStylePanelProps { settings: TitleSettings + /* 旧 props(兼容) */ onUpdatePosition: (position: string) => void onUpdateFont: (font: string) => void onUpdateSize: (size: number) => void @@ -31,9 +46,130 @@ interface TitleStylePanelProps { onToggleShadow: () => void onApplyPreset: (presetKey: string) => void activePreset: string | null - titlePresets: TitlePresetItem[] + titlePresets: LegacyPreset[] POSITION_OPTIONS: PositionOption[] - FONT_OPTIONS: string[] + FONT_OPTIONS?: string[] + /* 新增:统一字段更新 */ + onUpdateStyle?: (patch: Partial) => void + /* 是否显示封面独立标题切换 */ + showCoverToggle?: boolean + /** 画布预览宽度(默认 200) */ + previewWidth?: number +} + +/* ── 通用 Slider + Label 行 ── */ +const SliderRow: React.FC<{ + label: string + value: number + min: number + max: number + step?: number + unit?: string + onChange: (v: number) => void +}> = ({ label, value, min, max, step = 1, unit = "px", onChange }) => ( +
+
+ + + {value} + {unit} + +
+ onChange(Number(e.target.value))} + /> +
+) + +/* ── 色板 + 自定义颜色选择 ── */ +const ColorPicker: React.FC<{ + label?: string + value: string + palette: string[] + onChange: (c: string) => void +}> = ({ label, value, palette, onChange }) => { + const [customOpen, setCustomOpen] = useState(false) + return ( +
+ {label && } +
+ {palette.map((c) => ( + + onChange(e.target.value)} + style={{ + opacity: customOpen ? 1 : 0, + position: customOpen ? "static" : "absolute", + pointerEvents: customOpen ? "auto" : "none", + width: 0, + height: 0, + }} + /> +
+
+ 当前:{value} +
+
+ ) +} + +/* ── 预设网格(含爆款黄,10 个 + 迷你 Canvas 缩略) ── */ +const PresetGrid: React.FC<{ + activePreset: string | null + onApply: (key: string) => void + settings: TitleSettings +}> = ({ activePreset, onApply, settings }) => { + return ( +
+ {TITLE_PRESETS.map((p) => { + const isActive = activePreset === p.key + // 合并当前 style 与 preset.style 用于预览(仅预览时覆盖) + const previewStyle: TitleSettings = { ...settings, ...(p.style as Partial) } + return ( + + ) + })} +
+ ) } const TitleStylePanel: React.FC = ({ @@ -47,107 +183,357 @@ const TitleStylePanel: React.FC = ({ onToggleShadow, onApplyPreset, activePreset, - titlePresets, + titlePresets: _titlePresets, POSITION_OPTIONS, - FONT_OPTIONS, + showCoverToggle = false, + previewWidth = 220, + onUpdateStyle, }) => { + const upd = (patch: Partial) => { + onUpdateStyle?.(patch) + } + + /* 封面独立标题切换 */ + const [coverOpen, setCoverOpen] = useState(!!settings.coverTitle) + return ( -
-

标题样式

- - {/* 位置 + 字体 一行 */} -
-
- - -
-
- - -
-
- - {/* 字号滑块 */} -
-
- - {settings.size}px -
- onUpdateSize(Number(e.target.value))} +
+ {/* 实时迷你预览 */} +
+
- {/* 预设样式 */} -
- - + +
- {/* 样式按钮:粗体/斜体/描边/阴影 */} -
- -
- - - - -
-
+ + {/* 位置 + 字体 */} +
+
+ + +
+
+ + +
+
+ + + + {/* 样式按钮 */} +
+ +
+ + + + +
+
+ + {/* 字色 */} + upd({ color: c })} + /> + + ), + }, + { + key: "stroke", + label: "描边", + children: ( + <> +
+ +
+ {settings.stroke && ( + <> + upd({ strokeWidth: v })} + /> + upd({ strokeColor: c })} + /> + + )} + + ), + }, + { + key: "shadow", + label: "阴影", + children: ( + <> +
+ +
+ {settings.shadow && ( + <> + upd({ shadowOffsetX: v })} + /> + upd({ shadowOffsetY: v })} + /> + upd({ shadowBlur: v })} + /> +
+ + upd({ shadowColor: e.target.value })} + placeholder="rgba(0,0,0,0.8)" + /> +
+ + )} + + ), + }, + { + key: "bg", + label: "背景", + children: ( + <> +
+ +
+ {settings.bgEnabled && ( + <> + upd({ bgColor: c })} + /> + upd({ bgPadding: v })} + /> + upd({ bgRadius: v })} + /> + + )} + + ), + }, + { + key: "layout", + label: "排版", + children: ( + <> + upd({ maxCharsPerLine: v })} + /> +
+ 0 = 不自动换行(按 / 手动分行) +
+ upd({ lineHeight: Number(v.toFixed(2)) })} + /> + upd({ marginTop: v })} + /> + + ), + }, + ...(showCoverToggle + ? [ + { + key: "cover", + label: "封面", + children: ( + <> +
+ +
+ {coverOpen && settings.coverTitle && ( +
+ 封面样式已开启。可在「封面设置」面板单独调整封面标题的字体/字号/颜色。 +
+ )} + + ), + }, + ] + : []), + ]} + />
) } diff --git a/apps/web/src/pages/generate/constants.ts b/apps/web/src/pages/generate/constants.ts index 680dbb384..a35004088 100644 --- a/apps/web/src/pages/generate/constants.ts +++ b/apps/web/src/pages/generate/constants.ts @@ -54,11 +54,28 @@ export const POSITION_OPTIONS = [ { value: "custom", label: "自定义" }, ] -/* ── 标题字体选项 ── */ -export const FONT_OPTIONS = ["思源黑体", "思源宋体", "苹方", "微软雅黑", "楷体"] +/* ── 标题字体选项(#2001:新增 4 款爆款字体) ── */ +export const FONT_OPTIONS = [ + "优设标题黑", + "阿里普惠体Bold", + "抖音美好体", + "思源黑体Heavy", + "思源黑体", + "思源宋体", + "苹方", + "微软雅黑", + "楷体", +] /* ── 标题字体 CSS font-family 映射(中文显示名 → 浏览器可识别的字体栈) ── */ export const FONT_FAMILY_MAP: Record = { + 优设标题黑: + '"YouSheBiaoTiHei","YouShe Title Black","Source Han Sans SC Heavy","Noto Sans SC","PingFang SC",sans-serif', + 阿里普惠体Bold: + '"Alibaba PuHuiTi Bold","Alibaba PuHuiTi","Source Han Sans SC","PingFang SC",sans-serif', + 抖音美好体: '"Douyin Sans","DouyinSansBold","Source Han Sans SC Heavy","PingFang SC",sans-serif', + 思源黑体Heavy: + '"Source Han Sans SC Heavy","Noto Sans SC Heavy","Source Han Sans CN Heavy","PingFang SC",sans-serif', 思源黑体: '"Source Han Sans SC", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif', 思源宋体: '"Source Han Serif SC", "Noto Serif SC", "Songti SC", "SimSun", serif', 苹方: '"PingFang SC", -apple-system, "Helvetica Neue", sans-serif', diff --git a/apps/web/src/pages/generate/hooks/useBatchCovers.ts b/apps/web/src/pages/generate/hooks/useBatchCovers.ts index 7a134cca4..b053bbcb3 100644 --- a/apps/web/src/pages/generate/hooks/useBatchCovers.ts +++ b/apps/web/src/pages/generate/hooks/useBatchCovers.ts @@ -100,8 +100,35 @@ export function useBatchCovers({ font_color: titleStyle.color, position: titleStyle.position, bold: titleStyle.bold, - stroke: titleStyle.stroke, - shadow: titleStyle.shadow, + italic: titleStyle.italic, + stroke: titleStyle.stroke + ? { + enabled: true, + width: titleStyle.strokeWidth ?? 4, + color: titleStyle.strokeColor ?? "#000000", + } + : { enabled: false }, + shadow: titleStyle.shadow + ? { + enabled: true, + offset_x: titleStyle.shadowOffsetX ?? 2, + offset_y: titleStyle.shadowOffsetY ?? 2, + blur: titleStyle.shadowBlur ?? 4, + color: titleStyle.shadowColor ?? "rgba(0,0,0,0.8)", + } + : { enabled: false }, + line_height: titleStyle.lineHeight ?? 1.2, + margin_top: titleStyle.marginTop ?? 24, + max_chars_per_line: titleStyle.maxCharsPerLine ?? 0, + background: titleStyle.bgEnabled + ? { + enabled: true, + color: titleStyle.bgColor, + padding: titleStyle.bgPadding, + radius: titleStyle.bgRadius, + } + : { enabled: false }, + line_overrides: titleStyle.lineOverrides ?? [], }, } : {}), diff --git a/apps/web/src/pages/generate/hooks/useGenerateFormState/index.ts b/apps/web/src/pages/generate/hooks/useGenerateFormState/index.ts index ca3fd438b..7e6ab6ed0 100755 --- a/apps/web/src/pages/generate/hooks/useGenerateFormState/index.ts +++ b/apps/web/src/pages/generate/hooks/useGenerateFormState/index.ts @@ -34,6 +34,21 @@ const DEFAULT_TITLE_SETTINGS: TitleSettings = { color: "#ffffff", posX: null, posY: null, + lineHeight: 1.2, + marginTop: 24, + maxCharsPerLine: 0, + strokeWidth: 4, + strokeColor: "#000000", + shadowOffsetX: 2, + shadowOffsetY: 2, + shadowBlur: 4, + shadowColor: "rgba(0,0,0,0.8)", + bgEnabled: false, + bgColor: "rgba(0,0,0,0.5)", + bgPadding: 12, + bgRadius: 8, + lineOverrides: [], + coverTitle: null, } export interface GenerateFormState { diff --git a/apps/web/src/pages/generate/hooks/useGenerateFormState/usePlanConfigLoader.ts b/apps/web/src/pages/generate/hooks/useGenerateFormState/usePlanConfigLoader.ts index 01ed07438..6d830ed94 100755 --- a/apps/web/src/pages/generate/hooks/useGenerateFormState/usePlanConfigLoader.ts +++ b/apps/web/src/pages/generate/hooks/useGenerateFormState/usePlanConfigLoader.ts @@ -43,15 +43,93 @@ export function usePlanConfigLoader({ } if (config.title_config) { - const tc = config.title_config as TitleConfig + const tc = config.title_config as TitleConfig & { + bold?: boolean + italic?: boolean + 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 + line_height?: number + margin_top?: number + max_chars_per_line?: number + background?: { enabled?: boolean; color?: string; padding?: number; radius?: number } + bg_enabled?: boolean + bg_color?: string + bg_padding?: number + bg_radius?: number + line_overrides?: unknown[] + cover_title_config?: Record | null + } setTitleSettings((prev: TitleSettings) => ({ ...prev, title: tc.content || "", aiAutoSelect: tc.ai_auto_select || false, - position: prev.position, // 强制保留默认/用户选择,不从草稿配置同步位置 + position: prev.position, font: tc.font_preset || prev.font, size: tc.font_size || prev.size, color: tc.font_color || prev.color, + bold: tc.bold ?? prev.bold, + italic: tc.italic ?? prev.italic, + stroke: tc.stroke?.enabled ?? tc.stroke ?? prev.stroke, + strokeWidth: tc.stroke?.width ?? tc.stroke_width ?? prev.strokeWidth, + strokeColor: tc.stroke?.color ?? tc.stroke_color ?? prev.strokeColor, + shadow: tc.shadow?.enabled ?? tc.shadow ?? prev.shadow, + shadowOffsetX: tc.shadow?.offset_x ?? tc.shadow_offset_x ?? prev.shadowOffsetX, + shadowOffsetY: tc.shadow?.offset_y ?? tc.shadow_offset_y ?? prev.shadowOffsetY, + shadowBlur: tc.shadow?.blur ?? tc.shadow_blur ?? prev.shadowBlur, + shadowColor: tc.shadow?.color ?? tc.shadow_color ?? prev.shadowColor, + lineHeight: tc.line_height ?? prev.lineHeight, + marginTop: tc.margin_top ?? prev.marginTop, + maxCharsPerLine: tc.max_chars_per_line ?? prev.maxCharsPerLine, + bgEnabled: tc.background?.enabled ?? tc.bg_enabled ?? prev.bgEnabled, + bgColor: tc.background?.color ?? tc.bg_color ?? prev.bgColor, + bgPadding: tc.background?.padding ?? tc.bg_padding ?? prev.bgPadding, + bgRadius: tc.background?.radius ?? tc.bg_radius ?? prev.bgRadius, + lineOverrides: tc.line_overrides ?? prev.lineOverrides, + coverTitle: tc.cover_title_config + ? { + title: tc.cover_title_config.title, + font: tc.cover_title_config.font, + size: tc.cover_title_config.font_size ?? tc.cover_title_config.size, + color: tc.cover_title_config.font_color ?? tc.cover_title_config.color, + bold: tc.cover_title_config.bold, + italic: tc.cover_title_config.italic, + position: tc.cover_title_config.position, + stroke: tc.cover_title_config.stroke?.enabled ?? tc.cover_title_config.stroke, + strokeWidth: + tc.cover_title_config.stroke?.width ?? tc.cover_title_config.stroke_width, + strokeColor: + tc.cover_title_config.stroke?.color ?? tc.cover_title_config.stroke_color, + shadow: tc.cover_title_config.shadow?.enabled ?? tc.cover_title_config.shadow, + shadowOffsetX: + tc.cover_title_config.shadow?.offset_x ?? tc.cover_title_config.shadow_offset_x, + shadowOffsetY: + tc.cover_title_config.shadow?.offset_y ?? tc.cover_title_config.shadow_offset_y, + shadowBlur: tc.cover_title_config.shadow?.blur ?? tc.cover_title_config.shadow_blur, + shadowColor: + tc.cover_title_config.shadow?.color ?? tc.cover_title_config.shadow_color, + bgEnabled: + tc.cover_title_config.background?.enabled ?? tc.cover_title_config.bg_enabled, + bgColor: tc.cover_title_config.background?.color ?? tc.cover_title_config.bg_color, + bgPadding: + tc.cover_title_config.background?.padding ?? tc.cover_title_config.bg_padding, + bgRadius: + tc.cover_title_config.background?.radius ?? tc.cover_title_config.bg_radius, + } + : prev.coverTitle, })) } if (config.segments && config.segments.length > 0) { @@ -76,14 +154,102 @@ export function usePlanConfigLoader({ if (plan.name) setTitleSettings((prev: TitleSettings) => ({ ...prev, title: plan.name })) const cfg = plan.config if (cfg?.title_config) { + const tc2 = cfg.title_config! as { + content?: string + ai_auto_select?: boolean + font_preset?: string + font_size?: number + font_color?: string + bold?: boolean + italic?: boolean + 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 + line_height?: number + margin_top?: number + max_chars_per_line?: number + background?: { enabled?: boolean; color?: string; padding?: number; radius?: number } + bg_enabled?: boolean + bg_color?: string + bg_padding?: number + bg_radius?: number + line_overrides?: unknown[] + cover_title_config?: Record | null + } setTitleSettings((prev: TitleSettings) => ({ ...prev, - aiAutoSelect: cfg.title_config!.ai_auto_select, - title: cfg.title_config!.content || prev.title, - position: prev.position, // 强制保留默认/用户选择,不从远程草稿同步位置 - font: cfg.title_config!.font_preset || prev.font, - size: cfg.title_config!.font_size || prev.size, - color: cfg.title_config!.font_color || prev.color, + aiAutoSelect: tc2.ai_auto_select ?? false, + title: tc2.content || prev.title, + position: prev.position, + font: tc2.font_preset || prev.font, + size: tc2.font_size || prev.size, + color: tc2.font_color || prev.color, + bold: tc2.bold ?? prev.bold, + italic: tc2.italic ?? prev.italic, + stroke: tc2.stroke?.enabled ?? tc2.stroke ?? prev.stroke, + strokeWidth: tc2.stroke?.width ?? tc2.stroke_width ?? prev.strokeWidth, + strokeColor: tc2.stroke?.color ?? tc2.stroke_color ?? prev.strokeColor, + shadow: tc2.shadow?.enabled ?? tc2.shadow ?? prev.shadow, + shadowOffsetX: tc2.shadow?.offset_x ?? tc2.shadow_offset_x ?? prev.shadowOffsetX, + shadowOffsetY: tc2.shadow?.offset_y ?? tc2.shadow_offset_y ?? prev.shadowOffsetY, + shadowBlur: tc2.shadow?.blur ?? tc2.shadow_blur ?? prev.shadowBlur, + shadowColor: tc2.shadow?.color ?? tc2.shadow_color ?? prev.shadowColor, + lineHeight: tc2.line_height ?? prev.lineHeight, + marginTop: tc2.margin_top ?? prev.marginTop, + maxCharsPerLine: tc2.max_chars_per_line ?? prev.maxCharsPerLine, + bgEnabled: tc2.background?.enabled ?? tc2.bg_enabled ?? prev.bgEnabled, + bgColor: tc2.background?.color ?? tc2.bg_color ?? prev.bgColor, + bgPadding: tc2.background?.padding ?? tc2.bg_padding ?? prev.bgPadding, + bgRadius: tc2.background?.radius ?? tc2.bg_radius ?? prev.bgRadius, + lineOverrides: tc2.line_overrides ?? prev.lineOverrides, + coverTitle: tc2.cover_title_config + ? { + title: tc2.cover_title_config.title, + font: tc2.cover_title_config.font, + size: tc2.cover_title_config.font_size ?? tc2.cover_title_config.size, + color: tc2.cover_title_config.font_color ?? tc2.cover_title_config.color, + bold: tc2.cover_title_config.bold, + italic: tc2.cover_title_config.italic, + position: tc2.cover_title_config.position, + stroke: tc2.cover_title_config.stroke?.enabled ?? tc2.cover_title_config.stroke, + strokeWidth: + tc2.cover_title_config.stroke?.width ?? tc2.cover_title_config.stroke_width, + strokeColor: + tc2.cover_title_config.stroke?.color ?? tc2.cover_title_config.stroke_color, + shadow: tc2.cover_title_config.shadow?.enabled ?? tc2.cover_title_config.shadow, + shadowOffsetX: + tc2.cover_title_config.shadow?.offset_x ?? + tc2.cover_title_config.shadow_offset_x, + shadowOffsetY: + tc2.cover_title_config.shadow?.offset_y ?? + tc2.cover_title_config.shadow_offset_y, + shadowBlur: + tc2.cover_title_config.shadow?.blur ?? tc2.cover_title_config.shadow_blur, + shadowColor: + tc2.cover_title_config.shadow?.color ?? tc2.cover_title_config.shadow_color, + bgEnabled: + tc2.cover_title_config.background?.enabled ?? tc2.cover_title_config.bg_enabled, + bgColor: + tc2.cover_title_config.background?.color ?? tc2.cover_title_config.bg_color, + bgPadding: + tc2.cover_title_config.background?.padding ?? tc2.cover_title_config.bg_padding, + bgRadius: + tc2.cover_title_config.background?.radius ?? tc2.cover_title_config.bg_radius, + } + : prev.coverTitle, })) } if (cfg?.cover_config) { diff --git a/apps/web/src/pages/generate/hooks/useGenerateVideo.ts b/apps/web/src/pages/generate/hooks/useGenerateVideo.ts index ffb85965c..f7b80c0f4 100755 --- a/apps/web/src/pages/generate/hooks/useGenerateVideo.ts +++ b/apps/web/src/pages/generate/hooks/useGenerateVideo.ts @@ -241,8 +241,91 @@ export function useGenerateVideo(props: UseGenerateVideoProps) { } : {}), bold: props.titleSettings.bold, - stroke: props.titleSettings.stroke, - shadow: props.titleSettings.shadow, + italic: props.titleSettings.italic, + stroke: props.titleSettings.stroke + ? { + enabled: true, + width: props.titleSettings.strokeWidth ?? 4, + color: props.titleSettings.strokeColor ?? "#000000", + } + : { enabled: false }, + shadow: props.titleSettings.shadow + ? { + enabled: true, + offset_x: props.titleSettings.shadowOffsetX ?? 2, + offset_y: props.titleSettings.shadowOffsetY ?? 2, + blur: props.titleSettings.shadowBlur ?? 4, + color: props.titleSettings.shadowColor ?? "rgba(0,0,0,0.8)", + } + : { enabled: false }, + line_height: props.titleSettings.lineHeight ?? 1.2, + margin_top: props.titleSettings.marginTop ?? 24, + max_chars_per_line: props.titleSettings.maxCharsPerLine ?? 0, + ...(props.titleSettings.bgEnabled + ? { + background: { + enabled: true, + color: props.titleSettings.bgColor, + padding: props.titleSettings.bgPadding, + radius: props.titleSettings.bgRadius, + }, + } + : { background: { enabled: false } }), + line_overrides: (props.titleSettings.lineOverrides ?? []).map((lo) => ({ + line_index: lo.line_index, + text: lo.text, + size: lo.size, + color: lo.color, + bold: lo.bold, + italic: lo.italic, + stroke: lo.stroke, + highlights: lo.highlights?.map((h) => ({ + word: h.word, + color: h.color, + bold: h.bold, + scale: h.scale, + })), + })), + ...(props.titleSettings.coverTitle + ? { + cover_title_config: { + title: props.titleSettings.coverTitle.title, + font: props.titleSettings.coverTitle.font, + font_size: props.titleSettings.coverTitle.size, + font_color: props.titleSettings.coverTitle.color, + bold: props.titleSettings.coverTitle.bold, + italic: props.titleSettings.coverTitle.italic, + position: props.titleSettings.coverTitle.position, + stroke: props.titleSettings.coverTitle.stroke + ? { + enabled: true, + width: props.titleSettings.coverTitle.strokeWidth ?? 4, + color: props.titleSettings.coverTitle.strokeColor ?? "#000000", + } + : { enabled: false }, + shadow: props.titleSettings.coverTitle.shadow + ? { + enabled: true, + offset_x: props.titleSettings.coverTitle.shadowOffsetX ?? 2, + offset_y: props.titleSettings.coverTitle.shadowOffsetY ?? 2, + blur: props.titleSettings.coverTitle.shadowBlur ?? 4, + color: + props.titleSettings.coverTitle.shadowColor ?? "rgba(0,0,0,0.8)", + } + : { enabled: false }, + ...(props.titleSettings.coverTitle.bgEnabled + ? { + background: { + enabled: true, + color: props.titleSettings.coverTitle.bgColor, + padding: props.titleSettings.coverTitle.bgPadding, + radius: props.titleSettings.coverTitle.bgRadius, + }, + } + : { background: { enabled: false } }), + }, + } + : {}), }, } : {}), diff --git a/apps/web/src/pages/generate/hooks/useStep4Title/useTitleStyleUpdaters.ts b/apps/web/src/pages/generate/hooks/useStep4Title/useTitleStyleUpdaters.ts index 5a7934c87..aa77f976a 100644 --- a/apps/web/src/pages/generate/hooks/useStep4Title/useTitleStyleUpdaters.ts +++ b/apps/web/src/pages/generate/hooks/useStep4Title/useTitleStyleUpdaters.ts @@ -1,5 +1,6 @@ import { useCallback, useMemo } from "react" import { TITLE_PRESETS } from "../../constants" +import { TITLE_PRESETS as NEW_TITLE_PRESETS } from "@/components/title/constants" import type { TitleSettings } from "../../types" interface UseTitleStyleUpdatersOptions { @@ -97,26 +98,45 @@ export function useTitleStyleUpdaters({ onTitleSettingsChange({ ...titleSettings, shadow: !titleSettings.shadow }) }, [titleSettings, onTitleSettingsChange]) - /** 应用预设:只覆盖 color/bold/italic/stroke/shadow,不改变字号 */ + /** 应用预设(支持新预设细粒度字段) */ const applyPreset = useCallback( (presetKey: string) => { - const preset = TITLE_PRESETS.find((p) => p.key === presetKey) - if (!preset) return + // 优先匹配新预设(10个爆款预设),fallback 旧预设 + const newPreset = NEW_TITLE_PRESETS.find((p) => p.key === presetKey) + const oldPreset = TITLE_PRESETS.find((p) => p.key === presetKey) + if (newPreset) { + onTitleSettingsChange({ + ...titleSettings, + ...(newPreset.style as Partial), + // 清除逐行覆盖 + lineOverrides: [], + }) + return + } + if (!oldPreset) return onTitleSettingsChange({ ...titleSettings, - color: preset.style.color, - bold: preset.style.bold, - italic: preset.style.italic, - stroke: preset.style.stroke, - shadow: preset.style.shadow, + color: oldPreset.style.color, + bold: oldPreset.style.bold, + italic: oldPreset.style.italic, + stroke: oldPreset.style.stroke, + shadow: oldPreset.style.shadow, }) }, [titleSettings, onTitleSettingsChange], ) + /** 通用字段更新(patch) */ + const updateStyle = useCallback( + (patch: Partial) => { + onTitleSettingsChange({ ...titleSettings, ...patch }) + }, + [titleSettings, onTitleSettingsChange], + ) + return { activePreset, - titlePresets: TITLE_PRESETS, + titlePresets: NEW_TITLE_PRESETS, updateTitle, toggleAiAutoSelect, updatePosition, @@ -129,5 +149,6 @@ export function useTitleStyleUpdaters({ toggleStroke, toggleShadow, applyPreset, + updateStyle, } } diff --git a/apps/web/src/pages/generate/types.ts b/apps/web/src/pages/generate/types.ts index b279973aa..3b2669bd0 100644 --- a/apps/web/src/pages/generate/types.ts +++ b/apps/web/src/pages/generate/types.ts @@ -3,8 +3,9 @@ */ import type { AssetItem } from "@/api/assets" +import type { TitleLineOverride } from "@/components/title/types" -/* ── 标题设置 ── */ +/* ── 标题设置(#2001 升级:新增描边/阴影/背景/逐行/封面独立样式/排版字段) ── */ export interface TitleSettings { aiAutoSelect: boolean title: string @@ -19,6 +20,58 @@ export interface TitleSettings { /** 自由位置坐标(PlayRes 像素),仅当 position="custom" 时有效 */ posX: number | null posY: number | null + + /* ── 排版(P0) ── */ + /** 行距倍数,默认 1.2 */ + lineHeight: number + /** 顶部边距(position=top,px @720p) */ + marginTop: number + /** 每行最大字符数(4-20),0=不自动换行 */ + maxCharsPerLine: number + + /* ── 描边参数化(P0) ── */ + strokeWidth: number + strokeColor: string + + /* ── 阴影参数化(P1) ── */ + shadowOffsetX: number + shadowOffsetY: number + shadowBlur: number + shadowColor: string + + /* ── 背景色块(P1) ── */ + bgEnabled: boolean + bgColor: string + bgPadding: number + bgRadius: number + + /* ── 逐行独立样式(P1) ── */ + lineOverrides: TitleLineOverride[] + + /* ── 封面独立标题(P1):null=沿用主标题 ── */ + coverTitle: null | { + 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 + maxCharsPerLine?: number + } } /* ── 智能匹配结果 ── */ @@ -49,28 +102,33 @@ export interface StepDef { label: string } -/* ── 标题预设样式 ── */ -export interface TitlePresetStyle { - size: number - color: string - bold: boolean - italic: boolean - stroke: boolean - shadow: boolean -} - -export interface TitlePreset { - key: string - label: string - style: TitlePresetStyle - previewStyle: Record -} - -/* ── 生成结果视频 ── */ -export interface GeneratedVideoResult { - id: string - url: string - thumbnail: string - duration: number - title: string +/** 旧版 TitleSettings 的默认值字段(P0/P1 新字段补齐默认值) */ +export const DEFAULT_TITLE_SETTINGS_FULL: TitleSettings = { + aiAutoSelect: false, + title: "", + position: "top", + font: "思源黑体", + size: 28, + bold: true, + italic: false, + stroke: true, + shadow: false, + color: "#ffffff", + posX: null, + posY: null, + lineHeight: 1.2, + marginTop: 24, + maxCharsPerLine: 0, + strokeWidth: 4, + strokeColor: "#000000", + shadowOffsetX: 2, + shadowOffsetY: 2, + shadowBlur: 4, + shadowColor: "rgba(0,0,0,0.8)", + bgEnabled: false, + bgColor: "rgba(0,0,0,0.5)", + bgPadding: 12, + bgRadius: 8, + lineOverrides: [], + coverTitle: null, }