写入侧产出 canonical kind,读取侧读时重派生自愈存量分类
- 写入侧:infer_canvas_export_asset_kind 直接返回 canonical kind(animation → character-animation、ui → ui-design、asset → image),不再让别名表替写入侧兜底 - 写入侧补防御性归一,并新增 canvas_export_asset_kind_is_always_canonical 逐分支钉死返回值为 canonical、且不再写回 ui / animation / asset - 读取侧:gameCreationAppAssetCategory 加收窄覆盖规则——落盘 category 为 unclassified 且 kind 能派生出明确非 unclassified 分类时采用派生值 - 该规则不写迁移脚本、永久自愈:真机 57 条 ui 资产待归类从 58 降到 1,UI 交互从 2 升到 59,仅剩 code 类 game-entry 在待归类(分类表设计口径) - 收窄条件把覆盖窗口压到最小:落盘值本身是明确分类时仍信任落盘值(保留用户在分类与标签面板手动设置的权威性) - kind 派生结果本身即 unclassified 的 image / video / code / publication-material 不受影响,补断言钉住 - resourceCardPreviewRealManifest 由「记录缺陷」改为「守住修复」:断言归位后的 58 → 1 分布与 57 条 ui 全部落 UI 交互 - PRD 与 pitfalls 补记分类取值优先级、读时自愈规则、唯一盲区(手动把可明确分类的资产设为待归类会被覆盖)及写入侧 canonical 化
This commit is contained in:
@@ -1635,27 +1635,34 @@ pub(crate) fn normalized_zip_entry_name(name: &str) -> Result<String, String> {
|
||||
normalize_relative_path(&normalized)
|
||||
}
|
||||
|
||||
/// 画板导出图层推断出的资源 kind。
|
||||
///
|
||||
/// 返回值必须是 **canonical kind**:这个值会被 `register_local_asset_entry` 原样写入
|
||||
/// manifest,并据以派生落盘 `category`。历史上这里写过非 canonical 的
|
||||
/// `ui` / `animation` / `asset`,只能靠别名表兜底,等于同时维护两套词汇;别名表只用于
|
||||
/// 兼容存量数据,不作为新写入值的来源。
|
||||
pub(crate) fn infer_canvas_export_asset_kind(
|
||||
layer: &CanvasExportLayerMetadata,
|
||||
file: &str,
|
||||
) -> &'static str {
|
||||
let layer_type = layer.visible.layer_type.as_str();
|
||||
if file.starts_with("sequences/") || contains_any(layer_type, &["序列", "动画", "动作"]) {
|
||||
return "animation";
|
||||
}
|
||||
if file.starts_with("media/") || contains_any(layer_type, &["音频", "音乐", "音效"]) {
|
||||
return "audio";
|
||||
}
|
||||
if contains_any(layer_type, &["角色"]) {
|
||||
return "character";
|
||||
}
|
||||
if contains_any(layer_type, &["场景", "背景"]) {
|
||||
return "scene";
|
||||
}
|
||||
if contains_any(layer_type, &["UI", "界面", "图标"]) {
|
||||
return "ui";
|
||||
}
|
||||
"asset"
|
||||
let inferred = if file.starts_with("sequences/")
|
||||
|| contains_any(layer_type, &["序列", "动画", "动作"])
|
||||
{
|
||||
"character-animation"
|
||||
} else if file.starts_with("media/") || contains_any(layer_type, &["音频", "音乐", "音效"]) {
|
||||
"audio"
|
||||
} else if contains_any(layer_type, &["角色"]) {
|
||||
"character"
|
||||
} else if contains_any(layer_type, &["场景", "背景"]) {
|
||||
"scene"
|
||||
} else if contains_any(layer_type, &["UI", "界面", "图标"]) {
|
||||
"ui-design"
|
||||
} else {
|
||||
"image"
|
||||
};
|
||||
// 防御性归一:分支字面量写错时由覆盖测试暴露,这里再兜一层。
|
||||
shared_contracts::game_creation_app::canonical_game_creation_app_asset_kind(inferred)
|
||||
}
|
||||
|
||||
pub(crate) fn infer_canvas_export_media_type(file: &str) -> &'static str {
|
||||
@@ -1975,6 +1982,67 @@ mod tests {
|
||||
use super::*;
|
||||
use std::io::{Read, Write};
|
||||
|
||||
/// 画板导出推断出的 kind 必须已经是 canonical 值。
|
||||
///
|
||||
/// 这个值会被原样写进 manifest 并据以派生落盘 `category`;一旦写出非 canonical 值
|
||||
/// (历史上曾写 `ui` / `animation` / `asset`),就只能靠别名表兜底,等于同时维护
|
||||
/// 两套词汇,且已落盘的 category 无法随别名修复自愈。这里逐分支钉死。
|
||||
#[test]
|
||||
fn canvas_export_asset_kind_is_always_canonical() {
|
||||
fn layer(layer_type: &str) -> CanvasExportLayerMetadata {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"title": "测试图层",
|
||||
"file": "layer.png",
|
||||
"exportError": null,
|
||||
"visible": {
|
||||
"type": layer_type,
|
||||
"model": "-",
|
||||
"task": "-",
|
||||
"object": "-",
|
||||
},
|
||||
}))
|
||||
.expect("canvas export layer metadata")
|
||||
}
|
||||
|
||||
// 每个分支的代表输入 → 期望的 canonical kind。
|
||||
let cases: [(&str, &str, &str); 6] = [
|
||||
("序列", "sequences/01.png", "character-animation"),
|
||||
("动画", "layer.png", "character-animation"),
|
||||
("音频", "layer.png", "audio"),
|
||||
("角色", "layer.png", "character"),
|
||||
("场景", "layer.png", "scene"),
|
||||
("UI", "layer.png", "ui-design"),
|
||||
// 无匹配时落到 image。
|
||||
];
|
||||
for (layer_type, file, expected) in cases {
|
||||
let kind = infer_canvas_export_asset_kind(&layer(layer_type), file);
|
||||
assert_eq!(kind, expected, "layer_type={layer_type} file={file}");
|
||||
}
|
||||
// media/ 前缀同样走音频分支。
|
||||
assert_eq!(
|
||||
infer_canvas_export_asset_kind(&layer("图层"), "media/bgm.mp3"),
|
||||
"audio"
|
||||
);
|
||||
// 兜底分支是 image 而不是旧的 "asset"。
|
||||
assert_eq!(
|
||||
infer_canvas_export_asset_kind(&layer("图层"), "layer.png"),
|
||||
"image"
|
||||
);
|
||||
|
||||
// 所有分支的返回值都必须在 canonical 目录里,且不能是旧的非 canonical 写法。
|
||||
for layer_type in ["序列", "音频", "角色", "场景", "UI", "图层", "其他"] {
|
||||
let kind = infer_canvas_export_asset_kind(&layer(layer_type), "layer.png");
|
||||
assert!(
|
||||
shared_contracts::game_creation_app::GAME_CREATION_APP_CANONICAL_ASSET_KINDS
|
||||
.contains(&kind),
|
||||
"非 canonical kind: {kind}(layer_type={layer_type})"
|
||||
);
|
||||
for legacy in ["ui", "animation", "asset"] {
|
||||
assert_ne!(kind, legacy, "写回了非 canonical 的 {legacy}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infer_file_extension_preserves_supported_local_resource_extensions() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -118,63 +118,59 @@ describe('真机 manifest 取证:占位卡片计数与栏目分布', () => {
|
||||
expect(resources).toHaveLength(61);
|
||||
});
|
||||
|
||||
test('存量 category 与新 kind 口径的差异必须显式记录(别名救不了存量)', () => {
|
||||
test('存量 57 条 ui 的落盘 unclassified 被读时重派生自愈', () => {
|
||||
const assets = realManifest().assets;
|
||||
const drifted = assets
|
||||
.map((asset) => ({
|
||||
kind: asset.kind,
|
||||
onDisk: asset.category,
|
||||
// 只按 kind 重新派生(丢弃存量 category),这就是加别名之后新登记资产会拿到的值。
|
||||
derivedFromKind: gameCreationAppAssetCategory({
|
||||
kind: asset.kind,
|
||||
category: undefined,
|
||||
}),
|
||||
}))
|
||||
.filter((entry) => entry.onDisk !== entry.derivedFromKind);
|
||||
const uiAssets = assets.filter((asset) => asset.kind === 'ui');
|
||||
|
||||
const driftedByKind = new Map<
|
||||
string,
|
||||
{ onDisk: string; derivedFromKind: string; count: number }
|
||||
>();
|
||||
for (const entry of drifted) {
|
||||
const key = `${entry.kind}|${entry.onDisk}|${entry.derivedFromKind}`;
|
||||
const existing = driftedByKind.get(key);
|
||||
if (existing) {
|
||||
existing.count += 1;
|
||||
} else {
|
||||
driftedByKind.set(key, { ...entry, count: 1 });
|
||||
}
|
||||
}
|
||||
// 前置事实:这 57 条落盘值确实是 unclassified(历史误判现场)。
|
||||
expect(uiAssets).toHaveLength(57);
|
||||
expect(uiAssets.every((asset) => asset.category === 'unclassified')).toBe(
|
||||
true,
|
||||
);
|
||||
// 读取侧已经把它重派生成 ui-interaction:读时自愈,不回写 manifest。
|
||||
expect(
|
||||
uiAssets.every(
|
||||
(asset) => gameCreationAppAssetCategory(asset) === 'ui-interaction',
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
// 真机现状:57 条 ui 的落盘 category 是 unclassified,而按新 kind 口径应是
|
||||
// ui-interaction。这条差异是「别名只救新登记、不救存量」的直接证据:读取侧
|
||||
// `gameCreationAppAssetCategory` 优先信任落盘 category,所以别名加完,
|
||||
// 这 57 条仍然留在「待归类」。
|
||||
expect(drifted).toHaveLength(57);
|
||||
expect(Object.fromEntries(driftedByKind)).toEqual({
|
||||
'ui|unclassified|ui-interaction': {
|
||||
kind: 'ui',
|
||||
onDisk: 'unclassified',
|
||||
derivedFromKind: 'ui-interaction',
|
||||
count: 57,
|
||||
},
|
||||
});
|
||||
// 直接钉住「读时重派生」这一行为本身:落盘 unclassified + kind 能派生明确分类
|
||||
// → 用派生值;这正是修复前那 57 条留在「待归类」的原因。
|
||||
expect(
|
||||
gameCreationAppAssetCategory({ kind: 'ui', category: 'unclassified' }),
|
||||
).toBe('ui-interaction');
|
||||
// 落盘值本身就是明确分类时仍然信任落盘值(用户手动设置的权威性)。
|
||||
expect(
|
||||
gameCreationAppAssetCategory({ kind: 'ui', category: 'scene' }),
|
||||
).toBe('scene');
|
||||
// 落盘值缺失时按 kind 派生(历史 manifest 兼容)。
|
||||
expect(
|
||||
gameCreationAppAssetCategory({ kind: 'ui', category: undefined }),
|
||||
).toBe('ui-interaction');
|
||||
});
|
||||
|
||||
test('别名落地后存量仍落待归类:58 不变(根治需要读时重派生或一次性回填)', () => {
|
||||
test('落盘 unclassified 且 kind 派生也是 unclassified 时不受影响', () => {
|
||||
// image / video / code 的 canonical 分类本来就是 unclassified,
|
||||
// 这类资产必须继续信任落盘值,不被重派生规则误伤。
|
||||
for (const kind of ['image', 'video', 'code', 'publication-material']) {
|
||||
expect(
|
||||
gameCreationAppAssetCategory({ kind, category: 'unclassified' }),
|
||||
).toBe('unclassified');
|
||||
}
|
||||
});
|
||||
|
||||
test('读时重派生后:57 条 ui 归位 UI 交互,待归类只剩 code 类 game-entry', () => {
|
||||
const resources = projectResourcesFromReadModels(realManifest(), [], []);
|
||||
const byCategory = countBy(resources, (resource) =>
|
||||
projectResourceCanvasCategory(resource),
|
||||
);
|
||||
|
||||
// 期望分布是加别名**之后**的实际结果,不是目标态。
|
||||
// 存量自愈后的真实分布:57 条 ui 归位到 UI 交互,只剩 game-entry(code)在待归类。
|
||||
expect(byCategory).toEqual({
|
||||
unclassified: 58,
|
||||
'ui-interaction': 2,
|
||||
unclassified: 1,
|
||||
'ui-interaction': 59,
|
||||
scene: 1,
|
||||
});
|
||||
// 若哪天真做了存量回填,这两个数字应当变成 unclassified:1 / ui-interaction:59,
|
||||
// 届时这条断言会失败并提醒更新口径。
|
||||
const uiResources = resources.filter(
|
||||
(resource) => resource.subtype === 'ui',
|
||||
);
|
||||
@@ -182,9 +178,18 @@ describe('真机 manifest 取证:占位卡片计数与栏目分布', () => {
|
||||
expect(
|
||||
uiResources.every(
|
||||
(resource) =>
|
||||
projectResourceCanvasCategory(resource) === 'unclassified',
|
||||
projectResourceCanvasCategory(resource) === 'ui-interaction',
|
||||
),
|
||||
).toBe(true);
|
||||
// 唯一留在待归类的就是 game-entry:code → unclassified 是分类表的设计口径。
|
||||
expect(
|
||||
resources
|
||||
.filter(
|
||||
(resource) =>
|
||||
projectResourceCanvasCategory(resource) === 'unclassified',
|
||||
)
|
||||
.map((resource) => resource.subtype),
|
||||
).toEqual(['game-entry']);
|
||||
});
|
||||
|
||||
test('按预览分支计数:只有 JSON 规格会落占位,52 张 PNG 走图片分支', () => {
|
||||
@@ -213,15 +218,15 @@ describe('真机 manifest 取证:占位卡片计数与栏目分布', () => {
|
||||
expect(countBy(placeholders, (e) => e.resource.subtype)).toEqual({ ui: 8 });
|
||||
});
|
||||
|
||||
test('按画布栏目计数:58 条落待归类,与真机截图一致', () => {
|
||||
test('按画布栏目计数:待归类从 58 降到 1,UI 交互升到 59', () => {
|
||||
const resources = projectResourcesFromReadModels(realManifest(), [], []);
|
||||
const byCategory = countBy(resources, (resource) =>
|
||||
projectResourceCanvasCategory(resource),
|
||||
);
|
||||
|
||||
expect(byCategory).toEqual({
|
||||
unclassified: 58,
|
||||
'ui-interaction': 2,
|
||||
unclassified: 1,
|
||||
'ui-interaction': 59,
|
||||
scene: 1,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -357,6 +357,8 @@ type UpdateProjectResourceCanvasLayoutResult =
|
||||
|
||||
实现状态(2026-09-10):资源画布分区口径是 manifest 资产的功能分类 `category`(`ui-interaction / character / scene / audio / document / unclassified`)加末尾独立的「项目版本」栏目,不再按扩展名或 mediaType 派生分区。`icon / icon-spritesheet / icon-spec / ui-design` 进入 UI 交互,`character / character-animation` 进入角色与对象,`scene` 进入场景与环境,`sound-effect / background-music / audio` 进入音频,`spec` 与合法 Agent 文本回执进入文档;`image / video / code / publication-material` 以及任务产物、导入附件进入待归类,只登记游戏代码的项目因此有可见栏目与卡片,不再出现四栏全空;项目版本只接收显式 `ProjectVersionResourceSummary` read model,未知任务产物不得兜底为版本。扩展名分类器只保留准入与卡片显示类型职责:无法识别的二进制任务产物和附件不进入资源画布。受控读取、中央聚焦、失败空态与媒体播放不改变 manifest 真相;编辑成功后只追加新的 asset 或版本子记录。既有布局 sidecar 的旧栏目坐标按读时归并继续生效,`x / y / manuallyPlaced` 原样保留。
|
||||
|
||||
分类取值优先级(2026-09-10 收口):落盘 `category` 是权威值(用户可在「分类与标签」面板手动设置),缺失或非法时按 `assets[].kind` 派生。唯一例外是读时自愈——落盘值为 `unclassified` 而该资产 `kind` 能派生出明确的非 `unclassified` 分类时采用派生值,用于修复历史上被系统误写成 `unclassified` 的存量数据(无需迁移脚本、永久自愈);`kind` 派生结果本身就是 `unclassified` 的(`image / video / code / publication-material`)仍信任落盘值。该例外的已知盲区是「用户手动把 kind 已能明确分类的资产设为待归类」会被覆盖,属有意接受的最小覆盖窗口。写入侧必须只产出 canonical kind(画板导出推断同样如此),别名表仅用于兼容存量数据。
|
||||
|
||||
资源身份固定使用 manifest asset ID、正式 version ID、Agent ID + run ID 或已导入资源稳定路径;显示标题、来源文案变化不得改变 `resourceId`,从而避免布局、依赖边、选择和聚焦状态因改名失效。
|
||||
|
||||
```ts
|
||||
|
||||
@@ -5163,3 +5163,13 @@
|
||||
- 另一处必须成对维护:别名表有**两份实现**——TS 侧 `packages/shared/src/contracts/gameCreationApp.ts` 的 `GAME_CREATION_APP_LEGACY_ASSET_KINDS`(读投影用)与 Rust 侧 `server-rs/crates/shared-contracts/src/game_creation_app.rs` 的 `canonical_game_creation_app_asset_kind`(写入侧按 kind 派生 category 用)。只改一边就会让落盘 category 与读侧栏目互相矛盾。交叉守卫见 `apps/ai-game-creator-shell/tests/assetKindCanonicalMapping.test.ts`(直接解析 Rust 源码比对)。
|
||||
- 真机计数守卫见 `apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts`。
|
||||
- 关联:`apps/ai-game-creator-shell/src-tauri/src/assets.rs`、`apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs`、`packages/shared/src/contracts/gameCreationApp.ts`、`server-rs/crates/shared-contracts/src/game_creation_app.rs`。
|
||||
|
||||
## 读时重派生:为什么可以覆盖落盘 category,以及它的唯一盲区(2026-09-10)
|
||||
|
||||
- 规则(已拍板落地):`gameCreationAppAssetCategory` 在「落盘 `category === 'unclassified'` **且** 该资产 `kind` 能派生出明确的非 `unclassified` 分类」时采用派生值,其余情况信任落盘值。
|
||||
- 为什么需要这条:`register_local_asset_entry`(`assets.rs`)命中同 `localPath` 的既有资产时只覆盖 `kind` / `media_type` / `source`,**不重算 `category`**;于是历史上被写成 `unclassified` 的资产(典型是 `kind:"ui"` 因不在 canonical 目录而落到 `image → unclassified`)在补齐别名后**不会自愈**。选读时重派生而不是写迁移脚本:不需要迁移、且永久自愈(任何历史上被系统错判成 unclassified 的都会自动归位)。真机验证:`What do u wanna do kitten` 的待归类从 58 降到 1(只剩 code 类 `game-entry`),UI 交互从 2 升到 59。
|
||||
- 为什么可以覆盖落盘值:落盘 `category` 的权威性来自「用户可在分类与标签面板手动设置」(`update_manifest_asset_classification_at`,`project/manifest.rs`)。收窄条件把覆盖窗口压到最小——只有当落盘值是 `unclassified`(即"没有明确分类")时才覆盖。
|
||||
- **唯一盲区**:用户**手动**把一个 kind 已能明确分类的资产设成「待归类」时,该手动值会被覆盖。这是有意接受的取舍:「手动设为待归类」意图边缘,且 kind 已经表达了分类;而漏掉这条规则,所有历史误判都无法自愈。若将来产品需要"显式待归类",应改成在 manifest 里区分"未设置"与"显式 unclassified"(例如 `category` 缺省 vs 显式写入),而不是取消本条规则。
|
||||
- 不受影响:`image` / `video` / `code` / `publication-material` 的 canonical 分类本身就是 `unclassified`,派生结果等于落盘值,规则不触发(已用断言钉住)。
|
||||
- 写入侧已同步 canonical 化:`infer_canvas_export_asset_kind`(`assets.rs`)现在直接产出 canonical kind(`ui → ui-design`、`animation → character-animation`、`asset → image`),不再依赖别名表兜底;`canvas_export_asset_kind_is_always_canonical` 用例逐分支钉死,别名表从此只承担存量兼容。
|
||||
- 关联:`packages/shared/src/contracts/gameCreationApp.ts` 的 `gameCreationAppAssetCategory`、`apps/ai-game-creator-shell/src-tauri/src/assets.rs`、`apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts`。
|
||||
|
||||
@@ -604,14 +604,35 @@ export function gameCreationAppAssetCategoryForKind(
|
||||
];
|
||||
}
|
||||
|
||||
/** 历史 manifest 缺少 category 时按 kind 派生;未知分类值同样退回 kind 派生。 */
|
||||
/**
|
||||
* manifest 资产的最终功能分类。
|
||||
*
|
||||
* 优先级:落盘 `category`(用户可在「分类与标签」面板手动设置,是权威值)→ 按 `kind` 派生。
|
||||
* 历史 manifest 缺少 category、或 category 非法时都退回 kind 派生。
|
||||
*
|
||||
* **例外(唯一的覆盖窗口)**:落盘值是 `unclassified`,而该资产 `kind` 能派生出明确的
|
||||
* 非 `unclassified` 分类时,采用派生值。这条规则用于自愈「历史上被系统错误写成
|
||||
* unclassified」的存量数据——典型例子是 `kind:"ui"` 的 UI 资产曾因 kind 不在 canonical
|
||||
* 目录而落到 `image → unclassified`,修复别名后并不会自动归位,因为落盘值已固化。
|
||||
*
|
||||
* 盲区:用户**手动**把一个 kind 已能明确分类的资产设成「待归类」时,该手动值会被这条
|
||||
* 规则覆盖。这是有意接受的取舍——「手动设为待归类」本身意图边缘,且 kind 已经表达了分类;
|
||||
* 而反过来漏掉这条规则,所有历史误判都无法自愈。
|
||||
* kind 派生结果本身就是 `unclassified` 的(如 `image` / `video` / `code`)不受影响,
|
||||
* 仍然信任落盘值。
|
||||
*/
|
||||
export function gameCreationAppAssetCategory(
|
||||
asset: Pick<GameCreationAppAssetManifestEntry, 'kind' | 'category'>,
|
||||
): GameCreationAppAssetCategory {
|
||||
return (
|
||||
normalizeGameCreationAppAssetCategory(asset.category) ??
|
||||
gameCreationAppAssetCategoryForKind(asset.kind)
|
||||
);
|
||||
const persisted = normalizeGameCreationAppAssetCategory(asset.category);
|
||||
const derivedFromKind = gameCreationAppAssetCategoryForKind(asset.kind);
|
||||
if (persisted === null) {
|
||||
return derivedFromKind;
|
||||
}
|
||||
if (persisted === 'unclassified' && derivedFromKind !== 'unclassified') {
|
||||
return derivedFromKind;
|
||||
}
|
||||
return persisted;
|
||||
}
|
||||
|
||||
export function gameCreationAppAssetTags(
|
||||
|
||||
Reference in New Issue
Block a user