视频标注工作台
v0.9.16 落地视频工作台 M0 + M1:视频元数据、manifest、播放/逐帧定位,以及当前帧 bbox 标注。
v0.9.17 把视频标注升级为 video_track:一条 annotation 保存一个对象轨迹和 compact keyframes,前端按需显示关键帧与线性插值结果。
v0.9.19 补齐视频工作台基础设施:关键帧编辑进入 keyframe 级撤销/重做,视频创建 / 更新 / 重命名复用离线队列兜底,时间轴改为画布内悬浮 overlay。
v0.9.20 分离视频矩形框与轨迹工具:video_bbox 重新成为可创建的一等对象,video_track 只由轨迹工具创建或延续,并新增 track → video_bbox 事务转换 API。
v0.9.21 加入帧时间表与前端 FrameClock:media worker 生成 frame_index -> pts_ms,前端 seek/playback 优先用 requestVideoFrameCallback 与真实 PTS 做帧号映射;轨迹插值也改为 keyframe 索引 + 二分查找。
v0.9.22 把视频渲染面向 CVAT 的 canvas 边界对齐:Media / Bitmap / Grid / Objects / Text / Interaction / Attachment 分层,bbox 命中测试迁到 Interaction 层统一 picker,并新增时间轴 frame bucket helper。
v0.9.23 引入 outside 段语义:video_track 可用闭区间表达目标在一段帧内不存在,前后端渲染、导出和 track → video_bbox 转换都兼容旧 absent=true 并优先尊重 outside。
v0.9.24 升级时间轴可视化:选中轨迹时显示 keyframe、outside、interpolated 和 prediction 分布;未选中轨迹时显示全局 keyframe 密度条,并支持 Shift+←/→ 跳上/下可见关键帧。
v0.9.26 补齐轻量视频导航:时间轴支持本地 loop region,播放可在片段内循环;当前帧可打书签并通过 marker 跳回;显式 seek 会记录最近 50 个位置用于前进 / 后退。
v0.9.27 接入后端单帧缓存的第一层前端消费:时间轴 hover 显示当前帧缩略图,并对选中轨迹关键帧、书签帧和 loop region 边界做预取 hint。
v0.9.29 增加 J/K/L 多速率播放和异步 seek 原语:显式跳帧入口统一走 seekFrameAsync,L 正向播放 / 加速,K 暂停,J 反向播放 / 减速;反向播放按帧步进,不使用浏览器负 playbackRate。
v0.9.31 补齐视频工作台观测包:本地 video:bench 固定回归矩阵,VideoStage 暴露当前 task 诊断快照,BugReportDrawer 会在视频工作台自动附带 frame clock、recent seeks、frame preview cache 和 timeline mode。
v0.9.39 完成视频渲染 P0:VideoStage 新增 ImageBitmap LRU 帧缓存和 bitmap canvas,seek / scrub 时可先显示缓存帧;视频 media、bitmap、grid、objects、text、interaction 和 attachment 层共享 viewport transform,支持 F fit、0 1:1、Ctrl/Meta+滚轮缩放、右键拖拽平移与 minimap。
v0.9.33 补齐视频资产失败可见性:存储管理页汇总 probe、poster、frame timetable、chunk 和 frame cache 失败,管理员可手动投递 media 队列重试。
数据入口
视频文件通过 dataset 导入进入系统:
DatasetItem.file_type = "video"。- 上传、ZIP 导入、bucket scan 完成后投递
app.workers.media.generate_video_metadata。 - Celery media worker 下载原视频到临时目录,调用
ffprobe解析元数据,再用ffmpeg抽首帧 poster。 - 元数据写入
dataset_items.metadata["video"],poster 路径写入dataset_items.thumbnail_path,使任务列表复用现有缩略图链路。
metadata["video"] 当前字段:
| 字段 | 含义 |
|---|---|
duration_ms | 视频时长,毫秒 |
fps | 帧率,优先取 avg_frame_rate |
frame_count | 帧数,优先取 nb_frames,缺失时用 duration * fps 估算 |
width / height | 视频原始尺寸 |
codec | 视频编码名 |
playback_path / playback_codec | 非浏览器兼容编码转码后的 H.264 MP4 对象路径与编码 |
poster_frame_path | poster 对象存储路径 |
probe_error / poster_error / playback_error | 解析、抽帧或播放转码失败原因 |
frame_timetable_frame_count | 已生成帧时间表的帧数 |
frame_timetable_error | 帧时间表生成失败原因;失败时前端按 fps 估算降级 |
这些失败字段会出现在 /storage 的「视频资产失败」面板中。管理员点击重试后,probe / poster / frame timetable 统一投递 generate_video_metadata;chunk / frame cache 失败则投递对应的 ensure_video_chunks / extract_video_frames。
Manifest API
GET /tasks/{task_id}/video/manifest 返回播放所需信息:
{
"task_id": "...",
"video_url": "https://...",
"poster_url": "https://...",
"metadata": {
"duration_ms": 1000,
"fps": 25,
"frame_count": 25,
"width": 640,
"height": 360,
"codec": "mpeg4",
"playback_path": "playback/..."
},
"expires_in": 3600
}非视频任务会返回 400。如果 playback_path 存在,manifest 的 video_url 会优先指向转码后的 H.264 MP4;否则使用原始视频对象。GET /tasks/{id} 也透出 video_metadata,用于列表和工作台决定是否进入视频 stage。
Frame Timetable API
v0.9.21 新增:
GET /api/v1/tasks/{task_id}/video/frame-timetable?from=0&to=120响应示例:
{
"task_id": "...",
"fps": 29.97,
"frame_count": 1800,
"source": "ffprobe",
"frames": [
{
"frame_index": 0,
"pts_ms": 0,
"is_keyframe": true,
"pict_type": "I",
"byte_offset": 48
}
]
}当存量视频还没有时间表时,接口返回 source: "estimated" 和空 frames;前端使用 fps 与 frame_count 继续估算,不阻断打开工作台。from / to 都是可选且包含边界。
Frame Preview API
v0.9.27 起,视频工作台前端消费 v0.9.25 的 task 级单帧缓存接口:
GET /api/v1/tasks/{task_id}/video/frames/{frame_index}?format=webp&w=320
POST /api/v1/tasks/{task_id}/video/frames:prefetchVideoStage 只把它用于时间轴 hover preview 和轻量预取,不替代 <video> 的主播放源。响应状态处理:
ready且有url:时间轴 preview popover 显示 signed URL 图片。pending:显示轻量 loading,并在短延迟后重试一次;不弹 toast。failed或网络错误:保留 frame/time 文案,当前 hover 帧不阻断 seek/playback。400/404:认为当前 task 不支持 frame service,本次打开期间停用 hover preview,只保留原 frame tooltip。
前端会对以下帧调用 frames:prefetch 作为 hint:当前选中 video_track 的 keyframes、当前 task 的 bookmark frames,以及 loop region 的起止帧。预取只影响后端单帧缓存,不写 annotation,也不会改变播放 / seek 语义。
Observability
v0.9.31 起,视频工作台提供两层前端诊断:
window.__videoFrameClockDiagnostics:按 task 保存useFrameClock诊断,包含 seek 次数、stale 回调、long task 计数、最近 frame-ready source 和最近 seek 样本。window.__videoWorkbenchDiagnostics:按 task 保存工作台快照,包含当前 frame、fps、timeline mode、J/K/L 播放速率、当前对象密度、loop/bookmark 状态,以及 frame preview cache hit/miss。
BugReportDrawer 会在视频工作台提交反馈时自动读取当前 active task 快照:
- 描述末尾追加
Video Workbench DiagnosticsJSON 块,方便管理员在/bugs直接查看。 recent_console_errors插入[video-workbench-diagnostics]结构化 payload,方便后续导出或聚类。- 如果快照里的
taskId是 UUID,会同步写入 bug report 的task_id字段。
本地性能回归入口:
pnpm --filter @anno/web video:bench -- --dry-run
pnpm --filter @anno/web video:bench详细流程见 视频工作台性能回归。
Annotation Schema
视频工作台支持两种视频 geometry:
video_bbox:当前帧独立矩形框。video_track:跨帧对象轨迹。
v0.9.20 起,前端通过 videoTool 决定新拖框落库类型:矩形框工具写 video_bbox,轨迹工具写 video_track 或追加 keyframe。
video_track 示例:
{
"type": "video_track",
"track_id": "trk_...",
"outside": [
{ "from": 24, "to": 48, "source": "manual" }
],
"keyframes": [
{
"frame_index": 0,
"bbox": { "x": 0.1, "y": 0.2, "w": 0.3, "h": 0.4 },
"source": "manual",
"absent": false,
"occluded": false
}
]
}约定:
annotation_type写video_track。track_id在单条 annotation 内稳定,用于 UI 展示和审核定位。- 类别继续使用 annotation 顶层
class_name,本期不引入稳定class_id。 keyframes[]是持久化数据;插值结果由前端按相邻关键帧计算,不写库。v0.9.21 起前端用缓存索引和二分查找解析当前帧。outside[]是 v0.9.23 起的一等消失段,使用闭区间{ from, to }表示目标在该段帧内不存在;相邻或重叠区间会在读写 helper 中归一化。source当前支持manual/prediction/interpolated;前端不会把计算得到的 interpolated frame 展开保存。absent=true是旧版单帧消失标记;读路径会把它视为单帧 outside,新的 UI 写入优先使用outside。- outside/absent 对渲染和导出优先级最高:落在 outside 的帧不显示对象、不导出 bbox,也不会参与 track →
video_bbox转换。 occluded=true表示目标存在但被遮挡,前端用虚线状态显示。
video_bbox geometry:
{
"type": "video_bbox",
"frame_index": 12,
"x": 0.1,
"y": 0.2,
"w": 0.3,
"h": 0.4
}约定:
frame_index从 0 开始,是唯一时间轴定位字段。x/y/w/h与图片 bbox 一样使用归一化坐标。annotation_type写video_bbox。- v0.9.20 起,
video_bbox可由视频矩形框工具直接创建,也可由 track 转换 API 生成。
Track 转独立框 API
v0.9.20 新增:
POST /api/v1/tasks/{task_id}/annotations/{annotation_id}/video/convert-to-bboxes请求体:
{
"operation": "copy",
"scope": "track",
"frame_mode": "all_frames"
}字段:
| 字段 | 取值 | 说明 |
|---|---|---|
operation | copy / split | copy 保留原 track;split 会移除源 keyframe 或删除整条源 track |
scope | frame / track | 转换当前帧或整条轨迹 |
frame_index | number | scope=frame 时必填 |
frame_mode | keyframes / all_frames | scope=track 时决定只转关键帧还是展开插值帧 |
响应返回源 annotation 的新状态、创建出的 video_bbox[]、是否删除源 track,以及被移除的 frame indexes。copy 不会改动源轨迹,removed_frame_indexes 为空;split 才会移除源关键帧或删除整条源轨迹,并返回被移除的帧号。all_frames 使用与 Video Tracks JSON 导出相同的后端插值 helper:outside/absent 范围不输出 bbox,也不会跨消失段转换。为避免长视频一次性写爆 annotation 表,单次请求最多生成 5000 个 video_bbox。
Track Composition
v0.9.37 新增反向组合接口:
POST /api/v1/tasks/{task_id}/annotations/video/track-compositions请求体按 operation 分三类:
{
"operation": "merge_tracks",
"annotation_ids": ["track-a", "track-b"],
"frame_index": 120
}字段:
| 字段 | 取值 | 说明 |
|---|---|---|
operation | aggregate_bboxes / split_track / merge_tracks | 聚合单帧框、拆分轨迹、合并轨迹 |
annotation_ids | UUID[] | 聚合时传 video_bbox[];拆分时传 1 条 video_track;合并时传 2 条 video_track |
frame_index | number | split_track 必填,表示在当前可见帧之后切出后段 |
delete_sources | boolean | aggregate_bboxes 默认为 true,成功后删除源 video_bbox |
约束:
aggregate_bboxes要求同任务、同类、每帧最多一个video_bbox。split_track要求切点是可见帧,源 annotation 保留前段,新 annotation 保存后段。merge_tracks只接受两条同类且可见帧区间不重叠的 track;中间 gap 会写入outside段。- 响应返回
updated_annotations[]、created_annotations[]和deleted_annotation_ids[],前端用这些结果更新 annotation cache 并组成 undo/redo batch。
插值与质量检查
前端只在相邻有效关键帧之间做 bbox 线性插值:
x/y/w/h按frame_index距离线性计算。- 如果两个关键帧之间存在
absent=true,不显示跨段插值。 - 手工 / 预测关键帧优先于插值结果。
- 编辑时 bbox 会 clamp 到
[0, 1]归一化范围。
当前质检提示在前端完成,不阻止保存:
- 同一 track 关键帧间隔过大。
- 当前帧 bbox 极小。
- 当前帧同类别 bbox 高度重叠。
Video Tracks JSON 导出
v0.9.18 起,video-track 项目可通过现有导出入口拿到专用 JSON:
GET /api/v1/projects/{project_id}/export?format=coco&video_frame_mode=keyframes
GET /api/v1/projects/{project_id}/batches/{batch_id}/export?format=coco&video_frame_mode=all_frames虽然复用了 format=coco 查询参数,响应不是 COCO,而是:
{
"export_type": "video_tracks",
"exported_at": "2026-05-11T00:00:00",
"frame_mode": "keyframes",
"project": { "id": "...", "display_id": "P-1", "type_key": "video-track" },
"categories": [{ "id": 0, "name": "car" }],
"tasks": [{ "id": "...", "display_id": "T-1", "video_metadata": { "fps": 25 } }],
"tracks": [
{
"annotation_id": "...",
"task_id": "...",
"track_id": "trk_car",
"class_name": "car",
"outside": [{ "from": 24, "to": 48, "source": "manual" }],
"keyframes": [
{
"frame_index": 0,
"bbox": { "x": 0.1, "y": 0.2, "w": 0.3, "h": 0.4 },
"source": "manual",
"absent": false,
"occluded": false
}
]
}
],
"keyframes": [],
"video_bbox": [],
"video_metadata": {}
}导出模式:
keyframes:只输出持久化关键帧。all_frames:每条 track 增加frames[],后端按相邻有效关键帧线性插值x/y/w/h。
插值规则与前端显示保持一致:outside 段优先;精确关键帧其次;absent=true 的旧关键帧会被当作单帧 outside;occluded=true 表示目标存在但遮挡,不阻断插值。video_frame_mode=all_frames 不输出 outside 范围内的 bbox,也不会把 track → video_bbox 转换到 outside 帧上。
include_attributes=false 会移除 project.attribute_schema 以及 track / legacy video_bbox 上的 attributes。format=yolo|voc 对视频项目返回 400,因为这两个格式会丢失 track 与关键帧语义。
前端 Stage 边界
WorkbenchShell 只计算 stageKind。WorkbenchStageHost 根据 stageKind 分派到 ImageWorkbench / VideoWorkbench / ThreeDWorkbench.placeholder;视频任务由 VideoWorkbench 包装 VideoStage。
stageKind 的视频入口仍由 task.file_type === "video" 或项目类型 video-track 决定。3D 入口只显示占位,不复用视频内部 geometry。
VideoStage 暴露 VideoStageControls ref,由 useWorkbenchHotkeys 在 videoMode 下统一分发快捷键。视频模式快捷键:
Space播放 / 暂停J/K/L反向播放或减速 / 暂停 / 正向播放或加速B/T切换视频矩形框 / 轨迹工具←/→逐帧,/.逐帧备用键Shift + ←/→选中video_track时跳上/下可见关键帧;未选中轨迹时跳 10 帧Ctrl+M当前帧添加 / 移除书签Ctrl+[/Ctrl+]跳转历史后退 / 前进Alt+L清除本地 loop regionDelete/Backspace删除选中轨迹Tab/Shift+Tab循环轨迹Esc取消选择1-9有选中视频对象时改其class_name;无选中时切 active class
图片工作台的 SAM、polygon、canvas 工具在视频任务中不展示;左侧队列、顶部提交/审核、右侧属性面板、评论、任务锁和离线队列继续复用同一个 Workbench 外壳。
视频创建、追加关键帧、重命名、改类、track 转 bbox 等动作由 useVideoAnnotationActions 维护。跨 Stage 的 class picker / 改类 / SAM 接受 / 批量改类弹窗由 WorkbenchOverlays 渲染,不再挂在 ImageStage.overlay 上。
视频渲染层
v0.9.22 起,视频画布结构对齐 CVAT 的 canvas layer contract,但仍保留本项目的 React + SVG + HTML video 实现:
| 层 | 文件 | 职责 |
|---|---|---|
| Media | VideoMediaLayer.tsx | 承载 <video>,由 useFrameClock 驱动 |
| Bitmap | VideoBitmapLayer.tsx | v0.9.39 起绘制 ImageBitmap LRU 命中的缓存帧 |
| Grid | VideoGridLayer.tsx | viewport 同步层,后续可接网格 / ruler |
| Objects | VideoObjectsLayer.tsx | 渲染 committed bbox、track path preview 和 pending draft |
| Text | VideoTextLayer.tsx | 独立渲染 label,避免文字吞掉 handle 命中 |
| Interaction | VideoInteractionLayer.tsx | 统一 pointer 入口、picker、选中框、resize handle、draft、ghost |
| Attachment | VideoAttachmentLayer.tsx | 后续 hover thumbnail、review issue、comment anchor 的 DOM 挂载点 |
VideoStageSurface 负责统一尺寸、aspect ratio、层叠顺序和 viewport transform。对象层不再给每个 bbox 主体挂 pointerdown,Interaction 层通过 videoStageCoordinates.ts 把 client 坐标映射到视频归一化坐标,再用 videoStagePicking.ts 选择顶层框。
v0.9.39 起,视频工作台的 viewport 与图片工作台复用同一套 useViewportTransform 行为:F 适应视口、0 回到 1:1、Ctrl/Meta+滚轮以光标为锚点缩放、右键拖拽平移。缩放和平移只影响显示层,保存到 annotation 的 bbox / keyframe 仍是 [0,1] 归一化视频坐标。
R5.2 的 bitmap cache 只优化前端体感,不替代 <video> 播放源。useVideoBitmapCache 在浏览器支持 createImageBitmap(video) 时按 taskId + frameIndex 保存 LRU;seek / scrub 命中时 VideoBitmapLayer 先绘制缓存帧,<video> 异步追赶。浏览器不支持或抓帧失败时,bitmap 层保持隐藏并在诊断里标记 unsupported / errors。
videoStageMode.ts 提供轻量 busy guard:idle 允许 seek / draw / drag / resize;draw / drag / resize 期间 frame setup 会被拦截并暂停播放,避免播放 tick 覆盖编辑中的几何。
v0.9.19 后,VideoStage 底部固定控制条改为 VideoPlaybackOverlay:
- 悬浮在视频画布底部,不再占用 stage 布局高度。
- hover 时显示,离开后延迟淡出;绘制或拖动 bbox 时隐藏,避免误触 scrubber。
- 保留播放 / 暂停、逐帧按钮、range scrubber、关键帧 tick、当前帧号、时间和当前帧框数。
- v0.9.23 起,底部标记的数据源升级为 timeline markers:keyframe 仍显示为细线,prediction 使用不同颜色,outside 段显示为灰色区间。
- v0.9.24 起,选中
video_track时显示该轨迹的单轨 timeline:keyframe 圆点、outside 灰段、interpolated 虚线段和 prediction 标记;未选中轨迹时显示全局 keyframe 密度条。 Shift+←/→复用同一套可见关键帧计算,跳过 outside 和 legacyabsent=true帧;如果没有选中轨迹,则保持原有 ±10 帧跳转。- v0.9.26 起,
Shift+drag时间轴可创建本地 loop region;播放越过范围末帧后 seek 回起始帧,逐帧和手动 seek 不被限制。 - loop region、书签和跳转历史只存前端会话状态,按 task 写入
sessionStorage,不改变 annotation schema 或后端 API。 - 书签以小三角 marker 显示,
Ctrl+M在当前帧加 / 删;显式 seek、bookmark 跳转和关键帧跳转写入最近 50 条跳转历史,播放 tick 不写历史。 - v0.9.27 起,hover 时间轴会请求单帧预览图;ready 时显示缩略图,pending/error 时降级显示 frame/time。选中轨迹关键帧、书签帧和 loop region 边界会被预取。
- v0.9.29 起,
useFrameClock.seekToAsync作为VideoStage.seekFrameAsync的底层原语;时间轴 scrub、逐帧、关键帧、书签和跳转历史都通过它跳帧。J/K/L jog 播放支持0.25x / 0.5x / 1x / 2x / 4x,overlay 会显示当前速度,反向播放通过帧步进实现。 - v0.9.35 起,review 模式的
raw / final / diff同步作用于视频工作台:raw显示 prediction / interpolated 来源,final显示 manual / legacy,diff叠加。评论协议增加可选anchor,视频评论可记录当前frameIndex、trackId和来源,评论 chip 可点击跳回对应帧。 - v0.9.39 起,工作台右下角复用 Minimap,放大后显示当前视口、当前帧位置和 ImageBitmap 已缓存帧范围;
window.__videoWorkbenchDiagnostics也包含 bitmap cache 与 viewport/minimap 状态。
History / Offline
图片工作台的 useAnnotationHistory 仍处理 annotation 级 create / update / delete。视频侧在 v0.9.19 增加 videoKeyframe command:
- 单个
frame_index的关键帧新增、移动、absent和occluded切换只撤销该关键帧。 - 创建 / 删除整条 track、重命名类别仍按 annotation 级命令处理。
- apply 时读取当前最新
video_trackgeometry,只替换目标帧 keyframe,保留其它关键帧。
视频写操作仍走原 annotation API。网络断开或 5xx 时:
- create 进入现有 offline queue 的
createop。 - keyframe update / rename 进入现有 offline queue 的
updateop。 - 恢复连接后由
useWorkbenchOfflineQueue顺序重放。 - 409 版本冲突不进入离线队列,继续打开通用
ConflictModal;keyframe diff UI 留后续增强。
VideoStage 内部维护轨迹列表 UI 状态:
- 显隐和锁定只影响当前工作台会话,不持久化。
- 重命名轨迹会更新 annotation 顶层
class_name。 - 选中轨迹但当前帧无可显示 bbox 时,stage 会用最近非
absent且未落入 outside 的关键帧渲染虚线参考框;拖动参考框或点击「复制到当前帧」会通过同一upsertKeyframe路径创建当前帧关键帧,并清理当前帧 outside 覆盖。 - 当前轨迹面板展示
track_id+frame_index,审核退回时可复制到原因文本中定位问题。