1142 lines
40 KiB
Rust
1142 lines
40 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())
|
|
}
|
|
|
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub(crate) struct PuzzleGenerateImagesWorkerPayload {
|
|
pub session_id: String,
|
|
pub owner_user_id: String,
|
|
pub billing_asset_id: String,
|
|
#[serde(default)]
|
|
pub level_id: Option<String>,
|
|
#[serde(default)]
|
|
pub prompt_text: Option<String>,
|
|
#[serde(default)]
|
|
pub reference_image_src: Option<String>,
|
|
#[serde(default)]
|
|
pub reference_image_srcs: Vec<String>,
|
|
#[serde(default)]
|
|
pub reference_image_asset_object_id: Option<String>,
|
|
#[serde(default)]
|
|
pub reference_image_asset_object_ids: Vec<String>,
|
|
#[serde(default)]
|
|
pub image_model: Option<String>,
|
|
#[serde(default)]
|
|
pub ai_redraw: Option<bool>,
|
|
#[serde(default)]
|
|
pub should_auto_name_level: Option<bool>,
|
|
#[serde(default)]
|
|
pub work_title: Option<String>,
|
|
#[serde(default)]
|
|
pub work_description: Option<String>,
|
|
#[serde(default)]
|
|
pub picture_description: Option<String>,
|
|
#[serde(default)]
|
|
pub summary: Option<String>,
|
|
#[serde(default)]
|
|
pub theme_tags: Option<Vec<String>>,
|
|
#[serde(default)]
|
|
pub levels_json: Option<String>,
|
|
pub requested_at_micros: i64,
|
|
}
|
|
|
|
impl PuzzleGenerateImagesWorkerPayload {
|
|
fn to_action_request(&self) -> ExecutePuzzleAgentActionRequest {
|
|
ExecutePuzzleAgentActionRequest {
|
|
action: "generate_puzzle_images".to_string(),
|
|
prompt_text: self.prompt_text.clone(),
|
|
reference_image_src: self.reference_image_src.clone(),
|
|
reference_image_srcs: self.reference_image_srcs.clone(),
|
|
reference_image_asset_object_id: self.reference_image_asset_object_id.clone(),
|
|
reference_image_asset_object_ids: self.reference_image_asset_object_ids.clone(),
|
|
image_model: self.image_model.clone(),
|
|
ai_redraw: self.ai_redraw,
|
|
candidate_count: Some(1),
|
|
should_auto_name_level: self.should_auto_name_level,
|
|
candidate_id: None,
|
|
level_id: self.level_id.clone(),
|
|
work_title: self.work_title.clone(),
|
|
work_description: self.work_description.clone(),
|
|
picture_description: self.picture_description.clone(),
|
|
level_name: None,
|
|
summary: self.summary.clone(),
|
|
theme_tags: self.theme_tags.clone(),
|
|
levels_json: self.levels_json.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub(crate) struct PuzzleGenerateUiBackgroundWorkerPayload {
|
|
pub session_id: String,
|
|
pub owner_user_id: String,
|
|
pub billing_asset_id: String,
|
|
#[serde(default)]
|
|
pub level_id: Option<String>,
|
|
#[serde(default)]
|
|
pub prompt_text: Option<String>,
|
|
#[serde(default)]
|
|
pub levels_json: Option<String>,
|
|
pub requested_at_micros: i64,
|
|
}
|
|
|
|
impl PuzzleGenerateUiBackgroundWorkerPayload {
|
|
fn to_action_request(&self) -> ExecutePuzzleAgentActionRequest {
|
|
ExecutePuzzleAgentActionRequest {
|
|
action: "generate_puzzle_ui_background".to_string(),
|
|
prompt_text: self.prompt_text.clone(),
|
|
reference_image_src: None,
|
|
reference_image_srcs: Vec::new(),
|
|
reference_image_asset_object_id: None,
|
|
reference_image_asset_object_ids: Vec::new(),
|
|
image_model: None,
|
|
ai_redraw: None,
|
|
candidate_count: None,
|
|
should_auto_name_level: None,
|
|
candidate_id: None,
|
|
level_id: self.level_id.clone(),
|
|
work_title: None,
|
|
work_description: None,
|
|
picture_description: None,
|
|
level_name: None,
|
|
summary: None,
|
|
theme_tags: None,
|
|
levels_json: self.levels_json.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn execute_puzzle_generate_images_worker_job(
|
|
state: &PuzzleApiState,
|
|
request_context: &RequestContext,
|
|
payload: PuzzleGenerateImagesWorkerPayload,
|
|
external_generation_guard: ExternalGenerationWriteLeaseGuard,
|
|
) -> Result<PuzzleAgentSessionRecord, PuzzleExternalGenerationWorkerError> {
|
|
let now = current_utc_micros();
|
|
let session = execute_billable_asset_operation_with_cost(
|
|
state.root_state(),
|
|
&payload.owner_user_id,
|
|
"puzzle_generated_image",
|
|
&payload.billing_asset_id,
|
|
PUZZLE_IMAGE_GENERATION_POINTS_COST,
|
|
async {
|
|
execute_puzzle_generate_images_worker_job_inner(
|
|
state,
|
|
request_context,
|
|
&payload,
|
|
now,
|
|
&external_generation_guard,
|
|
)
|
|
.await
|
|
},
|
|
)
|
|
.await;
|
|
|
|
match session {
|
|
Ok(session) => Ok(session),
|
|
Err(error) => {
|
|
match mark_puzzle_level_generation_failure_for_worker(
|
|
state,
|
|
&payload,
|
|
error.body_text(),
|
|
now,
|
|
&external_generation_guard,
|
|
)
|
|
.await
|
|
{
|
|
Ok(()) => {
|
|
Err(PuzzleExternalGenerationWorkerError::with_failure_state_written(error))
|
|
}
|
|
Err(mark_error) => {
|
|
Err(PuzzleExternalGenerationWorkerError::with_failure_state_pending(mark_error))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn execute_puzzle_generate_ui_background_worker_job(
|
|
state: &PuzzleApiState,
|
|
request_context: &RequestContext,
|
|
payload: PuzzleGenerateUiBackgroundWorkerPayload,
|
|
external_generation_guard: ExternalGenerationWriteLeaseGuard,
|
|
) -> Result<PuzzleAgentSessionRecord, PuzzleExternalGenerationWorkerError> {
|
|
let now = current_utc_micros();
|
|
let session = execute_billable_asset_operation_with_cost(
|
|
state.root_state(),
|
|
&payload.owner_user_id,
|
|
"puzzle_ui_background_image",
|
|
&payload.billing_asset_id,
|
|
PUZZLE_IMAGE_GENERATION_POINTS_COST,
|
|
async {
|
|
execute_puzzle_generate_ui_background_worker_job_inner(
|
|
state,
|
|
request_context,
|
|
&payload,
|
|
now,
|
|
&external_generation_guard,
|
|
)
|
|
.await
|
|
},
|
|
)
|
|
.await;
|
|
|
|
match session {
|
|
Ok(session) => Ok(session),
|
|
Err(error) => {
|
|
match mark_puzzle_level_generation_failure_for_external_generation(
|
|
state,
|
|
&payload.session_id,
|
|
&payload.owner_user_id,
|
|
payload.level_id.clone(),
|
|
payload.levels_json.clone(),
|
|
error.body_text(),
|
|
now,
|
|
&external_generation_guard,
|
|
)
|
|
.await
|
|
{
|
|
Ok(()) => {
|
|
Err(PuzzleExternalGenerationWorkerError::with_failure_state_written(error))
|
|
}
|
|
Err(mark_error) => {
|
|
Err(PuzzleExternalGenerationWorkerError::with_failure_state_pending(mark_error))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn execute_puzzle_generate_images_worker_job_inner(
|
|
state: &PuzzleApiState,
|
|
request_context: &RequestContext,
|
|
payload: &PuzzleGenerateImagesWorkerPayload,
|
|
now: i64,
|
|
external_generation_guard: &ExternalGenerationWriteLeaseGuard,
|
|
) -> Result<PuzzleAgentSessionRecord, AppError> {
|
|
let action_payload = payload.to_action_request();
|
|
let target_level_id = payload.level_id.clone();
|
|
let levels_json = payload.levels_json.clone();
|
|
let session = get_puzzle_session_for_image_generation(
|
|
state,
|
|
payload.session_id.clone(),
|
|
payload.owner_user_id.clone(),
|
|
&action_payload,
|
|
levels_json.as_deref(),
|
|
now,
|
|
)
|
|
.await?;
|
|
let mut draft = session.draft.clone().ok_or_else(|| {
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
|
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
|
"message": "拼图结果页草稿尚未生成",
|
|
}))
|
|
})?;
|
|
if let Some(levels_json) = levels_json.as_ref() {
|
|
draft.levels = parse_puzzle_level_records_from_module_json(levels_json)?;
|
|
}
|
|
let mut target_level = select_puzzle_level_for_api(&draft, target_level_id.as_deref())?;
|
|
let prompt = resolve_puzzle_level_image_prompt(
|
|
payload.prompt_text.as_deref(),
|
|
&target_level.picture_description,
|
|
&draft.summary,
|
|
);
|
|
let should_auto_name_level = payload
|
|
.should_auto_name_level
|
|
.unwrap_or_else(|| target_level.level_name.trim().is_empty());
|
|
if should_auto_name_level {
|
|
let naming =
|
|
generate_puzzle_first_level_name(state, target_level.picture_description.as_str())
|
|
.await;
|
|
target_level.level_name = naming.level_name.clone();
|
|
target_level.ui_background_prompt = naming.ui_background_prompt.clone();
|
|
}
|
|
let reference_image_sources = collect_puzzle_reference_image_sources(
|
|
payload.reference_image_src.as_deref(),
|
|
payload.reference_image_srcs.as_slice(),
|
|
payload.reference_image_asset_object_id.as_deref(),
|
|
payload.reference_image_asset_object_ids.as_slice(),
|
|
);
|
|
let primary_reference_image_src = reference_image_sources.first().map(String::as_str);
|
|
// 中文注释:拼图结果页从多候选抽卡收口为单图替换,前端传入的旧 candidateCount 只做兼容忽略。
|
|
let candidate_start_index = target_level.candidates.len();
|
|
let ai_redraw = payload.ai_redraw.unwrap_or(true);
|
|
let mut candidates =
|
|
if should_use_uploaded_puzzle_image_directly(primary_reference_image_src, ai_redraw) {
|
|
vec![
|
|
create_uploaded_puzzle_image_candidate(
|
|
state,
|
|
payload.owner_user_id.as_str(),
|
|
&session.session_id,
|
|
&target_level.level_name,
|
|
&prompt,
|
|
primary_reference_image_src.expect("checked reference image"),
|
|
candidate_start_index,
|
|
)
|
|
.await?,
|
|
]
|
|
} else {
|
|
let (_, profile_id) = build_stable_puzzle_work_ids(&session.session_id);
|
|
generate_puzzle_image_candidates(
|
|
state,
|
|
payload.owner_user_id.as_str(),
|
|
Some(profile_id.as_str()),
|
|
&session.session_id,
|
|
&target_level.level_name,
|
|
&prompt,
|
|
primary_reference_image_src,
|
|
ai_redraw,
|
|
payload.image_model.as_deref(),
|
|
1,
|
|
candidate_start_index,
|
|
)
|
|
.await
|
|
.map_err(map_puzzle_generation_endpoint_error)?
|
|
};
|
|
if candidates.is_empty() {
|
|
return Err(
|
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
|
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
|
"message": "拼图候选图生成结果为空",
|
|
})),
|
|
);
|
|
}
|
|
if let Some(refined_naming) = generate_puzzle_first_level_name_from_image(
|
|
state,
|
|
target_level.picture_description.as_str(),
|
|
&candidates[0].downloaded_image,
|
|
)
|
|
.await
|
|
.filter(|_| should_auto_name_level)
|
|
{
|
|
target_level.level_name = refined_naming.level_name.clone();
|
|
if refined_naming.ui_background_prompt.is_some() {
|
|
target_level.ui_background_prompt = refined_naming.ui_background_prompt.clone();
|
|
}
|
|
}
|
|
let mut updated_levels =
|
|
build_puzzle_levels_with_primary_update(&draft, &target_level, primary_reference_image_src);
|
|
for candidate in &mut candidates {
|
|
candidate.record.prompt = prompt.clone();
|
|
}
|
|
let selected_candidate = candidates
|
|
.iter()
|
|
.find(|candidate| candidate.record.selected)
|
|
.or_else(|| candidates.first())
|
|
.ok_or_else(|| {
|
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
|
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
|
"message": "拼图候选图生成结果为空",
|
|
}))
|
|
})?;
|
|
let asset_bundle = generate_puzzle_level_asset_bundle_required(
|
|
state,
|
|
request_context,
|
|
payload.owner_user_id.as_str(),
|
|
&session.session_id,
|
|
&target_level,
|
|
&selected_candidate.downloaded_image,
|
|
)
|
|
.await?;
|
|
attach_puzzle_level_asset_bundle(
|
|
&mut updated_levels,
|
|
target_level.level_id.as_str(),
|
|
asset_bundle,
|
|
);
|
|
attach_selected_puzzle_candidate_to_levels(
|
|
&mut updated_levels,
|
|
target_level.level_id.as_str(),
|
|
&selected_candidate.record,
|
|
);
|
|
let levels_json_with_generated_name =
|
|
Some(serialize_puzzle_level_records_for_module(&updated_levels)?);
|
|
let candidates_json = serde_json::to_string(
|
|
&candidates
|
|
.iter()
|
|
.map(|candidate| to_puzzle_generated_image_candidate(&candidate.record))
|
|
.collect::<Vec<_>>(),
|
|
)
|
|
.map_err(|error| {
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
|
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
|
"message": format!("拼图候选图序列化失败:{error}"),
|
|
}))
|
|
})?;
|
|
state
|
|
.spacetime_client()
|
|
.save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput {
|
|
session_id: session.session_id.clone(),
|
|
owner_user_id: payload.owner_user_id.clone(),
|
|
level_id: Some(target_level.level_id.clone()),
|
|
levels_json: levels_json_with_generated_name,
|
|
candidates_json,
|
|
saved_at_micros: now,
|
|
external_generation_job_id: external_generation_guard.job_id.clone(),
|
|
external_generation_worker_id: external_generation_guard.worker_id.clone(),
|
|
external_generation_lease_token: external_generation_guard.lease_token.clone(),
|
|
})
|
|
.await
|
|
.map_err(map_puzzle_client_error)
|
|
}
|
|
|
|
async fn execute_puzzle_generate_ui_background_worker_job_inner(
|
|
state: &PuzzleApiState,
|
|
request_context: &RequestContext,
|
|
payload: &PuzzleGenerateUiBackgroundWorkerPayload,
|
|
now: i64,
|
|
external_generation_guard: &ExternalGenerationWriteLeaseGuard,
|
|
) -> Result<PuzzleAgentSessionRecord, AppError> {
|
|
let action_payload = payload.to_action_request();
|
|
let target_level_id = payload.level_id.clone();
|
|
let levels_json = payload.levels_json.clone();
|
|
let session = get_puzzle_session_for_image_generation(
|
|
state,
|
|
payload.session_id.clone(),
|
|
payload.owner_user_id.clone(),
|
|
&action_payload,
|
|
levels_json.as_deref(),
|
|
now,
|
|
)
|
|
.await?;
|
|
let mut draft = session.draft.clone().ok_or_else(|| {
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
|
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
|
"message": "拼图结果页草稿尚未生成",
|
|
}))
|
|
})?;
|
|
if let Some(levels_json) = levels_json.as_ref() {
|
|
draft.levels = parse_puzzle_level_records_from_module_json(levels_json)?;
|
|
}
|
|
let target_level = select_puzzle_level_for_api(&draft, target_level_id.as_deref())?;
|
|
let raw_prompt = payload
|
|
.prompt_text
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.unwrap_or_default()
|
|
.to_string();
|
|
let resolved_prompt =
|
|
normalize_puzzle_ui_background_prompt(raw_prompt.as_str(), &draft, &target_level);
|
|
let generated = generate_puzzle_ui_background_image(
|
|
state,
|
|
request_context,
|
|
payload.owner_user_id.as_str(),
|
|
&session.session_id,
|
|
&target_level.level_name,
|
|
resolved_prompt.as_str(),
|
|
)
|
|
.await
|
|
.map_err(map_puzzle_generation_endpoint_error)?;
|
|
state
|
|
.spacetime_client()
|
|
.save_puzzle_ui_background(PuzzleUiBackgroundSaveRecordInput {
|
|
session_id: session.session_id.clone(),
|
|
owner_user_id: payload.owner_user_id.clone(),
|
|
level_id: Some(target_level.level_id.clone()),
|
|
levels_json,
|
|
prompt: resolved_prompt.clone(),
|
|
image_src: generated.image_src.clone(),
|
|
image_object_key: Some(generated.object_key.clone()),
|
|
saved_at_micros: now,
|
|
external_generation_job_id: external_generation_guard.job_id.clone(),
|
|
external_generation_worker_id: external_generation_guard.worker_id.clone(),
|
|
external_generation_lease_token: external_generation_guard.lease_token.clone(),
|
|
})
|
|
.await
|
|
.map_err(map_puzzle_client_error)
|
|
}
|
|
|
|
pub(crate) async fn mark_puzzle_level_generation_failure_for_worker(
|
|
state: &PuzzleApiState,
|
|
payload: &PuzzleGenerateImagesWorkerPayload,
|
|
error_message: String,
|
|
failed_at_micros: i64,
|
|
external_generation_guard: &ExternalGenerationWriteLeaseGuard,
|
|
) -> Result<(), AppError> {
|
|
mark_puzzle_level_generation_failure_for_external_generation(
|
|
state,
|
|
&payload.session_id,
|
|
&payload.owner_user_id,
|
|
payload.level_id.clone(),
|
|
payload.levels_json.clone(),
|
|
error_message,
|
|
failed_at_micros,
|
|
external_generation_guard,
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn mark_puzzle_level_generation_failure_for_external_generation(
|
|
state: &PuzzleApiState,
|
|
session_id: &str,
|
|
owner_user_id: &str,
|
|
level_id: Option<String>,
|
|
levels_json: Option<String>,
|
|
error_message: String,
|
|
failed_at_micros: i64,
|
|
external_generation_guard: &ExternalGenerationWriteLeaseGuard,
|
|
) -> Result<(), AppError> {
|
|
let result = state
|
|
.spacetime_client()
|
|
.mark_puzzle_level_generation_failed(PuzzleLevelGenerationFailureRecordInput {
|
|
session_id: session_id.to_string(),
|
|
owner_user_id: owner_user_id.to_string(),
|
|
level_id,
|
|
levels_json,
|
|
error_message,
|
|
failed_at_micros,
|
|
external_generation_job_id: external_generation_guard.job_id.clone(),
|
|
external_generation_worker_id: external_generation_guard.worker_id.clone(),
|
|
external_generation_lease_token: external_generation_guard.lease_token.clone(),
|
|
})
|
|
.await;
|
|
if let Err(error) = result {
|
|
tracing::warn!(
|
|
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
|
session_id = %session_id,
|
|
owner_user_id = %owner_user_id,
|
|
message = %error,
|
|
"拼图 worker 关卡生图失败态回写失败"
|
|
);
|
|
return Err(map_puzzle_client_error(error));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
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)),
|
|
))
|
|
}
|