Merge remote-tracking branch 'origin/master' into editor-agent-refactored
# Conflicts: # docs/README.md # src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx # src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts # src/components/image-editor/EditorAgentConversationPanelView.test.tsx # src/components/image-editor/ImageCanvasStageView.tsx
This commit is contained in:
+2
-2
@@ -30,10 +30,10 @@ cargo test -p api-server --manifest-path server-rs/Cargo.toml
|
||||
npm run check:server-rs-ddd
|
||||
npm run check:spacetime-schema
|
||||
npm run spacetime:generate
|
||||
npm run api-server
|
||||
npm run dev:api-server
|
||||
```
|
||||
|
||||
涉及 API smoke 时用 `npm run api-server` 启动后端并检查 `/healthz`,不要使用旧 `api-server:maincloud`。
|
||||
涉及 API smoke 时用 `npm run dev:api-server` 启动后端并检查 `/healthz`,不要使用旧 `api-server:maincloud`。
|
||||
|
||||
## 当前文档
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
1. 进程启动时通过 `shared-logging` 统一初始化 `tracing subscriber`。
|
||||
2. 默认日志过滤器来自 `GENARRATIVE_API_LOG`,未提供时回落到 `info,tower_http=info`。
|
||||
3. HTTP 访问日志统一通过 Axum 路由层的 `TraceLayer` 输出,后续 `request_id`、响应头与错误中间件继续在同一层扩展。
|
||||
4. 本地启动器 `npm run api-server` 和完整联调入口 `npm run dev` / `npm run dev:rust` 会在保留终端实时输出的同时,把同一份 `cargo` / `api-server` 输出持久化到 `logs/api-server/`。如需固定文件或目录,可设置 `GENARRATIVE_API_SERVER_LOG_FILE` 或 `GENARRATIVE_API_SERVER_LOG_DIR`。
|
||||
4. 本地启动器 `npm run dev:api-server` 和完整联调入口 `npm run dev` 会在保留终端实时输出的同时,把同一份 `cargo` / `api-server` 输出持久化到 `logs/api-server/`。如需固定文件或目录,可设置 `GENARRATIVE_API_SERVER_LOG_FILE` 或 `GENARRATIVE_API_SERVER_LOG_DIR`。
|
||||
|
||||
当前 request context 约定:
|
||||
|
||||
|
||||
@@ -1725,19 +1725,18 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn editor_character_animation_accepts_character_image_body_above_default_limit() {
|
||||
async fn editor_character_animation_rejects_inline_data_url_before_queueing() {
|
||||
let state = AppState::new(AppConfig {
|
||||
external_generation_mode: ExternalGenerationMode::Inline,
|
||||
external_generation_mode: ExternalGenerationMode::Queue,
|
||||
..AppConfig::default()
|
||||
})
|
||||
.expect("state should build");
|
||||
let seed_user = seed_phone_user_with_password(&state, "13800138190", TEST_PASSWORD).await;
|
||||
let token = sign_test_user_token(&state, &seed_user, "sess_editor_character_body");
|
||||
let app = build_router(state);
|
||||
let source_image_src = format!("data:image/png;base64,{}", "A".repeat(3 * 1024 * 1024));
|
||||
let request_body = serde_json::json!({
|
||||
"sourceLayerId": "layer-character-large",
|
||||
"sourceImageSrc": source_image_src,
|
||||
"sourceImageSrc": "data:image/png;base64,AAAA",
|
||||
"sourceWidth": 1024,
|
||||
"sourceHeight": 1024,
|
||||
"promptText": "待机呼吸循环。",
|
||||
@@ -1748,7 +1747,6 @@ mod tests {
|
||||
"model": "seedance2.0-fast"
|
||||
})
|
||||
.to_string();
|
||||
assert!(request_body.len() > 2 * 1024 * 1024);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
@@ -1763,7 +1761,7 @@ mod tests {
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
@@ -1772,10 +1770,9 @@ mod tests {
|
||||
.to_bytes();
|
||||
let body_text = String::from_utf8_lossy(&body);
|
||||
assert!(
|
||||
body_text.contains("ARK_CHARACTER_VIDEO_BASE_URL"),
|
||||
"handler should parse the oversized character source image before checking Ark config: {body_text}"
|
||||
body_text.contains("先上传 OSS"),
|
||||
"handler should reject inline character sources: {body_text}"
|
||||
);
|
||||
assert!(!body_text.contains("length limit exceeded"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1811,23 +1808,21 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn editor_ui_design_asset_extraction_accepts_image_body_above_default_limit() {
|
||||
async fn editor_ui_design_asset_extraction_rejects_inline_data_url_before_queueing() {
|
||||
let state = AppState::new(AppConfig {
|
||||
external_generation_mode: ExternalGenerationMode::Inline,
|
||||
external_generation_mode: ExternalGenerationMode::Queue,
|
||||
..AppConfig::default()
|
||||
})
|
||||
.expect("state should build");
|
||||
let seed_user = seed_phone_user_with_password(&state, "13800138188", TEST_PASSWORD).await;
|
||||
let token = sign_test_user_token(&state, &seed_user, "sess_editor_ui_extract_body");
|
||||
let app = build_router(state);
|
||||
let source_image_src = format!("data:image/png;base64,{}", "A".repeat(3 * 1024 * 1024));
|
||||
let request_body = serde_json::json!({
|
||||
"sourceImageSrc": source_image_src,
|
||||
"sourceImageSrc": "data:image/png;base64,AAAA",
|
||||
"aspectRatio": "1:1",
|
||||
"imageSize": "1K",
|
||||
})
|
||||
.to_string();
|
||||
assert!(request_body.len() > 2 * 1024 * 1024);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
@@ -1842,7 +1837,7 @@ mod tests {
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
@@ -1851,33 +1846,28 @@ mod tests {
|
||||
.to_bytes();
|
||||
let body_text = String::from_utf8_lossy(&body);
|
||||
assert!(
|
||||
body_text.contains("VECTOR_ENGINE")
|
||||
|| body_text.contains("OPENAI")
|
||||
|| body_text.contains("配置"),
|
||||
"handler should parse the oversized UI design image before checking image config: {body_text}"
|
||||
body_text.contains("先上传 OSS"),
|
||||
"handler should reject inline UI design sources: {body_text}"
|
||||
);
|
||||
assert!(!body_text.contains("length limit exceeded"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn editor_image_generation_accepts_reference_body_above_default_limit() {
|
||||
async fn editor_image_generation_rejects_inline_data_url_before_queueing() {
|
||||
let state = AppState::new(AppConfig {
|
||||
external_generation_mode: ExternalGenerationMode::Inline,
|
||||
external_generation_mode: ExternalGenerationMode::Queue,
|
||||
..AppConfig::default()
|
||||
})
|
||||
.expect("state should build");
|
||||
let seed_user = seed_phone_user_with_password(&state, "13800138028", TEST_PASSWORD).await;
|
||||
let token = sign_test_user_token(&state, &seed_user, "sess_editor_image_reference_body");
|
||||
let app = build_router(state);
|
||||
let reference_image_src = format!("data:image/png;base64,{}", "A".repeat(3 * 1024 * 1024));
|
||||
let request_body = serde_json::json!({
|
||||
"prompt": "生成宣发素材",
|
||||
"kind": "publication-material",
|
||||
"size": "720x540",
|
||||
"referenceImageSrcs": [reference_image_src],
|
||||
"referenceImageSrcs": ["data:image/png;base64,AAAA"],
|
||||
})
|
||||
.to_string();
|
||||
assert!(request_body.len() > 2 * 1024 * 1024);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
@@ -1892,7 +1882,7 @@ mod tests {
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
@@ -1901,31 +1891,28 @@ mod tests {
|
||||
.to_bytes();
|
||||
let body_text = String::from_utf8_lossy(&body);
|
||||
assert!(
|
||||
body_text.contains("VECTOR_ENGINE_BASE_URL"),
|
||||
"handler should parse the oversized editor reference before checking VectorEngine config: {body_text}"
|
||||
body_text.contains("先上传 OSS"),
|
||||
"handler should reject inline editor references: {body_text}"
|
||||
);
|
||||
assert!(!body_text.contains("length limit exceeded"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn editor_image_edit_accepts_reference_body_above_default_limit() {
|
||||
async fn editor_image_edit_rejects_inline_data_url_before_queueing() {
|
||||
let state = AppState::new(AppConfig {
|
||||
external_generation_mode: ExternalGenerationMode::Inline,
|
||||
external_generation_mode: ExternalGenerationMode::Queue,
|
||||
..AppConfig::default()
|
||||
})
|
||||
.expect("state should build");
|
||||
let seed_user = seed_phone_user_with_password(&state, "13800138029", TEST_PASSWORD).await;
|
||||
let token = sign_test_user_token(&state, &seed_user, "sess_editor_image_edit_body");
|
||||
let app = build_router(state);
|
||||
let source_image_src = format!("data:image/png;base64,{}", "A".repeat(3 * 1024 * 1024));
|
||||
let request_body = serde_json::json!({
|
||||
"prompt": "快速编辑图片",
|
||||
"sourceImageSrc": source_image_src,
|
||||
"sourceImageSrc": "data:image/png;base64,AAAA",
|
||||
"size": "1024x1024",
|
||||
"model": "gpt-image-2"
|
||||
})
|
||||
.to_string();
|
||||
assert!(request_body.len() > 2 * 1024 * 1024);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
@@ -1940,7 +1927,7 @@ mod tests {
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
@@ -1949,10 +1936,9 @@ mod tests {
|
||||
.to_bytes();
|
||||
let body_text = String::from_utf8_lossy(&body);
|
||||
assert!(
|
||||
body_text.contains("VECTOR_ENGINE_BASE_URL"),
|
||||
"handler should parse the oversized editor edit image before checking VectorEngine config: {body_text}"
|
||||
body_text.contains("先上传 OSS"),
|
||||
"handler should reject inline editor edit sources: {body_text}"
|
||||
);
|
||||
assert!(!body_text.contains("length limit exceeded"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2025,10 +2011,7 @@ mod tests {
|
||||
payload["user"]["loginMethod"],
|
||||
Value::String("password".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
payload["user"]["createdAt"],
|
||||
Value::String(seed_user.created_at)
|
||||
);
|
||||
assert!(payload["user"].get("createdAt").is_none());
|
||||
assert!(payload["token"].as_str().is_some());
|
||||
}
|
||||
|
||||
@@ -2308,7 +2291,7 @@ mod tests {
|
||||
payload["user"]["phoneNumberMasked"],
|
||||
Value::String("138****8000".to_string())
|
||||
);
|
||||
assert!(payload["user"]["createdAt"].as_str().is_some());
|
||||
assert!(payload["user"].get("createdAt").is_none());
|
||||
assert_eq!(payload["created"], Value::Bool(true));
|
||||
assert!(payload["referral"].is_null());
|
||||
}
|
||||
|
||||
@@ -1217,13 +1217,17 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_upload_ticket_returns_service_unavailable_when_oss_missing() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
let token =
|
||||
seed_authenticated_token(&state, "13800138121", "sess_assets_missing_oss").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")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
@@ -1490,13 +1494,17 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn sts_upload_credentials_are_disabled_for_browser_writes() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
let token =
|
||||
seed_authenticated_token(&state, "13800138122", "sess_assets_sts_disabled").await;
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/assets/sts-upload-credentials")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("x-genarrative-response-envelope", "1")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
@@ -1525,13 +1533,18 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn confirm_asset_object_returns_service_unavailable_when_oss_missing() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
let token =
|
||||
seed_authenticated_token(&state, "13800138123", "sess_assets_confirm_missing_oss")
|
||||
.await;
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/assets/objects/confirm")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
@@ -1558,13 +1571,17 @@ mod tests {
|
||||
..AppConfig::default()
|
||||
};
|
||||
|
||||
let app = build_router(AppState::new(config).expect("state should build"));
|
||||
let state = AppState::new(config).expect("state should build");
|
||||
let token =
|
||||
seed_authenticated_token(&state, "13800138124", "sess_assets_bucket_mismatch").await;
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/assets/objects/confirm")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
@@ -1588,13 +1605,17 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn bind_asset_object_rejects_missing_slot_before_calling_spacetime() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
let token =
|
||||
seed_authenticated_token(&state, "13800138125", "sess_assets_missing_slot").await;
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/assets/objects/bind")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
|
||||
@@ -64,13 +64,15 @@ use crate::{
|
||||
EditorScreenBackgroundColor, editor_green_screen_character_prompt_clause,
|
||||
},
|
||||
editor_project::{
|
||||
EDITOR_BGFILTER_CROSS_CHECK_ENABLED, EDITOR_BGFILTER_DEFAULT_SEG_MODEL,
|
||||
EDITOR_ASSET_ID_PREFIX, EDITOR_BGFILTER_CROSS_CHECK_ENABLED,
|
||||
EDITOR_BGFILTER_DEFAULT_SEG_MODEL, EDITOR_RESOURCE_ID_PREFIX,
|
||||
EditorCanvasGeneratedLayerInput, EditorGenerationPhaseReporter,
|
||||
PersistEditorGeneratedAssetRequest,
|
||||
apply_editor_screen_background_decision_to_generation_inputs,
|
||||
build_editor_canvas_generated_layer_item, complete_editor_canvas_generation_with_items,
|
||||
persist_editor_generated_media_asset,
|
||||
remove_editor_generated_screen_background_with_bgfilter_with_request_timeout,
|
||||
resolve_editor_reference_object_key_for_owner,
|
||||
},
|
||||
editor_screen_background_decision::{
|
||||
EditorScreenBackgroundDecisionInput, EditorScreenBackgroundDecisionKind,
|
||||
@@ -130,7 +132,7 @@ const EDITOR_VIDEO_MODEL_VEO_3_1_FAST: &str = "veo3.1-fast";
|
||||
const EDITOR_VIDEO_REFERENCE_IMAGE_LIMIT: usize = 9;
|
||||
const EDITOR_VIDEO_REFERENCE_VIDEO_LIMIT: usize = 3;
|
||||
const EDITOR_VIDEO_REFERENCE_AUDIO_LIMIT: usize = 3;
|
||||
const EDITOR_VIDEO_REFERENCE_REQUEST_LIMIT_BYTES: usize = 64 * 1024 * 1024;
|
||||
const EDITOR_VIDEO_REFERENCE_REQUEST_LIMIT_BYTES: usize = 256 * 1024;
|
||||
const EDITOR_VIDEO_REFERENCE_READ_EXPIRE_SECONDS: u64 = 60 * 60;
|
||||
|
||||
const BUILT_IN_MOTION_TEMPLATES: [MotionTemplate; 4] = [
|
||||
@@ -568,6 +570,14 @@ pub async fn generate_editor_character_animation(
|
||||
)
|
||||
})?;
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
if matches_inline_media_source(payload.source_image_src.as_str()) {
|
||||
return Err(character_animation_error_response(
|
||||
&request_context,
|
||||
editor_character_animation_bad_request(
|
||||
"sourceImageSrc 必须先上传 OSS,并使用 objectKey 或画板资源引用。",
|
||||
),
|
||||
));
|
||||
}
|
||||
if !state.config.external_generation_mode.is_inline() {
|
||||
let pricing = state.editor_generation_pricing().await.map_err(|error| {
|
||||
character_animation_error_response(
|
||||
@@ -641,6 +651,14 @@ pub(crate) async fn generate_editor_character_animation_for_owner(
|
||||
let asset_folder_id = payload.asset_folder_id.clone();
|
||||
let asset_label =
|
||||
resolve_editor_character_animation_asset_label(payload.asset_label.as_deref());
|
||||
if matches_inline_media_source(payload.source_image_src.as_str()) {
|
||||
return Err(character_animation_error_response(
|
||||
&request_context,
|
||||
editor_character_animation_bad_request(
|
||||
"sourceImageSrc 必须先上传 OSS,并使用 objectKey 或画板资源引用。",
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let pricing = state.editor_generation_pricing().await.map_err(|error| {
|
||||
character_animation_error_response(
|
||||
@@ -654,10 +672,17 @@ pub(crate) async fn generate_editor_character_animation_for_owner(
|
||||
// 先解析源角色图:图生视频的主体颜色完全由源图决定,背景色决策必须看到这张图。
|
||||
let source_resolve_client = build_upstream_http_client(SOURCE_IMAGE_RESOLVE_TIMEOUT_MS)
|
||||
.map_err(|error| character_animation_error_response(&request_context, error))?;
|
||||
let source_object_key = resolve_editor_reference_object_key_for_owner(
|
||||
&state,
|
||||
owner_user_id.as_str(),
|
||||
payload.source_image_src.trim(),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| character_animation_error_response(&request_context, error))?;
|
||||
let source_data_url = resolve_media_source_as_data_url(
|
||||
&state,
|
||||
&source_resolve_client,
|
||||
payload.source_image_src.trim(),
|
||||
source_object_key.as_str(),
|
||||
"sourceImageSrc",
|
||||
)
|
||||
.await
|
||||
@@ -2014,7 +2039,8 @@ async fn request_editor_video_preview(
|
||||
request: &NormalizedEditorVideoRequest,
|
||||
) -> Result<GeneratedAnimationPreview, AppError> {
|
||||
let upstream_task_id =
|
||||
create_editor_text_to_video_task(state, http_client, settings, request).await?;
|
||||
create_editor_text_to_video_task(state, http_client, settings, owner_user_id, request)
|
||||
.await?;
|
||||
let video_url =
|
||||
wait_for_ark_content_generation_task(http_client, &settings.ark, upstream_task_id.as_str())
|
||||
.await?;
|
||||
@@ -2038,9 +2064,10 @@ async fn create_editor_text_to_video_task(
|
||||
state: &AppState,
|
||||
http_client: &reqwest::Client,
|
||||
settings: &EditorVideoSettings,
|
||||
owner_user_id: &str,
|
||||
request: &NormalizedEditorVideoRequest,
|
||||
) -> Result<String, AppError> {
|
||||
let request_body = build_editor_video_ark_request_body(state, request)?;
|
||||
let request_body = build_editor_video_ark_request_body(state, owner_user_id, request).await?;
|
||||
let response = http_client
|
||||
.post(format!(
|
||||
"{}/contents/generations/tasks",
|
||||
@@ -3402,11 +3429,12 @@ fn normalize_editor_video_request_with_pricing(
|
||||
})
|
||||
}
|
||||
|
||||
fn build_editor_video_ark_request_body(
|
||||
async fn build_editor_video_ark_request_body(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
request: &NormalizedEditorVideoRequest,
|
||||
) -> Result<Value, AppError> {
|
||||
let content = build_editor_video_ark_content(state, request)?;
|
||||
let content = build_editor_video_ark_content(state, owner_user_id, request).await?;
|
||||
Ok(json!({
|
||||
"model": request.provider_model,
|
||||
"content": content,
|
||||
@@ -3418,8 +3446,9 @@ fn build_editor_video_ark_request_body(
|
||||
}))
|
||||
}
|
||||
|
||||
fn build_editor_video_ark_content(
|
||||
async fn build_editor_video_ark_content(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
request: &NormalizedEditorVideoRequest,
|
||||
) -> Result<Vec<Value>, AppError> {
|
||||
let mut content = Vec::new();
|
||||
@@ -3431,22 +3460,25 @@ fn build_editor_video_ark_content(
|
||||
}
|
||||
let reference_image_urls = resolve_editor_video_reference_urls(
|
||||
state,
|
||||
owner_user_id,
|
||||
request.reference_image_srcs.as_slice(),
|
||||
"参考图片",
|
||||
true,
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
let reference_video_urls = resolve_editor_video_reference_urls(
|
||||
state,
|
||||
owner_user_id,
|
||||
request.reference_video_srcs.as_slice(),
|
||||
"参考视频",
|
||||
false,
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
let reference_audio_urls = resolve_editor_video_reference_urls(
|
||||
state,
|
||||
owner_user_id,
|
||||
request.reference_audio_srcs.as_slice(),
|
||||
"参考音频",
|
||||
true,
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
content.extend(reference_image_urls.iter().map(|url| {
|
||||
json!({
|
||||
"type": "image_url",
|
||||
@@ -3471,31 +3503,30 @@ fn build_editor_video_ark_content(
|
||||
Ok(content)
|
||||
}
|
||||
|
||||
fn resolve_editor_video_reference_urls(
|
||||
async fn resolve_editor_video_reference_urls(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
sources: &[String],
|
||||
label: &str,
|
||||
allow_data_url: bool,
|
||||
) -> Result<Vec<String>, AppError> {
|
||||
sources
|
||||
.iter()
|
||||
.map(|source| {
|
||||
resolve_editor_video_reference_url(state, source.as_str(), label, allow_data_url)
|
||||
})
|
||||
.collect()
|
||||
let mut resolved = Vec::with_capacity(sources.len());
|
||||
for source in sources {
|
||||
resolved.push(
|
||||
resolve_editor_video_reference_url(state, owner_user_id, source.as_str(), label)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
fn resolve_editor_video_reference_url(
|
||||
async fn resolve_editor_video_reference_url(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
source: &str,
|
||||
label: &str,
|
||||
allow_data_url: bool,
|
||||
) -> Result<String, AppError> {
|
||||
let value = source.trim();
|
||||
if value.starts_with("data:") {
|
||||
if allow_data_url {
|
||||
return Ok(value.to_string());
|
||||
}
|
||||
return Err(editor_video_bad_request(format!(
|
||||
"{label}格式不支持,请使用官方支持的 URL、素材 ID 或画板资源路径。"
|
||||
)));
|
||||
@@ -3507,17 +3538,24 @@ fn resolve_editor_video_reference_url(
|
||||
return Ok(value.to_string());
|
||||
}
|
||||
|
||||
let object_key = value.trim_start_matches('/');
|
||||
if LegacyAssetPrefix::from_object_key(object_key).is_none() {
|
||||
let format_hint = if allow_data_url {
|
||||
"公网 URL、素材 ID、data URL 或画板资源路径"
|
||||
} else {
|
||||
"公网 URL、素材 ID 或画板资源路径"
|
||||
};
|
||||
return Err(editor_video_bad_request(format!(
|
||||
"{label}必须是官方支持的{format_hint}。"
|
||||
)));
|
||||
}
|
||||
let object_key = resolve_editor_reference_object_key_for_owner(state, owner_user_id, value)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
error.with_details(json!({
|
||||
"provider": "editor-video",
|
||||
"field": label,
|
||||
"message": format!(
|
||||
"{label}必须是当前账号的 objectKey、项目资源 ID、素材 ID,或官方支持的公网 URL。"
|
||||
),
|
||||
}))
|
||||
})?;
|
||||
sign_editor_video_object_key_url(state, object_key.as_str())
|
||||
}
|
||||
|
||||
fn sign_editor_video_object_key_url(
|
||||
state: &AppState,
|
||||
object_key: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let oss_client = state.oss_client().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
@@ -3582,9 +3620,9 @@ fn normalize_editor_video_references(
|
||||
validate_editor_video_reference_src(
|
||||
src,
|
||||
"参考图片",
|
||||
&["data:image/", "http://", "https://", "asset://"],
|
||||
&["http://", "https://", "asset://"],
|
||||
true,
|
||||
"对应媒体 data URL",
|
||||
"URL、素材 ID 或画板资源路径",
|
||||
)?;
|
||||
}
|
||||
for src in &video_srcs {
|
||||
@@ -3600,9 +3638,9 @@ fn normalize_editor_video_references(
|
||||
validate_editor_video_reference_src(
|
||||
src,
|
||||
"参考音频",
|
||||
&["data:audio/", "http://", "https://", "asset://"],
|
||||
&["http://", "https://", "asset://"],
|
||||
true,
|
||||
"对应媒体 data URL",
|
||||
"URL、素材 ID 或画板资源路径",
|
||||
)?;
|
||||
}
|
||||
|
||||
@@ -3613,7 +3651,9 @@ fn normalize_editor_video_references(
|
||||
.map(String::len)
|
||||
.sum::<usize>();
|
||||
if request_bytes > EDITOR_VIDEO_REFERENCE_REQUEST_LIMIT_BYTES {
|
||||
return Err(editor_video_bad_request("参考文件总请求体不能超过 64MB。"));
|
||||
return Err(editor_video_bad_request(
|
||||
"参考素材引用字段总长度不能超过 256KB。",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(NormalizedEditorVideoReferences {
|
||||
@@ -3631,6 +3671,11 @@ fn normalize_reference_srcs(values: Vec<String>) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn matches_inline_media_source(value: &str) -> bool {
|
||||
let value = value.trim_start().to_ascii_lowercase();
|
||||
value.starts_with("data:") || value.starts_with("blob:")
|
||||
}
|
||||
|
||||
fn validate_editor_video_reference_src(
|
||||
value: &str,
|
||||
label: &str,
|
||||
@@ -3649,6 +3694,9 @@ fn validate_editor_video_reference_src(
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if value.starts_with(EDITOR_RESOURCE_ID_PREFIX) || value.starts_with(EDITOR_ASSET_ID_PREFIX) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(editor_video_bad_request(format!(
|
||||
"{label}格式不支持,请使用官方支持的{format_hint}。"
|
||||
)))
|
||||
@@ -6072,6 +6120,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_character_animation_rejects_inline_media_before_queueing() {
|
||||
let source = include_str!("character_animation_assets.rs");
|
||||
assert_function_contains_in_order(
|
||||
source,
|
||||
"pub async fn generate_editor_character_animation",
|
||||
"pub(crate) async fn generate_editor_character_animation_for_owner",
|
||||
&[
|
||||
"matches_inline_media_source",
|
||||
"enqueue_editor_generation_job",
|
||||
],
|
||||
);
|
||||
assert_function_contains_in_order(
|
||||
source,
|
||||
"pub(crate) async fn generate_editor_character_animation_for_owner",
|
||||
"pub async fn generate_editor_video",
|
||||
&[
|
||||
"resolve_editor_reference_object_key_for_owner",
|
||||
"resolve_media_source_as_data_url",
|
||||
],
|
||||
);
|
||||
assert!(matches_inline_media_source("data:image/png;base64,AAAA"));
|
||||
assert!(matches_inline_media_source(" BLOB:local-preview"));
|
||||
assert!(!matches_inline_media_source(
|
||||
"/generated-images/editor/character.png"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_character_animation_reports_processing_after_video_generation() {
|
||||
let source = include_str!("character_animation_assets.rs");
|
||||
@@ -6532,11 +6608,15 @@ mod tests {
|
||||
mode: "std".to_string(),
|
||||
sound: "off".to_string(),
|
||||
web_search_enabled: true,
|
||||
reference_image_srcs: vec!["data:image/png;base64,image".to_string()],
|
||||
reference_image_srcs: vec![
|
||||
"generated-character-drafts/editor/seedance-references/image/demo.png".to_string(),
|
||||
],
|
||||
reference_video_srcs: vec![
|
||||
"generated-character-drafts/editor/seedance-references/video/demo.mp4".to_string(),
|
||||
],
|
||||
reference_audio_srcs: vec!["data:audio/mpeg;base64,audio".to_string()],
|
||||
reference_audio_srcs: vec![
|
||||
"generated-character-drafts/editor/seedance-references/audio/demo.mp3".to_string(),
|
||||
],
|
||||
project_id: None,
|
||||
canvas_completion: None,
|
||||
generation_inputs: None,
|
||||
@@ -6549,7 +6629,7 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
normalized.reference_image_srcs,
|
||||
vec!["data:image/png;base64,image"]
|
||||
vec!["generated-character-drafts/editor/seedance-references/image/demo.png"]
|
||||
);
|
||||
assert_eq!(
|
||||
normalized.reference_video_srcs,
|
||||
@@ -6557,7 +6637,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
normalized.reference_audio_srcs,
|
||||
vec!["data:audio/mpeg;base64,audio"]
|
||||
vec!["generated-character-drafts/editor/seedance-references/audio/demo.mp3"]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6589,7 +6669,46 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_video_ark_body_sets_generate_audio_false_without_mode() {
|
||||
fn editor_video_rejects_seedance_image_and_audio_data_urls() {
|
||||
for (image_srcs, audio_srcs, expected_message) in [
|
||||
(
|
||||
vec!["data:image/png;base64,image".to_string()],
|
||||
Vec::new(),
|
||||
"参考图片格式不支持",
|
||||
),
|
||||
(
|
||||
vec!["generated-character-drafts/editor/reference.png".to_string()],
|
||||
vec!["data:audio/mpeg;base64,audio".to_string()],
|
||||
"参考音频格式不支持",
|
||||
),
|
||||
] {
|
||||
let error = normalize_editor_video_request(EditorVideoGenerateRequest {
|
||||
prompt: "参考素材生成视频".to_string(),
|
||||
model: EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST.to_string(),
|
||||
aspect_ratio: "16:9".to_string(),
|
||||
duration_seconds: 4,
|
||||
resolution: "480p".to_string(),
|
||||
mode: "std".to_string(),
|
||||
sound: "off".to_string(),
|
||||
web_search_enabled: true,
|
||||
reference_image_srcs: image_srcs,
|
||||
reference_video_srcs: Vec::new(),
|
||||
reference_audio_srcs: audio_srcs,
|
||||
project_id: None,
|
||||
canvas_completion: None,
|
||||
generation_inputs: None,
|
||||
source_resource_id: None,
|
||||
asset_kind: None,
|
||||
asset_folder_id: None,
|
||||
asset_label: None,
|
||||
})
|
||||
.expect_err("Seedance references must not accept data URLs");
|
||||
assert!(error.body_text().contains(expected_message));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn editor_video_ark_body_sets_generate_audio_false_without_mode() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
let request = normalize_editor_video_request(EditorVideoGenerateRequest {
|
||||
prompt: "参考素材生成视频".to_string(),
|
||||
@@ -6600,9 +6719,9 @@ mod tests {
|
||||
mode: "std".to_string(),
|
||||
sound: "off".to_string(),
|
||||
web_search_enabled: true,
|
||||
reference_image_srcs: vec!["data:image/png;base64,image".to_string()],
|
||||
reference_image_srcs: vec!["https://assets.example.com/reference.png".to_string()],
|
||||
reference_video_srcs: vec!["https://assets.example.com/reference.mp4".to_string()],
|
||||
reference_audio_srcs: vec!["data:audio/mpeg;base64,audio".to_string()],
|
||||
reference_audio_srcs: vec!["https://assets.example.com/reference.mp3".to_string()],
|
||||
project_id: None,
|
||||
canvas_completion: None,
|
||||
generation_inputs: None,
|
||||
@@ -6613,8 +6732,9 @@ mod tests {
|
||||
})
|
||||
.expect("seedance multimodal request should normalize");
|
||||
|
||||
let body =
|
||||
build_editor_video_ark_request_body(&state, &request).expect("ark body should build");
|
||||
let body = build_editor_video_ark_request_body(&state, "video-owner", &request)
|
||||
.await
|
||||
.expect("ark body should build");
|
||||
|
||||
assert_eq!(body["generate_audio"], json!(false));
|
||||
assert!(body.get("mode").is_none());
|
||||
@@ -6628,7 +6748,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_video_ark_content_signs_generated_object_keys() {
|
||||
fn editor_video_reference_resolution_checks_owner_before_signing() {
|
||||
let state = AppState::new(AppConfig {
|
||||
oss_bucket: Some("genarrative-assets".to_string()),
|
||||
oss_endpoint: Some("oss-cn-shanghai.aliyuncs.com".to_string()),
|
||||
@@ -6637,7 +6757,14 @@ mod tests {
|
||||
..AppConfig::default()
|
||||
})
|
||||
.expect("state should build with OSS");
|
||||
let request = normalize_editor_video_request(EditorVideoGenerateRequest {
|
||||
let object_key = "generated-character-drafts/editor/seedance-references/video/demo.mp4";
|
||||
let signed_url = sign_editor_video_object_key_url(&state, object_key)
|
||||
.expect("validated object keys should be signable");
|
||||
assert!(signed_url.starts_with("https://genarrative-assets.oss-cn-shanghai.aliyuncs.com/"));
|
||||
assert!(signed_url.contains(object_key));
|
||||
assert!(signed_url.contains("x-oss-signature="));
|
||||
|
||||
let registered_reference = normalize_editor_video_request(EditorVideoGenerateRequest {
|
||||
prompt: "参考视频生成视频".to_string(),
|
||||
model: EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST.to_string(),
|
||||
aspect_ratio: "16:9".to_string(),
|
||||
@@ -6647,9 +6774,7 @@ mod tests {
|
||||
sound: "off".to_string(),
|
||||
web_search_enabled: true,
|
||||
reference_image_srcs: Vec::new(),
|
||||
reference_video_srcs: vec![
|
||||
"generated-character-drafts/editor/seedance-references/video/demo.mp4".to_string(),
|
||||
],
|
||||
reference_video_srcs: vec!["editor-resource-video-reference".to_string()],
|
||||
reference_audio_srcs: Vec::new(),
|
||||
project_id: None,
|
||||
canvas_completion: None,
|
||||
@@ -6659,23 +6784,21 @@ mod tests {
|
||||
asset_folder_id: None,
|
||||
asset_label: None,
|
||||
})
|
||||
.expect("seedance objectKey request should normalize");
|
||||
|
||||
let content = build_editor_video_ark_content(&state, &request)
|
||||
.expect("ark content should sign object keys");
|
||||
let signed_url = content[1]["video_url"]["url"]
|
||||
.as_str()
|
||||
.expect("video url should be string");
|
||||
|
||||
assert!(signed_url.starts_with("https://genarrative-assets.oss-cn-shanghai.aliyuncs.com/"));
|
||||
assert!(
|
||||
signed_url
|
||||
.contains("generated-character-drafts/editor/seedance-references/video/demo.mp4")
|
||||
.expect("registered editor resource IDs should normalize");
|
||||
assert_eq!(
|
||||
registered_reference.reference_video_srcs,
|
||||
vec!["editor-resource-video-reference"]
|
||||
);
|
||||
assert!(signed_url.contains("x-oss-signature="));
|
||||
assert_ne!(
|
||||
signed_url,
|
||||
"generated-character-drafts/editor/seedance-references/video/demo.mp4"
|
||||
|
||||
let source = include_str!("character_animation_assets.rs");
|
||||
assert_function_contains_in_order(
|
||||
source,
|
||||
"async fn resolve_editor_video_reference_url",
|
||||
"fn sign_editor_video_object_key_url",
|
||||
&[
|
||||
"resolve_editor_reference_object_key_for_owner",
|
||||
"sign_editor_video_object_key_url",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -28,7 +28,6 @@ use crate::{
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
const EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024;
|
||||
const EDITOR_AGENT_MESSAGE_BODY_LIMIT_BYTES: usize = 2 * 1024 * 1024;
|
||||
|
||||
pub fn router(state: AppState) -> Router<AppState> {
|
||||
@@ -183,49 +182,31 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
)
|
||||
.route(
|
||||
"/api/editor/images/generations",
|
||||
post(generate_editor_image)
|
||||
.layer(DefaultBodyLimit::max(
|
||||
// 中文注释:图片画布生图仍兼容参考图 Data URL 入参,宣发素材提交前会压缩,后端保留有限兜底。
|
||||
EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES,
|
||||
))
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
post(generate_editor_image).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/editor/images/edits",
|
||||
post(edit_editor_image)
|
||||
.layer(DefaultBodyLimit::max(
|
||||
EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES,
|
||||
))
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
post(edit_editor_image).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/editor/images/background-removals",
|
||||
post(remove_editor_image_background)
|
||||
.layer(DefaultBodyLimit::max(
|
||||
EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES,
|
||||
))
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
post(remove_editor_image_background).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/editor/icon-spritesheets/generations",
|
||||
post(generate_editor_icon_spritesheet)
|
||||
// 中文注释:图标规范参考图会以 Data URL 提交,需避免默认 2MB body limit 在 handler 前拦截。
|
||||
.layer(DefaultBodyLimit::max(
|
||||
EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES,
|
||||
))
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
post(generate_editor_icon_spritesheet).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/editor/icon-spritesheets/slices",
|
||||
@@ -236,14 +217,9 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
)
|
||||
.route(
|
||||
"/api/editor/ui-designs/assets/extractions",
|
||||
post(extract_editor_ui_design_assets)
|
||||
// 中文注释:UI 设计图提取素材复用图片 Data URL 入参,需兼容较大的画板生成图。
|
||||
.layer(DefaultBodyLimit::max(
|
||||
EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES,
|
||||
))
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
post(extract_editor_ui_design_assets).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
use axum::{
|
||||
Router,
|
||||
extract::DefaultBodyLimit,
|
||||
middleware,
|
||||
Router, middleware,
|
||||
routing::{get, patch, post},
|
||||
};
|
||||
|
||||
@@ -28,9 +26,6 @@ use crate::{
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
const EXTERNAL_EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024;
|
||||
const EXTERNAL_EDITOR_VIDEO_BODY_LIMIT_BYTES: usize = 64 * 1024 * 1024;
|
||||
|
||||
pub fn router(state: AppState) -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/external/v1/openapi.json", get(openapi_json))
|
||||
@@ -141,69 +136,42 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
)
|
||||
.route(
|
||||
"/api/external/v1/editor/images/generations",
|
||||
post(generate_external_editor_image)
|
||||
.layer(DefaultBodyLimit::max(
|
||||
EXTERNAL_EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES,
|
||||
))
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_external_api_key,
|
||||
)),
|
||||
post(generate_external_editor_image).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_external_api_key,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/external/v1/editor/images/edits",
|
||||
post(edit_external_editor_image)
|
||||
.layer(DefaultBodyLimit::max(
|
||||
EXTERNAL_EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES,
|
||||
))
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_external_api_key,
|
||||
)),
|
||||
post(edit_external_editor_image).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_external_api_key,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/external/v1/editor/icon-spritesheets/generations",
|
||||
post(generate_external_editor_icon_spritesheet)
|
||||
.layer(DefaultBodyLimit::max(
|
||||
EXTERNAL_EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES,
|
||||
))
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_external_api_key,
|
||||
)),
|
||||
post(generate_external_editor_icon_spritesheet).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_external_api_key),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/api/external/v1/editor/ui-designs/assets/extractions",
|
||||
post(extract_external_editor_ui_design_assets)
|
||||
.layer(DefaultBodyLimit::max(
|
||||
EXTERNAL_EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES,
|
||||
))
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_external_api_key,
|
||||
)),
|
||||
post(extract_external_editor_ui_design_assets).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_external_api_key),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/api/external/v1/editor/character-animations/generations",
|
||||
post(generate_external_editor_character_animation)
|
||||
.layer(DefaultBodyLimit::max(
|
||||
EXTERNAL_EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES,
|
||||
))
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_external_api_key,
|
||||
)),
|
||||
post(generate_external_editor_character_animation).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_external_api_key),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/api/external/v1/editor/videos/generations",
|
||||
post(generate_external_editor_video)
|
||||
.layer(DefaultBodyLimit::max(
|
||||
EXTERNAL_EDITOR_VIDEO_BODY_LIMIT_BYTES,
|
||||
))
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_external_api_key,
|
||||
)),
|
||||
post(generate_external_editor_video).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_external_api_key,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/external/v1/editor/audios/sound-effects/generations",
|
||||
|
||||
@@ -52,8 +52,6 @@ use crate::{
|
||||
};
|
||||
|
||||
const HYPER3D_IMAGE_TO_MODEL_BODY_LIMIT_BYTES: usize = 56 * 1024 * 1024;
|
||||
const EDITOR_CHARACTER_ANIMATION_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024;
|
||||
const EDITOR_VIDEO_BODY_LIMIT_BYTES: usize = 64 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct PlayFlowDomainAdapter {
|
||||
@@ -460,24 +458,17 @@ fn play_flow_support_router(state: AppState) -> Router<AppState> {
|
||||
)
|
||||
.route(
|
||||
"/api/editor/character-animations/generations",
|
||||
post(generate_editor_character_animation)
|
||||
.layer(DefaultBodyLimit::max(
|
||||
// 中文注释:画板角色动画首版仍兼容角色图 Data URL 入参,避免大于 Axum 默认 2MB 的角色图在 handler 前被拦截。
|
||||
EDITOR_CHARACTER_ANIMATION_BODY_LIMIT_BYTES,
|
||||
))
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
post(generate_editor_character_animation).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/editor/videos/generations",
|
||||
post(generate_editor_video)
|
||||
.layer(DefaultBodyLimit::max(EDITOR_VIDEO_BODY_LIMIT_BYTES))
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
post(generate_editor_video).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/editor/audios/sound-effects/generations",
|
||||
|
||||
@@ -126,8 +126,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_inventory_returns_bad_gateway_when_spacetime_not_published() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let (state, token) = seed_authenticated_state().await;
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
@@ -161,35 +160,29 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
async fn seed_authenticated_state() -> AppState {
|
||||
async fn seed_authenticated_state() -> (AppState, String) {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
state
|
||||
let user = state
|
||||
.seed_test_phone_user_with_password("13800138103", "secret123")
|
||||
.await
|
||||
.id;
|
||||
state
|
||||
}
|
||||
|
||||
fn issue_access_token(state: &AppState) -> String {
|
||||
.await;
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
session_id: state.seed_test_refresh_session_for_user_id(
|
||||
"user_00000001",
|
||||
"sess_runtime_inventory",
|
||||
),
|
||||
user_id: user.id.clone(),
|
||||
session_id: state
|
||||
.seed_test_refresh_session_for_user(&user, "sess_runtime_inventory"),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 2,
|
||||
token_version: user.token_version,
|
||||
phone_verified: true,
|
||||
binding_status: BindingStatus::Active,
|
||||
display_name: Some("背包查询用户".to_string()),
|
||||
display_name: Some(user.display_name.clone()),
|
||||
},
|
||||
state.auth_jwt_config(),
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.expect("claims should build");
|
||||
|
||||
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
|
||||
let token = sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign");
|
||||
(state, token)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +123,7 @@ impl TrackingOutbox {
|
||||
.as_mut()
|
||||
.expect("active file should be open before append");
|
||||
file.write_all(&line).await?;
|
||||
file.flush().await?;
|
||||
inner.active_count = inner.active_count.saturating_add(1);
|
||||
inner.active_bytes = inner.active_bytes.saturating_add(line_bytes);
|
||||
inner.total_bytes = inner.total_bytes.saturating_add(line_bytes);
|
||||
@@ -571,6 +572,19 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enqueue_flushes_active_file_before_success() {
|
||||
let dir = test_dir("flush-before-success");
|
||||
let outbox = test_outbox(dir.clone(), 500, 1024 * 1024);
|
||||
|
||||
outbox.enqueue(sample_event("event-1")).await.unwrap();
|
||||
|
||||
let active_contents = std::fs::read_to_string(dir.join(ACTIVE_FILE_NAME)).unwrap();
|
||||
assert!(active_contents.contains("event-1"));
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enqueue_drops_when_outbox_exceeds_max_bytes() {
|
||||
let dir = test_dir("max-bytes");
|
||||
|
||||
@@ -1201,7 +1201,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_video_request_supports_seedance_multimodal_references() {
|
||||
fn editor_video_request_supports_stable_seedance_multimodal_references() {
|
||||
let payload = serde_json::to_value(EditorVideoGenerateRequest {
|
||||
prompt: "参考素材生成视频".to_string(),
|
||||
model: "seedance2.0-fast".to_string(),
|
||||
@@ -1211,11 +1211,15 @@ mod tests {
|
||||
mode: "std".to_string(),
|
||||
sound: "off".to_string(),
|
||||
web_search_enabled: true,
|
||||
reference_image_srcs: vec!["data:image/png;base64,image".to_string()],
|
||||
reference_image_srcs: vec![
|
||||
"generated-character-drafts/editor/seedance-references/image/demo.png".to_string(),
|
||||
],
|
||||
reference_video_srcs: vec![
|
||||
"generated-character-drafts/editor/seedance-references/video/demo.mp4".to_string(),
|
||||
],
|
||||
reference_audio_srcs: vec!["data:audio/mpeg;base64,audio".to_string()],
|
||||
reference_audio_srcs: vec![
|
||||
"generated-character-drafts/editor/seedance-references/audio/demo.mp3".to_string(),
|
||||
],
|
||||
project_id: None,
|
||||
canvas_completion: None,
|
||||
generation_inputs: None,
|
||||
@@ -1228,7 +1232,7 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
payload["referenceImageSrcs"],
|
||||
json!(["data:image/png;base64,image"])
|
||||
json!(["generated-character-drafts/editor/seedance-references/image/demo.png"])
|
||||
);
|
||||
assert_eq!(
|
||||
payload["referenceVideoSrcs"],
|
||||
@@ -1236,7 +1240,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
payload["referenceAudioSrcs"],
|
||||
json!(["data:audio/mpeg;base64,audio"])
|
||||
json!(["generated-character-drafts/editor/seedance-references/audio/demo.mp3"])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1288,8 +1292,8 @@ mod tests {
|
||||
#[test]
|
||||
fn editor_icon_spritesheet_request_omits_price_and_response_includes_price() {
|
||||
let request_payload = serde_json::to_value(EditorIconSpritesheetGenerateRequest {
|
||||
reference_image_src: "data:image/png;base64,spec".to_string(),
|
||||
reference_image_srcs: vec!["data:image/png;base64,style".to_string()],
|
||||
reference_image_src: "generated-images/editor/spec.png".to_string(),
|
||||
reference_image_srcs: vec!["generated-images/editor/style.png".to_string()],
|
||||
icon_descriptions: vec!["返回按钮".to_string(), "设置按钮".to_string()],
|
||||
model: Some("gemini-3.1-flash-image-preview".to_string()),
|
||||
aspect_ratio: Some("1:1".to_string()),
|
||||
@@ -1299,16 +1303,16 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
request_payload["referenceImageSrc"],
|
||||
json!("data:image/png;base64,spec")
|
||||
json!("generated-images/editor/spec.png")
|
||||
);
|
||||
assert_eq!(
|
||||
request_payload["referenceImageSrcs"],
|
||||
json!(["data:image/png;base64,style"])
|
||||
json!(["generated-images/editor/style.png"])
|
||||
);
|
||||
assert!(request_payload.get("priceMudPoints").is_none());
|
||||
|
||||
let response_payload = serde_json::to_value(EditorIconSpritesheetGenerateResponse {
|
||||
spritesheet_image_src: "data:image/png;base64,sheet".to_string(),
|
||||
spritesheet_image_src: "/generated-images/editor/sheet.png".to_string(),
|
||||
spritesheet_width: 512,
|
||||
spritesheet_height: 512,
|
||||
icon_image_srcs: Vec::new(),
|
||||
|
||||
@@ -4,7 +4,7 @@ param(
|
||||
[switch]$Help,
|
||||
[string]$ListenHost = "127.0.0.1",
|
||||
[int]$Port = 3000,
|
||||
[string]$RootDir = ""
|
||||
[string]$DataDir = ""
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
@@ -31,8 +31,8 @@ if ($Help) {
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$serverRsDir = Split-Path -Parent $scriptDir
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($RootDir)) {
|
||||
$RootDir = Join-Path $serverRsDir ".spacetimedb\local"
|
||||
if ([string]::IsNullOrWhiteSpace($DataDir)) {
|
||||
$DataDir = Join-Path $serverRsDir ".spacetimedb\local\data"
|
||||
}
|
||||
|
||||
if (-not (Test-Path (Join-Path $serverRsDir "crates\spacetime-module\README.md"))) {
|
||||
@@ -49,19 +49,19 @@ if ($null -eq $spacetimeCommand) {
|
||||
) -join " "
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $RootDir | Out-Null
|
||||
New-Item -ItemType Directory -Force -Path $DataDir | Out-Null
|
||||
|
||||
$listenAddress = "$ListenHost`:$Port"
|
||||
|
||||
Write-Host "[server-rs:spacetime-dev] working dir: $serverRsDir"
|
||||
Write-Host "[server-rs:spacetime-dev] root dir: $RootDir"
|
||||
Write-Host "[server-rs:spacetime-dev] data dir: $DataDir"
|
||||
Write-Host "[server-rs:spacetime-dev] listen addr: $listenAddress"
|
||||
Write-Host "[server-rs:spacetime-dev] mode: standalone"
|
||||
Write-Host "[server-rs:spacetime-dev] note: module scaffold already exists; publish remains manual in this stage"
|
||||
|
||||
Push-Location $serverRsDir
|
||||
try {
|
||||
& $spacetimeCommand.Source --root-dir $RootDir start --edition standalone --listen-addr $listenAddress
|
||||
& $spacetimeCommand.Source start --data-dir $DataDir --listen-addr $listenAddress --non-interactive
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
|
||||
@@ -27,7 +27,7 @@ SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SERVER_RS_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
|
||||
LISTEN_HOST="${GENARRATIVE_SPACETIME_HOST:-127.0.0.1}"
|
||||
PORT="${GENARRATIVE_SPACETIME_PORT:-3000}"
|
||||
ROOT_DIR="${GENARRATIVE_SPACETIME_ROOT_DIR:-${SERVER_RS_DIR}/.spacetimedb/local}"
|
||||
DATA_DIR="${GENARRATIVE_SPACETIME_DATA_DIR:-${SERVER_RS_DIR}/.spacetimedb/local/data}"
|
||||
|
||||
if [[ ! -f "${SERVER_RS_DIR}/crates/spacetime-module/README.md" ]]; then
|
||||
echo "[server-rs:spacetime-dev] 未找到 crates/spacetime-module/README.md,无法启动本地 SpacetimeDB 脚本。" >&2
|
||||
@@ -41,13 +41,13 @@ if ! command -v spacetime >/dev/null 2>&1; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "${ROOT_DIR}"
|
||||
mkdir -p "${DATA_DIR}"
|
||||
|
||||
echo "[server-rs:spacetime-dev] 工作目录: ${SERVER_RS_DIR}"
|
||||
echo "[server-rs:spacetime-dev] 数据目录: ${ROOT_DIR}"
|
||||
echo "[server-rs:spacetime-dev] 数据目录: ${DATA_DIR}"
|
||||
echo "[server-rs:spacetime-dev] 监听地址: ${LISTEN_HOST}:${PORT}"
|
||||
echo "[server-rs:spacetime-dev] 模式: standalone"
|
||||
echo "[server-rs:spacetime-dev] 说明: 当前阶段已落 crate scaffold,但仍不自动 publish crates/spacetime-module"
|
||||
|
||||
cd "${SERVER_RS_DIR}"
|
||||
spacetime --root-dir "${ROOT_DIR}" start --edition standalone --listen-addr "${LISTEN_HOST}:${PORT}"
|
||||
spacetime start --data-dir "${DATA_DIR}" --listen-addr "${LISTEN_HOST}:${PORT}" --non-interactive
|
||||
|
||||
Reference in New Issue
Block a user