Compare commits

...

1 Commits

Author SHA1 Message Date
张宏杰 1e2d06ec1b fix: 预览播放器4个线上问题修复
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 48s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 48s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m43s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m46s
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m54s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 2m1s
AI Code Review / AI Code Review (pull_request) Failing after 2m24s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m58s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m56s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m20s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 7m45s
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 10s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 35s
Bug 1: 弹窗顶部被裁剪
- generate.css: .ant-modal-content/.ant-modal-close 规则限定到 .xx-preview-modal
- GeneratePage.tsx: 预览弹窗 Modal 添加 className='xx-preview-modal'

Bug 2: Canvas 黑屏(HEVC 编码不支持)
- useCanvasPlayer.ts: 新增 findCodecConfig 模块级函数解析 MP4 buffer
- 提取 avcC/hvcC box 数据作为 VideoDecoder description
- SegmentMeta 新增 description 字段,decoder.configure 传入

Bug 3: 标题 / 手动换行
- useCanvasPlayer.ts: drawTitle 按 / 分割多行绘制
- lineHeight = fontSize * 1.3,根据 position 居中/对齐

Bug 4: 封面生成 400 错误
- useStep6Cover.ts: 封面 API 返回 400 时自动创建后端预览渲染
- 轮询等待预览完成后重试封面生成
2026-08-19 14:05:17 +08:00
4 changed files with 166 additions and 17 deletions
@@ -287,6 +287,7 @@ const GeneratePage: React.FC = () => {
{/* ── 视频预览弹窗 ── */}
<Modal
className="xx-preview-modal"
open={previewModalOpen}
onCancel={() => setPreviewModalOpen(false)}
footer={null}
+2 -2
View File
@@ -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),
)
// 容器 boxfullbox 多 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,
}
// 提取所有 samplesstart() 会同步触发 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) => {