落地方洞挑战图片与运行态交互
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
kdletters
2026-05-06 12:51:28 +08:00
parent 60b667a9d1
commit d06107f2c6
51 changed files with 2590 additions and 989 deletions

View File

@@ -1,4 +1,5 @@
use std::{
collections::BTreeMap,
convert::Infallible,
time::{SystemTime, UNIX_EPOCH},
};
@@ -14,11 +15,16 @@ use axum::{
},
};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use module_assets::{
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input,
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
};
use module_square_hole::{
SQUARE_HOLE_MESSAGE_ID_PREFIX, SQUARE_HOLE_PROFILE_ID_PREFIX, SQUARE_HOLE_RUN_ID_PREFIX,
SQUARE_HOLE_SESSION_ID_PREFIX, default_background_prompt, normalize_hole_options,
normalize_shape_options,
};
use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use shared_contracts::{
@@ -36,10 +42,11 @@ use shared_contracts::{
SquareHoleShapeSnapshotResponse, StartSquareHoleRunRequest, StopSquareHoleRunRequest,
},
square_hole_works::{
PutSquareHoleWorkRequest, SquareHoleHoleOptionResponse as SquareHoleWorkHoleOptionResponse,
PutSquareHoleWorkRequest, RegenerateSquareHoleWorkImageRequest,
SquareHoleHoleOptionResponse as SquareHoleWorkHoleOptionResponse,
SquareHoleShapeOptionResponse as SquareHoleWorkShapeOptionResponse,
SquareHoleWorkDetailResponse, SquareHoleWorkMutationResponse,
SquareHoleWorkProfileResponse, SquareHoleWorkSummaryResponse, SquareHoleWorksResponse,
SquareHoleWorkDetailResponse, SquareHoleWorkMutationResponse, SquareHoleWorkProfileResponse,
SquareHoleWorkSummaryResponse, SquareHoleWorksResponse,
},
};
use shared_kernel::build_prefixed_uuid_id;
@@ -60,9 +67,10 @@ use crate::{
auth::AuthenticatedAccessToken,
http_error::AppError,
openai_image_generation::{
build_openai_image_http_client, create_openai_image_generation,
DownloadedOpenAiImage, build_openai_image_http_client, create_openai_image_generation,
require_openai_image_settings,
},
platform_errors::map_oss_error,
request_context::RequestContext,
square_hole_agent_turn::{
SquareHoleAgentTurnRequest, build_finalize_record_input, run_square_hole_agent_turn,
@@ -78,6 +86,11 @@ const SQUARE_HOLE_DEFAULT_TWIST_RULE: &str = "方洞万能";
const SQUARE_HOLE_DEFAULT_SHAPE_COUNT: u32 = 12;
const SQUARE_HOLE_DEFAULT_DIFFICULTY: u32 = 4;
const SQUARE_HOLE_QUESTION_THEME: &str = "你想做什么题材";
const SQUARE_HOLE_ENTITY_KIND: &str = "square_hole_work";
const SQUARE_HOLE_COVER_IMAGE_KIND: &str = "square_hole_cover_image";
const SQUARE_HOLE_BACKGROUND_IMAGE_KIND: &str = "square_hole_background_image";
const SQUARE_HOLE_SHAPE_IMAGE_KIND: &str = "square_hole_shape_image";
const SQUARE_HOLE_HOLE_IMAGE_KIND: &str = "square_hole_hole_image";
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -104,6 +117,8 @@ struct SquareHoleConfigShapeOptionJson {
option_id: String,
shape_kind: String,
label: String,
#[serde(default)]
target_hole_id: String,
image_prompt: String,
#[serde(default, deserialize_with = "deserialize_optional_string_as_default")]
image_src: String,
@@ -116,7 +131,9 @@ struct SquareHoleConfigHoleOptionJson {
hole_kind: String,
label: String,
#[serde(default)]
bonus: bool,
image_prompt: String,
#[serde(default, deserialize_with = "deserialize_optional_string_as_default")]
image_src: String,
}
#[derive(Clone, Debug, Deserialize)]
@@ -359,6 +376,9 @@ pub async fn execute_square_hole_agent_action(
&request_context,
&authenticated,
session_id,
payload.regenerate_visual_assets.unwrap_or(false),
payload.visual_asset_slot,
payload.visual_asset_option_id,
)
.await?
}
@@ -543,18 +563,18 @@ pub async fn put_square_hole_work(
.clone()
.filter(|value| !value.trim().is_empty())
.unwrap_or(existing.theme_text);
let shape_options_json = payload
.shape_options
.clone()
.map(square_hole_work_shape_options_to_records)
.unwrap_or_else(|| existing.shape_options.clone());
let shape_options_json = serialize_square_hole_shape_option_records(&shape_options_json);
let hole_options_json = payload
let hole_options = payload
.hole_options
.clone()
.map(square_hole_work_hole_options_to_records)
.unwrap_or_else(|| existing.hole_options.clone());
let hole_options_json = serialize_square_hole_hole_option_records(&hole_options_json);
let hole_options_json = serialize_square_hole_hole_option_records(&hole_options);
let shape_options = payload
.shape_options
.clone()
.map(|options| square_hole_work_shape_options_to_records(options, hole_options.as_slice()))
.unwrap_or_else(|| existing.shape_options.clone());
let shape_options_json = serialize_square_hole_shape_option_records(&shape_options);
let item = state
.spacetime_client()
.update_square_hole_work(SquareHoleWorkUpdateRecordInput {
@@ -633,6 +653,40 @@ pub async fn publish_square_hole_work(
))
}
pub async fn regenerate_square_hole_work_image(
State(state): State<AppState>,
Path(profile_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<RegenerateSquareHoleWorkImageRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = square_hole_json(payload, &request_context, SQUARE_HOLE_WORKS_PROVIDER)?;
ensure_non_empty(
&request_context,
SQUARE_HOLE_WORKS_PROVIDER,
&profile_id,
"profileId",
)?;
let owner_user_id = authenticated.claims().user_id().to_string();
let item = regenerate_square_hole_visual_asset_for_work(
&state,
&request_context,
owner_user_id,
profile_id,
payload.visual_asset_slot,
payload.visual_asset_option_id,
)
.await?;
Ok(json_success_body(
Some(&request_context),
SquareHoleWorkMutationResponse {
item: map_square_hole_work_profile_response(item),
},
))
}
pub async fn delete_square_hole_work(
State(state): State<AppState>,
Path(profile_id): Path<String>,
@@ -1064,6 +1118,9 @@ async fn generate_square_hole_visual_assets_for_session(
request_context: &RequestContext,
authenticated: &AuthenticatedAccessToken,
session_id: String,
regenerate_visual_assets: bool,
visual_asset_slot: Option<String>,
visual_asset_option_id: Option<String>,
) -> Result<SquareHoleAgentSessionRecord, Response> {
let owner_user_id = authenticated.claims().user_id().to_string();
let session = state
@@ -1100,11 +1157,29 @@ async fn generate_square_hole_visual_assets_for_session(
)
})?;
let requested_slot = normalize_square_hole_visual_asset_slot(
visual_asset_slot.as_deref(),
visual_asset_option_id.as_deref(),
);
let cover_image_src = match work.cover_image_src.clone() {
Some(value) if !value.trim().is_empty() => Some(value),
Some(value)
if !should_generate_square_hole_cover_image(
requested_slot.as_ref(),
regenerate_visual_assets,
value.as_str(),
) =>
{
Some(value)
}
_ => Some(
generate_square_hole_image_data_url(
state,
&owner_user_id,
&session_id,
profile_id.as_str(),
"cover",
SQUARE_HOLE_COVER_IMAGE_KIND,
build_square_hole_cover_prompt(&work).as_str(),
"16:9",
"生成方洞挑战封面图失败",
@@ -1116,10 +1191,23 @@ async fn generate_square_hole_visual_assets_for_session(
),
};
let background_image_src = match work.background_image_src.clone() {
Some(value) if !value.trim().is_empty() => Some(value),
Some(value)
if !should_generate_square_hole_background_image(
requested_slot.as_ref(),
regenerate_visual_assets,
value.as_str(),
) =>
{
Some(value)
}
_ => Some(
generate_square_hole_image_data_url(
state,
&owner_user_id,
&session_id,
profile_id.as_str(),
"background",
SQUARE_HOLE_BACKGROUND_IMAGE_KIND,
build_square_hole_background_prompt(&work).as_str(),
"16:9",
"生成方洞挑战背景图失败",
@@ -1133,18 +1221,21 @@ async fn generate_square_hole_visual_assets_for_session(
let mut shape_options = work.shape_options.clone();
let prompt_work = work.clone();
for option in shape_options.iter_mut() {
if option
.image_src
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some()
{
if !should_generate_square_hole_shape_image(
requested_slot.as_ref(),
regenerate_visual_assets,
option,
) {
continue;
}
option.image_src = Some(
generate_square_hole_image_data_url(
state,
&owner_user_id,
&session_id,
profile_id.as_str(),
option.option_id.as_str(),
SQUARE_HOLE_SHAPE_IMAGE_KIND,
build_square_hole_shape_prompt(&prompt_work, option).as_str(),
"1:1",
"生成方洞挑战形状贴图失败",
@@ -1155,6 +1246,33 @@ async fn generate_square_hole_visual_assets_for_session(
})?,
);
}
let mut hole_options = work.hole_options.clone();
for option in hole_options.iter_mut() {
if !should_generate_square_hole_hole_image(
requested_slot.as_ref(),
regenerate_visual_assets,
option,
) {
continue;
}
option.image_src = Some(
generate_square_hole_image_data_url(
state,
&owner_user_id,
&session_id,
profile_id.as_str(),
option.hole_id.as_str(),
SQUARE_HOLE_HOLE_IMAGE_KIND,
build_square_hole_hole_prompt(&prompt_work, option).as_str(),
"1:1",
"生成方洞挑战洞口贴图失败",
)
.await
.map_err(|error| {
square_hole_error_response(request_context, SQUARE_HOLE_AGENT_PROVIDER, error)
})?,
);
}
work = state
.spacetime_client()
@@ -1171,7 +1289,7 @@ async fn generate_square_hole_visual_assets_for_session(
background_prompt: work.background_prompt.clone(),
background_image_src: background_image_src.clone().unwrap_or_default(),
shape_options_json: serialize_square_hole_shape_option_records(&shape_options),
hole_options_json: serialize_square_hole_hole_option_records(&work.hole_options),
hole_options_json: serialize_square_hole_hole_option_records(&hole_options),
shape_count: work.shape_count,
difficulty: work.difficulty,
updated_at_micros: current_utc_micros(),
@@ -1206,8 +1324,180 @@ async fn generate_square_hole_visual_assets_for_session(
Ok(next_session)
}
async fn regenerate_square_hole_visual_asset_for_work(
state: &AppState,
request_context: &RequestContext,
owner_user_id: String,
profile_id: String,
visual_asset_slot: String,
visual_asset_option_id: Option<String>,
) -> Result<SquareHoleWorkProfileRecord, Response> {
let mut work = state
.spacetime_client()
.get_square_hole_work_detail(profile_id.clone(), owner_user_id.clone())
.await
.map_err(|error| {
square_hole_error_response(
request_context,
SQUARE_HOLE_WORKS_PROVIDER,
map_square_hole_client_error(error),
)
})?;
let requested_slot = normalize_square_hole_visual_asset_slot(
Some(visual_asset_slot.as_str()),
visual_asset_option_id.as_deref(),
)
.ok_or_else(|| {
square_hole_bad_request(
request_context,
SQUARE_HOLE_WORKS_PROVIDER,
"图片槽位不存在",
)
})?;
let synthetic_session_id = work
.source_session_id
.clone()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| profile_id.clone());
let prompt_work = work.clone();
match &requested_slot {
SquareHoleVisualAssetSlotRequest::Cover => {
work.cover_image_src = Some(
generate_square_hole_image_data_url(
state,
owner_user_id.as_str(),
synthetic_session_id.as_str(),
profile_id.as_str(),
"cover",
SQUARE_HOLE_COVER_IMAGE_KIND,
build_square_hole_cover_prompt(&prompt_work).as_str(),
"16:9",
"生成方洞挑战封面图失败",
)
.await
.map_err(|error| {
square_hole_error_response(request_context, SQUARE_HOLE_WORKS_PROVIDER, error)
})?,
);
}
SquareHoleVisualAssetSlotRequest::Background => {
work.background_image_src = Some(
generate_square_hole_image_data_url(
state,
owner_user_id.as_str(),
synthetic_session_id.as_str(),
profile_id.as_str(),
"background",
SQUARE_HOLE_BACKGROUND_IMAGE_KIND,
build_square_hole_background_prompt(&prompt_work).as_str(),
"16:9",
"生成方洞挑战背景图失败",
)
.await
.map_err(|error| {
square_hole_error_response(request_context, SQUARE_HOLE_WORKS_PROVIDER, error)
})?,
);
}
SquareHoleVisualAssetSlotRequest::Shape(option_id) => {
let Some(option) = work
.shape_options
.iter_mut()
.find(|option| option.option_id == *option_id)
else {
return Err(square_hole_bad_request(
request_context,
SQUARE_HOLE_WORKS_PROVIDER,
"形状图片槽位不存在",
));
};
option.image_src = Some(
generate_square_hole_image_data_url(
state,
owner_user_id.as_str(),
synthetic_session_id.as_str(),
profile_id.as_str(),
option.option_id.as_str(),
SQUARE_HOLE_SHAPE_IMAGE_KIND,
build_square_hole_shape_prompt(&prompt_work, option).as_str(),
"1:1",
"生成方洞挑战形状贴图失败",
)
.await
.map_err(|error| {
square_hole_error_response(request_context, SQUARE_HOLE_WORKS_PROVIDER, error)
})?,
);
}
SquareHoleVisualAssetSlotRequest::Hole(hole_id) => {
let Some(option) = work
.hole_options
.iter_mut()
.find(|option| option.hole_id == *hole_id)
else {
return Err(square_hole_bad_request(
request_context,
SQUARE_HOLE_WORKS_PROVIDER,
"洞口图片槽位不存在",
));
};
option.image_src = Some(
generate_square_hole_image_data_url(
state,
owner_user_id.as_str(),
synthetic_session_id.as_str(),
profile_id.as_str(),
option.hole_id.as_str(),
SQUARE_HOLE_HOLE_IMAGE_KIND,
build_square_hole_hole_prompt(&prompt_work, option).as_str(),
"1:1",
"生成方洞挑战洞口贴图失败",
)
.await
.map_err(|error| {
square_hole_error_response(request_context, SQUARE_HOLE_WORKS_PROVIDER, error)
})?,
);
}
}
state
.spacetime_client()
.update_square_hole_work(SquareHoleWorkUpdateRecordInput {
profile_id,
owner_user_id,
game_name: work.game_name.clone(),
theme_text: work.theme_text.clone(),
twist_rule: work.twist_rule.clone(),
summary_text: work.summary.clone(),
tags_json: serde_json::to_string(&normalize_tags(work.tags.clone()))
.unwrap_or_default(),
cover_image_src: work.cover_image_src.clone().unwrap_or_default(),
background_prompt: work.background_prompt.clone(),
background_image_src: work.background_image_src.clone().unwrap_or_default(),
shape_options_json: serialize_square_hole_shape_option_records(&work.shape_options),
hole_options_json: serialize_square_hole_hole_option_records(&work.hole_options),
shape_count: work.shape_count,
difficulty: work.difficulty,
updated_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
square_hole_error_response(
request_context,
SQUARE_HOLE_WORKS_PROVIDER,
map_square_hole_client_error(error),
)
})
}
async fn generate_square_hole_image_data_url(
state: &AppState,
owner_user_id: &str,
session_id: &str,
profile_id: &str,
slot: &str,
asset_kind: &str,
prompt: &str,
size: &str,
failure_context: &str,
@@ -1232,11 +1522,220 @@ async fn generate_square_hole_image_data_url(
}))
})?;
Ok(format!(
let fallback_data_url = format_square_hole_data_url(&image);
match persist_square_hole_generated_asset(
state,
owner_user_id,
session_id,
profile_id,
slot,
asset_kind,
generated.task_id.as_str(),
image,
current_utc_micros(),
)
.await
{
Ok(image_src) => Ok(image_src),
Err(error) => {
tracing::warn!(
provider = "square-hole-assets",
owner_user_id,
session_id,
profile_id,
slot,
asset_kind,
message = %error.body_text(),
"方洞图片已生成但资产持久化失败,降级回写 Data URL"
);
Ok(fallback_data_url)
}
}
}
fn format_square_hole_data_url(image: &DownloadedOpenAiImage) -> String {
format!(
"data:{};base64,{}",
image.mime_type,
BASE64_STANDARD.encode(image.bytes)
))
BASE64_STANDARD.encode(&image.bytes)
)
}
#[allow(clippy::too_many_arguments)]
async fn persist_square_hole_generated_asset(
state: &AppState,
owner_user_id: &str,
session_id: &str,
profile_id: &str,
slot: &str,
asset_kind: &str,
task_id: &str,
image: DownloadedOpenAiImage,
generated_at_micros: i64,
) -> Result<String, AppError> {
let oss_client = state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-oss",
"reason": "OSS 未完成环境变量配置",
}))
})?;
let http_client = reqwest::Client::new();
let storage_slot = sanitize_square_hole_asset_segment(slot, "slot");
let put_result = oss_client
.put_object(
&http_client,
OssPutObjectRequest {
prefix: LegacyAssetPrefix::SquareHoleAssets,
path_segments: vec![
sanitize_square_hole_asset_segment(session_id, "session"),
sanitize_square_hole_asset_segment(profile_id, "profile"),
sanitize_square_hole_asset_segment(asset_kind, "asset"),
storage_slot.clone(),
format!("asset-{generated_at_micros}"),
],
file_name: format!("image.{}", image.extension),
content_type: Some(image.mime_type.clone()),
access: OssObjectAccess::Private,
metadata: build_square_hole_asset_metadata(
asset_kind,
owner_user_id,
profile_id,
slot,
),
body: image.bytes,
},
)
.await
.map_err(map_square_hole_asset_oss_error)?;
let head = oss_client
.head_object(
&http_client,
OssHeadObjectRequest {
object_key: put_result.object_key.clone(),
},
)
.await
.map_err(map_square_hole_asset_oss_error)?;
match state
.spacetime_client()
.confirm_asset_object(
build_asset_object_upsert_input(
generate_asset_object_id(generated_at_micros),
head.bucket,
head.object_key,
AssetObjectAccessPolicy::Private,
head.content_type.or(Some(image.mime_type)),
head.content_length,
head.etag,
asset_kind.to_string(),
Some(task_id.to_string()),
Some(owner_user_id.to_string()),
Some(profile_id.to_string()),
Some(profile_id.to_string()),
generated_at_micros,
)
.map_err(map_square_hole_asset_field_error)?,
)
.await
{
Ok(asset_object) => {
if let Err(error) = state
.spacetime_client()
.bind_asset_object_to_entity(
build_asset_entity_binding_input(
generate_asset_binding_id(generated_at_micros),
asset_object.asset_object_id,
SQUARE_HOLE_ENTITY_KIND.to_string(),
profile_id.to_string(),
slot.to_string(),
asset_kind.to_string(),
Some(owner_user_id.to_string()),
Some(profile_id.to_string()),
generated_at_micros,
)
.map_err(map_square_hole_asset_field_error)?,
)
.await
{
tracing::warn!(
provider = "spacetimedb",
owner_user_id,
session_id,
profile_id,
slot,
asset_kind,
error = %error,
"方洞图片资产绑定失败,历史素材索引可能缺少绑定记录"
);
}
}
Err(error) => {
tracing::warn!(
provider = "spacetimedb",
owner_user_id,
session_id,
profile_id,
slot,
asset_kind,
error = %error,
"方洞图片资产对象确认失败,历史素材索引可能缺少本次记录"
);
}
}
Ok(put_result.legacy_public_path)
}
fn build_square_hole_asset_metadata(
asset_kind: &str,
owner_user_id: &str,
profile_id: &str,
slot: &str,
) -> BTreeMap<String, String> {
BTreeMap::from([
("asset_kind".to_string(), asset_kind.to_string()),
("owner_user_id".to_string(), owner_user_id.to_string()),
("profile_id".to_string(), profile_id.to_string()),
(
"entity_kind".to_string(),
SQUARE_HOLE_ENTITY_KIND.to_string(),
),
("entity_id".to_string(), profile_id.to_string()),
("slot".to_string(), slot.to_string()),
])
}
fn map_square_hole_asset_oss_error(error: platform_oss::OssError) -> AppError {
map_oss_error(error, "aliyun-oss")
}
fn map_square_hole_asset_field_error(error: AssetObjectFieldError) -> AppError {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "square-hole-assets",
"message": error.to_string(),
}))
}
fn sanitize_square_hole_asset_segment(value: &str, fallback: &str) -> String {
let sanitized = value
.trim()
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ('\u{4e00}'..='\u{9fff}').contains(&ch) {
ch
} else {
'-'
}
})
.collect::<String>()
.trim_matches('-')
.to_string();
if sanitized.is_empty() {
fallback.to_string()
} else {
sanitized
}
}
fn build_square_hole_cover_prompt(work: &SquareHoleWorkProfileRecord) -> String {
@@ -1280,6 +1779,24 @@ fn build_square_hole_shape_prompt(
)
}
fn build_square_hole_hole_prompt(
work: &SquareHoleWorkProfileRecord,
option: &SquareHoleHoleOptionRecord,
) -> String {
let image_prompt = option.image_prompt.trim();
let option_prompt = if image_prompt.is_empty() {
format!("{} 主题的 {}", work.theme_text, option.label)
} else {
image_prompt.to_string()
};
format!(
"单个游戏洞口贴图,透明或干净浅色背景。洞口名称:{}。主题贴图:{}。要求主体居中、边缘清晰、适合放在可接收拖拽形状的洞口平面上,不要文字、不要 UI、不要水印。",
clean_prompt_text(&option.label, "洞口"),
clean_prompt_text(&option_prompt, "主题洞口")
)
}
fn build_square_hole_negative_prompt() -> String {
"文字、水印、复杂 UI、真实人物、恐怖血腥、低清晰度、过度模糊、主体被裁切、多个主体".to_string()
}
@@ -1518,6 +2035,7 @@ fn map_square_hole_shape_response(
shape_id: item.shape_id,
shape_kind: item.shape_kind,
label: item.label,
target_hole_id: item.target_hole_id,
color: item.color,
image_src: item.image_src,
}
@@ -1532,7 +2050,7 @@ fn map_square_hole_hole_response(
label: slot.label,
x: slot.x,
y: slot.y,
bonus: slot.bonus,
image_src: slot.image_src,
}
}
@@ -1543,6 +2061,7 @@ fn map_square_hole_shape_option_response(
option_id: item.option_id,
shape_kind: item.shape_kind,
label: item.label,
target_hole_id: item.target_hole_id,
image_prompt: item.image_prompt,
image_src: item.image_src,
}
@@ -1555,7 +2074,8 @@ fn map_square_hole_hole_option_response(
hole_id: item.hole_id,
hole_kind: item.hole_kind,
label: item.label,
bonus: item.bonus,
image_prompt: item.image_prompt,
image_src: item.image_src,
}
}
@@ -1566,6 +2086,7 @@ fn map_square_hole_work_shape_option_response(
option_id: item.option_id,
shape_kind: item.shape_kind,
label: item.label,
target_hole_id: item.target_hole_id,
image_prompt: item.image_prompt,
image_src: item.image_src,
}
@@ -1578,7 +2099,8 @@ fn map_square_hole_work_hole_option_response(
hole_id: item.hole_id,
hole_kind: item.hole_kind,
label: item.label,
bonus: item.bonus,
image_prompt: item.image_prompt,
image_src: item.image_src,
}
}
@@ -1595,15 +2117,16 @@ fn map_square_hole_feedback_response(
fn build_config_from_create_request(
payload: &CreateSquareHoleSessionRequest,
) -> SquareHoleConfigJson {
let theme_text = payload
.theme_text
.as_deref()
.or(payload.seed_text.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(SQUARE_HOLE_DEFAULT_THEME);
let hole_options = normalize_hole_options(Vec::new(), theme_text);
SquareHoleConfigJson {
theme_text: payload
.theme_text
.as_deref()
.or(payload.seed_text.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(SQUARE_HOLE_DEFAULT_THEME)
.to_string(),
theme_text: theme_text.to_string(),
twist_rule: payload
.twist_rule
.as_deref()
@@ -1621,20 +2144,11 @@ fn build_config_from_create_request(
.clamp(1, 10),
shape_options: square_hole_shape_records_to_config_json(normalize_shape_options(
Vec::new(),
payload
.theme_text
.as_deref()
.or(payload.seed_text.as_deref())
.unwrap_or(SQUARE_HOLE_DEFAULT_THEME),
theme_text,
hole_options.as_slice(),
)),
hole_options: square_hole_hole_records_to_config_json(normalize_hole_options(Vec::new())),
background_prompt: default_background_prompt(
payload
.theme_text
.as_deref()
.or(payload.seed_text.as_deref())
.unwrap_or(SQUARE_HOLE_DEFAULT_THEME),
),
hole_options: square_hole_hole_records_to_config_json(hole_options),
background_prompt: default_background_prompt(theme_text),
cover_image_src: String::new(),
background_image_src: String::new(),
}
@@ -1660,12 +2174,17 @@ fn resolve_config_or_default(
twist_rule: SQUARE_HOLE_DEFAULT_TWIST_RULE.to_string(),
shape_count: SQUARE_HOLE_DEFAULT_SHAPE_COUNT,
difficulty: SQUARE_HOLE_DEFAULT_DIFFICULTY,
shape_options: square_hole_shape_records_to_config_json(normalize_shape_options(
Vec::new(),
SQUARE_HOLE_DEFAULT_THEME,
)),
shape_options: {
let hole_options = normalize_hole_options(Vec::new(), SQUARE_HOLE_DEFAULT_THEME);
square_hole_shape_records_to_config_json(normalize_shape_options(
Vec::new(),
SQUARE_HOLE_DEFAULT_THEME,
hole_options.as_slice(),
))
},
hole_options: square_hole_hole_records_to_config_json(normalize_hole_options(
Vec::new(),
SQUARE_HOLE_DEFAULT_THEME,
)),
background_prompt: default_background_prompt(SQUARE_HOLE_DEFAULT_THEME),
cover_image_src: String::new(),
@@ -1730,13 +2249,23 @@ fn square_hole_hole_records_to_config_json(
fn square_hole_work_shape_options_to_records(
options: Vec<SquareHoleWorkShapeOptionResponse>,
hole_options: &[SquareHoleHoleOptionRecord],
) -> Vec<SquareHoleShapeOptionRecord> {
let fallback_hole_id = hole_options
.first()
.map(|option| option.hole_id.clone())
.unwrap_or_else(|| "hole-1".to_string());
options
.into_iter()
.map(|option| SquareHoleShapeOptionRecord {
option_id: option.option_id,
shape_kind: option.shape_kind,
label: option.label,
target_hole_id: hole_options
.iter()
.find(|hole| hole.hole_id == option.target_hole_id)
.map(|hole| hole.hole_id.clone())
.unwrap_or_else(|| fallback_hole_id.clone()),
image_prompt: option.image_prompt,
image_src: option.image_src.filter(|value| !value.trim().is_empty()),
})
@@ -1752,7 +2281,8 @@ fn square_hole_work_hole_options_to_records(
hole_id: option.hole_id,
hole_kind: option.hole_kind,
label: option.label,
bonus: option.bonus,
image_prompt: option.image_prompt,
image_src: option.image_src.filter(|value| !value.trim().is_empty()),
})
.collect()
}
@@ -1782,12 +2312,104 @@ fn clean_prompt_text(value: &str, fallback: &str) -> String {
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum SquareHoleVisualAssetSlotRequest {
Cover,
Background,
Shape(String),
Hole(String),
}
fn normalize_square_hole_visual_asset_slot(
slot: Option<&str>,
option_id: Option<&str>,
) -> Option<SquareHoleVisualAssetSlotRequest> {
match slot.map(str::trim).unwrap_or_default() {
"cover" => Some(SquareHoleVisualAssetSlotRequest::Cover),
"background" => Some(SquareHoleVisualAssetSlotRequest::Background),
"shape" => option_id
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| SquareHoleVisualAssetSlotRequest::Shape(value.to_string())),
"hole" => option_id
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| SquareHoleVisualAssetSlotRequest::Hole(value.to_string())),
_ => None,
}
}
fn should_generate_square_hole_cover_image(
requested_slot: Option<&SquareHoleVisualAssetSlotRequest>,
regenerate_visual_assets: bool,
current_image_src: &str,
) -> bool {
matches!(
requested_slot,
Some(SquareHoleVisualAssetSlotRequest::Cover)
) || (requested_slot.is_none()
&& (regenerate_visual_assets || current_image_src.trim().is_empty()))
}
fn should_generate_square_hole_background_image(
requested_slot: Option<&SquareHoleVisualAssetSlotRequest>,
regenerate_visual_assets: bool,
current_image_src: &str,
) -> bool {
matches!(
requested_slot,
Some(SquareHoleVisualAssetSlotRequest::Background)
) || (requested_slot.is_none()
&& (regenerate_visual_assets || current_image_src.trim().is_empty()))
}
fn should_generate_square_hole_shape_image(
requested_slot: Option<&SquareHoleVisualAssetSlotRequest>,
regenerate_visual_assets: bool,
option: &SquareHoleShapeOptionRecord,
) -> bool {
match requested_slot {
Some(SquareHoleVisualAssetSlotRequest::Shape(option_id)) => option.option_id == *option_id,
Some(_) => false,
None => {
regenerate_visual_assets
|| option
.image_src
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_none()
}
}
}
fn should_generate_square_hole_hole_image(
requested_slot: Option<&SquareHoleVisualAssetSlotRequest>,
regenerate_visual_assets: bool,
option: &SquareHoleHoleOptionRecord,
) -> bool {
match requested_slot {
Some(SquareHoleVisualAssetSlotRequest::Hole(hole_id)) => option.hole_id == *hole_id,
Some(_) => false,
None => {
regenerate_visual_assets
|| option
.image_src
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_none()
}
}
}
impl From<module_square_hole::SquareHoleShapeOption> for SquareHoleConfigShapeOptionJson {
fn from(option: module_square_hole::SquareHoleShapeOption) -> Self {
Self {
option_id: option.option_id,
shape_kind: option.shape_kind,
label: option.label,
target_hole_id: option.target_hole_id,
image_prompt: option.image_prompt,
image_src: option.image_src.unwrap_or_default(),
}
@@ -1800,6 +2422,7 @@ impl From<SquareHoleShapeOptionRecord> for SquareHoleConfigShapeOptionJson {
option_id: option.option_id,
shape_kind: option.shape_kind,
label: option.label,
target_hole_id: option.target_hole_id,
image_prompt: option.image_prompt,
image_src: option.image_src.unwrap_or_default(),
}
@@ -1812,7 +2435,8 @@ impl From<module_square_hole::SquareHoleHoleOption> for SquareHoleConfigHoleOpti
hole_id: option.hole_id,
hole_kind: option.hole_kind,
label: option.label,
bonus: option.bonus,
image_prompt: option.image_prompt,
image_src: option.image_src.unwrap_or_default(),
}
}
}
@@ -1823,7 +2447,8 @@ impl From<SquareHoleHoleOptionRecord> for SquareHoleConfigHoleOptionJson {
hole_id: option.hole_id,
hole_kind: option.hole_kind,
label: option.label,
bonus: option.bonus,
image_prompt: option.image_prompt,
image_src: option.image_src.unwrap_or_default(),
}
}
}