Merge origin/master into codex/rpg-creation-cg-fix
This commit is contained in:
@@ -394,9 +394,13 @@ pub async fn confirm_asset_object(
|
||||
let result = state
|
||||
.spacetime_client()
|
||||
.confirm_asset_object(
|
||||
build_confirm_asset_object_upsert_input(oss_client, payload)
|
||||
.await
|
||||
.map_err(map_confirm_asset_object_prepare_error)?,
|
||||
build_confirm_asset_object_upsert_input(
|
||||
oss_client,
|
||||
payload,
|
||||
authenticated.claims().user_id(),
|
||||
)
|
||||
.await
|
||||
.map_err(map_confirm_asset_object_prepare_error)?,
|
||||
)
|
||||
.await
|
||||
.map_err(map_confirm_asset_object_error)?;
|
||||
@@ -592,6 +596,7 @@ fn supported_asset_history_kind_message() -> String {
|
||||
async fn build_confirm_asset_object_upsert_input(
|
||||
oss_client: &platform_oss::OssClient,
|
||||
payload: ConfirmAssetObjectRequest,
|
||||
authenticated_owner_user_id: &str,
|
||||
) -> Result<module_assets::AssetObjectUpsertInput, ConfirmAssetObjectPrepareError> {
|
||||
let configured_bucket = oss_client.config_bucket().to_string();
|
||||
let resolved_bucket = payload
|
||||
@@ -629,6 +634,14 @@ async fn build_confirm_asset_object_upsert_input(
|
||||
{
|
||||
return Err(ConfirmAssetObjectPrepareError::ContentLengthMismatch);
|
||||
}
|
||||
let owner_user_id = normalize_optional_value(payload.owner_user_id).or_else(|| {
|
||||
let owner = authenticated_owner_user_id.trim();
|
||||
if owner.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(owner.to_string())
|
||||
}
|
||||
});
|
||||
|
||||
let now_micros = current_utc_micros();
|
||||
build_asset_object_upsert_input(
|
||||
@@ -645,7 +658,7 @@ async fn build_confirm_asset_object_upsert_input(
|
||||
normalize_optional_value(payload.content_hash),
|
||||
payload.asset_kind,
|
||||
payload.source_job_id,
|
||||
payload.owner_user_id,
|
||||
owner_user_id,
|
||||
payload.profile_id,
|
||||
payload.entity_id,
|
||||
now_micros,
|
||||
|
||||
@@ -1049,6 +1049,7 @@ mod tests {
|
||||
base_url: "https://vector.example".to_string(),
|
||||
api_key: "secret".to_string(),
|
||||
request_timeout_ms: 180_000,
|
||||
external_api_audit_state: None,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
|
||||
372
server-rs/crates/api-server/src/external_api_audit.rs
Normal file
372
server-rs/crates/api-server/src/external_api_audit.rs
Normal file
@@ -0,0 +1,372 @@
|
||||
use axum::http::StatusCode;
|
||||
use module_runtime::RuntimeTrackingScopeKind;
|
||||
use serde_json::{Value, json};
|
||||
use time::OffsetDateTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{state::AppState, tracking::TrackingEventDraft};
|
||||
|
||||
pub(crate) const EXTERNAL_API_FAILURE_EVENT_KEY: &str = "external_api_call_failure";
|
||||
pub(crate) const EXTERNAL_API_AUDIT_MODULE_KEY: &str = "external-api";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ExternalApiFailureDraft {
|
||||
pub(crate) provider: &'static str,
|
||||
pub(crate) endpoint: String,
|
||||
pub(crate) operation: String,
|
||||
pub(crate) failure_stage: &'static str,
|
||||
pub(crate) status_code: Option<u16>,
|
||||
pub(crate) status_class: Option<&'static str>,
|
||||
pub(crate) timeout: bool,
|
||||
pub(crate) retryable: bool,
|
||||
pub(crate) error_message: String,
|
||||
pub(crate) error_source: Option<String>,
|
||||
pub(crate) raw_excerpt: Option<String>,
|
||||
pub(crate) latency_ms: Option<u64>,
|
||||
pub(crate) prompt_chars: Option<usize>,
|
||||
pub(crate) reference_image_count: Option<usize>,
|
||||
pub(crate) image_model: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl ExternalApiFailureDraft {
|
||||
pub(crate) fn new(
|
||||
provider: &'static str,
|
||||
endpoint: impl Into<String>,
|
||||
operation: impl Into<String>,
|
||||
failure_stage: &'static str,
|
||||
error_message: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
provider,
|
||||
endpoint: endpoint.into(),
|
||||
operation: operation.into(),
|
||||
failure_stage,
|
||||
status_code: None,
|
||||
status_class: None,
|
||||
timeout: false,
|
||||
retryable: false,
|
||||
error_message: error_message.into(),
|
||||
error_source: None,
|
||||
raw_excerpt: None,
|
||||
latency_ms: None,
|
||||
prompt_chars: None,
|
||||
reference_image_count: None,
|
||||
image_model: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_status_code(mut self, status_code: Option<u16>) -> Self {
|
||||
self.status_code = status_code;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_optional_status_class(mut self, status_class: Option<&'static str>) -> Self {
|
||||
self.status_class = status_class;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_timeout(mut self, timeout: bool) -> Self {
|
||||
self.timeout = timeout;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_retryable(mut self, retryable: bool) -> Self {
|
||||
self.retryable = retryable;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_error_source(mut self, error_source: Option<String>) -> Self {
|
||||
self.error_source = error_source;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_raw_excerpt(mut self, raw_excerpt: Option<String>) -> Self {
|
||||
self.raw_excerpt = raw_excerpt;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_latency_ms(mut self, latency_ms: Option<u64>) -> Self {
|
||||
self.latency_ms = latency_ms;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_prompt_chars(mut self, prompt_chars: Option<usize>) -> Self {
|
||||
self.prompt_chars = prompt_chars;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_reference_image_count(
|
||||
mut self,
|
||||
reference_image_count: Option<usize>,
|
||||
) -> Self {
|
||||
self.reference_image_count = reference_image_count;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_image_model(mut self, image_model: Option<&'static str>) -> Self {
|
||||
self.image_model = image_model;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// 中文注释:下载图片、OSS 读写等非标准 HTTP 状态统一显式归类,避免 OTLP 低基数 label 误落到 `transport`。
|
||||
pub(crate) fn app_error_status_class(status_code: StatusCode) -> &'static str {
|
||||
status_class(Some(status_code.as_u16()))
|
||||
}
|
||||
|
||||
/// 中文注释:外部供应商失败同时进入 OTLP 和 tracking_event;失败审计不能反向阻断主业务错误返回。
|
||||
pub(crate) async fn record_external_api_failure(state: &AppState, draft: ExternalApiFailureDraft) {
|
||||
record_external_api_failure_otlp(&draft);
|
||||
|
||||
let tracking_event = build_external_api_failure_tracking_draft(&draft);
|
||||
if let Some(outbox) = state.tracking_outbox() {
|
||||
match outbox
|
||||
.enqueue(crate::tracking::build_tracking_event_input(
|
||||
tracking_event.clone(),
|
||||
))
|
||||
.await
|
||||
{
|
||||
Ok(crate::tracking_outbox::TrackingOutboxEnqueueOutcome::Enqueued) => {}
|
||||
Ok(crate::tracking_outbox::TrackingOutboxEnqueueOutcome::Dropped { reason }) => {
|
||||
tracing::warn!(
|
||||
provider = draft.provider,
|
||||
endpoint = %draft.endpoint,
|
||||
operation = %draft.operation,
|
||||
failure_stage = draft.failure_stage,
|
||||
reason,
|
||||
"外部 API 失败审计写入 outbox 被保护阈值拒绝,回退同步直写 SpacetimeDB"
|
||||
);
|
||||
crate::tracking::record_tracking_event_after_success(
|
||||
state,
|
||||
&audit_request_context(),
|
||||
tracking_event,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
provider = draft.provider,
|
||||
endpoint = %draft.endpoint,
|
||||
operation = %draft.operation,
|
||||
failure_stage = draft.failure_stage,
|
||||
error = %error,
|
||||
"外部 API 失败审计写入 outbox 失败,回退同步直写 SpacetimeDB"
|
||||
);
|
||||
crate::tracking::record_tracking_event_after_success(
|
||||
state,
|
||||
&audit_request_context(),
|
||||
tracking_event,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
crate::tracking::record_tracking_event_after_success(
|
||||
state,
|
||||
&audit_request_context(),
|
||||
tracking_event,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(crate) fn build_external_api_failure_tracking_draft(
|
||||
failure: &ExternalApiFailureDraft,
|
||||
) -> TrackingEventDraft {
|
||||
let mut draft = TrackingEventDraft::new(
|
||||
EXTERNAL_API_FAILURE_EVENT_KEY,
|
||||
EXTERNAL_API_AUDIT_MODULE_KEY,
|
||||
);
|
||||
draft.scope_kind = RuntimeTrackingScopeKind::Module;
|
||||
draft.scope_id = failure.provider.to_string();
|
||||
draft.metadata = build_external_api_failure_metadata(failure);
|
||||
draft
|
||||
}
|
||||
|
||||
fn build_external_api_failure_metadata(failure: &ExternalApiFailureDraft) -> Value {
|
||||
let mut metadata = json!({
|
||||
"provider": failure.provider,
|
||||
"endpoint": failure.endpoint,
|
||||
"operation": failure.operation,
|
||||
"failureStage": failure.failure_stage,
|
||||
"statusCode": failure.status_code,
|
||||
"statusClass": failure.status_class.unwrap_or_else(|| status_class(failure.status_code)),
|
||||
"timeout": failure.timeout,
|
||||
"retryable": failure.retryable,
|
||||
"errorMessage": truncate_field(failure.error_message.as_str(), 1_000),
|
||||
"occurredAt": current_utc_iso_text(),
|
||||
});
|
||||
|
||||
if let Some(latency_ms) = failure.latency_ms {
|
||||
metadata["latencyMs"] = json!(latency_ms);
|
||||
}
|
||||
if let Some(prompt_chars) = failure.prompt_chars {
|
||||
metadata["promptChars"] = json!(prompt_chars);
|
||||
}
|
||||
if let Some(reference_image_count) = failure.reference_image_count {
|
||||
metadata["referenceImageCount"] = json!(reference_image_count);
|
||||
}
|
||||
if let Some(image_model) = failure.image_model {
|
||||
metadata["imageModel"] = json!(image_model);
|
||||
}
|
||||
if let Some(source) = failure
|
||||
.error_source
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
metadata["errorSource"] = json!(truncate_field(source, 1_000));
|
||||
}
|
||||
if let Some(excerpt) = failure
|
||||
.raw_excerpt
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
metadata["rawExcerpt"] = json!(truncate_field(excerpt, 800));
|
||||
}
|
||||
|
||||
metadata
|
||||
}
|
||||
|
||||
pub(crate) fn is_retryable_external_api_failure(
|
||||
status_code: Option<u16>,
|
||||
timeout: bool,
|
||||
connect: bool,
|
||||
) -> bool {
|
||||
timeout
|
||||
|| connect
|
||||
|| status_code.is_some_and(|status| {
|
||||
status == StatusCode::TOO_MANY_REQUESTS.as_u16()
|
||||
|| status == StatusCode::REQUEST_TIMEOUT.as_u16()
|
||||
|| status >= 500
|
||||
})
|
||||
}
|
||||
|
||||
fn record_external_api_failure_otlp(failure: &ExternalApiFailureDraft) {
|
||||
crate::telemetry::record_external_api_failure(
|
||||
failure.provider,
|
||||
failure.failure_stage,
|
||||
failure
|
||||
.status_class
|
||||
.unwrap_or_else(|| status_class(failure.status_code)),
|
||||
failure.retryable,
|
||||
);
|
||||
|
||||
tracing::error!(
|
||||
provider = failure.provider,
|
||||
endpoint = %failure.endpoint,
|
||||
operation = %failure.operation,
|
||||
failure_stage = failure.failure_stage,
|
||||
status_code = failure.status_code,
|
||||
status_class = failure.status_class.unwrap_or_else(|| status_class(failure.status_code)),
|
||||
timeout = failure.timeout,
|
||||
retryable = failure.retryable,
|
||||
latency_ms = failure.latency_ms,
|
||||
prompt_chars = failure.prompt_chars,
|
||||
reference_image_count = failure.reference_image_count,
|
||||
image_model = failure.image_model,
|
||||
error = %failure.error_message,
|
||||
"外部 API 调用失败"
|
||||
);
|
||||
}
|
||||
|
||||
fn status_class(status_code: Option<u16>) -> &'static str {
|
||||
match status_code {
|
||||
Some(100..=199) => "1xx",
|
||||
Some(200..=299) => "2xx",
|
||||
Some(300..=399) => "3xx",
|
||||
Some(400..=499) => "4xx",
|
||||
Some(500..=599) => "5xx",
|
||||
Some(_) => "unknown",
|
||||
None => "transport",
|
||||
}
|
||||
}
|
||||
|
||||
fn audit_request_context() -> crate::request_context::RequestContext {
|
||||
crate::request_context::RequestContext::new(
|
||||
format!("external-api-audit-{}", Uuid::new_v4()),
|
||||
"external-api audit".to_string(),
|
||||
std::time::Duration::ZERO,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
fn truncate_field(value: &str, max_chars: usize) -> String {
|
||||
value.chars().take(max_chars).collect()
|
||||
}
|
||||
|
||||
fn current_utc_iso_text() -> String {
|
||||
shared_kernel::format_rfc3339(OffsetDateTime::now_utc())
|
||||
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::Value;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn external_api_failure_tracking_draft_uses_module_scope_and_safe_metadata() {
|
||||
let draft = build_external_api_failure_tracking_draft(
|
||||
&ExternalApiFailureDraft::new(
|
||||
"vector-engine",
|
||||
"https://vector.example/v1/images/generations",
|
||||
"拼图 UI 背景图生成失败",
|
||||
"upstream_status",
|
||||
"上游 429",
|
||||
)
|
||||
.with_status_code(Some(429))
|
||||
.with_retryable(true)
|
||||
.with_latency_ms(Some(1234))
|
||||
.with_prompt_chars(Some(88))
|
||||
.with_reference_image_count(Some(2))
|
||||
.with_image_model(Some("gpt-image-2-all")),
|
||||
);
|
||||
|
||||
assert_eq!(draft.event_key, EXTERNAL_API_FAILURE_EVENT_KEY);
|
||||
assert_eq!(draft.scope_kind, RuntimeTrackingScopeKind::Module);
|
||||
assert_eq!(draft.scope_id, "vector-engine");
|
||||
assert_eq!(draft.module_key, Some(EXTERNAL_API_AUDIT_MODULE_KEY));
|
||||
|
||||
let metadata = draft.metadata;
|
||||
assert_eq!(metadata["provider"], "vector-engine");
|
||||
assert_eq!(metadata["statusCode"], 429);
|
||||
assert_eq!(metadata["statusClass"], "4xx");
|
||||
assert_eq!(metadata["retryable"], true);
|
||||
assert_eq!(metadata["latencyMs"], 1234);
|
||||
assert_eq!(metadata["promptChars"], 88);
|
||||
assert_eq!(metadata["referenceImageCount"], 2);
|
||||
assert_eq!(metadata["imageModel"], "gpt-image-2-all");
|
||||
assert!(matches!(metadata["occurredAt"], Value::String(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retryable_classification_keeps_transport_and_overload_failures_actionable() {
|
||||
assert!(is_retryable_external_api_failure(None, true, false));
|
||||
assert!(is_retryable_external_api_failure(None, false, true));
|
||||
assert!(is_retryable_external_api_failure(Some(429), false, false));
|
||||
assert!(is_retryable_external_api_failure(Some(502), false, false));
|
||||
assert!(!is_retryable_external_api_failure(Some(400), false, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_error_status_class_can_override_successful_upstream_status() {
|
||||
let draft = build_external_api_failure_tracking_draft(
|
||||
&ExternalApiFailureDraft::new(
|
||||
"vector-engine",
|
||||
"https://cdn.example/generated.png",
|
||||
"下载生成图片",
|
||||
"image_download",
|
||||
"下载生成图片失败",
|
||||
)
|
||||
.with_status_code(Some(200))
|
||||
.with_optional_status_class(Some(app_error_status_class(StatusCode::BAD_GATEWAY))),
|
||||
);
|
||||
|
||||
assert_eq!(draft.metadata["statusCode"], 200);
|
||||
assert_eq!(draft.metadata["statusClass"], "5xx");
|
||||
}
|
||||
}
|
||||
@@ -197,6 +197,86 @@ pub(crate) fn slice_generated_asset_sheet(
|
||||
Ok(slices)
|
||||
}
|
||||
|
||||
pub(crate) fn slice_generated_asset_sheet_two_items_per_row(
|
||||
image: &DownloadedOpenAiImage,
|
||||
item_names: &[String],
|
||||
grid_size: usize,
|
||||
views_per_item: usize,
|
||||
) -> Result<Vec<Vec<GeneratedAssetSheetSliceImage>>, AppError> {
|
||||
if grid_size == 0 || views_per_item == 0 {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": GENERATED_ASSET_SHEET_PROVIDER,
|
||||
"message": "系列素材图集的 n 和每物品视图数必须大于 0。",
|
||||
})),
|
||||
);
|
||||
}
|
||||
if !grid_size.is_multiple_of(views_per_item) {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": GENERATED_ASSET_SHEET_PROVIDER,
|
||||
"message": "系列素材图集每行必须能均分为若干物品。",
|
||||
"gridSize": grid_size,
|
||||
"viewsPerItem": views_per_item,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
let grid_size_u32 = u32::try_from(grid_size).map_err(|_| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": GENERATED_ASSET_SHEET_PROVIDER,
|
||||
"message": "系列素材图集的 n 超出可支持范围。",
|
||||
}))
|
||||
})?;
|
||||
let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": GENERATED_ASSET_SHEET_PROVIDER,
|
||||
"message": format!("系列素材图集解码失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
let source = apply_generated_asset_sheet_green_screen_alpha(source);
|
||||
let (width, height) = source.dimensions();
|
||||
if width / grid_size_u32 == 0 || height / grid_size_u32 == 0 {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": GENERATED_ASSET_SHEET_PROVIDER,
|
||||
"message": "系列素材图集尺寸过小,无法切割。",
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
let items_per_row = grid_size / views_per_item;
|
||||
let max_item_count = grid_size.saturating_mul(items_per_row);
|
||||
let mut slices = Vec::with_capacity(item_names.len().min(max_item_count));
|
||||
for item_index in 0..item_names.len().min(max_item_count) {
|
||||
let row = (item_index / items_per_row) as u32;
|
||||
let start_col = ((item_index % items_per_row) * views_per_item) as u32;
|
||||
let mut views = Vec::with_capacity(views_per_item);
|
||||
for view_offset in 0..views_per_item {
|
||||
let col = start_col + view_offset as u32;
|
||||
let (crop_x, crop_y, crop_width, crop_height) =
|
||||
resolve_generated_asset_sheet_cell_crop(&source, grid_size_u32, row, col);
|
||||
let cropped = source.crop_imm(crop_x, crop_y, crop_width, crop_height);
|
||||
let cleaned = crop_generated_asset_sheet_view_edge_matte(cropped);
|
||||
let mut cursor = std::io::Cursor::new(Vec::new());
|
||||
cleaned
|
||||
.write_to(&mut cursor, ImageFormat::Png)
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": GENERATED_ASSET_SHEET_PROVIDER,
|
||||
"message": format!("系列素材图集切割失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
views.push(GeneratedAssetSheetSliceImage {
|
||||
bytes: cursor.into_inner(),
|
||||
});
|
||||
}
|
||||
slices.push(views);
|
||||
}
|
||||
|
||||
Ok(slices)
|
||||
}
|
||||
|
||||
pub(crate) fn crop_generated_asset_sheet_view_edge_matte(
|
||||
image: image::DynamicImage,
|
||||
) -> image::DynamicImage {
|
||||
@@ -958,7 +1038,7 @@ fn collect_generated_asset_sheet_visible_neighbor_color(
|
||||
))
|
||||
}
|
||||
|
||||
fn apply_generated_asset_sheet_green_screen_alpha(
|
||||
pub(crate) fn apply_generated_asset_sheet_green_screen_alpha(
|
||||
source: image::DynamicImage,
|
||||
) -> image::DynamicImage {
|
||||
let mut image = source.to_rgba8();
|
||||
|
||||
@@ -39,6 +39,7 @@ mod custom_world_rpg_draft_prompts;
|
||||
mod edutainment_baby_drawing;
|
||||
mod edutainment_baby_object;
|
||||
mod error_middleware;
|
||||
mod external_api_audit;
|
||||
pub(crate) mod generated_asset_sheets;
|
||||
mod generated_image_assets;
|
||||
mod health;
|
||||
|
||||
@@ -71,10 +71,12 @@ use crate::{
|
||||
},
|
||||
auth::AuthenticatedAccessToken,
|
||||
config::AppConfig,
|
||||
generated_asset_sheets::apply_generated_asset_sheet_green_screen_alpha,
|
||||
http_error::AppError,
|
||||
openai_image_generation::{
|
||||
DownloadedOpenAiImage, OpenAiGeneratedImages, OpenAiReferenceImage,
|
||||
build_openai_image_http_client, create_openai_image_edit, create_openai_image_generation,
|
||||
build_openai_image_http_client, create_openai_image_edit,
|
||||
create_openai_image_edit_with_references, create_openai_image_generation,
|
||||
require_openai_image_settings,
|
||||
},
|
||||
platform_errors::map_oss_error,
|
||||
@@ -95,10 +97,10 @@ const MATCH3D_DEFAULT_DIFFICULTY: u32 = 4;
|
||||
const MATCH3D_DRAFT_GENERATION_POINTS_COST: u64 = 10;
|
||||
const MATCH3D_BACKGROUND_IMAGE_POINTS_COST: u64 = 2;
|
||||
const MATCH3D_ITEM_ASSETS_POINTS_PER_BATCH: u64 = 2;
|
||||
const MATCH3D_MATERIAL_ITEM_BATCH_SIZE: usize = 5;
|
||||
const MATCH3D_MATERIAL_ITEM_BATCH_SIZE: usize = 20;
|
||||
const MATCH3D_ITEM_VIEW_COUNT: usize = 5;
|
||||
const MATCH3D_MATERIAL_GRID_SIZE: u32 = 5;
|
||||
const MATCH3D_MAX_GENERATED_ITEM_COUNT: usize = 25;
|
||||
const MATCH3D_MATERIAL_GRID_SIZE: u32 = 10;
|
||||
const MATCH3D_MAX_GENERATED_ITEM_COUNT: usize = 20;
|
||||
const MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_MODEL: &str = "gemini-3-pro-image-preview";
|
||||
const MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_ASPECT_RATIO: &str = "1:1";
|
||||
const MATCH3D_LEGACY_MODEL_DOWNLOAD_TIMEOUT_MS: u64 = 3 * 60_000;
|
||||
@@ -118,7 +120,7 @@ const MATCH3D_QUESTION_DIFFICULTY: &str = "如果难度是从1-10,你要创作
|
||||
const MATCH3D_CLICK_SOUND_ASSET_KIND: &str = "match3d_click_sound";
|
||||
const MATCH3D_PIXEL_RETRO_STYLE_PROMPT: &str = "真正复古像素 2D 游戏道具 sprite 风格,先以约 64x64 低分辨率像素块绘制再按整数倍放大,硬边方块像素清晰可见,有限色板 12-24 色,禁止抗锯齿、柔焦、平滑渐变、真实 3D 渲染、PBR 材质和摄影光照。";
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Match3DConfigJson {
|
||||
theme_text: String,
|
||||
@@ -170,15 +172,33 @@ struct Match3DGeneratedItemImageView {
|
||||
image_object_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Match3DGeneratedBackgroundAsset {
|
||||
prompt: String,
|
||||
#[serde(default)]
|
||||
level_scene_prompt: Option<String>,
|
||||
#[serde(default)]
|
||||
level_scene_image_src: Option<String>,
|
||||
#[serde(default)]
|
||||
level_scene_image_object_key: Option<String>,
|
||||
#[serde(default)]
|
||||
image_src: Option<String>,
|
||||
#[serde(default)]
|
||||
image_object_key: Option<String>,
|
||||
#[serde(default)]
|
||||
ui_spritesheet_prompt: Option<String>,
|
||||
#[serde(default)]
|
||||
ui_spritesheet_image_src: Option<String>,
|
||||
#[serde(default)]
|
||||
ui_spritesheet_image_object_key: Option<String>,
|
||||
#[serde(default)]
|
||||
item_spritesheet_prompt: Option<String>,
|
||||
#[serde(default)]
|
||||
item_spritesheet_image_src: Option<String>,
|
||||
#[serde(default)]
|
||||
item_spritesheet_image_object_key: Option<String>,
|
||||
#[serde(default)]
|
||||
container_prompt: Option<String>,
|
||||
#[serde(default)]
|
||||
container_image_src: Option<String>,
|
||||
@@ -445,8 +465,17 @@ impl From<shared_contracts::match3d_works::Match3DGeneratedItemAssetResponse>
|
||||
.background_asset
|
||||
.map(|asset| Match3DGeneratedBackgroundAsset {
|
||||
prompt: asset.prompt,
|
||||
level_scene_prompt: asset.level_scene_prompt,
|
||||
level_scene_image_src: asset.level_scene_image_src,
|
||||
level_scene_image_object_key: asset.level_scene_image_object_key,
|
||||
image_src: asset.image_src,
|
||||
image_object_key: asset.image_object_key,
|
||||
ui_spritesheet_prompt: asset.ui_spritesheet_prompt,
|
||||
ui_spritesheet_image_src: asset.ui_spritesheet_image_src,
|
||||
ui_spritesheet_image_object_key: asset.ui_spritesheet_image_object_key,
|
||||
item_spritesheet_prompt: asset.item_spritesheet_prompt,
|
||||
item_spritesheet_image_src: asset.item_spritesheet_image_src,
|
||||
item_spritesheet_image_object_key: asset.item_spritesheet_image_object_key,
|
||||
container_prompt: asset.container_prompt,
|
||||
container_image_src: asset.container_image_src,
|
||||
container_image_object_key: asset.container_image_object_key,
|
||||
|
||||
@@ -229,12 +229,27 @@ pub(super) async fn compile_match3d_draft_for_session(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let existing_assets = get_match3d_existing_generated_item_assets(
|
||||
let mut existing_assets = get_match3d_existing_generated_item_assets(
|
||||
state,
|
||||
owner_user_id.as_str(),
|
||||
profile_id.as_str(),
|
||||
)
|
||||
.await;
|
||||
let generated_background_asset = resolve_or_generate_match3d_level_asset_bundle(
|
||||
state,
|
||||
request_context,
|
||||
owner_user_id.as_str(),
|
||||
session.session_id.as_str(),
|
||||
profile_id.as_str(),
|
||||
&config,
|
||||
generated_work_metadata.background_prompt.as_str(),
|
||||
&existing_assets,
|
||||
)
|
||||
.await?;
|
||||
attach_match3d_background_asset_to_assets(
|
||||
&mut existing_assets,
|
||||
generated_background_asset.clone(),
|
||||
);
|
||||
let generated_item_assets = generate_match3d_item_assets(
|
||||
state,
|
||||
request_context,
|
||||
@@ -245,18 +260,22 @@ pub(super) async fn compile_match3d_draft_for_session(
|
||||
&config,
|
||||
generated_work_metadata.items,
|
||||
existing_assets,
|
||||
Some(generated_background_asset.clone()),
|
||||
)
|
||||
.await?;
|
||||
let generated_item_assets = ensure_match3d_background_asset(
|
||||
let mut generated_item_assets = generated_item_assets;
|
||||
attach_match3d_background_asset_to_assets(
|
||||
&mut generated_item_assets,
|
||||
generated_background_asset,
|
||||
);
|
||||
persist_match3d_generated_item_assets_snapshot(
|
||||
state,
|
||||
request_context,
|
||||
authenticated,
|
||||
owner_user_id.as_str(),
|
||||
session.session_id.as_str(),
|
||||
owner_user_id.as_str(),
|
||||
profile_id.as_str(),
|
||||
&config,
|
||||
generated_work_metadata.background_prompt.as_str(),
|
||||
generated_item_assets,
|
||||
&generated_item_assets,
|
||||
)
|
||||
.await?;
|
||||
let existing_cover_image_src = get_match3d_existing_cover_image_src(
|
||||
|
||||
@@ -3,9 +3,8 @@ use super::*;
|
||||
use crate::generated_asset_sheets::crop_generated_asset_sheet_view_edge_matte;
|
||||
use crate::generated_asset_sheets::{
|
||||
GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt,
|
||||
GeneratedAssetSheetPromptInput, GeneratedAssetSheetSliceImage,
|
||||
build_generated_asset_sheet_prompt, persist_generated_asset_sheet_bytes,
|
||||
slice_generated_asset_sheet,
|
||||
GeneratedAssetSheetSliceImage, persist_generated_asset_sheet_bytes,
|
||||
slice_generated_asset_sheet_two_items_per_row,
|
||||
};
|
||||
|
||||
pub(super) async fn generate_match3d_item_assets(
|
||||
@@ -18,6 +17,7 @@ pub(super) async fn generate_match3d_item_assets(
|
||||
config: &Match3DConfigJson,
|
||||
item_plan: Vec<Match3DGeneratedItemPlan>,
|
||||
existing_assets: Vec<Match3DGeneratedItemAsset>,
|
||||
generated_background_asset: Option<Match3DGeneratedBackgroundAsset>,
|
||||
) -> Result<Vec<Match3DGeneratedItemAsset>, Response> {
|
||||
// 中文注释:抓大鹅音频生成当前关闭;自动草稿只补齐 2D 物品图片和可选点击音效。
|
||||
let target_item_count = resolve_match3d_generated_item_count(config);
|
||||
@@ -37,6 +37,7 @@ pub(super) async fn generate_match3d_item_assets(
|
||||
config,
|
||||
item_plan,
|
||||
assets,
|
||||
generated_background_asset,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
@@ -76,6 +77,7 @@ async fn ensure_match3d_item_image_assets(
|
||||
config: &Match3DConfigJson,
|
||||
item_plan: Vec<Match3DGeneratedItemPlan>,
|
||||
existing_assets: Vec<Match3DGeneratedItemAsset>,
|
||||
generated_background_asset: Option<Match3DGeneratedBackgroundAsset>,
|
||||
) -> Result<Vec<Match3DGeneratedItemAsset>, Response> {
|
||||
let mut assets = normalize_match3d_generated_item_assets_for_resume(existing_assets);
|
||||
let target_item_count = resolve_match3d_generated_item_count(config);
|
||||
@@ -101,9 +103,11 @@ async fn ensure_match3d_item_image_assets(
|
||||
background_music_style: None,
|
||||
background_music_prompt: None,
|
||||
background_asset: if index == 0 {
|
||||
assets
|
||||
.first()
|
||||
.and_then(|asset| asset.background_asset.clone())
|
||||
generated_background_asset.clone().or_else(|| {
|
||||
assets
|
||||
.first()
|
||||
.and_then(|asset| asset.background_asset.clone())
|
||||
})
|
||||
} else {
|
||||
None
|
||||
},
|
||||
@@ -160,6 +164,8 @@ struct Match3DItemImageGenerationSeed {
|
||||
struct Match3DMaterialBatchOutput {
|
||||
task_id: String,
|
||||
prompt: String,
|
||||
image_src: Option<String>,
|
||||
image_object_key: Option<String>,
|
||||
generated_at_micros: i64,
|
||||
items: Vec<(
|
||||
Match3DItemImageGenerationSeed,
|
||||
@@ -194,12 +200,17 @@ async fn generate_match3d_item_image_assets_in_batches(
|
||||
.map(|chunk| {
|
||||
let chunk_seeds = chunk.to_vec();
|
||||
async move {
|
||||
let item_names = chunk_seeds
|
||||
.iter()
|
||||
.map(|item| item.item_name.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let material_sheet =
|
||||
generate_match3d_material_sheet(state, config, &item_names).await?;
|
||||
let material_sheet = generate_match3d_material_sheet_from_level_scene(
|
||||
state,
|
||||
owner_user_id,
|
||||
session_id,
|
||||
profile_id,
|
||||
config,
|
||||
chunk_seeds
|
||||
.iter()
|
||||
.find_map(|seed| seed.background_asset.as_ref()),
|
||||
)
|
||||
.await?;
|
||||
let generated_at_micros = current_utc_micros();
|
||||
let persisted_seed_count = chunk_seeds
|
||||
.iter()
|
||||
@@ -218,14 +229,17 @@ async fn generate_match3d_item_image_assets_in_batches(
|
||||
.iter()
|
||||
.map(|item| item.item_name.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let item_images = slice_generated_asset_sheet(
|
||||
let item_images = slice_generated_asset_sheet_two_items_per_row(
|
||||
&material_sheet.image,
|
||||
&persisted_item_names,
|
||||
MATCH3D_MATERIAL_GRID_SIZE as usize,
|
||||
MATCH3D_ITEM_VIEW_COUNT,
|
||||
)?;
|
||||
Ok::<_, AppError>(Match3DMaterialBatchOutput {
|
||||
task_id: material_sheet.task_id,
|
||||
prompt: material_sheet.prompt,
|
||||
image_src: material_sheet.image_src,
|
||||
image_object_key: material_sheet.image_object_key,
|
||||
generated_at_micros,
|
||||
items: persisted_seeds
|
||||
.into_iter()
|
||||
@@ -248,14 +262,22 @@ async fn generate_match3d_item_image_assets_in_batches(
|
||||
for batch in batches {
|
||||
let sheet_task_id = batch.task_id;
|
||||
let sheet_prompt = batch.prompt;
|
||||
let sheet_image_src = batch.image_src;
|
||||
let sheet_image_object_key = batch.image_object_key;
|
||||
let generated_at_micros = batch.generated_at_micros;
|
||||
for (item_index, (seed, item_images)) in batch.items.into_iter().enumerate() {
|
||||
let item_slug = build_match3d_item_slug(seed.item_id.as_str(), seed.item_name.as_str());
|
||||
let mut image_views = Vec::with_capacity(item_images.len());
|
||||
for (view_index, item_image) in item_images.into_iter().enumerate() {
|
||||
let view_number = view_index + 1;
|
||||
let item_name_prompt =
|
||||
format!("第{}行:{} 的 5 个不同视角", item_index + 1, seed.item_name);
|
||||
let (sheet_row_index, sheet_col_index) =
|
||||
resolve_match3d_material_sheet_cell_indices(item_index, view_index);
|
||||
let item_name_prompt = format!(
|
||||
"第{}行第{}种:{} 的 5 个不同形态",
|
||||
item_index / 2 + 1,
|
||||
item_index % 2 + 1,
|
||||
seed.item_name
|
||||
);
|
||||
let view_upload = persist_generated_asset_sheet_bytes(
|
||||
state,
|
||||
GeneratedAssetSheetPersistInput {
|
||||
@@ -277,8 +299,8 @@ async fn generate_match3d_item_image_assets_in_batches(
|
||||
(item_index * MATCH3D_ITEM_VIEW_COUNT + view_index) as i64 + 1,
|
||||
),
|
||||
grid_size: MATCH3D_MATERIAL_GRID_SIZE as usize,
|
||||
row_index: item_index + 1,
|
||||
view_index: view_number,
|
||||
row_index: sheet_row_index,
|
||||
view_index: sheet_col_index,
|
||||
prompt: GeneratedAssetSheetPersistPrompt {
|
||||
sheet_prompt: Some(sheet_prompt.clone()),
|
||||
item_name_prompt: Some(item_name_prompt),
|
||||
@@ -322,7 +344,12 @@ async fn generate_match3d_item_image_assets_in_batches(
|
||||
background_music_prompt: seed.background_music_prompt,
|
||||
background_music: None,
|
||||
click_sound: None,
|
||||
background_asset: seed.background_asset,
|
||||
background_asset: merge_match3d_item_spritesheet_asset_metadata(
|
||||
seed.background_asset,
|
||||
sheet_prompt.clone(),
|
||||
sheet_image_src.clone(),
|
||||
sheet_image_object_key.clone(),
|
||||
),
|
||||
status: "image_ready".to_string(),
|
||||
error: None,
|
||||
},
|
||||
@@ -512,6 +539,7 @@ async fn append_match3d_new_item_assets(
|
||||
return Ok(assets);
|
||||
}
|
||||
let mut next_item_index = next_match3d_generated_item_index(&assets);
|
||||
let background_asset = find_match3d_generated_background_asset(&assets);
|
||||
let item_seeds = append_plan
|
||||
.padded_item_names
|
||||
.into_iter()
|
||||
@@ -527,7 +555,11 @@ async fn append_match3d_new_item_assets(
|
||||
background_music_title: None,
|
||||
background_music_style: None,
|
||||
background_music_prompt: None,
|
||||
background_asset: None,
|
||||
background_asset: if index == 0 {
|
||||
background_asset.clone()
|
||||
} else {
|
||||
None
|
||||
},
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
@@ -697,6 +729,8 @@ async fn replace_match3d_item_assets(
|
||||
pub(super) struct Match3DMaterialSheet {
|
||||
pub(super) task_id: String,
|
||||
pub(super) prompt: String,
|
||||
pub(super) image_src: Option<String>,
|
||||
pub(super) image_object_key: Option<String>,
|
||||
pub(super) image: DownloadedOpenAiImage,
|
||||
}
|
||||
|
||||
@@ -710,6 +744,118 @@ pub(super) struct Match3DVectorEngineGeminiImageSettings {
|
||||
pub(super) struct Match3DSlicedItemImage {
|
||||
pub(super) bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
async fn generate_match3d_material_sheet_from_level_scene(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
profile_id: &str,
|
||||
config: &Match3DConfigJson,
|
||||
background_asset: Option<&Match3DGeneratedBackgroundAsset>,
|
||||
) -> Result<Match3DMaterialSheet, AppError> {
|
||||
let settings = require_openai_image_settings(state)?;
|
||||
let http_client = build_openai_image_http_client(&settings)?;
|
||||
let prompt = build_match3d_item_spritesheet_prompt();
|
||||
let reference = load_match3d_level_scene_reference_image(state, background_asset).await?;
|
||||
let generated = create_openai_image_edit(
|
||||
&http_client,
|
||||
&settings,
|
||||
prompt.as_str(),
|
||||
Some(build_match3d_material_sheet_negative_prompt(config).as_str()),
|
||||
"2k",
|
||||
&reference,
|
||||
"抓大鹅物品 spritesheet 生成失败",
|
||||
)
|
||||
.await?;
|
||||
let image = generated.images.into_iter().next().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine",
|
||||
"message": "抓大鹅物品 spritesheet 生成失败:未返回图片",
|
||||
}))
|
||||
})?;
|
||||
let image = make_match3d_spritesheet_image_transparent(image)?;
|
||||
let upload = persist_match3d_generated_bytes(
|
||||
state,
|
||||
owner_user_id,
|
||||
session_id,
|
||||
profile_id,
|
||||
&["item-spritesheet", generated.task_id.as_str()],
|
||||
"item-spritesheet.png",
|
||||
image.mime_type.as_str(),
|
||||
image.bytes.clone(),
|
||||
"match3d_item_spritesheet_image",
|
||||
Some(generated.task_id.as_str()),
|
||||
current_utc_micros(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Match3DMaterialSheet {
|
||||
task_id: generated.task_id,
|
||||
prompt,
|
||||
image_src: Some(upload.src),
|
||||
image_object_key: Some(upload.object_key),
|
||||
image,
|
||||
})
|
||||
}
|
||||
|
||||
fn merge_match3d_item_spritesheet_asset_metadata(
|
||||
background_asset: Option<Match3DGeneratedBackgroundAsset>,
|
||||
prompt: String,
|
||||
image_src: Option<String>,
|
||||
image_object_key: Option<String>,
|
||||
) -> Option<Match3DGeneratedBackgroundAsset> {
|
||||
background_asset.map(|mut asset| {
|
||||
asset.item_spritesheet_prompt = Some(prompt);
|
||||
asset.item_spritesheet_image_src = image_src;
|
||||
asset.item_spritesheet_image_object_key = image_object_key;
|
||||
asset
|
||||
})
|
||||
}
|
||||
|
||||
async fn load_match3d_level_scene_reference_image(
|
||||
state: &AppState,
|
||||
background_asset: Option<&Match3DGeneratedBackgroundAsset>,
|
||||
) -> Result<OpenAiReferenceImage, AppError> {
|
||||
let Some(source) = background_asset
|
||||
.and_then(|asset| {
|
||||
asset
|
||||
.level_scene_image_object_key
|
||||
.as_deref()
|
||||
.or(asset.level_scene_image_src.as_deref())
|
||||
})
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": MATCH3D_AGENT_PROVIDER,
|
||||
"message": "抓大鹅物品 spritesheet 生成缺少关卡画面参考图",
|
||||
})),
|
||||
);
|
||||
};
|
||||
let bytes = if source.starts_with("data:image/") {
|
||||
decode_match3d_data_url_bytes(source)?
|
||||
} else if source.trim_start_matches('/').starts_with("generated-") {
|
||||
read_match3d_generated_object_bytes(
|
||||
state,
|
||||
source,
|
||||
"读取抓大鹅关卡画面参考图失败",
|
||||
MATCH3D_ITEM_IMAGE_MAX_BYTES,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": MATCH3D_AGENT_PROVIDER,
|
||||
"message": "抓大鹅关卡画面参考图必须是图片 Data URL 或 /generated-* 路径",
|
||||
})),
|
||||
);
|
||||
};
|
||||
Ok(OpenAiReferenceImage {
|
||||
bytes,
|
||||
mime_type: "image/png".to_string(),
|
||||
file_name: "match3d-level-scene.png".to_string(),
|
||||
})
|
||||
}
|
||||
pub(super) fn normalize_match3d_item_name(raw: &str) -> String {
|
||||
raw.trim()
|
||||
.trim_matches(['"', '\'', '“', '”', '。', ',', ',', '、'])
|
||||
@@ -1115,20 +1261,20 @@ pub(super) fn resolve_match3d_gameplay_item_count(config: &Match3DConfigJson) ->
|
||||
8 => 3,
|
||||
12 => 9,
|
||||
16 => 15,
|
||||
20 | 21 => 21,
|
||||
20 | 21 => 20,
|
||||
_ => match config.difficulty {
|
||||
0..=2 => 3,
|
||||
3..=4 => 9,
|
||||
5..=6 => 15,
|
||||
_ => 21,
|
||||
_ => 20,
|
||||
},
|
||||
}
|
||||
.min(MATCH3D_MAX_GENERATED_ITEM_COUNT)
|
||||
}
|
||||
|
||||
pub(super) fn resolve_match3d_generated_item_count(config: &Match3DConfigJson) -> usize {
|
||||
round_match3d_item_count_to_full_sheet(resolve_match3d_gameplay_item_count(config))
|
||||
.min(MATCH3D_MAX_GENERATED_ITEM_COUNT)
|
||||
let _ = config;
|
||||
MATCH3D_MAX_GENERATED_ITEM_COUNT
|
||||
}
|
||||
|
||||
fn round_match3d_item_count_to_full_sheet(item_count: usize) -> usize {
|
||||
@@ -1138,6 +1284,16 @@ fn round_match3d_item_count_to_full_sheet(item_count: usize) -> usize {
|
||||
item_count.div_ceil(MATCH3D_MATERIAL_ITEM_BATCH_SIZE) * MATCH3D_MATERIAL_ITEM_BATCH_SIZE
|
||||
}
|
||||
|
||||
pub(super) fn resolve_match3d_material_sheet_cell_indices(
|
||||
item_index: usize,
|
||||
view_index: usize,
|
||||
) -> (usize, usize) {
|
||||
let items_per_row = (MATCH3D_MATERIAL_GRID_SIZE as usize / MATCH3D_ITEM_VIEW_COUNT).max(1);
|
||||
let row_index = item_index / items_per_row + 1;
|
||||
let col_index = (item_index % items_per_row) * MATCH3D_ITEM_VIEW_COUNT + view_index + 1;
|
||||
(row_index, col_index)
|
||||
}
|
||||
|
||||
pub(super) fn sort_match3d_generated_assets(
|
||||
mut assets: Vec<Match3DGeneratedItemAsset>,
|
||||
) -> Vec<Match3DGeneratedItemAsset> {
|
||||
@@ -1295,11 +1451,23 @@ pub(super) fn is_match3d_background_asset_ready(asset: &Match3DGeneratedBackgrou
|
||||
.filter(|value| !value.is_empty())
|
||||
.is_some())
|
||||
&& (asset
|
||||
.container_image_object_key
|
||||
.ui_spritesheet_image_object_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.is_some()
|
||||
|| asset
|
||||
.ui_spritesheet_image_src
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.is_some()
|
||||
|| asset
|
||||
.container_image_object_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.is_some()
|
||||
|| asset
|
||||
.container_image_src
|
||||
.as_deref()
|
||||
@@ -1312,34 +1480,16 @@ pub(super) fn build_match3d_material_sheet_prompt(
|
||||
config: &Match3DConfigJson,
|
||||
item_names: &[String],
|
||||
) -> String {
|
||||
let asset_style_prompt = resolve_match3d_asset_style_prompt(config);
|
||||
let style_clause = asset_style_prompt
|
||||
.as_ref()
|
||||
.map(|prompt| format!("整体画风遵循:{prompt}。"))
|
||||
.unwrap_or_default();
|
||||
let subject_text = format!(
|
||||
"{}题材的抓大鹅游戏2D物品素材。{style_clause}",
|
||||
config.theme_text
|
||||
);
|
||||
let special_prompt = match3d_material_sheet_special_prompt();
|
||||
build_generated_asset_sheet_prompt(&GeneratedAssetSheetPromptInput {
|
||||
subject_text: subject_text.as_str(),
|
||||
item_names,
|
||||
grid_size: MATCH3D_MATERIAL_GRID_SIZE as usize,
|
||||
item_name_prompt_template: Some("第{row_index}行:{item_name} 的 {view_count} 个不同视角"),
|
||||
special_prompt: Some(special_prompt.as_str()),
|
||||
})
|
||||
.unwrap_or_else(|_| {
|
||||
format!(
|
||||
"生成一张1:1图片。固定生成5行*5列网格素材图,画面是{}题材的抓大鹅游戏2D物品素材。{}",
|
||||
config.theme_text,
|
||||
match3d_material_sheet_special_prompt(),
|
||||
)
|
||||
})
|
||||
let _ = (config, item_names);
|
||||
build_match3d_item_spritesheet_prompt()
|
||||
}
|
||||
|
||||
pub(super) fn build_match3d_item_spritesheet_prompt() -> String {
|
||||
"固定生成10行*10列spritesheet图,统一纯绿色绿幕背景(高饱和亮绿色,接近 #00FF00),背景平整无纹理、无渐变、无阴影、无场景内容,后端会在生图后将绿幕扣成透明并把透明背景 PNG 存到 OSS。素材间距严格均匀分布,任意两个素材间距相同,物品来自参考图中画面中心容器中的2D素材。每一行包含两种物品,每种物品的五个不同形态。物品素材自身不得使用接近 #00FF00 的高饱和纯绿;绿色物品只能使用深绿、橄榄绿、金绿或蓝绿,并用清晰描边与绿幕区分。严禁出现两种高相似度的物品".to_string()
|
||||
}
|
||||
|
||||
fn match3d_material_sheet_special_prompt() -> String {
|
||||
"同一行五格必须是同一物品的五个不同视角,依次为正面、左前、右前、俯视、背面;".to_string()
|
||||
"每一行包含两种物品,每种物品的五个不同形态。".to_string()
|
||||
}
|
||||
|
||||
pub(super) fn build_match3d_material_sheet_negative_prompt(config: &Match3DConfigJson) -> String {
|
||||
@@ -1389,18 +1539,22 @@ pub(super) fn slice_match3d_material_sheet(
|
||||
image: &DownloadedOpenAiImage,
|
||||
item_names: &[String],
|
||||
) -> Result<Vec<Vec<Match3DSlicedItemImage>>, AppError> {
|
||||
slice_generated_asset_sheet(image, item_names, MATCH3D_MATERIAL_GRID_SIZE as usize).map(
|
||||
|rows| {
|
||||
rows.into_iter()
|
||||
.map(|views| {
|
||||
views
|
||||
.into_iter()
|
||||
.map(|view| Match3DSlicedItemImage { bytes: view.bytes })
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
},
|
||||
slice_generated_asset_sheet_two_items_per_row(
|
||||
image,
|
||||
item_names,
|
||||
MATCH3D_MATERIAL_GRID_SIZE as usize,
|
||||
MATCH3D_ITEM_VIEW_COUNT,
|
||||
)
|
||||
.map(|rows| {
|
||||
rows.into_iter()
|
||||
.map(|views| {
|
||||
views
|
||||
.into_iter()
|
||||
.map(|view| Match3DSlicedItemImage { bytes: view.bytes })
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -282,13 +282,25 @@ pub(super) fn map_match3d_image_view_from_work(
|
||||
pub(super) fn map_match3d_background_asset_for_agent(
|
||||
asset: Match3DGeneratedBackgroundAsset,
|
||||
) -> shared_contracts::match3d_agent::Match3DGeneratedBackgroundAssetResponse {
|
||||
let ui_spritesheet_image_src = asset.ui_spritesheet_image_src.clone();
|
||||
let ui_spritesheet_image_object_key = asset.ui_spritesheet_image_object_key.clone();
|
||||
shared_contracts::match3d_agent::Match3DGeneratedBackgroundAssetResponse {
|
||||
prompt: asset.prompt,
|
||||
level_scene_prompt: asset.level_scene_prompt,
|
||||
level_scene_image_src: asset.level_scene_image_src,
|
||||
level_scene_image_object_key: asset.level_scene_image_object_key,
|
||||
image_src: asset.image_src,
|
||||
image_object_key: asset.image_object_key,
|
||||
ui_spritesheet_prompt: asset.ui_spritesheet_prompt,
|
||||
ui_spritesheet_image_src: ui_spritesheet_image_src.clone(),
|
||||
ui_spritesheet_image_object_key: ui_spritesheet_image_object_key.clone(),
|
||||
item_spritesheet_prompt: asset.item_spritesheet_prompt,
|
||||
item_spritesheet_image_src: asset.item_spritesheet_image_src,
|
||||
item_spritesheet_image_object_key: asset.item_spritesheet_image_object_key,
|
||||
container_prompt: asset.container_prompt,
|
||||
container_image_src: asset.container_image_src,
|
||||
container_image_object_key: asset.container_image_object_key,
|
||||
container_image_src: ui_spritesheet_image_src.or(asset.container_image_src),
|
||||
container_image_object_key: ui_spritesheet_image_object_key
|
||||
.or(asset.container_image_object_key),
|
||||
status: asset.status,
|
||||
error: asset.error,
|
||||
}
|
||||
@@ -299,8 +311,17 @@ pub(super) fn map_match3d_background_asset_for_work(
|
||||
) -> shared_contracts::match3d_works::Match3DGeneratedBackgroundAssetResponse {
|
||||
shared_contracts::match3d_works::Match3DGeneratedBackgroundAssetResponse {
|
||||
prompt: asset.prompt,
|
||||
level_scene_prompt: asset.level_scene_prompt,
|
||||
level_scene_image_src: asset.level_scene_image_src,
|
||||
level_scene_image_object_key: asset.level_scene_image_object_key,
|
||||
image_src: asset.image_src,
|
||||
image_object_key: asset.image_object_key,
|
||||
ui_spritesheet_prompt: asset.ui_spritesheet_prompt,
|
||||
ui_spritesheet_image_src: asset.ui_spritesheet_image_src,
|
||||
ui_spritesheet_image_object_key: asset.ui_spritesheet_image_object_key,
|
||||
item_spritesheet_prompt: asset.item_spritesheet_prompt,
|
||||
item_spritesheet_image_src: asset.item_spritesheet_image_src,
|
||||
item_spritesheet_image_object_key: asset.item_spritesheet_image_object_key,
|
||||
container_prompt: asset.container_prompt,
|
||||
container_image_src: asset.container_image_src,
|
||||
container_image_object_key: asset.container_image_object_key,
|
||||
@@ -327,6 +348,14 @@ pub(super) fn resolve_match3d_default_cover_image_src(
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.or_else(|| {
|
||||
asset
|
||||
.ui_spritesheet_image_src
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
})
|
||||
.or_else(|| {
|
||||
asset
|
||||
.container_image_object_key
|
||||
@@ -335,6 +364,14 @@ pub(super) fn resolve_match3d_default_cover_image_src(
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
})
|
||||
.or_else(|| {
|
||||
asset
|
||||
.ui_spritesheet_image_object_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
})
|
||||
.or_else(|| {
|
||||
asset
|
||||
.image_src
|
||||
@@ -408,6 +445,10 @@ fn match3d_item_asset_has_image(asset: &Match3DGeneratedItemAssetJson) -> bool {
|
||||
fn match3d_background_asset_has_image(asset: &Match3DGeneratedBackgroundAsset) -> bool {
|
||||
match3d_text_present(asset.image_src.as_ref())
|
||||
|| match3d_text_present(asset.image_object_key.as_ref())
|
||||
|| match3d_text_present(asset.ui_spritesheet_image_src.as_ref())
|
||||
|| match3d_text_present(asset.ui_spritesheet_image_object_key.as_ref())
|
||||
|| match3d_text_present(asset.item_spritesheet_image_src.as_ref())
|
||||
|| match3d_text_present(asset.item_spritesheet_image_object_key.as_ref())
|
||||
|| match3d_text_present(asset.container_image_src.as_ref())
|
||||
|| match3d_text_present(asset.container_image_object_key.as_ref())
|
||||
}
|
||||
|
||||
@@ -147,17 +147,17 @@ fn match3d_item_image_path_segments_stay_unique_for_chinese_names() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_material_sheet_slicing_uses_fixed_five_by_five_rows() {
|
||||
let width = 500;
|
||||
let height = 500;
|
||||
fn match3d_material_sheet_slicing_uses_fixed_ten_by_ten_two_items_per_row() {
|
||||
let width = 1000;
|
||||
let height = 1000;
|
||||
let item_names = vec!["樱桃".to_string(), "苹果".to_string(), "香蕉".to_string()];
|
||||
let mut sheet = image::RgbaImage::new(width, height);
|
||||
for row in 0..5 {
|
||||
for col in 0..5 {
|
||||
for row in 0..10 {
|
||||
for col in 0..10 {
|
||||
let color = image::Rgba([
|
||||
32 + row as u8 * 40,
|
||||
24 + col as u8 * 36,
|
||||
210 - row as u8 * 30,
|
||||
32 + row as u8 * 16,
|
||||
24 + col as u8 * 18,
|
||||
210 - row as u8 * 12,
|
||||
255,
|
||||
]);
|
||||
for y in row * 100..(row + 1) * 100 {
|
||||
@@ -180,9 +180,12 @@ fn match3d_material_sheet_slicing_uses_fixed_five_by_five_rows() {
|
||||
let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice");
|
||||
|
||||
assert_eq!(slices.len(), 3);
|
||||
for (row, views) in slices.iter().enumerate() {
|
||||
for (item_index, views) in slices.iter().enumerate() {
|
||||
let row = item_index / 2;
|
||||
let start_col = (item_index % 2) * MATCH3D_ITEM_VIEW_COUNT;
|
||||
assert_eq!(views.len(), MATCH3D_ITEM_VIEW_COUNT);
|
||||
for (col, view) in views.iter().enumerate() {
|
||||
for (view_index, view) in views.iter().enumerate() {
|
||||
let col = start_col + view_index;
|
||||
let decoded = image::load_from_memory(view.bytes.as_slice())
|
||||
.expect("view should decode")
|
||||
.to_rgba8();
|
||||
@@ -190,12 +193,12 @@ fn match3d_material_sheet_slicing_uses_fixed_five_by_five_rows() {
|
||||
assert_eq!(
|
||||
pixel.0,
|
||||
[
|
||||
32 + row as u8 * 40,
|
||||
24 + col as u8 * 36,
|
||||
210 - row as u8 * 30,
|
||||
32 + row as u8 * 16,
|
||||
24 + col as u8 * 18,
|
||||
210 - row as u8 * 12,
|
||||
255,
|
||||
],
|
||||
"row {row} col {col} should be cut from the fixed 5*5 grid row"
|
||||
"item {item_index} view {view_index} should be cut from the fixed 10*10 grid"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -203,8 +206,8 @@ fn match3d_material_sheet_slicing_uses_fixed_five_by_five_rows() {
|
||||
|
||||
#[test]
|
||||
fn match3d_material_sheet_slicing_keeps_near_edge_foreground_pixels() {
|
||||
let width = 500;
|
||||
let height = 500;
|
||||
let width = 1000;
|
||||
let height = 1000;
|
||||
let item_names = vec!["樱桃".to_string(), "苹果".to_string(), "香蕉".to_string()];
|
||||
let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([255, 255, 255, 255]));
|
||||
for y in 1..5 {
|
||||
@@ -616,6 +619,52 @@ fn match3d_background_image_postprocess_removes_transparent_pixels() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_level_scene_prompt_uses_requested_theme_and_full_ui_layout() {
|
||||
let prompt = build_match3d_level_scene_generation_prompt(&config("重庆火锅", 12, 4));
|
||||
|
||||
assert!(prompt.contains("重庆火锅"));
|
||||
assert!(prompt.contains("第1关 重庆火锅"));
|
||||
assert!(prompt.contains("返回按钮位于顶部左上角"));
|
||||
assert!(prompt.contains("设置按钮"));
|
||||
assert!(prompt.contains("和主题匹配的容器"));
|
||||
assert!(prompt.contains("移出"));
|
||||
assert!(prompt.contains("凑齐"));
|
||||
assert!(prompt.contains("打乱"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_derived_asset_prompts_match_three_sheet_pipeline() {
|
||||
let config = config("水果", 12, 4);
|
||||
let ui_prompt = build_match3d_ui_spritesheet_prompt();
|
||||
let background_prompt = build_match3d_background_from_scene_prompt();
|
||||
let item_prompt = build_match3d_material_sheet_prompt(
|
||||
&config,
|
||||
&["草莓".to_string(), "苹果".to_string(), "香蕉".to_string()],
|
||||
);
|
||||
|
||||
assert!(ui_prompt.contains("返回按钮"));
|
||||
assert!(ui_prompt.contains("设置按钮"));
|
||||
assert!(ui_prompt.contains("方格素材"));
|
||||
assert!(ui_prompt.contains("纯绿色绿幕背景spritesheet"));
|
||||
assert!(ui_prompt.contains("绿幕扣成透明"));
|
||||
assert!(background_prompt.contains("移除画面中的所有UI组件"));
|
||||
assert!(background_prompt.contains("完整保留容器和背景"));
|
||||
assert!(item_prompt.contains("10行*10列"));
|
||||
assert!(item_prompt.contains("纯绿色绿幕背景"));
|
||||
assert!(item_prompt.contains("扣成透明"));
|
||||
assert!(item_prompt.contains("每一行包含两种物品"));
|
||||
assert!(item_prompt.contains("五个不同形态"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_hardcore_generated_item_count_is_capped_by_ten_by_ten_sheet() {
|
||||
assert_eq!(
|
||||
resolve_match3d_generated_item_count(&config("水果", 21, 8)),
|
||||
20
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_work_metadata_parses_gpt4o_json() {
|
||||
let metadata = parse_match3d_work_metadata(
|
||||
@@ -687,38 +736,69 @@ fn match3d_legacy_item_asset_without_size_defaults_to_large() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_draft_item_plan_rounds_up_to_full_five_item_sheets() {
|
||||
fn match3d_draft_item_plan_rounds_up_to_full_ten_by_ten_sheet() {
|
||||
let plan = parse_match3d_draft_plan(
|
||||
r#"{"gameName":"果园大鹅宴","tags":["水果","抓大鹅"],"backgroundMusic":{"title":"果园轻舞","style":"轻快, 休闲","prompt":""},"backgroundPrompt":"果园主题竖屏纯背景","items":[{"name":"草莓","soundPrompt":"草莓点击音效"},{"name":"苹果","soundPrompt":"苹果点击音效"},{"name":"香蕉","soundPrompt":"香蕉点击音效"},{"name":"葡萄","soundPrompt":"葡萄点击音效"},{"name":"西瓜","soundPrompt":"西瓜点击音效"},{"name":"梨子","soundPrompt":"梨子点击音效"},{"name":"桃子","soundPrompt":"桃子点击音效"},{"name":"橙子","soundPrompt":"橙子点击音效"},{"name":"蓝莓","soundPrompt":"蓝莓点击音效"}]}"#,
|
||||
&config("水果", 12, 4),
|
||||
)
|
||||
.expect("draft plan should parse");
|
||||
|
||||
assert_eq!(plan.items.len(), 10);
|
||||
assert_eq!(plan.items.len(), 20);
|
||||
assert_eq!(plan.items[8].name, "蓝莓");
|
||||
assert_ne!(plan.items[9].name, "蓝莓");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_generated_item_count_rounds_up_to_five_multiples() {
|
||||
fn match3d_generated_item_count_uses_full_ten_by_ten_sheet_capacity() {
|
||||
assert_eq!(
|
||||
resolve_match3d_generated_item_count(&config("水果", 8, 2)),
|
||||
5
|
||||
20
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_match3d_generated_item_count(&config("水果", 12, 4)),
|
||||
10
|
||||
20
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_match3d_generated_item_count(&config("水果", 16, 6)),
|
||||
15
|
||||
20
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_match3d_generated_item_count(&config("水果", 21, 8)),
|
||||
25
|
||||
20
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_gameplay_item_count_uses_difficulty_loading_limit() {
|
||||
assert_eq!(
|
||||
resolve_match3d_gameplay_item_count(&config("水果", 8, 2)),
|
||||
3
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_match3d_gameplay_item_count(&config("水果", 12, 4)),
|
||||
9
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_match3d_gameplay_item_count(&config("水果", 16, 6)),
|
||||
15
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_match3d_gameplay_item_count(&config("水果", 21, 8)),
|
||||
20
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_material_sheet_cell_indices_stay_inside_ten_by_ten_grid() {
|
||||
let first = resolve_match3d_material_sheet_cell_indices(0, 0);
|
||||
let second = resolve_match3d_material_sheet_cell_indices(1, 0);
|
||||
let twentieth_last_view = resolve_match3d_material_sheet_cell_indices(19, 4);
|
||||
|
||||
assert_eq!(first, (1, 1));
|
||||
assert_eq!(second, (1, 6));
|
||||
assert_eq!(twentieth_last_view, (10, 10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_generated_assets_require_only_images_when_click_sound_is_closed() {
|
||||
let assets = vec![test_match3d_generated_item_asset(1, "草莓")];
|
||||
@@ -731,12 +811,11 @@ fn match3d_generated_assets_require_only_images_when_click_sound_is_closed() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_item_asset_points_cost_counts_five_item_batches() {
|
||||
fn match3d_item_asset_points_cost_counts_ten_by_ten_sheet_batches() {
|
||||
assert_eq!(calculate_match3d_item_assets_points_cost(0), 0);
|
||||
assert_eq!(calculate_match3d_item_assets_points_cost(1), 2);
|
||||
assert_eq!(calculate_match3d_item_assets_points_cost(5), 2);
|
||||
assert_eq!(calculate_match3d_item_assets_points_cost(6), 4);
|
||||
assert_eq!(calculate_match3d_item_assets_points_cost(10), 4);
|
||||
assert_eq!(calculate_match3d_item_assets_points_cost(20), 2);
|
||||
assert_eq!(calculate_match3d_item_assets_points_cost(21), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -775,7 +854,7 @@ fn match3d_item_asset_append_plan_pads_generation_without_persisting_padding() {
|
||||
);
|
||||
|
||||
assert_eq!(plan.requested_item_names, vec!["苹果", "香蕉", "梨子"]);
|
||||
assert_eq!(plan.padded_item_names.len(), 5);
|
||||
assert_eq!(plan.padded_item_names.len(), 20);
|
||||
assert_eq!(&plan.padded_item_names[..3], ["苹果", "香蕉", "梨子"]);
|
||||
assert_eq!(
|
||||
calculate_match3d_item_assets_points_cost(plan.requested_item_names.len()),
|
||||
@@ -872,6 +951,7 @@ fn match3d_regenerated_asset_keeps_stable_identity_and_side_assets() {
|
||||
container_image_object_key: None,
|
||||
status: "image_ready".to_string(),
|
||||
error: None,
|
||||
..Default::default()
|
||||
});
|
||||
let mut generated_asset = test_match3d_generated_item_asset(99, "新草莓");
|
||||
generated_asset.image_src =
|
||||
@@ -897,20 +977,19 @@ fn match3d_regenerated_asset_keeps_stable_identity_and_side_assets() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_material_sheet_prompt_requires_uniform_five_by_five_layout() {
|
||||
fn match3d_material_sheet_prompt_requires_uniform_ten_by_ten_transparent_layout() {
|
||||
let prompt = build_match3d_material_sheet_prompt(
|
||||
&config("水果", 12, 4),
|
||||
&["草莓".to_string(), "苹果".to_string(), "香蕉".to_string()],
|
||||
);
|
||||
|
||||
assert!(prompt.contains("5行*5列"));
|
||||
assert!(prompt.contains("严格5*5均匀排布"));
|
||||
assert!(prompt.contains("绿幕背景"));
|
||||
assert!(prompt.contains("10行*10列spritesheet图"));
|
||||
assert!(prompt.contains("纯绿色绿幕背景"));
|
||||
assert!(prompt.contains("#00FF00"));
|
||||
assert!(prompt.contains("单个素材格宽度的1/4空白间距"));
|
||||
assert!(prompt.contains("约25%单格宽度"));
|
||||
assert!(prompt.contains("禁止主体跨格"));
|
||||
assert!(prompt.contains("贴边或越界"));
|
||||
assert!(prompt.contains("素材间距严格均匀分布"));
|
||||
assert!(prompt.contains("每一行包含两种物品"));
|
||||
assert!(prompt.contains("每种物品的五个不同形态"));
|
||||
assert!(prompt.contains("严禁出现两种高相似度的物品"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -921,16 +1000,53 @@ fn match3d_material_sheet_prompt_hardens_pixel_retro_style() {
|
||||
let prompt = build_match3d_material_sheet_prompt(&config, &["草莓".to_string()]);
|
||||
let negative_prompt = build_match3d_material_sheet_negative_prompt(&config);
|
||||
|
||||
assert!(prompt.contains("64x64"));
|
||||
assert!(prompt.contains("整数倍放大"));
|
||||
assert!(prompt.contains("禁止抗锯齿"));
|
||||
assert!(prompt.contains("真实 3D 渲染"));
|
||||
assert!(prompt.contains("PBR 材质"));
|
||||
assert!(prompt.contains("10行*10列spritesheet图"));
|
||||
assert!(prompt.contains("纯绿色绿幕背景"));
|
||||
assert!(negative_prompt.contains("抗锯齿"));
|
||||
assert!(negative_prompt.contains("平滑插画"));
|
||||
assert!(negative_prompt.contains("真实 3D 渲染"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_spritesheet_green_screen_postprocess_turns_background_transparent() {
|
||||
let width = 100;
|
||||
let height = 100;
|
||||
let mut image = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 255, 0, 255]));
|
||||
for y in 32..68 {
|
||||
for x in 32..68 {
|
||||
image.put_pixel(x, y, image::Rgba([220, 32, 48, 255]));
|
||||
}
|
||||
}
|
||||
|
||||
let mut encoded = std::io::Cursor::new(Vec::new());
|
||||
image::DynamicImage::ImageRgba8(image)
|
||||
.write_to(&mut encoded, ImageFormat::Png)
|
||||
.expect("spritesheet should encode");
|
||||
|
||||
let processed = make_match3d_spritesheet_image_transparent(DownloadedOpenAiImage {
|
||||
bytes: encoded.into_inner(),
|
||||
mime_type: "image/png".to_string(),
|
||||
extension: "png".to_string(),
|
||||
})
|
||||
.expect("spritesheet should postprocess");
|
||||
let decoded = image::load_from_memory(processed.bytes.as_slice())
|
||||
.expect("processed spritesheet should decode")
|
||||
.to_rgba8();
|
||||
|
||||
assert_eq!(processed.mime_type, "image/png");
|
||||
assert_eq!(processed.extension, "png");
|
||||
assert_eq!(
|
||||
decoded.get_pixel(0, 0).0[3],
|
||||
0,
|
||||
"绿幕背景必须在上传 OSS 前扣成透明 alpha"
|
||||
);
|
||||
assert_eq!(
|
||||
decoded.get_pixel(width / 2, height / 2).0,
|
||||
[220, 32, 48, 255],
|
||||
"物品主体不能被绿幕去背误删"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_material_sheet_request_uses_vector_engine_gemini_contract() {
|
||||
let body = build_match3d_vector_engine_gemini_image_request_body(
|
||||
@@ -1060,6 +1176,7 @@ fn match3d_background_asset_requires_background_and_container_images() {
|
||||
container_image_object_key: None,
|
||||
status: "image_ready".to_string(),
|
||||
error: None,
|
||||
..Default::default()
|
||||
};
|
||||
let with_container = Match3DGeneratedBackgroundAsset {
|
||||
container_prompt: Some("果园容器".to_string()),
|
||||
@@ -1106,6 +1223,7 @@ fn match3d_default_cover_prefers_generated_container_ui_image() {
|
||||
container_image_object_key: None,
|
||||
status: "image_ready".to_string(),
|
||||
error: None,
|
||||
..Default::default()
|
||||
}),
|
||||
status: "image_ready".to_string(),
|
||||
error: None,
|
||||
@@ -1181,8 +1299,8 @@ fn match3d_cover_reference_prompt_marks_reference_images() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_cover_edit_prompt_preserves_uploaded_image() {
|
||||
let prompt = build_match3d_cover_edit_prompt("水果封面");
|
||||
fn match3d_cover_reference_generation_prompt_preserves_uploaded_image() {
|
||||
let prompt = build_match3d_cover_uploaded_reference_prompt("水果封面");
|
||||
|
||||
assert!(prompt.contains("上传的封面图作为第一优先级"));
|
||||
assert!(prompt.contains("保留主图的主体、构图、视角和主要配色"));
|
||||
@@ -1225,6 +1343,7 @@ fn match3d_fallback_work_profile_keeps_generated_background_asset() {
|
||||
),
|
||||
status: "image_ready".to_string(),
|
||||
error: None,
|
||||
..Default::default()
|
||||
}),
|
||||
status: "image_ready".to_string(),
|
||||
error: None,
|
||||
@@ -1362,6 +1481,7 @@ fn match3d_agent_session_response_hydrates_persisted_ui_assets() {
|
||||
),
|
||||
status: "image_ready".to_string(),
|
||||
error: None,
|
||||
..Default::default()
|
||||
}),
|
||||
status: "image_ready".to_string(),
|
||||
error: None,
|
||||
@@ -1437,6 +1557,7 @@ fn match3d_agent_session_response_keeps_draft_ui_assets_without_work_detail_hydr
|
||||
),
|
||||
status: "image_ready".to_string(),
|
||||
error: None,
|
||||
..Default::default()
|
||||
}),
|
||||
status: "image_ready".to_string(),
|
||||
error: None,
|
||||
@@ -1820,6 +1941,7 @@ fn match3d_work_summary_marks_complete_generated_assets_ready() {
|
||||
),
|
||||
status: "image_ready".to_string(),
|
||||
error: None,
|
||||
..Default::default()
|
||||
}),
|
||||
..test_match3d_generated_item_asset(1, "草莓")
|
||||
}];
|
||||
|
||||
@@ -27,6 +27,8 @@ pub(super) async fn generate_match3d_material_sheet(
|
||||
Ok(Match3DMaterialSheet {
|
||||
task_id: generated.task_id,
|
||||
prompt,
|
||||
image_src: None,
|
||||
image_object_key: None,
|
||||
image,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -212,7 +212,7 @@ pub(super) async fn ensure_match3d_background_asset(
|
||||
}
|
||||
}
|
||||
|
||||
let generated_background = generate_match3d_background_image(
|
||||
let generated_background = generate_match3d_level_asset_bundle(
|
||||
state,
|
||||
owner_user_id,
|
||||
session_id,
|
||||
@@ -236,6 +236,40 @@ pub(super) async fn ensure_match3d_background_asset(
|
||||
Ok(assets)
|
||||
}
|
||||
|
||||
pub(super) async fn resolve_or_generate_match3d_level_asset_bundle(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
profile_id: &str,
|
||||
config: &Match3DConfigJson,
|
||||
background_prompt: &str,
|
||||
assets: &[Match3DGeneratedItemAsset],
|
||||
) -> Result<Match3DGeneratedBackgroundAsset, Response> {
|
||||
if let Some(existing_background) = find_match3d_generated_background_asset(assets) {
|
||||
if is_match3d_background_asset_ready(&existing_background) {
|
||||
return Ok(existing_background);
|
||||
}
|
||||
}
|
||||
|
||||
let normalized_prompt = normalize_match3d_background_prompt(background_prompt);
|
||||
let resolved_prompt = if normalized_prompt.is_empty() {
|
||||
build_fallback_match3d_background_prompt(config)
|
||||
} else {
|
||||
normalized_prompt
|
||||
};
|
||||
generate_match3d_level_asset_bundle(
|
||||
state,
|
||||
owner_user_id,
|
||||
session_id,
|
||||
profile_id,
|
||||
config,
|
||||
resolved_prompt.as_str(),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))
|
||||
}
|
||||
|
||||
pub(super) fn attach_match3d_background_asset_to_assets(
|
||||
assets: &mut Vec<Match3DGeneratedItemAsset>,
|
||||
background_asset: Match3DGeneratedBackgroundAsset,
|
||||
@@ -281,7 +315,7 @@ pub(super) async fn generate_match3d_cover_image_asset(
|
||||
create_openai_image_edit(
|
||||
&http_client,
|
||||
&settings,
|
||||
build_match3d_cover_edit_prompt(cover_prompt.as_str()).as_str(),
|
||||
build_match3d_cover_uploaded_reference_prompt(cover_prompt.as_str()).as_str(),
|
||||
Some("文字、水印、UI、按钮、倒计时、分数、教程浮层、菜单、边框"),
|
||||
"1:1",
|
||||
&uploaded_image,
|
||||
@@ -289,27 +323,38 @@ pub(super) async fn generate_match3d_cover_image_asset(
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
let reference_images = resolve_match3d_cover_reference_image_data_urls(
|
||||
let reference_images = resolve_match3d_cover_reference_images_for_edit(
|
||||
state,
|
||||
reference_image_srcs,
|
||||
MATCH3D_ITEM_IMAGE_MAX_BYTES,
|
||||
)
|
||||
.await?;
|
||||
create_openai_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
build_match3d_cover_reference_generation_prompt(
|
||||
if reference_images.is_empty() {
|
||||
create_openai_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
cover_prompt.as_str(),
|
||||
!reference_images.is_empty(),
|
||||
Some("文字、水印、UI、按钮、倒计时、分数、教程浮层、菜单、边框"),
|
||||
"1:1",
|
||||
1,
|
||||
&[],
|
||||
"抓大鹅封面图生成失败",
|
||||
)
|
||||
.as_str(),
|
||||
Some("文字、水印、UI、按钮、倒计时、分数、教程浮层、菜单、边框"),
|
||||
"1:1",
|
||||
1,
|
||||
reference_images.as_slice(),
|
||||
"抓大鹅封面图生成失败",
|
||||
)
|
||||
.await?
|
||||
.await?
|
||||
} else {
|
||||
create_openai_image_edit_with_references(
|
||||
&http_client,
|
||||
&settings,
|
||||
build_match3d_cover_reference_generation_prompt(cover_prompt.as_str(), true)
|
||||
.as_str(),
|
||||
Some("文字、水印、UI、按钮、倒计时、分数、教程浮层、菜单、边框"),
|
||||
"1:1",
|
||||
1,
|
||||
reference_images.as_slice(),
|
||||
"抓大鹅封面图生成失败",
|
||||
)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
let image = generated.images.into_iter().next().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
@@ -347,7 +392,7 @@ fn build_match3d_cover_generation_prompt(config: &Match3DConfigJson, prompt: &st
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn build_match3d_cover_edit_prompt(prompt: &str) -> String {
|
||||
pub(super) fn build_match3d_cover_uploaded_reference_prompt(prompt: &str) -> String {
|
||||
format!(
|
||||
concat!(
|
||||
"请以随请求上传的封面图作为第一优先级重绘依据,保留主图的主体、构图、视角和主要配色;",
|
||||
@@ -382,24 +427,113 @@ pub(super) async fn generate_match3d_background_image(
|
||||
profile_id: &str,
|
||||
config: &Match3DConfigJson,
|
||||
prompt: &str,
|
||||
) -> Result<Match3DGeneratedBackgroundAsset, AppError> {
|
||||
generate_match3d_level_asset_bundle(
|
||||
state,
|
||||
owner_user_id,
|
||||
session_id,
|
||||
profile_id,
|
||||
config,
|
||||
prompt,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn generate_match3d_level_asset_bundle(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
profile_id: &str,
|
||||
config: &Match3DConfigJson,
|
||||
prompt: &str,
|
||||
) -> Result<Match3DGeneratedBackgroundAsset, AppError> {
|
||||
require_match3d_oss_client(state)?;
|
||||
let settings = require_openai_image_settings(state)?;
|
||||
let http_client = build_openai_image_http_client(&settings)?;
|
||||
let reference_image = load_match3d_container_reference_image()?;
|
||||
let generated_background = create_openai_image_generation(
|
||||
|
||||
let level_scene_prompt = build_match3d_level_scene_generation_prompt(config);
|
||||
let generated_scene = create_openai_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
build_match3d_background_generation_prompt(config, prompt).as_str(),
|
||||
Some(
|
||||
"文字、水印、UI、按钮、倒计时、分数、物品、角色、手、边框、教程浮层、菜单、透明区域、透明 alpha、镂空、棋盘格透明底",
|
||||
),
|
||||
level_scene_prompt.as_str(),
|
||||
Some("水印、教程浮层、菜单、广告、真实手机外框、浏览器 UI"),
|
||||
"9:16",
|
||||
1,
|
||||
&[],
|
||||
"抓大鹅背景图生成失败",
|
||||
"抓大鹅关卡画面生成失败",
|
||||
)
|
||||
.await?;
|
||||
let level_scene_image = generated_scene.images.into_iter().next().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine",
|
||||
"message": "抓大鹅关卡画面生成失败:未返回图片",
|
||||
}))
|
||||
})?;
|
||||
let level_scene_reference = OpenAiReferenceImage {
|
||||
bytes: level_scene_image.bytes.clone(),
|
||||
mime_type: level_scene_image.mime_type.clone(),
|
||||
file_name: "match3d-level-scene.png".to_string(),
|
||||
};
|
||||
let level_scene_upload = persist_match3d_generated_bytes(
|
||||
state,
|
||||
owner_user_id,
|
||||
session_id,
|
||||
profile_id,
|
||||
&["level-scene", generated_scene.task_id.as_str()],
|
||||
"scene.png",
|
||||
level_scene_image.mime_type.as_str(),
|
||||
level_scene_image.bytes,
|
||||
"match3d_level_scene_image",
|
||||
Some(generated_scene.task_id.as_str()),
|
||||
current_utc_micros(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let ui_prompt = build_match3d_ui_spritesheet_prompt();
|
||||
let background_extract_prompt = build_match3d_background_from_scene_prompt();
|
||||
let generated_ui_future = create_openai_image_edit(
|
||||
&http_client,
|
||||
&settings,
|
||||
ui_prompt.as_str(),
|
||||
Some("整页背景、中心物品、容器内物品、重复按钮、文字说明、白底、纯色底、网格线"),
|
||||
"1:1",
|
||||
&level_scene_reference,
|
||||
"抓大鹅 UI spritesheet 生成失败",
|
||||
);
|
||||
let generated_background_future = create_openai_image_edit(
|
||||
&http_client,
|
||||
&settings,
|
||||
background_extract_prompt.as_str(),
|
||||
Some("返回按钮、设置按钮、倒计时、标题文字、道具按钮、物品、容器内含物、菜单、教程浮层"),
|
||||
"9:16",
|
||||
&level_scene_reference,
|
||||
"抓大鹅背景图生成失败",
|
||||
);
|
||||
let (generated_ui, generated_background) =
|
||||
tokio::try_join!(generated_ui_future, generated_background_future)?;
|
||||
|
||||
let ui_image = generated_ui.images.into_iter().next().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine",
|
||||
"message": "抓大鹅 UI spritesheet 生成失败:未返回图片",
|
||||
}))
|
||||
})?;
|
||||
let ui_image = make_match3d_spritesheet_image_transparent(ui_image)?;
|
||||
let ui_upload = persist_match3d_generated_bytes(
|
||||
state,
|
||||
owner_user_id,
|
||||
session_id,
|
||||
profile_id,
|
||||
&["ui-spritesheet", generated_ui.task_id.as_str()],
|
||||
"ui-spritesheet.png",
|
||||
ui_image.mime_type.as_str(),
|
||||
ui_image.bytes,
|
||||
"match3d_ui_spritesheet_image",
|
||||
Some(generated_ui.task_id.as_str()),
|
||||
current_utc_micros(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let background_image = generated_background
|
||||
.images
|
||||
.into_iter()
|
||||
@@ -426,50 +560,22 @@ pub(super) async fn generate_match3d_background_image(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let container_prompt = build_match3d_container_generation_prompt(config, prompt);
|
||||
let generated_container = create_openai_image_edit(
|
||||
&http_client,
|
||||
&settings,
|
||||
container_prompt.as_str(),
|
||||
Some("文字、水印、按钮、倒计时、分数、物品、角色、手、教程浮层、菜单、整页背景、小容器、正俯视圆盘、侧视碗、餐盘、托盘、画布大留白"),
|
||||
"1:1",
|
||||
&reference_image,
|
||||
"抓大鹅容器 UI 图生成失败",
|
||||
)
|
||||
.await?;
|
||||
let container_image = generated_container
|
||||
.images
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine",
|
||||
"message": "抓大鹅容器 UI 图生成失败:未返回图片",
|
||||
}))
|
||||
})?;
|
||||
let container_image = make_match3d_container_image_transparent(container_image)?;
|
||||
let container_upload = persist_match3d_generated_bytes(
|
||||
state,
|
||||
owner_user_id,
|
||||
session_id,
|
||||
profile_id,
|
||||
&["ui-container", generated_container.task_id.as_str()],
|
||||
"container.png",
|
||||
container_image.mime_type.as_str(),
|
||||
container_image.bytes,
|
||||
"match3d_ui_container_image",
|
||||
Some(generated_container.task_id.as_str()),
|
||||
current_utc_micros(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Match3DGeneratedBackgroundAsset {
|
||||
prompt: prompt.to_string(),
|
||||
level_scene_prompt: Some(level_scene_prompt),
|
||||
level_scene_image_src: Some(level_scene_upload.src),
|
||||
level_scene_image_object_key: Some(level_scene_upload.object_key),
|
||||
image_src: Some(background_upload.src),
|
||||
image_object_key: Some(background_upload.object_key),
|
||||
container_prompt: Some(container_prompt),
|
||||
container_image_src: Some(container_upload.src),
|
||||
container_image_object_key: Some(container_upload.object_key),
|
||||
ui_spritesheet_prompt: Some(ui_prompt.clone()),
|
||||
ui_spritesheet_image_src: Some(ui_upload.src.clone()),
|
||||
ui_spritesheet_image_object_key: Some(ui_upload.object_key.clone()),
|
||||
item_spritesheet_prompt: None,
|
||||
item_spritesheet_image_src: None,
|
||||
item_spritesheet_image_object_key: None,
|
||||
container_prompt: Some(ui_prompt),
|
||||
container_image_src: Some(ui_upload.src),
|
||||
container_image_object_key: Some(ui_upload.object_key),
|
||||
status: "image_ready".to_string(),
|
||||
error: None,
|
||||
})
|
||||
@@ -533,6 +639,7 @@ pub(super) async fn generate_match3d_container_image(
|
||||
container_image_object_key: Some(container_upload.object_key),
|
||||
status: "image_ready".to_string(),
|
||||
error: None,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -549,12 +656,39 @@ pub(super) fn merge_match3d_container_image_into_background_asset(
|
||||
.unwrap_or_else(|| container_asset.prompt.clone());
|
||||
Match3DGeneratedBackgroundAsset {
|
||||
prompt,
|
||||
level_scene_prompt: existing_background
|
||||
.as_ref()
|
||||
.and_then(|asset| asset.level_scene_prompt.clone()),
|
||||
level_scene_image_src: existing_background
|
||||
.as_ref()
|
||||
.and_then(|asset| asset.level_scene_image_src.clone()),
|
||||
level_scene_image_object_key: existing_background
|
||||
.as_ref()
|
||||
.and_then(|asset| asset.level_scene_image_object_key.clone()),
|
||||
image_src: existing_background
|
||||
.as_ref()
|
||||
.and_then(|asset| asset.image_src.clone()),
|
||||
image_object_key: existing_background
|
||||
.as_ref()
|
||||
.and_then(|asset| asset.image_object_key.clone()),
|
||||
ui_spritesheet_prompt: existing_background
|
||||
.as_ref()
|
||||
.and_then(|asset| asset.ui_spritesheet_prompt.clone()),
|
||||
ui_spritesheet_image_src: existing_background
|
||||
.as_ref()
|
||||
.and_then(|asset| asset.ui_spritesheet_image_src.clone()),
|
||||
ui_spritesheet_image_object_key: existing_background
|
||||
.as_ref()
|
||||
.and_then(|asset| asset.ui_spritesheet_image_object_key.clone()),
|
||||
item_spritesheet_prompt: existing_background
|
||||
.as_ref()
|
||||
.and_then(|asset| asset.item_spritesheet_prompt.clone()),
|
||||
item_spritesheet_image_src: existing_background
|
||||
.as_ref()
|
||||
.and_then(|asset| asset.item_spritesheet_image_src.clone()),
|
||||
item_spritesheet_image_object_key: existing_background
|
||||
.as_ref()
|
||||
.and_then(|asset| asset.item_spritesheet_image_object_key.clone()),
|
||||
container_prompt: container_asset.container_prompt,
|
||||
container_image_src: container_asset.container_image_src,
|
||||
container_image_object_key: container_asset.container_image_object_key,
|
||||
@@ -582,6 +716,44 @@ pub(super) fn load_match3d_container_reference_image() -> Result<OpenAiReference
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn build_match3d_level_scene_generation_prompt(config: &Match3DConfigJson) -> String {
|
||||
let theme = config.theme_text.trim();
|
||||
let theme = if theme.is_empty() {
|
||||
MATCH3D_DEFAULT_THEME
|
||||
} else {
|
||||
theme
|
||||
};
|
||||
let style_clause = resolve_match3d_asset_style_prompt(config)
|
||||
.map(|style| format!("\n整体美术风格要求:{style}"))
|
||||
.unwrap_or_default();
|
||||
|
||||
format!(
|
||||
concat!(
|
||||
"生成抓大鹅游戏关卡画面,要求画面中所有元素精致且风格高度一致,画面中所有UI细节饱满精致、完成度高、顶级游戏品质\n\n",
|
||||
"抓大鹅主题描述:\n",
|
||||
"{theme}{style_clause}\n\n",
|
||||
"画面元素:\n",
|
||||
"返回按钮位于顶部左上角,顶部中间显示关卡标题“第1关 重庆火锅”和倒计时时间,右上角显示设置按钮\n",
|
||||
"画面中间是一个和主题匹配的容器,宽度与画面宽度同宽,紧贴画面横向边缘\n",
|
||||
"底部还有三个道具按钮分别为“移出”、“凑齐”、“打乱”"
|
||||
),
|
||||
theme = theme,
|
||||
style_clause = style_clause,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn build_match3d_ui_spritesheet_prompt() -> String {
|
||||
"提取画面中的UI元素,将返回按钮、设置按钮、方格素材(不含边框,仅保留一个)、移出按钮、凑齐按钮、打乱按钮的顺序从上到下从左到右整理成纯绿色绿幕背景spritesheet。背景必须是统一纯绿色绿幕(高饱和亮绿色,接近 #00FF00),背景平整无纹理、无渐变、无阴影、无场景内容,后端会在生图后将绿幕扣成透明并把透明背景 PNG 存到 OSS。UI 素材自身不得使用接近 #00FF00 的高饱和纯绿;绿色题材只能使用深绿、橄榄绿、金绿或蓝绿,并用清晰描边与绿幕区分。".to_string()
|
||||
}
|
||||
|
||||
pub(super) fn build_match3d_background_from_scene_prompt() -> String {
|
||||
"移除画面中的所有UI组件和容器中的内含物,完整保留容器和背景,补全被UI覆盖的背景内容".to_string()
|
||||
}
|
||||
|
||||
pub(super) fn build_match3d_item_spritesheet_prompt() -> String {
|
||||
"固定生成10行*10列spritesheet图,统一纯绿色绿幕背景(高饱和亮绿色,接近 #00FF00),背景平整无纹理、无渐变、无阴影、无场景内容,后端会在生图后将绿幕扣成透明并把透明背景 PNG 存到 OSS。素材间距严格均匀分布,任意两个素材间距相同,物品来自参考图中画面中心容器中的2D素材。每一行包含两种物品,每种物品的五个不同形态。物品素材自身不得使用接近 #00FF00 的高饱和纯绿;绿色物品只能使用深绿、橄榄绿、金绿或蓝绿,并用清晰描边与绿幕区分。严禁出现两种高相似度的物品".to_string()
|
||||
}
|
||||
|
||||
pub(super) fn build_match3d_background_generation_prompt(
|
||||
config: &Match3DConfigJson,
|
||||
prompt: &str,
|
||||
@@ -761,6 +933,32 @@ pub(super) fn make_match3d_container_image_transparent(
|
||||
extension: "png".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn make_match3d_spritesheet_image_transparent(
|
||||
image: DownloadedOpenAiImage,
|
||||
) -> Result<DownloadedOpenAiImage, AppError> {
|
||||
let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "match3d-assets",
|
||||
"message": format!("抓大鹅 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": "match3d-assets",
|
||||
"message": format!("抓大鹅 spritesheet 图透明化失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
|
||||
Ok(DownloadedOpenAiImage {
|
||||
bytes: encoded.into_inner(),
|
||||
mime_type: "image/png".to_string(),
|
||||
extension: "png".to_string(),
|
||||
})
|
||||
}
|
||||
pub(super) async fn download_match3d_legacy_model(
|
||||
file: &hyper3d_contract::Hyper3dDownloadFilePayload,
|
||||
) -> Result<Match3DDownloadedModel, AppError> {
|
||||
@@ -864,7 +1062,7 @@ pub(super) fn is_match3d_glb_binary_payload(bytes: &[u8]) -> bool {
|
||||
magic == 0x4654_6c67 && version == 2 && declared_length == bytes.len()
|
||||
}
|
||||
|
||||
async fn read_match3d_generated_object_bytes(
|
||||
pub(super) async fn read_match3d_generated_object_bytes(
|
||||
state: &AppState,
|
||||
object_key: &str,
|
||||
message_prefix: &str,
|
||||
@@ -915,57 +1113,6 @@ async fn read_match3d_generated_object_bytes(
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
|
||||
async fn resolve_match3d_reference_image_data_url(
|
||||
state: &AppState,
|
||||
source: Option<&str>,
|
||||
max_size_bytes: usize,
|
||||
) -> Result<Option<String>, AppError> {
|
||||
let Some(source) = source.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if source.starts_with("data:image/") {
|
||||
return Ok(Some(source.to_string()));
|
||||
}
|
||||
if let Some(public_path) = normalize_match3d_public_reference_image_path(source) {
|
||||
let bytes = tokio::fs::read(public_path.as_str())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": MATCH3D_WORKS_PROVIDER,
|
||||
"message": format!("读取抓大鹅本地参考图失败:{error}"),
|
||||
"path": public_path,
|
||||
}))
|
||||
})?;
|
||||
if bytes.is_empty() || bytes.len() > max_size_bytes {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": MATCH3D_WORKS_PROVIDER,
|
||||
"field": "referenceImageSrcs",
|
||||
"message": "封面参考图过大,请压缩后重试。",
|
||||
"maxBytes": max_size_bytes,
|
||||
"actualBytes": bytes.len(),
|
||||
})),
|
||||
);
|
||||
}
|
||||
return Ok(Some(format!(
|
||||
"data:{};base64,{}",
|
||||
infer_match3d_image_mime_type(bytes.as_slice()),
|
||||
BASE64_STANDARD.encode(bytes)
|
||||
)));
|
||||
}
|
||||
if !source.trim_start_matches('/').starts_with("generated-") {
|
||||
return Ok(Some(source.to_string()));
|
||||
}
|
||||
let bytes =
|
||||
read_match3d_generated_object_bytes(state, source, "读取抓大鹅参考图失败", max_size_bytes)
|
||||
.await?;
|
||||
Ok(Some(format!(
|
||||
"data:{};base64,{}",
|
||||
infer_match3d_image_mime_type(bytes.as_slice()),
|
||||
BASE64_STANDARD.encode(bytes)
|
||||
)))
|
||||
}
|
||||
|
||||
pub(super) fn normalize_match3d_public_reference_image_path(source: &str) -> Option<String> {
|
||||
let source = source
|
||||
.trim()
|
||||
@@ -1018,18 +1165,22 @@ pub(super) fn collect_match3d_cover_reference_image_sources(
|
||||
sources
|
||||
}
|
||||
|
||||
async fn resolve_match3d_cover_reference_image_data_urls(
|
||||
async fn resolve_match3d_cover_reference_images_for_edit(
|
||||
state: &AppState,
|
||||
sources: Vec<String>,
|
||||
max_size_bytes: usize,
|
||||
) -> Result<Vec<String>, AppError> {
|
||||
) -> Result<Vec<OpenAiReferenceImage>, AppError> {
|
||||
let mut resolved = Vec::new();
|
||||
for source in sources {
|
||||
if let Some(data_url) =
|
||||
resolve_match3d_reference_image_data_url(state, Some(source.as_str()), max_size_bytes)
|
||||
.await?
|
||||
for (index, source) in sources.into_iter().enumerate() {
|
||||
if let Some(image) = resolve_match3d_reference_image_for_edit(
|
||||
state,
|
||||
Some(source.as_str()),
|
||||
max_size_bytes,
|
||||
format!("match3d-cover-reference-{index}").as_str(),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
resolved.push(data_url);
|
||||
resolved.push(image);
|
||||
}
|
||||
}
|
||||
Ok(resolved)
|
||||
@@ -1046,6 +1197,16 @@ async fn resolve_match3d_reference_image_for_edit(
|
||||
};
|
||||
let bytes = if source.starts_with("data:image/") {
|
||||
decode_match3d_data_url_bytes(source)?
|
||||
} else if let Some(public_path) = normalize_match3d_public_reference_image_path(source) {
|
||||
tokio::fs::read(public_path.as_str())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": MATCH3D_WORKS_PROVIDER,
|
||||
"message": format!("读取抓大鹅本地参考图失败:{error}"),
|
||||
"path": public_path,
|
||||
}))
|
||||
})?
|
||||
} else if source.trim_start_matches('/').starts_with("generated-") {
|
||||
read_match3d_generated_object_bytes(
|
||||
state,
|
||||
@@ -1059,7 +1220,7 @@ async fn resolve_match3d_reference_image_for_edit(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": MATCH3D_WORKS_PROVIDER,
|
||||
"field": "uploadedImageSrc",
|
||||
"message": "封面上传图必须是图片 Data URL 或 /generated-* 路径。",
|
||||
"message": "封面参考图必须是图片 Data URL、本地 public 参考图或 /generated-* 路径。",
|
||||
})),
|
||||
);
|
||||
};
|
||||
@@ -1086,7 +1247,7 @@ async fn resolve_match3d_reference_image_for_edit(
|
||||
}))
|
||||
}
|
||||
|
||||
fn decode_match3d_data_url_bytes(source: &str) -> Result<Vec<u8>, AppError> {
|
||||
pub(super) fn decode_match3d_data_url_bytes(source: &str) -> Result<Vec<u8>, AppError> {
|
||||
let Some((header, data)) = source.split_once(',') else {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use axum::{
|
||||
Router,
|
||||
extract::DefaultBodyLimit,
|
||||
extract::{DefaultBodyLimit, FromRef},
|
||||
middleware,
|
||||
routing::{get, post},
|
||||
};
|
||||
@@ -17,12 +17,13 @@ use crate::{
|
||||
submit_puzzle_agent_message, submit_puzzle_leaderboard, swap_puzzle_pieces,
|
||||
update_puzzle_run_pause, use_puzzle_runtime_prop,
|
||||
},
|
||||
state::AppState,
|
||||
state::{AppState, PuzzleApiState},
|
||||
};
|
||||
|
||||
const PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024;
|
||||
|
||||
pub fn router(state: AppState) -> Router<AppState> {
|
||||
// 中文注释:拼图 handler 只接收 PuzzleApiState,鉴权层仍使用全局 AppState。
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/runtime/puzzle/agent/sessions",
|
||||
@@ -181,4 +182,6 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.with_state(PuzzleApiState::from_ref(&state))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -40,9 +40,11 @@ pub(crate) fn resolve_puzzle_draft_cover_prompt(
|
||||
pub(crate) fn resolve_puzzle_level_image_prompt(
|
||||
explicit_prompt: Option<&str>,
|
||||
level_picture_description: &str,
|
||||
draft_summary: &str,
|
||||
) -> String {
|
||||
normalize_prompt_part(explicit_prompt)
|
||||
.or_else(|| normalize_prompt_part(Some(level_picture_description)))
|
||||
.or_else(|| normalize_prompt_part(Some(draft_summary)))
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
}
|
||||
@@ -76,8 +78,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn level_image_prompt_falls_back_to_level_description() {
|
||||
let prompt = resolve_puzzle_level_image_prompt(Some(" "), "关卡画面描述");
|
||||
let prompt = resolve_puzzle_level_image_prompt(Some(" "), "关卡画面描述", "作品简介");
|
||||
|
||||
assert_eq!(prompt, "关卡画面描述");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_image_prompt_falls_back_to_draft_summary_like_initial_cover() {
|
||||
let prompt = resolve_puzzle_level_image_prompt(Some(" "), " ", "作品简介");
|
||||
|
||||
assert_eq!(prompt, "作品简介");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ fn build_puzzle_image_prompt_text(_level_name: &str, prompt: &str) -> String {
|
||||
concat!(
|
||||
"请生成一张高清插画。",
|
||||
"画面主体:{prompt}。",
|
||||
"画面要求:1:1",
|
||||
"画面要求:输出画面比例为1:1,",
|
||||
"主体要清晰集中,前中后景层次明确,局部细节丰富但不要杂乱,",
|
||||
"避免文字、水印、边框和 UI 元素。"
|
||||
),
|
||||
@@ -77,7 +77,7 @@ mod tests {
|
||||
let prompt = build_puzzle_image_prompt("雨夜神庙", "猫咪在发光遗迹前寻找线索");
|
||||
|
||||
assert!(prompt.contains("猫咪在发光遗迹前寻找线索"));
|
||||
assert!(prompt.contains("1:1"));
|
||||
assert!(prompt.contains("输出画面比例为1:1"));
|
||||
assert!(prompt.contains("主体要清晰集中"));
|
||||
assert!(prompt.contains("避免文字、水印、边框和 UI 元素"));
|
||||
}
|
||||
@@ -90,7 +90,7 @@ mod tests {
|
||||
let prompt = build_puzzle_image_prompt(long_level_name.as_str(), long_description.as_str());
|
||||
|
||||
assert!(prompt.chars().count() <= PUZZLE_TEXT_TO_IMAGE_PROMPT_MAX_CHARS);
|
||||
assert!(prompt.contains("1:1"));
|
||||
assert!(prompt.contains("输出画面比例为1:1"));
|
||||
assert!(prompt.contains("主体要清晰集中"));
|
||||
assert!(prompt.contains("避免文字、水印、边框和 UI 元素"));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
error::Error as StdError,
|
||||
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
@@ -21,10 +20,8 @@ use module_assets::{
|
||||
};
|
||||
use module_puzzle::{PuzzleGeneratedImageCandidate, PuzzleRuntimeLevelStatus};
|
||||
use platform_llm::{LlmMessage, LlmMessageContentPart, LlmTextRequest};
|
||||
use platform_oss::{
|
||||
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest,
|
||||
OssSignedGetObjectUrlRequest,
|
||||
};
|
||||
use platform_oss::{LegacyAssetPrefix, OssSignedGetObjectUrlRequest};
|
||||
use platform_oss::{OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest};
|
||||
use serde_json::{Map, Value, json};
|
||||
use shared_contracts::{
|
||||
creation_audio::CreationAudioAsset,
|
||||
@@ -80,10 +77,11 @@ use crate::{
|
||||
should_skip_asset_operation_billing_for_connectivity,
|
||||
},
|
||||
auth::AuthenticatedAccessToken,
|
||||
generated_asset_sheets::apply_generated_asset_sheet_green_screen_alpha,
|
||||
http_error::AppError,
|
||||
llm_model_routing::{CREATION_TEMPLATE_LLM_MODEL, PUZZLE_LEVEL_NAME_VISION_LLM_MODEL},
|
||||
openai_image_generation::{
|
||||
DownloadedOpenAiImage, VECTOR_ENGINE_GPT_IMAGE_2_MODEL, build_openai_image_http_client,
|
||||
DownloadedOpenAiImage, GPT_IMAGE_2_MODEL, build_openai_image_http_client,
|
||||
create_openai_image_generation, require_openai_image_settings,
|
||||
},
|
||||
platform_errors::map_oss_error,
|
||||
@@ -105,12 +103,9 @@ use crate::{
|
||||
},
|
||||
puzzle_gallery_cache::{build_puzzle_gallery_window_response, puzzle_gallery_cached_json},
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
vector_engine_audio_generation::{
|
||||
GeneratedCreationAudioTarget, generate_background_music_asset_for_creation,
|
||||
},
|
||||
work_author::resolve_work_author_by_user_id,
|
||||
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
|
||||
state::PuzzleApiState,
|
||||
work_author::resolve_puzzle_work_author_by_user_id,
|
||||
work_play_tracking::{WorkPlayTrackingDraft, record_puzzle_work_play_start_after_success},
|
||||
};
|
||||
|
||||
const PUZZLE_AGENT_API_BASE_PROVIDER: &str = "puzzle-agent";
|
||||
@@ -121,8 +116,6 @@ const PUZZLE_IMAGE_MODEL_GPT_IMAGE_2: &str = "gpt-image-2";
|
||||
const PUZZLE_IMAGE_MODEL_GEMINI_31_FLASH_PREVIEW: &str = "gemini-3.1-flash-image-preview";
|
||||
const PUZZLE_IMAGE_GENERATION_POINTS_COST: u64 = 2;
|
||||
const PUZZLE_ENTITY_KIND: &str = "puzzle_work";
|
||||
const PUZZLE_BACKGROUND_MUSIC_ASSET_KIND: &str = "puzzle_background_music";
|
||||
const PUZZLE_BACKGROUND_MUSIC_SLOT: &str = "background_music";
|
||||
#[cfg(test)]
|
||||
const PUZZLE_GENERATED_IMAGE_SIZE: &str = "1024*1024";
|
||||
const PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE: &str = "1024x1024";
|
||||
@@ -131,9 +124,13 @@ const PUZZLE_LEVEL_NAME_VISION_IMAGE_MAX_SIDE: u32 = 768;
|
||||
const PUZZLE_LEVEL_NAME_VISION_MAX_TOKENS: u32 = 512;
|
||||
const PUZZLE_REFERENCE_IMAGE_MAX_BYTES: usize = 8 * 1024 * 1024;
|
||||
const PUZZLE_REFERENCE_IMAGE_SOURCE_LIMIT: usize = 5;
|
||||
const PUZZLE_VECTOR_ENGINE_IMAGE_EDIT_MODEL: &str = "gpt-image-2";
|
||||
const PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER: &str =
|
||||
"移动端拼图游戏纯背景,题材氛围清晰,不包含拼图槽或 UI 元素";
|
||||
const PUZZLE_VECTOR_ENGINE_SQUARE_IMAGE_SIZE: &str = "1024x1024";
|
||||
const PUZZLE_VECTOR_ENGINE_PORTRAIT_IMAGE_SIZE: &str = "1024x1536";
|
||||
const PUZZLE_LEVEL_SCENE_IMAGE_PROMPT: &str = "参考图作为拼图画面,生成对应的拼图游戏关卡画面,要求画面中所有元素精致且风格高度一致,画面中所有UI细节饱满精致、完成度高、顶级游戏品质\n\n画面元素:\n返回按钮位于顶部左上角,顶部中间显示关卡标题“第1关 影”和倒计时时间,右上角显示设置按钮\n画面中间是一个正方形的3*3拼图,拼图区域宽度与画面宽度同宽,紧贴画面横向边缘,拼图区域边界带有边框装饰\n拼图区域下方包含一个下一关按钮,仅在关卡完成时显示\n底部是三个贴合画面主题的道具按钮分别为“提示”、“原图”、“冻结”\n道具按钮上不要显示次数标注,返回按钮和设置按钮旁禁止标注文字";
|
||||
const PUZZLE_UI_SPRITESHEET_IMAGE_PROMPT: &str = "提取画面中的UI元素,将返回按钮、设置按钮、下一关按钮、提示按钮、原图按钮、冻结按钮整理成纯绿色绿幕背景的spritesheet。背景必须是统一纯绿色绿幕(高饱和亮绿色,接近 #00FF00),背景平整无纹理、无渐变、无阴影、无场景内容,后端会在生图后将绿幕扣成透明并把透明背景 PNG 存到 OSS。按钮顺序必须按原图位置从左到右、从上到下排列:返回、设置、下一关、提示、原图、冻结。按钮素材内必须保留对应中文文字,每个按钮必须是独立完整图形,按钮之间保留足够纯绿色绿幕空白,不能相互接触、重叠或连成一片,方便运行态按自动边界检测识别矩形素材。返回按钮和设置按钮不要额外画白色外圈、白底圆环或浮雕外框,直接画扁平图标本体。按钮自身不得使用接近 #00FF00 的高饱和纯绿;绿色题材只能使用深绿、橄榄绿、金绿或蓝绿,并用清晰描边与绿幕区分。禁止水印、数字、次数标注、透明背景、背景图、拼图块、棋盘、网格线、按钮外标签和额外按钮。";
|
||||
const PUZZLE_LEVEL_BACKGROUND_IMAGE_PROMPT: &str = "移除参考图中所有UI元素、移除拼图画面,仅保留背景图,补全被覆盖的背景图内容。禁止在背景中出现人像或和拼图画面中主体一致的内容";
|
||||
mod handlers;
|
||||
pub(crate) use self::handlers::*;
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ pub(crate) fn build_puzzle_form_seed_text_from_parts(
|
||||
}
|
||||
|
||||
pub(crate) async fn save_puzzle_form_payload_before_compile(
|
||||
state: &AppState,
|
||||
state: &PuzzleApiState,
|
||||
request_context: &RequestContext,
|
||||
session_id: &str,
|
||||
owner_user_id: &str,
|
||||
@@ -76,7 +76,7 @@ pub(crate) async fn save_puzzle_form_payload_before_compile(
|
||||
}
|
||||
|
||||
pub(crate) async fn create_seeded_puzzle_session_when_form_save_missing(
|
||||
state: &AppState,
|
||||
state: &PuzzleApiState,
|
||||
request_context: &RequestContext,
|
||||
session_id: &str,
|
||||
owner_user_id: &str,
|
||||
@@ -184,6 +184,12 @@ pub(crate) fn parse_puzzle_level_records_from_module_json(
|
||||
ui_background_prompt: level.ui_background_prompt,
|
||||
ui_background_image_src: level.ui_background_image_src,
|
||||
ui_background_image_object_key: level.ui_background_image_object_key,
|
||||
level_scene_image_src: level.level_scene_image_src,
|
||||
level_scene_image_object_key: level.level_scene_image_object_key,
|
||||
ui_spritesheet_image_src: level.ui_spritesheet_image_src,
|
||||
ui_spritesheet_image_object_key: level.ui_spritesheet_image_object_key,
|
||||
level_background_image_src: level.level_background_image_src,
|
||||
level_background_image_object_key: level.level_background_image_object_key,
|
||||
background_music: level
|
||||
.background_music
|
||||
.map(map_puzzle_audio_asset_domain_record),
|
||||
@@ -209,7 +215,7 @@ pub(crate) fn parse_puzzle_level_records_from_module_json(
|
||||
}
|
||||
|
||||
pub(crate) async fn get_puzzle_session_for_image_generation(
|
||||
state: &AppState,
|
||||
state: &PuzzleApiState,
|
||||
session_id: String,
|
||||
owner_user_id: String,
|
||||
payload: &ExecutePuzzleAgentActionRequest,
|
||||
@@ -357,6 +363,12 @@ pub(crate) fn serialize_puzzle_levels_response(
|
||||
"ui_background_prompt": level.ui_background_prompt,
|
||||
"ui_background_image_src": level.ui_background_image_src,
|
||||
"ui_background_image_object_key": level.ui_background_image_object_key,
|
||||
"level_scene_image_src": level.level_scene_image_src,
|
||||
"level_scene_image_object_key": level.level_scene_image_object_key,
|
||||
"ui_spritesheet_image_src": level.ui_spritesheet_image_src,
|
||||
"ui_spritesheet_image_object_key": level.ui_spritesheet_image_object_key,
|
||||
"level_background_image_src": level.level_background_image_src,
|
||||
"level_background_image_object_key": level.level_background_image_object_key,
|
||||
"background_music": puzzle_audio_asset_response_module_json(&level.background_music),
|
||||
"candidates": level
|
||||
.candidates
|
||||
@@ -411,6 +423,12 @@ pub(crate) fn normalize_puzzle_levels_json_for_module(
|
||||
"ui_background_prompt": level.ui_background_prompt,
|
||||
"ui_background_image_src": level.ui_background_image_src,
|
||||
"ui_background_image_object_key": level.ui_background_image_object_key,
|
||||
"level_scene_image_src": level.level_scene_image_src,
|
||||
"level_scene_image_object_key": level.level_scene_image_object_key,
|
||||
"ui_spritesheet_image_src": level.ui_spritesheet_image_src,
|
||||
"ui_spritesheet_image_object_key": level.ui_spritesheet_image_object_key,
|
||||
"level_background_image_src": level.level_background_image_src,
|
||||
"level_background_image_object_key": level.level_background_image_object_key,
|
||||
"background_music": puzzle_audio_asset_response_module_json(&level.background_music),
|
||||
"candidates": level
|
||||
.candidates
|
||||
@@ -469,7 +487,7 @@ impl PuzzleLevelNaming {
|
||||
}
|
||||
|
||||
pub(crate) async fn generate_puzzle_first_level_name(
|
||||
state: &AppState,
|
||||
state: &PuzzleApiState,
|
||||
picture_description: &str,
|
||||
) -> PuzzleLevelNaming {
|
||||
if let Some(llm_client) = state.llm_client() {
|
||||
@@ -511,7 +529,7 @@ pub(crate) async fn generate_puzzle_first_level_name(
|
||||
}
|
||||
|
||||
pub(crate) async fn generate_puzzle_first_level_name_from_image(
|
||||
state: &AppState,
|
||||
state: &PuzzleApiState,
|
||||
picture_description: &str,
|
||||
image: &PuzzleDownloadedImage,
|
||||
) -> Option<PuzzleLevelNaming> {
|
||||
@@ -918,6 +936,15 @@ pub(crate) fn build_puzzle_levels_with_primary_update(
|
||||
levels[index].ui_background_image_src = target_level.ui_background_image_src.clone();
|
||||
levels[index].ui_background_image_object_key =
|
||||
target_level.ui_background_image_object_key.clone();
|
||||
levels[index].level_scene_image_src = target_level.level_scene_image_src.clone();
|
||||
levels[index].level_scene_image_object_key =
|
||||
target_level.level_scene_image_object_key.clone();
|
||||
levels[index].ui_spritesheet_image_src = target_level.ui_spritesheet_image_src.clone();
|
||||
levels[index].ui_spritesheet_image_object_key =
|
||||
target_level.ui_spritesheet_image_object_key.clone();
|
||||
levels[index].level_background_image_src = target_level.level_background_image_src.clone();
|
||||
levels[index].level_background_image_object_key =
|
||||
target_level.level_background_image_object_key.clone();
|
||||
if let Some(picture_reference) = picture_reference
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
@@ -1033,42 +1060,31 @@ pub(crate) fn attach_puzzle_level_ui_background(
|
||||
levels[index].ui_background_image_object_key = Some(generated.object_key);
|
||||
}
|
||||
|
||||
pub(crate) async fn generate_puzzle_background_music_required(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
profile_id: &str,
|
||||
title: &str,
|
||||
) -> Result<CreationAudioAsset, AppError> {
|
||||
let normalized_title = title.trim();
|
||||
if normalized_title.is_empty() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": "拼图草稿背景音乐名称为空,无法完成背景音乐生成",
|
||||
})),
|
||||
);
|
||||
}
|
||||
generate_background_music_asset_for_creation(
|
||||
state,
|
||||
owner_user_id,
|
||||
String::new(),
|
||||
normalized_title.to_string(),
|
||||
Some("轻快, 拼图, 循环, instrumental".to_string()),
|
||||
None,
|
||||
GeneratedCreationAudioTarget {
|
||||
entity_kind: PUZZLE_ENTITY_KIND.to_string(),
|
||||
entity_id: profile_id.to_string(),
|
||||
slot: PUZZLE_BACKGROUND_MUSIC_SLOT.to_string(),
|
||||
asset_kind: PUZZLE_BACKGROUND_MUSIC_ASSET_KIND.to_string(),
|
||||
profile_id: Some(profile_id.to_string()),
|
||||
storage_prefix: LegacyAssetPrefix::PuzzleAssets,
|
||||
},
|
||||
)
|
||||
.await
|
||||
pub(crate) fn attach_puzzle_level_asset_bundle(
|
||||
levels: &mut [PuzzleDraftLevelRecord],
|
||||
level_id: &str,
|
||||
generated: GeneratedPuzzleLevelAssetBundle,
|
||||
) {
|
||||
let Some(index) = levels
|
||||
.iter()
|
||||
.position(|level| level.level_id == level_id)
|
||||
.or_else(|| (!levels.is_empty()).then_some(0))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let level = &mut levels[index];
|
||||
level.level_scene_image_src = Some(generated.level_scene.image_src);
|
||||
level.level_scene_image_object_key = Some(generated.level_scene.object_key);
|
||||
level.ui_spritesheet_image_src = Some(generated.ui_spritesheet.image_src);
|
||||
level.ui_spritesheet_image_object_key = Some(generated.ui_spritesheet.object_key);
|
||||
level.level_background_image_src = Some(generated.level_background.image_src.clone());
|
||||
level.level_background_image_object_key = Some(generated.level_background.object_key.clone());
|
||||
level.ui_background_image_src = Some(generated.level_background.image_src);
|
||||
level.ui_background_image_object_key = Some(generated.level_background.object_key);
|
||||
}
|
||||
|
||||
pub(crate) async fn generate_puzzle_initial_ui_background_required(
|
||||
state: &AppState,
|
||||
state: &PuzzleApiState,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
draft: &PuzzleResultDraftRecord,
|
||||
@@ -1086,26 +1102,56 @@ pub(crate) async fn generate_puzzle_initial_ui_background_required(
|
||||
Ok((prompt, generated))
|
||||
}
|
||||
|
||||
pub(crate) async fn generate_puzzle_level_asset_bundle_required(
|
||||
state: &PuzzleApiState,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
target_level: &PuzzleDraftLevelRecord,
|
||||
puzzle_image: &PuzzleDownloadedImage,
|
||||
) -> Result<GeneratedPuzzleLevelAssetBundle, AppError> {
|
||||
generate_puzzle_level_asset_bundle(
|
||||
state,
|
||||
owner_user_id,
|
||||
session_id,
|
||||
target_level.level_name.as_str(),
|
||||
puzzle_image,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_puzzle_initial_level_assets_ready(
|
||||
level: &PuzzleDraftLevelRecord,
|
||||
) -> Result<(), AppError> {
|
||||
let has_ui_background = level
|
||||
.ui_background_image_src
|
||||
let has_level_background = level
|
||||
.level_background_image_src
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
|| level
|
||||
.ui_background_image_object_key
|
||||
.level_background_image_object_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty());
|
||||
if has_ui_background {
|
||||
let has_ui_spritesheet = level
|
||||
.ui_spritesheet_image_src
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
|| level
|
||||
.ui_spritesheet_image_object_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty());
|
||||
if has_level_background && has_ui_spritesheet {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut missing = Vec::new();
|
||||
if !has_ui_background {
|
||||
missing.push("UI背景图");
|
||||
if !has_level_background {
|
||||
missing.push("关卡背景图");
|
||||
}
|
||||
if !has_ui_spritesheet {
|
||||
missing.push("UI spritesheet");
|
||||
}
|
||||
|
||||
Err(
|
||||
@@ -1128,7 +1174,7 @@ pub(crate) fn find_puzzle_level_for_initial_asset_check<'a>(
|
||||
}
|
||||
|
||||
pub(crate) async fn compile_puzzle_draft_with_initial_cover(
|
||||
state: &AppState,
|
||||
state: &PuzzleApiState,
|
||||
session_id: String,
|
||||
owner_user_id: String,
|
||||
prompt_text: Option<&str>,
|
||||
@@ -1159,8 +1205,8 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover(
|
||||
target_level.level_name = generated_naming.level_name.clone();
|
||||
target_level.ui_background_prompt = generated_naming.ui_background_prompt.clone();
|
||||
let mut generated_metadata = generated_naming;
|
||||
// 点击生成草稿时一次性完成首图生成、UI 背景生成与正式图选定,前端只展示进度,不再承担业务编排。
|
||||
let candidates_future = generate_puzzle_image_candidates(
|
||||
// 点击生成草稿时一次性完成拼图主图和运行态资产包,前端只展示进度,不再承担业务编排。
|
||||
let mut candidates = generate_puzzle_image_candidates(
|
||||
state,
|
||||
owner_user_id.as_str(),
|
||||
&compiled_session.session_id,
|
||||
@@ -1171,18 +1217,8 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover(
|
||||
image_model,
|
||||
1,
|
||||
target_level.candidates.len(),
|
||||
);
|
||||
let ui_background_future = generate_puzzle_initial_ui_background_required(
|
||||
state,
|
||||
owner_user_id.as_str(),
|
||||
compiled_session.session_id.as_str(),
|
||||
&draft,
|
||||
&target_level,
|
||||
);
|
||||
// 中文注释:命名稳定后并行发起首关图与 UI 背景,避免两次外部生图串行等待。
|
||||
let (candidates_result, ui_background_result) =
|
||||
tokio::join!(candidates_future, ui_background_future);
|
||||
let mut candidates = candidates_result?;
|
||||
)
|
||||
.await?;
|
||||
if let Some(first_candidate) = candidates.first()
|
||||
&& let Some(refined_naming) = generate_puzzle_first_level_name_from_image(
|
||||
state,
|
||||
@@ -1218,19 +1254,25 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover(
|
||||
"message": "拼图候选图生成结果为空",
|
||||
}))
|
||||
})?;
|
||||
// 中文注释:拼图草稿音频生成临时关闭,首版生成只补首图与 UI 背景。
|
||||
let (ui_prompt, ui_background) = ui_background_result?;
|
||||
attach_puzzle_level_ui_background(
|
||||
&mut updated_levels,
|
||||
target_level.level_id.as_str(),
|
||||
ui_prompt,
|
||||
ui_background,
|
||||
);
|
||||
// 中文注释:拼图草稿音频生成临时关闭,首版生成只补首图、关卡背景和 UI spritesheet。
|
||||
if let Some(selected_candidate) = candidates
|
||||
.iter()
|
||||
.find(|candidate| candidate.record.selected)
|
||||
.or_else(|| candidates.first())
|
||||
{
|
||||
let asset_bundle = generate_puzzle_level_asset_bundle_required(
|
||||
state,
|
||||
owner_user_id.as_str(),
|
||||
compiled_session.session_id.as_str(),
|
||||
&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(),
|
||||
@@ -1398,7 +1440,7 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover(
|
||||
}
|
||||
|
||||
pub(crate) async fn compile_puzzle_draft_with_uploaded_cover(
|
||||
state: &AppState,
|
||||
state: &PuzzleApiState,
|
||||
session_id: String,
|
||||
owner_user_id: String,
|
||||
prompt_text: Option<&str>,
|
||||
@@ -1417,7 +1459,12 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover(
|
||||
})?;
|
||||
let http_client = reqwest::Client::new();
|
||||
let uploaded_downloaded_image =
|
||||
resolve_puzzle_reference_image_as_data_url(state, &http_client, uploaded_image_src)
|
||||
resolve_puzzle_reference_image(
|
||||
state,
|
||||
&http_client,
|
||||
uploaded_image_src,
|
||||
Some(owner_user_id.as_str()),
|
||||
)
|
||||
.await
|
||||
.map(PuzzleDownloadedImage::from_resolved_reference_image)
|
||||
.map_err(|error| {
|
||||
@@ -1425,7 +1472,7 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"field": "referenceImageSrc",
|
||||
"message": "关闭 AI 重绘时上传图必须是图片 Data URL 或历史生成图片路径。",
|
||||
"message": "关闭 AI 重绘时上传图必须是拼图图片 assetObjectId、图片 Data URL 或历史生成图片路径。",
|
||||
}))
|
||||
} else {
|
||||
error
|
||||
@@ -1484,7 +1531,7 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover(
|
||||
let generated_level_name = target_level.level_name.clone();
|
||||
let mut updated_levels =
|
||||
build_puzzle_levels_with_primary_update(&draft, &target_level, reference_image_src);
|
||||
let persist_upload_future = persist_puzzle_generated_asset(
|
||||
let persisted_upload = persist_puzzle_generated_asset(
|
||||
state,
|
||||
owner_user_id.as_str(),
|
||||
&compiled_session.session_id,
|
||||
@@ -1493,24 +1540,20 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover(
|
||||
"uploaded-direct",
|
||||
uploaded_downloaded_image.clone(),
|
||||
current_utc_micros(),
|
||||
);
|
||||
let ui_background_future = generate_puzzle_initial_ui_background_required(
|
||||
)
|
||||
.await?;
|
||||
let asset_bundle = generate_puzzle_level_asset_bundle_required(
|
||||
state,
|
||||
owner_user_id.as_str(),
|
||||
compiled_session.session_id.as_str(),
|
||||
&draft,
|
||||
&target_level,
|
||||
);
|
||||
// 中文注释:直用上传图时并行完成上传图持久化与 UI 背景生成;音频生成入口临时关闭。
|
||||
let (persisted_upload_result, ui_background_result) =
|
||||
tokio::join!(persist_upload_future, ui_background_future);
|
||||
let persisted_upload = persisted_upload_result?;
|
||||
let (ui_prompt, ui_background) = ui_background_result?;
|
||||
attach_puzzle_level_ui_background(
|
||||
&uploaded_downloaded_image,
|
||||
)
|
||||
.await?;
|
||||
attach_puzzle_level_asset_bundle(
|
||||
&mut updated_levels,
|
||||
target_level.level_id.as_str(),
|
||||
ui_prompt,
|
||||
ui_background,
|
||||
asset_bundle,
|
||||
);
|
||||
attach_selected_puzzle_candidate_to_levels(
|
||||
&mut updated_levels,
|
||||
|
||||
@@ -12,19 +12,77 @@ pub(crate) fn map_puzzle_generation_endpoint_error(error: AppError) -> AppError
|
||||
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) 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: &AppState,
|
||||
state: &PuzzleApiState,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
level_name: &str,
|
||||
prompt: &str,
|
||||
reference_image_src: Option<&str>,
|
||||
use_reference_image_edit: bool,
|
||||
use_reference_image_generation: bool,
|
||||
image_model: Option<&str>,
|
||||
candidate_count: u32,
|
||||
candidate_start_index: usize,
|
||||
@@ -34,11 +92,13 @@ pub(crate) async fn generate_puzzle_image_candidates(
|
||||
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 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_edit,
|
||||
should_use_reference_image_generation,
|
||||
);
|
||||
tracing::info!(
|
||||
provider = resolved_model.provider_name(),
|
||||
@@ -48,18 +108,19 @@ pub(crate) async fn generate_puzzle_image_candidates(
|
||||
prompt_chars = prompt.chars().count(),
|
||||
actual_prompt_chars = actual_prompt.chars().count(),
|
||||
has_reference_image,
|
||||
use_reference_image_edit = should_use_reference_image_edit,
|
||||
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_edit)
|
||||
.filter(|_| should_use_reference_image_generation)
|
||||
{
|
||||
Some(source) => {
|
||||
let resolved =
|
||||
resolve_puzzle_reference_image_as_data_url(state, &http_client, source).await?;
|
||||
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(),
|
||||
@@ -74,14 +135,14 @@ pub(crate) async fn generate_puzzle_image_candidates(
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
if !should_use_reference_image_edit {
|
||||
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_edit = should_use_reference_image_edit,
|
||||
use_reference_image_generation = should_use_reference_image_generation,
|
||||
elapsed_ms = reference_image_started_at.elapsed().as_millis() as u64,
|
||||
"拼图参考图解析跳过"
|
||||
);
|
||||
@@ -90,7 +151,7 @@ pub(crate) async fn generate_puzzle_image_candidates(
|
||||
// 中文注释:拼图作品资产统一按 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 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",
|
||||
@@ -98,43 +159,17 @@ pub(crate) async fn generate_puzzle_image_candidates(
|
||||
"message": "AI 重绘需要提供参考图。",
|
||||
}))
|
||||
})?;
|
||||
let edit_result = create_puzzle_vector_engine_image_edit(
|
||||
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,
|
||||
reference_image,
|
||||
Some(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),
|
||||
}
|
||||
.await
|
||||
} else {
|
||||
create_puzzle_vector_engine_image_generation(
|
||||
&http_client,
|
||||
@@ -219,13 +254,13 @@ pub(crate) async fn generate_puzzle_image_candidates(
|
||||
}
|
||||
|
||||
pub(crate) async fn generate_puzzle_ui_background_image(
|
||||
state: &AppState,
|
||||
state: &PuzzleApiState,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
level_name: &str,
|
||||
prompt: &str,
|
||||
) -> Result<GeneratedPuzzleUiBackgroundResponse, AppError> {
|
||||
let settings = require_openai_image_settings(state)?;
|
||||
let settings = require_openai_image_settings(state.root_state())?;
|
||||
let http_client = build_openai_image_http_client(&settings)?;
|
||||
let generated = create_openai_image_generation(
|
||||
&http_client,
|
||||
@@ -255,6 +290,175 @@ pub(crate) async fn generate_puzzle_ui_background_image(
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn generate_puzzle_level_asset_bundle(
|
||||
state: &PuzzleApiState,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
level_name: &str,
|
||||
puzzle_image: &PuzzleDownloadedImage,
|
||||
) -> Result<GeneratedPuzzleLevelAssetBundle, AppError> {
|
||||
let settings = require_puzzle_vector_engine_settings(state)?;
|
||||
let http_client = build_puzzle_image_http_client(state, PuzzleImageModel::GptImage2)?;
|
||||
let puzzle_reference = build_puzzle_downloaded_image_reference(puzzle_image);
|
||||
let scene_generated = 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)?;
|
||||
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_future = 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,
|
||||
);
|
||||
let spritesheet_future = 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",
|
||||
);
|
||||
let background_future = 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",
|
||||
);
|
||||
let (level_scene, ui_spritesheet, level_background) =
|
||||
tokio::join!(scene_persist_future, spritesheet_future, background_future);
|
||||
|
||||
Ok(GeneratedPuzzleLevelAssetBundle {
|
||||
level_scene: level_scene?,
|
||||
ui_spritesheet: ui_spritesheet?,
|
||||
level_background: 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 generated = 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)?;
|
||||
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
|
||||
};
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -262,3 +466,45 @@ pub(crate) fn build_puzzle_ui_background_request_prompt_for_test(
|
||||
) -> 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)),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::*;
|
||||
|
||||
pub async fn create_puzzle_agent_session(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<CreatePuzzleAgentSessionRequest>, JsonRejection>,
|
||||
@@ -46,7 +46,7 @@ pub async fn create_puzzle_agent_session(
|
||||
}
|
||||
|
||||
pub async fn generate_puzzle_onboarding_work(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
payload: Result<Json<PuzzleOnboardingGenerateRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
@@ -113,6 +113,12 @@ pub async fn generate_puzzle_onboarding_work(
|
||||
ui_background_prompt: naming.ui_background_prompt.clone(),
|
||||
ui_background_image_src: None,
|
||||
ui_background_image_object_key: None,
|
||||
level_scene_image_src: None,
|
||||
level_scene_image_object_key: None,
|
||||
ui_spritesheet_image_src: None,
|
||||
ui_spritesheet_image_object_key: None,
|
||||
level_background_image_src: None,
|
||||
level_background_image_object_key: None,
|
||||
background_music: None,
|
||||
candidates,
|
||||
selected_candidate_id: Some(selected.candidate_id.clone()),
|
||||
@@ -161,7 +167,7 @@ pub async fn generate_puzzle_onboarding_work(
|
||||
}
|
||||
|
||||
pub async fn save_puzzle_onboarding_work(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<PuzzleOnboardingSaveRequest>, JsonRejection>,
|
||||
@@ -270,7 +276,7 @@ pub async fn save_puzzle_onboarding_work(
|
||||
}
|
||||
|
||||
pub async fn get_puzzle_agent_session(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
AxumPath(session_id): AxumPath<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
@@ -303,7 +309,7 @@ pub async fn get_puzzle_agent_session(
|
||||
}
|
||||
|
||||
pub async fn submit_puzzle_agent_message(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
AxumPath(session_id): AxumPath<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
@@ -359,7 +365,7 @@ pub async fn submit_puzzle_agent_message(
|
||||
llm_client: state.llm_client(),
|
||||
session: &submitted_session,
|
||||
quick_fill_requested: payload.quick_fill_requested.unwrap_or(false),
|
||||
enable_web_search: state.config.creation_agent_llm_web_search_enabled,
|
||||
enable_web_search: state.creation_agent_llm_web_search_enabled(),
|
||||
},
|
||||
|_| {},
|
||||
)
|
||||
@@ -401,7 +407,7 @@ pub async fn submit_puzzle_agent_message(
|
||||
}
|
||||
|
||||
pub async fn stream_puzzle_agent_message(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
AxumPath(session_id): AxumPath<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
@@ -464,7 +470,7 @@ pub async fn stream_puzzle_agent_message(
|
||||
llm_client: state.llm_client(),
|
||||
session: &session,
|
||||
quick_fill_requested,
|
||||
enable_web_search: state.config.creation_agent_llm_web_search_enabled,
|
||||
enable_web_search: state.creation_agent_llm_web_search_enabled(),
|
||||
},
|
||||
move |text| {
|
||||
let _ = reply_tx.send(text.to_string());
|
||||
@@ -554,7 +560,7 @@ pub async fn stream_puzzle_agent_message(
|
||||
}
|
||||
|
||||
pub async fn execute_puzzle_agent_action(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
AxumPath(session_id): AxumPath<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
@@ -595,6 +601,8 @@ pub async fn execute_puzzle_agent_action(
|
||||
has_reference_image = has_puzzle_reference_images(
|
||||
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(),
|
||||
),
|
||||
"拼图 Agent action 开始执行"
|
||||
);
|
||||
@@ -604,6 +612,8 @@ pub async fn execute_puzzle_agent_action(
|
||||
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);
|
||||
let prompt_text = payload
|
||||
@@ -627,7 +637,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
};
|
||||
let session = if ai_redraw {
|
||||
execute_billable_asset_operation_with_cost(
|
||||
&state,
|
||||
state.root_state(),
|
||||
&owner_user_id,
|
||||
"puzzle_initial_image",
|
||||
&billing_asset_id,
|
||||
@@ -652,7 +662,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
compile_session_id.clone(),
|
||||
owner_user_id.clone(),
|
||||
prompt_text,
|
||||
payload.reference_image_src.as_deref(),
|
||||
primary_reference_image_src,
|
||||
now,
|
||||
)
|
||||
.await
|
||||
@@ -737,7 +747,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
}))
|
||||
});
|
||||
let session = execute_billable_asset_operation_with_cost(
|
||||
&state,
|
||||
state.root_state(),
|
||||
&owner_user_id,
|
||||
"puzzle_generated_image",
|
||||
&billing_asset_id,
|
||||
@@ -768,6 +778,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
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
|
||||
@@ -787,26 +798,46 @@ pub async fn execute_puzzle_agent_action(
|
||||
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_count = 1;
|
||||
let candidate_start_index = target_level.candidates.len();
|
||||
let candidates = generate_puzzle_image_candidates(
|
||||
&state,
|
||||
owner_user_id.as_str(),
|
||||
&session.session_id,
|
||||
&target_level.level_name,
|
||||
&prompt,
|
||||
let ai_redraw = payload.ai_redraw.unwrap_or(true);
|
||||
let mut candidates = if should_use_uploaded_puzzle_image_directly(
|
||||
primary_reference_image_src,
|
||||
payload.ai_redraw.unwrap_or(true),
|
||||
payload.image_model.as_deref(),
|
||||
candidate_count,
|
||||
candidate_start_index,
|
||||
)
|
||||
.await
|
||||
.map_err(map_puzzle_generation_endpoint_error)?;
|
||||
ai_redraw,
|
||||
) {
|
||||
vec![
|
||||
create_uploaded_puzzle_image_candidate(
|
||||
&state,
|
||||
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 {
|
||||
generate_puzzle_image_candidates(
|
||||
&state,
|
||||
owner_user_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!({
|
||||
@@ -831,14 +862,44 @@ pub async fn execute_puzzle_agent_action(
|
||||
generated_naming = Some(refined_naming);
|
||||
}
|
||||
let generated_level_name = target_level.level_name.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,
|
||||
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(
|
||||
&build_puzzle_levels_with_primary_update(
|
||||
&draft,
|
||||
&target_level,
|
||||
primary_reference_image_src,
|
||||
),
|
||||
)?);
|
||||
Some(serialize_puzzle_level_records_for_module(&updated_levels)?);
|
||||
let candidates_json = serde_json::to_string(
|
||||
&candidates
|
||||
.iter()
|
||||
@@ -890,7 +951,11 @@ pub async fn execute_puzzle_agent_action(
|
||||
};
|
||||
let mut fallback_session =
|
||||
apply_generated_puzzle_candidates_to_session_snapshot(
|
||||
fallback_session,
|
||||
apply_generated_puzzle_levels_to_session_snapshot(
|
||||
fallback_session,
|
||||
updated_levels,
|
||||
now,
|
||||
),
|
||||
target_level.level_id.as_str(),
|
||||
candidates.into_records(),
|
||||
primary_reference_image_src,
|
||||
@@ -942,7 +1007,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
}))
|
||||
});
|
||||
let session = execute_billable_asset_operation_with_cost(
|
||||
&state,
|
||||
state.root_state(),
|
||||
&owner_user_id,
|
||||
"puzzle_ui_background_image",
|
||||
&billing_asset_id,
|
||||
@@ -1147,7 +1212,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
let (work_id, profile_id) = build_stable_puzzle_work_ids(&session_id);
|
||||
let author_display_name = resolve_author_display_name(&state, &authenticated);
|
||||
let profile = execute_billable_asset_operation(
|
||||
&state,
|
||||
state.root_state(),
|
||||
&owner_user_id,
|
||||
"puzzle_publish_work",
|
||||
&work_id,
|
||||
@@ -1235,7 +1300,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
}
|
||||
|
||||
pub async fn get_puzzle_works(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
@@ -1263,7 +1328,7 @@ pub async fn get_puzzle_works(
|
||||
}
|
||||
|
||||
pub async fn get_puzzle_work_detail(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
AxumPath(profile_id): AxumPath<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
@@ -1296,7 +1361,7 @@ pub async fn get_puzzle_work_detail(
|
||||
}
|
||||
|
||||
pub async fn put_puzzle_work(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
AxumPath(profile_id): AxumPath<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
@@ -1355,7 +1420,7 @@ pub async fn put_puzzle_work(
|
||||
}
|
||||
|
||||
pub async fn delete_puzzle_work(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
AxumPath(profile_id): AxumPath<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
@@ -1391,7 +1456,7 @@ pub async fn delete_puzzle_work(
|
||||
}
|
||||
|
||||
pub async fn claim_puzzle_work_point_incentive(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
AxumPath(profile_id): AxumPath<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
@@ -1428,7 +1493,7 @@ pub async fn claim_puzzle_work_point_incentive(
|
||||
}
|
||||
|
||||
pub async fn list_puzzle_gallery(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
) -> Result<Response, Response> {
|
||||
if let Some(response) = state.puzzle_gallery_cache().read_fresh_response().await {
|
||||
@@ -1487,7 +1552,7 @@ pub async fn list_puzzle_gallery(
|
||||
}
|
||||
|
||||
pub async fn get_puzzle_gallery_detail(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
AxumPath(profile_id): AxumPath<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
@@ -1519,7 +1584,7 @@ pub async fn get_puzzle_gallery_detail(
|
||||
}
|
||||
|
||||
pub async fn record_puzzle_gallery_like(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
AxumPath(profile_id): AxumPath<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
@@ -1556,7 +1621,7 @@ pub async fn record_puzzle_gallery_like(
|
||||
}
|
||||
|
||||
pub async fn remix_puzzle_gallery_work(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
AxumPath(profile_id): AxumPath<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
@@ -1599,7 +1664,7 @@ pub async fn remix_puzzle_gallery_work(
|
||||
}
|
||||
|
||||
pub async fn start_puzzle_run(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<StartPuzzleRunRequest>, JsonRejection>,
|
||||
@@ -1639,7 +1704,7 @@ pub async fn start_puzzle_run(
|
||||
)
|
||||
})?;
|
||||
|
||||
record_work_play_start_after_success(
|
||||
record_puzzle_work_play_start_after_success(
|
||||
&state,
|
||||
&request_context,
|
||||
WorkPlayTrackingDraft::new(
|
||||
@@ -1665,7 +1730,7 @@ pub async fn start_puzzle_run(
|
||||
}
|
||||
|
||||
pub async fn get_puzzle_run(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
AxumPath(run_id): AxumPath<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
@@ -1693,7 +1758,7 @@ pub async fn get_puzzle_run(
|
||||
}
|
||||
|
||||
pub async fn swap_puzzle_pieces(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
AxumPath(run_id): AxumPath<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
@@ -1750,7 +1815,7 @@ pub async fn swap_puzzle_pieces(
|
||||
}
|
||||
|
||||
pub async fn drag_puzzle_piece_or_group(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
AxumPath(run_id): AxumPath<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
@@ -1802,7 +1867,7 @@ pub async fn drag_puzzle_piece_or_group(
|
||||
}
|
||||
|
||||
pub async fn advance_puzzle_next_level(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
AxumPath(run_id): AxumPath<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
@@ -1854,7 +1919,7 @@ pub async fn advance_puzzle_next_level(
|
||||
}
|
||||
|
||||
pub async fn update_puzzle_run_pause(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
AxumPath(run_id): AxumPath<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
@@ -1898,7 +1963,7 @@ pub async fn update_puzzle_run_pause(
|
||||
}
|
||||
|
||||
pub async fn use_puzzle_runtime_prop(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
AxumPath(run_id): AxumPath<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
@@ -1944,7 +2009,7 @@ pub async fn use_puzzle_runtime_prop(
|
||||
let fallback_run_id = run_id.clone();
|
||||
let fallback_owner_user_id = owner_user_id.clone();
|
||||
let run_result = execute_billable_asset_operation(
|
||||
&state,
|
||||
state.root_state(),
|
||||
&owner_user_id,
|
||||
billing_asset_kind,
|
||||
billing_asset_id.as_str(),
|
||||
@@ -1996,7 +2061,7 @@ pub async fn use_puzzle_runtime_prop(
|
||||
}
|
||||
|
||||
pub async fn submit_puzzle_leaderboard(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
AxumPath(run_id): AxumPath<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
|
||||
@@ -105,6 +105,12 @@ pub(super) fn map_puzzle_draft_level_response(
|
||||
ui_background_prompt: level.ui_background_prompt,
|
||||
ui_background_image_src: level.ui_background_image_src,
|
||||
ui_background_image_object_key: level.ui_background_image_object_key,
|
||||
level_scene_image_src: level.level_scene_image_src,
|
||||
level_scene_image_object_key: level.level_scene_image_object_key,
|
||||
ui_spritesheet_image_src: level.ui_spritesheet_image_src,
|
||||
ui_spritesheet_image_object_key: level.ui_spritesheet_image_object_key,
|
||||
level_background_image_src: level.level_background_image_src,
|
||||
level_background_image_object_key: level.level_background_image_object_key,
|
||||
background_music: level
|
||||
.background_music
|
||||
.map(map_puzzle_audio_asset_record_response),
|
||||
@@ -343,11 +349,11 @@ fn has_puzzle_level_image(level: &PuzzleDraftLevelRecord) -> bool {
|
||||
}
|
||||
|
||||
pub(super) fn map_puzzle_work_summary_response(
|
||||
state: &AppState,
|
||||
state: &PuzzleApiState,
|
||||
item: PuzzleWorkProfileRecord,
|
||||
) -> PuzzleWorkSummaryResponse {
|
||||
let generation_status = resolve_puzzle_work_generation_status(&item);
|
||||
let author = resolve_work_author_by_user_id(
|
||||
let author = resolve_puzzle_work_author_by_user_id(
|
||||
state,
|
||||
&item.owner_user_id,
|
||||
Some(&item.author_display_name),
|
||||
@@ -391,10 +397,10 @@ pub(super) fn map_puzzle_work_summary_response(
|
||||
}
|
||||
|
||||
pub(super) fn map_puzzle_gallery_card_response(
|
||||
state: &AppState,
|
||||
state: &PuzzleApiState,
|
||||
item: PuzzleGalleryCardRecord,
|
||||
) -> PuzzleWorkSummaryResponse {
|
||||
let author = resolve_work_author_by_user_id(
|
||||
let author = resolve_puzzle_work_author_by_user_id(
|
||||
state,
|
||||
&item.owner_user_id,
|
||||
Some(&item.author_display_name),
|
||||
@@ -434,7 +440,7 @@ pub(super) fn map_puzzle_gallery_card_response(
|
||||
}
|
||||
|
||||
pub(super) fn map_puzzle_work_profile_response(
|
||||
state: &AppState,
|
||||
state: &PuzzleApiState,
|
||||
item: PuzzleWorkProfileRecord,
|
||||
) -> PuzzleWorkProfileResponse {
|
||||
let mut summary = map_puzzle_work_summary_response(state, item.clone());
|
||||
@@ -491,7 +497,7 @@ pub(super) fn map_puzzle_recommended_next_work_response(
|
||||
}
|
||||
|
||||
pub(super) async fn enrich_puzzle_run_author_name(
|
||||
state: &AppState,
|
||||
state: &PuzzleApiState,
|
||||
mut run: PuzzleRunRecord,
|
||||
) -> PuzzleRunRecord {
|
||||
if let Some(level) = run.current_level.as_mut() {
|
||||
@@ -500,7 +506,7 @@ pub(super) async fn enrich_puzzle_run_author_name(
|
||||
.get_puzzle_gallery_detail(level.profile_id.clone())
|
||||
.await
|
||||
{
|
||||
level.author_display_name = resolve_work_author_by_user_id(
|
||||
level.author_display_name = resolve_puzzle_work_author_by_user_id(
|
||||
state,
|
||||
&profile.owner_user_id,
|
||||
Some(&profile.author_display_name),
|
||||
@@ -541,6 +547,10 @@ pub(super) fn map_puzzle_runtime_level_response(
|
||||
cover_image_src: level.cover_image_src,
|
||||
ui_background_image_src: level.ui_background_image_src,
|
||||
ui_background_image_object_key: level.ui_background_image_object_key,
|
||||
level_background_image_src: level.level_background_image_src,
|
||||
level_background_image_object_key: level.level_background_image_object_key,
|
||||
ui_spritesheet_image_src: level.ui_spritesheet_image_src,
|
||||
ui_spritesheet_image_object_key: level.ui_spritesheet_image_object_key,
|
||||
background_music: level
|
||||
.background_music
|
||||
.map(map_puzzle_audio_asset_record_response),
|
||||
@@ -632,7 +642,7 @@ pub(super) fn map_puzzle_board_response(
|
||||
}
|
||||
|
||||
pub(super) fn resolve_author_display_name(
|
||||
state: &AppState,
|
||||
state: &PuzzleApiState,
|
||||
authenticated: &AuthenticatedAccessToken,
|
||||
) -> String {
|
||||
state
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) async fn generate_puzzle_work_tags(
|
||||
state: &AppState,
|
||||
state: &PuzzleApiState,
|
||||
work_title: &str,
|
||||
work_description: &str,
|
||||
) -> Vec<String> {
|
||||
@@ -143,7 +143,7 @@ pub(super) fn build_fallback_puzzle_tags(
|
||||
}
|
||||
|
||||
pub(super) async fn save_generated_puzzle_tags_to_session(
|
||||
state: &AppState,
|
||||
state: &PuzzleApiState,
|
||||
session_id: &str,
|
||||
owner_user_id: &str,
|
||||
payload: &ExecutePuzzleAgentActionRequest,
|
||||
@@ -278,6 +278,12 @@ pub(super) fn serialize_puzzle_level_records_for_module(
|
||||
"ui_background_prompt": level.ui_background_prompt,
|
||||
"ui_background_image_src": level.ui_background_image_src,
|
||||
"ui_background_image_object_key": level.ui_background_image_object_key,
|
||||
"level_scene_image_src": level.level_scene_image_src,
|
||||
"level_scene_image_object_key": level.level_scene_image_object_key,
|
||||
"ui_spritesheet_image_src": level.ui_spritesheet_image_src,
|
||||
"ui_spritesheet_image_object_key": level.ui_spritesheet_image_object_key,
|
||||
"level_background_image_src": level.level_background_image_src,
|
||||
"level_background_image_object_key": level.level_background_image_object_key,
|
||||
"background_music": puzzle_audio_asset_record_module_json(&level.background_music),
|
||||
"candidates": level
|
||||
.candidates
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use crate::openai_image_generation::GPT_IMAGE_2_MODEL;
|
||||
|
||||
#[test]
|
||||
fn puzzle_generated_image_size_is_square_1_1() {
|
||||
@@ -7,7 +8,7 @@ fn puzzle_generated_image_size_is_square_1_1() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_vector_engine_request_uses_gpt_image_2_all_and_reference_images() {
|
||||
fn puzzle_vector_engine_create_request_uses_gpt_image_2_without_reference_images() {
|
||||
let body = build_puzzle_vector_engine_image_request_body(
|
||||
PuzzleImageModel::Gemini31FlashPreview,
|
||||
"一只猫在雨夜灯牌下回头。",
|
||||
@@ -17,7 +18,7 @@ fn puzzle_vector_engine_request_uses_gpt_image_2_all_and_reference_images() {
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(body["model"], VECTOR_ENGINE_GPT_IMAGE_2_MODEL);
|
||||
assert_eq!(body["model"], GPT_IMAGE_2_MODEL);
|
||||
assert_eq!(body["size"], PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE);
|
||||
assert_eq!(body["n"], 1);
|
||||
assert!(body.get("official_fallback").is_none());
|
||||
@@ -31,7 +32,7 @@ fn puzzle_vector_engine_request_uses_gpt_image_2_all_and_reference_images() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_vector_engine_generation_fallback_includes_reference_image() {
|
||||
fn puzzle_vector_engine_create_request_never_embeds_reference_image() {
|
||||
let image = image::DynamicImage::ImageRgb8(image::RgbImage::new(4, 4));
|
||||
let mut cursor = std::io::Cursor::new(Vec::new());
|
||||
image
|
||||
@@ -41,6 +42,7 @@ fn puzzle_vector_engine_generation_fallback_includes_reference_image() {
|
||||
mime_type: "image/png".to_string(),
|
||||
bytes_len: cursor.get_ref().len(),
|
||||
bytes: cursor.into_inner(),
|
||||
signed_read_url: None,
|
||||
};
|
||||
|
||||
let body = build_puzzle_vector_engine_image_request_body(
|
||||
@@ -52,20 +54,185 @@ fn puzzle_vector_engine_generation_fallback_includes_reference_image() {
|
||||
Some(&reference_image),
|
||||
);
|
||||
|
||||
let images = body["image"]
|
||||
.as_array()
|
||||
.expect("fallback generation should include reference image array");
|
||||
assert_eq!(images.len(), 1);
|
||||
assert!(body.get("image").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_level_scene_spritesheet_and_background_requests_use_references() {
|
||||
let image = image::DynamicImage::ImageRgb8(image::RgbImage::new(4, 4));
|
||||
let mut cursor = std::io::Cursor::new(Vec::new());
|
||||
image
|
||||
.write_to(&mut cursor, ImageFormat::Png)
|
||||
.expect("test image should encode");
|
||||
let reference_image = PuzzleDownloadedImage {
|
||||
extension: "png".to_string(),
|
||||
mime_type: "image/png".to_string(),
|
||||
bytes: cursor.into_inner(),
|
||||
};
|
||||
|
||||
let scene_body = build_puzzle_level_scene_image_request_body_for_test(&reference_image)
|
||||
.expect("scene request should build");
|
||||
assert_eq!(scene_body["model"], GPT_IMAGE_2_MODEL);
|
||||
assert_eq!(scene_body["size"], "1024x1536");
|
||||
assert!(scene_body.get("image").is_none());
|
||||
assert!(
|
||||
images[0]
|
||||
scene_body["prompt"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.starts_with("data:image/png;base64,")
|
||||
.contains("参考图作为拼图画面")
|
||||
);
|
||||
assert!(
|
||||
scene_body["prompt"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("道具按钮上不要显示次数标注")
|
||||
);
|
||||
assert!(
|
||||
scene_body["prompt"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("返回按钮和设置按钮旁禁止标注文字")
|
||||
);
|
||||
|
||||
let spritesheet_body = build_puzzle_ui_spritesheet_request_body_for_test(&reference_image)
|
||||
.expect("spritesheet request should build");
|
||||
assert_eq!(spritesheet_body["model"], GPT_IMAGE_2_MODEL);
|
||||
assert_eq!(spritesheet_body["size"], "1024x1024");
|
||||
assert!(
|
||||
spritesheet_body["prompt"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("返回按钮、设置按钮、下一关按钮、提示按钮、原图按钮、冻结按钮")
|
||||
);
|
||||
assert!(
|
||||
spritesheet_body["prompt"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("纯绿色绿幕背景")
|
||||
);
|
||||
assert!(
|
||||
spritesheet_body["prompt"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("绿幕扣成透明")
|
||||
);
|
||||
assert!(
|
||||
spritesheet_body["prompt"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("自动边界检测")
|
||||
);
|
||||
assert!(
|
||||
spritesheet_body["prompt"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("按钮素材内必须保留对应中文文字")
|
||||
);
|
||||
assert!(
|
||||
spritesheet_body["prompt"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("不要额外画白色外圈")
|
||||
);
|
||||
assert!(
|
||||
spritesheet_body["prompt"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("白底圆环")
|
||||
);
|
||||
assert!(
|
||||
!spritesheet_body["prompt"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("禁止文字")
|
||||
);
|
||||
|
||||
let background_body = build_puzzle_level_background_request_body_for_test(&reference_image)
|
||||
.expect("background request should build");
|
||||
assert_eq!(background_body["model"], GPT_IMAGE_2_MODEL);
|
||||
assert_eq!(background_body["size"], "1024x1536");
|
||||
assert!(
|
||||
background_body["prompt"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("移除参考图中所有UI元素")
|
||||
);
|
||||
assert!(
|
||||
background_body["prompt"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("禁止在背景中出现人像或和拼图画面中主体一致的内容")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_vector_engine_edit_url_uses_images_edits_endpoint() {
|
||||
fn puzzle_ui_spritesheet_postprocess_turns_green_screen_transparent() {
|
||||
let mut source = image::RgbaImage::from_pixel(8, 8, image::Rgba([0, 255, 0, 255]));
|
||||
for y in 2..6 {
|
||||
for x in 2..6 {
|
||||
source.put_pixel(x, y, image::Rgba([190, 78, 42, 255]));
|
||||
}
|
||||
}
|
||||
let mut cursor = std::io::Cursor::new(Vec::new());
|
||||
image::DynamicImage::ImageRgba8(source)
|
||||
.write_to(&mut cursor, ImageFormat::Png)
|
||||
.expect("test image should encode");
|
||||
|
||||
let processed = make_puzzle_ui_spritesheet_image_transparent_for_test(PuzzleDownloadedImage {
|
||||
extension: "png".to_string(),
|
||||
mime_type: "image/png".to_string(),
|
||||
bytes: cursor.into_inner(),
|
||||
})
|
||||
.expect("green screen postprocess should succeed");
|
||||
|
||||
assert_eq!(processed.extension, "png");
|
||||
assert_eq!(processed.mime_type, "image/png");
|
||||
let decoded = image::load_from_memory(processed.bytes.as_slice())
|
||||
.expect("processed image should decode")
|
||||
.to_rgba8();
|
||||
assert_eq!(decoded.get_pixel(0, 0).0[3], 0);
|
||||
assert_eq!(decoded.get_pixel(3, 3).0[3], 255);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_vector_engine_create_request_never_embeds_signed_reference_url() {
|
||||
let reference_image = PuzzleResolvedReferenceImage {
|
||||
mime_type: "image/png".to_string(),
|
||||
bytes_len: 4,
|
||||
bytes: b"test".to_vec(),
|
||||
signed_read_url: Some(
|
||||
"https://oss.example/generated-puzzle-assets/reference.png?x-oss-signature=abc"
|
||||
.to_string(),
|
||||
),
|
||||
};
|
||||
|
||||
let body = build_puzzle_vector_engine_image_request_body(
|
||||
PuzzleImageModel::GptImage2,
|
||||
"参考图里的小猫做成拼图主图。",
|
||||
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
|
||||
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
|
||||
1,
|
||||
Some(&reference_image),
|
||||
);
|
||||
|
||||
assert!(body.get("image").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_vector_engine_generation_url_normalizes_base_url() {
|
||||
let settings = PuzzleVectorEngineSettings {
|
||||
base_url: "https://vector.example/v1".to_string(),
|
||||
api_key: "test-key".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
puzzle_vector_engine_images_generation_url(&settings),
|
||||
"https://vector.example/v1/images/generations"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_vector_engine_edit_url_normalizes_base_url() {
|
||||
let settings = PuzzleVectorEngineSettings {
|
||||
base_url: "https://vector.example/v1".to_string(),
|
||||
api_key: "test-key".to_string(),
|
||||
@@ -107,18 +274,31 @@ fn puzzle_vector_engine_prompt_keeps_text_only_prompt_unchanged() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_reference_image_edit_requires_ai_redraw() {
|
||||
assert!(!should_use_puzzle_reference_image_edit(None, true));
|
||||
assert!(!should_use_puzzle_reference_image_edit(
|
||||
fn puzzle_reference_image_generation_requires_ai_redraw() {
|
||||
assert!(!should_use_puzzle_reference_image_generation(None, true));
|
||||
assert!(!should_use_puzzle_reference_image_generation(
|
||||
Some("data:image/png;base64,abcd"),
|
||||
false
|
||||
));
|
||||
assert!(should_use_puzzle_reference_image_edit(
|
||||
assert!(should_use_puzzle_reference_image_generation(
|
||||
Some("data:image/png;base64,abcd"),
|
||||
true
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_result_level_direct_upload_skips_cover_image_generation() {
|
||||
assert!(should_use_uploaded_puzzle_image_directly(
|
||||
Some("data:image/png;base64,abcd"),
|
||||
false
|
||||
));
|
||||
assert!(!should_use_uploaded_puzzle_image_directly(
|
||||
Some("data:image/png;base64,abcd"),
|
||||
true
|
||||
));
|
||||
assert!(!should_use_uploaded_puzzle_image_directly(None, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_reference_image_sources_are_deduped_and_limited() {
|
||||
let sources = collect_puzzle_reference_image_sources(
|
||||
@@ -131,6 +311,8 @@ fn puzzle_reference_image_sources_are_deduped_and_limited() {
|
||||
"data:image/png;base64,e".to_string(),
|
||||
"data:image/png;base64,f".to_string(),
|
||||
],
|
||||
None,
|
||||
&[],
|
||||
);
|
||||
|
||||
assert_eq!(sources.len(), 5);
|
||||
@@ -139,6 +321,62 @@ fn puzzle_reference_image_sources_are_deduped_and_limited() {
|
||||
assert!(!sources.contains(&"data:image/png;base64,f".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_reference_image_sources_prefer_asset_object_ids() {
|
||||
let sources = collect_puzzle_reference_image_sources(
|
||||
Some("data:image/png;base64,legacy"),
|
||||
&["/generated-puzzle-assets/legacy.png".to_string()],
|
||||
Some("asset-main-1"),
|
||||
&[
|
||||
"asset-main-1".to_string(),
|
||||
"asset-prompt-1".to_string(),
|
||||
"asset-prompt-2".to_string(),
|
||||
],
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
sources,
|
||||
vec![
|
||||
"asset-object:asset-main-1".to_string(),
|
||||
"asset-object:asset-prompt-1".to_string(),
|
||||
"asset-object:asset-prompt-2".to_string(),
|
||||
"data:image/png;base64,legacy".to_string(),
|
||||
"/generated-puzzle-assets/legacy.png".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_asset_object_reference_requires_matching_owner() {
|
||||
let asset_object = module_assets::AssetObjectRecord {
|
||||
asset_object_id: "assetobj_reference_1".to_string(),
|
||||
bucket: "genarrative-assets".to_string(),
|
||||
object_key: "generated-puzzle-assets/reference/image.png".to_string(),
|
||||
access_policy: module_assets::AssetObjectAccessPolicy::Private,
|
||||
content_type: Some("image/png".to_string()),
|
||||
content_length: 1024,
|
||||
content_hash: None,
|
||||
version: 1,
|
||||
source_job_id: None,
|
||||
owner_user_id: Some("user-other".to_string()),
|
||||
profile_id: None,
|
||||
entity_id: None,
|
||||
asset_kind: "puzzle_cover_image".to_string(),
|
||||
created_at: "2026-05-21T00:00:00Z".to_string(),
|
||||
updated_at: "2026-05-21T00:00:00Z".to_string(),
|
||||
};
|
||||
|
||||
let error = validate_puzzle_reference_asset_object(
|
||||
&asset_object,
|
||||
Some("user-current"),
|
||||
"genarrative-assets",
|
||||
)
|
||||
.expect_err("其他账号的参考图资产应被拒绝");
|
||||
|
||||
assert_eq!(error.status_code(), StatusCode::FORBIDDEN);
|
||||
assert!(error.body_text().contains("不属于当前账号"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_vector_engine_timeout_maps_to_gateway_timeout() {
|
||||
let error = map_puzzle_vector_engine_request_error(
|
||||
@@ -153,51 +391,14 @@ fn puzzle_vector_engine_timeout_maps_to_gateway_timeout() {
|
||||
fn puzzle_vector_engine_upstream_timeout_maps_to_gateway_timeout() {
|
||||
let error = map_puzzle_vector_engine_upstream_error(
|
||||
reqwest::StatusCode::GATEWAY_TIMEOUT,
|
||||
r#"{"error":{"message":"VectorEngine edit endpoint timeout"}}"#,
|
||||
"创建拼图 VectorEngine 图片编辑任务失败",
|
||||
r#"{"error":{"message":"VectorEngine generation endpoint timeout"}}"#,
|
||||
"创建拼图 VectorEngine 图片生成任务失败",
|
||||
);
|
||||
|
||||
let response = error.into_response();
|
||||
assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_reference_edit_fallback_only_accepts_timeout_errors() {
|
||||
let timeout_error = map_puzzle_vector_engine_upstream_error(
|
||||
reqwest::StatusCode::GATEWAY_TIMEOUT,
|
||||
r#"{"error":{"message":"VectorEngine edit endpoint timeout"}}"#,
|
||||
"创建拼图 VectorEngine 图片编辑任务失败",
|
||||
);
|
||||
assert!(should_fallback_puzzle_reference_edit_to_generation(
|
||||
&timeout_error
|
||||
));
|
||||
|
||||
let auth_error = map_puzzle_vector_engine_upstream_error(
|
||||
reqwest::StatusCode::UNAUTHORIZED,
|
||||
r#"{"error":{"message":"invalid api key"}}"#,
|
||||
"创建拼图 VectorEngine 图片编辑任务失败",
|
||||
);
|
||||
assert!(!should_fallback_puzzle_reference_edit_to_generation(
|
||||
&auth_error
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_vector_engine_reqwest_error_maps_to_bad_gateway() {
|
||||
let error = match reqwest::Client::new().get("http://[::1").build() {
|
||||
Ok(_) => panic!("invalid url should fail request build"),
|
||||
Err(error) => error,
|
||||
};
|
||||
let app_error = map_puzzle_vector_engine_reqwest_error(
|
||||
"创建拼图 VectorEngine 图片编辑任务失败",
|
||||
"https://api.vectorengine.ai/v1/images/edits",
|
||||
error,
|
||||
);
|
||||
|
||||
let response = app_error.into_response();
|
||||
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_compile_error_preserves_vector_engine_unavailable_status() {
|
||||
let error = map_puzzle_compile_error(SpacetimeClientError::Runtime(
|
||||
@@ -250,6 +451,8 @@ fn puzzle_image_generation_builds_fallback_session_from_levels_snapshot() {
|
||||
prompt_text: None,
|
||||
reference_image_src: None,
|
||||
reference_image_srcs: Vec::new(),
|
||||
reference_image_asset_object_id: None,
|
||||
reference_image_asset_object_ids: Vec::new(),
|
||||
image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()),
|
||||
ai_redraw: None,
|
||||
candidate_count: Some(1),
|
||||
@@ -383,6 +586,7 @@ fn puzzle_uploaded_cover_can_reuse_resolved_history_image() {
|
||||
mime_type: "image/png".to_string(),
|
||||
bytes_len: 8,
|
||||
bytes: b"pngbytes".to_vec(),
|
||||
signed_read_url: None,
|
||||
};
|
||||
|
||||
let downloaded = PuzzleDownloadedImage::from_resolved_reference_image(resolved);
|
||||
@@ -410,6 +614,8 @@ fn puzzle_first_level_name_snapshot_defaults_work_title() {
|
||||
prompt_text: None,
|
||||
reference_image_src: None,
|
||||
reference_image_srcs: Vec::new(),
|
||||
reference_image_asset_object_id: None,
|
||||
reference_image_asset_object_ids: Vec::new(),
|
||||
image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()),
|
||||
ai_redraw: None,
|
||||
candidate_count: Some(1),
|
||||
@@ -510,6 +716,12 @@ fn puzzle_level_audio_asset_roundtrips_between_response_and_module_json() {
|
||||
ui_background_prompt: None,
|
||||
ui_background_image_src: None,
|
||||
ui_background_image_object_key: None,
|
||||
level_scene_image_src: None,
|
||||
level_scene_image_object_key: None,
|
||||
ui_spritesheet_image_src: None,
|
||||
ui_spritesheet_image_object_key: None,
|
||||
level_background_image_src: None,
|
||||
level_background_image_object_key: None,
|
||||
background_music: Some(CreationAudioAsset {
|
||||
task_id: "suno-task-1".to_string(),
|
||||
provider: "vector-engine-suno".to_string(),
|
||||
@@ -575,6 +787,12 @@ fn puzzle_ui_background_fields_roundtrip_between_response_and_module_json() {
|
||||
ui_background_image_object_key: Some(
|
||||
"generated-puzzle-assets/session/ui/background.png".to_string(),
|
||||
),
|
||||
level_scene_image_src: None,
|
||||
level_scene_image_object_key: None,
|
||||
ui_spritesheet_image_src: None,
|
||||
ui_spritesheet_image_object_key: None,
|
||||
level_background_image_src: None,
|
||||
level_background_image_object_key: None,
|
||||
background_music: None,
|
||||
candidates: vec![],
|
||||
selected_candidate_id: None,
|
||||
@@ -612,9 +830,86 @@ fn puzzle_ui_background_fields_roundtrip_between_response_and_module_json() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_level_asset_bundle_fields_roundtrip_between_response_and_module_json() {
|
||||
let level = PuzzleDraftLevelResponse {
|
||||
level_id: "puzzle-level-1".to_string(),
|
||||
level_name: "雨夜猫街".to_string(),
|
||||
picture_description: "一只猫在雨夜灯牌下回头。".to_string(),
|
||||
picture_reference: None,
|
||||
ui_background_prompt: Some("雨夜猫街竖屏拼图UI背景".to_string()),
|
||||
ui_background_image_src: Some(
|
||||
"/generated-puzzle-assets/session/legacy-ui/background.png".to_string(),
|
||||
),
|
||||
ui_background_image_object_key: Some(
|
||||
"generated-puzzle-assets/session/legacy-ui/background.png".to_string(),
|
||||
),
|
||||
level_scene_image_src: Some(
|
||||
"/generated-puzzle-assets/session/level-scene/scene.png".to_string(),
|
||||
),
|
||||
level_scene_image_object_key: Some(
|
||||
"generated-puzzle-assets/session/level-scene/scene.png".to_string(),
|
||||
),
|
||||
ui_spritesheet_image_src: Some(
|
||||
"/generated-puzzle-assets/session/ui-spritesheet/sheet.png".to_string(),
|
||||
),
|
||||
ui_spritesheet_image_object_key: Some(
|
||||
"generated-puzzle-assets/session/ui-spritesheet/sheet.png".to_string(),
|
||||
),
|
||||
level_background_image_src: Some(
|
||||
"/generated-puzzle-assets/session/level-background/background.png".to_string(),
|
||||
),
|
||||
level_background_image_object_key: Some(
|
||||
"generated-puzzle-assets/session/level-background/background.png".to_string(),
|
||||
),
|
||||
background_music: None,
|
||||
candidates: vec![],
|
||||
selected_candidate_id: None,
|
||||
cover_image_src: Some("/generated-puzzle-assets/session/cover.png".to_string()),
|
||||
cover_asset_id: Some("asset-1".to_string()),
|
||||
generation_status: "ready".to_string(),
|
||||
};
|
||||
let request_context = RequestContext::new(
|
||||
"test-request".to_string(),
|
||||
"PUT /api/runtime/puzzle/works/test".to_string(),
|
||||
Duration::ZERO,
|
||||
false,
|
||||
);
|
||||
|
||||
let levels_json = serialize_puzzle_levels_response(&request_context, &[level])
|
||||
.expect("levels should serialize");
|
||||
let payload: Value = serde_json::from_str(&levels_json).expect("levels json should parse");
|
||||
assert_eq!(
|
||||
payload[0]["level_background_image_object_key"],
|
||||
Value::String(
|
||||
"generated-puzzle-assets/session/level-background/background.png".to_string()
|
||||
)
|
||||
);
|
||||
assert!(payload[0].get("levelBackgroundImageObjectKey").is_none());
|
||||
|
||||
let records = parse_puzzle_level_records_from_module_json(&levels_json)
|
||||
.expect("levels should map back into records");
|
||||
assert_eq!(
|
||||
records[0].level_scene_image_src.as_deref(),
|
||||
Some("/generated-puzzle-assets/session/level-scene/scene.png")
|
||||
);
|
||||
assert_eq!(
|
||||
records[0].ui_spritesheet_image_object_key.as_deref(),
|
||||
Some("generated-puzzle-assets/session/ui-spritesheet/sheet.png")
|
||||
);
|
||||
|
||||
let response = map_puzzle_draft_level_response(records[0].clone());
|
||||
assert_eq!(
|
||||
response.level_background_image_src.as_deref(),
|
||||
Some("/generated-puzzle-assets/session/level-background/background.png")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_work_summary_response_keeps_levels_for_shelf_cover() {
|
||||
let state = AppState::new(crate::config::AppConfig::default()).expect("state should build");
|
||||
let app_state = crate::state::AppState::new(crate::config::AppConfig::default())
|
||||
.expect("state should build");
|
||||
let state: PuzzleApiState = axum::extract::FromRef::from_ref(&app_state);
|
||||
let level = PuzzleDraftLevelRecord {
|
||||
level_id: "puzzle-level-1".to_string(),
|
||||
level_name: "雨夜猫街".to_string(),
|
||||
@@ -623,6 +918,12 @@ fn puzzle_work_summary_response_keeps_levels_for_shelf_cover() {
|
||||
ui_background_prompt: None,
|
||||
ui_background_image_src: None,
|
||||
ui_background_image_object_key: None,
|
||||
level_scene_image_src: None,
|
||||
level_scene_image_object_key: None,
|
||||
ui_spritesheet_image_src: None,
|
||||
ui_spritesheet_image_object_key: None,
|
||||
level_background_image_src: None,
|
||||
level_background_image_object_key: None,
|
||||
background_music: None,
|
||||
candidates: vec![PuzzleGeneratedImageCandidateRecord {
|
||||
candidate_id: "candidate-1".to_string(),
|
||||
@@ -756,12 +1057,15 @@ fn puzzle_initial_draft_assets_must_include_ui_background() {
|
||||
let missing_all = ensure_puzzle_initial_level_assets_ready(&draft.levels[0])
|
||||
.expect_err("缺少自动生成资产时不能把草稿标记为完成");
|
||||
assert_eq!(missing_all.status_code(), StatusCode::BAD_GATEWAY);
|
||||
assert!(missing_all.body_text().contains("UI背景图"));
|
||||
assert!(missing_all.body_text().contains("关卡背景图"));
|
||||
assert!(missing_all.body_text().contains("UI spritesheet"));
|
||||
|
||||
draft.levels[0].ui_background_image_src =
|
||||
Some("/generated-puzzle-assets/session/ui/background.png".to_string());
|
||||
draft.levels[0].level_background_image_src =
|
||||
Some("/generated-puzzle-assets/session/background/background.png".to_string());
|
||||
draft.levels[0].ui_spritesheet_image_src =
|
||||
Some("/generated-puzzle-assets/session/spritesheet/sheet.png".to_string());
|
||||
ensure_puzzle_initial_level_assets_ready(&draft.levels[0])
|
||||
.expect("UI 背景存在时即可完成自动草稿资源检查");
|
||||
.expect("关卡背景和 UI spritesheet 存在时即可完成自动草稿资源检查");
|
||||
}
|
||||
|
||||
fn test_puzzle_anchor_pack_record() -> PuzzleAnchorPackRecord {
|
||||
@@ -805,6 +1109,12 @@ fn test_puzzle_draft_record() -> PuzzleResultDraftRecord {
|
||||
ui_background_prompt: None,
|
||||
ui_background_image_src: None,
|
||||
ui_background_image_object_key: None,
|
||||
level_scene_image_src: None,
|
||||
level_scene_image_object_key: None,
|
||||
ui_spritesheet_image_src: None,
|
||||
ui_spritesheet_image_object_key: None,
|
||||
level_background_image_src: None,
|
||||
level_background_image_object_key: None,
|
||||
background_music: None,
|
||||
candidates: vec![],
|
||||
selected_candidate_id: None,
|
||||
|
||||
@@ -12,7 +12,7 @@ impl PuzzleImageModel {
|
||||
}
|
||||
|
||||
pub(crate) fn request_model_name(self) -> &'static str {
|
||||
VECTOR_ENGINE_GPT_IMAGE_2_MODEL
|
||||
GPT_IMAGE_2_MODEL
|
||||
}
|
||||
|
||||
pub(crate) fn candidate_source_type(self) -> &'static str {
|
||||
@@ -37,6 +37,7 @@ pub(crate) struct PuzzleResolvedReferenceImage {
|
||||
pub(crate) mime_type: String,
|
||||
pub(crate) bytes_len: usize,
|
||||
pub(crate) bytes: Vec<u8>,
|
||||
pub(crate) signed_read_url: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) struct GeneratedPuzzleImageCandidate {
|
||||
@@ -94,13 +95,26 @@ pub(crate) struct GeneratedPuzzleUiBackgroundResponse {
|
||||
pub(crate) object_key: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct GeneratedPuzzleLevelAssetResponse {
|
||||
pub(crate) image_src: String,
|
||||
pub(crate) object_key: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct GeneratedPuzzleLevelAssetBundle {
|
||||
pub(crate) level_scene: GeneratedPuzzleLevelAssetResponse,
|
||||
pub(crate) ui_spritesheet: GeneratedPuzzleLevelAssetResponse,
|
||||
pub(crate) level_background: GeneratedPuzzleLevelAssetResponse,
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_puzzle_image_model(value: Option<&str>) -> PuzzleImageModel {
|
||||
match value.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
Some(PUZZLE_IMAGE_MODEL_GEMINI_31_FLASH_PREVIEW) => {
|
||||
tracing::warn!(
|
||||
requested_model = PUZZLE_IMAGE_MODEL_GEMINI_31_FLASH_PREVIEW,
|
||||
effective_model = VECTOR_ENGINE_GPT_IMAGE_2_MODEL,
|
||||
"拼图 nanobanana2 历史选项已回落到 VectorEngine GPT-image-2-all"
|
||||
effective_model = GPT_IMAGE_2_MODEL,
|
||||
"拼图 nanobanana2 历史选项已回落到 VectorEngine GPT-image-2"
|
||||
);
|
||||
PuzzleImageModel::Gemini31FlashPreview
|
||||
}
|
||||
@@ -109,13 +123,9 @@ pub(crate) fn resolve_puzzle_image_model(value: Option<&str>) -> PuzzleImageMode
|
||||
}
|
||||
|
||||
pub(crate) fn require_puzzle_vector_engine_settings(
|
||||
state: &AppState,
|
||||
state: &PuzzleApiState,
|
||||
) -> Result<PuzzleVectorEngineSettings, AppError> {
|
||||
let base_url = state
|
||||
.config
|
||||
.vector_engine_base_url
|
||||
.trim()
|
||||
.trim_end_matches('/');
|
||||
let base_url = state.vector_engine_base_url().trim().trim_end_matches('/');
|
||||
if base_url.is_empty() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||
@@ -127,9 +137,7 @@ pub(crate) fn require_puzzle_vector_engine_settings(
|
||||
}
|
||||
|
||||
let api_key = state
|
||||
.config
|
||||
.vector_engine_api_key
|
||||
.as_deref()
|
||||
.vector_engine_api_key()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
@@ -147,15 +155,15 @@ pub(crate) fn require_puzzle_vector_engine_settings(
|
||||
}
|
||||
|
||||
pub(crate) fn build_puzzle_image_http_client(
|
||||
state: &AppState,
|
||||
state: &PuzzleApiState,
|
||||
image_model: PuzzleImageModel,
|
||||
) -> Result<reqwest::Client, AppError> {
|
||||
let provider = image_model.provider_name();
|
||||
let request_timeout_ms = state.config.vector_engine_image_request_timeout_ms;
|
||||
let request_timeout_ms = state.vector_engine_image_request_timeout_ms();
|
||||
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_millis(request_timeout_ms.max(1)))
|
||||
// 中文注释:VectorEngine 的图片编辑接口是 multipart 请求;强制 HTTP/1.1 可避开部分网关对 HTTP/2 multipart 流的中断兼容问题。
|
||||
// 中文注释:参考图走 multipart edits;强制 HTTP/1.1 可降低部分网关对长耗时上传流的兼容风险。
|
||||
.http1_only()
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
@@ -191,6 +199,20 @@ pub(crate) async fn create_puzzle_vector_engine_image_generation(
|
||||
candidate_count: u32,
|
||||
reference_image: Option<&PuzzleResolvedReferenceImage>,
|
||||
) -> Result<PuzzleGeneratedImages, AppError> {
|
||||
if let Some(reference_image) = reference_image {
|
||||
return create_puzzle_vector_engine_image_edit(
|
||||
http_client,
|
||||
settings,
|
||||
image_model,
|
||||
prompt,
|
||||
negative_prompt,
|
||||
size,
|
||||
candidate_count,
|
||||
reference_image,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let request_body = build_puzzle_vector_engine_image_request_body(
|
||||
image_model,
|
||||
prompt,
|
||||
@@ -267,6 +289,15 @@ pub(crate) async fn create_puzzle_vector_engine_image_generation(
|
||||
return Ok(images);
|
||||
}
|
||||
|
||||
let b64_images = extract_puzzle_b64_images(&payload);
|
||||
if !b64_images.is_empty() {
|
||||
return Ok(puzzle_images_from_base64(
|
||||
format!("vector-engine-{}", current_utc_micros()),
|
||||
b64_images,
|
||||
candidate_count,
|
||||
));
|
||||
}
|
||||
|
||||
Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": VECTOR_ENGINE_PROVIDER,
|
||||
@@ -278,6 +309,7 @@ pub(crate) async fn create_puzzle_vector_engine_image_generation(
|
||||
pub(crate) async fn create_puzzle_vector_engine_image_edit(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &PuzzleVectorEngineSettings,
|
||||
image_model: PuzzleImageModel,
|
||||
prompt: &str,
|
||||
negative_prompt: &str,
|
||||
size: &str,
|
||||
@@ -300,7 +332,7 @@ pub(crate) async fn create_puzzle_vector_engine_image_edit(
|
||||
})?;
|
||||
let form = reqwest::multipart::Form::new()
|
||||
.part("image", image_part)
|
||||
.text("model", PUZZLE_VECTOR_ENGINE_IMAGE_EDIT_MODEL.to_string())
|
||||
.text("model", image_model.request_model_name().to_string())
|
||||
.text(
|
||||
"prompt",
|
||||
build_puzzle_vector_engine_prompt(prompt, negative_prompt),
|
||||
@@ -319,16 +351,14 @@ pub(crate) async fn create_puzzle_vector_engine_image_edit(
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
map_puzzle_vector_engine_reqwest_error(
|
||||
"创建拼图 VectorEngine 图片编辑任务失败",
|
||||
&request_url,
|
||||
error,
|
||||
)
|
||||
map_puzzle_vector_engine_request_error(format!(
|
||||
"创建拼图 VectorEngine 图片编辑任务失败:{error}"
|
||||
))
|
||||
})?;
|
||||
let status = response.status();
|
||||
tracing::info!(
|
||||
provider = VECTOR_ENGINE_PROVIDER,
|
||||
image_model = PUZZLE_VECTOR_ENGINE_IMAGE_EDIT_MODEL,
|
||||
image_model = image_model.request_model_name(),
|
||||
endpoint = %request_url,
|
||||
status = status.as_u16(),
|
||||
prompt_chars = prompt.chars().count(),
|
||||
@@ -377,6 +407,17 @@ pub(crate) async fn create_puzzle_vector_engine_image_edit(
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn build_puzzle_downloaded_image_reference(
|
||||
image: &PuzzleDownloadedImage,
|
||||
) -> PuzzleResolvedReferenceImage {
|
||||
PuzzleResolvedReferenceImage {
|
||||
mime_type: image.mime_type.clone(),
|
||||
bytes_len: image.bytes.len(),
|
||||
bytes: image.bytes.clone(),
|
||||
signed_read_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_puzzle_vector_engine_image_request_body(
|
||||
image_model: PuzzleImageModel,
|
||||
prompt: &str,
|
||||
@@ -385,7 +426,7 @@ pub(crate) fn build_puzzle_vector_engine_image_request_body(
|
||||
candidate_count: u32,
|
||||
reference_image: Option<&PuzzleResolvedReferenceImage>,
|
||||
) -> Value {
|
||||
let mut body = Map::from_iter([
|
||||
let body = Map::from_iter([
|
||||
(
|
||||
"model".to_string(),
|
||||
Value::String(image_model.request_model_name().to_string()),
|
||||
@@ -397,12 +438,7 @@ pub(crate) fn build_puzzle_vector_engine_image_request_body(
|
||||
("n".to_string(), json!(candidate_count.clamp(1, 1))),
|
||||
("size".to_string(), Value::String(size.to_string())),
|
||||
]);
|
||||
if let Some(reference_image) = reference_image
|
||||
&& let Some(reference_data_url) =
|
||||
build_puzzle_generation_reference_image_data_url(reference_image)
|
||||
{
|
||||
body.insert("image".to_string(), json!([reference_data_url]));
|
||||
}
|
||||
let _ = reference_image;
|
||||
|
||||
Value::Object(body)
|
||||
}
|
||||
@@ -426,32 +462,6 @@ pub(crate) fn build_puzzle_vector_engine_generation_prompt(
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn build_puzzle_generation_reference_image_data_url(
|
||||
image: &PuzzleResolvedReferenceImage,
|
||||
) -> Option<String> {
|
||||
let bytes = resize_puzzle_generation_reference_image_bytes(image.bytes.as_slice())
|
||||
.unwrap_or_else(|| image.bytes.clone());
|
||||
let mime_type = if bytes.starts_with(b"\x89PNG\r\n\x1A\n") {
|
||||
"image/png"
|
||||
} else {
|
||||
image.mime_type.as_str()
|
||||
};
|
||||
|
||||
Some(format!(
|
||||
"data:{};base64,{}",
|
||||
normalize_puzzle_downloaded_image_mime_type(mime_type),
|
||||
BASE64_STANDARD.encode(bytes)
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn resize_puzzle_generation_reference_image_bytes(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
let image = image::load_from_memory(bytes).ok()?;
|
||||
let resized = image.resize(1024, 1024, image::imageops::FilterType::Triangle);
|
||||
let mut cursor = std::io::Cursor::new(Vec::new());
|
||||
resized.write_to(&mut cursor, ImageFormat::Png).ok()?;
|
||||
Some(cursor.into_inner())
|
||||
}
|
||||
|
||||
pub(crate) fn has_puzzle_reference_image(reference_image_src: Option<&str>) -> bool {
|
||||
reference_image_src
|
||||
.map(str::trim)
|
||||
@@ -462,6 +472,48 @@ pub(crate) fn has_puzzle_reference_image(reference_image_src: Option<&str>) -> b
|
||||
pub(crate) fn collect_puzzle_reference_image_sources(
|
||||
legacy_reference_image_src: Option<&str>,
|
||||
reference_image_srcs: &[String],
|
||||
reference_image_asset_object_id: Option<&str>,
|
||||
reference_image_asset_object_ids: &[String],
|
||||
) -> Vec<String> {
|
||||
let mut sources = Vec::new();
|
||||
for source in reference_image_asset_object_id
|
||||
.into_iter()
|
||||
.chain(reference_image_asset_object_ids.iter().map(String::as_str))
|
||||
.map(|asset_object_id| {
|
||||
asset_object_id
|
||||
.trim()
|
||||
.strip_prefix("asset-object:")
|
||||
.unwrap_or_else(|| asset_object_id.trim())
|
||||
})
|
||||
.filter(|asset_object_id| !asset_object_id.is_empty())
|
||||
.map(|asset_object_id| format!("asset-object:{asset_object_id}"))
|
||||
.chain(
|
||||
legacy_reference_image_src
|
||||
.into_iter()
|
||||
.chain(reference_image_srcs.iter().map(String::as_str))
|
||||
.map(str::to_string),
|
||||
)
|
||||
{
|
||||
let normalized = source.trim();
|
||||
if normalized.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !sources
|
||||
.iter()
|
||||
.any(|existing: &String| existing == normalized)
|
||||
{
|
||||
sources.push(normalized.to_string());
|
||||
}
|
||||
if sources.len() >= PUZZLE_REFERENCE_IMAGE_SOURCE_LIMIT {
|
||||
break;
|
||||
}
|
||||
}
|
||||
sources
|
||||
}
|
||||
|
||||
pub(crate) fn collect_legacy_puzzle_reference_image_sources(
|
||||
legacy_reference_image_src: Option<&str>,
|
||||
reference_image_srcs: &[String],
|
||||
) -> Vec<String> {
|
||||
let mut sources = Vec::new();
|
||||
for source in legacy_reference_image_src
|
||||
@@ -488,16 +540,23 @@ pub(crate) fn collect_puzzle_reference_image_sources(
|
||||
pub(crate) fn has_puzzle_reference_images(
|
||||
legacy_reference_image_src: Option<&str>,
|
||||
reference_image_srcs: &[String],
|
||||
reference_image_asset_object_id: Option<&str>,
|
||||
reference_image_asset_object_ids: &[String],
|
||||
) -> bool {
|
||||
!collect_puzzle_reference_image_sources(legacy_reference_image_src, reference_image_srcs)
|
||||
.is_empty()
|
||||
!collect_puzzle_reference_image_sources(
|
||||
legacy_reference_image_src,
|
||||
reference_image_srcs,
|
||||
reference_image_asset_object_id,
|
||||
reference_image_asset_object_ids,
|
||||
)
|
||||
.is_empty()
|
||||
}
|
||||
|
||||
pub(crate) fn should_use_puzzle_reference_image_edit(
|
||||
pub(crate) fn should_use_puzzle_reference_image_generation(
|
||||
reference_image_src: Option<&str>,
|
||||
use_reference_image_edit: bool,
|
||||
use_reference_image_generation: bool,
|
||||
) -> bool {
|
||||
use_reference_image_edit && has_puzzle_reference_image(reference_image_src)
|
||||
use_reference_image_generation && has_puzzle_reference_image(reference_image_src)
|
||||
}
|
||||
|
||||
pub(crate) fn build_puzzle_vector_engine_prompt(prompt: &str, negative_prompt: &str) -> String {
|
||||
@@ -546,10 +605,19 @@ pub(crate) async fn download_puzzle_images_from_urls(
|
||||
Ok(PuzzleGeneratedImages { task_id, images })
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_puzzle_reference_image_as_data_url(
|
||||
state: &AppState,
|
||||
pub(crate) fn parse_puzzle_asset_object_reference(source: &str) -> Option<&str> {
|
||||
source
|
||||
.trim()
|
||||
.strip_prefix("asset-object:")
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_puzzle_reference_image(
|
||||
state: &PuzzleApiState,
|
||||
http_client: &reqwest::Client,
|
||||
source: &str,
|
||||
owner_user_id: Option<&str>,
|
||||
) -> Result<PuzzleResolvedReferenceImage, AppError> {
|
||||
let trimmed = source.trim();
|
||||
if trimmed.is_empty() {
|
||||
@@ -562,6 +630,16 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url(
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(asset_object_id) = parse_puzzle_asset_object_reference(trimmed) {
|
||||
return resolve_puzzle_reference_asset_object(
|
||||
state,
|
||||
http_client,
|
||||
asset_object_id,
|
||||
owner_user_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
if let Some(parsed) = parse_puzzle_image_data_url(trimmed) {
|
||||
let bytes_len = parsed.bytes.len();
|
||||
if bytes_len > PUZZLE_REFERENCE_IMAGE_MAX_BYTES {
|
||||
@@ -579,6 +657,7 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url(
|
||||
mime_type: parsed.mime_type,
|
||||
bytes_len,
|
||||
bytes: parsed.bytes,
|
||||
signed_read_url: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -587,7 +666,7 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "puzzle",
|
||||
"field": "referenceImageSrc",
|
||||
"message": "参考图必须是 Data URL 或 /generated-* 旧路径。",
|
||||
"message": "参考图必须是 assetObjectId、Data URL 或 /generated-* 旧路径。",
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -598,7 +677,7 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "puzzle",
|
||||
"field": "referenceImageSrc",
|
||||
"message": "参考图当前只支持 /generated-* 旧路径。",
|
||||
"message": "参考图当前只支持 assetObjectId 或 /generated-* 旧路径。",
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -615,8 +694,159 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url(
|
||||
expire_seconds: Some(60),
|
||||
})
|
||||
.map_err(map_puzzle_asset_oss_error)?;
|
||||
let signed_read_url = signed.signed_url;
|
||||
download_signed_puzzle_reference_image(
|
||||
http_client,
|
||||
signed_read_url,
|
||||
object_key,
|
||||
None,
|
||||
"referenceImageSrc",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_puzzle_reference_image_as_data_url(
|
||||
state: &PuzzleApiState,
|
||||
http_client: &reqwest::Client,
|
||||
source: &str,
|
||||
) -> Result<PuzzleResolvedReferenceImage, AppError> {
|
||||
resolve_puzzle_reference_image(state, http_client, source, None).await
|
||||
}
|
||||
|
||||
async fn resolve_puzzle_reference_asset_object(
|
||||
state: &PuzzleApiState,
|
||||
http_client: &reqwest::Client,
|
||||
asset_object_id: &str,
|
||||
owner_user_id: Option<&str>,
|
||||
) -> Result<PuzzleResolvedReferenceImage, AppError> {
|
||||
let asset_object = state
|
||||
.spacetime_client()
|
||||
.get_asset_object(asset_object_id.to_string())
|
||||
.await
|
||||
.map_err(map_puzzle_client_error)?
|
||||
.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "asset-object",
|
||||
"field": "referenceImageAssetObjectId",
|
||||
"assetObjectId": asset_object_id,
|
||||
"message": "参考图资产不存在或当前账号不可见。",
|
||||
}))
|
||||
})?;
|
||||
let oss_client = state.oss_client().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"reason": "OSS 未完成环境变量配置",
|
||||
}))
|
||||
})?;
|
||||
validate_puzzle_reference_asset_object(
|
||||
&asset_object,
|
||||
owner_user_id,
|
||||
oss_client.config_bucket(),
|
||||
)?;
|
||||
let signed = oss_client
|
||||
.sign_get_object_url(OssSignedGetObjectUrlRequest {
|
||||
object_key: asset_object.object_key.clone(),
|
||||
expire_seconds: Some(60),
|
||||
})
|
||||
.map_err(map_puzzle_asset_oss_error)?;
|
||||
let content_type = asset_object.content_type.clone();
|
||||
download_signed_puzzle_reference_image(
|
||||
http_client,
|
||||
signed.signed_url,
|
||||
asset_object.object_key.as_str(),
|
||||
content_type.as_deref(),
|
||||
"referenceImageAssetObjectId",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) fn validate_puzzle_reference_asset_object(
|
||||
asset_object: &module_assets::AssetObjectRecord,
|
||||
owner_user_id: Option<&str>,
|
||||
oss_bucket: &str,
|
||||
) -> Result<(), AppError> {
|
||||
if asset_object.bucket.trim() != oss_bucket.trim() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "asset-object",
|
||||
"field": "referenceImageAssetObjectId",
|
||||
"assetObjectId": asset_object.asset_object_id,
|
||||
"message": "参考图资产 bucket 与当前服务 OSS 配置不一致。",
|
||||
})),
|
||||
);
|
||||
}
|
||||
if asset_object.asset_kind.trim() != "puzzle_cover_image" {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "asset-object",
|
||||
"field": "referenceImageAssetObjectId",
|
||||
"assetObjectId": asset_object.asset_object_id,
|
||||
"message": "参考图资产类型不属于拼图图片。",
|
||||
})),
|
||||
);
|
||||
}
|
||||
let content_type = asset_object
|
||||
.content_type
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.unwrap_or_default();
|
||||
if !content_type.starts_with("image/") {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "asset-object",
|
||||
"field": "referenceImageAssetObjectId",
|
||||
"assetObjectId": asset_object.asset_object_id,
|
||||
"message": "参考图资产不是图片类型。",
|
||||
})),
|
||||
);
|
||||
}
|
||||
if asset_object.content_length == 0
|
||||
|| asset_object.content_length > PUZZLE_REFERENCE_IMAGE_MAX_BYTES as u64
|
||||
{
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "asset-object",
|
||||
"field": "referenceImageAssetObjectId",
|
||||
"assetObjectId": asset_object.asset_object_id,
|
||||
"message": "参考图资产大小不符合拼图生成要求。",
|
||||
"maxBytes": PUZZLE_REFERENCE_IMAGE_MAX_BYTES,
|
||||
"actualBytes": asset_object.content_length,
|
||||
})),
|
||||
);
|
||||
}
|
||||
if let Some(expected_owner_user_id) = owner_user_id
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
let actual_owner_user_id = asset_object
|
||||
.owner_user_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
if actual_owner_user_id != Some(expected_owner_user_id) {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({
|
||||
"provider": "asset-object",
|
||||
"field": "referenceImageAssetObjectId",
|
||||
"assetObjectId": asset_object.asset_object_id,
|
||||
"message": "参考图资产不属于当前账号。",
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn download_signed_puzzle_reference_image(
|
||||
http_client: &reqwest::Client,
|
||||
signed_read_url: String,
|
||||
object_key: &str,
|
||||
fallback_content_type: Option<&str>,
|
||||
field: &str,
|
||||
) -> Result<PuzzleResolvedReferenceImage, AppError> {
|
||||
let response = http_client
|
||||
.get(signed.signed_url)
|
||||
.get(signed_read_url.as_str())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| map_puzzle_image_request_error(format!("读取拼图参考图失败:{error}")))?;
|
||||
@@ -625,6 +855,7 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url(
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.or(fallback_content_type)
|
||||
.unwrap_or("image/png")
|
||||
.to_string();
|
||||
let body = response.bytes().await.map_err(|error| {
|
||||
@@ -636,6 +867,7 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url(
|
||||
"provider": "aliyun-oss",
|
||||
"message": format!("读取参考图失败,状态码:{status}"),
|
||||
"objectKey": object_key,
|
||||
"field": field,
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -645,6 +877,7 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url(
|
||||
"provider": "aliyun-oss",
|
||||
"message": "读取参考图失败:对象内容为空",
|
||||
"objectKey": object_key,
|
||||
"field": field,
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -655,6 +888,7 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url(
|
||||
mime_type,
|
||||
bytes_len,
|
||||
bytes: body.to_vec(),
|
||||
signed_read_url: Some(signed_read_url),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -693,7 +927,7 @@ pub(crate) async fn download_puzzle_remote_image(
|
||||
}
|
||||
|
||||
pub(crate) async fn persist_puzzle_generated_asset(
|
||||
state: &AppState,
|
||||
state: &PuzzleApiState,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
level_name: &str,
|
||||
@@ -805,7 +1039,7 @@ pub(crate) async fn persist_puzzle_generated_asset(
|
||||
}
|
||||
|
||||
pub(crate) async fn persist_puzzle_ui_background_image(
|
||||
state: &AppState,
|
||||
state: &PuzzleApiState,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
level_name: &str,
|
||||
@@ -845,6 +1079,57 @@ pub(crate) async fn persist_puzzle_ui_background_image(
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn persist_puzzle_level_asset_image(
|
||||
state: &PuzzleApiState,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
level_name: &str,
|
||||
task_id: &str,
|
||||
path_segment: &str,
|
||||
asset_kind: &str,
|
||||
slot: &str,
|
||||
file_stem: &str,
|
||||
image: PuzzleDownloadedImage,
|
||||
) -> Result<GeneratedPuzzleLevelAssetResponse, 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 put_result = oss_client
|
||||
.put_object(
|
||||
&http_client,
|
||||
OssPutObjectRequest {
|
||||
prefix: LegacyAssetPrefix::PuzzleAssets,
|
||||
path_segments: vec![
|
||||
sanitize_path_segment(session_id, "session"),
|
||||
sanitize_path_segment(level_name, "puzzle"),
|
||||
sanitize_path_segment(path_segment, "level-asset"),
|
||||
sanitize_path_segment(task_id, "task"),
|
||||
],
|
||||
file_name: format!("{file_stem}.{}", image.extension),
|
||||
content_type: Some(image.mime_type.clone()),
|
||||
access: OssObjectAccess::Private,
|
||||
metadata: build_puzzle_level_asset_metadata(
|
||||
owner_user_id,
|
||||
session_id,
|
||||
asset_kind,
|
||||
slot,
|
||||
),
|
||||
body: image.bytes,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(map_puzzle_asset_oss_error)?;
|
||||
|
||||
Ok(GeneratedPuzzleLevelAssetResponse {
|
||||
image_src: put_result.legacy_public_path,
|
||||
object_key: put_result.object_key,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn handle_puzzle_asset_spacetime_index_error(
|
||||
error: SpacetimeClientError,
|
||||
owner_user_id: &str,
|
||||
@@ -899,6 +1184,21 @@ pub(crate) fn build_puzzle_ui_background_asset_metadata(
|
||||
])
|
||||
}
|
||||
|
||||
pub(crate) fn build_puzzle_level_asset_metadata(
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
asset_kind: &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()),
|
||||
("entity_kind".to_string(), PUZZLE_ENTITY_KIND.to_string()),
|
||||
("entity_id".to_string(), session_id.to_string()),
|
||||
("slot".to_string(), slot.to_string()),
|
||||
])
|
||||
}
|
||||
|
||||
pub(crate) fn parse_puzzle_json_payload(
|
||||
raw_text: &str,
|
||||
fallback_message: &str,
|
||||
@@ -1104,72 +1404,6 @@ pub(crate) fn map_puzzle_vector_engine_request_error(message: String) -> AppErro
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn map_puzzle_vector_engine_reqwest_error(
|
||||
context: &str,
|
||||
request_url: &str,
|
||||
error: reqwest::Error,
|
||||
) -> AppError {
|
||||
let message = format!(
|
||||
"{context}:{}",
|
||||
normalize_puzzle_reqwest_error_message(&error)
|
||||
);
|
||||
let is_timeout = error.is_timeout() || is_puzzle_request_timeout_message(message.as_str());
|
||||
let is_connect = error.is_connect();
|
||||
let status = if is_timeout {
|
||||
StatusCode::GATEWAY_TIMEOUT
|
||||
} else {
|
||||
StatusCode::BAD_GATEWAY
|
||||
};
|
||||
let source = error.source().map(ToString::to_string).unwrap_or_default();
|
||||
|
||||
tracing::warn!(
|
||||
provider = VECTOR_ENGINE_PROVIDER,
|
||||
endpoint = %request_url,
|
||||
timeout = is_timeout,
|
||||
connect = is_connect,
|
||||
request = error.is_request(),
|
||||
body = error.is_body(),
|
||||
source = %source,
|
||||
message = %message,
|
||||
"拼图 VectorEngine 请求发送失败"
|
||||
);
|
||||
|
||||
AppError::from_status(status).with_details(json!({
|
||||
"provider": VECTOR_ENGINE_PROVIDER,
|
||||
"message": message,
|
||||
"reason": resolve_puzzle_vector_engine_request_failure_reason(&error),
|
||||
"endpoint": request_url,
|
||||
"timeout": is_timeout,
|
||||
"connect": is_connect,
|
||||
"request": error.is_request(),
|
||||
"body": error.is_body(),
|
||||
"source": source,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_puzzle_reqwest_error_message(error: &reqwest::Error) -> String {
|
||||
error
|
||||
.to_string()
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_puzzle_vector_engine_request_failure_reason(
|
||||
error: &reqwest::Error,
|
||||
) -> &'static str {
|
||||
if error.is_timeout() {
|
||||
return "VectorEngine 图片编辑请求超时,请稍后重试或调大 VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS";
|
||||
}
|
||||
if error.is_connect() {
|
||||
return "无法连接 VectorEngine 图片编辑接口,请检查服务器网络、DNS、防火墙或代理配置";
|
||||
}
|
||||
if error.is_body() {
|
||||
return "发送 VectorEngine 图片编辑 multipart 请求体失败,请重试并检查参考图大小";
|
||||
}
|
||||
"VectorEngine 图片编辑请求发送失败,请查看 source 字段中的底层网络错误"
|
||||
}
|
||||
|
||||
pub(crate) fn is_puzzle_request_timeout_message(message: &str) -> bool {
|
||||
let lower = message.to_ascii_lowercase();
|
||||
lower.contains("timed out")
|
||||
|
||||
@@ -141,6 +141,86 @@ impl FromRef<AppState> for BackpressureState {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PuzzleApiState {
|
||||
root_state: AppState,
|
||||
spacetime_client: SpacetimeClient,
|
||||
puzzle_gallery_cache: PuzzleGalleryCache,
|
||||
oss_client: Option<OssClient>,
|
||||
auth_user_service: AuthUserService,
|
||||
llm_client: Option<LlmClient>,
|
||||
creative_agent_gpt5_client: Option<LlmClient>,
|
||||
creation_agent_llm_web_search_enabled: bool,
|
||||
vector_engine_image_request_timeout_ms: u64,
|
||||
}
|
||||
|
||||
impl PuzzleApiState {
|
||||
pub fn root_state(&self) -> &AppState {
|
||||
&self.root_state
|
||||
}
|
||||
|
||||
pub fn spacetime_client(&self) -> &SpacetimeClient {
|
||||
&self.spacetime_client
|
||||
}
|
||||
|
||||
pub fn puzzle_gallery_cache(&self) -> &PuzzleGalleryCache {
|
||||
&self.puzzle_gallery_cache
|
||||
}
|
||||
|
||||
pub fn oss_client(&self) -> Option<&OssClient> {
|
||||
self.oss_client.as_ref()
|
||||
}
|
||||
|
||||
pub fn auth_user_service(&self) -> &AuthUserService {
|
||||
&self.auth_user_service
|
||||
}
|
||||
|
||||
pub fn llm_client(&self) -> Option<&LlmClient> {
|
||||
self.llm_client.as_ref()
|
||||
}
|
||||
|
||||
pub fn creative_agent_gpt5_client(&self) -> Option<&LlmClient> {
|
||||
self.creative_agent_gpt5_client.as_ref()
|
||||
}
|
||||
|
||||
pub fn creation_agent_llm_web_search_enabled(&self) -> bool {
|
||||
self.creation_agent_llm_web_search_enabled
|
||||
}
|
||||
|
||||
pub fn vector_engine_image_request_timeout_ms(&self) -> u64 {
|
||||
self.vector_engine_image_request_timeout_ms
|
||||
}
|
||||
|
||||
pub fn vector_engine_base_url(&self) -> &str {
|
||||
self.root_state.config.vector_engine_base_url.as_str()
|
||||
}
|
||||
|
||||
pub fn vector_engine_api_key(&self) -> Option<&str> {
|
||||
self.root_state.config.vector_engine_api_key.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
impl FromRef<AppState> for PuzzleApiState {
|
||||
fn from_ref(state: &AppState) -> Self {
|
||||
// 中文注释:拼图路由只暴露本能力需要的依赖快照,避免 handler 直接看见完整 AppState。
|
||||
Self {
|
||||
root_state: state.clone(),
|
||||
spacetime_client: state.spacetime_client.clone(),
|
||||
puzzle_gallery_cache: state.puzzle_gallery_cache.clone(),
|
||||
oss_client: state.oss_client.clone(),
|
||||
auth_user_service: state.auth_user_service.clone(),
|
||||
llm_client: state.llm_client.clone(),
|
||||
creative_agent_gpt5_client: state.creative_agent_gpt5_client.clone(),
|
||||
creation_agent_llm_web_search_enabled: state
|
||||
.config
|
||||
.creation_agent_llm_web_search_enabled,
|
||||
vector_engine_image_request_timeout_ms: state
|
||||
.config
|
||||
.vector_engine_image_request_timeout_ms,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Axum/Hyper 会在路由树和连接 service 上频繁 clone state;AppState 外层必须保持浅拷贝。
|
||||
#[derive(Debug)]
|
||||
pub struct AppStateInner {
|
||||
@@ -1319,4 +1399,23 @@ mod tests {
|
||||
);
|
||||
assert!(client.config().official_fallback());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_api_state_exposes_puzzle_dependency_snapshot() {
|
||||
let mut config = AppConfig::default();
|
||||
config.creation_agent_llm_web_search_enabled = false;
|
||||
config.vector_engine_image_request_timeout_ms = 987_654;
|
||||
|
||||
let state = AppState::new(config).expect("state should build");
|
||||
let puzzle_state: PuzzleApiState = FromRef::from_ref(&state);
|
||||
|
||||
assert!(!puzzle_state.creation_agent_llm_web_search_enabled());
|
||||
assert_eq!(
|
||||
puzzle_state.vector_engine_image_request_timeout_ms(),
|
||||
987_654
|
||||
);
|
||||
assert!(puzzle_state.llm_client().is_none());
|
||||
assert!(puzzle_state.creative_agent_gpt5_client().is_none());
|
||||
assert!(puzzle_state.oss_client().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,6 +172,23 @@ pub(crate) fn update_tracking_outbox_pending_files(files: usize) {
|
||||
TRACKING_OUTBOX_PENDING_FILES.store(files.min(i64::MAX as usize) as i64, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub(crate) fn record_external_api_failure(
|
||||
provider: &'static str,
|
||||
failure_stage: &'static str,
|
||||
status_class: &'static str,
|
||||
retryable: bool,
|
||||
) {
|
||||
external_api_metrics().failures.add(
|
||||
1,
|
||||
&[
|
||||
KeyValue::new("provider", provider),
|
||||
KeyValue::new("failure_stage", failure_stage),
|
||||
KeyValue::new("status_class", status_class),
|
||||
KeyValue::new("retryable", retryable),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
fn track_response_body_in_flight(response: Response<Body>) -> Response<Body> {
|
||||
response.map(|body| {
|
||||
HTTP_RESPONSE_BODY_IN_FLIGHT.fetch_add(1, Ordering::Relaxed);
|
||||
@@ -211,6 +228,10 @@ struct TrackingOutboxMetrics {
|
||||
flushed_bytes: Counter<u64>,
|
||||
}
|
||||
|
||||
struct ExternalApiMetrics {
|
||||
failures: Counter<u64>,
|
||||
}
|
||||
|
||||
struct HttpRequestPermitsAvailableGauges {
|
||||
default: Arc<AtomicI64>,
|
||||
gallery: Arc<AtomicI64>,
|
||||
@@ -359,6 +380,21 @@ fn tracking_outbox_metrics() -> &'static TrackingOutboxMetrics {
|
||||
})
|
||||
}
|
||||
|
||||
fn external_api_metrics() -> &'static ExternalApiMetrics {
|
||||
static METRICS: std::sync::OnceLock<ExternalApiMetrics> = std::sync::OnceLock::new();
|
||||
METRICS.get_or_init(|| {
|
||||
let meter = global::meter("genarrative-api");
|
||||
ExternalApiMetrics {
|
||||
failures: meter
|
||||
.u64_counter("genarrative.external_api.failures")
|
||||
.with_description(
|
||||
"External API call failures grouped by provider and failure stage",
|
||||
)
|
||||
.build(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn register_http_request_permits_available_metric() -> HttpRequestPermitsAvailableGauges {
|
||||
let gauges = HttpRequestPermitsAvailableGauges::new();
|
||||
let meter = global::meter("genarrative-api");
|
||||
|
||||
@@ -584,6 +584,26 @@ async fn record_route_tracking_event_via_outbox_after_success(
|
||||
record_tracking_event_input_after_success(state, request_context, event).await;
|
||||
}
|
||||
|
||||
pub(crate) fn build_tracking_event_input(
|
||||
draft: TrackingEventDraft,
|
||||
) -> module_runtime::RuntimeTrackingEventInput {
|
||||
let occurred_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
|
||||
let event_id = build_tracking_event_id(&draft, occurred_at_micros);
|
||||
|
||||
module_runtime::RuntimeTrackingEventInput {
|
||||
event_id,
|
||||
event_key: draft.event_key.to_string(),
|
||||
scope_kind: draft.scope_kind,
|
||||
scope_id: draft.scope_id,
|
||||
user_id: draft.user_id,
|
||||
owner_user_id: draft.owner_user_id,
|
||||
profile_id: draft.profile_id,
|
||||
module_key: draft.module_key.map(str::to_string),
|
||||
metadata_json: draft.metadata.to_string(),
|
||||
occurred_at_micros: occurred_at_micros as i64,
|
||||
}
|
||||
}
|
||||
|
||||
async fn record_tracking_event_input_after_success(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
@@ -642,26 +662,6 @@ async fn record_tracking_event_input_after_success(
|
||||
}
|
||||
}
|
||||
|
||||
fn build_tracking_event_input(
|
||||
draft: TrackingEventDraft,
|
||||
) -> module_runtime::RuntimeTrackingEventInput {
|
||||
let occurred_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
|
||||
let event_id = build_tracking_event_id(&draft, occurred_at_micros);
|
||||
|
||||
module_runtime::RuntimeTrackingEventInput {
|
||||
event_id,
|
||||
event_key: draft.event_key.to_string(),
|
||||
scope_kind: draft.scope_kind,
|
||||
scope_id: draft.scope_id,
|
||||
user_id: draft.user_id,
|
||||
owner_user_id: draft.owner_user_id,
|
||||
profile_id: draft.profile_id,
|
||||
module_key: draft.module_key.map(str::to_string),
|
||||
metadata_json: draft.metadata.to_string(),
|
||||
occurred_at_micros: occurred_at_micros as i64,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_tracking_event_id(draft: &TrackingEventDraft, occurred_at_micros: i128) -> String {
|
||||
if draft.event_key == "daily_login"
|
||||
&& draft.scope_kind == RuntimeTrackingScopeKind::User
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use module_auth::AuthUser;
|
||||
|
||||
use crate::state::AppState;
|
||||
use crate::state::{AppState, PuzzleApiState};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct WorkAuthorSummary {
|
||||
@@ -14,6 +14,34 @@ pub fn resolve_work_author_by_user_id(
|
||||
owner_user_id: &str,
|
||||
fallback_display_name: Option<&str>,
|
||||
fallback_public_user_code: Option<&str>,
|
||||
) -> WorkAuthorSummary {
|
||||
resolve_work_author_by_user_id_with_service(
|
||||
state.auth_user_service(),
|
||||
owner_user_id,
|
||||
fallback_display_name,
|
||||
fallback_public_user_code,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn resolve_puzzle_work_author_by_user_id(
|
||||
state: &PuzzleApiState,
|
||||
owner_user_id: &str,
|
||||
fallback_display_name: Option<&str>,
|
||||
fallback_public_user_code: Option<&str>,
|
||||
) -> WorkAuthorSummary {
|
||||
resolve_work_author_by_user_id_with_service(
|
||||
state.auth_user_service(),
|
||||
owner_user_id,
|
||||
fallback_display_name,
|
||||
fallback_public_user_code,
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_work_author_by_user_id_with_service(
|
||||
auth_user_service: &module_auth::AuthUserService,
|
||||
owner_user_id: &str,
|
||||
fallback_display_name: Option<&str>,
|
||||
fallback_public_user_code: Option<&str>,
|
||||
) -> WorkAuthorSummary {
|
||||
let fallback_display_name =
|
||||
normalize_optional_text(fallback_display_name).unwrap_or_else(|| "玩家".to_string());
|
||||
@@ -26,7 +54,7 @@ pub fn resolve_work_author_by_user_id(
|
||||
};
|
||||
};
|
||||
|
||||
match state.auth_user_service().get_user_by_id(&owner_user_id) {
|
||||
match auth_user_service.get_user_by_id(&owner_user_id) {
|
||||
Ok(Some(user)) => map_auth_user_to_work_author_summary(user, fallback_display_name),
|
||||
Ok(None) | Err(_) => WorkAuthorSummary {
|
||||
display_name: fallback_display_name,
|
||||
|
||||
@@ -4,7 +4,7 @@ use serde_json::{Value, json};
|
||||
use crate::{
|
||||
auth::AuthenticatedAccessToken,
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
state::{AppState, PuzzleApiState},
|
||||
tracking::{TrackingEventDraft, record_tracking_event_after_success},
|
||||
};
|
||||
|
||||
@@ -68,6 +68,22 @@ pub(crate) async fn record_work_play_start_after_success(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
draft: WorkPlayTrackingDraft,
|
||||
) {
|
||||
record_work_play_start_input_after_success(state, request_context, draft).await;
|
||||
}
|
||||
|
||||
pub(crate) async fn record_puzzle_work_play_start_after_success(
|
||||
state: &PuzzleApiState,
|
||||
request_context: &RequestContext,
|
||||
draft: WorkPlayTrackingDraft,
|
||||
) {
|
||||
record_work_play_start_input_after_success(state.root_state(), request_context, draft).await;
|
||||
}
|
||||
|
||||
async fn record_work_play_start_input_after_success(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
draft: WorkPlayTrackingDraft,
|
||||
) {
|
||||
let mut metadata = json!({
|
||||
"operation": WORK_PLAY_START_EVENT_KEY,
|
||||
|
||||
Reference in New Issue
Block a user