feat: 完善淘宝小程序拍照、报告与登录流程
接入本地实时识别和自动三连拍,调整报告裁切、商品跳转、分享标题及居中页眉;完善游客会话、后台渠道隔离、部署脚本与回归测试。
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
'use strict';
|
||||
// Reproducible CPU/no-DOM benchmark. Prints timings/counts, never pixels/points.
|
||||
const fs = require('node:fs'), path = require('node:path'), vm = require('node:vm'), zlib = require('node:zlib');
|
||||
const root = path.resolve(__dirname, '..'), cache = {};
|
||||
const context = vm.createContext({ console: { log() {}, warn() {}, error() {} }, setTimeout, clearTimeout });
|
||||
function load(file) {
|
||||
file = path.resolve(file); if (cache[file]) return cache[file].exports;
|
||||
const module = { exports: {} }; cache[file] = module;
|
||||
const run = vm.runInContext('(function(exports,require,module){' + fs.readFileSync(file, 'utf8') + '\n})', context, { filename: file });
|
||||
run(module.exports, name => { if (!name.startsWith('.')) throw new Error('Unexpected runtime dependency'); return load(path.resolve(path.dirname(file), name + '.js')); }, module);
|
||||
return module.exports;
|
||||
}
|
||||
const fixture = JSON.parse(fs.readFileSync(path.join(root, 'test/fixtures/native-face-c3.json'), 'utf8'));
|
||||
const source = new Uint8Array(zlib.gunzipSync(Buffer.from(fixture.gzipBase64, 'base64')));
|
||||
const portrait = { width: 540, height: 960, data: new Uint8Array(540 * 960 * 4) };
|
||||
for (let at = 3; at < portrait.data.length; at += 4) portrait.data[at] = 255;
|
||||
for (let y = 0; y < 405; y++) for (let x = 0; x < 540; x++) {
|
||||
const at = (Math.floor(y * fixture.height / 405) * fixture.width + Math.floor(x * fixture.width / 540)) * 4;
|
||||
portrait.data.set(source.subarray(at, at + 4), ((y + 277) * 540 + x) * 4);
|
||||
}
|
||||
const repeatArg = process.argv.find(value => value.startsWith('--repeats='));
|
||||
const repeats = repeatArg ? Math.max(1, Math.min(10, Number(repeatArg.split('=')[1]) || 2)) : 2;
|
||||
const cpuOptimization = !process.argv.includes('--baseline-cpu');
|
||||
const maxSideArg = process.argv.find(value => value.startsWith('--max-side='));
|
||||
const maxSide = maxSideArg ? Number(maxSideArg.split('=')[1]) : 320;
|
||||
const sizesArg = process.argv.find(value => value.startsWith('--input-sizes='));
|
||||
const sizes = sizesArg ? sizesArg.split('=')[1].split(',').map(Number) : [256, 192, 160, 128];
|
||||
const api = load(path.join(root, 'utils/native-face.js'));
|
||||
(async () => {
|
||||
if (process.argv.includes('--guided')) {
|
||||
const seed = api.create({ inputSize: 256, landmarkModel: 'full' });
|
||||
const originalPortrait = await seed.detect(portrait);
|
||||
const reference = await seed.detect({ width: fixture.width, height: fixture.height, data: source });
|
||||
seed.dispose();
|
||||
const box = reference.detections[0].detection.box, scale = 960 * 0.45 / box.height;
|
||||
const centerX = box.x + box.width / 2, centerY = box.y + box.height / 2;
|
||||
for (let y = 0; y < 960; y++) for (let x = 0; x < 540; x++) {
|
||||
const sx = Math.floor((x - 270) / scale + centerX), sy = Math.floor((y - 480) / scale + centerY), to = (y * 540 + x) * 4;
|
||||
portrait.data.fill(0, to, to + 3); portrait.data[to + 3] = 255;
|
||||
if (sx >= 0 && sx < fixture.width && sy >= 0 && sy < fixture.height) portrait.data.set(source.subarray((sy * fixture.width + sx) * 4, (sy * fixture.width + sx) * 4 + 4), to);
|
||||
}
|
||||
process.stdout.write(JSON.stringify({ setup: 'guided-portrait', originalPortraitFaceHeightRatio: originalPortrait.detections[0].detection.box.height / 960, requestedFaceHeightRatio: 0.45 }) + '\n');
|
||||
}
|
||||
for (const landmarkModel of ['full', 'tiny']) for (const inputSize of sizes) {
|
||||
const engine = api.create({ defaultPixelFormat: 'RGBA', maxSide, inputSize, landmarkModel, cpuOptimization });
|
||||
const initialized = Date.now(); await engine.init(); const initMs = Date.now() - initialized;
|
||||
const samples = [];
|
||||
for (let index = 0; index < repeats; index++) {
|
||||
const result = await engine.detect(portrait);
|
||||
samples.push({ ...result.timings, faces: result.faceCount, landmarks: result.detections[0] ? result.detections[0].landmarks.positions.length : 0,
|
||||
faceHeightRatio: result.detections[0] ? result.detections[0].detection.box.height / result.height : 0 });
|
||||
}
|
||||
const mean = {};
|
||||
for (const key of ['snapshotMs', 'modelMs', 'tensorMs', 'detectorMs', 'landmarkMs', 'totalMs']) mean[key] = Math.round(samples.reduce((sum, item) => sum + item[key], 0) / samples.length);
|
||||
const memory = engine.stats(); engine.dispose();
|
||||
process.stdout.write(JSON.stringify({ landmarkModel, inputSize, maxSide, cpuOptimization, frame: '540x960', initMs, mean,
|
||||
allSamplesDetected: samples.every(item => item.faces > 0 && item.landmarks === 68), samples,
|
||||
modelBytes: memory.numBytes, modelTensors: memory.numTensors, afterDisposeTensors: engine.stats().numTensors }) + '\n');
|
||||
}
|
||||
})().catch(error => { process.stderr.write(error.message + '\n'); process.exitCode = 1; });
|
||||
@@ -0,0 +1,44 @@
|
||||
'use strict';
|
||||
// Rasterize the existing H5 vector geometry for the native image component.
|
||||
// No remote assets or generated face shapes are used.
|
||||
const fs = require('node:fs'), path = require('node:path'), vm = require('node:vm');
|
||||
const { chromium } = require('playwright');
|
||||
const root = path.resolve(__dirname, '..'), h5 = path.join(root, '../h5');
|
||||
const out = path.join(root, 'assets/camera');
|
||||
async function main() {
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
const sandbox = { window: {} };
|
||||
vm.runInNewContext(fs.readFileSync(path.join(h5, 'js/camera-geometry.js'), 'utf8'), sandbox);
|
||||
const app = fs.readFileSync(path.join(h5, 'js/app.js'), 'utf8');
|
||||
vm.runInNewContext(app.split('/* camera-outline-pure-begin */')[1].split('/* camera-outline-pure-end */')[0], sandbox);
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 375, height: 726 }, deviceScaleFactor: 2 });
|
||||
for (const [index, name] of ['front', 'left', 'right'].entries()) {
|
||||
const geometry = sandbox.window.CameraGeometry[index];
|
||||
for (const live of [false, true]) {
|
||||
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="375" height="726" viewBox="0 86 375 726">' +
|
||||
'<defs><mask id="cut"><rect x="0" y="86" width="375" height="726" fill="white"/><path d="' + sandbox.smoothCameraOutline(geometry.outline) + '" fill="black"/></mask></defs>' +
|
||||
'<rect x="0" y="86" width="375" height="726" fill="black" fill-opacity=".85" mask="url(#cut)"/>' +
|
||||
'<g fill="none" stroke="' + (live ? '#fff' : '#111') + '" stroke-width="2.5" stroke-dasharray="5 5">' + geometry.guides.map(d => '<path d="' + d + '"/>').join('') + '</g></svg>';
|
||||
await page.setContent('<style>html,body{margin:0;background:transparent}svg{display:block}</style>' + svg);
|
||||
await page.screenshot({ path: path.join(out, 'mask-' + name + (live ? '-live' : '') + '.png'), omitBackground: true });
|
||||
}
|
||||
await page.setContent('<style>html,body{margin:0;background:transparent}svg{display:block}</style><svg xmlns="http://www.w3.org/2000/svg" width="375" height="726" viewBox="0 86 375 726"><path fill="white" d="' + sandbox.smoothCameraOutline(geometry.outline) + '"/></svg>');
|
||||
await page.screenshot({ path: path.join(out, 'hole-' + name + '.png'), omitBackground: true });
|
||||
}
|
||||
for (const name of ['icon-album.png', 'icon-shutter.png', 'icon-switch-camera.png']) {
|
||||
fs.copyFileSync(path.join(h5, 'assets/img/ui-20261009', name), path.join(out, name));
|
||||
}
|
||||
fs.copyFileSync(path.join(h5, 'assets/img/ui-20261012/scan-face-mask.png'), path.join(out, 'scan-face-mask.png'));
|
||||
const data = fs.readFileSync(path.join(h5, 'assets/img/ui-20261012/scan-base.webp')).toString('base64');
|
||||
const png = await page.evaluate(async data => {
|
||||
const image = new Image(); image.src = 'data:image/webp;base64,' + data; await image.decode();
|
||||
const canvas = document.createElement('canvas'); canvas.width = image.width; canvas.height = image.height;
|
||||
canvas.getContext('2d').drawImage(image, 0, 0); return canvas.toDataURL('image/png').split(',')[1];
|
||||
}, data);
|
||||
fs.writeFileSync(path.join(out, 'scan-base.png'), Buffer.from(png, 'base64'));
|
||||
} finally { await browser.close(); }
|
||||
console.log('Built H5 camera geometry, icons and scan artwork in taobao/assets/camera');
|
||||
}
|
||||
main().catch(error => { console.error(error.message); process.exitCode = 1; });
|
||||
@@ -0,0 +1,58 @@
|
||||
'use strict';
|
||||
// Build-time only. The miniapp runtime never reads files or uses Buffer.
|
||||
const fs = require('node:fs'), path = require('node:path'), crypto = require('node:crypto');
|
||||
const source = path.resolve(__dirname, '../../h5/vendor/faceapi');
|
||||
const target = path.resolve(__dirname, '../vendor/native-face');
|
||||
const modelSources = path.resolve(__dirname, '../../reference/native-face-models');
|
||||
const models = [['tiny', 'tiny_face_detector_model', path.join(source, 'models')], ['landmark', 'face_landmark_68_model', path.join(source, 'models')],
|
||||
['landmarkTiny', 'face_landmark_68_tiny_model', modelSources]];
|
||||
const output = {};
|
||||
const sourceFiles = [];
|
||||
function record(file, data) { sourceFiles.push({ path: path.relative(path.resolve(__dirname, '../..'), file).replace(/\\/g, '/'), bytes: data.length,
|
||||
sha256: crypto.createHash('sha256').update(data).digest('hex') }); }
|
||||
let bytes = 0;
|
||||
for (const [key, name, directory] of models) {
|
||||
const manifestPath = path.join(directory, name + '-weights_manifest.json');
|
||||
const manifestBytes = fs.readFileSync(manifestPath); record(manifestPath, manifestBytes);
|
||||
const manifest = JSON.parse(manifestBytes.toString('utf8'));
|
||||
if (manifest.length !== 1) throw new Error('Expected one weight group: ' + name);
|
||||
const data = Buffer.concat(manifest[0].paths.map(file => {
|
||||
if (path.basename(file) !== file) throw new Error('Unexpected model path');
|
||||
const fullPath = path.join(directory, file), content = fs.readFileSync(fullPath); record(fullPath, content); return content;
|
||||
}));
|
||||
bytes += data.length;
|
||||
output[key] = { weights: manifest[0].weights, base64: data.toString('base64') };
|
||||
}
|
||||
const bundle = fs.readFileSync(path.join(source, 'face-api.js'));
|
||||
record(path.join(source, 'face-api.js'), bundle);
|
||||
// Old bundled TFJS needs a registry named global/window/self. Give this copy a
|
||||
// module-local registry and prevent Node environment branches in the miniapp.
|
||||
const adapter = '// Miniapp CPU adapter: private TFJS registry; no Node or DOM environment.\nvar global = {}; var process; var Buffer;\n' +
|
||||
'function nativeFaceNodeUnsupported(name) { throw new Error("Node-only module unavailable in local miniapp CPU engine: " + name); }\n';
|
||||
// The IDE statically resolves require(), even inside unreachable Node branches.
|
||||
// Stub these optional Node-only paths rather than bundling Node polyfills. The
|
||||
// tfjs-node occurrence is only a console-help string; remove it there too.
|
||||
const nodeOnlyModules = ['crypto', 'node-fetch', 'util', 'fs', '@tensorflow/tfjs-node'];
|
||||
const counts = {};
|
||||
const adaptedBundle = bundle.toString('utf8').replace(/\brequire\s*\(\s*(['"])(crypto|node-fetch|util|fs|@tensorflow\/tfjs-node)\1\s*\)/g, (expression, quote, name) => {
|
||||
counts[name] = (counts[name] || 0) + 1;
|
||||
return 'nativeFaceNodeUnsupported(' + quote + name + quote + ')';
|
||||
});
|
||||
for (const name of nodeOnlyModules) if (counts[name] !== 1) throw new Error('Upstream Node branch audit changed: ' + name);
|
||||
if (/\brequire\s*\(/.test(adaptedBundle)) throw new Error('Unreviewed runtime require in face-api bundle');
|
||||
const weightModule = '// Generated by scripts/build-native-face.cjs; quantized source weights, no runtime filesystem.\nmodule.exports = ' + JSON.stringify(output) + ';\n';
|
||||
const provenance = { source: 'Existing h5/vendor/faceapi (unchanged) plus official upstream tiny 68 model in reference/native-face-models', upstream: 'https://github.com/justadudewhohacks/face-api.js',
|
||||
build: 'node taobao/scripts/build-native-face.cjs', verify: 'node taobao/scripts/build-native-face.cjs --check',
|
||||
modifications: ['Module-local TFJS registry', 'Shadow process and Buffer to keep Node branches inactive', 'Node-only require calls replaced with explicit unsupported stubs'],
|
||||
nodeOnlyStubs: counts, quantizedBytes: bytes, sourceFiles, downloadedSources: JSON.parse(fs.readFileSync(path.join(modelSources, 'sources.json'), 'utf8')) };
|
||||
const files = { 'face-api.js': adapter + adaptedBundle, 'weights.js': weightModule, 'provenance.json': JSON.stringify(provenance, null, 2) + '\n' };
|
||||
const check = process.argv.includes('--check');
|
||||
if (!check) fs.mkdirSync(target, { recursive: true });
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
const destination = path.join(target, name), expected = Buffer.from(content);
|
||||
if (check) {
|
||||
if (!fs.existsSync(destination) || !fs.readFileSync(destination).equals(expected)) throw new Error('Generated model bundle differs: ' + name);
|
||||
} else fs.writeFileSync(destination, expected);
|
||||
}
|
||||
process.stdout.write(JSON.stringify({ checked: check, quantizedBytes: bytes, bundleBytes: bundle.length,
|
||||
sourceSHA256: crypto.createHash('sha256').update(bundle).digest('hex') }) + '\n');
|
||||
@@ -28,9 +28,10 @@ http.createServer(async (req, res) => {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const raw = Buffer.concat(chunks).toString('utf8');
|
||||
if (url.pathname === '/api/taobao/session') {
|
||||
url.search = new URLSearchParams({ app_key: process.env.TAOBAO_APP_KEY,
|
||||
open_id: 'local-development-user', sign_method: 'md5' }).toString();
|
||||
if (['/api/taobao/session', '/api/taobao/guest-session', '/api/taobao/me'].includes(url.pathname)) {
|
||||
const query = { app_key: process.env.TAOBAO_APP_KEY, sign_method: 'md5' };
|
||||
if (url.pathname !== '/api/taobao/guest-session') query.open_id = 'local-development-user';
|
||||
url.search = new URLSearchParams(query).toString();
|
||||
const params = Object.fromEntries(url.searchParams);
|
||||
url.searchParams.set('sign', auth.signature(params, raw, process.env.TAOBAO_APP_SECRET));
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use strict';
|
||||
const fs = require('node:fs'), path = require('node:path'), crypto = require('node:crypto');
|
||||
const root = path.resolve(__dirname, '..'), check = process.argv.includes('--check');
|
||||
const files = ['skin-score.js', 'report-ui.js', 'report-copy.js'];
|
||||
const files = ['skin-score.js', 'report-ui.js', 'report-copy.js', 'head-pose.js', 'capture-quality.js'];
|
||||
const manifest = {};
|
||||
for (const name of files) {
|
||||
const source = fs.readFileSync(path.join(root, '../h5/js', name));
|
||||
@@ -15,4 +15,4 @@ for (const name of files) {
|
||||
const dest = path.join(root, 'vendor/manifest.json'), expected = JSON.stringify(manifest, null, 2) + '\n';
|
||||
if (check) { if (fs.readFileSync(dest, 'utf8') !== expected) throw Error('Shared manifest drift'); }
|
||||
else fs.writeFileSync(dest, expected);
|
||||
console.log(check ? 'PASS: scoring, report UI and copy match H5 byte for byte' : 'Synced shared logic from H5 (source files unchanged)');
|
||||
console.log(check ? 'PASS: scoring, report UI, copy and capture gates match H5 byte for byte' : 'Synced shared logic from H5 (source files unchanged)');
|
||||
|
||||
Reference in New Issue
Block a user