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:
840
src/store.ts
840
src/store.ts
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user