Compare commits

..

1 Commits

Author SHA1 Message Date
张宏杰 5ef5d0d75b fix: setExtractionOptions 改为 nbSamples: Infinity,提取全部样本 2026-08-19 19:53:34 +08:00
7 changed files with 93 additions and 185 deletions
+4
View File
@@ -3,9 +3,13 @@ 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"
+2 -27
View File
@@ -57,33 +57,8 @@ export interface TaskListResponse {
export interface CreateGenerationTaskRequest {
template_id: string
asset_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
}
/** 来源剪辑计划 ID,后端据此从 edit_plan_clips 读取素材/音频/标题配置 */
source_edit_plan_id?: string
title_ids: string[]
voice_ids: string[]
}
/** 创建生成任务响应(对齐后端 GenerationTaskResponse */
+1 -2
View File
@@ -72,7 +72,6 @@ const GeneratePage: React.FC = () => {
autoSubtitles,
bgm,
editPlanId,
sourceEditPlanId,
previewVideo,
setPreviewVideo,
previewModalOpen,
@@ -159,7 +158,6 @@ const GeneratePage: React.FC = () => {
} = useGenerateVideo({
titleSettings,
selectedTemplate,
sourceEditPlanId,
selectedMaterials,
materialMode,
smartSelectedIds,
@@ -173,6 +171,7 @@ const GeneratePage: React.FC = () => {
autoSubtitles,
bgm,
generateCount,
previewTaskId: "",
})
/* ================================================================
@@ -6,8 +6,6 @@ import type { TitleSettings } from "../../types"
export interface UseGenerateVideoProps {
titleSettings: TitleSettings
selectedTemplate: string
/** 来源剪辑计划 ID,仅取 URL edit_plan_id,无则 null 让后端兜底 */
sourceEditPlanId?: string | null
selectedMaterials: string[]
materialMode: "manual" | "auto"
smartSelectedIds: string[]
@@ -21,6 +19,8 @@ export interface UseGenerateVideoProps {
autoSubtitles: boolean
bgm: boolean
generateCount: number
/** 预览任务的 task_id(用于新确认生成 API */
previewTaskId: string
}
/** 生成阶段 */
@@ -10,43 +10,14 @@ import type { Movie, Sample } from "mp4box"
// ── MP4 Box 解析辅助函数 ──
// MP4 标准容器 box 列表(递归时会进入这些 box 内部搜索子 box)
const MP4_CONTAINER_TYPES = [
"moov",
"trak",
"mdia",
"minf",
"stbl",
"stsd",
"dinf",
"edts",
"udta",
"meta",
"tref",
]
const VISUAL_SAMPLE_ENTRY_TYPES = ["avc1", "avc3", "hvc1", "hev1"]
/**
* 递归搜索 box 树,找到 hvcC 或 avcC box 并返回其配置数据(不含 8 字节 box header
*
* MP4 box 嵌套结构:moov → trak → mdia → minf → stbl → stsd → hev1 → hvcC
* - 普通容器 box 从 offset+8 开始递归
* - stsd 有额外 8 字节头(version/flags 4B + entry_count 4B),从 offset+16 开始
* - VisualSampleEntry (avc1/avc3/hvc1/hev1) 前 78 字节是固定字段,子 box 从 offset+8+78 开始
*/
function findCodecConfigRecursive(
buffer: ArrayBuffer,
start: number,
end: number,
): ArrayBuffer | undefined {
/** 在指定范围内查找 avcC / hvcC box,返回其数据 */
function findCodecConfig(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
if (size < 8) break
const type = String.fromCharCode(
view.getUint8(offset + 4),
view.getUint8(offset + 5),
@@ -54,28 +25,37 @@ function findCodecConfigRecursive(
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)
}
// VisualSampleEntry:前 78 字节是固定字段,子 box 在 78 字节之后
if (VISUAL_SAMPLE_ENTRY_TYPES.includes(type)) {
const childResult = findCodecConfigRecursive(buffer, offset + 8 + 78, offset + size)
if (childResult) return childResult
}
// stsd:额外 8 字节头(version/flags 4B + entry_count 4B),子 box 在 offset+16
else if (type === "stsd") {
const childResult = findCodecConfigRecursive(buffer, offset + 8 + 8, offset + size)
if (childResult) return childResult
}
// 标准容器 boxoffset+8 开始递归
else if (MP4_CONTAINER_TYPES.includes(type)) {
const childResult = findCodecConfigRecursive(buffer, offset + 8, offset + size)
if (childResult) return childResult
// 容器 boxfullbox 多 4 字节
const containerBoxes = ["trak", "mdia", "minf", "stbl"]
if (containerBoxes.includes(type)) {
// fullbox: size(4) + type(4) + version(1) + flags(3) = 12 bytes header
const contentStart = offset + 12
const result = findCodecConfig(buffer, contentStart, offset + size)
if (result) return result
} else if (type === "stsd") {
// SampleDescriptionBox 是 fullbox: 8 header + 4 version/flags + 4 entry_count
const entryCount = view.getUint32(offset + 12)
let entryOffset = offset + 16
for (let i = 0; i < entryCount && entryOffset < offset + size; i++) {
const entrySize = view.getUint32(entryOffset)
// 视觉样本条目: 8 header + 6 reserved + 2 data_ref_index + remaining
// 子 box 从 entryOffset + 16 + 62 开始 (skip reserved + data_ref_index + predefined)
// 实际结构: 8(header) + 6(reserved) + 2(data_ref_index) + 16(predefined+reserved) + 2(width) + 2(height) + ...
// box entryOffset + 8 + 6 + 2 + 16 + 2 + 2 + 2 + 2 + 4 + 2 + 2 + 2 + 2 = entryOffset + 78
// 更简单的做法:扫描 entry 内的子 box
const entryEnd = entryOffset + entrySize
const subBoxStart = entryOffset + 8 + 70 // VisualSampleEntry 固定字段共 70 字节
const result = findCodecConfig(buffer, subBoxStart, entryEnd)
if (result) return result
entryOffset += entrySize
}
} else if (type === "avcC" || type === "hvcC") {
// 找到目标 box,返回完整 box(含 header
// 返回完整 box(含 size + type header),WebCodecs HEVC decoder 需要
return buffer.slice(offset, offset + size)
}
if (size === 0) break
offset += size
}
return undefined
@@ -226,6 +206,7 @@ export function useCanvasPlayer(
const videoDimRef = useRef<{ width: number; height: number }>({ width: 0, height: 0 })
const isDestroyedRef = useRef(false)
const lastProgressUpdateRef = useRef<number>(0)
const descriptionCache = useRef<Map<string, ArrayBuffer>>(new Map())
// 计算总时长
const totalDuration = segments.reduce((sum, seg) => sum + (seg.endTime - seg.startTime), 0)
@@ -265,15 +246,7 @@ export function useCanvasPlayer(
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
return findCodecConfig(buffer, offset + 8, offset + size)
}
if (size === 0) break
offset += size
@@ -339,7 +312,15 @@ export function useCanvasPlayer(
}
// 提取编解码器配置数据(HEVC 必需,H.264 也需要)
const description = extractCodecDescription(buffer)
let description = extractCodecDescription(buffer)
// 如果当前分片没有 description,尝试从缓存获取
if (!description) {
for (const cached of descriptionCache.current.values()) {
description = cached
break
}
}
// ✅ 如果 description 缺失,无法解码 HEVC
if (!description) {
@@ -352,6 +333,9 @@ export function useCanvasPlayer(
return
}
// 缓存 description 供后续分片使用
descriptionCache.current.set(segment.assetId, description)
meta = {
assetId: segment.assetId,
videoUrl: segment.videoUrl,
@@ -483,8 +467,6 @@ export function useCanvasPlayer(
})
decoderRef.current = decoder
decoderReady = true
// 标记缓冲结束,让 UI 开始渲染
setState((s) => ({ ...s, isBuffering: false }))
// 更新视频尺寸(用于 aspect ratio
if (meta.videoWidth > 0 && meta.videoHeight > 0) {
@@ -499,24 +481,17 @@ export function useCanvasPlayer(
// 使用 demuxSegment 中已提取并过滤的 samples(前端切片)
const samplesCollected = meta.samples
console.log(
`[useCanvasPlayer] Segment ${meta.assetId}: ${samplesCollected.length} samples to decode`,
)
if (samplesCollected.length === 0) {
console.warn("[useCanvasPlayer] No samples to decode for segment", meta.assetId)
return
}
// 送入解码器
let decodedCount = 0
let skippedCount = 0
for (const sample of samplesCollected) {
if (!sample.data || isDestroyedRef.current) {
skippedCount++
continue
}
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 ?? 0) / (meta.timescale || 90000)) * 1_000_000,
@@ -526,24 +501,14 @@ export function useCanvasPlayer(
try {
decoder.decode(chunk)
decodedCount++
} catch (e) {
console.warn("[useCanvasPlayer] Decode chunk error:", e)
}
}
console.log(
`[useCanvasPlayer] Segment ${meta.assetId}: decoded ${decodedCount}, skipped ${skippedCount}, decoder.state=${decoder.state}`,
)
// flush 超时保护:10秒
// flush 确保所有帧输出
try {
await Promise.race([
decoder.flush(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("flush timeout 10s")), 10_000),
),
])
console.log(`[useCanvasPlayer] Segment ${meta.assetId}: flush complete`)
await decoder.flush()
} catch (e) {
console.warn("[useCanvasPlayer] Decoder flush error:", e)
}
@@ -724,6 +689,7 @@ export function useCanvasPlayer(
frameQueueRef.current.clear()
segmentDataRef.current.clear()
segmentMetaRef.current = []
descriptionCache.current.clear()
}, [])
// ── 预加载下一个片段的数据 ──
@@ -740,16 +706,7 @@ export function useCanvasPlayer(
// ── 初始化:加载并解码所有片段 ──
useEffect(() => {
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)
if (!state.hasSupport || segments.length === 0) return
const init = async () => {
setState((s) => ({ ...s, isBuffering: true }))
@@ -757,37 +714,20 @@ export function useCanvasPlayer(
// 1. 加载所有片段数据
for (const seg of segments) {
await loadSegment(seg)
if (cancelled) {
console.log("[useCanvasPlayer] Cancelled during loadSegment")
return
}
}
// 验证 buffer 是否都已存入
const bufferCheck = segments.map((s) => ({
assetId: s.assetId,
hasBuffer: segmentDataRef.current.has(s.assetId),
}))
console.log("[useCanvasPlayer] Buffers loaded:", bufferCheck)
if (isDestroyedRef.current) return
// 2. 解析每个片段的轨道元数据(await 等待 onSamples 回调完成)
const metas: SegmentMeta[] = []
for (let i = 0; i < segments.length; i++) {
const buffer = segmentDataRef.current.get(segments[i].assetId)
if (!buffer) {
console.warn("[useCanvasPlayer] No buffer for segment", i, segments[i].assetId)
continue
}
if (!buffer) continue
const meta = await demuxSegment(buffer, i)
if (cancelled) {
console.log("[useCanvasPlayer] Cancelled during demuxSegment")
return
}
if (meta) metas.push(meta)
}
if (cancelled || metas.length === 0) {
console.warn("[useCanvasPlayer] Init failed:", { cancelled, metasCount: metas.length })
if (isDestroyedRef.current || metas.length === 0) {
setState((s) => ({ ...s, isBuffering: false }))
return
}
@@ -804,19 +744,15 @@ export function useCanvasPlayer(
const buffer = segmentDataRef.current.get(meta.assetId)
if (!buffer) continue
await decodeSegment(buffer, meta)
if (cancelled) break
if (isDestroyedRef.current) break
}
if (!cancelled) {
console.log("[useCanvasPlayer] Init complete, isReady = true")
setState((s) => ({ ...s, duration: totalDuration, isReady: true, isBuffering: false }))
}
setState((s) => ({ ...s, duration: totalDuration, isReady: true, isBuffering: false }))
}
init()
return () => {
cancelled = true
destroy()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -82,14 +82,6 @@ export interface GenerateFormState {
editPlanId: string | null
planConfigStr: string | null
/**
* 传给后端的 source_edit_plan_id。
* 仅使用 URL 中的 edit_plan_id(从剪辑模板编辑器跳转时携带)。
* URL 没有时传 null,后端正式生成 API 会通过 template_id+user_id 兜底查找正确的 plan。
* 注意:selectedTemplate 是模板 ID,不是 edit_plan_id,不能作为此值传递。
*/
sourceEditPlanId: string | null
/* 预览弹窗 */
previewVideo: GeneratedVideo | null
setPreviewVideo: (v: GeneratedVideo | null) => void
@@ -101,8 +93,6 @@ export const useGenerateFormState = (): GenerateFormState => {
const [searchParams] = useSearchParams()
const editPlanId = searchParams.get("edit_plan_id")
const planConfigStr = searchParams.get("plan_config")
/* ── source_edit_plan_id:仅取 URL 参数,无则 null 让后端兜底 ── */
const sourceEditPlanId = editPlanId || null
/* ── 步骤状态 ── */
const [currentStep, setCurrentStep] = useState(1)
@@ -200,7 +190,6 @@ export const useGenerateFormState = (): GenerateFormState => {
bgm,
editPlanId,
planConfigStr,
sourceEditPlanId,
previewVideo,
setPreviewVideo,
previewModalOpen,
@@ -5,7 +5,7 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import type { GeneratedVideo } from "@/api/template-editor"
import { createGenerationTask } from "@/api/tasks/tasks"
import { confirmGeneration, createPreview } from "@/api/generation"
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,23 +87,20 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
outputHeight = 1920
}
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.sourceEditPlanId ? { source_edit_plan_id: props.sourceEditPlanId } : {}),
...(props.titleSettings?.title
? {
title_config: {
// 获取或创建后端任务 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
? {
text: props.titleSettings.title,
font: props.titleSettings.font,
font_size: props.titleSettings.size,
@@ -112,9 +109,17 @@ 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()