Cropping mostly done
This commit is contained in:
@@ -1,4 +1,402 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { columnMapping, filteredSheetData, currentStep } from '$lib/stores';
|
||||
import { downloadDriveImage, isGoogleDriveUrl, createImageObjectUrl } from '$lib/google';
|
||||
import PhotoCard from '../PhotoCard.svelte';
|
||||
|
||||
interface PhotoInfo {
|
||||
name: string;
|
||||
url: string;
|
||||
status: 'loading' | 'success' | 'error';
|
||||
objectUrl?: string;
|
||||
retryCount: number;
|
||||
cropData?: { x: number; y: number; width: number; height: number };
|
||||
faceDetectionStatus?: 'pending' | 'processing' | 'completed' | 'failed';
|
||||
}
|
||||
|
||||
let photos: PhotoInfo[] = [];
|
||||
let isProcessing = false;
|
||||
let processedCount = 0;
|
||||
let totalCount = 0;
|
||||
let faceDetectionInProgress = false;
|
||||
let faceDetectionCount = { started: 0, completed: 0 };
|
||||
|
||||
// Process photos when component mounts
|
||||
onMount(() => {
|
||||
console.log('StepGallery mounted, processing photos...');
|
||||
if ($filteredSheetData.length > 0 && $columnMapping.pictureUrl !== undefined) {
|
||||
console.log('Processing photos for gallery step');
|
||||
processPhotos();
|
||||
} else {
|
||||
console.log('No data to process:', {
|
||||
dataLength: $filteredSheetData.length,
|
||||
pictureUrlMapping: $columnMapping.pictureUrl
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function processPhotos() {
|
||||
if (isProcessing) return;
|
||||
|
||||
console.log('Starting processPhotos...');
|
||||
isProcessing = true;
|
||||
processedCount = 0;
|
||||
|
||||
// Get valid and included rows from filteredSheetData
|
||||
const validRows = $filteredSheetData.filter(row => row._isValid);
|
||||
console.log(`Found ${validRows.length} valid rows`);
|
||||
|
||||
// Get unique photos to process
|
||||
const photoUrls = new Set<string>();
|
||||
const photoMap = new Map<string, any[]>(); // url -> row data
|
||||
|
||||
validRows.forEach((row: any) => {
|
||||
const photoUrl = row.pictureUrl;
|
||||
|
||||
if (photoUrl && photoUrl.trim()) {
|
||||
photoUrls.add(photoUrl.trim());
|
||||
if (!photoMap.has(photoUrl.trim())) {
|
||||
photoMap.set(photoUrl.trim(), []);
|
||||
}
|
||||
photoMap.get(photoUrl.trim())!.push(row);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Found ${photoUrls.size} unique photo URLs`);
|
||||
totalCount = photoUrls.size;
|
||||
|
||||
// Initialize photos array
|
||||
photos = Array.from(photoUrls).map(url => ({
|
||||
name: photoMap.get(url)![0].name + ' ' + photoMap.get(url)![0].surname, // Use first person's name for display
|
||||
url,
|
||||
status: 'loading' as const,
|
||||
retryCount: 0,
|
||||
faceDetectionStatus: 'pending' as const
|
||||
}));
|
||||
|
||||
// Process each photo
|
||||
for (let i = 0; i < photos.length; i++) {
|
||||
await loadPhoto(i);
|
||||
processedCount++;
|
||||
}
|
||||
|
||||
isProcessing = false;
|
||||
}
|
||||
|
||||
async function loadPhoto(index: number, isRetry = false) {
|
||||
const photo = photos[index];
|
||||
|
||||
if (!isRetry) {
|
||||
photo.status = 'loading';
|
||||
photos = [...photos]; // Trigger reactivity
|
||||
}
|
||||
|
||||
try {
|
||||
let objectUrl: string;
|
||||
|
||||
if (isGoogleDriveUrl(photo.url)) {
|
||||
// Download from Google Drive
|
||||
console.log(`Downloading from Google Drive: ${photo.name}`);
|
||||
const blob = await downloadDriveImage(photo.url);
|
||||
objectUrl = createImageObjectUrl(blob);
|
||||
} else {
|
||||
// Use direct URL
|
||||
objectUrl = photo.url;
|
||||
}
|
||||
|
||||
// Test if image loads properly
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve();
|
||||
img.onerror = (error) => {
|
||||
console.error(`Failed to load image for ${photo.name}:`, error);
|
||||
reject(new Error('Failed to load image'));
|
||||
};
|
||||
img.src = objectUrl;
|
||||
});
|
||||
|
||||
photo.objectUrl = objectUrl;
|
||||
photo.status = 'success';
|
||||
console.log(`Photo loaded successfully: ${photo.name}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to load photo for ${photo.name}:`, error);
|
||||
photo.status = 'error';
|
||||
}
|
||||
|
||||
photos = [...photos]; // Trigger reactivity
|
||||
}
|
||||
|
||||
async function retryPhoto(index: number) {
|
||||
const photo = photos[index];
|
||||
|
||||
if (photo.retryCount >= 3) {
|
||||
return; // Max retries reached
|
||||
}
|
||||
|
||||
photo.retryCount++;
|
||||
await loadPhoto(index, true);
|
||||
}
|
||||
|
||||
function handleCropUpdate(index: number, cropData: { x: number; y: number; width: number; height: number }) {
|
||||
photos[index].cropData = cropData;
|
||||
photos = [...photos]; // Trigger reactivity
|
||||
}
|
||||
|
||||
function handleFaceDetectionStarted(index: number) {
|
||||
photos[index].faceDetectionStatus = 'processing';
|
||||
faceDetectionCount.started++;
|
||||
faceDetectionInProgress = true;
|
||||
photos = [...photos]; // Trigger reactivity
|
||||
console.log(`Face detection started for photo ${index + 1}, total started: ${faceDetectionCount.started}`);
|
||||
}
|
||||
|
||||
function handleFaceDetectionCompleted(index: number, detail: { success: boolean; hasAutoDetectedCrop: boolean }) {
|
||||
photos[index].faceDetectionStatus = detail.success ? 'completed' : 'failed';
|
||||
faceDetectionCount.completed++;
|
||||
|
||||
console.log(`Face detection completed for photo ${index + 1}, total completed: ${faceDetectionCount.completed}`);
|
||||
|
||||
// Check if all face detections are complete
|
||||
if (faceDetectionCount.completed >= faceDetectionCount.started) {
|
||||
faceDetectionInProgress = false;
|
||||
console.log('All face detections completed');
|
||||
}
|
||||
|
||||
photos = [...photos]; // Trigger reactivity
|
||||
}
|
||||
|
||||
function canProceed() {
|
||||
const hasPhotos = photos.length > 0;
|
||||
const allLoaded = photos.every(photo => photo.status === 'success');
|
||||
const allCropped = photos.every(photo => photo.cropData);
|
||||
const faceDetectionComplete = !faceDetectionInProgress;
|
||||
|
||||
return hasPhotos && allLoaded && allCropped && faceDetectionComplete;
|
||||
}
|
||||
|
||||
// Cleanup object URLs when component is destroyed
|
||||
function cleanupObjectUrls() {
|
||||
photos.forEach(photo => {
|
||||
if (photo.objectUrl && photo.objectUrl.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(photo.objectUrl);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Cleanup on unmount or when photos change
|
||||
$: {
|
||||
// This will run when photos array changes
|
||||
if (photos.length === 0) {
|
||||
cleanupObjectUrls();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900">Review Photos</h2>
|
||||
<p class="text-sm text-gray-700">Photo gallery and review functionality will be implemented here.</p>
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<div class="mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-2">
|
||||
Review & Crop Photos
|
||||
</h2>
|
||||
|
||||
<p class="text-sm text-gray-700 mb-4">
|
||||
Photos are automatically cropped using face detection. Click the pen icon to manually adjust the crop area.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Processing Status -->
|
||||
{#if isProcessing}
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<div class="w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mr-3"></div>
|
||||
<span class="text-sm text-blue-800">
|
||||
Processing photos...
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-sm text-blue-600">
|
||||
{processedCount} / {totalCount}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if totalCount > 0}
|
||||
<div class="mt-3 w-full bg-blue-200 rounded-full h-2">
|
||||
<div
|
||||
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style="width: {(processedCount / totalCount) * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if faceDetectionInProgress}
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<div class="w-5 h-5 border-2 border-green-600 border-t-transparent rounded-full animate-spin mr-3"></div>
|
||||
<span class="text-sm text-green-800">
|
||||
Detecting faces and auto-cropping...
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-sm text-green-600">
|
||||
{faceDetectionCount.completed} / {faceDetectionCount.started}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if faceDetectionCount.started > 0}
|
||||
<div class="mt-3 w-full bg-green-200 rounded-full h-2">
|
||||
<div
|
||||
class="bg-green-600 h-2 rounded-full transition-all duration-300"
|
||||
style="width: {(faceDetectionCount.completed / faceDetectionCount.started) * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Summary Stats -->
|
||||
{#if !isProcessing && !faceDetectionInProgress && photos.length > 0}
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 mb-6">
|
||||
<h3 class="text-sm font-medium text-gray-700 mb-3">Processing Summary</h3>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-5 gap-4 text-sm">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-gray-900">{photos.length}</div>
|
||||
<div class="text-gray-600">Total Photos</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-green-600">
|
||||
{photos.filter(p => p.status === 'success').length}
|
||||
</div>
|
||||
<div class="text-gray-600">Loaded</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-blue-600">
|
||||
{photos.filter(p => p.faceDetectionStatus === 'completed').length}
|
||||
</div>
|
||||
<div class="text-gray-600">Auto-cropped</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-purple-600">
|
||||
{photos.filter(p => p.cropData).length}
|
||||
</div>
|
||||
<div class="text-gray-600">Ready</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-red-600">
|
||||
{photos.filter(p => p.status === 'error').length}
|
||||
</div>
|
||||
<div class="text-gray-600">Failed</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if photos.filter(p => p.status === 'error').length > 0}
|
||||
<div class="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded">
|
||||
<p class="text-sm text-yellow-800">
|
||||
<strong>Note:</strong> Cards will only be generated for photos that load successfully.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !canProceed() && photos.filter(p => p.status === 'success').length > 0}
|
||||
<div class="mt-4 p-3 bg-blue-50 border border-blue-200 rounded">
|
||||
<p class="text-sm text-blue-800">
|
||||
<strong>Tip:</strong> All photos need to be cropped before proceeding. Face detection runs automatically.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Photo Grid -->
|
||||
<div class="bg-white border border-gray-200 rounded-lg overflow-hidden mb-6">
|
||||
{#if photos.length === 0 && !isProcessing}
|
||||
<div class="text-center py-12">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">No photos found</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
Go back to check your column mapping and selected rows.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{#each photos as photo, index}
|
||||
{#if photo.status === 'loading'}
|
||||
<div class="border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm">
|
||||
<div class="aspect-square bg-gray-100 flex items-center justify-center">
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mb-2"></div>
|
||||
<span class="text-xs text-gray-600">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<h4 class="font-medium text-sm text-gray-900 truncate">{photo.name}</h4>
|
||||
<span class="text-xs text-blue-600">Processing photo...</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else if photo.status === 'success' && photo.objectUrl}
|
||||
<PhotoCard
|
||||
imageUrl={photo.objectUrl}
|
||||
personName={photo.name}
|
||||
isProcessing={false}
|
||||
on:cropUpdated={(e) => handleCropUpdate(index, e.detail)}
|
||||
on:faceDetectionStarted={() => handleFaceDetectionStarted(index)}
|
||||
on:faceDetectionCompleted={(e) => handleFaceDetectionCompleted(index, e.detail)}
|
||||
/>
|
||||
{:else if photo.status === 'error'}
|
||||
<div class="border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm">
|
||||
<div class="aspect-square bg-gray-100 flex items-center justify-center">
|
||||
<div class="flex flex-col items-center text-center p-4">
|
||||
<svg class="w-12 h-12 text-red-400 mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<span class="text-xs text-red-600 mb-2">Failed to load</span>
|
||||
<button
|
||||
class="text-xs text-blue-600 hover:text-blue-800 underline"
|
||||
on:click={() => retryPhoto(index)}
|
||||
disabled={photo.retryCount >= 3}
|
||||
>
|
||||
{photo.retryCount >= 3 ? 'Max retries' : 'Retry'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<h4 class="font-medium text-sm text-gray-900 truncate">{photo.name}</h4>
|
||||
<span class="text-xs text-red-600">Failed to load</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="flex justify-between">
|
||||
<button
|
||||
on:click={() => currentStep.set(3)}
|
||||
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300"
|
||||
>
|
||||
← Back to Row Filter
|
||||
</button>
|
||||
|
||||
<button
|
||||
on:click={() => currentStep.set(5)}
|
||||
disabled={!canProceed()}
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
{canProceed()
|
||||
? `Generate ${photos.filter(p => p.status === 'success' && p.cropData).length} Cards →`
|
||||
: 'Waiting for photos to load and crop'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user