| <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; |
| zephyrClient: GradioClient; |
| fluxClient: GradioClient; |
| qwenClient: GradioClient; |
| } |
| |
| let { joyCaptionClient, zephyrClient, fluxClient, qwenClient }: Props = $props(); |
| |
| |
| 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 |
| }); |
| |
| let showDetails = $state(false); |
| let isInitializing = $state(false); |
| let shouldStop = $state(false); |
| |
| |
| let picletGenerator: any; |
| |
| |
| $effect(() => { |
| if (joyCaptionClient && zephyrClient && fluxClient) { |
| loadInitialState(); |
| } |
| }); |
| |
| async function loadInitialState() { |
| try { |
| isInitializing = true; |
| |
| |
| 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) { |
| |
| break; |
| } |
| |
| scanState.currentImage = nextImage.imagePath; |
| scanState.currentTrainer = nextImage.trainerName; |
| |
| try { |
| |
| const imageFile = await fetchRemoteImage(nextImage.remoteUrl, nextImage.imagePath); |
| |
| |
| if (picletGenerator) { |
| picletGenerator.queueTrainerImage(imageFile, nextImage.imagePath); |
| } |
| |
| |
| |
| |
| } catch (error) { |
| console.error(`Failed to process ${nextImage.imagePath}:`, error); |
| await markImageProcessingFailed( |
| nextImage.imagePath, |
| error instanceof Error ? error.message : 'Unknown error' |
| ); |
| } |
| |
| await updateProgress(); |
| |
| |
| await new Promise(resolve => setTimeout(resolve, 1000)); |
| } |
| } |
| |
| 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}`); |
| await markImageProcessingCompleted(imagePath, picletId); |
| await updateProgress(); |
| } |
| |
| async function onTrainerImageFailed(imagePath: string, error: string) { |
| console.error(`❌ Trainer image failed: ${imagePath} -> ${error}`); |
| await markImageProcessingFailed(imagePath, error); |
| await updateProgress(); |
| } |
| |
| 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 ''; |
| |
| 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} |
| {zephyrClient} |
| {fluxClient} |
| {qwenClient} |
| 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> |