Files
momentry_studio/src/views/PeopleView.vue
Momentry Studio e6b9b7b4b8 refactor: add # separator to display format
Display format with # separator:
- Frame: F#{number} (e.g., F#333)
- Face Trace: FS#{number} (e.g., FS#233)

Changes:
- Line 105: Frame range display (F#2219–F#2225)
- Line 143: Face Detail Modal (file_uuid:FS#233)
- Line 262: Cluster strip fallback (FS#233)
- Line 264: Cluster strip label (FS#233)

Benefits:
- # separator makes ID clearer
- Consistent format across all displays
- Visual distinction between type and number
2026-07-25 13:55:01 +08:00

2647 lines
123 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-toolbar">
<select v-model="selectedFileUuid" @change="onFileFilterChange" class="ms-ppl-file-select">
<option value="">Please select a video file</option>
<option v-for="f in fileFilterList" :key="f.file_uuid" :value="f.file_uuid">{{ f.file_name }}</option>
</select>
<button v-if="selectedFileUuid" class="ms-ppl-section-toggle-btn" :class="{ active: showCluster }" @click="showCluster = !showCluster">
{{ t('people.toolbar.face_group') }} <span class="ms-ppl-toggle-dot" :class="{ on: showCluster }"></span>
</button>
<button v-if="selectedFileUuid" class="ms-ppl-section-toggle-btn" :class="{ active: showUface }" @click="showUface = !showUface">
{{ t('people.toolbar.pending_faces') }} <span class="ms-ppl-toggle-dot" :class="{ on: showUface }"></span>
</button>
<button v-if="selectedFileUuid" class="ms-ppl-cluster-btn ms-ppl-cluster-btn-inline" @click="runClusterAgent" :disabled="clusterRunning">
<svg v-if="!clusterRunning" width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M21 12a9 9 0 11-6.219-8.56" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><path d="M21 3v6h-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
<div v-else class="spinner-sm"></div>
{{ clusterRunning ? 'Running...' : 'Face Deduplication' }}
</button>
<button v-if="selectedFileUuid" class="ms-ppl-section-toggle-btn" @click="runDataQC" :disabled="showQCModal" title="Run Data Quality Check">🔍 QC</button>
<button class="ms-ppl-section-toggle-btn" @click="toggleDark()" title="Toggle Dark Mode">{{ isDark ? '☀' : '☾' }}</button>
</div>
<!-- Undo Banner -->
<div v-if="showUndoBanner" class="ms-undo-banner">
<span>Deleted {{ lastDeletedTraces.length }} trace(s)</span>
<button class="ms-fm-btn ms-fm-btn-sm ms-fm-btn-primary" @click="undoLastDelete"> Undo</button>
<button class="ms-fm-btn ms-fm-btn-sm" @click="showUndoBanner = false; lastDeletedTraces = []"></button>
</div>
<!-- Operation Loading Overlay -->
<div v-if="operationLoading" class="ms-operation-overlay">
<div class="ms-operation-spinner"></div>
<span class="ms-operation-message">{{ operationMessage }}</span>
</div>
<div v-if="loading" class="loading-state">
<div class="spinner-lg"></div>
<p>{{ loadingStep || 'Loading...' }}</p>
</div>
<template v-else>
<!-- Face Group -->
<div v-if="showCluster && clusterAsPending.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">({{ clusterAsPending.length }})</span></div>
<button v-if="!batchMode" class="ms-fm-btn ms-fm-btn-sm" @click="batchMode = true"> Select</button>
<template v-else>
<button class="ms-fm-btn ms-fm-btn-sm ms-fm-btn-primary" @click="batchMergeGroups" :disabled="selectedGroups.length < 2"> Merge ({{ selectedGroups.length }})</button>
<button class="ms-fm-btn ms-fm-btn-sm ms-fm-btn-danger" @click="batchDeleteGroups" :disabled="!selectedGroups.length">🗑 Delete ({{ selectedGroups.length }})</button>
<button class="ms-fm-btn ms-fm-btn-sm" @click="clearSelection"> Cancel</button>
</template>
</div>
<div class="ms-ppl-face-grid">
<div v-for="c in clusterAsPending" :key="c.identity_uuid" class="ms-ppl-face-card ms-ppl-cluster-card" :class="{ 'ms-ppl-face-card-selected': isGroupSelected(c) }" @click="batchMode ? toggleGroupSelection(c) : selectCluster(c)" title="View member faces">
<div v-if="batchMode" class="ms-ppl-card-checkbox" @click.stop="toggleGroupSelection(c)">
<span v-if="isGroupSelected(c)"></span>
</div>
<div class="ms-ppl-face-img-wrap">
<img v-if="c.rep_trace && faceThumbsCache[thumbTraceKey(c.rep_trace)]" :src="faceThumbsCache[thumbTraceKey(c.rep_trace)]" alt="">
<svg v-else class="ms-silhouette" viewBox="0 0 120 120" fill="none">
<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>
<span class="ms-ppl-face-name">{{ c.trace_count }} Faces</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 v-if="!batchMode" class="ms-fm-btn ms-fm-btn-sm" @click="batchMode = true"> Select</button>
<template v-else>
<button class="ms-fm-btn ms-fm-btn-sm ms-fm-btn-primary" @click="batchDeleteFaces" :disabled="!selectedFaces.length">🗑 Delete ({{ selectedFaces.length }})</button>
<button class="ms-fm-btn ms-fm-btn-sm ms-fm-btn-blue" @click="batchMoveFaces" :disabled="!selectedFaces.length"> Move ({{ selectedFaces.length }})</button>
<button class="ms-fm-btn ms-fm-btn-sm" @click="clearSelection"> Cancel</button>
</template>
</div>
<div v-if="unassignedTraces.length" class="ms-ppl-face-grid ms-uface-grid">
<div v-for="t in unassignedTraces" :key="`${t.trace_id}-${t.file_uuid}`" class="ms-ppl-face-card" :class="{ 'ms-ppl-face-card-selected': isFaceSelected(t) }" :data-file-uuid="t.file_uuid" @click="batchMode ? toggleFaceSelection(t) : openFaceDetailFromTrace(t)" v-observe="() => loadTraceThumb(t)">
<div v-if="batchMode" class="ms-ppl-card-checkbox" @click.stop="toggleFaceSelection(t)">
<span v-if="isFaceSelected(t)"></span>
</div>
<div class="ms-ppl-face-img-wrap">
<template v-if="activeFrameCard === `${t.trace_id}-${t.file_uuid}`">
<img :src="`/api/v1/media/frame?file_uuid=${t.file_uuid}&frame=${t.best_face_frame ?? t.start_frame ?? 0}`" alt="Key Frame" class="ms-ppl-frame-img">
<div v-if="activeFrameMode === 'bbox' && t.best_face_bbox" class="ms-ppl-bbox-overlay" :style="{
left: (t.best_face_bbox.x / (fileInfoCache[t.file_uuid]?.width || 1) * 100) + '%',
top: (t.best_face_bbox.y / (fileInfoCache[t.file_uuid]?.height || 1) * 100) + '%',
width: (t.best_face_bbox.width / (fileInfoCache[t.file_uuid]?.width || 1) * 100) + '%',
height: (t.best_face_bbox.height / (fileInfoCache[t.file_uuid]?.height || 1) * 100) + '%'
}"></div>
</template>
<template v-else>
<img v-if="faceThumbsCache[thumbTraceKey(t)]" :src="faceThumbsCache[thumbTraceKey(t)]" alt="">
<div v-else class="face-placeholder">{{ t.frame_count }}f</div>
</template>
</div>
<span class="ms-ppl-face-name">F#{{ 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>
<!-- 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]" :src="candidateThumbs[faceDetailModal.candidate?.id]" alt="">
<div v-else class="face-placeholder">{{ Math.round(faceDetailModal.candidate?.confidence * 100 || 0) }}%</div>
</div>
<div class="ms-face-detail-info">
<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 v-if="faceDetailPose" class="ms-face-detail-pose">
<div class="ms-pose-controls">
<label class="ms-pose-toggle"><input type="checkbox" v-model="showBboxOverlay"> BBox</label>
<label class="ms-pose-toggle"><input type="checkbox" v-model="showPoseOverlay"> Pose</label>
<span class="ms-pose-frame-no">Frame: {{ faceDetailModal.candidate?.frame_number || '—' }}</span>
</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.identity_uuid" class="ms-merge-face-card" @click="executeMerge(c.identity_uuid)">
<div class="ms-merge-face-img">
<img v-if="c.rep_trace && faceThumbsCache[thumbTraceKey(c.rep_trace)]" :src="faceThumbsCache[thumbTraceKey(c.rep_trace)]" alt="">
<svg v-else viewBox="0 0 120 120" fill="none" style="width:100%;height:100%;">
<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 clusterAsPending" :key="c.identity_uuid" class="ms-merge-face-card" @click="moveToGroup(c)">
<div class="ms-merge-face-img">
<img v-if="c.rep_trace && faceThumbsCache[thumbTraceKey(c.rep_trace)]" :src="faceThumbsCache[thumbTraceKey(c.rep_trace)]" alt="">
<svg v-else viewBox="0 0 120 120" fill="none" style="width:100%;height:100%;">
<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>
<!-- 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">Traces</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">Trace #{{ 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, loadFaceThumb, loadTraceThumb, loadThumbnail, thumbnailsCache, clusterResultsCache, clusterRunningState, isClusterRunning, setClusterRunning, getClusterResults, setClusterResults, pollClusterResults, updateTraceProfileGroup, loadClusterNames, getTraceProfile, getPose, getAppearance, syncAllProcessingFiles } from '@/store'
import { useI18n } from 'vue-i18n'
import { drawPoseSkeleton, drawAppearanceOverlay } from '@/utils/poseRenderer'
import type { PoseResponse, AppearanceResponse } from '@/api/types'
import { isDark, toggleDark } from '@/composables/useTheme'
import { getNextZIndex } from '@/composables/useZIndex'
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 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 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.identity_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 clusterAsPending = computed(() => {
console.log('[clusterAsPending] computing, selectedFileUuid:', selectedFileUuid.value, 'clusterResults:', clusterResults.value.length, clusterResults.value)
if (!selectedFileUuid.value || !clusterResults.value.length) {
console.log('[clusterAsPending] empty:', { selected: selectedFileUuid.value, clusters: clusterResults.value.length })
return []
}
console.log('[clusterAsPending] mapping', clusterResults.value.length, 'clusters')
return 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 {
identity_uuid: `cluster_${c.cluster_id ?? c.group_id}`,
identity_id: `cluster_${String(c.cluster_id ?? c.group_id).padStart(2, '0')}`,
name: c.name,
status: 'pending',
starred: false,
trace_count: c.trace_count,
representative_trace: repTraceId,
cluster_id: c.cluster_id ?? c.group_id,
trace_ids: traceIds,
rep_trace: repTrace,
}
})
})
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 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 = 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
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)
}
refreshing.value = false
loadingStep.value = ''
// Resume polling if cluster was running for this file
if (isClusterRunning(selectedFileUuid.value)) {
pollClusterResults(selectedFileUuid.value, () => loadClusterResults())
} else {
loadClusterResults()
}
} 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
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
}
// 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.identity_uuid
if (!identityCounts[key]) {
identityCounts[key] = { name: s.name, uuid: s.identity_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 || candidateThumbs.value[c.id]) 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
// Debug: log coordinates
console.log('[redrawOverlay] canvas:', canvas.width, 'x', canvas.height)
console.log('[redrawOverlay] bbox:', bbox)
console.log('[redrawOverlay] pose keypoints:', pose?.keypoints?.slice(0, 3))
if (showBboxOverlay.value && bbox) {
// Check if bbox is normalized (0-1) or absolute
const isNormalized = bbox.x <= 1.0 && bbox.y <= 1.0 && bbox.width <= 1.0 && bbox.height <= 1.0
console.log('[redrawOverlay] bbox isNormalized:', isNormalized)
const x = isNormalized ? bbox.x * canvas.width : bbox.x
const y = isNormalized ? bbox.y * canvas.height : bbox.y
const w = isNormalized ? bbox.width * canvas.width : bbox.width
const h = isNormalized ? bbox.height * canvas.height : bbox.height
console.log('[redrawOverlay] drawing bbox 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) {
const faceKps = ['nose', 'left_eye', 'right_eye']
const faceKeypoints = pose.keypoints.filter(kp =>
faceKps.includes(kp.name) && kp.confidence > 0 && kp.x > 0 && kp.y > 0
)
if (faceKeypoints.length < 2) return
const centerX = faceKeypoints.reduce((sum, kp) => sum + kp.x, 0) / faceKeypoints.length
const centerY = faceKeypoints.reduce((sum, kp) => sum + kp.y, 0) / faceKeypoints.length
const inBbox = centerX >= bbox.x && centerX <= bbox.x + bbox.width &&
centerY >= bbox.y && centerY <= bbox.y + bbox.height
if (!inBbox) return
const validKeypoints = pose.keypoints.filter(kp => kp.confidence > 0 && kp.x > 0 && kp.y > 0)
ctx.fillStyle = '#00ff00'
for (const kp of validKeypoints) {
ctx.beginPath()
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[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[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.identity_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 = []
batchMode.value = false
batchAction.value = ''
}
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[selectedFileUuid.value] = validClusters
}
function updateClusterAfterMerge(sourceGroupId: string, targetGroupId: string) {
const clusters = clusterResultsCache.value[selectedFileUuid.value]
if (!clusters) return
const sourceCluster = clusters.find((c: any) => c.identity_uuid === sourceGroupId)
const targetCluster = clusters.find((c: any) => c.identity_uuid === targetGroupId)
if (sourceCluster && targetCluster) {
targetCluster.trace_ids = [...targetCluster.trace_ids, ...sourceCluster.trace_ids]
targetCluster.trace_count = targetCluster.trace_ids.length
const idx = clusters.findIndex((c: any) => c.identity_uuid === sourceGroupId)
if (idx >= 0) {
clusters.splice(idx, 1) // Remove empty source group from cache
}
// Trigger computed property update
clusterResultsCache.value = { ...clusterResultsCache.value }
}
}
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 = clusterAsPending.value.filter((c: any) => groupIds.includes(c.identity_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 = clusterAsPending.value.filter((c: any) => groupIds.includes(c.identity_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 = clusterAsPending.value.find((c: any) => c.identity_uuid === targetGroupId)
console.log('[executeMerge] targetGroup:', targetGroup)
if (!targetGroup) return
const targetTraceId = targetGroup.trace_ids?.[0]
console.log('[executeMerge] targetTraceId:', targetTraceId, typeof targetTraceId)
if (!targetTraceId) {
alert('Target group has no traces')
return
}
const targetTrace = clusterTracesMap.value[String(targetTraceId)]
console.log('[executeMerge] targetTrace:', targetTrace)
const targetFileUuid = targetTrace?.file_uuid
if (!targetFileUuid) {
alert('Target trace has no file_uuid')
return
}
const groupIds = Array.from(selectedGroups.value)
const sourceGroupIds = groupIds.filter(id => id !== targetGroupId)
const sourceGroups = clusterAsPending.value.filter((c: any) => sourceGroupIds.includes(c.identity_uuid))
console.log('[executeMerge] sourceGroups:', sourceGroups.length)
let totalTraces = 0
let validTraces = 0
for (const g of sourceGroups) {
for (const tid of g.trace_ids || []) {
totalTraces++
const t = clusterTracesMap.value[String(tid)]
if (t?.file_uuid) validTraces++
}
}
console.log('[executeMerge] totalTraces:', totalTraces, 'validTraces:', validTraces)
if (validTraces === 0) {
alert('No valid traces to merge (all missing file_uuid)')
return
}
if (!confirm(`Merge ${validTraces}/${totalTraces} trace(s) from ${sourceGroups.length} group(s)?`)) return
operationLoading.value = true
operationMessage.value = `Merging ${validTraces} trace(s)...`
let successCount = 0
let failCount = 0
for (const g of sourceGroups) {
for (const srcTraceId of g.trace_ids || []) {
const srcTrace = clusterTracesMap.value[String(srcTraceId)]
const srcFileUuid = srcTrace?.file_uuid
if (!srcFileUuid) {
console.warn('[merge] skipping trace without file_uuid:', srcTraceId)
failCount++
continue
}
if (srcFileUuid !== targetFileUuid) {
console.warn('[merge] skipping cross-file trace:', srcTraceId, srcFileUuid, '!=', targetFileUuid)
failCount++
continue
}
try {
console.log('[executeMerge] calling merge_trace:', {
file_uuid: srcFileUuid,
source_id: srcTraceId,
target_id: targetTraceId
})
await apiCall('merge_trace', {
file_uuid: srcFileUuid,
source_id: srcTraceId,
target_id: targetTraceId
})
successCount++
} catch (e) {
console.error('Merge trace failed:', srcTraceId, e)
failCount++
}
}
}
console.log('[executeMerge] completed:', successCount, 'success,', failCount, 'failed')
operationLoading.value = false
operationMessage.value = ''
clearSelection()
await loadClusterResults()
}
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 targetTrace = clusterTracesMap.value[String(targetTraceId)]
const targetFileUuid = targetTrace?.file_uuid
if (!targetFileUuid) {
alert('Target trace has no file_uuid')
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)
}
}
removeTracesFromCache(selectedFaces.value)
operationLoading.value = false
operationMessage.value = ''
clearSelection()
}
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
try {
const result: any = await apiCall('create_identity_from_face', {
fileUuid: firstTrace.file_uuid,
name,
traceIds: traceList.map(t => t.trace_id)
})
if (result?.identity_uuid) {
clearSelection()
await refresh()
}
} 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, filePath: '', fileName: '' }) },
{ 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 }
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 {
await apiCall('update_file_profile', {
fileUuid,
filePath: '',
fileName: ''
})
fileProfileResult.canWrite = 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, 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 clusterAsPending.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} traces in ${clusterAsPending.value.length} clusters`
})
// ===== 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 #${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
if (profile) {
result.hasKeyFrame = profile.key_frame != null && profile.key_frame >= 0
result.hasKeyFace = profile.key_face != null && profile.key_face >= 0
if (result.hasKeyFrame) withKeyFrame++
if (result.hasKeyFace) withKeyFace++
}
} catch (e: any) {
result.error = e?.message?.slice(0, 30) || 'read failed'
}
if (result.canRead) {
try {
await apiCall('update_trace_profile', {
fileUuid: trace.file_uuid,
traceId,
label: ''
})
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
}
// Trace 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: 'Trace Profile Read',
status: readFailures === 0 ? 'PASS' : 'FAIL',
details: readFailures === 0
? `All ${validTraceCount} readable`
: `${readFailures}/${validTraceCount} NOT readable`
})
qcReport.value.push({
check: 'Trace 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; }
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; }
.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-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.ms-ppl-section-title { font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 14px; font-weight: 600; color: var(--text-primary); margin: 0; display: flex; align-items: center; gap: 6px; }
.ms-ppl-section-count { font-weight: 400; color: var(--muted-text); font-size: 13px; }
.ms-ppl-hr { border: none; border-top: 1.5px solid var(--border-light); margin: 24px 0; }
.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 { box-shadow: 0 0 0 2px var(--primary-color); }
.ms-ppl-card-checkbox { position: absolute; top: 4px; right: 4px; width: 20px; height: 20px; border-radius: 4px; background: var(--card-background); border: 1.5px solid var(--border-color); display: flex; align-items: center; justify-content: center; font-size: 12px; color: var(--primary-color); z-index: 3; cursor: pointer; }
.ms-ppl-card-checkbox:hover { border-color: var(--primary-color); }
.ms-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-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-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>