743 lines
24 KiB
JavaScript
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;
|
|
}
|