2026-06-04 11:04:48 +08:00

743 lines
24 KiB
JavaScript

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