Files
juzi/taobao/utils/image.js
T
xsl 774376fa16 feat: 完善淘宝小程序拍照、报告与登录流程
接入本地实时识别和自动三连拍,调整报告裁切、商品跳转、分享标题及居中页眉;完善游客会话、后台渠道隔离、部署脚本与回归测试。
2026-11-04 15:07:51 +08:00

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