Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c648ab9c7 | |||
| 155f0d316a |
@@ -30,6 +30,7 @@ The hosted MCP offers the following tools. Choose the task tool when its action
|
||||
| `PATCH /api/external/v1/editor/assets/{assetId}` | `organize_asset_library` (`update_asset`) | `update_editor_asset` |
|
||||
| `DELETE /api/external/v1/editor/assets/{assetId}` | `delete_resources` (`delete_asset`) | `delete_editor_asset` |
|
||||
| `POST /api/external/v1/editor/images/generations` | `generate_image`, `modify_image` (`variation`, fixed `kind="quick-edit"`) | `generate_external_editor_image` |
|
||||
| `POST /api/external/v1/editor/scenes/generations` | structured game-scene generation (no hosted MCP tool yet) | `generate_external_editor_scene` |
|
||||
| `POST /api/external/v1/editor/images/edits` | `modify_image` (`edit`) | `edit_external_editor_image` |
|
||||
| `POST /api/external/v1/editor/images/background-removals` | `modify_image` (`remove_background`) | `remove_external_editor_image_background` |
|
||||
| `POST /api/external/v1/editor/icon-spritesheets/generations` | `generate_icon_spritesheet` | `generate_external_editor_icon_spritesheet` |
|
||||
@@ -89,6 +90,7 @@ Every generation row requires a stable `Idempotency-Key` header and returns HTTP
|
||||
| Capability | POST path | Required body fields | Common optional body fields |
|
||||
| ------------------- | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Image generation | `/api/external/v1/editor/images/generations` | `prompt` | `kind`, `style`, `model`, `aspectRatio`, `imageSize`, `size`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
|
||||
| Game scene | `/api/external/v1/editor/scenes/generations` | `sceneContent`, `stylePreset` | `customStyle` (required when `stylePreset="custom"`), `model`, `aspectRatio`, `imageSize`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
|
||||
| Image edit/redraw | `/api/external/v1/editor/images/edits` | `prompt`, `sourceReferenceId` | `referenceImageSrcs`, `model`, `size`, `projectId`, `assetFolderId`, `assetLabel`, `targetLayerId`, `canvasCompletion` |
|
||||
| Background removal | `/api/external/v1/editor/images/background-removals` | `sourceImageSrc` | `projectId`, `sourceResourceId`, `targetLayerId`, static-image `assetKind`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
|
||||
| Icon spritesheet | `/api/external/v1/editor/icon-spritesheets/generations` | `referenceId`, `iconDescriptions`, `sliceMode` | `gridX`, `gridY`, `sliceCount`, `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
|
||||
@@ -98,7 +100,7 @@ Every generation row requires a stable `Idempotency-Key` header and returns HTTP
|
||||
| Sound effect | `/api/external/v1/editor/audios/sound-effects/generations` | `prompt` | `model`, `duration`, `loop`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
|
||||
| Background music | `/api/external/v1/editor/audios/background-music/generations` | `gptDescriptionPrompt`, `makeInstrumental` | `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
|
||||
|
||||
Poll all nine through:
|
||||
Poll all ten through:
|
||||
|
||||
```text
|
||||
GET /api/external/v1/generations/{operationId}
|
||||
@@ -140,7 +142,7 @@ The icon-spritesheet primary `referenceId` is intentionally stricter than ordina
|
||||
Use OpenAPI as the final authority; these common values are a routing aid:
|
||||
|
||||
- Image `kind`: `spec`, `character`, `quick-edit`, `ui-design`, `publication-material`; ordinary image generation may omit it.
|
||||
- External v1 currently has no structured game-scene generation operation. Do not send `kind: "scene"` or `assetKind: "scene"` through generic image generation; the server rejects both before queueing.
|
||||
- Game scenes must use the dedicated structured route `POST /api/external/v1/editor/scenes/generations` (`sceneContent` + `stylePreset`; `customStyle` required for `custom`). Do not send `kind: "scene"` or `assetKind: "scene"` through generic image generation; the server rejects both before queueing. The scene route assembles the full provider prompt server-side and never accepts a caller-assembled `prompt`.
|
||||
- Image `model`: `gpt-image-2`, `gemini-3.1-flash-image-preview`, `nanobanana2`, `nano-banana`.
|
||||
- Image `aspectRatio`: `1:1`, `2:3`, `3:2`, `9:16`, `16:9`.
|
||||
- Image `imageSize`: `0.5K`, `1K`, `2K`.
|
||||
|
||||
@@ -148,7 +148,7 @@ For the lower-level asset/resource creation endpoints, `generationInputs` is rep
|
||||
|
||||
## Art Spec and Image Request
|
||||
|
||||
Generic External v1 image generation does not expose the main-site structured game-scene contract. `kind: "scene"` and `assetKind: "scene"` are both invalid and return HTTP `400` before any generation job is queued. Do not replace the structured scene fields and server-owned prompt assembly with a generic image prompt.
|
||||
Game scenes have a dedicated structured route: `POST /api/external/v1/editor/scenes/generations` with `sceneContent` and `stylePreset` (`customStyle` required when `stylePreset` is `custom`). The server assembles the full provider prompt; a caller-assembled `prompt` is not accepted. `kind: "scene"` and `assetKind: "scene"` remain invalid on generic image generation and return HTTP `400` before any generation job is queued.
|
||||
|
||||
When maintaining a reusable art spec, carry it in `generationInputs.artSpec` and reflect important constraints in the prompt. This is an example with both canvas and library destinations, not a requirement for every generation:
|
||||
|
||||
|
||||
@@ -2326,6 +2326,30 @@ fn direct_taonier_reference_matches_local_source(
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn direct_taonier_background_asset_identity(
|
||||
root: &Path,
|
||||
expected_reference_source: Option<&DirectTaonierArtAssetIdentity>,
|
||||
) -> Option<DirectTaonierArtAssetIdentity> {
|
||||
direct_taonier_art_asset_identity(
|
||||
root,
|
||||
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
|
||||
GameCreationAppAssetKind::Scene,
|
||||
"/api/external/v1/editor/scenes/generations",
|
||||
"scene",
|
||||
expected_reference_source,
|
||||
)
|
||||
.or_else(|| {
|
||||
direct_taonier_art_asset_identity(
|
||||
root,
|
||||
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
|
||||
GameCreationAppAssetKind::Scene,
|
||||
"/api/external/v1/editor/images/generations",
|
||||
"spec",
|
||||
expected_reference_source,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn direct_taonier_art_base_is_valid(root: &Path) -> bool {
|
||||
let Some(art_spec) = direct_taonier_art_asset_identity(
|
||||
root,
|
||||
@@ -2337,14 +2361,7 @@ fn direct_taonier_art_base_is_valid(root: &Path) -> bool {
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
let Some(background) = direct_taonier_art_asset_identity(
|
||||
root,
|
||||
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
|
||||
GameCreationAppAssetKind::Scene,
|
||||
"/api/external/v1/editor/images/generations",
|
||||
"spec",
|
||||
Some(&art_spec),
|
||||
) else {
|
||||
let Some(background) = direct_taonier_background_asset_identity(root, Some(&art_spec)) else {
|
||||
return false;
|
||||
};
|
||||
let _ = (background, art_spec);
|
||||
@@ -3127,27 +3144,16 @@ async fn generate_direct_taonier_art_asset_at(
|
||||
};
|
||||
let runtime_context =
|
||||
direct_taonier_art_generation_runtime_context(root, output_path, asset_kind)?;
|
||||
let generation = if retain_runtime_state {
|
||||
generate_platform_art_asset_with_retained_runtime_options_at(
|
||||
root,
|
||||
prompt,
|
||||
&[],
|
||||
&options,
|
||||
require_slices,
|
||||
&runtime_context,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
generate_platform_art_asset_with_runtime_options_at(
|
||||
root,
|
||||
prompt,
|
||||
&[],
|
||||
&options,
|
||||
require_slices,
|
||||
&runtime_context,
|
||||
)
|
||||
.await
|
||||
};
|
||||
let generation = generate_art_package_asset_with_runtime_options_at(
|
||||
root,
|
||||
prompt,
|
||||
&[],
|
||||
&options,
|
||||
require_slices,
|
||||
retain_runtime_state,
|
||||
&runtime_context,
|
||||
)
|
||||
.await;
|
||||
let generated = match generation {
|
||||
Ok(generated) => generated,
|
||||
Err(error)
|
||||
@@ -3325,15 +3331,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at(
|
||||
})
|
||||
.flatten();
|
||||
let existing_art_spec_and_background = existing_art_spec.as_ref().is_some_and(|art_spec| {
|
||||
direct_taonier_art_asset_identity(
|
||||
root,
|
||||
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
|
||||
GameCreationAppAssetKind::Scene,
|
||||
"/api/external/v1/editor/images/generations",
|
||||
"spec",
|
||||
Some(art_spec),
|
||||
)
|
||||
.is_some()
|
||||
direct_taonier_background_asset_identity(root, Some(art_spec)).is_some()
|
||||
});
|
||||
let art_spec = match existing_art_spec {
|
||||
Some(identity) => identity,
|
||||
@@ -3409,15 +3407,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at(
|
||||
}
|
||||
};
|
||||
if mode.regenerates_existing()
|
||||
|| direct_taonier_art_asset_identity(
|
||||
root,
|
||||
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
|
||||
GameCreationAppAssetKind::Scene,
|
||||
"/api/external/v1/editor/images/generations",
|
||||
"spec",
|
||||
Some(&art_spec),
|
||||
)
|
||||
.is_none()
|
||||
|| direct_taonier_background_asset_identity(root, Some(&art_spec)).is_none()
|
||||
{
|
||||
emit_direct_game_creator_progress(root, "art.background", "正在生成 16:9 游戏场景背景图");
|
||||
let outcome = match generate_direct_taonier_art_asset_at(
|
||||
@@ -8294,8 +8284,8 @@ mod tests {
|
||||
vec!["taonier-resource-icon-spec".to_string()],
|
||||
),
|
||||
GameCreationAppAssetKind::Scene => (
|
||||
"/api/external/v1/editor/images/generations",
|
||||
"spec",
|
||||
"/api/external/v1/editor/scenes/generations",
|
||||
"scene",
|
||||
vec!["taonier-resource-icon-spec".to_string()],
|
||||
),
|
||||
_ => (
|
||||
@@ -8679,6 +8669,46 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_route_background_registration_still_counts_as_valid_base() {
|
||||
let root = tempfile::tempdir().expect("temp dir");
|
||||
init_local_game_project_at(root.path(), "legacy-background-route", "旧路由背景登记")
|
||||
.expect("init project");
|
||||
std::fs::create_dir_all(root.path().join("assets")).expect("assets dir");
|
||||
register_direct_taonier_art_asset_fixture(
|
||||
root.path(),
|
||||
DIRECT_CODEX_ART_SPEC_ASSET_PATH,
|
||||
GameCreationAppAssetKind::IconSpec,
|
||||
);
|
||||
std::fs::write(
|
||||
root.path().join(DIRECT_CODEX_BACKGROUND_ASSET_PATH),
|
||||
tiny_visible_png(),
|
||||
)
|
||||
.expect("background bytes");
|
||||
register_local_asset_at(
|
||||
root.path(),
|
||||
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
|
||||
GameCreationAppAssetKind::Scene,
|
||||
"image/png",
|
||||
"platform-art",
|
||||
GameCreationAppAssetSource {
|
||||
kind: GameCreationAppAssetSourceKind::Canvas,
|
||||
canvas_project_id: Some("taonier-project".to_string()),
|
||||
resource_id: Some("taonier-resource-game-background".to_string()),
|
||||
asset_object_id: Some("taonier-object-game-background".to_string()),
|
||||
task_id: Some("taonier-task-game-background".to_string()),
|
||||
prompt: None,
|
||||
model: Some("gpt-image-2".to_string()),
|
||||
generation_route: Some("/api/external/v1/editor/images/generations".to_string()),
|
||||
generation_kind: Some("spec".to_string()),
|
||||
reference_resource_ids: vec!["taonier-resource-icon-spec".to_string()],
|
||||
},
|
||||
)
|
||||
.expect("register legacy-route background");
|
||||
|
||||
assert!(direct_taonier_art_base_is_valid(root.path()));
|
||||
}
|
||||
|
||||
fn tiny_visible_png() -> Vec<u8> {
|
||||
let mut bytes = Vec::new();
|
||||
let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
|
||||
|
||||
@@ -18,8 +18,7 @@ pub(crate) use canvas_generation::submit_external_generation_request;
|
||||
pub(in crate::agent) use canvas_generation::{
|
||||
admit_platform_art_generation_at, commit_prepared_platform_art_asset_at,
|
||||
commit_prepared_platform_art_asset_strict_slices_at, generate_admitted_platform_art_asset_at,
|
||||
generate_platform_art_asset_with_retained_runtime_options_at,
|
||||
generate_platform_art_asset_with_runtime_options_at,
|
||||
generate_art_package_asset_with_runtime_options_at,
|
||||
platform_art_generation_error_result_unknown, recover_persisted_visual_generation_options,
|
||||
register_existing_platform_art_slices_at, request_platform_art_asset_with_runtime_options_at,
|
||||
restore_platform_art_asset_bytes_at,
|
||||
|
||||
@@ -2267,6 +2267,35 @@ fn decode_platform_art_image_bytes_with_limits(
|
||||
.map_err(|error| format!("{label}无法在安全内存边界内解码:{error}"))
|
||||
}
|
||||
|
||||
fn normalize_art_package_png(download: &mut CanvasResourceDownload) -> Result<(), String> {
|
||||
if download.bytes.len() > PLATFORM_ART_SPRITESHEET_SINGLE_DOWNLOAD_BYTES {
|
||||
return Err("美术包图片超过 20 MiB 本地校验上限".to_string());
|
||||
}
|
||||
let format = image::guess_format(&download.bytes)
|
||||
.map_err(|error| format!("美术包图片无法识别格式:{error}"))?;
|
||||
if !matches!(
|
||||
format,
|
||||
image::ImageFormat::Png | image::ImageFormat::Jpeg | image::ImageFormat::WebP
|
||||
) {
|
||||
return Err("美术包图片仅支持 PNG、JPEG、WebP".to_string());
|
||||
}
|
||||
// PNG 也必须完整解码;保留原字节以兼容既有摘要和恢复记录。
|
||||
let decoded = decode_platform_art_image_with_limits(download, "美术包图片")?;
|
||||
if format != image::ImageFormat::Png {
|
||||
let mut output = std::io::Cursor::new(Vec::new());
|
||||
decoded
|
||||
.write_to(&mut output, image::ImageFormat::Png)
|
||||
.map_err(|error| format!("美术包图片转为 PNG 失败:{error}"))?;
|
||||
let bytes = output.into_inner();
|
||||
if bytes.len() > PLATFORM_ART_SPRITESHEET_SINGLE_DOWNLOAD_BYTES {
|
||||
return Err("美术包图片转为 PNG 后超过 20 MiB 本地校验上限".to_string());
|
||||
}
|
||||
download.bytes = bytes;
|
||||
}
|
||||
download.media_type = "image/png".to_string();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(in crate::agent) struct ValidatedPlatformArtPng {
|
||||
pub(in crate::agent) width: u32,
|
||||
@@ -2555,6 +2584,7 @@ pub(in crate::agent) async fn generate_admitted_platform_art_asset_at(
|
||||
false,
|
||||
&context,
|
||||
Some(admission.guard),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -2735,20 +2765,19 @@ pub(in crate::agent) async fn generate_platform_art_asset_with_runtime_options_a
|
||||
false,
|
||||
runtime_context,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Installs a recovered or newly generated asset while retaining the durable
|
||||
/// request state. Package-level workflows use this to compensate local files
|
||||
/// after a later stage fails without losing the accepted operation identity or
|
||||
/// issuing another billable POST when the package is retried.
|
||||
pub(in crate::agent) async fn generate_platform_art_asset_with_retained_runtime_options_at(
|
||||
/// 美术包统一输出 PNG;重生成时保留账本,以便后续阶段失败后复用已付费结果。
|
||||
pub(in crate::agent) async fn generate_art_package_asset_with_runtime_options_at(
|
||||
root: &Path,
|
||||
prompt: &str,
|
||||
briefs: &[AgentGroupBrief],
|
||||
options: &PlatformArtAssetGenerationOptions,
|
||||
require_slices: bool,
|
||||
retain_runtime_state: bool,
|
||||
runtime_context: &PlatformArtGenerationRuntimeContext,
|
||||
) -> Result<GeneratedPlatformArtAsset, String> {
|
||||
generate_platform_art_asset_with_runtime_options_and_retention_at(
|
||||
@@ -2757,9 +2786,10 @@ pub(in crate::agent) async fn generate_platform_art_asset_with_retained_runtime_
|
||||
briefs,
|
||||
options,
|
||||
require_slices,
|
||||
true,
|
||||
retain_runtime_state,
|
||||
runtime_context,
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -2835,14 +2865,15 @@ pub(in crate::agent) fn retained_platform_art_generation_runtime_state_matches_d
|
||||
&& art_spec_asset_type == Some("icon-spec")
|
||||
}
|
||||
GameCreationAppAssetKind::Scene => {
|
||||
snapshot.endpoint == "/api/external/v1/editor/images/generations"
|
||||
&& snapshot.generation_kind == "spec"
|
||||
snapshot.endpoint == "/api/external/v1/editor/scenes/generations"
|
||||
&& snapshot.generation_kind == "scene"
|
||||
&& platform_art_runtime_references_match_request_contract(
|
||||
&snapshot.reference_resource_ids,
|
||||
GameCreationAppAssetKind::Scene,
|
||||
)
|
||||
&& request_asset_kind.as_deref() == Some("game-background")
|
||||
&& art_spec_asset_type == Some("background")
|
||||
&& json_string_field(&request_body, "sceneContent")
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
&& json_string_field(&request_body, "stylePreset").as_deref() == Some("custom")
|
||||
}
|
||||
GameCreationAppAssetKind::IconSpritesheet => {
|
||||
snapshot.endpoint == "/api/external/v1/editor/icon-spritesheets/generations"
|
||||
@@ -2908,6 +2939,7 @@ async fn generate_platform_art_asset_with_runtime_options_and_retention_at(
|
||||
retain_runtime_state: bool,
|
||||
runtime_context: &PlatformArtGenerationRuntimeContext,
|
||||
admitted_guard: Option<super::external_generation_state::DurablePlatformArtGenerationGuard>,
|
||||
normalize_package_png: bool,
|
||||
) -> Result<GeneratedPlatformArtAsset, String> {
|
||||
if require_slices && options.asset_kind != GameCreationAppAssetKind::IconSpritesheet {
|
||||
return Err("严格游戏切片生成只允许 icon-spritesheet 资产类型".to_string());
|
||||
@@ -2979,7 +3011,7 @@ async fn generate_platform_art_asset_with_runtime_options_and_retention_at(
|
||||
)?;
|
||||
recover_interrupted_strict_platform_art_transaction_locked_at(root, &recovery_lock)?;
|
||||
}
|
||||
let prepared = request_platform_art_asset_with_runtime_options_at(
|
||||
let mut prepared = request_platform_art_asset_with_runtime_options_at(
|
||||
root,
|
||||
prompt,
|
||||
briefs,
|
||||
@@ -2987,6 +3019,21 @@ async fn generate_platform_art_asset_with_runtime_options_and_retention_at(
|
||||
Some(runtime_context),
|
||||
)
|
||||
.await?;
|
||||
if normalize_package_png {
|
||||
normalize_art_package_png(&mut prepared.download)?;
|
||||
prepared.extension = "png".to_string();
|
||||
if options.asset_kind == GameCreationAppAssetKind::IconSpritesheet
|
||||
&& prepared.download.bytes.len()
|
||||
+ prepared
|
||||
.slices
|
||||
.iter()
|
||||
.map(|slice| slice.download.bytes.len())
|
||||
.sum::<usize>()
|
||||
> PLATFORM_ART_SPRITESHEET_TOTAL_DOWNLOAD_BYTES
|
||||
{
|
||||
return Err("美术包图集转码后与切片累计超过 32 MiB,已拒绝同步".to_string());
|
||||
}
|
||||
}
|
||||
prepared.validate_frozen_session()?;
|
||||
let lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
root,
|
||||
@@ -3248,6 +3295,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
|
||||
GameCreationAppAssetKind::Image => "image",
|
||||
GameCreationAppAssetKind::Character => "character",
|
||||
GameCreationAppAssetKind::PublicationMaterial => "publication-material",
|
||||
GameCreationAppAssetKind::Scene => "scene",
|
||||
_ => "spec",
|
||||
};
|
||||
let is_canonical_art_spritesheet =
|
||||
@@ -3291,6 +3339,25 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
|
||||
},
|
||||
}),
|
||||
)
|
||||
} else if options.asset_kind == GameCreationAppAssetKind::Scene {
|
||||
(
|
||||
"/api/external/v1/editor/scenes/generations",
|
||||
serde_json::json!({
|
||||
"sceneContent": generation_prompt,
|
||||
"stylePreset": "custom",
|
||||
"customStyle": prompt_text!("media.scene_style"),
|
||||
"aspectRatio": options.aspect_ratio,
|
||||
"imageSize": options.image_size,
|
||||
"assetLabel": options.asset_label,
|
||||
"projectId": canvas_context.project_id,
|
||||
"assetFolderId": canvas_context.asset_folder_id,
|
||||
"referenceImageSrcs": references.ordered.clone(),
|
||||
"canvasCompletion": {
|
||||
"title": options.asset_label,
|
||||
"placeholder": external_canvas_placeholder(&options.aspect_ratio),
|
||||
},
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"/api/external/v1/editor/images/generations",
|
||||
@@ -10301,6 +10368,81 @@ mod canvas_generation_tests {
|
||||
rgba_test_png_with_quality(alpha, CompressionType::Fast, FilterType::Adaptive)
|
||||
}
|
||||
|
||||
fn art_package_test_image(format: image::ImageFormat) -> CanvasResourceDownload {
|
||||
let image = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(
|
||||
2,
|
||||
2,
|
||||
image::Rgb([12, 34, 56]),
|
||||
));
|
||||
let mut output = std::io::Cursor::new(Vec::new());
|
||||
image.write_to(&mut output, format).expect("encode fixture");
|
||||
CanvasResourceDownload {
|
||||
bytes: output.into_inner(),
|
||||
media_type: format.to_mime_type().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_package_png_normalization_preserves_png_bytes_and_real_alpha() {
|
||||
let mut png = rgba_test_png(120);
|
||||
let original = png.bytes.clone();
|
||||
png.media_type = "application/octet-stream".to_string();
|
||||
normalize_art_package_png(&mut png).expect("validate existing PNG");
|
||||
assert_eq!(png.bytes, original);
|
||||
assert_eq!(png.media_type, "image/png");
|
||||
|
||||
let mut webp = std::io::Cursor::new(Vec::new());
|
||||
image::load_from_memory(&original)
|
||||
.unwrap()
|
||||
.write_to(&mut webp, image::ImageFormat::WebP)
|
||||
.unwrap();
|
||||
let mut webp = CanvasResourceDownload {
|
||||
bytes: webp.into_inner(),
|
||||
media_type: "image/webp".to_string(),
|
||||
};
|
||||
normalize_art_package_png(&mut webp).unwrap();
|
||||
let validated = validate_platform_art_png_bytes_with_limits(&webp.bytes, "test").unwrap();
|
||||
assert!(validated.has_transparent_pixels && validated.has_visible_pixels);
|
||||
assert_eq!(
|
||||
validated.pixel_sha256,
|
||||
validate_platform_art_png_bytes_with_limits(&original, "test")
|
||||
.unwrap()
|
||||
.pixel_sha256
|
||||
);
|
||||
|
||||
let mut jpeg = art_package_test_image(image::ImageFormat::Jpeg);
|
||||
normalize_art_package_png(&mut jpeg).unwrap();
|
||||
let (transparent, visible, _) = platform_art_spritesheet_alpha_contract(&jpeg).unwrap();
|
||||
assert!(!transparent, "JPEG 转码不能补造透明像素");
|
||||
assert!(visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_package_png_normalization_rejects_invalid_or_oversized_images() {
|
||||
let mut oversized = Vec::new();
|
||||
PngEncoder::new(&mut oversized)
|
||||
.write_image(
|
||||
&vec![0; (PLATFORM_ART_SPRITESHEET_MAX_DIMENSION as usize + 1) * 4],
|
||||
PLATFORM_ART_SPRITESHEET_MAX_DIMENSION + 1,
|
||||
1,
|
||||
ColorType::Rgba8.into(),
|
||||
)
|
||||
.unwrap();
|
||||
for bytes in [
|
||||
b"GIF89a".to_vec(),
|
||||
b"\x89PNG\r\n\x1a\n".to_vec(),
|
||||
vec![0xff, 0xd8, 0xff],
|
||||
oversized,
|
||||
vec![0; PLATFORM_ART_SPRITESHEET_SINGLE_DOWNLOAD_BYTES + 1],
|
||||
] {
|
||||
let mut download = CanvasResourceDownload {
|
||||
bytes,
|
||||
media_type: "image/png".to_string(),
|
||||
};
|
||||
assert!(normalize_art_package_png(&mut download).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
fn rgba_test_png_with_quality(
|
||||
alpha: u8,
|
||||
compression: CompressionType,
|
||||
@@ -10986,6 +11128,16 @@ mod canvas_generation_tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn retained_runtime_generation_retries_a_completed_stage_without_posting_again() {
|
||||
for format in [
|
||||
image::ImageFormat::Png,
|
||||
image::ImageFormat::Jpeg,
|
||||
image::ImageFormat::WebP,
|
||||
] {
|
||||
assert_art_package_recovers_completed_stage(format).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn assert_art_package_recovers_completed_stage(format: image::ImageFormat) {
|
||||
let temporary = tempfile::tempdir().expect("create retained runtime project");
|
||||
let root = temporary.path();
|
||||
init_local_game_project_at(root, "retained-runtime", "整包阶段恢复")
|
||||
@@ -10993,12 +11145,14 @@ mod canvas_generation_tests {
|
||||
let listener =
|
||||
std::net::TcpListener::bind("127.0.0.1:0").expect("bind retained download fixture");
|
||||
let base_url = format!("http://{}", listener.local_addr().expect("fixture address"));
|
||||
let image_url = format!("{base_url}/retained.png");
|
||||
let extension = format.extensions_str()[0];
|
||||
let image_url = format!("{base_url}/retained.{extension}");
|
||||
let signed_image_url = image_url.clone();
|
||||
let png = rgba_test_png(u8::MAX).bytes;
|
||||
let source = art_package_test_image(format);
|
||||
let source_bytes = source.bytes.clone();
|
||||
let (request_sender, request_receiver) = std::sync::mpsc::channel();
|
||||
let server = std::thread::spawn(move || {
|
||||
for _ in 0..4 {
|
||||
for request_index in 0..6 {
|
||||
let (mut stream, _) = listener.accept().expect("accept retained download");
|
||||
let request = read_test_http_request(&mut stream);
|
||||
request_sender
|
||||
@@ -11017,12 +11171,19 @@ mod canvas_generation_tests {
|
||||
)
|
||||
.expect("write retained signed URL");
|
||||
} else {
|
||||
// 首次下载有合法签名但内容损坏,重试必须仍使用同一已完成账本。
|
||||
let bytes = if request_index == 1 {
|
||||
&source.bytes[..16]
|
||||
} else {
|
||||
&source.bytes
|
||||
};
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
png.len()
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
source.media_type,
|
||||
bytes.len()
|
||||
)
|
||||
.and_then(|_| stream.write_all(&png))
|
||||
.and_then(|_| stream.write_all(bytes))
|
||||
.expect("write retained image");
|
||||
}
|
||||
}
|
||||
@@ -11081,7 +11242,7 @@ mod canvas_generation_tests {
|
||||
"resource": {
|
||||
"resourceId": "retained-resource",
|
||||
"projectId": "retained-canvas-project",
|
||||
"objectKey": "retained/spec.png",
|
||||
"objectKey": format!("retained/spec.{extension}"),
|
||||
"imageSrc": image_url
|
||||
},
|
||||
"taskId": "retained-task",
|
||||
@@ -11097,21 +11258,76 @@ mod canvas_generation_tests {
|
||||
"retained-runtime-key".to_string(),
|
||||
),
|
||||
async {
|
||||
for _ in 0..2 {
|
||||
generate_platform_art_asset_with_retained_runtime_options_at(
|
||||
let ledger_before =
|
||||
read_platform_art_generation_runtime_state(root, &runtime_context)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let failure = generate_art_package_asset_with_runtime_options_at(
|
||||
root,
|
||||
prompt,
|
||||
&[],
|
||||
&options,
|
||||
false,
|
||||
false,
|
||||
&runtime_context,
|
||||
)
|
||||
.await;
|
||||
assert!(failure.is_err());
|
||||
assert!(!root.join("assets/art-spec.png").exists());
|
||||
assert!(read_manifest_for_project(root).unwrap().assets.is_empty());
|
||||
let ledger_after =
|
||||
read_platform_art_generation_runtime_state(root, &runtime_context)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
platform_art_generation_runtime_idempotency_key(&ledger_before),
|
||||
platform_art_generation_runtime_idempotency_key(&ledger_after)
|
||||
);
|
||||
assert_eq!(
|
||||
platform_art_generation_runtime_retained_result(&ledger_before),
|
||||
platform_art_generation_runtime_retained_result(&ledger_after)
|
||||
);
|
||||
let mut installed_bytes = None;
|
||||
let mut installed_asset_id = None;
|
||||
for retain in [true, false] {
|
||||
generate_art_package_asset_with_runtime_options_at(
|
||||
root,
|
||||
prompt,
|
||||
&[],
|
||||
&options,
|
||||
false,
|
||||
retain,
|
||||
&runtime_context,
|
||||
)
|
||||
.await
|
||||
.expect("recover retained completed stage");
|
||||
assert!(
|
||||
let bytes = fs::read(root.join("assets/art-spec.png")).unwrap();
|
||||
let validated =
|
||||
validate_platform_art_png_bytes_with_limits(&bytes, "recovered").unwrap();
|
||||
assert_eq!((validated.width, validated.height), (2, 2));
|
||||
if format == image::ImageFormat::Png {
|
||||
assert_eq!(bytes, source_bytes);
|
||||
}
|
||||
if let Some(previous) = installed_bytes.replace(bytes) {
|
||||
assert_eq!(installed_bytes.as_ref().unwrap(), &previous);
|
||||
}
|
||||
let manifest = read_manifest_for_project(root).unwrap();
|
||||
assert_eq!(manifest.assets.len(), 1);
|
||||
let asset = &manifest.assets[0];
|
||||
assert_eq!(asset.local_path, "assets/art-spec.png");
|
||||
assert_eq!(asset.media_type, "image/png");
|
||||
assert_eq!(
|
||||
asset.source.resource_id.as_deref(),
|
||||
Some("retained-resource")
|
||||
);
|
||||
if let Some(previous) = installed_asset_id.replace(asset.id.clone()) {
|
||||
assert_eq!(asset.id, previous);
|
||||
}
|
||||
assert_eq!(
|
||||
read_platform_art_generation_runtime_state(root, &runtime_context)
|
||||
.expect("read retained stage ledger")
|
||||
.is_some()
|
||||
.is_some(),
|
||||
retain
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -11124,13 +11340,13 @@ mod canvas_generation_tests {
|
||||
.ok()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(requests.len(), 4);
|
||||
assert_eq!(requests.len(), 6);
|
||||
assert_eq!(
|
||||
requests
|
||||
.iter()
|
||||
.filter(|request| request.starts_with("GET /retained.png "))
|
||||
.filter(|request| request.starts_with(&format!("GET /retained.{extension} ")))
|
||||
.count(),
|
||||
2
|
||||
3
|
||||
);
|
||||
assert!(requests.iter().all(|request| request.starts_with("GET ")));
|
||||
assert!(requests.iter().all(|request| !request.starts_with("POST ")));
|
||||
@@ -13272,14 +13488,13 @@ mod canvas_generation_tests {
|
||||
let background = write_retained_stage(
|
||||
root,
|
||||
"run-background-canonical-and-user-references",
|
||||
"/api/external/v1/editor/images/generations",
|
||||
"/api/external/v1/editor/scenes/generations",
|
||||
serde_json::json!({
|
||||
"prompt": "保留账本参考合同",
|
||||
"kind": "spec",
|
||||
"assetKind": "game-background",
|
||||
"sceneContent": "保留账本参考合同",
|
||||
"stylePreset": "custom",
|
||||
"customStyle": "原创横屏 Web 游戏场景背景",
|
||||
"projectId": "test-canvas-project",
|
||||
"assetFolderId": "test-asset-folder",
|
||||
"generationInputs": { "artSpec": { "assetType": "background" } },
|
||||
"referenceImageSrcs": ["resource-icon-spec", "user-reference-1"],
|
||||
}),
|
||||
);
|
||||
|
||||
+24
@@ -735,6 +735,30 @@ pub(super) fn platform_art_generation_runtime_request_snapshot(
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
(generation_kind, reference_resource_ids)
|
||||
}
|
||||
"/api/external/v1/editor/scenes/generations" => {
|
||||
if json_string_field(&request_body, "sceneContent")
|
||||
.is_none_or(|value| value.trim().is_empty())
|
||||
{
|
||||
return Err("External Editor 场景生成账本请求缺少 sceneContent".to_string());
|
||||
}
|
||||
let reference_resource_ids = request_body
|
||||
.get("referenceImageSrcs")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.ok_or_else(|| {
|
||||
"External Editor 场景生成账本请求缺少 referenceImageSrcs".to_string()
|
||||
})?
|
||||
.iter()
|
||||
.map(|value| {
|
||||
value
|
||||
.as_str()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| "External Editor 场景生成账本引用资源 ID 无效".to_string())
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
("scene".to_string(), reference_resource_ids)
|
||||
}
|
||||
"/api/external/v1/editor/icon-spritesheets/generations" => {
|
||||
let reference_resource_id = json_string_field(&request_body, "referenceId")
|
||||
.or_else(|| json_string_field(&request_body, "referenceImageSrc"))
|
||||
|
||||
@@ -555,6 +555,7 @@ fn platform_art_generation_request_snapshot_is_valid(payload: &serde_json::Value
|
||||
endpoint,
|
||||
Some(
|
||||
"/api/external/v1/editor/images/generations"
|
||||
| "/api/external/v1/editor/scenes/generations"
|
||||
| "/api/external/v1/editor/icon-spritesheets/generations"
|
||||
)
|
||||
);
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
- [外部 OpenAPI 与 API Key 接入方案](./【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md)
|
||||
- [外部 MCP 语义工具说明与参数设计](./technical/【技术方案】外部MCP语义工具说明与参数设计-2026-09-23.md):15 个新增语义工具与全部原工具并存,复用现有 External API;包含工具说明、action、参数、幂等和兼容合同。
|
||||
- [External v1 OpenAPI](./openapi/genarrative-external-v1.openapi.json):公开 HTTP 契约唯一机器可读来源。
|
||||
- [External v1 游戏场景生成路由](./technical/【技术方案】ExternalV1游戏场景生成路由-2026-09-24.md):external v1 结构化场景生成专用路由与 AGC 美术包背景阶段迁移合同。
|
||||
|
||||
## AI 游戏创作与 Agent Runtime
|
||||
|
||||
|
||||
@@ -1052,7 +1052,60 @@
|
||||
"$ref": "#/components/responses/UpstreamError"
|
||||
}
|
||||
},
|
||||
"description": "支持普通生图、规范图、角色图、快速编辑参考图、UI 设计图和宣发素材生成。kind 可取 spec、character、quick-edit、ui-design、publication-material。"
|
||||
"description": "支持普通生图、规范图、角色图、快速编辑参考图、UI 设计图和宣发素材生成。kind 可取 spec、character、quick-edit、ui-design、publication-material。游戏场景不接受本接口的 kind/assetKind = scene,必须使用 /api/external/v1/editor/scenes/generations 提交结构化场景意图。"
|
||||
}
|
||||
},
|
||||
"/api/external/v1/editor/scenes/generations": {
|
||||
"post": {
|
||||
"x-mcp-excluded": true,
|
||||
"tags": ["Editor Images"],
|
||||
"operationId": "generateExternalEditorScene",
|
||||
"summary": "生成编辑器游戏场景(结构化场景意图)",
|
||||
"description": "只接受结构化场景意图:sceneContent + stylePreset(custom 时必须提供 customStyle),完整 Provider Prompt 由服务端组装,不接受调用方提交的完整 prompt。入队后按 kind/assetKind = scene 持久化,产物保存 scene.generate V2 配方;队列、计费、资源入库与画布写回与站内场景路由一致。",
|
||||
"security": [
|
||||
{
|
||||
"ExternalApiKey": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/IdempotencyKey"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/EditorSceneGenerationRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"202": {
|
||||
"description": "生成任务已持久化入队",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ExternalEditorGenerationSubmissionResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#/components/responses/BadRequest"
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/components/responses/Forbidden"
|
||||
},
|
||||
"502": {
|
||||
"$ref": "#/components/responses/UpstreamError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/external/v1/editor/images/edits": {
|
||||
@@ -2965,6 +3018,69 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"EditorSceneGenerationRequest": {
|
||||
"type": "object",
|
||||
"required": ["sceneContent", "stylePreset"],
|
||||
"properties": {
|
||||
"sceneContent": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "画面内容(结构化场景意图主体)。纯空白在入队前返回 400。"
|
||||
},
|
||||
"stylePreset": {
|
||||
"type": "string",
|
||||
"enum": ["anime", "watercolor", "flat", "stop-motion", "custom"],
|
||||
"description": "视觉风格预设。custom 时必须同时提供非空 customStyle,否则返回 400。"
|
||||
},
|
||||
"customStyle": {
|
||||
"type": ["string", "null"],
|
||||
"description": "自定义画风描述,仅 stylePreset = custom 时使用。"
|
||||
},
|
||||
"model": {
|
||||
"type": ["string", "null"],
|
||||
"description": "图片模型,省略时使用服务端默认场景模型。"
|
||||
},
|
||||
"aspectRatio": {
|
||||
"type": ["string", "null"],
|
||||
"default": "16:9"
|
||||
},
|
||||
"imageSize": {
|
||||
"type": ["string", "null"],
|
||||
"default": "1K"
|
||||
},
|
||||
"referenceImageSrcs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "可选参考图,沿用普通图片生成的参考图口径。"
|
||||
},
|
||||
"projectId": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"generationInputs": {
|
||||
"$ref": "#/components/schemas/JsonValue",
|
||||
"description": "场景配方由服务端重建;仅保留 source 精确等于 ai-game-creator-client 的客户端来源标记,用于选择 AGC 队列结果与幂等命名空间。调用方 fields、action 和引用 provenance 不会覆盖服务端配方。"
|
||||
},
|
||||
"assetFolderId": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"assetLabel": {
|
||||
"type": ["string", "null"],
|
||||
"description": "省略或纯空白时统一使用「游戏场景」。"
|
||||
},
|
||||
"canvasCompletion": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/EditorCanvasGenerationCompletion"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"EditorImageGenerationRequest": {
|
||||
"type": "object",
|
||||
"required": ["prompt"],
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# 【实施计划】External v1 游戏场景生成路由
|
||||
|
||||
| 字段 | 值 |
|
||||
| --- | --- |
|
||||
| Milestone | `docs/project-memory/plans/【里程碑】ExternalV1游戏场景生成路由-2026-09-24.md` |
|
||||
| Status | ready |
|
||||
| Owner | Agent |
|
||||
|
||||
## 修改边界
|
||||
|
||||
- 允许修改:
|
||||
- `server-rs/crates/api-server/src/editor_project.rs`(抽取场景组装/入队共享逻辑)
|
||||
- `server-rs/crates/api-server/src/external_editor_api.rs`(新 handler)
|
||||
- `server-rs/crates/api-server/src/modules/external_api.rs`(路由表与契约矩阵)
|
||||
- `docs/openapi/genarrative-external-v1.openapi.json` 与对应契约测试
|
||||
- AGC `src-tauri/src/agent/generation/canvas_generation.rs`、`src/agent/direct_runtime/mod.rs`、`src/agent/generation/external_generation_state.rs`、`src/agent/runtime_driver/recovery_scan.rs`、`src/project/manifest.rs`(背景阶段路由与身份口径)
|
||||
- `.codex/skills/genarrative-external-editor-api/references/`(外部 API 说明)
|
||||
- 明确不修改:站内场景生成规则、通用图片入口校验、SpacetimeDB schema、计费、动画/抠图链路(issue #495);站内入口补齐现有 AGC 账号模式的幂等与结果协议。
|
||||
|
||||
## 实现顺序
|
||||
|
||||
1. 后端:把 `generate_editor_scene` 的「payload → `EditorImageGenerationRequest`」段抽成 `pub(crate)` 共享函数(含 options 标准化、Prompt 组装、generationInputs 重建、assetLabel 规范化),站内 handler 改为调用它。
|
||||
2. 后端:`external_editor_api.rs` 新增 `generate_external_editor_scene`:`require_scope(SCOPE_EDITOR_IMAGE_GENERATE)` + `require_idempotency_key` + 共享函数 + `enqueue_editor_image_generation_for_owner` + `external_generation_accepted_response`。
|
||||
3. 后端:`modules/external_api.rs` 路由表与 `PROTECTED_ROUTES` 各加 `/api/external/v1/editor/scenes/generations`(POST)。
|
||||
4. 后端:补单测/契约测试(鉴权、参数 400、幂等重放、与站内同 Prompt);同步 OpenAPI JSON 与契约测试。
|
||||
5. 客户端:`canvas_generation.rs` 请求构造为 Scene 拆专属分支——新路由 + `EditorSceneGenerateRequest` 形状 body(`sceneContent` = 现有场景 prompt 文本,`stylePreset = "custom"`,`customStyle` = 现有风格描述文案,其余字段沿用);修 Scene 保留账本匹配器(新路由 + `sceneContent` 口径),统一 `game-background`/`scene` 不一致。
|
||||
6. 客户端:身份/对账常量迁移——`direct_runtime/mod.rs` 背景身份三元组路由与 kind、`manifest.rs` 期望路由、`recovery_scan.rs`、`external_generation_state.rs`;保持旧路由登记可读。
|
||||
7. 文档:外部 API Skill references 补新路由;运行编码与 diff 检查。
|
||||
8. 审查修复:站内场景 handler 读取并透传可选幂等键;共享组装只保留精确 AGC 来源标记。增加非法键路由测试与场景 payload 到队列可下载结果的回归测试,同步 OpenAPI 元数据说明。
|
||||
|
||||
## 验证命令
|
||||
|
||||
1. `cargo test -p api-server`(重点:external_editor_api、editor_project 场景相关)
|
||||
2. `cargo test -p shared-contracts`
|
||||
3. AGC:`cargo test` 于 `apps/ai-game-creator-shell/src-tauri`(定向:canvas_generation、direct_runtime、recovery_scan、manifest)
|
||||
4. `npm run check:encoding`、`git diff --check`、`npm run check:doc-index`
|
||||
5. smoke:`npm run dev:api-server` 后 `curl /healthz`,再用测试 Key POST 新路由验证受理与重放。
|
||||
|
||||
## 风险与回滚点
|
||||
|
||||
- 共享逻辑抽取改变站内路由行为:以「相同输入 Prompt 逐字一致」单测为门禁;回滚点为抽取提交本身。
|
||||
- 旧路由登记的身份记录在新常量下读不到:保留旧路由 + `spec` 的读取分支,仅新增写入走新路由;回滚点为常量迁移提交。
|
||||
- 幂等重放依赖现有外部生成重放链路(issue #495 问题一不涉及图片生成入口,实测正常);若 smoke 暴露重放异常,停止合并并记录。
|
||||
@@ -0,0 +1,46 @@
|
||||
# 【里程碑】External v1 游戏场景生成路由
|
||||
|
||||
| 字段 | 值 |
|
||||
| --- | --- |
|
||||
| Version | 1.0 |
|
||||
| Status | proposed |
|
||||
| Date | 2026-09-24 |
|
||||
| Parent Spec | `docs/technical/【技术方案】ExternalV1游戏场景生成路由-2026-09-24.md` |
|
||||
|
||||
## 背景与触发
|
||||
|
||||
平台自 `72f268e08`(游戏场景需求 v1.0)起在通用图片生成入口拒绝 `kind = scene` / `assetKind = scene`,要求场景必须走结构化专用路由;但 external v1 没有场景路由,AGC 客户端陶泥儿美术包的背景阶段仍向 `/api/external/v1/editor/images/generations` 提交 `assetKind = scene`,被平台参数校验拒绝(HTTP 400),新项目美术包无法生成(实测产物 `gameagent-f91a64dd`)。同时发现背景阶段保留账本匹配器期望 `assetKind = "game-background"`,与实际请求线上的 `"scene"` 不一致,中断恢复匹配失效。
|
||||
|
||||
## 目标
|
||||
|
||||
1. external v1 新增 `POST /api/external/v1/editor/scenes/generations`,与站内场景路由共享同一场景 Prompt 组装与入队实现,鉴权、幂等、计费、持久化语义与现役外部生成入口一致。
|
||||
2. AGC 美术包背景阶段迁移到新路由,以结构化场景意图提交,`reuse-or-create` 与 `regenerate` 均恢复可用,出图风格与尺寸不变。
|
||||
3. 背景阶段的路由、manifest 身份登记、保留账本匹配与恢复扫描常量整圈对齐到新路由与实际线上值;历史登记保持可读。
|
||||
|
||||
## 不在本里程碑内
|
||||
|
||||
- 保持站内场景生成规则,补齐账号模式幂等与结果协议;不改通用图片入口的 scene 拒绝校验。
|
||||
- 不改 SpacetimeDB schema、队列类型、计费档位。
|
||||
- 不迁移历史背景登记数据。
|
||||
- 不处理 issue #495 的两个既有问题。
|
||||
|
||||
## 依赖与前置条件
|
||||
|
||||
- `shared-contracts`:`EditorSceneGenerateRequest` 已存在,直接复用。
|
||||
- `api-server`:站内 `generate_editor_scene` 的组装与入队逻辑抽取为共享实现。
|
||||
- AGC `src-tauri`:背景阶段请求构造、保留账本与身份常量迁移。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [x] 账号场景入口透传可选幂等键、入队前拒绝非法键;场景配方保留 AGC 来源标记且队列完成结果包含可下载资源(路由校验及组装到结果序列化的定向测试)。
|
||||
- [ ] 新路由契约测试通过:路由矩阵与鉴权、参数 400、同键重放返回原任务、同键不同请求 409。
|
||||
- [ ] 外部场景路由与站内路由对相同输入产出相同的后端 Prompt 与入队 payload(共享实现单测对照)。
|
||||
- [ ] `docs/openapi/genarrative-external-v1.openapi.json` 与实现一致,相关契约检查通过。
|
||||
- [ ] 客户端定向测试:背景阶段请求命中新路由且为结构化字段;保留账本匹配器与实际请求口径一致;`reuse-or-create` 与 `regenerate` 路径回归通过。
|
||||
- [ ] 本地真实栈 smoke:`npm run dev:api-server` + 测试 Key 下美术包背景阶段受理成功(Provider 出图按环境可用性记录为已验证或未验证)。
|
||||
|
||||
## 证据要求
|
||||
|
||||
- 自动化:`cargo test -p api-server`(契约与场景相关单测)、AGC `src-tauri` 定向测试、`npm run check:encoding`、`git diff --check`。
|
||||
- 运行时:本地 api-server 受理 smoke。
|
||||
- 边界:鉴权 401/403、幂等重放、参数失败关闭。
|
||||
@@ -1,5 +1,17 @@
|
||||
# 踩坑与排障记录
|
||||
|
||||
## 美术包固定 PNG 路径与平台返回格式不一致
|
||||
|
||||
- 平台生成成功仍可能返回 JPEG/WebP;直接按 `assets/art-spec.png` 等固定路径保存会在扩展名校验时失败,图片尚未落盘但已付费结果账本仍存在,不能据 manifest 为空认定没有生成结果。
|
||||
- 美术包专用入口在本地提交前有界解码并归一化为 PNG,原 PNG 字节不变;扩展名、MIME、内容摘要和恢复比较必须使用同一份最终字节。普通图片工具和独立切片的格式合同不随之放宽,转码也不能代替真实透明度校验。
|
||||
- 失败后保留原账本并按冻结意图恢复,不能删除账本后重新生成。详情见 [AGC 美术包合同](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md#2026-08-23-direct-codex-美术包显式重生成与切片投影)。
|
||||
|
||||
## 2026-09-24 AGC 生成路由迁移必须核对账号队列契约
|
||||
|
||||
- AGC 普通账号会把 external editor 路由映射到站内入口;只验证 API Key 路由不足以证明客户端链路可用。
|
||||
- 站内入口必须读取客户端持久化的 `Idempotency-Key`,场景配方重建须保留精确的 `generationInputs.source = ai-game-creator-client` 标记。否则可能按请求 ID 重复入队,且普通队列 consumer 不保存客户端轮询所需的可下载 `result`。
|
||||
- 场景其余执行字段和引用身份仍由服务端重建,不整体信任调用方 `generationInputs`。验证应覆盖来源标记经过组装、队列清理和结果序列化的完整纯逻辑链路,以及账号路由的幂等键校验。
|
||||
|
||||
## 同一祖先下的多个项目会各自弹一次 UAC
|
||||
|
||||
- **现象**:AGC 启动页一次挂载出现多个叠在一起的 UAC 提权弹窗;用户点「否」后仍会被再问一次。
|
||||
|
||||
@@ -1493,6 +1493,9 @@ game-project/
|
||||
|
||||
## 2026-08-23 Direct Codex 美术包显式重生成与切片投影
|
||||
|
||||
- 标准美术包在客户端将规范图、背景图和主图集统一保存为 PNG:下载仍校验来源、声明类型与文件签名,随后按真实内容接受 PNG/JPEG/WebP,在已有 20 MiB、4096 像素单边和 64 MiB 解码内存限制内完整解码。有效 PNG 原样保留,JPEG/WebP 编码成 PNG,最终内容也不得超过 20 MiB;本地媒体类型固定为 `image/png`。转码不补造透明度,主图集与独立切片继续执行真实 alpha、可见像素、尺寸和唯一性合同;平台独立切片仍须为 PNG。此行为仅属于美术包,普通图片工具的指定扩展名合同不变。
|
||||
- 美术包的转码在项目提交锁和本地写入之前完成,文件摘要、已安装结果识别、替换恢复和 manifest 登记均使用最终 PNG。转码失败保留原生成账本与平台身份,同冻结意图重试重新读取已有结果,不提交新的付费生成;不改变请求快照、幂等身份、固定资源路径或旧 PNG 包的复用方式,无数据迁移。验收覆盖 JPEG/WebP 转码、PNG 字节不变、损坏/超限拒绝、真实透明度以及已有结果重复恢复零生成 POST;客户端真实 Provider 的整包验证单独记录。
|
||||
- 转码自动化验收由 `canvas_generation_tests` 的格式/边界用例和 `retained_runtime_generation_retries_a_completed_stage_without_posting_again` 本地 HTTP 夹具覆盖:PNG/JPEG/WebP 均先下载损坏内容,再从同一已完成账本恢复两次,核对最终 PNG、稳定本地 asset ID、Canvas 来源身份、账本保留/清理及零生成 POST;替换与补偿继续由现有图集事务和 Direct 重生成用例覆盖。真实 Provider 的客户端整包效果不由这些夹具替代。
|
||||
- `agc_tools.taonier_prepare_game_art` 的请求模式固定为 `reuse-or-create | regenerate`。缺省使用 `reuse-or-create`,完整且可信的本地包继续零付费复用;只有用户显式要求重做、替换或切换视觉风格时使用 `regenerate`,并绕过完整包短路,按规范图、背景图、透明图集顺序生成和原位替换。`regenerate` 的旧包前置门只要求规范图和背景图已经可下载、可解码、来源一致且存在可信 manifest 登记,使两项旧字节与登记可以完整 rollback;历史主图集、私有回执、公开清单或 canonical 切片可以缺失。客户端必须把八个严格路径的实际存在性和摘要,以及其中受管顶层 asset identity,逐项冻结为 `Present/Some` 或 `Missing/None`,不能把缺失状态伪造成空文件或虚假登记。规范图或背景图任一缺失或身份无效时才失败关闭并提示先用 `reuse-or-create` 修复基础素材。
|
||||
- 显式重生成不放宽 External Editor 幂等与未知态边界。固定阶段已有 `prepared / accepted` 账本时,本次生成 prompt 必须与账本冻结 prompt 一致才可恢复;不一致返回 `platform-generation-result-unknown` 并保留原 `Idempotency-Key / operationId` 对账,禁止把旧结果解释为新意图,也禁止另起付费 POST。
|
||||
- 整包重生成在首个付费阶段前建立客户端私有 v4 workflow,状态固定为 `resetting / in-progress / compensating / completed`,并同时绑定意图摘要和客户端稳定 `clientTurnId`。专用 `direct-codex-art` 跨进程执行锁覆盖整个付费重生成生命周期,但不持有通用项目写锁等待网络。规范图和背景图替换后立即持久化旧字节、旧 manifest entry 与本轮双 CAS 锚点;任一后续阶段失败时进入 `compensating`,可在进程重启后继续恢复旧文件及旧登记。已成功阶段的生成账本继续保留;`completed` 持久化经脱敏和数量 / 长度限制的完整工具结果,同一 `clientTurnId` 回包丢失时必须等值重放且零新 POST。新的显式用户回合先持久化目标回合所有的 `resetting` workflow,再清理上一轮三阶段账本并转回 `in-progress`,任一崩溃点都不得出现无 workflow 窗口。只有尚无任何阶段账本且无替换锚点的孤立 `in-progress` 空壳允许被新回合原子接管;其余身份冲突、未知版本以及缺少新恢复字段的旧 v2/v3 workflow 均失败关闭,不能用 serde 缺省值把旧状态升级成可执行状态。
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# 【技术方案】External v1 游戏场景生成路由
|
||||
|
||||
更新时间:`2026-09-24`
|
||||
|
||||
## 目标
|
||||
|
||||
为 `/api/external/v1` 补齐游戏场景生成的结构化专用路由,使 AGC 客户端(陶泥儿美术包背景阶段)在平台收紧 `kind = scene` / `assetKind = scene` 边界校验后仍有合规的场景生成入口:
|
||||
|
||||
```text
|
||||
AGC 美术包背景阶段(结构化场景意图)
|
||||
-> POST /api/external/v1/editor/scenes/generations
|
||||
-> 后端确定性组装场景 Prompt(与站内场景路由同一实现)
|
||||
-> 现有 editor_image_generation 队列与 Worker
|
||||
-> 现有计费、幂等、失败、资源持久化与 canvasCompletion
|
||||
```
|
||||
|
||||
同时修复 AGC 客户端背景阶段保留账本匹配口径与实际请求不一致的既有隐患。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 保持站内 `/api/editor/scenes/generations` 的场景字段、Prompt 和计费规则;补齐 AGC 账号模式所需的可选幂等键与队列结果协议。
|
||||
- 不放松通用 `/api/editor/images/generations` 与 `/api/external/v1/editor/images/generations` 对 `kind = scene` / `assetKind = scene` 的拒绝。
|
||||
- 不新增场景 Worker、任务表、计费档位或 SpacetimeDB schema。
|
||||
- 不改变美术包背景图的出图风格与尺寸(16:9 / 1K)。
|
||||
- 不处理 issue #495 的抠图重放 409 与动画 compact 字段问题(独立排期)。
|
||||
|
||||
## 入口与边界
|
||||
|
||||
- 系统入口:AGC 客户端 Direct 美术包流程的背景阶段(`reuse-or-create` 与 `regenerate` 均经过)。
|
||||
- 涉及模块:`api-server`(external v1 路由与场景 Prompt 组装)、`shared-contracts`(DTO 复用)、AGC `src-tauri`(请求构造、保留账本、身份对账与恢复扫描常量)。
|
||||
- 正式状态来源:`external_generation_job` 队列记录与项目 manifest 登记,与现役外部生成入口一致。
|
||||
|
||||
## 必须成立的行为
|
||||
|
||||
### 正常路径
|
||||
|
||||
1. 新路由 `POST /api/external/v1/editor/scenes/generations` 接受与站内场景路由相同的 `EditorSceneGenerateRequest` 字段(`sceneContent`、`stylePreset`、`customStyle?`、`model?`、`aspectRatio?`、`imageSize?`、`referenceImageSrcs?`、`projectId?`、`generationInputs?`、`assetFolderId?`、`assetLabel?`、`canvasCompletion?`),不接受调用方组装后的完整 `prompt`。
|
||||
2. 场景 Prompt 由后端经与站内路由完全相同的组装实现生成;两路由只共享这一份组装逻辑。
|
||||
3. 鉴权复用现有 `editor:image-generate` scope;与现役外部生成入口一样强制 `Idempotency-Key` 请求头。
|
||||
4. 受理响应与现役外部生成入口同形(operationId 异步受理信封),轮询继续走 `/api/external/v1/generations/{operation_id}`。
|
||||
5. 入队后 `kind = scene`、`assetKind = scene`,队列类型、Worker、计费与持久化与站内场景路由一致;队列标题与任务摘要口径不变。
|
||||
6. AGC 美术包背景阶段以 `stylePreset = custom` + `customStyle` 承载现有风格描述,`sceneContent` 承载 brief 衍生的画面内容,出图风格与比例不因迁移改变。
|
||||
7. 普通账号自动映射到 `/api/editor/scenes/generations`,该入口读取可选 `Idempotency-Key` 并传给现有队列;未提供时保留站内按请求 ID 入队的行为。场景组装仅保留 `generationInputs.source = ai-game-creator-client` 这一精确标记,其余配方字段仍由服务端重建。该标记让账号任务沿用 AGC 幂等命名空间和包含可下载 `result` 的队列结果,轮询走 `/api/runtime/external-generation/jobs/{operation_id}`。
|
||||
|
||||
### 失败、重试与幂等
|
||||
|
||||
1. 缺少 `sceneContent`、非法 `stylePreset`、自定义风格缺少 `customStyle` 等参数错误返回 400,与站内路由同语义。
|
||||
2. 缺少或非法幂等键、越权 scope 的拒绝语义与现役外部生成入口一致。
|
||||
3. 同一幂等键 + 同一请求重放返回原任务,不新建任务、不重复扣费;同键不同请求返回 409。
|
||||
4. Provider 失败、取消与 lease 耗尽沿用现有扣退费语义。
|
||||
5. 账号场景入口拒绝非法幂等键(400);同键重放复用现有队列幂等实现。来源标记不参与权限授予,任务归属仍来自已认证用户。
|
||||
|
||||
### 权限、归属与数据边界
|
||||
|
||||
1. 资源归属、项目绑定与素材文件夹解析沿用现役外部生成入口的 owner 口径。
|
||||
2. 任务摘要只展示 `generationInputs.fields` 的「画面内容」,不把后端完整 Prompt 暴露到任务侧栏。
|
||||
3. 场景产物以 `assetKind = scene` 持久化并保存 `scene.generate` V2 配方,与站内产物口径一致。
|
||||
|
||||
## 契约与迁移
|
||||
|
||||
- API / DTO / OpenAPI:新增 external v1 场景路由,DTO 复用 `shared-contracts` 的 `EditorSceneGenerateRequest`;同一次变更同步 `docs/openapi/genarrative-external-v1.openapi.json` 与契约测试(路由矩阵、鉴权、参数 400、幂等重放)。
|
||||
- SpacetimeDB schema / migration / bindings:不变。
|
||||
- 兼容与迁移策略:AGC 客户端背景阶段的路由、manifest 身份登记、保留账本匹配与恢复扫描常量整圈迁移到新路由;历史已登记的背景身份(旧通用路由 + `kind = spec`)保持可读,不做数据迁移。
|
||||
|
||||
## 验收标准与证据
|
||||
|
||||
| 条款 | 验收方式 | 证据 |
|
||||
| ---- | -------- | ---- |
|
||||
| 账号入口幂等键校验与 AGC 下载结果 | 非法键路由测试、场景来源到结果序列化测试、共享队列幂等命名空间测试 | `cargo test --locked -p api-server scene`(18 项)、`editor_generation_queue::tests`(19 项)、`external`(158 项)通过;本地启动因 SpacetimeDB 连接拒绝未通过健康检查,真实 Provider 出图与账号同键重放尚未联调 |
|
||||
| 新路由受理/参数校验/鉴权/幂等重放 | api-server 契约测试与单测 | 待补 |
|
||||
| 与站内路由同一 Prompt 组装结果 | 共享实现的单测对照 | 待补 |
|
||||
| OpenAPI 与实现一致 | 契约测试 + `check:openapi` 类门禁 | 待补 |
|
||||
| 美术包背景端到端(reuse-or-create / regenerate) | 客户端定向测试 + 本地真实栈 smoke | 待补 |
|
||||
| 中断恢复:保留账本匹配与身份对账 | 客户端定向测试 | 待补 |
|
||||
|
||||
## 未决问题与决策
|
||||
|
||||
- `stylePreset` 取舍:已决策——AGC 美术包背景固定 `custom` + `customStyle` 承载现有风格文案,不绑定预设风格(2026-09-24,与用户确认)。
|
||||
@@ -277,13 +277,11 @@ test('hook fixtures do not mutate the calling linked worktree or its index', ()
|
||||
writeFileSync(join(worktree, 'sentinel.txt'), 'unstaged\n');
|
||||
const gitDir = git(worktree, 'rev-parse', '--absolute-git-dir').trim();
|
||||
const indexPath = join(gitDir, 'index');
|
||||
// `git status` 会刷新并重写索引里的 stat 缓存(macOS 与部分 git 版本上字节必然变化),
|
||||
// 所以先跑完会写索引的探针再取快照,否则断言的是探针自身的副作用而不是被测行为。
|
||||
const before = {
|
||||
refs: git(outerRepo, 'show-ref'),
|
||||
config: readFileSync(join(outerRepo, '.git', 'config'), 'utf8'),
|
||||
status: git(worktree, 'status', '--porcelain'),
|
||||
index: readFileSync(indexPath),
|
||||
status: git(worktree, 'status', '--porcelain'),
|
||||
};
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
|
||||
@@ -2749,6 +2749,44 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn editor_scene_generation_rejects_invalid_idempotency_key_before_queueing() {
|
||||
let state = AppState::new(AppConfig {
|
||||
external_generation_mode: ExternalGenerationMode::Queue,
|
||||
..AppConfig::default()
|
||||
})
|
||||
.expect("state should build");
|
||||
let seed_user = seed_phone_user_with_password(&state, "13800138232", TEST_PASSWORD).await;
|
||||
let token = sign_test_user_token(&state, &seed_user, "sess_editor_scene_idempotency");
|
||||
state.fail_test_editor_generation_enqueue();
|
||||
let app = build_router(state.clone());
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/editor/scenes/generations")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.header("idempotency-key", "contains space")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"sceneContent": "雨夜小镇",
|
||||
"stylePreset": "anime",
|
||||
"generationInputs": { "source": "ai-game-creator-client" },
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should complete");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(state.test_editor_generation_enqueue_attempts(), 0);
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
assert!(String::from_utf8_lossy(&body).contains("Idempotency-Key"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn editor_scene_generation_rejects_inline_data_url_before_queueing() {
|
||||
let state = AppState::new(AppConfig {
|
||||
|
||||
@@ -826,6 +826,19 @@ mod tests {
|
||||
editor_generation_idempotency_namespace(&game_creator),
|
||||
GAME_CREATOR_CLIENT_GENERATION_DEDUPE_PREFIX
|
||||
);
|
||||
let scene = crate::editor_project::build_editor_scene_image_generation_payload(
|
||||
serde_json::from_value(json!({
|
||||
"sceneContent": "雨夜小镇",
|
||||
"stylePreset": "anime",
|
||||
"generationInputs": { "source": GAME_CREATOR_CLIENT_GENERATION_SOURCE },
|
||||
}))
|
||||
.expect("scene request should deserialize"),
|
||||
)
|
||||
.expect("scene payload should build");
|
||||
assert_eq!(
|
||||
editor_generation_idempotency_namespace(&scene),
|
||||
GAME_CREATOR_CLIENT_GENERATION_DEDUPE_PREFIX,
|
||||
);
|
||||
assert_eq!(
|
||||
editor_generation_idempotency_namespace(&ordinary),
|
||||
EXTERNAL_API_GENERATION_DEDUPE_PREFIX
|
||||
|
||||
@@ -2658,12 +2658,23 @@ fn build_editor_scene_generation_inputs(
|
||||
{
|
||||
fields.push(json!({ "id": "customStyle", "title": "自定义画风", "value": custom_style }));
|
||||
}
|
||||
json!({
|
||||
let mut inputs = json!({
|
||||
"version": 2,
|
||||
"action": "scene.generate",
|
||||
"fields": fields,
|
||||
"references": references,
|
||||
})
|
||||
});
|
||||
// AGC 账号任务依赖来源标记选择幂等命名空间和可下载的队列结果;其余字段仍由服务端重建。
|
||||
if payload
|
||||
.generation_inputs
|
||||
.as_ref()
|
||||
.and_then(|inputs| inputs.get("source"))
|
||||
.and_then(Value::as_str)
|
||||
== Some(GAME_CREATOR_CLIENT_GENERATION_SOURCE)
|
||||
{
|
||||
inputs["source"] = json!(GAME_CREATOR_CLIENT_GENERATION_SOURCE);
|
||||
}
|
||||
inputs
|
||||
}
|
||||
|
||||
fn normalize_editor_scene_optional_text<'a>(value: Option<&'a str>, default: &'a str) -> &'a str {
|
||||
@@ -2692,13 +2703,9 @@ fn normalize_editor_scene_asset_label(asset_label: Option<String>) -> String {
|
||||
resolve_editor_generated_asset_label(asset_label, "游戏场景")
|
||||
}
|
||||
|
||||
pub async fn generate_editor_scene(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<EditorSceneGenerateRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let Json(payload) = parse_editor_generation_json_payload(payload)?;
|
||||
pub(crate) fn build_editor_scene_image_generation_payload(
|
||||
payload: EditorSceneGenerateRequest,
|
||||
) -> Result<EditorImageGenerationRequest, AppError> {
|
||||
let generation_options = normalize_editor_scene_generation_options(
|
||||
payload.model.as_deref(),
|
||||
payload.aspect_ratio.as_deref(),
|
||||
@@ -2717,8 +2724,7 @@ pub async fn generate_editor_scene(
|
||||
}))
|
||||
})?;
|
||||
let generation_inputs = build_editor_scene_generation_inputs(&payload, &generation_options);
|
||||
let caller = EditorGenerationCaller::from_authenticated(&authenticated);
|
||||
let image_payload = EditorImageGenerationRequest {
|
||||
Ok(EditorImageGenerationRequest {
|
||||
prompt,
|
||||
size: None,
|
||||
kind: Some("scene".to_string()),
|
||||
@@ -2737,14 +2743,27 @@ pub async fn generate_editor_scene(
|
||||
asset_label: Some(normalize_editor_scene_asset_label(payload.asset_label)),
|
||||
source_resource_id: None,
|
||||
canvas_completion: payload.canvas_completion,
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn generate_editor_scene(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
headers: HeaderMap,
|
||||
payload: Result<Json<EditorSceneGenerateRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let Json(payload) = parse_editor_generation_json_payload(payload)?;
|
||||
let idempotency_key = optional_editor_idempotency_key(&headers)?;
|
||||
let image_payload = build_editor_scene_image_generation_payload(payload)?;
|
||||
let caller = EditorGenerationCaller::from_authenticated(&authenticated);
|
||||
if !state.config.external_generation_mode.is_inline() {
|
||||
let queue_job = enqueue_editor_image_generation_for_owner(
|
||||
&state,
|
||||
&request_context,
|
||||
&caller,
|
||||
image_payload,
|
||||
None,
|
||||
idempotency_key,
|
||||
)
|
||||
.await?;
|
||||
return Ok(json_success_body(
|
||||
@@ -13269,6 +13288,98 @@ mod tests {
|
||||
thread,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn scene_image_generation_payload_uses_shared_scene_contract() {
|
||||
let payload = build_editor_scene_image_generation_payload(EditorSceneGenerateRequest {
|
||||
scene_content: "雨夜小镇的街道".to_string(),
|
||||
style_preset: "custom".to_string(),
|
||||
custom_style: Some("像素水彩混合".to_string()),
|
||||
model: None,
|
||||
aspect_ratio: None,
|
||||
image_size: None,
|
||||
reference_image_srcs: vec!["resource-ref-1".to_string()],
|
||||
project_id: Some("project-1".to_string()),
|
||||
generation_inputs: None,
|
||||
asset_folder_id: Some("folder-1".to_string()),
|
||||
asset_label: Some(" ".to_string()),
|
||||
canvas_completion: None,
|
||||
})
|
||||
.expect("合法场景意图必须组装成功");
|
||||
|
||||
assert_eq!(payload.kind.as_deref(), Some("scene"));
|
||||
assert_eq!(payload.asset_kind.as_deref(), Some("scene"));
|
||||
assert_eq!(payload.aspect_ratio.as_deref(), Some("16:9"));
|
||||
assert_eq!(payload.image_size.as_deref(), Some("1K"));
|
||||
assert_eq!(payload.asset_label.as_deref(), Some("游戏场景"));
|
||||
assert_eq!(
|
||||
payload.reference_image_srcs.as_deref(),
|
||||
Some(["resource-ref-1".to_string()].as_slice())
|
||||
);
|
||||
assert!(payload.prompt.contains("雨夜小镇的街道"));
|
||||
assert!(payload.prompt.contains("像素水彩混合"));
|
||||
let generation_inputs = payload
|
||||
.generation_inputs
|
||||
.as_ref()
|
||||
.expect("场景 generationInputs 必须存在");
|
||||
assert_eq!(generation_inputs["version"], json!(2));
|
||||
assert_eq!(generation_inputs["action"], json!("scene.generate"));
|
||||
assert_eq!(
|
||||
generation_inputs["references"],
|
||||
json!([{ "id": "reference" }])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scene_generation_preserves_agc_downloadable_queue_result() {
|
||||
let request = serde_json::from_value::<EditorSceneGenerateRequest>(json!({
|
||||
"sceneContent": "雨夜小镇",
|
||||
"stylePreset": "anime",
|
||||
"referenceImageSrcs": ["art-spec-resource"],
|
||||
"generationInputs": {
|
||||
"source": GAME_CREATOR_CLIENT_GENERATION_SOURCE,
|
||||
"action": "client-supplied-action",
|
||||
"fields": [{ "id": "prompt", "value": "不可信配方" }],
|
||||
"references": [{ "refId": "untrusted-resource" }],
|
||||
},
|
||||
}))
|
||||
.expect("scene request should deserialize");
|
||||
let mut image_payload = build_editor_scene_image_generation_payload(request)
|
||||
.expect("scene payload should build");
|
||||
image_payload.generation_inputs =
|
||||
sanitize_editor_queued_generation_inputs(image_payload.generation_inputs);
|
||||
let inputs = image_payload.generation_inputs.as_ref().unwrap();
|
||||
assert_eq!(inputs["source"], GAME_CREATOR_CLIENT_GENERATION_SOURCE);
|
||||
assert_eq!(inputs["action"], "scene.generate");
|
||||
assert_eq!(inputs["fields"][0]["value"], "雨夜小镇");
|
||||
assert_eq!(inputs["references"], json!([{ "id": "reference" }]));
|
||||
|
||||
let mut job = atomic_editor_generation_job_fixture();
|
||||
job.request_payload_json = serde_json::to_string(&image_payload).unwrap();
|
||||
let context = EditorGenerationQueueResultContext::from_job(&job);
|
||||
assert_eq!(
|
||||
context.consumer,
|
||||
EditorGenerationQueueConsumer::GameCreatorResourceEditor
|
||||
);
|
||||
let result: Value = serde_json::from_str(
|
||||
&serialize_atomic_editor_generation_job_result(
|
||||
&context,
|
||||
&json!({
|
||||
"ok": true,
|
||||
"objectKey": "generated/scene.png",
|
||||
"resource": {
|
||||
"resourceId": "scene-resource",
|
||||
"objectKey": "generated/scene.png",
|
||||
"assetObjectId": "scene-object",
|
||||
},
|
||||
}),
|
||||
)
|
||||
.expect("AGC scene result should serialize"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result["result"]["objectKey"], "generated/scene.png");
|
||||
assert_eq!(result["result"]["resource"]["resourceId"], "scene-resource");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_removal_options_preserve_queue_parameters_and_legacy_identity() {
|
||||
for (fields, mode, color) in [
|
||||
|
||||
@@ -7,7 +7,9 @@ use axum::{
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::assets::EditorCanvasGenerationCompletionPayload;
|
||||
use shared_contracts::assets::{
|
||||
EditorCanvasGenerationCompletionPayload, EditorSceneGenerateRequest,
|
||||
};
|
||||
use shared_contracts::external_generation::{
|
||||
ExternalEditorGenerationJobResponse, ExternalEditorGenerationSubmissionResponse,
|
||||
ExternalGenerationJobStatus,
|
||||
@@ -38,12 +40,13 @@ use crate::{
|
||||
EditorCanvasViewportPayload, EditorGenerationCaller, EditorImageEditRequest,
|
||||
EditorImageGenerationRequest, EditorProjectListQuery, EditorProjectListView,
|
||||
EditorProjectPayload, EditorProjectResourcePayload, EditorProjectSummaryListResponse,
|
||||
EditorUiDesignAssetExtractionRequest, current_utc_micros,
|
||||
editor_asset_folder_payload_from_record, editor_asset_library_payload_from_record,
|
||||
editor_asset_payload_from_record, editor_idempotent_create_id,
|
||||
editor_project_payload_from_record, editor_project_resource_payload_from_record,
|
||||
editor_project_summary_from_record, enqueue_editor_background_removal_for_owner,
|
||||
enqueue_editor_image_edit_for_owner, enqueue_editor_image_generation_for_owner,
|
||||
EditorUiDesignAssetExtractionRequest, build_editor_scene_image_generation_payload,
|
||||
current_utc_micros, editor_asset_folder_payload_from_record,
|
||||
editor_asset_library_payload_from_record, editor_asset_payload_from_record,
|
||||
editor_idempotent_create_id, editor_project_payload_from_record,
|
||||
editor_project_resource_payload_from_record, editor_project_summary_from_record,
|
||||
enqueue_editor_background_removal_for_owner, enqueue_editor_image_edit_for_owner,
|
||||
enqueue_editor_image_generation_for_owner,
|
||||
enqueue_editor_ui_design_asset_extraction_for_owner,
|
||||
ensure_generic_editor_image_generation_contract, map_editor_project_error,
|
||||
normalize_editor_persisted_media_src, normalize_optional_string,
|
||||
@@ -750,6 +753,29 @@ pub async fn generate_external_editor_image(
|
||||
Ok(external_generation_accepted_response(&request_context, job))
|
||||
}
|
||||
|
||||
pub async fn generate_external_editor_scene(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(principal): Extension<ExternalApiPrincipal>,
|
||||
headers: HeaderMap,
|
||||
payload: Result<Json<EditorSceneGenerateRequest>, JsonRejection>,
|
||||
) -> Result<Response, AppError> {
|
||||
let Json(payload) = parse_editor_generation_json_payload(payload)?;
|
||||
require_scope(&principal, SCOPE_EDITOR_IMAGE_GENERATE)?;
|
||||
let idempotency_key = require_idempotency_key(&headers)?;
|
||||
let project_id = payload.project_id.clone();
|
||||
let image_payload = build_editor_scene_image_generation_payload(payload)?;
|
||||
let job = enqueue_editor_image_generation_for_owner(
|
||||
&state,
|
||||
&request_context,
|
||||
&editor_generation_caller(&principal, project_id),
|
||||
image_payload,
|
||||
Some(idempotency_key),
|
||||
)
|
||||
.await?;
|
||||
Ok(external_generation_accepted_response(&request_context, job))
|
||||
}
|
||||
|
||||
pub async fn edit_external_editor_image(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -1792,6 +1818,61 @@ mod tests {
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_scene_generation_rejects_invalid_intent_before_queueing() {
|
||||
let state = AppState::new(crate::config::AppConfig::default())
|
||||
.expect("external scene test state should build");
|
||||
state.fail_test_editor_generation_enqueue();
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/external/v1/editor/scenes/generations",
|
||||
post(generate_external_editor_scene),
|
||||
)
|
||||
.layer(Extension(request_context(false)))
|
||||
.layer(Extension(ExternalApiPrincipal::for_test(
|
||||
"user-external-scene",
|
||||
&[SCOPE_EDITOR_IMAGE_GENERATE],
|
||||
)))
|
||||
.with_state(state.clone());
|
||||
|
||||
for (case_name, request_body) in [
|
||||
(
|
||||
"empty sceneContent",
|
||||
json!({"sceneContent": " ", "stylePreset": "anime"}),
|
||||
),
|
||||
(
|
||||
"unknown stylePreset",
|
||||
json!({"sceneContent": "雨夜小镇", "stylePreset": "oil-painting"}),
|
||||
),
|
||||
(
|
||||
"custom without customStyle",
|
||||
json!({"sceneContent": "雨夜小镇", "stylePreset": "custom"}),
|
||||
),
|
||||
("missing sceneContent", json!({"stylePreset": "anime"})),
|
||||
] {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/external/v1/editor/scenes/generations")
|
||||
.header("content-type", "application/json")
|
||||
.header(IDEMPOTENCY_KEY_HEADER, "scene-contract-test")
|
||||
.body(Body::from(request_body.to_string()))
|
||||
.expect("external scene request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("external scene response should return");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{case_name}");
|
||||
assert_eq!(
|
||||
state.test_editor_generation_enqueue_attempts(),
|
||||
0,
|
||||
"{case_name} must fail before queueing",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_background_removal_rejects_invalid_parameters_before_queueing() {
|
||||
let state = AppState::new(crate::config::AppConfig::default())
|
||||
@@ -2221,6 +2302,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_openapi_documents_dedicated_scene_generation_route() {
|
||||
let parsed: Value = serde_json::from_str(OPENAPI_JSON).expect("openapi json should parse");
|
||||
|
||||
let operation = &parsed["paths"]["/api/external/v1/editor/scenes/generations"]["post"];
|
||||
assert_eq!(
|
||||
operation["requestBody"]["content"]["application/json"]["schema"]["$ref"],
|
||||
json!("#/components/schemas/EditorSceneGenerationRequest")
|
||||
);
|
||||
assert_eq!(
|
||||
parsed["components"]["schemas"]["EditorSceneGenerationRequest"]["required"],
|
||||
json!(["sceneContent", "stylePreset"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exported_openapi_json_contains_external_editor_routes_and_security() {
|
||||
let parsed: Value = serde_json::from_str(OPENAPI_JSON).expect("openapi json should parse");
|
||||
|
||||
@@ -19,12 +19,12 @@ use crate::{
|
||||
delete_external_editor_project, edit_external_editor_image,
|
||||
extract_external_editor_ui_design_assets, generate_external_editor_background_music,
|
||||
generate_external_editor_character_animation, generate_external_editor_icon_spritesheet,
|
||||
generate_external_editor_image, generate_external_editor_sound_effect,
|
||||
generate_external_editor_video, get_external_editor_asset_library,
|
||||
get_external_editor_generation_job, get_external_editor_project,
|
||||
list_external_editor_projects, load_recent_external_editor_project, openapi_json,
|
||||
remove_external_editor_image_background, rename_external_editor_project,
|
||||
save_external_editor_canvas, update_external_editor_asset,
|
||||
generate_external_editor_image, generate_external_editor_scene,
|
||||
generate_external_editor_sound_effect, generate_external_editor_video,
|
||||
get_external_editor_asset_library, get_external_editor_generation_job,
|
||||
get_external_editor_project, list_external_editor_projects,
|
||||
load_recent_external_editor_project, openapi_json, remove_external_editor_image_background,
|
||||
rename_external_editor_project, save_external_editor_canvas, update_external_editor_asset,
|
||||
update_external_editor_asset_folder,
|
||||
},
|
||||
external_mcp,
|
||||
@@ -111,6 +111,10 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
"/api/external/v1/editor/images/generations",
|
||||
post(generate_external_editor_image),
|
||||
),
|
||||
(
|
||||
"/api/external/v1/editor/scenes/generations",
|
||||
post(generate_external_editor_scene),
|
||||
),
|
||||
(
|
||||
"/api/external/v1/editor/images/edits",
|
||||
post(edit_external_editor_image),
|
||||
@@ -257,6 +261,7 @@ mod route_contract_tests {
|
||||
),
|
||||
("/api/external/v1/generations/{operation_id}", &["GET"]),
|
||||
("/api/external/v1/editor/images/generations", &["POST"]),
|
||||
("/api/external/v1/editor/scenes/generations", &["POST"]),
|
||||
("/api/external/v1/editor/images/edits", &["POST"]),
|
||||
(
|
||||
"/api/external/v1/editor/images/background-removals",
|
||||
|
||||
Reference in New Issue
Block a user