feat: complete M6 asset OSS migration
This commit is contained in:
@@ -50,6 +50,17 @@
|
||||
- `POST /api/runtime/story/actions/resolve`
|
||||
- `POST /api/runtime/story/initial`
|
||||
- `POST /api/runtime/story/continue`
|
||||
26. 接入 `POST /api/assets/character-visual/generate`
|
||||
27. 接入 `GET /api/assets/character-visual/jobs/{task_id}`
|
||||
28. 接入 `POST /api/assets/character-visual/publish`
|
||||
29. 接入 `GET /api/assets/character-animation/templates`
|
||||
30. 接入 `POST /api/assets/character-animation/import-video`
|
||||
31. 接入 `GET /api/assets/character-workflow-cache/{character_id}`
|
||||
32. 接入 `POST /api/assets/character-workflow-cache`
|
||||
33. 接入 `POST /api/assets/character-animation/generate`
|
||||
34. 接入 `GET /api/assets/character-animation/jobs/{task_id}`
|
||||
35. 接入 `POST /api/assets/character-animation/publish`
|
||||
36. 接入旧 `/generated-character-drafts/*`、`/generated-characters/*`、`/generated-animations/*`、`/generated-custom-world-scenes/*`、`/generated-custom-world-covers/*`、`/generated-qwen-sprites/*` 到 OSS 私有读代理
|
||||
|
||||
后续与本 crate 直接相关的任务包括:
|
||||
|
||||
@@ -75,6 +86,11 @@
|
||||
20. [x] 接入 `custom world library / gallery / publish_world` 首批 facade
|
||||
21. [x] 接入 `custom world agent session create / snapshot` facade
|
||||
22. [x] 接入旧 `runtime story` compat facade
|
||||
23. [x] 接入 `character-visual generate / jobs / publish` 第一批 OSS 主链兼容 facade
|
||||
24. [x] 接入 `character-animation templates / import-video` 第一批 OSS 草稿兼容 facade
|
||||
25. [x] 接入 `character-workflow-cache get / save` 第一批 OSS JSON 草稿兼容 facade
|
||||
26. [x] 接入 `character-animation generate / jobs / publish` 第一批 OSS 主链兼容 facade
|
||||
27. [x] 接入旧 `/generated-*` 路径到 OSS 私有读同源代理
|
||||
|
||||
当前 tracing 约定:
|
||||
|
||||
@@ -144,3 +160,9 @@
|
||||
13. 当前 `/api/assets/sts-upload-credentials` 按“服务器上传、Web 只下载”口径固定返回 `403`,不向浏览器下发 OSS 写权限。
|
||||
14. 当前 `/api/runtime/custom-world/agent/sessions` 与 `/api/runtime/custom-world/agent/sessions/{session_id}` 只提供 deterministic session 骨架与 snapshot 读取,不承诺 message submit、operation query、card detail 的完整能力。
|
||||
15. 当前 `/api/runtime/story/*` 已在 Rust 侧补齐 compat handler,但内部仍是 `runtime_snapshot` 驱动的兼容桥与确定性动作编排,不应误判为真正的 SpacetimeDB `resolve_story_action` 真相链已完成。
|
||||
16. 当前 `/api/assets/character-visual/*` 第一批只保证旧接口 contract、OSS 草稿/正式对象、`asset_object` 与 `asset_entity_binding` 主链可用;真实图片模型、workflow cache 与本地角色覆盖写回仍在后续阶段。
|
||||
17. 当前 `/api/assets/character-animation/import-video` 第一批只接受 `data:video/*;base64,...` 并写入 OSS 草稿区,不读取旧本地 `public/` 路径,也不创建正式 `asset_object`。
|
||||
18. 当前 `/api/assets/character-workflow-cache/*` 第一批只把工作流 JSON 草稿写入 OSS,不迁移历史本地缓存,也不创建正式 `asset_object`。
|
||||
19. 当前 `/api/assets/character-animation/generate` 第一批只用 Rust 占位产物打通 `AiTaskService + OSS` 草稿链;`image-sequence` 写 SVG 帧,视频类策略优先复用参考视频或仓库内可播放占位视频,不代表真实上游视频模型已完成迁移。
|
||||
20. 当前 `/api/assets/character-animation/publish` 会把前端提交帧、动作级 manifest 与总 manifest 写入 OSS,并只把总 manifest 确认为 `asset_object` 后绑定到 `character / animation_set`。
|
||||
21. 当前旧 `/generated-*` 读取兼容层只代理受支持 generated 前缀到 OSS 私有读签名,不回退仓库 `public/`,Stage 1 不支持视频 Range 分片。
|
||||
|
||||
@@ -24,6 +24,14 @@ use crate::{
|
||||
},
|
||||
auth_me::auth_me,
|
||||
auth_sessions::auth_sessions,
|
||||
character_animation_assets::{
|
||||
generate_character_animation, get_character_animation_job, get_character_workflow_cache,
|
||||
import_character_animation_video, list_character_animation_templates,
|
||||
publish_character_animation, save_character_workflow_cache,
|
||||
},
|
||||
character_visual_assets::{
|
||||
generate_character_visual, get_character_visual_job, publish_character_visual,
|
||||
},
|
||||
custom_world::{
|
||||
create_custom_world_agent_session, execute_custom_world_agent_action,
|
||||
get_custom_world_agent_card_detail, get_custom_world_agent_operation,
|
||||
@@ -40,6 +48,11 @@ use crate::{
|
||||
},
|
||||
error_middleware::normalize_error_response,
|
||||
health::health_check,
|
||||
legacy_generated_assets::{
|
||||
proxy_generated_animations, proxy_generated_character_drafts, proxy_generated_characters,
|
||||
proxy_generated_custom_world_covers, proxy_generated_custom_world_scenes,
|
||||
proxy_generated_qwen_sprites,
|
||||
},
|
||||
llm::proxy_llm_chat_completions,
|
||||
login_options::auth_login_options,
|
||||
logout::logout,
|
||||
@@ -95,6 +108,30 @@ pub fn build_router(state: AppState) -> Router {
|
||||
)),
|
||||
)
|
||||
.route("/api/auth/login-options", get(auth_login_options))
|
||||
.route(
|
||||
"/generated-character-drafts/{*path}",
|
||||
get(proxy_generated_character_drafts),
|
||||
)
|
||||
.route(
|
||||
"/generated-characters/{*path}",
|
||||
get(proxy_generated_characters),
|
||||
)
|
||||
.route(
|
||||
"/generated-animations/{*path}",
|
||||
get(proxy_generated_animations),
|
||||
)
|
||||
.route(
|
||||
"/generated-custom-world-scenes/{*path}",
|
||||
get(proxy_generated_custom_world_scenes),
|
||||
)
|
||||
.route(
|
||||
"/generated-custom-world-covers/{*path}",
|
||||
get(proxy_generated_custom_world_covers),
|
||||
)
|
||||
.route(
|
||||
"/generated-qwen-sprites/{*path}",
|
||||
get(proxy_generated_qwen_sprites),
|
||||
)
|
||||
.route(
|
||||
"/api/auth/me",
|
||||
get(auth_me).route_layer(middleware::from_fn_with_state(
|
||||
@@ -234,6 +271,46 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/assets/objects/bind",
|
||||
post(bind_asset_object_to_entity),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-visual/generate",
|
||||
post(generate_character_visual),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-visual/jobs/{task_id}",
|
||||
get(get_character_visual_job),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-visual/publish",
|
||||
post(publish_character_visual),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-animation/generate",
|
||||
post(generate_character_animation),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-animation/jobs/{task_id}",
|
||||
get(get_character_animation_job),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-animation/publish",
|
||||
post(publish_character_animation),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-animation/import-video",
|
||||
post(import_character_animation_video),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-animation/templates",
|
||||
get(list_character_animation_templates),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-workflow-cache",
|
||||
post(save_character_workflow_cache),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-workflow-cache/{character_id}",
|
||||
get(get_character_workflow_cache),
|
||||
)
|
||||
.route("/api/assets/read-url", get(get_asset_read_url))
|
||||
.route(
|
||||
"/api/runtime/settings",
|
||||
|
||||
1930
server-rs/crates/api-server/src/character_animation_assets.rs
Normal file
1930
server-rs/crates/api-server/src/character_animation_assets.rs
Normal file
File diff suppressed because it is too large
Load Diff
938
server-rs/crates/api-server/src/character_visual_assets.rs
Normal file
938
server-rs/crates/api-server/src/character_visual_assets.rs
Normal file
@@ -0,0 +1,938 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, Path, State, rejection::JsonRejection},
|
||||
http::StatusCode,
|
||||
response::Response,
|
||||
};
|
||||
use module_ai::{
|
||||
AiResultReferenceKind, AiStageCompletionInput, AiTaskCreateInput, AiTaskKind,
|
||||
AiTaskServiceError, AiTaskSnapshot, AiTaskStageKind, AiTaskStatus, generate_ai_task_id,
|
||||
};
|
||||
use module_assets::{
|
||||
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input,
|
||||
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
|
||||
};
|
||||
use platform_llm::{LlmMessage, LlmTextRequest};
|
||||
use platform_oss::{
|
||||
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest,
|
||||
OssSignedGetObjectUrlRequest,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::assets::{
|
||||
CharacterAssetJobStatusPayload, CharacterAssetJobStatusText, CharacterVisualDraftPayload,
|
||||
CharacterVisualGenerateRequest, CharacterVisualGenerateResponse, CharacterVisualPublishRequest,
|
||||
CharacterVisualPublishResponse,
|
||||
};
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, http_error::AppError, request_context::RequestContext,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
const CHARACTER_VISUAL_MODEL: &str = "rust-svg-character-visual";
|
||||
const CHARACTER_VISUAL_ASSET_KIND: &str = "character_visual";
|
||||
const CHARACTER_VISUAL_ENTITY_KIND: &str = "character";
|
||||
const CHARACTER_VISUAL_SLOT: &str = "primary_visual";
|
||||
|
||||
pub async fn generate_character_visual(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
payload: Result<Json<CharacterVisualGenerateRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = payload.map_err(|error| {
|
||||
character_visual_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "character-visual",
|
||||
"message": error.body_text(),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
// 旧资产工坊接口没有显式 Bearer 头,Rust 兼容层先使用工具用户归属,避免破坏现有前端调用。
|
||||
let owner_user_id = "asset-tool".to_string();
|
||||
let task_id = generate_ai_task_id(current_utc_micros());
|
||||
let prompt = build_character_visual_prompt(
|
||||
payload.prompt_text.as_str(),
|
||||
payload.character_brief_text.as_deref(),
|
||||
);
|
||||
let character_id = normalize_required_text(payload.character_id.as_str(), "character");
|
||||
let model = normalize_required_text(payload.image_model.as_str(), CHARACTER_VISUAL_MODEL);
|
||||
let size = normalize_required_text(payload.size.as_str(), "1024*1024");
|
||||
let candidate_count = payload.candidate_count.clamp(1, 4);
|
||||
|
||||
let created = create_visual_task(
|
||||
&state,
|
||||
&task_id,
|
||||
&owner_user_id,
|
||||
&character_id,
|
||||
&model,
|
||||
&prompt,
|
||||
)
|
||||
.map_err(|error| character_visual_error_response(&request_context, error))?;
|
||||
|
||||
let result = async {
|
||||
state
|
||||
.ai_task_service()
|
||||
.start_task(task_id.as_str(), current_utc_micros())
|
||||
.map_err(map_ai_task_error)?;
|
||||
state
|
||||
.ai_task_service()
|
||||
.start_stage(
|
||||
task_id.as_str(),
|
||||
AiTaskStageKind::PreparePrompt,
|
||||
current_utc_micros(),
|
||||
)
|
||||
.map_err(map_ai_task_error)?;
|
||||
state
|
||||
.ai_task_service()
|
||||
.complete_stage(AiStageCompletionInput {
|
||||
task_id: task_id.clone(),
|
||||
stage_kind: AiTaskStageKind::PreparePrompt,
|
||||
text_output: Some(prompt.clone()),
|
||||
structured_payload_json: Some(
|
||||
json!({
|
||||
"characterId": character_id,
|
||||
"sourceMode": payload.source_mode,
|
||||
"size": size,
|
||||
"referenceImageCount": payload.reference_image_data_urls.len(),
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
warning_messages: Vec::new(),
|
||||
completed_at_micros: current_utc_micros(),
|
||||
})
|
||||
.map_err(map_ai_task_error)?;
|
||||
|
||||
let visual_seed = generate_visual_seed_with_llm(&state, &prompt, &character_id).await;
|
||||
|
||||
state
|
||||
.ai_task_service()
|
||||
.start_stage(
|
||||
task_id.as_str(),
|
||||
AiTaskStageKind::RequestModel,
|
||||
current_utc_micros(),
|
||||
)
|
||||
.map_err(map_ai_task_error)?;
|
||||
state
|
||||
.ai_task_service()
|
||||
.complete_stage(AiStageCompletionInput {
|
||||
task_id: task_id.clone(),
|
||||
stage_kind: AiTaskStageKind::RequestModel,
|
||||
text_output: Some(visual_seed.clone()),
|
||||
structured_payload_json: None,
|
||||
warning_messages: Vec::new(),
|
||||
completed_at_micros: current_utc_micros(),
|
||||
})
|
||||
.map_err(map_ai_task_error)?;
|
||||
|
||||
let drafts = persist_visual_drafts(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
&character_id,
|
||||
&task_id,
|
||||
&visual_seed,
|
||||
&size,
|
||||
candidate_count,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let result_payload = json!({
|
||||
"drafts": drafts,
|
||||
"draftRelativeDir": format!(
|
||||
"generated-character-drafts/{}/visual/{}",
|
||||
sanitize_storage_segment(character_id.as_str(), "character"),
|
||||
task_id
|
||||
),
|
||||
});
|
||||
|
||||
state
|
||||
.ai_task_service()
|
||||
.start_stage(
|
||||
task_id.as_str(),
|
||||
AiTaskStageKind::NormalizeResult,
|
||||
current_utc_micros(),
|
||||
)
|
||||
.map_err(map_ai_task_error)?;
|
||||
state
|
||||
.ai_task_service()
|
||||
.complete_stage(AiStageCompletionInput {
|
||||
task_id: task_id.clone(),
|
||||
stage_kind: AiTaskStageKind::NormalizeResult,
|
||||
text_output: None,
|
||||
structured_payload_json: Some(result_payload.to_string()),
|
||||
warning_messages: Vec::new(),
|
||||
completed_at_micros: current_utc_micros(),
|
||||
})
|
||||
.map_err(map_ai_task_error)?;
|
||||
state
|
||||
.ai_task_service()
|
||||
.complete_stage(AiStageCompletionInput {
|
||||
task_id: task_id.clone(),
|
||||
stage_kind: AiTaskStageKind::PersistResult,
|
||||
text_output: Some("角色主形象候选草稿已写入 OSS。".to_string()),
|
||||
structured_payload_json: Some(result_payload.to_string()),
|
||||
warning_messages: Vec::new(),
|
||||
completed_at_micros: current_utc_micros(),
|
||||
})
|
||||
.map_err(map_ai_task_error)?;
|
||||
state
|
||||
.ai_task_service()
|
||||
.complete_task(task_id.as_str(), current_utc_micros())
|
||||
.map_err(map_ai_task_error)?;
|
||||
|
||||
Ok::<_, AppError>(drafts)
|
||||
}
|
||||
.await;
|
||||
|
||||
let drafts = match result {
|
||||
Ok(drafts) => drafts,
|
||||
Err(error) => {
|
||||
let _ = state.ai_task_service().fail_task(
|
||||
created.task_id.as_str(),
|
||||
error.message().to_string(),
|
||||
current_utc_micros(),
|
||||
);
|
||||
return Err(character_visual_error_response(&request_context, error));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
CharacterVisualGenerateResponse {
|
||||
ok: true,
|
||||
task_id,
|
||||
model,
|
||||
prompt,
|
||||
drafts,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_character_visual_job(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Path(task_id): Path<String>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let task = state
|
||||
.ai_task_service()
|
||||
.get_task(task_id.as_str())
|
||||
.map_err(map_ai_task_error)
|
||||
.map_err(|error| character_visual_error_response(&request_context, error))?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
build_character_visual_job_payload(task),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn publish_character_visual(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
payload: Result<Json<CharacterVisualPublishRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = payload.map_err(|error| {
|
||||
character_visual_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "character-visual",
|
||||
"message": error.body_text(),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
// 旧资产工坊接口没有显式 Bearer 头,Rust 兼容层先使用工具用户归属,避免破坏现有前端调用。
|
||||
let owner_user_id = "asset-tool".to_string();
|
||||
let character_id = normalize_required_text(payload.character_id.as_str(), "character");
|
||||
if payload.selected_preview_source.trim().is_empty() {
|
||||
return Err(character_visual_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "character-visual",
|
||||
"message": "selectedPreviewSource is required.",
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
let asset_id = format!("visual-{}", current_utc_millis());
|
||||
let published = persist_published_visual(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
&character_id,
|
||||
asset_id.as_str(),
|
||||
payload.selected_preview_source.as_str(),
|
||||
payload.prompt_text.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| character_visual_error_response(&request_context, error))?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
CharacterVisualPublishResponse {
|
||||
ok: true,
|
||||
asset_id,
|
||||
portrait_path: published,
|
||||
override_map: json!({}),
|
||||
save_message: if payload.update_character_override == Some(false) {
|
||||
"主形象已写入 OSS 并绑定当前角色,可直接写回当前自定义世界角色。".to_string()
|
||||
} else {
|
||||
"主形象已写入 OSS 并绑定当前角色;Rust 后端不再写本地角色覆盖文件。".to_string()
|
||||
},
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn create_visual_task(
|
||||
state: &AppState,
|
||||
task_id: &str,
|
||||
owner_user_id: &str,
|
||||
character_id: &str,
|
||||
model: &str,
|
||||
prompt: &str,
|
||||
) -> Result<AiTaskSnapshot, AppError> {
|
||||
state
|
||||
.ai_task_service()
|
||||
.create_task(AiTaskCreateInput {
|
||||
task_id: task_id.to_string(),
|
||||
task_kind: AiTaskKind::CustomWorldGeneration,
|
||||
owner_user_id: owner_user_id.to_string(),
|
||||
request_label: "生成角色主形象".to_string(),
|
||||
source_module: "assets.character_visual".to_string(),
|
||||
source_entity_id: Some(character_id.to_string()),
|
||||
request_payload_json: Some(
|
||||
json!({
|
||||
"characterId": character_id,
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
stages: AiTaskKind::CustomWorldGeneration.default_stage_blueprints(),
|
||||
created_at_micros: current_utc_micros(),
|
||||
})
|
||||
.map_err(map_ai_task_error)
|
||||
}
|
||||
|
||||
async fn generate_visual_seed_with_llm(
|
||||
state: &AppState,
|
||||
prompt: &str,
|
||||
character_id: &str,
|
||||
) -> String {
|
||||
let fallback = format!("{character_id}:{prompt}");
|
||||
let Some(llm_client) = state.llm_client() else {
|
||||
return fallback;
|
||||
};
|
||||
|
||||
let request = LlmTextRequest::new(vec![
|
||||
LlmMessage::system(
|
||||
"你是游戏角色主形象草稿描述器。只输出一句中文视觉摘要,不要输出 Markdown。",
|
||||
),
|
||||
LlmMessage::user(
|
||||
json!({
|
||||
"task": "summarize_character_visual_seed",
|
||||
"characterId": character_id,
|
||||
"prompt": prompt,
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
])
|
||||
.with_max_tokens(96);
|
||||
|
||||
llm_client
|
||||
.request_text(request)
|
||||
.await
|
||||
.ok()
|
||||
.map(|response| response.content.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(fallback)
|
||||
}
|
||||
|
||||
async fn persist_visual_drafts(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
character_id: &str,
|
||||
task_id: &str,
|
||||
visual_seed: &str,
|
||||
size: &str,
|
||||
candidate_count: u32,
|
||||
) -> Result<Vec<CharacterVisualDraftPayload>, AppError> {
|
||||
let mut drafts = Vec::with_capacity(candidate_count as usize);
|
||||
for index in 0..candidate_count {
|
||||
let file_name = format!("candidate-{:02}.svg", index + 1);
|
||||
let body =
|
||||
build_character_visual_svg(size, visual_seed, format!("候选 {}", index + 1).as_str())
|
||||
.into_bytes();
|
||||
let put_result = put_character_visual_object(
|
||||
state,
|
||||
LegacyAssetPrefix::CharacterDrafts,
|
||||
vec![
|
||||
sanitize_storage_segment(character_id, "character"),
|
||||
"visual".to_string(),
|
||||
task_id.to_string(),
|
||||
],
|
||||
file_name,
|
||||
"image/svg+xml".to_string(),
|
||||
body,
|
||||
build_asset_metadata(
|
||||
CHARACTER_VISUAL_ASSET_KIND,
|
||||
owner_user_id,
|
||||
CHARACTER_VISUAL_ENTITY_KIND,
|
||||
character_id,
|
||||
"draft",
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
|
||||
drafts.push(CharacterVisualDraftPayload {
|
||||
id: format!("candidate-{}", index + 1),
|
||||
label: format!("候选 {}", index + 1),
|
||||
image_src: put_result.legacy_public_path,
|
||||
width: parse_size(size).0,
|
||||
height: parse_size(size).1,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(drafts)
|
||||
}
|
||||
|
||||
async fn persist_published_visual(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
character_id: &str,
|
||||
asset_id: &str,
|
||||
selected_preview_source: &str,
|
||||
prompt_text: Option<&str>,
|
||||
) -> Result<String, AppError> {
|
||||
let oss_client = require_oss_client(state)?;
|
||||
let http_client = reqwest::Client::new();
|
||||
let source_object_key = resolve_object_key_from_legacy_path(selected_preview_source)?;
|
||||
let head = oss_client
|
||||
.head_object(
|
||||
&http_client,
|
||||
OssHeadObjectRequest {
|
||||
object_key: source_object_key.clone(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(map_character_visual_oss_error)?;
|
||||
let signed = oss_client
|
||||
.sign_get_object_url(OssSignedGetObjectUrlRequest {
|
||||
object_key: source_object_key,
|
||||
expire_seconds: Some(60),
|
||||
})
|
||||
.map_err(map_character_visual_oss_error)?;
|
||||
let source_body = http_client
|
||||
.get(signed.signed_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"message": format!("读取候选主形象失败:{error}"),
|
||||
}))
|
||||
})?
|
||||
.error_for_status()
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"message": format!("读取候选主形象失败:{error}"),
|
||||
}))
|
||||
})?
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"message": format!("读取候选主形象内容失败:{error}"),
|
||||
}))
|
||||
})?
|
||||
.to_vec();
|
||||
|
||||
let content_type = head
|
||||
.content_type
|
||||
.clone()
|
||||
.unwrap_or_else(|| "image/svg+xml".to_string());
|
||||
let file_name = match content_type.as_str() {
|
||||
"image/png" => "master.png",
|
||||
"image/jpeg" => "master.jpg",
|
||||
"image/webp" => "master.webp",
|
||||
_ => "master.svg",
|
||||
}
|
||||
.to_string();
|
||||
let put_result = put_character_visual_object(
|
||||
state,
|
||||
LegacyAssetPrefix::Characters,
|
||||
vec![
|
||||
sanitize_storage_segment(character_id, "character"),
|
||||
"visual".to_string(),
|
||||
asset_id.to_string(),
|
||||
],
|
||||
file_name,
|
||||
content_type.clone(),
|
||||
source_body,
|
||||
build_asset_metadata(
|
||||
CHARACTER_VISUAL_ASSET_KIND,
|
||||
owner_user_id,
|
||||
CHARACTER_VISUAL_ENTITY_KIND,
|
||||
character_id,
|
||||
CHARACTER_VISUAL_SLOT,
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
let confirmed = confirm_character_visual_asset_object(
|
||||
state,
|
||||
owner_user_id,
|
||||
character_id,
|
||||
asset_id,
|
||||
put_result.object_key.clone(),
|
||||
content_type,
|
||||
prompt_text.map(str::to_string),
|
||||
)
|
||||
.await?;
|
||||
bind_character_visual_asset(
|
||||
state,
|
||||
owner_user_id,
|
||||
character_id,
|
||||
confirmed.record.asset_object_id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(put_result.legacy_public_path)
|
||||
}
|
||||
|
||||
async fn put_character_visual_object(
|
||||
state: &AppState,
|
||||
prefix: LegacyAssetPrefix,
|
||||
path_segments: Vec<String>,
|
||||
file_name: String,
|
||||
content_type: String,
|
||||
body: Vec<u8>,
|
||||
metadata: BTreeMap<String, String>,
|
||||
) -> Result<platform_oss::OssPutObjectResponse, AppError> {
|
||||
let oss_client = require_oss_client(state)?;
|
||||
oss_client
|
||||
.put_object(
|
||||
&reqwest::Client::new(),
|
||||
OssPutObjectRequest {
|
||||
prefix,
|
||||
path_segments,
|
||||
file_name,
|
||||
content_type: Some(content_type),
|
||||
access: OssObjectAccess::Private,
|
||||
metadata,
|
||||
body,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(map_character_visual_oss_error)
|
||||
}
|
||||
|
||||
async fn confirm_character_visual_asset_object(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
character_id: &str,
|
||||
source_job_id: &str,
|
||||
object_key: String,
|
||||
content_type: String,
|
||||
prompt_text: Option<String>,
|
||||
) -> Result<module_assets::ConfirmAssetObjectResult, AppError> {
|
||||
let oss_client = require_oss_client(state)?;
|
||||
let head = oss_client
|
||||
.head_object(&reqwest::Client::new(), OssHeadObjectRequest { object_key })
|
||||
.await
|
||||
.map_err(map_character_visual_oss_error)?;
|
||||
let now_micros = current_utc_micros();
|
||||
let record = state
|
||||
.spacetime_client()
|
||||
.confirm_asset_object(
|
||||
build_asset_object_upsert_input(
|
||||
generate_asset_object_id(now_micros),
|
||||
head.bucket,
|
||||
head.object_key,
|
||||
AssetObjectAccessPolicy::Private,
|
||||
head.content_type.or(Some(content_type)),
|
||||
head.content_length,
|
||||
prompt_text.or(head.etag),
|
||||
CHARACTER_VISUAL_ASSET_KIND.to_string(),
|
||||
Some(source_job_id.to_string()),
|
||||
Some(owner_user_id.to_string()),
|
||||
None,
|
||||
Some(character_id.to_string()),
|
||||
now_micros,
|
||||
)
|
||||
.map_err(map_asset_object_prepare_error)?,
|
||||
)
|
||||
.await
|
||||
.map_err(map_character_visual_spacetime_error)?;
|
||||
let _ = state.ai_task_service().attach_result_reference(
|
||||
source_job_id,
|
||||
AiResultReferenceKind::AssetObject,
|
||||
record.asset_object_id.clone(),
|
||||
Some("角色主形象正式对象".to_string()),
|
||||
now_micros,
|
||||
);
|
||||
Ok(module_assets::ConfirmAssetObjectResult { record })
|
||||
}
|
||||
|
||||
async fn bind_character_visual_asset(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
character_id: &str,
|
||||
asset_object_id: String,
|
||||
) -> Result<(), AppError> {
|
||||
let now_micros = current_utc_micros();
|
||||
state
|
||||
.spacetime_client()
|
||||
.bind_asset_object_to_entity(
|
||||
build_asset_entity_binding_input(
|
||||
generate_asset_binding_id(now_micros),
|
||||
asset_object_id,
|
||||
CHARACTER_VISUAL_ENTITY_KIND.to_string(),
|
||||
character_id.to_string(),
|
||||
CHARACTER_VISUAL_SLOT.to_string(),
|
||||
CHARACTER_VISUAL_ASSET_KIND.to_string(),
|
||||
Some(owner_user_id.to_string()),
|
||||
None,
|
||||
now_micros,
|
||||
)
|
||||
.map_err(map_asset_binding_prepare_error)?,
|
||||
)
|
||||
.await
|
||||
.map_err(map_character_visual_spacetime_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_character_visual_job_payload(task: AiTaskSnapshot) -> CharacterAssetJobStatusPayload {
|
||||
let request_payload = task
|
||||
.request_payload_json
|
||||
.as_deref()
|
||||
.and_then(|value| serde_json::from_str::<Value>(value).ok())
|
||||
.unwrap_or_else(|| json!({}));
|
||||
let result = task
|
||||
.latest_structured_payload_json
|
||||
.as_deref()
|
||||
.and_then(|value| serde_json::from_str::<Value>(value).ok());
|
||||
|
||||
CharacterAssetJobStatusPayload {
|
||||
task_id: task.task_id,
|
||||
kind: "visual".to_string(),
|
||||
status: match task.status {
|
||||
AiTaskStatus::Pending => CharacterAssetJobStatusText::Queued,
|
||||
AiTaskStatus::Running => CharacterAssetJobStatusText::Running,
|
||||
AiTaskStatus::Completed => CharacterAssetJobStatusText::Completed,
|
||||
AiTaskStatus::Failed | AiTaskStatus::Cancelled => CharacterAssetJobStatusText::Failed,
|
||||
},
|
||||
character_id: request_payload
|
||||
.get("characterId")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
animation: None,
|
||||
strategy: None,
|
||||
model: request_payload
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(CHARACTER_VISUAL_MODEL)
|
||||
.to_string(),
|
||||
prompt: request_payload
|
||||
.get("prompt")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
created_at: format_utc_micros(task.created_at_micros),
|
||||
updated_at: format_utc_micros(task.updated_at_micros),
|
||||
result,
|
||||
error_message: task.failure_message,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_character_visual_prompt(prompt_text: &str, character_brief_text: Option<&str>) -> String {
|
||||
let merged = [character_brief_text.unwrap_or_default(), prompt_text]
|
||||
.into_iter()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
format!(
|
||||
"{}\n单人全身,右向斜侧身,3 到 4 头身,像素动作角色,纯绿色背景,服装完整,轮廓清晰,不要复杂背景。",
|
||||
if merged.is_empty() {
|
||||
"自定义世界角色,服装完整,姿态自然。"
|
||||
} else {
|
||||
merged.as_str()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fn build_character_visual_svg(size: &str, label: &str, candidate_label: &str) -> String {
|
||||
let (width, height) = parse_size(size);
|
||||
format!(
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">
|
||||
<rect width="100%" height="100%" fill="#00ff00"/>
|
||||
<ellipse cx="{shadow_x}" cy="{shadow_y}" rx="{shadow_rx}" ry="{shadow_ry}" fill="rgba(0,0,0,0.18)"/>
|
||||
<path d="M {body_x} {body_y} C {body_c1x} {body_c1y}, {body_c2x} {body_c2y}, {body_x2} {body_y2} L {leg_x} {leg_y} L {leg2_x} {leg_y} Z" fill="#1f2937"/>
|
||||
<circle cx="{head_x}" cy="{head_y}" r="{head_r}" fill="#f8d7b0"/>
|
||||
<path d="M {weapon_x} {weapon_y} L {weapon_x2} {weapon_y2}" stroke="#e5e7eb" stroke-width="{weapon_w}" stroke-linecap="round"/>
|
||||
<text x="50%" y="{text_y}" text-anchor="middle" fill="#0f172a" font-size="{font_main}" font-family="Microsoft YaHei, PingFang SC, sans-serif">{title}</text>
|
||||
<text x="50%" y="{sub_y}" text-anchor="middle" fill="#0f172a" font-size="{font_sub}" font-family="Microsoft YaHei, PingFang SC, sans-serif">{candidate}</text>
|
||||
</svg>"##,
|
||||
width = width,
|
||||
height = height,
|
||||
shadow_x = width / 2,
|
||||
shadow_y = height * 5 / 6,
|
||||
shadow_rx = width / 5,
|
||||
shadow_ry = height / 28,
|
||||
body_x = width * 45 / 100,
|
||||
body_y = height * 34 / 100,
|
||||
body_c1x = width * 34 / 100,
|
||||
body_c1y = height * 50 / 100,
|
||||
body_c2x = width * 43 / 100,
|
||||
body_c2y = height * 72 / 100,
|
||||
body_x2 = width * 56 / 100,
|
||||
body_y2 = height * 72 / 100,
|
||||
leg_x = width * 48 / 100,
|
||||
leg_y = height * 84 / 100,
|
||||
leg2_x = width * 62 / 100,
|
||||
head_x = width * 53 / 100,
|
||||
head_y = height * 25 / 100,
|
||||
head_r = (width.min(height) / 12).max(18),
|
||||
weapon_x = width * 57 / 100,
|
||||
weapon_y = height * 42 / 100,
|
||||
weapon_x2 = width * 76 / 100,
|
||||
weapon_y2 = height * 34 / 100,
|
||||
weapon_w = (width.min(height) / 90).max(4),
|
||||
text_y = height * 91 / 100,
|
||||
sub_y = height * 96 / 100,
|
||||
font_main = (width.min(height) / 28).max(14),
|
||||
font_sub = (width.min(height) / 36).max(11),
|
||||
title = escape_svg_text(label),
|
||||
candidate = escape_svg_text(candidate_label),
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_object_key_from_legacy_path(value: &str) -> Result<String, AppError> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "character-visual",
|
||||
"message": "selectedPreviewSource is required.",
|
||||
})),
|
||||
);
|
||||
}
|
||||
if trimmed.starts_with("data:") {
|
||||
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "character-visual",
|
||||
"message": "Rust 版 publish 当前要求 selectedPreviewSource 为已写入 OSS 的 /generated-* 路径。",
|
||||
})));
|
||||
}
|
||||
Ok(trimmed.trim_start_matches('/').to_string())
|
||||
}
|
||||
|
||||
fn build_asset_metadata(
|
||||
asset_kind: &str,
|
||||
owner_user_id: &str,
|
||||
entity_kind: &str,
|
||||
entity_id: &str,
|
||||
slot: &str,
|
||||
) -> BTreeMap<String, String> {
|
||||
BTreeMap::from([
|
||||
("asset_kind".to_string(), asset_kind.to_string()),
|
||||
("owner_user_id".to_string(), owner_user_id.to_string()),
|
||||
("entity_kind".to_string(), entity_kind.to_string()),
|
||||
("entity_id".to_string(), entity_id.to_string()),
|
||||
("slot".to_string(), slot.to_string()),
|
||||
])
|
||||
}
|
||||
|
||||
fn require_oss_client(state: &AppState) -> Result<&platform_oss::OssClient, AppError> {
|
||||
state.oss_client().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"reason": "OSS 未完成环境变量配置",
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_required_text(value: &str, fallback: &str) -> String {
|
||||
value
|
||||
.trim()
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.chars()
|
||||
.take(180)
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_string()
|
||||
.if_empty_then(fallback)
|
||||
}
|
||||
|
||||
fn sanitize_storage_segment(value: &str, fallback: &str) -> String {
|
||||
let normalized = value
|
||||
.trim()
|
||||
.chars()
|
||||
.map(|character| match character {
|
||||
'a'..='z' | '0'..='9' | '-' | '_' => character,
|
||||
'A'..='Z' => character.to_ascii_lowercase(),
|
||||
_ => '-',
|
||||
})
|
||||
.collect::<String>();
|
||||
let normalized = collapse_dashes(&normalized);
|
||||
if normalized.is_empty() {
|
||||
fallback.to_string()
|
||||
} else {
|
||||
normalized
|
||||
}
|
||||
}
|
||||
|
||||
fn collapse_dashes(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.fold(
|
||||
(String::new(), false),
|
||||
|(mut output, last_is_dash), character| {
|
||||
let is_dash = character == '-';
|
||||
if is_dash && last_is_dash {
|
||||
return (output, true);
|
||||
}
|
||||
output.push(character);
|
||||
(output, is_dash)
|
||||
},
|
||||
)
|
||||
.0
|
||||
.trim_matches('-')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn parse_size(size: &str) -> (u32, u32) {
|
||||
let mut parts = size.split('*');
|
||||
let width = parts
|
||||
.next()
|
||||
.and_then(|value| value.trim().parse::<u32>().ok())
|
||||
.filter(|value| *value > 0)
|
||||
.unwrap_or(1024);
|
||||
let height = parts
|
||||
.next()
|
||||
.and_then(|value| value.trim().parse::<u32>().ok())
|
||||
.filter(|value| *value > 0)
|
||||
.unwrap_or(1024);
|
||||
(width, height)
|
||||
}
|
||||
|
||||
fn escape_svg_text(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
fn format_utc_micros(micros: i64) -> String {
|
||||
module_runtime::format_utc_micros(micros)
|
||||
}
|
||||
|
||||
fn current_utc_millis() -> i64 {
|
||||
current_utc_micros() / 1_000
|
||||
}
|
||||
|
||||
fn current_utc_micros() -> i64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
let duration = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system clock should be after unix epoch");
|
||||
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
|
||||
}
|
||||
|
||||
fn map_ai_task_error(error: AiTaskServiceError) -> AppError {
|
||||
let status = match error {
|
||||
AiTaskServiceError::TaskNotFound => StatusCode::NOT_FOUND,
|
||||
AiTaskServiceError::TaskAlreadyExists => StatusCode::CONFLICT,
|
||||
AiTaskServiceError::Field(_) | AiTaskServiceError::StageNotFound => StatusCode::BAD_REQUEST,
|
||||
AiTaskServiceError::Store(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
AppError::from_status(status).with_details(json!({
|
||||
"provider": "ai-task",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn map_asset_object_prepare_error(error: AssetObjectFieldError) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "asset-object",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn map_asset_binding_prepare_error(error: AssetObjectFieldError) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "asset-entity-binding",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn map_character_visual_spacetime_error(error: SpacetimeClientError) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "spacetimedb",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn map_character_visual_oss_error(error: platform_oss::OssError) -> AppError {
|
||||
let status = match error {
|
||||
platform_oss::OssError::InvalidConfig(_) | platform_oss::OssError::InvalidRequest(_) => {
|
||||
StatusCode::BAD_REQUEST
|
||||
}
|
||||
platform_oss::OssError::ObjectNotFound(_) => StatusCode::NOT_FOUND,
|
||||
platform_oss::OssError::Request(_)
|
||||
| platform_oss::OssError::SerializePolicy(_)
|
||||
| platform_oss::OssError::Sign(_) => StatusCode::BAD_GATEWAY,
|
||||
};
|
||||
AppError::from_status(status).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn character_visual_error_response(request_context: &RequestContext, error: AppError) -> Response {
|
||||
error.into_response_with_context(Some(request_context))
|
||||
}
|
||||
|
||||
trait EmptyFallback {
|
||||
fn if_empty_then(self, fallback: &str) -> String;
|
||||
}
|
||||
|
||||
impl EmptyFallback for String {
|
||||
fn if_empty_then(self, fallback: &str) -> String {
|
||||
if self.is_empty() {
|
||||
fallback.to_string()
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn build_character_visual_prompt_keeps_generation_constraints() {
|
||||
let prompt = build_character_visual_prompt("潮雾港向导", Some("旧港守望者"));
|
||||
|
||||
assert!(prompt.contains("潮雾港向导"));
|
||||
assert!(prompt.contains("右向斜侧身"));
|
||||
assert!(prompt.contains("纯绿色背景"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_storage_segment_keeps_legacy_safe_shape() {
|
||||
assert_eq!(
|
||||
sanitize_storage_segment("Harbor Guide/潮雾", "character"),
|
||||
"harbor-guide"
|
||||
);
|
||||
}
|
||||
}
|
||||
209
server-rs/crates/api-server/src/legacy_generated_assets.rs
Normal file
209
server-rs/crates/api-server/src/legacy_generated_assets.rs
Normal file
@@ -0,0 +1,209 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Path, State},
|
||||
http::{HeaderName, HeaderValue, StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use platform_oss::{LegacyAssetPrefix, OssSignedGetObjectUrlRequest};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{http_error::AppError, state::AppState};
|
||||
|
||||
const CACHE_CONTROL_VALUE: &str = "private, max-age=60";
|
||||
const ASSET_OBJECT_KEY_HEADER: &str = "x-genarrative-asset-object-key";
|
||||
|
||||
pub async fn proxy_generated_character_drafts(
|
||||
State(state): State<AppState>,
|
||||
Path(path): Path<String>,
|
||||
) -> Response {
|
||||
proxy_legacy_generated_asset(state, LegacyAssetPrefix::CharacterDrafts, path).await
|
||||
}
|
||||
|
||||
pub async fn proxy_generated_characters(
|
||||
State(state): State<AppState>,
|
||||
Path(path): Path<String>,
|
||||
) -> Response {
|
||||
proxy_legacy_generated_asset(state, LegacyAssetPrefix::Characters, path).await
|
||||
}
|
||||
|
||||
pub async fn proxy_generated_animations(
|
||||
State(state): State<AppState>,
|
||||
Path(path): Path<String>,
|
||||
) -> Response {
|
||||
proxy_legacy_generated_asset(state, LegacyAssetPrefix::Animations, path).await
|
||||
}
|
||||
|
||||
pub async fn proxy_generated_custom_world_scenes(
|
||||
State(state): State<AppState>,
|
||||
Path(path): Path<String>,
|
||||
) -> Response {
|
||||
proxy_legacy_generated_asset(state, LegacyAssetPrefix::CustomWorldScenes, path).await
|
||||
}
|
||||
|
||||
pub async fn proxy_generated_custom_world_covers(
|
||||
State(state): State<AppState>,
|
||||
Path(path): Path<String>,
|
||||
) -> Response {
|
||||
proxy_legacy_generated_asset(state, LegacyAssetPrefix::CustomWorldCovers, path).await
|
||||
}
|
||||
|
||||
pub async fn proxy_generated_qwen_sprites(
|
||||
State(state): State<AppState>,
|
||||
Path(path): Path<String>,
|
||||
) -> Response {
|
||||
proxy_legacy_generated_asset(state, LegacyAssetPrefix::QwenSprites, path).await
|
||||
}
|
||||
|
||||
async fn proxy_legacy_generated_asset(
|
||||
state: AppState,
|
||||
prefix: LegacyAssetPrefix,
|
||||
path: String,
|
||||
) -> Response {
|
||||
match read_legacy_generated_asset(&state, prefix, path).await {
|
||||
Ok(response) => response,
|
||||
Err(error) => error.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_legacy_generated_asset(
|
||||
state: &AppState,
|
||||
prefix: LegacyAssetPrefix,
|
||||
path: String,
|
||||
) -> Result<Response, AppError> {
|
||||
let oss_client = state.oss_client().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"reason": "OSS 未完成环境变量配置",
|
||||
}))
|
||||
})?;
|
||||
let object_key = build_generated_object_key(prefix, path.as_str())?;
|
||||
let signed = oss_client
|
||||
.sign_get_object_url(OssSignedGetObjectUrlRequest {
|
||||
object_key: object_key.clone(),
|
||||
expire_seconds: Some(60),
|
||||
})
|
||||
.map_err(map_legacy_generated_oss_error)?;
|
||||
let upstream_response = reqwest::Client::new()
|
||||
.get(signed.signed_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"message": format!("读取 OSS 旧 generated 资源失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
|
||||
if upstream_response.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"objectKey": object_key,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
let status = upstream_response.status();
|
||||
let content_type = upstream_response
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.cloned();
|
||||
let bytes = upstream_response
|
||||
.error_for_status()
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"message": format!("读取 OSS 旧 generated 资源失败:{error}"),
|
||||
}))
|
||||
})?
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"message": format!("读取 OSS 旧 generated 资源内容失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
let mut response = Response::builder()
|
||||
.status(status)
|
||||
.header(header::CACHE_CONTROL, CACHE_CONTROL_VALUE)
|
||||
.header(
|
||||
HeaderName::from_static(ASSET_OBJECT_KEY_HEADER),
|
||||
HeaderValue::from_str(object_key.as_str()).map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": "legacy-generated-assets",
|
||||
"message": format!("构造资源响应头失败:{error}"),
|
||||
}))
|
||||
})?,
|
||||
);
|
||||
if let Some(content_type) = content_type {
|
||||
response = response.header(header::CONTENT_TYPE, content_type);
|
||||
}
|
||||
|
||||
response.body(Body::from(bytes)).map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": "legacy-generated-assets",
|
||||
"message": format!("构造资源响应失败:{error}"),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
fn build_generated_object_key(prefix: LegacyAssetPrefix, path: &str) -> Result<String, AppError> {
|
||||
let path = path.trim().trim_matches('/');
|
||||
if path.is_empty() || path.split('/').any(is_invalid_path_segment) {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "legacy-generated-assets",
|
||||
"message": "generated 资源路径不合法。",
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(format!("{}/{}", prefix.as_str(), path))
|
||||
}
|
||||
|
||||
fn is_invalid_path_segment(segment: &str) -> bool {
|
||||
segment.is_empty() || segment == "." || segment == ".." || segment.contains('\\')
|
||||
}
|
||||
|
||||
fn map_legacy_generated_oss_error(error: platform_oss::OssError) -> AppError {
|
||||
let status = match error {
|
||||
platform_oss::OssError::InvalidConfig(_) | platform_oss::OssError::InvalidRequest(_) => {
|
||||
StatusCode::BAD_REQUEST
|
||||
}
|
||||
platform_oss::OssError::ObjectNotFound(_) => StatusCode::NOT_FOUND,
|
||||
platform_oss::OssError::Request(_)
|
||||
| platform_oss::OssError::SerializePolicy(_)
|
||||
| platform_oss::OssError::Sign(_) => StatusCode::BAD_GATEWAY,
|
||||
};
|
||||
AppError::from_status(status).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn build_generated_object_key_keeps_supported_prefix() {
|
||||
let object_key = build_generated_object_key(
|
||||
LegacyAssetPrefix::Animations,
|
||||
"hero/animation-set-1/idle/frame01.png",
|
||||
)
|
||||
.expect("object key should build");
|
||||
|
||||
assert_eq!(
|
||||
object_key,
|
||||
"generated-animations/hero/animation-set-1/idle/frame01.png"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_generated_object_key_rejects_parent_segment() {
|
||||
assert!(
|
||||
build_generated_object_key(LegacyAssetPrefix::Characters, "../secret.png").is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,15 @@ mod auth;
|
||||
mod auth_me;
|
||||
mod auth_session;
|
||||
mod auth_sessions;
|
||||
mod character_animation_assets;
|
||||
mod character_visual_assets;
|
||||
mod config;
|
||||
mod custom_world;
|
||||
mod custom_world_ai;
|
||||
mod error_middleware;
|
||||
mod health;
|
||||
mod http_error;
|
||||
mod legacy_generated_assets;
|
||||
mod llm;
|
||||
mod login_options;
|
||||
mod logout;
|
||||
|
||||
@@ -51,6 +51,28 @@
|
||||
5. `story-sessions/begin`
|
||||
6. `story-sessions/continue`
|
||||
|
||||
当前阶段新增 Stage6 `character visual` 兼容 DTO:
|
||||
|
||||
1. `assets/character-visual/generate`
|
||||
2. `assets/character-visual/jobs/:taskId`
|
||||
3. `assets/character-visual/publish`
|
||||
|
||||
当前阶段新增 Stage7 `character animation` 模板与导入兼容 DTO:
|
||||
|
||||
1. `assets/character-animation/templates`
|
||||
2. `assets/character-animation/import-video`
|
||||
|
||||
当前阶段新增 Stage8 `character workflow cache` 第一批兼容 DTO:
|
||||
|
||||
1. `assets/character-workflow-cache`
|
||||
2. `assets/character-workflow-cache/:characterId`
|
||||
|
||||
当前阶段新增 Stage9 `character animation` 主链兼容 DTO:
|
||||
|
||||
1. `assets/character-animation/generate`
|
||||
2. `assets/character-animation/jobs/:taskId`
|
||||
3. `assets/character-animation/publish`
|
||||
|
||||
当前阶段新增 Stage5 `runtime story` 兼容桥 DTO 基线:
|
||||
|
||||
1. `runtime/story/state/resolve` 请求 DTO
|
||||
|
||||
@@ -4,6 +4,7 @@ use platform_oss::{
|
||||
OssObjectAccess, OssPostObjectFormFields, OssPostObjectResponse, OssSignedGetObjectUrlResponse,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -83,6 +84,289 @@ pub struct BindAssetObjectRequest {
|
||||
pub profile_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum CharacterVisualSourceMode {
|
||||
TextToImage,
|
||||
ImageToImage,
|
||||
Upload,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterVisualGenerateRequest {
|
||||
pub character_id: String,
|
||||
pub source_mode: CharacterVisualSourceMode,
|
||||
pub prompt_text: String,
|
||||
#[serde(default)]
|
||||
pub character_brief_text: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reference_image_data_urls: Vec<String>,
|
||||
pub candidate_count: u32,
|
||||
pub image_model: String,
|
||||
pub size: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterVisualDraftPayload {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub image_src: String,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterVisualGenerateResponse {
|
||||
pub ok: bool,
|
||||
pub task_id: String,
|
||||
pub model: String,
|
||||
pub prompt: String,
|
||||
pub drafts: Vec<CharacterVisualDraftPayload>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CharacterAssetJobStatusText {
|
||||
Queued,
|
||||
Running,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterAssetJobStatusPayload {
|
||||
pub task_id: String,
|
||||
pub kind: String,
|
||||
pub status: CharacterAssetJobStatusText,
|
||||
pub character_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub animation: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub strategy: Option<String>,
|
||||
pub model: String,
|
||||
pub prompt: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub result: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterVisualPublishRequest {
|
||||
pub character_id: String,
|
||||
pub source_mode: CharacterVisualSourceMode,
|
||||
#[serde(default)]
|
||||
pub prompt_text: Option<String>,
|
||||
pub selected_preview_source: String,
|
||||
#[serde(default)]
|
||||
pub preview_sources: Vec<String>,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
#[serde(default)]
|
||||
pub update_character_override: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterVisualPublishResponse {
|
||||
pub ok: bool,
|
||||
pub asset_id: String,
|
||||
pub portrait_path: String,
|
||||
pub override_map: Value,
|
||||
pub save_message: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterAnimationTemplatePayload {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub animation: String,
|
||||
pub prompt_suffix: String,
|
||||
pub notes: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterAnimationTemplatesResponse {
|
||||
pub ok: bool,
|
||||
pub templates: Vec<CharacterAnimationTemplatePayload>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterAnimationImportVideoRequest {
|
||||
pub character_id: String,
|
||||
pub animation: String,
|
||||
pub video_source: String,
|
||||
#[serde(default)]
|
||||
pub source_label: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterAnimationImportVideoResponse {
|
||||
pub ok: bool,
|
||||
pub imported_video_path: String,
|
||||
pub draft_id: String,
|
||||
pub save_message: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum CharacterAnimationStrategy {
|
||||
ImageSequence,
|
||||
ImageToVideo,
|
||||
MotionTransfer,
|
||||
ReferenceToVideo,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterAnimationGenerateRequest {
|
||||
pub character_id: String,
|
||||
pub strategy: CharacterAnimationStrategy,
|
||||
pub animation: String,
|
||||
pub prompt_text: String,
|
||||
#[serde(default)]
|
||||
pub character_brief_text: Option<String>,
|
||||
#[serde(default)]
|
||||
pub action_template_id: Option<String>,
|
||||
pub visual_source: String,
|
||||
#[serde(default)]
|
||||
pub reference_image_data_urls: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub reference_video_data_urls: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub last_frame_image_data_url: Option<String>,
|
||||
pub frame_count: u32,
|
||||
pub fps: u32,
|
||||
pub duration_seconds: u32,
|
||||
#[serde(rename = "loop")]
|
||||
pub loop_: bool,
|
||||
pub use_chroma_key: bool,
|
||||
pub resolution: String,
|
||||
pub ratio: String,
|
||||
pub image_sequence_model: String,
|
||||
pub video_model: String,
|
||||
pub reference_video_model: String,
|
||||
pub motion_transfer_model: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterAnimationGenerateResponse {
|
||||
pub ok: bool,
|
||||
pub task_id: String,
|
||||
pub strategy: CharacterAnimationStrategy,
|
||||
pub model: String,
|
||||
pub prompt: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub image_sources: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub preview_video_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterAnimationDraftPayload {
|
||||
pub frames_data_urls: Vec<String>,
|
||||
pub fps: u32,
|
||||
#[serde(rename = "loop")]
|
||||
pub loop_: bool,
|
||||
pub frame_width: u32,
|
||||
pub frame_height: u32,
|
||||
#[serde(default)]
|
||||
pub preview_video_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterAnimationPublishRequest {
|
||||
pub character_id: String,
|
||||
pub visual_asset_id: String,
|
||||
pub animations: BTreeMap<String, CharacterAnimationDraftPayload>,
|
||||
#[serde(default)]
|
||||
pub update_character_override: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterAnimationPublishResponse {
|
||||
pub ok: bool,
|
||||
pub animation_set_id: String,
|
||||
pub override_map: Value,
|
||||
pub animation_map: Value,
|
||||
pub save_message: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterWorkflowCachePayload {
|
||||
pub character_id: String,
|
||||
pub visual_prompt_text: String,
|
||||
pub animation_prompt_text: String,
|
||||
pub visual_drafts: Vec<CharacterVisualDraftPayload>,
|
||||
pub selected_visual_draft_id: String,
|
||||
pub selected_animation: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub image_src: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub generated_visual_asset_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub generated_animation_set_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub animation_map: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterWorkflowCacheSaveRequest {
|
||||
pub character_id: String,
|
||||
#[serde(default)]
|
||||
pub visual_prompt_text: Option<String>,
|
||||
#[serde(default)]
|
||||
pub animation_prompt_text: Option<String>,
|
||||
#[serde(default)]
|
||||
pub visual_drafts: Vec<CharacterVisualDraftPayload>,
|
||||
#[serde(default)]
|
||||
pub selected_visual_draft_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub selected_animation: Option<String>,
|
||||
#[serde(default)]
|
||||
pub image_src: Option<String>,
|
||||
#[serde(default)]
|
||||
pub generated_visual_asset_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub generated_animation_set_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub animation_map: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterWorkflowCacheGetResponse {
|
||||
pub ok: bool,
|
||||
pub cache: Option<CharacterWorkflowCachePayload>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterWorkflowCacheSaveResponse {
|
||||
pub ok: bool,
|
||||
pub cache: CharacterWorkflowCachePayload,
|
||||
pub save_message: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateDirectUploadTicketResponse {
|
||||
@@ -358,4 +642,177 @@ mod tests {
|
||||
assert_eq!(payload["assetObject"]["accessPolicy"], json!("private"));
|
||||
assert_eq!(payload["assetObject"]["contentLength"], json!(1024));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_visual_source_mode_uses_legacy_kebab_case() {
|
||||
let payload = serde_json::to_value(CharacterVisualSourceMode::ImageToImage)
|
||||
.expect("source mode should serialize");
|
||||
|
||||
assert_eq!(payload, json!("image-to-image"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_visual_generate_response_keeps_legacy_shape() {
|
||||
let payload = serde_json::to_value(CharacterVisualGenerateResponse {
|
||||
ok: true,
|
||||
task_id: "visual_1".to_string(),
|
||||
model: "rust-svg-character-visual".to_string(),
|
||||
prompt: "角色提示词".to_string(),
|
||||
drafts: vec![CharacterVisualDraftPayload {
|
||||
id: "candidate-1".to_string(),
|
||||
label: "候选 1".to_string(),
|
||||
image_src: "/generated-character-drafts/hero/visual/visual_1/candidate-01.svg"
|
||||
.to_string(),
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
}],
|
||||
})
|
||||
.expect("response should serialize");
|
||||
|
||||
assert_eq!(payload["ok"], json!(true));
|
||||
assert_eq!(payload["taskId"], json!("visual_1"));
|
||||
assert_eq!(
|
||||
payload["drafts"][0]["imageSrc"],
|
||||
json!("/generated-character-drafts/hero/visual/visual_1/candidate-01.svg")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_animation_templates_response_keeps_legacy_shape() {
|
||||
let payload = serde_json::to_value(CharacterAnimationTemplatesResponse {
|
||||
ok: true,
|
||||
templates: vec![CharacterAnimationTemplatePayload {
|
||||
id: "idle_loop".to_string(),
|
||||
label: "待机循环".to_string(),
|
||||
animation: "idle".to_string(),
|
||||
prompt_suffix: "保持呼吸感。".to_string(),
|
||||
notes: "默认待机模板。".to_string(),
|
||||
}],
|
||||
})
|
||||
.expect("response should serialize");
|
||||
|
||||
assert_eq!(payload["ok"], json!(true));
|
||||
assert_eq!(payload["templates"][0]["id"], json!("idle_loop"));
|
||||
assert_eq!(
|
||||
payload["templates"][0]["promptSuffix"],
|
||||
json!("保持呼吸感。")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_animation_import_video_response_keeps_legacy_shape() {
|
||||
let payload =
|
||||
serde_json::to_value(CharacterAnimationImportVideoResponse {
|
||||
ok: true,
|
||||
imported_video_path:
|
||||
"/generated-character-drafts/hero/animation/idle/import-1/reference.mp4"
|
||||
.to_string(),
|
||||
draft_id: "animation-import-1".to_string(),
|
||||
save_message: "参考视频已导入 OSS 草稿区。".to_string(),
|
||||
})
|
||||
.expect("response should serialize");
|
||||
|
||||
assert_eq!(
|
||||
payload["importedVideoPath"],
|
||||
json!("/generated-character-drafts/hero/animation/idle/import-1/reference.mp4")
|
||||
);
|
||||
assert_eq!(payload["draftId"], json!("animation-import-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_workflow_cache_response_keeps_legacy_shape() {
|
||||
let payload = serde_json::to_value(CharacterWorkflowCacheSaveResponse {
|
||||
ok: true,
|
||||
cache: CharacterWorkflowCachePayload {
|
||||
character_id: "hero".to_string(),
|
||||
visual_prompt_text: "主形象".to_string(),
|
||||
animation_prompt_text: "待机".to_string(),
|
||||
visual_drafts: vec![CharacterVisualDraftPayload {
|
||||
id: "draft-1".to_string(),
|
||||
label: "候选 1".to_string(),
|
||||
image_src: "/generated-character-drafts/hero/visual/job/candidate.svg"
|
||||
.to_string(),
|
||||
width: 1024,
|
||||
height: 1536,
|
||||
}],
|
||||
selected_visual_draft_id: "draft-1".to_string(),
|
||||
selected_animation: "idle".to_string(),
|
||||
image_src: Some("/generated-characters/hero/master.png".to_string()),
|
||||
generated_visual_asset_id: None,
|
||||
generated_animation_set_id: None,
|
||||
animation_map: Some(json!({ "idle": { "frames": 4 } })),
|
||||
updated_at: Some("2026-04-22T12:00:00Z".to_string()),
|
||||
},
|
||||
save_message: "角色形象生成缓存已更新。".to_string(),
|
||||
})
|
||||
.expect("response should serialize");
|
||||
|
||||
assert_eq!(payload["ok"], json!(true));
|
||||
assert_eq!(payload["cache"]["characterId"], json!("hero"));
|
||||
assert_eq!(
|
||||
payload["cache"]["visualDrafts"][0]["imageSrc"],
|
||||
json!("/generated-character-drafts/hero/visual/job/candidate.svg")
|
||||
);
|
||||
assert_eq!(payload["cache"]["animationMap"]["idle"]["frames"], json!(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_animation_strategy_uses_legacy_kebab_case() {
|
||||
let payload = serde_json::to_value(CharacterAnimationStrategy::MotionTransfer)
|
||||
.expect("strategy should serialize");
|
||||
|
||||
assert_eq!(payload, json!("motion-transfer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_animation_generate_response_keeps_image_sequence_shape() {
|
||||
let payload = serde_json::to_value(CharacterAnimationGenerateResponse {
|
||||
ok: true,
|
||||
task_id: "animation_1".to_string(),
|
||||
strategy: CharacterAnimationStrategy::ImageSequence,
|
||||
model: "rust-svg-animation-sequence".to_string(),
|
||||
prompt: "待机动作".to_string(),
|
||||
image_sources: vec![
|
||||
"/generated-character-drafts/hero/animation/idle/job/frame-01.svg".to_string(),
|
||||
],
|
||||
preview_video_path: None,
|
||||
})
|
||||
.expect("response should serialize");
|
||||
|
||||
assert_eq!(payload["ok"], json!(true));
|
||||
assert_eq!(payload["taskId"], json!("animation_1"));
|
||||
assert_eq!(payload["strategy"], json!("image-sequence"));
|
||||
assert_eq!(
|
||||
payload["imageSources"][0],
|
||||
json!("/generated-character-drafts/hero/animation/idle/job/frame-01.svg")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_animation_publish_response_keeps_legacy_shape() {
|
||||
let payload = serde_json::to_value(CharacterAnimationPublishResponse {
|
||||
ok: true,
|
||||
animation_set_id: "animation-set-1".to_string(),
|
||||
override_map: json!({}),
|
||||
animation_map: json!({
|
||||
"idle": {
|
||||
"folder": "idle",
|
||||
"prefix": "frame",
|
||||
"frames": 2,
|
||||
"startFrame": 1,
|
||||
"extension": "svg",
|
||||
"basePath": "/generated-animations/hero/animation-set-1/idle",
|
||||
"frameWidth": 192,
|
||||
"frameHeight": 256,
|
||||
"fps": 8,
|
||||
"loop": true
|
||||
}
|
||||
}),
|
||||
save_message: "基础动作资源已写入 OSS 并绑定当前角色。".to_string(),
|
||||
})
|
||||
.expect("response should serialize");
|
||||
|
||||
assert_eq!(payload["animationSetId"], json!("animation-set-1"));
|
||||
assert_eq!(payload["animationMap"]["idle"]["frames"], json!(2));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user