Xây dựng MediaPipe Wrapper cho Vue
Mục tiêu bài học
- Hiểu tại sao cần wrapper cho MediaPipe trong Vue
- Học cách isolate WASM khỏi Vue reactivity
- Nắm được architecture của wrapper class
- Biết cách integrate wrapper vào Vue component
Vấn đề: Vue Reactivity vs WASM
Vue Proxy và WASM không tương thích
Vue 3 sử dụng Proxy để tạo reactivity. Khi bạn truyền một object vào ref() hoặc reactive(), Vue bọc nó trong Proxy để theo dõi thay đổi.
Vấn đề: Module MediaPipe WASM không thể làm việc với Proxy:
typescript
// ❌ KHÔNG hoạt động
const faceMesh = ref(new FaceMesh(...));
const videoElement = ref<HTMLVideoElement>();
// Vue sẽ bọc cả 2 trong Proxy
// → WASM crash với lỗi:
// "TypeError: 'get' on proxy: property '$$' is a read-only..."
await faceMesh.value.send({ image: videoElement.value });Giải pháp: Wrapper Class
Tạo một class JavaScript thuần để:
- Quản lý instance MediaPipe (không dùng Vue reactivity)
- Cung cấp API đơn giản cho Vue component
- Xử lý vòng đời (init, start, stop, destroy)
typescript
// ✅ Hoạt động
import { MediaPipeFaceMeshWrapper } from "@/utils/ekyc/MediaPipeFaceMeshWrapper";
// Wrapper class không dùng Vue reactivity
const wrapper = new MediaPipeFaceMeshWrapper();
await wrapper.initialize();
// Component chỉ tương tác qua wrapper API
wrapper.onResults((results) => {
// Update Vue state ở đây (an toàn)
faceDetected.value = results.multiFaceLandmarks?.length > 0;
});Architecture của Wrapper
Class Structure
typescript
/**
* MediaPipeFaceMeshWrapper.ts
* Pure JavaScript wrapper - NO Vue reactivity!
*/
export class MediaPipeFaceMeshWrapper {
// Thuộc tính private (JavaScript thuần, không có Proxy)
private faceMesh: any = null;
private videoElement: HTMLVideoElement | null = null;
private intervalId: number | null = null;
private isInitialized = false;
private callback: FaceMeshCallback | null = null;
constructor() {
console.log('[Wrapper] Constructor được gọi');
}
// API công khai
async initialize(): Promise<void> { ... }
setVideoElement(video: HTMLVideoElement): void { ... }
onResults(callback: FaceMeshCallback): void { ... }
startDetection(): void { ... }
stopDetection(): void { ... }
destroy(): void { ... }
isReady(): boolean { ... }
}Implementation Chi Tiết
1. Initialize MediaPipe
typescript
async initialize(): Promise<void> {
if (this.isInitialized) {
console.log('[Wrapper] Already initialized');
return;
}
return new Promise(async (resolve, reject) => {
try {
console.log('[Wrapper] Initializing...');
// Import động FaceMesh (tránh các vấn đề khi build)
if (!FaceMeshClass) {
const faceMeshModule = await import('@mediapipe/face_mesh');
// Thử nhiều cách lấy constructor (xem bài production issues)
FaceMeshClass =
faceMeshModule.FaceMesh ??
faceMeshModule.default?.FaceMesh ??
faceMeshModule.default ??
(faceMeshModule as any).FaceMesh;
if (!FaceMeshClass || typeof FaceMeshClass !== 'function') {
throw new Error('Không tìm thấy constructor FaceMesh');
}
}
// Tạo instance FaceMesh
this.faceMesh = new FaceMeshClass({
locateFile: (file: string) => {
// Dùng file local từ public/face_mesh/
return `/face_mesh/${file}`;
}
});
// Cấu hình
this.faceMesh.setOptions({
selfieMode: true, // Lật cho camera trước
maxNumFaces: 1, // Chỉ 1 mặt
refineLandmarks: true, // Chi tiết (mắt, môi)
minDetectionConfidence: 0.5,
minTrackingConfidence: 0.5
});
// Khởi động với ảnh nhỏ để tải models
const warmupImage = new Image();
warmupImage.src = 'data:image/png;base64,iVBORw0KGgo...'; // 1x1 pixel
warmupImage.onload = () => {
// Đặt callback tạm thời cho khởi động
this.faceMesh.onResults(() => {
this.isInitialized = true;
console.log('[Wrapper] Khởi tạo thành công!');
resolve();
});
// Gửi ảnh khởi động
this.faceMesh.send({ image: warmupImage }).catch((err: any) => {
console.error('[Wrapper] Lỗi khởi động:', err);
resolve(); // Vẫn resolve
});
};
warmupImage.onerror = () => {
reject(new Error('Failed to load warmup image'));
};
} catch (error) {
console.error('[Wrapper] Init error:', error);
reject(error);
}
});
}2. Set Video Element
Quan trọng: Phải truyền HTMLVideoElement thuần, không phải Vue ref!
typescript
/**
* Đặt phần tử video (PHẢI là HTMLVideoElement thuần, KHÔNG phải Vue ref!)
*/
setVideoElement(video: HTMLVideoElement): void {
// QUAN TRỌNG: Lưu dưới dạng tham chiếu thuần, không có Vue proxy!
this.videoElement = video;
console.log('[Wrapper] Phần tử video đã được đặt');
}Cách sử dụng trong Vue:
vue
<template>
<video ref="videoRef" autoplay></video>
</template>
<script setup lang="ts">
const videoRef = ref<HTMLVideoElement>();
const wrapper = new MediaPipeFaceMeshWrapper();
onMounted(async () => {
await wrapper.initialize();
// ✅ Đúng: truyền phần tử thuần
if (videoRef.value) {
wrapper.setVideoElement(videoRef.value);
}
// ❌ Sai: truyền Vue ref
// wrapper.setVideoElement(videoRef); // Lỗi!
});
</script>3. Đăng ký Callback
typescript
/**
* Đặt callback kết quả
*/
onResults(callback: FaceMeshCallback): void {
this.callback = callback;
if (this.faceMesh) {
// Thiết lập callback MediaPipe
this.faceMesh.onResults((results: Results) => {
if (this.callback) {
this.callback(results);
}
});
console.log('[Wrapper] Callback đã được đăng ký');
}
}Sử dụng:
typescript
wrapper.onResults((results) => {
// An toàn cập nhật trạng thái Vue ở đây
if (results.multiFaceLandmarks && results.multiFaceLandmarks.length > 0) {
faceDetected.value = true;
landmarks.value = results.multiFaceLandmarks[0];
} else {
faceDetected.value = false;
}
});4. Vòng lặp phát hiện
Quan trọng: Dùng setInterval, không phải requestAnimationFrame
Lý do: MediaPipe đã tự tối ưu hóa, RAF có thể gây xung đột.
typescript
/**
* Bắt đầu vòng lặp phát hiện (CHÍNH XÁC như React với setInterval)
*/
startDetection(): void {
if (!this.faceMesh) {
console.error('[Wrapper] FaceMesh chưa được khởi tạo');
return;
}
if (!this.videoElement) {
console.error('[Wrapper] Phần tử video chưa được đặt');
return;
}
console.log('[Wrapper] Đang bắt đầu vòng lặp phát hiện...');
// Bắt đầu interval (10ms → ~100fps tối đa, MediaPipe sẽ giới hạn)
this.intervalId = window.setInterval(async () => {
// Kiểm tra video sẵn sàng
if (this.videoElement && this.videoElement.readyState === 4 && this.faceMesh) {
try {
// QUAN TRỌNG: Gửi phần tử video thuần trực tiếp
// KHÔNG cần toRaw() vì videoElement đã là JavaScript thuần
await this.faceMesh.send({ image: this.videoElement });
} catch (error) {
console.error('[Wrapper] Lỗi phát hiện:', error);
}
}
}, 10); // Khoảng thời gian 10ms
}5. Dừng phát hiện
typescript
/**
* Dừng vòng lặp phát hiện
*/
stopDetection(): void {
if (this.intervalId !== null) {
clearInterval(this.intervalId);
this.intervalId = null;
console.log('[Wrapper] Phát hiện đã dừng');
}
}6. Dọn dẹp
typescript
/**
* Dọn dẹp và hủy
*/
destroy(): void {
console.log('[Wrapper] Đang hủy...');
this.stopDetection();
if (this.faceMesh) {
try {
this.faceMesh.close();
} catch (error) {
console.error('[Wrapper] Lỗi khi đóng FaceMesh:', error);
}
this.faceMesh = null;
}
this.videoElement = null;
this.callback = null;
this.isInitialized = false;
}Sử dụng Wrapper trong Vue Component
Full Example
vue
<template>
<div class="face-detection-container">
<video ref="videoRef" autoplay playsinline class="video-preview" />
<canvas ref="canvasRef" class="canvas-overlay" />
<div class="status">
<p v-if="!isReady">{{ t("locale.loading_face_detection_model") }}</p>
<p v-else-if="!faceDetected">{{ t("locale.no_face_detected") }}</p>
<p v-else class="success">{{ t("locale.face_detected") }}</p>
</div>
<BtnBase
v-if="isReady && faceDetected"
class="c-btn-primary"
:title="t('locale.capture')"
@click="captureImage"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from "vue";
import { MediaPipeFaceMeshWrapper } from "@/utils/ekyc/MediaPipeFaceMeshWrapper";
import type { Results } from "@mediapipe/face_mesh";
// Props & Emits
const emit = defineEmits<{
takingImage: [imageDataUrl: string];
}>();
// Refs
const videoRef = ref<HTMLVideoElement>();
const canvasRef = ref<HTMLCanvasElement>();
const isReady = ref(false);
const faceDetected = ref(false);
// Wrapper instance (không dùng ref/reactive!)
let wrapper: MediaPipeFaceMeshWrapper | null = null;
let stream: MediaStream | null = null;
// Lifecycle
onMounted(async () => {
await setupCamera();
await setupFaceDetection();
});
onBeforeUnmount(() => {
cleanup();
});
// Setup camera
async function setupCamera() {
try {
stream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
facingMode: "user",
},
});
if (videoRef.value) {
videoRef.value.srcObject = stream;
}
} catch (error) {
console.error("Camera access error:", error);
}
}
// Setup face detection
async function setupFaceDetection() {
try {
// Tạo wrapper (plain JS class)
wrapper = new MediaPipeFaceMeshWrapper();
// Initialize (load models)
await wrapper.initialize();
// Set video element
if (videoRef.value) {
wrapper.setVideoElement(videoRef.value);
}
// Register callback
wrapper.onResults(onFaceMeshResults);
// Start detection loop
wrapper.startDetection();
isReady.value = true;
} catch (error) {
console.error("Face detection setup error:", error);
}
}
// Handle results
function onFaceMeshResults(results: Results) {
// Update Vue state (an toàn ở đây)
faceDetected.value = !!results.multiFaceLandmarks?.length;
// Vẽ lên canvas nếu cần
if (canvasRef.value && results.multiFaceLandmarks) {
drawLandmarks(results.multiFaceLandmarks[0]);
}
}
// Draw landmarks
function drawLandmarks(landmarks: any[]) {
const canvas = canvasRef.value;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Vẽ mesh
for (const landmark of landmarks) {
const x = landmark.x * canvas.width;
const y = landmark.y * canvas.height;
ctx.beginPath();
ctx.arc(x, y, 1, 0, 2 * Math.PI);
ctx.fillStyle = "#00FF00";
ctx.fill();
}
}
// Capture image
function captureImage() {
if (!videoRef.value) return;
const canvas = document.createElement("canvas");
canvas.width = videoRef.value.videoWidth;
canvas.height = videoRef.value.videoHeight;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.drawImage(videoRef.value, 0, 0);
const imageDataUrl = canvas.toDataURL("image/jpeg", 0.95);
emit("takingImage", imageDataUrl);
}
// Cleanup
function cleanup() {
// Stop detection
if (wrapper) {
wrapper.destroy();
wrapper = null;
}
// Stop camera
if (stream) {
stream.getTracks().forEach((track) => track.stop());
stream = null;
}
}
</script>Best Practices
1. Luôn dùng JavaScript thuần cho WASM
typescript
// ✅ Đúng
let wrapper: MediaPipeFaceMeshWrapper | null = null;
// ❌ Sai
const wrapper = ref<MediaPipeFaceMeshWrapper | null>(null);2. Truyền phần tử DOM thuần
typescript
// ✅ Đúng
if (videoRef.value) {
wrapper.setVideoElement(videoRef.value);
}
// ❌ Sai
wrapper.setVideoElement(toRaw(videoRef)); // Không cần toRaw!3. Cập nhật trạng thái Vue trong callback
typescript
// ✅ Đúng: Cập nhật trạng thái trong callback
wrapper.onResults((results) => {
faceDetected.value = !!results.multiFaceLandmarks?.length;
});
// ❌ Sai: Truyền object reactive vào WASM
this.faceMesh.send({ image: videoRef.value }); // Lỗi!4. Dọn dẹp đúng cách
typescript
onBeforeUnmount(() => {
// Dừng phát hiện trước
wrapper?.stopDetection();
// Hủy wrapper
wrapper?.destroy();
// Dừng camera
stream?.getTracks().forEach((track) => track.stop());
});Tổng kết
Wrapper class giúp:
- ✅ Tách biệt WASM khỏi Vue Proxy
- ✅ Đóng gói độ phức tạp của MediaPipe
- ✅ Cung cấp API sạch cho Vue component
- ✅ Xử lý vòng đời đúng cách