import {SdkVerifierType, VerifierState} from '../generated/zenid-enums.generated.js'; import {UploadReadyData, VerifierSettingsBase, VerifierStateContainerForPublicDataBase} from '../generated/zenid-types.generated.js'; import ZenidSDK from '../internal/zenid-sdk.js'; import {zenidLog} from "../zenid-log.js"; import {CameraFacingMode, ICamera, IVerifier, IVisualizer, OnFrameProcessedCallback} from "../../interfaces/verification-system-interfaces.js"; /** * Generic base class for all verifiers * @template TPublicData - The type of public data returned from processing * @template TSettings - The type of settings used for initialization * @template TUploadData - The type of upload data returned */ export abstract class VerifierBase implements IVerifier { // Fields protected sdk: ZenidSDK; protected feature: SdkVerifierType; protected stopCameraLoopRequested: boolean = false; private settings: TSettings | null = null; private processingLoopPromise: Promise | null = null; private readonly targetFrameTime: number = 33; // Target time per frame in ms private lastFrameTime: number = 0; private frameProcessingResolution: number = 0; // For performance measurement (enabled in window.__zenid_enable_perf_measurement). // It's a property on window because I didn't find a good place to put it // without causing circular dependencies and other mess. static readonly measurePerformanceInterval: number = 10000; // in ms private hasProcessedFrame: boolean = true; private measuredFrameTimes: number[] = []; private lastMeasureUpdateTime: number = 0; // FPS tracking with EMA private lastRenderTime: number = 0; private lastProcessTime: number = 0; private renderFps: number = 0; private processFps: number = 0; /** * Create a new verifier * @param feature - The feature name (e.g., 'document', 'selfie') */ protected constructor(feature: SdkVerifierType) { this.sdk = ZenidSDK.Instance; this.feature = feature; } // PUBLIC METHODS (alphabetically sorted) facingMode(): CameraFacingMode { return CameraFacingMode.environment; } /** * Get render commands for visualization * @param width - Width of the visualization * @param height - Height of the visualization */ async getRenderCommands(width: number, height: number): Promise { this.updateRenderFps(); let commands = await this.sdk.getRenderCommands(this.feature, width, height); // Append FPS display if debug visualization enabled if (this.sdk.debugVisualizationEnabled) { const x = Math.round(width * 0.95); const y1 = Math.round(height * 0.03); const y2 = Math.round(height * 0.06); const line1 = `text;Render: ${this.renderFps.toFixed(1)} fps;${x};${y1};right;top;100;255;100;255`; const line2 = `text;Logic: ${this.processFps.toFixed(1)} fps;${x};${y2};right;top;100;255;100;255`; // Only use JSON parsing if commands starts with '[' (JSON array format) if (commands.trimStart().startsWith('[')) { const widgets = JSON.parse(commands); widgets.push({ type: 'legacy_commands', commands: line1 + '\n' + line2 }); commands = JSON.stringify(widgets); } else { // Plain text mode - just append the FPS lines commands += '\n' + line1 + '\n' + line2; } } return commands; } getSettings(): VerifierSettingsBase | null { return this.settings; } async getStateContainer(): Promise { let resultJson = await this.sdk.getStateContainerJson( this.feature); return JSON.parse(resultJson) as TPublicData; } /** * Get the upload-ready data parsed as TUploadData */ async getUploadReadyData(): Promise { return await this.sdk.getUploadReadyData(this.feature); } getVerifierType(): SdkVerifierType { return this.feature; } /** * Initialize the verifier * @param settings - Settings object of type TSettings */ async initOrRestart( settings: TSettings | string = {} as TSettings ): Promise { try { this.settings = typeof settings === 'string' ? JSON.parse(settings) : settings; } catch (error) { this.settings = null; } const jsSettings = JSON.stringify(this.settings); await this.sdk.initOrRestart( this.feature, jsSettings ); this.frameProcessingResolution = await this.sdk.getFrameProcessingResolution(this.feature); zenidLog.info("Initialized verifier " + this.getVerifierType() + ", settings: " + jsSettings); } /** * Process a frame and return the raw JSON response * @param imageData - The image data to process * @param width - The width of the image * @param height - The height of the image */ async processFrame( imageData: Uint8ClampedArray, width: number, height: number ): Promise { this.updateProcessFps(); await this.sdk.processFrame( this.feature, imageData, width, height ); } async processFramesFromCamera(camera: ICamera, visualizer: IVisualizer, onGiveMe?: ((state: TPublicData) => Promise) | null, onFrameProcessed?: OnFrameProcessedCallback, signal?: AbortSignal): Promise { this.stopCameraLoopRequested = false; this.processingLoopPromise = this.runProcessingLoop(camera, visualizer, onGiveMe, onFrameProcessed, signal); // Wait for the processing loop to complete and return its result return this.processingLoopPromise; } /** * Check if the verifier is ready for a new frame */ async readyForFrame(): Promise { return await this.sdk.readyForFrame(this.feature); } /** * Restart the verifier */ async restartWithPreviousSettings(): Promise { await this.sdk.restartWithPreviousSettings(this.feature); } /** * Set the global visualizer version * @param visualizerVersion - Visualizer version */ async setVisualizerVersion(visualizerVersion: number = 1): Promise { await this.sdk.setVisualizerVersion( visualizerVersion ); } /** * Requests the processing loop to stop and returns a promise that resolves * when the loop has completely stopped processing * @returns Promise that resolves when the loop has completely stopped */ async stopProcessingLoop(): Promise { this.stopCameraLoopRequested = true; // Wait for the loop to actually stop. How it ended doesn't matter here — a rejection is // already reported at the central sink (ZenidController.runVerifier), so swallow it. A null // promise (no loop running) just resolves immediately. try { await this.processingLoopPromise; } catch { // ignore — the loop's failure is surfaced elsewhere } } /** * Submit an external response to the verifier * @param responseJson */ async submitExternalResponse( responseJson: string ): Promise { await this.sdk.submitExternalResponse( this.feature, responseJson ); } // PROTECTED METHODS protected async runProcessingLoop(camera: ICamera, visualizer: IVisualizer, onGiveMe?: ((state: TPublicData) => Promise) | null, onFrameProcessed?: OnFrameProcessedCallback, signal?: AbortSignal): Promise { signal?.addEventListener("abort", () => { this.stopCameraLoopRequested = true; }); let stateContainer: TPublicData | null = null; const enablePerf: boolean = (window as any).__zenid_enable_perf_measurement; this.lastMeasureUpdateTime = Date.now(); await camera.start(this.facingMode(), this.frameProcessingResolution); while (!this.stopCameraLoopRequested) { if (!camera.isActive()) { await this.delay(this.targetFrameTime); continue; } const frameStartTime = performance.now(); try { const isReady = await this.readyForFrame(); if (isReady) { const now = Date.now(); if (enablePerf && !this.hasProcessedFrame) { this.hasProcessedFrame = true; this.measuredFrameTimes.push(Date.now() - this.lastFrameTime); } if (now - this.lastFrameTime >= this.targetFrameTime) { // standard behavior let cameraFrame = await camera.getFrame(); if (cameraFrame != null) { await this.processFrame(cameraFrame.image, cameraFrame.width, cameraFrame.height); this.lastFrameTime = now; this.hasProcessedFrame = false; } } if (enablePerf && now - this.lastMeasureUpdateTime >= VerifierBase.measurePerformanceInterval) { let len = this.measuredFrameTimes.length; let avg = Math.round(this.measuredFrameTimes.reduce((p, k) => p + k, 0) / len * 100) / 100; zenidLog.info(`Avg frame time: ${avg}ms over ${len} frames`); this.lastMeasureUpdateTime = now; this.measuredFrameTimes = []; } } stateContainer = await this.getStateContainer() as TPublicData; let renderCommands = await this.getRenderCommands(visualizer.getWidth(), visualizer.getHeight()); visualizer.drawOverlay(this, renderCommands); if (stateContainer.State === VerifierState.GiveMe) { if (onGiveMe) { const response = await onGiveMe(stateContainer); if (response) await this.submitExternalResponse(response); stateContainer = await this.getStateContainer() as TPublicData; } else { throw new Error("GiveMe state received but no callback provided to handle it."); } } // Call the frame processed callback if provided if (onFrameProcessed && stateContainer) { await onFrameProcessed(stateContainer, this); } if (stateContainer.State === VerifierState.Success || stateContainer.State === VerifierState.Failed) { return this.getUploadReadyData(); } const frameProcessingTime = performance.now() - frameStartTime; if (!enablePerf) await this.delay(this.targetFrameTime - frameProcessingTime); } catch (error) { // Let the error propagate to the central sink (ZenidController.runVerifier), which // logs the full cause with stack. Swallowing it here would hide why a run aborted. throw error; } } return null; } // PRIVATE METHODS private delay(ms: number): Promise { return ms <= 0 ? Promise.resolve() : new Promise(resolve => setTimeout(resolve, ms)); } private updateRenderFps(): void { const alpha = 0.02; const now = performance.now(); if (this.lastRenderTime > 0) { const delta = now - this.lastRenderTime; const instantFps = 1000 / delta; this.renderFps = this.renderFps === 0 ? instantFps : this.renderFps * (1 - alpha) + instantFps * alpha; } this.lastRenderTime = now; } private updateProcessFps(): void { const alpha = 0.1; const now = performance.now(); if (this.lastProcessTime > 0) { const delta = now - this.lastProcessTime; const instantFps = 1000 / delta; this.processFps = this.processFps === 0 ? instantFps : this.processFps * (1 - alpha) + instantFps * alpha; } this.lastProcessTime = now; } }