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
28 KiB
28 KiB
多轨 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 核心类型定义
// ========== 基础类型 ==========
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 操作规范
// 所有 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 轨道操作
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 数据源转换
// 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(同步锁)
// 全局同步锁
const syncLock = ref<boolean>(true) // 默认锁定
// 锁定 🔒:点击 Mark → 所有轨道跳转到同一 frame
// 解锁 🔓:点击 Mark → 仅当前轨道跳转
UI 控件:
[🔒 Sync ON] / [🔓 Sync OFF]
6.3 PlayMode(全局播放模式)
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 代码实现
// 播放状态
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 播放机制
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 对齐保证
// 验证原点对齐
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 计算
// 所有 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
// 创建项目
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
// 获取轨道
GET /api/v1/track/:id
// 更新轨道
PUT /api/v1/track/:id
{
enabled?: boolean,
name?: string
}
// 删除轨道
DELETE /api/v1/track/:id
9.3 Mark API
// 获取轨道的所有 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 表结构
-- 项目表
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
<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)
-
数据结构定义
- 定义 TypeScript 接口
- 实现 Frame 操作函数
-
SQLite 存储
- 创建表结构
- 实现 CRUD API
-
单轨道 Mark 系统
- Track + Mark 基础功能
- Timeline 展示
12.2 Phase 2: 多轨播放(P1)
-
多轨道管理
- Project 层实现
- Track 增删改
-
同步播放
- PlaybackController
- 全局 Playhead
-
Timeline 对齐
- 统一计算公式
- 视觉对齐
12.3 Phase 3: 数据集成(P1)
-
数据源转换
- Face Trace → Mark
- ASR → Mark
- Noise → Mark
-
FPS 管理
- 视频元数据获取
- Time ↔ Frame 转换
12.4 Phase 4: 操作功能(P2)
-
Mark 操作
- 批量更新
- 批量删除
-
剪辑功能
- 基于 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 点击交互行为章节 |