265 lines
9.9 KiB
Rust
265 lines
9.9 KiB
Rust
use super::*;
|
||
|
||
pub(crate) fn map_puzzle_generation_endpoint_error(error: AppError) -> AppError {
|
||
if error.code() == "UPSTREAM_ERROR" {
|
||
let body_text = error.body_text();
|
||
return AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||
"message": format!("拼图图片生成失败:{body_text}"),
|
||
}));
|
||
}
|
||
|
||
error
|
||
}
|
||
|
||
pub(crate) fn should_fallback_puzzle_reference_edit_to_generation(error: &AppError) -> bool {
|
||
error.status_code() == StatusCode::GATEWAY_TIMEOUT
|
||
|| is_puzzle_request_timeout_message(error.body_text().as_str())
|
||
}
|
||
|
||
pub(crate) async fn generate_puzzle_image_candidates(
|
||
state: &AppState,
|
||
owner_user_id: &str,
|
||
session_id: &str,
|
||
level_name: &str,
|
||
prompt: &str,
|
||
reference_image_src: Option<&str>,
|
||
use_reference_image_edit: bool,
|
||
image_model: Option<&str>,
|
||
candidate_count: u32,
|
||
candidate_start_index: usize,
|
||
) -> Result<Vec<GeneratedPuzzleImageCandidate>, AppError> {
|
||
let total_started_at = Instant::now();
|
||
let count = candidate_count.clamp(1, 1);
|
||
let resolved_model = resolve_puzzle_image_model(image_model);
|
||
let http_client = build_puzzle_image_http_client(state, resolved_model)?;
|
||
let has_reference_image = has_puzzle_reference_image(reference_image_src);
|
||
let should_use_reference_image_edit =
|
||
should_use_puzzle_reference_image_edit(reference_image_src, use_reference_image_edit);
|
||
let actual_prompt = build_puzzle_vector_engine_generation_prompt(
|
||
build_puzzle_image_prompt(level_name, prompt).as_str(),
|
||
should_use_reference_image_edit,
|
||
);
|
||
tracing::info!(
|
||
provider = resolved_model.provider_name(),
|
||
image_model = resolved_model.request_model_name(),
|
||
session_id,
|
||
level_name,
|
||
prompt_chars = prompt.chars().count(),
|
||
actual_prompt_chars = actual_prompt.chars().count(),
|
||
has_reference_image,
|
||
use_reference_image_edit = should_use_reference_image_edit,
|
||
"拼图图片生成请求已准备"
|
||
);
|
||
let reference_image_started_at = Instant::now();
|
||
let reference_image = match reference_image_src
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.filter(|_| should_use_reference_image_edit)
|
||
{
|
||
Some(source) => {
|
||
let resolved =
|
||
resolve_puzzle_reference_image_as_data_url(state, &http_client, source).await?;
|
||
tracing::info!(
|
||
provider = resolved_model.provider_name(),
|
||
image_model = resolved_model.request_model_name(),
|
||
session_id,
|
||
level_name,
|
||
reference_mime = %resolved.mime_type,
|
||
reference_bytes = resolved.bytes_len,
|
||
elapsed_ms = reference_image_started_at.elapsed().as_millis() as u64,
|
||
"拼图参考图解析完成"
|
||
);
|
||
Some(resolved)
|
||
}
|
||
None => None,
|
||
};
|
||
if !should_use_reference_image_edit {
|
||
tracing::info!(
|
||
provider = resolved_model.provider_name(),
|
||
image_model = resolved_model.request_model_name(),
|
||
session_id,
|
||
level_name,
|
||
has_reference_image,
|
||
use_reference_image_edit = should_use_reference_image_edit,
|
||
elapsed_ms = reference_image_started_at.elapsed().as_millis() as u64,
|
||
"拼图参考图解析跳过"
|
||
);
|
||
}
|
||
// 中文注释:SpacetimeDB reducer 不能做外部 I/O,参考图读取与外部生图都必须停留在 api-server。
|
||
// 中文注释:拼图作品资产统一按 1:1 正方形生成,前端运行时也按正方形棋盘切块承载。
|
||
let settings = require_puzzle_vector_engine_settings(state)?;
|
||
let vector_engine_started_at = Instant::now();
|
||
let generated = if should_use_reference_image_edit {
|
||
let reference_image = reference_image.as_ref().ok_or_else(|| {
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "puzzle",
|
||
"field": "referenceImageSrc",
|
||
"message": "AI 重绘需要提供参考图。",
|
||
}))
|
||
})?;
|
||
let edit_result = create_puzzle_vector_engine_image_edit(
|
||
&http_client,
|
||
&settings,
|
||
actual_prompt.as_str(),
|
||
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
|
||
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
|
||
count,
|
||
reference_image,
|
||
)
|
||
.await;
|
||
match edit_result {
|
||
Ok(generated) => Ok(generated),
|
||
Err(error) if should_fallback_puzzle_reference_edit_to_generation(&error) => {
|
||
tracing::warn!(
|
||
provider = resolved_model.provider_name(),
|
||
image_model = resolved_model.request_model_name(),
|
||
session_id,
|
||
level_name,
|
||
reference_mime = %reference_image.mime_type,
|
||
reference_bytes = reference_image.bytes_len,
|
||
error = %error,
|
||
"拼图参考图编辑接口超时,降级为带参考图的生成接口"
|
||
);
|
||
create_puzzle_vector_engine_image_generation(
|
||
&http_client,
|
||
&settings,
|
||
resolved_model,
|
||
actual_prompt.as_str(),
|
||
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
|
||
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
|
||
count,
|
||
Some(reference_image),
|
||
)
|
||
.await
|
||
}
|
||
Err(error) => Err(error),
|
||
}
|
||
} else {
|
||
create_puzzle_vector_engine_image_generation(
|
||
&http_client,
|
||
&settings,
|
||
resolved_model,
|
||
actual_prompt.as_str(),
|
||
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
|
||
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
|
||
count,
|
||
None,
|
||
)
|
||
.await
|
||
}
|
||
.map_err(map_puzzle_generation_endpoint_error)?;
|
||
tracing::info!(
|
||
provider = resolved_model.provider_name(),
|
||
image_model = resolved_model.request_model_name(),
|
||
session_id,
|
||
level_name,
|
||
generated_image_count = generated.images.len(),
|
||
elapsed_ms = vector_engine_started_at.elapsed().as_millis() as u64,
|
||
"拼图 VectorEngine 生图与下载完成"
|
||
);
|
||
let mut items = Vec::with_capacity(generated.images.len());
|
||
|
||
for (index, image) in generated.images.into_iter().enumerate() {
|
||
let candidate_id = format!(
|
||
"{session_id}-candidate-{}",
|
||
candidate_start_index + index + 1
|
||
);
|
||
let downloaded_image = image.clone();
|
||
let persist_started_at = Instant::now();
|
||
let asset = persist_puzzle_generated_asset(
|
||
state,
|
||
owner_user_id,
|
||
session_id,
|
||
level_name,
|
||
candidate_id.as_str(),
|
||
generated.task_id.as_str(),
|
||
image,
|
||
current_utc_micros(),
|
||
)
|
||
.await
|
||
.map_err(map_puzzle_generation_endpoint_error)?;
|
||
tracing::info!(
|
||
provider = resolved_model.provider_name(),
|
||
image_model = resolved_model.request_model_name(),
|
||
session_id,
|
||
level_name,
|
||
candidate_id = %candidate_id,
|
||
image_bytes = downloaded_image.bytes.len(),
|
||
image_mime = %downloaded_image.mime_type,
|
||
elapsed_ms = persist_started_at.elapsed().as_millis() as u64,
|
||
"拼图生成图片已写入 OSS 与资产索引"
|
||
);
|
||
items.push(GeneratedPuzzleImageCandidate {
|
||
record: PuzzleGeneratedImageCandidateRecord {
|
||
candidate_id,
|
||
image_src: asset.image_src,
|
||
asset_id: asset.asset_id,
|
||
prompt: prompt.to_string(),
|
||
actual_prompt: Some(actual_prompt.clone()),
|
||
source_type: resolved_model.candidate_source_type().to_string(),
|
||
// 单图生成结果总是直接成为当前正式图。
|
||
selected: index == 0,
|
||
},
|
||
downloaded_image,
|
||
});
|
||
}
|
||
|
||
tracing::info!(
|
||
provider = resolved_model.provider_name(),
|
||
image_model = resolved_model.request_model_name(),
|
||
session_id,
|
||
level_name,
|
||
candidate_count = items.len(),
|
||
has_reference_image,
|
||
elapsed_ms = total_started_at.elapsed().as_millis() as u64,
|
||
"拼图图片候选生成完成"
|
||
);
|
||
Ok(items)
|
||
}
|
||
|
||
pub(crate) async fn generate_puzzle_ui_background_image(
|
||
state: &AppState,
|
||
owner_user_id: &str,
|
||
session_id: &str,
|
||
level_name: &str,
|
||
prompt: &str,
|
||
) -> Result<GeneratedPuzzleUiBackgroundResponse, AppError> {
|
||
let settings = require_openai_image_settings(state)?;
|
||
let http_client = build_openai_image_http_client(&settings)?;
|
||
let generated = create_openai_image_generation(
|
||
&http_client,
|
||
&settings,
|
||
build_puzzle_ui_background_generation_prompt(level_name, prompt).as_str(),
|
||
Some("文字、水印、按钮文字、数字、教程浮层、拼图碎片、完整拼图图像、拼图槽、棋盘、拼图区边框、物品槽、HUD、角色手指"),
|
||
"9:16",
|
||
1,
|
||
&[],
|
||
"拼图 UI 背景图生成失败",
|
||
)
|
||
.await?;
|
||
let image = generated.images.into_iter().next().ok_or_else(|| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": VECTOR_ENGINE_PROVIDER,
|
||
"message": "拼图 UI 背景图生成失败:未返回图片",
|
||
}))
|
||
})?;
|
||
persist_puzzle_ui_background_image(
|
||
state,
|
||
owner_user_id,
|
||
session_id,
|
||
level_name,
|
||
generated.task_id.as_str(),
|
||
image,
|
||
)
|
||
.await
|
||
}
|
||
|
||
#[cfg(test)]
|
||
pub(crate) fn build_puzzle_ui_background_request_prompt_for_test(
|
||
level_name: &str,
|
||
prompt: &str,
|
||
) -> String {
|
||
build_puzzle_ui_background_generation_prompt(level_name, prompt)
|
||
}
|