Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ee69a51406 | |||
| 355431b867 | |||
| 041cec8670 |
@@ -82,6 +82,8 @@ export interface CreateGenerationTaskRequest {
|
||||
stroke?: boolean
|
||||
shadow?: boolean
|
||||
}
|
||||
/** 来源剪辑计划 ID,后端据此从 edit_plan_clips 读取素材/音频/标题配置 */
|
||||
source_edit_plan_id?: string
|
||||
}
|
||||
|
||||
/** 创建生成任务响应(对齐后端 GenerationTaskResponse) */
|
||||
|
||||
@@ -72,6 +72,7 @@ const GeneratePage: React.FC = () => {
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
editPlanId,
|
||||
sourceEditPlanId,
|
||||
previewVideo,
|
||||
setPreviewVideo,
|
||||
previewModalOpen,
|
||||
@@ -158,6 +159,7 @@ const GeneratePage: React.FC = () => {
|
||||
} = useGenerateVideo({
|
||||
titleSettings,
|
||||
selectedTemplate,
|
||||
sourceEditPlanId,
|
||||
selectedMaterials,
|
||||
materialMode,
|
||||
smartSelectedIds,
|
||||
|
||||
@@ -6,6 +6,8 @@ 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[]
|
||||
|
||||
@@ -10,12 +10,30 @@ 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 开始递归;VisualSampleEntry (avc1/avc3/hvc1/hev1) 前 78 字节
|
||||
* 是固定字段(ISO 14496-12),子 box 从 offset+8+78 开始
|
||||
* - 普通容器 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,
|
||||
@@ -42,21 +60,18 @@ function findCodecConfigRecursive(
|
||||
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 开始递归
|
||||
// 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
|
||||
}
|
||||
// 标准容器 box:从 offset+8 开始递归
|
||||
else if (MP4_CONTAINER_TYPES.includes(type)) {
|
||||
const childResult = findCodecConfigRecursive(buffer, offset + 8, offset + size)
|
||||
if (childResult) return childResult
|
||||
}
|
||||
@@ -468,6 +483,8 @@ export function useCanvasPlayer(
|
||||
})
|
||||
decoderRef.current = decoder
|
||||
decoderReady = true
|
||||
// 标记缓冲结束,让 UI 开始渲染
|
||||
setState((s) => ({ ...s, isBuffering: false }))
|
||||
|
||||
// 更新视频尺寸(用于 aspect ratio)
|
||||
if (meta.videoWidth > 0 && meta.videoHeight > 0) {
|
||||
@@ -482,17 +499,24 @@ 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) continue
|
||||
if (!sample.data || isDestroyedRef.current) {
|
||||
skippedCount++
|
||||
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,
|
||||
@@ -502,14 +526,24 @@ 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 确保所有帧输出
|
||||
// flush 超时保护:10秒
|
||||
try {
|
||||
await decoder.flush()
|
||||
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`)
|
||||
} catch (e) {
|
||||
console.warn("[useCanvasPlayer] Decoder flush error:", e)
|
||||
}
|
||||
|
||||
@@ -82,6 +82,14 @@ 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
|
||||
@@ -93,6 +101,8 @@ 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)
|
||||
@@ -190,6 +200,7 @@ export const useGenerateFormState = (): GenerateFormState => {
|
||||
bgm,
|
||||
editPlanId,
|
||||
planConfigStr,
|
||||
sourceEditPlanId,
|
||||
previewVideo,
|
||||
setPreviewVideo,
|
||||
previewModalOpen,
|
||||
|
||||
@@ -100,6 +100,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
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: {
|
||||
|
||||
Reference in New Issue
Block a user