diff --git a/server-rs/crates/api-server/src/editor_generation_model3d_records.rs b/server-rs/crates/api-server/src/editor_generation_model3d_records.rs new file mode 100644 index 000000000..d47a4cf9e --- /dev/null +++ b/server-rs/crates/api-server/src/editor_generation_model3d_records.rs @@ -0,0 +1,386 @@ +//! 3D 定价段在「配置文件结构」与「SpacetimeDB 记录结构」之间的转换。 +//! +//! 记录结构按展平风格存键值行:版本价一行含两档价,加价项一行一条,键都是字符串。 +//! 写入方向严格——写之前先按契约强校验,缺键与契约外的键都不允许落库; +//! 读取方向做契约归一化——老记录缺的键从本地配置同段补齐,契约之外的遗留键剔除并告警, +//! 归一化后仍不合法才判配置非法。 + +use std::collections::BTreeMap; + +use serde::{Serialize, de::DeserializeOwned}; +use serde_json::Value; +use shared_contracts::model3d::wire_str; +use spacetime_client::{ + EditorGenerationModel3dAddOnPriceRecord, EditorGenerationModel3dPricingRecord, + EditorGenerationModel3dVersionPriceRecord, +}; +use tracing::warn; + +use crate::editor_generation_config::EditorGenerationPricingError; +use shared_contracts::model3d::common::Model3dModelVersion; + +use crate::tripo3d::pricing::{ + Model3dAddOn, Model3dBasePrice, Model3dEndpoint, Model3dEndpointPricing, Model3dPricingConfig, +}; + +/// 3D 定价在记录形状里的两段:每个端点一段,段内是版本价行与加价项行。 +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct EditorGenerationModel3dSections { + pub text_to_model_pricing: Option, + pub image_to_model_pricing: Option, +} + +/// 配置 → 记录。段整体缺失时两列都写空,代表 3D 定价未配置(提交 fail closed)。 +pub(crate) fn model3d_sections_from_config( + config: Option<&Model3dPricingConfig>, +) -> Result { + let Some(config) = config else { + return Ok(EditorGenerationModel3dSections::default()); + }; + config.validate().map_err(|message| { + EditorGenerationPricingError::Invalid(format!("model3d 定价:{message}")) + })?; + Ok(EditorGenerationModel3dSections { + text_to_model_pricing: Some(record_from_endpoint_pricing( + Model3dEndpoint::TextToModel, + &config.text_to_model_pricing, + )?), + image_to_model_pricing: Some(record_from_endpoint_pricing( + Model3dEndpoint::ImageToModel, + &config.image_to_model_pricing, + )?), + }) +} + +/// 记录 → 配置,并按契约归一化。 +/// +/// `fallback` 是本地配置(受控默认 JSON / 覆盖文件)里的 3D 段,只承担两件事: +/// 补齐记录里缺的键(含整段缺失的老记录),以及在没有表数据时决定「3D 是否已配置」。 +pub(crate) fn model3d_config_from_sections( + sections: EditorGenerationModel3dSections, + fallback: Option<&Model3dPricingConfig>, +) -> Result, EditorGenerationPricingError> { + let text_to_model_pricing = endpoint_pricing_from_record( + Model3dEndpoint::TextToModel, + sections.text_to_model_pricing, + fallback.map(|config| &config.text_to_model_pricing), + )?; + let image_to_model_pricing = endpoint_pricing_from_record( + Model3dEndpoint::ImageToModel, + sections.image_to_model_pricing, + fallback.map(|config| &config.image_to_model_pricing), + )?; + match (text_to_model_pricing, image_to_model_pricing) { + (None, None) => Ok(None), + (Some(text_to_model_pricing), Some(image_to_model_pricing)) => { + Ok(Some(Model3dPricingConfig { + text_to_model_pricing, + image_to_model_pricing, + })) + } + // 单段缺失说明写入侧或归一化出了问题:不能拿一半的定价去收费。 + (text, image) => Err(EditorGenerationPricingError::Invalid(format!( + "SpacetimeDB 模型定价配置 3D 段只配了一半:text_to_model_pricing={} image_to_model_pricing={}", + text.is_some(), + image.is_some() + ))), + } +} + +fn record_from_endpoint_pricing( + endpoint: Model3dEndpoint, + pricing: &Model3dEndpointPricing, +) -> Result { + let version_prices = pricing + .version_prices + .iter() + .map(|(model_version, base)| { + Ok(EditorGenerationModel3dVersionPriceRecord { + model_version: contract_key(model_version, endpoint, "模型版本")?, + no_texture: base.no_texture, + texture: base.texture, + }) + }) + .collect::, EditorGenerationPricingError>>()?; + let add_on_prices = pricing + .add_on_prices + .iter() + .map(|(add_on, price)| { + Ok(EditorGenerationModel3dAddOnPriceRecord { + add_on: contract_key(add_on, endpoint, "加价项")?, + price: *price, + }) + }) + .collect::, EditorGenerationPricingError>>()?; + Ok(EditorGenerationModel3dPricingRecord { + version_prices, + add_on_prices, + }) +} + +fn endpoint_pricing_from_record( + endpoint: Model3dEndpoint, + record: Option, + fallback: Option<&Model3dEndpointPricing>, +) -> Result, EditorGenerationPricingError> { + // 记录整段缺失(老行、或迁移前从未配过 3D)时直接用本地配置:这才是「覆盖文件只做 + // 种子与兜底」的落点,缺键补齐与整段兜底走同一条路径。 + let Some(record) = record else { + return Ok(fallback.cloned()); + }; + + let mut version_prices = BTreeMap::new(); + for entry in record.version_prices { + let Some(model_version) = + parse_contract_key::(&entry.model_version, endpoint, "模型版本") + else { + continue; + }; + if version_prices + .insert( + model_version, + Model3dBasePrice { + no_texture: entry.no_texture, + texture: entry.texture, + }, + ) + .is_some() + { + return Err(EditorGenerationPricingError::Invalid(format!( + "SpacetimeDB 模型定价配置 3D 段重复模型版本:endpoint={} model={}", + endpoint.as_str(), + entry.model_version + ))); + } + } + let mut add_on_prices = BTreeMap::new(); + for entry in record.add_on_prices { + let Some(add_on) = parse_contract_key::(&entry.add_on, endpoint, "加价项") + else { + continue; + }; + if add_on_prices.insert(add_on, entry.price).is_some() { + return Err(EditorGenerationPricingError::Invalid(format!( + "SpacetimeDB 模型定价配置 3D 段重复加价项:endpoint={} add_on={}", + endpoint.as_str(), + entry.add_on + ))); + } + } + + // 记录值优先,本地配置补缺:发布新增模型版本后,表里还没有该键时按本地受控价补齐。 + let mut merged = fallback.cloned().unwrap_or_default(); + merged.version_prices.extend(version_prices); + merged.add_on_prices.extend(add_on_prices); + merged.validate(endpoint).map_err(|message| { + EditorGenerationPricingError::Invalid(format!("SpacetimeDB 模型定价配置 {message}")) + })?; + Ok(Some(merged)) +} + +/// 记录里的键是字符串,契约之外的遗留键(例如版本下线后表里的旧键)在这里剔除并告警。 +fn parse_contract_key( + key: &str, + endpoint: Model3dEndpoint, + kind: &str, +) -> Option { + match serde_json::from_value::(Value::String(key.to_string())) { + Ok(value) => Some(value), + Err(_) => { + warn!( + endpoint = endpoint.as_str(), + kind, key, "SpacetimeDB 模型定价配置 3D 段出现契约之外的键,已剔除" + ); + None + } + } +} + +fn contract_key( + value: &T, + endpoint: Model3dEndpoint, + kind: &str, +) -> Result { + match wire_str(value) { + Ok(key) => Ok(key), + Err(error) => Err(EditorGenerationPricingError::Invalid(format!( + "3D 定价段 endpoint={} 的{kind}无法写成线上取值:{error}", + endpoint.as_str() + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tripo3d::pricing::{Model3dBasePrice, Model3dEndpointPricing}; + + fn endpoint_pricing(scale: u32) -> Model3dEndpointPricing { + Model3dEndpointPricing { + version_prices: Model3dModelVersion::ALL + .iter() + .map(|version| { + ( + *version, + Model3dBasePrice { + no_texture: scale, + texture: scale + 1, + }, + ) + }) + .collect(), + add_on_prices: Model3dAddOn::ALL + .iter() + .map(|add_on| (*add_on, scale)) + .collect(), + } + } + + fn config() -> Model3dPricingConfig { + Model3dPricingConfig { + text_to_model_pricing: endpoint_pricing(10), + image_to_model_pricing: endpoint_pricing(20), + } + } + + #[test] + fn not_configured_sections_stay_not_configured() { + let sections = model3d_sections_from_config(None).expect("段缺失应能写成两列空值"); + assert_eq!(sections, EditorGenerationModel3dSections::default()); + assert_eq!( + model3d_config_from_sections(sections, None).expect("两段都空应可读"), + None + ); + } + + #[test] + fn both_endpoints_round_trip_without_local_fallback() { + let expected = config(); + let sections = model3d_sections_from_config(Some(&expected)).expect("完整配置应能写成记录"); + + assert_eq!( + model3d_config_from_sections(sections, None).expect("完整记录应可读"), + Some(expected) + ); + } + + #[test] + fn missing_keys_and_missing_sections_are_backfilled_from_local_config() { + let fallback = config(); + let mut sections = + model3d_sections_from_config(Some(&fallback)).expect("完整配置应能写成记录"); + sections + .text_to_model_pricing + .as_mut() + .expect("文生段应存在") + .version_prices + .retain(|entry| entry.model_version != "P2-20260801"); + sections.image_to_model_pricing = None; + + assert_eq!( + model3d_config_from_sections(sections, Some(&fallback)) + .expect("缺键与缺段都应从本地配置补齐"), + Some(fallback) + ); + } + + #[test] + fn out_of_contract_keys_are_dropped() { + let fallback = config(); + let expected = fallback.clone(); + let mut sections = + model3d_sections_from_config(Some(&fallback)).expect("完整配置应能写成记录"); + let text = sections + .text_to_model_pricing + .as_mut() + .expect("文生段应存在"); + text.version_prices + .push(EditorGenerationModel3dVersionPriceRecord { + model_version: "v9.9-29991231".to_string(), + no_texture: 1, + texture: 1, + }); + text.add_on_prices + .push(EditorGenerationModel3dAddOnPriceRecord { + add_on: "teleport".to_string(), + price: 1, + }); + + assert_eq!( + model3d_config_from_sections(sections, Some(&fallback)).expect("契约外键应被剔除"), + Some(expected) + ); + } + + #[test] + fn missing_key_without_local_value_fails_closed() { + let fallback = config(); + let mut sections = + model3d_sections_from_config(Some(&fallback)).expect("完整配置应能写成记录"); + sections + .text_to_model_pricing + .as_mut() + .expect("文生段应存在") + .version_prices + .retain(|entry| entry.model_version != "P2-20260801"); + let mut incomplete_fallback = fallback.clone(); + incomplete_fallback + .text_to_model_pricing + .version_prices + .remove(&Model3dModelVersion::P2); + + let error = model3d_config_from_sections(sections, Some(&incomplete_fallback)) + .expect_err("记录与本地配置都缺键时不能放行"); + assert!( + error.to_string().contains("缺少"), + "报错应说明缺键,实际为:{error}" + ); + } + + #[test] + fn half_configured_sections_are_rejected() { + let mut sections = + model3d_sections_from_config(Some(&config())).expect("完整配置应能写成记录"); + sections.image_to_model_pricing = None; + + let error = model3d_config_from_sections(sections, None).expect_err("只配一段时不能放行"); + assert!( + error.to_string().contains("只配了一半"), + "报错应说明只配了一半,实际为:{error}" + ); + } + + #[test] + fn duplicate_keys_are_rejected() { + let mut sections = + model3d_sections_from_config(Some(&config())).expect("完整配置应能写成记录"); + let text = sections + .text_to_model_pricing + .as_mut() + .expect("文生段应存在"); + let duplicate = text.version_prices[0].clone(); + text.version_prices.push(duplicate); + + let error = model3d_config_from_sections(sections, None).expect_err("重复键应被拒绝"); + assert!( + error.to_string().contains("重复模型版本"), + "报错应说明重复,实际为:{error}" + ); + } + + #[test] + fn writing_requires_the_complete_contract_key_set() { + let mut incomplete = config(); + incomplete + .image_to_model_pricing + .add_on_prices + .remove(&Model3dAddOn::QuadMesh); + + let error = model3d_sections_from_config(Some(&incomplete)) + .expect_err("写入方向不做宽松:缺键不能落库"); + assert!( + error.to_string().contains("add-on"), + "报错应说明缺 add-on,实际为:{error}" + ); + } +} diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 0e832e92a..7641e19e7 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -27,6 +27,7 @@ mod custom_world_asset_prompts; mod editor_agent; mod editor_background_music_prompt_assist; mod editor_generation_config; +mod editor_generation_model3d_records; mod editor_generation_queue; mod editor_green_screen; mod editor_project; diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index c16e77cfd..82a70ee5c 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -45,6 +45,9 @@ use crate::editor_generation_config::{ EditorGenerationPricingConfig, EditorGenerationPricingError, EditorGenerationPricingStore, EditorGenerationPricingUnit, }; +use crate::editor_generation_model3d_records::{ + EditorGenerationModel3dSections, model3d_config_from_sections, model3d_sections_from_config, +}; use crate::tracking_outbox::TrackingOutbox; use crate::wallet_refund_outbox::{ProfileWalletRefundOutboxWorker, WalletRefundOutbox}; use crate::wechat::pay::{build_wechat_pay_config, map_wechat_pay_init_error}; @@ -425,13 +428,17 @@ pub enum AppStateInitError { Llm(LlmError), } +/// 定价配置落库用的完整记录内容:`models` 段与 3D 两段同事务写入。 +struct EditorGenerationPricingRecordInput { + models: Vec, + model3d: EditorGenerationModel3dSections, +} + fn editor_generation_pricing_to_records( config: &EditorGenerationPricingConfig, -) -> Result, EditorGenerationPricingError> { +) -> Result { config.validate()?; - // 只序列化 `models`:3D 定价段不在记录形状里,由文件配置提供,见 - // editor_generation_pricing_from_record 的说明。 - Ok(config + let models = config .models .iter() .map(|(model, pricing)| EditorGenerationModelPricingRecord { @@ -451,7 +458,11 @@ fn editor_generation_pricing_to_records( }) .collect(), }) - .collect()) + .collect(); + Ok(EditorGenerationPricingRecordInput { + models, + model3d: model3d_sections_from_config(config.model3d.as_ref())?, + }) } fn editor_generation_pricing_from_record( @@ -508,15 +519,16 @@ fn editor_generation_pricing_from_record( })?; models.insert(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS.to_string(), pricing); } - // TODO Tripo 3D 定价:后台记录形状还只有 `模型 → 档位 → 单价`,表达不了 - // endpoint × 模型版本 × 是否有贴图的底价与 add-on 叠加,因此 3D 段暂由文件配置提供, - // 后台 payload 的 `model3d` 可空:省略 / null 一律沿用当前值(见保存路径的 - // `with_previous_model3d`),后台改价不会覆盖它; - // 等记录形状补齐后再改为从 SpacetimeDB 读取。 - let config = EditorGenerationPricingConfig { - models, - model3d: legacy_fallback.model3d.clone(), - }; + // 3D 两段以 SpacetimeDB 为准;记录里缺键(含整段缺失的老行)用本地配置同段补齐, + // 契约之外的遗留键剔除。本地配置在这里只承担种子与兜底,不再是 3D 的权威。 + let model3d = model3d_config_from_sections( + EditorGenerationModel3dSections { + text_to_model_pricing: record.text_to_model_pricing, + image_to_model_pricing: record.image_to_model_pricing, + }, + legacy_fallback.model3d.as_ref(), + )?; + let config = EditorGenerationPricingConfig { models, model3d }; config.validate()?; Ok(config) } @@ -524,20 +536,19 @@ fn editor_generation_pricing_from_record( fn editor_generation_pricing_upsert_input( config: &AppConfig, admin_user_id: String, - models: Vec, + records: EditorGenerationPricingRecordInput, updated_at_micros: i64, ) -> EditorGenerationPricingConfigUpsertRecordInput { EditorGenerationPricingConfigUpsertRecordInput { admin_user_id, - models, + models: records.models, updated_at_micros, bootstrap_secret: config .spacetime_runtime_service_bootstrap_secret .clone() .unwrap_or_default(), - // 3D 两段列在后续步骤接入真实值,当前保存保持空值,读取仍走本地文件缓存。 - text_to_model_pricing: None, - image_to_model_pricing: None, + text_to_model_pricing: records.model3d.text_to_model_pricing, + image_to_model_pricing: records.model3d.image_to_model_pricing, } } @@ -816,13 +827,13 @@ impl AppState { #[cfg(not(test))] { - let models = editor_generation_pricing_to_records(&next)?; + let records = editor_generation_pricing_to_records(&next)?; let record = self .spacetime_client .upsert_editor_generation_pricing_config(editor_generation_pricing_upsert_input( &self.config, admin_user_id, - models, + records, crate::editor_project::current_utc_micros(), )) .await @@ -846,14 +857,14 @@ impl AppState { &self, ) -> Result { let fallback = self.editor_generation_pricing_store.snapshot()?; - let models = editor_generation_pricing_to_records(&fallback)?; + let records = editor_generation_pricing_to_records(&fallback)?; let record = self .spacetime_client .initialize_editor_generation_pricing_config_if_missing( editor_generation_pricing_upsert_input( &self.config, "system:editor-generation-pricing".to_string(), - models, + records, crate::editor_project::current_utc_micros(), ), ) @@ -2170,14 +2181,14 @@ async fn initialize_editor_generation_runtime_service_identity_for_startup( let fallback = pricing_store .snapshot() .map_err(|error| AppStateInitError::DependencyUnavailable(error.to_string()))?; - let models = editor_generation_pricing_to_records(&fallback) + let records = editor_generation_pricing_to_records(&fallback) .map_err(|error| AppStateInitError::DependencyUnavailable(error.to_string()))?; spacetime_client .initialize_editor_generation_pricing_config_if_missing( editor_generation_pricing_upsert_input( config, "system:editor-generation-pricing".to_string(), - models, + records, crate::editor_project::current_utc_micros(), ), ) @@ -3005,6 +3016,13 @@ mod tests { ); } + fn empty_pricing_record_input() -> EditorGenerationPricingRecordInput { + EditorGenerationPricingRecordInput { + models: Vec::new(), + model3d: EditorGenerationModel3dSections::default(), + } + } + #[test] fn editor_generation_pricing_typed_record_round_trips() { let expected = crate::editor_generation_config::parse_editor_generation_pricing_json( @@ -3012,15 +3030,16 @@ mod tests { "test default pricing", ) .expect("default pricing should parse"); + let records = + editor_generation_pricing_to_records(&expected).expect("pricing should map to records"); let record = EditorGenerationPricingConfigRecord { config_id: "global".to_string(), - models: editor_generation_pricing_to_records(&expected) - .expect("pricing should map to records"), + models: records.models, updated_by_admin_user_id: Some("admin:test".to_string()), updated_at: "2026-07-10T00:00:00Z".to_string(), updated_at_micros: 1, - text_to_model_pricing: None, - image_to_model_pricing: None, + text_to_model_pricing: records.model3d.text_to_model_pricing, + image_to_model_pricing: records.model3d.image_to_model_pricing, }; let actual = editor_generation_pricing_from_record(record, &expected) @@ -3036,17 +3055,19 @@ mod tests { "test default pricing", ) .expect("default pricing should parse"); - let mut models = + let mut records = editor_generation_pricing_to_records(&fallback).expect("pricing should map to records"); - models.retain(|pricing| pricing.model != EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS); + records + .models + .retain(|pricing| pricing.model != EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS); let record = EditorGenerationPricingConfigRecord { config_id: "global".to_string(), - models, + models: records.models, updated_by_admin_user_id: Some("admin:test".to_string()), updated_at: "2026-08-07T00:00:00Z".to_string(), updated_at_micros: 1, - text_to_model_pricing: None, - image_to_model_pricing: None, + text_to_model_pricing: records.model3d.text_to_model_pricing, + image_to_model_pricing: records.model3d.image_to_model_pricing, }; let actual = editor_generation_pricing_from_record(record, &fallback) @@ -3064,7 +3085,7 @@ mod tests { let without_secret = editor_generation_pricing_upsert_input( &config, "admin:test".to_string(), - Vec::new(), + empty_pricing_record_input(), 123, ); assert_eq!(without_secret.bootstrap_secret, ""); @@ -3073,7 +3094,7 @@ mod tests { let with_secret = editor_generation_pricing_upsert_input( &config, "admin:test".to_string(), - Vec::new(), + empty_pricing_record_input(), 123, ); @@ -3089,17 +3110,18 @@ mod tests { "test default pricing", ) .expect("default pricing should parse"); - let mut models = + let mut records = editor_generation_pricing_to_records(&config).expect("pricing should map to records"); - models.push(models[0].clone()); + let duplicate = records.models[0].clone(); + records.models.push(duplicate); let record = EditorGenerationPricingConfigRecord { config_id: "global".to_string(), - models, + models: records.models, updated_by_admin_user_id: None, updated_at: "2026-07-10T00:00:00Z".to_string(), updated_at_micros: 1, - text_to_model_pricing: None, - image_to_model_pricing: None, + text_to_model_pricing: records.model3d.text_to_model_pricing, + image_to_model_pricing: records.model3d.image_to_model_pricing, }; let error = editor_generation_pricing_from_record(record, &config) diff --git a/server-rs/crates/api-server/src/tripo3d/pricing.rs b/server-rs/crates/api-server/src/tripo3d/pricing.rs index 54a674b66..c8326e751 100644 --- a/server-rs/crates/api-server/src/tripo3d/pricing.rs +++ b/server-rs/crates/api-server/src/tripo3d/pricing.rs @@ -56,7 +56,7 @@ impl Model3dAddOn { } /// 同一模型版本在「不带贴图 / 带贴图」两种形态下的底价。 -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct Model3dBasePrice { pub no_texture: u32, @@ -76,7 +76,7 @@ impl Model3dBasePrice { /// 单个端点的整套 3D 定价:模型版本底价 + 该端点自己的加价项价目。 /// /// 两段加价项当前数值相同,仍各持一份:Tripo 若对某个端点差异化加价,只改那一段。 -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct Model3dEndpointPricing { pub version_prices: BTreeMap, @@ -284,7 +284,7 @@ fn overflow_error() -> String { } impl Model3dEndpointPricing { - fn validate(&self, endpoint: Model3dEndpoint) -> Result<(), String> { + pub(crate) fn validate(&self, endpoint: Model3dEndpoint) -> Result<(), String> { for model_version in Model3dModelVersion::ALL.iter().copied() { if !self.version_prices.contains_key(&model_version) { return Err(format!( diff --git a/server-rs/crates/spacetime-client/src/active/mapper.rs b/server-rs/crates/spacetime-client/src/active/mapper.rs index 8e2480e14..ac278bee7 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper.rs @@ -54,13 +54,15 @@ pub use self::editor_project::{ EditorAssetFolderUpdateRecordInput, EditorAssetGroupCohortCompleteRecordInput, EditorAssetGroupSourceLookupRecordInput, EditorAssetLibraryRecord, EditorAssetMediaRepairRecordInput, EditorAssetRecord, EditorAssetUpdateRecordInput, - EditorCanvasRecord, EditorCanvasViewportRecord, EditorGenerationModelPricingRecord, - EditorGenerationPricingConfigRecord, EditorGenerationPricingConfigUpsertRecordInput, - EditorGenerationPricingTierRecord, EditorProjectCreateRecordInput, - EditorProjectDeleteRecordInput, EditorProjectGetRecordInput, EditorProjectLayoutSaveAckRecord, - EditorProjectLayoutSaveRecordInput, EditorProjectLayoutSaveV2AckRecord, - EditorProjectLayoutSaveV2RecordInput, EditorProjectRecord, EditorProjectRenameRecordInput, - EditorProjectResourceCreateRecordInput, EditorProjectResourceMediaRepairRecordInput, + EditorCanvasRecord, EditorCanvasViewportRecord, EditorGenerationModel3dAddOnPriceRecord, + EditorGenerationModel3dPricingRecord, EditorGenerationModel3dVersionPriceRecord, + EditorGenerationModelPricingRecord, EditorGenerationPricingConfigRecord, + EditorGenerationPricingConfigUpsertRecordInput, EditorGenerationPricingTierRecord, + EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput, EditorProjectGetRecordInput, + EditorProjectLayoutSaveAckRecord, EditorProjectLayoutSaveRecordInput, + EditorProjectLayoutSaveV2AckRecord, EditorProjectLayoutSaveV2RecordInput, EditorProjectRecord, + EditorProjectRenameRecordInput, EditorProjectResourceCreateRecordInput, + EditorProjectResourceMediaRepairRecordInput, EditorProjectResourcePublicShowcaseListRecordInput, EditorProjectResourceRecord, EditorProjectResourceShowcaseUpdateRecordInput, EditorReferenceRecord, EditorReferenceResolveRecordInput, EditorShowcaseAssetAdminListRecordInput,