From be964c0aa54ce62771502c1bbea9e5604b3d53d2 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Mon, 7 Sep 2026 01:23:00 +0800 Subject: [PATCH] =?UTF-8?q?fix(generate):=20#1750=20=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E4=BA=94=E8=BF=9E=E4=BF=AE=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=88=A0=E6=9C=AC=E5=9C=B0=E6=A8=A1=E6=8B=9F=E5=81=87=E6=95=B0?= =?UTF-8?q?=E6=8D=AE/=E9=85=8D=E9=9F=B3=E6=97=B6=E9=95=BF/=E7=AB=96?= =?UTF-8?q?=E5=B1=8F=E6=92=AD=E6=94=BE=E5=99=A8/=E5=B0=81=E9=9D=A2?= =?UTF-8?q?=E4=B8=80=E9=94=AE=E7=94=9F=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 配音列表显示时长:Step5VoiceSelect 卡片底行展示真实时长(X.X秒 / 文件大小), AI 音色显示「按文本合成」,时长缺失兜底「时长未知」 2. 批量预览假数据根治(铁证:variant-plans 接口 404,前端静默降级 9素材×5s=45s): - 删除 fallback 本地模拟整条分支(variantSeed 轮换/seededRandom/duration 兜底/模板时长) - 预览片段唯一来源为后端 variant-plans 真实 clips;总时长=片段时长之和 - 接口失败显示「预览加载失败,请重试」三态占位(9:16 防塌陷),重试按钮仅变体0 - serverClips 引用素材缺失时 usePreviewAssets.ensureAssets 按 asset_id 差集补拉 - playToken 替代 variantSeed(0 基变体序号)驱动播放互斥 3. 独立配音模式:useVariantVoicePreview 每变体挂各自配音 URL(voiceLibraryIds[i]), 共用模式全变体同一条;有配音时素材视频强制 muted 4. 竖屏播放器自适应:结果页/生成中/批量网格播放器容器按 9:16 自适应, video max-height:70vh + aspect-ratio,消除竖屏成片左右黑边;N=1 零回归 5. 封面一键全部自动生成:generateAll 一次点击串行触发所有未生成封面, 失败单个不阻塞,分级 toast(全成功/部分失败/全失败);按钮 nowrap 不换行 配套清理: - 删除死代码 PreviewVideoPanel/useServerPreview/calculateTotalVideoDuration/ GenerateResultPanel/result-panel/ - 模板时长概念全链路删除(模板库/我的模板/智能匹配/详情弹窗), api 类型 duration_min/max/estimated_duration 标 @deprecated 兼容旧数据 - useBatchVariantPlans 重写:error/retry 状态,retry 由 state nonce 驱动 effect 统一发请求(修复 retry 直调 load 与 effect 双发 requestSeq 竞争) - useVariantVoicePreview 修复渲染风暴:useQuery 解构禁用 = [] 默认值, 改用模块级稳定常量 EMPTY_VOICE_MATERIALS + setUrls 内容比较 bailout (修复全量 vitest worker OOM 根因) 依赖后端 #1749:variant-plans 接口上线前,staging 批量预览显示 「预览加载失败,请重试」为预期行为(严禁假数据冒充预览) 测试:722 passed (149 files);tsc/eslint(max-warnings 0)/prettier/build 全绿 --- apps/web/src/api/editing-planner/types.ts | 12 +- apps/web/src/api/templates/types.ts | 8 +- .../template-management/usePlanLoading.ts | 5 +- .../template-management/useTemplateDetail.ts | 5 +- apps/web/src/pages/generate/GeneratePage.tsx | 72 +++- .../components/BatchGenerationGrid.tsx | 32 +- .../generate/components/CanvasPreviewGrid.tsx | 129 +++--- .../components/FrontendPreviewPlayer.tsx | 129 ++---- .../components/GenerateResultPanel.tsx | 96 ----- .../generate/components/PreviewVideoPanel.tsx | 380 ------------------ .../generate/components/Step5VoiceSelect.tsx | 10 +- .../components/Step6CoverSettings.tsx | 15 +- .../result-panel/ProgressIndicator.tsx | 40 -- .../result-panel/ResultVideoCard.tsx | 63 --- apps/web/src/pages/generate/generate.css | 42 +- .../hooks/step2-materials/useSmartMatch.ts | 12 +- .../pages/generate/hooks/useBatchCovers.ts | 99 +++-- .../generate/hooks/useBatchVariantPlans.ts | 80 ++-- .../pages/generate/hooks/usePreviewAssets.ts | 38 +- .../pages/generate/hooks/useServerPreview.ts | 297 -------------- .../generate/hooks/useVariantVoicePreview.ts | 166 ++++++++ .../utils/calculateTotalVideoDuration.ts | 54 --- .../my-templates/components/TemplateCard.tsx | 2 +- .../my-templates/hooks/useMyTemplates.ts | 5 - .../src/pages/templates/TemplateLibrary.tsx | 7 +- .../template-library/TemplateCard.tsx | 4 +- .../template-library/TemplateDetailModal.tsx | 11 +- .../template-library/TemplateGrid.tsx | 4 +- .../template-library/TemplateToolbar.tsx | 22 +- .../template-detail-modal/SegmentList.tsx | 16 +- .../templates/constants/templateLibrary.ts | 12 +- .../templates/hooks/useTemplateLibrary.ts | 14 +- .../pages/templates/types/templateLibrary.ts | 1 - .../pages/templates/utils/templateLibrary.ts | 16 - .../generate/CanvasPreviewGrid.audio.test.tsx | 129 ++++-- .../FrontendPreviewPlayer.audio.test.tsx | 74 +++- .../pages/generate/smart-match-pool.test.ts | 47 +-- .../src/test/pages/generate/smoke.test.tsx | 5 +- .../generate/useBatchVariantPlans.test.tsx | 93 +++-- 39 files changed, 826 insertions(+), 1420 deletions(-) delete mode 100755 apps/web/src/pages/generate/components/GenerateResultPanel.tsx delete mode 100644 apps/web/src/pages/generate/components/PreviewVideoPanel.tsx delete mode 100755 apps/web/src/pages/generate/components/result-panel/ProgressIndicator.tsx delete mode 100755 apps/web/src/pages/generate/components/result-panel/ResultVideoCard.tsx delete mode 100644 apps/web/src/pages/generate/hooks/useServerPreview.ts create mode 100644 apps/web/src/pages/generate/hooks/useVariantVoicePreview.ts delete mode 100644 apps/web/src/pages/generate/utils/calculateTotalVideoDuration.ts diff --git a/apps/web/src/api/editing-planner/types.ts b/apps/web/src/api/editing-planner/types.ts index 80a4a0180..c29525d92 100644 --- a/apps/web/src/api/editing-planner/types.ts +++ b/apps/web/src/api/editing-planner/types.ts @@ -44,8 +44,10 @@ export interface BgmConfig { export interface TemplateSegment { id?: string segment_order: number - duration_min: number - duration_max: number + /** @deprecated 模板无时长概念(#1750 基线):字段保留仅为兼容旧数据读取,新模板可不传 */ + duration_min?: number + /** @deprecated 同上 */ + duration_max?: number material_type: string | null } @@ -59,7 +61,8 @@ export interface EditingTemplate { title_config: TitleConfig subtitle_config: SubtitleConfig bgm_config: BgmConfig - estimated_duration: number + /** @deprecated 模板无时长概念(#1750 基线):成片时长由配音时长决定;字段保留兼容旧数据 */ + estimated_duration?: number segments: TemplateSegment[] watermark_config?: WatermarkConfig intro_outro_config?: IntroOutroConfig @@ -89,7 +92,8 @@ export interface SaveTemplatePayload { title_config: TitleConfig subtitle_config: SubtitleConfig bgm_config: BgmConfig - estimated_duration: number + /** @deprecated 模板无时长概念(#1750 基线):保留兼容旧数据 */ + estimated_duration?: number segments: Omit[] watermark_config?: WatermarkConfig intro_outro_config?: IntroOutroConfig diff --git a/apps/web/src/api/templates/types.ts b/apps/web/src/api/templates/types.ts index 8b0575dd7..5016f4959 100644 --- a/apps/web/src/api/templates/types.ts +++ b/apps/web/src/api/templates/types.ts @@ -42,8 +42,10 @@ export interface TemplateItem { export interface TemplateSegment { id?: string segment_order: number - duration_min: number - duration_max: number + /** @deprecated 模板无时长概念(#1750 基线):字段保留仅为兼容旧数据读取 */ + duration_min?: number + /** @deprecated 同上 */ + duration_max?: number material_type: string | null description?: string } @@ -55,8 +57,6 @@ export interface TemplateListParams { category?: string tags?: string keyword?: string - /** 时长筛选(秒):short < 30, medium 30-120, long > 120 */ - duration_range?: "short" | "medium" | "long" } /** 模板列表分页响应 */ diff --git a/apps/web/src/pages/editing-planner/hooks/template-management/usePlanLoading.ts b/apps/web/src/pages/editing-planner/hooks/template-management/usePlanLoading.ts index 1c5f209e0..52a28abce 100644 --- a/apps/web/src/pages/editing-planner/hooks/template-management/usePlanLoading.ts +++ b/apps/web/src/pages/editing-planner/hooks/template-management/usePlanLoading.ts @@ -108,7 +108,10 @@ export function usePlanLoading({ id: `seg-${idx}`, template_segment_id: `seg-${idx}`, type: (seg.material_type === "voiceover" ? "voice" : "pip") as ClipType, - duration: (seg.duration_min + seg.duration_max) / 2, + duration: + seg.duration_min != null && seg.duration_max != null + ? (seg.duration_min + seg.duration_max) / 2 + : 5, startOffset: 0, script_text: "", order: seg.segment_order, diff --git a/apps/web/src/pages/editing-planner/hooks/template-management/useTemplateDetail.ts b/apps/web/src/pages/editing-planner/hooks/template-management/useTemplateDetail.ts index f3ce6c19f..0a3bc3508 100644 --- a/apps/web/src/pages/editing-planner/hooks/template-management/useTemplateDetail.ts +++ b/apps/web/src/pages/editing-planner/hooks/template-management/useTemplateDetail.ts @@ -43,7 +43,10 @@ export function useTemplateDetail({ id: seg.id || `seg-${idx}`, template_segment_id: seg.id || `seg-${idx}`, type: (seg.material_type === "voiceover" ? "voice" : "pip") as ClipType, - duration: (seg.duration_min + seg.duration_max) / 2, + duration: + seg.duration_min != null && seg.duration_max != null + ? (seg.duration_min + seg.duration_max) / 2 + : 5, startOffset: 0, script_text: "", order: seg.segment_order, diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index 990dc52cf..d3c5214ae 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -28,6 +28,7 @@ import { useGenerateVideo } from "./hooks/useGenerateVideo" import { usePreviewAssets } from "./hooks/usePreviewAssets" import { useBatchVariantPlans } from "./hooks/useBatchVariantPlans" +import { useVariantVoicePreview } from "./hooks/useVariantVoicePreview" import { useTitleStyleUpdaters } from "./hooks/useStep4Title/useTitleStyleUpdaters" import { getAssetsByKind } from "@/api/assets" import { previewTts } from "@/api/tts" @@ -137,14 +138,17 @@ const GeneratePage: React.FC = () => { const variant0Title = isBatch ? previewTitles?.[0] || "" : "" useEffect(() => { + // #1750:批量模式每变体独立/共用配音由 useVariantVoicePreview 接管(含独立模式每卡片挂各自 URL), + // 本 effect 只服务单视频,批量模式直接跳过(避免共用 TTS 结果错误下发给所有变体) + if (isBatch) return const voiceAsset = voiceMaterials.find((m) => m.id === selectedVoice) if (voiceAsset?.file_url) { setPreviewVoiceAudioUrl(voiceAsset.file_url) return } - // 批量模式下 TTS 文案跟随变体0标题;单视频跟随主标题 - const ttsTitle = isBatch ? variant0Title || "" : titleSettings.title + // 单视频 TTS 文案跟随主标题 + const ttsTitle = titleSettings.title const voiceId = selectedClonedVoice || selectedVoice if (!voiceId || !ttsTitle) { setPreviewVoiceAudioUrl(null) @@ -214,10 +218,11 @@ const GeneratePage: React.FC = () => { /* ── 加载素材详情(供前端预览播放器使用) ── */ const previewAssetsEnabled = previewAssetIds.length > 0 - const { assets: previewAssets, ready: previewAssetsReady } = usePreviewAssets( - previewAssetIds, - previewAssetsEnabled, - ) + const { + assets: previewAssets, + ready: previewAssetsReady, + ensureAssets, + } = usePreviewAssets(previewAssetIds, previewAssetsEnabled) /* ── 预览就绪:纯前端 Canvas 预览,素材详情加载完即可秒开(单视频/批量一致) ── */ const previewReady = previewAssetsReady && !!currentTemplate @@ -229,6 +234,8 @@ const GeneratePage: React.FC = () => { clipsByVariant: variantClips, planIdsByVariant: variantPlanIds, loading: variantClipsLoading, + error: variantClipsError, + retry: retryVariantClips, } = useBatchVariantPlans({ enabled: isBatch && currentStep === 4 && previewAssetsReady, count: previewCount, @@ -237,6 +244,30 @@ const GeneratePage: React.FC = () => { sourcePlanId: storedSourceEditPlanId || sourceEditPlanId || "", }) + /* ── 批量变体配音预览 URL(#1750):独立模式每变体挂各自配音,共用模式全挂同一条; + hook 内部自取配音素材库并做 TTS 缓存/串行/防竞态 */ + const variantVoiceAudioUrls = useVariantVoicePreview({ + enabled: isBatch, + count: previewCount, + perVideo: voiceModePerVideo, + sharedVoiceId: selectedVoice || "", + clonedVoiceId: selectedClonedVoice || "", + variantVoiceIds: voiceLibraryIds || [], + titles: previewTitles || [], + }) + + /* ── 变体计划 clips 引用的素材可能不在已选列表中(#1750):差集补拉素材详情, + 保证播放器拿得到 file_url,而不是静默丢片段/用假数据冒充 */ + const clipAssetIds = useMemo(() => { + if (!isBatch || !variantClips?.length) return [] + const ids = new Set() + variantClips.forEach((list) => list.forEach((c) => c.asset_id && ids.add(c.asset_id))) + return Array.from(ids) + }, [isBatch, variantClips]) + useEffect(() => { + if (clipAssetIds.length > 0) void ensureAssets(clipAssetIds) + }, [clipAssetIds, ensureAssets]) + /* ── 勾选变体 ── */ const toggleVariantSelect = useCallback( (index: number) => { @@ -411,7 +442,6 @@ const GeneratePage: React.FC = () => { /* 单视频:前端 Canvas 实时预览(与旧版一致,零回归) */ 0} serverClips={serverClips} @@ -443,13 +473,14 @@ const GeneratePage: React.FC = () => { { {/* ════ 步骤5/6(单视频):右侧成片播放器 ════ */} {currentStep >= 5 && !isBatch && generated && finalVideo && (
-
+
diff --git a/apps/web/src/pages/generate/components/Step6CoverSettings.tsx b/apps/web/src/pages/generate/components/Step6CoverSettings.tsx index 41eaba9c0..bc79ade53 100755 --- a/apps/web/src/pages/generate/components/Step6CoverSettings.tsx +++ b/apps/web/src/pages/generate/components/Step6CoverSettings.tsx @@ -91,11 +91,13 @@ const Step6CoverSettings: React.FC = (props) => { shadow: props.titleSettings?.shadow ?? false, }, covers: batchCoversList, - onCoversChange: (urls) => { - // 按卡片顺序写回对应变体索引 + onCoversChange: (updater) => { + // 按卡片顺序写回对应变体索引;支持函数式 updater(#1750:串行回写避免闭包覆盖) + const prevCardView = cardIndexes.map((vi) => (props.previewCovers || [])[vi] || "") + const nextCardView = typeof updater === "function" ? updater(prevCardView) : updater const next = [...(props.previewCovers || [])] cardIndexes.forEach((vi, cardPos) => { - next[vi] = urls[cardPos] || "" + next[vi] = nextCardView[cardPos] || "" }) props.onPreviewCoversChange?.(next) }, @@ -142,7 +144,8 @@ const Step6CoverSettings: React.FC = (props) => { @@ -151,8 +154,8 @@ const Step6CoverSettings: React.FC = (props) => {
{cardIndexes.map((variantIndex, cardPos) => { const url = batchCoversList[cardPos] - const isLoading = batchCovers.loadingIndex === cardPos - const isUploading = batchCovers.uploadingIndex === cardPos + const isLoading = batchCovers.busyIndexes.includes(cardPos) + const isUploading = batchCovers.uploadingIndexes.includes(cardPos) const title = batchTitles[cardPos] return (
diff --git a/apps/web/src/pages/generate/components/result-panel/ProgressIndicator.tsx b/apps/web/src/pages/generate/components/result-panel/ProgressIndicator.tsx deleted file mode 100755 index 6dc1850b7..000000000 --- a/apps/web/src/pages/generate/components/result-panel/ProgressIndicator.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import React from "react" -import { Typography } from "antd" - -const { Text } = Typography - -interface ProgressIndicatorProps { - progress: number -} - -export const ProgressIndicator: React.FC = ({ progress }) => { - return ( -
-
- - - - - {Math.round(progress)}% -
-
- - 正在生成视频 - - - AI 正在处理素材,请稍候… - -
-
- ) -} diff --git a/apps/web/src/pages/generate/components/result-panel/ResultVideoCard.tsx b/apps/web/src/pages/generate/components/result-panel/ResultVideoCard.tsx deleted file mode 100755 index 5f73e6058..000000000 --- a/apps/web/src/pages/generate/components/result-panel/ResultVideoCard.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import React from "react" -import { PlayCircleOutlined, DownloadOutlined, ShareAltOutlined } from "@ant-design/icons" -import type { GeneratedVideo } from "@/api/template-editor" -import { formatDuration } from "@/api/voice-clone" - -interface ResultVideoCardProps { - video: GeneratedVideo - index: number - onPreview: (video: GeneratedVideo) => void - onDownload: () => void - onShare: () => void -} - -export const ResultVideoCard: React.FC = ({ - video, - index, - onPreview, - onDownload, - onShare, -}) => { - return ( -
onPreview(video)}> -
- {video.thumbnail_url ? ( - - ) : ( -
- -
- )} -
- -
- {video.duration && ( - {formatDuration(video.duration)} - )} -
-
-
视频 {index + 1}
-
- - -
-
-
- ) -} diff --git a/apps/web/src/pages/generate/generate.css b/apps/web/src/pages/generate/generate.css index 8d782eaf3..96302ede3 100644 --- a/apps/web/src/pages/generate/generate.css +++ b/apps/web/src/pages/generate/generate.css @@ -3341,25 +3341,35 @@ accent-color: var(--primary-color, #1677ff); } -/* #1744:批量变体独立选片加载提示(浮在卡片右上角,不遮挡播放控件) */ -.xx-variant-clips-loading { - position: absolute; - top: 34px; - right: 8px; - z-index: 3; - display: inline-flex; +/* #1750:批量变体片段加载/错误占位(9:16 竖屏比例,与播放器卡片同尺寸防塌陷) */ +.xx-variant-clips-status { + position: relative; + width: 100%; + aspect-ratio: 9 / 16; + max-height: 70vh; + display: flex; + flex-direction: column; align-items: center; - gap: 5px; - padding: 3px 8px; - font-size: 11px; - color: #fff; - background: rgba(0, 0, 0, 0.55); - border-radius: 10px; - pointer-events: none; + justify-content: center; + gap: 4px; + font-size: 13px; + color: rgba(255, 255, 255, 0.75); + background: #0a0a0a; + border-radius: 16px; + overflow: hidden; + box-shadow: + 0 4px 6px -1px rgba(0, 0, 0, 0.3), + 0 20px 50px -12px rgba(0, 0, 0, 0.5), + inset 0 0 0 1px rgba(255, 255, 255, 0.06); } -.xx-variant-clips-loading .anticon { - font-size: 12px; +.xx-variant-clips-status .anticon { + font-size: 22px; +} + +.xx-variant-clips-error-text { + color: rgba(255, 255, 255, 0.85); + font-size: 13px; } /* ============================================================ diff --git a/apps/web/src/pages/generate/hooks/step2-materials/useSmartMatch.ts b/apps/web/src/pages/generate/hooks/step2-materials/useSmartMatch.ts index 5a625fa3d..3950a3864 100755 --- a/apps/web/src/pages/generate/hooks/step2-materials/useSmartMatch.ts +++ b/apps/web/src/pages/generate/hooks/step2-materials/useSmartMatch.ts @@ -22,17 +22,13 @@ const DEFAULT_LIMIT = 30 const ASSET_POOL_MULTIPLIER = 3 /** - * 根据模板 segments 计算智能匹配候选素材数量上限(#1744 调整)。 - * 候选池至少为「片段数 × 3」: - * - 每个片段至少有 3 个候选素材可供变体间洗牌/避让(独立选片 + 区间不重叠); - * - 同时不低于「总时长/15 秒」的时长覆盖率估算,两者取大; - * 结果钳制到 [1, 200] 区间(后端 limit 上限 200)。 + * 根据模板 segments 计算智能匹配候选素材数量上限(#1750:模板无时长概念)。 + * 候选池 = 片段数 × 3:每个片段至少 3 个候选素材供变体间洗牌/避让 + * (独立选片 + 区间不重叠);结果钳制到 [1, 200] 区间(后端 limit 上限 200)。 */ export function computeLimitFromSegments(segments?: TemplateSegment[]): number { if (!segments || segments.length === 0) return DEFAULT_LIMIT - const totalSeconds = segments.reduce((sum, seg) => sum + (seg.duration_min || 0), 0) - const byDuration = totalSeconds > 0 ? Math.ceil(totalSeconds / 15) : 0 - const limit = Math.max(segments.length * ASSET_POOL_MULTIPLIER, byDuration, segments.length) + const limit = segments.length * ASSET_POOL_MULTIPLIER return Math.max(1, Math.min(limit, 200)) } diff --git a/apps/web/src/pages/generate/hooks/useBatchCovers.ts b/apps/web/src/pages/generate/hooks/useBatchCovers.ts index 2f2519d26..fa0f8de5d 100644 --- a/apps/web/src/pages/generate/hooks/useBatchCovers.ts +++ b/apps/web/src/pages/generate/hooks/useBatchCovers.ts @@ -1,6 +1,13 @@ /** - * 批量封面 Hook(Issue #1677) + * 批量封面 Hook(Issue #1677 / #1750 修复) * N 个视频时:逐个自动生成封面(从对应成片抽帧 + 叠加对应标题)或上传自定义封面 + * + * #1750 修复: + * - patchCover 改为函数式 updater 回写(onCoversChange 支持 (prev)=>next), + * 修复串行 generateAll 时闭包捕获旧 covers 数组导致回写互相覆盖的 bug; + * - loading/uploading 状态改数组(busyIndexes/uploadingIndexes),支持多个卡片在途; + * - generateOne 返回 boolean,generateAll 据此统计成功/失败数量分级提示,单个失败不阻塞; + * - generateAll 一次点击即处理所有未生成封面(无需点 3 次)。 */ import { useCallback, useState } from "react" import { message } from "antd" @@ -8,6 +15,9 @@ import { generateCover } from "@/api/generation" import { uploadAssetDirect, getAssetLibraries } from "@/api/assets" import type { GeneratedVideo } from "@/api/template-editor" +/** onCoversChange 支持直接传值或函数式 updater(函数式用于串行回写避免闭包覆盖) */ +type CoversChangeFn = (updater: string[] | ((prev: string[]) => string[])) => void + interface UseBatchCoversOptions { selectedTemplate: string generatedVideos: GeneratedVideo[] @@ -24,7 +34,7 @@ interface UseBatchCoversOptions { shadow: boolean } covers: string[] - onCoversChange: (urls: string[]) => void + onCoversChange: CoversChangeFn } export function useBatchCovers({ @@ -35,28 +45,46 @@ export function useBatchCovers({ covers, onCoversChange, }: UseBatchCoversOptions) { - const [loadingIndex, setLoadingIndex] = useState(null) - const [uploadingIndex, setUploadingIndex] = useState(null) + /** 正在自动生成封面的变体序号集合(数组支持多个卡片同时在途) */ + const [busyIndexes, setBusyIndexes] = useState([]) + /** 正在上传自定义封面的变体序号集合 */ + const [uploadingIndexes, setUploadingIndexes] = useState([]) + const addBusy = useCallback((i: number) => { + setBusyIndexes((prev) => (prev.includes(i) ? prev : [...prev, i])) + }, []) + const removeBusy = useCallback((i: number) => { + setBusyIndexes((prev) => prev.filter((x) => x !== i)) + }, []) + const addUploading = useCallback((i: number) => { + setUploadingIndexes((prev) => (prev.includes(i) ? prev : [...prev, i])) + }, []) + const removeUploading = useCallback((i: number) => { + setUploadingIndexes((prev) => prev.filter((x) => x !== i)) + }, []) + + /** 函数式回写单个封面位置(基于最新 prev,不受闭包旧值影响) */ const patchCover = useCallback( (index: number, url: string) => { - const next = [...covers] - next[index] = url - onCoversChange(next) + onCoversChange((prev) => { + const next = [...prev] + next[index] = url + return next + }) }, - [covers, onCoversChange], + [onCoversChange], ) - /** 为第 index 个视频自动生成封面 */ + /** 为第 index 个视频自动生成封面;返回是否成功(供 generateAll 统计) */ const generateOne = useCallback( - async (index: number) => { + async (index: number): Promise => { const finalVideos = generatedVideos.filter((v) => v.status === "completed") const target = finalVideos[index] || generatedVideos[index] if (!target) { message.warning("该视频尚未生成完成") - return + return false } - setLoadingIndex(index) + addBusy(index) try { const titleText = titles[index] || "" const response = await generateCover(selectedTemplate, { @@ -82,23 +110,25 @@ export function useBatchCovers({ if (url) { patchCover(index, url) message.success(`视频 ${index + 1} 封面生成成功`) - } else { - message.warning(`视频 ${index + 1} 封面生成未返回图片,请重试`) + return true } + message.warning(`视频 ${index + 1} 封面生成未返回图片,请重试`) + return false } catch (err) { console.error(`[封面] 视频 ${index + 1} 生成失败:`, err) message.error(`视频 ${index + 1} 封面生成失败,请重试`) + return false } finally { - setLoadingIndex(null) + removeBusy(index) } }, - [generatedVideos, titles, titleStyle, selectedTemplate, patchCover], + [generatedVideos, titles, titleStyle, selectedTemplate, patchCover, addBusy, removeBusy], ) /** 为第 index 个视频上传自定义封面 */ const uploadOne = useCallback( async (index: number, file: File) => { - setUploadingIndex(index) + addUploading(index) try { const libs = await getAssetLibraries() const imageLib = libs.find((l) => l.kind === "image") || libs[0] @@ -121,26 +151,43 @@ export function useBatchCovers({ console.error(`[封面] 视频 ${index + 1} 上传失败:`, err) message.error("封面上传失败,请重试") } finally { - setUploadingIndex(null) + removeUploading(index) } }, - [patchCover], + [patchCover, addUploading, removeUploading], ) - /** 一键全部自动生成(串行,避免队列限流) */ + /** 一键全部自动生成(串行,避免队列限流;单个失败不阻塞,结束后分级提示) */ const generateAll = useCallback(async () => { const finalVideos = generatedVideos.filter((v) => v.status === "completed") - for (let i = 0; i < finalVideos.length; i++) { - if (covers[i]) continue // 已有封面跳过 + const total = finalVideos.length + // 待处理:基于调用时刻的 covers 快照判断(已有封面跳过); + // 回写走函数式 updater,循环内不再依赖可能过期的 covers 闭包 + const pending = finalVideos.map((_, i) => i).filter((i) => !covers[i]) + if (pending.length === 0) { + message.info("所有视频封面已生成") + return + } + let okCount = 0 + let failCount = 0 + for (const i of pending) { // eslint-disable-next-line no-await-in-loop - await generateOne(i) + const ok = await generateOne(i) + if (ok) okCount += 1 + else failCount += 1 + } + if (failCount === 0) { + message.success(`全部 ${total} 个封面已生成`) + } else if (okCount === 0) { + message.error("封面生成全部失败,请重试") + } else { + message.warning(`已生成 ${okCount} 个封面,${failCount} 个失败,请对失败项重试`) } - message.success("全部封面已生成") }, [generatedVideos, covers, generateOne]) return { - loadingIndex, - uploadingIndex, + busyIndexes, + uploadingIndexes, generateOne, uploadOne, generateAll, diff --git a/apps/web/src/pages/generate/hooks/useBatchVariantPlans.ts b/apps/web/src/pages/generate/hooks/useBatchVariantPlans.ts index e13649fe3..42d5a8f9d 100644 --- a/apps/web/src/pages/generate/hooks/useBatchVariantPlans.ts +++ b/apps/web/src/pages/generate/hooks/useBatchVariantPlans.ts @@ -1,5 +1,5 @@ /** - * 批量变体真实片段 Hook(#1744) + * 批量变体真实片段 Hook(#1744 接入 / #1750 修正) * * 批量预览(N>1)进入第 4 步时,向后端轻量接口 POST /generation/variant-plans * 申请 N 个变体的独立剪辑计划片段: @@ -10,28 +10,30 @@ * 因此前端按这些 clips 播放的预览与最终成片一致; * - 正式生成时把 plan_ids 回传,后端直接关联预览 plan 渲染,不再重新选片。 * - * 降级策略(不阻塞用户): - * - 端点 404(后端未上线)/ 网络错误 / 超时:静默降级为本地 variantSeed 模拟; - * - 400(素材库不足无法独立选片):展示后端提示一次,降级本地模拟 - * (正式生成时后端仍会拦截并给出同样提示,不会静默出同源成片); - * - 单个变体 clips 为空:该变体降级本地模拟。 + * 错误处理(#1750 基线:严禁用前端假数据冒充预览): + * - 端点 404(后端未上线)/ 400 / 网络错误 / 超时:一律置 error 状态, + * UI 显示「预览加载失败,请重试」,不再静默降级本地模拟; + * - 单个变体 clips 为空同样视为加载失败(计划数据不完整)。 * * N=1 不调用本 hook(单视频零回归)。 */ import { useCallback, useEffect, useRef, useState } from "react" -import { message } from "antd" import type { EditPlanClip } from "@/api/template-editor" import { createBatchVariantPlans, type VariantPlan } from "@/api/generation/variantPlans" export interface BatchVariantClipsState { - /** 各变体的服务端真实片段(按 variant_index 排序);未就绪/降级的变体为空数组 */ + /** 各变体的服务端真实片段(按 variant_index 排序);未就绪为空数组 */ clipsByVariant: EditPlanClip[][] - /** 各变体的 plan_id(正式生成回传,保证预览即成片);降级/未就绪为空串 */ + /** 各变体的 plan_id(正式生成回传,保证预览即成片);未就绪为空串 */ planIdsByVariant: string[] /** 是否正在向后端申请变体计划 */ loading: boolean - /** 后端真实片段是否可用(至少变体 0 有片段);false 时调用方应走本地模拟 */ + /** 后端真实片段是否全部可用(每个变体都有 ≥1 条片段) */ ready: boolean + /** 加载失败(端点未上线/网络错误/数据不完整);true 时 UI 显示错误态+重试 */ + error: boolean + /** 用户点击「重试」:强制重新申请变体计划 */ + retry: () => void } interface UseBatchVariantPlansOptions { @@ -54,15 +56,19 @@ export function useBatchVariantPlans({ const [clipsByVariant, setClipsByVariant] = useState([]) const [planIdsByVariant, setPlanIdsByVariant] = useState([]) const [loading, setLoading] = useState(false) + const [error, setError] = useState(false) const requestSeqRef = useRef(0) - const warnedRef = useRef(false) // 记录上次成功申请的入参指纹,素材/数量未变时不重复请求 const lastKeyRef = useRef("") + // 重试计数(state):用户点重试时 +1,驱动 effect 重新发起请求(即使指纹相同); + // 用 state 而非 ref + 直接调 load,避免 retry 与 effect 双发导致 seq 竞争 + const [retryNonce, setRetryNonce] = useState(0) const load = useCallback(async () => { const seq = ++requestSeqRef.current setLoading(true) + setError(false) try { const resp = await createBatchVariantPlans({ template_id: templateId, @@ -83,32 +89,35 @@ export function useBatchVariantPlans({ .filter((c) => c && c.asset_id && c.status === "ready") .sort((a, b) => a.order - b.order) } - setClipsByVariant(clips) - setPlanIdsByVariant(planIds) + // 数据完整性校验:每个变体都必须有真实片段,否则视为失败(不允许假数据冒充) + const incomplete = clips.some((list) => list.length === 0) + if (incomplete) { + console.warn("[useBatchVariantPlans] 变体计划数据不完整(存在空片段变体),标记加载失败") + setClipsByVariant([]) + setPlanIdsByVariant([]) + setError(true) + } else { + setClipsByVariant(clips) + setPlanIdsByVariant(planIds) + setError(false) + } } catch (err) { if (seq !== requestSeqRef.current) return - const status = (err as { response?: { status?: number } })?.response?.status - if (status === 404) { - // 后端端点未上线:静默降级(本地 variantSeed 模拟),不打扰用户 - setClipsByVariant([]) - setPlanIdsByVariant([]) - } else if (status === 400) { - // 素材不足等业务错误:后端 detail 已由全局拦截器 toast,这里只标记降级,不重复提示 - setClipsByVariant([]) - setPlanIdsByVariant([]) - } else if (!warnedRef.current) { - // 网络/超时/5xx:提示一次后静默降级 - warnedRef.current = true - console.warn("[useBatchVariantPlans] 申请变体计划失败,降级本地模拟预览:", err) - message.info("预览素材排布加载失败,正式生成时每个视频仍会独立随机选片") - setClipsByVariant([]) - setPlanIdsByVariant([]) - } + // 404(后端端点未上线)/ 400(参数错误)/ 网络错误 / 超时:统一错误态 + console.warn("[useBatchVariantPlans] 申请变体计划失败,预览加载失败:", err) + setClipsByVariant([]) + setPlanIdsByVariant([]) + setError(true) } finally { if (seq === requestSeqRef.current) setLoading(false) } }, [templateId, count, sourcePlanId, assetIds]) + /** 用户点击「重试」:nonce +1 驱动 effect 重新发起请求(effect 内 lastKey 校验保证只发一次) */ + const retry = useCallback(() => { + setRetryNonce((n) => n + 1) + }, []) + useEffect(() => { if (!enabled || !templateId || assetIds.length === 0 || count <= 1) { requestSeqRef.current += 1 @@ -117,22 +126,27 @@ export function useBatchVariantPlans({ setClipsByVariant((prev) => (prev.length === 0 ? prev : [])) setPlanIdsByVariant((prev) => (prev.length === 0 ? prev : [])) setLoading((prev) => (prev === false ? prev : false)) + setError((prev) => (prev === false ? prev : false)) lastKeyRef.current = "" return } - const key = `${templateId}|${count}|${sourcePlanId}|${[...assetIds].sort().join(",")}` + const key = `${retryNonce}|${templateId}|${count}|${sourcePlanId}|${[...assetIds] + .sort() + .join(",")}` if (key === lastKeyRef.current) return lastKeyRef.current = key load() - }, [enabled, templateId, count, sourcePlanId, assetIds, load]) + }, [enabled, templateId, count, sourcePlanId, assetIds, load, retryNonce]) - const ready = clipsByVariant.some((list) => list.length > 0) + const ready = !error && !loading && clipsByVariant.every((list) => list.length > 0) return { clipsByVariant, planIdsByVariant, loading, ready, + error, + retry, } } diff --git a/apps/web/src/pages/generate/hooks/usePreviewAssets.ts b/apps/web/src/pages/generate/hooks/usePreviewAssets.ts index 7bf8e801e..c43172d99 100644 --- a/apps/web/src/pages/generate/hooks/usePreviewAssets.ts +++ b/apps/web/src/pages/generate/hooks/usePreviewAssets.ts @@ -44,6 +44,12 @@ interface UsePreviewAssetsReturn { ready: boolean /** 手动触发重新加载 */ reload: () => void + /** + * 差集补拉(#1750):后端变体计划 clips 可能引用不在用户已选列表中的素材 + * (跨素材库选片/素材池扩展),发现 assets 中缺失的 asset_id 时补拉详情并合并, + * 保证预览播放器拿得到素材文件 URL,而不是静默丢片段。 + */ + ensureAssets: (ids: string[]) => Promise } /** @@ -91,11 +97,41 @@ export function usePreviewAssets(assetIds: string[], enabled: boolean): UsePrevi } }, [stableAssetIds, enabled]) + // 补拉用独立序号:不干扰主 load 的竞态守卫 + const ensureSeqRef = useRef(0) + const assetsRef = useRef([]) + useEffect(() => { + assetsRef.current = assets + }, [assets]) + + /** + * 差集补拉(#1750):变体计划 clips 引用的 asset_id 不在当前素材列表时, + * 补拉素材详情并去重合并(不静默丢片段、不用假数据冒充)。 + */ + const ensureAssets = useCallback(async (ids: string[]) => { + const validIds = ids.filter((id): id is string => typeof id === "string" && id.length > 0) + if (!validIds.length) return + const seq = ++ensureSeqRef.current + const missing = Array.from(new Set(validIds)).filter( + (id) => !assetsRef.current.some((a) => a.id === id), + ) + if (!missing.length) return + const fetched = await fetchAssetsByIds(missing) + if (seq !== ensureSeqRef.current || !fetched.length) return + const existing = new Set(assetsRef.current.map((a) => a.id)) + const additions = fetched.filter((a) => !existing.has(a.id)) + if (!additions.length) return + const merged = [...assetsRef.current, ...additions] + assetsRef.current = merged + setAssets(merged) + setReady(merged.length > 0) + }, []) + useEffect(() => { load() }, [load]) - return { assets, loading, ready, reload: load } + return { assets, loading, ready, reload: load, ensureAssets } } /** diff --git a/apps/web/src/pages/generate/hooks/useServerPreview.ts b/apps/web/src/pages/generate/hooks/useServerPreview.ts deleted file mode 100644 index f1724629f..000000000 --- a/apps/web/src/pages/generate/hooks/useServerPreview.ts +++ /dev/null @@ -1,297 +0,0 @@ -/** - * 服务器渲染预览 Hook - * - * 核心职责: - * 1. 调用 POST /generation/preview 创建服务器预览渲染任务 - * 2. 轮询 GET /generation/preview/{task_id} 直到完成 - * 3. 返回服务器渲染的真实视频 URL(供
-
- 预估总时长:{formatDuration(Math.round(totalDuration))} -
) } diff --git a/apps/web/src/pages/templates/constants/templateLibrary.ts b/apps/web/src/pages/templates/constants/templateLibrary.ts index fd79ed0bc..5206045f6 100644 --- a/apps/web/src/pages/templates/constants/templateLibrary.ts +++ b/apps/web/src/pages/templates/constants/templateLibrary.ts @@ -1,4 +1,4 @@ -import type { EditTemplateType, DurationRange } from "../types/templateLibrary" +import type { EditTemplateType } from "../types/templateLibrary" export const TEMPLATE_TYPES: Array<{ type: EditTemplateType | "全部" @@ -15,16 +15,6 @@ export const TEMPLATE_TYPES: Array<{ { type: "Vlog", label: "Vlog", icon: "📹", color: "#ec4899" }, ] -export const DURATION_OPTIONS: Array<{ - value: DurationRange - label: string -}> = [ - { value: "", label: "全部时长" }, - { value: "short", label: "30秒以内" }, - { value: "medium", label: "30秒-2分钟" }, - { value: "long", label: "2分钟以上" }, -] - export const MATERIAL_TYPE_LABELS: Record = { video: "视频", image: "图片", diff --git a/apps/web/src/pages/templates/hooks/useTemplateLibrary.ts b/apps/web/src/pages/templates/hooks/useTemplateLibrary.ts index 028f8d1b8..74996f87a 100644 --- a/apps/web/src/pages/templates/hooks/useTemplateLibrary.ts +++ b/apps/web/src/pages/templates/hooks/useTemplateLibrary.ts @@ -9,7 +9,7 @@ import { type TemplateItem, type TemplateListParams, } from "@/api/templates" -import type { EditTemplateType, DurationRange } from "../types/templateLibrary" +import type { EditTemplateType } from "../types/templateLibrary" import { DEFAULT_PAGE_SIZE } from "../constants/templateLibrary" export const useTemplateLibrary = () => { @@ -19,7 +19,6 @@ export const useTemplateLibrary = () => { /* 筛选状态 */ const [searchText, setSearchText] = useState("") const [activeType, setActiveType] = useState("全部") - const [durationRange, setDurationRange] = useState("") const [page, setPage] = useState(1) const [pageSize] = useState(DEFAULT_PAGE_SIZE) @@ -31,9 +30,8 @@ export const useTemplateLibrary = () => { } if (activeType !== "全部") params.category = activeType if (searchText.trim()) params.keyword = searchText.trim() - if (durationRange) params.duration_range = durationRange return params - }, [page, pageSize, activeType, searchText, durationRange]) + }, [page, pageSize, activeType, searchText]) /* 获取模板列表 */ const { @@ -112,12 +110,6 @@ export const useTemplateLibrary = () => { setPage(1) }, []) - /* 切换时长筛选 */ - const handleDurationChange = useCallback((value: DurationRange) => { - setDurationRange(value) - setPage(1) - }, []) - return { /* 状态 */ templates, @@ -127,7 +119,6 @@ export const useTemplateLibrary = () => { error, searchText, activeType, - durationRange, page, pageSize, /* mutations */ @@ -142,6 +133,5 @@ export const useTemplateLibrary = () => { handleCreate, handleSearchChange, handleCategoryChange, - handleDurationChange, } } diff --git a/apps/web/src/pages/templates/types/templateLibrary.ts b/apps/web/src/pages/templates/types/templateLibrary.ts index fb5e60b0c..d88345c9c 100644 --- a/apps/web/src/pages/templates/types/templateLibrary.ts +++ b/apps/web/src/pages/templates/types/templateLibrary.ts @@ -2,7 +2,6 @@ export type EditTemplateType = "口播" | "种草" | "产品" | "品牌" | "混剪" | "Vlog" /** 时长筛选值 */ -export type DurationRange = "" | "short" | "medium" | "long" /** 配置展示字段 */ export interface ConfigDisplayFields { diff --git a/apps/web/src/pages/templates/utils/templateLibrary.ts b/apps/web/src/pages/templates/utils/templateLibrary.ts index f5c619239..5d9298af1 100644 --- a/apps/web/src/pages/templates/utils/templateLibrary.ts +++ b/apps/web/src/pages/templates/utils/templateLibrary.ts @@ -5,7 +5,6 @@ import { DEFAULT_GRADIENT, MATERIAL_TYPE_LABELS, } from "../constants/templateLibrary" -import type { TemplateSegment } from "@/api/templates" /** 获取类型对应颜色 */ export const getTypeColor = (type: string): string => { @@ -18,16 +17,6 @@ export const gradientForCategory = (category: string): string => { return CATEGORY_GRADIENT_MAP[category] ?? DEFAULT_GRADIENT } -/** 格式化时长 */ -export const formatDuration = (seconds: number | undefined | null): string => { - if (!seconds || seconds <= 0) return "0秒" - const totalSec = Math.round(seconds) - const m = Math.floor(totalSec / 60) - const s = totalSec % 60 - if (m === 0) return `${s}秒` - return `${m}分${s > 0 ? `${s}秒` : ""}` -} - /** 格式化配置对象为可读文本 */ export const formatConfig = (config?: object): string => { if (!config || Object.keys(config).length === 0) return "默认" @@ -47,8 +36,3 @@ export const getMaterialTypeLabel = (materialType: string | null | undefined): s if (!materialType) return "不限" return MATERIAL_TYPE_LABELS[materialType] ?? materialType } - -/** 计算片段总时长(取每个片段 min/max 的平均值) */ -export const calcTotalSegmentDuration = (segments: TemplateSegment[]): number => { - return segments.reduce((sum, s) => sum + (s.duration_min + s.duration_max) / 2, 0) -} diff --git a/apps/web/src/test/pages/generate/CanvasPreviewGrid.audio.test.tsx b/apps/web/src/test/pages/generate/CanvasPreviewGrid.audio.test.tsx index 4ff9402dd..b1d798ab9 100644 --- a/apps/web/src/test/pages/generate/CanvasPreviewGrid.audio.test.tsx +++ b/apps/web/src/test/pages/generate/CanvasPreviewGrid.audio.test.tsx @@ -1,33 +1,38 @@ /** - * CanvasPreviewGrid 单测(Issue #1741) + * CanvasPreviewGrid 单测(Issue #1741 / #1750) * * 验证: - * - N=3 时每个变体都拿到 voiceAudioUrl(修复前仅 index 0 有,视频 2/3 无声) + * - N=3 时每个变体都拿到各自的 voiceAudioUrls[i](独立配音模式,null=TTS 合成中) + * - 每个变体都拿到各自的后端真实片段 variantClips[i](serverClips),playToken 为 0 基变体序号 * - 每个变体都收到 activePlayToken / onPlayTokenChange(播放互斥接线) * - 某个实例上报播放 → 所有实例的 activePlayToken 变为该实例(其他实例收到 token≠自身,自动暂停) * - 实例上报暂停(null)→ 播放权释放 + * - #1750 变体计划申请失败:渲染 role=alert 错误占位 ×N(严禁假数据/不渲染播放器), + * 「重试」按钮仅变体 0 卡片出现且点击触发 onRetryClips + * - #1750 加载态:渲染加载占位,不渲染播放器 */ -import { describe, it, expect, vi } from "vitest" -import { render, fireEvent } from "@testing-library/react" +import { describe, it, expect, beforeEach, vi } from "vitest" +import { render, fireEvent, screen } from "@testing-library/react" import type { AssetItem } from "@/api/assets" +import type { EditPlanClip } from "@/api/template-editor" const playerCalls = vi.hoisted(() => [] as Array>) vi.mock("@/pages/generate/components/FrontendPreviewPlayer", () => ({ default: (props: Record) => { playerCalls.push(props) - const seed = props.variantSeed as number - const token = props.activePlayToken as number | null + const token = props.playToken as number + const active = props.activePlayToken as number | null const change = props.onPlayTokenChange as (t: number | null) => void return ( -
- {props.voiceAudioUrl ? "has-voice" : "no-voice"} - {token == null ? "none" : String(token)} -
) @@ -49,6 +54,24 @@ function makeAsset(id: string): AssetItem { } } +function makeClips(variant: number): EditPlanClip[] { + return ["a1", "a2"].map((assetId, order) => ({ + id: `clip-v${variant}-${assetId}`, + plan_id: `plan-${variant}`, + clip_type: "main", + order, + asset_id: assetId, + text_content: "", + start_time: 0, + duration: 5, + transition_effect: "none", + transition_duration: 0, + playback_speed: 1, + status: "ready", + config: {}, + })) +} + const titleSettings = { size: 36, font: "思源黑体", @@ -62,62 +85,92 @@ const titleSettings = { posY: null, } -function renderGrid(count = 3) { +const variantClips: EditPlanClip[][] = [makeClips(0), makeClips(1), makeClips(2)] + +function renderGrid(overrides: Record = {}) { playerCalls.length = 0 return render( {}} + {...overrides} />, ) } -describe("CanvasPreviewGrid 配音与播放互斥 (#1741)", () => { - it("N=3 时每个变体都拿到 voiceAudioUrl(不再只有 index 0)", () => { - renderGrid(3) +describe("CanvasPreviewGrid 配音、真实片段与播放互斥 (#1741/#1750)", () => { + beforeEach(() => { + playerCalls.length = 0 + }) + + it("N=3 时每个变体都拿到各自的配音 URL 与后端真实片段,playToken 为 0 基序号", () => { + renderGrid() expect(playerCalls).toHaveLength(3) playerCalls.forEach((p, i) => { - expect(p.voiceAudioUrl).toBe("https://cdn.example.com/tts.mp3") - expect(p.variantSeed).toBe(i + 1) + expect(p.playToken).toBe(i) + expect(p.serverClips).toBe(variantClips[i]) + expect(p.variantTitle).toBe(`标题${i + 1}`) }) + // 独立配音:变体 0/2 有各自 URL;变体 1 为 null(TTS 合成中)→ 传 undefined + expect(playerCalls[0].voiceAudioUrl).toBe("https://cdn.example.com/v0.mp3") + expect(playerCalls[1].voiceAudioUrl).toBeUndefined() + expect(playerCalls[2].voiceAudioUrl).toBe("https://cdn.example.com/v2.mp3") + // 配音状态徽标 + expect(screen.getByTestId("voice-0").textContent).toBe("has-voice") + expect(screen.getByTestId("voice-1").textContent).toBe("no-voice") + expect(screen.getByTestId("voice-2").textContent).toBe("has-voice") }) it("每个变体都接线 activePlayToken / onPlayTokenChange", () => { - renderGrid(3) + renderGrid() playerCalls.forEach((p) => { expect(p.activePlayToken).toBeNull() expect(typeof p.onPlayTokenChange).toBe("function") }) }) - it("点击实例2播放:所有实例 activePlayToken 变为 2(其他实例自动暂停)", () => { - const { getByTestId } = renderGrid(3) - fireEvent.click(getByTestId("player-2").querySelector("button")!) - expect(getByTestId("token-1").textContent).toBe("2") - expect(getByTestId("token-2").textContent).toBe("2") - expect(getByTestId("token-3").textContent).toBe("2") + it("点击变体2播放:所有实例 activePlayToken 变为 1(0 基,其他实例自动暂停)", () => { + renderGrid() + fireEvent.click(screen.getByTestId("player-1").querySelector("button")!) + expect(screen.getByTestId("token-0").textContent).toBe("1") + expect(screen.getByTestId("token-1").textContent).toBe("1") + expect(screen.getByTestId("token-2").textContent).toBe("1") }) it("正在播放实例上报暂停后,播放权释放(token 回 null)", () => { - const { getByTestId } = renderGrid(3) - fireEvent.click(getByTestId("player-3").querySelector("button")!) - expect(getByTestId("token-1").textContent).toBe("3") + renderGrid() + fireEvent.click(screen.getByTestId("player-2").querySelector("button")!) + expect(screen.getByTestId("token-0").textContent).toBe("2") - fireEvent.click(getByTestId("player-3").querySelectorAll("button")[1]) - expect(getByTestId("token-3").textContent).toBe("none") + fireEvent.click(screen.getByTestId("player-2").querySelectorAll("button")[1]) + expect(screen.getByTestId("token-2").textContent).toBe("none") }) - it("渲染 N 个勾选标签(视频1..N)且配音状态全部 has-voice", () => { - const { getByTestId } = renderGrid(3) - expect(getByTestId("voice-1").textContent).toBe("has-voice") - expect(getByTestId("voice-2").textContent).toBe("has-voice") - expect(getByTestId("voice-3").textContent).toBe("has-voice") + it("clipsError:每张卡片显示错误占位(role=alert)且不渲染任何播放器,重试按钮仅一个并触发回调", () => { + const onRetry = vi.fn() + renderGrid({ clipsError: true, onRetryClips: onRetry }) + expect(screen.getAllByRole("alert")).toHaveLength(3) + expect(screen.getAllByText("预览加载失败,请重试")).toHaveLength(3) + // 严禁假数据:错误态不渲染播放器 + expect(playerCalls).toHaveLength(0) + // 重试按钮仅变体 0 卡片出现 + const retryButtons = screen.getAllByText("重试") + expect(retryButtons).toHaveLength(1) + fireEvent.click(retryButtons[0]) + expect(onRetry).toHaveBeenCalledTimes(1) + }) + + it("clipsLoading:渲染加载占位,不渲染播放器", () => { + renderGrid({ clipsLoading: true }) + expect(screen.getAllByText("独立选片中…")).toHaveLength(3) + expect(screen.queryAllByRole("alert")).toHaveLength(0) + expect(playerCalls).toHaveLength(0) }) }) diff --git a/apps/web/src/test/pages/generate/FrontendPreviewPlayer.audio.test.tsx b/apps/web/src/test/pages/generate/FrontendPreviewPlayer.audio.test.tsx index 3a4f23e97..4551e5bed 100644 --- a/apps/web/src/test/pages/generate/FrontendPreviewPlayer.audio.test.tsx +++ b/apps/web/src/test/pages/generate/FrontendPreviewPlayer.audio.test.tsx @@ -1,17 +1,19 @@ /** - * FrontendPreviewPlayer 音频行为单测(Issue #1741) + * FrontendPreviewPlayer 音频行为单测(Issue #1741 / #1750) * * useSegmentScheduler/useCanvasPlayer 用 mock 控制播放态,专注验证本组件的音频逻辑: * - 有配音时 video 保持 muted(素材原声不与配音混音) * - 无配音时 video 不 muted(素材原声兜底,保证任何情况下播放有声) * - 静音按钮:默认有声;点击后切 muted,aria-label 与图标切换 - * - 批量播放互斥:activePlayToken 变为其他实例且本实例在播放时,调用 pause + * - 批量播放互斥(#1750 playToken 为 0 基变体序号):activePlayToken 变为其他实例且本实例在播放时,调用 pause * - 点击播放/暂停时上报播放权(onPlayTokenChange) + * - #1750:serverClips 是唯一片段来源,缺失时不渲染任何 video(无本地模拟 fallback) */ import { describe, it, expect, beforeEach, vi } from "vitest" import { render, screen, fireEvent } from "@testing-library/react" import FrontendPreviewPlayer from "@/pages/generate/components/FrontendPreviewPlayer" import type { AssetItem } from "@/api/assets" +import type { EditPlanClip } from "@/api/template-editor" const mocks = vi.hoisted(() => ({ isPlaying: false, @@ -20,17 +22,19 @@ const mocks = vi.hoisted(() => ({ })) vi.mock("@/pages/generate/hooks/useSegmentScheduler", () => ({ - useSegmentScheduler: () => ({ + // segments 由组件 buildPlaybackSegments 产出(唯一来源 serverClips); + // canPlay 与真实 hook 一致:无片段时不可播放 + useSegmentScheduler: vi.fn((segments: unknown[]) => ({ isPlaying: mocks.isPlaying, currentTime: 0, totalDuration: 20, currentSegmentIndex: 0, - canPlay: true, + canPlay: segments.length > 0, togglePlayPause: mocks.togglePlayPause, seekTo: vi.fn(), pause: mocks.pause, videoRefs: { current: [] as (HTMLVideoElement | null)[] }, - }), + })), })) vi.mock("@/pages/generate/hooks/useCanvasPlayer", () => ({ @@ -75,18 +79,38 @@ function makeAsset(id: string): AssetItem { } } +function makeClip(assetId: string, order: number): EditPlanClip { + return { + id: `clip-${assetId}`, + plan_id: "plan-1", + clip_type: "main", + order, + asset_id: assetId, + text_content: "", + start_time: 0, + duration: 5, + transition_effect: "none", + transition_duration: 0, + playback_speed: 1, + status: "ready", + config: {}, + } +} + +const serverClips = [makeClip("a1", 0), makeClip("a2", 1)] + const baseProps = { assets: [makeAsset("a1"), makeAsset("a2")], - template: null, videoRatio: "9:16", ready: true, + serverClips, } function videos(): HTMLVideoElement[] { return Array.from(document.querySelectorAll("video")) } -describe("FrontendPreviewPlayer 音频行为 (#1741)", () => { +describe("FrontendPreviewPlayer 音频行为 (#1741/#1750)", () => { it("有配音时 video 保持 muted(素材原声不与配音混音)", () => { render() expect(videos()).toHaveLength(2) @@ -115,20 +139,20 @@ describe("FrontendPreviewPlayer 音频行为 (#1741)", () => { const { rerender } = render( {}} compact />, ) expect(mocks.pause).not.toHaveBeenCalled() - // 播放权切给实例 3 + // 播放权切给实例 2(0 基 token) rerender( {}} compact />, @@ -139,8 +163,8 @@ describe("FrontendPreviewPlayer 音频行为 (#1741)", () => { rerender( {}} compact />, @@ -151,33 +175,39 @@ describe("FrontendPreviewPlayer 音频行为 (#1741)", () => { it("未播放时 token 变化不触发暂停(effect 仅在本实例播放时生效)", () => { // mocks.isPlaying = false(beforeEach 重置) const { rerender } = render( - , + , ) - rerender() + rerender() expect(mocks.pause).not.toHaveBeenCalled() }) - it("暂停状态下点击播放按钮:上报播放权为自身 variantSeed 并触发播放", () => { + it("暂停状态下点击播放按钮:上报播放权为自身 playToken 并触发播放", () => { const onToken = vi.fn() render( - , + , ) // 暂停态有两个图标播放按钮(中央大按钮 + 控制条按钮),均调 handleTogglePlay,点中央那个 const playButtons = screen.getAllByRole("button").filter((b) => !b.getAttribute("aria-label")) expect(playButtons.length).toBeGreaterThanOrEqual(1) fireEvent.click(playButtons[0]) - expect(onToken).toHaveBeenCalledWith(3) + expect(onToken).toHaveBeenCalledWith(2) expect(mocks.togglePlayPause).toHaveBeenCalledTimes(1) }) it("每个卡片都渲染独立静音按钮", () => { render(
- - - + + +
, ) expect(screen.getAllByRole("button", { name: "静音" })).toHaveLength(3) }) + + it("#1750 serverClips 缺失时不渲染任何 video(无本地模拟 fallback,显示无可播放素材)", () => { + render() + expect(videos()).toHaveLength(0) + expect(screen.getByText("暂无可播放素材")).toBeInTheDocument() + }) }) diff --git a/apps/web/src/test/pages/generate/smart-match-pool.test.ts b/apps/web/src/test/pages/generate/smart-match-pool.test.ts index 6dcdd161f..a5b12d9c3 100644 --- a/apps/web/src/test/pages/generate/smart-match-pool.test.ts +++ b/apps/web/src/test/pages/generate/smart-match-pool.test.ts @@ -1,45 +1,40 @@ /** - * 智能匹配候选池数量规则单测(#1744) - * 候选池从「总时长/15秒」扩大到至少「片段数 × 3」,供批量变体独立选片避让。 + * 智能匹配候选池数量规则单测(#1744/#1750) + * + * #1750 基线:模板只有片段数/结构、没有时长概念——候选池纯按「片段数 × 3」 + * (每个片段至少 3 个候选供变体间洗牌避让),不再参考任何 duration 字段; + * 结果钳制到 [1, 200](后端 limit 上限 200);无片段信息时兜底 30。 */ import { describe, it, expect } from "vitest" import { computeLimitFromSegments } from "@/pages/generate/hooks/step2-materials/useSmartMatch" import type { TemplateSegment } from "@/api/templates/types" -function seg(duration_min: number, duration_max = duration_min): TemplateSegment { - return { duration_min, duration_max } as TemplateSegment +function segs(n: number): TemplateSegment[] { + // 新规则只看片段数量,duration 字段已废弃,这里不再构造 + return Array.from({ length: n }, () => ({}) as TemplateSegment) } -describe("computeLimitFromSegments (#1744 候选池×3)", () => { +describe("computeLimitFromSegments (#1750 候选池 = 片段数×3)", () => { it("无 segments 时返回兜底 30", () => { expect(computeLimitFromSegments(undefined)).toBe(30) expect(computeLimitFromSegments([])).toBe(30) }) - it("候选池至少为片段数 × 3(短片段场景)", () => { - // 5 个片段,每个 2 秒:旧规则 max(5, ceil(10/15)=1)=5;新规则 5×3=15 - const segs = Array.from({ length: 5 }, () => seg(2)) - expect(computeLimitFromSegments(segs)).toBe(15) + it("候选池 = 片段数 × 3(与任何时长字段无关)", () => { + expect(computeLimitFromSegments(segs(1))).toBe(3) + expect(computeLimitFromSegments(segs(3))).toBe(9) + expect(computeLimitFromSegments(segs(5))).toBe(15) + expect(computeLimitFromSegments(segs(10))).toBe(30) }) - it("10 个片段 → 至少 30 个候选", () => { - const segs = Array.from({ length: 10 }, () => seg(3)) - expect(computeLimitFromSegments(segs)).toBe(30) + it("超过上限钳制为 200(后端 limit 上限)", () => { + expect(computeLimitFromSegments(segs(67))).toBe(200) // 67×3=201 → 200 + expect(computeLimitFromSegments(segs(100))).toBe(200) // 100×3=300 → 200 }) - it("长时长场景取「时长/15秒」与「片段数×3」的较大值", () => { - // 3 个片段各 300 秒:3×3=9 vs ceil(900/15)=60 → 60 - const segs = Array.from({ length: 3 }, () => seg(300)) - expect(computeLimitFromSegments(segs)).toBe(60) - }) - - it("上限钳制 200(后端 limit 上限)", () => { - const segs = Array.from({ length: 100 }, () => seg(10)) - expect(computeLimitFromSegments(segs)).toBe(200) - }) - - it("总时长为 0 时仍保证片段数×3", () => { - const segs = Array.from({ length: 4 }, () => seg(0)) - expect(computeLimitFromSegments(segs)).toBe(12) + it("任何正常片段数下 limit 均 ≥ 1", () => { + for (let n = 0; n <= 10; n++) { + expect(computeLimitFromSegments(segs(n))).toBeGreaterThanOrEqual(1) + } }) }) diff --git a/apps/web/src/test/pages/generate/smoke.test.tsx b/apps/web/src/test/pages/generate/smoke.test.tsx index 9e58e0c6b..eaf71c9e0 100755 --- a/apps/web/src/test/pages/generate/smoke.test.tsx +++ b/apps/web/src/test/pages/generate/smoke.test.tsx @@ -5,7 +5,6 @@ * * 重构记录: * - useStep2Materials 拆分为 useMaterialLibrary + useSmartMatch 子 Hook - * - Phase1 预览生成:新增 generation API 层 + PreviewVideoPanel */ import { describe, it, expect } from "vitest" @@ -23,8 +22,6 @@ import "@/pages/generate/components/Step3VoiceWithMode" import "@/pages/generate/components/CanvasPreviewGrid" import "@/pages/generate/components/BatchGenerationGrid" import "@/pages/generate/components/PreviewCountModal" -import "@/pages/generate/components/PreviewVideoPanel" -import "@/pages/generate/components/GenerateResultPanel" import "@/pages/generate/components/GenerateStepContent" import "@/pages/generate/components/voice/VoiceRecommendSection" import "@/pages/generate/components/voice/VoiceChoiceCard" @@ -50,6 +47,8 @@ describe("GeneratePage module smoke test", () => { }) import "@/pages/generate/hooks/useGenerateVideo" import "@/pages/generate/hooks/useBatchCovers" +import "@/pages/generate/hooks/useBatchVariantPlans" +import "@/pages/generate/hooks/useVariantVoicePreview" import "@/pages/generate/hooks/usePreviewAssets" import "@/pages/generate/hooks/useSegmentScheduler" import "@/pages/generate/hooks/generate-video/useGenerationPolling" diff --git a/apps/web/src/test/pages/generate/useBatchVariantPlans.test.tsx b/apps/web/src/test/pages/generate/useBatchVariantPlans.test.tsx index bf639888e..5e87e7c55 100644 --- a/apps/web/src/test/pages/generate/useBatchVariantPlans.test.tsx +++ b/apps/web/src/test/pages/generate/useBatchVariantPlans.test.tsx @@ -1,13 +1,14 @@ /** - * useBatchVariantPlans 单测(#1744) + * useBatchVariantPlans 单测(#1750 修正:严禁假数据冒充预览) * - 批量(N>1)时向后端申请变体计划,返回 clips/planIds - * - 404(端点未上线)静默降级:ready=false,不 toast - * - 400(素材不足)静默降级(后端 detail 由全局拦截器 toast) - * - N=1 / 无素材不发请求 - * - 入参变化(素材/数量)重新申请;未变化不重复请求 + * - 404(端点未上线)/ 400(参数错误)/ 网络错误:一律 error=true,UI 可重试 + * - 数据不完整(某变体 clips 为空):error=true + * - retry() 重新发起请求,成功后 ready=true + * - N=1 / 无素材 / disabled 不发请求 + * - 入参变化重新申请;未变化不重复请求 */ import { describe, it, expect, vi, beforeEach } from "vitest" -import { renderHook, waitFor } from "@testing-library/react" +import { renderHook, waitFor, act } from "@testing-library/react" import { useBatchVariantPlans } from "@/pages/generate/hooks/useBatchVariantPlans" import type { EditPlanClip } from "@/api/template-editor" @@ -15,13 +16,7 @@ vi.mock("@/api/generation/variantPlans", () => ({ createBatchVariantPlans: vi.fn(), })) -// 轻量 mock:hook 只用到 message.info(避免全量 importActual antd 的开销) -vi.mock("antd", () => ({ - message: { info: vi.fn(), error: vi.fn(), warning: vi.fn(), success: vi.fn(), loading: vi.fn() }, -})) - import { createBatchVariantPlans } from "@/api/generation/variantPlans" -import { message } from "antd" const mockCreate = vi.mocked(createBatchVariantPlans) @@ -60,7 +55,7 @@ beforeEach(() => { mockCreate.mockReset() }) -describe("useBatchVariantPlans", () => { +describe("useBatchVariantPlans (#1750 错误态)", () => { it("批量 N=3 时申请变体计划并返回按索引对齐的 clips/planIds", async () => { mockCreate.mockResolvedValueOnce(buildResp(3)) const { result } = renderHook(() => @@ -83,6 +78,7 @@ describe("useBatchVariantPlans", () => { }) expect(result.current.planIdsByVariant).toEqual(["plan-0", "plan-1", "plan-2"]) expect(result.current.clipsByVariant[1]?.[0]?.start_time).toBe(2) + expect(result.current.error).toBe(false) expect(result.current.loading).toBe(false) }) @@ -96,48 +92,68 @@ describe("useBatchVariantPlans", () => { expect(arg).not.toHaveProperty("source_edit_plan_id") }) - it("404(端点未上线)静默降级:ready=false 且不弹任何提示", async () => { + it("404(后端端点未上线)→ error=true,不再静默降级", async () => { mockCreate.mockRejectedValueOnce({ response: { status: 404 } }) const { result } = renderHook(() => useBatchVariantPlans({ enabled: true, count: 3, templateId: "tpl-1", assetIds: ["a1"] }), ) await waitFor(() => expect(result.current.loading).toBe(false)) + expect(result.current.error).toBe(true) expect(result.current.ready).toBe(false) expect(result.current.clipsByVariant).toEqual([]) - expect(message.info).not.toHaveBeenCalled() - expect(message.error).not.toHaveBeenCalled() }) - it("400(素材不足)静默降级,不重复弹错(后端 detail 全局拦截器已 toast)", async () => { - mockCreate.mockRejectedValueOnce({ response: { status: 400, data: { detail: "素材不足" } } }) + it("400(参数错误)→ error=true", async () => { + mockCreate.mockRejectedValueOnce({ response: { status: 400, data: { detail: "参数错误" } } }) const { result } = renderHook(() => useBatchVariantPlans({ enabled: true, count: 3, templateId: "tpl-1", assetIds: ["a1"] }), ) await waitFor(() => expect(result.current.loading).toBe(false)) + expect(result.current.error).toBe(true) expect(result.current.ready).toBe(false) - expect(message.info).not.toHaveBeenCalled() }) - it("网络错误降级并仅提示一次", async () => { + it("网络错误 → error=true", async () => { mockCreate.mockRejectedValue(new Error("Network Error")) - const { result, rerender } = renderHook( - (props: { count: number }) => - useBatchVariantPlans({ - enabled: true, - count: props.count, - templateId: "tpl-1", - assetIds: ["a1"], - }), - { initialProps: { count: 3 } }, + const { result } = renderHook(() => + useBatchVariantPlans({ enabled: true, count: 3, templateId: "tpl-1", assetIds: ["a1"] }), ) await waitFor(() => expect(result.current.loading).toBe(false)) + expect(result.current.error).toBe(true) expect(result.current.ready).toBe(false) - expect(message.info).toHaveBeenCalledTimes(1) + }) - // 入参变化触发第二次失败,warnedRef 保证不再重复提示 - rerender({ count: 4 }) + it("某变体 clips 为空(数据不完整)→ error=true,严禁假数据冒充", async () => { + mockCreate.mockResolvedValueOnce({ + total: 2, + items: [ + { variant_index: 0, plan_id: "plan-0", clips: [makeClip({ asset_id: "a1", order: 0 })] }, + { variant_index: 1, plan_id: "plan-1", clips: [] }, + ], + }) + const { result } = renderHook(() => + useBatchVariantPlans({ enabled: true, count: 2, templateId: "tpl-1", assetIds: ["a1"] }), + ) await waitFor(() => expect(result.current.loading).toBe(false)) - expect(message.info).toHaveBeenCalledTimes(1) + expect(result.current.error).toBe(true) + expect(result.current.ready).toBe(false) + expect(result.current.clipsByVariant).toEqual([]) + }) + + it("retry() 失败后重试:成功则 error 清除、ready=true", async () => { + mockCreate.mockRejectedValueOnce({ response: { status: 404 } }) + const { result } = renderHook(() => + useBatchVariantPlans({ enabled: true, count: 3, templateId: "tpl-1", assetIds: ["a1"] }), + ) + await waitFor(() => expect(result.current.error).toBe(true)) + + mockCreate.mockResolvedValueOnce(buildResp(3)) + act(() => { + result.current.retry() + }) + await waitFor(() => expect(result.current.ready).toBe(true)) + expect(result.current.error).toBe(false) + expect(mockCreate).toHaveBeenCalledTimes(2) }) it("N=1 不发请求(单视频零回归)", () => { @@ -163,11 +179,11 @@ describe("useBatchVariantPlans", () => { expect(mockCreate).not.toHaveBeenCalled() }) - it("响应乱序/越界 variant_index 被归一化到按索引对齐", async () => { + it("响应乱序/越界 variant_index:越界丢弃、其余按索引对齐", async () => { mockCreate.mockResolvedValueOnce({ total: 2, items: [ - { variant_index: 99, plan_id: "plan-bad", clips: [] }, + { variant_index: 99, plan_id: "plan-bad", clips: [makeClip({ asset_id: "a9", order: 0 })] }, { variant_index: 1, plan_id: "plan-1", clips: [makeClip({ asset_id: "a2", order: 0 })] }, { variant_index: 0, plan_id: "plan-0", clips: [makeClip({ asset_id: "a1", order: 0 })] }, ], @@ -181,10 +197,14 @@ describe("useBatchVariantPlans", () => { }), ) await waitFor(() => expect(result.current.ready).toBe(true)) + // 越界 99 丢弃;乱序返回的 0/1 按索引对齐 + expect(result.current.error).toBe(false) expect(result.current.planIdsByVariant).toEqual(["plan-0", "plan-1"]) + expect(result.current.clipsByVariant[0]?.[0]?.asset_id).toBe("a1") + expect(result.current.clipsByVariant[1]?.[0]?.asset_id).toBe("a2") }) - it("非 ready 状态的 clip 被过滤", async () => { + it("非 ready 状态的 clip 被过滤;过滤后每变体仍有片段则 ready", async () => { mockCreate.mockResolvedValueOnce({ total: 2, items: [ @@ -203,6 +223,7 @@ describe("useBatchVariantPlans", () => { useBatchVariantPlans({ enabled: true, count: 2, templateId: "tpl-1", assetIds: ["a1"] }), ) await waitFor(() => expect(result.current.ready).toBe(true)) + expect(result.current.error).toBe(false) expect(result.current.clipsByVariant[0]).toHaveLength(1) expect(result.current.clipsByVariant[0]?.[0]?.asset_id).toBe("a1") }) -- 2.54.0