Skip to content

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 để:

  1. Quản lý instance MediaPipe (không dùng Vue reactivity)
  2. Cung cấp API đơn giản cho Vue component
  3. 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

Bài học tiếp theo

Camera Pipeline và Detection Loop

Internal documentation for iNET Portal