合并主分支并解决画布复制冲突
同步主分支的编辑器、运行时、后端与运维改动 保留画布副本复用项目资源和结构化持久化行为 采用显式资源持久化状态与图层素材类型覆盖合同 补齐相关测试、技术文档与共享项目记忆
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user