diff --git a/src/store.ts b/src/store.ts index 7831565..34b220a 100644 --- a/src/store.ts +++ b/src/store.ts @@ -21,6 +21,7 @@ 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>({}) @@ -123,20 +124,11 @@ 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>>({}) @@ -332,7 +324,7 @@ loadProgressCacheFromStorage() const thumbQueue: (() => Promise)[] = [] let activeThumbLoads = 0 -const MAX_CONCURRENT = 16 +const MAX_CONCURRENT = 32 const profileQueue: (() => Promise)[] = [] let activeProfileLoads = 0 @@ -341,6 +333,7 @@ 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 @@ -376,11 +369,10 @@ export function stopStatsAutoRefresh() { export function startProcessingFilesPolling() { if (processingPollInterval) return processingPollInterval = setInterval(async () => { - 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 + 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) } export function stopProcessingFilesPolling() { @@ -409,16 +401,20 @@ export function startFilesWatch() { filesCache.value = newFiles } - // Check status changes for existing files + // Check status changes for existing files (never regress from agent_ready) 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) { - // Status changed, update cache - 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) 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 } } } catch (e) { @@ -479,10 +475,10 @@ let _lastLoadedFileUuid: string | undefined = undefined export async function ensureFiles() { console.error('[ensureFiles] starting, filesLoaded.value:', filesLoaded.value) - if (filesLoaded.value) return + if (filesLoaded.value && filesCache.value.length > 0) return if (_filesLoading) { - await new Promise(r => { const check = setInterval(() => { if (filesLoaded.value || !_filesLoading) { clearInterval(check); r() } }, 100) }) - return + 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 } _filesLoading = true try { @@ -492,7 +488,7 @@ export async function ensureFiles() { console.error('Failed to load files:', e) } finally { _filesLoading = false - filesLoaded.value = true + filesLoaded.value = filesCache.value.length > 0 } } @@ -586,7 +582,7 @@ export async function ensureUnassignedTraces(fileUuid: string, page?: number) { } export function loadTraceThumb(t: any) { - if (!t.file_uuid || !t.trace_id) return + if (!t.file_uuid || t.trace_id == null) 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) @@ -609,6 +605,33 @@ 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 @@ -796,17 +819,13 @@ 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)) @@ -837,25 +856,7 @@ export function clearFileStatusCache(fileUuid: string) { export async function refreshFileStatus(fileUuid: string) { try { - 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' - } - } + await loadPipelineStats(fileUuid, true) } catch (e) { console.error('refreshFileStatus failed:', fileUuid, e) } @@ -874,20 +875,13 @@ 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 === 'processing') { + if (newStatus === 'scanning') { await loadPipelineStats(fileUuid, true) } } @@ -901,8 +895,8 @@ export async function syncFileStatus(fileUuid: string): Promise { } export async function syncAllProcessingFiles(): Promise { - const processingFiles = filesCache.value.filter((f: any) => f.status === 'processing' && f.file_uuid) - for (const f of processingFiles) { + const scanningFiles = filesCache.value.filter((f: any) => f.status === 'scanning' && f.file_uuid) + for (const f of scanningFiles) { await syncFileStatus(f.file_uuid) } } @@ -921,11 +915,10 @@ export async function loadPipelineStats(fileUuid: string, force = false): Promis const WEIGHT_MAP: Record = { processors: 0.30, rule1_ingestion: 0.05, - face_tracing: 0.05, - identity_agent: 0.10, - tkg_nodes: 0.20, - tkg_edges: 0.15, - rule2_ingestion: 0.15 + face_tracing: 0.08, + tkg_nodes: 0.23, + tkg_edges: 0.18, + rule2_ingestion: 0.16 } const mappedStages = stages.map((s: any) => ({ @@ -936,7 +929,9 @@ export async function loadPipelineStats(fileUuid: string, force = false): Promis })) // 用前端权重重新计算 overall_progress(不信任 API 返回的值) - const calculatedProgress = mappedStages.reduce((sum, s) => sum + s.weight * s.progress, 0) + 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 pipelineStats: PipelineStats = { overall_progress: calculatedProgress, @@ -947,31 +942,18 @@ 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: [ @@ -979,6 +961,7 @@ 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) @@ -1054,25 +1037,11 @@ 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 7103461..636233d 100644 --- a/src/views/PeopleView.vue +++ b/src/views/PeopleView.vue @@ -1,6 +1,7 @@ +
@@ -135,7 +122,7 @@
- +
{{ Math.round(faceDetailModal.candidate?.confidence * 100 || 0) }}%
@@ -164,8 +151,8 @@ {{ t('people.face_detail.play_segment') }}
-
-
+
+
Frame: {{ faceDetailModal.candidate?.frame_number || '—' }} @@ -283,7 +270,7 @@

Select Target Group

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

-
+
@@ -313,7 +300,7 @@
-
+
@@ -468,13 +455,16 @@ 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, loadFaceThumb, loadTraceThumb, 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, frameThumbsCache, frameThumbKey, loadFaceThumb, loadTraceThumb, loadFrameThumb, 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' @@ -496,6 +486,7 @@ 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('') @@ -522,6 +513,7 @@ 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: '' }) @@ -633,9 +625,10 @@ 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 [] } - return clusterResults.value.map((c: any, idx: number) => { + const groups = clusterResults.value.map((c: any, idx: number) => { const traceIds = c.trace_ids || [] const repTraceId = c.representative_trace || traceIds[0] const traceMap = clusterTracesMap.value @@ -660,6 +653,10 @@ 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(() => { @@ -715,7 +712,8 @@ async function saveClusterEdit() { clusterEditMode.value = false // Update cluster results cache const clusters = getClusterResults(selectedFileUuid.value) - const idx = clusters.findIndex((x: any) => x.cluster_id === c.cluster_id) + const targetId = c.cluster_id ?? c.group_id + const idx = clusters.findIndex((x: any) => (x.cluster_id ?? x.group_id) === targetId) if (idx >= 0) { clusters[idx].name = name setClusterResults(selectedFileUuid.value, [...clusters]) @@ -754,6 +752,7 @@ async function refresh() { if (selectedFileUuid.value) { unassignedTracesPage.value = 1 + unassignedTracesLoaded.value = false delete clusterResultsCache.value[selectedFileUuid.value] clusterTracesMap.value = {} clusterVideoTraces.value = [] @@ -796,14 +795,17 @@ 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()) + pollClusterResults(selectedFileUuid.value, () => { + loadClusterResults() + refreshing.value = false + }) } else { - loadClusterResults() + await loadClusterResults() + refreshing.value = false } } else { unassignedTracesLoaded.value = false @@ -821,6 +823,7 @@ 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 }) @@ -914,6 +917,23 @@ 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 || [] @@ -977,7 +997,9 @@ onUnmounted(() => { }) async function loadCandidateThumb(c: any) { - if (!c?.file_uuid || candidateThumbs.value[c.id]) return + 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 loadFaceThumb(String(c.id), c.file_uuid, c.frame_number || 0, c.bbox) } @@ -1211,22 +1233,32 @@ 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('[redrawOverlay] bbox isNormalized:', isNormalized) + console.log('[bbox] canvas:', canvasWidth, 'x', canvasHeight, '| isNormalized:', isNormalized) - 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 + 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 + } - console.log('[redrawOverlay] drawing bbox at:', x, y, w, h) + console.log('[bbox] drawing at:', { x, y, w, h }) ctx.strokeStyle = '#00ffff' ctx.lineWidth = 3 @@ -1236,22 +1268,19 @@ function redrawOverlay() { } if (showPoseOverlay.value && pose?.keypoints && bbox) { - 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) + // 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) + }) + ctx.fillStyle = '#00ff00' for (const kp of validKeypoints) { ctx.beginPath() @@ -1403,7 +1432,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[selectedFileUuid.value] = finalClusters + clusterResultsCache.value = { ...clusterResultsCache.value, [selectedFileUuid.value]: finalClusters } try { await updateTraceProfileGroup(c.file_uuid, [tid], { name: target.name || `Group ${targetId}` }) @@ -1447,7 +1476,7 @@ async function confirmMoveFaceToNewGroup() { } } } - clusterResultsCache.value[selectedFileUuid.value] = updatedClusters + clusterResultsCache.value = { ...clusterResultsCache.value, [selectedFileUuid.value]: updatedClusters } try { await updateTraceProfileGroup(c.file_uuid, [tid], { name }) } catch (e) { console.error('create group and move face failed:', e) } @@ -1469,12 +1498,12 @@ async function createNewIdentityFromFace() { fileUuid: c.file_uuid, traceIds: c.trace_id ? [c.trace_id] : undefined }) - if (result?.group_uuid) { + if (result?.identity_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.group_uuid.replace(/-/g, '') + const cleanUuid = result.identity_uuid.replace(/-/g, '') router.push(`/people/${cleanUuid}`) } else { alert(t('people.detail.create_identity_failed')) @@ -1532,10 +1561,38 @@ 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) @@ -1590,7 +1647,7 @@ function removeTracesFromClusterCache(traceIds: number[]) { } const validClusters = clusters.filter((c: any) => c.trace_count > 0) - clusterResultsCache.value[selectedFileUuid.value] = validClusters + clusterResultsCache.value = { ...clusterResultsCache.value, [selectedFileUuid.value]: validClusters } } async function batchDeleteFaces() { @@ -1768,11 +1825,10 @@ async function moveToGroup(targetGroup: any) { alert('Target group has no traces') return } - - const targetTrace = clusterTracesMap.value[String(targetTraceId)] - const targetFileUuid = targetTrace?.file_uuid + + const targetFileUuid = selectedFileUuid.value if (!targetFileUuid) { - alert('Target trace has no file_uuid') + alert('No file selected') return } @@ -1799,12 +1855,39 @@ async function moveToGroup(targetGroup: any) { } } - removeTracesFromCache(selectedFaces.value) + // 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 } 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() { @@ -1822,17 +1905,27 @@ async function createNewGroupAndMove() { const firstTrace = traceList[0] if (!firstTrace) return + // Update trace profiles to assign them to the new group try { - const result: any = await apiCall('create_identity_from_face', { - fileUuid: firstTrace.file_uuid, - name, - traceIds: traceList.map(t => t.trace_id) - }) - - if (result?.group_uuid) { - clearSelection() - await refresh() + 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}`, + }) + clusterResultsCache.value = { ...clusterResultsCache.value, [selectedFileUuid.value]: clusters } } catch (e) { console.error('Create new group failed:', e) alert('Failed to create new group') @@ -1886,7 +1979,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, filePath: '', fileName: '' }) }, + { name: 'update_file_profile', call: () => apiCall('update_file_profile', { fileUuid, fileName: 'QC-Test' }) }, { name: 'get_file_keyframe', call: () => apiCall('get_thumbnail', { uuid: fileUuid, frame: 0 }) }, ] @@ -1977,7 +2070,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 } + const fileProfileResult = { canRead: false, canWrite: false, keyFrame: null as number | null, writeVerified: false } try { const fileProfile: any = await apiCall('get_file_profile', { fileUuid }) fileProfileResult.canRead = !!fileProfile @@ -1994,12 +2087,15 @@ async function runDataQC() { if (fileProfileResult.canRead) { try { - await apiCall('update_file_profile', { - fileUuid, - filePath: '', - fileName: '' - }) - fileProfileResult.canWrite = true + // 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 + } } catch (e: any) { console.warn('[QC] File profile write failed:', e) } @@ -2009,7 +2105,7 @@ async function runDataQC() { check: 'File Profile', status: fileProfileResult.canRead && fileProfileResult.canWrite ? 'PASS' : 'FAIL', details: fileProfileResult.canRead && fileProfileResult.canWrite - ? `Read/Write OK, Key Frame: ${fileProfileResult.keyFrame ?? 'N/A'}` + ? `Read/Write OK (verified), Key Frame: ${fileProfileResult.keyFrame ?? 'N/A'}` : `Read: ${fileProfileResult.canRead ? '✓' : '✗'}, Write: ${fileProfileResult.canWrite ? '✓' : '✗'}` }) @@ -2065,24 +2161,38 @@ async function runDataQC() { traceId }) result.canRead = !!profile - 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++ - } + // 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++ } 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, - label: '' + name: testLabel }) - result.canWrite = true + // Verify write + const verify: any = await apiCall('get_trace_profile', { + fileUuid: trace.file_uuid, + traceId + }) + if (verify?.name === testLabel) { + result.canWrite = true + } } catch (e: any) { result.error = e?.message?.slice(0, 30) || 'write failed' } @@ -2200,7 +2310,9 @@ function copyQCReport() {