318 lines
20 KiB
JavaScript
318 lines
20 KiB
JavaScript
// Standalone device probe. No account API, upload, server call or saved photo.
|
||
var AR = require('../../utils/ar-camera');
|
||
var image = require('../../utils/image');
|
||
var permissions = require('../../utils/permissions');
|
||
var config = require('../../config');
|
||
var PROBE_VERSION = 'AR-DIAG-20261102-05';
|
||
var CALIBRATION_FORMATS = ['RGBA', 'BGRA', 'ARGB', 'ABGR'];
|
||
function confirmedPixelFormat() {
|
||
try {
|
||
var format = image.confirmedFramePixelFormat();
|
||
return CALIBRATION_FORMATS.indexOf(format) >= 0 ? format : '';
|
||
} catch (_) { return ''; }
|
||
}
|
||
Page({
|
||
data: { running: false, busy: false, exporting: false, devicePosition: config.arFrontPosition === 'back' ? 'back' : 'front', cameraSession: 0, status: '请点击开启,验证淘宝真实 AR 帧与照片导出。',
|
||
probeVersion: PROBE_VERSION, toolbarTop: 112, environmentText: '', capabilityText: '', contextText: '等待 onInit 后读取公开方法名', eventText: '尚未收到原生事件', copyStatus: '', diagnosticText: '',
|
||
target: 'front', frameCount: 0, standardFrameCount: 0, frameText: '尚未收到帧事件',
|
||
calibrationItems: [], calibrationReady: false, calibrationText: '', calibrationConfirmed: '',
|
||
poseText: '', fieldText: '', errorText: '', snapshotUrl: '', snapshotText: '', compressWidth: 1, compressHeight: 1 },
|
||
onLoad: function () {
|
||
this.alive = true; this.visible = true; this.sequence = 0;
|
||
var capability = AR.capability(my);
|
||
this.diagnosticSystem = AR.diagnostics.system(my); this.contextMethodNames = []; this.nativeEventStats = {};
|
||
this.diagnosticCapability = 'ar-camera=' + capability.componentSignal + '; createArCameraContext=' + capability.contextFactory;
|
||
this.setData({ toolbarTop: AR.diagnostics.toolbarTop(my), calibrationConfirmed: confirmedPixelFormat(), environmentText: this.diagnosticSystem.join('\n') || '系统未返回可读取的设备/宿主版本;请手动补充淘宝版本。',
|
||
capabilityText: 'ar-camera 声明:' + capability.componentSignal + ';上下文工厂:' + (capability.contextFactory ? '存在' : '不存在') + '。声明结果不代表真机已支持。' });
|
||
},
|
||
onShow: function () { this.visible = true; },
|
||
flushFrameDiagnostic: function () {
|
||
var pending = this.pendingFrameDiagnostic;
|
||
if (!pending) return;
|
||
this.pendingFrameDiagnostic = null;
|
||
var record = this.nativeEventStats[pending.key], summary = AR.diagnostics.event(pending.event);
|
||
record.type = summary.type || '(未提供 type)'; record.metadata = summary.metadata;
|
||
this.lastDiagnosticMetadata = summary.metadata;
|
||
},
|
||
nativeEventView: function () {
|
||
var that = this;
|
||
return { eventText: Object.keys(this.nativeEventStats).map(function (item) {
|
||
var value = that.nativeEventStats[item];
|
||
return item + ' · type=' + value.type + ' · 次数 ' + value.count;
|
||
}).join('\n'), fieldText: (this.lastDiagnosticMetadata || []).join('\n') || '本事件未返回白名单元信息;不会输出未知字段值或人脸坐标。' };
|
||
},
|
||
recordNativeEvent: function (name, event, deferFrame) {
|
||
var key = name + ':request=' + this.data.devicePosition;
|
||
var record = this.nativeEventStats[key] || { count: 0, type: '(未提供 type)', metadata: [] };
|
||
record.count++;
|
||
this.nativeEventStats[key] = record;
|
||
// Count every frame in JS, but do not scan descriptors or cross the UI
|
||
// bridge until the 400ms refresh. Only the latest frame is retained.
|
||
if (deferFrame) { this.pendingFrameDiagnostic = { key: key, event: event }; return; }
|
||
this.flushFrameDiagnostic();
|
||
var summary = AR.diagnostics.event(event);
|
||
record.type = summary.type || '(未提供 type)'; record.metadata = summary.metadata;
|
||
this.lastDiagnosticMetadata = summary.metadata;
|
||
this.setData(this.nativeEventView());
|
||
return summary;
|
||
},
|
||
diagnosticReport: function () {
|
||
// A user-requested copy gets up-to-date counters and last-frame metadata
|
||
// without introducing another UI refresh on the high-rate native stream.
|
||
this.flushFrameDiagnostic();
|
||
var that = this, lines = [PROBE_VERSION].concat(this.diagnosticSystem || []);
|
||
lines.push(this.diagnosticCapability || 'capability=unavailable');
|
||
lines.push('requestedPosition=' + this.data.devicePosition + '; actualPosition=需核对实际画面');
|
||
lines.push('context.methods=' + ((this.contextMethodNames || []).join(', ') || '(none observed)'));
|
||
lines.push('pixelFormat.default=' + (CALIBRATION_FORMATS.indexOf(config.arPixelFormat) >= 0 ? config.arPixelFormat : '(none)') + '; defaultIsUserConfirmed=false');
|
||
lines.push('pixelFormat.confirmed=' + (confirmedPixelFormat() || '(unconfirmed)'));
|
||
lines.push('currentSession.frameEvents=' + (this.frames || 0) + '; parsableFrames=' + (this.standardFrames || 0));
|
||
Object.keys(this.nativeEventStats || {}).forEach(function (key) {
|
||
var record = that.nativeEventStats[key];
|
||
lines.push(key + '; type=' + record.type + '; count=' + record.count);
|
||
record.metadata.forEach(function (item) { lines.push(' ' + item); });
|
||
});
|
||
return lines.join('\n');
|
||
},
|
||
onCopyDiagnostics: function () {
|
||
if (!this.alive) return;
|
||
// Always reveal the same privacy-filtered text first, so clipboard failure
|
||
// or an absent API still leaves a native selectable-text fallback.
|
||
var report = this.diagnosticReport();
|
||
this.setData({ diagnosticText: report, copyStatus: '安全诊断已生成;下方文字可长按复制。' });
|
||
if (typeof my.setClipboard !== 'function') { this.setData({ copyStatus: '当前淘宝未提供剪贴板 API,请长按下方诊断文字复制。' }); return; }
|
||
var that = this;
|
||
try {
|
||
my.setClipboard({ text: report, success: function () { if (that.alive) that.setData({ copyStatus: '诊断信息已复制。也可长按下方文字复制。' }); },
|
||
fail: function () { if (that.alive) that.setData({ copyStatus: '自动复制失败,请长按下方诊断文字复制。' }); } });
|
||
} catch (_) { this.setData({ copyStatus: '自动复制失败,请长按下方诊断文字复制。' }); }
|
||
},
|
||
onOpenNativeProbe: function () {
|
||
if (this.data.busy || this.data.exporting) return;
|
||
my.redirectTo({ url: '/pages/camnative/camnative' });
|
||
},
|
||
currentCalibration: function (batch) {
|
||
return !!(batch && this.calibrationBatch === batch && batch.sequence === this.sequence && this.alive && this.visible && this.data.running);
|
||
},
|
||
invalidateCalibration: function () {
|
||
if (this.calibrationBatch) this.calibrationBatch.frame = null;
|
||
this.calibrationBatch = null;
|
||
if (this.alive) this.setData({ calibrationItems: [], calibrationReady: false, calibrationText: '' });
|
||
},
|
||
onCalibrate: function () {
|
||
if (!this.alive || !this.visible || !this.data.running) return Promise.resolve();
|
||
if (this.exportInFlight || this.data.busy) {
|
||
this.setData({ errorText: '上一批照片仍在导出,请稍候再校验颜色。' }); return Promise.resolve();
|
||
}
|
||
if (!this.lastFrame) { this.setData({ errorText: '尚未收到真实相机帧,无法校验照片颜色。' }); return Promise.resolve(); }
|
||
var frozen, formats;
|
||
try {
|
||
// Freeze once: every preview and the eventual explicit confirmation refer
|
||
// to the exact same image, not a later native/reused camera buffer.
|
||
frozen = image.freezeCalibrationFrame(this.lastFrame);
|
||
formats = image.frameCalibrationFormats(frozen).filter(function (format) { return CALIBRATION_FORMATS.indexOf(format) >= 0; });
|
||
} catch (error) { this.setData({ errorText: error.message || '当前帧无法进行本地颜色校验。' }); return Promise.resolve(); }
|
||
if (!formats.length) {
|
||
this.setData({ errorText: '当前帧不是可校验的无格式四通道图像,请等待新帧或直接导出照片。' }); return Promise.resolve();
|
||
}
|
||
this.invalidateCalibration();
|
||
var that = this, operation = {}, batch = { sequence: this.sequence, id: this.sequence + '-' + ((this.calibrationSerial || 0) + 1),
|
||
frame: frozen, formats: formats, items: [], settled: false };
|
||
this.calibrationSerial = (this.calibrationSerial || 0) + 1;
|
||
this.calibrationBatch = batch; this.exportInFlight = operation;
|
||
this.setData({ busy: true, exporting: true, errorText: '', snapshotUrl: '', snapshotText: '',
|
||
calibrationText: '正在按顺序生成本地颜色预览;全部完成后,请选择颜色正常的一张。' });
|
||
return formats.reduce(function (chain, format) {
|
||
return chain.then(function () {
|
||
if (!that.currentCalibration(batch)) return;
|
||
return image.captureFrameToBase64(batch.frame, that, { pixelFormat: format, maxSide: 480 }).then(function (url) {
|
||
if (!that.currentCalibration(batch)) return;
|
||
batch.items.push({ format: format, url: url, batchId: batch.id });
|
||
that.setData({ calibrationItems: batch.items.slice(), calibrationText: '已生成 ' + batch.items.length + '/' + batch.formats.length + ' 张,完成后可确认。' });
|
||
});
|
||
});
|
||
}, Promise.resolve()).then(function () {
|
||
batch.settled = true;
|
||
if (that.currentCalibration(batch)) that.setData({ calibrationReady: true, calibrationText: '请比较肤色和背景,点击颜色正常照片下方的按钮;尚未确认任何新格式。' });
|
||
}).catch(function (error) {
|
||
batch.settled = true;
|
||
if (that.currentCalibration(batch)) {
|
||
that.invalidateCalibration();
|
||
that.setData({ errorText: error.message || '颜色预览导出失败,请重试。', calibrationText: '本批颜色预览导出失败,未确认新格式,请重试。' });
|
||
}
|
||
}).then(function () {
|
||
if (!that.currentCalibration(batch)) batch.frame = null;
|
||
if (that.exportInFlight !== operation) return;
|
||
that.exportInFlight = null;
|
||
if (that.alive) {
|
||
var patch = { exporting: false };
|
||
if (batch.sequence === that.sequence) patch.busy = false;
|
||
that.setData(patch);
|
||
}
|
||
});
|
||
},
|
||
onConfirmCalibration: function (event) {
|
||
var batch = this.calibrationBatch, dataset = event && event.currentTarget && event.currentTarget.dataset || {}, format = dataset.format;
|
||
if (!this.currentCalibration(batch) || !batch.settled || String(dataset.batch) !== batch.id || batch.formats.indexOf(format) < 0
|
||
|| !batch.items.some(function (item) { return item.format === format; })) return;
|
||
try {
|
||
var confirmed = image.confirmFramePixelFormat(batch.frame, format);
|
||
if (confirmed !== format) throw new Error('未能确认相机格式,请重新校验。');
|
||
this.invalidateCalibration();
|
||
this.setData({ calibrationConfirmed: confirmed, calibrationText: '本次相机格式已确认:' + confirmed + ',已记录本次颜色选择。', errorText: '' });
|
||
} catch (error) { this.setData({ errorText: error.message || '格式确认失败,请重新校验。' }); }
|
||
},
|
||
acceptsCameraEvent: function (event) {
|
||
if (!this.alive || !this.visible || !this.data.running) return false;
|
||
var target = event && (event.currentTarget || event.target), dataset = target && target.dataset;
|
||
// Native callbacks from an unmounted camera must not enter a new session.
|
||
// Events without a marker remain compatible with older host callbacks.
|
||
if (dataset && dataset.session !== undefined && String(dataset.session) !== String(this.data.cameraSession)) return false;
|
||
var id = target && target.id;
|
||
return typeof id !== 'string' || id.indexOf('test-ar-camera-') !== 0 || id === 'test-ar-camera-' + this.data.cameraSession;
|
||
},
|
||
mountCamera: function (sequence) {
|
||
if (!this.alive || !this.visible || sequence !== this.sequence) return;
|
||
var that = this;
|
||
clearTimeout(this.frameTimer);
|
||
this.frames = 0; this.standardFrames = 0; this.lastFrame = null; this.context = null; this.lastUpdate = 0; this.frameUiShown = false; this.pendingFrameDiagnostic = null; this.contextMethodNames = [];
|
||
this.setData({ running: true, busy: false, cameraSession: sequence, frameCount: 0, standardFrameCount: 0, frameText: '尚未收到帧事件', contextText: '等待 onInit 后读取公开方法名',
|
||
status: '已请求' + (this.data.devicePosition === 'front' ? '前置' : '后置') + '镜头,等待真实帧;请核对实际画面…' });
|
||
this.frameTimer = setTimeout(function () {
|
||
if (that.alive && that.visible && sequence === that.sequence && !that.frames) that.setData({ status: '10秒内未收到帧事件(onARFrame)。请复制诊断,不能视为能力已通过。' });
|
||
}, 10000);
|
||
},
|
||
onStart: function () {
|
||
if (this.data.running || this.data.busy) return Promise.resolve();
|
||
var that = this, sequence = ++this.sequence;
|
||
this.setData({ busy: true, errorText: '', snapshotUrl: '', snapshotText: '', frameCount: 0, poseText: '', fieldText: '' });
|
||
// Camera permission only. Neither Taobao userInfo nor album is needed.
|
||
return permissions.ensure('scope.camera').then(function () {
|
||
if (!that.alive || !that.visible || sequence !== that.sequence) return;
|
||
that.mountCamera(sequence);
|
||
}).catch(function (error) {
|
||
if (that.alive && sequence === that.sequence) that.setData({ errorText: error.message || '相机授权失败' });
|
||
}).then(function () { if (that.alive && sequence === that.sequence) that.setData({ busy: false }); });
|
||
},
|
||
onARInit: function (event) {
|
||
if (!this.acceptsCameraEvent(event)) return;
|
||
this.recordNativeEvent('onInit', event);
|
||
try {
|
||
this.context = AR.context(my, 'test-ar-camera-' + this.data.cameraSession);
|
||
// Enumerate descriptors only. This diagnostic never probes unknown methods
|
||
// or executes property getters/automatic context actions.
|
||
this.contextMethodNames = AR.diagnostics.methods(this.context);
|
||
this.setData({ contextText: this.contextMethodNames.join(', ') || '未发现可读取的公开方法;不代表原生能力不存在。', status: 'AR 初始化完成,等待实时人脸数据…' });
|
||
} catch (error) { this.setData({ errorText: error.message || 'AR 上下文初始化失败' }); }
|
||
},
|
||
onARFrame: function (event) {
|
||
if (!this.acceptsCameraEvent(event)) return;
|
||
this.frames = (this.frames || 0) + 1; this.lastFrame = event && event.detail || event;
|
||
this.recordNativeEvent('onARFrame', event, true);
|
||
clearTimeout(this.frameTimer);
|
||
// Facing selects hardware, not the native landmark coordinate convention.
|
||
// Do not reverse yaw solely because a front camera was requested.
|
||
var verdict;
|
||
try { verdict = AR.evaluate(event, this.data.target, false); } catch (_) { verdict = { frameReceived: false }; }
|
||
if (verdict.frameReceived) this.standardFrames = (this.standardFrames || 0) + 1;
|
||
var now = Date.now();
|
||
if (this.frameUiShown && now - this.lastUpdate < 400) return;
|
||
this.lastUpdate = now; this.frameUiShown = true;
|
||
this.flushFrameDiagnostic();
|
||
var patch = this.nativeEventView();
|
||
patch.frameCount = this.frames; patch.standardFrameCount = this.standardFrames || 0;
|
||
if (!verdict.frameReceived) {
|
||
patch.status = '已收到 onARFrame 回调,但格式不符合 width/height/anchors;请复制诊断。';
|
||
patch.frameText = '已收到原生回调,尚无可解析的标准帧'; patch.poseText = '';
|
||
this.setData(patch);
|
||
return;
|
||
}
|
||
var observation = verdict.observation;
|
||
patch.status = verdict.ok ? '实时人脸判断通过;仍需点击导出照片验证原始图像。' : verdict.hint;
|
||
patch.frameText = verdict.width + '×' + verdict.height + ' · 人脸 ' + verdict.faceCount + ' · 已收到真实帧';
|
||
patch.poseText = observation ? '几何姿态 yaw ' + observation.yaw.toFixed(1) + '° / pitch ' + observation.pitch.toFixed(1) + '° / roll ' + observation.roll.toFixed(1) + '°' : '';
|
||
this.setData(patch);
|
||
},
|
||
onTarget: function (event) {
|
||
var target = event.currentTarget.dataset.target;
|
||
if (['front', 'left', 'right'].indexOf(target) >= 0) this.setData({ target: target });
|
||
},
|
||
onFlip: function () {
|
||
if (!this.alive || !this.visible || !this.data.running) return Promise.resolve();
|
||
var that = this, sequence = ++this.sequence;
|
||
clearTimeout(this.frameTimer);
|
||
this.flushFrameDiagnostic();
|
||
this.invalidateCalibration();
|
||
this.lastFrame = null; this.context = null; this.frames = 0; this.lastUpdate = 0;
|
||
// devicePosition is shared-component compatibility evidence. Its actual
|
||
// effect in the Taobao host must be checked on a phone; no context method
|
||
// or successful callback is treated as proof that hardware changed.
|
||
return new Promise(function (resolve) {
|
||
that.setData({ running: false, busy: true, devicePosition: that.data.devicePosition === 'front' ? 'back' : 'front',
|
||
cameraSession: sequence, frameCount: 0, frameText: '尚未收到真实帧', poseText: '', fieldText: '', errorText: '',
|
||
snapshotUrl: '', snapshotText: '', status: '正在重新挂载相机;切换效果以实际画面为准…' }, function () {
|
||
that.mountCamera(sequence);
|
||
resolve();
|
||
});
|
||
});
|
||
},
|
||
onSnapshot: function () {
|
||
// Image decoding/draw callbacks may still use the shared canvas after a
|
||
// camera session changes. Release this lock only when that export settles.
|
||
if (this.exportInFlight) {
|
||
this.setData({ errorText: '上一帧照片仍在导出,请稍候再试。' });
|
||
return Promise.resolve();
|
||
}
|
||
if (!this.lastFrame || this.data.busy) {
|
||
if (!this.lastFrame) this.setData({ errorText: '尚未收到真实相机帧,无法验证照片导出。' });
|
||
return Promise.resolve();
|
||
}
|
||
var that = this, sequence = this.sequence, operation = {};
|
||
this.exportInFlight = operation;
|
||
this.setData({ busy: true, exporting: true, errorText: '', snapshotUrl: '', snapshotText: '' });
|
||
var work;
|
||
try { work = image.captureFrameToBase64(this.lastFrame, this, { defaultPixelFormat: config.arPixelFormat }); } catch (error) { work = Promise.reject(error); }
|
||
return Promise.resolve(work).then(function (value) {
|
||
if (!that.alive || !that.visible || sequence !== that.sequence) return;
|
||
that.setData({ snapshotUrl: value, snapshotText: 'JPEG ' + that.data.compressWidth + '×' + that.data.compressHeight + ',仅本页预览,未上传、未保存到相册。', status: '已导出真实帧照片,请核对方向和颜色。' });
|
||
}).catch(function (error) {
|
||
if (that.alive && that.visible && sequence === that.sequence) that.setData({ errorText: error.message || '照片导出失败,请记录帧字段与实际错误。' });
|
||
}).then(function () {
|
||
if (that.exportInFlight !== operation) return;
|
||
that.exportInFlight = null;
|
||
if (that.alive) {
|
||
var patch = { exporting: false };
|
||
if (sequence === that.sequence) patch.busy = false;
|
||
that.setData(patch);
|
||
}
|
||
});
|
||
},
|
||
onARStop: function (event) {
|
||
if (!this.acceptsCameraEvent(event)) return;
|
||
this.recordNativeEvent('onStop', event);
|
||
this.invalidateCalibration();
|
||
this.sequence++;
|
||
this.setData({ status: 'AR相机已停止。请重新开启后验证。', running: false, busy: false });
|
||
this.lastFrame = null; this.context = null; clearTimeout(this.frameTimer);
|
||
},
|
||
onError: function (event) {
|
||
if (!this.acceptsCameraEvent(event)) return;
|
||
var summary = this.recordNativeEvent('onError', event);
|
||
this.invalidateCalibration();
|
||
this.sequence++;
|
||
this.lastFrame = null; this.context = null; clearTimeout(this.frameTimer);
|
||
this.setData({ running: false, busy: false, status: 'AR 相机未通过真机验证。', errorText: '收到 AR 错误事件。' + (summary.metadata.join(';') || '未返回可安全展示的错误码,请复制诊断。') });
|
||
},
|
||
onStop: function () {
|
||
this.flushFrameDiagnostic();
|
||
this.invalidateCalibration();
|
||
this.sequence++; this.lastFrame = null; this.context = null; clearTimeout(this.frameTimer);
|
||
if (this.alive) this.setData({ running: false, busy: false, status: '已停止,帧缓存已清空。' });
|
||
},
|
||
onHide: function () {
|
||
this.visible = false; this.onStop();
|
||
if (this.alive) this.setData({ snapshotUrl: '', snapshotText: '', poseText: '', fieldText: '' });
|
||
},
|
||
onUnload: function () { this.onHide(); this.alive = false; }
|
||
});
|