feat: frame-based positioning and mark system foundation

Core Changes:
- Fix SearchView to use start_frame/end_frame directly (no time*fps conversion)
- Add hard_delete support to delete_trace API
- VideoPlayer: Main timeline + Mark system foundation
- Proxy: Add local routes for auth, media, identity-matches, cluster-results
- Add .gitignore to exclude build artifacts and dependencies

Design Documents:
- Multi-track Mark system design (.opencode/plans/)
- Video editing positioning standards research

Files Modified:
- src/views/SearchView.vue: Frame positioning, ensureMinDuration (240 frames)
- src/views/PeopleView.vue: batchDeleteGroups with hard_delete
- src/api/index.ts: delete_trace with hard_delete body
- src/components/VideoPlayer.vue: Timeline + Mark UI
- src-tauri/src/proxy.rs: New local routes
- AGENTS.md: Update documentation
This commit is contained in:
2026-07-24 20:19:47 +08:00
parent 299f58e883
commit 5951aca086
38 changed files with 12107 additions and 1666 deletions

View File

@@ -1,10 +1,38 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { isTauri } from '@/api/config'
import AgentTip from './components/AgentTip.vue'
import { loadUserBehavior } from '@/stores/agentStore'
import { useI18n } from 'vue-i18n'
import { changeLanguage, getCurrentLanguage } from './locales'
const { t } = useI18n()
const router = useRouter()
const appZoom = ref(100)
const loggedInUser = ref(localStorage.getItem('username') || '')
const currentLanguage = ref(getCurrentLanguage())
const sidebarWidth = ref(240)
const isDragging = ref(false)
function refreshUser() {
const user = localStorage.getItem('username') || ''
loggedInUser.value = user
}
function handleLogout() {
localStorage.removeItem('token')
localStorage.removeItem('username')
localStorage.removeItem('expires_at')
loggedInUser.value = ''
router.push('/login')
}
function onChangeLanguage() {
changeLanguage(currentLanguage.value)
}
let systemMedia: MediaQueryList | null = null
function handleKeydown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey)) {
@@ -24,77 +52,116 @@ function handleKeydown(e: KeyboardEvent) {
}
}
function startDrag(e: MouseEvent) {
isDragging.value = true
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
}
function handleDrag(e: MouseEvent) {
if (!isDragging.value) return
const newWidth = e.clientX - 28
sidebarWidth.value = Math.max(180, Math.min(400, newWidth))
}
function stopDrag() {
isDragging.value = false
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
onMounted(() => {
document.addEventListener('keydown', handleKeydown)
document.addEventListener('mousemove', handleDrag)
document.addEventListener('mouseup', stopDrag)
refreshUser()
loadUserBehavior()
if (isTauri) return
const token = localStorage.getItem('token')
const expiresAt = localStorage.getItem('expires_at')
const currentPath = router.currentRoute.value.path
if (currentPath !== '/login') {
if (!token || !expiresAt) {
router.push('/login')
} else {
const now = new Date()
const expires = new Date(expiresAt)
if (now >= expires) {
localStorage.removeItem('token')
localStorage.removeItem('username')
localStorage.removeItem('expires_at')
router.push('/login')
}
}
}
window.addEventListener('ms-login', () => {
refreshUser()
loadUserBehavior()
})
})
onUnmounted(() => document.removeEventListener('keydown', handleKeydown))
watch(() => router.currentRoute.value.path, () => {
refreshUser()
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
document.removeEventListener('mousemove', handleDrag)
document.removeEventListener('mouseup', stopDrag)
})
</script>
<template>
<div id="app" class="ms-app" :data-current-login="'guest'">
<aside class="ms-side">
<aside class="ms-side" :style="{ width: sidebarWidth + 'px', minWidth: sidebarWidth + 'px' }">
<div class="gs-logo">Workspace</div>
<nav class="gs-nav">
<router-link to="/search" class="gs-nav-item" active-class="active">
<img class="gs-nav-icon" src="/icons/Search.png" alt="">
<span>Search</span>
<svg class="gs-nav-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/>
<path d="M21 21l-4.35-4.35"/>
</svg>
<span>{{ t('nav.search') }}</span>
</router-link>
<router-link to="/library" class="gs-nav-item" active-class="active">
<img class="gs-nav-icon" src="/icons/Library.png" alt="">
<span>Library</span>
<svg class="gs-nav-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7" rx="1"/>
<rect x="14" y="3" width="7" height="7" rx="1"/>
<rect x="3" y="14" width="7" height="7" rx="1"/>
<rect x="14" y="14" width="7" height="7" rx="1"/>
</svg>
<span>{{ t('nav.library') }}</span>
</router-link>
<router-link to="/people" class="gs-nav-item" active-class="active">
<img class="gs-nav-icon" src="/icons/People.png" alt="">
<span>People</span>
<router-link to="/face" class="gs-nav-item" active-class="active">
<svg class="gs-nav-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="12" cy="10" r="4"/>
<path d="M6 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2"/>
</svg>
<span>Face</span>
</router-link>
<router-link to="/admin" class="gs-nav-item" active-class="active">
<svg class="gs-nav-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
<span>Admin</span>
<span>{{ t('nav.admin') }}</span>
</router-link>
<router-link to="/client" class="gs-nav-item" active-class="active">
<svg class="gs-nav-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
<span>Client</span>
<span>{{ t('nav.client') }}</span>
</router-link>
</nav>
<div class="gs-divider"></div>
<div class="gs-divider"></div>
<div class="gs-footer">
<div class="gs-theme-switcher">
<button class="gs-theme-btn active">☀️</button>
<button class="gs-theme-btn">🌙</button>
<button class="gs-theme-btn">🌗</button>
<div class="gs-lang-switcher">
<select v-model="currentLanguage" @change="onChangeLanguage" class="gs-lang-select">
<option value="en">English</option>
<option value="zh-TW">繁體中文</option>
<option value="auto">Auto Detect</option>
</select>
</div>
<div class="gs-account-row">
<template v-if="loggedInUser">
<span class="gs-account-name">{{ loggedInUser }}</span>
<button class="gs-logout-btn" @click="handleLogout" title="Logout">⏻ {{ t('common.logout') }}</button>
</template>
<template v-else>
<router-link to="/login" class="gs-login-link">{{ t('common.login') }}</router-link>
</template>
</div>
<div class="gs-account"><span class="gs-account-name">Demo</span></div>
</div>
</aside>
<main class="ms-main">
<div class="ms-content"><router-view /></div>
<div class="ms-divider" @mousedown="startDrag"></div>
<main class="ms-main">
<router-view />
</main>
<AgentTip v-if="loggedInUser" />
</div>
</template>
@@ -104,22 +171,28 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown))
<style scoped>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'DM Sans', 'Noto Sans TC', -apple-system, BlinkMacSystemFont, sans-serif; background: #fff; color: #202124; }
#app { display: flex; max-width: 1400px; margin: 0 auto; padding: 28px; gap: 28px; min-height: 100vh; box-sizing: border-box; align-items: stretch; }
.ms-side { width: 240px; min-width: 240px; flex-shrink: 0; background: #fff; border-radius: 18px; padding: 18px 14px; box-shadow: 0 16px 38px rgba(0,0,0,0.10), 0 2px 8px rgba(0,0,0,0.06); display: flex; flex-direction: column; position: relative; height: calc(100vh - 125px); align-self: flex-start; overflow: visible; }
.gs-logo { font-size: 15px; font-weight: 600; color: #202124; padding: 8px 12px 16px 12px; flex-shrink: 0; }
#app, .ms-app { background: var(--background-color) !important; display: flex; max-width: 1400px; margin: 0 auto; padding: 28px; gap: 0; min-height: 100vh; box-sizing: border-box; align-items: stretch; }
.ms-side { width: 240px; min-width: 240px; flex-shrink: 0; background: var(--card-background) !important; border-radius: 18px; padding: 18px 14px; box-shadow: var(--shadow); display: flex; flex-direction: column; position: relative; height: calc(100vh - 125px); align-self: flex-start; overflow: visible; }
.ms-divider { width: 28px; flex-shrink: 0; cursor: col-resize; display: flex; align-items: center; justify-content: center; }
.ms-divider::after { content: ''; width: 4px; height: 40px; background: var(--border-color); border-radius: 2px; transition: background 0.2s, height 0.2s; }
.ms-divider:hover::after { background: var(--primary-color); height: 60px; }
.gs-logo { font-size: 15px; font-weight: 600; color: var(--text-primary); padding: 8px 12px 16px 12px; flex-shrink: 0; }
.gs-nav { display: flex; flex-direction: column; gap: 4px; flex-shrink: 0; }
.gs-nav-item { display: flex; align-items: center; gap: 12px; height: 48px; padding: 0 12px; border-radius: 10px; text-decoration: none; color: #3c4043; font-weight: 600; font-size: 18px; line-height: 1; background: transparent; box-sizing: border-box; }
.gs-nav-item:hover { background: var(--ms-accent-soft); color: var(--ms-accent-text); }
.gs-nav-item.active { background: var(--ms-accent-soft) !important; color: var(--ms-accent-text) !important; font-weight: 600; }
.gs-nav-icon { width: 24px; height: 24px; object-fit: contain; flex-shrink: 0; }
.gs-divider { height: 1px; background: #eee; margin: 14px 8px; flex-shrink: 0; }
.gs-footer { margin-top: auto; padding: 10px 12px; border-radius: 12px; display: flex; align-items: center; gap: 10px; background: #f8f9fa; flex-shrink: 0; }
.gs-theme-switcher { display: flex; gap: 5px; }
.gs-theme-btn { width: 14px; height: 14px; border-radius: 50%; border: 1px solid rgba(0,0,0,.08); background: #f3f3f3; cursor: pointer; padding: 0; }
.gs-theme-btn:hover { transform: scale(1.08); }
.gs-theme-btn.active { box-shadow: 0 0 0 2px #fff, 0 0 0 3px rgba(0,0,0,.16); }
.gs-account-name { font-size: 12px; color: #5f6368; }
.ms-main { flex: 1; min-width: 0; background: #fff; border-radius: 18px; padding: 36px 42px; overflow-y: auto; overflow-x: hidden; }
.gs-nav-item { display: flex; align-items: center; gap: 12px; height: 48px; padding: 0 12px; border-radius: 10px; text-decoration: none; color: var(--text-primary) !important; font-weight: 500; font-size: 16px; line-height: 1; background: transparent !important; box-sizing: border-box; transition: all 0.15s; }
.gs-nav-item:hover { background: var(--hover-background) !important; color: var(--primary-color) !important; }
.gs-nav-item.active { background: var(--hover-background) !important; color: var(--primary-color) !important; font-weight: 600; }
.gs-nav-icon { width: 22px; height: 22px; flex-shrink: 0; }
.gs-divider { height: 1px; background: var(--border-light); margin: 14px 8px; flex-shrink: 0; }
.gs-footer { margin-top: auto; padding: 10px 8px; border-radius: 12px; background: var(--hover-background); flex-shrink: 0; display: flex; flex-direction: column; gap: 8px; }
.gs-lang-switcher { width: 100%; }
.gs-lang-select { width: 100%; padding: 6px 8px; border: 1px solid var(--border-color); border-radius: 6px; background: var(--card-background); font-size: 12px; font-family: inherit; color: var(--text-secondary); cursor: pointer; outline: none; }
.gs-lang-select:focus { border-color: var(--text-primary); }
.gs-account-row { display: flex; align-items: center; justify-content: center; gap: 8px; }
.gs-account-name { font-size: 12px; color: var(--text-secondary); }
.gs-logout-btn { background: none; border: 1px solid var(--border-color); cursor: pointer; font-size: 13px; color: var(--text-secondary); padding: 3px 8px; border-radius: 6px; transition: all .15s; margin-left: 6px; }
.gs-logout-btn:hover { color: var(--danger-color); border-color: var(--danger-color); background: var(--danger-background); }
.gs-login-link { font-size: 12px; color: var(--text-secondary); text-decoration: none; }
.gs-login-link:hover { color: var(--text-primary); }
.ms-main { flex: 1; min-width: 0; background: var(--card-background) !important; border-radius: 18px; padding: 36px 42px; overflow-y: auto; overflow-x: hidden; }
.ms-content { max-width: 1200px; }
</style>

View File

@@ -2,5 +2,10 @@ export const isTauri = typeof window !== 'undefined' && !!(window as any).__TAUR
export function getApiBase(): string {
if (isTauri) return 'http://localhost:8888'
return localStorage.getItem('proxy_url') || window.location.origin
const stored = localStorage.getItem('proxy_url')
if (stored) return stored
const hostname = typeof window !== 'undefined' ? window.location.hostname : ''
const isLocal = hostname === 'localhost' || hostname === '127.0.0.1'
if (isLocal) return '' // 走 Vite proxy → Rust proxy (8888)
return window.location.origin
}

View File

@@ -1,6 +1,16 @@
import { invoke } from '@tauri-apps/api/core'
import { isTauri, getApiBase } from './config'
// Detect base URL: use current origin when remote, fallback to localStorage or localhost
function detectBaseUrl(): string {
const hostname = typeof window !== 'undefined' ? window.location.hostname : ''
const isLocal = hostname === 'localhost' || hostname === '127.0.0.1'
if (!isLocal && typeof window !== 'undefined') {
return window.location.origin
}
return localStorage.getItem('markbase_url') || 'http://localhost:11438'
}
// API proxy: Tauri IPC or HTTP fetch
export async function apiCall(cmd: string, args: Record<string, any>): Promise<any> {
if (cmd === 'get_video_stream') {
@@ -31,6 +41,41 @@ export async function apiCall(cmd: string, args: Record<string, any>): Promise<a
}
return httpCall('update_identity', { uuid: args.uuid, metadataJson: JSON.stringify(metadata) })
}
// run_identity_agent 沒有 Tauri command,直接走 HTTP 避免 invoke error
if (cmd === 'run_identity_agent') {
const data = await httpCall(cmd, args)
return transformResponse(cmd, data)
}
// run_identity_for_seed 也沒有 Tauri command
if (cmd === 'run_identity_for_seed') {
const data = await httpCall(cmd, args)
return transformResponse(cmd, data)
}
// run_cluster_agent 也沒有 Tauri command
if (cmd === 'run_cluster_agent') {
const data = await httpCall(cmd, args)
return transformResponse(cmd, data)
}
// get_cluster_results 也沒有 Tauri command
if (cmd === 'get_cluster_results') {
const data = await httpCall(cmd, args)
return transformResponse(cmd, data)
}
// create_pending_identity 也沒有 Tauri command
if (cmd === 'create_pending_identity') {
const data = await httpCall(cmd, args)
return transformResponse(cmd, data)
}
// bind_speakers 也沒有 Tauri command
if (cmd === 'bind_speakers') {
const data = await httpCall(cmd, args)
return transformResponse(cmd, data)
}
// get_processor_json 也沒有 Tauri command
if (cmd === 'get_processor_json') {
const data = await httpCall(cmd, args)
return transformResponse(cmd, data)
}
if (isTauri) {
try {
const data = await invoke(cmd, args)
@@ -47,36 +92,90 @@ export async function apiCall(cmd: string, args: Record<string, any>): Promise<a
return transformResponse(cmd, data)
}
const DEFAULT_TIMEOUT = 30000
export enum ApiErrorType {
NETWORK = 'network',
TIMEOUT = 'timeout',
SERVER = 'server',
VALIDATION = 'validation',
NOT_FOUND = 'not_found',
}
export class ApiError extends Error {
type: ApiErrorType
status?: number
constructor(type: ApiErrorType, message: string, status?: number) {
super(message)
this.type = type
this.status = status
}
}
async function httpCall(cmd: string, args: Record<string, any>, retries = 3): Promise<any> {
const base = getApiBase()
const { url, method, body } = buildHttpRequest(cmd, args)
const fullUrl = `${base}${url}`
const fullUrl = url.startsWith('http://') || url.startsWith('https://') ? url : `${base}${url}`
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT)
let response: Response | null = null
for (let i = 0; i < retries; i++) {
try {
response = await fetch(fullUrl, {
method,
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined,
})
if (response.ok || response.status < 500) break
} catch (e: any) {
if (i < retries - 1) {
await new Promise(r => setTimeout(r, 1000 * (i + 1)))
continue
try {
for (let i = 0; i < retries; i++) {
try {
response = await fetch(fullUrl, {
method,
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
})
if (response.ok) break
if (response.status >= 500 && i < retries - 1) {
await new Promise(r => setTimeout(r, 1000 * Math.pow(2, i)))
response = null
continue
}
break
} catch (e: any) {
if (e.name === 'AbortError') {
throw new ApiError(ApiErrorType.TIMEOUT, `Request timeout: ${cmd}`)
}
if (i < retries - 1) {
await new Promise(r => setTimeout(r, 1000 * Math.pow(2, i)))
continue
}
throw new ApiError(ApiErrorType.NETWORK, `Network error: ${e.message}`)
}
throw e
}
} finally {
clearTimeout(timeout)
}
if (!response) throw new ApiError(ApiErrorType.NETWORK, 'No response')
if (!response.ok) {
const status = response.status
const errorText = await response.text().catch(() => 'Unknown error')
if (status === 404) {
throw new ApiError(ApiErrorType.NOT_FOUND, errorText, status)
}
if (status >= 400 && status < 500) {
throw new ApiError(ApiErrorType.VALIDATION, errorText, status)
}
throw new ApiError(ApiErrorType.SERVER, errorText, status)
}
if (response.status === 204) {
return { success: true }
}
if (!response) throw new Error('No response')
const contentType = response.headers.get('content-type') || ''
if (contentType.includes('image/') || contentType.includes('video/') || contentType.includes('octet-stream')) {
const buffer = await response.arrayBuffer()
const bytes = new Uint8Array(buffer)
if (cmd === 'get_identity_profile' || cmd === 'get_thumbnail' || cmd === 'get_face_thumbnail') {
if (cmd === 'get_identity_profile' || cmd === 'get_thumbnail' || cmd === 'get_face_thumbnail' || cmd === 'get_file_thumbnail_by_path') {
const ext = contentType.includes('png') ? 'png' : 'jpeg'
const blob = new Blob([bytes], { type: `image/${ext}` })
return new Promise<string>((resolve, reject) => {
@@ -115,6 +214,12 @@ function buildHttpRequest(cmd: string, args: Record<string, any>): { url: string
case 'get_people': {
return { url: `/api/v1/identities?page=${a.page || 1}&per_page=${a.perPage || 100}`, method: 'GET' }
}
case 'get_file_identities': {
return { url: `/api/v1/file/${a.fileUuid}/identities?page=${a.page || 1}&page_size=${a.pageSize || 50}`, method: 'GET' }
}
case 'get_identity_files': {
return { url: `/api/v1/identity/${a.uuid}/files?page=${a.page || 1}&page_size=${a.pageSize || 20}`, method: 'GET' }
}
case 'get_faces': {
return { url: `/api/v1/identity/${a.uuid}/faces?page_size=${a.perPage || 100}`, method: 'GET' }
}
@@ -124,7 +229,9 @@ function buildHttpRequest(cmd: string, args: Record<string, any>): { url: string
return { url, method: 'GET' }
}
case 'get_face_candidates': {
return { url: `/api/v1/faces/candidates?page=${a.page || 1}&page_size=${a.perPage || 100}`, method: 'GET' }
let url = `/api/v1/faces/candidates?page=${a.page || 1}&page_size=${a.perPage || 100}`
if (a.fileUuid) url += `&file_uuid=${a.fileUuid}`
return { url, method: 'GET' }
}
case 'get_unassigned_traces': {
let url = `/api/v1/traces/unassigned?page=${a.page || 1}&page_size=${a.perPage || 20}`
@@ -139,6 +246,12 @@ function buildHttpRequest(cmd: string, args: Record<string, any>): { url: string
}
// --- Search APIs ---
case 'search_keyword': {
return { url: '/api/v1/search/keyword', method: 'POST', body: { query: a.query, limit: a.limit || 20 } }
}
case 'search_semantic': {
return { url: '/api/v1/search/semantic', method: 'POST', body: { query: a.query, limit: a.limit || 20 } }
}
case 'search_llm_smart': {
return { url: '/api/v1/search/llm-smart', method: 'POST', body: { query: a.query, limit: a.limit || 20 } }
}
@@ -166,12 +279,18 @@ function buildHttpRequest(cmd: string, args: Record<string, any>): { url: string
if (a.bboxH != null) url += `&bbox_h=${a.bboxH}`
return { url, method: 'GET' }
}
case 'get_file_thumbnail_by_path': {
return { url: `/api/v1/file/thumbnail?path=${encodeURIComponent(a.path)}&frame=${a.frame || 30}`, method: 'GET' }
}
// --- Video API ---
case 'get_video_stream': {
let url = `/api/v1/file/${a.uuid}/video?start_time=${a.startTime}&end_time=${a.endTime}`
if (a.startFrame != null) url += `&start_frame=${a.startFrame}`
if (a.endFrame != null) url += `&end_frame=${a.endFrame}`
let url = `/api/v1/file/${a.uuid}/video`
const params: string[] = []
if (a.startFrame != null) params.push(`start_frame=${a.startFrame}`)
if (a.endFrame != null) params.push(`end_frame=${a.endFrame}`)
if (a.original === true) params.push('original=true')
if (params.length) url += '?' + params.join('&')
return { url, method: 'GET' }
}
@@ -195,6 +314,14 @@ function buildHttpRequest(cmd: string, args: Record<string, any>): { url: string
case 'upload_profile_image': {
return { url: `/api/v1/identity/${a.uuid}/profile-image`, method: 'POST' }
}
case 'set_profile_from_face': {
const body: any = { file_uuid: a.fileUuid }
if (a.faceId) body.face_id = a.faceId
if (a.id) body.id = a.id
if (a.traceId) body.trace_id = a.traceId
if (a.frameNumber) body.frame_number = a.frameNumber
return { url: `/api/v1/identity/${a.uuid}/profile-image/from-face`, method: 'POST', body }
}
case 'delete_identity': {
return { url: `/api/v1/identity/${a.uuid}`, method: 'DELETE' }
}
@@ -208,6 +335,30 @@ function buildHttpRequest(cmd: string, args: Record<string, any>): { url: string
case 'merge_identities': {
return { url: `/api/v1/identity/${a.uuid}/mergeinto`, method: 'POST', body: { into_uuid: a.intoUuid } }
}
// --- Trace Management ---
case 'list_traces': {
return { url: `/api/v1/file/${a.file_uuid}/traces`, method: 'POST' }
}
case 'get_trace_profile': {
const params = new URLSearchParams()
if (a.file_uuid) params.set('file_uuid', a.file_uuid)
if (a.trace_id) params.set('trace_id', String(a.trace_id))
return { url: `/api/v1/trace-profile?${params.toString()}`, method: 'GET' }
}
case 'update_trace_profile': {
return { url: '/api/v1/trace-profile', method: 'PUT', body: a.body }
}
case 'delete_trace': {
const body: any = { hard_delete: a.hard_delete ?? true }
return { url: `/api/v1/file/${a.file_uuid}/trace/${a.trace_id}`, method: 'DELETE', body }
}
case 'restore_trace': {
return { url: `/api/v1/file/${a.file_uuid}/trace/${a.trace_id}/restore`, method: 'POST' }
}
case 'merge_trace': {
return { url: `/api/v1/file/${a.file_uuid}/trace/${a.source_id}/merge/${a.target_id}`, method: 'POST' }
}
case 'bind_face': {
const bindBody: any = { file_uuid: a.fileUuid }
if (a.faceId) bindBody.face_id = a.faceId
@@ -222,6 +373,11 @@ function buildHttpRequest(cmd: string, args: Record<string, any>): { url: string
return { url: `/api/v1/identity/${a.uuid}/unbind`, method: 'POST', body: unbindBody }
}
// --- Trace Operations ---
case 'merge_traces': {
return { url: `/api/v1/file/${a.file_uuid}/trace/${a.source_trace_id}/merge/${a.target_trace_id}`, method: 'POST' }
}
// --- Identity Undo/Redo ---
case 'identity_undo': {
const body: any = {}
@@ -277,12 +433,57 @@ function buildHttpRequest(cmd: string, args: Record<string, any>): { url: string
}
// --- File Operations ---
case 'get_health': {
return { url: '/api/v1/health', method: 'GET' }
}
case 'list_jobs': {
return { url: '/api/v1/jobs', method: 'POST', body: {} }
}
case 'register_file': {
return { url: '/api/v1/files/register', method: 'POST', body: { file_path: a.filePath } }
}
case 'process_file': {
return { url: `/api/v1/file/${a.fileUuid}/process`, method: 'POST', body: { processors: a.processors } }
}
case 'run_identity_agent': {
return { url: `/api/v1/file/${a.fileUuid}/identity-agent`, method: 'POST' }
}
case 'run_identity_for_seed': {
return { url: '/api/v1/agents/identity/run-for-seed', method: 'POST', body: { file_uuid: a.fileUuid, identity_uuid: a.identityUuid } }
}
case 'get_identity_matches': {
return { url: `/api/v1/identity-matches?file_hash=${a.fileHash}`, method: 'GET' }
}
case 'run_cluster_agent': {
return { url: `/api/v1/file/${a.fileUuid}/cluster-agent`, method: 'POST' }
}
case 'get_cluster_results': {
return { url: `/api/v1/cluster-results?file_hash=${a.fileHash}`, method: 'GET' }
}
case 'get_trace_profile': {
return { url: `/api/v1/trace-profile?file_uuid=${a.fileUuid}&trace_id=${a.traceId}`, method: 'GET' }
}
case 'update_trace_profile': {
return { url: '/api/v1/trace-profile', method: 'PUT', body: { file_uuid: a.fileUuid, trace_id: a.traceId, label: a.label, key_frame: a.keyFrame, key_face: a.keyFace, aliases: a.aliases } }
}
case 'update_trace_profile_group': {
return { url: '/api/v1/trace-profile/group', method: 'PUT', body: { file_uuid: a.fileUuid, trace_ids: a.traceIds, name: a.name, key_frame: a.keyFrame, key_face: a.keyFace } }
}
case 'get_file_profile': {
return { url: `/api/v1/file-profile?file_uuid=${a.fileUuid}`, method: 'GET' }
}
case 'update_file_profile': {
return { url: '/api/v1/file-profile', method: 'PUT', body: { file_uuid: a.fileUuid, file_path: a.filePath, file_name: a.fileName } }
}
case 'get_processor_json': {
return { url: `/api/v1/processor-json?file_hash=${a.fileHash}&processor=${a.processor}`, method: 'GET' }
}
case 'create_pending_identity': {
return { url: '/api/v1/identities/pending', method: 'POST', body: { file_uuid: a.fileUuid, trace_id: a.traceId, face_id: a.faceId, cluster_id: a.clusterId, trace_count: a.traceCount } }
}
case 'bind_speakers': {
return { url: `/api/v1/file/${a.fileUuid}/bind-speakers`, method: 'POST' }
}
case 'unregister_file': {
const body: any = { file_uuid: a.fileUuid }
if (a.deleteOutputFiles != null) body.delete_output_files = a.deleteOutputFiles
@@ -305,6 +506,12 @@ function buildHttpRequest(cmd: string, args: Record<string, any>): { url: string
case 'get_progress': {
return { url: `/api/v1/progress/${a.fileUuid}`, method: 'POST', body: {} }
}
case 'get_pipeline_stats': {
return { url: `/api/v1/stats/pipeline/${a.fileUuid}`, method: 'GET' }
}
case 'get_file_stats': {
return { url: `/api/v1/stats/file/${a.fileUuid}`, method: 'GET' }
}
// --- Search History ---
case 'get_search_history': {
@@ -335,6 +542,46 @@ function buildHttpRequest(cmd: string, args: Record<string, any>): { url: string
return { url: `/api/v1/bookmarks/${a.id}`, method: 'DELETE' }
}
// --- Service Management ---
case 'stop_service': {
const mbUrl = detectBaseUrl()
return { url: `${mbUrl}/api/v2/service/stop`, method: 'POST', body: { name: a.name } }
}
case 'start_service': {
const mbUrl = detectBaseUrl()
return { url: `${mbUrl}/api/v2/service/start`, method: 'POST', body: { name: a.name } }
}
// --- Admin Panel ---
case 'get_system_stats':
return { url: '/api/v2/admin/system-stats', method: 'GET' }
case 'list_admin_users':
return { url: '/api/v2/admin/users', method: 'GET' }
case 'list_shares':
return { url: '/api/v2/admin/shares', method: 'GET' }
// --- Pose & Appearance ---
case 'get_pose': {
let url = `/api/v1/file/${a.fileUuid}/pose?frame=${a.frame}`
if (a.bboxX != null) url += `&bbox_x=${a.bboxX}`
if (a.bboxY != null) url += `&bbox_y=${a.bboxY}`
if (a.bboxW != null) url += `&bbox_w=${a.bboxW}`
if (a.bboxH != null) url += `&bbox_h=${a.bboxH}`
return { url, method: 'GET' }
}
case 'get_appearance': {
let url = `/api/v1/file/${a.fileUuid}/appearance?frame=${a.frame}`
if (a.bboxX != null) url += `&bbox_x=${a.bboxX}`
if (a.bboxY != null) url += `&bbox_y=${a.bboxY}`
if (a.bboxW != null) url += `&bbox_w=${a.bboxW}`
if (a.bboxH != null) url += `&bbox_h=${a.bboxH}`
return { url, method: 'GET' }
}
case 'sync_file_status': {
return { url: `/api/v1/file/${a.fileUuid}/sync-status`, method: 'POST' }
}
default:
throw new Error(`Unknown command: ${cmd}`)
}
@@ -367,8 +614,13 @@ export function transformResponse(cmd: string, data: any): any {
status: p.status || p.metadata?.status || 'pending',
metadata: p.metadata || {},
file_uuids: p.file_uuids || [],
source: p.source || '',
tmdb_id: p.tmdb_id ?? null,
}))
}
case 'get_file_identities': {
return data
}
case 'get_faces': {
const faces = data.data || data.faces || data || []
return faces.map((f: any) => ({
@@ -475,6 +727,34 @@ case 'get_unassigned_traces': {
}
})
}
case 'search_keyword': {
const results = data.results || data.data || data || []
return results.map((r: any) => ({
file_uuid: r.file_uuid || '',
start_time: r.start_time ?? 0,
end_time: r.end_time ?? 0,
start_frame: r.start_frame ?? 0,
end_frame: r.end_frame ?? 0,
summary: r.summary || r.raw_text || '',
similarity: r.similarity || 0,
file_name: r.file_name || null,
source_type: r.source_type || null,
}))
}
case 'search_semantic': {
const results = data.results || data.data || data || []
return results.map((r: any) => ({
file_uuid: r.file_uuid || '',
start_time: r.start_time ?? 0,
end_time: r.end_time ?? 0,
start_frame: r.start_frame ?? 0,
end_frame: r.end_frame ?? 0,
summary: r.summary || r.raw_text || '',
similarity: r.similarity || 0,
file_name: r.file_name || null,
source_type: r.source_type || null,
}))
}
case 'search_identities': {
const results = data.results || data.data || data || []
return results.map((r: any) => ({
@@ -505,6 +785,48 @@ case 'get_unassigned_traces': {
message: data.message ?? '',
}
}
case 'run_identity_agent': {
return {
success: data.success ?? false,
file_uuid: data.file_uuid ?? '',
message: data.message ?? '',
}
}
case 'run_cluster_agent': {
return {
success: data.success ?? false,
file_uuid: data.file_uuid ?? '',
message: data.message ?? '',
clusters: data.clusters ?? 0,
total_traces: data.total_traces ?? 0,
}
}
case 'get_cluster_results': {
return data || null
}
case 'get_trace_profile': { return data || null }
case 'update_trace_profile': { return data || { success: true } }
case 'update_trace_profile_group': { return data || { success: true } }
case 'get_file_profile': { return data || null }
case 'update_file_profile': { return data || { success: true } }
case 'get_processor_json': {
return data || null
}
case 'bind_speakers': {
return {
success: data.success ?? false,
bindings: data.bindings ?? 0,
message: data.message ?? '',
}
}
case 'create_pending_identity': {
return {
success: data.success ?? false,
identity_uuid: data.identity_uuid ?? '',
identity_id: data.identity_id ?? 0,
message: data.message ?? '',
}
}
case 'unregister_file':
case 'ingest_file':
case 'checkout_file': {
@@ -518,6 +840,7 @@ case 'get_unassigned_traces': {
case 'upload_profile_image':
case 'get_identity_profile':
case 'get_face_thumbnail':
case 'get_file_thumbnail_by_path':
case 'get_search_history':
case 'save_search_history':
case 'rename_search_history':
@@ -544,6 +867,13 @@ case 'get_unassigned_traces': {
name: respData.name ?? '',
}
}
case 'get_pipeline_stats':
case 'get_file_stats': {
return data
}
case 'get_pose':
case 'get_appearance':
return data
default:
return data
}

View File

@@ -1,15 +1,62 @@
html, body {
background-color: var(--background-color) !important;
color: var(--text-primary);
font-family: 'DM Sans', 'Noto Sans TC', -apple-system, BlinkMacSystemFont, sans-serif;
margin: 0;
}
:root {
--primary-color: #2563eb;
--primary-color-rgb: 37, 99, 235;
--secondary-color: #64748b;
--success-color: #22c55e;
--warning-color: #f59e0b;
--error-color: #ef4444;
--background-color: #f8fafc;
--card-background: #ffffff;
--card-background: rgba(255, 255, 255, 0.88);
--text-primary: #1e293b;
--text-secondary: #64748b;
--border-color: rgba(0, 0, 0, 0.08);
--border-light: rgba(0, 0, 0, 0.05);
--border-dark: rgba(0, 0, 0, 0.12);
--hover-background: rgba(0, 0, 0, 0.04);
--input-background: rgba(255, 255, 255, 0.95);
--input-border: rgba(0, 0, 0, 0.1);
--danger-color: #d93025;
--danger-hover: #c5221f;
--danger-background: rgba(217, 48, 37, 0.08);
--success-text: #1e8e3e;
--warning-text: #e37400;
--muted-text: #9aa0a6;
--border-radius: 8px;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
--shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
[data-theme="dark"] {
--primary-color: #3b82f6;
--primary-color-rgb: 59, 130, 246;
--secondary-color: #94a3b8;
--success-color: #22c55e;
--warning-color: #f59e0b;
--error-color: #ef4444;
--background-color: #0f172a;
--card-background: rgba(30, 41, 59, 0.92);
--text-primary: #e2e8f0;
--text-secondary: #94a3b8;
--border-color: rgba(51, 65, 85, 0.5);
--border-light: rgba(51, 65, 85, 0.3);
--border-dark: #475569;
--hover-background: rgba(51, 65, 85, 0.4);
--input-background: rgba(30, 41, 59, 0.95);
--input-border: rgba(71, 85, 105, 0.5);
--danger-color: #ef4444;
--danger-hover: #dc2626;
--danger-background: rgba(69, 10, 10, 0.4);
--success-text: #22c55e;
--warning-text: #f59e0b;
--muted-text: #64748b;
--border-radius: 8px;
--shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
}
body {

View File

@@ -1,5 +1,5 @@
<template>
<div v-if="visible" class="ms-modal-overlay show" @click.self="close">
<div v-if="visible" class="ms-modal-overlay show" :style="{ zIndex: zIndexValue }" @click.self="close">
<div class="ms-modal ms-modal-video">
<div class="ms-modal-video-header">
<h3 class="ms-modal-video-title">{{ title }}</h3>
@@ -9,9 +9,9 @@
<!-- Mode toggle (only show when not simple and has traces/segments) -->
<div v-if="!simple && (allTraces.length || mergedSegments.length)" class="ms-video-mode-toggle">
<button :class="{ active: playMode === 'trace' }" @click="switchMode('trace')">追蹤</button>
<button :class="{ active: playMode === 'segment' }" @click="switchMode('segment')">片段</button>
<button :class="{ active: playMode === 'continuous' }" @click="switchMode('continuous')">連續</button>
<button :class="{ active: playMode === 'trace' }" @click="switchMode('trace')">{{ t('person.mode_trace', 'Trace') }}</button>
<button :class="{ active: playMode === 'segment' }" @click="switchMode('segment')">{{ t('person.mode_segment', 'Segment') }}</button>
<button :class="{ active: playMode === 'continuous' }" @click="switchMode('continuous')">{{ t('person.mode_continuous', 'Continuous') }}</button>
</div>
<div v-if="videoError" class="ms-video-loading">
@@ -24,7 +24,37 @@
<video v-if="videoSrc && !videoLoading" ref="videoEl" :src="videoSrc" class="video" controls autoplay playsinline preload="auto" @loadedmetadata="onLoaded" @timeupdate="onTimeUpdate" @error="onVideoError"></video>
<!-- Timeline bar -->
<!-- Main Timeline bar -->
<div class="ms-video-timeline-wrap" v-if="!videoLoading">
<div class="ms-video-tl-bar ms-video-tl-main" @click="onMainTimelineClick">
<div class="ms-video-tl-progress" :style="{ width: (currentTime / duration * 100) + '%' }"></div>
<div class="ms-video-tl-position" :style="{ left: (currentTime / duration * 100) + '%' }"></div>
</div>
<div class="ms-video-tl-labels">
<span>0:00</span>
<span>{{ formatTime(duration) }}</span>
</div>
</div>
<!-- Mark Timelines (Advanced only) -->
<div class="ms-video-mark-timelines" v-if="isAdvanced && !videoLoading && visibleTags.length">
<div v-for="tag in visibleTags" :key="tag" class="ms-video-mark-tl-row">
<span class="ms-video-mark-tl-label">{{ tag }}</span>
<div class="ms-video-mark-tl-bar">
<div
v-for="mark in marksByTag(tag)"
:key="mark.id"
class="ms-video-mark-tl-mark"
:class="{ 'ms-mark-active': isMarkActive(mark) }"
:style="getMarkStyle(mark)"
@click="goToMark(mark)"
:title="`#${mark.id} ${mark.note || ''}`"
></div>
</div>
</div>
</div>
<!-- Original segment timeline (keep for traces/segments mode) -->
<div class="ms-video-timeline-wrap" v-if="!simple && timelineMarkers.length && !videoLoading">
<div class="ms-video-tl-bar">
<div
@@ -37,6 +67,9 @@
>
<span class="ms-video-tl-tip">{{ formatTime(dot.start) }}</span>
</div>
<div v-if="positionPct !== null" class="ms-video-tl-position" :style="{ left: positionPct + '%' }">
<span class="ms-video-tl-pos-tip">{{ formatTime(currentTime) }}</span>
</div>
</div>
<div class="ms-video-tl-labels">
<span>{{ formatTime(tlStart) }}</span>
@@ -44,7 +77,63 @@
</div>
</div>
<!-- Mark Controls (Advanced only) -->
<div class="ms-video-mark-controls" v-if="isAdvanced && !videoLoading">
<button class="ms-fm-btn ms-mark-btn" @click="startMark" :class="{ active: markingStart !== null }">
{{ markingStart !== null ? `F${markingStart} → ...` : '+ Mark' }}
</button>
<button v-if="markingStart !== null" class="ms-fm-btn ms-mark-btn ms-mark-end" @click="endMark">End F{{ currentFrame }}</button>
<button v-if="markingStart !== null" class="ms-fm-btn ms-mark-btn ms-mark-cancel" @click="cancelMark">✕</button>
<!-- Tag filter -->
<select v-model="tagFilter" class="ms-mark-filter">
<option value="">All Tags</option>
<option v-for="tag in allTags" :key="tag" :value="tag">{{ tag }}</option>
</select>
<!-- Toggle marks panel -->
<button class="ms-fm-btn ms-mark-panel-toggle" @click="showMarksPanel = !showMarksPanel">
Marks ({{ filteredMarks.length }})
</button>
</div>
<!-- Marks Panel (Advanced only) -->
<div class="ms-video-marks-panel" v-if="isAdvanced && showMarksPanel && !videoLoading">
<div class="ms-marks-header">
<span>Marks</span>
<button class="ms-marks-close" @click="showMarksPanel = false">✕</button>
</div>
<div class="ms-marks-list">
<div v-for="mark in filteredMarks" :key="mark.id" class="ms-mark-item" @click="goToMark(mark)">
<span class="ms-mark-id">#{{ mark.id }}</span>
<span class="ms-mark-tag" :style="{ background: getTagColor(mark.tag) }">{{ mark.tag }}</span>
<span class="ms-mark-range">F{{ mark.startFrame }}{{ mark.endFrame ? '-' + mark.endFrame : '' }}</span>
<span class="ms-mark-note">{{ mark.note || '—' }}</span>
<button class="ms-mark-del" @click.stop="deleteMark(mark.id)">✕</button>
</div>
<div v-if="filteredMarks.length === 0" class="ms-marks-empty">No marks</div>
</div>
<!-- Add mark form -->
<div class="ms-mark-add-form" v-if="showAddMarkForm">
<input v-model="newMarkTag" placeholder="Tag" class="ms-mark-input" list="existing-tags" />
<datalist id="existing-tags">
<option v-for="tag in allTags" :key="tag" :value="tag" />
</datalist>
<input v-model="newMarkNote" placeholder="Note (optional)" class="ms-mark-input" />
<button class="ms-fm-btn" @click="saveMark">Save</button>
<button class="ms-fm-btn" @click="showAddMarkForm = false">Cancel</button>
</div>
</div>
<div class="ms-video-nav" v-if="!videoLoading">
<!-- Frame seek controls (Advanced only) -->
<div v-if="isAdvanced" class="ms-video-frame-seek">
<span class="ms-video-frame-label">Frame:</span>
<input type="number" v-model.number="seekFrame" class="ms-video-frame-input" :placeholder="currentFrame.toString()" @keyup.enter="goToFrame" @focus="seekFrame = currentFrame" />
<button class="ms-fm-btn ms-video-frame-btn" @click="goToFrame">Go</button>
<span class="ms-video-frame-current">F{{ currentFrame }} / {{ totalFrames }}</span>
</div>
<div v-if="!simple && (allTraces.length || mergedSegments.length)" style="display:flex;align-items:center;gap:10px;flex:1;">
<button class="ms-fm-btn ms-video-nav-btn" @click="prevItem" :disabled="!canPrev">&larr; 上一個</button>
<span class="ms-video-seg-info2">{{ currentItemIdx + 1 }} / {{ totalItems }}</span>
@@ -59,6 +148,14 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { apiCall } from '@/api'
import { useI18n } from 'vue-i18n'
import { getNextZIndex } from '@/composables/useZIndex'
import { useAdvancedMode } from '@/stores/advancedMode'
const { t } = useI18n()
const { isAdvanced } = useAdvancedMode()
const zIndexValue = ref(getNextZIndex())
const props = withDefaults(defineProps<{
fileUuid: string
@@ -70,12 +167,14 @@ const props = withDefaults(defineProps<{
initialSegmentIdx?: number
title?: string
simple?: boolean
useOriginal?: boolean
}>(), {
allTraces: () => [],
mergedSegments: () => [],
initialTraceIdx: 0,
initialSegmentIdx: 0,
simple: false,
useOriginal: false,
})
const emit = defineEmits(['close', 'trace-change', 'segment-change'])
@@ -91,6 +190,143 @@ const videoError = ref('')
const currentTraceIdx = ref(props.initialTraceIdx ?? 0)
const currentSegmentIdx = ref(props.initialSegmentIdx ?? 0)
const curFileUuid = ref(props.fileUuid)
const videoFps = ref(30)
const seekFrame = ref<number | null>(null)
const currentFrame = computed(() => Math.round(currentTime.value * videoFps.value))
const totalFrames = computed(() => Math.round(duration.value * videoFps.value))
// Mark system
interface Mark {
id: number
startFrame: number
endFrame?: number
tag: string
note?: string
source?: 'user' | 'face' | 'ocr' | 'auto'
}
const marks = ref<Mark[]>([])
const markingStart = ref<number | null>(null)
const showMarksPanel = ref(false)
const showAddMarkForm = ref(false)
const tagFilter = ref('')
const newMarkTag = ref('')
const newMarkNote = ref('')
let nextMarkId = 1
// Convert face traces to marks
function loadTracesAsMarks() {
if (!props.allTraces.length) return
for (const trace of props.allTraces) {
const startFrame = trace.first_frame || 0
const endFrame = trace.last_frame || startFrame
const identity = trace.identity_name || trace.person_name || `Trace ${trace.trace_id || ''}`
marks.value.push({
id: nextMarkId++,
startFrame,
endFrame: endFrame > startFrame ? endFrame : undefined,
tag: 'face',
note: identity,
source: 'face'
})
}
}
// Tag colors
const tagColors: Record<string, string> = {}
const colorPalette = ['#4285f4', '#ea4335', '#fbbc04', '#34a853', '#9c27b0', '#ff5722', '#00bcd4', '#e91e63']
function getTagColor(tag: string): string {
if (!tagColors[tag]) {
const idx = Object.keys(tagColors).length % colorPalette.length
tagColors[tag] = colorPalette[idx]
}
return tagColors[tag]
}
const allTags = computed(() => [...new Set(marks.value.map(m => m.tag))])
const visibleTags = computed(() => tagFilter.value ? [tagFilter.value] : allTags.value)
const filteredMarks = computed(() => {
if (!tagFilter.value) return marks.value
return marks.value.filter(m => m.tag === tagFilter.value)
})
function marksByTag(tag: string): Mark[] {
return marks.value.filter(m => m.tag === tag)
}
function startMark() {
markingStart.value = currentFrame.value
}
function endMark() {
if (markingStart.value === null) return
showAddMarkForm.value = true
showMarksPanel.value = true
}
function cancelMark() {
markingStart.value = null
}
function saveMark() {
if (markingStart.value === null) return
const tag = newMarkTag.value.trim() || 'default'
const start = markingStart.value
const end = currentFrame.value > start ? currentFrame.value : undefined
marks.value.push({
id: nextMarkId++,
startFrame: start,
endFrame: end,
tag,
note: newMarkNote.value.trim() || undefined
})
// Reset
markingStart.value = null
newMarkTag.value = ''
newMarkNote.value = ''
showAddMarkForm.value = false
}
function deleteMark(id: number) {
marks.value = marks.value.filter(m => m.id !== id)
}
function goToMark(mark: Mark) {
if (!videoEl.value) return
videoEl.value.currentTime = mark.startFrame / videoFps.value
}
function isMarkActive(mark: Mark): boolean {
const f = currentFrame.value
if (mark.endFrame) {
return f >= mark.startFrame && f <= mark.endFrame
}
return f === mark.startFrame
}
function getMarkStyle(mark: Mark): any {
const startPct = (mark.startFrame / totalFrames.value) * 100
const endPct = mark.endFrame ? (mark.endFrame / totalFrames.value) * 100 : startPct
const width = Math.max(0.5, endPct - startPct)
return {
left: startPct + '%',
width: width + '%',
background: getTagColor(mark.tag)
}
}
function onMainTimelineClick(e: MouseEvent) {
if (!videoEl.value) return
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const pct = (e.clientX - rect.left) / rect.width
videoEl.value.currentTime = pct * duration.value
}
const playMode = ref<'trace' | 'segment' | 'continuous'>('trace')
const pendingSeekTime = ref<number | null>(null)
@@ -131,6 +367,33 @@ const tlEnd = computed(() => {
const tlRange = computed(() => Math.max(tlEnd.value - tlStart.value, 1))
const continuousFps = computed(() => {
if (!props.allTraces.length) return 30
return getTraceFps(props.allTraces[0]) || 30
})
const continuousStartSec = computed(() => {
if (!props.allTraces.length) return 0
return props.allTraces[0].first_sec || props.allTraces[0].start_time || 0
})
const continuousEndSec = computed(() => {
if (!props.allTraces.length) return 0
const last = props.allTraces[props.allTraces.length - 1]
return last.last_sec || last.end_time || 0
})
const positionPct = computed(() => {
if (!videoEl.value || duration.value === 0) return null
const start = continuousStartSec.value
const end = continuousEndSec.value
const range = end - start || 1
const pct = ((currentTime.value - start) / range) * 100
return Math.max(0, Math.min(100, pct))
})
const timelineMarkers = computed(() => {
if (playMode.value === 'segment' && props.mergedSegments.length) {
return props.mergedSegments.map((s: any, i: number) => {
@@ -154,6 +417,7 @@ const timelineMarkers = computed(() => {
end: en,
pct: ((st - tlStart.value) / tlRange.value) * 100,
w: Math.max(0.5, ((en - st) / tlRange.value) * 100),
first_frame: t.first_frame,
}
})
})
@@ -177,9 +441,10 @@ function isMarkerActive(i: number): boolean {
}
function getTraceFps(t: any): number {
if (!t) return 30
const frameDiff = (t.last_frame || 0) - (t.first_frame || 0)
const secDiff = (t.last_sec || 0) - (t.first_sec || 0)
if (secDiff <= 0) return 30
if (secDiff <= 0 || frameDiff <= 0) return 30
return frameDiff / secDiff
}
@@ -239,9 +504,8 @@ async function switchMode(mode: 'trace' | 'segment' | 'continuous') {
fps = sFps
absFrame = (s.start_frame || 0) + Math.round(prevTime * fps)
} else if (prevMode === 'continuous') {
const firstTrace = props.allTraces[0]
fps = getTraceFps(firstTrace)
absFrame = (firstTrace.first_frame || 0) + Math.round(prevTime * fps)
fps = continuousFps.value
absFrame = Math.round(prevTime * fps)
}
playMode.value = mode
@@ -249,28 +513,29 @@ async function switchMode(mode: 'trace' | 'segment' | 'continuous') {
if (mode === 'continuous') {
const firstTrace = props.allTraces[0]
const lastTrace = props.allTraces[props.allTraces.length - 1]
const fps = getTraceFps(firstTrace)
const fps = continuousFps.value
const firstFrame = firstTrace?.first_frame || 0
const lastFrame = lastTrace?.last_frame || firstFrame + 1
const targetIdx = findTraceIdxForFrame(absFrame)
currentTraceIdx.value = targetIdx
const targetTrace = props.allTraces[targetIdx]
const targetFrame = Math.min(absFrame, lastFrame)
pendingSeekTime.value = targetTrace?.first_sec || targetTrace?.start_time || 0
curFileUuid.value = continuousFileUuid.value
const prevVolume = videoEl.value?.volume ?? 1
videoLoading.value = true
videoError.value = ''
videoSrc.value = ''
pendingSeekTime.value = null
try {
const data = await apiCall('get_video_stream', {
uuid: continuousFileUuid.value,
startTime: null,
endTime: null,
startFrame: targetFrame,
startFrame: absFrame,
endFrame: lastFrame,
original: props.useOriginal,
})
if (typeof data === 'string') {
videoSrc.value = data
@@ -294,17 +559,13 @@ async function switchMode(mode: 'trace' | 'segment' | 'continuous') {
} else if (mode === 'trace') {
const targetIdx = findTraceIdxForFrame(absFrame)
const targetTrace = props.allTraces[targetIdx]
fps = getTraceFps(targetTrace)
const relFrame = absFrame - (targetTrace?.first_frame || 0)
pendingSeekTime.value = Math.max(0, relFrame / fps)
pendingSeekTime.value = targetTrace?.first_sec || targetTrace?.start_time || 0
await loadTrace(targetIdx)
} else if (mode === 'segment') {
const targetIdx = findSegmentIdxForFrame(absFrame)
const targetSeg = props.mergedSegments[targetIdx]
if (props.mergedSegments.length && targetSeg) {
fps = getTraceFps(props.allTraces[targetSeg._startIdx || 0])
const relFrame = absFrame - (targetSeg.start_frame || 0)
pendingSeekTime.value = Math.max(0, relFrame / fps)
pendingSeekTime.value = targetSeg.start || 0
await loadSegment(targetIdx)
}
}
@@ -322,6 +583,8 @@ async function loadTrace(idx: number) {
const stFrame = t.first_frame || 0
const enFrame = Math.max(t.last_frame || 0, stFrame + 1)
pendingSeekTime.value = t.first_sec || t.start_time || 0
curFileUuid.value = fu
const prevVolume = videoEl.value?.volume ?? 1
videoLoading.value = true
@@ -334,6 +597,7 @@ async function loadTrace(idx: number) {
endTime: null,
startFrame: stFrame,
endFrame: enFrame,
original: props.useOriginal,
})
if (typeof data === 'string') {
videoSrc.value = data
@@ -368,6 +632,8 @@ async function loadSegment(idx: number) {
const stFrame = s.start_frame || 0
const enFrame = Math.max(s.end_frame || 0, stFrame + 1)
pendingSeekTime.value = s.start || 0
curFileUuid.value = fu
const prevVolume = videoEl.value?.volume ?? 1
videoLoading.value = true
@@ -380,6 +646,7 @@ async function loadSegment(idx: number) {
endTime: null,
startFrame: stFrame,
endFrame: enFrame,
original: props.useOriginal,
})
if (typeof data === 'string') {
videoSrc.value = data
@@ -411,6 +678,8 @@ async function loadContinuous() {
const stFrame = firstTrace?.first_frame || 0
const enFrame = Math.max(lastTrace?.last_frame || 0, stFrame + 1)
pendingSeekTime.value = continuousStartSec.value
curFileUuid.value = fu
const prevVolume = videoEl.value?.volume ?? 1
videoLoading.value = true
@@ -423,6 +692,7 @@ async function loadContinuous() {
endTime: null,
startFrame: stFrame,
endFrame: enFrame,
original: props.useOriginal,
})
if (typeof data === 'string') {
videoSrc.value = data
@@ -448,8 +718,7 @@ async function loadContinuous() {
function onTimelineClick(marker: any) {
if (playMode.value === 'continuous') {
if (!videoEl.value) return
const seekTime = marker.start - continuousStart.value
videoEl.value.currentTime = Math.max(0, seekTime)
videoEl.value.currentTime = marker.start
currentTraceIdx.value = marker.idx
} else if (playMode.value === 'trace') {
loadTrace(marker.idx)
@@ -499,8 +768,7 @@ function seekToPrevTrace() {
currentTraceIdx.value--
const t = props.allTraces[currentTraceIdx.value]
if (!t) return
const seekTime = (t.first_sec || 0) - continuousStart.value
videoEl.value.currentTime = Math.max(0, seekTime)
videoEl.value.currentTime = t.first_sec || t.start_time || 0
}
function seekToNextTrace() {
@@ -508,8 +776,7 @@ function seekToNextTrace() {
currentTraceIdx.value++
const t = props.allTraces[currentTraceIdx.value]
if (!t) return
const seekTime = (t.first_sec || 0) - continuousStart.value
videoEl.value.currentTime = Math.max(0, seekTime)
videoEl.value.currentTime = t.first_sec || t.start_time || 0
}
function close() {
@@ -517,10 +784,26 @@ function close() {
emit('close')
}
function goToFrame() {
if (!videoEl.value || seekFrame.value === null) return
const targetTime = seekFrame.value / videoFps.value
videoEl.value.currentTime = Math.min(Math.max(0, targetTime), duration.value)
seekFrame.value = null
}
function onLoaded() {
const el = videoEl.value
if (!el) return
// Try to detect fps from video
// @ts-ignore - webkitVideoDecodedByteCount etc. are non-standard
if (el.getVideoPlaybackQuality) {
const quality = el.getVideoPlaybackQuality()
if (quality.totalVideoFrames > 0 && el.duration > 0) {
videoFps.value = Math.round(quality.totalVideoFrames / el.duration)
}
}
if (pendingSeekTime.value !== null) {
el.currentTime = pendingSeekTime.value
pendingSeekTime.value = null
@@ -537,10 +820,9 @@ function onTimeUpdate() {
duration.value = videoEl.value.duration || 0
if (playMode.value === 'continuous') {
const absTime = currentTime.value + continuousStart.value
for (let i = props.allTraces.length - 1; i >= 0; i--) {
const t = props.allTraces[i]
if (absTime >= (t.first_sec || 0)) {
if (currentTime.value >= (t.first_sec || 0)) {
currentTraceIdx.value = i
break
}
@@ -590,6 +872,9 @@ onMounted(async () => {
return
}
// Load face traces as marks
loadTracesAsMarks()
if (props.allTraces.length || props.mergedSegments.length) {
if (playMode.value === 'continuous') {
await loadContinuous()
@@ -602,14 +887,10 @@ onMounted(async () => {
} else {
try {
const st = props.startTime ?? 0
let et = props.endTime ?? st + 1
if (et <= st || et - st < 1) et = st + 1
pendingSeekTime.value = st
const data = await apiCall('get_video_stream', {
uuid: props.fileUuid,
startTime: st,
endTime: et,
startFrame: null,
endFrame: null,
original: props.useOriginal,
})
if (typeof data === 'string') {
videoSrc.value = data
@@ -635,6 +916,13 @@ watch(() => props.initialTraceIdx, (newIdx) => {
}
})
// Watch for traces changes and reload marks
watch(() => props.allTraces, () => {
// Remove old face marks and reload
marks.value = marks.value.filter(m => m.source !== 'face')
loadTracesAsMarks()
}, { deep: true })
watch(() => props.initialSegmentIdx, (newIdx) => {
if (newIdx === undefined || newIdx === null) return
if (playMode.value === 'segment' && newIdx !== currentSegmentIdx.value) {
@@ -671,10 +959,96 @@ onUnmounted(() => {
.ms-video-tl-labels { display: flex; justify-content: space-between; font-size: 10px; color: rgba(255,255,255,0.45); }
.ms-video-tl-tip { display: none; position: absolute; bottom: 16px; transform: translateX(-50%); background: rgba(0,0,0,.75); color: #fff; font-size: 10px; padding: 2px 7px; border-radius: 4px; white-space: nowrap; pointer-events: none; z-index: 10; }
.ms-video-tl-dot:hover .ms-video-tl-tip { display: block; }
.ms-video-nav { display: flex; align-items: center; justify-content: space-between; margin-top: 14px; gap: 10px; }
.ms-video-tl-position { position: absolute; top: 50%; transform: translate(-50%, -50%); width: 2px; height: 16px; background: #1a56db; border-radius: 1px; z-index: 3; pointer-events: none; }
.ms-video-tl-pos-tip { display: none; position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%); background: #1a56db; color: #fff; font-size: 10px; padding: 2px 6px; border-radius: 4px; white-space: nowrap; pointer-events: none; z-index: 10; }
.ms-video-tl-position:hover .ms-video-tl-pos-tip { display: block; }
.ms-video-nav { display: flex; align-items: center; justify-content: space-between; margin-top: 14px; gap: 10px; flex-wrap: wrap; }
.ms-video-seg-info { font-size: 12px; color: rgba(255,255,255,0.6); flex-shrink: 0; }
.ms-video-seg-info2 { font-size: 12px; color: #9aa0a6; flex: 1; text-align: center; }
/* Frame seek controls */
.ms-video-frame-seek {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: rgba(255, 255, 255, 0.08);
border-radius: 8px;
}
.ms-video-frame-label {
font-size: 12px;
color: var(--text-secondary, #9aa0a6);
}
.ms-video-frame-input {
width: 70px;
padding: 4px 8px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
background: rgba(0, 0, 0, 0.3);
color: var(--text-primary, #e8eaed);
font-size: 13px;
}
.ms-video-frame-input:focus {
outline: none;
border-color: #4285f4;
}
.ms-video-frame-btn {
padding: 4px 12px;
font-size: 12px;
background: #4285f4;
border: none;
border-radius: 4px;
color: white;
cursor: pointer;
}
.ms-video-frame-btn:hover { background: #5294f5; }
.ms-video-frame-current {
font-size: 12px;
color: var(--text-secondary, #9aa0a6);
font-family: monospace;
}
.ms-video-nav-btn { background: #2c2c2c; border-color: #3c3c3c; color: #e8eaed; }
.ms-video-nav-btn:hover { background: #3c3c3c; }
.ms-video-nav-btn:disabled { opacity: 0.35; cursor: default; }
/* Main Timeline */
.ms-video-tl-main { cursor: pointer; position: relative; height: 8px; background: rgba(255,255,255,0.15); border-radius: 4px; }
.ms-video-tl-progress { position: absolute; left: 0; top: 0; height: 100%; background: #4285f4; border-radius: 4px; transition: width 0.1s; }
.ms-video-tl-main .ms-video-tl-position { width: 12px; height: 12px; background: #fff; border: 2px solid #4285f4; border-radius: 50%; top: 50%; transform: translate(-50%, -50%); }
/* Mark Timelines */
.ms-video-mark-timelines { margin-top: 8px; }
.ms-video-mark-tl-row { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
.ms-video-mark-tl-label { font-size: 10px; color: var(--text-secondary, #9aa0a6); min-width: 50px; text-align: right; }
.ms-video-mark-tl-bar { flex: 1; height: 6px; background: rgba(255,255,255,0.08); border-radius: 3px; position: relative; }
.ms-video-mark-tl-mark { position: absolute; top: 0; height: 100%; border-radius: 3px; cursor: pointer; opacity: 0.8; transition: opacity 0.2s, transform 0.2s; }
.ms-video-mark-tl-mark:hover { opacity: 1; transform: scaleY(1.3); }
.ms-video-mark-tl-mark.ms-mark-active { opacity: 1; box-shadow: 0 0 6px currentColor; }
/* Mark Controls */
.ms-video-mark-controls { display: flex; align-items: center; gap: 10px; margin-top: 12px; flex-wrap: wrap; }
.ms-mark-btn { padding: 6px 14px; font-size: 12px; background: #34a853; border: none; border-radius: 6px; color: white; cursor: pointer; }
.ms-mark-btn:hover { background: #3cb85c; }
.ms-mark-btn.active { background: #ea4335; }
.ms-mark-btn.ms-mark-end { background: #4285f4; }
.ms-mark-btn.ms-mark-cancel { background: #666; padding: 6px 10px; }
.ms-mark-filter { padding: 6px 10px; border: 1px solid rgba(255,255,255,0.2); border-radius: 6px; background: rgba(0,0,0,0.3); color: var(--text-primary, #e8eaed); font-size: 12px; }
.ms-mark-panel-toggle { padding: 6px 14px; font-size: 12px; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); border-radius: 6px; color: var(--text-primary, #e8eaed); cursor: pointer; }
/* Marks Panel */
.ms-video-marks-panel { margin-top: 12px; background: rgba(0,0,0,0.3); border-radius: 8px; padding: 12px; max-height: 200px; overflow-y: auto; }
.ms-marks-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; font-weight: 600; color: var(--text-primary, #e8eaed); }
.ms-marks-close { background: none; border: none; color: var(--text-secondary, #9aa0a6); cursor: pointer; font-size: 16px; }
.ms-marks-list { display: flex; flex-direction: column; gap: 6px; }
.ms-mark-item { display: flex; align-items: center; gap: 10px; padding: 8px 10px; background: rgba(255,255,255,0.05); border-radius: 6px; cursor: pointer; transition: background 0.2s; }
.ms-mark-item:hover { background: rgba(255,255,255,0.1); }
.ms-mark-id { font-size: 11px; color: var(--text-secondary, #9aa0a6); min-width: 30px; }
.ms-mark-tag { font-size: 10px; padding: 2px 8px; border-radius: 10px; color: white; font-weight: 600; }
.ms-mark-range { font-size: 12px; font-family: monospace; color: var(--text-primary, #e8eaed); }
.ms-mark-note { flex: 1; font-size: 12px; color: var(--text-secondary, #9aa0a6); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ms-mark-del { background: none; border: none; color: #ea4335; cursor: pointer; font-size: 12px; padding: 2px 6px; }
.ms-marks-empty { text-align: center; color: var(--text-secondary, #9aa0a6); font-size: 12px; padding: 20px; }
.ms-mark-add-form { display: flex; gap: 8px; margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(255,255,255,0.1); }
.ms-mark-input { flex: 1; padding: 8px 10px; border: 1px solid rgba(255,255,255,0.2); border-radius: 6px; background: rgba(0,0,0,0.3); color: var(--text-primary, #e8eaed); font-size: 12px; }
</style>

View File

@@ -2,8 +2,11 @@ import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import vObserve from './directives/vObserve'
import i18n from './locales'
import './assets/momentry-base.css'
const app = createApp(App)
app.use(router)
app.use(i18n)
app.directive('observe', vObserve)
app.mount('#app')

View File

@@ -12,8 +12,8 @@ const routes: RouteRecordRaw[] = [
{ path: '/login', name: 'Login', component: LoginView },
{ path: '/search', name: 'Search', component: SearchView },
{ path: '/library', name: 'Library', component: LibraryView },
{ path: '/people', name: 'People', component: PeopleView },
{ path: '/people/:uuid', name: 'PersonDetail', component: PersonDetailView },
{ path: '/face', name: 'Face', component: PeopleView },
{ path: '/face/:uuid', name: 'FaceDetail', component: PersonDetailView },
{ path: '/admin', name: 'Admin', component: AdminView },
{ path: '/client', name: 'Client', component: ClientView }
]
@@ -23,4 +23,23 @@ const router = createRouter({
routes
})
router.beforeEach((to, _from, next) => {
const token = localStorage.getItem('token')
const isLoggedIn = !!token
if (to.path === '/login') {
if (isLoggedIn) {
next('/search')
} else {
next()
}
} else {
if (isLoggedIn) {
next()
} else {
next('/login')
}
}
})
export default router

View File

@@ -1,6 +1,7 @@
import { ref } from 'vue'
import { apiCall } from '@/api'
import { isTauri } from './api/config'
import type { PoseResponse, AppearanceResponse } from '@/api/types'
export const filesCache = ref<any[]>([])
export const filesLoaded = ref(false)
@@ -13,10 +14,15 @@ export const faceCandidatesLoaded = ref(false)
export const unassignedTracesCache = ref<any[]>([])
export const unassignedTracesLoaded = ref(false)
export const unassignedTracesTotal = ref(0)
export const unassignedTracesPage = ref(1)
const UNASSIGNED_PER_PAGE = 20
export const thumbnailsCache = ref<Record<string, string>>({})
export const profilesCache = ref<Record<string, string>>({})
export const faceThumbsCache = ref<Record<string, string>>({})
export const poseCache = ref<Record<string, PoseResponse>>({})
export const appearanceCache = ref<Record<string, AppearanceResponse>>({})
export interface ProcessorOutputInfo {
has_json: boolean
@@ -44,10 +50,286 @@ export interface FileProgress {
memory_percent?: number
}
// Pipeline阶段接口
export interface PipelineStage {
name: string
weight: number
progress: number
status: 'pending' | 'running' | 'completed' | 'failed'
}
export interface PipelineStats {
overall_progress: number
stages: PipelineStage[]
}
// File详细统计接口
export interface JsonProcessorInfo {
status: string
segment_count?: number
frame_count?: number
chunk_count?: number
}
export interface JsonStats {
file_name: string
status: string
processors: Record<string, JsonProcessorInfo>
}
export interface PostgresqlStats {
sentence_chunks: number
trace_chunks: number
relationship_chunks: number
identities: number
file_identities: number
}
export interface QdrantStats {
faces: number
face_traces: number
face_identities: number
text_chunks: number
speakers: number
}
export interface TkgNodeStats {
character: number
object: number
scene: number
action: number
location: number
event: number
dialogue: number
emotion: number
relation: number
}
export interface TkgEdgeStats {
appear_in: number
interact_with: number
located_at: number
perform_action: number
cause_effect: number
temporal_link: number
speak_to: number
feel: number
}
export interface TkgStats {
nodes: TkgNodeStats
edges: TkgEdgeStats
total_nodes: number
total_edges: number
}
export interface IdentityAgentStats {
clusters: number
identities_created: number
tmdb_matches: number
speaker_bindings: number
confirmations: number
}
export interface FileStats {
json: JsonStats
postgresql: PostgresqlStats
qdrant: QdrantStats
tkg: TkgStats
identity_agent: IdentityAgentStats
}
export const processorCountsCache = ref<Record<string, Record<string, ProcessorOutputInfo>>>({})
export const fileProgressCache = ref<Record<string, FileProgress>>({})
export const fileStatusCache = ref<Record<string, string>>({})
// Cluster agent state (persistent across navigation)
export const clusterResultsCache = ref<Record<string, any[]>>({})
export const clusterRunningState = ref<Record<string, boolean>>({})
function loadClusterStateFromStorage() {
try {
const saved = localStorage.getItem('momentry_cluster_running')
if (saved) clusterRunningState.value = JSON.parse(saved)
} catch (e) {}
}
function saveClusterStateToStorage() {
try {
localStorage.setItem('momentry_cluster_running', JSON.stringify(clusterRunningState.value))
} catch (e) {}
}
loadClusterStateFromStorage()
export function setClusterRunning(fileUuid: string, running: boolean) {
clusterRunningState.value[fileUuid] = running
saveClusterStateToStorage()
}
export function isClusterRunning(fileUuid: string): boolean {
return clusterRunningState.value[fileUuid] || false
}
export async function pollClusterResults(fileUuid: string, onDone?: (results: any[]) => void) {
if (!fileUuid || isClusterRunning(fileUuid)) return
setClusterRunning(fileUuid, true)
try {
for (let i = 0; i < 120; i++) {
if (!isClusterRunning(fileUuid)) return
await new Promise(r => setTimeout(r, 3000))
try {
const results: any = await apiCall('get_cluster_results', { fileHash: fileUuid })
const groups = results?.face_groups || results?.clusters || []
if (groups.length) {
await loadClusterNames(fileUuid, groups)
clusterResultsCache.value[fileUuid] = groups
setClusterRunning(fileUuid, false)
onDone?.(groups)
return
}
} catch { /* not ready */ }
}
} finally {
setClusterRunning(fileUuid, false)
}
}
export async function loadClusterNames(fileUuid: string, clusters: any[]) {
console.log('[loadClusterNames] loading names for', clusters.length, 'clusters')
const promises = clusters.map(async (c) => {
const repTid = c.representative_trace || c.trace_ids?.[0]
if (repTid != null) {
try {
const key = `${fileUuid}:${repTid}`
delete traceProfileCache.value[key]
const profile = await getTraceProfile(fileUuid, repTid)
console.log('[loadClusterNames] cluster', c.cluster_id, 'trace', repTid, 'profile:', profile?.name)
if (profile?.name) c.name = profile.name
} catch (e) { console.error('[loadClusterNames] failed:', repTid, e) }
}
})
await Promise.all(promises)
console.log('[loadClusterNames] done, clusters:', clusters.map(c => ({ id: c.cluster_id, name: c.name })))
}
export function getClusterResults(fileUuid: string): any[] {
return clusterResultsCache.value[fileUuid] || []
}
export function setClusterResults(fileUuid: string, results: any[]) {
clusterResultsCache.value[fileUuid] = results
}
export function clearClusterState(fileUuid: string) {
delete clusterRunningState.value[fileUuid]
delete clusterResultsCache.value[fileUuid]
saveClusterStateToStorage()
}
// Trace & File Profile cache
export const traceProfileCache = ref<Record<string, any>>({})
export const fileProfileCache = ref<Record<string, any>>({})
export async function getTraceProfile(fileUuid: string, traceId: number) {
const key = `${fileUuid}:${traceId}`
if (traceProfileCache.value[key]) return traceProfileCache.value[key]
try {
const data = await apiCall('get_trace_profile', { fileUuid, traceId })
if (data) traceProfileCache.value[key] = data
return data
} catch (e) {
console.error('getTraceProfile failed:', fileUuid, traceId, e)
return null
}
}
export async function updateTraceProfile(fileUuid: string, traceId: number, updates: any) {
try {
const data = await apiCall('update_trace_profile', { fileUuid, traceId, ...updates })
const key = `${fileUuid}:${traceId}`
if (data) traceProfileCache.value[key] = data
return data
} catch (e) {
console.error('updateTraceProfile failed:', fileUuid, traceId, e)
return null
}
}
export async function updateTraceProfileGroup(fileUuid: string, traceIds: number[], updates: any) {
try {
const data = await apiCall('update_trace_profile_group', { fileUuid, traceIds, ...updates })
// Update cache for all traces
for (const tid of traceIds) {
const key = `${fileUuid}:${tid}`
if (traceProfileCache.value[key]) {
traceProfileCache.value[key] = { ...traceProfileCache.value[key], ...updates }
} else {
traceProfileCache.value[key] = { file_uuid: fileUuid, trace_id: tid, ...updates }
}
}
return data
} catch (e) {
console.error('updateTraceProfileGroup failed:', fileUuid, e)
return null
}
}
export async function getFileProfile(fileUuid: string) {
if (fileProfileCache.value[fileUuid]) return fileProfileCache.value[fileUuid]
try {
const data = await apiCall('get_file_profile', { fileUuid })
if (data) fileProfileCache.value[fileUuid] = data
return data
} catch (e) {
console.error('getFileProfile failed:', fileUuid, e)
return null
}
}
export async function updateFileProfile(fileUuid: string, updates: any) {
try {
const data = await apiCall('update_file_profile', { fileUuid, ...updates })
if (data) fileProfileCache.value[fileUuid] = { ...fileProfileCache.value[fileUuid], ...data }
return data
} catch (e) {
console.error('updateFileProfile failed:', fileUuid, e)
return null
}
}
export function invalidateTraceProfile(fileUuid: string, traceId: number) {
delete traceProfileCache.value[`${fileUuid}:${traceId}`]
}
export function invalidateFileProfile(fileUuid: string) {
delete fileProfileCache.value[fileUuid]
}
function loadProgressCacheFromStorage() {
try {
const savedProgress = localStorage.getItem('momentry_file_progress')
const savedStatus = localStorage.getItem('momentry_file_status')
if (savedProgress) {
fileProgressCache.value = JSON.parse(savedProgress)
}
if (savedStatus) {
fileStatusCache.value = JSON.parse(savedStatus)
}
} catch (e) {
console.error('Failed to load progress cache:', e)
}
}
function saveProgressCacheToStorage() {
try {
localStorage.setItem('momentry_file_progress', JSON.stringify(fileProgressCache.value))
localStorage.setItem('momentry_file_status', JSON.stringify(fileStatusCache.value))
} catch (e) {
console.error('Failed to save progress cache:', e)
}
}
loadProgressCacheFromStorage()
const thumbQueue: (() => Promise<void>)[] = []
let activeThumbLoads = 0
const MAX_CONCURRENT = 16
@@ -61,6 +343,51 @@ const loadingProfiles = new Set<string>()
const loadingFaceThumbs = new Set<string>()
const loadingProcessorCounts = new Set<string>()
const pollingProgress = new Set<string>()
export const pollingProgressSet = pollingProgress
export const pipelineStatsCache = ref<Record<string, PipelineStats>>({})
export const fileStatsCache = ref<Record<string, FileStats>>({})
const loadingPipelineStats = new Set<string>()
const loadingFileStats = new Set<string>()
let statsRefreshInterval: ReturnType<typeof setInterval> | null = null
let processingPollInterval: ReturnType<typeof setInterval> | null = null
export function startStatsAutoRefresh() {
if (statsRefreshInterval) return
statsRefreshInterval = setInterval(async () => {
for (const fileUuid of Object.keys(pipelineStatsCache.value)) {
await loadPipelineStats(fileUuid, true)
}
for (const fileUuid of Object.keys(fileStatsCache.value)) {
await loadFileStats(fileUuid, true)
}
}, 30000)
}
export function stopStatsAutoRefresh() {
if (statsRefreshInterval) {
clearInterval(statsRefreshInterval)
statsRefreshInterval = null
}
}
export function startProcessingFilesPolling() {
if (processingPollInterval) return
processingPollInterval = setInterval(async () => {
const processingFiles = filesCache.value.filter((f: any) => f.status === 'processing' && f.file_uuid)
if (processingFiles.length === 0) return
// Use Promise.all to sync in parallel, not sequentially
await Promise.all(processingFiles.map((f: any) => syncFileStatus(f.file_uuid)))
}, 10000) // Increased from 5s to 10s
}
export function stopProcessingFilesPolling() {
if (processingPollInterval) {
clearInterval(processingPollInterval)
processingPollInterval = null
}
}
function drainThumbQueue() {
if (activeThumbLoads >= MAX_CONCURRENT || thumbQueue.length === 0) return
@@ -101,6 +428,7 @@ function queueProfile(fn: () => Promise<void>) {
let _peopleLoading = false
let _filesLoading = false
let _faceCandidatesLoading = false
let _fileUuidForCandidates: string | undefined = undefined
let _unassignedTracesLoading = false
let _lastLoadedFileUuid: string | undefined = undefined
@@ -123,11 +451,11 @@ export async function ensureFiles() {
}
}
export async function ensurePeople() {
if (peopleLoaded.value) return
export async function ensurePeople(force = false) {
if (peopleLoaded.value && !force) return
if (_peopleLoading) {
await new Promise<void>(r => { const check = setInterval(() => { if (peopleLoaded.value || !_peopleLoading) { clearInterval(check); r() } }, 100) })
return
if (!force) return
}
_peopleLoading = true
try {
@@ -153,21 +481,25 @@ export async function ensurePeople() {
}
}
export async function ensureFaceCandidates() {
if (faceCandidatesLoaded.value) return
export async function ensureFaceCandidates(fileUuid: string) {
if (!fileUuid) return
if (faceCandidatesLoaded.value && _fileUuidForCandidates === fileUuid) return
if (_faceCandidatesLoading) {
await new Promise<void>(r => { const check = setInterval(() => { if (faceCandidatesLoaded.value || !_faceCandidatesLoading) { clearInterval(check); r() } }, 100) })
return
}
_faceCandidatesLoading = true
_fileUuidForCandidates = fileUuid
try {
if (isTauri) {
const fc: any = await apiCall('get_face_candidates', { page: 1, perPage: 100 })
const args: any = { page: 1, perPage: 20, fileUuid }
const fc: any = await apiCall('get_face_candidates', args)
faceCandidatesCache.value = Array.isArray(fc) ? fc : []
} else {
const all: any[] = []
for (let page = 1; page <= 5; page++) {
const batch: any = await apiCall('get_face_candidates', { page, perPage: 20 })
for (let page = 1; page <= 3; page++) {
const args: any = { page, perPage: 20, fileUuid }
const batch: any = await apiCall('get_face_candidates', args)
const arr = Array.isArray(batch) ? batch : []
if (!arr.length) break
all.push(...arr)
@@ -183,23 +515,22 @@ export async function ensureFaceCandidates() {
}
}
export async function ensureUnassignedTraces(fileUuid?: string) {
if (unassignedTracesLoaded.value && _lastLoadedFileUuid === fileUuid) return
export async function ensureUnassignedTraces(fileUuid: string, page?: number) {
if (!fileUuid) return
const targetPage = page ?? unassignedTracesPage.value
if (unassignedTracesLoaded.value && _lastLoadedFileUuid === fileUuid && targetPage === unassignedTracesPage.value) return
if (_unassignedTracesLoading) {
await new Promise<void>(r => { const check = setInterval(() => { if (unassignedTracesLoaded.value || !_unassignedTracesLoading) { clearInterval(check); r() } }, 100) })
if (unassignedTracesLoaded.value && _lastLoadedFileUuid === fileUuid) return
if (unassignedTracesLoaded.value && _lastLoadedFileUuid === fileUuid && targetPage === unassignedTracesPage.value) return
}
_unassignedTracesLoading = true
try {
const all: any[] = []
for (let page = 1; page <= 5; page++) {
const batch: any = await apiCall('get_unassigned_traces', { page, perPage: 20 })
const traces = batch?.traces || []
if (!traces.length) break
all.push(...traces)
if (traces.length < 20) break
}
unassignedTracesCache.value = all
const args: any = { page: targetPage, perPage: UNASSIGNED_PER_PAGE, fileUuid }
const batch: any = await apiCall('get_unassigned_traces', args)
const traces = batch?.traces || []
unassignedTracesCache.value = traces
unassignedTracesTotal.value = batch?.total ?? traces.length
unassignedTracesPage.value = targetPage
_lastLoadedFileUuid = fileUuid
} catch (e) {
console.error('Failed to load unassigned traces:', e)
@@ -211,17 +542,17 @@ export async function ensureUnassignedTraces(fileUuid?: string) {
export function loadTraceThumb(t: any) {
if (!t.file_uuid || !t.trace_id) return
const key = `${t.trace_id}-${t.file_uuid}`
const key = `${t.trace_id}-${t.file_uuid}-${t.best_face_id || 0}`
if (faceThumbsCache.value[key] || loadingFaceThumbs.has(key)) return
loadingFaceThumbs.add(key)
queueThumb(async () => {
try {
const args: any = { uuid: t.file_uuid, frame: t.best_face_frame || t.start_frame || 0 }
if (t.best_face_bbox) {
args.bboxX = Math.round(t.best_face_bbox.x)
args.bboxY = Math.round(t.best_face_bbox.y)
args.bboxW = Math.round(t.best_face_bbox.width)
args.bboxH = Math.round(t.best_face_bbox.height)
args.bboxX = t.best_face_bbox.x
args.bboxY = t.best_face_bbox.y
args.bboxW = t.best_face_bbox.width
args.bboxH = t.best_face_bbox.height
}
const result = await apiCall('get_face_thumbnail', args)
if (result) faceThumbsCache.value[key] = result
@@ -249,9 +580,25 @@ export function loadThumbnail(uuid: string, frame = 30) {
})
}
export function loadProfile(uuid: string) {
export function loadUnregisteredThumbnail(filePath: string, frame = 30) {
const key = `unreg:${filePath}:${frame}`
if (!filePath || thumbnailsCache.value[key] || loadingThumbs.has(key)) return
loadingThumbs.add(key)
queueThumb(async () => {
try {
const result = await apiCall('get_file_thumbnail_by_path', { path: filePath, frame })
if (result) thumbnailsCache.value[key] = result
} catch (e) {
console.error('loadUnregisteredThumbnail failed:', filePath, frame, e)
} finally {
loadingThumbs.delete(key)
}
})
}
export function loadProfile(uuid: string, forceReload = false) {
if (!uuid) { console.error('[loadProfile] called with empty uuid'); return }
if (profilesCache.value[uuid]) return
if (!forceReload && profilesCache.value[uuid]) return
if (loadingProfiles.has(uuid)) return
loadingProfiles.add(uuid)
console.error('[loadProfile] requesting', uuid)
@@ -276,10 +623,10 @@ export function loadFaceThumb(key: string, uuid: string, frame: number, bbox?: a
try {
const args: any = { uuid, frame }
if (bbox) {
args.bboxX = Math.round(bbox.x)
args.bboxY = Math.round(bbox.y)
args.bboxW = Math.round(bbox.width)
args.bboxH = Math.round(bbox.height)
args.bboxX = bbox.x
args.bboxY = bbox.y
args.bboxW = bbox.width
args.bboxH = bbox.height
}
const result = await apiCall('get_face_thumbnail', args)
if (result) faceThumbsCache.value[key] = result
@@ -320,6 +667,69 @@ export function getProcessorCounts(fileUuid: string): Record<string, ProcessorOu
return processorCountsCache.value[fileUuid] || {}
}
export type ProcessingLevel = 'unprocessed' | 'partially_processed' | 'fully_processed'
export interface ProcessingStatus {
level: ProcessingLevel
hasTraces: boolean
hasIdentities: boolean
hasTranscription: boolean
hasOcr: boolean
hasFrameExtraction: boolean
details?: Record<string, ProcessorOutputInfo>
}
export async function getFileProcessingStatus(fileUuid: string): Promise<ProcessingStatus> {
try {
const traces: any = await apiCall('get_traces', { fileUuid, pageSize: 1 })
const hasTraces = traces?.total > 0
await loadProcessorCounts(fileUuid)
const outputs = getProcessorCounts(fileUuid)
const hasTranscription = outputs.transcription?.has_json || false
const hasOcr = outputs.ocr?.has_json || false
const hasFrameExtraction = outputs.frame_extraction?.frame_count > 0 || false
let identities: any = null
let hasIdentities = false
try {
identities = await apiCall('get_file_identities', { fileUuid, pageSize: 1 })
hasIdentities = identities?.total > 0
} catch (e) {
}
let level: ProcessingLevel
if (!hasTraces && !hasTranscription && !hasOcr) {
level = 'unprocessed'
} else if (hasTraces && hasIdentities && hasTranscription && hasOcr && hasFrameExtraction) {
level = 'fully_processed'
} else {
level = 'partially_processed'
}
return {
level,
hasTraces,
hasIdentities,
hasTranscription,
hasOcr,
hasFrameExtraction,
details: outputs
}
} catch (e) {
console.error('getFileProcessingStatus failed:', fileUuid, e)
return {
level: 'unprocessed',
hasTraces: false,
hasIdentities: false,
hasTranscription: false,
hasOcr: false,
hasFrameExtraction: false
}
}
}
export async function pollProgress(fileUuid: string) {
if (!fileUuid || pollingProgress.has(fileUuid)) return
pollingProgress.add(fileUuid)
@@ -341,10 +751,16 @@ export async function pollProgress(fileUuid: string) {
gpu_percent: data.gpu_percent,
memory_percent: data.memory_percent,
}
fileStatusCache.value[fileUuid] = 'processing'
const f = filesCache.value.find((x: any) => x.file_uuid === fileUuid)
if (f) f.status = 'processing'
saveProgressCacheToStorage()
const allComplete = data.processors?.every((p: any) => p.status === 'complete' || p.status === 'failed')
if (allComplete || data.overall_progress >= 100) {
stopPolling(fileUuid)
fileStatusCache.value[fileUuid] = 'completed'
if (f) f.status = 'completed'
saveProgressCacheToStorage()
await loadProcessorCounts(fileUuid)
break
}
@@ -369,18 +785,30 @@ export function getFileStatus(fileUuid: string): string {
return fileStatusCache.value[fileUuid] || ''
}
export function clearFileStatusCache(fileUuid: string) {
delete fileStatusCache.value[fileUuid]
saveProgressCacheToStorage()
}
export async function refreshFileStatus(fileUuid: string) {
try {
const data: any = await apiCall('get_progress', { fileUuid })
if (data.overall_progress >= 100 || data.processors?.every((p: any) => p.status === 'complete')) {
fileStatusCache.value[fileUuid] = 'completed'
await loadProcessorCounts(fileUuid)
} else if (data.processors?.some((p: any) => p.status === 'running')) {
fileStatusCache.value[fileUuid] = 'processing'
fileProgressCache.value[fileUuid] = {
file_uuid: fileUuid,
overall_progress: data.overall_progress || 0,
processors: data.processors || [],
const pipeline = await loadPipelineStats(fileUuid, true)
if (pipeline) {
const isComplete = pipeline.overall_progress >= 1.0 || pipeline.stages?.every((s: any) => s.status === 'completed' || s.status === 'failed')
const isRunning = pipeline.stages?.some((s: any) => s.status === 'running')
if (isComplete) {
fileStatusCache.value[fileUuid] = 'completed'
await loadFileStats(fileUuid, true)
} else if (isRunning) {
fileStatusCache.value[fileUuid] = 'processing'
}
// Also update the file object in filesCache
const f = filesCache.value.find((x: any) => x.file_uuid === fileUuid)
if (f) {
if (isComplete) f.status = 'completed'
else if (isRunning) f.status = 'processing'
}
}
} catch (e) {
@@ -389,10 +817,294 @@ export async function refreshFileStatus(fileUuid: string) {
}
export async function refreshAllFilesStatus() {
for (const f of filesCache.value) {
if (f.isRegistered && f.file_uuid) {
await refreshFileStatus(f.file_uuid)
const tasks = filesCache.value
.filter((f: any) => f.isRegistered && f.file_uuid)
.map((f: any) => refreshFileStatus(f.file_uuid))
await Promise.all(tasks)
}
export async function syncFileStatus(fileUuid: string): Promise<any> {
try {
const result = await apiCall('sync_file_status', { fileUuid })
if (result?.status) {
const newStatus = result.status
const oldStatus = fileStatusCache.value[fileUuid]
fileStatusCache.value[fileUuid] = newStatus
const f = filesCache.value.find((x: any) => x.file_uuid === fileUuid)
if (f) {
// If status changed from processing to completed, clear pipeline cache
if (oldStatus === 'processing' && newStatus === 'completed') {
delete pipelineStatsCache.value[fileUuid]
delete fileProgressCache.value[fileUuid]
}
f.status = newStatus
}
}
return result
} catch (e) {
console.error('syncFileStatus failed:', fileUuid, e)
return null
}
}
export async function syncAllProcessingFiles(): Promise<void> {
const processingFiles = filesCache.value.filter((f: any) => f.status === 'processing' && f.file_uuid)
for (const f of processingFiles) {
await syncFileStatus(f.file_uuid)
}
}
export async function loadPipelineStats(fileUuid: string, force = false): Promise<PipelineStats | null> {
if (!fileUuid) return null
if (!force && pipelineStatsCache.value[fileUuid]) return pipelineStatsCache.value[fileUuid]
if (loadingPipelineStats.has(fileUuid)) return null
loadingPipelineStats.add(fileUuid)
try {
const data: any = await apiCall('get_pipeline_stats', { fileUuid })
// 过滤掉identity_agent阶段(独立于pipeline)
const filteredStages = (data.stages || []).filter((s: any) => s.name !== 'identity_agent')
// 覆盖权重为最终设计值(Core API返回的权重可能不匹配)
const WEIGHT_MAP: Record<string, number> = {
processors: 0.30,
rule1_ingestion: 0.10,
face_tracing: 0.10,
tkg_nodes: 0.20,
tkg_edges: 0.15,
rule2_ingestion: 0.15
}
const mappedStages = filteredStages.map((s: any) => ({
name: s.name,
weight: WEIGHT_MAP[s.name] ?? s.weight ?? 0,
progress: s.progress || 0,
status: s.status || 'pending'
}))
// 用前端权重重新计算 overall_progress(不信任 API 返回的值)
const calculatedProgress = mappedStages.reduce((sum, s) => sum + s.weight * s.progress, 0)
const pipelineStats: PipelineStats = {
overall_progress: calculatedProgress,
stages: mappedStages
}
console.log('[loadPipelineStats]', fileUuid, pipelineStats)
pipelineStatsCache.value = { ...pipelineStatsCache.value, [fileUuid]: pipelineStats }
// If pipeline is complete, update file status
if (calculatedProgress >= 1.0) {
const f = filesCache.value.find((x: any) => x.file_uuid === fileUuid)
if (f && f.status !== 'completed') {
f.status = 'completed'
fileStatusCache.value[fileUuid] = 'completed'
}
}
return pipelineStats
} catch (e) {
console.warn('get_pipeline_stats failed, falling back to get_progress:', fileUuid, e)
try {
const oldData: any = await apiCall('get_progress', { fileUuid })
// 检查所有处理器是否都完成
const allComplete = oldData.processors?.length > 0 && oldData.processors?.every((p: any) => p.status === 'complete' || p.status === 'failed')
const hasRunning = oldData.processors?.some((p: any) => p.status === 'running')
// 如果都完成,显示100%
const progress = allComplete ? 1.0 : (oldData.overall_progress || 0) / 100
const status = allComplete ? 'completed' : (hasRunning ? 'running' : 'pending')
console.log('[loadPipelineStats fallback]', fileUuid, { allComplete, hasRunning, progress, status, processorCount: oldData.processors?.length })
const fallbackStats: PipelineStats = {
overall_progress: progress,
stages: [
{ name: 'processors', weight: 1.0, progress, status }
]
}
pipelineStatsCache.value = { ...pipelineStatsCache.value, [fileUuid]: fallbackStats }
return fallbackStats
} catch (fallbackError) {
console.error('Both pipeline APIs failed:', fileUuid, fallbackError)
return null
}
} finally {
loadingPipelineStats.delete(fileUuid)
}
}
export async function loadFileStats(fileUuid: string, force = false): Promise<FileStats | null> {
if (!fileUuid) return null
if (!force && fileStatsCache.value[fileUuid]) return fileStatsCache.value[fileUuid]
if (loadingFileStats.has(fileUuid)) return null
loadingFileStats.add(fileUuid)
try {
const data: any = await apiCall('get_file_stats', { fileUuid })
// 转换新API格式为前端格式
const processors: Record<string, JsonProcessorInfo> = {}
for (const p of data.processors || []) {
const name = p.name.toLowerCase()
processors[name] = {
status: p.status,
segment_count: p.name === 'ASR' || p.name === 'ASRX' ? null : null,
frame_count: p.name === 'FACE' || p.name === 'OCR' || p.name === 'POSE' || p.name === 'APPEARANCE' || p.name === 'CUT' ? null : null,
chunk_count: null
}
}
const fileStats: FileStats = {
json: {
file_name: data.file_name || '',
status: data.status || '',
processors
},
postgresql: {
sentence_chunks: data.postgres?.sentence_chunks || 0,
trace_chunks: data.postgres?.trace_chunks || 0,
relationship_chunks: data.postgres?.relationship_chunks || 0,
identities: data.postgres?.identities || 0,
file_identities: data.postgres?.file_identities || 0
},
qdrant: {
faces: data.qdrant?.faces || 0,
face_traces: data.qdrant?.face_traces || 0,
face_identities: data.qdrant?.face_identities || 0,
text_chunks: data.qdrant?.text_chunks || 0,
speakers: data.qdrant?.speakers || 0
},
tkg: {
nodes: {
character: data.tkg?.face_track_nodes || 0,
object: data.tkg?.object_nodes || 0,
scene: 0,
action: 0,
location: 0,
event: 0,
dialogue: data.tkg?.text_region_nodes || 0,
emotion: 0,
relation: 0
},
edges: {
appear_in: data.tkg?.face_face_edges || 0,
interact_with: data.tkg?.co_occurrence_edges || 0,
located_at: 0,
perform_action: 0,
cause_effect: 0,
temporal_link: 0,
speak_to: data.tkg?.speaker_face_edges || 0,
feel: 0
},
total_nodes: data.tkg?.total_nodes || 0,
total_edges: data.tkg?.total_edges || 0
},
identity_agent: {
clusters: data.identity_agent?.clusters || 0,
identities_created: data.identity_agent?.identities_created || 0,
tmdb_matches: data.identity_agent?.tmdb_matches || 0,
speaker_bindings: data.identity_agent?.speaker_bindings || 0,
confirmations: data.identity_agent?.confirmations || 0
}
}
fileStatsCache.value = { ...fileStatsCache.value, [fileUuid]: fileStats }
if (data.status === 'completed') {
pipelineStatsCache.value[fileUuid] = {
overall_progress: 1.0,
stages: [{ name: 'processors', weight: 1.0, progress: 1.0, status: 'completed' }]
}
}
return fileStats
} catch (e) {
console.warn('get_file_stats failed, falling back to get_processor_counts:', fileUuid, e)
try {
// 降级到processor-counts API
await loadProcessorCounts(fileUuid, true)
const procCounts = getProcessorCounts(fileUuid)
// 构建降级版FileStats
const fallbackStats: FileStats = {
json: {
file_name: '',
status: 'completed',
processors: {}
},
postgresql: { sentence_chunks: 0, trace_chunks: 0, relationship_chunks: 0, identities: 0, file_identities: 0 },
qdrant: { faces: 0, face_traces: 0, face_identities: 0, text_chunks: 0, speakers: 0 },
tkg: { nodes: { character: 0, object: 0, scene: 0, action: 0, location: 0, event: 0, dialogue: 0, emotion: 0, relation: 0 }, edges: { appear_in: 0, interact_with: 0, located_at: 0, perform_action: 0, cause_effect: 0, temporal_link: 0, speak_to: 0, feel: 0 }, total_nodes: 0, total_edges: 0 },
identity_agent: { clusters: 0, identities_created: 0, tmdb_matches: 0, speaker_bindings: 0, confirmations: 0 }
}
// 从processorCounts填充json.processors
for (const [proc, info] of Object.entries(procCounts)) {
fallbackStats.json.processors[proc] = {
status: info.has_json ? 'completed' : 'pending',
segment_count: info.segment_count,
frame_count: info.frame_count,
chunk_count: info.chunk_count
}
}
fileStatsCache.value = { ...fileStatsCache.value, [fileUuid]: fallbackStats }
return fallbackStats
} catch (fallbackError) {
console.error('Both file stats APIs failed:', fileUuid, fallbackError)
return null
}
} finally {
loadingFileStats.delete(fileUuid)
}
}
export function getPipelineStats(fileUuid: string): PipelineStats | null {
return pipelineStatsCache.value[fileUuid] || null
}
export function getFileStats(fileUuid: string): FileStats | null {
return fileStatsCache.value[fileUuid] || null
}
export async function pollPipelineProgress(fileUuid: string) {
if (!fileUuid || pollingProgress.has(fileUuid)) return
pollingProgress.add(fileUuid)
try {
while (pollingProgress.has(fileUuid)) {
const stats = await loadPipelineStats(fileUuid, true)
if (stats) {
const idx = filesCache.value.findIndex((x: any) => x.file_uuid === fileUuid)
if (idx === -1) continue
fileStatusCache.value[fileUuid] = 'processing'
filesCache.value.splice(idx, 1, { ...filesCache.value[idx], status: 'processing' })
saveProgressCacheToStorage()
const allComplete = stats.stages.every(s => s.status === 'completed' || s.status === 'failed')
if (allComplete || stats.overall_progress >= 1.0) {
stopPolling(fileUuid)
fileStatusCache.value[fileUuid] = 'completed'
filesCache.value.splice(idx, 1, { ...filesCache.value[idx], status: 'completed' })
saveProgressCacheToStorage()
await loadFileStats(fileUuid, true)
break
}
}
await new Promise(r => setTimeout(r, 2000))
}
} catch (e) {
console.error('pollPipelineProgress failed:', fileUuid, e)
} finally {
pollingProgress.delete(fileUuid)
}
}
@@ -411,4 +1123,44 @@ export function invalidatePeople() {
export function invalidateProfile(uuid: string) {
delete profilesCache.value[uuid]
loadingProfiles.delete(uuid)
}
export async function getPose(fileUuid: string, frame: number, bbox?: { x: number; y: number; width: number; height: number } | null): Promise<PoseResponse | null> {
const key = bbox ? `${fileUuid}:${frame}:${bbox.x}:${bbox.y}` : `${fileUuid}:${frame}`
if (poseCache.value[key]) return poseCache.value[key]
try {
const args: any = { fileUuid, frame }
if (bbox) {
args.bboxX = bbox.x
args.bboxY = bbox.y
args.bboxW = bbox.width
args.bboxH = bbox.height
}
const data = await apiCall('get_pose', args)
if (data) poseCache.value[key] = data
return data
} catch (e) {
console.error('getPose failed:', fileUuid, frame, e)
return null
}
}
export async function getAppearance(fileUuid: string, frame: number, bbox?: { x: number; y: number; width: number; height: number } | null): Promise<AppearanceResponse | null> {
const key = bbox ? `${fileUuid}:${frame}:${bbox.x}:${bbox.y}` : `${fileUuid}:${frame}`
if (appearanceCache.value[key]) return appearanceCache.value[key]
try {
const args: any = { fileUuid, frame }
if (bbox) {
args.bboxX = bbox.x
args.bboxY = bbox.y
args.bboxW = bbox.width
args.bboxH = bbox.height
}
const data = await apiCall('get_appearance', args)
if (data) appearanceCache.value[key] = data
return data
} catch (e) {
console.error('getAppearance failed:', fileUuid, frame, e)
return null
}
}

View File

@@ -6,6 +6,7 @@
<button class="admin-tab" :class="{ active: activeTab === 'dashboard' }" @click="activeTab = 'dashboard'">Dashboard</button>
<button class="admin-tab" :class="{ active: activeTab === 'users' }" @click="activeTab = 'users'">Users</button>
<button class="admin-tab" :class="{ active: activeTab === 'shares' }" @click="activeTab = 'shares'">Shares</button>
<button class="admin-tab" :class="{ active: activeTab === 'settings' }" @click="activeTab = 'settings'">Settings</button>
</div>
</div>
@@ -41,6 +42,108 @@
</tbody>
</table>
</div>
<div v-if="activeTab === 'settings'" class="settings-panel">
<div v-if="settingsError" class="settings-error">{{ settingsError }}</div>
<div class="settings-group">
<h3>Upload Path</h3>
<p class="settings-desc">Base directory where uploaded files are stored (appended with /&lt;username&gt;).</p>
<div class="settings-row">
<input class="settings-input" v-model="uploadPath" placeholder="/Users/accusys/momentry/var/sftpgo/data" />
<button class="settings-save-btn" @click="saveUploadPath" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
</div>
<div v-if="saveMessage" :class="['settings-msg', saveError ? 'error' : 'ok']">{{ saveMessage }}</div>
</div>
<div class="settings-group">
<h3>Service URLs</h3>
<p class="settings-desc">Configure backend service endpoints.</p>
<div class="service-card">
<div class="service-header">
<span class="service-name">Core API</span>
<span :class="['service-status', coreStatus]">{{ coreStatus }}</span>
</div>
<div class="service-row">
<input class="settings-input" v-model="coreApiUrl" placeholder="http://localhost:3002" />
<button class="settings-test-btn" @click="testCoreApi" :disabled="testingCore">{{ testingCore ? 'Testing...' : 'Test' }}</button>
</div>
<div class="service-actions">
<button class="service-stop-btn" @click="stopService('core')" :disabled="coreActionLoading">Stop</button>
<button class="service-start-btn" @click="startService('core')" :disabled="coreActionLoading">Start</button>
<span v-if="coreActionLoading" class="action-loading">Processing...</span>
<span v-if="coreActionResult" :class="['action-result', coreActionResultOk ? 'ok' : 'error']">{{ coreActionResult }}</span>
</div>
<div v-if="coreInfo" class="service-info">
<span>Version: {{ coreInfo.version || 'N/A' }}</span>
<span>Uptime: {{ formatUptime(coreInfo.uptime) }}</span>
</div>
</div>
<div class="service-card">
<div class="service-header">
<span class="service-name">MarkBase</span>
<span :class="['service-status', mbStatus]">{{ mbStatus }}</span>
</div>
<div class="service-row">
<input class="settings-input" v-model="markbaseUrl" placeholder="http://localhost:11438" />
<button class="settings-test-btn" @click="testMarkbase" :disabled="testingMb">{{ testingMb ? 'Testing...' : 'Test' }}</button>
</div>
<div class="service-actions">
<button class="service-stop-btn" @click="stopService('markbase')" :disabled="mbActionLoading">Stop</button>
<button class="service-start-btn" @click="startService('markbase')" :disabled="mbActionLoading">Start</button>
<span v-if="mbActionLoading" class="action-loading">Processing...</span>
<span v-if="mbActionResult" :class="['action-result', mbActionResultOk ? 'ok' : 'error']">{{ mbActionResult }}</span>
</div>
<div v-if="mbInfo" class="service-info">
<span>Version: {{ mbInfo.version || 'N/A' }}</span>
<span>Uptime: {{ formatUptime(mbInfo.uptime) }}</span>
</div>
</div>
<div class="service-card">
<div class="service-header">
<span class="service-name">MarkBaseEngine</span>
<span :class="['service-status', mbeStatus]">{{ mbeStatus }}</span>
</div>
<div class="service-row">
<input class="settings-input" v-model="mbeUrl" placeholder="http://localhost:8080" />
<button class="settings-test-btn" @click="testMbe" :disabled="testingMbe">{{ testingMbe ? 'Testing...' : 'Test' }}</button>
</div>
<div class="service-actions">
<button class="service-stop-btn" @click="stopService('mbe')" :disabled="mbeActionLoading">Stop</button>
<button class="service-start-btn" @click="startService('mbe')" :disabled="mbeActionLoading">Start</button>
<span v-if="mbeActionLoading" class="action-loading">Processing...</span>
<span v-if="mbeActionResult" :class="['action-result', mbeActionResultOk ? 'ok' : 'error']">{{ mbeActionResult }}</span>
</div>
<div v-if="mbeInfo" class="service-info">
<span>Version: {{ mbeInfo.version || 'N/A' }}</span>
<span>Uptime: {{ formatUptime(mbeInfo.uptime) }}</span>
</div>
</div>
<div class="service-card">
<div class="service-header">
<span class="service-name">Proxy</span>
<span :class="['service-status', proxyStatus]">{{ proxyStatus }}</span>
</div>
<div class="service-row">
<input class="settings-input" v-model="proxyUrl" placeholder="http://localhost:8888" />
<button class="settings-test-btn" @click="testProxy" :disabled="testingProxy">{{ testingProxy ? 'Testing...' : 'Test' }}</button>
</div>
<div class="service-actions">
<button class="service-stop-btn" @click="stopService('proxy')" :disabled="proxyActionLoading">Stop</button>
<button class="service-start-btn" @click="startService('proxy')" :disabled="proxyActionLoading">Start</button>
<span v-if="proxyActionLoading" class="action-loading">Processing...</span>
<span v-if="proxyActionResult" :class="['action-result', proxyActionResultOk ? 'ok' : 'error']">{{ proxyActionResult }}</span>
</div>
</div>
<button class="settings-save-btn" @click="saveServiceUrls" style="margin-top: 12px;">Save URLs</button>
<div v-if="urlSaveMsg" :class="['settings-msg', urlSaveError ? 'error' : 'ok']">{{ urlSaveMsg }}</div>
</div>
</div>
</div>
</div>
</template>
@@ -54,6 +157,226 @@ const stats = ref({ cpu: 0, memory: 0, disk: 0 })
const users = ref<any[]>([])
const shares = ref<any[]>([])
// Settings
const uploadPath = ref('')
const saving = ref(false)
const saveMessage = ref('')
const saveError = ref(false)
const settingsError = ref('')
const coreApiUrl = ref('http://localhost:3002')
const markbaseUrl = ref('http://localhost:11438')
const mbeUrl = ref('http://localhost:8080')
const proxyUrl = ref('http://localhost:8888')
const coreStatus = ref('unknown')
const mbStatus = ref('unknown')
const mbeStatus = ref('unknown')
const proxyStatus = ref('unknown')
const coreInfo = ref<any>(null)
const mbInfo = ref<any>(null)
const mbeInfo = ref<any>(null)
const testingCore = ref(false)
const testingMb = ref(false)
const testingMbe = ref(false)
const testingProxy = ref(false)
const urlSaveMsg = ref('')
const urlSaveError = ref(false)
// Service stop/start state
const coreActionLoading = ref(false)
const coreActionResult = ref('')
const coreActionResultOk = ref(false)
const mbActionLoading = ref(false)
const mbActionResult = ref('')
const mbActionResultOk = ref(false)
const mbeActionLoading = ref(false)
const mbeActionResult = ref('')
const mbeActionResultOk = ref(false)
const proxyActionLoading = ref(false)
const proxyActionResult = ref('')
const proxyActionResultOk = ref(false)
async function stopService(name: string) {
const s = serviceState(name)
s.loading.value = true
s.result.value = ''
try {
const result = await apiCall('stop_service', { name })
s.result.value = result === 'stopped' ? 'Service stopped' : result
s.ok.value = true
setTimeout(() => s.test(), 1000)
} catch (e: any) {
s.result.value = 'Error: ' + (e.message || e)
s.ok.value = false
} finally {
s.loading.value = false
}
}
async function startService(name: string) {
const s = serviceState(name)
s.loading.value = true
s.result.value = ''
try {
const result = await apiCall('start_service', { name })
const started = typeof result === 'string' ? result === 'started' : result?.result === 'started'
s.result.value = started ? (name === 'mbe' ? 'Loading model...' : 'Service started') : JSON.stringify(result)
s.ok.value = started
// Poll up to 30s for the service to pass health check
for (let i = 0; i < 15; i++) {
await new Promise(r => setTimeout(r, 2000))
await s.test()
if (s.status.value === 'online') {
s.result.value = 'Service started'
break
}
}
} catch (e: any) {
s.result.value = 'Error: ' + (e.message || e)
s.ok.value = false
} finally {
s.loading.value = false
}
}
function serviceState(name: string) {
switch (name) {
case 'core': return { loading: coreActionLoading, result: coreActionResult, ok: coreActionResultOk, status: coreStatus, test: testCoreApi }
case 'markbase': return { loading: mbActionLoading, result: mbActionResult, ok: mbActionResultOk, status: mbStatus, test: testMarkbase }
case 'proxy': return { loading: proxyActionLoading, result: proxyActionResult, ok: proxyActionResultOk, status: proxyStatus, test: testProxy }
default: return { loading: mbeActionLoading, result: mbeActionResult, ok: mbeActionResultOk, status: mbeStatus, test: testMbe }
}
}
function loadServiceUrls() {
const stored = localStorage.getItem('service_urls')
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
const currentOrigin = window.location.origin
if (stored) {
try {
const urls = JSON.parse(stored)
coreApiUrl.value = urls.core || (isLocalhost ? 'http://localhost:3002' : currentOrigin)
markbaseUrl.value = urls.markbase || (isLocalhost ? 'http://localhost:11438' : currentOrigin)
mbeUrl.value = urls.mbe || (isLocalhost ? 'http://localhost:8080' : currentOrigin)
proxyUrl.value = urls.proxy || (isLocalhost ? 'http://localhost:8888' : currentOrigin)
} catch (e) {
coreApiUrl.value = isLocalhost ? 'http://localhost:3002' : currentOrigin
markbaseUrl.value = isLocalhost ? 'http://localhost:11438' : currentOrigin
mbeUrl.value = isLocalhost ? 'http://localhost:8080' : currentOrigin
proxyUrl.value = isLocalhost ? 'http://localhost:8888' : currentOrigin
}
} else {
coreApiUrl.value = isLocalhost ? 'http://localhost:3002' : currentOrigin
markbaseUrl.value = isLocalhost ? 'http://localhost:11438' : currentOrigin
mbeUrl.value = isLocalhost ? 'http://localhost:8080' : currentOrigin
proxyUrl.value = isLocalhost ? 'http://localhost:8888' : currentOrigin
}
}
function saveServiceUrls() {
urlSaveMsg.value = ''
urlSaveError.value = false
try {
localStorage.setItem('service_urls', JSON.stringify({
core: coreApiUrl.value,
markbase: markbaseUrl.value,
mbe: mbeUrl.value,
proxy: proxyUrl.value,
}))
;(window as any).__CORE_API_URL__ = coreApiUrl.value
;(window as any).__MARKBASE_URL__ = markbaseUrl.value
;(window as any).__MBE_URL__ = mbeUrl.value
urlSaveMsg.value = 'Service URLs saved'
} catch (e: any) {
urlSaveError.value = true
urlSaveMsg.value = 'Failed to save: ' + (e.message || e)
}
}
async function testCoreApi() {
testingCore.value = true
coreInfo.value = null
try {
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
const healthUrl = isLocalhost ? `${coreApiUrl.value}/health` : `${coreApiUrl.value}/api/v1/health`
const resp = await fetch(healthUrl, { signal: AbortSignal.timeout(5000) })
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
coreStatus.value = 'online'
coreInfo.value = { version: data.version, uptime: data.uptime_ms }
} catch (e: any) {
coreStatus.value = 'offline'
coreInfo.value = null
} finally {
testingCore.value = false
}
}
async function testMarkbase() {
testingMb.value = true
mbInfo.value = null
try {
const resp = await fetch(`${markbaseUrl.value}/api/v2/health`, { signal: AbortSignal.timeout(5000) })
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
mbStatus.value = 'online'
mbInfo.value = { version: data.version, uptime: data.uptime }
} catch (e: any) {
mbStatus.value = 'offline'
mbInfo.value = null
} finally {
testingMb.value = false
}
}
async function testMbe() {
testingMbe.value = true
mbeInfo.value = null
try {
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
const healthUrl = isLocalhost ? `${mbeUrl.value}/health` : `${mbeUrl.value}/v1/health`
const resp = await fetch(healthUrl, { signal: AbortSignal.timeout(5000) })
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const text = await resp.text()
if (text === 'OK') {
mbeStatus.value = 'online'
mbeInfo.value = { version: 'MarkBaseEngine', uptime: 0 }
} else {
throw new Error('Unexpected response: ' + text)
}
} catch (e: any) {
mbeStatus.value = 'offline'
mbeInfo.value = null
} finally {
testingMbe.value = false
}
}
async function testProxy() {
testingProxy.value = true
try {
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
const healthUrl = isLocalhost ? `${proxyUrl.value}/health` : `${proxyUrl.value}/api/v1/health`
const resp = await fetch(healthUrl, { signal: AbortSignal.timeout(5000) })
proxyStatus.value = resp.ok ? 'online' : 'offline'
} catch {
proxyStatus.value = 'offline'
} finally {
testingProxy.value = false
}
}
function formatUptime(seconds: number | undefined): string {
if (!seconds) return 'N/A'
const d = Math.floor(seconds / 86400)
const h = Math.floor((seconds % 86400) / 3600)
const m = Math.floor((seconds % 3600) / 60)
if (d > 0) return `${d}d ${h}h ${m}m`
if (h > 0) return `${h}h ${m}m`
return `${m}m`
}
async function loadDashboard() {
try { stats.value = await apiCall('get_system_stats', {}) || { cpu: 0, memory: 0, disk: 0 } } catch (e) { console.error(e) }
}
@@ -66,20 +389,90 @@ async function loadShares() {
try { shares.value = await apiCall('list_shares', {}) || [] } catch (e) { console.error(e) }
}
onMounted(async () => { await loadDashboard(); await loadUsers(); await loadShares() })
async function loadSettings() {
settingsError.value = ''
saveMessage.value = ''
try {
const resp = await fetch(`${markbaseUrl.value}/api/v2/config`)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const cfg = await resp.json()
uploadPath.value = cfg.server?.upload_path || ''
} catch (e: any) {
settingsError.value = 'Failed to load settings: ' + (e.message || e)
}
}
async function saveUploadPath() {
saving.value = true
saveMessage.value = ''
saveError.value = false
try {
const resp = await fetch(`${markbaseUrl.value}/api/v2/config/edit?key=server.upload_path&value=${encodeURIComponent(uploadPath.value)}`, { method: 'POST' })
const data = await resp.json()
if (data.ok) {
saveMessage.value = 'Upload path saved successfully'
} else {
saveError.value = true
saveMessage.value = data.error || 'Save failed'
}
} catch (e: any) {
saveError.value = true
saveMessage.value = 'Error: ' + (e.message || e)
} finally {
saving.value = false
}
}
onMounted(async () => {
loadServiceUrls()
await Promise.all([loadDashboard(), loadUsers(), loadShares(), loadSettings()])
await Promise.all([testCoreApi(), testMarkbase(), testMbe(), testProxy()])
})
</script>
<style scoped>
#ms-view-admin { padding: 20px; }
#ms-view-admin { padding: 20px; color: var(--text-primary); }
.admin-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.admin-tabs { display: flex; gap: 10px; }
.admin-tab { padding: 8px 16px; border: none; background: #f0f0f0; cursor: pointer; border-radius: 4px; }
.admin-tab.active { background: #2563eb; color: white; }
.admin-content { background: white; border-radius: 8px; padding: 20px; }
.admin-tab { padding: 8px 16px; border: none; background: var(--hover-background); color: var(--text-primary); cursor: pointer; border-radius: 4px; }
.admin-tab.active { background: var(--primary-color); color: white; }
.admin-content { background: var(--card-background); border-radius: 8px; padding: 20px; }
.stats-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 15px; }
.stat-card { background: #f8f9fa; padding: 15px; border-radius: 8px; text-align: center; }
.stat-label { font-size: 12px; color: #666; }
.stat-value { font-size: 24px; font-weight: bold; }
.stat-card { background: var(--hover-background); padding: 15px; border-radius: 8px; text-align: center; }
.stat-label { font-size: 12px; color: var(--text-secondary); }
.stat-value { font-size: 24px; font-weight: bold; color: var(--text-primary); }
.admin-table { width: 100%; border-collapse: collapse; }
.admin-table th, .admin-table td { padding: 10px; border: 1px solid #ddd; text-align: left; }
.admin-table th, .admin-table td { padding: 10px; border: 1px solid var(--border-color); text-align: left; color: var(--text-primary); }
.settings-panel { max-width: 600px; }
.settings-group { margin-bottom: 24px; }
.settings-group h3 { margin: 0 0 4px; font-size: 16px; color: var(--text-primary); }
.settings-desc { margin: 0 0 10px; font-size: 13px; color: var(--text-secondary); }
.settings-row { display: flex; gap: 8px; align-items: center; }
.settings-input { flex: 1; padding: 8px 12px; border: 1px solid var(--border-color); border-radius: 4px; font-size: 14px; background: var(--card-background); color: var(--text-primary); }
.settings-save-btn { padding: 8px 16px; background: var(--primary-color); color: white; border: none; border-radius: 4px; cursor: pointer; }
.settings-save-btn:disabled { opacity: 0.6; cursor: default; }
.settings-msg { margin-top: 8px; font-size: 13px; }
.settings-msg.ok { color: var(--success-color); }
.settings-msg.error { color: var(--danger-color); }
.settings-error { background: var(--danger-background); color: var(--danger-color); padding: 10px; border-radius: 4px; margin-bottom: 16px; }
.service-card { background: var(--hover-background); border-radius: 6px; padding: 12px; margin-top: 12px; }
.service-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.service-name { font-weight: 600; font-size: 14px; color: var(--text-primary); }
.service-status { font-size: 12px; padding: 2px 8px; border-radius: 4px; text-transform: uppercase; }
.service-status.online { background: rgba(var(--success-color), 0.2); color: var(--success-color); }
.service-status.offline { background: var(--danger-background); color: var(--danger-color); }
.service-status.unknown { background: var(--hover-background); color: var(--text-secondary); }
.service-row { display: flex; gap: 8px; align-items: center; }
.service-test-btn { padding: 6px 12px; background: var(--text-secondary); color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 13px; }
.service-test-btn:disabled { opacity: 0.6; cursor: default; }
.service-info { margin-top: 8px; font-size: 12px; color: var(--text-secondary); display: flex; gap: 16px; }
.service-actions { display: flex; gap: 6px; align-items: center; margin-top: 8px; }
.service-stop-btn { padding: 4px 12px; background: var(--danger-color); color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; }
.service-stop-btn:disabled { opacity: 0.5; cursor: default; }
.service-start-btn { padding: 4px 12px; background: var(--success-color); color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; }
.service-start-btn:disabled { opacity: 0.5; cursor: default; }
.action-loading { font-size: 12px; color: var(--text-secondary); }
.action-result { font-size: 12px; }
.action-result.ok { color: var(--success-color); }
.action-result.error { color: var(--danger-color); }
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -20,9 +20,6 @@
<div v-if="error" class="error-message">{{ error }}</div>
<div v-if="success" class="success-message">Login successful!</div>
</form>
<div class="login-footer">
<p>Default credentials: momentry / demo123</p>
</div>
</div>
</div>
</template>
@@ -30,6 +27,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { isTauri } from '@/api/config'
const router = useRouter()
const username = ref('')
@@ -44,33 +42,57 @@ async function handleLogin() {
success.value = false
try {
const response = await fetch('http://localhost:11438/api/v2/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
if (isTauri) {
const { invoke } = await import('@tauri-apps/api/core')
const result: any = await invoke('login_user', {
username: username.value,
password: password.value,
}),
})
const data = await response.json()
if (response.ok && data.token) {
success.value = true
localStorage.setItem('token', data.token)
localStorage.setItem('username', username.value)
localStorage.setItem('expires_at', data.expires_at)
password: password.value
})
setTimeout(() => {
router.push('/search')
}, 1000)
if (result.success && result.token) {
success.value = true
localStorage.setItem('token', result.token)
localStorage.setItem('username', username.value)
window.dispatchEvent(new CustomEvent('ms-login', { detail: { username: username.value } }))
setTimeout(() => {
router.push('/search')
}, 500)
} else {
error.value = result.error || 'Login failed'
}
} else {
error.value = data.error || 'Login failed'
const apiBase = localStorage.getItem('proxy_url') || ''
const response = await fetch(apiBase + '/api/v1/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: username.value,
password: password.value,
}),
})
const data = await response.json()
if (response.ok && data.success && data.token) {
success.value = true
localStorage.setItem('token', data.token)
localStorage.setItem('username', username.value)
window.dispatchEvent(new CustomEvent('ms-login', { detail: { username: username.value } }))
setTimeout(() => {
router.push('/search')
}, 500)
} else {
error.value = data.error || 'Login failed'
}
}
} catch (e: any) {
error.value = 'Network error: ' + e.message
error.value = 'Login error: ' + (e.message || 'Unknown error')
} finally {
loading.value = false
}
@@ -172,11 +194,4 @@ async function handleLogin() {
text-align: center;
font-size: 14px;
}
.login-footer {
margin-top: 20px;
text-align: center;
color: #999;
font-size: 12px;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -5,10 +5,10 @@
<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>
@@ -24,7 +24,7 @@
<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:cover;">
<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"/>
@@ -45,25 +45,39 @@
<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">角色</span>
<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">描述</span>
<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="加入人名">
<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">
@@ -80,24 +94,24 @@
<option value="ja">日本語</option>
<option value="ko">한국어</option>
</select>
<input v-model="aliasName" class="ms-ppl-alias-inline-input" placeholder="輸入別名後按 Enter" @keyup.enter="addAlias">
<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 = ''">+ 別名</button>
<button v-else class="ms-ppl-alias-add-btn" @click="showAliasInput = false">收起</button>
<button v-if="!showAliasInput" class="ms-ppl-alias-add-btn" @click="showAliasInput = true; aliasName = ''">+ 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">角色</label>
<input v-model="editRole" class="ms-ppl-edit-input ms-ppl-edit-field-input" placeholder="角色名稱">
<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">描述</label>
<textarea v-model="editNotes" class="ms-ppl-edit-textarea ms-ppl-edit-field-input" placeholder="自訂描述"></textarea>
<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 ? '儲存中...' : '✓ 儲存更改' }}</button>
<button class="ms-fm-btn" @click="cancelEditing">取消</button>
<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>
@@ -105,14 +119,14 @@
<!-- 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-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 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="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;">
<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>
@@ -126,8 +140,8 @@
<!-- 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="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 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">
@@ -137,23 +151,33 @@
{{ (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-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="playTrace(selectedTrace)">▶ 播放</button>
<button v-if="isEditing && selectedTrace.face_id" class="ms-fm-btn ms-fm-btn-danger" @click="unbindFace(selectedTrace); selectedTrace = null">✕ 解綁</button>
<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:cover;">
<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>
@@ -161,8 +185,8 @@
<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 }} 個片段 · {{ allTraces.length }} 個追蹤</div>
<div class="ms-ppl-media-sub">所有片段 · {{ formatTime(allSegCard.start) }}</div>
<div class="ms-ppl-media-title">{{ allSegCard.count }} segments · {{ allTraces.length }} traces</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 trace segments available for this person</div>
@@ -171,16 +195,16 @@
<div v-show="activeTab === 'actions'">
<div class="ms-ppl-actions-section">
<div class="ms-ppl-actions-status">
<span class="ms-ppl-actions-label">狀態:</span>
<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')">✓ 確認</button>
<button v-if="person.status !== 'pending'" class="ms-fm-btn" @click="updateStatus('pending')">待定</button>
<button v-if="person.status !== 'skipped'" class="ms-fm-btn" @click="updateStatus('skipped')">略過</button>
<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;">+ 綁定人臉</button>
<button class="ms-fm-btn ms-fm-btn-blue" @click="showMerge = true" style="margin-bottom:8px;">⇄ 合併到其他人物</button>
<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">
@@ -191,7 +215,7 @@
<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>
@@ -221,10 +245,10 @@
<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">⇄ 合併到其他人物</h2>
<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="搜尋人物名稱..." @input="onMergeSearch" />
<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)">
@@ -241,7 +265,7 @@
</div>
</div>
<div class="ms-merge-footer">
<button class="ms-fm-btn" @click="showMerge = false">取消</button>
<button class="ms-fm-btn" @click="showMerge = false">Cancel</button>
</div>
</div>
</div>
@@ -250,7 +274,7 @@
<!-- 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>
<button class="ms-ctx-item ms-ctx-danger" @click="traceCtxAction('unbind')">Not This Person</button>
</div>
</div>
</template>
@@ -260,7 +284,7 @@ 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 } from '@/store'
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()
@@ -286,6 +310,26 @@ 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
@@ -317,6 +361,7 @@ 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)
@@ -329,15 +374,27 @@ const faceStripTotalPages = computed(() => Math.ceil(allTraces.value.length / fa
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 ensurePeople()
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)
}
@@ -381,10 +438,13 @@ 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 || '',
startTime: t.first_sec || t.start_time || 0,
endTime: t.last_sec || t.end_time || 0,
startTime: startFrame / fps,
endTime: endFrame / fps,
traceIdx: idx,
title: `${person.value?.name} · T${t.trace_id}`,
}
@@ -498,8 +558,10 @@ function loadThumb(uuid: string, frame: number) {
}
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 })
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}`
@@ -632,8 +694,8 @@ 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('此 trace 沒有 face_id,無法解綁'); return }
if (!confirm(`解綁 trace T${t.trace_id}(face_id: ${fid})?`)) return
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,
@@ -653,7 +715,7 @@ async function unbindFace(t: any) {
}
} catch (e) {
console.error('Unbind failed:', e)
alert('解綁失敗:' + (e instanceof Error ? e.message : String(e)))
alert('Unbind failed: ' + (e instanceof Error ? e.message : String(e)))
}
}
@@ -675,7 +737,7 @@ function playAllSegments() {
startTime: t.first_sec || t.start_time || 0,
endTime: t.last_sec || t.end_time || 0,
traceIdx: 0,
title: `${person.value?.name} · ${card.count} 個片段`,
title: `${person.value?.name} · ${card.count} segments`,
}
playing.value = true
}
@@ -735,7 +797,7 @@ async function onMergeSearch() {
async function confirmMergeTarget(target: any) {
if (!person.value) return
if (!confirm(`合併「${person.value.name}」到「${target.name}」?`)) 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
@@ -749,96 +811,106 @@ let mergeSearchTimer: any
<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: #5f6368; cursor: pointer; outline: none; padding: 4px 10px; display: flex; align-items: center; gap: 4px; transition: color .15s; }
.ms-ppl-edit-text-btn:hover { color: #202124; }
.loading-state, .empty { text-align: center; padding: 60px 0; color: #5f6368; }
.spinner-lg { width: 24px; height: 24px; border: 3px solid #e8eaed; border-top-color: #202124; border-radius: 50%; animation: spin 0.7s linear infinite; margin: 0 auto 12px; }
.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: #e0e0e0; flex-shrink: 0; overflow: hidden; position: relative; }
.ms-ppl-detail-avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: 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: #d1d5db; transition: color .15s; padding: 0; flex-shrink: 0; }
.ms-ppl-star-btn.starred { color: #f59e0b; }
.ms-ppl-detail-aliases { display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 12px; }
.ms-ppl-alias-chip { display: inline-flex; align-items: center; background: #f0f0f0; border-radius: 999px; padding: 3px 10px; font-size: 11.5px; color: #5f6368; }
.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: #e8eaed; border-radius: 999px; padding: 4px 10px; font-size: 12.5px; color: #202124; }
.ms-ppl-alias-tag button { border: none; background: transparent; cursor: pointer; color: #5f6368; font-size: 14px; line-height: 1; padding: 0; display: flex; align-items: center; }
.ms-ppl-alias-add-btn { border: 1.5px dashed #bbb; background: transparent; border-radius: 999px; padding: 4px 10px; font-size: 12px; color: #5f6368; cursor: pointer; outline: none; }
.ms-ppl-alias-add-btn:hover { border-color: #202124; color: #202124; }
.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 #1a56db; border-radius: 999px; padding: 4px 12px; font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 12.5px; color: #202124; outline: none; background: #fff; min-width: 140px; transition: border-color .15s; }
.ms-ppl-alias-inline-input:focus { border-color: #1a56db; }
.ms-ppl-alias-locale-select { border: 1.5px solid #1a56db; border-radius: 999px; padding: 4px 10px; font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 12px; color: #202124; background: #fff; outline: none; cursor: pointer; height: 30px; }
.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: #202124; letter-spacing: .03em; min-width: 30px; text-align: right; flex-shrink: 0; padding-top: 2px; }
.ms-ppl-edit-input { border: 1.5px solid #e8eaed; border-radius: 12px; padding: 9px 14px; font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 13.5px; color: #202124; outline: none; background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,.06); transition: border-color .15s, box-shadow .15s; min-width: 0; box-sizing: border-box; width: 100%; }
.ms-ppl-edit-input:focus { border-color: #1a56db; box-shadow: 0 0 0 3px rgba(26,86,219,.1); }
.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 #e8eaed; border-radius: 12px; padding: 9px 14px; font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 13px; color: #202124; outline: none; resize: vertical; min-height: 72px; background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,.06); transition: border-color .15s, box-shadow .15s; box-sizing: border-box; }
.ms-ppl-edit-textarea:focus { border-color: #1a56db; box-shadow: 0 0 0 3px rgba(26,86,219,.1); }
.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: #202124; min-height: 24px; display: flex; align-items: center; word-break: break-word; padding: 2px 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: #3c4043; }
.ms-ppl-view-notes-box { flex: 1; min-width: 0; min-height: 24px; align-items: flex-start; color: #9aa0a6; font-size: 13px; white-space: pre-wrap; }
.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 #bdc1c6; background: #fff; font-size: 20px; color: #bdc1c6; 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: #202124; color: #202124; }
.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: 4px; scrollbar-width: thin; flex: 1; }
.ms-ppl-strip-face { position: relative; flex-shrink: 0; cursor: pointer; }
.ms-ppl-strip-face-clickable.selected { outline: 2px solid #1a56db; outline-offset: 2px; border-radius: 14px; }
.ms-ppl-strip-face-img { width: 52px; height: 52px; border-radius: 12px; border: 2px solid transparent; background: #e8eaed; overflow: hidden; transition: border-color .15s; }
.ms-ppl-strip-face:hover .ms-ppl-strip-face-img { border-color: #202124; }
.ms-ppl-face-card-detail { display: flex; gap: 16px; align-items: flex-start; background: #fff; border: 1px solid #e0e0e0; border-radius: 16px; padding: 16px; margin: 12px 0; position: relative; box-shadow: 0 4px 16px rgba(0,0,0,.08); }
.ms-ppl-face-card-close { position: absolute; top: 8px; right: 10px; background: none; border: none; font-size: 20px; cursor: pointer; color: #9aa0a6; line-height: 1; padding: 2px 6px; border-radius: 6px; }
.ms-ppl-face-card-close:hover { background: #f3f4f6; color: #202124; }
.ms-ppl-face-card-img-wrap { width: 120px; height: 120px; border-radius: 12px; overflow: hidden; flex-shrink: 0; background: #e8eaed; }
.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-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-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:not(:disabled) { background: #f3f4f6; color: #202124; }
.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: #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); }
.ms-ppl-media-thumb { position: relative; width: 100%; aspect-ratio: 16/9; overflow: hidden; background: #e8eaed; border-radius: 12px 12px 0 0; }
.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: #fff; }
.ms-ppl-media-title { font-size: 12px; font-weight: 600; color: #202124; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 2px; }
.ms-ppl-media-sub { font-size: 10.5px; color: #9aa0a6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.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 #f3f4f6; margin: 0 0 20px; }
.ms-ppl-delete-zone-btn { color: #d93025; border-color: #fecaca; background: #fff; }
.ms-ppl-delete-zone-btn:hover { background: #fef2f2; border-color: #d93025; }
.face-placeholder { font-size: 0.6rem; color: #5f6368; }
.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: #f8f9fa; border-radius: 10px; font-size: 13px; }
.ms-ppl-actions-label { color: #5f6368; font-weight: 500; }
.ms-ppl-actions-value { color: #202124; font-weight: 600; text-transform: capitalize; }
.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; }

File diff suppressed because it is too large Load Diff