Files
momentry_studio/.opencode/plans/multi_track_mark_system_design.md
Momentry Studio 5951aca086 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
2026-07-24 20:19:47 +08:00

28 KiB
Raw Permalink Blame History

多轨 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

  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 点击交互行为章节