Files
momentry_studio/src/views/PersonDetailView.vue
Momentry Studio f3d75ebce8 feat: Library 批量注册 + PeopleView 修复 + 国际化
Library:
- 添加 checkbox 和鼠标框选功能
- Unregistered 卡片添加快速注册按钮
- 状态标签移至左下方,人脸图标移至左侧中间
- Deep Scan 改为 unregister -> register -> process
- 完整国际化(日语、繁体中文、英语)

PeopleView:
- 修复卡片不显示问题(使用 get_file_traces API)
- 移除对 identity_id 字段的依赖

其他:
- 添加日语语言包
- 添加 useDragSelect composable
- 新增文档:Face Bbox Null Bug Report
2026-10-01 00:42:13 +08:00

940 lines
49 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="people-view ms-ppl-detail-view">
<div class="ms-ppl-topbar">
<button class="ms-fm-btn" @click="router.back()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M15 18l-6-6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
Back
</button>
<button v-if="!isEditing" class="ms-ppl-edit-text-btn" @click="startEditing">
Edit
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" style="margin-left:4px;">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</button>
</div>
<div v-if="loading" class="loading-state">
<div class="spinner-lg"></div>
<p>Loading...</p>
</div>
<template v-else-if="person">
<div class="ms-ppl-detail-header">
<div style="display:flex;flex-direction:column;align-items:center;gap:8px;flex-shrink:0;">
<div class="ms-ppl-detail-avatar" :class="{ 'ms-ppl-avatar-editable': isEditing }" @click="isEditing && triggerAvatarUpload()">
<img v-if="profile" :src="profile" alt="" style="width:100%;height:100%;object-fit:contain;">
<svg v-else class="ms-silhouette" viewBox="0 0 120 120" fill="none">
<circle cx="60" cy="45" r="25" fill="#d1d5db"/>
<ellipse cx="60" cy="105" rx="40" ry="25" fill="#d1d5db"/>
</svg>
<div v-if="isEditing" class="ms-ppl-avatar-upload-hint">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="12" cy="13" r="4" stroke="currentColor" stroke-width="1.5"/>
</svg>
</div>
</div>
<input ref="fileInput" type="file" accept="image/jpeg,image/png" style="display:none" @change="handleAvatarUpload">
</div>
<div style="flex:1;min-width:0;">
<!-- View mode -->
<div v-if="!isEditing" id="msPplInfoView">
<div class="ms-ppl-detail-name-row">
<button class="ms-ppl-star-btn" :class="{ starred: person.starred }" @click="toggleStar">{{ person.starred ? '★' : '☆' }}</button>
<div class="ms-ppl-view-box ms-ppl-view-name-box">{{ person.name || '—' }}</div>
</div>
<div style="text-align:center;margin-top:2px;">
<span class="ms-ppl-source-badge" :class="detailSourceClass">{{ detailSourceLabel }}</span>
</div>
<div class="ms-ppl-detail-aliases" v-if="person.metadata?.aliases?.length">
<span v-for="(a, i) in person.metadata.aliases" :key="i" class="ms-ppl-alias-chip">{{ a.name }}</span>
</div>
<div class="ms-ppl-edit-fields" style="margin-top:4px;">
<div class="ms-ppl-edit-field-row">
<span class="ms-ppl-edit-label">Role</span>
<div class="ms-ppl-view-box ms-ppl-view-field-box">{{ person.metadata?.role || '—' }}</div>
</div>
<div class="ms-ppl-edit-field-row ms-ppl-edit-field-row--top">
<span class="ms-ppl-edit-label">Description</span>
<div class="ms-ppl-view-box ms-ppl-view-notes-box">{{ person.metadata?.notes || '—' }}</div>
</div>
<div class="ms-ppl-edit-field-row" v-if="person.file_uuids?.length">
<span class="ms-ppl-edit-label">Appears in</span>
<div class="ms-ppl-files-list">
<div v-for="fu in person.file_uuids" :key="fu" class="ms-ppl-file-chip" :title="fu">
<template v-if="isTmdb">
<span class="ms-keyword-label">Keyword</span>
</template>
{{ getFileName(fu) }}<span class="ms-ppl-file-chip-uuid">{{ fu }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Edit mode -->
<div v-else id="msPplInfoEdit">
<div class="ms-ppl-edit-row ms-ppl-edit-name-row">
<button class="ms-ppl-star-btn" :class="{ starred: person.starred }" @click="toggleStar">{{ person.starred ? '★' : '☆' }}</button>
<input v-model="editName" class="ms-ppl-edit-input ms-ppl-edit-name-input" placeholder="Enter name">
</div>
<div class="ms-ppl-edit-row ms-ppl-edit-alias-row">
<div class="ms-ppl-alias-wrap-inner">
<span v-for="(a, i) in editAliases" :key="i" class="ms-ppl-alias-tag">
{{ a.name }}
<button @click="removeAlias(i)">×</button>
</span>
</div>
<template v-if="showAliasInput">
<select v-model="aliasLocale" class="ms-ppl-alias-locale-select">
<option value="en">{{ $t('language.en') }}</option>
<option value="zh-TW">{{ $t('language.zh-TW') }}</option>
<option value="zh-CN">{{ $t('language.zh-CN') }}</option>
<option value="ja">{{ $t('language.ja') }}</option>
<option value="ko">{{ $t('language.ko') }}</option>
</select>
<input v-model="aliasName" class="ms-ppl-alias-inline-input" placeholder="Enter alias and press Enter" @keyup.enter="addAlias">
</template>
<button v-if="!showAliasInput" class="ms-ppl-alias-add-btn" @click="showAliasInput = true; aliasName = ''">{{ $t('people.detail.add_alias') }}</button>
<button v-else class="ms-ppl-alias-add-btn" @click="showAliasInput = false">Collapse</button>
</div>
<div class="ms-ppl-edit-fields">
<div class="ms-ppl-edit-field-row">
<label class="ms-ppl-edit-label">Role</label>
<input v-model="editRole" class="ms-ppl-edit-input ms-ppl-edit-field-input" placeholder="Role name">
</div>
<div class="ms-ppl-edit-field-row ms-ppl-edit-field-row--top">
<label class="ms-ppl-edit-label">Description</label>
<textarea v-model="editNotes" class="ms-ppl-edit-textarea ms-ppl-edit-field-input" placeholder="Custom description"></textarea>
</div>
</div>
<div class="ms-ppl-edit-actions">
<button class="ms-fm-btn ms-fm-btn-primary" @click="saveEdit" :disabled="saving">{{ saving ? 'Saving...' : '✓ Save Changes' }}</button>
<button class="ms-fm-btn" @click="cancelEditing">Cancel</button>
</div>
</div>
</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="Add same person">+</button>
<button class="ms-ppl-strip-arrow" :disabled="faceStripPage === 1" @click="prevFacePage">‹</button>
<div class="ms-ppl-face-strip">
<div v-if="loadingTraces" style="padding:10px;color:#999;font-size:12px;">Loading FS...</div>
<div v-else-if="allTraces.length === 0" style="padding:10px;color:#999;font-size:12px;">No FS 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="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:contain;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>
<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 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>
<!-- 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" @click="showEnlargedFace = true" style="cursor:pointer;">
<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:contain;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">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-frame-range">
Frame: #{{ selectedTrace.first_frame }} ~ #{{ selectedTrace.last_frame }}
</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-actions">
<button class="ms-fm-btn" @click="playTrace(selectedTrace)">▶ Play</button>
<button v-if="isEditing && selectedTrace.face_id" class="ms-fm-btn ms-fm-btn-danger" @click="unbindFace(selectedTrace); selectedTrace = null">✕ Unbind</button>
</div>
</div>
</div>
<!-- Enlarged face image modal -->
<div v-if="showEnlargedFace && selectedTrace" class="ms-modal-overlay show" @click.self="showEnlargedFace = false" style="z-index:10000;">
<div class="ms-enlarged-face-modal">
<button class="ms-modal-video-close" @click="showEnlargedFace = false">&times;</button>
<img v-if="traceThumbs[thumbKey(selectedTrace.file_uuid, selectedTrace.first_frame)]" :src="traceThumbs[thumbKey(selectedTrace.file_uuid, selectedTrace.first_frame)]" alt="" style="max-width:90vw;max-height:90vh;object-fit:contain;border-radius:12px;">
</div>
</div>
<!-- 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[allSegCard.thumbKey]" :src="thumbs[allSegCard.thumbKey]" alt="" loading="lazy" style="width:100%;height:100%;object-fit:contain;">
<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">{{ 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">{{ allSegCard.count }} segments · {{ allTraces.length }} FS</div>
<div class="ms-ppl-media-sub">All segments · {{ formatTime(allSegCard.start) }}</div>
</div>
</div>
<div v-else-if="!loadingTraces" class="ms-ppl-media-label" style="margin-bottom:20px;">No FS segments available for this person</div>
<!-- Tabs -->
<div v-show="activeTab === 'actions'">
<div class="ms-ppl-actions-section">
<div class="ms-ppl-actions-status">
<span class="ms-ppl-actions-label">Status:</span>
<span class="ms-ppl-actions-value">{{ person.status || 'confirmed' }}</span>
<button v-if="person.status !== 'confirmed'" class="ms-fm-btn ms-fm-btn-primary" @click="updateStatus('confirmed')">✓ Confirm</button>
<button v-if="person.status !== 'pending'" class="ms-fm-btn" @click="updateStatus('pending')">Pending</button>
<button v-if="person.status !== 'skipped'" class="ms-fm-btn" @click="updateStatus('skipped')">Skip</button>
</div>
</div>
<div class="ms-ppl-actions-section" style="margin-top:16px;">
<button class="ms-fm-btn ms-fm-btn-primary" @click="showCandidates = true" style="margin-bottom:8px;">+ Bind Face</button>
<button class="ms-fm-btn ms-fm-btn-blue" @click="showMerge = true" style="margin-bottom:8px;">⇄ Merge to Other Person</button>
</div>
<div class="ms-ppl-delete-zone">
<hr class="ms-ppl-delete-hr">
<button class="ms-fm-btn ms-ppl-delete-zone-btn" @click="confirmDelete">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<polyline points="3 6 5 6 21 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></polyline>
<path d="M19 6l-1 14H6L5 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M10 11v6M14 11v6" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>
</svg>
Delete This Person
</button>
</div>
</div>
</template>
<div v-else class="empty">
Person not found: {{ route.params.uuid }}
<br><br>
<span style="font-size:12px;color:#9aa0a6;">People loaded: {{ peopleCount }}</span>
</div>
<div v-if="showCandidates" class="ms-modal-overlay show" @click.self="showCandidates = false">
<div class="ms-modal ms-modal-merge">
<button class="ms-fm-icon-btn close-btn" @click="showCandidates = false">×</button>
<h2 class="ms-ppl-section-title">Bind Face</h2>
<div class="ms-merge-grid">
<div v-for="c in candidates" :key="c.id" class="ms-merge-face-card" @click="bindCandidate(c)">
<div class="ms-merge-face-img">
<img v-if="faceThumbs[c.thumbKey]" :src="faceThumbs[c.thumbKey]" alt="">
<div v-else class="face-placeholder">{{ Math.round(c.confidence * 100) }}%</div>
</div>
<span class="ms-merge-face-name">{{ c.file_uuid?.slice(0, 8) }}... #{{ c.frame_number }}</span>
</div>
</div>
</div>
</div>
<div v-if="showMerge" class="ms-modal-overlay show" @click.self="showMerge = false">
<div class="ms-modal ms-modal-merge">
<button class="ms-fm-icon-btn close-btn" @click="showMerge = false">×</button>
<h2 class="ms-ppl-section-title">⇄ Merge to Other Person</h2>
<div class="ms-merge-search-wrap">
<span class="ms-merge-search-icon">🔍</span>
<input v-model="mergeSearchQuery" class="ms-merge-search-input" placeholder="Search person name..." @input="onMergeSearch" />
</div>
<div class="ms-merge-grid">
<div v-for="r in mergeSearchResults" :key="r.identity_id" class="ms-merge-face-card" @click="confirmMergeTarget(r)">
<div class="ms-merge-face-img">
<svg viewBox="0 0 120 120" fill="none" style="width:100%;height:100%;">
<circle cx="60" cy="45" r="25" fill="#d1d5db"/>
<ellipse cx="60" cy="105" rx="40" ry="25" fill="#d1d5db"/>
</svg>
</div>
<span class="ms-merge-face-name">{{ r.name }}</span>
</div>
<div v-if="!mergeSearchResults.length && mergeSearchQuery" class="ms-merge-face-card" style="opacity:0.5;cursor:default;width:100%;padding:20px;text-align:center;">
No results found
</div>
</div>
<div class="ms-merge-footer">
<button class="ms-fm-btn" @click="showMerge = false">Cancel</button>
</div>
</div>
</div>
<VideoPlayer v-if="playing" :file-uuid="currentVideo.fileUuid" :start-frame="currentVideo.startFrame" :end-frame="currentVideo.endFrame" :all-traces="allTraces" :merged-segments="mergedSegments" :initial-trace-idx="currentVideo.traceIdx" :title="currentVideo.title" :show-face="true" :show-asrx="false" :show-ocr="false" :show-pose="false" @close="playing = false" />
<!-- 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')">Not This Person</button>
</div>
</div>
</template>
<script setup lang="ts">
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, loadThumbnail, invalidatePeople, invalidateProfile, filesCache, ensureFiles } from '@/store'
import VideoPlayer from '@/components/VideoPlayer.vue'
const route = useRoute()
const router = useRouter()
const person = ref<any>(null)
const loading = ref(true)
const loadingTraces = ref(true)
const profile = ref('')
const profileUuid = ref('')
const peopleCount = ref(0)
const allPeople = ref<any[]>([])
const allTraces = ref<any[]>([])
const mergedSegments = ref<any[]>([])
const playing = ref(false)
const currentVideo = ref({ fileUuid: '', startFrame: 0, endFrame: 0, traceIdx: 0, title: '' })
const showCandidates = ref(false)
const showMerge = ref(false)
const mergeSearchQuery = ref('')
const mergeSearchResults = ref<any[]>([])
const candidates = ref<any[]>([])
const thumbs = thumbnailsCache
const traceThumbs = thumbnailsCache
const faceThumbs = faceThumbsCache
const isTmdb = computed(() => {
const p = person.value
return !!(p && (p.source === 'tmdb' || p.tmdb_id || p.metadata?.role))
})
const detailSourceLabel = computed(() => {
const p = person.value
if (!p) return ''
if (p.source === 'tmdb' || p.tmdb_id) return 'TMDB'
if (p.source === 'user') return 'User'
if (p.metadata?.role) return 'TMDB'
return 'Auto'
})
const detailSourceClass = computed(() => {
const p = person.value
if (!p) return ''
if (p.source === 'tmdb' || p.tmdb_id || p.metadata?.role) return 'ms-source-tmdb'
if (p.source === 'user') return 'ms-source-user'
return 'ms-source-auto'
})
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('')
const editNotes = ref('')
const editAliases = ref<{name: string, locale: string}[]>([])
const showAliasInput = ref(false)
const aliasLocale = ref('zh-TW')
const aliasName = ref('')
const saving = ref(false)
const avatarUploading = ref(false)
const activeTab = ref<'faces' | 'actions'>('faces')
const traceCtxMenu = ref({ show: false, x: 0, y: 0, trace: null as any })
const selectedTrace = ref<any>(null)
const showEnlargedFace = ref(false)
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++ }
function getFileName(fileUuid: string): string {
const f = filesCache.value.find((f: any) => f.file_uuid?.replace(/-/g, '') === fileUuid)
return f?.file_name || fileUuid
}
onMounted(async () => {
const uuid = route.params.uuid as string
try {
await Promise.all([ensurePeople(), ensureFiles()])
peopleCount.value = peopleCache.value.length
allPeople.value = peopleCache.value
const found = allPeople.value.find((p: any) => p.identity_uuid === uuid)
if (found) {
person.value = { ...found, status: found.status || 'confirmed' }
if (!person.value.file_uuids?.length) {
try {
const result: any = await apiCall('get_identity_files', { uuid, pageSize: 20 })
const files = result?.data || []
person.value.file_uuids = files.map((f: any) => f.file_uuid)
} catch (e) { /* ignore */ }
}
loadProfile(uuid)
loadMedia(uuid)
}
} catch (e) {
console.error('Failed to load person:', e)
} finally {
loading.value = false
}
loadCandidates()
document.addEventListener('click', closeTraceCtxMenu)
})
onUnmounted(() => {
document.removeEventListener('click', closeTraceCtxMenu)
})
function loadProfile(uuid: string) {
profileUuid.value = uuid
if (profilesCache.value[uuid]) {
profile.value = profilesCache.value[uuid]
} else {
storeLoadProfile(uuid)
}
}
watch(() => profileUuid.value && profilesCache.value[profileUuid.value], (val) => {
if (val && !profile.value) profile.value = val
})
function thumbKey(uuid: string, frame: number): string {
return `${uuid}:${frame}`
}
function selectTrace(t: any) {
selectedTrace.value = t
// Load thumbnail for this trace
loadThumb(t.file_uuid, t.first_frame || 0)
}
function playTrace(t: any) {
if (!t) return
const idx = allTraces.value.indexOf(t)
if (idx < 0) return
const fps = traceFps(t) || 24
const startFrame = Math.max(0, (t.first_frame || 0) - 10)
const endFrame = (t.last_frame || 0) + 10
currentVideo.value = {
fileUuid: t.file_uuid || '',
startFrame: startFrame,
endFrame: endFrame,
traceIdx: idx,
title: `${person.value?.name} · T${t.trace_id}`,
}
playing.value = true
}
function showTraceCtxMenu(e: MouseEvent, t: any) {
e.preventDefault()
e.stopPropagation()
traceCtxMenu.value = { show: true, x: e.clientX, y: e.clientY, trace: { ...t } }
}
function closeTraceCtxMenu(e?: MouseEvent) {
traceCtxMenu.value.show = false
}
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('traceCtxAction: no person data')
return
}
if (action === 'unbind') {
unbindFace(t)
}
}
async function loadMedia(uuid: string) {
loadingTraces.value = true
try {
// 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; 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
loadThumbnail(uuid, frame)
}
async function loadCandidates() {
const fileUuid = person.value?.file_uuids?.[0]
if (!fileUuid) { candidates.value = []; return }
try {
const result: any = await apiCall('get_face_candidates', { page: 1, perPage: 20, fileUuid })
candidates.value = (Array.isArray(result) ? result : []).map((c: any) => ({
...c,
thumbKey: `cand_${c.id}`
}))
;(Array.isArray(result) ? result : []).forEach((c: any) => loadFaceCandidateThumb(c))
} catch (e) { console.error('Failed to load candidates:', e) }
}
async function loadFaceCandidateThumb(c: any) {
if (!c?.file_uuid) return
storeLoadFaceThumb(`cand_${c.id}`, c.file_uuid, c.frame_number || 0, c.bbox)
}
async function toggleStar() {
if (!person.value) return
const newVal = !person.value.starred
try {
await apiCall('update_identity_starred', { uuid: person.value.identity_uuid, starred: newVal })
person.value.starred = newVal
invalidatePeople()
} catch (e) {
console.error('Failed to update star:', e)
}
}
async function confirmDelete() {
if (!person.value || !confirm(`Delete "${person.value.name}"?`)) return
try {
await apiCall('delete_identity', { uuid: person.value.identity_uuid })
router.back()
} catch (e) { console.error('Failed to delete:', e) }
}
async function bindCandidate(c: any) {
if (!person.value) return
const fid = c.face_id || String(c.id)
try {
await apiCall('bind_face', { uuid: person.value.identity_uuid, faceId: fid, faceRowId: c.face_id ? undefined : c.id, fileUuid: c.file_uuid })
showCandidates.value = false
await refreshPerson()
} catch (e) { console.error('Bind failed:', e) }
}
function startEditing() {
if (!person.value) return
editName.value = person.value.name || ''
const meta = person.value.metadata || {}
editRole.value = meta.role || ''
editNotes.value = meta.notes || ''
editAliases.value = JSON.parse(JSON.stringify(meta.aliases || []))
isEditing.value = true
}
function cancelEditing() {
isEditing.value = false
}
async function saveEdit() {
if (!person.value || saving.value) return
saving.value = true
try {
const cleanAliases = editAliases.value.filter(a => a.name?.trim()).map(a => ({ name: a.name.trim(), locale: a.locale }))
const metadata = {
...(person.value.metadata || {}),
name: editName.value.trim(),
role: editRole.value.trim(),
notes: editNotes.value.trim(),
starred: person.value.starred,
aliases: cleanAliases,
}
await apiCall('update_identity', {
uuid: person.value.identity_uuid,
name: editName.value.trim(),
metadataJson: JSON.stringify(metadata),
})
isEditing.value = false
invalidatePeople()
await refreshPerson()
} catch (e) {
console.error('Failed to save:', e)
} finally {
saving.value = false
}
}
function addAlias() {
const name = aliasName.value.trim()
if (!name) return
editAliases.value.push({ name, locale: aliasLocale.value })
aliasName.value = ''
}
function removeAlias(index: number) {
editAliases.value.splice(index, 1)
}
async function handleAvatarUpload(event: Event) {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (!file || !person.value) return
avatarUploading.value = true
try {
if (isTauri) {
const path = (file as any).path || file.name
await (window as any).__TAURI__.invoke('upload_profile_image', {
uuid: person.value.identity_uuid,
filePath: path,
})
} else {
await apiCall('upload_profile_image', {
uuid: person.value.identity_uuid,
file: file,
})
}
invalidateProfile(person.value.identity_uuid)
loadProfile(person.value.identity_uuid)
} catch (e) {
console.error('Avatar upload failed:', e)
} finally {
avatarUploading.value = false
target.value = ''
}
}
function triggerAvatarUpload() {
fileInput.value?.click()
}
async function unbindFace(t: any) {
if (!person.value) return
const uuid = person.value.identity_uuid
const fid = t.face_id || t.faceId || null
if (!fid) { alert('This trace has no face_id, cannot unbind'); return }
if (!confirm(`Unbind trace T${t.trace_id} (face_id: ${fid})?`)) return
try {
await apiCall('unbind_face', {
uuid,
faceId: fid,
faceRowId: null,
fileUuid: t.file_uuid || t.fileUuid || '',
frameNumber: null,
})
// Refresh traces
await loadMedia(uuid)
invalidatePeople()
await ensurePeople()
allPeople.value = peopleCache.value
const found = allPeople.value.find((p: any) => p.identity_uuid === uuid)
if (found) {
person.value = { ...found, status: found.status || 'confirmed' }
}
} catch (e) {
console.error('Unbind failed:', e)
alert('Unbind failed: ' + (e instanceof Error ? e.message : String(e)))
}
}
async function updateStatus(status: string) {
if (!person.value) return
try {
await apiCall('update_identity_status', { uuid: person.value.identity_uuid, status })
person.value.status = status
await refreshPerson()
} 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 || '',
startFrame: t.first_frame || 0,
endFrame: t.last_frame || (t.first_frame || 0) + 30,
traceIdx: 0,
title: `F${t.first_frame || 0}-F${t.last_frame || 0} (${formatTime(t.first_sec || 0)}-${formatTime(t.last_sec || 0)})`,
}
playing.value = true
}
function playMerged(m: any) {
const t = allTraces.value[m._startIdx]
if (!t) return
currentVideo.value = {
fileUuid: t.file_uuid || '',
startFrame: t.first_frame || 0,
endFrame: t.last_frame || (t.first_frame || 0) + 30,
traceIdx: m._startIdx,
title: `F${t.first_frame || 0}-F${t.last_frame || 0} (${formatTime(t.first_sec || 0)}-${formatTime(t.last_sec || 0)})`,
}
playing.value = true
}
function formatTime(sec: number): string {
const m = Math.floor(sec / 60); const s = Math.floor(sec % 60)
return `${m}:${s.toString().padStart(2, '0')}`
}
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()
allPeople.value = peopleCache.value
const uuid = person.value?.identity_uuid || (route.params.uuid as string)
const found = allPeople.value.find((p: any) => p.identity_uuid === uuid)
if (found) {
person.value = { ...found, status: found.status || 'confirmed' }
}
}
async function onMergeSearch() {
clearTimeout(mergeSearchTimer)
if (!mergeSearchQuery.value.trim()) { mergeSearchResults.value = []; return }
mergeSearchTimer = setTimeout(() => {
const q = mergeSearchQuery.value.toLowerCase()
mergeSearchResults.value = allPeople.value
.filter((p: any) => p.identity_uuid !== person.value?.identity_uuid && p.name.toLowerCase().includes(q))
.slice(0, 20)
}, 200)
}
async function confirmMergeTarget(target: any) {
if (!person.value) return
if (!confirm(`Merge "${person.value.name}" into "${target.name}"?`)) return
try {
await apiCall('merge_identities', { uuid: person.value.identity_uuid, intoUuid: target.identity_uuid })
showMerge.value = false
router.back()
} catch (e) { console.error('Merge failed:', e) }
}
let mergeSearchTimer: any
</script>
<style scoped>
.people-view { max-width: 1200px; padding-top: 20px; }
.ms-ppl-topbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; margin-top: 20px; }
.ms-ppl-edit-text-btn { border: none; background: transparent; font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 13px; color: var(--text-secondary); cursor: pointer; outline: none; padding: 4px 10px; display: flex; align-items: center; gap: 4px; transition: color .15s; }
.ms-ppl-edit-text-btn:hover { color: var(--text-primary); }
.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); } }
.close-btn { position: absolute; top: 16px; right: 16px; }
.ms-ppl-detail-header { display: flex; align-items: flex-start; gap: 22px; margin-bottom: 28px; position: relative; margin-top: 20px; }
.ms-ppl-detail-avatar { width: 120px; height: 120px; border-radius: 20px; background: var(--border-light); flex-shrink: 0; overflow: hidden; position: relative; }
.ms-ppl-detail-avatar img { width: 100%; height: 100%; object-fit: contain; border-radius: 20px; }
.ms-ppl-avatar-editable { cursor: pointer; }
.ms-ppl-avatar-upload-hint { position: absolute; inset: 0; border-radius: 20px; background: rgba(0,0,0,.45); display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity .15s; pointer-events: none; }
.ms-ppl-avatar-editable:hover .ms-ppl-avatar-upload-hint { opacity: 1; }
.ms-ppl-avatar-upload-hint svg { color: #fff; }
.ms-silhouette { width: 100%; height: 100%; }
.ms-ppl-detail-name-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
.ms-ppl-star-btn { font-size: 20px; background: transparent; border: none; cursor: pointer; outline: none; line-height: 1; color: var(--text-secondary); transition: color .15s; padding: 0; flex-shrink: 0; }
.ms-ppl-star-btn.starred { color: var(--warning-color); }
.ms-ppl-alias-chip { display: inline-flex; align-items: center; background: var(--hover-background); border-radius: 999px; padding: 3px 10px; font-size: 11.5px; color: var(--text-secondary); }
.ms-keyword-label { display: inline-flex; align-items: center; background: rgba(var(--primary-color-rgb), 0.1); border-radius: 4px; padding: 1px 6px; margin-right: 4px; font-size: 10px; color: var(--primary-color); font-weight: 500; }
.ms-ppl-source-badge { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: .03em; }
.ms-source-tmdb { color: #1a73e8; }
.ms-source-user { color: #188038; }
.ms-source-auto { color: #9aa0a6; }
.ms-ppl-files-list { display: flex; flex-wrap: wrap; gap: 4px; }
.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: 200px; cursor: default; display: inline-flex; align-items: center; gap: 6px; }
.ms-ppl-file-chip-uuid { font-size: 9px; color: var(--text-secondary); font-family: monospace; }
.ms-ppl-edit-row { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; flex-wrap: wrap; }
.ms-ppl-edit-name-row { margin-bottom: 8px; }
.ms-ppl-edit-alias-row { padding-left: 32px; margin-bottom: 16px; gap: 6px; align-items: center; }
.ms-ppl-alias-wrap-inner { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.ms-ppl-alias-tag { display: inline-flex; align-items: center; gap: 5px; background: var(--hover-background); border-radius: 999px; padding: 4px 10px; font-size: 12.5px; color: var(--text-primary); }
.ms-ppl-alias-tag button { border: none; background: transparent; cursor: pointer; color: var(--text-secondary); font-size: 14px; line-height: 1; padding: 0; display: flex; align-items: center; }
.ms-ppl-alias-add-btn { border: 1.5px dashed var(--border-color); background: transparent; border-radius: 999px; padding: 4px 10px; font-size: 12px; color: var(--text-secondary); cursor: pointer; outline: none; }
.ms-ppl-alias-add-btn:hover { border-color: var(--text-primary); color: var(--text-primary); }
.ms-ppl-alias-inline-wrap { display: flex; align-items: center; }
.ms-ppl-alias-inline-input { border: 1.5px solid var(--primary-color); border-radius: 999px; padding: 4px 12px; font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 12.5px; color: var(--text-primary); outline: none; background: var(--card-background); min-width: 140px; transition: border-color .15s; }
.ms-ppl-alias-inline-input:focus { border-color: var(--primary-color); }
.ms-ppl-alias-locale-select { border: 1.5px solid var(--primary-color); border-radius: 999px; padding: 4px 10px; font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 12px; color: var(--text-primary); background: var(--card-background); outline: none; cursor: pointer; height: 30px; }
.ms-ppl-edit-fields { display: flex; flex-direction: column; gap: 10px; margin-bottom: 16px; }
.ms-ppl-edit-field-row { display: flex; align-items: center; gap: 12px; }
.ms-ppl-edit-field-row--top { align-items: flex-start; }
.ms-ppl-edit-label { font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 12px; font-weight: 700; color: var(--text-primary); letter-spacing: .03em; min-width: 30px; text-align: right; flex-shrink: 0; padding-top: 2px; }
.ms-ppl-edit-input { border: 1.5px solid var(--border-color); border-radius: 12px; padding: 9px 14px; font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 13.5px; color: var(--text-primary); outline: none; background: var(--card-background); box-shadow: var(--shadow); transition: border-color .15s, box-shadow .15s; min-width: 0; box-sizing: border-box; width: 100%; }
.ms-ppl-edit-input:focus { border-color: var(--primary-color); box-shadow: 0 0 0 3px rgba(var(--primary-color-rgb), 0.1); }
.ms-ppl-edit-name-input { font-size: 16px !important; font-weight: 700 !important; flex: 1; min-width: 160px; }
.ms-ppl-edit-textarea { width: 100%; border: 1.5px solid var(--border-color); border-radius: 12px; padding: 9px 14px; font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 13px; color: var(--text-primary); outline: none; resize: vertical; min-height: 72px; background: var(--card-background); box-shadow: var(--shadow); transition: border-color .15s, box-shadow .15s; box-sizing: border-box; }
.ms-ppl-edit-textarea:focus { border-color: var(--primary-color); box-shadow: 0 0 0 3px rgba(var(--primary-color-rgb), 0.1); }
.ms-ppl-edit-actions { display: flex; gap: 8px; padding-left: 42px; }
.ms-ppl-edit-field-input { flex: 1; min-width: 0; }
.ms-ppl-view-box { font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 13.5px; color: var(--text-primary); min-height: 24px; display: flex; align-items: center; word-break: break-word; padding: 2px 0; }
.ms-ppl-view-name-box { font-size: 20px; font-weight: 700; flex: 1; min-width: 0; }
.ms-ppl-view-field-box { flex: 1; min-width: 0; color: var(--text-primary); }
.ms-ppl-view-notes-box { flex: 1; min-width: 0; min-height: 24px; align-items: flex-start; color: var(--text-secondary); font-size: 13px; white-space: pre-wrap; }
.ms-ppl-strip-wrap { display: flex; align-items: center; gap: 10px; margin-bottom: 28px; }
.ms-ppl-strip-add-btn { width: 52px; height: 52px; border-radius: 12px; border: 1.5px dashed var(--border-color); background: var(--card-background); font-size: 20px; color: var(--text-secondary); display: grid; place-items: center; cursor: pointer; outline: none; flex-shrink: 0; transition: border-color .15s, color .15s; }
.ms-ppl-strip-add-btn:hover { border-color: var(--text-primary); color: var(--text-primary); }
.ms-ppl-face-strip { display: flex; gap: 10px; overflow-x: auto; padding-bottom: 8px; scrollbar-width: thin; scrollbar-color: #b8bec6 transparent; flex: 1; min-width: 0; }
.ms-ppl-face-strip::-webkit-scrollbar { height: 8px; }
.ms-ppl-face-strip::-webkit-scrollbar-track { background: transparent; }
.ms-ppl-face-strip::-webkit-scrollbar-thumb { background: #b8bec6; border-radius: 999px; }
.ms-ppl-face-strip::-webkit-scrollbar-thumb:hover { background: #8a919b; }
.ms-ppl-strip-face { position: relative; flex-shrink: 0; cursor: pointer; }
.ms-ppl-strip-face-clickable.selected { outline: 2px solid var(--primary-color); outline-offset: 2px; border-radius: 14px; }
.ms-ppl-strip-face-img { width: 52px; height: 52px; border-radius: 12px; border: 2px solid transparent; background: var(--border-light); overflow: hidden; transition: border-color .15s; }
.ms-ppl-strip-face:hover .ms-ppl-strip-face-img { border-color: var(--text-primary); }
.ms-ppl-face-card-detail { display: flex; gap: 16px; align-items: flex-start; background: var(--card-background); border: 1px solid var(--border-color); border-radius: 16px; padding: 16px; margin: 12px 0; position: relative; box-shadow: var(--shadow); }
.ms-ppl-face-card-close { position: absolute; top: 8px; right: 10px; background: none; border: none; font-size: 20px; cursor: pointer; color: var(--text-secondary); line-height: 1; padding: 2px 6px; border-radius: 6px; z-index: 10; }
.ms-ppl-face-card-close:hover { background: var(--hover-background); color: var(--text-primary); }
.ms-ppl-face-card-img-wrap { width: 120px; height: 120px; border-radius: 12px; overflow: hidden; flex-shrink: 0; background: var(--border-light); }
.ms-ppl-face-card-info { flex: 1; min-width: 0; padding-top: 4px; }
.ms-ppl-face-card-file { font-size: 13px; color: var(--text-secondary); font-family: monospace; }
.ms-ppl-face-card-frame { font-size: 13px; color: var(--text-primary); margin-top: 2px; }
.ms-ppl-face-card-frame-range { font-size: 12px; color: var(--text-secondary); margin-top: 2px; }
.ms-ppl-face-card-time { font-size: 13px; color: var(--text-primary); margin-top: 4px; }
.ms-ppl-face-card-dur { color: var(--text-secondary); margin-left: 4px; }
.ms-ppl-face-card-fps { font-size: 12px; color: var(--text-secondary); margin-top: 2px; }
.ms-ppl-face-card-conf { font-size: 12px; color: var(--text-secondary); margin-top: 2px; }
.ms-ppl-face-card-actions { display: flex; gap: 8px; margin-top: 10px; }
.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-ppl-strip-arrow { width: 28px; height: 28px; border-radius: 50%; border: 1.5px solid var(--border-color); background: var(--card-background); font-size: 18px; display: grid; place-items: center; cursor: pointer; color: var(--text-secondary); outline: none; flex-shrink: 0; transition: background .15s; }
.ms-ppl-strip-arrow:hover:not(:disabled) { background: var(--hover-background); color: var(--text-primary); }
.ms-ppl-strip-arrow:disabled { opacity: 0.35; cursor: default; }
.ms-ppl-strip-page { font-size: 11px; color: var(--text-secondary); min-width: 32px; text-align: center; flex-shrink: 0; }
.ms-ppl-media-label { font-size: 13px; color: var(--text-secondary); margin-bottom: 16px; }
.ms-ppl-media-item { cursor: pointer; border-radius: 12px; overflow: visible; background: var(--hover-background); transition: transform .15s, box-shadow .15s; border: 1px solid var(--border-color); }
.ms-ppl-media-item:hover { transform: translateY(-2px); box-shadow: var(--shadow); }
.ms-ppl-media-thumb { position: relative; width: 100%; aspect-ratio: 16/9; overflow: hidden; background: var(--border-light); border-radius: 12px 12px 0 0; }
.ms-ppl-thumb-play-circle { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.18); border-radius: 12px 12px 0 0; }
.ms-ppl-thumb-play-circle svg { width: 38px; height: 38px; opacity: .7; transition: opacity .15s, transform .15s; }
.ms-ppl-media-item:hover .ms-ppl-thumb-play-circle svg { opacity: 1; transform: scale(1.08); }
.ms-ppl-media-dur { position: absolute; bottom: 5px; right: 7px; background: rgba(0,0,0,.5); color: #fff; font-size: 10px; padding: 1px 5px; border-radius: 3px; }
.ms-ppl-media-info { padding: 8px 10px 10px; background: var(--card-background); }
.ms-ppl-media-title { font-size: 12px; font-weight: 600; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 2px; }
.ms-ppl-media-sub { font-size: 10.5px; color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ms-ppl-delete-zone { margin-top: 48px; padding-bottom: 32px; }
.ms-ppl-delete-hr { border: none; border-top: 1.5px solid var(--border-light); margin: 0 0 20px; }
.ms-ppl-delete-zone-btn { color: var(--danger-color); border-color: var(--danger-color); background: var(--card-background); }
.ms-ppl-delete-zone-btn:hover { background: var(--danger-background); border-color: var(--danger-color); }
.face-placeholder { font-size: 0.6rem; color: var(--text-secondary); }
.ms-ppl-actions-section { display: flex; flex-direction: column; gap: 8px; }
.ms-ppl-actions-status { display: flex; align-items: center; gap: 10px; padding: 12px; background: var(--hover-background); border-radius: 10px; font-size: 13px; }
.ms-ppl-actions-label { color: var(--text-secondary); font-weight: 500; }
.ms-ppl-actions-value { color: var(--text-primary); font-weight: 600; text-transform: capitalize; }
.ms-ppl-media-badge-conf { position: absolute; top: 5px; left: 7px; background: rgba(0,0,0,.5); color: #fff; font-size: 10px; padding: 1px 5px; border-radius: 3px; }
.segment-card { max-width: 320px; margin-bottom: 20px; }
/* Context menu */
.ms-ctx-menu { position: fixed; z-index: 99999; background: #fff; border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,.15); padding: 6px; min-width: 140px; font-size: 13px; color: #222; }
.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: #222; font-family: inherit; }
.ms-ctx-item:hover { background: #f3f4f6; }
.ms-ctx-item.ms-ctx-danger { color: #d93025; }
.ms-ctx-item.ms-ctx-danger:hover { background: #fce8e6; }
.ms-ctx-item-disabled { padding: 8px 12px; font-size: 12px; color: #9aa0a6; cursor: default; }
/* Remove button for face strip */
.ms-ppl-strip-remove-btn { display: none; position: absolute; top: -6px; right: -6px; width: 18px; height: 18px; border-radius: 50%; background: #fff; border: 1.5px solid #d1d5db; font-size: 11px; color: #5f6368; line-height: 1; cursor: pointer; outline: none; place-items: center; }
.ms-ppl-edit-mode .ms-ppl-strip-remove-btn { display: grid; }
@media (max-width: 767px) {
.people-view { padding-top: 8px; }
.ms-ppl-strip-wrap { gap: 6px; margin-bottom: 16px; }
.ms-ppl-strip-face-img { width: 40px; height: 40px; }
.ms-ppl-detail-name { font-size: 16px; }
.ms-ppl-detail-info { flex-direction: column; align-items: flex-start; }
}
</style>