新增编辑器生成规范、生成角色形象、生成图标素材等功能
新增编辑器生成规范、生成角色形象、生成图标素材等功能
This commit is contained in:
@@ -41,6 +41,8 @@ use shared_contracts::assets::{
|
||||
CharacterRoleAssetWorkflowResolveRequest, CharacterRoleAssetWorkflowResponse,
|
||||
CharacterVisualDraftPayload, CharacterWorkflowCacheGetResponse, CharacterWorkflowCachePayload,
|
||||
CharacterWorkflowCacheSaveRequest, CharacterWorkflowCacheSaveResponse,
|
||||
EditorCharacterAnimationFramePayload, EditorCharacterAnimationGenerateRequest,
|
||||
EditorCharacterAnimationGenerateResponse,
|
||||
};
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
|
||||
@@ -82,6 +84,9 @@ const FIXED_ARK_CHARACTER_VIDEO_RESOLUTION: &str = "480p";
|
||||
const FIXED_ARK_CHARACTER_VIDEO_RATIO: &str = "1:1";
|
||||
const FIXED_ARK_CHARACTER_VIDEO_DURATION_SECONDS: u32 = 4;
|
||||
const ARK_VIDEO_TASK_POLL_INTERVAL_MS: u64 = 5_000;
|
||||
const EDITOR_CHARACTER_ANIMATION_MODEL: &str = "seedance2.0";
|
||||
const EDITOR_CHARACTER_ANIMATION_ASSET_KIND: &str = "editor_character_animation";
|
||||
const EDITOR_CHARACTER_ANIMATION_PROMPT_PREFIX: &str = "生成游戏角色动画,参考图作为首帧和尾帧,画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。背景固定为纯绿色绿幕,只作为抠像底色,禁止出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。\n动作描述:";
|
||||
|
||||
const BUILT_IN_MOTION_TEMPLATES: [MotionTemplate; 4] = [
|
||||
MotionTemplate {
|
||||
@@ -489,6 +494,87 @@ pub async fn generate_character_animation(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn generate_editor_character_animation(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
payload: Result<Json<EditorCharacterAnimationGenerateRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = payload.map_err(|error| {
|
||||
character_animation_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "editor-character-animation",
|
||||
"message": error.body_text(),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let normalized = normalize_editor_character_animation_request(payload)
|
||||
.map_err(|error| character_animation_error_response(&request_context, error))?;
|
||||
let settings = require_editor_character_animation_settings(&state, &normalized)
|
||||
.map_err(|error| character_animation_error_response(&request_context, error))?;
|
||||
let extraction_settings = resolve_backend_frame_extraction_settings(&state);
|
||||
let http_client = build_upstream_http_client(settings.ark.request_timeout_ms)
|
||||
.map_err(|error| character_animation_error_response(&request_context, error))?;
|
||||
let owner_user_id = "editor-character-animation".to_string();
|
||||
let task_id = generate_ai_task_id(current_utc_micros());
|
||||
|
||||
let result = async {
|
||||
let source_data_url = resolve_media_source_as_data_url(
|
||||
&state,
|
||||
&http_client,
|
||||
normalized.source_image_src.as_str(),
|
||||
"sourceImageSrc",
|
||||
)
|
||||
.await?;
|
||||
let generated = request_editor_character_animation_preview(
|
||||
&state,
|
||||
&http_client,
|
||||
&settings,
|
||||
owner_user_id.as_str(),
|
||||
normalized.source_layer_id.as_str(),
|
||||
task_id.as_str(),
|
||||
normalized.prompt.as_str(),
|
||||
source_data_url.as_str(),
|
||||
)
|
||||
.await?;
|
||||
let frames = extract_and_persist_editor_character_animation_frames(
|
||||
&state,
|
||||
owner_user_id.as_str(),
|
||||
normalized.source_layer_id.as_str(),
|
||||
task_id.as_str(),
|
||||
generated.preview_video_path.as_str(),
|
||||
&normalized,
|
||||
&extraction_settings,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok::<_, AppError>((generated, frames))
|
||||
}
|
||||
.await;
|
||||
|
||||
let (generated, frames) = result
|
||||
.map_err(|error| character_animation_error_response(&request_context, error))?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
EditorCharacterAnimationGenerateResponse {
|
||||
ok: true,
|
||||
task_id,
|
||||
model: EDITOR_CHARACTER_ANIMATION_MODEL.to_string(),
|
||||
prompt: generated.submitted_prompt,
|
||||
preview_video_path: generated.preview_video_path,
|
||||
frame_count: frames.len() as u32,
|
||||
duration_seconds: normalized.duration_seconds,
|
||||
frame_width: normalized.frame_width,
|
||||
frame_height: normalized.frame_height,
|
||||
fps: normalized.fps,
|
||||
price_mud_points: normalized.price_mud_points,
|
||||
frames,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_character_animation_job(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -1248,6 +1334,167 @@ async fn put_generated_preview_video(
|
||||
Ok(put_result.legacy_public_path)
|
||||
}
|
||||
|
||||
async fn request_editor_character_animation_preview(
|
||||
state: &AppState,
|
||||
http_client: &reqwest::Client,
|
||||
settings: &EditorCharacterAnimationSettings,
|
||||
owner_user_id: &str,
|
||||
source_layer_id: &str,
|
||||
task_id: &str,
|
||||
prompt: &str,
|
||||
source_frame_data_url: &str,
|
||||
) -> Result<GeneratedAnimationPreview, AppError> {
|
||||
let upstream_task_id =
|
||||
create_editor_ark_image_to_video_task(http_client, settings, prompt, source_frame_data_url)
|
||||
.await?;
|
||||
let video_url =
|
||||
wait_for_ark_content_generation_task(http_client, &settings.ark, upstream_task_id.as_str())
|
||||
.await?;
|
||||
let preview_payload =
|
||||
download_generated_video(http_client, video_url.as_str(), "下载画板角色动画视频失败。")
|
||||
.await?;
|
||||
let preview_video_path = put_generated_preview_video(
|
||||
state,
|
||||
owner_user_id,
|
||||
source_layer_id,
|
||||
"editor-character-animation",
|
||||
task_id,
|
||||
preview_payload,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(GeneratedAnimationPreview {
|
||||
preview_video_path,
|
||||
upstream_task_id,
|
||||
submitted_prompt: prompt.to_string(),
|
||||
moderation_fallback_applied: false,
|
||||
})
|
||||
}
|
||||
|
||||
async fn create_editor_ark_image_to_video_task(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &EditorCharacterAnimationSettings,
|
||||
prompt: &str,
|
||||
source_frame_data_url: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let response = http_client
|
||||
.post(format!("{}/contents/generations/tasks", settings.ark.base_url))
|
||||
.header(
|
||||
reqwest::header::AUTHORIZATION,
|
||||
format!("Bearer {}", settings.ark.api_key),
|
||||
)
|
||||
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
||||
.json(&json!({
|
||||
"model": settings.ark.model,
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": prompt,
|
||||
},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": source_frame_data_url,
|
||||
},
|
||||
"role": "first_frame",
|
||||
},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": source_frame_data_url,
|
||||
},
|
||||
"role": "last_frame",
|
||||
}
|
||||
],
|
||||
"resolution": settings.resolution,
|
||||
"ratio": settings.ratio,
|
||||
"duration": settings.duration_seconds,
|
||||
"watermark": false,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
map_character_animation_upstream_error(format!("请求 Ark 视频服务失败:{error}"))
|
||||
})?;
|
||||
|
||||
let status = response.status();
|
||||
let body = response.text().await.map_err(|error| {
|
||||
map_character_animation_upstream_error(format!("读取 Ark 视频任务响应失败:{error}"))
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
return Err(parse_animation_upstream_error(
|
||||
body.as_str(),
|
||||
"创建画板角色动画视频任务失败。",
|
||||
));
|
||||
}
|
||||
let payload = parse_animation_json_payload(body.as_str(), "创建画板角色动画视频任务失败。")?;
|
||||
extract_animation_task_id(&payload.payload).ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "ark",
|
||||
"message": "画板角色动画视频任务未返回任务 id。",
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
async fn extract_and_persist_editor_character_animation_frames(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
source_layer_id: &str,
|
||||
task_id: &str,
|
||||
preview_video_path: &str,
|
||||
request: &NormalizedEditorCharacterAnimationRequest,
|
||||
extraction_settings: &BackendFrameExtractionSettings,
|
||||
) -> Result<Vec<EditorCharacterAnimationFramePayload>, AppError> {
|
||||
let plan = AnimationFrameExtractionPlan {
|
||||
frame_count: request.frame_count,
|
||||
apply_chroma_key: true,
|
||||
sample_start_ratio: 0.0,
|
||||
sample_end_ratio: 1.0,
|
||||
};
|
||||
let finalized_frames = extract_animation_frames_from_preview_video(
|
||||
state,
|
||||
preview_video_path,
|
||||
request.frame_width,
|
||||
request.frame_height,
|
||||
extraction_settings,
|
||||
&plan,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut frame_payloads = Vec::with_capacity(finalized_frames.len());
|
||||
for (index, frame) in finalized_frames.into_iter().enumerate() {
|
||||
let put_result = put_character_animation_object(
|
||||
state,
|
||||
LegacyAssetPrefix::Animations,
|
||||
vec![
|
||||
"editor".to_string(),
|
||||
sanitize_storage_segment(source_layer_id, "layer"),
|
||||
task_id.to_string(),
|
||||
],
|
||||
format!("frame{:02}.{}", index + 1, frame.extension),
|
||||
frame.mime_type,
|
||||
frame.bytes,
|
||||
build_asset_metadata(
|
||||
EDITOR_CHARACTER_ANIMATION_ASSET_KIND,
|
||||
owner_user_id,
|
||||
"editor_layer",
|
||||
source_layer_id,
|
||||
"animation_frame",
|
||||
"editor-character-animation",
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
frame_payloads.push(EditorCharacterAnimationFramePayload {
|
||||
frame_index: index as u32 + 1,
|
||||
image_src: put_result.legacy_public_path,
|
||||
width: request.frame_width,
|
||||
height: request.frame_height,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(frame_payloads)
|
||||
}
|
||||
|
||||
async fn publish_animation_set(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
@@ -1864,6 +2111,235 @@ fn resolve_character_animation_model(payload: &CharacterAnimationGenerateRequest
|
||||
normalize_required_text(candidate, CHARACTER_ANIMATION_MODEL)
|
||||
}
|
||||
|
||||
fn normalize_editor_character_animation_request(
|
||||
payload: EditorCharacterAnimationGenerateRequest,
|
||||
) -> Result<NormalizedEditorCharacterAnimationRequest, AppError> {
|
||||
let source_layer_id = normalize_required_text(payload.source_layer_id.as_str(), "");
|
||||
if source_layer_id.is_empty() {
|
||||
return Err(editor_character_animation_bad_request(
|
||||
"sourceLayerId 不能为空。",
|
||||
));
|
||||
}
|
||||
let source_image_src = trim_optional_text(Some(payload.source_image_src.as_str()))
|
||||
.ok_or_else(|| editor_character_animation_bad_request("sourceImageSrc 不能为空。"))?;
|
||||
let prompt_text = payload.prompt_text.trim().chars().take(4000).collect::<String>();
|
||||
if prompt_text.is_empty() {
|
||||
return Err(editor_character_animation_bad_request("动画描述不能为空。"));
|
||||
}
|
||||
let resolution = normalize_editor_character_animation_resolution(payload.resolution.as_str())?;
|
||||
let ratio = normalize_editor_character_animation_ratio(payload.ratio.as_str())?;
|
||||
let frame_count = normalize_editor_character_animation_frame_count(payload.frame_count)?;
|
||||
let duration_seconds =
|
||||
normalize_editor_character_animation_duration(payload.duration_seconds, frame_count)?;
|
||||
let expected_price = calculate_editor_character_animation_price(resolution, duration_seconds);
|
||||
if payload.price_mud_points != expected_price {
|
||||
return Err(editor_character_animation_bad_request(format!(
|
||||
"priceMudPoints 与分辨率和时长不一致,应为 {expected_price}。"
|
||||
)));
|
||||
}
|
||||
if payload.model.trim() != EDITOR_CHARACTER_ANIMATION_MODEL {
|
||||
return Err(editor_character_animation_bad_request(
|
||||
"model 必须固定为 seedance2.0。",
|
||||
));
|
||||
}
|
||||
let (frame_width, frame_height) = resolve_editor_character_animation_frame_size(
|
||||
payload.source_width,
|
||||
payload.source_height,
|
||||
ratio,
|
||||
resolution,
|
||||
);
|
||||
let prompt = build_editor_character_animation_prompt(prompt_text.as_str());
|
||||
|
||||
Ok(NormalizedEditorCharacterAnimationRequest {
|
||||
source_layer_id,
|
||||
source_image_src,
|
||||
prompt,
|
||||
resolution: resolution.to_string(),
|
||||
ratio: resolve_editor_character_animation_provider_ratio(
|
||||
ratio,
|
||||
payload.source_width,
|
||||
payload.source_height,
|
||||
),
|
||||
frame_count,
|
||||
duration_seconds,
|
||||
price_mud_points: expected_price,
|
||||
frame_width,
|
||||
frame_height,
|
||||
fps: (frame_count / duration_seconds).max(1),
|
||||
})
|
||||
}
|
||||
|
||||
fn require_editor_character_animation_settings(
|
||||
state: &AppState,
|
||||
request: &NormalizedEditorCharacterAnimationRequest,
|
||||
) -> Result<EditorCharacterAnimationSettings, AppError> {
|
||||
let base_url = state
|
||||
.config
|
||||
.ark_character_video_base_url
|
||||
.trim()
|
||||
.trim_end_matches('/');
|
||||
if base_url.is_empty() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||
"provider": "ark",
|
||||
"reason": "ARK_CHARACTER_VIDEO_BASE_URL 未配置",
|
||||
})),
|
||||
);
|
||||
}
|
||||
let api_key = state
|
||||
.config
|
||||
.ark_character_video_api_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||
"provider": "ark",
|
||||
"reason": "ARK_CHARACTER_VIDEO_API_KEY 未配置",
|
||||
}))
|
||||
})?;
|
||||
// 中文注释:画板角色动画入口产品侧固定为 seedance2.0,不继承通用角色视频环境模型覆盖。
|
||||
Ok(EditorCharacterAnimationSettings {
|
||||
ark: ArkVideoSettings {
|
||||
base_url: base_url.to_string(),
|
||||
api_key: api_key.to_string(),
|
||||
request_timeout_ms: state.config.ark_character_video_request_timeout_ms.max(1),
|
||||
model: EDITOR_CHARACTER_ANIMATION_MODEL.to_string(),
|
||||
},
|
||||
resolution: request.resolution.clone(),
|
||||
ratio: request.ratio.clone(),
|
||||
duration_seconds: request.duration_seconds,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_editor_character_animation_prompt(prompt_text: &str) -> String {
|
||||
format!(
|
||||
"{}\n{}",
|
||||
EDITOR_CHARACTER_ANIMATION_PROMPT_PREFIX,
|
||||
prompt_text.trim()
|
||||
)
|
||||
}
|
||||
|
||||
fn normalize_editor_character_animation_resolution(value: &str) -> Result<&'static str, AppError> {
|
||||
match value.trim() {
|
||||
"480p" => Ok("480p"),
|
||||
"720p" => Ok("720p"),
|
||||
_ => Err(editor_character_animation_bad_request(
|
||||
"resolution 只支持 480p 或 720p。",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_editor_character_animation_ratio(value: &str) -> Result<&'static str, AppError> {
|
||||
match value.trim() {
|
||||
"same" => Ok("same"),
|
||||
"1:1" => Ok("1:1"),
|
||||
"4:3" => Ok("4:3"),
|
||||
"16:9" => Ok("16:9"),
|
||||
"9:16" => Ok("9:16"),
|
||||
"3:4" => Ok("3:4"),
|
||||
_ => Err(editor_character_animation_bad_request(
|
||||
"ratio 只支持 same、1:1、4:3、16:9、9:16、3:4。",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_editor_character_animation_frame_count(value: u32) -> Result<u32, AppError> {
|
||||
match value {
|
||||
32 | 40 | 48 => Ok(value),
|
||||
_ => Err(editor_character_animation_bad_request(
|
||||
"frameCount 只支持 32、40、48。",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_editor_character_animation_duration(
|
||||
value: u32,
|
||||
frame_count: u32,
|
||||
) -> Result<u32, AppError> {
|
||||
let expected = match frame_count {
|
||||
32 => 4,
|
||||
40 => 5,
|
||||
48 => 6,
|
||||
_ => 0,
|
||||
};
|
||||
if value == expected {
|
||||
Ok(value)
|
||||
} else {
|
||||
Err(editor_character_animation_bad_request(
|
||||
"durationSeconds 必须与帧数组合为 32/4、40/5 或 48/6。",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn calculate_editor_character_animation_price(resolution: &str, duration_seconds: u32) -> u32 {
|
||||
let per_second = if resolution == "720p" { 20 } else { 10 };
|
||||
per_second * duration_seconds
|
||||
}
|
||||
|
||||
fn resolve_editor_character_animation_provider_ratio(
|
||||
ratio: &str,
|
||||
source_width: u32,
|
||||
source_height: u32,
|
||||
) -> String {
|
||||
if ratio != "same" {
|
||||
return ratio.to_string();
|
||||
}
|
||||
if source_width == 0 || source_height == 0 {
|
||||
return "1:1".to_string();
|
||||
}
|
||||
let normalized = source_width as f32 / source_height as f32;
|
||||
[
|
||||
("1:1", 1.0f32),
|
||||
("4:3", 4.0 / 3.0),
|
||||
("16:9", 16.0 / 9.0),
|
||||
("9:16", 9.0 / 16.0),
|
||||
("3:4", 3.0 / 4.0),
|
||||
]
|
||||
.into_iter()
|
||||
.min_by(|(_, left), (_, right)| {
|
||||
(normalized - *left)
|
||||
.abs()
|
||||
.partial_cmp(&(normalized - *right).abs())
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
})
|
||||
.map(|(value, _)| value.to_string())
|
||||
.unwrap_or_else(|| "1:1".to_string())
|
||||
}
|
||||
|
||||
fn resolve_editor_character_animation_frame_size(
|
||||
source_width: u32,
|
||||
source_height: u32,
|
||||
ratio: &str,
|
||||
resolution: &str,
|
||||
) -> (u32, u32) {
|
||||
let long_edge = if resolution == "720p" { 720 } else { 480 };
|
||||
let (ratio_width, ratio_height) = match ratio {
|
||||
"same" if source_width > 0 && source_height > 0 => (source_width, source_height),
|
||||
"4:3" => (4, 3),
|
||||
"16:9" => (16, 9),
|
||||
"9:16" => (9, 16),
|
||||
"3:4" => (3, 4),
|
||||
_ => (1, 1),
|
||||
};
|
||||
if ratio_width >= ratio_height {
|
||||
let height = ((long_edge as f32 * ratio_height as f32 / ratio_width as f32).round() as u32)
|
||||
.max(1);
|
||||
(long_edge, height)
|
||||
} else {
|
||||
let width = ((long_edge as f32 * ratio_width as f32 / ratio_height as f32).round() as u32)
|
||||
.max(1);
|
||||
(width, long_edge)
|
||||
}
|
||||
}
|
||||
|
||||
fn editor_character_animation_bad_request(message: impl Into<String>) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "editor-character-animation",
|
||||
"message": message.into(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn build_animation_generate_result_payload(generated: &CharacterAnimationGeneratedDraft) -> Value {
|
||||
match generated.preview_video_path.as_ref() {
|
||||
Some(preview_video_path) => json!({
|
||||
@@ -3353,6 +3829,28 @@ struct ArkVideoSettings {
|
||||
model: String,
|
||||
}
|
||||
|
||||
struct EditorCharacterAnimationSettings {
|
||||
ark: ArkVideoSettings,
|
||||
resolution: String,
|
||||
ratio: String,
|
||||
duration_seconds: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct NormalizedEditorCharacterAnimationRequest {
|
||||
source_layer_id: String,
|
||||
source_image_src: String,
|
||||
prompt: String,
|
||||
resolution: String,
|
||||
ratio: String,
|
||||
frame_count: u32,
|
||||
duration_seconds: u32,
|
||||
price_mud_points: u32,
|
||||
frame_width: u32,
|
||||
frame_height: u32,
|
||||
fps: u32,
|
||||
}
|
||||
|
||||
struct GeneratedAnimationPreview {
|
||||
preview_video_path: String,
|
||||
upstream_task_id: String,
|
||||
@@ -3548,4 +4046,73 @@ mod tests {
|
||||
|
||||
assert_eq!(resolve_character_animation_model(&payload), "wan-move");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_character_animation_normalizes_seedance_request_contract() {
|
||||
let normalized =
|
||||
normalize_editor_character_animation_request(EditorCharacterAnimationGenerateRequest {
|
||||
source_layer_id: " layer-hero ".to_string(),
|
||||
source_image_src: "/generated-characters/hero/master.png".to_string(),
|
||||
source_width: 768,
|
||||
source_height: 1024,
|
||||
prompt_text: "待机呼吸,轻微摆动。".to_string(),
|
||||
resolution: "720p".to_string(),
|
||||
ratio: "same".to_string(),
|
||||
frame_count: 48,
|
||||
duration_seconds: 6,
|
||||
price_mud_points: 120,
|
||||
model: EDITOR_CHARACTER_ANIMATION_MODEL.to_string(),
|
||||
})
|
||||
.expect("editor request should normalize");
|
||||
|
||||
assert_eq!(normalized.source_layer_id, "layer-hero");
|
||||
assert_eq!(normalized.frame_count, 48);
|
||||
assert_eq!(normalized.duration_seconds, 6);
|
||||
assert_eq!(normalized.price_mud_points, 120);
|
||||
assert_eq!(normalized.fps, 8);
|
||||
assert_eq!(normalized.frame_width, 540);
|
||||
assert_eq!(normalized.frame_height, 720);
|
||||
assert_eq!(normalized.ratio, "3:4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_character_animation_rejects_invalid_frame_duration_pair() {
|
||||
let error =
|
||||
normalize_editor_character_animation_request(EditorCharacterAnimationGenerateRequest {
|
||||
source_layer_id: "layer-hero".to_string(),
|
||||
source_image_src: "/generated-characters/hero/master.png".to_string(),
|
||||
source_width: 1024,
|
||||
source_height: 1024,
|
||||
prompt_text: "奔跑".to_string(),
|
||||
resolution: "480p".to_string(),
|
||||
ratio: "1:1".to_string(),
|
||||
frame_count: 48,
|
||||
duration_seconds: 4,
|
||||
price_mud_points: 40,
|
||||
model: EDITOR_CHARACTER_ANIMATION_MODEL.to_string(),
|
||||
})
|
||||
.expect_err("invalid frame/duration pair should fail");
|
||||
|
||||
assert!(
|
||||
error
|
||||
.body_text()
|
||||
.contains("durationSeconds 必须与帧数组合")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_character_animation_builds_required_green_screen_prompt() {
|
||||
let prompt = build_editor_character_animation_prompt("行走两步后回到站姿。");
|
||||
|
||||
assert!(prompt.contains("生成游戏角色动画"));
|
||||
assert!(prompt.contains("参考图作为首帧和尾帧"));
|
||||
assert!(prompt.contains("背景固定为纯绿色绿幕"));
|
||||
assert!(prompt.contains("动作描述:\n行走两步后回到站姿。"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_character_animation_price_depends_on_resolution_and_duration() {
|
||||
assert_eq!(calculate_editor_character_animation_price("480p", 4), 40);
|
||||
assert_eq!(calculate_editor_character_animation_price("720p", 6), 120);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,31 @@
|
||||
use std::{borrow::Cow, collections::BTreeMap};
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, Path, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||
use module_assets::{
|
||||
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_object_upsert_input,
|
||||
generate_asset_object_id,
|
||||
};
|
||||
use platform_image::{
|
||||
DownloadedImage,
|
||||
generated_asset_sheets::{
|
||||
GeneratedAssetSheetConnectedIcon, slice_generated_icon_spritesheet_by_connected_components,
|
||||
},
|
||||
};
|
||||
use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use shared_kernel::build_prefixed_uuid_id;
|
||||
use spacetime_client::{
|
||||
EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput,
|
||||
EditorAssetFolderDeleteRecordInput, EditorAssetFolderRecord, EditorAssetFolderUpdateRecordInput,
|
||||
EditorAssetLibraryRecord, EditorAssetRecord, EditorAssetUpdateRecordInput, EditorCanvasRecord,
|
||||
EditorCanvasViewportRecord, EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput,
|
||||
EditorProjectGetRecordInput,
|
||||
EditorAssetFolderDeleteRecordInput, EditorAssetFolderRecord,
|
||||
EditorAssetFolderUpdateRecordInput, EditorAssetLibraryRecord, EditorAssetRecord,
|
||||
EditorAssetUpdateRecordInput, EditorCanvasRecord, EditorCanvasViewportRecord,
|
||||
EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput, EditorProjectGetRecordInput,
|
||||
EditorProjectLayoutSaveRecordInput, EditorProjectRecord, EditorProjectRenameRecordInput,
|
||||
EditorProjectResourceCreateRecordInput, EditorProjectResourceRecord, SpacetimeClientError,
|
||||
};
|
||||
@@ -20,12 +33,19 @@ use spacetime_client::{
|
||||
use crate::{
|
||||
api_response::json_success_body,
|
||||
auth::AuthenticatedAccessToken,
|
||||
generated_image_assets::{
|
||||
GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl,
|
||||
adapter::{GeneratedImageAssetAdapterMetadata, GeneratedImageAssetPersistInput},
|
||||
normalize_generated_image_asset_mime,
|
||||
},
|
||||
http_error::AppError,
|
||||
openai_image_generation::{
|
||||
GPT_IMAGE_2_MODEL, OpenAiReferenceImage, build_openai_image_http_client,
|
||||
create_openai_image_edit_with_references, create_openai_image_generation,
|
||||
DownloadedOpenAiImage, GPT_IMAGE_2_MODEL, OpenAiReferenceImage,
|
||||
build_openai_image_http_client, create_openai_image_edit_with_references,
|
||||
create_openai_image_edit_with_references_and_model, create_openai_image_generation,
|
||||
require_openai_image_settings,
|
||||
},
|
||||
platform_errors::map_oss_error,
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
};
|
||||
@@ -37,6 +57,13 @@ const EDITOR_ASSET_ID_PREFIX: &str = "editor-asset-";
|
||||
const EDITOR_LAYOUT_MAX_BYTES: usize = 256 * 1024;
|
||||
const EDITOR_PROJECT_DEFAULT_TITLE: &str = "未命名画布";
|
||||
const EDITOR_IMAGE_GENERATION_SIZE: &str = "1024x1024";
|
||||
const EDITOR_ICON_SPRITESHEET_MODEL: &str = "gemini-3.1-flash-image-preview";
|
||||
const EDITOR_ICON_SPRITESHEET_SMALL_SIZE: &str = "512x512";
|
||||
const EDITOR_ICON_SPRITESHEET_LARGE_SIZE: &str = "1024x1024";
|
||||
const EDITOR_ICON_DESCRIPTION_LIMIT: usize = 100;
|
||||
const EDITOR_CHARACTER_IMAGE_ASSET_KIND: &str = "editor_character_image";
|
||||
const EDITOR_CHARACTER_IMAGE_ENTITY_KIND: &str = "editor_project";
|
||||
const EDITOR_CHARACTER_IMAGE_SLOT: &str = "character";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -125,6 +152,10 @@ pub struct EditorAssetUpdateRequest {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorImageGenerationRequest {
|
||||
prompt: String,
|
||||
size: Option<String>,
|
||||
kind: Option<String>,
|
||||
model: Option<String>,
|
||||
reference_image_srcs: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -134,6 +165,14 @@ pub struct EditorImageEditRequest {
|
||||
source_image_src: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorIconSpritesheetGenerationRequest {
|
||||
reference_image_src: String,
|
||||
icon_descriptions: Vec<String>,
|
||||
model: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorProjectResponse {
|
||||
@@ -192,6 +231,8 @@ pub struct EditorAssetResponse {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorImageGenerationResponse {
|
||||
image_src: String,
|
||||
object_key: Option<String>,
|
||||
asset_object_id: Option<String>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
source_type: &'static str,
|
||||
@@ -202,6 +243,29 @@ pub struct EditorImageGenerationResponse {
|
||||
task_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorIconSpritesheetIconResponse {
|
||||
name: String,
|
||||
image_src: String,
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorIconSpritesheetGenerationResponse {
|
||||
spritesheet_image_src: String,
|
||||
spritesheet_width: u32,
|
||||
spritesheet_height: u32,
|
||||
icon_image_srcs: Vec<EditorIconSpritesheetIconResponse>,
|
||||
prompt: String,
|
||||
actual_prompt: Option<String>,
|
||||
model: String,
|
||||
provider: &'static str,
|
||||
task_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorProjectPayload {
|
||||
@@ -683,8 +747,8 @@ pub async fn generate_editor_image(
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<EditorImageGenerationRequest>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let prompt = payload.prompt.trim().to_string();
|
||||
if prompt.is_empty() {
|
||||
let role_setting = payload.prompt.trim().to_string();
|
||||
if role_setting.is_empty() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "editor-image-generation",
|
||||
@@ -693,33 +757,93 @@ pub async fn generate_editor_image(
|
||||
);
|
||||
}
|
||||
|
||||
let image_size = normalize_editor_image_generation_size(payload.size.as_deref());
|
||||
let _requested_model = payload.model.as_deref();
|
||||
let normalized_kind = payload.kind.as_deref().map(str::trim);
|
||||
let is_character_generation = matches!(normalized_kind, Some("character"));
|
||||
let submitted_prompt = if is_character_generation {
|
||||
build_editor_character_image_prompt(role_setting.as_str())
|
||||
} else {
|
||||
role_setting.clone()
|
||||
};
|
||||
let failure_context = match normalized_kind {
|
||||
Some("character") => "图片画布生成角色形象",
|
||||
Some("spec") => "图片画布生成规范",
|
||||
Some("quick-edit") => "图片画布快速编辑图片",
|
||||
_ => "图片画布生成图片",
|
||||
};
|
||||
let reference_sources = payload
|
||||
.reference_image_srcs
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|source| source.trim().to_string())
|
||||
.filter(|source| !source.is_empty())
|
||||
.take(5)
|
||||
.collect::<Vec<_>>();
|
||||
let settings = require_openai_image_settings(&state)?.with_external_api_audit_context(
|
||||
&request_context,
|
||||
Some(authenticated.claims().user_id().to_string()),
|
||||
None,
|
||||
);
|
||||
let http_client = build_openai_image_http_client(&settings)?;
|
||||
let generated = create_openai_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
prompt.as_str(),
|
||||
Some("文字、水印、边框、按钮、UI 控件、低清晰度、变形主体"),
|
||||
EDITOR_IMAGE_GENERATION_SIZE,
|
||||
1,
|
||||
&[],
|
||||
"图片画布生成图片",
|
||||
)
|
||||
.await?;
|
||||
let image = generated.images.into_iter().next().ok_or_else(|| {
|
||||
let negative_prompt = Some("文字、水印、边框、按钮、UI 控件、低清晰度、变形主体");
|
||||
let generated = if reference_sources.is_empty() {
|
||||
create_openai_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
submitted_prompt.as_str(),
|
||||
negative_prompt,
|
||||
image_size.as_ref(),
|
||||
1,
|
||||
&[],
|
||||
failure_context,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
let reference_images = reference_sources
|
||||
.iter()
|
||||
.map(|source| parse_editor_reference_image(source.as_str()))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
create_openai_image_edit_with_references(
|
||||
&http_client,
|
||||
&settings,
|
||||
submitted_prompt.as_str(),
|
||||
negative_prompt,
|
||||
image_size.as_ref(),
|
||||
1,
|
||||
reference_images.as_slice(),
|
||||
failure_context,
|
||||
)
|
||||
.await?
|
||||
};
|
||||
let mut image = generated.images.into_iter().next().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine",
|
||||
"message": "VectorEngine 未返回图片",
|
||||
}))
|
||||
})?;
|
||||
if is_character_generation {
|
||||
image = prepare_editor_character_image_for_response(image);
|
||||
}
|
||||
|
||||
let (width, height) = image::load_from_memory(image.bytes.as_slice())
|
||||
.map(|image| (image.width(), image.height()))
|
||||
.unwrap_or((1024, 1024));
|
||||
let persisted = if is_character_generation {
|
||||
Some(
|
||||
persist_editor_character_image(
|
||||
&state,
|
||||
authenticated.claims().user_id(),
|
||||
generated.task_id.as_str(),
|
||||
&image,
|
||||
submitted_prompt.as_str(),
|
||||
generated.actual_prompt.as_deref(),
|
||||
)
|
||||
.await?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let image_src = format!(
|
||||
"data:{};base64,{}",
|
||||
image.mime_type,
|
||||
@@ -730,10 +854,14 @@ pub async fn generate_editor_image(
|
||||
Some(&request_context),
|
||||
EditorImageGenerationResponse {
|
||||
image_src,
|
||||
object_key: persisted.as_ref().map(|asset| asset.object_key.clone()),
|
||||
asset_object_id: persisted
|
||||
.as_ref()
|
||||
.map(|asset| asset.asset_object_id.clone()),
|
||||
width,
|
||||
height,
|
||||
source_type: "generated",
|
||||
prompt,
|
||||
prompt: role_setting,
|
||||
actual_prompt: generated.actual_prompt,
|
||||
model: GPT_IMAGE_2_MODEL,
|
||||
provider: "VectorEngine",
|
||||
@@ -742,6 +870,31 @@ pub async fn generate_editor_image(
|
||||
))
|
||||
}
|
||||
|
||||
fn normalize_editor_image_generation_size(size: Option<&str>) -> Cow<'static, str> {
|
||||
match size.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
Some("1024x1024") | Some("1024*1024") | Some("1:1") => Cow::Borrowed("1024x1024"),
|
||||
Some("1536x1024") | Some("1536*1024") | Some("16:9") => Cow::Borrowed("1536x1024"),
|
||||
Some("2048x1152") | Some("2048*1152") | Some("1920x1080") | Some("1920*1080")
|
||||
| Some("2k-16:9") => Cow::Borrowed("2048x1152"),
|
||||
Some("1024x1536") | Some("1024*1536") | Some("9:16") => Cow::Borrowed("1024x1536"),
|
||||
Some(value) if is_editor_custom_image_size(value) => Cow::Owned(value.to_string()),
|
||||
_ => Cow::Borrowed(EDITOR_IMAGE_GENERATION_SIZE),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_editor_custom_image_size(value: &str) -> bool {
|
||||
let Some((width, height)) = value.split_once('x') else {
|
||||
return false;
|
||||
};
|
||||
let Ok(width) = width.parse::<u32>() else {
|
||||
return false;
|
||||
};
|
||||
let Ok(height) = height.parse::<u32>() else {
|
||||
return false;
|
||||
};
|
||||
(64..=4096).contains(&width) && (64..=4096).contains(&height)
|
||||
}
|
||||
|
||||
pub async fn edit_editor_image(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -795,6 +948,8 @@ pub async fn edit_editor_image(
|
||||
Some(&request_context),
|
||||
EditorImageGenerationResponse {
|
||||
image_src,
|
||||
object_key: None,
|
||||
asset_object_id: None,
|
||||
width,
|
||||
height,
|
||||
source_type: "generated",
|
||||
@@ -807,6 +962,96 @@ pub async fn edit_editor_image(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn generate_editor_icon_spritesheet(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<EditorIconSpritesheetGenerationRequest>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let icon_descriptions = normalize_icon_descriptions(payload.icon_descriptions)?;
|
||||
let reference_image = parse_editor_reference_image(payload.reference_image_src.as_str())
|
||||
.map_err(|error| {
|
||||
error.with_details(json!({
|
||||
"provider": "editor-icon-spritesheet",
|
||||
"field": "referenceImageSrc",
|
||||
"message": "图标素材规范必须是图片 Data URL。",
|
||||
}))
|
||||
})?;
|
||||
let model = payload
|
||||
.model
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(EDITOR_ICON_SPRITESHEET_MODEL)
|
||||
.to_string();
|
||||
let size = editor_icon_spritesheet_size_for_count(icon_descriptions.len());
|
||||
let prompt = build_editor_icon_spritesheet_prompt(&icon_descriptions);
|
||||
|
||||
let settings = require_openai_image_settings(&state)?.with_external_api_audit_context(
|
||||
&request_context,
|
||||
Some(authenticated.claims().user_id().to_string()),
|
||||
None,
|
||||
);
|
||||
let http_client = build_openai_image_http_client(&settings)?;
|
||||
let generated = create_openai_image_edit_with_references_and_model(
|
||||
&http_client,
|
||||
&settings,
|
||||
model.as_str(),
|
||||
prompt.as_str(),
|
||||
None,
|
||||
size,
|
||||
1,
|
||||
&[reference_image],
|
||||
"图片画布生成图标素材 spritesheet",
|
||||
)
|
||||
.await?;
|
||||
let image = generated.images.into_iter().next().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine",
|
||||
"message": "VectorEngine 未返回图标 spritesheet",
|
||||
}))
|
||||
})?;
|
||||
let (spritesheet_width, spritesheet_height) = image::load_from_memory(image.bytes.as_slice())
|
||||
.map(|image| (image.width(), image.height()))
|
||||
.unwrap_or((512, 512));
|
||||
let source = DownloadedImage {
|
||||
bytes: image.bytes.clone(),
|
||||
mime_type: image.mime_type.clone(),
|
||||
extension: image.extension.clone(),
|
||||
};
|
||||
let icon_slices = slice_generated_icon_spritesheet_by_connected_components(
|
||||
&source,
|
||||
icon_descriptions.as_slice(),
|
||||
)
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "editor-icon-spritesheet",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
})?;
|
||||
let spritesheet_image_src =
|
||||
data_url_from_image_bytes(image.mime_type.as_str(), image.bytes.as_slice());
|
||||
let icon_image_srcs = icon_slices
|
||||
.into_iter()
|
||||
.map(editor_icon_response_from_slice)
|
||||
.collect();
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
EditorIconSpritesheetGenerationResponse {
|
||||
spritesheet_image_src,
|
||||
spritesheet_width,
|
||||
spritesheet_height,
|
||||
icon_image_srcs,
|
||||
prompt,
|
||||
actual_prompt: generated.actual_prompt,
|
||||
model,
|
||||
provider: "VectorEngine",
|
||||
task_id: generated.task_id,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn editor_project_payload_from_record(record: EditorProjectRecord) -> EditorProjectPayload {
|
||||
let canvas = editor_canvas_payload_from_record(record.canvas);
|
||||
EditorProjectPayload {
|
||||
@@ -957,6 +1202,229 @@ fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
||||
.filter(|item| !item.is_empty())
|
||||
}
|
||||
|
||||
fn sanitize_editor_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>()
|
||||
.split('-')
|
||||
.filter(|part| !part.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("-");
|
||||
if normalized.is_empty() {
|
||||
fallback.to_string()
|
||||
} else {
|
||||
normalized
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_icon_descriptions(descriptions: Vec<String>) -> Result<Vec<String>, AppError> {
|
||||
let normalized = descriptions
|
||||
.into_iter()
|
||||
.map(|description| description.trim().to_string())
|
||||
.filter(|description| !description.is_empty())
|
||||
.take(EDITOR_ICON_DESCRIPTION_LIMIT.saturating_add(1))
|
||||
.collect::<Vec<_>>();
|
||||
if normalized.is_empty() || normalized.len() > EDITOR_ICON_DESCRIPTION_LIMIT {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "editor-icon-spritesheet",
|
||||
"field": "iconDescriptions",
|
||||
"message": "图标素材描述数量必须在 1 到 100 个之间。",
|
||||
})),
|
||||
);
|
||||
}
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
fn build_editor_icon_spritesheet_prompt(icon_descriptions: &[String]) -> String {
|
||||
format!(
|
||||
"参考图1的图标素材规范,纯绿幕背景方便扣除背景,禁止出现文字,保证每个图标素材的所有内容区域是完全连通的。按照以下的素材的顺序从上到下从左到右依次生成并整理成一张spritesheet:\n\n{}",
|
||||
icon_descriptions.join("、")
|
||||
)
|
||||
}
|
||||
|
||||
fn build_editor_character_image_prompt(role_setting: &str) -> String {
|
||||
vec![
|
||||
"基于图1的角色美术视觉规范指导生成游戏角色形象图。画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。背景固定为纯绿色绿幕,只作为抠像底色,禁止生成美术视觉规范、出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。".to_string(),
|
||||
format!("角色设定:{}", role_setting.trim()),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn prepare_editor_character_image_for_response(
|
||||
image: DownloadedOpenAiImage,
|
||||
) -> DownloadedOpenAiImage {
|
||||
let source_bytes = if image.mime_type == "image/png" {
|
||||
image.bytes
|
||||
} else {
|
||||
match image::load_from_memory(image.bytes.as_slice()) {
|
||||
Ok(decoded) => {
|
||||
let mut encoded = std::io::Cursor::new(Vec::new());
|
||||
if decoded
|
||||
.write_to(&mut encoded, image::ImageFormat::Png)
|
||||
.is_err()
|
||||
{
|
||||
return image;
|
||||
}
|
||||
encoded.into_inner()
|
||||
}
|
||||
Err(_) => return image,
|
||||
}
|
||||
};
|
||||
|
||||
let processed =
|
||||
crate::character_visual_assets::try_apply_background_alpha_to_png(source_bytes.as_slice());
|
||||
let Some(bytes) = processed else {
|
||||
return DownloadedOpenAiImage {
|
||||
bytes: source_bytes,
|
||||
mime_type: "image/png".to_string(),
|
||||
extension: "png".to_string(),
|
||||
};
|
||||
};
|
||||
|
||||
DownloadedOpenAiImage {
|
||||
bytes,
|
||||
mime_type: "image/png".to_string(),
|
||||
extension: "png".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn editor_icon_spritesheet_size_for_count(icon_count: usize) -> &'static str {
|
||||
if icon_count <= 25 {
|
||||
EDITOR_ICON_SPRITESHEET_SMALL_SIZE
|
||||
} else {
|
||||
EDITOR_ICON_SPRITESHEET_LARGE_SIZE
|
||||
}
|
||||
}
|
||||
|
||||
fn data_url_from_image_bytes(mime_type: &str, bytes: &[u8]) -> String {
|
||||
format!(
|
||||
"data:{};base64,{}",
|
||||
mime_type,
|
||||
BASE64_STANDARD.encode(bytes)
|
||||
)
|
||||
}
|
||||
|
||||
fn editor_icon_response_from_slice(
|
||||
icon: GeneratedAssetSheetConnectedIcon,
|
||||
) -> EditorIconSpritesheetIconResponse {
|
||||
EditorIconSpritesheetIconResponse {
|
||||
name: icon.name,
|
||||
image_src: data_url_from_image_bytes("image/png", icon.bytes.as_slice()),
|
||||
width: icon.width,
|
||||
height: icon.height,
|
||||
}
|
||||
}
|
||||
|
||||
struct PersistedEditorGeneratedImage {
|
||||
object_key: String,
|
||||
asset_object_id: String,
|
||||
}
|
||||
|
||||
async fn persist_editor_character_image(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
task_id: &str,
|
||||
image: &DownloadedOpenAiImage,
|
||||
prompt: &str,
|
||||
actual_prompt: Option<&str>,
|
||||
) -> Result<PersistedEditorGeneratedImage, 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 prepared =
|
||||
GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput {
|
||||
prefix: LegacyAssetPrefix::CharacterDrafts,
|
||||
path_segments: vec![
|
||||
"editor".to_string(),
|
||||
"character-images".to_string(),
|
||||
sanitize_editor_storage_segment(task_id, "task"),
|
||||
],
|
||||
file_stem: "image".to_string(),
|
||||
image: GeneratedImageAssetDataUrl {
|
||||
format: normalize_generated_image_asset_mime(image.mime_type.as_str()),
|
||||
bytes: image.bytes.clone(),
|
||||
},
|
||||
access: OssObjectAccess::Private,
|
||||
metadata: GeneratedImageAssetAdapterMetadata {
|
||||
asset_kind: Some(EDITOR_CHARACTER_IMAGE_ASSET_KIND.to_string()),
|
||||
owner_user_id: Some(owner_user_id.to_string()),
|
||||
entity_kind: Some(EDITOR_CHARACTER_IMAGE_ENTITY_KIND.to_string()),
|
||||
entity_id: Some(task_id.to_string()),
|
||||
slot: Some(EDITOR_CHARACTER_IMAGE_SLOT.to_string()),
|
||||
provider: Some("vector-engine".to_string()),
|
||||
task_id: Some(task_id.to_string()),
|
||||
},
|
||||
extra_metadata: BTreeMap::from([(
|
||||
"source".to_string(),
|
||||
"image-canvas-editor".to_string(),
|
||||
)]),
|
||||
})
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": "generated-image-assets",
|
||||
"message": format!("准备画板角色形象 OSS 上传请求失败:{error:?}"),
|
||||
}))
|
||||
})?;
|
||||
let persisted_mime_type = prepared.format.mime_type.clone();
|
||||
let http_client = reqwest::Client::new();
|
||||
let put_result = oss_client
|
||||
.put_object(&http_client, prepared.request)
|
||||
.await
|
||||
.map_err(|error| map_oss_error(error, "aliyun-oss"))?;
|
||||
let head = oss_client
|
||||
.head_object(
|
||||
&http_client,
|
||||
OssHeadObjectRequest {
|
||||
object_key: put_result.object_key.clone(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|error| map_oss_error(error, "aliyun-oss"))?;
|
||||
let now_micros = current_utc_micros();
|
||||
let asset_object = state
|
||||
.spacetime_client()
|
||||
.confirm_asset_object(
|
||||
build_asset_object_upsert_input(
|
||||
generate_asset_object_id(now_micros),
|
||||
head.bucket,
|
||||
head.object_key.clone(),
|
||||
AssetObjectAccessPolicy::Private,
|
||||
head.content_type.or(Some(persisted_mime_type)),
|
||||
head.content_length,
|
||||
Some(actual_prompt.unwrap_or(prompt).to_string()),
|
||||
EDITOR_CHARACTER_IMAGE_ASSET_KIND.to_string(),
|
||||
Some(task_id.to_string()),
|
||||
Some(owner_user_id.to_string()),
|
||||
None,
|
||||
Some(task_id.to_string()),
|
||||
now_micros,
|
||||
)
|
||||
.map_err(map_editor_asset_field_error)?,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "spacetimedb",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
})?;
|
||||
|
||||
Ok(PersistedEditorGeneratedImage {
|
||||
object_key: head.object_key,
|
||||
asset_object_id: asset_object.asset_object_id,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_editor_reference_image(source: &str) -> Result<OpenAiReferenceImage, AppError> {
|
||||
let Some((header, data)) = source.trim().split_once(',') else {
|
||||
return Err(
|
||||
@@ -1023,12 +1491,11 @@ fn map_editor_project_error(error: SpacetimeClientError) -> AppError {
|
||||
"message": message,
|
||||
}))
|
||||
}
|
||||
SpacetimeClientError::Runtime(message) => {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
SpacetimeClientError::Runtime(message) => AppError::from_status(StatusCode::BAD_REQUEST)
|
||||
.with_details(json!({
|
||||
"provider": "editor-project",
|
||||
"message": message,
|
||||
}))
|
||||
}
|
||||
})),
|
||||
other => AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "spacetimedb",
|
||||
"message": other.to_string(),
|
||||
@@ -1036,6 +1503,13 @@ fn map_editor_project_error(error: SpacetimeClientError) -> AppError {
|
||||
}
|
||||
}
|
||||
|
||||
fn map_editor_asset_field_error(error: AssetObjectFieldError) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "asset-object",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn current_utc_micros() -> i64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
@@ -1044,3 +1518,136 @@ fn current_utc_micros() -> i64 {
|
||||
.expect("system clock should be after unix epoch");
|
||||
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn editor_image_generation_size_keeps_quick_edit_canvas_ratio_presets() {
|
||||
assert_eq!(normalize_editor_image_generation_size(None), "1024x1024");
|
||||
assert_eq!(
|
||||
normalize_editor_image_generation_size(Some("1536x1024")),
|
||||
"1536x1024"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_editor_image_generation_size(Some("1024x1536")),
|
||||
"1024x1536"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_editor_image_generation_size(Some("2048x1152")),
|
||||
"2048x1152"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_editor_image_generation_size(Some("640x640")),
|
||||
"640x640"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_editor_image_generation_size(Some("bad-size")),
|
||||
"1024x1024"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_character_image_prompt_appends_user_role_setting() {
|
||||
let prompt = build_editor_character_image_prompt("菜市场卖菜大妈");
|
||||
|
||||
assert!(prompt.contains("基于图1的角色美术视觉规范指导生成游戏角色形象图。"));
|
||||
assert!(prompt.contains("背景固定为纯绿色绿幕"));
|
||||
assert!(prompt.contains("禁止镜头透视"));
|
||||
assert!(prompt.contains("角色设定:菜市场卖菜大妈"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_character_image_postprocess_removes_green_screen_background() {
|
||||
let width = 12;
|
||||
let height = 12;
|
||||
let mut image = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 255, 0, 255]));
|
||||
for y in 4..8 {
|
||||
for x in 4..8 {
|
||||
image.put_pixel(x, y, image::Rgba([188, 82, 45, 255]));
|
||||
}
|
||||
}
|
||||
let mut encoded = std::io::Cursor::new(Vec::new());
|
||||
image::DynamicImage::ImageRgba8(image)
|
||||
.write_to(&mut encoded, image::ImageFormat::Png)
|
||||
.expect("test image should encode");
|
||||
|
||||
let processed = prepare_editor_character_image_for_response(DownloadedOpenAiImage {
|
||||
bytes: encoded.into_inner(),
|
||||
mime_type: "image/png".to_string(),
|
||||
extension: "png".to_string(),
|
||||
});
|
||||
let decoded = image::load_from_memory(processed.bytes.as_slice())
|
||||
.expect("processed image should decode")
|
||||
.to_rgba8();
|
||||
|
||||
assert_eq!(processed.mime_type, "image/png");
|
||||
assert_eq!(processed.extension, "png");
|
||||
assert_eq!(decoded.get_pixel(0, 0).0[3], 0);
|
||||
assert_eq!(decoded.get_pixel(5, 5).0[3], 255);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_character_image_postprocess_converts_non_png_green_screen_to_transparent_png() {
|
||||
let width = 12;
|
||||
let height = 12;
|
||||
let mut image =
|
||||
image::RgbImage::from_pixel(width, height, image::Rgb([0_u8, 255_u8, 0_u8]));
|
||||
for y in 4..8 {
|
||||
for x in 4..8 {
|
||||
image.put_pixel(x, y, image::Rgb([188, 82, 45]));
|
||||
}
|
||||
}
|
||||
let mut encoded = std::io::Cursor::new(Vec::new());
|
||||
image::DynamicImage::ImageRgb8(image)
|
||||
.write_to(&mut encoded, image::ImageFormat::Jpeg)
|
||||
.expect("test image should encode");
|
||||
|
||||
let processed = prepare_editor_character_image_for_response(DownloadedOpenAiImage {
|
||||
bytes: encoded.into_inner(),
|
||||
mime_type: "image/jpeg".to_string(),
|
||||
extension: "jpg".to_string(),
|
||||
});
|
||||
let decoded = image::load_from_memory(processed.bytes.as_slice())
|
||||
.expect("processed image should decode")
|
||||
.to_rgba8();
|
||||
|
||||
assert_eq!(processed.mime_type, "image/png");
|
||||
assert_eq!(processed.extension, "png");
|
||||
assert_eq!(decoded.get_pixel(0, 0).0[3], 0);
|
||||
assert_eq!(decoded.get_pixel(5, 5).0[3], 255);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_icon_spritesheet_prompt_uses_ordered_descriptions_and_size_tiers() {
|
||||
let descriptions = vec![
|
||||
"返回按钮".to_string(),
|
||||
"设置按钮".to_string(),
|
||||
"下一关按钮".to_string(),
|
||||
];
|
||||
let prompt = build_editor_icon_spritesheet_prompt(&descriptions);
|
||||
|
||||
assert!(prompt.contains("参考图1的图标素材规范"));
|
||||
assert!(prompt.contains("纯绿幕背景方便扣除背景"));
|
||||
assert!(prompt.contains("返回按钮、设置按钮、下一关按钮"));
|
||||
assert_eq!(editor_icon_spritesheet_size_for_count(25), "512x512");
|
||||
assert_eq!(editor_icon_spritesheet_size_for_count(26), "1024x1024");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_icon_description_validation_filters_empty_and_rejects_more_than_limit() {
|
||||
let descriptions = normalize_icon_descriptions(vec![
|
||||
" 返回按钮 ".to_string(),
|
||||
" ".to_string(),
|
||||
"设置按钮".to_string(),
|
||||
])
|
||||
.expect("valid descriptions should pass");
|
||||
assert_eq!(descriptions, vec!["返回按钮", "设置按钮"]);
|
||||
|
||||
let too_many = (0..101)
|
||||
.map(|index| format!("图标{index}"))
|
||||
.collect::<Vec<_>>();
|
||||
assert!(normalize_icon_descriptions(too_many).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -744,6 +744,7 @@ mod tests {
|
||||
started_at: Some("2026-06-03T00:00:00Z".to_string()),
|
||||
completed_at: None,
|
||||
updated_at: "2026-06-03T00:00:00Z".to_string(),
|
||||
updated_at_micros: 1_780_000_000_000_000,
|
||||
lease_token: lease_token.map(ToOwned::to_owned),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@ use crate::{
|
||||
create_editor_asset, create_editor_asset_folder, create_editor_project,
|
||||
create_editor_project_resource, delete_editor_asset, delete_editor_asset_folder,
|
||||
delete_editor_project, edit_editor_image, generate_editor_image,
|
||||
get_editor_asset_library, get_editor_project, list_editor_projects,
|
||||
load_recent_editor_project, rename_editor_project, save_editor_project_layout,
|
||||
update_editor_asset, update_editor_asset_folder,
|
||||
generate_editor_icon_spritesheet, get_editor_asset_library, get_editor_project,
|
||||
list_editor_projects, load_recent_editor_project, rename_editor_project,
|
||||
save_editor_project_layout, update_editor_asset, update_editor_asset_folder,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
@@ -111,4 +111,11 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/editor/icon-spritesheets/generations",
|
||||
post(generate_editor_icon_spritesheet).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,8 +16,9 @@ use crate::{
|
||||
assets::get_asset_history,
|
||||
auth::require_bearer_auth,
|
||||
character_animation_assets::{
|
||||
generate_character_animation, get_character_animation_job, get_character_workflow_cache,
|
||||
import_character_animation_video, list_character_animation_templates,
|
||||
generate_character_animation, generate_editor_character_animation,
|
||||
get_character_animation_job, get_character_workflow_cache, import_character_animation_video,
|
||||
list_character_animation_templates,
|
||||
publish_character_animation, put_role_asset_workflow, resolve_role_asset_workflow,
|
||||
save_character_workflow_cache,
|
||||
},
|
||||
@@ -452,6 +453,10 @@ fn play_flow_support_router(state: AppState) -> Router<AppState> {
|
||||
"/api/assets/character-animation/generate",
|
||||
post(generate_character_animation),
|
||||
)
|
||||
.route(
|
||||
"/api/editor/character-animations/generations",
|
||||
post(generate_editor_character_animation),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-animation/jobs/{task_id}",
|
||||
get(get_character_animation_job),
|
||||
|
||||
@@ -3,7 +3,7 @@ use platform_image::{
|
||||
DownloadedImage, GeneratedImages, PlatformImageError, PlatformImageStatusHint, ReferenceImage,
|
||||
VECTOR_ENGINE_PROVIDER, VectorEngineImageSettings, build_vector_engine_image_http_client,
|
||||
create_vector_engine_image_edit, create_vector_engine_image_edit_with_references,
|
||||
create_vector_engine_image_generation,
|
||||
create_vector_engine_image_edit_with_references_and_model, create_vector_engine_image_generation,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use platform_image::{
|
||||
@@ -236,6 +236,49 @@ pub(crate) async fn create_openai_image_edit_with_references(
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn create_openai_image_edit_with_references_and_model(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &OpenAiImageSettings,
|
||||
model: &str,
|
||||
prompt: &str,
|
||||
negative_prompt: Option<&str>,
|
||||
size: &str,
|
||||
candidate_count: u32,
|
||||
reference_images: &[OpenAiReferenceImage],
|
||||
failure_context: &str,
|
||||
) -> Result<OpenAiGeneratedImages, AppError> {
|
||||
let started_at_micros = current_utc_micros();
|
||||
let request_payload = json!({
|
||||
"model": model,
|
||||
"size": size,
|
||||
"promptChars": prompt.chars().count(),
|
||||
"negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count),
|
||||
"referenceImageCount": reference_images.len(),
|
||||
});
|
||||
let result = create_vector_engine_image_edit_with_references_and_model(
|
||||
http_client,
|
||||
&settings.provider_settings(),
|
||||
model,
|
||||
prompt,
|
||||
negative_prompt,
|
||||
size,
|
||||
candidate_count,
|
||||
reference_images,
|
||||
failure_context,
|
||||
)
|
||||
.await;
|
||||
map_platform_image_result(
|
||||
settings,
|
||||
result,
|
||||
"image_edit_with_references",
|
||||
failure_context,
|
||||
request_payload,
|
||||
started_at_micros,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn build_openai_image_request_body(
|
||||
prompt: &str,
|
||||
|
||||
@@ -16,7 +16,9 @@ pub use persist::{
|
||||
};
|
||||
pub use prompt::{GeneratedAssetSheetPromptInput, build_generated_asset_sheet_prompt};
|
||||
pub use sheet::{
|
||||
GeneratedAssetSheetSliceImage, crop_generated_asset_sheet_view_edge_matte,
|
||||
GeneratedAssetSheetConnectedIcon, GeneratedAssetSheetSliceImage,
|
||||
crop_generated_asset_sheet_view_edge_matte,
|
||||
crop_generated_asset_sheet_view_edge_matte_with_options, slice_generated_asset_sheet,
|
||||
slice_generated_asset_sheet_two_items_per_row,
|
||||
slice_generated_icon_spritesheet_by_connected_components,
|
||||
};
|
||||
|
||||
@@ -132,6 +132,25 @@ pub fn slice_generated_asset_sheet_two_items_per_row(
|
||||
Ok(slices)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct GeneratedAssetSheetConnectedIcon {
|
||||
pub name: String,
|
||||
pub bytes: Vec<u8>,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
pub fn slice_generated_icon_spritesheet_by_connected_components(
|
||||
image: &crate::DownloadedImage,
|
||||
icon_names: &[String],
|
||||
) -> Result<Vec<GeneratedAssetSheetConnectedIcon>, GeneratedAssetSheetError> {
|
||||
let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| {
|
||||
GeneratedAssetSheetError::decode_image(format!("图标 spritesheet 解码失败:{error}"))
|
||||
})?;
|
||||
let source = apply_generated_asset_sheet_green_screen_alpha(source);
|
||||
slice_generated_icon_spritesheet_rgba_by_connected_components(source, icon_names)
|
||||
}
|
||||
|
||||
pub fn crop_generated_asset_sheet_view_edge_matte(
|
||||
image: image::DynamicImage,
|
||||
) -> image::DynamicImage {
|
||||
@@ -141,6 +160,207 @@ pub fn crop_generated_asset_sheet_view_edge_matte(
|
||||
)
|
||||
}
|
||||
|
||||
fn slice_generated_icon_spritesheet_rgba_by_connected_components(
|
||||
source: image::DynamicImage,
|
||||
icon_names: &[String],
|
||||
) -> Result<Vec<GeneratedAssetSheetConnectedIcon>, GeneratedAssetSheetError> {
|
||||
let image = source.to_rgba8();
|
||||
let (width, height) = image.dimensions();
|
||||
let pixel_count = (width as usize).saturating_mul(height as usize);
|
||||
if pixel_count == 0 {
|
||||
return Err(GeneratedAssetSheetError::invalid_request(
|
||||
"图标 spritesheet 尺寸为空。",
|
||||
));
|
||||
}
|
||||
|
||||
let mut visited = vec![false; pixel_count];
|
||||
let mut components = Vec::<GeneratedAssetSheetCellBounds>::new();
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let pixel_index = (y as usize)
|
||||
.saturating_mul(width as usize)
|
||||
.saturating_add(x as usize);
|
||||
if visited[pixel_index] || image.get_pixel(x, y).0[3] == 0 {
|
||||
continue;
|
||||
}
|
||||
components.push(flood_fill_generated_icon_component(
|
||||
&image,
|
||||
&mut visited,
|
||||
width,
|
||||
height,
|
||||
x,
|
||||
y,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
components.sort_by_key(|bounds| (bounds.y0, bounds.x0));
|
||||
if components.len() < icon_names.len() {
|
||||
return Err(GeneratedAssetSheetError::invalid_request(format!(
|
||||
"图标 spritesheet 连通域数量不足:需要 {} 个,实际 {} 个。",
|
||||
icon_names.len(),
|
||||
components.len()
|
||||
)));
|
||||
}
|
||||
|
||||
let mut icons = Vec::with_capacity(icon_names.len());
|
||||
for (name, bounds) in icon_names.iter().zip(components.into_iter()) {
|
||||
let pad_x = (bounds.width() / 12).clamp(4, 16);
|
||||
let pad_y = (bounds.height() / 12).clamp(4, 16);
|
||||
let crop = GeneratedAssetSheetCellBounds {
|
||||
x0: bounds.x0.saturating_sub(pad_x),
|
||||
y0: bounds.y0.saturating_sub(pad_y),
|
||||
x1: bounds.x1.saturating_add(pad_x).min(width),
|
||||
y1: bounds.y1.saturating_add(pad_y).min(height),
|
||||
};
|
||||
let cropped = image::imageops::crop_imm(
|
||||
&image,
|
||||
crop.x0,
|
||||
crop.y0,
|
||||
crop.width(),
|
||||
crop.height(),
|
||||
)
|
||||
.to_image();
|
||||
let mut cursor = std::io::Cursor::new(Vec::new());
|
||||
image::DynamicImage::ImageRgba8(cropped)
|
||||
.write_to(&mut cursor, ImageFormat::Png)
|
||||
.map_err(|error| {
|
||||
GeneratedAssetSheetError::encode_image(format!(
|
||||
"图标 spritesheet 切割失败:{error}"
|
||||
))
|
||||
})?;
|
||||
icons.push(GeneratedAssetSheetConnectedIcon {
|
||||
name: name.clone(),
|
||||
bytes: cursor.into_inner(),
|
||||
width: crop.width(),
|
||||
height: crop.height(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(icons)
|
||||
}
|
||||
|
||||
fn flood_fill_generated_icon_component(
|
||||
image: &image::RgbaImage,
|
||||
visited: &mut [bool],
|
||||
width: u32,
|
||||
height: u32,
|
||||
start_x: u32,
|
||||
start_y: u32,
|
||||
) -> GeneratedAssetSheetCellBounds {
|
||||
let mut queue = vec![(start_x, start_y)];
|
||||
let mut queue_index = 0usize;
|
||||
let start_index = (start_y as usize)
|
||||
.saturating_mul(width as usize)
|
||||
.saturating_add(start_x as usize);
|
||||
visited[start_index] = true;
|
||||
let mut bounds = GeneratedAssetSheetCellBounds {
|
||||
x0: start_x,
|
||||
y0: start_y,
|
||||
x1: start_x.saturating_add(1),
|
||||
y1: start_y.saturating_add(1),
|
||||
};
|
||||
|
||||
while queue_index < queue.len() {
|
||||
let (x, y) = queue[queue_index];
|
||||
queue_index += 1;
|
||||
bounds.x0 = bounds.x0.min(x);
|
||||
bounds.y0 = bounds.y0.min(y);
|
||||
bounds.x1 = bounds.x1.max(x.saturating_add(1));
|
||||
bounds.y1 = bounds.y1.max(y.saturating_add(1));
|
||||
|
||||
for next_y in y.saturating_sub(1)..=(y.saturating_add(1).min(height.saturating_sub(1))) {
|
||||
for next_x in x.saturating_sub(1)..=(x.saturating_add(1).min(width.saturating_sub(1))) {
|
||||
if next_x == x && next_y == y {
|
||||
continue;
|
||||
}
|
||||
let next_index = (next_y as usize)
|
||||
.saturating_mul(width as usize)
|
||||
.saturating_add(next_x as usize);
|
||||
if visited[next_index] || image.get_pixel(next_x, next_y).0[3] == 0 {
|
||||
continue;
|
||||
}
|
||||
visited[next_index] = true;
|
||||
queue.push((next_x, next_y));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bounds
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use image::{ImageBuffer, Rgba};
|
||||
|
||||
fn encode_png(image: image::RgbaImage) -> Vec<u8> {
|
||||
let mut cursor = std::io::Cursor::new(Vec::new());
|
||||
image::DynamicImage::ImageRgba8(image)
|
||||
.write_to(&mut cursor, ImageFormat::Png)
|
||||
.expect("png should encode");
|
||||
cursor.into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slices_icon_spritesheet_by_connected_components_in_reading_order() {
|
||||
let mut sheet: image::RgbaImage =
|
||||
ImageBuffer::from_pixel(96, 64, Rgba([0, 255, 0, 255]));
|
||||
for y in 10..24 {
|
||||
for x in 12..28 {
|
||||
sheet.put_pixel(x, y, Rgba([240, 80, 80, 255]));
|
||||
}
|
||||
}
|
||||
for y in 32..46 {
|
||||
for x in 52..70 {
|
||||
sheet.put_pixel(x, y, Rgba([80, 120, 240, 255]));
|
||||
}
|
||||
}
|
||||
|
||||
let source = crate::DownloadedImage {
|
||||
bytes: encode_png(sheet),
|
||||
mime_type: "image/png".to_string(),
|
||||
extension: "png".to_string(),
|
||||
};
|
||||
let icons = slice_generated_icon_spritesheet_by_connected_components(
|
||||
&source,
|
||||
&["返回按钮".to_string(), "设置按钮".to_string()],
|
||||
)
|
||||
.expect("icons should slice");
|
||||
|
||||
assert_eq!(icons.len(), 2);
|
||||
assert_eq!(icons[0].name, "返回按钮");
|
||||
assert_eq!(icons[1].name, "设置按钮");
|
||||
assert!(icons[0].width >= 16);
|
||||
assert!(icons[0].height >= 14);
|
||||
assert!(image::load_from_memory(icons[0].bytes.as_slice()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_when_connected_components_are_fewer_than_icon_names() {
|
||||
let mut sheet: image::RgbaImage =
|
||||
ImageBuffer::from_pixel(48, 48, Rgba([0, 255, 0, 255]));
|
||||
for y in 12..24 {
|
||||
for x in 12..24 {
|
||||
sheet.put_pixel(x, y, Rgba([240, 80, 80, 255]));
|
||||
}
|
||||
}
|
||||
let source = crate::DownloadedImage {
|
||||
bytes: encode_png(sheet),
|
||||
mime_type: "image/png".to_string(),
|
||||
extension: "png".to_string(),
|
||||
};
|
||||
|
||||
let error = slice_generated_icon_spritesheet_by_connected_components(
|
||||
&source,
|
||||
&["返回按钮".to_string(), "设置按钮".to_string()],
|
||||
)
|
||||
.expect_err("missing component should fail");
|
||||
|
||||
assert!(error.to_string().contains("连通域数量不足"));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn crop_generated_asset_sheet_view_edge_matte_with_options(
|
||||
image: image::DynamicImage,
|
||||
options: GeneratedAssetSheetAlphaOptions,
|
||||
|
||||
@@ -8,6 +8,7 @@ pub use vector_engine::{
|
||||
VECTOR_ENGINE_GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER, VectorEngineImageSettings,
|
||||
build_vector_engine_image_http_client, build_vector_engine_image_request_body,
|
||||
create_vector_engine_image_edit, create_vector_engine_image_edit_with_references,
|
||||
create_vector_engine_image_generation, download_remote_image, vector_engine_images_edit_url,
|
||||
vector_engine_images_generation_url,
|
||||
create_vector_engine_image_edit_with_references_and_model,
|
||||
create_vector_engine_image_generation, create_vector_engine_image_generation_with_model,
|
||||
download_remote_image, vector_engine_images_edit_url, vector_engine_images_generation_url,
|
||||
};
|
||||
|
||||
@@ -13,8 +13,10 @@ use super::{
|
||||
error::PlatformImageError,
|
||||
image_source::resolve_reference_images,
|
||||
request::{
|
||||
build_vector_engine_image_edit_request_log_params, build_vector_engine_image_request_body,
|
||||
normalize_image_size, vector_engine_images_edit_url, vector_engine_images_generation_url,
|
||||
build_vector_engine_image_edit_request_log_params,
|
||||
build_vector_engine_image_request_body_with_model, normalize_image_size,
|
||||
normalize_vector_engine_image_model, vector_engine_images_edit_url,
|
||||
vector_engine_images_generation_url,
|
||||
},
|
||||
response::handle_vector_engine_response,
|
||||
types::{GeneratedImages, ReferenceImage, VectorEngineImageSettings},
|
||||
@@ -31,12 +33,40 @@ pub async fn create_vector_engine_image_generation(
|
||||
reference_images: &[String],
|
||||
failure_context: &str,
|
||||
) -> Result<GeneratedImages, PlatformImageError> {
|
||||
create_vector_engine_image_generation_with_model(
|
||||
http_client,
|
||||
settings,
|
||||
GPT_IMAGE_2_MODEL,
|
||||
prompt,
|
||||
negative_prompt,
|
||||
size,
|
||||
candidate_count,
|
||||
reference_images,
|
||||
failure_context,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn create_vector_engine_image_generation_with_model(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &VectorEngineImageSettings,
|
||||
model: &str,
|
||||
prompt: &str,
|
||||
negative_prompt: Option<&str>,
|
||||
size: &str,
|
||||
candidate_count: u32,
|
||||
reference_images: &[String],
|
||||
failure_context: &str,
|
||||
) -> Result<GeneratedImages, PlatformImageError> {
|
||||
let model = normalize_vector_engine_image_model(model);
|
||||
if !reference_images.is_empty() {
|
||||
let resolved_references =
|
||||
resolve_reference_images(http_client, reference_images, failure_context).await?;
|
||||
return create_vector_engine_image_edit_with_references(
|
||||
return create_vector_engine_image_edit_with_references_and_model(
|
||||
http_client,
|
||||
settings,
|
||||
model,
|
||||
prompt,
|
||||
negative_prompt,
|
||||
size,
|
||||
@@ -49,7 +79,8 @@ pub async fn create_vector_engine_image_generation(
|
||||
|
||||
let request_url = vector_engine_images_generation_url(settings);
|
||||
let normalized_size = normalize_image_size(size);
|
||||
let request_body = build_vector_engine_image_request_body(
|
||||
let request_body = build_vector_engine_image_request_body_with_model(
|
||||
model,
|
||||
prompt,
|
||||
negative_prompt,
|
||||
normalized_size.as_str(),
|
||||
@@ -125,6 +156,7 @@ pub async fn create_vector_engine_image_generation(
|
||||
provider = VECTOR_ENGINE_PROVIDER,
|
||||
endpoint = %request_url,
|
||||
status = response_status,
|
||||
image_model = model,
|
||||
prompt_chars = prompt.chars().count(),
|
||||
size = %normalized_size,
|
||||
reference_image_count = reference_images.len(),
|
||||
@@ -181,6 +213,33 @@ pub async fn create_vector_engine_image_edit_with_references(
|
||||
reference_images: &[ReferenceImage],
|
||||
failure_context: &str,
|
||||
) -> Result<GeneratedImages, PlatformImageError> {
|
||||
create_vector_engine_image_edit_with_references_and_model(
|
||||
http_client,
|
||||
settings,
|
||||
GPT_IMAGE_2_MODEL,
|
||||
prompt,
|
||||
negative_prompt,
|
||||
size,
|
||||
candidate_count,
|
||||
reference_images,
|
||||
failure_context,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn create_vector_engine_image_edit_with_references_and_model(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &VectorEngineImageSettings,
|
||||
model: &str,
|
||||
prompt: &str,
|
||||
negative_prompt: Option<&str>,
|
||||
size: &str,
|
||||
candidate_count: u32,
|
||||
reference_images: &[ReferenceImage],
|
||||
failure_context: &str,
|
||||
) -> Result<GeneratedImages, PlatformImageError> {
|
||||
let model = normalize_vector_engine_image_model(model);
|
||||
if reference_images.is_empty() {
|
||||
return Err(PlatformImageError::InvalidRequest {
|
||||
provider: VECTOR_ENGINE_PROVIDER,
|
||||
@@ -191,6 +250,7 @@ pub async fn create_vector_engine_image_edit_with_references(
|
||||
let request_url = vector_engine_images_edit_url(settings);
|
||||
let normalized_size = normalize_image_size(size);
|
||||
let request_params = build_vector_engine_image_edit_request_log_params(
|
||||
model,
|
||||
prompt,
|
||||
negative_prompt,
|
||||
normalized_size.as_str(),
|
||||
@@ -208,7 +268,7 @@ pub async fn create_vector_engine_image_edit_with_references(
|
||||
tracing::info!(
|
||||
provider = VECTOR_ENGINE_PROVIDER,
|
||||
endpoint = %request_url,
|
||||
image_model = GPT_IMAGE_2_MODEL,
|
||||
image_model = model,
|
||||
size = %normalized_size,
|
||||
candidate_count = candidate_count.clamp(1, 4),
|
||||
requested_candidate_count = candidate_count,
|
||||
@@ -230,6 +290,7 @@ pub async fn create_vector_engine_image_edit_with_references(
|
||||
match send_vector_engine_multipart_edit_request_with_curl(
|
||||
request_url.as_str(),
|
||||
settings.api_key.as_str(),
|
||||
model,
|
||||
prompt,
|
||||
negative_prompt,
|
||||
normalized_size.as_str(),
|
||||
|
||||
@@ -8,7 +8,7 @@ use serde_json::Value;
|
||||
|
||||
use super::{
|
||||
audit::build_failure_audit,
|
||||
constants::{GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER},
|
||||
constants::VECTOR_ENGINE_PROVIDER,
|
||||
error::PlatformImageError,
|
||||
request::build_prompt_with_negative,
|
||||
types::ReferenceImage,
|
||||
@@ -115,6 +115,7 @@ pub(crate) async fn send_vector_engine_json_request_with_curl(
|
||||
pub(crate) async fn send_vector_engine_multipart_edit_request_with_curl(
|
||||
request_url: &str,
|
||||
api_key: &str,
|
||||
model: &str,
|
||||
prompt: &str,
|
||||
negative_prompt: Option<&str>,
|
||||
normalized_size: &str,
|
||||
@@ -124,6 +125,7 @@ pub(crate) async fn send_vector_engine_multipart_edit_request_with_curl(
|
||||
) -> Result<VectorEngineCurlResponse, VectorEngineCurlError> {
|
||||
let request_url = request_url.to_string();
|
||||
let api_key = api_key.to_string();
|
||||
let model = model.to_string();
|
||||
let prompt = prompt.to_string();
|
||||
let negative_prompt = negative_prompt.map(str::to_string);
|
||||
let normalized_size = normalized_size.to_string();
|
||||
@@ -132,6 +134,7 @@ pub(crate) async fn send_vector_engine_multipart_edit_request_with_curl(
|
||||
send_multipart_edit_request_with_curl_blocking(
|
||||
request_url.as_str(),
|
||||
api_key.as_str(),
|
||||
model.as_str(),
|
||||
prompt.as_str(),
|
||||
negative_prompt.as_deref(),
|
||||
normalized_size.as_str(),
|
||||
@@ -230,6 +233,7 @@ fn send_json_request_with_curl_blocking(
|
||||
fn send_multipart_edit_request_with_curl_blocking(
|
||||
request_url: &str,
|
||||
api_key: &str,
|
||||
model: &str,
|
||||
prompt: &str,
|
||||
negative_prompt: Option<&str>,
|
||||
normalized_size: &str,
|
||||
@@ -239,7 +243,7 @@ fn send_multipart_edit_request_with_curl_blocking(
|
||||
) -> Result<VectorEngineCurlResponse, VectorEngineCurlError> {
|
||||
let mut form = Form::new();
|
||||
form.part("model")
|
||||
.contents(GPT_IMAGE_2_MODEL.as_bytes())
|
||||
.contents(model.as_bytes())
|
||||
.add()?;
|
||||
form.part("prompt")
|
||||
.contents(build_prompt_with_negative(prompt, negative_prompt).as_bytes())
|
||||
@@ -295,7 +299,7 @@ fn perform_curl_request(mut easy: Easy) -> Result<VectorEngineCurlResponse, curl
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::vector_engine::types::ReferenceImage;
|
||||
use crate::vector_engine::{constants::GPT_IMAGE_2_MODEL, types::ReferenceImage};
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
net::TcpListener,
|
||||
@@ -330,6 +334,7 @@ mod tests {
|
||||
let response = send_vector_engine_multipart_edit_request_with_curl(
|
||||
format!("{base_url}/v1/images/edits").as_str(),
|
||||
"test-key",
|
||||
GPT_IMAGE_2_MODEL,
|
||||
"测试提示词",
|
||||
None,
|
||||
"1024x1024",
|
||||
|
||||
@@ -14,14 +14,15 @@ mod util;
|
||||
pub use audit::PlatformImageFailureAudit;
|
||||
pub use client::{
|
||||
create_vector_engine_image_edit, create_vector_engine_image_edit_with_references,
|
||||
create_vector_engine_image_generation,
|
||||
create_vector_engine_image_edit_with_references_and_model,
|
||||
create_vector_engine_image_generation, create_vector_engine_image_generation_with_model,
|
||||
};
|
||||
pub use constants::{GPT_IMAGE_2_MODEL, VECTOR_ENGINE_GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER};
|
||||
pub use error::{PlatformImageError, PlatformImageStatusHint};
|
||||
pub use image_source::download_remote_image;
|
||||
pub use request::{
|
||||
build_vector_engine_image_request_body, normalize_image_size, vector_engine_images_edit_url,
|
||||
vector_engine_images_generation_url,
|
||||
build_vector_engine_image_request_body, build_vector_engine_image_request_body_with_model,
|
||||
normalize_image_size, vector_engine_images_edit_url, vector_engine_images_generation_url,
|
||||
};
|
||||
pub use transport::build_vector_engine_image_http_client;
|
||||
pub use types::{DownloadedImage, GeneratedImages, ReferenceImage, VectorEngineImageSettings};
|
||||
|
||||
@@ -12,10 +12,29 @@ pub fn build_vector_engine_image_request_body(
|
||||
candidate_count: u32,
|
||||
_reference_images: &[String],
|
||||
) -> Value {
|
||||
build_vector_engine_image_request_body_with_model(
|
||||
GPT_IMAGE_2_MODEL,
|
||||
prompt,
|
||||
negative_prompt,
|
||||
size,
|
||||
candidate_count,
|
||||
_reference_images,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_vector_engine_image_request_body_with_model(
|
||||
model: &str,
|
||||
prompt: &str,
|
||||
negative_prompt: Option<&str>,
|
||||
size: &str,
|
||||
candidate_count: u32,
|
||||
_reference_images: &[String],
|
||||
) -> Value {
|
||||
let model = normalize_vector_engine_image_model(model);
|
||||
let body = Map::from_iter([
|
||||
(
|
||||
"model".to_string(),
|
||||
Value::String(GPT_IMAGE_2_MODEL.to_string()),
|
||||
Value::String(model.to_string()),
|
||||
),
|
||||
(
|
||||
"prompt".to_string(),
|
||||
@@ -31,11 +50,20 @@ pub fn build_vector_engine_image_request_body(
|
||||
Value::Object(body)
|
||||
}
|
||||
|
||||
pub fn normalize_vector_engine_image_model(model: &str) -> &str {
|
||||
match model.trim() {
|
||||
"" => GPT_IMAGE_2_MODEL,
|
||||
value => value,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_image_size(size: &str) -> String {
|
||||
match size.trim() {
|
||||
"1024*1024" | "1024x1024" | "1:1" => "1024x1024",
|
||||
"1280*720" | "1280x720" | "1600*900" | "1600x900" | "16:9" | "1536x1024" | "2048x1152"
|
||||
| "2k" => "1536x1024",
|
||||
"1280*720" | "1280x720" | "1600*900" | "1600x900" | "16:9" | "1536x1024" | "2k" => {
|
||||
"1536x1024"
|
||||
}
|
||||
"1920*1080" | "1920x1080" | "2048*1152" | "2048x1152" | "2k-16:9" => "2048x1152",
|
||||
"1024*1536" | "1024x1536" | "9:16" => "1024x1536",
|
||||
value if !value.is_empty() => value,
|
||||
_ => "1024x1024",
|
||||
@@ -60,12 +88,14 @@ pub fn vector_engine_images_edit_url(settings: &VectorEngineImageSettings) -> St
|
||||
}
|
||||
|
||||
pub(crate) fn build_vector_engine_image_edit_request_log_params(
|
||||
model: &str,
|
||||
prompt: &str,
|
||||
negative_prompt: Option<&str>,
|
||||
size: &str,
|
||||
candidate_count: u32,
|
||||
reference_images: &[ReferenceImage],
|
||||
) -> Value {
|
||||
let model = normalize_vector_engine_image_model(model);
|
||||
let prompt = prompt.trim();
|
||||
let negative_prompt = negative_prompt
|
||||
.map(str::trim)
|
||||
@@ -91,7 +121,7 @@ pub(crate) fn build_vector_engine_image_edit_request_log_params(
|
||||
.sum();
|
||||
|
||||
json!({
|
||||
"model": GPT_IMAGE_2_MODEL,
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"negativePrompt": negative_prompt.unwrap_or_default(),
|
||||
"promptChars": prompt.chars().count(),
|
||||
@@ -125,6 +155,7 @@ mod tests {
|
||||
#[test]
|
||||
fn edit_request_log_params_include_reference_image_sizes_without_secrets_or_bytes() {
|
||||
let params = build_vector_engine_image_edit_request_log_params(
|
||||
GPT_IMAGE_2_MODEL,
|
||||
" 拼图参考图重绘 ",
|
||||
Some(" 文字,水印 "),
|
||||
"1024x1024",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use platform_image::vector_engine::{
|
||||
GPT_IMAGE_2_MODEL, ReferenceImage, VECTOR_ENGINE_PROVIDER, VectorEngineImageSettings,
|
||||
build_vector_engine_image_http_client, build_vector_engine_image_request_body,
|
||||
create_vector_engine_image_edit, create_vector_engine_image_generation,
|
||||
build_vector_engine_image_request_body_with_model, create_vector_engine_image_edit,
|
||||
create_vector_engine_image_generation,
|
||||
vector_engine_images_edit_url, vector_engine_images_generation_url,
|
||||
};
|
||||
use std::{
|
||||
@@ -43,6 +44,31 @@ fn vector_engine_module_exposes_provider_protocol_helpers() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_engine_normalizes_2k_landscape_spec_size() {
|
||||
let body = build_vector_engine_image_request_body("生成规范图", None, "2048x1152", 1, &[]);
|
||||
|
||||
assert_eq!(body["model"], GPT_IMAGE_2_MODEL);
|
||||
assert_eq!(body["size"], "2048x1152");
|
||||
assert_eq!(body["n"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_engine_request_body_can_use_nanobanana2_model() {
|
||||
let body = build_vector_engine_image_request_body_with_model(
|
||||
"gemini-3.1-flash-image-preview",
|
||||
"生成图标 spritesheet",
|
||||
None,
|
||||
"512x512",
|
||||
1,
|
||||
&[],
|
||||
);
|
||||
|
||||
assert_eq!(body["model"], "gemini-3.1-flash-image-preview");
|
||||
assert_eq!(body["size"], "512x512");
|
||||
assert_eq!(body["n"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn vector_engine_image_edit_retries_send_timeout_once_and_succeeds() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
|
||||
@@ -304,6 +304,48 @@ pub struct CharacterAnimationGenerateResponse {
|
||||
pub preview_video_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorCharacterAnimationGenerateRequest {
|
||||
pub source_layer_id: String,
|
||||
pub source_image_src: String,
|
||||
pub source_width: u32,
|
||||
pub source_height: u32,
|
||||
pub prompt_text: String,
|
||||
pub resolution: String,
|
||||
pub ratio: String,
|
||||
pub frame_count: u32,
|
||||
pub duration_seconds: u32,
|
||||
pub price_mud_points: u32,
|
||||
pub model: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorCharacterAnimationFramePayload {
|
||||
pub frame_index: u32,
|
||||
pub image_src: String,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorCharacterAnimationGenerateResponse {
|
||||
pub ok: bool,
|
||||
pub task_id: String,
|
||||
pub model: String,
|
||||
pub prompt: String,
|
||||
pub preview_video_path: String,
|
||||
pub frames: Vec<EditorCharacterAnimationFramePayload>,
|
||||
pub frame_count: u32,
|
||||
pub duration_seconds: u32,
|
||||
pub frame_width: u32,
|
||||
pub frame_height: u32,
|
||||
pub fps: u32,
|
||||
pub price_mud_points: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterAnimationDraftPayload {
|
||||
@@ -815,6 +857,57 @@ mod tests {
|
||||
assert_eq!(payload["draftId"], json!("animation-import-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_character_animation_request_uses_expected_camel_case_shape() {
|
||||
let payload = serde_json::to_value(EditorCharacterAnimationGenerateRequest {
|
||||
source_layer_id: "layer-1".to_string(),
|
||||
source_image_src: "/generated-characters/hero/master.png".to_string(),
|
||||
source_width: 768,
|
||||
source_height: 1024,
|
||||
prompt_text: "待机呼吸".to_string(),
|
||||
resolution: "720p".to_string(),
|
||||
ratio: "same".to_string(),
|
||||
frame_count: 48,
|
||||
duration_seconds: 6,
|
||||
price_mud_points: 120,
|
||||
model: "seedance2.0".to_string(),
|
||||
})
|
||||
.expect("request should serialize");
|
||||
|
||||
assert_eq!(payload["sourceLayerId"], json!("layer-1"));
|
||||
assert_eq!(payload["sourceImageSrc"], json!("/generated-characters/hero/master.png"));
|
||||
assert_eq!(payload["priceMudPoints"], json!(120));
|
||||
assert_eq!(payload["model"], json!("seedance2.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_character_animation_response_includes_frames_and_preview_video() {
|
||||
let payload = serde_json::to_value(EditorCharacterAnimationGenerateResponse {
|
||||
ok: true,
|
||||
task_id: "task-1".to_string(),
|
||||
model: "seedance2.0".to_string(),
|
||||
prompt: "生成游戏角色动画".to_string(),
|
||||
preview_video_path: "/generated-character-drafts/editor/layer/preview.mp4".to_string(),
|
||||
frames: vec![EditorCharacterAnimationFramePayload {
|
||||
frame_index: 1,
|
||||
image_src: "/generated-animations/editor/layer/frame01.png".to_string(),
|
||||
width: 768,
|
||||
height: 1024,
|
||||
}],
|
||||
frame_count: 1,
|
||||
duration_seconds: 4,
|
||||
frame_width: 768,
|
||||
frame_height: 1024,
|
||||
fps: 8,
|
||||
price_mud_points: 40,
|
||||
})
|
||||
.expect("response should serialize");
|
||||
|
||||
assert_eq!(payload["previewVideoPath"], json!("/generated-character-drafts/editor/layer/preview.mp4"));
|
||||
assert_eq!(payload["frames"][0]["imageSrc"], json!("/generated-animations/editor/layer/frame01.png"));
|
||||
assert_eq!(payload["fps"], json!(8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_workflow_cache_response_keeps_legacy_shape() {
|
||||
let payload = serde_json::to_value(CharacterWorkflowCacheSaveResponse {
|
||||
|
||||
Reference in New Issue
Block a user