From bcbcb41750971118983865da2925bb64f5686b0b Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 6 Sep 2026 20:45:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(generate):=20#1744=20=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E5=AF=B9=E6=8E=A5=E5=90=8E=E7=AB=AF=E5=8F=98?= =?UTF-8?q?=E4=BD=93=E7=9C=9F=E5=AE=9E=E7=89=87=E6=AE=B5=20+=20=E6=99=BA?= =?UTF-8?q?=E8=83=BD=E5=8C=B9=E9=85=8D=E5=80=99=E9=80=89=E6=B1=A0=C3=973?= =?UTF-8?q?=20(#1748)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: xiaoxia Co-committed-by: xiaoxia --- apps/web/src/api/generation/variantPlans.ts | 61 +++++ apps/web/src/api/tasks/types.ts | 6 + apps/web/src/pages/generate/GeneratePage.tsx | 20 ++ .../generate/components/CanvasPreviewGrid.tsx | 28 ++- apps/web/src/pages/generate/generate.css | 22 ++ .../generate/hooks/generate-video/types.ts | 6 + .../hooks/step2-materials/useSmartMatch.ts | 23 +- .../generate/hooks/useBatchVariantPlans.ts | 139 ++++++++++++ .../pages/generate/hooks/useGenerateVideo.ts | 9 + .../pages/generate/smart-match-pool.test.ts | 45 ++++ .../generate/useBatchVariantPlans.test.tsx | 209 ++++++++++++++++++ 11 files changed, 557 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/api/generation/variantPlans.ts create mode 100644 apps/web/src/pages/generate/hooks/useBatchVariantPlans.ts create mode 100644 apps/web/src/test/pages/generate/smart-match-pool.test.ts create mode 100644 apps/web/src/test/pages/generate/useBatchVariantPlans.test.tsx diff --git a/apps/web/src/api/generation/variantPlans.ts b/apps/web/src/api/generation/variantPlans.ts new file mode 100644 index 000000000..b8fa4b116 --- /dev/null +++ b/apps/web/src/api/generation/variantPlans.ts @@ -0,0 +1,61 @@ +/** + * 批量变体剪辑计划 API(#1744) + * + * 批量预览时向后端申请 N 个变体的「独立剪辑计划片段」: + * - 变体 0 保留源 plan(用户在编辑器/智能选片产出的片段,含标题样式编辑结果); + * - 变体 1..N-1 由后端 reselect_plan_for_variant 完整重跑单视频选片流程 + * (素材洗牌 + main 片段顺序洗牌 + 镜头/起点随机 + 跨变体 20% 区间避让 + + * 素材使用区间写回 metadata),与正式批量生成 POST /generation/tasks?count=N + * 使用同一套选片逻辑; + * - 正式生成时把 variant_plan_ids 原样回传,后端直接关联这些 plan 渲染, + * 不再重新选片 —— 预览所见即成片。 + * + * 该接口只做选片/建 plan(秒级),不触发视频渲染,无渲染成本。 + * 后端端点未上线(404)或选片失败(素材不足等)时前端降级为本地 variantSeed + * 模拟预览,不阻塞用户流程。 + */ +import apiClient from "../client" +import type { EditPlanClip } from "../template-editor" + +/** 批量变体计划请求体 */ +export interface BatchVariantPlansRequest { + template_id: string + /** 本批次素材池(手动选择或智能匹配结果) */ + asset_ids: string[] + /** 变体数量(≥1);=1 时只返回源 plan 片段 */ + count: number + /** 源剪辑计划 ID:优先取预览/草稿关联的 plan;不传由后端按 template_id+user 兜底最新 plan */ + source_edit_plan_id?: string +} + +/** 单个变体的计划片段 */ +export interface VariantPlan { + /** 变体序号,从 0 开始 */ + variant_index: number + /** 该变体关联的剪辑计划 ID(正式生成时回传,实现预览即成片) */ + plan_id: string + /** 该变体的真实片段(顺序/素材/起点与正式成片一致) */ + clips: EditPlanClip[] +} + +/** 批量变体计划响应 */ +export interface BatchVariantPlansResponse { + items: VariantPlan[] + total: number +} + +/** + * 创建批量变体剪辑计划并返回各变体片段。 + * + * 注意:端点 404(后端未上线)/ 400(素材不足)等失败由调用方 catch 后降级, + * 不要抛 unhandled rejection。 + */ +export async function createBatchVariantPlans( + params: BatchVariantPlansRequest, +): Promise { + const response = await apiClient.post( + "/generation/variant-plans", + params, + ) + return response.data +} diff --git a/apps/web/src/api/tasks/types.ts b/apps/web/src/api/tasks/types.ts index 246c8debe..4dd381eef 100644 --- a/apps/web/src/api/tasks/types.ts +++ b/apps/web/src/api/tasks/types.ts @@ -100,6 +100,12 @@ export interface CreateGenerationTaskRequest { voice_library_ids?: string[] /** 各变体独立封面URL:长度1=共用,长度=count=独立,空数组=回退 cover_url */ cover_urls?: string[] + /** + * 批量变体剪辑计划 ID(#1744):预览阶段后端独立选片产出的 plan id 列表 + * (按变体全量索引,长度=previewCount)。正式生成回传后后端直接关联这些 + * plan 渲染、不再重新选片,保证预览所见即成片。后端未支持时忽略该字段。 + */ + variant_plan_ids?: string[] } /** 单个生成任务详情(对齐后端 GenerationTaskResponse) */ diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index a31a87f58..990dc52cf 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -27,6 +27,7 @@ import { useStepNavigation } from "./hooks/useStepNavigation" import { useGenerateVideo } from "./hooks/useGenerateVideo" import { usePreviewAssets } from "./hooks/usePreviewAssets" +import { useBatchVariantPlans } from "./hooks/useBatchVariantPlans" import { useTitleStyleUpdaters } from "./hooks/useStep4Title/useTitleStyleUpdaters" import { getAssetsByKind } from "@/api/assets" import { previewTts } from "@/api/tts" @@ -221,6 +222,21 @@ const GeneratePage: React.FC = () => { /* ── 预览就绪:纯前端 Canvas 预览,素材详情加载完即可秒开(单视频/批量一致) ── */ const previewReady = previewAssetsReady && !!currentTemplate + /* ── 批量变体真实片段(#1744):后端独立选片,预览即成片;失败静默降级本地模拟 ── + 仅批量(N>1)且在第 4 步预览时申请,避免选素材阶段频繁请求; + 变体 0 沿用草稿 plan(与单视频一致),变体 1..N-1 后端 reselect 独立选片 */ + const { + clipsByVariant: variantClips, + planIdsByVariant: variantPlanIds, + loading: variantClipsLoading, + } = useBatchVariantPlans({ + enabled: isBatch && currentStep === 4 && previewAssetsReady, + count: previewCount, + templateId: selectedTemplate || "", + assetIds: previewAssetIds, + sourcePlanId: storedSourceEditPlanId || sourceEditPlanId || "", + }) + /* ── 勾选变体 ── */ const toggleVariantSelect = useCallback( (index: number) => { @@ -262,6 +278,8 @@ const GeneratePage: React.FC = () => { autoSubtitles, bgm, sourceEditPlanId: storedSourceEditPlanId || sourceEditPlanId, + // #1744:批量预览阶段后端为每个变体生成的独立 plan id,正式生成回传 → 预览即成片 + variantPlanIds, previewTaskId, bgmConfig, previewCount, @@ -430,6 +448,8 @@ const GeneratePage: React.FC = () => { titles={previewTitles} titleSettings={titleSettings} voiceAudioUrl={previewVoiceAudioUrl || undefined} + variantClips={variantClips} + clipsLoading={variantClipsLoading} selectedIds={selectedVariantIds} onToggleSelect={toggleVariantSelect} selectable={!generating} diff --git a/apps/web/src/pages/generate/components/CanvasPreviewGrid.tsx b/apps/web/src/pages/generate/components/CanvasPreviewGrid.tsx index 775aea879..618053d2e 100644 --- a/apps/web/src/pages/generate/components/CanvasPreviewGrid.tsx +++ b/apps/web/src/pages/generate/components/CanvasPreviewGrid.tsx @@ -1,9 +1,11 @@ /** - * 批量前端 Canvas 实时预览网格(Issue #1677 修正方案) + * 批量前端 Canvas 实时预览网格(Issue #1677 修正方案,#1744 对齐后端真实选片) * * N 个 FrontendPreviewPlayer 网格排列: * - 纯前端 Canvas + video 元素实时播放素材片段,不调任何后端渲染接口 - * - variantSeed 让每个变体素材排布/起始点不同,画面有可见差异 + * - #1744:优先使用后端变体独立选片返回的真实片段(variantClips)——与正式 + * 批量生成同源自 reselect_plan_for_variant,预览素材排布/起点即成片; + * 后端片段未就绪(端点未上线/降级)时回退 variantSeed 本地模拟,保证可用 * - 各自叠加独立标题浮层(variantTitle),标题样式全局共用 * - 勾选框决定提交时生成哪些变体 * - 每个变体都挂载同一条配音 URL(浏览器缓存不重复下载);播放互斥: @@ -12,6 +14,8 @@ import React, { useState } from "react" import type { AssetItem } from "@/api/assets" import type { EditingTemplate } from "@/api/editing-planner" +import type { EditPlanClip } from "@/api/template-editor" +import { LoadingOutlined } from "@ant-design/icons" import type { TitleSettings } from "../types" import FrontendPreviewPlayer from "./FrontendPreviewPlayer" @@ -24,6 +28,13 @@ interface CanvasPreviewGridProps { titleSettings: TitleSettings /** 共用配音预览音频 URL(#1741:每个变体都挂载,播放互斥保证同一时刻只有一路发声) */ voiceAudioUrl?: string + /** + * 各变体的后端真实片段(#1744):长度=count,空数组=该变体未就绪/降级本地模拟。 + * 与正式批量生成同源选片,预览所见即成片。 + */ + variantClips?: EditPlanClip[][] + /** 是否正在向后端申请变体计划(显示轻量加载提示,不阻塞本地模拟预览) */ + clipsLoading?: boolean /** 勾选的变体序号 */ selectedIds: number[] onToggleSelect: (index: number) => void @@ -39,6 +50,8 @@ const CanvasPreviewGrid: React.FC = ({ titles, titleSettings, voiceAudioUrl, + variantClips, + clipsLoading = false, selectedIds, onToggleSelect, selectable = true, @@ -70,12 +83,23 @@ const CanvasPreviewGrid: React.FC = ({ 视频 {i + 1} + {clipsLoading && (!variantClips || variantClips[i]?.length === 0) && ( +
+ + 独立选片中… +
+ )} 0} variantSeed={i + 1} + // #1744:有后端真实片段时播放器优先使用(buildPlaybackSegments 内 serverClips 优先), + // 与正式成片同源;为空则自动回退 variantSeed 本地模拟 + serverClips={ + variantClips && variantClips[i]?.length > 0 ? variantClips[i] : undefined + } variantTitle={titles[i] || ""} voiceAudioUrl={voiceAudioUrl} activePlayToken={activePlayToken} diff --git a/apps/web/src/pages/generate/generate.css b/apps/web/src/pages/generate/generate.css index 28c95db7e..8d782eaf3 100644 --- a/apps/web/src/pages/generate/generate.css +++ b/apps/web/src/pages/generate/generate.css @@ -3299,6 +3299,7 @@ } .xx-canvas-grid-card { + position: relative; border: 2px solid var(--border-primary, #e2e8f0); border-radius: 12px; overflow: hidden; @@ -3340,6 +3341,27 @@ accent-color: var(--primary-color, #1677ff); } +/* #1744:批量变体独立选片加载提示(浮在卡片右上角,不遮挡播放控件) */ +.xx-variant-clips-loading { + position: absolute; + top: 34px; + right: 8px; + z-index: 3; + display: inline-flex; + 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; +} + +.xx-variant-clips-loading .anticon { + font-size: 12px; +} + /* ============================================================ 批量标题:AI 一键生成行(Issue #1677) ============================================================ */ diff --git a/apps/web/src/pages/generate/hooks/generate-video/types.ts b/apps/web/src/pages/generate/hooks/generate-video/types.ts index 7d288cd3c..9b0ed91f8 100755 --- a/apps/web/src/pages/generate/hooks/generate-video/types.ts +++ b/apps/web/src/pages/generate/hooks/generate-video/types.ts @@ -42,6 +42,12 @@ export interface UseGenerateVideoProps { variantCoverUrls?: string[] /** 勾选要生成的变体索引(批量模式) */ selectedVariantIndexes?: number[] + /** + * 批量变体剪辑计划 ID(#1744,按变体全量索引长度=previewCount): + * 预览阶段后端独立选片产出,正式生成按勾选顺序回传,实现预览即成片; + * 为空(降级本地模拟/后端未上线)时不传,后端自行独立选片。 + */ + variantPlanIds?: string[] } /** 生成阶段 */ 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 3be6c288e..5a625fa3d 100755 --- a/apps/web/src/pages/generate/hooks/step2-materials/useSmartMatch.ts +++ b/apps/web/src/pages/generate/hooks/step2-materials/useSmartMatch.ts @@ -13,21 +13,26 @@ interface UseSmartMatchOptions { } /** 默认 limit(拿不到目标时长时的兜底上限) */ -const DEFAULT_LIMIT = 10 -/** 每个素材切片按 15 秒估算所需素材数 */ -const SECONDS_PER_ASSET = 15 +const DEFAULT_LIMIT = 30 +/** + * 候选池放大倍数(#1744):批量 N 个变体独立选片、跨变体 20% 区间避让需要 + * 足够大的素材池才能保证 N 条成片素材排布互不相同(历史已用区间写回素材 metadata)。 + * 旧规则「总时长/15 秒」只够 1 条成片的选片量,池太小会导致变体间大量复用同区间。 + */ +const ASSET_POOL_MULTIPLIER = 3 /** - * 根据模板 segments 计算所需素材数量上限。 - * 取每个 segment 的 duration_min 之和作为目标视频总时长, - * 再按 15 秒/素材估算需要多少个素材,且保证不少于片段数(每个片段至少 1 个素材); + * 根据模板 segments 计算智能匹配候选素材数量上限(#1744 调整)。 + * 候选池至少为「片段数 × 3」: + * - 每个片段至少有 3 个候选素材可供变体间洗牌/避让(独立选片 + 区间不重叠); + * - 同时不低于「总时长/15 秒」的时长覆盖率估算,两者取大; * 结果钳制到 [1, 200] 区间(后端 limit 上限 200)。 */ -function computeLimitFromSegments(segments?: TemplateSegment[]): number { +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) - if (totalSeconds <= 0) return DEFAULT_LIMIT - const limit = Math.max(segments.length, Math.ceil(totalSeconds / SECONDS_PER_ASSET)) + const byDuration = totalSeconds > 0 ? Math.ceil(totalSeconds / 15) : 0 + const limit = Math.max(segments.length * ASSET_POOL_MULTIPLIER, byDuration, segments.length) return Math.max(1, Math.min(limit, 200)) } diff --git a/apps/web/src/pages/generate/hooks/useBatchVariantPlans.ts b/apps/web/src/pages/generate/hooks/useBatchVariantPlans.ts new file mode 100644 index 000000000..e13649fe3 --- /dev/null +++ b/apps/web/src/pages/generate/hooks/useBatchVariantPlans.ts @@ -0,0 +1,139 @@ +/** + * 批量变体真实片段 Hook(#1744) + * + * 批量预览(N>1)进入第 4 步时,向后端轻量接口 POST /generation/variant-plans + * 申请 N 个变体的独立剪辑计划片段: + * - 接口只做选片/建 plan(秒级),不渲染视频,无渲染成本; + * - 选片逻辑与正式批量生成(POST /generation/tasks?count=N)完全同源 + * (reselect_plan_for_variant:素材洗牌 + main 片段洗牌 + 起点随机 + + * 跨变体 20% 区间避让 + 使用区间写回素材 metadata), + * 因此前端按这些 clips 播放的预览与最终成片一致; + * - 正式生成时把 plan_ids 回传,后端直接关联预览 plan 渲染,不再重新选片。 + * + * 降级策略(不阻塞用户): + * - 端点 404(后端未上线)/ 网络错误 / 超时:静默降级为本地 variantSeed 模拟; + * - 400(素材库不足无法独立选片):展示后端提示一次,降级本地模拟 + * (正式生成时后端仍会拦截并给出同样提示,不会静默出同源成片); + * - 单个变体 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 排序);未就绪/降级的变体为空数组 */ + clipsByVariant: EditPlanClip[][] + /** 各变体的 plan_id(正式生成回传,保证预览即成片);降级/未就绪为空串 */ + planIdsByVariant: string[] + /** 是否正在向后端申请变体计划 */ + loading: boolean + /** 后端真实片段是否可用(至少变体 0 有片段);false 时调用方应走本地模拟 */ + ready: boolean +} + +interface UseBatchVariantPlansOptions { + /** 是否启用:仅批量(count>1)且素材已选时为 true */ + enabled: boolean + count: number + templateId: string + assetIds: string[] + /** 源剪辑计划 ID(草稿/预览关联),无则空串由后端兜底最新 plan */ + sourcePlanId?: string +} + +export function useBatchVariantPlans({ + enabled, + count, + templateId, + assetIds, + sourcePlanId = "", +}: UseBatchVariantPlansOptions): BatchVariantClipsState { + const [clipsByVariant, setClipsByVariant] = useState([]) + const [planIdsByVariant, setPlanIdsByVariant] = useState([]) + const [loading, setLoading] = useState(false) + + const requestSeqRef = useRef(0) + const warnedRef = useRef(false) + // 记录上次成功申请的入参指纹,素材/数量未变时不重复请求 + const lastKeyRef = useRef("") + + const load = useCallback(async () => { + const seq = ++requestSeqRef.current + setLoading(true) + try { + const resp = await createBatchVariantPlans({ + template_id: templateId, + asset_ids: assetIds, + count, + ...(sourcePlanId ? { source_edit_plan_id: sourcePlanId } : {}), + }) + if (seq !== requestSeqRef.current) return + + const items: VariantPlan[] = Array.isArray(resp.items) ? resp.items : [] + const clips: EditPlanClip[][] = Array.from({ length: count }, () => []) + const planIds: string[] = Array.from({ length: count }, () => "") + for (const item of items) { + const idx = item.variant_index + if (idx < 0 || idx >= count) continue + planIds[idx] = item.plan_id || "" + clips[idx] = (item.clips || []) + .filter((c) => c && c.asset_id && c.status === "ready") + .sort((a, b) => a.order - b.order) + } + setClipsByVariant(clips) + setPlanIdsByVariant(planIds) + } 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([]) + } + } finally { + if (seq === requestSeqRef.current) setLoading(false) + } + }, [templateId, count, sourcePlanId, assetIds]) + + useEffect(() => { + if (!enabled || !templateId || assetIds.length === 0 || count <= 1) { + requestSeqRef.current += 1 + // 函数式更新:已是目标值时返回 prev(Object.is 相等 React 跳过渲染), + // 避免父组件传入内联字面量数组导致 effect 每次 render 触发 → 无限 setState 循环 + setClipsByVariant((prev) => (prev.length === 0 ? prev : [])) + setPlanIdsByVariant((prev) => (prev.length === 0 ? prev : [])) + setLoading((prev) => (prev === false ? prev : false)) + lastKeyRef.current = "" + return + } + const key = `${templateId}|${count}|${sourcePlanId}|${[...assetIds].sort().join(",")}` + if (key === lastKeyRef.current) return + lastKeyRef.current = key + load() + }, [enabled, templateId, count, sourcePlanId, assetIds, load]) + + const ready = clipsByVariant.some((list) => list.length > 0) + + return { + clipsByVariant, + planIdsByVariant, + loading, + ready, + } +} + +export default useBatchVariantPlans diff --git a/apps/web/src/pages/generate/hooks/useGenerateVideo.ts b/apps/web/src/pages/generate/hooks/useGenerateVideo.ts index 2d7a6d0d9..f4ded6a95 100755 --- a/apps/web/src/pages/generate/hooks/useGenerateVideo.ts +++ b/apps/web/src/pages/generate/hooks/useGenerateVideo.ts @@ -178,6 +178,14 @@ export function useGenerateVideo(props: UseGenerateVideoProps) { isBatch && props.variantCoverUrls?.length ? indexes.map((i) => props.variantCoverUrls![i] || "") : [] + // #1744 变体 plan 数组:预览阶段后端独立选片产出的 plan id,按勾选顺序回传, + // 后端直接关联这些 plan 渲染(不再重新选片)→ 预览所见即成片。 + // 全部为空(降级本地模拟/后端端点未上线)时不传,后端走自身独立选片。 + const variantPlansArr = + isBatch && props.variantPlanIds?.length + ? indexes.map((i) => props.variantPlanIds![i] || "") + : [] + const hasVariantPlans = variantPlansArr.some((id) => !!id) try { const taskResp = await createGenerationTask({ @@ -200,6 +208,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) { ...(titlesArr.length ? { titles: titlesArr } : {}), ...(voiceArr.length ? { voice_library_ids: voiceArr } : {}), ...(coversArr.length ? { cover_urls: coversArr } : {}), + ...(hasVariantPlans ? { variant_plan_ids: variantPlansArr } : {}), ...(props.titleSettings?.title ? { title_config: { 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 new file mode 100644 index 000000000..6dcdd161f --- /dev/null +++ b/apps/web/src/test/pages/generate/smart-match-pool.test.ts @@ -0,0 +1,45 @@ +/** + * 智能匹配候选池数量规则单测(#1744) + * 候选池从「总时长/15秒」扩大到至少「片段数 × 3」,供批量变体独立选片避让。 + */ +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 +} + +describe("computeLimitFromSegments (#1744 候选池×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("10 个片段 → 至少 30 个候选", () => { + const segs = Array.from({ length: 10 }, () => seg(3)) + expect(computeLimitFromSegments(segs)).toBe(30) + }) + + 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) + }) +}) diff --git a/apps/web/src/test/pages/generate/useBatchVariantPlans.test.tsx b/apps/web/src/test/pages/generate/useBatchVariantPlans.test.tsx new file mode 100644 index 000000000..bf639888e --- /dev/null +++ b/apps/web/src/test/pages/generate/useBatchVariantPlans.test.tsx @@ -0,0 +1,209 @@ +/** + * useBatchVariantPlans 单测(#1744) + * - 批量(N>1)时向后端申请变体计划,返回 clips/planIds + * - 404(端点未上线)静默降级:ready=false,不 toast + * - 400(素材不足)静默降级(后端 detail 由全局拦截器 toast) + * - N=1 / 无素材不发请求 + * - 入参变化(素材/数量)重新申请;未变化不重复请求 + */ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { renderHook, waitFor } from "@testing-library/react" +import { useBatchVariantPlans } from "@/pages/generate/hooks/useBatchVariantPlans" +import type { EditPlanClip } from "@/api/template-editor" + +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) + +function makeClip( + partial: Partial & { asset_id: string; order: number }, +): EditPlanClip { + return { + id: `clip-${partial.asset_id}-${partial.order}`, + plan_id: "plan-x", + clip_type: "main", + start_time: 0, + duration: 5, + text_content: "", + transition_effect: "", + transition_duration: 0, + playback_speed: 1, + status: "ready", + config: {}, + ...partial, + } +} + +function buildResp(count: number) { + return { + total: count, + items: Array.from({ length: count }, (_, i) => ({ + variant_index: i, + plan_id: `plan-${i}`, + clips: [makeClip({ asset_id: `a${(i % 3) + 1}`, order: 0, start_time: i * 2 })], + })), + } +} + +beforeEach(() => { + vi.clearAllMocks() + mockCreate.mockReset() +}) + +describe("useBatchVariantPlans", () => { + it("批量 N=3 时申请变体计划并返回按索引对齐的 clips/planIds", async () => { + mockCreate.mockResolvedValueOnce(buildResp(3)) + const { result } = renderHook(() => + useBatchVariantPlans({ + enabled: true, + count: 3, + templateId: "tpl-1", + assetIds: ["a1", "a2", "a3"], + sourcePlanId: "plan-src", + }), + ) + + await waitFor(() => expect(result.current.ready).toBe(true)) + expect(mockCreate).toHaveBeenCalledTimes(1) + expect(mockCreate).toHaveBeenCalledWith({ + template_id: "tpl-1", + asset_ids: ["a1", "a2", "a3"], + count: 3, + source_edit_plan_id: "plan-src", + }) + expect(result.current.planIdsByVariant).toEqual(["plan-0", "plan-1", "plan-2"]) + expect(result.current.clipsByVariant[1]?.[0]?.start_time).toBe(2) + expect(result.current.loading).toBe(false) + }) + + it("sourcePlanId 为空时不传 source_edit_plan_id 字段", async () => { + mockCreate.mockResolvedValueOnce(buildResp(2)) + const { result } = renderHook(() => + useBatchVariantPlans({ enabled: true, count: 2, templateId: "tpl-1", assetIds: ["a1"] }), + ) + await waitFor(() => expect(result.current.ready).toBe(true)) + const arg = mockCreate.mock.calls[0][0] + expect(arg).not.toHaveProperty("source_edit_plan_id") + }) + + it("404(端点未上线)静默降级:ready=false 且不弹任何提示", 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.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: "素材不足" } } }) + const { result } = renderHook(() => + useBatchVariantPlans({ enabled: true, count: 3, templateId: "tpl-1", assetIds: ["a1"] }), + ) + await waitFor(() => expect(result.current.loading).toBe(false)) + expect(result.current.ready).toBe(false) + expect(message.info).not.toHaveBeenCalled() + }) + + it("网络错误降级并仅提示一次", 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 } }, + ) + await waitFor(() => expect(result.current.loading).toBe(false)) + expect(result.current.ready).toBe(false) + expect(message.info).toHaveBeenCalledTimes(1) + + // 入参变化触发第二次失败,warnedRef 保证不再重复提示 + rerender({ count: 4 }) + await waitFor(() => expect(result.current.loading).toBe(false)) + expect(message.info).toHaveBeenCalledTimes(1) + }) + + it("N=1 不发请求(单视频零回归)", () => { + renderHook(() => + useBatchVariantPlans({ enabled: true, count: 1, templateId: "tpl-1", assetIds: ["a1"] }), + ) + expect(mockCreate).not.toHaveBeenCalled() + }) + + it("无素材/enabled=false 不发请求", () => { + const { rerender } = renderHook( + (props: { enabled: boolean; ids: string[] }) => + useBatchVariantPlans({ + enabled: props.enabled, + count: 3, + templateId: "tpl-1", + assetIds: props.ids, + }), + { initialProps: { enabled: true, ids: [] as string[] } }, + ) + expect(mockCreate).not.toHaveBeenCalled() + rerender({ enabled: false, ids: ["a1"] }) + expect(mockCreate).not.toHaveBeenCalled() + }) + + it("响应乱序/越界 variant_index 被归一化到按索引对齐", async () => { + mockCreate.mockResolvedValueOnce({ + total: 2, + items: [ + { variant_index: 99, plan_id: "plan-bad", clips: [] }, + { 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 })] }, + ], + }) + const { result } = renderHook(() => + useBatchVariantPlans({ + enabled: true, + count: 2, + templateId: "tpl-1", + assetIds: ["a1", "a2"], + }), + ) + await waitFor(() => expect(result.current.ready).toBe(true)) + expect(result.current.planIdsByVariant).toEqual(["plan-0", "plan-1"]) + }) + + it("非 ready 状态的 clip 被过滤", async () => { + mockCreate.mockResolvedValueOnce({ + total: 2, + items: [ + { + variant_index: 0, + plan_id: "plan-0", + clips: [ + makeClip({ asset_id: "a1", order: 0 }), + makeClip({ asset_id: "a2", order: 1, status: "pending" }), + ], + }, + { variant_index: 1, plan_id: "plan-1", clips: [makeClip({ asset_id: "a3", order: 0 })] }, + ], + }) + const { result } = renderHook(() => + useBatchVariantPlans({ enabled: true, count: 2, templateId: "tpl-1", assetIds: ["a1"] }), + ) + await waitFor(() => expect(result.current.ready).toBe(true)) + expect(result.current.clipsByVariant[0]).toHaveLength(1) + expect(result.current.clipsByVariant[0]?.[0]?.asset_id).toBe("a1") + }) +})