rethinks commited on
Commit
cd40c5f
·
verified ·
1 Parent(s): 4aee874

Upload 6 files

Browse files
app.py CHANGED
@@ -821,30 +821,102 @@ def process_photos_quality_selection(job_id, upload_dir, quality_mode, similarit
821
 
822
  print(f"[Job {job_id}] Target per month: {target_per_month}")
823
 
824
- # Step 1: Generate embeddings for confirmed photos
825
  processing_jobs[job_id]['progress'] = 10
826
- processing_jobs[job_id]['message'] = f'Analyzing photos with {model_display_name}...'
827
 
828
- print(f"[Job {job_id}] Generating {model_display_name} embeddings for {len(confirmed_photos)} photos...")
829
 
830
- embedder = Embedder()
831
- embeddings = {}
 
 
 
 
 
832
 
 
 
 
 
 
833
  for i, filename in enumerate(confirmed_photos):
834
  filepath = os.path.join(upload_dir, filename)
835
  if os.path.exists(filepath):
836
- img = embedder.load_image(filepath)
837
- if img is not None:
838
- embedding = embedder.get_embedding(img)
839
- if embedding is not None:
840
- embeddings[filename] = embedding
841
- img.close()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
842
 
843
- # Update progress (10-30%)
844
- progress = 10 + int((i / len(confirmed_photos)) * 20)
845
- processing_jobs[job_id]['progress'] = progress
 
 
846
 
847
- print(f"[Job {job_id}] Embeddings generated: {len(embeddings)}")
848
 
849
  # Step 2: Initialize monthly selector
850
  processing_jobs[job_id]['progress'] = 35
@@ -1056,6 +1128,7 @@ def process_photos_quality_selection(job_id, upload_dir, quality_mode, similarit
1056
  'category': photo.get('category', 'unknown'),
1057
  'num_faces': int(photo.get('num_faces', 0)),
1058
  'cluster_id': cid,
 
1059
  'cluster_total': cluster_total,
1060
  'cluster_selected': cluster_selected,
1061
  'event_id': photo.get('event_id', -1),
@@ -1114,6 +1187,7 @@ def process_photos_quality_selection(job_id, upload_dir, quality_mode, similarit
1114
  'month': month,
1115
  'category': photo.get('category', 'unknown'),
1116
  'cluster_id': cid,
 
1117
  'cluster_total': cluster_total,
1118
  'cluster_selected': cluster_selected,
1119
  'event_id': photo.get('event_id', -1),
 
821
 
822
  print(f"[Job {job_id}] Target per month: {target_per_month}")
823
 
824
+ # Step 1: Generate embeddings for confirmed photos (with caching)
825
  processing_jobs[job_id]['progress'] = 10
826
+ processing_jobs[job_id]['message'] = f'Checking embedding cache...'
827
 
828
+ print(f"[Job {job_id}] Processing {len(confirmed_photos)} photos for {model_display_name} embeddings...")
829
 
830
+ # Import cache functions
831
+ from supabase_storage import (
832
+ compute_file_hash,
833
+ get_cached_embeddings_batch,
834
+ save_embeddings_batch,
835
+ is_supabase_available
836
+ )
837
 
838
+ # Step 1a: Compute hashes for all files
839
+ file_hashes = {} # filename -> hash
840
+ hash_to_filename = {} # hash -> filename (for reverse lookup)
841
+
842
+ print(f"[Job {job_id}] Computing file hashes...")
843
  for i, filename in enumerate(confirmed_photos):
844
  filepath = os.path.join(upload_dir, filename)
845
  if os.path.exists(filepath):
846
+ file_hash = compute_file_hash(filepath)
847
+ if file_hash:
848
+ file_hashes[filename] = file_hash
849
+ hash_to_filename[file_hash] = filename
850
+
851
+ # Update progress (10-15%)
852
+ if i % 100 == 0:
853
+ progress = 10 + int((i / len(confirmed_photos)) * 5)
854
+ processing_jobs[job_id]['progress'] = progress
855
+
856
+ print(f"[Job {job_id}] Computed {len(file_hashes)} hashes")
857
+
858
+ # Step 1b: Check cache for existing embeddings
859
+ embeddings = {}
860
+ cached_count = 0
861
+ uncached_filenames = []
862
+
863
+ if is_supabase_available() and file_hashes:
864
+ processing_jobs[job_id]['message'] = f'Checking embedding cache...'
865
+ all_hashes = list(file_hashes.values())
866
+
867
+ # Query cache in batches (Supabase has query limits)
868
+ cached_embeddings = {}
869
+ batch_size = 500
870
+ for i in range(0, len(all_hashes), batch_size):
871
+ batch_hashes = all_hashes[i:i + batch_size]
872
+ batch_result = get_cached_embeddings_batch(batch_hashes, embedding_model)
873
+ cached_embeddings.update(batch_result)
874
+
875
+ # Map cached embeddings back to filenames
876
+ for filename, file_hash in file_hashes.items():
877
+ if file_hash in cached_embeddings:
878
+ embeddings[filename] = cached_embeddings[file_hash]
879
+ cached_count += 1
880
+ else:
881
+ uncached_filenames.append(filename)
882
+
883
+ print(f"[Job {job_id}] Cache hit: {cached_count}/{len(file_hashes)} embeddings")
884
+ else:
885
+ uncached_filenames = list(file_hashes.keys())
886
+ print(f"[Job {job_id}] Cache not available, computing all embeddings")
887
+
888
+ # Step 1c: Compute embeddings for uncached files only
889
+ newly_computed = {}
890
+ if uncached_filenames:
891
+ processing_jobs[job_id]['message'] = f'Analyzing {len(uncached_filenames)} photos with {model_display_name}...'
892
+ print(f"[Job {job_id}] Computing {model_display_name} embeddings for {len(uncached_filenames)} uncached photos...")
893
+
894
+ embedder = Embedder()
895
+
896
+ for i, filename in enumerate(uncached_filenames):
897
+ filepath = os.path.join(upload_dir, filename)
898
+ if os.path.exists(filepath):
899
+ img = embedder.load_image(filepath)
900
+ if img is not None:
901
+ embedding = embedder.get_embedding(img)
902
+ if embedding is not None:
903
+ embeddings[filename] = embedding
904
+ newly_computed[filename] = embedding
905
+ img.close()
906
+
907
+ # Update progress (15-30%)
908
+ progress = 15 + int((i / len(uncached_filenames)) * 15)
909
+ processing_jobs[job_id]['progress'] = progress
910
+
911
+ print(f"[Job {job_id}] Computed {len(newly_computed)} new embeddings")
912
 
913
+ # Step 1d: Save newly computed embeddings to cache
914
+ if newly_computed and is_supabase_available():
915
+ processing_jobs[job_id]['message'] = 'Saving embeddings to cache...'
916
+ saved = save_embeddings_batch(newly_computed, file_hashes, embedding_model)
917
+ print(f"[Job {job_id}] Saved {saved} embeddings to cache")
918
 
919
+ print(f"[Job {job_id}] Total embeddings: {len(embeddings)} (cached: {cached_count}, computed: {len(newly_computed)})")
920
 
921
  # Step 2: Initialize monthly selector
922
  processing_jobs[job_id]['progress'] = 35
 
1128
  'category': photo.get('category', 'unknown'),
1129
  'num_faces': int(photo.get('num_faces', 0)),
1130
  'cluster_id': cid,
1131
+ 'original_cluster_id': photo.get('original_cluster_id', cid),
1132
  'cluster_total': cluster_total,
1133
  'cluster_selected': cluster_selected,
1134
  'event_id': photo.get('event_id', -1),
 
1187
  'month': month,
1188
  'category': photo.get('category', 'unknown'),
1189
  'cluster_id': cid,
1190
+ 'original_cluster_id': photo.get('original_cluster_id', cid),
1191
  'cluster_total': cluster_total,
1192
  'cluster_selected': cluster_selected,
1193
  'event_id': photo.get('event_id', -1),
photo_selector/monthly_selector.py CHANGED
@@ -809,6 +809,79 @@ class MonthlyPhotoSelector:
809
 
810
  return merged
811
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
812
  def select_hybrid_hdbscan(self, photos: List[Dict],
813
  embeddings: Dict[str, np.ndarray],
814
  target: int,
@@ -849,11 +922,21 @@ class MonthlyPhotoSelector:
849
  # Step 1: Cluster photos with HDBSCAN (for cluster_id info in results)
850
  clusters = self.cluster_photos_hdbscan(photos, embeddings)
851
 
 
 
 
 
 
 
852
  # Step 1.5: Merge clusters that have photos too similar to each other
853
  # This prevents "too similar" rejections during selection by ensuring
854
  # similar photos are in the same cluster from the start
855
  clusters = self.merge_similar_clusters(clusters, embeddings, similarity_threshold=0.80)
856
- print(f" Final cluster count: {len(clusters)}")
 
 
 
 
857
 
858
  # Step 2: Flatten all photos with cluster_id and sort by score
859
  all_photos = []
 
809
 
810
  return merged
811
 
812
+ def merge_same_event_clusters(self, clusters: Dict[int, List[Dict]]) -> Dict[int, List[Dict]]:
813
+ """
814
+ Merge clusters that share the same event_id.
815
+
816
+ If any photo in Cluster A has the same event_id as any photo in Cluster B,
817
+ merge them into one cluster. This ensures photos from the same event
818
+ appear together in the cluster view.
819
+
820
+ Note: original_cluster_id should already be saved before this function is called.
821
+
822
+ Args:
823
+ clusters: Dict mapping cluster_id to list of photos
824
+
825
+ Returns:
826
+ Merged clusters dict
827
+ """
828
+ if len(clusters) <= 1:
829
+ return clusters
830
+
831
+ cluster_ids = list(clusters.keys())
832
+ n_clusters = len(cluster_ids)
833
+
834
+ # Build a union-find structure for merging
835
+ parent = {cid: cid for cid in cluster_ids}
836
+
837
+ def find(x):
838
+ if parent[x] != x:
839
+ parent[x] = find(parent[x])
840
+ return parent[x]
841
+
842
+ def union(x, y):
843
+ px, py = find(x), find(y)
844
+ if px != py:
845
+ parent[px] = py
846
+
847
+ # Build event_id -> cluster_ids mapping
848
+ event_to_clusters = {}
849
+ for cid, photos in clusters.items():
850
+ for photo in photos:
851
+ event_id = photo.get('event_id', -1)
852
+ if event_id != -1:
853
+ if event_id not in event_to_clusters:
854
+ event_to_clusters[event_id] = set()
855
+ event_to_clusters[event_id].add(cid)
856
+
857
+ # Merge clusters that share the same event_id
858
+ merge_count = 0
859
+ for event_id, cluster_set in event_to_clusters.items():
860
+ cluster_list = list(cluster_set)
861
+ if len(cluster_list) > 1:
862
+ # Merge all clusters in this event
863
+ first_cid = cluster_list[0]
864
+ for other_cid in cluster_list[1:]:
865
+ if find(first_cid) != find(other_cid):
866
+ union(first_cid, other_cid)
867
+ merge_count += 1
868
+
869
+ if merge_count == 0:
870
+ print(f" No clusters needed event-based merging")
871
+ return clusters
872
+
873
+ # Build merged clusters
874
+ merged = {}
875
+ for cid in cluster_ids:
876
+ root = find(cid)
877
+ if root not in merged:
878
+ merged[root] = []
879
+ merged[root].extend(clusters[cid])
880
+
881
+ print(f" Event-merged {merge_count} cluster pairs: {n_clusters} -> {len(merged)} clusters")
882
+
883
+ return merged
884
+
885
  def select_hybrid_hdbscan(self, photos: List[Dict],
886
  embeddings: Dict[str, np.ndarray],
887
  target: int,
 
922
  # Step 1: Cluster photos with HDBSCAN (for cluster_id info in results)
923
  clusters = self.cluster_photos_hdbscan(photos, embeddings)
924
 
925
+ # Step 1.1: Save original_cluster_id BEFORE any merging
926
+ # This preserves the true HDBSCAN cluster assignment for display
927
+ for cid, cluster_photos in clusters.items():
928
+ for photo in cluster_photos:
929
+ photo['original_cluster_id'] = int(cid)
930
+
931
  # Step 1.5: Merge clusters that have photos too similar to each other
932
  # This prevents "too similar" rejections during selection by ensuring
933
  # similar photos are in the same cluster from the start
934
  clusters = self.merge_similar_clusters(clusters, embeddings, similarity_threshold=0.80)
935
+
936
+ # Step 1.6: Merge clusters that share the same event_id
937
+ # This ensures photos from the same event appear together in cluster view
938
+ clusters = self.merge_same_event_clusters(clusters)
939
+ print(f" Final cluster count after event merge: {len(clusters)}")
940
 
941
  # Step 2: Flatten all photos with cluster_id and sort by score
942
  all_photos = []
supabase_storage.py CHANGED
@@ -1,10 +1,14 @@
1
  """
2
  Supabase Storage Integration for Photo Selection App
3
  Handles persistent storage of dataset metadata (not photos) in Supabase.
 
4
  """
5
 
6
  import os
7
  import json
 
 
 
8
  from typing import Optional, List, Dict, Any
9
 
10
  # Supabase credentials
@@ -343,3 +347,187 @@ def check_dataset_exists_in_supabase(dataset_name: str) -> bool:
343
  return len(files) > 0
344
  except:
345
  return False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
  Supabase Storage Integration for Photo Selection App
3
  Handles persistent storage of dataset metadata (not photos) in Supabase.
4
+ Also provides global embedding cache for CLIP/SigLIP embeddings.
5
  """
6
 
7
  import os
8
  import json
9
+ import base64
10
+ import hashlib
11
+ import numpy as np
12
  from typing import Optional, List, Dict, Any
13
 
14
  # Supabase credentials
 
347
  return len(files) > 0
348
  except:
349
  return False
350
+
351
+
352
+ # =============================================================================
353
+ # GLOBAL EMBEDDING CACHE
354
+ # =============================================================================
355
+ # Stores CLIP/SigLIP embeddings in Supabase database table for reuse.
356
+ # Table schema (create in Supabase Dashboard):
357
+ #
358
+ # CREATE TABLE image_embeddings (
359
+ # id BIGSERIAL PRIMARY KEY,
360
+ # image_hash TEXT NOT NULL,
361
+ # embedding_model TEXT NOT NULL,
362
+ # embedding TEXT NOT NULL,
363
+ # embedding_dim INTEGER NOT NULL,
364
+ # created_at TIMESTAMPTZ DEFAULT NOW(),
365
+ # UNIQUE(image_hash, embedding_model)
366
+ # );
367
+ # CREATE INDEX idx_image_embeddings_hash_model ON image_embeddings(image_hash, embedding_model);
368
+ # =============================================================================
369
+
370
+ EMBEDDING_TABLE = 'image_embeddings'
371
+
372
+
373
+ def compute_file_hash(filepath: str) -> Optional[str]:
374
+ """
375
+ Compute MD5 hash of a file.
376
+
377
+ Args:
378
+ filepath: Path to the image file
379
+
380
+ Returns:
381
+ MD5 hash string or None if error
382
+ """
383
+ try:
384
+ md5 = hashlib.md5()
385
+ with open(filepath, 'rb') as f:
386
+ # Read in chunks for memory efficiency
387
+ for chunk in iter(lambda: f.read(8192), b''):
388
+ md5.update(chunk)
389
+ return md5.hexdigest()
390
+ except Exception as e:
391
+ print(f"[EmbeddingCache] Error hashing {filepath}: {e}")
392
+ return None
393
+
394
+
395
+ def _embedding_to_base64(embedding: np.ndarray) -> str:
396
+ """Convert numpy embedding to base64 string for storage."""
397
+ return base64.b64encode(embedding.astype(np.float32).tobytes()).decode('utf-8')
398
+
399
+
400
+ def _base64_to_embedding(b64_str: str, dim: int) -> np.ndarray:
401
+ """Convert base64 string back to numpy embedding."""
402
+ bytes_data = base64.b64decode(b64_str)
403
+ return np.frombuffer(bytes_data, dtype=np.float32).reshape(dim)
404
+
405
+
406
+ def get_cached_embeddings_batch(
407
+ image_hashes: List[str],
408
+ embedding_model: str
409
+ ) -> Dict[str, np.ndarray]:
410
+ """
411
+ Get cached embeddings for multiple images in one query.
412
+
413
+ Args:
414
+ image_hashes: List of MD5 hashes to look up
415
+ embedding_model: Model name ('siglip' or 'clip')
416
+
417
+ Returns:
418
+ Dict mapping hash -> embedding for found entries
419
+ """
420
+ client = get_supabase_client()
421
+ if not client or not image_hashes:
422
+ return {}
423
+
424
+ try:
425
+ # Query all hashes at once
426
+ response = client.table(EMBEDDING_TABLE).select(
427
+ 'image_hash, embedding, embedding_dim'
428
+ ).in_('image_hash', image_hashes).eq('embedding_model', embedding_model).execute()
429
+
430
+ result = {}
431
+ for row in response.data:
432
+ embedding = _base64_to_embedding(row['embedding'], row['embedding_dim'])
433
+ result[row['image_hash']] = embedding
434
+
435
+ print(f"[EmbeddingCache] Found {len(result)}/{len(image_hashes)} cached embeddings for {embedding_model}")
436
+ return result
437
+
438
+ except Exception as e:
439
+ print(f"[EmbeddingCache] Error fetching batch: {e}")
440
+ return {}
441
+
442
+
443
+ def save_embeddings_batch(
444
+ embeddings: Dict[str, np.ndarray],
445
+ image_hashes: Dict[str, str],
446
+ embedding_model: str
447
+ ) -> int:
448
+ """
449
+ Save multiple embeddings to cache.
450
+
451
+ Args:
452
+ embeddings: Dict mapping filename -> embedding
453
+ image_hashes: Dict mapping filename -> hash
454
+ embedding_model: Model name ('siglip' or 'clip')
455
+
456
+ Returns:
457
+ Number of embeddings saved
458
+ """
459
+ client = get_supabase_client()
460
+ if not client or not embeddings:
461
+ return 0
462
+
463
+ try:
464
+ # Prepare batch insert data
465
+ rows = []
466
+ for filename, embedding in embeddings.items():
467
+ img_hash = image_hashes.get(filename)
468
+ if img_hash and embedding is not None:
469
+ rows.append({
470
+ 'image_hash': img_hash,
471
+ 'embedding_model': embedding_model,
472
+ 'embedding': _embedding_to_base64(embedding),
473
+ 'embedding_dim': len(embedding)
474
+ })
475
+
476
+ if not rows:
477
+ return 0
478
+
479
+ # Insert with upsert (ignore conflicts)
480
+ # Batch in chunks of 100 to avoid request size limits
481
+ saved = 0
482
+ chunk_size = 100
483
+ for i in range(0, len(rows), chunk_size):
484
+ chunk = rows[i:i + chunk_size]
485
+ try:
486
+ client.table(EMBEDDING_TABLE).upsert(
487
+ chunk,
488
+ on_conflict='image_hash,embedding_model'
489
+ ).execute()
490
+ saved += len(chunk)
491
+ except Exception as e:
492
+ print(f"[EmbeddingCache] Error saving chunk {i//chunk_size}: {e}")
493
+
494
+ print(f"[EmbeddingCache] Saved {saved} new embeddings for {embedding_model}")
495
+ return saved
496
+
497
+ except Exception as e:
498
+ print(f"[EmbeddingCache] Error saving batch: {e}")
499
+ return 0
500
+
501
+
502
+ def get_embedding_cache_stats() -> Dict[str, Any]:
503
+ """Get statistics about the embedding cache."""
504
+ client = get_supabase_client()
505
+ if not client:
506
+ return {'available': False}
507
+
508
+ try:
509
+ # Count by model
510
+ response = client.table(EMBEDDING_TABLE).select(
511
+ 'embedding_model',
512
+ count='exact'
513
+ ).execute()
514
+
515
+ # Get counts per model
516
+ siglip_count = client.table(EMBEDDING_TABLE).select(
517
+ 'id', count='exact'
518
+ ).eq('embedding_model', 'siglip').execute()
519
+
520
+ clip_count = client.table(EMBEDDING_TABLE).select(
521
+ 'id', count='exact'
522
+ ).eq('embedding_model', 'clip').execute()
523
+
524
+ return {
525
+ 'available': True,
526
+ 'siglip_count': siglip_count.count or 0,
527
+ 'clip_count': clip_count.count or 0,
528
+ 'total': (siglip_count.count or 0) + (clip_count.count or 0)
529
+ }
530
+
531
+ except Exception as e:
532
+ print(f"[EmbeddingCache] Error getting stats: {e}")
533
+ return {'available': False, 'error': str(e)}
templates/reupload_photos.html CHANGED
@@ -453,26 +453,8 @@
453
 
454
  <!-- Upload Area -->
455
  <div class="upload-card">
456
- <!-- Upload Method Tabs -->
457
- <div class="upload-tabs">
458
- <button class="upload-tab active" onclick="switchUploadMethod('browser')">Browser Upload</button>
459
- <button class="upload-tab" id="drive-tab" onclick="switchUploadMethod('drive')" style="display: none;">Google Drive</button>
460
- </div>
461
-
462
- <!-- Browser Upload Area -->
463
- <div class="upload-area" id="upload-area" onclick="document.getElementById('file-input').click()">
464
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
465
- <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
466
- <polyline points="17 8 12 3 7 8"/>
467
- <line x1="12" y1="3" x2="12" y2="15"/>
468
- </svg>
469
- <h3>Upload Your Photos</h3>
470
- <p>Drag and drop photos or a .zip file here, or click to browse</p>
471
- <span class="btn">Select Photos or Zip</span>
472
- </div>
473
-
474
  <!-- Google Drive Input Area -->
475
- <div class="drive-input-area" id="drive-input-area">
476
  <svg viewBox="0 0 24 24" width="50" height="50" fill="none" stroke="#667eea" stroke-width="1.5" style="margin-bottom: 15px;">
477
  <path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
478
  </svg>
@@ -491,11 +473,6 @@
491
  </button>
492
  </div>
493
 
494
- <input type="file" id="file-input" multiple accept="image/*,.heic,.heif,.zip">
495
-
496
- <!-- File List Preview -->
497
- <div class="file-list" id="file-list" style="display: none;"></div>
498
-
499
  <!-- Progress Section -->
500
  <div class="progress-section" id="progress-section">
501
  <div class="progress-header">
@@ -526,50 +503,8 @@
526
 
527
  <script>
528
  const datasetName = "{{ dataset_name }}";
529
- let selectedFiles = [];
530
- let uploadMethod = 'browser'; // 'browser' or 'drive'
531
- let driveAvailable = false;
532
  let driveFolderVerified = false;
533
 
534
- // Check if Google Drive is available on page load
535
- async function checkDriveAvailability() {
536
- try {
537
- const response = await fetch('/check_drive_status');
538
- const data = await response.json();
539
- driveAvailable = data.available;
540
- if (driveAvailable) {
541
- document.getElementById('drive-tab').style.display = '';
542
- }
543
- } catch (e) {
544
- console.log('Drive check failed:', e);
545
- }
546
- }
547
- checkDriveAvailability();
548
-
549
- // Upload method switcher
550
- function switchUploadMethod(method) {
551
- uploadMethod = method;
552
- document.querySelectorAll('.upload-tab').forEach(tab => {
553
- const tabText = tab.textContent.toLowerCase();
554
- const isActive = (method === 'browser' && tabText.includes('browser')) ||
555
- (method === 'drive' && tabText.includes('drive'));
556
- tab.classList.toggle('active', isActive);
557
- });
558
-
559
- // Show/hide upload areas
560
- document.getElementById('upload-area').style.display = method === 'browser' ? 'block' : 'none';
561
- document.getElementById('drive-input-area').classList.toggle('active', method === 'drive');
562
-
563
- // Reset file list when switching
564
- if (method === 'drive') {
565
- document.getElementById('file-list').style.display = 'none';
566
- selectedFiles = [];
567
- document.getElementById('btn-process').disabled = !driveFolderVerified;
568
- } else {
569
- document.getElementById('btn-process').disabled = selectedFiles.length === 0;
570
- }
571
- }
572
-
573
  // Preview Google Drive folder
574
  async function previewDriveFolder() {
575
  const driveUrl = document.getElementById('drive-folder-url').value.trim();
@@ -616,91 +551,9 @@
616
  document.getElementById('drive-folder-url').addEventListener('input', function() {
617
  driveFolderVerified = false;
618
  document.getElementById('drive-preview').classList.remove('show');
619
- if (uploadMethod === 'drive') {
620
- document.getElementById('btn-process').disabled = true;
621
- }
622
  });
623
 
624
- // File input change
625
- document.getElementById('file-input').addEventListener('change', function(e) {
626
- handleFiles(e.target.files);
627
- });
628
-
629
- // Drag and drop
630
- const uploadArea = document.getElementById('upload-area');
631
-
632
- uploadArea.addEventListener('dragover', (e) => {
633
- e.preventDefault();
634
- uploadArea.classList.add('dragover');
635
- });
636
-
637
- uploadArea.addEventListener('dragleave', () => {
638
- uploadArea.classList.remove('dragover');
639
- });
640
-
641
- uploadArea.addEventListener('drop', (e) => {
642
- e.preventDefault();
643
- uploadArea.classList.remove('dragover');
644
- handleFiles(e.dataTransfer.files);
645
- });
646
-
647
- let isZipUpload = false;
648
-
649
- function handleFiles(files) {
650
- const allFiles = Array.from(files);
651
-
652
- // Check if a zip file was selected
653
- const zipFile = allFiles.find(f => f.name.toLowerCase().endsWith('.zip'));
654
-
655
- if (zipFile) {
656
- // Zip file upload mode
657
- isZipUpload = true;
658
- selectedFiles = [zipFile];
659
-
660
- const fileList = document.getElementById('file-list');
661
- fileList.style.display = 'block';
662
- fileList.innerHTML = `
663
- <div class="file-item">
664
- <span class="file-name">${zipFile.name}</span>
665
- <span class="file-size">${(zipFile.size / 1024 / 1024).toFixed(1)} MB (zip)</span>
666
- </div>
667
- <div class="file-item" style="color: #28a745;">
668
- <span class="file-name">✓ Zip upload is faster and more reliable</span>
669
- </div>
670
- `;
671
- document.getElementById('btn-process').disabled = false;
672
- } else {
673
- // Individual photos mode
674
- isZipUpload = false;
675
- selectedFiles = allFiles.filter(f =>
676
- f.type.startsWith('image/') ||
677
- f.name.toLowerCase().endsWith('.heic') ||
678
- f.name.toLowerCase().endsWith('.heif')
679
- );
680
-
681
- // Update file list
682
- const fileList = document.getElementById('file-list');
683
- if (selectedFiles.length > 0) {
684
- fileList.style.display = 'block';
685
- fileList.innerHTML = selectedFiles.slice(0, 10).map(f => `
686
- <div class="file-item">
687
- <span class="file-name">${f.name}</span>
688
- <span class="file-size">${(f.size / 1024 / 1024).toFixed(1)} MB</span>
689
- </div>
690
- `).join('') + (selectedFiles.length > 10 ? `
691
- <div class="file-item">
692
- <span class="file-name">... and ${selectedFiles.length - 10} more files</span>
693
- </div>
694
- ` : '');
695
-
696
- document.getElementById('btn-process').disabled = false;
697
- } else {
698
- fileList.style.display = 'none';
699
- document.getElementById('btn-process').disabled = true;
700
- }
701
- }
702
- }
703
-
704
  let processingInterval = null;
705
  const processingSteps = [
706
  'Loading dataset from Supabase...',
@@ -719,8 +572,6 @@
719
  }
720
  }
721
 
722
- const CHUNK_SIZE = 20; // Upload 20 photos per batch (smaller = more reliable on HuggingFace)
723
-
724
  async function processPhotos() {
725
  const btn = document.getElementById('btn-process');
726
  btn.disabled = true;
@@ -729,28 +580,7 @@
729
  const progressSection = document.getElementById('progress-section');
730
  progressSection.classList.add('active');
731
 
732
- // Google Drive import mode
733
- if (uploadMethod === 'drive') {
734
- await importFromDrive();
735
- return;
736
- }
737
-
738
- // Browser upload modes
739
- if (selectedFiles.length === 0) return;
740
-
741
- // If zip upload, use single request (zip is already one file)
742
- if (isZipUpload) {
743
- await uploadZipFile();
744
- return;
745
- }
746
-
747
- // For individual photos, use chunked upload
748
- await uploadInChunks();
749
- }
750
-
751
- // Google Drive import function
752
- async function importFromDrive() {
753
- const btn = document.getElementById('btn-process');
754
  const driveUrl = document.getElementById('drive-folder-url').value.trim();
755
 
756
  if (!driveUrl || !driveFolderVerified) {
@@ -819,286 +649,6 @@
819
  }
820
  }
821
 
822
- async function uploadZipFile() {
823
- const btn = document.getElementById('btn-process');
824
- const formData = new FormData();
825
- formData.append('zipfile', selectedFiles[0]);
826
-
827
- try {
828
- const xhr = new XMLHttpRequest();
829
-
830
- xhr.upload.addEventListener('progress', (e) => {
831
- if (e.lengthComputable) {
832
- const percent = Math.round((e.loaded / e.total) * 100);
833
- document.getElementById('progress-fill').style.width = percent + '%';
834
- document.getElementById('progress-percent').textContent = percent + '%';
835
-
836
- if (percent < 100) {
837
- const mbUploaded = (e.loaded / 1024 / 1024).toFixed(1);
838
- const mbTotal = (e.total / 1024 / 1024).toFixed(1);
839
- document.getElementById('progress-status').textContent =
840
- `Uploading zip file... (${mbUploaded} MB / ${mbTotal} MB)`;
841
- } else {
842
- document.getElementById('progress-text').textContent = 'Processing...';
843
- document.getElementById('progress-status').innerHTML =
844
- '<strong>Extracting zip file...</strong><br>' +
845
- '<span style="color: #888; font-size: 12px;">This may take a moment for large files</span>';
846
- currentStep = 0;
847
- updateProcessingStatus();
848
- processingInterval = setInterval(updateProcessingStatus, 1500);
849
- }
850
- }
851
- });
852
-
853
- xhr.addEventListener('load', () => handleUploadComplete(xhr, btn));
854
- xhr.addEventListener('error', () => handleUploadError(btn));
855
-
856
- xhr.open('POST', `/process_reupload/${datasetName}`);
857
- xhr.send(formData);
858
- } catch (err) {
859
- handleUploadError(btn, err.message);
860
- }
861
- }
862
-
863
- const PARALLEL_UPLOADS = 4; // Upload 4 chunks simultaneously
864
-
865
- async function uploadInChunks() {
866
- const btn = document.getElementById('btn-process');
867
- const totalFiles = selectedFiles.length;
868
- const totalChunks = Math.ceil(totalFiles / CHUNK_SIZE);
869
-
870
- // First, start an upload session
871
- let sessionResponse;
872
- try {
873
- document.getElementById('progress-status').textContent = 'Starting upload session...';
874
- const startRes = await fetch(`/start_chunked_upload/${datasetName}`, {
875
- method: 'POST',
876
- headers: { 'Content-Type': 'application/json' },
877
- body: JSON.stringify({ total_files: totalFiles, total_chunks: totalChunks })
878
- });
879
- sessionResponse = await startRes.json();
880
- if (!sessionResponse.upload_id) {
881
- throw new Error(sessionResponse.error || 'Failed to start upload session');
882
- }
883
- } catch (err) {
884
- showResult(false, 'Failed to start upload: ' + err.message);
885
- btn.disabled = false;
886
- btn.innerHTML = 'Continue Processing';
887
- return;
888
- }
889
-
890
- const uploadId = sessionResponse.upload_id;
891
- let uploadedCount = 0;
892
- let failedChunks = [];
893
-
894
- // Prepare all chunk data
895
- const chunks = [];
896
- for (let i = 0; i < totalChunks; i++) {
897
- const start = i * CHUNK_SIZE;
898
- const end = Math.min(start + CHUNK_SIZE, totalFiles);
899
- chunks.push({
900
- index: i,
901
- files: selectedFiles.slice(start, end),
902
- start: start,
903
- end: end
904
- });
905
- }
906
-
907
- // Upload chunks in parallel batches
908
- for (let batchStart = 0; batchStart < totalChunks; batchStart += PARALLEL_UPLOADS) {
909
- const batchEnd = Math.min(batchStart + PARALLEL_UPLOADS, totalChunks);
910
- const batchChunks = chunks.slice(batchStart, batchEnd);
911
-
912
- document.getElementById('progress-text').textContent =
913
- `Uploading batches ${batchStart + 1}-${batchEnd} of ${totalChunks}`;
914
- document.getElementById('progress-status').textContent =
915
- `Parallel upload: ${PARALLEL_UPLOADS} batches at once for faster upload`;
916
-
917
- // Upload batch in parallel
918
- const uploadPromises = batchChunks.map(chunk =>
919
- uploadChunkWithRetry(uploadId, chunk, totalChunks)
920
- );
921
-
922
- const results = await Promise.allSettled(uploadPromises);
923
-
924
- // Process results
925
- for (let i = 0; i < results.length; i++) {
926
- if (results[i].status === 'fulfilled' && results[i].value.success) {
927
- uploadedCount += batchChunks[i].files.length;
928
- } else {
929
- failedChunks.push(batchChunks[i].index);
930
- }
931
- }
932
-
933
- // Update progress
934
- const percent = Math.round((uploadedCount / totalFiles) * 100);
935
- document.getElementById('progress-fill').style.width = percent + '%';
936
- document.getElementById('progress-percent').textContent = percent + '%';
937
- }
938
-
939
- // Retry failed chunks one more time (sequentially for reliability)
940
- if (failedChunks.length > 0) {
941
- document.getElementById('progress-status').textContent =
942
- `Retrying ${failedChunks.length} failed batches...`;
943
-
944
- for (const chunkIndex of failedChunks) {
945
- const chunk = chunks[chunkIndex];
946
- const result = await uploadChunkWithRetry(uploadId, chunk, totalChunks, 2);
947
- if (result.success) {
948
- uploadedCount += chunk.files.length;
949
- const percent = Math.round((uploadedCount / totalFiles) * 100);
950
- document.getElementById('progress-fill').style.width = percent + '%';
951
- document.getElementById('progress-percent').textContent = percent + '%';
952
- } else {
953
- showResult(false, `Upload failed at batch ${chunkIndex + 1}. Please try again.`);
954
- btn.disabled = false;
955
- btn.innerHTML = 'Continue Processing';
956
- return;
957
- }
958
- }
959
- }
960
-
961
- // All chunks uploaded, finalize
962
- document.getElementById('progress-text').textContent = 'Processing...';
963
- document.getElementById('progress-status').textContent = 'Finalizing upload...';
964
- currentStep = 0;
965
- updateProcessingStatus();
966
- processingInterval = setInterval(updateProcessingStatus, 1500);
967
-
968
- try {
969
- const finalRes = await fetch(`/finish_chunked_upload/${datasetName}`, {
970
- method: 'POST',
971
- headers: { 'Content-Type': 'application/json' },
972
- body: JSON.stringify({ upload_id: uploadId })
973
- });
974
- const result = await finalRes.json();
975
-
976
- if (processingInterval) {
977
- clearInterval(processingInterval);
978
- processingInterval = null;
979
- }
980
-
981
- if (result.success) {
982
- document.getElementById('progress-status').innerHTML =
983
- `<strong>Success!</strong> Matched ${result.matched_photos} of ${result.total_uploaded} photos.`;
984
- showResult(true,
985
- `Matched ${result.matched_photos} photos from ${result.total_uploaded} uploaded. Redirecting...`
986
- );
987
- setTimeout(() => {
988
- window.location.href = result.redirect_url;
989
- }, 1500);
990
- } else {
991
- showResult(false, result.error || 'Failed to process upload');
992
- btn.disabled = false;
993
- btn.innerHTML = 'Continue Processing';
994
- }
995
- } catch (err) {
996
- if (processingInterval) {
997
- clearInterval(processingInterval);
998
- processingInterval = null;
999
- }
1000
- showResult(false, 'Failed to finalize upload: ' + err.message);
1001
- btn.disabled = false;
1002
- btn.innerHTML = 'Continue Processing';
1003
- }
1004
- }
1005
-
1006
- async function uploadChunkWithRetry(uploadId, chunk, totalChunks, maxRetries = 5) {
1007
- let retries = maxRetries;
1008
- let delay = 2000; // Start with 2 second delay
1009
- while (retries > 0) {
1010
- try {
1011
- const success = await uploadChunk(uploadId, chunk.index, chunk.files, totalChunks);
1012
- if (success) {
1013
- return { success: true };
1014
- }
1015
- } catch (err) {
1016
- retries--;
1017
- console.log(`Chunk ${chunk.index} failed: ${err.message}. Retries left: ${retries}`);
1018
- if (retries > 0) {
1019
- document.getElementById('progress-status').textContent =
1020
- `Batch ${chunk.index + 1} failed, retrying in ${delay/1000}s... (${retries} attempts left)`;
1021
- await new Promise(r => setTimeout(r, delay));
1022
- delay = Math.min(delay * 1.5, 10000); // Increase delay up to 10s max
1023
- }
1024
- }
1025
- }
1026
- return { success: false };
1027
- }
1028
-
1029
- async function uploadChunk(uploadId, chunkIndex, files, totalChunks) {
1030
- return new Promise((resolve, reject) => {
1031
- const formData = new FormData();
1032
- formData.append('upload_id', uploadId);
1033
- formData.append('chunk_index', chunkIndex);
1034
- formData.append('total_chunks', totalChunks);
1035
- files.forEach(file => {
1036
- formData.append('photos', file);
1037
- });
1038
-
1039
- const xhr = new XMLHttpRequest();
1040
- xhr.timeout = 120000; // 2 minute timeout per chunk
1041
-
1042
- xhr.addEventListener('load', () => {
1043
- if (xhr.status === 200) {
1044
- const response = JSON.parse(xhr.responseText);
1045
- if (response.success) {
1046
- resolve(true);
1047
- } else {
1048
- reject(new Error(response.error || 'Chunk upload failed'));
1049
- }
1050
- } else {
1051
- reject(new Error('Server error: ' + xhr.status));
1052
- }
1053
- });
1054
-
1055
- xhr.addEventListener('error', () => reject(new Error('Network error')));
1056
- xhr.addEventListener('timeout', () => reject(new Error('Upload timeout')));
1057
-
1058
- xhr.open('POST', `/upload_reupload_chunk/${datasetName}`);
1059
- xhr.send(formData);
1060
- });
1061
- }
1062
-
1063
- function handleUploadComplete(xhr, btn) {
1064
- if (processingInterval) {
1065
- clearInterval(processingInterval);
1066
- processingInterval = null;
1067
- }
1068
-
1069
- if (xhr.status === 200) {
1070
- const response = JSON.parse(xhr.responseText);
1071
- if (response.success) {
1072
- document.getElementById('progress-status').innerHTML =
1073
- `<strong>Success!</strong> Matched ${response.matched_photos} of ${response.total_uploaded} photos.`;
1074
- showResult(true,
1075
- `Matched ${response.matched_photos} photos from ${response.total_uploaded} uploaded. Redirecting...`
1076
- );
1077
- setTimeout(() => {
1078
- window.location.href = response.redirect_url;
1079
- }, 1500);
1080
- } else {
1081
- showResult(false, response.error || 'Unknown error');
1082
- btn.disabled = false;
1083
- btn.innerHTML = 'Continue Processing';
1084
- }
1085
- } else {
1086
- showResult(false, 'Upload failed. Please try again.');
1087
- btn.disabled = false;
1088
- btn.innerHTML = 'Continue Processing';
1089
- }
1090
- }
1091
-
1092
- function handleUploadError(btn, message = 'Network error. Please try again.') {
1093
- if (processingInterval) {
1094
- clearInterval(processingInterval);
1095
- processingInterval = null;
1096
- }
1097
- showResult(false, message);
1098
- btn.disabled = false;
1099
- btn.innerHTML = 'Continue Processing';
1100
- }
1101
-
1102
  function showResult(success, message) {
1103
  const resultSection = document.getElementById('result-section');
1104
  const resultTitle = document.getElementById('result-title');
 
453
 
454
  <!-- Upload Area -->
455
  <div class="upload-card">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
456
  <!-- Google Drive Input Area -->
457
+ <div class="drive-input-area active" id="drive-input-area">
458
  <svg viewBox="0 0 24 24" width="50" height="50" fill="none" stroke="#667eea" stroke-width="1.5" style="margin-bottom: 15px;">
459
  <path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
460
  </svg>
 
473
  </button>
474
  </div>
475
 
 
 
 
 
 
476
  <!-- Progress Section -->
477
  <div class="progress-section" id="progress-section">
478
  <div class="progress-header">
 
503
 
504
  <script>
505
  const datasetName = "{{ dataset_name }}";
 
 
 
506
  let driveFolderVerified = false;
507
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
508
  // Preview Google Drive folder
509
  async function previewDriveFolder() {
510
  const driveUrl = document.getElementById('drive-folder-url').value.trim();
 
551
  document.getElementById('drive-folder-url').addEventListener('input', function() {
552
  driveFolderVerified = false;
553
  document.getElementById('drive-preview').classList.remove('show');
554
+ document.getElementById('btn-process').disabled = true;
 
 
555
  });
556
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
557
  let processingInterval = null;
558
  const processingSteps = [
559
  'Loading dataset from Supabase...',
 
572
  }
573
  }
574
 
 
 
575
  async function processPhotos() {
576
  const btn = document.getElementById('btn-process');
577
  btn.disabled = true;
 
580
  const progressSection = document.getElementById('progress-section');
581
  progressSection.classList.add('active');
582
 
583
+ // Google Drive import
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
584
  const driveUrl = document.getElementById('drive-folder-url').value.trim();
585
 
586
  if (!driveUrl || !driveFolderVerified) {
 
649
  }
650
  }
651
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
652
  function showResult(success, message) {
653
  const resultSection = document.getElementById('result-section');
654
  const resultTitle = document.getElementById('result-title');
templates/step2_upload.html CHANGED
@@ -739,25 +739,8 @@
739
  <h2>Upload Your Event Photos</h2>
740
  <p>Upload all photos from the event - we'll find the best ones of your child</p>
741
 
742
- <!-- Upload Method Tabs -->
743
- <div class="upload-tabs">
744
- <button class="upload-tab active" onclick="switchUploadMethod('browser')">Browser Upload</button>
745
- <button class="upload-tab" id="drive-tab" onclick="switchUploadMethod('drive')" style="display: none;">Google Drive</button>
746
- </div>
747
-
748
- <!-- Browser Upload Area -->
749
- <div class="upload-area" id="drop-zone">
750
- <div class="upload-icon">&#128194;</div>
751
- <h3>Drag & Drop Photos Here</h3>
752
- <p>Supports JPG, PNG, HEIC, WebP (recommended for &lt;500 photos)</p>
753
- <input type="file" id="file-input" multiple accept=".jpg,.jpeg,.png,.heic,.heif,.webp" hidden>
754
- <button class="btn btn-primary" onclick="document.getElementById('file-input').click()">
755
- Select Files
756
- </button>
757
- </div>
758
-
759
  <!-- Google Drive Input -->
760
- <div class="folder-input-area hidden" id="drive-input-area">
761
  <div class="upload-icon" style="font-size: 48px;">&#9729;</div>
762
  <h3>Import from Google Drive</h3>
763
  <p>Paste a Google Drive folder URL (folder must be shared with the service account)</p>
@@ -854,52 +837,10 @@
854
 
855
  <script>
856
  const hasReferences = {{ reference_count }} > 0;
857
- let selectedFiles = [];
858
  let qualityMode = 'balanced';
859
  let jobId = null;
860
- let uploadMethod = 'browser'; // 'browser', 'folder', or 'drive'
861
- let driveAvailable = false;
862
  let driveFolderVerified = false;
863
 
864
- // Check if Google Drive is available on page load
865
- async function checkDriveAvailability() {
866
- try {
867
- const response = await fetch('/check_drive_status');
868
- const data = await response.json();
869
- driveAvailable = data.available;
870
- if (driveAvailable) {
871
- document.getElementById('drive-tab').style.display = '';
872
- }
873
- } catch (e) {
874
- console.log('Drive check failed:', e);
875
- }
876
- }
877
- checkDriveAvailability();
878
-
879
- // Upload method switcher
880
- function switchUploadMethod(method) {
881
- uploadMethod = method;
882
- document.querySelectorAll('.upload-tab').forEach(tab => {
883
- const tabText = tab.textContent.toLowerCase();
884
- const isActive = (method === 'browser' && tabText.includes('browser')) ||
885
- (method === 'drive' && tabText.includes('drive'));
886
- tab.classList.toggle('active', isActive);
887
- });
888
- document.getElementById('drop-zone').classList.toggle('hidden', method !== 'browser');
889
- document.getElementById('drive-input-area').classList.toggle('hidden', method !== 'drive');
890
-
891
- // Update file preview visibility based on method
892
- if (method === 'drive') {
893
- document.getElementById('file-preview').classList.add('hidden');
894
- updateDrivePreview();
895
- } else {
896
- // Browser mode - show preview if files selected
897
- if (selectedFiles.length > 0) {
898
- document.getElementById('file-preview').classList.remove('hidden');
899
- }
900
- }
901
- }
902
-
903
  function updateDrivePreview() {
904
  const driveUrl = document.getElementById('drive-folder-url').value.trim();
905
  const preview = document.getElementById('file-preview');
@@ -957,9 +898,6 @@
957
  }
958
  }
959
 
960
- // Folder path input handler
961
- document.getElementById('folder-path').addEventListener('input', updateFolderPreview);
962
-
963
  // Drive URL input handler - reset verification on change
964
  document.getElementById('drive-folder-url').addEventListener('input', function() {
965
  driveFolderVerified = false;
@@ -967,69 +905,6 @@
967
  document.getElementById('file-preview').classList.add('hidden');
968
  });
969
 
970
- // File input handler
971
- document.getElementById('file-input').addEventListener('change', function(e) {
972
- handleFiles(e.target.files);
973
- });
974
-
975
- // Drag and drop
976
- const dropZone = document.getElementById('drop-zone');
977
-
978
- dropZone.addEventListener('dragover', (e) => {
979
- e.preventDefault();
980
- dropZone.classList.add('drag-over');
981
- });
982
-
983
- dropZone.addEventListener('dragleave', () => {
984
- dropZone.classList.remove('drag-over');
985
- });
986
-
987
- dropZone.addEventListener('drop', (e) => {
988
- e.preventDefault();
989
- dropZone.classList.remove('drag-over');
990
- handleFiles(e.dataTransfer.files);
991
- });
992
-
993
- function handleFiles(files) {
994
- selectedFiles = Array.from(files);
995
- updatePreview();
996
- }
997
-
998
- function updatePreview() {
999
- const preview = document.getElementById('file-preview');
1000
- const grid = document.getElementById('preview-grid');
1001
- const countEl = document.getElementById('file-count');
1002
-
1003
- if (selectedFiles.length === 0) {
1004
- preview.classList.add('hidden');
1005
- return;
1006
- }
1007
-
1008
- preview.classList.remove('hidden');
1009
- countEl.textContent = `${selectedFiles.length} photos`;
1010
- grid.innerHTML = '';
1011
-
1012
- const maxPreview = 11;
1013
- const filesToShow = selectedFiles.slice(0, maxPreview);
1014
-
1015
- filesToShow.forEach(file => {
1016
- const div = document.createElement('div');
1017
- div.className = 'preview-item';
1018
-
1019
- const img = document.createElement('img');
1020
- img.src = URL.createObjectURL(file);
1021
- div.appendChild(img);
1022
- grid.appendChild(div);
1023
- });
1024
-
1025
- if (selectedFiles.length > maxPreview) {
1026
- const more = document.createElement('div');
1027
- more.className = 'preview-item more-indicator';
1028
- more.textContent = `+${selectedFiles.length - maxPreview}`;
1029
- grid.appendChild(more);
1030
- }
1031
- }
1032
-
1033
  function setQualityMode(mode) {
1034
  qualityMode = mode;
1035
  document.querySelectorAll('.quality-btn').forEach(btn => {
@@ -1046,140 +921,48 @@
1046
  // Show processing overlay
1047
  document.getElementById('processing-overlay').classList.remove('hidden');
1048
 
1049
- if (uploadMethod === 'drive') {
1050
- // Google Drive import mode
1051
- const driveUrl = document.getElementById('drive-folder-url').value.trim();
1052
- if (!driveUrl) {
1053
- alert('Please enter a Google Drive folder URL');
1054
- document.getElementById('processing-overlay').classList.add('hidden');
1055
- return;
1056
- }
1057
 
1058
- if (!driveFolderVerified) {
1059
- alert('Please click "Preview Folder" first to verify access');
1060
- document.getElementById('processing-overlay').classList.add('hidden');
1061
- return;
1062
- }
1063
 
1064
- document.getElementById('processing-message').textContent = 'Connecting to Google Drive...';
1065
-
1066
- try {
1067
- const response = await fetch('/import_from_drive', {
1068
- method: 'POST',
1069
- headers: { 'Content-Type': 'application/json' },
1070
- body: JSON.stringify({
1071
- folder_url: driveUrl,
1072
- quality_mode: qualityMode,
1073
- similarity_threshold: parseFloat(document.getElementById('similarity').value)
1074
- })
1075
- });
1076
-
1077
- const data = await response.json();
1078
-
1079
- if (data.error) {
1080
- alert('Error: ' + data.error);
1081
- document.getElementById('processing-overlay').classList.add('hidden');
1082
- return;
1083
- }
1084
-
1085
- jobId = data.job_id;
1086
- pollStatus();
1087
-
1088
- } catch (error) {
1089
- console.error('Error:', error);
1090
- alert('Failed to import from Google Drive. Please try again.');
1091
- document.getElementById('processing-overlay').classList.add('hidden');
1092
- }
1093
 
1094
- } else {
1095
- // Browser upload mode
1096
- if (selectedFiles.length === 0) {
1097
- alert('Please select photos first');
 
 
 
 
 
 
 
 
 
 
 
1098
  document.getElementById('processing-overlay').classList.add('hidden');
1099
  return;
1100
  }
1101
 
1102
- // Upload files in chunks to avoid 413 errors
1103
- const CHUNK_SIZE = 50; // Upload 50 files at a time
1104
- const totalFiles = selectedFiles.length;
1105
- let uploadedCount = 0;
1106
-
1107
- document.getElementById('processing-message').textContent = `Uploading photos (0/${totalFiles})...`;
1108
-
1109
- try {
1110
- // First, start a new upload session
1111
- const initResponse = await fetch('/upload_init', {
1112
- method: 'POST',
1113
- headers: { 'Content-Type': 'application/json' },
1114
- body: JSON.stringify({
1115
- total_files: totalFiles,
1116
- quality_mode: qualityMode,
1117
- similarity_threshold: parseFloat(document.getElementById('similarity').value)
1118
- })
1119
- });
1120
-
1121
- const initData = await initResponse.json();
1122
- if (initData.error) {
1123
- alert('Error: ' + initData.error);
1124
- document.getElementById('processing-overlay').classList.add('hidden');
1125
- return;
1126
- }
1127
-
1128
- const sessionId = initData.session_id;
1129
-
1130
- // Upload files in chunks
1131
- for (let i = 0; i < totalFiles; i += CHUNK_SIZE) {
1132
- const chunk = selectedFiles.slice(i, i + CHUNK_SIZE);
1133
- const formData = new FormData();
1134
-
1135
- chunk.forEach(file => formData.append('files', file));
1136
- formData.append('session_id', sessionId);
1137
- formData.append('chunk_index', Math.floor(i / CHUNK_SIZE));
1138
-
1139
- const chunkResponse = await fetch('/upload_chunk', {
1140
- method: 'POST',
1141
- body: formData
1142
- });
1143
-
1144
- const chunkData = await chunkResponse.json();
1145
- if (chunkData.error) {
1146
- alert('Error uploading chunk: ' + chunkData.error);
1147
- document.getElementById('processing-overlay').classList.add('hidden');
1148
- return;
1149
- }
1150
-
1151
- uploadedCount += chunk.length;
1152
- const percent = Math.round((uploadedCount / totalFiles) * 30); // Upload is 30% of progress
1153
- document.getElementById('processing-message').textContent = `Uploading photos (${uploadedCount}/${totalFiles})...`;
1154
- document.getElementById('progress-fill').style.width = `${percent}%`;
1155
- document.getElementById('progress-percent').textContent = percent;
1156
- }
1157
-
1158
- // All chunks uploaded, start processing
1159
- document.getElementById('processing-message').textContent = 'Starting AI processing...';
1160
-
1161
- const processResponse = await fetch('/upload_complete', {
1162
- method: 'POST',
1163
- headers: { 'Content-Type': 'application/json' },
1164
- body: JSON.stringify({ session_id: sessionId })
1165
- });
1166
-
1167
- const processData = await processResponse.json();
1168
-
1169
- if (processData.error) {
1170
- alert('Error: ' + processData.error);
1171
- document.getElementById('processing-overlay').classList.add('hidden');
1172
- return;
1173
- }
1174
-
1175
- jobId = processData.job_id;
1176
- pollStatus();
1177
-
1178
- } catch (error) {
1179
- console.error('Upload error:', error);
1180
- alert('Upload failed. Please try again.');
1181
- document.getElementById('processing-overlay').classList.add('hidden');
1182
- }
1183
  }
1184
  }
1185
 
 
739
  <h2>Upload Your Event Photos</h2>
740
  <p>Upload all photos from the event - we'll find the best ones of your child</p>
741
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
742
  <!-- Google Drive Input -->
743
+ <div class="folder-input-area" id="drive-input-area">
744
  <div class="upload-icon" style="font-size: 48px;">&#9729;</div>
745
  <h3>Import from Google Drive</h3>
746
  <p>Paste a Google Drive folder URL (folder must be shared with the service account)</p>
 
837
 
838
  <script>
839
  const hasReferences = {{ reference_count }} > 0;
 
840
  let qualityMode = 'balanced';
841
  let jobId = null;
 
 
842
  let driveFolderVerified = false;
843
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
844
  function updateDrivePreview() {
845
  const driveUrl = document.getElementById('drive-folder-url').value.trim();
846
  const preview = document.getElementById('file-preview');
 
898
  }
899
  }
900
 
 
 
 
901
  // Drive URL input handler - reset verification on change
902
  document.getElementById('drive-folder-url').addEventListener('input', function() {
903
  driveFolderVerified = false;
 
905
  document.getElementById('file-preview').classList.add('hidden');
906
  });
907
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
908
  function setQualityMode(mode) {
909
  qualityMode = mode;
910
  document.querySelectorAll('.quality-btn').forEach(btn => {
 
921
  // Show processing overlay
922
  document.getElementById('processing-overlay').classList.remove('hidden');
923
 
924
+ // Google Drive import mode
925
+ const driveUrl = document.getElementById('drive-folder-url').value.trim();
926
+ if (!driveUrl) {
927
+ alert('Please enter a Google Drive folder URL');
928
+ document.getElementById('processing-overlay').classList.add('hidden');
929
+ return;
930
+ }
 
931
 
932
+ if (!driveFolderVerified) {
933
+ alert('Please click "Preview Folder" first to verify access');
934
+ document.getElementById('processing-overlay').classList.add('hidden');
935
+ return;
936
+ }
937
 
938
+ document.getElementById('processing-message').textContent = 'Connecting to Google Drive...';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
939
 
940
+ try {
941
+ const response = await fetch('/import_from_drive', {
942
+ method: 'POST',
943
+ headers: { 'Content-Type': 'application/json' },
944
+ body: JSON.stringify({
945
+ folder_url: driveUrl,
946
+ quality_mode: qualityMode,
947
+ similarity_threshold: parseFloat(document.getElementById('similarity').value)
948
+ })
949
+ });
950
+
951
+ const data = await response.json();
952
+
953
+ if (data.error) {
954
+ alert('Error: ' + data.error);
955
  document.getElementById('processing-overlay').classList.add('hidden');
956
  return;
957
  }
958
 
959
+ jobId = data.job_id;
960
+ pollStatus();
961
+
962
+ } catch (error) {
963
+ console.error('Error:', error);
964
+ alert('Failed to import from Google Drive. Please try again.');
965
+ document.getElementById('processing-overlay').classList.add('hidden');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
966
  }
967
  }
968
 
templates/step4_results.html CHANGED
@@ -1295,6 +1295,188 @@
1295
  filter: grayscale(30%);
1296
  }
1297
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1298
  .cluster-photo-badge {
1299
  position: absolute;
1300
  top: 6px;
@@ -1470,9 +1652,11 @@
1470
  <!-- Cluster View Container -->
1471
  <div class="cluster-view-container">
1472
  <div class="cluster-view-header">
1473
- <h3>Photo Clusters</h3>
1474
  <p class="cluster-view-subtitle">Photos grouped by visual similarity. Click to expand and see all photos in each cluster.</p>
1475
  </div>
 
 
1476
  <div id="cluster-grid" class="cluster-grid"></div>
1477
  </div>
1478
  </div>
@@ -1992,9 +2176,26 @@
1992
  </div>
1993
  `;
1994
 
1995
- // Click to expand/collapse
1996
  card.onclick = (e) => {
1997
- // Don't toggle if clicking on a photo
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1998
  if (e.target.closest('.cluster-photo-item')) return;
1999
  toggleClusterCard(card, cluster.id);
2000
  };
@@ -2003,8 +2204,6 @@
2003
  }
2004
 
2005
  function renderClusterCardPhotos(cluster) {
2006
- let html = '';
2007
-
2008
  // Ensure arrays exist
2009
  const selectedPhotos = cluster.selected || [];
2010
  const rejectedPhotos = cluster.rejected || [];
@@ -2020,12 +2219,88 @@
2020
  return '<div style="padding: 20px; color: #888; text-align: center;">No photos in this cluster</div>';
2021
  }
2022
 
2023
- // Render all photos in one grid (selected first, then rejected)
2024
- html += '<div class="cluster-photos-grid">';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2025
  for (const photo of allPhotos) {
2026
- html += createClusterPhotoItem(photo, photo._isSelected);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2027
  }
2028
- html += '</div>';
 
 
 
 
2029
 
2030
  return html;
2031
  }
@@ -2272,7 +2547,20 @@
2272
  const uniqueness = photo.uniqueness ? (photo.uniqueness * 100).toFixed(0) : 'N/A';
2273
  const eventId = photo.event_id !== undefined ? photo.event_id : -1;
2274
  const clusterId = photo.cluster_id !== undefined ? photo.cluster_id : -1;
2275
- const clusterLabel = clusterId >= 0 ? `Cluster ${clusterId}` : 'None';
 
 
 
 
 
 
 
 
 
 
 
 
 
2276
  const similarity = photo.max_similarity !== undefined ? (photo.max_similarity * 100).toFixed(1) : '0.0';
2277
  const category = photo.category || 'Unknown';
2278
  const datetime = photo.date || 'N/A';
 
1295
  filter: grayscale(30%);
1296
  }
1297
 
1298
+ /* Cluster Details Table */
1299
+ .cluster-details-table {
1300
+ width: 100%;
1301
+ border-collapse: separate;
1302
+ border-spacing: 0;
1303
+ font-size: 13px;
1304
+ border: 1px solid #e2e8f0;
1305
+ border-radius: 10px;
1306
+ overflow: hidden;
1307
+ box-shadow: 0 1px 3px rgba(0,0,0,0.05);
1308
+ }
1309
+
1310
+ .cluster-details-table th {
1311
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
1312
+ padding: 14px 12px;
1313
+ text-align: center;
1314
+ font-weight: 600;
1315
+ color: white;
1316
+ border-right: 1px solid rgba(255,255,255,0.15);
1317
+ white-space: nowrap;
1318
+ font-size: 11px;
1319
+ text-transform: uppercase;
1320
+ letter-spacing: 0.8px;
1321
+ }
1322
+
1323
+ .cluster-details-table th:first-child {
1324
+ text-align: left;
1325
+ padding-left: 16px;
1326
+ }
1327
+
1328
+ .cluster-details-table th:last-child {
1329
+ border-right: none;
1330
+ }
1331
+
1332
+ .cluster-details-table td {
1333
+ padding: 14px 10px;
1334
+ border-bottom: 1px solid #f1f5f9;
1335
+ border-right: 1px solid #f8fafc;
1336
+ vertical-align: middle;
1337
+ text-align: center;
1338
+ }
1339
+
1340
+ .cluster-details-table td:first-child {
1341
+ text-align: left;
1342
+ padding-left: 12px;
1343
+ }
1344
+
1345
+ .cluster-details-table td:last-child {
1346
+ border-right: none;
1347
+ }
1348
+
1349
+ .cluster-details-table tbody tr {
1350
+ cursor: pointer;
1351
+ transition: all 0.15s ease;
1352
+ }
1353
+
1354
+ .cluster-details-table tbody tr:hover {
1355
+ background: #f8fafc;
1356
+ }
1357
+
1358
+ .cluster-details-table tbody tr:last-child td {
1359
+ border-bottom: none;
1360
+ }
1361
+
1362
+ .cluster-details-table tr.selected-row {
1363
+ background: linear-gradient(90deg, #f0fdf4 0%, #ffffff 100%);
1364
+ }
1365
+
1366
+ .cluster-details-table tr.selected-row:hover {
1367
+ background: linear-gradient(90deg, #dcfce7 0%, #f0fdf4 100%);
1368
+ }
1369
+
1370
+ .cluster-details-table tr.rejected-row {
1371
+ background: #ffffff;
1372
+ }
1373
+
1374
+ .cluster-details-table tr.rejected-row:hover {
1375
+ background: linear-gradient(90deg, #fef2f2 0%, #ffffff 100%);
1376
+ }
1377
+
1378
+ .cluster-table-photo {
1379
+ display: flex;
1380
+ flex-direction: column;
1381
+ align-items: center;
1382
+ gap: 6px;
1383
+ }
1384
+
1385
+ .cluster-table-photo img {
1386
+ width: 120px;
1387
+ height: 120px;
1388
+ object-fit: cover;
1389
+ border-radius: 8px;
1390
+ border: 3px solid transparent;
1391
+ }
1392
+
1393
+ .cluster-table-photo.selected img {
1394
+ border-color: #10b981;
1395
+ }
1396
+
1397
+ .cluster-table-photo.rejected img {
1398
+ border-color: #ef4444;
1399
+ opacity: 0.85;
1400
+ }
1401
+
1402
+ .cluster-table-photo .photo-filename {
1403
+ font-size: 11px;
1404
+ color: #64748b;
1405
+ max-width: 120px;
1406
+ word-break: break-all;
1407
+ text-align: center;
1408
+ }
1409
+
1410
+ .cluster-table-photo .photo-score {
1411
+ font-size: 14px;
1412
+ font-weight: 700;
1413
+ }
1414
+
1415
+ .cluster-table-photo.selected .photo-score {
1416
+ color: #10b981;
1417
+ }
1418
+
1419
+ .cluster-table-photo.rejected .photo-score {
1420
+ color: #ef4444;
1421
+ }
1422
+
1423
+ .cluster-table-photo .photo-badge {
1424
+ position: absolute;
1425
+ top: 4px;
1426
+ right: 4px;
1427
+ width: 22px;
1428
+ height: 22px;
1429
+ border-radius: 50%;
1430
+ display: flex;
1431
+ align-items: center;
1432
+ justify-content: center;
1433
+ font-size: 12px;
1434
+ font-weight: bold;
1435
+ color: white;
1436
+ }
1437
+
1438
+ .cluster-table-photo.selected .photo-badge {
1439
+ background: #10b981;
1440
+ }
1441
+
1442
+ .cluster-table-photo.rejected .photo-badge {
1443
+ background: #ef4444;
1444
+ }
1445
+
1446
+ .cluster-table-reason {
1447
+ max-width: 200px;
1448
+ font-size: 12px;
1449
+ color: #475569;
1450
+ }
1451
+
1452
+ .cluster-table-meta {
1453
+ font-size: 12px;
1454
+ color: #64748b;
1455
+ white-space: nowrap;
1456
+ }
1457
+
1458
+ .cluster-table-meta .original {
1459
+ color: #94a3b8;
1460
+ font-size: 11px;
1461
+ }
1462
+
1463
+ .score-cell {
1464
+ text-align: center;
1465
+ font-weight: 500;
1466
+ }
1467
+
1468
+ .score-cell.good {
1469
+ color: #10b981;
1470
+ }
1471
+
1472
+ .score-cell.medium {
1473
+ color: #f59e0b;
1474
+ }
1475
+
1476
+ .score-cell.low {
1477
+ color: #ef4444;
1478
+ }
1479
+
1480
  .cluster-photo-badge {
1481
  position: absolute;
1482
  top: 6px;
 
1652
  <!-- Cluster View Container -->
1653
  <div class="cluster-view-container">
1654
  <div class="cluster-view-header">
1655
+ <h3 id="cluster-view-title">Photo Clusters</h3>
1656
  <p class="cluster-view-subtitle">Photos grouped by visual similarity. Click to expand and see all photos in each cluster.</p>
1657
  </div>
1658
+
1659
+ <!-- Clusters grid -->
1660
  <div id="cluster-grid" class="cluster-grid"></div>
1661
  </div>
1662
  </div>
 
2176
  </div>
2177
  `;
2178
 
2179
+ // Click to expand/collapse (only on header, not on table)
2180
  card.onclick = (e) => {
2181
+ // Don't toggle if clicking on the photo table or its contents
2182
+ if (e.target.closest('.cluster-details-table')) {
2183
+ // Handle table row click to open modal
2184
+ const row = e.target.closest('tr[data-filename]');
2185
+ if (row) {
2186
+ const filename = row.dataset.filename;
2187
+ const isSelected = row.dataset.selected === 'true';
2188
+
2189
+ // Find the photo in cluster data
2190
+ const allPhotos = [...(cluster.selected || []), ...(cluster.rejected || [])];
2191
+ const photo = allPhotos.find(p => p.filename === filename);
2192
+ if (photo) {
2193
+ openModal(photo, isSelected);
2194
+ }
2195
+ }
2196
+ return;
2197
+ }
2198
+ // Don't toggle if clicking on old photo items (fallback)
2199
  if (e.target.closest('.cluster-photo-item')) return;
2200
  toggleClusterCard(card, cluster.id);
2201
  };
 
2204
  }
2205
 
2206
  function renderClusterCardPhotos(cluster) {
 
 
2207
  // Ensure arrays exist
2208
  const selectedPhotos = cluster.selected || [];
2209
  const rejectedPhotos = cluster.rejected || [];
 
2219
  return '<div style="padding: 20px; color: #888; text-align: center;">No photos in this cluster</div>';
2220
  }
2221
 
2222
+ // Render table with all photo details
2223
+ let html = `
2224
+ <table class="cluster-details-table">
2225
+ <thead>
2226
+ <tr>
2227
+ <th>Photo</th>
2228
+ <th>Score</th>
2229
+ <th>Face Quality</th>
2230
+ <th>Aesthetic</th>
2231
+ <th>Emotional</th>
2232
+ <th>Uniqueness</th>
2233
+ <th>Reason</th>
2234
+ <th>Cluster / Event</th>
2235
+ </tr>
2236
+ </thead>
2237
+ <tbody>
2238
+ `;
2239
+
2240
  for (const photo of allPhotos) {
2241
+ const isSelected = photo._isSelected;
2242
+ const filename = photo.filename || 'unknown';
2243
+ const thumbnail = getPhotoThumbnail(photo);
2244
+ const score = photo.score ? (photo.score * 100).toFixed(0) : '0';
2245
+ const faceQuality = photo.face_quality ? (photo.face_quality * 100).toFixed(0) : 'N/A';
2246
+ const aesthetic = photo.aesthetic_quality ? (photo.aesthetic_quality * 100).toFixed(0) : 'N/A';
2247
+ const emotional = photo.emotional_signal ? (photo.emotional_signal * 100).toFixed(0) : 'N/A';
2248
+ const uniqueness = photo.uniqueness ? (photo.uniqueness * 100).toFixed(0) : 'N/A';
2249
+ const reason = isSelected
2250
+ ? (photo.selection_reason || 'Selected')
2251
+ : (photo.rejection_reason || photo.reason || 'Not selected');
2252
+
2253
+ const clusterId = photo.cluster_id !== undefined ? photo.cluster_id : -1;
2254
+ const originalClusterId = photo.original_cluster_id !== undefined ? photo.original_cluster_id : clusterId;
2255
+ const eventId = photo.event_id !== undefined ? photo.event_id : -1;
2256
+
2257
+ // Show original cluster if different
2258
+ let clusterDisplay = clusterId >= 0 ? `C:${clusterId}` : 'None';
2259
+ if (originalClusterId !== clusterId && originalClusterId >= 0) {
2260
+ clusterDisplay += ` <span class="original">(orig:${originalClusterId})</span>`;
2261
+ }
2262
+ const eventDisplay = eventId >= 0 ? `E:${eventId}` : '';
2263
+
2264
+ // Score color classes
2265
+ const getScoreClass = (val) => {
2266
+ if (val === 'N/A') return '';
2267
+ const num = parseInt(val);
2268
+ if (num >= 70) return 'good';
2269
+ if (num >= 50) return 'medium';
2270
+ return 'low';
2271
+ };
2272
+
2273
+ const rowClass = isSelected ? 'selected-row' : 'rejected-row';
2274
+ const photoClass = isSelected ? 'selected' : 'rejected';
2275
+ const badge = isSelected ? '✓' : '✗';
2276
+
2277
+ html += `
2278
+ <tr class="${rowClass}" data-filename="${filename}" data-selected="${isSelected}">
2279
+ <td>
2280
+ <div class="cluster-table-photo ${photoClass}">
2281
+ <div style="position: relative;">
2282
+ <img src="/thumbnail/${jobId}/${thumbnail}" alt="${filename}" loading="lazy">
2283
+ <span class="photo-badge">${badge}</span>
2284
+ </div>
2285
+ <span class="photo-score">${score}%</span>
2286
+ <span class="photo-filename">${filename}</span>
2287
+ </div>
2288
+ </td>
2289
+ <td class="score-cell ${getScoreClass(score)}">${score}%</td>
2290
+ <td class="score-cell ${getScoreClass(faceQuality)}">${faceQuality}%</td>
2291
+ <td class="score-cell ${getScoreClass(aesthetic)}">${aesthetic}%</td>
2292
+ <td class="score-cell ${getScoreClass(emotional)}">${emotional}%</td>
2293
+ <td class="score-cell ${getScoreClass(uniqueness)}">${uniqueness}%</td>
2294
+ <td class="cluster-table-reason">${reason}</td>
2295
+ <td class="cluster-table-meta">${clusterDisplay}<br>${eventDisplay}</td>
2296
+ </tr>
2297
+ `;
2298
  }
2299
+
2300
+ html += `
2301
+ </tbody>
2302
+ </table>
2303
+ `;
2304
 
2305
  return html;
2306
  }
 
2547
  const uniqueness = photo.uniqueness ? (photo.uniqueness * 100).toFixed(0) : 'N/A';
2548
  const eventId = photo.event_id !== undefined ? photo.event_id : -1;
2549
  const clusterId = photo.cluster_id !== undefined ? photo.cluster_id : -1;
2550
+ const originalClusterId = photo.original_cluster_id !== undefined ? photo.original_cluster_id : clusterId;
2551
+
2552
+ // Show original cluster if different from current (merged) cluster
2553
+ let clusterLabel;
2554
+ if (clusterId >= 0) {
2555
+ if (originalClusterId !== clusterId && originalClusterId >= 0) {
2556
+ clusterLabel = `${clusterId} (orig: ${originalClusterId})`;
2557
+ } else {
2558
+ clusterLabel = `Cluster ${clusterId}`;
2559
+ }
2560
+ } else {
2561
+ clusterLabel = 'None';
2562
+ }
2563
+
2564
  const similarity = photo.max_similarity !== undefined ? (photo.max_similarity * 100).toFixed(1) : '0.0';
2565
  const category = photo.category || 'Unknown';
2566
  const datetime = photo.date || 'N/A';