UNPKG

ts-webcam

Version:

A production-grade TypeScript webcam library with callback-based APIs, flexible permission handling, and comprehensive device support

706 lines 27.3 kB
import { WebcamError, WebcamErrorCode } from "./errors"; export class Webcam { constructor() { /** * Initializes the webcam state */ this.state = { status: "idle", activeStream: null, permissions: {}, error: null, }; this._disposed = false; this._debugEnabled = false; // Simple callback-based approach // todo: set default config like debug mode } /** * Enable debug logging */ enableDebug() { this._debugEnabled = true; } /** * Disable debug logging */ disableDebug() { this._debugEnabled = false; } /** * Check if debug logging is enabled */ isDebugEnabled() { return this._debugEnabled; } /** * Log debug message if debug is enabled * @param message The message to log * @param args Additional arguments to log */ debugLog(message, ...args) { if (this._debugEnabled) { console.log(`[Webcam Debug] ${message}`, ...args); } } /** * Get the current state of the webcam. * @returns WebcamState */ getState() { this._ensureNotDisposed(); return { ...this.state }; } /** * Check the current permissions of the user. * @returns Record<string, PermissionState> */ async getCurrentDevice() { if (!this.state.activeStream) return null; const tracks = this.state.activeStream.getVideoTracks(); if (tracks.length === 0) return null; const track = tracks[0]; const deviceId = track.getSettings().deviceId; // Get device info from enumerateDevices const devices = await navigator.mediaDevices.enumerateDevices(); return devices.find(d => d.deviceId === deviceId) || null; } getCurrentResolution() { if (!this.state.activeStream) return null; const tracks = this.state.activeStream.getVideoTracks(); if (tracks.length === 0) return null; const track = tracks[0]; const settings = track.getSettings(); const width = settings.width || 0; const height = settings.height || 0; return { width, height, name: `${width}x${height}` }; } async checkPermissions() { this._ensureNotDisposed(); const permissions = {}; // Check camera permission try { const cameraPerm = await navigator.permissions.query({ name: "camera" }); permissions["camera"] = cameraPerm.state; this._callPermissionChange(permissions); } catch { permissions["camera"] = "prompt"; } // Check microphone permission try { const micPerm = await navigator.permissions.query({ name: "microphone" }); permissions["microphone"] = micPerm.state; this._callPermissionChange(permissions); } catch { permissions["microphone"] = "prompt"; } this.state.permissions = permissions; return permissions; } /** * Request permissions from the user. * @param options PermissionRequestOptions * @returns Record<string, PermissionState> */ async requestPermissions(options = { video: true, audio: false }) { this._ensureNotDisposed(); try { const stream = await navigator.mediaDevices.getUserMedia({ video: options.video || false, audio: options.audio || false, }); // Stop stream immediately after getting permission stream.getTracks().forEach((track) => track.stop()); const permissions = await this.checkPermissions(); this._callPermissionChange(permissions); return permissions; } catch (error) { const permissions = await this.checkPermissions(); this._callPermissionChange(permissions); return permissions; } } /** * Get a list of available video devices. * @returns Promise<MediaDeviceInfo[]> */ async getVideoDevices() { this._ensureNotDisposed(); try { const devices = await navigator.mediaDevices.enumerateDevices(); const videoDevices = devices.filter((device) => device.kind === "videoinput"); this._callDeviceChange(videoDevices); return videoDevices; } catch (error) { const webcamError = new WebcamError("Failed to enumerate devices", WebcamErrorCode.DEVICES_ERROR); this._setError(webcamError); this._callError(webcamError); throw webcamError; } } /** * Start the camera with the provided configuration. * @param config WebcamConfiguration * @returns Promise<void> */ async startCamera(config) { this._ensureNotDisposed(); this._config = config; // Store config for callbacks try { this._setStatus("initializing"); this._callStateChange(); let stream = null; if (Array.isArray(config.preferredResolutions)) { let lastError; for (const resolution of config.preferredResolutions) { try { const constraints = await this._buildConstraints({ ...config, preferredResolutions: resolution }); stream = await navigator.mediaDevices.getUserMedia(constraints); break; // Found a working resolution } catch (error) { lastError = error instanceof Error ? error : new Error('Failed to get media stream'); continue; } } if (!stream) { throw lastError || new Error('No resolution worked'); } } else { const constraints = await this._buildConstraints(config); stream = await navigator.mediaDevices.getUserMedia(constraints); } // Configure video element if provided if (config.videoElement) { config.videoElement.srcObject = stream; this.state.videoElement = config.videoElement; } this.state.activeStream = stream; this.state.deviceInfo = config.deviceInfo; this._setStatus("ready"); this._clearError(); this._callStreamStart(stream); this._callStateChange(); } catch (error) { const webcamError = this._handleStartCameraError(error, config); this._setError(webcamError); this._callError(webcamError); this._callStateChange(); throw webcamError; } } /** * Stop the camera and release resources. */ stopCamera() { this._ensureNotDisposed(); if (this.state.activeStream) { this.state.activeStream.getTracks().forEach((track) => track.stop()); this.state.activeStream = null; this._callStreamStop(); } if (this.state.videoElement) { this.state.videoElement.srcObject = null; this.state.videoElement = undefined; } this.state.deviceInfo = undefined; this._setStatus("idle"); this._clearError(); this._callStateChange(); } /** * Capture an image from the webcam. * @param options Capture options including image type, quality, and scale * @returns A Promise that resolves with a CaptureResult object containing both blob and base64 * @example * // Basic usage - returns { blob, base64, width, height, mimeType, timestamp } * const result = await webcam.captureImage(); * console.log("Base64 image:", result.base64); * * // With options * const result = await webcam.captureImage({ * imageType: 'image/jpeg', * quality: 0.8, * scale: 0.5 * }); * * // Or destructure what you need * const { base64, blob } = await webcam.captureImage(); */ async captureImage(options = {}) { this._ensureNotDisposed(); if (!this.state.activeStream || this.state.status !== "ready") { const error = new WebcamError("Camera is not ready for capture", WebcamErrorCode.STREAM_FAILED); this._callError(error); throw error; } if (!this.state.videoElement) { const error = new WebcamError("No video element available for capture", WebcamErrorCode.VIDEO_ELEMENT_NOT_SET); this._callError(error); throw error; } try { // Set default options const captureOptions = { imageType: options.imageType || 'image/jpeg', quality: options.quality !== undefined ? Math.max(0, Math.min(1, options.quality)) : 0.92, scale: options.scale !== undefined ? Math.max(0.1, Math.min(2, options.scale)) : 1.0 }; return await this._captureFromVideoElement(this.state.videoElement, captureOptions); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error during capture'; const webcamError = new WebcamError(`Failed to capture image: ${errorMessage}`, WebcamErrorCode.CAPTURE_FAILED); this._callError(webcamError); throw webcamError; } } /** * Get the capabilities of a specific device. * @param deviceId The ID of the device to get capabilities for. * @returns A Promise that resolves with the device capabilities. */ async getDeviceCapabilities(deviceId) { var _a, _b, _c, _d, _e, _f; this._ensureNotDisposed(); try { // Test stream to get capabilities const testStream = await navigator.mediaDevices.getUserMedia({ video: { deviceId: { exact: deviceId } }, }); const track = testStream.getVideoTracks()[0]; const capabilities = track.getCapabilities(); const settings = track.getSettings(); // Stop test stream testStream.getTracks().forEach((t) => t.stop()); return { deviceId, label: track.label, maxWidth: ((_a = capabilities.width) === null || _a === void 0 ? void 0 : _a.max) || settings.width || 1920, maxHeight: ((_b = capabilities.height) === null || _b === void 0 ? void 0 : _b.max) || settings.height || 1080, minWidth: ((_c = capabilities.width) === null || _c === void 0 ? void 0 : _c.min) || 320, minHeight: ((_d = capabilities.height) === null || _d === void 0 ? void 0 : _d.min) || 240, supportedFrameRates: capabilities.frameRate && capabilities.frameRate.min !== undefined && capabilities.frameRate.max !== undefined ? [capabilities.frameRate.min, capabilities.frameRate.max] : undefined, hasZoom: "zoom" in capabilities, hasTorch: "torch" in capabilities, hasFocus: "focusMode" in capabilities, maxZoom: (_e = capabilities.zoom) === null || _e === void 0 ? void 0 : _e.max, minZoom: (_f = capabilities.zoom) === null || _f === void 0 ? void 0 : _f.min, supportedFocusModes: capabilities.focusMode, }; } catch (error) { const webcamError = new WebcamError(`Failed to get device capabilities: ${error instanceof Error ? error.message : "Unknown error"}`, WebcamErrorCode.DEVICES_ERROR); this._callError(webcamError); throw webcamError; } } dispose() { if (this._disposed) { return; } // Stop camera this.stopCamera(); // Remove event listener if (this._deviceChangeListener) { navigator.mediaDevices.removeEventListener("devicechange", this._deviceChangeListener); this._deviceChangeListener = undefined; } this._disposed = true; this._config = undefined; } // --- Private Helper Methods --- _ensureNotDisposed() { if (this._disposed) { throw new WebcamError("TsWebcam instance has been disposed", WebcamErrorCode.UNKNOWN_ERROR); } } /** * Set the status of the webcam. * @param status The new status of the webcam. */ _setStatus(status) { this.state.status = status; } /** * Set the error state of the webcam. * @param error The new error state of the webcam. */ _setError(error) { this.state.error = error; this._setStatus("error"); } /** * Clear the error state of the webcam. */ _clearError() { this.state.error = null; } /** * Call the onStateChange callback with the current state. */ _callStateChange() { var _a; if ((_a = this._config) === null || _a === void 0 ? void 0 : _a.onStateChange) { this._config.onStateChange(this.getState()); } } /** * Call the onDeviceChange callback with the current devices. * @param devices The current devices. */ _callDeviceChange(devices) { var _a; if ((_a = this._config) === null || _a === void 0 ? void 0 : _a.onDeviceChange) { this._config.onDeviceChange(devices); } } /** * Call the onStreamStart callback with the current stream. * @param stream The MediaStream that has started. */ _callStreamStart(stream) { var _a; if ((_a = this._config) === null || _a === void 0 ? void 0 : _a.onStreamStart) { this._config.onStreamStart(stream); } } /** * Call the onStreamStop callback. */ _callStreamStop() { var _a; if ((_a = this._config) === null || _a === void 0 ? void 0 : _a.onStreamStop) { this._config.onStreamStop(); } } /** * Call the onError callback with the current error. * @param error The WebcamError that has occurred. */ _callError(error) { var _a; if ((_a = this._config) === null || _a === void 0 ? void 0 : _a.onError) { this._config.onError(error); } } /** * Call the onPermissionChange callback with the current permissions. * @param permissions The current permissions. */ _callPermissionChange(permissions) { var _a; if ((_a = this._config) === null || _a === void 0 ? void 0 : _a.onPermissionChange) { this._config.onPermissionChange(permissions); } } /** * Call the onDeviceChange callback with the current devices. * @param devices The current devices. */ async _buildConstraints(config) { var _a, _b; // Validate resolution if specified if (config.preferredResolutions) { const resolutions = Array.isArray(config.preferredResolutions) ? config.preferredResolutions : [config.preferredResolutions]; for (const res of resolutions) { if (!res.width || !res.height) { throw new WebcamError(`Invalid resolution: width and height must be specified`, WebcamErrorCode.INVALID_CONFIG); } if (res.width <= 0 || res.height <= 0) { throw new WebcamError(`Invalid resolution: width and height must be positive numbers`, WebcamErrorCode.INVALID_CONFIG); } if (res.width > 4096 || res.height > 4096) { throw new WebcamError(`Invalid resolution: maximum width/height is 4096 pixels`, WebcamErrorCode.INVALID_CONFIG); } } } // Get device info if not provided if (!config.deviceInfo) { try { const devices = await navigator.mediaDevices.enumerateDevices(); const videoDevices = devices.filter(d => d.kind === 'videoinput'); if (videoDevices.length === 0) { throw new WebcamError(`No video devices found`, WebcamErrorCode.DEVICE_NOT_FOUND); } config.deviceInfo = videoDevices[0]; } catch (error) { throw new WebcamError(`Failed to get video devices: ${error instanceof Error ? error.message : 'Unknown error'}`, WebcamErrorCode.DEVICES_ERROR); } } // Build constraints const videoConstraints = { deviceId: { exact: config.deviceInfo.deviceId }, width: { exact: Array.isArray(config.preferredResolutions) ? config.preferredResolutions[0].width : ((_a = config.preferredResolutions) === null || _a === void 0 ? void 0 : _a.width) || 1280 }, height: { exact: Array.isArray(config.preferredResolutions) ? config.preferredResolutions[0].height : ((_b = config.preferredResolutions) === null || _b === void 0 ? void 0 : _b.height) || 720 }, }; return { video: videoConstraints, audio: config.enableAudio || false, }; } _handleStartCameraError(error, config) { var _a, _b; // Extract device and resolution info let deviceLabel = ((_a = config === null || config === void 0 ? void 0 : config.deviceInfo) === null || _a === void 0 ? void 0 : _a.label) || ((_b = config === null || config === void 0 ? void 0 : config.deviceInfo) === null || _b === void 0 ? void 0 : _b.deviceId) || "Unknown device"; let resolution = Array.isArray(config === null || config === void 0 ? void 0 : config.preferredResolutions) ? config === null || config === void 0 ? void 0 : config.preferredResolutions[0] : config === null || config === void 0 ? void 0 : config.preferredResolutions; let resolutionText = Array.isArray(config === null || config === void 0 ? void 0 : config.preferredResolutions) ? config === null || config === void 0 ? void 0 : config.preferredResolutions.map(r => `${r.width}x${r.height}`).join(', ') : resolution ? `${resolution.width}x${resolution.height}` : "Unknown resolution"; let context = "Start Camera"; if (error instanceof WebcamError) { // Add context if not present error.message = `[${context}] ${error.message} (Device: ${deviceLabel}, Resolution: ${resolutionText})`; return error; } let baseMsg = `[${context}] `; if ((error === null || error === void 0 ? void 0 : error.name) === "NotAllowedError") { return new WebcamError(`${baseMsg}Camera access denied (Device: ${deviceLabel}, Resolution: ${resolutionText})`, WebcamErrorCode.PERMISSION_DENIED); } if ((error === null || error === void 0 ? void 0 : error.name) === "NotFoundError") { return new WebcamError(`${baseMsg}Camera device not found (Device: ${deviceLabel}, Resolution: ${resolutionText})`, WebcamErrorCode.DEVICE_NOT_FOUND); } if ((error === null || error === void 0 ? void 0 : error.name) === "NotReadableError") { return new WebcamError(`${baseMsg}Camera is already in use (Device: ${deviceLabel}, Resolution: ${resolutionText})`, WebcamErrorCode.DEVICE_BUSY); } if ((error === null || error === void 0 ? void 0 : error.name) === "OverconstrainedError") { return new WebcamError(`${baseMsg}Camera constraints not satisfied (Device: ${deviceLabel}, Resolution: ${resolutionText})`, WebcamErrorCode.OVERCONSTRAINED); } // เพิ่มรายละเอียด error message const details = [ error === null || error === void 0 ? void 0 : error.message, error === null || error === void 0 ? void 0 : error.name, error === null || error === void 0 ? void 0 : error.code, error === null || error === void 0 ? void 0 : error.constraint, (error === null || error === void 0 ? void 0 : error.toString) && error.toString(), ] .filter(Boolean) .join(" | "); return new WebcamError(`${baseMsg}Failed to start camera: ${details || "Unknown error"} (Device: ${deviceLabel}, Resolution: ${resolutionText})`, WebcamErrorCode.UNKNOWN_ERROR); } async _captureFromVideoElement(videoElement, options) { const { imageType, quality, scale } = options; const canvas = document.createElement("canvas"); const context = canvas.getContext("2d"); if (!context) { throw new Error("Could not get canvas context"); } // Check video dimensions if (videoElement.videoWidth === 0 || videoElement.videoHeight === 0) { throw new Error("Video dimensions are zero - video may not be loaded"); } // Calculate new dimensions based on scale const width = Math.floor(videoElement.videoWidth * scale); const height = Math.floor(videoElement.videoHeight * scale); canvas.width = width; canvas.height = height; try { // Draw video frame to canvas context.drawImage(videoElement, 0, 0, videoElement.videoWidth, videoElement.videoHeight, // source 0, 0, width, height // destination ); } catch (error) { throw new Error(`Failed to draw video to canvas: ${error instanceof Error ? error.message : String(error)}`); } // Convert canvas to Blob const blob = await new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error("Capture timed out - could not convert to blob")); }, 5000); canvas.toBlob((blob) => { clearTimeout(timeout); if (!blob) { reject(new Error("Failed to create blob from canvas")); return; } resolve(blob); }, imageType, quality); }); // Convert Blob to base64 data URL const base64 = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onloadend = () => { const result = reader.result; resolve(result); // Return full data URL including prefix }; reader.onerror = reject; reader.readAsDataURL(blob); }); return { blob, base64, width, height, mimeType: imageType, timestamp: Date.now() }; } /** * Mirror control (CSS only, always supported if video element present). * @param mirror boolean */ setMirror(mirror) { if (this.state.videoElement) { this.state.videoElement.style.transform = mirror ? "scaleX(-1)" : ""; } } /** * Get the current mirror state. * @returns boolean */ getMirror() { return !!(this.state.videoElement && this.state.videoElement.style.transform === "scaleX(-1)"); } /** * Check if mirror is supported. * @returns boolean */ isMirrorSupported() { return !!this.state.videoElement; } /** * Torch control (if supported). * @param enabled boolean */ async setTorch(enabled) { const track = this._getActiveVideoTrack(); if (this.isTorchSupported()) { // @ts-ignore await track.applyConstraints({ advanced: [{ torch: enabled }] }); this.state.torch = enabled; // Update internal state this._callStateChange(); } else { throw new Error("Torch is not supported on this device"); } } /** * Get the current torch state. * @returns boolean */ getTorch() { const track = this._getActiveVideoTrack(); if (this.isTorchSupported()) { // @ts-ignore return track.getSettings().torch; } return undefined; } /** * Check if torch is supported. * @returns boolean */ isTorchSupported() { const track = this._getActiveVideoTrack(); return !!(track && "torch" in track.getCapabilities()); } /** * Zoom control (if supported). * @param zoom number */ async setZoom(zoom) { const track = this._getActiveVideoTrack(); if (this.isZoomSupported()) { // @ts-ignore await track.applyConstraints({ advanced: [{ zoom }] }); this.state.zoom = zoom; // Update internal state this._callStateChange(); } else { throw new Error("Zoom is not supported on this device"); } } /** * Get the current zoom level. * @returns number */ getZoom() { const track = this._getActiveVideoTrack(); if (this.isZoomSupported()) { // @ts-ignore return track.getSettings().zoom; } return undefined; } /** * Check if zoom is supported. * @returns boolean */ isZoomSupported() { const track = this._getActiveVideoTrack(); return !!(track && "zoom" in track.getCapabilities()); } /** * Focus mode control (if supported). * @param mode string */ async setFocusMode(mode) { const track = this._getActiveVideoTrack(); if (this.isFocusSupported()) { // @ts-ignore await track.applyConstraints({ advanced: [{ focusMode: mode }] }); this.state.focusMode = mode; // Update internal state this._callStateChange(); } else { throw new Error("Focus mode is not supported on this device"); } } /** * Get the current focus mode. * @returns string */ getFocusMode() { const track = this._getActiveVideoTrack(); if (this.isFocusSupported()) { // @ts-ignore return track.getSettings().focusMode; } return undefined; } /** * Check if focus mode is supported. * @returns boolean */ isFocusSupported() { const track = this._getActiveVideoTrack(); return !!(track && "focusMode" in track.getCapabilities()); } /** * Helper: get the active video track **/ _getActiveVideoTrack() { var _a; return (_a = this.state.activeStream) === null || _a === void 0 ? void 0 : _a.getVideoTracks()[0]; } } //# sourceMappingURL=ts-webcam.js.map