功能: - LibraryView 添加批量 unregister 按钮(勾选多个已注册文件后显示) - 添加确认对话框,支持批量取消注册 - 添加中英日三语翻译 性能优化: - search_agents API timeout 提升到 90s(避免前端超时) - thumbnail 并发数从 32 降到 10(减少服务器压力) 修复: - PeopleView 添加 group_uuid 字段映射 - LibraryView 排序优化(scanning/preparing 优先)
4463 lines
212 KiB
Vue
4463 lines
212 KiB
Vue
<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">×</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">×</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">×</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>
|