/** * RAP-1.0 - Core: CameraManager.js * Responsável estritamente pelo controle de hardware, captura e fornecimento de ImageBitmap e metadados. */ class CameraError extends Error { constructor(code, message) { super(message); this.code = code; this.name = "CameraError"; } } class CameraManager { constructor(config = {}) { this.config = { width: config.width || 1280, height: config.height || 720, frameRate: config.frameRate || 60, facingMode: config.facingMode || "environment", preferImageBitmap: config.preferImageBitmap !== false, autoRestart: config.autoRestart !== false, mirror: config.mirror || false }; this.videoElement = null; this.stream = null; this.canvas = null; this.ctx = null; this.isRunning = false; this.isPaused = false; this.animationFrameId = null; this.frameNumber = 0; this.lastTimestamp = performance.now(); this.currentFps = 0; this.currentBitmap = null; this.currentMetadata = null; this.listeners = { ready: [], start: [], frame: [], pause: [], resume: [], error: [], stop: [] }; } _emit(event, payload) { if (this.listeners[event]) { this.listeners[event].forEach(callback => callback(payload)); } } onFrame(callback) { if (typeof callback === 'function') { this.listeners.frame.push(callback); } } on(event, callback) { if (this.listeners[event] && typeof callback === 'function') { this.listeners[event].push(callback); } } async initialize(videoElement, options = {}) { if (!videoElement || !(videoElement instanceof HTMLVideoElement)) { throw new CameraError("INVALID_ELEMENT", "Elemento de vídeo inválido ou não fornecido."); } this.videoElement = videoElement; this.config = { ...this.config, ...options }; this.canvas = document.createElement('canvas'); this.canvas.width = this.config.width; this.canvas.height = this.config.height; this.ctx = this.canvas.getContext('2d', { willReadFrequently: false }); try { await this.start(); } catch (error) { this._emit('error', error); throw error; } } async start() { if (this.isRunning && !this.isPaused) return; try { const constraints = { video: { width: { ideal: this.config.width }, height: { ideal: this.config.height }, frameRate: { ideal: this.config.frameRate }, facingMode: this.config.facingMode }, audio: false }; this.stream = await navigator.mediaDevices.getUserMedia(constraints).catch(err => { if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') { throw new CameraError("CAMERA_PERMISSION_DENIED", "Permissão de acesso à câmera negada pelo usuário."); } else if (err.name === 'NotFoundError' || err.name === 'DevicesNotFoundError') { throw new CameraError("CAMERA_NOT_FOUND", "Nenhum dispositivo de câmera compatível encontrado."); } else if (err.name === 'NotReadableError' || err.name === 'TrackStartError') { throw new CameraError("CAMERA_IN_USE", "A câmera já está em uso por outro aplicativo."); } else { throw new CameraError("STREAM_INTERRUPTED", err.message); } }); this.videoElement.srcObject = this.stream; this.videoElement.muted = true; this.videoElement.playsInline = true; await this.videoElement.play(); const track = this.stream.getVideoTracks()[0]; track.onended = () => { if (this.isRunning) { this._emit('error', new CameraError("STREAM_INTERRUPTED", "O fluxo de vídeo foi interrompido externamente.")); if (this.config.autoRestart) { this.start().catch(() => {}); } } }; this.isRunning = true; this.isPaused = false; this.frameNumber = 0; this.lastTimestamp = performance.now(); this._emit('ready', { width: this.config.width, height: this.config.height }); this._emit('start', {}); this._loop(); } catch (error) { this._emit('error', error); throw error; } } _loop() { if (!this.isRunning) return; if (!this.isPaused && this.videoElement.readyState >= 2) { const now = performance.now(); const duration = now - this.lastTimestamp; this.currentFps = duration > 0 ? 1000 / duration : this.config.frameRate; this.lastTimestamp = now; this.frameNumber++; try { this.ctx.drawImage(this.videoElement, 0, 0, this.canvas.width, this.canvas.height); if (this.config.preferImageBitmap && typeof createImageBitmap === 'function') { createImageBitmap(this.canvas).then(bitmap => { this.currentBitmap = bitmap; this.currentMetadata = this.getMetadata(); this._emit('frame', { bitmap: this.currentBitmap, metadata: this.currentMetadata }); }).catch(() => {}); } else { this.currentMetadata = this.getMetadata(); this._emit('frame', { bitmap: this.canvas, metadata: this.currentMetadata }); } } catch (err) { this._emit('error', new CameraError("FRAME_TIMEOUT", "Falha ao processar o frame atual da câmera.")); } } this.animationFrameId = requestAnimationFrame(() => this._loop()); } pause() { if (!this.isRunning || this.isPaused) return; this.isPaused = true; if (this.videoElement) { this.videoElement.pause(); } this._emit('pause', {}); } resume() { if (!this.isRunning || !this.isPaused) return; if (this.videoElement) { this.videoElement.play().then(() => { this.isPaused = false; this.lastTimestamp = performance.now(); this._emit('resume', {}); }).catch(err => { this._emit('error', new CameraError("STREAM_INTERRUPTED", err.message)); }); } } stop() { if (!this.isRunning) return; this.isRunning = false; this.isPaused = false; if (this.animationFrameId) { cancelAnimationFrame(this.animationFrameId); this.animationFrameId = null; } if (this.stream) { this.stream.getTracks().forEach(track => track.stop()); this.stream = null; } if (this.videoElement) { this.videoElement.srcObject = null; } this._emit('stop', {}); } async switchCamera() { this.config.facingMode = this.config.facingMode === "environment" ? "user" : "environment"; this.stop(); await this.start(); } getFrame() { return { bitmap: this.currentBitmap, metadata: this.currentMetadata }; } getMetadata() { const track = this.stream ? this.stream.getVideoTracks()[0] : null; const settings = track ? track.getSettings() : {}; return { timestamp: performance.now(), width: settings.width || this.config.width, height: settings.height || this.config.height, fps: Number(this.currentFps.toFixed(2)), facingMode: settings.facingMode || this.config.facingMode, deviceId: settings.deviceId || "unknown", frameNumber: this.frameNumber }; } destroy() { this.stop(); this.listeners = { ready: [], start: [], frame: [], pause: [], resume: [], error: [], stop: [] }; if (this.canvas) { this.canvas.width = 1; this.canvas.height = 1; this.canvas = null; this.ctx = null; } } }