From 5be57aec7dcaa5a5851f55bb219294c104d7a97b Mon Sep 17 00:00:00 2001 From: Momentry Studio Date: Sat, 1 Aug 2026 01:58:01 +0800 Subject: [PATCH] Revert "perf: lazy load face group thumbnails + increase concurrency to 32" This reverts commit 26b94f72431bdd90eb6a8eb506b1923713bb1ecd. --- src/store.ts | 193 +++++++++++++------- src/views/PeopleView.vue | 372 +++++++++++++-------------------------- 2 files changed, 248 insertions(+), 317 deletions(-) diff --git a/src/store.ts b/src/store.ts index 34b220a..7831565 100644 --- a/src/store.ts +++ b/src/store.ts @@ -21,7 +21,6 @@ const UNASSIGNED_PER_PAGE = 20 export const thumbnailsCache = ref>({}) export const profilesCache = ref>({}) export const faceThumbsCache = ref>({}) -export const frameThumbsCache = ref>({}) export const poseCache = ref>({}) export const appearanceCache = ref>({}) @@ -124,11 +123,20 @@ export interface TkgStats { total_edges: number } +export interface IdentityAgentStats { + clusters: number + identities_created: number + tmdb_matches: number + speaker_bindings: number + confirmations: number +} + export interface FileStats { json: JsonStats postgresql: PostgresqlStats qdrant: QdrantStats tkg: TkgStats + identity_agent: IdentityAgentStats } export const processorCountsCache = ref>>({}) @@ -324,7 +332,7 @@ loadProgressCacheFromStorage() const thumbQueue: (() => Promise)[] = [] let activeThumbLoads = 0 -const MAX_CONCURRENT = 32 +const MAX_CONCURRENT = 16 const profileQueue: (() => Promise)[] = [] let activeProfileLoads = 0 @@ -333,7 +341,6 @@ const MAX_PROFILE_CONCURRENT = 4 const loadingThumbs = new Set() const loadingProfiles = new Set() const loadingFaceThumbs = new Set() -const loadingFrameThumbs = new Set() const loadingProcessorCounts = new Set() const pollingProgress = new Set() export const pollingProgressSet = pollingProgress @@ -369,10 +376,11 @@ export function stopStatsAutoRefresh() { export function startProcessingFilesPolling() { if (processingPollInterval) return processingPollInterval = setInterval(async () => { - const scanningFiles = filesCache.value.filter((f: any) => f.status === 'scanning' && f.file_uuid) - if (scanningFiles.length === 0) return - await Promise.all(scanningFiles.map((f: any) => syncFileStatus(f.file_uuid))) - }, 5000) + const processingFiles = filesCache.value.filter((f: any) => f.status === 'processing' && f.file_uuid) + if (processingFiles.length === 0) return + // Use Promise.all to sync in parallel, not sequentially + await Promise.all(processingFiles.map((f: any) => syncFileStatus(f.file_uuid))) + }, 10000) // Increased from 5s to 10s } export function stopProcessingFilesPolling() { @@ -401,20 +409,16 @@ export function startFilesWatch() { filesCache.value = newFiles } - // Check status changes for existing files (never regress from agent_ready) + // Check status changes for existing files for (const newFile of newFiles) { if (!newFile.file_uuid) continue const oldFile = filesCache.value.find((f: any) => f.file_uuid === newFile.file_uuid) - if (!oldFile || oldFile.status === newFile.status) continue - if (oldFile.status === 'agent_ready') continue - // files/scan often returns stale 'scanning' — resolve via syncFileStatus - if (newFile.status === 'scanning') { - syncFileStatus(newFile.file_uuid) - continue - } - const idx = filesCache.value.findIndex((f: any) => f.file_uuid === newFile.file_uuid) - if (idx >= 0) { - filesCache.value[idx] = newFile + if (oldFile && oldFile.status !== newFile.status) { + // Status changed, update cache + const idx = filesCache.value.findIndex((f: any) => f.file_uuid === newFile.file_uuid) + if (idx >= 0) { + filesCache.value[idx] = newFile + } } } } catch (e) { @@ -475,10 +479,10 @@ let _lastLoadedFileUuid: string | undefined = undefined export async function ensureFiles() { console.error('[ensureFiles] starting, filesLoaded.value:', filesLoaded.value) - if (filesLoaded.value && filesCache.value.length > 0) return + if (filesLoaded.value) return if (_filesLoading) { - await new Promise(r => { const check = setInterval(() => { if (filesLoaded.value && filesCache.value.length > 0 || !_filesLoading) { clearInterval(check); r() } }, 100) }) - if (filesLoaded.value && filesCache.value.length > 0) return + await new Promise(r => { const check = setInterval(() => { if (filesLoaded.value || !_filesLoading) { clearInterval(check); r() } }, 100) }) + return } _filesLoading = true try { @@ -488,7 +492,7 @@ export async function ensureFiles() { console.error('Failed to load files:', e) } finally { _filesLoading = false - filesLoaded.value = filesCache.value.length > 0 + filesLoaded.value = true } } @@ -582,7 +586,7 @@ export async function ensureUnassignedTraces(fileUuid: string, page?: number) { } export function loadTraceThumb(t: any) { - if (!t.file_uuid || t.trace_id == null) return + if (!t.file_uuid || !t.trace_id) return const key = `${t.trace_id}-${t.file_uuid}-${t.best_face_id || 0}` if (faceThumbsCache.value[key] || loadingFaceThumbs.has(key)) return loadingFaceThumbs.add(key) @@ -605,33 +609,6 @@ export function loadTraceThumb(t: any) { }) } -export function frameThumbKey(t: any): string { - return `${t.trace_id}-${t.file_uuid}` -} - -export function loadFrameThumb(t: any) { - if (!t.file_uuid || t.trace_id == null) return - const key = frameThumbKey(t) - if (frameThumbsCache.value[key] || loadingFrameThumbs.has(key)) return - loadingFrameThumbs.add(key) - queueThumb(async () => { - try { - const frame = t.best_face_frame ?? t.start_frame ?? 0 - const url = `/api/v1/media/frame?file_uuid=${t.file_uuid}&frame=${frame}` - const resp = await fetch(url) - if (resp.ok) { - const blob = await resp.blob() - const dataUrl = URL.createObjectURL(blob) - frameThumbsCache.value[key] = dataUrl - } - } catch (e) { - console.error('loadFrameThumb failed:', key, e) - } finally { - loadingFrameThumbs.delete(key) - } - }) -} - export function loadThumbnail(uuid: string, frame = 30) { const key = `${uuid}:${frame}` if (!uuid || thumbnailsCache.value[key] || loadingThumbs.has(key)) return @@ -819,13 +796,17 @@ export async function pollProgress(fileUuid: string) { gpu_percent: data.gpu_percent, memory_percent: data.memory_percent, } + fileStatusCache.value[fileUuid] = 'processing' + const f = filesCache.value.find((x: any) => x.file_uuid === fileUuid) + if (f) f.status = 'processing' saveProgressCacheToStorage() const allComplete = data.processors?.every((p: any) => p.status === 'complete' || p.status === 'failed') if (allComplete || data.overall_progress >= 100) { stopPolling(fileUuid) + fileStatusCache.value[fileUuid] = 'completed' + if (f) f.status = 'completed' saveProgressCacheToStorage() await loadProcessorCounts(fileUuid) - await syncFileStatus(fileUuid) break } await new Promise(r => setTimeout(r, 2000)) @@ -856,7 +837,25 @@ export function clearFileStatusCache(fileUuid: string) { export async function refreshFileStatus(fileUuid: string) { try { - await loadPipelineStats(fileUuid, true) + const pipeline = await loadPipelineStats(fileUuid, true) + if (pipeline) { + const isComplete = pipeline.overall_progress >= 1.0 || pipeline.stages?.every((s: any) => s.status === 'completed' || s.status === 'failed') + const isRunning = pipeline.stages?.some((s: any) => s.status === 'running') + + if (isComplete) { + fileStatusCache.value[fileUuid] = 'completed' + await loadFileStats(fileUuid, true) + } else if (isRunning) { + fileStatusCache.value[fileUuid] = 'processing' + } + + // Also update the file object in filesCache + const f = filesCache.value.find((x: any) => x.file_uuid === fileUuid) + if (f) { + if (isComplete) f.status = 'completed' + else if (isRunning) f.status = 'processing' + } + } } catch (e) { console.error('refreshFileStatus failed:', fileUuid, e) } @@ -875,13 +874,20 @@ export async function syncFileStatus(fileUuid: string): Promise { if (result?.status) { const newStatus = result.status + const oldStatus = fileStatusCache.value[fileUuid] fileStatusCache.value[fileUuid] = newStatus const f = filesCache.value.find((x: any) => x.file_uuid === fileUuid) if (f) { + // If status changed from processing to completed, clear pipeline cache + if (oldStatus === 'processing' && newStatus === 'completed') { + delete pipelineStatsCache.value[fileUuid] + delete fileProgressCache.value[fileUuid] + } f.status = newStatus - if (newStatus === 'scanning') { + + if (newStatus === 'processing') { await loadPipelineStats(fileUuid, true) } } @@ -895,8 +901,8 @@ export async function syncFileStatus(fileUuid: string): Promise { } export async function syncAllProcessingFiles(): Promise { - const scanningFiles = filesCache.value.filter((f: any) => f.status === 'scanning' && f.file_uuid) - for (const f of scanningFiles) { + const processingFiles = filesCache.value.filter((f: any) => f.status === 'processing' && f.file_uuid) + for (const f of processingFiles) { await syncFileStatus(f.file_uuid) } } @@ -915,10 +921,11 @@ export async function loadPipelineStats(fileUuid: string, force = false): Promis const WEIGHT_MAP: Record = { processors: 0.30, rule1_ingestion: 0.05, - face_tracing: 0.08, - tkg_nodes: 0.23, - tkg_edges: 0.18, - rule2_ingestion: 0.16 + face_tracing: 0.05, + identity_agent: 0.10, + tkg_nodes: 0.20, + tkg_edges: 0.15, + rule2_ingestion: 0.15 } const mappedStages = stages.map((s: any) => ({ @@ -929,9 +936,7 @@ export async function loadPipelineStats(fileUuid: string, force = false): Promis })) // 用前端权重重新计算 overall_progress(不信任 API 返回的值) - const calculatedRaw = mappedStages.reduce((sum, s) => sum + s.weight * s.progress, 0) - const allDone = mappedStages.length > 0 && mappedStages.every((s: any) => s.status === 'completed' || s.status === 'failed') - const calculatedProgress = allDone ? 1.0 : Math.min(calculatedRaw, 1.0) + const calculatedProgress = mappedStages.reduce((sum, s) => sum + s.weight * s.progress, 0) const pipelineStats: PipelineStats = { overall_progress: calculatedProgress, @@ -942,18 +947,31 @@ export async function loadPipelineStats(fileUuid: string, force = false): Promis pipelineStatsCache.value = { ...pipelineStatsCache.value, [fileUuid]: pipelineStats } + // If pipeline is complete, update file status + if (calculatedProgress >= 1.0) { + const f = filesCache.value.find((x: any) => x.file_uuid === fileUuid) + if (f && f.status !== 'completed') { + f.status = 'completed' + fileStatusCache.value[fileUuid] = 'completed' + } + } + return pipelineStats } catch (e) { console.warn('get_pipeline_stats failed, falling back to get_progress:', fileUuid, e) try { const oldData: any = await apiCall('get_progress', { fileUuid }) + // 检查所有处理器是否都完成 const allComplete = oldData.processors?.length > 0 && oldData.processors?.every((p: any) => p.status === 'complete' || p.status === 'failed') const hasRunning = oldData.processors?.some((p: any) => p.status === 'running') + // 如果都完成,显示100% const progress = allComplete ? 1.0 : (oldData.overall_progress || 0) / 100 const status = allComplete ? 'completed' : (hasRunning ? 'running' : 'pending') + console.log('[loadPipelineStats fallback]', fileUuid, { allComplete, hasRunning, progress, status, processorCount: oldData.processors?.length }) + const fallbackStats: PipelineStats = { overall_progress: progress, stages: [ @@ -961,7 +979,6 @@ export async function loadPipelineStats(fileUuid: string, force = false): Promis ] } pipelineStatsCache.value = { ...pipelineStatsCache.value, [fileUuid]: fallbackStats } - return fallbackStats } catch (fallbackError) { console.error('Both pipeline APIs failed:', fileUuid, fallbackError) @@ -1037,11 +1054,25 @@ export async function loadFileStats(fileUuid: string, force = false): Promise x.file_uuid === fileUuid) + if (idx === -1) continue + + fileStatusCache.value[fileUuid] = 'processing' + filesCache.value.splice(idx, 1, { ...filesCache.value[idx], status: 'processing' }) + saveProgressCacheToStorage() + + const allComplete = stats.stages.every(s => s.status === 'completed' || s.status === 'failed') + if (allComplete || stats.overall_progress >= 1.0) { + stopPolling(fileUuid) + fileStatusCache.value[fileUuid] = 'completed' + filesCache.value.splice(idx, 1, { ...filesCache.value[idx], status: 'completed' }) + saveProgressCacheToStorage() + await loadFileStats(fileUuid, true) + break + } + } + await new Promise(r => setTimeout(r, 2000)) + } + } catch (e) { + console.error('pollPipelineProgress failed:', fileUuid, e) + } finally { + pollingProgress.delete(fileUuid) + } +} + export function invalidateFiles() { filesLoaded.value = false filesCache.value = [] diff --git a/src/views/PeopleView.vue b/src/views/PeopleView.vue index 636233d..7103461 100644 --- a/src/views/PeopleView.vue +++ b/src/views/PeopleView.vue @@ -1,7 +1,6 @@ -
@@ -122,7 +135,7 @@
- +
{{ Math.round(faceDetailModal.candidate?.confidence * 100 || 0) }}%
@@ -151,8 +164,8 @@ {{ t('people.face_detail.play_segment') }}
-
-
+
+
Frame: {{ faceDetailModal.candidate?.frame_number || '—' }} @@ -270,7 +283,7 @@

Select Target Group

Choose which group to keep. Other selected groups will be merged into this one.

-
+
@@ -300,7 +313,7 @@
-
+
@@ -455,16 +468,13 @@ import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue' import { useRouter } from 'vue-router' import { apiCall } from '@/api' -import { ensureUnassignedTraces, unassignedTracesCache, unassignedTracesLoaded, unassignedTracesTotal, unassignedTracesPage, ensureFiles, filesCache, faceThumbsCache, frameThumbsCache, frameThumbKey, loadFaceThumb, loadTraceThumb, loadFrameThumb, loadThumbnail, thumbnailsCache, clusterResultsCache, clusterRunningState, isClusterRunning, setClusterRunning, getClusterResults, setClusterResults, pollClusterResults, updateTraceProfileGroup, loadClusterNames, getTraceProfile, getPose, getAppearance, syncAllProcessingFiles } from '@/store' +import { ensureUnassignedTraces, unassignedTracesCache, unassignedTracesLoaded, unassignedTracesTotal, unassignedTracesPage, ensureFiles, filesCache, faceThumbsCache, loadFaceThumb, loadTraceThumb, loadThumbnail, thumbnailsCache, clusterResultsCache, clusterRunningState, isClusterRunning, setClusterRunning, getClusterResults, setClusterResults, pollClusterResults, updateTraceProfileGroup, loadClusterNames, getTraceProfile, getPose, getAppearance, syncAllProcessingFiles } from '@/store' import { useI18n } from 'vue-i18n' import { drawPoseSkeleton, drawAppearanceOverlay } from '@/utils/poseRenderer' import type { PoseResponse, AppearanceResponse } from '@/api/types' -import { useAdvancedMode } from '@/stores/advancedMode' import { isDark, toggleDark } from '@/composables/useTheme' import { getNextZIndex } from '@/composables/useZIndex' -const { isAdvanced } = useAdvancedMode() - const { t } = useI18n() const router = useRouter() import VideoPlayer from '../components/VideoPlayer.vue' @@ -486,7 +496,6 @@ const fileInfoCache = ref>({}) const poseCanvas = ref(null) const colorCanvas = ref(null) const poseFrameContainer = ref(null) -const keyFrameContainer = ref(null) const faceDetailPose = ref(null) const faceDetailAppearance = ref(null) const faceDetailFrameUrl = ref('') @@ -513,7 +522,6 @@ const selectedClusterTraces = ref([]) const operationLoading = ref(false) const operationMessage = ref('') -const aiSuggestLoading = ref(false) const showQCModal = ref(false) const qcProgress = ref({ current: 0, total: 0, status: '' }) @@ -625,10 +633,9 @@ const clusterRunning = computed(() => isClusterRunning(selectedFileUuid.value)) const faceGroups = computed(() => { if (!selectedFileUuid.value || !clusterResults.value.length) { - console.log('[faceGroups] empty: file=%s clusters=%d', selectedFileUuid.value, clusterResults.value.length) return [] } - const groups = clusterResults.value.map((c: any, idx: number) => { + return clusterResults.value.map((c: any, idx: number) => { const traceIds = c.trace_ids || [] const repTraceId = c.representative_trace || traceIds[0] const traceMap = clusterTracesMap.value @@ -653,10 +660,6 @@ const faceGroups = computed(() => { rep_trace: repTrace, } }) - // Sort by group name - groups.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })) - console.log('[faceGroups] returning %d groups:', groups.length, groups.map(g => ({ name: g.name, tc: g.trace_count, id: g.group_id }))) - return groups }) const otherClusters = computed(() => { @@ -712,8 +715,7 @@ async function saveClusterEdit() { clusterEditMode.value = false // Update cluster results cache const clusters = getClusterResults(selectedFileUuid.value) - const targetId = c.cluster_id ?? c.group_id - const idx = clusters.findIndex((x: any) => (x.cluster_id ?? x.group_id) === targetId) + const idx = clusters.findIndex((x: any) => x.cluster_id === c.cluster_id) if (idx >= 0) { clusters[idx].name = name setClusterResults(selectedFileUuid.value, [...clusters]) @@ -752,7 +754,6 @@ async function refresh() { if (selectedFileUuid.value) { unassignedTracesPage.value = 1 - unassignedTracesLoaded.value = false delete clusterResultsCache.value[selectedFileUuid.value] clusterTracesMap.value = {} clusterVideoTraces.value = [] @@ -795,17 +796,14 @@ async function onFileFilterChange() { for (const t of unassignedTraces.value.slice(0, 20)) { if (t.file_uuid) loadTraceThumb(t) } + refreshing.value = false loadingStep.value = '' // Resume polling if cluster was running for this file if (isClusterRunning(selectedFileUuid.value)) { - pollClusterResults(selectedFileUuid.value, () => { - loadClusterResults() - refreshing.value = false - }) + pollClusterResults(selectedFileUuid.value, () => loadClusterResults()) } else { - await loadClusterResults() - refreshing.value = false + loadClusterResults() } } else { unassignedTracesLoaded.value = false @@ -823,7 +821,6 @@ async function onFileFilterChange() { async function runClusterAgent() { if (!selectedFileUuid.value || isClusterRunning(selectedFileUuid.value)) return - if (!confirm('Run Face Deduplication?\n\nThis will re-analyze all face groups and may take several minutes.')) return setClusterRunning(selectedFileUuid.value, true) try { const result: any = await apiCall('run_cluster_agent', { fileUuid: selectedFileUuid.value }) @@ -917,23 +914,6 @@ async function loadClusterResults() { clusterTracesMap.value[String(t.trace_id)] = t } - // Also add traces from cluster results to the map - for (const c of groups) { - const traceIds = c.trace_ids || [] - for (const tid of traceIds) { - if (!clusterTracesMap.value[String(tid)]) { - // Create trace entry from cluster data - const repTrace = clusterTracesMap.value[String(c.representative_trace || traceIds[0])] - if (repTrace) { - clusterTracesMap.value[String(tid)] = { - ...repTrace, - trace_id: tid, - } - } - } - } - } - // Load thumbnails for cluster representatives for (const c of groups) { const traceIds = c.trace_ids || [] @@ -997,9 +977,7 @@ onUnmounted(() => { }) async function loadCandidateThumb(c: any) { - if (!c?.file_uuid || c.id == null) return - const key = `${c.id}-${c.file_uuid}-${c.best_face_id || 0}` - if (candidateThumbs.value[key]) return + if (!c?.file_uuid || candidateThumbs.value[c.id]) return loadFaceThumb(String(c.id), c.file_uuid, c.frame_number || 0, c.bbox) } @@ -1233,32 +1211,22 @@ function redrawOverlay() { const bbox = faceDetailModal.value.candidate?.bbox const pose = faceDetailPose.value + // Debug: log coordinates + console.log('[redrawOverlay] canvas:', canvas.width, 'x', canvas.height) + console.log('[redrawOverlay] bbox:', bbox) + console.log('[redrawOverlay] pose keypoints:', pose?.keypoints?.slice(0, 3)) + if (showBboxOverlay.value && bbox) { - console.log('[bbox] raw:', bbox) - // Canvas internal size is naturalWidth x naturalHeight - // Browser scales canvas via CSS, so draw bbox in natural coordinates - const canvasWidth = canvas.width - const canvasHeight = canvas.height - // Check if bbox is normalized (0-1) or absolute const isNormalized = bbox.x <= 1.0 && bbox.y <= 1.0 && bbox.width <= 1.0 && bbox.height <= 1.0 - console.log('[bbox] canvas:', canvasWidth, 'x', canvasHeight, '| isNormalized:', isNormalized) + console.log('[redrawOverlay] bbox isNormalized:', isNormalized) - let x, y, w, h - if (isNormalized) { - x = bbox.x * canvasWidth - y = bbox.y * canvasHeight - w = bbox.width * canvasWidth - h = bbox.height * canvasHeight - } else { - // Absolute bbox: already in natural coordinates - x = bbox.x - y = bbox.y - w = bbox.width - h = bbox.height - } + const x = isNormalized ? bbox.x * canvas.width : bbox.x + const y = isNormalized ? bbox.y * canvas.height : bbox.y + const w = isNormalized ? bbox.width * canvas.width : bbox.width + const h = isNormalized ? bbox.height * canvas.height : bbox.height - console.log('[bbox] drawing at:', { x, y, w, h }) + console.log('[redrawOverlay] drawing bbox at:', x, y, w, h) ctx.strokeStyle = '#00ffff' ctx.lineWidth = 3 @@ -1268,19 +1236,22 @@ function redrawOverlay() { } if (showPoseOverlay.value && pose?.keypoints && bbox) { - // Check if bbox is normalized (0-1) or absolute - const isNormalized = bbox.x <= 1.0 && bbox.y <= 1.0 && bbox.width <= 1.0 && bbox.height <= 1.0 - const bx = isNormalized ? bbox.x * canvas.width : bbox.x - const by = isNormalized ? bbox.y * canvas.height : bbox.y - const bw = isNormalized ? bbox.width * canvas.width : bbox.width - const bh = isNormalized ? bbox.height * canvas.height : bbox.height - - // Filter out keypoints that fall inside the face bbox - const validKeypoints = pose.keypoints.filter(kp => { - if (kp.confidence <= 0 || kp.x <= 0 || kp.y <= 0) return false - return !(kp.x >= bx && kp.x <= bx + bw && kp.y >= by && kp.y <= by + bh) - }) - + const faceKps = ['nose', 'left_eye', 'right_eye'] + const faceKeypoints = pose.keypoints.filter(kp => + faceKps.includes(kp.name) && kp.confidence > 0 && kp.x > 0 && kp.y > 0 + ) + + if (faceKeypoints.length < 2) return + + const centerX = faceKeypoints.reduce((sum, kp) => sum + kp.x, 0) / faceKeypoints.length + const centerY = faceKeypoints.reduce((sum, kp) => sum + kp.y, 0) / faceKeypoints.length + + const inBbox = centerX >= bbox.x && centerX <= bbox.x + bbox.width && + centerY >= bbox.y && centerY <= bbox.y + bbox.height + + if (!inBbox) return + + const validKeypoints = pose.keypoints.filter(kp => kp.confidence > 0 && kp.x > 0 && kp.y > 0) ctx.fillStyle = '#00ff00' for (const kp of validKeypoints) { ctx.beginPath() @@ -1432,7 +1403,7 @@ async function confirmMoveFace() { target.trace_count = (target.trace_count || 0) + 1 const finalClusters = updatedClusters.filter((cr: any) => cr.trace_ids.length > 0 || cr.cluster_id === Number(targetId)) - clusterResultsCache.value = { ...clusterResultsCache.value, [selectedFileUuid.value]: finalClusters } + clusterResultsCache.value[selectedFileUuid.value] = finalClusters try { await updateTraceProfileGroup(c.file_uuid, [tid], { name: target.name || `Group ${targetId}` }) @@ -1476,7 +1447,7 @@ async function confirmMoveFaceToNewGroup() { } } } - clusterResultsCache.value = { ...clusterResultsCache.value, [selectedFileUuid.value]: updatedClusters } + clusterResultsCache.value[selectedFileUuid.value] = updatedClusters try { await updateTraceProfileGroup(c.file_uuid, [tid], { name }) } catch (e) { console.error('create group and move face failed:', e) } @@ -1498,12 +1469,12 @@ async function createNewIdentityFromFace() { fileUuid: c.file_uuid, traceIds: c.trace_id ? [c.trace_id] : undefined }) - if (result?.identity_uuid) { + if (result?.group_uuid) { faceDetailModal.value.show = false if (c.trace_id) { unassignedTracesCache.value = unassignedTracesCache.value.filter((t: any) => t.trace_id !== c.trace_id) } - const cleanUuid = result.identity_uuid.replace(/-/g, '') + const cleanUuid = result.group_uuid.replace(/-/g, '') router.push(`/people/${cleanUuid}`) } else { alert(t('people.detail.create_identity_failed')) @@ -1561,38 +1532,10 @@ function toggleClusterTraceSelection(t: any) { function clearSelection() { selectedFaces.value = [] selectedGroups.value = [] + batchMode.value = false batchAction.value = '' } -function toggleBatchMode() { -} - -async function aiSuggest() { - if (!selectedGroups.value.length || !selectedFileUuid.value) return - aiSuggestLoading.value = true - try { - const group = faceGroups.value.find((g: any) => selectedGroups.value.includes(g.group_uuid)) - if (!group || !group.trace_ids?.length) return - const result = await apiCall('get_similar_face_groups', { - fileUuid: selectedFileUuid.value, - traceIds: group.trace_ids.join(','), - topK: 1, - minScore: 0.6 - }) - if (result?.suggestions?.length) { - const sug = result.suggestions[0] - const target = faceGroups.value.find((g: any) => g.name === sug.group_name) - if (target && !selectedGroups.value.includes(target.group_uuid)) { - selectedGroups.value.push(target.group_uuid) - } - } - } catch (e) { - console.error('AI Suggest failed:', e) - } finally { - aiSuggestLoading.value = false - } -} - function showUndoBannerWithTimer() { showUndoBanner.value = true if (undoBannerTimer) clearTimeout(undoBannerTimer) @@ -1647,7 +1590,7 @@ function removeTracesFromClusterCache(traceIds: number[]) { } const validClusters = clusters.filter((c: any) => c.trace_count > 0) - clusterResultsCache.value = { ...clusterResultsCache.value, [selectedFileUuid.value]: validClusters } + clusterResultsCache.value[selectedFileUuid.value] = validClusters } async function batchDeleteFaces() { @@ -1825,10 +1768,11 @@ async function moveToGroup(targetGroup: any) { alert('Target group has no traces') return } - - const targetFileUuid = selectedFileUuid.value + + const targetTrace = clusterTracesMap.value[String(targetTraceId)] + const targetFileUuid = targetTrace?.file_uuid if (!targetFileUuid) { - alert('No file selected') + alert('Target trace has no file_uuid') return } @@ -1855,39 +1799,12 @@ async function moveToGroup(targetGroup: any) { } } - // Update cluster results cache locally - create new array for reactivity - const clusters = [...(clusterResultsCache.value[selectedFileUuid.value] || [])] - const targetCluster = clusters.find((c: any) => c.cluster_id === targetGroup.cluster_id || c.group_uuid === targetGroup.group_uuid) - if (targetCluster) { - // Add all source trace_ids to target - targetCluster.trace_ids = [...new Set([...(targetCluster.trace_ids || []), ...traceList.map(t => t.trace_id)])] - targetCluster.trace_count = targetCluster.trace_ids.length - // Update representative if needed - if (!targetCluster.trace_ids.includes(targetCluster.representative_trace)) { - targetCluster.representative_trace = targetCluster.trace_ids[0] - } - } - - // Remove traces from source groups - for (const t of traceList) { - for (const cluster of clusters) { - if (cluster === targetCluster) continue - if (cluster.trace_ids?.includes(t.trace_id)) { - cluster.trace_ids = cluster.trace_ids.filter((id: number) => id !== t.trace_id) - cluster.trace_count = cluster.trace_ids.length - } - } - } - clusterResultsCache.value = { ...clusterResultsCache.value, [selectedFileUuid.value]: clusters } + removeTracesFromCache(selectedFaces.value) operationLoading.value = false operationMessage.value = '' clearSelection() - - // Wait for Core API to process merge, then refresh - await new Promise(r => setTimeout(r, 2000)) - await loadClusterResults() } async function createNewGroupAndMove() { @@ -1905,27 +1822,17 @@ async function createNewGroupAndMove() { const firstTrace = traceList[0] if (!firstTrace) return - // Update trace profiles to assign them to the new group try { - for (const t of traceList) { - await updateTraceProfileGroup(t.file_uuid, [t.trace_id], { name }) - } - - clearSelection() - - // Add new group to cluster results cache - const clusters = [...(clusterResultsCache.value[selectedFileUuid.value] || [])] - const repTraceId = traceList[0]?.trace_id - const newId = Date.now() - clusters.push({ - cluster_id: newId, - name: name, - trace_ids: traceList.map(t => t.trace_id), - trace_count: traceList.length, - representative_trace: repTraceId, - group_uuid: `group_${newId}`, + const result: any = await apiCall('create_identity_from_face', { + fileUuid: firstTrace.file_uuid, + name, + traceIds: traceList.map(t => t.trace_id) }) - clusterResultsCache.value = { ...clusterResultsCache.value, [selectedFileUuid.value]: clusters } + + if (result?.group_uuid) { + clearSelection() + await refresh() + } } catch (e) { console.error('Create new group failed:', e) alert('Failed to create new group') @@ -1979,7 +1886,7 @@ async function runDataQC() { // File APIs const fileApis = [ { name: 'get_file_profile', call: () => apiCall('get_file_profile', { fileUuid }) }, - { name: 'update_file_profile', call: () => apiCall('update_file_profile', { fileUuid, fileName: 'QC-Test' }) }, + { name: 'update_file_profile', call: () => apiCall('update_file_profile', { fileUuid, filePath: '', fileName: '' }) }, { name: 'get_file_keyframe', call: () => apiCall('get_thumbnail', { uuid: fileUuid, frame: 0 }) }, ] @@ -2070,7 +1977,7 @@ async function runDataQC() { // ===== 1. FILE PROFILE QC ===== qcProgress.value.status = 'Checking file profile...' - const fileProfileResult = { canRead: false, canWrite: false, keyFrame: null as number | null, writeVerified: false } + const fileProfileResult = { canRead: false, canWrite: false, keyFrame: null as number | null } try { const fileProfile: any = await apiCall('get_file_profile', { fileUuid }) fileProfileResult.canRead = !!fileProfile @@ -2087,15 +1994,12 @@ async function runDataQC() { if (fileProfileResult.canRead) { try { - // Write test with actual data - const testFileName = `QC-Test-${Date.now()}` - await apiCall('update_file_profile', { fileUuid, fileName: testFileName }) - // Verify write - const verify: any = await apiCall('get_file_profile', { fileUuid }) - if (verify?.file_name === testFileName) { - fileProfileResult.canWrite = true - fileProfileResult.writeVerified = true - } + await apiCall('update_file_profile', { + fileUuid, + filePath: '', + fileName: '' + }) + fileProfileResult.canWrite = true } catch (e: any) { console.warn('[QC] File profile write failed:', e) } @@ -2105,7 +2009,7 @@ async function runDataQC() { check: 'File Profile', status: fileProfileResult.canRead && fileProfileResult.canWrite ? 'PASS' : 'FAIL', details: fileProfileResult.canRead && fileProfileResult.canWrite - ? `Read/Write OK (verified), Key Frame: ${fileProfileResult.keyFrame ?? 'N/A'}` + ? `Read/Write OK, Key Frame: ${fileProfileResult.keyFrame ?? 'N/A'}` : `Read: ${fileProfileResult.canRead ? '✓' : '✗'}, Write: ${fileProfileResult.canWrite ? '✓' : '✗'}` }) @@ -2161,38 +2065,24 @@ async function runDataQC() { traceId }) result.canRead = !!profile - // Check key_frame/key_face from HTTP endpoint (actual file existence) - try { - const kfResp = await fetch(`/api/v1/file/${trace.file_uuid}/trace/${traceId}/thumbnail`) - result.hasKeyFrame = kfResp.ok && (kfResp.headers.get('content-type') || '').includes('image') - } catch {} - try { - const kfaResp = await fetch(`/api/v1/face-thumbnail?uuid=${trace.file_uuid}&frame=${traceId}`) - result.hasKeyFace = kfaResp.ok && (kfaResp.headers.get('content-type') || '').includes('image') - } catch {} - if (result.hasKeyFrame) withKeyFrame++ - if (result.hasKeyFace) withKeyFace++ + if (profile) { + result.hasKeyFrame = profile.key_frame != null && profile.key_frame >= 0 + result.hasKeyFace = profile.key_face != null && profile.key_face >= 0 + if (result.hasKeyFrame) withKeyFrame++ + if (result.hasKeyFace) withKeyFace++ + } } catch (e: any) { result.error = e?.message?.slice(0, 30) || 'read failed' } if (result.canRead) { try { - // Write test with actual data - const testLabel = `QC-${traceId}-${Date.now()}` await apiCall('update_trace_profile', { fileUuid: trace.file_uuid, traceId, - name: testLabel + label: '' }) - // Verify write - const verify: any = await apiCall('get_trace_profile', { - fileUuid: trace.file_uuid, - traceId - }) - if (verify?.name === testLabel) { - result.canWrite = true - } + result.canWrite = true } catch (e: any) { result.error = e?.message?.slice(0, 30) || 'write failed' } @@ -2310,9 +2200,7 @@ function copyQCReport() {