diff --git a/apps/web/src/api/viral-video/index.ts b/apps/web/src/api/viral-video/index.ts index 4a595a446..b6d3b20a9 100644 --- a/apps/web/src/api/viral-video/index.ts +++ b/apps/web/src/api/viral-video/index.ts @@ -1,91 +1,47 @@ -/** - * 爆款视频 API 封装 - * 所有端点:/api/v1/viral-video/* - */ -import apiClient from "../client" +import apiClient from "@/api/client" import type { - AnalyzeStyleResponse, - ConfirmIntentRequest, - CreateViralVideoRequest, - StyleTemplateListResponse, - ViralVideoHistoryResponse, + GenerateViralVideoRequest, + HistoryResponse, + StyleTemplate, ViralVideoJob, } from "./types" -/** 上传图片/视频资源并返回可访问 URL(复用资产上传接口) */ -export const uploadViralAsset = async ( - file: File, - kind: "image" | "video" = "image", -): Promise => { - const formData = new FormData() - formData.append("file", file) - formData.append("kind", kind) - // 复用通用资产上传;若后端有专用 /viral-video/upload 端点可替换 - const { data } = await apiClient.post<{ url: string; id?: string }>("/assets/upload", formData, { - headers: { "Content-Type": "multipart/form-data" }, - timeout: 120_000, - }) - return data.url -} - /** 创建爆款视频任务 */ -export const createViralVideoJob = async ( - params: CreateViralVideoRequest, -): Promise => { - const { data } = await apiClient.post("/viral-video/generate", params, { - timeout: 60_000, - }) - return data +export function generateViralVideo(payload: GenerateViralVideoRequest) { + return apiClient.post("/viral-video/generate", payload).then((r) => r.data) } -/** 查询爆款视频任务 */ -export const getViralVideoJob = async (jobId: string): Promise => { - const { data } = await apiClient.get(`/viral-video/${jobId}`) - return data +/** 查询单个任务 */ +export function getViralVideoJob(id: string) { + return apiClient.get(`/viral-video/${id}`).then((r) => r.data) +} + +/** 用户确认/修改 AI 理解的意图后继续 */ +export function confirmViralVideoIntent( + id: string, + payload: { confirmed_copy?: string; edits?: Record }, +) { + return apiClient + .post(`/viral-video/${id}/confirm-intent`, payload) + .then((r) => r.data) } /** 重试失败任务 */ -export const retryViralVideoJob = async (jobId: string): Promise => { - const { data } = await apiClient.post(`/viral-video/${jobId}/retry`) - return data +export function retryViralVideo(id: string) { + return apiClient.post(`/viral-video/${id}/retry`).then((r) => r.data) } -/** 用户确认 AI 意图后继续流水线 */ -export const confirmViralIntent = async ( - jobId: string, - body: ConfirmIntentRequest = {}, -): Promise => { - const { data } = await apiClient.post(`/viral-video/${jobId}/confirm-intent`, body) - return data +/** 历史记录(分页) */ +export function getViralVideoHistory(params?: { page?: number; page_size?: number }) { + return apiClient.get("/viral-video/history", { params }).then((r) => r.data) } -/** 触发参考视频风格分析 */ -export const analyzeViralStyle = async ( - jobId: string, - referenceVideoUrl: string, - styleTemplateId: string = "", -): Promise => { - const { data } = await apiClient.post( - `/viral-video/${jobId}/analyze-style`, - { reference_video_url: referenceVideoUrl, style_template_id: styleTemplateId }, - { timeout: 120_000 }, - ) - return data +/** 预设风格模板 */ +export function getViralStyleTemplates() { + return apiClient.get("/viral-video/style-templates").then((r) => r.data) } -/** 获取风格模板列表 */ -export const listStyleTemplates = async (): Promise => { - const { data } = await apiClient.get("/viral-video/style-templates") - return data -} - -/** 获取历史记录 */ -export const listViralVideoHistory = async ( - limit = 50, - offset = 0, -): Promise => { - const { data } = await apiClient.get("/viral-video/history", { - params: { limit, offset }, - }) - return data +/** 上传参考视频后触发风格分析(返回带 style_guide 的任务详情) */ +export function analyzeViralStyle(id: string) { + return apiClient.post(`/viral-video/${id}/analyze-style`).then((r) => r.data) } diff --git a/apps/web/src/api/viral-video/types.ts b/apps/web/src/api/viral-video/types.ts index b86e71b65..3327fda2a 100644 --- a/apps/web/src/api/viral-video/types.ts +++ b/apps/web/src/api/viral-video/types.ts @@ -1,19 +1,20 @@ -/** - * 爆款视频 API 类型定义 - * 与后端 apps/api/app/schemas/viral_video.py 对齐 - */ +export type FusionLevel = "full_ai" | "polish" | "as_is" +export const FUSION_LEVELS: { value: FusionLevel; label: string; desc: string }[] = [ + { value: "full_ai", label: "AI 全写", desc: "给我方向,全由AI创作" }, + { value: "polish", label: "AI润色", desc: "我写草稿,AI帮我润色" }, + { value: "as_is", label: "按我写的来", desc: "几乎不改我的文案" }, +] -/** 文案融合级别 */ -export type FusionLevel = "ai_full" | "ai_polish" | "user_primary" - -/** 参考视频风格强度 */ export type StyleStrength = "light" | "medium" | "strict" +export const STYLE_STRENGTHS: { value: StyleStrength; label: string }[] = [ + { value: "light", label: "轻度借鉴" }, + { value: "medium", label: "中度参考" }, + { value: "strict", label: "深度模仿" }, +] -/** 任务状态 */ export type ViralVideoStatus = "pending" | "running" | "wait_user_confirm" | "completed" | "failed" | "cancelled" -/** 流水线阶段(对应后端 VALID_STAGES) */ export type ViralVideoStage = | "image_analysis" | "video_analysis" @@ -27,136 +28,94 @@ export type ViralVideoStage = | "musetalk" | "uploading" -/** 创建爆款视频任务请求 */ -export interface CreateViralVideoRequest { - images: string[] - industry?: string - target_customer?: string - persona_id?: string - viral_structure?: string - marketing_purpose?: string - bgm_preference?: string - duration?: number - user_copy_text?: string - fusion_level?: FusionLevel - reference_audio_path?: string - /** v1.3 参考爆款视频 */ - reference_video_url?: string - style_strength?: StyleStrength - style_template_id?: string +export interface StageDisplay { + label: string + /** 插值到的总体进度百分比 */ + pct: number } -/** 爆款视频任务详情 */ -export interface ViralVideoJob { - id: string - user_id: string - images: string[] - industry: string - target_customer: string - persona_id: string - viral_structure: string - marketing_purpose: string - bgm_preference: string - duration: number - user_copy_text: string - fusion_level: FusionLevel - reference_audio_path: string - reference_video_url: string - style_strength: StyleStrength - style_guide: Record | null - style_template_id: string - status: ViralVideoStatus - intent_result: IntentResult | null - result_video_url: string - credits_cost: number - error_msg: string - retry_count: number - started_at: string | null - completed_at: string | null - created_at: string | null - updated_at: string | null +export const STAGE_DISPLAYS: Record = { + image_analysis: { label: "图片分析", pct: 15 }, + video_analysis: { label: "参考视频风格分析", pct: 25 }, + intent_parsing: { label: "意图理解", pct: 35 }, + copy_fusion: { label: "文案融合创作", pct: 50 }, + storyboard: { label: "分镜生成", pct: 60 }, + review: { label: "AI审核", pct: 70 }, + tts: { label: "配音生成", pct: 78 }, + bgm_select: { label: "BGM匹配", pct: 85 }, + rendering: { label: "视频渲染", pct: 92 }, + musetalk: { label: "口型同步", pct: 97 }, + uploading: { label: "上传成片", pct: 100 }, } -/** AI 意图摘要卡片(intent_result 字段) */ -export interface IntentResult { - /** 核心卖点 */ - selling_points?: string[] - /** 目标人群 */ - target_audience?: string - /** 营销钩子 */ - hook?: string - /** 视频节奏/结构 */ - structure?: string - /** AI 生成的润色后文案 */ - ai_copy?: string - /** 视觉建议 */ - visual_notes?: string[] - /** 其它字段 */ - [key: string]: unknown -} - -/** 确认意图请求 */ -export interface ConfirmIntentRequest { - confirmed_copy?: string - adjustments?: string -} - -/** 风格模板 */ export interface StyleTemplate { id: string name: string - description: string - thumbnail_url: string - style_config: Record + description?: string + preview_url?: string + tags?: string[] } -export interface StyleTemplateListResponse { - items: StyleTemplate[] +export interface IntentResult { + product: string + selling_points: string[] + target_audience: string + tone: string + structure: string + duration: number + suggested_title?: string + suggested_copy?: string } -/** 风格分析响应 */ -export interface AnalyzeStyleResponse { - job_id: string - status: string - style_guide: Record | null +export interface ViralVideoJob { + id: string + status: ViralVideoStatus + images: string[] + reference_video_url?: string + style_strength?: StyleStrength + style_template_id?: string + style_guide?: string + user_copy_text?: string + final_copy_text?: string + fusion_level?: FusionLevel + voice_id?: string + voice_mode?: "global" | "per_video" + bgm_preference?: string + intent_result?: IntentResult + intent_text?: string + progress_stage?: ViralVideoStage + progress_percent?: number + progress_message?: string + output_url?: string + error_message?: string + credits_cost?: number + created_at?: string + updated_at?: string } -/** 历史记录列表响应 */ -export interface ViralVideoHistoryResponse { +export interface GenerateViralVideoRequest { + images: string[] + reference_video_url?: string + style_strength?: StyleStrength + style_template_id?: string + user_copy_text?: string + fusion_level?: FusionLevel + voice_id?: string + bgm_preference?: string + industry?: string + target_customer?: string + language?: string + persona_id?: string + viral_structure?: string + marketing_purpose?: string + duration?: number + video_model?: string + video_ratio?: string +} + +export interface HistoryResponse { items: ViralVideoJob[] total: number + page: number + page_size: number } - -/** WebSocket/Redis 推送的进度事件 */ -export interface WSProgressEvent { - type: "viral_video:progress" - job_id: string - stage: ViralVideoStage - progress: number - message: string - data: Record -} - -/** 前端阶段展示配置 */ -export interface StageDisplay { - key: ViralVideoStage - label: string - /** 该阶段在总进度条中的起始百分比 */ - startPct: number - /** 该阶段在总进度条中的结束百分比 */ - endPct: number -} - -export const STAGE_DISPLAYS: StageDisplay[] = [ - { key: "image_analysis", label: "图片分析", startPct: 0, endPct: 15 }, - { key: "video_analysis", label: "视频风格分析", startPct: 15, endPct: 25 }, - { key: "intent_parsing", label: "意图理解", startPct: 25, endPct: 35 }, - { key: "copy_fusion", label: "文案创作", startPct: 35, endPct: 50 }, - { key: "storyboard", label: "分镜脚本", startPct: 50, endPct: 60 }, - { key: "review", label: "合规审核", startPct: 60, endPct: 70 }, - { key: "tts", label: "配音生成", startPct: 70, endPct: 75 }, - { key: "bgm_select", label: "BGM 选择", startPct: 75, endPct: 78 }, - { key: "rendering", label: "视频渲染", startPct: 78, endPct: 88 }, - { key: "musetalk", label: "数字人口型", startPct: 88, endPct: 93 }, - { key: "uploading", label: "成片上传", startPct: 93, endPct: 100 }, -] diff --git a/apps/web/src/pages/viral-video/ViralVideoPage.css b/apps/web/src/pages/viral-video/ViralVideoPage.css index 11a9b01bc..d35bd5ac3 100644 --- a/apps/web/src/pages/viral-video/ViralVideoPage.css +++ b/apps/web/src/pages/viral-video/ViralVideoPage.css @@ -1,263 +1,392 @@ /* ============================================================ - 爆款视频创作页 - 暗色科技风(PRD §9) - 三栏布局: - .vv-shell 全屏 flex(在 MainLayout 内) - ├── .vv-history 左栏:历史记录(280px,可折叠) - ├── .vv-editor 中栏:素材+文案(1fr,minmax 480px) - └── .vv-preview 右栏:预览/进度/成片(420px) + 爆款视频创作页 - 浅色紫调(对齐 AI 数字人页视觉规范) + 布局(参考 ui-ref-step-layout.png 三列等宽 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-shell { - display: flex; - gap: 16px; - padding: 20px; - height: calc(100vh - 56px); - min-height: 640px; - background: linear-gradient(135deg, #0b0f1a 0%, #111827 50%, #0f172a 100%); - color: #e5e7eb; - overflow: hidden; +.vv-page { + padding: 16px; + background: #f5f6fa; + min-height: calc(100vh - 56px); } -/* ── 通用卡片样式 ────────────────────────────────────── */ -.vv-card { - background: rgba(17, 24, 39, 0.7); - border: 1px solid rgba(99, 102, 241, 0.2); +/* ── 顶部任务 Tab 栏 ─────────────────────────────────── */ +.vv-tabs { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 14px; + padding: 6px 8px; + background: #fff; + border-radius: 10px; + border: 1px solid #e5e7eb; + flex-wrap: wrap; +} +.vv-tab { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 6px; + font-size: 13px; + color: #6b7280; + cursor: pointer; + border: 1px solid transparent; + background: transparent; + transition: all 0.15s; +} +.vv-tab:hover { + background: #f3f4f6; + color: #374151; +} +.vv-tab.active { + background: #f3f0ff; + color: #7c3aed; + border-color: #d8cafc; + font-weight: 500; +} +.vv-tab .vv-tab-close { + width: 16px; + height: 16px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 12px; + color: #9ca3af; +} +.vv-tab .vv-tab-close:hover { + background: rgba(0, 0, 0, 0.08); + color: #374151; +} +.vv-tab-new { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 6px 10px; + border-radius: 6px; + font-size: 13px; + color: #7c3aed; + cursor: pointer; + border: 1px dashed #d8cafc; + background: transparent; +} +.vv-tab-new:hover { + background: #f3f0ff; +} +.vv-tabs-right { + margin-left: auto; + display: flex; + gap: 8px; +} + +/* ── 三列等宽网格 ───────────────────────────────────── */ +.vv-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; + align-items: start; +} +@media (max-width: 1280px) { + .vv-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} +@media (max-width: 900px) { + .vv-grid { + grid-template-columns: 1fr; + } +} + +.vv-col { + display: flex; + flex-direction: column; + gap: 14px; +} + +/* ── 可折叠模块(对齐 AI 数字人卡块) ────────────────── */ +.vv-section { + background: #fff; + border: 1px solid #e5e7eb; border-radius: 12px; - padding: 20px; - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - position: relative; overflow: hidden; } -.vv-card::before { - content: ""; - position: absolute; - inset: 0; - background: linear-gradient(135deg, rgba(99, 102, 241, 0.05), transparent 40%); - pointer-events: none; +.vv-section-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px; + cursor: pointer; + user-select: none; + border-bottom: 1px solid #f3f4f6; } -.vv-card-title { +.vv-section.collapsed .vv-section-head { + border-bottom: none; +} +.vv-section-title { display: flex; align-items: center; gap: 8px; - font-size: 14px; + font-size: 15px; font-weight: 600; - color: #c7d2fe; - margin-bottom: 14px; - letter-spacing: 0.5px; + color: #111827; } -.vv-card-title .vv-icon-dot { - width: 8px; - height: 8px; +.vv-section-title .vv-step-badge { + width: 22px; + height: 22px; border-radius: 50%; - background: #6366f1; - box-shadow: 0 0 8px rgba(99, 102, 241, 0.8); -} - -/* ── 左栏:历史记录 ─────────────────────────────────── */ -.vv-history { - width: 280px; - flex-shrink: 0; - display: flex; - flex-direction: column; - gap: 12px; - overflow: hidden; -} -.vv-history-scroll { - flex: 1; - overflow-y: auto; - padding-right: 4px; -} -.vv-history-scroll::-webkit-scrollbar { - width: 4px; -} -.vv-history-scroll::-webkit-scrollbar-thumb { - background: rgba(99, 102, 241, 0.3); - border-radius: 2px; -} -.vv-history-item { - display: flex; - gap: 10px; - padding: 10px; - border-radius: 8px; - cursor: pointer; - transition: background 0.15s; - margin-bottom: 6px; - border: 1px solid transparent; -} -.vv-history-item:hover { - background: rgba(99, 102, 241, 0.1); - border-color: rgba(99, 102, 241, 0.2); -} -.vv-history-item.active { - background: rgba(99, 102, 241, 0.15); - border-color: rgba(99, 102, 241, 0.4); -} -.vv-history-thumb { - width: 56px; - height: 56px; - border-radius: 6px; - background: #1f2937; - object-fit: cover; - flex-shrink: 0; -} -.vv-history-meta { - flex: 1; - min-width: 0; -} -.vv-history-title { - font-size: 13px; - color: #e5e7eb; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - margin-bottom: 4px; -} -.vv-history-time { - font-size: 11px; - color: #6b7280; -} -.vv-history-status { - font-size: 11px; - margin-top: 2px; -} -.vv-history-status.running { - color: #60a5fa; -} -.vv-history-status.completed { - color: #34d399; -} -.vv-history-status.failed { - color: #f87171; -} -.vv-history-status.wait_user_confirm { - color: #fbbf24; -} -.vv-history-status.queued { - color: #a78bfa; -} -.vv-new-btn { - width: 100%; - padding: 10px; - background: linear-gradient(135deg, #6366f1, #8b5cf6); - color: white; - border: none; - border-radius: 8px; - font-size: 13px; + background: #7c3aed; + color: #fff; + font-size: 12px; + display: inline-flex; + align-items: center; + justify-content: center; font-weight: 600; - cursor: pointer; +} +.vv-section-arrow { + color: #9ca3af; + font-size: 12px; + transition: transform 0.2s; +} +.vv-section.collapsed .vv-section-arrow { + transform: rotate(-90deg); +} +.vv-section-body { + padding: 14px 16px 16px; +} +.vv-section.collapsed .vv-section-body { + display: none; +} + +/* ── 通用表单元素(对齐 AI 数字人样式) ─────────────── */ +.vv-label { + display: block; + font-size: 12px; + color: #6b7280; + margin-bottom: 6px; +} +.vv-input, +.vv-select, +.vv-textarea { + width: 100%; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 9px 12px; + font-size: 13px; + color: #111827; + background: #fff; + outline: none; transition: - transform 0.15s, + border-color 0.15s, box-shadow 0.15s; + font-family: inherit; } -.vv-new-btn:hover { - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4); +.vv-textarea { + line-height: 1.6; + resize: vertical; + min-height: 90px; +} +.vv-input:focus, +.vv-select:focus, +.vv-textarea:focus { + border-color: #7c3aed; + box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.1); +} +.vv-input::placeholder, +.vv-textarea::placeholder { + color: #d1d5db; +} +.vv-form-row { + margin-bottom: 12px; +} +.vv-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; } -/* ── 中栏:编辑器 ─────────────────────────────────── */ -.vv-editor { - flex: 1; - min-width: 480px; +/* ── Tab 分段(对齐"系统预设/我的音色"样式) ────────── */ +.vv-seg-tabs { display: flex; - flex-direction: column; - gap: 16px; - overflow-y: auto; - padding-right: 4px; + gap: 8px; + margin-bottom: 12px; + border-radius: 8px; + padding: 3px; + background: #f5f6fa; } -.vv-editor::-webkit-scrollbar { - width: 6px; +.vv-seg-tab { + flex: 1; + padding: 7px 10px; + font-size: 13px; + text-align: center; + border-radius: 6px; + cursor: pointer; + color: #6b7280; + background: transparent; + border: 1px solid transparent; + transition: all 0.15s; + font-family: inherit; } -.vv-editor::-webkit-scrollbar-thumb { - background: rgba(99, 102, 241, 0.25); - border-radius: 3px; +.vv-seg-tab:hover { + color: #374151; +} +.vv-seg-tab.active { + background: #fff; + color: #7c3aed; + border-color: #7c3aed; + font-weight: 500; + box-shadow: 0 1px 2px rgba(124, 58, 237, 0.06); } -/* 图片上传区 */ -.vv-uploader { - border: 2px dashed rgba(99, 102, 241, 0.3); +/* 融合强度大按钮(选中紫色描边+浅紫底) */ +.vv-fusion-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + margin-bottom: 12px; +} +.vv-fusion-btn { + padding: 10px 8px; + border: 1px solid #e5e7eb; + background: #fff; + border-radius: 8px; + font-size: 12px; + cursor: pointer; + color: #6b7280; + text-align: center; + line-height: 1.4; + transition: all 0.15s; + font-family: inherit; +} +.vv-fusion-btn strong { + display: block; + font-size: 13px; + color: #111827; + margin-bottom: 2px; + font-weight: 600; +} +.vv-fusion-btn:hover { + border-color: #d8cafc; +} +.vv-fusion-btn.active { + background: #f3f0ff; + border-color: #7c3aed; + color: #7c3aed; +} +.vv-fusion-btn.active strong { + color: #7c3aed; +} + +/* 风格强度小分段按钮(三档,参考配音风格按钮) */ +.vv-pill-row { + display: flex; + gap: 6px; + flex-wrap: wrap; +} +.vv-pill { + padding: 6px 12px; + border: 1px solid #e5e7eb; + background: #fff; + border-radius: 6px; + font-size: 12px; + color: #6b7280; + cursor: pointer; + font-family: inherit; + transition: all 0.15s; +} +.vv-pill:hover { + border-color: #d8cafc; + color: #7c3aed; +} +.vv-pill.active { + background: #f3f0ff; + border-color: #7c3aed; + color: #7c3aed; + font-weight: 500; +} + +/* ── 上传区(浅灰虚线框) ───────────────────────────── */ +.vv-upload { + border: 1.5px dashed #d1d5db; border-radius: 10px; - padding: 24px; + padding: 20px; text-align: center; cursor: pointer; transition: border-color 0.2s, background 0.2s; - background: rgba(99, 102, 241, 0.03); - min-height: 140px; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; + background: #fafbfc; color: #9ca3af; } -.vv-uploader:hover, -.vv-uploader.dragover { - border-color: #6366f1; - background: rgba(99, 102, 241, 0.08); - color: #c7d2fe; +.vv-upload:hover, +.vv-upload.dragover { + border-color: #7c3aed; + background: #f9f7ff; + color: #7c3aed; } -.vv-uploader-icon { - font-size: 32px; - margin-bottom: 8px; - color: #6366f1; +.vv-upload-icon { + font-size: 28px; + margin-bottom: 6px; } -.vv-uploader-hint { - font-size: 13px; - margin-top: 6px; -} -.vv-uploader-hint small { - color: #6b7280; - font-size: 11px; +.vv-upload small { display: block; + font-size: 11px; + color: #9ca3af; margin-top: 4px; } -.vv-image-grid { + +/* 图片网格 */ +.vv-img-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); - gap: 10px; - margin-top: 14px; + grid-template-columns: repeat(auto-fill, minmax(90px, 1fr)); + gap: 8px; + margin-top: 12px; } -.vv-image-item { +.vv-img-item { position: relative; aspect-ratio: 1; border-radius: 8px; overflow: hidden; - border: 1px solid rgba(99, 102, 241, 0.3); + border: 1px solid #e5e7eb; cursor: grab; + background: #f5f6fa; } -.vv-image-item.dragging { +.vv-img-item.dragging { opacity: 0.4; - cursor: grabbing; } -.vv-image-item img { +.vv-img-item img { width: 100%; height: 100%; object-fit: cover; } -.vv-image-item .vv-index-badge { +.vv-img-badge { position: absolute; top: 4px; left: 4px; - background: rgba(99, 102, 241, 0.9); - color: white; + background: rgba(124, 58, 237, 0.9); + color: #fff; font-size: 11px; font-weight: 600; - padding: 2px 6px; + padding: 1px 6px; border-radius: 4px; } -.vv-image-item .vv-del-btn { +.vv-img-del { position: absolute; top: 4px; right: 4px; - width: 22px; - height: 22px; + width: 20px; + height: 20px; border-radius: 50%; background: rgba(239, 68, 68, 0.9); - color: white; + color: #fff; border: none; cursor: pointer; - font-size: 14px; + font-size: 12px; line-height: 1; display: flex; align-items: center; @@ -265,512 +394,449 @@ opacity: 0; transition: opacity 0.15s; } -.vv-image-item:hover .vv-del-btn { +.vv-img-item:hover .vv-img-del { opacity: 1; } -.vv-image-item .vv-add-more { +.vv-img-add { width: 100%; height: 100%; - border: 2px dashed rgba(99, 102, 241, 0.4); + border: 1.5px dashed #d1d5db; border-radius: 8px; display: flex; flex-direction: column; align-items: center; justify-content: center; - color: #6366f1; + 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; + overflow: hidden; + background: #000; + margin-top: 10px; + border: 1px solid #e5e7eb; +} +.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; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: #9ca3af; + cursor: pointer; + background: #fafbfc; + font-size: 12px; + gap: 4px; + margin-top: 10px; + transition: all 0.15s; +} +.vv-video-ph:hover { + border-color: #7c3aed; + color: #7c3aed; + background: #f9f7ff; +} + +/* ── 音色列表(参考 AI 数字人「龙小淳」卡片) ──────── */ +.vv-voice-tabs { + margin-bottom: 10px; +} +.vv-voice-list { + max-height: 280px; + overflow-y: auto; + border: 1px solid #f3f4f6; + border-radius: 8px; +} +.vv-voice-list::-webkit-scrollbar { + width: 6px; +} +.vv-voice-list::-webkit-scrollbar-thumb { + background: #e5e7eb; + border-radius: 3px; +} +.vv-voice-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-bottom: 1px solid #f3f4f6; cursor: pointer; - background: rgba(99, 102, 241, 0.05); transition: background 0.15s; } -.vv-image-item .vv-add-more:hover { - background: rgba(99, 102, 241, 0.15); +.vv-voice-item:last-child { + border-bottom: none; } - -/* 文案输入 */ -.vv-textarea { - width: 100%; - min-height: 120px; - background: rgba(15, 23, 42, 0.6); - border: 1px solid rgba(99, 102, 241, 0.25); - border-radius: 8px; - padding: 12px; - color: #e5e7eb; +.vv-voice-item:hover { + background: #f9fafb; +} +.vv-voice-item.selected { + background: #f3f0ff; +} +.vv-voice-radio { + width: 16px; + height: 16px; + border-radius: 50%; + border: 2px solid #d1d5db; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; +} +.vv-voice-item.selected .vv-voice-radio { + border-color: #7c3aed; +} +.vv-voice-item.selected .vv-voice-radio::after { + content: ""; + width: 8px; + height: 8px; + border-radius: 50%; + background: #7c3aed; +} +.vv-voice-info { + flex: 1; + min-width: 0; +} +.vv-voice-name { font-size: 13px; - line-height: 1.6; - resize: vertical; - font-family: inherit; - outline: none; - transition: border-color 0.15s; + color: #111827; + font-weight: 500; } -.vv-textarea:focus { - border-color: #6366f1; - box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.15); -} -.vv-textarea::placeholder { - color: #4b5563; -} - -.vv-input { - width: 100%; - background: rgba(15, 23, 42, 0.6); - border: 1px solid rgba(99, 102, 241, 0.25); - border-radius: 8px; - padding: 9px 12px; - color: #e5e7eb; - font-size: 13px; - outline: none; - transition: border-color 0.15s; -} -.vv-input:focus { - border-color: #6366f1; - box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.15); -} -.vv-input::placeholder { - color: #4b5563; -} -.vv-label { - display: block; - font-size: 12px; +.vv-voice-desc { + font-size: 11px; color: #9ca3af; - margin-bottom: 6px; - letter-spacing: 0.3px; + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } - -/* 融合强度 / 风格强度 分段按钮 */ -.vv-segment { - display: grid; - gap: 8px; -} -.vv-segment.cols-3 { - grid-template-columns: repeat(3, 1fr); -} -.vv-segment-btn { - padding: 10px 8px; - background: rgba(15, 23, 42, 0.6); - border: 1px solid rgba(99, 102, 241, 0.25); - border-radius: 8px; - color: #9ca3af; - font-size: 12px; +.vv-voice-play { + width: 28px; + height: 28px; + border-radius: 50%; + border: 1px solid #e5e7eb; + background: #fff; + color: #6b7280; cursor: pointer; - text-align: center; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 12px; + flex-shrink: 0; transition: all 0.15s; - line-height: 1.4; } -.vv-segment-btn strong { - display: block; - font-size: 13px; - color: #e5e7eb; - margin-bottom: 2px; +.vv-voice-play:hover { + border-color: #7c3aed; + color: #7c3aed; } -.vv-segment-btn:hover { - border-color: rgba(99, 102, 241, 0.5); - color: #c7d2fe; -} -.vv-segment-btn.active { - background: linear-gradient(135deg, rgba(99, 102, 241, 0.25), rgba(139, 92, 246, 0.2)); - border-color: #6366f1; - color: #e0e7ff; - box-shadow: 0 0 12px rgba(99, 102, 241, 0.3); -} -.vv-segment-btn.active strong { +.vv-voice-play.playing { + background: #7c3aed; + border-color: #7c3aed; color: #fff; } -/* 表单小项两列 */ -.vv-form-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 12px; -} - -/* v1.3 参考视频 */ -.vv-ref-video { - border: 1px dashed rgba(139, 92, 246, 0.4); - border-radius: 8px; +/* ── AI 摘要确认卡(黄色高亮) ─────────────────────── */ +.vv-intent { + background: #fffbeb; + border: 1px solid #fcd34d; + border-radius: 10px; padding: 14px; - background: rgba(139, 92, 246, 0.04); -} -.vv-ref-video-preview { - position: relative; - width: 100%; - aspect-ratio: 16/9; - border-radius: 6px; - overflow: hidden; - background: #0f172a; - margin-top: 10px; -} -.vv-ref-video-preview video { - width: 100%; - height: 100%; - object-fit: cover; -} -.vv-ref-video-placeholder { - width: 100%; - aspect-ratio: 16/9; - border: 2px dashed rgba(139, 92, 246, 0.4); - border-radius: 6px; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - color: #a78bfa; - cursor: pointer; - transition: background 0.15s; - margin-top: 10px; -} -.vv-ref-video-placeholder:hover { - background: rgba(139, 92, 246, 0.08); -} - -/* 风格模板 */ -.vv-templates { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); - gap: 10px; - margin-top: 10px; -} -.vv-template-card { - border: 1px solid rgba(99, 102, 241, 0.25); - border-radius: 8px; - overflow: hidden; - cursor: pointer; - background: rgba(15, 23, 42, 0.6); - transition: all 0.15s; -} -.vv-template-card:hover { - border-color: rgba(99, 102, 241, 0.6); -} -.vv-template-card.selected { - border-color: #8b5cf6; - box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.3); -} -.vv-template-thumb { - width: 100%; - aspect-ratio: 16/9; - object-fit: cover; - background: #1f2937; -} -.vv-template-meta { - padding: 8px; -} -.vv-template-name { - font-size: 12px; - color: #e5e7eb; - font-weight: 500; -} -.vv-template-desc { - font-size: 11px; - color: #6b7280; - margin-top: 2px; - line-height: 1.4; -} - -/* AI 摘要确认卡片 */ -.vv-intent-card { - background: linear-gradient(135deg, rgba(251, 191, 36, 0.1), rgba(99, 102, 241, 0.1)); - border: 1px solid rgba(251, 191, 36, 0.4); - border-radius: 12px; - padding: 18px; - margin-top: 14px; + margin-top: 12px; } .vv-intent-title { display: flex; align-items: center; - gap: 8px; - font-size: 14px; + gap: 6px; + font-size: 13px; font-weight: 600; - color: #fbbf24; - margin-bottom: 12px; + color: #b45309; + margin-bottom: 10px; } .vv-intent-row { - margin-bottom: 10px; + margin-bottom: 8px; font-size: 13px; line-height: 1.6; } .vv-intent-row .vv-k { - color: #9ca3af; font-size: 12px; + color: #92400e; margin-bottom: 3px; } .vv-intent-row .vv-v { - color: #e5e7eb; -} -.vv-intent-actions { - display: flex; - gap: 10px; - margin-top: 14px; + color: #111827; } .vv-chip { display: inline-block; - padding: 3px 8px; - background: rgba(99, 102, 241, 0.2); + padding: 2px 8px; + background: #fef3c7; border-radius: 4px; font-size: 11px; - color: #c7d2fe; - margin-right: 6px; - margin-bottom: 4px; + color: #92400e; + margin-right: 4px; + margin-bottom: 3px; } -/* 按钮 */ -.vv-btn { - padding: 9px 18px; - border-radius: 8px; - font-size: 13px; - font-weight: 500; - cursor: pointer; - border: 1px solid transparent; - transition: all 0.15s; - display: inline-flex; - align-items: center; - gap: 6px; -} -.vv-btn-primary { - background: linear-gradient(135deg, #6366f1, #8b5cf6); - color: white; -} -.vv-btn-primary:hover { - box-shadow: 0 4px 14px rgba(99, 102, 241, 0.4); - transform: translateY(-1px); -} -.vv-btn-primary:disabled { - opacity: 0.5; - cursor: not-allowed; - transform: none; - box-shadow: none; -} -.vv-btn-ghost { - background: transparent; - color: #c7d2fe; - border-color: rgba(99, 102, 241, 0.4); -} -.vv-btn-ghost:hover { - background: rgba(99, 102, 241, 0.1); - border-color: #6366f1; -} -.vv-btn-warn { - background: rgba(251, 191, 36, 0.15); - color: #fbbf24; - border-color: rgba(251, 191, 36, 0.4); -} -.vv-btn-warn:hover { - background: rgba(251, 191, 36, 0.25); -} -.vv-btn-danger { - background: rgba(239, 68, 68, 0.15); - color: #f87171; - border-color: rgba(239, 68, 68, 0.4); -} -.vv-btn-danger:hover { - background: rgba(239, 68, 68, 0.25); -} - -/* ── 右栏:预览 / 进度 / 结果 ──────────────────────── */ +/* ── 9:16 预览区 ────────────────────────────────────── */ .vv-preview { - width: 420px; - flex-shrink: 0; - display: flex; - flex-direction: column; - gap: 16px; - overflow-y: auto; - padding-right: 4px; -} -.vv-preview::-webkit-scrollbar { - width: 6px; -} -.vv-preview::-webkit-scrollbar-thumb { - background: rgba(99, 102, 241, 0.25); - border-radius: 3px; -} - -/* 主预览 */ -.vv-main-preview { + width: 100%; aspect-ratio: 9/16; + max-height: 480px; border-radius: 12px; - background: #0f172a; - border: 1px solid rgba(99, 102, 241, 0.3); + background: #fff; + border: 1.5px dashed #d1d5db; overflow: hidden; position: relative; display: flex; align-items: center; justify-content: center; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); } -.vv-main-preview video { +.vv-preview video { width: 100%; height: 100%; object-fit: contain; background: #000; } -.vv-preview-empty { +.vv-preview-placeholder { text-align: center; - color: #4b5563; + color: #9ca3af; padding: 20px; } -.vv-preview-empty-icon { - font-size: 48px; - margin-bottom: 12px; - opacity: 0.3; +.vv-preview-placeholder .ph-icon { + font-size: 40px; + opacity: 0.4; + margin-bottom: 8px; } - -/* 进度条 */ -.vv-progress { - margin-top: 8px; -} -.vv-progress-bar { - width: 100%; - height: 6px; - background: rgba(99, 102, 241, 0.15); - border-radius: 3px; - overflow: hidden; - position: relative; -} -.vv-progress-fill { - height: 100%; - background: linear-gradient(90deg, #6366f1, #8b5cf6, #ec4899); - border-radius: 3px; - transition: width 0.4s ease; - position: relative; -} -.vv-progress-fill::after { - content: ""; - position: absolute; - inset: 0; - background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent); - animation: vv-shimmer 1.5s infinite; -} -@keyframes vv-shimmer { - 0% { - transform: translateX(-100%); - } - 100% { - transform: translateX(100%); - } -} -.vv-progress-meta { - display: flex; - justify-content: space-between; +.vv-preview-placeholder .ph-txt { font-size: 12px; - color: #9ca3af; - margin-top: 8px; -} -.vv-progress-pct { - color: #c7d2fe; - font-weight: 600; -} -.vv-progress-msg { - color: #6b7280; - font-size: 11px; - margin-top: 4px; - font-style: italic; } -/* 阶段列表 */ +/* ── 阶段进度列表 ──────────────────────────────────── */ .vv-stages { - margin-top: 14px; display: flex; flex-direction: column; - gap: 6px; + gap: 4px; + margin-top: 8px; } .vv-stage { display: flex; align-items: center; gap: 10px; font-size: 12px; - color: #6b7280; - padding: 6px 0; + color: #9ca3af; + padding: 5px 0; } .vv-stage-dot { - width: 18px; - height: 18px; + width: 16px; + height: 16px; border-radius: 50%; - border: 2px solid #374151; + border: 2px solid #e5e7eb; display: flex; align-items: center; justify-content: center; - font-size: 10px; + font-size: 9px; + background: #fff; flex-shrink: 0; - background: #0f172a; } .vv-stage.active { - color: #c7d2fe; + color: #7c3aed; } .vv-stage.active .vv-stage-dot { - border-color: #6366f1; - background: rgba(99, 102, 241, 0.2); - animation: vv-pulse 1.5s infinite; + border-color: #7c3aed; + background: #f3f0ff; } .vv-stage.done { - color: #34d399; + color: #10b981; } .vv-stage.done .vv-stage-dot { - border-color: #34d399; - background: #34d399; - color: #0f172a; + border-color: #10b981; + background: #10b981; + color: #fff; } .vv-stage.failed { - color: #f87171; + color: #ef4444; } .vv-stage.failed .vv-stage-dot { - border-color: #f87171; - background: #f87171; -} -@keyframes vv-pulse { - 0%, - 100% { - box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.5); - } - 50% { - box-shadow: 0 0 0 6px rgba(99, 102, 241, 0); - } + border-color: #ef4444; + background: #ef4444; + color: #fff; } -/* 错误提示 */ -.vv-error { - background: rgba(239, 68, 68, 0.1); - border: 1px solid rgba(239, 68, 68, 0.3); +.vv-progress-bar { + width: 100%; + height: 6px; + background: #f3f4f6; + border-radius: 3px; + overflow: hidden; + margin-top: 10px; +} +.vv-progress-fill { + height: 100%; + background: linear-gradient(90deg, #7c3aed, #a855f7); + 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-credits { + background: #f9fafb; + border: 1px solid #f3f4f6; border-radius: 8px; padding: 10px 12px; - color: #fca5a5; + font-size: 12px; + color: #6b7280; + display: flex; + justify-content: space-between; + align-items: center; +} +.vv-credits strong { + color: #7c3aed; + font-weight: 600; +} + +.vv-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 10px 16px; + border-radius: 8px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + border: 1px solid transparent; + transition: all 0.15s; + font-family: inherit; +} +.vv-btn-primary { + width: 100%; + background: linear-gradient(135deg, #7c3aed, #a855f7); + color: #fff; + padding: 12px; + font-size: 14px; + margin-top: 10px; +} +.vv-btn-primary:hover:not(:disabled) { + box-shadow: 0 4px 14px rgba(124, 58, 237, 0.3); + transform: translateY(-1px); +} +.vv-btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.vv-btn-ghost { + background: #fff; + color: #6b7280; + border-color: #e5e7eb; +} +.vv-btn-ghost:hover { + border-color: #7c3aed; + color: #7c3aed; +} +.vv-btn-warn { + background: #fef3c7; + color: #b45309; + border-color: #fcd34d; +} +.vv-btn-sm { + padding: 6px 12px; + font-size: 12px; +} + +.vv-error { + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 8px; + padding: 10px 12px; + color: #dc2626; font-size: 12px; margin-top: 10px; +} +.vv-spinner { + width: 14px; + height: 14px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-top-color: #fff; + border-radius: 50%; + animation: vvspin 0.8s linear infinite; + display: inline-block; +} +@keyframes vvspin { + to { + transform: rotate(360deg); + } +} + +.vv-muted { + color: #9ca3af; + font-size: 12px; +} +.vv-meta { display: flex; - align-items: flex-start; - gap: 8px; + justify-content: space-between; + font-size: 11px; + color: #9ca3af; + margin-top: 4px; } - -/* 结果操作区 */ -.vv-result-actions { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 8px; - margin-top: 12px; +.vv-file-name { + font-size: 12px; + color: #374151; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 8px; } - -/* 响应式:窄屏折叠历史栏 */ -@media (max-width: 1200px) { - .vv-history { - width: 220px; - } - .vv-preview { - width: 360px; - } +.vv-link-btn { + background: none; + border: none; + color: #7c3aed; + font-size: 12px; + cursor: pointer; + padding: 0; + font-family: inherit; } -@media (max-width: 1024px) { - .vv-shell { - flex-direction: column; - height: auto; - overflow: visible; - } - .vv-history, - .vv-preview { - width: 100%; - } - .vv-editor { - min-width: 0; - } +.vv-link-btn:hover { + text-decoration: underline; } - -/* 通用小工具 */ .vv-flex { display: flex; } @@ -781,33 +847,9 @@ .vv-gap-8 { gap: 8px; } -.vv-gap-12 { - gap: 12px; -} .vv-mt-8 { margin-top: 8px; } .vv-mt-12 { margin-top: 12px; } -.vv-mt-16 { - margin-top: 16px; -} -.vv-muted { - color: #6b7280; - font-size: 12px; -} -.vv-spinner { - width: 14px; - height: 14px; - border: 2px solid rgba(99, 102, 241, 0.3); - border-top-color: #6366f1; - border-radius: 50%; - animation: vv-spin 0.8s linear infinite; - display: inline-block; -} -@keyframes vv-spin { - to { - transform: rotate(360deg); - } -} diff --git a/apps/web/src/pages/viral-video/ViralVideoPage.tsx b/apps/web/src/pages/viral-video/ViralVideoPage.tsx index 8f30047cd..12529b1a5 100644 --- a/apps/web/src/pages/viral-video/ViralVideoPage.tsx +++ b/apps/web/src/pages/viral-video/ViralVideoPage.tsx @@ -1,938 +1,1112 @@ -/** - * 爆款视频创作页(工单 #2041,PRD v1.4 第八章 / 第九章 / v1.3 参考视频扩展) - * - * 三栏暗色科技风布局: - * ┌──────────┬────────────────────────┬─────────────┐ - * │ 历史记录 │ 中栏:素材/文案/参数 │ 预览/进度/成片│ - * └──────────┴────────────────────────┴─────────────┘ - */ -import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" import { - CloudUploadOutlined, - DeleteOutlined, - DownloadOutlined, - HistoryOutlined, PlusOutlined, - ReloadOutlined, - ThunderboltOutlined, + CloseOutlined, + UploadOutlined, + PlayCircleOutlined, + PauseCircleOutlined, + CaretDownOutlined, VideoCameraOutlined, + SoundOutlined, + CheckOutlined, + ReloadOutlined, + WarningFilled, + BulbFilled, + AudioOutlined, } from "@ant-design/icons" -import { message } from "antd" +import { Button as AntdButton, message, Select } from "antd" +import { uploadAssetDirect, getAssetLibraries, getAssetsByKind } from "@/api/assets" import { - confirmViralIntent, - createViralVideoJob, - listStyleTemplates, - listViralVideoHistory, - retryViralVideoJob, - uploadViralAsset, -} from "@/api/viral-video" -import type { - CreateViralVideoRequest, - FusionLevel, - IntentResult, - StyleStrength, - StyleTemplate, - ViralVideoJob, + FUSION_LEVELS, + STYLE_STRENGTHS, + STAGE_DISPLAYS, + type FusionLevel, + type StyleStrength, + type ViralVideoJob, + type ViralVideoStage, } from "@/api/viral-video/types" -import { useViralVideoPolling, VIRAL_VIDEO_STAGES } from "./hooks/useViralVideoPolling" +import { generateViralVideo, confirmViralVideoIntent, retryViralVideo } from "@/api/viral-video" +import { useViralVideoPolling } from "./hooks/useViralVideoPolling" +import CloneModal from "@/components/voice/CloneModal" import "./ViralVideoPage.css" -const FUSION_OPTIONS: { value: FusionLevel; title: string; desc: string }[] = [ - { value: "ai_full", title: "AI 全写", desc: "只上传产品图,文案完全由 AI 创作" }, - { value: "ai_polish", title: "我打草稿 AI 润色", desc: "给要点/口语,AI 润色成爆款文案" }, - { value: "user_primary", title: "按我的话来", desc: "严格保留文案原意,仅调整节奏断句" }, -] +/* ─────────── 常量与类型 ─────────── */ -const STYLE_STRENGTH_OPTIONS: { value: StyleStrength; title: string; desc: string }[] = [ - { value: "light", title: "轻度参考", desc: "只借鉴节奏,保留我产品的风格" }, - { value: "medium", title: "中度模仿", desc: "参考镜头语言和节奏,内容适配产品" }, - { value: "strict", title: "像素级复刻", desc: "严格还原参考视频的转场和镜头" }, -] - -const DURATION_OPTIONS = [15, 30, 45, 60, 90] - -type PageView = "editor" | "result" - -const formatTime = (iso?: string | null) => { - if (!iso) return "" - try { - const d = new Date(iso) - const pad = (n: number) => String(n).padStart(2, "0") - return `${d.getMonth() + 1}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}` - } catch { - return "" +type TabTask = { + id: string + title: string + // 编辑态 + images: { + localId: string + url: string + name: string + uploading: boolean + pct: number + ossUrl?: string + }[] + refVideo: { url: string; name: string; uploading: boolean; pct: number; ossUrl?: string } | null + fusionLevel: FusionLevel + styleStrength: StyleStrength + styleTemplateId: string + voiceId: string + userCopy: string + industry: string + targetCustomer: string + language: string + viralStructure: string + marketingPurpose: string + duration: number + bgm: string + 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 } + // 运行态 + job: ViralVideoJob | null + jobId: string | null + playingVoiceId: string | null + audioRef: HTMLAudioElement | null } +const INDUSTRIES = [ + "美妆个护", + "服饰鞋包", + "数码3C", + "食品饮料", + "家居日用", + "母婴亲子", + "教育培训", + "其他", +] +const LANGUAGES = ["中文(普通话)", "粤语", "英语", "日语", "韩语"] +const STRUCTURES = [ + "痛点开场→卖点→促单", + "反差对比→证据→促单", + "故事讲述→共鸣→促单", + "专家背书→数据→促单", +] +const PURPOSES = ["品牌种草", "新品上市", "促销转化", "活动引流"] +const BGMS = ["无BGM", "欢快活泼", "温柔治愈", "励志燃向", "搞笑魔性", "科技感"] +const RATIOS = [ + { v: "9:16", label: "9:16 竖屏(抖音/视频号)" }, + { v: "16:9", label: "16:9 横屏(B站/YouTube)" }, + { v: "1:1", label: "1:1 方形(小红书)" }, +] +const MODELS = [ + { v: "seedance-2.5", label: "Seedance 2.5(推荐)" }, + { v: "seedance-2.0", label: "Seedance 2.0" }, +] + +const emptyTask = (id: string, title: string): TabTask => ({ + id, + title, + images: [], + refVideo: null, + fusionLevel: "full_ai", + styleStrength: "medium", + styleTemplateId: "", + voiceId: "", + userCopy: "", + industry: "", + targetCustomer: "", + language: "中文(普通话)", + viralStructure: STRUCTURES[0], + marketingPurpose: PURPOSES[0], + duration: 30, + bgm: BGMS[0], + 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, + }, + job: null, + jobId: null, + playingVoiceId: null, + audioRef: null, +}) + +/* ─────────── 折叠 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 = () => { - /* ── 表单 state ── */ - const [images, setImages] = useState<{ url: string; file?: File; localUrl?: string }[]>([]) - const [userCopyText, setUserCopyText] = useState("") - const [fusionLevel, setFusionLevel] = useState("ai_polish") - const [industry, setIndustry] = useState("") - const [targetCustomer, setTargetCustomer] = useState("") - const [viralStructure, setViralStructure] = useState("") - const [marketingPurpose, setMarketingPurpose] = useState("") - const [bgmPreference, setBgmPreference] = useState("") - const [duration, setDuration] = useState(30) - // v1.3 - const [referenceVideoUrl, setReferenceVideoUrl] = useState("") - const [referenceVideoFile, setReferenceVideoFile] = useState(null) - const [referenceVideoLocalUrl, setReferenceVideoLocalUrl] = useState("") - const [styleStrength, setStyleStrength] = useState("medium") - const [styleTemplateId, setStyleTemplateId] = useState("") - const [styleTemplates, setStyleTemplates] = useState([]) - // 生成流程 + 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 [submitting, setSubmitting] = useState(false) - const [view, setView] = useState("editor") - const [confirmedCopyText, setConfirmedCopyText] = useState("") - const [confirming, setConfirming] = useState(false) - const [history, setHistory] = useState([]) - const [activeHistoryId, setActiveHistoryId] = useState("") - const [loadingHistory, setLoadingHistory] = useState(false) + const [confirmingIntent, setConfirmingIntent] = useState(false) + const dragImgRef = useRef(null) + const dragVideoRef = useRef(null) + const dragFromIdxRef = useRef(-1) + const audioRef = useRef(null) - const imageInputRef = useRef(null) - const videoInputRef = useRef(null) - const [dragOver, setDragOver] = useState(false) - const [dragIdx, setDragIdx] = useState(-1) - - /* ── 轮询 ── */ - const { - job, - loading: polling, - error: pollingError, - stage, - overallProgress, - stageMessage, - startPolling, - stopPolling, - } = useViralVideoPolling({ - onComplete: () => { - setSubmitting(false) - setView("result") - message.success("视频生成完成!") - loadHistory() + const task = tasks.find((t) => t.id === activeId) || tasks[0] + const setTask = useCallback( + (patch: Partial | ((t: TabTask) => TabTask)) => { + setTasks((ts) => + ts.map((t) => + t.id === activeId ? (typeof patch === "function" ? patch(t) : { ...t, ...patch }) : t, + ), + ) }, - onWaitConfirm: (j) => { - setSubmitting(false) - setConfirmedCopyText(j.intent_result?.ai_copy || j.user_copy_text || "") - message.info("AI 已完成意图理解,请确认文案后继续") - loadHistory() + [activeId], + ) + const toggleCollapse = useCallback( + (key: keyof TabTask["collapsed"]) => { + setTask((t) => ({ ...t, collapsed: { ...t.collapsed, [key]: !t.collapsed[key] } })) }, - onFailed: (msg) => { - setSubmitting(false) - message.error(msg) - loadHistory() - }, - }) - - /* ── 历史记录 ── */ - const loadHistory = useCallback(async () => { - setLoadingHistory(true) - try { - const resp = await listViralVideoHistory(30, 0) - setHistory(resp.items) - } catch { - // 静默失败 - } finally { - setLoadingHistory(false) - } - }, []) - - useEffect(() => { - loadHistory() - listStyleTemplates() - .then((r) => setStyleTemplates(r.items)) - .catch(() => {}) - }, [loadHistory]) - - /* ── 图片上传 ── */ - const handleAddImages = useCallback( - async (files: FileList | File[]) => { - const arr = Array.from(files).filter((f) => f.type.startsWith("image/")) - if (!arr.length) return - if (images.length + arr.length > 9) { - message.warning("最多上传 9 张产品图") - } - const toAdd = arr.slice(0, Math.max(0, 9 - images.length)) - const hide = message.loading(`上传 ${toAdd.length} 张图片…`, 0) - try { - const uploaded = await Promise.all( - toAdd.map(async (f) => { - const localUrl = URL.createObjectURL(f) - try { - const url = await uploadViralAsset(f, "image") - return { url, file: f, localUrl } - } catch { - return { url: localUrl, file: f, localUrl } - } - }), - ) - setImages((prev) => [...prev, ...uploaded]) - } finally { - hide() - } - }, - [images.length], + [setTask], ) - const handleImageInput = (e: React.ChangeEvent) => { - if (e.target.files) handleAddImages(e.target.files) - e.target.value = "" - } + /* ── 加载音色素材 ── */ + useEffect(() => { + 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(() => {}) + }, []) - const handleDrop = (e: React.DragEvent) => { + /* ── 轮询 ── */ + const onPollUpdate = useCallback( + (job: ViralVideoJob) => { + setTask((t) => { + // 首次拿到 wait_user_confirm 时,初始化 intent 编辑文本 + const prevIntent = t.job?.status + const newIntentEdit = + prevIntent !== "wait_user_confirm" && job.status === "wait_user_confirm" + ? job.intent_result?.suggested_copy || t.userCopy + : t.intentEditText + return { ...t, job, intentEditText: newIntentEdit } + }) + }, + [setTask], + ) + useViralVideoPolling(task.jobId, onPollUpdate) + + /* ── 工具函数:上传文件到 OSS ── */ + const uploadFile = useCallback( + async ( + file: File, + kind: "image" | "video" | "voice", + onProgress?: (pct: number) => Promise | void, + ) => { + const libs = await getAssetLibraries() + const lib = libs.find((l: { kind: string; id: string }) => l.kind === kind) || libs[0] + if (!lib) throw new Error("未找到素材库") + const res = await uploadAssetDirect({ + file, + library_id: lib.id, + onProgress: (p: number) => onProgress?.(Math.round(p * 100)), + }) + return res.url + }, + [], + ) + + /* ── 上传图片 ── */ + const handleImageFiles = useCallback( + async (files: FileList | File[]) => { + const arr = Array.from(files).slice(0, 9 - task.images.length) + if (arr.length === 0) return + const newImgs = arr.map((f) => ({ + localId: `${Date.now()}_${Math.random().toString(36).slice(2, 7)}`, + url: URL.createObjectURL(f), + name: f.name, + uploading: true, + pct: 0, + file: f, + })) + setTask((t) => ({ + ...t, + images: [ + ...t.images, + ...newImgs.map(({ file: _f, ...rest }) => rest as TabTask["images"][number]), + ], + })) + + for (const item of newImgs) { + try { + const url = await uploadFile(item.file, "image", (p) => { + setTask((t) => ({ + ...t, + images: t.images.map((im) => (im.localId === item.localId ? { ...im, pct: p } : im)), + })) + }) + setTask((t) => ({ + ...t, + images: t.images.map((im) => + im.localId === item.localId ? { ...im, uploading: false, pct: 100, ossUrl: url } : im, + ), + })) + } catch (err) { + message.error(`${item.name} 上传失败`) + setTask((t) => ({ ...t, images: t.images.filter((im) => im.localId !== item.localId) })) + } + } + }, + [task.images.length, setTask, uploadFile], + ) + + const onDropImg = useCallback( + (e: React.DragEvent) => { + e.preventDefault() + if (e.dataTransfer.files?.length) handleImageFiles(e.dataTransfer.files) + }, + [handleImageFiles], + ) + + 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) } + }) + }, + [setTask], + ) + + const onImgDragStart = (idx: number) => { + dragFromIdxRef.current = idx + } + const onImgDragOver = (e: React.DragEvent) => { e.preventDefault() - setDragOver(false) - const files = e.dataTransfer.files - if (files?.length) handleAddImages(files) + } + const onImgDrop = (idx: number) => { + const from = dragFromIdxRef.current + if (from < 0 || from === idx) return + setTask((t) => { + const imgs = [...t.images] + const [m] = imgs.splice(from, 1) + imgs.splice(idx, 0, m) + return { ...t, images: imgs } + }) + dragFromIdxRef.current = -1 } - const handleRemoveImage = (idx: number) => { - setImages((prev) => { - const next = [...prev] - if (next[idx].localUrl) URL.revokeObjectURL(next[idx].localUrl!) - next.splice(idx, 1) + /* ── 上传参考视频 ── */ + const handleRefVideo = useCallback( + async (file: File) => { + setTask({ + refVideo: { url: URL.createObjectURL(file), name: file.name, uploading: true, pct: 0 }, + }) + try { + const url = await uploadFile(file, "video", (p) => { + setTask((t) => (t.refVideo ? { ...t, refVideo: { ...t.refVideo, pct: p } } : t)) + }) + setTask((t) => + t.refVideo + ? { ...t, refVideo: { ...t.refVideo, uploading: false, pct: 100, ossUrl: url } } + : t, + ) + } catch { + message.error("参考视频上传失败") + setTask({ refVideo: null }) + } + }, + [setTask, uploadFile], + ) + + /* ── 新建/关闭 Tab ── */ + const newTab = () => { + const id = `t${Date.now()}` + setTasks((ts) => [...ts, emptyTask(id, `生成 ${ts.length + 1}`)]) + setActiveId(id) + } + const closeTab = (id: string) => { + setTasks((ts) => { + const next = ts.filter((t) => t.id !== id) + if (next.length === 0) { + const fresh = emptyTask("t1", "生成 1") + setActiveId("t1") + return [fresh] + } + if (id === activeId) setActiveId(next[0].id) return next }) } - /* 拖拽排序 */ - const handleDragStart = (i: number) => setDragIdx(i) - const handleDragOver = (e: React.DragEvent, i: number) => { - e.preventDefault() - if (dragIdx < 0 || dragIdx === i) return - setImages((prev) => { - const next = [...prev] - const [moved] = next.splice(dragIdx, 1) - next.splice(i, 0, moved) - return next - }) - setDragIdx(i) - } - const handleDragEnd = () => setDragIdx(-1) - - /* ── 参考视频上传 ── */ - const handleReferenceVideoSelected = async (f: File) => { - const localUrl = URL.createObjectURL(f) - setReferenceVideoFile(f) - setReferenceVideoLocalUrl(localUrl) - setReferenceVideoUrl("") - const hide = message.loading("上传参考视频…", 0) - try { - const url = await uploadViralAsset(f, "video") - setReferenceVideoUrl(url) - } catch (err) { - message.error("参考视频上传失败") - } finally { - hide() - } + /* ── 重置当前任务 ── */ + const resetTask = () => { + // 停掉播放 + audioRef.current?.pause() + setTasks((ts) => ts.map((t) => (t.id === activeId ? emptyTask(t.id, t.title) : t))) } - const handleVideoInput = (e: React.ChangeEvent) => { - const f = e.target.files?.[0] - if (f) handleReferenceVideoSelected(f) - e.target.value = "" - } - - // 风格分析作为生成流水线的一部分在后端自动触发(提交后进入 video_analysis 阶段),无需前端单独调用 - - /* ── 提交 ── */ - const handleSubmit = async () => { - if (images.length === 0) { - message.warning("请至少上传 1 张产品图") + /* ── 开始生成 ── */ + const handleGenerate = async () => { + const imgUrls = task.images + .filter((i) => i.ossUrl && !i.uploading) + .map((i) => i.ossUrl as string) + if (imgUrls.length === 0) { + message.warning("请至少上传 1 张产品图,等图片上传完成后再生成") return } - if (fusionLevel !== "ai_full" && !userCopyText.trim()) { - message.warning( - fusionLevel === "ai_polish" ? "请输入文案草稿(AI 会帮你润色)" : "请输入要使用的文案", - ) + if (task.images.some((i) => i.uploading)) { + message.warning("图片上传中,请稍候") + return + } + if (task.refVideo?.uploading) { + message.warning("参考视频上传中,请稍候") + return + } + if (!task.voiceId) { + message.warning("请先选择一个配音音色") return } setSubmitting(true) - setView("editor") - const body: CreateViralVideoRequest = { - images: images.map((i) => i.url), - industry, - target_customer: targetCustomer, - viral_structure: viralStructure, - marketing_purpose: marketingPurpose, - bgm_preference: bgmPreference, - duration, - user_copy_text: userCopyText, - fusion_level: fusionLevel, - reference_video_url: referenceVideoUrl, - style_strength: styleStrength, - style_template_id: styleTemplateId, - } try { - const j = await createViralVideoJob(body) - setActiveHistoryId(j.id) - startPolling(j.id) - message.success("任务已提交,开始生成…") - } catch (err) { - setSubmitting(false) + const job = await generateViralVideo({ + images: imgUrls, + reference_video_url: task.refVideo?.ossUrl || undefined, + style_strength: task.refVideo?.ossUrl ? task.styleStrength : undefined, + user_copy_text: task.fusionLevel !== "full_ai" ? task.userCopy : undefined, + fusion_level: task.fusionLevel, + voice_id: task.voiceId, + bgm_preference: task.bgm === "无BGM" ? undefined : task.bgm, + industry: task.industry || undefined, + target_customer: task.targetCustomer || undefined, + language: task.language || undefined, + viral_structure: task.viralStructure || undefined, + marketing_purpose: task.marketingPurpose || undefined, + duration: task.duration, + video_model: task.videoModel, + video_ratio: task.videoRatio, + }) + setTask({ job, jobId: job.id }) + message.success("任务已创建,开始生成…") + } catch (err: unknown) { const msg = err instanceof Error ? err.message : "提交失败" message.error(msg) - } - } - - /* ── 确认 AI 意图 ── */ - const handleConfirmIntent = async () => { - if (!job) return - setConfirming(true) - try { - await confirmViralIntent(job.id, { - confirmed_copy: confirmedCopyText.trim() || undefined, - }) - message.success("已确认文案,继续生成…") - startPolling(job.id) - } catch (err) { - message.error("确认失败,请重试") } finally { - setConfirming(false) - } - } - - /* ── 重试失败任务 ── */ - const handleRetry = async () => { - if (!job) return - setSubmitting(true) - try { - const j = await retryViralVideoJob(job.id) - startPolling(j.id) - message.success("已重新入队") - } catch { setSubmitting(false) - message.error("重试失败") } } - /* ── 选中历史任务 ── */ - const handleSelectHistory = (j: ViralVideoJob) => { - stopPolling() - setActiveHistoryId(j.id) - if (j.status === "running" || j.status === "pending" || j.status === "wait_user_confirm") { - startPolling(j.id) - setView("editor") - } else if (j.status === "completed") { - setView("result") - } else { - setView("editor") + const handleConfirmIntent = async () => { + if (!task.jobId) return + setConfirmingIntent(true) + try { + const j = await confirmViralVideoIntent(task.jobId, { confirmed_copy: task.intentEditText }) + setTask({ job: j }) + message.success("已确认文案,继续生成…") + } catch (err: unknown) { + message.error(err instanceof Error ? err.message : "确认失败") + } finally { + setConfirmingIntent(false) } } - /* ── 新建任务 ── */ - const handleNew = () => { - stopPolling() - setActiveHistoryId("") - setImages([]) - setUserCopyText("") - setConfirmedCopyText("") - setReferenceVideoFile(null) - setReferenceVideoLocalUrl("") - setReferenceVideoUrl("") - setView("editor") + const handleRetry = async () => { + if (!task.jobId) return + try { + const j = await retryViralVideo(task.jobId) + setTask({ job: j }) + message.success("已重试") + } catch (err: unknown) { + message.error(err instanceof Error ? err.message : "重试失败") + } } - /* ── 下载 ── */ - const handleDownload = () => { - if (!job?.result_video_url) return - const a = document.createElement("a") - a.href = job.result_video_url - a.download = `viral-video-${job.id.slice(0, 8)}.mp4` - a.target = "_blank" - document.body.appendChild(a) - a.click() - document.body.removeChild(a) + /* ── 播放/暂停音色 ── */ + const toggleVoice = (v: { id: string; url: string }) => { + if (task.playingVoiceId === v.id) { + audioRef.current?.pause() + setTask({ playingVoiceId: null }) + return + } + audioRef.current?.pause() + const a = new Audio(v.url) + a.onended = () => setTask({ playingVoiceId: null }) + a.play().catch(() => {}) + audioRef.current = a + setTask({ playingVoiceId: v.id, audioRef: a }) + } + useEffect( + () => () => { + audioRef.current?.pause() + }, + [], + ) + + const onCloneSuccess = (voice: { id?: string; name?: string; url?: 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 isRunning = polling || submitting - const waitConfirm = job?.status === "wait_user_confirm" - const hasResult = job?.status === "completed" && job.result_video_url - const failed = job?.status === "failed" - - const previewThumb = useMemo(() => { - if (images.length > 0) return images[0].localUrl || images[0].url - return "" - }, [images]) - - const intentResult: IntentResult | null = job?.intent_result || null + /* ── 运行状态派生值 ── */ + const isRunning = task.job?.status === "running" || task.job?.status === "pending" + const isAwaitingConfirm = task.job?.status === "wait_user_confirm" + const isDone = task.job?.status === "completed" + const isFailed = task.job?.status === "failed" + const progressPct = useMemo(() => { + if (!task.job) return 0 + if (task.job.progress_percent) return task.job.progress_percent + if (task.job.progress_stage) return STAGE_DISPLAYS[task.job.progress_stage]?.pct ?? 0 + return 0 + }, [task.job]) + const currentStage: ViralVideoStage | undefined = task.job?.progress_stage return ( -
- {/* ── 左栏:历史 ── */} - +
- {/* ── 中栏:编辑器 ── */} -
- {/* 图片上传 */} -
-
- - - 产品图片({images.length}/9) -
- {images.length === 0 ? ( + {/* 三列 */} +
+ {/* ─────────── 左列:STEP 1 上传素材 ─────────── */} +
+
toggleCollapse("images")} + >
imageInputRef.current?.click()} + className="vv-upload" onDragOver={(e) => { e.preventDefault() - setDragOver(true) + ;(e.currentTarget as HTMLDivElement).classList.add("dragover") }} - onDragLeave={() => setDragOver(false)} - onDrop={handleDrop} + onDragLeave={(e) => (e.currentTarget as HTMLDivElement).classList.remove("dragover")} + onDrop={onDropImg} + onClick={() => dragImgRef.current?.click()} > -
- -
-
点击或拖拽上传产品图(1~9 张)
-
- 支持 JPG / PNG / WEBP,建议 1:1 或 9:16,单张 ≤ 10MB;拖拽可调整顺序 +
+
+
点击或拖拽上传 1-9 张产品图
+ 建议 JPG/PNG,单张 ≤ 10MB
- ) : ( - <> -
- {images.map((img, i) => ( + { + if (e.target.files) handleImageFiles(e.target.files) + e.target.value = "" + }} + /> + {task.images.length > 0 && ( +
+ {task.images.map((im, i) => (
handleDragStart(i)} - onDragOver={(e) => handleDragOver(e, i)} - onDragEnd={handleDragEnd} + onDragStart={() => onImgDragStart(i)} + onDragOver={onImgDragOver} + onDrop={() => onImgDrop(i)} > - {`产品图 - {i + 1} + {im.name} + {i + 1} + {im.uploading &&
{im.pct}%
}
))} - {images.length < 9 && ( -
imageInputRef.current?.click()}> -
- - 添加图片 + {task.images.length < 9 && ( +
dragImgRef.current?.click()}> +
+ + 添加
)}
-
imageInputRef.current?.click()} - onDragOver={(e) => { - e.preventDefault() - setDragOver(true) - }} - onDragLeave={() => setDragOver(false)} - onDrop={handleDrop} - > - 继续上传或拖拽更多图片(最多 9 张) -
- - )} - -
+ )} +
- {/* 文案融合 */} -
-
- - - 我说你写 — 文案融合 -
-
- {FUSION_OPTIONS.map((opt) => ( - - ))} -
-