|
import type { GradioClient } from '$lib/types'; |
|
import { |
|
initializeTrainerScanProgress, |
|
getNextPendingImage, |
|
markImageProcessingStarted, |
|
markImageProcessingCompleted, |
|
markImageProcessingFailed, |
|
getScanningStats, |
|
getCurrentProcessingImage |
|
} from '$lib/db/trainerScanning'; |
|
import { savePicletInstance, generatedDataToPicletInstance } from '$lib/db/piclets'; |
|
import { extractPicletMetadata } from './picletMetadata'; |
|
import { removeBackground } from '$lib/utils/professionalImageProcessing'; |
|
import type { PicletStats } from '$lib/types'; |
|
|
|
export interface TrainerScanState { |
|
isScanning: boolean; |
|
currentImage: string | null; |
|
currentTrainer: string | null; |
|
progress: { |
|
total: number; |
|
completed: number; |
|
failed: number; |
|
pending: number; |
|
}; |
|
error: string | null; |
|
} |
|
|
|
export class TrainerScanService { |
|
private joyCaptionClient: GradioClient; |
|
private zephyrClient: GradioClient; |
|
private fluxClient: GradioClient; |
|
|
|
private isScanning = false; |
|
private shouldStop = false; |
|
private stateCallbacks: ((state: TrainerScanState) => void)[] = []; |
|
|
|
constructor( |
|
joyCaptionClient: GradioClient, |
|
zephyrClient: GradioClient, |
|
fluxClient: GradioClient |
|
) { |
|
this.joyCaptionClient = joyCaptionClient; |
|
this.zephyrClient = zephyrClient; |
|
this.fluxClient = fluxClient; |
|
} |
|
|
|
|
|
onStateChange(callback: (state: TrainerScanState) => void) { |
|
this.stateCallbacks.push(callback); |
|
} |
|
|
|
|
|
private notifyStateChange(state: Partial<TrainerScanState>) { |
|
const fullState = { ...this.getCurrentState(), ...state }; |
|
this.stateCallbacks.forEach(callback => callback(fullState)); |
|
} |
|
|
|
|
|
private async getCurrentState(): Promise<TrainerScanState> { |
|
const stats = await getScanningStats(); |
|
return { |
|
isScanning: this.isScanning, |
|
currentImage: null, |
|
currentTrainer: null, |
|
progress: { |
|
total: stats.total, |
|
completed: stats.completed, |
|
failed: stats.failed, |
|
pending: stats.pending |
|
}, |
|
error: null |
|
}; |
|
} |
|
|
|
|
|
async initializeFromFile(): Promise<void> { |
|
try { |
|
console.log('🔍 TrainerScanService: Starting initializeFromFile...'); |
|
|
|
const response = await fetch('/trainer_image_paths.txt'); |
|
if (!response.ok) { |
|
throw new Error(`Failed to fetch trainer_image_paths.txt: ${response.statusText}`); |
|
} |
|
|
|
console.log('🔍 TrainerScanService: Successfully fetched trainer_image_paths.txt'); |
|
|
|
const content = await response.text(); |
|
if (!content) { |
|
throw new Error('trainer_image_paths.txt is empty'); |
|
} |
|
|
|
console.log(`🔍 TrainerScanService: File content length: ${content.length}`); |
|
console.log(`🔍 TrainerScanService: First 200 chars: ${content.substring(0, 200)}`); |
|
|
|
const lines = content.trim().split('\n'); |
|
console.log(`🔍 TrainerScanService: Split into ${lines.length} lines`); |
|
|
|
const imagePaths = lines |
|
.map((path, index) => { |
|
console.log(`🔍 TrainerScanService: Processing line ${index}: "${path}" (type: ${typeof path})`); |
|
if (typeof path !== 'string') { |
|
console.warn(`🔍 TrainerScanService: Line ${index} is not a string:`, path); |
|
return ''; |
|
} |
|
const trimmed = path.trim(); |
|
console.log(`🔍 TrainerScanService: Line ${index} trimmed: "${trimmed}"`); |
|
return trimmed; |
|
}) |
|
.filter((path, index) => { |
|
const isValid = path.length > 0; |
|
console.log(`🔍 TrainerScanService: Line ${index} valid: ${isValid}`); |
|
return isValid; |
|
}); |
|
|
|
console.log(`🔍 TrainerScanService: Loaded ${imagePaths.length} trainer image paths`); |
|
console.log(`🔍 TrainerScanService: First 5 paths:`, imagePaths.slice(0, 5)); |
|
|
|
if (imagePaths.length === 0) { |
|
throw new Error('No valid image paths found in trainer_image_paths.txt'); |
|
} |
|
|
|
console.log('🔍 TrainerScanService: About to call initializeTrainerScanProgress...'); |
|
await initializeTrainerScanProgress(imagePaths); |
|
console.log('🔍 TrainerScanService: initializeTrainerScanProgress completed successfully'); |
|
|
|
console.log('🔍 TrainerScanService: About to get current state...'); |
|
const currentState = await this.getCurrentState(); |
|
console.log('🔍 TrainerScanService: Got current state:', currentState); |
|
|
|
this.notifyStateChange(currentState); |
|
console.log('🔍 TrainerScanService: initializeFromFile completed successfully'); |
|
} catch (error) { |
|
console.error('❌ TrainerScanService: Failed to initialize trainer scan progress:', error); |
|
console.error('❌ TrainerScanService: Error stack:', error instanceof Error ? error.stack : 'No stack trace'); |
|
throw new Error('Failed to load trainer image paths'); |
|
} |
|
} |
|
|
|
|
|
async startScanning(): Promise<void> { |
|
if (this.isScanning) { |
|
throw new Error('Scanning is already in progress'); |
|
} |
|
|
|
|
|
const stats = await getScanningStats(); |
|
if (stats.total === 0) { |
|
await this.initializeFromFile(); |
|
} |
|
|
|
|
|
const currentProcessing = await getCurrentProcessingImage(); |
|
if (currentProcessing) { |
|
|
|
await markImageProcessingFailed(currentProcessing.imagePath, 'Process interrupted'); |
|
} |
|
|
|
this.isScanning = true; |
|
this.shouldStop = false; |
|
this.notifyStateChange({ isScanning: true, error: null }); |
|
|
|
try { |
|
await this.processingLoop(); |
|
} catch (error) { |
|
console.error('Scanning error:', error); |
|
this.notifyStateChange({ error: error instanceof Error ? error.message : 'Unknown error' }); |
|
} finally { |
|
this.isScanning = false; |
|
this.notifyStateChange({ isScanning: false, currentImage: null, currentTrainer: null }); |
|
|
|
|
|
const finalStats = await getScanningStats(); |
|
console.log(`🏁 Scanning session complete:`, { |
|
total: finalStats.total, |
|
completed: finalStats.completed, |
|
failed: finalStats.failed, |
|
pending: finalStats.pending, |
|
successRate: finalStats.total > 0 ? Math.round((finalStats.completed / finalStats.total) * 100) + '%' : '0%' |
|
}); |
|
} |
|
} |
|
|
|
|
|
stopScanning(): void { |
|
this.shouldStop = true; |
|
} |
|
|
|
|
|
private async processingLoop(): Promise<void> { |
|
while (!this.shouldStop) { |
|
const nextImage = await getNextPendingImage(); |
|
|
|
if (!nextImage) { |
|
|
|
break; |
|
} |
|
|
|
this.notifyStateChange({ |
|
currentImage: nextImage.imagePath, |
|
currentTrainer: typeof nextImage.trainerName === 'string' ? nextImage.trainerName : null |
|
}); |
|
|
|
try { |
|
await this.processImage(nextImage.imagePath, nextImage.remoteUrl); |
|
console.log(`✅ Successfully processed: ${nextImage.imagePath} (${nextImage.trainerName})`); |
|
|
|
await new Promise(resolve => setTimeout(resolve, 1000)); |
|
} catch (error) { |
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error'; |
|
console.error(`❌ Failed to process ${nextImage.imagePath} (${nextImage.trainerName}):`, { |
|
imagePath: nextImage.imagePath, |
|
trainerName: nextImage.trainerName, |
|
remoteUrl: nextImage.remoteUrl, |
|
error: errorMessage, |
|
fullError: error |
|
}); |
|
|
|
await markImageProcessingFailed(nextImage.imagePath, errorMessage); |
|
|
|
|
|
console.log(`🔄 Continuing to next image despite failure...`); |
|
} |
|
|
|
|
|
this.notifyStateChange(await this.getCurrentState()); |
|
} |
|
} |
|
|
|
|
|
private async processImage(imagePath: string, remoteUrl: string): Promise<void> { |
|
await markImageProcessingStarted(imagePath); |
|
|
|
try { |
|
console.log(`🔄 Processing ${imagePath}: Fetching remote image...`); |
|
|
|
const imageFile = await this.fetchRemoteImage(remoteUrl, imagePath); |
|
|
|
console.log(`🔄 Processing ${imagePath}: Captioning image...`); |
|
|
|
const imageCaption = await this.captionImage(imageFile); |
|
|
|
console.log(`🔄 Processing ${imagePath}: Generating concept...`); |
|
|
|
const picletConcept = await this.generatePicletConcept(imageCaption); |
|
|
|
console.log(`🔄 Processing ${imagePath}: Generating stats...`); |
|
|
|
const picletStats = await this.generatePicletStats(picletConcept); |
|
|
|
console.log(`🔄 Processing ${imagePath}: Generating image prompt...`); |
|
|
|
const imagePrompt = await this.generateImagePrompt(picletConcept); |
|
|
|
console.log(`🔄 Processing ${imagePath}: Generating monster image...`); |
|
|
|
const picletImageUrl = await this.generateMonsterImage(imagePrompt); |
|
|
|
console.log(`🔄 Processing ${imagePath}: Processing generated image...`); |
|
|
|
const imageData = await this.processGeneratedImage(picletImageUrl); |
|
|
|
console.log(`🔄 Processing ${imagePath}: Creating piclet instance...`); |
|
|
|
const generatedData = { |
|
name: this.extractNameFromConcept(picletConcept), |
|
imageUrl: picletImageUrl, |
|
imageData, |
|
imageCaption, |
|
concept: picletConcept, |
|
imagePrompt, |
|
stats: picletStats, |
|
createdAt: new Date() |
|
}; |
|
|
|
const picletInstance = await generatedDataToPicletInstance(generatedData, 5); |
|
const savedId = await savePicletInstance(picletInstance); |
|
|
|
await markImageProcessingCompleted(imagePath, savedId); |
|
|
|
} catch (error) { |
|
|
|
const enhancedError = new Error(`Failed during processing of ${imagePath}: ${error instanceof Error ? error.message : 'Unknown error'}`); |
|
enhancedError.cause = error; |
|
throw enhancedError; |
|
} |
|
} |
|
|
|
|
|
private async fetchRemoteImage(remoteUrl: string, originalPath: string): Promise<File> { |
|
const response = await fetch(remoteUrl); |
|
if (!response.ok) { |
|
throw new Error(`Failed to fetch ${remoteUrl}: ${response.statusText}`); |
|
} |
|
|
|
const blob = await response.blob(); |
|
const fileName = originalPath.split('/').pop() || 'trainer_image.jpg'; |
|
|
|
return new File([blob], fileName, { type: blob.type }); |
|
} |
|
|
|
|
|
private async captionImage(imageFile: File): Promise<string> { |
|
const result = await this.joyCaptionClient.predict("/caption", [imageFile, "descriptive", "any", false]); |
|
const captionResult = result.data[0] as string; |
|
|
|
if (!captionResult || captionResult.trim() === '') { |
|
throw new Error('Failed to generate image caption'); |
|
} |
|
|
|
return captionResult.trim(); |
|
} |
|
|
|
|
|
private async generatePicletConcept(imageCaption: string): Promise<string> { |
|
const prompt = `Based on this image description, create a unique creature concept for a Pokemon-style monster collection game called "Pictuary": |
|
|
|
"${imageCaption}" |
|
|
|
Create a creative, original monster concept that: |
|
1. Is inspired by elements from the image but is clearly a fantastical creature |
|
2. Has a unique name and personality |
|
3. Includes special abilities related to its appearance |
|
4. Is suitable for a family-friendly game |
|
|
|
Write a detailed monster concept (2-3 paragraphs).`; |
|
|
|
const result = await this.zephyrClient.predict("/chat", [ |
|
[["user", prompt]], |
|
512, |
|
0.7, |
|
0.9, |
|
]); |
|
|
|
const conceptResult = result.data[0][1][1] as string; |
|
|
|
if (!conceptResult || conceptResult.trim() === '') { |
|
throw new Error('Failed to generate piclet concept'); |
|
} |
|
|
|
return conceptResult.trim(); |
|
} |
|
|
|
|
|
private async generatePicletStats(concept: string): Promise<PicletStats> { |
|
return await extractPicletMetadata(concept, this.zephyrClient); |
|
} |
|
|
|
|
|
private async generateImagePrompt(concept: string): Promise<string> { |
|
const prompt = `Extract ONLY the visual appearance from this monster concept and describe it in one concise sentence: |
|
"${concept}" |
|
|
|
Focus on: colors, body shape, eyes, limbs, mouth, and key visual features. Omit backstory, abilities, and non-visual details.`; |
|
|
|
const result = await this.zephyrClient.predict("/chat", [ |
|
[["user", prompt]], |
|
256, |
|
0.5, |
|
0.9, |
|
]); |
|
|
|
const promptResult = result.data[0][1][1] as string; |
|
|
|
if (!promptResult || promptResult.trim() === '') { |
|
throw new Error('Failed to generate image prompt'); |
|
} |
|
|
|
return promptResult.trim(); |
|
} |
|
|
|
|
|
private async generateMonsterImage(imagePrompt: string): Promise<string> { |
|
const fullPrompt = `${imagePrompt}, digital art, creature design, fantasy monster, clean background, professional illustration`; |
|
|
|
const result = await this.fluxClient.predict("/infer", [ |
|
fullPrompt, |
|
"", |
|
832, |
|
1216, |
|
1, |
|
3.0, |
|
0, |
|
]); |
|
|
|
const imageUrl = result.data[0] as string; |
|
|
|
if (!imageUrl) { |
|
throw new Error('Failed to generate monster image'); |
|
} |
|
|
|
return imageUrl; |
|
} |
|
|
|
|
|
private async processGeneratedImage(imageUrl: string): Promise<string> { |
|
try { |
|
return await removeBackground(imageUrl); |
|
} catch (error) { |
|
console.warn('Background removal failed, using original image:', error); |
|
return imageUrl; |
|
} |
|
} |
|
|
|
|
|
private extractNameFromConcept(concept: string): string { |
|
|
|
const patterns = [ |
|
/(?:called|named)\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)/, |
|
/^([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)/, |
|
/this\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)/, |
|
]; |
|
|
|
for (const pattern of patterns) { |
|
const match = concept.match(pattern); |
|
if (match && match[1]) { |
|
return match[1].trim(); |
|
} |
|
} |
|
|
|
|
|
const trainerNames = ['Snap', 'Blaze', 'Nimbus', 'Breaker', 'Trinket']; |
|
const randomName = trainerNames[Math.floor(Math.random() * trainerNames.length)]; |
|
return `Trainer${randomName}`; |
|
} |
|
} |