mp4布局
This commit is contained in:
parent
46bd69cb01
commit
216031f776
@ -21,6 +21,9 @@ const videoFrameStepSeconds = 1 / 24;
|
|||||||
const maxSeekFrameCount = 360;
|
const maxSeekFrameCount = 360;
|
||||||
const videoLoadTimeoutMs = 8000;
|
const videoLoadTimeoutMs = 8000;
|
||||||
const videoSeekTimeoutMs = 5000;
|
const videoSeekTimeoutMs = 5000;
|
||||||
|
const mp4BoxHeaderSize = 8;
|
||||||
|
const mp4LargeBoxHeaderSize = 16;
|
||||||
|
const vapcMarkerBytes = [118, 97, 112, 99];
|
||||||
|
|
||||||
export function isMp4File(file) {
|
export function isMp4File(file) {
|
||||||
if (!file?.name) {
|
if (!file?.name) {
|
||||||
@ -170,6 +173,10 @@ export async function detectMp4AlphaLayout(file) {
|
|||||||
if (!isMp4File(file)) {
|
if (!isMp4File(file)) {
|
||||||
throw new Error("请选择 MP4 动效素材");
|
throw new Error("请选择 MP4 动效素材");
|
||||||
}
|
}
|
||||||
|
const vapcLayout = await detectVapcMp4AlphaLayout(file);
|
||||||
|
if (vapcLayout) {
|
||||||
|
return vapcLayout;
|
||||||
|
}
|
||||||
const frames = await readVideoFrames(file);
|
const frames = await readVideoFrames(file);
|
||||||
if (!frames.length) {
|
if (!frames.length) {
|
||||||
const info = await readMp4VideoInfo(file);
|
const info = await readMp4VideoInfo(file);
|
||||||
@ -183,6 +190,44 @@ export async function detectMp4AlphaLayout(file) {
|
|||||||
return detectMp4AlphaLayoutFromFrames(frames);
|
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) {
|
export async function mp4FileFromSource(source) {
|
||||||
const normalizedSource = String(source || "").trim();
|
const normalizedSource = String(source || "").trim();
|
||||||
if (!isMp4Source(normalizedSource)) {
|
if (!isMp4Source(normalizedSource)) {
|
||||||
@ -353,6 +398,90 @@ function parseFrameText(value) {
|
|||||||
return values.length >= 4 && values.every(Number.isFinite) ? values.slice(0, 4) : null;
|
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) {
|
function filenameFromSource(source) {
|
||||||
try {
|
try {
|
||||||
const url = new URL(source, "https://admin.local");
|
const url = new URL(source, "https://admin.local");
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { afterEach, describe, expect, test, vi } from "vitest";
|
|||||||
import {
|
import {
|
||||||
confirmMp4AlphaLayoutMetadata,
|
confirmMp4AlphaLayoutMetadata,
|
||||||
createMp4AlphaLayout,
|
createMp4AlphaLayout,
|
||||||
|
detectMp4AlphaLayout,
|
||||||
detectMp4AlphaLayoutFromFrames,
|
detectMp4AlphaLayoutFromFrames,
|
||||||
isMp4Source,
|
isMp4Source,
|
||||||
mp4AlphaLayoutFromMetadata,
|
mp4AlphaLayoutFromMetadata,
|
||||||
@ -158,8 +159,154 @@ describe("mp4 alpha layout metadata", () => {
|
|||||||
video_w: 1500,
|
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 }) {
|
function buildSyntheticFrame(width, height, { alphaFrame, rgbFrame }) {
|
||||||
const data = new Uint8ClampedArray(width * height * 4);
|
const data = new Uint8ClampedArray(width * height * 4);
|
||||||
fillRect(data, width, [0, 0, width, height], [32, 32, 32]);
|
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 FileUploadOutlined from "@mui/icons-material/FileUploadOutlined";
|
||||||
import ImageOutlined from "@mui/icons-material/ImageOutlined";
|
import ImageOutlined from "@mui/icons-material/ImageOutlined";
|
||||||
import InsertDriveFileOutlined from "@mui/icons-material/InsertDriveFileOutlined";
|
import InsertDriveFileOutlined from "@mui/icons-material/InsertDriveFileOutlined";
|
||||||
|
import VisibilityOutlined from "@mui/icons-material/VisibilityOutlined";
|
||||||
import CircularProgress from "@mui/material/CircularProgress";
|
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 Tooltip from "@mui/material/Tooltip";
|
||||||
import { useEffect, useId, useRef, useState } from "react";
|
import { useEffect, useId, useRef, useState } from "react";
|
||||||
import { uploadFile, uploadImage } from "@/shared/api/upload";
|
import { uploadFile, uploadImage } from "@/shared/api/upload";
|
||||||
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
import styles from "./UploadField.module.css";
|
import styles from "./UploadField.module.css";
|
||||||
|
|
||||||
@ -33,6 +39,7 @@ export function UploadField({
|
|||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [localPreview, setLocalPreview] = useState(null);
|
const [localPreview, setLocalPreview] = useState(null);
|
||||||
|
const [videoPreviewOpen, setVideoPreviewOpen] = useState(false);
|
||||||
const isImage = kind === "image";
|
const isImage = kind === "image";
|
||||||
const uploadMode = uploadKind || kind;
|
const uploadMode = uploadKind || kind;
|
||||||
const useImageUpload = uploadMode === "image";
|
const useImageUpload = uploadMode === "image";
|
||||||
@ -40,6 +47,7 @@ export function UploadField({
|
|||||||
const assetKind = localPreview?.assetKind || getAssetKind(value, kind);
|
const assetKind = localPreview?.assetKind || getAssetKind(value, kind);
|
||||||
const displayName = localPreview?.name || getDisplayValue(value);
|
const displayName = localPreview?.name || getDisplayValue(value);
|
||||||
const inputAccept = accept !== undefined ? accept : useImageUpload && isImage ? imageAccept : undefined;
|
const inputAccept = accept !== undefined ? accept : useImageUpload && isImage ? imageAccept : undefined;
|
||||||
|
const canPreviewVideo = Boolean(source && assetKind === "mp4");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@ -89,6 +97,7 @@ export function UploadField({
|
|||||||
|
|
||||||
const clearValue = () => {
|
const clearValue = () => {
|
||||||
setLocalPreview(null);
|
setLocalPreview(null);
|
||||||
|
setVideoPreviewOpen(false);
|
||||||
onChange("");
|
onChange("");
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -142,6 +151,21 @@ export function UploadField({
|
|||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</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 ? (
|
{source ? (
|
||||||
<Tooltip arrow title="清除">
|
<Tooltip arrow title="清除">
|
||||||
<span>
|
<span>
|
||||||
@ -160,6 +184,12 @@ export function UploadField({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<VideoPreviewDialog
|
||||||
|
label={label}
|
||||||
|
open={videoPreviewOpen}
|
||||||
|
src={canPreviewVideo ? source : ""}
|
||||||
|
onClose={() => setVideoPreviewOpen(false)}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
accept={inputAccept}
|
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 }) {
|
function AssetPreview({ assetKind, isImage, src }) {
|
||||||
if (!src) {
|
if (!src) {
|
||||||
return (
|
return (
|
||||||
@ -368,6 +416,9 @@ function getAssetKind(value, kind, contentType = "") {
|
|||||||
if (extension === "pag") {
|
if (extension === "pag") {
|
||||||
return "pag";
|
return "pag";
|
||||||
}
|
}
|
||||||
|
if (extension === "mp4") {
|
||||||
|
return "mp4";
|
||||||
|
}
|
||||||
if (isRasterImageExtension(extension)) {
|
if (isRasterImageExtension(extension)) {
|
||||||
return "image";
|
return "image";
|
||||||
}
|
}
|
||||||
@ -378,6 +429,9 @@ function getAssetKind(value, kind, contentType = "") {
|
|||||||
if (normalizedType.includes("pag")) {
|
if (normalizedType.includes("pag")) {
|
||||||
return "pag";
|
return "pag";
|
||||||
}
|
}
|
||||||
|
if (normalizedType === "video/mp4") {
|
||||||
|
return "mp4";
|
||||||
|
}
|
||||||
if (isRasterImageContentType(normalizedType)) {
|
if (isRasterImageContentType(normalizedType)) {
|
||||||
return "image";
|
return "image";
|
||||||
}
|
}
|
||||||
@ -422,5 +476,8 @@ function assetKindLabel(assetKind, isImage) {
|
|||||||
if (assetKind === "pag") {
|
if (assetKind === "pag") {
|
||||||
return "PAG";
|
return "PAG";
|
||||||
}
|
}
|
||||||
|
if (assetKind === "mp4") {
|
||||||
|
return "MP4";
|
||||||
|
}
|
||||||
return isImage ? "图片" : "文件";
|
return isImage ? "图片" : "文件";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -175,6 +175,30 @@
|
|||||||
opacity: 0.48;
|
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 {
|
.compact {
|
||||||
gap: var(--space-1);
|
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 { expect, test, vi } from "vitest";
|
||||||
import { uploadFile, uploadImage } from "@/shared/api/upload";
|
import { uploadFile, uploadImage } from "@/shared/api/upload";
|
||||||
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
|
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(uploadImage).not.toHaveBeenCalled();
|
||||||
expect(onChange).toHaveBeenCalledWith("https://media.haiyihy.com/admin/files/effect.bin");
|
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