mp4布局

This commit is contained in:
zhx 2026-07-05 00:48:26 +08:00
parent 46bd69cb01
commit 216031f776
5 changed files with 383 additions and 1 deletions

View File

@ -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");

View File

@ -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]);

View File

@ -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({
</button>
</span>
</Tooltip>
{canPreviewVideo ? (
<Tooltip arrow title="查看 MP4">
<span>
<button
className={styles.action}
disabled={disabled || uploading}
type="button"
onClick={() => setVideoPreviewOpen(true)}
>
<VisibilityOutlined fontSize="small" />
查看
</button>
</span>
</Tooltip>
) : null}
{source ? (
<Tooltip arrow title="清除">
<span>
@ -160,6 +184,12 @@ export function UploadField({
</span>
</div>
</div>
<VideoPreviewDialog
label={label}
open={videoPreviewOpen}
src={canPreviewVideo ? source : ""}
onClose={() => setVideoPreviewOpen(false)}
/>
<input
ref={inputRef}
accept={inputAccept}
@ -172,6 +202,24 @@ export function UploadField({
);
}
function VideoPreviewDialog({ label, onClose, open, src }) {
return (
<Dialog fullWidth maxWidth="sm" open={open} onClose={onClose}>
<DialogTitle>{label} MP4</DialogTitle>
<DialogContent>
<div className={styles.videoPreviewFrame}>
{src ? (
<video className={styles.videoPreview} controls playsInline preload="metadata" src={src} />
) : null}
</div>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>关闭</Button>
</DialogActions>
</Dialog>
);
}
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 ? "图片" : "文件";
}

View File

@ -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);
}

View File

@ -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(
<ToastProvider>
<UploadField kind="file" label="动效素材" value={source} onChange={onChange} />
</ToastProvider>,
);
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();
});
});