Files
infinite-canvas/docs/canvas-data-structure.md
T
HouYunFei 472bf8b732 first commit
Co-Authored-By: Codex <noreply@openai.com>
2026-05-19 19:50:36 +08:00

9.7 KiB
Raw Blame History

画布数据结构

本文档说明当前画布在前端本地保存的数据结构、图片文件的存储和清理方式,以及后续接入后端存储时建议保持的兼容边界。

当前存储位置

当前画布项目主要保存在浏览器本地:

  • 画布项目 JSONlocalForage,数据库名 infinite-canvasstoreName app_statekey 为 infinite-canvas:canvas_store
  • 我的素材 JSONlocalForage,数据库名 infinite-canvasstoreName app_statekey 为 infinite-canvas:asset_store
  • 图片 Blob:单独存到 localForage 实例,数据库名 infinite-canvasstoreName image_files

画布 JSON 不直接长期保存大体积 base64 图片。图片节点、助手图片和素材图片只保存展示 URL、storageKey 和图片元信息,真实图片 Blob 通过 storageKey 读取。

画布项目结构

每个画布项目是一个 CanvasProject

type CanvasProject = {
  id: string;
  title: string;
  createdAt: string;
  updatedAt: string;
  nodes: CanvasNodeData[];
  connections: CanvasConnection[];
  chatSessions: CanvasAssistantSession[];
  activeChatId: string | null;
  backgroundMode: "lines" | "dots" | "blank";
  viewport: { x: number; y: number; k: number };
};

字段说明:

  • id:画布项目 ID,当前前端生成。
  • title:画布名称。
  • createdAt / updatedAtISO 字符串。
  • nodes:画布节点列表。
  • connections:节点连线列表。
  • chatSessions:右侧画布助手会话。
  • activeChatId:当前选中的助手会话 ID。
  • backgroundMode:画布背景模式。
  • viewport:视口变换,x/y 是屏幕平移,k 是缩放比例。

节点结构

每个节点是一个 CanvasNodeData

type CanvasNodeData = {
  id: string;
  type: "image" | "text" | "config";
  title: string;
  position: { x: number; y: number };
  width: number;
  height: number;
  metadata?: CanvasNodeMetadata;
};

通用字段:

  • id:节点 ID。
  • type:节点类型,当前有图片、文本、生成配置三类。
  • title:节点标题。
  • position:画布世界坐标,不是屏幕坐标。
  • width / height:画布世界坐标下的节点尺寸。
  • metadata:节点内容和业务状态。

metadata 当前常用字段:

type CanvasNodeMetadata = {
  content?: string;
  prompt?: string;
  status?: "idle" | "success" | "loading" | "error";
  errorDetails?: string;
  fontSize?: number;
  generationMode?: "text" | "image";
  model?: string;
  size?: string;
  count?: number;
  naturalWidth?: number;
  naturalHeight?: number;
  freeResize?: boolean;
  isBatchRoot?: boolean;
  batchRootId?: string;
  batchChildIds?: string[];
  primaryImageId?: string;
  imageBatchExpanded?: boolean;
  inputOrder?: string[];
  storageKey?: string;
  mimeType?: string;
  bytes?: number;
};

不同节点的使用方式:

  • 图片节点:content 是当前可展示的图片 URL,通常是 blob: URLstorageKey 指向本地图片 BlobnaturalWidth/naturalHeight/bytes/mimeType 保存原图信息。
  • 文本节点:content 保存文本内容;fontSize 保存字体大小;prompt/status/errorDetails 保存生成状态。
  • 生成配置节点:generationMode/model/size/count/inputOrder 保存生成配置;上游输入通过 connections 计算。
  • 图片组节点:根节点用 isBatchRoot/batchChildIds/primaryImageId/imageBatchExpanded 记录批量生成结果;子图节点用 batchRootId 指回根节点。

连线结构

每条连线是一个 CanvasConnection

type CanvasConnection = {
  id: string;
  fromNodeId: string;
  toNodeId: string;
};

连线只保存节点 ID,不保存端口坐标。渲染时根据节点位置和尺寸计算路径。

删除节点时会同步删除以该节点为起点或终点的连线。删除图片组根节点时,会把对应子节点一起删除。

助手会话结构

助手会话保存在画布项目内:

type CanvasAssistantSession = {
  id: string;
  title: string;
  messages: CanvasAssistantMessage[];
  createdAt: string;
  updatedAt: string;
};

消息结构:

type CanvasAssistantMessage = {
  id: string;
  role: "user" | "assistant";
  mode: "ask" | "image";
  text: string;
  isLoading?: boolean;
  references?: CanvasAssistantReference[];
  images?: CanvasAssistantImage[];
};

图片引用和助手生成图片也遵循同一套图片存储规则:

  • dataUrl 字段当前可能是 blob: URL,也可能是旧数据中的 data:image/...
  • storageKey 存在时,以 storageKey 为准读取图片 Blob。
  • 发送到 AI 接口前,如果接口需要 base64,会通过 imageToDataUrl 临时把 Blob URL 转成 data URL。

图片写入流程

所有新增图片应通过 uploadImage(input) 写入:

  1. 传入 Blob 或 data URL。
  2. 内部转成 Blob
  3. 生成 storageKey,格式为 image:<id>
  4. 把 Blob 写入 image_files
  5. 创建 blob: URL,并缓存在内存 objectUrls
  6. 读取图片宽高,返回:
type UploadedImage = {
  url: string;
  storageKey: string;
  width: number;
  height: number;
  bytes: number;
  mimeType: string;
};

图片节点会通过 imageMetadata(image) 写入:

{
  content: image.url,
  storageKey: image.storageKey,
  status: "success",
  naturalWidth: image.width,
  naturalHeight: image.height,
  bytes: image.bytes,
  mimeType: image.mimeType
}

因此,content 只适合当前浏览器会话展示,不能作为长期文件标识;长期标识是 storageKey

图片读取和旧数据迁移

打开画布时会执行图片补水:

  • 如果图片节点有 storageKey,通过 resolveImageUrl(storageKey, fallback) 读取 Blob 并生成新的 blob: URL。
  • 如果图片节点没有 storageKey,但 content 是旧的 data:image/...,会调用 uploadImage(content) 迁移到 image_files,并补上 storageKey
  • 助手消息里的引用图和生成图也会执行同类逻辑。

我的素材读取时也会做迁移:

  • storageKey:恢复 coverUrldata.dataUrl 的可展示 URL。
  • storageKey 且保存了 base64:写入 image_files,然后更新素材里的 storageKey

图片移除和清理

图片不是在删除节点时立即按节点逐张删除,而是做引用清理:

  1. 删除节点、清空画布、删除画布、删除素材、删除助手会话时,会触发 cleanupImages
  2. cleanupImages 会收集当前仍被画布项目、素材和额外传入数据引用的所有 storageKey
  3. cleanupUnusedImages 遍历 image_files 中的全部图片。
  4. 不在引用集合里的图片会被删除。
  5. 删除时会同时 URL.revokeObjectURL,并从内存缓存 objectUrls 移除。

这套方式可以避免同一张图片被画布、素材或助手同时引用时误删。

需要注意:

  • 只要某个 JSON 结构里仍有 storageKey,清理逻辑就会认为图片仍被使用。
  • collectImageStorageKeys 会递归扫描对象中的 storageKey 字段,字段值必须以 image: 开头才会被当成本地图片。
  • 如果后续新增保存图片引用的数据结构,也要确保它能传入清理上下文,或者位于现有项目/素材结构内。

后端存储兼容建议

后续接入后端时,建议保持“画布 JSON”和“图片文件”分离:

  • 画布表保存项目元信息和画布 JSON。
  • 文件表保存图片文件、访问 URL、哈希、大小、MIME、宽高、归属用户等信息。
  • 画布节点中继续保存轻量图片引用,不把图片二进制或 base64 写进画布 JSON。

建议图片引用逐步扩展为兼容本地和云端的结构:

type ImageRef = {
  storageKey?: string;
  fileId?: string;
  url?: string;
  width?: number;
  height?: number;
  bytes?: number;
  mimeType?: string;
};

兼容规则:

  • 本地旧数据:有 storageKey,无 fileId,通过 IndexedDB 读取。
  • 已上传后端:有 fileId,展示时优先使用后端返回的签名 URL 或公开 URL。
  • 迁移过渡期:可以同时保留 storageKeyfileId;确认云端文件可用后,再按清理策略删除本地 Blob。
  • content/dataUrl/coverUrl 仍只作为当前可展示 URL,不作为稳定 ID。

建议读取优先级:

  1. fileId:向后端换取可访问 URL。
  2. storageKey:从本地 IndexedDB 生成 blob: URL。
  3. 有旧 data:image/...:先写入本地图片存储,再视需要上传后端。
  4. 只有普通 URL:直接展示,但不要假设可长期访问。

建议删除策略:

  • 删除节点只删除画布 JSON 引用,不直接删除后端文件。
  • 后端文件删除应按引用计数或定期扫描未引用文件处理。
  • 保存到“我的素材”的图片,即使原画布节点删除,也应继续保留文件引用。
  • 删除画布、删除素材、删除助手会话后,再由后端清理任务判断文件是否无人引用。

建议同步流程:

  1. 前端保存画布 JSON 时,保持节点 ID、连线 ID、storageKey/fileId 不变。
  2. 遇到只有 storageKey 的图片,后台同步前先上传 Blob,得到 fileId
  3. 上传成功后给对应图片引用补 fileId 和云端元信息。
  4. 服务端保存更新后的画布 JSON。
  5. 前端下次打开时优先走 fileId,本地 storageKey 只作为缓存或离线回退。

后续改动约束

  • 不要把新生成的大图直接长期写入画布 JSON。
  • 新增图片来源时统一走 uploadImage 或未来的文件上传服务。
  • 新增图片引用字段时,应保留 storageKey 兼容旧本地数据。
  • 新增清理入口时,要把仍需保留的画布、素材、助手数据传给 cleanupUnusedImages
  • 后端同步完成前,文档和 UI 不要写成已支持云同步。