
screenpipe 时间线视频帧加载架构解析从每次滚动 1.5~4 秒的 ffmpeg 抽帧到 100ms 的浏览器硬解跳帧【免费下载链接】screenpipeYC (S26) | Open Computer History | Record your screen continuously locally and provide context to your agents (Claude, Codex, Openclaw, Hermes, Runner...)项目地址: https://gitcode.com/GitHub_Trending/sc/screenpipe导读本文以 screenpipe 仓库中的 docs/TIMELINE_VIDEO_SPEC.mdIssue #2165 对应架构规范为主体完整剖析 timeline时间线回放模块如何把每滚动一帧就 spawn 一次 ffmpeg 进程抽 JPEG的高延迟链路重构为直接用video元素 video.currentTime offset_index / fps做浏览器硬件级跳帧的低延迟方案。读完本文你将掌握该方案的完整数据模型、后端/前端改造点、双缓冲视频池设计、全量边界情况清单与测试验收标准并能在仓库源码中逐一验证其落地状态。1. 问题背景每帧视图的现状链路与瓶颈定位1.1 现有架构的时间线帧加载链路在现有实现中timeline 中展示的每一帧都走同一条路径用户滚动 → frame_id 变化 → 150ms 防抖 → HTTP GET /frames/{frame_id} → 服务端DB 查询 (file_path, offset_index) → ffprobe 读取视频元数据 (~200-500ms首次后缓存) → ffmpeg 进程 spawn、seek、抽取单帧 JPEG (~500ms-3s) → 写 JPEG 到 /tmp/screenpipe_frames/ → 通过 HTTP 回传文件 → 浏览器渲染 img 单帧总计耗时1.5–4 秒这条链路的核心矛盾在于每一帧视图都会 spawn 一个新的 ffmpeg 进程。以一个典型工作日估算——0.5 fps 采集、双显示器、8 小时工作系统共产生28,800 帧、分布在1,920 个视频 chunk中意味着每天要 spawn 上万次 ffmpeg 进程来干一件浏览器本来就能硬件完成的事。1.2 用户反馈规范文档引用了两类真实反馈用户 Creon Levit截图上下文希望滚动时更快地检索与显示截图并可选地提高压缩质量、减少模糊。用户 MenelausDiscord把 fps 调到 0.5 后仍觉得 timeline 行为怪异、难以判断是正常工作还是 bug 卡顿。1.3 根因分析规范明确指出瓶颈是每帧 spawn ffmpeg 进程并给出 5 条关键论据数据本来就以浏览器可播放的格式存在HEVC/H.265 封装于 fragmented MP4screenpipe 编码时使用-movflags frag_keyframeempty_moovdefault_base_moofmoov atom 位于文件开头——即使是正在录制的文件也可 seek0.5 fps 下 GOP 结构使大多数帧就是关键帧浏览器 seek 能做到帧级精确macOS 上的 WebKitTauri 的 webview原生支持 HEVCfile_path已经通过 WebSocket 元数据下发给客户端了——只是没有被用于显示。即数据已在手边、浏览器可硬解、路径已下发却依然每天 spawn 28,800 个 ffmpeg 进程——这就是本次架构重构要消灭的根本浪费。2. 目标与非目标Goals优先级排序优先级目标P0同 chunk seek 帧显示延迟 100ms30 秒窗口内滚动P0跨 chunk seek 延迟 500ms跳到相邻 chunkP0跨天跳转延迟 1s点击 15 天前的日期P0正常时间线滚动期间零 ffmpeg 进程 spawnP1无缝的 chunk 边界切换无闪烁P1支持正在录制的 chunklive edge 实时边沿P1OCR 文本覆盖层继续正常工作P2降低服务端抽帧的 CPU 占用Non-Goals明确不做不改变录制格式HEVC 保持不变数据库 schema 只做增量字段扩展本迭代不支持无 HEVC 能力的浏览器Linux / 部分 Windows 保留 ffmpeg 回退路径不做视频播放播放/暂停——本文只讨论静态帧 seek不大改 WebSocket 流式协议。3. 数据模型现状盘点3.1 数据库 Schema规范给出了当前两张核心表-- video_chunks: 每个 MP4 文件一行 CREATE TABLE video_chunks ( id INTEGER PRIMARY KEY AUTOINCREMENT, file_path TEXT NOT NULL -- e.g., ~/.screenpipe/data/monitor_1_2026-02-06_10-30-00.mp4 ); -- frames: 每捕获帧一行 CREATE TABLE frames ( id INTEGER PRIMARY KEY AUTOINCREMENT, video_chunk_id INTEGER NOT NULL, offset_index INTEGER NOT NULL, -- chunk 内帧号 (0, 1, 2, ...) timestamp TIMESTAMP NOT NULL, FOREIGN KEY (video_chunk_id) REFERENCES video_chunks(id) );3.2 视频文件属性属性值编码HEVC (hvc1)经 libx265容器Fragmented MP4frag_keyframeempty_moovdefault_base_moofFPS可配置0.2–1.0默认 0.5Chunk 时长30sTauri 应用/ 60sCLI每 chunk 帧数~15 帧0.5fps × 30s分辨率显示器原生分辨率如 1920×1080、3024×1964单 chunk 典型大小300KB–1MB命名monitor_{id}_{YYYY-MM-DD_HH-MM-SS}.mp4在源码 crates/screenpipe-core/src/video.rs 的start_ffmpeg_process中可以看到编码参数与表格完全对应-vcodec libx265 -tag:v hvc1保证 hvc1 标签以便 Apple 平台识别、-preset与-crf由video_quality映射而来并且显式禁用 B 帧-bf 0——注释解释得很清楚libx265 默认的 B 帧缓冲会把 PTS 偏移 2 帧0.5fps 下首帧会落在 4s 而不是 0s导致前端 seek 到错误帧而截图场景每帧视觉独立B 帧毫无收益。这个细节正是seek 数学能够成立的前提。3.3 Seek 数学currentTime offset_index / fps 0.5 fps、30s chunk15 帧示例 offset_index0 → currentTime 0s offset_index1 → currentTime 2s offset_index7 → currentTime 14s offset_index14 → currentTime 28s3.4 客户端当前通过 WebSocket 收到的内容interface StreamTimeSeriesResponse { timestamp: string; // ISO 8601 devices: DeviceFrameResponse[]; } interface DeviceFrameResponse { device_id: string; // monitor_1 frame_id: string; // DB id — 用于 GET /frames/{id} metadata: { file_path: string; // ✅ 已下发 — MP4 路径 app_name: string; window_name: string; ocr_text: string; browser_url?: string; }; audio: AudioData[]; }客户端缺失的信息offset_index和fps——而它们正是video.currentTime计算所必需的。3.5 Asset 协议作用域现状// tauri.conf.json security: { assetProtocol: { enable: true, scope: [$APPDATA/**] // ⚠️ 不包含 ~/.screenpipe/data/ } }问题视频文件位于~/.screenpipe/data/在$APPDATAmacOS 上是~/Library/Application Support/之外。因此要么扩展 asset protocol 作用域要么改由 HTTP 服务器提供视频文件。4. 提议架构用浏览器硬解跳帧替代 ffmpeg 抽帧4.1 方案总览现状: scroll → frame_id → HTTP GET → ffprobe → ffmpeg → JPEG → img 延迟: 1.5–4s 提案: scroll → (file_path, offset_index, fps) 已在内存中 → video.currentTime offset_index / fps → 浏览器硬件 seek HEVC 延迟: 同 chunk 10–50ms换 chunk 100–300ms4.2 组件图┌─────────────────────────────────────────────────────────┐ │ Timeline UI │ │ │ │ ┌──────────────┐ ┌──────────────────────────────────┐ │ │ │ Timeline Bar │ │ VideoFrameDisplay │ │ │ │ (unchanged) │ │ │ │ │ │ │ │ ┌────────────────────────────┐ │ │ │ │ Scrolls → │ │ │ video current chunk │ │ │ │ │ frame index │ │ │ .currentTime offset/fps │ │ │ │ │ │ │ └────────────────────────────┘ │ │ │ │ │ │ ┌────────────────────────────┐ │ │ │ │ │ │ │ video preloaded next │ │ │ │ │ │ │ │ (hidden, ready to swap) │ │ │ │ │ │ │ └────────────────────────────┘ │ │ │ │ │ │ ┌────────────────────────────┐ │ │ │ │ │ │ │ canvas for OCR overlay │ │ │ │ │ │ │ │ (captures video frame) │ │ │ │ │ │ │ └────────────────────────────┘ │ │ │ └──────────────┘ └──────────────────────────────────┘ │ └─────────────────────────────────────────────────────────┘ │ │ │ WebSocket │ file:// or http:// ▼ ▼ ┌─────────────────┐ ┌──────────────────────┐ │ Backend Server │ │ Local filesystem │ │ (localhost:3030) │ │ ~/.screenpipe/data/ │ │ │ │ monitor_*.mp4 │ │ Sends metadata: │ └──────────────────────┘ │ file_path │ │ offset_index │ ← NEW │ fps │ ← NEW │ chunk_id │ ← NEW │ frame_id │ └──────────────────┘4.3 视频访问策略双轨并进方案 A扩展 Tauri asset protocol 作用域scope: [$APPDATA/**, $HOME/.screenpipe/**]随后用convertFileSrc(file_path)得到asset://localhost/...URL。✅ 零 HTTP 开销✅ 原生文件流 Range 请求⚠️ 需处理自定义dataDir设置用户可改数据存放位置⚠️ 对自定义目录需用scope.allow_file()动态添加路径方案 B新增 HTTP 端点/video/chunk/{chunk_id}通过现有 HTTP 服务器直接提供原始 MP4 文件并支持 Range 请求。✅ 无需修改 scope 配置✅ 适用于任意 data dir 位置⚠️ 有 HTTP 开销本地连接可忽略⚠️ 必须支持 Range 头才能高效 seek推荐方案 Aasset protocol为主、方案 B 为自定义 data dir 的回退。两者都应实现因为video元素只需一个 URL。仓库落地验证在 apps/screenpipe-app-tauri/src-tauri/tauri.conf.json 中assetProtocol.scope已扩展为[$APPDATA/**, $HOME/.screenpipe/**]与方案 A 完全一致。5. 详细设计5.1 后端改动尽量最小5.1.1 向 WebSocket 响应追加offset_index与fps// server.rs — DeviceFrameResponse pub struct DeviceFrameResponse { pub device_id: String, pub frame_id: i64, pub offset_index: i64, // NEW: chunk 内帧索引 pub fps: f64, // NEW: 视频 chunk 的 fps pub metadata: DeviceMetadata, pub audio: VecAudioData, }offset_index本就存在于 DB 查询返回的FrameData中fps有两个来源通过既有元数据缓存get_video_fps_and_duration从视频文件读取或存入新的video_chunks.fps列更优——彻底避免 ffprobe。推荐方案给video_chunks表加fps REAL列在 chunk 创建时写入启动 ffmpeg 时 fps 是已知参数。随后 WebSocket 响应直接从 DB 查询带出零额外成本。5.1.2 给 video_chunks 增加 fps 列-- Migration ALTER TABLE video_chunks ADD COLUMN fps REAL DEFAULT 0.5;存量数据回填-- Backfill: 使用全局默认值。会话中途改过 fps 的用户的 -- 部分 chunk 可能 fps 不准但 seek 结果足够接近±1 帧。 UPDATE video_chunks SET fps 0.5 WHERE fps IS NULL OR fps 0;在 chunk 创建时fps 是start_ffmpeg_process的参数直接存储即可// 在 save_frames_as_video 中创建新 chunk 时: new_chunk_callback(output_file, fps); // 把 fps 传给回调 // DB 插入: INSERT INTO video_chunks (file_path, fps) VALUES (?, ?);仓库落地验证迁移文件 crates/screenpipe-db/src/migrations/20260206200000_add_fps_to_video_chunks.sql 已实现ALTER TABLE video_chunks ADD COLUMN fps REAL NOT NULL DEFAULT 0.5注释说明默认 0.5 对应 App 默认CLI 默认 1.0回填迁移 crates/screenpipe-db/src/migrations/20260207100000_backfill_fps_defaults.sql 则处理了 v0.3.130 中 nullable 版本的存量 NULL 值UPDATE video_chunks SET fps 0.5 WHERE fps IS NULL保证无论用户从哪个版本升级数据都一致。5.1.3 新端点/video/stream/{chunk_path}用于带正确 Range 请求支持的视频文件服务async fn stream_video_file( Path(path): PathString, ) - impl IntoResponse { // 校验路径必须在 screenpipe data dir 下安全 // 返回 Accept-Ranges、Content-Range 头 // 使 video 元素能够高效 seek }这是 asset protocol 作用域未覆盖文件路径时的回退方案。5.1.4 保留/frames/{frame_id}端点ffmpeg 回退现有端点不要删除它继续服务于无 HEVC 浏览器支持平台的回退如 Linux视频文件损坏导致video失败时的回退需要 JPEG 帧的导出功能。另需注意帧图在静态存储时已由 image-PII workerrfdetr → 黑框打码做过脱敏不存在按请求的?redact_pii查询参数。5.2 前端改动5.2.1 新组件VideoFrameDisplay用它替换CurrentFrameTimeline中的img改为video元素池。interface VideoFrameDisplayProps { filePath: string; // 来自 WebSocket metadata offsetIndex: number; // NEW 来自 WebSocket fps: number; // NEW 来自 WebSocket frameId: string; // 用于 OCR 数据获取 onFrameReady: () void; onError: () void; // 触发 ffmpeg 回退 }核心 seek 逻辑const seekToFrame useCallback((video: HTMLVideoElement, offsetIndex: number, fps: number) { const targetTime offsetIndex / fps; // 仅在未处于该时间点时 seek避免冗余 seek if (Math.abs(video.currentTime - targetTime) 0.001) return; video.currentTime targetTime; // seeked 事件在帧可显示时触发 }, []);5.2.2 视频元素池双缓冲每个显示器维护 2 个video元素Active当前正在显示某一帧Preloaded已加载并准备好下一个/上一个 chunk。interface VideoPool { active: { element: HTMLVideoElement; chunkPath: string; fps: number; }; preloaded: { element: HTMLVideoElement; chunkPath: string; fps: number; } | null; }跨 chunk 边界时的处理顺序交换 active ↔ preloaded瞬时完成——元素已有数据把新的相邻 chunk 加载进刚腾出的 preloaded 槽位上一个 chunk 的video清空src以释放内存。5.2.3 Chunk 预加载策略显示某一帧时按以下优先级预加载 chunk下一个时间顺序的 chunk正向滚动 / 位于 live edge 时上一个时间顺序的 chunk反向滚动时仅当相邻 chunk 距当前 ≤ 2 个 chunk 时才预加载不要预加载 50 个 chunk。预加载是被动的——只需设置video src...并加preloadauto缓冲交给浏览器。5.2.4 Chunk 到 URL 的映射建立从file_path到可播放 URL 的映射function getVideoUrl(filePath: string): string { // 先尝试 asset protocol try { return convertFileSrc(filePath); } catch { // 回退到 HTTP 端点 return http://localhost:3030/video/stream?path${encodeURIComponent(filePath)}; } }5.2.5 回退到 ffmpegimg路径当video触发error事件不支持的编码、文件损坏等时const handleVideoError useCallback(() { console.warn(Video element failed, falling back to ffmpeg frame extraction); setUseFfmpegFallback(true); // 改为渲染 img src{http://localhost:3030/frames/${frameId}} }, [frameId]);按 chunk 记录失败状态避免反复重试const failedChunks useRef(new Setstring());5.2.6 OCR 覆盖层交互当前 OCR 文本位置按frame_id获取并覆盖在img上。切换到video后OCR 数据获取不变以frame_id为键从/frames/{frame_id}/ocr获取覆盖层div的定位方式不变基于容器百分比定位naturalDimensions改用video.videoWidth/video.videoHeight而非img.naturalWidth文本选择行为不变覆盖层 div 叠加在视频上方。OCR 覆盖层无需改动——它本就与图片来源无关。5.2.7 Timeline Store 改动useTimelineStore需要从到达的帧构建 chunk 索引按file_path分组跟踪每个video元素当前加载了哪个 chunk提供getChunkForFrame(frameIndex)→{ filePath, fps, offsetIndex }。// 由 frames 数组派生——每次 flush 时重算 interface ChunkIndex { // 带时间范围的有序唯一 chunk 列表 chunks: Array{ filePath: string; fps: number; startTimestamp: string; endTimestamp: string; frameIds: number[]; // 该 chunk 中的帧 offsetIndices: number[]; // 对应的偏移 }; // 快速查找: frameId → chunk 索引 frameToChunk: Mapstring, number; }5.3 迁移与向后兼容组件改动向后兼容DeviceFrameResponse新增offset_index、fps字段✅ 纯增量 JSON 字段video_chunks表新增带 DEFAULT 的fps列✅ 存量行获得默认值CurrentFrameTimeline替换内部实现、保留 props✅ 组件 API 不变/frames/{frame_id}端点原样保留✅ 无改动Asset protocol 作用域扩展✅ 只授予更多访问use-timeline-store新增 chunk 索引✅ 内部重构6. 边界情况全量清单规范用 9 组场景表格逐一定义了边界行为这是整份文档最有工程价值的部分完整罗列如下。6.1 同 chunk 导航最常见——约占滚动量的 80%场景用户滚动的所有帧都属于同一个 30 秒视频 chunk。边界情况表现处理已加载 chunk 内 seekvideo.currentTime offset/fps10-50ms硬件加速快速滚动30 帧/秒大量currentTime赋值80ms 防抖。浏览器合并 seek只有最后一次生效seek 到完全相同的帧currentTime未变化跳过 seekdelta 0.001offset_index0 的帧currentTime 0正常——视频在起点chunk 内最后一个偏移的帧currentTime 28s0.5fps 的 30s chunk正常——在时长范围内seeked事件在绘制前触发画面尚未视觉更新用requestAnimationFrame在seeked后确认绘制视频元素尚未加载loadeddata未触发排队 seekloadeddata后再执行6.2 chunk 边界跨越场景用户从 chunk A 的最后一帧滚到 chunk B 的第一帧。边界情况表现处理下一 chunk 已预加载交换 active ↔ preloaded然后 seek100ms——交换瞬时、seek 瞬时下一 chunk 未预加载必须加载新视频文件保持旧 chunk 最后一帧hold开始加载新 chunkseeked后显示新帧。本地文件最坏 200-500ms预加载的 chunk 错了用户改变方向预加载的是 next 但用户去了 prev逐出预加载加载正确 chunk。延迟 200-500ms。更新预加载启发式两个 chunk fps 不同旧 chunk 0.5fps新 chunk 0.2fps每个video用自己的 fps。fps 随 chunk 元数据走无问题chunk 文件被删除用户清理旧数据video触发error该帧回退 ffmpeg 路径。标记 chunk 为失败chunk 之间有空隙录制短暂停止5 分钟无帧然后下一个 chunktimeline 本就处理空隙无帧可显示。下一个可滚动帧跳到新 chunk两个显示器在不同时刻跨边界显示器 1 在 :30 换 chunk显示器 2 在 :45每个显示器独立视频池。独立 chunk 跟踪6.3 跨天导航日历跳转场景用户在日历选择器点击某个日期跳到完全不同的一天。边界情况表现处理跳到昨天WebSocket 拉取昨天的帧新 file_path 到达清空视频池。加载目标天首个 chunk。加载期间显示 skeleton1s跳到 15 天前同上但需流式传输更多帧WebSocket 本就处理该情况渐进式帧流。视频从第一帧到达即开始加载跳到无数据日期hasFramesForDate返回 false向后回退最多 7 天既有行为。帧到达前视频不变跳转后立即再次跳转第一次跳转的数据仍在流式传输时发生第二次跳转中止/忽略第一次的视频加载。视频源变化时使用AbortController。导航时清空视频池向前跳再回到今天回到 live edge恢复实时轮询。加载最新 chunk跳到最早录制日期非常老的数据可能跳过数千 chunk只为选中帧加载特定 chunk。不要预加载数千 chunk6.4 Live Edge正在录制场景用户正在查看最新一帧新帧实时捕获中。边界情况表现处理同 chunk 内出现新帧轮询检测到新帧file_path 相同、offset_index 更高seek 到新偏移。Fragmented MP4 让浏览器可读新 fragment。可能需要触发video.load()拾取新数据观看时 chunk 轮转编码器完成 chunk A、开始 chunk B。新帧有新 file_path切换到 chunk B 的视频元素。Chunk A 保持有效已 finalize我们作为 active 加载后 chunk A 才 finalize加载时 A 不完整现在完整且 moov 已定稿无问题——fragmented MP4 的 moov 在开头。浏览器已有所需数据非常新的帧尚未落盘帧已捕获但 ffmpeg 尚未写入offset_index 指向文件中尚不存在的帧。浏览器 seek 会静默落到最近帧。WebSocket 只发送已写入视频之后的帧经FrameWriteTracker0.5fps 下 live edge 有 2 秒延迟用户看到 2 秒前的数据这是采集率的固有限制不是视频加载问题观看时 screenpipe 进程重启视频连接断开新 chunk 开始WebSocket 重连已处理。重连时视频池重置6.5 多显示器场景2 个及以上显示器各自产生独立视频 chunk。边界情况表现处理2 显示器、正在看显示器 1只加载显示器 1 的视频每个可见显示器一个视频池。非活动显示器不加载视频从显示器 1 切到显示器 2需要不同 file_path加载显示器 2 的 chunk。显示器 1 的视频保留在池中可能切回显示器中途断开该 monitor_id 的帧停止timeline 显示空隙。无视频可加载。既有行为3 显示器全部并排可见3 个 active 元素 3 个 preloaded 6 个6 个视频元素对浏览器无压力约 6MB 内存显示器分辨率不同显示器 13024×1964显示器 21920×1080每个video有独立 natural dimensions。OCR 覆盖层独立缩放显示器 fps 不同自适应 fps显示器 10.5fps显示器 20.2fpsfps 按 chunk 存储于元数据。每个视频池用各自的 fps seek6.6 错误处理与文件损坏边界情况表现处理视频文件损坏截断video触发error标记 chunk 失败。回退 ffmpeg 抽帧可能也失败。自动跳到下一个有效帧文件存在但为 0 字节video触发error同损坏。服务端已处理VIDEO_CORRUPTED: empty file视频文件权限被拒video触发error同损坏。记录具体错误便于调试不支持 HEVCLinux、部分 Windowsvideo立即触发error或显示绿帧首次加载视频时检测。若首次加载 500ms 内报错全局置useVideoElement false。本会话所有帧回退 ffmpegimg路径浏览器内存不足视频过多内存压力、标签页崩溃视频元素总数限制为 63 显示器 × 2。主动 revoke 旧 blob URL。用video.src 卸载未使用视频HTTP 视频端点网络错误video触发 network 类型 error500ms 后重试一次。再失败回退 ffmpeg 路径6.7 用户设置与配置边界情况表现处理自定义数据目录视频文件在非标准路径asset protocol 作用域必须包含自定义路径。用动态scope.allow_directory()或回退 HTTP 服务一天中途改 fps改前 chunk 0.5fps改后 0.2fps每个 chunk 在元数据中携带自己的 fps。seek 使用按 chunk 的 fps视频质量改变不同 chunk 不同 CRF 值对video透明。不影响 seek开启 frame_cache服务端服务端有预计算 JPEG 缓存WebSocket 路径仅元数据不用 frame_cache。/frames/{id}回退受益于缓存。无冲突自适应 FPS 开启每 chunk fps 不同同一天中途改 fps——按 chunk fps 处理6.8 UI/UX 细节边界情况表现处理chunk 加载时显示 skeleton用户看到 shimmer 动画与现有 SkeletonLoader 相同。chunk 变化立即显示seeked事件时隐藏双击缩放/全屏视频元素有原生控件设controls{false}、muted、playsInline。阻止默认双击视频上右键菜单浏览器显示 Save Video As...阻止video默认右键菜单。需要时显示应用自有菜单视频帧 vs 图像帧视觉差异视频渲染可能与 ffmpeg JPEG 略有不同接受。视频才是数据源。JPEG 本就是有损衍生物视频加载期间拖动时间线源快速变化前次加载仍在途中每次加载请求记录 generation ID。丢弃与当前 generation 不匹配的过期seeked事件帧间过渡动画现有 150ms 透明度过渡对canvas应用相同 CSS 过渡或用双缓冲做可见性交换6.9 文本选择与 URL 检测边界情况表现处理OCR 覆盖层在video而非img上覆盖层 div 定位在视频上方无改动——覆盖层基于容器百分比定位。videoWidth/videoHeight替代naturalWidth/naturalHeight跨视频帧变化选择文本用户正选中文本时帧变化帧变化时清除选择既有行为URL 检测时序OCR 数据在视频帧显示之后到达今天已如此——OCR 异步加载。无需改动7. 测试计划7.1 自动化测试测试验证点Seek 精确度video.currentTime offset/fps→seeked事件 →video.currentTime与预期误差 ±0.1sChunk 切换加载视频 A、seek 到末尾加载视频 B、seek 到开头。验证总切换 300ms错误回退提供损坏视频 → 验证 1s 内回退到 ffmpegimg路径内存管理顺序加载/卸载 50 个 chunk → 验证内存不无界增长多显示器3 个视频元素同时 seek → 无竞态7.2 手动测试场景#测试预期结果1打开 overlay向前滚动 10 帧每帧 100ms 出现。无 skeleton 闪烁2快速滚动 5 秒后停下只加载最终帧。中间帧被跳过。停止后 200ms3点击 前一天 按钮新一天的帧 1s 出现。短暂显示 skeleton4通过日历跳到 2 周前帧 1.5s 出现5滚动跨过 chunk 边界无可见间隙或闪烁6overlay 停在 live edge 5 分钟每 ~2s 出现新帧。内存不增长7断开一个显示器滚动越过该时间点优雅显示空隙。不崩溃8设置中更改数据目录后重开 overlay视频从新目录加载9在 Linux无 HEVC 支持测试自动回退 ffmpeg 路径。帧仍可加载较慢7.3 性能基准指标现状目标测量方式同 chunk seek 延迟1.5–4s100msperformance.now()记录currentTime设置到seeked事件跨 chunk seek 延迟1.5–4s500ms同上但包含新源的loadeddata事件跨天跳转延迟3–8s1s从点击日期到首帧绘制的时间每次滚动会话 spawn 的 ffmpeg 进程数~500统计服务端日志中的进程 spawn单个视频元素内存N/A5MB浏览器 DevTools 内存快照PostHogtimeline_frame_load_timeP95~3000ms200msPostHog 仪表盘8. 实施里程碑Phase 1后端数据1 天给video_chunks表加fps列迁移在save_frames_as_video创建 chunk 时写入 fps在 WebSocket 的DeviceFrameResponse中追加offset_index与fps用默认 fps 回填存量video_chunks行新增带 Range 请求支持的/video/stream端点Phase 2前端视频元素2 天创建VideoFrameDisplay组件实现videoseek实现双缓冲池active preloaded为~/.screenpipe/data/扩展 asset protocol 作用域处理seeked事件作为帧就绪信号video报错时回退 ffmpegimg路径接入既有CurrentFrameTimelinepropsPhase 3Chunk 管理1 天从 WebSocket 帧流构建 chunk 索引实现预加载逻辑相邻 chunk处理 chunk 切换交换 active ↔ preloaded内存管理逐出旧视频元素Live edge chunk 轮转处理Phase 4打磨与边界1 天基于video尺寸的 OCR 覆盖层平台检测 → Linux 的 HEVC 回退自定义数据目录支持PostHog 指标seek 延迟、chunk 加载耗时、回退率清理把 ffmpeg 抽帧移出热路径保留为回退更新 TESTING.md 中的回归测试9. 开放问题是否还要预计算缩略图条带单个 JPEG 拼图包含 chunk 内全部帧~15 帧可支持瞬时 scrubbing 预览。v1 范围外但值得考虑。video是否使用poster属性可把 ffmpeg 抽出的 JPEG 设为加载占位图视频加载完成后切换为视频 seek。这样视频加载期间也有即时可见内容。像素级操作是否走 canvas 帧捕获若需客户端文本检测、PII 脱敏或图像导出就要把video帧捕获到canvas。只是一次ctx.drawImage(video, 0, 0)但增加了复杂度。/frames/{id}端点的消费方怎么办搜索结果、AI 聊天、导出功能都用/frames/{id}获取 JPEG。这些应继续走 ffmpeg 路径——它们对延迟不敏感且需要真实图像数据。只有 timeline 实时滚动切换到video。Rust 服务器是否支持 HTTP Range 请求现有 axum 静态文件服务是否支持 Range 头若不支持需要tower-http的ServeFile带 range 支持。10. 仓库落地状态核查截至当前快照规范的多数设计已在当前仓库落地可以从源码逐一印证WebSocket 下发offset_indexfps在 crates/screenpipe-engine/src/routes/streaming.rs 中DeviceFrameResponse已包含offset_index: i64与fps: f64字段StreamTimeSeriesResponse::from与 live 帧推送路径均会带上这两个值——规范中缺失的两个字段已补齐。video_chunks.fps列与回填迁移 20260206200000_add_fps_to_video_chunks.sql 与 20260207100000_backfill_fps_defaults.sql 已就位。Asset protocol 作用域扩展tauri.conf.json 中 scope 已包含$HOME/.screenpipe/**。前端消费侧use-timeline-store、use-current-frame、use-frame-loading以及 rewind 目录下的native-timeline.tsx等前端实现均已存在参见 apps/screenpipe-app-tauri/lib/hooks/use-timeline-store.tsx 与 apps/screenpipe-app-tauri/components/rewind/hooks/use-frame-loading.ts时间线导航逻辑集中于 apps/screenpipe-app-tauri/lib/timeline-navigation.ts。编码端支撑crates/screenpipe-core/src/video.rs 的start_ffmpeg_process印证了 hvc1 标签、libx265、禁用 B 帧等关键编码前提保证currentTime offset_index / fps的 seek 数学成立。阅读 docs/TIMELINE_VIDEO_SPEC.md 原文并结合上述文件可完整还原从架构设计到代码落地的全链路。对希望复刻同类本地录制 时间线回放方案或优化既有 ffmpeg 抽帧热路径的开发者这份规范提供了可直接套用的数据模型、双缓冲策略与边界清单模板。【免费下载链接】screenpipeYC (S26) | Open Computer History | Record your screen continuously locally and provide context to your agents (Claude, Codex, Openclaw, Hermes, Runner...)项目地址: https://gitcode.com/GitHub_Trending/sc/screenpipe创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考