@face-detector/core
Version:
Face Detector Web SDK Core Package
1 lines • 80 kB
Source Map (JSON)
{"version":3,"file":"index.cjs","sources":["../../types/dist/index.es.js","../src/faceDetector/managers/BaseManager.abstract.ts","../src/faceDetector/utils/logger.ts","../src/faceDetector/managers/FaceDetectionManager.ts","../src/faceDetector/utils/calculateROI.ts","../src/faceDetector/utils/calculateBoundingBox.ts","../src/faceDetector/utils/WorkerService.ts","../src/faceDetector/managers/DataProcessingManager.ts","../src/faceDetector/managers/WebCamManager.ts","../src/faceDetector/core/MeasurementLoop.ts","../src/faceDetector/utils/calculateInterval.ts","../src/faceDetector/utils/clamp.ts","../src/faceDetector/state/StateMachine.ts","../src/faceDetector/events/EventEmitter.ts","../src/faceDetector/configs/defaultConfig.ts","../src/faceDetector/FaceDetector.ts"],"sourcesContent":["var i = /* @__PURE__ */ ((n) => (n.INITIALIZING = \"initializing\", n.READY = \"ready\", n.RUNNING = \"running\", n.MEASURING = \"measuring\", n.COMPLETED = \"completed\", n.FAILED = \"failed\", n))(i || {}), N = /* @__PURE__ */ ((n) => (n.WEB = \"web\", n.ANDROID = \"android\", n.IOS = \"ios\", n))(N || {}), I = /* @__PURE__ */ ((n) => (n.OPTION1 = \"option1\", n.OPTION2 = \"option2\", n.OPTION3 = \"option3\", n))(I || {});\nconst O = I;\nexport {\n I as FilterOption,\n N as Platform,\n i as ProcessState,\n O as fitlerOption\n};\n//# sourceMappingURL=index.es.js.map\n","import type { ManagerConfig } from \"@face-detector/types\";\r\nimport { EventEmitter } from \"../events/EventEmitter\";\r\n\r\nexport abstract class BaseManager {\r\n protected _config: ManagerConfig | null;\r\n protected _eventEmitter: EventEmitter;\r\n\r\n constructor(config: ManagerConfig | null, eventEmitter: EventEmitter) {\r\n this._config = config;\r\n this._eventEmitter = eventEmitter;\r\n }\r\n\r\n get config(): ManagerConfig | null {\r\n return this._config;\r\n }\r\n\r\n get eventEmitter(): EventEmitter {\r\n return this._eventEmitter;\r\n }\r\n\r\n abstract initialize(arg?: unknown, arg2?: unknown): void;\r\n abstract reset(): void;\r\n abstract dispose(): void;\r\n abstract stop(): void;\r\n}\r\n","class Logger {\r\n private static instance: Logger;\r\n private debugEnabled: boolean = false;\r\n\r\n private constructor() {}\r\n\r\n static getInstance(): Logger {\r\n if (!Logger.instance) {\r\n Logger.instance = new Logger();\r\n }\r\n return Logger.instance;\r\n }\r\n\r\n setDebugMode(enabled: boolean): void {\r\n this.debugEnabled = enabled;\r\n }\r\n\r\n debug(message: string, ...args: any[]): void {\r\n if (this.debugEnabled) {\r\n console.log(`[FaceDetectionSDK] ${message}`, ...args);\r\n }\r\n }\r\n\r\n error(message: string, ...args: any[]): void {\r\n console.error(`[FaceDetectionSDK ERROR] ${message}`, ...args);\r\n }\r\n\r\n warn(message: string, ...args: any[]): void {\r\n console.warn(`[FaceDetectionSDK WARN] ${message}`, ...args);\r\n }\r\n}\r\n\r\nexport const logger = Logger.getInstance();\r\n","import { FaceDetector, FilesetResolver, FaceDetectorOptions, FaceDetectorResult, BoundingBox } from '@mediapipe/tasks-vision';\r\nimport type { FaceDetectionManagerConfig, FacePosition, ROI } from '@face-detector/types';\r\nimport { EventEmitter } from '../events/EventEmitter';\r\nimport { BaseManager } from '../managers/BaseManager.abstract';\r\nimport { calculateROI } from '../utils/calculateROI';\r\nimport { logger } from '../utils/logger';\r\n\r\nexport class FaceDetectionManager extends BaseManager {\r\n private faceDetector!: FaceDetector;\r\n private faceDetectionManagerConfig!: FaceDetectionManagerConfig;\r\n\r\n private minDetectionConfidence!: number;\r\n\r\n private _isDetected!: boolean;\r\n private unsubFrameCaptured?: () => void;\r\n private unsubFrameWarmUp?: () => void;\r\n private _facePosition: FacePosition | null = null;\r\n private _ROI: ROI | null = null;\r\n private imageWidth: number = 0;\r\n private imageHeight: number = 0;\r\n \r\n constructor(config: FaceDetectionManagerConfig, eventEmitter: EventEmitter) {\r\n super(config, eventEmitter);\r\n this.faceDetectionManagerConfig = config;\r\n this.minDetectionConfidence = config.minDetectionConfidence;\r\n this._isDetected = false;\r\n logger.debug('FaceDetectionManager initialized', { \r\n minDetectionConfidence: config.minDetectionConfidence,\r\n delegate: config.delegate \r\n });\r\n }\r\n\r\n get isDetected(): boolean {\r\n return this._isDetected;\r\n }\r\n\r\n set isDetected(isDetected: boolean) {\r\n this._isDetected = isDetected;\r\n }\r\n\r\n set facePosition(facePosition: FacePosition | null) {\r\n if (this._facePosition === facePosition) return;\r\n this._facePosition = facePosition;\r\n this.eventEmitter.emit('face:position', facePosition);\r\n }\r\n\r\n get facePosition(): FacePosition | null {\r\n return this._facePosition;\r\n }\r\n\r\n get ROI(): ROI | null {\r\n return this._ROI;\r\n }\r\n\r\n set ROI(ROI: ROI | null) {\r\n this._ROI = ROI;\r\n }\r\n\r\n /**\r\n * MediaPipe Face Detection 초기화 및 결과 콜백/이벤트 구독 설정\r\n */\r\n public async initialize(video: HTMLVideoElement): Promise<void> {\r\n this.imageWidth = video.videoWidth;\r\n this.imageHeight = video.videoHeight;\r\n this.unSubscribeEventListeners();\r\n if (this.faceDetector) {\r\n this.faceDetector.close();\r\n this.faceDetector = null!;\r\n }\r\n\r\n const vision = await FilesetResolver.forVisionTasks(\r\n \"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision/wasm\"\r\n );\r\n\r\n const faceDetectorOptions: FaceDetectorOptions = {\r\n baseOptions: {\r\n modelAssetPath: `https://storage.googleapis.com/mediapipe-models/face_detector/blaze_face_short_range/float16/1/blaze_face_short_range.tflite`,\r\n delegate: this.faceDetectionManagerConfig.delegate,\r\n },\r\n\r\n minDetectionConfidence: this.minDetectionConfidence,\r\n runningMode: 'VIDEO',\r\n };\r\n\r\n this.faceDetector = await FaceDetector.createFromOptions(vision, faceDetectorOptions);\r\n this.setupEventListeners(video);\r\n logger.debug('MediaPipe Face Detection initialized', { \r\n videoSize: `${video.videoWidth}x${video.videoHeight}`,\r\n confidence: this.minDetectionConfidence \r\n });\r\n }\r\n \r\n reset(): void {\r\n this.facePosition = null;\r\n this.ROI = null;\r\n this.isDetected = false;\r\n logger.debug('FaceDetectionManager reset completed');\r\n }\r\n\r\n /**\r\n * 현재 캔버스를 MediaPipe에 전송하여 얼굴 위치 검증 실행\r\n */\r\n public validateFacePosition(video: HTMLVideoElement, isWarmUp: boolean = false): void {\r\n const timestamp = performance.now();\r\n\r\n const results = this.faceDetector.detectForVideo(video, timestamp);\r\n if (isWarmUp) {\r\n return;\r\n }\r\n this.onResults(results);\r\n }\r\n\r\n public stop(): void {\r\n this._isDetected = false;\r\n }\r\n\r\n\r\n public dispose(): void {\r\n this.isDetected = false;\r\n if (this.faceDetector) {\r\n this.faceDetector.close();\r\n this.faceDetector = null!;\r\n }\r\n this.unSubscribeEventListeners();\r\n }\r\n\r\n private setupEventListeners(video: HTMLVideoElement): void {\r\n this.unsubFrameWarmUp?.();\r\n this.unsubFrameWarmUp = this.eventEmitter.subscribe('frame:warmUp', () => {\r\n this.validateFacePosition(video, true);\r\n });\r\n }\r\n\r\n /**\r\n * MediaPipe 결과 콜백\r\n * - 검출 여부 판단, 바운딩박스 갱신, 상태 이벤트 발행\r\n */\r\n private onResults = (results: FaceDetectorResult): void => {\r\n const isDetected = results.detections.length > 0;\r\n const wasDetected = this.isDetected;\r\n this.isDetected = isDetected;\r\n \r\n if (isDetected && this.imageWidth > 0 && this.imageHeight > 0) {\r\n const detection = results.detections[0];\r\n if (detection.boundingBox) {\r\n this.facePosition = this.convertBoundingBoxToFacePosition(detection.boundingBox)\r\n this.ROI = calculateROI(this.facePosition, this.imageWidth, this.imageHeight);\r\n \r\n if (!wasDetected) {\r\n logger.debug('Face detected', { \r\n confidence: detection.categories?.[0]?.score,\r\n position: this.facePosition \r\n });\r\n }\r\n }\r\n } else {\r\n if (wasDetected) {\r\n logger.debug('Face lost');\r\n }\r\n this.facePosition = null;\r\n this.ROI = null;\r\n this.eventEmitter.emit('face:lost', undefined);\r\n this.eventEmitter.emit('system:running', undefined);\r\n }\r\n }\r\n\r\n private unSubscribeEventListeners(): void {\r\n this.unsubFrameCaptured?.();\r\n this.unsubFrameWarmUp?.();\r\n this.unsubFrameCaptured = undefined;\r\n this.unsubFrameWarmUp = undefined;\r\n } \r\n\r\n private convertBoundingBoxToFacePosition(bbox: BoundingBox): FacePosition {\r\n return {\r\n xCenter: (bbox.originX + bbox.width / 2) / this.imageWidth,\r\n yCenter: (bbox.originY + bbox.height / 2) / this.imageHeight,\r\n width: bbox.width / this.imageWidth,\r\n height: bbox.height / this.imageHeight,\r\n };\r\n } \r\n\r\n \r\n}\r\n","import { FacePosition } from \"@face-detector/types\";\r\nimport { calculateBoundingBox } from \"./calculateBoundingBox\";\r\n\r\nexport function calculateROI(facePosition: FacePosition, frameWidth: number, frameHeight: number) {\r\n const boundingBox = calculateBoundingBox(facePosition, frameWidth, frameHeight);\r\n const sx = Math.max(0, Math.floor(boundingBox.left));\r\n const sy = Math.max(0, Math.floor(boundingBox.top));\r\n const sw = Math.max(1, Math.floor(Math.min(boundingBox.width, frameWidth - sx)));\r\n const sh = Math.max(1, Math.floor(Math.min(boundingBox.height, frameHeight - sy)));\r\n return { sx, sy, sw, sh };\r\n}\r\n ","import { FacePosition } from \"@face-detector/types\";\r\n\r\nexport function calculateBoundingBox(\r\n boundingBox: FacePosition,\r\n imageWidth: number,\r\n imageHeight: number,\r\n ): CalculatedBoundingBox {\r\n const scaleFactor = 0.6;\r\n const width = boundingBox.width * imageWidth * scaleFactor;\r\n const height = boundingBox.height * imageHeight * scaleFactor;\r\n const left = boundingBox.xCenter * imageWidth - width / 2;\r\n const top = boundingBox.yCenter * imageHeight - height / 2;\r\n return { left, top, width, height };\r\n }\r\n\r\n \r\n export interface CalculatedBoundingBox {\r\n left: number;\r\n top: number;\r\n width: number;\r\n height: number;\r\n }\r\n\r\n","type MessageHandler<T = any> = (message: T) => void;\r\n\r\ninterface PendingRequest<T = any> {\r\n resolve: (result: T) => void;\r\n reject: (reason?: any) => void;\r\n}\r\n\r\ninterface WorkerServiceConfig {\r\n workerUrl: string;\r\n messageHandlers?: {\r\n [messageType: string]: MessageHandler;\r\n };\r\n onMessage?: MessageHandler<any>;\r\n}\r\n\r\nexport class WorkerService {\r\n private worker!: Worker;\r\n private pending: Map<number, PendingRequest<any>> = new Map();\r\n private requestId: number = 0;\r\n private messageHandlers: Map<string, MessageHandler> = new Map();\r\n\r\n async initialize(config: WorkerServiceConfig): Promise<void> {\r\n // 기존 워커가 있다면 정리\r\n if (this.worker) {\r\n this.worker.terminate();\r\n this.worker = null!;\r\n }\r\n\r\n this.worker = new Worker(config.workerUrl, { type: 'module' });\r\n \r\n // 메시지 핸들러 등록\r\n if (config.messageHandlers) {\r\n Object.entries(config.messageHandlers).forEach(([type, handler]) => {\r\n this.registerMessageHandler(type, handler);\r\n });\r\n }\r\n \r\n // 워커 메시지 리스너 설정\r\n this.worker.onmessage = (event) => {\r\n const message = event.data;\r\n \r\n // Promise 기반 요청 처리 - 전체 응답 객체 반환\r\n if ('id' in message && this.pending.has(message.id)) {\r\n const { resolve } = this.pending.get(message.id)!;\r\n resolve(message);\r\n this.pending.delete(message.id);\r\n return;\r\n }\r\n \r\n // 일반 메시지 핸들러 실행\r\n const handler = this.messageHandlers.get(message.type) || config.onMessage;\r\n if (handler) {\r\n handler(message);\r\n }\r\n };\r\n }\r\n\r\n registerMessageHandler(messageType: string, handler: MessageHandler): void {\r\n this.messageHandlers.set(messageType, handler);\r\n }\r\n\r\n sendMessage<T extends Record<string, any>>(message: T, transferables?: Transferable[]): void {\r\n if (!this.worker) {\r\n throw new Error('WorkerService not initialized');\r\n }\r\n this.worker.postMessage(message, transferables || []);\r\n }\r\n\r\n sendRequest<TResponse extends Record<string, any>>(\r\n message: Record<string, any> & { id: number }, \r\n transferables?: Transferable[]\r\n ): Promise<TResponse> {\r\n if (!this.worker) {\r\n return Promise.reject(new Error('WorkerService not initialized'));\r\n }\r\n\r\n return new Promise((resolve, reject) => {\r\n this.pending.set(message.id, { resolve, reject });\r\n this.worker.postMessage(message, transferables || []);\r\n });\r\n }\r\n\r\n generateRequestId(): number {\r\n return this.requestId++;\r\n }\r\n\r\n clearPendingRequests(): void {\r\n this.pending.forEach(({ reject }) => {\r\n reject(new Error('WorkerService disposed'));\r\n });\r\n this.pending.clear();\r\n }\r\n\r\n dispose(): void {\r\n this.clearPendingRequests();\r\n this.messageHandlers.clear();\r\n if (this.worker) {\r\n this.worker.terminate();\r\n this.worker = null!;\r\n }\r\n }\r\n}\r\n","import { BaseManager } from './BaseManager.abstract';\r\nimport { EventEmitter } from '../events/EventEmitter';\r\nimport type { DataBucket, DataProcessingManagerConfig, Report, ExtractedRGB } from '@face-detector/types';\r\nimport { logger } from '../utils/logger';\r\nimport { WorkerService } from '../utils/WorkerService';\r\nimport { ConnectVideoCanvasMessage, ReleaseVideoCanvasMessage, ProcessFrameMessage, ProcessFrameResponse, ConnectVideoCanvasResponse, ReleaseVideoCanvasResponse } from '../../types/worker.types';\r\n\r\nexport class DataProcessingManager extends BaseManager{\r\n private workerService!: WorkerService;\r\n private _dataBucket!: DataBucket;\r\n \r\n private _progressPercentage!: number;\r\n \r\n private targetDataLength!: number;\r\n private unsubFaceLost?: () => void;\r\n private unsubCompleted?: () => void;\r\n \r\n constructor(config: DataProcessingManagerConfig, eventEmitter: EventEmitter) {\r\n super(config, eventEmitter);\r\n this.targetDataLength = config.targetDataLength;\r\n this._dataBucket = {\r\n sigR: [],\r\n sigG: [],\r\n sigB: [],\r\n timestamps: [],\r\n };\r\n logger.debug('DataProcessingManager initialized', { targetDataLength: config.targetDataLength });\r\n }\r\n\r\n get dataBucket(): DataBucket {\r\n return this._dataBucket;\r\n }\r\n\r\n get progressPercentage(): number {\r\n return this._progressPercentage;\r\n }\r\n \r\n /**\r\n * 워커 초기화 및 오프스크린 캔버스 연결\r\n */\r\n async initialize(extractingCanvas: HTMLCanvasElement, willReadFrequently: boolean = false): Promise<void> {\r\n this.unSubscribeEventListeners();\r\n \r\n try {\r\n await this.initializeWorkerService();\r\n this.clearDataBucket();\r\n this.setUpEventListeners();\r\n this.updateProgressPercentage(0);\r\n await this.connectVideoCanvas(extractingCanvas, willReadFrequently);\r\n logger.debug('DataProcessingManager Worker initialized');\r\n } catch (error) {\r\n logger.error('DataProcessingManager initialization failed', error);\r\n }\r\n }\r\n \r\n reset(): void {\r\n this.clearDataBucket();\r\n if (this.workerService) {\r\n this.workerService.clearPendingRequests();\r\n }\r\n this.updateProgressPercentage(0);\r\n }\r\n\r\n async dispose(): Promise<void> {\r\n this.stop();\r\n if (this.workerService) {\r\n this.workerService.dispose();\r\n this.workerService = null!;\r\n }\r\n this.unSubscribeEventListeners();\r\n }\r\n\r\n stop(): void {\r\n this.clearDataBucket();\r\n if (this.workerService) {\r\n this.workerService.clearPendingRequests();\r\n }\r\n }\r\n\r\n /**\r\n * 오프스크린 캔버스를 워커에 전송하여 연결\r\n */\r\n async connectVideoCanvas(extractingCanvas: HTMLCanvasElement, willReadFrequently: boolean = false): Promise<boolean> {\r\n // 분기 처리 더 상세하게 수정할 필요 있음\r\n if (extractingCanvas.dataset.transferred === 'true') {\r\n return true;\r\n }\r\n try {\r\n const offscreenExtractingCanvas = extractingCanvas.transferControlToOffscreen();\r\n extractingCanvas.dataset.transferred = 'true';\r\n const message: ConnectVideoCanvasMessage = {\r\n type: 'connectVideoCanvas',\r\n id: this.workerService.generateRequestId(),\r\n extractingCanvas: offscreenExtractingCanvas,\r\n willReadFrequently: willReadFrequently,\r\n };\r\n \r\n const response = await this.workerService.sendRequest<ConnectVideoCanvasResponse>(message,[offscreenExtractingCanvas]);\r\n \r\n return response.success;\r\n } catch (error) {\r\n logger.error('Offscreen canvas connection failed', error);\r\n return false;\r\n }\r\n }\r\n\r\n /**\r\n * 비디오 캔버스 연결 해제 요청을 보내고 완료를 대기\r\n */\r\n async releaseVideoCanvas(): Promise<boolean> {\r\n const message: ReleaseVideoCanvasMessage = {\r\n type: 'releaseVideoCanvas',\r\n id: this.workerService.generateRequestId(),\r\n };\r\n \r\n const response = await this.workerService.sendRequest<ReleaseVideoCanvasResponse>(message);\r\n return response.success;\r\n }\r\n\r\n /**\r\n * 프레임과 얼굴 위치, 옵션을 워커에 전달하여 처리 요청\r\n */\r\n processFrame(frame: ImageBitmap, isWarmUp: boolean = false): void {\r\n const message: ProcessFrameMessage = {\r\n type: 'processFrame',\r\n frame: frame,\r\n isWarmUp: isWarmUp, \r\n };\r\n this.workerService.sendMessage(message, [frame]);\r\n }\r\n\r\n /**\r\n * 이벤트 구독 설정: 얼굴 유실 시 버킷 초기화 등\r\n */\r\n private setUpEventListeners(): void {\r\n this.unsubFaceLost?.();\r\n this.unsubFaceLost = this.eventEmitter.subscribe('face:lost', () => {\r\n this.clearDataBucket();\r\n this.updateProgressPercentage(this.calculateProgressPercentage());\r\n });\r\n this.unsubCompleted?.();\r\n this.unsubCompleted = this.eventEmitter.subscribe('system:completed', () => {\r\n });\r\n }\r\n\r\n private async initializeWorkerService(): Promise<void> {\r\n this.workerService = new WorkerService();\r\n \r\n await this.workerService.initialize({\r\n workerUrl: new URL('../../workers/worker.js', import.meta.url).href,\r\n messageHandlers: {\r\n 'processFrameResponse': (message: ProcessFrameResponse) => {\r\n this.handleProcessFrameResponse(message);\r\n }\r\n }\r\n });\r\n }\r\n\r\n unSubscribeEventListeners(): void {\r\n this.unsubFaceLost?.();\r\n this.unsubFaceLost = undefined;\r\n this.unsubCompleted?.();\r\n this.unsubCompleted = undefined;\r\n }\r\n\r\n isDataBucketFull(): boolean {\r\n return this._dataBucket.timestamps.length >= this.targetDataLength;\r\n }\r\n\r\n\r\n private clearDataBucket(): void {\r\n this._dataBucket = {\r\n sigR: [],\r\n sigG: [],\r\n sigB: [],\r\n timestamps: [],\r\n };\r\n }\r\n \r\n private pushToDataBucket(rgbData: ExtractedRGB): void {\r\n this._dataBucket.sigR.push(rgbData.sigR as number);\r\n this._dataBucket.sigG.push(rgbData.sigG as number);\r\n this._dataBucket.sigB.push(rgbData.sigB as number);\r\n this._dataBucket.timestamps.push(rgbData.timestamp);\r\n }\r\n\r\n private getReport(): Report {\r\n return {\r\n rawData: this._dataBucket,\r\n quality: {\r\n positionError: 0,\r\n yPositionError: 0,\r\n dataPointCount: this._dataBucket.timestamps.length,\r\n }\r\n };\r\n }\r\n\r\n private calculateProgressPercentage(): number {\r\n return Math.round((this._dataBucket.timestamps.length / this.targetDataLength) * 100);\r\n }\r\n\r\n private updateProgressPercentage(number: number): void {\r\n this._progressPercentage = number;\r\n this.eventEmitter.emit(\"progress:updated\", this._progressPercentage);\r\n }\r\n\r\n /**\r\n * 워커 프레임 처리 응답\r\n * - RGB 누적, 진행률 갱신, 완료 시 완료 이벤트 발행\r\n */\r\n private handleProcessFrameResponse(message: ProcessFrameResponse): void {\r\n if (message.isWarmUp) {\r\n this.eventEmitter.emit('frame:warmUp', undefined);\r\n return;\r\n }\r\n if (message.extractedRGB !== null) {\r\n this.pushToDataBucket(message.extractedRGB);\r\n this.updateProgressPercentage(this.calculateProgressPercentage());\r\n if (this.isDataBucketFull()) {\r\n this.updateProgressPercentage(100);\r\n logger.debug('Data collection completed', { \r\n dataPoints: this._dataBucket.timestamps.length,\r\n target: this.targetDataLength \r\n });\r\n this.eventEmitter.emit('system:completed', this.getReport());\r\n }\r\n }\r\n }\r\n\r\n}","import type { ROI, WebCamManagerConfig } from '@face-detector/types';\r\nimport { EventEmitter } from '../events/EventEmitter';\r\nimport { BaseManager } from './BaseManager.abstract';\r\nimport { logger } from '../utils/logger';\r\n\r\nconst VIDEO_READY_STATE = 3;\r\n\r\nexport class WebCamManager extends BaseManager {\r\n private webCamStream: MediaStream | null = null;\r\n private videoElement: HTMLVideoElement;\r\n\r\n private _width: number;\r\n private _height: number;\r\n\r\n private _frame: ImageBitmap | null = null;\r\n\r\n constructor(\r\n config: WebCamManagerConfig,\r\n eventEmitter: EventEmitter,\r\n videoElement: HTMLVideoElement,\r\n ) {\r\n super(config, eventEmitter);\r\n this.videoElement = videoElement;\r\n this._width = config.width;\r\n this._height = config.height;\r\n this.setupEventListeners();\r\n logger.debug('WebCamManager initialized', {\r\n size: `${config.width}x${config.height}`,\r\n });\r\n }\r\n\r\n get frame(): ImageBitmap | null {\r\n return this._frame;\r\n }\r\n\r\n get width(): number {\r\n return this._width;\r\n }\r\n\r\n get height(): number {\r\n return this._height;\r\n }\r\n\r\n /**\r\n * 브라우저 미디어 스트림을 초기화하고 video 요소에 연결\r\n */\r\n public initialize(): Promise<void> {\r\n return new Promise(async (resolve, reject) => {\r\n try {\r\n this.webCamStream = await navigator.mediaDevices.getUserMedia({\r\n video: {\r\n width: this.width,\r\n height: this.height,\r\n },\r\n });\r\n\r\n this.handleWebcamMalfunction();\r\n\r\n this.videoElement.srcObject = this.webCamStream;\r\n\r\n this.videoElement.oncanplay = () => {\r\n this.startWebcam();\r\n logger.debug('Webcam stream connected', {\r\n size: `${this.width}x${this.height}`,\r\n });\r\n resolve();\r\n };\r\n\r\n this.videoElement.oninvalid;\r\n\r\n this.videoElement.onerror = (err) => {\r\n logger.error('Video stream load failed', err);\r\n this.eventEmitter.emit('webcam:videoError', undefined);\r\n reject(new Error(`Video stream load failed: ${err}`));\r\n };\r\n } catch (err) {\r\n logger.error('Webcam initialization failed', err);\r\n reject(err);\r\n }\r\n });\r\n }\r\n\r\n async reset(): Promise<void> {\r\n this.stopWebCam();\r\n await this.initialize();\r\n }\r\n\r\n public startWebcam(): void {\r\n try {\r\n this.videoElement.play();\r\n } catch (err) {\r\n logger.error('Webcam start failed', err);\r\n }\r\n }\r\n\r\n public stopWebCam(): void {\r\n this.webCamStream?.getTracks().forEach((track) => track.stop());\r\n this.webCamStream = null;\r\n logger.debug('Webcam stream stopped');\r\n }\r\n\r\n public validateVideoReadyState(): boolean {\r\n if (this.videoElement.readyState < VIDEO_READY_STATE) {\r\n return false;\r\n }\r\n return true;\r\n }\r\n\r\n /**\r\n * 웹캠 프레임 캡처\r\n * - 얼굴인식이 필요한 경우 전체 프레임 캡처\r\n * - 얼굴인식이 필요하지 않은 경우 ROI 범위 내에서만 캡처\r\n * @param roi ROI 범위\r\n */\r\n public async captureFrame(roi: ROI): Promise<void> {\r\n try {\r\n this._frame = await createImageBitmap(this.videoElement, roi.sx, roi.sy, roi.sw, roi.sh);\r\n } catch (err) {\r\n logger.error('Frame capture failed', err);\r\n }\r\n }\r\n\r\n private setupEventListeners(): void {}\r\n\r\n private handleWebcamMalfunction(): void {\r\n this.webCamStream?.getTracks()[0]?.addEventListener('mute', () => {\r\n console.log('webcam muted');\r\n this.checkWebcamMutedAfterTimeout(1000);\r\n });\r\n this.webCamStream?.getTracks()[0]?.addEventListener('ended', () => {\r\n console.log('webcam ended');\r\n this.eventEmitter.emit('webcam:streamEnded', undefined);\r\n });\r\n }\r\n\r\n private checkWebcamMutedAfterTimeout(timeout: number): void {\r\n setTimeout(() => {\r\n const tracks = this.webCamStream?.getTracks();\r\n if (tracks) {\r\n const track = tracks[0];\r\n if (track.muted) {\r\n this.eventEmitter.emit('webcam:streamMuted', undefined);\r\n }\r\n }\r\n }, timeout);\r\n }\r\n\r\n public stop(): void {\r\n if (this.videoElement) {\r\n this.videoElement.pause();\r\n }\r\n this._frame = null;\r\n }\r\n\r\n public dispose(): void {\r\n this.stop();\r\n this.stopWebCam();\r\n if (this.videoElement) {\r\n this.videoElement.srcObject = null;\r\n }\r\n }\r\n\r\n public clearFrame(): void {\r\n this._frame = null;\r\n }\r\n}\r\n","import type { Config, MeasurementConfig, FacePosition } from '@face-detector/types';\r\nimport { EventEmitter } from '../events/EventEmitter';\r\nimport { FaceDetectionManager } from '../managers/FaceDetectionManager';\r\nimport { DataProcessingManager } from '../managers/DataProcessingManager';\r\nimport { logger } from '../utils/logger';\r\nimport { WebCamManager } from '../managers/WebCamManager';\r\nimport { calculateInterval } from '../utils/calculateInterval';\r\nimport { clamp } from '../utils/clamp';\r\n\r\nexport class MeasurementLoop {\r\n private eventEmitter: EventEmitter;\r\n private measurementConfig: MeasurementConfig;\r\n\r\n private _shouldExcuteRGBExtraction: boolean;\r\n\r\n private _countdown!: number;\r\n\r\n private isRunning: boolean = false;\r\n private processingTimer?: NodeJS.Timeout;\r\n private unsubscribeFunctions: (() => void)[] = [];\r\n\r\n private dataProcessingManager: DataProcessingManager;\r\n private faceDetectionManager: FaceDetectionManager;\r\n private webCamManager: WebCamManager;\r\n\r\n private loopCount!: number;\r\n private exptected!: number;\r\n private minSpacing!: number;\r\n\r\n constructor(config: Config, eventEmitter: EventEmitter) {\r\n this.eventEmitter = eventEmitter;\r\n this.measurementConfig = config.measurementConfig;\r\n this.dataProcessingManager = new DataProcessingManager(\r\n config.dataProcessingManagerConfig,\r\n eventEmitter,\r\n );\r\n this.faceDetectionManager = new FaceDetectionManager(\r\n config.faceDetectionManagerConfig,\r\n eventEmitter,\r\n );\r\n this.webCamManager = new WebCamManager(\r\n config.webCamManagerConfig,\r\n eventEmitter,\r\n config.measurementConfig.videoElement,\r\n );\r\n this._shouldExcuteRGBExtraction = true;\r\n\r\n logger.debug('MeasurementLoop initialized', {\r\n countdown: config.measurementConfig.countdown,\r\n processingFps: config.measurementConfig.processingFps,\r\n targetDataLength: config.dataProcessingManagerConfig.targetDataLength,\r\n });\r\n\r\n this.resetProcess();\r\n this.setUpEventListeners();\r\n }\r\n\r\n get shouldExcuteRGBExtraction(): boolean {\r\n return this._shouldExcuteRGBExtraction;\r\n }\r\n\r\n get countdown(): number {\r\n return this._countdown;\r\n }\r\n\r\n set countdown(countdown: number) {\r\n this._countdown = countdown;\r\n }\r\n\r\n get progressPercentage(): number {\r\n return this.dataProcessingManager.progressPercentage;\r\n }\r\n\r\n get facePosition(): FacePosition | null {\r\n return this.faceDetectionManager.facePosition;\r\n }\r\n\r\n private setUpEventListeners(): void {\r\n // 기존 구독해제\r\n this.unsubscribeFunctions.forEach((unsubscribe) => unsubscribe());\r\n this.unsubscribeFunctions = [];\r\n\r\n // 새로운 구독 및 해제 함수 저장\r\n this.unsubscribeFunctions.push(\r\n this.eventEmitter.subscribe('system:initialize', async () => {\r\n await this.initialize();\r\n }),\r\n );\r\n\r\n this.unsubscribeFunctions.push(\r\n this.eventEmitter.subscribe('system:reset', () => {\r\n this.resetSystem();\r\n }),\r\n );\r\n\r\n this.unsubscribeFunctions.push(\r\n this.eventEmitter.subscribe('system:start', () => {\r\n this.startSystem();\r\n }),\r\n );\r\n\r\n this.unsubscribeFunctions.push(\r\n this.eventEmitter.subscribe('system:completed', () => {\r\n this.stopSystem();\r\n }),\r\n );\r\n\r\n this.unsubscribeFunctions.push(\r\n this.eventEmitter.subscribe('system:stop', () => {\r\n this.stopSystem();\r\n }),\r\n );\r\n\r\n this.unsubscribeFunctions.push(\r\n this.eventEmitter.subscribe('system:terminate', async () => {\r\n await this.terminateSystem();\r\n }),\r\n );\r\n\r\n this.unsubscribeFunctions.push(\r\n this.eventEmitter.subscribe('system:toggleMeasure', (shouldExcuteRGBExtraction: boolean) => {\r\n this.enableRGBExtraction(shouldExcuteRGBExtraction);\r\n }),\r\n );\r\n\r\n this.unsubscribeFunctions.push(\r\n this.eventEmitter.subscribe('webcam:streamMuted', () => {\r\n this.eventEmitter.emit('system:failed', new Error('Webcam stream muted'));\r\n this.stopSystem();\r\n }),\r\n );\r\n\r\n this.unsubscribeFunctions.push(\r\n this.eventEmitter.subscribe('webcam:videoError', () => {\r\n this.eventEmitter.emit('system:failed', new Error('Webcam video error'));\r\n this.stopSystem();\r\n }),\r\n );\r\n\r\n this.unsubscribeFunctions.push(\r\n this.eventEmitter.subscribe('webcam:streamEnded', () => {\r\n this.eventEmitter.emit('system:failed', new Error('Webcam stream ended'));\r\n this.stopSystem();\r\n }),\r\n );\r\n }\r\n\r\n /**\r\n * 웹캠/데이터/검출 매니저를 병렬 초기화하고 상태를 갱신\r\n */\r\n private async initialize(): Promise<void> {\r\n this.resetProcess();\r\n try {\r\n (await this.webCamManager.initialize(),\r\n await Promise.all([\r\n this.dataProcessingManager.initialize(\r\n this.measurementConfig.extractingCanvas,\r\n this.measurementConfig.willReadFrequently,\r\n ),\r\n this.faceDetectionManager.initialize(this.measurementConfig.videoElement),\r\n ]));\r\n await this.processWarmUpWhileInitializing();\r\n this.eventEmitter.emit('system:ready', undefined);\r\n } catch (err) {\r\n logger.error('MeasurementLoop initialization failed', err);\r\n this.eventEmitter.emit('system:failed', err as Error);\r\n }\r\n }\r\n\r\n /**\r\n * 각 매니저를 재초기화하고 READY 상태로 복귀\r\n */\r\n private resetSystem(): void {\r\n try {\r\n this.dataProcessingManager.reset();\r\n this.faceDetectionManager.reset();\r\n this.resetProcess();\r\n this.eventEmitter.emit('system:ready', undefined);\r\n } catch (err) {\r\n logger.error('MeasurementLoop system reset failed', err);\r\n this.eventEmitter.emit('system:failed', err as Error);\r\n }\r\n }\r\n\r\n /**\r\n * 모든 리소스 해제 및 처리 타이머 정리\r\n */\r\n private async terminateSystem(): Promise<void> {\r\n this.isRunning = false;\r\n this.clearProcessingTimer();\r\n this.webCamManager.dispose();\r\n this.dataProcessingManager.dispose();\r\n this.faceDetectionManager.dispose();\r\n this.dispose();\r\n }\r\n\r\n /**\r\n * MeasurementLoop의 이벤트 구독 해제\r\n */\r\n dispose(): void {\r\n this.unsubscribeFunctions.forEach((unsubscribe) => unsubscribe());\r\n this.unsubscribeFunctions = [];\r\n }\r\n\r\n /**\r\n * 루프 중지 및 각 매니저 정지, 상태를 CANCELED로 변경\r\n */\r\n private stopSystem(): void {\r\n this.isRunning = false;\r\n this.clearProcessingTimer();\r\n this.webCamManager.clearFrame();\r\n this.dataProcessingManager.stop();\r\n this.faceDetectionManager.stop();\r\n }\r\n\r\n /**\r\n * 카운트다운 후 프레임 처리 루프 시작\r\n */\r\n private async startSystem(): Promise<void> {\r\n if (this.isRunning) return;\r\n await Promise.all([this.processCountdown(), this.processWarmUpWhileCountdown()]);\r\n logger.debug('Measurement loop started');\r\n this.runLoop();\r\n }\r\n\r\n /**\r\n * 다음 프레임 처리까지의 간격을 계산해 루프를 구동\r\n * - 비디오 준비상태 검증, RUNNING 상태 알림, 간격 계산\r\n */\r\n private runLoop(): void {\r\n if (!this.webCamManager.validateVideoReadyState()) {\r\n this.eventEmitter.emit('system:failed', new Error('Video is not ready'));\r\n return;\r\n }\r\n this.isRunning = true;\r\n const frameInterval = calculateInterval(this.measurementConfig.processingFps);\r\n this.exptected = performance.now() + frameInterval;\r\n this.minSpacing = clamp(frameInterval * 0.15, 6, 12);\r\n this.processLoop(frameInterval);\r\n }\r\n\r\n /**\r\n * 지연 보정 로직으로 다음 실행 시점을 계산하며 반복 실행\r\n * - shouldContinueLoop로 종료 조건을 선확인\r\n */\r\n private processLoop(idleInterval: number): void {\r\n if (!this.shouldContinueLoop()) {\r\n this.handleLoopTermination();\r\n return;\r\n }\r\n\r\n const actualInterval = this.getCalcualteInterval();\r\n\r\n this.processingTimer = setTimeout(() => {\r\n if (!this.shouldContinueLoop()) {\r\n this.handleLoopTermination();\r\n return;\r\n }\r\n this.processFrame();\r\n\r\n this.exptected += idleInterval;\r\n\r\n if (this.shouldContinueLoop()) {\r\n this.processLoop(idleInterval);\r\n } else {\r\n this.handleLoopTermination();\r\n }\r\n }, actualInterval);\r\n }\r\n\r\n /**\r\n * 코어 사이클 : RGB 추출 및 얼굴인식 루프의 사이클\r\n *\r\n * case\r\n * - shouldExtractRGB: true && shouldValidateFacePosition: true : 비디오 프레임 전체 캡처\r\n * - shouldExtractRGB: true && shouldValidateFacePosition: false : ROI 범위 내에서만 캡처\r\n * - shouldExtractRGB: false && shouldValidateFacePosition: true : 비디오 프레임 전체 캡처\r\n * - shouldExtractRGB: false && shouldValidateFacePosition: false : 아무것도 하지 않음\r\n */\r\n private async processFrame(): Promise<void> {\r\n this.loopCount++;\r\n\r\n const shouldValidateFacePosition = this.shouldValidateFacePosition();\r\n const shouldExtractRGB = this.shouldExtractRGB();\r\n this.emitProcessingState(shouldExtractRGB);\r\n\r\n if (shouldValidateFacePosition) {\r\n this.faceDetectionManager.validateFacePosition(this.measurementConfig.videoElement);\r\n }\r\n\r\n if (!shouldExtractRGB && !shouldValidateFacePosition) return;\r\n\r\n if (shouldExtractRGB) {\r\n await this.extractAndProcessRGB();\r\n }\r\n }\r\n\r\n /**\r\n * 얼굴 위치 검증 상태에 따른 이벤트 발생\r\n */\r\n private emitProcessingState(shouldExtractRGB: boolean): void {\r\n if (shouldExtractRGB) {\r\n this.eventEmitter.emit('system:measuring', undefined);\r\n }\r\n }\r\n\r\n /**\r\n * RGB 데이터 추출 및 처리\r\n */\r\n private async extractAndProcessRGB(): Promise<void> {\r\n if (this.faceDetectionManager.ROI === null) return;\r\n\r\n await this.webCamManager.captureFrame(this.faceDetectionManager.ROI);\r\n this.dataProcessingManager.processFrame(this.webCamManager.frame!);\r\n }\r\n\r\n /**\r\n * 워밍업 사이클\r\n */\r\n private async warmUp(): Promise<void> {\r\n this.faceDetectionManager.validateFacePosition(this.measurementConfig.videoElement, true);\r\n if (this.webCamManager.frame !== null) {\r\n this.dataProcessingManager.processFrame(this.webCamManager.frame, true);\r\n }\r\n }\r\n\r\n private shouldContinueLoop(): boolean {\r\n // 기본 상태 체크\r\n if (!this.isRunning) {\r\n return false;\r\n }\r\n\r\n // 웹캠 상태 체크\r\n if (!this.webCamManager.validateVideoReadyState()) {\r\n this.eventEmitter.emit('system:failed', new Error('Video connection lost'));\r\n return false;\r\n }\r\n\r\n // 데이터 수집 완료 체크\r\n if (this.dataProcessingManager.isDataBucketFull()) {\r\n return false; // system:completed는 DataProcessingManager에서 발생\r\n }\r\n\r\n return true;\r\n }\r\n\r\n private handleLoopTermination(): void {\r\n this.isRunning = false;\r\n this.clearProcessingTimer();\r\n }\r\n\r\n /**\r\n * 초 단위 카운트다운을 진행하며 매 초마다 이벤트 발행\r\n */\r\n private async processCountdown(): Promise<void> {\r\n this.countdown = this.measurementConfig.countdown;\r\n while (this.countdown > 0) {\r\n this.countdown--;\r\n this.eventEmitter.emit('countdown:tick', this.countdown);\r\n await new Promise((resolve) => setTimeout(resolve, 1000));\r\n }\r\n }\r\n\r\n /**\r\n * 카운트다운 중 워밍업 처리\r\n */\r\n private async processWarmUpWhileCountdown(): Promise<void> {\r\n while (this.countdown > 0) {\r\n await this.warmUp();\r\n await new Promise((resolve) => setTimeout(resolve, 100));\r\n }\r\n }\r\n\r\n /**\r\n * 초기화 중 워밍업 처리\r\n */\r\n private async processWarmUpWhileInitializing(): Promise<void> {\r\n for (let i = 0; i < 5; i++) {\r\n await this.warmUp();\r\n await new Promise((resolve) => setTimeout(resolve, 100));\r\n }\r\n }\r\n\r\n private clearProcessingTimer(): void {\r\n if (this.processingTimer) {\r\n clearTimeout(this.processingTimer);\r\n this.processingTimer = undefined;\r\n }\r\n }\r\n\r\n private resetProcess(): void {\r\n this.countdown = 0;\r\n this.loopCount = 0;\r\n this.exptected = 0;\r\n this._shouldExcuteRGBExtraction = true;\r\n this.clearProcessingTimer();\r\n }\r\n\r\n /**\r\n * 지연 보정: 예상 시점 대비 뒤처짐 여부에 따라 최소 지연으로 보정\r\n */\r\n private getCalcualteInterval(): number {\r\n const now = performance.now();\r\n const baseDelay = this.exptected - now;\r\n const behind = baseDelay <= 0;\r\n\r\n const delay = behind ? this.minSpacing : Math.max(baseDelay, this.minSpacing);\r\n\r\n return Math.max(delay, 0);\r\n }\r\n\r\n private shouldValidateFacePosition(): boolean {\r\n return (\r\n this.loopCount %\r\n (this.measurementConfig.processingFps / this.measurementConfig.validationFps) ===\r\n 0\r\n );\r\n }\r\n\r\n private shouldExtractRGB(): boolean {\r\n return this.faceDetectionManager.isDetected && this.shouldExcuteRGBExtraction;\r\n }\r\n\r\n enableRGBExtraction(shouldExcuteRGBExtraction: boolean): void {\r\n try {\r\n if (this._shouldExcuteRGBExtraction === shouldExcuteRGBExtraction) {\r\n return;\r\n }\r\n this._shouldExcuteRGBExtraction = shouldExcuteRGBExtraction;\r\n if (this._shouldExcuteRGBExtraction) {\r\n this.dataProcessingManager.reset();\r\n this.eventEmitter.emit('system:measuring', undefined);\r\n } else {\r\n this.eventEmitter.emit('system:running', undefined);\r\n }\r\n } catch (err) {\r\n logger.error('RGB extraction toggle failed', err);\r\n this.eventEmitter.emit('system:failed', err as Error);\r\n }\r\n }\r\n}\r\n","export const calculateInterval = (fps: number) => {\r\n return 1000 / fps;\r\n}","export function clamp(v: number, lo: number, hi: number): number {\r\n if (lo > hi) [lo, hi] = [hi, lo];\r\n \r\n if (Number.isNaN(v)) return v;\r\n \r\n return Math.max(lo, Math.min(v, hi));\r\n }","import { ProcessState } from '@face-detector/types';\r\nimport { EventEmitter } from '../events/EventEmitter';\r\nimport { logger } from '../utils/logger';\r\n\r\nexport class StateMachine {\r\n private state: ProcessState;\r\n private eventEmitter: EventEmitter;\r\n private unsubscribeFunctions: (() => void)[] = [];\r\n private validTransitions: Record<ProcessState, ProcessState[]> = {\r\n [ProcessState.INITIALIZING]: [\r\n ProcessState.READY,\r\n ProcessState.FAILED,\r\n ProcessState.INITIALIZING,\r\n ],\r\n [ProcessState.READY]: [\r\n ProcessState.RUNNING,\r\n ProcessState.MEASURING,\r\n ProcessState.INITIALIZING,\r\n ProcessState.FAILED,\r\n ProcessState.READY,\r\n ],\r\n [ProcessState.RUNNING]: [\r\n ProcessState.MEASURING,\r\n ProcessState.READY,\r\n ProcessState.FAILED,\r\n ProcessState.RUNNING,\r\n ],\r\n [ProcessState.MEASURING]: [\r\n ProcessState.RUNNING,\r\n ProcessState.COMPLETED,\r\n ProcessState.READY,\r\n ProcessState.FAILED,\r\n ProcessState.MEASURING,\r\n ],\r\n [ProcessState.COMPLETED]: [\r\n ProcessState.INITIALIZING,\r\n ProcessState.FAILED,\r\n ProcessState.COMPLETED,\r\n ],\r\n [ProcessState.FAILED]: [ProcessState.INITIALIZING, ProcessState.FAILED],\r\n };\r\n\r\n constructor(eventEmitter: EventEmitter) {\r\n this.state = ProcessState.INITIALIZING;\r\n this.eventEmitter = eventEmitter;\r\n this.setupEventListeners();\r\n logger.debug('StateMachine initialized');\r\n }\r\n\r\n getState(): ProcessState {\r\n return this.state;\r\n }\r\n\r\n setState(newState: ProcessState): void {\r\n const previousState = this.state;\r\n if (!this.isValidTransition(previousState, newState)) {\r\n logger.warn('Invalid state transition', { from: previousState, to: newState });\r\n return;\r\n }\r\n if (previousState !== newState) {\r\n this.state = newState;\r\n logger.debug('State changed', { from: previousState, to: newState });\r\n }\r\n this.eventEmitter.emit('state:changed', newState);\r\n }\r\n\r\n /**\r\n * 시스템 이벤트를 상태 변경으로 매핑\r\n * - 각 이벤트 수신 시 대응되는 상태로 전이\r\n */\r\n private setupEventListeners(): void {\r\n this.unsubscribeFunctions.push(\r\n this.eventEmitter.subscribe('system:initialize', () => {\r\n this.setState(ProcessState.INITIALIZING);\r\n }),\r\n );\r\n\r\n this.unsubscribeFunctions.push(\r\n this.eventEmitter.subscribe('system:ready', () => {\r\n this.setState(ProcessState.READY);\r\n }),\r\n );\r\n\r\n this.unsubscribeFunctions.push(\r\n this.eventEmitter.subscribe('system:start', () => {\r\n this.setState(ProcessState.RUNNING);\r\n }),\r\n );\r\n\r\n this.unsubscribeFunctions.push(\r\n this.eventEmitter.subscribe('system:running', () => {\r\n this.setState(ProcessState.RUNNING);\r\n }),\r\n );\r\n\r\n this.unsubscribeFunctions.push(\r\n this.eventEmitter.subscribe('system:measuring', () => {\r\n this.setState(ProcessState.MEASURING);\r\n }),\r\n );\r\n\r\n this.unsubscribeFunctions.push(\r\n this.eventEmitter.subscribe('system:completed', () => {\r\n this.setState(ProcessState.COMPLETED);\r\n }),\r\n );\r\n\r\n this.unsubscribeFunctions.push(\r\n this.eventEmitter.subscribe('system:stop', () => {\r\n this.setState(ProcessState.READY);\r\n }),\r\n );\r\n\r\n this.unsubscribeFunctions.push(\r\n this.eventEmitter.subscribe('system:failed', () => {\r\n this.setState(ProcessState.FAILED);\r\n }),\r\n );\r\n\r\n this.unsubscribeFunctions.push(\r\n this.eventEmitter.subscribe('system:reset', () => {\r\n this.setState(ProcessState.INITIALIZING);\r\n }),\r\n );\r\n }\r\n\r\n dispose(): void {\r\n this.unsubscribeFunctions.forEach((unsubscribe) => unsubscribe());\r\n this.unsubscribeFunctions = [];\r\n }\r\n\r\n private isValidTransition(from: ProcessState, to: ProcessState): boolean {\r\n return this.validTransitions[from]?.includes(to) ?? false;\r\n }\r\n}\r\n","import type { VideoProcessingEvents, EventNames } from '../../types/events.types';\r\n\r\n/**\r\n * 이벤트 발행/구독을 단순화한 경량 이벤트 시스템\r\n * - 제네릭으로 이벤트 페이로드 타입을 보장\r\n */\r\nexport class EventEmitter {\r\n private events: { [K in EventNames]?: ((data: VideoProcessingEvents[K]) => void)[] } = {};\r\n\r\n /**\r\n * 동기적으로 리스너들을 호출하여 이벤트를 전달\r\n * @param eventType 이벤트 이름\r\n * @param data 이벤트 페이로드\r\n */\r\n emit<T extends EventNames>(eventType: T, data: VideoProcessingEvents[T]): void {\r\n if (this.events[eventType]) {\r\n (this.events[eventType] as any[]).forEach(callback => callback(data));\r\n }\r\n }\r\n\r\n /**\r\n * 이벤트를 구독하고 해제 함수 반환\r\n * @param eventType 이벤트 이름\r\n * @param callback 수신 콜백\r\n * @returns 구독 해제 함수\r\n */\r\n subscribe<T extends EventNames>(\r\n eventType: T, \r\n callback: (data: VideoProcessingEvents[T]) => void\r\n ): () => void {\r\n if (!this.events[eventType]) {\r\n this.events[eventType] = [];\r\n }\r\n (this.events[eventType] as any[]).push(callback);\r\n \r\n // unsubscribe 함수 반환\r\n return () => {\r\n if (this.events[eventType]) {\r\n this.events[eventType] = (this.events[eventType] as any[]).filter((cb) => cb !== callback);\r\n }\r\n };\r\n }\r\n\r\n /**\r\n * 특정 콜백을 이벤트에서 해제\r\n * @param eventType 이벤트 이름\r\n * @param callback 해제할 콜백\r\n */\r\n unsubscribe<T extends EventNames>(\r\n eventType: T, \r\n callback: (data: VideoProcessingEvents[T]) => void\r\n ): void {\r\n if (this.events[eventType]) {\r\n this.events[eventType] = (this.events[eventType] as any[]).filter((cb) => cb !== callback);\r\n }\r\n }\r\n\r\n /**\r\n * 특정 이벤트 또는 전체 이벤트에 등록된 리스너를 제거\r\n * @param eventType 생략 시 전체 제거\r\n */\r\n removeAllListeners<T extends EventNames>(eventType?: T): void {\r\n if (eventType) {\r\n delete this.events[eventType];\r\n } else {\r\n this.events = {};\r\n }\r\n }\r\n\r\n}\r\n\r\n","import type { Config, ROI } from \"@face-detector/types\";\r\n\r\nexport const defaultConfig = (videoElement: HTMLVideoElement, extractingCanvasElement: HTMLCanvasElement): Config => {\r\n return { \r\n faceDetectionManagerConfig: {\r\n minDetectionConfidence: 0.5,\r\n delegate: \"CPU\",\r\n },\r\n webCamManagerConfig: {\r\n width: 640,\r\n height: 480,\r\n },\r\n measurementConfig: {\r\n countdown: 3,\r\n processingFps: 30,\r\n validationFps: 1,\r\n retryCount: 3,\r\n extractingCanvas: extractingCanvasElement,\r\n videoElement: videoElement,\r\n willReadFrequently: false,\r\n },\r\n dataProcessingManagerConfig: {\r\n targetDataLength: 450,\r\n },\r\n debug: true,\r\n }\r\n}; \r\n\r\n\r\nexport const warmUpROI: ROI = {\r\n sx: 200,\r\n sy: 350,\r\n sw: 80,\r\n sh: 80,\r\n};","import type { IFaceDetector as FaceDetectorType, Config, Report, FacePosition } from '@face-detector/types';\r\nimport { ProcessState } from '@face-detector/types';\r\nimport { MeasurementLoop } from './core/MeasurementLoop';\r\nimport { StateMachine } from './state/StateMachine';\r\nimport { EventEmitter } from './events/EventEmitter';\r\nimport { defaultConfig } from './configs/defaultConfig';\r\nimport { logger } from './utils/logger';\r\n\r\nexport class FaceDetector implements FaceDetectorType {\r\n\r\n\r\n private stateMachine: StateMachine;\r\n private measurementLoop: MeasurementLoop;\r\n private eventEmitter: EventEmitter;\r\n \r\n private activeUnsubscribeCompleted?: () => void;\r\n private activeUnsubscribeFailed?: () => void;\r\n\r\n constructor(config: Config);\r\n constructor(videoElement: HTMLVideoElement, extractingCanvasElement: HTMLCanvasElement);\r\n\r\n constructor(configOrVideoElement: Config | HTMLVideoElement, extractingCanvasElement?: HTMLCanvasElement) {\r\n let config: Config;\r\n \r\n if (configOrVideoElement instanceof HTMLVideoElement) {\r\n config = defaultConfig(configOrVideoElement!, extractingCanvasElement!);\r\n } else {\r\n config = configOrVideoElement;\r\n }\r\n \r\n logger.setDebugMode(config.debug);\r\n logger.debug('FaceDetector initialization started', { debug: config.debug });\r\n \r\n this.eventEmitter = new EventEmitter();\r\n this.stateMachine = new StateMachine(this.eventEmitter);\r\n this.measurementLoop = new MeasurementLoop(config, this.eventEmitter); \r\n this.eventEmitter.emit('system:initialize', undefined);\r\n }\r\n \r\n\r\n getState(): ProcessState {\r\n retu