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

This reverts commit 26b94f7243.
This commit is contained in:
2026-08-01 01:58:01 +08:00
parent 26b94f7243
commit 5be57aec7d
2 changed files with 248 additions and 317 deletions

View File

@@ -21,7 +21,6 @@ const UNASSIGNED_PER_PAGE = 20
export const thumbnailsCache = ref<Record<string, string>>({})
export const profilesCache = ref<Record<string, string>>({})
export const faceThumbsCache = ref<Record<string, string>>({})
export const frameThumbsCache = ref<Record<string, string>>({})
export const poseCache = ref<Record<string, PoseResponse>>({})
export const appearanceCache = ref<Record<string, AppearanceResponse>>({})
@@ -124,11 +123,20 @@ export interface TkgStats {
total_edges: number
}
export interface IdentityAgentStats {
clusters: number
identities_created: number
tmdb_matches: number
speaker_bindings: number
confirmations: number
}
export interface FileStats {
json: JsonStats
postgresql: PostgresqlStats
qdrant: QdrantStats
tkg: TkgStats
identity_agent: IdentityAgentStats
}
export const processorCountsCache = ref<Record<string, Record<string, ProcessorOutputInfo>>>({})
@@ -324,7 +332,7 @@ loadProgressCacheFromStorage()
const thumbQueue: (() => Promise<void>)[] = []
let activeThumbLoads = 0
const MAX_CONCURRENT = 32
const MAX_CONCURRENT = 16
const profileQueue: (() => Promise<void>)[] = []
let activeProfileLoads = 0
@@ -333,7 +341,6 @@ const MAX_PROFILE_CONCURRENT = 4
const loadingThumbs = new Set<string>()
const loadingProfiles = new Set<string>()
const loadingFaceThumbs = new Set<string>()
const loadingFrameThumbs = new Set<string>()
const loadingProcessorCounts = new Set<string>()
const pollingProgress = new Set<string>()
export const pollingProgressSet = pollingProgress
@@ -369,10 +376,11 @@ export function stopStatsAutoRefresh() {
export function startProcessingFilesPolling() {
if (processingPollInterval) return
processingPollInterval = setInterval(async () => {
const scanningFiles = filesCache.value.filter((f: any) => f.status === 'scanning' && f.file_uuid)
if (scanningFiles.length === 0) return
await Promise.all(scanningFiles.map((f: any) => syncFileStatus(f.file_uuid)))
}, 5000)
const processingFiles = filesCache.value.filter((f: any) => f.status === 'processing' && f.file_uuid)
if (processingFiles.length === 0) return
// Use Promise.all to sync in parallel, not sequentially
await Promise.all(processingFiles.map((f: any) => syncFileStatus(f.file_uuid)))
}, 10000) // Increased from 5s to 10s
}
export function stopProcessingFilesPolling() {
@@ -401,20 +409,16 @@ export function startFilesWatch() {
filesCache.value = newFiles
}
// Check status changes for existing files (never regress from agent_ready)
// Check status changes for existing files
for (const newFile of newFiles) {
if (!newFile.file_uuid) continue
const oldFile = filesCache.value.find((f: any) => f.file_uuid === newFile.file_uuid)
if (!oldFile || oldFile.status === newFile.status) continue
if (oldFile.status === 'agent_ready') continue
// files/scan often returns stale 'scanning' — resolve via syncFileStatus
if (newFile.status === 'scanning') {
syncFileStatus(newFile.file_uuid)
continue
}
const idx = filesCache.value.findIndex((f: any) => f.file_uuid === newFile.file_uuid)
if (idx >= 0) {
filesCache.value[idx] = newFile
if (oldFile && oldFile.status !== newFile.status) {
// Status changed, update cache
const idx = filesCache.value.findIndex((f: any) => f.file_uuid === newFile.file_uuid)
if (idx >= 0) {
filesCache.value[idx] = newFile
}
}
}
} catch (e) {
@@ -475,10 +479,10 @@ let _lastLoadedFileUuid: string | undefined = undefined
export async function ensureFiles() {
console.error('[ensureFiles] starting, filesLoaded.value:', filesLoaded.value)
if (filesLoaded.value && filesCache.value.length > 0) return
if (filesLoaded.value) return
if (_filesLoading) {
await new Promise<void>(r => { const check = setInterval(() => { if (filesLoaded.value && filesCache.value.length > 0 || !_filesLoading) { clearInterval(check); r() } }, 100) })
if (filesLoaded.value && filesCache.value.length > 0) return
await new Promise<void>(r => { const check = setInterval(() => { if (filesLoaded.value || !_filesLoading) { clearInterval(check); r() } }, 100) })
return
}
_filesLoading = true
try {
@@ -488,7 +492,7 @@ export async function ensureFiles() {
console.error('Failed to load files:', e)
} finally {
_filesLoading = false
filesLoaded.value = filesCache.value.length > 0
filesLoaded.value = true
}
}
@@ -582,7 +586,7 @@ export async function ensureUnassignedTraces(fileUuid: string, page?: number) {
}
export function loadTraceThumb(t: any) {
if (!t.file_uuid || t.trace_id == null) return
if (!t.file_uuid || !t.trace_id) return
const key = `${t.trace_id}-${t.file_uuid}-${t.best_face_id || 0}`
if (faceThumbsCache.value[key] || loadingFaceThumbs.has(key)) return
loadingFaceThumbs.add(key)
@@ -605,33 +609,6 @@ export function loadTraceThumb(t: any) {
})
}
export function frameThumbKey(t: any): string {
return `${t.trace_id}-${t.file_uuid}`
}
export function loadFrameThumb(t: any) {
if (!t.file_uuid || t.trace_id == null) return
const key = frameThumbKey(t)
if (frameThumbsCache.value[key] || loadingFrameThumbs.has(key)) return
loadingFrameThumbs.add(key)
queueThumb(async () => {
try {
const frame = t.best_face_frame ?? t.start_frame ?? 0
const url = `/api/v1/media/frame?file_uuid=${t.file_uuid}&frame=${frame}`
const resp = await fetch(url)
if (resp.ok) {
const blob = await resp.blob()
const dataUrl = URL.createObjectURL(blob)
frameThumbsCache.value[key] = dataUrl
}
} catch (e) {
console.error('loadFrameThumb failed:', key, e)
} finally {
loadingFrameThumbs.delete(key)
}
})
}
export function loadThumbnail(uuid: string, frame = 30) {
const key = `${uuid}:${frame}`
if (!uuid || thumbnailsCache.value[key] || loadingThumbs.has(key)) return
@@ -819,13 +796,17 @@ export async function pollProgress(fileUuid: string) {
gpu_percent: data.gpu_percent,
memory_percent: data.memory_percent,
}
fileStatusCache.value[fileUuid] = 'processing'
const f = filesCache.value.find((x: any) => x.file_uuid === fileUuid)
if (f) f.status = 'processing'
saveProgressCacheToStorage()
const allComplete = data.processors?.every((p: any) => p.status === 'complete' || p.status === 'failed')
if (allComplete || data.overall_progress >= 100) {
stopPolling(fileUuid)
fileStatusCache.value[fileUuid] = 'completed'
if (f) f.status = 'completed'
saveProgressCacheToStorage()
await loadProcessorCounts(fileUuid)
await syncFileStatus(fileUuid)
break
}
await new Promise(r => setTimeout(r, 2000))
@@ -856,7 +837,25 @@ export function clearFileStatusCache(fileUuid: string) {
export async function refreshFileStatus(fileUuid: string) {
try {
await loadPipelineStats(fileUuid, true)
const pipeline = await loadPipelineStats(fileUuid, true)
if (pipeline) {
const isComplete = pipeline.overall_progress >= 1.0 || pipeline.stages?.every((s: any) => s.status === 'completed' || s.status === 'failed')
const isRunning = pipeline.stages?.some((s: any) => s.status === 'running')
if (isComplete) {
fileStatusCache.value[fileUuid] = 'completed'
await loadFileStats(fileUuid, true)
} else if (isRunning) {
fileStatusCache.value[fileUuid] = 'processing'
}
// Also update the file object in filesCache
const f = filesCache.value.find((x: any) => x.file_uuid === fileUuid)
if (f) {
if (isComplete) f.status = 'completed'
else if (isRunning) f.status = 'processing'
}
}
} catch (e) {
console.error('refreshFileStatus failed:', fileUuid, e)
}
@@ -875,13 +874,20 @@ export async function syncFileStatus(fileUuid: string): Promise<any> {
if (result?.status) {
const newStatus = result.status
const oldStatus = fileStatusCache.value[fileUuid]
fileStatusCache.value[fileUuid] = newStatus
const f = filesCache.value.find((x: any) => x.file_uuid === fileUuid)
if (f) {
// If status changed from processing to completed, clear pipeline cache
if (oldStatus === 'processing' && newStatus === 'completed') {
delete pipelineStatsCache.value[fileUuid]
delete fileProgressCache.value[fileUuid]
}
f.status = newStatus
if (newStatus === 'scanning') {
if (newStatus === 'processing') {
await loadPipelineStats(fileUuid, true)
}
}
@@ -895,8 +901,8 @@ export async function syncFileStatus(fileUuid: string): Promise<any> {
}
export async function syncAllProcessingFiles(): Promise<void> {
const scanningFiles = filesCache.value.filter((f: any) => f.status === 'scanning' && f.file_uuid)
for (const f of scanningFiles) {
const processingFiles = filesCache.value.filter((f: any) => f.status === 'processing' && f.file_uuid)
for (const f of processingFiles) {
await syncFileStatus(f.file_uuid)
}
}
@@ -915,10 +921,11 @@ export async function loadPipelineStats(fileUuid: string, force = false): Promis
const WEIGHT_MAP: Record<string, number> = {
processors: 0.30,
rule1_ingestion: 0.05,
face_tracing: 0.08,
tkg_nodes: 0.23,
tkg_edges: 0.18,
rule2_ingestion: 0.16
face_tracing: 0.05,
identity_agent: 0.10,
tkg_nodes: 0.20,
tkg_edges: 0.15,
rule2_ingestion: 0.15
}
const mappedStages = stages.map((s: any) => ({
@@ -929,9 +936,7 @@ export async function loadPipelineStats(fileUuid: string, force = false): Promis
}))
// 用前端权重重新计算 overall_progress(不信任 API 返回的值)
const calculatedRaw = mappedStages.reduce((sum, s) => sum + s.weight * s.progress, 0)
const allDone = mappedStages.length > 0 && mappedStages.every((s: any) => s.status === 'completed' || s.status === 'failed')
const calculatedProgress = allDone ? 1.0 : Math.min(calculatedRaw, 1.0)
const calculatedProgress = mappedStages.reduce((sum, s) => sum + s.weight * s.progress, 0)
const pipelineStats: PipelineStats = {
overall_progress: calculatedProgress,
@@ -942,18 +947,31 @@ export async function loadPipelineStats(fileUuid: string, force = false): Promis
pipelineStatsCache.value = { ...pipelineStatsCache.value, [fileUuid]: pipelineStats }
// If pipeline is complete, update file status
if (calculatedProgress >= 1.0) {
const f = filesCache.value.find((x: any) => x.file_uuid === fileUuid)
if (f && f.status !== 'completed') {
f.status = 'completed'
fileStatusCache.value[fileUuid] = 'completed'
}
}
return pipelineStats
} catch (e) {
console.warn('get_pipeline_stats failed, falling back to get_progress:', fileUuid, e)
try {
const oldData: any = await apiCall('get_progress', { fileUuid })
// 检查所有处理器是否都完成
const allComplete = oldData.processors?.length > 0 && oldData.processors?.every((p: any) => p.status === 'complete' || p.status === 'failed')
const hasRunning = oldData.processors?.some((p: any) => p.status === 'running')
// 如果都完成,显示100%
const progress = allComplete ? 1.0 : (oldData.overall_progress || 0) / 100
const status = allComplete ? 'completed' : (hasRunning ? 'running' : 'pending')
console.log('[loadPipelineStats fallback]', fileUuid, { allComplete, hasRunning, progress, status, processorCount: oldData.processors?.length })
const fallbackStats: PipelineStats = {
overall_progress: progress,
stages: [
@@ -961,7 +979,6 @@ export async function loadPipelineStats(fileUuid: string, force = false): Promis
]
}
pipelineStatsCache.value = { ...pipelineStatsCache.value, [fileUuid]: fallbackStats }
return fallbackStats
} catch (fallbackError) {
console.error('Both pipeline APIs failed:', fileUuid, fallbackError)
@@ -1037,11 +1054,25 @@ export async function loadFileStats(fileUuid: string, force = false): Promise<Fi
},
total_nodes: data.tkg?.total_nodes || 0,
total_edges: data.tkg?.total_edges || 0
},
identity_agent: {
clusters: data.identity_agent?.clusters || 0,
identities_created: data.identity_agent?.identities_created || 0,
tmdb_matches: data.identity_agent?.tmdb_matches || 0,
speaker_bindings: data.identity_agent?.speaker_bindings || 0,
confirmations: data.identity_agent?.confirmations || 0
}
}
fileStatsCache.value = { ...fileStatsCache.value, [fileUuid]: fileStats }
if (data.status === 'completed') {
pipelineStatsCache.value[fileUuid] = {
overall_progress: 1.0,
stages: [{ name: 'processors', weight: 1.0, progress: 1.0, status: 'completed' }]
}
}
return fileStats
} catch (e) {
console.warn('get_file_stats failed, falling back to get_processor_counts:', fileUuid, e)
@@ -1059,7 +1090,8 @@ export async function loadFileStats(fileUuid: string, force = false): Promise<Fi
},
postgresql: { sentence_chunks: 0, trace_chunks: 0, relationship_chunks: 0, identities: 0, file_identities: 0 },
qdrant: { faces: 0, face_traces: 0, face_identities: 0, text_chunks: 0, speakers: 0 },
tkg: { nodes: { character: 0, object: 0, scene: 0, action: 0, location: 0, event: 0, dialogue: 0, emotion: 0, relation: 0 }, edges: { appear_in: 0, interact_with: 0, located_at: 0, perform_action: 0, cause_effect: 0, temporal_link: 0, speak_to: 0, feel: 0 }, total_nodes: 0, total_edges: 0 }
tkg: { nodes: { character: 0, object: 0, scene: 0, action: 0, location: 0, event: 0, dialogue: 0, emotion: 0, relation: 0 }, edges: { appear_in: 0, interact_with: 0, located_at: 0, perform_action: 0, cause_effect: 0, temporal_link: 0, speak_to: 0, feel: 0 }, total_nodes: 0, total_edges: 0 },
identity_agent: { clusters: 0, identities_created: 0, tmdb_matches: 0, speaker_bindings: 0, confirmations: 0 }
}
// 从processorCounts填充json.processors
@@ -1091,6 +1123,39 @@ export function getFileStats(fileUuid: string): FileStats | null {
return fileStatsCache.value[fileUuid] || null
}
export async function pollPipelineProgress(fileUuid: string) {
if (!fileUuid || pollingProgress.has(fileUuid)) return
pollingProgress.add(fileUuid)
try {
while (pollingProgress.has(fileUuid)) {
const stats = await loadPipelineStats(fileUuid, true)
if (stats) {
const idx = filesCache.value.findIndex((x: any) => x.file_uuid === fileUuid)
if (idx === -1) continue
fileStatusCache.value[fileUuid] = 'processing'
filesCache.value.splice(idx, 1, { ...filesCache.value[idx], status: 'processing' })
saveProgressCacheToStorage()
const allComplete = stats.stages.every(s => s.status === 'completed' || s.status === 'failed')
if (allComplete || stats.overall_progress >= 1.0) {
stopPolling(fileUuid)
fileStatusCache.value[fileUuid] = 'completed'
filesCache.value.splice(idx, 1, { ...filesCache.value[idx], status: 'completed' })
saveProgressCacheToStorage()
await loadFileStats(fileUuid, true)
break
}
}
await new Promise(r => setTimeout(r, 2000))
}
} catch (e) {
console.error('pollPipelineProgress failed:', fileUuid, e)
} finally {
pollingProgress.delete(fileUuid)
}
}
export function invalidateFiles() {
filesLoaded.value = false
filesCache.value = []

View File

@@ -1,7 +1,6 @@
<template>
<div class="people-view">
<div class="ms-ppl-control-area">
<div class="ms-ppl-toolbar">
<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>
@@ -17,7 +16,8 @@
<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>
<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>
<!-- Undo Banner -->
@@ -32,9 +32,7 @@
<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>
@@ -43,14 +41,19 @@
<!-- 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="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 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>
<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="selectCluster(c)" title="View member faces" v-observe="() => { if (c.rep_trace) loadTraceThumb(c.rep_trace) }">
<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 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">
@@ -58,32 +61,43 @@
<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 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>
<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>
</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="openFaceDetailFromTrace(t)" v-observe="() => loadTraceThumb(t)">
<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 class="ms-ppl-face-img-wrap">
<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>
<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>
</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>
@@ -99,7 +113,6 @@
</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' }">
@@ -122,7 +135,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}-${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="">
<img v-if="candidateThumbs[faceDetailModal.candidate?.id]" :src="candidateThumbs[faceDetailModal.candidate?.id]" alt="">
<div v-else class="face-placeholder">{{ Math.round(faceDetailModal.candidate?.confidence * 100 || 0) }}%</div>
</div>
<div class="ms-face-detail-info">
@@ -151,8 +164,8 @@
{{ t('people.face_detail.play_segment') }}
</button>
</div>
<div class="ms-face-detail-pose">
<div v-if="faceDetailPose" class="ms-pose-controls">
<div v-if="faceDetailPose" class="ms-face-detail-pose">
<div 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>
@@ -270,7 +283,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)" v-observe="() => { if (c.rep_trace) loadTraceThumb(c.rep_trace) }">
<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="">
<svg v-else viewBox="0 0 120 120" fill="none" style="width:100%;height:100%;">
@@ -300,7 +313,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)" v-observe="() => { if (c.rep_trace) loadTraceThumb(c.rep_trace) }">
<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="">
<svg v-else viewBox="0 0 120 120" fill="none" style="width:100%;height:100%;">
@@ -455,16 +468,13 @@
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, frameThumbsCache, frameThumbKey, loadFaceThumb, loadTraceThumb, loadFrameThumb, 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, loadFaceThumb, loadTraceThumb, 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'
@@ -486,7 +496,6 @@ 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>('')
@@ -513,7 +522,6 @@ 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: '' })
@@ -625,10 +633,9 @@ 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 []
}
const groups = clusterResults.value.map((c: any, idx: number) => {
return clusterResults.value.map((c: any, idx: number) => {
const traceIds = c.trace_ids || []
const repTraceId = c.representative_trace || traceIds[0]
const traceMap = clusterTracesMap.value
@@ -653,10 +660,6 @@ 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(() => {
@@ -712,8 +715,7 @@ async function saveClusterEdit() {
clusterEditMode.value = false
// Update cluster results cache
const clusters = getClusterResults(selectedFileUuid.value)
const targetId = c.cluster_id ?? c.group_id
const idx = clusters.findIndex((x: any) => (x.cluster_id ?? x.group_id) === targetId)
const idx = clusters.findIndex((x: any) => x.cluster_id === c.cluster_id)
if (idx >= 0) {
clusters[idx].name = name
setClusterResults(selectedFileUuid.value, [...clusters])
@@ -752,7 +754,6 @@ async function refresh() {
if (selectedFileUuid.value) {
unassignedTracesPage.value = 1
unassignedTracesLoaded.value = false
delete clusterResultsCache.value[selectedFileUuid.value]
clusterTracesMap.value = {}
clusterVideoTraces.value = []
@@ -795,17 +796,14 @@ 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()
refreshing.value = false
})
pollClusterResults(selectedFileUuid.value, () => loadClusterResults())
} else {
await loadClusterResults()
refreshing.value = false
loadClusterResults()
}
} else {
unassignedTracesLoaded.value = false
@@ -823,7 +821,6 @@ 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 })
@@ -917,23 +914,6 @@ 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 || []
@@ -997,9 +977,7 @@ onUnmounted(() => {
})
async function loadCandidateThumb(c: any) {
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
if (!c?.file_uuid || candidateThumbs.value[c.id]) return
loadFaceThumb(String(c.id), c.file_uuid, c.frame_number || 0, c.bbox)
}
@@ -1233,32 +1211,22 @@ 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('[bbox] canvas:', canvasWidth, 'x', canvasHeight, '| isNormalized:', isNormalized)
console.log('[redrawOverlay] bbox isNormalized:', isNormalized)
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
}
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
console.log('[bbox] drawing at:', { x, y, w, h })
console.log('[redrawOverlay] drawing bbox at:', x, y, w, h)
ctx.strokeStyle = '#00ffff'
ctx.lineWidth = 3
@@ -1268,19 +1236,22 @@ function redrawOverlay() {
}
if (showPoseOverlay.value && pose?.keypoints && bbox) {
// 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)
})
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)
ctx.fillStyle = '#00ff00'
for (const kp of validKeypoints) {
ctx.beginPath()
@@ -1432,7 +1403,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 = { ...clusterResultsCache.value, [selectedFileUuid.value]: finalClusters }
clusterResultsCache.value[selectedFileUuid.value] = finalClusters
try {
await updateTraceProfileGroup(c.file_uuid, [tid], { name: target.name || `Group ${targetId}` })
@@ -1476,7 +1447,7 @@ async function confirmMoveFaceToNewGroup() {
}
}
}
clusterResultsCache.value = { ...clusterResultsCache.value, [selectedFileUuid.value]: updatedClusters }
clusterResultsCache.value[selectedFileUuid.value] = updatedClusters
try {
await updateTraceProfileGroup(c.file_uuid, [tid], { name })
} catch (e) { console.error('create group and move face failed:', e) }
@@ -1498,12 +1469,12 @@ async function createNewIdentityFromFace() {
fileUuid: c.file_uuid,
traceIds: c.trace_id ? [c.trace_id] : undefined
})
if (result?.identity_uuid) {
if (result?.group_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.identity_uuid.replace(/-/g, '')
const cleanUuid = result.group_uuid.replace(/-/g, '')
router.push(`/people/${cleanUuid}`)
} else {
alert(t('people.detail.create_identity_failed'))
@@ -1561,38 +1532,10 @@ 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)
@@ -1647,7 +1590,7 @@ function removeTracesFromClusterCache(traceIds: number[]) {
}
const validClusters = clusters.filter((c: any) => c.trace_count > 0)
clusterResultsCache.value = { ...clusterResultsCache.value, [selectedFileUuid.value]: validClusters }
clusterResultsCache.value[selectedFileUuid.value] = validClusters
}
async function batchDeleteFaces() {
@@ -1825,10 +1768,11 @@ async function moveToGroup(targetGroup: any) {
alert('Target group has no traces')
return
}
const targetFileUuid = selectedFileUuid.value
const targetTrace = clusterTracesMap.value[String(targetTraceId)]
const targetFileUuid = targetTrace?.file_uuid
if (!targetFileUuid) {
alert('No file selected')
alert('Target trace has no file_uuid')
return
}
@@ -1855,39 +1799,12 @@ async function moveToGroup(targetGroup: any) {
}
}
// 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 }
removeTracesFromCache(selectedFaces.value)
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() {
@@ -1905,27 +1822,17 @@ async function createNewGroupAndMove() {
const firstTrace = traceList[0]
if (!firstTrace) return
// Update trace profiles to assign them to the new group
try {
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}`,
const result: any = await apiCall('create_identity_from_face', {
fileUuid: firstTrace.file_uuid,
name,
traceIds: traceList.map(t => t.trace_id)
})
clusterResultsCache.value = { ...clusterResultsCache.value, [selectedFileUuid.value]: clusters }
if (result?.group_uuid) {
clearSelection()
await refresh()
}
} catch (e) {
console.error('Create new group failed:', e)
alert('Failed to create new group')
@@ -1979,7 +1886,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, fileName: 'QC-Test' }) },
{ name: 'update_file_profile', call: () => apiCall('update_file_profile', { fileUuid, filePath: '', fileName: '' }) },
{ name: 'get_file_keyframe', call: () => apiCall('get_thumbnail', { uuid: fileUuid, frame: 0 }) },
]
@@ -2070,7 +1977,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, writeVerified: false }
const fileProfileResult = { canRead: false, canWrite: false, keyFrame: null as number | null }
try {
const fileProfile: any = await apiCall('get_file_profile', { fileUuid })
fileProfileResult.canRead = !!fileProfile
@@ -2087,15 +1994,12 @@ async function runDataQC() {
if (fileProfileResult.canRead) {
try {
// 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
}
await apiCall('update_file_profile', {
fileUuid,
filePath: '',
fileName: ''
})
fileProfileResult.canWrite = true
} catch (e: any) {
console.warn('[QC] File profile write failed:', e)
}
@@ -2105,7 +2009,7 @@ async function runDataQC() {
check: 'File Profile',
status: fileProfileResult.canRead && fileProfileResult.canWrite ? 'PASS' : 'FAIL',
details: fileProfileResult.canRead && fileProfileResult.canWrite
? `Read/Write OK (verified), Key Frame: ${fileProfileResult.keyFrame ?? 'N/A'}`
? `Read/Write OK, Key Frame: ${fileProfileResult.keyFrame ?? 'N/A'}`
: `Read: ${fileProfileResult.canRead ? '✓' : '✗'}, Write: ${fileProfileResult.canWrite ? '✓' : '✗'}`
})
@@ -2161,38 +2065,24 @@ async function runDataQC() {
traceId
})
result.canRead = !!profile
// 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++
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++
}
} 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,
name: testLabel
label: ''
})
// Verify write
const verify: any = await apiCall('get_trace_profile', {
fileUuid: trace.file_uuid,
traceId
})
if (verify?.name === testLabel) {
result.canWrite = true
}
result.canWrite = true
} catch (e: any) {
result.error = e?.message?.slice(0, 30) || 'write failed'
}
@@ -2310,9 +2200,7 @@ function copyQCReport() {
</script>
<style scoped>
.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; }
.people-view { max-width: 1200px; }
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; }
@@ -2325,11 +2213,6 @@ 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); }
@@ -2344,8 +2227,7 @@ 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 + .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-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.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; }
@@ -2360,10 +2242,9 @@ 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 { 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-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: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; }
@@ -2494,16 +2375,6 @@ 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 */
@@ -2566,11 +2437,6 @@ 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; }