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

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