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:
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 作为元数据
|
||||
- ✅ 原点对齐
|
||||
- ✅ 多轨同步
|
||||
|
||||
可以继续按照现有设计文档推进实现。
|
||||
Reference in New Issue
Block a user