perf: use direct trace-thumb endpoint + fallback for face group thumbnails
- loadTraceThumb now uses /api/v1/trace-thumb/{uuid}/{tid} (~8ms)
- Face group imgs use direct trace-thumb URL with fallback
- Merge dialogs also use direct trace-thumb with fallback
- MAX_CONCURRENT increased to 32
Fallback chain:
1. c.rep_trace.file_uuid/trace_id (full trace data)
2. selectedFileUuid/representative_trace (group data only)
3. SVG silhouette
This commit is contained in:
17
src/store.ts
17
src/store.ts
@@ -332,7 +332,7 @@ loadProgressCacheFromStorage()
|
||||
|
||||
const thumbQueue: (() => Promise<void>)[] = []
|
||||
let activeThumbLoads = 0
|
||||
const MAX_CONCURRENT = 16
|
||||
const MAX_CONCURRENT = 32
|
||||
|
||||
const profileQueue: (() => Promise<void>)[] = []
|
||||
let activeProfileLoads = 0
|
||||
@@ -592,15 +592,14 @@ export function loadTraceThumb(t: any) {
|
||||
loadingFaceThumbs.add(key)
|
||||
queueThumb(async () => {
|
||||
try {
|
||||
const args: any = { uuid: t.file_uuid, frame: t.best_face_frame || t.start_frame || 0 }
|
||||
if (t.best_face_bbox) {
|
||||
args.bboxX = t.best_face_bbox.x
|
||||
args.bboxY = t.best_face_bbox.y
|
||||
args.bboxW = t.best_face_bbox.width
|
||||
args.bboxH = t.best_face_bbox.height
|
||||
// Use direct trace-thumb endpoint for fast keyFace.jpg loading (~8ms)
|
||||
const url = `/api/v1/trace-thumb/${t.file_uuid}/${t.trace_id}`
|
||||
const resp = await fetch(url)
|
||||
if (resp.ok) {
|
||||
const blob = await resp.blob()
|
||||
const dataUrl = URL.createObjectURL(blob)
|
||||
faceThumbsCache.value[key] = dataUrl
|
||||
}
|
||||
const result = await apiCall('get_face_thumbnail', args)
|
||||
if (result) faceThumbsCache.value[key] = result
|
||||
} catch (e) {
|
||||
console.error('loadTraceThumb failed:', key, e)
|
||||
} finally {
|
||||
|
||||
@@ -1,66 +1,67 @@
|
||||
<template>
|
||||
<div class="people-view">
|
||||
<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>
|
||||
</select>
|
||||
<button v-if="selectedFileUuid" class="ms-ppl-section-toggle-btn" :class="{ active: showCluster }" @click="showCluster = !showCluster">
|
||||
{{ t('people.toolbar.face_group') }} <span class="ms-ppl-toggle-dot" :class="{ on: showCluster }"></span>
|
||||
</button>
|
||||
<button v-if="selectedFileUuid" class="ms-ppl-section-toggle-btn" :class="{ active: showUface }" @click="showUface = !showUface">
|
||||
{{ t('people.toolbar.pending_faces') }} <span class="ms-ppl-toggle-dot" :class="{ on: showUface }"></span>
|
||||
</button>
|
||||
<button v-if="selectedFileUuid" class="ms-ppl-cluster-btn ms-ppl-cluster-btn-inline" @click="runClusterAgent" :disabled="clusterRunning">
|
||||
<svg v-if="!clusterRunning" width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M21 12a9 9 0 11-6.219-8.56" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><path d="M21 3v6h-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<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>
|
||||
<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>
|
||||
</select>
|
||||
<button v-if="selectedFileUuid" class="ms-ppl-section-toggle-btn" :class="{ active: showCluster }" @click="showCluster = !showCluster">
|
||||
{{ t('people.toolbar.face_group') }} <span class="ms-ppl-toggle-dot" :class="{ on: showCluster }"></span>
|
||||
</button>
|
||||
<button v-if="selectedFileUuid" class="ms-ppl-section-toggle-btn" :class="{ active: showUface }" @click="showUface = !showUface">
|
||||
{{ t('people.toolbar.pending_faces') }} <span class="ms-ppl-toggle-dot" :class="{ on: showUface }"></span>
|
||||
</button>
|
||||
<button v-if="selectedFileUuid" class="ms-ppl-cluster-btn ms-ppl-cluster-btn-inline" @click="runClusterAgent" :disabled="clusterRunning">
|
||||
<svg v-if="!clusterRunning" width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M21 12a9 9 0 11-6.219-8.56" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><path d="M21 3v6h-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<div v-else class="spinner-sm"></div>
|
||||
{{ clusterRunning ? 'Running...' : 'Face Deduplication' }}
|
||||
</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 -->
|
||||
<div v-if="showUndoBanner" class="ms-undo-banner">
|
||||
<span>Deleted {{ lastDeletedTraces.length }} trace(s)</span>
|
||||
<button class="ms-fm-btn ms-fm-btn-sm ms-fm-btn-primary" @click="undoLastDelete">↩ Undo</button>
|
||||
<button class="ms-fm-btn ms-fm-btn-sm" @click="showUndoBanner = false; lastDeletedTraces = []">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Operation Loading Overlay -->
|
||||
<div v-if="operationLoading" class="ms-operation-overlay">
|
||||
<div class="ms-operation-spinner"></div>
|
||||
<span class="ms-operation-message">{{ operationMessage }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Undo Banner -->
|
||||
<div v-if="showUndoBanner" class="ms-undo-banner">
|
||||
<span>Deleted {{ lastDeletedTraces.length }} trace(s)</span>
|
||||
<button class="ms-fm-btn ms-fm-btn-sm ms-fm-btn-primary" @click="undoLastDelete">↩ Undo</button>
|
||||
<button class="ms-fm-btn ms-fm-btn-sm" @click="showUndoBanner = false; lastDeletedTraces = []">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Operation Loading Overlay -->
|
||||
<div v-if="operationLoading" class="ms-operation-overlay">
|
||||
<div class="ms-operation-spinner"></div>
|
||||
<span class="ms-operation-message">{{ operationMessage }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-state">
|
||||
<div class="spinner-lg"></div>
|
||||
<p>{{ loadingStep || 'Loading...' }}</p>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="ms-ppl-scroll-area">
|
||||
<div v-if="loading" class="loading-state">
|
||||
<div class="spinner-lg"></div>
|
||||
<p>{{ loadingStep || 'Loading...' }}</p>
|
||||
</div>
|
||||
<template v-else>
|
||||
<!-- 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>
|
||||
<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">
|
||||
<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="">
|
||||
<img v-if="c.rep_trace && c.rep_trace.file_uuid" :src="`/api/v1/trace-thumb/${c.rep_trace.file_uuid}/${c.rep_trace.trace_id}`" alt="" loading="lazy">
|
||||
<img v-else-if="selectedFileUuid && c.representative_trace" :src="`/api/v1/trace-thumb/${selectedFileUuid}/${c.representative_trace}`" alt="" loading="lazy">
|
||||
<svg v-else class="ms-silhouette" viewBox="0 0 120 120" fill="none">
|
||||
<circle cx="60" cy="45" r="25" fill="var(--border-color)"/>
|
||||
<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>
|
||||
@@ -72,18 +73,12 @@
|
||||
<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>
|
||||
<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)">
|
||||
<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">
|
||||
@@ -95,9 +90,11 @@
|
||||
}"></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>
|
||||
<img :src="`/api/v1/trace-thumb/${t.file_uuid}/${t.trace_id}`" alt="" loading="lazy">
|
||||
</template>
|
||||
<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 +110,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' }">
|
||||
@@ -222,7 +220,7 @@
|
||||
<div v-else class="ms-cluster-title" @click="startClusterEdit">
|
||||
{{ clusterDetailModal.cluster?.name }} ({{ clusterDetailModal.cluster?.trace_count }} Faces)
|
||||
</div>
|
||||
<button class="ms-fm-btn ms-fm-btn-primary ms-cluster-play-btn" @click="playClusterVideo" :disabled="!clusterDetailModal.cluster?.rep_trace?.file_uuid">
|
||||
<button class="ms-fm-btn ms-fm-btn-primary ms-cluster-play-btn" @click="playClusterVideo" :disabled="!clusterDetailCluster?.rep_trace?.file_uuid">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" style="margin-right:6px;">
|
||||
<polygon points="5 3 19 12 5 21 5 3" fill="currentColor"></polygon>
|
||||
</svg>
|
||||
@@ -285,7 +283,8 @@
|
||||
<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 class="ms-merge-face-img">
|
||||
<img v-if="c.rep_trace && faceThumbsCache[thumbTraceKey(c.rep_trace)]" :src="faceThumbsCache[thumbTraceKey(c.rep_trace)]" alt="">
|
||||
<img v-if="c.rep_trace && c.rep_trace.file_uuid" :src="`/api/v1/trace-thumb/${c.rep_trace.file_uuid}/${c.rep_trace.trace_id}`" alt="" loading="lazy">
|
||||
<img v-else-if="selectedFileUuid && c.representative_trace" :src="`/api/v1/trace-thumb/${selectedFileUuid}/${c.representative_trace}`" alt="" loading="lazy">
|
||||
<svg v-else viewBox="0 0 120 120" fill="none" style="width:100%;height:100%;">
|
||||
<circle cx="60" cy="45" r="25" fill="var(--border-color)"/>
|
||||
<ellipse cx="60" cy="105" rx="40" ry="25" fill="var(--border-color)"/>
|
||||
@@ -315,7 +314,8 @@
|
||||
<div class="ms-merge-grid">
|
||||
<div v-for="c in faceGroups" :key="c.group_uuid" class="ms-merge-face-card" @click="moveToGroup(c)">
|
||||
<div class="ms-merge-face-img">
|
||||
<img v-if="c.rep_trace && faceThumbsCache[thumbTraceKey(c.rep_trace)]" :src="faceThumbsCache[thumbTraceKey(c.rep_trace)]" alt="">
|
||||
<img v-if="c.rep_trace && c.rep_trace.file_uuid" :src="`/api/v1/trace-thumb/${c.rep_trace.file_uuid}/${c.rep_trace.trace_id}`" alt="" loading="lazy">
|
||||
<img v-else-if="selectedFileUuid && c.representative_trace" :src="`/api/v1/trace-thumb/${selectedFileUuid}/${c.representative_trace}`" alt="" loading="lazy">
|
||||
<svg v-else viewBox="0 0 120 120" fill="none" style="width:100%;height:100%;">
|
||||
<circle cx="60" cy="45" r="25" fill="var(--border-color)"/>
|
||||
<ellipse cx="60" cy="105" rx="40" ry="25" fill="var(--border-color)"/>
|
||||
@@ -472,11 +472,12 @@ import { ensureUnassignedTraces, unassignedTracesCache, unassignedTracesLoaded,
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { drawPoseSkeleton, drawAppearanceOverlay } from '@/utils/poseRenderer'
|
||||
import type { PoseResponse, AppearanceResponse } from '@/api/types'
|
||||
import { isDark, toggleDark } from '@/composables/useTheme'
|
||||
import { getNextZIndex } from '@/composables/useZIndex'
|
||||
import { useAdvancedMode } from '@/stores/advancedMode'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const { isAdvanced } = useAdvancedMode()
|
||||
import VideoPlayer from '../components/VideoPlayer.vue'
|
||||
|
||||
const faceDetailZIndex = ref(10010)
|
||||
@@ -508,8 +509,7 @@ const showAppearanceOverlay = ref(true)
|
||||
|
||||
const selectedFaces = ref<string[]>([])
|
||||
const selectedGroups = ref<string[]>([])
|
||||
const batchMode = ref(false)
|
||||
const batchAction = ref<'delete' | 'merge' | 'move' | ''>('')
|
||||
const aiSuggestLoading = ref(false)
|
||||
const showMergeTargetModal = ref(false)
|
||||
const mergeCandidateGroups = ref<any[]>([])
|
||||
const showMoveFacesModal = ref(false)
|
||||
@@ -667,6 +667,16 @@ const otherClusters = computed(() => {
|
||||
return clusterResults.value.filter((c: any) => (c.cluster_id ?? c.group_id) !== faceSourceClusterId.value)
|
||||
})
|
||||
|
||||
// Live cluster for the detail modal — tracks faceGroups so rep_trace is
|
||||
// populated once clusterTracesMap finishes loading (snapshot at click time is stale)
|
||||
const clusterDetailCluster = computed(() => {
|
||||
const snapshot = clusterDetailModal.value.cluster
|
||||
if (!snapshot) return null
|
||||
const cid = snapshot.cluster_id ?? snapshot.group_id
|
||||
const live = faceGroups.value.find((g: any) => g.cluster_id === cid)
|
||||
return live || snapshot
|
||||
})
|
||||
|
||||
// Map trace_id → group name for Faces section
|
||||
const traceGroupMap = computed(() => {
|
||||
const map: Record<string, string> = {}
|
||||
@@ -758,18 +768,22 @@ async function refresh() {
|
||||
clusterTracesMap.value = {}
|
||||
clusterVideoTraces.value = []
|
||||
clusterVideoSegments.value = []
|
||||
loadingStep.value = 'Loading traces...'
|
||||
await ensureUnassignedTraces(selectedFileUuid.value)
|
||||
for (const t of unassignedTraces.value.slice(0, 20)) {
|
||||
if (t.file_uuid) loadTraceThumb(t)
|
||||
}
|
||||
loadingStep.value = ''
|
||||
// Load cluster results
|
||||
loadingStep.value = 'Loading clusters...'
|
||||
// Load cluster results first
|
||||
if (isClusterRunning(selectedFileUuid.value)) {
|
||||
pollClusterResults(selectedFileUuid.value, () => loadClusterResults())
|
||||
} else {
|
||||
loadClusterResults()
|
||||
}
|
||||
// Only load unassigned traces if Faces section is visible
|
||||
if (showUface.value) {
|
||||
loadingStep.value = 'Loading traces...'
|
||||
await ensureUnassignedTraces(selectedFileUuid.value)
|
||||
for (const t of unassignedTraces.value.slice(0, 20)) {
|
||||
if (t.file_uuid) loadTraceThumb(t)
|
||||
}
|
||||
}
|
||||
loadingStep.value = ''
|
||||
} else {
|
||||
unassignedTracesLoaded.value = false
|
||||
unassignedTracesCache.value = []
|
||||
@@ -893,10 +907,12 @@ async function loadClusterResults() {
|
||||
}
|
||||
console.log('[cluster] groups extracted:', groups.length, groups)
|
||||
if (groups.length) {
|
||||
await loadClusterNames(fileUuid, groups)
|
||||
setClusterResults(fileUuid, groups)
|
||||
console.log('[cluster] set', groups.length, 'groups')
|
||||
|
||||
// Load trace profiles for names in background (non-blocking)
|
||||
loadClusterNames(fileUuid, groups).catch(() => {})
|
||||
|
||||
// Build trace map for representative thumbnails
|
||||
const allTraces: any[] = []
|
||||
for (let page = 1; page <= 10; page++) {
|
||||
@@ -914,6 +930,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 || []
|
||||
@@ -989,7 +1022,7 @@ function selectCluster(c: any) {
|
||||
}
|
||||
|
||||
async function playClusterVideo() {
|
||||
const c = clusterDetailModal.value.cluster
|
||||
const c = clusterDetailCluster.value || clusterDetailModal.value.cluster
|
||||
if (!c?.rep_trace?.file_uuid) return
|
||||
const trace = c.rep_trace
|
||||
const fileUuid = trace.file_uuid
|
||||
@@ -1532,8 +1565,32 @@ function toggleClusterTraceSelection(t: any) {
|
||||
function clearSelection() {
|
||||
selectedFaces.value = []
|
||||
selectedGroups.value = []
|
||||
batchMode.value = false
|
||||
batchAction.value = ''
|
||||
}
|
||||
|
||||
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: any = 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() {
|
||||
@@ -2200,7 +2257,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; }
|
||||
@@ -2227,7 +2286,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; }
|
||||
@@ -2243,8 +2303,9 @@ h1 { margin: 0; }
|
||||
.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-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; }
|
||||
@@ -2374,8 +2435,9 @@ h1 { margin: 0; }
|
||||
.ms-modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.35); z-index: 9998; align-items: center; justify-content: center; }
|
||||
.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-trace-label { display: flex; flex-direction: column; align-items: center; gap: 2px; max-width: 100%; overflow: hidden; }
|
||||
.ms-ppl-strip-group-name { font-size: 9px; color: var(--muted-text); max-width: 56px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ms-ppl-strip-face { flex-shrink: 0; width: 80px; }
|
||||
|
||||
/* Assign modal */
|
||||
.ms-modal-assign { max-width: 560px; width: 92%; padding: 28px 32px; text-align: left; max-height: 85vh; overflow-y: auto; margin-top: 48px; align-self: flex-start; background: var(--card-background); border-radius: 16px; box-shadow: var(--shadow); position: relative; }
|
||||
|
||||
Reference in New Issue
Block a user