feat: auto-chain vision backfill + face recluster after scan, show progress in UI
Scan now automatically queues backfill_vision (+90s) and recluster_faces (+300s) after dispatching folder scans. Face extraction also schedules a debounced recluster via Redis so incremental file-watcher imports get clustered without manual intervention. The ScanProgress widget now tracks worker queue activity beyond the scan phase, showing a "Processing Photos" indicator with vision queue counts while background tasks (embeddings, faces, tags, OCR) are running. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -409,6 +409,7 @@ async def _scan_all_source_roots_async():
|
|||||||
still hit Settings → Re-detect duplicates to force a fresh pass.
|
still hit Settings → Re-detect duplicates to force a fresh pass.
|
||||||
"""
|
"""
|
||||||
from app.tasks.thumbs import regroup_duplicates_task
|
from app.tasks.thumbs import regroup_duplicates_task
|
||||||
|
from app.tasks.vision import backfill_vision, recluster_faces
|
||||||
|
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
@@ -433,6 +434,21 @@ async def _scan_all_source_roots_async():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not queue post-scan regroup: {e}")
|
logger.warning(f"Could not queue post-scan regroup: {e}")
|
||||||
|
|
||||||
|
# 90s lets thumbnails finish so photos reach processing_status
|
||||||
|
# 'completed', which backfill_vision uses as its filter.
|
||||||
|
try:
|
||||||
|
backfill_vision.apply_async(countdown=90)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not queue post-scan vision backfill: {e}")
|
||||||
|
|
||||||
|
# 300s gives face extraction time to run before reclustering.
|
||||||
|
# Fires even if some faces are still in-flight — the task is
|
||||||
|
# idempotent and the user can re-trigger from Settings.
|
||||||
|
try:
|
||||||
|
recluster_faces.apply_async(countdown=300)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not queue post-scan face recluster: {e}")
|
||||||
|
|
||||||
|
|
||||||
@shared_task(name='watch_folders')
|
@shared_task(name='watch_folders')
|
||||||
def watch_folders():
|
def watch_folders():
|
||||||
|
|||||||
@@ -348,13 +348,40 @@ def _save_faces(photo_id: str, faces) -> dict:
|
|||||||
|
|
||||||
if faces:
|
if faces:
|
||||||
logger.info("Extracted %d verified face(s) from photo %s", len(faces), photo_id)
|
logger.info("Extracted %d verified face(s) from photo %s", len(faces), photo_id)
|
||||||
|
_schedule_recluster_debounced()
|
||||||
return {'status': 'success', 'photo_id': photo_id, 'faces': len(faces)}
|
return {'status': 'success', 'photo_id': photo_id, 'faces': len(faces)}
|
||||||
|
|
||||||
|
|
||||||
|
RECLUSTER_DEBOUNCE_KEY = "mule:recluster_faces:pending"
|
||||||
|
RECLUSTER_DELAY = 120 # seconds after last face extraction
|
||||||
|
|
||||||
|
|
||||||
|
def _schedule_recluster_debounced():
|
||||||
|
"""Schedule a recluster_faces run, debounced so rapid-fire face
|
||||||
|
extractions don't spawn hundreds of redundant cluster jobs."""
|
||||||
|
try:
|
||||||
|
import redis as _redis
|
||||||
|
r = _redis.from_url(settings.redis_url)
|
||||||
|
already_pending = r.set(RECLUSTER_DEBOUNCE_KEY, "1",
|
||||||
|
ex=RECLUSTER_DELAY, nx=True)
|
||||||
|
if already_pending:
|
||||||
|
recluster_faces.apply_async(countdown=RECLUSTER_DELAY)
|
||||||
|
logger.info("Scheduled debounced recluster_faces in %ds", RECLUSTER_DELAY)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("recluster debounce check failed: %s", e)
|
||||||
|
|
||||||
|
|
||||||
@shared_task(name='recluster_faces', queue='vision')
|
@shared_task(name='recluster_faces', queue='vision')
|
||||||
def recluster_faces():
|
def recluster_faces():
|
||||||
"""Run DBSCAN clustering over all face embeddings and assign/create
|
"""Run DBSCAN clustering over all face embeddings and assign/create
|
||||||
Tag(kind=face_cluster) entries."""
|
Tag(kind=face_cluster) entries."""
|
||||||
|
# Clear debounce key so new face extractions can schedule another round.
|
||||||
|
try:
|
||||||
|
import redis as _redis
|
||||||
|
_redis.from_url(settings.redis_url).delete(RECLUSTER_DEBOUNCE_KEY)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
if not settings.vision.enabled or not settings.vision.faces.enabled:
|
if not settings.vision.enabled or not settings.vision.faces.enabled:
|
||||||
return {'status': 'skipped', 'reason': 'faces disabled'}
|
return {'status': 'skipped', 'reason': 'faces disabled'}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { FolderOpen, Loader2, Check, AlertCircle, X } from 'lucide-react'
|
import { FolderOpen, Loader2, Check, AlertCircle, X, Brain, Sparkles } from 'lucide-react'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { library } from '../services/api'
|
import { library, WorkerStatus } from '../services/api'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
interface ScanStatus {
|
interface ScanStatus {
|
||||||
@@ -12,65 +12,101 @@ interface ScanStatus {
|
|||||||
errors: string[]
|
errors: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Phase = 'idle' | 'scanning' | 'processing' | 'done'
|
||||||
|
|
||||||
export function ScanProgress() {
|
export function ScanProgress() {
|
||||||
const [isVisible, setIsVisible] = useState(false)
|
const [isVisible, setIsVisible] = useState(false)
|
||||||
const [isMinimized, setIsMinimized] = useState(false)
|
const [isMinimized, setIsMinimized] = useState(false)
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const wasScanningRef = useRef(false)
|
const wasScanningRef = useRef(false)
|
||||||
|
const wasProcessingRef = useRef(false)
|
||||||
|
|
||||||
// Poll scan status every 2 seconds when scanning
|
|
||||||
const { data: scanStatus } = useQuery<ScanStatus>({
|
const { data: scanStatus } = useQuery<ScanStatus>({
|
||||||
queryKey: ['scan-status'],
|
queryKey: ['scan-status'],
|
||||||
queryFn: async () => {
|
queryFn: () => library.scanStatus(),
|
||||||
const response = await library.scanStatus()
|
refetchInterval: (query) =>
|
||||||
return response
|
query.state.data?.is_scanning ? 2000 : 10000,
|
||||||
},
|
enabled: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const isScanning = scanStatus?.is_scanning ?? false
|
||||||
|
|
||||||
|
// Poll worker status to track vision queue activity.
|
||||||
|
// Fast polling (3s) while processing, slow (15s) otherwise.
|
||||||
|
const { data: workerStatus } = useQuery<WorkerStatus>({
|
||||||
|
queryKey: ['worker-status-progress'],
|
||||||
|
queryFn: () => library.maintenance.workerStatus(),
|
||||||
refetchInterval: (query) => {
|
refetchInterval: (query) => {
|
||||||
// Poll every 2 seconds if scanning, otherwise every 10 seconds
|
const q = totalQueued(query.state.data)
|
||||||
return query.state.data?.is_scanning ? 2000 : 10000
|
return q > 0 ? 3000 : 15000
|
||||||
},
|
},
|
||||||
enabled: true,
|
enabled: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
const visionActive = visionQueued(workerStatus)
|
||||||
const isScanning = scanStatus?.is_scanning ?? false
|
const totalActive = totalQueued(workerStatus)
|
||||||
|
|
||||||
if (isScanning) {
|
const phase: Phase = isScanning
|
||||||
|
? 'scanning'
|
||||||
|
: totalActive > 0
|
||||||
|
? 'processing'
|
||||||
|
: 'idle'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase === 'scanning') {
|
||||||
setIsVisible(true)
|
setIsVisible(true)
|
||||||
setIsMinimized(false)
|
setIsMinimized(false)
|
||||||
wasScanningRef.current = true
|
wasScanningRef.current = true
|
||||||
} else if (wasScanningRef.current) {
|
wasProcessingRef.current = false
|
||||||
// Just transitioned from scanning → done. THIS is the right moment
|
} else if (phase === 'processing') {
|
||||||
// to invalidate caches that might have new data: the photos query
|
// Show widget when processing starts (even without a prior scan,
|
||||||
// (new files indexed), the folder tree (new folders walked), the
|
// e.g. backfill triggered from Settings).
|
||||||
// heap counts (in case a heap photo got reattached).
|
if (!isVisible) setIsVisible(true)
|
||||||
wasScanningRef.current = false
|
wasProcessingRef.current = true
|
||||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['folders'] })
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] })
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['heaps'] })
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['tags'] })
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['library', 'stats'] })
|
|
||||||
|
|
||||||
if (isVisible && (scanStatus?.processed_files ?? 0) > 0) {
|
if (wasScanningRef.current) {
|
||||||
// Keep showing for 3 seconds after scan completes
|
// Scan just finished — invalidate data caches.
|
||||||
setTimeout(() => {
|
wasScanningRef.current = false
|
||||||
if (!scanStatus?.is_scanning) {
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||||
setIsVisible(false)
|
queryClient.invalidateQueries({ queryKey: ['folders'] })
|
||||||
}
|
queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] })
|
||||||
}, 3000)
|
queryClient.invalidateQueries({ queryKey: ['heaps'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tags'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['library', 'stats'] })
|
||||||
|
}
|
||||||
|
} else if (phase === 'idle') {
|
||||||
|
if (wasScanningRef.current) {
|
||||||
|
// Scan finished with no queued processing (small import).
|
||||||
|
wasScanningRef.current = false
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['folders'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['heaps'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tags'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['library', 'stats'] })
|
||||||
|
}
|
||||||
|
if (wasProcessingRef.current) {
|
||||||
|
// Processing just drained — refresh tags (new clusters/objects).
|
||||||
|
wasProcessingRef.current = false
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tags'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['library', 'stats'] })
|
||||||
|
}
|
||||||
|
if (isVisible) {
|
||||||
|
setTimeout(() => setIsVisible(false), 3000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [scanStatus?.is_scanning, scanStatus?.processed_files, isVisible, queryClient])
|
}, [phase, isVisible, queryClient])
|
||||||
|
|
||||||
if (!isVisible || !scanStatus) return null
|
if (!isVisible) return null
|
||||||
|
|
||||||
const progress = scanStatus.total_files > 0
|
// Scan progress percentage
|
||||||
|
const scanProgress = scanStatus && scanStatus.total_files > 0
|
||||||
? (scanStatus.processed_files / scanStatus.total_files) * 100
|
? (scanStatus.processed_files / scanStatus.total_files) * 100
|
||||||
: 0
|
: 0
|
||||||
|
|
||||||
const isComplete = !scanStatus.is_scanning && scanStatus.processed_files > 0
|
const isComplete = phase === 'idle' && (scanStatus?.processed_files ?? 0) > 0
|
||||||
const hasErrors = scanStatus.errors && scanStatus.errors.length > 0
|
const hasErrors = scanStatus?.errors && scanStatus.errors.length > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -85,8 +121,10 @@ export function ScanProgress() {
|
|||||||
onClick={() => setIsMinimized(!isMinimized)}
|
onClick={() => setIsMinimized(!isMinimized)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{scanStatus.is_scanning ? (
|
{phase === 'scanning' ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||||
|
) : phase === 'processing' ? (
|
||||||
|
<Sparkles className="h-4 w-4 animate-pulse text-amber-400" />
|
||||||
) : isComplete && !hasErrors ? (
|
) : isComplete && !hasErrors ? (
|
||||||
<Check className="h-4 w-4 text-pick" />
|
<Check className="h-4 w-4 text-pick" />
|
||||||
) : hasErrors ? (
|
) : hasErrors ? (
|
||||||
@@ -96,11 +134,13 @@ export function ScanProgress() {
|
|||||||
)}
|
)}
|
||||||
{!isMinimized && (
|
{!isMinimized && (
|
||||||
<span className="text-sm font-medium text-text">
|
<span className="text-sm font-medium text-text">
|
||||||
{scanStatus.is_scanning
|
{phase === 'scanning'
|
||||||
? 'Scanning Folders'
|
? 'Scanning Folders'
|
||||||
|
: phase === 'processing'
|
||||||
|
? 'Processing Photos'
|
||||||
: isComplete
|
: isComplete
|
||||||
? 'Scan Complete'
|
? 'Complete'
|
||||||
: 'Scan Status'}
|
: 'Status'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -120,59 +160,80 @@ export function ScanProgress() {
|
|||||||
{/* Content */}
|
{/* Content */}
|
||||||
{!isMinimized && (
|
{!isMinimized && (
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
{/* Current folder */}
|
{/* Scan phase */}
|
||||||
{scanStatus.current_folder && (
|
{phase === 'scanning' && scanStatus && (
|
||||||
<div className="mb-2 text-xs text-text-muted">
|
<>
|
||||||
<span className="font-mono">{scanStatus.current_folder}</span>
|
{scanStatus.current_folder && (
|
||||||
</div>
|
<div className="mb-2 text-xs text-text-muted">
|
||||||
|
<span className="font-mono">{scanStatus.current_folder}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mb-2">
|
||||||
|
<div className="h-1.5 overflow-hidden rounded-full bg-surface-offset">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary transition-all duration-300"
|
||||||
|
style={{ width: `${scanProgress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-text-muted">
|
||||||
|
{scanStatus.processed_files} / {scanStatus.total_files || '?'} files
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-primary">
|
||||||
|
{Math.round(scanProgress)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Progress bar */}
|
{/* Processing phase */}
|
||||||
<div className="mb-2">
|
{phase === 'processing' && workerStatus && (
|
||||||
<div className="h-1.5 overflow-hidden rounded-full bg-surface-offset">
|
<>
|
||||||
<div
|
<div className="mb-2 flex items-center gap-2 text-xs text-text-muted">
|
||||||
className={clsx(
|
<Brain className="h-3.5 w-3.5 text-amber-400" />
|
||||||
'h-full transition-all duration-300',
|
<span>Analyzing photos…</span>
|
||||||
scanStatus.is_scanning
|
</div>
|
||||||
? 'bg-primary'
|
<div className="space-y-1 text-xs">
|
||||||
: hasErrors
|
{visionActive > 0 && (
|
||||||
? 'bg-reject'
|
<div className="flex items-center justify-between">
|
||||||
: 'bg-pick'
|
<span className="text-text-muted">Vision pipeline</span>
|
||||||
|
<span className="font-mono text-amber-400">{visionActive} queued</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
style={{ width: `${progress}%` }}
|
{(totalActive - visionActive) > 0 && (
|
||||||
/>
|
<div className="flex items-center justify-between">
|
||||||
</div>
|
<span className="text-text-muted">Other tasks</span>
|
||||||
</div>
|
<span className="font-mono text-text-muted">{totalActive - visionActive} queued</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-[11px] text-text-muted/60">
|
||||||
|
Thumbnails, embeddings, faces, tags — runs in background
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Done phase */}
|
||||||
<div className="flex items-center justify-between text-xs">
|
{phase === 'idle' && isComplete && (
|
||||||
<span className="text-text-muted">
|
<div className="flex items-center gap-2 text-xs text-pick">
|
||||||
{scanStatus.processed_files} / {scanStatus.total_files || '?'} files
|
<Check className="h-3.5 w-3.5" />
|
||||||
</span>
|
<span>All processing complete</span>
|
||||||
<span className={clsx(
|
</div>
|
||||||
'font-medium',
|
)}
|
||||||
scanStatus.is_scanning ? 'text-primary' : hasErrors ? 'text-reject' : 'text-pick'
|
|
||||||
)}>
|
|
||||||
{scanStatus.is_scanning
|
|
||||||
? `${Math.round(progress)}%`
|
|
||||||
: isComplete
|
|
||||||
? 'Done'
|
|
||||||
: 'Idle'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Errors */}
|
{/* Errors */}
|
||||||
{hasErrors && (
|
{hasErrors && (
|
||||||
<div className="mt-2 max-h-20 overflow-y-auto rounded bg-reject/10 p-2">
|
<div className="mt-2 max-h-20 overflow-y-auto rounded bg-reject/10 p-2">
|
||||||
<div className="text-xs text-reject">
|
<div className="text-xs text-reject">
|
||||||
{scanStatus.errors.slice(0, 3).map((error, i) => (
|
{scanStatus!.errors.slice(0, 3).map((error, i) => (
|
||||||
<div key={i} className="truncate">
|
<div key={i} className="truncate">
|
||||||
• {error}
|
• {error}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{scanStatus.errors.length > 3 && (
|
{scanStatus!.errors.length > 3 && (
|
||||||
<div className="mt-1 text-text-muted">
|
<div className="mt-1 text-text-muted">
|
||||||
+{scanStatus.errors.length - 3} more errors
|
+{scanStatus!.errors.length - 3} more errors
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -182,4 +243,22 @@ export function ScanProgress() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function visionQueued(ws: WorkerStatus | undefined): number {
|
||||||
|
if (!ws) return 0
|
||||||
|
const queued = ws.queues?.['vision'] ?? 0
|
||||||
|
const active = ws.workers
|
||||||
|
?.filter(w => w.queues?.includes('vision'))
|
||||||
|
.reduce((sum, w) => sum + (w.active ?? 0) + (w.reserved ?? 0), 0) ?? 0
|
||||||
|
return queued + active
|
||||||
|
}
|
||||||
|
|
||||||
|
function totalQueued(ws: WorkerStatus | undefined): number {
|
||||||
|
if (!ws) return 0
|
||||||
|
const queued = Object.values(ws.queues ?? {}).reduce((a, b) => a + b, 0)
|
||||||
|
const active = ws.workers?.reduce(
|
||||||
|
(sum, w) => sum + (w.active ?? 0) + (w.reserved ?? 0), 0
|
||||||
|
) ?? 0
|
||||||
|
return queued + active
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user