fix(generate): #1750 批量预览五连修——删除假数据 fallback/配音时长/竖屏播放器/封面一键生成 #1751
@@ -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<TemplateSegment, "id">[]
|
||||
watermark_config?: WatermarkConfig
|
||||
intro_outro_config?: IntroOutroConfig
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
/** 模板列表分页响应 */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string>()
|
||||
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 实时预览(与旧版一致,零回归) */
|
||||
<FrontendPreviewPlayer
|
||||
assets={previewAssets}
|
||||
template={currentTemplate}
|
||||
videoRatio={videoRatio}
|
||||
ready={previewAssets.length > 0}
|
||||
serverClips={serverClips}
|
||||
@@ -443,13 +473,14 @@ const GeneratePage: React.FC = () => {
|
||||
<CanvasPreviewGrid
|
||||
count={previewCount}
|
||||
assets={previewAssets}
|
||||
template={currentTemplate}
|
||||
videoRatio={videoRatio}
|
||||
titles={previewTitles}
|
||||
titleSettings={titleSettings}
|
||||
voiceAudioUrl={previewVoiceAudioUrl || undefined}
|
||||
voiceAudioUrls={variantVoiceAudioUrls}
|
||||
variantClips={variantClips}
|
||||
clipsLoading={variantClipsLoading}
|
||||
clipsError={variantClipsError}
|
||||
onRetryClips={retryVariantClips}
|
||||
selectedIds={selectedVariantIds}
|
||||
onToggleSelect={toggleVariantSelect}
|
||||
selectable={!generating}
|
||||
@@ -527,12 +558,31 @@ const GeneratePage: React.FC = () => {
|
||||
{/* ════ 步骤5/6(单视频):右侧成片播放器 ════ */}
|
||||
{currentStep >= 5 && !isBatch && generated && finalVideo && (
|
||||
<div className="xx-generate-right-col">
|
||||
<div className="xx-inline-video-player">
|
||||
<div
|
||||
className="xx-inline-video-player"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
background: "#000",
|
||||
borderRadius: 12,
|
||||
padding: 8,
|
||||
}}
|
||||
>
|
||||
<video
|
||||
src={finalVideo.download_url || finalVideo.file_url}
|
||||
controls
|
||||
autoPlay={currentStep === 5}
|
||||
style={{ width: "100%", maxHeight: "70vh", objectFit: "contain", borderRadius: 12 }}
|
||||
// 竖屏自适应(#1750):成片固定 1080×1920(9:16),元数据到达前按 9:16 占位,
|
||||
// 到达后浏览器按真实宽高比 contain;黑底居中杜绝左右大黑边
|
||||
style={{
|
||||
width: "auto",
|
||||
maxWidth: "100%",
|
||||
maxHeight: "70vh",
|
||||
aspectRatio: "9 / 16",
|
||||
objectFit: "contain",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
poster={finalVideo.thumbnail_url || undefined}
|
||||
/>
|
||||
<div style={{ display: "flex", gap: 8, marginTop: 12, justifyContent: "center" }}>
|
||||
|
||||
@@ -66,12 +66,32 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
|
||||
</>
|
||||
)}
|
||||
{task.status === "completed" && video && (
|
||||
<video
|
||||
src={video.download_url || video.file_url}
|
||||
controls
|
||||
style={{ width: "100%", borderRadius: 8, background: "#000", maxHeight: 280 }}
|
||||
poster={video.thumbnail_url}
|
||||
/>
|
||||
// 竖屏自适应容器(#1750):成片固定 1080×1920(9:16),
|
||||
// 视频按真实宽高比 contain 显示,黑底居中,杜绝横屏播放器左右大黑边
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
background: "#000",
|
||||
borderRadius: 8,
|
||||
padding: 8,
|
||||
}}
|
||||
>
|
||||
<video
|
||||
src={video.download_url || video.file_url}
|
||||
controls
|
||||
style={{
|
||||
width: "auto",
|
||||
maxWidth: "100%",
|
||||
maxHeight: "70vh",
|
||||
aspectRatio: "9 / 16",
|
||||
borderRadius: 4,
|
||||
background: "#000",
|
||||
}}
|
||||
poster={video.thumbnail_url}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{task.status === "completed" && !video && (
|
||||
<div className="xx-batch-gen-card-done">✅ 已完成(成片可在下一步选择封面)</div>
|
||||
|
||||
@@ -1,40 +1,47 @@
|
||||
/**
|
||||
* 批量前端 Canvas 实时预览网格(Issue #1677 修正方案,#1744 对齐后端真实选片)
|
||||
* 批量前端 Canvas 实时预览网格(Issue #1677 起,#1750 对齐基线:只播放后端真实计划)
|
||||
*
|
||||
* N 个 FrontendPreviewPlayer 网格排列:
|
||||
* - 纯前端 Canvas + video 元素实时播放素材片段,不调任何后端渲染接口
|
||||
* - #1744:优先使用后端变体独立选片返回的真实片段(variantClips)——与正式
|
||||
* 批量生成同源自 reselect_plan_for_variant,预览素材排布/起点即成片;
|
||||
* 后端片段未就绪(端点未上线/降级)时回退 variantSeed 本地模拟,保证可用
|
||||
* - 各自叠加独立标题浮层(variantTitle),标题样式全局共用
|
||||
* - 勾选框决定提交时生成哪些变体
|
||||
* - 每个变体都挂载同一条配音 URL(浏览器缓存不重复下载);播放互斥:
|
||||
* 点击某卡片播放时其他卡片自动暂停,同一时刻只有一路声音(#1741)
|
||||
* - 纯前端 Canvas + video 元素实时播放素材片段,不调任何后端渲染接口;
|
||||
* - #1744/#1750:片段只来自后端变体计划接口(POST /generation/variant-plans)的真实
|
||||
* clips,与正式批量生成同源自 reselect_plan_for_variant,预览即成片;
|
||||
* 接口失败/数据不完整 → 整网格显示错误态+重试(严禁本地假数据冒充预览);
|
||||
* 加载中 → 9:16 占位防塌陷;
|
||||
* - 各自叠加独立标题浮层(variantTitle),标题样式全局共用;
|
||||
* - 勾选框决定提交时生成哪些变体;
|
||||
* - 每个变体挂载各自配音 URL(独立模式 #1750)或共用同一条;播放互斥:
|
||||
* 点击某卡片播放时其他卡片自动暂停,同一时刻只有一路声音(#1741)。
|
||||
*/
|
||||
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 { LoadingOutlined, ReloadOutlined } from "@ant-design/icons"
|
||||
import { Button } from "antd"
|
||||
import type { TitleSettings } from "../types"
|
||||
import FrontendPreviewPlayer from "./FrontendPreviewPlayer"
|
||||
|
||||
interface CanvasPreviewGridProps {
|
||||
count: number
|
||||
assets: AssetItem[]
|
||||
template: EditingTemplate | null
|
||||
videoRatio: string
|
||||
titles: string[]
|
||||
titleSettings: TitleSettings
|
||||
/** 共用配音预览音频 URL(#1741:每个变体都挂载,播放互斥保证同一时刻只有一路发声) */
|
||||
voiceAudioUrl?: string
|
||||
/**
|
||||
* 各变体的后端真实片段(#1744):长度=count,空数组=该变体未就绪/降级本地模拟。
|
||||
* 与正式批量生成同源选片,预览所见即成片。
|
||||
* 各变体配音预览音频 URL(#1750:独立配音模式每变体一条;共用模式全为同一条;
|
||||
* 元素为 null 表示该变体暂无音频(AI 音色 TTS 合成中))
|
||||
*/
|
||||
voiceAudioUrls?: (string | null)[]
|
||||
/**
|
||||
* 各变体的后端真实片段(#1744/#1750):长度=count。
|
||||
* 仅 clipsLoading=false 且 clipsError=false 时才会传给播放器。
|
||||
*/
|
||||
variantClips?: EditPlanClip[][]
|
||||
/** 是否正在向后端申请变体计划(显示轻量加载提示,不阻塞本地模拟预览) */
|
||||
/** 是否正在向后端申请变体计划 */
|
||||
clipsLoading?: boolean
|
||||
/** 申请变体计划失败(端点未上线/网络错误/数据不完整):显示错误态,严禁假数据 */
|
||||
clipsError?: boolean
|
||||
/** 用户点击错误态「重试」 */
|
||||
onRetryClips?: () => void
|
||||
/** 勾选的变体序号 */
|
||||
selectedIds: number[]
|
||||
onToggleSelect: (index: number) => void
|
||||
@@ -45,19 +52,19 @@ interface CanvasPreviewGridProps {
|
||||
const CanvasPreviewGrid: React.FC<CanvasPreviewGridProps> = ({
|
||||
count,
|
||||
assets,
|
||||
template,
|
||||
videoRatio,
|
||||
titles,
|
||||
titleSettings,
|
||||
voiceAudioUrl,
|
||||
voiceAudioUrls,
|
||||
variantClips,
|
||||
clipsLoading = false,
|
||||
clipsError = false,
|
||||
onRetryClips,
|
||||
selectedIds,
|
||||
onToggleSelect,
|
||||
selectable = true,
|
||||
}) => {
|
||||
// ── 播放互斥(#1741):同一时刻只有一个卡片持有播放权,点击其他卡片自动暂停当前卡片 ──
|
||||
// token 用 variantSeed(i+1),与 FrontendPreviewPlayer 内部 variantSeed 一致
|
||||
// ── 播放互斥(#1741):同一时刻只有一个卡片持有播放权(token = 变体序号,0 起,#1750) ──
|
||||
const [activePlayToken, setActivePlayToken] = useState<number | null>(null)
|
||||
|
||||
// count 上限已在源头 PreviewCountModal 的数量选择(1~MAX_PREVIEW_COUNT=10)clamp,
|
||||
@@ -83,42 +90,54 @@ 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>
|
||||
{clipsError ? (
|
||||
// ── 错误态(#1750):9:16 占位防塌陷,不渲染任何播放器(严禁假数据) ──
|
||||
<div className="xx-variant-clips-status" role="alert">
|
||||
<span className="xx-variant-clips-error-text">预览加载失败,请重试</span>
|
||||
{i === 0 && onRetryClips ? (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={onRetryClips}
|
||||
style={{ marginTop: 12 }}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : clipsLoading || !variantClips?.[i]?.length ? (
|
||||
// ── 加载态:9:16 占位防塌陷 ──
|
||||
<div className="xx-variant-clips-status" aria-label={`变体${i + 1}片段加载中`}>
|
||||
<LoadingOutlined />
|
||||
<span style={{ marginTop: 8 }}>独立选片中…</span>
|
||||
</div>
|
||||
) : (
|
||||
<FrontendPreviewPlayer
|
||||
assets={assets}
|
||||
videoRatio={videoRatio}
|
||||
ready={assets.length > 0}
|
||||
playToken={i}
|
||||
serverClips={variantClips[i]}
|
||||
variantTitle={titles[i] || ""}
|
||||
voiceAudioUrl={voiceAudioUrls?.[i] || undefined}
|
||||
activePlayToken={activePlayToken}
|
||||
onPlayTokenChange={setActivePlayToken}
|
||||
compact
|
||||
titleSettings={{
|
||||
title: titles[i] || "",
|
||||
size: titleSettings.size,
|
||||
font: titleSettings.font,
|
||||
color: titleSettings.color,
|
||||
position: titleSettings.position as "top" | "center" | "bottom" | "custom",
|
||||
bold: titleSettings.bold,
|
||||
italic: titleSettings.italic,
|
||||
stroke: titleSettings.stroke,
|
||||
shadow: titleSettings.shadow,
|
||||
posX: titleSettings.posX,
|
||||
posY: titleSettings.posY,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<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}
|
||||
onPlayTokenChange={setActivePlayToken}
|
||||
compact
|
||||
titleSettings={{
|
||||
title: titles[i] || "",
|
||||
size: titleSettings.size,
|
||||
font: titleSettings.font,
|
||||
color: titleSettings.color,
|
||||
position: titleSettings.position as "top" | "center" | "bottom" | "custom",
|
||||
bold: titleSettings.bold,
|
||||
italic: titleSettings.italic,
|
||||
stroke: titleSettings.stroke,
|
||||
shadow: titleSettings.shadow,
|
||||
posX: titleSettings.posX,
|
||||
posY: titleSettings.posY,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -17,16 +17,15 @@ import {
|
||||
AudioMutedOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import type { EditPlanClip } from "@/api/template-editor"
|
||||
import { useSegmentScheduler, type PlaybackSegment } from "../hooks/useSegmentScheduler"
|
||||
import { useCanvasPlayer } from "../hooks/useCanvasPlayer"
|
||||
|
||||
interface FrontendPreviewPlayerProps {
|
||||
assets: AssetItem[]
|
||||
template: EditingTemplate | null
|
||||
videoRatio: string
|
||||
ready: boolean
|
||||
/** 服务端变体计划真实片段(#1750:必填,无 fallback;批量网格传入各变体自己的 clips) */
|
||||
serverClips?: EditPlanClip[]
|
||||
voiceAudioUrl?: string
|
||||
titleSettings?: {
|
||||
@@ -44,21 +43,20 @@ interface FrontendPreviewPlayerProps {
|
||||
}
|
||||
onTitlePositionChange?: (posX: number, posY: number) => void
|
||||
/**
|
||||
* 变体种子(批量生成 #1677):同一批素材在不同变体中采用不同的素材顺序与
|
||||
* 片段起始点,让 N 个 Canvas 预览画面有差异(纯前端随机剪辑模拟,不调后端)。
|
||||
* 0 / 不传 = 单视频,排布与旧版完全一致(零回归)。
|
||||
* 播放互斥 token(#1750):批量网格中为变体序号(0 起),单视频不传。
|
||||
* 持有播放权的实例播放,其余自动暂停。
|
||||
*/
|
||||
variantSeed?: number
|
||||
playToken?: number
|
||||
/** 变体标题文字(批量时每个预览独立标题,叠加在画面上);不传用 titleSettings.title */
|
||||
variantTitle?: string
|
||||
/** 紧凑模式(批量网格中使用,缩小内边距/标题尺寸) */
|
||||
compact?: boolean
|
||||
/**
|
||||
* 批量网格播放互斥(#1741):当前持有播放权的实例 token(variantSeed)。
|
||||
* 批量网格播放互斥(#1741/#1750):当前持有播放权的实例 token(变体序号)。
|
||||
* 持有权变化且不等于自身时,本实例自动暂停(视频+配音)。单视频模式不传。
|
||||
*/
|
||||
activePlayToken?: number | null
|
||||
/** 播放权变化回调:本实例请求播放时传自身 variantSeed,暂停时传 null */
|
||||
/** 播放权变化回调:本实例请求播放时传自身 playToken,暂停时传 null */
|
||||
onPlayTokenChange?: (token: number | null) => void
|
||||
}
|
||||
|
||||
@@ -69,110 +67,51 @@ function formatTime(seconds: number): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 将素材映射为播放片段(复用原逻辑)
|
||||
* 将后端变体计划 clips 映射为播放片段(#1750:唯一数据来源,无本地模拟 fallback)
|
||||
*
|
||||
* 预览不渲染:浏览器按后端真实计划即时播放素材原片,预览即成片。
|
||||
* 模板片段数固定、成片时长=配音时长(后端等比分配),前端不再有任何时长假设。
|
||||
*/
|
||||
/** 简单可复现随机数(mulberry32),同一种子产出稳定排布,避免每次渲染抖动 */
|
||||
function seededRandom(seed: number): () => number {
|
||||
let a = seed >>> 0
|
||||
return () => {
|
||||
a |= 0
|
||||
a = (a + 0x6d2b79f5) | 0
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
function buildPlaybackSegments(
|
||||
assets: AssetItem[],
|
||||
template: EditingTemplate | null,
|
||||
serverClips?: EditPlanClip[],
|
||||
variantSeed = 0,
|
||||
): PlaybackSegment[] {
|
||||
if (!assets.length) return []
|
||||
if (!assets.length || !serverClips || serverClips.length === 0) return []
|
||||
|
||||
// Build asset lookup map
|
||||
const assetMap = new Map(assets.map((a) => [a.id, a]))
|
||||
|
||||
// 优先使用服务端 clips(含随机 start_time 和正确数量),与最终生成结果一致
|
||||
if (serverClips && serverClips.length > 0) {
|
||||
const segments: PlaybackSegment[] = []
|
||||
for (const clip of serverClips) {
|
||||
const asset = assetMap.get(clip.asset_id)
|
||||
if (!asset) continue
|
||||
const assetDuration = asset.duration || asset.metadata?.duration || 30
|
||||
const startTime = clip.start_time || 0
|
||||
const endTime = Math.min(startTime + (clip.duration || assetDuration), assetDuration)
|
||||
const videoUrl = asset.file_url || asset.storage_key
|
||||
segments.push({ assetId: asset.id, videoUrl, startTime, endTime, order: clip.order })
|
||||
}
|
||||
if (segments.length > 0) {
|
||||
return segments.sort((a, b) => a.order - b.order)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: 本地构建片段
|
||||
// variantSeed=0(单视频):与旧行为完全一致(素材原序、起始点 0),零回归
|
||||
// variantSeed>0(批量变体):素材顺序按种子轮换 + 片段起始点在素材内偏移,
|
||||
// 模拟后端"AI 随机剪辑出不同版本",让 N 个预览画面有可见差异
|
||||
const templateSegments = template?.segments || []
|
||||
const segments: PlaybackSegment[] = []
|
||||
const orderedAssets = variantSeed > 0 ? [...assets] : assets
|
||||
if (variantSeed > 0 && orderedAssets.length > 1) {
|
||||
const rand = seededRandom(variantSeed * 7919 + 13)
|
||||
// 素材轮换:把数组旋转 (seed % n) 位,再对后半段做一次稳定交换
|
||||
const n = orderedAssets.length
|
||||
const rotate = variantSeed % n
|
||||
orderedAssets.push(...orderedAssets.splice(0, rotate))
|
||||
const swapA = Math.floor(rand() * n)
|
||||
const swapB = Math.floor(rand() * n)
|
||||
if (swapA !== swapB) {
|
||||
;[orderedAssets[swapA], orderedAssets[swapB]] = [orderedAssets[swapB], orderedAssets[swapA]]
|
||||
}
|
||||
}
|
||||
|
||||
orderedAssets.forEach((asset, i) => {
|
||||
const assetDuration = asset.duration || asset.metadata?.duration || 30
|
||||
const tplSeg = templateSegments[i] || templateSegments[templateSegments.length - 1]
|
||||
const segDuration = tplSeg
|
||||
? Math.min(tplSeg.duration_max, Math.max(tplSeg.duration_min, assetDuration))
|
||||
: Math.min(assetDuration, 10)
|
||||
|
||||
let startTime = 0
|
||||
if (variantSeed > 0 && assetDuration - segDuration > 1) {
|
||||
const rand = seededRandom(variantSeed * 104729 + i * 31 + 7)
|
||||
// 起始点在素材可用区间内随机偏移(至少留 0.5s 余量)
|
||||
const maxStart = Math.max(0, assetDuration - segDuration - 0.5)
|
||||
startTime = Math.round(rand() * maxStart * 10) / 10
|
||||
}
|
||||
const endTime = Math.min(startTime + segDuration, assetDuration)
|
||||
for (const clip of serverClips) {
|
||||
const asset = assetMap.get(clip.asset_id)
|
||||
if (!asset) continue
|
||||
const assetDuration = asset.duration || asset.metadata?.duration || 0
|
||||
const startTime = clip.start_time || 0
|
||||
// 片段时长以后端计划为准(配音时长等比分配);素材时长仅用于兜底钳制边界
|
||||
const clipDuration = clip.duration || 0
|
||||
const endTime =
|
||||
assetDuration > 0
|
||||
? Math.min(startTime + clipDuration, assetDuration)
|
||||
: startTime + clipDuration
|
||||
const videoUrl = asset.file_url || asset.storage_key
|
||||
|
||||
segments.push({ assetId: asset.id, videoUrl, startTime, endTime, order: i })
|
||||
})
|
||||
|
||||
return segments
|
||||
segments.push({ assetId: asset.id, videoUrl, startTime, endTime, order: clip.order })
|
||||
}
|
||||
return segments.sort((a, b) => a.order - b.order)
|
||||
}
|
||||
|
||||
const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
assets,
|
||||
template,
|
||||
videoRatio,
|
||||
ready,
|
||||
serverClips,
|
||||
voiceAudioUrl,
|
||||
titleSettings,
|
||||
onTitlePositionChange,
|
||||
variantSeed = 0,
|
||||
playToken,
|
||||
variantTitle,
|
||||
compact = false,
|
||||
activePlayToken = null,
|
||||
onPlayTokenChange,
|
||||
}) => {
|
||||
const segments = useMemo(
|
||||
() => buildPlaybackSegments(assets, template, serverClips, variantSeed),
|
||||
[assets, template, serverClips, variantSeed],
|
||||
)
|
||||
const segments = useMemo(() => buildPlaybackSegments(assets, serverClips), [assets, serverClips])
|
||||
// 批量变体:标题文字取 variantTitle,样式仍由全局 titleSettings 控制
|
||||
const effectiveTitle = variantTitle ?? titleSettings?.title
|
||||
|
||||
@@ -428,7 +367,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
|
||||
// ── 批量网格播放互斥(#1741):播放权属于其他实例时,本实例自动暂停(视频+配音) ──
|
||||
useEffect(() => {
|
||||
if (activePlayToken == null || activePlayToken === variantSeed) return
|
||||
if (activePlayToken == null || playToken == null || activePlayToken === playToken) return
|
||||
if (effectiveUseWebCodecs) {
|
||||
if (canvasState.isPlaying) canvasControls.pause()
|
||||
} else if (isPlaying) {
|
||||
@@ -437,7 +376,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
// isPlaying/canvasState.isPlaying 不放依赖:只在 token 变化时执行一次暂停,
|
||||
// token 等于自身时本实例的播放在 handleTogglePlay 里处理
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activePlayToken, variantSeed, effectiveUseWebCodecs])
|
||||
}, [activePlayToken, playToken, effectiveUseWebCodecs])
|
||||
|
||||
const handleTogglePlay = useCallback(() => {
|
||||
if (effectiveUseWebCodecs) {
|
||||
@@ -445,12 +384,12 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
canvasControls.pause()
|
||||
onPlayTokenChange?.(null)
|
||||
} else {
|
||||
onPlayTokenChange?.(variantSeed)
|
||||
if (playToken != null) onPlayTokenChange?.(playToken)
|
||||
canvasControls.play()
|
||||
}
|
||||
} else {
|
||||
// video fallback:先上报播放权(暂停其他卡片),再切换本卡片播放/暂停
|
||||
onPlayTokenChange?.(isPlaying ? null : variantSeed)
|
||||
if (playToken != null) onPlayTokenChange?.(isPlaying ? null : playToken)
|
||||
videoTogglePlayPause()
|
||||
}
|
||||
}, [
|
||||
@@ -459,7 +398,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
canvasControls,
|
||||
videoTogglePlayPause,
|
||||
isPlaying,
|
||||
variantSeed,
|
||||
playToken,
|
||||
onPlayTokenChange,
|
||||
])
|
||||
|
||||
@@ -704,8 +643,8 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
? { top: "50%", transform: "translate(-50%, -50%)" }
|
||||
: { bottom: `${titleBottomPct}%` }),
|
||||
}),
|
||||
pointerEvents: onTitlePositionChange && variantSeed === 0 ? "auto" : "none",
|
||||
cursor: onTitlePositionChange && variantSeed === 0 ? "grab" : "default",
|
||||
pointerEvents: onTitlePositionChange && playToken == null ? "auto" : "none",
|
||||
cursor: onTitlePositionChange && playToken == null ? "grab" : "default",
|
||||
touchAction: "none",
|
||||
userSelect: "none",
|
||||
WebkitUserSelect: "none",
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import React from "react"
|
||||
import { Typography } from "antd"
|
||||
import { PlayCircleOutlined, CloseCircleOutlined } from "@ant-design/icons"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import { ProgressIndicator } from "./result-panel/ProgressIndicator"
|
||||
import { ResultVideoCard } from "./result-panel/ResultVideoCard"
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
interface GenerateResultPanelProps {
|
||||
generated: boolean
|
||||
generating: boolean
|
||||
progress: number
|
||||
generateError: string | null
|
||||
generatedVideos: GeneratedVideo[]
|
||||
onVideoPreview: (video: GeneratedVideo) => void
|
||||
onDownload: () => void
|
||||
onShare: () => void
|
||||
onGoToLibrary: () => void
|
||||
}
|
||||
|
||||
const GenerateResultPanel: React.FC<GenerateResultPanelProps> = ({
|
||||
generated,
|
||||
generating,
|
||||
progress,
|
||||
generateError,
|
||||
generatedVideos,
|
||||
onVideoPreview,
|
||||
onDownload,
|
||||
onShare,
|
||||
onGoToLibrary,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-generate-result">
|
||||
<div className="xx-result-header">
|
||||
<h3>生成结果</h3>
|
||||
{generated && generatedVideos.length > 0 && (
|
||||
<span className="xx-result-count">{generatedVideos.length} 个视频</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{generating && <ProgressIndicator progress={progress} />}
|
||||
|
||||
{generateError && !generating && (
|
||||
<div className="xx-result-empty">
|
||||
<CloseCircleOutlined style={{ fontSize: 40, color: "#ff4d4f", marginBottom: 12 }} />
|
||||
<Text strong style={{ display: "block", marginBottom: 4 }}>
|
||||
生成失败
|
||||
</Text>
|
||||
<Text style={{ fontSize: 12, color: "var(--text-secondary)" }}>
|
||||
{typeof generateError === "string" ? generateError : "请重试"}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!generated && !generating && !generateError && (
|
||||
<div className="xx-result-empty">
|
||||
<PlayCircleOutlined
|
||||
style={{ fontSize: 48, color: "var(--text-tertiary)", marginBottom: 12 }}
|
||||
/>
|
||||
<Text style={{ color: "var(--text-secondary)", fontSize: 13 }}>
|
||||
完成配置后点击「确认生成」
|
||||
</Text>
|
||||
<Text style={{ color: "var(--text-tertiary)", fontSize: 12, marginTop: 4 }}>
|
||||
生成的视频将在这里展示
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{generated && generatedVideos.length > 0 && (
|
||||
<div className="xx-video-grid">
|
||||
{generatedVideos.map((video, idx) => (
|
||||
<ResultVideoCard
|
||||
key={video.id || idx}
|
||||
video={video}
|
||||
index={idx}
|
||||
onPreview={onVideoPreview}
|
||||
onDownload={onDownload}
|
||||
onShare={onShare}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{generated && (
|
||||
<div className="xx-result-footer">
|
||||
<button className="xx-btn xx-btn-ghost xx-btn-block" onClick={onGoToLibrary}>
|
||||
前往成片库 →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GenerateResultPanel
|
||||
@@ -1,380 +0,0 @@
|
||||
/**
|
||||
* 右侧预览视频面板 — 服务器渲染预览架构
|
||||
*
|
||||
* Step4+: 显示预览面板
|
||||
* Step5: 播放服务器渲染的真实视频(POST /generation/preview)
|
||||
*
|
||||
* 架构:
|
||||
* - 进入 Step4/5 时自动创建服务器预览渲染任务
|
||||
* - 轮询完成后用 <video> 标签播放返回的 video_url
|
||||
* - 标题样式编辑时 CSS TitleOverlay 实时叠加预览
|
||||
* - 素材/配音/BGM 变更自动重新渲染
|
||||
* - 标题文字/样式变更标记 stale,保留旧视频 + 显示"重新预览"按钮
|
||||
*
|
||||
* 点"确认生成"时走 confirm 路径,成品就是预览视频本身,100% 一致。
|
||||
*/
|
||||
import React, { useMemo, useRef, useState, useEffect } from "react"
|
||||
import { LoadingOutlined, ReloadOutlined, ExclamationCircleOutlined } from "@ant-design/icons"
|
||||
import { Button } from "antd"
|
||||
import type { TitleSettings } from "../types"
|
||||
import { getFontFamily } from "../constants"
|
||||
import type { ServerPreviewStatus } from "../hooks/useServerPreview"
|
||||
|
||||
interface PreviewVideoPanelProps {
|
||||
/** 服务器预览状态 */
|
||||
previewStatus: ServerPreviewStatus
|
||||
/** 服务器渲染视频 URL */
|
||||
videoUrl: string | null
|
||||
/** 渲染进度 0-100 */
|
||||
progress: number
|
||||
/** 错误信息 */
|
||||
error: string | null
|
||||
/** 重新预览回调 */
|
||||
onRetry: () => void
|
||||
/** 视频比例 */
|
||||
videoRatio: string
|
||||
/** 标题设置 — CSS 实时预览层 */
|
||||
titleSettings?: TitleSettings
|
||||
/** 素材数量 */
|
||||
assetCount?: number
|
||||
}
|
||||
|
||||
/* ── ASS 坐标系参数(与后端 ass_subtitle_builder.py 一致) ── */
|
||||
const TITLE_MARGIN_TOP = 120
|
||||
const TITLE_MARGIN_BOTTOM = 60
|
||||
const TITLE_MARGIN_SIDE = 40
|
||||
|
||||
/** 根据视频比例返回后端实际渲染分辨率(PlayResX × PlayResY) */
|
||||
function getResolution(ratio: string): { width: number; height: number } {
|
||||
switch (ratio) {
|
||||
case "16:9":
|
||||
return { width: 1920, height: 1080 }
|
||||
case "1:1":
|
||||
return { width: 1080, height: 1080 }
|
||||
case "9:16":
|
||||
default:
|
||||
return { width: 1080, height: 1920 }
|
||||
}
|
||||
}
|
||||
|
||||
function getPositionStyle(
|
||||
position: string,
|
||||
playResX: number,
|
||||
playResY: number,
|
||||
): React.CSSProperties {
|
||||
const sidePercent = (TITLE_MARGIN_SIDE / playResX) * 100
|
||||
switch (position) {
|
||||
case "bottom":
|
||||
return {
|
||||
bottom: `${(TITLE_MARGIN_BOTTOM / playResY) * 100}%`,
|
||||
left: `${sidePercent}%`,
|
||||
right: `${sidePercent}%`,
|
||||
textAlign: "center",
|
||||
}
|
||||
case "center":
|
||||
return {
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
left: `${sidePercent}%`,
|
||||
right: `${sidePercent}%`,
|
||||
textAlign: "center",
|
||||
}
|
||||
case "top":
|
||||
default:
|
||||
return {
|
||||
top: `${(TITLE_MARGIN_TOP / playResY) * 100}%`,
|
||||
left: `${sidePercent}%`,
|
||||
right: `${sidePercent}%`,
|
||||
textAlign: "center",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildTitleStyle(
|
||||
settings: TitleSettings,
|
||||
containerHeight: number,
|
||||
playResY: number,
|
||||
): React.CSSProperties {
|
||||
// 字号按容器高度与 PlayResY 的比例缩放,不设上限(与后端一致)
|
||||
const fontSizePx =
|
||||
containerHeight > 0
|
||||
? (settings.size / playResY) * containerHeight
|
||||
: (settings.size / playResY) * 400
|
||||
|
||||
const base: React.CSSProperties = {
|
||||
fontFamily: getFontFamily(settings.font),
|
||||
fontSize: `${fontSizePx}px`,
|
||||
color: settings.color || "#ffffff",
|
||||
fontWeight: settings.bold ? 700 : 400,
|
||||
fontStyle: settings.italic ? "italic" : "normal",
|
||||
lineHeight: 1.05,
|
||||
wordBreak: "break-word",
|
||||
pointerEvents: "none",
|
||||
userSelect: "none",
|
||||
}
|
||||
if (settings.stroke) base.WebkitTextStroke = "1px #000000"
|
||||
if (settings.shadow) base.textShadow = "2px 2px 4px rgba(0,0,0,0.8)"
|
||||
return base
|
||||
}
|
||||
|
||||
/** CSS 标题实时预览覆盖层 */
|
||||
const TitleOverlay: React.FC<{ titleSettings: TitleSettings; videoRatio: string }> = ({
|
||||
titleSettings,
|
||||
videoRatio,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [containerHeight, setContainerHeight] = useState(400)
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const h = entry.contentRect.height
|
||||
if (h > 0) setContainerHeight(h)
|
||||
}
|
||||
})
|
||||
ro.observe(el)
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.height > 0) setContainerHeight(rect.height)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
const { width: playResX, height: playResY } = getResolution(videoRatio)
|
||||
|
||||
const positionStyle = useMemo(
|
||||
() => getPositionStyle(titleSettings.position, playResX, playResY),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[titleSettings.position, playResX, playResY],
|
||||
)
|
||||
const titleStyle = useMemo(
|
||||
() => buildTitleStyle(titleSettings, containerHeight, playResY),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
containerHeight,
|
||||
titleSettings.font,
|
||||
titleSettings.size,
|
||||
titleSettings.color,
|
||||
titleSettings.bold,
|
||||
titleSettings.italic,
|
||||
titleSettings.stroke,
|
||||
titleSettings.shadow,
|
||||
playResY,
|
||||
],
|
||||
)
|
||||
|
||||
const displayTitle = titleSettings.title?.trim() || "标题预览"
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
zIndex: 20,
|
||||
pointerEvents: "none",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div style={{ ...positionStyle, ...titleStyle, position: "absolute" }}>
|
||||
{displayTitle.split(/[//]/).map((part, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && <br />}
|
||||
{part}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── 主组件 ── */
|
||||
|
||||
export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
previewStatus,
|
||||
videoUrl,
|
||||
progress,
|
||||
error,
|
||||
onRetry,
|
||||
videoRatio,
|
||||
titleSettings,
|
||||
assetCount,
|
||||
}) => {
|
||||
const videoAspectStyle = { aspectRatio: (videoRatio || "9:16").replace(":", "/") }
|
||||
const isLoading = previewStatus === "loading"
|
||||
const isReady = previewStatus === "ready" || previewStatus === "stale"
|
||||
const isFailed = previewStatus === "failed"
|
||||
const isIdle = previewStatus === "idle"
|
||||
const isStale = previewStatus === "stale"
|
||||
|
||||
return (
|
||||
<div className="xx-generate-preview">
|
||||
<div className="xx-preview-header">
|
||||
<h3>预览视频</h3>
|
||||
{isReady && !isStale && <span className="xx-preview-badge">服务器渲染</span>}
|
||||
{isStale && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "#faad14",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<ExclamationCircleOutlined /> 配置已变更
|
||||
</span>
|
||||
)}
|
||||
{isLoading && <span className="xx-preview-badge">渲染中</span>}
|
||||
</div>
|
||||
|
||||
<div className="xx-preview-video" style={{ ...videoAspectStyle, position: "relative" }}>
|
||||
{/* 加载中 */}
|
||||
{isLoading && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 5,
|
||||
background: "rgba(0,0,0,0.6)",
|
||||
}}
|
||||
>
|
||||
<LoadingOutlined style={{ fontSize: 36, color: "#fff" }} spin />
|
||||
<p style={{ marginTop: 12, color: "rgba(255,255,255,0.9)", fontSize: 14 }}>
|
||||
正在渲染预览视频{progress > 0 ? `...${progress}%` : "..."}
|
||||
</p>
|
||||
<p style={{ marginTop: 4, color: "rgba(255,255,255,0.5)", fontSize: 12 }}>
|
||||
首次渲染约需 30-60 秒
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 空闲状态(尚未触发预览) */}
|
||||
{isIdle && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(0,0,0,0.3)",
|
||||
zIndex: 5,
|
||||
}}
|
||||
>
|
||||
<p style={{ color: "rgba(255,255,255,0.7)", fontSize: 14 }}>等待素材选择...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 服务器渲染的真实视频 */}
|
||||
{isReady && videoUrl && (
|
||||
<video
|
||||
key={videoUrl}
|
||||
src={videoUrl}
|
||||
controls
|
||||
autoPlay
|
||||
loop
|
||||
playsInline
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "contain",
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 标题样式实时预览层(仅在有视频时叠加) */}
|
||||
{isReady && titleSettings && (
|
||||
<TitleOverlay titleSettings={titleSettings} videoRatio={videoRatio} />
|
||||
)}
|
||||
|
||||
{/* stale 遮罩:配置变更提示 */}
|
||||
{isStale && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
padding: "10px 16px",
|
||||
background: "linear-gradient(transparent, rgba(0,0,0,0.85))",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 8,
|
||||
zIndex: 30,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "rgba(255,255,255,0.9)", fontSize: 12 }}>
|
||||
配置已变更,预览内容可能不是最新
|
||||
</span>
|
||||
<Button size="small" type="primary" icon={<ReloadOutlined />} onClick={onRetry}>
|
||||
重新预览
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误状态 */}
|
||||
{isFailed && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(0,0,0,0.7)",
|
||||
zIndex: 10,
|
||||
padding: 24,
|
||||
}}
|
||||
>
|
||||
<ExclamationCircleOutlined style={{ fontSize: 40, color: "#ff4d4f" }} />
|
||||
<p
|
||||
style={{
|
||||
marginTop: 12,
|
||||
color: "rgba(255,255,255,0.9)",
|
||||
fontSize: 14,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{error || "预览渲染失败"}
|
||||
</p>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={onRetry}
|
||||
style={{ marginTop: 12 }}
|
||||
>
|
||||
重新预览
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 素材信息 */}
|
||||
{assetCount !== undefined && assetCount > 0 && (
|
||||
<div className="xx-preview-info">
|
||||
<div className="xx-preview-info-row">
|
||||
<span>素材数</span>
|
||||
<span>{assetCount} 个</span>
|
||||
</div>
|
||||
<div className="xx-preview-info-row">
|
||||
<span>比例</span>
|
||||
<span>{videoRatio}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PreviewVideoPanel
|
||||
@@ -37,6 +37,12 @@ const isAiVoice = (item: AssetItem): boolean => {
|
||||
return (!duration || duration <= 0) && (!size || size <= 0)
|
||||
}
|
||||
|
||||
/** 格式化时长(秒 → "14.7秒";0/无效 → "时长未知",不做任何时长过滤) */
|
||||
const formatDurationSeconds = (seconds?: number): string => {
|
||||
if (!seconds || seconds <= 0) return "时长未知"
|
||||
return `${seconds.toFixed(1)}秒`
|
||||
}
|
||||
|
||||
/** 格式化文件大小 */
|
||||
const formatFileSize = (bytes?: number): string => {
|
||||
if (!bytes || bytes <= 0) return "未知"
|
||||
@@ -261,7 +267,9 @@ const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
|
||||
>
|
||||
{isAiVoice(item) ? (
|
||||
<span style={{ color: "#1677ff", fontWeight: 500 }}>AI 音色</span>
|
||||
) : null}
|
||||
) : (
|
||||
<span>{formatDurationSeconds(getDuration(item))}</span>
|
||||
)}
|
||||
<span>{isAiVoice(item) ? "按文本合成" : formatFileSize(getFileSize(item))}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -91,11 +91,13 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (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<Step6CoverSettingsProps> = (props) => {
|
||||
<Button
|
||||
buttonType="primary"
|
||||
onClick={() => void batchCovers.generateAll()}
|
||||
disabled={completedVideos.length === 0 || batchCovers.loadingIndex !== null}
|
||||
disabled={completedVideos.length === 0 || batchCovers.busyIndexes.length > 0}
|
||||
style={{ whiteSpace: "nowrap", flexShrink: 0 }}
|
||||
>
|
||||
✨ 一键全部自动生成
|
||||
</Button>
|
||||
@@ -151,8 +154,8 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
<div className="xx-cover-grid">
|
||||
{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 (
|
||||
<div className="xx-cover-card" key={variantIndex}>
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import React from "react"
|
||||
import { Typography } from "antd"
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
interface ProgressIndicatorProps {
|
||||
progress: number
|
||||
}
|
||||
|
||||
export const ProgressIndicator: React.FC<ProgressIndicatorProps> = ({ progress }) => {
|
||||
return (
|
||||
<div className="xx-result-progress">
|
||||
<div className="xx-progress-circle">
|
||||
<svg viewBox="0 0 80 80">
|
||||
<circle cx="40" cy="40" r="36" fill="none" stroke="var(--border-color)" strokeWidth="6" />
|
||||
<circle
|
||||
cx="40"
|
||||
cy="40"
|
||||
r="36"
|
||||
fill="none"
|
||||
stroke="var(--primary-color)"
|
||||
strokeWidth="6"
|
||||
strokeDasharray={`${Math.round(progress) * 2.26} 226`}
|
||||
strokeLinecap="round"
|
||||
transform="rotate(-90 40 40)"
|
||||
/>
|
||||
</svg>
|
||||
<span className="xx-progress-percent">{Math.round(progress)}%</span>
|
||||
</div>
|
||||
<div className="xx-progress-text">
|
||||
<Text strong style={{ fontSize: 14, display: "block", marginBottom: 4 }}>
|
||||
正在生成视频
|
||||
</Text>
|
||||
<Text style={{ fontSize: 12, color: "var(--text-secondary)" }}>
|
||||
AI 正在处理素材,请稍候…
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<ResultVideoCardProps> = ({
|
||||
video,
|
||||
index,
|
||||
onPreview,
|
||||
onDownload,
|
||||
onShare,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-video-card" onClick={() => onPreview(video)}>
|
||||
<div className="xx-video-thumb">
|
||||
{video.thumbnail_url ? (
|
||||
<img src={video.thumbnail_url} alt="" />
|
||||
) : (
|
||||
<div className="xx-video-thumb-placeholder">
|
||||
<PlayCircleOutlined style={{ fontSize: 32, opacity: 0.5 }} />
|
||||
</div>
|
||||
)}
|
||||
<div className="xx-video-play-overlay">
|
||||
<PlayCircleOutlined style={{ fontSize: 36, color: "#fff" }} />
|
||||
</div>
|
||||
{video.duration && (
|
||||
<span className="xx-video-duration">{formatDuration(video.duration)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="xx-video-info">
|
||||
<div className="xx-video-title">视频 {index + 1}</div>
|
||||
<div className="xx-video-actions">
|
||||
<button
|
||||
className="xx-video-action-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDownload()
|
||||
}}
|
||||
>
|
||||
<DownloadOutlined />
|
||||
</button>
|
||||
<button
|
||||
className="xx-video-action-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onShare()
|
||||
}}
|
||||
>
|
||||
<ShareAltOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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<number | null>(null)
|
||||
const [uploadingIndex, setUploadingIndex] = useState<number | null>(null)
|
||||
/** 正在自动生成封面的变体序号集合(数组支持多个卡片同时在途) */
|
||||
const [busyIndexes, setBusyIndexes] = useState<number[]>([])
|
||||
/** 正在上传自定义封面的变体序号集合 */
|
||||
const [uploadingIndexes, setUploadingIndexes] = useState<number[]>([])
|
||||
|
||||
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<boolean> => {
|
||||
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,
|
||||
|
||||
@@ -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<EditPlanClip[][]>([])
|
||||
const [planIdsByVariant, setPlanIdsByVariant] = useState<string[]>([])
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,12 @@ interface UsePreviewAssetsReturn {
|
||||
ready: boolean
|
||||
/** 手动触发重新加载 */
|
||||
reload: () => void
|
||||
/**
|
||||
* 差集补拉(#1750):后端变体计划 clips 可能引用不在用户已选列表中的素材
|
||||
* (跨素材库选片/素材池扩展),发现 assets 中缺失的 asset_id 时补拉详情并合并,
|
||||
* 保证预览播放器拿得到素材文件 URL,而不是静默丢片段。
|
||||
*/
|
||||
ensureAssets: (ids: string[]) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,11 +97,41 @@ export function usePreviewAssets(assetIds: string[], enabled: boolean): UsePrevi
|
||||
}
|
||||
}, [stableAssetIds, enabled])
|
||||
|
||||
// 补拉用独立序号:不干扰主 load 的竞态守卫
|
||||
const ensureSeqRef = useRef(0)
|
||||
const assetsRef = useRef<AssetItem[]>([])
|
||||
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 }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,297 +0,0 @@
|
||||
/**
|
||||
* 服务器渲染预览 Hook
|
||||
*
|
||||
* 核心职责:
|
||||
* 1. 调用 POST /generation/preview 创建服务器预览渲染任务
|
||||
* 2. 轮询 GET /generation/preview/{task_id} 直到完成
|
||||
* 3. 返回服务器渲染的真实视频 URL(供 <video> 标签播放)
|
||||
* 4. 检测配置变更,标记预览失效(stale)或自动重新渲染
|
||||
* 5. 网络错误自动重试 2 次
|
||||
*
|
||||
* 状态机:
|
||||
* idle → loading → ready → stale (config changed)
|
||||
* ↘ failed → idle (retry)
|
||||
*/
|
||||
import { useState, useCallback, useRef, useEffect } from "react"
|
||||
import { createPreview, getPreviewStatus } from "@/api/generation/preview"
|
||||
import type { CreatePreviewRequest } from "@/api/generation/types"
|
||||
|
||||
export type ServerPreviewStatus = "idle" | "loading" | "ready" | "stale" | "failed"
|
||||
|
||||
interface UseServerPreviewOptions {
|
||||
/** 是否启用预览(Step4+ 且有素材和模板时) */
|
||||
enabled: boolean
|
||||
/** 构建预览请求参数(每次 render 调用,获取最新配置) */
|
||||
buildRequest: () => CreatePreviewRequest
|
||||
/** 预览任务创建成功回调 */
|
||||
onPreviewTaskCreated?: (taskId: string, sourceEditPlanId?: string) => void
|
||||
}
|
||||
|
||||
interface UseServerPreviewReturn {
|
||||
status: ServerPreviewStatus
|
||||
videoUrl: string | null
|
||||
error: string | null
|
||||
/** 进度 0-100 */
|
||||
progress: number
|
||||
/** 手动触发预览创建("重新预览"按钮或标题变更后手动刷新) */
|
||||
triggerPreview: () => void
|
||||
/** 当前预览任务 ID */
|
||||
taskId: string | null
|
||||
}
|
||||
|
||||
const POLL_INTERVAL = 2000
|
||||
const POLL_TIMEOUT = 120_000
|
||||
const MAX_NETWORK_RETRIES = 2
|
||||
|
||||
/**
|
||||
* 对配置参数做指纹,用于检测配置是否变化
|
||||
*/
|
||||
function buildFingerprint(req: CreatePreviewRequest): string {
|
||||
return JSON.stringify({
|
||||
t: req.template_id,
|
||||
a: [...req.asset_ids].sort(),
|
||||
d: req.duration,
|
||||
r: req.video_ratio,
|
||||
v: req.voice_library_id,
|
||||
b: req.bgm_config,
|
||||
title: req.title_config,
|
||||
})
|
||||
}
|
||||
|
||||
export function useServerPreview({
|
||||
enabled,
|
||||
buildRequest,
|
||||
onPreviewTaskCreated,
|
||||
}: UseServerPreviewOptions): UseServerPreviewReturn {
|
||||
const [status, setStatus] = useState<ServerPreviewStatus>("idle")
|
||||
const [videoUrl, setVideoUrl] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [taskId, setTaskId] = useState<string | null>(null)
|
||||
|
||||
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const timeoutTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const requestSeqRef = useRef(0)
|
||||
const renderedFingerprintRef = useRef<string>("")
|
||||
const mountedRef = useRef(true)
|
||||
const networkRetriesRef = useRef(0)
|
||||
|
||||
// 始终持有最新的 buildRequest 和回调
|
||||
const buildRequestRef = useRef(buildRequest)
|
||||
buildRequestRef.current = buildRequest
|
||||
const onCreatedRef = useRef(onPreviewTaskCreated)
|
||||
onCreatedRef.current = onPreviewTaskCreated
|
||||
|
||||
/* ── 清理 ── */
|
||||
const clearTimers = useCallback(() => {
|
||||
if (pollTimerRef.current) {
|
||||
clearTimeout(pollTimerRef.current)
|
||||
pollTimerRef.current = null
|
||||
}
|
||||
if (timeoutTimerRef.current) {
|
||||
clearTimeout(timeoutTimerRef.current)
|
||||
timeoutTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
clearTimers()
|
||||
}
|
||||
}, [clearTimers])
|
||||
|
||||
/* ── 创建预览 + 轮询 ── */
|
||||
const createAndPoll = useCallback(
|
||||
async (request: CreatePreviewRequest, seq: number) => {
|
||||
setStatus("loading")
|
||||
setProgress(0)
|
||||
setError(null)
|
||||
networkRetriesRef.current = 0
|
||||
|
||||
try {
|
||||
const resp = await createPreview(request)
|
||||
if (seq !== requestSeqRef.current || !mountedRef.current) return
|
||||
|
||||
// 兼容批量响应 {items, total}:取第一个变体
|
||||
const firstTask = resp.items?.[0]
|
||||
const taskId = firstTask?.task_id || ""
|
||||
setTaskId(taskId)
|
||||
onCreatedRef.current?.(taskId, resp.source_edit_plan_id)
|
||||
|
||||
let completed = false
|
||||
|
||||
// 超时保护
|
||||
timeoutTimerRef.current = setTimeout(() => {
|
||||
if (completed || seq !== requestSeqRef.current || !mountedRef.current) return
|
||||
completed = true
|
||||
clearTimers()
|
||||
setStatus("failed")
|
||||
setError("预览渲染超时(120秒),请重试")
|
||||
}, POLL_TIMEOUT)
|
||||
|
||||
const poll = async () => {
|
||||
if (completed || seq !== requestSeqRef.current || !mountedRef.current) return
|
||||
|
||||
try {
|
||||
const st = await getPreviewStatus(taskId)
|
||||
if (completed || seq !== requestSeqRef.current || !mountedRef.current) return
|
||||
|
||||
if (st.status === "completed" && st.video_url) {
|
||||
completed = true
|
||||
clearTimers()
|
||||
renderedFingerprintRef.current = buildFingerprint(request)
|
||||
setVideoUrl(st.video_url)
|
||||
setProgress(100)
|
||||
setStatus("ready")
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
|
||||
if (st.status === "failed" || st.status === "cancelled") {
|
||||
completed = true
|
||||
clearTimers()
|
||||
setStatus("failed")
|
||||
setError(
|
||||
st.status === "cancelled"
|
||||
? "预览任务已取消"
|
||||
: st.error_message || "预览渲染失败,请重试",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// pending / generating
|
||||
if (typeof st.progress === "number") setProgress(st.progress)
|
||||
pollTimerRef.current = setTimeout(poll, POLL_INTERVAL)
|
||||
} catch (pollErr) {
|
||||
if (completed || seq !== requestSeqRef.current || !mountedRef.current) return
|
||||
if (networkRetriesRef.current < MAX_NETWORK_RETRIES) {
|
||||
networkRetriesRef.current += 1
|
||||
console.warn(
|
||||
`[ServerPreview] 轮询网络错误,第 ${networkRetriesRef.current} 次重试`,
|
||||
pollErr,
|
||||
)
|
||||
pollTimerRef.current = setTimeout(poll, POLL_INTERVAL * 2)
|
||||
} else {
|
||||
completed = true
|
||||
clearTimers()
|
||||
setStatus("failed")
|
||||
setError("网络错误,无法获取预览状态,请重试")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
poll()
|
||||
} catch (createErr) {
|
||||
if (seq !== requestSeqRef.current || !mountedRef.current) return
|
||||
console.error("[ServerPreview] 创建预览任务失败:", createErr)
|
||||
|
||||
const isNetworkError =
|
||||
!!(createErr as { request?: unknown })?.request ||
|
||||
(createErr as { code?: string })?.code === "ERR_NETWORK"
|
||||
|
||||
if (isNetworkError && networkRetriesRef.current < MAX_NETWORK_RETRIES) {
|
||||
networkRetriesRef.current += 1
|
||||
console.warn(`[ServerPreview] 创建任务网络错误,第 ${networkRetriesRef.current} 次重试`)
|
||||
setTimeout(() => {
|
||||
if (seq === requestSeqRef.current && mountedRef.current) {
|
||||
createAndPoll(request, seq)
|
||||
}
|
||||
}, POLL_INTERVAL * 2)
|
||||
return
|
||||
}
|
||||
|
||||
const errData = (
|
||||
createErr as { response?: { data?: { detail?: string; message?: string } } }
|
||||
)?.response?.data
|
||||
setStatus("failed")
|
||||
setError(errData?.detail || errData?.message || "预览任务创建失败,请重试")
|
||||
}
|
||||
},
|
||||
[clearTimers],
|
||||
)
|
||||
|
||||
/* ── 手动触发预览 ── */
|
||||
const triggerPreview = useCallback(() => {
|
||||
if (!enabled) return
|
||||
const request = buildRequestRef.current()
|
||||
if (!request.template_id || request.asset_ids.length === 0) return
|
||||
|
||||
clearTimers()
|
||||
const seq = ++requestSeqRef.current
|
||||
setVideoUrl(null)
|
||||
setTaskId(null)
|
||||
createAndPoll(request, seq)
|
||||
}, [enabled, clearTimers, createAndPoll])
|
||||
|
||||
/* ── 自动触发 + 配置变更检测 ── */
|
||||
// 每次 render 都检查最新配置 fingerprint,与已渲染的 fingerprint 比较
|
||||
const request = enabled ? buildRequest() : null
|
||||
const currentFingerprint = request
|
||||
? request.template_id && request.asset_ids.length > 0
|
||||
? buildFingerprint(request)
|
||||
: ""
|
||||
: ""
|
||||
|
||||
// 首次进入自动触发
|
||||
const didInitRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (!enabled || !currentFingerprint) {
|
||||
didInitRef.current = false
|
||||
// 禁用时取消进行中的轮询,避免回到前序步骤后仍在后台轮询
|
||||
requestSeqRef.current += 1
|
||||
clearTimers()
|
||||
return
|
||||
}
|
||||
if (!didInitRef.current) {
|
||||
didInitRef.current = true
|
||||
renderedFingerprintRef.current = currentFingerprint
|
||||
triggerPreview()
|
||||
}
|
||||
}, [enabled, currentFingerprint, triggerPreview, clearTimers])
|
||||
|
||||
// 配置变更检测:素材/配音/BGM 等变化 → 自动重渲染;标题样式变化 → 标记 stale
|
||||
const prevFingerprintRef = useRef(currentFingerprint)
|
||||
useEffect(() => {
|
||||
if (!enabled || !currentFingerprint) return
|
||||
const prev = prevFingerprintRef.current
|
||||
prevFingerprintRef.current = currentFingerprint
|
||||
|
||||
if (!prev || prev === currentFingerprint) return
|
||||
if (currentFingerprint === renderedFingerprintRef.current) return
|
||||
|
||||
// 配置已变更
|
||||
// 判断是标题样式变更还是素材/配音/BGM 变更
|
||||
const prevParsed = JSON.parse(prev) as Record<string, unknown>
|
||||
const currParsed = JSON.parse(currentFingerprint) as Record<string, unknown>
|
||||
const nonTitleChanged =
|
||||
prevParsed.t !== currParsed.t ||
|
||||
prevParsed.a !== currParsed.a ||
|
||||
prevParsed.d !== currParsed.d ||
|
||||
prevParsed.r !== currParsed.r ||
|
||||
prevParsed.v !== currParsed.v ||
|
||||
JSON.stringify(prevParsed.b) !== JSON.stringify(currParsed.b)
|
||||
|
||||
if (nonTitleChanged) {
|
||||
// 素材/配音/BGM/模板等变化 → 自动重新渲染
|
||||
renderedFingerprintRef.current = currentFingerprint
|
||||
triggerPreview()
|
||||
} else {
|
||||
// 仅标题文字/样式变化 → 标记 stale,不自动重渲染(避免频繁请求)
|
||||
// 实时预览由 CSS TitleOverlay 提供
|
||||
setStatus((s) => (s === "ready" ? "stale" : s))
|
||||
}
|
||||
}, [enabled, currentFingerprint, triggerPreview])
|
||||
|
||||
return {
|
||||
status,
|
||||
videoUrl,
|
||||
error,
|
||||
progress,
|
||||
triggerPreview,
|
||||
taskId,
|
||||
}
|
||||
}
|
||||
|
||||
export default useServerPreview
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* 批量变体配音预览音频解析(#1750)
|
||||
*
|
||||
* 独立配音模式下每个变体挂载各自的配音 URL:
|
||||
* - 配音素材(voice 资产,有 file_url)→ 直接用素材文件 URL;
|
||||
* - AI 音色(预置/克隆,无实体文件)→ 按该变体自己的标题调 /tts/preview 合成;
|
||||
* - 共用模式下所有变体解析为同一条(等价于旧 previewVoiceAudioUrl)。
|
||||
*
|
||||
* N=1 不使用本 hook(单视频配音预览逻辑在 GeneratePage 内保持不变,零回归)。
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getAssetsByKind } from "@/api/assets"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import { previewTts } from "@/api/tts"
|
||||
|
||||
interface UseVariantVoicePreviewOptions {
|
||||
/** 是否批量模式(count>1) */
|
||||
enabled: boolean
|
||||
/** 变体数量 */
|
||||
count: number
|
||||
/** 是否每个视频独立配音 */
|
||||
perVideo: boolean
|
||||
/** 共用配音 ID(独立模式下为变体 0 的配音) */
|
||||
sharedVoiceId: string
|
||||
/** 克隆音色 ID 覆盖(共用模式,与旧逻辑一致:selectedClonedVoice || selectedVoice) */
|
||||
clonedVoiceId?: string
|
||||
/** 各变体独立配音 ID(独立模式);长度不足时回退共用 */
|
||||
variantVoiceIds: string[]
|
||||
/** 各变体标题(TTS 合成文案源) */
|
||||
titles: string[]
|
||||
}
|
||||
|
||||
/** 稳定的空数组常量:避免 useQuery 数据未就绪时每次渲染产生新引用导致 effect 无限触发 */
|
||||
const EMPTY_VOICE_MATERIALS: AssetItem[] = []
|
||||
|
||||
/** 判断配音 ID 是否对应实体素材(有 file_url);否则视为 AI 音色需 TTS */
|
||||
function findMaterialUrl(id: string, materials: AssetItem[]): string | null {
|
||||
if (!id) return null
|
||||
const m = materials.find((x) => x.id === id)
|
||||
return m?.file_url || null
|
||||
}
|
||||
|
||||
export function useVariantVoicePreview({
|
||||
enabled,
|
||||
count,
|
||||
perVideo,
|
||||
sharedVoiceId,
|
||||
clonedVoiceId = "",
|
||||
variantVoiceIds,
|
||||
titles,
|
||||
}: UseVariantVoicePreviewOptions): (string | null)[] {
|
||||
const [urls, setUrls] = useState<(string | null)[]>([])
|
||||
// 配音素材库:组件内部自取,避免调用方传入不稳定数组引用导致 effect 反复触发
|
||||
const { data: voiceMaterialsData } = useQuery({
|
||||
queryKey: ["assets", "voice"],
|
||||
queryFn: () => getAssetsByKind("voice", { limit: 50 }),
|
||||
})
|
||||
const voiceMaterials: AssetItem[] = voiceMaterialsData ?? EMPTY_VOICE_MATERIALS
|
||||
// 已缓存的 TTS 结果:key = `${voiceId}|${title}`,避免重复合成
|
||||
const ttsCacheRef = useRef<Map<string, string>>(new Map())
|
||||
// 在途请求 AbortController
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const seqRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || count <= 1) {
|
||||
setUrls((prev) => (prev.length === 0 ? prev : []))
|
||||
return
|
||||
}
|
||||
|
||||
const seq = ++seqRef.current
|
||||
abortRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
|
||||
// 每个变体实际使用的配音 ID
|
||||
const voiceIds = Array.from({ length: count }, (_, i) =>
|
||||
perVideo ? variantVoiceIds[i] || sharedVoiceId : sharedVoiceId,
|
||||
)
|
||||
|
||||
// 先用素材 URL 同步填充;AI 音色位置先置 null,待 TTS 完成
|
||||
const result: (string | null)[] = voiceIds.map((id, i) => {
|
||||
const materialUrl = findMaterialUrl(id, voiceMaterials)
|
||||
if (materialUrl) return materialUrl
|
||||
// 共用模式下克隆音色 ID 可能与 selectedVoice 不同(与旧 useEffect 逻辑一致)
|
||||
if (!perVideo && i === 0 && clonedVoiceId) {
|
||||
return findMaterialUrl(clonedVoiceId, voiceMaterials)
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
// 收集需要 TTS 的变体:无素材 URL 且有标题且有音色 ID
|
||||
const ttsJobs: { index: number; voiceId: string; title: string }[] = []
|
||||
voiceIds.forEach((id, i) => {
|
||||
if (result[i]) return
|
||||
// 共用模式沿用旧逻辑:voice_id = selectedClonedVoice || selectedVoice
|
||||
const ttsVoiceId = !perVideo && i === 0 ? clonedVoiceId || id : id
|
||||
const title = titles[i] || ""
|
||||
if (!ttsVoiceId || !title) return
|
||||
ttsJobs.push({ index: i, voiceId: ttsVoiceId, title })
|
||||
})
|
||||
|
||||
setUrls((prev) =>
|
||||
prev.length === result.length && prev.every((v, i) => v === result[i]) ? prev : result,
|
||||
)
|
||||
|
||||
if (ttsJobs.length === 0) return
|
||||
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
// 串行 TTS,避免瞬间 N 个合成请求打爆后端
|
||||
for (const job of ttsJobs) {
|
||||
const cacheKey = `${job.voiceId}|${job.title}`
|
||||
const cached = ttsCacheRef.current.get(cacheKey)
|
||||
if (cached) {
|
||||
if (seq === seqRef.current) {
|
||||
setUrls((prev) => {
|
||||
if (prev[job.index] === cached) return prev
|
||||
const next = [...prev]
|
||||
next[job.index] = cached
|
||||
return next
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const res = await previewTts({ text: job.title, voice_id: job.voiceId })
|
||||
if (cancelled || controller.signal.aborted || seq !== seqRef.current) return
|
||||
const audioUrl = res.audio_url || ""
|
||||
if (audioUrl) {
|
||||
ttsCacheRef.current.set(cacheKey, audioUrl)
|
||||
setUrls((prev) => {
|
||||
if (prev[job.index] === audioUrl) return prev
|
||||
const next = [...prev]
|
||||
next[job.index] = audioUrl
|
||||
return next
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
if (cancelled || controller.signal.aborted || seq !== seqRef.current) return
|
||||
console.warn(`[变体${job.index + 1}预览配音生成失败]`, err)
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
controller.abort()
|
||||
}
|
||||
}, [
|
||||
enabled,
|
||||
count,
|
||||
perVideo,
|
||||
sharedVoiceId,
|
||||
clonedVoiceId,
|
||||
variantVoiceIds,
|
||||
titles,
|
||||
voiceMaterials,
|
||||
])
|
||||
|
||||
return urls
|
||||
}
|
||||
|
||||
export default useVariantVoicePreview
|
||||
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* 共享:根据素材列表和模板片段计算总视频时长
|
||||
* GeneratePage(配音校验)和 FrontendPreviewPlayer(播放控制)共用
|
||||
*/
|
||||
|
||||
export interface DurationAsset {
|
||||
id?: string
|
||||
duration?: number
|
||||
metadata?: { duration?: number }
|
||||
}
|
||||
|
||||
export interface DurationTemplateSegment {
|
||||
duration_min?: number
|
||||
duration_max?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算总视频时长
|
||||
* @param assets 素材列表
|
||||
* @param template 模板(含 segments)
|
||||
* @returns 总时长(秒),无有效数据时返回 0
|
||||
*/
|
||||
export function calculateTotalVideoDuration(
|
||||
assets: DurationAsset[] | undefined,
|
||||
template: { segments?: DurationTemplateSegment[] } | undefined,
|
||||
): number {
|
||||
if (!assets || assets.length === 0 || !template) return 0
|
||||
|
||||
const templateSegments = template.segments || []
|
||||
|
||||
return assets.reduce((sum, asset, i) => {
|
||||
const assetDuration = asset.duration || asset.metadata?.duration || 30
|
||||
const tplSeg = templateSegments[i] || templateSegments[templateSegments.length - 1]
|
||||
const segDuration = tplSeg
|
||||
? Math.min(
|
||||
tplSeg.duration_max ?? assetDuration,
|
||||
Math.max(tplSeg.duration_min ?? 0, assetDuration),
|
||||
)
|
||||
: Math.min(assetDuration, 10)
|
||||
return sum + segDuration
|
||||
}, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 估算总视频时长(仅依赖模板 segments)
|
||||
* 当素材未加载或加载失败时,用各片段 duration_max 之和作为估算值
|
||||
* 确保配音时长校验不会因素材未就绪而跳过
|
||||
*/
|
||||
export function estimateTotalVideoDuration(
|
||||
template: { segments?: DurationTemplateSegment[] } | undefined,
|
||||
): number {
|
||||
if (!template?.segments || template.segments.length === 0) return 0
|
||||
return template.segments.reduce((sum, seg) => sum + (seg.duration_max || 0), 0)
|
||||
}
|
||||
@@ -66,7 +66,7 @@ export const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||
|
||||
<div className="mt-card-meta">
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{tpl.segments.length} 片段 · 预估 ~{tpl.estimated_duration}s
|
||||
{tpl.segments.length} 个片段
|
||||
</Text>
|
||||
{tpl.category && <Tag style={{ fontSize: 11, marginTop: 4 }}>{tpl.category}</Tag>}
|
||||
</div>
|
||||
|
||||
@@ -57,11 +57,6 @@ export function useMyTemplates() {
|
||||
title_config: tpl.title_config,
|
||||
subtitle_config: tpl.subtitle_config,
|
||||
bgm_config: tpl.bgm_config,
|
||||
estimated_duration:
|
||||
tpl.estimated_duration ??
|
||||
Math.round(
|
||||
tpl.segments.reduce((s, seg) => s + (seg.duration_min + seg.duration_max) / 2, 0),
|
||||
),
|
||||
segments: tpl.segments.map(({ id: _id, ...rest }) => rest),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 模板库页面(升级版)— V21 设计系统
|
||||
* 对接后端模板管理 API:
|
||||
* - 分页查询(page/page_size/category/keyword/duration_range)
|
||||
* - 分页查询(page/page_size/category/keyword)
|
||||
* - 模板详情(素材规则、字幕样式、BGM、比例等参数配置)
|
||||
* - 复制模板 / 从模板生成
|
||||
* - 卡片网格布局 + 类型筛选 + 搜索 + 收藏
|
||||
@@ -24,7 +24,6 @@ const TemplateLibrary: React.FC = () => {
|
||||
error,
|
||||
searchText,
|
||||
activeType,
|
||||
durationRange,
|
||||
page,
|
||||
pageSize,
|
||||
setPage,
|
||||
@@ -34,7 +33,6 @@ const TemplateLibrary: React.FC = () => {
|
||||
handleCreate,
|
||||
handleSearchChange,
|
||||
handleCategoryChange,
|
||||
handleDurationChange,
|
||||
} = useTemplateLibrary()
|
||||
|
||||
const {
|
||||
@@ -62,8 +60,6 @@ const TemplateLibrary: React.FC = () => {
|
||||
onSearchChange={handleSearchChange}
|
||||
activeType={activeType}
|
||||
onTypeChange={handleCategoryChange}
|
||||
durationRange={durationRange}
|
||||
onDurationChange={handleDurationChange}
|
||||
/>
|
||||
|
||||
{/* 模板展示区 */}
|
||||
@@ -77,7 +73,6 @@ const TemplateLibrary: React.FC = () => {
|
||||
errorMessage={error?.message}
|
||||
searchText={searchText}
|
||||
activeType={activeType}
|
||||
durationRange={durationRange}
|
||||
onPageChange={setPage}
|
||||
onPreview={handlePreview}
|
||||
onToggleFavorite={toggleFavorite}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react"
|
||||
import { Tag } from "antd"
|
||||
import type { TemplateItem } from "@/api/templates"
|
||||
import { gradientForCategory, getTypeColor, formatDuration } from "../../utils/templateLibrary"
|
||||
import { gradientForCategory, getTypeColor } from "../../utils/templateLibrary"
|
||||
|
||||
interface TemplateCardProps {
|
||||
template: TemplateItem
|
||||
@@ -37,7 +37,7 @@ export const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||
<div className="xx-template-thumb-name">{template.name}</div>
|
||||
<div className="xx-template-thumb-meta">
|
||||
<span className="xx-template-thumb-duration">
|
||||
{formatDuration(template.estimated_duration ?? template.target_duration)}
|
||||
{template.clip_count || template.segments?.length || 0} 个片段
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-template-preview-hint">点击查看详情</div>
|
||||
|
||||
@@ -5,9 +5,8 @@ import PreviewArea from "./template-detail-modal/PreviewArea"
|
||||
import SegmentList from "./template-detail-modal/SegmentList"
|
||||
import StyleConfig from "./template-detail-modal/StyleConfig"
|
||||
import DetailFooter from "./template-detail-modal/DetailFooter"
|
||||
import { getTypeColor, formatDuration } from "../../utils/templateLibrary"
|
||||
import { getTypeColor } from "../../utils/templateLibrary"
|
||||
import { TEMPLATE_TYPES } from "../../constants/templateLibrary"
|
||||
import { calcTotalSegmentDuration } from "../../utils/templateLibrary"
|
||||
|
||||
export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
|
||||
template,
|
||||
@@ -18,7 +17,6 @@ export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
|
||||
onCopy,
|
||||
}) => {
|
||||
const segments = template.segments ?? []
|
||||
const totalSegmentDuration = calcTotalSegmentDuration(segments)
|
||||
const typeInfo = TEMPLATE_TYPES.find((t) => t.type === template.category)
|
||||
|
||||
return (
|
||||
@@ -64,11 +62,6 @@ export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
|
||||
size="small"
|
||||
className="xx-template-modal-desc-table"
|
||||
items={[
|
||||
{
|
||||
key: "duration",
|
||||
label: "目标时长",
|
||||
children: formatDuration(template.estimated_duration ?? template.target_duration),
|
||||
},
|
||||
{
|
||||
key: "clips",
|
||||
label: "片段数量",
|
||||
@@ -87,7 +80,7 @@ export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
|
||||
]}
|
||||
/>
|
||||
|
||||
<SegmentList segments={segments} totalDuration={totalSegmentDuration} />
|
||||
<SegmentList segments={segments} />
|
||||
<StyleConfig template={template} />
|
||||
<DetailFooter
|
||||
template={template}
|
||||
|
||||
@@ -14,7 +14,6 @@ interface TemplateGridProps {
|
||||
errorMessage?: string
|
||||
searchText: string
|
||||
activeType: string
|
||||
durationRange: string
|
||||
onPageChange: (page: number) => void
|
||||
onPreview: (template: TemplateItem) => void
|
||||
onToggleFavorite: (id: string, e: React.MouseEvent) => void
|
||||
@@ -31,7 +30,6 @@ export const TemplateGrid: React.FC<TemplateGridProps> = ({
|
||||
errorMessage,
|
||||
searchText,
|
||||
activeType,
|
||||
durationRange,
|
||||
onPageChange,
|
||||
onPreview,
|
||||
onToggleFavorite,
|
||||
@@ -64,7 +62,7 @@ export const TemplateGrid: React.FC<TemplateGridProps> = ({
|
||||
|
||||
// 空状态
|
||||
if (templates.length === 0) {
|
||||
const hasFilter = !!searchText || activeType !== "全部" || !!durationRange
|
||||
const hasFilter = !!searchText || activeType !== "全部"
|
||||
return (
|
||||
<div className="xx-templates-empty">
|
||||
<div className="xx-templates-empty-icon">
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import React from "react"
|
||||
import { SearchOutlined } from "@ant-design/icons"
|
||||
import type { EditTemplateType, DurationRange } from "../../types/templateLibrary"
|
||||
import { TEMPLATE_TYPES, DURATION_OPTIONS } from "../../constants/templateLibrary"
|
||||
import type { EditTemplateType } from "../../types/templateLibrary"
|
||||
import { TEMPLATE_TYPES } from "../../constants/templateLibrary"
|
||||
|
||||
interface TemplateToolbarProps {
|
||||
searchText: string
|
||||
onSearchChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
activeType: EditTemplateType | "全部"
|
||||
onTypeChange: (type: EditTemplateType | "全部") => void
|
||||
durationRange: DurationRange
|
||||
onDurationChange: (value: DurationRange) => void
|
||||
}
|
||||
|
||||
export const TemplateToolbar: React.FC<TemplateToolbarProps> = ({
|
||||
@@ -17,8 +15,6 @@ export const TemplateToolbar: React.FC<TemplateToolbarProps> = ({
|
||||
onSearchChange,
|
||||
activeType,
|
||||
onTypeChange,
|
||||
durationRange,
|
||||
onDurationChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-templates-toolbar">
|
||||
@@ -46,18 +42,8 @@ export const TemplateToolbar: React.FC<TemplateToolbarProps> = ({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* 时长筛选 */}
|
||||
<div className="xx-templates-duration-filter">
|
||||
{DURATION_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={`xx-templates-duration-btn${durationRange === opt.value ? " active" : ""}`}
|
||||
onClick={() => onDurationChange(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TemplateToolbar
|
||||
|
||||
+3
-13
@@ -1,15 +1,13 @@
|
||||
import React from "react"
|
||||
import { Tooltip } from "antd"
|
||||
import type { TemplateSegment } from "@/api/templates"
|
||||
import { getMaterialTypeLabel, getTypeColor, formatDuration } from "../../../utils/templateLibrary"
|
||||
import { getMaterialTypeLabel, getTypeColor } from "../../../utils/templateLibrary"
|
||||
|
||||
interface SegmentListProps {
|
||||
segments: TemplateSegment[]
|
||||
totalDuration: number
|
||||
}
|
||||
|
||||
/** 素材规则 / 片段列表 */
|
||||
const SegmentList: React.FC<SegmentListProps> = ({ segments, totalDuration }) => {
|
||||
/** 素材规则 / 片段列表(#1750:模板只定片段数/结构,无时长概念) */
|
||||
const SegmentList: React.FC<SegmentListProps> = ({ segments }) => {
|
||||
if (segments.length === 0) return null
|
||||
|
||||
return (
|
||||
@@ -35,17 +33,9 @@ const SegmentList: React.FC<SegmentListProps> = ({ segments, totalDuration }) =>
|
||||
<span className="xx-template-modal-clip-desc">
|
||||
{seg.description || `片段 ${seg.segment_order}`}
|
||||
</span>
|
||||
<Tooltip title={`时长范围: ${seg.duration_min}秒 - ${seg.duration_max}秒`}>
|
||||
<span className="xx-template-modal-clip-duration">
|
||||
{seg.duration_min}-{seg.duration_max}秒
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="xx-template-modal-total-duration">
|
||||
预估总时长:{formatDuration(Math.round(totalDuration))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
video: "视频",
|
||||
image: "图片",
|
||||
|
||||
@@ -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<EditTemplateType | "全部">("全部")
|
||||
const [durationRange, setDurationRange] = useState<DurationRange>("")
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
export type EditTemplateType = "口播" | "种草" | "产品" | "品牌" | "混剪" | "Vlog"
|
||||
|
||||
/** 时长筛选值 */
|
||||
export type DurationRange = "" | "short" | "medium" | "long"
|
||||
|
||||
/** 配置展示字段 */
|
||||
export interface ConfigDisplayFields {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<Record<string, unknown>>)
|
||||
|
||||
vi.mock("@/pages/generate/components/FrontendPreviewPlayer", () => ({
|
||||
default: (props: Record<string, unknown>) => {
|
||||
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 (
|
||||
<div data-testid={`player-${seed}`}>
|
||||
<span data-testid={`voice-${seed}`}>{props.voiceAudioUrl ? "has-voice" : "no-voice"}</span>
|
||||
<span data-testid={`token-${seed}`}>{token == null ? "none" : String(token)}</span>
|
||||
<button type="button" onClick={() => change(seed)}>
|
||||
play-{seed}
|
||||
<div data-testid={`player-${token}`}>
|
||||
<span data-testid={`voice-${token}`}>{props.voiceAudioUrl ? "has-voice" : "no-voice"}</span>
|
||||
<span data-testid={`token-${token}`}>{active == null ? "none" : String(active)}</span>
|
||||
<button type="button" onClick={() => change(token)}>
|
||||
play-{token}
|
||||
</button>
|
||||
<button type="button" onClick={() => change(null)}>
|
||||
pause-{seed}
|
||||
pause-{token}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
@@ -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<string, unknown> = {}) {
|
||||
playerCalls.length = 0
|
||||
return render(
|
||||
<CanvasPreviewGrid
|
||||
count={count}
|
||||
count={3}
|
||||
assets={[makeAsset("a1"), makeAsset("a2"), makeAsset("a3")]}
|
||||
template={null}
|
||||
videoRatio="9:16"
|
||||
titles={["标题1", "标题2", "标题3"]}
|
||||
titleSettings={titleSettings}
|
||||
voiceAudioUrl="https://cdn.example.com/tts.mp3"
|
||||
variantClips={variantClips}
|
||||
voiceAudioUrls={["https://cdn.example.com/v0.mp3", null, "https://cdn.example.com/v2.mp3"]}
|
||||
selectedIds={[0, 1, 2]}
|
||||
onToggleSelect={() => {}}
|
||||
{...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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(<FrontendPreviewPlayer {...baseProps} voiceAudioUrl="https://cdn.example.com/tts.mp3" />)
|
||||
expect(videos()).toHaveLength(2)
|
||||
@@ -115,20 +139,20 @@ describe("FrontendPreviewPlayer 音频行为 (#1741)", () => {
|
||||
const { rerender } = render(
|
||||
<FrontendPreviewPlayer
|
||||
{...baseProps}
|
||||
variantSeed={2}
|
||||
activePlayToken={2}
|
||||
playToken={1}
|
||||
activePlayToken={1}
|
||||
onPlayTokenChange={() => {}}
|
||||
compact
|
||||
/>,
|
||||
)
|
||||
expect(mocks.pause).not.toHaveBeenCalled()
|
||||
|
||||
// 播放权切给实例 3
|
||||
// 播放权切给实例 2(0 基 token)
|
||||
rerender(
|
||||
<FrontendPreviewPlayer
|
||||
{...baseProps}
|
||||
variantSeed={2}
|
||||
activePlayToken={3}
|
||||
playToken={1}
|
||||
activePlayToken={2}
|
||||
onPlayTokenChange={() => {}}
|
||||
compact
|
||||
/>,
|
||||
@@ -139,8 +163,8 @@ describe("FrontendPreviewPlayer 音频行为 (#1741)", () => {
|
||||
rerender(
|
||||
<FrontendPreviewPlayer
|
||||
{...baseProps}
|
||||
variantSeed={2}
|
||||
activePlayToken={2}
|
||||
playToken={1}
|
||||
activePlayToken={1}
|
||||
onPlayTokenChange={() => {}}
|
||||
compact
|
||||
/>,
|
||||
@@ -151,33 +175,39 @@ describe("FrontendPreviewPlayer 音频行为 (#1741)", () => {
|
||||
it("未播放时 token 变化不触发暂停(effect 仅在本实例播放时生效)", () => {
|
||||
// mocks.isPlaying = false(beforeEach 重置)
|
||||
const { rerender } = render(
|
||||
<FrontendPreviewPlayer {...baseProps} variantSeed={1} activePlayToken={1} compact />,
|
||||
<FrontendPreviewPlayer {...baseProps} playToken={0} activePlayToken={0} compact />,
|
||||
)
|
||||
rerender(<FrontendPreviewPlayer {...baseProps} variantSeed={1} activePlayToken={2} compact />)
|
||||
rerender(<FrontendPreviewPlayer {...baseProps} playToken={0} activePlayToken={1} compact />)
|
||||
expect(mocks.pause).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("暂停状态下点击播放按钮:上报播放权为自身 variantSeed 并触发播放", () => {
|
||||
it("暂停状态下点击播放按钮:上报播放权为自身 playToken 并触发播放", () => {
|
||||
const onToken = vi.fn()
|
||||
render(
|
||||
<FrontendPreviewPlayer {...baseProps} variantSeed={3} onPlayTokenChange={onToken} compact />,
|
||||
<FrontendPreviewPlayer {...baseProps} playToken={2} onPlayTokenChange={onToken} compact />,
|
||||
)
|
||||
// 暂停态有两个图标播放按钮(中央大按钮 + 控制条按钮),均调 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(
|
||||
<div>
|
||||
<FrontendPreviewPlayer {...baseProps} variantSeed={1} compact />
|
||||
<FrontendPreviewPlayer {...baseProps} variantSeed={2} compact />
|
||||
<FrontendPreviewPlayer {...baseProps} variantSeed={3} compact />
|
||||
<FrontendPreviewPlayer {...baseProps} playToken={0} compact />
|
||||
<FrontendPreviewPlayer {...baseProps} playToken={1} compact />
|
||||
<FrontendPreviewPlayer {...baseProps} playToken={2} compact />
|
||||
</div>,
|
||||
)
|
||||
expect(screen.getAllByRole("button", { name: "静音" })).toHaveLength(3)
|
||||
})
|
||||
|
||||
it("#1750 serverClips 缺失时不渲染任何 video(无本地模拟 fallback,显示无可播放素材)", () => {
|
||||
render(<FrontendPreviewPlayer {...baseProps} serverClips={undefined} />)
|
||||
expect(videos()).toHaveLength(0)
|
||||
expect(screen.getByText("暂无可播放素材")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user