const defaultMetadata = "{}"; const profileCardLayoutKey = "profile_card_layout"; const sampleFrameCount = 6; const alphaTransparentThreshold = 245; const alphaContentThreshold = 16; const alphaMaskBrightnessThreshold = 80; const alphaMaskContentRowRatio = 0.5; const rgbDiffThreshold = 24; const minContentRowRatio = 0.01; const consecutiveRows = 3; const stableTopQuantile = 0.75; const contentTopSnapPixels = 4; const pagWasmURL = "/vendor/libpag.wasm"; let pagModulePromise; export async function detectProfileCardLayout(file) { if (!file) { throw new Error("资料卡素材不存在"); } if (isVideoFile(file)) { return detectVideoLayout(file); } if (isPagFile(file)) { return detectPagLayout(file); } if (isImageFile(file)) { return detectImageLayout(file); } throw new Error("当前素材类型暂不支持自动解析内容高度"); } export function profileCardMetadataJSON(metadataJson) { const layout = profileCardLayoutFromMetadata(metadataJson); if (!layout) { return defaultMetadata; } return JSON.stringify({ [profileCardLayoutKey]: layout }); } export function mergeProfileCardLayoutMetadata(metadataJson, layout) { const sanitized = sanitizeProfileCardLayout(layout); if (!sanitized) { return profileCardMetadataJSON(metadataJson); } return JSON.stringify({ [profileCardLayoutKey]: sanitized }); } export function profileCardLayoutFromMetadata(metadataJson) { if (!metadataJson) { return null; } try { const metadata = JSON.parse(String(metadataJson).trim()); return sanitizeProfileCardLayout(metadata?.[profileCardLayoutKey]); } catch { return null; } } export function measureProfileCardContent(frames, options = {}) { const validFrames = (frames || []).filter( (frame) => frame?.data && Number.isInteger(frame.width) && Number.isInteger(frame.height) && frame.width > 0 && frame.height > 0, ); if (!validFrames.length) { throw new Error("资料卡素材无法读取有效画面"); } const vapcLayout = sanitizeVapcLayout(options.vapcLayout, validFrames[0]); if (vapcLayout) { const vapcMeasure = measureVapcProfileCardContent(validFrames, vapcLayout); if (vapcMeasure) { return vapcMeasure; } } const bounds = validFrames.map((frame) => scanFrameBounds(frame)).filter(Boolean); if (!bounds.length) { throw new Error("资料卡素材没有解析到有效内容"); } const firstFrame = validFrames[0]; const top = Math.min(...bounds.map((item) => item.contentTop)); const bottom = Math.max(...bounds.map((item) => item.contentBottom)); const scanWidth = Math.min(...bounds.map((item) => item.colorContentWidth).filter((value) => value > 0)); const contentHeight = Math.max(0, bottom - top + 1); return { source_width: firstFrame.width, source_height: firstFrame.height, color_content_width: scanWidth || firstFrame.width, content_top: top, content_bottom: bottom, content_height: contentHeight, content_top_ratio: ratio(top, firstFrame.height), content_height_ratio: ratio(contentHeight, firstFrame.height), detect_version: 1, }; } function measureVapcProfileCardContent(frames, vapcLayout) { const bounds = frames .map((frame) => scanFrameBounds(frame, { scanLeft: vapcLayout.rgbFrame.x, scanWidth: vapcLayout.rgbFrame.width, }), ) .filter(Boolean); const alphaTops = frames .map((frame) => scanVapcAlphaTop(frame, vapcLayout)) .filter((value) => Number.isInteger(value) && value >= 0); if (!bounds.length || !alphaTops.length) { return null; } const firstFrame = frames[0]; const top = clampInteger( snapPixel(upperQuantile(alphaTops, stableTopQuantile), contentTopSnapPixels), 0, firstFrame.height - 1, ); const bottom = Math.max(...bounds.map((item) => item.contentBottom)); if (bottom < top) { return null; } const contentHeight = bottom - top + 1; return { source_width: firstFrame.width, source_height: firstFrame.height, color_content_width: vapcLayout.rgbFrame.width, content_top: top, content_bottom: bottom, content_height: contentHeight, content_top_ratio: ratio(top, firstFrame.height), content_height_ratio: ratio(contentHeight, firstFrame.height), detect_version: 2, }; } function scanFrameBounds(frame, options = {}) { const { data, width, height } = frame; const alphaMode = hasMeaningfulAlpha(data); const scanLeft = clampInteger(options.scanLeft ?? 0, 0, width - 1); const maxScanWidth = width - scanLeft; const colorContentWidth = alphaMode ? maxScanWidth : options.scanWidth || detectColorContentWidth(data, width, height); const scanWidth = Math.max(1, Math.min(maxScanWidth, colorContentWidth)); const background = alphaMode ? null : sampleBackgroundColor(data, width, height, scanLeft, scanWidth); const minPixels = Math.max(3, Math.floor(scanWidth * minContentRowRatio)); const rowHasContent = (y) => rowContentPixels(data, width, scanLeft, scanWidth, y, background, alphaMode) >= minPixels; const contentTop = findFirstContentRow(height, rowHasContent); const contentBottom = findLastContentRow(height, rowHasContent); if (contentTop < 0 || contentBottom < contentTop) { return null; } return { colorContentWidth: scanWidth, contentTop, contentBottom, }; } function scanVapcAlphaTop(frame, vapcLayout) { const { data, width, height } = frame; const alphaFrame = vapcLayout.alphaFrame; const left = clampInteger(alphaFrame.x, 0, width - 1); const top = clampInteger(alphaFrame.y, 0, height - 1); const scanWidth = Math.max(1, Math.min(alphaFrame.width, width - left)); const scanHeight = Math.max(1, Math.min(alphaFrame.height, height - top)); const minPixels = Math.max(3, Math.floor(scanWidth * alphaMaskContentRowRatio)); const rowHasContent = (y) => rowAlphaMaskPixels(data, width, left, scanWidth, y) >= minPixels; let run = 0; for (let y = top; y < top + scanHeight; y += 1) { run = rowHasContent(y) ? run + 1 : 0; if (run >= consecutiveRows) { return y - consecutiveRows + 1; } } return -1; } function findFirstContentRow(height, rowHasContent) { let run = 0; for (let y = 0; y < height; y += 1) { run = rowHasContent(y) ? run + 1 : 0; if (run >= consecutiveRows) { return y - consecutiveRows + 1; } } return -1; } function findLastContentRow(height, rowHasContent) { let run = 0; for (let y = height - 1; y >= 0; y -= 1) { run = rowHasContent(y) ? run + 1 : 0; if (run >= consecutiveRows) { return y + consecutiveRows - 1; } } return -1; } function rowContentPixels(data, width, scanLeft, scanWidth, y, background, alphaMode) { let count = 0; const offset = y * width * 4; for (let x = scanLeft; x < scanLeft + scanWidth; x += 1) { const index = offset + x * 4; const alpha = data[index + 3]; if (alphaMode) { if (alpha > alphaContentThreshold) { count += 1; } continue; } if (alpha <= alphaContentThreshold) { continue; } const diff = Math.abs(data[index] - background.r) + Math.abs(data[index + 1] - background.g) + Math.abs(data[index + 2] - background.b); if (diff > rgbDiffThreshold) { count += 1; } } return count; } function rowAlphaMaskPixels(data, width, scanLeft, scanWidth, y) { let count = 0; const offset = y * width * 4; for (let x = scanLeft; x < scanLeft + scanWidth; x += 1) { const index = offset + x * 4; const brightness = (data[index] + data[index + 1] + data[index + 2]) / 3; if (brightness > alphaMaskBrightnessThreshold) { count += 1; } } return count; } function hasMeaningfulAlpha(data) { const totalPixels = Math.floor(data.length / 4); if (!totalPixels) { return false; } let transparentPixels = 0; for (let index = 3; index < data.length; index += 4) { if (data[index] < alphaTransparentThreshold) { transparentPixels += 1; } } return transparentPixels / totalPixels > 0.005; } function sampleBackgroundColor(data, width, height, scanLeft, scanWidth) { const sampleRows = Math.max(1, Math.floor(height * 0.02)); const sampleStep = Math.max(1, Math.floor(scanWidth / 160)); let r = 0; let g = 0; let b = 0; let count = 0; for (let y = 0; y < sampleRows; y += 1) { const offset = y * width * 4; for (let x = scanLeft; x < scanLeft + scanWidth; x += sampleStep) { const index = offset + x * 4; r += data[index]; g += data[index + 1]; b += data[index + 2]; count += 1; } } return count ? { r: Math.round(r / count), g: Math.round(g / count), b: Math.round(b / count), } : { r: 0, g: 0, b: 0 }; } function detectColorContentWidth(data, width, height) { const minMaskWidth = Math.max(1, Math.floor(width * 0.15)); const minStart = Math.floor(width * 0.45); const rowStep = Math.max(1, Math.floor(height / 240)); let run = 0; let start = width; for (let x = width - 1; x >= 0; x -= 1) { if (isLowChromaColumn(data, width, height, x, rowStep)) { run += 1; start = x; continue; } if (run >= minMaskWidth && start >= minStart) { return start; } run = 0; start = width; } if (run >= minMaskWidth && start >= minStart) { return start; } return width; } function isLowChromaColumn(data, width, height, x, rowStep) { let lowChromaPixels = 0; let sampled = 0; for (let y = 0; y < height; y += rowStep) { const index = (y * width + x) * 4; const max = Math.max(data[index], data[index + 1], data[index + 2]); const min = Math.min(data[index], data[index + 1], data[index + 2]); if (max - min <= 8) { lowChromaPixels += 1; } sampled += 1; } return sampled > 0 && lowChromaPixels / sampled > 0.92; } async function detectImageLayout(file) { const bitmap = await createImageBitmap(file); try { const frame = drawBitmapFrame(bitmap, bitmap.width, bitmap.height); return measureProfileCardContent([frame]); } finally { bitmap.close?.(); } } async function detectVideoLayout(file) { const video = document.createElement("video"); const url = URL.createObjectURL(file); try { const vapcLayoutPromise = parseVapcLayout(file).catch(() => null); video.muted = true; video.preload = "metadata"; video.playsInline = true; video.src = url; await waitForVideoEvent(video, "loadedmetadata"); await waitForVideoReady(video); const duration = Number.isFinite(video.duration) && video.duration > 0 ? video.duration : 0; const width = video.videoWidth; const height = video.videoHeight; if (!width || !height) { throw new Error("资料卡视频尺寸无效"); } const sampleTimes = videoSampleTimes(duration); const frames = []; for (const time of sampleTimes) { await seekVideo(video, time); frames.push(drawBitmapFrame(video, width, height)); } return measureProfileCardContent(frames, { vapcLayout: await vapcLayoutPromise }); } finally { video.removeAttribute("src"); video.load(); URL.revokeObjectURL(url); } } async function parseVapcLayout(file) { const bytes = new Uint8Array(await file.arrayBuffer()); const markerIndex = indexOfBytes(bytes, [118, 97, 112, 99]); if (markerIndex < 0) { return null; } const searchEnd = Math.min(bytes.length, markerIndex + 4096); const jsonStart = findByte(bytes, 123, markerIndex + 4, searchEnd); if (jsonStart < 0) { return null; } const jsonEnd = findJsonObjectEnd(bytes, jsonStart, searchEnd); if (jsonEnd <= jsonStart) { return null; } const payload = JSON.parse(new TextDecoder().decode(bytes.slice(jsonStart, jsonEnd + 1))); return payload?.info || null; } function sanitizeVapcLayout(value, frame) { if (!value || typeof value !== "object") { return null; } const sourceWidth = positiveInteger(value.videoW ?? value.video_width); const sourceHeight = positiveInteger(value.videoH ?? value.video_height); if (frame && (sourceWidth !== frame.width || sourceHeight !== frame.height)) { return null; } const rgbFrame = parseVapcFrame(value.rgbFrame ?? value.rgb_frame); const alphaFrame = parseVapcFrame(value.aFrame ?? value.a_frame); if (!sourceWidth || !sourceHeight || !rgbFrame || !alphaFrame) { return null; } if (!rectFitsFrame(rgbFrame, sourceWidth, sourceHeight) || !rectFitsFrame(alphaFrame, sourceWidth, sourceHeight)) { return null; } return { sourceWidth, sourceHeight, contentWidth: positiveInteger(value.w) || rgbFrame.width, contentHeight: positiveInteger(value.h) || rgbFrame.height, rgbFrame, alphaFrame, }; } function parseVapcFrame(value) { const raw = Array.isArray(value) ? value : typeof value === "string" ? value.split(",").map((item) => Number(item.trim())) : []; if (raw.length < 4) { return null; } const rect = { x: nonNegativeInteger(raw[0]), y: nonNegativeInteger(raw[1]), width: positiveInteger(raw[2]), height: positiveInteger(raw[3]), }; if (rect.x === null || rect.y === null || !rect.width || !rect.height) { return null; } return rect; } function indexOfBytes(bytes, pattern) { if (!bytes?.length || !pattern?.length || pattern.length > bytes.length) { return -1; } for (let index = 0; index <= bytes.length - pattern.length; index += 1) { let matched = true; for (let patternIndex = 0; patternIndex < pattern.length; patternIndex += 1) { if (bytes[index + patternIndex] !== pattern[patternIndex]) { matched = false; break; } } if (matched) { return index; } } return -1; } 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 rectFitsFrame(rect, width, height) { return ( rect.x >= 0 && rect.y >= 0 && rect.width > 0 && rect.height > 0 && rect.x + rect.width <= width && rect.y + rect.height <= height ); } async function detectPagLayout(file) { const PAG = await getPAGModule(); let pagFile; let pagView; try { const buffer = await file.arrayBuffer(); pagFile = await PAG.PAGFile.load(buffer); const width = pagFile.width(); const height = pagFile.height(); if (!width || !height) { throw new Error("资料卡 PAG 尺寸无效"); } const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; pagView = await PAG.PAGView.init(pagFile, canvas, { firstFrame: false, useScale: false }); if (!pagView) { throw new Error("资料卡 PAG 读取失败"); } const frames = []; for (const progress of pagSampleProgresses()) { pagView.setProgress(progress); await pagView.flush(); frames.push(drawBitmapFrame(canvas, width, height)); } return measureProfileCardContent(frames); } finally { if (pagView) { pagView.destroy(); } pagFile?.destroy?.(); } } function drawBitmapFrame(source, width, height) { const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; const context = canvas.getContext("2d", { willReadFrequently: true }); if (!context) { throw new Error("当前浏览器无法读取资料卡画面"); } context.clearRect(0, 0, width, height); context.drawImage(source, 0, 0, width, height); const imageData = context.getImageData(0, 0, width, height); return { data: imageData.data, width, height, }; } function videoSampleTimes(duration) { if (!duration) { return [0]; } const last = Math.max(0, duration - 0.05); if (sampleFrameCount <= 1) { return [Math.min(0.05, last)]; } return Array.from({ length: sampleFrameCount }, (_, index) => { const progress = index / (sampleFrameCount - 1); return Math.min(last, Math.max(0, progress * last)); }); } function pagSampleProgresses() { return Array.from({ length: sampleFrameCount }, (_, index) => sampleFrameCount <= 1 ? 0 : index / (sampleFrameCount - 1), ); } function waitForVideoEvent(video, eventName) { return new Promise((resolve, reject) => { const cleanup = () => { video.removeEventListener(eventName, handleSuccess); video.removeEventListener("error", handleError); }; const handleSuccess = () => { cleanup(); resolve(); }; const handleError = () => { cleanup(); reject(new Error("资料卡视频读取失败")); }; video.addEventListener(eventName, handleSuccess, { once: true }); video.addEventListener("error", handleError, { once: true }); }); } function seekVideo(video, time) { return new Promise((resolve, reject) => { const targetTime = Math.min(Math.max(0, time), video.duration || time || 0); if (video.readyState >= 2 && Math.abs(video.currentTime - targetTime) < 0.01) { waitForPresentedVideoFrame(video).then(resolve).catch(reject); return; } const cleanup = () => { video.removeEventListener("seeked", handleSeeked); video.removeEventListener("error", handleError); }; const handleSeeked = () => { cleanup(); waitForPresentedVideoFrame(video).then(resolve).catch(reject); }; const handleError = () => { cleanup(); reject(new Error("资料卡视频抽帧失败")); }; video.addEventListener("seeked", handleSeeked, { once: true }); video.addEventListener("error", handleError, { once: true }); video.currentTime = targetTime; }); } function waitForVideoReady(video) { if (video.readyState >= 2) { return waitForPresentedVideoFrame(video); } return waitForVideoEvent(video, "loadeddata").then(() => waitForPresentedVideoFrame(video)); } function waitForPresentedVideoFrame(video) { if (typeof video.requestVideoFrameCallback === "function") { return new Promise((resolve) => { const timeout = window.setTimeout(resolve, 120); video.requestVideoFrameCallback(() => { window.clearTimeout(timeout); resolve(); }); }); } return new Promise((resolve) => { requestAnimationFrame(() => requestAnimationFrame(resolve)); }); } function sanitizeProfileCardLayout(value) { if (!value || typeof value !== "object") { return null; } const sourceWidth = positiveInteger(value.source_width); const sourceHeight = positiveInteger(value.source_height); const contentTop = nonNegativeInteger(value.content_top); const contentBottom = nonNegativeInteger(value.content_bottom); const contentHeight = positiveInteger(value.content_height); if (!sourceWidth || !sourceHeight || contentTop === null || contentBottom === null || !contentHeight) { return null; } if ( contentBottom < contentTop || contentBottom >= sourceHeight || contentHeight !== contentBottom - contentTop + 1 ) { return null; } return { source_width: sourceWidth, source_height: sourceHeight, color_content_width: positiveInteger(value.color_content_width) || sourceWidth, content_top: contentTop, content_bottom: contentBottom, content_height: contentHeight, content_top_ratio: decimalRatio(value.content_top_ratio, ratio(contentTop, sourceHeight)), content_height_ratio: decimalRatio(value.content_height_ratio, ratio(contentHeight, sourceHeight)), detect_version: positiveInteger(value.detect_version) || 1, }; } function isVideoFile(file) { return file.type?.startsWith("video/") || /\.(mp4|mov|m4v|webm)$/i.test(file.name || ""); } function isPagFile(file) { return file.type?.includes("pag") || /\.pag$/i.test(file.name || ""); } function isImageFile(file) { return file.type?.startsWith("image/") || /\.(avif|gif|jpe?g|png|webp)$/i.test(file.name || ""); } function getPAGModule() { if (!pagModulePromise) { pagModulePromise = import("libpag").then(({ PAGInit }) => PAGInit({ locateFile: () => pagWasmURL })); } return pagModulePromise; } function positiveInteger(value) { const number = Number(value); return Number.isInteger(number) && number > 0 ? number : 0; } function nonNegativeInteger(value) { const number = Number(value); return Number.isInteger(number) && number >= 0 ? number : null; } function ratio(value, total) { return Number((value / total).toFixed(6)); } function decimalRatio(value, fallback) { const number = Number(value); return Number.isFinite(number) && number >= 0 && number <= 1 ? Number(number.toFixed(6)) : fallback; } function clampInteger(value, min, max) { const number = Number(value); if (!Number.isInteger(number)) { return min; } return Math.min(max, Math.max(min, number)); } function upperQuantile(values, quantile) { const sorted = values.filter((value) => Number.isFinite(value)).sort((left, right) => left - right); if (!sorted.length) { return 0; } const index = Math.min(sorted.length - 1, Math.ceil((sorted.length - 1) * quantile)); return sorted[index]; } function snapPixel(value, size) { if (!size || size <= 1) { return Math.round(value); } return Math.round(value / size) * size; }