Files
juzi/h5/test/capture-quality-browser.mjs
T

159 lines
11 KiB
JavaScript

// Real local detector + synthetic video containing existing three-view photos.
// Auth/analysis are in-memory fixtures: no account/report writes or paid calls.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { execSync } from 'node:child_process';
import { pathToFileURL, fileURLToPath } from 'node:url';
const { chromium } = await import(pathToFileURL(execSync('npm root -g', { encoding: 'utf8' }).trim() + '/playwright/index.mjs').href);
const base = process.env.H5_BASE_URL || 'http://127.0.0.1:8080/';
const browser = await chromium.launch({ headless: true, args: ['--autoplay-policy=no-user-gesture-required'] });
try {
const page = await browser.newPage({ viewport: { width: 375, height: 726 } });
page.setDefaultTimeout(60000);
const errors = [], failedRequests = [];
page.on('pageerror', e => errors.push(e.message));
page.on('requestfailed', request => failedRequests.push({ path: new URL(request.url()).pathname, error: request.failure().errorText }));
await page.route('**/api/auth/session', route => route.fulfill({ json: { ok: true, user: { id: 'local-camera-fixture', kind: 'browser' } } }));
for (const name of ['C3_180.jpg', 'C3_135.jpg', 'C3_-135.jpg']) {
const body = fs.readFileSync(fileURLToPath(new URL('../../reference/facepp-skinanalyze/examples/images/' + name, import.meta.url)));
await page.route('**/sample-' + name, route => route.fulfill({ contentType: 'image/jpeg', body }));
}
await page.addInitScript(() => {
// Record actual decoded audio starts, not preload requests. Include the
// visible capture step so an early "turn left" instruction is detectable.
const ids = new WeakMap(), buffers = new WeakMap(), originalFetch = window.fetch;
window.__voice = [];
window.fetch = function(url, options) {
const match = String(url).match(/voice\/([a-z0-9-]+)\.mp3/);
return originalFetch.call(this, url, options).then(response => {
if (match) {
const original = response.arrayBuffer.bind(response);
response.arrayBuffer = () => original().then(data => { ids.set(data, match[1]); return data; });
}
return response;
});
};
const OriginalAudioContext = window.AudioContext || window.webkitAudioContext;
function AudioRecorder(...args) {
const context = new OriginalAudioContext(...args), decode = context.decodeAudioData.bind(context);
context.decodeAudioData = (data, ok, error) => decode(data, buffer => { buffers.set(buffer, ids.get(data)); if (ok) ok(buffer); }, error);
const create = context.createBufferSource.bind(context);
context.createBufferSource = () => {
const source = create(), start = source.start.bind(source);
source.start = (...args) => {
window.__voice.push({ id: buffers.get(source.buffer), at: Date.now(), duration: source.buffer.duration * 1000, step: document.querySelector('#cam-step').textContent });
return start(...args);
};
return source;
};
return context;
}
AudioRecorder.prototype = OriginalAudioContext.prototype;
window.AudioContext = window.webkitAudioContext = AudioRecorder;
const canvas = document.createElement('canvas'); canvas.width = 800; canvas.height = 600;
let currentImage, offsetX = 0;
function draw() {
if (!currentImage) return;
const c = canvas.getContext('2d'); c.fillStyle = '#111'; c.fillRect(0, 0, canvas.width, canvas.height);
c.drawImage(currentImage, offsetX, 0, canvas.width, canvas.height);
}
setInterval(draw, 100);
window.__cameraFixture = { canvas, set: async name => {
const image = new Image(); image.src = 'sample-' + name; await image.decode();
// Simulate a person following the centering prompt, not an off-center
// landscape source stretched into a portrait camera frame.
let nextOffset = 0;
if (window.faceapi && faceapi.nets.faceLandmark68Net.isLoaded) {
const faces = await faceapi.detectAllFaces(image, new faceapi.TinyFaceDetectorOptions({ inputSize: 416, scoreThreshold: .5 }));
if (faces.length === 1) nextOffset = canvas.width / 2 - (faces[0].box.x + faces[0].box.width / 2) * canvas.width / image.naturalWidth;
}
currentImage = image; offsetX = nextOffset; draw();
} };
navigator.mediaDevices.getUserMedia = async () => {
await window.__cameraFixture.set('C3_135.jpg'); // invalid front: must not prompt a next-step turn
return canvas.captureStream(10);
};
});
await page.route('**/api/skin-analyze', async route => {
const fixture = await page.evaluate(() => ({ ok: true, result: SkinScore.MOCK_RESULT, side: SkinScore.MOCK_SIDE, report_saved: true }));
await route.fulfill({ json: fixture });
});
await page.goto(base); await page.click('[data-action="open-privacy"]');
assert.equal(await page.title(), "1'me肌肤状态检测");
await page.click('#privacy-check'); await page.click('[data-action="agree-continue"]');
for (const selector of ['#pick-birth', '#pick-gender', '#pick-region']) {
await page.click(selector); await page.click('.ms-mobile-select.ms-show .ms-ensure'); await page.waitForTimeout(400);
}
await page.click('[data-action="submit-profile"]'); await page.click('[data-action="start-camera"]');
try {
await page.waitForFunction(() => window.faceapi && faceapi.nets.faceLandmark68Net.isLoaded && document.querySelector('#s-cam').classList.contains('cam-live'));
} catch (error) {
console.log(await page.evaluate(() => ({ status: document.querySelector('#cam-status').textContent, hint: document.querySelector('#cam-hint').textContent, toast: document.querySelector('#toast').textContent, width: document.querySelector('#cam-video').videoWidth, classes: document.querySelector('#s-cam').className, engine: !!window.faceapi })), errors, failedRequests);
throw error;
}
assert.equal(await page.locator('#s-cam.cam-manual').count(), 0, 'real automatic pipeline, not manual bypass');
await page.evaluate(() => {
window.__holdSamples = []; window.__hints = []; window.__evaluations = [];
const evaluate = CaptureQuality.evaluate;
CaptureQuality.evaluate = function(d, options) {
const result = evaluate(d, options);
window.__evaluations.push({ target: options.target, ok: result.ok, hint: result.hint });
return result;
};
const hold = CaptureQuality.hold;
CaptureQuality.hold = function(state, obs, now) {
const result = hold(state, obs, now);
window.__holdSamples.push({ gap: state ? now - state.lastAt : null, frames: result.state && result.state.frames, ready: result.ready, obs });
if (window.__holdSamples.length > 8) window.__holdSamples.shift();
return result;
};
new MutationObserver(() => { window.__hints.push(document.querySelector('#cam-hint').textContent); if (window.__hints.length > 20) window.__hints.shift(); }).observe(document.querySelector('#cam-hint'), { childList: true, subtree: true });
});
await page.evaluate(() => window.__cameraFixture.set('C3_135.jpg'));
await page.waitForFunction(() => window.__evaluations.some(row => row.target === 'front' && !row.ok && row.hint === '请正对屏幕'));
await page.waitForFunction(() => window.__voice.some(row => row.id === 'hint-front'));
assert((await page.locator('#cam-step').textContent()).includes('1/3'), 'invalid frontal angle never advances');
assert.equal(await page.evaluate(() => window.__voice.some(row => /^hint-turn-|^step[23]-|^shot-done$/.test(row.id))), false, 'no turn/next-step/completion audio before a successful front photo');
await page.evaluate(() => window.__cameraFixture.set('C3_180.jpg'));
try { await page.waitForFunction(() => document.querySelector('#cam-step').textContent.includes('2/3')); }
catch (error) {
console.log(await page.evaluate(async () => {
const v = document.querySelector('#cam-video');
const faces = await faceapi.detectAllFaces(v, new faceapi.TinyFaceDetectorOptions({ inputSize: 416, scoreThreshold: .5 })).withFaceLandmarks();
return { status: document.querySelector('#cam-status').textContent, hint: document.querySelector('#cam-hint').textContent,
step: document.querySelector('#cam-step').textContent, size: [v.videoWidth, v.videoHeight, v.clientWidth, v.clientHeight],
ratio: window.__dbgRatio, faces: faces.map(d => ({ score: d.detection.score, pose: HeadPose.estimate(d.landmarks.positions, false), box: d.detection.box })), holds: window.__holdSamples, hints: window.__hints };
}).then(value => JSON.stringify(value, null, 2)), errors); throw error;
}
await page.waitForFunction(() => window.__voice.some(row => row.id === 'step2-rightface'));
const firstVoice = await page.evaluate(() => window.__voice);
const completion = firstVoice.find(row => row.id === 'shot-done'), turn = firstVoice.find(row => row.id === 'step2-rightface');
assert(completion && turn && completion.step.includes('1/3') && turn.step.includes('2/3'));
assert(turn.at >= completion.at + completion.duration - 100, 'turn instruction waits for captured-front completion speech to finish');
await page.waitForFunction(() => window.__evaluations.some(row => row.target === 'right' && !row.ok));
assert((await page.locator('#cam-step').textContent()).includes('2/3'), 'front photo cannot pass the side gate');
await page.evaluate(() => window.__cameraFixture.set('C3_135.jpg'));
await page.waitForFunction(() => document.querySelector('#cam-step').textContent.includes('3/3'));
await page.waitForFunction(() => window.__evaluations.some(row => row.target === 'left' && !row.ok));
assert((await page.locator('#cam-step').textContent()).includes('3/3'), 'wrong-direction side cannot pass');
const sent = page.waitForRequest(r => r.url().endsWith('/api/skin-analyze'));
await page.evaluate(() => window.__cameraFixture.set('C3_-135.jpg'));
const body = (await sent).postDataJSON();
assert.equal(await page.locator('#scan-photo').count(), 0, 'live waiting does not display the captured face');
assert.equal(await page.locator('#scan-artwork').count(), 1, 'waiting retains original PSD head');
await page.waitForSelector('#s-report.active');
assert.equal(await page.locator('#p-analyzing.show').count(), 0, 'waiting is cleared after success');
for (const key of ['front', 'left', 'right']) {
const row = body.capture_diagnostics.views[key];
assert.equal(row.mode, 'live'); assert.equal(row.direction_checked, true);
assert(row.score >= .4 && row.luma >= 65 && row.luma <= 235);
assert(Math.abs(row.roll) <= 15);
}
assert(Math.abs(body.capture_diagnostics.views.front.yaw) <= 15);
assert(body.capture_diagnostics.views.left.yaw <= -30);
assert(body.capture_diagnostics.views.right.yaw >= 30);
assert(await page.evaluate(() => document.querySelector('#cam-video').srcObject === null), 'camera released after success');
assert.deepEqual(errors, []);
console.log('PASS: actual local models + automatic three-view capture + final JPEG revalidation; invalid front stays on step 1 with neutral audio, no early turn; actual decoded shot-done finishes before side guidance; wrong-direction frames blocked; approved samples/camera cleanup; no paid call/backend writes');
} finally { await browser.close(); }