use axum::{ Json, extract::{Extension, Path, Query, State, rejection::JsonRejection}, http::{HeaderMap, HeaderValue, StatusCode, header::CONTENT_TYPE}, response::{IntoResponse, Response}, }; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use shared_contracts::assets::EditorCanvasGenerationCompletionPayload; use shared_contracts::external_generation::{ ExternalEditorGenerationJobResponse, ExternalEditorGenerationSubmissionResponse, ExternalGenerationJobStatus, }; use shared_kernel::build_prefixed_uuid_id; use spacetime_client::{ EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput, EditorAssetFolderDeleteRecordInput, EditorAssetFolderUpdateRecordInput, EditorAssetUpdateRecordInput, EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput, EditorProjectGetRecordInput, EditorProjectRecord, EditorProjectRenameRecordInput, EditorProjectResourceCreateRecordInput, ExternalGenerationJobGetRecordInput, ExternalGenerationJobRecord, SpacetimeClientError, }; use crate::editor_project_icon::{ EditorIconSpritesheetGenerationRequest, enqueue_editor_icon_spritesheet_generation_for_owner, }; use crate::{ api_response::json_success_body, character_animation_assets::{ enqueue_editor_character_animation_for_owner, enqueue_editor_video_generation_for_owner, }, editor_generation_queue::editor_generation_queue_state, editor_project::{ EDITOR_ASSET_FOLDER_ID_PREFIX, EDITOR_ASSET_ID_PREFIX, EDITOR_PROJECT_DEFAULT_TITLE, EDITOR_PROJECT_ID_PREFIX, EDITOR_RESOURCE_ID_PREFIX, EditorAssetFolderPayload, EditorAssetLibraryPayload, EditorAssetPayload, EditorBackgroundRemovalRequest, EditorCanvasViewportPayload, EditorGenerationCaller, EditorImageEditRequest, EditorImageGenerationRequest, EditorProjectPayload, EditorProjectResourcePayload, EditorUiDesignAssetExtractionRequest, current_utc_micros, editor_asset_folder_payload_from_record, editor_asset_library_payload_from_record, editor_asset_payload_from_record, editor_idempotent_create_id, editor_project_payload_from_record, editor_project_resource_payload_from_record, enqueue_editor_background_removal_for_owner, enqueue_editor_image_edit_for_owner, enqueue_editor_image_generation_for_owner, enqueue_editor_ui_design_asset_extraction_for_owner, ensure_generic_editor_image_generation_contract, map_editor_project_error, normalize_editor_persisted_media_src, normalize_optional_string, optional_editor_idempotency_key, 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, http_error::AppError, request_context::RequestContext, state::AppState, vector_engine_audio_generation::{ enqueue_editor_background_music_generation_for_owner, 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"; const SCOPE_EDITOR_CANVAS: &str = "editor:canvas"; const SCOPE_EDITOR_IMAGE_GENERATE: &str = "editor:image-generate"; const SCOPE_EDITOR_ASSET: &str = "editor:asset"; const OPENAPI_JSON: &str = include_str!("../../../../docs/openapi/genarrative-external-v1.openapi.json"); const EXTERNAL_GENERATION_POLL_AFTER_MS: u64 = 1_500; const IDEMPOTENCY_KEY_HEADER: &str = "idempotency-key"; const PROJECT_COVER_SNAPSHOT_ASSET_KIND: &str = "project-cover-snapshot"; #[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] enum ExternalEditorProjectListView { #[default] Full, Summary, } #[derive(Debug, Default, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExternalEditorProjectListQuery { #[serde(default)] view: ExternalEditorProjectListView, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExternalEditorProjectCreateRequest { title: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExternalEditorCanvasSaveRequest { viewport: EditorCanvasViewportPayload, layers: Value, expected_revision: u64, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExternalEditorProjectRenameRequest { title: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExternalEditorAssetFolderCreateRequest { label: String, sort_order: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExternalEditorAssetFolderUpdateRequest { label: Option, collapsed: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExternalEditorAssetCreateRequest { folder_id: String, label: String, image_src: String, object_key: Option, asset_object_id: Option, width: u32, height: u32, source_type: String, prompt: Option, actual_prompt: Option, model: Option, provider: Option, task_id: Option, asset_kind: Option, generation_inputs: Option, image_sequence_frames: Option, image_sequence_duration_ms: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExternalEditorProjectResourceCreateRequest { image_src: String, object_key: Option, asset_object_id: Option, width: u32, height: u32, source_type: String, prompt: Option, actual_prompt: Option, model: Option, provider: Option, task_id: Option, source_resource_id: Option, asset_kind: Option, generation_inputs: Option, image_sequence_frames: Option, image_sequence_duration_ms: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct ExternalEditorBackgroundRemovalRequest { source_image_src: String, project_id: Option, target_layer_id: Option, asset_kind: Option, generation_inputs: Option, asset_folder_id: Option, asset_label: Option, source_resource_id: Option, canvas_completion: Option, } impl From for EditorBackgroundRemovalRequest { fn from(payload: ExternalEditorBackgroundRemovalRequest) -> Self { Self { source_image_src: payload.source_image_src, project_id: payload.project_id, target_layer_id: payload.target_layer_id, asset_kind: payload.asset_kind, generation_inputs: payload.generation_inputs, asset_folder_id: payload.asset_folder_id, asset_label: payload.asset_label, source_resource_id: payload.source_resource_id, task_id: None, canvas_completion: payload.canvas_completion, } } } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExternalEditorAssetUpdateRequest { label: Option, folder_id: Option, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ExternalEditorProjectResponse { project: EditorProjectPayload, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ExternalEditorProjectRecentResponse { project: Option, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ExternalEditorProjectListResponse { projects: Vec, } #[derive(Debug, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub struct ExternalEditorProjectSummary { project_id: String, title: String, updated_at: String, cover: Option, } #[derive(Debug, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub struct ExternalEditorProjectCoverSummary { resource_id: String, object_key: String, width: u32, height: u32, updated_at: String, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ExternalEditorProjectSummaryListResponse { projects: Vec, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ExternalEditorProjectDeleteResponse { deleted_project_id: String, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ExternalEditorAssetLibraryResponse { library: EditorAssetLibraryPayload, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ExternalEditorAssetFolderResponse { folder: EditorAssetFolderPayload, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ExternalEditorAssetFolderDeleteResponse { library: EditorAssetLibraryPayload, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ExternalEditorAssetResponse { asset: EditorAssetPayload, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ExternalEditorProjectResourceResponse { resource: EditorProjectResourcePayload, } pub async fn openapi_json() -> Response { ( [(CONTENT_TYPE, "application/json; charset=utf-8")], OPENAPI_JSON, ) .into_response() } pub async fn create_external_editor_project( State(state): State, headers: HeaderMap, Extension(request_context): Extension, Extension(principal): Extension, Json(payload): Json, ) -> Result, AppError> { require_scope(&principal, SCOPE_EDITOR_PROJECT)?; let owner_user_id = principal.owner_user_id().to_string(); let project_id = optional_editor_idempotency_key(&headers)? .map(|key| { editor_idempotent_create_id( EDITOR_PROJECT_ID_PREFIX, &owner_user_id, "external-editor-project", key, ) }) .unwrap_or_else(|| build_prefixed_uuid_id(EDITOR_PROJECT_ID_PREFIX)); let project = state .spacetime_client() .create_editor_project(EditorProjectCreateRecordInput { project_id, owner_user_id, title: normalize_project_title(payload.title), now_micros: current_utc_micros(), }) .await .map_err(map_editor_project_error)?; Ok(json_success_body( Some(&request_context), ExternalEditorProjectResponse { project: editor_project_payload_from_record(project), }, )) } pub async fn list_external_editor_projects( State(state): State, Query(query): Query, Extension(request_context): Extension, Extension(principal): Extension, ) -> Result, AppError> { require_scope(&principal, SCOPE_EDITOR_PROJECT)?; let projects = state .spacetime_client() .list_editor_projects(principal.owner_user_id().to_string()) .await .map_err(map_editor_project_error)?; match query.view { ExternalEditorProjectListView::Full => Ok(json_success_body( Some(&request_context), ExternalEditorProjectListResponse { projects: projects .into_iter() .map(editor_project_payload_from_record) .collect(), }, )), ExternalEditorProjectListView::Summary => Ok(json_success_body( Some(&request_context), ExternalEditorProjectSummaryListResponse { projects: projects .into_iter() .map(external_editor_project_summary_from_record) .collect(), }, )), } } fn external_editor_project_summary_from_record( record: EditorProjectRecord, ) -> ExternalEditorProjectSummary { let cover = record .resources .iter() .filter_map(|resource| { (resource.asset_kind.as_deref() == Some(PROJECT_COVER_SNAPSHOT_ASSET_KIND)) .then_some(resource) .zip( resource .object_key .as_deref() .filter(|object_key| !object_key.trim().is_empty()), ) }) .max_by(|(left, _), (right, _)| { left.updated_at .cmp(&right.updated_at) .then_with(|| left.resource_id.cmp(&right.resource_id)) }) .map(|(resource, object_key)| ExternalEditorProjectCoverSummary { resource_id: resource.resource_id.clone(), object_key: object_key.to_string(), width: resource.width, height: resource.height, updated_at: resource.updated_at.clone(), }); ExternalEditorProjectSummary { project_id: record.project_id, title: record.title, updated_at: record.updated_at, cover, } } pub async fn load_recent_external_editor_project( State(state): State, Extension(request_context): Extension, Extension(principal): Extension, ) -> Result, AppError> { require_scope(&principal, SCOPE_EDITOR_PROJECT)?; let project = state .spacetime_client() .get_recent_editor_project(principal.owner_user_id().to_string()) .await .map_err(map_editor_project_error)? .map(editor_project_payload_from_record); Ok(json_success_body( Some(&request_context), ExternalEditorProjectRecentResponse { project }, )) } pub async fn get_external_editor_project( State(state): State, Path(project_id): Path, Extension(request_context): Extension, Extension(principal): Extension, ) -> Result, AppError> { require_scope(&principal, SCOPE_EDITOR_PROJECT)?; let project = state .spacetime_client() .get_editor_project(EditorProjectGetRecordInput { project_id, owner_user_id: principal.owner_user_id().to_string(), }) .await .map_err(map_editor_project_error)?; Ok(json_success_body( Some(&request_context), ExternalEditorProjectResponse { project: editor_project_payload_from_record(project), }, )) } pub async fn rename_external_editor_project( State(state): State, Path(project_id): Path, Extension(request_context): Extension, Extension(principal): Extension, Json(payload): Json, ) -> Result, AppError> { require_scope(&principal, SCOPE_EDITOR_PROJECT)?; let project = state .spacetime_client() .rename_editor_project(EditorProjectRenameRecordInput { project_id, owner_user_id: principal.owner_user_id().to_string(), title: payload.title, updated_at_micros: current_utc_micros(), }) .await .map_err(map_editor_project_error)?; Ok(json_success_body( Some(&request_context), ExternalEditorProjectResponse { project: editor_project_payload_from_record(project), }, )) } pub async fn delete_external_editor_project( State(state): State, Path(project_id): Path, Extension(request_context): Extension, Extension(principal): Extension, ) -> Result, AppError> { require_scope(&principal, SCOPE_EDITOR_PROJECT)?; let deleted_project_id = state .spacetime_client() .delete_editor_project(EditorProjectDeleteRecordInput { project_id, owner_user_id: principal.owner_user_id().to_string(), }) .await .map_err(map_editor_project_error)?; Ok(json_success_body( Some(&request_context), ExternalEditorProjectDeleteResponse { deleted_project_id }, )) } pub async fn save_external_editor_canvas( State(state): State, Path(project_id): Path, Extension(request_context): Extension, Extension(principal): Extension, Json(payload): Json, ) -> Result, AppError> { require_scope(&principal, SCOPE_EDITOR_CANVAS)?; let project = save_editor_project_layout_with_revision_and_get( &state, project_id.as_str(), principal.owner_user_id(), payload.viewport.into_record(), payload.layers, payload.expected_revision, ) .await?; Ok(json_success_body( Some(&request_context), ExternalEditorProjectResponse { project: editor_project_payload_from_record(project), }, )) } pub async fn get_external_editor_asset_library( State(state): State, Extension(request_context): Extension, Extension(principal): Extension, ) -> Result, AppError> { require_scope(&principal, SCOPE_EDITOR_ASSET)?; let library = state .spacetime_client() .get_editor_asset_library(principal.owner_user_id().to_string(), current_utc_micros()) .await .map_err(map_editor_project_error)?; Ok(json_success_body( Some(&request_context), ExternalEditorAssetLibraryResponse { library: editor_asset_library_payload_from_record(library), }, )) } pub async fn create_external_editor_asset_folder( State(state): State, headers: HeaderMap, Extension(request_context): Extension, Extension(principal): Extension, Json(payload): Json, ) -> Result, AppError> { require_scope(&principal, SCOPE_EDITOR_ASSET)?; let owner_user_id = principal.owner_user_id().to_string(); let folder_id = optional_editor_idempotency_key(&headers)? .map(|key| { editor_idempotent_create_id( EDITOR_ASSET_FOLDER_ID_PREFIX, &owner_user_id, "external-editor-asset-folder", key, ) }) .unwrap_or_else(|| build_prefixed_uuid_id(EDITOR_ASSET_FOLDER_ID_PREFIX)); let folder = state .spacetime_client() .create_editor_asset_folder(EditorAssetFolderCreateRecordInput { folder_id, owner_user_id, label: payload.label, sort_order: payload.sort_order.unwrap_or(100), now_micros: current_utc_micros(), }) .await .map_err(map_editor_project_error)?; Ok(json_success_body( Some(&request_context), ExternalEditorAssetFolderResponse { folder: editor_asset_folder_payload_from_record(folder), }, )) } pub async fn update_external_editor_asset_folder( State(state): State, Path(folder_id): Path, Extension(request_context): Extension, Extension(principal): Extension, Json(payload): Json, ) -> Result, AppError> { require_scope(&principal, SCOPE_EDITOR_ASSET)?; let folder = state .spacetime_client() .update_editor_asset_folder(EditorAssetFolderUpdateRecordInput { folder_id, owner_user_id: principal.owner_user_id().to_string(), label: normalize_optional_string(payload.label), collapsed: payload.collapsed, updated_at_micros: current_utc_micros(), }) .await .map_err(map_editor_project_error)?; Ok(json_success_body( Some(&request_context), ExternalEditorAssetFolderResponse { folder: editor_asset_folder_payload_from_record(folder), }, )) } pub async fn delete_external_editor_asset_folder( State(state): State, Path(folder_id): Path, Extension(request_context): Extension, Extension(principal): Extension, ) -> Result, AppError> { require_scope(&principal, SCOPE_EDITOR_ASSET)?; let library = state .spacetime_client() .delete_editor_asset_folder(EditorAssetFolderDeleteRecordInput { folder_id, owner_user_id: principal.owner_user_id().to_string(), updated_at_micros: current_utc_micros(), }) .await .map_err(map_editor_project_error)?; Ok(json_success_body( Some(&request_context), ExternalEditorAssetFolderDeleteResponse { library: editor_asset_library_payload_from_record(library), }, )) } pub async fn create_external_editor_asset( State(state): State, Extension(request_context): Extension, Extension(principal): Extension, Json(payload): Json, ) -> Result, AppError> { require_scope(&principal, SCOPE_EDITOR_ASSET)?; 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 .spacetime_client() .create_editor_asset(EditorAssetCreateRecordInput { asset_id: build_prefixed_uuid_id(EDITOR_ASSET_ID_PREFIX), owner_user_id: principal.owner_user_id().to_string(), folder_id: payload.folder_id, label: payload.label, asset_object_id: normalize_optional_string(payload.asset_object_id), image_src, object_key, width: payload.width, height: payload.height, source_type: payload.source_type, prompt: normalize_optional_string(payload.prompt), actual_prompt: normalize_optional_string(payload.actual_prompt), model: normalize_optional_string(payload.model), provider: normalize_optional_string(payload.provider), task_id: normalize_optional_string(payload.task_id), asset_kind: normalize_optional_string(payload.asset_kind), generation_inputs_json, source_resource_id: None, generation_cost_mud_points: 0, now_micros: current_utc_micros(), 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)?; Ok(json_success_body( Some(&request_context), ExternalEditorAssetResponse { asset: editor_asset_payload_from_record(asset), }, )) } pub async fn update_external_editor_asset( State(state): State, Path(asset_id): Path, Extension(request_context): Extension, Extension(principal): Extension, Json(payload): Json, ) -> Result, AppError> { require_scope(&principal, SCOPE_EDITOR_ASSET)?; let asset = state .spacetime_client() .update_editor_asset(EditorAssetUpdateRecordInput { asset_id, owner_user_id: principal.owner_user_id().to_string(), label: normalize_optional_string(payload.label), folder_id: normalize_optional_string(payload.folder_id), updated_at_micros: current_utc_micros(), }) .await .map_err(map_editor_project_error)?; Ok(json_success_body( Some(&request_context), ExternalEditorAssetResponse { asset: editor_asset_payload_from_record(asset), }, )) } pub async fn delete_external_editor_asset( State(state): State, Path(asset_id): Path, Extension(request_context): Extension, Extension(principal): Extension, ) -> Result, AppError> { require_scope(&principal, SCOPE_EDITOR_ASSET)?; let asset = state .spacetime_client() .delete_editor_asset(EditorAssetDeleteRecordInput { asset_id, owner_user_id: principal.owner_user_id().to_string(), }) .await .map_err(map_editor_project_error)?; Ok(json_success_body( Some(&request_context), ExternalEditorAssetResponse { asset: editor_asset_payload_from_record(asset), }, )) } pub async fn create_external_editor_project_resource( State(state): State, Path(project_id): Path, headers: HeaderMap, Extension(request_context): Extension, Extension(principal): Extension, Json(payload): Json, ) -> Result, AppError> { require_scope(&principal, SCOPE_EDITOR_ASSET)?; 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 owner_user_id = principal.owner_user_id().to_string(); let resource_id = optional_editor_idempotency_key(&headers)? .map(|key| { editor_idempotent_create_id( EDITOR_RESOURCE_ID_PREFIX, &owner_user_id, "external-editor-project-resource", key, ) }) .unwrap_or_else(|| build_prefixed_uuid_id(EDITOR_RESOURCE_ID_PREFIX)); let resource = state .spacetime_client() .create_editor_project_resource(EditorProjectResourceCreateRecordInput { resource_id, project_id, owner_user_id, asset_object_id: normalize_optional_string(payload.asset_object_id), image_src, object_key, width: payload.width, height: payload.height, source_type: payload.source_type, prompt: normalize_optional_string(payload.prompt), actual_prompt: normalize_optional_string(payload.actual_prompt), model: normalize_optional_string(payload.model), provider: normalize_optional_string(payload.provider), task_id: normalize_optional_string(payload.task_id), source_resource_id: normalize_optional_string(payload.source_resource_id), 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)?; Ok(json_success_body( Some(&request_context), ExternalEditorProjectResourceResponse { resource: editor_project_resource_payload_from_record(resource), }, )) } pub async fn generate_external_editor_image( State(state): State, Extension(request_context): Extension, Extension(principal): Extension, headers: HeaderMap, payload: Result, JsonRejection>, ) -> Result { let Json(payload) = parse_editor_generation_json_payload(payload)?; require_scope(&principal, SCOPE_EDITOR_IMAGE_GENERATE)?; let idempotency_key = require_idempotency_key(&headers)?; ensure_generic_editor_image_generation_contract(&payload)?; let project_id = payload.project_id.clone(); let job = enqueue_editor_image_generation_for_owner( &state, &request_context, &editor_generation_caller(&principal, project_id), payload, Some(idempotency_key), ) .await?; Ok(external_generation_accepted_response(&request_context, job)) } pub async fn edit_external_editor_image( State(state): State, Extension(request_context): Extension, Extension(principal): Extension, headers: HeaderMap, payload: Result, JsonRejection>, ) -> Result { let Json(payload) = parse_editor_generation_json_payload(payload)?; require_scope(&principal, SCOPE_EDITOR_IMAGE_GENERATE)?; let idempotency_key = require_idempotency_key(&headers)?; let project_id = payload.project_id.clone(); let job = enqueue_editor_image_edit_for_owner( &state, &request_context, &editor_generation_caller(&principal, project_id), payload, Some(idempotency_key), ) .await?; Ok(external_generation_accepted_response(&request_context, job)) } pub async fn remove_external_editor_image_background( State(state): State, Extension(request_context): Extension, Extension(principal): Extension, headers: HeaderMap, payload: Result, JsonRejection>, ) -> Result { require_scope_response(&request_context, &principal, SCOPE_EDITOR_IMAGE_GENERATE)?; let idempotency_key = require_idempotency_key(&headers) .map_err(|error| error.into_response_with_context(Some(&request_context)))?; let Json(payload) = parse_external_generation_json_payload(&request_context, payload)?; let payload = EditorBackgroundRemovalRequest::from(payload); let project_id = payload.project_id.clone(); #[cfg(test)] if let Some(job) = state.intercept_test_external_background_removal_enqueue( principal.owner_user_id(), payload.source_image_src.as_str(), idempotency_key, ) { return Ok(external_generation_accepted_response(&request_context, job)); } let job = enqueue_editor_background_removal_for_owner( &state, &request_context, &editor_generation_caller(&principal, project_id), payload, Some(idempotency_key), ) .await .map_err(|error| error.into_response_with_context(Some(&request_context)))?; Ok(external_generation_accepted_response(&request_context, job)) } pub async fn generate_external_editor_icon_spritesheet( State(state): State, Extension(request_context): Extension, Extension(principal): Extension, headers: HeaderMap, payload: Result, JsonRejection>, ) -> Result { let Json(payload) = parse_editor_generation_json_payload(payload)?; require_scope(&principal, SCOPE_EDITOR_IMAGE_GENERATE)?; let idempotency_key = require_idempotency_key(&headers)?; let project_id = payload.project_id.clone(); let job = enqueue_editor_icon_spritesheet_generation_for_owner( &state, &request_context, &editor_generation_caller(&principal, project_id), payload, Some(idempotency_key), ) .await?; Ok(external_generation_accepted_response(&request_context, job)) } pub async fn extract_external_editor_ui_design_assets( State(state): State, Extension(request_context): Extension, Extension(principal): Extension, headers: HeaderMap, Json(payload): Json, ) -> Result { require_scope(&principal, SCOPE_EDITOR_IMAGE_GENERATE)?; let idempotency_key = require_idempotency_key(&headers)?; let project_id = payload.project_id.clone(); let job = enqueue_editor_ui_design_asset_extraction_for_owner( &state, &request_context, &editor_generation_caller(&principal, project_id), payload, Some(idempotency_key), ) .await?; Ok(external_generation_accepted_response(&request_context, job)) } pub async fn generate_external_editor_character_animation( State(state): State, Extension(request_context): Extension, Extension(principal): Extension, headers: HeaderMap, payload: Result< Json, JsonRejection, >, ) -> Result { require_scope_response(&request_context, &principal, SCOPE_EDITOR_IMAGE_GENERATE)?; let idempotency_key = require_idempotency_key(&headers) .map_err(|error| error.into_response_with_context(Some(&request_context)))?; let Json(payload) = parse_external_generation_json_payload(&request_context, payload)?; let job = enqueue_editor_character_animation_for_owner( &state, &request_context, principal.owner_user_id(), payload, Some(idempotency_key), ) .await?; Ok(external_generation_accepted_response(&request_context, job)) } pub async fn generate_external_editor_video( State(state): State, Extension(request_context): Extension, Extension(principal): Extension, headers: HeaderMap, payload: Result, JsonRejection>, ) -> Result { require_scope_response(&request_context, &principal, SCOPE_EDITOR_IMAGE_GENERATE)?; let idempotency_key = require_idempotency_key(&headers) .map_err(|error| error.into_response_with_context(Some(&request_context)))?; let Json(payload) = parse_external_generation_json_payload(&request_context, payload)?; let job = enqueue_editor_video_generation_for_owner( &state, &request_context, principal.owner_user_id(), payload, Some(idempotency_key), ) .await?; Ok(external_generation_accepted_response(&request_context, job)) } fn canonicalize_external_editor_sound_effect_model( value: Option<&str>, ) -> Result<&'static str, AppError> { shared_contracts::assets::canonicalize_editor_sound_effect_model(value).map_err(|message| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": EXTERNAL_EDITOR_PROVIDER, "field": "model", "message": message, })) }) } pub async fn generate_external_editor_sound_effect( State(state): State, Extension(request_context): Extension, Extension(principal): Extension, headers: HeaderMap, payload: Result< Json, JsonRejection, >, ) -> Result { require_scope_response(&request_context, &principal, SCOPE_EDITOR_IMAGE_GENERATE)?; let idempotency_key = require_idempotency_key(&headers) .map_err(|error| error.into_response_with_context(Some(&request_context)))?; let Json(mut payload) = parse_external_generation_json_payload(&request_context, payload)?; let model = canonicalize_external_editor_sound_effect_model(payload.model.as_deref()) .map_err(|error| error.into_response_with_context(Some(&request_context)))?; payload.model = Some(model.to_string()); let job = enqueue_editor_sound_effect_generation_for_owner( &state, &request_context, principal.owner_user_id(), payload, Some(idempotency_key), ) .await?; Ok(external_generation_accepted_response(&request_context, job)) } pub async fn generate_external_editor_background_music( State(state): State, Extension(request_context): Extension, Extension(principal): Extension, headers: HeaderMap, payload: Result< Json, JsonRejection, >, ) -> Result { require_scope_response(&request_context, &principal, SCOPE_EDITOR_IMAGE_GENERATE)?; let idempotency_key = require_idempotency_key(&headers) .map_err(|error| error.into_response_with_context(Some(&request_context)))?; let Json(payload) = parse_external_generation_json_payload(&request_context, payload)?; let job = enqueue_editor_background_music_generation_for_owner( &state, &request_context, principal.owner_user_id(), payload, Some(idempotency_key), ) .await?; Ok(external_generation_accepted_response(&request_context, job)) } pub async fn get_external_editor_generation_job( State(state): State, Path(operation_id): Path, Extension(request_context): Extension, Extension(principal): Extension, ) -> Result, AppError> { require_scope(&principal, SCOPE_EDITOR_IMAGE_GENERATE)?; let input = ExternalGenerationJobGetRecordInput { job_id: operation_id, owner_user_id: principal.owner_user_id().to_string(), }; let summary = state .spacetime_client() .get_external_generation_job_summary(input.clone()) .await .map_err(map_external_generation_lookup_error)?; let detail = map_external_generation_job_status_detail(summary.clone()); let result = if detail.status.status == ExternalGenerationJobStatus::Completed { let artifacts = state .spacetime_client() .get_external_generation_job_generated_artifacts(input) .await .map_err(map_external_generation_lookup_error)?; let payload = artifacts .result_payload_json .as_deref() .and_then(|payload| serde_json::from_str::(payload).ok()) .ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": EXTERNAL_EDITOR_PROVIDER, "message": "生成任务已完成,但结果暂时不可读取。", })) })?; Some(payload.get("result").cloned().ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": EXTERNAL_EDITOR_PROVIDER, "message": "生成任务已完成,但稳定结果引用缺失。", })) })?) } else { None }; let poll_after_ms = matches!( detail.status.status, ExternalGenerationJobStatus::Queued | ExternalGenerationJobStatus::Running ) .then_some(EXTERNAL_GENERATION_POLL_AFTER_MS); Ok(json_success_body( Some(&request_context), ExternalEditorGenerationJobResponse { operation_id: detail.status.operation_id, kind: summary.job_kind, status: detail.status.status, phase_label: detail.status.phase_label, phase_detail: detail.status.phase_detail, progress: detail.status.progress, error: detail.status.error, warning: detail.warning, result, poll_after_ms, updated_at_micros: detail.status.updated_at_micros, }, )) } fn external_generation_accepted_response( request_context: &RequestContext, job: ExternalGenerationJobRecord, ) -> Response { let kind = job.job_kind.clone(); let status = editor_generation_queue_state(job); let status_url = format!("/api/external/v1/generations/{}", status.operation_id); let mut response = ( StatusCode::ACCEPTED, json_success_body( Some(request_context), ExternalEditorGenerationSubmissionResponse { operation_id: status.operation_id, kind, status: status.status, status_url: status_url.clone(), poll_after_ms: EXTERNAL_GENERATION_POLL_AFTER_MS, updated_at_micros: status.updated_at_micros, }, ), ) .into_response(); if let Ok(location) = HeaderValue::from_str(&status_url) { response.headers_mut().insert("location", location); } response .headers_mut() .insert("retry-after", HeaderValue::from_static("2")); response } fn require_idempotency_key(headers: &HeaderMap) -> Result<&str, AppError> { let value = headers .get(IDEMPOTENCY_KEY_HEADER) .and_then(|value| value.to_str().ok()) .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": EXTERNAL_EDITOR_PROVIDER, "message": "生成请求必须携带 Idempotency-Key 请求头。", })) })?; if value.len() > 128 || !value.bytes().all(|byte| (0x21..=0x7e).contains(&byte)) { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": EXTERNAL_EDITOR_PROVIDER, "message": "Idempotency-Key 必须是 1-128 个可打印 ASCII 字符,且不能包含空格。", })), ); } Ok(value) } fn parse_external_generation_json_payload( request_context: &RequestContext, payload: Result, JsonRejection>, ) -> Result, Response> { payload.map_err(|error| { AppError::from_status(StatusCode::BAD_REQUEST) .with_details(json!({ "provider": EXTERNAL_EDITOR_PROVIDER, "message": error.body_text(), })) .into_response_with_context(Some(request_context)) }) } fn map_external_generation_lookup_error(error: SpacetimeClientError) -> AppError { if error.to_string().contains("不存在") { AppError::from_status(StatusCode::NOT_FOUND) } else { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": EXTERNAL_EDITOR_PROVIDER, "message": "生成任务状态暂时不可用。", })) } } fn editor_generation_caller( principal: &ExternalApiPrincipal, project_id: Option, ) -> EditorGenerationCaller { EditorGenerationCaller { owner_user_id: principal.owner_user_id().to_string(), audit_subject_user_id: Some(principal.owner_user_id().to_string()), audit_project_id: normalize_optional_string(project_id), phase_reporter: None, operation: None, } } fn require_scope(principal: &ExternalApiPrincipal, scope: &str) -> Result<(), AppError> { if principal.has_scope(scope) { return Ok(()); } Err( AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({ "provider": EXTERNAL_EDITOR_PROVIDER, "keyId": principal.key_id(), "scope": scope, "message": "API Key 缺少所需权限", })), ) } fn require_scope_response( request_context: &RequestContext, principal: &ExternalApiPrincipal, scope: &str, ) -> Result<(), Response> { require_scope(principal, scope) .map_err(|error| error.into_response_with_context(Some(request_context))) } fn normalize_project_title(title: Option) -> String { title .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .unwrap_or_else(|| EDITOR_PROJECT_DEFAULT_TITLE.to_string()) } fn serialize_external_editor_generation_inputs( asset_kind: Option<&str>, generation_inputs: Option, ) -> Result, AppError> { serialize_editor_generation_inputs( asset_kind, sanitize_editor_untrusted_generation_inputs(generation_inputs), ) } fn serialize_external_editor_image_sequence_frames( frames: Option, ) -> Result, AppError> { crate::editor_project::serialize_editor_image_sequence_frames(frames) } #[cfg(test)] mod tests { use super::*; use axum::{Router, body::Body, routing::post}; use spacetime_client::{ EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectResourceRecord, }; use tower::ServiceExt; fn external_editor_project_resource_fixture( resource_id: &str, asset_kind: Option<&str>, object_key: Option<&str>, updated_at: &str, ) -> EditorProjectResourceRecord { serde_json::from_value(json!({ "resource_id": resource_id, "project_id": "proj-summary-test", "owner_user_id": "user-1", "asset_object_id": format!("asset-object-{resource_id}"), "image_src": format!("/api/assets/{resource_id}"), "object_key": object_key, "width": 320, "height": 240, "source_type": "uploaded", "prompt": null, "actual_prompt": null, "model": null, "provider": null, "task_id": null, "source_resource_id": null, "asset_kind": asset_kind, "generation_inputs": null, "public_showcase_enabled": false, "created_at": updated_at, "updated_at": updated_at })) .expect("项目资源 fixture 应兼容可选字段扩展") } fn external_editor_project_record_fixture( resources: Vec, ) -> EditorProjectRecord { EditorProjectRecord { project_id: "proj-summary-test".to_string(), owner_user_id: "user-1".to_string(), title: "摘要项目".to_string(), canvas: EditorCanvasRecord { canvas_id: "canvas-summary-test".to_string(), project_id: "proj-summary-test".to_string(), title: "摘要项目".to_string(), viewport: EditorCanvasViewportRecord { x: 0.0, y: 0.0, scale: 1.0, }, layers: json!([{"large": "canvas payload must not enter summary"}]), revision: 3, layout_storage_version: 1, background_color: None, created_at: "2026-08-01T00:00:00Z".to_string(), updated_at: "2026-08-07T00:00:00Z".to_string(), }, viewport: EditorCanvasViewportRecord { x: 0.0, y: 0.0, scale: 1.0, }, layers: json!([{"large": "project payload must not enter summary"}]), resources, created_at: "2026-08-01T00:00:00Z".to_string(), updated_at: "2026-08-07T00:00:00Z".to_string(), } } #[test] fn external_sound_effect_model_canonicalizer_freezes_the_t1_matrix() { for accepted in [ None, Some(""), Some(" \t\r\n"), Some("\u{0085}\u{2003}\u{00a0}"), Some("eleven_text_to_sound_v2"), Some("\u{0085} eleven_text_to_sound_v2 \u{2003}"), ] { assert_eq!( canonicalize_external_editor_sound_effect_model(accepted) .expect("accepted model form should canonicalize"), shared_contracts::assets::EDITOR_SOUND_EFFECT_MODEL, "accepted={accepted:?}" ); } for rejected in [ "audio1.0", "AUDIO1.0", "Eleven_text_to_sound_v2", "eleven_text_to_sound_v3", "\u{200b}", "\u{feff}", ] { let error = canonicalize_external_editor_sound_effect_model(Some(rejected)) .expect_err("legacy and unknown non-empty models should be rejected"); assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); assert!(error.body_text().contains("model")); } } 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(), dedupe_key: "external-api-generation:editor_image_generation:fingerprint".to_string(), job_kind: "editor_image_generation".to_string(), owner_user_id: "user-1".to_string(), source_module: "editor-canvas".to_string(), source_entity_id: "project-1".to_string(), request_label: "图片画布生成图片".to_string(), request_payload_json: "{}".to_string(), status: status.to_string(), attempt: 0, max_attempts: 1, last_error_message: None, worker_id: None, lease_expires_at: None, available_at: "2026-07-31T00:00:00Z".to_string(), result_payload_json: None, created_at: "2026-07-31T00:00:00Z".to_string(), started_at: None, completed_at: None, updated_at: "2026-07-31T00:00:00Z".to_string(), updated_at_micros: 1_785_456_000_000_000, lease_token: None, price_mud_points: 2, refund_ledger_id: None, notification_acknowledged_at: None, notification_acknowledged_at_micros: None, phase: None, } } fn request_context(wants_envelope: bool) -> RequestContext { RequestContext::new( "req-external-generation-test".to_string(), "POST /api/external/v1/editor/images/generations".to_string(), std::time::Duration::ZERO, wants_envelope, ) } #[test] fn external_editor_project_list_query_defaults_to_full_and_accepts_summary() { let without_view = "http://localhost/api/external/v1/editor/projects" .parse() .expect("测试 URI 应合法"); let Query(query) = Query::::try_from_uri(&without_view) .expect("缺省 view 应保持 full 兼容语义"); assert_eq!(query.view, ExternalEditorProjectListView::Full); let explicit_full = "http://localhost/api/external/v1/editor/projects?view=full" .parse() .expect("测试 URI 应合法"); let Query(query) = Query::::try_from_uri(&explicit_full) .expect("显式 full 应合法"); assert_eq!(query.view, ExternalEditorProjectListView::Full); let summary = "http://localhost/api/external/v1/editor/projects?view=summary" .parse() .expect("测试 URI 应合法"); let Query(query) = Query::::try_from_uri(&summary) .expect("summary 应合法"); assert_eq!(query.view, ExternalEditorProjectListView::Summary); let unknown = "http://localhost/api/external/v1/editor/projects?view=compact" .parse() .expect("测试 URI 应合法"); assert!( Query::::try_from_uri(&unknown).is_err(), "未知 view 不得静默回落到 full" ); } #[test] fn external_editor_project_summary_selects_latest_persisted_cover() { let project = external_editor_project_record_fixture(vec![ external_editor_project_resource_fixture( "resource-cover-old", Some(PROJECT_COVER_SNAPSHOT_ASSET_KIND), Some("editor/project-covers/old.webp"), "2026-08-02T00:00:00Z", ), external_editor_project_resource_fixture( "resource-other-newer", Some("editor_generated_image"), Some("editor/generated/newer.webp"), "2026-08-06T00:00:00Z", ), external_editor_project_resource_fixture( "resource-cover-empty-key", Some(PROJECT_COVER_SNAPSHOT_ASSET_KIND), Some(" "), "2026-08-07T00:00:00Z", ), external_editor_project_resource_fixture( "resource-cover-latest", Some(PROJECT_COVER_SNAPSHOT_ASSET_KIND), Some("editor/project-covers/latest.webp"), "2026-08-05T00:00:00Z", ), ]); let summary = external_editor_project_summary_from_record(project); let serialized = serde_json::to_value(&summary).expect("项目摘要应可序列化"); assert_eq!( serialized, json!({ "projectId": "proj-summary-test", "title": "摘要项目", "updatedAt": "2026-08-07T00:00:00Z", "cover": { "resourceId": "resource-cover-latest", "objectKey": "editor/project-covers/latest.webp", "width": 320, "height": 240, "updatedAt": "2026-08-05T00:00:00Z" } }) ); assert!(serialized.get("canvas").is_none()); assert!(serialized.get("layers").is_none()); assert!(serialized.get("resources").is_none()); } #[test] fn external_editor_project_summary_returns_null_without_persisted_cover() { let project = external_editor_project_record_fixture(vec![ external_editor_project_resource_fixture( "resource-non-cover", Some("editor_generated_image"), Some("editor/generated/image.webp"), "2026-08-06T00:00:00Z", ), external_editor_project_resource_fixture( "resource-cover-without-object", Some(PROJECT_COVER_SNAPSHOT_ASSET_KIND), None, "2026-08-07T00:00:00Z", ), ]); let summary = external_editor_project_summary_from_record(project); assert_eq!(summary.cover, None); assert_eq!( serde_json::to_value(summary).expect("无封面摘要应可序列化")["cover"], Value::Null ); } #[test] fn nineteen_large_projects_stay_below_mcp_limit_in_summary_view() { const MCP_LIMIT_BYTES: usize = 4 * 1024 * 1024; let projects = (0..19) .map(|index| { let mut project = external_editor_project_record_fixture(Vec::new()); project.project_id = format!("proj-summary-{index}"); project.canvas.project_id = project.project_id.clone(); project.title = format!("项目 {index}"); project.layers = json!({"largePayload": "x".repeat(160_000)}); project.canvas.layers = json!({"largePayload": "x".repeat(160_000)}); project }) .collect::>(); let full = serde_json::to_vec( &projects .iter() .cloned() .map(editor_project_payload_from_record) .collect::>(), ) .expect("完整项目列表应可序列化"); let summaries = projects .into_iter() .map(external_editor_project_summary_from_record) .collect::>(); let summary = serde_json::to_vec(&summaries).expect("项目摘要列表应可序列化"); assert!( full.len() > MCP_LIMIT_BYTES, "测试数据必须先复现完整列表超过 MCP 上限" ); assert_eq!(summaries.len(), 19); assert!( summary.len() < MCP_LIMIT_BYTES, "同一批项目的摘要必须低于 MCP 上限" ); assert!( !summary .windows(b"largePayload".len()) .any(|window| { window == b"largePayload" }) ); } #[test] fn external_editor_canvas_save_request_requires_expected_revision() { let missing_revision = serde_json::from_value::(json!({ "viewport": { "x": 0.0, "y": 0.0, "scale": 1.0 }, "layers": [], })) .expect_err("外部画布保存缺少 expectedRevision 时必须在进入写路径前失败"); assert!(missing_revision.to_string().contains("expectedRevision")); let request = serde_json::from_value::(json!({ "viewport": { "x": 0.0, "y": 0.0, "scale": 1.0 }, "layers": [], "expectedRevision": 7, })) .expect("外部画布保存携带 expectedRevision 时应通过反序列化"); assert_eq!(request.expected_revision, 7); } #[test] 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["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] fn external_generation_requires_bounded_printable_idempotency_key() { let missing = HeaderMap::new(); let error = require_idempotency_key(&missing).expect_err("外部生成必须显式提供幂等键"); assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); assert!(error.body_text().contains("Idempotency-Key")); let mut whitespace = HeaderMap::new(); whitespace.insert(IDEMPOTENCY_KEY_HEADER, HeaderValue::from_static(" ")); assert!(require_idempotency_key(&whitespace).is_err()); let mut valid = HeaderMap::new(); let longest_valid = "x".repeat(128); valid.insert( IDEMPOTENCY_KEY_HEADER, HeaderValue::from_str(&longest_valid).expect("128 字节可打印 ASCII 应是合法 header"), ); assert_eq!( require_idempotency_key(&valid).expect("边界长度幂等键应通过"), longest_valid ); for invalid in [ "x".repeat(129), "contains space".to_string(), "中文".to_string(), ] { let mut headers = HeaderMap::new(); headers.insert( IDEMPOTENCY_KEY_HEADER, HeaderValue::from_str(&invalid).expect("测试值应可构造为 HTTP header"), ); let error = require_idempotency_key(&headers) .expect_err("超长、含空格或非 ASCII 的幂等键必须拒绝"); assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); } } #[test] fn external_project_create_rejects_an_invalid_optional_idempotency_header() { let mut headers = HeaderMap::new(); headers.insert( IDEMPOTENCY_KEY_HEADER, HeaderValue::from_static("contains space"), ); let error = optional_editor_idempotency_key(&headers) .expect_err("External project create 必须拒绝非法可选幂等键"); assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); let source = include_str!("external_editor_api.rs"); let handler = source .split_once("pub async fn create_external_editor_project(") .and_then(|(_, tail)| { tail.split_once("pub async fn list_external_editor_projects(") .map(|(body, _)| body) }) .expect("external project create handler"); assert!(handler.contains("optional_editor_idempotency_key(&headers)?")); } #[tokio::test] async fn external_generation_submission_is_accepted_with_poll_contract() { let response = external_generation_accepted_response( &request_context(false), external_generation_job_fixture("pending"), ); assert_eq!(response.status(), StatusCode::ACCEPTED); assert_eq!( response .headers() .get("location") .and_then(|value| value.to_str().ok()), Some("/api/external/v1/generations/task-external-test") ); assert_eq!( response .headers() .get("retry-after") .and_then(|value| value.to_str().ok()), Some("2") ); let body = axum::body::to_bytes(response.into_body(), 64 * 1024) .await .expect("submission body 应可读取"); let payload: Value = serde_json::from_slice(&body).expect("submission body 应为 JSON"); assert_eq!(payload["operationId"], json!("task-external-test")); assert_eq!(payload["kind"], json!("editor_image_generation")); assert_eq!(payload["status"], json!("queued")); assert_eq!( payload["statusUrl"], json!("/api/external/v1/generations/task-external-test") ); assert_eq!( payload["pollAfterMs"], json!(EXTERNAL_GENERATION_POLL_AFTER_MS) ); } #[tokio::test] async fn idempotent_completed_submission_reports_completed_in_envelope() { let response = external_generation_accepted_response( &request_context(true), external_generation_job_fixture("completed"), ); let body = axum::body::to_bytes(response.into_body(), 64 * 1024) .await .expect("submission envelope 应可读取"); let payload: Value = serde_json::from_slice(&body).expect("submission envelope 应为 JSON"); assert_eq!(payload["ok"], json!(true)); assert_eq!(payload["data"]["operationId"], json!("task-external-test")); assert_eq!(payload["data"]["status"], json!("completed")); assert_eq!( payload["data"]["pollAfterMs"], json!(EXTERNAL_GENERATION_POLL_AFTER_MS) ); } async fn assert_external_generic_image_scene_bypass_is_rejected_before_queueing( case_name: &str, idempotency_key: &str, request_body: Value, ) { let state = AppState::new(crate::config::AppConfig::default()) .expect("external image test state should build"); state.fail_test_editor_generation_enqueue(); let app = Router::new() .route( "/api/external/v1/editor/images/generations", post(generate_external_editor_image), ) .layer(Extension(request_context(false))) .layer(Extension(ExternalApiPrincipal::for_test( "user-external-scene-bypass", &[SCOPE_EDITOR_IMAGE_GENERATE], ))) .with_state(state.clone()); let response = app .oneshot( axum::http::Request::builder() .method("POST") .uri("/api/external/v1/editor/images/generations") .header("content-type", "application/json") .header(IDEMPOTENCY_KEY_HEADER, idempotency_key) .body(Body::from(request_body.to_string())) .expect("external scene bypass request should build"), ) .await .expect("external scene bypass response should return"); let status = response.status(); let body = axum::body::to_bytes(response.into_body(), 64 * 1024) .await .expect("external scene bypass response body should collect"); let body_text = String::from_utf8_lossy(&body); assert_eq!( (status, state.test_editor_generation_enqueue_attempts()), (StatusCode::BAD_REQUEST, 0), "{case_name} must fail at the generic External boundary before queueing: {body_text}", ); assert!( body_text.contains("/api/editor/scenes/generations"), "{case_name} should direct callers to the dedicated scene contract: {body_text}", ); } #[tokio::test] async fn external_generic_image_generation_rejects_scene_kind_before_queueing() { assert_external_generic_image_scene_bypass_is_rejected_before_queueing( "scene kind", "scene-bypass-kind", json!({ "prompt": "绕过后端场景 Prompt 组装", "kind": "scene", }), ) .await; } #[tokio::test] async fn external_background_removal_rejects_undocumented_fields_before_queueing() { let state = AppState::new(crate::config::AppConfig::default()) .expect("external background removal test state should build"); state.fail_test_editor_generation_enqueue(); let app = Router::new() .route( "/api/external/v1/editor/images/background-removals", post(remove_external_editor_image_background), ) .layer(Extension(request_context(false))) .layer(Extension(ExternalApiPrincipal::for_test( "user-external-background-removal", &[SCOPE_EDITOR_IMAGE_GENERATE], ))) .with_state(state.clone()); for (case_name, extra_field) in [ ( "internal taskId", json!({"taskId": "caller-controlled-task"}), ), ("unknown field", json!({"unexpected": true})), ] { let mut request_body = json!({"sourceImageSrc": "editor-upload/source.png"}); request_body .as_object_mut() .expect("background removal body should be an object") .extend( extra_field .as_object() .expect("extra field fixture should be an object") .clone(), ); let response = app .clone() .oneshot( axum::http::Request::builder() .method("POST") .uri("/api/external/v1/editor/images/background-removals") .header("content-type", "application/json") .header(IDEMPOTENCY_KEY_HEADER, "background-removal-contract-test") .body(Body::from(request_body.to_string())) .expect("external background removal request should build"), ) .await .expect("external background removal response should return"); assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{case_name}"); assert_eq!( state.test_editor_generation_enqueue_attempts(), 0, "{case_name} must fail before queueing", ); } } #[tokio::test] async fn external_background_removal_rejects_unstable_sources_before_queueing() { let state = AppState::new(crate::config::AppConfig::default()) .expect("external background removal source test state should build"); state.fail_test_editor_generation_enqueue(); let app = Router::new() .route( "/api/external/v1/editor/images/background-removals", post(remove_external_editor_image_background), ) .layer(Extension(request_context(false))) .layer(Extension(ExternalApiPrincipal::for_test( "user-external-background-removal-source", &[SCOPE_EDITOR_IMAGE_GENERATE], ))) .with_state(state.clone()); for source_image_src in [ "data:image/png;base64,AAAA", "blob:browser-only", "https://oss.example/private.png?signature=temporary", ] { let response = app .clone() .oneshot( axum::http::Request::builder() .method("POST") .uri("/api/external/v1/editor/images/background-removals") .header("content-type", "application/json") .header( IDEMPOTENCY_KEY_HEADER, "background-removal-source-contract-test", ) .body(Body::from( json!({"sourceImageSrc": source_image_src}).to_string(), )) .expect("external background removal source request should build"), ) .await .expect("external background removal source response should return"); assert_eq!( response.status(), StatusCode::BAD_REQUEST, "{source_image_src}" ); assert_eq!( state.test_editor_generation_enqueue_attempts(), 0, "unstable source must fail before queueing: {source_image_src}", ); } } #[tokio::test] async fn external_background_removal_rejects_non_static_asset_kind_before_queueing() { let state = AppState::new(crate::config::AppConfig::default()) .expect("external background removal kind test state should build"); state.fail_test_editor_generation_enqueue(); let app = Router::new() .route( "/api/external/v1/editor/images/background-removals", post(remove_external_editor_image_background), ) .layer(Extension(request_context(false))) .layer(Extension(ExternalApiPrincipal::for_test( "user-external-background-removal-kind", &[SCOPE_EDITOR_IMAGE_GENERATE], ))) .with_state(state.clone()); for asset_kind in ["video", "audio", "character-animation", "image-sequence"] { let response = app .clone() .oneshot( axum::http::Request::builder() .method("POST") .uri("/api/external/v1/editor/images/background-removals") .header("content-type", "application/json") .header( IDEMPOTENCY_KEY_HEADER, format!("background-removal-kind-{asset_kind}"), ) .body(Body::from( json!({ "sourceImageSrc": "editor-upload/source.png", "assetKind": asset_kind, }) .to_string(), )) .expect("external background removal kind request should build"), ) .await .expect("external background removal kind response should return"); assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{asset_kind}"); assert_eq!( state.test_editor_generation_enqueue_attempts(), 0, "non-static kind must fail before queueing: {asset_kind}", ); } } #[tokio::test] async fn external_background_removal_rejects_target_without_project_before_queueing() { let state = AppState::new(crate::config::AppConfig::default()) .expect("external background removal target test state should build"); state.fail_test_editor_generation_enqueue(); let app = Router::new() .route( "/api/external/v1/editor/images/background-removals", post(remove_external_editor_image_background), ) .layer(Extension(request_context(false))) .layer(Extension(ExternalApiPrincipal::for_test( "user-external-background-removal-target", &[SCOPE_EDITOR_IMAGE_GENERATE], ))) .with_state(state.clone()); let response = app .oneshot( axum::http::Request::builder() .method("POST") .uri("/api/external/v1/editor/images/background-removals") .header("content-type", "application/json") .header( IDEMPOTENCY_KEY_HEADER, "background-removal-target-contract-test", ) .body(Body::from( json!({ "sourceImageSrc": "editor-upload/source.png", "targetLayerId": "layer-source", }) .to_string(), )) .expect("external background removal target request should build"), ) .await .expect("external background removal target response should return"); assert_eq!(response.status(), StatusCode::BAD_REQUEST); assert_eq!( state.test_editor_generation_enqueue_attempts(), 0, "target without project must fail before queueing", ); } #[tokio::test] async fn external_background_removal_route_accepts_a_valid_submission_once() { const OWNER_USER_ID: &str = "user-external-background-removal-success"; const SOURCE_IMAGE_SRC: &str = "editor-upload/background-removal-source.png"; const IDEMPOTENCY_KEY: &str = "background-removal-success-contract-test"; const OPERATION_ID: &str = "task-external-background-removal-success"; let state = AppState::new(crate::config::AppConfig::default()) .expect("external background removal success test state should build"); let mut queued_job = external_generation_job_fixture("pending"); queued_job.job_id = OPERATION_ID.to_string(); queued_job.job_kind = "editor_background_removal".to_string(); queued_job.owner_user_id = OWNER_USER_ID.to_string(); state.set_test_external_background_removal_enqueue( OWNER_USER_ID, SOURCE_IMAGE_SRC, IDEMPOTENCY_KEY, queued_job, ); let request_body = json!({"sourceImageSrc": SOURCE_IMAGE_SRC}).to_string(); let without_scope = Router::new() .route( "/api/external/v1/editor/images/background-removals", post(remove_external_editor_image_background), ) .layer(Extension(request_context(false))) .layer(Extension(ExternalApiPrincipal::for_test( OWNER_USER_ID, &[], ))) .with_state(state.clone()); let forbidden = without_scope .oneshot( axum::http::Request::builder() .method("POST") .uri("/api/external/v1/editor/images/background-removals") .header("content-type", "application/json") .header(IDEMPOTENCY_KEY_HEADER, IDEMPOTENCY_KEY) .body(Body::from(request_body.clone())) .expect("external background removal forbidden request should build"), ) .await .expect("external background removal forbidden response should return"); assert_eq!(forbidden.status(), StatusCode::FORBIDDEN); assert_eq!(state.test_editor_generation_enqueue_attempts(), 0); let app = Router::new() .route( "/api/external/v1/editor/images/background-removals", post(remove_external_editor_image_background), ) .layer(Extension(request_context(false))) .layer(Extension(ExternalApiPrincipal::for_test( OWNER_USER_ID, &[SCOPE_EDITOR_IMAGE_GENERATE], ))) .with_state(state.clone()); let response = app .oneshot( axum::http::Request::builder() .method("POST") .uri("/api/external/v1/editor/images/background-removals") .header("content-type", "application/json") .header(IDEMPOTENCY_KEY_HEADER, IDEMPOTENCY_KEY) .body(Body::from(request_body)) .expect("external background removal success request should build"), ) .await .expect("external background removal success response should return"); assert_eq!(response.status(), StatusCode::ACCEPTED); assert_eq!( response .headers() .get("location") .and_then(|value| value.to_str().ok()), Some("/api/external/v1/generations/task-external-background-removal-success") ); let body = axum::body::to_bytes(response.into_body(), 64 * 1024) .await .expect("external background removal success body should collect"); let payload: Value = serde_json::from_slice(&body) .expect("external background removal success body should be JSON"); assert_eq!(payload["operationId"], json!(OPERATION_ID)); assert_eq!(payload["status"], json!("queued")); assert_eq!( payload["statusUrl"], json!(format!("/api/external/v1/generations/{OPERATION_ID}")) ); assert_eq!( payload["pollAfterMs"], json!(EXTERNAL_GENERATION_POLL_AFTER_MS) ); assert_eq!( state.test_editor_generation_enqueue_attempts(), 1, "valid External background removal should enqueue exactly once", ); } #[tokio::test] async fn external_generic_image_generation_rejects_scene_asset_kind_before_queueing() { assert_external_generic_image_scene_bypass_is_rejected_before_queueing( "scene asset kind", "scene-bypass-asset-kind", json!({ "prompt": "把普通图片伪装成正式场景产物", "assetKind": "scene", }), ) .await; } #[test] fn generation_lookup_hides_cross_owner_jobs_as_not_found() { let not_found = map_external_generation_lookup_error(SpacetimeClientError::Procedure( "external_generation_job 不存在".to_string(), )); assert_eq!(not_found.status_code(), StatusCode::NOT_FOUND); let unavailable = map_external_generation_lookup_error(SpacetimeClientError::ConnectDropped); assert_eq!(unavailable.status_code(), StatusCode::BAD_GATEWAY); 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::>(); 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::>(), 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") .is_some() ); assert!( parsed["paths"] .get("/api/external/v1/assets/direct-upload-tickets") .is_some() ); assert!( parsed["paths"] .get("/api/external/v1/assets/objects/confirm") .is_some() ); assert!( parsed["paths"] .get("/api/external/v1/assets/read-url") .is_some() ); assert!( parsed["paths"]["/api/external/v1/editor/projects"] .get("get") .is_some() ); assert!( parsed["paths"]["/api/external/v1/editor/projects"] .get("post") .is_some() ); for path in [ "/api/external/v1/editor/projects", "/api/external/v1/editor/projects/{projectId}/resources", "/api/external/v1/editor/assets/folders", ] { assert_eq!( parsed["paths"][path]["post"]["responses"]["409"]["$ref"], "#/components/responses/IdempotentCreateConflict", "{path} 必须公开专用稳定创建冲突" ); assert!( parsed["paths"][path]["post"]["parameters"] .as_array() .is_some_and(|parameters| { parameters.iter().any(|parameter| { parameter.get("$ref").and_then(Value::as_str) == Some("#/components/parameters/IdempotentCreateKey") }) }), "{path} 必须公开可选的稳定创建幂等键" ); } assert_eq!( parsed["components"]["parameters"]["IdempotentCreateKey"]["required"], false ); assert!( parsed["components"]["parameters"]["IdempotentCreateKey"]["description"] .as_str() .is_some_and(|description| description.contains("已删除")) ); assert!( parsed["paths"]["/api/external/v1/editor/projects"]["post"]["responses"] .get("400") .is_some(), "External project create 必须声明非法 Idempotency-Key 的 400" ); assert!( parsed["paths"] .get("/api/external/v1/editor/projects/recent") .is_some() ); assert!( parsed["paths"]["/api/external/v1/editor/projects/{projectId}"] .get("delete") .is_some() ); assert!( parsed["paths"] .get("/api/external/v1/editor/projects/{projectId}/metadata") .is_some() ); assert!( parsed["paths"] .get("/api/external/v1/editor/images/generations") .is_some() ); for path in [ "/api/external/v1/editor/images/generations", "/api/external/v1/editor/images/edits", "/api/external/v1/editor/images/background-removals", "/api/external/v1/editor/icon-spritesheets/generations", "/api/external/v1/editor/ui-designs/assets/extractions", "/api/external/v1/editor/character-animations/generations", "/api/external/v1/editor/videos/generations", "/api/external/v1/editor/audios/sound-effects/generations", "/api/external/v1/editor/audios/background-music/generations", ] { let operation = &parsed["paths"][path]["post"]; assert!(operation["responses"].get("202").is_some(), "{path}"); assert!(operation["responses"].get("200").is_none(), "{path}"); assert!( operation["parameters"] .as_array() .is_some_and(|parameters| { parameters.iter().any(|parameter| { parameter.get("$ref").and_then(Value::as_str) == Some("#/components/parameters/IdempotencyKey") }) }) ); } assert!( parsed["paths"] .get("/api/external/v1/generations/{operationId}") .is_some() ); for path in [ "/api/external/v1/agent-integration.json", "/api/external/v1/skill/SKILL.md", "/api/external/v1/skill.zip", "/api/external/v1/mcp", ] { assert!(parsed["paths"].get(path).is_some(), "{path}"); } assert!( parsed["components"]["schemas"]["EditorImageGenerationRequest"]["required"] .as_array() .is_some_and(|required| !required.contains(&json!("priceMudPoints"))) ); assert!( parsed["components"]["schemas"]["EditorImageGenerationRequest"]["properties"] .get("priceMudPoints") .is_none() ); let image_style_schema = &parsed["components"]["schemas"]["EditorImageGenerationRequest"]["properties"]["style"]; assert_eq!(image_style_schema["anyOf"][0]["type"], "string"); assert!(image_style_schema["anyOf"][0].get("enum").is_none()); assert_eq!(image_style_schema["examples"], json!(["none", "pixelArt"])); assert!( parsed["components"]["schemas"]["EditorImageGenerationRequest"]["properties"]["kind"] .get("default") .is_none() ); let image_kind_schema = &parsed["components"]["schemas"]["EditorImageGenerationRequest"]["properties"]["kind"]; assert!( image_kind_schema["enum"] .as_array() .is_some_and(|values| !values.contains(&json!("scene"))) ); assert!( image_kind_schema["description"] .as_str() .is_some_and(|description| description.contains("不开放结构化游戏场景")) ); let image_asset_kind_schema = &parsed["components"]["schemas"]["EditorImageGenerationRequest"] ["properties"]["assetKind"]; assert_eq!( image_asset_kind_schema["anyOf"][0]["not"]["pattern"], json!(r"^\s*scene\s*$") ); assert!( image_asset_kind_schema["description"] .as_str() .is_some_and(|description| description.contains("禁止使用 scene")) ); 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" ); assert_eq!( parsed["components"]["schemas"]["EditorCanvas"]["properties"]["layers"]["type"], "array" ); assert!( parsed["components"]["schemas"]["ExternalEditorCanvasSaveRequest"]["required"] .as_array() .is_some_and(|required| required.contains(&json!("expectedRevision"))) ); assert!( parsed["paths"] .get("/api/external/v1/editor/images/edits") .is_some() ); assert!( parsed["components"]["schemas"]["EditorImageEditRequest"]["required"] .as_array() .is_some_and(|required| required.contains(&json!("sourceReferenceId")) && !required.contains(&json!("priceMudPoints"))) ); assert!( parsed["components"]["schemas"]["EditorImageEditRequest"]["properties"] .get("priceMudPoints") .is_none() ); assert!( parsed["components"]["schemas"]["EditorImageEditRequest"]["properties"] .get("targetLayerId") .is_some() ); let image_edit_schema = &parsed["components"]["schemas"]["EditorImageEditRequest"]["properties"]; for legacy_field in ["sourceImageSrc", "sourceResourceId", "assetKind"] { assert!( image_edit_schema.get(legacy_field).is_none(), "{legacy_field}" ); } assert!( image_edit_schema["sourceReferenceId"]["description"] .as_str() .is_some_and(|description| description.contains("项目资源 ID 或素材 ID") && description.contains("objectKey") && description.contains("未登记上传对象") && description.contains("400")) ); let image_edit_references = &image_edit_schema["referenceImageSrcs"]; assert!( image_edit_references["items"]["description"] .as_str() .is_some_and(|description| description.contains("sourceReferenceId") && !description.contains("sourceImageSrc")) ); assert_eq!( parsed["components"]["schemas"]["EditorImageEditRequest"]["additionalProperties"], json!(false) ); let image_edit_operation = &parsed["paths"]["/api/external/v1/editor/images/edits"]["post"]; assert_eq!( image_edit_operation["x-genarrative-allowed-effective-asset-kinds"], json!([ null, "spec", "character", "icon-spritesheet", "icon-spec", "publication-material", "ui-design", "scene" ]) ); assert!( image_edit_operation["description"] .as_str() .is_some_and(|description| description.contains("targetLayerId") && description.contains("sourceReferenceId") && description.contains("assetObjectId") && description.contains("bucket/objectKey") && description.contains("scene") && description.contains("未知类型") && description.contains("返回 400")) ); assert!(image_edit_operation["responses"].get("400").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") .is_some() ); let icon_spritesheet_request = &parsed["components"]["schemas"]["EditorIconSpritesheetGenerationRequest"]; assert!( icon_spritesheet_request["required"] .as_array() .is_some_and(|required| required.contains(&json!("referenceId"))) ); assert!( icon_spritesheet_request["properties"] .get("referenceImageSrc") .is_none() ); let icon_descriptions = &icon_spritesheet_request["properties"]["iconDescriptions"]; assert_eq!( icon_descriptions["items"]["maxLength"], crate::editor_project_icon::EDITOR_ICON_DESCRIPTION_MAX_CHARS ); assert_eq!( icon_descriptions["x-genarrative-maxTotalCharacters"], crate::editor_project_icon::EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS ); assert_eq!( icon_descriptions["x-genarrative-maxTotalUtf8Bytes"], crate::editor_project_icon::EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES ); assert!( icon_descriptions["description"] .as_str() .is_some_and(|description| description.contains("同步返回 400")) ); assert_eq!( icon_spritesheet_request["properties"]["sliceCount"]["minimum"], json!(1) ); let icon_style_schema = &parsed["components"]["schemas"]["EditorIconSpritesheetGenerationRequest"] ["properties"]["style"]; assert_eq!(icon_style_schema["anyOf"][0]["type"], "string"); assert!(icon_style_schema["anyOf"][0].get("enum").is_none()); assert_eq!(icon_style_schema["examples"], json!(["none", "pixelArt"])); assert_eq!( parsed["components"]["schemas"]["EditorIconSpritesheetGenerationResponse"]["properties"] ["sliceWarning"]["anyOf"][0]["$ref"], "#/components/schemas/EditorIconSpritesheetSliceWarning" ); assert!(parsed["components"]["schemas"]["EditorIconSpritesheetGenerationResponse"]["properties"]["sliceCount"].is_object()); assert_eq!( parsed["components"]["schemas"]["EditorImageGenerationResponse"]["properties"]["warning"] ["anyOf"][0]["$ref"], "#/components/schemas/EditorGenerationWarning" ); assert_eq!( parsed["components"]["schemas"]["EditorIconSpritesheetGenerationResponse"]["properties"] ["warning"]["anyOf"][0]["$ref"], "#/components/schemas/EditorGenerationWarning" ); assert_eq!( parsed["components"]["schemas"]["EditorGenerationWarning"]["required"], json!(["code", "reason"]) ); assert_eq!( parsed["components"]["schemas"]["EditorGenerationWarning"]["properties"]["code"]["enum"], json!([ "postprocess-failed-source-preserved", "dimension-restore-fallback", "unsupported-image-style", "multiple-generation-warnings" ]) ); assert!( parsed["paths"] .get("/api/external/v1/editor/ui-designs/assets/extractions") .is_some() ); let ui_extraction_schema = &parsed["components"]["schemas"]["EditorUiDesignAssetExtractionRequest"]; assert_eq!( ui_extraction_schema["properties"]["aspectRatio"]["const"], "1:1" ); assert_eq!( ui_extraction_schema["properties"]["imageSize"]["enum"], json!(["1K", "2K"]) ); assert_eq!( ui_extraction_schema["properties"]["model"]["default"], "gemini-3.1-flash-image-preview" ); assert!( ui_extraction_schema["properties"] .get("priceMudPoints") .is_none() ); assert!(ui_extraction_schema["properties"].get("style").is_none()); assert!( parsed["paths"] .get("/api/external/v1/editor/videos/generations") .is_some() ); assert!( parsed["paths"] .get("/api/external/v1/editor/audios/sound-effects/generations") .is_some() ); let sound_request = &parsed["components"]["schemas"]["EditorSoundEffectGenerationRequest"]; assert_eq!(sound_request["required"], json!(["prompt"])); assert_eq!( sound_request["properties"]["prompt"]["maxLength"], json!(2048) ); assert_eq!( sound_request["properties"]["model"]["default"], json!(shared_contracts::assets::EDITOR_SOUND_EFFECT_MODEL) ); assert_eq!( sound_request["properties"]["duration"]["anyOf"][0]["type"], json!("number") ); assert_eq!( sound_request["properties"]["duration"]["anyOf"][0]["minimum"], json!(0.5) ); assert_eq!( sound_request["properties"]["duration"]["anyOf"][0]["maximum"], json!(30) ); assert_eq!(sound_request["properties"]["loop"]["default"], json!(false)); let audio_response = &parsed["components"]["schemas"]["EditorAudioGenerationResponse"]; assert_eq!( audio_response["properties"]["durationSeconds"]["maximum"], json!(600) ); assert_eq!( audio_response["properties"]["loop"]["type"], json!(["boolean", "null"]) ); let generation_job = &parsed["components"]["schemas"]["ExternalEditorGenerationJobResponse"]; assert_eq!( generation_job["properties"]["result"]["$ref"], "#/components/schemas/ExternalEditorGenerationCompletedResult" ); assert!( generation_job["required"] .as_array() .is_some_and(|required| !required.contains(&json!("result"))), "历史 completed 轮询结果可能没有 result,不能在无版本迁移时改为必填" ); let completed_result = &parsed["components"]["schemas"]["ExternalEditorGenerationCompletedResult"]; assert_eq!( completed_result["oneOf"][0]["$ref"], "#/components/schemas/ExternalEditorSoundEffectCompactResult" ); assert_eq!( completed_result["oneOf"][1]["$ref"], "#/components/schemas/ExternalEditorGenerationGenericCompactResult" ); assert!( completed_result.get("discriminator").is_none(), "completed result 联合不能带 discriminator:BGM 回 audioKind=background-music,图片、\ 视频与历史结果没有 audioKind,而 fallback 分支不可能把 audioKind 声明为必填,\ 任何映射都覆盖不全合法结果" ); let sound_effect_result = &parsed["components"]["schemas"]["ExternalEditorSoundEffectCompactResult"]; assert_eq!(sound_effect_result["required"], json!(["audioKind"])); assert_eq!( sound_effect_result["properties"]["audioKind"]["const"], "sound-effect" ); assert_eq!( sound_effect_result["properties"]["durationSeconds"]["type"], "number" ); assert_eq!( sound_effect_result["properties"]["durationSeconds"]["exclusiveMinimum"], json!(0) ); assert_eq!( sound_effect_result["properties"]["durationSeconds"]["maximum"], json!(600) ); assert_eq!(sound_effect_result["properties"]["loop"]["type"], "boolean"); for stable_reference in ["taskId", "objectKey", "assetObjectId", "resource", "asset"] { assert!( sound_effect_result["properties"] .get(stable_reference) .is_some(), "SFX compact result must expose {stable_reference} when it is present" ); } for redacted_field in ["prompt", "actualPrompt", "model", "provider"] { assert!( sound_effect_result["properties"] .get(redacted_field) .is_none(), "SFX compact schema must not declare redacted {redacted_field}" ); } let generic_result = &parsed["components"]["schemas"]["ExternalEditorGenerationGenericCompactResult"]; assert_eq!(generic_result["not"]["required"], json!(["audioKind"])); assert_eq!( generic_result["not"]["properties"]["audioKind"]["const"], "sound-effect" ); assert_eq!( generic_result["not"]["properties"] .as_object() .map(serde_json::Map::len), Some(1), "fallback 只能排除 SFX 一种 audioKind;再排除别的值会把 BGM 等合法结果挤出联合" ); assert!( generic_result["not"]["properties"]["audioKind"] .get("enum") .is_none(), "排除条件必须是 const sound-effect,换成 enum 会连带排掉 BGM" ); assert!( parsed["paths"] .get("/api/external/v1/editor/audios/background-music/generations") .is_some() ); assert!( parsed["paths"] .get("/api/external/v1/editor/images/background-removals") .is_some() ); assert_eq!( parsed["paths"]["/api/external/v1/editor/images/background-removals"]["post"]["operationId"], "removeExternalEditorImageBackground" ); assert_eq!( parsed["components"]["schemas"]["EditorBackgroundRemovalRequest"]["required"], json!(["sourceImageSrc"]) ); assert_eq!( parsed["components"]["schemas"]["EditorBackgroundRemovalRequest"]["additionalProperties"], json!(false) ); assert!( parsed["components"]["schemas"]["EditorBackgroundRemovalRequest"]["properties"] .get("taskId") .is_none() ); let background_target_description = parsed["components"]["schemas"] ["EditorBackgroundRemovalRequest"]["properties"]["targetLayerId"]["description"] .as_str() .expect("background removal targetLayerId should document placement semantics"); assert!(background_target_description.contains("projectId")); assert!(background_target_description.contains("canvasCompletion")); assert!(background_target_description.contains("assetObjectId")); assert!(background_target_description.contains("bucket/objectKey")); assert!(background_target_description.contains("不自动写入画布")); let background_asset_kind = &parsed["components"]["schemas"]["EditorBackgroundRemovalRequest"] ["properties"]["assetKind"]; assert_eq!( background_asset_kind["x-genarrative-media-family"], "static-image" ); let background_asset_kind_description = background_asset_kind["description"] .as_str() .expect("background removal assetKind should document authoritative static semantics"); assert!(background_asset_kind_description.contains("权威来源类型")); assert!(background_asset_kind_description.contains("入队前返回 400")); let background_source_resource_description = parsed["components"]["schemas"] ["EditorBackgroundRemovalRequest"]["properties"]["sourceResourceId"]["description"] .as_str() .expect("background removal sourceResourceId should document disambiguation"); assert!(background_source_resource_description.contains("消歧")); assert!(background_source_resource_description.contains("targetLayerId")); assert!(background_source_resource_description.contains("Worker")); assert!( parsed["paths"] .get("/api/external/v1/editor/assets/library") .is_some() ); assert!( parsed["paths"] .get("/api/external/v1/editor/projects/{projectId}/resources") .is_some() ); assert!( parsed["paths"] .get("/api/external/v1/editor/assets/folders") .is_some() ); assert!( parsed["paths"] .get("/api/external/v1/editor/assets/{assetId}") .is_some() ); assert!(parsed["paths"].get("/api/profile/api-keys").is_none()); assert!( parsed["components"]["securitySchemes"] .get("UserAccessToken") .is_none() ); assert_eq!( parsed["components"]["securitySchemes"]["ExternalApiKey"]["scheme"], "bearer" ); for schema_name in [ "EditorProjectResource", "EditorAsset", "EditorImageGenerationResponse", "EditorIconSpritesheetGenerationResponse", "EditorVideoGenerationResponse", "EditorAudioGenerationResponse", ] { let schema = &parsed["components"]["schemas"][schema_name]; assert!( schema["properties"].get("provider").is_none(), "{schema_name} 不应向普通用户公开 provider" ); assert!( schema["required"] .as_array() .is_some_and(|required| !required.contains(&json!("provider"))), "{schema_name} 不应要求 provider" ); } } }