1
This commit is contained in:
@@ -1,7 +1,4 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
@@ -38,17 +35,20 @@ use crate::{
|
||||
build_fallback_moderation_safe_character_visual_prompt,
|
||||
},
|
||||
http_error::AppError,
|
||||
openai_image_generation::{
|
||||
DownloadedOpenAiImage, GPT_IMAGE_2_MODEL, OpenAiImageSettings,
|
||||
build_openai_image_http_client, create_openai_image_generation,
|
||||
require_openai_image_settings,
|
||||
},
|
||||
platform_errors::map_oss_error,
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
};
|
||||
use tokio::time::sleep;
|
||||
|
||||
const CHARACTER_VISUAL_MODEL: &str = "wan2.7-image-pro";
|
||||
const CHARACTER_VISUAL_MODEL: &str = GPT_IMAGE_2_MODEL;
|
||||
const CHARACTER_VISUAL_ASSET_KIND: &str = "character_visual";
|
||||
const CHARACTER_VISUAL_ENTITY_KIND: &str = "character";
|
||||
const CHARACTER_VISUAL_SLOT: &str = "primary_visual";
|
||||
const CHARACTER_VISUAL_TASK_POLL_INTERVAL_MS: u64 = 2_500;
|
||||
const CHARACTER_VISUAL_MODERATION_FALLBACK_MAX_ATTEMPTS: u8 = 2;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -79,7 +79,7 @@ pub async fn generate_character_visual(
|
||||
let fallback_prompt =
|
||||
build_fallback_moderation_safe_character_visual_prompt(payload.prompt_text.as_str());
|
||||
let character_id = normalize_required_text(payload.character_id.as_str(), "character");
|
||||
let model = normalize_required_text(payload.image_model.as_str(), CHARACTER_VISUAL_MODEL);
|
||||
let model = resolve_character_visual_model(payload.image_model.as_str());
|
||||
let size = normalize_required_text(payload.size.as_str(), "1024*1024");
|
||||
let candidate_count = payload.candidate_count.clamp(1, 4);
|
||||
|
||||
@@ -94,8 +94,8 @@ pub async fn generate_character_visual(
|
||||
.map_err(|error| character_visual_error_response(&request_context, error))?;
|
||||
|
||||
let result = async {
|
||||
let settings = require_dashscope_settings(&state)?;
|
||||
let http_client = build_dashscope_http_client(&settings)?;
|
||||
let settings = require_openai_image_settings(&state)?;
|
||||
let http_client = build_openai_image_http_client(&settings)?;
|
||||
|
||||
state
|
||||
.ai_task_service()
|
||||
@@ -121,6 +121,7 @@ pub async fn generate_character_visual(
|
||||
"sourceMode": payload.source_mode,
|
||||
"size": size,
|
||||
"referenceImageCount": payload.reference_image_data_urls.len(),
|
||||
"provider": "apimart",
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
@@ -192,7 +193,7 @@ pub async fn generate_character_visual(
|
||||
),
|
||||
structured_payload_json: Some(
|
||||
json!({
|
||||
"provider": "dashscope",
|
||||
"provider": "apimart",
|
||||
"taskId": generated.task_id,
|
||||
"model": model,
|
||||
"imageCount": generated.images.len(),
|
||||
@@ -307,7 +308,7 @@ pub(crate) async fn generate_character_primary_visual_for_profile(
|
||||
let fallback_prompt =
|
||||
build_fallback_moderation_safe_character_visual_prompt(payload.prompt_text.as_str());
|
||||
let character_id = normalize_required_text(payload.character_id.as_str(), "character");
|
||||
let model = normalize_required_text(payload.image_model.as_str(), CHARACTER_VISUAL_MODEL);
|
||||
let model = resolve_character_visual_model(payload.image_model.as_str());
|
||||
let size = normalize_required_text(payload.size.as_str(), "1024*1024");
|
||||
create_visual_task(
|
||||
state,
|
||||
@@ -317,8 +318,8 @@ pub(crate) async fn generate_character_primary_visual_for_profile(
|
||||
&model,
|
||||
&prompt,
|
||||
)?;
|
||||
let settings = require_dashscope_settings(state)?;
|
||||
let http_client = build_dashscope_http_client(&settings)?;
|
||||
let settings = require_openai_image_settings(state)?;
|
||||
let http_client = build_openai_image_http_client(&settings)?;
|
||||
state
|
||||
.ai_task_service()
|
||||
.start_task(task_id.as_str(), current_utc_micros())
|
||||
@@ -768,47 +769,17 @@ fn build_character_visual_job_payload(task: AiTaskSnapshot) -> CharacterAssetJob
|
||||
}
|
||||
}
|
||||
|
||||
fn require_dashscope_settings(state: &AppState) -> Result<DashScopeSettings, AppError> {
|
||||
// Stage 2 的真实图片生成统一走 DashScope,这里先把配置缺失拦在业务入口前。
|
||||
let base_url = state.config.dashscope_base_url.trim().trim_end_matches('/');
|
||||
if base_url.is_empty() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||
"provider": "dashscope",
|
||||
"reason": "DASHSCOPE_BASE_URL 未配置",
|
||||
})),
|
||||
fn resolve_character_visual_model(value: &str) -> String {
|
||||
// 中文注释:旧前端和历史草稿可能仍传 wan2.7-image-pro;RPG 主图当前统一归一到 gpt-image-2。
|
||||
let trimmed = value.trim();
|
||||
if !trimmed.is_empty() && trimmed != CHARACTER_VISUAL_MODEL {
|
||||
tracing::warn!(
|
||||
requested_model = trimmed,
|
||||
effective_model = CHARACTER_VISUAL_MODEL,
|
||||
"角色主形象图片模型已归一到 gpt-image-2"
|
||||
);
|
||||
}
|
||||
|
||||
let api_key = state
|
||||
.config
|
||||
.dashscope_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": "dashscope",
|
||||
"reason": "DASHSCOPE_API_KEY 未配置",
|
||||
}))
|
||||
})?;
|
||||
|
||||
Ok(DashScopeSettings {
|
||||
base_url: base_url.to_string(),
|
||||
api_key: api_key.to_string(),
|
||||
request_timeout_ms: state.config.dashscope_image_request_timeout_ms.max(1),
|
||||
})
|
||||
}
|
||||
fn build_dashscope_http_client(settings: &DashScopeSettings) -> Result<reqwest::Client, AppError> {
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_millis(settings.request_timeout_ms))
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": "dashscope",
|
||||
"message": format!("构造 DashScope HTTP 客户端失败:{error}"),
|
||||
}))
|
||||
})
|
||||
CHARACTER_VISUAL_MODEL.to_string()
|
||||
}
|
||||
|
||||
async fn resolve_reference_image_as_data_url(
|
||||
@@ -868,9 +839,7 @@ async fn resolve_reference_image_as_data_url(
|
||||
.get(signed.signed_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
map_dashscope_request_error(format!("读取角色主形象参考图失败:{error}"))
|
||||
})?;
|
||||
.map_err(|error| map_image_request_error(format!("读取角色主形象参考图失败:{error}")))?;
|
||||
let status = response.status();
|
||||
let content_type = response
|
||||
.headers()
|
||||
@@ -879,7 +848,7 @@ async fn resolve_reference_image_as_data_url(
|
||||
.unwrap_or("image/png")
|
||||
.to_string();
|
||||
let body = response.bytes().await.map_err(|error| {
|
||||
map_dashscope_request_error(format!("读取角色主形象参考图内容失败:{error}"))
|
||||
map_image_request_error(format!("读取角色主形象参考图内容失败:{error}"))
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
return Err(
|
||||
@@ -911,7 +880,7 @@ async fn resolve_reference_image_as_data_url(
|
||||
|
||||
async fn create_character_visual_generation(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &DashScopeSettings,
|
||||
settings: &OpenAiImageSettings,
|
||||
model: &str,
|
||||
prompt: &str,
|
||||
fallback_prompt: &str,
|
||||
@@ -922,12 +891,13 @@ async fn create_character_visual_generation(
|
||||
let mut active_prompt = prompt;
|
||||
let mut moderation_fallback_applied = false;
|
||||
let mut last_moderation_error = String::new();
|
||||
let model = resolve_character_visual_model(model);
|
||||
|
||||
for attempt_index in 0..CHARACTER_VISUAL_MODERATION_FALLBACK_MAX_ATTEMPTS {
|
||||
match create_character_visual_generation_once(
|
||||
http_client,
|
||||
settings,
|
||||
model,
|
||||
model.as_str(),
|
||||
active_prompt,
|
||||
size,
|
||||
candidate_count,
|
||||
@@ -944,7 +914,7 @@ async fn create_character_visual_generation(
|
||||
if attempt_index == 0
|
||||
&& !fallback_prompt.trim().is_empty()
|
||||
&& fallback_prompt.trim() != prompt.trim()
|
||||
&& is_dashscope_moderation_error(&error) =>
|
||||
&& is_image_moderation_error(&error) =>
|
||||
{
|
||||
last_moderation_error = error.body_text();
|
||||
active_prompt = fallback_prompt;
|
||||
@@ -954,7 +924,7 @@ async fn create_character_visual_generation(
|
||||
}
|
||||
}
|
||||
|
||||
Err(map_dashscope_request_error(format!(
|
||||
Err(map_image_request_error(format!(
|
||||
"角色主形象安全兜底重试未返回结果:{}",
|
||||
last_moderation_error.if_empty_then("上游内容审核仍未通过。")
|
||||
)))
|
||||
@@ -962,183 +932,44 @@ async fn create_character_visual_generation(
|
||||
|
||||
async fn create_character_visual_generation_once(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &DashScopeSettings,
|
||||
model: &str,
|
||||
settings: &OpenAiImageSettings,
|
||||
_model: &str,
|
||||
prompt: &str,
|
||||
size: &str,
|
||||
candidate_count: u32,
|
||||
reference_images: &[String],
|
||||
) -> Result<GeneratedCharacterVisuals, AppError> {
|
||||
let mut content = vec![json!({ "text": prompt })];
|
||||
for image in reference_images {
|
||||
content.push(json!({ "image": image }));
|
||||
}
|
||||
|
||||
let response = http_client
|
||||
.post(format!(
|
||||
"{}/services/aigc/image-generation/generation",
|
||||
settings.base_url
|
||||
))
|
||||
.header(
|
||||
reqwest::header::AUTHORIZATION,
|
||||
format!("Bearer {}", settings.api_key),
|
||||
)
|
||||
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
||||
.header("X-DashScope-Async", "enable")
|
||||
.json(&json!({
|
||||
"model": model,
|
||||
"input": {
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": content,
|
||||
}
|
||||
],
|
||||
},
|
||||
"parameters": {
|
||||
"n": candidate_count,
|
||||
"size": size,
|
||||
"negative_prompt": build_character_visual_negative_prompt(),
|
||||
"prompt_extend": true,
|
||||
"watermark": false,
|
||||
},
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| map_dashscope_request_error(format!("创建角色主形象任务失败:{error}")))?;
|
||||
let response_status = response.status();
|
||||
let response_text = response.text().await.map_err(|error| {
|
||||
map_dashscope_request_error(format!("读取角色主形象任务响应失败:{error}"))
|
||||
})?;
|
||||
if !response_status.is_success() {
|
||||
return Err(map_dashscope_upstream_error(
|
||||
response_text.as_str(),
|
||||
"创建角色主形象任务失败。",
|
||||
));
|
||||
}
|
||||
let response_json = parse_json_payload(response_text.as_str(), "创建角色主形象任务失败。")?;
|
||||
let task_id = extract_task_id(&response_json.payload).ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "dashscope",
|
||||
"message": "角色主形象任务未返回 task_id",
|
||||
}))
|
||||
})?;
|
||||
|
||||
let deadline = Instant::now() + Duration::from_millis(settings.request_timeout_ms);
|
||||
while Instant::now() < deadline {
|
||||
let poll_response = http_client
|
||||
.get(format!("{}/tasks/{}", settings.base_url, task_id))
|
||||
.header(
|
||||
reqwest::header::AUTHORIZATION,
|
||||
format!("Bearer {}", settings.api_key),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
map_dashscope_request_error(format!("查询角色主形象任务失败:{error}"))
|
||||
})?;
|
||||
let poll_status = poll_response.status();
|
||||
let poll_text = poll_response.text().await.map_err(|error| {
|
||||
map_dashscope_request_error(format!("读取角色主形象任务状态失败:{error}"))
|
||||
})?;
|
||||
if !poll_status.is_success() {
|
||||
return Err(map_dashscope_upstream_error(
|
||||
poll_text.as_str(),
|
||||
"查询角色主形象任务失败。",
|
||||
));
|
||||
}
|
||||
let poll_json = parse_json_payload(poll_text.as_str(), "查询角色主形象任务失败。")?;
|
||||
let task_status = find_first_string_by_key(&poll_json.payload, "task_status")
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
if task_status == "SUCCEEDED" {
|
||||
let image_urls = extract_image_urls(&poll_json.payload);
|
||||
if image_urls.is_empty() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "dashscope",
|
||||
"message": "角色主形象生成成功,但没有返回可下载图片。",
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
let mut images = Vec::with_capacity(image_urls.len());
|
||||
for image_url in image_urls {
|
||||
images.push(
|
||||
download_generated_image(
|
||||
http_client,
|
||||
image_url.as_str(),
|
||||
"下载角色主形象候选图失败。",
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
|
||||
return Ok(GeneratedCharacterVisuals {
|
||||
task_id,
|
||||
actual_prompt: find_first_string_by_key(&poll_json.payload, "actual_prompt"),
|
||||
submitted_prompt: prompt.to_string(),
|
||||
moderation_fallback_applied: false,
|
||||
images,
|
||||
});
|
||||
}
|
||||
if matches!(task_status.as_str(), "FAILED" | "UNKNOWN" | "CANCELED") {
|
||||
return Err(map_dashscope_upstream_error(
|
||||
poll_text.as_str(),
|
||||
"角色主形象任务执行失败。",
|
||||
));
|
||||
}
|
||||
|
||||
sleep(Duration::from_millis(
|
||||
CHARACTER_VISUAL_TASK_POLL_INTERVAL_MS,
|
||||
))
|
||||
.await;
|
||||
}
|
||||
|
||||
Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "dashscope",
|
||||
"message": "角色主形象任务执行超时,请稍后重试。",
|
||||
})),
|
||||
let generated = create_openai_image_generation(
|
||||
http_client,
|
||||
settings,
|
||||
prompt,
|
||||
Some(build_character_visual_negative_prompt().as_str()),
|
||||
size,
|
||||
candidate_count,
|
||||
reference_images,
|
||||
"角色主形象生成失败",
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(GeneratedCharacterVisuals {
|
||||
task_id: generated.task_id,
|
||||
actual_prompt: generated.actual_prompt,
|
||||
submitted_prompt: prompt.to_string(),
|
||||
moderation_fallback_applied: false,
|
||||
images: generated
|
||||
.images
|
||||
.into_iter()
|
||||
.map(downloaded_openai_to_character_visual_image)
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn download_generated_image(
|
||||
http_client: &reqwest::Client,
|
||||
image_url: &str,
|
||||
fallback_message: &str,
|
||||
) -> Result<DownloadedGeneratedImage, AppError> {
|
||||
let response = http_client
|
||||
.get(image_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| map_dashscope_request_error(format!("{fallback_message}:{error}")))?;
|
||||
let status = response.status();
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("image/jpeg")
|
||||
.to_string();
|
||||
let body = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|error| map_dashscope_request_error(format!("{fallback_message}:{error}")))?;
|
||||
if !status.is_success() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "dashscope",
|
||||
"message": fallback_message,
|
||||
"status": status.as_u16(),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
let normalized_mime_type = normalize_downloaded_image_mime_type(content_type.as_str());
|
||||
let mut bytes = body.to_vec();
|
||||
let mut extension = mime_to_extension(normalized_mime_type.as_str()).to_string();
|
||||
let mut mime_type = normalized_mime_type;
|
||||
fn downloaded_openai_to_character_visual_image(
|
||||
image: DownloadedOpenAiImage,
|
||||
) -> DownloadedGeneratedImage {
|
||||
let mut bytes = image.bytes;
|
||||
let mut extension = image.extension;
|
||||
let mut mime_type = image.mime_type;
|
||||
|
||||
if mime_type == "image/png"
|
||||
&& let Some(optimized) = try_apply_background_alpha_to_png(bytes.as_slice())
|
||||
@@ -1148,11 +979,11 @@ async fn download_generated_image(
|
||||
mime_type = "image/png".to_string();
|
||||
}
|
||||
|
||||
Ok(DownloadedGeneratedImage {
|
||||
DownloadedGeneratedImage {
|
||||
bytes,
|
||||
mime_type,
|
||||
extension,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 统一的 PNG 透明背景后处理入口。
|
||||
@@ -1339,79 +1170,32 @@ fn map_character_visual_oss_error(error: platform_oss::OssError) -> AppError {
|
||||
map_oss_error(error, "aliyun-oss")
|
||||
}
|
||||
|
||||
fn parse_json_payload(
|
||||
raw_text: &str,
|
||||
fallback_message: &str,
|
||||
) -> Result<ParsedJsonPayload, AppError> {
|
||||
serde_json::from_str::<Value>(raw_text)
|
||||
.map(|payload| ParsedJsonPayload { payload })
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "dashscope",
|
||||
"message": format!("{fallback_message}:解析响应失败:{error}"),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_api_error_message(raw_text: &str, fallback_message: &str) -> String {
|
||||
if raw_text.trim().is_empty() {
|
||||
return fallback_message.to_string();
|
||||
}
|
||||
|
||||
if let Ok(parsed) = serde_json::from_str::<Value>(raw_text) {
|
||||
if let Some(message) = parsed
|
||||
.pointer("/error/message")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return message.to_string();
|
||||
}
|
||||
if let Some(message) = parsed
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return message.to_string();
|
||||
}
|
||||
if let Some(code) = parsed
|
||||
.pointer("/error/code")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return format!("{fallback_message}({code})");
|
||||
}
|
||||
if let Some(code) = parsed
|
||||
.get("code")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return format!("{fallback_message}({code})");
|
||||
}
|
||||
}
|
||||
|
||||
raw_text.trim().to_string()
|
||||
}
|
||||
|
||||
fn map_dashscope_request_error(message: String) -> AppError {
|
||||
fn map_image_request_error(message: String) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "dashscope",
|
||||
"provider": "apimart",
|
||||
"message": message,
|
||||
}))
|
||||
}
|
||||
|
||||
fn map_dashscope_upstream_error(raw_text: &str, fallback_message: &str) -> AppError {
|
||||
#[cfg(test)]
|
||||
fn map_image_upstream_error(raw_text: &str, fallback_message: &str) -> AppError {
|
||||
let message = match raw_text.trim() {
|
||||
"" => fallback_message.to_string(),
|
||||
value => value.to_string(),
|
||||
};
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "dashscope",
|
||||
"message": parse_api_error_message(raw_text, fallback_message),
|
||||
"provider": "apimart",
|
||||
"message": message,
|
||||
"raw": raw_text.trim(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn is_dashscope_moderation_error(error: &AppError) -> bool {
|
||||
#[cfg(test)]
|
||||
fn is_image_test_moderation_error(error: &AppError) -> bool {
|
||||
is_image_moderation_error(error)
|
||||
}
|
||||
|
||||
fn is_image_moderation_error(error: &AppError) -> bool {
|
||||
let text = error.body_text();
|
||||
let normalized = text.to_ascii_lowercase();
|
||||
normalized.contains("ipinfringementsuspect")
|
||||
@@ -1424,77 +1208,6 @@ fn is_dashscope_moderation_error(error: &AppError) -> bool {
|
||||
|| text.contains("知识产权")
|
||||
}
|
||||
|
||||
fn collect_strings_by_key(value: &Value, target_key: &str, results: &mut Vec<String>) {
|
||||
match value {
|
||||
Value::Array(entries) => {
|
||||
for entry in entries {
|
||||
collect_strings_by_key(entry, target_key, results);
|
||||
}
|
||||
}
|
||||
Value::Object(object) => {
|
||||
for (key, nested_value) in object {
|
||||
if key == target_key
|
||||
&& let Some(text) = nested_value
|
||||
.as_str()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
results.push(text.to_string());
|
||||
continue;
|
||||
}
|
||||
collect_strings_by_key(nested_value, target_key, results);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn find_first_string_by_key(value: &Value, target_key: &str) -> Option<String> {
|
||||
let mut results = Vec::new();
|
||||
collect_strings_by_key(value, target_key, &mut results);
|
||||
results.into_iter().next()
|
||||
}
|
||||
|
||||
fn extract_task_id(payload: &Value) -> Option<String> {
|
||||
find_first_string_by_key(payload, "task_id")
|
||||
}
|
||||
|
||||
fn extract_image_urls(payload: &Value) -> Vec<String> {
|
||||
let mut urls = Vec::new();
|
||||
collect_strings_by_key(payload, "image", &mut urls);
|
||||
collect_strings_by_key(payload, "url", &mut urls);
|
||||
let mut deduped = Vec::new();
|
||||
for url in urls {
|
||||
if !deduped.contains(&url) {
|
||||
deduped.push(url);
|
||||
}
|
||||
}
|
||||
deduped
|
||||
}
|
||||
|
||||
fn normalize_downloaded_image_mime_type(content_type: &str) -> String {
|
||||
let mime_type = content_type
|
||||
.split(';')
|
||||
.next()
|
||||
.map(str::trim)
|
||||
.unwrap_or("image/jpeg");
|
||||
match mime_type {
|
||||
"image/png" | "image/webp" | "image/jpeg" | "image/jpg" | "image/gif" => {
|
||||
mime_type.to_string()
|
||||
}
|
||||
_ => "image/jpeg".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn mime_to_extension(mime_type: &str) -> &str {
|
||||
match mime_type {
|
||||
"image/png" => "png",
|
||||
"image/webp" => "webp",
|
||||
"image/gif" => "gif",
|
||||
_ => "jpg",
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_image_data_url(value: &str) -> Option<ParsedImageDataUrl> {
|
||||
let body = value.trim().strip_prefix("data:")?;
|
||||
let (mime_type, data) = body.split_once(";base64,")?;
|
||||
@@ -2012,12 +1725,6 @@ impl EmptyFallback for String {
|
||||
}
|
||||
}
|
||||
|
||||
struct DashScopeSettings {
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
request_timeout_ms: u64,
|
||||
}
|
||||
|
||||
struct GeneratedCharacterVisuals {
|
||||
task_id: String,
|
||||
actual_prompt: Option<String>,
|
||||
@@ -2032,10 +1739,6 @@ struct DownloadedGeneratedImage {
|
||||
extension: String,
|
||||
}
|
||||
|
||||
struct ParsedJsonPayload {
|
||||
payload: Value,
|
||||
}
|
||||
|
||||
struct ParsedImageDataUrl {
|
||||
mime_type: String,
|
||||
bytes: Vec<u8>,
|
||||
@@ -2066,13 +1769,22 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dashscope_ip_infringement_error_uses_moderation_fallback() {
|
||||
let error = map_dashscope_upstream_error(
|
||||
fn legacy_character_visual_model_normalizes_to_gpt_image_2() {
|
||||
assert_eq!(
|
||||
resolve_character_visual_model("wan2.7-image-pro"),
|
||||
"gpt-image-2"
|
||||
);
|
||||
assert_eq!(resolve_character_visual_model(""), "gpt-image-2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_ip_infringement_error_uses_moderation_fallback() {
|
||||
let error = map_image_upstream_error(
|
||||
r#"{"request_id":"a18fb05d","output":{"task_id":"cb768c95","task_status":"FAILED","code":"IPInfringementSuspect","message":"Input data is suspected of being involved in IP infringement."}}"#,
|
||||
"角色主形象任务执行失败。",
|
||||
);
|
||||
|
||||
assert!(is_dashscope_moderation_error(&error));
|
||||
assert!(is_image_test_moderation_error(&error));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user