diff --git a/apps/web/src/api/viral-video/types.ts b/apps/web/src/api/viral-video/types.ts index 01d7c894e..a04adddd1 100644 --- a/apps/web/src/api/viral-video/types.ts +++ b/apps/web/src/api/viral-video/types.ts @@ -9,7 +9,7 @@ export type StyleStrength = "light" | "medium" | "strict" export const STYLE_STRENGTHS: { value: StyleStrength; label: string }[] = [ { value: "light", label: "轻度借鉴" }, { value: "medium", label: "中度参考" }, - { value: "strict", label: "深度模仿" }, + { value: "strict", label: "像素级复刻" }, ] export type ViralVideoStatus = @@ -43,11 +43,33 @@ export function isAnalysisStage(stage: ViralVideoStage | undefined): boolean { return !!stage && ANALYSIS_STAGES.has(stage) } +/** 单张图片 VLM 识别出的商品信息 */ +export interface ImageProductAnalysis { + /** 商品名称/标题 */ + name?: string + /** 净含量/规格 */ + spec?: string + /** 视觉特征描述(材质/颜色/文字标签等),可能是拼接字符串或数组 */ + features?: string[] | string + /** 卖点 */ + selling_points?: string[] + /** 适用场景 */ + scene?: string + /** 图片索引 */ + image_index?: number +} + +/** 图片 VLM 分析结果(后端字段 image_analysis) */ +export interface ImageAnalysisResult { + products?: ImageProductAnalysis[] +} + export interface StyleTemplate { id: string name: string description?: string - preview_url?: string + thumbnail_url?: string + style_config?: Record tags?: string[] } @@ -69,20 +91,25 @@ export interface ViralVideoJob { reference_video_url?: string style_strength?: StyleStrength style_template_id?: string - style_guide?: string + style_guide?: string | Record user_copy_text?: string final_copy_text?: string fusion_level?: FusionLevel voice_id?: string voice_mode?: "global" | "per_video" + voice_source?: "preset" | "library" | "clone" | "upload" bgm_preference?: string intent_result?: IntentResult intent_text?: string + /** 图片 VLM 分析结果(后端字段 image_analysis) */ + image_analysis?: ImageAnalysisResult progress_stage?: ViralVideoStage progress_percent?: number progress_message?: string output_url?: string + result_video_url?: string error_message?: string + error_msg?: string credits_cost?: number created_at?: string updated_at?: string @@ -91,11 +118,14 @@ export interface ViralVideoJob { export interface GenerateViralVideoRequest { images: string[] reference_video_url?: string + /** 抖音分享链接(后端解析后下载) */ + douyin_url?: string style_strength?: StyleStrength style_template_id?: string user_copy_text?: string fusion_level?: FusionLevel voice_id?: string + voice_source?: "preset" | "library" | "clone" | "upload" bgm_preference?: string industry?: string target_customer?: string diff --git a/apps/web/src/pages/viral-video/ViralVideoPage.css b/apps/web/src/pages/viral-video/ViralVideoPage.css index 074204928..5ff97bd18 100644 --- a/apps/web/src/pages/viral-video/ViralVideoPage.css +++ b/apps/web/src/pages/viral-video/ViralVideoPage.css @@ -1,12 +1,11 @@ /* ============================================================ 爆款视频创作页 - 浅色紫调(对齐 AI 数字人页视觉规范) - 布局(参考 ui-ref-step-layout.png 三列等宽 STEP 向导): + 布局:三列等宽 STEP 向导 .vv-tabs 顶栏多任务 Tab(生成1 × / + 新建) - .vv-grid 三列等宽 grid(1fr 1fr 1fr,gap 16) - ├── .vv-col 左:STEP 1 上传素材(图片+参考视频+配音) - ├── .vv-col 中:STEP 2 生成视频文案(融合Tab+参数+文案I/O+AI摘要) - └── .vv-col 右:STEP 3 生成视频(预览+参数+进度+扣点+按钮) - 可折叠模块:.vv-section > .vv-section-head[aria-expanded] + .vv-section-body + .vv-grid 三列等宽 grid + ├── .vv-col 左:STEP 1 上传图片+参考视频+参考音频+识别汇览 + ├── .vv-col 中:STEP 2 融合档位+视频参数+AI 文案理解 + └── .vv-col 右:STEP 3 视频预览+视频设置+扣点+状态按钮 ============================================================ */ .vv-page { @@ -109,7 +108,7 @@ gap: 14px; } -/* ── 可折叠模块(对齐 AI 数字人卡块) ────────────────── */ +/* ── 模块卡片 ──────────────────────────────────────── */ .vv-section { background: #fff; border: 1px solid #e5e7eb; @@ -124,6 +123,7 @@ cursor: pointer; user-select: none; border-bottom: 1px solid #f3f4f6; + gap: 8px; } .vv-section.collapsed .vv-section-head { border-bottom: none; @@ -136,7 +136,7 @@ font-weight: 600; color: #111827; } -.vv-section-title .vv-step-badge { +.vv-step-badge { width: 22px; height: 22px; border-radius: 50%; @@ -147,14 +147,7 @@ align-items: center; justify-content: center; font-weight: 600; -} -.vv-section-arrow { - color: #9ca3af; - font-size: 12px; - transition: transform 0.2s; -} -.vv-section.collapsed .vv-section-arrow { - transform: rotate(-90deg); + flex-shrink: 0; } .vv-section-body { padding: 14px 16px 16px; @@ -163,7 +156,22 @@ display: none; } -/* ── 通用表单元素(对齐 AI 数字人样式) ─────────────── */ +/* ── STEP 1 标题(不带折叠,与截图一致) ─────────── */ +.vv-step1-head { + display: flex; + align-items: center; + gap: 10px; + padding: 14px 16px; + border-bottom: 1px solid #f3f4f6; +} +.vv-step1-title { + font-size: 17px; + font-weight: 700; + color: #111827; + letter-spacing: 0.2px; +} + +/* ── 通用表单元素 ─────────────────────────────────── */ .vv-label { display: block; font-size: 12px; @@ -210,7 +218,7 @@ gap: 10px; } -/* ── Tab 分段(对齐"系统预设/我的音色"样式) ────────── */ +/* ── Tab 分段 ─────────────────────────────────────── */ .vv-seg-tabs { display: flex; gap: 8px; @@ -243,7 +251,7 @@ box-shadow: 0 1px 2px rgba(124, 58, 237, 0.06); } -/* 融合强度大按钮(选中紫色描边+浅紫底) */ +/* 融合档位大按钮 */ .vv-fusion-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -282,7 +290,7 @@ color: #7c3aed; } -/* 风格强度小分段按钮(三档,参考配音风格按钮) */ +/* 小标签按钮 */ .vv-pill-row { display: flex; gap: 6px; @@ -309,173 +317,284 @@ color: #7c3aed; font-weight: 500; } - -/* ── 上传区(浅灰虚线框) ───────────────────────────── */ -.vv-upload { - border: 1.5px dashed #d1d5db; - border-radius: 10px; - padding: 20px; - text-align: center; - cursor: pointer; - transition: - border-color 0.2s, - background 0.2s; - background: #fafbfc; - color: #9ca3af; -} -.vv-upload:hover, -.vv-upload.dragover { - border-color: #7c3aed; - background: #f9f7ff; - color: #7c3aed; -} -.vv-upload-icon { - font-size: 28px; - margin-bottom: 6px; -} -.vv-upload small { - display: block; - font-size: 11px; - color: #9ca3af; - margin-top: 4px; +.vv-pill:disabled, +.vv-fusion-btn:disabled { + opacity: 0.5; + cursor: not-allowed; } -/* 图片网格 */ -.vv-img-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(90px, 1fr)); - gap: 8px; - margin-top: 12px; +/* ── STEP1 操作按钮(截图:白底圆角按钮,hover紫边紫底) ── */ +.vv-action-row { + display: flex; + gap: 10px; + margin-bottom: 10px; } -.vv-img-item { - position: relative; - aspect-ratio: 1; +.vv-action-btn { + flex: 1; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 10px 14px; border-radius: 8px; - overflow: hidden; border: 1px solid #e5e7eb; - cursor: grab; + background: #fff; + color: #374151; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; + font-family: inherit; +} +.vv-action-btn:hover:not(:disabled) { + border-color: #7c3aed; + color: #7c3aed; + background: #f9f7ff; +} +.vv-action-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* 已选计数 + 清空 */ +.vv-count-row { + display: flex; + align-items: center; + justify-content: space-between; + margin: 4px 0 10px; + font-size: 13px; +} + +/* 文件列表(图片/视频展示,和截图一致:缩略图左 + 文件名/大小 中 + 操作右) */ +.vv-file-list { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 12px; +} +.vv-file-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #fff; + transition: all 0.15s; +} +.vv-file-item:hover { + border-color: #d8cafc; +} +.vv-file-thumb { + position: relative; + width: 52px; + height: 52px; + border-radius: 6px; + overflow: hidden; background: #f5f6fa; + flex-shrink: 0; + border: 1px solid #f3f4f6; } -.vv-img-item.dragging { - opacity: 0.4; -} -.vv-img-item img { +.vv-file-thumb img { width: 100%; height: 100%; object-fit: cover; } -.vv-img-badge { +.vv-file-thumb .vv-img-badge { position: absolute; - top: 4px; - left: 4px; - background: rgba(124, 58, 237, 0.9); + top: 2px; + left: 2px; +} +.vv-img-badge { + background: rgba(124, 58, 237, 0.92); color: #fff; - font-size: 11px; + font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 4px; + line-height: 1.4; } -.vv-img-del { - position: absolute; - top: 4px; - right: 4px; - width: 20px; - height: 20px; - border-radius: 50%; - background: rgba(239, 68, 68, 0.9); - color: #fff; - border: none; - cursor: pointer; - font-size: 12px; - line-height: 1; - display: flex; - align-items: center; - justify-content: center; - opacity: 0; - transition: opacity 0.15s; +.vv-file-info { + flex: 1; + min-width: 0; } -.vv-img-item:hover .vv-img-del { - opacity: 1; -} -.vv-img-add { - width: 100%; - height: 100%; - border: 1.5px dashed #d1d5db; - border-radius: 8px; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - color: #9ca3af; - cursor: pointer; - background: #fafbfc; - font-size: 11px; - gap: 2px; - transition: all 0.15s; -} -.vv-img-add:hover { - border-color: #7c3aed; - color: #7c3aed; - background: #f9f7ff; -} -.vv-progress-mini { - position: absolute; - inset: 0; - background: rgba(0, 0, 0, 0.35); - color: #fff; - display: flex; - align-items: center; - justify-content: center; - font-size: 11px; -} - -/* 参考视频预览 */ -.vv-video-preview { - width: 100%; - aspect-ratio: 16/9; - border-radius: 8px; +.vv-file-name { + font-size: 13px; + color: #111827; + font-weight: 500; + white-space: nowrap; overflow: hidden; - background: #000; - margin-top: 10px; - border: 1px solid #e5e7eb; + text-overflow: ellipsis; } -.vv-video-preview video { - width: 100%; - height: 100%; - object-fit: cover; -} -.vv-video-ph { - width: 100%; - aspect-ratio: 16/9; - border: 1.5px dashed #d1d5db; - border-radius: 8px; +.vv-file-sub { + font-size: 11px; + color: #9ca3af; + margin-top: 3px; display: flex; - flex-direction: column; + align-items: center; + gap: 6px; +} +.vv-file-actions { + display: flex; + gap: 4px; + flex-shrink: 0; +} +.vv-icon-btn { + width: 26px; + height: 26px; + border-radius: 6px; + border: 1px solid #e5e7eb; + background: #fff; + color: #6b7280; + cursor: pointer; + display: inline-flex; align-items: center; justify-content: center; - color: #9ca3af; - cursor: pointer; - background: #fafbfc; - font-size: 12px; - gap: 4px; - margin-top: 10px; + font-size: 11px; transition: all 0.15s; } -.vv-video-ph:hover { +.vv-icon-btn:hover:not(:disabled) { border-color: #7c3aed; color: #7c3aed; background: #f9f7ff; } +.vv-icon-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} +.vv-icon-btn-danger:hover:not(:disabled) { + border-color: #ef4444; + color: #ef4444; + background: #fef2f2; +} -/* ── 音色列表(参考 AI 数字人「龙小淳」卡片) ──────── */ -.vv-voice-tabs { +/* ── 子区块(参考视频 / 参考音频 带"选填"标签) ──── */ +.vv-subblock { + margin-top: 16px; + padding-top: 14px; + border-top: 1px dashed #e5e7eb; +} +.vv-subblock-head { + display: flex; + align-items: center; + gap: 8px; margin-bottom: 10px; } -.vv-voice-list { - max-height: 280px; - overflow-y: auto; +.vv-subblock-title { + font-size: 14px; + font-weight: 600; + color: #111827; +} +.vv-opt-tag { + font-size: 11px; + color: #9ca3af; + background: #f3f4f6; + border-radius: 4px; + padding: 1px 6px; +} +.vv-subblock-hint { + margin-left: auto; + font-size: 11px; + color: #9ca3af; +} + +/* 抖音链接输入 */ +.vv-douyin-input { + margin-bottom: 10px; +} + +/* 参考视频已选预览 */ +.vv-ref-video-box { + margin-top: 10px; +} +.vv-ref-video-el { + width: 100%; + border-radius: 8px; + background: #000; + border: 1px solid #e5e7eb; +} + +/* ── 参考音频 2x2 网格 ──────────────────────────── */ +.vv-audio-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin-bottom: 10px; +} +.vv-audio-cell { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + padding: 16px 10px; + border-radius: 10px; + border: 1.5px solid #e5e7eb; + background: #fff; + color: #374151; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; + font-family: inherit; +} +.vv-audio-ic { + font-size: 22px; + color: #9ca3af; + transition: color 0.15s; +} +.vv-audio-cell:hover:not(:disabled) { + border-color: #7c3aed; + color: #7c3aed; + background: #f9f7ff; +} +.vv-audio-cell:hover:not(:disabled) .vv-audio-ic { + color: #7c3aed; +} +.vv-audio-cell.active { + border-color: #7c3aed; + background: #f3f0ff; + color: #7c3aed; +} +.vv-audio-cell.active .vv-audio-ic { + color: #7c3aed; +} +.vv-audio-cell.selected { + border-color: #7c3aed; + background: #f3f0ff; + color: #7c3aed; +} +.vv-audio-cell.selected .vv-audio-ic { + color: #7c3aed; +} +.vv-audio-cell:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.vv-selected-audio { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + background: #f3f0ff; + border: 1px solid #d8cafc; + border-radius: 8px; + font-size: 12px; + color: #5b21b6; + margin-bottom: 10px; +} + +/* 展开的音色列表面板 */ +.vv-voice-panel { border: 1px solid #f3f4f6; border-radius: 8px; + margin-bottom: 10px; + overflow: hidden; +} +.vv-voice-list { + max-height: 240px; + overflow-y: auto; } .vv-voice-list::-webkit-scrollbar { width: 6px; @@ -564,13 +683,73 @@ color: #fff; } -/* ── AI 摘要确认卡(黄色高亮) ─────────────────────── */ +/* 折叠提示 */ +.vv-tips { + margin-top: 6px; + font-size: 12px; +} +.vv-tips-head { + background: none; + border: none; + padding: 0; + color: #9ca3af; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + font-family: inherit; +} +.vv-tips-head:hover { + color: #7c3aed; +} +.vv-tips-body { + margin-top: 6px; + padding: 8px 10px; + background: #f9fafb; + border-radius: 6px; + color: #6b7280; + font-size: 11px; + line-height: 1.6; +} + +/* ── 识别描述汇览 ─────────────────────────────────── */ +.vv-recog { + margin-top: 16px; + padding-top: 14px; + border-top: 1px dashed #e5e7eb; +} +.vv-recog-title { + display: flex; + align-items: center; + gap: 6px; + font-size: 14px; + font-weight: 600; + color: #111827; + margin-bottom: 8px; +} +.vv-recog-item { + padding: 10px 12px; + background: #f9fafb; + border-radius: 8px; + font-size: 13px; + line-height: 1.7; + color: #374151; +} +.vv-recog-line + .vv-recog-line { + margin-top: 4px; +} +.vv-recog-k { + color: #6b7280; + font-weight: 500; +} + +/* ── AI 摘要确认卡 ────────────────────────────────── */ .vv-intent { background: #fffbeb; border: 1px solid #fcd34d; border-radius: 10px; padding: 14px; - margin-top: 12px; } .vv-intent-title { display: flex; @@ -605,7 +784,7 @@ margin-bottom: 3px; } -/* ── 9:16 预览区 ────────────────────────────────────── */ +/* ── 9:16 预览 ───────────────────────────────────── */ .vv-preview { width: 100%; aspect-ratio: 9/16; @@ -639,13 +818,14 @@ font-size: 12px; } +/* 上传进度条(保留给视频/音频上传使用) */ .vv-progress-bar { width: 100%; height: 6px; background: #f3f4f6; border-radius: 3px; overflow: hidden; - margin-top: 10px; + margin-top: 6px; } .vv-progress-fill { height: 100%; @@ -653,19 +833,19 @@ border-radius: 3px; transition: width 0.4s ease; } -.vv-progress-meta { - display: flex; - justify-content: space-between; - font-size: 11px; - color: #9ca3af; - margin-top: 6px; -} -.vv-progress-pct { - color: #7c3aed; - font-weight: 600; +.vv-progress-mini-inline { + display: inline-block; + width: 10px; + height: 10px; + border: 2px solid rgba(124, 58, 237, 0.25); + border-top-color: #7c3aed; + border-radius: 50%; + animation: vvspin 0.8s linear infinite; + vertical-align: middle; + margin-right: 4px; } -/* ── 扣点 & 按钮 ────────────────────────────────────── */ +/* ── 扣点 & 按钮 ────────────────────────────────── */ .vv-credits { background: #f9fafb; border: 1px solid #f3f4f6; @@ -730,6 +910,9 @@ padding: 6px 12px; font-size: 12px; } +.vv-btn:disabled { + cursor: not-allowed; +} .vv-error { background: #fef2f2; @@ -738,8 +921,8 @@ padding: 10px 12px; color: #dc2626; font-size: 12px; - margin-top: 10px; } + .vv-spinner { width: 14px; height: 14px; @@ -759,19 +942,7 @@ color: #9ca3af; font-size: 12px; } -.vv-meta { - display: flex; - justify-content: space-between; - font-size: 11px; - color: #9ca3af; - margin-top: 4px; -} -.vv-file-name { - font-size: 12px; - color: #374151; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; +.vv-mt-8 { margin-top: 8px; } .vv-link-btn { @@ -786,32 +957,197 @@ .vv-link-btn:hover { text-decoration: underline; } -.vv-flex { - display: flex; -} -.vv-between { - justify-content: space-between; - align-items: center; -} -.vv-gap-8 { - gap: 8px; -} -.vv-mt-8 { - margin-top: 8px; -} -.vv-mt-12 { - margin-top: 12px; +.vv-link-btn:disabled { + opacity: 0.4; + cursor: not-allowed; + text-decoration: none; } -/* disabled 状态 */ -.vv-pill:disabled, -.vv-fusion-btn:disabled { - opacity: 0.6; - cursor: not-allowed; +/* 状态提示条 */ +.vv-hint-gray { + padding: 12px 16px; + background: #f9fafb; + border-radius: 8px; + font-size: 12px; + text-align: center; + color: #6b7280; } -.vv-btn:disabled { - cursor: not-allowed; +.vv-hint-yellow { + padding: 12px 16px; + background: #fffbeb; + border: 1px solid #fcd34d; + border-radius: 8px; + font-size: 12px; + text-align: center; + color: #b45309; } -.vv-section-head { + +/* ── 素材选择弹窗 ────────────────────────────────── */ +.vv-modal-mask { + position: fixed; + inset: 0; + background: rgba(17, 24, 39, 0.45); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; +} +.vv-modal { + width: 720px; + max-width: 100%; + max-height: 82vh; + background: #fff; + border-radius: 12px; + display: flex; + flex-direction: column; + overflow: hidden; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18); +} +.vv-modal-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 18px; + border-bottom: 1px solid #f3f4f6; +} +.vv-modal-title { + font-size: 15px; + font-weight: 600; + color: #111827; +} +.vv-modal-close { + width: 28px; + height: 28px; + border-radius: 6px; + border: none; + background: transparent; + color: #9ca3af; + font-size: 18px; + cursor: pointer; +} +.vv-modal-close:hover { + background: #f3f4f6; + color: #374151; +} +.vv-modal-body { + flex: 1; + overflow: hidden; + padding: 14px 18px; + display: flex; + flex-direction: column; + gap: 12px; +} +.vv-asset-search { + display: flex; gap: 8px; } +.vv-asset-thumbs { + flex: 1; + overflow-y: auto; + display: grid; + gap: 10px; + padding: 4px; +} +.vv-asset-image { + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); +} +.vv-asset-video { + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); +} +.vv-asset-voice { + grid-template-columns: 1fr; +} +.vv-thumb-card { + position: relative; + border: 1px solid #e5e7eb; + border-radius: 8px; + overflow: hidden; + cursor: pointer; + background: #f9fafb; + transition: all 0.15s; +} +.vv-asset-image .vv-thumb-card, +.vv-asset-video .vv-thumb-card { + aspect-ratio: 1; +} +.vv-thumb-card:hover { + border-color: #7c3aed; +} +.vv-thumb-card.selected { + border-color: #7c3aed; + box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.2); +} +.vv-thumb-card img, +.vv-thumb-card video { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.vv-thumb-ph { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-size: 32px; + color: #9ca3af; +} +.vv-asset-voice .vv-thumb-card { + padding: 10px 14px; + display: flex; + align-items: center; + gap: 10px; + aspect-ratio: auto; +} +.vv-thumb-check { + position: absolute; + top: 6px; + right: 6px; + width: 22px; + height: 22px; + border-radius: 50%; + background: #7c3aed; + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; +} +.vv-thumb-name { + font-size: 12px; + color: #374151; + padding: 6px 8px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: center; + background: #fff; +} +.vv-asset-voice .vv-thumb-name { + text-align: left; + padding: 0; + flex: 1; +} +.vv-modal-empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + color: #9ca3af; + font-size: 13px; +} +.vv-empty-icon { + font-size: 32px; + opacity: 0.6; +} +.vv-modal-foot { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 12px 18px; + border-top: 1px solid #f3f4f6; +} diff --git a/apps/web/src/pages/viral-video/ViralVideoPage.tsx b/apps/web/src/pages/viral-video/ViralVideoPage.tsx index b0e9763a0..96f156267 100644 --- a/apps/web/src/pages/viral-video/ViralVideoPage.tsx +++ b/apps/web/src/pages/viral-video/ViralVideoPage.tsx @@ -6,16 +6,23 @@ import { PlayCircleOutlined, PauseCircleOutlined, CaretDownOutlined, + CaretRightOutlined, VideoCameraOutlined, SoundOutlined, ReloadOutlined, WarningFilled, BulbFilled, - AudioOutlined, ThunderboltOutlined, + ArrowUpOutlined, + ArrowDownOutlined, + FileTextOutlined, + FolderOpenOutlined, + AudioMutedOutlined, + LinkOutlined, + LoadingOutlined, } from "@ant-design/icons" -import { Select, message } from "antd" -import { uploadAssetDirect, getAssetLibraries, getAssetsByKind } from "@/api/assets" +import { Select, Input, message } from "antd" +import { uploadAssetDirect, getAssetLibraries, getAssetsByKind, type AssetItem } from "@/api/assets" import { FUSION_LEVELS, STYLE_STRENGTHS, @@ -23,38 +30,56 @@ import { type FusionLevel, type StyleStrength, type ViralVideoJob, + type ImageProductAnalysis, } from "@/api/viral-video/types" import { generateViralVideo, confirmViralVideoIntent, retryViralVideo } from "@/api/viral-video" import { useViralVideoPolling } from "./hooks/useViralVideoPolling" import CloneModal from "@/components/voice/CloneModal" +import AssetPickerModal from "./components/AssetPickerModal" import "./ViralVideoPage.css" /* ─────────── 常量与类型 ─────────── */ +type UploadedImage = { + localId: string + url: string + name: string + size: number + uploading: boolean + pct: number + ossUrl?: string + file?: File +} + +type RefAudio = { + source: "preset" | "library" | "upload" | "clone" + id?: string + name: string + url?: string +} | null + +type VoicePanel = "preset" | "upload" | "library" | "record" | "douyin" | null + type TabTask = { id: string title: string // 编辑态 - images: { - localId: string - url: string - name: string - uploading: boolean - pct: number - ossUrl?: string - file?: File - }[] + images: UploadedImage[] refVideo: { - url: string + source: "local" | "douyin" + url: string // blob: 预览或 oss URL name: string uploading: boolean pct: number ossUrl?: string file?: File + douyinUrl?: string } | null + douyinInput: string + voicePanel: VoicePanel + refAudio: RefAudio fusionLevel: FusionLevel styleStrength: StyleStrength - voiceId: string userCopy: string industry: string targetCustomer: string @@ -66,23 +91,12 @@ type TabTask = { videoRatio: string videoModel: string intentEditText: string - collapsed: { - images: boolean - refVideo: boolean - voice: boolean - fusion: boolean - params: boolean - copy: boolean - preview: boolean - videoParams: boolean - stages: boolean - } + voiceTipsOpen: boolean // 运行态 job: ViralVideoJob | null jobId: string | null playingVoiceId: string | null audioInst: HTMLAudioElement | null - // 按钮 loading analyzing: boolean generating: boolean } @@ -115,15 +129,25 @@ const MODELS = [ { v: "seedance-2.5", label: "Seedance 2.5(推荐)" }, { v: "seedance-2.0", label: "Seedance 2.0" }, ] +const VOICE_TIPS = + "支持 MP3/WAV/M4A/AAC/OGG 格式,最大 10MB,时长 ≤30 秒。建议清晰人声、无背景音乐、环境安静;录音请保持距麦克风 15-20cm,音量适中。" + +const fmtSize = (bytes: number) => { + if (bytes < 1024) return `${bytes}B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB` + return `${(bytes / 1024 / 1024).toFixed(1)}MB` +} const emptyTask = (id: string, title: string): TabTask => ({ id, title, images: [], refVideo: null, + douyinInput: "", + voicePanel: null, + refAudio: null, fusionLevel: "ai_full", styleStrength: "medium", - voiceId: "", userCopy: "", industry: "", targetCustomer: "", @@ -135,17 +159,7 @@ const emptyTask = (id: string, title: string): TabTask => ({ videoRatio: "9:16", videoModel: "seedance-2.5", intentEditText: "", - collapsed: { - images: false, - refVideo: false, - voice: false, - fusion: false, - params: false, - copy: false, - preview: false, - videoParams: false, - stages: false, - }, + voiceTipsOpen: false, job: null, jobId: null, playingVoiceId: null, @@ -154,38 +168,26 @@ const emptyTask = (id: string, title: string): TabTask => ({ generating: false, }) -/* ─────────── 折叠 Section ─────────── */ -const Section: React.FC<{ - title: React.ReactNode - step?: number - collapsed: boolean - onToggle: () => void - children: React.ReactNode -}> = ({ title, step, collapsed, onToggle, children }) => ( -
-
-
- {step !== undefined && {step}} - {title} -
- -
- {!collapsed &&
{children}
} -
-) - /* ─────────── 主页面 ─────────── */ const ViralVideoPage: React.FC = () => { const [tasks, setTasks] = useState(() => [emptyTask("t1", "生成 1")]) const [activeId, setActiveId] = useState("t1") - const [voiceTab, setVoiceTab] = useState<"preset" | "clone">("preset") const [cloneModalOpen, setCloneModalOpen] = useState(false) const [voiceAssets, setVoiceAssets] = useState< { id: string; name: string; url: string; desc?: string }[] >([]) - const dragImgRef = useRef(null) - const dragVideoRef = useRef(null) - const dragFromIdxRef = useRef(-1) + const [presetVoices, setPresetVoices] = useState<{ id: string; name: string; desc?: string }[]>( + [], + ) + const [assetPicker, setAssetPicker] = useState<{ + open: boolean + kind: "image" | "video" | "voice" + multiple: boolean + }>({ open: false, kind: "image", multiple: false }) + + const imgInputRef = useRef(null) + const videoInputRef = useRef(null) + const voiceInputRef = useRef(null) const audioRef = useRef(null) const task = tasks.find((t) => t.id === activeId) || tasks[0] @@ -199,12 +201,6 @@ const ViralVideoPage: React.FC = () => { }, [activeId], ) - const toggleCollapse = useCallback( - (key: keyof TabTask["collapsed"]) => { - setTask((t) => ({ ...t, collapsed: { ...t.collapsed, [key]: !t.collapsed[key] } })) - }, - [setTask], - ) /* ── 加载音色素材 ── */ useEffect(() => { @@ -220,13 +216,34 @@ const ViralVideoPage: React.FC = () => { ), ) .catch(() => {}) + // 预置音色:从 /api/tts/presets 拉(失败则用占位) + fetch("/api/v1/tts/presets", { credentials: "include" }) + .then((r) => (r.ok ? r.json() : [])) + .then((list: { voice_id?: string; id?: string; name?: string; display_name?: string }[]) => { + const arr = Array.isArray(list) ? list : [] + setPresetVoices( + arr.map((v) => ({ + id: v.voice_id || v.id || "", + name: v.display_name || v.name || v.voice_id || "预置音色", + desc: "系统音色", + })), + ) + }) + .catch(() => { + // 兜底:展示一些常见音色名 + setPresetVoices([ + { id: "zh-CN-XiaoxiaoNeural", name: "晓晓", desc: "温柔女声" }, + { id: "zh-CN-YunxiNeural", name: "云希", desc: "活力男声" }, + { id: "zh-CN-YunjianNeural", name: "云健", desc: "专业男声" }, + { id: "zh-CN-XiaoyiNeural", name: "晓伊", desc: "甜美女声" }, + ]) + }) }, []) /* ── 轮询 ── */ const onPollUpdate = useCallback( (job: ViralVideoJob) => { setTask((t) => { - // 首次进入 wait_user_confirm 时初始化 intent 编辑文本 const prevStatus = t.job?.status const newIntentEdit = prevStatus !== "wait_user_confirm" && job.status === "wait_user_confirm" @@ -239,13 +256,15 @@ const ViralVideoPage: React.FC = () => { analyzing: job.status === "wait_user_confirm" || job.status === "completed" || - job.status === "failed" + job.status === "failed" || + job.status === "cancelled" ? false : t.analyzing, generating: job.status === "completed" || job.status === "failed" || - job.status === "wait_user_confirm" + job.status === "wait_user_confirm" || + job.status === "cancelled" ? false : t.generating, } @@ -271,35 +290,34 @@ const ViralVideoPage: React.FC = () => { [], ) - /* ── 上传图片 ── */ - const handleImageFiles = useCallback( + /* ── 上传图片(本地+素材库选择) ── */ + const appendImagesFromFiles = useCallback( async (files: FileList | File[]) => { const remaining = 9 - task.images.length if (remaining <= 0) { message.warning("最多上传 9 张产品图") return } - const arr = Array.from(files).slice(0, remaining) + const arr = Array.from(files) + .filter((f) => f.type.startsWith("image/")) + .slice(0, remaining) if (arr.length === 0) return - const newImgs = arr.map((f) => ({ + const newImgs: UploadedImage[] = arr.map((f) => ({ localId: `${Date.now()}_${Math.random().toString(36).slice(2, 7)}`, url: URL.createObjectURL(f), name: f.name, + size: f.size, uploading: true, pct: 0, file: f, })) setTask((t) => ({ ...t, - images: [ - ...t.images, - ...newImgs.map(({ file: _f, ...rest }) => rest as TabTask["images"][number]), - ], + images: [...t.images, ...newImgs.map(({ file: _f, ...rest }) => rest as UploadedImage)], })) - for (const item of newImgs) { try { - const url = await uploadFile(item.file, "image", (p) => { + const url = await uploadFile(item.file!, "image", (p) => { setTask((t) => ({ ...t, images: t.images.map((im) => (im.localId === item.localId ? { ...im, pct: p } : im)), @@ -320,48 +338,71 @@ const ViralVideoPage: React.FC = () => { [task.images.length, setTask, uploadFile], ) - const onDropImg = useCallback( - (e: React.DragEvent) => { - e.preventDefault() - if (e.dataTransfer.files?.length) handleImageFiles(e.dataTransfer.files) + const appendImagesFromAssets = useCallback( + (assets: AssetItem[]) => { + const remaining = 9 - task.images.length + const picked = assets.slice(0, remaining) + if (picked.length === 0) return + const newImgs: UploadedImage[] = picked.map((a) => ({ + localId: `lib_${a.id}_${Math.random().toString(36).slice(2, 6)}`, + url: a.thumbnail_url || a.file_url || "", + name: a.name || "未命名", + size: a.file_size || 0, + uploading: false, + pct: 100, + ossUrl: a.file_url || undefined, + })) + setTask((t) => ({ ...t, images: [...t.images, ...newImgs] })) + message.success(`已添加 ${picked.length} 张图片`) }, - [handleImageFiles], + [task.images.length, setTask], ) - const removeImg = useCallback( - (localId: string) => { - setTask((t) => { - const im = t.images.find((i) => i.localId === localId) - if (im?.url?.startsWith("blob:")) URL.revokeObjectURL(im.url) - return { ...t, images: t.images.filter((i) => i.localId !== localId) } + const clearImages = () => { + setTask((t) => { + t.images.forEach((im) => { + if (im.url?.startsWith("blob:")) URL.revokeObjectURL(im.url) }) - }, - [setTask], - ) + return { ...t, images: [] } + }) + } - const onImgDragStart = (idx: number) => { - dragFromIdxRef.current = idx + const removeImg = (localId: string) => { + setTask((t) => { + const im = t.images.find((i) => i.localId === localId) + if (im?.url?.startsWith("blob:")) URL.revokeObjectURL(im.url) + return { ...t, images: t.images.filter((i) => i.localId !== localId) } + }) } - const onImgDragOver = (e: React.DragEvent) => { - e.preventDefault() - } - const onImgDrop = (idx: number) => { - const from = dragFromIdxRef.current - if (from < 0 || from === idx) return + + const moveImg = (idx: number, dir: -1 | 1) => { setTask((t) => { const imgs = [...t.images] - const [m] = imgs.splice(from, 1) - imgs.splice(idx, 0, m) + const target = idx + dir + if (target < 0 || target >= imgs.length) return t + ;[imgs[idx], imgs[target]] = [imgs[target], imgs[idx]] return { ...t, images: imgs } }) - dragFromIdxRef.current = -1 } - /* ── 上传参考视频 ── */ - const handleRefVideo = useCallback( + /* ── 参考视频:本地 / 抖音链接 ── */ + const handleLocalRefVideo = useCallback( async (file: File) => { + if (file.size > 200 * 1024 * 1024) { + message.warning("视频不能超过 200MB") + return + } const localUrl = URL.createObjectURL(file) - setTask({ refVideo: { url: localUrl, name: file.name, uploading: true, pct: 0 } }) + setTask({ + refVideo: { + source: "local", + url: localUrl, + name: file.name, + uploading: true, + pct: 0, + file, + }, + }) try { const url = await uploadFile(file, "video", (p) => { setTask((t) => (t.refVideo ? { ...t, refVideo: { ...t.refVideo, pct: p } } : t)) @@ -380,6 +421,102 @@ const ViralVideoPage: React.FC = () => { [setTask, uploadFile], ) + const handleDouyinUrl = () => { + const url = task.douyinInput.trim() + if (!url) { + message.warning("请粘贴抖音分享链接") + return + } + if (!/(douyin\.com|iesdouyin\.com)/.test(url)) { + message.warning("请输入有效的抖音链接(v.douyin.com 或 www.douyin.com)") + return + } + // 后端解析接口尚未开放:先提示,保留 URL 到 payload 中作为 douyin_url 字段(后端若不识别会忽略) + message.info("抖音链接识别功能即将上线,当前请使用本地上传参考视频") + setTask({ douyinInput: "" }) + } + + const removeRefVideo = () => { + setTask((t) => { + if (t.refVideo?.url.startsWith("blob:")) URL.revokeObjectURL(t.refVideo.url) + return { ...t, refVideo: null } + }) + } + + /* ── 参考音频 ── */ + const handleVoiceFile = useCallback( + async (file: File) => { + if (file.size > 10 * 1024 * 1024) { + message.warning("音频不能超过 10MB") + return + } + const localUrl = URL.createObjectURL(file) + const name = file.name + // 上传到 voice 素材库 + try { + const url = await uploadFile(file, "voice") + setTask({ + refAudio: { source: "upload", name, url, id: url }, + voicePanel: null, + }) + message.success("音频已上传") + getAssetsByKind("voice", { limit: 50 }) + .then((list) => + setVoiceAssets( + list.map((a) => ({ + id: a.id, + name: a.name, + url: a.file_url || "", + desc: a.duration ? `${Math.round(a.duration)}s` : "我的音色", + })), + ), + ) + .catch(() => {}) + } catch { + message.error("音频上传失败") + URL.revokeObjectURL(localUrl) + } + }, + [setTask, uploadFile], + ) + + const onCloneSuccess = (voice: { id?: string; name?: string }) => { + setCloneModalOpen(false) + if (voice?.id) { + setTask({ + refAudio: { source: "clone", id: voice.id, name: voice.name || "我录制的音色" }, + voicePanel: null, + }) + getAssetsByKind("voice", { limit: 50 }) + .then((list) => + setVoiceAssets( + list.map((a) => ({ id: a.id, name: a.name, url: a.file_url || "", desc: "我的音色" })), + ), + ) + .catch(() => {}) + message.success("录音已完成,已自动选中") + } + } + + const pickVoiceAsset = (asset: AssetItem) => { + setTask({ + refAudio: { + source: "library", + id: asset.id, + name: asset.name, + url: asset.file_url || "", + }, + voicePanel: null, + }) + } + + const pickPresetVoice = (v: { id: string; name: string }) => { + setTask({ + refAudio: { source: "preset", id: v.id, name: v.name }, + voicePanel: null, + }) + } + /* ── Tab 管理 ── */ const newTab = () => { const id = `t${Date.now()}` @@ -402,13 +539,13 @@ const ViralVideoPage: React.FC = () => { setTasks((ts) => ts.map((t) => (t.id === activeId ? emptyTask(t.id, t.title) : t))) } - /* ── STEP1:开始分析(触发图片分析+视频分析+意图理解 → WAIT_USER_CONFIRM 暂停) ── */ + /* ── STEP1:开始分析 ── */ const handleAnalyze = async () => { const imgUrls = task.images .filter((i) => i.ossUrl && !i.uploading) .map((i) => i.ossUrl as string) if (imgUrls.length === 0) { - message.warning("请至少上传 1 张产品图,等待上传完成后再分析") + message.warning("请至少上传 1 张产品图") return } if (task.images.some((i) => i.uploading)) { @@ -421,16 +558,14 @@ const ViralVideoPage: React.FC = () => { } setTask({ analyzing: true }) try { - const job = await generateViralVideo({ + const voice_id = task.refAudio?.id || "" + const payload: Parameters[0] = { images: imgUrls, reference_video_url: task.refVideo?.ossUrl || undefined, style_strength: task.refVideo?.ossUrl ? task.styleStrength : undefined, user_copy_text: task.fusionLevel !== "ai_full" ? task.userCopy : undefined, fusion_level: task.fusionLevel, - // 注意:STEP1「开始分析」阶段还没有真正开始生成视频,后端会跑到 WAIT_USER_CONFIRM 暂停。 - // 配音和视频参数在 STEP2 确认文案时由 confirm-intent 继续使用;但后端接口目前只接收一次 payload, - // 因此先把配音/参数一并提交(用户可在确认前继续调整配音、比例等,但最终生成用的是提交时的值)。 - voice_id: task.voiceId || undefined, + voice_id: voice_id || undefined, bgm_preference: task.bgm === "无BGM" ? undefined : task.bgm, industry: task.industry || undefined, target_customer: task.targetCustomer || undefined, @@ -440,7 +575,8 @@ const ViralVideoPage: React.FC = () => { duration: task.duration, video_model: task.videoModel, video_ratio: task.videoRatio, - }) + } + const job = await generateViralVideo(payload) setTask({ job, jobId: job.id, analyzing: false }) message.success("开始分析,稍候会给出 AI 理解的文案…") } catch (err: unknown) { @@ -449,7 +585,7 @@ const ViralVideoPage: React.FC = () => { } } - /* ── STEP2:确认并生成视频(文案融合→分镜→审核→TTS→渲染→上传,一跑到底) ── */ + /* ── STEP2:确认并生成视频 ── */ const handleConfirmAndGenerate = async () => { if (!task.jobId) return setTask({ generating: true }) @@ -476,6 +612,7 @@ const ViralVideoPage: React.FC = () => { /* ── 音色播放 ── */ const toggleVoice = (v: { id: string; url: string }) => { + if (!v.url) return if (task.playingVoiceId === v.id) { audioRef.current?.pause() setTask({ playingVoiceId: null }) @@ -490,24 +627,9 @@ const ViralVideoPage: React.FC = () => { } useEffect(() => () => audioRef.current?.pause(), []) - const onCloneSuccess = (voice: { id?: string }) => { - setCloneModalOpen(false) - if (voice?.id) { - setTask({ voiceId: voice.id }) - getAssetsByKind("voice", { limit: 50 }) - .then((list) => - setVoiceAssets( - list.map((a) => ({ id: a.id, name: a.name, url: a.file_url || "", desc: "我的音色" })), - ), - ) - .catch(() => {}) - message.success("音色已上传,已自动选中") - } - } - /* ── 派生状态 ── */ const isAwaitingConfirm = task.job?.status === "wait_user_confirm" - const isPreAnalysis = !task.jobId && !task.analyzing // 还没点开始分析 + const isPreAnalysis = !task.jobId && !task.analyzing const isAnalyzing = task.analyzing || (task.job && @@ -520,6 +642,60 @@ const ViralVideoPage: React.FC = () => { const isDone = task.job?.status === "completed" const isFailed = task.job?.status === "failed" + const outputUrl = task.job?.output_url || task.job?.result_video_url + const errMsg = task.job?.error_message || task.job?.error_msg + + /* ── 识别描述汇览渲染 ── */ + const renderRecognition = () => { + const analysis = task.job?.image_analysis + const products: ImageProductAnalysis[] = + (analysis?.products as ImageProductAnalysis[] | undefined) || [] + if (isAnalyzing && products.length === 0) { + return ( +
+
+ + 识别描述汇览 +
+
AI 正在识别商品特征…
+
+ ) + } + if (!analysis || products.length === 0) return null + + const featureText = (f: string[] | string | undefined) => { + if (!f) return "" + if (Array.isArray(f)) return f.join(";") + return f + } + + return ( +
+
+ + 识别描述汇览 +
+ {products.map((p, i) => ( +
+ {p.name && ( +
+ 商品: + {p.name} + {p.spec && ,{p.spec}} +
+ )} + {featureText(p.features) && ( +
+ 视觉特征: + {featureText(p.features)} +
+ )} +
+ ))} +
+ ) + } + return (
{/* 顶部 Tab */} @@ -555,378 +731,550 @@ const ViralVideoPage: React.FC = () => {
{/* ─────────── 左列 STEP 1 ─────────── */}
-
toggleCollapse("images")} - > -
{ - e.preventDefault() - ;(e.currentTarget as HTMLDivElement).classList.add("dragover") - }} - onDragLeave={(e) => (e.currentTarget as HTMLDivElement).classList.remove("dragover")} - onDrop={onDropImg} - onClick={() => dragImgRef.current?.click()} - > -
- -
-
点击或拖拽上传 1-9 张产品图
- 建议 JPG/PNG,单张 ≤ 10MB + {/* ===== STEP 1 卡片(不折叠,标题即"STEP 1 上传图片分析") ===== */} +
+
+ 1 + STEP 1 上传图片分析
- { - if (e.target.files) handleImageFiles(e.target.files) - e.target.value = "" - }} - /> - {task.images.length > 0 && ( -
- {task.images.map((im, i) => ( -
onImgDragStart(i)} - onDragOver={onImgDragOver} - onDrop={() => onImgDrop(i)} +
+ {/* 两个按钮:本地上传 / 从素材库选择 */} +
+ + +
+ { + if (e.target.files) appendImagesFromFiles(e.target.files) + e.target.value = "" + }} + /> + + {/* 已选计数 + 清空 */} +
+ + 已选 {task.images.length}/9 + + {task.images.length > 0 && ( + - {im.uploading &&
{im.pct}%
} -
- ))} - {task.images.length < 9 && ( -
dragImgRef.current?.click()}> -
- - 添加 -
-
+ 清空图片 + )}
- )} -
-
toggleCollapse("refVideo")} - > - {!task.refVideo ? ( - <> -
dragVideoRef.current?.click()}> - - 上传参考视频 - - AI 会借鉴该视频的镜头/节奏/风格 - + {/* 图片列表(按截图:缩略图+文件名+大小+上下箭头+删除) */} + {task.images.length > 0 && ( +
+ {task.images.map((im, i) => ( +
+
+ {i + 1} + {im.name} +
+
+
+ {im.name} +
+
+ {im.uploading ? ( + <> + + 上传中 {im.pct}% + + ) : ( + <>{fmtSize(im.size)} + )} +
+
+
+ + + +
+
+ ))} +
+ )} + + {/* ===== 参考视频(在图片和配音之间) ===== */} +
+
+ 参考视频 + 选填 + 最大 200MB · 最长 120 秒 +
+
+ +
{ - if (e.target.files?.[0]) handleRefVideo(e.target.files[0]) + if (e.target.files?.[0]) handleLocalRefVideo(e.target.files[0]) e.target.value = "" }} /> - - ) : ( - <> -
-
- {task.refVideo.uploading && ( - <> -
-
-
-
- 上传中… - {task.refVideo.pct}% -
- - )} -
{task.refVideo.name}
- - {task.refVideo.ossUrl && ( -
-
风格借鉴强度
-
- {STYLE_STRENGTHS.map((s) => ( - - ))} -
-
- )} - - )} -
-
toggleCollapse("voice")} - > -
- - -
-
- {voiceAssets.length === 0 && ( -
- 暂无可用音色,去「我的音色」上传 -
- )} - {voiceAssets.map((v) => ( -
setTask({ voiceId: v.id })} - > -
-
-
{v.name}
-
{v.desc || ""}
+ {/* 抖音链接输入框 */} + {task.voicePanel === "douyin" && !task.refVideo && ( +
+ setTask({ douyinInput: e.target.value })} + onPressEnter={handleDouyinUrl} + suffix={ + + } + /> +
+ 粘贴后自动解析下载参考视频(敬请期待) +
- + {task.refVideo.ossUrl && ( +
+
+ 风格借鉴强度 +
+
+ {STYLE_STRENGTHS.map((s) => ( + + ))} +
+
)} +
+ )} +
+ + {/* ===== 参考音频 2x2 按钮网格 ===== */} +
+
+ 参考音频 + 选填 + 最大 10MB · 最长 30 秒 +
+
+ + + +
- ))} -
- + { + if (e.target.files?.[0]) handleVoiceFile(e.target.files[0]) + e.target.value = "" + }} + /> - {/* STEP1 主按钮:开始分析 */} - -
- 点击后将开始图片/视频风格分析与意图理解 + {/* 已选音频提示 */} + {task.refAudio && ( +
+ + 已选:{task.refAudio.name} + +
+ )} + + {/* 展开:内置音色列表 */} + {task.voicePanel === "preset" && ( +
+ {presetVoices.length === 0 ? ( +
+ 加载中… +
+ ) : ( +
+ {presetVoices.map((v) => ( +
pickPresetVoice(v)} + > +
+
+
{v.name}
+
{v.desc || ""}
+
+
+ ))} +
+ )} +
+ )} + + {/* 展开:我的音色(素材库 voice) */} + {task.voicePanel === "library" && ( +
+ {voiceAssets.length === 0 ? ( +
+ 暂无已上传的音频 +
+ ) : ( +
+ {voiceAssets.map((v) => ( +
+ pickVoiceAsset({ + id: v.id, + name: v.name, + file_url: v.url, + } as AssetItem) + } + > +
+
+
{v.name}
+
{v.desc || ""}
+
+ {v.url && ( + + )} +
+ ))} +
+ )} +
+ )} + + {/* 折叠提示:支持格式与录音建议 */} +
+ + {task.voiceTipsOpen &&
{VOICE_TIPS}
} +
+
+ + {/* 识别描述汇览(分析中/分析完成后展示) */} + {renderRecognition()} + + {/* STEP1 主按钮 */} +
-
+
{/* ─────────── 中列 STEP 2 ─────────── */}
-
toggleCollapse("fusion")} - > -
- {FUSION_LEVELS.map((f) => ( - - ))} +
+
+
+ 2 + 文案融合方式 +
-
- -