Files
momentry_studio/src/views/PeopleView.vue
Momentry Studio 26b94f7243 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.
2026-08-01 01:49:45 +08:00

2724 lines
128 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="people-view">
<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>
<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="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="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">
<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>
</div>
</div>
<!-- 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>
<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>
</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 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>
</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>
<div class="ms-ppl-face-range">
<span class="ms-ppl-face-frame">F#{{ t.start_frame }}–F#{{ t.end_frame }}</span>
</div>
</div>
</div>
<div v-if="selectedFileUuid && unassignedTracesTotal > 20" class="ms-ppl-pagination">
<button class="ms-fm-icon-btn" :disabled="unassignedTracesPage <= 1" @click="goToUfacePage(unassignedTracesPage - 1)">‹</button>
<span class="ms-ppl-page-info">Page {{ unassignedTracesPage }} / {{ unassignedTotalPages }} ({{ unassignedTracesTotal }} total)</span>
<button class="ms-fm-icon-btn" :disabled="unassignedTracesPage >= unassignedTotalPages" @click="goToUfacePage(unassignedTracesPage + 1)">›</button>
</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' }">
<button class="ms-ctx-item" @click="faceCtxAction('detail')">{{ t('people.face_context.details') }}</button>
</div>
<!-- Cluster Context Menu -->
<div v-if="clusterCtxMenu.show" class="ms-ctx-menu" :style="{ left: clusterCtxMenu.x + 'px', top: clusterCtxMenu.y + 'px', display: 'block' }">
<div class="ms-ctx-filename">{{ clusterCtxMenu.cluster?.name }}</div>
<hr class="ms-ctx-menu-divider">
<button class="ms-ctx-item" @click="clusterCtxAction('rename')">✏️ Edit Name</button>
<hr class="ms-ctx-menu-divider">
<button class="ms-ctx-item ms-ctx-danger" @click="clusterCtxAction('skip')">✕ Skip Group</button>
</div>
<!-- Assign modal -->
<!-- Face detail modal -->
<div v-if="faceDetailModal.show" class="ms-modal-overlay show" :style="{ zIndex: faceDetailZIndex }" @click.self="faceDetailModal.show = false">
<div class="ms-modal ms-modal-face-detail">
<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="">
<div v-else class="face-placeholder">{{ Math.round(faceDetailModal.candidate?.confidence * 100 || 0) }}%</div>
</div>
<div class="ms-face-detail-info">
<div class="ms-face-detail-title">
<span class="ms-face-trace-id">{{ faceDetailModal.candidate?.file_uuid }}:FS#{{ faceDetailModal.candidate?.trace_id }}</span>
</div>
<div class="ms-face-detail-row">
<span class="ms-face-detail-label">{{ t('people.face_detail.frame_number') }}</span>
<span class="ms-face-detail-value">#{{ faceDetailModal.candidate?.frame_number }}</span>
</div>
<div class="ms-face-detail-row">
<span class="ms-face-detail-label">{{ t('people.face_detail.confidence') }}</span>
<span class="ms-face-detail-value">{{ Math.round(faceDetailModal.candidate?.confidence * 100) }}%</span>
</div>
<div class="ms-face-detail-row">
<span class="ms-face-detail-label">{{ t('people.face_detail.face_id') }}</span>
<span class="ms-face-detail-value">{{ faceDetailModal.candidate?.face_id || faceDetailModal.candidate?.id }}</span>
</div>
</div>
</div>
<div class="ms-face-detail-video">
<button class="ms-fm-btn ms-fm-btn-primary" @click="playFaceDetailVideo" :disabled="!faceDetailModal.candidate?.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>
{{ t('people.face_detail.play_segment') }}
</button>
</div>
<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>
</div>
<div v-if="faceDetailModal.candidate?.bbox" class="ms-pose-bbox-info">
BBox: ({{ Math.round(faceDetailModal.candidate.bbox.x) }}, {{ Math.round(faceDetailModal.candidate.bbox.y) }}) {{ Math.round(faceDetailModal.candidate.bbox.width) }}x{{ Math.round(faceDetailModal.candidate.bbox.height) }}
</div>
<div class="ms-pose-frame-container" ref="poseFrameContainer">
<img v-if="faceDetailFrameUrl" :src="faceDetailFrameUrl" class="ms-pose-frame-img" @load="onFrameLoad" alt="Frame">
<canvas ref="poseCanvas" class="ms-pose-overlay-canvas"></canvas>
</div>
</div>
<hr class="ms-face-detail-divider">
<div class="ms-face-detail-move-group">
<div class="ms-face-detail-move-title">Current Group</div>
<div class="ms-face-detail-current-group" :title="traceGroupMap[String(faceDetailModal.candidate?.trace_id)] || getFileName(faceDetailModal.candidate?.file_uuid) || ''">{{ traceGroupMap[String(faceDetailModal.candidate?.trace_id)] || getFileName(faceDetailModal.candidate?.file_uuid) || '—' }}</div>
<div class="ms-face-detail-move-title" style="margin-top: 12px;">Move to Group</div>
<div class="ms-face-detail-move-row">
<select v-model="faceMoveTarget" class="ms-ppl-file-select">
<option value="">— Select group —</option>
<option v-for="g in otherClusters" :key="g.cluster_id" :value="String(g.cluster_id)">{{ g.name }} ({{ g.trace_count }} Faces)</option>
<option value="__new__">+ New Group...</option>
</select>
<button class="ms-fm-btn ms-fm-btn-primary" @click="confirmMoveFace" :disabled="!faceMoveTarget">Move</button>
</div>
<div v-if="faceMoveTarget === '__new__'" class="ms-face-detail-move-new-row">
<input v-model="faceMoveNewGroupName" class="ms-ppl-edit-input" placeholder="New group name..." />
<button class="ms-fm-btn ms-fm-btn-primary" @click="confirmMoveFaceToNewGroup" :disabled="!faceMoveNewGroupName.trim()">Create & Move</button>
</div>
</div>
<div class="ms-face-detail-footer">
<button class="ms-fm-btn" @click="faceDetailModal.show = false">{{ t('people.face_detail.close') }}</button>
</div>
</div>
</div>
<!-- Enlarged face image modal -->
<div v-if="showEnlargedFace && faceDetailModal.candidate" class="ms-modal-overlay show" style="z-index:10000;" @click.self="showEnlargedFace = false">
<div class="ms-enlarged-face-modal">
<button class="ms-modal-video-close" @click="showEnlargedFace = false">&times;</button>
<img v-if="candidateThumbs[faceDetailModal.candidate.id]" :src="candidateThumbs[faceDetailModal.candidate.id]" alt="" style="max-width:90vw;max-height:90vh;object-fit:contain;border-radius:12px;">
</div>
</div>
<!-- Cluster Detail Modal -->
<div v-if="clusterDetailModal.show" class="ms-modal-overlay show" :style="{ zIndex: clusterDetailZIndex }" @click.self="clusterDetailModal.show = false">
<div class="ms-modal ms-modal-cluster-detail">
<button class="ms-fm-icon-btn close-btn" @click="clusterDetailModal.show = false">×</button>
<!-- Header with editable name -->
<div class="ms-cluster-header">
<div class="ms-cluster-title-wrap">
<input v-if="clusterEditMode" v-model="clusterEditName" class="ms-cluster-edit-input" @keyup.enter="saveClusterEdit" @keyup.escape="cancelClusterEdit" ref="clusterEditInput" />
<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">
<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>
Play
</button>
</div>
<div class="ms-cluster-actions">
<button v-if="clusterEditMode" class="ms-fm-btn ms-fm-btn-primary" @click="saveClusterEdit">✓ Save</button>
<button v-if="clusterEditMode" class="ms-fm-btn" @click="cancelClusterEdit">Cancel</button>
</div>
</div>
<!-- Search -->
<div class="ms-cluster-search">
<input v-model="clusterSearchQuery" class="ms-cluster-search-input" placeholder="Search faces..." />
</div>
<!-- Trace strip with pagination -->
<div class="ms-ppl-strip-wrap">
<div class="ms-cluster-strip-toolbar">
<button v-if="!clusterBatchMode" class="ms-fm-btn ms-fm-btn-sm" @click="clusterBatchMode = true">☑ Select</button>
<template v-else>
<button class="ms-fm-btn ms-fm-btn-sm ms-fm-btn-primary" @click="deleteSelectedClusterTraces" :disabled="!selectedClusterTraces.length">🗑 Delete ({{ selectedClusterTraces.length }})</button>
<button class="ms-fm-btn ms-fm-btn-sm" @click="clusterBatchMode = false; selectedClusterTraces = []">✕ Cancel</button>
</template>
</div>
<button class="ms-ppl-strip-arrow" :disabled="clusterStripPage === 1" @click="clusterStripPage--">‹</button>
<div class="ms-ppl-face-strip">
<div v-if="loadingClusterTraces" style="padding:10px;color:#999;font-size:12px;">Loading...</div>
<div v-else-if="filteredClusterTraces.length === 0" style="padding:10px;color:#999;font-size:12px;">No faces</div>
<div v-for="t in paginatedClusterTraces" :key="t.trace_id" class="ms-ppl-strip-face ms-ppl-strip-face-clickable" :class="{ selected: clusterDetailModal.selectedTrace?.trace_id === t.trace_id || isClusterTraceSelected(t) }" @click="clusterBatchMode ? toggleClusterTraceSelection(t) : openFaceDetailFromTrace(t)">
<div v-if="clusterBatchMode" class="ms-ppl-card-checkbox" @click.stop="toggleClusterTraceSelection(t)">
<span v-if="isClusterTraceSelected(t)">✓</span>
</div>
<div class="ms-ppl-strip-face-img">
<img v-if="faceThumbsCache[thumbTraceKey(t)]" :src="faceThumbsCache[thumbTraceKey(t)]" alt="" loading="lazy">
<div v-else style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:#eef2ff;color:#6366f1;font-size:10px;font-weight:600;border-radius:8px;">FS#{{ t.trace_id }}</div>
</div>
<div class="ms-ppl-strip-trace-label">FS#{{ t.trace_id }}<span class="ms-ppl-strip-group-name">{{ clusterDetailModal.cluster?.name }}</span></div>
</div>
</div>
<button class="ms-ppl-strip-arrow" :disabled="clusterStripPage >= clusterStripTotalPages" @click="clusterStripPage++">›</button>
<span v-if="clusterStripTotalPages > 1" class="ms-ppl-strip-page">{{ clusterStripPage }}/{{ clusterStripTotalPages }}</span>
</div>
<div class="ms-cluster-footer">
<button class="ms-fm-btn" @click="clusterDetailModal.show = false">Close</button>
</div>
</div>
</div>
<VideoPlayer v-if="playing" :file-uuid="currentVideo.fileUuid" :start-frame="currentVideo.startFrame" :end-frame="currentVideo.endFrame" :all-traces="clusterVideoTraces" :merged-segments="clusterVideoSegments" :title="currentVideo.title" :show-face="true" :show-asrx="false" :show-ocr="false" :show-pose="false" @close="playing = false; clusterVideoTraces = []; clusterVideoSegments = []" />
<!-- Merge Target Selection Modal -->
<div v-if="showMergeTargetModal" class="ms-modal-overlay show" @click.self="showMergeTargetModal = false">
<div class="ms-modal ms-modal-merge">
<button class="ms-fm-icon-btn close-btn" @click="showMergeTargetModal = false">×</button>
<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 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%;">
<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>
</div>
<span class="ms-merge-face-name">{{ c.name }}</span>
<span class="ms-merge-face-count">{{ c.trace_count }} faces</span>
</div>
</div>
</div>
</div>
<!-- Move Faces Modal -->
<div v-if="showMoveFacesModal" class="ms-modal-overlay show" @click.self="showMoveFacesModal = false">
<div class="ms-modal ms-modal-merge">
<button class="ms-fm-icon-btn close-btn" @click="showMoveFacesModal = false">×</button>
<h2 class="ms-ppl-section-title">Move {{ selectedFaces.length }} Trace(s)</h2>
<p class="ms-merge-hint">Choose a target group or create a new one.</p>
<div class="ms-move-new-group" @click="createNewGroupAndMove">
<span class="ms-move-new-icon">+</span>
<span class="ms-move-new-label">New Group</span>
</div>
<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 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%;">
<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>
</div>
<span class="ms-merge-face-name">{{ c.name || 'Unnamed Group' }}</span>
<span class="ms-merge-face-count">{{ c.trace_count || 0 }} faces</span>
</div>
</div>
</div>
</div>
<!-- QC Modal -->
<div v-if="showQCModal" class="ms-modal-overlay show">
<div class="ms-modal ms-modal-qc">
<button class="ms-fm-icon-btn close-btn" @click="showQCModal = false">×</button>
<div class="ms-qc-header">
<h2>🔍 Data Quality Check</h2>
<div class="ms-qc-file-info">
<span class="ms-qc-file-name">{{ getFileName(selectedFileUuid) }}</span>
<span class="ms-qc-file-uuid">{{ selectedFileUuid?.slice(0, 8) }}...</span>
</div>
</div>
<!-- API Tests Section -->
<div v-if="qcApiTests.length > 0" class="ms-qc-api-section">
<h3>📡 API Connectivity</h3>
<div class="ms-qc-api-category">
<div class="ms-qc-api-cat-name">General</div>
<div class="ms-qc-api-grid">
<div v-for="api in qcApiTests.filter(a => ['get_files', 'get_file_info', 'get_unassigned_traces'].includes(a.name))" :key="api.name" class="ms-qc-api-item" :class="{ fail: !api.status }">
<span class="ms-qc-api-status">{{ api.status ? '✓' : '✗' }}</span>
<span class="ms-qc-api-name">{{ api.name }}</span>
</div>
</div>
</div>
<div class="ms-qc-api-category">
<div class="ms-qc-api-cat-name">File Profile</div>
<div class="ms-qc-api-grid">
<div v-for="api in qcApiTests.filter(a => ['get_file_profile', 'update_file_profile', 'get_file_keyframe'].includes(a.name))" :key="api.name" class="ms-qc-api-item" :class="{ fail: !api.status }">
<span class="ms-qc-api-status">{{ api.status ? '✓' : '✗' }}</span>
<span class="ms-qc-api-name">{{ api.name }}</span>
</div>
</div>
</div>
<div class="ms-qc-api-category">
<div class="ms-qc-api-cat-name">Trace Profile</div>
<div class="ms-qc-api-grid">
<div v-for="api in qcApiTests.filter(a => ['get_trace_profile', 'update_trace_profile'].includes(a.name))" :key="api.name" class="ms-qc-api-item" :class="{ fail: !api.status }">
<span class="ms-qc-api-status">{{ api.status ? '✓' : '✗' }}</span>
<span class="ms-qc-api-name">{{ api.name }}</span>
</div>
</div>
</div>
</div>
<!-- API Blocked Warning -->
<div v-if="qcApiBlocked" class="ms-qc-blocked">
<div class="ms-qc-blocked-icon">⛔</div>
<div class="ms-qc-blocked-title">API Test Failed</div>
<div class="ms-qc-blocked-desc">Data QC is blocked. Please check API connectivity.</div>
</div>
<!-- Data QC Section (only show if API OK) -->
<template v-if="!qcApiBlocked">
<!-- File Key Frame -->
<div v-if="qcFileProfile?.key_frame_url" class="ms-qc-keyframe">
<div class="ms-qc-keyframe-label">File Key Frame #{{ qcFileProfile.key_frame }}</div>
<img :src="qcFileProfile.key_frame_url" alt="Key Frame" class="ms-qc-keyframe-img" />
</div>
<div class="ms-qc-stats">
<div class="ms-qc-stat">
<span class="ms-qc-stat-value">{{ qcProgress.total }}</span>
<span class="ms-qc-stat-label">FS</span>
</div>
<div class="ms-qc-stat">
<span class="ms-qc-stat-value">{{ qcProgress.current }}</span>
<span class="ms-qc-stat-label">Tested</span>
</div>
<div class="ms-qc-stat">
<span class="ms-qc-stat-value ms-qc-pass">{{ qcReport.filter(r => r.status === 'PASS').length }}</span>
<span class="ms-qc-stat-label">Passed</span>
</div>
<div class="ms-qc-stat">
<span class="ms-qc-stat-value ms-qc-fail">{{ qcReport.filter(r => r.status === 'FAIL').length }}</span>
<span class="ms-qc-stat-label">Failed</span>
</div>
</div>
<!-- Trace Key Frame/Face Summary -->
<div v-if="qcTraceSummary.total > 0" class="ms-qc-trace-summary">
<div class="ms-qc-trace-stat">
<span class="ms-qc-trace-stat-label">Key Frame</span>
<span class="ms-qc-trace-stat-value">{{ qcTraceSummary.withKeyFrame }}/{{ qcTraceSummary.total }}</span>
</div>
<div class="ms-qc-trace-stat">
<span class="ms-qc-trace-stat-label">Key Face</span>
<span class="ms-qc-trace-stat-value">{{ qcTraceSummary.withKeyFace }}/{{ qcTraceSummary.total }}</span>
</div>
</div>
<div class="ms-qc-progress-section">
<div class="ms-qc-status">{{ qcProgress.status }}</div>
<div class="ms-qc-bar">
<div class="ms-qc-bar-fill" :style="{ width: qcProgress.total > 0 ? (qcProgress.current / qcProgress.total * 100) + '%' : '0%' }"></div>
</div>
</div>
<div class="ms-qc-report">
<div v-for="(r, i) in qcReport" :key="i" class="ms-qc-item" :class="'ms-qc-' + r.status.toLowerCase()">
<span class="ms-qc-icon">{{ r.status === 'PASS' ? '✓' : r.status === 'FAIL' ? '✗' : '⚠' }}</span>
<div class="ms-qc-content">
<span class="ms-qc-check">{{ r.check }}</span>
<span class="ms-qc-details">{{ r.details }}</span>
</div>
</div>
</div>
<div v-if="qcProfileIssues.length > 0" class="ms-qc-issues">
<h3>⚠️ Profile Issues ({{ qcProfileIssues.length }})</h3>
<div class="ms-qc-issues-list">
<div v-for="(p, i) in qcProfileIssues.slice(0, 10)" :key="i" class="ms-qc-issue-item">
<span class="ms-qc-issue-trace">FS#{{ p.traceId }}</span>
<span class="ms-qc-issue-group">{{ p.groupName }}</span>
<span class="ms-qc-issue-status" :class="{ fail: !p.canRead }">{{ p.canRead ? '✓ R' : '✗ R' }}</span>
<span class="ms-qc-issue-status" :class="{ fail: !p.canWrite }">{{ p.canWrite ? '✓ W' : '✗ W' }}</span>
<span class="ms-qc-issue-status" :class="{ warn: !p.hasKeyFrame }">{{ p.hasKeyFrame ? 'KF' : '-' }}</span>
<span class="ms-qc-issue-status" :class="{ warn: !p.hasKeyFace }">{{ p.hasKeyFace ? 'KF' : '-' }}</span>
</div>
<div v-if="qcProfileIssues.length > 10" class="ms-qc-more">... +{{ qcProfileIssues.length - 10 }} more issues</div>
</div>
</div>
</template>
<div v-if="qcProgress.status === 'QC Complete' || qcApiBlocked" class="ms-qc-footer">
<button class="ms-fm-btn ms-fm-btn-sm" @click="copyQCReport">📋 Copy Report</button>
<button class="ms-fm-btn ms-fm-btn-sm" @click="showQCModal = false">Close</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
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 { 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'
const faceDetailZIndex = ref(10010)
const clusterDetailZIndex = ref(10000)
const refreshing = ref(false)
const loadingStep = ref('')
const loading = computed(() => refreshing.value)
const faces = ref<any[]>([])
const traces = ref<any[]>([])
const playing = ref(false)
const currentVideo = ref({ fileUuid: '', startFrame: 0, endFrame: 0, title: '' })
const candidateThumbs = faceThumbsCache
const activeFrameCard = ref('')
const activeFrameMode = ref('')
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>('')
const faceDetailVideoSize = ref<{ width: number; height: number } | null>(null)
const faceDetailScale = ref<{ x: number; y: number; offsetX: number; offsetY: number }>({ x: 1, y: 1, offsetX: 0, offsetY: 0 })
const showEnlargedFace = ref(false)
const showBboxOverlay = ref(true)
const showPoseOverlay = ref(true)
const showAppearanceOverlay = ref(true)
const selectedFaces = ref<string[]>([])
const selectedGroups = ref<string[]>([])
const batchMode = ref(false)
const batchAction = ref<'delete' | 'merge' | 'move' | ''>('')
const showMergeTargetModal = ref(false)
const mergeCandidateGroups = ref<any[]>([])
const showMoveFacesModal = ref(false)
const lastDeletedTraces = ref<{ file_uuid: string; trace_id: number }[]>([])
const showUndoBanner = ref(false)
let undoBannerTimer: ReturnType<typeof setTimeout> | null = null
const clusterBatchMode = ref(false)
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: '' })
const qcReport = ref<{ check: string; status: string; details: string }[]>([])
const qcProfileIssues = ref<{ traceId: number; fileUuid: string; groupName: string; canRead: boolean; canWrite: boolean; hasKeyFrame?: boolean; hasKeyFace?: boolean; error?: string }[]>([])
const qcApiTests = ref<{ name: string; status: boolean; error?: string }[]>([])
const qcApiBlocked = ref(false)
const qcFileProfile = ref<{ key_frame?: number; key_frame_url?: string } | null>(null)
const qcTraceSummary = ref({ total: 0, withKeyFrame: 0, withKeyFace: 0, missingBoth: 0 })
function isFaceSelected(t: any): boolean {
return selectedFaces.value.includes(`${t.file_uuid}:${t.trace_id}`)
}
function isGroupSelected(c: any): boolean {
return selectedGroups.value.includes(c.group_uuid)
}
function isClusterTraceSelected(t: any): boolean {
return selectedClusterTraces.value.includes(t.trace_id)
}
async function toggleFrameView(t: any, mode: 'frame' | 'bbox') {
const key = `${t.trace_id}-${t.file_uuid}`
if (activeFrameCard.value === key && activeFrameMode.value === mode) {
activeFrameCard.value = ''
activeFrameMode.value = ''
return
}
activeFrameCard.value = key
activeFrameMode.value = mode
if (!fileInfoCache.value[t.file_uuid] && t.file_uuid) {
try {
const info: any = await apiCall('get_file_info', { uuid: t.file_uuid })
fileInfoCache.value[t.file_uuid] = { width: info.width, height: info.height }
} catch { /* ignore */ }
}
}
function goToUfacePage(page: number) {
if (page < 1) return
ensureUnassignedTraces(selectedFileUuid.value, page)
for (const t of unassignedTracesCache.value.slice(0, 20)) {
if (t.file_uuid) loadTraceThumb(t)
}
}
function thumbTraceKey(t: any): string {
if (!t) return ''
return `${t.trace_id || 0}-${t.file_uuid || ''}-${t.best_face_id || 0}`
}
function getFileName(fileUuid: string): string {
if (!fileUuid) return ''
const f = filesCache.value.find((f: any) => f.file_uuid?.replace(/-/g, '') === fileUuid)
return f?.file_name || fileUuid
}
// File filter
const selectedFileUuid = ref('')
const unassignedTraces = computed(() => {
const all = unassignedTracesCache.value
try {
return [...all].sort((a: any, b: any) => {
const an = getFileName(a.file_uuid) || ''
const bn = getFileName(b.file_uuid) || ''
if (an !== bn) return an.localeCompare(bn)
return (a.trace_id || 0) - (b.trace_id || 0)
})
} catch (e) {
console.error('unassignedTraces sort failed:', e)
return [...all]
}
})
const unassignedTotalPages = computed(() => {
return Math.ceil(unassignedTracesTotal.value / 20) || 1
})
const faceCtxMenu = ref({ show: false, x: 0, y: 0, candidate: null as any })
const clusterCtxMenu = ref({ show: false, x: 0, y: 0, cluster: null as any })
const faceDetailModal = ref({ show: false, candidate: null as any, newIdentityName: '' })
const clusterDetailModal = ref({ show: false, cluster: null as any, selectedTrace: null as any })
const clusterVideoTraces = ref<any[]>([])
const clusterVideoSegments = ref<any[]>([])
const clusterStripPage = ref(1)
const clusterStripPerPage = 20
const clusterEditMode = ref(false)
const clusterEditName = ref('')
const clusterSearchQuery = ref('')
const fileFilterList = computed(() => {
const files = filesCache.value
return files.filter((f: any) => f.status === 'completed').map((f: any) => ({
file_uuid: f.file_uuid?.replace(/-/g, ''),
file_name: f.file_name
}))
})
// Section visibility toggles
const showCluster = ref(true)
const showUface = ref(true)
const starFilter = ref(false)
const faceSourceClusterId = ref<number | null>(null)
const faceMoveTarget = ref('')
const faceMoveNewGroupName = ref('')
// Cluster results (synced with store for cross-navigation persistence)
const clusterResults = computed(() => clusterResultsCache.value[selectedFileUuid.value] || [])
const clusterTracesMap = ref<Record<string, any>>({})
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) => {
const traceIds = c.trace_ids || []
const repTraceId = c.representative_trace || traceIds[0]
const traceMap = clusterTracesMap.value
let repTrace: any = null
if (traceMap[String(repTraceId)]) {
repTrace = traceMap[String(repTraceId)]
} else {
for (const tid of traceIds) {
if (traceMap[String(tid)]) { repTrace = traceMap[String(tid)]; break }
}
}
const defaultName = `group ${String(idx + 1).padStart(2, '0')}`
if (!c.name) c.name = defaultName
return {
group_uuid: `group_${c.cluster_id ?? c.group_id}`,
group_id: c.cluster_id ?? c.group_id,
name: c.name,
trace_count: c.trace_count,
representative_trace: repTraceId,
cluster_id: c.cluster_id ?? c.group_id,
trace_ids: traceIds,
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(() => {
if (faceSourceClusterId.value === null) return clusterResults.value
return clusterResults.value.filter((c: any) => (c.cluster_id ?? c.group_id) !== faceSourceClusterId.value)
})
// Map trace_id → group name for Faces section
const traceGroupMap = computed(() => {
const map: Record<string, string> = {}
for (const c of clusterResults.value) {
for (const tid of c.trace_ids || []) {
map[String(tid)] = c.name
}
}
return map
})
// Cluster detail modal computed
const loadingClusterTraces = ref(false)
const filteredClusterTraces = computed(() => {
const c = clusterDetailModal.value.cluster
if (!c) return []
const traceIds = c.trace_ids || []
const traces = traceIds.map((tid: any) => clusterTracesMap.value[String(tid)]).filter((t: any) => t)
if (!clusterSearchQuery.value) return traces
const q = clusterSearchQuery.value.toLowerCase()
return traces.filter((t: any) => String(t.trace_id).includes(q))
})
const clusterStripTotalPages = computed(() => Math.ceil(filteredClusterTraces.value.length / clusterStripPerPage) || 1)
const paginatedClusterTraces = computed(() => {
const start = (clusterStripPage.value - 1) * clusterStripPerPage
return filteredClusterTraces.value.slice(start, start + clusterStripPerPage)
})
const clusterEditInput = ref<HTMLInputElement | null>(null)
function startClusterEdit() {
clusterEditMode.value = true
clusterEditName.value = clusterDetailModal.value.cluster?.tmdb_name || clusterDetailModal.value.cluster?.name || ''
setTimeout(() => clusterEditInput.value?.focus(), 50)
}
async function saveClusterEdit() {
const c = clusterDetailModal.value.cluster
if (!c) return
const name = clusterEditName.value.trim()
if (!name) { clusterEditMode.value = false; return }
c.name = name
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)
if (idx >= 0) {
clusters[idx].name = name
setClusterResults(selectedFileUuid.value, [...clusters])
}
// Update trace profiles via API
const traceIds = c.trace_ids || []
if (traceIds.length && selectedFileUuid.value) {
try {
await updateTraceProfileGroup(selectedFileUuid.value, traceIds, { name })
} catch (e) { console.error('save cluster edit failed:', e) }
}
}
function cancelClusterEdit() {
clusterEditMode.value = false
}
async function refresh() {
refreshing.value = true
loadingStep.value = 'Loading files...'
await ensureFiles()
const processingFiles = filesCache.value.filter((f: any) => f.status === 'processing')
if (processingFiles.length > 0) {
try {
loadingStep.value = 'Syncing file status...'
await syncAllProcessingFiles()
} catch (e) {
console.error('syncAllProcessingFiles error:', e)
}
}
refreshing.value = false
loadingStep.value = ''
if (selectedFileUuid.value) {
unassignedTracesPage.value = 1
unassignedTracesLoaded.value = false
delete clusterResultsCache.value[selectedFileUuid.value]
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
if (isClusterRunning(selectedFileUuid.value)) {
pollClusterResults(selectedFileUuid.value, () => loadClusterResults())
} else {
loadClusterResults()
}
} else {
unassignedTracesLoaded.value = false
unassignedTracesCache.value = []
delete clusterResultsCache.value[selectedFileUuid.value]
clusterTracesMap.value = {}
clusterVideoTraces.value = []
clusterVideoSegments.value = []
}
loadingStep.value = ''
}
async function onFileFilterChange() {
refreshing.value = true
if (selectedFileUuid.value) {
unassignedTracesLoaded.value = false
unassignedTracesPage.value = 1
delete clusterResultsCache.value[selectedFileUuid.value]
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 = ''
// Resume polling if cluster was running for this file
if (isClusterRunning(selectedFileUuid.value)) {
pollClusterResults(selectedFileUuid.value, () => {
loadClusterResults()
refreshing.value = false
})
} else {
await loadClusterResults()
refreshing.value = false
}
} else {
unassignedTracesLoaded.value = false
unassignedTracesCache.value = []
unassignedTracesTotal.value = 0
unassignedTracesPage.value = 1
delete clusterResultsCache.value[selectedFileUuid.value]
clusterTracesMap.value = {}
clusterVideoTraces.value = []
clusterVideoSegments.value = []
refreshing.value = false
loadingStep.value = ''
}
}
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 })
if (result.success) {
// Poll for results
for (let i = 0; i < 120; i++) {
await new Promise(r => setTimeout(r, 3000))
try {
const results: any = await apiCall('get_cluster_results', { fileHash: selectedFileUuid.value })
const groups = results?.face_groups || results?.clusters || []
if (groups.length) {
await loadClusterResults()
return
}
} catch { /* not ready */ }
}
}
} catch (e) {
console.error('Cluster agent failed:', e)
} finally {
setClusterRunning(selectedFileUuid.value, false)
}
}
async function loadClusterResults() {
if (!selectedFileUuid.value) {
console.log('[cluster] no file selected')
return
}
const fileUuid = selectedFileUuid.value
try {
console.log('[cluster] loading for', fileUuid)
const results: any = await apiCall('get_cluster_results', { fileHash: fileUuid })
console.log('[cluster] api returned:', results)
console.log('[cluster] results.face_groups:', results?.face_groups)
console.log('[cluster] results.clusters:', results?.clusters)
if (selectedFileUuid.value !== fileUuid) {
console.log('[cluster] file changed during load')
return
}
const groups = results?.face_groups || results?.clusters || []
const frames = results?.frames || []
// 統一 group_id → cluster_id
for (const g of groups) {
if (g.group_id !== undefined && g.cluster_id === undefined) {
g.cluster_id = g.group_id
}
}
// Build cluster_id → faces[] mapping from frames
const clusterFacesMap: Record<string, any[]> = {}
for (const f of frames) {
for (const face of (f.faces || [])) {
const cid = face.cluster_id
if (!cid) continue
if (!clusterFacesMap[cid]) clusterFacesMap[cid] = []
clusterFacesMap[cid].push({
face_id: face.face_id,
frame: f.frame,
timestamp: f.timestamp,
confidence: face.confidence,
cluster_id: cid
})
}
}
// Assign faces to each cluster
for (const c of groups) {
const cid = c.cluster_id || c.group_id
c.member_faces = clusterFacesMap[cid] || []
c.face_count = c.member_faces.length
}
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')
// Build trace map for representative thumbnails
const allTraces: any[] = []
for (let page = 1; page <= 10; page++) {
const resp: any = await apiCall('get_unassigned_traces', { fileUuid, page, perPage: 100 })
const batch = resp.traces || []
allTraces.push(...batch)
if (batch.length < 100) break
}
if (selectedFileUuid.value !== fileUuid) return
clusterTracesMap.value = {}
for (const t of allTraces) {
if (!t.file_uuid) {
console.warn('[cluster] trace missing file_uuid:', t.trace_id, t)
}
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 || []
const repId = c.representative_trace || traceIds[0]
const rep = clusterTracesMap.value[String(repId)]
if (rep?.file_uuid) loadTraceThumb(rep)
}
// Load TMDB identity matches and assign to clusters
try {
const identityResults: any = await apiCall('get_identity_matches', { fileHash: fileUuid })
if (identityResults?.suggestions) {
const suggestions = identityResults.suggestions as Record<string, any>
for (const c of groups) {
const traceIds = c.trace_ids || []
// Count identity matches in this cluster
const identityCounts: Record<string, { name: string; uuid: string; count: number }> = {}
for (const tid of traceIds) {
const s = suggestions[String(tid)]
if (s) {
const key = s.group_uuid
if (!identityCounts[key]) {
identityCounts[key] = { name: s.name, uuid: s.group_uuid, count: 0 }
}
identityCounts[key].count++
}
}
// Assign the most common identity name
const best = Object.values(identityCounts).sort((a, b) => b.count - a.count)[0]
if (best) {
c.tmdb_name = best.name
c.tmdb_uuid = best.uuid
c.tmdb_count = best.count
}
}
}
} catch { /* no identity matches available */ }
}
} catch (e) {
console.error('[cluster] loadClusterResults failed:', e)
}
console.log('[cluster] cluster results length:', getClusterResults(fileUuid).length)
}
onMounted(async () => {
await refresh()
// Resume cluster polling if a run was in progress when user navigated away
if (selectedFileUuid.value && isClusterRunning(selectedFileUuid.value)) {
console.log('[cluster] resuming poll for', selectedFileUuid.value)
await pollClusterResults(selectedFileUuid.value, async (results) => {
await loadClusterResults()
})
}
document.addEventListener('click', closeFaceCtxMenu)
document.addEventListener('click', closeClusterCtxMenu)
})
onUnmounted(() => {
document.removeEventListener('click', closeFaceCtxMenu)
document.removeEventListener('click', closeClusterCtxMenu)
})
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
loadFaceThumb(String(c.id), c.file_uuid, c.frame_number || 0, c.bbox)
}
function selectCluster(c: any) {
clusterDetailZIndex.value = getNextZIndex()
clusterDetailModal.value = { show: true, cluster: c, selectedTrace: null }
clusterStripPage.value = 1
clusterSearchQuery.value = ''
}
async function playClusterVideo() {
const c = clusterDetailModal.value.cluster
if (!c?.rep_trace?.file_uuid) return
const trace = c.rep_trace
const fileUuid = trace.file_uuid
// Ensure traces are loaded
const firstTid = c.trace_ids?.[0]
if (firstTid && !clusterTracesMap.value[String(firstTid)]) {
await loadClusterResults()
}
try {
const fpsResult: any = await apiCall('get_file_info', { uuid: fileUuid })
const fps = fpsResult?.fps || 24
// Build traces array for timeline
const clusterTraces = (c.trace_ids || []).map((tid: any) => clusterTracesMap.value[String(tid)]).filter((t: any) => t && t.file_uuid)
const allClusterTraces = clusterTraces.map((t: any) => ({
trace_id: t.trace_id,
start_frame: t.start_frame || 0,
end_frame: t.end_frame || (t.start_frame || 0) + (t.frame_count || 1),
frame_count: t.frame_count || 1,
first_frame: t.start_frame || 0,
last_frame: t.end_frame || (t.start_frame || 0) + (t.frame_count || 1),
first_sec: (t.start_frame || 0) / fps,
last_sec: (t.end_frame || (t.start_frame || 0) + (t.frame_count || 1)) / fps,
best_face_id: t.best_face_id,
best_face_frame: t.best_face_frame,
best_face_confidence: t.best_face_confidence,
best_face_bbox: t.best_face_bbox,
file_uuid: t.file_uuid,
})).sort((a: any, b: any) => a.start_frame - b.start_frame)
// Compute merged segments (gap < 30 sec)
const merged: any[] = []
let cur: any = null
allClusterTraces.forEach((item: any, idx: number) => {
const st = item.first_sec || 0
const en = item.last_sec || 0
const fu = item.file_uuid || ''
if (cur && fu === cur.file_uuid && (st - cur.end) < 10) {
cur.end = Math.max(cur.end, en)
cur.end_frame = Math.max(cur.end_frame, item.last_frame || 0)
cur.count++
cur._endIdx = idx
} else {
if (cur) merged.push(cur)
cur = {
file_uuid: fu,
start_frame: item.first_frame || 0,
end_frame: item.last_frame || 0,
start: st,
end: en,
count: 1,
_startIdx: idx,
_endIdx: idx,
}
}
})
if (cur) merged.push(cur)
const firstTrace = allClusterTraces[0]
const lastTrace = allClusterTraces[allClusterTraces.length - 1]
currentVideo.value = {
fileUuid,
startFrame: firstTrace ? firstTrace.start_frame : 0,
endFrame: lastTrace ? lastTrace.end_frame : 30,
title: `${getFileName(fileUuid)} - ${c.name}`,
}
clusterVideoTraces.value = allClusterTraces
clusterVideoSegments.value = merged
playing.value = true
} catch (e) {
console.error('Failed to get file info:', e)
}
}
async function playClusterTraceVideo(trace: any) {
if (!trace?.file_uuid) return
const fileUuid = trace.file_uuid
try {
const fpsResult: any = await apiCall('get_file_info', { uuid: fileUuid })
const fps = fpsResult?.fps || 24
const startFrame = trace.start_frame || 0
const endFrame = trace.end_frame || startFrame + (trace.frame_count || 30)
currentVideo.value = {
fileUuid,
startFrame: startFrame,
endFrame: endFrame,
title: `${getFileName(fileUuid)} - F${trace.trace_id}`,
}
clusterVideoTraces.value = [{
trace_id: trace.trace_id,
start_frame: startFrame,
end_frame: endFrame,
first_sec: startFrame / fps,
last_sec: endFrame / fps,
first_frame: startFrame,
last_frame: endFrame,
file_uuid: fileUuid,
}]
clusterVideoSegments.value = []
playing.value = true
} catch (e) {
console.error('Failed to get file info:', e)
}
}
async function assignClusterTrace(trace: any) {
const candidateLike = {
id: trace.trace_id,
file_uuid: trace.file_uuid,
frame_number: trace.best_face_frame || trace.start_frame,
confidence: trace.best_face_confidence,
bbox: trace.best_face_bbox,
trace_id: trace.trace_id,
frame_count: trace.frame_count,
best_face_id: trace.best_face_id,
}
}
function playTrace(t: any) {
currentVideo.value = {
fileUuid: t.file_uuid,
startFrame: t.first_frame || 0,
endFrame: t.last_frame || (t.first_frame || 0) + 30,
title: `F${t.first_frame || 0}-F${t.last_frame || 0} (${formatTime(t.first_sec || 0)}-${formatTime(t.last_sec || 0)})`
}
playing.value = true
}
function formatTime(sec: number): string {
const m = Math.floor(sec / 60); const s = Math.floor(sec % 60)
return `${m}:${s.toString().padStart(2, '0')}`
}
async function openFaceDetailFromTrace(t: any) {
faceDetailZIndex.value = getNextZIndex()
const candidate = {
id: t.trace_id,
file_uuid: t.file_uuid,
frame_number: t.best_face_frame || t.start_frame,
confidence: t.best_face_confidence,
bbox: t.best_face_bbox,
trace_id: t.trace_id,
frame_count: t.frame_count,
best_face_id: t.best_face_id,
}
faceDetailModal.value = { show: true, candidate, newIdentityName: '' }
faceSourceClusterId.value = clusterDetailModal.value.cluster?.cluster_id ?? null
faceMoveTarget.value = ''
faceMoveNewGroupName.value = ''
loadCandidateThumb(candidate)
faceDetailPose.value = null
faceDetailAppearance.value = null
faceDetailFrameUrl.value = ''
faceDetailVideoSize.value = null
const frame = t.best_face_frame || t.start_frame
const bbox = t.best_face_bbox
if (t.file_uuid && frame) {
faceDetailFrameUrl.value = `/api/v1/media/frame?file_uuid=${t.file_uuid}&frame=${frame}`
try {
const fileInfo: any = await apiCall('get_file_info', { uuid: t.file_uuid })
const videoWidth = fileInfo?.width || 1920
const videoHeight = fileInfo?.height || 1080
faceDetailVideoSize.value = { width: videoWidth, height: videoHeight }
console.log('[pose] Video size:', videoWidth, 'x', videoHeight)
console.log('[pose] BBox:', bbox)
console.log('[pose] Loading for file:', t.file_uuid, 'frame:', frame)
const [pose, appearance] = await Promise.all([
getPose(t.file_uuid, frame, bbox),
getAppearance(t.file_uuid, frame, bbox)
])
console.log('[pose] Response:', pose)
console.log('[appearance] Response:', appearance)
faceDetailPose.value = pose
faceDetailAppearance.value = appearance
} catch (e) {
console.error('Failed to load pose/appearance:', e)
}
}
}
function onFrameLoad(e: Event) {
const img = e.target as HTMLImageElement
const canvas = poseCanvas.value
if (!canvas) return
const container = poseFrameContainer.value
if (!container) return
const imgRect = img.getBoundingClientRect()
const containerRect = container.getBoundingClientRect()
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
canvas.style.width = imgRect.width + 'px'
canvas.style.height = imgRect.height + 'px'
canvas.style.left = (imgRect.left - containerRect.left) + 'px'
canvas.style.top = (imgRect.top - containerRect.top) + 'px'
redrawOverlay()
}
function redrawOverlay() {
const canvas = poseCanvas.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.clearRect(0, 0, canvas.width, canvas.height)
const bbox = faceDetailModal.value.candidate?.bbox
const pose = faceDetailPose.value
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)
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('[bbox] drawing at:', { x, y, w, h })
ctx.strokeStyle = '#00ffff'
ctx.lineWidth = 3
ctx.strokeRect(x, y, w, h)
ctx.fillStyle = 'rgba(0, 255, 255, 0.1)'
ctx.fillRect(x, y, w, h)
}
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)
})
ctx.fillStyle = '#00ff00'
for (const kp of validKeypoints) {
ctx.beginPath()
ctx.arc(kp.x, kp.y, 6, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = '#fff'
ctx.font = '10px sans-serif'
ctx.fillText(kp.name, kp.x + 8, kp.y + 4)
ctx.fillStyle = '#00ff00'
}
}
}
watch([showBboxOverlay, showPoseOverlay], () => {
redrawOverlay()
})
function formatHistoryTime(ts: string): string {
if (!ts) return ''
const d = new Date(ts)
const now = new Date()
const diff = now.getTime() - d.getTime()
if (diff < 60000) return '剛剛'
if (diff < 3600000) return `${Math.floor(diff / 60000)}分鐘前`
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小時前`
return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours()}:${d.getMinutes().toString().padStart(2, '0')}`
}
function showFaceCtxMenu(e: MouseEvent, c: any) {
e.preventDefault()
e.stopPropagation()
faceCtxMenu.value = { show: true, x: e.clientX, y: e.clientY, candidate: c }
}
function closeFaceCtxMenu(e?: MouseEvent) {
if (e && e.target instanceof Element && e.target.closest('.ms-ctx-menu')) return
faceCtxMenu.value.show = false
}
function faceCtxAction(action: string) {
const c = faceCtxMenu.value.candidate
if (!c) return
faceCtxMenu.value.show = false
if (action === 'detail') {
faceDetailModal.value = { show: true, candidate: c, newIdentityName: '' }
loadCandidateThumb(c)
}
}
// Cluster context menu
function showClusterCtxMenu(e: MouseEvent, c: any) {
e.preventDefault()
e.stopPropagation()
clusterCtxMenu.value = { show: true, x: e.clientX, y: e.clientY, cluster: c }
}
function closeClusterCtxMenu(e?: MouseEvent) {
if (e && e.target instanceof Element && e.target.closest('.ms-ctx-menu')) return
clusterCtxMenu.value.show = false
}
function clusterCtxAction(action: string) {
const c = clusterCtxMenu.value.cluster
if (!c) return
clusterCtxMenu.value.show = false
if (action === 'rename') {
const newName = prompt('Edit group name:', c.name)
if (newName && newName.trim()) {
const name = newName.trim()
const clusters = getClusterResults(selectedFileUuid.value)
const idx = clusters.findIndex((x: any) => x.cluster_id === c.cluster_id)
if (idx >= 0) {
clusters[idx].name = name
setClusterResults(selectedFileUuid.value, [...clusters])
}
// Update trace profiles via API
const traceIds = clusters[idx]?.trace_ids || []
if (traceIds.length && selectedFileUuid.value) {
updateTraceProfileGroup(selectedFileUuid.value, traceIds, { name })
}
}
} else if (action === 'skip') {
const clusters = getClusterResults(selectedFileUuid.value)
const idx = clusters.findIndex((x: any) => x.cluster_id === c.cluster_id)
if (idx >= 0) {
clusters.splice(idx, 1)
setClusterResults(selectedFileUuid.value, [...clusters])
}
}
}
async function playFaceDetailVideo() {
const c = faceDetailModal.value.candidate
if (!c || !c.file_uuid) return
const frame = c.frame_number || 0
const defaultFps = 30
try {
const fileInfo: any = await apiCall('get_file_info', { uuid: c.file_uuid })
const fps = fileInfo?.fps || defaultFps
const frameTime = frame / fps
const startTime = Math.max(0, frameTime - 5)
const endTime = frameTime + 5
currentVideo.value = {
fileUuid: c.file_uuid,
startFrame: Math.round(startTime * fps),
endFrame: Math.round(endTime * fps),
title: `F${Math.round(startTime * fps)}-F${Math.round(endTime * fps)} (${c.name})`
}
playing.value = true
} catch (e) {
console.error('Failed to get file info:', e)
const frameTime = frame / defaultFps
currentVideo.value = {
fileUuid: c.file_uuid,
startFrame: Math.round(Math.max(0, frameTime - 5) * defaultFps),
endFrame: Math.round((frameTime + 5) * defaultFps),
title: `F${Math.round(Math.max(0, frameTime - 5) * defaultFps)}-F${Math.round((frameTime + 5) * defaultFps)}`
}
playing.value = true
}
}
async function confirmMoveFace() {
const c = faceDetailModal.value.candidate
const targetId = faceMoveTarget.value
if (!c || !targetId || targetId === '__new__') return
const tid = Number(c.trace_id)
const sourceId = faceSourceClusterId.value
const currentClusters = clusterResultsCache.value[selectedFileUuid.value] || []
const updatedClusters = currentClusters.map((cr: any) => ({ ...cr, trace_ids: [...(cr.trace_ids || [])] }))
const target = updatedClusters.find((cr: any) => (cr.cluster_id ?? cr.group_id) === Number(targetId))
if (!target) return
const source = sourceId != null ? updatedClusters.find((cr: any) => cr.cluster_id === sourceId) : null
if (source) {
source.trace_ids = source.trace_ids.filter((id: number) => id !== tid)
source.trace_count = source.trace_ids.length
if (source.trace_ids.length > 0 && source.representative_trace === tid) {
source.representative_trace = source.trace_ids[0]
}
}
target.trace_ids = [...(target.trace_ids || []), tid]
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 }
try {
await updateTraceProfileGroup(c.file_uuid, [tid], { name: target.name || `Group ${targetId}` })
} catch (e) { console.error('move face failed:', e) }
faceDetailModal.value.show = false
if (sourceId === clusterDetailModal.value.cluster?.cluster_id) {
await loadClusterResults()
}
}
async function confirmMoveFaceToNewGroup() {
const c = faceDetailModal.value.candidate
const name = faceMoveNewGroupName.value.trim()
if (!c || !name) return
const tid = Number(c.trace_id)
const newId = Date.now()
// Create new cluster
const newCluster: any = {
cluster_id: newId,
name,
trace_ids: [tid],
trace_count: 1,
representative_trace: tid,
}
const sourceId = faceSourceClusterId.value
const currentClusters = clusterResultsCache.value[selectedFileUuid.value] || []
const updatedClusters = [...currentClusters, newCluster]
// Remove from source (if any)
if (sourceId != null) {
const sourceIdx = updatedClusters.findIndex((cr: any) => (cr.cluster_id ?? cr.group_id) === sourceId)
if (sourceIdx >= 0) {
const source = updatedClusters[sourceIdx]
source.trace_ids = source.trace_ids.filter((id: number) => id !== tid)
source.trace_count = source.trace_ids.length
if (source.representative_trace === tid) {
source.representative_trace = source.trace_ids[0] || null
}
if (source.trace_ids.length === 0) {
updatedClusters.splice(sourceIdx, 1)
}
}
}
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) }
faceDetailModal.value.show = false
if (sourceId === clusterDetailModal.value.cluster?.cluster_id) {
await loadClusterResults()
}
}
async function createNewIdentityFromFace() {
const c = faceDetailModal.value.candidate
const name = faceDetailModal.value.newIdentityName.trim()
if (!c || !name) {
return
}
try {
const result: any = await apiCall('create_identity_from_face', {
name,
fileUuid: c.file_uuid,
traceIds: c.trace_id ? [c.trace_id] : undefined
})
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.identity_uuid.replace(/-/g, '')
router.push(`/people/${cleanUuid}`)
} else {
alert(t('people.detail.create_identity_failed'))
}
} catch (e) {
console.error('[createNewIdentityFromFace] error:', e)
alert(t('people.detail.create_identity_error', { error: String(e) }))
}
}
function showTraceCtxMenu(e: MouseEvent, t: any) {
e.preventDefault()
const candidateLike = {
id: t.trace_id,
file_uuid: t.file_uuid,
frame_number: t.best_face_frame || t.start_frame,
confidence: t.best_face_confidence,
bbox: t.best_face_bbox,
trace_id: t.trace_id,
frame_count: t.frame_count,
}
faceCtxMenu.value = { show: true, x: e.clientX, y: e.clientY, candidate: candidateLike }
}
function toggleFaceSelection(t: any) {
const key = `${t.file_uuid}:${t.trace_id}`
const idx = selectedFaces.value.indexOf(key)
if (idx >= 0) {
selectedFaces.value.splice(idx, 1)
} else {
selectedFaces.value.push(key)
}
}
function toggleGroupSelection(c: any) {
const key = c.group_uuid
const idx = selectedGroups.value.indexOf(key)
if (idx >= 0) {
selectedGroups.value.splice(idx, 1)
} else {
selectedGroups.value.push(key)
}
}
function toggleClusterTraceSelection(t: any) {
const traceId = t.trace_id
const idx = selectedClusterTraces.value.indexOf(traceId)
if (idx >= 0) {
selectedClusterTraces.value.splice(idx, 1)
} else {
selectedClusterTraces.value.push(traceId)
}
}
function clearSelection() {
selectedFaces.value = []
selectedGroups.value = []
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)
undoBannerTimer = setTimeout(() => {
showUndoBanner.value = false
lastDeletedTraces.value = []
}, 5000)
}
async function undoLastDelete() {
showUndoBanner.value = false
if (undoBannerTimer) clearTimeout(undoBannerTimer)
for (const t of lastDeletedTraces.value) {
try {
await apiCall('restore_trace', { file_uuid: t.file_uuid, trace_id: t.trace_id })
} catch (e) {
console.error('Restore failed:', t, e)
}
}
lastDeletedTraces.value = []
await refresh()
}
function removeTracesFromCache(traceKeys: string[]) {
for (const key of traceKeys) {
const [file_uuid, trace_id] = key.split(':')
const uIdx = unassignedTracesCache.value.findIndex(
(t: any) => `${t.file_uuid}:${t.trace_id}` === key
)
if (uIdx >= 0) {
unassignedTracesCache.value.splice(uIdx, 1)
}
delete clusterTracesMap.value[trace_id]
}
}
function removeTracesFromClusterCache(traceIds: number[]) {
for (const tid of traceIds) {
delete clusterTracesMap.value[String(tid)]
}
const clusters = clusterResultsCache.value[selectedFileUuid.value]
if (!clusters) return
for (const c of clusters) {
c.trace_ids = (c.trace_ids || []).filter((id: number) => !traceIds.includes(id))
c.trace_count = c.trace_ids.length
}
const validClusters = clusters.filter((c: any) => c.trace_count > 0)
clusterResultsCache.value = { ...clusterResultsCache.value, [selectedFileUuid.value]: validClusters }
}
async function batchDeleteFaces() {
if (!selectedFaces.value.length) return
const traceList: { file_uuid: string; trace_id: number }[] = []
for (const key of selectedFaces.value) {
const [file_uuid, trace_id] = key.split(':')
traceList.push({ file_uuid, trace_id: parseInt(trace_id) })
}
if (!confirm(`Delete ${traceList.length} trace(s)?`)) return
operationLoading.value = true
operationMessage.value = `Deleting ${traceList.length} trace(s)...`
const deletedKeys = [...selectedFaces.value]
lastDeletedTraces.value = []
for (const t of traceList) {
try {
await apiCall('delete_trace', { file_uuid: t.file_uuid, trace_id: t.trace_id })
lastDeletedTraces.value.push(t)
} catch (e) {
console.error('Delete failed:', t, e)
}
}
removeTracesFromCache(deletedKeys)
operationLoading.value = false
operationMessage.value = ''
clearSelection()
showUndoBannerWithTimer()
}
async function batchDeleteGroups() {
if (!selectedGroups.value.length) return
const groupIds = Array.from(selectedGroups.value)
const groupsToDelete = faceGroups.value.filter((c: any) => groupIds.includes(c.group_uuid))
let totalTraces = 0
const allTraceIds: number[] = []
for (const g of groupsToDelete) {
totalTraces += (g.trace_ids || []).length
allTraceIds.push(...(g.trace_ids || []))
}
if (!confirm(`Delete ${groupsToDelete.length} group(s) with ${totalTraces} trace(s)?`)) return
operationLoading.value = true
operationMessage.value = `Deleting ${totalTraces} trace(s)...`
lastDeletedTraces.value = []
for (const g of groupsToDelete) {
const traceIds = g.trace_ids || []
for (const tid of traceIds) {
const trace = clusterTracesMap.value[String(tid)]
const fileUuid = trace?.file_uuid
console.log('[batchDeleteGroups] trace_id:', tid, 'trace:', trace, 'fileUuid:', fileUuid)
if (!fileUuid) {
console.error('[batchDeleteGroups] Missing file_uuid for trace:', tid)
continue
}
try {
await apiCall('delete_trace', { file_uuid: fileUuid, trace_id: tid })
lastDeletedTraces.value.push({ file_uuid: fileUuid, trace_id: tid })
console.log('[batchDeleteGroups] Deleted trace:', tid)
} catch (e) {
console.error('Delete trace failed:', tid, e)
}
}
}
removeTracesFromClusterCache(allTraceIds)
operationLoading.value = false
operationMessage.value = ''
clearSelection()
showUndoBannerWithTimer()
}
async function batchMergeGroups() {
if (selectedGroups.value.length < 2) {
alert('Select at least 2 groups to merge')
return
}
const groupIds = Array.from(selectedGroups.value)
console.log('[batchMergeGroups] groupIds:', groupIds)
mergeCandidateGroups.value = faceGroups.value.filter((c: any) => groupIds.includes(c.group_uuid))
console.log('[batchMergeGroups] mergeCandidateGroups:', mergeCandidateGroups.value.length)
showMergeTargetModal.value = true
}
async function executeMerge(targetGroupId: string) {
console.log('[executeMerge] targetGroupId:', targetGroupId)
showMergeTargetModal.value = false
const targetGroup = faceGroups.value.find((c: any) => c.group_uuid === targetGroupId)
console.log('[executeMerge] targetGroup:', targetGroup)
if (!targetGroup) return
const targetGroupName = targetGroup.name
console.log('[executeMerge] targetGroupName:', targetGroupName)
if (!targetGroupName) {
alert('Target group has no name')
return
}
const groupIds = Array.from(selectedGroups.value)
const sourceGroupIds = groupIds.filter(id => id !== targetGroupId)
const sourceGroups = faceGroups.value.filter((c: any) => sourceGroupIds.includes(c.group_uuid))
console.log('[executeMerge] sourceGroups:', sourceGroups.length)
const sourceGroupNames = sourceGroups.map(g => g.name).filter(Boolean)
console.log('[executeMerge] sourceGroupNames:', sourceGroupNames)
if (sourceGroupNames.length === 0) {
alert('No valid source groups to merge')
return
}
if (!confirm(`Merge ${sourceGroupNames.length} group(s) into "${targetGroupName}"?`)) return
operationLoading.value = true
operationMessage.value = `Merging ${sourceGroupNames.length} group(s)...`
try {
console.log('[executeMerge] calling merge_groups API:', {
fileUuid: selectedFileUuid.value,
sourceGroups: sourceGroupNames,
targetGroupName: targetGroupName
})
const result = await apiCall('merge_groups', {
fileUuid: selectedFileUuid.value,
sourceGroups: sourceGroupNames,
targetGroupName: targetGroupName
})
console.log('[executeMerge] result:', result)
if (result?.success) {
console.log('[executeMerge] success, traces_merged:', result.traces_merged)
await loadClusterResults()
clearSelection()
} else {
alert(`Merge failed: ${result?.error || 'Unknown error'}`)
}
} catch (e) {
console.error('[executeMerge] error:', e)
alert(`Merge failed: ${e}`)
} finally {
operationLoading.value = false
operationMessage.value = ''
}
}
async function batchMoveFaces() {
if (!selectedFaces.value.length) return
showMoveFacesModal.value = true
}
async function moveToGroup(targetGroup: any) {
showMoveFacesModal.value = false
const targetTraceId = targetGroup.trace_ids?.[0]
if (!targetTraceId) {
alert('Target group has no traces')
return
}
const targetFileUuid = selectedFileUuid.value
if (!targetFileUuid) {
alert('No file selected')
return
}
const traceList: { file_uuid: string; trace_id: number }[] = []
for (const key of selectedFaces.value) {
const [file_uuid, trace_id] = key.split(':')
traceList.push({ file_uuid, trace_id: parseInt(trace_id) })
}
if (!confirm(`Move ${traceList.length} trace(s) to "${targetGroup.name}"?`)) return
operationLoading.value = true
operationMessage.value = `Moving ${traceList.length} trace(s)...`
for (const t of traceList) {
try {
await apiCall('merge_trace', {
file_uuid: t.file_uuid,
source_id: t.trace_id,
target_id: targetTraceId
})
} catch (e) {
console.error('Move trace failed:', t, e)
}
}
// 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() {
const name = prompt('Enter new group name:')
if (!name) return
showMoveFacesModal.value = false
const traceList: { file_uuid: string; trace_id: number }[] = []
for (const key of selectedFaces.value) {
const [file_uuid, trace_id] = key.split(':')
traceList.push({ file_uuid, trace_id: parseInt(trace_id) })
}
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}`,
})
clusterResultsCache.value = { ...clusterResultsCache.value, [selectedFileUuid.value]: clusters }
} catch (e) {
console.error('Create new group failed:', e)
alert('Failed to create new group')
}
}
async function deleteSelectedClusterTraces() {
if (!selectedClusterTraces.value.length) return
const cluster = clusterDetailModal.value.cluster
if (!cluster) return
if (!confirm(`Delete ${selectedClusterTraces.value.length} trace(s)?`)) return
for (const traceId of selectedClusterTraces.value) {
const trace = clusterTracesMap.value[String(traceId)]
if (!trace?.file_uuid) continue
try {
await apiCall('delete_trace', { file_uuid: trace.file_uuid, trace_id: traceId })
} catch (e) {
console.error('Delete trace failed:', traceId, e)
}
}
selectedClusterTraces.value = []
clusterBatchMode.value = false
await loadClusterResults()
}
async function runDataQC() {
if (!selectedFileUuid.value) {
alert('Please select a file first')
return
}
// Reset and show modal
qcReport.value = []
qcProfileIssues.value = []
qcApiTests.value = []
qcApiBlocked.value = false
qcProgress.value = { current: 0, total: 0, status: '' }
showQCModal.value = true
const fileUuid = selectedFileUuid.value
// ===== 0. API CONNECTIVITY TEST =====
qcProgress.value.status = 'Testing API connectivity...'
qcApiTests.value = []
// 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: 'get_file_keyframe', call: () => apiCall('get_thumbnail', { uuid: fileUuid, frame: 0 }) },
]
// General APIs
const generalApis = [
{ name: 'get_files', call: () => apiCall('get_files', { page: 1, perPage: 1 }) },
{ name: 'get_file_info', call: () => apiCall('get_file_info', { uuid: fileUuid }) },
{ name: 'get_unassigned_traces', call: () => apiCall('get_unassigned_traces', { fileUuid, page: 1, perPage: 1 }) },
]
// Test General APIs
for (const api of generalApis) {
qcProgress.value.status = `Testing: ${api.name}`
try {
await api.call()
qcApiTests.value.push({ name: api.name, status: true })
} catch (e: any) {
qcApiTests.value.push({ name: api.name, status: false, error: e?.message?.slice(0, 50) })
}
}
// Get valid trace_id for trace profile tests
let validTraceId: number | null = null
try {
const unassignedTraces: any = await apiCall('get_unassigned_traces', { fileUuid, page: 1, perPage: 10 })
if (unassignedTraces?.length > 0) {
validTraceId = unassignedTraces[0].trace_id
}
} catch (e) {
console.warn('[QC] Failed to get unassigned traces:', e)
}
// Trace APIs - use valid trace_id from API
const traceApis = validTraceId !== null ? [
{ name: 'get_trace_profile', call: () => apiCall('get_trace_profile', { fileUuid, traceId: validTraceId }) },
{ name: 'update_trace_profile', call: () => apiCall('update_trace_profile', { fileUuid, traceId: validTraceId, label: '' }) },
] : []
// Test File APIs
for (const api of fileApis) {
qcProgress.value.status = `Testing: ${api.name}`
try {
await api.call()
qcApiTests.value.push({ name: api.name, status: true })
} catch (e: any) {
qcApiTests.value.push({ name: api.name, status: false, error: e?.message?.slice(0, 50) })
}
}
// Test Trace APIs (skip if no valid trace_id)
if (traceApis.length > 0) {
for (const api of traceApis) {
qcProgress.value.status = `Testing: ${api.name}`
try {
await api.call()
qcApiTests.value.push({ name: api.name, status: true })
} catch (e: any) {
qcApiTests.value.push({ name: api.name, status: false, error: e?.message?.slice(0, 50) })
}
}
} else {
qcReport.value.push({
check: 'Trace Profile APIs',
status: 'SKIP',
details: 'No valid trace_id found for testing'
})
}
const apiFailures = qcApiTests.value.filter(t => !t.status)
if (apiFailures.length > 0) {
qcApiBlocked.value = true
qcProgress.value.status = 'API test failed - QC blocked'
qcReport.value.push({
check: 'API Connectivity',
status: 'FAIL',
details: `${apiFailures.length}/${qcApiTests.value.length} APIs failed. Data QC blocked.`
})
return
}
qcReport.value.push({
check: 'API Connectivity',
status: 'PASS',
details: `All ${qcApiTests.value.length} APIs OK`
})
// ===== 1. FILE PROFILE QC =====
qcProgress.value.status = 'Checking file profile...'
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
if (fileProfile?.key_frame != null) {
fileProfileResult.keyFrame = fileProfile.key_frame
qcFileProfile.value = {
key_frame: fileProfile.key_frame,
key_frame_url: `/api/v1/file/${fileUuid}/thumbnail?frame=${fileProfile.key_frame}`
}
}
} catch (e: any) {
console.warn('[QC] File profile read failed:', e)
}
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
}
} catch (e: any) {
console.warn('[QC] File profile write failed:', e)
}
}
qcReport.value.push({
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: ${fileProfileResult.canRead ? '✓' : '✗'}, Write: ${fileProfileResult.canWrite ? '✓' : '✗'}`
})
// ===== 2. COLLECT TRACE IDS FROM CLUSTERS =====
qcProgress.value.status = 'Collecting trace IDs from clusters...'
const allTraceIds = new Set<number>()
for (const c of faceGroups.value) {
for (const tid of c.trace_ids || []) {
allTraceIds.add(tid)
}
}
const totalTraces = allTraceIds.size
qcProgress.value.total = totalTraces
qcReport.value.push({
check: 'Traces from Clusters',
status: totalTraces > 0 ? 'PASS' : 'WARN',
details: `${totalTraces} FS in ${faceGroups.value.length} groups`
})
// ===== 3. TEST TRACE PROFILES =====
qcProgress.value.status = `Testing ${totalTraces} trace profiles...`
let tested = 0
let withKeyFrame = 0
let withKeyFace = 0
const profileIssues: { traceId: number; fileUuid: string; groupName: string; canRead: boolean; canWrite: boolean; hasKeyFrame: boolean; hasKeyFace: boolean; error?: string }[] = []
for (const traceId of allTraceIds) {
tested++
qcProgress.value.current = tested
const trace = clusterTracesMap.value[String(traceId)]
const groupName = traceGroupMap.value[String(traceId)] || 'Unknown'
qcProgress.value.status = `Testing FS#${traceId} (${groupName})`
const result: { traceId: number; fileUuid: string; groupName: string; canRead: boolean; canWrite: boolean; hasKeyFrame: boolean; hasKeyFace: boolean; error?: string } = {
traceId,
fileUuid: trace?.file_uuid || '',
groupName,
canRead: false,
canWrite: false,
hasKeyFrame: false,
hasKeyFace: false
}
if (trace?.file_uuid) {
try {
const profile: any = await apiCall('get_trace_profile', {
fileUuid: trace.file_uuid,
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++
} 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
})
// 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'
}
}
} else {
result.error = 'missing file_uuid'
}
if (!result.canRead || !result.canWrite || (!result.hasKeyFrame && !result.hasKeyFace)) {
profileIssues.push(result)
qcProfileIssues.value = [...profileIssues]
}
}
qcTraceSummary.value = {
total: tested,
withKeyFrame,
withKeyFace,
missingBoth: tested - withKeyFrame - withKeyFace + profileIssues.filter(p => p.hasKeyFrame && p.hasKeyFace).length
}
// FS profile summary
const readFailures = profileIssues.filter(r => !r.canRead).length
const writeFailures = profileIssues.filter(r => !r.canWrite).length
const validTraceCount = tested
const missingKeyFrame = validTraceCount - withKeyFrame
const missingKeyFace = validTraceCount - withKeyFace
qcReport.value.push({
check: 'FS Profile Read',
status: readFailures === 0 ? 'PASS' : 'FAIL',
details: readFailures === 0
? `All ${validTraceCount} readable`
: `${readFailures}/${validTraceCount} NOT readable`
})
qcReport.value.push({
check: 'FS Profile Write',
status: writeFailures === 0 ? 'PASS' : 'FAIL',
details: writeFailures === 0
? `All ${validTraceCount} writable`
: `${writeFailures}/${validTraceCount} NOT writable`
})
qcReport.value.push({
check: 'Trace Key Frame',
status: missingKeyFrame === 0 ? 'PASS' : 'WARN',
details: missingKeyFrame === 0
? `All ${validTraceCount} have key_frame`
: `${withKeyFrame}/${validTraceCount} have key_frame`
})
qcReport.value.push({
check: 'Trace Key Face',
status: missingKeyFace === 0 ? 'PASS' : 'WARN',
details: missingKeyFace === 0
? `All ${validTraceCount} have key_face`
: `${withKeyFace}/${validTraceCount} have key_face`
})
qcProgress.value.status = 'QC Complete'
console.log('[QC] Complete:', qcReport.value)
}
function copyQCReport() {
const lines: string[] = []
lines.push(`QC Report - ${getFileName(selectedFileUuid.value)}`)
lines.push(`File UUID: ${selectedFileUuid.value}`)
lines.push('')
if (qcApiTests.value.length > 0) {
lines.push('API Tests:')
const general = qcApiTests.value.filter(a => ['get_files', 'get_file_info', 'get_unassigned_traces'].includes(a.name))
const fileApis = qcApiTests.value.filter(a => ['get_file_profile', 'update_file_profile', 'get_file_keyframe'].includes(a.name))
const traceApis = qcApiTests.value.filter(a => ['get_trace_profile', 'update_trace_profile'].includes(a.name))
lines.push(` General: ${general.filter(a => a.status).length}/${general.length} OK`)
lines.push(` File Profile: ${fileApis.filter(a => a.status).length}/${fileApis.length} OK`)
lines.push(` Trace Profile: ${traceApis.filter(a => a.status).length}/${traceApis.length} OK`)
}
if (qcApiBlocked.value) {
lines.push('STATUS: BLOCKED')
navigator.clipboard.writeText(lines.join('\n'))
return
}
lines.push('')
lines.push(`Traces: ${qcTraceSummary.value.total}`)
if (qcTraceSummary.value.total === 100) {
lines.push(`(Note: Check if all traces loaded - may need API fix)`)
}
lines.push(`Key Frame: ${qcTraceSummary.value.withKeyFrame}/${qcTraceSummary.value.total}`)
lines.push(`Key Face: ${qcTraceSummary.value.withKeyFace}/${qcTraceSummary.value.total}`)
lines.push('')
const passed = qcReport.value.filter(r => r.status === 'PASS').length
const failed = qcReport.value.filter(r => r.status === 'FAIL').length
const warned = qcReport.value.filter(r => r.status === 'WARN').length
lines.push(`Results: ${passed}✓ ${warned}⚠ ${failed}✗`)
lines.push('')
if (qcProfileIssues.value.length > 0) {
lines.push(`Issues: ${qcProfileIssues.value.length} traces`)
for (const p of qcProfileIssues.value.slice(0, 5)) {
lines.push(` #${p.traceId}: Read ${p.canRead ? '✓' : '✗'} Write ${p.canWrite ? '✓' : '✗'} KeyFrame ${p.hasKeyFrame ? '✓' : '✗'} KeyFace ${p.hasKeyFace ? '✓' : '✗'}`)
}
if (qcProfileIssues.value.length > 5) lines.push(` ... +${qcProfileIssues.value.length - 5}`)
}
navigator.clipboard.writeText(lines.join('\n')).then(() => alert('Copied!')).catch(() => alert('Failed'))
}
</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; }
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; }
@keyframes spin { to { transform: rotate(360deg); } }
.ms-ppl-toolbar { display: flex; align-items: center; gap: 4px; margin-bottom: 6px; }
.ms-ppl-toolbar-bottom { display: flex; align-items: center; gap: 4px; margin-bottom: 6px; flex-wrap: wrap; }
.ms-ppl-file-hint { font-size: 12px; color: var(--muted-text); margin-bottom: 12px; }
.ms-ppl-cluster-btn { display: flex; align-items: center; gap: 6px; padding: 6px 14px; border: 1.5px solid var(--border-color); border-radius: 10px; background: var(--card-background); cursor: pointer; font-size: 13px; font-family: inherit; color: var(--text-primary); transition: border-color .15s; margin-bottom: 12px; }
.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); }
.ms-ppl-star-toggle-btn { display: flex; align-items: center; gap: 6px; padding: 6px 14px; border: 1.5px solid var(--border-color); border-radius: 10px; background: var(--card-background); cursor: pointer; font-size: 13px; font-family: inherit; color: var(--text-primary); transition: border-color .15s; }
.ms-ppl-star-toggle-btn:hover { border-color: var(--text-primary); }
.ms-ppl-star-icon { font-size: 16px; color: var(--border-color); transition: color .15s; }
.ms-ppl-star-icon.starred { color: var(--warning-color); }
.ms-fm-icon-btn { width: 34px; height: 34px; border: 1.5px solid var(--border-color); border-radius: 10px; background: var(--card-background); cursor: pointer; display: grid; place-items: center; font-size: 16px; color: var(--text-secondary); transition: border-color .15s, color .15s; }
.ms-fm-icon-btn:hover { border-color: var(--text-primary); color: var(--text-primary); }
.ms-ppl-section-toggle-btn { display: flex; align-items: center; gap: 6px; 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-section-toggle-btn.active { border-color: var(--text-primary); color: var(--text-primary); background: var(--hover-background); }
.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-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; }
.ms-ppl-face-grid { display: flex; flex-wrap: wrap; gap: 16px; }
.ms-ppl-face-card { width: 120px; cursor: pointer; border-radius: 12px; transition: transform .15s, box-shadow .15s; position: relative; overflow: visible; }
.ms-ppl-face-card:hover { transform: translateY(-3px); box-shadow: 0 6px 18px rgba(0,0,0,.1); }
.ms-ppl-face-card:hover .ms-ppl-face-img-wrap { border-radius: 0; }
.ms-ppl-face-card:hover .ms-ppl-face-img-wrap img { transform: scale(1.02); }
.ms-ppl-trigger-btn { position: absolute; top: 4px; left: 4px; width: 24px; height: 24px; border-radius: 6px; border: none; background: rgba(255,255,255,0.9); cursor: pointer; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity .15s; z-index: 2; }
.ms-ppl-face-card:hover .ms-ppl-trigger-btn { opacity: 1; }
.ms-ppl-trigger-btn:hover { background: rgba(var(--primary-color-rgb), 0.1); }
.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-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; }
.ms-fm-btn-sm { padding: 4px 10px; font-size: 12px; }
.ms-fm-btn-blue { background: rgba(var(--primary-color-rgb), 0.1); color: var(--primary-color); border-color: var(--primary-color); }
.ms-fm-btn-blue:hover { background: var(--primary-color); color: #fff; }
.ms-fm-btn-danger { background: var(--danger-background); color: var(--danger-color); border-color: var(--danger-color); }
.ms-fm-btn-danger:hover { background: var(--danger-color); color: #fff; }
.ms-merge-face-count { font-size: 11px; color: var(--text-secondary); }
.ms-modal-merge { max-width: 480px; width: 92%; padding: 24px 28px; background: var(--card-background); border-radius: 16px; box-shadow: var(--shadow); position: relative; }
.ms-merge-hint { font-size: 13px; color: var(--text-secondary); margin-bottom: 16px; }
.ms-merge-grid { display: flex; flex-wrap: wrap; gap: 12px; }
.ms-merge-face-card { display: flex; flex-direction: column; align-items: center; gap: 6px; cursor: pointer; width: 100px; padding: 12px; border-radius: 12px; background: var(--hover-background); transition: background .15s, transform .15s; }
.ms-merge-face-card:hover { background: rgba(var(--primary-color-rgb), 0.1); transform: translateY(-2px); }
.ms-merge-face-img { width: 64px; height: 64px; border-radius: 12px; overflow: hidden; background: var(--border-light); }
.ms-merge-face-img img { width: 100%; height: 100%; object-fit: cover; }
.ms-merge-face-name { font-size: 12px; color: var(--text-primary); text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 88px; font-weight: 500; }
.ms-merge-divider { border: none; border-top: 1px solid var(--border-color); margin: 16px 0; }
.ms-undo-banner {
display: flex; align-items: center; gap: 12px;
padding: 10px 16px; margin-bottom: 12px;
background: rgba(var(--warning-color), 0.15); border: 1px solid var(--warning-color); border-radius: 8px;
font-size: 13px; color: var(--warning-color);
}
.ms-undo-banner button { margin: 0; }
.ms-operation-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.3);
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 16px; z-index: 1000;
}
.ms-operation-spinner {
width: 32px; height: 32px;
border: 3px solid var(--border-light); border-top-color: var(--primary-color);
border-radius: 50%; animation: spin 0.7s linear infinite;
}
.ms-operation-message {
color: #fff; font-size: 14px; font-weight: 500;
}
.ms-move-new-group {
display: flex; align-items: center; gap: 12px;
padding: 14px 16px;
background: rgba(var(--success-color), 0.1); border: 2px dashed var(--success-color); border-radius: 12px;
cursor: pointer; transition: background .15s;
}
.ms-move-new-group:hover { background: rgba(var(--success-color), 0.2); }
.ms-move-new-icon {
width: 36px; height: 36px;
display: flex; align-items: center; justify-content: center;
background: var(--success-color); color: #fff; border-radius: 8px;
font-size: 20px; font-weight: bold;
}
.ms-move-new-label { font-size: 14px; color: var(--success-color); font-weight: 500; }
.ms-ppl-face-img-wrap { width: 120px; height: 120px; border-radius: 20px; background: var(--border-light); overflow: hidden; position: relative; transition: border-radius 0.2s; }
.ms-ppl-face-img-wrap img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.2s; }
.ms-ppl-frame-img { width: 100%; height: 100%; object-fit: contain; background: #000; }
.ms-ppl-bbox-overlay { position: absolute; border: 2px solid #ea4335; background: rgba(234,67,53,0.15); pointer-events: none; }
.ms-ppl-card-star { display: none; position: absolute; top: 4px; right: 4px; font-size: 14px; line-height: 1; }
.ms-silhouette { width: 100%; height: 100%; }
.ms-ppl-face-name { display: block; text-align: center; font-size: 12px; color: var(--text-primary); margin-top: 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ms-ppl-face-uuid { display: block; text-align: center; font-size: 9px; color: var(--text-secondary); margin-top: 1px; font-family: monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 120px; }
.ms-ppl-trace-group-label { display: block; text-align: center; font-size: 10px; color: var(--primary-color); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 116px; }
.ms-ppl-face-range { display: flex; justify-content: center; gap: 8px; margin-top: 4px; font-size: 10px; }
.ms-ppl-face-frame { color: var(--text-secondary); }
.ms-ppl-face-samples { color: var(--muted-text); }
.ms-ppl-face-action-btn { width: 24px; height: 24px; border-radius: 6px; border: none; background: var(--card-background); cursor: pointer; display: flex; align-items: center; justify-content: center; color: var(--text-secondary); }
.ms-ppl-face-action-btn:hover { background: rgba(var(--primary-color-rgb), 0.1); color: var(--primary-color); }
.ms-ppl-face-action-btn.active { background: var(--primary-color); color: #fff; }
.ms-uface-grid .ms-ppl-face-card { width: 120px; }
.ms-uface-grid .ms-ppl-face-img-wrap { width: 120px; height: 120px; border-radius: 20px; }
.face-placeholder { font-size: 0.6rem; color: var(--text-secondary); }
.ms-ppl-files-list { display: flex; flex-wrap: wrap; gap: 4px; justify-content: center; }
.ms-ppl-file-chip { background: rgba(var(--primary-color-rgb), 0.1); border-radius: 4px; padding: 2px 8px; font-size: 11px; color: var(--primary-color); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 110px; cursor: default; display: inline-flex; align-items: center; gap: 6px; }
.ms-ppl-file-chip-uuid { font-size: 9px; color: var(--primary-color); opacity: 0.7; font-family: monospace; overflow: hidden; text-overflow: ellipsis; }
/* Search input */
.ms-ppl-search-wrap { position: relative; }
.ms-ppl-search-input { padding: 6px 12px; border: 1.5px solid var(--border-color); border-radius: 8px; font-size: 12.5px; outline: none; width: 160px; font-family: inherit; background: var(--card-background); color: var(--text-primary); transition: border-color .15s; }
.ms-ppl-search-input:focus { border-color: var(--text-primary); }
/* Sort panel */
.ms-fm-sort-panel { position: absolute; top: 100%; right: 0; z-index: 999; background: var(--card-background); border-radius: 12px; box-shadow: var(--shadow); padding: 12px 16px; min-width: 180px; margin-top: 4px; border: 1px solid var(--border-color); }
.ms-fm-sort-section { }
.ms-fm-sort-title { font-size: 11.5px; font-weight: 600; color: var(--muted-text); text-transform: uppercase; letter-spacing: .04em; margin-bottom: 8px; }
.ms-undo-row { display: flex; gap: 8px; }
.ms-undo-btn { display: flex; align-items: center; gap: 4px; padding: 6px 14px; border: 1.5px solid var(--border-color); border-radius: 8px; background: var(--card-background); cursor: pointer; font-size: 13px; font-family: inherit; color: var(--primary-color); transition: border-color .15s, background .15s; }
.ms-undo-btn:hover:not(:disabled) { border-color: var(--primary-color); background: rgba(var(--primary-color-rgb), 0.1); }
.ms-undo-btn:disabled { color: var(--muted-text); cursor: not-allowed; opacity: 0.6; }
.ms-undo-btn-danger { color: var(--danger-color); border-color: var(--danger-color); }
.ms-undo-btn-danger:hover:not(:disabled) { border-color: var(--danger-color); background: var(--danger-background); }
.ms-history-list { margin-top: 8px; max-height: 200px; overflow-y: auto; }
.ms-history-item { display: flex; justify-content: space-between; align-items: center; padding: 4px 0; font-size: 12px; color: var(--text-secondary); border-bottom: 1px solid var(--border-light); }
.ms-history-item:last-child { border-bottom: none; }
.ms-history-item-undone { opacity: 0.5; text-decoration: line-through; }
.ms-history-label { color: var(--text-primary); }
.ms-history-time { color: var(--muted-text); font-size: 11px; white-space: nowrap; margin-left: 8px; }
.ms-history-actions { display: flex; gap: 2px; margin-left: 4px; }
.ms-history-act-btn { background: none; border: 1px solid var(--border-color); border-radius: 4px; padding: 1px 5px; font-size: 12px; cursor: pointer; color: var(--primary-color); line-height: 1; }
.ms-history-act-btn:hover { background: rgba(var(--primary-color-rgb), 0.1); border-color: var(--primary-color); }
.ms-history-act-redo { color: var(--primary-color); }
.ms-history-empty { font-size: 12px; color: var(--muted-text); padding: 4px 0; }
.ms-fm-sort-section label { display: flex; align-items: center; gap: 8px; padding: 6px 0; font-size: 13px; color: var(--text-primary); cursor: pointer; }
.ms-fm-sort-section input[type="radio"] { margin: 0; accent-color: var(--text-primary); }
/* Context menu */
.ms-ctx-menu { position: fixed; z-index: 99999; background: var(--card-background); border-radius: 12px; box-shadow: var(--shadow); padding: 6px; min-width: 160px; font-size: 13px; color: var(--text-primary); border: 1px solid var(--border-color); }
.ms-ctx-filename { font-weight: 600; font-size: 13px; color: var(--text-primary); padding: 6px 12px 4px; word-break: break-all; }
.ms-ctx-item { display: flex; align-items: center; gap: 8px; padding: 8px 12px; cursor: pointer; border-radius: 8px; border: none; background: transparent; width: 100%; text-align: left; font-size: 13px; color: var(--text-primary); font-family: inherit; }
.ms-ctx-item:hover { background: var(--hover-background); }
.ms-ctx-item.ms-ctx-danger { color: var(--danger-color); }
.ms-ctx-item.ms-ctx-danger:hover { background: var(--danger-background); }
.ms-ctx-item.ms-ctx-undo { color: var(--primary-color); }
.ms-ctx-item.ms-ctx-redo { color: var(--primary-color); }
.ms-ctx-item.ms-ctx-undo:disabled, .ms-ctx-item.ms-ctx-redo:disabled { color: var(--muted-text); cursor: default; }
.ms-ctx-item.ms-ctx-undo:disabled:hover, .ms-ctx-item.ms-ctx-redo:disabled:hover { background: transparent; }
.ms-ctx-menu-divider { height: 1px; background: var(--border-light); margin: 4px 8px; }
.ms-ctx-history-item { display: flex; align-items: center; gap: 4px; padding: 3px 10px; font-size: 12px; color: var(--text-secondary); }
.ms-ctx-history-label { flex: 1; color: var(--text-primary); font-size: 12px; }
.ms-ctx-history-time { color: var(--muted-text); font-size: 10px; white-space: nowrap; }
.ms-ctx-history-btn { padding: 2px 6px !important; font-size: 12px !important; min-width: 24px; }
/* Modal overlay */
.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-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 */
.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; }
.ms-assign-header { display: flex; align-items: center; gap: 16px; margin-bottom: 20px; }
.ms-assign-trigger-face { width: 72px; height: 72px; border-radius: 14px; background: var(--border-light); flex-shrink: 0; overflow: hidden; }
.ms-assign-trigger-face img { width: 100%; height: 100%; object-fit: cover; }
.ms-assign-info { flex: 1; min-width: 0; }
.ms-assign-title { font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 16px; font-weight: 600; color: var(--text-primary); margin: 0 0 4px; }
.ms-assign-sub { font-size: 12px; color: var(--muted-text); }
.ms-assign-search-wrap { position: relative; margin-bottom: 16px; }
.ms-assign-search-icon { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); font-size: 14px; color: var(--muted-text); }
.ms-assign-search-input { width: 100%; padding: 10px 14px 10px 36px; border: 1.5px solid var(--border-color); border-radius: 10px; font-size: 13px; outline: none; font-family: inherit; background: var(--card-background); color: var(--text-primary); transition: border-color .15s; }
.ms-assign-search-input:focus { border-color: var(--text-primary); }
.ms-assign-grid { display: flex; flex-wrap: wrap; gap: 12px; min-height: 120px; max-height: 45vh; overflow-y: auto; padding: 4px 0; }
.ms-assign-face-card { display: flex; flex-direction: column; align-items: center; gap: 6px; cursor: pointer; width: 80px; padding: 8px; border-radius: 12px; transition: background .15s; }
.ms-assign-face-card:hover { background: var(--hover-background); }
.ms-assign-face-card.selected { background: rgba(var(--primary-color-rgb), 0.1); }
.ms-assign-face-img { width: 64px; height: 64px; border-radius: 14px; overflow: hidden; background: var(--border-light); border: 2px solid transparent; transition: border-color .15s; }
.ms-assign-face-card.selected .ms-assign-face-img { border-color: var(--primary-color); }
.ms-assign-face-img img { width: 100%; height: 100%; object-fit: cover; }
.ms-assign-face-name { font-size: 11px; color: var(--text-primary); text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 72px; }
.ms-assign-empty { width: 100%; padding: 32px 0; text-align: center; color: var(--muted-text); font-size: 13px; }
.ms-assign-footer { display: flex; justify-content: flex-end; gap: 12px; margin-top: 20px; }
.ms-fm-btn-primary { background: var(--primary-color); color: #fff; border-color: var(--primary-color); }
.ms-fm-btn-primary:hover { background: var(--primary-color); opacity: 0.9; }
.ms-fm-btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.ms-assign-toggle-row { display: flex; gap: 6px; margin-top: 8px; }
.ms-assign-bbox-info { font-size: 11px; color: var(--muted-text); margin-top: 4px; font-family: monospace; }
.ms-toggle-btn { font-size: 11px; padding: 3px 10px; border-radius: 6px; background: var(--hover-background); border: 1px solid var(--border-color); color: var(--text-secondary); cursor: pointer; transition: all .15s; }
.ms-toggle-btn:hover { background: var(--border-light); }
.ms-toggle-btn.active { background: var(--text-primary); border-color: var(--text-primary); color: #fff; }
.ms-toggle-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.ms-frame-preview-wrap { margin-bottom: 16px; text-align: center; }
.ms-frame-loading { padding: 40px; color: var(--muted-text); font-size: 13px; }
.ms-frame-preview { display: inline-block; position: relative; max-width: 100%; border-radius: 8px; overflow: hidden; background: #000; }
.ms-frame-preview-img { display: block; max-width: 100%; max-height: 60vh; object-fit: contain; }
/* Face detail modal */
.ms-modal-face-detail {
max-width: 420px;
width: 92%;
max-height: 85vh;
overflow-y: auto;
padding: 24px 28px;
background: var(--card-background);
border-radius: 16px;
box-shadow: var(--shadow);
position: relative;
}
.ms-face-detail-header { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
.ms-face-detail-thumb { width: 100px; height: 100px; border-radius: 14px; background: var(--border-light); flex-shrink: 0; overflow: hidden; }
.ms-face-detail-thumb img { width: 100%; height: 100%; object-fit: cover; }
.ms-face-detail-info { flex: 1; min-width: 0; }
.ms-face-detail-title { font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 16px; font-weight: 600; color: var(--text-primary); margin: 0 0 12px; display: flex; align-items: center; gap: 8px; }
.ms-face-detail-title input { flex: 1; }
.ms-face-trace-id { font-size: 11px; color: var(--text-secondary); font-family: monospace; }
.ms-edit-btn { font-size: 11px; padding: 2px 8px; border-radius: 6px; background: var(--hover-background); border: 1px solid var(--border-color); color: var(--text-secondary); cursor: pointer; }
.ms-face-detail-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.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; }
.ms-face-detail-new-input-row { display: flex; gap: 8px; }
.ms-face-detail-new-input-row .ms-ppl-edit-input { flex: 1; }
.ms-face-detail-move-group { margin-top: 16px; }
.ms-face-detail-move-title { font-size: 14px; font-weight: 500; color: var(--text-primary); margin-bottom: 12px; }
.ms-face-detail-current-group { font-size: 13px; color: var(--primary-color); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ms-face-detail-move-row { display: flex; gap: 8px; }
.ms-face-detail-move-row select { flex: 1; }
.ms-face-detail-move-new-row { display: flex; gap: 8px; margin-top: 8px; }
.ms-face-detail-move-new-row .ms-ppl-edit-input { flex: 1; }
.ms-face-detail-footer { display: flex; justify-content: flex-end; margin-top: 20px; }
.ms-enlarged-face-modal { display: flex; align-items: center; justify-content: center; background: var(--card-background); border-radius: 16px; padding: 20px; position: relative; max-width: 95vw; max-height: 95vh; }
.ms-enlarged-face-modal .ms-modal-video-close { position: absolute; top: 8px; right: 12px; background: none; border: none; font-size: 24px; cursor: pointer; color: var(--text-secondary); line-height: 1; }
.ms-load-more { text-align: center; padding: 12px 0; }
.ms-load-more .ms-fm-btn { font-size: 13px; padding: 6px 20px; color: var(--text-secondary); background: var(--hover-background); border: 1px solid var(--border-color); border-radius: 8px; }
.ms-load-more .ms-fm-btn:hover { background: var(--border-light); }
.ms-ppl-loading-inline { font-size: 12px; color: var(--muted-text); margin-left: 12px; }
.ms-ppl-pagination { display: flex; align-items: center; justify-content: center; gap: 12px; padding: 12px 0; }
.ms-ppl-page-info { font-size: 13px; color: var(--text-secondary); }
.ms-ppl-pagination .ms-fm-icon-btn { width: 32px; height: 32px; border-radius: 8px; border: 1px solid var(--border-color); background: var(--card-background); font-size: 16px; cursor: pointer; display: flex; align-items: center; justify-content: center; }
.ms-ppl-pagination .ms-fm-icon-btn:disabled { opacity: 0.3; cursor: default; }
.ms-ppl-pagination .ms-fm-icon-btn:hover:not(:disabled) { background: var(--hover-background); }
.ms-ppl-cluster-card { border: 2px dashed var(--muted-text); }
.ms-ppl-cluster-card:hover { border-color: var(--text-primary); }
.ms-ppl-cluster-name-overlay { position: absolute; bottom: 0; left: 0; right: 0; background: linear-gradient(transparent, rgba(0,0,0,0.7)); color: #fff; font-size: 11px; font-weight: 600; padding: 4px 6px 6px; text-align: center; border-radius: 0 0 8px 8px; }
.ms-ppl-trace-group-label { display: block; text-align: center; font-size: 11px; font-weight: 500; color: var(--primary-color); margin-top: 2px; line-height: 1.2; }
.ms-modal-cluster-detail { max-width: 520px; width: 92%; padding: 24px 28px; background: var(--card-background); border-radius: 16px; box-shadow: var(--shadow); position: relative; max-height: 80vh; display: flex; flex-direction: column; }
.ms-cluster-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; }
.ms-cluster-title { font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 16px; font-weight: 600; color: var(--text-primary); }
.ms-cluster-traces { flex: 1; overflow-y: auto; max-height: 400px; display: flex; flex-direction: column; gap: 8px; }
.ms-cluster-trace-item { display: flex; align-items: center; gap: 12px; padding: 8px; border-radius: 10px; cursor: pointer; transition: background .15s; }
.ms-cluster-trace-item:hover { background: var(--hover-background); }
.ms-cluster-trace-thumb { width: 60px; height: 60px; border-radius: 12px; background: var(--border-light); overflow: hidden; flex-shrink: 0; }
.ms-cluster-trace-thumb img { width: 100%; height: 100%; object-fit: cover; }
.ms-cluster-trace-info { flex: 1; min-width: 0; }
.ms-cluster-trace-name { font-size: 13px; font-weight: 500; color: var(--text-primary); }
.ms-cluster-trace-meta { font-size: 11px; color: var(--muted-text); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ms-cluster-footer { display: flex; justify-content: flex-end; margin-top: 16px; }
.ms-cluster-title-wrap { flex: 1; display: flex; align-items: center; gap: 8px; }
.ms-cluster-play-btn { flex-shrink: 0; }
.ms-cluster-edit-input { font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 16px; font-weight: 600; color: var(--text-primary); border: 1.5px solid var(--border-color); border-radius: 8px; padding: 4px 8px; width: 100%; max-width: 300px; outline: none; background: var(--card-background); }
.ms-cluster-edit-input:focus { border-color: var(--text-primary); }
.ms-cluster-title { cursor: pointer; display: flex; align-items: center; gap: 8px; }
.ms-cluster-title:hover { opacity: 0.8; }
.ms-cluster-edit-hint { font-size: 11px; color: var(--primary-color); background: rgba(var(--primary-color-rgb), 0.1); padding: 2px 6px; border-radius: 4px; font-weight: 500; }
.ms-cluster-actions { display: flex; align-items: center; gap: 8px; }
.ms-cluster-search { margin-bottom: 12px; }
.ms-cluster-search-input { width: 100%; padding: 6px 12px; border: 1.5px solid var(--border-color); border-radius: 8px; font-size: 13px; font-family: inherit; outline: none; background: var(--card-background); color: var(--text-primary); }
.ms-cluster-search-input:focus { border-color: var(--text-primary); }
.ms-face-detail-pose { margin-top: 16px; padding: 12px; background: var(--hover-background); border-radius: 12px; }
.ms-pose-controls { display: flex; gap: 16px; margin-bottom: 12px; align-items: center; }
.ms-pose-toggle { display: flex; align-items: center; gap: 4px; font-size: 13px; color: var(--text-secondary); cursor: pointer; }
.ms-pose-toggle input { width: 16px; height: 16px; cursor: pointer; }
.ms-pose-frame-no { font-size: 12px; color: var(--primary-color); font-weight: 500; margin-left: auto; }
.ms-pose-bbox-info { font-size: 11px; color: var(--text-secondary); margin-bottom: 8px; font-family: monospace; }
.ms-pose-frame-container {
position: relative;
width: 100%;
max-width: 360px;
border-radius: 8px;
background: #000;
}
.ms-pose-frame-img {
width: 100%;
display: block;
object-fit: contain;
}
.ms-pose-overlay-canvas {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
}
/* QC Modal */
.ms-modal-qc { max-width: 560px; width: 94%; padding: 24px; background: var(--card-background); border-radius: 16px; box-shadow: var(--shadow); position: relative; max-height: 85vh; overflow-y: auto; }
.ms-qc-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 16px; }
.ms-qc-header h2 { margin: 0; font-size: 18px; color: var(--text-primary); }
.ms-qc-file-info { text-align: right; }
.ms-qc-file-name { font-size: 14px; color: var(--text-primary); font-weight: 500; display: block; }
.ms-qc-file-uuid { font-size: 11px; color: var(--text-secondary); font-family: monospace; }
.ms-qc-api-section { margin-bottom: 16px; padding: 16px; background: var(--hover-background); border-radius: 12px; }
.ms-qc-api-section h3 { margin: 0 0 12px; font-size: 14px; color: var(--text-secondary); }
.ms-qc-api-category { margin-bottom: 12px; }
.ms-qc-api-category:last-child { margin-bottom: 0; }
.ms-qc-api-cat-name { font-size: 12px; color: var(--text-primary); font-weight: 500; margin-bottom: 6px; }
.ms-qc-api-grid { display: flex; flex-wrap: wrap; gap: 8px; }
.ms-qc-api-item { display: flex; align-items: center; gap: 6px; padding: 6px 10px; background: var(--card-background); border-radius: 6px; font-size: 12px; border: 1px solid var(--border-color); }
.ms-qc-api-item.fail { border-color: var(--danger-color); background: rgba(var(--danger-color), 0.08); }
.ms-qc-api-status { font-weight: 600; }
.ms-qc-api-item.fail .ms-qc-api-status { color: var(--danger-color); }
.ms-qc-api-item:not(.fail) .ms-qc-api-status { color: var(--success-color); }
.ms-qc-api-name { color: var(--text-primary); }
.ms-qc-blocked { text-align: center; padding: 32px 16px; }
.ms-qc-blocked-icon { font-size: 48px; margin-bottom: 12px; }
.ms-qc-blocked-title { font-size: 18px; font-weight: 600; color: var(--danger-color); margin-bottom: 8px; }
.ms-qc-blocked-desc { font-size: 13px; color: var(--text-secondary); }
.ms-qc-keyframe { margin-bottom: 16px; text-align: center; }
.ms-qc-keyframe-label { font-size: 12px; color: var(--text-secondary); margin-bottom: 8px; }
.ms-qc-keyframe-img { max-width: 100%; max-height: 160px; border-radius: 8px; }
.ms-qc-trace-summary { display: flex; gap: 16px; margin-bottom: 16px; padding: 12px; background: var(--hover-background); border-radius: 8px; }
.ms-qc-trace-stat { display: flex; flex-direction: column; align-items: center; flex: 1; }
.ms-qc-trace-stat-label { font-size: 11px; color: var(--text-secondary); }
.ms-qc-trace-stat-value { font-size: 16px; font-weight: 600; color: var(--text-primary); }
.ms-qc-stats { display: flex; gap: 16px; margin-bottom: 20px; padding: 16px; background: var(--hover-background); border-radius: 12px; }
.ms-qc-stat { display: flex; flex-direction: column; align-items: center; flex: 1; }
.ms-qc-stat-value { font-size: 28px; font-weight: 700; color: var(--text-primary); line-height: 1; }
.ms-qc-stat-value.ms-qc-pass { color: var(--success-color); }
.ms-qc-stat-value.ms-qc-fail { color: var(--danger-color); }
.ms-qc-stat-label { font-size: 11px; color: var(--text-secondary); margin-top: 4px; }
.ms-qc-progress-section { margin-bottom: 16px; }
.ms-qc-status { font-size: 13px; color: var(--text-secondary); margin-bottom: 8px; }
.ms-qc-bar { height: 6px; background: var(--border-light); border-radius: 3px; overflow: hidden; }
.ms-qc-bar-fill { height: 100%; background: var(--primary-color); border-radius: 3px; transition: width 0.1s; }
.ms-qc-report { margin-bottom: 16px; }
.ms-qc-item { display: flex; align-items: flex-start; gap: 10px; padding: 10px 12px; border-radius: 8px; margin-bottom: 8px; }
.ms-qc-item.ms-qc-pass { background: rgba(var(--success-color), 0.08); }
.ms-qc-item.ms-qc-fail { background: rgba(var(--danger-color), 0.08); }
.ms-qc-item.ms-qc-warn { background: rgba(var(--warning-color), 0.08); }
.ms-qc-icon { font-size: 16px; flex-shrink: 0; }
.ms-qc-pass .ms-qc-icon { color: var(--success-color); }
.ms-qc-fail .ms-qc-icon { color: var(--danger-color); }
.ms-qc-warn .ms-qc-icon { color: var(--warning-color); }
.ms-qc-content { display: flex; flex-direction: column; gap: 2px; }
.ms-qc-check { font-size: 13px; color: var(--text-primary); font-weight: 500; }
.ms-qc-details { font-size: 12px; color: var(--text-secondary); }
.ms-qc-issues { margin-top: 16px; padding: 16px; background: rgba(var(--danger-color), 0.05); border-radius: 12px; border: 1px solid rgba(var(--danger-color), 0.2); }
.ms-qc-issues h3 { margin: 0 0 12px; font-size: 14px; color: var(--danger-color); }
.ms-qc-issues-list { max-height: 200px; overflow-y: auto; }
.ms-qc-issue-item { display: flex; align-items: center; gap: 12px; padding: 6px 8px; font-size: 12px; }
.ms-qc-issue-trace { color: var(--text-primary); font-weight: 500; min-width: 70px; }
.ms-qc-issue-group { color: var(--primary-color); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ms-qc-issue-status { color: var(--success-color); min-width: 32px; }
.ms-qc-issue-status.fail { color: var(--danger-color); }
.ms-qc-issue-status.warn { color: var(--warning-color); }
.ms-qc-more { text-align: center; font-size: 11px; color: var(--text-secondary); padding: 8px; }
.ms-qc-footer { display: flex; justify-content: flex-end; margin-top: 16px; }
</style>