602 lines
20 KiB
JavaScript
602 lines
20 KiB
JavaScript
export const mp4AlphaLayoutKey = "mp4_alpha_layout";
|
|
export const mp4AlphaDetectVersion = 1;
|
|
|
|
export const mp4AlphaLayoutOptions = [
|
|
["normal", "普通 MP4"],
|
|
["alpha_left_rgb_right", "左 Alpha / 右 RGB"],
|
|
["rgb_left_alpha_right", "左 RGB / 右 Alpha"],
|
|
["alpha_top_rgb_bottom", "上 Alpha / 下 RGB"],
|
|
["rgb_top_alpha_bottom", "上 RGB / 下 Alpha"],
|
|
["custom", "自定义"],
|
|
];
|
|
|
|
const transparentLayoutKinds = new Set([
|
|
"alpha_left_rgb_right",
|
|
"rgb_left_alpha_right",
|
|
"alpha_top_rgb_bottom",
|
|
"rgb_top_alpha_bottom",
|
|
"custom",
|
|
]);
|
|
const videoFrameStepSeconds = 1 / 24;
|
|
const maxSeekFrameCount = 360;
|
|
const videoLoadTimeoutMs = 8000;
|
|
const videoSeekTimeoutMs = 5000;
|
|
|
|
export function isMp4File(file) {
|
|
if (!file?.name) {
|
|
return false;
|
|
}
|
|
const filename = String(file.name).toLowerCase();
|
|
const type = String(file.type || "").toLowerCase();
|
|
return filename.endsWith(".mp4") || type === "video/mp4";
|
|
}
|
|
|
|
export function isMp4Source(source = "") {
|
|
const normalized = String(source || "").trim();
|
|
if (!normalized) {
|
|
return false;
|
|
}
|
|
try {
|
|
const url = new URL(normalized, "https://admin.local");
|
|
return url.pathname.toLowerCase().endsWith(".mp4");
|
|
} catch {
|
|
return normalized.toLowerCase().split("?")[0].endsWith(".mp4");
|
|
}
|
|
}
|
|
|
|
export function createMp4AlphaLayout({
|
|
alphaLayout = "normal",
|
|
videoW,
|
|
videoH,
|
|
rgbFrame,
|
|
alphaFrame = null,
|
|
confirmed = false,
|
|
confidence = 0,
|
|
detectVersion = mp4AlphaDetectVersion,
|
|
} = {}) {
|
|
const videoWidth = positiveInteger(videoW);
|
|
const videoHeight = positiveInteger(videoH);
|
|
if (videoWidth <= 0 || videoHeight <= 0) {
|
|
return null;
|
|
}
|
|
const layoutKind = normalizeAlphaLayout(alphaLayout);
|
|
const defaultFrames = framePairForLayout(layoutKind, videoWidth, videoHeight);
|
|
const normalizedRgbFrame = sanitizeFrame(rgbFrame, videoWidth, videoHeight) || defaultFrames.rgbFrame;
|
|
const normalizedAlphaFrame =
|
|
layoutKind === "normal" ? null : sanitizeFrame(alphaFrame, videoWidth, videoHeight) || defaultFrames.alphaFrame;
|
|
|
|
if (!normalizedRgbFrame || (layoutKind !== "normal" && !normalizedAlphaFrame)) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
alpha_layout: layoutKind,
|
|
video_w: videoWidth,
|
|
video_h: videoHeight,
|
|
rgb_frame: normalizedRgbFrame,
|
|
alpha_frame: normalizedAlphaFrame,
|
|
confirmed: Boolean(confirmed),
|
|
confidence: roundConfidence(confidence),
|
|
detect_version: positiveInteger(detectVersion) || mp4AlphaDetectVersion,
|
|
};
|
|
}
|
|
|
|
export function mp4AlphaLayoutFromMetadata(metadataJson) {
|
|
const metadata = parseMetadataObject(metadataJson);
|
|
return sanitizeMp4AlphaLayout(metadata?.[mp4AlphaLayoutKey]);
|
|
}
|
|
|
|
export function mergeMp4AlphaLayoutMetadata(metadataJson, layout) {
|
|
const sanitizedLayout = sanitizeMp4AlphaLayout(layout);
|
|
if (!sanitizedLayout) {
|
|
return normalizeMetadataJSON(metadataJson);
|
|
}
|
|
return stringifyMetadata({
|
|
...parseMetadataObject(metadataJson),
|
|
animation_format: "mp4",
|
|
[mp4AlphaLayoutKey]: sanitizedLayout,
|
|
});
|
|
}
|
|
|
|
export function removeMp4AlphaLayoutMetadata(metadataJson) {
|
|
const metadata = parseMetadataObject(metadataJson);
|
|
delete metadata[mp4AlphaLayoutKey];
|
|
if (metadata.animation_format === "mp4") {
|
|
delete metadata.animation_format;
|
|
}
|
|
return stringifyMetadata(metadata);
|
|
}
|
|
|
|
export function confirmMp4AlphaLayoutMetadata(metadataJson) {
|
|
const layout = mp4AlphaLayoutFromMetadata(metadataJson);
|
|
if (!layout) {
|
|
return normalizeMetadataJSON(metadataJson);
|
|
}
|
|
return mergeMp4AlphaLayoutMetadata(metadataJson, { ...layout, confirmed: true });
|
|
}
|
|
|
|
export function updateMp4AlphaLayoutKind(metadataJson, alphaLayout) {
|
|
const layout = mp4AlphaLayoutFromMetadata(metadataJson);
|
|
if (!layout) {
|
|
return normalizeMetadataJSON(metadataJson);
|
|
}
|
|
const nextLayout = createMp4AlphaLayout({
|
|
alphaLayout,
|
|
alphaFrame: layout.alpha_frame,
|
|
confirmed: false,
|
|
confidence: layout.confidence,
|
|
detectVersion: layout.detect_version,
|
|
rgbFrame: layout.rgb_frame,
|
|
videoH: layout.video_h,
|
|
videoW: layout.video_w,
|
|
});
|
|
return nextLayout ? mergeMp4AlphaLayoutMetadata(metadataJson, nextLayout) : normalizeMetadataJSON(metadataJson);
|
|
}
|
|
|
|
export function updateMp4AlphaLayoutFrame(metadataJson, key, value) {
|
|
const layout = mp4AlphaLayoutFromMetadata(metadataJson);
|
|
if (!layout) {
|
|
return normalizeMetadataJSON(metadataJson);
|
|
}
|
|
const frame = parseFrameText(value);
|
|
const nextLayout = createMp4AlphaLayout({
|
|
alphaLayout: layout.alpha_layout,
|
|
rgbFrame: key === "rgb_frame" ? frame : layout.rgb_frame,
|
|
alphaFrame: key === "alpha_frame" ? frame : layout.alpha_frame,
|
|
confirmed: false,
|
|
confidence: layout.confidence,
|
|
detectVersion: layout.detect_version,
|
|
videoH: layout.video_h,
|
|
videoW: layout.video_w,
|
|
});
|
|
return nextLayout ? mergeMp4AlphaLayoutMetadata(metadataJson, nextLayout) : normalizeMetadataJSON(metadataJson);
|
|
}
|
|
|
|
export function hasUnconfirmedMp4Layout(metadataJson) {
|
|
const layout = mp4AlphaLayoutFromMetadata(metadataJson);
|
|
return Boolean(layout && !layout.confirmed);
|
|
}
|
|
|
|
export function mp4LayoutLabel(layout) {
|
|
const kind = typeof layout === "string" ? layout : layout?.alpha_layout;
|
|
return mp4AlphaLayoutOptions.find(([value]) => value === kind)?.[1] || "未解析";
|
|
}
|
|
|
|
export function frameText(frame) {
|
|
return Array.isArray(frame) ? frame.join(",") : "";
|
|
}
|
|
|
|
export async function detectMp4AlphaLayout(file) {
|
|
if (!isMp4File(file)) {
|
|
throw new Error("请选择 MP4 动效素材");
|
|
}
|
|
const frames = await readVideoFrames(file);
|
|
if (!frames.length) {
|
|
const info = await readMp4VideoInfo(file);
|
|
return createMp4AlphaLayout({
|
|
alphaLayout: "normal",
|
|
videoW: info.width,
|
|
videoH: info.height,
|
|
confirmed: false,
|
|
});
|
|
}
|
|
return detectMp4AlphaLayoutFromFrames(frames);
|
|
}
|
|
|
|
export async function mp4FileFromSource(source) {
|
|
const normalizedSource = String(source || "").trim();
|
|
if (!isMp4Source(normalizedSource)) {
|
|
throw new Error("动态资源不是 MP4");
|
|
}
|
|
let response;
|
|
try {
|
|
response = await fetch(normalizedSource);
|
|
} catch {
|
|
throw new Error("MP4 素材下载失败,请确认素材 URL 可访问");
|
|
}
|
|
if (!response.ok) {
|
|
throw new Error("MP4 素材下载失败,请确认素材 URL 可访问");
|
|
}
|
|
const blob = await response.blob();
|
|
return new File([blob], filenameFromSource(normalizedSource), { type: blob.type || "video/mp4" });
|
|
}
|
|
|
|
export async function readMp4VideoInfo(file) {
|
|
const video = await loadVideo(file);
|
|
try {
|
|
return {
|
|
duration: Number.isFinite(video.duration) ? video.duration : 0,
|
|
height: video.videoHeight || 0,
|
|
width: video.videoWidth || 0,
|
|
};
|
|
} finally {
|
|
releaseVideo(video);
|
|
}
|
|
}
|
|
|
|
export function detectMp4AlphaLayoutFromFrames(frames) {
|
|
const validFrames = (frames || []).filter((frame) => frame?.data && frame.width > 0 && frame.height > 0);
|
|
if (!validFrames.length) {
|
|
return null;
|
|
}
|
|
const { width, height } = validFrames[0];
|
|
const candidates = candidateLayoutsForVideo(width, height).map((alphaLayout) => {
|
|
const pair = framePairForLayout(alphaLayout, width, height);
|
|
return {
|
|
alphaLayout,
|
|
...pair,
|
|
score: scoreCandidate(validFrames, pair),
|
|
};
|
|
});
|
|
candidates.sort((left, right) => right.score - left.score);
|
|
const selected = candidates[0];
|
|
if (!selected || selected.score < 0.42) {
|
|
return createMp4AlphaLayout({
|
|
alphaLayout: "normal",
|
|
videoW: width,
|
|
videoH: height,
|
|
confirmed: false,
|
|
confidence: selected?.score || 0,
|
|
});
|
|
}
|
|
return createMp4AlphaLayout({
|
|
alphaLayout: selected.alphaLayout,
|
|
videoW: width,
|
|
videoH: height,
|
|
rgbFrame: selected.rgbFrame,
|
|
alphaFrame: selected.alphaFrame,
|
|
confirmed: false,
|
|
confidence: selected.score,
|
|
});
|
|
}
|
|
|
|
function candidateLayoutsForVideo(width, height) {
|
|
const aspectRatio = width / height;
|
|
const layouts = [];
|
|
if (aspectRatio >= 0.85) {
|
|
layouts.push("alpha_left_rgb_right", "rgb_left_alpha_right");
|
|
}
|
|
if (aspectRatio <= 0.52) {
|
|
layouts.push("alpha_top_rgb_bottom", "rgb_top_alpha_bottom");
|
|
}
|
|
return layouts;
|
|
}
|
|
|
|
export function sanitizeMp4AlphaLayout(layout) {
|
|
if (!layout || typeof layout !== "object") {
|
|
return null;
|
|
}
|
|
return createMp4AlphaLayout({
|
|
alphaLayout: layout.alpha_layout ?? layout.alphaLayout,
|
|
videoW: layout.video_w ?? layout.videoW,
|
|
videoH: layout.video_h ?? layout.videoH,
|
|
rgbFrame: layout.rgb_frame ?? layout.rgbFrame,
|
|
alphaFrame: layout.alpha_frame ?? layout.alphaFrame,
|
|
confirmed: layout.confirmed,
|
|
confidence: layout.confidence,
|
|
detectVersion: layout.detect_version ?? layout.detectVersion,
|
|
});
|
|
}
|
|
|
|
function normalizeAlphaLayout(value) {
|
|
const normalized = String(value || "")
|
|
.trim()
|
|
.toLowerCase();
|
|
if (normalized === "normal" || transparentLayoutKinds.has(normalized)) {
|
|
return normalized;
|
|
}
|
|
return "normal";
|
|
}
|
|
|
|
function framePairForLayout(alphaLayout, width, height) {
|
|
if (alphaLayout === "alpha_left_rgb_right") {
|
|
const half = Math.floor(width / 2);
|
|
return {
|
|
alphaFrame: [0, 0, half, height],
|
|
rgbFrame: [half, 0, width - half, height],
|
|
};
|
|
}
|
|
if (alphaLayout === "rgb_left_alpha_right") {
|
|
const half = Math.floor(width / 2);
|
|
return {
|
|
alphaFrame: [half, 0, width - half, height],
|
|
rgbFrame: [0, 0, half, height],
|
|
};
|
|
}
|
|
if (alphaLayout === "alpha_top_rgb_bottom") {
|
|
const half = Math.floor(height / 2);
|
|
return {
|
|
alphaFrame: [0, 0, width, half],
|
|
rgbFrame: [0, half, width, height - half],
|
|
};
|
|
}
|
|
if (alphaLayout === "rgb_top_alpha_bottom") {
|
|
const half = Math.floor(height / 2);
|
|
return {
|
|
alphaFrame: [0, half, width, height - half],
|
|
rgbFrame: [0, 0, width, half],
|
|
};
|
|
}
|
|
return {
|
|
alphaFrame: null,
|
|
rgbFrame: [0, 0, width, height],
|
|
};
|
|
}
|
|
|
|
function sanitizeFrame(value, videoWidth, videoHeight) {
|
|
const values = Array.isArray(value) ? value : parseFrameText(value);
|
|
if (!Array.isArray(values) || values.length < 4) {
|
|
return null;
|
|
}
|
|
const frame = values.slice(0, 4).map(positiveIntegerOrZero);
|
|
if (frame[0] < 0 || frame[1] < 0) {
|
|
return null;
|
|
}
|
|
if (frame[2] <= 0 || frame[3] <= 0) {
|
|
return null;
|
|
}
|
|
if (frame[0] + frame[2] > videoWidth || frame[1] + frame[3] > videoHeight) {
|
|
return null;
|
|
}
|
|
return frame;
|
|
}
|
|
|
|
function parseFrameText(value) {
|
|
if (Array.isArray(value)) {
|
|
return value;
|
|
}
|
|
const text = String(value || "").trim();
|
|
if (!text) {
|
|
return null;
|
|
}
|
|
const values = text.split(",").map((item) => Number(item.trim()));
|
|
return values.length >= 4 && values.every(Number.isFinite) ? values.slice(0, 4) : null;
|
|
}
|
|
|
|
function filenameFromSource(source) {
|
|
try {
|
|
const url = new URL(source, "https://admin.local");
|
|
const pathname = decodeURIComponent(url.pathname || "");
|
|
return pathname.split("/").filter(Boolean).pop() || "animation.mp4";
|
|
} catch {
|
|
const clean = source.split("?")[0].split("#")[0];
|
|
return decodeURIComponent(clean.split("/").filter(Boolean).pop() || "animation.mp4");
|
|
}
|
|
}
|
|
|
|
function parseMetadataObject(metadataJson) {
|
|
const normalized = String(metadataJson || "").trim();
|
|
if (!normalized) {
|
|
return {};
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(normalized);
|
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function normalizeMetadataJSON(metadataJson) {
|
|
return stringifyMetadata(parseMetadataObject(metadataJson));
|
|
}
|
|
|
|
function stringifyMetadata(metadata) {
|
|
const clean = {};
|
|
Object.entries(metadata || {}).forEach(([key, value]) => {
|
|
if (value !== undefined && value !== null && value !== "") {
|
|
clean[key] = value;
|
|
}
|
|
});
|
|
return Object.keys(clean).length ? JSON.stringify(clean) : "{}";
|
|
}
|
|
|
|
function positiveInteger(value) {
|
|
const number = Number(value);
|
|
return Number.isInteger(number) && number > 0 ? number : 0;
|
|
}
|
|
|
|
function positiveIntegerOrZero(value) {
|
|
const number = Number(value);
|
|
return Number.isInteger(number) && number >= 0 ? number : -1;
|
|
}
|
|
|
|
function roundConfidence(value) {
|
|
const number = Number(value);
|
|
if (!Number.isFinite(number) || number <= 0) {
|
|
return 0;
|
|
}
|
|
return Math.round(Math.min(number, 1) * 1000) / 1000;
|
|
}
|
|
|
|
async function readVideoFrames(file) {
|
|
const video = await loadVideo(file);
|
|
try {
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = video.videoWidth;
|
|
canvas.height = video.videoHeight;
|
|
const context = canvas.getContext("2d", { willReadFrequently: true });
|
|
if (!context || canvas.width <= 0 || canvas.height <= 0) {
|
|
throw new Error("MP4 画面无法读取");
|
|
}
|
|
await waitForVideoCurrentData(video);
|
|
const duration = Number.isFinite(video.duration) && video.duration > 0 ? video.duration : 0;
|
|
const frameCount = Math.min(Math.max(Math.ceil(duration / videoFrameStepSeconds), 1), maxSeekFrameCount);
|
|
const frames = [];
|
|
for (let index = 0; index < frameCount; index += 1) {
|
|
const time = duration > 0 ? Math.min(duration - 0.001, index * videoFrameStepSeconds) : 0;
|
|
await seekVideo(video, Math.max(0, time));
|
|
context.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
frames.push({
|
|
data: context.getImageData(0, 0, canvas.width, canvas.height).data,
|
|
height: canvas.height,
|
|
width: canvas.width,
|
|
});
|
|
}
|
|
return frames;
|
|
} finally {
|
|
releaseVideo(video);
|
|
}
|
|
}
|
|
|
|
function loadVideo(file) {
|
|
return new Promise((resolve, reject) => {
|
|
const video = document.createElement("video");
|
|
let settled = false;
|
|
let timeoutId = null;
|
|
const finish = (callback, value) => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
window.clearTimeout(timeoutId);
|
|
video.onloadedmetadata = null;
|
|
video.onerror = null;
|
|
callback(value);
|
|
};
|
|
video.muted = true;
|
|
video.playsInline = true;
|
|
video.preload = "auto";
|
|
video.src = URL.createObjectURL(file);
|
|
timeoutId = window.setTimeout(() => {
|
|
finish(reject, new Error("MP4 元数据读取超时"));
|
|
releaseVideo(video);
|
|
}, videoLoadTimeoutMs);
|
|
video.onloadedmetadata = () => finish(resolve, video);
|
|
video.onerror = () => {
|
|
finish(reject, new Error("MP4 元数据读取失败"));
|
|
releaseVideo(video);
|
|
};
|
|
});
|
|
}
|
|
|
|
function seekVideo(video, time) {
|
|
return new Promise((resolve, reject) => {
|
|
if (Math.abs((video.currentTime || 0) - time) < 0.001 && video.readyState >= 2) {
|
|
resolve();
|
|
return;
|
|
}
|
|
let timeoutId = null;
|
|
const done = () => {
|
|
window.clearTimeout(timeoutId);
|
|
video.removeEventListener("seeked", done);
|
|
video.removeEventListener("error", fail);
|
|
resolve();
|
|
};
|
|
const fail = () => {
|
|
window.clearTimeout(timeoutId);
|
|
video.removeEventListener("seeked", done);
|
|
video.removeEventListener("error", fail);
|
|
reject(new Error("MP4 画面解析失败"));
|
|
};
|
|
timeoutId = window.setTimeout(() => {
|
|
video.removeEventListener("seeked", done);
|
|
video.removeEventListener("error", fail);
|
|
reject(new Error("MP4 画面解析超时"));
|
|
}, videoSeekTimeoutMs);
|
|
video.addEventListener("seeked", done, { once: true });
|
|
video.addEventListener("error", fail, { once: true });
|
|
try {
|
|
video.currentTime = time;
|
|
} catch {
|
|
fail();
|
|
}
|
|
});
|
|
}
|
|
|
|
function waitForVideoCurrentData(video) {
|
|
return new Promise((resolve, reject) => {
|
|
if (video.readyState >= 2) {
|
|
resolve();
|
|
return;
|
|
}
|
|
let timeoutId = null;
|
|
const done = () => {
|
|
window.clearTimeout(timeoutId);
|
|
video.removeEventListener("loadeddata", done);
|
|
video.removeEventListener("canplay", done);
|
|
video.removeEventListener("error", fail);
|
|
resolve();
|
|
};
|
|
const fail = () => {
|
|
window.clearTimeout(timeoutId);
|
|
video.removeEventListener("loadeddata", done);
|
|
video.removeEventListener("canplay", done);
|
|
video.removeEventListener("error", fail);
|
|
reject(new Error("MP4 画面读取失败"));
|
|
};
|
|
timeoutId = window.setTimeout(() => {
|
|
video.removeEventListener("loadeddata", done);
|
|
video.removeEventListener("canplay", done);
|
|
video.removeEventListener("error", fail);
|
|
reject(new Error("MP4 画面读取超时"));
|
|
}, videoLoadTimeoutMs);
|
|
video.addEventListener("loadeddata", done, { once: true });
|
|
video.addEventListener("canplay", done, { once: true });
|
|
video.addEventListener("error", fail, { once: true });
|
|
});
|
|
}
|
|
|
|
function releaseVideo(video) {
|
|
if (!video) {
|
|
return;
|
|
}
|
|
if (video.src) {
|
|
URL.revokeObjectURL(video.src);
|
|
}
|
|
video.removeAttribute("src");
|
|
video.load();
|
|
}
|
|
|
|
function scoreCandidate(frames, candidate) {
|
|
const scores = frames.map((frame) => scoreFrameCandidate(frame, candidate));
|
|
if (!scores.length) {
|
|
return 0;
|
|
}
|
|
const average = scores.reduce((sum, value) => sum + value, 0) / scores.length;
|
|
return Math.max(0, Math.min(1, average));
|
|
}
|
|
|
|
function scoreFrameCandidate(frame, candidate) {
|
|
const alpha = regionStats(frame, candidate.alphaFrame);
|
|
const rgb = regionStats(frame, candidate.rgbFrame);
|
|
const maskScore = alpha.lowChromaRatio * 0.45 + alpha.brightnessSpread * 0.35 + alpha.darkOrBrightRatio * 0.2;
|
|
const rgbScore = Math.max(0, 1 - rgb.lowChromaRatio) * 0.6 + rgb.brightnessSpread * 0.4;
|
|
return maskScore * 0.7 + rgbScore * 0.3;
|
|
}
|
|
|
|
function regionStats(frame, rect) {
|
|
const [left, top, width, height] = rect;
|
|
const xStep = Math.max(1, Math.floor(width / 90));
|
|
const yStep = Math.max(1, Math.floor(height / 90));
|
|
let count = 0;
|
|
let lowChroma = 0;
|
|
let darkOrBright = 0;
|
|
let minBrightness = 255;
|
|
let maxBrightness = 0;
|
|
for (let y = top; y < top + height; y += yStep) {
|
|
for (let x = left; x < left + width; x += xStep) {
|
|
const index = (y * frame.width + x) * 4;
|
|
const r = frame.data[index];
|
|
const g = frame.data[index + 1];
|
|
const b = frame.data[index + 2];
|
|
const brightness = (r + g + b) / 3;
|
|
const chroma = Math.max(r, g, b) - Math.min(r, g, b);
|
|
count += 1;
|
|
if (chroma <= 16) {
|
|
lowChroma += 1;
|
|
}
|
|
if (brightness <= 32 || brightness >= 224) {
|
|
darkOrBright += 1;
|
|
}
|
|
minBrightness = Math.min(minBrightness, brightness);
|
|
maxBrightness = Math.max(maxBrightness, brightness);
|
|
}
|
|
}
|
|
return {
|
|
brightnessSpread: count ? (maxBrightness - minBrightness) / 255 : 0,
|
|
darkOrBrightRatio: count ? darkOrBright / count : 0,
|
|
lowChromaRatio: count ? lowChroma / count : 0,
|
|
};
|
|
}
|