diff --git a/apps/web/src/pages/ai-avatar/AiAvatar.css b/apps/web/src/pages/ai-avatar/AiAvatar.css index 209b67fd7..ba6bd51ad 100644 --- a/apps/web/src/pages/ai-avatar/AiAvatar.css +++ b/apps/web/src/pages/ai-avatar/AiAvatar.css @@ -1,71 +1,86 @@ -/** - * AI数字人 — 页面样式(v3) - * 命名空间: .aa- (ai-avatar 缩写) - */ +/* AI 数字人页面样式 */ -/* ── 页面容器 ── */ .aa-page { - display: flex; - flex-direction: column; - height: 100%; - min-width: 1280px; - background: #f5f6fa; - overflow-x: auto; + min-height: 100vh; + background: #f5f5f7; + padding: 20px; } .aa-page-header { - padding: 16px 24px; - background: #fff; - border-bottom: 1px solid #e8e8ec; - flex-shrink: 0; + margin-bottom: 20px; } .aa-page-header h1 { - margin: 0; - font-size: 20px; + font-size: 24px; font-weight: 600; color: #1a1a2e; + margin: 0; } -.aa-page-body { - flex: 1; +/* 步骤指示器 */ +.aa-steps { display: flex; - gap: 12px; - padding: 16px; - overflow-x: auto; - min-height: 0; + align-items: center; + margin-bottom: 24px; + padding: 16px 20px; + background: #fff; + border-radius: 8px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06); } -/* ── 面板通用 ── */ -.aa-panel { - background: #fff; - border-radius: 12px; - border: 1px solid #e8e8ec; +.aa-step { + display: flex; + align-items: center; + gap: 8px; +} + +.aa-step__number { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 50%; + background: #e8e8ed; + color: #8c8ca1; + font-size: 14px; + font-weight: 600; +} + +.aa-step.active .aa-step__number { + background: #6c5ce7; + color: #fff; +} + +.aa-step__label { + font-size: 14px; + color: #8c8ca1; +} + +.aa-step.active .aa-step__label { + color: #1a1a2e; + font-weight: 500; +} + +.aa-step__connector { + flex: 1; + height: 2px; + background: #e8e8ed; + margin: 0 16px; +} + +/* 面板布局 */ +.aa-page-body { display: flex; flex-direction: column; - overflow: hidden; - transition: width 0.2s ease; + gap: 16px; } -.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; +.aa-panel { + background: #fff; + border-radius: 8px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06); + overflow: hidden; } .aa-panel.collapsed .aa-panel__body { @@ -76,34 +91,25 @@ display: flex; align-items: center; justify-content: space-between; - padding: 12px 16px; - border-bottom: 1px solid #f0f0f5; + padding: 14px 20px; cursor: pointer; user-select: none; - flex-shrink: 0; + border-bottom: 1px solid #f0f0f0; } .aa-panel__header:hover { - background: #fafafe; + background: #fafafa; } .aa-panel__title { - font-size: 14px; + font-size: 15px; font-weight: 600; color: #1a1a2e; } .aa-panel__toggle { - width: 24px; - height: 24px; - border: none; - background: none; - cursor: pointer; + font-size: 12px; color: #8c8ca1; - display: flex; - align-items: center; - justify-content: center; - border-radius: 4px; transition: transform 0.2s; } @@ -112,385 +118,358 @@ } .aa-panel__body { - flex: 1; - overflow-y: auto; - padding: 16px; + padding: 20px; } -/* ── 通用表单元素 ── */ -.aa-label { - display: block; - font-size: 12px; - font-weight: 500; - color: #6b6b80; - margin-bottom: 6px; -} - -.aa-input, -.aa-select { - width: 100%; - padding: 8px 12px; - border: 1px solid #e0e0e8; - border-radius: 8px; - font-size: 13px; - color: #1a1a2e; - background: #fff; - outline: none; - transition: border-color 0.15s; -} - -.aa-input:focus, -.aa-select:focus { - border-color: #7c3aed; - box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.1); -} - -.aa-textarea { - width: 100%; - 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-step-actions { + display: flex; + justify-content: center; + padding: 20px 0; + gap: 12px; } +/* 按钮 */ .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; + padding: 10px 24px; + border-radius: 6px; + font-size: 14px; 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; + border: none; + transition: all 0.2s; } .aa-btn--primary { - background: #7c3aed; + background: #6c5ce7; color: #fff; - border-color: #7c3aed; } + .aa-btn--primary:hover { - background: #6d28d9; + background: #5b4cdb; +} + +.aa-btn--danger { + background: #ff4d4f; + color: #fff; +} + +.aa-btn--danger:hover { + background: #ff7875; +} + +.aa-btn:not(.aa-btn--primary):not(.aa-btn--danger) { + background: #f0f0f0; + color: #1a1a2e; +} + +.aa-btn:not(.aa-btn--primary):not(.aa-btn--danger):hover { + background: #e8e8ed; +} + +/* 弹窗 */ +.aa-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.45); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.aa-modal { + background: #fff; + border-radius: 12px; + width: 480px; + max-width: 90vw; + max-height: 80vh; + overflow: hidden; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); +} + +.aa-modal__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid #f0f0f0; +} + +.aa-modal__title { + font-size: 16px; + font-weight: 600; + color: #1a1a2e; +} + +.aa-modal__close { + background: none; + border: none; + font-size: 18px; + cursor: pointer; + color: #8c8ca1; + padding: 4px; +} + +.aa-modal__close:hover { + color: #1a1a2e; +} + +.aa-modal__body { + padding: 20px; +} + +.aa-modal__footer { + display: flex; + justify-content: center; + padding: 16px 20px; + border-top: 1px solid #f0f0f0; +} + +/* 对口型 spinner */ +.aa-lipsync-spinner { + width: 48px; + height: 48px; + border: 4px solid #f0f0f0; + border-top-color: #6c5ce7; + border-radius: 50%; + animation: aa-spin 0.8s linear infinite; +} + +@keyframes aa-spin { + to { + transform: rotate(360deg); + } +} + +/* 文案列表 */ +.aa-script-list-header { + margin-bottom: 16px; +} + +.aa-input { + width: 100%; + padding: 10px 12px; + border: 1px solid #e8e8ed; + border-radius: 6px; + font-size: 14px; + outline: none; +} + +.aa-input:focus { + border-color: #6c5ce7; +} + +.aa-empty { + text-align: center; + padding: 40px 20px; + color: #8c8ca1; +} + +.aa-empty__icon { + font-size: 32px; + margin-bottom: 8px; +} + +.aa-script-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 300px; + overflow-y: auto; +} + +.aa-script-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + border-radius: 6px; + cursor: pointer; + transition: background 0.2s; +} + +.aa-script-item:hover { + background: #f5f5f7; +} + +.aa-script-item__icon { + font-size: 20px; +} + +.aa-script-item__info { + flex: 1; + min-width: 0; +} + +.aa-script-item__title { + font-size: 14px; + font-weight: 500; + color: #1a1a2e; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.aa-script-item__meta { + font-size: 12px; + color: #8c8ca1; + margin-top: 2px; +} + +/* ── PanelScript 文案面板 ── */ +.aa-script-panel { + display: flex; + flex-direction: column; + gap: 8px; +} + +.aa-script-tabs { + display: flex; + gap: 8px; + margin-bottom: 4px; +} + +.aa-script-tab { + padding: 8px 16px; + border-radius: 6px; + border: 1px solid #e8e8ed; + background: #fff; + font-size: 13px; + cursor: pointer; + transition: all 0.2s; +} + +.aa-script-tab.active { + background: #6c5ce7; + color: #fff; + border-color: #6c5ce7; +} + +.aa-textarea { + width: 100%; + min-height: 120px; + padding: 12px; + border: 1px solid #e8e8ed; + border-radius: 6px; + font-size: 14px; + line-height: 1.6; + resize: vertical; + outline: none; + font-family: inherit; +} + +.aa-textarea:focus { + border-color: #6c5ce7; +} + +.aa-char-count { + text-align: right; + font-size: 12px; + color: #8c8ca1; } .aa-btn--ghost { background: transparent; - border-color: #d0d0dd; - color: #6b6b80; + color: #6c5ce7; + border: 1px dashed #6c5ce7; } -.aa-btn--danger { - background: #fee2e2; - border-color: #fca5a5; - color: #dc2626; -} -.aa-btn--danger:hover { - background: #fecaca; +.aa-btn--ghost:hover { + background: #f0edff; } .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; - display: flex; - align-items: center; - justify-content: center; - 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 { +/* ── PanelLipsyncPreview 对口型预览面板 ── */ +.aa-lipsync-preview-panel { display: flex; flex-direction: column; - gap: 8px; - max-height: 240px; - overflow-y: auto; + gap: 20px; } -.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; +.aa-lipsync-section { 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; -} - -.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-size: 14px; font-weight: 600; color: #1a1a2e; - margin-bottom: 12px; +} + +.aa-broll-badge { + font-size: 12px; + color: #6c5ce7; + background: #f0edff; + padding: 2px 8px; + border-radius: 10px; +} + +.aa-lipsync-actions { + display: flex; + flex-direction: column; + gap: 8px; +} + +.aa-broll-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.aa-broll-item { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + background: #f9f9fb; + border-radius: 6px; +} + +.aa-broll-item__thumb { + width: 64px; + height: 36px; + object-fit: cover; + border-radius: 4px; + background: #e8e8ed; +} + +.aa-broll-item__info { + flex: 1; + min-width: 0; +} + +.aa-broll-item__remove { + background: none; + border: none; + color: #8c8ca1; + cursor: pointer; + font-size: 14px; + padding: 4px; +} + +.aa-broll-item__remove:hover { + color: #ff4d4f; } .aa-lipsync-preview { width: 100%; - aspect-ratio: 9/16; - background: #0f0f1a; - border-radius: 10px; + aspect-ratio: 9 / 16; + max-height: 400px; + background: #1a1a2e; + border-radius: 8px; overflow: hidden; - margin-bottom: 12px; display: flex; align-items: center; justify-content: center; @@ -502,677 +481,22 @@ 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; -} - -.aa-cover-preview__placeholder { - font-size: 12px; +.aa-video-preview__placeholder { + text-align: center; color: #8c8ca1; + font-size: 14px; } -.aa-cover-actions { - display: flex; - gap: 8px; - margin-bottom: 16px; -} - -.aa-generate-section { - margin-top: 16px; - padding-top: 16px; - border-top: 1px solid #f0f0f5; -} - -.aa-config-summary { - margin: 12px 0; - padding: 12px; - background: #f8f8fc; - border-radius: 8px; - font-size: 12px; -} - -.aa-config-summary__row { - display: flex; - justify-content: space-between; - padding: 4px 0; - color: #6b6b80; -} - -.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; + background: #333; border-radius: 3px; overflow: hidden; - margin: 8px 0; } .aa-progress__bar { height: 100%; - background: linear-gradient(90deg, #7c3aed, #a855f7); - border-radius: 3px; + background: #6c5ce7; transition: width 0.3s; } - -/* ── 弹窗通用 ── */ -.aa-modal-overlay { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.5); - display: flex; - align-items: center; - justify-content: center; - z-index: 1000; -} - -.aa-modal { - background: #fff; - border-radius: 14px; - width: 90%; - max-width: 720px; - max-height: 85vh; - display: flex; - flex-direction: column; - overflow: hidden; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15); -} - -.aa-modal__header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 16px 20px; - border-bottom: 1px solid #f0f0f5; -} - -.aa-modal__title { - font-size: 16px; - font-weight: 600; - color: #1a1a2e; -} - -.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; -} - -.aa-modal__footer { - display: flex; - justify-content: flex-end; - gap: 10px; - padding: 14px 20px; - border-top: 1px solid #f0f0f5; -} - -/* ── 素材库弹窗 ── */ -.aa-asset-search { - display: flex; - gap: 10px; - margin-bottom: 16px; -} - -.aa-asset-search .aa-input { - flex: 1; -} - -.aa-asset-grid { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 10px; -} - -.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: center; - font-size: 12px; -} - -/* ── B-roll 弹窗 ── */ -.aa-broll-modal-body { - display: flex; - gap: 20px; -} - -.aa-broll-left { - flex: 1; -} - -.aa-broll-right { - width: 220px; - flex-shrink: 0; -} - -.aa-broll-asset-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 8px; - margin-bottom: 16px; -} - -.aa-broll-asset-thumb { - aspect-ratio: 9/16; - border-radius: 6px; - overflow: hidden; - border: 2px solid transparent; - cursor: pointer; - position: relative; - background: #f0f0f5; -} - -.aa-broll-asset-thumb.selected { - border-color: #7c3aed; -} - -.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; - font-size: 11px; - display: flex; - align-items: center; - justify-content: center; -} - -.aa-broll-asset-thumb img { - width: 100%; - height: 100%; - object-fit: cover; -} - -.aa-broll-settings { - display: flex; - flex-direction: column; - gap: 12px; -} - -.aa-broll-mode-toggle { - display: flex; - gap: 6px; -} - -.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; - padding: 8px 10px; - background: #f8f8fc; - border-radius: 8px; - font-size: 12px; -} - -.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; - 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; -} - -/* ============================================================ - #1809 ④⑤⑥ B-roll 弹窗:选库行 + 文案句子列表 + 自动估算提示 - ============================================================ */ - -/* 宽弹窗:左右两栏 + 句子列表需要更大空间 */ -.aa-modal--wide { - max-width: 960px; - width: 94%; -} - -.aa-broll-modal-body .aa-broll-right { - width: 300px; -} - -/* 左侧素材库选择行 */ -.aa-broll-lib-row { - margin-bottom: 10px; -} - -.aa-broll-lib-row .aa-select { - width: 100%; - height: 36px; - border: 1px solid var(--border-color, #e2e2ea); - border-radius: 8px; - padding: 0 10px; - font-size: 13px; - background: #fff; - color: #1a1a2e; - outline: none; -} - -/* 无缩略图时的素材占位 */ -.aa-broll-asset-placeholder { - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - font-size: 18px; -} - -/* 文案句子列表 */ -.aa-sentence-list { - max-height: 240px; - overflow-y: auto; - display: flex; - flex-direction: column; - gap: 6px; - padding-right: 4px; -} - -.aa-sentence-item { - display: flex; - align-items: flex-start; - gap: 8px; - width: 100%; - text-align: left; - border: 1px solid var(--border-color, #e2e2ea); - border-radius: 8px; - background: #fff; - padding: 8px 10px; - cursor: pointer; - transition: all 0.15s; -} - -.aa-sentence-item:hover { - border-color: #c0c0d0; - background: #f8f8fc; -} - -.aa-sentence-item.active { - border-color: #7c3aed; - background: #f3edff; -} - -.aa-sentence-item__idx { - flex-shrink: 0; - width: 20px; - height: 20px; - border-radius: 50%; - background: #f0f0f5; - color: #6b6b80; - font-size: 11px; - font-weight: 600; - display: flex; - align-items: center; - justify-content: center; -} - -.aa-sentence-item.active .aa-sentence-item__idx { - background: #7c3aed; - color: #fff; -} - -.aa-sentence-item__text { - flex: 1; - font-size: 12px; - line-height: 1.5; - color: #1a1a2e; - word-break: break-all; -} - -.aa-sentence-item__time { - flex-shrink: 0; - font-size: 10px; - color: #8c8ca1; - margin-top: 2px; -} - -.aa-sentence-empty { - font-size: 12px; - color: #8c8ca1; - background: #f8f8fc; - border-radius: 8px; - padding: 10px; -} - -/* 选择/估算提示 */ -.aa-broll-hint { - font-size: 12px; - color: #059669; - background: #f8f8fc; - border-radius: 6px; - padding: 8px 10px; - display: flex; - flex-direction: column; - gap: 4px; -} - -/* ─ 对口型生成弹窗 Spinner ── */ -.aa-lipsync-spinner { - width: 48px; - height: 48px; - border: 4px solid #f0f0f5; - border-top-color: #6366f1; - border-radius: 50%; - animation: aa-spin 0.8s linear infinite; -} - -@keyframes aa-spin { - to { - transform: rotate(360deg); - } -} - -.aa-btn--danger { - background: #ff4d4f; - color: #fff; - border: none; -} - -.aa-btn--danger:hover { - background: #ff7875; -} diff --git a/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx b/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx index 14a43197a..06bf75464 100644 --- a/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx +++ b/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx @@ -1,6 +1,7 @@ /** - * AI数字人 — 主页面(v3) - * 5列水平面板布局 + * AI数字人 — 主页面(两步骤版) + * 步骤1:出镜视频 / 配音库 / 文案 + * 步骤2:插入画面 / 对口型预览 / 标题配置 / 封面&生成 */ import React, { useState, useCallback, useEffect, useRef } from "react" import { message } from "antd" @@ -8,7 +9,8 @@ import "./AiAvatar.css" import { useAiAvatar } from "./hooks/useAiAvatar" import { PanelVideoSelector } from "./components/PanelVideoSelector" import PanelVoiceSelector from "./components/PanelVoiceSelector" -import PanelScriptAndLipsync from "./components/PanelScriptAndLipsync" +import PanelScript from "./components/PanelScript" +import PanelLipsyncPreview from "./components/PanelLipsyncPreview" import PanelTitleConfig from "./components/PanelTitleConfig" import PanelCoverAndGenerate from "./components/PanelCoverAndGenerate" import { ModalAssetPicker } from "./components/ModalAssetPicker" @@ -28,25 +30,33 @@ import { } from "./utils/contract" /** 面板折叠状态 */ -type PanelKey = "video" | "voice" | "script" | "title" | "cover" +type PanelKey = + | "video" + | "voice" + | "script" + | "lipsync" + | "title" + | "cover" const AiAvatarPage: React.FC = () => { const state = useAiAvatar() + const [currentStep, setCurrentStep] = useState(1) const [collapsed, setCollapsed] = useState>({ video: false, voice: false, script: false, + lipsync: false, title: false, cover: false, }) /* ── 对口型生成弹窗 ── */ const [showLipsyncModal, setShowLipsyncModal] = useState(false) - const [lipsyncStatus, setLipsyncStatus] = useState<"generating" | "completed" | "failed">( - "generating", - ) + const [lipsyncStatus, setLipsyncStatus] = useState< + "generating" | "completed" | "failed" + >("generating") const [lipsyncErrorMessage, setLipsyncErrorMessage] = useState("") - /* ── 智能封面加载态 ── */ + /* ─ 智能封面加载态 ── */ const [smartCoverLoading, setSmartCoverLoading] = useState(false) /* ── 对口型轮询 ── */ @@ -56,9 +66,25 @@ const AiAvatarPage: React.FC = () => { setCollapsed((prev) => ({ ...prev, [key]: !prev[key] })) }, []) - /* ── 对口型 ── */ + /* ── 步骤切换 ── */ + const canGoToStep2 = useCallback(() => { + return state.selectedVideo && state.selectedVoice && state.scriptText.trim() + }, [state.selectedVideo, state.selectedVoice, state.scriptText]) + + const handleNextStep = useCallback(() => { + if (!canGoToStep2()) { + message.warning("请先完成出镜视频、配音和文案的选择") + return + } + setCurrentStep(2) + }, [canGoToStep2]) + + const handlePrevStep = useCallback(() => { + setCurrentStep(1) + }, []) + + /* ── 对口型 ─ */ const handleGenerateLipsync = useCallback(async () => { - // ② 缺项明确提示(#1809):不再静默 return const video = state.selectedVideo const voice = state.selectedVoice const text = state.scriptText.trim() @@ -71,12 +97,10 @@ const AiAvatarPage: React.FC = () => { return } try { - // 显示生成弹窗 setShowLipsyncModal(true) setLipsyncStatus("generating") setLipsyncErrorMessage("") - // ① 先按素材 id 拿 file_url(#1809 补充:对齐后端新参数 video_url) console.log("[对口型] 开始生成:", { videoId: video.id, voiceId: voice.voice_id, @@ -95,19 +119,20 @@ const AiAvatarPage: React.FC = () => { message.error("获取出镜视频播放地址失败,请重新选择素材") return } - // ② 模式A TTS直生:video_url + voice_id + script_text,语速/情绪英文枚举透传(#1822) const payload = { voice_id: voice.voice_id, script_text: state.scriptText, video_url: videoUrl, - speed: state.speed, // 语速 0.5~2.0 - emotion: normalizeEmotion(state.emotion), // natural/excited/calm/friendly + speed: state.speed, + emotion: normalizeEmotion(state.emotion), } console.log("[对口型] createLipsyncJob 请求:", payload) const job = await createLipsyncJob(payload) - console.log("[对口型] createLipsyncJob 响应:", { id: job.id, status: job.status }) + console.log("[对口型] createLipsyncJob 响应:", { + id: job.id, + status: job.status, + }) state.setLipsyncJob(job) - // 开始轮询 if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current) lipsyncTimerRef.current = setInterval(async () => { try { @@ -128,7 +153,9 @@ const AiAvatarPage: React.FC = () => { } else if (updated.status === "failed") { if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current) setLipsyncStatus("failed") - setLipsyncErrorMessage(updated.error_message || "对口型生成失败") + setLipsyncErrorMessage( + updated.error_message || "对口型生成失败", + ) } } catch (err) { console.error("[对口型] 轮询错误:", err) @@ -141,12 +168,19 @@ const AiAvatarPage: React.FC = () => { message: err instanceof Error ? err.message : String(err), }) setShowLipsyncModal(false) - message.error(err instanceof Error ? err.message : "对口型任务提交失败,请重试") + message.error( + err instanceof Error ? err.message : "对口型任务提交失败,请重试", + ) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [state.selectedVideo, state.selectedVoice, state.scriptText, state.speed, state.emotion]) + }, [ + state.selectedVideo, + state.selectedVoice, + state.scriptText, + state.speed, + state.emotion, + ]) - // 取消对口型生成 const handleCancelLipsync = useCallback(() => { if (lipsyncTimerRef.current) { clearInterval(lipsyncTimerRef.current) @@ -157,7 +191,6 @@ const AiAvatarPage: React.FC = () => { setLipsyncErrorMessage("") }, []) - // 清理轮询 useEffect(() => { return () => { if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current) @@ -166,7 +199,6 @@ const AiAvatarPage: React.FC = () => { /* ── 生成视频 ── */ const handleGenerate = useCallback(async () => { - // ② 前置条件提示(#1809) if (!state.lipsyncJob || state.lipsyncJob.status !== "completed") { message.warning("请先生成对口型视频,待对口型完成后再提交渲染") return @@ -177,16 +209,19 @@ const AiAvatarPage: React.FC = () => { lipsync_job_id: state.lipsyncJob.id, script_id: state.script?.id, b_roll_segments: state.bRollSegments as never, - // 字段映射:build_title_drawtext_filter 真实口径 text/font_size/font_color/position/... title_config: buildTitleConfigPayload(state.titleConfig), - // cover_config:智能封面 cover_url + 截帧 timestamp - cover_config: buildCoverConfigPayload(state.coverConfig, state.coverConfig.smart_cover_url), + cover_config: buildCoverConfigPayload( + state.coverConfig, + state.coverConfig.smart_cover_url, + ), resolution: state.resolution, }) message.success("渲染任务已提交,可在视频管理中查看进度") } catch (err) { console.error("渲染任务提交失败:", err) - message.error(err instanceof Error ? err.message : "渲染任务提交失败,请重试") + message.error( + err instanceof Error ? err.message : "渲染任务提交失败,请重试", + ) } finally { state.setIsGenerating(false) } @@ -200,9 +235,8 @@ const AiAvatarPage: React.FC = () => { state.resolution, ]) - /* ── 智能封面:调后端 MediaKit 选帧接口(#1822) ── */ + /* ── 智能封面 ── */ const handleSmartCover = useCallback(async () => { - // 基于对口型成片抽帧,必须先完成对口型 const videoUrl = state.lipsyncJob?.output_video_url if (state.lipsyncJob?.status !== "completed" || !videoUrl) { message.warning("请先生成对口型视频,完成后再智能获取封面") @@ -224,7 +258,9 @@ const AiAvatarPage: React.FC = () => { } } catch (err) { console.error("智能封面生成失败:", err) - message.error(err instanceof Error ? err.message : "智能封面生成失败,请重试") + message.error( + err instanceof Error ? err.message : "智能封面生成失败,请重试", + ) } finally { setSmartCoverLoading(false) } @@ -245,101 +281,196 @@ const AiAvatarPage: React.FC = () => { return (
-

AI数字人

+

AI 数字人

+ + {/* 步骤指示器 */} +
+
= 1 ? "active" : ""}`}> + 1 + 视频 / 配音 / 文案 +
+
+
= 2 ? "active" : ""}`}> + 2 + + 对口型 / 标题 / 封面 / 生成 + +
+
+
- {/* 面板1:出镜视频 */} -
-
togglePanel("video")}> - 出镜视频 - -
-
- state.setShowAssetPicker(true)} - onRemoveVideo={state.removeVideo} - titleConfig={state.titleConfig} - /> -
-
+ {/* ════ 步骤 1:出镜视频 / 配音库 / 文案 ═══ */} + {currentStep === 1 && ( + <> + {/* 面板 1:出镜视频 */} +
+
togglePanel("video")} + > + 出镜视频 + +
+
+ state.setShowAssetPicker(true)} + onRemoveVideo={state.removeVideo} + titleConfig={state.titleConfig} + /> +
+
- {/* 面板2:配音库 */} -
-
togglePanel("voice")}> - 配音库 - -
-
- -
-
+ {/* 面板 2:配音库 */} +
+
togglePanel("voice")} + > + 配音库 + +
+
+ +
+
- {/* 面板3:文案 & 对口型 */} -
-
togglePanel("script")}> - 文案 & 对口型 - -
-
- state.setShowScriptModal(true)} - lipsyncJob={state.lipsyncJob} - onGenerateLipsync={handleGenerateLipsync} - bRollSegments={state.bRollSegments} - onOpenBRollModal={() => state.setShowBRollModal(true)} - onRemoveBRoll={state.removeBRollSegment} - /> -
-
+ {/* 面板 3:文案 */} +
+
togglePanel("script")} + > + 文案 + +
+
+ state.setShowScriptModal(true)} + /> +
+
- {/* 面板4:标题配置 */} -
-
togglePanel("title")}> - 标题配置 - -
-
- -
-
+ {/* 步骤 1 底部按钮 */} +
+ +
+ + )} - {/* 面板5:封面 & 生成 */} -
-
togglePanel("cover")}> - 封面 & 生成 - -
-
- - state.setCoverConfig((prev) => ({ ...prev, ...partial })) - } - onSmartCover={handleSmartCover} - smartCoverLoading={smartCoverLoading} - canSmartCover={state.lipsyncJob?.status === "completed"} - resolution={state.resolution} - onResolutionChange={state.setResolution} - isGenerating={state.isGenerating} - onGenerate={handleGenerate} - summary={summary} - /> -
-
+ {/* ════ 步骤 2:插入画面 / 对口型预览 / 标题配置 / 封面&生成 ═══ */} + {currentStep === 2 && ( + <> + {/* 面板 4:插入画面 & 对口型预览 */} +
+
togglePanel("lipsync")} + > + + 对口型预览 + + +
+
+ state.setShowBRollModal(true)} + onRemoveBRoll={state.removeBRollSegment} + /> +
+
+ + {/* 面板 5:标题配置 */} +
+
togglePanel("title")} + > + 标题配置 + +
+
+ +
+
+ + {/* 面板 6:封面 & 生成 */} +
+
togglePanel("cover")} + > + 封面 & 生成 + +
+
+ + state.setCoverConfig((prev) => ({ + ...prev, + ...partial, + })) + } + onSmartCover={handleSmartCover} + smartCoverLoading={smartCoverLoading} + canSmartCover={ + state.lipsyncJob?.status === "completed" + } + resolution={state.resolution} + onResolutionChange={state.setResolution} + isGenerating={state.isGenerating} + onGenerate={handleGenerate} + summary={summary} + /> +
+
+ + {/* 步骤 2 底部按钮 */} +
+ +
+ + )}
{/* 素材库弹窗 */} @@ -380,7 +511,10 @@ const AiAvatarPage: React.FC = () => {
e.stopPropagation()}>
对口型生成 -
@@ -396,10 +530,22 @@ const AiAvatarPage: React.FC = () => { {lipsyncStatus === "generating" && ( <>
-
+
对口型视频生成中…
-
+
请勿关闭页面,完成后将自动提示
@@ -407,7 +553,13 @@ const AiAvatarPage: React.FC = () => { {lipsyncStatus === "completed" && ( <>
-
+
对口型视频生成完成
@@ -415,11 +567,23 @@ const AiAvatarPage: React.FC = () => { {lipsyncStatus === "failed" && ( <>
-
+
对口型生成失败
{lipsyncErrorMessage && ( -
+
{lipsyncErrorMessage}
)} @@ -428,7 +592,10 @@ const AiAvatarPage: React.FC = () => {
{lipsyncStatus === "generating" && ( - )} @@ -496,12 +663,17 @@ const ScriptSelectModalLazy: React.FC<{ ) : (
{filtered.map((s) => ( -
onSelect(s)}> +
onSelect(s)} + > 📄
{s.title}
- {s.char_count}字 · {new Date(s.created_at).toLocaleDateString()} + {s.char_count}字 ·{" "} + {new Date(s.created_at).toLocaleDateString()}
diff --git a/apps/web/src/pages/ai-avatar/components/PanelScriptAndLipsync.tsx b/apps/web/src/pages/ai-avatar/components/PanelLipsyncPreview.tsx old mode 100644 new mode 100755 similarity index 66% rename from apps/web/src/pages/ai-avatar/components/PanelScriptAndLipsync.tsx rename to apps/web/src/pages/ai-avatar/components/PanelLipsyncPreview.tsx index 94c4a6b6d..1e32c5186 --- a/apps/web/src/pages/ai-avatar/components/PanelScriptAndLipsync.tsx +++ b/apps/web/src/pages/ai-avatar/components/PanelLipsyncPreview.tsx @@ -1,14 +1,10 @@ /** - * AI数字人 — 文案 & 对口型面板(面板4) - * 上半区:文案(文案库选择 / 手动输入);下半区:对口型视频预览(9:16)+ B-roll 画面 + * AI数字人 — 对口型预览面板(步骤2用) + * B-roll 画面插入 + 对口型视频预览 + 生成/重新生成按钮 */ -import { useState } from "react" import type { LipsyncJob, BRollSegment } from "../types" -interface PanelScriptAndLipsyncProps { - scriptText: string - onScriptTextChange: (text: string) => void - onOpenScriptModal: () => void +interface PanelLipsyncPreviewProps { lipsyncJob: LipsyncJob | null onGenerateLipsync: () => void bRollSegments: BRollSegment[] @@ -16,8 +12,6 @@ interface PanelScriptAndLipsyncProps { onRemoveBRoll: (id: string) => void } -type ScriptTab = "library" | "manual" - const BROLL_MODE_LABEL: Record = { fullscreen: "全屏", pip: "画中画", @@ -29,20 +23,15 @@ function formatTime(seconds: number): string { return `${m}:${s.toString().padStart(2, "0")}` } -export function PanelScriptAndLipsync({ - scriptText, - onScriptTextChange, - onOpenScriptModal, +export function PanelLipsyncPreview({ lipsyncJob, onGenerateLipsync, bRollSegments, onOpenBRollModal, onRemoveBRoll, -}: PanelScriptAndLipsyncProps) { - const [scriptTab, setScriptTab] = useState("library") - - /* 对口型状态判断 */ - const isGenerating = lipsyncJob?.status === "pending" || lipsyncJob?.status === "processing" +}: PanelLipsyncPreviewProps) { + const isGenerating = + lipsyncJob?.status === "pending" || lipsyncJob?.status === "processing" const isDone = lipsyncJob?.status === "completed" const isFailed = lipsyncJob?.status === "failed" @@ -54,53 +43,15 @@ export function PanelScriptAndLipsync({ : "对口型生成中…" return ( -
- {/* ── 上半区:文案 ── */} -
- - -
- - {scriptTab === "library" && ( - - )} - -