Files
ttstd-infinite-canvas/docs/design/canvas-final-performance-architecture.md

16 KiB
Raw Permalink Blame History

画布性能与媒体分发最终改造方案

状态:基于当前 main(7bf655e9)和现有 OSS/CDN 重构重新整理。本文是当前仓库的最终目标和一次性改造边界,不把已经存在的能力重复当成待建设,也不把关键问题推到二期、三期。

1. 最终结论

当前仓库已经完成了画布优化的第一代基础设施:

  • 资源访问已经收敛到 assets.ResolveAccess 和 POST /api/resources/access。
  • 画布已经有 canvas-live-viewport 的合成层预览、Leafer underlay/overlay 图形层、空间索引、节点拖拽预览和选择预览。
  • 图片、视频和音频的多数展示路径已经使用 storageKey 解析访问地址。
  • 云资源 /file 路径已经能够重定向到 CDN/源站,平台不必转发云端媒体正文。

因此,最终改造方向不是重写成 WebGL,也不是复制 LibTV 的组件,而是把当前已经存在的能力合并成一个闭环:

稳定资源身份
  -> 统一访问描述
  -> poster-first 媒体状态机
  -> 展示与字节读取分离
  -> 交互预览与持久化提交分离
  -> 可见节点 / 轻量节点 / 完整节点三层 LOD
  -> 同一帧节点、连接、对齐线更新
  -> 以设备帧预算驱动调度和验收

用户手势帧永远不承担以下工作:整视频 Blob 下载、视频首帧编码上传、批量 React 复杂节点挂载、全量连线重算、逐 pointermove 持久化和签名地址同步写入画布。

2. 外部方案需要修正的地方

外部方案仍然正确的核心原则是视频默认图片化、旧画面不清空、交互期间不做重活、屏内节点不能被屏外预算截断,以及节点与连线使用同一帧预览。

以下内容不能直接照搬:

  1. OSS snapshot 尚未成为当前仓库的统一首帧供应链。 当前 web/src/services/canvas-video-preview.ts 仍在缺少持久 poster 时使用浏览器截帧并后台上传;这只能是历史资源的降级路径,不能继续作为新资源的正常路径。
  2. 当前没有完整的 SelfVirtualizingNode 和渐进挂载队列。 use-canvas-render-model.ts 主要负责空间查询和 DOM 节点挂载窗口,固定使用 280/420/720 节点预算;这不是完整 LOD,也不能宣称支持任意万级画布。
  3. Leafer 目前主要承载连接、选框、对齐线和草稿线。 节点正文仍是 DOM。canvas-live-viewport.ts 已解决交互期合成变换,但不能替代节点 LOD 和挂载调度。
  4. 小地图仍然为所有节点创建 DOM 和标签。 只有图片预览数量限制为 24,节点和标签没有虚拟化;节点规模增大时,小地图本身会变成主线程负担。
  5. 当前播放后整文件缓存会制造重复流量。 resource-blob-cache.ts 曾由播放器播放后延迟 4 秒拉取完整 Blob;播放 Range 和完整下载可能并存,且缓存预算最高可达 2 GiB。这不属于普通展示行为。
  6. provider-input 的浏览器边界与文档不一致。 当前登录用户 /resources/access 直接以 allowProvider=true 处理该用途,前端可以申请长 TTL 模型输入地址。最终合同必须把模型输入迁移到服务端受控读取,浏览器公开 batch 只接受展示、复制、下载和浏览器字节处理。
  7. 固定数字不是性能承诺。 0.23/0.27、256px、30+4、280/420/720、5000 条连接、DPR 上限 3 都只能是初始参数。最终参数必须由真实设备和帧预算测量得到。

3. 最终分层

3.1 持久数据层

画布 JSON 只保存稳定身份和业务元数据:

type CanvasMediaRef = {
  kind: "image" | "video" | "audio";
  resourceId?: string;
  storageKey?: string;       // resource:<id>
  originalUrl?: string;      // 外部资源历史兼容字段
  previewContent?: string;   // 已经持久化的静态预览
  videoPreview?: {
    storageKey?: string;
    content?: string;
    width?: number;
    height?: number;
    mimeType?: string;
  };
  naturalWidth?: number;
  naturalHeight?: number;
  mimeType?: string;
};

临时签名 URL、blob: URL、当前播放器实例、缓存 lease 和访问描述不得写入画布 JSON、任务结果、localStorage 或日志。

上传、生成、导入三条路径必须在创建节点时统一写入同一 resource:<id>。只有外部直链未转存时才保留外部 URL,并明确标记为外部资源。

3.2 访问描述层

assets.ResolveAccess 是唯一分发策略入口。浏览器端的 media-access 只负责:

  • 按 scope、资源、用途、变体合并请求。
  • 使用服务端返回的 expiresAt、refreshAt 和 revision。
  • 账号切换时取消在途请求并清理旧描述。
  • 只把描述交给 <img>、<video>、原生下载或明确的字节读取器。

服务端用途矩阵固定为:

用途 浏览器公开 batch 服务端内部 变体
display 允许 允许 original/playback
copy 允许 允许 original
download 允许 允许 original
browser-process 允许,必须满足 CORS/Range 允许 original/playback
provider-input 禁止 仅模型请求组合根 original

模型输入由后端在真正发出上游请求前解析,前端只提交稳定资源身份或引用绑定。不得让浏览器先申请 4 小时地址再把它当作普通页面能力。

ResourceAccess 的缓存键至少包含:

userScope + resourceId + purpose + requestedVariant + serverRevision

响应返回后必须再次校验发起时的 scope 和 access generation;账号切换后旧请求即使晚到,也不能写入新账号缓存。

3.3 媒体层

视频节点采用 poster-first 状态机:

no-poster -> requesting-poster -> poster-visible
poster-visible -> activating -> player-created
player-created -> metadata-ready -> first-frame-ready -> player-visible
任何失败 -> poster-visible / 可重试状态

硬规则:

  • 未选中视频不挂载 <video>。
  • 激活时 poster 保持在底层,播放器真实出帧后才淡入。
  • 播放 URL 不先置空,不能制造黑屏窗口。
  • canplay 或 requestVideoFrameCallback 之前播放器保持透明。
  • 播放失败、签名过期和 CORS 失败都回到 poster,并给出可见重试。
  • 普通播放不调用完整 Blob 缓存。Blob 只服务裁剪、像素读取、导出、识别和明确离线场景。
  • 播放器池只保留少量热实例,按设备内存和可见性淘汰。

3.4 首帧供应链

新上传和生成完成的视频必须优先得到服务端可复用的静态首帧:

  1. OSS 资源使用对应对象存储的 video/snapshot 或同等处理能力生成首帧访问描述。
  2. 处理参数固定由服务端白名单生成,宽度按 400/800 等档位选择,不接受任意客户端处理串。
  3. 签名 URL 只在内存中使用,不写回画布。
  4. 高频资源可以异步物化为独立 poster 对象,但不放进上传同步关键路径。
  5. 历史没有 poster 的资源才使用浏览器截帧兜底,且该路径必须限流、可取消、可观测。

首帧接口需要验证资源归属、资源类型、ready 状态、实际存储位置和访问用途。非支持 OSS 资源返回结构化 supported=false,不能伪造一个视频 URL 当作图片。

3.5 画布渲染层

画布分成四个运行时状态:

committedViewport       持久化视口
liveViewport            手势期间实时视口
committedNodes          持久化节点状态
mediaRuntime            poster/player/cache 运行时状态

平移、缩放、小地图拖动和节点拖动期间只更新 live 状态、CSS 合成层和 Leafer preview;手势结束后一次性提交 React/store/服务端状态。媒体运行时状态不能因为 viewport 每帧变化而重置。

节点渲染采用三层 LOD:

层级 条件 内容
shell 远景、屏外 retain 区、交互资源不足 尺寸、标题、连接入口、色块或低清 poster
preview entry margin、可见但非焦点 poster/图片、最少状态、静态文字
full 屏内焦点、选中、拖动、连接、编辑 完整业务组件和交互控件

进入和退出完整层必须有迟滞;选中、拖动、连接和编辑是强制升级条件。屏内节点和 entry margin 节点不能被总预算裁掉,预算只用于更远的 shell 和完整层数量。

渐进挂载调度器以帧预算为准:

  • 每帧先测量 performance.now() 消耗。
  • 剩余预算足够时才挂载下一个完整节点。
  • pointerdown、wheel、拖动、框选期间暂停后台升级。
  • 优先级顺序为当前交互节点、屏内节点、entry margin、按距离排序的 shell。
  • 连续超预算时自动降低媒体效果和完整层并发,而不是硬编码一个全球固定数量。

3.6 连接和图形层

Leafer 保留为连接、选框、对齐线和草稿线的图形层。连接路径按连接 ID 缓存,并维护节点到连接的反向索引。

拖动的一帧必须按以下顺序完成:

pointermove
  -> 计算 dragOffset
  -> DOM 节点临时 translate
  -> Leafer 只更新受影响连接
  -> 更新选择框和对齐线
  -> 同一 RAF 提交图形脏区

只有一端移动时重算曲线,两端一起移动时可整体平移旧路径。屏内连接不能因为屏外连接预算截断。拖动期间暂停流光动画,避免动画和路径重算争抢帧预算。

3.7 小地图

小地图改为单一 Canvas/Leafer 绘制:

  • 节点按颜色和尺寸画矩形。
  • 只对当前焦点或极少数指定节点画 poster。
  • 不创建全量标题 DOM。
  • 世界范围使用空间索引或增量 bounds,不在每次 pointermove 全量计算。
  • 视口框拖动保留抓取偏移;框内拖动不跳到鼠标中心。
  • 小地图拖动持续发布 live viewport,render window 同步跟随。

4. 缓存最终规则

缓存分成四类,不能互相替代:

  1. 请求合并:同一 scope、资源、用途、变体、档位只允许一个在途请求。
  2. 访问描述缓存:只缓存短期签名描述,按服务端 refreshAt 和 revision 失效。
  3. 展示对象缓存:交给浏览器/CDN;普通展示不转 Blob。
  4. 字节缓存:仅给明确 Blob 消费者,按 scope、实际变体、revision 隔离,有单文件上限、总量上限、LRU 和可取消下载。

账号切换必须同时:

  • 清理 access descriptor、in-flight descriptor 和 resource metadata cache。
  • 撤销展示用途产生的 Object URL。
  • 取消或使旧 Blob 下载代际失效。
  • 清理播放器池和媒体 lease。
  • 响应提交前核对 scope/generation。

Service Worker 只允许缓存公开、无 token、无签名敏感参数的缩略图。私有 OSS、原始视频、鉴权 API 和包含用户 token 的 URL 不进入公开 Service Worker。

5. 后端 OSS/CDN 合同

资源访问顺序固定为:

业务授权
  -> ready 校验
  -> 变体解析
  -> 实际存储位置解析
  -> 用途能力与保护校验
  -> CDN/源站签名或明确平台流计划

当前仓库必须保持以下边界:

  • CDN 是分发能力,不等于 OSS 厂商名称。
  • 历史资源按资源绑定的 provider、endpoint、bucket 和 storage setting 读取,不能把所有历史对象拼到当前 CDN 域名。
  • download 使用对象存储响应的 Content-Disposition,不能因为改文件名而把媒体正文拉回平台。
  • local/proxy 正文流必须带原因、字节量、Range 和失败指标。
  • /api/resources/access 不得成为浏览器获取长效 provider-input 凭据的入口。
  • revision 必须包含资源实际版本、播放副本状态和分发配置版本;配置变化后旧描述不能继续被前端长期使用。
  • CORS、GET/HEAD、Range、206、416、签名过期、续期和 CDN 命中必须通过真实链路验证,不能用本地单测替代。

6. 一次性改造清单

这次改造以一个完整发布批次交付,下面是同一批次内的工作流,不是功能延期:

  1. 访问合同收口:公开 batch 拒绝 provider-input;模型输入迁移到后端组合根;访问描述缓存增加 scope/generation/revision 失效;补全账号切换清理。
  2. 展示与字节读取分离:移除播放后自动整 Blob 下载;保留 Blob 只给裁剪、导出、识别等真实字节消费者;补取消、去重、LRU 和单文件上限。
  3. poster-first 完整化:新资源首帧走 OSS/CDN snapshot;浏览器截帧只做历史/不支持资源兜底;播放器保留 poster 到真实首帧。
  4. LOD 和渐进挂载:在现有 useCanvasRenderModel 上增加 shell/preview/full 三层模型、迟滞和帧预算队列;当前固定节点预算降为初始策略,不作为产品承诺。
  5. 小地图换成图形绘制:去掉全量节点标签 DOM 和图片遍历,保留色块、焦点预览和视口框。
  6. 连接增量更新:保留现有 Leafer 路径缓存和拖动预览,补齐连接空间索引裁剪、屏内连接强制保留和动画暂停。
  7. 运行时观测:记录手势帧 P50/P95/max、50ms 长任务、可见/挂载节点、完整层数量、播放器数量、poster 请求、Blob 字节、平台正文 200/206 字节、CDN/origin 命中和降级原因。
  8. 全入口清理:静态扫描并删除页面自行拼接签名 URL、旧 direct/proxy 开关、展示路径隐式 Blob、重复的 provider 判断和旧直链 helper。

7. 验收门槛

画布交互

  • 5%、10%、25% 缩放下四向连续平移,屏内节点不成片消失。
  • 快速跳远、缩放和小地图持续拖动不出现成片黑边;新区域在手势进行中进入 render window。
  • 单节点、多节点拖动时节点、连接端点、选择框、对齐线同一帧移动。
  • pointercancel、Esc、失焦不会写入错误位置,也不会触发点击或工具栏。
  • 连续框选不激活播放器、不显示单节点工具栏、不批量升级完整节点。

媒体

  • 未选中视频 DOM 中 <video> 数量为 0。
  • 选中视频在播放器真实出帧前 poster 持续可见。
  • 播放失败、断网、签名过期和 CORS 失败不会黑屏。
  • 普通播放不会触发整文件 Blob 下载;播放 Range 不与后台整文件下载重复。
  • 相同资源/用途/变体的并发访问描述请求为 1。
  • 账号切换后旧账号 URL、Blob、播放器和 in-flight 结果不会进入新账号。

OSS/CDN

  • CDN、源站、local、明确批准的 proxy、历史资源位置均有合同测试。
  • 真实浏览器验证 CORS、Range、206、416、签名过期、续期、暂停后继续和下载文件名。
  • 平台只对 local/approved-proxy 发送媒体正文;每次正文都有结构化原因。
  • provider-input 不再由登录浏览器 batch 直接获取长效 URL。

规模与设备

  • 至少覆盖 100、300、1000、3000 节点和 100、900、2000、5000 条连接。
  • 覆盖 DPR 1/2、390px 窄屏、桌面、低端设备模拟、冷缓存和热缓存。
  • 记录 P50、P95、最大帧间隔和长任务,不能只看平均 FPS。
  • 所有固定数字都必须回填到设备/帧预算实测结果,不把某次机器上的 60fps 结果写成普遍承诺。

8. 风险控制

  • 旧资源没有 poster 时允许降级为占位卡和可取消浏览器截帧,但不得挂载常驻被动播放器。
  • OSS/CDN 配置无效时明确返回结构化错误,不把故障静默转成平台代理。
  • 图形层异常时保留 DOM 节点和静态连接;LOD 调度异常时退回 shell/preview,不在手势帧内批量升级。
  • 资源清理继续执行画布、项目、任务、素材和历史快照引用检查;性能改造不改变删除安全边界。
  • 不以新增 Service Worker、will-change、WebGL 或 OffscreenCanvas 掩盖访问合同和渲染调度问题。