Files
Genarrative/server-rs/crates/api-server/src/puzzle/generation.rs
T

638 lines
22 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_use_uploaded_puzzle_image_directly(
reference_image_src: Option<&str>,
ai_redraw: bool,
) -> bool {
!ai_redraw
&& reference_image_src
.map(str::trim)
.is_some_and(|value| !value.is_empty())
}
pub(crate) async fn create_uploaded_puzzle_image_candidate(
state: &PuzzleApiState,
owner_user_id: &str,
session_id: &str,
level_name: &str,
prompt: &str,
reference_image_src: &str,
candidate_start_index: usize,
) -> Result<GeneratedPuzzleImageCandidate, AppError> {
let http_client = reqwest::Client::new();
let downloaded_image =
resolve_puzzle_reference_image_as_data_url(state, &http_client, reference_image_src)
.await
.map(PuzzleDownloadedImage::from_resolved_reference_image)
.map_err(|error| {
if error.status_code() == StatusCode::BAD_REQUEST {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"field": "referenceImageSrc",
"message": "关闭 AI 重绘时上传图必须是图片 Data URL 或历史生成图片路径。",
}))
} else {
error
}
})?;
let candidate_id = format!("{session_id}-candidate-{}", candidate_start_index + 1);
let asset = persist_puzzle_generated_asset(
state,
owner_user_id,
session_id,
level_name,
candidate_id.as_str(),
"uploaded-direct",
downloaded_image.clone(),
current_utc_micros(),
)
.await
.map_err(map_puzzle_generation_endpoint_error)?;
Ok(GeneratedPuzzleImageCandidate {
record: PuzzleGeneratedImageCandidateRecord {
candidate_id,
image_src: asset.image_src,
asset_id: asset.asset_id,
prompt: prompt.to_string(),
actual_prompt: None,
source_type: "uploaded".to_string(),
selected: true,
},
downloaded_image,
})
}
pub(crate) async fn generate_puzzle_image_candidates(
state: &PuzzleApiState,
owner_user_id: &str,
profile_id: Option<&str>,
session_id: &str,
level_name: &str,
prompt: &str,
reference_image_src: Option<&str>,
use_reference_image_generation: 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_generation = should_use_puzzle_reference_image_generation(
reference_image_src,
use_reference_image_generation,
);
let actual_prompt = build_puzzle_vector_engine_generation_prompt(
build_puzzle_image_prompt(level_name, prompt).as_str(),
should_use_reference_image_generation,
);
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_generation = should_use_reference_image_generation,
"拼图图片生成请求已准备"
);
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_generation)
{
Some(source) => {
let resolved =
resolve_puzzle_reference_image(state, &http_client, source, Some(owner_user_id))
.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_generation {
tracing::info!(
provider = resolved_model.provider_name(),
image_model = resolved_model.request_model_name(),
session_id,
level_name,
has_reference_image,
use_reference_image_generation = should_use_reference_image_generation,
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 settings = PuzzleVectorEngineSettings {
external_api_audit_user_id: Some(owner_user_id.to_string()),
external_api_audit_profile_id: profile_id.map(ToOwned::to_owned),
..settings
};
let vector_engine_started_at = Instant::now();
let generated = if should_use_reference_image_generation {
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 重绘需要提供参考图。",
}))
})?;
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
} 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: &PuzzleApiState,
request_context: &RequestContext,
owner_user_id: &str,
session_id: &str,
level_name: &str,
prompt: &str,
) -> Result<GeneratedPuzzleUiBackgroundResponse, AppError> {
let settings = require_openai_image_settings(state.root_state())?
.with_external_api_audit_context(
request_context,
Some(owner_user_id.to_string()),
Some(session_id.to_string()),
);
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
}
pub(crate) async fn generate_puzzle_level_asset_bundle(
state: &PuzzleApiState,
request_context: &RequestContext,
owner_user_id: &str,
session_id: &str,
level_name: &str,
puzzle_image: &PuzzleDownloadedImage,
) -> Result<GeneratedPuzzleLevelAssetBundle, AppError> {
let settings = require_puzzle_vector_engine_settings(state)?.with_external_api_audit_context(
request_context,
Some(owner_user_id.to_string()),
Some(session_id.to_string()),
);
let http_client = build_puzzle_image_http_client(state, PuzzleImageModel::GptImage2)?;
let puzzle_reference = build_puzzle_downloaded_image_reference(puzzle_image);
let bundle_started_at = Instant::now();
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
image_model = PuzzleImageModel::GptImage2.request_model_name(),
session_id,
level_name,
"拼图关卡资产包生成开始"
);
let scene_started_at = Instant::now();
let scene_generated = match create_puzzle_vector_engine_image_generation(
&http_client,
&settings,
PuzzleImageModel::GptImage2,
PUZZLE_LEVEL_SCENE_IMAGE_PROMPT,
"",
PUZZLE_VECTOR_ENGINE_PORTRAIT_IMAGE_SIZE,
1,
Some(&puzzle_reference),
)
.await
.map_err(map_puzzle_generation_endpoint_error)
{
Ok(generated) => {
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
image_model = PuzzleImageModel::GptImage2.request_model_name(),
session_id,
level_name,
slot = "level_scene",
elapsed_ms = scene_started_at.elapsed().as_millis() as u64,
"拼图关卡场景图生成完成"
);
generated
}
Err(error) => {
tracing::warn!(
provider = VECTOR_ENGINE_PROVIDER,
image_model = PuzzleImageModel::GptImage2.request_model_name(),
session_id,
level_name,
slot = "level_scene",
elapsed_ms = scene_started_at.elapsed().as_millis() as u64,
error = %error,
"拼图关卡场景图生成失败"
);
return Err(error);
}
};
let scene_image = scene_generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"message": "拼图关卡画面图生成失败:未返回图片",
}))
})?;
let scene_reference = build_puzzle_downloaded_image_reference(&scene_image);
let scene_persist_started_at = Instant::now();
let level_scene = persist_puzzle_level_asset_image(
state,
owner_user_id,
session_id,
level_name,
scene_generated.task_id.as_str(),
"level-scene",
"puzzle_level_scene_image",
"level_scene",
"scene",
scene_image,
)
.await?;
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
image_model = PuzzleImageModel::GptImage2.request_model_name(),
session_id,
level_name,
slot = "level_scene",
elapsed_ms = scene_persist_started_at.elapsed().as_millis() as u64,
"拼图关卡场景图持久化完成"
);
let ui_spritesheet = generate_and_persist_puzzle_level_asset(
state,
&http_client,
&settings,
owner_user_id,
session_id,
level_name,
PUZZLE_UI_SPRITESHEET_IMAGE_PROMPT,
PUZZLE_VECTOR_ENGINE_SQUARE_IMAGE_SIZE,
&scene_reference,
"ui-spritesheet",
"puzzle_ui_spritesheet_image",
"ui_spritesheet",
"spritesheet",
)
.await?;
let level_background = generate_and_persist_puzzle_level_asset(
state,
&http_client,
&settings,
owner_user_id,
session_id,
level_name,
PUZZLE_LEVEL_BACKGROUND_IMAGE_PROMPT,
PUZZLE_VECTOR_ENGINE_PORTRAIT_IMAGE_SIZE,
&scene_reference,
"level-background",
"puzzle_level_background_image",
"level_background",
"background",
)
.await?;
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
image_model = PuzzleImageModel::GptImage2.request_model_name(),
session_id,
level_name,
elapsed_ms = bundle_started_at.elapsed().as_millis() as u64,
"拼图关卡资产包生成完成"
);
Ok(GeneratedPuzzleLevelAssetBundle {
level_scene,
ui_spritesheet,
level_background,
})
}
async fn generate_and_persist_puzzle_level_asset(
state: &PuzzleApiState,
http_client: &reqwest::Client,
settings: &PuzzleVectorEngineSettings,
owner_user_id: &str,
session_id: &str,
level_name: &str,
prompt: &str,
size: &str,
reference_image: &PuzzleResolvedReferenceImage,
path_segment: &str,
asset_kind: &str,
slot: &str,
file_stem: &str,
) -> Result<GeneratedPuzzleLevelAssetResponse, AppError> {
let started_at = Instant::now();
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
image_model = PuzzleImageModel::GptImage2.request_model_name(),
session_id,
level_name,
slot,
asset_kind,
size,
prompt_chars = prompt.chars().count(),
reference_image_bytes = reference_image.bytes_len,
"拼图关卡资产生成请求开始"
);
let generated = match create_puzzle_vector_engine_image_generation(
http_client,
settings,
PuzzleImageModel::GptImage2,
prompt,
"",
size,
1,
Some(reference_image),
)
.await
.map_err(map_puzzle_generation_endpoint_error)
{
Ok(generated) => {
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
image_model = PuzzleImageModel::GptImage2.request_model_name(),
session_id,
level_name,
slot,
asset_kind,
elapsed_ms = started_at.elapsed().as_millis() as u64,
"拼图关卡资产生成请求完成"
);
generated
}
Err(error) => {
tracing::warn!(
provider = VECTOR_ENGINE_PROVIDER,
image_model = PuzzleImageModel::GptImage2.request_model_name(),
session_id,
level_name,
slot,
asset_kind,
elapsed_ms = started_at.elapsed().as_millis() as u64,
error = %error,
"拼图关卡资产生成请求失败"
);
return Err(error);
}
};
let image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"message": format!("拼图关卡资产生成失败:{asset_kind} 未返回图片"),
}))
})?;
let image = if slot == "ui_spritesheet" {
make_puzzle_ui_spritesheet_image_transparent(image)?
} else {
image
};
let persist_started_at = Instant::now();
let persisted = persist_puzzle_level_asset_image(
state,
owner_user_id,
session_id,
level_name,
generated.task_id.as_str(),
path_segment,
asset_kind,
slot,
file_stem,
image,
)
.await?;
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
image_model = PuzzleImageModel::GptImage2.request_model_name(),
session_id,
level_name,
slot,
asset_kind,
elapsed_ms = persist_started_at.elapsed().as_millis() as u64,
"拼图关卡资产持久化完成"
);
Ok(persisted)
}
pub(crate) fn make_puzzle_ui_spritesheet_image_transparent(
image: PuzzleDownloadedImage,
) -> Result<PuzzleDownloadedImage, AppError> {
let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"message": format!("拼图 UI spritesheet 图解码失败:{error}"),
}))
})?;
let mut encoded = std::io::Cursor::new(Vec::new());
apply_generated_asset_sheet_green_screen_alpha(source)
.write_to(&mut encoded, ImageFormat::Png)
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"message": format!("拼图 UI spritesheet 图透明化失败:{error}"),
}))
})?;
Ok(PuzzleDownloadedImage {
extension: "png".to_string(),
mime_type: "image/png".to_string(),
bytes: encoded.into_inner(),
})
}
#[cfg(test)]
pub(crate) fn make_puzzle_ui_spritesheet_image_transparent_for_test(
image: PuzzleDownloadedImage,
) -> Result<PuzzleDownloadedImage, AppError> {
make_puzzle_ui_spritesheet_image_transparent(image)
}
#[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)
}
#[cfg(test)]
pub(crate) fn build_puzzle_level_scene_image_request_body_for_test(
reference_image: &PuzzleDownloadedImage,
) -> Result<Value, AppError> {
Ok(build_puzzle_vector_engine_image_request_body(
PuzzleImageModel::GptImage2,
PUZZLE_LEVEL_SCENE_IMAGE_PROMPT,
"",
PUZZLE_VECTOR_ENGINE_PORTRAIT_IMAGE_SIZE,
1,
Some(&build_puzzle_downloaded_image_reference(reference_image)),
))
}
#[cfg(test)]
pub(crate) fn build_puzzle_ui_spritesheet_request_body_for_test(
reference_image: &PuzzleDownloadedImage,
) -> Result<Value, AppError> {
Ok(build_puzzle_vector_engine_image_request_body(
PuzzleImageModel::GptImage2,
PUZZLE_UI_SPRITESHEET_IMAGE_PROMPT,
"",
PUZZLE_VECTOR_ENGINE_SQUARE_IMAGE_SIZE,
1,
Some(&build_puzzle_downloaded_image_reference(reference_image)),
))
}
#[cfg(test)]
pub(crate) fn build_puzzle_level_background_request_body_for_test(
reference_image: &PuzzleDownloadedImage,
) -> Result<Value, AppError> {
Ok(build_puzzle_vector_engine_image_request_body(
PuzzleImageModel::GptImage2,
PUZZLE_LEVEL_BACKGROUND_IMAGE_PROMPT,
"",
PUZZLE_VECTOR_ENGINE_PORTRAIT_IMAGE_SIZE,
1,
Some(&build_puzzle_downloaded_image_reference(reference_image)),
))
}