From 1e2d06ec1b003f19933aaa14c84ce1baa20fb8c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=8F=E6=9D=B0?= Date: Wed, 19 Aug 2026 14:05:17 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E9=A2=84=E8=A7=88=E6=92=AD=E6=94=BE?= =?UTF-8?q?=E5=99=A84=E4=B8=AA=E7=BA=BF=E4=B8=8A=E9=97=AE=E9=A2=98?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 时自动创建后端预览渲染 - 轮询等待预览完成后重试封面生成 --- apps/web/src/pages/generate/GeneratePage.tsx | 1 + apps/web/src/pages/generate/generate.css | 4 +- .../pages/generate/hooks/useCanvasPlayer.ts | 121 ++++++++++++++++-- .../src/pages/generate/hooks/useStep6Cover.ts | 57 ++++++++- 4 files changed, 166 insertions(+), 17 deletions(-) diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index 94d5488c6..4e0165e79 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -287,6 +287,7 @@ const GeneratePage: React.FC = () => { {/* ── 视频预览弹窗 ── */} setPreviewModalOpen(false)} footer={null} diff --git a/apps/web/src/pages/generate/generate.css b/apps/web/src/pages/generate/generate.css index 5ad4f589c..3f26d8a95 100644 --- a/apps/web/src/pages/generate/generate.css +++ b/apps/web/src/pages/generate/generate.css @@ -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; } diff --git a/apps/web/src/pages/generate/hooks/useCanvasPlayer.ts b/apps/web/src/pages/generate/hooks/useCanvasPlayer.ts index ba20f84ed..a95eab845 100644 --- a/apps/web/src/pages/generate/hooks/useCanvasPlayer.ts +++ b/apps/web/src/pages/generate/hooks/useCanvasPlayer.ts @@ -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 diff --git a/apps/web/src/pages/generate/hooks/useStep6Cover.ts b/apps/web/src/pages/generate/hooks/useStep6Cover.ts index 53473da54..4f6d8b2be 100644 --- a/apps/web/src/pages/generate/hooks/useStep6Cover.ts +++ b/apps/web/src/pages/generate/hooks/useStep6Cover.ts @@ -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((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) => { -- 2.54.0