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:
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
src-tauri/target/
|
||||
|
||||
# Secrets
|
||||
.gitea_token
|
||||
|
||||
# Backup files
|
||||
*.bak
|
||||
*.backup
|
||||
*.tar.gz
|
||||
|
||||
# Database journals
|
||||
*.sqlite-journal
|
||||
*.sqlite-wal
|
||||
978
.opencode/plans/multi_track_mark_system_design.md
Normal file
978
.opencode/plans/multi_track_mark_system_design.md
Normal file
@@ -0,0 +1,978 @@
|
||||
# 多轨 Mark 系统设计文档
|
||||
|
||||
## 一、概述
|
||||
|
||||
### 1.1 目标
|
||||
|
||||
构建一个多轨道视频/音频编辑展示系统,支持:
|
||||
- 多轨 Video/Audio 同步播放
|
||||
- 每个轨道独立的 Mark 系统
|
||||
- 基于 Frame 的精确定位和对齐
|
||||
|
||||
### 1.2 核心原则
|
||||
|
||||
| 原则 | 说明 |
|
||||
|------|------|
|
||||
| **Frame 是唯一标准** | 所有定位、长度、对齐都用 Frame(整数) |
|
||||
| **Time 仅用于显示** | Frame / FPS 转换,不存储 |
|
||||
| **整数运算** | 所有 Frame 操作都是整数,避免浮点精度问题 |
|
||||
| **原点对齐** | Frame 0 = Time 0,所有轨道共享同一原点 |
|
||||
| **轨道独立** | 每个轨道有独立的 Mark 系统 |
|
||||
| **同步播放** | 全局播放头对齐所有轨道 |
|
||||
|
||||
### 1.3 概念统一
|
||||
|
||||
**核心洞察**:Trace 和 Segment 只是不同 tag 的 Marks
|
||||
|
||||
```
|
||||
传统概念 → 统一概念
|
||||
────────────────────────────────
|
||||
Trace → Marks with tag='face'
|
||||
Segment → Marks with tag='segment'
|
||||
ASR 分析 → Marks with tag='asr'
|
||||
Noise 检测 → Marks with tag='noise'
|
||||
用户标记 → Marks with tag='user'
|
||||
```
|
||||
|
||||
**好处**:
|
||||
- 统一数据模型
|
||||
- 简化代码实现
|
||||
- 一致的交互行为
|
||||
- 灵活扩展新类型
|
||||
|
||||
---
|
||||
|
||||
## 二、架构设计
|
||||
|
||||
### 2.1 层级架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 应用层 (Application) │
|
||||
│ - 剪接工具 │
|
||||
│ - 审核工具 │
|
||||
│ - 播放器 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 项目层 (Project) │
|
||||
│ - 多轨道管理 │
|
||||
│ - 全局播放状态 │
|
||||
│ - FPS / TotalFrames 元数据 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 轨道层 (Track) │
|
||||
│ - Video Track (视频轨道 + Marks) │
|
||||
│ - Audio Track (音频轨道 + Marks) │
|
||||
│ - Mark Track (纯 Mark 轨道) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Mark 层 (Mark) │
|
||||
│ - startFrame / endFrame (整数) │
|
||||
│ - tag / note / metadata │
|
||||
│ - 属于特定 Track │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.2 数据流
|
||||
|
||||
```
|
||||
┌──────────────┐
|
||||
│ 数据源 │
|
||||
│ - Face API │
|
||||
│ - ASR API │
|
||||
│ - Noise API │
|
||||
└──────┬───────┘
|
||||
│ 获取数据
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ 转换层 │
|
||||
│ Time→Frame │ (用 FPS)
|
||||
│ 统一格式 │
|
||||
└──────┬───────┘
|
||||
│ Mark 数据
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ Project │
|
||||
│ 多轨道管理 │
|
||||
└──────┬───────┘
|
||||
│ Track + Marks
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ 展示层 │
|
||||
│ - Timeline │
|
||||
│ - 播放器 │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、数据结构
|
||||
|
||||
### 3.1 核心类型定义
|
||||
|
||||
```typescript
|
||||
// ========== 基础类型 ==========
|
||||
|
||||
type Frame = number // 整数,绝对定位标准
|
||||
|
||||
// ========== Mark ==========
|
||||
|
||||
interface Mark {
|
||||
id: string
|
||||
trackId: string // 所属轨道
|
||||
|
||||
// 定位(Frame,整数)
|
||||
startFrame: Frame
|
||||
endFrame?: Frame
|
||||
|
||||
// 分类与内容
|
||||
tag: string // 'face' | 'asr' | 'noise' | 'user' | ...
|
||||
note?: string
|
||||
metadata?: any // 原始数据(如 ASR text, noise dB)
|
||||
|
||||
// 审核状态
|
||||
status?: 'pending' | 'confirmed' | 'resolved' | 'ignored'
|
||||
severity?: 'low' | 'medium' | 'high'
|
||||
}
|
||||
|
||||
// ========== Clip ==========
|
||||
|
||||
interface Clip {
|
||||
id: string
|
||||
|
||||
// 源文件范围
|
||||
sourceStartFrame: Frame
|
||||
sourceEndFrame: Frame
|
||||
|
||||
// 时间轴位置(全局 Frame)
|
||||
timelineStartFrame: Frame
|
||||
timelineEndFrame: Frame
|
||||
}
|
||||
|
||||
// ========== Track ==========
|
||||
|
||||
interface Track {
|
||||
id: string
|
||||
type: 'video' | 'audio' | 'mark-only'
|
||||
name: string
|
||||
enabled: boolean // 是否启用
|
||||
|
||||
// 轨道内容
|
||||
file_uuid?: string
|
||||
clips?: Clip[]
|
||||
|
||||
// 独立的 Mark 系统
|
||||
marks: Mark[]
|
||||
}
|
||||
|
||||
// ========== Project ==========
|
||||
|
||||
interface Project {
|
||||
id: string
|
||||
name: string
|
||||
|
||||
// 全局元数据(所有轨道共享)
|
||||
fps: number // 帧率
|
||||
totalFrames: Frame // 总帧数(最长轨道)
|
||||
duration: number // 总时长(秒),仅用于显示
|
||||
|
||||
// 多轨道
|
||||
tracks: Track[]
|
||||
|
||||
// 播放状态
|
||||
playhead: Frame // 当前播放位置(全局)
|
||||
playing: boolean
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Frame 操作规范
|
||||
|
||||
```typescript
|
||||
// 所有 Frame 操作都是整数
|
||||
|
||||
// Frame → Time(仅显示用)
|
||||
function frameToTime(frame: Frame, fps: number): number {
|
||||
return frame / fps
|
||||
}
|
||||
|
||||
// Time → Frame(数据转换用)
|
||||
function timeToFrame(time: number, fps: number): Frame {
|
||||
return Math.round(time * fps)
|
||||
}
|
||||
|
||||
// 位置百分比(Timeline 显示)
|
||||
function frameToPercent(frame: Frame, totalFrames: Frame): number {
|
||||
return (frame / totalFrames) * 100
|
||||
}
|
||||
|
||||
// 格式化显示
|
||||
function formatFrame(frame: Frame, fps: number): string {
|
||||
const sec = frame / fps
|
||||
const m = Math.floor(sec / 60)
|
||||
const s = Math.floor(sec % 60)
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、轨道系统
|
||||
|
||||
### 4.1 轨道类型
|
||||
|
||||
| 类型 | 说明 | 内容 | Marks |
|
||||
|------|------|------|-------|
|
||||
| **Video Track** | 视频轨道 | video clips + file_uuid | face, noise, blurry, user... |
|
||||
| **Audio Track** | 音频轨道 | audio clips + file_uuid | asr, silence, beat, noise... |
|
||||
| **Mark Track** | 纯标记轨道 | 无媒体内容 | user, comment, todo, review... |
|
||||
|
||||
### 4.2 轨道 UI 布局
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Global Playhead: Frame 12345 (0:24.5) │
|
||||
│ ▼ │
|
||||
│ ──────────────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ ☑ V1 (主视频) │
|
||||
│ ├─ ████████████████████████████████ │
|
||||
│ └─ marks: │
|
||||
│ ├─ [face] ████ F100-120 │
|
||||
│ ├─ [face] ███████████ F500-650 │
|
||||
│ └─ [noise] ████ F800-900 │
|
||||
│ │
|
||||
│ ☑ A1 (原声) │
|
||||
│ ├─ ████████████████████████████████ │
|
||||
│ └─ marks: │
|
||||
│ ├─ [asr] ████ F100-120 "你好" │
|
||||
│ └─ [noise] ████ F800-900 │
|
||||
│ │
|
||||
│ ☑ M1 (审核标记) │
|
||||
│ └─ marks: │
|
||||
│ ├─ [user] ▼ F500 "需要检查" │
|
||||
│ └─ [user] ▼ F1000 "待确认" │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.3 轨道操作
|
||||
|
||||
```typescript
|
||||
interface TrackOperations {
|
||||
// 播放控制
|
||||
play(): void
|
||||
pause(): void
|
||||
seek(frame: Frame): void
|
||||
|
||||
// Mark 操作
|
||||
addMark(mark: Omit<Mark, 'id' | 'trackId'>): Mark
|
||||
updateMark(id: string, updates: Partial<Mark>): void
|
||||
deleteMark(id: string): void
|
||||
|
||||
// 批量操作
|
||||
batchUpdateMarks(filter: (m: Mark) => boolean, updates: Partial<Mark>): void
|
||||
batchDeleteMarks(filter: (m: Mark) => boolean): void
|
||||
|
||||
// 导航
|
||||
nextMark(tag?: string): Mark | null
|
||||
prevMark(tag?: string): Mark | null
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、Mark 系统
|
||||
|
||||
### 5.1 Mark 类型与来源
|
||||
|
||||
| Tag | 来源 | 数据格式 | 转换 |
|
||||
|-----|------|----------|------|
|
||||
| **face** | Face Trace API | first_frame, last_frame | 无需转换 |
|
||||
| **asr** | ASR API | start_time, end_time | Time → Frame |
|
||||
| **noise** | 噪声检测 | start_time, end_time | Time → Frame |
|
||||
| **silent** | 静音检测 | start_time, end_time | Time → Frame |
|
||||
| **user** | 用户创建 | frame | 无需转换 |
|
||||
| **comment** | 用户评论 | frame | 无需转换 |
|
||||
|
||||
### 5.2 Mark 数据源转换
|
||||
|
||||
```typescript
|
||||
// Face Trace → Mark
|
||||
function traceToMark(trace: FaceTrace, trackId: string): Mark {
|
||||
return {
|
||||
id: generateId(),
|
||||
trackId,
|
||||
startFrame: trace.first_frame,
|
||||
endFrame: trace.last_frame,
|
||||
tag: 'face',
|
||||
note: trace.identity_name,
|
||||
metadata: trace
|
||||
}
|
||||
}
|
||||
|
||||
// ASR → Mark
|
||||
function asrToMark(asr: AsrChunk, trackId: string, fps: number): Mark {
|
||||
return {
|
||||
id: generateId(),
|
||||
trackId,
|
||||
startFrame: timeToFrame(asr.start_time, fps),
|
||||
endFrame: timeToFrame(asr.end_time, fps),
|
||||
tag: 'asr',
|
||||
note: asr.text,
|
||||
metadata: asr
|
||||
}
|
||||
}
|
||||
|
||||
// Noise → Mark
|
||||
function noiseToMark(noise: NoiseSegment, trackId: string, fps: number): Mark {
|
||||
return {
|
||||
id: generateId(),
|
||||
trackId,
|
||||
startFrame: timeToFrame(noise.start_time, fps),
|
||||
endFrame: timeToFrame(noise.end_time, fps),
|
||||
tag: 'noise',
|
||||
note: `${noise.db}dB`,
|
||||
metadata: noise,
|
||||
severity: noise.db > -30 ? 'high' : noise.db > -40 ? 'medium' : 'low'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 Mark 操作表
|
||||
|
||||
| Tag | 操作 | 参数 |
|
||||
|-----|------|------|
|
||||
| **noise** | 删除片段 | padding: 5 frames |
|
||||
| **silent** | 静音处理 | fade: 10ms |
|
||||
| **blurry** | 标记忽略 | - |
|
||||
| **face** | 绑定身份 | identity_uuid |
|
||||
| **asr** | 编辑文本 | new_text |
|
||||
| **user** | 自定义 | - |
|
||||
|
||||
---
|
||||
|
||||
## 六、Mark 点击交互行为
|
||||
|
||||
### 6.1 核心概念
|
||||
|
||||
点击 Mark 时,根据**当前播放状态**和**全局 PlayMode** 决定行为。
|
||||
|
||||
### 6.2 Sync Lock(同步锁)
|
||||
|
||||
```typescript
|
||||
// 全局同步锁
|
||||
const syncLock = ref<boolean>(true) // 默认锁定
|
||||
|
||||
// 锁定 🔒:点击 Mark → 所有轨道跳转到同一 frame
|
||||
// 解锁 🔓:点击 Mark → 仅当前轨道跳转
|
||||
```
|
||||
|
||||
**UI 控件**:
|
||||
```
|
||||
[🔒 Sync ON] / [🔓 Sync OFF]
|
||||
```
|
||||
|
||||
### 6.3 PlayMode(全局播放模式)
|
||||
|
||||
```typescript
|
||||
type PlayMode = 'normal' | 'continue' | 'loop'
|
||||
const globalPlayMode = ref<PlayMode>('normal')
|
||||
```
|
||||
|
||||
| 模式 | 行为 |
|
||||
|------|------|
|
||||
| **Normal** | 播放到 Mark.endFrame 后停止 |
|
||||
| **Continue** | 播放到 Mark.endFrame 后继续播放 |
|
||||
| **Loop** | 循环播放 Mark.startFrame ~ endFrame |
|
||||
|
||||
**UI 控件**:
|
||||
```
|
||||
[Normal] [Continue] [Loop]
|
||||
```
|
||||
|
||||
### 6.4 点击逻辑流程
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ 点击 Mark 触发 │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 1. Seek 到 Mark.startFrame │
|
||||
│ ├─ SyncLock ON → 所有轨道同步跳转 │
|
||||
│ └─ SyncLock OFF → 仅当前轨道跳转 │
|
||||
│ │
|
||||
│ 2. 检查当前播放状态 │
|
||||
│ ├─ Pause → 只跳转,不播放 │
|
||||
│ └─ Play → 跳转后根据 PlayMode 播放 │
|
||||
│ │
|
||||
│ 3. 根据 PlayMode 设置播放行为 │
|
||||
│ ├─ Normal → 设置 stopAtFrame │
|
||||
│ ├─ Continue → 清除 stopAtFrame │
|
||||
│ └─ Loop → 设置 loopRange │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.5 代码实现
|
||||
|
||||
```typescript
|
||||
// 播放状态
|
||||
const playhead = ref<Frame>(0)
|
||||
const isPlaying = ref<boolean>(false)
|
||||
const stopAtFrame = ref<Frame | null>(null)
|
||||
const loopRange = ref<{ start: Frame; end: Frame } | null>(null)
|
||||
|
||||
// 点击 Mark
|
||||
function onMarkClick(mark: Mark) {
|
||||
// 1. Seek
|
||||
if (syncLock.value) {
|
||||
seekAllTracks(mark.startFrame) // 所有轨道同步
|
||||
} else {
|
||||
seekTrack(mark.trackId, mark.startFrame) // 仅当前轨道
|
||||
}
|
||||
|
||||
// 2. Pause 状态:只跳转,不播放
|
||||
if (!isPlaying.value) return
|
||||
|
||||
// 3. Play 状态:设置播放行为
|
||||
switch (globalPlayMode.value) {
|
||||
case 'normal':
|
||||
stopAtFrame.value = mark.endFrame || mark.startFrame
|
||||
loopRange.value = null
|
||||
break
|
||||
|
||||
case 'continue':
|
||||
stopAtFrame.value = null
|
||||
loopRange.value = null
|
||||
break
|
||||
|
||||
case 'loop':
|
||||
// 不退出 loop(点击其他 mark 不退出)
|
||||
loopRange.value = {
|
||||
start: mark.startFrame,
|
||||
end: mark.endFrame || mark.startFrame
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
play()
|
||||
}
|
||||
|
||||
// PlayMode 切换
|
||||
function setPlayMode(mode: PlayMode) {
|
||||
globalPlayMode.value = mode
|
||||
|
||||
// 切换模式时退出 loop
|
||||
if (mode !== 'loop') {
|
||||
loopRange.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 播放控制器
|
||||
function tick() {
|
||||
if (!isPlaying.value) return
|
||||
|
||||
const currentFrame = playhead.value
|
||||
|
||||
// 检查 Loop 模式
|
||||
if (loopRange.value && currentFrame >= loopRange.value.end) {
|
||||
seekToFrame(loopRange.value.start)
|
||||
requestAnimationFrame(() => tick())
|
||||
return
|
||||
}
|
||||
|
||||
// 检查 Normal 模式的停止点
|
||||
if (stopAtFrame.value !== null && currentFrame >= stopAtFrame.value) {
|
||||
pause()
|
||||
return
|
||||
}
|
||||
|
||||
// Continue 模式:正常播放
|
||||
playhead.value++
|
||||
requestAnimationFrame(() => tick())
|
||||
}
|
||||
```
|
||||
|
||||
### 6.6 Loop 退出条件
|
||||
|
||||
| 操作 | 是否退出 Loop |
|
||||
|------|--------------|
|
||||
| 点击其他 Mark | ❌ 不退出 |
|
||||
| 切换 PlayMode | ✅ 退出 |
|
||||
| 手动暂停 | ✅ 退出 |
|
||||
| 关闭播放器 | ✅ 退出 |
|
||||
|
||||
### 6.7 UI 控件设计
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ 控制栏 │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ [🔒 Sync] | [Normal] [Continue] [Loop] │
|
||||
│ │
|
||||
│ 状态指示: │
|
||||
│ - Sync: ON/OFF │
|
||||
│ - Mode: Normal/Continue/Loop │
|
||||
│ - Loop Range: F100-200 (如果 looping) │
|
||||
│ - Stop At: F300 (如果 normal mode) │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、同步播放
|
||||
|
||||
### 6.1 播放机制
|
||||
|
||||
```typescript
|
||||
class PlaybackController {
|
||||
private project: Project
|
||||
private playhead: Frame = 0
|
||||
private playing: boolean = false
|
||||
private startTime: number = 0
|
||||
private startFrame: Frame = 0
|
||||
|
||||
play() {
|
||||
this.playing = true
|
||||
this.startTime = performance.now()
|
||||
this.startFrame = this.playhead
|
||||
|
||||
this.tick()
|
||||
}
|
||||
|
||||
private tick() {
|
||||
if (!this.playing) return
|
||||
|
||||
const elapsed = (performance.now() - this.startTime) / 1000
|
||||
this.playhead = this.startFrame + Math.round(elapsed * this.project.fps)
|
||||
|
||||
// 同步所有轨道
|
||||
this.syncAllTracks(this.playhead)
|
||||
|
||||
requestAnimationFrame(() => this.tick())
|
||||
}
|
||||
|
||||
private syncAllTracks(frame: Frame) {
|
||||
this.project.tracks.forEach(track => {
|
||||
if (!track.enabled) return
|
||||
|
||||
// 更新媒体内容
|
||||
if (track.type === 'video') {
|
||||
this.syncVideo(track, frame)
|
||||
} else if (track.type === 'audio') {
|
||||
this.syncAudio(track, frame)
|
||||
}
|
||||
|
||||
// 高亮 Marks
|
||||
this.highlightMarks(track.marks, frame)
|
||||
})
|
||||
}
|
||||
|
||||
seek(frame: Frame) {
|
||||
this.playhead = frame
|
||||
this.startTime = performance.now()
|
||||
this.startFrame = frame
|
||||
this.syncAllTracks(frame)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 对齐保证
|
||||
|
||||
```typescript
|
||||
// 验证原点对齐
|
||||
function validateAlignment(project: Project): boolean {
|
||||
// 所有轨道共享原点
|
||||
const globalPlayhead = project.playhead
|
||||
|
||||
project.tracks.forEach(track => {
|
||||
// 所有轨道的当前帧 = 全局播放头
|
||||
const trackFrame = globalPlayhead
|
||||
|
||||
// Mark 高亮基于同一 frame
|
||||
track.marks.forEach(mark => {
|
||||
const isActive = mark.startFrame <= trackFrame &&
|
||||
(mark.endFrame || mark.startFrame) >= trackFrame
|
||||
})
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、Timeline 设计
|
||||
|
||||
### 8.1 Timeline 对齐原则
|
||||
|
||||
```
|
||||
所有 Timeline 共享同一基准:
|
||||
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 基准:Frame 0 ~ TotalFrames (65000) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Main Timeline │
|
||||
│ ██████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
|
||||
│ ▲ Playhead: F12345 │
|
||||
│ │
|
||||
│ V1 Timeline │
|
||||
│ ████████████████████████████████ │
|
||||
│ [face] ████ [noise] ████ │
|
||||
│ │
|
||||
│ A1 Timeline │
|
||||
│ ████████████████████████████████ │
|
||||
│ [asr] ████ [asr] ████ │
|
||||
│ │
|
||||
│ M1 Timeline │
|
||||
│ [user] ▼ [user] ▼ │
|
||||
│ │
|
||||
│ 所有 Timeline 长度一致,位置百分比计算相同 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 8.2 Timeline 计算
|
||||
|
||||
```typescript
|
||||
// 所有 Timeline 用相同公式
|
||||
const totalFrames = project.totalFrames
|
||||
|
||||
// 位置(百分比)
|
||||
function getPosition(frame: Frame): number {
|
||||
return (frame / totalFrames) * 100
|
||||
}
|
||||
|
||||
// 宽度(百分比)
|
||||
function getWidth(startFrame: Frame, endFrame: Frame): number {
|
||||
return ((endFrame - startFrame) / totalFrames) * 100
|
||||
}
|
||||
|
||||
// 像素位置(用于精确渲染)
|
||||
function getPixelPosition(frame: Frame, width: number): number {
|
||||
return Math.round((frame / totalFrames) * width)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 九、API 设计
|
||||
|
||||
### 9.1 Project API
|
||||
|
||||
```typescript
|
||||
// 创建项目
|
||||
POST /api/v1/project
|
||||
{
|
||||
name: string,
|
||||
fps: number,
|
||||
totalFrames: number,
|
||||
tracks: TrackConfig[]
|
||||
}
|
||||
|
||||
// 获取项目
|
||||
GET /api/v1/project/:id
|
||||
|
||||
// 更新项目
|
||||
PUT /api/v1/project/:id
|
||||
|
||||
// 添加轨道
|
||||
POST /api/v1/project/:id/track
|
||||
{
|
||||
type: 'video' | 'audio' | 'mark-only',
|
||||
name: string,
|
||||
file_uuid?: string
|
||||
}
|
||||
```
|
||||
|
||||
### 9.2 Track API
|
||||
|
||||
```typescript
|
||||
// 获取轨道
|
||||
GET /api/v1/track/:id
|
||||
|
||||
// 更新轨道
|
||||
PUT /api/v1/track/:id
|
||||
{
|
||||
enabled?: boolean,
|
||||
name?: string
|
||||
}
|
||||
|
||||
// 删除轨道
|
||||
DELETE /api/v1/track/:id
|
||||
```
|
||||
|
||||
### 9.3 Mark API
|
||||
|
||||
```typescript
|
||||
// 获取轨道的所有 Marks
|
||||
GET /api/v1/track/:trackId/marks
|
||||
|
||||
// 添加 Mark
|
||||
POST /api/v1/track/:trackId/mark
|
||||
{
|
||||
startFrame: Frame,
|
||||
endFrame?: Frame,
|
||||
tag: string,
|
||||
note?: string
|
||||
}
|
||||
|
||||
// 更新 Mark
|
||||
PUT /api/v1/mark/:id
|
||||
{
|
||||
startFrame?: Frame,
|
||||
endFrame?: Frame,
|
||||
tag?: string,
|
||||
note?: string,
|
||||
status?: string
|
||||
}
|
||||
|
||||
// 删除 Mark
|
||||
DELETE /api/v1/mark/:id
|
||||
|
||||
// 批量操作
|
||||
POST /api/v1/track/:trackId/marks/batch
|
||||
{
|
||||
action: 'update' | 'delete',
|
||||
filter: { tag?: string, status?: string },
|
||||
updates?: Partial<Mark>
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十、数据持久化
|
||||
|
||||
### 10.1 SQLite 表结构
|
||||
|
||||
```sql
|
||||
-- 项目表
|
||||
CREATE TABLE projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
fps INTEGER NOT NULL,
|
||||
total_frames INTEGER NOT NULL,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 轨道表
|
||||
CREATE TABLE tracks (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL, -- 'video' | 'audio' | 'mark-only'
|
||||
name TEXT NOT NULL,
|
||||
file_uuid TEXT,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
position INTEGER, -- 轨道顺序
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id)
|
||||
);
|
||||
|
||||
-- Mark 表
|
||||
CREATE TABLE marks (
|
||||
id TEXT PRIMARY KEY,
|
||||
track_id TEXT NOT NULL,
|
||||
start_frame INTEGER NOT NULL,
|
||||
end_frame INTEGER,
|
||||
tag TEXT NOT NULL,
|
||||
note TEXT,
|
||||
status TEXT DEFAULT 'pending',
|
||||
severity TEXT,
|
||||
metadata TEXT, -- JSON
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (track_id) REFERENCES tracks(id)
|
||||
);
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX idx_marks_track ON marks(track_id);
|
||||
CREATE INDEX idx_marks_frame ON marks(start_frame, end_frame);
|
||||
CREATE INDEX idx_marks_tag ON marks(tag);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十一、UI 组件
|
||||
|
||||
### 11.1 组件层级
|
||||
|
||||
```
|
||||
App
|
||||
└─ ProjectEditor
|
||||
├─ Toolbar
|
||||
│ ├─ PlayButton
|
||||
│ ├─ FrameInput
|
||||
│ └─ ZoomControl
|
||||
├─ TimelinePanel
|
||||
│ ├─ PlayheadIndicator
|
||||
│ ├─ TrackList
|
||||
│ │ ├─ TrackItem (Video)
|
||||
│ │ ├─ TrackItem (Audio)
|
||||
│ │ └─ TrackItem (Mark-only)
|
||||
│ └─ TimeScale
|
||||
└─ VideoPlayer (可选)
|
||||
└─ MarkOverlay
|
||||
```
|
||||
|
||||
### 11.2 核心组件
|
||||
|
||||
#### TimelinePanel
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="timeline-panel">
|
||||
<div class="playhead" :style="{ left: playheadPct + '%' }"></div>
|
||||
|
||||
<div v-for="track in tracks" :key="track.id" class="track-row">
|
||||
<div class="track-header">
|
||||
<input type="checkbox" v-model="track.enabled" />
|
||||
<span>{{ track.name }}</span>
|
||||
</div>
|
||||
<div class="track-timeline" @click="onTimelineClick">
|
||||
<div
|
||||
v-for="mark in track.marks"
|
||||
:key="mark.id"
|
||||
class="mark"
|
||||
:class="{ active: isMarkActive(mark) }"
|
||||
:style="getMarkStyle(mark)"
|
||||
@click.stop="onMarkClick(mark)"
|
||||
>
|
||||
{{ mark.tag }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const playheadPct = computed(() =>
|
||||
(project.playhead / project.totalFrames) * 100
|
||||
)
|
||||
|
||||
function getMarkStyle(mark: Mark) {
|
||||
return {
|
||||
left: `${(mark.startFrame / project.totalFrames) * 100}%`,
|
||||
width: `${((mark.endFrame || mark.startFrame) - mark.startFrame) / project.totalFrames * 100}%`
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十二、实现步骤
|
||||
|
||||
### 12.1 Phase 1: 基础架构(P0)
|
||||
|
||||
1. **数据结构定义**
|
||||
- 定义 TypeScript 接口
|
||||
- 实现 Frame 操作函数
|
||||
|
||||
2. **SQLite 存储**
|
||||
- 创建表结构
|
||||
- 实现 CRUD API
|
||||
|
||||
3. **单轨道 Mark 系统**
|
||||
- Track + Mark 基础功能
|
||||
- Timeline 展示
|
||||
|
||||
### 12.2 Phase 2: 多轨播放(P1)
|
||||
|
||||
1. **多轨道管理**
|
||||
- Project 层实现
|
||||
- Track 增删改
|
||||
|
||||
2. **同步播放**
|
||||
- PlaybackController
|
||||
- 全局 Playhead
|
||||
|
||||
3. **Timeline 对齐**
|
||||
- 统一计算公式
|
||||
- 视觉对齐
|
||||
|
||||
### 12.3 Phase 3: 数据集成(P1)
|
||||
|
||||
1. **数据源转换**
|
||||
- Face Trace → Mark
|
||||
- ASR → Mark
|
||||
- Noise → Mark
|
||||
|
||||
2. **FPS 管理**
|
||||
- 视频元数据获取
|
||||
- Time ↔ Frame 转换
|
||||
|
||||
### 12.4 Phase 4: 操作功能(P2)
|
||||
|
||||
1. **Mark 操作**
|
||||
- 批量更新
|
||||
- 批量删除
|
||||
|
||||
2. **剪辑功能**
|
||||
- 基于 Mark 的剪辑
|
||||
- 片段操作
|
||||
|
||||
---
|
||||
|
||||
## 十三、测试计划
|
||||
|
||||
### 13.1 单元测试
|
||||
|
||||
- Frame 操作函数
|
||||
- Time ↔ Frame 转换
|
||||
- Mark 过滤/排序
|
||||
- Timeline 位置计算
|
||||
|
||||
### 13.2 集成测试
|
||||
|
||||
- 多轨同步播放
|
||||
- Playhead 对齐
|
||||
- Mark 高亮状态
|
||||
- 数据持久化
|
||||
|
||||
### 13.3 E2E 测试
|
||||
|
||||
- 创建项目
|
||||
- 添加轨道
|
||||
- 创建/编辑/删除 Mark
|
||||
- 播放和导航
|
||||
|
||||
---
|
||||
|
||||
## 十四、附录
|
||||
|
||||
### 14.1 命名约定
|
||||
|
||||
| 概念 | 变量名 | 类型 |
|
||||
|------|--------|------|
|
||||
| 帧 | `frame` | `Frame` (number) |
|
||||
| 起始帧 | `startFrame` | `Frame` |
|
||||
| 结束帧 | `endFrame` | `Frame` |
|
||||
| 总帧数 | `totalFrames` | `Frame` |
|
||||
| 帧率 | `fps` | `number` |
|
||||
| 播放头 | `playhead` | `Frame` |
|
||||
| 时间(秒) | `time` / `sec` | `number` |
|
||||
|
||||
### 14.2 配置参数
|
||||
|
||||
| 参数 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| 默认 FPS | 30 | 无视频时使用 |
|
||||
| 最小 Mark 宽度 | 1px | 渲染时保证可见 |
|
||||
| Timeline 高度 | 24px | 每个轨道行高 |
|
||||
| Playhead 更新频率 | 60fps | requestAnimationFrame |
|
||||
|
||||
---
|
||||
|
||||
## 十五、版本历史
|
||||
|
||||
| 版本 | 日期 | 变更 |
|
||||
|------|------|------|
|
||||
| 1.0 | 2026-07-24 | 初始设计文档 |
|
||||
| 1.1 | 2026-07-24 | 添加概念统一说明、Mark 点击交互行为章节 |
|
||||
281
.opencode/plans/video_editing_positioning_standards_research.md
Normal file
281
.opencode/plans/video_editing_positioning_standards_research.md
Normal file
@@ -0,0 +1,281 @@
|
||||
# 视频编辑定位标准研究报告
|
||||
|
||||
## 一、行业标准概览
|
||||
|
||||
### 1.1 SMPTE Timecode(行业标准)
|
||||
|
||||
**来源**: Society of Motion Picture and Television Engineers (SMPTE)
|
||||
**标准**: SMPTE 12M (2008年修订为 SMPTE 12M-1, 12M-2, 12M-3)
|
||||
|
||||
**核心原则**:
|
||||
- **Frame-based**: 以帧为单位,格式 `HH:MM:SS:FF`(小时:分钟:秒:帧)
|
||||
- **整数运算**: 所有帧号都是整数
|
||||
- **原点对齐**: Frame 0 = Time 0
|
||||
- **FPS 依赖**: 帧率决定显示时间,但帧数不变
|
||||
|
||||
**支持的帧率**:
|
||||
| FPS | 用途 |
|
||||
|-----|------|
|
||||
| 23.98 (24÷1.001) | 北美 HDTV |
|
||||
| 24 | 电影、ATSC、2K/4K/6K |
|
||||
| 25 | PAL/SECAM(欧洲、澳大利亚)|
|
||||
| 29.97 (30÷1.001) | NTSC(北美、日本)|
|
||||
| 30 | ATSC |
|
||||
|
||||
**Drop-Frame vs Non-Drop-Frame**:
|
||||
- **Drop-Frame (DF)**: 每 minute 跳过 frame 0,1(除第10分钟),用于补偿 29.97fps
|
||||
- **Non-Drop-Frame (NDF)**: 连续帧号
|
||||
- 表示法: DF 用分号 `HH;MM;SS;FF`,NDF 用冒号 `HH:MM:SS:FF`
|
||||
|
||||
---
|
||||
|
||||
### 1.2 EDL (Edit Decision List)
|
||||
|
||||
**用途**: 剪辑决策列表,用于记录剪辑点
|
||||
|
||||
**简单 EDL 格式**:
|
||||
|
||||
**Time-based**:
|
||||
```
|
||||
[begin second] [end second] [action]
|
||||
5.3 7.1 0 # cut from 5.3s to 7.1s
|
||||
15 16.7 1 # mute from 15s to 16.7s
|
||||
```
|
||||
|
||||
**Frame-based**:
|
||||
```
|
||||
#[begin frame] #[end frame] [action]
|
||||
#127 #170 0 # cut from frame 127 to 170
|
||||
#360 #400 1 # mute from frame 360 to 400
|
||||
```
|
||||
|
||||
**关键点**:
|
||||
- 支持两种定位方式:time(浮点)和 frame(整数)
|
||||
- Action 类型:0=cut, 1=mute, 2=scene marker, 3=skip
|
||||
- **推荐使用 Frame-based** 以保证精度
|
||||
|
||||
---
|
||||
|
||||
### 1.3 AAF (Advanced Authoring Format)
|
||||
|
||||
**来源**: Advanced Media Workflow Association (AMWA)
|
||||
**标准化**: SMPTE
|
||||
|
||||
**特点**:
|
||||
- 专业级跨平台数据交换格式
|
||||
- 包含 Essence Data(音频、视频等)和 Metadata
|
||||
- 支持复杂的对象关系描述
|
||||
- 追踪从源文件到最终产出的完整历史
|
||||
- 用于"进行中的作品"(works in progress)
|
||||
|
||||
**与 MXF 的关系**:
|
||||
- AAF 用于编辑中的项目
|
||||
- MXF 用于交换完成的媒体产品
|
||||
- MXF 是 AAF 数据模型的子集
|
||||
|
||||
---
|
||||
|
||||
### 1.4 OpenTimelineIO
|
||||
|
||||
**来源**: Pixar 开发,现由 Academy Software Foundation 维护
|
||||
**状态**: 成熟框架,广泛应用于影视行业
|
||||
|
||||
**核心设计**:
|
||||
- **OpenTime**: 无依赖的时间处理库
|
||||
- **数据模型**: Timeline → Track → Clip → Media Reference
|
||||
- **帧与时间分离**:
|
||||
- `RationalTime`: value + rate(精确的时间表示)
|
||||
- `TimeRange`: start_time + duration
|
||||
- 帧是整数,时间是 RationalTime
|
||||
|
||||
**示例**:
|
||||
```python
|
||||
import opentimelineio as otio
|
||||
|
||||
timeline = otio.adapters.read_from_file("project.aaf")
|
||||
for clip in timeline.find_clips():
|
||||
print(clip.name, clip.duration())
|
||||
# duration() 返回 RationalTime(value, rate)
|
||||
```
|
||||
|
||||
**支持的格式**:
|
||||
- Final Cut Pro XML
|
||||
- AAF
|
||||
- CMX 3600 EDL
|
||||
- 原生 `.otio`, `.otioz`, `.otiod`
|
||||
|
||||
---
|
||||
|
||||
## 二、主流软件对比
|
||||
|
||||
### 2.1 Adobe Premiere Pro
|
||||
|
||||
**定位方式**:
|
||||
- 使用 SMPTE Timecode
|
||||
- 支持 Drop-Frame 和 Non-Drop-Frame
|
||||
- 内部用帧号定位,显示用时间码
|
||||
- 导出格式:EDL, XML, AAF
|
||||
|
||||
### 2.2 Final Cut Pro
|
||||
|
||||
**定位方式**:
|
||||
- 使用 Apple 时间码格式
|
||||
- Frame-based 内部处理
|
||||
- 导出格式:FCP XML(专有格式)
|
||||
- 时间以 frame + fps 存储
|
||||
|
||||
### 2.3 DaVinci Resolve
|
||||
|
||||
**定位方式**:
|
||||
- 支持多种时间码格式
|
||||
- 项目设置选择 FPS(全局)
|
||||
- Timeline 使用 frame 定位
|
||||
- 支持 EDL, AAF, OTIO 导出
|
||||
|
||||
### 2.4 Avid Media Composer
|
||||
|
||||
**定位方式**:
|
||||
- 专业级 SMPTE Timecode 实现
|
||||
- 支持 Drop-Frame 计数
|
||||
- 轨道级时间码管理
|
||||
- AAF 原生支持
|
||||
|
||||
---
|
||||
|
||||
## 三、核心设计原则总结
|
||||
|
||||
### 3.1 行业共识
|
||||
|
||||
| 原则 | 说明 |
|
||||
|------|------|
|
||||
| **Frame 是唯一标准** | 所有定位、剪辑、同步都用帧号 |
|
||||
| **整数运算** | 避免浮点精度问题 |
|
||||
| **FPS 是元数据** | 不改变帧数,只改变显示时间 |
|
||||
| **原点对齐** | Frame 0 = Time 0 |
|
||||
| **Time 仅用于显示** | Frame ÷ FPS = Time |
|
||||
|
||||
### 3.2 数据流架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Source Data (各种格式) │
|
||||
│ - Video files (frame-based metadata) │
|
||||
│ - Audio files (time-based or sample-based) │
|
||||
│ - Analysis results (various formats) │
|
||||
└─────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 统一转换层 (标准化) │
|
||||
│ - Time → Frame (用 FPS) │
|
||||
│ - Sample → Frame (用 sample_rate) │
|
||||
│ - 所有数据统一为 Frame │
|
||||
└─────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 内部数据模型 (Frame-based) │
|
||||
│ - Mark.startFrame (integer) │
|
||||
│ - Mark.endFrame (integer) │
|
||||
│ - Clip.timelineStartFrame (integer) │
|
||||
│ - Project.fps (metadata) │
|
||||
└─────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Timeline 展示层 │
|
||||
│ - 位置:frame / totalFrames * 100% │
|
||||
│ - 显示:frame / fps → "MM:SS" │
|
||||
│ - 所有轨道对齐 │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、对 Momentry Studio 的启示
|
||||
|
||||
### 4.1 遵循行业标准
|
||||
|
||||
**推荐做法**:
|
||||
1. ✅ 采用 Frame 作为唯一定位标准
|
||||
2. ✅ 所有 Frame 操作使用整数
|
||||
3. ✅ FPS 作为项目元数据
|
||||
4. ✅ Time 仅用于显示转换
|
||||
5. ✅ 原点对齐:Frame 0 = Time 0
|
||||
|
||||
### 4.2 数据格式建议
|
||||
|
||||
```typescript
|
||||
// Mark 定义(符合行业标准)
|
||||
interface Mark {
|
||||
id: string
|
||||
startFrame: number // integer, SMPTE-style
|
||||
endFrame?: number // integer
|
||||
tag: string
|
||||
note?: string
|
||||
}
|
||||
|
||||
// Clip 定义(类似 OTIO)
|
||||
interface Clip {
|
||||
sourceStartFrame: number // integer
|
||||
sourceEndFrame: number // integer
|
||||
timelineStartFrame: number // integer
|
||||
timelineEndFrame: number // integer
|
||||
}
|
||||
|
||||
// Project 定义
|
||||
interface Project {
|
||||
fps: number // 元数据
|
||||
totalFrames: number // integer
|
||||
duration: number // 仅用于显示,计算得出
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 导出格式支持
|
||||
|
||||
**优先级**:
|
||||
1. **P0 - 内部格式**: 自定义 JSON/SQLite(Frame-based)
|
||||
2. **P1 - EDL**: 简单 EDL(Frame-based)
|
||||
3. **P2 - OTIO**: OpenTimelineIO 格式(行业标准)
|
||||
4. **P3 - AAF/FCPXML**: 专业软件互操作
|
||||
|
||||
---
|
||||
|
||||
## 五、参考资料
|
||||
|
||||
### 5.1 标准文档
|
||||
|
||||
- [SMPTE ST 12-1:2008](https://ieeexplore.ieee.org/document/7289820/) - Time and Control Code
|
||||
- [CMX 3600 EDL Specification](http://xmil.biz/EDL-X/CMX3600.pdf)
|
||||
- [OpenTimelineIO Documentation](https://opentimelineio.readthedocs.io/)
|
||||
|
||||
### 5.2 相关 Wikipedia 文章
|
||||
|
||||
- [SMPTE timecode](https://en.wikipedia.org/wiki/SMPTE_timecode)
|
||||
- [Edit decision list](https://en.wikipedia.org/wiki/Edit_decision_list)
|
||||
- [Advanced Authoring Format](https://en.wikipedia.org/wiki/Advanced_Authoring_Format)
|
||||
|
||||
### 5.3 开源项目
|
||||
|
||||
- [OpenTimelineIO](https://github.com/AcademySoftwareFoundation/OpenTimelineIO) - Pixar 开源
|
||||
- [OpenTimelineIO Plugins](https://github.com/OpenTimelineIO) - 各种适配器
|
||||
|
||||
---
|
||||
|
||||
## 六、结论
|
||||
|
||||
视频编辑行业的定位标准明确且统一:
|
||||
|
||||
1. **Frame 是唯一标准** - 所有专业软件和行业标准都基于帧号
|
||||
2. **整数运算** - 避免浮点精度问题
|
||||
3. **FPS 作为元数据** - 不改变帧数,只影响显示时间
|
||||
4. **OpenTimelineIO 是最佳参考** - 现代、开源、行业标准
|
||||
|
||||
Momentry Studio 的设计完全符合行业标准:
|
||||
- ✅ Frame-based 定位
|
||||
- ✅ 整数运算
|
||||
- ✅ FPS 作为元数据
|
||||
- ✅ 原点对齐
|
||||
- ✅ 多轨同步
|
||||
|
||||
可以继续按照现有设计文档推进实现。
|
||||
14
AGENTS.md
14
AGENTS.md
@@ -1,5 +1,15 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Important: File Path Convention
|
||||
|
||||
**All file paths in this document and code references must use full absolute paths.**
|
||||
|
||||
Examples:
|
||||
- ✅ `/Users/accusys/momentry_studio/src/views/PeopleView.vue`
|
||||
- ❌ `src/views/PeopleView.vue`
|
||||
- ✅ `/Users/accusys/momentry_studio/src-tauri/src/db.rs`
|
||||
- ❌ `src-tauri/src/db.rs`
|
||||
|
||||
## Stack
|
||||
- **Frontend**: Vue 3 + TypeScript + Vite (port 5173)
|
||||
- **Desktop**: Tauri 2 (Rust backend in `src-tauri/`)
|
||||
@@ -7,13 +17,15 @@
|
||||
|
||||
## Commands
|
||||
```
|
||||
npm run dev # Vite dev server (port 5173)
|
||||
npm run proxy # Start Rust proxy binary only (port 8888)
|
||||
npm run dev # Start Rust proxy + Vite dev server (port 5173)
|
||||
npm run build # vue-tsc --noEmit && vite build (typecheck + bundle)
|
||||
npm run preview # Preview production build
|
||||
npm run tauri # Alias for `cargo tauri`
|
||||
cargo tauri dev # Full Tauri desktop dev (builds frontend + opens native window)
|
||||
cargo tauri build # Full native app build (outputs to src-tauri/target/release/bundle/)
|
||||
```
|
||||
- **Browser dev flow**: `npm run dev` → kills old proxy on 8888 → starts `momentry-proxy` → sleeps 2s → starts Vite. Vite proxy forwards `/api/v1/*` to Rust proxy at `localhost:8888`. Rust proxy handles face-crop, cluster-results, identity-matches, search-history, bookmarks, profile images locally; proxies rest to Core API at `localhost:3002`.
|
||||
|
||||
## Architecture
|
||||
- **Dual-mode API**: Frontend uses `apiCall()` from `src/api/index.ts` which detects `window.__TAURI__` and dispatches to either Tauri IPC (`invoke`) or HTTP to proxy at `http://0.0.0.0:8888`.
|
||||
|
||||
68
docs/FACE_CLUSTERING_AGENT_REQUEST.md
Normal file
68
docs/FACE_CLUSTERING_AGENT_REQUEST.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Face Clustering Agent Endpoint Request
|
||||
|
||||
## 需求
|
||||
新增一個 on-demand face clustering endpoint,讓前端可以手動觸發 face trace 分組。
|
||||
|
||||
## 背景
|
||||
Momentry Studio 的 People 頁面有「Face Deduplication」按鈕,目的是讓使用者可以對已處理 face 的影片執行 face 分組(clustering)。目前這個功能無法使用,因為對應的 API endpoint 不存在。
|
||||
|
||||
## 需要的 Endpoint
|
||||
|
||||
### `POST /api/v1/file/:file_uuid/cluster-agent`
|
||||
|
||||
**用途**:對指定檔案觸發 face clustering,將相似的 face traces 分組。
|
||||
|
||||
#### Request
|
||||
```
|
||||
POST /api/v1/file/{file_uuid}/cluster-agent
|
||||
Content-Type: application/json
|
||||
X-API-Key: {key}
|
||||
|
||||
{}
|
||||
```
|
||||
|
||||
| 參數 | 類型 | 必填 | 說明 |
|
||||
|------|------|------|------|
|
||||
| `file_uuid` | string | ✅ (URL path) | 檔案 UUID |
|
||||
|
||||
#### Response (200)
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"file_uuid": "477b24d3...",
|
||||
"message": "Clustering started for file",
|
||||
"clusters": 0,
|
||||
"total_traces": 2
|
||||
}
|
||||
```
|
||||
|
||||
| 欄位 | 類型 | 說明 |
|
||||
|------|------|------|
|
||||
| `success` | boolean | 是否成功觸發 |
|
||||
| `file_uuid` | string | 檔案 UUID |
|
||||
| `message` | string | 狀態訊息 |
|
||||
| `clusters` | integer | 已分組數量(剛觸發時為 0) |
|
||||
| `total_traces` | integer | 該檔案總 face trace 數 |
|
||||
|
||||
#### 實作邏輯(建議)
|
||||
1. 檢查檔案是否存在且 face 已處理完成
|
||||
2. 讀取 face detections / face embeddings
|
||||
3. 執行 face clustering(可參考 pipeline 中的 clustering 邏輯)
|
||||
4. 將分組結果寫入 TKG(更新 `face_trace` node 的 `label`)
|
||||
5. 回傳觸發結果(clustering 可非同步執行)
|
||||
|
||||
### 前端消費流程
|
||||
```
|
||||
1. POST /api/v1/file/{uuid}/cluster-agent → 觸發 clustering
|
||||
2. GET /api/v1/file/{uuid}/face-groups → 輪詢直到 face_groups 有資料
|
||||
3. 顯示 face groups 在 People 頁面
|
||||
```
|
||||
|
||||
## 相關檔案
|
||||
- **前端按鈕**: `src/views/PeopleView.vue` line 14-18 (`runClusterAgent()`)
|
||||
- **API 映射**: `src/api/index.ts` line 369-371 (`run_cluster_agent`)
|
||||
- **現有 face-groups**: `GET /api/v1/file/:file_uuid/face-groups` (已存在,用於讀取結果)
|
||||
- **Pipeline 處理**: `docs_v1.0/doc_wasm/modules/05_process.md` (face 處理流程)
|
||||
|
||||
## 優先級
|
||||
中優先級 — 目前有 2 個 unassigned traces 但無法分組,使用者無法有效的進行 face deduplication。
|
||||
358
docs/SEARCH_IMPROVEMENTS_FOR_CORE_TEAM.md
Normal file
358
docs/SEARCH_IMPROVEMENTS_FOR_CORE_TEAM.md
Normal file
@@ -0,0 +1,358 @@
|
||||
# Search Improvements for Core Team
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the search improvements implemented in Studio that may require Core API support or coordination.
|
||||
|
||||
---
|
||||
|
||||
## 1. Keyword Search - Result Merging
|
||||
|
||||
### Current Implementation (Studio-side)
|
||||
|
||||
```typescript
|
||||
function mergeResults(results: any[]): any[] {
|
||||
// Sort by start_frame
|
||||
const sorted = [...results].sort((a, b) => (a.start_frame || 0) - (b.start_frame || 0))
|
||||
|
||||
// Merge overlapping results (gap <= 30 frames)
|
||||
for (const r of sorted) {
|
||||
const last = merged[merged.length - 1]
|
||||
if (last && last.file_uuid === r.file_uuid) {
|
||||
const gap = r.start_frame - last.end_frame
|
||||
if (gap <= 30) {
|
||||
// Merge: extend end_frame, keep best summary
|
||||
last.end_frame = Math.max(last.end_frame, r.end_frame)
|
||||
if (r.similarity > last.similarity) {
|
||||
last.summary = r.summary
|
||||
last.similarity = r.similarity
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
merged.push({ ...r })
|
||||
}
|
||||
return merged.slice(0, 10) // Top 10
|
||||
}
|
||||
```
|
||||
|
||||
### Behavior
|
||||
|
||||
| Before | After |
|
||||
|--------|-------|
|
||||
| 20 results, overlapping segments | 10 merged results |
|
||||
| Same content appears multiple times | Deduplicated by frame proximity |
|
||||
| Short segments (1-2 seconds) | Merged into longer segments |
|
||||
|
||||
### Core API Considerations
|
||||
|
||||
**Option A: Server-side Merging**
|
||||
- Add `merge=true` parameter to `/api/v1/search/smart`
|
||||
- Server returns pre-merged results
|
||||
- More efficient, less data transfer
|
||||
|
||||
**Option B: Client-side (Current)**
|
||||
- Studio fetches 30 results, merges locally
|
||||
- More flexible, no Core API changes needed
|
||||
|
||||
---
|
||||
|
||||
## 2. Minimum Duration Enforcement
|
||||
|
||||
### Problem
|
||||
|
||||
Some search results are too short (1-2 seconds) to provide meaningful context.
|
||||
|
||||
### Proposed Solution
|
||||
|
||||
Add `min_duration` parameter to search endpoints:
|
||||
|
||||
```bash
|
||||
POST /api/v1/search/smart
|
||||
{
|
||||
"query": "Audrey Hepburn",
|
||||
"min_duration": 10, // Minimum 10 seconds
|
||||
"limit": 10
|
||||
}
|
||||
```
|
||||
|
||||
### Core API Impact
|
||||
|
||||
| Endpoint | Change |
|
||||
|----------|--------|
|
||||
| `/api/v1/search/smart` | Add `min_duration` param |
|
||||
| `/api/v1/search/llm-smart` | Add `min_duration` param |
|
||||
|
||||
**Implementation:**
|
||||
1. Filter results where `end_time - start_time < min_duration`
|
||||
2. Or expand short results by including adjacent chunks
|
||||
|
||||
---
|
||||
|
||||
## 3. Frame-based Range Adjustment
|
||||
|
||||
### Current Studio Implementation
|
||||
|
||||
Users can adjust playback range by frame number:
|
||||
|
||||
```vue
|
||||
<div class="card-range">
|
||||
<input type="number" v-model.number="r._startFrame">
|
||||
<input type="number" v-model.number="r._endFrame">
|
||||
</div>
|
||||
```
|
||||
|
||||
### Core API Requirements
|
||||
|
||||
No changes needed - Studio uses existing `start_frame`/`end_frame` fields.
|
||||
|
||||
**Display format:**
|
||||
```
|
||||
F100–F350 (4:10–14:35)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Mark & Export Feature
|
||||
|
||||
### Studio Implementation
|
||||
|
||||
```typescript
|
||||
const markedResults = ref<any[]>([])
|
||||
|
||||
function toggleMark(r: any) {
|
||||
const key = `${r.file_uuid}:${r.start_frame}-${r.end_frame}`
|
||||
const idx = markedResults.value.findIndex(m =>
|
||||
`${m.file_uuid}:${m.start_frame}-${m.end_frame}` === key
|
||||
)
|
||||
if (idx >= 0) {
|
||||
markedResults.value.splice(idx, 1)
|
||||
} else {
|
||||
markedResults.value.push({...r})
|
||||
}
|
||||
}
|
||||
|
||||
function exportMarked() {
|
||||
const data = {
|
||||
exportedAt: new Date().toISOString(),
|
||||
count: markedResults.value.length,
|
||||
results: markedResults.value
|
||||
}
|
||||
// Download as JSON
|
||||
}
|
||||
```
|
||||
|
||||
### Export Format
|
||||
|
||||
```json
|
||||
{
|
||||
"exportedAt": "2026-07-20T12:00:00.000Z",
|
||||
"count": 3,
|
||||
"results": [
|
||||
{
|
||||
"file_uuid": "abc123",
|
||||
"file_name": "Roman Holiday.mp4",
|
||||
"start_frame": 100,
|
||||
"end_frame": 350,
|
||||
"start_time": 4.16,
|
||||
"end_time": 14.58,
|
||||
"summary": "Audrey Hepburn speaking...",
|
||||
"similarity": 0.85
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Future Core API Integration
|
||||
|
||||
**Possible endpoints:**
|
||||
|
||||
```
|
||||
POST /api/v1/marks # Save marked segment
|
||||
GET /api/v1/marks # List saved marks
|
||||
POST /api/v1/marks/export # Export marks as EDL/JSON
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Vector/Keyword Weight Ratio
|
||||
|
||||
### Current State
|
||||
|
||||
`/api/v1/search/llm-smart` uses RRF (Reciprocal Rank Fusion):
|
||||
- Vector search: 50%
|
||||
- Keyword (BM25): 50%
|
||||
|
||||
### Proposed Change
|
||||
|
||||
Increase vector weight for semantic relevance:
|
||||
|
||||
```python
|
||||
# Current
|
||||
score = 0.5 / (k + rank_vector) + 0.5 / (k + rank_keyword)
|
||||
|
||||
# Proposed
|
||||
VECTOR_WEIGHT = 0.7
|
||||
KEYWORD_WEIGHT = 0.3
|
||||
score = VECTOR_WEIGHT / (k + rank_vector) + KEYWORD_WEIGHT / (k + rank_keyword)
|
||||
```
|
||||
|
||||
### Core API Change Required
|
||||
|
||||
Add `vector_weight` parameter:
|
||||
|
||||
```bash
|
||||
POST /api/v1/search/llm-smart
|
||||
{
|
||||
"query": "two people talking",
|
||||
"vector_weight": 0.7, # Default: 0.5
|
||||
"limit": 10
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Agent Search - Question Templates
|
||||
|
||||
### Studio Implementation
|
||||
|
||||
Pre-defined question templates shown below search input:
|
||||
|
||||
```typescript
|
||||
const QUESTION_TEMPLATES = [
|
||||
{
|
||||
category: '找檔案',
|
||||
examples: [
|
||||
'找出包含 {人物} 的影片',
|
||||
'找出 {年份} 年的影片'
|
||||
]
|
||||
},
|
||||
{
|
||||
category: '找人物',
|
||||
examples: [
|
||||
'{人物} 出現在哪些影片?',
|
||||
'{人物} 和 {人物} 第一次同框'
|
||||
]
|
||||
},
|
||||
{
|
||||
category: '找內容',
|
||||
examples: [
|
||||
'找出討論 {主題} 的片段',
|
||||
'找出 {時間點} 發生什麼事'
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Core API Requirements
|
||||
|
||||
No changes needed - templates are Studio-side UI.
|
||||
|
||||
---
|
||||
|
||||
## 7. VLM Search Integration (Future)
|
||||
|
||||
### Proposal
|
||||
|
||||
Add visual-language model search capability:
|
||||
|
||||
```bash
|
||||
POST /api/v1/search/vlm
|
||||
{
|
||||
"query": "person wearing red dress",
|
||||
"file_uuid": "optional"
|
||||
}
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"file_uuid": "abc123",
|
||||
"start_frame": 1000,
|
||||
"end_frame": 1050,
|
||||
"description": "Woman in red dress walking",
|
||||
"confidence": 0.92
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Core Team Considerations
|
||||
|
||||
1. Model selection (CLIP, BLIP, etc.)
|
||||
2. GPU requirements
|
||||
3. Indexing strategy
|
||||
4. Latency expectations
|
||||
|
||||
---
|
||||
|
||||
## Summary of Core API Changes
|
||||
|
||||
| Feature | Priority | Core API Changes |
|
||||
|---------|----------|------------------|
|
||||
| Result merging | Low | Optional: `merge=true` param |
|
||||
| Min duration | Medium | Add `min_duration` param |
|
||||
| Frame adjustment | None | No changes needed |
|
||||
| Mark/Export | Low | Future: marks endpoints |
|
||||
| Vector weight | Medium | Add `vector_weight` param |
|
||||
| Question templates | None | Studio-side only |
|
||||
| VLM search | Future | New endpoint |
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist for Core Team
|
||||
|
||||
### 1. Search Endpoint Testing
|
||||
|
||||
```bash
|
||||
# Test basic search
|
||||
curl -X POST http://localhost:3002/api/v1/search/smart \
|
||||
-H "X-API-Key: $KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "test", "limit": 30}'
|
||||
|
||||
# Test with min_duration (if implemented)
|
||||
curl -X POST http://localhost:3002/api/v1/search/smart \
|
||||
-H "X-API-Key: $KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "test", "min_duration": 10}'
|
||||
|
||||
# Test vector weight (if implemented)
|
||||
curl -X POST http://localhost:3002/api/v1/search/llm-smart \
|
||||
-H "X-API-Key: $KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "test", "vector_weight": 0.7}'
|
||||
```
|
||||
|
||||
### 2. Performance Testing
|
||||
|
||||
| Metric | Target |
|
||||
|--------|--------|
|
||||
| Search latency | < 500ms |
|
||||
| Result count | Stable at limit=30 |
|
||||
| Memory usage | No significant increase |
|
||||
|
||||
### 3. Integration Testing
|
||||
|
||||
- [ ] Search with file_uuid filter
|
||||
- [ ] Search without file_uuid (global)
|
||||
- [ ] Verify start_frame/end_frame accuracy
|
||||
- [ ] Verify time/frame consistency (fps)
|
||||
|
||||
---
|
||||
|
||||
## Questions for Core Team
|
||||
|
||||
1. Should result merging be server-side or client-side?
|
||||
2. What's the acceptable latency for search?
|
||||
3. Is `min_duration` parameter feasible?
|
||||
4. Can `vector_weight` be made configurable?
|
||||
5. Timeline for VLM search integration?
|
||||
|
||||
---
|
||||
|
||||
*Document created: 2026-07-20*
|
||||
*Author: Studio Team*
|
||||
117
docs/SEARCH_SOURCE_TYPE_REQUEST.md
Normal file
117
docs/SEARCH_SOURCE_TYPE_REQUEST.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# Search API Source Type Request
|
||||
|
||||
## Problem
|
||||
|
||||
Currently, the Search API (`/api/v1/search/keyword` and `/api/v1/search/semantic`) returns results without indicating the source type:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 0,
|
||||
"file_uuid": "...",
|
||||
"start_frame": 0,
|
||||
"end_frame": 128,
|
||||
"start_time": 0.0,
|
||||
"end_time": 5.33,
|
||||
"summary": "And speaking of storage and workflow...",
|
||||
"text_content": "And speaking of storage and workflow...",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
There's no field to distinguish between:
|
||||
- **OCR**: Text extracted from video frames (on-screen text, titles, credits)
|
||||
- **ASRX**: Speech-to-text transcription
|
||||
|
||||
---
|
||||
|
||||
## Request
|
||||
|
||||
Add a `source_type` field to the `SearchResult` struct:
|
||||
|
||||
```rust
|
||||
pub struct SearchResult {
|
||||
pub id: i32,
|
||||
pub file_uuid: Option<String>,
|
||||
pub parent_id: i32,
|
||||
pub scene_order: Option<i32>,
|
||||
pub start_frame: i64,
|
||||
pub end_frame: i64,
|
||||
pub fps: f64,
|
||||
pub start_time: f64,
|
||||
pub end_time: f64,
|
||||
pub raw_text: Option<String>,
|
||||
pub summary: Option<String>,
|
||||
pub text_content: Option<String>,
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
pub similarity: Option<f64>,
|
||||
pub file_name: Option<String>,
|
||||
pub serve_url: Option<String>,
|
||||
pub thumbnail_url: Option<String>,
|
||||
pub source_type: Option<String>, // NEW: "ocr" or "asrx"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Expected Response
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 0,
|
||||
"file_uuid": "...",
|
||||
"start_frame": 0,
|
||||
"end_frame": 128,
|
||||
"start_time": 0.0,
|
||||
"end_time": 5.33,
|
||||
"summary": "And speaking of storage and workflow...",
|
||||
"text_content": "And speaking of storage and workflow...",
|
||||
"source_type": "ocr",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Use Case
|
||||
|
||||
Studio frontend needs to display source type tags on search result cards:
|
||||
|
||||
```
|
||||
[OCR] And speaking of storage and workflow...
|
||||
F0–128 (0:00–0:05)
|
||||
```
|
||||
|
||||
```
|
||||
[ASRX] Today we're talking about storage solutions...
|
||||
(0:10–0:20)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
1. The database already has `chunk_type` field with values like `'sentence'`, `'cut'`, etc.
|
||||
2. Need to map chunk types to source types:
|
||||
- OCR chunks → `source_type: "ocr"`
|
||||
- ASRX/sentence chunks → `source_type: "asrx"`
|
||||
3. Update `enrich_from_pg()` function to include source_type
|
||||
4. Update `SearchResult` struct serialization
|
||||
|
||||
---
|
||||
|
||||
## Affected Files
|
||||
|
||||
- `/Users/accusys/momentry_core/src/api/search.rs`: `SearchResult` struct and enrichment
|
||||
- `/Users/accusys/momentry_core/src/core/db/postgres_db.rs`: `get_chunk_by_file_and_chunk_id()` query
|
||||
|
||||
---
|
||||
|
||||
## Priority
|
||||
|
||||
Medium - Required for proper search result categorization and display
|
||||
|
||||
---
|
||||
|
||||
## Contact
|
||||
|
||||
Studio Team
|
||||
90
docs/VLM_AGENT_TOOL_REQUEST.md
Normal file
90
docs/VLM_AGENT_TOOL_REQUEST.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# VLM (Vision Language Model) Tool for Agent Search
|
||||
|
||||
## Request
|
||||
Add a `vlm_describe` tool to the Agent system that can analyze video keyframes via VLM.
|
||||
|
||||
## Background
|
||||
We have Ollama with LLaVA running locally (`http://localhost:11434`). The VLM can describe image content (people, objects, scenes, clothing, text, etc.). We want the Agent to be able to use this as a tool when users ask questions about visual content.
|
||||
|
||||
## Use Cases
|
||||
- "What is the person wearing in this scene?"
|
||||
- "Find scenes where someone is wearing a red shirt"
|
||||
- "Describe the background of this video"
|
||||
- "What objects are visible in frame 1000?"
|
||||
|
||||
## API Design
|
||||
|
||||
### Agent Tool Registration
|
||||
Add a `vlm_describe` tool to the Agent's tool registry in Core API.
|
||||
|
||||
### Tool Definition
|
||||
```json
|
||||
{
|
||||
"name": "vlm_describe",
|
||||
"description": "Analyze a video frame using Vision Language Model. Returns a description of the image content.",
|
||||
"parameters": {
|
||||
"file_uuid": "string - UUID of the video file",
|
||||
"frame": "integer - Frame number to analyze",
|
||||
"prompt": "string (optional) - Specific question about the image (default: 'Describe this image in 1-2 sentences.')"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Internal Implementation
|
||||
The Core API should call:
|
||||
```
|
||||
POST http://localhost:11434/api/generate
|
||||
{
|
||||
"model": "llava",
|
||||
"prompt": "<user prompt or default>",
|
||||
"images": ["<base64 of frame thumbnail>"],
|
||||
"stream": false,
|
||||
"options": { "num_predict": 80 }
|
||||
}
|
||||
```
|
||||
|
||||
To get the frame image, use the existing thumbnail API:
|
||||
```
|
||||
GET /api/v1/file/{file_uuid}/thumbnail?api_key={key}&frame={frame}
|
||||
```
|
||||
→ Returns JPEG bytes → resize to 480x270 → base64 encode → send to Ollama.
|
||||
|
||||
### Result Format
|
||||
```json
|
||||
{
|
||||
"tool": "vlm_describe",
|
||||
"result": {
|
||||
"file_uuid": "...",
|
||||
"frame": 1234,
|
||||
"description": "A man wearing a blue suit and red tie standing at a podium.",
|
||||
"time_sec": 41.13
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Performance (Benchmarked on M5 Max)
|
||||
|
||||
**Model**: LLaVA 7B Q4_0 (Ollama), 1280x720 → 480x270 resize
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Avg per frame | **1.02s** |
|
||||
| Min | 0.81s |
|
||||
| Max | 1.60s |
|
||||
| Throughput | ~1 fps (sequential) |
|
||||
| Concurrent (4 workers) | No speedup — Ollama queues single-GPU |
|
||||
|
||||
**Recommendations**:
|
||||
- Resize to 480x270 before sending reduces token count without quality loss
|
||||
- Batch processing doesn't help (Ollama queues requests to single model instance)
|
||||
- For large video indexing, process frames asynchronously in background
|
||||
- Consider a faster model if throughput becomes critical (e.g., LLaVA-Next 8B, Moondream 1.6B)
|
||||
|
||||
## Frontend Changes (for reference)
|
||||
Once Core API returns `vlm_describe` in agent sources, the frontend will display:
|
||||
- Tool badge: "VLM"
|
||||
- Description text paired with the relevant video frame
|
||||
- Click to jump to that frame in the video player
|
||||
|
||||
## Timeline
|
||||
Not urgent - this is an enhancement for visual search capabilities.
|
||||
186
docs/core-api-fix-video-range.md
Normal file
186
docs/core-api-fix-video-range.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# Core API Fix Request: Video Range Requests Support
|
||||
|
||||
## Issue
|
||||
|
||||
Video player in Studio cannot seek (跳轉) because Core API video endpoint does not support HTTP Range requests.
|
||||
|
||||
## Current Behavior
|
||||
|
||||
```bash
|
||||
$ curl -I "http://localhost:3002/api/v1/file/{uuid}/video?start_time=0&end_time=5"
|
||||
HTTP/1.1 200 OK
|
||||
content-type: video/mp4
|
||||
content-length: 1918410
|
||||
# Missing: Accept-Ranges header
|
||||
# Missing: 206 Partial Content support
|
||||
```
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
```bash
|
||||
$ curl -I "http://localhost:3002/api/v1/file/{uuid}/video" -H "Range: bytes=0-1000"
|
||||
HTTP/1.1 206 Partial Content
|
||||
content-type: video/mp4
|
||||
content-range: bytes 0-1000/1918410
|
||||
accept-ranges: bytes
|
||||
content-length: 1001
|
||||
```
|
||||
|
||||
## Why This Matters
|
||||
|
||||
1. **Browser seeking requires Range support** - HTML5 `<video>` element uses Range requests to seek to different timestamps
|
||||
2. **Current workaround is inefficient** - Users must download entire video or use `start_time` param (re-downloads video)
|
||||
3. **High bitrate videos suffer most** - 4K/10Mbps videos cannot be quickly navigated
|
||||
|
||||
## Implementation Requirements
|
||||
|
||||
### 1. Add `Accept-Ranges: bytes` header to video endpoint
|
||||
|
||||
```
|
||||
GET /api/v1/file/{uuid}/video
|
||||
Response Headers:
|
||||
Accept-Ranges: bytes
|
||||
```
|
||||
|
||||
### 2. Handle Range requests with 206 Partial Content
|
||||
|
||||
```rust
|
||||
// Pseudo-code for Rust implementation
|
||||
if let Some(range_header) = request.headers().get("Range") {
|
||||
let (start, end) = parse_range(range_header)?;
|
||||
let file_chunk = read_file_range(file_path, start, end)?;
|
||||
|
||||
Response::builder()
|
||||
.status(206)
|
||||
.header("Content-Range", format!("bytes {}-{}/{}", start, end, file_size))
|
||||
.header("Accept-Ranges", "bytes")
|
||||
.header("Content-Length", end - start + 1)
|
||||
.body(file_chunk)
|
||||
} else {
|
||||
// Full file response
|
||||
Response::builder()
|
||||
.status(200)
|
||||
.header("Accept-Ranges", "bytes")
|
||||
.body(full_file)
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Support multiple Range formats
|
||||
|
||||
- `Range: bytes=0-999` (first 1000 bytes)
|
||||
- `Range: bytes=1000-` (from byte 1000 to end)
|
||||
- `Range: bytes=-500` (last 500 bytes)
|
||||
|
||||
## Affected Files (Core API)
|
||||
|
||||
- `src/api/files.rs` - Video streaming endpoint
|
||||
- Possibly `src/api/video.rs` if exists
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Test 1: Initial request should include Accept-Ranges
|
||||
curl -I "http://localhost:3002/api/v1/file/{uuid}/video"
|
||||
# Expect: Accept-Ranges: bytes
|
||||
|
||||
# Test 2: Range request should return 206
|
||||
curl -I "http://localhost:3002/api/v1/file/{uuid}/video" -H "Range: bytes=0-1000"
|
||||
# Expect: HTTP/1.1 206 Partial Content
|
||||
# Expect: Content-Range: bytes 0-1000/{total}
|
||||
|
||||
# Test 3: Browser seeking should work
|
||||
# Open Studio, play video, click on timeline
|
||||
# Video should seek without re-downloading
|
||||
```
|
||||
|
||||
## Priority
|
||||
|
||||
**High** - Critical for user experience when navigating long videos
|
||||
|
||||
## Related Studio Files
|
||||
|
||||
- `src/components/VideoPlayer.vue` - Uses `<video>` element with native controls
|
||||
- `src-tauri/src/proxy.rs` - Already streams video, will pass through Range headers
|
||||
|
||||
## Notes
|
||||
|
||||
- Studio proxy already supports streaming (lines 325-367 in proxy.rs)
|
||||
- Just need Core API to handle Range requests properly
|
||||
- This is standard HTTP behavior for video streaming
|
||||
|
||||
---
|
||||
|
||||
## Additional Request: Adaptive Bitrate / Resolution Selection
|
||||
|
||||
### Problem
|
||||
|
||||
High bitrate videos (4K, 10+ Mbps) cause playback lag in browser due to software decoding limitations.
|
||||
|
||||
### Solution Options
|
||||
|
||||
#### Option A: Resolution Parameter
|
||||
|
||||
```bash
|
||||
GET /api/v1/file/{uuid}/video?start_time=0&end_time=60&resolution=720p
|
||||
GET /api/v1/file/{uuid}/video?start_time=0&end_time=60&resolution=480p
|
||||
```
|
||||
|
||||
**Implementation:**
|
||||
- Use FFmpeg to transcode on-the-fly or pre-generate proxy versions
|
||||
- Store proxy videos at lower bitrates (720p @ 2Mbps, 480p @ 1Mbps)
|
||||
|
||||
#### Option B: Quality Parameter (1-100)
|
||||
|
||||
```bash
|
||||
GET /api/v1/file/{uuid}//video?start_time=0&end_time=60&quality=50
|
||||
```
|
||||
|
||||
#### Option C: Max Bitrate Parameter
|
||||
|
||||
```bash
|
||||
GET /api/v1/file/{uuid}/video?start_time=0&end_time=60&max_bitrate=2000
|
||||
```
|
||||
|
||||
### Recommended Approach
|
||||
|
||||
**Pre-generate proxy videos during processing:**
|
||||
|
||||
```rust
|
||||
// During file processing, generate multiple versions:
|
||||
// - Original: {uuid}_original.mp4 (preserve original quality)
|
||||
// - 720p: {uuid}_720p.mp4 (2 Mbps)
|
||||
// - 480p: {uuid}_480p.mp4 (1 Mbps)
|
||||
```
|
||||
|
||||
**Storage Impact:**
|
||||
- Original 100MB video → ~105MB total (5% overhead for proxies)
|
||||
|
||||
**API Changes:**
|
||||
|
||||
```rust
|
||||
pub fn get_video_stream(
|
||||
file_uuid: String,
|
||||
start_time: f64,
|
||||
end_time: f64,
|
||||
resolution: Option<String>, // "original" | "720p" | "480p"
|
||||
) -> Result<VideoStream, Error>
|
||||
```
|
||||
|
||||
### Frontend Integration
|
||||
|
||||
Studio VideoPlayer can add quality selector:
|
||||
|
||||
```vue
|
||||
<select v-model="selectedResolution">
|
||||
<option value="original">Original (1080p)</option>
|
||||
<option value="720p">HD (720p)</option>
|
||||
<option value="480p">SD (480p)</option>
|
||||
</select>
|
||||
```
|
||||
|
||||
### Benefits
|
||||
|
||||
1. **Eliminates lag** on high bitrate videos
|
||||
2. **Faster loading** with smaller file sizes
|
||||
3. **Bandwidth savings** for remote access
|
||||
4. **Better UX** - smooth playback on all devices
|
||||
290
docs/core-api-pose-appearance-endpoint.md
Normal file
290
docs/core-api-pose-appearance-endpoint.md
Normal file
@@ -0,0 +1,290 @@
|
||||
# Pose & Appearance API Endpoints - Technical Specification
|
||||
|
||||
## Overview
|
||||
|
||||
This document specifies two new Core API endpoints needed for displaying pose skeleton and appearance colors in Momentry Studio's Face Detail Modal.
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
### 1. Get Pose
|
||||
|
||||
```
|
||||
GET /api/v1/file/:file_uuid/pose?frame=:frame_no
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"frame": 100,
|
||||
"keypoints": [
|
||||
{ "name": "nose", "x": 993, "y": 372, "confidence": 0.84 },
|
||||
{ "name": "left_eye", "x": 950, "y": 350, "confidence": 0.91 },
|
||||
{ "name": "right_eye", "x": 1030, "y": 352, "confidence": 0.89 },
|
||||
{ "name": "left_ear", "x": 920, "y": 360, "confidence": 0.75 },
|
||||
{ "name": "right_ear", "x": 1060, "y": 358, "confidence": 0.77 },
|
||||
{ "name": "left_shoulder", "x": 850, "y": 480, "confidence": 0.88 },
|
||||
{ "name": "right_shoulder", "x": 1100, "y": 475, "confidence": 0.90 },
|
||||
{ "name": "left_elbow", "x": 780, "y": 620, "confidence": 0.82 },
|
||||
{ "name": "right_elbow", "x": 1180, "y": 610, "confidence": 0.85 },
|
||||
{ "name": "left_wrist", "x": 720, "y": 750, "confidence": 0.78 },
|
||||
{ "name": "right_wrist", "x": 1240, "y": 740, "confidence": 0.80 },
|
||||
{ "name": "left_hip", "x": 900, "y": 720, "confidence": 0.86 },
|
||||
{ "name": "right_hip", "x": 1050, "y": 715, "confidence": 0.87 },
|
||||
{ "name": "left_knee", "x": 870, "y": 950, "confidence": 0.83 },
|
||||
{ "name": "right_knee", "x": 1080, "y": 945, "confidence": 0.84 },
|
||||
{ "name": "left_ankle", "x": 850, "y": 1150, "confidence": 0.79 },
|
||||
{ "name": "right_ankle", "x": 1100, "y": 1145, "confidence": 0.81 }
|
||||
],
|
||||
"pose_class": "standing",
|
||||
"confidence": 0.95
|
||||
}
|
||||
```
|
||||
|
||||
**Fields:**
|
||||
- `frame` (int): Frame number
|
||||
- `keypoints` (array): 17 COCO keypoints
|
||||
- `name` (string): Keypoint name (see list below)
|
||||
- `x` (float): X coordinate in pixels
|
||||
- `y` (float): Y coordinate in pixels
|
||||
- `confidence` (float, optional): Detection confidence 0-1
|
||||
- `pose_class` (string): One of `standing`, `sitting`, `kneeling`, `lying`, `prone`, `unknown`
|
||||
- `confidence` (float, optional): Overall pose classification confidence
|
||||
|
||||
**COCO-17 Keypoint Names:**
|
||||
```
|
||||
nose, left_eye, right_eye, left_ear, right_ear,
|
||||
left_shoulder, right_shoulder, left_elbow, right_elbow,
|
||||
left_wrist, right_wrist, left_hip, right_hip,
|
||||
left_knee, right_knee, left_ankle, right_ankle
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Get Appearance
|
||||
|
||||
```
|
||||
GET /api/v1/file/:file_uuid/appearance?frame=:frame_no
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"frame": 100,
|
||||
"dominant_colors": [
|
||||
{ "rgb": [255, 100, 50], "percentage": 0.35 },
|
||||
{ "rgb": [50, 150, 200], "percentage": 0.25 },
|
||||
{ "rgb": [100, 200, 100], "percentage": 0.15 }
|
||||
],
|
||||
"hsv_histogram": [
|
||||
[/* 30 bins for H */],
|
||||
[/* 30 bins for S */],
|
||||
[/* 30 bins for V */]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Fields:**
|
||||
- `frame` (int): Frame number
|
||||
- `dominant_colors` (array, optional): Top 3-5 dominant colors
|
||||
- `rgb` (array): [R, G, B] values 0-255
|
||||
- `percentage` (float, optional): Proportion 0-1
|
||||
- `hsv_histogram` (array, optional): Raw HSV histogram for custom analysis
|
||||
|
||||
---
|
||||
|
||||
## Data Source
|
||||
|
||||
### Option A: Read from JSON files
|
||||
|
||||
**Pose file location:**
|
||||
```
|
||||
/momentry/output/{file_hash}/{file_hash}.pose.json
|
||||
```
|
||||
|
||||
**Appearance file location:**
|
||||
```
|
||||
/momentry/output/{file_hash}/{file_hash}.appearance.json
|
||||
```
|
||||
|
||||
**Expected JSON structure:**
|
||||
```json
|
||||
{
|
||||
"frames": [
|
||||
{
|
||||
"frame": 0,
|
||||
"keypoints": [...],
|
||||
"pose_class": "standing"
|
||||
},
|
||||
{
|
||||
"frame": 1,
|
||||
"keypoints": [...],
|
||||
"pose_class": "standing"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Option B: Query from TKG
|
||||
|
||||
If pose/appearance data is stored in TKG, implement Neo4j query:
|
||||
|
||||
```cypher
|
||||
MATCH (p:Pose {file_uuid: $file_uuid, frame: $frame})
|
||||
RETURN p.keypoints, p.pose_class, p.confidence
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Example (FastAPI)
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI, HTTPException
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
OUTPUT_BASE = "/momentry/output"
|
||||
|
||||
@app.get("/api/v1/file/{file_uuid}/pose")
|
||||
async def get_pose(file_uuid: str, frame: int):
|
||||
pose_file = Path(f"{OUTPUT_BASE}/{file_uuid}/{file_uuid}.pose.json")
|
||||
|
||||
if not pose_file.exists():
|
||||
raise HTTPException(status_code=404, detail="Pose data not found")
|
||||
|
||||
with open(pose_file) as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Find frame
|
||||
for frame_data in data.get("frames", []):
|
||||
if frame_data.get("frame") == frame:
|
||||
return {
|
||||
"frame": frame,
|
||||
"keypoints": frame_data.get("keypoints", []),
|
||||
"pose_class": frame_data.get("pose_class", "unknown"),
|
||||
"confidence": frame_data.get("confidence")
|
||||
}
|
||||
|
||||
raise HTTPException(status_code=404, detail="Frame not found")
|
||||
|
||||
@app.get("/api/v1/file/{file_uuid}/appearance")
|
||||
async def get_appearance(file_uuid: str, frame: int):
|
||||
appearance_file = Path(f"{OUTPUT_BASE}/{file_uuid}/{file_uuid}.appearance.json")
|
||||
|
||||
if not appearance_file.exists():
|
||||
raise HTTPException(status_code=404, detail="Appearance data not found")
|
||||
|
||||
with open(appearance_file) as f:
|
||||
data = json.load(f)
|
||||
|
||||
for frame_data in data.get("frames", []):
|
||||
if frame_data.get("frame") == frame:
|
||||
return {
|
||||
"frame": frame,
|
||||
"dominant_colors": frame_data.get("dominant_colors", []),
|
||||
"hsv_histogram": frame_data.get("hsv_histogram")
|
||||
}
|
||||
|
||||
raise HTTPException(status_code=404, detail="Frame not found")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mock Data for Testing
|
||||
|
||||
If real data is not yet available, use mock response:
|
||||
|
||||
```python
|
||||
@app.get("/api/v1/file/{file_uuid}/pose")
|
||||
async def get_pose(file_uuid: str, frame: int):
|
||||
return {
|
||||
"frame": frame,
|
||||
"keypoints": [
|
||||
{"name": "nose", "x": 100, "y": 50},
|
||||
{"name": "left_eye", "x": 90, "y": 45},
|
||||
{"name": "right_eye", "x": 110, "y": 45},
|
||||
{"name": "left_ear", "x": 80, "y": 50},
|
||||
{"name": "right_ear", "x": 120, "y": 50},
|
||||
{"name": "left_shoulder", "x": 60, "y": 100},
|
||||
{"name": "right_shoulder", "x": 140, "y": 100},
|
||||
{"name": "left_elbow", "x": 50, "y": 150},
|
||||
{"name": "right_elbow", "x": 150, "y": 150},
|
||||
{"name": "left_wrist", "x": 45, "y": 190},
|
||||
{"name": "right_wrist", "x": 155, "y": 190},
|
||||
{"name": "left_hip", "x": 70, "y": 200},
|
||||
{"name": "right_hip", "x": 130, "y": 200},
|
||||
{"name": "left_knee", "x": 65, "y": 280},
|
||||
{"name": "right_knee", "x": 135, "y": 280},
|
||||
{"name": "left_ankle", "x": 60, "y": 350},
|
||||
{"name": "right_ankle", "x": 140, "y": 350}
|
||||
],
|
||||
"pose_class": "standing"
|
||||
}
|
||||
|
||||
@app.get("/api/v1/file/{file_uuid}/appearance")
|
||||
async def get_appearance(file_uuid: str, frame: int):
|
||||
return {
|
||||
"frame": frame,
|
||||
"dominant_colors": [
|
||||
{"rgb": [255, 100, 50]},
|
||||
{"rgb": [50, 150, 200]}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Studio Integration Status
|
||||
|
||||
**Phase 1 (Completed):**
|
||||
- ✅ TypeScript interfaces (`src/api/types.ts`)
|
||||
- ✅ API builder cases (`src/api/index.ts`)
|
||||
- ✅ Store functions (`src/store.ts`)
|
||||
- ✅ Canvas rendering utilities (`src/utils/poseRenderer.ts`)
|
||||
- ✅ Mock data for testing (`src/utils/mockPoseData.ts`)
|
||||
|
||||
**Phase 2 (Pending - Core Team):**
|
||||
- ⏳ Implement pose endpoint
|
||||
- ⏳ Implement appearance endpoint
|
||||
|
||||
**Phase 3-4 (After Phase 2):**
|
||||
- Studio Proxy handlers
|
||||
- UI integration in Face Detail Modal
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Manual Test
|
||||
|
||||
```bash
|
||||
curl "http://localhost:3002/api/v1/file/{file_uuid}/pose?frame=100"
|
||||
curl "http://localhost:3002/api/v1/file/{file_uuid}/appearance?frame=100"
|
||||
```
|
||||
|
||||
### Expected Response Time
|
||||
|
||||
- Target: < 100ms per request
|
||||
- Cacheable: Yes (pose/appearance data doesn't change)
|
||||
|
||||
---
|
||||
|
||||
## Questions
|
||||
|
||||
1. **Data location**: Confirm if `.pose.json` and `.appearance.json` files exist, or if data is in TKG?
|
||||
|
||||
2. **Frame alignment**: Confirm that pose/appearance frame numbers align with face `best_face_frame`?
|
||||
|
||||
3. **Pose classification**: Do you have pose classification (`standing`, `sitting`, etc.), or just keypoints?
|
||||
|
||||
4. **Dominant colors**: Is color extraction already done, or need to implement?
|
||||
|
||||
---
|
||||
|
||||
## Contact
|
||||
|
||||
Studio Team: Ready to integrate once endpoints are available.
|
||||
Expected Phase 3-4 completion: 1-2 hours after Phase 2 delivery.
|
||||
434
docs/core-api-usage.md
Normal file
434
docs/core-api-usage.md
Normal file
@@ -0,0 +1,434 @@
|
||||
# Momentry Studio Core API 使用說明
|
||||
|
||||
本文檔整理 Momentry Studio 使用的 Core API 端點及其使用時機。
|
||||
|
||||
**Core API 地址**: `http://localhost:3002`
|
||||
**API Key**: 透過 `api_key` query parameter 注入(由 Rust proxy 自動處理)
|
||||
|
||||
---
|
||||
|
||||
## 一、搜尋相關 API
|
||||
|
||||
### 1.1 智能搜尋
|
||||
|
||||
| 端點 | 方法 | 說明 |
|
||||
|------|------|------|
|
||||
| `/api/v1/search/llm-smart` | POST | LLM 智能搜尋,結合關鍵字和語意搜尋 |
|
||||
| `/api/v1/search/keyword` | POST | 純關鍵字搜尋 |
|
||||
| `/api/v1/search/semantic` | POST | 純語意搜尋(向量相似度) |
|
||||
| `/api/v1/agents/search` | POST | Agent 搜尋(支援多輪對話) |
|
||||
|
||||
**使用時機**:
|
||||
- `llm-smart`: 搜尋頁面預設搜尋
|
||||
- `keyword`: 快速關鍵字匹配
|
||||
- `semantic`: 概念搜尋
|
||||
- `agents/search`: 搜尋頁面對話模式(支援 `conversation_id` 多輪對話)
|
||||
|
||||
**Request Body**:
|
||||
```json
|
||||
{
|
||||
"query": "搜尋詞",
|
||||
"limit": 20
|
||||
}
|
||||
```
|
||||
|
||||
**Response**: 搜尋結果陣列,每個結果包含 `file_uuid`, `start_time`, `end_time`, `start_frame`, `end_frame`, `summary`, `similarity`
|
||||
|
||||
---
|
||||
|
||||
### 1.2 人物搜尋
|
||||
|
||||
| 端點 | 方法 | 說明 |
|
||||
|------|------|------|
|
||||
| `/api/v1/identities/search` | GET | 搜尋人物名稱 |
|
||||
|
||||
**使用時機**: 在人物詳情頁面搜尋要合併的目標人物
|
||||
|
||||
**Query Params**: `q` (搜尋詞), `limit`
|
||||
|
||||
---
|
||||
|
||||
## 二、檔案管理 API
|
||||
|
||||
### 2.1 檔案列表與詳情
|
||||
|
||||
| 端點 | 方法 | 說明 |
|
||||
|------|------|------|
|
||||
| `/api/v1/files/scan` | GET | 掃描目錄,取得檔案列表 |
|
||||
| `/api/v1/file/:uuid` | GET | 取得檔案詳情(fps, duration, width, height 等) |
|
||||
| `/api/v1/file/:uuid/processor-counts` | GET | 取得檔案的處理器計數統計 |
|
||||
|
||||
**使用時機**:
|
||||
- `files/scan`: 檔案庫頁面載入檔案列表
|
||||
- `file/:uuid`: 播放器需要影片參數、縮圖需要原始尺寸
|
||||
- `processor-counts`: 檔案詳情頁面顯示處理狀態
|
||||
|
||||
---
|
||||
|
||||
### 2.2 檔案註冊與處理
|
||||
|
||||
| 端點 | 方法 | 說明 |
|
||||
|------|------|------|
|
||||
| `/api/v1/files/register` | POST | 註冊檔案到系統 |
|
||||
| `/api/v1/file/:uuid/process` | POST | 執行處理器(transcribe, ocr, face 等) |
|
||||
| `/api/v1/file/:uuid/checkin` | POST | 檔案入庫(ingest) |
|
||||
| `/api/v1/file/:uuid/checkout` | POST | 檔案出庫 |
|
||||
| `/api/v1/unregister` | POST | 取消註冊檔案 |
|
||||
|
||||
**使用時機**:
|
||||
- `register`: 檔案庫頁面註冊新檔案
|
||||
- `process`: 檔案庫頁面觸發處理流程
|
||||
- `checkin/checkout`: 檔案版本控制
|
||||
- `unregister`: 檔案庫頁面刪除檔案
|
||||
|
||||
---
|
||||
|
||||
### 2.3 檔案狀態同步
|
||||
|
||||
| 端點 | 方法 | 說明 |
|
||||
|------|------|------|
|
||||
| `/api/v1/file/:uuid/sync-status` | POST | 從資料庫同步檔案處理狀態 |
|
||||
|
||||
**使用時機**: 前端 polling 檢查處理中的檔案狀態(每 10 秒)
|
||||
|
||||
---
|
||||
|
||||
## 三、媒體 API
|
||||
|
||||
### 3.1 縮圖
|
||||
|
||||
| 端點 | 方法 | 說明 | 實作位置 |
|
||||
|------|------|------|----------|
|
||||
| `/api/v1/file/:uuid/thumbnail` | GET | 取得檔案縮圖 | Core API |
|
||||
| `/api/v1/face-thumbnail` | GET | 取得人臉縮圖(含 bbox crop) | **Studio 本地** |
|
||||
| `/api/v1/file/thumbnail` | GET | 依路徑取得縮圖(未註冊檔案) | **Studio 本地** |
|
||||
|
||||
**使用時機**:
|
||||
- `file/:uuid/thumbnail`: 搜尋結果縮圖、影片時間軸縮圖
|
||||
- `face-thumbnail`: 人物詳情頁面人臉縮圖(含 bbox crop)
|
||||
- `file/thumbnail`: 檔案庫頁面未註冊檔案的縮圖
|
||||
|
||||
**注意**: `face-thumbnail` 由 Studio 本地處理,因為 Core API 不支援 bbox crop
|
||||
|
||||
---
|
||||
|
||||
### 3.2 影片串流
|
||||
|
||||
| 端點 | 方法 | 說明 |
|
||||
|------|------|------|
|
||||
| `/api/v1/file/:uuid/video` | GET | 影片串流 |
|
||||
|
||||
**使用時機**: 搜尋結果播放影片
|
||||
|
||||
**注意**: Core API 已支援 HTTP Range requests,瀏覽器可透過 `<video>` 元素跳轉播放
|
||||
|
||||
---
|
||||
|
||||
### 3.3 影格提取
|
||||
|
||||
| 端點 | 方法 | 說明 | 實作位置 |
|
||||
|------|------|------|----------|
|
||||
| `/api/v1/media/frame` | GET | 提取指定影格 | **Studio 本地** |
|
||||
|
||||
**使用時機**: 人物詳情頁面顯示特定幀的圖片
|
||||
|
||||
---
|
||||
|
||||
## 四、人物(Identity)API
|
||||
|
||||
### 4.1 人物列表
|
||||
|
||||
| 端點 | 方法 | 說明 |
|
||||
|------|------|------|
|
||||
| `/api/v1/identities` | GET | 取得所有人物(分頁) |
|
||||
|
||||
**使用時機**: 人物頁面載入人物列表
|
||||
|
||||
**注意**: Studio 使用特殊邏輯(最多 10 頁 × 100 筆)避免 Core API timeout
|
||||
|
||||
---
|
||||
|
||||
### 4.2 人物詳情
|
||||
|
||||
| 端點 | 方法 | 說明 |
|
||||
|------|------|------|
|
||||
| `/api/v1/identity/:uuid` | GET | 取得單一人物詳情 |
|
||||
| `/api/v1/identity/:uuid/faces` | GET | 取得人物的人臉列表 |
|
||||
| `/api/v1/identity/:uuid/traces` | GET | 取得人物的軌跡列表 |
|
||||
| `/api/v1/identity/:uuid/files` | GET | 取得人物出現的檔案列表 |
|
||||
|
||||
**使用時機**:
|
||||
- `identity/:uuid`: 人物詳情頁面
|
||||
- `faces`: 人物詳情頁面人臉列表
|
||||
- `traces`: 人物詳情頁面軌跡列表
|
||||
- `files`: 人物詳情頁面檔案列表
|
||||
|
||||
---
|
||||
|
||||
### 4.3 人物更新
|
||||
|
||||
| 端點 | 方法 | 說明 |
|
||||
|------|------|------|
|
||||
| `/api/v1/identity/:uuid` | PATCH | 更新人物名稱或 metadata |
|
||||
|
||||
**使用時機**: 人物詳情頁面編輯名稱、狀態、星號
|
||||
|
||||
**Request Body**:
|
||||
```json
|
||||
{
|
||||
"name": "新名稱",
|
||||
"metadata": {
|
||||
"status": "confirmed",
|
||||
"starred": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.4 人物大頭貼
|
||||
|
||||
| 端點 | 方法 | 說明 | 實作位置 |
|
||||
|------|------|------|----------|
|
||||
| `/api/v1/identity/:uuid/profile` | GET | 取得人物大頭貼 | **Studio 本地** |
|
||||
| `/api/v1/identity/:uuid/profile-image` | POST | 上傳大頭貼 | Core API |
|
||||
| `/api/v1/identity/:uuid/profile-image/from-face` | POST | 從人臉設定大頭貼 | Core API |
|
||||
|
||||
**使用時機**:
|
||||
- `profile`: 人物列表、人物詳情頁面顯示大頭貼(本地檔案系統)
|
||||
- `profile-image`: 上傳自訂大頭貼
|
||||
- `profile-image/from-face`: 從人臉截圖設定大頭貼
|
||||
|
||||
**注意**: `profile` 由 Studio 本地處理,讀取 `output/identities/{uuid}/profile.jpg`
|
||||
|
||||
---
|
||||
|
||||
### 4.5 人物操作
|
||||
|
||||
| 端點 | 方法 | 說明 |
|
||||
|------|------|------|
|
||||
| `/api/v1/identity/:uuid/bind` | POST | 綁定人臉到人物 |
|
||||
| `/api/v1/identity/:uuid/unbind` | POST | 解綁人臉 |
|
||||
| `/api/v1/identity/:uuid/mergeinto` | POST | 合併人物到另一個人物 |
|
||||
| `/api/v1/identity/:uuid` | DELETE | 刪除人物 |
|
||||
|
||||
**使用時機**:
|
||||
- `bind`: 人物詳情頁面新增人臉
|
||||
- `unbind`: 人物詳情頁面移除人臉
|
||||
- `mergeinto`: 人物詳情頁面合併兩個人物
|
||||
- `delete`: 人物詳情頁面刪除人物
|
||||
|
||||
---
|
||||
|
||||
### 4.6 人物建立
|
||||
|
||||
| 端點 | 方法 | 說明 |
|
||||
|------|------|------|
|
||||
| `/api/v1/file/:uuid/pending-person` | POST | 從檔案建立待確認人物 |
|
||||
| `/api/v1/identities/pending` | POST | 建立待確認身份 |
|
||||
|
||||
**使用時機**:
|
||||
- `pending-person`: 從軌跡建立新人物
|
||||
- `pending`: 從軌跡/人臉建立待確認身份
|
||||
|
||||
---
|
||||
|
||||
### 4.7 Undo/Redo
|
||||
|
||||
| 端點 | 方法 | 說明 |
|
||||
|------|------|------|
|
||||
| `/api/v1/identity/:uuid/undo` | POST | 復原人物操作 |
|
||||
| `/api/v1/identity/:uuid/redo` | POST | 重做人物操作 |
|
||||
| `/api/v1/identity/:uuid/history` | GET | 取得操作歷史 |
|
||||
| `/api/v1/identity/:uuid/bind/undo` | POST | 復原綁定操作 |
|
||||
| `/api/v1/identity/:uuid/bind/redo` | POST | 重做綁定操作 |
|
||||
| `/api/v1/identity/:uuid/bind/history` | GET | 取得綁定操作歷史 |
|
||||
| `/api/v1/identity/merge/:mergeId/undo` | POST | 復原合併 |
|
||||
| `/api/v1/identity/merge/:mergeId/redo` | POST | 重做合併 |
|
||||
| `/api/v1/identity/merge/history` | GET | 取得合併歷史 |
|
||||
|
||||
**使用時機**: 人物詳情頁面 Undo/Redo 功能
|
||||
|
||||
---
|
||||
|
||||
## 五、人臉與軌跡 API
|
||||
|
||||
### 5.1 人臉候選
|
||||
|
||||
| 端點 | 方法 | 說明 |
|
||||
|------|------|------|
|
||||
| `/api/v1/faces/candidates` | GET | 取得未綁定的人臉候選 |
|
||||
|
||||
**使用時機**: 人物詳情頁面顯示可綁定的人臉
|
||||
|
||||
---
|
||||
|
||||
### 5.2 軌跡管理
|
||||
|
||||
| 端點 | 方法 | 說明 |
|
||||
|------|------|------|
|
||||
| `/api/v1/traces/unassigned` | GET | 取得未分配的軌跡 |
|
||||
| `/api/v1/file/:uuid/traces` | POST | 列出檔案的所有軌跡 |
|
||||
| `/api/v1/file/:uuid/trace/:traceId` | DELETE | 刪除軌跡 |
|
||||
| `/api/v1/file/:uuid/trace/:traceId/restore` | POST | 還原刪除的軌跡 |
|
||||
| `/api/v1/file/:uuid/trace/:sourceId/merge/:targetId` | POST | 合併軌跡 |
|
||||
|
||||
**使用時機**:
|
||||
- `unassigned`: 人物詳情頁面顯示未分配軌跡
|
||||
- 其他: 軌跡管理功能
|
||||
|
||||
---
|
||||
|
||||
### 5.3 軌跡 Profile
|
||||
|
||||
| 端點 | 方法 | 說明 |
|
||||
|------|------|------|
|
||||
| `/api/v1/trace-profile` | GET | 取得軌跡 profile |
|
||||
| `/api/v1/trace-profile` | PUT | 更新軌跡 profile |
|
||||
| `/api/v1/trace-profile/group` | PUT | 批次更新軌跡 profile |
|
||||
|
||||
**使用時機**: 軌跡標記、命名
|
||||
|
||||
---
|
||||
|
||||
### 5.4 檔案 Identity 列表
|
||||
|
||||
| 端點 | 方法 | 說明 |
|
||||
|------|------|------|
|
||||
| `/api/v1/file/:uuid/identities` | GET | 取得檔案中出現的人物 |
|
||||
| `/api/v1/file/:uuid/pending-persons` | GET | 取得檔案的待確認人物 |
|
||||
|
||||
**使用時機**: 檔案詳情頁面顯示人物列表
|
||||
|
||||
---
|
||||
|
||||
## 六、處理器 API
|
||||
|
||||
### 6.1 執行 Agent
|
||||
|
||||
| 端點 | 方法 | 說明 |
|
||||
|------|------|------|
|
||||
| `/api/v1/file/:uuid/identity-agent` | POST | 執行人物識別 Agent |
|
||||
| `/api/v1/file/:uuid/cluster-agent` | POST | 執行軌跡聚類 Agent |
|
||||
| `/api/v1/agents/identity/run-for-seed` | POST | 為種子人物執行識別 |
|
||||
| `/api/v1/agents/identity/match-from-photo` | POST | 從照片匹配人物 |
|
||||
|
||||
**使用時機**:
|
||||
- `identity-agent`: 檔案庫頁面執行人物識別
|
||||
- `cluster-agent`: 檔案庫頁面執行軌跡聚類
|
||||
- `run-for-seed`: 為已知人物擴展識別
|
||||
- `match-from-photo`: 上傳照片比對人物
|
||||
|
||||
---
|
||||
|
||||
### 6.2 處理器結果
|
||||
|
||||
| 端點 | 方法 | 說明 | 實作位置 |
|
||||
|------|------|------|----------|
|
||||
| `/api/v1/file/:uuid/face-groups` | GET | 取得人臉分組結果 | Core API |
|
||||
| `/api/v1/file/:uuid/json/:processor` | POST | 取得處理器 JSON 輸出 | Core API |
|
||||
| `/api/v1/cluster-results` | GET | 取得聚類結果 | Studio proxy |
|
||||
| `/api/v1/processor-json` | GET | 取得處理器 JSON | Studio proxy |
|
||||
| `/api/v1/identity-matches` | GET | 取得人物匹配結果 | Studio 本地檔案 |
|
||||
|
||||
**使用時機**:
|
||||
- `face-groups`: 取得人臉分組
|
||||
- `json/:processor`: 取得特定處理器的輸出(如 ocr, asr)
|
||||
- `cluster-results`, `processor-json`, `identity-matches`: QC 頁面
|
||||
|
||||
---
|
||||
|
||||
## 七、Profile API
|
||||
|
||||
### 7.1 檔案 Profile
|
||||
|
||||
| 端點 | 方法 | 說明 |
|
||||
|------|------|------|
|
||||
| `/api/v1/file-profile` | GET | 取得檔案 profile |
|
||||
| `/api/v1/file-profile` | PUT | 更新檔案 profile |
|
||||
|
||||
**使用時機**: 檔案元數據管理
|
||||
|
||||
---
|
||||
|
||||
## 八、統計 API
|
||||
|
||||
| 端點 | 方法 | 說明 |
|
||||
|------|------|------|
|
||||
| `/api/v1/progress/:uuid` | POST | 取得處理進度 |
|
||||
| `/api/v1/stats/pipeline/:uuid` | GET | 取得 pipeline 統計 |
|
||||
| `/api/v1/stats/file/:uuid` | GET | 取得檔案統計 |
|
||||
|
||||
**使用時機**: 檔案處理進度顯示
|
||||
|
||||
---
|
||||
|
||||
## 九、姿勢與外觀 API
|
||||
|
||||
| 端點 | 方法 | 說明 |
|
||||
|------|------|------|
|
||||
| `/api/v1/file/:uuid/pose` | GET | 取得指定幀的姿勢估計 |
|
||||
| `/api/v1/file/:uuid/appearance` | GET | 取得指定幀的外觀特徵 |
|
||||
|
||||
**Query Params**: `frame`, `bbox_x`, `bbox_y`, `bbox_w`, `bbox_h`
|
||||
|
||||
**使用時機**: 人物詳情頁面顯示姿勢/外觀分析
|
||||
|
||||
---
|
||||
|
||||
## 十、其他 API
|
||||
|
||||
### 10.1 說話者綁定
|
||||
|
||||
| 端點 | 方法 | 說明 |
|
||||
|------|------|------|
|
||||
| `/api/v1/file/:uuid/bind-speakers` | POST | 綁定說話者到人物 |
|
||||
|
||||
**使用時機**: 將語音與人物關聯
|
||||
|
||||
---
|
||||
|
||||
### 10.2 本地 API(不經過 Core API)
|
||||
|
||||
以下 API 由 Studio 本地處理,**不會**轉發到 Core API:
|
||||
|
||||
| 端點 | 說明 | 資料來源 |
|
||||
|------|------|----------|
|
||||
| `/api/v1/auth/login` | 本地登入驗證 | SQLite `app_users` 表 |
|
||||
| `/api/v1/search-history` | 搜尋歷史 CRUD | SQLite `search_history` 表 |
|
||||
| `/api/v1/bookmarks` | 書籤 CRUD | SQLite `bookmarks` 表 |
|
||||
| `/api/v1/identity/:uuid/profile` | 人物大頭貼 | 本地檔案系統 |
|
||||
| `/api/v1/face-thumbnail` | 人臉縮圖(bbox crop) | Core API + 本地裁切 |
|
||||
| `/api/v1/media/frame` | 影格提取 | ffmpeg 本地執行 |
|
||||
| `/api/v1/file/thumbnail` | 依路徑縮圖 | ffmpeg 本地執行 |
|
||||
| `/api/v1/identity-matches` | 人物匹配結果 | 本地 JSON 檔案 |
|
||||
| `/api/v1/cluster-results` | 聚類結果 | Core API `/face-groups` |
|
||||
| `/api/v1/processor-json` | 處理器 JSON | Core API `/json/:processor` |
|
||||
|
||||
---
|
||||
|
||||
## 十一、已知問題與改進建議
|
||||
|
||||
### 11.1 高畫質影片卡頓
|
||||
|
||||
**問題**: 4K、10+ Mbps 影片在瀏覽器播放卡頓
|
||||
|
||||
**建議**: Core API 實作 adaptive bitrate streaming(HLS/DASH)
|
||||
|
||||
### 11.2 分頁限制
|
||||
|
||||
**問題**: Core API 在 `per_page >= 100` 時可能 timeout
|
||||
|
||||
**現行解法**: 前端限制 `perPage <= 20`,`get_people` 使用特殊分頁邏輯
|
||||
|
||||
---
|
||||
|
||||
## 十二、API Key 注入
|
||||
|
||||
所有經由 Rust proxy 轉發的請求都會自動注入 `api_key` query parameter:
|
||||
|
||||
```
|
||||
http://localhost:3002/api/v1/identities?api_key=muser_xxx&page=1&per_page=100
|
||||
```
|
||||
|
||||
前端無需手動處理 API Key,由 proxy.rs 統一管理。
|
||||
147
docs/issues/corrupted-video-seek-issue.md
Normal file
147
docs/issues/corrupted-video-seek-issue.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# 影片 Seek 異常問題
|
||||
|
||||
## 問題描述
|
||||
|
||||
**影片檔案**: `Gamma 8-Director Chih-Lin Yang Shares His Experience:楊智麟導演經驗分享.mp4`
|
||||
**UUID**: `d3f9ae8e471a1fc4d47022c66091b920`
|
||||
**大小**: 219.0 MB
|
||||
**時長**: 298.67 秒(約 5 分鐘)
|
||||
|
||||
**症狀**:
|
||||
- 3 分鐘後 seek 異常
|
||||
- 異常行為:
|
||||
1. 回到影片開頭
|
||||
2. 跳到影片結尾
|
||||
|
||||
## 診斷結果
|
||||
|
||||
### ffprobe 檢測錯誤
|
||||
|
||||
```
|
||||
[h264 @ 0x7ad048e00] Invalid NAL unit size (49912 > 22668).
|
||||
[h264 @ 0x7ad048a80] missing picture in access unit with size 22672
|
||||
[h264 @ 0x7ad048a80] Error splitting the input into NAL units.
|
||||
```
|
||||
|
||||
### 影片結構分析
|
||||
|
||||
| 項目 | 數值 | 狀態 |
|
||||
|------|------|------|
|
||||
| Codec | H.264 High Profile | ✅ |
|
||||
| 解析度 | 1920x1080 | ✅ |
|
||||
| 幀率 | 29.97 fps (30000/1001) | ✅ |
|
||||
| Keyframe 間距 | ~28 幀 (~1 秒) | ✅ |
|
||||
| moov atom 位置 | 28 bytes | ✅ 正確(在開頭) |
|
||||
| stss seek table | 存在 | ✅ |
|
||||
| NAL units | **損壞** | ❌ |
|
||||
|
||||
### Range Requests 測試
|
||||
|
||||
Core API Range requests 運作正常:
|
||||
|
||||
```
|
||||
curl -I -H "Range: bytes=150000000-150001000" ...
|
||||
HTTP/1.1 206 Partial Content
|
||||
content-range: bytes 150000000-150001000/229638144
|
||||
```
|
||||
|
||||
## 根本原因
|
||||
|
||||
**影片檔案本身有損壞的 NAL units**,非 Studio 或 Core API 問題。
|
||||
|
||||
瀏覽器的 H.264 解碼器在遇到損壞的 NAL units 時會:
|
||||
1. 無法正確解碼目標幀
|
||||
2. 嘔試從最近的 keyframe 恢復
|
||||
3. 若恢復失敗,跳回開頭或結尾
|
||||
|
||||
## 解決方案
|
||||
|
||||
### 方案 1: 重新封裝(Remux)
|
||||
|
||||
不重新編碼,僅重新打包容器:
|
||||
|
||||
```bash
|
||||
ffmpeg -i "/Users/accusys/momentry/var/sftpgo/data/demo/Gamma 8-Director Chih-Lin Yang Shares His Experience:楊智麟導演經驗分享.mp4" \
|
||||
-c copy \
|
||||
"/Users/accusys/momentry/var/sftpgo/data/demo/Gamma 8-Director Chih-Lin Yang Shares His Experience_fixed.mp4"
|
||||
```
|
||||
|
||||
優點:快速、不損失畫質
|
||||
缺點:可能無法修復損壞的幀
|
||||
|
||||
### 方案 2: 重新編碼(Transcode)
|
||||
|
||||
完整重新編碼:
|
||||
|
||||
```bash
|
||||
ffmpeg -i "/Users/accusys/momentry/var/sftpgo/data/demo/Gamma 8-Director Chih-Lin Yang Shares His Experience:楊智麟導演經驗分享.mp4" \
|
||||
-c:v libx264 -crf 18 \
|
||||
-c:a aac -b:a 128k \
|
||||
"/Users/accusys/momentry/var/sftpgo/data/demo/Gamma 8-Director Chih-Lin Yang Shares His Experience_reencoded.mp4"
|
||||
```
|
||||
|
||||
優點:完整修復損壞的幀
|
||||
缺點:耗時、些微畫質損失
|
||||
|
||||
### 方案 3: 重新註冊
|
||||
|
||||
修復後需重新註冊到系統:
|
||||
|
||||
```bash
|
||||
# 取消註冊舊檔案
|
||||
curl -X POST http://localhost:8888/api/v1/unregister \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"file_uuid": "d3f9ae8e471a1fc4d47022c66091b920"}'
|
||||
|
||||
# 註冊新檔案
|
||||
curl -X POST http://localhost:8888/api/v1/files/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"file_path": "/path/to/fixed_video.mp4"}'
|
||||
```
|
||||
|
||||
## 預防措施
|
||||
|
||||
### 上傳時驗證
|
||||
|
||||
可在檔案註冊時加入驗證:
|
||||
|
||||
```bash
|
||||
ffprobe -v error -count_frames -select_streams v:0 \
|
||||
-show_entries stream=codec_name \
|
||||
-of default=noprint_wrappers=1 input.mp4
|
||||
```
|
||||
|
||||
若有錯誤輸出,標記檔案為「需要檢查」或自動嘗試修復。
|
||||
|
||||
### 定期檢查腳本
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# check_videos.sh
|
||||
for file in /path/to/videos/*.mp4; do
|
||||
errors=$(ffprobe -v error -count_frames "$file" 2>&1 | grep -i "invalid\|error\|corrupt")
|
||||
if [ -n "$errors" ]; then
|
||||
echo "ISSUE: $file"
|
||||
echo "$errors"
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
## 其他影片測試
|
||||
|
||||
測試其他影片均正常,確認問題僅限於此特定檔案。
|
||||
|
||||
## 狀態
|
||||
|
||||
- [x] 問題診斷完成
|
||||
- [ ] 嘗試 remux 修復
|
||||
- [ ] 嘗試 transcode 修復(若 remux 失敗)
|
||||
- [ ] 重新註冊檔案
|
||||
- [ ] 驗證 seek 功能正常
|
||||
|
||||
---
|
||||
|
||||
**建立日期**: 2026-07-22
|
||||
**相關文件**:
|
||||
- `/Users/accusys/momentry_studio/docs/core-api-usage.md`
|
||||
- `/Users/accusys/momentry_studio/docs/core-api-fix-video-range.md`
|
||||
451
docs/proposals/core-api-face-groups-endpoint.md
Normal file
451
docs/proposals/core-api-face-groups-endpoint.md
Normal file
@@ -0,0 +1,451 @@
|
||||
# Core API Face Groups Endpoint - 技術建議書
|
||||
|
||||
**日期**: 2026-07-19
|
||||
**提出團隊**: Momentry Studio
|
||||
**狀態**: 建議中
|
||||
|
||||
---
|
||||
|
||||
## 一、背景
|
||||
|
||||
### 1.1 問題描述
|
||||
|
||||
Momentry Studio Proxy 當前實現以下 endpoints:
|
||||
|
||||
| Endpoint | 當前行為 | 用途 |
|
||||
|----------|---------|------|
|
||||
| `/api/v1/processor-json` | 讀取本地 `{file_hash}.{processor}.json` | 返回 processor 輸出(asrx, ocr, face 等)|
|
||||
| `/api/v1/cluster-results` | 讀取本地 `{file_hash}.cluster_result.json` | 返回 face 分組結果 |
|
||||
|
||||
**問題**:
|
||||
- 遠端瀏覽器訪問 `studio.momentry.ddns.net` 時,無法獲取本地文件數據
|
||||
- Proxy 應該作為 forward 層,將請求轉發到 Core API
|
||||
|
||||
### 1.2 現有 Core API Endpoints
|
||||
|
||||
| Endpoint | 狀態 | 說明 |
|
||||
|----------|------|------|
|
||||
| `POST /api/v1/file/:file_uuid/json/:processor` | ✅ 已存在 | 返回 processor JSON 數據 |
|
||||
| `POST /api/v1/file/:file_uuid/tkg/nodes` | ✅ 已存在 | 查詢 TKG nodes |
|
||||
| `GET /api/v1/trace-profile` | ✅ 已存在 | 查詢單一 trace profile |
|
||||
| `PUT /api/v1/trace-profile` | ✅ 已存在 | 更新 trace profile(含名稱)|
|
||||
| `PUT /api/v1/trace-profile/group` | ✅ 已存在 | 批量更新 trace 名稱 |
|
||||
| `GET /api/v1/file/:file_uuid/cluster-results` | ❌ 不存在 | 需要新增 |
|
||||
|
||||
---
|
||||
|
||||
## 二、架構理解
|
||||
|
||||
### 2.1 Face Group 概念
|
||||
|
||||
**定義**:
|
||||
- Face Group = 多個 face traces 共享同一個名稱(label)
|
||||
- 名稱來自 TKG `face_trace` node 的 `label` 欄位
|
||||
- 名稱可被用戶覆蓋(overridable)
|
||||
|
||||
**技術實現**:
|
||||
```json
|
||||
{
|
||||
"node_type": "face_trace",
|
||||
"label": "Cary Grant", // ← 可被用戶覆蓋
|
||||
"properties": {
|
||||
"trace_id": 9,
|
||||
"face_count": 142,
|
||||
"avg_confidence": 0.87
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Cluster 與 Face Group 的關係
|
||||
|
||||
**核心觀點**:
|
||||
- **Cluster 是技術手段**:用於將多個 face traces 分組
|
||||
- **Face Group 是語義概念**:多個 traces 共享同一個名稱
|
||||
- **Cluster ID 是技術編號**:無業務語義,僅用於識別分組
|
||||
|
||||
**命名流程**:
|
||||
```
|
||||
用戶修改名稱
|
||||
→ Studio Frontend
|
||||
→ Studio Proxy
|
||||
→ Core API: PUT /api/v1/trace-profile
|
||||
→ 更新 TKG node 的 label
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、建議方案
|
||||
|
||||
### 3.1 Processor JSON Endpoint
|
||||
|
||||
**建議**:使用 Core API 現有 endpoint
|
||||
|
||||
**Studio Proxy 實現**:
|
||||
```
|
||||
GET /api/v1/processor-json?file_hash={uuid}&processor={name}
|
||||
→ Forward to Core API:
|
||||
POST /api/v1/file/{uuid}/json/{name}?api_key={API_KEY}
|
||||
```
|
||||
|
||||
**Core API 需要做的**:無需修改
|
||||
|
||||
---
|
||||
|
||||
### 3.2 Face Groups Endpoint(建議新增)
|
||||
|
||||
#### 選項 A:以 Cluster 格式返回
|
||||
|
||||
**Endpoint**:
|
||||
```
|
||||
GET /api/v1/file/:file_uuid/cluster-results
|
||||
```
|
||||
|
||||
**響應格式**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"file_uuid": "d3f9ae8e471a1fc4d47022c66091b920",
|
||||
"clusters": [
|
||||
{
|
||||
"cluster_id": 1,
|
||||
"name": "Cary Grant",
|
||||
"trace_ids": [9, 6, 8],
|
||||
"trace_count": 3,
|
||||
"representative_trace": 9
|
||||
},
|
||||
{
|
||||
"cluster_id": 2,
|
||||
"name": "Audrey Hepburn",
|
||||
"trace_ids": [1, 11, 4, 10],
|
||||
"trace_count": 4,
|
||||
"representative_trace": 1
|
||||
}
|
||||
],
|
||||
"total_clusters": 2
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 選項 B:以 Face Groups 格式返回(推薦)
|
||||
|
||||
**Endpoint**:
|
||||
```
|
||||
GET /api/v1/file/:file_uuid/face-groups
|
||||
```
|
||||
|
||||
**響應格式**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"file_uuid": "d3f9ae8e471a1fc4d47022c66091b920",
|
||||
"face_groups": [
|
||||
{
|
||||
"group_id": 1,
|
||||
"name": "Cary Grant",
|
||||
"trace_ids": [9, 6, 8],
|
||||
"trace_count": 3,
|
||||
"representative_trace": 9,
|
||||
"editable": true,
|
||||
"total_face_count": 425
|
||||
},
|
||||
{
|
||||
"group_id": 2,
|
||||
"name": "Audrey Hepburn",
|
||||
"trace_ids": [1, 11, 4, 10],
|
||||
"trace_count": 4,
|
||||
"representative_trace": 1,
|
||||
"editable": true,
|
||||
"total_face_count": 380
|
||||
}
|
||||
],
|
||||
"total_groups": 2,
|
||||
"unassigned_traces": [5, 7]
|
||||
}
|
||||
```
|
||||
|
||||
**推薦理由**:
|
||||
- 語義更清晰(Face Group vs Cluster)
|
||||
- 與命名機制對應
|
||||
- 擴展性更好(可加入 `editable`, `total_face_count` 等屬性)
|
||||
- 可區分已命名和未命名的 traces
|
||||
|
||||
---
|
||||
|
||||
### 3.3 實現邏輯(建議)
|
||||
|
||||
```python
|
||||
# Pseudocode
|
||||
def get_face_groups(file_uuid: str):
|
||||
# 1. 查詢 TKG nodes
|
||||
response = post(
|
||||
f"{CORE_API}/api/v1/file/{file_uuid}/tkg/nodes",
|
||||
json={"node_type": "face_trace", "page_size": 500}
|
||||
)
|
||||
nodes = response.json()["nodes"]
|
||||
|
||||
# 2. 按 label 分組
|
||||
groups = {}
|
||||
unassigned = []
|
||||
|
||||
for node in nodes:
|
||||
trace_id = node["properties"]["trace_id"]
|
||||
label = node["label"]
|
||||
face_count = node["properties"].get("face_count", 0)
|
||||
|
||||
# 判斷是否為預設名稱(未命名)
|
||||
if label.startswith("Face Trace ") or label.startswith("Trace "):
|
||||
unassigned.append(trace_id)
|
||||
continue
|
||||
|
||||
if label not in groups:
|
||||
groups[label] = {
|
||||
"trace_ids": [],
|
||||
"total_face_count": 0
|
||||
}
|
||||
|
||||
groups[label]["trace_ids"].append(trace_id)
|
||||
groups[label]["total_face_count"] += face_count
|
||||
|
||||
# 3. 轉換為輸出格式
|
||||
result = []
|
||||
for idx, (name, data) in enumerate(sorted(groups.items()), 1):
|
||||
result.append({
|
||||
"group_id": idx,
|
||||
"name": name,
|
||||
"trace_ids": data["trace_ids"],
|
||||
"trace_count": len(data["trace_ids"]),
|
||||
"representative_trace": data["trace_ids"][0],
|
||||
"editable": True,
|
||||
"total_face_count": data["total_face_count"]
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"file_uuid": file_uuid,
|
||||
"face_groups": result,
|
||||
"total_groups": len(result),
|
||||
"unassigned_traces": unassigned
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、設計決策問題
|
||||
|
||||
### 4.1 Endpoint 命名
|
||||
|
||||
**問題**:
|
||||
- 使用 `/cluster-results`(現有概念)?
|
||||
- 使用 `/face-groups`(語義更清晰)?
|
||||
|
||||
**建議**:使用 `/face-groups`,理由:
|
||||
- 語義更準確(名稱可被覆蓋,不是固定的 cluster)
|
||||
- 與 `trace-profile` endpoint 命名一致
|
||||
- 未來可以擴展(如 `/face-groups/:name` 查詢單一 group)
|
||||
|
||||
---
|
||||
|
||||
### 4.2 未命名 Traces 處理
|
||||
|
||||
**場景**:
|
||||
- 新創建的 face_trace,`label` 為預設值(如 `"Face Trace 9"`)
|
||||
- 用戶尚未命名
|
||||
|
||||
**建議選項**:
|
||||
|
||||
| 選項 | 處理方式 | 優點 | 缺點 |
|
||||
|------|---------|------|------|
|
||||
| A | 歸類為 `unassigned_traces` 列表 | 清晰區分已命名/未命名 | 需要額外欄位 |
|
||||
| B | 每個未命名 trace 作為獨立 group | 格式統一 | 可能產生大量 group |
|
||||
| C | 不返回未命名 traces | 響應簡潔 | 遺失數據 |
|
||||
|
||||
**建議**:選項 A,返回 `unassigned_traces` 列表
|
||||
|
||||
---
|
||||
|
||||
### 4.3 Cluster ID / Group ID 的必要性
|
||||
|
||||
**問題**:
|
||||
- `cluster_id` 或 `group_id` 是否需要?
|
||||
- 如果只是順序編號,每次查詢可能不同
|
||||
|
||||
**建議選項**:
|
||||
|
||||
| 選項 | 實現方式 | 優點 | 缺點 |
|
||||
|------|---------|------|------|
|
||||
| A | 使用 `name` 作為唯一標識 | 語義清晰,無需額外 ID | 名稱變更時引用會失效 |
|
||||
| B | 生成固定的 `group_id`(如 UUID) | ID 固定,不受名稱變更影響 | 需要額外存儲 |
|
||||
| C | 每次查詢時動態生成順序編號 | 實現簡單 | 編號可能變化 |
|
||||
|
||||
**建議**:選項 C,動態生成順序編號(因為 Cluster ID 只是技術手段)
|
||||
|
||||
---
|
||||
|
||||
### 4.4 分頁支持
|
||||
|
||||
**問題**:
|
||||
- 如果一個文件有大量 face traces(如超過 500),如何處理?
|
||||
|
||||
**建議**:
|
||||
- 支持分頁參數 `page` 和 `page_size`
|
||||
- 默認 `page_size=100`,最大 `500`
|
||||
|
||||
---
|
||||
|
||||
### 4.5 性能考慮
|
||||
|
||||
**問題**:
|
||||
- TKG nodes 查詢是否需要緩存?
|
||||
|
||||
**建議**:
|
||||
- Core API 可以在內部緩存 TKG nodes 查詢結果
|
||||
- 緩存時效:5-10 分鐘(或直到有 trace-profile 更新)
|
||||
|
||||
---
|
||||
|
||||
## 五、Studio Proxy 職責
|
||||
|
||||
### 5.1 Processor JSON Handler
|
||||
|
||||
**修改前**:
|
||||
```rust
|
||||
// 讀取本地文件
|
||||
let path = format!("{}/{}.{}.json", output_dir, file_hash, processor);
|
||||
let content = std::fs::read_to_string(&path)?;
|
||||
```
|
||||
|
||||
**修改後**:
|
||||
```rust
|
||||
// Forward 到 Core API
|
||||
let url = format!("{}/api/v1/file/{}/json/{}?api_key={}",
|
||||
CORE_API, file_hash, processor, API_KEY);
|
||||
let response = client.post(&url).send().await?;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.2 Cluster Results / Face Groups Handler
|
||||
|
||||
**修改前**:
|
||||
```rust
|
||||
// 讀取本地文件
|
||||
let path = format!("{}/{}/{}.cluster_result.json", base, file_hash, file_hash);
|
||||
let content = std::fs::read_to_string(&path)?;
|
||||
```
|
||||
|
||||
**修改後**:
|
||||
```rust
|
||||
// Forward 到 Core API
|
||||
let url = format!("{}/api/v1/file/{}/face-groups?api_key={}",
|
||||
CORE_API, file_hash, API_KEY);
|
||||
let response = client.get(&url).send().await?;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.3 共享邏輯
|
||||
|
||||
**建議抽取共享函數**:
|
||||
```rust
|
||||
async fn call_core_api(url: &str, method: Method, body: Option<Value>) -> Response {
|
||||
let client = reqwest::Client::new();
|
||||
// 統一的錯誤處理
|
||||
// 統一的響應轉換
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、數據流向
|
||||
|
||||
### 6.1 當前流程(本地文件)
|
||||
|
||||
```
|
||||
Studio Frontend
|
||||
↓
|
||||
Studio Proxy
|
||||
↓
|
||||
本地文件系統 → {file_hash}.{processor}.json
|
||||
↓
|
||||
返回數據
|
||||
|
||||
問題:遠端瀏覽器無法訪問本地文件
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6.2 建議流程(Forward 到 Core API)
|
||||
|
||||
```
|
||||
Studio Frontend
|
||||
↓
|
||||
Studio Proxy (forward)
|
||||
↓
|
||||
Core API
|
||||
├── POST /file/{uuid}/json/{processor} (已有)
|
||||
└── GET /file/{uuid}/face-groups (新增)
|
||||
↓
|
||||
返回數據
|
||||
|
||||
優點:遠端瀏覽器可正常訪問
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、請求反饋
|
||||
|
||||
### 7.1 請 Core API 團隊確認
|
||||
|
||||
1. **Endpoint 選擇**:
|
||||
- `/cluster-results` 或 `/face-groups`?
|
||||
|
||||
2. **未命名 traces**:
|
||||
- 是否同意返回 `unassigned_traces` 列表?
|
||||
|
||||
3. **Group ID**:
|
||||
- 是否同意使用動態生成的順序編號?
|
||||
- 或需要固定的 UUID?
|
||||
|
||||
4. **分頁**:
|
||||
- 是否需要支持?
|
||||
|
||||
5. **性能**:
|
||||
- 是否需要內部緩存?
|
||||
|
||||
---
|
||||
|
||||
### 7.2 Studio 團隊負責
|
||||
|
||||
1. 修改 Proxy handlers:
|
||||
- `get_processor_json_handler` → Forward 到 Core API
|
||||
- `get_cluster_results_handler` → Forward 到 Core API
|
||||
|
||||
2. 抽取共享邏輯:
|
||||
- 統一 Core API 客戶端
|
||||
- 統一錯誤處理
|
||||
|
||||
---
|
||||
|
||||
## 八、附錄
|
||||
|
||||
### A. 相關 Core API Endpoints
|
||||
|
||||
| Endpoint | 文檔位置 |
|
||||
|----------|---------|
|
||||
| Processor JSON | `docs_v1.0/doc_wasm/modules/04_lookup.md` |
|
||||
| TKG Nodes | `docs_v1.0/doc_wasm/modules/15_tkg.md` |
|
||||
| Trace Profile | `docs_v1.0/doc_wasm/modules/18_profile.md` |
|
||||
| Progress | `docs_v1.0/doc_wasm/modules/17_progress.md` |
|
||||
|
||||
### B. Studio 相關代碼
|
||||
|
||||
| 文件 | 說明 |
|
||||
|------|------|
|
||||
| `src-tauri/src/proxy.rs` | Proxy handlers |
|
||||
| `src/views/PeopleView.vue` | Face 頁面 |
|
||||
| `src/store.ts` | 數據加載邏輯 |
|
||||
|
||||
---
|
||||
|
||||
**以上為 Studio 團隊提出的技術建議,歡迎 Core API 團隊討論與反饋。**
|
||||
285
docs/proposals/face-group-naming-issue.md
Normal file
285
docs/proposals/face-group-naming-issue.md
Normal file
@@ -0,0 +1,285 @@
|
||||
# Face Group 命名持久化問題 - 技術分析報告
|
||||
|
||||
**日期**: 2026-07-19
|
||||
**提出團隊**: Momentry Studio
|
||||
**狀態**: 待 Core Team 討論
|
||||
|
||||
---
|
||||
|
||||
## 一、問題現象
|
||||
|
||||
### 1.1 用戶操作流程
|
||||
|
||||
1. 選擇影片檔案
|
||||
2. 建立 face group 並命名(如 "test group")
|
||||
3. 將 trace 移入該 group
|
||||
4. 成功顯示新 group,可播放影片
|
||||
5. 跳轉到其他頁面
|
||||
6. 返回 Face 頁面
|
||||
7. **"test group" 消失**
|
||||
|
||||
### 1.2 影響範圍
|
||||
|
||||
| 功能 | 影響 |
|
||||
|------|------|
|
||||
| 新建 face group | ❌ 無法持久化 |
|
||||
| 重新命名 face group | ❌ 無法持久化 |
|
||||
| 移動 trace 到 group | ❌ 無法持久化 |
|
||||
| 系統自動生成的 group | ✅ 正常 |
|
||||
|
||||
---
|
||||
|
||||
## 二、技術分析
|
||||
|
||||
### 2.1 數據流追蹤
|
||||
|
||||
```
|
||||
Frontend Core API TKG
|
||||
│ │ │
|
||||
│ updateTraceProfileGroup( │ │
|
||||
│ fileUuid, traceIds, │ │
|
||||
│ { name: "test group" } │ │
|
||||
│ ) │ │
|
||||
├─────────────────────────────────►│ │
|
||||
│ │ PUT /trace-profile/group │
|
||||
│ │ { name: "test group" } │
|
||||
│ ├─────────────────────────►│
|
||||
│ │ │
|
||||
│ │ 存入 face_trace.label
|
||||
│ │ │
|
||||
│ loadClusterResults() │ │
|
||||
├─────────────────────────────────►│ │
|
||||
│ │ GET /file/{uuid}/ │
|
||||
│ │ face-groups │
|
||||
│ ├─────────────────────────►│
|
||||
│ │ │
|
||||
│ │ 從 TKG 讀取 label
|
||||
│ │◄─────────────────────────┤
|
||||
│ │ │
|
||||
│ ◄───────────────────────────────┤ { face_groups: [...] } │
|
||||
│ │ │
|
||||
```
|
||||
|
||||
### 2.2 Bug 定位
|
||||
|
||||
**文件位置**: `/Users/accusys/momentry_studio/src/api/index.ts`
|
||||
**行號**: 379
|
||||
|
||||
**問題代碼**:
|
||||
```javascript
|
||||
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.group_name || a.label, // ← BUG: 忽略 a.name
|
||||
key_frame: a.keyFrame,
|
||||
key_face: a.keyFace
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Frontend 調用分析**:
|
||||
|
||||
| 行號 | 文件 | 調用方式 | 參數 |
|
||||
|------|------|---------|------|
|
||||
| 430 | PeopleView.vue | `updateTraceProfileGroup(..., { name })` | `name` ✅ |
|
||||
| 864 | PeopleView.vue | `updateTraceProfileGroup(..., { label: name })` | `label` ⚠️ |
|
||||
| 928 | PeopleView.vue | `updateTraceProfileGroup(..., { name: ... })` | `name` ✅ |
|
||||
| 971 | PeopleView.vue | `updateTraceProfileGroup(..., { name })` | `name` ✅ |
|
||||
|
||||
**結果**:
|
||||
- API Builder 期望 `group_name` 或 `label`
|
||||
- Frontend 大部分傳入 `name`
|
||||
- 導致 Core API 收到 `name: undefined`
|
||||
|
||||
---
|
||||
|
||||
## 三、命名不一致問題
|
||||
|
||||
### 3.1 各層級欄位對照
|
||||
|
||||
| 層級 | 欄位名稱 | 說明 |
|
||||
|------|---------|------|
|
||||
| TKG `face_trace` node | `label` | 根本存儲位置 |
|
||||
| Core API `/trace-profile` (GET) | `name` | 返回給前端 |
|
||||
| Core API `/trace-profile/group` (PUT) | `name` | 接受前端參數 |
|
||||
| Frontend 大部分調用 | `name` | ✅ 正確 |
|
||||
| Frontend line 864 | `label` | ⚠️ 不一致 |
|
||||
| API Builder 期望 | `group_name \|\| label` | ❌ 不匹配 |
|
||||
|
||||
### 3.2 Core API 文檔定義
|
||||
|
||||
**`PUT /api/v1/trace-profile/group`**
|
||||
|
||||
| 參數 | 類型 | 必填 | 說明 |
|
||||
|------|------|------|------|
|
||||
| `file_uuid` | string | Yes | File UUID |
|
||||
| `trace_ids` | integer[] | Yes | Trace IDs |
|
||||
| `name` | string | Yes | 新群組名稱 |
|
||||
|
||||
**Core API 明確接受 `name` 參數。**
|
||||
|
||||
---
|
||||
|
||||
## 四、架構設計問題
|
||||
|
||||
### 4.1 TKG Label 類型混淆
|
||||
|
||||
**現狀**: TKG `label` 欄位可能代表不同意圖:
|
||||
|
||||
| 來源 | 值範例 | 說明 |
|
||||
|------|-------|------|
|
||||
| 用戶命名 | "Peter", "Cary Grant" | 用戶主動指定 |
|
||||
| 系統生成 | "Trace_1", "Face Trace 9" | 自動生成 |
|
||||
| 其他 | 可能還有其他類型 | 待確認 |
|
||||
|
||||
**問題**: 無法區分 label 的來源/類型
|
||||
|
||||
### 4.2 建議方案
|
||||
|
||||
#### 選項 A:增加 `label_type` 欄位
|
||||
|
||||
```javascript
|
||||
// TKG face_trace node
|
||||
{
|
||||
"node_type": "face_trace",
|
||||
"label": "Peter",
|
||||
"label_type": "face_name", // 新增欄位
|
||||
"properties": {
|
||||
"trace_id": 9,
|
||||
"face_count": 142,
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`label_type` 可能值**:
|
||||
|
||||
| 值 | 說明 |
|
||||
|----|------|
|
||||
| `face_name` | 用戶定義的名稱 |
|
||||
| `system` | 系統自動生成 |
|
||||
| `tmdb` | 來自 TMDB 識別 |
|
||||
|
||||
#### 選項 B:使用 `labels` 陣列(支援多標籤)
|
||||
|
||||
```javascript
|
||||
{
|
||||
"node_type": "face_trace",
|
||||
"labels": [
|
||||
{ "value": "Peter", "type": "face_name", "source": "user" },
|
||||
{ "value": "Trace_9", "type": "system", "source": "auto" }
|
||||
],
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
#### 選項 C:維持現狀
|
||||
|
||||
- TKG 保持 `label` 單一欄位
|
||||
- Core API 翻譯:`label` ↔ `name`
|
||||
- Frontend 統一使用 `name`
|
||||
|
||||
---
|
||||
|
||||
## 五、修復方案
|
||||
|
||||
### 5.1 方案 A:前端統一使用 `name`(最小改動)
|
||||
|
||||
**修改點**:
|
||||
|
||||
| 文件 | 行號 | 原代碼 | 新代碼 |
|
||||
|------|------|--------|--------|
|
||||
| `src/api/index.ts` | 379 | `name: a.group_name \|\| a.label` | `name: a.name` |
|
||||
| `src/views/PeopleView.vue` | 864 | `{ label: name }` | `{ name }` |
|
||||
|
||||
**優點**:
|
||||
- 改動少
|
||||
- 符合 Core API 文檔
|
||||
- 立即解決問題
|
||||
|
||||
**缺點**:
|
||||
- 未處理 TKG label 類型問題
|
||||
- 未來可能有類似問題
|
||||
|
||||
**預計工作量**: 0.5 小時
|
||||
|
||||
### 5.2 方案 B:TKG 增加 `label_type`(完整方案)
|
||||
|
||||
**需要修改**:
|
||||
|
||||
1. **TKG Schema**
|
||||
- 增加 `label_type` 欄位
|
||||
|
||||
2. **Core API**
|
||||
- `/trace-profile/group` 增加 `label_type` 參數(默認 `face_name`)
|
||||
- 更新 TKG node 時設定 `label_type`
|
||||
|
||||
3. **Frontend**
|
||||
- 傳遞 `{ name: "...", label_type: "face_name" }`
|
||||
|
||||
**優點**:
|
||||
- 完整解決命名類型問題
|
||||
- 支援未來擴展
|
||||
|
||||
**缺點**:
|
||||
- 需要改動 TKG 和 Core API
|
||||
- 工作量較大
|
||||
|
||||
**預計工作量**: 2-4 小時(跨團隊)
|
||||
|
||||
---
|
||||
|
||||
## 六、討論議題
|
||||
|
||||
### 6.1 短期決策
|
||||
|
||||
1. **是否先執行方案 A**,讓功能正常運作?
|
||||
2. **方案 A 的風險評估**?
|
||||
|
||||
### 6.2 長期設計
|
||||
|
||||
1. **TKG 是否需要 `label_type` 欄位**?
|
||||
2. **如果需要,`label_type` 應該有哪些值**?
|
||||
3. **Core API 是否需要同時支援 `name` 和 `label_type` 參數**?
|
||||
|
||||
### 6.3 命名規範
|
||||
|
||||
**建議建立正式對照表**:
|
||||
|
||||
| 層級 | 欄位 | 類型 | 說明 |
|
||||
|------|------|------|------|
|
||||
| TKG | `label` | string | 根本存儲 |
|
||||
| TKG | `label_type` | string | 類型標識(新增?) |
|
||||
| Core API | `name` | string | API 參數/返回 |
|
||||
| Frontend | `name` | string | 統一使用 |
|
||||
|
||||
---
|
||||
|
||||
## 七、附錄
|
||||
|
||||
### A. 相關代碼位置
|
||||
|
||||
| 文件 | 說明 |
|
||||
|------|------|
|
||||
| `/Users/accusys/momentry_studio/src/api/index.ts:379` | API Builder |
|
||||
| `/Users/accusys/momentry_studio/src/store.ts:252` | updateTraceProfileGroup |
|
||||
| `/Users/accusys/momentry_studio/src/views/PeopleView.vue:430,864,928,971` | 調用點 |
|
||||
| `/Users/accusys/momentry_core/docs_v1.0/doc_wasm/modules/18_profile.md` | Core API 文檔 |
|
||||
|
||||
### B. Core API 端點
|
||||
|
||||
| 端點 | 方法 | 說明 |
|
||||
|------|------|------|
|
||||
| `/api/v1/trace-profile` | GET | 獲取單一 trace profile |
|
||||
| `/api/v1/trace-profile` | PUT | 更新單一 trace profile |
|
||||
| `/api/v1/trace-profile/group` | PUT | 批量更新 trace profile |
|
||||
| `/api/v1/file/{uuid}/face-groups` | GET | 獲取 face groups |
|
||||
|
||||
---
|
||||
|
||||
**以上問題請 Core Team 確認後,再決定修復方向。**
|
||||
111
package-lock.json
generated
111
package-lock.json
generated
@@ -9,7 +9,9 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.0",
|
||||
"@vueuse/core": "^14.3.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-i18n": "^9.14.5",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -507,6 +509,50 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@intlify/core-base": {
|
||||
"version": "9.14.5",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.14.5.tgz",
|
||||
"integrity": "sha512-5ah5FqZG4pOoHjkvs8mjtv+gPKYU0zCISaYNjBNNqYiaITxW8ZtVih3GS/oTOqN8d9/mDLyrjD46GBApNxmlsA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@intlify/message-compiler": "9.14.5",
|
||||
"@intlify/shared": "9.14.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/kazupon"
|
||||
}
|
||||
},
|
||||
"node_modules/@intlify/message-compiler": {
|
||||
"version": "9.14.5",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.14.5.tgz",
|
||||
"integrity": "sha512-IHzgEu61/YIpQV5Pc3aRWScDcnFKWvQA9kigcINcCBXN8mbW+vk9SK+lDxA6STzKQsVJxUPg9ACC52pKKo3SVQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@intlify/shared": "9.14.5",
|
||||
"source-map-js": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/kazupon"
|
||||
}
|
||||
},
|
||||
"node_modules/@intlify/shared": {
|
||||
"version": "9.14.5",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.14.5.tgz",
|
||||
"integrity": "sha512-9gB+E53BYuAEMhbCAxVgG38EZrk59sxBtv3jSizNL2hEWlgjBjAw1AwpLHtNaeda12pe6W20OGEa0TwuMSRbyQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/kazupon"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
@@ -919,6 +965,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/web-bluetooth": {
|
||||
"version": "0.0.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
|
||||
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-vue": {
|
||||
"version": "5.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
|
||||
@@ -1104,6 +1156,44 @@
|
||||
"integrity": "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vueuse/core": {
|
||||
"version": "14.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.3.0.tgz",
|
||||
"integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/web-bluetooth": "^0.0.21",
|
||||
"@vueuse/metadata": "14.3.0",
|
||||
"@vueuse/shared": "14.3.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/metadata": {
|
||||
"version": "14.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.3.0.tgz",
|
||||
"integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/shared": {
|
||||
"version": "14.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.3.0.tgz",
|
||||
"integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/alien-signals": {
|
||||
"version": "1.0.13",
|
||||
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz",
|
||||
@@ -1536,6 +1626,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vue-i18n": {
|
||||
"version": "9.14.5",
|
||||
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-9.14.5.tgz",
|
||||
"integrity": "sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g==",
|
||||
"deprecated": "v9 and v10 no longer supported. please migrate to v11. about maintenance status, see https://vue-i18n.intlify.dev/guide/maintenance.html",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@intlify/core-base": "9.14.5",
|
||||
"@intlify/shared": "9.14.5",
|
||||
"@vue/devtools-api": "^6.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/kazupon"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-router": {
|
||||
"version": "4.6.4",
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
|
||||
|
||||
@@ -3,14 +3,17 @@
|
||||
"version": "0.1.0",
|
||||
"description": "Momentry Studio - Video Analysis Platform",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"proxy": "lsof -ti :8888 | xargs kill -9 2>/dev/null; MOMENTRY_DATA_DIR=$(pwd)/data/users ./src-tauri/target/debug/momentry-proxy",
|
||||
"dev": "lsof -ti :8888 | xargs kill -9 2>/dev/null; MOMENTRY_DATA_DIR=$(pwd)/data/users ./src-tauri/target/debug/momentry-proxy > /tmp/momentry_proxy.log 2>&1 & sleep 2 && vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "cargo tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.0",
|
||||
"@vueuse/core": "^14.3.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-i18n": "^9.14.5",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
109
src-tauri/Cargo.lock
generated
109
src-tauri/Cargo.lock
generated
@@ -3509,6 +3509,15 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kamadak-exif"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1130d80c7374efad55a117d715a3af9368f0fa7a2c54573afc15a188cd984837"
|
||||
dependencies = [
|
||||
"mutate_once",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keccak"
|
||||
version = "0.2.0"
|
||||
@@ -3704,6 +3713,15 @@ dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lz4_flex"
|
||||
version = "0.11.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a"
|
||||
dependencies = [
|
||||
"twox-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lz4_flex"
|
||||
version = "0.13.1"
|
||||
@@ -3747,6 +3765,7 @@ dependencies = [
|
||||
"http 1.4.2",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"lz4_flex 0.11.6",
|
||||
"md5 0.8.0",
|
||||
"nix 0.29.0",
|
||||
"once_cell",
|
||||
@@ -3768,18 +3787,21 @@ dependencies = [
|
||||
"smb2",
|
||||
"ssh-key",
|
||||
"ssh2",
|
||||
"sysinfo",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-postgres",
|
||||
"tokio-util",
|
||||
"toml 0.8.2",
|
||||
"tower-http 0.5.2",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"ureq",
|
||||
"url",
|
||||
"uuid",
|
||||
"x25519-dalek",
|
||||
"xattr",
|
||||
"xmltree",
|
||||
"zip",
|
||||
"zstd 0.13.3",
|
||||
@@ -3933,14 +3955,17 @@ dependencies = [
|
||||
name = "momentry-studio"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"argon2 0.5.3",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
"futures",
|
||||
"image",
|
||||
"kamadak-exif",
|
||||
"lru 0.12.5",
|
||||
"markbase-core",
|
||||
"rand 0.8.6",
|
||||
"reqwest 0.11.27",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
@@ -3992,6 +4017,12 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mutate_once"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af"
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.18"
|
||||
@@ -4063,6 +4094,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ntapi"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
@@ -6509,7 +6549,7 @@ dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"hmac 0.13.0",
|
||||
"log",
|
||||
"lz4_flex",
|
||||
"lz4_flex 0.13.1",
|
||||
"md-5 0.11.0",
|
||||
"md4",
|
||||
"num_enum",
|
||||
@@ -7027,6 +7067,20 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sysinfo"
|
||||
version = "0.32.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c33cd241af0f2e9e3b5c32163b873b29956890b5342e6745b917ce9d490f4af"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"memchr",
|
||||
"ntapi",
|
||||
"rayon",
|
||||
"windows 0.57.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.5.1"
|
||||
@@ -8530,6 +8584,16 @@ dependencies = [
|
||||
"windows-version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143"
|
||||
dependencies = [
|
||||
"windows-core 0.57.0",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.58.0"
|
||||
@@ -8583,6 +8647,18 @@ dependencies = [
|
||||
"windows-core 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d"
|
||||
dependencies = [
|
||||
"windows-implement 0.57.0",
|
||||
"windows-interface 0.57.0",
|
||||
"windows-result 0.1.2",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.58.0"
|
||||
@@ -8644,6 +8720,17 @@ dependencies = [
|
||||
"windows-threading 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.58.0"
|
||||
@@ -8666,6 +8753,17 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.58.0"
|
||||
@@ -8720,6 +8818,15 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.2.0"
|
||||
|
||||
@@ -30,11 +30,14 @@ base64 = "0.22"
|
||||
lru = "0.12"
|
||||
futures = "0.3"
|
||||
image = { version = "0.24", default-features = false, features = ["jpeg"] }
|
||||
kamadak-exif = "0.6"
|
||||
axum = "0.7"
|
||||
tower-http = { version = "0.5", features = ["cors", "fs"] }
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
argon2 = "0.5"
|
||||
rand = "0.8"
|
||||
# MarkBase Core for Admin/Client GUI
|
||||
markbase-core = { path = "../../markbase/markbase-core" }
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// Standalone proxy binary for testing/development
|
||||
// Re-exports the proxy module from main crate
|
||||
#[path = "../proxy.rs"]
|
||||
mod proxy;
|
||||
|
||||
fn main() {
|
||||
// We can't import from the main crate as a library easily,
|
||||
// so instead we'll just call through the Tauri binary mechanism.
|
||||
// For now, the full Tauri binary must be used to run the proxy.
|
||||
eprintln!("Use `cargo tauri dev` to start the full application with proxy.");
|
||||
eprintln!("For standalone proxy testing, the proxy runs as part of the Tauri binary.");
|
||||
std::process::exit(1);
|
||||
#[path = "../db.rs"]
|
||||
mod db;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let _ = db::init_db();
|
||||
proxy::start_proxy_server().await;
|
||||
}
|
||||
@@ -2,6 +2,8 @@ use rusqlite::Connection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
use argon2::{self, Algorithm, Argon2, Params, PasswordHash, PasswordHasher, PasswordVerifier, Version};
|
||||
use argon2::password_hash::{SaltString, rand_core::OsRng};
|
||||
|
||||
const APP_TABLES: &str = "
|
||||
CREATE TABLE IF NOT EXISTS search_history (
|
||||
@@ -25,6 +27,7 @@ CREATE TABLE IF NOT EXISTS bookmarks (
|
||||
CREATE TABLE IF NOT EXISTS app_users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT,
|
||||
display_name TEXT,
|
||||
role TEXT DEFAULT 'user',
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
@@ -49,19 +52,27 @@ pub fn init_db() -> Result<(), String> {
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL;").map_err(|e| format!("WAL mode error: {}", e))?;
|
||||
conn.execute_batch(APP_TABLES).map_err(|e| format!("Failed to create tables: {}", e))?;
|
||||
|
||||
// Add password_hash column if not exists (migration)
|
||||
conn.execute("ALTER TABLE app_users ADD COLUMN password_hash TEXT", []).ok();
|
||||
|
||||
let default_id = "demo";
|
||||
let default_user = "demo";
|
||||
let count: i64 = conn
|
||||
let default_password = "demo123";
|
||||
|
||||
// Check if demo user exists and has password
|
||||
let password_hash: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM app_users WHERE id = ?1",
|
||||
"SELECT password_hash FROM app_users WHERE id = ?1",
|
||||
[default_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
if count == 0 {
|
||||
.unwrap_or(None);
|
||||
|
||||
if password_hash.is_none() || password_hash.as_ref().map_or(true, |h| h.is_empty()) {
|
||||
let password_hash = hash_password(default_password);
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO app_users (id, username, display_name, role) VALUES (?1, ?2, ?3, 'admin')",
|
||||
rusqlite::params![default_id, default_user, "Demo User"],
|
||||
"INSERT OR REPLACE INTO app_users (id, username, password_hash, display_name, role) VALUES (?1, ?2, ?3, ?4, 'admin')",
|
||||
rusqlite::params![default_id, default_user, password_hash, "Demo User"],
|
||||
)
|
||||
.map_err(|e| format!("Failed to insert default user: {}", e))?;
|
||||
}
|
||||
@@ -98,6 +109,39 @@ pub struct BookmarkItem {
|
||||
pub created_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct User {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub display_name: Option<String>,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct LoginResult {
|
||||
pub success: bool,
|
||||
pub user: Option<User>,
|
||||
pub token: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
fn hash_password(password: &str) -> String {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, Params::default());
|
||||
argon2.hash_password(password.as_bytes(), &salt).unwrap().to_string()
|
||||
}
|
||||
|
||||
fn verify_password(hash: &str, password: &str) -> bool {
|
||||
let parsed_hash = PasswordHash::new(hash);
|
||||
match parsed_hash {
|
||||
Ok(h) => {
|
||||
let argon2 = Argon2::default();
|
||||
argon2.verify_password(password.as_bytes(), &h).is_ok()
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub fn get_search_history(limit: Option<u32>) -> Result<Vec<HistoryItem>, String> {
|
||||
let limit = limit.unwrap_or(30).min(30);
|
||||
@@ -236,3 +280,57 @@ pub fn delete_bookmark(id: i64) -> Result<(), String> {
|
||||
.map_err(|e| format!("Delete error: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn verify_user(username: &str, password: &str) -> Result<Option<User>, String> {
|
||||
let conn = get_conn()?;
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id, username, password_hash, display_name, role FROM app_users WHERE username = ?1")
|
||||
.map_err(|e| format!("Prepare error: {}", e))?;
|
||||
|
||||
let result = stmt
|
||||
.query_row([username], |row| {
|
||||
let id: String = row.get(0)?;
|
||||
let username: String = row.get(1)?;
|
||||
let password_hash: Option<String> = row.get(2)?;
|
||||
let display_name: Option<String> = row.get(3)?;
|
||||
let role: String = row.get(4)?;
|
||||
Ok((id, username, password_hash, display_name, role))
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok((id, uname, password_hash, display_name, role)) => {
|
||||
if let Some(hash) = password_hash {
|
||||
if verify_password(&hash, password) {
|
||||
Ok(Some(User { id, username: uname, display_name, role }))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_user_by_username(username: &str) -> Result<Option<User>, String> {
|
||||
let conn = get_conn()?;
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id, username, display_name, role FROM app_users WHERE username = ?1")
|
||||
.map_err(|e| format!("Prepare error: {}", e))?;
|
||||
|
||||
let result = stmt
|
||||
.query_row([username], |row| {
|
||||
Ok(User {
|
||||
id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
display_name: row.get(2)?,
|
||||
role: row.get(3)?,
|
||||
})
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok(user) => Ok(Some(user)),
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
}
|
||||
@@ -7,13 +7,28 @@ mod db;
|
||||
|
||||
use serde::Serialize;
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use std::sync::Mutex;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::num::NonZeroUsize;
|
||||
use lru::LruCache;
|
||||
use image::GenericImageView;
|
||||
use image::DynamicImage;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
static THUMB_CACHE: Mutex<Option<LruCache<String, String>>> = Mutex::new(None);
|
||||
static PROFILE_CACHE: Mutex<Option<LruCache<String, String>>> = Mutex::new(None);
|
||||
static RAW_DIM_CACHE: Mutex<Option<HashMap<String, (u32, u32)>>> = Mutex::new(None);
|
||||
/// Per-file semaphore: only one thumbnail extraction per file at a time,
|
||||
/// preventing ffmpeg storms when many traces hit the same file concurrently.
|
||||
static THUMB_SEM_MAP: Mutex<Option<HashMap<String, Arc<Semaphore>>>> = Mutex::new(None);
|
||||
|
||||
fn acquire_thumb_semaphore(uuid: &str) -> Arc<Semaphore> {
|
||||
let mut map = THUMB_SEM_MAP.lock().unwrap();
|
||||
let map = map.get_or_insert_with(HashMap::new);
|
||||
map.entry(uuid.to_string())
|
||||
.or_insert_with(|| Arc::new(Semaphore::new(1)))
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn get_client() -> reqwest::Client {
|
||||
reqwest::Client::builder()
|
||||
@@ -59,6 +74,8 @@ struct PersonInfo {
|
||||
status: String,
|
||||
metadata: serde_json::Value,
|
||||
file_uuids: Vec<String>,
|
||||
source: String,
|
||||
tmdb_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -91,6 +108,8 @@ struct FileDetail {
|
||||
file_name: String,
|
||||
fps: f64,
|
||||
duration: f64,
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -355,7 +374,7 @@ async fn unregister_file(file_uuid: String, delete_output_files: Option<bool>) -
|
||||
async fn get_people(_page: usize, _per_page: usize) -> Result<Vec<PersonInfo>, String> {
|
||||
eprintln!("[get_people] called");
|
||||
let mut people = Vec::new();
|
||||
for page in 1u32..=2 {
|
||||
for page in 1u32..=10 {
|
||||
let url = format!("{}/api/v1/identities?api_key={}&page={}&per_page=100", CORE_API, API_KEY, page);
|
||||
let resp = match get_client().get(&url).send().await {
|
||||
Ok(r) => r,
|
||||
@@ -398,6 +417,8 @@ async fn get_people(_page: usize, _per_page: usize) -> Result<Vec<PersonInfo>, S
|
||||
status: i["status"].as_str().unwrap_or_else(|| i["metadata"]["status"].as_str().unwrap_or("pending")).to_string(),
|
||||
metadata: i["metadata"].clone(),
|
||||
file_uuids: i["file_uuids"].as_array().map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()).unwrap_or_default(),
|
||||
source: i["source"].as_str().unwrap_or("").to_string(),
|
||||
tmdb_id: i["tmdb_id"].as_i64(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -499,9 +520,12 @@ async fn get_traces(uuid: String, per_page: usize, page: Option<u32>) -> Result<
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
async fn get_unassigned_traces(page: usize, per_page: usize) -> Result<serde_json::Value, String> {
|
||||
async fn get_unassigned_traces(page: usize, per_page: usize, file_uuid: Option<String>) -> Result<serde_json::Value, String> {
|
||||
let page_size = per_page.min(100);
|
||||
let url = format!("{}/api/v1/traces/unassigned?api_key={}&page={}&page_size={}", CORE_API, API_KEY, page, page_size);
|
||||
let mut url = format!("{}/api/v1/traces/unassigned?api_key={}&page={}&page_size={}", CORE_API, API_KEY, page, page_size);
|
||||
if let Some(ref uuid) = file_uuid {
|
||||
url.push_str(&format!("&file_uuid={}", uuid));
|
||||
}
|
||||
let resp = get_client().get(&url).send().await
|
||||
.map_err(|e| format!("Unassigned traces request failed: {}", e))?;
|
||||
let json: serde_json::Value = resp.json().await
|
||||
@@ -521,9 +545,42 @@ async fn get_file_info(uuid: String) -> Result<FileDetail, String> {
|
||||
file_name: json["file_name"].as_str().unwrap_or("").to_string(),
|
||||
fps: json["fps"].as_f64().unwrap_or(24.0),
|
||||
duration: json["duration"].as_f64().unwrap_or(0.0),
|
||||
width: json["width"].as_u64().unwrap_or(0) as u32,
|
||||
height: json["height"].as_u64().unwrap_or(0) as u32,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read EXIF orientation and physically rotate pixels, stripping orientation tag.
|
||||
/// This ensures all browsers see the same pixel dimensions regardless of EXIF handling.
|
||||
fn apply_orientation(bytes: &[u8]) -> Result<Vec<u8>, String> {
|
||||
use std::io::{Cursor, BufReader};
|
||||
// Read EXIF orientation tag
|
||||
let orientation = {
|
||||
let cursor = Cursor::new(bytes);
|
||||
let mut reader = BufReader::new(cursor);
|
||||
match exif::Reader::new().read_from_container(&mut reader) {
|
||||
Ok(exif) => exif.get_field(exif::Tag::Orientation, exif::In::PRIMARY)
|
||||
.and_then(|f| f.value.get_uint(0))
|
||||
.unwrap_or(1),
|
||||
Err(_) => 1,
|
||||
}
|
||||
};
|
||||
let img = image::load_from_memory(bytes).map_err(|e| format!("Image decode: {}", e))?;
|
||||
let img = match orientation {
|
||||
2 => img.fliph(),
|
||||
3 => img.rotate180(),
|
||||
4 => img.fliph().rotate180(),
|
||||
5 => img.rotate90().flipv(),
|
||||
6 => img.rotate270(),
|
||||
7 => img.rotate90().fliph(),
|
||||
8 => img.rotate90(),
|
||||
_ => img,
|
||||
};
|
||||
let mut buf = Cursor::new(Vec::new());
|
||||
img.write_to(&mut buf, image::ImageFormat::Jpeg).map_err(|e| format!("JPEG encode: {}", e))?;
|
||||
Ok(buf.into_inner())
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
async fn get_thumbnail(uuid: String, frame: u32) -> Result<String, String> {
|
||||
let key = format!("{}:{}", uuid, frame);
|
||||
@@ -535,23 +592,8 @@ async fn get_thumbnail(uuid: String, frame: u32) -> Result<String, String> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let url = format!("{}/api/v1/file/{}/thumbnail?api_key={}&frame={}", CORE_API, uuid, API_KEY, frame);
|
||||
let bytes = get_client().get(&url).send().await
|
||||
.map_err(|e| format!("Thumbnail request failed: {}", e))?
|
||||
.bytes().await
|
||||
.map_err(|e| format!("Thumbnail read failed: {}", e))?;
|
||||
let result = format!("data:image/jpeg;base64,{}", STANDARD.encode(&bytes));
|
||||
{
|
||||
let mut cache = THUMB_CACHE.lock().unwrap();
|
||||
let cache = cache.get_or_insert_with(|| LruCache::new(NonZeroUsize::new(500).unwrap()));
|
||||
cache.put(key, result.clone());
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
async fn get_face_thumbnail(uuid: String, frame: u32, bbox_x: Option<f64>, bbox_y: Option<f64>, bbox_w: Option<f64>, bbox_h: Option<f64>) -> Result<String, String> {
|
||||
let key = format!("face:{}:{}:{:?}:{:?}:{:?}:{:?}", uuid, frame, bbox_x, bbox_y, bbox_w, bbox_h);
|
||||
let _permit = acquire_thumb_semaphore(&uuid).acquire_owned().await.unwrap();
|
||||
// Double-check cache after acquiring semaphore
|
||||
{
|
||||
let mut cache = THUMB_CACHE.lock().unwrap();
|
||||
if let Some(cache) = cache.as_mut() {
|
||||
@@ -565,15 +607,138 @@ async fn get_face_thumbnail(uuid: String, frame: u32, bbox_x: Option<f64>, bbox_
|
||||
.map_err(|e| format!("Thumbnail request failed: {}", e))?
|
||||
.bytes().await
|
||||
.map_err(|e| format!("Thumbnail read failed: {}", e))?;
|
||||
let corrected = apply_orientation(&bytes)?;
|
||||
let result = format!("data:image/jpeg;base64,{}", STANDARD.encode(&corrected));
|
||||
{
|
||||
let mut cache = THUMB_CACHE.lock().unwrap();
|
||||
let cache = cache.get_or_insert_with(|| LruCache::new(NonZeroUsize::new(500).unwrap()));
|
||||
cache.put(key, result.clone());
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
async fn get_file_thumbnail_by_path(path: String, frame: u32) -> Result<String, String> {
|
||||
if !std::path::Path::new(&path).exists() {
|
||||
return Err("File not found".into());
|
||||
}
|
||||
|
||||
let ext = path.rsplit('.').next().unwrap_or("").to_lowercase();
|
||||
let is_image = matches!(ext.as_str(), "jpg" | "jpeg" | "png" | "gif" | "webp");
|
||||
|
||||
if is_image {
|
||||
let bytes = std::fs::read(&path).map_err(|e| format!("Read failed: {}", e))?;
|
||||
let mime = match ext.as_str() {
|
||||
"png" => "image/png",
|
||||
"gif" => "image/gif",
|
||||
"webp" => "image/webp",
|
||||
_ => "image/jpeg",
|
||||
};
|
||||
Ok(format!("data:{};base64,{}", mime, STANDARD.encode(&bytes)))
|
||||
} else {
|
||||
let time_sec = frame as f64 / 24.0;
|
||||
let output = tokio::process::Command::new("ffmpeg")
|
||||
.args([
|
||||
"-ss", &time_sec.to_string(),
|
||||
"-i", &path,
|
||||
"-vframes", "1",
|
||||
"-q:v", "2",
|
||||
"-f", "mjpeg",
|
||||
"-"
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("ffmpeg failed: {}", e))?;
|
||||
|
||||
if output.status.success() {
|
||||
Ok(format!("data:image/jpeg;base64,{}", STANDARD.encode(&output.stdout)))
|
||||
} else {
|
||||
Err("Thumbnail extraction failed".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch raw video dimensions from Core API with in-memory cache.
|
||||
/// Returns `None` if the request fails or dimensions are 0.
|
||||
async fn fetch_raw_dimensions(uuid: &str) -> Option<(u32, u32)> {
|
||||
{
|
||||
let cache = RAW_DIM_CACHE.lock().unwrap();
|
||||
if let Some(cache) = cache.as_ref() {
|
||||
if let Some(dims) = cache.get(uuid) {
|
||||
return Some(*dims);
|
||||
}
|
||||
}
|
||||
}
|
||||
let url = format!("{}/api/v1/file/{}?api_key={}", CORE_API, uuid, API_KEY);
|
||||
let result = get_client().get(&url).send().await.ok()?
|
||||
.json::<serde_json::Value>().await.ok()?;
|
||||
let w = result["width"].as_u64().unwrap_or(0) as u32;
|
||||
let h = result["height"].as_u64().unwrap_or(0) as u32;
|
||||
if w == 0 || h == 0 {
|
||||
return None;
|
||||
}
|
||||
{
|
||||
let mut cache = RAW_DIM_CACHE.lock().unwrap();
|
||||
let cache = cache.get_or_insert_with(HashMap::new);
|
||||
cache.insert(uuid.to_string(), (w, h));
|
||||
}
|
||||
Some((w, h))
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
async fn get_face_thumbnail(uuid: String, frame: u32, bbox_x: Option<f64>, bbox_y: Option<f64>, bbox_w: Option<f64>, bbox_h: Option<f64>) -> Result<String, String> {
|
||||
let key = format!("face:{}:{}:{:?}:{:?}:{:?}:{:?}", uuid, frame, bbox_x, bbox_y, bbox_w, bbox_h);
|
||||
{
|
||||
let mut cache = THUMB_CACHE.lock().unwrap();
|
||||
if let Some(cache) = cache.as_mut() {
|
||||
if let Some(cached) = cache.get(&key) {
|
||||
return Ok(cached.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let _permit = acquire_thumb_semaphore(&uuid).acquire_owned().await.unwrap();
|
||||
// Double-check cache after acquiring semaphore
|
||||
{
|
||||
let mut cache = THUMB_CACHE.lock().unwrap();
|
||||
if let Some(cache) = cache.as_mut() {
|
||||
if let Some(cached) = cache.get(&key) {
|
||||
return Ok(cached.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let url = format!("{}/api/v1/file/{}/thumbnail?api_key={}&frame={}", CORE_API, uuid, API_KEY, frame);
|
||||
let bytes = get_client().get(&url).send().await
|
||||
.map_err(|e| format!("Thumbnail request failed: {}", e))?
|
||||
.bytes().await
|
||||
.map_err(|e| format!("Thumbnail read failed: {}", e))?;
|
||||
let result = if let (Some(bx), Some(by), Some(bw), Some(bh)) = (bbox_x, bbox_y, bbox_w, bbox_h) {
|
||||
let img = image::load_from_memory(&bytes).map_err(|e| format!("Image decode failed: {}", e))?;
|
||||
let (w, h) = img.dimensions();
|
||||
// Determine if CCW rotation is needed by comparing raw video dimensions
|
||||
// against thumbnail dimensions. If raw != thumb, Core API rotated the frame
|
||||
// and bbox is in raw space. Fall back to aspect ratio if raw dims unavailable.
|
||||
let raw_dims = fetch_raw_dimensions(&uuid).await;
|
||||
let needs_ccw = match raw_dims {
|
||||
Some((rw, rh)) => (rw, rh) != (w, h),
|
||||
None => w < h && (w as f64) / (h as f64) < 0.62,
|
||||
};
|
||||
let (bx, by, bw, bh) = if !(bx <= 1.0 && by <= 1.0 && bw <= 1.0 && bh <= 1.0) && needs_ccw {
|
||||
let _rw = h as f64;
|
||||
let rh = w as f64;
|
||||
(rh - by - bh, bx, bh, bw)
|
||||
} else {
|
||||
(bx, by, bw, bh)
|
||||
};
|
||||
let (px, py, pw, ph) = if bx <= 1.0 && by <= 1.0 && bw <= 1.0 && bh <= 1.0 {
|
||||
((bx * w as f64) as u32, (by * h as f64) as u32, (bw * w as f64) as u32, (bh * h as f64) as u32)
|
||||
} else {
|
||||
(bx as u32, by as u32, bw as u32, bh as u32)
|
||||
};
|
||||
eprintln!("[get_face_thumbnail] {} frame={} img={}x{} bbox={},{},{},{} -> pixel={},{},{},{} crop={},{},{},{}",
|
||||
uuid, frame, w, h, bx, by, bw, bh, px, py, pw, ph,
|
||||
px.min(w.saturating_sub(1)), py.min(h.saturating_sub(1)),
|
||||
pw.min(w.saturating_sub(px.min(w.saturating_sub(1)))).max(1),
|
||||
ph.min(h.saturating_sub(py.min(h.saturating_sub(1)))).max(1));
|
||||
let cx = px.min(w.saturating_sub(1));
|
||||
let cy = py.min(h.saturating_sub(1));
|
||||
let cw = pw.min(w.saturating_sub(cx)).max(1);
|
||||
@@ -1124,8 +1289,122 @@ async fn merge_history(source_uuid: Option<String>, target_uuid: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
// === Service Management ===
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
async fn stop_service(name: String) -> Result<String, String> {
|
||||
let port = match name.as_str() {
|
||||
"Core API" | "core" => 3002,
|
||||
"MarkBase" | "markbase" => 11438,
|
||||
"Proxy" | "proxy" => 8888,
|
||||
"MarkBaseEngine" | "mbe" => 8080,
|
||||
"SFTP" => 2024,
|
||||
"SMB" => 4445,
|
||||
_ => return Err(format!("Unknown service: {}", name)),
|
||||
};
|
||||
let output = std::process::Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(format!("lsof -ti :{} | xargs kill -9 2>/dev/null; echo 'stopped'", port))
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to stop service: {}", e))?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
Ok(stdout.trim().to_string())
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
async fn start_service(name: String) -> Result<String, String> {
|
||||
let cmd = match name.as_str() {
|
||||
"MarkBase" | "markbase" => {
|
||||
"cd /Users/accusys/markbase && nohup cargo run --bin markbase-core -- web-start --port 11438 > /tmp/markbase_web.log 2>&1 &"
|
||||
}
|
||||
"Core API" | "core" => {
|
||||
"cd /Users/accusys/momentry_core && nohup bash run-server-3002.sh > /tmp/momentry_start.log 2>&1 &"
|
||||
}
|
||||
"Proxy" | "proxy" => {
|
||||
"cd /Users/accusys/momentry_studio/src-tauri && nohup ./target/debug/momentry-proxy > /tmp/momentry_proxy.log 2>&1 &"
|
||||
}
|
||||
"MarkBaseEngine" | "mbe" => {
|
||||
"cd /Users/accusys/MarkBaseEngine && nohup .build/debug/MarkBaseServer > /tmp/markbase_engine.log 2>&1 &"
|
||||
}
|
||||
"SFTP" => {
|
||||
"cd /Users/accusys/markbase && nohup cargo run --bin markbase-core -- ssh-start --port 2024 > /tmp/markbase_ssh.log 2>&1 &"
|
||||
}
|
||||
"SMB" => {
|
||||
"cd /Users/accusys/markbase && nohup cargo run --bin markbase-core --features smb-server -- smb-start --port 4445 --share-name markbase --root /Users/accusys/momentry/var/sftpgo/data --user demo:demo123 > /tmp/markbase_smb.log 2>&1 &"
|
||||
}
|
||||
_ => return Err(format!("Unknown service: {}", name)),
|
||||
};
|
||||
let output = std::process::Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(cmd)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to start service: {}", e))?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Start failed: {}", stderr));
|
||||
}
|
||||
let port = match name.as_str() {
|
||||
"MarkBase" | "markbase" => 11438,
|
||||
"Core API" | "core" => 3002,
|
||||
"Proxy" | "proxy" => 8888,
|
||||
"MarkBaseEngine" | "mbe" => 8080,
|
||||
"SFTP" => 2024,
|
||||
"SMB" => 4445,
|
||||
_ => 0,
|
||||
};
|
||||
if port > 0 {
|
||||
// MarkBaseEngine takes 5-10s to load the model; skip port check
|
||||
if port == 8080 {
|
||||
return Ok("started".to_string());
|
||||
}
|
||||
// Wait a moment then check if port is up
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
let check = std::process::Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(format!("lsof -ti :{} 2>/dev/null", port))
|
||||
.output()
|
||||
.map_err(|e| format!("Check failed: {}", e))?;
|
||||
if check.stdout.is_empty() {
|
||||
return Err(format!("Service {} failed to start on port {}", name, port));
|
||||
}
|
||||
}
|
||||
Ok("started".to_string())
|
||||
}
|
||||
|
||||
// === Admin Commands ===
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
async fn login_user(username: String, password: String) -> Result<db::LoginResult, String> {
|
||||
match db::verify_user(&username, &password) {
|
||||
Ok(Some(user)) => {
|
||||
let token = uuid::Uuid::new_v4().to_string();
|
||||
Ok(db::LoginResult {
|
||||
success: true,
|
||||
user: Some(user),
|
||||
token: Some(token),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
Ok(None) => Ok(db::LoginResult {
|
||||
success: false,
|
||||
user: None,
|
||||
token: None,
|
||||
error: Some("Invalid username or password".to_string()),
|
||||
}),
|
||||
Err(e) => Ok(db::LoginResult {
|
||||
success: false,
|
||||
user: None,
|
||||
token: None,
|
||||
error: Some(e),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
async fn get_current_user(username: String) -> Result<Option<db::User>, String> {
|
||||
db::get_user_by_username(&username)
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
async fn list_admin_users() -> Result<serde_json::Value, String> {
|
||||
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/Users/accusys"));
|
||||
@@ -1161,9 +1440,9 @@ async fn get_system_stats() -> Result<serde_json::Value, String> {
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
async fn list_shares() -> Result<serde_json::Value, String> {
|
||||
let shares = vec![
|
||||
serde_json::json!({"name": "SMB", "port": 4445, "status": "running"}),
|
||||
serde_json::json!({"name": "SFTP", "port": 2024, "status": "running"}),
|
||||
serde_json::json!({"name": "WebDAV", "port": 11438, "status": "running"}),
|
||||
serde_json::json!({"name": "MarkBase Web", "port": 11438, "status": "running", "protocol": "HTTP/WebDAV", "url": "http://localhost:11438"}),
|
||||
serde_json::json!({"name": "SFTP", "port": 2024, "status": "running", "protocol": "SSH/SFTP"}),
|
||||
serde_json::json!({"name": "SMB", "port": 4445, "status": "running", "protocol": "SMB"}),
|
||||
];
|
||||
Ok(serde_json::Value::Array(shares))
|
||||
}
|
||||
@@ -1256,6 +1535,7 @@ fn main() {
|
||||
get_file_info,
|
||||
get_thumbnail,
|
||||
get_face_thumbnail,
|
||||
get_file_thumbnail_by_path,
|
||||
get_identity_profile,
|
||||
update_identity_name,
|
||||
update_identity_status,
|
||||
@@ -1288,12 +1568,16 @@ fn main() {
|
||||
db::get_bookmarks,
|
||||
db::save_bookmark,
|
||||
db::delete_bookmark,
|
||||
login_user,
|
||||
get_current_user,
|
||||
list_admin_users,
|
||||
get_system_stats,
|
||||
list_shares,
|
||||
list_client_files,
|
||||
mkdir_client,
|
||||
rm_client
|
||||
rm_client,
|
||||
stop_service,
|
||||
start_service
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -9,9 +9,12 @@ use tower_http::cors::{Any, CorsLayer};
|
||||
use crate::db;
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use image::GenericImageView;
|
||||
use std::sync::Mutex;
|
||||
use image::DynamicImage;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::num::NonZeroUsize;
|
||||
use lru::LruCache;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
const CORE_API: &str = "http://localhost:3002";
|
||||
const API_KEY: &str = "muser_68600856036340bcafc01930eb4bd839_1774418104_97221b69";
|
||||
@@ -25,6 +28,16 @@ const PROFILE_DIRS: [&str; 4] = [
|
||||
|
||||
static FACE_THUMB_CACHE: Mutex<Option<LruCache<String, String>>> = Mutex::new(None);
|
||||
static PROFILE_PROXY_CACHE: Mutex<Option<LruCache<String, String>>> = Mutex::new(None);
|
||||
static RAW_DIM_PROXY_CACHE: Mutex<Option<HashMap<String, (u32, u32)>>> = Mutex::new(None);
|
||||
static THUMB_SEM_MAP_PROXY: Mutex<Option<HashMap<String, Arc<Semaphore>>>> = Mutex::new(None);
|
||||
|
||||
fn acquire_thumb_semaphore(uuid: &str) -> Arc<Semaphore> {
|
||||
let mut map = THUMB_SEM_MAP_PROXY.lock().unwrap();
|
||||
let map = map.get_or_insert_with(HashMap::new);
|
||||
map.entry(uuid.to_string())
|
||||
.or_insert_with(|| Arc::new(Semaphore::new(1)))
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn get_face_thumb_cache() -> std::sync::MutexGuard<'static, Option<LruCache<String, String>>> {
|
||||
let mut guard = FACE_THUMB_CACHE.lock().unwrap();
|
||||
@@ -67,7 +80,15 @@ pub async fn start_proxy_server() {
|
||||
|
||||
let app = Router::new()
|
||||
.route("/health", axum::routing::get(health_handler))
|
||||
.route("/api/v1/auth/login", axum::routing::post(login_handler))
|
||||
.route("/api/v1/face-thumbnail", axum::routing::get(get_face_thumbnail_handler))
|
||||
.route("/api/v1/media/frame", axum::routing::get(get_frame_handler))
|
||||
.route("/api/v1/identity-matches", axum::routing::get(get_identity_matches_handler))
|
||||
.route("/api/v1/cluster-results", axum::routing::get(get_cluster_results_handler))
|
||||
.route("/api/v1/processor-json", axum::routing::get(get_processor_json_handler))
|
||||
.route("/api/v1/file/thumbnail", axum::routing::get(get_file_thumbnail_by_path_handler))
|
||||
.route("/api/v1/file/:file_uuid/pose", axum::routing::get(get_pose_handler))
|
||||
.route("/api/v1/file/:file_uuid/appearance", axum::routing::get(get_appearance_handler))
|
||||
.fallback(fallback_handler)
|
||||
.layer(cors)
|
||||
.with_state(state);
|
||||
@@ -91,6 +112,81 @@ async fn health_handler() -> &'static str {
|
||||
"ok"
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct LoginRequest {
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct LoginResponse {
|
||||
success: bool,
|
||||
user: Option<UserInfo>,
|
||||
token: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct UserInfo {
|
||||
id: String,
|
||||
username: String,
|
||||
display_name: Option<String>,
|
||||
role: String,
|
||||
}
|
||||
|
||||
async fn login_handler(body: axum::body::Bytes) -> Response {
|
||||
let req: Result<LoginRequest, _> = serde_json::from_slice(&body);
|
||||
|
||||
match req {
|
||||
Ok(login_req) => {
|
||||
match db::verify_user(&login_req.username, &login_req.password) {
|
||||
Ok(Some(user)) => {
|
||||
let token = uuid::Uuid::new_v4().to_string();
|
||||
let response = LoginResponse {
|
||||
success: true,
|
||||
user: Some(UserInfo {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
display_name: user.display_name,
|
||||
role: user.role,
|
||||
}),
|
||||
token: Some(token),
|
||||
error: None,
|
||||
};
|
||||
(StatusCode::OK, axum::Json(response)).into_response()
|
||||
}
|
||||
Ok(None) => {
|
||||
let response = LoginResponse {
|
||||
success: false,
|
||||
user: None,
|
||||
token: None,
|
||||
error: Some("Invalid username or password".to_string()),
|
||||
};
|
||||
(StatusCode::UNAUTHORIZED, axum::Json(response)).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
let response = LoginResponse {
|
||||
success: false,
|
||||
user: None,
|
||||
token: None,
|
||||
error: Some(e),
|
||||
};
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, axum::Json(response)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let response = LoginResponse {
|
||||
success: false,
|
||||
user: None,
|
||||
token: None,
|
||||
error: Some(format!("Invalid request: {}", e)),
|
||||
};
|
||||
(StatusCode::BAD_REQUEST, axum::Json(response)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn fallback_handler(State(state): State<ProxyState>, req: axum::extract::Request) -> Response {
|
||||
let path = req.uri().path().to_string();
|
||||
let method = req.method().clone();
|
||||
@@ -483,6 +579,35 @@ async fn get_identity_profile_handler_inner(uuid: String) -> Response {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read EXIF orientation and physically rotate pixels, stripping orientation tag.
|
||||
/// Fetch raw video dimensions from Core API with in-memory cache.
|
||||
async fn fetch_raw_dimensions(uuid: &str) -> Option<(u32, u32)> {
|
||||
{
|
||||
let cache = RAW_DIM_PROXY_CACHE.lock().unwrap();
|
||||
if let Some(cache) = cache.as_ref() {
|
||||
if let Some(dims) = cache.get(uuid) {
|
||||
return Some(*dims);
|
||||
}
|
||||
}
|
||||
}
|
||||
let url = format!("{}/api/v1/file/{}?api_key={}", CORE_API, uuid, API_KEY);
|
||||
let result = reqwest::get(&url).await.ok()?
|
||||
.json::<serde_json::Value>().await.ok()?;
|
||||
let w = result["width"].as_u64().unwrap_or(0) as u32;
|
||||
let h = result["height"].as_u64().unwrap_or(0) as u32;
|
||||
if w == 0 || h == 0 {
|
||||
return None;
|
||||
}
|
||||
{
|
||||
let mut cache = RAW_DIM_PROXY_CACHE.lock().unwrap();
|
||||
let cache = cache.get_or_insert_with(HashMap::new);
|
||||
cache.insert(uuid.to_string(), (w, h));
|
||||
}
|
||||
Some((w, h))
|
||||
}
|
||||
|
||||
|
||||
|
||||
async fn get_face_thumbnail_handler(State(state): State<ProxyState>, axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>) -> Response {
|
||||
let start = std::time::Instant::now();
|
||||
let uuid = match params.get("uuid") {
|
||||
@@ -511,6 +636,17 @@ async fn get_face_thumbnail_handler(State(state): State<ProxyState>, axum::extra
|
||||
}
|
||||
}
|
||||
|
||||
let _permit = acquire_thumb_semaphore(&uuid).acquire_owned().await.unwrap();
|
||||
// Double-check cache after acquiring semaphore
|
||||
{
|
||||
let mut cache = get_face_thumb_cache();
|
||||
if let Some(c) = cache.as_mut() {
|
||||
if let Some(val) = c.get(&cache_key) {
|
||||
eprintln!("[proxy] <-- FACE-THUMB {} (cached after wait) {}ms", uuid, start.elapsed().as_millis());
|
||||
return ([("content-type", "text/plain")], val.clone()).into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
let url = format!("{}/api/v1/file/{}/thumbnail?api_key={}&frame={}", CORE_API, uuid, API_KEY, frame);
|
||||
|
||||
let bytes = match state.client.get(&url).send().await {
|
||||
@@ -556,3 +692,327 @@ async fn get_face_thumbnail_handler(State(state): State<ProxyState>, axum::extra
|
||||
eprintln!("[proxy] <-- FACE-THUMB {} {} {}ms", uuid, result.len(), start.elapsed().as_millis());
|
||||
([("content-type", "text/plain")], result).into_response()
|
||||
}
|
||||
|
||||
async fn get_identity_matches_handler(axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>) -> Response {
|
||||
let file_hash = match params.get("file_hash") {
|
||||
Some(v) => v.clone(),
|
||||
None => return (StatusCode::BAD_REQUEST, "Missing file_hash").into_response(),
|
||||
};
|
||||
let output_bases = [
|
||||
"/Users/accusys/momentry/output",
|
||||
"/Users/accusys/momentry/output_dev",
|
||||
"/Volumes/external/momentry/output",
|
||||
"/Volumes/external/momentry/output_dev",
|
||||
];
|
||||
let mut match_path = None;
|
||||
for base in &output_bases {
|
||||
let path = format!("{}/{}/{}.identity_match_round1.json", base, file_hash, file_hash);
|
||||
if std::path::Path::new(&path).exists() {
|
||||
match_path = Some(path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
match match_path {
|
||||
Some(path) => match std::fs::read_to_string(&path) {
|
||||
Ok(content) => {
|
||||
let parsed: serde_json::Value = match serde_json::from_str(&content) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, format!("Parse failed: {}", e)).into_response();
|
||||
}
|
||||
};
|
||||
axum::Json(parsed).into_response()
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Read failed: {}", e)).into_response(),
|
||||
},
|
||||
None => (StatusCode::NOT_FOUND, "Identity match file not found").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_cluster_results_handler(axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>) -> Response {
|
||||
let file_hash = match params.get("file_hash") {
|
||||
Some(v) => v.clone(),
|
||||
None => return (StatusCode::BAD_REQUEST, "Missing file_hash").into_response(),
|
||||
};
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/api/v1/file/{}/face-groups", CORE_API, file_hash);
|
||||
let resp = match client
|
||||
.get(&url)
|
||||
.header("X-API-Key", API_KEY)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("Face groups request failed: {}", e)).into_response(),
|
||||
};
|
||||
|
||||
let status = resp.status();
|
||||
let body = match resp.text().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("Body read failed: {}", e)).into_response(),
|
||||
};
|
||||
|
||||
let axum_status = axum::http::StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
if !axum_status.is_success() {
|
||||
return (axum_status, body).into_response();
|
||||
}
|
||||
|
||||
let result: serde_json::Value = match serde_json::from_str(&body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("JSON parse failed: {}", e)).into_response(),
|
||||
};
|
||||
|
||||
let clusters = result.get("face_groups").cloned().unwrap_or(serde_json::json!([]));
|
||||
axum::Json(serde_json::json!({
|
||||
"success": true,
|
||||
"clusters": clusters
|
||||
})).into_response()
|
||||
}
|
||||
|
||||
async fn get_processor_json_handler(
|
||||
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let file_hash = match params.get("file_hash") {
|
||||
Some(v) => v.clone(),
|
||||
None => return (StatusCode::BAD_REQUEST, "Missing file_hash").into_response(),
|
||||
};
|
||||
let processor = match params.get("processor") {
|
||||
Some(v) => v.clone(),
|
||||
None => return (StatusCode::BAD_REQUEST, "Missing processor").into_response(),
|
||||
};
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let json_url = format!("{}/api/v1/file/{}/json/{}", CORE_API, file_hash, processor);
|
||||
let json_resp = match client
|
||||
.post(&json_url)
|
||||
.header("X-API-Key", API_KEY)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("JSON request failed: {}", e)).into_response(),
|
||||
};
|
||||
|
||||
let status = json_resp.status();
|
||||
let body = match json_resp.text().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("Body read failed: {}", e)).into_response(),
|
||||
};
|
||||
|
||||
let axum_status = axum::http::StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
if axum_status.is_success() {
|
||||
match serde_json::from_str::<serde_json::Value>(&body) {
|
||||
Ok(v) => axum::Json(v).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("JSON parse failed: {}", e)).into_response(),
|
||||
}
|
||||
} else {
|
||||
(axum_status, body).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_file_thumbnail_by_path_handler(axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>) -> Response {
|
||||
let path = match params.get("path") {
|
||||
Some(v) => v.clone(),
|
||||
None => return (StatusCode::BAD_REQUEST, "Missing path").into_response(),
|
||||
};
|
||||
let frame: u32 = params.get("frame").and_then(|v| v.parse().ok()).unwrap_or(30);
|
||||
|
||||
if !std::path::Path::new(&path).exists() {
|
||||
return (StatusCode::NOT_FOUND, "File not found").into_response();
|
||||
}
|
||||
|
||||
let ext = path.rsplit('.').next().unwrap_or("").to_lowercase();
|
||||
let is_image = matches!(ext.as_str(), "jpg" | "jpeg" | "png" | "gif" | "webp");
|
||||
|
||||
if is_image {
|
||||
// For images, read directly
|
||||
match std::fs::read(&path) {
|
||||
Ok(bytes) => {
|
||||
let mime = match ext.as_str() {
|
||||
"png" => "image/png",
|
||||
"gif" => "image/gif",
|
||||
"webp" => "image/webp",
|
||||
_ => "image/jpeg",
|
||||
};
|
||||
([( "content-type", mime )], axum::body::Body::from(bytes)).into_response()
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Read failed: {}", e)).into_response(),
|
||||
}
|
||||
} else {
|
||||
// For videos, extract frame using ffmpeg
|
||||
let time_sec = frame as f64 / 24.0;
|
||||
let output = tokio::process::Command::new("ffmpeg")
|
||||
.args([
|
||||
"-ss", &time_sec.to_string(),
|
||||
"-i", &path,
|
||||
"-vframes", "1",
|
||||
"-q:v", "2",
|
||||
"-f", "mjpeg",
|
||||
"-"
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
match output {
|
||||
Ok(out) if out.status.success() => {
|
||||
let jpeg = out.stdout;
|
||||
([("content-type", "image/jpeg")], axum::body::Body::from(jpeg)).into_response()
|
||||
}
|
||||
_ => (StatusCode::INTERNAL_SERVER_ERROR, "Thumbnail extraction failed").into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_frame_handler(State(state): State<ProxyState>, axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>) -> Response {
|
||||
let file_uuid = match params.get("file_uuid") {
|
||||
Some(v) => v.clone(),
|
||||
None => return (StatusCode::BAD_REQUEST, "Missing file_uuid").into_response(),
|
||||
};
|
||||
let frame: u32 = params.get("frame").and_then(|v| v.parse().ok()).unwrap_or(0);
|
||||
|
||||
// Get file path from Core API
|
||||
let url = format!("{}/api/v1/file/{}?api_key={}", CORE_API, file_uuid, API_KEY);
|
||||
let file_info = match state.client.get(&url).send().await {
|
||||
Ok(resp) => match resp.json::<serde_json::Value>().await {
|
||||
Ok(json) => json,
|
||||
Err(e) => return (StatusCode::BAD_GATEWAY, format!("Parse failed: {}", e)).into_response(),
|
||||
},
|
||||
Err(e) => return (StatusCode::BAD_GATEWAY, format!("Request failed: {}", e)).into_response(),
|
||||
};
|
||||
|
||||
let file_path = match file_info["file_path"].as_str() {
|
||||
Some(p) => p.to_string(),
|
||||
None => return (StatusCode::NOT_FOUND, "File path not found").into_response(),
|
||||
};
|
||||
|
||||
if !std::path::Path::new(&file_path).exists() {
|
||||
return (StatusCode::NOT_FOUND, "File not found").into_response();
|
||||
}
|
||||
|
||||
let ext = file_path.rsplit('.').next().unwrap_or("").to_lowercase();
|
||||
let is_image = matches!(ext.as_str(), "jpg" | "jpeg" | "png" | "gif" | "webp");
|
||||
|
||||
if is_image {
|
||||
match std::fs::read(&file_path) {
|
||||
Ok(bytes) => {
|
||||
let mime = match ext.as_str() {
|
||||
"png" => "image/png",
|
||||
"gif" => "image/gif",
|
||||
"webp" => "image/webp",
|
||||
_ => "image/jpeg",
|
||||
};
|
||||
([("content-type", mime)], axum::body::Body::from(bytes)).into_response()
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Read failed: {}", e)).into_response(),
|
||||
}
|
||||
} else {
|
||||
let fps = file_info["fps"].as_f64().unwrap_or(24.0);
|
||||
let time_sec = frame as f64 / fps;
|
||||
let output = tokio::process::Command::new("ffmpeg")
|
||||
.args([
|
||||
"-ss", &time_sec.to_string(),
|
||||
"-i", &file_path,
|
||||
"-vframes", "1",
|
||||
"-q:v", "2",
|
||||
"-f", "mjpeg",
|
||||
"-"
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
match output {
|
||||
Ok(out) if out.status.success() => {
|
||||
let jpeg = out.stdout;
|
||||
([("content-type", "image/jpeg")], axum::body::Body::from(jpeg)).into_response()
|
||||
}
|
||||
_ => (StatusCode::INTERNAL_SERVER_ERROR, "Frame extraction failed").into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_pose_handler(
|
||||
axum::extract::Path(file_uuid): axum::extract::Path<String>,
|
||||
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let frame = match params.get("frame").and_then(|v| v.parse::<u32>().ok()) {
|
||||
Some(f) => f,
|
||||
None => return (StatusCode::BAD_REQUEST, "Missing frame").into_response(),
|
||||
};
|
||||
|
||||
let mut url = format!("{}/api/v1/file/{}/pose?frame={}", CORE_API, file_uuid, frame);
|
||||
if let Some(bbox_x) = params.get("bbox_x") {
|
||||
url.push_str(&format!("&bbox_x={}", bbox_x));
|
||||
}
|
||||
if let Some(bbox_y) = params.get("bbox_y") {
|
||||
url.push_str(&format!("&bbox_y={}", bbox_y));
|
||||
}
|
||||
if let Some(bbox_w) = params.get("bbox_w") {
|
||||
url.push_str(&format!("&bbox_w={}", bbox_w));
|
||||
}
|
||||
if let Some(bbox_h) = params.get("bbox_h") {
|
||||
url.push_str(&format!("&bbox_h={}", bbox_h));
|
||||
}
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let resp = match client
|
||||
.get(&url)
|
||||
.header("X-API-Key", API_KEY)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => return (StatusCode::BAD_GATEWAY, format!("Request failed: {}", e)).into_response(),
|
||||
};
|
||||
|
||||
let status = StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
let body = match resp.text().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("Body read failed: {}", e)).into_response(),
|
||||
};
|
||||
|
||||
(status, body).into_response()
|
||||
}
|
||||
|
||||
async fn get_appearance_handler(
|
||||
axum::extract::Path(file_uuid): axum::extract::Path<String>,
|
||||
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let frame = match params.get("frame").and_then(|v| v.parse::<u32>().ok()) {
|
||||
Some(f) => f,
|
||||
None => return (StatusCode::BAD_REQUEST, "Missing frame").into_response(),
|
||||
};
|
||||
|
||||
let mut url = format!("{}/api/v1/file/{}/appearance?frame={}", CORE_API, file_uuid, frame);
|
||||
if let Some(bbox_x) = params.get("bbox_x") {
|
||||
url.push_str(&format!("&bbox_x={}", bbox_x));
|
||||
}
|
||||
if let Some(bbox_y) = params.get("bbox_y") {
|
||||
url.push_str(&format!("&bbox_y={}", bbox_y));
|
||||
}
|
||||
if let Some(bbox_w) = params.get("bbox_w") {
|
||||
url.push_str(&format!("&bbox_w={}", bbox_w));
|
||||
}
|
||||
if let Some(bbox_h) = params.get("bbox_h") {
|
||||
url.push_str(&format!("&bbox_h={}", bbox_h));
|
||||
}
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let resp = match client
|
||||
.get(&url)
|
||||
.header("X-API-Key", API_KEY)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => return (StatusCode::BAD_GATEWAY, format!("Request failed: {}", e)).into_response(),
|
||||
};
|
||||
|
||||
let status = StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
let body = match resp.text().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("Body read failed: {}", e)).into_response(),
|
||||
};
|
||||
|
||||
(status, body).into_response()
|
||||
}
|
||||
181
src/App.vue
181
src/App.vue
@@ -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)
|
||||
|
||||
if (isTauri) return
|
||||
refreshUser()
|
||||
loadUserBehavior()
|
||||
|
||||
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-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>
|
||||
<div class="ms-divider" @mousedown="startDrag"></div>
|
||||
<main class="ms-main">
|
||||
<div class="ms-content"><router-view /></div>
|
||||
<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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
354
src/api/index.ts
354
src/api/index.ts
@@ -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
|
||||
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 || response.status < 500) break
|
||||
} catch (e: any) {
|
||||
if (i < retries - 1) {
|
||||
await new Promise(r => setTimeout(r, 1000 * (i + 1)))
|
||||
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
|
||||
}
|
||||
throw e
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
if (!response) throw new Error('No response')
|
||||
} 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 }
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">← 上一個</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>
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
834
src/store.ts
834
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 args: any = { page: targetPage, perPage: UNASSIGNED_PER_PAGE, fileUuid }
|
||||
const batch: any = await apiCall('get_unassigned_traces', args)
|
||||
const traces = batch?.traces || []
|
||||
if (!traces.length) break
|
||||
all.push(...traces)
|
||||
if (traces.length < 20) break
|
||||
}
|
||||
unassignedTracesCache.value = all
|
||||
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')) {
|
||||
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 loadProcessorCounts(fileUuid)
|
||||
} else if (data.processors?.some((p: any) => p.status === 'running')) {
|
||||
await loadFileStats(fileUuid, true)
|
||||
} else if (isRunning) {
|
||||
fileStatusCache.value[fileUuid] = 'processing'
|
||||
fileProgressCache.value[fileUuid] = {
|
||||
file_uuid: fileUuid,
|
||||
overall_progress: data.overall_progress || 0,
|
||||
processors: data.processors || [],
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -412,3 +1124,43 @@ 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
|
||||
}
|
||||
}
|
||||
@@ -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 /<username>).</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
@@ -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,7 +42,29 @@ async function handleLogin() {
|
||||
success.value = false
|
||||
|
||||
try {
|
||||
const response = await fetch('http://localhost:11438/api/v2/auth/login', {
|
||||
if (isTauri) {
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
const result: any = await invoke('login_user', {
|
||||
username: username.value,
|
||||
password: password.value
|
||||
})
|
||||
|
||||
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 {
|
||||
const apiBase = localStorage.getItem('proxy_url') || ''
|
||||
const response = await fetch(apiBase + '/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -57,20 +77,22 @@ async function handleLogin() {
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok && data.token) {
|
||||
if (response.ok && data.success && data.token) {
|
||||
success.value = true
|
||||
localStorage.setItem('token', data.token)
|
||||
localStorage.setItem('username', username.value)
|
||||
localStorage.setItem('expires_at', data.expires_at)
|
||||
|
||||
window.dispatchEvent(new CustomEvent('ms-login', { detail: { username: username.value } }))
|
||||
|
||||
setTimeout(() => {
|
||||
router.push('/search')
|
||||
}, 1000)
|
||||
}, 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
@@ -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">×</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
@@ -10,10 +10,22 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
allowedHosts: [
|
||||
'studio.momentry.ddns.net',
|
||||
'localhost',
|
||||
'127.0.0.1',
|
||||
'.ddns.net'
|
||||
],
|
||||
proxy: {
|
||||
'/api': {
|
||||
'/api/v1': {
|
||||
target: 'http://localhost:8888',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/api/v2': {
|
||||
target: 'http://localhost:11438',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user