piclets / src /lib /components /AutoTrainerScanner /AutoTrainerScanner.svelte
Fraser's picture
cmcd
9c2938b
<script lang="ts">
import type { GradioClient } from '$lib/types';
import {
initializeTrainerScanProgress,
getNextPendingImage,
markImageProcessingCompleted,
markImageProcessingFailed,
getScanningStats
} from '$lib/db/trainerScanning';
import PicletGenerator from '$lib/components/PicletGenerator/PicletGenerator.svelte';
interface Props {
joyCaptionClient: GradioClient;
fluxClient: GradioClient;
commandClient: GradioClient;
// Unused clients (kept for future use)
// hunyuanClient: GradioClient;
// zephyrClient: GradioClient;
// qwenClient: GradioClient;
// dotsClient: GradioClient;
}
let { joyCaptionClient, fluxClient, commandClient }: Props = $props();
// Scanner state
let scanState = $state({
isScanning: false,
currentImage: null as string | null,
currentTrainer: null as string | null,
progress: {
total: 0,
completed: 0,
failed: 0,
pending: 0
},
error: null as string | null
});
// Promise tracking for current image processing
let currentImagePromise: {
resolve: (value: any) => void;
reject: (error: any) => void;
} | null = null;
let showDetails = $state(false);
let isInitializing = $state(false);
let shouldStop = $state(false);
// Reference to PicletGenerator component
let picletGenerator: any;
// Initialize trainer paths on component mount
$effect(() => {
if (joyCaptionClient && fluxClient && commandClient) {
loadInitialState();
}
});
async function loadInitialState() {
try {
isInitializing = true;
// Load trainer image paths and initialize database
const response = await fetch('/trainer_image_paths.txt');
if (!response.ok) {
throw new Error(`Failed to fetch trainer_image_paths.txt: ${response.statusText}`);
}
const content = await response.text();
const imagePaths = content.trim().split('\n')
.map(path => typeof path === 'string' ? path.trim() : '')
.filter(path => path.length > 0);
console.log(`Loaded ${imagePaths.length} trainer image paths`);
await initializeTrainerScanProgress(imagePaths);
await updateProgress();
} catch (error) {
console.error('Failed to initialize scanner:', error);
scanState.error = error instanceof Error ? error.message : 'Failed to initialize';
} finally {
isInitializing = false;
}
}
async function updateProgress() {
const stats = await getScanningStats();
scanState.progress = {
total: stats.total,
completed: stats.completed,
failed: stats.failed,
pending: stats.pending
};
}
async function startScanning() {
if (scanState.isScanning) return;
scanState.isScanning = true;
scanState.error = null;
shouldStop = false;
try {
await processTrainerImages();
} catch (error) {
console.error('Scanning error:', error);
scanState.error = error instanceof Error ? error.message : 'Unknown error';
} finally {
scanState.isScanning = false;
scanState.currentImage = null;
scanState.currentTrainer = null;
}
}
function stopScanning() {
shouldStop = true;
}
async function processTrainerImages() {
while (!shouldStop) {
const nextImage = await getNextPendingImage();
if (!nextImage) {
// No more pending images
break;
}
scanState.currentImage = nextImage.imagePath;
scanState.currentTrainer = nextImage.trainerName;
try {
// Fetch remote image
const imageFile = await fetchRemoteImage(nextImage.remoteUrl, nextImage.imagePath);
// Create a Promise that will be resolved by the completion callback
const imageProcessingPromise = new Promise<void>((resolve, reject) => {
currentImagePromise = { resolve, reject };
});
// Queue the image in PicletGenerator
if (picletGenerator) {
picletGenerator.queueTrainerImage(imageFile, nextImage.imagePath);
}
// Wait for this image to be processed before continuing
console.log(`🔧 DEBUG: Waiting for completion of ${nextImage.imagePath}...`);
await imageProcessingPromise;
console.log(`✅ DEBUG: Completed processing of ${nextImage.imagePath}, moving to next image`);
} catch (error) {
console.error(`Failed to process ${nextImage.imagePath}:`, error);
await markImageProcessingFailed(
nextImage.imagePath,
error instanceof Error ? error.message : 'Unknown error'
);
// Reject the current promise if it exists
if (currentImagePromise) {
currentImagePromise.reject(error);
currentImagePromise = null;
}
}
await updateProgress();
}
}
async function 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 });
}
async function onTrainerImageCompleted(imagePath: string, picletId: number) {
console.log(`✅ Trainer image completed: ${imagePath} -> Piclet ID: ${picletId}`);
try {
await markImageProcessingCompleted(imagePath, picletId);
} catch (error) {
console.error(`❌ Failed to mark ${imagePath} as completed:`, error);
}
await updateProgress();
// Resolve the current image processing promise
if (currentImagePromise) {
currentImagePromise.resolve(undefined);
currentImagePromise = null;
}
}
async function onTrainerImageFailed(imagePath: string, error: string) {
console.error(`❌ Trainer image failed: ${imagePath} -> ${error}`);
await markImageProcessingFailed(imagePath, error);
await updateProgress();
// Resolve the current image processing promise (failed images should still allow progression)
if (currentImagePromise) {
currentImagePromise.resolve(undefined);
currentImagePromise = null;
}
}
function formatImageName(imagePath: string | null): string {
if (!imagePath) return '';
const parts = imagePath.split('/');
return parts[parts.length - 1] || '';
}
function formatTrainerName(trainerName: string | null): string {
if (!trainerName) return '';
// Convert "001_Willow_Snap" to "Willow Snap"
return trainerName.split('_').slice(1).join(' ');
}
function getProgressPercent(): number {
const { total, completed } = scanState.progress;
return total > 0 ? Math.round((completed / total) * 100) : 0;
}
</script>
<div class="auto-trainer-scanner">
<div class="scanner-header">
<div class="title-section">
<h3>🤖 Auto Trainer Scanner</h3>
<button
class="details-toggle"
onclick={() => showDetails = !showDetails}
>
{showDetails ? '▼' : '▶'} Details
</button>
</div>
{#if scanState.progress.total > 0}
<div class="progress-summary">
<div class="progress-bar">
<div
class="progress-fill"
style="width: {getProgressPercent()}%"
></div>
</div>
<span class="progress-text">
{scanState.progress.completed} / {scanState.progress.total} ({getProgressPercent()}%)
</span>
</div>
{/if}
</div>
{#if showDetails}
<div class="scanner-details">
{#if isInitializing}
<div class="status-message">
<div class="spinner"></div>
<span>Initializing scanner...</span>
</div>
{:else if scanState.isScanning}
<div class="scanning-status">
<div class="current-processing">
<div class="spinner"></div>
<div class="processing-info">
<div class="current-trainer">
Processing: <strong>{formatTrainerName(scanState.currentTrainer)}</strong>
</div>
<div class="current-image">
{formatImageName(scanState.currentImage)}
</div>
</div>
</div>
<button class="stop-button" onclick={stopScanning}>
⏹️ Stop Scanning
</button>
</div>
{:else}
<div class="scanner-controls">
<button
class="start-button"
onclick={startScanning}
disabled={scanState.progress.pending === 0}
>
▶️ Start Auto Scan
</button>
</div>
{/if}
{#if scanState.progress.total > 0}
<div class="progress-details">
<div class="progress-stats">
<div class="stat">
<span class="stat-label">Total:</span>
<span class="stat-value">{scanState.progress.total}</span>
</div>
<div class="stat completed">
<span class="stat-label">Completed:</span>
<span class="stat-value">{scanState.progress.completed}</span>
</div>
<div class="stat pending">
<span class="stat-label">Pending:</span>
<span class="stat-value">{scanState.progress.pending}</span>
</div>
{#if scanState.progress.failed > 0}
<div class="stat failed">
<span class="stat-label">Failed:</span>
<span class="stat-value">{scanState.progress.failed}</span>
</div>
{/if}
</div>
</div>
{/if}
{#if scanState.error}
<div class="error-message">
<strong>Error:</strong> {scanState.error}
</div>
{/if}
<div class="scanner-info">
<p>
This will automatically process trainer images from the HuggingFace dataset,
converting them into unique Piclets. The scanner will resume from where it left off
if interrupted.
</p>
</div>
</div>
{/if}
</div>
<!-- Hidden PicletGenerator for trainer mode processing -->
<div style="display: none;">
<PicletGenerator
bind:this={picletGenerator}
{joyCaptionClient}
{fluxClient}
{commandClient}
isTrainerMode={true}
onTrainerImageCompleted={onTrainerImageCompleted}
onTrainerImageFailed={onTrainerImageFailed}
/>
</div>
<style>
.auto-trainer-scanner {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
padding: 1rem;
margin-bottom: 1rem;
color: white;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.scanner-header {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.title-section {
display: flex;
align-items: center;
justify-content: space-between;
}
.title-section h3 {
margin: 0;
font-size: 1.1rem;
}
.details-toggle {
background: rgba(255, 255, 255, 0.2);
border: none;
color: white;
padding: 0.3rem 0.6rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
transition: background-color 0.2s;
}
.details-toggle:hover {
background: rgba(255, 255, 255, 0.3);
}
.progress-summary {
display: flex;
align-items: center;
gap: 1rem;
}
.progress-bar {
flex: 1;
height: 8px;
background: rgba(255, 255, 255, 0.2);
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #4facfe 0%, #00f2fe 100%);
transition: width 0.3s ease;
}
.progress-text {
font-size: 0.9rem;
font-weight: 500;
white-space: nowrap;
}
.scanner-details {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.2);
}
.status-message {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.8rem;
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
margin-bottom: 1rem;
}
.scanning-status {
display: flex;
flex-direction: column;
gap: 1rem;
}
.current-processing {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
}
.processing-info {
flex: 1;
}
.current-trainer {
font-size: 1rem;
margin-bottom: 0.3rem;
}
.current-image {
font-size: 0.9rem;
opacity: 0.8;
}
.scanner-controls {
display: flex;
gap: 0.8rem;
margin-bottom: 1rem;
}
.start-button, .stop-button {
padding: 0.8rem 1.2rem;
border: none;
border-radius: 8px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.start-button {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
color: white;
}
.start-button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(79, 172, 254, 0.3);
}
.start-button:disabled {
background: rgba(255, 255, 255, 0.3);
cursor: not-allowed;
opacity: 0.6;
}
.stop-button {
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%);
color: white;
}
.stop-button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(255, 107, 107, 0.3);
}
.progress-details {
margin-bottom: 1rem;
}
.progress-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 0.8rem;
}
.stat {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.6rem;
background: rgba(255, 255, 255, 0.1);
border-radius: 6px;
border-left: 3px solid rgba(255, 255, 255, 0.5);
}
.stat.completed {
border-left-color: #4caf50;
}
.stat.pending {
border-left-color: #ff9800;
}
.stat.failed {
border-left-color: #f44336;
}
.stat-label {
font-size: 0.9rem;
opacity: 0.9;
}
.stat-value {
font-weight: 600;
font-size: 1rem;
}
.error-message {
background: rgba(244, 67, 54, 0.2);
border: 1px solid rgba(244, 67, 54, 0.4);
border-radius: 8px;
padding: 0.8rem;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.scanner-info {
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 0.8rem;
font-size: 0.9rem;
line-height: 1.4;
}
.scanner-info p {
margin: 0;
opacity: 0.9;
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top: 2px solid white;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (max-width: 768px) {
.progress-summary {
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
}
.current-processing {
flex-direction: column;
align-items: flex-start;
text-align: left;
}
.scanner-controls {
flex-direction: column;
}
.progress-stats {
grid-template-columns: 1fr;
}
}
</style>