# 多轨 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 updateMark(id: string, updates: Partial): void deleteMark(id: string): void // 批量操作 batchUpdateMarks(filter: (m: Mark) => boolean, updates: Partial): 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(true) // 默认锁定 // 锁定 🔒:点击 Mark → 所有轨道跳转到同一 frame // 解锁 🔓:点击 Mark → 仅当前轨道跳转 ``` **UI 控件**: ``` [🔒 Sync ON] / [🔓 Sync OFF] ``` ### 6.3 PlayMode(全局播放模式) ```typescript type PlayMode = 'normal' | 'continue' | 'loop' const globalPlayMode = ref('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(0) const isPlaying = ref(false) const stopAtFrame = ref(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 } ``` --- ## 十、数据持久化 ### 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 ``` --- ## 十二、实现步骤 ### 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 点击交互行为章节 |