feat(generate): #1744 批量预览对接后端变体真实片段 + 智能匹配候选池×3 (#1748)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 5s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 5s
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 11s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 32s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 32s
CI/CD Pipeline / Build Staging API Image (push) Successful in 41s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m27s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 54s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Validate - Style (push) Successful in 2m26s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m37s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m13s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m19s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m26s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 4m14s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m33s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m50s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m34s
AI Code Review / AI Code Review (pull_request) Successful in 6m48s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m13s
CI/CD Pipeline / Unit Tests (push) Failing after 8m1s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 5m11s
CI/CD Pipeline / Validate - Security (push) Successful in 18m6s
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped

Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
This commit was merged in pull request #1748.
This commit is contained in:
2026-09-06 20:45:10 +08:00
committed by auto-approve-bot
parent 4c03e05e6c
commit bcbcb41750
11 changed files with 557 additions and 11 deletions
@@ -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<BatchVariantPlansResponse> {
const response = await apiClient.post<BatchVariantPlansResponse>(
"/generation/variant-plans",
params,
)
return response.data
}
+6
View File
@@ -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 */
@@ -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}
@@ -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<CanvasPreviewGridProps> = ({
titles,
titleSettings,
voiceAudioUrl,
variantClips,
clipsLoading = false,
selectedIds,
onToggleSelect,
selectable = true,
@@ -70,12 +83,23 @@ const CanvasPreviewGrid: React.FC<CanvasPreviewGridProps> = ({
<span> {i + 1}</span>
</label>
</div>
{clipsLoading && (!variantClips || variantClips[i]?.length === 0) && (
<div className="xx-variant-clips-loading" aria-label={`变体${i + 1}片段加载中`}>
<LoadingOutlined />
<span></span>
</div>
)}
<FrontendPreviewPlayer
assets={assets}
template={template}
videoRatio={videoRatio}
ready={assets.length > 0}
variantSeed={i + 1}
// #1744:有后端真实片段时播放器优先使用(buildPlaybackSegments 内 serverClips 优先),
// 与正式成片同源;为空则自动回退 variantSeed 本地模拟
serverClips={
variantClips && variantClips[i]?.length > 0 ? variantClips[i] : undefined
}
variantTitle={titles[i] || ""}
voiceAudioUrl={voiceAudioUrl}
activePlayToken={activePlayToken}
+22
View File
@@ -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
============================================================ */
@@ -42,6 +42,12 @@ export interface UseGenerateVideoProps {
variantCoverUrls?: string[]
/** 勾选要生成的变体索引(批量模式) */
selectedVariantIndexes?: number[]
/**
* 批量变体剪辑计划 ID(#1744,按变体全量索引长度=previewCount):
* 预览阶段后端独立选片产出,正式生成按勾选顺序回传,实现预览即成片;
* 为空(降级本地模拟/后端未上线)时不传,后端自行独立选片。
*/
variantPlanIds?: string[]
}
/** 生成阶段 */
@@ -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))
}
@@ -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<EditPlanClip[][]>([])
const [planIdsByVariant, setPlanIdsByVariant] = useState<string[]>([])
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
// 函数式更新:已是目标值时返回 prevObject.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
@@ -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: {
@@ -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)
})
})
@@ -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(),
}))
// 轻量 mockhook 只用到 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<EditPlanClip> & { 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")
})
})