diff --git a/apps/web/src/config/navigation.ts b/apps/web/src/config/navigation.ts index dcbf208b2..0105d5742 100644 --- a/apps/web/src/config/navigation.ts +++ b/apps/web/src/config/navigation.ts @@ -18,6 +18,7 @@ import { ControlOutlined, CrownOutlined, UnorderedListOutlined, + UserOutlined, } from "@ant-design/icons" /** 导航项类型 */ @@ -88,6 +89,12 @@ export const NAV_ITEMS: NavItem[] = [ path: "/app/generate", icon: React.createElement(VideoCameraOutlined), }, + { + key: "ai-avatar", + label: "AI数字人", + path: "/app/ai-avatar", + icon: React.createElement(UserOutlined), + }, { key: "history", label: "任务历史", @@ -131,6 +138,12 @@ export const NAV_GROUPS: NavGroup[] = [ path: "/app/generate", icon: React.createElement(VideoCameraOutlined), }, + { + key: "ai-avatar", + label: "AI数字人", + path: "/app/ai-avatar", + icon: React.createElement(UserOutlined), + }, { key: "editing-planner", label: "剪辑模板", diff --git a/apps/web/src/pages/ai-avatar/AiAvatar.css b/apps/web/src/pages/ai-avatar/AiAvatar.css new file mode 100644 index 000000000..d2c3cb331 --- /dev/null +++ b/apps/web/src/pages/ai-avatar/AiAvatar.css @@ -0,0 +1,607 @@ +/** + * AI数字人页面 — 5 列水平面板布局 (#1798) + */ + +/* ── 页面容器 ── */ +.ai-avatar-page { + display: flex; + gap: 0; + height: 100%; + min-width: 1280px; + overflow-x: auto; + background: var(--bg-primary, #0f0f0f); +} + +/* ── 面板通用 ── */ +.ai-avatar-panel { + display: flex; + flex-direction: column; + border-right: 1px solid var(--border-color, #2a2a2a); + overflow: hidden; + transition: width 0.2s ease; +} + +.ai-avatar-panel:last-child { + border-right: none; +} + +.ai-avatar-panel.collapsed { + width: 48px !important; + min-width: 48px !important; +} + +.ai-avatar-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: var(--bg-secondary, #1a1a1a); + border-bottom: 1px solid var(--border-color, #2a2a2a); + cursor: pointer; + user-select: none; + flex-shrink: 0; +} + +.ai-avatar-panel-header h3 { + margin: 0; + font-size: 14px; + font-weight: 600; + color: var(--text-primary, #fff); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ai-avatar-panel-header .collapse-btn { + background: none; + border: none; + color: var(--text-secondary, #999); + cursor: pointer; + padding: 2px; + font-size: 12px; + transition: transform 0.2s; +} + +.ai-avatar-panel.collapsed .collapse-btn { + transform: rotate(180deg); +} + +.ai-avatar-panel-body { + flex: 1; + overflow-y: auto; + padding: 16px; +} + +.ai-avatar-panel.collapsed .ai-avatar-panel-body { + display: none; +} + +.ai-avatar-panel.collapsed .ai-avatar-panel-header h3 { + display: none; +} + +/* ── 面板宽度 ── */ +.ai-avatar-panel.panel-avatar-video { + width: 20%; +} +.ai-avatar-panel.panel-voice-clone { + width: 20%; +} +.ai-avatar-panel.panel-script-lipsync { + width: 25%; +} +.ai-avatar-panel.panel-title-config { + width: 17.5%; +} +.ai-avatar-panel.panel-cover-generate { + width: 17.5%; +} + +/* ── 上传拖拽区 ── */ +.ai-avatar-upload-zone { + border: 2px dashed var(--border-color, #2a2a2a); + border-radius: 8px; + padding: 32px 16px; + text-align: center; + cursor: pointer; + transition: + border-color 0.2s, + background 0.2s; + color: var(--text-secondary, #999); + font-size: 13px; +} + +.ai-avatar-upload-zone:hover { + border-color: var(--primary-color, #3b82f6); + background: rgba(59, 130, 246, 0.05); +} + +.ai-avatar-upload-zone .upload-icon { + font-size: 32px; + margin-bottom: 8px; + display: block; +} + +/* ── 视频/音频预览 ── */ +.ai-avatar-media-preview { + width: 100%; + border-radius: 8px; + overflow: hidden; + background: #000; + margin-top: 12px; +} + +.ai-avatar-media-preview video, +.ai-avatar-media-preview audio { + width: 100%; + display: block; +} + +.ai-avatar-media-info { + display: flex; + gap: 12px; + padding: 8px 0; + font-size: 12px; + color: var(--text-secondary, #999); +} + +/* ── 文案输入 ── */ +.ai-avatar-script-editor { + width: 100%; + min-height: 120px; + resize: vertical; + background: var(--bg-tertiary, #222); + border: 1px solid var(--border-color, #2a2a2a); + border-radius: 6px; + padding: 12px; + color: var(--text-primary, #fff); + font-size: 14px; + line-height: 1.6; + font-family: inherit; +} + +.ai-avatar-script-editor:focus { + outline: none; + border-color: var(--primary-color, #3b82f6); +} + +.ai-avatar-script-tabs { + display: flex; + gap: 8px; + margin-bottom: 12px; +} + +.ai-avatar-script-tabs button { + padding: 6px 12px; + border: 1px solid var(--border-color, #2a2a2a); + border-radius: 6px; + background: transparent; + color: var(--text-secondary, #999); + cursor: pointer; + font-size: 13px; + transition: all 0.2s; +} + +.ai-avatar-script-tabs button.active { + background: var(--primary-color, #3b82f6); + color: #fff; + border-color: var(--primary-color, #3b82f6); +} + +.ai-avatar-script-word-count { + text-align: right; + font-size: 12px; + color: var(--text-secondary, #999); + margin-top: 4px; +} + +/* ── 对口型预览 ── */ +.ai-avatar-lipsync-section { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--border-color, #2a2a2a); +} + +.ai-avatar-lipsync-section h4 { + font-size: 13px; + color: var(--text-secondary, #999); + margin: 0 0 12px; + font-weight: 500; +} + +.ai-avatar-lipsync-actions { + display: flex; + gap: 8px; + margin-top: 12px; +} + +/* ── 声音克隆状态 ── */ +.ai-avatar-clone-status { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 6px; + font-size: 13px; + margin-top: 12px; +} + +.ai-avatar-clone-status.success { + background: rgba(34, 197, 94, 0.1); + color: #22c55e; +} + +.ai-avatar-clone-status.cloning { + background: rgba(59, 130, 246, 0.1); + color: #3b82f6; +} + +.ai-avatar-clone-status.failed { + background: rgba(239, 68, 68, 0.1); + color: #ef4444; +} + +/* ── 音色列表 ── */ +.ai-avatar-voice-list { + margin-top: 16px; +} + +.ai-avatar-voice-list h4 { + font-size: 13px; + color: var(--text-secondary, #999); + margin: 0 0 8px; + font-weight: 500; +} + +.ai-avatar-voice-item { + display: flex; + align-items: center; + padding: 8px 12px; + border-radius: 6px; + cursor: pointer; + transition: background 0.2s; +} + +.ai-avatar-voice-item:hover { + background: var(--bg-tertiary, #222); +} + +.ai-avatar-voice-item.selected { + background: rgba(59, 130, 246, 0.1); + border: 1px solid var(--primary-color, #3b82f6); +} + +/* ── 字幕设置 ── */ +.ai-avatar-subtitle-section { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--border-color, #2a2a2a); +} + +.ai-avatar-subtitle-section label { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text-secondary, #999); + cursor: pointer; +} + +/* ── 封面预览 ── */ +.ai-avatar-cover-preview { + width: 100%; + aspect-ratio: 16/9; + border-radius: 8px; + overflow: hidden; + background: var(--bg-tertiary, #222); + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary, #999); + font-size: 13px; + margin-bottom: 12px; +} + +.ai-avatar-cover-preview img { + width: 100%; + height: 100%; + object-fit: cover; +} + +/* ── 生成设置 ── */ +.ai-avatar-generate-section { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--border-color, #2a2a2a); +} + +.ai-avatar-generate-section .field-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + font-size: 13px; + color: var(--text-primary, #fff); +} + +.ai-avatar-generate-section select { + background: var(--bg-tertiary, #222); + border: 1px solid var(--border-color, #2a2a2a); + border-radius: 4px; + color: var(--text-primary, #fff); + padding: 4px 8px; + font-size: 13px; +} + +/* ── 生成按钮 ── */ +.ai-avatar-generate-btn { + width: 100%; + padding: 12px; + border: none; + border-radius: 8px; + background: linear-gradient(135deg, #3b82f6, #8b5cf6); + color: #fff; + font-size: 15px; + font-weight: 600; + cursor: pointer; + transition: opacity 0.2s; + margin-top: 16px; +} + +.ai-avatar-generate-btn:hover { + opacity: 0.9; +} + +.ai-avatar-generate-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ── 弹窗通用 ── */ +.ai-avatar-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.ai-avatar-modal { + background: var(--bg-secondary, #1a1a1a); + border-radius: 12px; + width: 640px; + max-height: 80vh; + display: flex; + flex-direction: column; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); +} + +.ai-avatar-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--border-color, #2a2a2a); +} + +.ai-avatar-modal-header h3 { + margin: 0; + font-size: 16px; + color: var(--text-primary, #fff); +} + +.ai-avatar-modal-body { + flex: 1; + overflow-y: auto; + padding: 20px; +} + +.ai-avatar-modal-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 16px 20px; + border-top: 1px solid var(--border-color, #2a2a2a); +} + +/* ── 文案选择弹窗 ── */ +.ai-avatar-script-search { + display: flex; + gap: 8px; + margin-bottom: 16px; +} + +.ai-avatar-script-search input { + flex: 1; + background: var(--bg-tertiary, #222); + border: 1px solid var(--border-color, #2a2a2a); + border-radius: 6px; + padding: 8px 12px; + color: var(--text-primary, #fff); + font-size: 14px; +} + +.ai-avatar-script-search input:focus { + outline: none; + border-color: var(--primary-color, #3b82f6); +} + +.ai-avatar-script-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--border-color, #2a2a2a); + cursor: pointer; + transition: background 0.2s; +} + +.ai-avatar-script-item:hover { + background: var(--bg-tertiary, #222); +} + +.ai-avatar-script-item.selected { + background: rgba(59, 130, 246, 0.1); +} + +.ai-avatar-script-item-info { + flex: 1; +} + +.ai-avatar-script-item-info h4 { + margin: 0 0 4px; + font-size: 14px; + color: var(--text-primary, #fff); + font-weight: 500; +} + +.ai-avatar-script-item-info span { + font-size: 12px; + color: var(--text-secondary, #999); +} + +/* ── B-roll 弹窗 ── */ +.ai-avatar-broll-layout { + display: flex; + gap: 16px; +} + +.ai-avatar-broll-timeline { + flex: 1; + min-width: 0; +} + +.ai-avatar-broll-settings { + width: 220px; + flex-shrink: 0; +} + +.ai-avatar-broll-thumbnails { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 8px; + margin-bottom: 16px; +} + +.ai-avatar-broll-thumb { + aspect-ratio: 16/9; + border-radius: 4px; + overflow: hidden; + background: var(--bg-tertiary, #222); + cursor: grab; + border: 2px solid transparent; + transition: border-color 0.2s; +} + +.ai-avatar-broll-thumb:hover { + border-color: var(--primary-color, #3b82f6); +} + +.ai-avatar-broll-thumb.selected { + border-color: var(--primary-color, #3b82f6); +} + +.ai-avatar-pip-positions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4px; + width: 120px; +} + +.ai-avatar-pip-positions button { + padding: 4px 8px; + border: 1px solid var(--border-color, #2a2a2a); + border-radius: 4px; + background: transparent; + color: var(--text-secondary, #999); + font-size: 11px; + cursor: pointer; +} + +.ai-avatar-pip-positions button.active { + background: var(--primary-color, #3b82f6); + color: #fff; + border-color: var(--primary-color, #3b82f6); +} + +/* ── 状态指示器 ── */ +.ai-avatar-status-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 10px; + font-size: 12px; +} + +.ai-avatar-status-badge.completed { + background: rgba(34, 197, 94, 0.15); + color: #22c55e; +} + +.ai-avatar-status-badge.processing { + background: rgba(59, 130, 246, 0.15); + color: #3b82f6; +} + +.ai-avatar-status-badge.failed { + background: rgba(239, 68, 68, 0.15); + color: #ef4444; +} + +/* ── 通用按钮 ── */ +.aa-btn { + padding: 6px 14px; + border-radius: 6px; + font-size: 13px; + cursor: pointer; + border: 1px solid var(--border-color, #2a2a2a); + background: transparent; + color: var(--text-primary, #fff); + transition: all 0.2s; +} + +.aa-btn:hover { + background: var(--bg-tertiary, #222); +} + +.aa-btn-primary { + background: var(--primary-color, #3b82f6); + color: #fff; + border-color: var(--primary-color, #3b82f6); +} + +.aa-btn-primary:hover { + opacity: 0.9; + background: var(--primary-color, #3b82f6); +} + +.aa-btn-sm { + padding: 4px 10px; + font-size: 12px; +} + +/* ── 单选组 ── */ +.aa-radio-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.aa-radio-group label { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text-primary, #fff); + cursor: pointer; +} + +/* ── 分割线 ── */ +.aa-divider { + border: none; + border-top: 1px solid var(--border-color, #2a2a2a); + margin: 16px 0; +} diff --git a/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx b/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx new file mode 100644 index 000000000..3a27ddaad --- /dev/null +++ b/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx @@ -0,0 +1,102 @@ +/** + * AI数字人 — 主页面(5列水平面板布局)(#1798) + */ +import React from "react" +import { useAiAvatarState } from "./hooks/useAiAvatarState" +import AvatarVideoPanel from "./components/AvatarVideoPanel" +import VoiceClonePanel from "./components/VoiceClonePanel" +import ScriptLipsyncPanel from "./components/ScriptLipsyncPanel" +import TitleConfigPanel from "./components/TitleConfigPanel" +import CoverGeneratePanel from "./components/CoverGeneratePanel" +import ScriptSelectModal from "./components/ScriptSelectModal" +import BRollInsertModal from "./components/BRollInsertModal" +import "./AiAvatar.css" + +const AiAvatarPage: React.FC = () => { + const state = useAiAvatarState() + + const handleSubmitGenerate = React.useCallback(() => { + // TODO: 调用 submitRender API + state.setIsGenerating(true) + }, [state]) + + return ( +
+ {/* 面板1:出镜视频 */} + state.togglePanel("avatar-video")} + /> + + {/* 面板2:声音克隆 */} + state.togglePanel("voice-clone")} + /> + + {/* 面板3:文案 & 对口型 */} + state.setScriptModalOpen(true)} + onOpenBRollModal={() => state.setBrollModalOpen(true)} + collapsed={!!state.collapsedPanels["script-lipsync"]} + onToggleCollapse={() => state.togglePanel("script-lipsync")} + /> + + {/* 面板4:标题配置 */} + state.togglePanel("title-config")} + /> + + {/* 面板5:封面 & 生成 */} + state.togglePanel("cover-generate")} + /> + + {/* 弹窗:文案选择 */} + state.setScriptModalOpen(false)} + onSelect={(script) => { + state.setSelectedScript(script) + state.setScriptContent(script.content) + state.setScriptModalOpen(false) + }} + /> + + {/* 弹窗:B-roll 插入 */} + state.setBrollModalOpen(false)} + onConfirm={(segment) => { + state.setBRollSegments((prev) => [...prev, segment]) + state.setBrollModalOpen(false) + }} + videoDuration={state.lipsyncJob?.output_duration ?? 0} + /> +
+ ) +} + +export default AiAvatarPage diff --git a/apps/web/src/pages/ai-avatar/api/aiAvatar.ts b/apps/web/src/pages/ai-avatar/api/aiAvatar.ts new file mode 100644 index 000000000..a190a8b47 --- /dev/null +++ b/apps/web/src/pages/ai-avatar/api/aiAvatar.ts @@ -0,0 +1,75 @@ +/** + * AI数字人 API 调用封装 (#1798) + */ +import apiClient from "@/api/client" +import type { + Script, + LipsyncJob, + AiAvatarRenderRequest, + AiAvatarRenderJob, +} from "../types/aiAvatar" + +/* ── 文案库 ── */ +export async function getScripts(params?: { search?: string; offset?: number; limit?: number }) { + const { data } = await apiClient.get<{ items: Script[]; total: number }>("/scripts", { params }) + return data +} + +export async function getScript(id: string) { + const { data } = await apiClient.get