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:
2026-08-01 05:29:55 +08:00
parent 5be57aec7d
commit adc8d944c6
2 changed files with 150 additions and 89 deletions

View File

@@ -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 {

View File

@@ -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; }