fix(web): 修复批量变体预览3个P0 bug——配音参数传递/音画时长对齐/类型补齐
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 50s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 47s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m11s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m20s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 1m41s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m50s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 4m42s
AI Code Review / AI Code Review (pull_request) Successful in 6m16s
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 62h11m17s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 62h11m30s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 62h11m23s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 62h11m3s
CI/CD Pipeline / PR Build Worker Image (pull_request) Failing after 62h11m3s
CI/CD Pipeline / PR Build API Image (pull_request) Failing after 62h11m4s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 62h10m52s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 62h10m55s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 62h11m0s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 62h10m57s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 62h11m3s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 62h10m51s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 62h11m9s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 62h11m5s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 62h46m23s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 50s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 47s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m11s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m20s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 1m41s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m50s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 4m42s
AI Code Review / AI Code Review (pull_request) Successful in 6m16s
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 62h11m17s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 62h11m30s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 62h11m23s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 62h11m3s
CI/CD Pipeline / PR Build Worker Image (pull_request) Failing after 62h11m3s
CI/CD Pipeline / PR Build API Image (pull_request) Failing after 62h11m4s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 62h10m52s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 62h10m55s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 62h11m0s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 62h10m57s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 62h11m3s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 62h10m51s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 62h11m9s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 62h11m5s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 62h46m23s
This commit is contained in:
@@ -26,6 +26,10 @@ export interface BatchVariantPlansRequest {
|
||||
count: number
|
||||
/** 源剪辑计划 ID:优先取预览/草稿关联的 plan;不传由后端按 template_id+user 兜底最新 plan */
|
||||
source_edit_plan_id?: string
|
||||
/** 统一配音 ID(共用配音模式);独立配音模式不传,改传 voice_library_ids */
|
||||
voice_library_id?: string
|
||||
/** 独立配音 ID 列表(长度=count,按变体序号一一对应);共用配音模式不传 */
|
||||
voice_library_ids?: string[]
|
||||
}
|
||||
|
||||
/** 单个变体的计划片段 */
|
||||
@@ -36,6 +40,8 @@ export interface VariantPlan {
|
||||
plan_id: string
|
||||
/** 该变体的真实片段(顺序/素材/起点与正式成片一致) */
|
||||
clips: EditPlanClip[]
|
||||
/** 该变体实际配音时长(秒),用于前端预览按配音时长对齐音画;后端暂未返回时缺省 */
|
||||
voice_duration?: number
|
||||
}
|
||||
|
||||
/** 批量变体计划响应 */
|
||||
|
||||
@@ -231,9 +231,15 @@ const GeneratePage: React.FC = () => {
|
||||
/* ── 批量变体真实片段(#1744):后端独立选片,预览即成片;失败静默降级本地模拟 ──
|
||||
仅批量(N>1)且在第 4 步预览时申请,避免选素材阶段频繁请求;
|
||||
变体 0 沿用草稿 plan(与单视频一致),变体 1..N-1 后端 reselect 独立选片 */
|
||||
// P0 fix:批量变体计划请求需携带配音参数,避免后端按"无配音"选片导致 clips 时长与配音错位
|
||||
const batchVoiceLibraryId =
|
||||
voiceMode === "clone"
|
||||
? selectedClonedVoice || selectedVoice || ""
|
||||
: selectedVoice || ""
|
||||
const {
|
||||
clipsByVariant: variantClips,
|
||||
planIdsByVariant: variantPlanIds,
|
||||
voiceDurationsByVariant: variantVoiceDurations,
|
||||
loading: variantClipsLoading,
|
||||
error: variantClipsError,
|
||||
retry: retryVariantClips,
|
||||
@@ -243,6 +249,9 @@ const GeneratePage: React.FC = () => {
|
||||
templateId: selectedTemplate || "",
|
||||
assetIds: previewAssetIds,
|
||||
sourcePlanId: storedSourceEditPlanId || sourceEditPlanId || "",
|
||||
voiceLibraryId: batchVoiceLibraryId,
|
||||
voiceLibraryIds: voiceLibraryIds || [],
|
||||
voiceModePerVideo,
|
||||
})
|
||||
|
||||
/* ── 批量变体配音预览 URL(#1750):独立模式每变体挂各自配音,共用模式全挂同一条;
|
||||
@@ -476,6 +485,7 @@ const GeneratePage: React.FC = () => {
|
||||
titles={previewTitles}
|
||||
titleSettings={titleSettings}
|
||||
voiceAudioUrls={variantVoiceAudioUrls}
|
||||
voiceDurations={variantVoiceDurations}
|
||||
variantClips={variantClips}
|
||||
clipsLoading={variantClipsLoading}
|
||||
clipsError={variantClipsError}
|
||||
|
||||
@@ -31,6 +31,12 @@ interface CanvasPreviewGridProps {
|
||||
* 元素为 null 表示该变体暂无音频(AI 音色 TTS 合成中))
|
||||
*/
|
||||
voiceAudioUrls?: (string | null)[]
|
||||
/**
|
||||
* 各变体配音时长(秒):后端返回 voice_duration 优先;未返回则为 undefined,
|
||||
* 由 FrontendPreviewPlayer 在 audio loadedmetadata 时自测兜底。
|
||||
* 长度=count,undefined 项表示该变体未提供后端时长。
|
||||
*/
|
||||
voiceDurations?: (number | undefined)[]
|
||||
/**
|
||||
* 各变体的后端真实片段(#1744/#1750):长度=count。
|
||||
* 仅 clipsLoading=false 且 clipsError=false 时才会传给播放器。
|
||||
@@ -56,6 +62,7 @@ const CanvasPreviewGrid: React.FC<CanvasPreviewGridProps> = ({
|
||||
titles,
|
||||
titleSettings,
|
||||
voiceAudioUrls,
|
||||
voiceDurations,
|
||||
variantClips,
|
||||
clipsLoading = false,
|
||||
clipsError = false,
|
||||
@@ -120,6 +127,7 @@ const CanvasPreviewGrid: React.FC<CanvasPreviewGridProps> = ({
|
||||
serverClips={variantClips[i]}
|
||||
variantTitle={titles[i] || ""}
|
||||
voiceAudioUrl={voiceAudioUrls?.[i] || undefined}
|
||||
voiceDurationHint={voiceDurations?.[i]}
|
||||
activePlayToken={activePlayToken}
|
||||
onPlayTokenChange={setActivePlayToken}
|
||||
compact
|
||||
|
||||
@@ -58,6 +58,11 @@ interface FrontendPreviewPlayerProps {
|
||||
activePlayToken?: number | null
|
||||
/** 播放权变化回调:本实例请求播放时传自身 playToken,暂停时传 null */
|
||||
onPlayTokenChange?: (token: number | null) => void
|
||||
/**
|
||||
* 后端返回的配音时长(秒)P0 对齐:优先以该值作为音画时长锚点;
|
||||
* 未提供则在 audio loadedmetadata 后自测兜底。
|
||||
*/
|
||||
voiceDurationHint?: number
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
@@ -116,6 +121,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
ready,
|
||||
serverClips,
|
||||
voiceAudioUrl,
|
||||
voiceDurationHint,
|
||||
titleSettings,
|
||||
onTitlePositionChange,
|
||||
playToken,
|
||||
@@ -124,17 +130,26 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
activePlayToken = null,
|
||||
onPlayTokenChange,
|
||||
}) => {
|
||||
// #1754:测量配音时长,计算缩放因子
|
||||
const [voiceDuration, setVoiceDuration] = useState(0)
|
||||
// #1754→P0:配音时长作为音画时长锚点。
|
||||
// 优先使用后端返回的 voiceDurationHint;音频 loadedmetadata 后再以自测值覆盖(更精确)。
|
||||
const [voiceDuration, setVoiceDuration] = useState<number>(() =>
|
||||
voiceDurationHint && voiceDurationHint > 0 ? voiceDurationHint : 0,
|
||||
)
|
||||
// 当外部 hint 变化且尚未拿到自测值时先同步
|
||||
useEffect(() => {
|
||||
if (voiceDurationHint && voiceDurationHint > 0) {
|
||||
setVoiceDuration((prev) => (prev > 0 ? prev : voiceDurationHint))
|
||||
}
|
||||
}, [voiceDurationHint])
|
||||
useEffect(() => {
|
||||
if (!voiceAudioUrl) {
|
||||
setVoiceDuration(0)
|
||||
setVoiceDuration((prev) => (voiceDurationHint && voiceDurationHint > 0 ? voiceDurationHint : 0))
|
||||
return
|
||||
}
|
||||
const audio = new Audio()
|
||||
audio.preload = "metadata"
|
||||
const onLoaded = () => {
|
||||
if (audio.duration && isFinite(audio.duration)) {
|
||||
if (audio.duration && isFinite(audio.duration) && audio.duration > 0) {
|
||||
setVoiceDuration(audio.duration)
|
||||
}
|
||||
}
|
||||
@@ -143,7 +158,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
return () => {
|
||||
audio.removeEventListener("loadedmetadata", onLoaded)
|
||||
}
|
||||
}, [voiceAudioUrl])
|
||||
}, [voiceAudioUrl, voiceDurationHint])
|
||||
|
||||
// #1756:clips 原始总时长 + 转场时长(后端等比分配配音时包含转场占位)
|
||||
const rawClipsDuration = useMemo(() => {
|
||||
@@ -351,6 +366,13 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
const canPlay = effectiveUseWebCodecs ? canvasState.isReady : videoCanPlay
|
||||
const isBuffering = effectiveUseWebCodecs ? canvasState.isBuffering : false
|
||||
|
||||
// P0 fix:以配音时长为音画同步锚点。有配音时,播放器总时长 = 配音时长;
|
||||
// - 视频比配音短:播完后末帧冻结,直至配音结束自动暂停
|
||||
// - 视频比配音长:到配音时长点硬停,截断视频
|
||||
// 无配音时沿用视频总时长(单视频原声兜底路径)。
|
||||
const effectiveTotalDuration =
|
||||
!!voiceAudioUrl && voiceDuration > 0 ? voiceDuration : totalDuration
|
||||
|
||||
// ── 配音音频同步 ──
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const prevIsPlayingRef = useRef(false)
|
||||
@@ -400,8 +422,83 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [segmentSyncKey, isPlaying])
|
||||
|
||||
// P0 fix:视频比配音短时的「末帧冻结+音频续播」模式。
|
||||
// 视频调度器播完最后一段会自动 pause + setIsPlaying(false),此时若配音仍在播,
|
||||
// 用虚拟时钟继续推进 currentTime 直到配音时长,期间保持音频播放、视频停在末帧。
|
||||
const [tailCurrentTime, setTailCurrentTime] = useState<number | null>(null)
|
||||
const tailStartRef = useRef<number>(0)
|
||||
const tailBaseRef = useRef<number>(0)
|
||||
useEffect(() => {
|
||||
// 进入尾段条件:有配音 + 视频已停 + 配音还没播完
|
||||
const needTail =
|
||||
!!voiceAudioUrl &&
|
||||
voiceDuration > 0 &&
|
||||
!isPlaying &&
|
||||
!isDragging &&
|
||||
typeof currentTime === "number" &&
|
||||
currentTime >= totalDuration - 0.1 &&
|
||||
currentTime < voiceDuration - 0.1
|
||||
if (needTail && tailCurrentTime === null) {
|
||||
tailBaseRef.current = currentTime
|
||||
tailStartRef.current = performance.now()
|
||||
setTailCurrentTime(currentTime)
|
||||
const a = audioRef.current
|
||||
if (a && a.src) {
|
||||
try {
|
||||
if (Math.abs(a.currentTime - currentTime) > 0.3) a.currentTime = currentTime
|
||||
a.play().catch(() => {})
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!needTail && tailCurrentTime !== null) {
|
||||
setTailCurrentTime(null)
|
||||
}
|
||||
}, [isPlaying, currentTime, totalDuration, voiceDuration, voiceAudioUrl, isDragging, tailCurrentTime])
|
||||
|
||||
useEffect(() => {
|
||||
if (tailCurrentTime === null) return
|
||||
let raf = 0
|
||||
const tick = () => {
|
||||
const elapsed = (performance.now() - tailStartRef.current) / 1000
|
||||
const t = Math.min(tailBaseRef.current + elapsed, voiceDuration || tailBaseRef.current)
|
||||
setTailCurrentTime(t)
|
||||
const a = audioRef.current
|
||||
if (a && a.src && !a.paused && Math.abs(a.currentTime - t) > 0.5) {
|
||||
try {
|
||||
a.currentTime = t
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
if (t >= (voiceDuration || 0) - 0.05) {
|
||||
// 到达配音结尾:暂停音频,复位
|
||||
if (a) {
|
||||
try {
|
||||
a.pause()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
if (playToken != null) onPlayTokenChange?.(null)
|
||||
setTailCurrentTime(null)
|
||||
return
|
||||
}
|
||||
raf = requestAnimationFrame(tick)
|
||||
}
|
||||
raf = requestAnimationFrame(tick)
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [tailCurrentTime, voiceDuration, playToken, onPlayTokenChange])
|
||||
|
||||
// 呈现给 UI/进度条的「当前时间」:尾段用虚拟时间,否则用视频/Canvas 时间
|
||||
const displayCurrentTime = tailCurrentTime !== null ? tailCurrentTime : currentTime
|
||||
|
||||
const handleSeekTo = useCallback(
|
||||
(time: number) => {
|
||||
// 退出尾段模式,回到视频驱动
|
||||
setTailCurrentTime(null)
|
||||
if (effectiveUseWebCodecs) {
|
||||
canvasControls.seek(time)
|
||||
} else {
|
||||
@@ -458,12 +555,12 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
|
||||
const handleProgressClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!progressRef.current || totalDuration <= 0) return
|
||||
if (!progressRef.current || effectiveTotalDuration <= 0) return
|
||||
const rect = progressRef.current.getBoundingClientRect()
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
handleSeekTo(ratio * totalDuration)
|
||||
handleSeekTo(ratio * effectiveTotalDuration)
|
||||
},
|
||||
[totalDuration, handleSeekTo],
|
||||
[effectiveTotalDuration, handleSeekTo],
|
||||
)
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
@@ -477,10 +574,10 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
useEffect(() => {
|
||||
if (!isDragging) return
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!progressRef.current || totalDuration <= 0) return
|
||||
if (!progressRef.current || effectiveTotalDuration <= 0) return
|
||||
const rect = progressRef.current.getBoundingClientRect()
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
handleSeekTo(ratio * totalDuration)
|
||||
handleSeekTo(ratio * effectiveTotalDuration)
|
||||
}
|
||||
const handleMouseUp = () => setIsDragging(false)
|
||||
window.addEventListener("mousemove", handleMouseMove)
|
||||
@@ -489,13 +586,65 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
window.removeEventListener("mousemove", handleMouseMove)
|
||||
window.removeEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
}, [isDragging, totalDuration, handleSeekTo])
|
||||
}, [isDragging, effectiveTotalDuration, handleSeekTo])
|
||||
|
||||
const progressPercent = totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0
|
||||
|
||||
const progressPercent = effectiveTotalDuration > 0 ? (displayCurrentTime / effectiveTotalDuration) * 100 : 0
|
||||
|
||||
// ── Canvas 容器 ref(保留声明,WebCodecs 兜底路径仍引用) ──
|
||||
const canvasContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// P0 fix:音画同步看门狗——有配音时,播放时间达到配音时长立即暂停视频+音频(末帧冻结),
|
||||
// 防止视频比配音长时继续播放、或视频播完后音频继续拖尾;音频自身 ended 也走同一暂停路径。
|
||||
useEffect(() => {
|
||||
if (!isPlaying) return
|
||||
if (!voiceAudioUrl || voiceDuration <= 0) return
|
||||
if (displayCurrentTime < voiceDuration - 0.08) return
|
||||
if (effectiveUseWebCodecs) {
|
||||
canvasControls.pause()
|
||||
} else {
|
||||
videoPause()
|
||||
}
|
||||
const a = audioRef.current
|
||||
if (a) {
|
||||
try {
|
||||
a.pause()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
if (playToken != null) onPlayTokenChange?.(null)
|
||||
}, [
|
||||
isPlaying,
|
||||
displayCurrentTime,
|
||||
voiceAudioUrl,
|
||||
voiceDuration,
|
||||
effectiveUseWebCodecs,
|
||||
canvasControls,
|
||||
videoPause,
|
||||
playToken,
|
||||
onPlayTokenChange,
|
||||
])
|
||||
|
||||
// 音频自然结束时同步暂停视频(兜底:浏览器音频 ended 可能早于/晚于 currentTime 看门狗触发)
|
||||
useEffect(() => {
|
||||
const a = audioRef.current
|
||||
if (!a) return
|
||||
const onEnded = () => {
|
||||
if (!isPlaying) return
|
||||
if (effectiveUseWebCodecs) {
|
||||
canvasControls.pause()
|
||||
} else {
|
||||
videoPause()
|
||||
}
|
||||
if (playToken != null) onPlayTokenChange?.(null)
|
||||
}
|
||||
a.addEventListener("ended", onEnded)
|
||||
return () => {
|
||||
a.removeEventListener("ended", onEnded)
|
||||
}
|
||||
}, [isPlaying, effectiveUseWebCodecs, canvasControls, videoPause, playToken, onPlayTokenChange])
|
||||
|
||||
// ── 未就绪 ──
|
||||
if (!ready || !assets.length) {
|
||||
return (
|
||||
@@ -888,7 +1037,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
letterSpacing: 0.2,
|
||||
}}
|
||||
>
|
||||
{formatTime(currentTime)} / {formatTime(totalDuration)}
|
||||
{formatTime(displayCurrentTime)} / {formatTime(effectiveTotalDuration)}
|
||||
</span>
|
||||
|
||||
<div
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface BatchVariantClipsState {
|
||||
clipsByVariant: EditPlanClip[][]
|
||||
/** 各变体的 plan_id(正式生成回传,保证预览即成片);未就绪为空串 */
|
||||
planIdsByVariant: string[]
|
||||
/** 各变体的后端返回配音时长(秒);未就绪/未返回为 undefined */
|
||||
voiceDurationsByVariant: (number | undefined)[]
|
||||
/** 是否正在向后端申请变体计划 */
|
||||
loading: boolean
|
||||
/** 后端真实片段是否全部可用(每个变体都有 ≥1 条片段) */
|
||||
@@ -44,6 +46,12 @@ interface UseBatchVariantPlansOptions {
|
||||
assetIds: string[]
|
||||
/** 源剪辑计划 ID(草稿/预览关联),无则空串由后端兜底最新 plan */
|
||||
sourcePlanId?: string
|
||||
/** 统一配音 ID(共用配音模式),参考 useGenerateVideo voiceLibraryId 计算 */
|
||||
voiceLibraryId?: string
|
||||
/** 独立配音 ID 列表(每变体一条),voiceModePerVideo=true 时使用 */
|
||||
voiceLibraryIds?: string[]
|
||||
/** 是否启用独立配音模式(每变体各自一条配音) */
|
||||
voiceModePerVideo?: boolean
|
||||
}
|
||||
|
||||
export function useBatchVariantPlans({
|
||||
@@ -52,9 +60,13 @@ export function useBatchVariantPlans({
|
||||
templateId,
|
||||
assetIds,
|
||||
sourcePlanId = "",
|
||||
voiceLibraryId = "",
|
||||
voiceLibraryIds = [],
|
||||
voiceModePerVideo = false,
|
||||
}: UseBatchVariantPlansOptions): BatchVariantClipsState {
|
||||
const [clipsByVariant, setClipsByVariant] = useState<EditPlanClip[][]>([])
|
||||
const [planIdsByVariant, setPlanIdsByVariant] = useState<string[]>([])
|
||||
const [voiceDurationsByVariant, setVoiceDurationsByVariant] = useState<(number | undefined)[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
@@ -70,17 +82,30 @@ export function useBatchVariantPlans({
|
||||
setLoading(true)
|
||||
setError(false)
|
||||
try {
|
||||
// 配音参数:与 useGenerateVideo 保持一致的传参逻辑
|
||||
// - 独立配音模式 + voiceLibraryIds 非空:传 voice_library_ids
|
||||
// - 统一配音:传 voice_library_id
|
||||
// - 都没选:不传
|
||||
const voiceParam: { voice_library_id?: string; voice_library_ids?: string[] } = {}
|
||||
if (voiceModePerVideo && voiceLibraryIds.length > 0) {
|
||||
voiceParam.voice_library_ids = voiceLibraryIds
|
||||
} else if (voiceLibraryId) {
|
||||
voiceParam.voice_library_id = voiceLibraryId
|
||||
}
|
||||
|
||||
const resp = await createBatchVariantPlans({
|
||||
template_id: templateId,
|
||||
asset_ids: assetIds,
|
||||
count,
|
||||
...(sourcePlanId ? { source_edit_plan_id: sourcePlanId } : {}),
|
||||
...voiceParam,
|
||||
})
|
||||
if (seq !== requestSeqRef.current) return
|
||||
|
||||
const items: VariantPlan[] = Array.isArray(resp.items) ? resp.items : []
|
||||
const clips: EditPlanClip[][] = Array.from({ length: count }, () => [])
|
||||
const planIds: string[] = Array.from({ length: count }, () => "")
|
||||
const voiceDurs: (number | undefined)[] = Array.from({ length: count }, () => undefined)
|
||||
for (const item of items) {
|
||||
const idx = item.variant_index
|
||||
if (idx < 0 || idx >= count) continue
|
||||
@@ -88,6 +113,9 @@ export function useBatchVariantPlans({
|
||||
clips[idx] = (item.clips || [])
|
||||
.filter((c) => c && c.asset_id && c.status === "ready")
|
||||
.sort((a, b) => a.order - b.order)
|
||||
if (typeof item.voice_duration === "number" && item.voice_duration > 0) {
|
||||
voiceDurs[idx] = item.voice_duration
|
||||
}
|
||||
}
|
||||
// 数据完整性校验:每个变体都必须有真实片段,否则视为失败(不允许假数据冒充)
|
||||
const incomplete = clips.some((list) => list.length === 0)
|
||||
@@ -95,10 +123,12 @@ export function useBatchVariantPlans({
|
||||
console.warn("[useBatchVariantPlans] 变体计划数据不完整(存在空片段变体),标记加载失败")
|
||||
setClipsByVariant([])
|
||||
setPlanIdsByVariant([])
|
||||
setVoiceDurationsByVariant([])
|
||||
setError(true)
|
||||
} else {
|
||||
setClipsByVariant(clips)
|
||||
setPlanIdsByVariant(planIds)
|
||||
setVoiceDurationsByVariant(voiceDurs)
|
||||
setError(false)
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -107,11 +137,20 @@ export function useBatchVariantPlans({
|
||||
console.warn("[useBatchVariantPlans] 申请变体计划失败,预览加载失败:", err)
|
||||
setClipsByVariant([])
|
||||
setPlanIdsByVariant([])
|
||||
setVoiceDurationsByVariant([])
|
||||
setError(true)
|
||||
} finally {
|
||||
if (seq === requestSeqRef.current) setLoading(false)
|
||||
}
|
||||
}, [templateId, count, sourcePlanId, assetIds])
|
||||
}, [
|
||||
templateId,
|
||||
count,
|
||||
sourcePlanId,
|
||||
assetIds,
|
||||
voiceLibraryId,
|
||||
voiceLibraryIds,
|
||||
voiceModePerVideo,
|
||||
])
|
||||
|
||||
/** 用户点击「重试」:nonce +1 驱动 effect 重新发起请求(effect 内 lastKey 校验保证只发一次) */
|
||||
const retry = useCallback(() => {
|
||||
@@ -125,24 +164,40 @@ export function useBatchVariantPlans({
|
||||
// 避免父组件传入内联字面量数组导致 effect 每次 render 触发 → 无限 setState 循环
|
||||
setClipsByVariant((prev) => (prev.length === 0 ? prev : []))
|
||||
setPlanIdsByVariant((prev) => (prev.length === 0 ? prev : []))
|
||||
setVoiceDurationsByVariant((prev) => (prev.length === 0 ? prev : []))
|
||||
setLoading((prev) => (prev === false ? prev : false))
|
||||
setError((prev) => (prev === false ? prev : false))
|
||||
lastKeyRef.current = ""
|
||||
return
|
||||
}
|
||||
const voiceKey = voiceModePerVideo
|
||||
? `per:${[...voiceLibraryIds].sort().join(",")}`
|
||||
: `one:${voiceLibraryId}`
|
||||
const key = `${retryNonce}|${templateId}|${count}|${sourcePlanId}|${[...assetIds]
|
||||
.sort()
|
||||
.join(",")}`
|
||||
.join(",")}|${voiceKey}`
|
||||
if (key === lastKeyRef.current) return
|
||||
lastKeyRef.current = key
|
||||
load()
|
||||
}, [enabled, templateId, count, sourcePlanId, assetIds, load, retryNonce])
|
||||
}, [
|
||||
enabled,
|
||||
templateId,
|
||||
count,
|
||||
sourcePlanId,
|
||||
assetIds,
|
||||
load,
|
||||
retryNonce,
|
||||
voiceLibraryId,
|
||||
voiceLibraryIds,
|
||||
voiceModePerVideo,
|
||||
])
|
||||
|
||||
const ready = !error && !loading && clipsByVariant.every((list) => list.length > 0)
|
||||
|
||||
return {
|
||||
clipsByVariant,
|
||||
planIdsByVariant,
|
||||
voiceDurationsByVariant,
|
||||
loading,
|
||||
ready,
|
||||
error,
|
||||
|
||||
Reference in New Issue
Block a user