283 lines
8.1 KiB
TypeScript
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();
|
|
}
|
|
} |