Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e2d06ec1b |
@@ -287,6 +287,7 @@ const GeneratePage: React.FC = () => {
|
||||
|
||||
{/* ── 视频预览弹窗 ── */}
|
||||
<Modal
|
||||
className="xx-preview-modal"
|
||||
open={previewModalOpen}
|
||||
onCancel={() => setPreviewModalOpen(false)}
|
||||
footer={null}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,60 @@ import { useRef, useCallback, useEffect, useState } from "react"
|
||||
import { createFile } from "mp4box"
|
||||
import type { Movie, Sample } from "mp4box"
|
||||
|
||||
// ── MP4 Box 解析辅助函数 ──
|
||||
|
||||
/** 在指定范围内查找 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) break
|
||||
const type = String.fromCharCode(
|
||||
view.getUint8(offset + 4),
|
||||
view.getUint8(offset + 5),
|
||||
view.getUint8(offset + 6),
|
||||
view.getUint8(offset + 7),
|
||||
)
|
||||
|
||||
// 容器 box(fullbox 多 4 字节)
|
||||
const containerBoxes = ["trak", "mdia", "minf", "stbl"]
|
||||
if (containerBoxes.includes(type)) {
|
||||
// stbl 是 fullbox,跳过 4 字节 version/flags
|
||||
const contentStart = offset + 8
|
||||
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 // 跳过 header(8) + visual sample entry fixed fields(70)
|
||||
const result = findCodecConfig(buffer, subBoxStart, entryEnd)
|
||||
if (result) return result
|
||||
entryOffset += entrySize
|
||||
}
|
||||
} else if (type === "avcC" || type === "hvcC") {
|
||||
// 找到目标 box,返回其内容(不含 box header)
|
||||
const dataStart = offset + 8
|
||||
const dataSize = size - 8
|
||||
return buffer.slice(dataStart, dataStart + dataSize)
|
||||
}
|
||||
|
||||
if (size === 0) break
|
||||
offset += size
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// ── 帧队列(环形缓冲区) ──
|
||||
interface FrameEntry {
|
||||
frame: VideoFrame
|
||||
@@ -81,6 +135,8 @@ interface SegmentMeta {
|
||||
videoWidth: number
|
||||
/** 视频高度(像素) */
|
||||
videoHeight: number
|
||||
/** 解码器配置数据(HEVC hvcC / H.264 avcC),WebCodecs 必需 */
|
||||
description?: ArrayBuffer
|
||||
}
|
||||
|
||||
// ── 播放器状态 ──
|
||||
@@ -171,6 +227,34 @@ 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") {
|
||||
return findCodecConfig(buffer, offset + 8, offset + size)
|
||||
}
|
||||
if (size === 0) break
|
||||
offset += size
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[useCanvasPlayer] extractCodecDescription failed:", e)
|
||||
}
|
||||
return undefined
|
||||
}, [])
|
||||
|
||||
// ── 解封装单个片段,提取轨道元数据 ──
|
||||
const demuxSegment = useCallback(
|
||||
(buffer: ArrayBuffer, segIndex: number): SegmentMeta | null => {
|
||||
@@ -193,6 +277,9 @@ export function useCanvasPlayer(
|
||||
return
|
||||
}
|
||||
|
||||
// 提取编解码器配置数据(HEVC 必需,H.264 也需要)
|
||||
const description = extractCodecDescription(buffer)
|
||||
|
||||
meta = {
|
||||
assetId: segment.assetId,
|
||||
videoUrl: segment.videoUrl,
|
||||
@@ -203,6 +290,7 @@ export function useCanvasPlayer(
|
||||
codec: videoTrack.codec,
|
||||
videoWidth: videoTrack.track_width || 1280,
|
||||
videoHeight: videoTrack.track_height || 720,
|
||||
description,
|
||||
}
|
||||
|
||||
// 提取所有 samples(start() 会同步触发 onSamples)
|
||||
@@ -224,7 +312,7 @@ export function useCanvasPlayer(
|
||||
|
||||
return meta
|
||||
},
|
||||
[segments],
|
||||
[segments, extractCodecDescription],
|
||||
)
|
||||
|
||||
// ── 初始化 VideoDecoder 并解码指定片段 ──
|
||||
@@ -256,6 +344,7 @@ export function useCanvasPlayer(
|
||||
codec: meta.codec,
|
||||
codedWidth: meta.videoWidth,
|
||||
codedHeight: meta.videoHeight,
|
||||
...(meta.description ? { description: meta.description } : {}),
|
||||
})
|
||||
decoderRef.current = decoder
|
||||
decoderReady = true
|
||||
@@ -341,17 +430,23 @@ export function useCanvasPlayer(
|
||||
ctx.fillStyle = title.color
|
||||
ctx.textAlign = "center"
|
||||
|
||||
let y: number
|
||||
// 按 "/" 分割为多行("/" 作为手动换行符)
|
||||
const lines = title.text.split("/")
|
||||
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 +457,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
|
||||
|
||||
@@ -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,58 @@ 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 错误:后端缺少预览视频,自动创建后重试
|
||||
if (statusCode === 400) {
|
||||
console.log("[Step6] 后端返回 400,尝试自动创建预览渲染任务...")
|
||||
message.info("正在准备预览视频,请稍候...")
|
||||
try {
|
||||
const previewResp = await createPreview({
|
||||
template_id: selectedTemplate,
|
||||
asset_ids: assetIds,
|
||||
duration: duration || 30,
|
||||
})
|
||||
// 轮询等待预览渲染完成
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const poll = setInterval(async () => {
|
||||
try {
|
||||
const status = await getPreviewStatus(previewResp.task_id)
|
||||
if (status.status === "completed") {
|
||||
clearInterval(poll)
|
||||
resolve()
|
||||
} else if (status.status === "failed") {
|
||||
clearInterval(poll)
|
||||
reject(new Error(status.error_message || "预览渲染失败"))
|
||||
}
|
||||
} catch (e) {
|
||||
clearInterval(poll)
|
||||
reject(e)
|
||||
}
|
||||
}, 3000)
|
||||
})
|
||||
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 +188,7 @@ export function useStep6Cover({
|
||||
clearTimeout(timeoutId)
|
||||
setGenerating(false)
|
||||
}
|
||||
}, [selectedTemplate, assetIds, coverSettings, onCoverSettingsChange, generating])
|
||||
}, [selectedTemplate, assetIds, coverSettings, onCoverSettingsChange, generating, duration])
|
||||
|
||||
// ── 模板操作方法 ──
|
||||
const handleSelectTemplate = useCallback((id: string) => {
|
||||
|
||||
Reference in New Issue
Block a user