1
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-05-10 22:28:47 +08:00
parent d0a9348e72
commit 85ed8ca90c
14 changed files with 315 additions and 53 deletions

View File

@@ -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],

View File

@@ -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};

View File

@@ -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"),
}
}
}

View File

@@ -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>(

View File

@@ -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,

View File

@@ -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,

View File

@@ -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"),
}
}
}

View File

@@ -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>,
}

View File

@@ -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 真相表。

View File

@@ -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>,
}