themblem/scanner/pages/emblemscanner/emblemscanner.js
2025-10-29 21:27:29 +00:00

910 lines
27 KiB
JavaScript

// QR Scanner Module - Self-contained QR scanning page
// Adapted from existing camera implementation
//
// SCANNING MODES:
// 1. Web View Mode - Uses external camera-5.1 web interface in web-view component
// - Fallback for devices that need special handling
// - Configured via camera rules API (web_view: true)
// - Redirects back with wx_redirect_to parameter
//
// 2. Plain WASM Mode - Direct WASM processing in main thread
// - Uses qrtool.wx.js/qrtool.wx.wasm for QR detection
// - Camera frame capture via takePhoto() API
// - Suitable for most Android devices
//
// 3. Worker WASM Mode - WASM processing in worker thread (experimental)
// - Uses efficient camera frame API for high-performance processing
// - Enabled for iPhone devices (phone model contains "iphone")
// - Reduces main thread blocking during QR processing
//
// RUNTIME CONFIGURATION:
// - Camera rules fetched from API based on phone model substring matching
// - Default behavior: Plain WASM mode for most devices
// - iPhone detection: phone_model.toLowerCase().includes('iphone') → Worker WASM
// - Special device rules: camera rules API can override with web_view: true
// - no_web_view parameter can override web_view rules for testing
// Import utility functions from library
const {
get_system_info,
get_camera_rule,
make_query,
fetch_real_ip,
get_tenant_id,
is_emblem_qr_pattern
} = require('./libemblemscanner.js');
// Import upload functionality for verification (self-contained)
const {
upload_image_data_urls
} = require('./upload.js');
// Import precheck utilities for image processing
// Note: We now use our own data_url_from_frame from qrprocessor.js
// Import QR processing module
const {
load_qrtool,
is_qrtool_ready,
process_frame,
make_hint_text,
data_url_from_frame
} = require('./qrprocessor.js');
Page({
/**
* Page initial data
*/
data: {
hint_text: '查找二维码',
enable_debug: false,
camera_flash: 'off',
phone_model: 'unknown',
zoom: -1,
max_zoom: 1,
show_tip: false,
show_modal: '',
busy: true,
should_check_auto_torch: true,
done_checking_auto_torch: false,
camera_sensitivity: 1,
qrarc_class: 'sm',
qrmarkers_class: 'hidden',
frame_upload_interval_ms: 2000,
return_page: '/pages/test_result_page/test_result_page', // Page to navigate to after successful scan
server_url: 'https://themblem.com', // Default server URL
real_ip: '', // User's real IP address
tenant_id: '', // Tenant identifier
debug_msgs: [],
debug_image_data_url: '',
debug_last_result: null,
debug_current_frame_url: '', // Current frame being processed
qrtool_ready: false,
// Frame processing statistics
frames_processed: 0,
frames_skipped: 0,
ok_frames: 0,
total_processing_time: 0,
avg_processing_time_ms: 0,
last_frame_time_ms: 0,
rule_zoom: -1,
camera_rule: null,
use_web_view: false,
use_worker: false,
emblem_camera_url: null,
first_qr_found: false, // Track if first QR has been detected to trigger zoom-in
// State machine: loading_rules -> loading_qrtool -> init_camera -> scanning -> verifying -> result
app_state: 'loading_rules', // 'loading_rules', 'loading_qrtool', 'init_camera', 'scanning', 'webview_scanning', 'verifying', 'result'
scan_mode: 'unknown', // 'camera', 'webview'
no_web_view: false, // Override web-view rule, force native camera
worker_processing: false, // Track if worker is currently processing a frame
verifying_stage: 0 // Verification spinner animation stage
},
onLoad(options) {
console.log('QR Scanner module loaded', options);
const no_web_view = options.no_web_view === '1' || options.no_web_view === 'true';
this.setData({
return_page: options.return_page || '/pages/test_result_page/test_result_page',
no_web_view: no_web_view
});
// Initialize image collection for verification
this.image_data_urls = [];
options = options || {};
const enable_debug = options.debug || options.scene == 'debug' || false;
// Step 1: Initialize system and get phone model
this.initializeSystem(enable_debug);
this.fetchRealIP();
this.fetchTenantID();
// Step 2: Load camera rules based on phone model
this.loadCameraRules();
},
fetchRealIP() {
fetch_real_ip((_err, ip) => {
this.setData({ real_ip: ip });
});
},
fetchTenantID() {
const tenant_id = get_tenant_id();
this.setData({ tenant_id });
},
initializeQRTool() {
load_qrtool();
const checkReady = () => {
if (is_qrtool_ready()) {
this.setData({ qrtool_ready: true });
this.addDebugMessage('QRTool WASM loaded and ready');
// Step 4: QRTool loaded, now initialize camera
this.startCameraInit();
} else {
setTimeout(checkReady, 100);
}
};
checkReady();
},
initializeSystem(enable_debug) {
const systemInfo = get_system_info();
const phone_model = systemInfo.model;
const use_worker = phone_model.toLowerCase().includes("iphone");
this.setData({
enable_debug,
phone_model,
window_width: systemInfo.windowWidth,
window_height: systemInfo.windowHeight,
use_worker: use_worker
});
console.log(`Phone model: ${phone_model}, Use worker: ${use_worker}`);
// Set up worker if needed (like camera.js)
if (use_worker) {
this.setupWorker();
}
},
/**
* Set up worker for iPhone processing like camera.js
*/
setupWorker() {
// Create worker with local worker file
this.worker = wx.createWorker('/pages/emblemscanner/worker/index.js', {
useExperimentalWorker: true,
});
this.worker.onMessage((msg) => {
console.log('Worker message:', msg.type);
if (msg.type === "result") {
// Clear processing flag when we get a result
this.setData({ worker_processing: false });
const result = msg.res;
const processingTime = msg.processing_time;
// Add WASM response to debug messages
if (this.data.enable_debug) {
this.addDebugMessage(`WASM response: ${JSON.stringify(result)}`);
}
// Update statistics
const newFramesProcessed = this.data.frames_processed + 1;
const newTotalTime = this.data.total_processing_time + processingTime;
const newAvgTime = Math.round(newTotalTime / newFramesProcessed);
this.setData({
frames_processed: newFramesProcessed,
total_processing_time: newTotalTime,
avg_processing_time_ms: newAvgTime,
last_frame_time_ms: Math.round(processingTime)
});
if (result) {
// For worker, we need to trigger image collection when we find a good QR
if (result.qrcode && is_emblem_qr_pattern(result.qrcode)) {
this.addDebugMessage(`Worker QR detected: ${result.qrcode}`);
// Trigger zoom-in if function is set up
if (this.on_first_qr_found && !this.data.first_qr_found) {
this.on_first_qr_found();
}
if (result.ok) {
// Request worker to submit image data
this.worker.postMessage({ type: "ready_to_submit" });
}
}
this.handleQRResult(result, this.lastWorkerFrame);
}
} else if (msg.type === "submit") {
// Handle submit message from worker (image data for upload)
const result = msg.res;
const imageData = msg.image_data;
if (imageData) {
console.log(`worker submitted image data: ${imageData.data}`);
const uca = new Uint8ClampedArray(imageData.data);
console.log(`first 4 bytes of image data in message: ${uca[0]}, ${uca[1]}, ${uca[2]}, ${uca[3]}`);
const dataUrl = data_url_from_frame(imageData.width, imageData.height, uca);
this.updateDebugFrameUrls(dataUrl);
this.image_data_urls.push(dataUrl);
// Update ok frames counter
this.setData({
ok_frames: this.image_data_urls.length
});
if (this.image_data_urls.length >= 3) {
this.addDebugMessage('3 good images collected via worker, starting verification');
this.startVerifying();
this.submitImageForVerification(this.image_data_urls, result.qrcode);
this.image_data_urls = []; // Reset for next scan
this.setData({ ok_frames: 0 }); // Reset counter
} else {
this.addDebugMessage(`Collected ${this.image_data_urls.length}/3 worker images`);
}
}
}
});
this.addDebugMessage('Worker set up for iPhone processing');
},
loadCameraRules(max_zoom = null) {
get_camera_rule(max_zoom, (rule) => {
console.log('Camera rule loaded:', rule);
const should_use_webview = rule.web_view && !this.data.no_web_view;
let emblem_camera_url = null;
if (should_use_webview) {
emblem_camera_url = "https://themblem.com/camera-5.1/?" + make_query(rule.zoom, this.data.return_page, this.data.real_ip, this.data.tenant_id);
this.addDebugMessage(`Using web-view camera: ${emblem_camera_url}`);
} else {
this.addDebugMessage('Using native camera with local WASM processing');
}
this.setData({
camera_rule: rule,
zoom: rule.zoom,
rule_zoom: rule.zoom,
camera_sensitivity: rule.sensitivity || 1,
use_web_view: should_use_webview,
emblem_camera_url: emblem_camera_url
});
this.addDebugMessage(`Camera rule: zoom=${rule.zoom}, web_view=${rule.web_view}${this.data.no_web_view ? ' (NO_WEB_VIEW)' : ''}`);
if (should_use_webview) {
// Step 3a: Go directly to webview scanning (no QRTool needed)
this.startWebviewScanning();
} else {
// Step 3b: Load QRTool for camera mode
this.addDebugMessage('Starting QRTool initialization');
this.transitionToState('loading_qrtool');
this.initializeQRTool();
}
});
},
onCameraReady(e) {
console.log('Camera ready', e);
// Get max zoom from camera initialization
let max_zoom = null;
if (e && e.detail && e.detail.maxZoom) {
max_zoom = e.detail.maxZoom;
this.setData({
max_zoom: max_zoom
});
this.addDebugMessage(`Camera max zoom: ${max_zoom}`);
}
this.camera_context = wx.createCameraContext();
this.addDebugMessage('Camera context created');
// Step 5: Set up initial zoom and start scanning
if (this.data.camera_rule) {
this.setupCameraZoom(this.data.camera_rule);
this.addDebugMessage('Initial zoom set up');
}
// Step 6: Transition to scanning (which also starts frame processing)
this.startCameraScanning();
},
onCameraError(e) {
console.error('Camera error', e);
this.addDebugMessage(`Camera error: ${JSON.stringify(e.detail)}`);
this.setData({
show_modal: 'verifyfailed',
hint_text: '相机初始化失败'
});
},
toggle_torch() {
const newFlash = this.data.camera_flash === 'torch' ? 'off' : 'torch';
this.setData({
camera_flash: newFlash
});
console.log('Torch toggled to:', newFlash);
},
show_scanguide() {
this.setData({
show_modal: 'scanguide',
show_tip: false
});
},
show_service() {
this.setData({
show_modal: 'service'
});
},
restart_camera() {
this.restartScanning();
},
close_modal() {
this.setData({
show_modal: ''
});
},
debug_tap() {
const count = (this.debug_tap_count || 0) + 1;
this.debug_tap_count = count;
if (count >= 5) {
this.setData({
enable_debug: !this.data.enable_debug
});
this.debug_tap_count = 0;
}
setTimeout(() => {
this.debug_tap_count = 0;
}, 3000);
},
/**
* Set up camera zoom functionality similar to camera.js
*/
setupCameraZoom(rule) {
if (!this.camera_context) {
this.addDebugMessage('Cannot setup zoom: camera context not ready');
return;
}
console.log('setupCameraZoom called:', rule);
const zoom = rule.zoom;
const initial_zoom = 2;
this.setData({
zoom: initial_zoom,
rule_zoom: zoom
});
this.addDebugMessage(`Camera set initial zoom to ${initial_zoom}x, will zoom in to ${zoom}x when QR is found`);
this.camera_context.setZoom({ zoom: initial_zoom });
// Set up zoom-in behavior when QR is found
this.on_first_qr_found = () => {
this.setData({ first_qr_found: true });
this.addDebugMessage(`First QR found, zoom to ${zoom}x`);
this.camera_context.setZoom({ zoom: zoom });
this.setData({
zoom: zoom,
qrmarkers_class: '',
qrarc_class: 'lg'
});
};
},
/**
* State Machine: Transition to new state
*/
transitionToState(newState, mode = null) {
const oldState = this.data.app_state;
this.addDebugMessage(`State: ${oldState} -> ${newState}${mode ? ` (${mode})` : ''}`);
const stateData = { app_state: newState };
if (mode) stateData.scan_mode = mode;
this.setData(stateData);
},
/**
* State: Loading -> Init Camera (prepare camera context)
*/
startCameraInit() {
this.transitionToState('init_camera', 'camera');
this.setData({
hint_text: '初始化相机...',
busy: true
});
},
/**
* State: Init Camera -> Scanning (Camera)
* Also starts frame processing since all conditions are implied to be ready
*/
startCameraScanning() {
this.transitionToState('scanning', 'camera');
this.setData({
hint_text: '查找二维码',
busy: false
});
// Start frame processing - all conditions are implied to be ready at this point
this.addDebugMessage('Starting camera frame listener');
this.lastFrameTime = 0;
// Set up camera frame listener
this.listener = this.camera_context.onCameraFrame((frame) => {
this.onCameraFrame(frame);
});
// Start the listener with worker if using worker mode
if (this.data.use_worker) {
if (!this.worker) {
this.addDebugMessage('Worker not found, cannot start listener');
this.setData({
show_modal: 'verifyfailed',
hint_text: '工作线程初始化失败'
});
return;
}
this.listener.start({
worker: this.worker
});
} else {
this.listener.start();
}
},
/**
* Camera frame callback - receives live camera frames
*/
onCameraFrame(frame) {
// Only process frames when in camera scanning state
if (this.data.app_state !== 'scanning') {
return;
}
// Throttle frame processing to avoid overwhelming the system
const now = Date.now();
if (this.lastFrameTime && (now - this.lastFrameTime) < 100) {
this.setData({
frames_skipped: this.data.frames_skipped + 1
});
return;
}
this.lastFrameTime = now;
// Use worker for iPhone, direct processing for other devices
if (this.data.use_worker && this.worker) {
// Skip if worker is already processing a frame
if (this.data.worker_processing) {
this.setData({
frames_skipped: this.data.frames_skipped + 1
});
return;
}
// Worker processing (iPhone)
this.lastWorkerFrame = frame; // Store for handleQRResult
// Copy frame data to avoid TOCTOU race condition (like camera.js)
var uca1 = new Uint8ClampedArray(frame.data);
var uca = new Uint8ClampedArray(uca1);
// Generate debug frame data URL if debug is enabled
if (this.data.enable_debug) {
const frameDataUrl = data_url_from_frame(frame.width, frame.height, uca);
this.updateDebugFrameUrls(frameDataUrl);
}
// Set processing flag before sending message
this.setData({ worker_processing: true });
this.worker.postMessage({
type: 'frame',
width: frame.width,
height: frame.height,
camera_sensitivity: this.data.camera_sensitivity
});
} else {
// Direct processing (other devices)
this.processFrameDirect(frame);
}
},
/**
* Process frame directly (non-iPhone devices)
*/
processFrameDirect(frame) {
const processStart = Date.now();
try {
// Copy frame data to avoid TOCTOU race condition (like camera.js)
var uca1 = new Uint8ClampedArray(frame.data);
var uca = new Uint8ClampedArray(uca1);
// Generate debug frame data URL if debug is enabled
if (this.data.enable_debug) {
const frameDataUrl = data_url_from_frame(frame.width, frame.height, uca);
this.updateDebugFrameUrls(frameDataUrl);
}
const result = process_frame(frame.width, frame.height, uca, this.data.camera_sensitivity, this.data.enable_debug);
// Calculate processing time
const processEnd = Date.now();
const processingTime = processEnd - processStart;
// Update statistics
const newFramesProcessed = this.data.frames_processed + 1;
const newTotalTime = this.data.total_processing_time + processingTime;
const newAvgTime = Math.round(newTotalTime / newFramesProcessed);
this.setData({
frames_processed: newFramesProcessed,
total_processing_time: newTotalTime,
avg_processing_time_ms: newAvgTime,
last_frame_time_ms: Math.round(processingTime),
debug_last_result: result
});
if (result) {
this.handleQRResult(result, frame, uca);
}
} catch (error) {
this.addDebugMessage(`Frame processing error: ${error.message}`);
// Still count failed processing attempts
const processEnd = Date.now();
const processingTime = processEnd - processStart;
const newFramesProcessed = this.data.frames_processed + 1;
const newTotalTime = this.data.total_processing_time + processingTime;
const newAvgTime = Math.round(newTotalTime / newFramesProcessed);
this.setData({
frames_processed: newFramesProcessed,
total_processing_time: newTotalTime,
avg_processing_time_ms: newAvgTime,
last_frame_time_ms: Math.round(processingTime),
debug_last_result: null
});
}
},
/**
* Handle QR processing result
*/
handleQRResult(result, frame, copiedFrameData = null) {
// Update debug info - prioritize WASM debug image, fallback to original frame
if (this.data.enable_debug) {
if (result.debug_data_url) {
// Priority 1: WASM returned a processed debug image (with QR detection overlay, etc.)
this.setData({
debug_image_data_url: result.debug_data_url
});
} else if (this.data.debug_current_frame_url) {
// Priority 2: No WASM debug image available, show the raw camera frame
this.setData({
debug_image_data_url: this.data.debug_current_frame_url
});
}
// If neither exists, the debug image box will remain empty/previous image
}
// Generate hint text
const hint = make_hint_text(result);
// Check if we have a valid QR code that's ready for upload (like camera.js)
// don't require ok as we only care about the view has a valid qrcode in it
// zooming in so that it's more likely to be clear enough for upload
if (result.qrcode && is_emblem_qr_pattern(result.qrcode)) {
this.addDebugMessage(`QR detected: ${result.qrcode} ok: ${result.ok}: err ${result.err}`);
// Trigger zoom-in if function is set up
if (!this.data.first_qr_found && this.on_first_qr_found) {
this.on_first_qr_found();
}
if (result.ok) {
this.onQRCodeDetected(result.qrcode, frame, copiedFrameData); // Pass the copied frame data
}
} else {
// Update hint for user guidance
this.setData({ hint_text: hint });
if (result.qrcode) {
this.addDebugMessage(`QR found but not valid: ${result.qrcode} (${result.err})`);
}
}
},
/**
* Handle successful QR code detection - collect images from "ok" frames and verify (non-worker case)
*/
onQRCodeDetected(qrCode, frameData, copiedFrameData = null) {
// Only collect images for non-worker case (worker handles its own image collection)
if (!this.data.use_worker && frameData && copiedFrameData) {
// Convert frame data to data URL for upload (only called for "ok" frames)
// Use the copied frame data instead of original frameData.data to avoid corruption
const dataUrl = data_url_from_frame(frameData.width, frameData.height, copiedFrameData);
this.image_data_urls.push(dataUrl);
// Update ok frames counter
this.setData({
ok_frames: this.image_data_urls.length
});
this.addDebugMessage(`Collected ${this.image_data_urls.length}/3 good images (direct) - using copied data`);
// Add debug info about the submitted image
if (this.data.enable_debug) {
this.addDebugMessage(`Submitted image preview: ${dataUrl.substring(0, 50)}...`);
}
// Need 3 "ok" frames before verification (like camera.js)
if (this.image_data_urls.length >= 3) {
this.addDebugMessage('3 good images collected, starting verification');
this.startVerifying();
this.submitImageForVerification(this.image_data_urls, qrCode);
this.image_data_urls = []; // Reset for next scan
this.setData({ ok_frames: 0 }); // Reset counter
}
}
// For worker case, image collection is handled in setupWorker() message handler
// If less than 3 images, continue scanning to collect more
},
/**
* Submit images for verification like camera.js
*/
submitImageForVerification(dataUrls, qrCode) {
this.addDebugMessage('Submitting images for verification');
const begin = Date.now();
const success = (res) => {
this.addDebugMessage(`Upload success, code: ${res.statusCode}`);
if (res.statusCode === 200) {
let resp;
if (typeof res.data === "string") {
resp = JSON.parse(res.data);
} else {
resp = res.data;
}
// Store verification response
getApp().globalData.verify_resp = resp;
if (resp.serial_code) {
// Let verification animation run for a bit, then show result
const delay = 3000 - (Date.now() - begin);
setTimeout(() => {
this.goToResult(qrCode, resp.serial_code);
}, delay > 0 ? delay : 0);
} else {
this.showVerifyFailed();
}
} else {
this.showVerifyFailed();
}
};
const fail = (e) => {
this.addDebugMessage(`Upload failed: ${JSON.stringify(e)}`);
this.showVerifyFailed();
};
// Store global data like camera.js
const gd = getApp().globalData;
gd.image_data_urls = dataUrls;
gd.qr_code = qrCode;
upload_image_data_urls(dataUrls, success, fail, "emblemscanner verification");
},
/**
* Show verification failed modal
*/
showVerifyFailed() {
this.setData({
show_modal: 'verifyfailed',
hint_text: '验证失败'
});
},
/**
* State: Loading Rules -> Webview Scanning
*/
startWebviewScanning() {
this.transitionToState('webview_scanning', 'webview');
this.setData({
hint_text: '查找二维码',
busy: false
});
},
/**
* State: Scanning -> Verifying (only for camera mode)
*/
startVerifying() {
this.transitionToState('verifying');
this.setData({
hint_text: '识别成功',
show_modal: 'verifying',
verifying_stage: 0
});
// Start verification animation like verifyspin component
setTimeout(() => {
this.setData({
verifying_stage: 1
});
}, 3000);
},
/**
* State: Any -> Result (jump to return page)
*/
goToResult(qrCode, serialCode) {
this.transitionToState('result');
// Pass both qr_code and serial_code parameters like camera-5.1 does
// serialCode comes from server verification response
const url = `${this.data.return_page}?qr_code=${encodeURIComponent(qrCode)}&serial_code=${encodeURIComponent(serialCode)}`;
wx.redirectTo({
url: url,
success: () => {
this.addDebugMessage(`Navigated to: ${this.data.return_page}`);
},
fail: (err) => {
this.addDebugMessage(`Navigation failed: ${err.errMsg}`);
this.restart_camera();
}
});
},
/**
* State: Any -> Loading (restart)
*/
restart_camera() {
// Go to initializing if QRTool isn't ready, otherwise loading
const newState = this.data.qrtool_ready ? 'loading' : 'initializing';
this.transitionToState(newState);
const hintText = this.data.qrtool_ready ? '初始化相机...' : '初始化QR工具...';
this.setData({
show_modal: '',
hint_text: hintText,
busy: true,
// Reset frame processing statistics
frames_processed: 0,
frames_skipped: 0,
ok_frames: 0,
total_processing_time: 0,
avg_processing_time_ms: 0,
last_frame_time_ms: 0,
first_qr_found: false
});
// Reset frame timing
this.lastFrameTime = 0;
// Reload camera rules to restart the flow (only if QRTool is ready)
if (this.data.qrtool_ready) {
this.loadCameraRules();
}
},
/**
* Add debug message to debug overlay
*/
addDebugMessage(message) {
if (!this.data.enable_debug) return;
const timestamp = new Date().toLocaleTimeString();
const debugMsg = `${timestamp}: ${message}`;
this.setData({
debug_msgs: [debugMsg, ...this.data.debug_msgs].slice(0, 5) // Keep first 5 messages (newest on top)
});
},
/**
* Update debug frame URLs for visualization
*/
updateDebugFrameUrls(frameDataUrl) {
if (!this.data.enable_debug) return;
// Store current frame URL for potential use as fallback
this.setData({
debug_current_frame_url: frameDataUrl
});
},
/**
* Log function for debugging
*/
log(...args) {
console.log(...args);
this.addDebugMessage(args.join(' '));
},
/**
* Handle messages from web-view camera
*/
on_webview_message(e) {
console.log('Web-view message received:', e);
this.addDebugMessage(`Web-view message: ${JSON.stringify(e.detail)}`);
// Handle QR code results from web-view
if (e.detail && e.detail.data && e.detail.data.length > 0) {
const messageData = e.detail.data[0];
if (messageData.qr_code) {
// Web-view results go directly to result (no verification step)
this.goToResult(messageData.qr_code);
} else if (messageData.error) {
this.addDebugMessage(`Web-view error: ${messageData.error}`);
this.setData({
hint_text: '识别失败'
});
}
}
},
/**
* Check if QR code matches Emblem pattern
*/
isEmblemQRPattern(qrCode) {
return is_emblem_qr_pattern(qrCode);
},
/**
* Lifecycle - page hide
*/
onHide() {
this.cleanupListener();
},
/**
* Lifecycle - page unload
*/
onUnload() {
this.cleanupListener();
},
/**
* Clean up camera frame listener
*/
cleanupListener() {
if (this.listener) {
this.listener.stop();
this.listener = null;
this.addDebugMessage('Camera frame listener stopped');
}
}
});