Merge remote-tracking branch 'origin/codex/editor-asset-library' into codex/editor-asset-library
This commit is contained in:
@@ -42,7 +42,8 @@ use shared_contracts::assets::{
|
||||
CharacterVisualDraftPayload, CharacterWorkflowCacheGetResponse, CharacterWorkflowCachePayload,
|
||||
CharacterWorkflowCacheSaveRequest, CharacterWorkflowCacheSaveResponse,
|
||||
EditorCharacterAnimationFramePayload, EditorCharacterAnimationGenerateRequest,
|
||||
EditorCharacterAnimationGenerateResponse,
|
||||
EditorCharacterAnimationGenerateResponse, EditorVideoGenerateRequest,
|
||||
EditorVideoGenerateResponse,
|
||||
};
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
|
||||
@@ -87,6 +88,15 @@ 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 EDITOR_VIDEO_ASSET_KIND: &str = "editor_video";
|
||||
const EDITOR_VIDEO_ENTITY_KIND: &str = "editor_canvas";
|
||||
const EDITOR_VIDEO_SLOT: &str = "video_preview";
|
||||
const EDITOR_VIDEO_MODEL_SEEDANCE_2: &str = "seedance2.0";
|
||||
const EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST: &str = "seedance2.0-fast";
|
||||
const EDITOR_VIDEO_MODEL_KLING_3: &str = "kling3.0";
|
||||
const EDITOR_VIDEO_MODEL_KLING_3_OMNI: &str = "kling3.0-omni";
|
||||
const EDITOR_VIDEO_MODEL_VEO_3_1: &str = "veo3.1";
|
||||
const EDITOR_VIDEO_MODEL_VEO_3_1_FAST: &str = "veo3.1-fast";
|
||||
|
||||
const BUILT_IN_MOTION_TEMPLATES: [MotionTemplate; 4] = [
|
||||
MotionTemplate {
|
||||
@@ -575,6 +585,63 @@ pub async fn generate_editor_character_animation(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn generate_editor_video(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
payload: Result<Json<EditorVideoGenerateRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = payload.map_err(|error| {
|
||||
editor_video_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "editor-video",
|
||||
"message": error.body_text(),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let normalized =
|
||||
normalize_editor_video_request(payload).map_err(|error| {
|
||||
editor_video_error_response(&request_context, error)
|
||||
})?;
|
||||
let settings = require_editor_video_settings(&state, normalized.model.as_str()).map_err(
|
||||
|error| editor_video_error_response(&request_context, error),
|
||||
)?;
|
||||
let http_client = build_upstream_http_client(settings.ark.request_timeout_ms)
|
||||
.map_err(|error| editor_video_error_response(&request_context, error))?;
|
||||
let task_id = generate_ai_task_id(current_utc_micros());
|
||||
|
||||
let generated = request_editor_video_preview(
|
||||
&state,
|
||||
&http_client,
|
||||
&settings,
|
||||
"editor-video",
|
||||
task_id.as_str(),
|
||||
&normalized,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| editor_video_error_response(&request_context, error))?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
EditorVideoGenerateResponse {
|
||||
ok: true,
|
||||
video_src: generated.preview_video_path,
|
||||
width: normalized.width,
|
||||
height: normalized.height,
|
||||
source_type: "generated".to_string(),
|
||||
prompt: normalized.prompt,
|
||||
actual_prompt: Some(generated.submitted_prompt),
|
||||
model: normalized.model,
|
||||
provider: "VectorEngine".to_string(),
|
||||
task_id,
|
||||
duration_seconds: normalized.duration_seconds,
|
||||
resolution: normalized.resolution,
|
||||
price_mud_points: normalized.price_mud_points,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_character_animation_job(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -1371,6 +1438,114 @@ async fn request_editor_character_animation_preview(
|
||||
})
|
||||
}
|
||||
|
||||
async fn request_editor_video_preview(
|
||||
state: &AppState,
|
||||
http_client: &reqwest::Client,
|
||||
settings: &EditorVideoSettings,
|
||||
owner_user_id: &str,
|
||||
task_id: &str,
|
||||
request: &NormalizedEditorVideoRequest,
|
||||
) -> Result<GeneratedAnimationPreview, AppError> {
|
||||
let upstream_task_id =
|
||||
create_editor_text_to_video_task(http_client, settings, request).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_editor_video(state, owner_user_id, task_id, request, preview_payload).await?;
|
||||
|
||||
Ok(GeneratedAnimationPreview {
|
||||
preview_video_path,
|
||||
upstream_task_id,
|
||||
submitted_prompt: request.prompt.clone(),
|
||||
moderation_fallback_applied: false,
|
||||
})
|
||||
}
|
||||
|
||||
async fn create_editor_text_to_video_task(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &EditorVideoSettings,
|
||||
request: &NormalizedEditorVideoRequest,
|
||||
) -> 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": request.provider_model,
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": request.prompt,
|
||||
}
|
||||
],
|
||||
"resolution": request.resolution,
|
||||
"ratio": request.aspect_ratio,
|
||||
"duration": request.duration_seconds,
|
||||
"mode": "std",
|
||||
"watermark": false,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
map_character_animation_upstream_error(format!("请求画板视频服务失败:{error}"))
|
||||
})?;
|
||||
|
||||
let status = response.status();
|
||||
let body = response.text().await.map_err(|error| {
|
||||
map_character_animation_upstream_error(format!("读取画板视频任务响应失败:{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": "editor-video",
|
||||
"message": "画板视频任务未返回任务 id。",
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
async fn put_generated_editor_video(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
task_id: &str,
|
||||
request: &NormalizedEditorVideoRequest,
|
||||
preview_payload: MediaPayload,
|
||||
) -> Result<String, AppError> {
|
||||
let put_result = put_character_animation_object(
|
||||
state,
|
||||
LegacyAssetPrefix::CharacterDrafts,
|
||||
vec![
|
||||
"editor-videos".to_string(),
|
||||
sanitize_storage_segment(request.model.as_str(), "model"),
|
||||
task_id.to_string(),
|
||||
],
|
||||
format!("preview.{}", preview_payload.extension),
|
||||
preview_payload.mime_type,
|
||||
preview_payload.bytes,
|
||||
build_asset_metadata(
|
||||
EDITOR_VIDEO_ASSET_KIND,
|
||||
owner_user_id,
|
||||
EDITOR_VIDEO_ENTITY_KIND,
|
||||
task_id,
|
||||
EDITOR_VIDEO_SLOT,
|
||||
request.model.as_str(),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
Ok(put_result.legacy_public_path)
|
||||
}
|
||||
|
||||
async fn create_editor_ark_image_to_video_task(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &EditorCharacterAnimationSettings,
|
||||
@@ -2131,7 +2306,10 @@ fn normalize_editor_character_animation_request(
|
||||
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);
|
||||
let expected_price = crate::editor_generation_config::editor_character_animation_mud_points(
|
||||
resolution,
|
||||
duration_seconds,
|
||||
);
|
||||
if payload.price_mud_points != expected_price {
|
||||
return Err(editor_character_animation_bad_request(format!(
|
||||
"priceMudPoints 与分辨率和时长不一致,应为 {expected_price}。"
|
||||
@@ -2212,6 +2390,145 @@ fn require_editor_character_animation_settings(
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_editor_video_request(
|
||||
payload: EditorVideoGenerateRequest,
|
||||
) -> Result<NormalizedEditorVideoRequest, AppError> {
|
||||
let prompt = payload.prompt.trim().chars().take(4000).collect::<String>();
|
||||
if prompt.is_empty() {
|
||||
return Err(editor_video_bad_request("视频描述不能为空。"));
|
||||
}
|
||||
let model = normalize_editor_video_model(payload.model.as_str())?;
|
||||
let aspect_ratio = normalize_editor_video_aspect_ratio(payload.aspect_ratio.as_str())?;
|
||||
let resolution = normalize_editor_video_resolution(payload.resolution.as_str())?;
|
||||
let duration_seconds = normalize_editor_video_duration(payload.duration_seconds)?;
|
||||
if payload.mode.trim() != "std" {
|
||||
return Err(editor_video_bad_request("mode 只支持 std。"));
|
||||
}
|
||||
if payload.sound.trim() != "off" {
|
||||
return Err(editor_video_bad_request("sound 只支持 off。"));
|
||||
}
|
||||
let expected_price = crate::editor_generation_config::editor_video_generation_mud_points(
|
||||
resolution,
|
||||
duration_seconds,
|
||||
);
|
||||
if payload.price_mud_points != expected_price {
|
||||
return Err(editor_video_bad_request(format!(
|
||||
"priceMudPoints 与分辨率和时长不一致,应为 {expected_price}。"
|
||||
)));
|
||||
}
|
||||
let (width, height) = resolve_editor_video_size(aspect_ratio, resolution);
|
||||
|
||||
Ok(NormalizedEditorVideoRequest {
|
||||
prompt,
|
||||
model: model.to_string(),
|
||||
provider_model: resolve_editor_video_provider_model(model).to_string(),
|
||||
aspect_ratio: aspect_ratio.to_string(),
|
||||
resolution: resolution.to_string(),
|
||||
duration_seconds,
|
||||
price_mud_points: expected_price,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_editor_video_model(value: &str) -> Result<&'static str, AppError> {
|
||||
match value.trim() {
|
||||
EDITOR_VIDEO_MODEL_SEEDANCE_2 => Ok(EDITOR_VIDEO_MODEL_SEEDANCE_2),
|
||||
EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST => Ok(EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST),
|
||||
EDITOR_VIDEO_MODEL_KLING_3 => Ok(EDITOR_VIDEO_MODEL_KLING_3),
|
||||
EDITOR_VIDEO_MODEL_KLING_3_OMNI => Ok(EDITOR_VIDEO_MODEL_KLING_3_OMNI),
|
||||
EDITOR_VIDEO_MODEL_VEO_3_1 => Ok(EDITOR_VIDEO_MODEL_VEO_3_1),
|
||||
EDITOR_VIDEO_MODEL_VEO_3_1_FAST => Ok(EDITOR_VIDEO_MODEL_VEO_3_1_FAST),
|
||||
_ => Err(editor_video_bad_request(
|
||||
"model 只支持 seedance2.0、seedance2.0-fast、kling3.0、kling3.0-omni、veo3.1、veo3.1-fast。",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_editor_video_provider_model(model: &str) -> &str {
|
||||
match model {
|
||||
// 中文注释:Seedance 2.0 Fast 复用平台已有 fast seedance 上游模型;标准版按产品 ID 透传。
|
||||
EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST => CHARACTER_ANIMATION_MODEL,
|
||||
_ => model,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_editor_video_aspect_ratio(value: &str) -> Result<&'static str, AppError> {
|
||||
match value.trim() {
|
||||
"16:9" => Ok("16:9"),
|
||||
_ => Err(editor_video_bad_request("aspectRatio 只支持 16:9。")),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_editor_video_resolution(value: &str) -> Result<&'static str, AppError> {
|
||||
match value.trim() {
|
||||
"480p" => Ok("480p"),
|
||||
"720p" => Ok("720p"),
|
||||
_ => Err(editor_video_bad_request("resolution 只支持 480p 或 720p。")),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_editor_video_duration(value: u32) -> Result<u32, AppError> {
|
||||
match value {
|
||||
4 | 5 => Ok(value),
|
||||
_ => Err(editor_video_bad_request("durationSeconds 只支持 4 或 5。")),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_editor_video_size(aspect_ratio: &str, resolution: &str) -> (u32, u32) {
|
||||
match (aspect_ratio, resolution) {
|
||||
("16:9", "720p") => (1280, 720),
|
||||
_ => (854, 480),
|
||||
}
|
||||
}
|
||||
|
||||
fn require_editor_video_settings(
|
||||
state: &AppState,
|
||||
model: &str,
|
||||
) -> Result<EditorVideoSettings, 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 未配置",
|
||||
}))
|
||||
})?;
|
||||
|
||||
Ok(EditorVideoSettings {
|
||||
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: resolve_editor_video_provider_model(model).to_string(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn editor_video_bad_request(message: impl Into<String>) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "editor-video",
|
||||
"message": message.into(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn build_editor_character_animation_prompt(prompt_text: &str) -> String {
|
||||
format!(
|
||||
"{}\n{}",
|
||||
@@ -2272,11 +2589,6 @@ fn normalize_editor_character_animation_duration(
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -3792,6 +4104,10 @@ fn character_animation_error_response(
|
||||
error.into_response_with_context(Some(request_context))
|
||||
}
|
||||
|
||||
fn editor_video_error_response(request_context: &RequestContext, error: AppError) -> Response {
|
||||
error.into_response_with_context(Some(request_context))
|
||||
}
|
||||
|
||||
pub(crate) struct MotionTemplate {
|
||||
pub(crate) id: &'static str,
|
||||
pub(crate) label: &'static str,
|
||||
@@ -3836,6 +4152,10 @@ struct EditorCharacterAnimationSettings {
|
||||
duration_seconds: u32,
|
||||
}
|
||||
|
||||
struct EditorVideoSettings {
|
||||
ark: ArkVideoSettings,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct NormalizedEditorCharacterAnimationRequest {
|
||||
source_layer_id: String,
|
||||
@@ -3851,6 +4171,19 @@ struct NormalizedEditorCharacterAnimationRequest {
|
||||
fps: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct NormalizedEditorVideoRequest {
|
||||
prompt: String,
|
||||
model: String,
|
||||
provider_model: String,
|
||||
aspect_ratio: String,
|
||||
resolution: String,
|
||||
duration_seconds: u32,
|
||||
price_mud_points: u32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
|
||||
struct GeneratedAnimationPreview {
|
||||
preview_video_path: String,
|
||||
upstream_task_id: String,
|
||||
@@ -4112,7 +4445,71 @@ mod tests {
|
||||
|
||||
#[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);
|
||||
assert_eq!(
|
||||
crate::editor_generation_config::editor_character_animation_mud_points("480p", 4),
|
||||
40
|
||||
);
|
||||
assert_eq!(
|
||||
crate::editor_generation_config::editor_character_animation_mud_points("720p", 6),
|
||||
120
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_video_normalizes_lovart_model_contract() {
|
||||
let normalized = normalize_editor_video_request(EditorVideoGenerateRequest {
|
||||
prompt: " 让角色向镜头挥手 ".to_string(),
|
||||
model: EDITOR_VIDEO_MODEL_KLING_3_OMNI.to_string(),
|
||||
aspect_ratio: "16:9".to_string(),
|
||||
duration_seconds: 5,
|
||||
resolution: "480p".to_string(),
|
||||
mode: "std".to_string(),
|
||||
sound: "off".to_string(),
|
||||
price_mud_points: 50,
|
||||
})
|
||||
.expect("editor video request should normalize");
|
||||
|
||||
assert_eq!(normalized.prompt, "让角色向镜头挥手");
|
||||
assert_eq!(normalized.model, EDITOR_VIDEO_MODEL_KLING_3_OMNI);
|
||||
assert_eq!(normalized.provider_model, EDITOR_VIDEO_MODEL_KLING_3_OMNI);
|
||||
assert_eq!(normalized.width, 854);
|
||||
assert_eq!(normalized.height, 480);
|
||||
assert_eq!(normalized.price_mud_points, 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_video_seedance_fast_uses_existing_fast_seedance_model() {
|
||||
let normalized = normalize_editor_video_request(EditorVideoGenerateRequest {
|
||||
prompt: "快速生成镜头推进。".to_string(),
|
||||
model: EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST.to_string(),
|
||||
aspect_ratio: "16:9".to_string(),
|
||||
duration_seconds: 4,
|
||||
resolution: "720p".to_string(),
|
||||
mode: "std".to_string(),
|
||||
sound: "off".to_string(),
|
||||
price_mud_points: 80,
|
||||
})
|
||||
.expect("seedance fast request should normalize");
|
||||
|
||||
assert_eq!(normalized.provider_model, CHARACTER_ANIMATION_MODEL);
|
||||
assert_eq!(normalized.width, 1280);
|
||||
assert_eq!(normalized.height, 720);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_video_rejects_price_mismatch() {
|
||||
let error = normalize_editor_video_request(EditorVideoGenerateRequest {
|
||||
prompt: "生成镜头。".to_string(),
|
||||
model: EDITOR_VIDEO_MODEL_VEO_3_1.to_string(),
|
||||
aspect_ratio: "16:9".to_string(),
|
||||
duration_seconds: 5,
|
||||
resolution: "480p".to_string(),
|
||||
mode: "std".to_string(),
|
||||
sound: "off".to_string(),
|
||||
price_mud_points: 40,
|
||||
})
|
||||
.expect_err("wrong price should fail");
|
||||
|
||||
assert!(error.body_text().contains("priceMudPoints"));
|
||||
}
|
||||
}
|
||||
|
||||
72
server-rs/crates/api-server/src/editor_generation_config.rs
Normal file
72
server-rs/crates/api-server/src/editor_generation_config.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
/// 图片画布编辑器生成类能力的泥点配置。
|
||||
///
|
||||
/// 中文注释:先用 api-server 静态配置收口价格事实源,避免继续把价格散落在
|
||||
/// 前端常量和具体 handler 内;后续若接后台配置,可只替换本模块读取来源。
|
||||
const EDITOR_IMAGE_GENERATION_MUD_POINTS: u32 = 12;
|
||||
const EDITOR_SPEC_GENERATION_MUD_POINTS: u32 = 5;
|
||||
const EDITOR_CHARACTER_IMAGE_GENERATION_MUD_POINTS: u32 = 12;
|
||||
const EDITOR_ICON_SPRITESHEET_GENERATION_MUD_POINTS: u32 = 12;
|
||||
const EDITOR_UI_DESIGN_GENERATION_MUD_POINTS: u32 = 12;
|
||||
|
||||
pub(crate) const EDITOR_VIDEO_GENERATION_480P_MUD_POINTS_PER_SECOND: u32 = 10;
|
||||
pub(crate) const EDITOR_VIDEO_GENERATION_720P_MUD_POINTS_PER_SECOND: u32 = 20;
|
||||
|
||||
pub(crate) const EDITOR_CHARACTER_ANIMATION_480P_MUD_POINTS_PER_SECOND: u32 = 10;
|
||||
pub(crate) const EDITOR_CHARACTER_ANIMATION_720P_MUD_POINTS_PER_SECOND: u32 = 20;
|
||||
|
||||
pub(crate) fn editor_image_generation_mud_points(kind: Option<&str>) -> u32 {
|
||||
match kind.map(str::trim) {
|
||||
Some("spec") => EDITOR_SPEC_GENERATION_MUD_POINTS,
|
||||
Some("character") => EDITOR_CHARACTER_IMAGE_GENERATION_MUD_POINTS,
|
||||
Some("icon") => EDITOR_ICON_SPRITESHEET_GENERATION_MUD_POINTS,
|
||||
Some("ui-design") => EDITOR_UI_DESIGN_GENERATION_MUD_POINTS,
|
||||
_ => EDITOR_IMAGE_GENERATION_MUD_POINTS,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn editor_video_generation_mud_points(resolution: &str, duration_seconds: u32) -> u32 {
|
||||
let per_second = if resolution == "720p" {
|
||||
EDITOR_VIDEO_GENERATION_720P_MUD_POINTS_PER_SECOND
|
||||
} else {
|
||||
EDITOR_VIDEO_GENERATION_480P_MUD_POINTS_PER_SECOND
|
||||
};
|
||||
per_second * duration_seconds
|
||||
}
|
||||
|
||||
pub(crate) fn editor_character_animation_mud_points(
|
||||
resolution: &str,
|
||||
duration_seconds: u32,
|
||||
) -> u32 {
|
||||
let per_second = if resolution == "720p" {
|
||||
EDITOR_CHARACTER_ANIMATION_720P_MUD_POINTS_PER_SECOND
|
||||
} else {
|
||||
EDITOR_CHARACTER_ANIMATION_480P_MUD_POINTS_PER_SECOND
|
||||
};
|
||||
per_second * duration_seconds
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn editor_character_animation_price_uses_configured_resolution_rates() {
|
||||
assert_eq!(editor_character_animation_mud_points("480p", 4), 40);
|
||||
assert_eq!(editor_character_animation_mud_points("720p", 6), 120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_video_generation_price_uses_configured_resolution_rates() {
|
||||
assert_eq!(editor_video_generation_mud_points("480p", 4), 40);
|
||||
assert_eq!(editor_video_generation_mud_points("720p", 5), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_image_generation_price_uses_configured_kind_rates() {
|
||||
assert_eq!(editor_image_generation_mud_points(None), 12);
|
||||
assert_eq!(editor_image_generation_mud_points(Some("spec")), 5);
|
||||
assert_eq!(editor_image_generation_mud_points(Some("character")), 12);
|
||||
assert_eq!(editor_image_generation_mud_points(Some("icon")), 12);
|
||||
assert_eq!(editor_image_generation_mud_points(Some("ui-design")), 12);
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,8 @@ use crate::{
|
||||
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,
|
||||
create_openai_image_edit_with_references_and_model,
|
||||
create_openai_image_generation_with_model, create_openai_nanobanana_generate_content,
|
||||
require_openai_image_settings,
|
||||
},
|
||||
platform_errors::map_oss_error,
|
||||
@@ -57,9 +58,7 @@ 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_IMAGE_MODEL_NANOBANANA2: &str = "gemini-3.1-flash-image-preview";
|
||||
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";
|
||||
@@ -155,6 +154,8 @@ pub struct EditorImageGenerationRequest {
|
||||
size: Option<String>,
|
||||
kind: Option<String>,
|
||||
model: Option<String>,
|
||||
aspect_ratio: Option<String>,
|
||||
image_size: Option<String>,
|
||||
reference_image_srcs: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
@@ -171,6 +172,8 @@ pub struct EditorIconSpritesheetGenerationRequest {
|
||||
reference_image_src: String,
|
||||
icon_descriptions: Vec<String>,
|
||||
model: Option<String>,
|
||||
aspect_ratio: Option<String>,
|
||||
image_size: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -238,7 +241,7 @@ pub struct EditorImageGenerationResponse {
|
||||
source_type: &'static str,
|
||||
prompt: String,
|
||||
actual_prompt: Option<String>,
|
||||
model: &'static str,
|
||||
model: String,
|
||||
provider: &'static str,
|
||||
task_id: String,
|
||||
}
|
||||
@@ -757,12 +760,33 @@ 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 generation_options = normalize_editor_generation_options(
|
||||
payload.model.as_deref(),
|
||||
payload.aspect_ratio.as_deref(),
|
||||
payload.image_size.as_deref(),
|
||||
);
|
||||
let has_dimension_options = payload.aspect_ratio.is_some() || payload.image_size.is_some();
|
||||
let legacy_size = normalize_editor_image_generation_size(payload.size.as_deref());
|
||||
let image_size = if has_dimension_options {
|
||||
Cow::Owned(generation_options.size.clone())
|
||||
} else {
|
||||
legacy_size
|
||||
};
|
||||
let normalized_kind = payload.kind.as_deref().map(str::trim);
|
||||
let _configured_price_mud_points =
|
||||
crate::editor_generation_config::editor_image_generation_mud_points(normalized_kind);
|
||||
let is_character_generation = matches!(normalized_kind, Some("character"));
|
||||
let is_ui_design_generation = matches!(normalized_kind, Some("ui-design"));
|
||||
let submitted_prompt = if is_character_generation {
|
||||
build_editor_character_image_prompt(role_setting.as_str())
|
||||
} else if is_ui_design_generation {
|
||||
build_editor_ui_design_prompt(
|
||||
role_setting.as_str(),
|
||||
payload
|
||||
.reference_image_srcs
|
||||
.as_ref()
|
||||
.is_some_and(|references| references.iter().any(|source| !source.trim().is_empty())),
|
||||
)
|
||||
} else {
|
||||
role_setting.clone()
|
||||
};
|
||||
@@ -770,6 +794,7 @@ pub async fn generate_editor_image(
|
||||
Some("character") => "图片画布生成角色形象",
|
||||
Some("spec") => "图片画布生成规范",
|
||||
Some("quick-edit") => "图片画布快速编辑图片",
|
||||
Some("ui-design") => "图片画布生成UI设计图",
|
||||
_ => "图片画布生成图片",
|
||||
};
|
||||
let reference_sources = payload
|
||||
@@ -787,10 +812,46 @@ pub async fn generate_editor_image(
|
||||
);
|
||||
let http_client = build_openai_image_http_client(&settings)?;
|
||||
let negative_prompt = Some("文字、水印、边框、按钮、UI 控件、低清晰度、变形主体");
|
||||
let generated = if reference_sources.is_empty() {
|
||||
create_openai_image_generation(
|
||||
let reference_images = if reference_sources.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
reference_sources
|
||||
.iter()
|
||||
.map(|source| parse_editor_reference_image(source.as_str()))
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
};
|
||||
let generation_options = if is_ui_design_generation {
|
||||
normalize_editor_generation_options(
|
||||
Some(GPT_IMAGE_2_MODEL),
|
||||
payload.aspect_ratio.as_deref(),
|
||||
payload.image_size.as_deref(),
|
||||
)
|
||||
} else {
|
||||
generation_options
|
||||
};
|
||||
let image_size = if is_ui_design_generation {
|
||||
Cow::Owned(generation_options.size.clone())
|
||||
} else {
|
||||
image_size
|
||||
};
|
||||
let generated = if generation_options.model == EDITOR_IMAGE_MODEL_NANOBANANA2 {
|
||||
create_openai_nanobanana_generate_content(
|
||||
&http_client,
|
||||
&settings,
|
||||
generation_options.model,
|
||||
submitted_prompt.as_str(),
|
||||
negative_prompt,
|
||||
generation_options.aspect_ratio,
|
||||
generation_options.provider_image_size,
|
||||
reference_images.as_slice(),
|
||||
failure_context,
|
||||
)
|
||||
.await?
|
||||
} else if reference_images.is_empty() {
|
||||
create_openai_image_generation_with_model(
|
||||
&http_client,
|
||||
&settings,
|
||||
generation_options.model,
|
||||
submitted_prompt.as_str(),
|
||||
negative_prompt,
|
||||
image_size.as_ref(),
|
||||
@@ -800,13 +861,10 @@ pub async fn generate_editor_image(
|
||||
)
|
||||
.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(
|
||||
create_openai_image_edit_with_references_and_model(
|
||||
&http_client,
|
||||
&settings,
|
||||
generation_options.model,
|
||||
submitted_prompt.as_str(),
|
||||
negative_prompt,
|
||||
image_size.as_ref(),
|
||||
@@ -863,7 +921,7 @@ pub async fn generate_editor_image(
|
||||
source_type: "generated",
|
||||
prompt: role_setting,
|
||||
actual_prompt: generated.actual_prompt,
|
||||
model: GPT_IMAGE_2_MODEL,
|
||||
model: generation_options.model.to_string(),
|
||||
provider: "VectorEngine",
|
||||
task_id: generated.task_id,
|
||||
},
|
||||
@@ -895,6 +953,96 @@ fn is_editor_custom_image_size(value: &str) -> bool {
|
||||
(64..=4096).contains(&width) && (64..=4096).contains(&height)
|
||||
}
|
||||
|
||||
fn normalize_editor_generation_options(
|
||||
model: Option<&str>,
|
||||
aspect_ratio: Option<&str>,
|
||||
image_size: Option<&str>,
|
||||
) -> EditorGenerationOptions {
|
||||
let normalized_model = match model.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
Some(GPT_IMAGE_2_MODEL) => GPT_IMAGE_2_MODEL,
|
||||
Some(EDITOR_IMAGE_MODEL_NANOBANANA2) => EDITOR_IMAGE_MODEL_NANOBANANA2,
|
||||
// 中文注释:未显式传模型的旧普通生成、快速编辑和生成规范继续走 gpt-image-2;
|
||||
// 角色 / 图标素材入口由前端显式传入 nanobanana2 默认值。
|
||||
None => GPT_IMAGE_2_MODEL,
|
||||
_ => EDITOR_IMAGE_MODEL_NANOBANANA2,
|
||||
};
|
||||
let aspect_ratio = normalize_editor_generation_aspect_ratio(aspect_ratio);
|
||||
let image_size = normalize_editor_generation_image_size(normalized_model, image_size);
|
||||
let size = editor_generation_size_for_model(normalized_model, aspect_ratio, image_size);
|
||||
let provider_image_size =
|
||||
editor_generation_provider_image_size_for_model(normalized_model, image_size);
|
||||
|
||||
EditorGenerationOptions {
|
||||
model: normalized_model,
|
||||
aspect_ratio,
|
||||
image_size,
|
||||
size,
|
||||
provider_image_size,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_editor_generation_aspect_ratio(aspect_ratio: Option<&str>) -> &'static str {
|
||||
match aspect_ratio
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
Some("2:3") => "2:3",
|
||||
Some("3:2") => "3:2",
|
||||
Some("9:16") => "9:16",
|
||||
Some("16:9") => "16:9",
|
||||
_ => "1:1",
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_editor_generation_image_size(model: &str, image_size: Option<&str>) -> &'static str {
|
||||
match (
|
||||
model,
|
||||
image_size.map(str::trim).filter(|value| !value.is_empty()),
|
||||
) {
|
||||
(EDITOR_IMAGE_MODEL_NANOBANANA2, Some("0.5K")) => "0.5K",
|
||||
(_, Some("2K")) => "2K",
|
||||
_ => "1K",
|
||||
}
|
||||
}
|
||||
|
||||
fn editor_generation_size_for_model(model: &str, aspect_ratio: &str, image_size: &str) -> String {
|
||||
if model == EDITOR_IMAGE_MODEL_NANOBANANA2 {
|
||||
return match image_size {
|
||||
// 中文注释:VectorEngine 的 nanobanana2 文档要求 0.5K 传入 512。
|
||||
"0.5K" => "512",
|
||||
"2K" => "2048",
|
||||
_ => "1024",
|
||||
}
|
||||
.to_string();
|
||||
}
|
||||
|
||||
match (image_size, aspect_ratio) {
|
||||
("2K", "1:1") => "2048x2048",
|
||||
// 中文注释:gpt-image-2 文档未列出 2K 竖版,竖版选择回落到文档明确支持的 1K 竖版。
|
||||
("2K", "2:3") | ("2K", "9:16") => "1024x1536",
|
||||
("2K", "16:9") | ("2K", "3:2") => "2048x1152",
|
||||
("1K", "2:3") | ("1K", "9:16") => "1024x1536",
|
||||
("1K", "3:2") | ("1K", "16:9") => "1536x1024",
|
||||
_ => "1024x1024",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn editor_generation_provider_image_size_for_model(
|
||||
model: &str,
|
||||
image_size: &'static str,
|
||||
) -> &'static str {
|
||||
if model == EDITOR_IMAGE_MODEL_NANOBANANA2 {
|
||||
return match image_size {
|
||||
"0.5K" => "512",
|
||||
"2K" => "2K",
|
||||
_ => "1K",
|
||||
};
|
||||
}
|
||||
|
||||
image_size
|
||||
}
|
||||
|
||||
pub async fn edit_editor_image(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -955,7 +1103,7 @@ pub async fn edit_editor_image(
|
||||
source_type: "generated",
|
||||
prompt,
|
||||
actual_prompt: generated.actual_prompt,
|
||||
model: GPT_IMAGE_2_MODEL,
|
||||
model: GPT_IMAGE_2_MODEL.to_string(),
|
||||
provider: "VectorEngine",
|
||||
task_id: generated.task_id,
|
||||
},
|
||||
@@ -977,14 +1125,12 @@ pub async fn generate_editor_icon_spritesheet(
|
||||
"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 generation_options = normalize_editor_generation_options(
|
||||
payload.model.as_deref(),
|
||||
payload.aspect_ratio.as_deref(),
|
||||
payload.image_size.as_deref(),
|
||||
);
|
||||
let size = generation_options.size.as_str();
|
||||
let prompt = build_editor_icon_spritesheet_prompt(&icon_descriptions);
|
||||
|
||||
let settings = require_openai_image_settings(&state)?.with_external_api_audit_context(
|
||||
@@ -993,18 +1139,33 @@ pub async fn generate_editor_icon_spritesheet(
|
||||
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 generated = if generation_options.model == EDITOR_IMAGE_MODEL_NANOBANANA2 {
|
||||
create_openai_nanobanana_generate_content(
|
||||
&http_client,
|
||||
&settings,
|
||||
generation_options.model,
|
||||
prompt.as_str(),
|
||||
None,
|
||||
generation_options.aspect_ratio,
|
||||
generation_options.provider_image_size,
|
||||
&[reference_image],
|
||||
"图片画布生成图标素材 spritesheet",
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
create_openai_image_edit_with_references_and_model(
|
||||
&http_client,
|
||||
&settings,
|
||||
generation_options.model,
|
||||
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",
|
||||
@@ -1045,7 +1206,7 @@ pub async fn generate_editor_icon_spritesheet(
|
||||
icon_image_srcs,
|
||||
prompt,
|
||||
actual_prompt: generated.actual_prompt,
|
||||
model,
|
||||
model: generation_options.model.to_string(),
|
||||
provider: "VectorEngine",
|
||||
task_id: generated.task_id,
|
||||
},
|
||||
@@ -1249,6 +1410,17 @@ fn build_editor_icon_spritesheet_prompt(icon_descriptions: &[String]) -> String
|
||||
)
|
||||
}
|
||||
|
||||
fn build_editor_ui_design_prompt(user_input: &str, has_icon_spec_reference: bool) -> String {
|
||||
let mut prompt = vec![
|
||||
"生成玩法UI原型图".to_string(),
|
||||
format!("【用户输入】{}", user_input.trim()),
|
||||
];
|
||||
if has_icon_spec_reference {
|
||||
prompt.push("参考图1为图标素材规范,请在UI图标、按钮符号、描边、材质、圆角、阴影和状态层级上严格遵循参考图1的素材规范。".to_string());
|
||||
}
|
||||
prompt.join("\n")
|
||||
}
|
||||
|
||||
fn build_editor_character_image_prompt(role_setting: &str) -> String {
|
||||
vec![
|
||||
"严格基于图1的角色美术视觉规范指导中的美术风格、角色头身比、角色朝向等特征生成游戏角色形象图。画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。背景固定为纯绿色绿幕,只作为抠像底色,禁止生成美术视觉规范、出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。".to_string(),
|
||||
@@ -1295,14 +1467,6 @@ fn prepare_editor_character_image_for_response(
|
||||
}
|
||||
}
|
||||
|
||||
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,{}",
|
||||
@@ -1322,6 +1486,15 @@ fn editor_icon_response_from_slice(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct EditorGenerationOptions {
|
||||
model: &'static str,
|
||||
aspect_ratio: &'static str,
|
||||
image_size: &'static str,
|
||||
size: String,
|
||||
provider_image_size: &'static str,
|
||||
}
|
||||
|
||||
struct PersistedEditorGeneratedImage {
|
||||
object_key: String,
|
||||
asset_object_id: String,
|
||||
@@ -1548,6 +1721,68 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_generation_dimensions_follow_model_options() {
|
||||
let default_generation = normalize_editor_generation_options(None, Some("1:1"), Some("1K"));
|
||||
assert_eq!(default_generation.model, GPT_IMAGE_2_MODEL);
|
||||
assert_eq!(default_generation.size, "1024x1024");
|
||||
|
||||
let nanobanana = normalize_editor_generation_options(
|
||||
Some(EDITOR_IMAGE_MODEL_NANOBANANA2),
|
||||
Some("1:1"),
|
||||
Some("0.5K"),
|
||||
);
|
||||
assert_eq!(nanobanana.model, EDITOR_IMAGE_MODEL_NANOBANANA2);
|
||||
assert_eq!(nanobanana.size, "512");
|
||||
assert_eq!(nanobanana.aspect_ratio, "1:1");
|
||||
assert_eq!(nanobanana.image_size, "0.5K");
|
||||
assert_eq!(nanobanana.provider_image_size, "512");
|
||||
|
||||
let gpt = normalize_editor_generation_options(Some("gpt-image-2"), Some("2:3"), Some("1K"));
|
||||
assert_eq!(gpt.model, GPT_IMAGE_2_MODEL);
|
||||
assert_eq!(gpt.size, "1024x1536");
|
||||
assert_eq!(gpt.aspect_ratio, "2:3");
|
||||
assert_eq!(gpt.image_size, "1K");
|
||||
assert_eq!(gpt.provider_image_size, "1K");
|
||||
|
||||
let gpt_landscape_2k =
|
||||
normalize_editor_generation_options(Some("gpt-image-2"), Some("16:9"), Some("2K"));
|
||||
assert_eq!(gpt_landscape_2k.model, GPT_IMAGE_2_MODEL);
|
||||
assert_eq!(gpt_landscape_2k.size, "2048x1152");
|
||||
assert_eq!(gpt_landscape_2k.aspect_ratio, "16:9");
|
||||
assert_eq!(gpt_landscape_2k.image_size, "2K");
|
||||
|
||||
let gpt_portrait_2k_fallback =
|
||||
normalize_editor_generation_options(Some("gpt-image-2"), Some("9:16"), Some("2K"));
|
||||
assert_eq!(gpt_portrait_2k_fallback.model, GPT_IMAGE_2_MODEL);
|
||||
assert_eq!(gpt_portrait_2k_fallback.size, "1024x1536");
|
||||
assert_eq!(gpt_portrait_2k_fallback.aspect_ratio, "9:16");
|
||||
assert_eq!(gpt_portrait_2k_fallback.image_size, "2K");
|
||||
|
||||
let fallback = normalize_editor_generation_options(
|
||||
Some("unknown-model"),
|
||||
Some("bad-ratio"),
|
||||
Some("bad-size"),
|
||||
);
|
||||
assert_eq!(fallback.model, EDITOR_IMAGE_MODEL_NANOBANANA2);
|
||||
assert_eq!(fallback.size, "1024");
|
||||
assert_eq!(fallback.aspect_ratio, "1:1");
|
||||
assert_eq!(fallback.image_size, "1K");
|
||||
assert_eq!(fallback.provider_image_size, "1K");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_ui_design_prompt_uses_fixed_user_input_block_and_optional_icon_spec() {
|
||||
let prompt = build_editor_ui_design_prompt("二消玩法,主界面和结算弹窗", true);
|
||||
|
||||
assert!(prompt.contains("生成玩法UI原型图"));
|
||||
assert!(prompt.contains("【用户输入】二消玩法,主界面和结算弹窗"));
|
||||
assert!(prompt.contains("参考图1为图标素材规范"));
|
||||
|
||||
let no_reference_prompt = build_editor_ui_design_prompt("只要战斗HUD", false);
|
||||
assert!(!no_reference_prompt.contains("参考图1为图标素材规范"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_character_image_prompt_appends_user_role_setting() {
|
||||
let prompt = build_editor_character_image_prompt("菜市场卖菜大妈");
|
||||
@@ -1631,8 +1866,6 @@ mod tests {
|
||||
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]
|
||||
|
||||
@@ -36,6 +36,7 @@ mod custom_world_asset_prompts;
|
||||
mod custom_world_foundation_draft;
|
||||
mod custom_world_result_prompts;
|
||||
mod custom_world_rpg_draft_prompts;
|
||||
mod editor_generation_config;
|
||||
mod editor_project;
|
||||
mod edutainment_baby_drawing;
|
||||
mod edutainment_baby_object;
|
||||
|
||||
@@ -16,7 +16,7 @@ use crate::{
|
||||
assets::get_asset_history,
|
||||
auth::require_bearer_auth,
|
||||
character_animation_assets::{
|
||||
generate_character_animation, generate_editor_character_animation,
|
||||
generate_character_animation, generate_editor_character_animation, generate_editor_video,
|
||||
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,
|
||||
@@ -461,6 +461,13 @@ fn play_flow_support_router(state: AppState) -> Router<AppState> {
|
||||
EDITOR_CHARACTER_ANIMATION_BODY_LIMIT_BYTES,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/editor/videos/generations",
|
||||
post(generate_editor_video).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-animation/jobs/{task_id}",
|
||||
get(get_character_animation_job),
|
||||
|
||||
@@ -3,7 +3,9 @@ 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_edit_with_references_and_model, 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,
|
||||
create_vector_engine_nanobanana_generate_content,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use platform_image::{
|
||||
@@ -159,6 +161,94 @@ pub(crate) async fn create_openai_image_generation(
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn create_openai_image_generation_with_model(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &OpenAiImageSettings,
|
||||
model: &str,
|
||||
prompt: &str,
|
||||
negative_prompt: Option<&str>,
|
||||
size: &str,
|
||||
candidate_count: u32,
|
||||
reference_images: &[String],
|
||||
failure_context: &str,
|
||||
) -> Result<OpenAiGeneratedImages, AppError> {
|
||||
let started_at_micros = current_utc_micros();
|
||||
let request_payload = json!({
|
||||
"model": model,
|
||||
"size": size,
|
||||
"candidateCount": candidate_count,
|
||||
"promptChars": prompt.chars().count(),
|
||||
"negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count),
|
||||
"referenceImageCount": reference_images.len(),
|
||||
});
|
||||
let result = create_vector_engine_image_generation_with_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_generation",
|
||||
failure_context,
|
||||
request_payload,
|
||||
started_at_micros,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn create_openai_nanobanana_generate_content(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &OpenAiImageSettings,
|
||||
model: &str,
|
||||
prompt: &str,
|
||||
negative_prompt: Option<&str>,
|
||||
aspect_ratio: &str,
|
||||
image_size: &str,
|
||||
reference_images: &[OpenAiReferenceImage],
|
||||
failure_context: &str,
|
||||
) -> Result<OpenAiGeneratedImages, AppError> {
|
||||
let started_at_micros = current_utc_micros();
|
||||
let request_payload = json!({
|
||||
"model": model,
|
||||
"aspectRatio": aspect_ratio,
|
||||
"imageSize": image_size,
|
||||
"promptChars": prompt.chars().count(),
|
||||
"negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count),
|
||||
"referenceImageCount": reference_images.len(),
|
||||
});
|
||||
let result = create_vector_engine_nanobanana_generate_content(
|
||||
http_client,
|
||||
&settings.provider_settings(),
|
||||
model,
|
||||
prompt,
|
||||
negative_prompt,
|
||||
aspect_ratio,
|
||||
image_size,
|
||||
reference_images,
|
||||
failure_context,
|
||||
)
|
||||
.await;
|
||||
map_platform_image_result(
|
||||
settings,
|
||||
result,
|
||||
"nanobanana_generate_content",
|
||||
failure_context,
|
||||
request_payload,
|
||||
started_at_micros,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn create_openai_image_edit(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &OpenAiImageSettings,
|
||||
|
||||
@@ -7,8 +7,11 @@ pub use vector_engine::{
|
||||
PlatformImageFailureAudit, PlatformImageStatusHint, ReferenceImage,
|
||||
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,
|
||||
build_vector_engine_nanobanana_generate_content_request_body, create_vector_engine_image_edit,
|
||||
create_vector_engine_image_edit_with_references,
|
||||
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,
|
||||
create_vector_engine_nanobanana_generate_content, download_remote_image,
|
||||
vector_engine_images_edit_url, vector_engine_images_generation_url,
|
||||
vector_engine_nanobanana_generate_content_url,
|
||||
};
|
||||
|
||||
@@ -14,9 +14,10 @@ use super::{
|
||||
image_source::resolve_reference_images,
|
||||
request::{
|
||||
build_vector_engine_image_edit_request_log_params,
|
||||
build_vector_engine_image_request_body_with_model, normalize_image_size,
|
||||
build_vector_engine_image_request_body_with_model,
|
||||
build_vector_engine_nanobanana_generate_content_request_body, normalize_image_size,
|
||||
normalize_vector_engine_image_model, vector_engine_images_edit_url,
|
||||
vector_engine_images_generation_url,
|
||||
vector_engine_images_generation_url, vector_engine_nanobanana_generate_content_url,
|
||||
},
|
||||
response::handle_vector_engine_response,
|
||||
types::{GeneratedImages, ReferenceImage, VectorEngineImageSettings},
|
||||
@@ -181,6 +182,144 @@ pub async fn create_vector_engine_image_generation_with_model(
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn create_vector_engine_nanobanana_generate_content(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &VectorEngineImageSettings,
|
||||
model: &str,
|
||||
prompt: &str,
|
||||
negative_prompt: Option<&str>,
|
||||
aspect_ratio: &str,
|
||||
image_size: &str,
|
||||
reference_images: &[ReferenceImage],
|
||||
failure_context: &str,
|
||||
) -> Result<GeneratedImages, PlatformImageError> {
|
||||
let model = normalize_vector_engine_image_model(model);
|
||||
let request_url = vector_engine_nanobanana_generate_content_url(settings, model);
|
||||
let request_body = build_vector_engine_nanobanana_generate_content_request_body(
|
||||
prompt,
|
||||
negative_prompt,
|
||||
aspect_ratio,
|
||||
image_size,
|
||||
reference_images,
|
||||
);
|
||||
let reference_image_count = reference_images.iter().take(14).count();
|
||||
let reference_image_bytes_total: usize = reference_images
|
||||
.iter()
|
||||
.take(14)
|
||||
.map(|image| image.bytes.len())
|
||||
.sum();
|
||||
let request_params = serde_json::json!({
|
||||
"model": model,
|
||||
"promptChars": prompt.trim().chars().count(),
|
||||
"negativePromptChars": negative_prompt
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::chars)
|
||||
.map(Iterator::count)
|
||||
.unwrap_or_default(),
|
||||
"aspectRatio": aspect_ratio,
|
||||
"imageSize": image_size,
|
||||
"referenceImageCount": reference_image_count,
|
||||
"referenceImageBytesTotal": reference_image_bytes_total,
|
||||
});
|
||||
let started_at = std::time::Instant::now();
|
||||
let mut attempt = 1;
|
||||
let response = loop {
|
||||
match send_vector_engine_json_request_with_curl(
|
||||
request_url.as_str(),
|
||||
settings.api_key.as_str(),
|
||||
&request_body,
|
||||
settings.request_timeout_ms,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
if should_retry_vector_engine_upstream_status(response.status, attempt) {
|
||||
retry_vector_engine_upstream_status_after_delay(
|
||||
"nanobanana_generate_content",
|
||||
request_url.as_str(),
|
||||
attempt,
|
||||
response.status,
|
||||
response.body.as_str(),
|
||||
started_at.elapsed().as_millis() as u64,
|
||||
Some(prompt.chars().count()),
|
||||
Some(reference_image_count),
|
||||
Some(&request_params),
|
||||
)
|
||||
.await;
|
||||
attempt += 1;
|
||||
continue;
|
||||
}
|
||||
break response;
|
||||
}
|
||||
Err(error) => {
|
||||
if should_retry_vector_engine_curl_send_error(&error, attempt) {
|
||||
retry_vector_engine_send_after_delay(
|
||||
"nanobanana_generate_content",
|
||||
request_url.as_str(),
|
||||
"request_send",
|
||||
attempt,
|
||||
error.is_timeout(),
|
||||
error.is_connect() || error.is_transient_transport(),
|
||||
true,
|
||||
false,
|
||||
error.to_string().as_str(),
|
||||
started_at.elapsed().as_millis() as u64,
|
||||
Some(prompt.chars().count()),
|
||||
Some(reference_image_count),
|
||||
Some(&request_params),
|
||||
)
|
||||
.await;
|
||||
attempt += 1;
|
||||
continue;
|
||||
}
|
||||
return Err(map_curl_error(
|
||||
format!("{failure_context}:创建 nanobanana2 图片生成任务失败").as_str(),
|
||||
request_url.as_str(),
|
||||
"request_send",
|
||||
error,
|
||||
started_at.elapsed().as_millis() as u64,
|
||||
Some(prompt.chars().count()),
|
||||
Some(reference_image_count),
|
||||
Some(&request_params),
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
let response_status = response.status;
|
||||
tracing::info!(
|
||||
provider = VECTOR_ENGINE_PROVIDER,
|
||||
endpoint = %request_url,
|
||||
status = response_status,
|
||||
image_model = model,
|
||||
prompt_chars = prompt.chars().count(),
|
||||
aspect_ratio,
|
||||
image_size,
|
||||
reference_image_count,
|
||||
reference_image_bytes_total,
|
||||
request_params = %request_params,
|
||||
attempt,
|
||||
elapsed_ms = started_at.elapsed().as_millis() as u64,
|
||||
failure_context,
|
||||
"VectorEngine nanobanana2 图片生成 HTTP 返回"
|
||||
);
|
||||
let response_text = response.body;
|
||||
handle_vector_engine_response(
|
||||
http_client,
|
||||
request_url.as_str(),
|
||||
response_status,
|
||||
response_text.as_str(),
|
||||
failure_context,
|
||||
started_at.elapsed().as_millis() as u64,
|
||||
Some(prompt.chars().count()),
|
||||
Some(reference_image_count),
|
||||
1,
|
||||
"vector-engine-nanobanana",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_vector_engine_image_edit(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &VectorEngineImageSettings,
|
||||
|
||||
@@ -16,13 +16,16 @@ pub use client::{
|
||||
create_vector_engine_image_edit, create_vector_engine_image_edit_with_references,
|
||||
create_vector_engine_image_edit_with_references_and_model,
|
||||
create_vector_engine_image_generation, create_vector_engine_image_generation_with_model,
|
||||
create_vector_engine_nanobanana_generate_content,
|
||||
};
|
||||
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, build_vector_engine_image_request_body_with_model,
|
||||
normalize_image_size, vector_engine_images_edit_url, vector_engine_images_generation_url,
|
||||
build_vector_engine_nanobanana_generate_content_request_body, normalize_image_size,
|
||||
vector_engine_images_edit_url, vector_engine_images_generation_url,
|
||||
vector_engine_nanobanana_generate_content_url,
|
||||
};
|
||||
pub use transport::build_vector_engine_image_http_client;
|
||||
pub use types::{DownloadedImage, GeneratedImages, ReferenceImage, VectorEngineImageSettings};
|
||||
|
||||
@@ -88,9 +88,48 @@ pub(super) fn extract_image_urls(payload: &Value) -> Vec<String> {
|
||||
pub(super) fn extract_b64_images(payload: &Value) -> Vec<String> {
|
||||
let mut values = Vec::new();
|
||||
collect_strings_by_key(payload, "b64_json", &mut values);
|
||||
collect_inline_image_data(payload, &mut values);
|
||||
values
|
||||
}
|
||||
|
||||
fn collect_inline_image_data(value: &Value, results: &mut Vec<String>) {
|
||||
match value {
|
||||
Value::Array(entries) => {
|
||||
for entry in entries {
|
||||
collect_inline_image_data(entry, results);
|
||||
}
|
||||
}
|
||||
Value::Object(object) => {
|
||||
for key in ["inlineData", "inline_data"] {
|
||||
if let Some(Value::Object(inline_data)) = object.get(key) {
|
||||
let mime_type = inline_data
|
||||
.get("mimeType")
|
||||
.or_else(|| inline_data.get("mime_type"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or("image/png")
|
||||
.to_ascii_lowercase();
|
||||
if !mime_type.is_empty() && !mime_type.starts_with("image/") {
|
||||
continue;
|
||||
}
|
||||
if let Some(data) = inline_data
|
||||
.get("data")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
results.push(data.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
for nested_value in object.values() {
|
||||
collect_inline_image_data(nested_value, results);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn parse_api_error_message(raw_text: &str, fallback_message: &str) -> String {
|
||||
if raw_text.trim().is_empty() {
|
||||
return fallback_message.to_string();
|
||||
|
||||
@@ -32,10 +32,7 @@ pub fn build_vector_engine_image_request_body_with_model(
|
||||
) -> Value {
|
||||
let model = normalize_vector_engine_image_model(model);
|
||||
let body = Map::from_iter([
|
||||
(
|
||||
"model".to_string(),
|
||||
Value::String(model.to_string()),
|
||||
),
|
||||
("model".to_string(), Value::String(model.to_string())),
|
||||
(
|
||||
"prompt".to_string(),
|
||||
Value::String(build_prompt_with_negative(prompt, negative_prompt)),
|
||||
@@ -50,6 +47,42 @@ pub fn build_vector_engine_image_request_body_with_model(
|
||||
Value::Object(body)
|
||||
}
|
||||
|
||||
pub fn build_vector_engine_nanobanana_generate_content_request_body(
|
||||
prompt: &str,
|
||||
negative_prompt: Option<&str>,
|
||||
aspect_ratio: &str,
|
||||
image_size: &str,
|
||||
reference_images: &[ReferenceImage],
|
||||
) -> Value {
|
||||
let prompt = build_prompt_with_negative(prompt, negative_prompt);
|
||||
let mut parts = vec![json!({ "text": prompt })];
|
||||
for reference_image in reference_images.iter().take(14) {
|
||||
parts.push(json!({
|
||||
"inline_data": {
|
||||
"mime_type": reference_image.mime_type,
|
||||
"data": base64::Engine::encode(
|
||||
&base64::engine::general_purpose::STANDARD,
|
||||
reference_image.bytes.as_slice()
|
||||
),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
json!({
|
||||
"contents": [{
|
||||
"role": "user",
|
||||
"parts": parts,
|
||||
}],
|
||||
"generationConfig": {
|
||||
"responseModalities": ["IMAGE"],
|
||||
"imageConfig": {
|
||||
"aspectRatio": normalize_nanobanana_aspect_ratio(aspect_ratio),
|
||||
"imageSize": normalize_nanobanana_image_size(image_size),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub fn normalize_vector_engine_image_model(model: &str) -> &str {
|
||||
match model.trim() {
|
||||
"" => GPT_IMAGE_2_MODEL,
|
||||
@@ -71,6 +104,31 @@ pub fn normalize_image_size(size: &str) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn normalize_nanobanana_aspect_ratio(aspect_ratio: &str) -> &str {
|
||||
match aspect_ratio.trim() {
|
||||
"2:3" => "2:3",
|
||||
"3:2" => "3:2",
|
||||
"3:4" => "3:4",
|
||||
"4:3" => "4:3",
|
||||
"4:5" => "4:5",
|
||||
"5:4" => "5:4",
|
||||
"9:16" => "9:16",
|
||||
"16:9" => "16:9",
|
||||
"21:9" => "21:9",
|
||||
_ => "1:1",
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_nanobanana_image_size(image_size: &str) -> &str {
|
||||
match image_size.trim() {
|
||||
// 中文注释:nanobanana / Gemini 3.1 的 0.5K 在 VectorEngine 文档中要求传 512。
|
||||
"512" | "0.5K" => "512",
|
||||
"2K" => "2K",
|
||||
"4K" => "4K",
|
||||
_ => "1K",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn vector_engine_images_generation_url(settings: &VectorEngineImageSettings) -> String {
|
||||
if settings.base_url.ends_with("/v1") {
|
||||
format!("{}/images/generations", settings.base_url)
|
||||
@@ -87,6 +145,21 @@ pub fn vector_engine_images_edit_url(settings: &VectorEngineImageSettings) -> St
|
||||
}
|
||||
}
|
||||
|
||||
pub fn vector_engine_nanobanana_generate_content_url(
|
||||
settings: &VectorEngineImageSettings,
|
||||
model: &str,
|
||||
) -> String {
|
||||
let base_url = settings
|
||||
.base_url
|
||||
.trim_end_matches("/v1")
|
||||
.trim_end_matches('/');
|
||||
format!(
|
||||
"{}/v1beta/models/{}:generateContent",
|
||||
base_url,
|
||||
normalize_vector_engine_image_model(model)
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn build_vector_engine_image_edit_request_log_params(
|
||||
model: &str,
|
||||
prompt: &str,
|
||||
|
||||
@@ -66,8 +66,10 @@ mod tests {
|
||||
assert_eq!(reference.mime_type, "image/png");
|
||||
assert_eq!(reference.bytes, b"\x89PNG\r\n\x1A\nrest");
|
||||
|
||||
let image = decode_generated_image_base64(BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nrest").as_str())
|
||||
.expect("base64 image should decode");
|
||||
let image = decode_generated_image_base64(
|
||||
BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nrest").as_str(),
|
||||
)
|
||||
.expect("base64 image should decode");
|
||||
assert_eq!(image.extension, "png");
|
||||
assert_eq!(image.mime_type, "image/png");
|
||||
assert_eq!(image.bytes, b"\x89PNG\r\n\x1A\nrest");
|
||||
@@ -121,10 +123,22 @@ mod tests {
|
||||
audit: Some(audit.clone()),
|
||||
};
|
||||
|
||||
assert_eq!(invalid_config.status_hint(), PlatformImageStatusHint::ServiceUnavailable);
|
||||
assert_eq!(invalid_request.status_hint(), PlatformImageStatusHint::BadRequest);
|
||||
assert_eq!(request_error.status_hint(), PlatformImageStatusHint::GatewayTimeout);
|
||||
assert_eq!(upstream_timeout.status_hint(), PlatformImageStatusHint::GatewayTimeout);
|
||||
assert_eq!(
|
||||
invalid_config.status_hint(),
|
||||
PlatformImageStatusHint::ServiceUnavailable
|
||||
);
|
||||
assert_eq!(
|
||||
invalid_request.status_hint(),
|
||||
PlatformImageStatusHint::BadRequest
|
||||
);
|
||||
assert_eq!(
|
||||
request_error.status_hint(),
|
||||
PlatformImageStatusHint::GatewayTimeout
|
||||
);
|
||||
assert_eq!(
|
||||
upstream_timeout.status_hint(),
|
||||
PlatformImageStatusHint::GatewayTimeout
|
||||
);
|
||||
assert_eq!(
|
||||
PlatformImageError::MissingImage {
|
||||
provider: VECTOR_ENGINE_PROVIDER,
|
||||
@@ -137,7 +151,10 @@ mod tests {
|
||||
|
||||
let audit_ref = upstream_timeout.audit().expect("audit should be preserved");
|
||||
assert_eq!(audit_ref.provider, VECTOR_ENGINE_PROVIDER);
|
||||
assert_eq!(audit_ref.endpoint, "https://vector.example/v1/images/generations");
|
||||
assert_eq!(
|
||||
audit_ref.endpoint,
|
||||
"https://vector.example/v1/images/generations"
|
||||
);
|
||||
assert_eq!(audit_ref.status_code, Some(504));
|
||||
assert_eq!(audit_ref.status_class, Some("5xx"));
|
||||
assert!(audit_ref.timeout);
|
||||
@@ -158,7 +175,27 @@ mod tests {
|
||||
{"url": "https://example.com/b.png"}
|
||||
],
|
||||
"nested": {
|
||||
"b64_json": ["YWJj", "ZGVm"]
|
||||
"b64_json": ["YWJj", "ZGVm"],
|
||||
"parts": [
|
||||
{
|
||||
"inlineData": {
|
||||
"mimeType": "image/png",
|
||||
"data": "aW1hZ2UtMQ=="
|
||||
}
|
||||
},
|
||||
{
|
||||
"inline_data": {
|
||||
"mime_type": "image/jpeg",
|
||||
"data": "aW1hZ2UtMg=="
|
||||
}
|
||||
},
|
||||
{
|
||||
"inlineData": {
|
||||
"mimeType": "text/plain",
|
||||
"data": "bm90LWltYWdl"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
@@ -171,7 +208,12 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
extract_b64_images(&payload),
|
||||
vec!["YWJj".to_string(), "ZGVm".to_string()]
|
||||
vec![
|
||||
"YWJj".to_string(),
|
||||
"ZGVm".to_string(),
|
||||
"aW1hZ2UtMQ==".to_string(),
|
||||
"aW1hZ2UtMg==".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
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,
|
||||
build_vector_engine_image_request_body_with_model, create_vector_engine_image_edit,
|
||||
create_vector_engine_image_generation,
|
||||
build_vector_engine_image_request_body_with_model,
|
||||
build_vector_engine_nanobanana_generate_content_request_body, create_vector_engine_image_edit,
|
||||
create_vector_engine_image_generation, create_vector_engine_nanobanana_generate_content,
|
||||
vector_engine_images_edit_url, vector_engine_images_generation_url,
|
||||
vector_engine_nanobanana_generate_content_url,
|
||||
};
|
||||
use std::{
|
||||
sync::{
|
||||
@@ -69,6 +71,60 @@ fn vector_engine_request_body_can_use_nanobanana2_model() {
|
||||
assert_eq!(body["n"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_engine_request_body_can_use_nanobanana2_half_k() {
|
||||
let body = build_vector_engine_image_request_body_with_model(
|
||||
"gemini-3.1-flash-image-preview",
|
||||
"生成图标 spritesheet",
|
||||
None,
|
||||
"512",
|
||||
1,
|
||||
&[],
|
||||
);
|
||||
|
||||
assert_eq!(body["model"], "gemini-3.1-flash-image-preview");
|
||||
assert_eq!(body["size"], "512");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nanobanana_generate_content_body_carries_aspect_ratio_and_image_size() {
|
||||
let body = build_vector_engine_nanobanana_generate_content_request_body(
|
||||
"生成角色图",
|
||||
Some("文字、水印"),
|
||||
"2:3",
|
||||
"512",
|
||||
&[],
|
||||
);
|
||||
|
||||
assert_eq!(body["contents"][0]["role"], "user");
|
||||
assert_eq!(
|
||||
body["contents"][0]["parts"][0]["text"],
|
||||
"生成角色图\n避免:文字、水印"
|
||||
);
|
||||
assert_eq!(body["generationConfig"]["responseModalities"][0], "IMAGE");
|
||||
assert_eq!(
|
||||
body["generationConfig"]["imageConfig"]["aspectRatio"],
|
||||
"2:3"
|
||||
);
|
||||
assert_eq!(body["generationConfig"]["imageConfig"]["imageSize"], "512");
|
||||
assert!(body.get("model").is_none());
|
||||
assert!(body.get("n").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nanobanana_generate_content_url_uses_model_path() {
|
||||
let settings = VectorEngineImageSettings {
|
||||
base_url: "https://vector.example/v1".to_string(),
|
||||
api_key: "test-key".to_string(),
|
||||
request_timeout_ms: 1_000,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
vector_engine_nanobanana_generate_content_url(&settings, "gemini-3.1-flash-image-preview"),
|
||||
"https://vector.example/v1beta/models/gemini-3.1-flash-image-preview:generateContent"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn vector_engine_image_edit_retries_send_timeout_once_and_succeeds() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
@@ -136,6 +192,73 @@ async fn vector_engine_image_edit_retries_send_timeout_once_and_succeeds() {
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nanobanana_generate_content_posts_native_body_and_reads_inline_data() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("mock server should bind");
|
||||
let server_addr = listener
|
||||
.local_addr()
|
||||
.expect("mock server address should be readable");
|
||||
let server = tokio::spawn(async move {
|
||||
let Ok((mut stream, _)) = listener.accept().await else {
|
||||
return;
|
||||
};
|
||||
let mut request = Vec::new();
|
||||
let mut buffer = [0_u8; 4096];
|
||||
loop {
|
||||
let Ok(read) = stream.read(&mut buffer).await else {
|
||||
return;
|
||||
};
|
||||
if read == 0 {
|
||||
return;
|
||||
}
|
||||
request.extend_from_slice(&buffer[..read]);
|
||||
if request.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let request_text = String::from_utf8_lossy(request.as_slice());
|
||||
assert!(
|
||||
request_text.contains("/v1beta/models/gemini-3.1-flash-image-preview:generateContent")
|
||||
);
|
||||
assert!(request_text.contains("\"aspectRatio\":\"2:3\""));
|
||||
assert!(request_text.contains("\"imageSize\":\"512\""));
|
||||
|
||||
let body = r#"{"candidates":[{"content":{"parts":[{"inlineData":{"mimeType":"image/png","data":"iVBORw0KGgpyZXN0"}}]}}]}"#;
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
let _ = stream.write_all(response.as_bytes()).await;
|
||||
});
|
||||
let settings = VectorEngineImageSettings {
|
||||
base_url: format!("http://{}", server_addr),
|
||||
api_key: "test-key".to_string(),
|
||||
request_timeout_ms: 1_000,
|
||||
};
|
||||
let client = build_vector_engine_image_http_client(&settings).expect("client should build");
|
||||
|
||||
let generated = create_vector_engine_nanobanana_generate_content(
|
||||
&client,
|
||||
&settings,
|
||||
"gemini-3.1-flash-image-preview",
|
||||
"生成角色图",
|
||||
Some("文字、水印"),
|
||||
"2:3",
|
||||
"512",
|
||||
&[],
|
||||
"测试 nanobanana",
|
||||
)
|
||||
.await
|
||||
.expect("nanobanana response should parse");
|
||||
|
||||
assert_eq!(generated.images.len(), 1);
|
||||
assert_eq!(generated.images[0].mime_type, "image/png");
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn vector_engine_image_generation_retries_upstream_502_once_and_succeeds() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
|
||||
@@ -346,6 +346,38 @@ pub struct EditorCharacterAnimationGenerateResponse {
|
||||
pub price_mud_points: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorVideoGenerateRequest {
|
||||
pub prompt: String,
|
||||
pub model: String,
|
||||
pub aspect_ratio: String,
|
||||
pub duration_seconds: u32,
|
||||
pub resolution: String,
|
||||
pub mode: String,
|
||||
pub sound: String,
|
||||
pub price_mud_points: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorVideoGenerateResponse {
|
||||
pub ok: bool,
|
||||
pub video_src: String,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub source_type: String,
|
||||
pub prompt: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub actual_prompt: Option<String>,
|
||||
pub model: String,
|
||||
pub provider: String,
|
||||
pub task_id: String,
|
||||
pub duration_seconds: u32,
|
||||
pub resolution: String,
|
||||
pub price_mud_points: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterAnimationDraftPayload {
|
||||
@@ -908,6 +940,53 @@ mod tests {
|
||||
assert_eq!(payload["fps"], json!(8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_video_request_supports_lovart_model_contract() {
|
||||
let payload = serde_json::to_value(EditorVideoGenerateRequest {
|
||||
prompt: "让角色向镜头挥手".to_string(),
|
||||
model: "kling3.0-omni".to_string(),
|
||||
aspect_ratio: "16:9".to_string(),
|
||||
duration_seconds: 5,
|
||||
resolution: "480p".to_string(),
|
||||
mode: "std".to_string(),
|
||||
sound: "off".to_string(),
|
||||
price_mud_points: 50,
|
||||
})
|
||||
.expect("request should serialize");
|
||||
|
||||
assert_eq!(payload["aspectRatio"], json!("16:9"));
|
||||
assert_eq!(payload["durationSeconds"], json!(5));
|
||||
assert_eq!(payload["priceMudPoints"], json!(50));
|
||||
assert_eq!(payload["model"], json!("kling3.0-omni"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_video_response_uses_canvas_video_shape() {
|
||||
let payload = serde_json::to_value(EditorVideoGenerateResponse {
|
||||
ok: true,
|
||||
video_src: "/generated-editor-videos/task-1/preview.mp4".to_string(),
|
||||
width: 1280,
|
||||
height: 720,
|
||||
source_type: "generated".to_string(),
|
||||
prompt: "让角色向镜头挥手".to_string(),
|
||||
actual_prompt: Some("让角色向镜头挥手".to_string()),
|
||||
model: "kling3.0-omni".to_string(),
|
||||
provider: "VectorEngine".to_string(),
|
||||
task_id: "task-1".to_string(),
|
||||
duration_seconds: 5,
|
||||
resolution: "480p".to_string(),
|
||||
price_mud_points: 50,
|
||||
})
|
||||
.expect("response should serialize");
|
||||
|
||||
assert_eq!(
|
||||
payload["videoSrc"],
|
||||
json!("/generated-editor-videos/task-1/preview.mp4")
|
||||
);
|
||||
assert_eq!(payload["sourceType"], json!("generated"));
|
||||
assert_eq!(payload["durationSeconds"], json!(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_workflow_cache_response_keeps_legacy_shape() {
|
||||
let payload = serde_json::to_value(CharacterWorkflowCacheSaveResponse {
|
||||
|
||||
Reference in New Issue
Block a user