Files
gridpilot.gg/packages/infrastructure/adapters/automation/TemplateMatchingService.ts

283 lines
8.1 KiB
TypeScript

import { screen, imageResource, Region } from '@nut-tree-fork/nut-js';
import type { ImageTemplate } from '../../../domain/value-objects/ImageTemplate';
import type { ElementLocation, ScreenRegion, Point } from '../../../domain/value-objects/ScreenRegion';
import type { ILogger } from '../../../application/ports/ILogger';
import { NoOpLogAdapter } from '../logging/NoOpLogAdapter';
import * as fs from 'fs';
import * as path from 'path';
/**
* Result of a template matching operation.
*/
export interface TemplateMatchResult {
found: boolean;
location?: ElementLocation;
searchDurationMs: number;
error?: string;
}
/**
* Service for finding UI elements on screen using image template matching.
* Uses nut.js screen.find() with @nut-tree-fork/template-matcher for image comparison.
*/
export class TemplateMatchingService {
private logger: ILogger;
private templateBasePath: string;
private loadedTemplates: Map<string, string> = new Map();
constructor(templateBasePath?: string, logger?: ILogger) {
this.logger = logger ?? new NoOpLogAdapter();
this.templateBasePath = templateBasePath ?? './resources/templates';
}
/**
* Find an element on screen using image template matching.
* @param template - The image template to search for
* @returns ElementLocation if found, null if not found
*/
async findElement(template: ImageTemplate): Promise<ElementLocation | null> {
const startTime = Date.now();
try {
const fullPath = this.resolveTemplatePath(template.imagePath);
if (!this.templateExists(fullPath)) {
this.logger.warn('Template image not found, skipping search', {
templateId: template.id,
imagePath: fullPath,
description: template.description
});
return null;
}
this.logger.debug('Searching for template', {
templateId: template.id,
confidence: template.confidence,
searchRegion: template.searchRegion
});
const templateImage = await imageResource(fullPath);
// Set confidence threshold for this search
screen.config.confidence = template.confidence;
let foundRegion: Region;
if (template.searchRegion) {
// Search within a specific region for better performance
const searchArea = new Region(
template.searchRegion.x,
template.searchRegion.y,
template.searchRegion.width,
template.searchRegion.height
);
foundRegion = await screen.findRegion(templateImage, searchArea);
} else {
// Search entire screen
foundRegion = await screen.find(templateImage);
}
const location = this.regionToElementLocation(foundRegion, template.confidence);
const durationMs = Date.now() - startTime;
this.logger.debug('Template found', {
templateId: template.id,
location: location.center,
confidence: location.confidence,
durationMs
});
return location;
} catch (error) {
const durationMs = Date.now() - startTime;
// nut.js throws when template is not found - this is expected behavior
if (String(error).includes('No match')) {
this.logger.debug('Template not found on screen', {
templateId: template.id,
durationMs
});
return null;
}
this.logger.error('Template matching error', error instanceof Error ? error : new Error(String(error)), {
templateId: template.id,
durationMs
});
return null;
}
}
/**
* Find multiple occurrences of a template on screen.
* @param template - The image template to search for
* @returns Array of ElementLocation for all matches
*/
async findAllElements(template: ImageTemplate): Promise<ElementLocation[]> {
const startTime = Date.now();
try {
const fullPath = this.resolveTemplatePath(template.imagePath);
if (!this.templateExists(fullPath)) {
this.logger.warn('Template image not found', { templateId: template.id, imagePath: fullPath });
return [];
}
const templateImage = await imageResource(fullPath);
screen.config.confidence = template.confidence;
const foundRegions = await screen.findAll(templateImage);
const locations = foundRegions.map(region => this.regionToElementLocation(region, template.confidence));
const durationMs = Date.now() - startTime;
this.logger.debug('Found multiple templates', {
templateId: template.id,
count: locations.length,
durationMs
});
return locations;
} catch (error) {
const durationMs = Date.now() - startTime;
if (String(error).includes('No match')) {
this.logger.debug('No templates found on screen', { templateId: template.id, durationMs });
return [];
}
this.logger.error('Template matching error', error instanceof Error ? error : new Error(String(error)), {
templateId: template.id,
durationMs
});
return [];
}
}
/**
* Wait for a template to appear on screen.
* @param template - The image template to wait for
* @param maxWaitMs - Maximum time to wait in milliseconds
* @param pollIntervalMs - How often to check for the template
* @returns TemplateMatchResult with timing information
*/
async waitForTemplate(
template: ImageTemplate,
maxWaitMs: number = 10000,
pollIntervalMs: number = 500
): Promise<TemplateMatchResult> {
const startTime = Date.now();
this.logger.debug('Waiting for template', {
templateId: template.id,
maxWaitMs,
pollIntervalMs
});
while (Date.now() - startTime < maxWaitMs) {
const location = await this.findElement(template);
if (location) {
const durationMs = Date.now() - startTime;
this.logger.debug('Template appeared', { templateId: template.id, durationMs });
return {
found: true,
location,
searchDurationMs: durationMs,
};
}
await this.delay(pollIntervalMs);
}
const durationMs = Date.now() - startTime;
this.logger.debug('Template did not appear within timeout', {
templateId: template.id,
durationMs,
maxWaitMs
});
return {
found: false,
searchDurationMs: durationMs,
error: `Template '${template.id}' not found within ${maxWaitMs}ms`,
};
}
/**
* Check if a template exists on screen without returning location.
* Useful for boolean state checks.
*/
async isTemplateVisible(template: ImageTemplate): Promise<boolean> {
const location = await this.findElement(template);
return location !== null;
}
/**
* Resolve a template path relative to the base template directory.
*/
private resolveTemplatePath(imagePath: string): string {
if (path.isAbsolute(imagePath)) {
return imagePath;
}
return path.join(this.templateBasePath, imagePath);
}
/**
* Check if a template file exists.
*/
private templateExists(fullPath: string): boolean {
try {
return fs.existsSync(fullPath);
} catch {
return false;
}
}
/**
* Convert nut.js Region to ElementLocation.
*/
private regionToElementLocation(region: Region, confidence: number): ElementLocation {
const bounds: ScreenRegion = {
x: region.left,
y: region.top,
width: region.width,
height: region.height,
};
const center: Point = {
x: region.left + Math.floor(region.width / 2),
y: region.top + Math.floor(region.height / 2),
};
return {
center,
bounds,
confidence,
};
}
/**
* Utility delay function.
*/
private delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Get the configured template base path.
*/
getTemplateBasePath(): string {
return this.templateBasePath;
}
/**
* Set the template base path.
*/
setTemplateBasePath(basePath: string): void {
this.templateBasePath = basePath;
this.loadedTemplates.clear();
}
}