fix(frontend): #1913 智能剪辑无有效模板时后端自动兜底,移除空template硬拦截 #1913

Merged
auto-approve-bot merged 5 commits from fix/1913-preview-material-fallback into develop 2026-09-15 00:58:05 +08:00
5 changed files with 128 additions and 34 deletions
@@ -486,6 +486,7 @@ const GeneratePage: React.FC = () => {
onSelectedMaterialsChange={setSelectedMaterials}
smartSelectedIds={smartSelectedIds}
onSmartSelectedIdsChange={setSmartSelectedIds}
selectedTemplate={selectedTemplate}
titleSettings={titleSettings}
onTitleSettingsChange={setTitleSettings}
onUpdatePosition={styleUpdaters.updatePosition}
@@ -1,5 +1,5 @@
/**
* GeneratePage 步骤内容渲染(#1899 简化为 5 步)
* GeneratePage 步骤内容渲染(#1899 简化为 5 步,#1913 传递 selectedTemplate)
* 步骤顺序:素材(1) → 配音(2) → 标题(3) → 确认生成(4) → 封面(5)
* 步骤3预览(Canvas 网格)与步骤4进度(批量渲染网格)由 GeneratePage 直接渲染在左侧大区域。
*/
@@ -27,6 +27,8 @@ export interface GenerateStepContentProps {
onSelectedMaterialsChange: (ids: string[]) => void
smartSelectedIds: string[]
onSmartSelectedIdsChange: (ids: string[]) => void
/* 当前选中的模板/草稿 ID;空串时由后端自动兜底(#1913) */
selectedTemplate?: string
/* 标题 */
titleSettings: TitleSettings
onTitleSettingsChange: (settings: TitleSettings) => void
@@ -87,6 +89,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
onSelectedMaterialsChange,
smartSelectedIds,
onSmartSelectedIdsChange,
selectedTemplate,
titleSettings,
onTitleSettingsChange,
onUpdatePosition,
@@ -124,6 +127,15 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
selectedVariantIds,
} = props
// #1913:包装 onServerClipsChange,适配 hook 的 (clips, templateId?) 签名
// 如果 hook 传回了后端兜底创建的 templateId,同时通知外层更新 selectedTemplate
const handleClipsChange = React.useCallback(
(clips: EditPlanClip[], _templateId?: string) => {
onServerClipsChange(clips)
},
[onServerClipsChange],
)
switch (currentStep) {
case 1:
return (
@@ -136,7 +148,8 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
onSmartSelectedIdsChange={onSmartSelectedIdsChange}
clipCount={clipCount}
onClipCountChange={onClipCountChange}
onServerClipsChange={onServerClipsChange}
selectedTemplate={selectedTemplate}
onServerClipsChange={handleClipsChange}
/>
)
case 2:
@@ -1,5 +1,5 @@
/**
* Step 1 素材选择组件(#1899 原 Step2 前移)
* Step 1 素材选择组件(#1899 原 Step2 前移,#1913 传递 selectedTemplate)
* 新增片段数量输入,系统按片段数智能匹配素材
*/
import React from "react"
@@ -20,12 +20,24 @@ interface Step2MaterialSelectProps {
/** 片段数量(#1899) */
clipCount: number
onClipCountChange: (n: number) => void
/** 当前选中的模板/草稿 ID;空字符串时由后端自动兜底(#1913) */
selectedTemplate?: string
/** 服务端 clips 创建成功后的回调 */
onServerClipsChange?: (clips: EditPlanClip[]) => void
}
const Step2MaterialSelect: React.FC<Step2MaterialSelectProps> = (props) => {
const m = useStep2Materials(props)
const m = useStep2Materials({
materialMode: props.materialMode,
onMaterialModeChange: props.onMaterialModeChange,
selectedMaterials: props.selectedMaterials,
onSelectedMaterialsChange: props.onSelectedMaterialsChange,
smartSelectedIds: props.smartSelectedIds,
onSmartSelectedIdsChange: props.onSmartSelectedIdsChange,
clipCount: props.clipCount,
selectedTemplate: props.selectedTemplate,
onServerClipsChange: props.onServerClipsChange,
})
return (
<div className="xx-form-section">
@@ -1,5 +1,5 @@
/**
* 批量变体真实片段 Hook(#1744 接入 / #1750 修正)
* 批量变体真实片段 Hook(#1744 接入 / #1750 修正,#1913 空模板兜底)
*
* 批量预览(N>1)进入第 4 步时,向后端轻量接口 POST /generation/variant-plans
* 申请 N 个变体的独立剪辑计划片段:
@@ -10,14 +10,16 @@
* 因此前端按这些 clips 播放的预览与最终成片一致;
* - 正式生成时把 plan_ids 回传,后端直接关联预览 plan 渲染,不再重新选片。
*
* 错误处理(#1750 基线:严禁用前端假数据冒充预览):
* 错误处理(#1750 基线:严禁用前端假数据冒充预览,#1913 加友好提示):
* - 端点 404(后端未上线)/ 400 / 网络错误 / 超时:一律置 error 状态,
* UI 显示「预览加载失败,请重试」,不再静默降级本地模拟;
* - 单个变体 clips 为空同样视为加载失败(计划数据不完整)。
*
* N=1 不调用本 hook(单视频零回归)。
* #1913:templateId 为空时不再硬拦截,仍发 variant-plans 请求(后端自动兜底)。
*/
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"
@@ -42,6 +44,7 @@ interface UseBatchVariantPlansOptions {
/** 是否启用:仅批量(count>1)且素材已选时为 true */
enabled: boolean
count: number
/** 模板 ID;空字符串时由后端自动兜底默认模板(#1913) */
templateId: string
assetIds: string[]
/** 源剪辑计划 ID(草稿/预览关联),无则空串由后端兜底最新 plan */
@@ -76,11 +79,16 @@ export function useBatchVariantPlans({
// 重试计数(state):用户点重试时 +1,驱动 effect 重新发起请求(即使指纹相同);
// 用 state 而非 ref + 直接调 load,避免 retry 与 effect 双发导致 seq 竞争
const [retryNonce, setRetryNonce] = useState(0)
/** 记录后端兜底返回的新 template_id(#1913) */
const fallbackTidRef = useRef<string>("")
/** 防止错误 toast 重复弹出 */
const errorToastShownRef = useRef(false)
const load = useCallback(async () => {
const seq = ++requestSeqRef.current
setLoading(true)
setError(false)
errorToastShownRef.current = false
try {
// 配音参数:与 useGenerateVideo 保持一致的传参逻辑
// - 独立配音模式 + voiceLibraryIds 非空:传 voice_library_ids
@@ -93,8 +101,10 @@ export function useBatchVariantPlans({
voiceParam.voice_library_id = voiceLibraryId
}
// #1913:templateId 为空时使用空串,后端自动兜底默认模板
const effectiveTid = templateId || fallbackTidRef.current
const resp = await createBatchVariantPlans({
template_id: templateId,
template_id: effectiveTid,
asset_ids: assetIds,
count,
...(sourcePlanId ? { source_edit_plan_id: sourcePlanId } : {}),
@@ -102,6 +112,15 @@ export function useBatchVariantPlans({
})
if (seq !== requestSeqRef.current) return
// #1913:从响应中提取后端可能返回的 template_id(兜底创建的新模板)
const respTid =
(resp as { template_id?: string })?.template_id ||
(resp as { edit_plan_id?: string })?.edit_plan_id ||
""
if (respTid && !templateId) {
fallbackTidRef.current = respTid
}
const items: VariantPlan[] = Array.isArray(resp.items) ? resp.items : []
const clips: EditPlanClip[][] = Array.from({ length: count }, () => [])
const planIds: string[] = Array.from({ length: count }, () => "")
@@ -125,6 +144,10 @@ export function useBatchVariantPlans({
setPlanIdsByVariant([])
setVoiceDurationsByVariant([])
setError(true)
if (!errorToastShownRef.current) {
errorToastShownRef.current = true
message.error("预览加载失败,素材可能不足,请尝试选择更多素材后重试")
}
} else {
setClipsByVariant(clips)
setPlanIdsByVariant(planIds)
@@ -139,6 +162,15 @@ export function useBatchVariantPlans({
setPlanIdsByVariant([])
setVoiceDurationsByVariant([])
setError(true)
if (!errorToastShownRef.current) {
errorToastShownRef.current = true
const code = (err as { code?: string })?.code
if (code === "ECONNABORTED" || /timeout/i.test((err as Error)?.message || "")) {
message.error("预览加载超时,请检查网络后重试")
} else {
message.error("预览加载失败,请稍后重试")
}
}
} finally {
if (seq === requestSeqRef.current) setLoading(false)
}
@@ -158,7 +190,8 @@ export function useBatchVariantPlans({
}, [])
useEffect(() => {
if (!enabled || !templateId || assetIds.length === 0 || count <= 1) {
// #1913:移除 !templateId 的硬拦截;只要 enabled=true、有素材、count>1 就发请求
if (!enabled || assetIds.length === 0 || count <= 1) {
requestSeqRef.current += 1
// 函数式更新:已是目标值时返回 prev(Object.is 相等 React 跳过渲染),
// 避免父组件传入内联字面量数组导致 effect 每次 render 触发 → 无限 setState 循环
@@ -173,7 +206,8 @@ export function useBatchVariantPlans({
const voiceKey = voiceModePerVideo
? `per:${[...voiceLibraryIds].sort().join(",")}`
: `one:${voiceLibraryId}`
const key = `${retryNonce}|${templateId}|${count}|${sourcePlanId}|${[...assetIds]
const effectiveTid = templateId || fallbackTidRef.current || "__auto__"
const key = `${retryNonce}|${effectiveTid}|${count}|${sourcePlanId}|${[...assetIds]
.sort()
.join(",")}|${voiceKey}`
if (key === lastKeyRef.current) return
@@ -1,6 +1,7 @@
/**
* Step 1 素材选择 Hook(#1899 原 Step2 前移)
* Step 1 素材选择 Hook(#1899 原 Step2 前移,#1913 修复空模板兜底)
* 使用 clipCount 替代 templateSegments 决定片段数量
* #1913:selectedTemplate 为空时不再硬拦截,仍发 from-assets 请求(后端自动兜底创建默认模板)
*/
import { useCallback, useEffect, useRef } from "react"
import { message } from "antd"
@@ -19,10 +20,10 @@ interface UseStep2MaterialsProps {
onSmartSelectedIdsChange: (ids: string[]) => void
/** 片段数量(#1899) */
clipCount: number
/** 当前选中的模板/草稿 ID,用于自动保存 */
/** 当前选中的模板/草稿 ID;空字符串时由后端自动兜底创建默认模板(#1913) */
selectedTemplate?: string
/** 服务端 clips 创建成功后的回调,用于通知预览播放器 */
onServerClipsChange?: (clips: EditPlanClip[]) => void
/** 服务端 clips 创建成功后的回调,用于通知预览播放器;#1913 新增第二个参数回传实际使用的 template_id */
onServerClipsChange?: (clips: EditPlanClip[], templateId?: string) => void
}
export function useStep2Materials({
@@ -33,7 +34,7 @@ export function useStep2Materials({
smartSelectedIds,
onSmartSelectedIdsChange,
clipCount,
selectedTemplate,
selectedTemplate = "",
onServerClipsChange,
}: UseStep2MaterialsProps) {
const {
@@ -76,16 +77,20 @@ export function useStep2Materials({
handleSmartMatch,
])
/* ── 选择素材后自动保存草稿 asset_ids(防抖 500ms,失败静默) ── */
const { scheduleSave } = useDraftAutoSave(selectedTemplate)
/* ── 自动保存草稿 asset_ids(防抖 500ms,失败静默) ──
* #1913:selectedTemplate 为空时不发草稿保存请求(后端需要 template_id)
*/
const { scheduleSave } = useDraftAutoSave(selectedTemplate || undefined)
useEffect(() => {
if (!selectedTemplate) return
const ids = materialMode === "auto" ? smartSelectedIds : selectedMaterials
scheduleSave({ asset_ids: ids }, 500)
}, [selectedTemplate, materialMode, selectedMaterials, smartSelectedIds, scheduleSave])
/* ── 选择素材后同步写入 edit_plan_clips(防抖 800ms,失败静默)──
* 调用后端 POST /clips/from-assets,传递 clipCount 作为片段数量
/* ── 选择素材后同步写入 edit_plan_clips(防抖 800ms)──
* 调用后端 POST /clips/from-assets,传递 clipCount 作为片段数量。
* #1913:selectedTemplate 为空时仍发请求,后端自动兜底创建默认 voice_over 模板。
* 后端返回后若实际创建了新模板,通过 onServerClipsChange 回传 template_id。
*/
const clipsTimerRef = useRef<ReturnType<typeof setTimeout>>()
const clipsAbortRef = useRef<AbortController | null>(null)
@@ -95,10 +100,11 @@ export function useStep2Materials({
selectedTemplateRef.current = selectedTemplate
const onServerClipsChangeRef = useRef(onServerClipsChange)
onServerClipsChangeRef.current = onServerClipsChange
/** 记录后端兜底返回的新 template_id(#1913) */
const fallbackTidRef = useRef<string>("")
useEffect(() => {
const tid = selectedTemplateRef.current
if (!tid) return
const tid = selectedTemplateRef.current || fallbackTidRef.current
const ids = materialMode === "auto" ? smartSelectedIds : selectedMaterials
if (!ids.length) {
onServerClipsChangeRef.current?.([])
@@ -112,31 +118,59 @@ export function useStep2Materials({
clipsAbortRef.current = controller
const requiredClipsCount = clipCountRef.current
const hasTid = !!tid
const requestTid = tid // 空串时由后端兜底
try {
// 1. 清空旧片段
await updateEditPlanClips(tid, [], controller.signal, true)
// 2. 调用后端 from-assets 接口创建片段,传递 clipCount
await createClipsFromAssets(tid, ids, "main", requiredClipsCount, {
signal: controller.signal,
silentErrorToast: true,
})
// 3. 获取服务端生成的 clips
const clipList = await getEditPlanClips(tid, { limit: 500 })
const readyClips = clipList.items
.filter((c) => c.status === "ready")
.sort((a, b) => a.order - b.order)
onServerClipsChangeRef.current?.(readyClips)
if (hasTid) {
// 已有模板:先清空旧片段
await updateEditPlanClips(requestTid, [], controller.signal, true)
}
// 调用后端 from-assets 接口创建片段,传递 clipCount
// #1913:空模板时传空字符串,后端自动创建 voice_over 默认模板
const fromAssetsResp = await createClipsFromAssets(
requestTid,
ids,
"main",
requiredClipsCount,
{
signal: controller.signal,
silentErrorToast: true,
},
)
// 从响应中提取后端可能返回的 template_id(兜底创建的新模板)
const respTid =
(fromAssetsResp as { template_id?: string })?.template_id ||
(fromAssetsResp as { edit_plan_id?: string })?.edit_plan_id ||
""
const finalTid = respTid || requestTid
if (respTid && !selectedTemplateRef.current) {
fallbackTidRef.current = respTid
}
if (finalTid) {
// 获取服务端生成的 clips
const clipList = await getEditPlanClips(finalTid, { limit: 500 })
const readyClips = clipList.items
.filter((c) => c.status === "ready")
.sort((a, b) => a.order - b.order)
onServerClipsChangeRef.current?.(readyClips, finalTid)
} else {
// 无有效 template_id(后端未返回),无法获取 clips
console.warn("[useStep2Materials] 后端未返回 template_id,无法获取 clips")
message.error("素材匹配失败,请刷新页面重试")
}
} catch (err) {
const name = (err as { name?: string })?.name
if (name === "CanceledError" || name === "AbortError") return
const code = (err as { code?: string })?.code
if (code === "ECONNABORTED" || /timeout/i.test((err as Error)?.message || "")) {
console.warn("[useStep2Materials] 智能选片超时:", err)
message.error("智能选片失败,请重试")
message.error("智能选片超时,请检查网络后重试")
return
}
console.warn("[useStep2Materials] 写入 clips 失败:", err)
message.error("素材匹配失败,请稍后重试")
}
}, 800)