Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b53c4a130 | |||
| 632c3043bc | |||
| 472f371d2f | |||
| 709d65ce52 | |||
| ae1b9f8ddb | |||
| 1c9903574a | |||
| cfedc06df0 | |||
| 4429784a79 | |||
| aa36e63591 | |||
| c3373de4f3 | |||
| 60d95e64ab | |||
| 7775d5d118 | |||
| f025514d16 | |||
| e043b0aa6a | |||
| fdce08356b | |||
| 89cccf294c | |||
| 5f3ff6bb8c | |||
| ca78b182af | |||
| ca8f1079b1 | |||
| ee92f72252 | |||
| 7b63219d9e | |||
| dfd821f5ba | |||
| bfc9e07170 | |||
| 2c2fcabd6c | |||
| 4e19d08a98 | |||
| d9b4041215 | |||
| 48d1b4dd33 | |||
| 25ce6d6286 |
@@ -3,13 +3,9 @@ export type {
|
||||
CreatePreviewRequest,
|
||||
CreatePreviewResponse,
|
||||
PreviewTaskResponse,
|
||||
ConfirmGenerationRequest,
|
||||
ConfirmGenerationResponse,
|
||||
ConfirmGenerationTaskItem,
|
||||
} from "./types"
|
||||
|
||||
export { createPreview, getPreviewStatus } from "./preview"
|
||||
export { confirmGeneration } from "./confirm"
|
||||
|
||||
export { generateCover } from "./cover"
|
||||
export type { GenerateCoverRequest, GenerateCoverResponse } from "./cover"
|
||||
|
||||
@@ -57,8 +57,31 @@ export interface TaskListResponse {
|
||||
export interface CreateGenerationTaskRequest {
|
||||
template_id: string
|
||||
asset_ids: string[]
|
||||
title_ids: string[]
|
||||
voice_ids: string[]
|
||||
title_ids?: string[]
|
||||
voice_ids?: string[]
|
||||
/** 输出视频宽度 */
|
||||
output_width?: number
|
||||
/** 输出视频高度 */
|
||||
output_height?: number
|
||||
/** 自定义封面图片 URL */
|
||||
cover_url?: string
|
||||
/** 自定义视频标题 */
|
||||
custom_title?: string
|
||||
/** 视频时长(秒) */
|
||||
duration?: number
|
||||
/** 视频宽高比,如 "9:16" */
|
||||
video_ratio?: string
|
||||
/** 标题烧录配置 */
|
||||
title_config?: {
|
||||
text?: string
|
||||
font?: string
|
||||
font_size?: number
|
||||
font_color?: string
|
||||
position?: string
|
||||
bold?: boolean
|
||||
stroke?: boolean
|
||||
shadow?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
/** 创建生成任务响应(对齐后端 GenerationTaskResponse) */
|
||||
|
||||
@@ -18,6 +18,10 @@ import { useCloneProgress } from "@/hooks/useCloneProgress"
|
||||
import { getAssetsByKind } from "@/api/assets"
|
||||
import CloneModal from "@/components/voice/CloneModal"
|
||||
import GenerateHeader from "./components/GenerateHeader"
|
||||
import {
|
||||
calculateTotalVideoDuration,
|
||||
estimateTotalVideoDuration,
|
||||
} from "./utils/calculateTotalVideoDuration"
|
||||
import GenerateStepsBar from "./components/GenerateStepsBar"
|
||||
import GenerateResultPanel from "./components/GenerateResultPanel"
|
||||
import PreviewVideoPanel from "./components/PreviewVideoPanel"
|
||||
@@ -109,16 +113,10 @@ const GeneratePage: React.FC = () => {
|
||||
|
||||
/* ── 视频总时长计算(用于配音时长校验) ── */
|
||||
const totalVideoDuration = useMemo(() => {
|
||||
if (!previewAssets.length || !currentTemplate) return 0
|
||||
const templateSegments = currentTemplate.segments || []
|
||||
return previewAssets.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, Math.max(tplSeg.duration_min, assetDuration))
|
||||
: Math.min(assetDuration, 10)
|
||||
return sum + segDuration
|
||||
}, 0)
|
||||
// 优先用素材精确时长;素材未加载时用模板 segments 的 duration_max 之和估算
|
||||
const exact = calculateTotalVideoDuration(previewAssets, currentTemplate ?? undefined)
|
||||
if (exact > 0) return exact
|
||||
return estimateTotalVideoDuration(currentTemplate ?? undefined)
|
||||
}, [previewAssets, currentTemplate])
|
||||
|
||||
/* ── 配音音频 URL ── */
|
||||
@@ -173,7 +171,6 @@ const GeneratePage: React.FC = () => {
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
generateCount,
|
||||
previewTaskId: "",
|
||||
})
|
||||
|
||||
/* ================================================================
|
||||
@@ -289,6 +286,7 @@ const GeneratePage: React.FC = () => {
|
||||
|
||||
{/* ── 视频预览弹窗 ── */}
|
||||
<Modal
|
||||
className="xx-preview-modal"
|
||||
open={previewModalOpen}
|
||||
onCancel={() => setPreviewModalOpen(false)}
|
||||
footer={null}
|
||||
|
||||
@@ -272,21 +272,22 @@ const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
|
||||
>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||
{formatDuration(item.duration)}
|
||||
{totalVideoDuration > 0 && (item.duration || 0) < totalVideoDuration && (
|
||||
<span
|
||||
style={{
|
||||
color: "#ff4d4f",
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<WarningOutlined />
|
||||
时长不足
|
||||
</span>
|
||||
)}
|
||||
{totalVideoDuration > 0 &&
|
||||
(Number(item.duration) || 0) < Number(totalVideoDuration) && (
|
||||
<span
|
||||
style={{
|
||||
color: "#ff4d4f",
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<WarningOutlined />
|
||||
时长不足
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span>{formatFileSize(item.file_size)}</span>
|
||||
</div>
|
||||
|
||||
@@ -2881,11 +2881,11 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ant-modal-content {
|
||||
.xx-preview-modal .ant-modal-content {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.ant-modal-close {
|
||||
.xx-preview-modal .ant-modal-close {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,6 @@ export interface UseGenerateVideoProps {
|
||||
autoSubtitles: boolean
|
||||
bgm: boolean
|
||||
generateCount: number
|
||||
/** 预览任务的 task_id(用于新确认生成 API) */
|
||||
previewTaskId: string
|
||||
}
|
||||
|
||||
/** 生成阶段 */
|
||||
|
||||
@@ -8,6 +8,78 @@ import { useRef, useCallback, useEffect, useState } from "react"
|
||||
import { createFile } from "mp4box"
|
||||
import type { Movie, Sample } from "mp4box"
|
||||
|
||||
// ── MP4 Box 解析辅助函数 ──
|
||||
|
||||
/**
|
||||
* 递归搜索 box 树,找到 hvcC 或 avcC box 并返回其配置数据(不含 8 字节 box header)
|
||||
*
|
||||
* MP4 box 嵌套结构:moov → trak → mdia → minf → stbl → stsd → hev1 → hvcC
|
||||
* 普通容器 box 从 offset+8 开始递归;VisualSampleEntry (avc1/avc3/hvc1/hev1) 前 78 字节
|
||||
* 是固定字段(ISO 14496-12),子 box 从 offset+8+78 开始
|
||||
*/
|
||||
function findCodecConfigRecursive(
|
||||
buffer: ArrayBuffer,
|
||||
start: number,
|
||||
end: number,
|
||||
): ArrayBuffer | undefined {
|
||||
const view = new DataView(buffer)
|
||||
let offset = start
|
||||
|
||||
while (offset < end - 8) {
|
||||
const size = view.getUint32(offset)
|
||||
if (size < 8 || offset + size > end) break
|
||||
|
||||
const type = String.fromCharCode(
|
||||
view.getUint8(offset + 4),
|
||||
view.getUint8(offset + 5),
|
||||
view.getUint8(offset + 6),
|
||||
view.getUint8(offset + 7),
|
||||
)
|
||||
|
||||
// 找到目标 codec 配置 box,返回内容(不含 8 字节 header)
|
||||
if (type === "avcC" || type === "hvcC") {
|
||||
console.log("[findCodecConfig] Found", type, "at offset", offset, "size", size)
|
||||
return buffer.slice(offset + 8, offset + size)
|
||||
}
|
||||
|
||||
const visualSampleEntryTypes = ["avc1", "avc3", "hvc1", "hev1"]
|
||||
const isVisualSampleEntry = visualSampleEntryTypes.includes(type)
|
||||
|
||||
if (isVisualSampleEntry) {
|
||||
// VisualSampleEntry: 前 78 字节是固定字段,子 box 在 78 字节之后
|
||||
// 先尝试 offset+8+78,如果没找到再尝试 offset+8(兼容不同 MP4 结构)
|
||||
console.log(
|
||||
"[findCodecConfig] VisualSampleEntry:",
|
||||
type,
|
||||
"at",
|
||||
offset,
|
||||
"size",
|
||||
size,
|
||||
"trying offset+8+78",
|
||||
)
|
||||
const childResult1 = findCodecConfigRecursive(buffer, offset + 8 + 78, offset + size)
|
||||
if (childResult1) return childResult1
|
||||
|
||||
console.log(
|
||||
"[findCodecConfig] VisualSampleEntry:",
|
||||
type,
|
||||
"at",
|
||||
offset,
|
||||
"trying offset+8 (fallback)",
|
||||
)
|
||||
const childResult2 = findCodecConfigRecursive(buffer, offset + 8, offset + size)
|
||||
if (childResult2) return childResult2
|
||||
} else {
|
||||
// 普通容器 box,从 offset+8 开始递归
|
||||
const childResult = findCodecConfigRecursive(buffer, offset + 8, offset + size)
|
||||
if (childResult) return childResult
|
||||
}
|
||||
|
||||
offset += size
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// ── 帧队列(环形缓冲区) ──
|
||||
interface FrameEntry {
|
||||
frame: VideoFrame
|
||||
@@ -81,6 +153,10 @@ interface SegmentMeta {
|
||||
videoWidth: number
|
||||
/** 视频高度(像素) */
|
||||
videoHeight: number
|
||||
/** 解码器配置数据(HEVC hvcC / H.264 avcC),WebCodecs 必需 */
|
||||
description?: ArrayBuffer
|
||||
/** 前端提取的样本数据(已按时间范围过滤,从关键帧开始) */
|
||||
samples: Sample[]
|
||||
}
|
||||
|
||||
// ── 播放器状态 ──
|
||||
@@ -141,6 +217,10 @@ export function useCanvasPlayer(
|
||||
// ── 内部引用 ──
|
||||
const decoderRef = useRef<VideoDecoder | null>(null)
|
||||
const frameQueueRef = useRef(new FrameQueue(10))
|
||||
/** 已解码的片段索引集合,用于按需解码 */
|
||||
const decodedSegmentsRef = useRef(new Set<number>())
|
||||
/** 上一帧引用,用于在绘制新帧前释放上一帧防止内存泄漏 */
|
||||
const lastDrawnFrameRef = useRef<VideoFrame | null>(null)
|
||||
const rafRef = useRef<number>(0)
|
||||
const playStartRef = useRef<number>(0)
|
||||
const playStartOffsetRef = useRef<number>(0)
|
||||
@@ -171,11 +251,51 @@ export function useCanvasPlayer(
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ── 解封装单个片段,提取轨道元数据 ──
|
||||
// ── 从 MP4 buffer 提取编解码器配置数据(avcC / hvcC) ──
|
||||
// WebCodecs VideoDecoder 对 HEVC/H.265 必须提供 description 字段
|
||||
const extractCodecDescription = useCallback((buffer: ArrayBuffer): ArrayBuffer | undefined => {
|
||||
try {
|
||||
const view = new DataView(buffer)
|
||||
let offset = 0
|
||||
|
||||
// 查找 moov box
|
||||
while (offset < buffer.byteLength - 8) {
|
||||
const size = view.getUint32(offset)
|
||||
const type = String.fromCharCode(
|
||||
view.getUint8(offset + 4),
|
||||
view.getUint8(offset + 5),
|
||||
view.getUint8(offset + 6),
|
||||
view.getUint8(offset + 7),
|
||||
)
|
||||
if (type === "moov") {
|
||||
const result = findCodecConfigRecursive(buffer, offset + 8, offset + size)
|
||||
console.log("[useCanvasPlayer] extractCodecDescription:", {
|
||||
moovOffset: offset,
|
||||
moovSize: size,
|
||||
searchRange: [offset + 8, offset + size],
|
||||
found: !!result,
|
||||
resultByteLength: result?.byteLength,
|
||||
})
|
||||
return result
|
||||
}
|
||||
if (size === 0) break
|
||||
offset += size
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[useCanvasPlayer] extractCodecDescription failed:", e)
|
||||
}
|
||||
return undefined
|
||||
}, [])
|
||||
|
||||
// ── 解封装单个片段,提取轨道元数据 + 按时间范围过滤样本 ──
|
||||
// ✅ 关键修复:改为异步函数,等待 MP4Box.js 的 onSamples 回调完成后再返回
|
||||
const demuxSegment = useCallback(
|
||||
(buffer: ArrayBuffer, segIndex: number): SegmentMeta | null => {
|
||||
const segment = segments[segIndex]
|
||||
if (!segment) return null
|
||||
async (buffer: ArrayBuffer, segIndex: number): Promise<SegmentMeta | null> => {
|
||||
const segment = segments?.[segIndex]
|
||||
if (!segment) {
|
||||
console.warn("[useCanvasPlayer] No segment at index", segIndex)
|
||||
return null
|
||||
}
|
||||
|
||||
// 计算全局偏移
|
||||
let globalStart = 0
|
||||
@@ -184,55 +304,153 @@ export function useCanvasPlayer(
|
||||
}
|
||||
|
||||
const mp4File = createFile()
|
||||
let meta: SegmentMeta | null = null
|
||||
|
||||
mp4File.onReady = (info: Movie) => {
|
||||
const videoTrack = info.videoTracks[0]
|
||||
if (!videoTrack) {
|
||||
console.error("[useCanvasPlayer] No video track found for segment", segIndex)
|
||||
return
|
||||
return new Promise<SegmentMeta | null>((resolve) => {
|
||||
let meta: SegmentMeta | null = null
|
||||
let resolved = false
|
||||
|
||||
// ✅ 超时保护:5秒后如果 onSamples 没有触发,返回 null
|
||||
const timeout = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
console.error(
|
||||
`[useCanvasPlayer] Timeout: onSamples not triggered for segment ${segIndex}`,
|
||||
)
|
||||
resolved = true
|
||||
resolve(null)
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
mp4File.onReady = (info: Movie) => {
|
||||
const videoTrack = info?.videoTracks?.[0]
|
||||
|
||||
console.log("[useCanvasPlayer] demuxSegment:", {
|
||||
segIndex,
|
||||
startTime: segment.startTime,
|
||||
endTime: segment.endTime,
|
||||
nbSamples: videoTrack?.nb_samples,
|
||||
codec: videoTrack?.codec,
|
||||
videoWidth: videoTrack?.track_width,
|
||||
videoHeight: videoTrack?.track_height,
|
||||
})
|
||||
|
||||
if (!videoTrack) {
|
||||
console.warn("[useCanvasPlayer] No video track found for segment", segIndex)
|
||||
clearTimeout(timeout)
|
||||
resolved = true
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
|
||||
// 提取编解码器配置数据(HEVC 必需,H.264 也需要)
|
||||
const description = extractCodecDescription(buffer)
|
||||
|
||||
// ✅ 如果 description 缺失,无法解码 HEVC
|
||||
if (!description) {
|
||||
console.error(
|
||||
`[useCanvasPlayer] No description found for segment ${segIndex}, cannot decode HEVC`,
|
||||
)
|
||||
clearTimeout(timeout)
|
||||
resolved = true
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
|
||||
meta = {
|
||||
assetId: segment.assetId,
|
||||
videoUrl: segment.videoUrl,
|
||||
globalStartTime: globalStart,
|
||||
globalEndTime: globalStart + (segment.endTime - segment.startTime),
|
||||
trackId: videoTrack.id ?? 1,
|
||||
timescale: videoTrack.timescale ?? 90000,
|
||||
codec: videoTrack.codec ?? "avc1.42E01E",
|
||||
videoWidth: videoTrack.track_width || 1280,
|
||||
videoHeight: videoTrack.track_height || 720,
|
||||
description,
|
||||
samples: [],
|
||||
}
|
||||
|
||||
// 提取所有 samples
|
||||
mp4File.setExtractionOptions(videoTrack.id ?? 1, null, {
|
||||
nbSamples: Infinity, // 提取所有 sample
|
||||
})
|
||||
mp4File.start()
|
||||
}
|
||||
|
||||
meta = {
|
||||
assetId: segment.assetId,
|
||||
videoUrl: segment.videoUrl,
|
||||
globalStartTime: globalStart,
|
||||
globalEndTime: globalStart + (segment.endTime - segment.startTime),
|
||||
trackId: videoTrack.id,
|
||||
timescale: videoTrack.timescale,
|
||||
codec: videoTrack.codec,
|
||||
videoWidth: videoTrack.track_width || 1280,
|
||||
videoHeight: videoTrack.track_height || 720,
|
||||
mp4File.onSamples = (_trackId: number, _user: unknown, samples: Sample[]) => {
|
||||
if (resolved) return // ✅ 防止重复 resolve
|
||||
|
||||
if (!meta) {
|
||||
clearTimeout(timeout)
|
||||
resolved = true
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
|
||||
// 前端切片:按 [startTime, endTime] 时间范围过滤样本
|
||||
const timescale = meta.timescale
|
||||
const startCts = segment.startTime * timescale
|
||||
const endCts = segment.endTime * timescale
|
||||
|
||||
// 过滤出时间范围内的样本
|
||||
let filtered = samples.filter((s) => (s?.cts ?? 0) >= startCts && (s?.cts ?? 0) < endCts)
|
||||
|
||||
// 确保从关键帧开始(跳过第一个 sync 之前的非关键帧)
|
||||
let foundSync = false
|
||||
filtered = filtered.filter((s) => {
|
||||
if (s.is_sync) {
|
||||
foundSync = true
|
||||
return true
|
||||
}
|
||||
return foundSync
|
||||
})
|
||||
|
||||
// Fallback:如果时间范围内没有样本,使用全部样本从第一个关键帧开始
|
||||
if (filtered.length === 0) {
|
||||
console.warn(
|
||||
`[useCanvasPlayer] No samples in range [${segment.startTime}s, ${segment.endTime}s] for segment ${segIndex}, fallback to all from keyframe`,
|
||||
)
|
||||
let sync = false
|
||||
filtered = samples.filter((s) => {
|
||||
if (s.is_sync) {
|
||||
sync = true
|
||||
return true
|
||||
}
|
||||
return sync
|
||||
})
|
||||
}
|
||||
|
||||
meta.samples = filtered
|
||||
console.log(
|
||||
`[useCanvasPlayer] Segment ${segIndex}: ${filtered.length}/${samples.length} samples (range ${segment.startTime}s-${segment.endTime}s)`,
|
||||
)
|
||||
|
||||
// ✅ 关键修复:等待 onSamples 完成后再返回
|
||||
clearTimeout(timeout)
|
||||
resolved = true
|
||||
resolve(meta)
|
||||
}
|
||||
|
||||
// 提取所有 samples(start() 会同步触发 onSamples)
|
||||
mp4File.setExtractionOptions(videoTrack.id, null, {
|
||||
nbSamples: videoTrack.nb_samples,
|
||||
})
|
||||
mp4File.start()
|
||||
}
|
||||
mp4File.onError = (_module: string, message: string) => {
|
||||
console.error(`[useCanvasPlayer] MP4Box error: ${message}`)
|
||||
clearTimeout(timeout)
|
||||
resolved = true
|
||||
resolve(null)
|
||||
}
|
||||
|
||||
mp4File.onError = (_module: string, message: string) => {
|
||||
console.error(`[useCanvasPlayer] MP4Box error: ${message}`)
|
||||
}
|
||||
|
||||
// mp4box 需要在 buffer 上设置 fileStart 属性
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(buffer as any).fileStart = 0
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mp4File.appendBuffer(buffer as any)
|
||||
|
||||
return meta
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(buffer as any).fileStart = 0
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mp4File.appendBuffer(buffer as any)
|
||||
})
|
||||
},
|
||||
[segments],
|
||||
[segments, extractCodecDescription],
|
||||
)
|
||||
|
||||
// ── 初始化 VideoDecoder 并解码指定片段 ──
|
||||
const decodeSegment = useCallback(
|
||||
async (buffer: ArrayBuffer, meta: SegmentMeta): Promise<void> => {
|
||||
async (_buffer: ArrayBuffer, meta: SegmentMeta): Promise<void> => {
|
||||
if (isDestroyedRef.current) return
|
||||
|
||||
const mp4File = createFile()
|
||||
let decoderReady = false
|
||||
|
||||
// 配置解码器(每个片段可能需要不同的 codec/分辨率)
|
||||
@@ -251,11 +469,20 @@ export function useCanvasPlayer(
|
||||
},
|
||||
})
|
||||
|
||||
console.log("[useCanvasPlayer] configure:", {
|
||||
codec: meta.codec,
|
||||
description: meta.description,
|
||||
descriptionByteLength: meta.description?.byteLength,
|
||||
videoWidth: meta.videoWidth,
|
||||
videoHeight: meta.videoHeight,
|
||||
})
|
||||
|
||||
try {
|
||||
await decoder.configure({
|
||||
codec: meta.codec,
|
||||
codedWidth: meta.videoWidth,
|
||||
codedHeight: meta.videoHeight,
|
||||
...(meta.description ? { description: meta.description } : {}),
|
||||
})
|
||||
decoderRef.current = decoder
|
||||
decoderReady = true
|
||||
@@ -271,45 +498,24 @@ export function useCanvasPlayer(
|
||||
|
||||
if (!decoderReady) return
|
||||
|
||||
// 收集 samples
|
||||
const samplesCollected: Sample[] = []
|
||||
|
||||
mp4File.onReady = (info: Movie) => {
|
||||
const videoTrack = info.videoTracks[0]
|
||||
if (!videoTrack) return
|
||||
|
||||
mp4File.setExtractionOptions(videoTrack.id, null, {
|
||||
nbSamples: videoTrack.nb_samples,
|
||||
})
|
||||
mp4File.start()
|
||||
// 使用 demuxSegment 中已提取并过滤的 samples(前端切片)
|
||||
const samplesCollected = meta.samples
|
||||
if (samplesCollected.length === 0) {
|
||||
console.warn("[useCanvasPlayer] No samples to decode for segment", meta.assetId)
|
||||
return
|
||||
}
|
||||
|
||||
mp4File.onSamples = (_trackId: number, _user: unknown, samples: Sample[]) => {
|
||||
samplesCollected.push(...samples)
|
||||
}
|
||||
|
||||
mp4File.onError = (_module: string, message: string) => {
|
||||
console.error(`[useCanvasPlayer] MP4Box decode error: ${message}`)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(buffer as any).fileStart = 0
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mp4File.appendBuffer(buffer as any)
|
||||
|
||||
// 等待 samples 收集完成(mp4box start() 是同步的)
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
|
||||
// 送入解码器
|
||||
for (const sample of samplesCollected) {
|
||||
if (!sample.data || isDestroyedRef.current) continue
|
||||
if (decoder.state === "closed") break
|
||||
|
||||
if (!sample.data) continue
|
||||
const chunk = new EncodedVideoChunk({
|
||||
type: sample.is_sync ? "key" : "delta",
|
||||
timestamp: (sample.cts / meta.timescale) * 1_000_000,
|
||||
duration: (sample.duration / meta.timescale) * 1_000_000,
|
||||
data: sample.data.buffer as ArrayBuffer,
|
||||
timestamp: ((sample.cts ?? 0) / (meta.timescale || 90000)) * 1_000_000,
|
||||
duration: ((sample.duration ?? 0) / (meta.timescale || 90000)) * 1_000_000,
|
||||
data: sample.data,
|
||||
})
|
||||
|
||||
try {
|
||||
@@ -329,6 +535,52 @@ export function useCanvasPlayer(
|
||||
[],
|
||||
)
|
||||
|
||||
/**
|
||||
* 按需解码当前播放位置 ±1 个片段。
|
||||
* 在渲染循环中定期调用,避免一次性解码所有片段导致环形缓冲区溢出丢帧。
|
||||
*/
|
||||
const decodeAroundPosition = useCallback(
|
||||
async (currentTime: number) => {
|
||||
const metas = segmentMetaRef.current
|
||||
if (!metas || metas.length === 0) return
|
||||
|
||||
// 找到当前时间对应的片段索引
|
||||
let targetIdx = -1
|
||||
let acc = 0
|
||||
for (let i = 0; i < metas.length; i++) {
|
||||
const dur = metas[i].globalEndTime - metas[i].globalStartTime
|
||||
if (currentTime < acc + dur) {
|
||||
targetIdx = i
|
||||
break
|
||||
}
|
||||
acc += dur
|
||||
}
|
||||
if (targetIdx === -1) targetIdx = metas.length - 1
|
||||
|
||||
// 解码当前 ±1 片段
|
||||
for (
|
||||
let i = Math.max(0, targetIdx - 1);
|
||||
i <= Math.min(metas.length - 1, targetIdx + 1);
|
||||
i++
|
||||
) {
|
||||
if (decodedSegmentsRef.current.has(i)) continue
|
||||
const meta = metas[i]
|
||||
const buffer = segmentDataRef.current.get(meta.assetId)
|
||||
if (!buffer) continue
|
||||
// 先标记为解码中,防止下一帧重复发起解码
|
||||
decodedSegmentsRef.current.add(i)
|
||||
try {
|
||||
await decodeSegment(buffer, meta)
|
||||
} catch (e) {
|
||||
// 解码失败则移除标记,允许后续重试
|
||||
decodedSegmentsRef.current.delete(i)
|
||||
console.warn(`[useCanvasPlayer] 按需解码片段 ${i} 失败:`, e)
|
||||
}
|
||||
}
|
||||
},
|
||||
[decodeSegment],
|
||||
)
|
||||
|
||||
// ── 标题绘制 ──
|
||||
const drawTitle = useCallback(
|
||||
(
|
||||
@@ -341,17 +593,24 @@ export function useCanvasPlayer(
|
||||
ctx.fillStyle = title.color
|
||||
ctx.textAlign = "center"
|
||||
|
||||
let y: number
|
||||
// 按 "/" 分割为多行("/" 作为手动换行符)
|
||||
const lines = title.text.split(/[//⁄∕]/)
|
||||
console.log("[drawTitle] 原始标题:", JSON.stringify(title.text), "分割后:", lines)
|
||||
const lineHeight = fontSize * 1.3
|
||||
const totalHeight = lines.length * lineHeight
|
||||
|
||||
// 根据 position 计算第一行的 Y 坐标
|
||||
let startY: number
|
||||
switch (title.position) {
|
||||
case "top":
|
||||
y = fontSize + canvas.height * 0.08
|
||||
startY = fontSize + canvas.height * 0.08
|
||||
break
|
||||
case "bottom":
|
||||
y = canvas.height - canvas.height * 0.08
|
||||
startY = canvas.height - canvas.height * 0.08 - totalHeight + lineHeight
|
||||
break
|
||||
case "center":
|
||||
default:
|
||||
y = canvas.height / 2
|
||||
startY = (canvas.height - totalHeight) / 2 + lineHeight
|
||||
break
|
||||
}
|
||||
|
||||
@@ -362,13 +621,15 @@ export function useCanvasPlayer(
|
||||
ctx.shadowOffsetY = 2
|
||||
}
|
||||
|
||||
if (title.stroke) {
|
||||
ctx.strokeStyle = "#000000"
|
||||
ctx.lineWidth = 1
|
||||
ctx.strokeText(title.text, canvas.width / 2, y)
|
||||
}
|
||||
|
||||
ctx.fillText(title.text, canvas.width / 2, y)
|
||||
lines.forEach((line, idx) => {
|
||||
const y = startY + idx * lineHeight
|
||||
if (title.stroke) {
|
||||
ctx.strokeStyle = "#000000"
|
||||
ctx.lineWidth = 1
|
||||
ctx.strokeText(line, canvas.width / 2, y)
|
||||
}
|
||||
ctx.fillText(line, canvas.width / 2, y)
|
||||
})
|
||||
|
||||
ctx.shadowColor = "transparent"
|
||||
ctx.shadowBlur = 0
|
||||
@@ -423,12 +684,19 @@ export function useCanvasPlayer(
|
||||
|
||||
const frame = frameQueueRef.current.getCurrentFrame(currentTime)
|
||||
|
||||
// 释放上一帧,防止 VideoFrame 内存泄漏
|
||||
if (lastDrawnFrameRef.current) {
|
||||
lastDrawnFrameRef.current.close()
|
||||
lastDrawnFrameRef.current = null
|
||||
}
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
if (frame) {
|
||||
const rect = computeDrawRect(canvas.width, canvas.height)
|
||||
ctx.drawImage(frame, rect.dx, rect.dy, rect.dw, rect.dh)
|
||||
frame.close()
|
||||
// 保持引用,下一帧绘制时再释放
|
||||
lastDrawnFrameRef.current = frame
|
||||
}
|
||||
|
||||
if (titleSettings?.text) {
|
||||
@@ -445,6 +713,8 @@ export function useCanvasPlayer(
|
||||
}
|
||||
return s
|
||||
})
|
||||
// 按需解码当前 ±1 片段
|
||||
decodeAroundPosition(currentTime)
|
||||
}
|
||||
|
||||
if (currentTime >= totalDuration) {
|
||||
@@ -453,7 +723,7 @@ export function useCanvasPlayer(
|
||||
}
|
||||
|
||||
rafRef.current = requestAnimationFrame(renderFrame)
|
||||
}, [canvasRef, totalDuration, titleSettings, drawTitle, computeDrawRect])
|
||||
}, [canvasRef, totalDuration, titleSettings, drawTitle, computeDrawRect, decodeAroundPosition])
|
||||
|
||||
// ── 播放控制 ──
|
||||
const play = useCallback(async () => {
|
||||
@@ -472,15 +742,22 @@ export function useCanvasPlayer(
|
||||
}, [])
|
||||
|
||||
const seek = useCallback(
|
||||
(time: number) => {
|
||||
async (time: number) => {
|
||||
const clampedTime = Math.max(0, Math.min(time, totalDuration))
|
||||
setState((s) => ({ ...s, currentTime: clampedTime }))
|
||||
playStartOffsetRef.current = clampedTime
|
||||
playStartRef.current = performance.now()
|
||||
// seek 后清空帧队列,等待新帧解码
|
||||
// seek 后清空帧队列 + 释放上一帧
|
||||
frameQueueRef.current.clear()
|
||||
if (lastDrawnFrameRef.current) {
|
||||
lastDrawnFrameRef.current.close()
|
||||
lastDrawnFrameRef.current = null
|
||||
}
|
||||
// 清空已解码标记,重新解码 seek 目标区域
|
||||
decodedSegmentsRef.current.clear()
|
||||
await decodeAroundPosition(clampedTime)
|
||||
},
|
||||
[totalDuration],
|
||||
[totalDuration, decodeAroundPosition],
|
||||
)
|
||||
|
||||
const destroy = useCallback(() => {
|
||||
@@ -491,9 +768,16 @@ export function useCanvasPlayer(
|
||||
decoderRef.current.close()
|
||||
}
|
||||
|
||||
// 释放上一帧引用
|
||||
if (lastDrawnFrameRef.current) {
|
||||
lastDrawnFrameRef.current.close()
|
||||
lastDrawnFrameRef.current = null
|
||||
}
|
||||
|
||||
frameQueueRef.current.clear()
|
||||
segmentDataRef.current.clear()
|
||||
segmentMetaRef.current = []
|
||||
decodedSegmentsRef.current.clear()
|
||||
}, [])
|
||||
|
||||
// ── 预加载下一个片段的数据 ──
|
||||
@@ -510,7 +794,16 @@ export function useCanvasPlayer(
|
||||
|
||||
// ── 初始化:加载并解码所有片段 ──
|
||||
useEffect(() => {
|
||||
if (!state.hasSupport || segments.length === 0) return
|
||||
if (!state.hasSupport || segments.length === 0) {
|
||||
console.log("[useCanvasPlayer] Skip init:", {
|
||||
hasSupport: state.hasSupport,
|
||||
segmentCount: segments.length,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
console.log("[useCanvasPlayer] Init start, segments:", segments.length)
|
||||
|
||||
const init = async () => {
|
||||
setState((s) => ({ ...s, isBuffering: true }))
|
||||
@@ -518,20 +811,37 @@ export function useCanvasPlayer(
|
||||
// 1. 加载所有片段数据
|
||||
for (const seg of segments) {
|
||||
await loadSegment(seg)
|
||||
if (cancelled) {
|
||||
console.log("[useCanvasPlayer] Cancelled during loadSegment")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (isDestroyedRef.current) return
|
||||
// 验证 buffer 是否都已存入
|
||||
const bufferCheck = segments.map((s) => ({
|
||||
assetId: s.assetId,
|
||||
hasBuffer: segmentDataRef.current.has(s.assetId),
|
||||
}))
|
||||
console.log("[useCanvasPlayer] Buffers loaded:", bufferCheck)
|
||||
|
||||
// 2. 解析每个片段的轨道元数据
|
||||
// 2. 解析每个片段的轨道元数据(await 等待 onSamples 回调完成)
|
||||
const metas: SegmentMeta[] = []
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const buffer = segmentDataRef.current.get(segments[i].assetId)
|
||||
if (!buffer) continue
|
||||
const meta = demuxSegment(buffer, i)
|
||||
if (!buffer) {
|
||||
console.warn("[useCanvasPlayer] No buffer for segment", i, segments[i].assetId)
|
||||
continue
|
||||
}
|
||||
const meta = await demuxSegment(buffer, i)
|
||||
if (cancelled) {
|
||||
console.log("[useCanvasPlayer] Cancelled during demuxSegment")
|
||||
return
|
||||
}
|
||||
if (meta) metas.push(meta)
|
||||
}
|
||||
|
||||
if (isDestroyedRef.current || metas.length === 0) {
|
||||
if (cancelled || metas.length === 0) {
|
||||
console.warn("[useCanvasPlayer] Init failed:", { cancelled, metasCount: metas.length })
|
||||
setState((s) => ({ ...s, isBuffering: false }))
|
||||
return
|
||||
}
|
||||
@@ -543,20 +853,36 @@ export function useCanvasPlayer(
|
||||
videoDimRef.current = { width: metas[0].videoWidth, height: metas[0].videoHeight }
|
||||
}
|
||||
|
||||
// 4. 依次解码每个片段
|
||||
for (const meta of metas) {
|
||||
// 4. 按需解码:初始只解码前 3 个片段,后续通过 decodeAroundPosition 动态加载
|
||||
// 避免一次性全量解码导致 frameQueue 环形缓冲区旧帧被丢弃引发黑屏
|
||||
decodedSegmentsRef.current.clear()
|
||||
const initialDecodeCount = Math.min(metas.length, 3)
|
||||
for (let i = 0; i < initialDecodeCount; i++) {
|
||||
if (cancelled) break
|
||||
const meta = metas[i]
|
||||
const buffer = segmentDataRef.current.get(meta.assetId)
|
||||
if (!buffer) continue
|
||||
await decodeSegment(buffer, meta)
|
||||
if (isDestroyedRef.current) break
|
||||
// 先标记为解码中,防止重复解码
|
||||
decodedSegmentsRef.current.add(i)
|
||||
try {
|
||||
await decodeSegment(buffer, meta)
|
||||
} catch (e) {
|
||||
// 解码失败则移除标记,允许后续重试
|
||||
decodedSegmentsRef.current.delete(i)
|
||||
console.warn(`[useCanvasPlayer] 初始化解码片段 ${i} 失败:`, e)
|
||||
}
|
||||
}
|
||||
|
||||
setState((s) => ({ ...s, duration: totalDuration, isReady: true, isBuffering: false }))
|
||||
if (!cancelled) {
|
||||
console.log("[useCanvasPlayer] Init complete, isReady = true")
|
||||
setState((s) => ({ ...s, duration: totalDuration, isReady: true, isBuffering: false }))
|
||||
}
|
||||
}
|
||||
|
||||
init()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
destroy()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import { confirmGeneration, createPreview } from "@/api/generation"
|
||||
import { createGenerationTask } from "@/api/tasks/tasks"
|
||||
import type { UseGenerateVideoProps } from "./generate-video/types"
|
||||
import { getGenerationPhase } from "./generate-video/phase"
|
||||
import { useGenerationPolling } from "./generate-video/useGenerationPolling"
|
||||
@@ -55,7 +55,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
clearTimer()
|
||||
|
||||
try {
|
||||
// 解析分辨率:videoRatio 可能是 "9:16"(宽高比)或 "1080x1920"(分辨率)
|
||||
// 解析分辨率
|
||||
const ratio = props.videoRatio || "9:16"
|
||||
let outputWidth: number
|
||||
let outputHeight: number
|
||||
@@ -87,20 +87,22 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
outputHeight = 1920
|
||||
}
|
||||
|
||||
// 获取或创建后端任务 ID
|
||||
// 预览改为前端播放后,不再有预览任务,需要在此处创建
|
||||
let taskId = props.previewTaskId
|
||||
if (!taskId) {
|
||||
const assetIds =
|
||||
props.materialMode === "auto" ? props.smartSelectedIds : props.selectedMaterials
|
||||
const previewResp = await createPreview({
|
||||
template_id: selectedTemplate,
|
||||
asset_ids: assetIds,
|
||||
duration: props.duration || undefined,
|
||||
video_ratio: props.videoRatio,
|
||||
voice_ids: undefined,
|
||||
title_config: props.titleSettings?.title
|
||||
? {
|
||||
const assetIds =
|
||||
props.materialMode === "auto" ? props.smartSelectedIds : props.selectedMaterials
|
||||
|
||||
// 直接创建正式生成任务
|
||||
await createGenerationTask({
|
||||
template_id: selectedTemplate,
|
||||
asset_ids: assetIds,
|
||||
output_width: outputWidth,
|
||||
output_height: outputHeight,
|
||||
cover_url: props.coverSettings?.upload_url || "",
|
||||
custom_title: props.titleSettings?.title || "",
|
||||
duration: props.duration || undefined,
|
||||
video_ratio: props.videoRatio,
|
||||
...(props.titleSettings?.title
|
||||
? {
|
||||
title_config: {
|
||||
text: props.titleSettings.title,
|
||||
font: props.titleSettings.font,
|
||||
font_size: props.titleSettings.size,
|
||||
@@ -109,17 +111,9 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
bold: props.titleSettings.bold,
|
||||
stroke: props.titleSettings.stroke,
|
||||
shadow: props.titleSettings.shadow,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
taskId = previewResp.task_id
|
||||
}
|
||||
|
||||
await confirmGeneration(taskId, {
|
||||
output_width: outputWidth,
|
||||
output_height: outputHeight,
|
||||
cover_url: props.coverSettings.upload_url || "",
|
||||
custom_title: props.titleSettings.title || "",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
|
||||
startPolling()
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useCallback, useEffect, useState } from "react"
|
||||
import { message } from "antd"
|
||||
import type { CoverConfig, CoverTemplate } from "../types/cover"
|
||||
import { generateCover } from "@/api/generation"
|
||||
import { createPreview, getPreviewStatus } from "@/api/generation/preview"
|
||||
import {
|
||||
fetchCoverTemplates,
|
||||
createCoverTemplate,
|
||||
@@ -111,8 +112,87 @@ export function useStep6Cover({
|
||||
// 提取详细错误信息
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const anyErr = err as any
|
||||
// 如果 API 拦截器已经弹出了后端返回的具体错误信息,这里跳过重复 toast
|
||||
if (anyErr?.__msgShown) {
|
||||
const statusCode = anyErr?.response?.status
|
||||
|
||||
// 400 错误:精确判断是否为"预览缺失",避免误判其他 400 错误
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const errCode = anyErr?.response?.data?.code as string | undefined
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const errMsg = (anyErr?.response?.data?.message ||
|
||||
anyErr?.response?.data?.detail ||
|
||||
"") as string
|
||||
const isPreviewMissing =
|
||||
statusCode === 400 &&
|
||||
(errCode?.includes("PREVIEW") ||
|
||||
/预览.*(?:缺失|不存在|未找到)|(?:missing|not found|does not exist).*preview/i.test(
|
||||
errMsg,
|
||||
))
|
||||
|
||||
if (isPreviewMissing) {
|
||||
console.log("[Step6] 检测到预览缺失,尝试自动创建预览渲染任务...")
|
||||
message.info("正在准备预览视频,请稍候...")
|
||||
try {
|
||||
const previewResp = await createPreview({
|
||||
template_id: selectedTemplate,
|
||||
asset_ids: assetIds,
|
||||
duration: duration || 30,
|
||||
})
|
||||
// 轮询等待预览渲染完成,双重超时保护
|
||||
const maxPolls = 60 // 最多轮询 60 次(每 2 秒,共 120 秒)
|
||||
let pollCount = 0
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
// 总超时保护:120 秒后强制 reject
|
||||
const timeoutId = setTimeout(() => {
|
||||
clearInterval(poll)
|
||||
reject(new Error("预览生成超时,请稍后重试"))
|
||||
}, 120_000)
|
||||
const poll = setInterval(async () => {
|
||||
pollCount++
|
||||
try {
|
||||
const status = await getPreviewStatus(previewResp.task_id)
|
||||
if (status.status === "completed") {
|
||||
clearTimeout(timeoutId)
|
||||
clearInterval(poll)
|
||||
resolve()
|
||||
} else if (status.status === "failed") {
|
||||
clearTimeout(timeoutId)
|
||||
clearInterval(poll)
|
||||
reject(new Error(status.error_message || "预览渲染失败"))
|
||||
}
|
||||
if (pollCount >= maxPolls) {
|
||||
clearTimeout(timeoutId)
|
||||
clearInterval(poll)
|
||||
reject(new Error("预览生成超时,请稍后重试"))
|
||||
}
|
||||
} catch (e) {
|
||||
clearTimeout(timeoutId)
|
||||
clearInterval(poll)
|
||||
reject(e)
|
||||
}
|
||||
}, 2000)
|
||||
})
|
||||
message.success("预览视频就绪,重新生成封面...")
|
||||
// 重试封面生成
|
||||
const retryResp = await generateCover(selectedTemplate, {
|
||||
asset_ids: assetIds,
|
||||
cover_type: "ai_frame",
|
||||
})
|
||||
const retryUrl = retryResp.cover?.image_url || ""
|
||||
if (retryUrl) {
|
||||
onCoverSettingsChange({
|
||||
...coverSettings,
|
||||
thumbnail_url: retryUrl,
|
||||
ai_suggested_time: retryResp.cover?.frame_time ?? null,
|
||||
})
|
||||
message.success("封面生成成功")
|
||||
} else {
|
||||
message.warning("封面生成未返回图片,请重试")
|
||||
}
|
||||
} catch (retryErr) {
|
||||
console.error("[Step6] 自动创建预览后重试失败:", retryErr)
|
||||
message.error("预览视频创建失败,请稍后重试")
|
||||
}
|
||||
} else if (anyErr?.__msgShown) {
|
||||
// 拦截器已处理,不再重复弹出
|
||||
} else {
|
||||
let errorMsg = "封面生成失败"
|
||||
@@ -137,7 +217,7 @@ export function useStep6Cover({
|
||||
clearTimeout(timeoutId)
|
||||
setGenerating(false)
|
||||
}
|
||||
}, [selectedTemplate, assetIds, coverSettings, onCoverSettingsChange, generating])
|
||||
}, [selectedTemplate, assetIds, coverSettings, onCoverSettingsChange, generating, duration])
|
||||
|
||||
// ── 模板操作方法 ──
|
||||
const handleSelectTemplate = useCallback((id: string) => {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 共享:根据素材列表和模板片段计算总视频时长
|
||||
* 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)
|
||||
}
|
||||
Reference in New Issue
Block a user