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

This commit is contained in:
灵应
2026-09-14 01:12:05 +08:00
parent 5b931438e0
commit 8278456cd4
5 changed files with 244 additions and 16 deletions
@@ -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,