Rewrite Genarrative play-type integration guidance across .codex and .hermes to define a platform-level SOP: default to form/image workbench, unify single-image asset slots (CreativeImageInputPanel), standardize series-material sheet->cut->transparent->OSS pipeline, and forbid copying legacy chat/agent workflows as the default. Add decision-log entry freezing the SOP and a pitfalls note warning against direct reuse of old play tools. Update CONTEXT.md and docs/README.md, add a new PRD file, and apply related small server-side changes (module-auth, spacetime-client mappers and runtime) to align back-end code with the new contracts and flows.
485 lines
16 KiB
Rust
485 lines
16 KiB
Rust
use super::*;
|
||
|
||
pub(super) async fn generate_match3d_material_sheet(
|
||
state: &AppState,
|
||
config: &Match3DConfigJson,
|
||
item_names: &[String],
|
||
) -> Result<Match3DMaterialSheet, AppError> {
|
||
let settings = require_match3d_vector_engine_gemini_image_settings(state)?;
|
||
let http_client = build_match3d_vector_engine_gemini_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_match3d_vector_engine_gemini_image_generation(
|
||
&http_client,
|
||
&settings,
|
||
prompt.as_str(),
|
||
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-gemini",
|
||
"message": "抓大鹅素材图生成失败:未返回图片",
|
||
}))
|
||
})?;
|
||
|
||
Ok(Match3DMaterialSheet {
|
||
task_id: generated.task_id,
|
||
prompt,
|
||
image,
|
||
})
|
||
}
|
||
|
||
fn require_match3d_vector_engine_gemini_image_settings(
|
||
state: &AppState,
|
||
) -> Result<Match3DVectorEngineGeminiImageSettings, AppError> {
|
||
let base_url = state
|
||
.config
|
||
.vector_engine_base_url
|
||
.trim()
|
||
.trim_end_matches('/');
|
||
if base_url.is_empty() {
|
||
return Err(
|
||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||
"provider": "vector-engine-gemini",
|
||
"reason": "VECTOR_ENGINE_BASE_URL 未配置",
|
||
})),
|
||
);
|
||
}
|
||
|
||
let api_key = state
|
||
.config
|
||
.vector_engine_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": "vector-engine-gemini",
|
||
"reason": "VECTOR_ENGINE_API_KEY 未配置",
|
||
}))
|
||
})?;
|
||
|
||
Ok(Match3DVectorEngineGeminiImageSettings {
|
||
base_url: base_url.to_string(),
|
||
api_key: api_key.to_string(),
|
||
request_timeout_ms: state.config.vector_engine_image_request_timeout_ms.max(1),
|
||
})
|
||
}
|
||
|
||
fn build_match3d_vector_engine_gemini_image_http_client(
|
||
settings: &Match3DVectorEngineGeminiImageSettings,
|
||
) -> 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": "vector-engine-gemini",
|
||
"message": format!("构造抓大鹅 VectorEngine Gemini 图片生成 HTTP 客户端失败:{error}"),
|
||
}))
|
||
})
|
||
}
|
||
|
||
async fn create_match3d_vector_engine_gemini_image_generation(
|
||
http_client: &reqwest::Client,
|
||
settings: &Match3DVectorEngineGeminiImageSettings,
|
||
prompt: &str,
|
||
negative_prompt: &str,
|
||
failure_context: &str,
|
||
) -> Result<OpenAiGeneratedImages, AppError> {
|
||
let request_body = build_match3d_vector_engine_gemini_image_request_body(
|
||
prompt,
|
||
negative_prompt,
|
||
MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_ASPECT_RATIO,
|
||
);
|
||
let response = http_client
|
||
.post(build_match3d_vector_engine_gemini_generate_content_url(
|
||
settings,
|
||
))
|
||
.query(&[("key", settings.api_key.as_str())])
|
||
.header(header::ACCEPT, "application/json")
|
||
.header(header::CONTENT_TYPE, "application/json")
|
||
.json(&request_body)
|
||
.send()
|
||
.await
|
||
.map_err(|error| {
|
||
map_match3d_vector_engine_gemini_image_request_error(format!(
|
||
"{failure_context}:调用 VectorEngine Gemini 图片生成失败:{error}"
|
||
))
|
||
})?;
|
||
let status = response.status();
|
||
let response_text = response.text().await.map_err(|error| {
|
||
map_match3d_vector_engine_gemini_image_request_error(format!(
|
||
"{failure_context}:读取 VectorEngine Gemini 图片生成响应失败:{error}"
|
||
))
|
||
})?;
|
||
if !status.is_success() {
|
||
return Err(map_match3d_vector_engine_gemini_image_upstream_error(
|
||
status,
|
||
response_text.as_str(),
|
||
failure_context,
|
||
));
|
||
}
|
||
|
||
let payload = parse_match3d_json_payload(
|
||
response_text.as_str(),
|
||
"解析抓大鹅 VectorEngine Gemini 图片生成响应失败",
|
||
"vector-engine-gemini",
|
||
)?;
|
||
let image_urls = extract_match3d_image_urls(&payload);
|
||
if !image_urls.is_empty() {
|
||
return download_match3d_images_from_urls(
|
||
http_client,
|
||
format!("vector-engine-gemini-{}", current_utc_micros()),
|
||
image_urls,
|
||
1,
|
||
"vector-engine-gemini",
|
||
)
|
||
.await;
|
||
}
|
||
|
||
let b64_images = extract_match3d_b64_images(&payload);
|
||
if !b64_images.is_empty() {
|
||
return Ok(match3d_images_from_base64(
|
||
format!("vector-engine-gemini-{}", current_utc_micros()),
|
||
b64_images,
|
||
1,
|
||
));
|
||
}
|
||
|
||
Err(
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "vector-engine-gemini",
|
||
"message": "抓大鹅 VectorEngine Gemini 图片生成未返回图片",
|
||
"rawExcerpt": trim_match3d_upstream_excerpt(response_text.as_str(), 800),
|
||
})),
|
||
)
|
||
}
|
||
|
||
pub(super) fn build_match3d_vector_engine_gemini_image_request_body(
|
||
prompt: &str,
|
||
negative_prompt: &str,
|
||
aspect_ratio: &str,
|
||
) -> Value {
|
||
json!({
|
||
"contents": [{
|
||
"role": "user",
|
||
"parts": [{
|
||
"text": build_match3d_vector_engine_gemini_prompt(prompt, negative_prompt),
|
||
}],
|
||
}],
|
||
"generationConfig": {
|
||
"responseModalities": ["TEXT", "IMAGE"],
|
||
"imageConfig": {
|
||
"aspectRatio": aspect_ratio,
|
||
},
|
||
},
|
||
})
|
||
}
|
||
|
||
pub(super) fn build_match3d_vector_engine_gemini_generate_content_url(
|
||
settings: &Match3DVectorEngineGeminiImageSettings,
|
||
) -> String {
|
||
let base_url = settings.base_url.trim_end_matches("/v1");
|
||
format!(
|
||
"{}/v1beta/models/{}:generateContent",
|
||
base_url, MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_MODEL
|
||
)
|
||
}
|
||
|
||
fn build_match3d_vector_engine_gemini_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 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_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
|
||
}
|
||
|
||
pub(super) fn extract_match3d_b64_images(payload: &Value) -> Vec<String> {
|
||
let mut values = Vec::new();
|
||
collect_match3d_strings_by_key(payload, "b64_json", &mut values);
|
||
collect_match3d_inline_image_data(payload, &mut values);
|
||
values
|
||
}
|
||
|
||
fn collect_match3d_inline_image_data(payload: &Value, results: &mut Vec<String>) {
|
||
match payload {
|
||
Value::Array(entries) => {
|
||
for entry in entries {
|
||
collect_match3d_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_match3d_inline_image_data(nested_value, results);
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
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_vector_engine_gemini_image_request_error(message: String) -> AppError {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "vector-engine-gemini",
|
||
"message": message,
|
||
}))
|
||
}
|
||
|
||
fn map_match3d_vector_engine_gemini_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 = "vector-engine-gemini",
|
||
upstream_status = upstream_status.as_u16(),
|
||
message = %message,
|
||
raw_excerpt = %raw_excerpt,
|
||
"抓大鹅 VectorEngine Gemini 图片生成上游请求失败"
|
||
);
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "vector-engine-gemini",
|
||
"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(),
|
||
}
|
||
}
|
||
|
||
pub(super) 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",
|
||
}
|
||
}
|