Files
momentry_studio/src/views/PeopleView.vue
Momentry Studio e65d5648b8 feat: 批量 unregister 功能 + 性能优化
功能:
- LibraryView 添加批量 unregister 按钮(勾选多个已注册文件后显示)
- 添加确认对话框,支持批量取消注册
- 添加中英日三语翻译

性能优化:
- search_agents API timeout 提升到 90s(避免前端超时)
- thumbnail 并发数从 32 降到 10(减少服务器压力)

修复:
- PeopleView 添加 group_uuid 字段映射
- LibraryView 排序优化(scanning/preparing 优先)
2026-10-07 23:39:31 +08:00

4463 lines
212 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="people-view">
<div class="ms-ppl-control-area">
<div class="ms-ppl-toolbar">
<select v-model="selectedFileUuid" @change="onFileFilterChange" class="ms-ppl-file-select">
<option value="">Please select a video file</option>
<option v-for="f in fileFilterList" :key="f.file_uuid" :value="f.file_uuid">{{ f.file_name }}</option>
</select>
<button v-if="selectedFileUuid" class="ms-ppl-section-toggle-btn" :class="{ active: showCluster }" @click="showCluster = !showCluster">
{{ t('people.toolbar.face_group') }} <span class="ms-ppl-toggle-dot" :class="{ on: showCluster }"></span>
</button>
<button v-if="selectedFileUuid" class="ms-ppl-section-toggle-btn" :class="{ active: showUface }" @click="showUface = !showUface">
{{ t('people.toolbar.pending_faces') }} <span class="ms-ppl-toggle-dot" :class="{ on: showUface }"></span>
</button>
<button v-if="selectedFileUuid" class="ms-ppl-cluster-btn ms-ppl-cluster-btn-inline" @click="runClusterAgent" :disabled="clusterRunning">
<svg v-if="!clusterRunning" width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M21 12a9 9 0 11-6.219-8.56" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><path d="M21 3v6h-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
<div v-else class="spinner-sm"></div>
{{ clusterRunning ? 'Running...' : 'Face Deduplication' }}
</button>
<button v-if="selectedFileUuid" class="ms-ppl-section-toggle-btn" @click="refresh" :disabled="refreshing" title="Refresh face groups and pending faces">
<svg v-if="!refreshing" 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>
Refresh
</button>
<button v-if="selectedFileUuid" class="ms-ppl-section-toggle-btn" :class="{ active: showFaceFilter }" @click="toggleFaceFilter">
Face Filter <span class="ms-ppl-toggle-dot" :class="{ on: showFaceFilter }"></span>
</button>
<button v-if="selectedFileUuid && isAdvanced" class="ms-ppl-section-toggle-btn" @click="runDataQC" :disabled="showQCModal" title="Run Data Quality Check">🔍 QC</button>
<button class="ms-ppl-close-btn" @click="goToLibrary" title="Back to Library">✕</button>
</div>
<!-- Undo Banner -->
<div v-if="showUndoBanner" class="ms-undo-banner">
<span>Deleted {{ lastDeletedTraces.length }} trace(s)</span>
<button class="ms-fm-btn ms-fm-btn-sm ms-fm-btn-primary" @click="undoLastDelete">↩ Undo</button>
<button class="ms-fm-btn ms-fm-btn-sm" @click="showUndoBanner = false; lastDeletedTraces = []">✕</button>
</div>
<!-- Operation Loading Overlay -->
<div v-if="operationLoading" class="ms-operation-overlay">
<div class="ms-operation-spinner"></div>
<span class="ms-operation-message">{{ operationMessage }}</span>
</div>
</div>
<div class="ms-ppl-scroll-area">
<div v-if="loading" class="loading-state">
<div class="spinner-lg"></div>
<p>{{ loadingStep || 'Loading...' }}</p>
</div>
<template v-else>
<!-- Face Filter (sticky glass panel — grids scroll underneath) -->
<div v-if="showFaceFilter && selectedFileUuid" class="ms-ppl-section ms-face-filter-section">
<div class="ms-ppl-section-toolbar">
<div class="ms-ppl-section-title">Face Filter <span class="ms-ff-count" :class="{ cut: ffTraceHidden > 0 }">keep {{ ffTraceVisible }} / {{ ffTraceTotal }} FS (Face Sequence)</span></div>
<div class="ms-ff-toolbar-actions">
<button class="ms-fm-btn ms-fm-btn-sm" @click="resetFilterToDefault" title="Reset to system default">⟲</button>
<button class="ms-fm-btn ms-fm-btn-sm" @click="loadFaces" :disabled="ffLoading">↻</button>
<button class="ms-fm-btn ms-fm-btn-sm" @click="showFaceFilter = false" title="Close face filter">✕</button>
</div>
</div>
<div v-if="ffLoading" class="loading-state"><div class="spinner-sm"></div></div>
<template v-else>
<div class="ms-ff-hist-row">
<!-- Confidence histogram -->
<div class="ms-ff-hist-group">
<div class="ms-ff-hist-title">Confidence</div>
<div class="ms-ff-hist-wrap" @click="setThreshold('conf', $event)">
<div class="ms-ff-hist">
<div v-for="b in ffConfBins" :key="b.key" class="ms-ff-hist-col" :title="`${b.label}: ${b.count} faces`">
<div class="ms-ff-hist-bar" :class="{ cut: b.isCut }" :style="{ height: ffBarH(b) }">
<span v-if="b.count" class="ms-ff-hist-num">{{ b.count }}</span>
</div>
</div>
<div class="ms-ff-rug">
<div v-for="d in confRugDots" :key="d.trace_id" class="ms-ff-rug-tick" :class="{ cut: d.cut }" :style="{ left: d.pct + '%' }" :title="d.value.toFixed(1) + '%'"></div>
</div>
<div v-if="ffFaces.length" class="ms-ff-hist-cursor" :style="{ left: ffConfCursorPct + '%' }" @mousedown.stop="startDrag('conf', $event)">
<span class="ms-ff-cursor-label">{{ ffMinConfPct.toFixed(1) }}%</span>
</div>
</div>
<div class="ms-ff-hist-axis">
<span class="ms-ff-hist-tick">{{ ffConfMinPct.toFixed(1) }}</span>
<span class="ms-ff-hist-tick ms-ff-hist-tick-end">{{ ffConfMaxPct.toFixed(1) }}</span>
</div>
</div>
</div>
<!-- Face size histogram -->
<div class="ms-ff-hist-group">
<div class="ms-ff-hist-title">Face Size</div>
<div class="ms-ff-hist-wrap" @click="setThreshold('size', $event)">
<div class="ms-ff-hist">
<div v-for="b in ffSizeBins" :key="b.key" class="ms-ff-hist-col" :title="`${b.label}: ${b.count} faces`">
<div class="ms-ff-hist-bar" :class="{ cut: b.isCut }" :style="{ height: ffBarH(b) }">
<span v-if="b.count" class="ms-ff-hist-num">{{ b.count }}</span>
</div>
</div>
<div class="ms-ff-rug">
<div v-for="d in sizeRugDots" :key="d.trace_id" class="ms-ff-rug-tick" :class="{ cut: d.cut }" :style="{ left: d.pct + '%' }" :title="Math.round(d.value) + 'px'"></div>
</div>
<div v-if="ffFaces.length" class="ms-ff-hist-cursor" :style="{ left: ffSizeCursorPct + '%' }" @mousedown.stop="startDrag('size', $event)">
<span class="ms-ff-cursor-label">{{ ffMinSizePx }}px</span>
</div>
</div>
<div class="ms-ff-hist-axis">
<span class="ms-ff-hist-tick">{{ ffSizeMinRounded }}</span>
<span class="ms-ff-hist-tick ms-ff-hist-tick-end">{{ ffSizeMaxRounded }}</span>
</div>
</div>
</div>
<!-- Face capture quality histogram -->
<div class="ms-ff-hist-group">
<div class="ms-ff-hist-title">Capture Quality</div>
<div class="ms-ff-hist-wrap" @click="setThreshold('quality', $event)">
<div class="ms-ff-hist">
<div v-for="b in ffQualityBins" :key="b.key" class="ms-ff-hist-col" :title="`${b.label}: ${b.count} faces`">
<div class="ms-ff-hist-bar" :class="{ cut: b.isCut }" :style="{ height: ffBarH(b) }">
<span v-if="b.count" class="ms-ff-hist-num">{{ b.count }}</span>
</div>
</div>
<div class="ms-ff-rug">
<div v-for="d in qualityRugDots" :key="d.trace_id" class="ms-ff-rug-tick" :class="{ cut: d.cut }" :style="{ left: d.pct + '%' }" :title="d.value.toFixed(1) + '%'"></div>
</div>
<div v-if="ffQualityValues.length" class="ms-ff-hist-cursor" :style="{ left: ffQualityCursorPct + '%' }" @mousedown.stop="startDrag('quality', $event)">
<span class="ms-ff-cursor-label">{{ ffMinQuality.toFixed(1) }}%</span>
</div>
</div>
<div class="ms-ff-hist-axis">
<span class="ms-ff-hist-tick">{{ ffQualityMinPct.toFixed(1) }}</span>
<span class="ms-ff-hist-tick ms-ff-hist-tick-end">{{ ffQualityMaxPct.toFixed(1) }}</span>
</div>
</div>
</div>
</div>
<div class="ms-ff-stats">
<div v-if="!ffFaces.length" class="ms-ff-stats-empty">No faces</div>
<template v-else>
<div class="ms-ff-stats-bar">
<span class="ms-ff-stats-bar-label">FS</span>
<span class="ms-ff-stats-bar-val"><b>{{ ffTraceVisible }}</b><span class="ms-ff-stats-bar-sep">/</span>{{ ffTraceHidden }} hidden<span class="ms-ff-stats-bar-sep">/</span>{{ ffTraceTotal }} total</span>
</div>
</template>
</div>
<div class="ms-ff-single-frame-section">
<div class="ms-ff-section-header">
<div class="ms-ff-section-title">Single Frame FS Settings</div>
<div class="ms-ff-section-actions">
<button class="ms-fm-btn ms-fm-btn-sm ms-fm-btn-primary" @click="saveSingleFrameSettings" :disabled="!singleFrameSettingsModified || singleFrameSettingsLoading">
{{ singleFrameSettingsLoading ? 'Saving...' : 'Apply Settings' }}
</button>
<button class="ms-fm-btn ms-fm-btn-sm" @click="resetSingleFrameSettings" :disabled="singleFrameSettingsLoading">Reset to Default</button>
</div>
</div>
<div class="ms-ff-checkbox-grid">
<div class="ms-ff-checkbox-item">
<label class="ms-ff-checkbox-label">
<input type="checkbox" v-model="singleFrameSettings.single_frame_trace_vlm_enabled" @change="markSingleFrameSettingsModified" />
VLM Processing
</label>
<div class="ms-ff-hint">Enable VLM for single-frame traces</div>
</div>
<div class="ms-ff-checkbox-item">
<label class="ms-ff-checkbox-label">
<input type="checkbox" v-model="singleFrameSettings.single_frame_trace_group_enabled" @change="markSingleFrameSettingsModified" />
Face Groups
</label>
<div class="ms-ff-hint">Include single-frame traces in groups</div>
</div>
<div class="ms-ff-checkbox-item">
<label class="ms-ff-checkbox-label">
<input type="checkbox" v-model="singleFrameSettings.single_frame_trace_unassigned_enabled" @change="markSingleFrameSettingsModified" />
Unassigned List
</label>
<div class="ms-ff-hint">Show single-frame traces in unassigned</div>
</div>
<div class="ms-ff-checkbox-item">
<label class="ms-ff-checkbox-label">
<input type="checkbox" v-model="singleFrameSettings.single_trace_group_filter_enabled" @change="markSingleFrameSettingsModified" />
Single Trace Groups
</label>
<div class="ms-ff-hint">Filter auto-generated Person_N groups</div>
</div>
</div>
</div>
</template>
</div>
<!-- Face Group -->
<div v-if="showCluster && faceGroups.length" class="ms-ppl-section">
<div class="ms-ppl-section-toolbar">
<div class="ms-ppl-section-title">{{ t('people.section.face_group') }}: <span class="ms-ppl-section-count">({{ groupSearchQuery ? filteredFaceGroups.length : faceGroups.length }})</span></div>
<input v-model="groupSearchQuery" class="ms-cluster-search-input ms-group-search-input" placeholder="Search groups..." />
<button v-if="selectedGroups.length >= 1" class="ms-fm-btn ms-fm-btn-sm ms-ppl-ai-btn" @click="aiSuggest" :disabled="aiSuggestLoading">{{ aiSuggestLoading ? '🤖 ...' : '🤖 Suggest' }}</button>
<button class="ms-fm-btn ms-fm-btn-sm ms-fm-btn-primary" @click="batchMergeGroups" :disabled="selectedGroups.length < 2">⇄ Merge ({{ selectedGroups.length }})</button>
<button class="ms-fm-btn ms-fm-btn-sm ms-fm-btn-primary" @click="batchDeleteGroups" :disabled="!selectedGroups.length">Unassign ({{ selectedGroups.length }})</button>
<button class="ms-fm-btn ms-fm-btn-sm ms-fm-btn-danger" @click="batchDeleteGroupTraces" :disabled="!selectedGroups.length">Delete ({{ selectedGroups.length }})</button>
<button v-if="selectedGroups.length" class="ms-fm-btn ms-fm-btn-sm" @click="clearSelection">✕ Cancel</button>
</div>
<div ref="groupGridEl" class="ms-ppl-face-grid" @mousedown="groupsDragMousedown" @click.capture="groupsDragClick">
<div v-for="c in filteredFaceGroups" :key="c.group_uuid" class="ms-ppl-face-card ms-ppl-cluster-card" :class="{ 'ms-ppl-face-card-selected': isGroupSelected(c) }" :data-drag-key="c.group_uuid" @click="selectCluster(c)" title="View member faces">
<div class="ms-ppl-face-img-wrap">
<img v-if="c.rep_trace && faceThumbsCache[thumbTraceKey(c.rep_trace)]" :src="faceThumbsCache[thumbTraceKey(c.rep_trace)]" alt="" loading="lazy">
<div v-else-if="c.rep_trace && loadingFaceThumbs.has(thumbTraceKey(c.rep_trace))" class="ms-ppl-face-img-loading"><span class="spinner-sm"></span></div>
<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"><template v-for="(seg, i) in highlightSegments(c.name, groupSearchQuery)" :key="i"><span :class="{ 'ms-search-hit': seg.hit }">{{ seg.text }}</span></template></span>
<div class="ms-ppl-card-checkbox" @click.stop="toggleGroupSelection(c)">
<span v-if="isGroupSelected(c)">✓</span>
</div>
</div>
<span class="ms-ppl-face-name">{{ ffFilterActive ? c.visible_count : c.trace_count }} FS<span v-if="ffFilterActive && c.visible_count !== c.trace_count" class="ms-ff-group-partial">{{ c.trace_count }}</span></span>
</div>
<div v-if="groupSearchQuery && filteredFaceGroups.length === 0" class="ms-ppl-empty-groups">No matching groups</div>
<div v-else-if="ffFilterActive && faceGroups.length === 0" class="ms-ppl-empty-groups">All groups filtered out — lower min confidence / min face size</div>
<div v-if="groupsDragBox" class="ms-select-rect" :style="dragSelectRectStyle(groupsDragBox)"></div>
</div>
</div>
<!-- Pending Faces -->
<div v-if="showUface && (unassignedTraces.length || searchFacesResults.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">({{ ffFilterActive ? visibleUnassignedCount + ' / ' : '' }}{{ facesPagingActive ? searchFacesTotal : (unassignedTracesTotal || unassignedTraces.length) }})</span></div>
<input v-model="facesSearchQuery" class="ms-cluster-search-input ms-faces-search-input" placeholder="Search name or F range..." />
<span v-if="facesPagingActive && searchFacesLoading" class="ms-ppl-loading-inline">Searching…</span>
<span v-if="loadingStep" class="ms-ppl-loading-inline">{{ loadingStep }}</span>
<button class="ms-fm-btn ms-fm-btn-sm ms-fm-btn-primary" @click="batchMoveFaces" :disabled="!selectedFaces.length">→ Move ({{ selectedFaces.length }})</button>
<button class="ms-fm-btn ms-fm-btn-sm ms-fm-btn-danger" @click="batchDeleteFaces" :disabled="!selectedFaces.length">🗑 Delete ({{ selectedFaces.length }})</button>
<button v-if="selectedFaces.length" class="ms-fm-btn ms-fm-btn-sm" @click="clearSelection">✕ Cancel</button>
</div>
<div v-if="unassignedTraces.length" ref="faceGridEl" class="ms-ppl-face-grid ms-uface-grid" @mousedown="facesDragMousedown" @click.capture="facesDragClick">
<div v-for="trace in filteredUnassignedTraces" :key="`${trace.trace_id}-${trace.file_uuid}`" class="ms-ppl-face-card" :class="{ 'ms-ppl-face-card-selected': isFaceSelected(trace) }" :data-drag-key="`${trace.file_uuid}:${trace.trace_id}`" @click="openFaceDetailFromTrace(trace)" v-observe="() => loadTraceThumb(trace)">
<div class="ms-ppl-face-img-wrap">
<template v-if="activeFrameCard === `${trace.trace_id}-${trace.file_uuid}`">
<img :src="`/api/v1/file/${trace.file_uuid}/thumbnail?frame=${trace.best_face_frame ?? trace.start_frame ?? 0}`" alt="Key Frame" class="ms-ppl-frame-img">
<div v-if="activeFrameMode === 'bbox' && trace.best_face_bbox" class="ms-ppl-bbox-overlay" :style="{
left: (trace.best_face_bbox.x / (fileInfoCache[trace.file_uuid]?.width || 1) * 100) + '%',
top: (trace.best_face_bbox.y / (fileInfoCache[trace.file_uuid]?.height || 1) * 100) + '%',
width: (trace.best_face_bbox.width / (fileInfoCache[trace.file_uuid]?.width || 1) * 100) + '%',
height: (trace.best_face_bbox.height / (fileInfoCache[trace.file_uuid]?.height || 1) * 100) + '%'
}"></div>
</template>
<template v-else>
<img v-if="faceThumbsCache[thumbTraceKey(trace)]" :src="faceThumbsCache[thumbTraceKey(trace)]" alt="">
<div v-else-if="loadingFaceThumbs.has(thumbTraceKey(trace))" class="face-placeholder"><span class="spinner-sm"></span></div>
<div v-else class="face-placeholder">{{ trace.frame_count }}f</div>
</template>
<div class="ms-ppl-card-checkbox" @click.stop="toggleFaceSelection(trace)">
<span v-if="isFaceSelected(trace)">✓</span>
</div>
</div>
<span class="ms-ppl-face-name">FS#<template v-for="(seg, i) in highlightSegments(String(trace.trace_id), facesSearchQuery)" :key="i"><span :class="{ 'ms-search-hit': seg.hit }">{{ seg.text }}</span></template> · {{ trace.frame_count }} frames</span>
<span class="ms-ppl-trace-group-label" :title="traceLabel(trace)"><template v-for="(seg, i) in highlightSegments(traceLabel(trace), facesSearchQuery)" :key="i"><span :class="{ 'ms-search-hit': seg.hit }">{{ seg.text }}</span></template></span>
<div class="ms-ppl-face-range">
<span class="ms-ppl-face-frame" :class="{ 'ms-search-hit': isFaceFRangeHit(trace) }"><template v-for="(seg, i) in highlightSegments('F#' + trace.start_frame + '–F#' + trace.end_frame, facesSearchQuery)" :key="i"><span :class="{ 'ms-search-hit': seg.hit }">{{ seg.text }}</span></template></span>
</div>
</div>
<div v-if="facesSearchQuery && filteredUnassignedTraces.length === 0" class="ms-ppl-empty-groups">No matching faces</div>
<div v-else-if="ffFilterActive && filteredUnassignedTraces.length === 0" class="ms-ppl-empty-groups">All faces filtered out — lower min confidence / min face size</div>
<div v-if="facesDragBox" class="ms-select-rect" :style="dragSelectRectStyle(facesDragBox)"></div>
</div>
<div v-if="selectedFileUuid && (facesPagingActive ? searchFacesTotal > 20 : 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 }} / {{ facesPagingActive ? facesTotalPages : unassignedTotalPages }} ({{ facesPagingActive ? searchFacesTotal : unassignedTracesTotal }} total)</span>
<span v-if="searchFacesLoading" class="ms-ppl-loading-inline">Loading...</span>
<button class="ms-fm-icon-btn" :disabled="unassignedTracesPage >= (facesPagingActive ? facesTotalPages : unassignedTotalPages)" @click="goToUfacePage(unassignedTracesPage + 1)">›</button>
</div>
</div>
</template>
</div>
<!-- Face Context Menu -->
<div v-if="faceCtxMenu.show" class="ms-ctx-menu" :style="{ left: faceCtxMenu.x + 'px', top: faceCtxMenu.y + 'px', display: 'block' }">
<button class="ms-ctx-item" @click="faceCtxAction('detail')">{{ t('people.face_context.details') }}</button>
</div>
<!-- Cluster Context Menu -->
<div v-if="clusterCtxMenu.show" class="ms-ctx-menu" :style="{ left: clusterCtxMenu.x + 'px', top: clusterCtxMenu.y + 'px', display: 'block' }">
<div class="ms-ctx-filename">{{ clusterCtxMenu.cluster?.name }}</div>
<hr class="ms-ctx-menu-divider">
<button class="ms-ctx-item" @click="clusterCtxAction('rename')">✏️ Edit Name</button>
<hr class="ms-ctx-menu-divider">
<button class="ms-ctx-item ms-ctx-danger" @click="clusterCtxAction('skip')">✕ Skip Group</button>
</div>
<!-- Assign modal -->
<!-- Face detail modal -->
<div v-if="faceDetailModal.show" class="ms-modal-overlay show" :style="{ zIndex: faceDetailZIndex }" @click.self="faceDetailModal.show = false">
<!-- Side navigation arrows -->
<button v-if="canFaceDetailPrev" class="ms-fs-nav-arrow ms-fs-nav-prev" @click="goFaceDetailPrev" title="Previous FS (←)">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"><polyline points="15 18 9 12 15 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<button v-if="canFaceDetailNext" class="ms-fs-nav-arrow ms-fs-nav-next" @click="goFaceDetailNext" title="Next FS (→)">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"><polyline points="9 18 15 12 9 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<div class="ms-modal ms-modal-face-detail">
<button class="ms-fm-icon-btn close-btn" @click="faceDetailModal.show = false" title="Close (Esc)">×</button>
<button class="ms-fm-icon-btn fs-delete-btn" @click="deleteCurrentFace" title="Delete this FS">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"><polyline points="3 6 5 6 21 6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<div v-if="keyFaceCtxMenu.show" class="ms-ctx-menu" :style="{ left: keyFaceCtxMenu.x + 'px', top: keyFaceCtxMenu.y + 'px', display: 'block' }">
<div class="ms-ctx-item" @click="regenerateFaceThumbnail">🔄 Regenerate Thumbnail</div>
<div class="ms-ctx-item" @click="openTraceProfileJson">📄 Open trace_profile.json</div>
<div class="ms-ctx-item" @click="showFullFace = true">🔍 Full Screen</div>
</div>
<div v-if="frameCtxMenu.show" class="ms-ctx-menu" :style="{ left: frameCtxMenu.x + 'px', top: frameCtxMenu.y + 'px', display: 'block' }">
<div class="ms-ctx-item" @click="openTraceProfileJson">📄 Open trace_profile.json</div>
<div class="ms-ctx-item" @click="showFullFrame = true">🔍 Full Screen</div>
</div>
<div class="ms-face-detail-header">
<div class="ms-face-detail-thumb" @click="showEnlargedFace = true" @contextmenu.prevent="showKeyFaceCtxMenu" style="cursor:pointer;">
<img v-if="candidateThumbs[thumbTraceKey(faceDetailModal.candidate)]" :src="candidateThumbs[thumbTraceKey(faceDetailModal.candidate)]" alt="">
<div v-else-if="faceDetailModal.candidate && loadingFaceThumbs.has(thumbTraceKey(faceDetailModal.candidate))" class="face-placeholder"><span class="spinner-sm"></span></div>
<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-value">F{{ faceDetailModal.candidate?.start_frame }}–[F{{ faceDetailModal.candidate?.frame_number }}]–F{{ faceDetailModal.candidate?.end_frame }} {{ faceDetailModal.candidate?.frame_count }} Frames</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">{{ (faceDetailModal.candidate?.confidence * 100).toFixed(1) }}%</span>
</div>
<div class="ms-face-detail-row">
<span class="ms-face-detail-label">Capture Quality:</span>
<span class="ms-face-detail-value">{{ faceDetailCaptureQuality }}</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="faceDetailFrameUrl" 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="showLandmarkOverlay"> Landmark</label>
<label class="ms-pose-toggle"><input type="checkbox" v-model="showOthersOverlay"> Others</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">
<span class="ms-lm-score">bbox: ({{ Math.round(faceDetailModal.candidate.bbox.x) }}, {{ Math.round(faceDetailModal.candidate.bbox.y) }}) size: {{ Math.round(faceDetailModal.candidate.bbox.width) }}x{{ Math.round(faceDetailModal.candidate.bbox.height) }}</span>
</div>
<div class="ms-pose-frame-container" ref="poseFrameContainer" @contextmenu.prevent="showFrameCtxMenu">
<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: <span class="ms-face-detail-current-group" :title="traceGroupMap[String(faceDetailModal.candidate?.trace_id)] || t('people.section.unassigned')">{{ traceGroupMap[String(faceDetailModal.candidate?.trace_id)] || t('people.section.unassigned') }}</span></div>
<div class="ms-face-detail-move-row">
<select v-model="faceMoveTarget" class="ms-ppl-file-select">
<option value="">Move to Group — 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">
<div class="ms-fs-nav-bar">
<button class="ms-fm-icon-btn" :disabled="!canFaceDetailPrev" @click="goFaceDetailPrev" title="Previous FS">‹</button>
<div class="ms-fs-nav-info">
<span class="ms-fs-nav-counter">{{ faceDetailNavTotal ? (faceDetailNavIndex + 1) + ' / ' + faceDetailNavTotal : '—' }}</span>
<span v-if="faceDetailNavLabel" class="ms-fs-nav-label">{{ faceDetailNavLabel }}</span>
</div>
<button class="ms-fm-icon-btn" :disabled="!canFaceDetailNext" @click="goFaceDetailNext" title="Next FS">›</button>
</div>
<button class="ms-fm-btn ms-fm-btn-sm ms-fm-btn-danger" @click="deleteCurrentFace" title="Delete this FS">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" style="margin-right:4px;"><polyline points="3 6 5 6 21 6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
Del
</button>
<button class="ms-fm-btn" @click="faceDetailModal.show = false">{{ t('people.face_detail.close') }}</button>
</div>
</div>
</div>
<!-- Enlarged face image modal -->
<div v-if="showEnlargedFace && faceDetailModal.candidate" class="ms-modal-overlay show" style="z-index:10000;" @click.self="showEnlargedFace = false">
<div class="ms-enlarged-face-modal">
<button class="ms-modal-video-close" @click="showEnlargedFace = false">&times;</button>
<img v-if="candidateThumbs[thumbTraceKey(faceDetailModal.candidate)]" :src="candidateThumbs[thumbTraceKey(faceDetailModal.candidate)]" alt="" style="max-width:90vw;max-height:90vh;object-fit:contain;border-radius:12px;">
</div>
</div>
<!-- Full Screen Key Face -->
<div v-if="showFullFace && faceDetailModal.candidate" class="ms-modal-overlay show" style="z-index:11000;" @click.self="showFullFace = false">
<div class="ms-full-face-modal">
<button class="ms-modal-video-close" @click="showFullFace = false">&times;</button>
<img v-if="candidateThumbs[thumbTraceKey(faceDetailModal.candidate)]" :src="candidateThumbs[thumbTraceKey(faceDetailModal.candidate)]" alt="" style="max-width:95vw;max-height:95vh;object-fit:contain;">
</div>
</div>
<!-- Full Screen Frame -->
<div v-if="showFullFrame && faceDetailFrameUrl" class="ms-modal-overlay show" style="z-index:11000;" @click.self="showFullFrame = false">
<div class="ms-full-face-modal" @mouseenter="fullControlsVisible = true" @mouseleave="fullControlsVisible = false">
<transition name="fade-controls">
<button v-show="fullControlsVisible" class="ms-modal-video-close" @click="showFullFrame = false">&times;</button>
</transition>
<transition name="fade-controls">
<div v-show="fullControlsVisible" class="ms-full-frame-controls">
<label class="ms-pose-toggle"><input type="checkbox" v-model="showBboxOverlay"> BBox</label>
<label class="ms-pose-toggle"><input type="checkbox" v-model="showLandmarkOverlay"> Landmark</label>
<label class="ms-pose-toggle"><input type="checkbox" v-model="showOthersOverlay"> Others</label>
</div>
</transition>
<div class="ms-full-frame-container">
<img :src="faceDetailFrameUrl" alt="Frame" class="ms-full-frame-img" @load="onFullFrameLoad">
<canvas ref="fullFrameCanvas" class="ms-full-frame-canvas"></canvas>
</div>
</div>
</div>
<!-- Trace Profile JSON Viewer Modal -->
<div v-if="traceProfileJsonDialog" class="ms-modal-overlay show" style="z-index:12000;" @click.self="traceProfileJsonDialog = false">
<div class="ms-modal-box ms-modal-wide">
<div class="ms-modal-header">
<span class="ms-modal-title">{{ traceProfileJsonTitle }}</span>
<button class="ms-modal-close" @click="traceProfileJsonDialog = false">✕</button>
</div>
<div class="ms-modal-body">
<pre class="ms-json-view">{{ traceProfileJsonContent }}</pre>
</div>
</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">
{{ clusterDetailCluster?.name }} ({{ clusterDetailCluster?.visible_count }} FS)<span v-if="ffFilterActive && clusterDetailCluster && clusterDetailCluster.visible_count !== clusterDetailCluster.trace_count" class="ms-ff-group-partial">{{ clusterDetailCluster.trace_count }}</span>
</div>
<button class="ms-fm-btn ms-fm-btn-primary ms-cluster-play-btn" @click="playClusterVideo" :disabled="!clusterDetailCluster?.rep_trace?.file_uuid">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" style="margin-right:6px;">
<polygon points="5 3 19 12 5 21 5 3" fill="currentColor"></polygon>
</svg>
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;">{{ ffFilterActive ? 'All faces filtered out — lower min confidence / min face size' : 'No faces' }}</div>
<div v-for="trace in paginatedClusterTraces" :key="trace.trace_id" class="ms-ppl-strip-face ms-ppl-strip-face-clickable" :class="{ selected: clusterDetailModal.selectedTrace?.trace_id === trace.trace_id || isClusterTraceSelected(trace) }" @click="clusterBatchMode ? toggleClusterTraceSelection(trace) : openFaceDetailFromTrace(trace)" v-observe="() => loadTraceThumb(trace)">
<div v-if="clusterBatchMode" class="ms-ppl-card-checkbox" @click.stop="toggleClusterTraceSelection(trace)">
<span v-if="isClusterTraceSelected(trace)">✓</span>
</div>
<div class="ms-ppl-strip-face-img">
<img v-if="faceThumbsCache[thumbTraceKey(trace)]" :src="faceThumbsCache[thumbTraceKey(trace)]" alt="" loading="lazy">
<div v-else-if="loadingFaceThumbs.has(thumbTraceKey(trace))" class="spinner-sm"></div>
<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#{{ trace.trace_id }}</div>
</div>
<div class="ms-ppl-strip-trace-label">FS#{{ trace.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" :key-frame="currentVideo.keyFrame" :title="currentVideo.title" :show-face="true" :show-asrx="false" :show-ocr="false" :show-pose="false" @close="playing = false; clusterVideoTraces = []; clusterVideoSegments = []" />
<!-- Merge Target Selection Modal -->
<div v-if="showMergeTargetModal" class="ms-modal-overlay show" @click.self="showMergeTargetModal = false">
<div class="ms-modal ms-modal-merge">
<button class="ms-fm-icon-btn close-btn" @click="showMergeTargetModal = false">×</button>
<h2 class="ms-ppl-section-title">Select Target Group</h2>
<p class="ms-merge-hint">Choose which group to keep. Other selected groups will be merged into this one.</p>
<div class="ms-merge-grid">
<div v-for="c in mergeCandidateGroups" :key="c.group_uuid" class="ms-merge-face-card" @click="executeMerge(c.group_uuid)">
<div class="ms-merge-face-img">
<img v-if="c.rep_trace && faceThumbsCache[thumbTraceKey(c.rep_trace)]" :src="faceThumbsCache[thumbTraceKey(c.rep_trace)]" alt="" loading="lazy">
<svg v-else viewBox="0 0 120 120" fill="none" style="width:100%;height:100%;">
<circle cx="60" cy="45" r="25" fill="var(--border-color)"/>
<ellipse cx="60" cy="105" rx="40" ry="25" fill="var(--border-color)"/>
</svg>
</div>
<span class="ms-merge-face-name">{{ c.name }}</span>
<span class="ms-merge-face-count">{{ c.trace_count }} faces</span>
</div>
</div>
</div>
</div>
<!-- Move Faces Modal -->
<div v-if="showMoveFacesModal" class="ms-modal-overlay show" @click.self="showMoveFacesModal = false">
<div class="ms-modal ms-modal-merge">
<button class="ms-fm-icon-btn close-btn" @click="showMoveFacesModal = false">×</button>
<h2 class="ms-ppl-section-title">Move {{ selectedFaces.length }} Trace(s)</h2>
<p class="ms-merge-hint">Choose a target group or create a new one.</p>
<div class="ms-move-new-group" @click="createNewGroupAndMove">
<span class="ms-move-new-icon">+</span>
<span class="ms-move-new-label">New Group</span>
</div>
<hr class="ms-merge-divider">
<div class="ms-merge-grid">
<div v-for="c in faceGroups" :key="c.group_uuid" class="ms-merge-face-card" @click="moveToGroup(c)">
<div class="ms-merge-face-img">
<img v-if="c.rep_trace && faceThumbsCache[thumbTraceKey(c.rep_trace)]" :src="faceThumbsCache[thumbTraceKey(c.rep_trace)]" alt="" loading="lazy">
<svg v-else viewBox="0 0 120 120" fill="none" style="width:100%;height:100%;">
<circle cx="60" cy="45" r="25" fill="var(--border-color)"/>
<ellipse cx="60" cy="105" rx="40" ry="25" fill="var(--border-color)"/>
</svg>
</div>
<span class="ms-merge-face-name">{{ c.name || 'Unnamed Group' }}</span>
<span class="ms-merge-face-count">{{ c.trace_count || 0 }} faces</span>
</div>
</div>
</div>
</div>
<!-- Delete Faces Confirm Modal -->
<div v-if="showDeleteFacesModal" class="ms-modal-overlay show" :style="{ zIndex: getNextZIndex() }" @click.self="showDeleteFacesModal = false">
<div class="ms-modal ms-modal-delete-faces">
<button class="ms-fm-icon-btn close-btn" @click="showDeleteFacesModal = false">×</button>
<h2 class="ms-ppl-section-title">Delete {{ pendingDeleteFaces.length }} FS?</h2>
<p class="ms-delete-faces-warning">
⚠️ This will <strong>permanently delete</strong> {{ pendingDeleteFaces.length }} unassigned FS (face traces).
The face data, profile and image files will be removed. This action <strong>cannot be undone</strong>.
</p>
<div class="ms-delete-faces-confirm">
<label class="ms-delete-faces-label">Type <code>DELETE</code> to confirm:</label>
<input v-model="deleteConfirmInput" class="ms-ppl-edit-input" placeholder="DELETE" autocomplete="off" @keyup.enter="confirmDeleteFaces" />
</div>
<div class="ms-delete-faces-actions">
<button class="ms-fm-btn" @click="showDeleteFacesModal = false">Cancel</button>
<button class="ms-fm-btn ms-fm-btn-danger" :disabled="deleteConfirmInput.trim().toUpperCase() !== 'DELETE'" @click="confirmDeleteFaces">Permanently Delete</button>
</div>
</div>
</div>
<!-- Delete Group Faces Confirm Modal -->
<div v-if="showDeleteGroupModal" class="ms-modal-overlay show" :style="{ zIndex: getNextZIndex() }" @click.self="showDeleteGroupModal = false">
<div class="ms-modal ms-modal-delete-faces">
<button class="ms-fm-icon-btn close-btn" @click="showDeleteGroupModal = false">×</button>
<h2 class="ms-ppl-section-title">Delete {{ pendingDeleteGroupFaces.length }} FS from selected group(s)?</h2>
<p class="ms-delete-faces-warning">
⚠️ This will <strong>permanently delete</strong> {{ pendingDeleteGroupFaces.length }} face trace(s) across {{ selectedGroups.length }} group(s).
All face data, profiles and image files will be removed. This action <strong>cannot be undone</strong>.
</p>
<div class="ms-delete-faces-confirm">
<label class="ms-delete-faces-label">Type <code>DELETE</code> to confirm:</label>
<input v-model="deleteGroupConfirmInput" class="ms-ppl-edit-input" placeholder="DELETE" autocomplete="off" @keyup.enter="confirmDeleteGroupFaces" />
</div>
<div class="ms-delete-faces-actions">
<button class="ms-fm-btn" @click="showDeleteGroupModal = false">Cancel</button>
<button class="ms-fm-btn ms-fm-btn-danger" :disabled="deleteGroupConfirmInput.trim().toUpperCase() !== 'DELETE'" @click="confirmDeleteGroupFaces">Permanently Delete</button>
</div>
</div>
</div>
<!-- QC Modal -->
<div v-if="showQCModal" class="ms-modal-overlay show">
<div class="ms-modal ms-modal-qc">
<button class="ms-fm-icon-btn close-btn" @click="showQCModal = false">×</button>
<div class="ms-qc-header">
<h2>🔍 Data Quality Check</h2>
<div class="ms-qc-file-info">
<span class="ms-qc-file-name">{{ getFileName(selectedFileUuid) }}</span>
<span class="ms-qc-file-uuid">{{ selectedFileUuid?.slice(0, 8) }}...</span>
</div>
</div>
<!-- API Tests Section -->
<div v-if="qcApiTests.length > 0" class="ms-qc-api-section">
<h3>📡 API Connectivity</h3>
<div class="ms-qc-api-category">
<div class="ms-qc-api-cat-name">General</div>
<div class="ms-qc-api-grid">
<div v-for="api in qcApiTests.filter(a => ['get_files', 'get_file_info', 'get_unassigned_traces'].includes(a.name))" :key="api.name" class="ms-qc-api-item" :class="{ fail: !api.status }">
<span class="ms-qc-api-status">{{ api.status ? '✓' : '✗' }}</span>
<span class="ms-qc-api-name">{{ api.name }}</span>
</div>
</div>
</div>
<div class="ms-qc-api-category">
<div class="ms-qc-api-cat-name">File Profile</div>
<div class="ms-qc-api-grid">
<div v-for="api in qcApiTests.filter(a => ['get_file_profile', 'update_file_profile', 'get_file_keyframe'].includes(a.name))" :key="api.name" class="ms-qc-api-item" :class="{ fail: !api.status }">
<span class="ms-qc-api-status">{{ api.status ? '✓' : '✗' }}</span>
<span class="ms-qc-api-name">{{ api.name }}</span>
</div>
</div>
</div>
<div class="ms-qc-api-category">
<div class="ms-qc-api-cat-name">Trace Profile</div>
<div class="ms-qc-api-grid">
<div v-for="api in qcApiTests.filter(a => ['get_trace_profile', 'update_trace_profile'].includes(a.name))" :key="api.name" class="ms-qc-api-item" :class="{ fail: !api.status }">
<span class="ms-qc-api-status">{{ api.status ? '✓' : '✗' }}</span>
<span class="ms-qc-api-name">{{ api.name }}</span>
</div>
</div>
</div>
</div>
<!-- API Blocked Warning -->
<div v-if="qcApiBlocked" class="ms-qc-blocked">
<div class="ms-qc-blocked-icon">⛔</div>
<div class="ms-qc-blocked-title">API Test Failed</div>
<div class="ms-qc-blocked-desc">Data QC is blocked. Please check API connectivity.</div>
</div>
<!-- Data QC Section (only show if API OK) -->
<template v-if="!qcApiBlocked">
<!-- File Key Frame -->
<div v-if="qcFileProfile?.key_frame_url" class="ms-qc-keyframe">
<div class="ms-qc-keyframe-label">File Key Frame #{{ qcFileProfile.key_frame }}</div>
<img :src="qcFileProfile.key_frame_url" alt="Key Frame" class="ms-qc-keyframe-img" />
</div>
<div class="ms-qc-stats">
<div class="ms-qc-stat">
<span class="ms-qc-stat-value">{{ qcProgress.total }}</span>
<span class="ms-qc-stat-label">FS</span>
</div>
<div class="ms-qc-stat">
<span class="ms-qc-stat-value">{{ qcProgress.current }}</span>
<span class="ms-qc-stat-label">Tested</span>
</div>
<div class="ms-qc-stat">
<span class="ms-qc-stat-value ms-qc-pass">{{ qcReport.filter(r => r.status === 'PASS').length }}</span>
<span class="ms-qc-stat-label">Passed</span>
</div>
<div class="ms-qc-stat">
<span class="ms-qc-stat-value ms-qc-fail">{{ qcReport.filter(r => r.status === 'FAIL').length }}</span>
<span class="ms-qc-stat-label">Failed</span>
</div>
</div>
<!-- Trace Key Frame/Face Summary -->
<div v-if="qcTraceSummary.total > 0" class="ms-qc-trace-summary">
<div class="ms-qc-trace-stat">
<span class="ms-qc-trace-stat-label">Key Frame</span>
<span class="ms-qc-trace-stat-value">{{ qcTraceSummary.withKeyFrame }}/{{ qcTraceSummary.total }}</span>
</div>
<div class="ms-qc-trace-stat">
<span class="ms-qc-trace-stat-label">Key Face</span>
<span class="ms-qc-trace-stat-value">{{ qcTraceSummary.withKeyFace }}/{{ qcTraceSummary.total }}</span>
</div>
<div class="ms-qc-trace-stat">
<span class="ms-qc-trace-stat-label">Key Face BBox (short)</span>
<span class="ms-qc-trace-stat-value">{{ qcTraceSummary.bboxShort.avg }}px</span>
<span class="ms-qc-trace-stat-sub">{{ qcTraceSummary.bboxShort.min }}–{{ qcTraceSummary.bboxShort.max }}</span>
</div>
<div class="ms-qc-trace-stat">
<span class="ms-qc-trace-stat-label">Key Face Area</span>
<span class="ms-qc-trace-stat-value">{{ qcTraceSummary.bboxArea.avg }}px²</span>
<span class="ms-qc-trace-stat-sub">{{ qcTraceSummary.bboxArea.min }}–{{ qcTraceSummary.bboxArea.max }}</span>
</div>
<div class="ms-qc-trace-stat">
<span class="ms-qc-trace-stat-label">Confidence</span>
<span class="ms-qc-trace-stat-value">{{ (qcTraceSummary.conf.avg * 100).toFixed(1) }}%</span>
<span class="ms-qc-trace-stat-sub">{{ (qcTraceSummary.conf.min * 100).toFixed(0) }}%–{{ (qcTraceSummary.conf.max * 100).toFixed(0) }}%</span>
</div>
</div>
<!-- Face Filter Hidden-State Summary -->
<div class="ms-qc-filter-summary">
<div class="ms-qc-filter-row">
<span class="ms-qc-filter-label">Min Confidence</span>
<span class="ms-qc-filter-value">{{ qcTraceSummary.filter.minConfPct }}%</span>
</div>
<div class="ms-qc-filter-row">
<span class="ms-qc-filter-label">Min Face Size</span>
<span class="ms-qc-filter-value">{{ qcTraceSummary.filter.minSizePx }}px</span>
</div>
<div class="ms-qc-filter-row">
<span class="ms-qc-filter-label">Min Capture Quality</span>
<span class="ms-qc-filter-value">{{ qcTraceSummary.filter.minQualityPct }}%</span>
</div>
<div class="ms-qc-filter-row">
<span class="ms-qc-filter-label">Filter Active</span>
<span class="ms-qc-filter-value" :class="qcTraceSummary.filter.active ? 'ms-qc-pass' : ''">{{ qcTraceSummary.filter.active ? 'yes' : 'no' }}</span>
</div>
<div class="ms-qc-filter-row">
<span class="ms-qc-filter-label">Hidden FS</span>
<span class="ms-qc-filter-value">{{ qcTraceSummary.filter.hidden }}/{{ qcTraceSummary.total }}</span>
</div>
<div class="ms-qc-filter-row">
<span class="ms-qc-filter-label">Visible FS</span>
<span class="ms-qc-filter-value">{{ qcTraceSummary.filter.visible }}/{{ qcTraceSummary.total }}</span>
</div>
<div class="ms-qc-filter-sub">
conf-only {{ qcTraceSummary.filter.hiddenConfOnly }} · size-only {{ qcTraceSummary.filter.hiddenSizeOnly }} · qual-only {{ qcTraceSummary.filter.hiddenQualOnly }} · both {{ qcTraceSummary.filter.hiddenBoth }} · neither {{ qcTraceSummary.filter.hiddenNeither }}
</div>
</div>
<!-- File Profile Content -->
<div v-if="qcFileProfileInfo" class="ms-qc-fileprofile">
<div class="ms-qc-fileprofile-title">File Profile</div>
<div class="ms-qc-fileprofile-row"><span class="ms-qc-filter-label">name</span><span class="ms-qc-fileprofile-val">{{ qcFileProfileInfo.file_name }}</span></div>
<div class="ms-qc-fileprofile-row"><span class="ms-qc-filter-label">duration</span><span class="ms-qc-fileprofile-val">{{ qcFileProfileInfo.duration }}s</span></div>
<div class="ms-qc-fileprofile-row"><span class="ms-qc-filter-label">resolution</span><span class="ms-qc-fileprofile-val">{{ qcFileProfileInfo.width }}×{{ qcFileProfileInfo.height }}</span></div>
<div class="ms-qc-fileprofile-row"><span class="ms-qc-filter-label">fps</span><span class="ms-qc-fileprofile-val">{{ Number(qcFileProfileInfo.fps).toFixed(2) }}</span></div>
<div class="ms-qc-fileprofile-row"><span class="ms-qc-filter-label">total_frames</span><span class="ms-qc-fileprofile-val">{{ qcFileProfileInfo.total_frames }}</span></div>
<div class="ms-qc-fileprofile-row"><span class="ms-qc-filter-label">status</span><span class="ms-qc-fileprofile-val">{{ qcFileProfileInfo.status }}</span></div>
</div>
<!-- FS Profile Statistics -->
<div v-if="qcTraceSummary.total > 0" class="ms-qc-fsprofile">
<div class="ms-qc-fileprofile-title">FS Profile</div>
<div class="ms-qc-fs-row"><span class="ms-qc-filter-label">face_count</span><span class="ms-qc-fs-val">{{ qcTraceSummary.fs.faceCount.avg }} ({{ qcTraceSummary.fs.faceCount.min }}–{{ qcTraceSummary.fs.faceCount.max }})</span></div>
<div class="ms-qc-fs-row"><span class="ms-qc-filter-label">avg_confidence</span><span class="ms-qc-fs-val">{{ qcTraceSummary.fs.avgConf.avg }} ({{ qcTraceSummary.fs.avgConf.min }}–{{ qcTraceSummary.fs.avgConf.max }})</span></div>
<div class="ms-qc-fs-row"><span class="ms-qc-filter-label">best_confidence</span><span class="ms-qc-fs-val">{{ qcTraceSummary.fs.bestConf.avg }} ({{ qcTraceSummary.fs.bestConf.min }}–{{ qcTraceSummary.fs.bestConf.max }})</span></div>
<div class="ms-qc-fs-row"><span class="ms-qc-filter-label">duration_sec</span><span class="ms-qc-fs-val">{{ qcTraceSummary.fs.durationSec.avg }} ({{ qcTraceSummary.fs.durationSec.min }}–{{ qcTraceSummary.fs.durationSec.max }})</span></div>
<div class="ms-qc-fs-row"><span class="ms-qc-filter-label">pose_count</span><span class="ms-qc-fs-val">{{ qcTraceSummary.fs.poseCount.avg }} ({{ qcTraceSummary.fs.poseCount.min }}–{{ qcTraceSummary.fs.poseCount.max }})</span></div>
<div class="ms-qc-fs-row"><span class="ms-qc-filter-label">frame_span</span><span class="ms-qc-fs-val">{{ qcTraceSummary.fs.frameSpan.avg }} ({{ qcTraceSummary.fs.frameSpan.min }}–{{ qcTraceSummary.fs.frameSpan.max }})</span></div>
</div>
<div class="ms-qc-progress-section">
<div class="ms-qc-status">{{ qcProgress.status }}</div>
<div class="ms-qc-bar">
<div class="ms-qc-bar-fill" :style="{ width: qcProgress.total > 0 ? (qcProgress.current / qcProgress.total * 100) + '%' : '0%' }"></div>
</div>
</div>
<div class="ms-qc-report">
<div v-for="(r, i) in qcReport" :key="i" class="ms-qc-item" :class="'ms-qc-' + r.status.toLowerCase()">
<span class="ms-qc-icon">{{ r.status === 'PASS' ? '✓' : r.status === 'FAIL' ? '✗' : '⚠' }}</span>
<div class="ms-qc-content">
<span class="ms-qc-check">{{ r.check }}</span>
<span class="ms-qc-details">{{ r.details }}</span>
</div>
</div>
</div>
<div v-if="qcProfileIssues.length > 0" class="ms-qc-issues">
<h3>⚠️ Profile Issues ({{ qcProfileIssues.length }})</h3>
<div class="ms-qc-issues-list">
<div v-for="(p, i) in qcProfileIssues.slice(0, 10)" :key="i" class="ms-qc-issue-item">
<span class="ms-qc-issue-trace">FS#{{ p.traceId }}</span>
<span class="ms-qc-issue-group">{{ p.groupName }}</span>
<span class="ms-qc-issue-status" :class="{ fail: !p.canRead }">{{ p.canRead ? '✓ R' : '✗ R' }}</span>
<span class="ms-qc-issue-status" :class="{ fail: !p.canWrite }">{{ p.canWrite ? '✓ W' : '✗ W' }}</span>
<span class="ms-qc-issue-status" :class="{ warn: !p.hasKeyFrame }">{{ p.hasKeyFrame ? 'KF' : '-' }}</span>
<span class="ms-qc-issue-status" :class="{ warn: !p.hasKeyFace }">{{ p.hasKeyFace ? 'KF' : '-' }}</span>
</div>
<div v-if="qcProfileIssues.length > 10" class="ms-qc-more">... +{{ qcProfileIssues.length - 10 }} more issues</div>
</div>
</div>
</template>
<div v-if="qcProgress.status === 'QC Complete' || qcApiBlocked" class="ms-qc-footer">
<button class="ms-fm-btn ms-fm-btn-sm" @click="copyQCReport">📋 Copy Report</button>
<button class="ms-fm-btn ms-fm-btn-sm" @click="showQCModal = false">Close</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useRouter, useRoute } 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, getFaceLandmarks, getAppearance, syncAllProcessingFiles, loadingFaceThumbs, clearFaceThumbs, bumpThumbCacheBust } from '@/store'
import { useI18n } from 'vue-i18n'
import { drawPoseSkeleton, drawAppearanceOverlay } from '@/utils/poseRenderer'
import type { AppearanceResponse, FaceLandmarksResponse } from '@/api/types'
import { scoreFaceLandmarks, type LandmarkScoreResult } from '@/utils/faceLandmarkScoring'
import { getNextZIndex } from '@/composables/useZIndex'
import { getApiBase } from '@/api/config'
import { useAdvancedMode } from '@/stores/advancedMode'
import { useDragSelect, dragSelectRectStyle, type DragSelectBox } from '@/composables/useDragSelect'
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const { isAdvanced } = useAdvancedMode()
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: '', keyFrame: null as number | null })
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 fullFrameCanvas = ref<HTMLCanvasElement | null>(null)
const fullFrameContainer = ref<HTMLDivElement | null>(null)
const faceDetailLandmarks = ref<FaceLandmarksResponse | null>(null)
const landmarkScore = ref<LandmarkScoreResult | 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 showFullFace = ref(false)
const showFullFrame = ref(false)
const fullControlsVisible = ref(false)
const frameCtxMenu = ref({ show: false, x: 0, y: 0 })
const showBboxOverlay = ref(true)
const showLandmarkOverlay = ref(true)
const showOthersOverlay = ref(true)
const showAppearanceOverlay = ref(true)
const selectedFaces = ref<string[]>([])
const selectedGroups = ref<string[]>([])
const aiSuggestLoading = ref(false)
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 showDeleteFacesModal = ref(false)
const pendingDeleteFaces = ref<{ file_uuid: string; trace_id: number }[]>([])
const deleteConfirmInput = ref('')
const showDeleteGroupModal = ref(false)
const pendingDeleteGroupFaces = ref<{ file_uuid: string; trace_id: number }[]>([])
const deleteGroupConfirmInput = ref('')
const clusterBatchMode = ref(false)
const selectedClusterTraces = ref<number[]>([])
const faceGridEl = ref<HTMLElement | null>(null)
const groupGridEl = ref<HTMLElement | null>(null)
const facesDragBox = ref<DragSelectBox | null>(null)
const groupsDragBox = ref<DragSelectBox | null>(null)
const { onMousedown: facesDragMousedown, onClickCapture: facesDragClick } = useDragSelect(faceGridEl, {
getSelected: () => selectedFaces.value,
setSelected: (keys) => { selectedFaces.value = [...keys] },
onBox: (b) => { facesDragBox.value = b },
})
const { onMousedown: groupsDragMousedown, onClickCapture: groupsDragClick } = useDragSelect(groupGridEl, {
getSelected: () => selectedGroups.value,
setSelected: (keys) => { selectedGroups.value = [...keys] },
onBox: (b) => { groupsDragBox.value = b },
})
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,
bboxShort: { min: 0, avg: 0, max: 0 },
bboxArea: { min: 0, avg: 0, max: 0 },
conf: { min: 0, avg: 0, max: 0 },
filter: { active: false, minConfPct: 0, minSizePx: 0, minQualityPct: 0, visible: 0, hidden: 0, hiddenConfOnly: 0, hiddenSizeOnly: 0, hiddenBoth: 0, hiddenQualOnly: 0, hiddenNeither: 0 },
fs: { faceCount: { min: 0, avg: 0, max: 0 }, avgConf: { min: 0, avg: 0, max: 0 }, bestConf: { min: 0, avg: 0, max: 0 }, durationSec: { min: 0, avg: 0, max: 0 }, poseCount: { min: 0, avg: 0, max: 0 }, frameSpan: { min: 0, avg: 0, max: 0 } },
})
const qcFileProfileInfo = ref<Record<string, any> | null>(null)
function isFaceSelected(t: any): boolean {
return selectedFaces.value.includes(`${t.file_uuid}:${t.trace_id}`)
}
function isGroupSelected(c: any): boolean {
return selectedGroups.value.includes(c.group_uuid)
}
function isClusterTraceSelected(t: any): boolean {
return selectedClusterTraces.value.includes(t.trace_id)
}
async function toggleFrameView(t: any, mode: 'frame' | 'bbox') {
const key = `${t.trace_id}-${t.file_uuid}`
if (activeFrameCard.value === key && activeFrameMode.value === mode) {
activeFrameCard.value = ''
activeFrameMode.value = ''
return
}
activeFrameCard.value = key
activeFrameMode.value = mode
if (!fileInfoCache.value[t.file_uuid] && t.file_uuid) {
try {
const info: any = await apiCall('get_file_info', { uuid: t.file_uuid })
fileInfoCache.value[t.file_uuid] = { width: info.width, height: info.height }
} catch { /* ignore */ }
}
}
function goToUfacePage(page: number) {
if (page < 1) return
if (facesSearchQuery.value.trim()) {
unassignedTracesPage.value = page
runFacesSearch(page)
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.file_uuid || ''}-${t.trace_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 keyFaceCtxMenu = ref({ show: false, x: 0, y: 0 })
const traceProfileJsonDialog = ref(false)
const traceProfileJsonContent = ref('')
const traceProfileJsonTitle = ref('')
const faceDetailModal = ref({ show: false, candidate: null as any, newIdentityName: '' })
const faceDetailNavSource = ref<'unassigned' | 'cluster' | null>(null)
const faceDetailNavIndex = ref(-1)
const regeneratingThumbnail = ref(false)
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 groupSearchQuery = ref('')
const facesSearchQuery = ref('')
// Server-side faces search (all traces incl. grouped) via /file/:uuid/faces/search
const searchFacesResults = ref<any[]>([])
const searchFacesTotal = ref(0)
const searchFacesLoading = ref(false)
const facesPagingActive = computed(() => !!facesSearchQuery.value.trim())
let facesSearchTimer: ReturnType<typeof setTimeout> | null = null
async function runFacesSearch(page = 1) {
const q = facesSearchQuery.value.trim()
const fileUuid = selectedFileUuid.value
if (!q || !fileUuid) {
searchFacesResults.value = []
searchFacesTotal.value = 0
return
}
searchFacesLoading.value = true
try {
const res: any = await apiCall('search_file_faces', { fileUuid, q, page, perPage: 20 })
if (selectedFileUuid.value !== fileUuid || facesSearchQuery.value.trim() !== q) return
searchFacesResults.value = res.traces || []
searchFacesTotal.value = res.total || 0
} catch (e) {
console.error('search_file_faces failed:', e)
} finally {
searchFacesLoading.value = false
}
}
watch(facesSearchQuery, () => {
if (facesSearchTimer) clearTimeout(facesSearchTimer)
facesSearchTimer = setTimeout(() => {
if (facesSearchQuery.value.trim()) unassignedTracesPage.value = 1
runFacesSearch(1)
}, 250)
})
watch(selectedFileUuid, async () => {
searchFacesResults.value = []
searchFacesTotal.value = 0
ffFaces.value = []
if (selectedFileUuid.value) {
await loadDefaultFaceFilter()
await loadFaceFilterFromProfile(selectedFileUuid.value)
await loadFaces()
}
})
watch(() => route.query.file, async (v) => {
if (typeof v === 'string' && v !== selectedFileUuid.value) {
selectedFileUuid.value = v
await onFileFilterChange()
}
})
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 showFaceFilter = ref(false)
const ffLoading = ref(false)
const ffFaces = ref<any[]>([])
const ffMinConfPct = ref(0)
const ffMinSizePx = ref(0)
const ffMinQuality = ref(0)
const dragState = ref<{ type: 'conf' | 'size' | 'quality'; wrap: HTMLElement } | null>(null)
const starFilter = ref(false)
const singleFrameSettings = ref({
single_frame_trace_vlm_enabled: true,
single_frame_trace_group_enabled: true,
single_frame_trace_unassigned_enabled: true,
single_trace_group_filter_enabled: true,
})
const singleFrameSettingsLoading = ref(false)
const singleFrameSettingsModified = ref(false)
const faceSourceClusterId = ref<number | null>(null)
const faceMoveTarget = ref('')
const faceMoveNewGroupName = ref('')
// Cluster results (synced with store for cross-navigation persistence)
const clusterResults = computed(() => clusterResultsCache.value[selectedFileUuid.value] || [])
const clusterTracesMap = ref<Record<string, any>>({})
const clusterRunning = computed(() => isClusterRunning(selectedFileUuid.value))
const faceGroups = computed(() => {
if (!selectedFileUuid.value || !clusterResults.value.length) {
return []
}
return clusterResults.value.map((c: any, idx: number) => {
const traceIds = c.trace_ids || []
const repTraceId = c.representative_trace !== undefined && c.representative_trace !== null ? 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 }
}
}
// 若 clusterTracesMap 查無此 trace(map 只抓前 1000 筆,rep 常落在其後),
// 用 cluster-results 資料合成 rep_trace,讓縮圖仍能經 loadTraceThumb 載入。
if (!repTrace && repTraceId !== undefined && repTraceId !== null) {
repTrace = {
trace_id: repTraceId,
file_uuid: selectedFileUuid.value,
best_face_id: 0,
best_face_frame: 1,
start_frame: 1,
end_frame: 1,
}
}
const defaultName = `group ${String(idx + 1).padStart(2, '0')}`
if (!c.name) c.name = defaultName
const visibleTraceIds = ffFilterActive.value
? traceIds.filter(tid => !ffHiddenTraces.value.has(tid))
: traceIds
return {
group_uuid: `group_${c.cluster_id ?? c.group_id}`,
group_id: c.cluster_id ?? c.group_id,
name: c.name,
trace_count: c.trace_count,
visible_count: visibleTraceIds.length,
representative_trace: repTraceId,
cluster_id: c.cluster_id ?? c.group_id,
trace_ids: traceIds,
visible_trace_ids: visibleTraceIds,
rep_trace: repTrace,
}
}).filter((g: any) => !(ffFilterActive.value && g.visible_count === 0))
.sort((a: any, b: any) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }))
})
const filteredFaceGroups = computed(() => {
const groups = faceGroups.value.filter((g: any) => (g.name || '').toLowerCase() !== 'unassigned')
if (!groupSearchQuery.value) return groups
const q = groupSearchQuery.value.trim().toLowerCase()
if (!q) return groups
return groups.filter((g: any) => (g.name || '').toLowerCase().includes(q))
})
const otherClusters = computed(() => {
if (faceSourceClusterId.value === null) return clusterResults.value
return clusterResults.value.filter((c: any) => (c.cluster_id ?? c.group_id) !== faceSourceClusterId.value)
})
// Live cluster for the detail modal — tracks faceGroups so rep_trace is
// populated once clusterTracesMap finishes loading (snapshot at click time is stale)
const clusterDetailCluster = computed(() => {
const snapshot = clusterDetailModal.value.cluster
if (!snapshot) return null
const cid = snapshot.cluster_id ?? snapshot.group_id
const live = faceGroups.value.find((g: any) => g.cluster_id === cid)
return live || snapshot
})
// Map trace_id → group name for Faces section
const traceGroupMap = computed(() => {
const map: Record<string, string> = {}
for (const c of clusterResults.value) {
for (const tid of c.trace_ids || []) {
map[String(tid)] = c.name
}
}
return map
})
// Pending faces search: name (group label) or frame range (F#start–F#end).
// When a query is active, results come straight from the server-side search
// (/file/:uuid/faces/search) which covers ALL traces (incl. grouped); otherwise
// fall back to the paginated unassigned list.
// Also include unassigned traces when the query matches "Unassigned" label.
const filteredUnassignedTraces = computed(() => {
const q = facesSearchQuery.value.trim().toLowerCase()
let base: any[]
if (q) {
base = [...searchFacesResults.value]
// Also include unassigned traces whose label matches the query
if (q.length >= 1 && t('people.section.unassigned').toLowerCase().startsWith(q)) {
for (const t of unassignedTraces.value) {
if (!base.some((r: any) => r.trace_id === t.trace_id)) base.push(t)
}
}
} else {
base = unassignedTraces.value
}
if (!ffFilterActive.value) return base
return base.filter((tr: any) => !ffHiddenTraces.value.has(tr.trace_id))
})
const visibleUnassignedCount = computed(() => {
if (!ffFilterActive.value) return unassignedTracesTotal.value || unassignedTraces.value.length
const list = facesSearchQuery.value.trim().toLowerCase() ? searchFacesResults.value : unassignedTraces.value
return list.filter((t: any) => !ffHiddenTraces.value.has(t.trace_id)).length
})
const facesTotalPages = computed(() => {
return Math.ceil(searchFacesTotal.value / 20) || 1
})
function traceLabel(tr: any): string {
const n = tr?.name || traceGroupMap.value[String(tr.trace_id)]
return n || t('people.section.unassigned')
}
// Split text into segments, marking the literal query match for highlight
function highlightSegments(text: string, query: string): { text: string; hit: boolean }[] {
const q = (query || '').trim().toLowerCase()
const t = String(text ?? '')
if (!q || !t) return [{ text: t, hit: false }]
const lower = t.toLowerCase()
const segs: { text: string; hit: boolean }[] = []
let i = 0
let idx = lower.indexOf(q)
while (idx >= 0) {
if (idx > i) segs.push({ text: t.slice(i, idx), hit: false })
segs.push({ text: t.slice(idx, idx + q.length), hit: true })
i = idx + q.length
idx = lower.indexOf(q, i)
}
if (i < t.length) segs.push({ text: t.slice(i), hit: false })
return segs
}
// Whether a pending face matched the current faces query by FRAME (F# range) —
// either semantically (number within [start,end] / range overlap) or by
// substring of the start/end frame numbers, so F is searchable like FS.
function isFaceFRangeHit(t: any): boolean {
const q = facesSearchQuery.value.trim().toLowerCase()
if (!q || !/[\d-]/.test(q)) return false
const s = t.start_frame ?? 0
const e = t.end_frame ?? s
const rangeMatch = q.match(/^(\d+)\s*-\s*(\d+)$/)
if (rangeMatch) {
const qs = parseInt(rangeMatch[1], 10)
const qe = parseInt(rangeMatch[2], 10)
return qs <= qe && s <= qe && e >= qs
}
if (/^\d+$/.test(q)) {
const n = parseInt(q, 10)
if (n >= s && n <= e) return true
if (String(s).includes(q) || String(e).includes(q)) return true
}
return false
}
// Cluster detail modal computed
const loadingClusterTraces = ref(false)
const filteredClusterTraces = computed(() => {
const c = clusterDetailModal.value.cluster
if (!c) return []
const traceIds = c.trace_ids || []
let traces = traceIds.map((tid: any) => clusterTracesMap.value[String(tid)]).filter((t: any) => t)
if (ffFilterActive.value) traces = traces.filter((t: any) => !ffHiddenTraces.value.has(t.trace_id))
if (!clusterSearchQuery.value) return traces
const q = clusterSearchQuery.value.toLowerCase()
return traces.filter((t: any) => String(t.trace_id).includes(q))
})
// Group traces filtered by the active face filter (shared by cluster detail
// strip, FS detail navigation, etc.)
function filteredGroupTraces(c: any): any[] {
const traceIds = c.trace_ids || []
const traces = traceIds.map((tid: any) => clusterTracesMap.value[String(tid)]).filter((t: any) => t)
if (ffFilterActive.value) return traces.filter((t: any) => !ffHiddenTraces.value.has(t.trace_id))
return traces
}
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)
})
// Face detail modal navigation
const faceDetailNavList = computed(() => {
if (faceDetailNavSource.value === 'unassigned') return filteredUnassignedTraces.value
if (faceDetailNavSource.value === 'cluster') {
const tid = faceDetailModal.value.candidate?.trace_id
const groupName = traceGroupMap.value[String(tid)]
if (groupName) {
const cluster = clusterResults.value.find((c: any) => c.name === groupName)
if (cluster) return filteredGroupTraces(cluster)
}
}
return []
})
const canFaceDetailPrev = computed(() => faceDetailNavIndex.value > 0)
const canFaceDetailNext = computed(() => {
if (faceDetailNavSource.value === null) return false
return faceDetailNavIndex.value < faceDetailNavList.value.length - 1
})
const faceDetailNavTotal = computed(() => faceDetailNavList.value.length)
const faceDetailNavLabel = computed(() => {
if (faceDetailNavSource.value === 'unassigned') return 'Unassigned'
if (faceDetailNavSource.value === 'cluster') {
const tid = faceDetailModal.value.candidate?.trace_id
return traceGroupMap.value[String(tid)] || ''
}
return ''
})
const faceDetailCaptureQuality = computed(() => {
const raw = faceDetailModal.value.candidate?.capture_quality
if (raw == null) return '—'
const num = Number(raw)
if (!isFinite(num)) return '—'
return `${num.toFixed(1)}%`
})
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
}
// --- trace-level stats (FS = trace) ---
// Each trace is represented by its best face; the filter only changes the
// hidden trace set, and statistics below are counted per trace, not per face.
const ffTraceValues = computed(() => [...ffBestFaces.value.values()])
const ffTraceTotal = computed(() => ffTraceValues.value.length)
const ffTraceVisible = computed(() =>
ffTraceValues.value.filter((f: any) => !ffHiddenTraces.value.has(f.trace_id)).length)
const ffTraceHidden = computed(() => ffTraceTotal.value - ffTraceVisible.value)
const ffGroupsPartial = computed(() => {
if (!ffFilterActive.value) return 0
return faceGroups.value.filter((g: any) => g.visible_count > 0 && g.visible_count < g.trace_count).length
})
const ffGroupsHidden = computed(() => {
if (!ffFilterActive.value) return 0
return clusterResults.value.filter((c: any) => {
const ids = c.trace_ids || []
return ids.length > 0 && ids.every((tid: number) => ffHiddenTraces.value.has(tid))
}).length
})
const ffStatus = computed(() => {
const minConf = ffConfEffective.value / 100
const sizeEff = ffSizeEffective.value
const qualEff = ffQualityEffective.value
let both = 0, confOnly = 0, sizeOnly = 0, cut = 0
for (const f of ffTraceValues.value) {
const passConf = f.confidence >= minConf
const passSize = f.size >= sizeEff
const passQual = f.quality == null || f.quality >= qualEff
if (passConf && passSize && passQual) both++
else if (passConf && passQual) confOnly++
else if (passSize && passQual) sizeOnly++
else cut++
}
return { both, confOnly, sizeOnly, cut }
})
const ffStatusRows = computed(() => {
const s = ffStatus.value
return [
{ key: 'both', label: 'conf+size', count: s.both, cut: false },
{ key: 'confOnly', label: 'conf only', count: s.confOnly, cut: true },
{ key: 'sizeOnly', label: 'size only', count: s.sizeOnly, cut: true },
{ key: 'cut', label: 'neither', count: s.cut, cut: true },
]
})
// --- trace-level hidden flag mechanism ---
// Each trace is represented by its best face (max confidence). The filter only
// changes this hidden set; the FS section and face groups read it to hide/deduct.
const ffSizeEffective = computed(() =>
ffMinSizePx.value > ffSizeMinRounded.value ? ffMinSizePx.value : 0)
const ffConfEffective = computed(() =>
ffMinConfPct.value > ffConfMinPct.value ? ffMinConfPct.value : 0)
const ffFilterActive = computed(() =>
(ffFaces.value.length > 0) && (ffConfEffective.value > 0 || ffSizeEffective.value > 0 || ffQualityEffective.value > 0))
const ffBestFaces = computed(() => {
const m = new Map<number, any>()
for (const f of ffFaces.value) {
const cur = m.get(f.trace_id)
if (!cur || f.confidence > cur.confidence) m.set(f.trace_id, f)
}
return m
})
const ffHiddenTraces = computed(() => {
const minConf = ffConfEffective.value / 100
const sizeEff = ffSizeEffective.value
const qualEff = ffQualityEffective.value
const s = new Set<number>()
for (const [tid, best] of ffBestFaces.value) {
if (best.confidence < minConf || best.size < sizeEff ||
(best.quality != null && best.quality < qualEff)) s.add(tid)
}
return s
})
const ffConfMinMax = computed(() => {
let min = Infinity, max = -Infinity
for (const f of ffTraceValues.value) {
if (f.confidence < min) min = f.confidence
if (f.confidence > max) max = f.confidence
}
if (!ffTraceValues.value.length) return { min: 0, max: 1 }
return { min, max }
})
// Slider range mirrors the size slider: bound to the actual data range (%).
const ffConfMinPct = computed(() => {
const m = Math.floor(ffConfMinMax.value.min * 100)
return isFinite(m) ? m : 0
})
const ffConfMaxPct = computed(() => {
const m = Math.ceil(ffConfMinMax.value.max * 100)
const mi = ffConfMinPct.value
return isFinite(m) ? (m > mi ? m : mi + 1) : 1
})
const ffConfBins = computed(() => buildBins(ffTraceValues.value.map((f: any) => f.confidence), ffConfEffective.value / 100, 10, 'percent'))
const ffConfCursorPct = computed(() => {
const min = ffConfMinPct.value
const max = ffConfMaxPct.value
if (max <= min) return 0
return Math.max(0, Math.min(100, ((ffMinConfPct.value - min) / (max - min)) * 100))
})
const confRugDots = computed(() => {
const min = ffConfMinPct.value; const max = ffConfMaxPct.value
if (max <= min) return []
return ffTraceValues.value.map((f: any) => ({
trace_id: f.trace_id,
value: f.confidence * 100,
pct: ((f.confidence * 100 - min) / (max - min)) * 100,
cut: f.confidence * 100 < ffConfEffective.value,
}))
})
const ffSizeMinRounded = computed(() => {
let m = Infinity
for (const f of ffTraceValues.value) if (f.size < m) m = f.size
return isFinite(m) ? Math.floor(m) : 0
})
const ffSizeMaxRounded = computed(() => {
let m = -Infinity
for (const f of ffTraceValues.value) if (f.size > m) m = f.size
return isFinite(m) ? Math.ceil(m) : 1
})
const ffSizeBins = computed(() => buildBins(ffTraceValues.value.map((f: any) => f.size), ffMinSizePx.value + 0.5, 10, 'size'))
const ffSizeCursorPct = computed(() => {
const { min, max } = { min: ffSizeMinRounded.value, max: ffSizeMaxRounded.value }
if (max <= min) return 0
return Math.max(0, Math.min(100, ((ffMinSizePx.value - min) / (max - min)) * 100))
})
const sizeRugDots = computed(() => {
const min = ffSizeMinRounded.value; const max = ffSizeMaxRounded.value
if (max <= min) return []
return ffTraceValues.value.map((f: any) => ({
trace_id: f.trace_id,
value: f.size,
pct: ((f.size - min) / (max - min)) * 100,
cut: f.size < ffSizeEffective.value,
}))
})
const ffQualityValues = computed(() => ffTraceValues.value.filter((f: any) => f.quality != null))
const ffQualityMinMax = computed(() => {
let min = Infinity, max = -Infinity
for (const f of ffQualityValues.value) {
if (f.quality < min) min = f.quality
if (f.quality > max) max = f.quality
}
if (!ffQualityValues.value.length) return { min: 0, max: 100 }
return { min, max }
})
const ffQualityMinPct = computed(() => {
const m = Math.floor(ffQualityMinMax.value.min)
return isFinite(m) ? m : 0
})
const ffQualityMaxPct = computed(() => {
const m = Math.ceil(ffQualityMinMax.value.max)
const mi = ffQualityMinPct.value
return isFinite(m) ? (m > mi ? m : mi + 1) : 100
})
const ffQualityEffective = computed(() =>
ffMinQuality.value > ffQualityMinPct.value ? ffMinQuality.value : 0)
const ffQualityBins = computed(() => buildBins(ffQualityValues.value.map((f: any) => f.quality / 100), ffQualityEffective.value / 100, 10, 'percent'))
const ffQualityCursorPct = computed(() => {
const min = ffQualityMinPct.value
const max = ffQualityMaxPct.value
if (max <= min) return 0
return Math.max(0, Math.min(100, ((ffMinQuality.value - min) / (max - min)) * 100))
})
const qualityRugDots = computed(() => {
const min = ffQualityMinPct.value; const max = ffQualityMaxPct.value
if (max <= min) return []
return ffQualityValues.value.map((f: any) => ({
trace_id: f.trace_id,
value: f.quality,
pct: ((f.quality - min) / (max - min)) * 100,
cut: f.quality < ffQualityEffective.value,
}))
})
function buildBins(vals: number[], threshold: number, binCount: number, format: 'percent' | 'size' | 'decimal' = 'decimal') {
if (!vals.length) return []
const min = Math.min(...vals)
const max = Math.max(...vals)
const span = max - min
const width = span > 0 ? span / binCount : 1
const idxOf = (v: number) => (span > 0 ? Math.min(binCount - 1, Math.floor((v - min) / width)) : 0)
const counts = Array(binCount).fill(0)
for (const v of vals) counts[idxOf(v)]++
const top = Math.max(1, ...counts)
return counts.map((count, i) => {
const lo = min + i * width
const hi = min + (i + 1) * width
let label: string
if (span > 0) {
if (format === 'percent') {
const loPct = lo * 100
const hiPct = hi * 100
const hiDisplay = hiPct - 0.1
label = `${loPct.toFixed(1)}–${hiDisplay.toFixed(1)}`
} else if (format === 'size') {
const loInt = Math.round(lo)
const hiInt = Math.round(hi)
const hiDisplay = Math.max(loInt, hiInt - 1)
label = `${loInt}–${hiDisplay}`
} else {
label = `${lo.toFixed(1)}–${hi.toFixed(1)}`
}
} else {
if (format === 'percent') {
label = `${(max * 100).toFixed(1)}`
} else if (format === 'size') {
label = `${Math.round(max)}`
} else {
label = `${max}`
}
}
return {
key: i,
label,
count,
isCut: hi <= threshold,
top,
}
})
}
function ffBarH(b: any): string {
const h = Math.round((b.count / b.top) * 60)
return Math.max(3, h) + 'px'
}
function setThreshold(type: 'conf' | 'size' | 'quality', e: MouseEvent) {
if (dragState.value) return
applyThreshold(type, e)
}
function applyThreshold(type: 'conf' | 'size' | 'quality', e: MouseEvent) {
const wrap = (e.currentTarget as HTMLElement).closest('.ms-ff-hist-wrap') as HTMLElement
if (!wrap) return
const rect = wrap.getBoundingClientRect()
const x = e.clientX - rect.left
const pct = Math.max(0, Math.min(1, x / rect.width))
if (type === 'conf') {
const min = ffConfMinPct.value; const max = ffConfMaxPct.value
ffMinConfPct.value = Math.max(0, min + pct * (max - min))
} else if (type === 'size') {
const min = ffSizeMinRounded.value; const max = ffSizeMaxRounded.value
ffMinSizePx.value = Math.max(0, Math.round(min + pct * (max - min)))
} else {
const min = ffQualityMinPct.value; const max = ffQualityMaxPct.value
ffMinQuality.value = Math.max(0, min + pct * (max - min))
}
}
function startDrag(type: 'conf' | 'size' | 'quality', e: MouseEvent) {
e.preventDefault()
const wrap = (e.currentTarget as HTMLElement).closest('.ms-ff-hist-wrap') as HTMLElement
if (!wrap) return
dragState.value = { type, wrap }
applyThreshold(type, e)
}
function onDragMove(e: MouseEvent) {
if (!dragState.value) return
const { type, wrap } = dragState.value
const rect = wrap.getBoundingClientRect()
const x = e.clientX - rect.left
const pct = Math.max(0, Math.min(1, x / rect.width))
if (type === 'conf') {
const min = ffConfMinPct.value; const max = ffConfMaxPct.value
ffMinConfPct.value = Math.max(0, min + pct * (max - min))
} else if (type === 'size') {
const min = ffSizeMinRounded.value; const max = ffSizeMaxRounded.value
ffMinSizePx.value = Math.max(0, Math.round(min + pct * (max - min)))
} else {
const min = ffQualityMinPct.value; const max = ffQualityMaxPct.value
ffMinQuality.value = Math.max(0, min + pct * (max - min))
}
}
function onDragEnd() {
dragState.value = null
}
const FF_DOT_CAP = 15
function ffDotsFilled(n: number, max: number): boolean[] {
const cap = FF_DOT_CAP
const scaled = max > cap ? Math.max(1, Math.round((n / max) * cap)) : n
return Array.from({ length: cap }, (_, i) => i < scaled)
}
function faceQuality(t: any): number | null {
const pre = Number(t?.best_face_capture_quality ?? t?.capture_quality ?? NaN)
if (isFinite(pre)) return Math.max(0, Math.min(100, Math.round(pre * 100)))
const eye = Number(t?.best_face_eye_ratio ?? t?.eye_ratio ?? NaN)
const nose = Number(t?.best_face_nose_ratio ?? t?.nose_ratio ?? NaN)
const pose = Number(t?.best_face_pose_score ?? t?.pose_score ?? NaN)
if (!isFinite(eye) && !isFinite(nose) && !isFinite(pose)) return null
let score = 1.0
if (isFinite(eye) && (eye < 0.25 || eye > 0.55)) score -= 0.25
if (isFinite(nose) && nose < 0.08) score -= 0.25
if (isFinite(pose) && pose > 0.8) score -= 0.25
return Math.max(0, Math.min(100, Math.round(score * 100)))
}
async function toggleFaceFilter() {
showFaceFilter.value = !showFaceFilter.value
if (showFaceFilter.value && selectedFileUuid.value) {
if (ffFaces.value.length === 0) {
await loadFaces()
}
await loadSingleFrameSettings()
}
}
async function loadFaces() {
if (!selectedFileUuid.value || ffLoading.value) return
ffLoading.value = true
const uuid = selectedFileUuid.value
const all: any[] = []
try {
// Per-trace endpoint is trace-level (best face per trace), far lighter than
// downloading every individual face detection page-by-page. A file with
// 300k+ detections would otherwise need thousands of sequential requests.
const pageSize = 3000
const first: any = await apiCall('get_file_traces', { fileUuid: uuid, page: 1, page_size: pageSize, limit: pageSize })
const traces = first?.traces || []
const total = Number(first?.total ?? traces.length) || traces.length
if (total > traces.length) {
const totalPages = Math.ceil(total / pageSize)
const rest = await Promise.all(
Array.from({ length: totalPages - 1 }, (_, i) =>
apiCall('get_file_traces', { fileUuid: uuid, page: i + 2, page_size: pageSize, limit: pageSize })
.then((r: any) => r?.traces || [])
.catch(() => [] as any[])
)
)
for (const batch of rest) {
traces.push(...batch)
}
}
for (const t of traces) {
const conf = Number(t.best_face_confidence ?? 0)
const bbox = t.best_face_bbox || {}
all.push({
face_id: String(t.best_face_id ?? t.trace_id ?? 0),
trace_id: Number(t.trace_id ?? 0),
frame: Number(t.best_face_frame ?? 0),
confidence: conf,
confPct: Math.round(conf * 100),
size: Math.min(Number(bbox.width ?? 0), Number(bbox.height ?? 0)),
quality: faceQuality(t),
})
}
ffFaces.value = all
} catch (e) {
console.error('loadFaces error:', e)
} finally {
ffLoading.value = false
}
}
// Per-file face filter persistence via file profile (Core owns the storage).
// Fallback init from localStorage for backward-compat until per-file value exists.
let ffSaveTimer: ReturnType<typeof setTimeout> | null = null
let ffLastLoadedFile = ''
async function loadFaceFilterFromProfile(fileUuid: string) {
ffLastLoadedFile = fileUuid
try {
const fp: any = await apiCall('get_file_profile', { fileUuid })
const ff = fp?.face_filter
// Force minimum filter values to show all faces
ffMinConfPct.value = 0
ffMinSizePx.value = 0
ffMinQuality.value = 0
} catch (e) {
console.error('loadFaceFilterFromProfile error:', e)
// Fallback to minimum values
ffMinConfPct.value = 0
ffMinSizePx.value = 0
ffMinQuality.value = 0
}
}
function saveFaceFilterToProfile() {
if (!selectedFileUuid.value) return
if (ffSaveTimer) clearTimeout(ffSaveTimer)
ffSaveTimer = setTimeout(async () => {
try {
await apiCall('update_file_profile', {
fileUuid: selectedFileUuid.value,
faceFilter: {
min_confidence: ffMinConfPct.value,
min_size: ffMinSizePx.value,
min_capture_quality: ffMinQuality.value,
single_frame_trace_vlm_enabled: singleFrameSettings.value.single_frame_trace_vlm_enabled,
single_frame_trace_group_enabled: singleFrameSettings.value.single_frame_trace_group_enabled,
single_frame_trace_unassigned_enabled: singleFrameSettings.value.single_frame_trace_unassigned_enabled,
single_trace_group_filter_enabled: singleFrameSettings.value.single_trace_group_filter_enabled,
},
})
singleFrameSettingsModified.value = false
} catch (e) {
console.error('saveFaceFilterToProfile error:', e)
}
}, 400)
}
async function resetFilterToDefault() {
if (!selectedFileUuid.value) return
if (!confirm('Reset face filter to system default?')) return
try {
await apiCall('update_file_profile', {
fileUuid: selectedFileUuid.value,
faceFilter: null,
})
if (adminFaceFilter) {
if (adminFaceFilter.min_confidence != null) ffMinConfPct.value = Number(adminFaceFilter.min_confidence)
if (adminFaceFilter.min_size != null) ffMinSizePx.value = Number(adminFaceFilter.min_size)
if (adminFaceFilter.min_capture_quality != null) ffMinQuality.value = Number(adminFaceFilter.min_capture_quality)
}
} catch (e) {
console.error('resetFilterToDefault error:', e)
}
}
async function loadSingleFrameSettings() {
if (!selectedFileUuid.value) return
singleFrameSettingsLoading.value = true
try {
const profile = await apiCall('get_file_profile', { fileUuid: selectedFileUuid.value })
if (profile?.face_filter) {
singleFrameSettings.value = {
single_frame_trace_vlm_enabled: profile.face_filter.single_frame_trace_vlm_enabled ?? true,
single_frame_trace_group_enabled: profile.face_filter.single_frame_trace_group_enabled ?? true,
single_frame_trace_unassigned_enabled: profile.face_filter.single_frame_trace_unassigned_enabled ?? true,
single_trace_group_filter_enabled: profile.face_filter.single_trace_group_filter_enabled ?? true,
}
singleFrameSettingsModified.value = false
}
} catch (e) {
console.error('loadSingleFrameSettings error:', e)
} finally {
singleFrameSettingsLoading.value = false
}
}
async function saveSingleFrameSettings() {
if (!selectedFileUuid.value) return
singleFrameSettingsLoading.value = true
try {
await saveFaceFilterToProfile()
} finally {
singleFrameSettingsLoading.value = false
}
}
function resetSingleFrameSettings() {
singleFrameSettings.value = {
single_frame_trace_vlm_enabled: true,
single_frame_trace_group_enabled: true,
single_frame_trace_unassigned_enabled: true,
single_trace_group_filter_enabled: true,
}
singleFrameSettingsModified.value = true
}
function markSingleFrameSettingsModified() {
singleFrameSettingsModified.value = true
}
watch(ffMinConfPct, v => {
if (selectedFileUuid.value && ffLastLoadedFile === selectedFileUuid.value) saveFaceFilterToProfile()
})
watch(ffMinSizePx, v => {
if (selectedFileUuid.value && ffLastLoadedFile === selectedFileUuid.value) saveFaceFilterToProfile()
})
watch(ffMinQuality, v => {
if (selectedFileUuid.value && ffLastLoadedFile === selectedFileUuid.value) saveFaceFilterToProfile()
})
let adminFaceFilter: any = null
async function loadDefaultFaceFilter() {
try {
const base = getApiBase()
const res = await fetch(`${base}/api/v1/admin/config`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const config: any = await res.json()
adminFaceFilter = config?.filter_config?.face || null
} catch (e) {
console.error('loadDefaultFaceFilter error:', e)
}
}
loadDefaultFaceFilter()
async function refresh() {
// 清掉縮圖 blob cache + 換新 ?t=,讓 face grid 重抓最新 key_face
clearFaceThumbs()
bumpThumbCacheBust()
refreshing.value = true
loadingStep.value = 'Loading files...'
await ensureFiles()
const processingFiles = filesCache.value.filter((f: any) => f.status === 'processing')
if (processingFiles.length > 0) {
try {
loadingStep.value = 'Syncing file status...'
await syncAllProcessingFiles()
} catch (e) {
console.error('syncAllProcessingFiles error:', e)
}
}
refreshing.value = false
loadingStep.value = ''
if (selectedFileUuid.value) {
unassignedTracesPage.value = 1
unassignedTracesLoaded.value = false
unassignedTracesCache.value = []
delete clusterResultsCache.value[selectedFileUuid.value]
clusterTracesMap.value = {}
loadingClusterTraces.value = false
clusterVideoTraces.value = []
clusterVideoSegments.value = []
loadingStep.value = 'Loading clusters...'
// Load cluster results first (always reload server truth; clear stuck running flag)
setClusterRunning(selectedFileUuid.value, false)
await loadClusterResults()
// Only load unassigned traces if Faces section is visible
if (showUface.value) {
loadingStep.value = 'Loading traces...'
await ensureUnassignedTraces(selectedFileUuid.value)
for (const t of unassignedTraces.value.slice(0, 20)) {
if (t.file_uuid) loadTraceThumb(t)
}
}
loadingStep.value = ''
} else {
unassignedTracesLoaded.value = false
unassignedTracesCache.value = []
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)
let agentFinished = false
try {
try {
const result: any = await apiCall('run_cluster_agent', { fileUuid: selectedFileUuid.value })
if (result.success) agentFinished = true
} catch (e: any) {
// Timeout/network failure: server may still be processing. Fall through to polling.
console.warn('Cluster agent request interrupted, polling for results:', e)
}
// Poll for results (covers both success and interrupted/timeout cases)
for (let i = 0; i < 120; i++) {
if (!isClusterRunning(selectedFileUuid.value)) return
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) {
return
}
const fileUuid = selectedFileUuid.value
try {
const results: any = await apiCall('get_cluster_results', { fileHash: fileUuid })
if (selectedFileUuid.value !== fileUuid) {
return
}
const groups = results?.face_groups || results?.clusters || []
const frames = results?.frames || []
// 統一 group_id → cluster_id, 並添加 group_uuid
for (const g of groups) {
if (g.group_id !== undefined && g.cluster_id === undefined) {
g.cluster_id = g.group_id
}
// 添加 group_uuid 字段供前端使用
if (g.group_uuid === undefined) {
g.group_uuid = `group_${g.cluster_id || g.group_id}`
}
}
// Build cluster_id → faces[] mapping from frames
const clusterFacesMap: Record<string, any[]> = {}
const clusterTraceIdsMap: Record<string, Set<number>> = {}
const faceIdToTraceId: Record<string, number> = {}
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,
trace_id: face.trace_id
})
if (face.trace_id !== undefined && face.trace_id !== null) {
if (!clusterTraceIdsMap[cid]) clusterTraceIdsMap[cid] = new Set()
clusterTraceIdsMap[cid].add(face.trace_id)
if (face.face_id) {
faceIdToTraceId[face.face_id] = face.trace_id
}
}
}
}
// Assign faces and trace_ids 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
c.trace_ids = Array.from(clusterTraceIdsMap[cid] || [])
// Set representative_trace from representative_face
if (c.representative_face?.face_id) {
const repTraceId = faceIdToTraceId[c.representative_face.face_id]
if (repTraceId !== undefined) {
c.representative_trace = repTraceId
}
}
// Fallback to first trace_id
if (c.representative_trace === undefined && c.trace_ids.length > 0) {
c.representative_trace = c.trace_ids[0]
}
}
if (groups.length) {
setClusterResults(fileUuid, groups)
// Load trace profiles for names in background (non-blocking)
loadClusterNames(fileUuid, groups).catch(() => {})
// Load thumbnails for cluster representatives immediately.
// rep 資料只需 file_uuid + trace_id,皆已存在 cluster-results payload 中,
// 不需等待慢速的 get_unassigned_traces(~5s/page)。
for (const c of groups) {
const traceIds = c.trace_ids || []
const repId = c.representative_trace !== undefined && c.representative_trace !== null ? c.representative_trace : traceIds[0]
if (repId === undefined || repId === null) continue
loadTraceThumb({ trace_id: repId, file_uuid: fileUuid, best_face_id: 0 })
}
// Build trace map in background (non-blocking, parallel pages).
// 只服務 cluster detail modal 的 member strip,不應阻塞 Face Group 主網格。
buildClusterTracesMap(fileUuid, groups)
void (async () => {
// Load TMDB identity matches and assign to clusters
try {
const identityResults: any = await apiCall('get_identity_matches', { fileHash: fileUuid })
if (identityResults?.suggestions) {
const suggestions = identityResults.suggestions as Record<string, any>
for (const c of groups) {
const traceIds = c.trace_ids || []
// Count identity matches in this cluster
const identityCounts: Record<string, { name: string; uuid: string; count: number }> = {}
for (const tid of traceIds) {
const s = suggestions[String(tid)]
if (s) {
const key = s.group_uuid
if (!identityCounts[key]) {
identityCounts[key] = { name: s.name, uuid: s.group_uuid, count: 0 }
}
identityCounts[key].count++
}
}
// Assign the most common identity name
const best = Object.values(identityCounts).sort((a, b) => b.count - a.count)[0]
if (best) {
c.tmdb_name = best.name
c.tmdb_uuid = best.uuid
c.tmdb_count = best.count
}
}
}
} catch { /* no identity matches available */ }
})()
}
} catch (e) {
console.error('[cluster] loadClusterResults failed:', e)
}
}
// 背景建立 clusterTracesMap(並行抓取頁面),只服務 cluster detail modal 的 member strip。
async function buildClusterTracesMap(fileUuid: string, groups: any[]) {
loadingClusterTraces.value = true
try {
const allTraces: any[] = []
const pageSize = 3000
const first: any = await apiCall('get_file_traces', { fileUuid, page: 1, page_size: pageSize, limit: pageSize })
allTraces.push(...(first?.traces || []))
const total = first?.total || 0
const totalPages = Math.ceil(total / pageSize)
if (totalPages > 1) {
const rest = await Promise.all(
Array.from({ length: totalPages - 1 }, (_, i) =>
apiCall('get_file_traces', { fileUuid, page: i + 2, page_size: pageSize, limit: pageSize })
.then((r: any) => r?.traces || [])
.catch(() => [] as any[])
)
)
for (const batch of rest) {
allTraces.push(...batch)
}
}
if (selectedFileUuid.value !== fileUuid) return
clusterTracesMap.value = {}
for (const t of allTraces) {
if (!t.file_uuid) {
console.warn('[cluster] trace missing file_uuid:', t.trace_id, t)
}
clusterTracesMap.value[String(t.trace_id)] = t
}
// Also add traces from cluster results to the map
for (const c of groups) {
const traceIds = c.trace_ids || []
for (const tid of traceIds) {
if (!clusterTracesMap.value[String(tid)]) {
// Create trace entry from cluster data
const repTrace = clusterTracesMap.value[String(c.representative_trace !== undefined && c.representative_trace !== null ? c.representative_trace : traceIds[0])]
if (repTrace) {
clusterTracesMap.value[String(tid)] = {
...repTrace,
trace_id: tid,
}
}
}
}
}
} catch (e) {
console.error('[cluster] buildClusterTracesMap failed:', e)
} finally {
loadingClusterTraces.value = false
}
}
onMounted(async () => {
const rqFile = route.query.file
if (typeof rqFile === 'string') selectedFileUuid.value = rqFile
await refresh()
// Resume cluster polling if a run was in progress when user navigated away
if (selectedFileUuid.value && isClusterRunning(selectedFileUuid.value)) {
await pollClusterResults(selectedFileUuid.value, async (results) => {
await loadClusterResults()
})
}
document.addEventListener('click', closeFaceCtxMenu)
document.addEventListener('click', closeClusterCtxMenu)
document.addEventListener('click', closeKeyFaceCtxMenu)
document.addEventListener('click', closeFrameCtxMenu)
document.addEventListener('keydown', handleFaceDetailKeydown)
document.addEventListener('mousemove', onDragMove)
document.addEventListener('mouseup', onDragEnd)
window.addEventListener('resize', onResizeOverlay)
})
onUnmounted(() => {
document.removeEventListener('click', closeFaceCtxMenu)
document.removeEventListener('click', closeClusterCtxMenu)
document.removeEventListener('click', closeKeyFaceCtxMenu)
document.removeEventListener('click', closeFrameCtxMenu)
document.removeEventListener('keydown', handleFaceDetailKeydown)
document.removeEventListener('mousemove', onDragMove)
document.removeEventListener('mouseup', onDragEnd)
window.removeEventListener('resize', onResizeOverlay)
})
function loadCandidateThumb(c: any) {
if (!c?.file_uuid) return
const traceLike = {
trace_id: c.trace_id,
file_uuid: c.file_uuid,
best_face_id: c.best_face_id,
best_face_frame: c.best_face_frame !== undefined && c.best_face_frame !== null ? c.best_face_frame : (c.frame_number || c.start_frame || 0),
best_face_bbox: c.best_face_bbox || c.bbox,
start_frame: c.start_frame,
}
const key = thumbTraceKey(traceLike)
if (faceThumbsCache.value[key] || loadingFaceThumbs.has(key)) return
loadTraceThumb(traceLike)
}
async function regenerateFaceThumbnail() {
const c = faceDetailModal.value.candidate
if (!c?.file_uuid || c?.trace_id === undefined) return
regeneratingThumbnail.value = true
try {
await apiCall('regenerate_trace_thumbnail', {
fileUuid: c.file_uuid,
traceId: c.trace_id
})
const key = thumbTraceKey(c)
if (faceThumbsCache.value[key]) {
URL.revokeObjectURL(faceThumbsCache.value[key])
delete faceThumbsCache.value[key]
}
bumpThumbCacheBust()
loadCandidateThumb(c)
} catch (e) {
console.error('regenerateFaceThumbnail error:', e)
} finally {
regeneratingThumbnail.value = false
}
}
function selectCluster(c: any) {
clusterDetailZIndex.value = getNextZIndex()
clusterDetailModal.value = { show: true, cluster: c, selectedTrace: null }
clusterStripPage.value = 1
clusterSearchQuery.value = ''
const traceIds = c.trace_ids || []
const missing = traceIds.some((tid: any) => !clusterTracesMap.value[String(tid)])
if (missing && !loadingClusterTraces.value) {
loadingClusterTraces.value = true
loadClusterResults().finally(() => {
loadingClusterTraces.value = false
})
}
}
async function playClusterVideo() {
const c = clusterDetailCluster.value || 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}`,
keyFrame: firstTrace?.best_face_frame ?? firstTrace?.first_frame ?? null,
}
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}`,
keyFrame: trace.best_face_frame || startFrame,
}
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 !== undefined && trace.best_face_frame !== null ? trace.best_face_frame : (trace.start_frame || 0),
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)})`,
keyFrame: t.best_face_frame || t.first_frame || 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 !== undefined && t.best_face_frame !== null ? t.best_face_frame : (t.start_frame || 0),
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,
capture_quality: faceQuality(t),
start_frame: t.start_frame ?? 0,
end_frame: t.end_frame ?? (t.start_frame ?? 0) + (t.frame_count ?? 1),
}
faceDetailModal.value = { show: true, candidate, newIdentityName: '' }
faceSourceClusterId.value = clusterDetailModal.value.cluster?.cluster_id ?? null
faceMoveTarget.value = ''
faceMoveNewGroupName.value = ''
loadCandidateThumb(candidate)
faceDetailLandmarks.value = null
landmarkScore.value = null
faceDetailAppearance.value = null
faceDetailFrameUrl.value = ''
faceDetailVideoSize.value = null
const bbox = t.best_face_bbox
const frame = t.best_face_frame !== undefined && t.best_face_frame !== null ? t.best_face_frame : (t.start_frame || 0)
if (t.file_uuid && frame !== undefined && frame !== null) {
faceDetailFrameUrl.value = `/api/v1/file/${t.file_uuid}/thumbnail?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 }
const [landmarks, appearance] = await Promise.all([
getFaceLandmarks(t.file_uuid, frame, bbox),
getAppearance(t.file_uuid, frame, bbox)
])
faceDetailLandmarks.value = landmarks
faceDetailAppearance.value = appearance
if (landmarks) {
landmarkScore.value = scoreFaceLandmarks(landmarks)
}
} catch (e) {
console.error('Failed to load landmarks/appearance:', e)
}
}
setFaceDetailNavContext(t)
}
function setFaceDetailNavContext(t: any) {
// The trace's own group takes priority — navigation is limited to that group
const groupName = traceGroupMap.value[String(t.trace_id)]
if (groupName) {
const cluster = clusterResults.value.find((c: any) => c.name === groupName)
if (cluster) {
const traces = filteredGroupTraces(cluster)
const cIdx = traces.findIndex((x: any) => x.trace_id === t.trace_id)
if (cIdx >= 0) {
faceDetailNavSource.value = 'cluster'
faceDetailNavIndex.value = cIdx
return
}
}
}
// Truly unassigned traces (already filtered out grouped ones), filter-aware
const uIdx = filteredUnassignedTraces.value.findIndex(
(x: any) => x.trace_id === t.trace_id && x.file_uuid === t.file_uuid
)
if (uIdx >= 0) {
faceDetailNavSource.value = 'unassigned'
faceDetailNavIndex.value = uIdx
return
}
faceDetailNavSource.value = null
faceDetailNavIndex.value = -1
}
function goFaceDetailPrev() {
if (!canFaceDetailPrev.value) return
faceDetailNavIndex.value--
const prev = faceDetailNavList.value[faceDetailNavIndex.value]
if (prev) openFaceDetailFromTrace(prev)
}
function goFaceDetailNext() {
if (!canFaceDetailNext.value) return
faceDetailNavIndex.value++
const next = faceDetailNavList.value[faceDetailNavIndex.value]
if (next) openFaceDetailFromTrace(next)
}
async function deleteCurrentFace() {
const candidate = faceDetailModal.value.candidate
if (!candidate) return
const confirmed = window.confirm(`Delete FS#${candidate.trace_id}? This cannot be undone.`)
if (!confirmed) return
operationLoading.value = true
operationMessage.value = `Deleting FS#${candidate.trace_id}...`
try {
await apiCall('delete_trace', {
file_uuid: candidate.file_uuid,
trace_id: candidate.trace_id,
mode: 'delete'
})
const key = `${candidate.file_uuid}:${candidate.trace_id}`
removeTracesFromCache([key])
// Try next first, then prev
if (canFaceDetailNext.value) {
goFaceDetailNext()
} else if (canFaceDetailPrev.value) {
goFaceDetailPrev()
} else {
faceDetailModal.value.show = false
unassignedTracesLoaded.value = false
await ensureUnassignedTraces(selectedFileUuid.value, unassignedTracesPage.value)
}
} catch (e) {
console.error('Delete face failed:', e)
} finally {
operationLoading.value = false
operationMessage.value = ''
}
}
function handleFaceDetailKeydown(e: KeyboardEvent) {
if (!faceDetailModal.value.show) return
if (e.key === 'ArrowLeft') { e.preventDefault(); goFaceDetailPrev() }
if (e.key === 'ArrowRight') { e.preventDefault(); goFaceDetailNext() }
}
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 onFullFrameLoad(e: Event) {
const img = e.target as HTMLImageElement
const canvas = fullFrameCanvas.value
if (!canvas) return
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
const c = fullFrameContainer.value
if (c) {
const imgRect = img.getBoundingClientRect()
const cRect = c.getBoundingClientRect()
canvas.style.width = imgRect.width + 'px'
canvas.style.height = imgRect.height + 'px'
canvas.style.left = (imgRect.left - cRect.left) + 'px'
canvas.style.top = (imgRect.top - cRect.top) + 'px'
}
drawFullFrameOverlay()
}
function onResizeOverlay() {
redrawOverlay()
if (showFullFrame.value) drawFullFrameOverlay()
}
function drawFullFrameOverlay() {
const canvas = fullFrameCanvas.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.clearRect(0,0, canvas.width, canvas.height)
drawOverlays(ctx, canvas.width, canvas.height)
}
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)
drawOverlays(ctx, canvas.width, canvas.height)
}
function drawOverlays(ctx: CanvasRenderingContext2D, cw: number, ch: number) {
const bbox = faceDetailModal.value.candidate?.bbox
const landmarks = faceDetailLandmarks.value
if (showBboxOverlay.value && bbox) {
const isNormalized = bbox.x <= 1.0 && bbox.y <= 1.0 && bbox.width <= 1.0 && bbox.height <= 1.0
const x = isNormalized ? bbox.x * cw : bbox.x
const y = isNormalized ? bbox.y * ch : bbox.y
const w = isNormalized ? bbox.width * cw : bbox.width
const h = isNormalized ? bbox.height * ch : bbox.height
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 (showOthersOverlay.value && landmarks?.faces && landmarks.faces.length > 0) {
let selCx: number | null = null
let selCy: number | null = null
if (bbox) {
const isNorm = bbox.x <= 1.0 && bbox.y <= 1.0 && bbox.width <= 1.0 && bbox.height <= 1.0
const bx = isNorm ? bbox.x * cw : bbox.x
const by = isNorm ? bbox.y * ch : bbox.y
const bw = isNorm ? bbox.width * cw : bbox.width
const bh = isNorm ? bbox.height * ch : bbox.height
selCx = bx + bw / 2
selCy = by + bh / 2
}
ctx.lineWidth = 1
ctx.strokeStyle = 'rgba(255, 255, 255, 0.35)'
for (const f of landmarks.faces) {
const cx = f.x + f.width / 2
const cy = f.y + f.height / 2
if (selCx != null && selCy != null && Math.abs(cx - selCx) < 5 && Math.abs(cy - selCy) < 5) continue
ctx.strokeRect(f.x, f.y, f.width, f.height)
}
}
if (showLandmarkOverlay.value && landmarks?.landmarks) {
const groups: Array<{ key: string; label: string; color: string }> = [
{ key: 'right_eye', label: 'left_eye', color: '#00ffff' },
{ key: 'left_eye', label: 'right_eye', color: '#ffff00' },
{ key: 'nose', label: 'nose', color: '#00ff00' }
]
for (const g of groups) {
const ptsArr: number[][] = landmarks.landmarks?.[g.key as 'left_eye' | 'right_eye' | 'nose'] || []
if (ptsArr.length === 0) continue
ctx.fillStyle = g.color
for (const p of ptsArr) {
ctx.beginPath()
ctx.arc(p[0], p[1], 6, 0, Math.PI * 2)
ctx.fill()
}
ctx.fillStyle = '#fff'
ctx.font = '10px sans-serif'
ctx.fillText(g.label, ptsArr[0][0] + 8, ptsArr[0][1] + 4)
}
const outer = landmarks.lips?.outer_lips || []
if (outer.length > 0) {
ctx.strokeStyle = '#ff69b4'
ctx.lineWidth = 2
ctx.beginPath()
outer.forEach((p: number[], i: number) => {
if (i === 0) ctx.moveTo(p[0], p[1])
else ctx.lineTo(p[0], p[1])
})
ctx.closePath()
ctx.stroke()
}
}
}
watch([showBboxOverlay, showLandmarkOverlay, showOthersOverlay], () => {
redrawOverlay()
if (showFullFrame.value) drawFullFrameOverlay()
})
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})`,
keyFrame: frame,
}
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)}`,
keyFrame: frame,
}
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
// Remove from unassigned cache if source was unassigned
if (sourceId === null) {
unassignedTracesCache.value = unassignedTracesCache.value.filter((t: any) => t.trace_id !== tid)
}
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
// Remove from unassigned cache if source was unassigned
if (sourceId === null) {
unassignedTracesCache.value = unassignedTracesCache.value.filter((t: any) => t.trace_id !== tid)
}
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?.group_uuid) {
faceDetailModal.value.show = false
if (c.trace_id) {
unassignedTracesCache.value = unassignedTracesCache.value.filter((t: any) => t.trace_id !== c.trace_id)
}
const cleanUuid = result.group_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 !== undefined && t.best_face_frame !== null ? t.best_face_frame : (t.start_frame || 0),
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,
capture_quality: faceQuality(t),
}
faceCtxMenu.value = { show: true, x: e.clientX, y: e.clientY, candidate: candidateLike }
}
function showKeyFaceCtxMenu(e: MouseEvent) {
keyFaceCtxMenu.value = { show: true, x: e.clientX, y: e.clientY }
}
function showFrameCtxMenu(e: MouseEvent) {
frameCtxMenu.value = { show: true, x: e.clientX, y: e.clientY }
}
async function openTraceProfileJson() {
const c = faceDetailModal.value.candidate
if (!c?.file_uuid || c.trace_id == null) {
traceProfileJsonTitle.value = 'trace_profile.json'
traceProfileJsonContent.value = 'No file_uuid / trace_id available for this face.'
traceProfileJsonDialog.value = true
return
}
traceProfileJsonTitle.value = `trace_profile.json — ${c.file_uuid}:FS#${c.trace_id}`
traceProfileJsonContent.value = 'Loading...'
traceProfileJsonDialog.value = true
try {
const data: any = await apiCall('get_trace_profile_file', { file_uuid: c.file_uuid, trace_id: c.trace_id })
traceProfileJsonContent.value = JSON.stringify(data, null, 2)
} catch (e: any) {
traceProfileJsonContent.value = `Error: ${e?.message || e}`
}
}
function closeFrameCtxMenu() {
frameCtxMenu.value.show = false
}
function closeKeyFaceCtxMenu() {
keyFaceCtxMenu.value.show = false
}
function toggleFaceSelection(t: any) {
const key = `${t.file_uuid}:${t.trace_id}`
const idx = selectedFaces.value.indexOf(key)
if (idx >= 0) {
selectedFaces.value.splice(idx, 1)
} else {
selectedFaces.value.push(key)
}
}
function toggleGroupSelection(c: any) {
const key = c.group_uuid
const idx = selectedGroups.value.indexOf(key)
if (idx >= 0) {
selectedGroups.value.splice(idx, 1)
} else {
selectedGroups.value.push(key)
}
}
function toggleClusterTraceSelection(t: any) {
const traceId = t.trace_id
const idx = selectedClusterTraces.value.indexOf(traceId)
if (idx >= 0) {
selectedClusterTraces.value.splice(idx, 1)
} else {
selectedClusterTraces.value.push(traceId)
}
}
function clearSelection() {
selectedFaces.value = []
selectedGroups.value = []
}
async function aiSuggest() {
if (!selectedGroups.value.length || !selectedFileUuid.value) return
aiSuggestLoading.value = true
try {
const group = faceGroups.value.find((g: any) => selectedGroups.value.includes(g.group_uuid))
if (!group || !group.trace_ids?.length) return
const result: any = await apiCall('get_similar_face_groups', {
fileUuid: selectedFileUuid.value,
traceIds: group.trace_ids.join(','),
topK: 1,
minScore: 0.6
})
if (result?.suggestions?.length) {
const sug = result.suggestions[0]
const target = faceGroups.value.find((g: any) => g.name === sug.group_name)
if (target && !selectedGroups.value.includes(target.group_uuid)) {
selectedGroups.value.push(target.group_uuid)
}
}
} catch (e) {
console.error('AI Suggest failed:', e)
} finally {
aiSuggestLoading.value = false
}
}
function showUndoBannerWithTimer() {
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
}
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) })
}
pendingDeleteFaces.value = traceList
deleteConfirmInput.value = ''
showDeleteFacesModal.value = true
}
async function confirmDeleteFaces() {
if (deleteConfirmInput.value.trim().toUpperCase() !== 'DELETE') return
const traceList = [...pendingDeleteFaces.value]
if (!traceList.length) return
showDeleteFacesModal.value = false
deleteConfirmInput.value = ''
operationLoading.value = true
operationMessage.value = `Deleting ${traceList.length} trace(s)...`
const deletedKeys = [...selectedFaces.value]
let failedCount = 0
let filesNotRemoved = 0
for (const t of traceList) {
try {
const res: any = await apiCall('delete_trace', { file_uuid: t.file_uuid, trace_id: t.trace_id, mode: 'delete' })
if (res?.files_removed === false) filesNotRemoved++
} catch (e) {
console.error('Delete failed:', t, e)
failedCount++
}
}
removeTracesFromCache(deletedKeys)
operationLoading.value = false
operationMessage.value = ''
clearSelection()
pendingDeleteFaces.value = []
unassignedTracesLoaded.value = false
await ensureUnassignedTraces(selectedFileUuid.value, unassignedTracesPage.value)
if (failedCount > 0) {
alert(`${failedCount} trace(s) failed to delete.`)
} else if (filesNotRemoved > 0) {
alert(`${filesNotRemoved} trace(s) deleted from database but disk files could not be removed.`)
}
}
async function batchDeleteGroups() {
if (!selectedGroups.value.length) return
const groupIds = Array.from(selectedGroups.value)
const groupsToDelete = faceGroups.value.filter((c: any) => groupIds.includes(c.group_uuid))
let totalTraces = 0
const allTraceIds: number[] = []
for (const g of groupsToDelete) {
totalTraces += (g.trace_ids || []).length
allTraceIds.push(...(g.trace_ids || []))
}
if (!confirm(`Ungroup ${groupsToDelete.length} group(s) with ${totalTraces} trace(s)?\n\nTraces will be renamed to "Unassigned", not deleted.`)) return
operationLoading.value = true
operationMessage.value = `Ungrouping ${totalTraces} trace(s)...`
for (const g of groupsToDelete) {
const traceIds = g.trace_ids || []
if (!traceIds.length) continue
const fileUuid = g.rep_trace?.file_uuid || selectedFileUuid.value
if (!fileUuid) continue
try {
await updateTraceProfileGroup(fileUuid, traceIds, { name: 'Unassigned' })
} catch (e) {
console.error('Ungroup failed:', g.name, e)
}
}
// Remove ungrouped traces from cluster cache so they show in Pending Faces
for (const tid of allTraceIds) {
delete clusterTracesMap.value[String(tid)]
}
operationLoading.value = false
operationMessage.value = ''
clearSelection()
await loadClusterResults()
}
function batchDeleteGroupTraces() {
if (!selectedGroups.value.length) return
const groupIds = Array.from(selectedGroups.value)
const groupsToDelete = faceGroups.value.filter((c: any) => groupIds.includes(c.group_uuid))
const traceList: { file_uuid: string; trace_id: number }[] = []
for (const g of groupsToDelete) {
const traceIds = g.trace_ids || []
const fileUuid = g.rep_trace?.file_uuid || selectedFileUuid.value
if (!fileUuid) continue
for (const tid of traceIds) {
traceList.push({ file_uuid: fileUuid, trace_id: tid })
}
}
if (!traceList.length) return
pendingDeleteGroupFaces.value = traceList
deleteGroupConfirmInput.value = ''
showDeleteGroupModal.value = true
}
async function confirmDeleteGroupFaces() {
if (deleteGroupConfirmInput.value.trim().toUpperCase() !== 'DELETE') return
const traceList = [...pendingDeleteGroupFaces.value]
if (!traceList.length) return
showDeleteGroupModal.value = false
deleteGroupConfirmInput.value = ''
operationLoading.value = true
operationMessage.value = `Deleting ${traceList.length} trace(s) from group(s)...`
let failedCount = 0
let filesNotRemoved = 0
for (const t of traceList) {
try {
const res: any = await apiCall('delete_trace', { file_uuid: t.file_uuid, trace_id: t.trace_id, mode: 'delete' })
if (res?.files_removed === false) filesNotRemoved++
} catch (e) {
console.error('Delete group trace failed:', t, e)
failedCount++
}
}
// Remove deleted traces from cluster cache
for (const t of traceList) {
delete clusterTracesMap.value[String(t.trace_id)]
}
// Clear selection and reload
clearSelection()
pendingDeleteGroupFaces.value = []
unassignedTracesLoaded.value = false
await ensureUnassignedTraces(selectedFileUuid.value, unassignedTracesPage.value)
await loadClusterResults()
operationLoading.value = false
operationMessage.value = ''
if (failedCount > 0) {
alert(`${failedCount} trace(s) failed to delete.`)
} else if (filesNotRemoved > 0) {
alert(`${filesNotRemoved} trace(s) deleted from database but disk files could not be removed.`)
}
}
async function batchMergeGroups() {
if (selectedGroups.value.length < 2) {
alert('Select at least 2 groups to merge')
return
}
const groupIds = Array.from(selectedGroups.value)
mergeCandidateGroups.value = faceGroups.value.filter((c: any) => groupIds.includes(c.group_uuid))
showMergeTargetModal.value = true
}
async function executeMerge(targetGroupId: string) {
showMergeTargetModal.value = false
const targetGroup = faceGroups.value.find((c: any) => c.group_uuid === targetGroupId)
if (!targetGroup) return
const targetGroupName = targetGroup.name
if (!targetGroupName) {
alert('Target group has no name')
return
}
const groupIds = Array.from(selectedGroups.value)
const sourceGroupIds = groupIds.filter(id => id !== targetGroupId)
const sourceGroups = faceGroups.value.filter((c: any) => sourceGroupIds.includes(c.group_uuid))
const sourceGroupNames = sourceGroups.map(g => g.name).filter(Boolean)
if (sourceGroupNames.length === 0) {
alert('No valid source groups to merge')
return
}
if (!confirm(`Merge ${sourceGroupNames.length} group(s) into "${targetGroupName}"?`)) return
operationLoading.value = true
operationMessage.value = `Merging ${sourceGroupNames.length} group(s)...`
try {
const result = await apiCall('merge_groups', {
fileUuid: selectedFileUuid.value,
sourceGroups: sourceGroupNames,
targetGroupName: targetGroupName
})
if (result?.success) {
await loadClusterResults()
clearSelection()
} else {
alert(`Merge failed: ${result?.error || 'Unknown error'}`)
}
} catch (e) {
console.error('[executeMerge] error:', e)
alert(`Merge failed: ${e}`)
} finally {
operationLoading.value = false
operationMessage.value = ''
}
}
async function batchMoveFaces() {
if (!selectedFaces.value.length) return
showMoveFacesModal.value = true
}
async function moveToGroup(targetGroup: any) {
showMoveFacesModal.value = false
const 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 (!traceList.length) return
if (!confirm(`Move ${traceList.length} trace(s) to "${targetGroup.name}"?`)) return
operationLoading.value = true
operationMessage.value = `Moving ${traceList.length} trace(s)...`
// Group by file_uuid for batch update
const byFile: Record<string, number[]> = {}
for (const t of traceList) {
if (!byFile[t.file_uuid]) byFile[t.file_uuid] = []
byFile[t.file_uuid].push(t.trace_id)
}
for (const [fileUuid, traceIds] of Object.entries(byFile)) {
try {
await updateTraceProfileGroup(fileUuid, traceIds, { name: targetGroup.name })
} catch (e) {
console.error('Move traces failed:', fileUuid, traceIds, e)
}
}
// Refresh cluster results
if (selectedFileUuid.value) {
await loadClusterResults()
}
// Remove moved traces from unassigned cache
const movedTraceIds = traceList.map(t => t.trace_id)
unassignedTracesCache.value = unassignedTracesCache.value.filter((t: any) => !movedTraceIds.includes(t.trace_id))
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) })
}
if (!traceList.length) return
// Group by file_uuid for batch update
const byFile: Record<string, number[]> = {}
for (const t of traceList) {
if (!byFile[t.file_uuid]) byFile[t.file_uuid] = []
byFile[t.file_uuid].push(t.trace_id)
}
operationLoading.value = true
operationMessage.value = `Creating group "${name}" with ${traceList.length} trace(s)...`
for (const [fileUuid, traceIds] of Object.entries(byFile)) {
try {
await updateTraceProfileGroup(fileUuid, traceIds, { name })
} catch (e) {
console.error('Create group failed:', fileUuid, traceIds, e)
}
}
if (selectedFileUuid.value) {
await loadClusterResults()
}
// Remove moved traces from unassigned cache
const movedTraceIds = traceList.map(t => t.trace_id)
unassignedTracesCache.value = unassignedTracesCache.value.filter((t: any) => !movedTraceIds.includes(t.trace_id))
operationLoading.value = false
operationMessage.value = ''
clearSelection()
}
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()
}
function goToLibrary() {
router.push('/library')
}
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) {
qcFileProfileInfo.value = { ...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
? (() => {
const fp = qcFileProfileInfo.value
if (!fp) return `Read/Write OK, Key Frame: ${fileProfileResult.keyFrame ?? 'N/A'}`
const parts = []
if (fp.duration != null) parts.push(`duration=${fp.duration}s`)
if (fp.width && fp.height) parts.push(`${fp.width}×${fp.height}`)
if (fp.fps != null) parts.push(`fps=${Number(fp.fps).toFixed(2)}`)
if (fp.total_frames != null) parts.push(`frames=${fp.total_frames}`)
if (fp.status) parts.push(`status=${fp.status}`)
return `Read/Write OK, Key Frame: ${fileProfileResult.keyFrame ?? 'N/A'} | ${parts.join(' | ')}`
})()
: `Read: ${fileProfileResult.canRead ? '✓' : '✗'}, Write: ${fileProfileResult.canWrite ? '✓' : '✗'}`
})
// ===== 2. COLLECT TRACE IDS FROM CLUSTERS =====
qcProgress.value.status = 'Collecting trace IDs from clusters...'
const allTraceIds = new Set<number>()
for (const c of faceGroups.value) {
for (const tid of c.trace_ids || []) {
allTraceIds.add(tid)
}
}
const totalTraces = allTraceIds.size
qcProgress.value.total = totalTraces
qcReport.value.push({
check: 'Traces from Clusters',
status: totalTraces > 0 ? 'PASS' : 'WARN',
details: `${totalTraces} FS in ${faceGroups.value.length} groups`
})
// ===== 3. TEST TRACE PROFILES =====
qcProgress.value.status = `Testing ${totalTraces} trace profiles...`
let tested = 0
let withKeyFrame = 0
let withKeyFace = 0
const profileIssues: { traceId: number; fileUuid: string; groupName: string; canRead: boolean; canWrite: boolean; hasKeyFrame: boolean; hasKeyFace: boolean; error?: string }[] = []
// Key-face bbox / confidence / FS profile accumulators
const shortAcc = [] as number[]
const areaAcc = [] as number[]
const confAcc = [] as number[]
const fcAcc = [] as number[]
const avgConfAcc = [] as number[]
const bestConfAcc = [] as number[]
const durAcc = [] as number[]
const poseAcc = [] as number[]
const spanAcc = [] as number[]
const filterCand = [] as { conf: number; short: number; quality: number | null }[]
const sumMinMax = (arr: number[], rnd: (n: number) => number = (n) => Math.round(n * 1000) / 1000) => {
if (!arr.length) return { min: 0, avg: 0, max: 0 }
const sum = arr.reduce((a, b) => a + b, 0)
return { min: rnd(Math.min(...arr)), avg: rnd(sum / arr.length), max: rnd(Math.max(...arr)) }
}
for (const traceId of allTraceIds) {
tested++
qcProgress.value.current = tested
const trace = clusterTracesMap.value[String(traceId)]
const groupName = traceGroupMap.value[String(traceId)] || 'Unknown'
qcProgress.value.status = `Testing FS#${traceId} (${groupName})`
const result: { traceId: number; fileUuid: string; groupName: string; canRead: boolean; canWrite: boolean; hasKeyFrame: boolean; hasKeyFace: boolean; error?: string } = {
traceId,
fileUuid: trace?.file_uuid || '',
groupName,
canRead: false,
canWrite: false,
hasKeyFrame: false,
hasKeyFace: false
}
let profile: any = null
if (trace?.file_uuid) {
try {
profile = await apiCall('get_trace_profile', {
fileUuid: trace.file_uuid,
traceId
})
result.canRead = !!profile
if (profile) {
result.hasKeyFrame = profile.key_frame != null && profile.key_frame !== ''
result.hasKeyFace = profile.key_face != null && profile.key_face !== ''
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'
}
// Aggregate key-face bbox + confidence + FS profile stats
const t: any = trace || {}
const bbox: any = t.best_face_bbox || (result.canRead ? (profile?.bbox ?? profile?.properties?.avg_bbox ?? null) : null) || {}
const w = Number(bbox.width ?? 0)
const h = Number(bbox.height ?? 0)
if (w > 0 && h > 0) {
shortAcc.push(Math.min(w, h))
areaAcc.push(w * h)
}
const conf = Number(t.best_face_confidence ?? profile?.properties?.best_face_confidence ?? 0)
if (conf > 0) confAcc.push(conf)
const shortSide = Math.min(w, h)
if (conf > 0 && shortSide > 0) filterCand.push({ conf, short: shortSide, quality: faceQuality(t) })
const fc = Number(t.face_count ?? 0)
if (fc > 0) fcAcc.push(fc)
const avgConf = Number(t.avg_confidence ?? 0)
if (avgConf > 0) avgConfAcc.push(avgConf)
const bestConf = Number(t.best_face_confidence ?? 0)
if (bestConf > 0) bestConfAcc.push(bestConf)
const dur = Number(t.duration_sec ?? 0)
if (dur > 0) durAcc.push(dur)
const pose = Number(t.pose_count ?? profile?.properties?.pose_count ?? 0)
if (pose > 0) poseAcc.push(pose)
const span = Number(t.end_frame ?? profile?.properties?.end_frame ?? 0) - Number(t.start_frame ?? profile?.properties?.start_frame ?? 0)
if (span >= 0) spanAcc.push(span)
if (!result.canRead || !result.canWrite || (!result.hasKeyFrame && !result.hasKeyFace)) {
profileIssues.push(result)
qcProfileIssues.value = [...profileIssues]
}
}
// Face Filter hidden-state statistics — computed from the 22 traces' own data
// (best_face_confidence + best_face_bbox short side vs effective thresholds),
// NOT from ffFaces/ffStatus which may be empty during a QC run.
const rawMinConf = ffMinConfPct.value
const rawMinSize = ffMinSizePx.value
const rawMinQuality = ffMinQuality.value
const minConfEffective = filterCand.length && rawMinConf > 0
? (filterCand.some(c => c.conf * 100 < rawMinConf) ? rawMinConf : 0)
: 0
const minSizeEffective = filterCand.length && rawMinSize > 0
? (filterCand.some(c => c.short < rawMinSize) ? rawMinSize : 0)
: 0
const minQualityEffective = filterCand.length && rawMinQuality > 0
? (filterCand.some(c => c.quality != null && c.quality < rawMinQuality) ? rawMinQuality : 0)
: 0
const confThreshold = minConfEffective / 100
let visibleCount = 0, hiddenConfOnly = 0, hiddenSizeOnly = 0, hiddenBoth = 0, hiddenQualOnly = 0
for (const c of filterCand) {
const passConf = c.conf >= confThreshold
const passSize = c.short >= minSizeEffective
const passQual = c.quality == null || c.quality >= minQualityEffective
if (passConf && passSize && passQual) visibleCount++
else if (passConf && passSize) hiddenQualOnly++
else if (passConf && passQual) hiddenSizeOnly++
else if (passSize && passQual) hiddenConfOnly++
else hiddenBoth++
}
qcTraceSummary.value = {
total: tested,
withKeyFrame,
withKeyFace,
missingBoth: tested - withKeyFrame - withKeyFace + profileIssues.filter(p => p.hasKeyFrame && p.hasKeyFace).length,
bboxShort: sumMinMax(shortAcc, (n) => Math.round(n)),
bboxArea: sumMinMax(areaAcc, (n) => Math.round(n)),
conf: sumMinMax(confAcc, (n) => Math.round(n * 1000) / 1000),
filter: {
active: minConfEffective > 0 || minSizeEffective > 0 || minQualityEffective > 0,
minConfPct: minConfEffective,
minSizePx: minSizeEffective,
minQualityPct: minQualityEffective,
visible: visibleCount,
hidden: filterCand.length - visibleCount,
hiddenConfOnly,
hiddenSizeOnly,
hiddenBoth,
hiddenQualOnly,
hiddenNeither: visibleCount,
},
fs: {
faceCount: sumMinMax(fcAcc, (n) => Math.round(n)),
avgConf: sumMinMax(avgConfAcc, (n) => Math.round(n * 1000) / 1000),
bestConf: sumMinMax(bestConfAcc, (n) => Math.round(n * 1000) / 1000),
durationSec: sumMinMax(durAcc, (n) => Math.round(n * 100) / 100),
poseCount: sumMinMax(poseAcc, (n) => Math.round(n)),
frameSpan: sumMinMax(spanAcc, (n) => Math.round(n)),
},
}
// FS profile summary
const readFailures = profileIssues.filter(r => !r.canRead).length
const writeFailures = profileIssues.filter(r => !r.canWrite).length
const validTraceCount = tested
const missingKeyFrame = validTraceCount - withKeyFrame
const missingKeyFace = validTraceCount - withKeyFace
qcReport.value.push({
check: 'FS Profile Read',
status: readFailures === 0 ? 'PASS' : 'FAIL',
details: readFailures === 0
? `All ${validTraceCount} readable`
: `${readFailures}/${validTraceCount} NOT readable`
})
qcReport.value.push({
check: 'FS Profile Write',
status: writeFailures === 0 ? 'PASS' : 'FAIL',
details: writeFailures === 0
? `All ${validTraceCount} writable`
: `${writeFailures}/${validTraceCount} NOT writable`
})
qcReport.value.push({
check: 'Trace Key Frame',
status: missingKeyFrame === 0 ? 'PASS' : 'WARN',
details: missingKeyFrame === 0
? `All ${validTraceCount} have key_frame`
: `${withKeyFrame}/${validTraceCount} have key_frame`
})
qcReport.value.push({
check: 'Trace Key Face',
status: missingKeyFace === 0 ? 'PASS' : 'WARN',
details: missingKeyFace === 0
? `All ${validTraceCount} have key_face`
: `${withKeyFace}/${validTraceCount} have key_face`
})
qcProgress.value.status = 'QC Complete'
}
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(`Key Face BBox short: ${qcTraceSummary.value.bboxShort.avg}px (${qcTraceSummary.value.bboxShort.min}–${qcTraceSummary.value.bboxShort.max})`)
lines.push(`Key Face Area: ${qcTraceSummary.value.bboxArea.avg}px² (${qcTraceSummary.value.bboxArea.min}–${qcTraceSummary.value.bboxArea.max})`)
lines.push(`Confidence: ${(qcTraceSummary.value.conf.avg * 100).toFixed(1)}% (${(qcTraceSummary.value.conf.min * 100).toFixed(0)}%–${(qcTraceSummary.value.conf.max * 100).toFixed(0)}%)`)
lines.push('')
lines.push(`Filter: active=${qcTraceSummary.value.filter.active ? 'yes' : 'no'} minConf=${qcTraceSummary.value.filter.minConfPct}% minSize=${qcTraceSummary.value.filter.minSizePx}px minQuality=${qcTraceSummary.value.filter.minQualityPct}% hidden=${qcTraceSummary.value.filter.hidden}/${qcTraceSummary.value.total}`)
lines.push(` confOnly=${qcTraceSummary.value.filter.hiddenConfOnly} sizeOnly=${qcTraceSummary.value.filter.hiddenSizeOnly} qualOnly=${qcTraceSummary.value.filter.hiddenQualOnly} both=${qcTraceSummary.value.filter.hiddenBoth} neither=${qcTraceSummary.value.filter.hiddenNeither}`)
lines.push('')
if (qcFileProfileInfo.value) {
const fp = qcFileProfileInfo.value
lines.push(`File Profile: ${fp.file_name} | duration=${fp.duration}s | ${fp.width}×${fp.height} | fps=${Number(fp.fps).toFixed(2)} | frames=${fp.total_frames} | status=${fp.status}`)
lines.push('')
}
const fs = qcTraceSummary.value.fs
lines.push('FS Profile:')
lines.push(` face_count: ${fs.faceCount.avg} (${fs.faceCount.min}–${fs.faceCount.max})`)
lines.push(` avg_confidence: ${fs.avgConf.avg} (${fs.avgConf.min}–${fs.avgConf.max})`)
lines.push(` best_confidence: ${fs.bestConf.avg} (${fs.bestConf.min}–${fs.bestConf.max})`)
lines.push(` duration_sec: ${fs.durationSec.avg} (${fs.durationSec.min}–${fs.durationSec.max})`)
lines.push(` pose_count: ${fs.poseCount.avg} (${fs.poseCount.min}–${fs.poseCount.max})`)
lines.push(` frame_span: ${fs.frameSpan.avg} (${fs.frameSpan.min}–${fs.frameSpan.max})`)
lines.push('')
const passed = qcReport.value.filter(r => r.status === 'PASS').length
const failed = qcReport.value.filter(r => r.status === 'FAIL').length
const warned = qcReport.value.filter(r => r.status === 'WARN').length
lines.push(`Results: ${passed}✓ ${warned}⚠ ${failed}✗`)
lines.push('')
if (qcProfileIssues.value.length > 0) {
lines.push(`Issues: ${qcProfileIssues.value.length} traces`)
for (const p of qcProfileIssues.value.slice(0, 5)) {
lines.push(` #${p.traceId}: Read ${p.canRead ? '✓' : '✗'} Write ${p.canWrite ? '✓' : '✗'} KeyFrame ${p.hasKeyFrame ? '✓' : '✗'} KeyFace ${p.hasKeyFace ? '✓' : '✗'}`)
}
if (qcProfileIssues.value.length > 5) lines.push(` ... +${qcProfileIssues.value.length - 5}`)
}
navigator.clipboard.writeText(lines.join('\n')).then(() => alert('Copied!')).catch(() => alert('Failed'))
}
</script>
<style scoped>
.people-view { max-width: 1200px; display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.ms-ppl-control-area { position: sticky; top: 0; z-index: 10; background: var(--card-background); padding-bottom: 8px; flex-shrink: 0; }
.ms-ppl-scroll-area { overflow-y: auto; flex: 1; min-height: 0; }
h1 { margin: 0; }
.loading-state, .empty { text-align: center; padding: 60px 0; color: var(--text-secondary); }
.spinner-lg { width: 24px; height: 24px; border: 3px solid var(--border-light); border-top-color: var(--text-primary); border-radius: 50%; animation: spin 0.7s linear infinite; margin: 0 auto 12px; }
@keyframes spin { to { transform: rotate(360deg); } }
.ms-ppl-toolbar { display: flex; align-items: center; gap: 4px; margin-bottom: 6px; }
.ms-ppl-toolbar-bottom { display: flex; align-items: center; gap: 4px; margin-bottom: 6px; flex-wrap: wrap; }
.ms-ppl-file-hint { font-size: 12px; color: var(--muted-text); margin-bottom: 12px; }
.ms-ppl-cluster-btn { display: flex; align-items: center; gap: 6px; padding: 6px 14px; border: 1.5px solid var(--border-color); border-radius: 10px; background: var(--card-background); cursor: pointer; font-size: 13px; font-family: inherit; color: var(--text-primary); transition: border-color .15s; margin-bottom: 12px; }
.ms-ppl-cluster-btn:hover:not(:disabled) { border-color: var(--text-primary); }
.ms-ppl-cluster-btn:disabled { opacity: 0.5; cursor: default; }
.ms-ppl-cluster-btn-inline { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; margin-bottom: 0; }
.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-close-btn { margin-left: auto; width: 28px; height: 28px; border-radius: 50%; border: none; background: transparent; color: var(--text-secondary); cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 18px; transition: background .15s, color .15s; }
.ms-ppl-close-btn:hover { background: var(--hover-background); color: var(--text-primary); }
.ms-ppl-toggle-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--border-color); transition: background .15s; }
.ms-ppl-toggle-dot.on { background: var(--primary-color); }
.ms-ppl-section { margin-bottom: 8px; }
.ms-ppl-section + .ms-ppl-section { border-top: 1px solid var(--border-color); padding-top: 16px; margin-top: 8px; }
.ms-ppl-section-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; position: sticky; top: 0; z-index: 10; background: var(--card-background); padding: 4px 0; }
.ms-ppl-section-title { font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 14px; font-weight: 600; color: var(--text-primary); margin: 0; display: flex; align-items: center; gap: 6px; }
.ms-ppl-section-count { font-weight: 400; color: var(--muted-text); font-size: 13px; }
.ms-ppl-hr { border: none; border-top: 1.5px solid var(--border-light); margin: 24px 0; }
.ms-ppl-face-grid { display: flex; flex-wrap: wrap; gap: 16px; position: relative; }
.ms-select-rect { position: fixed; z-index: 9999; border: 1.5px solid var(--primary-color); background: rgba(var(--primary-color-rgb), 0.15); pointer-events: none; border-radius: 4px; }
.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; bottom: 4px; right: 4px; width: 20px; height: 20px; border-radius: 4px; background: var(--card-background); border: 1.5px solid var(--border-color); display: flex; align-items: center; justify-content: center; font-size: 12px; color: var(--primary-color); z-index: 3; cursor: pointer; }
.ms-ppl-card-checkbox:hover { border-color: var(--primary-color); }
.ms-ppl-strip-face .ms-ppl-card-checkbox { top: 4px; bottom: auto; right: 4px; }
.ms-fm-btn { padding: 6px 14px; border: 1px solid var(--border-color); border-radius: 8px; background: var(--card-background); cursor: pointer; font-size: 13px; font-family: inherit; color: var(--text-primary); transition: border-color .15s, background .15s; }
.ms-fm-btn:hover:not(:disabled) { border-color: var(--text-primary); background: var(--hover-background); }
.ms-fm-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.ms-fm-btn-sm { padding: 4px 10px; font-size: 12px; }
.ms-fm-btn-blue { background: rgba(var(--primary-color-rgb), 0.1); color: var(--primary-color); border-color: var(--primary-color); }
.ms-fm-btn-blue:hover { background: var(--primary-color); color: #fff; }
.ms-fm-btn-danger { background: var(--danger-background); color: var(--danger-color); border-color: var(--danger-color); }
.ms-fm-btn-danger:hover { background: var(--danger-color); color: #fff; }
.ms-merge-face-count { font-size: 11px; color: var(--text-secondary); }
.ms-modal-merge { max-width: 480px; width: 92%; padding: 24px 28px; background: var(--card-background); border-radius: 16px; box-shadow: var(--shadow); position: relative; }
.ms-merge-hint { font-size: 13px; color: var(--text-secondary); margin-bottom: 16px; }
.ms-merge-grid { display: flex; flex-wrap: wrap; gap: 12px; }
.ms-merge-face-card { display: flex; flex-direction: column; align-items: center; gap: 6px; cursor: pointer; width: 100px; padding: 12px; border-radius: 12px; background: var(--hover-background); transition: background .15s, transform .15s; }
.ms-merge-face-card:hover { background: rgba(var(--primary-color-rgb), 0.1); transform: translateY(-2px); }
.ms-merge-face-img { width: 64px; height: 64px; border-radius: 12px; overflow: hidden; background: var(--border-light); }
.ms-merge-face-img img { width: 100%; height: 100%; object-fit: cover; }
.ms-merge-face-name { font-size: 12px; color: var(--text-primary); text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 88px; font-weight: 500; }
.ms-merge-divider { border: none; border-top: 1px solid var(--border-color); margin: 16px 0; }
.ms-modal-delete-faces { max-width: 480px; width: 92%; padding: 24px 28px; background: var(--card-background); border-radius: 16px; box-shadow: var(--shadow); position: relative; }
.ms-delete-faces-warning { font-size: 13px; line-height: 1.6; color: var(--text-secondary); margin-bottom: 18px; }
.ms-delete-faces-warning strong { color: var(--danger-color); }
.ms-delete-faces-confirm { margin-bottom: 18px; }
.ms-delete-faces-label { display: block; font-size: 13px; color: var(--text-secondary); margin-bottom: 8px; }
.ms-delete-faces-label code { background: var(--border-light); padding: 1px 6px; border-radius: 4px; font-size: 12px; color: var(--text-primary); }
.ms-delete-faces-confirm .ms-ppl-edit-input { width: 100%; }
.ms-delete-faces-actions { display: flex; justify-content: flex-end; gap: 10px; }
.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-face-img-loading { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
.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; }
.fs-delete-btn { position: absolute; top: 16px; right: 56px; color: var(--text-secondary); }
.fs-delete-btn:hover { color: var(--danger-color); }
/* Face detail side navigation arrows */
.ms-fs-nav-arrow {
position: fixed; top: 50%; transform: translateY(-50%); z-index: 10010;
width: 44px; height: 44px; border-radius: 50%; border: none;
background: rgba(0,0,0,0.5); color: #fff; cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: background .2s, transform .15s;
}
.ms-fs-nav-arrow:hover { background: rgba(0,0,0,0.8); transform: translateY(-50%) scale(1.1); }
.ms-fs-nav-prev { left: 24px; }
.ms-fs-nav-next { right: 24px; }
.ms-ppl-strip-trace-label { display: flex; flex-direction: column; align-items: center; gap: 2px; max-width: 100%; overflow: hidden; }
.ms-ppl-strip-group-name { font-size: 9px; color: var(--muted-text); max-width: 56px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ms-ppl-face-strip { overflow-x: auto; min-width: 0; }
.ms-ppl-strip-face { flex-shrink: 0; width: 80px; }
/* Assign modal */
.ms-modal-assign { max-width: 560px; width: 92%; padding: 28px 32px; text-align: left; max-height: 85vh; overflow-y: auto; margin-top: 48px; align-self: flex-start; background: var(--card-background); border-radius: 16px; box-shadow: var(--shadow); position: relative; }
.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: 46px 28px 24px;
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; margin-left: 0; }
.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: 13px; font-weight: 600; color: var(--text-primary); margin: 0 0 8px; padding-top: 5px; display: flex; align-items: center; gap: 8px; }
.ms-face-detail-title input { flex: 1; }
.ms-face-trace-id { font-size: 12px; 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: 0; margin-bottom: 4px; }
.ms-face-detail-label { font-size: 12px; color: var(--text-secondary); min-width: 0; margin-right: 4px; }
.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; align-items: center; justify-content: flex-end; gap: 12px; margin-top: 20px; }
.ms-fs-nav-bar { display: flex; align-items: center; gap: 8px; flex: 1; }
.ms-fs-nav-info { display: flex; flex-direction: column; align-items: center; min-width: 60px; }
.ms-fs-nav-counter { font-size: 13px; font-weight: 600; color: var(--text-primary); font-variant-numeric: tabular-nums; }
.ms-fs-nav-label { font-size: 10px; color: var(--text-secondary); max-width: 100px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.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-full-face-modal { display: flex; align-items: center; justify-content: center; background: #000; position: fixed; inset: 0; z-index: 11000; }
.ms-full-face-modal .ms-modal-video-close { position: fixed; top: 16px; right: 20px; background: rgba(0,0,0,0.5); border: none; font-size: 28px; cursor: pointer; color: #fff; line-height: 1; width: 40px; height: 40px; border-radius: 50%; display: flex; align-items: center; justify-content: center; z-index: 11001; }
.ms-full-frame-controls { position: fixed; top: 16px; left: 50%; transform: translateX(-50%); display: flex; gap: 12px; z-index: 11001; background: rgba(0,0,0,0.6); padding: 8px 16px; border-radius: 8px; transition: opacity 0.3s; }
.ms-full-frame-container { position: relative; display: flex; align-items: center; justify-content: center; max-width: 95vw; max-height: 95vh; }
.ms-full-frame-img { max-width: 95vw; max-height: 95vh; object-fit: contain; }
.ms-full-frame-canvas { position: absolute; pointer-events: none; }
.fade-controls-enter-active, .fade-controls-leave-active { transition: opacity 0.3s; }
.fade-controls-enter-from, .fade-controls-leave-to { opacity: 0; }
.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-group-search-input, .ms-faces-search-input { width: auto; flex: 0 1 180px; min-width: 120px; padding: 5px 10px; font-size: 12px; border-radius: 999px; margin-left: 2px; }
.ms-search-hit { background: rgba(255, 193, 7, 0.4); border-radius: 3px; padding: 0 1px; }
.ms-ppl-empty-groups { width: 100%; grid-column: 1 / -1; padding: 24px; text-align: center; color: var(--text-secondary); font-size: 13px; }
.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-lm-verdict { font-weight: 700; padding: 1px 6px; border-radius: 4px; margin-right: 8px; }
.ms-lm-verdict--pass { color: #1a7f37; background: rgba(26, 127, 55, 0.15); }
.ms-lm-verdict--review { color: #b26b00; background: rgba(178, 107, 0, 0.15); }
.ms-lm-verdict--fail { color: #cf222e; background: rgba(207, 34, 46, 0.15); }
.ms-lm-score { font-weight: 600; margin-right: 8px; }
.ms-lm-reasons { color: var(--text-secondary); }
.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-trace-stat-sub { font-size: 11px; color: var(--muted-text); margin-top: 2px; }
.ms-qc-filter-summary { display: grid; grid-template-columns: repeat(2, 1fr); gap: 6px 16px; margin-bottom: 16px; padding: 12px; background: var(--hover-background); border-radius: 8px; }
.ms-qc-filter-row { display: flex; justify-content: space-between; align-items: center; }
.ms-qc-filter-label { font-size: 11px; color: var(--text-secondary); }
.ms-qc-filter-value { font-size: 13px; font-weight: 600; color: var(--text-primary); }
.ms-qc-filter-value.ms-qc-pass { color: var(--success-color); }
.ms-qc-filter-sub { grid-column: 1 / -1; font-size: 11px; color: var(--muted-text); margin-top: 4px; }
.ms-qc-fileprofile, .ms-qc-fsprofile { margin-bottom: 16px; padding: 12px; background: var(--hover-background); border-radius: 8px; }
.ms-qc-fileprofile-title { font-size: 13px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px; }
.ms-qc-fileprofile-row, .ms-qc-fs-row { display: flex; justify-content: space-between; align-items: center; padding: 3px 0; border-bottom: 1px solid var(--border-light); }
.ms-qc-fileprofile-row:last-child, .ms-qc-fs-row:last-child { border-bottom: none; }
.ms-qc-fileprofile-val, .ms-qc-fs-val { font-size: 12px; color: var(--text-primary); word-break: break-all; }
.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; }
/* Face Filter */
.ms-face-filter-section {
position: sticky;
top: 4px;
z-index: 30;
background:
linear-gradient(165deg, color-mix(in srgb, var(--background-color) 72%, rgb(var(--primary-color-rgb)) 10%),
color-mix(in srgb, var(--background-color) 45%, rgb(var(--primary-color-rgb)) 6%));
border: 1px solid color-mix(in srgb, var(--border-color) 80%, rgb(var(--primary-color-rgb)) 25%);
border-radius: 14px;
padding: 8px 14px;
margin-bottom: 10px;
backdrop-filter: blur(18px) saturate(1.6);
-webkit-backdrop-filter: blur(18px) saturate(1.6);
box-shadow: 0 6px 24px -6px rgba(0,0,0,0.18);
}
.ms-face-filter-section::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
background: linear-gradient(180deg, color-mix(in srgb, #fff 14%, transparent), transparent 42%);
}
.ms-face-filter-section .ms-ppl-section-toolbar { flex-wrap: wrap; row-gap: 4px; margin-bottom: 8px; padding: 0; background: transparent; border: none; position: relative; z-index: 1; }
.ms-ff-toolbar-actions { margin-left: auto; display: inline-flex; gap: 6px; }
.ms-face-filter-section > * { position: relative; z-index: 1; }
.ms-ff-count { font-weight: 400; color: var(--text-secondary); font-size: 12px; }
.ms-ff-count.cut { color: var(--danger-color); }
.ms-ff-group-partial { font-size: 10px; color: var(--danger-color); opacity: 0.7; margin-left: 4px; }
.ms-ff-hist-row { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 8px; }
.ms-ff-hist-group { flex: 1 1 260px; min-width: 260px; }
.ms-ff-hist-title { font-size: 11px; font-weight: 600; color: var(--text-secondary); margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.03em; }
.ms-ff-hist-wrap { border: 1px solid var(--border-color); border-radius: 8px; padding: 8px 10px 5px; cursor: pointer; background: color-mix(in srgb, var(--card-background) 85%, transparent); }
.ms-ff-hist { position: relative; display: flex; align-items: flex-end; gap: 3px; height: 52px; padding: 21px 2px 0; }
.ms-ff-hist-col { flex: 1; display: flex; flex-direction: column; justify-content: flex-end; align-items: center; min-width: 0; }
.ms-ff-hist-bar { width: 100%; border-radius: 3px 3px 0 0; background: var(--primary-color); opacity: 0.85; position: relative; min-height: 3px; }
.ms-ff-hist-bar.cut { background: var(--danger-color); opacity: 0.35; }
.ms-ff-hist-num { position: absolute; bottom: 4px; left: 50%; transform: translateX(-50%); font-size: 9px; color: rgba(255,255,255,0.85); white-space: nowrap; text-shadow: 0 1px 2px rgba(0,0,0,0.3); }
.ms-ff-hist-bar.cut .ms-ff-hist-num { color: var(--danger-color); text-shadow: none; }
.ms-ff-rug { position: absolute; bottom: 0; left: 0; right: 0; height: 6px; pointer-events: none; }
.ms-ff-rug-tick { position: absolute; bottom: 0; width: 1.5px; height: 5px; border-radius: 1px; background: var(--text-primary); opacity: 0.45; }
.ms-ff-rug-tick.cut { background: var(--danger-color); opacity: 0.35; }
.ms-ff-hist-axis { display: flex; justify-content: space-between; padding-top: 3px; margin-top: 2px; border-top: 1px solid var(--border-color); }
.ms-ff-hist-tick { font-size: 10px; color: var(--muted-text); line-height: 1; }
.ms-ff-hist-cursor { position: absolute; top: 0; bottom: 6px; width: 2px; background: #000; border: none; border-radius: 0; z-index: 2; cursor: ew-resize; transform: translateX(-50%); }
.ms-ff-hist-cursor::after { content: ''; position: absolute; bottom: 2px; left: 50%; transform: translateX(-50%); width: 10px; height: 10px; border-radius: 50%; background: #e11d48; border: 1px solid #fff; box-shadow: 0 0 3px rgba(0,0,0,0.4); }
.ms-ff-cursor-label { position: absolute; top: 6px; left: 50%; transform: translateX(-50%); font-size: 10px; font-weight: 600; color: #fff; background: var(--primary-color); border-radius: 3px; padding: 1px 5px; white-space: nowrap; line-height: 1; }
/* Face Filter stats bar (horizontal) */
.ms-ff-stats { display: block; }
.ms-ff-stats-bar { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-secondary); padding: 4px 0; }
.ms-ff-stats-bar-label { font-weight: 600; color: var(--text-primary); }
.ms-ff-stats-bar-val { font-size: 12px; color: var(--text-secondary); }
.ms-ff-stats-bar-val b { color: var(--primary-color); font-weight: 600; }
.ms-ff-stats-bar-sep { margin: 0 3px; opacity: 0.5; }
.ms-ff-stats-empty { font-size: 12px; color: var(--text-secondary); padding: 4px 0; }
/* Single Frame FS Settings */
.ms-ff-single-frame-section { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border-light); }
.ms-ff-section-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 10px; }
.ms-ff-section-title { font-size: 13px; font-weight: 600; color: var(--text-primary); }
.ms-ff-checkbox-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px 16px; }
.ms-ff-checkbox-item { display: flex; flex-direction: column; gap: 2px; }
.ms-ff-checkbox-label { display: flex; align-items: center; gap: 6px; font-size: 13px; cursor: pointer; color: var(--text-primary); }
.ms-ff-checkbox-label input { width: 14px; height: 14px; cursor: pointer; flex-shrink: 0; }
.ms-ff-hint { font-size: 11px; color: var(--text-secondary); margin-left: 20px; }
.ms-ff-section-actions { display: flex; gap: 8px; }
/* Trace Profile JSON Viewer Modal */
.ms-modal-box { background: var(--card-background); border-radius: 12px; box-shadow: var(--shadow); max-height: 80vh; display: flex; flex-direction: column; }
.ms-modal-wide { width: 90vw; max-width: 900px; }
.ms-modal-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid var(--border-light); }
.ms-modal-title { font-size: 15px; font-weight: 600; color: var(--text-primary); }
.ms-modal-close { background: none; border: none; font-size: 18px; cursor: pointer; color: var(--text-secondary); padding: 4px 8px; border-radius: 4px; }
.ms-modal-close:hover { background: var(--hover-background); }
.ms-modal-body { padding: 20px; overflow: auto; flex: 1; }
.ms-json-view { font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; font-size: 12px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; color: var(--text-primary); margin: 0; }
</style>