From de07b53ebc717ea439d85cc787ec8f49297f403d Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 9 Sep 2026 00:53:24 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20#1809=20AI=E6=95=B0=E5=AD=97=E4=BA=BA?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=207=20=E9=A1=B9=E5=AE=9E=E6=B5=8B=E9=97=AE?= =?UTF-8?q?=E9=A2=98=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ① P0 白屏修复:getScripts() 后端返回 {items,total} 分页对象, 改为解包 response.data.items ?? [];全量给 .filter/.map 数据源 加 Array.isArray 数组防御(文案列表、B-roll素材、音色列表) ② 生成对口型/渲染提交缺项不再静默 return,用 antd message 明确提示 「请先选择出镜视频/音色/文案」;接口失败弹错误 toast,成功弹提示 ③ 出镜视频选择改为「先选视频素材库 → 再选该库视频」, 用 getAssetLibraries('video') + getAssets(library_id),交互对齐智能剪辑 Step2 ④ B-roll 画面插入弹窗左侧复用同一套选库→选素材逻辑,已选素材标灰防重保留 ⑤ 文案段落索引数字框改为文案句子列表:按。!?;换行分句,每句一行可点选 ⑥ 删除手填开始/结束时间,按 该句字数÷全文字数×对口型总时长(output_duration) 自动估算 start_time/end_time 提交(后端字段不变),句子列表顺带显示估算时间段 ⑦ 标题预设样式修复:TitleStylePanel 共用样式抽成独立 TitleStylePanel.css 并由组件自带引入,24 个 T 格子网格布局/配色描边/选中态与智能剪辑完全一致, 不改动智能剪辑原页面样式 验证:tsc 0 错误 / ESLint 0 错误 / Prettier 通过 / vite build 成功 / 746 单测全过 --- apps/web/src/pages/ai-avatar/AiAvatar.css | 129 ++++++++ apps/web/src/pages/ai-avatar/AiAvatarPage.tsx | 54 ++-- apps/web/src/pages/ai-avatar/api/aiAvatar.ts | 8 +- .../ai-avatar/components/ModalAssetPicker.tsx | 142 +++++---- .../ai-avatar/components/ModalBRollEditor.tsx | 280 +++++++++++------- .../components/PanelVoiceSelector.tsx | 2 +- apps/web/src/pages/ai-avatar/types.ts | 2 + .../src/pages/ai-avatar/utils/sentences.ts | 62 ++++ .../components/title/TitleStylePanel.css | 192 ++++++++++++ .../components/title/TitleStylePanel.tsx | 3 + 10 files changed, 678 insertions(+), 196 deletions(-) create mode 100644 apps/web/src/pages/ai-avatar/utils/sentences.ts create mode 100644 apps/web/src/pages/generate/components/title/TitleStylePanel.css diff --git a/apps/web/src/pages/ai-avatar/AiAvatar.css b/apps/web/src/pages/ai-avatar/AiAvatar.css index 794ecf42e..be30cc561 100644 --- a/apps/web/src/pages/ai-avatar/AiAvatar.css +++ b/apps/web/src/pages/ai-avatar/AiAvatar.css @@ -1021,3 +1021,132 @@ font-size: 36px; margin-bottom: 8px; } + +/* ============================================================ + #1809 ④⑤⑥ B-roll 弹窗:选库行 + 文案句子列表 + 自动估算提示 + ============================================================ */ + +/* 宽弹窗:左右两栏 + 句子列表需要更大空间 */ +.aa-modal--wide { + max-width: 960px; + width: 94%; +} + +.aa-broll-modal-body .aa-broll-right { + width: 300px; +} + +/* 左侧素材库选择行 */ +.aa-broll-lib-row { + margin-bottom: 10px; +} + +.aa-broll-lib-row .aa-select { + width: 100%; + height: 36px; + border: 1px solid var(--border-color, #e2e2ea); + border-radius: 8px; + padding: 0 10px; + font-size: 13px; + background: #fff; + color: #1a1a2e; + outline: none; +} + +/* 无缩略图时的素材占位 */ +.aa-broll-asset-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; +} + +/* 文案句子列表 */ +.aa-sentence-list { + max-height: 240px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 6px; + padding-right: 4px; +} + +.aa-sentence-item { + display: flex; + align-items: flex-start; + gap: 8px; + width: 100%; + text-align: left; + border: 1px solid var(--border-color, #e2e2ea); + border-radius: 8px; + background: #fff; + padding: 8px 10px; + cursor: pointer; + transition: all 0.15s; +} + +.aa-sentence-item:hover { + border-color: #c0c0d0; + background: #f8f8fc; +} + +.aa-sentence-item.active { + border-color: #7c3aed; + background: #f3edff; +} + +.aa-sentence-item__idx { + flex-shrink: 0; + width: 20px; + height: 20px; + border-radius: 50%; + background: #f0f0f5; + color: #6b6b80; + font-size: 11px; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; +} + +.aa-sentence-item.active .aa-sentence-item__idx { + background: #7c3aed; + color: #fff; +} + +.aa-sentence-item__text { + flex: 1; + font-size: 12px; + line-height: 1.5; + color: #1a1a2e; + word-break: break-all; +} + +.aa-sentence-item__time { + flex-shrink: 0; + font-size: 10px; + color: #8c8ca1; + margin-top: 2px; +} + +.aa-sentence-empty { + font-size: 12px; + color: #8c8ca1; + background: #f8f8fc; + border-radius: 8px; + padding: 10px; +} + +/* 选择/估算提示 */ +.aa-broll-hint { + font-size: 12px; + color: #059669; + background: #f8f8fc; + border-radius: 6px; + padding: 8px 10px; + display: flex; + flex-direction: column; + gap: 4px; +} diff --git a/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx b/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx index 4544f5e04..d53b49dab 100644 --- a/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx +++ b/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx @@ -3,6 +3,7 @@ * 5列水平面板布局 */ import React, { useState, useCallback, useEffect, useRef } from "react" +import { message } from "antd" import "./AiAvatar.css" import { useAiAvatar } from "./hooks/useAiAvatar" import { PanelVideoSelector } from "./components/PanelVideoSelector" @@ -13,7 +14,6 @@ import PanelCoverAndGenerate from "./components/PanelCoverAndGenerate" import { ModalAssetPicker } from "./components/ModalAssetPicker" import ModalBRollEditor from "./components/ModalBRollEditor" import { getScripts, createLipsyncJob, getLipsyncJob, submitRender } from "./api/aiAvatar" -import { getAssetsByKind } from "@/api/assets" /** 面板折叠状态 */ type PanelKey = "video" | "voice" | "script" | "title" | "cover" @@ -28,9 +28,6 @@ const AiAvatarPage: React.FC = () => { cover: false, }) - /* ── 素材库弹窗 ── */ - const [bRollAssets, setBRollAssets] = useState([]) - /* ── 对口型轮询 ── */ const lipsyncTimerRef = useRef | null>(null) @@ -40,14 +37,26 @@ const AiAvatarPage: React.FC = () => { /* ── 对口型 ── */ const handleGenerateLipsync = useCallback(async () => { - if (!state.selectedVideo || !state.selectedVoice || !state.scriptText) return + // ② 缺项明确提示(#1809):不再静默 return + const video = state.selectedVideo + const voice = state.selectedVoice + const text = state.scriptText.trim() + const missing: string[] = [] + if (!video) missing.push("出镜视频") + if (!voice) missing.push("音色") + if (!text) missing.push("文案") + if (missing.length > 0 || !video || !voice) { + message.warning(`请先选择${missing.join("、")}`) + return + } try { const job = await createLipsyncJob({ - voice_id: state.selectedVoice.voice_id, + voice_id: voice.voice_id, script_text: state.scriptText, - video_asset_id: state.selectedVideo.id, + video_asset_id: video.id, }) state.setLipsyncJob(job) + message.success("对口型任务已提交,生成中…") // 开始轮询 if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current) lipsyncTimerRef.current = setInterval(async () => { @@ -56,13 +65,20 @@ const AiAvatarPage: React.FC = () => { state.setLipsyncJob(updated) if (updated.status === "completed" || updated.status === "failed") { if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current) + if (updated.status === "completed") { + message.success("对口型视频生成完成") + } else { + message.error(updated.error_message || "对口型生成失败") + } } } catch { - // 忽略轮询错误 + // 忽略轮询错误(轮询期间不打扰用户) } }, 3000) } catch (err) { + // ② 接口失败弹错误提示,不只 console console.error("对口型任务创建失败:", err) + message.error(err instanceof Error ? err.message : "对口型任务提交失败,请重试") } // eslint-disable-next-line react-hooks/exhaustive-deps }, [state.selectedVideo, state.selectedVoice, state.scriptText]) @@ -74,16 +90,13 @@ const AiAvatarPage: React.FC = () => { } }, []) - /* ── 加载 B-roll 素材 ── */ - useEffect(() => { - getAssetsByKind("video", { limit: 50 }) - .then(setBRollAssets) - .catch(() => {}) - }, []) - /* ── 生成视频 ── */ const handleGenerate = useCallback(async () => { - if (!state.lipsyncJob || state.lipsyncJob.status !== "completed") return + // ② 前置条件提示(#1809) + if (!state.lipsyncJob || state.lipsyncJob.status !== "completed") { + message.warning("请先生成对口型视频,待对口型完成后再提交渲染") + return + } state.setIsGenerating(true) try { await submitRender({ @@ -94,8 +107,10 @@ const AiAvatarPage: React.FC = () => { cover_config: state.coverConfig as unknown as Record, resolution: state.resolution, }) + message.success("渲染任务已提交,可在视频管理中查看进度") } catch (err) { console.error("渲染任务提交失败:", err) + message.error(err instanceof Error ? err.message : "渲染任务提交失败,请重试") } finally { state.setIsGenerating(false) } @@ -241,7 +256,8 @@ const AiAvatarPage: React.FC = () => { open={state.showBRollModal} onClose={() => state.setShowBRollModal(false)} existingSegments={state.bRollSegments} - availableAssets={bRollAssets} + scriptText={state.scriptText} + outputDuration={state.lipsyncJob?.output_duration ?? 0} onConfirm={state.addBRollSegment} onRemove={state.removeBRollSegment} /> @@ -264,8 +280,8 @@ const ScriptSelectModalLazy: React.FC<{ if (!open) return setLoading(true) getScripts() - .then(setScripts) - .catch(() => {}) + .then((items) => setScripts(Array.isArray(items) ? items : [])) + .catch(() => setScripts([])) .finally(() => setLoading(false)) }, [open]) diff --git a/apps/web/src/pages/ai-avatar/api/aiAvatar.ts b/apps/web/src/pages/ai-avatar/api/aiAvatar.ts index 24acab0ed..c84904f4f 100644 --- a/apps/web/src/pages/ai-avatar/api/aiAvatar.ts +++ b/apps/web/src/pages/ai-avatar/api/aiAvatar.ts @@ -6,8 +6,12 @@ import type { Script, LipsyncJob, RenderJob, BRollSegment } from "../types" /* ── 文案库 ── */ export const getScripts = async (): Promise => { - const response = await apiClient.get("/scripts") - return response.data + const response = await apiClient.get<{ items?: Script[] } | Script[]>("/scripts") + // 后端列表返回 { items, total } 分页对象,做兼容解包 + 数组防御(#1809 白屏修复) + const data = response.data as unknown + if (Array.isArray(data)) return data + const items = (data as { items?: Script[] })?.items + return Array.isArray(items) ? items : [] } export const getScriptById = async (id: string): Promise