fix(h5): optimize camera flipping and angle haptics, refresh splash branding

This commit is contained in:
xsl
2026-10-13 17:57:55 +08:00
parent e71368db6d
commit 7381306139
19 changed files with 2125 additions and 1282 deletions
+121
View File
@@ -0,0 +1,121 @@
// Canvas cameras and intercepted auth; no personal images, writes or paid API.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { execSync } from 'node:child_process';
import { fileURLToPath, pathToFileURL } from 'node:url';
const { chromium } = await import(pathToFileURL(execSync('npm root -g', { encoding: 'utf8' }).trim() + '/playwright/index.mjs').href);
const browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required'] });
try {
const page = await browser.newPage({ viewport: { width: 375, height: 726 } });
const errors = [];
page.on('pageerror', e => errors.push(e.message));
await page.route('**/api/**', route => {
assert.equal(route.request().method(), 'GET', 'no account/report writes');
return route.fulfill({ json: { ok: true, user: { id: 'camera-fixture', kind: 'browser' } } });
});
const source = fs.readFileSync(fileURLToPath(new URL('../js/app.js', import.meta.url)), 'utf8');
await page.route('**/js/app.js*', route => route.fulfill({ contentType: 'text/javascript', body: source.replace(/\}\)\(\);\s*$/, `
window.__cameraTest = {
enter: function() { showScreen('s-cam'); startCamera(); renderCamStep(); },
stop: stopCamera,
capture: function(value) { shutterLock = value; },
startDetection: function() { modelsOk = true; detectDisabled = false; IS_MOCK = false; maybeStartDetect(); },
state: function() { return { live: camLive, busy: camBusy, facing: camFacing, fallback: camFallback }; }
};
})();`) }));
await page.addInitScript(() => {
const nativeTimeout = window.setTimeout.bind(window);
window.setTimeout = (fn, ms, ...args) => nativeTimeout(fn, window.__shortCameraTimeout && ms === 12000 ? 200 : ms, ...args);
window.__calls = []; window.__delay = 0; window.__fail = false; window.__ignoreFacing = false;
window.__streams = []; window.__frameAt = 0;
navigator.mediaDevices.getUserMedia = async constraints => {
const facing = constraints.video.facingMode.ideal;
window.__calls.push(facing);
const fail = window.__fail, delay = window.__delay;
window.__fail = false;
await new Promise(r => setTimeout(r, delay));
if (fail) throw new Error('fixture camera unavailable');
const canvas = document.createElement('canvas'); canvas.width = 640; canvas.height = 480;
const ctx = canvas.getContext('2d');
ctx.fillStyle = facing === 'user' ? '#316a94' : '#d18e48'; ctx.fillRect(0, 0, 640, 480);
const stream = canvas.captureStream(20), track = stream.getVideoTracks()[0];
const settings = track.getSettings.bind(track);
track.getSettings = () => ({ ...settings(), facingMode: window.__ignoreFacing ? 'user' : facing });
const timer = setInterval(() => ctx.fillRect(0, 0, 640, 480), 40);
const stop = track.stop.bind(track); track.stop = () => { clearInterval(timer); stop(); };
window.__streams.push(stream);
return stream;
};
document.addEventListener('DOMContentLoaded', () => {
document.querySelector('#cam-video').addEventListener('loadeddata', () => { window.__frameAt = performance.now(); });
});
});
await page.goto((process.env.H5_BASE_URL || 'http://127.0.0.1:8080/') + '?mock=1');
await page.evaluate(() => window.__cameraTest.enter());
const live = () => page.waitForFunction(() => window.__cameraTest.state().live);
await live();
for (const delay of [0, 1000]) {
await page.evaluate(delay => { window.__delay = delay; document.querySelector('.cam-flip').click(); }, delay);
assert(await page.locator('#cam-frozen').isVisible(), 'frozen preview during switching');
const count = await page.evaluate(() => window.__calls.length);
await page.evaluate(() => document.querySelector('.cam-flip').click());
assert.equal(await page.evaluate(() => window.__calls.length), count, 'rapid click ignored');
await live();
const extra = await page.evaluate(() => performance.now() - window.__frameAt);
assert(extra < 500, `first-frame to visible latency ${extra}ms, no fixed 900ms wait`);
assert(!(await page.locator('#cam-frozen').isVisible()));
console.log('flip delay', delay, 'first-frame to visible', Math.round(extra), 'ms');
}
const before = await page.evaluate(() => window.__cameraTest.state().facing);
await page.evaluate(() => { window.__delay = 0; window.__fail = true; document.querySelector('.cam-flip').click(); });
await live();
assert.equal(await page.evaluate(() => window.__cameraTest.state().facing), before, 'failed flip restores camera');
assert.equal(await page.evaluate(() => window.__cameraTest.state().fallback), false);
await page.evaluate(() => { window.__ignoreFacing = true; document.querySelector('.cam-flip').click(); });
await live();
assert.equal(await page.evaluate(() => window.__cameraTest.state().facing), before, 'ignored facing is detected/restored');
await page.evaluate(() => {
window.__ignoreFacing = false; window.__shortCameraTimeout = true; window.__delay = 600;
document.querySelector('.cam-flip').click();
window.__delay = 0; // restoration succeeds while the original request is still pending
});
await live();
assert.equal(await page.evaluate(() => window.__cameraTest.state().facing), before, 'timeout restores original camera');
await page.waitForTimeout(700);
assert(await page.evaluate(() => window.__streams.slice(0, -2).every(s => s.getTracks().every(t => t.readyState === 'ended'))));
await page.evaluate(() => { window.__shortCameraTimeout = false; });
const count = await page.evaluate(() => window.__calls.length);
await page.evaluate(() => { window.__cameraTest.capture(true); document.querySelector('.cam-flip').click(); window.__cameraTest.capture(false); });
assert.equal(await page.evaluate(() => window.__calls.length), count, 'capture blocks flip');
// Pending inference from the old camera must neither update UI nor fork loops.
await page.evaluate(() => {
window.__detectCalls = 0; window.__concurrent = 0; window.__maxConcurrent = 0;
window.faceapi = {
nets: { tinyFaceDetector: { loadFromUri: () => Promise.resolve() }, faceLandmark68Net: { loadFromUri: () => Promise.resolve() } },
TinyFaceDetectorOptions: function() {},
detectAllFaces: function() { return { withFaceLandmarks: function() {
window.__detectCalls++; window.__concurrent++;
window.__maxConcurrent = Math.max(window.__maxConcurrent, window.__concurrent);
return new Promise(resolve => { window.__resolveDetect = () => { window.__concurrent--; resolve([]); }; });
} }; }
};
window.__cameraTest.startDetection();
});
await page.waitForFunction(() => window.__detectCalls === 1);
await page.evaluate(() => { window.__ignoreFacing = false; document.querySelector('.cam-flip').click(); });
await live();
await page.waitForTimeout(600);
assert.equal(await page.evaluate(() => window.__detectCalls), 1, 'do not overlap old inference');
await page.evaluate(() => window.__resolveDetect());
await page.waitForFunction(() => window.__detectCalls === 2);
await page.evaluate(() => window.__resolveDetect());
await page.waitForFunction(() => window.__detectCalls === 3);
assert.equal(await page.evaluate(() => window.__maxConcurrent), 1, 'single detection loop');
await page.evaluate(() => { window.__delay = 600; document.querySelector('.cam-flip').click(); window.__cameraTest.stop(); });
await page.waitForTimeout(900);
assert.equal(await page.evaluate(() => window.__cameraTest.state().live), false, 'late stream cannot revive stopped camera');
assert(await page.evaluate(() => window.__streams.every(s => s.getTracks().every(t => t.readyState === 'ended'))), 'all streams released');
assert.equal(await page.evaluate(() => document.querySelector('#cam-frozen').width), 0, 'frozen face released');
assert.deepEqual(errors, []);
console.log('PASS camera flip: first frame, frozen preview, rapid clicks, recovery/timeout, capture lock, stale inference, cleanup');
} finally { await browser.close(); }
+6
View File
@@ -31,6 +31,7 @@ assert(!check(null).ok); assert(!check(face(), { count: 2 }).ok);
for (const yaw of [-14, 14]) assert(check(face(yaw)).ok, 'former strict front limit now passes');
for (const yaw of [-16, 16, -45, 45]) {
assert(!check(face(yaw)).ok);
assert.equal(check(face(yaw)).reason, 'angle');
assert.equal(check(face(yaw)).hint, '请正对屏幕', 'front correction must not ask for a side view before capture');
assert.equal(check(face(yaw), { mirrored: true }).hint, '请正对屏幕');
}
@@ -49,6 +50,11 @@ assert(check(face(-45), { target: 'right', mirrored: true }).ok);
assert(check(face(-45), { target: 'right', ignoreDirection: true }).ok);
assert(!check(face(25), { target: 'left', ignoreDirection: true }).ok);
for (const luma of [null, 50, 240, NaN]) assert(!check(face(), { luma }).ok);
assert.equal(check(face(), { luma: 50 }).reason, 'quality', 'lighting must not trigger angle vibration');
assert.equal(check(null).reason, 'quality');
assert.equal(check(face(), { count: 2 }).reason, 'quality');
const brokenLandmarks = face(); brokenLandmarks.landmarks.positions = [];
assert.equal(check(brokenLandmarks).reason, 'quality', 'unreadable pose is not confirmed wrong angle');
for (const luma of [70, 230]) assert(check(face(), { luma }).ok);
assert(check(face(), { offsetX: .15 }).ok);
assert(!check(face(), { offsetX: .17 }).ok);
+42 -10
View File
@@ -36,8 +36,8 @@ assert.equal(JSON.stringify(geometry.window.CameraGeometry), original);
for (const value of [null, '', 'M--Z', 'M1,2L1,2Z', 'M0,0L0,0L0,0Z', 'M0,0Q1,2 3,4Z']) assert.equal(context.smoothCameraOutline(value), value);
for (const nav of [null, {}, { vibrate: false }, { vibrate: () => false }, { vibrate: () => { throw Error('denied'); } }]) assert.equal(context.captureHaptic(nav), false);
let pattern;
assert(context.captureHaptic({ vibrate: p => { pattern = Array.from(p); return true; } }));
assert.deepEqual(pattern, [70, 40, 70]);
assert(context.captureHaptic({ vibrate: p => { pattern = p; return true; } }));
assert.equal(pattern, 30);
// Exercise the actual readiness state machine; only camera/pose inputs and clock
// are stubs; the relaxed shared hold policy now requires 450ms/2 frames.
@@ -47,7 +47,8 @@ const state = {
faceOffsetX: () => 0, CENTER_TOL: .18, CAM_STEPS: [{ target: 'front' }], camStep: 0,
HeadPose: { check: () => ({ ok: true }) }, sideYawFlip: false, faceLuma: () => 100,
LUMA_DARK: 65, LUMA_BRIGHT: 235, window: {}, setMask: () => {}, setHint: () => {},
okSince: 0, readyHapticAt: 0, OK_HOLD_MS: 450, Date: { now: () => now },
okSince: 0, OK_HOLD_MS: 450, Date: { now: () => now },
shutterLock: false, capturing: false, camBusy: false, camLive: true, currentScreen: 's-cam', document: { hidden: false },
holdState: null, CaptureQuality: {
evaluate: d => ({ ok: d.detection.box.height >= 100, hint: '保持不动', observation: { yaw: 0, pitch: 0, center_x: .5, center_y: .5, ratio: .5 } }),
hold: require('../js/capture-quality').hold
@@ -56,13 +57,13 @@ const state = {
};
vm.runInNewContext(extract('camera-haptic-pure') + source.slice(source.indexOf(' function onDetect(d)'), source.indexOf(' /* ─── 拍照流程')), state);
const face = { detection: { box: { height: 250 } }, landmarks: { positions: [] } };
state.onDetect(face); assert.equal(buzzes, 1); assert.equal(captures, 0);
now += 200; state.onDetect(face); assert.equal(buzzes, 1); assert.equal(captures, 0);
now += 250; state.onDetect(face); assert.equal(buzzes, 1); assert.equal(captures, 1);
state.onDetect(face); assert.equal(buzzes, 0); assert.equal(captures, 0);
now += 200; state.onDetect(face); assert.equal(buzzes, 0); assert.equal(captures, 0);
now += 250; state.onDetect(face); assert.equal(buzzes, 0); assert.equal(captures, 1);
now += 100; state.onDetect({ ...face, detection: { box: { height: 20 } } });
now += 100; state.onDetect(face); assert.equal(buzzes, 1, 'jitter must not repeatedly buzz');
now += 100; state.onDetect(face); assert.equal(buzzes, 0, 'good angles must not buzz');
now += 2100; state.onDetect({ ...face, detection: { box: { height: 20 } } });
state.onDetect(face); assert.equal(buzzes, 2);
state.onDetect(face); assert.equal(buzzes, 0);
state.navigator.vibrate = () => { throw Error('blocked'); };
now += 300; state.onDetect(face);
now += 150; state.onDetect(face); assert.equal(captures, 2, 'vibration failure must not prevent capture');
@@ -74,8 +75,39 @@ for (const manual of [true, false]) {
$: () => ({ classList: { add: () => {} } }), setTimeout: (_, ms) => scheduled.push(ms) };
vm.runInNewContext(extract('camera-haptic-pure') + source.slice(source.indexOf(' function doCapture()'), source.indexOf(' // 三张拍完:按视角重排')), shutter);
shutter.doCapture();
assert.equal(manualBuzzes, manual ? 1 : 0, 'auto must not double buzz; manual must retain its cue');
assert.equal(manualBuzzes, 0, 'neither auto nor manual shutter should buzz');
assert(shutter.shutterLock && shutter.capturing);
assert.deepEqual(scheduled, [300], 'blocked haptic must still schedule camera capture');
}
console.log('PASS: three bounded rounded contours; haptic safety, pre-capture cue, jitter throttling; relaxed 450ms/2-frame hold');
// Drive actual onDetect with angle-only failures, not UI hint string matching.
const events = [];
state.navigator.vibrate = p => { events.push({ p, at: now }); return true; };
state.CaptureQuality.evaluate = () => ({ ok: false, reason: 'angle', hint: '请正对屏幕' });
state.onDetect(face);
now += 999; state.onDetect(face); assert.equal(events.length, 0);
now += 1; state.onDetect(face); assert.equal(events.length, 1); assert.equal(events[0].p, 30);
for (let i = 0; i < 3; i++) { now += 1000; state.onDetect(face); }
assert.equal(events.length, 1, 'no repeat before 4 seconds');
now += 1000; state.onDetect(face); assert.equal(events.length, 2);
state.onDetect(null); assert.equal(events.at(-1).p, 0, 'missing face cancels vibration immediately');
const positives = () => events.filter(e => e.p > 0).length;
state.onDetect(face); now += 999; state.onDetect(face); assert.equal(positives(), 2, 'new continuous interval required');
state.CaptureQuality.evaluate = () => ({ ok: false, reason: 'quality', hint: '请靠近一点' });
now += 10000; state.onDetect(face); assert.equal(positives(), 2, 'distance/lighting failure does not buzz');
state.CaptureQuality.evaluate = () => ({ ok: false, reason: 'angle', hint: '请正对屏幕' });
state.onDetect(face); now += 1000; state.onDetect(face); assert.equal(positives(), 3);
state.CaptureQuality.evaluate = () => ({ ok: false, reason: 'quality', hint: '请到光线充足处' });
state.onDetect(face); assert.equal(events.at(-1).p, 0, 'valid angle cancels even when lighting fails');
for (const [key, value] of [['camBusy', true], ['shutterLock', true], ['capturing', true], ['camLive', false], ['currentScreen', 's-report']]) {
const previous = state[key]; state[key] = value;
state.CaptureQuality.evaluate = () => ({ ok: false, reason: 'angle' });
state.onDetect(face); now += 5000; state.onDetect(face);
assert.equal(positives(), 3, key + ' blocks reminders'); state[key] = previous;
}
state.document.hidden = true; state.onDetect(face); now += 5000; state.onDetect(face); assert.equal(positives(), 3);
state.document.hidden = false;
for (const nav of [{}, { vibrate: () => false }, { vibrate: () => { throw Error('blocked'); } }]) {
state.navigator = nav; state.resetAngleHaptic();
state.onDetect(face); now += 1000; assert.doesNotThrow(() => state.onDetect(face));
}
console.log('PASS: rounded contours; 1s invalid-angle cue, 4s throttle, cancellation/lifecycle, unsupported safety; unchanged 450ms/2-frame capture hold');
+35
View File
@@ -0,0 +1,35 @@
// Read-only fixture: no accounts/reports created.
import assert from 'node:assert/strict';
import { execSync } from 'node:child_process';
import { pathToFileURL } from 'node:url';
const { chromium } = await import(pathToFileURL(execSync('npm root -g', { encoding:'utf8' }).trim() + '/playwright/index.mjs').href);
const browser = await chromium.launch();
try {
const page = await browser.newPage({ viewport:{ width:375, height:726 } });
await page.route('**/api/**', route => {
assert.equal(route.request().method(), 'GET');
return route.fulfill({ json:{ ok:true, user:null } });
});
await page.goto(process.env.H5_BASE_URL || 'http://127.0.0.1:8080/');
await page.click('[data-action="guest-experience"]');
await page.waitForSelector('#s-splash.active');
const brand = page.locator('#s-splash .splash-slogan');
assert.match(await brand.getAttribute('aria-label'), /^1'me/);
await page.waitForFunction(() => [...document.querySelectorAll('#s-splash .splash-slogan img')].every(i => i.complete && i.naturalWidth));
assert.equal(await page.locator('#s-splash .splash-slogan-brand').isVisible(), true);
assert.equal(await page.locator('#s-splash .splash-slogan-brand').textContent(), "1'me");
assert.equal(await page.locator('#s-splash .splash-slogan-brand').evaluate(el => getComputedStyle(el).fontWeight), '400');
const button = page.locator('#s-splash [data-action="open-privacy"]');
await button.focus();
const style = await button.evaluate(el => {
const s = getComputedStyle(el);
return { border:s.borderTopWidth, outline:s.outlineStyle, shadow:s.boxShadow, background:s.backgroundColor };
});
assert.deepEqual(style, { border:'0px', outline:'none', shadow:'none', background:'rgb(245, 240, 120)' });
await button.evaluate(el => el.blur());
await page.waitForTimeout(400); // finish the entry screen fade before the screenshot
await page.screenshot({ path:'/tmp/juzi-splash-brand-20261013.png' });
await button.click();
await page.waitForSelector('#p-privacy.show');
console.log('PASS splash brand, borderless focused start button, yellow fill, privacy entry');
} finally { await browser.close(); }