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:
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user