mp4布局
This commit is contained in:
parent
46bd69cb01
commit
216031f776
@ -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");
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -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 ? "图片" : "文件";
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user