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
2647 lines
123 KiB
Vue
2647 lines
123 KiB
Vue
<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">×</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>
|