AGC 壳把资源 kind 的登记与解析边界统一到严格口径

- assets.rs:新增 `registration_asset_kind()`,空白入参落中性 `image`、非空认不出严格收口成 `Unknown` 并留痕;`import_canvas_asset_at` 改走该函数。
- commands.rs:`imported_platform_asset_kind` 与 `register_local_asset` 一并委托给 `registration_asset_kind()`,删掉两处各写一遍的 trim/严格解析分支。
- agent/generation/canvas_generation.rs:切片残留登记判据改为只看 `assets/art-spritesheet-slices/` 路径——历史切片刻的 kind 是非 canonical 值,严格解析后收口成 `unknown`,再带 kind 判据会漏掉半成品登记并静默跳过回填;补平台词汇映射用例钉住别名与未映射值落点。
- project/resource_editor.rs:栅格归一化的源 subtype 收口到图片族成员(音频/视频/文档/字体等一律按图片登记),Agent 回执派生物写回 `Document` 而不再主动写 `Unknown`;补派生 kind 用例。
- ui_editor/workflow.rs:新增 `same_sprite_asset_identity()`,比较 sprite 身份时忽略随 kind 派生的 `metadata.asset_type`,避免对既有 State 重跑工作流误报资源冲突。
This commit is contained in:
2026-09-17 16:19:00 +08:00
parent 7a237431e0
commit 0510cd8d9c
5 changed files with 137 additions and 17 deletions
@@ -7013,11 +7013,14 @@ pub(in crate::agent) fn register_existing_platform_art_slices_at(
.clone()
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| "旧项目图集源资源缺少 sourceResourceId".to_string())?;
// 判据只看路径:`assets/art-spritesheet-slices/` 是切片专用目录,任何落在这里的登记都是
// 切片登记。不能再要求 `kind == Icon`——历史项目里切片刻的 kind 是非 canonical 值
// `art-spritesheet-slice` 等),严格解析后收口成 `unknown`,加上 kind 判据就会漏掉这些
// 半成品登记,让下面的回填被静默跳过。
let has_partial_or_invalid_registration = manifest.assets.iter().any(|asset| {
asset.kind == GameCreationAppAssetKind::Icon
&& asset
.local_path
.starts_with("assets/art-spritesheet-slices/")
asset
.local_path
.starts_with("assets/art-spritesheet-slices/")
});
let receipt_path =
@@ -7908,6 +7911,51 @@ mod canvas_generation_tests {
}
}
/// 平台请求词汇 → manifest kind 的**唯一**映射点:逐条别名、trim 与未映射值的落点都必须钉住。
///
/// 未映射值不能悄悄变成某个具体 kind:它走严格解析,认不出就是 `Unknown`(→「待归类」),
/// 同时由 reporter 把原始串写进日志——这条用例保证"不猜"。
#[test]
fn platform_art_asset_manifest_kind_maps_platform_vocabulary_explicitly() {
use GameCreationAppAssetKind as Kind;
for (platform_kind, expected) in [
("image", Kind::Image),
("game-art", Kind::Image),
("character", Kind::Character),
("character-art", Kind::Character),
("spec", Kind::IconSpec),
("icon-spec", Kind::IconSpec),
("ui-prototype", Kind::UiDesign),
("ui-design", Kind::UiDesign),
("art-spritesheet", Kind::IconSpritesheet),
("icon-spritesheet", Kind::IconSpritesheet),
("publication-material", Kind::PublicationMaterial),
("game-background", Kind::Scene),
("scene", Kind::Scene),
// 平台词汇这一步会 trim(与 `normalize_platform_art_asset_generation_kind` 同口径)。
(" ui-prototype ", Kind::UiDesign),
] {
assert_eq!(
platform_art_asset_manifest_kind(platform_kind),
expected,
"{platform_kind} 的映射必须显式钉住"
);
}
// canonical 成员即使不在上面的平台别名表里也照常解析(严格等值)。
assert_eq!(
platform_art_asset_manifest_kind("font"),
GameCreationAppAssetKind::Font
);
// 认不出的词汇不猜、不兜底成具体 kind。
for platform_kind in ["", "UI", "ui", "mystery", "unknown-kind"] {
assert_eq!(
platform_art_asset_manifest_kind(platform_kind),
GameCreationAppAssetKind::Unknown,
"{platform_kind} 不在映射表内,必须落 unknown 而不是被猜成某个 kind"
);
}
}
#[test]
fn generation_kind_catalog_binds_art_spritesheet_to_a_spec_board_prompt() {
// 放行 art-spritesheet 必须真的走到图集提示词与图集请求合同,
@@ -538,6 +538,22 @@ fn uploaded_asset_kind(file_name: &str, media_type: &str) -> GameCreationAppAsse
}
}
/// 登记边界的 kind 解析:空白入参按「没有信息」处理,落中性 `image`(→「待归类」);
/// 非空但认不出的原值严格收口成 `Unknown`(同样「待归类」)并把原始串交给 reporter 留痕。
///
/// 三条外部登记边界必须同口径:平台导入(`commands::imported_platform_asset_kind`)、
/// 画板导入(`sync_canvas_project_assets_at`)、外部 `register_local_asset`。
/// 只做严格等值匹配——不 trim 归一、不查别名、不做迁移。
pub(crate) fn registration_asset_kind(
value: &str,
context: &'static str,
) -> GameCreationAppAssetKind {
if value.trim().is_empty() {
return GameCreationAppAssetKind::Image;
}
GameCreationAppAssetKind::parse_with_context(value, context)
}
pub(crate) fn upload_local_asset_at(
root: &Path,
file_name: &str,
@@ -686,7 +702,7 @@ pub(crate) fn import_canvas_asset_at(
if resource_id.is_none() && asset_object_id.is_none() {
return Err("画板资源 ID 和资产对象 ID 至少需要一个".to_string());
}
let kind = GameCreationAppAssetKind::parse_with_context(kind.trim(), "canvas.asset_import");
let kind = registration_asset_kind(kind, "canvas.asset_import");
register_local_asset_at(
root,
@@ -2167,7 +2167,7 @@ pub(crate) fn register_local_asset(
register_local_asset_at(
root,
local_path.trim(),
GameCreationAppAssetKind::parse_with_context(kind.trim(), "asset.register"),
crate::assets::registration_asset_kind(&kind, "asset.register"),
media_type.trim(),
source_kind.trim(),
GameCreationAppAssetSource {
@@ -4189,13 +4189,13 @@ pub(crate) fn import_local_project_assets_for_agent(
///
/// kind 只有一条口径:严格解析,不做别名归一、不做迁移(`Kind.ALL` 是唯一词汇表)。
fn imported_platform_asset_kind(platform_kind: Option<&str>) -> GameCreationAppAssetKind {
match platform_kind.map(str::trim).filter(|kind| !kind.is_empty()) {
// 平台是外部系统,但 kind 口径**只有一条**:严格解析 + 非 canonical 原值留痕
// (回调已由壳层注册成 `app_log!`,见 `main.rs`。这里不再做别名收口,
// 认不出的值就是 `Unknown` → 「待归类」,原值只出现在日志里。
Some(kind) => GameCreationAppAssetKind::parse_with_context(kind, "platform.asset_kind"),
None => GameCreationAppAssetKind::Image,
}
// 平台是外部系统,但 kind 口径**只有一条**:严格解析 + 非 canonical 原值留痕
// (回调已由壳层注册成 `app_log!`,见 `main.rs`);缺失 / 空白按「没有信息」落
// 中性 `image`。这里不再做别名收口,认不出的值就是 `Unknown` → 「待归类」,
// 原值只出现在日志里。
platform_kind
.map(|kind| crate::assets::registration_asset_kind(kind, "platform.asset_kind"))
.unwrap_or(GameCreationAppAssetKind::Image)
}
/// 按账户素材 `assetId` 查询权威素材、换签下载并登记到当前项目。模型只提交
@@ -338,7 +338,12 @@ impl ResourceEditSourceKind {
fn manifest_kind(self) -> GameCreationAppAssetKind {
match self {
Self::Manifest(kind) => kind,
Self::ProjectVersion | Self::AgentResultText => GameCreationAppAssetKind::Unknown,
// Agent 回执派生物是 markdown 文本,与 `Text => Document` 同口径:写盘只写正式成员,
// 不能主动写 `Unknown`(那会把"派生物是文档"的事实换成"认不出的资源")。
Self::AgentResultText => GameCreationAppAssetKind::Document,
// 版本编辑不走 manifest 资产写回通道(版本有独立提交路径),此分支不可达;
// 真走到这里也只表示"没有 manifest kind"。
Self::ProjectVersion => GameCreationAppAssetKind::Unknown,
}
}
}
@@ -3849,8 +3854,10 @@ pub(crate) fn normalize_local_project_raster_resource_at(
.map_err(|error| format!("序列化源图片身份失败:{error}"))?;
let identity_hash = sha256_hex(&identity_material);
let asset_id = format!("normalized-{}", &identity_hash[..24]);
// 该接口只把栅格源登记成 manifest 图片资产:源 subtype 只接受正式枚举成员,
// 未声明或非图片族成员时按图片收口。
// 该接口只把栅格源登记成 manifest 图片资产:源 subtype 只接受**图片族**成员,
// 未声明、认不出或非图片族成员(音频 / 视频 / 文档 / 字体 / 代码…)时按图片收口。
// 只排除 `Unknown` 是不够的:`source_subtype` 是外部入参,传 `background-music` 就会把
// 一张 PNG 登记成音频 kind,和本函数的存在理由直接冲突。
let source_kind = input
.source_subtype
.as_deref()
@@ -3858,6 +3865,19 @@ pub(crate) fn normalize_local_project_raster_resource_at(
.filter(|value| !value.is_empty())
.map(|value| GameCreationAppAssetKind::parse_with_context(value, "resource-edit.normalize"))
.filter(|kind| *kind != GameCreationAppAssetKind::Unknown)
.filter(|kind| {
matches!(
kind,
GameCreationAppAssetKind::Image
| GameCreationAppAssetKind::Scene
| GameCreationAppAssetKind::Character
| GameCreationAppAssetKind::CharacterAnimation
| GameCreationAppAssetKind::Icon
| GameCreationAppAssetKind::IconSpritesheet
| GameCreationAppAssetKind::IconSpec
| GameCreationAppAssetKind::UiDesign
)
})
.unwrap_or(GameCreationAppAssetKind::Image);
let asset = GameCreationAppAssetManifestEntry {
id: asset_id.clone(),
@@ -6279,6 +6299,25 @@ mod tests {
);
}
/// 派生资源写回 manifest 的 kind 必须是正式成员:Agent 回执派生物是 markdown 文本,
/// 与 `Text => Document` 同口径,不许主动写 `Unknown`。
#[test]
fn derived_asset_manifest_kind_is_never_unknown_for_text_derivatives() {
assert_eq!(
ResourceEditSourceKind::AgentResultText.manifest_kind(),
GameCreationAppAssetKind::Document
);
assert_eq!(
ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::Video).manifest_kind(),
GameCreationAppAssetKind::Video
);
// 版本谱系没有 manifest kind,且不走资产写回通道(版本有独立提交路径)。
assert_eq!(
ResourceEditSourceKind::ProjectVersion.manifest_kind(),
GameCreationAppAssetKind::Unknown
);
}
#[test]
fn local_media_upload_ticket_uses_legal_private_editor_namespace() {
let directory = tempfile::tempdir().expect("create resource editor fixture");
@@ -854,6 +854,23 @@ fn ensure_page_ui_resource(
Ok(ui_asset)
}
/// 比较 sprite 资源身份时忽略 `metadata.asset_type`。
///
/// `asset_type` 是随 canonical kind 派生的展示串(`SpriteAssetMetadata` 参与 `PartialEq`),
/// 而迁移前的 State 里存的是当时的 manifest 原始 kind`ui-prototype` / `art-spritesheet` 等)。
/// 它参与比较会让"对既有 State 重跑工作流"误报「与已有 State 资源冲突」,所以身份判定只看
/// 真正影响渲染的字段;展示串随 kind 派生、不构成资源身份。
fn same_sprite_asset_identity(current: &SpriteAsset, next: &SpriteAsset) -> bool {
if current.metadata.asset_type == next.metadata.asset_type {
return current == next;
}
let mut current = current.clone();
let mut next = next.clone();
current.metadata.asset_type = String::new();
next.metadata.asset_type = String::new();
current == next
}
fn install_page_component_assets(
root: &Path,
state: &mut crate::ui_editor::state::State,
@@ -885,7 +902,7 @@ fn install_page_component_assets(
};
sprite.path = asset.local_path.clone();
match state.sprite_assets.get(&asset_id) {
Some(current) if current != &sprite => {
Some(current) if !same_sprite_asset_identity(current, &sprite) => {
return Err(format!(
"UI 独立图片/图标 {} 与已有 State 资源冲突",
asset.id