From b5bc285aff06fe0ee0fca7bab6d1790a4ad28011 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 8 Sep 2026 20:44:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20#1797=20AI=E6=95=B0=E5=AD=97=E4=BA=BA?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E9=A1=B5=E9=9D=A2v3=20=E2=80=94=20=E7=B4=A0?= =?UTF-8?q?=E6=9D=90=E5=BA=93=E9=80=89=E6=8B=A9/=E9=85=8D=E9=9F=B3?= =?UTF-8?q?=E5=BA=93=E5=A4=8D=E7=94=A8/TitleStylePanel=E7=9B=B4=E6=8E=A5im?= =?UTF-8?q?port?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 面板1:出镜视频改为从素材库弹窗选择(ModalAssetPicker),竖屏9:16预览 - 面板2:配音库改为系统预设/我的音色双tab切换,复用 /api/v1/voices - 面板3:对口型三态(生成/进度/完成+重新生成),竖屏9:16预览 - 面板4:标题配置直接 import TitleStylePanel + TITLE_PRESETS,与智能剪辑完全一致 - 面板5:封面+分辨率+配置汇总+渐变生成按钮,删除多余画面插入模式 - B-roll弹窗:已选素材标灰pointer-events:none防重选,画中画四角位置 - 路由 /app/ai-avatar + 侧边栏AI数字人导航项 --- apps/web/src/pages/ai-avatar/AiAvatar.css | 1300 +++++++++++------ apps/web/src/pages/ai-avatar/AiAvatarPage.tsx | 389 +++-- apps/web/src/pages/ai-avatar/api/aiAvatar.ts | 96 +- .../ai-avatar/components/AvatarVideoPanel.tsx | 226 --- .../ai-avatar/components/BRollInsertModal.tsx | 272 ---- .../components/CoverGeneratePanel.tsx | 131 -- .../ai-avatar/components/ModalAssetPicker.tsx | 211 +++ .../ai-avatar/components/ModalBRollEditor.tsx | 345 +++++ .../components/PanelCoverAndGenerate.tsx | 210 +++ .../components/PanelScriptAndLipsync.tsx | 222 +++ .../ai-avatar/components/PanelTitleConfig.tsx | 130 ++ .../components/PanelVideoSelector.tsx | 91 ++ .../components/PanelVoiceSelector.tsx | 264 ++++ .../components/ScriptLipsyncPanel.tsx | 189 --- .../components/ScriptSelectModal.tsx | 122 -- .../ai-avatar/components/TitleConfigPanel.tsx | 250 ---- .../ai-avatar/components/VoiceClonePanel.tsx | 256 ---- .../src/pages/ai-avatar/hooks/useAiAvatar.ts | 141 ++ .../pages/ai-avatar/hooks/useAiAvatarState.ts | 132 -- apps/web/src/pages/ai-avatar/types.ts | 121 ++ .../web/src/pages/ai-avatar/types/aiAvatar.ts | 121 -- 21 files changed, 2942 insertions(+), 2277 deletions(-) delete mode 100644 apps/web/src/pages/ai-avatar/components/AvatarVideoPanel.tsx delete mode 100644 apps/web/src/pages/ai-avatar/components/BRollInsertModal.tsx delete mode 100644 apps/web/src/pages/ai-avatar/components/CoverGeneratePanel.tsx create mode 100644 apps/web/src/pages/ai-avatar/components/ModalAssetPicker.tsx create mode 100644 apps/web/src/pages/ai-avatar/components/ModalBRollEditor.tsx create mode 100644 apps/web/src/pages/ai-avatar/components/PanelCoverAndGenerate.tsx create mode 100644 apps/web/src/pages/ai-avatar/components/PanelScriptAndLipsync.tsx create mode 100644 apps/web/src/pages/ai-avatar/components/PanelTitleConfig.tsx create mode 100644 apps/web/src/pages/ai-avatar/components/PanelVideoSelector.tsx create mode 100644 apps/web/src/pages/ai-avatar/components/PanelVoiceSelector.tsx delete mode 100644 apps/web/src/pages/ai-avatar/components/ScriptLipsyncPanel.tsx delete mode 100644 apps/web/src/pages/ai-avatar/components/ScriptSelectModal.tsx delete mode 100644 apps/web/src/pages/ai-avatar/components/TitleConfigPanel.tsx delete mode 100644 apps/web/src/pages/ai-avatar/components/VoiceClonePanel.tsx create mode 100644 apps/web/src/pages/ai-avatar/hooks/useAiAvatar.ts delete mode 100644 apps/web/src/pages/ai-avatar/hooks/useAiAvatarState.ts create mode 100644 apps/web/src/pages/ai-avatar/types.ts delete mode 100644 apps/web/src/pages/ai-avatar/types/aiAvatar.ts diff --git a/apps/web/src/pages/ai-avatar/AiAvatar.css b/apps/web/src/pages/ai-avatar/AiAvatar.css index d2c3cb331..794ecf42e 100644 --- a/apps/web/src/pages/ai-avatar/AiAvatar.css +++ b/apps/web/src/pages/ai-avatar/AiAvatar.css @@ -1,607 +1,1023 @@ /** - * AI数字人页面 — 5 列水平面板布局 (#1798) + * AI数字人 — 页面样式(v3) + * 命名空间: .aa- (ai-avatar 缩写) */ /* ── 页面容器 ── */ -.ai-avatar-page { +.aa-page { display: flex; - gap: 0; + flex-direction: column; height: 100%; min-width: 1280px; + background: #f5f6fa; overflow-x: auto; - background: var(--bg-primary, #0f0f0f); +} + +.aa-page-header { + padding: 16px 24px; + background: #fff; + border-bottom: 1px solid #e8e8ec; + flex-shrink: 0; +} + +.aa-page-header h1 { + margin: 0; + font-size: 20px; + font-weight: 600; + color: #1a1a2e; +} + +.aa-page-body { + flex: 1; + display: flex; + gap: 12px; + padding: 16px; + overflow-x: auto; + min-height: 0; } /* ── 面板通用 ── */ -.ai-avatar-panel { +.aa-panel { + background: #fff; + border-radius: 12px; + border: 1px solid #e8e8ec; 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; +.aa-panel--p1 { + width: 20%; + min-width: 220px; +} +.aa-panel--p2 { + width: 20%; + min-width: 220px; +} +.aa-panel--p3 { + width: 25%; + min-width: 280px; +} +.aa-panel--p4 { + width: 17.5%; + min-width: 200px; +} +.aa-panel--p5 { + width: 17.5%; + min-width: 200px; } -.ai-avatar-panel.collapsed { - width: 48px !important; - min-width: 48px !important; +.aa-panel.collapsed .aa-panel__body { + display: none; } -.ai-avatar-panel-header { +.aa-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); + border-bottom: 1px solid #f0f0f5; 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; +.aa-panel__header:hover { + background: #fafafe; } -.ai-avatar-panel-header .collapse-btn { - background: none; +.aa-panel__title { + font-size: 14px; + font-weight: 600; + color: #1a1a2e; +} + +.aa-panel__toggle { + width: 24px; + height: 24px; border: none; - color: var(--text-secondary, #999); + background: none; cursor: pointer; - padding: 2px; - font-size: 12px; + color: #8c8ca1; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; transition: transform 0.2s; } -.ai-avatar-panel.collapsed .collapse-btn { - transform: rotate(180deg); +.aa-panel.collapsed .aa-panel__toggle { + transform: rotate(-90deg); } -.ai-avatar-panel-body { +.aa-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; +/* ── 通用表单元素 ── */ +.aa-label { 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); + font-weight: 500; + color: #6b6b80; + margin-bottom: 6px; } -/* ── 文案输入 ── */ -.ai-avatar-script-editor { +.aa-input, +.aa-select { 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 { + padding: 8px 12px; + border: 1px solid #e0e0e8; + border-radius: 8px; + font-size: 13px; + color: #1a1a2e; + background: #fff; outline: none; - border-color: var(--primary-color, #3b82f6); + transition: border-color 0.15s; } -.ai-avatar-script-tabs { - display: flex; - gap: 8px; - margin-bottom: 12px; +.aa-input:focus, +.aa-select:focus { + border-color: #7c3aed; + box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.1); } -.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 { +.aa-textarea { width: 100%; - aspect-ratio: 16/9; + padding: 10px 12px; + border: 1px solid #e0e0e8; border-radius: 8px; + font-size: 13px; + color: #1a1a2e; + background: #fff; + outline: none; + resize: vertical; + min-height: 100px; + font-family: inherit; + transition: border-color 0.15s; +} + +.aa-textarea:focus { + border-color: #7c3aed; + box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.1); +} + +.aa-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px 16px; + border: 1px solid #e0e0e8; + border-radius: 8px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; + background: #fff; + color: #1a1a2e; +} + +.aa-btn:hover { + background: #f5f5fa; + border-color: #c0c0d0; +} +.aa-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.aa-btn--primary { + background: #7c3aed; + color: #fff; + border-color: #7c3aed; +} +.aa-btn--primary:hover { + background: #6d28d9; +} + +.aa-btn--ghost { + background: transparent; + border-color: #d0d0dd; + color: #6b6b80; +} + +.aa-btn--danger { + background: #fee2e2; + border-color: #fca5a5; + color: #dc2626; +} +.aa-btn--danger:hover { + background: #fecaca; +} + +.aa-btn--full { + width: 100%; +} + +.aa-btn--generate { + background: linear-gradient(135deg, #7c3aed, #a855f7); + color: #fff; + border: none; + padding: 12px 20px; + font-size: 15px; + font-weight: 600; + border-radius: 10px; +} +.aa-btn--generate:hover { + background: linear-gradient(135deg, #6d28d9, #9333ea); +} + +/* ── 竖屏视频预览 ── */ +.aa-video-preview { + width: 100%; + aspect-ratio: 9/16; + background: #0f0f1a; + border-radius: 10px; overflow: hidden; - background: var(--bg-tertiary, #222); display: flex; align-items: center; justify-content: center; - color: var(--text-secondary, #999); + position: relative; +} + +.aa-video-preview video { + width: 100%; + height: 100%; + object-fit: contain; +} + +.aa-video-preview__placeholder { + color: #6b6b80; font-size: 13px; + text-align: center; +} + +/* ── 上传区域 ── */ +.aa-upload-zone { + border: 2px dashed #d0d0dd; + border-radius: 10px; + padding: 24px 16px; + text-align: center; + cursor: pointer; + transition: all 0.15s; + background: #fafafe; +} + +.aa-upload-zone:hover { + border-color: #7c3aed; + background: #f5f0ff; +} + +.aa-upload-zone__icon { + font-size: 28px; + margin-bottom: 8px; + color: #8c8ca1; +} + +.aa-upload-zone__text { + font-size: 13px; + color: #6b6b80; +} + +/* ── 视频信息卡片 ── */ +.aa-video-info { + margin-top: 10px; + padding: 10px 12px; + background: #f8f8fc; + border-radius: 8px; + font-size: 12px; + color: #6b6b80; +} + +.aa-video-info__row { + display: flex; + justify-content: space-between; + padding: 3px 0; +} + +/* ── 配音面板 ── */ +.aa-voice-source-toggle { + display: flex; + gap: 8px; + margin-bottom: 16px; +} + +.aa-voice-source-btn { + flex: 1; + padding: 8px; + border: 1px solid #e0e0e8; + border-radius: 8px; + font-size: 12px; + font-weight: 500; + text-align: center; + cursor: pointer; + background: #fff; + color: #6b6b80; + transition: all 0.15s; +} + +.aa-voice-source-btn.active { + background: #f5f0ff; + border-color: #7c3aed; + color: #7c3aed; +} + +.aa-voice-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 240px; + overflow-y: auto; +} + +.aa-voice-card { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border: 1px solid #e8e8ec; + border-radius: 8px; + cursor: pointer; + transition: all 0.15s; +} + +.aa-voice-card:hover { + border-color: #c0c0d0; + background: #fafafe; +} + +.aa-voice-card.selected { + border-color: #7c3aed; + background: #f5f0ff; +} + +.aa-voice-card__radio { + width: 16px; + height: 16px; + border-radius: 50%; + border: 2px solid #d0d0dd; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.aa-voice-card.selected .aa-voice-card__radio { + border-color: #7c3aed; +} + +.aa-voice-card.selected .aa-voice-card__radio::after { + content: ""; + width: 8px; + height: 8px; + border-radius: 50%; + background: #7c3aed; +} + +.aa-voice-card__info { + flex: 1; + min-width: 0; +} + +.aa-voice-card__name { + font-size: 13px; + font-weight: 500; + color: #1a1a2e; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.aa-voice-card__desc { + font-size: 11px; + color: #8c8ca1; + margin-top: 2px; +} + +.aa-voice-card__preview { + width: 28px; + height: 28px; + border-radius: 50%; + border: none; + background: #f0f0f5; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + flex-shrink: 0; +} + +.aa-voice-card__preview:hover { + background: #e0e0ea; +} + +/* ── 配音参数 ── */ +.aa-voice-params { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid #f0f0f5; + display: flex; + flex-direction: column; + gap: 12px; +} + +.aa-voice-params__row { + display: flex; + gap: 8px; +} + +.aa-voice-params__field { + flex: 1; +} + +.aa-clone-entry { + margin-top: 12px; + text-align: center; +} + +/* ── 文案 & 对口型 ── */ +.aa-script-tabs { + display: flex; + gap: 8px; margin-bottom: 12px; } -.ai-avatar-cover-preview img { +.aa-script-tab { + padding: 6px 14px; + border: 1px solid #e0e0e8; + border-radius: 6px; + font-size: 12px; + cursor: pointer; + background: #fff; + color: #6b6b80; + transition: all 0.15s; +} + +.aa-script-tab.active { + background: #7c3aed; + border-color: #7c3aed; + color: #fff; +} + +.aa-char-count { + text-align: right; + font-size: 11px; + color: #8c8ca1; + margin-top: 4px; +} + +.aa-lipsync-section { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid #f0f0f5; +} + +.aa-lipsync-section__title { + font-size: 13px; + font-weight: 600; + color: #1a1a2e; + margin-bottom: 12px; +} + +.aa-lipsync-preview { + width: 100%; + aspect-ratio: 9/16; + background: #0f0f1a; + border-radius: 10px; + overflow: hidden; + margin-bottom: 12px; + display: flex; + align-items: center; + justify-content: center; +} + +.aa-lipsync-preview video { + width: 100%; + height: 100%; + object-fit: contain; +} + +.aa-lipsync-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.aa-broll-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + background: #f0fdf4; + border: 1px solid #86efac; + border-radius: 12px; + font-size: 11px; + color: #16a34a; +} + +/* ── 标题配置 ── */ +.aa-title-input { + margin-bottom: 16px; +} + +.aa-title-style-section { + margin-top: 12px; +} + +.aa-subtitle-toggle { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid #f0f0f5; +} + +.aa-checkbox-row { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: #1a1a2e; + cursor: pointer; +} + +/* ── 封面 & 生成 ── */ +.aa-cover-preview { + width: 100%; + aspect-ratio: 9/16; + max-height: 160px; + background: #f0f0f5; + border-radius: 10px; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 12px; +} + +.aa-cover-preview img { width: 100%; height: 100%; object-fit: cover; } -/* ── 生成设置 ── */ -.ai-avatar-generate-section { +.aa-cover-preview__placeholder { + font-size: 12px; + color: #8c8ca1; +} + +.aa-cover-actions { + display: flex; + gap: 8px; + margin-bottom: 16px; +} + +.aa-generate-section { margin-top: 16px; padding-top: 16px; - border-top: 1px solid var(--border-color, #2a2a2a); + border-top: 1px solid #f0f0f5; } -.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%; +.aa-config-summary { + margin: 12px 0; padding: 12px; - border: none; + background: #f8f8fc; 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; + font-size: 12px; } -.ai-avatar-generate-btn:hover { - opacity: 0.9; +.aa-config-summary__row { + display: flex; + justify-content: space-between; + padding: 4px 0; + color: #6b6b80; } -.ai-avatar-generate-btn:disabled { - opacity: 0.5; - cursor: not-allowed; +.aa-config-summary__value { + color: #1a1a2e; + font-weight: 500; +} + +.aa-config-summary__empty { + color: #c0c0d0; + font-style: italic; +} + +/* ── 状态标签 ── */ +.aa-status-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + border-radius: 12px; + font-size: 12px; + font-weight: 500; +} + +.aa-status-badge--idle { + background: #f0f0f5; + color: #8c8ca1; +} +.aa-status-badge--pending { + background: #fef3c7; + color: #d97706; +} +.aa-status-badge--processing { + background: #dbeafe; + color: #2563eb; +} +.aa-status-badge--completed { + background: #d1fae5; + color: #059669; +} +.aa-status-badge--failed { + background: #fee2e2; + color: #dc2626; +} + +/* ── 进度条 ── */ +.aa-progress { + width: 100%; + height: 6px; + background: #e8e8ec; + border-radius: 3px; + overflow: hidden; + margin: 8px 0; +} + +.aa-progress__bar { + height: 100%; + background: linear-gradient(90deg, #7c3aed, #a855f7); + border-radius: 3px; + transition: width 0.3s; } /* ── 弹窗通用 ── */ -.ai-avatar-modal-overlay { +.aa-modal-overlay { position: fixed; inset: 0; - background: rgba(0, 0, 0, 0.6); + background: rgba(0, 0, 0, 0.5); 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; +.aa-modal { + background: #fff; + border-radius: 14px; + width: 90%; + max-width: 720px; + max-height: 85vh; display: flex; flex-direction: column; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); + overflow: hidden; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15); } -.ai-avatar-modal-header { +.aa-modal__header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; - border-bottom: 1px solid var(--border-color, #2a2a2a); + border-bottom: 1px solid #f0f0f5; } -.ai-avatar-modal-header h3 { - margin: 0; +.aa-modal__title { font-size: 16px; - color: var(--text-primary, #fff); + font-weight: 600; + color: #1a1a2e; } -.ai-avatar-modal-body { +.aa-modal__close { + width: 32px; + height: 32px; + border: none; + background: none; + cursor: pointer; + font-size: 18px; + color: #8c8ca1; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; +} + +.aa-modal__close:hover { + background: #f0f0f5; +} + +.aa-modal__body { flex: 1; overflow-y: auto; padding: 20px; } -.ai-avatar-modal-footer { +.aa-modal__footer { display: flex; justify-content: flex-end; - gap: 8px; - padding: 16px 20px; - border-top: 1px solid var(--border-color, #2a2a2a); + gap: 10px; + padding: 14px 20px; + border-top: 1px solid #f0f0f5; } -/* ── 文案选择弹窗 ── */ -.ai-avatar-script-search { +/* ── 素材库弹窗 ── */ +.aa-asset-search { display: flex; - gap: 8px; + gap: 10px; margin-bottom: 16px; } -.ai-avatar-script-search input { +.aa-asset-search .aa-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); +.aa-asset-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 10px; } -.ai-avatar-script-item { +.aa-asset-card { + aspect-ratio: 9/16; + border-radius: 8px; + overflow: hidden; + border: 2px solid transparent; + cursor: pointer; + position: relative; + background: #f0f0f5; + transition: border-color 0.15s; +} + +.aa-asset-card:hover { + border-color: #c0c0d0; +} +.aa-asset-card.selected { + border-color: #7c3aed; +} + +.aa-asset-card img, +.aa-asset-card video { + width: 100%; + height: 100%; + object-fit: cover; +} + +.aa-asset-card__name { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 4px 6px; + background: linear-gradient(transparent, rgba(0, 0, 0, 0.7)); + color: #fff; + font-size: 10px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.aa-asset-card__check { + position: absolute; + top: 6px; + right: 6px; + width: 20px; + height: 20px; + border-radius: 50%; + background: #7c3aed; + color: #fff; 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 { + justify-content: center; font-size: 12px; - color: var(--text-secondary, #999); } /* ── B-roll 弹窗 ── */ -.ai-avatar-broll-layout { +.aa-broll-modal-body { display: flex; - gap: 16px; + gap: 20px; } -.ai-avatar-broll-timeline { +.aa-broll-left { flex: 1; - min-width: 0; } -.ai-avatar-broll-settings { +.aa-broll-right { width: 220px; flex-shrink: 0; } -.ai-avatar-broll-thumbnails { +.aa-broll-asset-grid { display: grid; - grid-template-columns: repeat(4, 1fr); + grid-template-columns: repeat(3, 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; +.aa-broll-asset-thumb { + aspect-ratio: 9/16; border-radius: 6px; - font-size: 13px; + overflow: hidden; + border: 2px solid transparent; cursor: pointer; - border: 1px solid var(--border-color, #2a2a2a); - background: transparent; - color: var(--text-primary, #fff); - transition: all 0.2s; + position: relative; + background: #f0f0f5; } -.aa-btn:hover { - background: var(--bg-tertiary, #222); +.aa-broll-asset-thumb.selected { + border-color: #7c3aed; } -.aa-btn-primary { - background: var(--primary-color, #3b82f6); +.aa-broll-asset-thumb.already-selected { + opacity: 0.4; + pointer-events: none; +} + +.aa-broll-asset-thumb.already-selected::after { + content: "已选择"; + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.5); color: #fff; - border-color: var(--primary-color, #3b82f6); + font-size: 11px; + display: flex; + align-items: center; + justify-content: center; } -.aa-btn-primary:hover { - opacity: 0.9; - background: var(--primary-color, #3b82f6); +.aa-broll-asset-thumb img { + width: 100%; + height: 100%; + object-fit: cover; } -.aa-btn-sm { - padding: 4px 10px; - font-size: 12px; -} - -/* ── 单选组 ── */ -.aa-radio-group { +.aa-broll-settings { display: flex; flex-direction: column; + gap: 12px; +} + +.aa-broll-mode-toggle { + display: flex; gap: 6px; } -.aa-radio-group label { +.aa-broll-mode-btn { + flex: 1; + padding: 6px 10px; + border: 1px solid #e0e0e8; + border-radius: 6px; + font-size: 11px; + text-align: center; + cursor: pointer; + background: #fff; + color: #6b6b80; +} + +.aa-broll-mode-btn.active { + background: #f5f0ff; + border-color: #7c3aed; + color: #7c3aed; +} + +.aa-pip-positions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; +} + +.aa-pip-pos-btn { + padding: 6px; + border: 1px solid #e0e0e8; + border-radius: 6px; + font-size: 11px; + text-align: center; + cursor: pointer; + background: #fff; + color: #6b6b80; +} + +.aa-pip-pos-btn.active { + background: #f5f0ff; + border-color: #7c3aed; + color: #7c3aed; +} + +/* ── 已配置 B-roll 列表 ── */ +.aa-broll-list { + margin-top: 16px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.aa-broll-list__title { + font-size: 12px; + font-weight: 600; + color: #6b6b80; +} + +.aa-broll-item { display: flex; align-items: center; gap: 8px; - font-size: 13px; - color: var(--text-primary, #fff); - cursor: pointer; + padding: 8px 10px; + background: #f8f8fc; + border-radius: 8px; + font-size: 12px; } -/* ── 分割线 ── */ -.aa-divider { +.aa-broll-item__thumb { + width: 32px; + height: 32px; + border-radius: 4px; + object-fit: cover; + background: #e8e8ec; +} + +.aa-broll-item__info { + flex: 1; +} + +.aa-broll-item__remove { + width: 24px; + height: 24px; border: none; - border-top: 1px solid var(--border-color, #2a2a2a); - margin: 16px 0; + background: none; + cursor: pointer; + color: #dc2626; + font-size: 14px; +} + +/* ── 文案列表(弹窗内) ── */ +.aa-script-list-header { + display: flex; + gap: 10px; + margin-bottom: 16px; +} + +.aa-script-list-header .aa-input { + flex: 1; +} + +.aa-script-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.aa-script-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + border: 1px solid #e8e8ec; + border-radius: 8px; + cursor: pointer; + transition: all 0.15s; +} + +.aa-script-item:hover { + border-color: #c0c0d0; + background: #fafafe; +} +.aa-script-item.selected { + border-color: #7c3aed; + background: #f5f0ff; +} + +.aa-script-item__icon { + font-size: 20px; + color: #8c8ca1; +} + +.aa-script-item__info { + flex: 1; +} + +.aa-script-item__title { + font-size: 13px; + font-weight: 500; + color: #1a1a2e; +} + +.aa-script-item__meta { + font-size: 11px; + color: #8c8ca1; + margin-top: 2px; +} + +.aa-empty { + text-align: center; + padding: 32px 16px; + color: #8c8ca1; + font-size: 13px; +} + +.aa-empty__icon { + font-size: 36px; + margin-bottom: 8px; } diff --git a/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx b/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx index 3a27ddaad..4544f5e04 100644 --- a/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx +++ b/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx @@ -1,100 +1,325 @@ /** - * AI数字人 — 主页面(5列水平面板布局)(#1798) + * AI数字人 — 主页面(v3) + * 5列水平面板布局 */ -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 React, { useState, useCallback, useEffect, useRef } from "react" import "./AiAvatar.css" +import { useAiAvatar } from "./hooks/useAiAvatar" +import { PanelVideoSelector } from "./components/PanelVideoSelector" +import PanelVoiceSelector from "./components/PanelVoiceSelector" +import PanelScriptAndLipsync from "./components/PanelScriptAndLipsync" +import PanelTitleConfig from "./components/PanelTitleConfig" +import PanelCoverAndGenerate from "./components/PanelCoverAndGenerate" +import { ModalAssetPicker } from "./components/ModalAssetPicker" +import ModalBRollEditor from "./components/ModalBRollEditor" +import { getScripts, createLipsyncJob, getLipsyncJob, submitRender } from "./api/aiAvatar" +import { getAssetsByKind } from "@/api/assets" + +/** 面板折叠状态 */ +type PanelKey = "video" | "voice" | "script" | "title" | "cover" const AiAvatarPage: React.FC = () => { - const state = useAiAvatarState() + const state = useAiAvatar() + const [collapsed, setCollapsed] = useState>({ + video: false, + voice: false, + script: false, + title: false, + cover: false, + }) - const handleSubmitGenerate = React.useCallback(() => { - // TODO: 调用 submitRender API + /* ── 素材库弹窗 ── */ + const [bRollAssets, setBRollAssets] = useState([]) + + /* ── 对口型轮询 ── */ + const lipsyncTimerRef = useRef | null>(null) + + const togglePanel = useCallback((key: PanelKey) => { + setCollapsed((prev) => ({ ...prev, [key]: !prev[key] })) + }, []) + + /* ── 对口型 ── */ + const handleGenerateLipsync = useCallback(async () => { + if (!state.selectedVideo || !state.selectedVoice || !state.scriptText) return + try { + const job = await createLipsyncJob({ + voice_id: state.selectedVoice.voice_id, + script_text: state.scriptText, + video_asset_id: state.selectedVideo.id, + }) + state.setLipsyncJob(job) + // 开始轮询 + if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current) + lipsyncTimerRef.current = setInterval(async () => { + try { + const updated = await getLipsyncJob(job.id) + state.setLipsyncJob(updated) + if (updated.status === "completed" || updated.status === "failed") { + if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current) + } + } catch { + // 忽略轮询错误 + } + }, 3000) + } catch (err) { + console.error("对口型任务创建失败:", err) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.selectedVideo, state.selectedVoice, state.scriptText]) + + // 清理轮询 + useEffect(() => { + return () => { + if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current) + } + }, []) + + /* ── 加载 B-roll 素材 ── */ + useEffect(() => { + getAssetsByKind("video", { limit: 50 }) + .then(setBRollAssets) + .catch(() => {}) + }, []) + + /* ── 生成视频 ── */ + const handleGenerate = useCallback(async () => { + if (!state.lipsyncJob || state.lipsyncJob.status !== "completed") return state.setIsGenerating(true) - }, [state]) + try { + await submitRender({ + lipsync_job_id: state.lipsyncJob.id, + script_id: state.script?.id, + b_roll_segments: state.bRollSegments as never, + title_config: state.titleConfig as unknown as Record, + cover_config: state.coverConfig as unknown as Record, + resolution: state.resolution, + }) + } catch (err) { + console.error("渲染任务提交失败:", err) + } finally { + state.setIsGenerating(false) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + state.lipsyncJob, + state.script, + state.bRollSegments, + state.titleConfig, + state.coverConfig, + state.resolution, + ]) + + /* ── 配置汇总 ── */ + const summary = { + videoName: state.selectedVideo?.name || null, + voiceName: state.selectedVoice?.name || null, + scriptLength: state.scriptText.length, + lipsyncStatus: state.lipsyncJob?.status || null, + brollCount: state.bRollSegments.length, + hasTitle: state.titleConfig.title.length > 0, + hasCover: state.coverConfig.enabled, + } return ( -
- {/* 面板1:出镜视频 */} - state.togglePanel("avatar-video")} - /> +
+
+

AI数字人

+
+
+ {/* 面板1:出镜视频 */} +
+
togglePanel("video")}> + 出镜视频 + +
+
+ state.setShowAssetPicker(true)} + onRemoveVideo={state.removeVideo} + /> +
+
- {/* 面板2:声音克隆 */} - state.togglePanel("voice-clone")} - /> + {/* 面板2:配音库 */} +
+
togglePanel("voice")}> + 配音库 + +
+
+ +
+
- {/* 面板3:文案 & 对口型 */} - state.setScriptModalOpen(true)} - onOpenBRollModal={() => state.setBrollModalOpen(true)} - collapsed={!!state.collapsedPanels["script-lipsync"]} - onToggleCollapse={() => state.togglePanel("script-lipsync")} - /> + {/* 面板3:文案 & 对口型 */} +
+
togglePanel("script")}> + 文案 & 对口型 + +
+
+ state.setShowScriptModal(true)} + lipsyncJob={state.lipsyncJob} + onGenerateLipsync={handleGenerateLipsync} + bRollSegments={state.bRollSegments} + onOpenBRollModal={() => state.setShowBRollModal(true)} + onRemoveBRoll={state.removeBRollSegment} + /> +
+
- {/* 面板4:标题配置 */} - state.togglePanel("title-config")} - /> + {/* 面板4:标题配置 */} +
+
togglePanel("title")}> + 标题配置 + +
+
+ +
+
- {/* 面板5:封面 & 生成 */} - state.togglePanel("cover-generate")} - /> + {/* 面板5:封面 & 生成 */} +
+
togglePanel("cover")}> + 封面 & 生成 + +
+
+ + state.setCoverConfig((prev) => ({ ...prev, ...partial })) + } + resolution={state.resolution} + onResolutionChange={state.setResolution} + isGenerating={state.isGenerating} + onGenerate={handleGenerate} + summary={summary} + /> +
+
+
- {/* 弹窗:文案选择 */} - state.setScriptModalOpen(false)} - onSelect={(script) => { - state.setSelectedScript(script) - state.setScriptContent(script.content) - state.setScriptModalOpen(false) - }} - /> + {/* 素材库弹窗 */} + {state.showAssetPicker && ( + state.setShowAssetPicker(false)} + onSelect={state.selectVideo} + selectedId={state.selectedVideo?.id} + /> + )} - {/* 弹窗:B-roll 插入 */} - state.setBrollModalOpen(false)} - onConfirm={(segment) => { - state.setBRollSegments((prev) => [...prev, segment]) - state.setBrollModalOpen(false) - }} - videoDuration={state.lipsyncJob?.output_duration ?? 0} - /> + {/* 文案选择弹窗 */} + {state.showScriptModal && ( + state.setShowScriptModal(false)} + onSelect={state.selectScript} + /> + )} + + {/* B-roll 编辑器弹窗 */} + {state.showBRollModal && ( + state.setShowBRollModal(false)} + existingSegments={state.bRollSegments} + availableAssets={bRollAssets} + onConfirm={state.addBRollSegment} + onRemove={state.removeBRollSegment} + /> + )} +
+ ) +} + +/** 文案选择弹窗(内联实现,轻量版) */ +const ScriptSelectModalLazy: React.FC<{ + open: boolean + onClose: () => void + onSelect: (script: import("./types").Script) => void +}> = ({ open, onClose, onSelect }) => { + const [scripts, setScripts] = useState([]) + const [search, setSearch] = useState("") + const [loading, setLoading] = useState(false) + + useEffect(() => { + if (!open) return + setLoading(true) + getScripts() + .then(setScripts) + .catch(() => {}) + .finally(() => setLoading(false)) + }, [open]) + + const filtered = scripts.filter( + (s) => !search || s.title.includes(search) || s.content.includes(search), + ) + + return ( +
+
e.stopPropagation()}> +
+ 选择文案 + +
+
+
+ setSearch(e.target.value)} + /> +
+ {loading ? ( +
加载中...
+ ) : filtered.length === 0 ? ( +
+
📝
+ 暂无文案,请手动输入或新建 +
+ ) : ( +
+ {filtered.map((s) => ( +
onSelect(s)}> + 📄 +
+
{s.title}
+
+ {s.char_count}字 · {new Date(s.created_at).toLocaleDateString()} +
+
+
+ ))} +
+ )} +
+
+ +
+
) } diff --git a/apps/web/src/pages/ai-avatar/api/aiAvatar.ts b/apps/web/src/pages/ai-avatar/api/aiAvatar.ts index a190a8b47..24acab0ed 100644 --- a/apps/web/src/pages/ai-avatar/api/aiAvatar.ts +++ b/apps/web/src/pages/ai-avatar/api/aiAvatar.ts @@ -1,75 +1,63 @@ /** - * AI数字人 API 调用封装 (#1798) + * AI数字人 — API 封装 */ import apiClient from "@/api/client" -import type { - Script, - LipsyncJob, - AiAvatarRenderRequest, - AiAvatarRenderJob, -} from "../types/aiAvatar" +import type { Script, LipsyncJob, RenderJob, BRollSegment } from "../types" /* ── 文案库 ── */ -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 const getScripts = async (): Promise => { + const response = await apiClient.get("/scripts") + return response.data } -export async function getScript(id: string) { - const { data } = await apiClient.get