合并主分支并解决画布复制冲突
Project CI / Repository checks (pull_request) Successful in 52s
Project CI / Backend tests (pull_request) Failing after 1m42s
Project CI / Frontend tests (pull_request) Successful in 2m58s
Project CI / Native shell tests (pull_request) Has been cancelled

同步主分支的编辑器、运行时、后端与运维改动
保留画布副本复用项目资源和结构化持久化行为
采用显式资源持久化状态与图层素材类型覆盖合同
补齐相关测试、技术文档与共享项目记忆
This commit is contained in:
2026-08-06 17:27:21 +08:00
348 changed files with 89218 additions and 6998 deletions
+119 -12
View File
@@ -33,13 +33,14 @@ use shared_contracts::admin::{
AdminDatabaseTableRowsResponse, AdminDatabaseTableStatPayload, AdminDebugHeaderInput,
AdminDebugHttpRequest, AdminDebugHttpResponse, AdminDirectUploadTicketPayload,
AdminEditorAssetListQuery, AdminEditorAssetListResponse, AdminEditorAssetPayload,
AdminEditorShowcaseAssetPayload, AdminEditorShowcaseAssetResponse,
AdminEditorShowcaseCampaignPayload, AdminEditorShowcaseCampaignResponse,
AdminEditorShowcaseDisplayRequest, AdminEditorShowcaseListQuery,
AdminEditorShowcaseListResponse, AdminEditorShowcaseReviewRequest,
AdminFeatureGateConfigPayload, AdminFeatureGateConfigResponse, AdminLoginRequest,
AdminLoginResponse, AdminMeResponse, AdminOverviewResponse, AdminServiceOverviewPayload,
AdminSessionPayload, AdminTrackingEventEntryPayload, AdminTrackingEventKeyListResponse,
AdminEditorImageSequenceFramePayload, AdminEditorShowcaseAssetPayload,
AdminEditorShowcaseAssetResponse, AdminEditorShowcaseCampaignPayload,
AdminEditorShowcaseCampaignResponse, AdminEditorShowcaseDisplayRequest,
AdminEditorShowcaseListQuery, AdminEditorShowcaseListResponse,
AdminEditorShowcaseReviewRequest, AdminFeatureGateConfigPayload,
AdminFeatureGateConfigResponse, AdminLoginRequest, AdminLoginResponse, AdminMeResponse,
AdminOverviewResponse, AdminServiceOverviewPayload, AdminSessionPayload,
AdminTrackingEventEntryPayload, AdminTrackingEventKeyListResponse,
AdminTrackingEventKeyPayload, AdminTrackingEventListQuery, AdminTrackingEventListResponse,
AdminUpsertEditorShowcaseCampaignRequest, AdminUpsertFeatureGateConfigRequest,
};
@@ -77,7 +78,10 @@ use crate::{
create_direct_upload_ticket_for_owner, get_asset_read_url_with_query,
},
editor_generation_config::EditorGenerationPricingConfig,
editor_project::current_utc_micros,
editor_project::{
current_utc_micros, normalize_editor_image_sequence_frames_value,
resolve_editor_asset_kind, sanitize_editor_generation_inputs,
},
http_error::AppError,
request_context::RequestContext,
state::{AdminRuntime, AppState},
@@ -1051,11 +1055,27 @@ fn map_admin_feature_gate_config(
}
}
fn normalize_admin_editor_image_sequence_frames(
frames: &Value,
) -> Option<Vec<AdminEditorImageSequenceFramePayload>> {
let normalized = normalize_editor_image_sequence_frames_value(frames)?;
serde_json::from_value(normalized).ok()
}
fn admin_editor_showcase_asset_payload_from_record(
state: &AppState,
record: EditorShowcaseAssetRecord,
) -> AdminEditorShowcaseAssetPayload {
let author = resolve_work_author_by_user_id(state, &record.owner_user_id, None, None);
let formal_image_sequence_frames = record
.image_sequence_frames
.as_ref()
.and_then(normalize_admin_editor_image_sequence_frames);
let asset_kind = resolve_editor_asset_kind(
record.asset_kind,
record.image_src.as_str(),
record.object_key.as_deref(),
);
AdminEditorShowcaseAssetPayload {
showcase_id: record.showcase_id,
asset_id: record.asset_id,
@@ -1072,8 +1092,8 @@ fn admin_editor_showcase_asset_payload_from_record(
model: record.model,
provider: record.provider,
task_id: record.task_id,
asset_kind: record.asset_kind,
generation_inputs: record.generation_inputs,
asset_kind,
generation_inputs: sanitize_editor_generation_inputs(record.generation_inputs),
thumbnail_src: record.thumbnail_src,
generation_cost_mud_points: record.generation_cost_mud_points,
refund_mud_points: record.refund_mud_points,
@@ -1091,6 +1111,8 @@ fn admin_editor_showcase_asset_payload_from_record(
rejected_at: record.rejected_at,
updated_at: record.updated_at,
showcase_category: record.showcase_category,
image_sequence_frames: formal_image_sequence_frames,
image_sequence_duration_ms: record.image_sequence_duration_ms,
}
}
@@ -1193,6 +1215,15 @@ fn admin_editor_asset_payload_from_record(
) -> AdminEditorAssetPayload {
let author = resolve_work_author_by_user_id(state, &record.owner_user_id, None, None);
let generator = resolve_admin_editor_asset_generator(&record);
let formal_image_sequence_frames = record
.image_sequence_frames
.as_ref()
.and_then(normalize_admin_editor_image_sequence_frames);
let asset_kind = resolve_editor_asset_kind(
record.asset_kind,
record.image_src.as_str(),
record.object_key.as_deref(),
);
AdminEditorAssetPayload {
asset_id: record.asset_id,
owner_user_id: record.owner_user_id,
@@ -1212,8 +1243,8 @@ fn admin_editor_asset_payload_from_record(
provider: record.provider,
task_id: record.task_id,
group_task_id: record.group_task_id,
asset_kind: record.asset_kind,
generation_inputs: record.generation_inputs,
asset_kind,
generation_inputs: sanitize_editor_generation_inputs(record.generation_inputs),
source_resource_id: record.source_resource_id,
thumbnail_src: record.thumbnail_src,
generation_cost_mud_points: record.generation_cost_mud_points,
@@ -1223,6 +1254,8 @@ fn admin_editor_asset_payload_from_record(
task_cost_mud_points: record.generation_cost_mud_points,
generator,
children: Vec::new(),
image_sequence_frames: formal_image_sequence_frames,
image_sequence_duration_ms: record.image_sequence_duration_ms,
}
}
@@ -4118,6 +4151,8 @@ mod tests {
rejected_at: None,
updated_at: "2026-07-04T10:00:00.000Z".to_string(),
showcase_category: None,
image_sequence_frames: None,
image_sequence_duration_ms: None,
}
}
@@ -4152,6 +4187,8 @@ mod tests {
generation_cost_mud_points,
created_at: "2026-07-04T10:00:00Z".to_string(),
updated_at: "2026-07-04T10:00:00Z".to_string(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
}
}
@@ -4316,6 +4353,76 @@ mod tests {
);
}
#[test]
fn admin_editor_showcase_payload_uses_formal_action_fields_only() {
let state = AppState::new(AppConfig::default()).expect("state should build");
let formal_frames = json!([
{
"imageSrc": "/generated/action/formal-01.png",
"objectKey": "generated/action/formal-01.png",
"assetObjectId": "asset-object-formal-01",
"width": 192,
"height": 256
},
{
"imageSrc": "/generated/action/formal-02.png",
"objectKey": "generated/action/formal-02.png",
"assetObjectId": "asset-object-formal-02",
"width": 192,
"height": 256
}
]);
let mut record = test_admin_editor_showcase_asset_record("user-author-1");
record.asset_kind = Some("character-animation".to_string());
record.image_sequence_frames = Some(formal_frames.clone());
record.image_sequence_duration_ms = Some(6_000);
record.generation_inputs = Some(json!({
"fields": [{ "title": "动作", "value": "挥手" }]
}));
let payload = admin_editor_showcase_asset_payload_from_record(&state, record);
assert_eq!(payload.asset_kind.as_deref(), Some("character-animation"));
assert_eq!(
serde_json::to_value(payload.image_sequence_frames)
.expect("formal frames should serialize"),
formal_frames
);
assert_eq!(payload.image_sequence_duration_ms, Some(6_000));
assert_eq!(
payload.generation_inputs,
Some(json!({ "fields": [{ "title": "动作", "value": "挥手" }] }))
);
}
#[test]
fn admin_editor_asset_payload_preserves_formal_character_action_metadata() {
let state = AppState::new(AppConfig::default()).expect("state should build");
let frames = json!([
{ "imageSrc": "/generated/action/frame-01.png", "objectKey": "generated/action/frame-01.png", "assetObjectId": "asset-object-frame-01", "width": 192, "height": 256 },
{ "imageSrc": "/generated/action/frame-02.png", "objectKey": "generated/action/frame-02.png", "assetObjectId": "asset-object-frame-02", "width": 192, "height": 256 }
]);
let mut record = test_admin_editor_asset_record(
"action-asset",
"action-task",
"角色动作",
"generated/action/frame-01.png",
12,
);
record.asset_kind = Some("character-animation".to_string());
record.image_sequence_frames = Some(frames.clone());
record.image_sequence_duration_ms = Some(5_000);
let payload = admin_editor_asset_payload_from_record(&state, record);
assert_eq!(
serde_json::to_value(payload.image_sequence_frames)
.expect("formal frames should serialize"),
frames
);
assert_eq!(payload.image_sequence_duration_ms, Some(5_000));
}
#[test]
fn admin_editor_asset_group_nests_each_intermediate_output_under_final_asset() {
let final_output = test_admin_editor_asset_record(
+141
View File
@@ -1963,6 +1963,147 @@ mod tests {
}
}
#[tokio::test]
async fn editor_pixel_art_snap_requires_bearer_auth() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let request_body = serde_json::json!({
"sourceImageSrc": "editor-resource-source",
"projectId": "proj-source",
"canvasCompletion": {
"dialogId": "dialog-pixel-art",
"title": "完美像素",
"placeholder": {
"x": 0,
"y": 0,
"width": 128,
"height": 128,
"originalWidth": 128,
"originalHeight": 128
}
}
});
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/editor/images/pixel-art-snaps")
.header("content-type", "application/json")
.body(Body::from(request_body.to_string()))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn editor_pixel_art_snap_rejects_inline_source_before_processing() {
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, "13800138230", TEST_PASSWORD).await;
let token = sign_test_user_token(&state, &seed_user, "sess_editor_pixel_snap_body");
let app = build_router(state);
let request_body = serde_json::json!({
"sourceImageSrc": "data:image/png;base64,AAAA",
"projectId": "proj-source",
"sourceResourceId": "editor-resource-source",
"assetKind": "character",
"generationInputs": { "fields": [], "references": [] },
"canvasCompletion": {
"dialogId": "dialog-pixel-art",
"title": "完美像素",
"placeholder": {
"x": 0,
"y": 0,
"width": 128,
"height": 128,
"originalWidth": 128,
"originalHeight": 128
}
}
});
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/editor/images/pixel-art-snaps")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(request_body.to_string()))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response
.into_body()
.collect()
.await
.expect("response body should collect")
.to_bytes();
let body_text = String::from_utf8_lossy(&body);
assert!(
body_text.contains("先上传 OSS"),
"handler should reject inline pixel-art sources: {body_text}"
);
}
#[tokio::test]
async fn editor_pixel_art_snap_requires_dialog_id_before_project_or_media_work() {
let state = AppState::new(AppConfig::default()).expect("state should build");
let seed_user = seed_phone_user_with_password(&state, "13800138231", TEST_PASSWORD).await;
let token = sign_test_user_token(&state, &seed_user, "sess_editor_pixel_snap_dialog");
let app = build_router(state);
let request_body = serde_json::json!({
"sourceImageSrc": "editor-resource-source",
"projectId": "proj-does-not-exist",
"canvasCompletion": {
"title": "完美像素",
"placeholder": {
"x": 0,
"y": 0,
"width": 128,
"height": 128,
"originalWidth": 128,
"originalHeight": 128
}
}
});
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/editor/images/pixel-art-snaps")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(request_body.to_string()))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response
.into_body()
.collect()
.await
.expect("response body should collect")
.to_bytes();
let body_text = String::from_utf8_lossy(&body);
assert!(
body_text.contains("canvasCompletion.dialogId"),
"handler should reject missing dialog ids before project lookup: {body_text}"
);
}
#[tokio::test]
async fn editor_generation_json_validation_preserves_unsupported_media_type() {
let state = AppState::new(AppConfig {
@@ -543,18 +543,20 @@ pub(crate) fn map_asset_operation_wallet_error(error: SpacetimeClientError) -> A
);
let is_insufficient_balance = matches!(
&error,
SpacetimeClientError::Procedure(message) if message.contains("泥点余额不足")
SpacetimeClientError::Procedure(message)
if message.contains("泥点余额不足") || message.contains("可消费泥点不足:")
);
let status = if is_insufficient_balance {
StatusCode::CONFLICT
} else {
StatusCode::BAD_GATEWAY
};
let public_message = is_insufficient_balance.then(|| message.clone());
let public_message = is_insufficient_balance.then_some("泥点余额不足");
let public_detail_message = public_message.unwrap_or(message.as_str());
let app_error = AppError::from_status(status).with_details(json!({
"provider": "profile-wallet",
"message": message,
"message": public_detail_message,
}));
if let Some(public_message) = public_message {
@@ -740,20 +742,25 @@ mod tests {
#[test]
fn asset_operation_wallet_insufficient_balance_is_public_message() {
let error = map_asset_operation_wallet_error(SpacetimeClientError::Procedure(
"泥点余额不足".to_string(),
));
for domain_message in [
"泥点余额不足",
"可消费泥点不足:需要 10,扣除退款占用后可用 2",
] {
let error = map_asset_operation_wallet_error(SpacetimeClientError::Procedure(
domain_message.to_string(),
));
assert_eq!(error.status_code(), StatusCode::CONFLICT);
assert_eq!(error.code(), "CONFLICT");
assert_eq!(error.message(), "泥点余额不足");
assert_eq!(
error
.details()
.and_then(|details| details.get("message"))
.and_then(serde_json::Value::as_str),
Some("泥点余额不足"),
);
assert_eq!(error.status_code(), StatusCode::CONFLICT);
assert_eq!(error.code(), "CONFLICT");
assert_eq!(error.message(), "泥点余额不足");
assert_eq!(
error
.details()
.and_then(|details| details.get("message"))
.and_then(serde_json::Value::as_str),
Some("泥点余额不足"),
);
}
}
#[test]
File diff suppressed because it is too large Load Diff
@@ -28,6 +28,7 @@ pub(crate) const EDITOR_GENERATION_QUEUE_SOURCE_MODULE: &str = "editor-canvas";
const EDITOR_GENERATION_QUEUE_PROVIDER: &str = "editor-generation-worker";
const MAX_EDITOR_GENERATION_JOB_PAYLOAD_BYTES: usize = 512 * 1024;
const EXTERNAL_API_GENERATION_DEDUPE_PREFIX: &str = "external-api-generation";
const EDITOR_API_REQUEST_GENERATION_DEDUPE_PREFIX: &str = "editor-api-request-generation";
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -37,7 +38,7 @@ pub(crate) struct EditorGenerationQueuedResponse {
pub(crate) async fn enqueue_editor_generation_job<T>(
state: &AppState,
_request_context: &RequestContext,
request_context: &RequestContext,
owner_user_id: &str,
job_kind: &str,
source_entity_id: impl Into<String>,
@@ -49,6 +50,12 @@ where
T: Serialize,
{
let job_id = build_prefixed_uuid_id("task-");
let dedupe_key = build_editor_generation_dedupe_key(
EDITOR_API_REQUEST_GENERATION_DEDUPE_PREFIX,
owner_user_id,
job_kind,
request_context.request_id().trim(),
);
enqueue_editor_generation_job_with_identity(
state,
owner_user_id,
@@ -58,11 +65,26 @@ where
price_mud_points,
payload,
job_id.clone(),
format!("editor-canvas:{job_kind}:{job_id}"),
dedupe_key,
)
.await
}
fn build_editor_generation_dedupe_key(
namespace: &str,
owner_user_id: &str,
job_kind: &str,
stable_key: &str,
) -> String {
let mut hasher = Sha256::new();
hasher.update(owner_user_id.trim().as_bytes());
hasher.update(b"\0");
hasher.update(job_kind.trim().as_bytes());
hasher.update(b"\0");
hasher.update(stable_key.as_bytes());
format!("{namespace}:{job_kind}:{:x}", hasher.finalize())
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn enqueue_editor_generation_job_with_identity<T>(
state: &AppState,
@@ -79,18 +101,25 @@ where
T: Serialize,
{
let request_payload_json = serialize_editor_generation_job_payload(payload)?;
enqueue_serialized_editor_generation_job_with_identity(
let job = enqueue_serialized_editor_generation_job_with_identity(
state,
owner_user_id,
job_kind,
source_entity_id,
request_label,
price_mud_points,
request_payload_json,
request_payload_json.clone(),
job_id,
dedupe_key,
)
.await
.await?;
ensure_editor_generation_job_matches_request(
job,
owner_user_id,
job_kind,
request_payload_json.as_str(),
"请求幂等键已用于不同的生成请求,请复用原请求参数或更换请求标识。",
)
}
#[allow(clippy::too_many_arguments)]
@@ -108,15 +137,11 @@ where
T: Serialize,
{
let request_payload_json = serialize_editor_generation_job_payload(payload)?;
let mut hasher = Sha256::new();
hasher.update(owner_user_id.trim().as_bytes());
hasher.update(b"\0");
hasher.update(job_kind.trim().as_bytes());
hasher.update(b"\0");
hasher.update(idempotency_key.as_bytes());
let dedupe_key = format!(
"{EXTERNAL_API_GENERATION_DEDUPE_PREFIX}:{job_kind}:{:x}",
hasher.finalize()
let dedupe_key = build_editor_generation_dedupe_key(
EXTERNAL_API_GENERATION_DEDUPE_PREFIX,
owner_user_id,
job_kind,
idempotency_key,
);
let requested_job_id = build_prefixed_uuid_id("task-");
let job = enqueue_serialized_editor_generation_job_with_identity(
@@ -132,20 +157,97 @@ where
)
.await?;
ensure_editor_generation_job_matches_request(
job,
owner_user_id,
job_kind,
request_payload_json.as_str(),
"Idempotency-Key 已用于不同的生成请求,请复用原请求参数或更换幂等键。",
)
}
fn ensure_editor_generation_job_matches_request(
job: ExternalGenerationJobRecord,
owner_user_id: &str,
job_kind: &str,
request_payload_json: &str,
conflict_message: &str,
) -> Result<ExternalGenerationJobRecord, AppError> {
if job.job_kind != job_kind
|| job.owner_user_id != owner_user_id
|| job.request_payload_json != request_payload_json
|| !editor_generation_request_payloads_match(
job_kind,
job.request_payload_json.as_str(),
request_payload_json,
)
{
return Err(
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": EDITOR_GENERATION_QUEUE_PROVIDER,
"message": "Idempotency-Key 已用于不同的生成请求,请复用原请求参数或更换幂等键。",
"message": conflict_message,
})),
);
}
Ok(job)
}
fn generation_input_references(value: &Value) -> Option<&Value> {
value
.as_object()
.and_then(|payload| payload.get("generationInputs"))
.and_then(Value::as_object)
.and_then(|generation_inputs| generation_inputs.get("references"))
}
fn strip_untrusted_generation_input_references_from_payload(value: &mut Value) -> bool {
let Some(generation_inputs) = value
.as_object_mut()
.and_then(|payload| payload.get_mut("generationInputs"))
.and_then(Value::as_object_mut)
else {
return false;
};
generation_inputs.remove("references").is_some()
}
fn job_kind_migrated_away_from_client_generation_references(job_kind: &str) -> bool {
matches!(
job_kind,
EDITOR_IMAGE_GENERATION_JOB_KIND
| EDITOR_IMAGE_EDIT_JOB_KIND
| EDITOR_BACKGROUND_REMOVAL_JOB_KIND
| EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND
| EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND
)
}
fn editor_generation_request_payloads_match(
job_kind: &str,
existing: &str,
requested: &str,
) -> bool {
if existing == requested {
return true;
}
let (Ok(mut existing), Ok(requested)) = (
serde_json::from_str::<Value>(existing),
serde_json::from_str::<Value>(requested),
) else {
return false;
};
// 只兼容部署前旧 payload 有 references、当前 sanitizer 已删除该字段的单向迁移。
// 音频、视频、角色动作等仍会保留 references;如果当前请求也带该字段,就必须完整
// 比较,不能把两个不同请求错误复用成同一任务。
if !job_kind_migrated_away_from_client_generation_references(job_kind)
|| generation_input_references(&existing).is_none()
|| generation_input_references(&requested).is_some()
|| !strip_untrusted_generation_input_references_from_payload(&mut existing)
{
return false;
}
existing == requested
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn enqueue_editor_generation_job_for_caller<T>(
state: &AppState,
@@ -363,6 +465,190 @@ mod tests {
}
}
#[test]
fn editor_api_request_dedupe_key_is_stable_and_namespaced() {
let first = build_editor_generation_dedupe_key(
EDITOR_API_REQUEST_GENERATION_DEDUPE_PREFIX,
"user-1",
EDITOR_IMAGE_GENERATION_JOB_KIND,
"request-1",
);
let replay = build_editor_generation_dedupe_key(
EDITOR_API_REQUEST_GENERATION_DEDUPE_PREFIX,
" user-1 ",
EDITOR_IMAGE_GENERATION_JOB_KIND,
"request-1",
);
let other_owner = build_editor_generation_dedupe_key(
EDITOR_API_REQUEST_GENERATION_DEDUPE_PREFIX,
"user-2",
EDITOR_IMAGE_GENERATION_JOB_KIND,
"request-1",
);
let external = build_editor_generation_dedupe_key(
EXTERNAL_API_GENERATION_DEDUPE_PREFIX,
"user-1",
EDITOR_IMAGE_GENERATION_JOB_KIND,
"request-1",
);
assert_eq!(first, replay);
assert_ne!(first, other_owner);
assert_ne!(first, external);
assert!(first.starts_with("editor-api-request-generation:editor_image_generation:"));
}
#[test]
fn external_api_dedupe_key_preserves_legacy_hash_bytes() {
let dedupe_key = build_editor_generation_dedupe_key(
EXTERNAL_API_GENERATION_DEDUPE_PREFIX,
" user-1 ",
EDITOR_IMAGE_GENERATION_JOB_KIND,
" request-1 ",
);
assert_eq!(
dedupe_key,
"external-api-generation:editor_image_generation:81e38a8eace5f098041b3c240f99053c555ccfa18139295576c9b4ba3d9acff6"
);
}
#[test]
fn replayed_editor_generation_job_must_match_original_request() {
let mut job = queue_job_fixture("queued", None);
job.owner_user_id = "user-1".to_string();
job.job_kind = EDITOR_IMAGE_GENERATION_JOB_KIND.to_string();
job.request_payload_json = r#"{"prompt":"same"}"#.to_string();
assert!(
ensure_editor_generation_job_matches_request(
job.clone(),
"user-1",
EDITOR_IMAGE_GENERATION_JOB_KIND,
r#"{"prompt":"same"}"#,
"幂等冲突",
)
.is_ok()
);
let error = ensure_editor_generation_job_matches_request(
job,
"user-1",
EDITOR_IMAGE_GENERATION_JOB_KIND,
r#"{"prompt":"changed"}"#,
"幂等冲突",
)
.expect_err("same request id must reject a different payload");
assert_eq!(error.status_code(), StatusCode::CONFLICT);
}
#[test]
fn replayed_legacy_external_job_ignores_only_removed_client_references() {
let mut job = queue_job_fixture("queued", None);
job.owner_user_id = "user-1".to_string();
job.job_kind = EDITOR_IMAGE_GENERATION_JOB_KIND.to_string();
job.request_payload_json = serde_json::to_string(&json!({
"prompt": "same",
"generationInputs": {
"fields": [{"title": "提示词", "value": "same"}],
"references": [{
"title": "旧客户端引用",
"refType": "asset",
"refId": "asset-forged"
}]
}
}))
.expect("legacy payload should serialize");
let requested = serde_json::to_string(&json!({
"prompt": "same",
"generationInputs": {
"fields": [{"title": "提示词", "value": "same"}]
}
}))
.expect("current payload should serialize");
assert!(
ensure_editor_generation_job_matches_request(
job.clone(),
"user-1",
EDITOR_IMAGE_GENERATION_JOB_KIND,
requested.as_str(),
"幂等冲突",
)
.is_ok()
);
let changed = requested.replace("same", "changed");
let error = ensure_editor_generation_job_matches_request(
job,
"user-1",
EDITOR_IMAGE_GENERATION_JOB_KIND,
changed.as_str(),
"幂等冲突",
)
.expect_err("non-reference payload changes must still conflict");
assert_eq!(error.status_code(), StatusCode::CONFLICT);
}
#[test]
fn replayed_jobs_with_references_on_both_sides_compare_them_strictly() {
let mut job = queue_job_fixture("queued", None);
job.owner_user_id = "user-1".to_string();
job.job_kind = "editor_video_generation".to_string();
job.request_payload_json = serde_json::to_string(&json!({
"prompt": "same",
"generationInputs": {
"references": [{"refType": "asset", "refId": "asset-1"}]
}
}))
.expect("existing video payload should serialize");
let requested = serde_json::to_string(&json!({
"prompt": "same",
"generationInputs": {
"references": [{"refType": "asset", "refId": "asset-2"}]
}
}))
.expect("requested video payload should serialize");
let error = ensure_editor_generation_job_matches_request(
job,
"user-1",
"editor_video_generation",
requested.as_str(),
"幂等冲突",
)
.expect_err("different retained references must conflict");
assert_eq!(error.status_code(), StatusCode::CONFLICT);
}
#[test]
fn replayed_video_job_does_not_use_image_reference_migration_compatibility() {
let mut job = queue_job_fixture("queued", None);
job.owner_user_id = "user-1".to_string();
job.job_kind = EDITOR_VIDEO_GENERATION_JOB_KIND.to_string();
job.request_payload_json = serde_json::to_string(&json!({
"prompt": "same",
"generationInputs": {
"references": [{"refType": "asset", "refId": "asset-1"}]
}
}))
.expect("existing video payload should serialize");
let requested = serde_json::to_string(&json!({
"prompt": "same",
"generationInputs": {}
}))
.expect("requested video payload should serialize");
let error = ensure_editor_generation_job_matches_request(
job,
"user-1",
EDITOR_VIDEO_GENERATION_JOB_KIND,
requested.as_str(),
"幂等冲突",
)
.expect_err("video payloads did not migrate away from references");
assert_eq!(error.status_code(), StatusCode::CONFLICT);
}
#[test]
fn serialize_payload_accepts_persistable_media_references() {
let payload = json!({
File diff suppressed because it is too large Load Diff
@@ -41,8 +41,8 @@ use crate::{
enqueue_editor_image_generation_for_owner,
enqueue_editor_ui_design_asset_extraction_for_owner, map_editor_project_error,
normalize_editor_persisted_media_src, normalize_optional_string,
parse_editor_generation_json_payload, sanitize_editor_client_generation_inputs,
save_editor_project_layout_with_revision_and_get, serialize_editor_asset_metadata,
parse_editor_generation_json_payload, sanitize_editor_untrusted_generation_inputs,
save_editor_project_layout_with_revision_and_get, serialize_editor_generation_inputs,
},
external_api_auth::ExternalApiPrincipal,
external_generation::map_external_generation_job_status_detail,
@@ -54,6 +54,7 @@ use crate::{
enqueue_editor_sound_effect_generation_for_owner,
},
};
use std::num::NonZeroU64;
const EXTERNAL_EDITOR_PROVIDER: &str = "external-editor-api";
const SCOPE_EDITOR_PROJECT: &str = "editor:project";
@@ -117,6 +118,8 @@ pub struct ExternalEditorAssetCreateRequest {
task_id: Option<String>,
asset_kind: Option<String>,
generation_inputs: Option<Value>,
image_sequence_frames: Option<Value>,
image_sequence_duration_ms: Option<NonZeroU64>,
}
#[derive(Debug, Deserialize)]
@@ -136,6 +139,8 @@ pub struct ExternalEditorProjectResourceCreateRequest {
source_resource_id: Option<String>,
asset_kind: Option<String>,
generation_inputs: Option<Value>,
image_sequence_frames: Option<Value>,
image_sequence_duration_ms: Option<NonZeroU64>,
}
#[derive(Debug, Deserialize)]
@@ -479,8 +484,12 @@ pub async fn create_external_editor_asset(
Json(payload): Json<ExternalEditorAssetCreateRequest>,
) -> Result<Json<Value>, AppError> {
require_scope(&principal, SCOPE_EDITOR_ASSET)?;
let generation_inputs_json =
serialize_external_editor_generation_inputs(payload.generation_inputs.clone())?;
let generation_inputs_json = serialize_external_editor_generation_inputs(
payload.asset_kind.as_deref(),
payload.generation_inputs.clone(),
)?;
let image_sequence_frames_json =
serialize_external_editor_image_sequence_frames(payload.image_sequence_frames)?;
let object_key = normalize_optional_string(payload.object_key);
let image_src = normalize_editor_persisted_media_src(payload.image_src, object_key.as_deref())?;
let asset = state
@@ -509,6 +518,8 @@ pub async fn create_external_editor_asset(
thumbnail_src: None,
group_task_id: None,
group_task_expected_asset_count: None,
image_sequence_frames_json,
image_sequence_duration_ms: payload.image_sequence_duration_ms.map(NonZeroU64::get),
})
.await
.map_err(map_editor_project_error)?;
@@ -581,8 +592,12 @@ pub async fn create_external_editor_project_resource(
Json(payload): Json<ExternalEditorProjectResourceCreateRequest>,
) -> Result<Json<Value>, AppError> {
require_scope(&principal, SCOPE_EDITOR_ASSET)?;
let generation_inputs_json =
serialize_external_editor_generation_inputs(payload.generation_inputs.clone())?;
let generation_inputs_json = serialize_external_editor_generation_inputs(
payload.asset_kind.as_deref(),
payload.generation_inputs.clone(),
)?;
let image_sequence_frames_json =
serialize_external_editor_image_sequence_frames(payload.image_sequence_frames)?;
let object_key = normalize_optional_string(payload.object_key);
let image_src = normalize_editor_persisted_media_src(payload.image_src, object_key.as_deref())?;
let resource = state
@@ -606,6 +621,8 @@ pub async fn create_external_editor_project_resource(
asset_kind: normalize_optional_string(payload.asset_kind),
generation_inputs_json,
updated_at_micros: current_utc_micros(),
image_sequence_frames_json,
image_sequence_duration_ms: payload.image_sequence_duration_ms.map(NonZeroU64::get),
})
.await
.map_err(map_editor_project_error)?;
@@ -989,15 +1006,30 @@ fn normalize_project_title(title: Option<String>) -> String {
}
fn serialize_external_editor_generation_inputs(
asset_kind: Option<&str>,
generation_inputs: Option<Value>,
) -> Result<Option<String>, AppError> {
serialize_editor_asset_metadata(sanitize_editor_client_generation_inputs(generation_inputs))
serialize_editor_generation_inputs(
asset_kind,
sanitize_editor_untrusted_generation_inputs(generation_inputs),
)
}
fn serialize_external_editor_image_sequence_frames(
frames: Option<Value>,
) -> Result<Option<String>, AppError> {
crate::editor_project::serialize_editor_image_sequence_frames(frames)
}
#[cfg(test)]
mod tests {
use super::*;
const EXTERNAL_MEDIA_CREATE_REQUEST_SCHEMAS: [&str; 2] = [
"ExternalEditorAssetCreateRequest",
"ExternalEditorProjectResourceCreateRequest",
];
fn external_generation_job_fixture(status: &str) -> ExternalGenerationJobRecord {
ExternalGenerationJobRecord {
job_id: "task-external-test".to_string(),
@@ -1058,23 +1090,122 @@ mod tests {
}
#[test]
fn external_editor_generation_inputs_cannot_forge_internal_audit_fields() {
let serialized = serialize_external_editor_generation_inputs(Some(json!({
"fields": [{"label": "角色设定", "value": "骑士"}],
"screenColorHex": "#00FF00",
"mattingProvider": "forged-provider",
"mattingModel": "forged-model",
"characterAnimation": {"durationSeconds": 4}
})))
fn external_editor_generation_inputs_sanitize_audit_fields_for_regular_assets() {
let serialized = serialize_external_editor_generation_inputs(
Some("image"),
Some(json!({
"fields": [{"label": "角色设定", "value": "骑士"}],
"screenColorHex": "#00FF00",
"mattingProvider": "forged-provider",
"mattingModel": "forged-model",
"references": [{
"title": "伪造引用",
"label": "其他用户素材",
"refType": "asset",
"refId": "asset-forged"
}],
"extension": {"durationSeconds": 4}
})),
)
.expect("外部编辑器生成输入应可序列化")
.expect("非空生成输入应保留");
let parsed: Value = serde_json::from_str(&serialized).expect("生成输入应为合法 JSON");
assert_eq!(parsed["fields"][0]["value"], "骑士");
assert_eq!(parsed["characterAnimation"]["durationSeconds"], 4);
assert_eq!(parsed["extension"]["durationSeconds"], 4);
assert!(parsed.get("screenColorHex").is_none());
assert!(parsed.get("mattingProvider").is_none());
assert!(parsed.get("mattingModel").is_none());
assert!(parsed.get("references").is_none());
}
#[test]
fn external_editor_character_animation_generation_inputs_reject_legacy_runtime_fields() {
for (field, value) in [
("characterAnimation", json!({ "durationSeconds": 4 })),
("frames", json!([])),
("previewVideoPath", json!("/generated/action/preview.mp4")),
("frameCount", json!(32)),
("fps", json!(8)),
("durationSeconds", json!(4)),
] {
let mut generation_inputs = serde_json::Map::new();
generation_inputs.insert("fields".to_string(), json!([]));
generation_inputs.insert(field.to_string(), value);
let error = serialize_external_editor_generation_inputs(
Some("character-animation"),
Some(Value::Object(generation_inputs)),
)
.expect_err("External v1 不应再接受角色动作旧运行字段");
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
assert_eq!(
error
.details()
.and_then(|details| details["fields"][0].as_str()),
Some(field)
);
assert!(error.body_text().contains("imageSequenceFrames"));
assert!(error.body_text().contains("imageSequenceDurationMs"));
}
}
#[test]
fn external_editor_sequence_frames_reject_temporary_signed_urls_without_stable_references() {
let error = serialize_external_editor_image_sequence_frames(Some(json!([
{
"imageSrc": "https://example.invalid/frame-01.png?Expires=60&Signature=temporary-1",
"width": 192,
"height": 256
},
{
"imageSrc": "https://example.invalid/frame-02.png?Expires=60&Signature=temporary-2",
"width": 192,
"height": 256
}
])))
.expect_err("External v1 不能把仅含临时签名 URL 的帧持久化为正式动作");
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
assert_eq!(
error.details().and_then(|details| details.get("fields")),
Some(&json!(["objectKey", "assetObjectId"]))
);
assert!(error.body_text().contains("临时签名 URL"));
}
#[test]
fn external_editor_sequence_frames_derive_persistent_paths_from_object_keys() {
let serialized = serialize_external_editor_image_sequence_frames(Some(json!([
{
"imageSrc": "https://example.invalid/frame-01.png?Expires=60&Signature=temporary-1",
"objectKey": "/generated/action/frame-01.png",
"assetObjectId": "asset-object-frame-01",
"width": 192,
"height": 256
},
{
"imageSrc": "https://example.invalid/frame-02.png?Expires=60&Signature=temporary-2",
"objectKey": "generated/action/frame-02.png",
"assetObjectId": "asset-object-frame-02",
"width": 192,
"height": 256
}
])))
.expect("带稳定对象引用的 External v1 正式帧应可持久化")
.expect("正式帧序列应产生 JSON");
let frames: Value = serde_json::from_str(&serialized).expect("正式帧序列应为合法 JSON");
assert_eq!(frames[0]["imageSrc"], "/generated/action/frame-01.png");
assert_eq!(frames[1]["imageSrc"], "/generated/action/frame-02.png");
assert!(frames.as_array().is_some_and(|frames| {
frames.iter().all(|frame| {
!frame["imageSrc"]
.as_str()
.is_some_and(|image_src| image_src.contains("Signature="))
})
}));
}
#[test]
@@ -1188,11 +1319,111 @@ mod tests {
assert!(!unavailable.body_text().contains("ConnectDropped"));
}
#[test]
fn external_openapi_character_animation_create_requires_complete_sequence_metadata() {
let parsed: Value = serde_json::from_str(OPENAPI_JSON).expect("openapi json should parse");
for request_schema_name in EXTERNAL_MEDIA_CREATE_REQUEST_SCHEMAS {
let request_schema = &parsed["components"]["schemas"][request_schema_name];
let conditional = &request_schema["allOf"][0];
assert_eq!(
conditional["then"]["required"],
json!(["imageSequenceFrames", "imageSequenceDurationMs"]),
"{request_schema_name}"
);
assert_eq!(
request_schema["properties"]["imageSequenceFrames"]["minItems"],
json!(2),
"{request_schema_name}"
);
let frame_schema = &parsed["components"]["schemas"]["EditorImageSequenceFrame"];
assert_eq!(
frame_schema["required"],
json!(["imageSrc", "objectKey", "assetObjectId", "width", "height"]),
"{request_schema_name}"
);
for stable_field in ["objectKey", "assetObjectId"] {
assert_eq!(
frame_schema["properties"][stable_field]["type"],
json!("string"),
"{request_schema_name}.{stable_field}"
);
assert_eq!(
frame_schema["properties"][stable_field]["minLength"],
json!(1),
"{request_schema_name}.{stable_field}"
);
}
assert_eq!(
request_schema["properties"]["imageSequenceDurationMs"]["minimum"],
json!(1),
"{request_schema_name}"
);
assert_eq!(
conditional["then"]["properties"]["generationInputs"]["$ref"],
"#/components/schemas/ExternalCharacterAnimationGenerationInputs",
"{request_schema_name}"
);
}
}
#[test]
fn external_openapi_non_animation_create_forbids_sequence_metadata() {
let parsed: Value = serde_json::from_str(OPENAPI_JSON).expect("openapi json should parse");
for request_schema_name in EXTERNAL_MEDIA_CREATE_REQUEST_SCHEMAS {
let request_schema = &parsed["components"]["schemas"][request_schema_name];
let conditional = &request_schema["allOf"][0];
assert_eq!(
conditional["if"]["required"],
json!(["assetKind"]),
"{request_schema_name}"
);
let forbidden_fields = conditional["else"]["not"]["anyOf"]
.as_array()
.expect("非角色动作分支必须逐项禁止序列字段")
.iter()
.filter_map(|schema| schema["required"][0].as_str())
.collect::<Vec<_>>();
assert_eq!(
forbidden_fields,
["imageSequenceFrames", "imageSequenceDurationMs"],
"{request_schema_name}"
);
}
}
#[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");
assert_eq!(parsed["openapi"], "3.1.0");
let legacy_runtime_fields = [
"characterAnimation",
"frames",
"previewVideoPath",
"frameCount",
"fps",
"durationSeconds",
];
let forbidden_generation_inputs = &parsed["components"]["schemas"]["ExternalCharacterAnimationGenerationInputs"]
["not"]["anyOf"];
assert_eq!(
forbidden_generation_inputs
.as_array()
.expect("角色动作 generationInputs 约束必须是数组")
.iter()
.filter_map(|schema| schema["required"][0].as_str())
.collect::<Vec<_>>(),
legacy_runtime_fields
);
for request_schema in EXTERNAL_MEDIA_CREATE_REQUEST_SCHEMAS {
assert_eq!(
parsed["components"]["schemas"][request_schema]["allOf"][0]["if"]["properties"]["assetKind"]
["const"],
"character-animation"
);
}
assert!(
parsed["paths"]
.get("/api/external/v1/editor/projects")
@@ -1300,6 +1531,14 @@ mod tests {
.get("default")
.is_none()
);
let generation_references = &parsed["components"]["schemas"]["EditorImageGenerationRequest"]
["properties"]["referenceImageSrcs"];
assert_eq!(generation_references["maxItems"], 9);
assert!(
generation_references["items"]["description"]
.as_str()
.is_some_and(|description| description.contains("超限返回 400"))
);
assert_eq!(
parsed["components"]["schemas"]["EditorProject"]["properties"]["layers"]["type"],
"array"
@@ -1333,6 +1572,21 @@ mod tests {
.get("targetLayerId")
.is_some()
);
for (schema, max_items) in [
("EditorImageEditRequest", 8),
("EditorIconSpritesheetGenerationRequest", 8),
("EditorUiDesignAssetExtractionRequest", 5),
] {
let references =
&parsed["components"]["schemas"][schema]["properties"]["referenceImageSrcs"];
assert_eq!(references["maxItems"], max_items, "{schema}");
assert!(
references["items"]["description"]
.as_str()
.is_some_and(|description| description.contains("超限返回 400")),
"{schema}"
);
}
assert!(
parsed["paths"]
.get("/api/external/v1/editor/icon-spritesheets/generations")
@@ -1276,8 +1276,12 @@ fn compact_editor_generation_result(mut result: Value) -> Value {
let Some(resource) = object.get_mut(field).and_then(Value::as_object_mut) else {
continue;
};
resource
.retain(|key, _| matches!(key.as_str(), "resourceId" | "objectKey" | "assetObjectId"));
resource.retain(|key, _| {
matches!(
key.as_str(),
"resourceId" | "objectKey" | "assetObjectId" | "sourceResourceId"
)
});
}
if let Some(icon_image_srcs) = object
.get_mut("iconImageSrcs")
@@ -1289,7 +1293,10 @@ fn compact_editor_generation_result(mut result: Value) -> Value {
};
if let Some(resource) = icon.get_mut("resource").and_then(Value::as_object_mut) {
resource.retain(|key, _| {
matches!(key.as_str(), "resourceId" | "objectKey" | "assetObjectId")
matches!(
key.as_str(),
"resourceId" | "objectKey" | "assetObjectId" | "sourceResourceId"
)
});
}
icon.retain(|key, _| {
@@ -1385,7 +1392,7 @@ fn compact_external_api_generation_result(result: Value) -> Value {
frame.retain(|key, _| {
matches!(
key.as_str(),
"frameIndex" | "imageSrc" | "objectKey" | "width" | "height"
"frameIndex" | "imageSrc" | "objectKey" | "assetObjectId" | "width" | "height"
)
});
remove_unstable_external_generation_media_fields(frame);
@@ -1417,6 +1424,7 @@ fn compact_external_generation_resource(resource: &mut serde_json::Map<String, V
| "sourceType"
| "assetKind"
| "taskId"
| "sourceResourceId"
)
});
remove_unstable_external_generation_media_fields(resource);
@@ -2088,6 +2096,7 @@ mod tests {
"resourceId": "icon-resource-1",
"objectKey": "users/user-1/backpack.png",
"assetObjectId": "icon-object-1",
"sourceResourceId": "sheet-resource",
"imageSrc": "data:image/png;base64,SHOULD_NOT_PERSIST",
},
"asset": { "assetId": "icon-asset-1" },
@@ -2101,6 +2110,7 @@ mod tests {
"resourceId": "icon-resource-2",
"objectKey": "users/user-1/map.png",
"assetObjectId": "icon-object-2",
"sourceResourceId": "sheet-resource",
},
},
],
@@ -2122,6 +2132,7 @@ mod tests {
"resourceId": "icon-resource-1",
"objectKey": "users/user-1/backpack.png",
"assetObjectId": "icon-object-1",
"sourceResourceId": "sheet-resource",
})
);
assert!(
@@ -2263,6 +2274,7 @@ mod tests {
"projectId": "project-1",
"objectKey": "users/user-1/generated/main.png",
"assetObjectId": "asset-object-main",
"sourceResourceId": "source-resource-main",
"imageSrc": "https://cdn.example.test/main.png?signature=secret",
"width": 1024,
"height": 1024,
@@ -2279,6 +2291,23 @@ mod tests {
"height": 1024,
"generationInputs": {"private": true}
},
"frames": [
{
"imageSrc": "/generated/action/frame-01.png",
"objectKey": "generated/action/frame-01.png",
"assetObjectId": "asset-object-frame-01",
"width": 192,
"height": 256,
"provider": "internal-provider-must-not-persist"
},
{
"imageSrc": "/generated/action/frame-02.png",
"objectKey": "generated/action/frame-02.png",
"assetObjectId": "asset-object-frame-02",
"width": 192,
"height": 256
}
],
"project": {
"projectId": "project-1",
"canvas": {"layers": ["large-layout-must-not-persist"]}
@@ -2318,6 +2347,10 @@ mod tests {
);
assert_eq!(result["assetObjectId"], json!("asset-object-main"));
assert_eq!(result["resource"]["resourceId"], json!("resource-main"));
assert_eq!(
result["resource"]["sourceResourceId"],
json!("source-resource-main")
);
assert_eq!(
result["resource"]["objectKey"],
json!("users/user-1/generated/main.png")
@@ -2331,6 +2364,16 @@ mod tests {
);
assert!(result["asset"].get("thumbnailSrc").is_none());
assert!(result["asset"].get("generationInputs").is_none());
assert_eq!(
result["frames"][0],
json!({
"imageSrc": "/generated/action/frame-01.png",
"objectKey": "generated/action/frame-01.png",
"assetObjectId": "asset-object-frame-01",
"width": 192,
"height": 256
})
);
assert_eq!(
result["warning"],
json!({
@@ -83,6 +83,7 @@ pub(crate) fn service() -> GenarrativeExternalMcpService {
.with_allowed_hosts([
"www.genarrative.world",
"genarrative.world",
"dev.genarrative.world",
"localhost",
"127.0.0.1",
"::1",
@@ -90,6 +91,7 @@ pub(crate) fn service() -> GenarrativeExternalMcpService {
.with_allowed_origins([
"https://www.genarrative.world",
"https://genarrative.world",
"https://dev.genarrative.world",
"http://localhost:3000",
"http://127.0.0.1:3000",
]);
@@ -603,7 +605,7 @@ mod tests {
use axum::{
http::{
StatusCode,
header::{ACCEPT, HOST},
header::{ACCEPT, HOST, ORIGIN},
},
middleware,
};
@@ -759,6 +761,47 @@ mod tests {
);
}
#[tokio::test]
async fn streamable_http_accepts_dev_host_and_origin_without_weakening_guards() {
for (host, origin, expected_status) in [
("dev.genarrative.world", None, StatusCode::OK),
(
"dev.genarrative.world",
Some("https://dev.genarrative.world"),
StatusCode::OK,
),
("untrusted.example", None, StatusCode::FORBIDDEN),
(
"dev.genarrative.world",
Some("https://untrusted.example"),
StatusCode::FORBIDDEN,
),
] {
let mut request = Request::builder()
.method(Method::POST)
.uri("/api/external/v1/mcp")
.header(HOST, host)
.header(CONTENT_TYPE, "application/json")
.header(ACCEPT, "application/json, text/event-stream");
if let Some(origin) = origin {
request = request.header(ORIGIN, origin);
}
let request = request
.body(Body::from(
r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"dev-host-test-agent","version":"1.0"}}}"#,
))
.expect("development initialize request should build");
let response = service()
.oneshot(request)
.await
.expect("MCP service should be infallible");
assert_eq!(response.status(), expected_status, "host={host}");
assert!(response.headers().get("mcp-session-id").is_none());
}
}
#[tokio::test]
async fn streamable_http_initialize_is_stateless_json() {
let request = Request::builder()
@@ -68,6 +68,24 @@ impl AppError {
self
}
/// 中文注释:在已有 details 上补一个字段,而不是像 `with_details` 那样整体替换。
/// 用于给沿途传上来的错误追加旁路信息(例如「失败发生在持久化开始之后」),同时保留
/// 下游原本写入的 provider / message 等字段——客户端要靠 message 定位,靠新字段决策。
pub fn with_detail_field(mut self, key: &'static str, value: Value) -> Self {
match self.details.take() {
Some(Value::Object(mut object)) => {
object.insert(key.to_string(), value);
self.details = Some(Value::Object(object));
}
// 中文注释:details 为空或不是对象时按对象重建。本仓库的 details 一律是对象,
// 走到后一个分支说明调用方写法有变,宁可保留标记也不静默丢弃。
_ => {
self.details = Some(serde_json::json!({ key: value }));
}
}
self
}
pub fn with_header(mut self, name: &'static str, value: HeaderValue) -> Self {
self.headers.insert(name, value);
self
@@ -22,9 +22,9 @@ use crate::{
get_editor_asset_library, get_editor_generation_pricing, get_editor_project,
list_editor_projects, list_public_editor_project_resources, load_recent_editor_project,
remove_editor_image_background, rename_editor_project, save_editor_project_layout,
split_editor_icon_spritesheet, submit_editor_asset_showcase,
toggle_editor_showcase_asset_like, update_editor_asset, update_editor_asset_folder,
update_editor_project_resource_showcase,
snap_editor_image_to_pixel_art, split_editor_icon_spritesheet,
submit_editor_asset_showcase, toggle_editor_showcase_asset_like, update_editor_asset,
update_editor_asset_folder, update_editor_project_resource_showcase,
},
state::AppState,
};
@@ -205,6 +205,13 @@ pub fn router(state: AppState) -> Router<AppState> {
require_bearer_auth,
)),
)
.route(
"/api/editor/images/pixel-art-snaps",
post(snap_editor_image_to_pixel_art).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/editor/icon-spritesheets/generations",
post(generate_editor_icon_spritesheet).route_layer(middleware::from_fn_with_state(
+42
View File
@@ -272,6 +272,7 @@ pub struct AppStateInner {
bgfilter_image_validation_limiter: Arc<Semaphore>,
character_animation_oss_http_client: reqwest::Client,
character_animation_oss_io_limiter: Arc<Semaphore>,
editor_oss_http_client: reqwest::Client,
#[cfg(any())]
creative_agent_executor: Arc<MockLangChainRustAgentExecutor>,
// Phase 1 任务 E 的 creative session facade 暂存在 api-server。
@@ -530,6 +531,7 @@ impl AppState {
let character_animation_oss_http_client = build_character_animation_oss_http_client()?;
let character_animation_oss_io_limiter =
Arc::new(Semaphore::new(CHARACTER_ANIMATION_OSS_MAX_CONCURRENCY));
let editor_oss_http_client = build_editor_oss_http_client()?;
let http_request_permit_pools = HttpRequestPermitPools::from_config(&config);
let (profile_recharge_order_updates, _) = broadcast::channel(128);
@@ -577,6 +579,7 @@ impl AppState {
bgfilter_image_validation_limiter,
character_animation_oss_http_client,
character_animation_oss_io_limiter,
editor_oss_http_client,
#[cfg(any())]
creative_agent_executor: Arc::new(MockLangChainRustAgentExecutor),
#[cfg(any())]
@@ -1317,6 +1320,10 @@ impl AppState {
self.character_animation_oss_io_limiter.clone()
}
pub fn editor_oss_http_client(&self) -> &reqwest::Client {
&self.editor_oss_http_client
}
#[cfg(any())]
pub fn creative_agent_executor(&self) -> Arc<MockLangChainRustAgentExecutor> {
self.creative_agent_executor.clone()
@@ -2089,6 +2096,31 @@ fn build_character_animation_oss_http_client() -> Result<reqwest::Client, AppSta
})
}
// 中文注释:编辑器图片的 OSS 读写此前每次调用都 `reqwest::Client::new()`,既没有任何
// 超时也没有连接复用——一个半开或黑洞的连接可以无限期挂住,同时占着 HTTP 准入许可和
// 已经读入的最多 32 MiB 图片缓冲。这里收口成进程级共享客户端,给整段请求(含 body
// 流式读写)一个绝对上界,作为所有调用方的兜底。读写共用一个客户端,两个方向都受同一份
// EDITOR_REFERENCE_IMAGE_MAX_SIZE_BYTES 约束,合用也能让 GET / PUT / HEAD 复用连接池。
//
// connect 取 10s:同区域 OSS 建连是百毫秒级,30s 只会让不可达端点多占 20s 槽位。
// total 取 120s:按较慢的写方向定尺寸——32 MiB 上传在 60s 内要求持续约 4.4 Mbps,
// 留一倍余量避免误伤正常流量。读方向不因此变松:完美像素的 GET 另受 30s 处理预算约束,
// 客户端超时只是兜底。
fn build_editor_oss_http_client() -> Result<reqwest::Client, AppStateInitError> {
reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(120))
.pool_idle_timeout(std::time::Duration::from_secs(300))
.pool_max_idle_per_host(8)
.tcp_keepalive(std::time::Duration::from_secs(60))
.build()
.map_err(|error| {
AppStateInitError::DependencyUnavailable(format!(
"初始化编辑器 OSS HTTP 客户端失败:{error}"
))
})
}
fn build_wechat_client(config: &AppConfig) -> WechatClient {
WechatClient::new(WechatConfig {
app_id: config.wechat_mini_program_app_id.clone(),
@@ -2236,6 +2268,16 @@ mod tests {
);
}
#[test]
fn app_state_reuses_editor_oss_client() {
let state = AppState::new(AppConfig::default()).expect("state should build");
assert!(std::ptr::eq(
state.editor_oss_http_client(),
state.editor_oss_http_client(),
));
}
#[test]
fn bgfilter_image_validation_limiter_is_bounded_per_process_role() {
let parent = AppState::new(AppConfig::default()).expect("parent state should build");
@@ -23,7 +23,7 @@ use crate::{
editor_project::{
EditorCanvasGeneratedLayerInput, PersistEditorGeneratedAssetRequest,
build_editor_canvas_generated_layer_item, complete_editor_canvas_generation_with_items,
persist_editor_generated_media_asset,
persist_editor_generated_media_asset, with_editor_media_duration_generation_input,
},
http_error::AppError,
request_context::RequestContext,
@@ -220,6 +220,10 @@ pub(crate) async fn generate_editor_sound_effect_for_owner(
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
let normalized = normalize_editor_sound_effect_request_with_pricing(payload, &pricing)
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
let generation_inputs = with_editor_media_duration_generation_input(
generation_inputs,
u32::from(normalized.duration),
);
let settings = require_vector_engine_audio_settings(&state)
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
let http_client = platform_audio::build_vector_engine_audio_http_client(&settings)
@@ -284,6 +288,8 @@ pub(crate) async fn generate_editor_sound_effect_for_owner(
generation_inputs: generation_inputs.clone(),
thumbnail_src: None,
generation_cost_mud_points: u64::from(normalized.price_mud_points),
image_sequence_frames: None,
image_sequence_duration_ms: None,
},
)
.await
@@ -301,7 +307,6 @@ pub(crate) async fn generate_editor_sound_effect_for_owner(
resource_id,
title: completion.title.trim().to_string(),
src: audio_src.clone(),
media_type: Some("audio".to_string()),
thumbnail_src: None,
original_width: EDITOR_AUDIO_WIDTH,
original_height: EDITOR_AUDIO_HEIGHT,
@@ -315,12 +320,9 @@ pub(crate) async fn generate_editor_sound_effect_for_owner(
task_id: Some(generated.task_id.clone()),
object_key: generated.object_key.clone(),
asset_object_id: generated.asset_object_id.clone(),
duration_seconds: Some(u32::from(normalized.duration)),
source_resource_id: None,
asset_kind: Some("sound-effect".to_string()),
generation_inputs,
image_sequence_frames: None,
preview_video_path: None,
},
);
complete_editor_canvas_generation_with_items(
@@ -521,6 +523,8 @@ pub(crate) async fn generate_editor_background_music_for_owner(
generation_inputs: generation_inputs.clone(),
thumbnail_src: None,
generation_cost_mud_points: u64::from(normalized.price_mud_points),
image_sequence_frames: None,
image_sequence_duration_ms: None,
},
)
.await
@@ -542,7 +546,6 @@ pub(crate) async fn generate_editor_background_music_for_owner(
}),
title: completion.title.trim().to_string(),
src: audio_src.clone(),
media_type: Some("audio".to_string()),
thumbnail_src: None,
original_width: EDITOR_AUDIO_WIDTH,
original_height: EDITOR_AUDIO_HEIGHT,
@@ -556,12 +559,9 @@ pub(crate) async fn generate_editor_background_music_for_owner(
task_id: Some(generated.task_id.clone()),
object_key: generated.object_key.clone(),
asset_object_id: generated.asset_object_id.clone(),
duration_seconds: None,
source_resource_id: None,
asset_kind: Some("background-music".to_string()),
generation_inputs,
image_sequence_frames: None,
preview_video_path: None,
},
);
complete_editor_canvas_generation_with_items(
+2 -1
View File
@@ -5,7 +5,8 @@ pub mod vector_engine;
pub use pixel_art_snapper::{
PIXEL_ART_ALPHA_COVERAGE_THRESHOLD, PIXEL_ART_ANALYSIS_COLORS, PIXEL_ART_KMEANS_SAMPLE_LIMIT,
PIXEL_ART_MAX_IMAGE_PIXELS, PixelArtSnapError, snap_pixel_art, snap_pixel_art_with_deadline,
PIXEL_ART_MAX_IMAGE_PIXELS, PixelArtSnapError, snap_pixel_art_strict_with_deadline,
snap_pixel_art_with_deadline,
};
pub use vector_engine::{
DownloadedImage, GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, GeneratedImages, NANOBANANA_2_MODEL,
@@ -48,6 +48,7 @@ const DEADLINE_CHECK_INTERVAL: usize = 4_096;
#[derive(Debug)]
pub enum PixelArtSnapError {
InvalidInput(String),
GridNotDetected,
Decode {
input: &'static str,
message: String,
@@ -63,6 +64,7 @@ impl fmt::Display for PixelArtSnapError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidInput(message) => write!(formatter, "像素规整输入无效:{message}"),
Self::GridNotDetected => write!(formatter, "像素规整未识别到可规整的像素网格"),
Self::Decode { input, message } => {
write!(formatter, "像素规整无法解码 {input}:{message}")
}
@@ -154,23 +156,38 @@ impl SnapConfig {
/// image is always a PNG at the original `rgba_source` dimensions. Its alpha
/// channel contains only `0` or `255`, and fully transparent pixels are
/// canonical `[0, 0, 0, 0]`.
pub fn snap_pixel_art(
grid_source: &DownloadedImage,
rgba_source: &DownloadedImage,
) -> Result<DownloadedImage, PixelArtSnapError> {
snap_pixel_art_with_deadline(grid_source, rgba_source, None)
}
/// Deadline-aware variant of [`snap_pixel_art`].
///
/// The deadline is checked before and after non-cooperative codec/resize
/// operations, and periodically inside the K-means, profile, and cell-sampling
/// loops. Exceeding it returns [`PixelArtSnapError::DeadlineExceeded`] without
/// producing a partial image.
/// producing a partial image. Pass `None` to opt out of deadline checks.
pub fn snap_pixel_art_with_deadline(
grid_source: &DownloadedImage,
rgba_source: &DownloadedImage,
deadline: Option<Instant>,
) -> Result<DownloadedImage, PixelArtSnapError> {
snap_pixel_art_with_grid_policy(grid_source, rgba_source, deadline, false)
}
/// Snap an image unless legacy analysis detects no grid step on either axis.
///
/// Unlike [`snap_pixel_art_with_deadline`], this entry does not synthesize a
/// uniform min-dimension/64 grid when neither axis contains a detectable step.
/// Every other detection, walking, sampling, and encoding behavior — including
/// the deadline semantics — is identical.
pub fn snap_pixel_art_strict_with_deadline(
grid_source: &DownloadedImage,
rgba_source: &DownloadedImage,
deadline: Option<Instant>,
) -> Result<DownloadedImage, PixelArtSnapError> {
snap_pixel_art_with_grid_policy(grid_source, rgba_source, deadline, true)
}
fn snap_pixel_art_with_grid_policy(
grid_source: &DownloadedImage,
rgba_source: &DownloadedImage,
deadline: Option<Instant>,
reject_uniform_grid_fallback: bool,
) -> Result<DownloadedImage, PixelArtSnapError> {
let deadline = DeadlineGuard::new(deadline);
deadline.check("输入解码")?;
@@ -197,6 +214,9 @@ pub fn snap_pixel_art_with_deadline(
let (profile_x, profile_y) = compute_profiles(&quantized_grid, deadline)?;
let estimated_x = estimate_step_size(&profile_x, config);
let estimated_y = estimate_step_size(&profile_y, config);
if reject_uniform_grid_fallback && estimated_x.is_none() && estimated_y.is_none() {
return Err(PixelArtSnapError::GridNotDetected);
}
let (step_x, step_y) = resolve_step_sizes(
estimated_x,
estimated_y,
@@ -1076,7 +1096,8 @@ mod tests {
let grid = downloaded_png(RgbaImage::from_pixel(8, 8, Rgba([10, 20, 30, 255])));
let rgba = downloaded_png(RgbaImage::from_pixel(8, 9, Rgba([10, 20, 30, 255])));
let error = snap_pixel_art(&grid, &rgba).expect_err("dimensions should mismatch");
let error = snap_pixel_art_with_deadline(&grid, &rgba, None)
.expect_err("dimensions should mismatch");
assert!(
error
.to_string()
@@ -1102,6 +1123,58 @@ mod tests {
));
}
#[test]
fn strict_mode_rejects_only_when_legacy_would_use_uniform_fallback() {
let source = downloaded_png(RgbaImage::from_pixel(128, 128, Rgba([10, 20, 30, 255])));
let legacy = snap_pixel_art_with_deadline(&source, &source, None)
.expect("legacy generation style should retain its uniform fallback");
let strict = snap_pixel_art_strict_with_deadline(&source, &source, None)
.expect_err("explicit strict action should reject an undetected grid");
assert_eq!(decode_output(&legacy).dimensions(), (128, 128));
assert!(matches!(strict, PixelArtSnapError::GridNotDetected));
}
#[test]
fn strict_mode_matches_legacy_when_either_axis_has_a_detected_step() {
for (vertical_lines, horizontal_lines, expected_axes) in [
(true, false, (true, false)),
(false, true, (false, true)),
(true, true, (true, true)),
] {
let mut image = RgbaImage::from_pixel(128, 128, Rgba([10, 20, 30, 255]));
for y in 0..128 {
for x in 0..128 {
if (vertical_lines && x % 8 == 0) || (horizontal_lines && y % 8 == 0) {
image.put_pixel(x, y, Rgba([240, 220, 80, 255]));
}
}
}
let config = SnapConfig::PRODUCTION;
let quantized = quantize_for_analysis(&image, config, DeadlineGuard::new(None))
.expect("test image should quantize");
let (profile_x, profile_y) = compute_profiles(&quantized, DeadlineGuard::new(None))
.expect("test profiles should compute");
assert_eq!(
(
estimate_step_size(&profile_x, config).is_some(),
estimate_step_size(&profile_y, config).is_some(),
),
expected_axes,
);
let source = downloaded_png(image);
let legacy = snap_pixel_art_with_deadline(&source, &source, None)
.expect("legacy processing should succeed with a detected step");
let strict = snap_pixel_art_strict_with_deadline(&source, &source, None)
.expect("strict processing should reuse the detected legacy step");
assert_eq!(strict.bytes, legacy.bytes);
assert_eq!(strict.mime_type, legacy.mime_type);
assert_eq!(strict.extension, legacy.extension);
}
}
#[test]
fn output_keeps_physical_size_and_uses_nearest_blocks() {
let grid_image = RgbaImage::from_pixel(128, 128, Rgba([0, 0, 0, 255]));
@@ -1121,9 +1194,10 @@ mod tests {
}
}
let output = snap_pixel_art(
let output = snap_pixel_art_with_deadline(
&downloaded_png(grid_image),
&downloaded_png(rgba_image.clone()),
None,
)
.expect("pixel snapping should succeed");
let decoded = decode_output(&output);
@@ -1150,8 +1224,10 @@ mod tests {
}
let rgba = downloaded_png(rgba);
let first = snap_pixel_art(&grid, &rgba).expect("first snap should succeed");
let second = snap_pixel_art(&grid, &rgba).expect("second snap should succeed");
let first =
snap_pixel_art_with_deadline(&grid, &rgba, None).expect("first snap should succeed");
let second =
snap_pixel_art_with_deadline(&grid, &rgba, None).expect("second snap should succeed");
assert_eq!(first.bytes, second.bytes);
for pixel in decode_output(&first).pixels() {
@@ -9,7 +9,10 @@ use super::{
effective_request_timeout_ms, request_budget_exhausted_error,
retry_delay_fits_request_deadline,
},
constants::{GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER},
constants::{
GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES,
VECTOR_ENGINE_NANOBANANA_MAX_REFERENCE_IMAGES, VECTOR_ENGINE_PROVIDER,
},
curl_transport::{
map_curl_error, send_vector_engine_json_request_with_curl,
send_vector_engine_multipart_edit_request_with_curl,
@@ -320,6 +323,15 @@ pub async fn create_vector_engine_nanobanana_generate_content(
reference_images: &[ReferenceImage],
failure_context: &str,
) -> Result<GeneratedImages, PlatformImageError> {
if reference_images.len() > VECTOR_ENGINE_NANOBANANA_MAX_REFERENCE_IMAGES {
return Err(PlatformImageError::InvalidRequest {
provider: VECTOR_ENGINE_PROVIDER,
message: format!(
"{failure_context}:参考图最多允许 {VECTOR_ENGINE_NANOBANANA_MAX_REFERENCE_IMAGES} 张,当前提交 {} 张。",
reference_images.len()
),
});
}
let model = normalize_vector_engine_image_model(model);
let request_url = vector_engine_nanobanana_generate_content_url(settings, model);
let request_body = build_vector_engine_nanobanana_generate_content_request_body(
@@ -329,12 +341,9 @@ pub async fn create_vector_engine_nanobanana_generate_content(
image_size,
reference_images,
);
let reference_image_count = reference_images.iter().take(14).count();
let reference_image_bytes_total: usize = reference_images
.iter()
.take(14)
.map(|image| image.bytes.len())
.sum();
let reference_image_count = reference_images.len();
let reference_image_bytes_total: usize =
reference_images.iter().map(|image| image.bytes.len()).sum();
let request_params = serde_json::json!({
"model": model,
"promptChars": prompt.trim().chars().count(),
@@ -536,16 +545,22 @@ pub async fn create_vector_engine_image_edit_with_references_and_model(
message: format!("{failure_context}:缺少参考图,图片编辑需要至少一张参考图。"),
});
}
if reference_images.len() > VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES {
return Err(PlatformImageError::InvalidRequest {
provider: VECTOR_ENGINE_PROVIDER,
message: format!(
"{failure_context}:参考图最多允许 {VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES} 张,当前提交 {} 张。",
reference_images.len()
),
});
}
let request_url = vector_engine_images_edit_url(settings);
let normalized_size = normalize_image_size_for_model(requested_model, size);
let reference_image_count = reference_images.iter().take(5).count();
let reference_image_bytes_total: usize = reference_images
.iter()
.take(5)
.map(|image| image.bytes.len())
.sum();
let reference_image_count = reference_images.len();
let reference_image_bytes_total: usize =
reference_images.iter().map(|image| image.bytes.len()).sum();
let started_at = std::time::Instant::now();
let mut upstream_model = preferred_vector_engine_upstream_model(requested_model);
let mut recovered_failure_audits = Vec::new();
@@ -1090,6 +1105,63 @@ fn vector_engine_send_retry_jitter_ms() -> u64 {
mod tests {
use super::*;
fn reference_image(index: usize) -> ReferenceImage {
ReferenceImage {
bytes: vec![index as u8],
mime_type: "image/png".to_string(),
file_name: format!("reference-{index}.png"),
}
}
fn test_settings() -> VectorEngineImageSettings {
VectorEngineImageSettings {
base_url: "http://127.0.0.1:9".to_string(),
api_key: "test-key".to_string(),
request_timeout_ms: 1_000,
request_deadline: None,
}
}
#[tokio::test]
async fn gpt_image_edit_rejects_six_references_before_network_send() {
let references = (0..6).map(reference_image).collect::<Vec<_>>();
let error = create_vector_engine_image_edit_with_references_and_model(
&reqwest::Client::new(),
&test_settings(),
GPT_IMAGE_2_MODEL,
"测试提示词",
None,
"1024x1024",
1,
references.as_slice(),
"测试图片编辑失败",
)
.await
.expect_err("the provider boundary must reject the sixth reference");
assert!(matches!(error, PlatformImageError::InvalidRequest { .. }));
}
#[tokio::test]
async fn nanobanana_rejects_fifteen_references_before_network_send() {
let references = (0..15).map(reference_image).collect::<Vec<_>>();
let error = create_vector_engine_nanobanana_generate_content(
&reqwest::Client::new(),
&test_settings(),
super::super::constants::NANOBANANA_2_MODEL,
"测试提示词",
None,
"1:1",
"1K",
references.as_slice(),
"测试图片生成失败",
)
.await
.expect_err("the provider boundary must reject the fifteenth reference");
assert!(matches!(error, PlatformImageError::InvalidRequest { .. }));
}
#[tokio::test]
async fn expired_deadline_stops_generation_before_network_send() {
let settings = VectorEngineImageSettings {
@@ -3,3 +3,5 @@ pub const GPT_IMAGE_2_C_MODEL: &str = "gpt-image-2-c";
pub const NANOBANANA_2_MODEL: &str = "gemini-3.1-flash-image-preview";
pub const VECTOR_ENGINE_GPT_IMAGE_2_MODEL: &str = GPT_IMAGE_2_MODEL;
pub const VECTOR_ENGINE_PROVIDER: &str = "vector-engine";
pub const VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES: usize = 5;
pub const VECTOR_ENGINE_NANOBANANA_MAX_REFERENCE_IMAGES: usize = 14;
@@ -126,7 +126,7 @@ pub(crate) async fn send_vector_engine_multipart_edit_request_with_curl(
let prompt = prompt.to_string();
let negative_prompt = negative_prompt.map(str::to_string);
let normalized_size = normalized_size.to_string();
let reference_images = reference_images.iter().take(5).cloned().collect::<Vec<_>>();
let reference_images = reference_images.to_vec();
tokio::task::spawn_blocking(move || {
send_multipart_edit_request_with_curl_blocking(
request_url.as_str(),
@@ -4,7 +4,7 @@ use std::time::Instant;
use super::{
budget::request_budget_exhausted_error,
constants::VECTOR_ENGINE_PROVIDER,
constants::{VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES, VECTOR_ENGINE_PROVIDER},
error::PlatformImageError,
types::{DownloadedImage, GeneratedImages, ReferenceImage},
};
@@ -129,8 +129,20 @@ pub(crate) async fn resolve_reference_images(
failure_context: &str,
request_deadline: Option<Instant>,
) -> Result<Vec<ReferenceImage>, PlatformImageError> {
let reference_count = reference_images
.iter()
.filter(|source| !source.trim().is_empty())
.count();
if reference_count > VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES {
return Err(PlatformImageError::InvalidRequest {
provider: VECTOR_ENGINE_PROVIDER,
message: format!(
"{failure_context}:参考图最多允许 {VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES} 张,当前提交 {reference_count} 张。"
),
});
}
let mut resolved = Vec::new();
for (index, source) in reference_images.iter().take(5).enumerate() {
for (index, source) in reference_images.iter().enumerate() {
let source = source.trim();
if source.is_empty() {
continue;
@@ -56,7 +56,7 @@ pub fn build_vector_engine_nanobanana_generate_content_request_body(
) -> Value {
let prompt = build_prompt_with_negative(prompt, negative_prompt);
let mut parts = vec![json!({ "text": prompt })];
for reference_image in reference_images.iter().take(14) {
for reference_image in reference_images {
parts.push(json!({
"inline_data": {
"mime_type": reference_image.mime_type,
@@ -287,7 +287,6 @@ pub(crate) fn build_vector_engine_image_edit_request_log_params(
.filter(|value| !value.is_empty());
let references: Vec<Value> = reference_images
.iter()
.take(5)
.enumerate()
.map(|(index, image)| {
json!({
@@ -299,11 +298,8 @@ pub(crate) fn build_vector_engine_image_edit_request_log_params(
})
})
.collect();
let reference_image_bytes_total: usize = reference_images
.iter()
.take(5)
.map(|image| image.bytes.len())
.sum();
let reference_image_bytes_total: usize =
reference_images.iter().map(|image| image.bytes.len()).sum();
json!({
"model": model,
+42 -2
View File
@@ -211,6 +211,18 @@ pub struct AdminEditorAssetListQuery {
pub limit: Option<u32>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminEditorImageSequenceFramePayload {
pub image_src: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub object_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub asset_object_id: Option<String>,
pub width: u32,
pub height: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminEditorAssetPayload {
@@ -243,6 +255,8 @@ pub struct AdminEditorAssetPayload {
pub task_generator: String,
pub task_cost_mud_points: u64,
pub children: Vec<AdminEditorAssetPayload>,
pub image_sequence_frames: Option<Vec<AdminEditorImageSequenceFramePayload>>,
pub image_sequence_duration_ms: Option<u64>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
@@ -300,6 +314,8 @@ pub struct AdminEditorShowcaseAssetPayload {
pub rejected_at: Option<String>,
pub updated_at: String,
pub showcase_category: Option<String>,
pub image_sequence_frames: Option<Vec<AdminEditorImageSequenceFramePayload>>,
pub image_sequence_duration_ms: Option<u64>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
@@ -1038,8 +1054,8 @@ mod tests {
use serde_json::json;
use super::{
AdminConfirmEditorShowcaseCampaignImageUploadRequest, AdminEditorShowcaseAssetPayload,
AdminRechargeRefundManualReviewResolveRequest,
AdminConfirmEditorShowcaseCampaignImageUploadRequest, AdminEditorImageSequenceFramePayload,
AdminEditorShowcaseAssetPayload, AdminRechargeRefundManualReviewResolveRequest,
};
#[test]
@@ -1116,6 +1132,23 @@ mod tests {
rejected_at: None,
updated_at: "2026-07-04T10:00:00.000Z".to_string(),
showcase_category: None,
image_sequence_frames: Some(vec![
AdminEditorImageSequenceFramePayload {
image_src: "/generated/action/frame-01.png".to_string(),
object_key: None,
asset_object_id: None,
width: 192,
height: 256,
},
AdminEditorImageSequenceFramePayload {
image_src: "/generated/action/frame-02.png".to_string(),
object_key: None,
asset_object_id: None,
width: 192,
height: 256,
},
]),
image_sequence_duration_ms: Some(5_000),
};
let value = serde_json::to_value(payload).expect("payload should serialize");
@@ -1126,8 +1159,15 @@ mod tests {
value["thumbnailSrc"],
json!("/generated-character-drafts/editor/spec-thumb.png")
);
assert_eq!(
value["imageSequenceFrames"].as_array().map(Vec::len),
Some(2)
);
assert_eq!(value["imageSequenceDurationMs"], json!(5_000));
assert!(value.get("author_display_name").is_none());
assert!(value.get("author_public_user_code").is_none());
assert!(value.get("thumbnail_src").is_none());
assert!(value.get("image_sequence_frames").is_none());
assert!(value.get("image_sequence_duration_ms").is_none());
}
}
@@ -341,8 +341,11 @@ pub struct EditorCharacterAnimationGenerateRequest {
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EditorCharacterAnimationFramePayload {
pub frame_index: u32,
pub image_src: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub object_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub asset_object_id: Option<String>,
pub width: u32,
pub height: u32,
}
@@ -365,6 +368,10 @@ pub struct EditorCharacterAnimationGenerateResponse {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resource: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub asset: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub queue_state: Option<ExternalGenerationJobStatusRecord>,
}
@@ -1139,8 +1146,9 @@ mod tests {
prompt: "生成游戏角色动画".to_string(),
preview_video_path: "/generated-character-drafts/editor/layer/preview.mp4".to_string(),
frames: vec![EditorCharacterAnimationFramePayload {
frame_index: 1,
image_src: "/generated-animations/editor/layer/frame01.png".to_string(),
object_key: Some("generated-animations/editor/layer/frame01.png".to_string()),
asset_object_id: Some("asset-object-frame01".to_string()),
width: 768,
height: 1024,
}],
@@ -1151,10 +1159,27 @@ mod tests {
fps: 8,
price_mud_points: 40,
project: None,
resource: Some(json!({
"resourceId": "editor-resource-action-1",
"assetKind": "character-animation",
})),
asset: Some(json!({
"assetId": "editor-asset-action-1",
"assetKind": "character-animation",
"frameCount": 2,
})),
queue_state: None,
})
.expect("response should serialize");
assert_eq!(payload["asset"]["assetId"], json!("editor-asset-action-1"));
assert_eq!(
payload["resource"]["resourceId"],
json!("editor-resource-action-1")
);
assert_eq!(payload["asset"]["assetKind"], json!("character-animation"));
assert_eq!(payload["asset"]["frameCount"], json!(2));
assert_eq!(
payload["previewVideoPath"],
json!("/generated-character-drafts/editor/layer/preview.mp4")
@@ -1163,6 +1188,14 @@ mod tests {
payload["frames"][0]["imageSrc"],
json!("/generated-animations/editor/layer/frame01.png")
);
assert_eq!(
payload["frames"][0]["objectKey"],
json!("generated-animations/editor/layer/frame01.png")
);
assert_eq!(
payload["frames"][0]["assetObjectId"],
json!("asset-object-frame01")
);
assert_eq!(payload["fps"], json!(8));
}
@@ -1435,6 +1468,7 @@ mod tests {
);
assert_eq!(response_payload["assetObjectId"], json!("assetobj_audio_1"));
assert_eq!(response_payload["audioKind"], json!("sound-effect"));
assert!(response_payload.get("durationSeconds").is_none());
assert!(response_payload.get("provider").is_none());
assert_eq!(
response_payload["asset"]["assetKind"],
@@ -1,5 +1,5 @@
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
pub const GAME_CREATION_APP_MANIFEST_SCHEMA_VERSION: &str = "game-creation-app.manifest.v1";
pub const GAME_CREATION_AGENT_RUN_SCHEMA_VERSION: &str = "game-creator-agent-run.v1";
@@ -21,7 +21,7 @@ pub struct GameCreationAppCommandDescriptor {
pub permission: GameCreationAppPermission,
}
pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 63] = [
pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 64] = [
command("help.show", GameCreationAppPermission::Auto),
command("project.create", GameCreationAppPermission::Confirm),
command("project.status", GameCreationAppPermission::Auto),
@@ -49,6 +49,7 @@ pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 63] = [
command("agent.delegate", GameCreationAppPermission::Confirm),
command("agent.spawn_isolated", GameCreationAppPermission::Confirm),
command("agent.schedule_ready", GameCreationAppPermission::Confirm),
command("agent.route_manifest", GameCreationAppPermission::Auto),
command("agent.capabilities", GameCreationAppPermission::Auto),
command("agent.audit", GameCreationAppPermission::Auto),
command("llm.config_check", GameCreationAppPermission::Auto),
@@ -611,6 +612,142 @@ pub struct GameCreationAppCommandRunState {
pub updated_at: u64,
}
pub const GAME_ITERATION_VERSION_MAX_COUNT: usize = 4096;
pub const GAME_ITERATION_VERSION_MAX_BINDING_COUNT: usize = 4096;
pub const GAME_ITERATION_VERSION_MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum GameIterationVersionCreatedReason {
Initial,
ResourceReplacement,
AgentRevision,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameIterationVersionResourceBinding {
pub slot_id: String,
pub resource_id: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameIterationVersion {
pub version_id: String,
pub parent_version_id: Option<String>,
pub project_revision: u64,
pub resource_bindings: Vec<GameIterationVersionResourceBinding>,
pub created_reason: GameIterationVersionCreatedReason,
pub created_at: u64,
}
fn validate_iteration_version_id(value: &str, label: &str, max_chars: usize) -> Result<(), String> {
if value.is_empty() || value.trim() != value {
return Err(format!("{label}不能为空或包含首尾空白"));
}
if value.chars().count() > max_chars || value.chars().any(char::is_control) {
return Err(format!("{label}无效"));
}
Ok(())
}
pub fn validate_game_iteration_versions(versions: &[GameIterationVersion]) -> Result<(), String> {
if versions.len() > GAME_ITERATION_VERSION_MAX_COUNT {
return Err(format!(
"项目版本最多支持 {GAME_ITERATION_VERSION_MAX_COUNT} 条"
));
}
let mut previous_versions = HashMap::<&str, (u64, u64)>::new();
for (index, version) in versions.iter().enumerate() {
validate_iteration_version_id(&version.version_id, "版本 ID", 128)?;
if previous_versions.contains_key(version.version_id.as_str()) {
return Err(format!("项目版本 ID 重复:{}", version.version_id));
}
if version.project_revision > GAME_ITERATION_VERSION_MAX_SAFE_INTEGER {
return Err(format!(
"项目版本 {} 的 projectRevision 超出 JavaScript 安全整数范围",
version.version_id
));
}
if version.created_at > GAME_ITERATION_VERSION_MAX_SAFE_INTEGER {
return Err(format!(
"项目版本 {} 的 createdAt 超出 JavaScript 安全整数范围",
version.version_id
));
}
if index == 0 {
if version.parent_version_id.is_some()
|| version.created_reason != GameIterationVersionCreatedReason::Initial
{
return Err(format!(
"项目版本 {} 的首条记录必须是无父版本的 initial 版本",
version.version_id
));
}
} else {
if version.created_reason == GameIterationVersionCreatedReason::Initial {
return Err(format!(
"项目版本 {} 只有首个版本可以使用 initial 创建原因",
version.version_id
));
}
let Some(parent_version_id) = &version.parent_version_id else {
return Err(format!(
"项目版本 {} 只有首个 initial 版本可以没有父版本",
version.version_id
));
};
validate_iteration_version_id(parent_version_id, "父版本 ID", 128)?;
let Some((parent_revision, parent_created_at)) =
previous_versions.get(parent_version_id.as_str())
else {
return Err(format!(
"项目版本 {} 的父版本必须先于子版本存在",
version.version_id
));
};
if version.project_revision <= *parent_revision {
return Err(format!(
"项目版本 {} 的 projectRevision 必须大于父版本",
version.version_id
));
}
if version.created_at < *parent_created_at {
return Err(format!(
"项目版本 {} 的 createdAt 不能早于父版本",
version.version_id
));
}
}
if version.resource_bindings.len() > GAME_ITERATION_VERSION_MAX_BINDING_COUNT {
return Err(format!(
"项目版本 {} 的资源绑定最多支持 {GAME_ITERATION_VERSION_MAX_BINDING_COUNT} 项",
version.version_id
));
}
let mut slot_ids = HashSet::new();
for binding in &version.resource_bindings {
validate_iteration_version_id(&binding.slot_id, "版本资源槽位 ID", 256)?;
validate_iteration_version_id(&binding.resource_id, "版本资源 ID", 512)?;
if !slot_ids.insert(binding.slot_id.as_str()) {
return Err(format!(
"项目版本 {} 的资源槽位重复:{}",
version.version_id, binding.slot_id
));
}
}
previous_versions.insert(
version.version_id.as_str(),
(version.project_revision, version.created_at),
);
}
Ok(())
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameCreationAppManifest {
@@ -626,6 +763,8 @@ pub struct GameCreationAppManifest {
pub preview: Option<GameCreationAppPreviewState>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub command_runs: Vec<GameCreationAppCommandRunState>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub versions: Vec<GameIterationVersion>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
@@ -757,6 +896,7 @@ pub fn new_game_creation_app_manifest(
assets: Vec::new(),
preview: None,
command_runs: Vec::new(),
versions: Vec::new(),
}
}
@@ -796,7 +936,7 @@ mod tests {
#[test]
fn command_contract_keeps_expected_permissions() {
assert_eq!(GAME_CREATION_APP_COMMANDS.len(), 63);
assert_eq!(GAME_CREATION_APP_COMMANDS.len(), 64);
let command_ids = GAME_CREATION_APP_COMMANDS
.iter()
@@ -1010,6 +1150,11 @@ mod tests {
GameCreationAppPermission::Confirm
);
}
let route_manifest = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "agent.route_manifest")
.unwrap();
assert_eq!(route_manifest.permission, GameCreationAppPermission::Auto);
let preview_validate = GAME_CREATION_APP_COMMANDS
.iter()
@@ -1594,6 +1739,17 @@ mod tests {
log_path: ".agent/logs/command.log".to_string(),
updated_at: 123,
});
manifest.versions.push(GameIterationVersion {
version_id: "version-1".to_string(),
parent_version_id: None,
project_revision: 7,
resource_bindings: vec![GameIterationVersionResourceBinding {
slot_id: "player".to_string(),
resource_id: "asset-player".to_string(),
}],
created_reason: GameIterationVersionCreatedReason::Initial,
created_at: 456,
});
manifest.assets.push(GameCreationAppAssetManifestEntry {
id: "asset-player".to_string(),
kind: "character".to_string(),
@@ -1630,6 +1786,95 @@ mod tests {
payload["assets"][0]["source"]["canvasProjectId"],
json!("canvas-project-1")
);
assert_eq!(payload["versions"][0]["versionId"], json!("version-1"));
assert_eq!(
payload["versions"][0]["resourceBindings"][0]["slotId"],
json!("player")
);
assert_eq!(payload["versions"][0]["createdReason"], json!("initial"));
}
#[test]
fn iteration_versions_require_an_append_ordered_parent_graph() {
let root = GameIterationVersion {
version_id: "version-root".to_string(),
parent_version_id: None,
project_revision: 4,
resource_bindings: vec![GameIterationVersionResourceBinding {
slot_id: "player".to_string(),
resource_id: "asset-player".to_string(),
}],
created_reason: GameIterationVersionCreatedReason::Initial,
created_at: 100,
};
let child = GameIterationVersion {
version_id: "version-child".to_string(),
parent_version_id: Some(root.version_id.clone()),
project_revision: 5,
resource_bindings: Vec::new(),
created_reason: GameIterationVersionCreatedReason::AgentRevision,
created_at: 101,
};
validate_game_iteration_versions(&[root.clone(), child.clone()])
.expect("valid version graph");
let mut invalid_child = child.clone();
invalid_child.version_id = root.version_id.clone();
assert!(
validate_game_iteration_versions(&[root.clone(), invalid_child])
.expect_err("reject duplicate version id")
.contains("版本 ID 重复")
);
let mut invalid_child = child.clone();
invalid_child.parent_version_id = Some("missing".to_string());
assert!(
validate_game_iteration_versions(&[root.clone(), invalid_child])
.expect_err("reject missing parent")
.contains("父版本必须先于子版本存在")
);
let mut invalid_child = child.clone();
invalid_child.project_revision = root.project_revision;
assert!(
validate_game_iteration_versions(&[root.clone(), invalid_child])
.expect_err("reject non-increasing revision")
.contains("projectRevision 必须大于父版本")
);
let mut invalid_child = child.clone();
invalid_child.created_at = root.created_at - 1;
assert!(
validate_game_iteration_versions(&[root.clone(), invalid_child])
.expect_err("reject time before parent")
.contains("createdAt 不能早于父版本")
);
let mut invalid_child = child.clone();
invalid_child.project_revision = GAME_ITERATION_VERSION_MAX_SAFE_INTEGER + 1;
assert!(
validate_game_iteration_versions(&[root.clone(), invalid_child])
.expect_err("reject unsafe project revision")
.contains("超出 JavaScript 安全整数范围")
);
let mut invalid_child = child;
invalid_child.resource_bindings = vec![
GameIterationVersionResourceBinding {
slot_id: "player".to_string(),
resource_id: "asset-player".to_string(),
},
GameIterationVersionResourceBinding {
slot_id: "player".to_string(),
resource_id: "asset-player-next".to_string(),
},
];
assert!(
validate_game_iteration_versions(&[root, invalid_child])
.expect_err("reject duplicate slot")
.contains("资源槽位重复")
);
}
#[test]
@@ -73,6 +73,8 @@ pub struct EditorProjectResourceRecord {
pub public_showcase_enabled: bool,
pub created_at: String,
pub updated_at: String,
pub image_sequence_frames: Option<serde_json::Value>,
pub image_sequence_duration_ms: Option<u64>,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
@@ -114,6 +116,8 @@ pub struct EditorAssetRecord {
pub showcase_like_count: Option<u64>,
pub created_at: String,
pub updated_at: String,
pub image_sequence_frames: Option<serde_json::Value>,
pub image_sequence_duration_ms: Option<u64>,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
@@ -141,6 +145,8 @@ pub struct AdminEditorAssetRecord {
pub generation_cost_mud_points: u64,
pub created_at: String,
pub updated_at: String,
pub image_sequence_frames: Option<serde_json::Value>,
pub image_sequence_duration_ms: Option<u64>,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
@@ -186,6 +192,8 @@ pub struct EditorShowcaseAssetRecord {
pub rejected_at: Option<String>,
pub updated_at: String,
pub showcase_category: Option<String>,
pub image_sequence_frames: Option<serde_json::Value>,
pub image_sequence_duration_ms: Option<u64>,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
@@ -293,6 +301,8 @@ pub struct EditorProjectResourceCreateRecordInput {
pub asset_kind: Option<String>,
pub generation_inputs_json: Option<String>,
pub updated_at_micros: i64,
pub image_sequence_frames_json: Option<String>,
pub image_sequence_duration_ms: Option<u64>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -369,6 +379,8 @@ pub struct EditorAssetCreateRecordInput {
pub generation_cost_mud_points: u64,
pub group_task_id: Option<String>,
pub group_task_expected_asset_count: Option<u32>,
pub image_sequence_frames_json: Option<String>,
pub image_sequence_duration_ms: Option<u64>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -608,6 +620,8 @@ impl From<EditorProjectResourceCreateRecordInput>
asset_kind: input.asset_kind,
generation_inputs_json: input.generation_inputs_json,
updated_at_micros: input.updated_at_micros,
image_sequence_frames_json: input.image_sequence_frames_json,
image_sequence_duration_ms: input.image_sequence_duration_ms,
}
}
}
@@ -717,6 +731,8 @@ impl From<EditorAssetCreateRecordInput> for crate::module_bindings::EditorAssetC
generation_cost_mud_points: input.generation_cost_mud_points,
group_task_id: input.group_task_id,
group_task_expected_asset_count: input.group_task_expected_asset_count,
image_sequence_frames_json: input.image_sequence_frames_json,
image_sequence_duration_ms: input.image_sequence_duration_ms,
}
}
}
@@ -1244,10 +1260,14 @@ fn map_editor_project_snapshot(
fn map_editor_project_resource_snapshot(
snapshot: EditorProjectResourceSnapshot,
) -> Result<EditorProjectResourceRecord, SpacetimeClientError> {
let generation_inputs = parse_optional_generation_inputs_json(
let generation_inputs = parse_optional_json(
snapshot.generation_inputs_json.as_deref(),
"图片画布资源生成输入 JSON",
)?;
let image_sequence_frames = parse_optional_json(
snapshot.image_sequence_frames_json.as_deref(),
"图片画布资源序列帧 JSON",
)?;
Ok(EditorProjectResourceRecord {
resource_id: snapshot.resource_id,
project_id: snapshot.project_id,
@@ -1269,6 +1289,8 @@ fn map_editor_project_resource_snapshot(
public_showcase_enabled: snapshot.public_showcase_enabled,
created_at: format_timestamp_micros(snapshot.created_at_micros),
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
image_sequence_frames,
image_sequence_duration_ms: snapshot.image_sequence_duration_ms,
})
}
@@ -1306,10 +1328,14 @@ fn map_editor_asset_folder_snapshot(
fn map_editor_asset_snapshot(
snapshot: EditorAssetSnapshot,
) -> Result<EditorAssetRecord, SpacetimeClientError> {
let generation_inputs = parse_optional_generation_inputs_json(
let generation_inputs = parse_optional_json(
snapshot.generation_inputs_json.as_deref(),
"图片画布素材生成输入 JSON",
)?;
let image_sequence_frames = parse_optional_json(
snapshot.image_sequence_frames_json.as_deref(),
"图片画布素材序列帧 JSON",
)?;
Ok(EditorAssetRecord {
asset_id: snapshot.asset_id,
folder_id: snapshot.folder_id,
@@ -1337,16 +1363,22 @@ fn map_editor_asset_snapshot(
showcase_like_count: snapshot.showcase_like_count,
created_at: format_timestamp_micros(snapshot.created_at_micros),
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
image_sequence_frames,
image_sequence_duration_ms: snapshot.image_sequence_duration_ms,
})
}
fn map_admin_editor_asset_snapshot(
snapshot: AdminEditorAssetSnapshot,
) -> Result<AdminEditorAssetRecord, SpacetimeClientError> {
let generation_inputs = parse_optional_generation_inputs_json(
let generation_inputs = parse_optional_json(
snapshot.generation_inputs_json.as_deref(),
"后台图片画布素材生成输入 JSON",
)?;
let image_sequence_frames = parse_optional_json(
snapshot.image_sequence_frames_json.as_deref(),
"后台图片画布素材序列帧 JSON",
)?;
Ok(AdminEditorAssetRecord {
asset_id: snapshot.asset_id,
owner_user_id: snapshot.owner_user_id,
@@ -1371,16 +1403,22 @@ fn map_admin_editor_asset_snapshot(
generation_cost_mud_points: snapshot.generation_cost_mud_points,
created_at: format_timestamp_micros(snapshot.created_at_micros),
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
image_sequence_frames,
image_sequence_duration_ms: snapshot.image_sequence_duration_ms,
})
}
fn map_editor_showcase_asset_snapshot(
snapshot: EditorShowcaseAssetSnapshot,
) -> Result<EditorShowcaseAssetRecord, SpacetimeClientError> {
let generation_inputs = parse_optional_generation_inputs_json(
let generation_inputs = parse_optional_json(
snapshot.generation_inputs_json.as_deref(),
"陶泥儿精选素材生成输入 JSON",
)?;
let image_sequence_frames = parse_optional_json(
snapshot.image_sequence_frames_json.as_deref(),
"陶泥儿精选素材序列帧 JSON",
)?;
Ok(EditorShowcaseAssetRecord {
showcase_id: snapshot.showcase_id,
asset_id: snapshot.asset_id,
@@ -1419,6 +1457,8 @@ fn map_editor_showcase_asset_snapshot(
rejected_at: snapshot.rejected_at_micros.map(format_timestamp_micros),
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
showcase_category: snapshot.showcase_category,
image_sequence_frames,
image_sequence_duration_ms: snapshot.image_sequence_duration_ms,
})
}
@@ -1468,7 +1508,7 @@ fn map_editor_generation_pricing_config_snapshot(
}
}
fn parse_optional_generation_inputs_json(
fn parse_optional_json(
raw_json: Option<&str>,
label: &str,
) -> Result<Option<serde_json::Value>, SpacetimeClientError> {
@@ -1,5 +1,111 @@
use super::*;
#[derive(Clone, Debug, PartialEq)]
pub struct EditorPixelArtCanvasPlaceholderRecordInput {
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
pub original_width: f64,
pub original_height: f64,
}
#[derive(Clone, Debug, PartialEq)]
pub struct EditorPixelArtCanvasCompletionRecordInput {
pub dialog_id: String,
pub title: String,
pub placeholder: EditorPixelArtCanvasPlaceholderRecordInput,
}
#[derive(Clone, Debug, PartialEq)]
pub struct EditorPixelArtResultPreflightRecordInput {
pub owner_user_id: String,
pub project_id: String,
pub asset_folder_id: String,
pub project_resource: EditorProjectResourceCreateRecordInput,
pub canvas_completion: EditorPixelArtCanvasCompletionRecordInput,
}
#[derive(Clone, Debug, PartialEq)]
pub struct EditorPixelArtResultPersistRecordInput {
pub owner_user_id: String,
pub project_id: String,
pub operation_id: String,
pub operation_fingerprint: String,
pub asset_object: module_assets::AssetObjectUpsertInput,
pub project_resource: EditorProjectResourceCreateRecordInput,
pub asset: EditorAssetCreateRecordInput,
pub canvas_completion: EditorPixelArtCanvasCompletionRecordInput,
pub completed_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq)]
pub struct EditorPixelArtResultPersistRecord {
pub asset_object: module_assets::AssetObjectUpsertSnapshot,
pub project_resource: EditorProjectResourceRecord,
pub asset: EditorAssetRecord,
pub project: Option<EditorProjectRecord>,
}
impl From<EditorPixelArtCanvasPlaceholderRecordInput>
for crate::module_bindings::EditorPixelArtCanvasPlaceholderInput
{
fn from(input: EditorPixelArtCanvasPlaceholderRecordInput) -> Self {
Self {
x: input.x,
y: input.y,
width: input.width,
height: input.height,
original_width: input.original_width,
original_height: input.original_height,
}
}
}
impl From<EditorPixelArtCanvasCompletionRecordInput>
for crate::module_bindings::EditorPixelArtCanvasCompletionInput
{
fn from(input: EditorPixelArtCanvasCompletionRecordInput) -> Self {
Self {
dialog_id: input.dialog_id,
title: input.title,
placeholder: input.placeholder.into(),
}
}
}
impl From<EditorPixelArtResultPreflightRecordInput>
for crate::module_bindings::EditorPixelArtResultPreflightInput
{
fn from(input: EditorPixelArtResultPreflightRecordInput) -> Self {
Self {
owner_user_id: input.owner_user_id,
project_id: input.project_id,
asset_folder_id: input.asset_folder_id,
project_resource: input.project_resource.into(),
canvas_completion: input.canvas_completion.into(),
}
}
}
impl From<EditorPixelArtResultPersistRecordInput>
for crate::module_bindings::EditorPixelArtResultPersistInput
{
fn from(input: EditorPixelArtResultPersistRecordInput) -> Self {
Self {
owner_user_id: input.owner_user_id,
project_id: input.project_id,
operation_id: input.operation_id,
operation_fingerprint: input.operation_fingerprint,
asset_object: input.asset_object.into(),
project_resource: input.project_resource.into(),
asset: input.asset.into(),
canvas_completion: input.canvas_completion.into(),
completed_at_micros: input.completed_at_micros,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EditorSpritesheetSlicePersistItemRecordInput {
pub asset_object: module_assets::AssetObjectUpsertInput,
@@ -57,6 +163,56 @@ impl From<EditorSpritesheetSliceBatchPersistRecordInput>
}
impl SpacetimeClient {
pub async fn preflight_editor_pixel_art_result(
&self,
input: EditorPixelArtResultPreflightRecordInput,
) -> Result<(), SpacetimeClientError> {
let procedure_input = input.into();
self.call_after_connect(
"preflight_editor_pixel_art_result_and_return",
move |connection, sender| {
connection
.procedures()
.preflight_editor_pixel_art_result_and_return_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_editor_pixel_art_result_preflight_result);
send_once(&sender, mapped);
},
);
},
)
.await
}
pub async fn persist_editor_pixel_art_result(
&self,
input: EditorPixelArtResultPersistRecordInput,
) -> Result<EditorPixelArtResultPersistRecord, SpacetimeClientError> {
let procedure_input = input.into();
self.call_after_connect(
"persist_editor_pixel_art_result_and_return",
move |connection, sender| {
connection
.procedures()
.persist_editor_pixel_art_result_and_return_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_editor_pixel_art_result_persist_result);
send_once(&sender, mapped);
},
);
},
)
.await
}
pub async fn persist_editor_spritesheet_slice_batch(
&self,
input: EditorSpritesheetSliceBatchPersistRecordInput,
@@ -981,6 +1137,59 @@ impl SpacetimeClient {
}
}
fn map_editor_pixel_art_result_preflight_result(
result: crate::module_bindings::EditorPixelArtResultPreflightResult,
) -> Result<(), SpacetimeClientError> {
if result.ok {
return Ok(());
}
Err(SpacetimeClientError::procedure_failed(result.error_message))
}
fn map_editor_pixel_art_result_persist_result(
result: crate::module_bindings::EditorPixelArtResultPersistResult,
) -> Result<EditorPixelArtResultPersistRecord, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::procedure_failed(result.error_message));
}
// 中文注释:三种落库状态(Applied / DialogMissing / AlreadyApplied)目前没有任何
// 上层消费方,不再向上透传;但状态缺失说明 procedure 返回体不完整,仍要拦在这里。
if result.status.is_none() {
return Err(SpacetimeClientError::validation_failed(
"完美像素持久化结果缺少状态",
));
}
let asset_object = result.asset_object.ok_or_else(|| {
SpacetimeClientError::validation_failed("完美像素持久化结果缺少 asset object")
})?;
let project_resource = result
.project_resource
.ok_or_else(|| SpacetimeClientError::validation_failed("完美像素持久化结果缺少项目资源"))?;
let asset = result
.asset
.ok_or_else(|| SpacetimeClientError::validation_failed("完美像素持久化结果缺少账号素材"))?;
let project = result
.project
.map(|project| {
map_editor_project_optional_procedure_result(
crate::module_bindings::EditorProjectProcedureResult {
ok: true,
project: Some(project),
error_message: None,
},
)
})
.transpose()?
.flatten();
Ok(EditorPixelArtResultPersistRecord {
asset_object: map_editor_spritesheet_asset_object_snapshot(asset_object),
project_resource: map_editor_spritesheet_project_resource_snapshot(project_resource)?,
asset: map_editor_spritesheet_asset_snapshot(asset)?,
project,
})
}
fn map_editor_spritesheet_slice_batch_persist_result(
result: crate::module_bindings::EditorSpritesheetSliceBatchPersistResult,
) -> Result<EditorSpritesheetSliceBatchPersistRecord, SpacetimeClientError> {
@@ -1060,6 +1269,11 @@ fn map_editor_spritesheet_project_resource_snapshot(
snapshot.generation_inputs_json,
"图片画布图集切片资源生成输入 JSON",
)?,
image_sequence_frames: parse_editor_spritesheet_generation_inputs(
snapshot.image_sequence_frames_json,
"图片画布图集切片资源序列帧 JSON",
)?,
image_sequence_duration_ms: snapshot.image_sequence_duration_ms,
public_showcase_enabled: snapshot.public_showcase_enabled,
created_at: format_timestamp_micros(snapshot.created_at_micros),
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
@@ -1089,6 +1303,11 @@ fn map_editor_spritesheet_asset_snapshot(
snapshot.generation_inputs_json,
"图片画布图集切片素材生成输入 JSON",
)?,
image_sequence_frames: parse_editor_spritesheet_generation_inputs(
snapshot.image_sequence_frames_json,
"图片画布图集切片素材序列帧 JSON",
)?,
image_sequence_duration_ms: snapshot.image_sequence_duration_ms,
source_resource_id: snapshot.source_resource_id,
public_showcase_enabled: snapshot.public_showcase_enabled,
thumbnail_src: snapshot.thumbnail_src,
@@ -1113,3 +1332,185 @@ fn parse_editor_spritesheet_generation_inputs(
})
.transpose()
}
#[cfg(test)]
mod pixel_art_persist_mapper_tests {
use super::*;
fn empty_pixel_art_persist_result(
ok: bool,
) -> crate::module_bindings::EditorPixelArtResultPersistResult {
crate::module_bindings::EditorPixelArtResultPersistResult {
ok,
status: None,
asset_object: None,
project_resource: None,
asset: None,
project: None,
error_message: None,
}
}
fn pixel_art_asset_object_snapshot() -> crate::module_bindings::AssetObjectUpsertSnapshot {
crate::module_bindings::AssetObjectUpsertSnapshot {
asset_object_id: "assetobj_pixel".to_string(),
bucket: "private".to_string(),
object_key: "editor/pixel.png".to_string(),
access_policy: crate::module_bindings::AssetObjectAccessPolicy::Private,
content_type: Some("image/png".to_string()),
content_length: 16,
content_hash: Some("output-sha256".to_string()),
version: 1,
source_job_id: Some("pixel-art-snap-dialog-1".to_string()),
owner_user_id: Some("user-1".to_string()),
profile_id: None,
entity_id: Some("project-1".to_string()),
asset_kind: "image".to_string(),
created_at_micros: 10,
updated_at_micros: 11,
}
}
fn pixel_art_project_resource_snapshot() -> crate::module_bindings::EditorProjectResourceSnapshot
{
crate::module_bindings::EditorProjectResourceSnapshot {
resource_id: "editor-resource-pixel".to_string(),
project_id: "project-1".to_string(),
owner_user_id: "user-1".to_string(),
asset_object_id: Some("assetobj_pixel".to_string()),
image_src: "/api/assets/assetobj_pixel/content".to_string(),
object_key: Some("editor/pixel.png".to_string()),
width: 32,
height: 32,
source_type: "perfect-pixel".to_string(),
prompt: None,
actual_prompt: None,
model: None,
provider: None,
task_id: Some("pixel-art-snap-dialog-1".to_string()),
source_resource_id: Some("source-resource".to_string()),
asset_kind: Some("image".to_string()),
generation_inputs_json: Some(r#"{"source":"canvas"}"#.to_string()),
public_showcase_enabled: false,
created_at_micros: 10,
updated_at_micros: 11,
image_sequence_frames_json: None,
image_sequence_duration_ms: None,
}
}
fn pixel_art_asset_snapshot() -> crate::module_bindings::EditorAssetSnapshot {
crate::module_bindings::EditorAssetSnapshot {
asset_id: "editor-asset-pixel".to_string(),
folder_id: "folder-1".to_string(),
label: "Pixel result".to_string(),
asset_object_id: Some("assetobj_pixel".to_string()),
image_src: "/api/assets/assetobj_pixel/content".to_string(),
object_key: Some("editor/pixel.png".to_string()),
width: 32,
height: 32,
source_type: "perfect-pixel".to_string(),
prompt: None,
actual_prompt: None,
model: None,
provider: None,
task_id: Some("pixel-art-snap-dialog-1".to_string()),
asset_kind: Some("image".to_string()),
generation_inputs_json: Some(r#"{"source":"canvas"}"#.to_string()),
source_resource_id: Some("source-resource".to_string()),
public_showcase_enabled: Some(false),
created_at_micros: 10,
updated_at_micros: 11,
thumbnail_src: None,
generation_cost_mud_points: 0,
showcase_id: None,
showcase_review_status: None,
showcase_display_enabled: None,
showcase_like_count: None,
group_task_id: None,
image_sequence_frames_json: None,
image_sequence_duration_ms: None,
}
}
fn successful_pixel_art_persist_result(
status: crate::module_bindings::EditorPixelArtResultPersistStatus,
) -> crate::module_bindings::EditorPixelArtResultPersistResult {
crate::module_bindings::EditorPixelArtResultPersistResult {
ok: true,
status: Some(status),
asset_object: Some(pixel_art_asset_object_snapshot()),
project_resource: Some(pixel_art_project_resource_snapshot()),
asset: Some(pixel_art_asset_snapshot()),
project: None,
error_message: None,
}
}
#[test]
fn editor_pixel_art_persist_mapper_rejects_procedure_failure_before_snapshots() {
let mut result = empty_pixel_art_persist_result(false);
result.error_message = Some("事务提交失败".to_string());
let error = map_editor_pixel_art_result_persist_result(result)
.expect_err("ok=false 必须保留 procedure 失败");
assert!(matches!(
error,
SpacetimeClientError::Procedure(message) if message == "事务提交失败"
));
}
#[test]
fn editor_pixel_art_persist_mapper_rejects_missing_required_snapshot() {
let mut result = empty_pixel_art_persist_result(true);
result.status = Some(crate::module_bindings::EditorPixelArtResultPersistStatus::Applied);
let error = map_editor_pixel_art_result_persist_result(result)
.expect_err("成功结果缺少 asset object 时必须拒绝");
assert!(matches!(
error,
SpacetimeClientError::Runtime(message)
if message == "完美像素持久化结果缺少 asset object"
));
}
#[test]
fn editor_pixel_art_persist_mapper_rejects_missing_status() {
let mut result = successful_pixel_art_persist_result(
crate::module_bindings::EditorPixelArtResultPersistStatus::Applied,
);
result.status = None;
let error = map_editor_pixel_art_result_persist_result(result)
.expect_err("成功结果缺少状态时必须拒绝");
assert!(matches!(
error,
SpacetimeClientError::Runtime(message)
if message == "完美像素持久化结果缺少状态"
));
}
#[test]
fn editor_pixel_art_persist_mapper_accepts_every_status_and_missing_project() {
let cases = [
crate::module_bindings::EditorPixelArtResultPersistStatus::Applied,
crate::module_bindings::EditorPixelArtResultPersistStatus::DialogMissing,
crate::module_bindings::EditorPixelArtResultPersistStatus::AlreadyApplied,
];
for binding_status in cases {
let mapped = map_editor_pixel_art_result_persist_result(
successful_pixel_art_persist_result(binding_status),
)
.expect("project=None 仍应映射成功");
assert_eq!(mapped.asset_object.asset_object_id, "assetobj_pixel");
assert_eq!(mapped.project_resource.resource_id, "editor-resource-pixel");
assert_eq!(mapped.asset.asset_id, "editor-asset-pixel");
assert!(mapped.project.is_none());
}
}
}

Some files were not shown because too many files have changed in this diff Show More