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, }; }