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,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
}
}