217 lines
14 KiB
JavaScript
217 lines
14 KiB
JavaScript
// Always redraw to JPEG: native compressImage changes quality, not dimensions.
|
|
var MAX_SIDE = 1280;
|
|
var confirmedRawFormat = null;
|
|
var CALIBRATION_FORMATS = ['RGBA', 'BGRA', 'ARGB', 'ABGR'];
|
|
// Taobao CanvasContext can return the data URL directly. Native bridges also
|
|
// unwrap an explicit base64 field, while other hosts keep an object wrapper.
|
|
function normalizeJPEG(result) {
|
|
var value, allowRaw = false;
|
|
if (typeof result === 'string') { value = result; allowRaw = true; }
|
|
else if (result && typeof result === 'object') {
|
|
if (typeof result.dataURL === 'string') value = result.dataURL;
|
|
else if (typeof result.dataUrl === 'string') value = result.dataUrl;
|
|
else if (typeof result.base64 === 'string') { value = result.base64; allowRaw = true; }
|
|
}
|
|
if (typeof value !== 'string') return null;
|
|
var prefix = 'data:image/jpeg;base64,';
|
|
if (value.indexOf(prefix) === 0) value = value.slice(prefix.length);
|
|
else if (!allowRaw || value.indexOf('data:') === 0) return null;
|
|
// Require standard base64 and the JPEG SOI/marker bytes FF D8 FF. Never label
|
|
// a PNG, empty content, unknown field or malformed native response as JPEG.
|
|
if (!value.length || value.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(value) || value.indexOf('/9j/') !== 0) return null;
|
|
return prefix + value;
|
|
}
|
|
function compressToBase64(src, page) {
|
|
return new Promise(function (resolve, reject) {
|
|
var settled = false;
|
|
var timer = typeof setTimeout === 'function' ? setTimeout(function () {
|
|
fail(new Error('照片处理超时,请重试;请确认相机页面已加载完整'));
|
|
}, 20000) : null;
|
|
function finish(value) { if (settled) return; settled = true; if (timer) clearTimeout(timer); resolve(value); }
|
|
function fail(err) {
|
|
if (settled) return; settled = true; if (timer) clearTimeout(timer);
|
|
reject(err && err.canceled ? err : new Error(err && (err.message || err.errorMessage || err.errMsg) || '照片处理失败,请重新拍摄'));
|
|
}
|
|
my.getImageInfo({ src: src, success: function (info) {
|
|
if (settled) return;
|
|
if (page.alive === false) { fail({ canceled: true }); return; }
|
|
if (!(info.width > 0 && info.height > 0)) { fail(new Error('无法读取照片尺寸,请重新选择')); return; }
|
|
var scale = Math.min(1, MAX_SIDE / Math.max(info.width, info.height));
|
|
var w = Math.max(1, Math.round(info.width * scale)), h = Math.max(1, Math.round(info.height * scale));
|
|
page.setData({ compressWidth: w, compressHeight: h }, function () {
|
|
if (settled) return;
|
|
if (page.alive === false) { fail({ canceled: true }); return; }
|
|
try {
|
|
var ctx = my.createCanvasContext('compress-canvas');
|
|
ctx.clearRect(0, 0, w, h);
|
|
ctx.drawImage(src, 0, 0, w, h);
|
|
ctx.draw(false, function () {
|
|
if (settled) return;
|
|
if (page.alive === false) { fail({ canceled: true }); return; }
|
|
var options = { x: 0, y: 0, width: w, height: h, destWidth: w, destHeight: h, fileType: 'jpg', quality: 0.88,
|
|
success: function (result) {
|
|
if (settled) return;
|
|
var file = result.apFilePath || result.tempFilePath;
|
|
if (!file) { fail(new Error('照片导出失败,请重试')); return; }
|
|
try {
|
|
my.getFileSystemManager().readFile({ filePath: file, encoding: 'base64',
|
|
success: function (r) {
|
|
if (settled) return;
|
|
if (!r || typeof r.data !== 'string' || !r.data.length) return fail(new Error('照片内容为空,请重新拍摄'));
|
|
var jpeg = normalizeJPEG(r.data);
|
|
if (!jpeg) return fail(new Error('照片 JPEG 转换失败,请重试'));
|
|
finish(jpeg);
|
|
}, fail: fail });
|
|
} catch (e) { fail(new Error('当前淘宝版本不支持照片读取,请升级后重试')); }
|
|
}, fail: fail };
|
|
try {
|
|
if (typeof ctx.toTempFilePath === 'function') ctx.toTempFilePath(options);
|
|
else if (typeof my.canvasToTempFilePath === 'function') { options.canvasId = 'compress-canvas'; my.canvasToTempFilePath(options); }
|
|
else if (typeof ctx.toDataURL === 'function') {
|
|
function acceptJPEG(result) {
|
|
if (settled) return;
|
|
var value = normalizeJPEG(result);
|
|
if (!value) return fail(new Error('照片 JPEG 转换失败,请重试'));
|
|
finish(value);
|
|
}
|
|
// Native Skia's legacy adapter returns a Promise in addition to
|
|
// invoking callbacks. Observe it so a failure cannot create an
|
|
// unhandled rejection; settled guards make dual results safe.
|
|
var exported = ctx.toDataURL({ fileType: 'jpg', quality: 0.88, success: acceptJPEG, fail: fail });
|
|
if (exported && typeof exported.then === 'function') Promise.resolve(exported).then(acceptJPEG, fail);
|
|
}
|
|
else fail(new Error('当前淘宝版本不支持照片导出,请升级后重试'));
|
|
} catch (error) { fail(error); }
|
|
});
|
|
} catch (e) { fail(new Error('照片处理不可用,请升级淘宝后重试')); }
|
|
});
|
|
}, fail: fail });
|
|
});
|
|
}
|
|
// AR exposes captured ArrayBuffers. Inspect actual event fields, never call an
|
|
// undocumented ArCameraContext.takePhoto or assume that a YUV frame is RGBA.
|
|
function capturedImage(frame) {
|
|
var visited = [], matches = [], rootWidth = frame && frame.width, rootHeight = frame && frame.height;
|
|
function walk(value, depth, metadata, path) {
|
|
if (!value || depth > 4 || visited.indexOf(value) >= 0) return;
|
|
if (typeof value === 'object') visited.push(value);
|
|
var bytes = null;
|
|
if (Object.prototype.toString.call(value) === '[object ArrayBuffer]') bytes = new Uint8Array(value);
|
|
else if (typeof ArrayBuffer !== 'undefined' && ArrayBuffer.isView && ArrayBuffer.isView(value)) bytes = new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
|
if (bytes) { matches.push({ bytes: bytes, width: metadata.width || rootWidth, height: metadata.height || rootHeight, format: metadata.format, path: path }); return; }
|
|
if (typeof value !== 'object' || Array.isArray(value)) return;
|
|
var next = { width: value.width || metadata.width, height: value.height || metadata.height,
|
|
format: value.pixelFormat || value.format || metadata.format };
|
|
Object.keys(value).forEach(function (key) { if (key !== 'anchors') walk(value[key], depth + 1, next, path ? path + '.' + key : key); });
|
|
}
|
|
walk(frame, 0, {}, '');
|
|
return matches.filter(function (item) { var b = item.bytes; return b.length > 4 && (b[0] === 255 && b[1] === 216 || b[0] === 137 && b[1] === 80 || b.length === item.width * item.height * 4); })[0] || null;
|
|
}
|
|
function rawFormat(format) {
|
|
var value = String(format || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
|
|
return /^(RGBA|BGRA|ARGB|ABGR)(8888|8UNORM|UNORM)?$/.test(value) ? value.slice(0, 4) : '';
|
|
}
|
|
function isFourChannel(image) {
|
|
return image && typeof image.width === 'number' && typeof image.height === 'number' &&
|
|
isFinite(image.width) && isFinite(image.height) && image.width > 0 && image.height > 0 &&
|
|
image.width === Math.floor(image.width) && image.height === Math.floor(image.height) &&
|
|
image.bytes.length === image.width * image.height * 4;
|
|
}
|
|
function frameCalibrationFormats(frame) {
|
|
var image = capturedImage(frame);
|
|
return isFourChannel(image) && (image.format === undefined || image.format === null || image.format === '')
|
|
? CALIBRATION_FORMATS.slice() : [];
|
|
}
|
|
function freezeCalibrationFrame(frame) {
|
|
var image = capturedImage(frame);
|
|
if (!frameCalibrationFormats(frame).length || !image.path) throw new Error('当前帧不是缺少格式说明的四通道图像,无需进行颜色校验');
|
|
var copy = { width: image.width, height: image.height }, cursor = copy, parts = image.path.split('.');
|
|
parts.forEach(function (part, index) {
|
|
if (!part || part === '__proto__' || part === 'constructor' || part === 'prototype') throw new Error('相机图像字段不支持颜色校验');
|
|
if (index === parts.length - 1) cursor[part] = new Uint8Array(image.bytes).buffer;
|
|
else { cursor[part] = {}; cursor = cursor[part]; }
|
|
});
|
|
return copy;
|
|
}
|
|
function confirmFramePixelFormat(frame, format) {
|
|
if (frameCalibrationFormats(frame).indexOf(format) < 0) throw new Error('该照片格式尚未通过本次颜色校验');
|
|
var image = capturedImage(frame);
|
|
// Runtime-only capability choice: never persist a face frame or this selection
|
|
// to Storage. A different native buffer field must be checked independently.
|
|
confirmedRawFormat = { format: format, path: image.path };
|
|
return format;
|
|
}
|
|
function confirmedFramePixelFormat() { return confirmedRawFormat ? confirmedRawFormat.format : ''; }
|
|
function encode(bytes) {
|
|
if (typeof my.arrayBufferToBase64 === 'function') return my.arrayBufferToBase64(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength));
|
|
var alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/', out = [];
|
|
for (var i = 0; i < bytes.length; i += 3) {
|
|
var a = bytes[i], b = bytes[i + 1], c = bytes[i + 2], n = (a << 16) | ((b || 0) << 8) | (c || 0);
|
|
out.push(alphabet[(n >>> 18) & 63], alphabet[(n >>> 12) & 63], i + 1 < bytes.length ? alphabet[(n >>> 6) & 63] : '=', i + 2 < bytes.length ? alphabet[n & 63] : '=');
|
|
}
|
|
return out.join('');
|
|
}
|
|
function captureFrameToBase64(frame, page, options) {
|
|
options = options || {};
|
|
var image = capturedImage(frame);
|
|
if (!image) return Promise.reject(new Error('已识别人脸,但未收到可导出的相机图像,请打开相机组件测试检查帧数据'));
|
|
var bytes = image.bytes;
|
|
var rawShape = isFourChannel(image) && !/^(JPEG|JPG|PNG)$/i.test(String(image.format || ''));
|
|
// Raw channel values may happen to begin with JPEG/PNG marker bytes. An
|
|
// explicit four-channel layout must not be reinterpreted as a compressed file.
|
|
if (!rawShape && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) return compressToBase64('data:image/jpeg;base64,' + encode(bytes), page);
|
|
if (!rawShape && bytes[0] === 137 && bytes[1] === 80 && bytes[2] === 78 && bytes[3] === 71) return compressToBase64('data:image/png;base64,' + encode(bytes), page);
|
|
var format = rawFormat(image.format), hasFormat = image.format !== undefined && image.format !== null && image.format !== '';
|
|
if (!hasFormat) format = rawFormat(options.pixelFormat) ||
|
|
(confirmedRawFormat && confirmedRawFormat.path === image.path ? confirmedRawFormat.format : '') || rawFormat(options.defaultPixelFormat);
|
|
if (!format || !isFourChannel(image)) return Promise.reject(new Error('相机原始图像格式尚未确认,已停止抓拍以避免颜色错误;请在相机测试页校验照片颜色'));
|
|
var maxSide = typeof options.maxSide === 'number' && isFinite(options.maxSide) && options.maxSide >= 128
|
|
? Math.min(MAX_SIDE, Math.floor(options.maxSide)) : MAX_SIDE;
|
|
var scale = Math.min(1, maxSide / Math.max(image.width, image.height));
|
|
var w = Math.max(1, Math.round(image.width * scale)), h = Math.max(1, Math.round(image.height * scale));
|
|
var rgba = new Uint8ClampedArray(w * h * 4);
|
|
for (var y = 0; y < h; y++) for (var x = 0; x < w; x++) {
|
|
var source = (Math.min(image.height - 1, Math.floor(y / scale)) * image.width + Math.min(image.width - 1, Math.floor(x / scale))) * 4;
|
|
var target = (y * w + x) * 4;
|
|
rgba[target] = bytes[source + (format === 'BGRA' ? 2 : format === 'ARGB' ? 1 : format === 'ABGR' ? 3 : 0)];
|
|
rgba[target + 1] = bytes[source + (format === 'ARGB' || format === 'ABGR' ? 2 : 1)];
|
|
rgba[target + 2] = bytes[source + (format === 'BGRA' ? 0 : format === 'ARGB' ? 3 : format === 'ABGR' ? 1 : 2)]; rgba[target + 3] = 255;
|
|
}
|
|
return new Promise(function (resolve, reject) {
|
|
var settled = false;
|
|
var timer = setTimeout(function () { fail(new Error('相机照片导出超时,请重试')); }, 20000);
|
|
function fail(error) { if (settled) return; settled = true; clearTimeout(timer); reject(error && error.canceled ? error : new Error(error && (error.message || error.errorMessage) || '相机照片导出失败')); }
|
|
page.setData({ compressWidth: w, compressHeight: h }, function () {
|
|
if (settled) return;
|
|
if (page.alive === false) { fail({ canceled: true }); return; }
|
|
try {
|
|
var ctx = my.createCanvasContext('compress-canvas');
|
|
var pixelOptions = { x: 0, y: 0, width: w, height: h, data: rgba,
|
|
success: function () {
|
|
if (settled) return;
|
|
if (page.alive === false) return fail({ canceled: true });
|
|
try {
|
|
if (!ctx || typeof ctx.toDataURL !== 'function') return fail(new Error('相机画布无法导出 JPEG,请升级淘宝后重试'));
|
|
function acceptJPEG(result) {
|
|
if (settled) return;
|
|
if (page.alive === false) return fail({ canceled: true });
|
|
var value = normalizeJPEG(result);
|
|
if (!value) return fail(new Error('JPEG 转换失败'));
|
|
settled = true; clearTimeout(timer); resolve(value);
|
|
}
|
|
var exported = ctx.toDataURL({ fileType: 'jpg', quality: .88, success: acceptJPEG, fail: fail });
|
|
if (exported && typeof exported.then === 'function') Promise.resolve(exported).then(acceptJPEG, fail);
|
|
} catch (error) { fail(error); }
|
|
}, fail: fail };
|
|
if (ctx && typeof ctx.putImageData === 'function') ctx.putImageData(pixelOptions);
|
|
else if (typeof my.canvasPutImageData === 'function') {
|
|
pixelOptions.id = 'compress-canvas'; pixelOptions.canvasId = 'compress-canvas'; my.canvasPutImageData(pixelOptions);
|
|
} else fail(new Error('当前淘宝版本不支持相机图像写入画布,请升级后重试'));
|
|
} catch (error) { fail(error); }
|
|
});
|
|
});
|
|
}
|
|
module.exports = { MAX_SIDE: MAX_SIDE, compressToBase64: compressToBase64, capturedImage: capturedImage, captureFrameToBase64: captureFrameToBase64,
|
|
frameCalibrationFormats: frameCalibrationFormats, freezeCalibrationFrame: freezeCalibrationFrame,
|
|
confirmFramePixelFormat: confirmFramePixelFormat, confirmedFramePixelFormat: confirmedFramePixelFormat };
|