@@ -1805,6 +1805,220 @@ async fn generate_match3d_material_sheet(
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn generate_match3d_rodin_model_asset(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
profile_id: &str,
|
||||
item_slug: &str,
|
||||
item_name: &str,
|
||||
config: &Match3DConfigJson,
|
||||
image_bytes: Vec<u8>,
|
||||
generated_at_micros: i64,
|
||||
) -> Result<Match3DRodinModelAsset, AppError> {
|
||||
let image_data_url = build_match3d_png_data_url(&image_bytes);
|
||||
let submit_response = submit_image_to_model(
|
||||
state,
|
||||
hyper3d_contract::Hyper3dImageToModelRequest {
|
||||
image_data_urls: vec![image_data_url],
|
||||
image_urls: Vec::new(),
|
||||
prompt: Some(build_match3d_rodin_model_prompt(config, item_name)),
|
||||
condition_mode: Some("concat".to_string()),
|
||||
seed: None,
|
||||
geometry_file_format: Some("glb".to_string()),
|
||||
material: Some("PBR".to_string()),
|
||||
quality: Some("medium".to_string()),
|
||||
mesh_mode: Some("Quad".to_string()),
|
||||
addons: Vec::new(),
|
||||
bbox_condition: None,
|
||||
preview_render: Some(true),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
wait_for_match3d_rodin_model(
|
||||
state,
|
||||
submit_response.subscription_key.as_str(),
|
||||
item_name,
|
||||
)
|
||||
.await?;
|
||||
let download_response = query_downloads(
|
||||
state,
|
||||
hyper3d_contract::Hyper3dDownloadRequest {
|
||||
task_uuid: submit_response.task_uuid.clone(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let model_file = select_match3d_glb_download(
|
||||
&download_response.files,
|
||||
submit_response.task_uuid.as_str(),
|
||||
item_name,
|
||||
)?;
|
||||
let downloaded_model = download_match3d_rodin_model(model_file).await?;
|
||||
let uploaded_model = persist_match3d_generated_bytes(
|
||||
state,
|
||||
owner_user_id,
|
||||
session_id,
|
||||
profile_id,
|
||||
&[
|
||||
"items",
|
||||
item_slug,
|
||||
"model",
|
||||
submit_response.task_uuid.as_str(),
|
||||
],
|
||||
downloaded_model.file_name.as_str(),
|
||||
downloaded_model.content_type.as_str(),
|
||||
downloaded_model.bytes,
|
||||
"match3d_item_model",
|
||||
Some(submit_response.task_uuid.as_str()),
|
||||
generated_at_micros,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Match3DRodinModelAsset {
|
||||
task_uuid: submit_response.task_uuid,
|
||||
subscription_key: submit_response.subscription_key,
|
||||
model_file_name: downloaded_model.file_name,
|
||||
upload: uploaded_model,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_match3d_png_data_url(image_bytes: &[u8]) -> String {
|
||||
format!(
|
||||
"data:image/png;base64,{}",
|
||||
BASE64_STANDARD.encode(image_bytes)
|
||||
)
|
||||
}
|
||||
|
||||
fn build_match3d_rodin_model_prompt(config: &Match3DConfigJson, item_name: &str) -> String {
|
||||
let style_clause = resolve_match3d_asset_style_prompt(config)
|
||||
.map(|prompt| format!("画风遵循:{prompt}。"))
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
"{theme}题材抓大鹅游戏物件:{item_name}。{style_clause}生成单个完整游戏 3D 模型,主体清晰,低面数,PBR 材质,适合移动端实时渲染,不要文字、底座、场景和额外物体。",
|
||||
theme = config.theme_text,
|
||||
style_clause = style_clause,
|
||||
)
|
||||
}
|
||||
|
||||
async fn wait_for_match3d_rodin_model(
|
||||
state: &AppState,
|
||||
subscription_key: &str,
|
||||
item_name: &str,
|
||||
) -> Result<(), AppError> {
|
||||
for attempt in 0..MATCH3D_RODIN_STATUS_MAX_ATTEMPTS {
|
||||
let status_response = query_task_status(
|
||||
state,
|
||||
hyper3d_contract::Hyper3dTaskStatusRequest {
|
||||
subscription_key: subscription_key.to_string(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
match status_response.status.as_str() {
|
||||
"done" => return Ok(()),
|
||||
"failed" => {
|
||||
let message = status_response
|
||||
.jobs
|
||||
.iter()
|
||||
.filter_map(|job| job.message.as_deref())
|
||||
.map(str::trim)
|
||||
.find(|value| !value.is_empty())
|
||||
.unwrap_or("Rodin 模型生成失败");
|
||||
return Err(match3d_bad_gateway(format!(
|
||||
"{item_name} 3D 模型生成失败:{message}"
|
||||
)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if attempt + 1 < MATCH3D_RODIN_STATUS_MAX_ATTEMPTS {
|
||||
tokio::time::sleep(Duration::from_millis(
|
||||
MATCH3D_RODIN_STATUS_POLL_INTERVAL_MS,
|
||||
))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
Err(match3d_bad_gateway(format!(
|
||||
"{item_name} 3D 模型生成超时,请稍后重试"
|
||||
)))
|
||||
}
|
||||
|
||||
fn select_match3d_glb_download<'a>(
|
||||
files: &'a [hyper3d_contract::Hyper3dDownloadFilePayload],
|
||||
task_uuid: &str,
|
||||
item_name: &str,
|
||||
) -> Result<&'a hyper3d_contract::Hyper3dDownloadFilePayload, AppError> {
|
||||
files
|
||||
.iter()
|
||||
.find(|file| {
|
||||
file.name.to_ascii_lowercase().ends_with(".glb")
|
||||
|| file.url.to_ascii_lowercase().split('?').next().unwrap_or("").ends_with(".glb")
|
||||
})
|
||||
.or_else(|| files.first())
|
||||
.ok_or_else(|| {
|
||||
match3d_bad_gateway(format!(
|
||||
"{item_name} 3D 模型已完成但未返回可下载模型文件:{task_uuid}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
async fn download_match3d_rodin_model(
|
||||
file: &hyper3d_contract::Hyper3dDownloadFilePayload,
|
||||
) -> Result<Match3DDownloadedModel, AppError> {
|
||||
let response = reqwest::Client::new()
|
||||
.get(file.url.as_str())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| match3d_bad_gateway(format!("下载 Rodin 模型失败:{error}")))?;
|
||||
let status = response.status();
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("model/gltf-binary")
|
||||
.to_string();
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|error| match3d_bad_gateway(format!("读取 Rodin 模型内容失败:{error}")))?;
|
||||
if !status.is_success() {
|
||||
return Err(match3d_bad_gateway(format!(
|
||||
"下载 Rodin 模型失败:HTTP {}",
|
||||
status.as_u16()
|
||||
)));
|
||||
}
|
||||
if bytes.is_empty() || bytes.len() > MATCH3D_RODIN_MAX_MODEL_BYTES {
|
||||
return Err(match3d_bad_gateway("Rodin 模型内容为空或超过大小上限"));
|
||||
}
|
||||
|
||||
Ok(Match3DDownloadedModel {
|
||||
bytes: bytes.to_vec(),
|
||||
file_name: normalize_match3d_model_file_name(file.name.as_str()),
|
||||
content_type: normalize_match3d_model_content_type(content_type.as_str()),
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_match3d_model_file_name(raw: &str) -> String {
|
||||
let trimmed = raw.trim().rsplit('/').next().unwrap_or(raw).trim();
|
||||
let without_query = trimmed.split('?').next().unwrap_or(trimmed).trim();
|
||||
let sanitized = sanitize_match3d_asset_segment(without_query, "model");
|
||||
if sanitized.to_ascii_lowercase().ends_with(".glb") {
|
||||
sanitized
|
||||
} else {
|
||||
"model.glb".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_match3d_model_content_type(raw: &str) -> String {
|
||||
let normalized = raw.split(';').next().unwrap_or(raw).trim().to_lowercase();
|
||||
if normalized == "model/gltf-binary" || normalized == "application/octet-stream" {
|
||||
return normalized;
|
||||
}
|
||||
"model/gltf-binary".to_string()
|
||||
}
|
||||
|
||||
fn build_match3d_material_sheet_prompt(
|
||||
config: &Match3DConfigJson,
|
||||
item_names: &[String],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
// This was generated using spacetimedb cli version 2.2.0 (commit eb11e2f5c41dce6979715ad407996270d61329f6).
|
||||
// This was generated using spacetimedb cli version 2.1.0 (commit 6981f48b4bc1a71c8dd9bdfe5a2c343f6370243d).
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
@@ -14,6 +14,7 @@ pub struct ProfileInviteCode {
|
||||
pub updated_at: __sdk::Timestamp,
|
||||
pub starts_at: Option<__sdk::Timestamp>,
|
||||
pub expires_at: Option<__sdk::Timestamp>,
|
||||
pub granted_user_tags: Vec<String>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for ProfileInviteCode {
|
||||
@@ -31,6 +32,7 @@ pub struct ProfileInviteCodeCols {
|
||||
pub updated_at: __sdk::__query_builder::Col<ProfileInviteCode, __sdk::Timestamp>,
|
||||
pub starts_at: __sdk::__query_builder::Col<ProfileInviteCode, Option<__sdk::Timestamp>>,
|
||||
pub expires_at: __sdk::__query_builder::Col<ProfileInviteCode, Option<__sdk::Timestamp>>,
|
||||
pub granted_user_tags: __sdk::__query_builder::Col<ProfileInviteCode, Vec<String>>,
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::HasCols for ProfileInviteCode {
|
||||
@@ -44,6 +46,7 @@ impl __sdk::__query_builder::HasCols for ProfileInviteCode {
|
||||
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
|
||||
starts_at: __sdk::__query_builder::Col::new(table_name, "starts_at"),
|
||||
expires_at: __sdk::__query_builder::Col::new(table_name, "expires_at"),
|
||||
granted_user_tags: __sdk::__query_builder::Col::new(table_name, "granted_user_tags"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait record_tracking_event_and_return {
|
||||
input: RuntimeTrackingEventInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeTrackingEventProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeTrackingEventProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl record_tracking_event_and_return for super::RemoteProcedures {
|
||||
input: RuntimeTrackingEventInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeTrackingEventProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeTrackingEventProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, RuntimeTrackingEventProcedureResult>(
|
||||
|
||||
@@ -10,6 +10,7 @@ pub struct RuntimeProfileInviteCodeAdminUpsertInput {
|
||||
pub admin_user_id: String,
|
||||
pub invite_code: String,
|
||||
pub metadata_json: String,
|
||||
pub granted_user_tags: Vec<String>,
|
||||
pub starts_at_micros: Option<i64>,
|
||||
pub expires_at_micros: Option<i64>,
|
||||
pub updated_at_micros: i64,
|
||||
|
||||
@@ -10,6 +10,7 @@ pub struct RuntimeProfileInviteCodeSnapshot {
|
||||
pub user_id: String,
|
||||
pub invite_code: String,
|
||||
pub metadata_json: String,
|
||||
pub granted_user_tags: Vec<String>,
|
||||
pub starts_at_micros: Option<i64>,
|
||||
pub expires_at_micros: Option<i64>,
|
||||
pub created_at_micros: i64,
|
||||
|
||||
@@ -20,6 +20,7 @@ pub struct UserAccount {
|
||||
pub password_hash: String,
|
||||
pub password_login_enabled: bool,
|
||||
pub token_version: u64,
|
||||
pub user_tags: Vec<String>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for UserAccount {
|
||||
@@ -43,6 +44,7 @@ pub struct UserAccountCols {
|
||||
pub password_hash: __sdk::__query_builder::Col<UserAccount, String>,
|
||||
pub password_login_enabled: __sdk::__query_builder::Col<UserAccount, bool>,
|
||||
pub token_version: __sdk::__query_builder::Col<UserAccount, u64>,
|
||||
pub user_tags: __sdk::__query_builder::Col<UserAccount, Vec<String>>,
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::HasCols for UserAccount {
|
||||
@@ -68,6 +70,7 @@ impl __sdk::__query_builder::HasCols for UserAccount {
|
||||
"password_login_enabled",
|
||||
),
|
||||
token_version: __sdk::__query_builder::Col::new(table_name, "token_version"),
|
||||
user_tags: __sdk::__query_builder::Col::new(table_name, "user_tags"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ pub struct UserAccount {
|
||||
pub(crate) password_hash: String,
|
||||
pub(crate) password_login_enabled: bool,
|
||||
pub(crate) token_version: u64,
|
||||
#[default(Vec::<String>::new())]
|
||||
pub(crate) user_tags: Vec<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -26,11 +26,14 @@ use module_puzzle::{
|
||||
tag_similarity_score,
|
||||
};
|
||||
use module_runtime::RuntimeProfileWalletLedgerSourceType;
|
||||
use module_runtime::visible_runtime_profile_user_tags;
|
||||
use serde_json::from_str as json_from_str;
|
||||
use serde_json::json;
|
||||
use serde_json::to_string as json_to_string;
|
||||
use spacetimedb::{ProcedureContext, SpacetimeType, Table, Timestamp, TxContext};
|
||||
|
||||
use crate::auth::user_account;
|
||||
|
||||
const PUZZLE_POINT_INCENTIVE_DEFAULT_U64: u64 = 0;
|
||||
|
||||
/// 拼图 Agent session 真相表。
|
||||
|
||||
@@ -191,7 +191,6 @@ pub struct ProfileInviteCode {
|
||||
pub(crate) starts_at: Option<Timestamp>,
|
||||
#[default(None::<Timestamp>)]
|
||||
pub(crate) expires_at: Option<Timestamp>,
|
||||
#[default(Vec::<String>::new())]
|
||||
pub(crate) granted_user_tags: Vec<String>,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user