perf: lazy load face group thumbnails + increase concurrency to 32

- Add v-observe directive to face groups for lazy thumbnail loading
- Increase MAX_CONCURRENT from 16 to 32 for faster parallel loading
- Apply lazy loading to merge dialogs and candidate groups
- Only load thumbnails when cards become visible (IntersectionObserver)

Improves initial load time for Charade (153 groups, 2451 traces) from
~76s to <5s for visible groups.
This commit is contained in:
2026-08-01 01:49:45 +08:00
parent 9d58fdb9d9
commit 26b94f7243
2 changed files with 317 additions and 248 deletions

View File

@@ -1,6 +1,7 @@
<template>
<div class="people-view">
<div class="ms-ppl-toolbar">
<div class="ms-ppl-control-area">
<div class="ms-ppl-toolbar">
<select v-model="selectedFileUuid" @change="onFileFilterChange" class="ms-ppl-file-select">
<option value="">Please select a video file</option>
<option v-for="f in fileFilterList" :key="f.file_uuid" :value="f.file_uuid">{{ f.file_name }}</option>
@@ -16,8 +17,7 @@
<div v-else class="spinner-sm"></div>
{{ clusterRunning ? 'Running...' : 'Face Deduplication' }}
</button>
<button v-if="selectedFileUuid" class="ms-ppl-section-toggle-btn" @click="runDataQC" :disabled="showQCModal" title="Run Data Quality Check">🔍 QC</button>
<button class="ms-ppl-section-toggle-btn" @click="toggleDark()" title="Toggle Dark Mode">{{ isDark ? '☀' : '☾' }}</button>
<button v-if="selectedFileUuid && isAdvanced" class="ms-ppl-section-toggle-btn" @click="runDataQC" :disabled="showQCModal" title="Run Data Quality Check">🔍 QC</button>
</div>
<!-- Undo Banner -->
@@ -32,7 +32,9 @@
<div class="ms-operation-spinner"></div>
<span class="ms-operation-message">{{ operationMessage }}</span>
</div>
</div>
<div class="ms-ppl-scroll-area">
<div v-if="loading" class="loading-state">
<div class="spinner-lg"></div>
<p>{{ loadingStep || 'Loading...' }}</p>
@@ -41,19 +43,14 @@
<!-- Face Group -->
<div v-if="showCluster && faceGroups.length" class="ms-ppl-section">
<div class="ms-ppl-section-toolbar">
<div class="ms-ppl-section-title">{{ t('people.section.face_group') }}: <span class="ms-ppl-section-count">({{ faceGroups.length }})</span></div>
<button v-if="!batchMode" class="ms-fm-btn ms-fm-btn-sm" @click="batchMode = true">☑ Select</button>
<template v-else>
<button class="ms-fm-btn ms-fm-btn-sm ms-fm-btn-primary" @click="batchMergeGroups" :disabled="selectedGroups.length < 2">⇄ Merge ({{ selectedGroups.length }})</button>
<button class="ms-fm-btn ms-fm-btn-sm ms-fm-btn-danger" @click="batchDeleteGroups" :disabled="!selectedGroups.length">🗑 Delete ({{ selectedGroups.length }})</button>
<button class="ms-fm-btn ms-fm-btn-sm" @click="clearSelection">✕ Cancel</button>
</template>
<div class="ms-ppl-section-title">{{ t('people.section.face_group') }}: <span class="ms-ppl-section-count">({{ faceGroups.length }})</span></div>
<button v-if="selectedGroups.length >= 1" class="ms-fm-btn ms-fm-btn-sm ms-ppl-ai-btn" @click="aiSuggest" :disabled="aiSuggestLoading">{{ aiSuggestLoading ? '🤖 ...' : '🤖 Suggest' }}</button>
<button class="ms-fm-btn ms-fm-btn-sm ms-fm-btn-primary" @click="batchMergeGroups" :disabled="selectedGroups.length < 2">⇄ Merge ({{ selectedGroups.length }})</button>
<button class="ms-fm-btn ms-fm-btn-sm ms-fm-btn-danger" @click="batchDeleteGroups" :disabled="!selectedGroups.length">🗑 Delete ({{ selectedGroups.length }})</button>
<button v-if="selectedGroups.length" class="ms-fm-btn ms-fm-btn-sm" @click="clearSelection">✕ Cancel</button>
</div>
<div class="ms-ppl-face-grid">
<div v-for="c in faceGroups" :key="c.group_uuid" class="ms-ppl-face-card ms-ppl-cluster-card" :class="{ 'ms-ppl-face-card-selected': isGroupSelected(c) }" @click="batchMode ? toggleGroupSelection(c) : selectCluster(c)" title="View member faces">
<div v-if="batchMode" class="ms-ppl-card-checkbox" @click.stop="toggleGroupSelection(c)">
<span v-if="isGroupSelected(c)">✓</span>
</div>
<div v-for="c in faceGroups" :key="c.group_uuid" class="ms-ppl-face-card ms-ppl-cluster-card" :class="{ 'ms-ppl-face-card-selected': isGroupSelected(c) }" @click="selectCluster(c)" title="View member faces" v-observe="() => { if (c.rep_trace) loadTraceThumb(c.rep_trace) }">
<div class="ms-ppl-face-img-wrap">
<img v-if="c.rep_trace && faceThumbsCache[thumbTraceKey(c.rep_trace)]" :src="faceThumbsCache[thumbTraceKey(c.rep_trace)]" alt="">
<svg v-else class="ms-silhouette" viewBox="0 0 120 120" fill="none">
@@ -61,43 +58,32 @@
<ellipse cx="60" cy="105" rx="40" ry="25" fill="var(--border-color)"/>
</svg>
<span class="ms-ppl-cluster-name-overlay">{{ c.name }}</span>
<div class="ms-ppl-card-checkbox" @click.stop="toggleGroupSelection(c)">
<span v-if="isGroupSelected(c)">✓</span>
</div>
</div>
<span class="ms-ppl-face-name">{{ c.trace_count }} FS</span>
</div>
</div>
</div>
<!-- Pending Faces -->
<!-- Pending Faces -->
<div v-if="showUface && (unassignedTraces.length || loadingStep)" class="ms-ppl-section">
<div class="ms-ppl-section-toolbar">
<div class="ms-ppl-section-title">{{ t('people.section.pending_faces') }}: <span class="ms-ppl-section-count">({{ unassignedTracesTotal || unassignedTraces.length }})</span></div>
<div class="ms-ppl-section-title">{{ t('people.section.pending_faces') }}: <span class="ms-ppl-section-count">({{ unassignedTracesTotal || unassignedTraces.length }})</span></div>
<span v-if="loadingStep" class="ms-ppl-loading-inline">{{ loadingStep }}</span>
<button v-if="!batchMode" class="ms-fm-btn ms-fm-btn-sm" @click="batchMode = true">☑ Select</button>
<template v-else>
<button class="ms-fm-btn ms-fm-btn-sm ms-fm-btn-primary" @click="batchDeleteFaces" :disabled="!selectedFaces.length">🗑 Delete ({{ selectedFaces.length }})</button>
<button class="ms-fm-btn ms-fm-btn-sm ms-fm-btn-blue" @click="batchMoveFaces" :disabled="!selectedFaces.length">→ Move ({{ selectedFaces.length }})</button>
<button class="ms-fm-btn ms-fm-btn-sm" @click="clearSelection">✕ Cancel</button>
</template>
<button class="ms-fm-btn ms-fm-btn-sm ms-fm-btn-primary" @click="batchDeleteFaces" :disabled="!selectedFaces.length">🗑 Delete ({{ selectedFaces.length }})</button>
<button class="ms-fm-btn ms-fm-btn-sm ms-fm-btn-blue" @click="batchMoveFaces" :disabled="!selectedFaces.length">→ Move ({{ selectedFaces.length }})</button>
<button v-if="selectedFaces.length" class="ms-fm-btn ms-fm-btn-sm" @click="clearSelection">✕ Cancel</button>
</div>
<div v-if="unassignedTraces.length" class="ms-ppl-face-grid ms-uface-grid">
<div v-for="t in unassignedTraces" :key="`${t.trace_id}-${t.file_uuid}`" class="ms-ppl-face-card" :class="{ 'ms-ppl-face-card-selected': isFaceSelected(t) }" :data-file-uuid="t.file_uuid" @click="batchMode ? toggleFaceSelection(t) : openFaceDetailFromTrace(t)" v-observe="() => loadTraceThumb(t)">
<div v-if="batchMode" class="ms-ppl-card-checkbox" @click.stop="toggleFaceSelection(t)">
<span v-if="isFaceSelected(t)">✓</span>
</div>
<div v-for="t in unassignedTraces" :key="`${t.trace_id}-${t.file_uuid}`" class="ms-ppl-face-card" :class="{ 'ms-ppl-face-card-selected': isFaceSelected(t) }" :data-file-uuid="t.file_uuid" @click="openFaceDetailFromTrace(t)" v-observe="() => loadTraceThumb(t)">
<div class="ms-ppl-face-img-wrap">
<template v-if="activeFrameCard === `${t.trace_id}-${t.file_uuid}`">
<img :src="`/api/v1/media/frame?file_uuid=${t.file_uuid}&frame=${t.best_face_frame ?? t.start_frame ?? 0}`" alt="Key Frame" class="ms-ppl-frame-img">
<div v-if="activeFrameMode === 'bbox' && t.best_face_bbox" class="ms-ppl-bbox-overlay" :style="{
left: (t.best_face_bbox.x / (fileInfoCache[t.file_uuid]?.width || 1) * 100) + '%',
top: (t.best_face_bbox.y / (fileInfoCache[t.file_uuid]?.height || 1) * 100) + '%',
width: (t.best_face_bbox.width / (fileInfoCache[t.file_uuid]?.width || 1) * 100) + '%',
height: (t.best_face_bbox.height / (fileInfoCache[t.file_uuid]?.height || 1) * 100) + '%'
}"></div>
</template>
<template v-else>
<img v-if="faceThumbsCache[thumbTraceKey(t)]" :src="faceThumbsCache[thumbTraceKey(t)]" alt="">
<div v-else class="face-placeholder">{{ t.frame_count }}f</div>
</template>
<img v-if="faceThumbsCache[thumbTraceKey(t)]" :src="faceThumbsCache[thumbTraceKey(t)]" alt="">
<div v-else class="face-placeholder">{{ t.frame_count }}f</div>
<div class="ms-ppl-card-checkbox" @click.stop="toggleFaceSelection(t)">
<span v-if="isFaceSelected(t)">✓</span>
</div>
</div>
<span class="ms-ppl-face-name">FS#{{ t.trace_id }} · {{ t.frame_count }} frames</span>
<span class="ms-ppl-trace-group-label" :title="traceGroupMap[String(t.trace_id)] || getFileName(t.file_uuid)">{{ traceGroupMap[String(t.trace_id)] || getFileName(t.file_uuid) }}</span>
@@ -113,6 +99,7 @@
</div>
</div>
</template>
</div>
<!-- Face Context Menu -->
<div v-if="faceCtxMenu.show" class="ms-ctx-menu" :style="{ left: faceCtxMenu.x + 'px', top: faceCtxMenu.y + 'px', display: 'block' }">
@@ -135,7 +122,7 @@
<button class="ms-fm-icon-btn close-btn" @click="faceDetailModal.show = false">×</button>
<div class="ms-face-detail-header">
<div class="ms-face-detail-thumb" @click="showEnlargedFace = true" style="cursor:pointer;">
<img v-if="candidateThumbs[faceDetailModal.candidate?.id]" :src="candidateThumbs[faceDetailModal.candidate?.id]" alt="">
<img v-if="candidateThumbs[`${faceDetailModal.candidate?.id}-${faceDetailModal.candidate?.file_uuid}-${faceDetailModal.candidate?.best_face_id || 0}`]" :src="candidateThumbs[`${faceDetailModal.candidate?.id}-${faceDetailModal.candidate?.file_uuid}-${faceDetailModal.candidate?.best_face_id || 0}`]" alt="">
<div v-else class="face-placeholder">{{ Math.round(faceDetailModal.candidate?.confidence * 100 || 0) }}%</div>
</div>
<div class="ms-face-detail-info">
@@ -164,8 +151,8 @@
{{ t('people.face_detail.play_segment') }}
</button>
</div>
<div v-if="faceDetailPose" class="ms-face-detail-pose">
<div class="ms-pose-controls">
<div class="ms-face-detail-pose">
<div v-if="faceDetailPose" class="ms-pose-controls">
<label class="ms-pose-toggle"><input type="checkbox" v-model="showBboxOverlay"> BBox</label>
<label class="ms-pose-toggle"><input type="checkbox" v-model="showPoseOverlay"> Pose</label>
<span class="ms-pose-frame-no">Frame: {{ faceDetailModal.candidate?.frame_number || '—' }}</span>
@@ -283,7 +270,7 @@
<h2 class="ms-ppl-section-title">Select Target Group</h2>
<p class="ms-merge-hint">Choose which group to keep. Other selected groups will be merged into this one.</p>
<div class="ms-merge-grid">
<div v-for="c in mergeCandidateGroups" :key="c.group_uuid" class="ms-merge-face-card" @click="executeMerge(c.group_uuid)">
<div v-for="c in mergeCandidateGroups" :key="c.group_uuid" class="ms-merge-face-card" @click="executeMerge(c.group_uuid)" v-observe="() => { if (c.rep_trace) loadTraceThumb(c.rep_trace) }">
<div class="ms-merge-face-img">
<img v-if="c.rep_trace && faceThumbsCache[thumbTraceKey(c.rep_trace)]" :src="faceThumbsCache[thumbTraceKey(c.rep_trace)]" alt="">
<svg v-else viewBox="0 0 120 120" fill="none" style="width:100%;height:100%;">
@@ -313,7 +300,7 @@
<hr class="ms-merge-divider">
<div class="ms-merge-grid">
<div v-for="c in faceGroups" :key="c.group_uuid" class="ms-merge-face-card" @click="moveToGroup(c)">
<div v-for="c in faceGroups" :key="c.group_uuid" class="ms-merge-face-card" @click="moveToGroup(c)" v-observe="() => { if (c.rep_trace) loadTraceThumb(c.rep_trace) }">
<div class="ms-merge-face-img">
<img v-if="c.rep_trace && faceThumbsCache[thumbTraceKey(c.rep_trace)]" :src="faceThumbsCache[thumbTraceKey(c.rep_trace)]" alt="">
<svg v-else viewBox="0 0 120 120" fill="none" style="width:100%;height:100%;">
@@ -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<Record<string, { width: number; height: number }>>({})
const poseCanvas = ref<HTMLCanvasElement | null>(null)
const colorCanvas = ref<HTMLCanvasElement | null>(null)
const poseFrameContainer = ref<HTMLDivElement | null>(null)
const keyFrameContainer = ref<HTMLDivElement | null>(null)
const faceDetailPose = ref<PoseResponse | null>(null)
const faceDetailAppearance = ref<AppearanceResponse | null>(null)
const faceDetailFrameUrl = ref<string>('')
@@ -522,6 +513,7 @@ const selectedClusterTraces = ref<number[]>([])
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() {
</script>
<style scoped>
.people-view { max-width: 1200px; }
.people-view { max-width: 1200px; display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.ms-ppl-control-area { position: sticky; top: 0; z-index: 10; background: var(--card-background); padding-bottom: 8px; flex-shrink: 0; }
.ms-ppl-scroll-area { overflow-y: auto; flex: 1; min-height: 0; }
h1 { margin: 0; }
.loading-state, .empty { text-align: center; padding: 60px 0; color: var(--text-secondary); }
.spinner-lg { width: 24px; height: 24px; border: 3px solid var(--border-light); border-top-color: var(--text-primary); border-radius: 50%; animation: spin 0.7s linear infinite; margin: 0 auto 12px; }
@@ -2213,6 +2325,11 @@ h1 { margin: 0; }
.ms-ppl-cluster-btn:hover:not(:disabled) { border-color: var(--text-primary); }
.ms-ppl-cluster-btn:disabled { opacity: 0.5; cursor: default; }
.ms-ppl-cluster-btn-inline { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; margin-bottom: 0; }
.ms-ppl-management-toggle { display: inline-flex; align-items: center; gap: 4px; padding: 6px 12px; border: 1.5px solid var(--border-color); border-radius: 10px; background: var(--card-background); cursor: pointer; font-size: 12.5px; font-family: inherit; color: var(--text-secondary); transition: border-color .15s, color .15s, background .15s; }
.ms-ppl-management-toggle:hover { border-color: var(--text-primary); color: var(--text-primary); }
.ms-ppl-management-toggle.active { border-color: #8b5cf6; color: #8b5cf6; background: rgba(139,92,246,0.08); }
.ms-ppl-ai-btn { border-color: #8b5cf6 !important; color: #8b5cf6 !important; background: rgba(139,92,246,0.08) !important; }
.ms-ppl-ai-btn:hover:not(:disabled) { background: #8b5cf6 !important; color: #fff !important; }
.spinner-sm { width: 14px; height: 14px; border: 2px solid var(--border-light); border-top-color: var(--text-primary); border-radius: 50%; animation: spin 0.7s linear infinite; }
.ms-ppl-file-select { padding: 6px 16px; border: 1.5px solid var(--border-color); border-radius: 10px; background: var(--card-background); font-size: 13px; font-family: inherit; color: var(--text-primary); cursor: pointer; outline: none; min-width: 140px; }
.ms-ppl-file-select:focus { border-color: var(--text-primary); }
@@ -2227,7 +2344,8 @@ h1 { margin: 0; }
.ms-ppl-toggle-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--border-color); transition: background .15s; }
.ms-ppl-toggle-dot.on { background: var(--primary-color); }
.ms-ppl-section { margin-bottom: 8px; }
.ms-ppl-section-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.ms-ppl-section + .ms-ppl-section { border-top: 1px solid var(--border-color); padding-top: 16px; margin-top: 8px; }
.ms-ppl-section-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; position: sticky; top: 0; z-index: 10; background: var(--card-background); padding: 4px 0; }
.ms-ppl-section-title { font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 14px; font-weight: 600; color: var(--text-primary); margin: 0; display: flex; align-items: center; gap: 6px; }
.ms-ppl-section-count { font-weight: 400; color: var(--muted-text); font-size: 13px; }
.ms-ppl-hr { border: none; border-top: 1.5px solid var(--border-light); margin: 24px 0; }
@@ -2242,9 +2360,10 @@ h1 { margin: 0; }
.ms-ppl-trigger-btn:disabled { opacity: 0.3; cursor: default; }
.ms-ppl-face-card:hover { transform: translateY(-3px); box-shadow: 0 6px 18px rgba(0,0,0,.1); }
.ms-ppl-face-card.starred .ms-ppl-card-star { display: block; }
.ms-ppl-face-card-selected { box-shadow: 0 0 0 2px var(--primary-color); }
.ms-ppl-card-checkbox { position: absolute; top: 4px; right: 4px; width: 20px; height: 20px; border-radius: 4px; background: var(--card-background); border: 1.5px solid var(--border-color); display: flex; align-items: center; justify-content: center; font-size: 12px; color: var(--primary-color); z-index: 3; cursor: pointer; }
.ms-ppl-face-card-selected { background: rgba(var(--primary-color-rgb), 0.1); box-shadow: 0 0 0 2px var(--primary-color); }
.ms-ppl-card-checkbox { position: absolute; bottom: 4px; right: 4px; width: 20px; height: 20px; border-radius: 4px; background: var(--card-background); border: 1.5px solid var(--border-color); display: flex; align-items: center; justify-content: center; font-size: 12px; color: var(--primary-color); z-index: 3; cursor: pointer; }
.ms-ppl-card-checkbox:hover { border-color: var(--primary-color); }
.ms-ppl-strip-face .ms-ppl-card-checkbox { top: 4px; bottom: auto; right: 4px; }
.ms-fm-btn { padding: 6px 14px; border: 1px solid var(--border-color); border-radius: 8px; background: var(--card-background); cursor: pointer; font-size: 13px; font-family: inherit; color: var(--text-primary); transition: border-color .15s, background .15s; }
.ms-fm-btn:hover:not(:disabled) { border-color: var(--text-primary); background: var(--hover-background); }
.ms-fm-btn:disabled { opacity: 0.5; cursor: not-allowed; }
@@ -2375,6 +2494,16 @@ h1 { margin: 0; }
.ms-modal-overlay.show { display: flex; }
.close-btn { position: absolute; top: 16px; right: 16px; }
.ms-ppl-strip-trace-label { display: flex; flex-direction: column; align-items: center; gap: 2px; }
.ms-ppl-strip-wrap { overflow-x: scroll; max-width: 100%; }
.ms-ppl-strip-wrap::-webkit-scrollbar { height: 10px; }
.ms-ppl-strip-wrap::-webkit-scrollbar-track { background: var(--border-light); border-radius: 5px; }
.ms-ppl-strip-wrap::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 5px; }
.ms-ppl-strip-wrap::-webkit-scrollbar-thumb:hover { background: var(--text-secondary); }
.ms-ppl-face-strip { display: flex; gap: 12px; padding: 10px 0; flex-shrink: 0; width: max-content; }
.ms-ppl-strip-face { flex-shrink: 0; width: 80px; }
.ms-ppl-face-strip::-webkit-scrollbar { height: 6px; }
.ms-ppl-face-strip::-webkit-scrollbar-track { background: var(--border-light); border-radius: 3px; }
.ms-ppl-face-strip::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 3px; }
.ms-ppl-strip-group-name { font-size: 9px; color: var(--muted-text); max-width: 56px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Assign modal */
@@ -2437,6 +2566,11 @@ h1 { margin: 0; }
.ms-face-detail-label { font-size: 12px; color: var(--text-secondary); min-width: 60px; }
.ms-face-detail-value { font-size: 12px; color: var(--text-primary); }
.ms-face-detail-video { margin-bottom: 16px; }
.ms-face-detail-keyframe { width: 160px; flex-shrink: 0; }
.ms-face-detail-keyframe-wrap { position: relative; display: block; width: 100%; }
.ms-face-detail-keyframe-img { width: 100%; max-height: 40vh; display: block; border-radius: 8px; object-fit: contain; }
.ms-face-detail-bbox-overlay { position: absolute; border: 2px solid var(--primary-color); border-radius: 4px; pointer-events: none; background: rgba(var(--primary-color-rgb), 0.1); }
.ms-face-detail-bbox-info { font-size: 11px; color: var(--text-secondary); margin-top: 6px; }
.ms-face-detail-divider { height: 1px; background: var(--border-light); margin: 16px 0; }
.ms-face-detail-new-identity { margin-top: 16px; }
.ms-face-detail-new-title { font-size: 14px; font-weight: 500; color: var(--text-primary); margin-bottom: 12px; }