feat: pending identity sorting, face detail modal, video player fps, processor counts
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
<div class="ms-fm-toolbar">
|
||||
<div class="ms-fm-toolbar-left">
|
||||
<button class="ms-fm-icon-btn" type="button" title="Refresh" @click="loadFiles">⟳</button>
|
||||
<button class="ms-fm-icon-btn" type="button" title="Refresh Status" @click="refreshAllStatus">⟡</button>
|
||||
<label class="ms-fm-radio-label">
|
||||
<input type="radio" name="ms-display-filter" value="all" v-model="displayFilter"> All
|
||||
</label>
|
||||
@@ -39,6 +40,8 @@
|
||||
<label class="ms-fm-radio-label"><input type="checkbox" v-model="filterRegistered"> 已註冊</label>
|
||||
<label class="ms-fm-radio-label"><input type="checkbox" v-model="filterPending"> 待處理</label>
|
||||
<label class="ms-fm-radio-label"><input type="checkbox" v-model="filterCompleted"> 已完成</label>
|
||||
<label class="ms-fm-radio-label"><input type="checkbox" v-model="filterNotIngested"> 未入庫</label>
|
||||
<label class="ms-fm-radio-label"><input type="checkbox" v-model="filterIngested"> 已入庫</label>
|
||||
<label class="ms-fm-radio-label"><input type="checkbox" v-model="onlyVideos"> 僅顯示影片</label>
|
||||
<label class="ms-fm-radio-label"><input type="checkbox" v-model="onlyPhotos"> 僅顯示照片</label>
|
||||
</div>
|
||||
@@ -62,6 +65,13 @@
|
||||
</div>
|
||||
|
||||
<div class="ms-fm-status">{{ statusText }}<span v-if="actionMsg" class="ms-fm-action-msg">{{ actionMsg }}</span></div>
|
||||
<div class="ms-fm-axis-bar">
|
||||
<span class="ms-fm-axis-item">📋 已註冊 <strong>{{ registeredCount }}</strong> · 未註冊 <strong>{{ unregisteredCount }}</strong></span>
|
||||
<span class="ms-fm-axis-sep">|</span>
|
||||
<span class="ms-fm-axis-item">⚙️ 待處理 <strong>{{ pendingCount }}</strong> · 處理中 <strong>{{ processingCount }}</strong> · 已處理 <strong>{{ completedCount }}</strong></span>
|
||||
<span class="ms-fm-axis-sep">|</span>
|
||||
<span class="ms-fm-axis-item">🗄️ 已入庫 <strong>{{ ingestedCount }}</strong> · 未入庫 <strong>{{ notIngestedCount }}</strong></span>
|
||||
</div>
|
||||
|
||||
<div class="ms-fm-grid">
|
||||
<div v-for="f in sortedFilteredFiles" :key="f.file_uuid" class="ms-fm-card" :class="{ 'is-completed': f.isRegistered, 'is-selected': selectedFiles.includes(f.file_uuid) }" @click="toggleSelect(f)" @contextmenu.prevent="openContextMenu($event, f)">
|
||||
@@ -74,9 +84,15 @@
|
||||
<span class="ms-fm-doc-ext">{{ getFileExt(f.file_name) }}</span>
|
||||
</div>
|
||||
<button v-if="isVideo(f)" class="ms-fm-play-btn" @click.stop="playVideo(f)">▶</button>
|
||||
<div class="ms-fm-check" v-if="f.isRegistered">✓</div>
|
||||
<div class="ms-fm-status-badge" v-if="f.status === 'completed'" style="background:#1e8e3e;color:#fff;">完成</div>
|
||||
<div class="ms-fm-status-badge" v-else-if="f.status === 'registered'" style="background:#f9ab00;color:#202124;">待處理</div>
|
||||
<div class="ms-fm-badges">
|
||||
<div class="ms-fm-badge-row" :class="f.isRegistered ? 'ms-badge-ok' : 'ms-badge-off'">📋 {{ f.isRegistered ? '已註冊' : '未註冊' }}</div>
|
||||
<div class="ms-fm-badge-row" :class="statusBadgeClass(f)">{{ statusBadgeIcon(f) }} {{ statusBadgeLabel(f) }}</div>
|
||||
<div class="ms-fm-badge-row" :class="ingestedBadgeClass(f)">{{ ingestedBadgeIcon(f) }} {{ ingestedBadgeLabel(f) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="getProgress(f.file_uuid)?.overall_progress < 100" class="ms-fm-progress">
|
||||
<div class="ms-fm-progress-bar" :style="{ width: getProgress(f.file_uuid)?.overall_progress + '%' }"></div>
|
||||
<div class="ms-fm-progress-text">{{ getProgress(f.file_uuid)?.overall_progress || 0 }}%</div>
|
||||
</div>
|
||||
<div class="ms-fm-meta">
|
||||
<div class="ms-fm-name">{{ f.file_name }}</div>
|
||||
@@ -98,18 +114,22 @@
|
||||
<button v-if="!ctxMenu.file?.isRegistered" class="ms-ctx-item" @click="ctxRegister(ctxMenu.file)">📥 註冊此檔案</button>
|
||||
<button v-if="ctxMenu.file?.isRegistered && ctxMenu.file?.status !== 'completed'" class="ms-ctx-item" @click="ctxProcess(ctxMenu.file)">⚙️ 處理此檔案</button>
|
||||
<button v-if="ctxMenu.file?.isRegistered && ctxMenu.file?.status === 'completed'" class="ms-ctx-item" @click="ctxProcess(ctxMenu.file)">⚙️ 重新處理</button>
|
||||
<button v-if="ctxMenu.file?.isRegistered && !ctxMenu.file?.ingested" class="ms-ctx-item" @click="ctxIngest(ctxMenu.file)">📦 觸發入庫</button>
|
||||
<button v-if="ctxMenu.file?.isRegistered && ctxMenu.file?.ingested" class="ms-ctx-item" @click="ctxCheckout(ctxMenu.file)">📤 出庫</button>
|
||||
<button v-if="ctxMenu.file?.isRegistered" class="ms-ctx-item ms-ctx-danger" @click="ctxUnregister(ctxMenu.file)">🗑️ 移除註冊</button>
|
||||
<button v-if="isVideo(ctxMenu.file) && ctxMenu.file?.isRegistered" class="ms-ctx-item" @click="playVideo(ctxMenu.file); closeContextMenu()">▶ 播放影片</button>
|
||||
<template v-if="ctxMenu.file?.isRegistered">
|
||||
<hr class="ms-ctx-divider">
|
||||
<div class="ms-ctx-procs">
|
||||
<div class="ms-ctx-procs-title">處理器選擇</div>
|
||||
<label class="ms-fm-check-label"><input type="checkbox" v-model="procAsr"> ASR</label>
|
||||
<label class="ms-fm-check-label"><input type="checkbox" v-model="procYolo"> YOLO</label>
|
||||
<label class="ms-fm-check-label"><input type="checkbox" v-model="procFace"> Face</label>
|
||||
<label class="ms-fm-check-label"><input type="checkbox" v-model="procOcr"> OCR</label>
|
||||
<label class="ms-fm-check-label"><input type="checkbox" v-model="procPose"> Pose</label>
|
||||
<label class="ms-fm-check-label"><input type="checkbox" v-model="procCut"> CUT</label>
|
||||
<label class="ms-fm-check-label" :class="procStatusClass('asr')"><input type="checkbox" v-model="procAsr"> ASR <span class="ms-proc-count">{{ procCountLabel('asr', 'segment') }}</span></label>
|
||||
<label class="ms-fm-check-label" :class="procStatusClass('asrx')"><input type="checkbox" v-model="procAsrx"> ASRX <span class="ms-proc-count">{{ procCountLabel('asrx', 'segment') }}</span></label>
|
||||
<label class="ms-fm-check-label" :class="procStatusClass('yolo')"><input type="checkbox" v-model="procYolo"> YOLO <span class="ms-proc-count">{{ procCountLabel('yolo', 'frame') }}</span></label>
|
||||
<label class="ms-fm-check-label" :class="procStatusClass('face')"><input type="checkbox" v-model="procFace"> Face <span class="ms-proc-count">{{ procCountLabel('face', 'frame') }}</span></label>
|
||||
<label class="ms-fm-check-label" :class="procStatusClass('ocr')"><input type="checkbox" v-model="procOcr"> OCR <span class="ms-proc-count">{{ procCountLabel('ocr', 'frame') }}</span></label>
|
||||
<label class="ms-fm-check-label" :class="procStatusClass('pose')"><input type="checkbox" v-model="procPose"> Pose <span class="ms-proc-count">{{ procCountLabel('pose', 'frame') }}</span></label>
|
||||
<label class="ms-fm-check-label" :class="procStatusClass('appearance')"><input type="checkbox" v-model="procAppearance"> Appearance <span class="ms-proc-count">{{ procCountLabel('appearance', 'frame') }}</span></label>
|
||||
<label class="ms-fm-check-label" :class="procStatusClass('cut')"><input type="checkbox" v-model="procCut"> CUT <span class="ms-proc-count">{{ procCountLabel('cut', 'frame') }}</span></label>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -121,7 +141,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { apiCall } from '@/api'
|
||||
import { ensureFiles, filesCache, filesLoaded, thumbnailsCache, loadThumbnail, invalidateFiles } from '@/store'
|
||||
import { ensureFiles, filesCache, filesLoaded, thumbnailsCache, loadThumbnail, invalidateFiles, processorCountsCache, loadProcessorCounts, getProcessorCounts, ProcessorOutputInfo, pollProgress, stopPolling, getProgress, getFileStatus, refreshFileStatus, refreshAllFilesStatus, fileProgressCache, fileStatusCache } from '@/store'
|
||||
import VideoPlayer from '../components/VideoPlayer.vue'
|
||||
|
||||
const files = computed(() => filesCache.value)
|
||||
@@ -142,6 +162,8 @@ const filterUnregistered = ref(false)
|
||||
const filterRegistered = ref(true)
|
||||
const filterPending = ref(false)
|
||||
const filterCompleted = ref(false)
|
||||
const filterNotIngested = ref(false)
|
||||
const filterIngested = ref(false)
|
||||
const onlyVideos = ref(false)
|
||||
const onlyPhotos = ref(false)
|
||||
const sizeMin = ref<number | null>(null)
|
||||
@@ -166,8 +188,10 @@ const sortedFilteredFiles = computed(() => {
|
||||
// Checkbox filters
|
||||
if (filterUnregistered.value && !filterRegistered.value) result = result.filter((f: any) => !f.isRegistered)
|
||||
else if (filterRegistered.value && !filterUnregistered.value) result = result.filter((f: any) => f.isRegistered)
|
||||
if (filterPending.value) result = result.filter((f: any) => f.status === 'registered')
|
||||
if (filterPending.value) result = result.filter((f: any) => f.status === 'registered' || f.status === 'pending')
|
||||
if (filterCompleted.value) result = result.filter((f: any) => f.status === 'completed')
|
||||
if (filterNotIngested.value && !filterIngested.value) result = result.filter((f: any) => f.isRegistered && !f.ingested)
|
||||
else if (filterIngested.value && !filterNotIngested.value) result = result.filter((f: any) => f.ingested)
|
||||
|
||||
if (onlyVideos.value) result = result.filter(isVideo)
|
||||
if (onlyPhotos.value) result = result.filter(isPhoto)
|
||||
@@ -203,6 +227,14 @@ const sortedFilteredFiles = computed(() => {
|
||||
return result
|
||||
})
|
||||
|
||||
const registeredCount = computed(() => files.value.filter((f: any) => f.isRegistered).length)
|
||||
const unregisteredCount = computed(() => files.value.filter((f: any) => !f.isRegistered).length)
|
||||
const pendingCount = computed(() => files.value.filter((f: any) => f.status === 'pending' || f.status === 'registered').length)
|
||||
const processingCount = computed(() => files.value.filter((f: any) => f.status === 'processing').length)
|
||||
const completedCount = computed(() => files.value.filter((f: any) => f.status === 'completed').length)
|
||||
const ingestedCount = computed(() => files.value.filter((f: any) => f.ingested).length)
|
||||
const notIngestedCount = computed(() => files.value.filter((f: any) => f.isRegistered && !f.ingested).length)
|
||||
|
||||
onMounted(() => { loadFiles(); document.addEventListener('click', docClickClose) })
|
||||
onUnmounted(() => { document.removeEventListener('click', docClickClose) })
|
||||
|
||||
@@ -215,10 +247,33 @@ async function loadFiles() {
|
||||
statusText.value = 'Loading...'
|
||||
invalidateFiles()
|
||||
await ensureFiles()
|
||||
statusText.value = `${files.value.length} files`
|
||||
statusText.value = `${files.value.length} 個檔案`
|
||||
for (const f of files.value.slice(0, 6)) {
|
||||
if ((isPhoto(f) || isVideo(f)) && f.file_uuid) loadThumbnail(f.file_uuid)
|
||||
}
|
||||
const registeredFiles = files.value.filter((f: any) => f.isRegistered && f.file_uuid && f.status !== 'completed')
|
||||
for (const f of registeredFiles) {
|
||||
try {
|
||||
const progress: any = await apiCall('get_progress', { fileUuid: f.file_uuid })
|
||||
if (progress && progress.overall_progress < 100 && progress.processors?.some((p: any) => p.status === 'running')) {
|
||||
f.status = 'processing'
|
||||
fileProgressCache.value[f.file_uuid] = {
|
||||
file_uuid: f.file_uuid,
|
||||
overall_progress: progress.overall_progress,
|
||||
processors: progress.processors,
|
||||
}
|
||||
pollProgress(f.file_uuid)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('check progress failed:', f.file_uuid, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAllStatus() {
|
||||
statusText.value = 'Refreshing status...'
|
||||
await refreshAllFilesStatus()
|
||||
statusText.value = `${files.value.length} 個檔案`
|
||||
}
|
||||
|
||||
function isVideo(f: any) {
|
||||
@@ -265,13 +320,17 @@ function playVideo(f: any) {
|
||||
|
||||
const registering = ref(false)
|
||||
const processing = ref(false)
|
||||
const ingesting = ref(false)
|
||||
const checkouting = ref(false)
|
||||
const unregistering = ref(false)
|
||||
const actionMsg = ref('')
|
||||
const procAsr = ref(true)
|
||||
const procAsrx = ref(true)
|
||||
const procYolo = ref(true)
|
||||
const procFace = ref(true)
|
||||
const procOcr = ref(false)
|
||||
const procPose = ref(false)
|
||||
const procOcr = ref(true)
|
||||
const procPose = ref(true)
|
||||
const procAppearance = ref(true)
|
||||
const procCut = ref(true)
|
||||
|
||||
const ctxMenu = ref<{ show: boolean; x: number; y: number; file: any }>({ show: false, x: 0, y: 0, file: null })
|
||||
@@ -279,24 +338,90 @@ const ctxMenu = ref<{ show: boolean; x: number; y: number; file: any }>({ show:
|
||||
function selectedProcessors(): string[] {
|
||||
const procs: string[] = []
|
||||
if (procAsr.value) procs.push('asr')
|
||||
if (procAsrx.value) procs.push('asrx')
|
||||
if (procYolo.value) procs.push('yolo')
|
||||
if (procFace.value) procs.push('face')
|
||||
if (procOcr.value) procs.push('ocr')
|
||||
if (procPose.value) procs.push('pose')
|
||||
if (procAppearance.value) procs.push('appearance')
|
||||
if (procCut.value) procs.push('cut')
|
||||
return procs
|
||||
}
|
||||
|
||||
function procStatusClass(proc: string): string {
|
||||
const fileUuid = ctxMenu.value.file?.file_uuid
|
||||
if (!fileUuid) return ''
|
||||
const info = getProcessorCounts(fileUuid)[proc]
|
||||
if (info?.has_json) return 'ms-proc-done'
|
||||
return ''
|
||||
}
|
||||
|
||||
function procCountLabel(proc: string, type: 'frame' | 'segment' | 'chunk'): string {
|
||||
const fileUuid = ctxMenu.value.file?.file_uuid
|
||||
if (!fileUuid) return ''
|
||||
const info = getProcessorCounts(fileUuid)[proc]
|
||||
if (!info?.has_json) return ''
|
||||
if (type === 'frame' && info.frame_count != null) return `(${info.frame_count})`
|
||||
if (type === 'segment' && info.segment_count != null) return `(${info.segment_count})`
|
||||
if (type === 'chunk' && info.chunk_count != null) return `(${info.chunk_count})`
|
||||
return '(✓)'
|
||||
}
|
||||
|
||||
function statusLabel(f: any): string {
|
||||
if (!f) return ''
|
||||
if (f.status === 'completed') return '已完成'
|
||||
if (f.status === 'registered') return '已註冊・待處理'
|
||||
if (f.status === 'processing') return '處理中'
|
||||
if (f.status === 'failed') return '處理失敗'
|
||||
if (f.status === 'pending') return '待處理'
|
||||
if (f.status === 'registered') return '待處理'
|
||||
if (f.isRegistered) return '已註冊'
|
||||
return '未註冊'
|
||||
}
|
||||
|
||||
function openContextMenu(e: MouseEvent, f: any) {
|
||||
function statusBadgeClass(f: any): string {
|
||||
if (f.status === 'processing') return 'ms-badge-proc'
|
||||
if (f.status === 'completed') return 'ms-badge-done'
|
||||
if (f.status === 'failed') return 'ms-badge-fail'
|
||||
if (f.status === 'pending' || f.status === 'registered') return 'ms-badge-warn'
|
||||
return 'ms-badge-off'
|
||||
}
|
||||
|
||||
function statusBadgeIcon(f: any): string {
|
||||
if (f.status === 'processing') return '⏳'
|
||||
if (f.status === 'completed') return '✅'
|
||||
if (f.status === 'failed') return '❌'
|
||||
if (f.status === 'pending' || f.status === 'registered') return '⏸️'
|
||||
return '⏺️'
|
||||
}
|
||||
|
||||
function statusBadgeLabel(f: any): string {
|
||||
if (!f) return ''
|
||||
if (f.status === 'completed') return '已處理'
|
||||
if (f.status === 'processing') return '處理中'
|
||||
if (f.status === 'failed') return '處理失敗'
|
||||
if (f.status === 'pending' || f.status === 'registered') return '待處理'
|
||||
return '—'
|
||||
}
|
||||
|
||||
function ingestedBadgeClass(f: any): string {
|
||||
if (f.ingested) return 'ms-badge-done'
|
||||
if (f.isRegistered) return 'ms-badge-warn'
|
||||
return 'ms-badge-off'
|
||||
}
|
||||
|
||||
function ingestedBadgeIcon(f: any): string {
|
||||
if (f.ingested) return '🗄️'
|
||||
if (f.isRegistered) return '📦'
|
||||
return '📦'
|
||||
}
|
||||
|
||||
function ingestedBadgeLabel(f: any): string {
|
||||
if (f.ingested) return '已入庫'
|
||||
if (f.isRegistered) return '未入庫'
|
||||
return '—'
|
||||
}
|
||||
|
||||
async function openContextMenu(e: MouseEvent, f: any) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
ctxMenu.value = {
|
||||
@@ -305,6 +430,9 @@ function openContextMenu(e: MouseEvent, f: any) {
|
||||
y: Math.min(e.clientY, window.innerHeight - 320),
|
||||
file: f
|
||||
}
|
||||
if (f.isRegistered && f.file_uuid) {
|
||||
await loadProcessorCounts(f.file_uuid, true)
|
||||
}
|
||||
}
|
||||
|
||||
function closeContextMenu() {
|
||||
@@ -354,6 +482,9 @@ async function ctxProcess(f: any) {
|
||||
const result: any = await apiCall('process_file', { fileUuid: f.file_uuid, processors: procs })
|
||||
if (result.success) {
|
||||
actionMsg.value = `處理已觸發:${f.file_name}`
|
||||
f.status = 'processing'
|
||||
fileStatusCache.value[f.file_uuid] = 'processing'
|
||||
pollProgress(f.file_uuid)
|
||||
} else {
|
||||
actionMsg.value = `處理失敗:${result.message}`
|
||||
}
|
||||
@@ -362,8 +493,6 @@ async function ctxProcess(f: any) {
|
||||
console.error('[process]', e)
|
||||
}
|
||||
processing.value = false
|
||||
await loadFiles()
|
||||
setTimeout(() => { actionMsg.value = '' }, 8000)
|
||||
}
|
||||
|
||||
async function ctxUnregister(f: any) {
|
||||
@@ -388,12 +517,58 @@ async function ctxUnregister(f: any) {
|
||||
setTimeout(() => { actionMsg.value = '' }, 8000)
|
||||
}
|
||||
|
||||
async function ctxIngest(f: any) {
|
||||
closeContextMenu()
|
||||
if (ingesting.value) return
|
||||
if (!f.isRegistered) { actionMsg.value = '檔案尚未註冊'; return }
|
||||
ingesting.value = true
|
||||
actionMsg.value = '入庫中...'
|
||||
try {
|
||||
const result: any = await apiCall('ingest_file', { fileUuid: f.file_uuid })
|
||||
if (result.success) {
|
||||
actionMsg.value = `入庫成功:${f.file_name}`
|
||||
} else {
|
||||
actionMsg.value = `入庫失敗:${result.message}`
|
||||
}
|
||||
} catch (e: any) {
|
||||
actionMsg.value = `入庫錯誤:${e}`
|
||||
console.error('[ingest]', e)
|
||||
}
|
||||
ingesting.value = false
|
||||
await loadFiles()
|
||||
setTimeout(() => { actionMsg.value = '' }, 8000)
|
||||
}
|
||||
|
||||
async function ctxCheckout(f: any) {
|
||||
closeContextMenu()
|
||||
if (checkouting.value) return
|
||||
if (!f.isRegistered) { actionMsg.value = '檔案尚未註冊'; return }
|
||||
checkouting.value = true
|
||||
actionMsg.value = '出庫中...'
|
||||
try {
|
||||
const result: any = await apiCall('checkout_file', { fileUuid: f.file_uuid })
|
||||
if (result.success) {
|
||||
actionMsg.value = `出庫成功:${f.file_name}`
|
||||
} else {
|
||||
actionMsg.value = `出庫失敗:${result.message}`
|
||||
}
|
||||
} catch (e: any) {
|
||||
actionMsg.value = `出庫錯誤:${e}`
|
||||
console.error('[checkout]', e)
|
||||
}
|
||||
checkouting.value = false
|
||||
await loadFiles()
|
||||
setTimeout(() => { actionMsg.value = '' }, 8000)
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
sortBy.value = 'time_desc'
|
||||
filterUnregistered.value = false
|
||||
filterRegistered.value = false
|
||||
filterPending.value = false
|
||||
filterCompleted.value = false
|
||||
filterNotIngested.value = false
|
||||
filterIngested.value = false
|
||||
onlyVideos.value = false
|
||||
onlyPhotos.value = false
|
||||
sizeMin.value = null
|
||||
@@ -439,6 +614,12 @@ function resetFilters() {
|
||||
.ms-ctx-danger:hover { background: #fce8e6 !important; }
|
||||
.ms-ctx-procs { padding: 6px 10px 8px; display: flex; flex-wrap: wrap; gap: 6px 10px; }
|
||||
.ms-ctx-procs-title { width: 100%; font-size: 11px; color: #7a7f87; font-weight: 600; margin-bottom: 2px; }
|
||||
.ms-proc-done { color: #059669; }
|
||||
.ms-proc-done .ms-proc-count { color: #059669; font-weight: 600; }
|
||||
.ms-proc-count { margin-left: 4px; font-size: 11px; color: #8a919c; }
|
||||
.ms-fm-progress { position: relative; height: 20px; background: #e8eaed; border-radius: 4px; margin-top: 4px; overflow: hidden; }
|
||||
.ms-fm-progress-bar { height: 100%; background: linear-gradient(90deg, #1a73e8, #4285f4); border-radius: 4px; transition: width 0.3s; }
|
||||
.ms-fm-progress-text { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 11px; font-weight: 600; color: #202124; }
|
||||
.ms-fm-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 16px; }
|
||||
.ms-fm-card { border-radius: 14px; cursor: pointer; position: relative; }
|
||||
.ms-fm-thumb-wrap { position: relative; border-radius: 12px; overflow: hidden; background: #eef2f7; aspect-ratio: 4 / 3; border: 1px solid #e6ebf1; box-shadow: 0 0 0 2px #d9dde3 inset; }
|
||||
@@ -457,4 +638,20 @@ function resetFilters() {
|
||||
.ms-fm-name { font-size: 12px; color: #42474f; line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.ms-fm-play-btn { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); width: 42px; height: 42px; border-radius: 50%; border: none; background: rgba(0,0,0,.58); color: #fff; font-size: 18px; display: flex; align-items: center; justify-content: center; cursor: pointer; z-index: 8; }
|
||||
.ms-fm-edit-input { width: 100%; height: 32px; border: 1px solid #e1e5ea; border-radius: 10px; padding: 0 9px; font-size: 12px; color: #42474f; background: #fff; outline: none; font-family: inherit; }
|
||||
|
||||
/* Three-axis badges */
|
||||
.ms-fm-badges { position: absolute; bottom: 4px; right: 4px; display: flex; flex-direction: column; gap: 2px; z-index: 9; }
|
||||
.ms-fm-badge-row { font-size: 9px; line-height: 1.2; padding: 1px 5px; border-radius: 3px; font-weight: 600; letter-spacing: 0.2px; white-space: nowrap; }
|
||||
.ms-badge-ok { background: #1a73e8; color: #fff; }
|
||||
.ms-badge-off { background: rgba(0,0,0,.48); color: #ccc; }
|
||||
.ms-badge-proc { background: #1a73e8; color: #fff; animation: badge-pulse 1.2s ease-in-out infinite; }
|
||||
.ms-badge-done { background: #1e8e3e; color: #fff; }
|
||||
.ms-badge-warn { background: #f9ab00; color: #202124; }
|
||||
.ms-badge-fail { background: #d93025; color: #fff; }
|
||||
@keyframes badge-pulse { 0%,100% { opacity: 1; } 50% { opacity: .6; } }
|
||||
|
||||
/* Three-axis status bar */
|
||||
.ms-fm-axis-bar { font-size: 12px; color: #5f6368; margin: 2px 0 12px; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.ms-fm-axis-item strong { color: #202124; }
|
||||
.ms-fm-axis-sep { color: #dadce0; }
|
||||
</style>
|
||||
|
||||
@@ -191,6 +191,7 @@
|
||||
<div v-if="ctxMenu.show" class="ms-ctx-menu" :style="{ left: ctxMenu.x + 'px', top: ctxMenu.y + 'px', display: 'block' }">
|
||||
<button class="ms-ctx-item" @click="ctxAction('star')">{{ ctxMenu.person?.starred ? '☆ 取消重要人物' : '★ 標為重要人物' }}</button>
|
||||
<hr class="ms-ctx-menu-divider">
|
||||
<button class="ms-ctx-item" @click="ctxAction('detail')">📋 詳情</button>
|
||||
<button class="ms-ctx-item" @click="ctxAction('confirm')" v-if="ctxMenu.person?.status !== 'confirmed'">✓ 確認人物</button>
|
||||
<button class="ms-ctx-item" @click="ctxAction('pending')" v-if="ctxMenu.person?.status !== 'pending'">待定</button>
|
||||
<button class="ms-ctx-item" @click="ctxAction('rename')">✎ 編輯名稱</button>
|
||||
@@ -214,6 +215,7 @@
|
||||
|
||||
<!-- Face candidate 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')">📋 詳情</button>
|
||||
<button class="ms-ctx-item" @click="faceCtxAction('assign')">⇄ 指派給現有人物</button>
|
||||
<button class="ms-ctx-item ms-ctx-danger" @click="faceCtxAction('skip')">✕ 略過此人臉</button>
|
||||
</div>
|
||||
@@ -256,6 +258,62 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Face detail modal -->
|
||||
<div v-if="faceDetailModal.show" class="ms-modal-overlay show" @click.self="faceDetailModal.show = false">
|
||||
<div class="ms-modal ms-modal-face-detail">
|
||||
<button class="ms-fm-icon-btn close-btn" @click="faceDetailModal.show = false">×</button>
|
||||
<div class="ms-face-detail-header">
|
||||
<div class="ms-face-detail-thumb">
|
||||
<img v-if="candidateThumbs[faceDetailModal.candidate?.id]" :src="candidateThumbs[faceDetailModal.candidate?.id]" alt="">
|
||||
<div v-else class="face-placeholder">{{ Math.round(faceDetailModal.candidate?.confidence * 100 || 0) }}%</div>
|
||||
</div>
|
||||
<div class="ms-face-detail-info">
|
||||
<div class="ms-face-detail-title">待定人臉詳情</div>
|
||||
<div class="ms-face-detail-row">
|
||||
<span class="ms-face-detail-label">檔案:</span>
|
||||
<span class="ms-face-detail-value">{{ faceDetailModal.candidate?.file_uuid?.slice(0, 12) }}...</span>
|
||||
</div>
|
||||
<div class="ms-face-detail-row">
|
||||
<span class="ms-face-detail-label">帧號:</span>
|
||||
<span class="ms-face-detail-value">#{{ faceDetailModal.candidate?.frame_number }}</span>
|
||||
</div>
|
||||
<div class="ms-face-detail-row">
|
||||
<span class="ms-face-detail-label">置信度:</span>
|
||||
<span class="ms-face-detail-value">{{ Math.round(faceDetailModal.candidate?.confidence * 100) }}%</span>
|
||||
</div>
|
||||
<div class="ms-face-detail-row">
|
||||
<span class="ms-face-detail-label">Face ID:</span>
|
||||
<span class="ms-face-detail-value">{{ faceDetailModal.candidate?.face_id || faceDetailModal.candidate?.id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ms-face-detail-video">
|
||||
<button class="ms-fm-btn ms-fm-btn-primary" @click="playFaceDetailVideo" :disabled="!faceDetailModal.candidate?.file_uuid">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" style="margin-right:6px;">
|
||||
<polygon points="5 3 19 12 5 21 5 3" fill="currentColor"></polygon>
|
||||
</svg>
|
||||
播放影片片段
|
||||
</button>
|
||||
</div>
|
||||
<hr class="ms-face-detail-divider">
|
||||
<div class="ms-face-detail-new-identity">
|
||||
<div class="ms-face-detail-new-title">新增為新人物</div>
|
||||
<div class="ms-face-detail-new-input-row">
|
||||
<input v-model="faceDetailModal.newIdentityName" class="ms-ppl-edit-input" placeholder="輸入人物名稱...">
|
||||
<button class="ms-fm-btn ms-fm-btn-primary" @click="createNewIdentityFromFace" :disabled="!faceDetailModal.newIdentityName.trim()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" style="margin-right:4px;">
|
||||
<path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>
|
||||
</svg>
|
||||
新增
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ms-face-detail-footer">
|
||||
<button class="ms-fm-btn" @click="faceDetailModal.show = false">關閉</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VideoPlayer v-if="playing" simple :file-uuid="currentVideo.fileUuid" :start-time="currentVideo.startTime" :end-time="currentVideo.endTime" :title="currentVideo.title" @close="playing = false" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -286,11 +344,13 @@ const mergeTarget = ref('')
|
||||
const candidates = ref<any[]>([])
|
||||
const candidateThumbs = faceThumbsCache
|
||||
const faceCandidates = faceCandidatesCache
|
||||
const traceCounts = ref<Record<string, number>>({})
|
||||
const ctxMenu = ref({ show: false, x: 0, y: 0, person: null as any })
|
||||
const faceCtxMenu = ref({ show: false, x: 0, y: 0, candidate: null as any })
|
||||
const assignModal = ref({ show: false, candidate: null as any })
|
||||
const assignSearchQuery = ref('')
|
||||
const assignSelected = ref<any>(null)
|
||||
const faceDetailModal = ref({ show: false, candidate: null as any, newIdentityName: '' })
|
||||
|
||||
// Section visibility toggles
|
||||
const showPending = ref(true)
|
||||
@@ -360,7 +420,13 @@ const pendingPeople = computed(() => {
|
||||
let base = people.value.filter((p: any) => p.status === 'pending')
|
||||
if (starFilter.value) base = base.filter((p: any) => p.starred)
|
||||
if (pendingSearch.value) base = base.filter((p: any) => p.name.toLowerCase().includes(pendingSearch.value.toLowerCase()))
|
||||
return sortPeople(base, pendingSort.value)
|
||||
// Sort by trace count (descending) - more traces = more suggestions
|
||||
const sorted = [...base].sort((a: any, b: any) => {
|
||||
const aCount = traceCounts.value[a.identity_uuid] || 0
|
||||
const bCount = traceCounts.value[b.identity_uuid] || 0
|
||||
return bCount - aCount
|
||||
})
|
||||
return sorted
|
||||
})
|
||||
|
||||
const skippedPeople = computed(() => {
|
||||
@@ -378,16 +444,28 @@ const assignSearchResults = computed(() => {
|
||||
})
|
||||
|
||||
async function refresh() {
|
||||
ensurePeople().then(() => {
|
||||
for (const p of people.value.slice(0, 30)) {
|
||||
if (p.identity_uuid) loadProfile(p.identity_uuid)
|
||||
await ensurePeople()
|
||||
for (const p of people.value.slice(0, 30)) {
|
||||
if (p.identity_uuid) loadProfile(p.identity_uuid)
|
||||
}
|
||||
await ensureFaceCandidates()
|
||||
for (const c of faceCandidates.value.slice(0, 20)) {
|
||||
if (c.file_uuid) loadFaceThumb(String(c.id), c.file_uuid, c.frame_number || 0, c.bbox)
|
||||
}
|
||||
// Load trace counts for pending identities
|
||||
const pending = people.value.filter((p: any) => p.status === 'pending')
|
||||
const counts: Record<string, number> = {}
|
||||
for (const p of pending) {
|
||||
if (p.identity_uuid) {
|
||||
try {
|
||||
const traces: any = await apiCall('get_traces', { uuid: p.identity_uuid, perPage: 1 })
|
||||
counts[p.identity_uuid] = traces?.total || 0
|
||||
} catch (e) {
|
||||
counts[p.identity_uuid] = 0
|
||||
}
|
||||
}
|
||||
})
|
||||
ensureFaceCandidates().then(() => {
|
||||
for (const c of faceCandidates.value.slice(0, 20)) {
|
||||
if (c.file_uuid) loadFaceThumb(String(c.id), c.file_uuid, c.frame_number || 0, c.bbox)
|
||||
}
|
||||
})
|
||||
}
|
||||
traceCounts.value = counts
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -561,6 +639,8 @@ async function ctxAction(action: string, extra?: any) {
|
||||
if (idx >= 0) people.value[idx].status = 'pending'
|
||||
refreshUndoCounts(uuid)
|
||||
}).catch(e => console.error('Pending failed:', e))
|
||||
} else if (action === 'detail') {
|
||||
router.push(`/people/${uuid}`)
|
||||
} else if (action === 'rename' || action === 'merge') {
|
||||
selectPerson(p)
|
||||
} else if (action === 'undo') {
|
||||
@@ -601,13 +681,79 @@ function faceCtxAction(action: string) {
|
||||
const c = faceCtxMenu.value.candidate
|
||||
if (!c) return
|
||||
faceCtxMenu.value.show = false
|
||||
if (action === 'assign') {
|
||||
if (action === 'detail') {
|
||||
faceDetailModal.value = { show: true, candidate: c, newIdentityName: '' }
|
||||
loadCandidateThumb(c)
|
||||
} else if (action === 'assign') {
|
||||
openAssignModal(c)
|
||||
} else if (action === 'skip') {
|
||||
faceCandidates.value = faceCandidates.value.filter((fc: any) => fc.id !== c.id)
|
||||
}
|
||||
}
|
||||
|
||||
async function playFaceDetailVideo() {
|
||||
const c = faceDetailModal.value.candidate
|
||||
if (!c || !c.file_uuid) return
|
||||
const frame = c.frame_number || 0
|
||||
try {
|
||||
const fileInfo: any = await apiCall('get_file_info', { uuid: c.file_uuid })
|
||||
const fps = fileInfo?.fps || 30
|
||||
const frameTime = frame / fps
|
||||
const startTime = Math.max(0, frameTime - 5)
|
||||
const endTime = frameTime + 5
|
||||
currentVideo.value = {
|
||||
fileUuid: c.file_uuid,
|
||||
startTime,
|
||||
endTime,
|
||||
title: `Face #${frame} (${Math.round(frameTime)}s)`
|
||||
}
|
||||
playing.value = true
|
||||
} catch (e) {
|
||||
console.error('Failed to get file info:', e)
|
||||
const frameTime = frame / 30
|
||||
currentVideo.value = {
|
||||
fileUuid: c.file_uuid,
|
||||
startTime: Math.max(0, frameTime - 5),
|
||||
endTime: frameTime + 5,
|
||||
title: `Face #${frame}`
|
||||
}
|
||||
playing.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function createNewIdentityFromFace() {
|
||||
const c = faceDetailModal.value.candidate
|
||||
const name = faceDetailModal.value.newIdentityName.trim()
|
||||
console.log('[createNewIdentityFromFace] candidate:', c, 'name:', name)
|
||||
if (!c || !name) {
|
||||
console.log('[createNewIdentityFromFace] missing candidate or name')
|
||||
return
|
||||
}
|
||||
try {
|
||||
console.log('[createNewIdentityFromFace] calling API with fileUuid:', c.file_uuid)
|
||||
const result: any = await apiCall('create_identity_from_face', {
|
||||
name,
|
||||
fileUuid: c.file_uuid
|
||||
})
|
||||
console.log('[createNewIdentityFromFace] result:', result)
|
||||
if (result?.identity_uuid) {
|
||||
faceDetailModal.value.show = false
|
||||
faceCandidates.value = faceCandidates.value.filter((fc: any) => fc.id !== c.id)
|
||||
invalidatePeople()
|
||||
await ensurePeople()
|
||||
// Remove hyphens from uuid for consistent format
|
||||
const cleanUuid = result.identity_uuid.replace(/-/g, '')
|
||||
router.push(`/people/${cleanUuid}`)
|
||||
} else {
|
||||
console.log('[createNewIdentityFromFace] no identity_uuid in result')
|
||||
alert('新增人物失敗:API 未返回 identity_uuid')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[createNewIdentityFromFace] error:', e)
|
||||
alert('新增人物失敗:' + String(e))
|
||||
}
|
||||
}
|
||||
|
||||
function openAssignModal(c: any) {
|
||||
assignModal.value = { show: true, candidate: c }
|
||||
assignSearchQuery.value = ''
|
||||
@@ -750,4 +896,22 @@ h1 { margin: 0; }
|
||||
.ms-fm-btn-primary { background: #202124; color: #fff; border-color: #202124; }
|
||||
.ms-fm-btn-primary:hover { background: #3c4043; }
|
||||
.ms-fm-btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* Face detail modal */
|
||||
.ms-modal-face-detail { max-width: 420px; width: 92%; padding: 24px 28px; background: #fff; border-radius: 16px; box-shadow: 0 8px 30px rgba(0,0,0,.2); position: relative; }
|
||||
.ms-face-detail-header { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
|
||||
.ms-face-detail-thumb { width: 100px; height: 100px; border-radius: 14px; background: #e8eaed; flex-shrink: 0; overflow: hidden; }
|
||||
.ms-face-detail-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.ms-face-detail-info { flex: 1; min-width: 0; }
|
||||
.ms-face-detail-title { font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 16px; font-weight: 600; color: #202124; margin: 0 0 12px; }
|
||||
.ms-face-detail-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
||||
.ms-face-detail-label { font-size: 12px; color: #5f6368; min-width: 60px; }
|
||||
.ms-face-detail-value { font-size: 12px; color: #202124; }
|
||||
.ms-face-detail-video { margin-bottom: 16px; }
|
||||
.ms-face-detail-divider { height: 1px; background: #e8eaed; margin: 16px 0; }
|
||||
.ms-face-detail-new-identity { margin-top: 16px; }
|
||||
.ms-face-detail-new-title { font-size: 14px; font-weight: 500; color: #202124; 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-footer { display: flex; justify-content: flex-end; margin-top: 20px; }
|
||||
</style>
|
||||
|
||||
@@ -103,57 +103,69 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Trace strip (face_traces from get_traces) -->
|
||||
<div class="ms-ppl-strip-wrap" :class="{ 'ms-ppl-edit-mode': isEditing }">
|
||||
<button class="ms-ppl-strip-add-btn" @click="showCandidates = true" title="加入相同人物">+</button>
|
||||
<button class="ms-ppl-strip-arrow" :disabled="faceStripPage === 1" @click="prevFacePage">‹</button>
|
||||
<div class="ms-ppl-face-strip">
|
||||
<div v-if="loadingFaces" style="padding:10px;color:#999;font-size:12px;">Loading faces...</div>
|
||||
<div v-else-if="faces.length === 0" style="padding:10px;color:#999;font-size:12px;">No faces found</div>
|
||||
<div v-for="f in faces.slice(0, showAllFaces ? faces.length : 30)" :key="f.id" class="ms-ppl-strip-face ms-ppl-strip-face-clickable" :class="{ selected: selectedFace?.id === f.id }" @click="selectFace(f)" @contextmenu.prevent="showFaceCtxMenu($event, f)">
|
||||
<div v-if="loadingTraces" style="padding:10px;color:#999;font-size:12px;">Loading traces...</div>
|
||||
<div v-else-if="allTraces.length === 0" style="padding:10px;color:#999;font-size:12px;">No traces found</div>
|
||||
<div v-for="(t, i) in paginatedTraces" :key="t.trace_id" class="ms-ppl-strip-face ms-ppl-strip-face-clickable" :class="{ selected: selectedTrace?.trace_id === t.trace_id }" @click="selectTrace(t)" @contextmenu.prevent="showTraceCtxMenu($event, t)">
|
||||
<div class="ms-ppl-strip-face-img">
|
||||
<img v-if="faceThumbs[f.id]" :src="faceThumbs[f.id]" alt="" loading="lazy" style="width:100%;height:100%;object-fit:cover;border-radius:8px;">
|
||||
<div v-else style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:#eef2ff;color:#6366f1;font-size:14px;font-weight:600;border-radius:8px;">{{ Math.round(f.confidence * 100) }}%</div>
|
||||
<img v-if="traceThumbs[thumbKey(t.file_uuid, t.first_frame)]" :src="traceThumbs[thumbKey(t.file_uuid, t.first_frame)]" alt="" loading="lazy" style="width:100%;height:100%;object-fit:cover;border-radius:8px;">
|
||||
<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;">T{{ t.trace_id }}</div>
|
||||
</div>
|
||||
<button v-if="isEditing" class="ms-ppl-strip-remove-btn" @click="unbindFace(f)">×</button>
|
||||
<div class="ms-ppl-strip-trace-label">T{{ t.trace_id }} {{ t.face_id ? 'F' + t.face_id.slice(-4) : '' }}</div>
|
||||
<button v-if="isEditing" class="ms-ppl-strip-remove-btn" @click="unbindFace(t)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<button v-if="faces.length > 30" class="ms-ppl-strip-arrow" @click="showAllFaces = !showAllFaces">›</button>
|
||||
<button class="ms-ppl-strip-arrow" :disabled="faceStripPage >= faceStripTotalPages" @click="nextFacePage">›</button>
|
||||
<span v-if="faceStripTotalPages > 1" class="ms-ppl-strip-page">{{ faceStripPage }}/{{ faceStripTotalPages }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Face detail card -->
|
||||
<div v-if="selectedFace" class="ms-ppl-face-card-detail">
|
||||
<button class="ms-ppl-face-card-close" @click="selectedFace = null">×</button>
|
||||
<!-- Trace detail card -->
|
||||
<div v-if="selectedTrace" class="ms-ppl-face-card-detail">
|
||||
<button class="ms-ppl-face-card-close" @click="selectedTrace = null">×</button>
|
||||
<div class="ms-ppl-face-card-img-wrap">
|
||||
<img v-if="faceThumbs[selectedFace.id]" :src="faceThumbs[selectedFace.id]" alt="" style="width:100%;height:100%;object-fit:cover;border-radius:12px;">
|
||||
<img v-if="traceThumbs[thumbKey(selectedTrace.file_uuid, selectedTrace.first_frame)]" :src="traceThumbs[thumbKey(selectedTrace.file_uuid, selectedTrace.first_frame)]" alt="" style="width:100%;height:100%;object-fit:cover;border-radius:12px;">
|
||||
<div v-else class="face-placeholder">?</div>
|
||||
</div>
|
||||
<div class="ms-ppl-face-card-info">
|
||||
<div class="ms-ppl-face-card-file">{{ (selectedFace.file_uuid || '').slice(0, 12) }}...</div>
|
||||
<div class="ms-ppl-face-card-frame">Frame #{{ selectedFace.frame_number }}</div>
|
||||
<div v-if="selectedFace.confidence" class="ms-ppl-face-card-conf">Confidence: {{ (selectedFace.confidence * 100).toFixed(1) }}%</div>
|
||||
<div class="ms-ppl-face-card-file">trace_id: {{ selectedTrace.trace_id }}</div>
|
||||
<div class="ms-ppl-face-card-frame">face_id: {{ selectedTrace.face_id || '—' }}</div>
|
||||
<div class="ms-ppl-face-card-time">
|
||||
{{ (selectedTrace.first_sec || 0).toFixed(1) }}s ~ {{ (selectedTrace.last_sec || 0).toFixed(1) }}s
|
||||
<span class="ms-ppl-face-card-dur">({{ traceDuration(selectedTrace).toFixed(1) }}s)</span>
|
||||
</div>
|
||||
<div class="ms-ppl-face-card-fps" v-if="traceFps(selectedTrace)">
|
||||
{{ traceFps(selectedTrace) }} fps · {{ selectedTrace.last_frame - selectedTrace.first_frame }} frames
|
||||
</div>
|
||||
<div class="ms-ppl-face-card-conf">#{{ selectedTrace.first_frame }}–#{{ selectedTrace.last_frame }} · {{ selectedTrace.file_uuid?.slice(0, 8) }}...</div>
|
||||
<div class="ms-ppl-face-card-actions">
|
||||
<button class="ms-fm-btn" @click="playFaceTrace(selectedFace)">▶ 播放片段</button>
|
||||
<button v-if="isEditing" class="ms-fm-btn ms-fm-btn-danger" @click="unbindFace(selectedFace); selectedFace = null">✕ 解綁</button>
|
||||
<button class="ms-fm-btn" @click="playTrace(selectedTrace)">▶ 播放</button>
|
||||
<button v-if="isEditing && selectedTrace.face_id" class="ms-fm-btn ms-fm-btn-danger" @click="unbindFace(selectedTrace); selectedTrace = null">✕ 解綁</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Segment card -->
|
||||
<div v-if="mergedSegments.length" class="ms-ppl-media-item segment-card" @click="playMerged(mergedSegments[0])">
|
||||
<!-- Single segment card (entry point to player with all traces) -->
|
||||
<div v-if="loadingTraces" class="ms-ppl-media-label" style="margin-bottom:20px;">Loading segments...</div>
|
||||
<div v-else-if="loadingMoreTraces" class="ms-ppl-media-label" style="margin-bottom:20px;color:#9aa0a6;">Loading more segments...</div>
|
||||
<div v-if="allSegCard" class="ms-ppl-media-item segment-card" @click="playAllSegments">
|
||||
<div class="ms-ppl-media-thumb">
|
||||
<img v-if="thumbs[mergedSegments[0].thumbKey]" :src="thumbs[mergedSegments[0].thumbKey]" alt="" loading="lazy" style="width:100%;height:100%;object-fit:cover;">
|
||||
<img v-if="thumbs[allSegCard.thumbKey]" :src="thumbs[allSegCard.thumbKey]" alt="" loading="lazy" style="width:100%;height:100%;object-fit:cover;">
|
||||
<div class="ms-thumb-play-circle">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24"><polygon points="6,4 20,12 6,20" fill="white"/></svg>
|
||||
</div>
|
||||
<span class="ms-ppl-media-dur">{{ (mergedSegments[0].end - mergedSegments[0].start).toFixed(1) }}s</span>
|
||||
<span v-if="mergedSegments[0].avg_confidence" class="ms-ppl-media-badge-conf">{{ (mergedSegments[0].avg_confidence * 100).toFixed(0) }}%</span>
|
||||
<span class="ms-ppl-media-dur">{{ allSegCard.duration.toFixed(1) }}s</span>
|
||||
<span v-if="allSegCard.avg_confidence" class="ms-ppl-media-badge-conf">{{ (allSegCard.avg_confidence * 100).toFixed(0) }}%</span>
|
||||
</div>
|
||||
<div class="ms-ppl-media-info">
|
||||
<div class="ms-ppl-media-title">{{ mergedSegments[0].count > 1 ? 'Merged ' + mergedSegments[0].count + ' segments' : 'Segment' }} · {{ mergedSegments[0].file_uuid.slice(0,8) }}...</div>
|
||||
<div class="ms-ppl-media-sub">#{{ mergedSegments[0].start_frame }} — #{{ mergedSegments[0].end_frame }} · {{ formatTime(mergedSegments[0].start) }}</div>
|
||||
<div class="ms-ppl-media-title">{{ allSegCard.count }} 個片段 · {{ allTraces.length }} 個追蹤</div>
|
||||
<div class="ms-ppl-media-sub">所有片段 · {{ formatTime(allSegCard.start) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="loadingTraces" class="ms-ppl-media-label" style="margin-bottom:20px;">Loading segments...</div>
|
||||
<div v-else-if="!mergedSegments.length" class="ms-ppl-media-label" style="margin-bottom:20px;">No trace segments available for this person</div>
|
||||
<div v-else-if="!loadingTraces" class="ms-ppl-media-label" style="margin-bottom:20px;">No trace segments available for this person</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div v-show="activeTab === 'actions'">
|
||||
@@ -234,21 +246,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VideoPlayer v-if="playing" :file-uuid="currentVideo.fileUuid" :start-time="currentVideo.startTime" :end-time="currentVideo.endTime" :all-traces="allTraces" :initial-trace-idx="currentVideo.traceIdx" :title="currentVideo.title" @close="playing = false" />
|
||||
<VideoPlayer v-if="playing" :file-uuid="currentVideo.fileUuid" :start-time="currentVideo.startTime" :end-time="currentVideo.endTime" :all-traces="allTraces" :merged-segments="mergedSegments" :initial-trace-idx="currentVideo.traceIdx" :title="currentVideo.title" @close="playing = false" />
|
||||
|
||||
<!-- Face strip context menu -->
|
||||
<div v-if="faceCtxMenu.show" class="ms-ctx-menu" :style="{ left: faceCtxMenu.x + 'px', top: faceCtxMenu.y + 'px', display: 'block' }" @click.stop @mousedown.stop @pointerdown.stop>
|
||||
<button class="ms-ctx-item ms-ctx-danger" @click="faceCtxAction('unbind')">不是此人物</button>
|
||||
<!-- Trace strip context menu -->
|
||||
<div v-if="traceCtxMenu.show" class="ms-ctx-menu" :style="{ left: traceCtxMenu.x + 'px', top: traceCtxMenu.y + 'px', display: 'block' }" @click.stop @mousedown.stop @pointerdown.stop>
|
||||
<button class="ms-ctx-item ms-ctx-danger" @click="traceCtxAction('unbind')">不是此人物</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { apiCall } from '@/api'
|
||||
import { isTauri } from '@/api/config'
|
||||
import { ensurePeople, peopleCache, peopleLoaded, profilesCache, loadProfile as storeLoadProfile, faceThumbsCache, loadFaceThumb as storeLoadFaceThumb, thumbnailsCache, invalidatePeople, invalidateProfile } from '@/store'
|
||||
import { ensurePeople, peopleCache, peopleLoaded, profilesCache, loadProfile as storeLoadProfile, faceThumbsCache, loadFaceThumb as storeLoadFaceThumb, thumbnailsCache, loadThumbnail, invalidatePeople, invalidateProfile } from '@/store'
|
||||
import VideoPlayer from '@/components/VideoPlayer.vue'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -256,13 +268,11 @@ const router = useRouter()
|
||||
|
||||
const person = ref<any>(null)
|
||||
const loading = ref(true)
|
||||
const loadingFaces = ref(true)
|
||||
const loadingTraces = ref(true)
|
||||
const profile = ref('')
|
||||
const profileUuid = ref('')
|
||||
const peopleCount = ref(0)
|
||||
const allPeople = ref<any[]>([])
|
||||
const faces = ref<any[]>([])
|
||||
const allTraces = ref<any[]>([])
|
||||
const mergedSegments = ref<any[]>([])
|
||||
const playing = ref(false)
|
||||
@@ -272,9 +282,28 @@ const showMerge = ref(false)
|
||||
const mergeSearchQuery = ref('')
|
||||
const mergeSearchResults = ref<any[]>([])
|
||||
const candidates = ref<any[]>([])
|
||||
const showAllFaces = ref(false)
|
||||
const thumbs = thumbnailsCache
|
||||
const traceThumbs = thumbnailsCache
|
||||
const faceThumbs = faceThumbsCache
|
||||
|
||||
const allSegCard = computed(() => {
|
||||
const m = mergedSegments.value
|
||||
if (!m.length) return null
|
||||
const segs = [...m].sort((a, b) => a.start - b.start)
|
||||
const first = segs[0]
|
||||
const last = segs[segs.length - 1]
|
||||
const firstTrace = allTraces.value[first._startIdx]
|
||||
return {
|
||||
thumbKey: first.thumbKey,
|
||||
start: first.start,
|
||||
end: last.end,
|
||||
duration: last.end - first.start,
|
||||
count: m.length,
|
||||
avg_confidence: m.reduce((s, x) => s + (x.avg_confidence || 0), 0) / m.length,
|
||||
firstTraceIdx: first._startIdx,
|
||||
}
|
||||
})
|
||||
|
||||
const isEditing = ref(false)
|
||||
const editName = ref('')
|
||||
const editRole = ref('')
|
||||
@@ -286,9 +315,19 @@ const aliasName = ref('')
|
||||
const saving = ref(false)
|
||||
const avatarUploading = ref(false)
|
||||
const activeTab = ref<'faces' | 'actions'>('faces')
|
||||
const faceCtxMenu = ref({ show: false, x: 0, y: 0, face: null as any })
|
||||
const selectedFace = ref<any>(null)
|
||||
const traceCtxMenu = ref({ show: false, x: 0, y: 0, trace: null as any })
|
||||
const selectedTrace = ref<any>(null)
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const loadingMoreTraces = ref(false)
|
||||
const faceStripPage = ref(1)
|
||||
const faceStripPerPage = 20
|
||||
const paginatedTraces = computed(() => {
|
||||
const start = (faceStripPage.value - 1) * faceStripPerPage
|
||||
return allTraces.value.slice(start, start + faceStripPerPage)
|
||||
})
|
||||
const faceStripTotalPages = computed(() => Math.ceil(allTraces.value.length / faceStripPerPage))
|
||||
function prevFacePage() { if (faceStripPage.value > 1) faceStripPage.value-- }
|
||||
function nextFacePage() { if (faceStripPage.value < faceStripTotalPages.value) faceStripPage.value++ }
|
||||
|
||||
onMounted(async () => {
|
||||
const uuid = route.params.uuid as string
|
||||
@@ -300,7 +339,6 @@ onMounted(async () => {
|
||||
if (found) {
|
||||
person.value = { ...found, status: found.status || 'confirmed' }
|
||||
loadProfile(uuid)
|
||||
loadFaces(uuid)
|
||||
loadMedia(uuid)
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -309,11 +347,11 @@ onMounted(async () => {
|
||||
loading.value = false
|
||||
}
|
||||
loadCandidates()
|
||||
document.addEventListener('click', closeFaceCtxMenu)
|
||||
document.addEventListener('click', closeTraceCtxMenu)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', closeFaceCtxMenu)
|
||||
document.removeEventListener('click', closeTraceCtxMenu)
|
||||
})
|
||||
|
||||
function loadProfile(uuid: string) {
|
||||
@@ -333,133 +371,130 @@ function thumbKey(uuid: string, frame: number): string {
|
||||
return `${uuid}:${frame}`
|
||||
}
|
||||
|
||||
async function loadFaces(uuid: string) {
|
||||
loadingFaces.value = true
|
||||
try {
|
||||
const result: any = await apiCall('get_faces', { uuid, perPage: 20 })
|
||||
const items = Array.isArray(result) ? result : []
|
||||
faces.value = items
|
||||
items.forEach((f: any) => {
|
||||
const fu = f.file_uuid || f.fileUuid || ''
|
||||
const fn = f.frame_number ?? f.frameNumber ?? 0
|
||||
const fid = f.id
|
||||
const bbox = f.bbox
|
||||
storeLoadFaceThumb(fid, fu, fn, bbox)
|
||||
})
|
||||
} catch (e) { console.error('Failed to load faces:', e) }
|
||||
finally { loadingFaces.value = false }
|
||||
function selectTrace(t: any) {
|
||||
selectedTrace.value = t
|
||||
// Load thumbnail for this trace
|
||||
loadThumb(t.file_uuid, t.first_frame || 0)
|
||||
}
|
||||
|
||||
function selectFace(f: any) {
|
||||
selectedFace.value = f
|
||||
}
|
||||
|
||||
function playFaceTrace(f: any) {
|
||||
const fu = f.file_uuid || f.fileUuid || ''
|
||||
const frame = f.frame_number ?? f.frameNumber ?? 0
|
||||
const trace = allTraces.value.find((t: any) => t.file_uuid === fu && frame >= (t.first_frame || t.start_frame || 0) && frame <= (t.last_frame || t.end_frame || 0))
|
||||
if (trace) {
|
||||
currentVideo.value = {
|
||||
fileUuid: fu,
|
||||
startTime: trace.first_sec || trace.start_time || 0,
|
||||
endTime: trace.last_sec || trace.end_time || 0,
|
||||
traceIdx: allTraces.value.indexOf(trace),
|
||||
title: `${person.value?.name} · Face #${frame}`,
|
||||
}
|
||||
} else {
|
||||
currentVideo.value = {
|
||||
fileUuid: fu,
|
||||
startTime: Math.max(0, (frame / 30) - 5),
|
||||
endTime: (frame / 30) + 5,
|
||||
traceIdx: 0,
|
||||
title: `${person.value?.name} · Face #${frame}`,
|
||||
}
|
||||
function playTrace(t: any) {
|
||||
if (!t) return
|
||||
const idx = allTraces.value.indexOf(t)
|
||||
if (idx < 0) return
|
||||
currentVideo.value = {
|
||||
fileUuid: t.file_uuid || '',
|
||||
startTime: t.first_sec || t.start_time || 0,
|
||||
endTime: t.last_sec || t.end_time || 0,
|
||||
traceIdx: idx,
|
||||
title: `${person.value?.name} · T${t.trace_id}`,
|
||||
}
|
||||
playing.value = true
|
||||
}
|
||||
|
||||
function showFaceCtxMenu(e: MouseEvent, f: any) {
|
||||
function showTraceCtxMenu(e: MouseEvent, t: any) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
faceCtxMenu.value = { show: true, x: e.clientX, y: e.clientY, face: { ...f } }
|
||||
traceCtxMenu.value = { show: true, x: e.clientX, y: e.clientY, trace: { ...t } }
|
||||
}
|
||||
|
||||
function closeFaceCtxMenu(e?: MouseEvent) {
|
||||
faceCtxMenu.value.show = false
|
||||
function closeTraceCtxMenu(e?: MouseEvent) {
|
||||
traceCtxMenu.value.show = false
|
||||
}
|
||||
|
||||
function faceCtxAction(action: string) {
|
||||
const f = faceCtxMenu.value.face
|
||||
faceCtxMenu.value = { show: false, x: 0, y: 0, face: null }
|
||||
if (!f) {
|
||||
console.error('faceCtxAction: no face data')
|
||||
function traceCtxAction(action: string) {
|
||||
const t = traceCtxMenu.value.trace
|
||||
traceCtxMenu.value = { show: false, x: 0, y: 0, trace: null }
|
||||
if (!t) {
|
||||
console.error('traceCtxAction: no trace data')
|
||||
return
|
||||
}
|
||||
if (!person.value) {
|
||||
console.error('faceCtxAction: no person data')
|
||||
console.error('traceCtxAction: no person data')
|
||||
return
|
||||
}
|
||||
if (action === 'unbind') {
|
||||
unbindFace(f)
|
||||
unbindFace(t)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMedia(uuid: string) {
|
||||
loadingTraces.value = true
|
||||
try {
|
||||
const result: any = await apiCall('get_traces', { uuid, perPage: 50 })
|
||||
const rawItems = Array.isArray(result) ? result : []
|
||||
if (!rawItems.length) { mergedSegments.value = []; return }
|
||||
|
||||
rawItems.sort((a: any, b: any) => (a.first_sec || a.start_time || 0) - (b.first_sec || b.start_time || 0))
|
||||
allTraces.value = rawItems
|
||||
|
||||
const merged: any[] = []
|
||||
let cur: any = null
|
||||
rawItems.forEach((item: any, idx: number) => {
|
||||
const st = item.first_sec || item.start_time || 0
|
||||
const en = item.last_sec || item.end_time || 0
|
||||
const fu = item.file_uuid || ''
|
||||
if (cur && fu === cur.file_uuid && (st - cur.end) < 30) {
|
||||
cur.end = Math.max(cur.end, en)
|
||||
cur.end_frame = Math.max(cur.end_frame, item.last_frame || 0)
|
||||
cur.count++
|
||||
cur._endIdx = idx
|
||||
cur.total_confidence += item.avg_confidence || 0
|
||||
} 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,
|
||||
total_confidence: item.avg_confidence || 0,
|
||||
_startIdx: idx,
|
||||
_endIdx: idx,
|
||||
thumbKey: thumbKey(fu, item.first_frame || 0),
|
||||
}
|
||||
}
|
||||
})
|
||||
if (cur) merged.push(cur)
|
||||
|
||||
merged.forEach((m: any) => {
|
||||
m.avg_confidence = m.count > 1 ? m.total_confidence / m.count : m.total_confidence
|
||||
delete m.total_confidence
|
||||
})
|
||||
mergedSegments.value = merged.slice(0, 1)
|
||||
merged.forEach((m: any) => loadThumb(m.file_uuid, m.start_frame))
|
||||
// Load all traces with pagination
|
||||
const allItems: any[] = []
|
||||
for (let page = 1; ; page++) {
|
||||
const result: any = await apiCall('get_traces', { uuid, perPage: 100, page })
|
||||
const items = result?.traces || []
|
||||
if (!items.length) break
|
||||
allItems.push(...items)
|
||||
}
|
||||
processTraces(allItems)
|
||||
} catch (e) { console.error('Failed to load media:', e) }
|
||||
finally { loadingTraces.value = false }
|
||||
finally { loadingTraces.value = false; loadingMoreTraces.value = false }
|
||||
}
|
||||
|
||||
function processTraces(newData: any, append = false) {
|
||||
const items = Array.isArray(newData) ? newData : []
|
||||
if (!items.length) return
|
||||
|
||||
if (!append) {
|
||||
allTraces.value = items.sort((a: any, b: any) => (a.first_sec || a.start_time || 0) - (b.first_sec || b.start_time || 0))
|
||||
} else {
|
||||
loadingMoreTraces.value = true
|
||||
const existingIds = new Set(allTraces.value.map((t: any) => t.trace_id))
|
||||
const fresh = items.filter((t: any) => !existingIds.has(t.trace_id))
|
||||
if (!fresh.length) return
|
||||
allTraces.value = [...allTraces.value, ...fresh].sort((a: any, b: any) =>
|
||||
(a.first_sec || a.start_time || 0) - (b.first_sec || b.start_time || 0)
|
||||
)
|
||||
}
|
||||
|
||||
// Recompute merged segments
|
||||
const merged: any[] = []
|
||||
let cur: any = null
|
||||
allTraces.value.forEach((item: any, idx: number) => {
|
||||
const st = item.first_sec || item.start_time || 0
|
||||
const en = item.last_sec || item.end_time || 0
|
||||
const fu = item.file_uuid || ''
|
||||
if (cur && fu === cur.file_uuid && (st - cur.end) < 30) {
|
||||
cur.end = Math.max(cur.end, en)
|
||||
cur.end_frame = Math.max(cur.end_frame, item.last_frame || 0)
|
||||
cur.count++
|
||||
cur._endIdx = idx
|
||||
cur.total_confidence += item.avg_confidence || 0
|
||||
} 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,
|
||||
total_confidence: item.avg_confidence || 0,
|
||||
_startIdx: idx,
|
||||
_endIdx: idx,
|
||||
thumbKey: thumbKey(fu, item.first_frame || 0),
|
||||
}
|
||||
}
|
||||
})
|
||||
if (cur) merged.push(cur)
|
||||
|
||||
merged.forEach((m: any) => {
|
||||
m.avg_confidence = m.count > 1 ? m.total_confidence / m.count : m.total_confidence
|
||||
delete m.total_confidence
|
||||
})
|
||||
mergedSegments.value = merged
|
||||
if (append) loadingMoreTraces.value = false
|
||||
|
||||
// Load thumbnails for visible segments
|
||||
merged.slice(0, 6).forEach((m: any) => loadThumb(m.file_uuid, m.start_frame))
|
||||
}
|
||||
|
||||
function loadThumb(uuid: string, frame: number) {
|
||||
const key = thumbKey(uuid, frame)
|
||||
if (thumbnailsCache.value[key]) return
|
||||
apiCall('get_thumbnail', { uuid, frame }).then((url: any) => {
|
||||
if (url) thumbnailsCache.value[key] = url
|
||||
}).catch(() => {})
|
||||
loadThumbnail(uuid, frame)
|
||||
}
|
||||
|
||||
async function loadCandidates() {
|
||||
@@ -593,19 +628,22 @@ function triggerAvatarUpload() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
async function unbindFace(f: any) {
|
||||
async function unbindFace(t: any) {
|
||||
if (!person.value) return
|
||||
const uuid = person.value.identity_uuid
|
||||
if (!confirm('解綁此人臉?')) return
|
||||
const fid = t.face_id || t.faceId || null
|
||||
if (!fid) { alert('此 trace 沒有 face_id,無法解綁'); return }
|
||||
if (!confirm(`解綁 trace T${t.trace_id}(face_id: ${fid})?`)) return
|
||||
try {
|
||||
await apiCall('unbind_face', {
|
||||
uuid,
|
||||
faceId: f.face_id || f.faceId || null,
|
||||
faceRowId: f.id || null,
|
||||
fileUuid: f.file_uuid || f.fileUuid || '',
|
||||
frameNumber: f.frame_number ?? f.frameNumber ?? null,
|
||||
faceId: fid,
|
||||
faceRowId: null,
|
||||
fileUuid: t.file_uuid || t.fileUuid || '',
|
||||
frameNumber: null,
|
||||
})
|
||||
await loadFaces(uuid)
|
||||
// Refresh traces
|
||||
await loadMedia(uuid)
|
||||
invalidatePeople()
|
||||
await ensurePeople()
|
||||
allPeople.value = peopleCache.value
|
||||
@@ -628,6 +666,20 @@ async function updateStatus(status: string) {
|
||||
} catch (e) { console.error('Failed to update status:', e) }
|
||||
}
|
||||
|
||||
function playAllSegments() {
|
||||
const card = allSegCard.value
|
||||
if (!card || !allTraces.value.length) return
|
||||
const t = allTraces.value[0]
|
||||
currentVideo.value = {
|
||||
fileUuid: t.file_uuid || '',
|
||||
startTime: t.first_sec || t.start_time || 0,
|
||||
endTime: t.last_sec || t.end_time || 0,
|
||||
traceIdx: 0,
|
||||
title: `${person.value?.name} · ${card.count} 個片段`,
|
||||
}
|
||||
playing.value = true
|
||||
}
|
||||
|
||||
function playMerged(m: any) {
|
||||
const t = allTraces.value[m._startIdx]
|
||||
if (!t) return
|
||||
@@ -646,6 +698,19 @@ function formatTime(sec: number): string {
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function traceDuration(t: any): number {
|
||||
if (!t) return 0
|
||||
return (t.last_sec || 0) - (t.first_sec || 0)
|
||||
}
|
||||
|
||||
function traceFps(t: any): number | null {
|
||||
if (!t) return null
|
||||
const frameDiff = (t.last_frame || 0) - (t.first_frame || 0)
|
||||
const secDiff = traceDuration(t)
|
||||
if (secDiff <= 0) return null
|
||||
return Math.round(frameDiff / secDiff)
|
||||
}
|
||||
|
||||
async function refreshPerson() {
|
||||
invalidatePeople()
|
||||
await ensurePeople()
|
||||
@@ -745,10 +810,15 @@ let mergeSearchTimer: any
|
||||
.ms-ppl-face-card-info { flex: 1; min-width: 0; }
|
||||
.ms-ppl-face-card-file { font-size: 13px; color: #5f6368; font-family: monospace; }
|
||||
.ms-ppl-face-card-frame { font-size: 13px; color: #3c4043; margin-top: 2px; }
|
||||
.ms-ppl-face-card-time { font-size: 13px; color: #3c4043; margin-top: 4px; }
|
||||
.ms-ppl-face-card-dur { color: #9aa0a6; margin-left: 4px; }
|
||||
.ms-ppl-face-card-fps { font-size: 12px; color: #5f6368; margin-top: 2px; }
|
||||
.ms-ppl-face-card-conf { font-size: 12px; color: #9aa0a6; margin-top: 2px; }
|
||||
.ms-ppl-face-card-actions { display: flex; gap: 8px; margin-top: 10px; }
|
||||
.ms-ppl-strip-arrow { width: 28px; height: 28px; border-radius: 50%; border: 1.5px solid #d1d5db; background: #fff; font-size: 18px; display: grid; place-items: center; cursor: pointer; color: #5f6368; outline: none; flex-shrink: 0; transition: background .15s; }
|
||||
.ms-ppl-strip-arrow:hover { background: #f3f4f6; color: #202124; }
|
||||
.ms-ppl-strip-arrow:hover:not(:disabled) { background: #f3f4f6; color: #202124; }
|
||||
.ms-ppl-strip-arrow:disabled { opacity: 0.35; cursor: default; }
|
||||
.ms-ppl-strip-page { font-size: 11px; color: #9aa0a6; min-width: 32px; text-align: center; flex-shrink: 0; }
|
||||
.ms-ppl-media-label { font-size: 13px; color: #5f6368; margin-bottom: 16px; }
|
||||
.ms-ppl-media-item { cursor: pointer; border-radius: 12px; overflow: visible; background: #f0f0f0; transition: transform .15s, box-shadow .15s; border: 1px solid #eee; }
|
||||
.ms-ppl-media-item:hover { transform: translateY(-2px); box-shadow: 0 6px 18px rgba(0,0,0,.1); }
|
||||
|
||||
Reference in New Issue
Block a user