记录资源预览读取分支与版本时间的排查口径

- PRD 明确队列身份用的 category 是资源分区栏目、只用于前端去重与布局,不作为 IPC 入参
- PRD 明确 read_local_project_media_preview 的线上 category 是原生读取分支,只接受 art / audio,须由卡片预览类型派生
- pitfalls 新增「媒体预览 category 是读取分支而非栏目」:记录阶段二把用例一起改成错误行为导致门禁失守,并写明现有守卫位置与实测失败口径
- pitfalls 新增「versions[].createdAt 是 Unix 秒」:记录夹具与实现一起错导致门禁失真,以及已知秒值断言年月日的验收口径
This commit is contained in:
2026-09-10 21:05:30 +08:00
parent 14d1635858
commit df4168f71f
2 changed files with 19 additions and 1 deletions
@@ -62,7 +62,7 @@
- 卡片外层是非交互容器;“打开详情”与“播放 / 暂停”必须是可分别键盘聚焦的同级按钮,禁止在 `<button>` 中嵌套 `<button>`。播放键不选择资源、不打开详情。
- 同一时间最多播放一个卡片音频或视频。打开详情、切换布局模式、切换项目、进入运行视图、搜索 / 筛选隐藏当前资源、当前资源被删除或资源身份 / 路径变化时,必须暂停旧媒体并收口待播放意图;卡片卸载还必须执行防御性暂停。同资源 ID 的无关 manifest 更新不得重建控件或抢走焦点。
- 图片、视频首帧和项目文档只在卡片进入资源画布可见区及小幅预取边界后读取;音频只在用户点击播放后读取。前端逻辑调度器必须使用有界并发、同身份去重、有界 LRU 淘汰,不允许按全部投影同步启动读取。`play > detail > visible` 是固定调度优先级;可见性预取不得占满全部队列容量或静默丢弃播放 / 详情请求,主动请求进入硬上限队列时必须替换最低优先级的排队预取并优先执行。终态预览缓存同时受 `48` 项和 `64 MiB` 总载荷双重上限约束,媒体按解码后的 Blob 字节、文档按 UTF-8 字节计入;Tauri 返回的 data URL 只允许作为 IPC 临时载体,进入 React 状态前必须转换为可撤销的 Blob URL。LRU 淘汰、资源删除、项目 / mode 切换和卸载都必须 `revokeObjectURL`,不能让 base64 字符串或失效 Blob 长期占用 WebView。
- 卡片读取继续调用 `read_local_project_image_preview``read_local_project_media_preview``read_local_project_text_preview`;项目边界、manifest / 任务登记、`file.read` 策略、普通文件 / 链接、签名、尺寸和读取漂移门禁不变。每个 Hook 挂载及每次 `projectPath + projectId + mode` 变化必须生成不复用的 `scopeId`,每个实际读取生成唯一 `requestId`;每个队列任务和结果继续绑定完整的 `projectPath + projectId + mode + resourceId + category + path + mediaType`。前端 scope epoch 只隔离逻辑队列、缓存和异步回调;三个读取命令在 Tauri 进程中共用唯一的全局 `3` 槽物理读取管理器,不能因项目、mode、Hook 或窗口不同获得额外物理槽。scope 切换或卸载必须调用窄职责 `cancel_local_project_resource_preview_scope`,使等待 permit 和分块读取中的旧任务协作取消;旧 epoch 的 `then / catch / finally` 仍不得写入、释放或扣减新 scope 的前端状态,`A → B → A` 也不得复用第一轮 A 的 scope、请求或迟到结果。
- 卡片读取继续调用 `read_local_project_image_preview``read_local_project_media_preview``read_local_project_text_preview`;项目边界、manifest / 任务登记、`file.read` 策略、普通文件 / 链接、签名、尺寸和读取漂移门禁不变。每个 Hook 挂载及每次 `projectPath + projectId + mode` 变化必须生成不复用的 `scopeId`,每个实际读取生成唯一 `requestId`;每个队列任务和结果继续绑定完整的 `projectPath + projectId + mode + resourceId + category + path + mediaType`这里绑定用的 `category` 是资源分区栏目(6 类资产分类 / 项目版本),只用于前端去重、缓存与布局身份,**不直接作为 IPC 入参**;`read_local_project_media_preview` 的线上 `category` 是原生侧的**文件读取分支**,只接受 `art` / `audio`,必须由卡片预览类型派生(`projectResourceMediaPreviewCategory`),不能把分区栏目值传过去。前端 scope epoch 只隔离逻辑队列、缓存和异步回调;三个读取命令在 Tauri 进程中共用唯一的全局 `3` 槽物理读取管理器,不能因项目、mode、Hook 或窗口不同获得额外物理槽。scope 切换或卸载必须调用窄职责 `cancel_local_project_resource_preview_scope`,使等待 permit 和分块读取中的旧任务协作取消;旧 epoch 的 `then / catch / finally` 仍不得写入、释放或扣减新 scope 的前端状态,`A → B → A` 也不得复用第一轮 A 的 scope、请求或迟到结果。
- 原生读取必须在命令入口、权限 / 登记复核后、打开文件后、每个固定上限读取块之间、签名 / 图片结构校验前、漂移复核前和 base64 编码前检查取消。取消的排队任务不得打开文件,取消的在途任务不得生成 data URL 或 Blob URL;全局 permit 只能在对应原生任务结束后释放。重复 `requestId` 失败关闭,取消未知或已完成 scope 幂等成功;成功、失败和取消均必须清理活动 request / scope registry。为防止近期 request ID 重放和已取消 scope 复活,原生管理器继续保留有界 tombstoneseen request 最多 `8192` 项;非活动 cancelled scope 的保留预算为 `1024` 项,仍有请求的已取消 scope 必须临时钉住,最后一个请求结束后立即重新淘汰到预算内。明确取消只静默收口旧 scope,当前 scope 的真实 transient / permanent 失败语义不变。
- 安全读取失败、图片解码失败或视频无可解码画面时,卡片展示稳定类型占位,不挂载破图,不降级为项目外 URL 或裸路径读取。滚动可见性不得自动重试已失败预取;读取漂移、文件替换和通用暂时失败标记为 transient,用户再次打开详情或点击播放时可以显式重试。超尺寸、损坏、类型不支持和不安全 SVG 等永久失败继续缓存且不得用“关闭后重试”误导用户。
@@ -5126,3 +5126,21 @@
- 排查口径:任何"复用了共享画布子组件、但没复用 `CanvasViewport`"的宿主,都要先确认这些子组件依赖的根类由谁渲染;共享 CSS 里出现没有 fallback 的 `var(--genarrative-image-canvas-*)` 时,缺根类就是静默丢样式。
- 验证:AGC 侧用真实 `pointerdown` / `pointermove` 造框选,断言选择框存在、内联几何非空,且最近的 `.genarrative-image-canvas` 祖先就是画本场景根;把根类去掉后该用例会失败(已实测)。
- 关联:`packages/image-canvas-react/src/styles.css``packages/image-canvas-react/src/SelectionOverlay.tsx``packages/image-canvas-react/src/CanvasViewport.tsx``apps/ai-game-creator-shell/src/view/project-development/index.tsx`
## AGC 卡片媒体预览的线上 `category` 是读取分支,不是画布分区栏目(2026-09-10)
- 现象:真机资源画布上大量卡片只显示占位方框图标,没有真实缩略图;门禁全绿。
- 原因:`read_local_project_media_preview` 的入参 `category` 在原生侧只认 `art` / `audio``apps/ai-game-creator-shell/src-tauri/src/commands.rs``read_local_project_media_preview_at``"art" => Art` / `"audio" => Audio` / `_ => Err("媒体预览类别只支持 art 或 audio")`)。阶段二(`2f8753f59`)把 `ProjectResource.category` 从旧的资源类型轴(`art` / `audio` / `code` / `document`)换成 6 类资产分类栏目轴(`ui-interaction` / `character` / `scene` / `audio` / `document` / `unclassified` / `version`),而调用点仍然透传 `job.resource.category`,于是栏目值被原生侧直接拒绝,所有走媒体分支的卡片(GIF / SVG / AVIF / BMP / MP4 / WebM / MOV 与音频)全部读不出预览。PNG / JPEG / WEBP 走 `read_local_project_image_preview`,该命令没有 `category` 入参,所以照常加载——这就是"有些图正常、有些图全不加载"的分裂观感。
- 处理:新增 `projectResourceMediaPreviewCategory(resource)`,从卡片预览类型(`projectResourceCardPreviewKind`)派生线上取值,与分区栏目轴解耦;调用点不再传栏目。
- 门禁为什么抓不到:阶段二把用例一起改成了错误行为——`tests/appSurface/project-development.suite.ts` 的 mock 注释直接写成"媒体读取的 `category` 现在传资源所在的功能栏目"并按 `relativePath` 返回,`tests/useProjectResourceCardPreviews.test.ts` 把断言从 `category: 'art'` 改成 `category: 'unclassified'`。**用例断言的是自己刚写的行为,不再对齐原生契约**,所以改坏也不会有红灯。
- 验收口径:断言必须钉住原生侧真实取值。现有守卫在 `tests/useProjectResourceCardPreviews.test.ts`(扩展名图片必须发 `category: 'art'`、音频必须在播放意图后发 `category: 'audio'`)与 `tests/appSurface/project-development.suite.ts``icon.svg` 必须发 `category: 'art'`,即便它落在「UI 交互」栏目)。把调用点改回 `job.resource.category` 时这两条会失败(已实测)。
- 关联:`apps/ai-game-creator-shell/src/view/project-development/resourceCardPreviewModel.ts``useProjectResourceCardPreviews.ts``apps/ai-game-creator-shell/src-tauri/src/commands.rs`
## AGC `versions[].createdAt` 是 Unix 秒,渲染前必须 ×10002026-09-10
- 现象:游戏版本选择器里所有版本都显示 `1970/1/22 00:41:17`
- 原因:写入侧 `apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs``created_at: unix_timestamp()`(两处:`ensure_initial_game_iteration_version_at``append_agent_game_iteration_version_at`),而 `unix_timestamp()``SystemTime::duration_since(UNIX_EPOCH).as_secs()` —— 单位是**秒**。显示侧 `formatIterationVersionLabel` 却写成 `new Date(version.createdAt)`,秒值被当成毫秒,`1788075047``1970/1/22 00:41:15`
- 处理:`new Date(version.createdAt * 1000)`,与既有秒口径(`ResourceAssetDeleteDialog.tsx`、待确认资源编辑创建时间标签)保持一致;并给 `GameIterationVersion.createdAt` 补契约注释写明单位。
- 门禁为什么抓不到:单测夹具本身就是毫秒值(`createdAt: 1_760_000_000_000`),断言用 `new Date(夹具值)` 反推期望,**夹具和实现一起错**,用例自洽却失真。
- 验收口径:夹具必须与落盘口径一致(真实秒值),并用已知秒值断言渲染出的年月日(`1788075047``2026/8/30`),而不是只断言"不等于 1970"。改成 `new Date(createdAt)` 时该用例会失败(已实测)。
- 关联:`apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasVersionBindingModel.ts``packages/shared/src/contracts/gameCreationApp.ts``apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs`