diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index 9db8f486e..bc66203ca 100755 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -4,14 +4,13 @@ * 左右布局:左侧 generate-form + 右侧 generate-preview * 保留所有现有业务逻辑(API 调用、URL 参数、CloneModal、TTS 等) */ -import React, { useState, useRef, useCallback, useEffect } from "react" +import React, { useState, useRef, useCallback, useEffect, useMemo } from "react" import { useQuery, useMutation } from "@tanstack/react-query" import { Typography, message, Select, Modal } from "antd" import { AudioOutlined, ThunderboltOutlined, CheckCircleFilled, - CheckCircleOutlined, CloseCircleOutlined, LoadingOutlined, PlayCircleOutlined, @@ -253,6 +252,30 @@ const SMART_MATCH_REASONS = [ "人物表情生动", ] +const AI_TITLE_TEMPLATES: Record = { + catchy: [ + "震惊!{topic}居然还能这样操作", + "99%的人都不知道的{topic}秘诀", + "{topic}的终极指南,看完直接封神", + "别再走弯路了!{topic}看这一篇就够", + "一个视频讲透{topic},建议收藏", + ], + emotional: [ + "致每一个在{topic}路上坚持的人", + "关于{topic},我想说句真心话", + "{topic}背后的故事,看完沉默了", + "为什么我劝你一定要了解{topic}", + "这才是{topic}最动人的样子", + ], + informative: [ + "{topic}完整科普:从入门到精通", + "深度解析{topic}的核心原理", + "{topic}行业趋势报告|2026最新版", + "三分钟带你全面了解{topic}", + "{topic}常见问题与解决方案汇总", + ], +} + /* ================================================================ 组件 ================================================================ */ @@ -294,6 +317,19 @@ const GeneratePage: React.FC = () => { /* 智能素材匹配:是否已执行过匹配 */ const [hasMatched, setHasMatched] = useState(false) + /* ── 智能标题生成 ── */ + const [aiTitleInput, setAiTitleInput] = useState("") + const [aiTitleGenerating, setAiTitleGenerating] = useState(false) + const [aiTitleResults, setAiTitleResults] = useState< + Array<{ title: string; highlight: string; style: "catchy" | "emotional" | "informative" }> + >([]) + const [hasGeneratedTitles, setHasGeneratedTitles] = useState(false) + + /* ── 智能配音推荐 ── */ + const [voiceRecommendLoading, setVoiceRecommendLoading] = useState(false) + const [voiceRecommendations, setVoiceRecommendations] = useState([]) + const [hasVoiceRecommend, setHasVoiceRecommend] = useState(false) + /* ── 标题设置 ── */ const [titleSettings, setTitleSettings] = useState(DEFAULT_TITLE_SETTINGS) @@ -453,7 +489,10 @@ const GeneratePage: React.FC = () => { queryKey: ["preset-voices"], queryFn: fetchPresetVoices, }) - const presetVoices: PresetVoiceItem[] = presetVoicesData?.items ?? [] + const presetVoices: PresetVoiceItem[] = useMemo( + () => presetVoicesData?.items ?? [], + [presetVoicesData], + ) /* ── TTS 自定义合成状态 ── */ const [customAudioUrl, setCustomAudioUrl] = useState(null) @@ -584,6 +623,152 @@ const GeneratePage: React.FC = () => { setSmartSelectedIds([]) }, []) + /* ── 智能标题生成 ── */ + + const extractTopic = (text: string): string => { + const keywords = text + .replace(/[,。!?、,.!?]/g, " ") + .split(/\s+/) + .filter(Boolean) + if (keywords.length === 0) return "这个话题" + // 取前3个关键词组合 + return keywords.slice(0, 3).join("") + } + + const handleGenerateAiTitles = useCallback(async () => { + if (!aiTitleInput.trim()) { + message.warning("请先输入视频描述或关键词") + return + } + setAiTitleGenerating(true) + setHasGeneratedTitles(true) + + // 模拟 AI 生成延迟 + await new Promise((resolve) => setTimeout(resolve, 1200)) + + const topic = extractTopic(aiTitleInput) + const results: Array<{ + title: string + highlight: string + style: "catchy" | "emotional" | "informative" + }> = [] + + const styles: Array<"catchy" | "emotional" | "informative"> = [ + "catchy", + "emotional", + "informative", + ] + styles.forEach((style) => { + const templates = AI_TITLE_TEMPLATES[style] + // 每种风格随机选2个 + const shuffled = [...templates].sort(() => Math.random() - 0.5).slice(0, 2) + shuffled.forEach((tpl) => { + const title = tpl.replace(/\{topic\}/g, topic) + const highlights = { + catchy: "吸睛标题", + emotional: "情感共鸣", + informative: "知识干货", + } + results.push({ + title, + highlight: highlights[style], + style, + }) + }) + }) + + // 打乱顺序 + results.sort(() => Math.random() - 0.5) + setAiTitleResults(results) + setAiTitleGenerating(false) + }, [aiTitleInput]) + + const handleSelectAiTitle = useCallback((title: string) => { + setTitleSettings((prev) => ({ ...prev, title, aiAutoSelect: false })) + message.success("已选用此标题") + }, []) + + const handleRefreshAiTitles = useCallback(async () => { + if (!aiTitleInput.trim()) return + setAiTitleGenerating(true) + await new Promise((resolve) => setTimeout(resolve, 800)) + // 重新生成一批 + const topic = extractTopic(aiTitleInput) + const results: typeof aiTitleResults = [] + const styles: Array<"catchy" | "emotional" | "informative"> = [ + "catchy", + "emotional", + "informative", + ] + const highlights = { catchy: "吸睛标题", emotional: "情感共鸣", informative: "知识干货" } + styles.forEach((style) => { + const templates = AI_TITLE_TEMPLATES[style] + const shuffled = [...templates].sort(() => Math.random() - 0.5).slice(0, 2) + shuffled.forEach((tpl) => { + results.push({ + title: tpl.replace(/\{topic\}/g, topic), + highlight: highlights[style], + style, + }) + }) + }) + results.sort(() => Math.random() - 0.5) + setAiTitleResults(results) + setAiTitleGenerating(false) + }, [aiTitleInput]) + + /* ── 智能配音推荐 ── */ + const handleVoiceRecommend = useCallback(async () => { + if (presetVoices.length === 0) return + setVoiceRecommendLoading(true) + setHasVoiceRecommend(true) + + await new Promise((resolve) => setTimeout(resolve, 1000)) + + // 根据标题内容风格模拟推荐:情感类→温柔女声,知识类→沉稳男声,活力类→阳光少年 + const title = titleSettings.title.toLowerCase() + let recommended: string[] = [] + + const femaleVoices = presetVoices.filter((v) => v.gender === "female").map((v) => v.voice_id) + const maleVoices = presetVoices.filter((v) => v.gender === "male").map((v) => v.voice_id) + const childVoices = presetVoices.filter((v) => v.gender === "child").map((v) => v.voice_id) + + if (/情感|感人|温暖|治愈|故事|回忆/.test(title)) { + recommended = femaleVoices.slice(0, 3) + } else if (/教程|知识|科普|干货|讲解|分析/.test(title)) { + recommended = maleVoices.slice(0, 2).concat(femaleVoices.slice(0, 1)) + } else if (/活力|热血|运动|搞笑|有趣/.test(title)) { + recommended = childVoices.slice(0, 1).concat(maleVoices.slice(0, 1), femaleVoices.slice(0, 1)) + } else { + // 默认推荐前3个 + recommended = presetVoices.slice(0, 3).map((v) => v.voice_id) + } + + // 不足3个时补足 + if (recommended.length < 3) { + const others = presetVoices + .filter((v) => !recommended.includes(v.voice_id)) + .map((v) => v.voice_id) + recommended = recommended.concat(others.slice(0, 3 - recommended.length)) + } + + setVoiceRecommendations(recommended) + setVoiceRecommendLoading(false) + }, [presetVoices, titleSettings.title]) + + const handleSelectRecommendedVoice = useCallback((voiceId: string) => { + setVoiceMode("preset") + setSelectedVoice(voiceId) + }, []) + + /* ── 生成阶段映射 ── */ + const getGenerationPhase = (p: number) => { + if (p < 20) return { label: "分析素材与配置", icon: "🔍" } + if (p < 50) return { label: "智能剪辑合成", icon: "🎬" } + if (p < 80) return { label: "渲染视频中", icon: "⚡" } + return { label: "即将完成", icon: "✨" } + } + const handleCloneSuccess = useCallback( (voice: VoiceClone) => { addClone(voice) @@ -1021,6 +1206,12 @@ const GeneratePage: React.FC = () => { smartSelectedIds, ]) + /* 重新生成(失败后重试) */ + const handleRetryGenerate = useCallback(() => { + setGenerateError(null) + handleGenerate() + }, [handleGenerate]) + /* ── 下载视频 ── */ const handleDownload = useCallback(async () => { if (!generatedVideos.length) return @@ -1502,6 +1693,90 @@ const GeneratePage: React.FC = () => {

📝 选择标题

+ {/* AI 智能生成标题 */} +
+
+ ✨ AI 智能生成标题 +
+
+ setAiTitleInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleGenerateAiTitles() + }} + /> + +
+ + {/* 生成结果 */} + {hasGeneratedTitles && !aiTitleGenerating && aiTitleResults.length > 0 && ( +
+
+ + 为你生成 {aiTitleResults.length} 个标题 + + +
+
+ {aiTitleResults.map((item, idx) => { + const isSelected = titleSettings.title === item.title + return ( +
handleSelectAiTitle(item.title)} + > +
{item.title}
+
{item.highlight}
+ {isSelected && ( +
+ +
+ )} +
+ ) + })} +
+
+ )} + + {/* 生成中 */} + {aiTitleGenerating && ( +
+ + AI 正在为你创作标题… +
+ )} +
+ +
+ 或手动选择 +
+ {/* AI 自动选择开关 */}
AI 自动选择标题 @@ -1693,6 +1968,77 @@ const GeneratePage: React.FC = () => {

🎙️ 选择配音

+ {/* AI 智能推荐配音 */} +
+
+ ✨ AI 智能推荐 + +
+ + {voiceRecommendLoading && ( +
+ + 根据视频内容为你匹配最合适的音色… +
+ )} + + {!voiceRecommendLoading && hasVoiceRecommend && voiceRecommendations.length > 0 && ( +
+ {voiceRecommendations.map((voiceId) => { + const v = presetVoices.find((pv) => pv.voice_id === voiceId) + if (!v) return null + const isSelected = voiceMode === "preset" && selectedVoice === v.voice_id + return ( +
handleSelectRecommendedVoice(v.voice_id)} + > +
+ {VOICE_GENDER_ICON[v.gender] ?? "✨"} +
+
+
{v.name}
+
{v.description}
+
+ {isSelected && ( +
+ +
+ )} +
+ ) + })} +
+ )} + + {!voiceRecommendLoading && !hasVoiceRecommend && ( +
+ 点击「智能推荐」,AI 根据视频内容匹配音色 +
+ )} +
+ +
+ 全部音色 +
+ {/* 配音方式选择卡片 — 从 API 预设音色动态生成 */}
{presetVoices.slice(0, 3).map((v) => ( @@ -2314,71 +2660,82 @@ const GeneratePage: React.FC = () => { {(generating || generated || generateError) && (
{generating && ( - <> -
+
+
+
+ +
+
+
+ {getGenerationPhase(progress).icon} {getGenerationPhase(progress).label} +
+
预计还需 1-2 分钟,请稍候…
+
+
{Math.round(progress)}%
+
+
- - - 正在生成视频,请稍候… {Math.round(progress)}% - - - )} - {generated && !generating && ( -
- -
- - 视频生成完成! - - - 可在右侧预览或前往成片库查看 - +
+ 💡 生成过程中可以切换到其他页面操作,完成后会自动通知
)} - {generateError && !generating && ( -
- +
+ +
+
+
视频生成完成!
+
+ 共生成 {generatedVideos.length} 条视频,可在右侧预览或前往成片库查看 +
+
+ +
+ )} + {generateError && !generating && ( +
+
+ +
+
+
生成失败
+
{typeof generateError === "string" ? generateError : JSON.stringify(generateError)} - +
+
+
+ +
)} diff --git a/apps/web/src/pages/generate/generate.css b/apps/web/src/pages/generate/generate.css index 64e9a06c5..2c2f0702c 100755 --- a/apps/web/src/pages/generate/generate.css +++ b/apps/web/src/pages/generate/generate.css @@ -273,6 +273,120 @@ color: var(--info-color); } +/* ============================================================ + AI 智能配音推荐 + ============================================================ */ +.xx-voice-recommend-section { + padding: 16px; + background: linear-gradient(135deg, #f0f4ff 0%, #faf5ff 100%); + border: 1px solid var(--border-primary, #e2e8f0); + border-radius: 14px; + margin-bottom: 16px; +} + +.xx-voice-recommend-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.xx-voice-recommend-label { + font-size: 14px; + font-weight: 600; + color: var(--text-primary, #1e293b); +} + +.xx-voice-recommend-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.xx-voice-recommend-card { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + background: #fff; + border: 2px solid var(--border-primary, #e2e8f0); + border-radius: 10px; + cursor: pointer; + transition: all 0.2s ease; + position: relative; +} + +.xx-voice-recommend-card:hover { + border-color: var(--primary-color, #4f46e5); + transform: translateX(2px); +} + +.xx-voice-recommend-card.selected { + border-color: var(--primary-color, #4f46e5); + background: var(--primary-soft, #eef2ff); +} + +.xx-voice-recommend-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + background: linear-gradient(135deg, #a5b4fc, #c4b5fd); + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + flex-shrink: 0; +} + +.xx-voice-recommend-info { + flex: 1; + min-width: 0; +} + +.xx-voice-recommend-name { + font-size: 13px; + font-weight: 500; + color: var(--text-primary, #1e293b); + margin-bottom: 2px; +} + +.xx-voice-recommend-desc { + font-size: 11px; + color: var(--text-tertiary, #94a3b8); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.xx-voice-recommend-check { + width: 20px; + height: 20px; + background: var(--primary-color, #4f46e5); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.xx-voice-recommend-loading { + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + font-size: 13px; + color: var(--text-secondary, #64748b); +} + +.xx-voice-recommend-empty { + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + font-size: 12px; + color: var(--text-tertiary, #94a3b8); +} + /* ============================================================ 配音卡片(步骤4 choice-list 变体) ============================================================ */ @@ -548,6 +662,147 @@ } } +/* ============================================================ + 生成进度 / 结果卡片 + ============================================================ */ +.xx-gen-progress-card { + padding: 16px; + background: linear-gradient(135deg, #eff6ff 0%, #eef2ff 100%); + border: 1px solid #bfdbfe; + border-radius: 12px; +} + +.xx-gen-progress-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; +} + +.xx-gen-progress-icon { + width: 40px; + height: 40px; + border-radius: 50%; + background: #fff; + display: flex; + align-items: center; + justify-content: center; + color: var(--primary-color, #4f46e5); + font-size: 18px; + flex-shrink: 0; +} + +.xx-gen-progress-info { + flex: 1; + min-width: 0; +} + +.xx-gen-progress-phase { + font-size: 14px; + font-weight: 600; + color: var(--text-primary, #1e293b); + margin-bottom: 2px; +} + +.xx-gen-progress-sub { + font-size: 12px; + color: var(--text-secondary, #64748b); +} + +.xx-gen-progress-percent { + font-size: 20px; + font-weight: 700; + color: var(--primary-color, #4f46e5); + flex-shrink: 0; +} + +.xx-gen-progress-bar { + width: 100%; + height: 6px; + background: rgba(79, 70, 229, 0.15); + border-radius: 3px; + overflow: hidden; + margin-bottom: 10px; +} + +.xx-gen-progress-bar-fill { + height: 100%; + background: linear-gradient(90deg, #4f46e5, #7c3aed); + border-radius: 3px; + transition: width 0.3s ease; +} + +.xx-gen-progress-tip { + font-size: 12px; + color: var(--text-tertiary, #94a3b8); + text-align: center; +} + +.xx-gen-success-card { + display: flex; + align-items: center; + gap: 12px; + padding: 16px; + background: #f0fdf4; + border: 1px solid #bbf7d0; + border-radius: 12px; +} + +.xx-gen-success-icon { + flex-shrink: 0; +} + +.xx-gen-success-info { + flex: 1; + min-width: 0; +} + +.xx-gen-success-title { + font-size: 15px; + font-weight: 600; + color: #166534; + margin-bottom: 4px; +} + +.xx-gen-success-sub { + font-size: 12px; + color: #15803d; +} + +.xx-gen-error-card { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 16px; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 12px; +} + +.xx-gen-error-icon { + flex-shrink: 0; + margin-top: 2px; +} + +.xx-gen-error-info { + flex: 1; + min-width: 0; +} + +.xx-gen-error-title { + font-size: 14px; + font-weight: 600; + color: #991b1b; + margin-bottom: 4px; +} + +.xx-gen-error-msg { + font-size: 12px; + color: #b91c1c; + line-height: 1.5; + word-break: break-all; +} + /* ============================================================ 确认生成(步骤5)摘要 ============================================================ */ @@ -1382,6 +1637,173 @@ align-items: center; } +/* ============================================================ + AI 智能生成标题 + ============================================================ */ +.xx-ai-title-section { + padding: 16px; + background: linear-gradient(135deg, #f0f4ff 0%, #faf5ff 100%); + border: 1px solid var(--border-primary, #e2e8f0); + border-radius: 14px; + margin-bottom: 16px; +} + +.xx-ai-title-header { + margin-bottom: 10px; +} + +.xx-ai-title-label { + font-size: 14px; + font-weight: 600; + color: var(--text-primary, #1e293b); +} + +.xx-ai-title-input-row { + display: flex; + gap: 10px; +} + +.xx-ai-title-input { + flex: 1; + padding: 10px 14px; + font-size: 13px; + border: 1px solid var(--border-primary, #e2e8f0); + border-radius: 10px; + background: #fff; + transition: border-color 0.2s; +} + +.xx-ai-title-input:focus { + outline: none; + border-color: var(--primary-color, #4f46e5); + box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1); +} + +.xx-ai-title-input::placeholder { + color: var(--text-tertiary, #94a3b8); +} + +.xx-ai-title-results { + margin-top: 14px; +} + +.xx-ai-title-results-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.xx-ai-title-results-count { + font-size: 13px; + font-weight: 500; + color: var(--text-primary, #1e293b); +} + +.xx-ai-title-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 320px; + overflow-y: auto; + padding-right: 4px; +} + +.xx-ai-title-card { + position: relative; + padding: 12px 14px; + background: #fff; + border: 2px solid var(--border-primary, #e2e8f0); + border-radius: 10px; + cursor: pointer; + transition: all 0.2s ease; +} + +.xx-ai-title-card:hover { + border-color: var(--primary-color, #4f46e5); + transform: translateX(2px); +} + +.xx-ai-title-card.selected { + border-color: var(--primary-color, #4f46e5); + background: var(--primary-soft, #eef2ff); +} + +.xx-ai-title-card-text { + font-size: 13px; + color: var(--text-primary, #1e293b); + line-height: 1.5; + padding-right: 50px; +} + +.xx-ai-title-card-tag { + display: inline-block; + margin-top: 6px; + padding: 2px 8px; + font-size: 11px; + border-radius: 10px; + background: #f1f5f9; + color: var(--text-secondary, #64748b); +} + +.xx-ai-title-card.catchy .xx-ai-title-card-tag { + background: #fef3c7; + color: #b45309; +} + +.xx-ai-title-card.emotional .xx-ai-title-card-tag { + background: #fce7f3; + color: #be185d; +} + +.xx-ai-title-card.informative .xx-ai-title-card-tag { + background: #dbeafe; + color: #1d4ed8; +} + +.xx-ai-title-card-check { + position: absolute; + top: 50%; + right: 12px; + transform: translateY(-50%); + width: 20px; + height: 20px; + background: var(--primary-color, #4f46e5); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +.xx-ai-title-loading { + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + font-size: 13px; + color: var(--text-secondary, #64748b); +} + +.xx-divider { + display: flex; + align-items: center; + margin: 16px 0; + color: var(--text-tertiary, #94a3b8); + font-size: 12px; +} + +.xx-divider::before, +.xx-divider::after { + content: ""; + flex: 1; + height: 1px; + background: var(--border-primary, #e2e8f0); +} + +.xx-divider span { + padding: 0 12px; +} + /* ============================================================ 标题设置(选择标题步骤) ============================================================ */