From 216031f776d4ebcdf43512ff0b4e16688d90f4df Mon Sep 17 00:00:00 2001 From: zhx Date: Sun, 5 Jul 2026 00:48:26 +0800 Subject: [PATCH] =?UTF-8?q?mp4=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/resources/mp4AlphaLayout.js | 129 +++++++++++++++ src/features/resources/mp4AlphaLayout.test.js | 147 ++++++++++++++++++ src/shared/ui/UploadField.jsx | 57 +++++++ src/shared/ui/UploadField.module.css | 24 +++ src/shared/ui/UploadField.test.jsx | 27 +++- 5 files changed, 383 insertions(+), 1 deletion(-) diff --git a/src/features/resources/mp4AlphaLayout.js b/src/features/resources/mp4AlphaLayout.js index fbd87d1..aadd91b 100644 --- a/src/features/resources/mp4AlphaLayout.js +++ b/src/features/resources/mp4AlphaLayout.js @@ -21,6 +21,9 @@ const videoFrameStepSeconds = 1 / 24; const maxSeekFrameCount = 360; const videoLoadTimeoutMs = 8000; const videoSeekTimeoutMs = 5000; +const mp4BoxHeaderSize = 8; +const mp4LargeBoxHeaderSize = 16; +const vapcMarkerBytes = [118, 97, 112, 99]; export function isMp4File(file) { if (!file?.name) { @@ -170,6 +173,10 @@ export async function detectMp4AlphaLayout(file) { if (!isMp4File(file)) { throw new Error("请选择 MP4 动效素材"); } + const vapcLayout = await detectVapcMp4AlphaLayout(file); + if (vapcLayout) { + return vapcLayout; + } const frames = await readVideoFrames(file); if (!frames.length) { const info = await readMp4VideoInfo(file); @@ -183,6 +190,44 @@ export async function detectMp4AlphaLayout(file) { return detectMp4AlphaLayoutFromFrames(frames); } +async function detectVapcMp4AlphaLayout(file) { + const info = await parseVapcInfo(file).catch(() => null); + if (!info || typeof info !== "object") { + return null; + } + return createMp4AlphaLayout({ + alphaLayout: "custom", + alphaFrame: info.aFrame ?? info.a_frame, + confirmed: false, + confidence: 0.99, + rgbFrame: info.rgbFrame ?? info.rgb_frame, + videoH: info.videoH ?? info.video_height, + videoW: info.videoW ?? info.video_width, + }); +} + +async function parseVapcInfo(file) { + if (!file?.arrayBuffer) { + return null; + } + const bytes = new Uint8Array(await file.arrayBuffer()); + const boxPayload = findMp4BoxPayload(bytes, vapcMarkerBytes); + return boxPayload ? parseVapcJsonPayload(boxPayload, 0, boxPayload.length) : null; +} + +function parseVapcJsonPayload(bytes, start, end) { + const jsonStart = findByte(bytes, 123, start, end); + if (jsonStart < 0) { + return null; + } + const jsonEnd = findJsonObjectEnd(bytes, jsonStart, end); + if (jsonEnd <= jsonStart) { + return null; + } + const payload = JSON.parse(new TextDecoder().decode(bytes.slice(jsonStart, jsonEnd + 1))); + return payload?.info || null; +} + export async function mp4FileFromSource(source) { const normalizedSource = String(source || "").trim(); if (!isMp4Source(normalizedSource)) { @@ -353,6 +398,90 @@ function parseFrameText(value) { return values.length >= 4 && values.every(Number.isFinite) ? values.slice(0, 4) : null; } +function findMp4BoxPayload(bytes, boxType) { + let offset = 0; + while (offset + mp4BoxHeaderSize <= bytes.length) { + const size = readUint32(bytes, offset); + const headerSize = size === 1 ? mp4LargeBoxHeaderSize : mp4BoxHeaderSize; + if (offset + headerSize > bytes.length) { + return null; + } + const boxEnd = size === 0 ? bytes.length : offset + (size === 1 ? readUint64(bytes, offset + 8) : size); + if (boxEnd < offset + headerSize || boxEnd > bytes.length) { + return null; + } + if (bytesMatchAt(bytes, boxType, offset + 4)) { + return bytes.slice(offset + headerSize, boxEnd); + } + offset = boxEnd; + } + return null; +} + +function bytesMatchAt(bytes, pattern, offset) { + if (!bytes?.length || !pattern?.length || offset < 0 || offset + pattern.length > bytes.length) { + return false; + } + for (let index = 0; index < pattern.length; index += 1) { + if (bytes[offset + index] !== pattern[index]) { + return false; + } + } + return true; +} + +function readUint32(bytes, offset) { + return bytes[offset] * 2 ** 24 + bytes[offset + 1] * 2 ** 16 + bytes[offset + 2] * 2 ** 8 + bytes[offset + 3]; +} + +function readUint64(bytes, offset) { + const value = readUint32(bytes, offset) * 2 ** 32 + readUint32(bytes, offset + 4); + return Number.isSafeInteger(value) ? value : 0; +} + +function findByte(bytes, target, start, end) { + for (let index = Math.max(0, start); index < Math.min(bytes.length, end); index += 1) { + if (bytes[index] === target) { + return index; + } + } + return -1; +} + +function findJsonObjectEnd(bytes, start, end) { + let depth = 0; + let inString = false; + let escaping = false; + for (let index = start; index < Math.min(bytes.length, end); index += 1) { + const byte = bytes[index]; + if (inString) { + if (escaping) { + escaping = false; + } else if (byte === 92) { + escaping = true; + } else if (byte === 34) { + inString = false; + } + continue; + } + if (byte === 34) { + inString = true; + continue; + } + if (byte === 123) { + depth += 1; + continue; + } + if (byte === 125) { + depth -= 1; + if (depth === 0) { + return index; + } + } + } + return -1; +} + function filenameFromSource(source) { try { const url = new URL(source, "https://admin.local"); diff --git a/src/features/resources/mp4AlphaLayout.test.js b/src/features/resources/mp4AlphaLayout.test.js index a415792..810c8e4 100644 --- a/src/features/resources/mp4AlphaLayout.test.js +++ b/src/features/resources/mp4AlphaLayout.test.js @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test, vi } from "vitest"; import { confirmMp4AlphaLayoutMetadata, createMp4AlphaLayout, + detectMp4AlphaLayout, detectMp4AlphaLayoutFromFrames, isMp4Source, mp4AlphaLayoutFromMetadata, @@ -158,8 +159,154 @@ describe("mp4 alpha layout metadata", () => { video_w: 1500, }); }); + + test("uses VAPC payload info as a custom transparent MP4 layout", async () => { + const createElementSpy = vi.spyOn(document, "createElement").mockImplementation((tagName, options) => { + if (String(tagName).toLowerCase() === "video") { + throw new Error("VAPC payload should be parsed without decoding video frames"); + } + return Document.prototype.createElement.call(document, tagName, options); + }); + const file = buildVapcMp4File({ + info: { + aFrame: [724, 0, 360, 780], + rgbFrame: [0, 0, 720, 1560], + videoH: 1568, + videoW: 1088, + }, + }); + + try { + const layout = await detectMp4AlphaLayout(file); + + expect(layout?.alpha_layout).not.toBe("normal"); + expect(layout).toMatchObject({ + alpha_frame: [724, 0, 360, 780], + alpha_layout: "custom", + rgb_frame: [0, 0, 720, 1560], + video_h: 1568, + video_w: 1088, + }); + } finally { + createElementSpy.mockRestore(); + } + }); + + test("ignores VAPC-looking bytes outside a real MP4 box and falls back to frame detection", async () => { + const restoreMockVideo = mockVideoFrameRead({ height: 30, width: 20 }); + const file = buildMdatWithVapcBytesFile({ + info: { + aFrame: [724, 0, 360, 780], + rgbFrame: [0, 0, 720, 1560], + videoH: 1568, + videoW: 1088, + }, + }); + + try { + const layout = await detectMp4AlphaLayout(file); + + expect(layout).toMatchObject({ + alpha_frame: null, + alpha_layout: "normal", + rgb_frame: [0, 0, 20, 30], + video_h: 30, + video_w: 20, + }); + } finally { + restoreMockVideo(); + } + }); }); +function buildVapcMp4File(payload) { + return new File( + [ + mp4Box("ftyp", new Uint8Array([105, 115, 111, 109, 0, 0, 0, 1, 105, 115, 111, 109])), + mp4Box("vapc", new TextEncoder().encode(JSON.stringify(payload))), + ], + "vapc-layout.mp4", + { type: "video/mp4" }, + ); +} + +function buildMdatWithVapcBytesFile(payload) { + return new File( + [ + mp4Box("ftyp", new Uint8Array([105, 115, 111, 109, 0, 0, 0, 1, 105, 115, 111, 109])), + mp4Box("mdat", new TextEncoder().encode(`frame bytes vapc ${JSON.stringify(payload)}`)), + ], + "ordinary-with-vapc-bytes.mp4", + { type: "video/mp4" }, + ); +} + +function mp4Box(type, payload) { + const bytes = new Uint8Array(8 + payload.length); + const view = new DataView(bytes.buffer); + view.setUint32(0, bytes.length); + for (let index = 0; index < 4; index += 1) { + bytes[index + 4] = type.charCodeAt(index); + } + bytes.set(payload, 8); + return bytes; +} + +function mockVideoFrameRead({ width, height }) { + const originalCreateElement = document.createElement.bind(document); + const createElementSpy = vi.spyOn(document, "createElement").mockImplementation((tagName, options) => { + const normalizedTag = String(tagName).toLowerCase(); + if (normalizedTag === "video") { + return mockVideoElement({ height, width }); + } + if (normalizedTag === "canvas") { + const canvas = originalCreateElement(tagName, options); + canvas.getContext = vi.fn(() => ({ + drawImage: vi.fn(), + getImageData: vi.fn((left, top, canvasWidth, canvasHeight) => ({ + data: new Uint8ClampedArray(canvasWidth * canvasHeight * 4), + height: canvasHeight, + width: canvasWidth, + })), + })); + return canvas; + } + return originalCreateElement(tagName, options); + }); + vi.stubGlobal("URL", { + createObjectURL: vi.fn(() => "blob:mock-video"), + revokeObjectURL: vi.fn(), + }); + return () => { + createElementSpy.mockRestore(); + vi.unstubAllGlobals(); + }; +} + +function mockVideoElement({ width, height }) { + return { + currentTime: 0, + duration: 0, + load: vi.fn(), + muted: false, + onerror: null, + playsInline: false, + preload: "", + readyState: 2, + removeAttribute: vi.fn(), + src: "", + videoHeight: height, + videoWidth: width, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + set onloadedmetadata(handler) { + if (typeof handler === "function") { + queueMicrotask(handler); + } + }, + }; +} + function buildSyntheticFrame(width, height, { alphaFrame, rgbFrame }) { const data = new Uint8ClampedArray(width * height * 4); fillRect(data, width, [0, 0, width, height], [32, 32, 32]); diff --git a/src/shared/ui/UploadField.jsx b/src/shared/ui/UploadField.jsx index 3af7bb5..0decc52 100644 --- a/src/shared/ui/UploadField.jsx +++ b/src/shared/ui/UploadField.jsx @@ -2,10 +2,16 @@ import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined"; import FileUploadOutlined from "@mui/icons-material/FileUploadOutlined"; import ImageOutlined from "@mui/icons-material/ImageOutlined"; import InsertDriveFileOutlined from "@mui/icons-material/InsertDriveFileOutlined"; +import VisibilityOutlined from "@mui/icons-material/VisibilityOutlined"; import CircularProgress from "@mui/material/CircularProgress"; +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogTitle from "@mui/material/DialogTitle"; import Tooltip from "@mui/material/Tooltip"; import { useEffect, useId, useRef, useState } from "react"; import { uploadFile, uploadImage } from "@/shared/api/upload"; +import { Button } from "@/shared/ui/Button.jsx"; import { useToast } from "@/shared/ui/ToastProvider.jsx"; import styles from "./UploadField.module.css"; @@ -33,6 +39,7 @@ export function UploadField({ const { showToast } = useToast(); const [uploading, setUploading] = useState(false); const [localPreview, setLocalPreview] = useState(null); + const [videoPreviewOpen, setVideoPreviewOpen] = useState(false); const isImage = kind === "image"; const uploadMode = uploadKind || kind; const useImageUpload = uploadMode === "image"; @@ -40,6 +47,7 @@ export function UploadField({ const assetKind = localPreview?.assetKind || getAssetKind(value, kind); const displayName = localPreview?.name || getDisplayValue(value); const inputAccept = accept !== undefined ? accept : useImageUpload && isImage ? imageAccept : undefined; + const canPreviewVideo = Boolean(source && assetKind === "mp4"); useEffect(() => { return () => { @@ -89,6 +97,7 @@ export function UploadField({ const clearValue = () => { setLocalPreview(null); + setVideoPreviewOpen(false); onChange(""); }; @@ -142,6 +151,21 @@ export function UploadField({ + {canPreviewVideo ? ( + + + + + + ) : null} {source ? ( @@ -160,6 +184,12 @@ export function UploadField({ + setVideoPreviewOpen(false)} + /> + {label} MP4 + +
+ {src ? ( +
+
+ + + + + ); +} + function AssetPreview({ assetKind, isImage, src }) { if (!src) { return ( @@ -368,6 +416,9 @@ function getAssetKind(value, kind, contentType = "") { if (extension === "pag") { return "pag"; } + if (extension === "mp4") { + return "mp4"; + } if (isRasterImageExtension(extension)) { return "image"; } @@ -378,6 +429,9 @@ function getAssetKind(value, kind, contentType = "") { if (normalizedType.includes("pag")) { return "pag"; } + if (normalizedType === "video/mp4") { + return "mp4"; + } if (isRasterImageContentType(normalizedType)) { return "image"; } @@ -422,5 +476,8 @@ function assetKindLabel(assetKind, isImage) { if (assetKind === "pag") { return "PAG"; } + if (assetKind === "mp4") { + return "MP4"; + } return isImage ? "图片" : "文件"; } diff --git a/src/shared/ui/UploadField.module.css b/src/shared/ui/UploadField.module.css index 60481d9..280f3d8 100644 --- a/src/shared/ui/UploadField.module.css +++ b/src/shared/ui/UploadField.module.css @@ -175,6 +175,30 @@ opacity: 0.48; } +.videoPreviewFrame { + display: flex; + width: 100%; + min-height: 320px; + align-items: center; + justify-content: center; + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: + linear-gradient(45deg, rgba(100, 116, 139, 0.08) 25%, transparent 25%) 0 0 / 16px 16px, + linear-gradient(45deg, transparent 75%, rgba(100, 116, 139, 0.08) 75%) 0 0 / 16px 16px, + linear-gradient(45deg, transparent 75%, rgba(100, 116, 139, 0.08) 75%) 8px 8px / 16px 16px, + linear-gradient(45deg, rgba(100, 116, 139, 0.08) 25%, transparent 25%) 8px 8px / 16px 16px, + var(--bg-card-strong); +} + +.videoPreview { + display: block; + width: 100%; + max-height: min(68vh, 640px); + object-fit: contain; +} + .compact { gap: var(--space-1); } diff --git a/src/shared/ui/UploadField.test.jsx b/src/shared/ui/UploadField.test.jsx index af36bdc..40a8e3e 100644 --- a/src/shared/ui/UploadField.test.jsx +++ b/src/shared/ui/UploadField.test.jsx @@ -1,4 +1,4 @@ -import { fireEvent, render, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { expect, test, vi } from "vitest"; import { uploadFile, uploadImage } from "@/shared/api/upload"; import { ToastProvider } from "@/shared/ui/ToastProvider.jsx"; @@ -32,3 +32,28 @@ test("file upload field does not restrict picker type and uses generic upload en expect(uploadImage).not.toHaveBeenCalled(); expect(onChange).toHaveBeenCalledWith("https://media.haiyihy.com/admin/files/effect.bin"); }); + +test("mp4 file upload field can open video preview dialog", async () => { + const source = "https://media.haiyihy.com/admin/files/gift.mp4?sign=1"; + const onChange = vi.fn(); + const { container } = render( + + + , + ); + + expect(screen.getByText("MP4")).toBeInTheDocument(); + const clearButton = screen.getByRole("button", { name: "清除" }); + + fireEvent.click(screen.getByRole("button", { name: "查看" })); + + expect(screen.getByRole("dialog", { name: "动效素材 MP4" })).toBeInTheDocument(); + expect(container.ownerDocument.querySelector("video")).toHaveAttribute("src", source); + + fireEvent.click(clearButton); + + expect(onChange).toHaveBeenCalledWith(""); + await waitFor(() => { + expect(screen.queryByRole("dialog", { name: "动效素材 MP4" })).not.toBeInTheDocument(); + }); +});