新增图集连通域与可配置网格切分
Project CI / AI game creator shell Rust shard 1/4 (push) Failing after 4m37s
Project CI / AI game creator shell Rust shard 2/4 (push) Failing after 4m36s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m31s
Project CI / Backend tests (push) Failing after 10s
Project CI / AI game creator shell Rust shard 4/4 (push) Failing after 4m2s
Project CI / AI game creator shell Rust shard 3/4 (push) Failing after 4m45s
Project CI / Repository checks (push) Failing after 11s
Project CI / AI game creator shell web tests (push) Failing after 1m18s
Project CI / AI game creator shell Rust crates (push) Successful in 2m36s
Project CI / Native shell tests (push) Failing after 2m34s
Project CI / Frontend tests (push) Successful in 4m52s
Project CI / AI game creator shell Rust shard 1/4 (push) Failing after 4m37s
Project CI / AI game creator shell Rust shard 2/4 (push) Failing after 4m36s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m31s
Project CI / Backend tests (push) Failing after 10s
Project CI / AI game creator shell Rust shard 4/4 (push) Failing after 4m2s
Project CI / AI game creator shell Rust shard 3/4 (push) Failing after 4m45s
Project CI / Repository checks (push) Failing after 11s
Project CI / AI game creator shell web tests (push) Failing after 1m18s
Project CI / AI game creator shell Rust crates (push) Successful in 2m36s
Project CI / Native shell tests (push) Failing after 2m34s
Project CI / Frontend tests (push) Successful in 4m52s
增加 connected-components 与 grid 切分模式 支持 gridX/gridY 并同步 API、MCP、Skill、AGC 客户端 移除固定 2x2 图集切分契约与文档
This commit is contained in:
@@ -864,7 +864,9 @@ impl EditorAgentTool for GenerateIconSpritesheetTool {
|
||||
reference_image_srcs: Some(reference_image_srcs),
|
||||
icon_descriptions: args.icon_descriptions,
|
||||
slice_count: None,
|
||||
slice_layout: None,
|
||||
slice_mode: None,
|
||||
grid_x: None,
|
||||
grid_y: None,
|
||||
style: None,
|
||||
model: Some(args.model),
|
||||
screen_color: Some("auto".to_string()),
|
||||
|
||||
@@ -93,7 +93,8 @@ use crate::{
|
||||
},
|
||||
editor_project_icon::{
|
||||
EditorIconSpritesheetGenerationResponse, EditorIconSpritesheetIconResponse,
|
||||
PersistEditorSpritesheetSlicesInput, editor_icon_spritesheet_slice_warning_from_error,
|
||||
EditorIconSpritesheetSliceMode, PersistEditorSpritesheetSlicesInput,
|
||||
editor_icon_spritesheet_slice_warning_from_error,
|
||||
editor_icon_spritesheet_warning_after_persist_error,
|
||||
prepare_editor_spritesheet_slices_for_generation, slice_editor_icon_spritesheet_all,
|
||||
},
|
||||
@@ -1267,7 +1268,7 @@ fn compact_external_api_generation_result(result: Value) -> Value {
|
||||
| "spritesheetWidth"
|
||||
| "spritesheetHeight"
|
||||
| "iconImageSrcs"
|
||||
| "sliceLayout"
|
||||
| "sliceMode"
|
||||
| "frames"
|
||||
| "frameCount"
|
||||
| "frameWidth"
|
||||
@@ -8578,7 +8579,9 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner(
|
||||
spritesheet_width: source_width,
|
||||
spritesheet_height: source_height,
|
||||
icon_image_srcs: Vec::new(),
|
||||
slice_layout: None,
|
||||
slice_mode: None,
|
||||
grid_x: None,
|
||||
grid_y: None,
|
||||
slice_count: None,
|
||||
slice_warning: None,
|
||||
prompt,
|
||||
@@ -8645,7 +8648,9 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner(
|
||||
spritesheet_width: source_width,
|
||||
spritesheet_height: source_height,
|
||||
icon_image_srcs: Vec::new(),
|
||||
slice_layout: None,
|
||||
slice_mode: None,
|
||||
grid_x: None,
|
||||
grid_y: None,
|
||||
slice_count: None,
|
||||
slice_warning: None,
|
||||
prompt,
|
||||
@@ -8730,8 +8735,10 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner(
|
||||
let (mut icon_image_srcs, slice_items, slice_warning) = match slice_editor_icon_spritesheet_all(
|
||||
slice_source,
|
||||
request_context.external_call_deadline(),
|
||||
EditorIconSpritesheetSliceMode::ConnectedComponents,
|
||||
None,
|
||||
None,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -8893,7 +8900,9 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner(
|
||||
spritesheet_width,
|
||||
spritesheet_height,
|
||||
icon_image_srcs,
|
||||
slice_layout: None,
|
||||
slice_mode: None,
|
||||
grid_x: None,
|
||||
grid_y: None,
|
||||
slice_count: None,
|
||||
slice_warning,
|
||||
prompt,
|
||||
@@ -19063,10 +19072,17 @@ mod tests {
|
||||
.checked_sub(Duration::from_millis(1))
|
||||
.expect("expired deadline should be representable");
|
||||
|
||||
let error = slice_editor_icon_spritesheet_all(source, Some(expired), None, None)
|
||||
.await
|
||||
.err()
|
||||
.expect("expired CPU budget must fail before decoding");
|
||||
let error = slice_editor_icon_spritesheet_all(
|
||||
source,
|
||||
Some(expired),
|
||||
EditorIconSpritesheetSliceMode::ConnectedComponents,
|
||||
None,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.expect("expired CPU budget must fail before decoding");
|
||||
|
||||
assert_eq!(error.status_code(), StatusCode::GATEWAY_TIMEOUT);
|
||||
assert_eq!(
|
||||
@@ -19751,7 +19767,9 @@ mod tests {
|
||||
spritesheet_width: 512,
|
||||
spritesheet_height: 512,
|
||||
icon_image_srcs: Vec::new(),
|
||||
slice_layout: None,
|
||||
slice_mode: None,
|
||||
grid_x: None,
|
||||
grid_y: None,
|
||||
slice_count: None,
|
||||
slice_warning: Some(EditorIconSpritesheetSliceWarningResponse {
|
||||
code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS,
|
||||
@@ -19984,7 +20002,6 @@ mod tests {
|
||||
fn atomic_job_result_keeps_only_the_target_consumer_contract() {
|
||||
let result = json!({
|
||||
"ok": true,
|
||||
"sliceLayout": "grid-2x2",
|
||||
"imageSrc": "/api/assets/object/generated.png",
|
||||
"objectKey": "generated/image.png",
|
||||
"width": 512,
|
||||
@@ -20069,7 +20086,6 @@ mod tests {
|
||||
);
|
||||
assert_eq!(external_payload["result"]["asset"]["assetId"], "asset-1");
|
||||
assert_eq!(external_payload["result"]["ok"], true);
|
||||
assert_eq!(external_payload["result"]["sliceLayout"], "grid-2x2");
|
||||
assert_eq!(external_payload["result"]["prompt"], "用户可见提示词");
|
||||
assert_eq!(
|
||||
external_payload["result"]["actualPrompt"],
|
||||
|
||||
@@ -14,7 +14,7 @@ use platform_image::{
|
||||
generated_asset_sheets::{
|
||||
GeneratedAssetSheetConnectedIcon, GeneratedAssetSheetConnectedIconPlan,
|
||||
GeneratedAssetSheetError, prepare_generated_icon_spritesheet_all_by_connected_components,
|
||||
prepare_generated_icon_spritesheet_grid_2x2,
|
||||
prepare_generated_icon_spritesheet_grid,
|
||||
},
|
||||
};
|
||||
use platform_llm::{EDITOR_AGENT_GPT5_MODEL, LlmMessage, LlmRunRequest};
|
||||
@@ -82,6 +82,7 @@ pub(crate) const EDITOR_ICON_SPRITESHEET_MEMORY_MAX_CONCURRENCY: usize = 2;
|
||||
pub(crate) const EDITOR_ICON_SPRITESHEET_UPLOAD_MAX_CONCURRENCY: usize = 2;
|
||||
pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_TOTAL_CROP_PIXELS: u64 =
|
||||
EDITOR_ICON_SPRITESHEET_MAX_PIXELS * 4;
|
||||
const EDITOR_ICON_SPRITESHEET_MAX_GRID_AXIS: u32 = 32;
|
||||
pub(crate) const EDITOR_ICON_SPRITESHEET_UPLOAD_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
pub(crate) const EDITOR_ICON_SPRITESHEET_UPLOAD_REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_PROCESSING_DURATION: Duration =
|
||||
@@ -254,8 +255,13 @@ pub(crate) struct EditorIconSpritesheetGenerationRequest {
|
||||
/// 用户要求的切片数量;未提供时按图像中的连通素材自动识别。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) slice_count: Option<usize>,
|
||||
/// 图集切分模式;省略时使用连通域切分。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) slice_layout: Option<EditorIconSpritesheetSliceLayout>,
|
||||
pub(crate) slice_mode: Option<EditorIconSpritesheetSliceMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) grid_x: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) grid_y: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) style: Option<String>,
|
||||
pub(crate) model: Option<String>,
|
||||
@@ -270,11 +276,50 @@ pub(crate) struct EditorIconSpritesheetGenerationRequest {
|
||||
pub(crate) canvas_completion: Option<EditorCanvasGenerationCompletionPayload>,
|
||||
}
|
||||
|
||||
/// Deprecated compatibility layout. New callers should use `sliceCount`。
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub(crate) enum EditorIconSpritesheetSliceLayout {
|
||||
#[serde(rename = "grid-2x2")]
|
||||
Grid2x2,
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub(crate) enum EditorIconSpritesheetSliceMode {
|
||||
ConnectedComponents,
|
||||
Grid,
|
||||
}
|
||||
|
||||
impl Default for EditorIconSpritesheetSliceMode {
|
||||
fn default() -> Self {
|
||||
Self::ConnectedComponents
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_editor_icon_spritesheet_slice_mode(
|
||||
slice_mode: Option<EditorIconSpritesheetSliceMode>,
|
||||
) -> EditorIconSpritesheetSliceMode {
|
||||
slice_mode.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn resolve_editor_icon_spritesheet_grid_dimensions(
|
||||
mode: EditorIconSpritesheetSliceMode,
|
||||
grid_x: Option<u32>,
|
||||
grid_y: Option<u32>,
|
||||
) -> Result<(u32, u32), AppError> {
|
||||
if mode == EditorIconSpritesheetSliceMode::ConnectedComponents {
|
||||
return Ok((0, 0));
|
||||
}
|
||||
let (Some(grid_x), Some(grid_y)) = (grid_x, grid_y) else {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"field": "gridX/gridY",
|
||||
"message": "grid 模式必须同时提供 gridX 与 gridY。",
|
||||
})),
|
||||
);
|
||||
};
|
||||
if !(1..=EDITOR_ICON_SPRITESHEET_MAX_GRID_AXIS).contains(&grid_x)
|
||||
|| !(1..=EDITOR_ICON_SPRITESHEET_MAX_GRID_AXIS).contains(&grid_y)
|
||||
{
|
||||
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"field": "gridX/gridY",
|
||||
"message": format!("gridX 与 gridY 必须在 1 到 {} 之间。", EDITOR_ICON_SPRITESHEET_MAX_GRID_AXIS),
|
||||
})));
|
||||
}
|
||||
Ok((grid_x, grid_y))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
@@ -313,7 +358,11 @@ pub(crate) struct EditorIconSpritesheetGenerationResponse {
|
||||
pub(crate) spritesheet_height: u32,
|
||||
pub(crate) icon_image_srcs: Vec<EditorIconSpritesheetIconResponse>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) slice_layout: Option<EditorIconSpritesheetSliceLayout>,
|
||||
pub(crate) slice_mode: Option<EditorIconSpritesheetSliceMode>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) grid_x: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) grid_y: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) slice_count: Option<usize>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -1579,6 +1628,12 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
|
||||
.or_else(|| payload.project_id.clone()),
|
||||
);
|
||||
let http_client = build_openai_image_http_client(&settings)?;
|
||||
let requested_slice_mode = resolve_editor_icon_spritesheet_slice_mode(payload.slice_mode);
|
||||
let (grid_x, grid_y) = resolve_editor_icon_spritesheet_grid_dimensions(
|
||||
requested_slice_mode,
|
||||
payload.grid_x,
|
||||
payload.grid_y,
|
||||
)?;
|
||||
// TODO(legacy-icon-spritesheet-billing-boundary): 该计费边界继承自 master 的历史实现;
|
||||
// Provider 成功后 operation 即提交,后续解码、OSS、资源与画布持久化失败时缺少可对账中间态。
|
||||
// 调整前需先定义 provider_succeeded/persistence_pending 等状态、稳定幂等键和补偿语义,
|
||||
@@ -1618,15 +1673,18 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
|
||||
)
|
||||
.await?;
|
||||
let screen_color = screen_background_decision.color;
|
||||
let prompt = match payload.slice_layout {
|
||||
Some(EditorIconSpritesheetSliceLayout::Grid2x2) => {
|
||||
crate::prompt::icon_spec::build_grid_2x2_spritesheet_prompt(
|
||||
let slice_mode = requested_slice_mode;
|
||||
let prompt = match slice_mode {
|
||||
EditorIconSpritesheetSliceMode::Grid => {
|
||||
crate::prompt::icon_spec::build_grid_spritesheet_prompt(
|
||||
&spritesheet_prompt,
|
||||
screen_color,
|
||||
icon_spec_genre,
|
||||
grid_x,
|
||||
grid_y,
|
||||
)
|
||||
}
|
||||
None => crate::prompt::icon_spec::build_spritesheet_prompt(
|
||||
EditorIconSpritesheetSliceMode::ConnectedComponents => crate::prompt::icon_spec::build_spritesheet_prompt(
|
||||
&spritesheet_prompt,
|
||||
screen_color,
|
||||
icon_spec_genre,
|
||||
@@ -1786,7 +1844,11 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
|
||||
spritesheet_width: source_width,
|
||||
spritesheet_height: source_height,
|
||||
icon_image_srcs: Vec::new(),
|
||||
slice_layout: payload.slice_layout,
|
||||
slice_mode: Some(requested_slice_mode),
|
||||
grid_x: (requested_slice_mode == EditorIconSpritesheetSliceMode::Grid)
|
||||
.then_some(grid_x),
|
||||
grid_y: (requested_slice_mode == EditorIconSpritesheetSliceMode::Grid)
|
||||
.then_some(grid_y),
|
||||
slice_count: Some(0),
|
||||
slice_warning: None,
|
||||
prompt,
|
||||
@@ -1868,7 +1930,11 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
|
||||
spritesheet_width: source_width,
|
||||
spritesheet_height: source_height,
|
||||
icon_image_srcs: Vec::new(),
|
||||
slice_layout: payload.slice_layout,
|
||||
slice_mode: Some(requested_slice_mode),
|
||||
grid_x: (requested_slice_mode == EditorIconSpritesheetSliceMode::Grid)
|
||||
.then_some(grid_x),
|
||||
grid_y: (requested_slice_mode == EditorIconSpritesheetSliceMode::Grid)
|
||||
.then_some(grid_y),
|
||||
slice_count: Some(0),
|
||||
slice_warning: None,
|
||||
prompt,
|
||||
@@ -1966,8 +2032,10 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
|
||||
let (mut icon_image_srcs, slice_items, slice_warning) = match slice_editor_icon_spritesheet_all(
|
||||
slice_source,
|
||||
request_context.external_call_deadline(),
|
||||
payload.slice_layout,
|
||||
requested_slice_mode,
|
||||
payload.slice_count,
|
||||
grid_x,
|
||||
grid_y,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -2059,7 +2127,12 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
|
||||
"spritesheetWidth": spritesheet_width,
|
||||
"spritesheetHeight": spritesheet_height,
|
||||
"iconImageSrcs": &icon_image_srcs,
|
||||
"sliceLayout": payload.slice_layout,
|
||||
|
||||
"sliceMode": requested_slice_mode,
|
||||
"gridX": (requested_slice_mode == EditorIconSpritesheetSliceMode::Grid)
|
||||
.then_some(grid_x),
|
||||
"gridY": (requested_slice_mode == EditorIconSpritesheetSliceMode::Grid)
|
||||
.then_some(grid_y),
|
||||
"sliceCount": payload.slice_count,
|
||||
"sliceWarning": &slice_warning,
|
||||
"warning": &generation_warning,
|
||||
@@ -2137,7 +2210,12 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
|
||||
spritesheet_width,
|
||||
spritesheet_height,
|
||||
icon_image_srcs,
|
||||
slice_layout: payload.slice_layout,
|
||||
|
||||
slice_mode: Some(requested_slice_mode),
|
||||
grid_x: (requested_slice_mode == EditorIconSpritesheetSliceMode::Grid)
|
||||
.then_some(grid_x),
|
||||
grid_y: (requested_slice_mode == EditorIconSpritesheetSliceMode::Grid)
|
||||
.then_some(grid_y),
|
||||
slice_count: Some(slice_count),
|
||||
slice_warning,
|
||||
prompt,
|
||||
@@ -2332,8 +2410,10 @@ pub async fn split_editor_icon_spritesheet(
|
||||
source,
|
||||
processing_deadline,
|
||||
memory_admission,
|
||||
EditorIconSpritesheetSliceMode::ConnectedComponents,
|
||||
None,
|
||||
None,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
.await?;
|
||||
let prompt = source_resource
|
||||
@@ -2408,8 +2488,10 @@ pub async fn split_editor_icon_spritesheet(
|
||||
pub(crate) async fn slice_editor_icon_spritesheet_all(
|
||||
source: DownloadedImage,
|
||||
request_deadline: Option<Instant>,
|
||||
slice_layout: Option<EditorIconSpritesheetSliceLayout>,
|
||||
slice_mode: EditorIconSpritesheetSliceMode,
|
||||
slice_count: Option<usize>,
|
||||
grid_x: u32,
|
||||
grid_y: u32,
|
||||
) -> Result<PreparedEditorIconSpritesheetPlan, AppError> {
|
||||
let processing_deadline =
|
||||
resolve_editor_icon_spritesheet_processing_deadline(Instant::now(), request_deadline);
|
||||
@@ -2419,8 +2501,10 @@ pub(crate) async fn slice_editor_icon_spritesheet_all(
|
||||
source,
|
||||
processing_deadline,
|
||||
memory_admission,
|
||||
slice_layout,
|
||||
slice_mode,
|
||||
slice_count,
|
||||
grid_x,
|
||||
grid_y,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -2458,8 +2542,10 @@ async fn slice_editor_icon_spritesheet_all_with_memory_admission(
|
||||
source: DownloadedImage,
|
||||
processing_deadline: Instant,
|
||||
memory_admission: Arc<EditorIconSpritesheetMemoryAdmission>,
|
||||
slice_layout: Option<EditorIconSpritesheetSliceLayout>,
|
||||
slice_mode: EditorIconSpritesheetSliceMode,
|
||||
slice_count: Option<usize>,
|
||||
grid_x: u32,
|
||||
grid_y: u32,
|
||||
) -> Result<PreparedEditorIconSpritesheetPlan, AppError> {
|
||||
if Instant::now() >= processing_deadline {
|
||||
return Err(editor_icon_spritesheet_processing_timeout_error());
|
||||
@@ -2492,17 +2578,19 @@ async fn slice_editor_icon_spritesheet_all_with_memory_admission(
|
||||
return Err(editor_icon_spritesheet_processing_timeout_error());
|
||||
}
|
||||
validate_editor_icon_spritesheet_source(&source)?;
|
||||
match slice_layout {
|
||||
Some(EditorIconSpritesheetSliceLayout::Grid2x2) => {
|
||||
prepare_generated_icon_spritesheet_grid_2x2(&source)
|
||||
match slice_mode {
|
||||
EditorIconSpritesheetSliceMode::Grid => {
|
||||
prepare_generated_icon_spritesheet_grid(&source, grid_x, grid_y)
|
||||
}
|
||||
EditorIconSpritesheetSliceMode::ConnectedComponents => {
|
||||
prepare_generated_icon_spritesheet_all_by_connected_components(
|
||||
&source,
|
||||
slice_count
|
||||
.unwrap_or(EDITOR_ICON_SPRITESHEET_MAX_SLICES)
|
||||
.min(EDITOR_ICON_SPRITESHEET_MAX_SLICES),
|
||||
EDITOR_ICON_SPRITESHEET_MAX_TOTAL_CROP_PIXELS,
|
||||
)
|
||||
}
|
||||
None => prepare_generated_icon_spritesheet_all_by_connected_components(
|
||||
&source,
|
||||
slice_count
|
||||
.unwrap_or(EDITOR_ICON_SPRITESHEET_MAX_SLICES)
|
||||
.min(EDITOR_ICON_SPRITESHEET_MAX_SLICES),
|
||||
EDITOR_ICON_SPRITESHEET_MAX_TOTAL_CROP_PIXELS,
|
||||
),
|
||||
}
|
||||
.map_err(map_editor_icon_spritesheet_platform_error)
|
||||
});
|
||||
@@ -2909,7 +2997,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grid_2x2_slicing_returns_exactly_four_quadrants_with_detached_details() {
|
||||
async fn grid_slicing_returns_one_slice_per_declared_cell_with_detached_details() {
|
||||
use image::{ImageBuffer, ImageFormat, Rgba};
|
||||
|
||||
let mut image: image::RgbaImage = ImageBuffer::from_pixel(128, 128, Rgba([0, 255, 0, 255]));
|
||||
@@ -2939,15 +3027,54 @@ mod tests {
|
||||
let prepared = slice_editor_icon_spritesheet_all(
|
||||
source,
|
||||
None,
|
||||
Some(EditorIconSpritesheetSliceLayout::Grid2x2),
|
||||
EditorIconSpritesheetSliceMode::Grid,
|
||||
None,
|
||||
2,
|
||||
2,
|
||||
)
|
||||
.await
|
||||
.expect("declared 2x2 sheet should slice");
|
||||
.expect("declared grid sheet should slice");
|
||||
|
||||
assert_eq!(prepared.plan.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slice_mode_defaults_to_connected_components_and_accepts_explicit_modes() {
|
||||
assert_eq!(
|
||||
resolve_editor_icon_spritesheet_slice_mode(None),
|
||||
EditorIconSpritesheetSliceMode::ConnectedComponents
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_editor_icon_spritesheet_grid_dimensions(
|
||||
EditorIconSpritesheetSliceMode::Grid,
|
||||
Some(3),
|
||||
Some(2),
|
||||
)
|
||||
.expect("grid dimensions should validate"),
|
||||
(3, 2)
|
||||
);
|
||||
let connected: EditorIconSpritesheetGenerationRequest = serde_json::from_value(json!({
|
||||
"referenceId": "spec",
|
||||
"iconDescriptions": ["素材"],
|
||||
"sliceMode": "connected-components"
|
||||
}))
|
||||
.expect("explicit connected-components mode should deserialize");
|
||||
assert_eq!(
|
||||
connected.slice_mode,
|
||||
Some(EditorIconSpritesheetSliceMode::ConnectedComponents)
|
||||
);
|
||||
let grid: EditorIconSpritesheetGenerationRequest = serde_json::from_value(json!({
|
||||
"referenceId": "spec",
|
||||
"iconDescriptions": ["素材"],
|
||||
"sliceMode": "grid",
|
||||
"gridX": 3,
|
||||
"gridY": 2
|
||||
}))
|
||||
.expect("grid mode should deserialize");
|
||||
assert_eq!(grid.grid_x, Some(3));
|
||||
assert_eq!(grid.grid_y, Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spritesheet_genre_requires_exact_game_type_title() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -2620,6 +2620,14 @@ mod tests {
|
||||
icon_spritesheet_request["properties"]["sliceCount"]["minimum"],
|
||||
json!(1)
|
||||
);
|
||||
assert_eq!(
|
||||
icon_spritesheet_request["properties"]["sliceMode"]["enum"],
|
||||
json!(["connected-components", "grid"])
|
||||
);
|
||||
assert_eq!(
|
||||
icon_spritesheet_request["properties"]["gridX"]["maximum"],
|
||||
json!(32)
|
||||
);
|
||||
let icon_style_schema = &parsed["components"]["schemas"]["EditorIconSpritesheetGenerationRequest"]
|
||||
["properties"]["style"];
|
||||
assert_eq!(icon_style_schema["anyOf"][0]["type"], "string");
|
||||
|
||||
@@ -1364,7 +1364,9 @@ fn compact_external_api_generation_result(result: Value) -> Value {
|
||||
| "spritesheetWidth"
|
||||
| "spritesheetHeight"
|
||||
| "iconImageSrcs"
|
||||
| "sliceLayout"
|
||||
| "sliceMode"
|
||||
| "gridX"
|
||||
| "gridY"
|
||||
| "sliceCount"
|
||||
| "frames"
|
||||
| "frameCount"
|
||||
@@ -2436,7 +2438,9 @@ mod tests {
|
||||
let mut job = external_generation_job_record_fixture(Some("lease-1"));
|
||||
job.dedupe_key = "external-api-generation:conversation-1:7:icon-spritesheet".to_string();
|
||||
let response = json!({
|
||||
"sliceLayout": "grid-2x2",
|
||||
"sliceMode": "grid",
|
||||
"gridX": 2,
|
||||
"gridY": 2,
|
||||
"iconImageSrcs": [
|
||||
{ "name": "素材 1", "imageSrc": "/api/assets/object/one.png" },
|
||||
{ "name": "素材 2", "imageSrc": "/api/assets/object/two.png" },
|
||||
@@ -2449,7 +2453,7 @@ mod tests {
|
||||
serde_json::from_str(&editor_generation_result_payload_json(&job, &response))
|
||||
.expect("worker result should be valid JSON");
|
||||
|
||||
assert_eq!(payload["result"]["sliceLayout"], json!("grid-2x2"));
|
||||
assert_eq!(payload["result"]["sliceMode"], json!("grid"));
|
||||
assert_eq!(
|
||||
payload["result"]["iconImageSrcs"].as_array().map(Vec::len),
|
||||
Some(4)
|
||||
@@ -2705,7 +2709,9 @@ mod tests {
|
||||
"spritesheetImageSrc": "/api/assets/object/core-sheet.png",
|
||||
"spritesheetWidth": 1024,
|
||||
"spritesheetHeight": 1024,
|
||||
"sliceLayout": "grid-2x2",
|
||||
"sliceMode": "grid",
|
||||
"gridX": 2,
|
||||
"gridY": 2,
|
||||
"spritesheetResource": {
|
||||
"resourceId": "sheet-resource-1",
|
||||
"objectKey": "users/user-1/core-sheet.png",
|
||||
@@ -2729,7 +2735,7 @@ mod tests {
|
||||
serde_json::from_str(&editor_generation_result_payload_json(&job, &response))
|
||||
.expect("游戏创作客户端完成结果应持久化为合法 JSON");
|
||||
|
||||
assert_eq!(payload["result"]["sliceLayout"], json!("grid-2x2"));
|
||||
assert_eq!(payload["result"]["sliceMode"], json!("grid"));
|
||||
assert_eq!(
|
||||
payload["result"]["iconImageSrcs"].as_array().map(Vec::len),
|
||||
Some(4)
|
||||
|
||||
@@ -54,7 +54,7 @@ const SKILL_REQUESTS_AND_OUTPUTS_URI: &str =
|
||||
"genarrative://external-editor/skill/references/requests-and-outputs.md";
|
||||
const MAX_MCP_REST_RESPONSE_BYTES: usize = 4 * 1024 * 1024;
|
||||
|
||||
const MCP_INSTRUCTIONS: &str = r#"陶泥儿外部编辑器工具。先创建或复用画布项目,并创建与画布同名的素材文件夹;生成结果应同时写入画布和素材库。参考本地文件时先走上传票据和对象确认,不要把 Data URL、Blob URL 或临时签名 URL写入生成参数。所有生成工具都是异步提交:必须提供 idempotencyKey,提交后按 pollAfterMs 调用 get_external_editor_generation_job,只有 status=completed 时消费 result;查询超时不能重新提交。warning 表示主结果可用但存在降级,sliceWarning 表示完整透明图集可用但切片未完成。详细说明、OpenAPI、Skill 主入口和分主题 references 见 resources/list;需要本地文件编排或不支持 MCP 时再下载 skill.zip。"#;
|
||||
const MCP_INSTRUCTIONS: &str = r#"陶泥儿外部编辑器工具。先创建或复用画布项目,并创建与画布同名的素材文件夹;生成结果应同时写入画布和素材库。参考本地文件时先走上传票据和对象确认,不要把 Data URL、Blob URL 或临时签名 URL写入生成参数。所有生成工具都是异步提交:必须提供 idempotencyKey,提交后按 pollAfterMs 调用 get_external_editor_generation_job,只有 status=completed 时消费 result;查询超时不能重新提交。图集生成可用 sliceMode=connected-components(默认连通域切分)或 grid(必须同时提供 gridX/gridY)。warning 表示主结果可用但存在降级,sliceWarning 表示完整透明图集可用但切片未完成。详细说明、OpenAPI、Skill 主入口和分主题 references 见 resources/list;需要本地文件编排或不支持 MCP 时再下载 skill.zip。"#;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct McpOperation {
|
||||
|
||||
@@ -276,13 +276,17 @@ pub(crate) fn build_spritesheet_prompt(
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn build_grid_2x2_spritesheet_prompt(
|
||||
pub(crate) fn build_grid_spritesheet_prompt(
|
||||
user_prompt: &ValidatedEditorIconSpritesheetPrompt,
|
||||
screen_color: EditorScreenBackgroundColor,
|
||||
genre: Option<GameGenre>,
|
||||
columns: u32,
|
||||
rows: u32,
|
||||
) -> String {
|
||||
format!(
|
||||
"{}\n\n固定 2×2 游戏核心素材图集合同:画面必须严格分为左上、右上、左下、右下四个等大的独立槽位;每个槽位只放一个完整、可单独用于游戏运行时的主体。四个槽位必须按用户给出的四条素材需求顺序对应,且每格都必须有清晰可见的主体。禁止生成任何额外图标、同一主体的多个姿势、序列帧、棋盘、场景、边框、流程箭头、标签、文字、Logo、装饰小物或第五个素材;禁止主体跨格、触碰或重叠。输出须是单张图集,背景只使用统一纯色以便透明化。",
|
||||
"{}\n\n固定网格游戏核心素材图集合同:画面必须严格分为 {}×{} 个等大的独立槽位;每个槽位只放一个完整、可单独用于游戏运行时的主体。素材按从左到右、从上到下顺序对应,禁止主体跨格、触碰或重叠。输出须是单张图集,背景只使用统一纯色以便透明化。",
|
||||
columns,
|
||||
rows,
|
||||
build_spritesheet_prompt(user_prompt, screen_color, genre),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ pub use sheet::{
|
||||
GeneratedAssetSheetSliceImage, crop_generated_asset_sheet_view_edge_matte,
|
||||
crop_generated_asset_sheet_view_edge_matte_with_options,
|
||||
prepare_generated_icon_spritesheet_all_by_connected_components,
|
||||
prepare_generated_icon_spritesheet_grid_2x2, slice_generated_asset_sheet,
|
||||
prepare_generated_icon_spritesheet_grid, slice_generated_asset_sheet,
|
||||
slice_generated_asset_sheet_two_items_per_row,
|
||||
slice_generated_icon_spritesheet_all_by_connected_components,
|
||||
};
|
||||
|
||||
@@ -254,35 +254,38 @@ pub fn prepare_generated_icon_spritesheet_all_by_connected_components(
|
||||
)
|
||||
}
|
||||
|
||||
/// Prepares the four authoritative slices of a provider-generated 2×2 atlas.
|
||||
/// Prepares the authoritative slices of a provider-generated rectangular grid atlas.
|
||||
///
|
||||
/// This deliberately does not inspect connected components. A caller that
|
||||
/// requested the fixed layout has already constrained the provider to place
|
||||
/// one complete asset in every quadrant; trying to infer individual pieces
|
||||
/// again would turn highlights and detached effects into extra game assets.
|
||||
pub fn prepare_generated_icon_spritesheet_grid_2x2(
|
||||
pub fn prepare_generated_icon_spritesheet_grid(
|
||||
image: &crate::DownloadedImage,
|
||||
columns: u32,
|
||||
rows: u32,
|
||||
) -> Result<GeneratedAssetSheetConnectedIconPlan, GeneratedAssetSheetError> {
|
||||
let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| {
|
||||
GeneratedAssetSheetError::decode_image(format!("图标 spritesheet 解码失败:{error}"))
|
||||
})?;
|
||||
let source = apply_generated_asset_sheet_green_screen_alpha(source).into_rgba8();
|
||||
let (width, height) = source.dimensions();
|
||||
if width < 2 || height < 2 {
|
||||
if columns == 0 || rows == 0 || width < columns || height < rows {
|
||||
return Err(GeneratedAssetSheetError::invalid_request(
|
||||
"2×2 图标 spritesheet 尺寸过小,无法切割。",
|
||||
"网格图标 spritesheet 的尺寸或网格参数无效,无法切割。",
|
||||
));
|
||||
}
|
||||
|
||||
let mut icons = Vec::with_capacity(4);
|
||||
for index in 0..4usize {
|
||||
let row = (index / 2) as u32;
|
||||
let col = (index % 2) as u32;
|
||||
let cell = resolve_generated_asset_sheet_cell_bounds(width, height, 2, row, col);
|
||||
let cell_count = columns.saturating_mul(rows) as usize;
|
||||
let mut icons = Vec::with_capacity(cell_count);
|
||||
for index in 0..cell_count {
|
||||
let row = (index as u32) / columns;
|
||||
let col = (index as u32) % columns;
|
||||
let cell = resolve_generated_asset_sheet_cell_bounds(width, height, columns, row, col);
|
||||
let foreground = detect_generated_asset_sheet_visible_bounds_in_cell(&source, cell)
|
||||
.ok_or_else(|| {
|
||||
GeneratedAssetSheetError::invalid_request(format!(
|
||||
"2×2 图标 spritesheet 的第 {} 个格子没有可见素材。",
|
||||
"网格图标 spritesheet 的第 {} 个格子没有可见素材。",
|
||||
index + 1
|
||||
))
|
||||
})?;
|
||||
@@ -295,7 +298,7 @@ pub fn prepare_generated_icon_spritesheet_grid_2x2(
|
||||
y1: foreground.y1.saturating_add(pad_y).min(cell.y1),
|
||||
};
|
||||
icons.push(GeneratedAssetSheetConnectedIconPlanItem {
|
||||
name: format!("2×2 素材 {}", index + 1),
|
||||
name: format!("网格素材 {}", index + 1),
|
||||
crop,
|
||||
});
|
||||
}
|
||||
@@ -1009,7 +1012,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_2x2_plan_keeps_one_durable_slice_per_quadrant_despite_detached_details() {
|
||||
fn grid_plan_keeps_one_durable_slice_per_cell_despite_detached_details() {
|
||||
let mut sheet: image::RgbaImage = ImageBuffer::from_pixel(128, 128, Rgba([0, 255, 0, 255]));
|
||||
let colors = [
|
||||
[240, 80, 80, 255],
|
||||
@@ -1036,8 +1039,8 @@ mod tests {
|
||||
extension: "png".to_string(),
|
||||
};
|
||||
|
||||
let plan = prepare_generated_icon_spritesheet_grid_2x2(&source)
|
||||
.expect("declared 2x2 atlas should prepare");
|
||||
let plan = prepare_generated_icon_spritesheet_grid(&source, 2, 2)
|
||||
.expect("declared grid atlas should prepare");
|
||||
let icons = (0..plan.len())
|
||||
.map(|index| plan.encode(index).expect("quadrant should encode"))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Reference in New Issue
Block a user