224 lines
7.3 KiB
TypeScript
224 lines
7.3 KiB
TypeScript
interface SKUResponse {
|
|
status: boolean;
|
|
SKU: string | null;
|
|
}
|
|
|
|
interface CacheEntry {
|
|
response: SKUResponse;
|
|
timestamp: number;
|
|
}
|
|
|
|
/**
|
|
* Service for identifying product SKUs from shoe images using external API
|
|
*/
|
|
export class SKUIdentificationService {
|
|
private cache = new Map<string, CacheEntry>();
|
|
private readonly API_ENDPOINT = 'https://pegasus-working-bison.ngrok-free.app/predictfile';
|
|
private readonly CACHE_TTL = 60 * 60 * 1000; // 1 hour cache
|
|
private readonly MAX_CACHE_SIZE = 100; // Prevent memory leaks
|
|
|
|
/**
|
|
* Convert ImageData to File object for form-data upload
|
|
*/
|
|
private imageDataToFile(imageData: ImageData, filename: string = 'shoe.jpg'): File {
|
|
// Create canvas from ImageData
|
|
const canvas = document.createElement('canvas');
|
|
const ctx = canvas.getContext('2d')!;
|
|
canvas.width = imageData.width;
|
|
canvas.height = imageData.height;
|
|
|
|
// Put image data onto canvas
|
|
ctx.putImageData(imageData, 0, 0);
|
|
|
|
// Convert canvas to blob then to File
|
|
return new Promise<File>((resolve) => {
|
|
canvas.toBlob((blob) => {
|
|
if (blob) {
|
|
const file = new File([blob], filename, { type: 'image/jpeg' });
|
|
resolve(file);
|
|
}
|
|
}, 'image/jpeg', 0.8); // 80% quality for optimal size/quality balance
|
|
}) as Promise<File>;
|
|
}
|
|
|
|
/**
|
|
* Generate a simple hash from image data for caching
|
|
*/
|
|
private generateImageHash(imageData: ImageData): string {
|
|
// Sample pixels at regular intervals for hash generation
|
|
const step = Math.floor(imageData.data.length / 1000); // Sample ~1000 pixels
|
|
let hash = 0;
|
|
|
|
for (let i = 0; i < imageData.data.length; i += step) {
|
|
hash = ((hash << 5) - hash + imageData.data[i]) & 0xffffffff;
|
|
}
|
|
|
|
return hash.toString(36);
|
|
}
|
|
|
|
/**
|
|
* Clean expired cache entries and maintain size limit
|
|
*/
|
|
private cleanCache(): void {
|
|
const now = Date.now();
|
|
const entries = Array.from(this.cache.entries());
|
|
|
|
// Remove expired entries
|
|
for (const [key, entry] of entries) {
|
|
if (now - entry.timestamp > this.CACHE_TTL) {
|
|
this.cache.delete(key);
|
|
}
|
|
}
|
|
|
|
// If still over limit, remove oldest entries
|
|
if (this.cache.size > this.MAX_CACHE_SIZE) {
|
|
const sortedEntries = Array.from(this.cache.entries())
|
|
.sort((a, b) => a[1].timestamp - b[1].timestamp);
|
|
|
|
const entriesToRemove = sortedEntries.slice(0, this.cache.size - this.MAX_CACHE_SIZE);
|
|
for (const [key] of entriesToRemove) {
|
|
this.cache.delete(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Identify product SKU from shoe image
|
|
* @param imageData - Image data captured from video
|
|
* @returns Promise<string | null> - SKU if found, null otherwise
|
|
*/
|
|
async identifySKU(imageData: ImageData): Promise<string | null> {
|
|
try {
|
|
// Generate hash for caching
|
|
const imageHash = this.generateImageHash(imageData);
|
|
|
|
// Check cache first
|
|
const cached = this.cache.get(imageHash);
|
|
if (cached && (Date.now() - cached.timestamp) < this.CACHE_TTL) {
|
|
console.log('🎯 SKU cache hit:', cached.response);
|
|
return cached.response.status && cached.response.SKU ? cached.response.SKU : null;
|
|
}
|
|
|
|
console.log('📡 Calling SKU identification API...');
|
|
|
|
// Convert ImageData to File synchronously (simplified approach)
|
|
const canvas = document.createElement('canvas');
|
|
const ctx = canvas.getContext('2d')!;
|
|
canvas.width = imageData.width;
|
|
canvas.height = imageData.height;
|
|
ctx.putImageData(imageData, 0, 0);
|
|
|
|
// Convert canvas to blob and create File
|
|
const blob = await new Promise<Blob>((resolve) => {
|
|
canvas.toBlob((blob) => resolve(blob!), 'image/jpeg', 0.9); // Higher quality
|
|
});
|
|
|
|
const file = new File([blob], 'shoe.jpg', {
|
|
type: 'image/jpeg',
|
|
lastModified: Date.now()
|
|
});
|
|
|
|
console.log('📸 Created file:', {
|
|
name: file.name,
|
|
size: file.size,
|
|
type: file.type,
|
|
lastModified: file.lastModified
|
|
});
|
|
|
|
// Prepare form data
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
|
|
console.log('📤 FormData prepared with file key');
|
|
|
|
// Debug FormData contents
|
|
for (const [key, value] of formData.entries()) {
|
|
console.log(`📋 FormData entry: ${key} =`, value);
|
|
if (value instanceof File) {
|
|
console.log(` 📄 File details: ${value.name}, ${value.size} bytes, ${value.type}`);
|
|
}
|
|
}
|
|
|
|
console.log('🚀 Making API call to:', this.API_ENDPOINT);
|
|
console.log('🔧 Request method: POST');
|
|
console.log('🔧 Request headers:', { 'ngrok-skip-browser-warning': 'true' });
|
|
console.log('🔧 Request body type:', typeof formData, formData.constructor.name);
|
|
|
|
// Make API call with CORS headers
|
|
const response = await fetch(this.API_ENDPOINT, {
|
|
method: 'POST',
|
|
body: formData,
|
|
mode: 'cors', // Need CORS headers from server
|
|
headers: {
|
|
// Don't set Content-Type, let browser set it with boundary for multipart/form-data
|
|
'ngrok-skip-browser-warning': 'true', // Skip ngrok browser warning
|
|
'Accept': 'application/json', // Tell server we expect JSON
|
|
}
|
|
});
|
|
|
|
console.log('📡 Response status:', response.status, response.statusText);
|
|
console.log('📡 Response headers:', Object.fromEntries(response.headers.entries()));
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`API call failed: ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
// Try to get response as text first to debug
|
|
const responseText = await response.text();
|
|
console.log('📡 Raw response text:', responseText);
|
|
|
|
// Try to parse as JSON
|
|
let data: SKUResponse;
|
|
try {
|
|
data = JSON.parse(responseText);
|
|
console.log('📦 SKU API response parsed:', data);
|
|
} catch (parseError) {
|
|
console.error('❌ Failed to parse JSON response:', parseError);
|
|
console.error('❌ Response was:', responseText);
|
|
throw new Error(`Invalid JSON response: ${parseError}`);
|
|
}
|
|
|
|
// Cache the response (both success and failure)
|
|
this.cleanCache();
|
|
this.cache.set(imageHash, {
|
|
response: data,
|
|
timestamp: Date.now()
|
|
});
|
|
|
|
// Return SKU only if status is true and SKU is not null
|
|
return data.status && data.SKU ? data.SKU : null;
|
|
|
|
} catch (error) {
|
|
console.error('❌ SKU identification failed:', error);
|
|
// Return null on any error (network, parsing, etc.)
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear all cached results
|
|
*/
|
|
clearCache(): void {
|
|
this.cache.clear();
|
|
console.log('🗑️ SKU cache cleared');
|
|
}
|
|
|
|
/**
|
|
* Get cache statistics for debugging
|
|
*/
|
|
getCacheStats() {
|
|
const now = Date.now();
|
|
const entries = Array.from(this.cache.values());
|
|
const validEntries = entries.filter(entry => (now - entry.timestamp) < this.CACHE_TTL);
|
|
|
|
return {
|
|
totalEntries: this.cache.size,
|
|
validEntries: validEntries.length,
|
|
expiredEntries: this.cache.size - validEntries.length,
|
|
cacheHitRate: validEntries.length / Math.max(1, this.cache.size)
|
|
};
|
|
}
|
|
}
|
|
|
|
// Export singleton instance
|
|
export const skuIdentificationService = new SKUIdentificationService(); |