This commit is contained in:
2026-05-14 13:40:50 +08:00
parent 5a55180b78
commit 2dc9d752e4
24 changed files with 1873 additions and 98 deletions

View File

@@ -101,6 +101,7 @@ pub struct AppConfig {
pub dashscope_image_request_timeout_ms: u64,
pub apimart_base_url: String,
pub apimart_api_key: Option<String>,
pub apimart_image_request_timeout_ms: u64,
pub vector_engine_base_url: String,
pub vector_engine_api_key: Option<String>,
pub vector_engine_image_request_timeout_ms: u64,
@@ -219,6 +220,7 @@ impl Default for AppConfig {
dashscope_image_request_timeout_ms: 150_000,
apimart_base_url: String::new(),
apimart_api_key: None,
apimart_image_request_timeout_ms: 180_000,
vector_engine_base_url: String::new(),
vector_engine_api_key: None,
vector_engine_image_request_timeout_ms: 180_000,
@@ -605,6 +607,12 @@ impl AppConfig {
config.apimart_api_key = read_first_non_empty_env(&["APIMART_API_KEY"]);
if let Some(apimart_image_request_timeout_ms) =
read_first_positive_u64_env(&["APIMART_IMAGE_REQUEST_TIMEOUT_MS"])
{
config.apimart_image_request_timeout_ms = apimart_image_request_timeout_ms;
}
if let Some(vector_engine_base_url) = read_first_non_empty_env(&["VECTOR_ENGINE_BASE_URL"])
{
config.vector_engine_base_url = vector_engine_base_url;
@@ -991,6 +999,7 @@ mod tests {
std::env::remove_var("GENARRATIVE_LLM_BASE_URL");
std::env::remove_var("GENARRATIVE_LLM_MODEL");
std::env::remove_var("APIMART_BASE_URL");
std::env::remove_var("APIMART_IMAGE_REQUEST_TIMEOUT_MS");
std::env::remove_var("VECTOR_ENGINE_BASE_URL");
std::env::remove_var("VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS");
std::env::remove_var("HYPER3D_BASE_URL");
@@ -1006,6 +1015,7 @@ mod tests {
);
std::env::set_var("GENARRATIVE_LLM_MODEL", "internal-text-model");
std::env::set_var("APIMART_BASE_URL", "https://responses.internal.example/v1");
std::env::set_var("APIMART_IMAGE_REQUEST_TIMEOUT_MS", "190000");
std::env::set_var("VECTOR_ENGINE_BASE_URL", "https://vector.internal.example");
std::env::set_var("VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS", "210000");
std::env::set_var("HYPER3D_BASE_URL", "https://model.internal.example/api/v2");
@@ -1027,6 +1037,7 @@ mod tests {
config.apimart_base_url,
"https://responses.internal.example/v1"
);
assert_eq!(config.apimart_image_request_timeout_ms, 190_000);
assert_eq!(
config.vector_engine_base_url,
"https://vector.internal.example"
@@ -1050,6 +1061,7 @@ mod tests {
std::env::remove_var("GENARRATIVE_LLM_BASE_URL");
std::env::remove_var("GENARRATIVE_LLM_MODEL");
std::env::remove_var("APIMART_BASE_URL");
std::env::remove_var("APIMART_IMAGE_REQUEST_TIMEOUT_MS");
std::env::remove_var("VECTOR_ENGINE_BASE_URL");
std::env::remove_var("VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS");
std::env::remove_var("HYPER3D_BASE_URL");

View File

@@ -5,6 +5,8 @@ use std::{
time::{Duration, SystemTime, UNIX_EPOCH},
};
use tokio::time::sleep;
use axum::{
Json,
extract::{Extension, Path, State, rejection::JsonRejection},
@@ -102,6 +104,9 @@ const MATCH3D_MATERIAL_FOREGROUND_ALPHA_THRESHOLD: i32 = 36;
const MATCH3D_MATERIAL_GREEN_SCREEN_MIN_SCORE: f32 = 0.34;
const MATCH3D_MATERIAL_GREEN_SCREEN_SOFT_SCORE: f32 = 0.18;
const MATCH3D_MAX_GENERATED_ITEM_COUNT: usize = 25;
const MATCH3D_MATERIAL_APIMART_MODEL: &str = "gemini-3.1-flash-image-preview";
const MATCH3D_MATERIAL_APIMART_SIZE: &str = "1:1";
const MATCH3D_MATERIAL_APIMART_RESOLUTION: &str = "1K";
const MATCH3D_LEGACY_MODEL_DOWNLOAD_TIMEOUT_MS: u64 = 3 * 60_000;
const MATCH3D_OSS_PUT_TIMEOUT_MS: u64 = 3 * 60_000;
const MATCH3D_LEGACY_MODEL_MAX_BYTES: usize = 120 * 1024 * 1024;
@@ -3766,6 +3771,12 @@ struct Match3DMaterialSheet {
image: DownloadedOpenAiImage,
}
struct Match3DApimartImageSettings {
base_url: String,
api_key: String,
request_timeout_ms: u64,
}
struct Match3DSlicedItemImage {
bytes: Vec<u8>,
}
@@ -4728,24 +4739,21 @@ async fn generate_match3d_material_sheet(
config: &Match3DConfigJson,
item_names: &[String],
) -> Result<Match3DMaterialSheet, AppError> {
let settings = require_openai_image_settings(state)?;
let http_client = build_openai_image_http_client(&settings)?;
let settings = require_match3d_apimart_image_settings(state)?;
let http_client = build_match3d_apimart_image_http_client(&settings)?;
let prompt = build_match3d_material_sheet_prompt(config, item_names);
let negative_prompt = build_match3d_material_sheet_negative_prompt(config);
let generated = create_openai_image_generation(
let generated = create_match3d_apimart_nanobanana_image_generation(
&http_client,
&settings,
prompt.as_str(),
Some(negative_prompt.as_str()),
"1:1",
1,
&[],
negative_prompt.as_str(),
"抓大鹅素材图生成失败",
)
.await?;
let image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"provider": "apimart",
"message": "抓大鹅素材图生成失败:未返回图片",
}))
})?;
@@ -4756,6 +4764,505 @@ async fn generate_match3d_material_sheet(
})
}
fn require_match3d_apimart_image_settings(
state: &AppState,
) -> Result<Match3DApimartImageSettings, AppError> {
let base_url = state.config.apimart_base_url.trim().trim_end_matches('/');
if base_url.is_empty() {
return Err(
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "apimart",
"reason": "APIMART_BASE_URL 未配置",
})),
);
}
let api_key = state
.config
.apimart_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": "apimart",
"reason": "APIMART_API_KEY 未配置",
}))
})?;
Ok(Match3DApimartImageSettings {
base_url: base_url.to_string(),
api_key: api_key.to_string(),
request_timeout_ms: state.config.apimart_image_request_timeout_ms.max(1),
})
}
fn build_match3d_apimart_image_http_client(
settings: &Match3DApimartImageSettings,
) -> 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": "apimart",
"message": format!("构造抓大鹅 APIMart 图片生成 HTTP 客户端失败:{error}"),
}))
})
}
async fn create_match3d_apimart_nanobanana_image_generation(
http_client: &reqwest::Client,
settings: &Match3DApimartImageSettings,
prompt: &str,
negative_prompt: &str,
failure_context: &str,
) -> Result<OpenAiGeneratedImages, AppError> {
let request_body = build_match3d_apimart_nanobanana_image_request_body(
prompt,
negative_prompt,
MATCH3D_MATERIAL_APIMART_SIZE,
);
let response = http_client
.post(format!("{}/images/generations", settings.base_url))
.header(
header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.header(header::ACCEPT, "application/json")
.header(header::CONTENT_TYPE, "application/json")
.json(&request_body)
.send()
.await
.map_err(|error| {
map_match3d_apimart_image_request_error(format!(
"{failure_context}:创建 APIMart nanobanana 图片生成任务失败:{error}"
))
})?;
let status = response.status();
let response_text = response.text().await.map_err(|error| {
map_match3d_apimart_image_request_error(format!(
"{failure_context}:读取 APIMart nanobanana 图片生成响应失败:{error}"
))
})?;
if !status.is_success() {
return Err(map_match3d_apimart_image_upstream_error(
status,
response_text.as_str(),
failure_context,
));
}
let payload = parse_match3d_json_payload(
response_text.as_str(),
"解析抓大鹅 APIMart nanobanana 图片生成响应失败",
"apimart",
)?;
let image_urls = extract_match3d_image_urls(&payload);
if !image_urls.is_empty() {
return download_match3d_images_from_urls(
http_client,
format!("apimart-nanobanana-{}", current_utc_micros()),
image_urls,
1,
"apimart",
)
.await;
}
let b64_images = extract_match3d_b64_images(&payload);
if !b64_images.is_empty() {
return Ok(match3d_images_from_base64(
format!("apimart-nanobanana-{}", current_utc_micros()),
b64_images,
1,
));
}
let task_id = extract_match3d_task_id(&payload).ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"message": "抓大鹅 APIMart nanobanana 图片生成未返回 task_id 或图片地址",
}))
})?;
wait_match3d_apimart_generated_images(http_client, settings, task_id.as_str(), failure_context)
.await
}
fn build_match3d_apimart_nanobanana_image_request_body(
prompt: &str,
negative_prompt: &str,
size: &str,
) -> Value {
Value::Object(serde_json::Map::from_iter([
(
"model".to_string(),
Value::String(MATCH3D_MATERIAL_APIMART_MODEL.to_string()),
),
(
"prompt".to_string(),
Value::String(build_match3d_apimart_prompt(prompt, negative_prompt)),
),
("n".to_string(), json!(1)),
("official_fallback".to_string(), Value::Bool(true)),
("size".to_string(), Value::String(size.to_string())),
(
"resolution".to_string(),
Value::String(MATCH3D_MATERIAL_APIMART_RESOLUTION.to_string()),
),
]))
}
fn build_match3d_apimart_prompt(prompt: &str, negative_prompt: &str) -> String {
let prompt = prompt.trim();
let negative_prompt = negative_prompt.trim();
if negative_prompt.is_empty() {
return prompt.to_string();
}
format!("{prompt}\n避免:{negative_prompt}")
}
async fn wait_match3d_apimart_generated_images(
http_client: &reqwest::Client,
settings: &Match3DApimartImageSettings,
task_id: &str,
failure_context: &str,
) -> Result<OpenAiGeneratedImages, AppError> {
let deadline = std::time::Instant::now() + Duration::from_millis(settings.request_timeout_ms);
while std::time::Instant::now() < deadline {
let poll_response = http_client
.get(format!("{}/tasks/{}", settings.base_url, task_id))
.header(
header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.header(header::ACCEPT, "application/json")
.send()
.await
.map_err(|error| {
map_match3d_apimart_image_request_error(format!(
"{failure_context}:查询 APIMart nanobanana 图片生成任务失败:{error}"
))
})?;
let poll_status = poll_response.status();
let poll_text = poll_response.text().await.map_err(|error| {
map_match3d_apimart_image_request_error(format!(
"{failure_context}:读取 APIMart nanobanana 图片生成任务响应失败:{error}"
))
})?;
if !poll_status.is_success() {
return Err(map_match3d_apimart_image_upstream_error(
poll_status,
poll_text.as_str(),
failure_context,
));
}
let payload = parse_match3d_json_payload(
poll_text.as_str(),
"解析抓大鹅 APIMart nanobanana 图片生成任务响应失败",
"apimart",
)?;
let task_status = find_first_match3d_string_by_key(&payload, "status")
.or_else(|| find_first_match3d_string_by_key(&payload, "task_status"))
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
if matches!(task_status.as_str(), "completed" | "succeeded" | "success") {
let image_urls = extract_match3d_image_urls(&payload);
if !image_urls.is_empty() {
return download_match3d_images_from_urls(
http_client,
task_id.to_string(),
image_urls,
1,
"apimart",
)
.await;
}
let b64_images = extract_match3d_b64_images(&payload);
if !b64_images.is_empty() {
return Ok(match3d_images_from_base64(task_id.to_string(), b64_images, 1));
}
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"message": "抓大鹅 APIMart nanobanana 图片生成成功但未返回图片",
})),
);
}
if matches!(
task_status.as_str(),
"failed" | "error" | "canceled" | "cancelled"
) {
return Err(map_match3d_apimart_image_upstream_error(
poll_status,
poll_text.as_str(),
failure_context,
));
}
sleep(Duration::from_secs(3)).await;
}
Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"message": "抓大鹅 APIMart nanobanana 图片生成超时或未返回图片",
})),
)
}
async fn download_match3d_images_from_urls(
http_client: &reqwest::Client,
task_id: String,
image_urls: Vec<String>,
candidate_count: u32,
provider: &str,
) -> Result<OpenAiGeneratedImages, AppError> {
let mut images = Vec::with_capacity(candidate_count.clamp(1, 4) as usize);
for image_url in image_urls
.into_iter()
.take(candidate_count.clamp(1, 4) as usize)
{
images.push(download_match3d_remote_image(http_client, image_url.as_str(), provider).await?);
}
Ok(OpenAiGeneratedImages {
task_id,
actual_prompt: None,
images,
})
}
async fn download_match3d_remote_image(
http_client: &reqwest::Client,
image_url: &str,
provider: &str,
) -> Result<DownloadedOpenAiImage, AppError> {
let response = http_client.get(image_url).send().await.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": provider,
"message": format!("下载抓大鹅生成图片失败:{error}"),
}))
})?;
let status = response.status();
let content_type = response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("image/png")
.to_string();
let body = response.bytes().await.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": provider,
"message": format!("读取抓大鹅生成图片内容失败:{error}"),
}))
})?;
if !status.is_success() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": provider,
"message": "下载抓大鹅生成图片失败",
"status": status.as_u16(),
})),
);
}
let mime_type = normalize_match3d_downloaded_image_mime_type(content_type.as_str());
Ok(DownloadedOpenAiImage {
extension: match3d_mime_to_extension(mime_type.as_str()).to_string(),
mime_type,
bytes: body.to_vec(),
})
}
fn match3d_images_from_base64(
task_id: String,
b64_images: Vec<String>,
candidate_count: u32,
) -> OpenAiGeneratedImages {
let images = b64_images
.into_iter()
.take(candidate_count.clamp(1, 4) as usize)
.filter_map(|raw| decode_match3d_base64_image(raw.as_str()))
.collect();
OpenAiGeneratedImages {
task_id,
actual_prompt: None,
images,
}
}
fn decode_match3d_base64_image(raw: &str) -> Option<DownloadedOpenAiImage> {
let bytes = BASE64_STANDARD.decode(raw.trim()).ok()?;
let mime_type = infer_match3d_image_mime_type(bytes.as_slice()).to_string();
Some(DownloadedOpenAiImage {
extension: match3d_mime_to_extension(mime_type.as_str()).to_string(),
mime_type,
bytes,
})
}
fn parse_match3d_json_payload(
raw_text: &str,
failure_context: &str,
provider: &str,
) -> Result<Value, AppError> {
serde_json::from_str::<Value>(raw_text).map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": provider,
"message": format!("{failure_context}{error}"),
"rawExcerpt": trim_match3d_upstream_excerpt(raw_text, 800),
}))
})
}
fn extract_match3d_task_id(payload: &Value) -> Option<String> {
find_first_match3d_string_by_key(payload, "task_id")
.or_else(|| find_first_match3d_string_by_key(payload, "id"))
}
fn extract_match3d_image_urls(payload: &Value) -> Vec<String> {
let mut urls = Vec::new();
collect_match3d_strings_by_key(payload, "url", &mut urls);
collect_match3d_strings_by_key(payload, "image", &mut urls);
collect_match3d_strings_by_key(payload, "image_url", &mut urls);
let mut deduped = Vec::new();
for url in urls {
if (url.starts_with("http://") || url.starts_with("https://")) && !deduped.contains(&url) {
deduped.push(url);
}
}
deduped
}
fn extract_match3d_b64_images(payload: &Value) -> Vec<String> {
let mut values = Vec::new();
collect_match3d_strings_by_key(payload, "b64_json", &mut values);
values
}
fn find_first_match3d_string_by_key(payload: &Value, target_key: &str) -> Option<String> {
let mut results = Vec::new();
collect_match3d_strings_by_key(payload, target_key, &mut results);
results.into_iter().next()
}
fn collect_match3d_strings_by_key(payload: &Value, target_key: &str, results: &mut Vec<String>) {
match payload {
Value::Array(entries) => {
for entry in entries {
collect_match3d_strings_by_key(entry, target_key, results);
}
}
Value::Object(object) => {
for (key, nested_value) in object {
if key == target_key {
match nested_value {
Value::String(text) => {
let text = text.trim();
if !text.is_empty() {
results.push(text.to_string());
}
}
Value::Array(entries) => {
for entry in entries {
if let Some(text) = entry
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
{
results.push(text.to_string());
}
}
}
_ => {}
}
}
collect_match3d_strings_by_key(nested_value, target_key, results);
}
}
_ => {}
}
}
fn map_match3d_apimart_image_request_error(message: String) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"message": message,
}))
}
fn map_match3d_apimart_image_upstream_error(
upstream_status: reqwest::StatusCode,
raw_text: &str,
fallback_message: &str,
) -> AppError {
let message = parse_match3d_api_error_message(raw_text, fallback_message);
let raw_excerpt = trim_match3d_upstream_excerpt(raw_text, 800);
tracing::warn!(
provider = "apimart",
upstream_status = upstream_status.as_u16(),
message = %message,
raw_excerpt = %raw_excerpt,
"抓大鹅 APIMart nanobanana 图片生成上游请求失败"
);
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"upstreamStatus": upstream_status.as_u16(),
"message": message,
"rawExcerpt": raw_excerpt,
}))
}
fn parse_match3d_api_error_message(raw_text: &str, fallback_message: &str) -> String {
let trimmed = raw_text.trim();
if trimmed.is_empty() {
return fallback_message.to_string();
}
if let Ok(payload) = serde_json::from_str::<Value>(trimmed) {
for key in ["message", "code"] {
if let Some(value) = find_first_match3d_string_by_key(&payload, key) {
return if key == "message" {
value
} else {
format!("{fallback_message}{value}")
};
}
}
}
trimmed.to_string()
}
fn trim_match3d_upstream_excerpt(raw_text: &str, max_chars: usize) -> String {
raw_text.chars().take(max_chars).collect()
}
fn normalize_match3d_downloaded_image_mime_type(content_type: &str) -> String {
let mime_type = content_type
.split(';')
.next()
.map(str::trim)
.unwrap_or("image/png");
match mime_type {
"image/png" | "image/webp" | "image/jpeg" | "image/jpg" | "image/gif" => {
mime_type.to_string()
}
_ => "image/png".to_string(),
}
}
fn match3d_mime_to_extension(mime_type: &str) -> &str {
match mime_type {
"image/png" => "png",
"image/webp" => "webp",
"image/gif" => "gif",
"image/jpeg" | "image/jpg" => "jpg",
_ => "png",
}
}
async fn download_match3d_legacy_model(
file: &hyper3d_contract::Hyper3dDownloadFilePayload,
) -> Result<Match3DDownloadedModel, AppError> {