合并master并保留Spine序列帧修复

合并master当前工程、后端、前端和文档更新

按已确认方案解决Spine序列帧多模态、帧数和快速编辑冲突

保留当前分支底部工具栏宽度与隐藏滚动条样式
This commit is contained in:
2026-08-14 21:33:26 +08:00
338 changed files with 69729 additions and 8828 deletions
+34 -2
View File
@@ -419,6 +419,38 @@ mod tests {
.expect("password login request should succeed")
}
#[tokio::test]
async fn showcase_invalid_bearer_is_not_downgraded_and_is_not_cacheable() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.uri("/api/editor/showcase/resources")
.header("authorization", "Bearer invalid-token")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
assert_eq!(
response
.headers()
.get("cache-control")
.and_then(|value| value.to_str().ok()),
Some("private, no-store")
);
assert_eq!(
response
.headers()
.get("vary")
.and_then(|value| value.to_str().ok()),
Some("Authorization")
);
}
#[cfg(any())]
fn build_internal_creative_agent_app() -> Router {
let mut config = AppConfig::default();
@@ -2390,7 +2422,7 @@ mod tests {
let app = build_router(state);
let request_body = serde_json::json!({
"prompt": "快速编辑图片",
"sourceImageSrc": "data:image/png;base64,AAAA",
"sourceReferenceId": "data:image/png;base64,AAAA",
"size": "1024x1024",
"model": "gpt-image-2"
})
@@ -2418,7 +2450,7 @@ mod tests {
.to_bytes();
let body_text = String::from_utf8_lossy(&body);
assert!(
body_text.contains("先上传 OSS"),
body_text.contains("只接受已登记的项目资源 ID 或素材 ID"),
"handler should reject inline editor edit sources: {body_text}"
);
}
+66
View File
@@ -1394,6 +1394,72 @@ mod tests {
assert!(payload["data"]["upload"].get("publicUrl").is_none());
}
#[tokio::test]
async fn direct_upload_ticket_accepts_asset_canvas_reference_namespace() {
let config = AppConfig {
oss_bucket: Some("genarrative-assets".to_string()),
oss_endpoint: Some("oss-cn-shanghai.aliyuncs.com".to_string()),
oss_access_key_id: Some("test-access-key-id".to_string()),
oss_access_key_secret: Some("test-access-key-secret".to_string()),
..AppConfig::default()
};
let state = AppState::new(config).expect("state should build");
let token =
seed_authenticated_token(&state, "13800138122", "sess_asset_canvas_reference_ticket")
.await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/assets/direct-upload-tickets")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "1")
.body(Body::from(
json!({
"legacyPrefix": "generated-character-drafts",
"pathSegments": [
"editor",
"asset-canvas-references",
"project-123",
"draft-456",
"generation-789"
],
"fileName": "reference-sha256.png",
"contentType": "image/png",
"access": "private",
"maxSizeBytes": 4096,
"successActionStatus": 204
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
let expected_object_key = "generated-character-drafts/editor/asset-canvas-references/project-123/draft-456/generation-789/reference-sha256.png";
assert_eq!(payload["data"]["upload"]["objectKey"], expected_object_key);
assert_eq!(
payload["data"]["upload"]["formFields"]["key"],
expected_object_key
);
assert_eq!(payload["data"]["upload"]["access"], "private");
assert_eq!(payload["data"]["upload"]["successActionStatus"], 204);
assert_eq!(payload["data"]["upload"]["maxSizeBytes"], 4096);
}
#[tokio::test]
async fn read_url_fails_closed_when_asset_metadata_authority_is_unavailable() {
let config = AppConfig {
@@ -11,7 +11,7 @@ use std::{
use axum::{
Json,
extract::{Extension, Path as AxumPath, Query, State, rejection::JsonRejection},
http::StatusCode,
http::{HeaderMap, StatusCode},
response::Response,
};
use image::{
@@ -43,10 +43,11 @@ use shared_contracts::assets::{
CharacterAssetJobStatusPayload, CharacterAssetJobStatusText, CharacterVisualDraftPayload,
CharacterWorkflowCacheGetResponse, CharacterWorkflowCachePayload,
CharacterWorkflowCacheSaveRequest, CharacterWorkflowCacheSaveResponse,
EditorCharacterAnimationFramePayload, EditorCharacterAnimationFrameSplitRequest,
EditorCharacterAnimationFrameSplitResponse, EditorCharacterAnimationGenerateRequest,
EditorCharacterAnimationGenerateResponse, EditorCharacterAnimationBackgroundRemovalRequest,
EditorCharacterAnimationBackgroundRemovalResponse, EditorCharacterAnimationVideoConversionRequest,
EditorCharacterAnimationBackgroundRemovalRequest,
EditorCharacterAnimationBackgroundRemovalResponse, EditorCharacterAnimationFramePayload,
EditorCharacterAnimationFrameSplitRequest, EditorCharacterAnimationFrameSplitResponse,
EditorCharacterAnimationGenerateRequest, EditorCharacterAnimationGenerateResponse,
EditorCharacterAnimationVideoConversionRequest,
EditorCharacterAnimationVideoConversionResponse, EditorVideoGenerateRequest,
EditorVideoGenerateResponse,
};
@@ -92,8 +93,9 @@ use crate::{
build_editor_canvas_generated_layer_item, editor_asset_payload_from_record,
editor_media_src_from_object_key, editor_project_payload_from_record,
editor_project_resource_payload_from_record, map_editor_project_error,
normalize_editor_record_object_key, persist_editor_generation_result_atomically,
preflight_editor_billable_generation_target, rebuild_editor_generation_input_references,
normalize_editor_record_object_key, optional_editor_idempotency_key,
persist_editor_generation_result_atomically, preflight_editor_billable_generation_target,
rebuild_editor_generation_input_references,
remove_editor_generated_screen_background_with_bgfilter,
request_editor_background_removal_image_with_bgfilter_worker,
resolve_editor_reference_object_key_for_owner, sanitize_editor_client_generation_inputs,
@@ -106,8 +108,8 @@ use crate::{
},
editor_sequence_animation::{
EDITOR_CHARACTER_ANIMATION_MODEL, EDITOR_CHARACTER_ANIMATION_REFERENCE_VIDEO_MAX_BYTES,
EditorCharacterAnimationInputMode, EditorCharacterAnimationReferenceMediaKind,
NormalizedEditorCharacterAnimationRequest,
EDITOR_CHARACTER_ANIMATION_VIDEO_CONVERSION_MAX_FRAMES, EditorCharacterAnimationInputMode,
EditorCharacterAnimationReferenceMediaKind, NormalizedEditorCharacterAnimationRequest,
apply_editor_character_animation_authoritative_reference_dimensions,
build_authoritative_editor_character_animation_generation_inputs,
build_authoritative_editor_character_animation_video_conversion_inputs,
@@ -810,11 +812,13 @@ pub async fn split_editor_character_animation_frames(
"message": "序列帧资源缺少完整的正式帧列表。",
}))
})?;
if frames.len() > 120 {
if frames.len() > EDITOR_CHARACTER_ANIMATION_VIDEO_CONVERSION_MAX_FRAMES as usize {
return Err(
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "editor-character-animation-frame-split",
"message": "单次最多拆分120帧。",
"message": format!(
"单次最多拆分{EDITOR_CHARACTER_ANIMATION_VIDEO_CONVERSION_MAX_FRAMES}帧。"
),
})),
);
}
@@ -1180,8 +1184,7 @@ pub(crate) async fn remove_editor_character_animation_background_for_owner(
})),
));
}
if source_resource.asset_kind.as_deref()
!= Some(EDITOR_CHARACTER_ANIMATION_RESOURCE_ASSET_KIND)
if source_resource.asset_kind.as_deref() != Some(EDITOR_CHARACTER_ANIMATION_RESOURCE_ASSET_KIND)
{
return Err(character_animation_error_response(
&request_context,
@@ -1202,10 +1205,12 @@ pub(crate) async fn remove_editor_character_animation_background_for_owner(
editor_character_animation_bad_request("序列帧资源缺少完整的正式帧列表。"),
)
})?;
if frames.len() > 120 {
if frames.len() > EDITOR_CHARACTER_ANIMATION_VIDEO_CONVERSION_MAX_FRAMES as usize {
return Err(character_animation_error_response(
&request_context,
editor_character_animation_bad_request("单次最多处理120帧。"),
editor_character_animation_bad_request(format!(
"单次最多处理{EDITOR_CHARACTER_ANIMATION_VIDEO_CONVERSION_MAX_FRAMES}帧。"
)),
));
}
let frame_width = source_resource.width;
@@ -2459,6 +2464,7 @@ pub async fn generate_editor_video(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
headers: HeaderMap,
payload: Result<Json<EditorVideoGenerateRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
@@ -2470,6 +2476,8 @@ pub async fn generate_editor_video(
})),
)
})?;
let idempotency_key = optional_editor_idempotency_key(&headers)
.map_err(|error| editor_video_error_response(&request_context, error))?;
let owner_user_id = authenticated.claims().user_id().to_string();
if !state.config.external_generation_mode.is_inline() {
let queue_job = enqueue_editor_video_generation_for_owner(
@@ -2477,7 +2485,7 @@ pub async fn generate_editor_video(
&request_context,
owner_user_id.as_str(),
payload,
None,
idempotency_key,
)
.await?;
return Ok(json_success_body(
@@ -9460,8 +9468,10 @@ mod tests {
&settings,
&normalized,
Some("users/owner/reference/action.mp4"),
&["generated-character-drafts/editor/seedance-references/image/appearance.png"
.to_string()],
&[
"generated-character-drafts/editor/seedance-references/image/appearance.png"
.to_string(),
],
)
.expect("video request with appearance reference should build");
assert_eq!(
@@ -1,6 +1,7 @@
use std::time::Duration;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::{Extension, Json};
use module_editor_agent::{
EDITOR_AGENT_CONVERSATION_ID_PREFIX, EDITOR_AGENT_DEFAULT_CONVERSATION_TITLE,
@@ -42,8 +43,13 @@ use crate::editor_agent::utils::{
};
use crate::editor_agent::{context, reconcile};
use crate::editor_generation_config::EditorGenerationPricingConfig;
use crate::editor_generation_queue::enqueue_editor_generation_job_with_identity;
use crate::editor_project::{current_utc_micros, map_editor_project_error};
use crate::editor_generation_queue::{
EDITOR_IMAGE_EDIT_JOB_KIND, enqueue_editor_generation_job_with_identity,
};
use crate::editor_project::{
EditorImageEditRequest, current_utc_micros, map_editor_project_error,
prepare_editor_image_edit_queue_payload_for_owner,
};
use crate::http_error::AppError;
use crate::request_context::RequestContext;
use crate::state::AppState;
@@ -712,6 +718,25 @@ mod tests {
"ERROR 美术 Agent 规划轮数已达上限:3"
);
}
#[test]
fn confirmed_image_edit_is_wrapped_before_queueing() {
let source = include_str!("api.rs");
let start = source
.rfind("pub async fn confirm_editor_agent_tool_call")
.expect("confirm endpoint should exist");
let body = &source[start..];
let prepare = body
.find("prepare_editor_image_edit_queue_payload_for_owner")
.expect("image edit should build its versioned queue payload");
let enqueue = body
.find("enqueue_editor_generation_job_with_identity")
.expect("confirmed job should be queued");
assert!(body[..prepare].contains("job_kind == EDITOR_IMAGE_EDIT_JOB_KIND"));
assert!(body[prepare..enqueue].contains("serde_json::to_value(queue_payload)"));
assert!(prepare < enqueue);
}
}
fn build_delta_messages(
outputs: Vec<PromptOutput>,
@@ -1067,7 +1092,29 @@ pub async fn confirm_editor_agent_tool_call(
let job_kind = prepared_job.job_kind;
let request_label = prepared_job.request_label;
let price_mud_points = prepared_job.price_mud_points;
let payload = prepared_job.payload;
let payload = if job_kind == EDITOR_IMAGE_EDIT_JOB_KIND {
let request = serde_json::from_value::<EditorImageEditRequest>(prepared_job.payload)
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "editor-agent",
"message": format!("画布 Agent 图片编辑任务参数无效:{error}"),
}))
})?;
let queue_payload = prepare_editor_image_edit_queue_payload_for_owner(
&state,
conversation.owner_user_id.as_str(),
request,
)
.await?;
serde_json::to_value(queue_payload).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "editor-agent",
"message": format!("画布 Agent 图片编辑任务载荷序列化失败:{error}"),
}))
})?
} else {
prepared_job.payload
};
let (job_id, dedupe_key) = editor_agent_tool_job_identity(
conversation.conversation_id.as_str(),
message_id,
@@ -182,7 +182,7 @@ mod tests {
}
}
fn project_with_resource_asset_kind(asset_kind: &str) -> EditorProjectRecord {
fn project_with_resource_asset_kind(asset_kind: Option<&str>) -> EditorProjectRecord {
let viewport = EditorCanvasViewportRecord {
x: 0.0,
y: 0.0,
@@ -222,7 +222,7 @@ mod tests {
provider: None,
task_id: None,
source_resource_id: None,
asset_kind: Some(asset_kind.to_string()),
asset_kind: asset_kind.map(str::to_string),
generation_inputs: None,
public_showcase_enabled: false,
created_at: "2026-08-08T00:00:00Z".to_string(),
@@ -271,7 +271,7 @@ mod tests {
"icon_descriptions": ["背包"]
});
let ordinary_project = project_with_resource_asset_kind("image");
let ordinary_project = project_with_resource_asset_kind(None);
let ordinary_context =
build_tool_context_from_authoritative_records(&document, Some(&ordinary_project), None);
let ordinary_tool = editor_agent_tool(GenerateIconSpritesheetTool::NAME, &ordinary_context)
@@ -281,7 +281,7 @@ mod tests {
.expect_err("ordinary registered image must be rejected before confirmation");
assert!(error.to_string().contains("icon-spec"));
let icon_spec_project = project_with_resource_asset_kind("icon-spec");
let icon_spec_project = project_with_resource_asset_kind(Some("icon-spec"));
let icon_spec_context = build_tool_context_from_authoritative_records(
&document,
Some(&icon_spec_project),
@@ -695,27 +695,30 @@ impl EditorAgentTool for EditImageTool {
) -> Result<PreparedEditorAgentJob, EditorAgentToolError> {
let price_mud_points = self.pricing(context.pricing, args)?;
let args: EditImageToolArgs = parse_invalid_args(Self::NAME, args)?;
let source_image_src = self
let source_reference_id = self
.context
.image_data_key(&args.object_image_id)
.ok_or_else(|| EditorAgentToolError::invalid_args("object image not found"))?
.image_metadata(&args.object_image_id)
.and_then(|metadata| metadata.reference_id.as_deref())
.ok_or_else(|| {
EditorAgentToolError::invalid_args(
"object image is not a registered resource or asset",
)
})?
.to_string();
let reference_image_srcs = resolve_image_ids(&args.reference_image_ids, &self.context)?;
let title = args.prompt.clone();
let payload = EditorImageEditRequest {
prompt: args.prompt,
source_image_src,
source_reference_id,
size: None,
model: Some(args.model),
aspect_ratio: None,
image_size: None,
reference_image_srcs: Some(reference_image_srcs),
project_id: Some(context.conversation.project_id.clone()),
asset_kind: Some("editor_agent_edit_image".to_string()),
generation_inputs: context.generation_inputs("用户指令", &title),
asset_folder_id: Some("project".to_string()),
asset_label: Some(title.clone()),
source_resource_id: None,
target_layer_id: None,
canvas_completion: Some(build_editor_agent_canvas_completion(
context.project,
@@ -1647,6 +1650,82 @@ mod tests {
);
}
#[test]
fn edit_image_job_uses_registered_reference_id_only() {
let pricing = pricing();
let conversation = conversation();
let project = project();
let tool = editor_agent_tool(EditImageTool::NAME, &context_with_image("image-1"))
.expect("edit tool should resolve");
let args = tool
.validate_args(&json!({
"object_image_id": "image-1",
"prompt": "改成蓝色"
}))
.expect("edit args should validate");
let job = tool
.prepare_job(
&args,
&EditorAgentPrepareJobContext {
conversation: &conversation,
project: &project,
message_id: 7,
pricing: &pricing,
},
)
.expect("edit job should prepare");
assert_eq!(job.job_kind, EDITOR_IMAGE_EDIT_JOB_KIND);
assert_eq!(job.payload["sourceReferenceId"], "resource-image-1");
assert!(job.payload.get("sourceImageSrc").is_none());
assert!(job.payload.get("sourceResourceId").is_none());
assert!(job.payload.get("assetKind").is_none());
assert_eq!(job.payload["projectId"], "project-1");
assert!(job.payload["targetLayerId"].is_null());
}
#[test]
fn edit_image_job_rejects_image_without_registered_reference_id() {
let pricing = pricing();
let conversation = conversation();
let project = project();
let mut context = context_with_image("image-1");
context
.images
.values_mut()
.next()
.expect("test image should exist")
.reference_id = None;
let tool =
editor_agent_tool(EditImageTool::NAME, &context).expect("edit tool should resolve");
let args = tool
.validate_args(&json!({
"object_image_id": "image-1",
"prompt": "改成蓝色"
}))
.expect("edit args should validate");
let error = match tool.prepare_job(
&args,
&EditorAgentPrepareJobContext {
conversation: &conversation,
project: &project,
message_id: 7,
pricing: &pricing,
},
) {
Ok(_) => panic!("unregistered image should fail closed"),
Err(error) => error,
};
assert!(error.is_invalid_args());
assert_eq!(
error.to_string(),
"object image is not a registered resource or asset"
);
}
#[test]
fn dyn_job_preparation_keeps_queue_contract_and_defaults() {
let pricing = pricing();
File diff suppressed because it is too large Load Diff
@@ -17,7 +17,7 @@ pub(crate) const EDITOR_CHARACTER_ANIMATION_MODEL: &str = "seedance2.0-fast";
pub(crate) const EDITOR_CHARACTER_ANIMATION_REFERENCE_VIDEO_MAX_BYTES: u64 = 50 * 1024 * 1024;
const EDITOR_CHARACTER_ANIMATION_REFERENCE_VIDEO_MIN_DURATION_SECONDS: f64 = 2.0;
const EDITOR_CHARACTER_ANIMATION_REFERENCE_VIDEO_MAX_DURATION_SECONDS: f64 = 15.0;
pub(crate) const EDITOR_CHARACTER_ANIMATION_VIDEO_CONVERSION_MAX_FRAMES: u32 = 120;
pub(crate) const EDITOR_CHARACTER_ANIMATION_VIDEO_CONVERSION_MAX_FRAMES: u32 = 66;
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct EditorCharacterAnimationVideoConversionPlan {
@@ -70,11 +70,12 @@ pub(crate) fn build_editor_character_animation_video_conversion_plan(
let scale = (f64::from(max_long_edge) / f64::from(source_long_edge)).min(1.0);
let frame_width = (f64::from(source_width) * scale).round().max(1.0) as u32;
let frame_height = (f64::from(source_height) * scale).round().max(1.0) as u32;
let sampling_fps = source_fps.min(8.0).max(1.0);
let frame_count = (duration_seconds * sampling_fps).round().clamp(
let requested_sampling_fps = source_fps.min(8.0).max(1.0);
let frame_count = (duration_seconds * requested_sampling_fps).round().clamp(
2.0,
f64::from(EDITOR_CHARACTER_ANIMATION_VIDEO_CONVERSION_MAX_FRAMES),
) as u32;
let sampling_fps = f64::from(frame_count) / duration_seconds;
Ok(EditorCharacterAnimationVideoConversionPlan {
duration_seconds,
duration_ms: (duration_seconds * 1_000.0).round().max(1.0) as u64,
@@ -547,8 +548,7 @@ pub(crate) fn build_authoritative_editor_character_animation_generation_inputs(
));
}
for appearance_object_key in appearance_object_keys {
let appearance_object_key =
appearance_object_key.trim().trim_start_matches('/');
let appearance_object_key = appearance_object_key.trim().trim_start_matches('/');
if appearance_object_key.is_empty() {
continue;
}
@@ -914,13 +914,13 @@ mod tests {
}
#[test]
fn video_conversion_plan_downscales_and_caps_at_120_frames() {
fn video_conversion_plan_downscales_and_caps_at_66_frames() {
let plan =
build_editor_character_animation_video_conversion_plan(15.0, 60.0, 1920, 1080, "480p")
.unwrap();
assert_eq!((plan.frame_width, plan.frame_height), (480, 270));
assert_eq!(plan.frame_count, 120);
assert_eq!(plan.sampling_fps, 8.0);
assert_eq!(plan.frame_count, 66);
assert!((plan.sampling_fps - 4.4).abs() < 0.001);
}
#[test]
@@ -811,8 +811,9 @@ pub async fn edit_external_editor_image(
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
headers: HeaderMap,
Json(payload): Json<EditorImageEditRequest>,
payload: Result<Json<EditorImageEditRequest>, JsonRejection>,
) -> Result<Response, AppError> {
let Json(payload) = parse_editor_generation_json_payload(payload)?;
require_scope(&principal, SCOPE_EDITOR_IMAGE_GENERATE)?;
let idempotency_key = require_idempotency_key(&headers)?;
let project_id = payload.project_id.clone();
@@ -2162,7 +2163,8 @@ mod tests {
assert!(
parsed["components"]["schemas"]["EditorImageEditRequest"]["required"]
.as_array()
.is_some_and(|required| !required.contains(&json!("priceMudPoints")))
.is_some_and(|required| required.contains(&json!("sourceReferenceId"))
&& !required.contains(&json!("priceMudPoints")))
);
assert!(
parsed["components"]["schemas"]["EditorImageEditRequest"]["properties"]
@@ -2174,6 +2176,59 @@ mod tests {
.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),
@@ -138,20 +138,29 @@ pub async fn get_external_generation_job_status(
Path(job_id): Path<String>,
) -> Result<Json<serde_json::Value>, Response> {
let owner_user_id = authenticated.claims().user_id().to_string();
let input = ExternalGenerationJobGetRecordInput {
job_id,
owner_user_id,
};
let job = state
.spacetime_client()
.get_external_generation_job_summary(ExternalGenerationJobGetRecordInput {
job_id,
owner_user_id,
})
.get_external_generation_job_summary(input.clone())
.await
.map_err(|error| external_generation_error_response(&request_context, error))?;
let mut detail = map_external_generation_job_status_detail(job);
if detail.status.status == ExternalGenerationJobStatus::Completed {
let artifacts = state
.spacetime_client()
.get_external_generation_job_generated_artifacts(input)
.await
.map_err(|error| external_generation_error_response(&request_context, error))?;
detail.result =
external_generation_completed_result(artifacts.result_payload_json.as_deref());
}
Ok(json_success_body(
Some(&request_context),
ExternalGenerationJobStatusResponse {
job: map_external_generation_job_status_detail(job),
},
ExternalGenerationJobStatusResponse { job: detail },
))
}
@@ -235,9 +244,18 @@ pub(crate) fn map_external_generation_job_status_detail(
ExternalGenerationJobStatusDetailRecord {
status: map_external_generation_job_status(job),
warning,
result: None,
}
}
fn external_generation_completed_result(
result_payload_json: Option<&str>,
) -> Option<serde_json::Value> {
result_payload_json
.and_then(|payload| serde_json::from_str::<serde_json::Value>(payload).ok())
.and_then(|payload| payload.get("result").cloned())
}
fn map_external_generation_task_record(
job: ExternalGenerationJobSummaryRecord,
) -> ExternalGenerationTaskRecord {
@@ -328,6 +346,18 @@ mod tests {
);
}
#[test]
fn completed_generation_result_uses_compact_result_payload() {
let result = external_generation_completed_result(Some(
r#"{"result":{"resourceId":"resource-1","objectKey":"generated/1.png"}}"#,
))
.expect("completed result should be readable");
assert_eq!(result["resourceId"], "resource-1");
assert!(external_generation_completed_result(Some(r#"{"data":{}}"#)).is_none());
assert!(external_generation_completed_result(Some("not-json")).is_none());
}
#[test]
fn maps_task_from_payload_free_summary_projection() {
let summary = ExternalGenerationJobSummaryRecord {
@@ -44,12 +44,14 @@ use crate::{
EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND, EDITOR_VIDEO_GENERATION_JOB_KIND,
},
editor_project::{
EDITOR_GENERATION_MULTIPLE_WARNINGS_CODE, EditorBackgroundRemovalRequest,
EditorGenerationCaller, EditorGenerationOperationContext, EditorGenerationPhaseReporter,
EditorGenerationQueueResultContext, EditorImageEditRequest, EditorImageGenerationRequest,
EditorUiDesignAssetExtractionRequest, compact_editor_generation_result,
edit_editor_image_for_owner, extract_editor_ui_design_assets_for_owner,
generate_editor_image_for_owner, remove_editor_image_background_for_owner,
EDITOR_GENERATION_MULTIPLE_WARNINGS_CODE, EDITOR_IMAGE_EDIT_QUEUE_PAYLOAD_VERSION,
EditorBackgroundRemovalRequest, EditorGenerationCaller, EditorGenerationOperationContext,
EditorGenerationPhaseReporter, EditorGenerationQueueResultContext,
EditorImageEditQueuePayload, EditorImageEditRequest, EditorImageEditResolvedSource,
EditorImageGenerationRequest, EditorUiDesignAssetExtractionRequest,
compact_editor_generation_result, edit_editor_image_for_owner_with_source_snapshot,
extract_editor_ui_design_assets_for_owner, generate_editor_image_for_owner,
remove_editor_image_background_for_owner,
},
editor_project_icon::{
EditorIconSpecGenerationRequest, EditorIconSpritesheetGenerationRequest,
@@ -793,22 +795,20 @@ async fn process_external_generation_job_once(
}
}
EDITOR_IMAGE_EDIT_JOB_KIND => {
let payload = match serde_json::from_str::<EditorImageEditRequest>(
job.request_payload_json.as_str(),
) {
let payload = match parse_editor_image_edit_worker_payload(&job) {
Ok(payload) => payload,
Err(error) => {
let message = format!("图片画布改图任务参数解析失败:{error}");
Err(message) => {
fail_job(&state, &worker_id, &job, message.clone()).await?;
return Err(message);
}
};
let request_context = worker_request_context(&job, provider_deadline);
match edit_editor_image_for_owner(
match edit_editor_image_for_owner_with_source_snapshot(
&state,
&request_context,
editor_generation_worker_caller(&worker_id, &job)?,
payload,
payload.request,
payload.source.as_ref(),
)
.await
{
@@ -1197,6 +1197,80 @@ fn worker_request_context(
.with_external_call_deadline(provider_deadline)
}
struct ParsedEditorImageEditWorkerPayload {
request: EditorImageEditRequest,
source: Option<EditorImageEditResolvedSource>,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct LegacyEditorImageEditWorkerPayload {
prompt: String,
source_image_src: String,
size: Option<String>,
model: Option<String>,
aspect_ratio: Option<String>,
image_size: Option<String>,
reference_image_srcs: Option<Vec<String>>,
project_id: Option<String>,
generation_inputs: Option<Value>,
asset_folder_id: Option<String>,
asset_label: Option<String>,
source_resource_id: Option<String>,
target_layer_id: Option<String>,
canvas_completion: Option<shared_contracts::assets::EditorCanvasGenerationCompletionPayload>,
}
fn parse_editor_image_edit_worker_payload(
job: &ExternalGenerationJobRecord,
) -> Result<ParsedEditorImageEditWorkerPayload, String> {
if let Ok(payload) =
serde_json::from_str::<EditorImageEditQueuePayload>(job.request_payload_json.as_str())
{
if payload.version != EDITOR_IMAGE_EDIT_QUEUE_PAYLOAD_VERSION {
return Err(format!(
"图片画布改图任务载荷版本不受支持:{}",
payload.version
));
}
return Ok(ParsedEditorImageEditWorkerPayload {
request: payload.request,
source: Some(payload.source),
});
}
let legacy: LegacyEditorImageEditWorkerPayload =
serde_json::from_str(job.request_payload_json.as_str())
.map_err(|error| format!("图片画布改图任务参数解析失败:{error}"))?;
let source_reference_id = legacy
.source_resource_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or_else(|| legacy.source_image_src.trim())
.to_string();
if source_reference_id.is_empty() {
return Err("历史图片画布改图任务缺少可迁移的业务引用 ID".to_string());
}
Ok(ParsedEditorImageEditWorkerPayload {
request: EditorImageEditRequest {
prompt: legacy.prompt,
source_reference_id,
size: legacy.size,
model: legacy.model,
aspect_ratio: legacy.aspect_ratio,
image_size: legacy.image_size,
reference_image_srcs: legacy.reference_image_srcs,
project_id: legacy.project_id,
generation_inputs: legacy.generation_inputs,
asset_folder_id: legacy.asset_folder_id,
asset_label: legacy.asset_label,
target_layer_id: legacy.target_layer_id,
canvas_completion: legacy.canvas_completion,
},
source: None,
})
}
fn editor_generation_worker_caller(
worker_id: &str,
job: &ExternalGenerationJobRecord,
@@ -1289,15 +1363,7 @@ fn is_external_api_generation_job(job: &ExternalGenerationJobRecord) -> bool {
}
fn is_editor_agent_generation_job(job: &ExternalGenerationJobRecord) -> bool {
serde_json::from_str::<Value>(job.request_payload_json.as_str())
.ok()
.is_some_and(|payload| {
payload
.pointer("/generationInputs/source")
.and_then(Value::as_str)
// TODO extract const
.is_some_and(|source| source.trim() == "editor-agent")
})
job.dedupe_key.trim().starts_with("editor-agent:")
}
fn compact_external_api_generation_result(result: Value) -> Value {
@@ -1745,6 +1811,78 @@ mod tests {
assert!(caller.phase_reporter.is_some());
}
#[test]
fn editor_image_edit_worker_parses_versioned_snapshot_payload() {
let mut job = external_generation_job_record_fixture(Some("lease-1"));
job.job_kind = EDITOR_IMAGE_EDIT_JOB_KIND.to_string();
job.request_payload_json = json!({
"version": 1,
"request": {
"prompt": "改成蓝色",
"sourceReferenceId": "resource-source"
},
"source": {
"referenceKind": "project-resource",
"sourceReferenceId": "resource-source",
"resourceId": "resource-source",
"assetId": null,
"assetObjectId": "object-source",
"bucket": "bucket",
"objectKey": "generated/source.png",
"sourceAssetKind": "character",
"effectiveAssetKind": "character",
"mediaType": "image",
"targetResourceId": null
}
})
.to_string();
let payload = parse_editor_image_edit_worker_payload(&job)
.expect("versioned image edit payload should parse");
assert_eq!(payload.request.source_reference_id, "resource-source");
assert_eq!(
payload.source.expect("snapshot should exist").object_key,
"generated/source.png"
);
}
#[test]
fn legacy_editor_image_edit_payload_migrates_only_business_ids() {
let mut job = external_generation_job_record_fixture(Some("lease-1"));
job.job_kind = EDITOR_IMAGE_EDIT_JOB_KIND.to_string();
job.request_payload_json = json!({
"prompt": "改成蓝色",
"sourceImageSrc": "raw/generated/source.png",
"sourceResourceId": "resource-source",
"projectId": "project-1",
"assetKind": "editor_agent_edit_image",
"generationInputs": { "source": "editor-agent" }
})
.to_string();
let legacy_agent_payload = parse_editor_image_edit_worker_payload(&job)
.expect("历史 Agent 图片编辑 payload 应由 worker 解析");
assert_eq!(
legacy_agent_payload.request.source_reference_id,
"resource-source"
);
assert!(legacy_agent_payload.source.is_none());
job.request_payload_json = json!({
"prompt": "改成蓝色",
"sourceImageSrc": "raw/generated/source.png",
"assetKind": "character"
})
.to_string();
let raw_only_payload = parse_editor_image_edit_worker_payload(&job)
.expect("raw-only legacy payload should migrate without object-key lookup");
assert_eq!(
raw_only_payload.request.source_reference_id,
"raw/generated/source.png"
);
assert!(raw_only_payload.source.is_none());
}
#[cfg(any())]
#[test]
fn worker_write_guard_requires_claimed_job_lease_token() {
@@ -1966,6 +2104,7 @@ mod tests {
#[test]
fn editor_agent_result_payload_keeps_compact_response() {
let mut job = external_generation_job_record_fixture(Some("lease-1"));
job.dedupe_key = "editor-agent:conversation-1:7:generate-image".to_string();
job.request_payload_json = json!({
"generationInputs": { "source": "editor-agent" },
})
@@ -2095,6 +2234,7 @@ mod tests {
),
] {
let mut job = external_generation_job_record_fixture(Some("lease-1"));
job.dedupe_key = format!("editor-agent:conversation-1:7:{tool_name}");
job.request_payload_json = json!({
"generationInputs": { "source": "editor-agent" },
})
@@ -2118,6 +2258,7 @@ mod tests {
let payload: Value =
serde_json::from_str(&editor_generation_result_payload_json(&job, &response))
.expect("worker compact payload should serialize");
assert!(payload.get("editor-agent-tool-call-result").is_some());
assert!(
payload["editor-agent-tool-call-result"]
.get("provider")
@@ -2159,6 +2300,7 @@ mod tests {
#[test]
fn editor_agent_spritesheet_result_keeps_all_persisted_slices() {
let mut job = external_generation_job_record_fixture(Some("lease-1"));
job.dedupe_key = "editor-agent:conversation-1:7:generate-icon-spritesheet".to_string();
job.request_payload_json = json!({
"generationInputs": { "source": "editor-agent" },
})
@@ -1,8 +1,11 @@
use axum::{
Router,
extract::DefaultBodyLimit,
extract::{DefaultBodyLimit, Request},
handler::Handler,
http::{HeaderValue, header},
middleware,
middleware::Next,
response::Response,
routing::{get, patch, post},
};
@@ -35,6 +38,18 @@ use crate::{
const EDITOR_AGENT_MESSAGE_BODY_LIMIT_BYTES: usize = 2 * 1024 * 1024;
async fn add_showcase_private_response_headers(request: Request, next: Next) -> Response {
let mut response = next.run(request).await;
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static("private, no-store"),
);
response
.headers_mut()
.append(header::VARY, HeaderValue::from_static("Authorization"));
response
}
pub fn router(state: AppState) -> Router<AppState> {
Router::new()
.route(
@@ -43,7 +58,8 @@ pub fn router(state: AppState) -> Router<AppState> {
)
.route(
"/api/editor/showcase/resources",
get(list_public_editor_project_resources),
get(list_public_editor_project_resources)
.route_layer(middleware::from_fn(add_showcase_private_response_headers)),
)
.route(
"/api/editor/projects/recent",
@@ -183,10 +199,12 @@ pub fn router(state: AppState) -> Router<AppState> {
)
.route(
"/api/editor/showcase/assets/{showcase_id}/likes",
post(toggle_editor_showcase_asset_like).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
post(toggle_editor_showcase_asset_like)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
))
.route_layer(middleware::from_fn(add_showcase_private_response_headers)),
)
.route(
"/api/editor/images/generations",
@@ -266,3 +284,53 @@ pub fn router(state: AppState) -> Router<AppState> {
)),
)
}
#[cfg(test)]
mod tests {
use axum::{
Router,
body::Body,
http::{Request, header},
middleware,
response::{IntoResponse, Response},
routing::get,
};
use tower::ServiceExt;
use super::add_showcase_private_response_headers;
async fn response_with_existing_vary() -> Response {
let mut response = "ok".into_response();
response
.headers_mut()
.insert(header::VARY, "Origin".parse().expect("valid Vary value"));
response
}
#[tokio::test]
async fn showcase_private_headers_preserve_existing_vary_fields() {
let app = Router::new().route(
"/",
get(response_with_existing_vary)
.route_layer(middleware::from_fn(add_showcase_private_response_headers)),
);
let response = app
.oneshot(
Request::builder()
.uri("/")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
let vary_values = response
.headers()
.get_all(header::VARY)
.iter()
.map(|value| value.to_str().expect("Vary should be ASCII"))
.collect::<Vec<_>>();
assert_eq!(vary_values, vec!["Origin", "Authorization"]);
}
}
@@ -1,7 +1,7 @@
use std::future::Future;
use axum::Extension;
use axum::http::StatusCode;
use axum::http::{HeaderMap, StatusCode};
use axum::{
Json,
extract::{State, rejection::JsonRejection},
@@ -34,9 +34,9 @@ use crate::{
EditorGenerationOperationContext, PersistEditorGeneratedAssetInput,
build_editor_canvas_generated_layer_item, editor_asset_payload_from_record,
editor_project_payload_from_record, editor_project_resource_payload_from_record,
normalize_optional_string, persist_editor_generation_result_atomically,
preflight_editor_billable_generation_target, prepare_editor_generated_asset,
sanitize_editor_client_generation_inputs,
normalize_optional_string, optional_editor_idempotency_key,
persist_editor_generation_result_atomically, preflight_editor_billable_generation_target,
prepare_editor_generated_asset, sanitize_editor_client_generation_inputs,
},
http_error::AppError,
request_context::RequestContext,
@@ -224,9 +224,12 @@ pub async fn generate_editor_sound_effect(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
headers: HeaderMap,
payload: Result<Json<assets::EditorSoundEffectGenerateRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = parse_json_payload(&request_context, payload)?;
let idempotency_key = optional_editor_idempotency_key(&headers)
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
let owner_user_id = authenticated.claims().user_id().to_string();
if !state.config.external_generation_mode.is_inline() {
let queue_job = enqueue_editor_sound_effect_generation_for_owner(
@@ -234,7 +237,7 @@ pub async fn generate_editor_sound_effect(
&request_context,
owner_user_id.as_str(),
payload,
None,
idempotency_key,
)
.await?;
return Ok(json_success_body(
@@ -823,9 +826,12 @@ pub async fn generate_editor_background_music(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
headers: HeaderMap,
payload: Result<Json<assets::EditorBackgroundMusicGenerateRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = parse_json_payload(&request_context, payload)?;
let idempotency_key = optional_editor_idempotency_key(&headers)
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
let payload = CanonicalEditorBackgroundMusicSubmissionPayload::new(payload)
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
let owner_user_id = authenticated.claims().user_id().to_string();
@@ -835,6 +841,7 @@ pub async fn generate_editor_background_music(
&request_context,
owner_user_id.as_str(),
payload,
idempotency_key,
)
.await?;
return Ok(json_success_body(
@@ -915,6 +922,7 @@ async fn enqueue_logged_in_editor_background_music_generation_for_owner(
request_context: &RequestContext,
owner_user_id: &str,
payload: CanonicalEditorBackgroundMusicSubmissionPayload,
external_idempotency_key: Option<&str>,
) -> Result<ExternalGenerationJobRecord, Response> {
let pricing = load_editor_background_music_queue_pricing(state, request_context).await?;
let prepared = payload
@@ -925,7 +933,7 @@ async fn enqueue_logged_in_editor_background_music_generation_for_owner(
request_context,
owner_user_id,
prepared,
None,
external_idempotency_key,
)
.await
}