图集切片上限对齐平台契约:客户端结果门 64 → 256
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m55s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m56s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 7m42s
Project CI / Backend tests (pull_request) Successful in 5m19s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 8m39s
Project CI / Native shell tests (pull_request) Successful in 7m24s
Project CI / Repository checks (pull_request) Successful in 3m54s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m32s
Project CI / Frontend tests (pull_request) Successful in 4m54s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m55s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m56s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 7m42s
Project CI / Backend tests (pull_request) Successful in 5m19s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 8m39s
Project CI / Native shell tests (pull_request) Successful in 7m24s
Project CI / Repository checks (pull_request) Successful in 3m54s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m32s
Project CI / Frontend tests (pull_request) Successful in 4m54s
- 现场(gameagent-6e53c9e8)「AI 生成图标素材」失败:平台已生成并切完图,客户端在绑定结果这一步把整条结果判失败(assetId 为空、付费产物被丢)。 - 根因:同一条链路存在两个上限——平台切分 / Agent 工具 schema sliceCount / 持久化产物批次都是 256,客户端两处结果绑定门仍是 64(external_generation_state.rs、canvas_generation.rs)。 - 修复:两处门统一到 PLATFORM_ART_SPRITESHEET_MAX_SLICES = 256,判据与拒绝文案各只留一份(数字由常量插值),注释点名三处同值权威。 - 用例:新增上限值/边界/文案用例与「64/65/256 片必须能落盘、257 片必须拒」的回归用例;两条都做过变异验证(常量改回 64 即红)。 - 文档:decision-log 与 pitfalls 各补一条(客户端门不得比平台产品上限更严;上限引同一常量)。
This commit is contained in:
@@ -32,6 +32,33 @@ const PLATFORM_ART_SPRITESHEET_MAX_DIMENSION: u32 = 4_096;
|
||||
const PLATFORM_ART_SPRITESHEET_TOTAL_PIXELS: u64 = 16 * 1024 * 1024;
|
||||
const PLATFORM_ART_SPRITESHEET_MAX_DECODE_ALLOC: u64 = 64 * 1024 * 1024;
|
||||
|
||||
/// 客户端接受的图集切片数量上限。
|
||||
///
|
||||
/// 这个值**不是**客户端自己的预算,而是与另外三处取同一个值的一致性约束:
|
||||
/// 平台切分上限(`server-rs/crates/api-server/src/editor_project_icon.rs` 的
|
||||
/// `EDITOR_ICON_SPRITESHEET_MAX_SLICES = 256`)、Agent 工具 schema 的 `sliceCount`(1..256,
|
||||
/// 见 `agent_native_tools.rs` 与 `direct_tool_bridge.rs`),以及公开契约
|
||||
/// (`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`:单边最多 4096 像素、总像素最多
|
||||
/// 2048×2048、最多 256 个输出)。
|
||||
///
|
||||
/// 客户端一旦比平台更严,平台**合法**产出的 65~256 片结果会在绑定结果这一步被整条丢弃:
|
||||
/// 用户只会看到「异步生成完成结果无法绑定到 operationId」加一句切片超限,既拿不到图也没法自查。
|
||||
/// 现场(`gameagent-6e53c9e8`,`connected-components` 自动切分)就是被旧值 64 挡下的。
|
||||
pub(crate) const PLATFORM_ART_SPRITESHEET_MAX_SLICES: usize = 256;
|
||||
|
||||
/// 平台返回的图集切片数量是否超出上限(两个结果绑定入口共用同一条判据)。
|
||||
pub(crate) fn platform_art_spritesheet_slice_count_exceeds_limit(slice_count: usize) -> bool {
|
||||
slice_count > PLATFORM_ART_SPRITESHEET_MAX_SLICES
|
||||
}
|
||||
|
||||
/// 切片数量超限的拒绝文案(两处入口同一句话,数字来自常量,避免再和上限漂移)。
|
||||
pub(crate) fn platform_art_spritesheet_slice_limit_error(
|
||||
source_label: &str,
|
||||
suffix: &str,
|
||||
) -> String {
|
||||
format!("{source_label}的图集切片超过 {PLATFORM_ART_SPRITESHEET_MAX_SLICES} 个{suffix}")
|
||||
}
|
||||
|
||||
type ExternalCanvasBindingAsyncLock = tokio::sync::Mutex<()>;
|
||||
const EXTERNAL_CANVAS_BINDING_PARTIAL_SCHEMA_VERSION: &str =
|
||||
"game-creator-external-editor-project-binding-partial.v2";
|
||||
@@ -2365,8 +2392,11 @@ async fn prepare_platform_art_spritesheet_slices(
|
||||
else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
if icons.len() > 64 {
|
||||
return Err("External Editor 返回的图集切片超过 64 个,已拒绝同步".to_string());
|
||||
if platform_art_spritesheet_slice_count_exceeds_limit(icons.len()) {
|
||||
return Err(platform_art_spritesheet_slice_limit_error(
|
||||
"External Editor 返回",
|
||||
",已拒绝同步",
|
||||
));
|
||||
}
|
||||
let mut prepared = Vec::with_capacity(icons.len());
|
||||
if initial_download_bytes > PLATFORM_ART_SPRITESHEET_TOTAL_DOWNLOAD_BYTES
|
||||
@@ -8394,6 +8424,26 @@ pub(crate) fn build_platform_art_asset_prompt(
|
||||
|
||||
#[cfg(test)]
|
||||
mod canvas_generation_tests {
|
||||
#[test]
|
||||
fn spritesheet_slice_limit_matches_platform_and_tool_contract() {
|
||||
// 现场回归(`gameagent-6e53c9e8`):平台按 `connected-components` 自动切图,客户端旧门
|
||||
// (>64 就拒)把 65~256 片的**合法**产出整条判失败。这里把上限钉在与平台切分、Agent
|
||||
// 工具 schema `sliceCount`(1..256)和公开契约同一个值上。
|
||||
assert_eq!(PLATFORM_ART_SPRITESHEET_MAX_SLICES, 256);
|
||||
assert!(!platform_art_spritesheet_slice_count_exceeds_limit(0));
|
||||
assert!(!platform_art_spritesheet_slice_count_exceeds_limit(64));
|
||||
assert!(!platform_art_spritesheet_slice_count_exceeds_limit(
|
||||
PLATFORM_ART_SPRITESHEET_MAX_SLICES
|
||||
));
|
||||
assert!(platform_art_spritesheet_slice_count_exceeds_limit(
|
||||
PLATFORM_ART_SPRITESHEET_MAX_SLICES + 1
|
||||
));
|
||||
// 拒绝文案里的数字来自常量本身,不会和上限再次漂移。
|
||||
assert_eq!(
|
||||
platform_art_spritesheet_slice_limit_error("External Editor 返回", ",已拒绝同步"),
|
||||
"External Editor 返回的图集切片超过 256 个,已拒绝同步"
|
||||
);
|
||||
}
|
||||
use super::*;
|
||||
use image::{
|
||||
codecs::png::{CompressionType, FilterType, PngEncoder},
|
||||
|
||||
+96
-2
@@ -912,8 +912,14 @@ fn durable_legacy_generation_result(
|
||||
.get("iconImageSrcs")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
{
|
||||
if icons.len() > 64 {
|
||||
return Err("External Editor 旧同步结果的图集切片超过 64 个".to_string());
|
||||
if super::canvas_generation::platform_art_spritesheet_slice_count_exceeds_limit(icons.len())
|
||||
{
|
||||
return Err(
|
||||
super::canvas_generation::platform_art_spritesheet_slice_limit_error(
|
||||
"External Editor 旧同步结果",
|
||||
"",
|
||||
),
|
||||
);
|
||||
}
|
||||
let mut durable_icons = Vec::with_capacity(icons.len());
|
||||
for (index, icon) in icons.iter().enumerate() {
|
||||
@@ -1995,6 +2001,94 @@ mod external_generation_state_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// 现场回归(`gameagent-6e53c9e8`,2026-09-21):平台按 `connected-components` 自动切图,
|
||||
/// 切出的数量落在 65~256 之间是**合法**产出;客户端旧门(>64 就拒)会把整条结果判失败,
|
||||
/// 用户只看到「异步生成完成结果无法绑定到 operationId」——图没落盘,付费结果被丢弃。
|
||||
///
|
||||
/// 上限必须与平台切分(`EDITOR_ICON_SPRITESHEET_MAX_SLICES = 256`)和 Agent 工具 schema
|
||||
/// 的 `sliceCount`(1..256)同值;超过上限仍然拒,且文案里的数字来自常量本身。
|
||||
#[test]
|
||||
fn legacy_result_accepts_slice_counts_up_to_platform_limit_and_rejects_beyond() {
|
||||
fn slice(index: usize) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"name": format!("素材 {index}"),
|
||||
"width": 64,
|
||||
"height": 64,
|
||||
"resource": {
|
||||
"resourceId": format!("slice-{index}"),
|
||||
"objectKey": format!("generated/slice-{index}.png")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn persist_legacy_slice_result(
|
||||
root: &Path,
|
||||
count: usize,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let pending = pending_canvas_generation(root);
|
||||
let context = platform_art_generation_runtime_context_from_pending(&pending);
|
||||
let access = developer_access("https://editor.example.test", "test-api-key");
|
||||
let request_body = serde_json::json!({
|
||||
"prompt": "生成统一视觉规范图",
|
||||
"kind": "spec",
|
||||
"projectId": "canvas-project",
|
||||
"assetFolderId": "asset-folder",
|
||||
"referenceImageSrcs": []
|
||||
});
|
||||
let (state, _) = prepare_platform_art_generation_runtime_state(
|
||||
root,
|
||||
&context,
|
||||
"/api/external/v1/editor/images/generations",
|
||||
"legacy-generation-canvas",
|
||||
"生成统一视觉规范图",
|
||||
&request_body,
|
||||
&access,
|
||||
)
|
||||
.expect("prepare legacy generation ledger");
|
||||
let completed = mark_platform_art_generation_runtime_legacy_completed(
|
||||
root,
|
||||
state,
|
||||
&serde_json::json!({
|
||||
"resource": {
|
||||
"resourceId": "legacy-resource",
|
||||
"objectKey": "generated/legacy.png"
|
||||
},
|
||||
"iconImageSrcs": (0..count).map(slice).collect::<Vec<_>>()
|
||||
}),
|
||||
)?;
|
||||
remove_platform_art_generation_runtime_state_at(
|
||||
root,
|
||||
&pending.agent_id,
|
||||
&pending.run_id,
|
||||
)
|
||||
.expect("reset legacy ledger between slice counts");
|
||||
platform_art_generation_runtime_legacy_result(&completed)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
let temporary = crate::tests::canonical_test_tempdir("legacy-generation-slice-limit-");
|
||||
let root = temporary.path();
|
||||
init_local_game_project_at(root, "legacy-slice-limit", "旧同步切片上限回归")
|
||||
.expect("init project");
|
||||
|
||||
// 64(旧上限)到 256(平台上限)之间都是合法产出:必须能持久化,切片一条不少。
|
||||
for count in [64_usize, 65, 256] {
|
||||
let durable =
|
||||
persist_legacy_slice_result(root, count).expect("persist legal slice count");
|
||||
assert_eq!(
|
||||
durable["iconImageSrcs"].as_array().map(Vec::len),
|
||||
Some(count)
|
||||
);
|
||||
}
|
||||
|
||||
// 257 片仍然拒:与平台同一句口径,数字来自常量。
|
||||
let error = persist_legacy_slice_result(root, 257).expect_err("reject beyond limit");
|
||||
assert!(
|
||||
error.contains("图集切片超过 256 个"),
|
||||
"unexpected rejection message: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn dangling_generation_ledger_symlink_is_reconciliation_evidence() {
|
||||
|
||||
Reference in New Issue
Block a user