ee4a9434fa
- shared-contracts 新增定价版本冲突标记,module 与 api-server 共用一处声明 - SpacetimeDB upsert input 增加期望版本,事务内比对不一致即整笔拒绝(种子写入显式不比对) - 定价 store 记录当前版本,读取路径带出 updated_at 微秒值 - 后台 GET 返回 models / 3D 两段 / updatedAtMicros,POST 要求两段显式给出且带期望版本 - 缺 3D 段 400、版本不匹配 409,退役 with_previous_model3d 与「省略即沿用」用例 - 绑定按 input 新字段重新生成,模块补乐观锁比对用例
1426 lines
51 KiB
Rust
1426 lines
51 KiB
Rust
use std::{
|
||
collections::BTreeMap,
|
||
fmt, fs, io,
|
||
path::{Path, PathBuf},
|
||
sync::{
|
||
Arc, RwLock,
|
||
atomic::{AtomicI64, Ordering},
|
||
},
|
||
};
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
use tracing::warn;
|
||
|
||
use crate::asset_billing::current_external_generation_billing_price_mud_points;
|
||
use crate::tripo3d::pricing::{
|
||
Model3dPricingConfig, Model3dPricingError, Model3dPricingPublicView, Model3dPricingQuery,
|
||
};
|
||
|
||
/// 图片画布编辑器生成类能力的泥点配置。
|
||
///
|
||
/// 中文注释:默认值来自独立 JSON 文件;后台修改写入 SpacetimeDB 全局配置表。
|
||
/// 生成扣费校验必须通过本模块读取运行时快照,避免前端展示价格成为真相源。
|
||
pub(crate) const EDITOR_GENERATION_PRICING_DEFAULT_JSON: &str =
|
||
include_str!("../config/editor-generation-pricing.default.json");
|
||
|
||
const EDITOR_IMAGE_MODEL_GPT_IMAGE_2: &str = "gpt-image-2";
|
||
const EDITOR_IMAGE_MODEL_NANOBANANA2: &str = "gemini-3.1-flash-image-preview";
|
||
const EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS: &str = "nanobanana2";
|
||
const EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS: &str = "nano-banana";
|
||
|
||
const EDITOR_VIDEO_MODEL_SEEDANCE_2: &str = "seedance2.0";
|
||
const EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST: &str = "seedance2.0-fast";
|
||
const EDITOR_VIDEO_MODEL_KLING_3: &str = "kling3.0";
|
||
const EDITOR_VIDEO_MODEL_KLING_3_OMNI: &str = "kling3.0-omni";
|
||
const EDITOR_VIDEO_MODEL_VEO_3_1: &str = "veo3.1";
|
||
const EDITOR_VIDEO_MODEL_VEO_3_1_FAST: &str = "veo3.1-fast";
|
||
pub(crate) const EDITOR_SOUND_EFFECT_MODEL_VIDU: &str = "audio1.0";
|
||
pub(crate) const EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS: &str =
|
||
shared_contracts::assets::EDITOR_SOUND_EFFECT_MODEL;
|
||
pub(crate) const EDITOR_BACKGROUND_MUSIC_MODEL_SUNO: &str = "chirp-v5";
|
||
|
||
const IMAGE_PRICE_SIZE_0_5K: &str = "0.5K";
|
||
const IMAGE_PRICE_SIZE_1K: &str = "1K";
|
||
const IMAGE_PRICE_SIZE_2K: &str = "2K";
|
||
const DEFAULT_IMAGE_PRICE_SIZE: &str = IMAGE_PRICE_SIZE_1K;
|
||
const SPEC_IMAGE_PRICE_SIZE: &str = IMAGE_PRICE_SIZE_2K;
|
||
const DEFAULT_VIDEO_RESOLUTION: &str = "480p";
|
||
|
||
const REQUIRED_NANOBANANA_IMAGE_SIZES: &[&str] = &[
|
||
IMAGE_PRICE_SIZE_0_5K,
|
||
IMAGE_PRICE_SIZE_1K,
|
||
IMAGE_PRICE_SIZE_2K,
|
||
];
|
||
const REQUIRED_GPT_IMAGE_SIZES: &[&str] = &[IMAGE_PRICE_SIZE_1K, IMAGE_PRICE_SIZE_2K];
|
||
const REQUIRED_VIDEO_MODELS: &[&str] = &[
|
||
EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST,
|
||
EDITOR_VIDEO_MODEL_SEEDANCE_2,
|
||
EDITOR_VIDEO_MODEL_KLING_3,
|
||
EDITOR_VIDEO_MODEL_KLING_3_OMNI,
|
||
EDITOR_VIDEO_MODEL_VEO_3_1,
|
||
EDITOR_VIDEO_MODEL_VEO_3_1_FAST,
|
||
];
|
||
const REQUIRED_VIDEO_RESOLUTIONS: &[&str] = &["480p", "720p", "1080p"];
|
||
|
||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub(crate) enum EditorGenerationPricingUnit {
|
||
PerGeneration,
|
||
PerSecond,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub(crate) struct EditorGenerationModelPricing {
|
||
pub unit: EditorGenerationPricingUnit,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub price: Option<u32>,
|
||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||
pub prices: BTreeMap<String, u32>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub(crate) struct EditorGenerationPricingConfig {
|
||
pub models: BTreeMap<String, EditorGenerationModelPricing>,
|
||
/// Tripo 3D 生成定价。业务给出泥点数值之前允许缺失,
|
||
/// 缺失即代表 3D 定价未配置:提交被拒绝,不扣费也不调用 provider。
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub model3d: Option<Model3dPricingConfig>,
|
||
}
|
||
|
||
/// 主站公开读模型的响应体:`models` 原样透出,3D 段投影回迁移前的旧形状
|
||
/// ([`Model3dPricingPublicView`]),让画布 3D 入口不需要随本次迁移改动。
|
||
#[derive(Clone, Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub(crate) struct EditorGenerationPricingPublicResponse {
|
||
pub models: BTreeMap<String, EditorGenerationModelPricing>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub model3d: Option<Model3dPricingPublicView>,
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
pub(crate) struct EditorGenerationPricingStore {
|
||
current: Arc<RwLock<EditorGenerationPricingConfig>>,
|
||
/// 当前配置的定价版本:SpacetimeDB 行的 `updated_at` 微秒值。0 表示还没读到过行
|
||
/// (表为空或读不到),后台保存的乐观锁就比对它。
|
||
version_micros: Arc<AtomicI64>,
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
pub(crate) enum EditorGenerationPricingError {
|
||
Io(io::Error),
|
||
Json(serde_json::Error),
|
||
Invalid(String),
|
||
/// 3D 定价查询失败:结构化保留「未配置 / 缺底价 / 缺加价项」三种语义。
|
||
Model3d(Model3dPricingError),
|
||
/// 保存时发现定价已被他人改动:后台必须重新读取后再保存,不能静默覆盖。
|
||
Conflict,
|
||
#[cfg_attr(test, allow(dead_code))]
|
||
Persistence(String),
|
||
LockPoisoned,
|
||
}
|
||
|
||
impl From<Model3dPricingError> for EditorGenerationPricingError {
|
||
fn from(error: Model3dPricingError) -> Self {
|
||
Self::Model3d(error)
|
||
}
|
||
}
|
||
|
||
impl EditorGenerationPricingConfig {
|
||
/// 公开读模型视图:字段名与嵌套形状保持迁移前一致,见
|
||
/// [`EditorGenerationPricingPublicResponse`]。
|
||
pub(crate) fn into_public_response(self) -> EditorGenerationPricingPublicResponse {
|
||
EditorGenerationPricingPublicResponse {
|
||
models: self.models,
|
||
model3d: self.model3d.map(Model3dPricingConfig::into_public_view),
|
||
}
|
||
}
|
||
|
||
/// Tripo 3D 提交查价:缺段或缺键都直接报错,不回退默认值。
|
||
///
|
||
/// 错误与同文件其它查询统一成 [`EditorGenerationPricingError`],3D 结构化原因收在
|
||
/// [`EditorGenerationPricingError::Model3d`] 里,调用方不必再处理第二套错误枚举。
|
||
pub(crate) fn model3d_price(
|
||
&self,
|
||
query: &Model3dPricingQuery,
|
||
) -> Result<u32, EditorGenerationPricingError> {
|
||
self.model3d
|
||
.as_ref()
|
||
.ok_or(Model3dPricingError::NotConfigured)?
|
||
.price(query)
|
||
.map_err(Into::into)
|
||
}
|
||
|
||
pub(crate) fn image_model_mud_points(
|
||
&self,
|
||
model: Option<&str>,
|
||
image_size: Option<&str>,
|
||
) -> u32 {
|
||
if let Some(price_mud_points) = current_external_generation_billing_price_mud_points() {
|
||
return price_mud_points;
|
||
}
|
||
let normalized_model = normalize_editor_image_model(model);
|
||
let normalized_size =
|
||
normalize_editor_generation_image_price_size(normalized_model, image_size);
|
||
read_tier_price(
|
||
&self.models,
|
||
normalized_model,
|
||
EDITOR_IMAGE_MODEL_NANOBANANA2,
|
||
normalized_size,
|
||
DEFAULT_IMAGE_PRICE_SIZE,
|
||
)
|
||
}
|
||
|
||
pub(crate) fn spec_model_mud_points(&self, model: Option<&str>) -> u32 {
|
||
if let Some(price_mud_points) = current_external_generation_billing_price_mud_points() {
|
||
return price_mud_points;
|
||
}
|
||
let normalized_model = normalize_non_empty_model(model, EDITOR_IMAGE_MODEL_GPT_IMAGE_2);
|
||
read_tier_price(
|
||
&self.models,
|
||
normalized_model,
|
||
EDITOR_IMAGE_MODEL_GPT_IMAGE_2,
|
||
SPEC_IMAGE_PRICE_SIZE,
|
||
SPEC_IMAGE_PRICE_SIZE,
|
||
)
|
||
}
|
||
|
||
pub(crate) fn image_generation_mud_points(
|
||
&self,
|
||
kind: Option<&str>,
|
||
model: Option<&str>,
|
||
image_size: Option<&str>,
|
||
) -> u32 {
|
||
if let Some(price_mud_points) = current_external_generation_billing_price_mud_points() {
|
||
return price_mud_points;
|
||
}
|
||
match kind.map(str::trim) {
|
||
Some("spec") => self.spec_model_mud_points(model),
|
||
Some("character" | "icon" | "ui-design" | "publication-material" | "scene") => {
|
||
self.image_model_mud_points(model, image_size)
|
||
}
|
||
_ => self.image_model_mud_points(model, image_size),
|
||
}
|
||
}
|
||
|
||
pub(crate) fn video_model_mud_points(
|
||
&self,
|
||
model: Option<&str>,
|
||
resolution: &str,
|
||
duration_seconds: u32,
|
||
) -> u32 {
|
||
if let Some(price_mud_points) = current_external_generation_billing_price_mud_points() {
|
||
return price_mud_points;
|
||
}
|
||
let normalized_model = normalize_video_model(model);
|
||
let per_second = read_tier_price(
|
||
&self.models,
|
||
normalized_model,
|
||
EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST,
|
||
resolution,
|
||
DEFAULT_VIDEO_RESOLUTION,
|
||
);
|
||
per_second * duration_seconds
|
||
}
|
||
|
||
pub(crate) fn character_animation_model_mud_points(
|
||
&self,
|
||
model: Option<&str>,
|
||
resolution: &str,
|
||
duration_seconds: u32,
|
||
) -> u32 {
|
||
if let Some(price_mud_points) = current_external_generation_billing_price_mud_points() {
|
||
return price_mud_points;
|
||
}
|
||
let normalized_model = normalize_non_empty_model(model, EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST);
|
||
let per_second = read_tier_price(
|
||
&self.models,
|
||
normalized_model,
|
||
EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST,
|
||
resolution,
|
||
DEFAULT_VIDEO_RESOLUTION,
|
||
);
|
||
per_second * duration_seconds
|
||
}
|
||
|
||
pub(crate) fn sound_effect_model_mud_points(&self, model: Option<&str>) -> u32 {
|
||
if let Some(price_mud_points) = current_external_generation_billing_price_mud_points() {
|
||
return price_mud_points;
|
||
}
|
||
let normalized_model =
|
||
normalize_non_empty_model(model, EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS);
|
||
read_flat_price(
|
||
&self.models,
|
||
normalized_model,
|
||
EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS,
|
||
)
|
||
}
|
||
|
||
pub(crate) fn background_music_model_mud_points(&self, model: Option<&str>) -> u32 {
|
||
if let Some(price_mud_points) = current_external_generation_billing_price_mud_points() {
|
||
return price_mud_points;
|
||
}
|
||
let normalized_model = normalize_non_empty_model(model, EDITOR_BACKGROUND_MUSIC_MODEL_SUNO);
|
||
read_flat_price(
|
||
&self.models,
|
||
normalized_model,
|
||
EDITOR_BACKGROUND_MUSIC_MODEL_SUNO,
|
||
)
|
||
}
|
||
|
||
pub(crate) fn validate(&self) -> Result<(), EditorGenerationPricingError> {
|
||
validate_all_model_entries(&self.models)?;
|
||
if let Some(model3d) = &self.model3d {
|
||
model3d.validate().map_err(|message| {
|
||
EditorGenerationPricingError::Invalid(format!("model3d 定价:{message}"))
|
||
})?;
|
||
}
|
||
validate_required_tier_prices(
|
||
&self.models,
|
||
EDITOR_IMAGE_MODEL_NANOBANANA2,
|
||
EditorGenerationPricingUnit::PerGeneration,
|
||
REQUIRED_NANOBANANA_IMAGE_SIZES,
|
||
)?;
|
||
validate_required_tier_prices(
|
||
&self.models,
|
||
EDITOR_IMAGE_MODEL_GPT_IMAGE_2,
|
||
EditorGenerationPricingUnit::PerGeneration,
|
||
REQUIRED_GPT_IMAGE_SIZES,
|
||
)?;
|
||
for model in REQUIRED_VIDEO_MODELS {
|
||
validate_required_tier_prices(
|
||
&self.models,
|
||
model,
|
||
EditorGenerationPricingUnit::PerSecond,
|
||
REQUIRED_VIDEO_RESOLUTIONS,
|
||
)?;
|
||
}
|
||
validate_required_flat_price(
|
||
&self.models,
|
||
EDITOR_SOUND_EFFECT_MODEL_VIDU,
|
||
EditorGenerationPricingUnit::PerGeneration,
|
||
)?;
|
||
validate_required_flat_price(
|
||
&self.models,
|
||
EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS,
|
||
EditorGenerationPricingUnit::PerGeneration,
|
||
)?;
|
||
validate_required_flat_price(
|
||
&self.models,
|
||
EDITOR_BACKGROUND_MUSIC_MODEL_SUNO,
|
||
EditorGenerationPricingUnit::PerGeneration,
|
||
)?;
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
impl EditorGenerationPricingStore {
|
||
pub(crate) fn load(override_path: PathBuf) -> Result<Self, EditorGenerationPricingError> {
|
||
let current = load_editor_generation_pricing_from_paths(Some(&override_path))?;
|
||
Ok(Self {
|
||
current: Arc::new(RwLock::new(current)),
|
||
version_micros: Arc::new(AtomicI64::new(0)),
|
||
})
|
||
}
|
||
|
||
/// 当前定价版本。后台把它发给前端,保存时原样回传做乐观锁比对。
|
||
pub(crate) fn version_micros(&self) -> i64 {
|
||
self.version_micros.load(Ordering::Acquire)
|
||
}
|
||
|
||
pub(crate) fn snapshot(
|
||
&self,
|
||
) -> Result<EditorGenerationPricingConfig, EditorGenerationPricingError> {
|
||
self.current
|
||
.read()
|
||
.map_err(|_| EditorGenerationPricingError::LockPoisoned)
|
||
.map(|guard| guard.clone())
|
||
}
|
||
|
||
pub(crate) fn replace(
|
||
&self,
|
||
next: EditorGenerationPricingConfig,
|
||
version_micros: i64,
|
||
) -> Result<EditorGenerationPricingConfig, EditorGenerationPricingError> {
|
||
next.validate()?;
|
||
let mut guard = self
|
||
.current
|
||
.write()
|
||
.map_err(|_| EditorGenerationPricingError::LockPoisoned)?;
|
||
*guard = next.clone();
|
||
self.version_micros.store(version_micros, Ordering::Release);
|
||
Ok(next)
|
||
}
|
||
}
|
||
|
||
pub(crate) fn default_editor_generation_pricing_override_path() -> PathBuf {
|
||
PathBuf::from(
|
||
"/var/lib/genarrative/editor-generation-pricing/editor-generation-pricing.override.json",
|
||
)
|
||
}
|
||
|
||
pub(crate) fn legacy_editor_generation_pricing_override_path() -> PathBuf {
|
||
PathBuf::from(".app/editor-generation-pricing.override.json")
|
||
}
|
||
|
||
pub(crate) fn load_editor_generation_pricing_from_paths(
|
||
override_path: Option<&Path>,
|
||
) -> Result<EditorGenerationPricingConfig, EditorGenerationPricingError> {
|
||
let legacy_override_path = legacy_editor_generation_pricing_override_path();
|
||
let default_override_path = default_editor_generation_pricing_override_path();
|
||
load_editor_generation_pricing_from_candidates(
|
||
override_path,
|
||
Some(legacy_override_path.as_path()),
|
||
override_path == Some(default_override_path.as_path()),
|
||
)
|
||
}
|
||
|
||
fn load_editor_generation_pricing_from_candidates(
|
||
override_path: Option<&Path>,
|
||
legacy_override_path: Option<&Path>,
|
||
allow_legacy_fallback: bool,
|
||
) -> Result<EditorGenerationPricingConfig, EditorGenerationPricingError> {
|
||
let mut config = parse_editor_generation_pricing_json(
|
||
EDITOR_GENERATION_PRICING_DEFAULT_JSON,
|
||
"默认模型定价配置",
|
||
)?;
|
||
let fallback_legacy_path =
|
||
legacy_override_path.filter(|path| allow_legacy_fallback && path.exists());
|
||
let selected_override_path = override_path
|
||
.filter(|path| path.exists())
|
||
.or(fallback_legacy_path);
|
||
if let Some(path) = selected_override_path {
|
||
let override_json = fs::read_to_string(path).map_err(EditorGenerationPricingError::Io)?;
|
||
let source = path.to_string_lossy();
|
||
let mut override_config =
|
||
serde_json::from_str::<EditorGenerationPricingConfig>(override_json.as_str())
|
||
.map_err(EditorGenerationPricingError::Json)?;
|
||
backfill_legacy_sfx_pricing(&mut override_config, &config, source.as_ref())?;
|
||
backfill_legacy_model3d_pricing(&mut override_config, &config, source.as_ref());
|
||
override_config.validate().map_err(|error| match error {
|
||
EditorGenerationPricingError::Invalid(message) => {
|
||
EditorGenerationPricingError::Invalid(format!("{source}: {message}"))
|
||
}
|
||
other => other,
|
||
})?;
|
||
config = override_config;
|
||
}
|
||
config.validate()?;
|
||
Ok(config)
|
||
}
|
||
|
||
fn backfill_legacy_sfx_pricing(
|
||
config: &mut EditorGenerationPricingConfig,
|
||
fallback: &EditorGenerationPricingConfig,
|
||
source: &str,
|
||
) -> Result<(), EditorGenerationPricingError> {
|
||
if config
|
||
.models
|
||
.contains_key(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS)
|
||
{
|
||
return Ok(());
|
||
}
|
||
let pricing = fallback
|
||
.models
|
||
.get(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS)
|
||
.cloned()
|
||
.ok_or_else(|| {
|
||
EditorGenerationPricingError::Invalid(format!(
|
||
"{source}: 受控默认配置缺少模型 {EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS}"
|
||
))
|
||
})?;
|
||
config
|
||
.models
|
||
.insert(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS.to_string(), pricing);
|
||
Ok(())
|
||
}
|
||
|
||
/// 旧覆盖文件可能整段没有 3D 定价(3D 是后加的段):缺失时用当前受控默认价补齐。
|
||
///
|
||
/// 覆盖文件只承担种子与兜底,不代表「3D 未配置」;这里补齐后,表内缺 3D 段的老行也能
|
||
/// 按同一份默认价归一化,不会因为文件里没有这一段就把 3D 生成整段关掉。
|
||
fn backfill_legacy_model3d_pricing(
|
||
config: &mut EditorGenerationPricingConfig,
|
||
fallback: &EditorGenerationPricingConfig,
|
||
source: &str,
|
||
) {
|
||
if config.model3d.is_some() || fallback.model3d.is_none() {
|
||
return;
|
||
}
|
||
warn!(
|
||
source,
|
||
"覆盖文件缺少 3D 定价段,已按受控默认价补齐;确认 3D 价格后再从后台保存一次"
|
||
);
|
||
config.model3d = fallback.model3d.clone();
|
||
}
|
||
|
||
pub(crate) fn parse_editor_generation_pricing_json(
|
||
json: &str,
|
||
source: &str,
|
||
) -> Result<EditorGenerationPricingConfig, EditorGenerationPricingError> {
|
||
serde_json::from_str::<EditorGenerationPricingConfig>(json)
|
||
.map_err(EditorGenerationPricingError::Json)
|
||
.and_then(|config| {
|
||
config.validate().map_err(|error| match error {
|
||
EditorGenerationPricingError::Invalid(message) => {
|
||
EditorGenerationPricingError::Invalid(format!("{source}: {message}"))
|
||
}
|
||
other => other,
|
||
})?;
|
||
Ok(config)
|
||
})
|
||
}
|
||
|
||
#[cfg(test)]
|
||
pub(crate) fn serialize_editor_generation_pricing_json(
|
||
config: &EditorGenerationPricingConfig,
|
||
) -> Result<String, EditorGenerationPricingError> {
|
||
config.validate()?;
|
||
serde_json::to_string(config).map_err(EditorGenerationPricingError::Json)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
pub(crate) fn editor_image_generation_mud_points(
|
||
kind: Option<&str>,
|
||
model: Option<&str>,
|
||
image_size: Option<&str>,
|
||
) -> u32 {
|
||
default_runtime_pricing().image_generation_mud_points(kind, model, image_size)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
pub(crate) fn editor_video_model_generation_mud_points(
|
||
model: Option<&str>,
|
||
resolution: &str,
|
||
duration_seconds: u32,
|
||
) -> u32 {
|
||
default_runtime_pricing().video_model_mud_points(model, resolution, duration_seconds)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
pub(crate) fn editor_character_animation_model_mud_points(
|
||
model: Option<&str>,
|
||
resolution: &str,
|
||
duration_seconds: u32,
|
||
) -> u32 {
|
||
default_runtime_pricing().character_animation_model_mud_points(
|
||
model,
|
||
resolution,
|
||
duration_seconds,
|
||
)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
pub(crate) fn editor_sound_effect_model_generation_mud_points(model: Option<&str>) -> u32 {
|
||
default_runtime_pricing().sound_effect_model_mud_points(model)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
pub(crate) fn editor_background_music_model_generation_mud_points(model: Option<&str>) -> u32 {
|
||
default_runtime_pricing().background_music_model_mud_points(model)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
fn default_runtime_pricing() -> EditorGenerationPricingConfig {
|
||
load_editor_generation_pricing_from_paths(None).expect("默认模型定价配置必须是合法 JSON")
|
||
}
|
||
|
||
fn normalize_editor_image_model(model: Option<&str>) -> &'static str {
|
||
match model.map(str::trim).filter(|value| !value.is_empty()) {
|
||
Some(EDITOR_IMAGE_MODEL_GPT_IMAGE_2) => EDITOR_IMAGE_MODEL_GPT_IMAGE_2,
|
||
Some(EDITOR_IMAGE_MODEL_NANOBANANA2)
|
||
| Some(EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS)
|
||
| Some(EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS) => EDITOR_IMAGE_MODEL_NANOBANANA2,
|
||
_ => EDITOR_IMAGE_MODEL_NANOBANANA2,
|
||
}
|
||
}
|
||
|
||
fn normalize_editor_generation_image_price_size(
|
||
model: &str,
|
||
image_size: Option<&str>,
|
||
) -> &'static str {
|
||
match image_size.map(str::trim).filter(|value| !value.is_empty()) {
|
||
Some("0.5K" | "0.5k") if model == EDITOR_IMAGE_MODEL_NANOBANANA2 => IMAGE_PRICE_SIZE_0_5K,
|
||
Some("2K" | "2k") => IMAGE_PRICE_SIZE_2K,
|
||
_ => IMAGE_PRICE_SIZE_1K,
|
||
}
|
||
}
|
||
|
||
fn normalize_video_model(model: Option<&str>) -> &'static str {
|
||
match model.map(str::trim).filter(|value| !value.is_empty()) {
|
||
Some(EDITOR_VIDEO_MODEL_SEEDANCE_2) => EDITOR_VIDEO_MODEL_SEEDANCE_2,
|
||
Some(EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST) => EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST,
|
||
Some(EDITOR_VIDEO_MODEL_KLING_3) => EDITOR_VIDEO_MODEL_KLING_3,
|
||
Some(EDITOR_VIDEO_MODEL_KLING_3_OMNI) => EDITOR_VIDEO_MODEL_KLING_3_OMNI,
|
||
Some(EDITOR_VIDEO_MODEL_VEO_3_1) => EDITOR_VIDEO_MODEL_VEO_3_1,
|
||
Some(EDITOR_VIDEO_MODEL_VEO_3_1_FAST) => EDITOR_VIDEO_MODEL_VEO_3_1_FAST,
|
||
_ => EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST,
|
||
}
|
||
}
|
||
|
||
fn normalize_non_empty_model<'a>(model: Option<&'a str>, fallback: &'static str) -> &'a str {
|
||
model
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.unwrap_or(fallback)
|
||
}
|
||
|
||
fn read_flat_price(
|
||
models: &BTreeMap<String, EditorGenerationModelPricing>,
|
||
model: &str,
|
||
fallback_model: &str,
|
||
) -> u32 {
|
||
models
|
||
.get(model)
|
||
.and_then(|entry| entry.price)
|
||
.or_else(|| models.get(fallback_model).and_then(|entry| entry.price))
|
||
.unwrap_or(0)
|
||
}
|
||
|
||
fn read_tier_price(
|
||
models: &BTreeMap<String, EditorGenerationModelPricing>,
|
||
model: &str,
|
||
fallback_model: &str,
|
||
tier: &str,
|
||
fallback_tier: &str,
|
||
) -> u32 {
|
||
models
|
||
.get(model)
|
||
.and_then(|entry| {
|
||
entry
|
||
.prices
|
||
.get(tier)
|
||
.or_else(|| entry.prices.get(fallback_tier))
|
||
.copied()
|
||
.or(entry.price)
|
||
})
|
||
.or_else(|| {
|
||
models.get(fallback_model).and_then(|entry| {
|
||
entry
|
||
.prices
|
||
.get(tier)
|
||
.or_else(|| entry.prices.get(fallback_tier))
|
||
.copied()
|
||
.or(entry.price)
|
||
})
|
||
})
|
||
.unwrap_or(0)
|
||
}
|
||
|
||
fn validate_all_model_entries(
|
||
models: &BTreeMap<String, EditorGenerationModelPricing>,
|
||
) -> Result<(), EditorGenerationPricingError> {
|
||
if models.is_empty() {
|
||
return Err(EditorGenerationPricingError::Invalid(
|
||
"models 不能为空".to_string(),
|
||
));
|
||
}
|
||
for (model, pricing) in models {
|
||
if model.trim().is_empty() {
|
||
return Err(EditorGenerationPricingError::Invalid(
|
||
"models 不能包含空模型名".to_string(),
|
||
));
|
||
}
|
||
if pricing.price.is_some() == !pricing.prices.is_empty() {
|
||
return Err(EditorGenerationPricingError::Invalid(format!(
|
||
"models.{model} 必须且只能配置 price 或 prices 其中一种"
|
||
)));
|
||
}
|
||
if pricing.price == Some(0) {
|
||
return Err(EditorGenerationPricingError::Invalid(format!(
|
||
"models.{model}.price 必须大于 0"
|
||
)));
|
||
}
|
||
for (tier, price) in &pricing.prices {
|
||
if tier.trim().is_empty() {
|
||
return Err(EditorGenerationPricingError::Invalid(format!(
|
||
"models.{model}.prices 不能包含空档位"
|
||
)));
|
||
}
|
||
if *price == 0 {
|
||
return Err(EditorGenerationPricingError::Invalid(format!(
|
||
"models.{model}.{tier} 必须大于 0"
|
||
)));
|
||
}
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn validate_required_flat_price(
|
||
models: &BTreeMap<String, EditorGenerationModelPricing>,
|
||
model: &str,
|
||
expected_unit: EditorGenerationPricingUnit,
|
||
) -> Result<(), EditorGenerationPricingError> {
|
||
let pricing = models.get(model).ok_or_else(|| {
|
||
EditorGenerationPricingError::Invalid(format!("models 缺少模型 {model} 的泥点配置"))
|
||
})?;
|
||
if pricing.unit != expected_unit {
|
||
return Err(EditorGenerationPricingError::Invalid(format!(
|
||
"models.{model}.unit 必须是 {}",
|
||
format_pricing_unit(expected_unit)
|
||
)));
|
||
}
|
||
let price = pricing.price.ok_or_else(|| {
|
||
EditorGenerationPricingError::Invalid(format!("models.{model} 缺少单次泥点配置"))
|
||
})?;
|
||
if price == 0 {
|
||
return Err(EditorGenerationPricingError::Invalid(format!(
|
||
"models.{model}.price 必须大于 0"
|
||
)));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn validate_required_tier_prices(
|
||
models: &BTreeMap<String, EditorGenerationModelPricing>,
|
||
model: &str,
|
||
expected_unit: EditorGenerationPricingUnit,
|
||
required_tiers: &[&str],
|
||
) -> Result<(), EditorGenerationPricingError> {
|
||
let pricing = models.get(model).ok_or_else(|| {
|
||
EditorGenerationPricingError::Invalid(format!("models 缺少模型 {model} 的泥点配置"))
|
||
})?;
|
||
if pricing.unit != expected_unit {
|
||
return Err(EditorGenerationPricingError::Invalid(format!(
|
||
"models.{model}.unit 必须是 {}",
|
||
format_pricing_unit(expected_unit)
|
||
)));
|
||
}
|
||
for tier in required_tiers {
|
||
let price = pricing.prices.get(*tier).ok_or_else(|| {
|
||
EditorGenerationPricingError::Invalid(format!("models.{model} 缺少 {tier} 的泥点配置"))
|
||
})?;
|
||
if *price == 0 {
|
||
return Err(EditorGenerationPricingError::Invalid(format!(
|
||
"models.{model}.{tier} 必须大于 0"
|
||
)));
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn format_pricing_unit(unit: EditorGenerationPricingUnit) -> &'static str {
|
||
match unit {
|
||
EditorGenerationPricingUnit::PerGeneration => "perGeneration",
|
||
EditorGenerationPricingUnit::PerSecond => "perSecond",
|
||
}
|
||
}
|
||
|
||
impl fmt::Display for EditorGenerationPricingError {
|
||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||
match self {
|
||
Self::Io(error) => write!(f, "模型定价配置文件读写失败:{error}"),
|
||
Self::Json(error) => write!(f, "模型定价配置 JSON 解析失败:{error}"),
|
||
Self::Invalid(message) => write!(f, "模型定价配置不合法:{message}"),
|
||
Self::Persistence(message) => write!(f, "模型定价配置入库失败:{message}"),
|
||
Self::LockPoisoned => write!(f, "模型定价配置锁已损坏"),
|
||
Self::Conflict => write!(f, "定价已被其他人更新,请重新读取后再保存"),
|
||
// 直接透传内层文案,保持接口响应里的 message 与改造前一致。
|
||
Self::Model3d(error) => write!(f, "{error}"),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl std::error::Error for EditorGenerationPricingError {}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use serde_json::{Value, json};
|
||
use shared_contracts::model3d::common::{
|
||
Model3dGeometryQuality, Model3dModelVersion, Model3dTextureQuality,
|
||
};
|
||
|
||
use crate::tripo3d::pricing::{
|
||
Model3dAddOn, Model3dAddOnSet, Model3dEndpoint, Model3dPricingError, Model3dPricingQuery,
|
||
model3d_add_ons,
|
||
};
|
||
|
||
/// 单个端点的最小完整定价段:每个模型版本两种贴图态 + 全部 add-on。
|
||
fn endpoint_pricing_json() -> Value {
|
||
let mut version_prices = serde_json::Map::new();
|
||
for version in [
|
||
"v3.1-20260211",
|
||
"v3.0-20250812",
|
||
"v2.5-20250123",
|
||
"P1-20260311",
|
||
"P2-20260801",
|
||
] {
|
||
version_prices.insert(
|
||
version.to_string(),
|
||
json!({ "noTexture": 10, "texture": 20 }),
|
||
);
|
||
}
|
||
json!({
|
||
"versionPrices": Value::Object(version_prices),
|
||
"addOnPrices": {
|
||
"hdTexture": 5,
|
||
"ultraTexture": 10,
|
||
"hdGeometry": 15,
|
||
"quadMesh": 20,
|
||
"smartLowPoly": 25,
|
||
"generateParts": 30
|
||
}
|
||
})
|
||
}
|
||
|
||
/// 最小完整 3D 价格段:两个端点并列,各持自己的版本底价与加价项。
|
||
fn model3d_pricing_json() -> Value {
|
||
json!({
|
||
"textToModelPricing": endpoint_pricing_json(),
|
||
"imageToModelPricing": endpoint_pricing_json(),
|
||
})
|
||
}
|
||
|
||
fn pricing_json_with_model3d(model3d: Value) -> String {
|
||
let mut config: Value =
|
||
serde_json::from_str(EDITOR_GENERATION_PRICING_DEFAULT_JSON).expect("默认配置是 JSON");
|
||
config["model3d"] = model3d;
|
||
config.to_string()
|
||
}
|
||
|
||
fn query(
|
||
texture: bool,
|
||
add_ons: crate::tripo3d::pricing::Model3dAddOnSet,
|
||
) -> Model3dPricingQuery {
|
||
Model3dPricingQuery {
|
||
endpoint: Model3dEndpoint::TextToModel,
|
||
model_version: Model3dModelVersion::H31,
|
||
texture,
|
||
add_ons,
|
||
}
|
||
}
|
||
|
||
/// Tripo 官方 credit → 泥点的换算系数:泥点 = `ceil(系数 × credit)`。
|
||
const MODEL3D_CREDIT_TO_MUD_FACTOR: f64 = 0.8;
|
||
|
||
fn mud_from_credit(credit: u32) -> u32 {
|
||
(f64::from(credit) * MODEL3D_CREDIT_TO_MUD_FACTOR).ceil() as u32
|
||
}
|
||
|
||
/// `model3d` 段缺失是合法形态,代表 3D 定价尚未配置:查价必须失败关闭,不扣费。
|
||
/// 受控默认配置现在带真实数值,所以这里显式摘掉该段来验证缺失语义。
|
||
#[test]
|
||
fn editor_generation_pricing_fails_closed_when_model3d_section_is_absent() {
|
||
let mut json: Value =
|
||
serde_json::from_str(EDITOR_GENERATION_PRICING_DEFAULT_JSON).expect("默认配置是 JSON");
|
||
json.as_object_mut()
|
||
.expect("默认配置应是对象")
|
||
.remove("model3d");
|
||
let config = parse_editor_generation_pricing_json(&json.to_string(), "测试配置")
|
||
.expect("缺少 3D 段仍应可加载");
|
||
|
||
assert!(config.model3d.is_none());
|
||
let error = config
|
||
.model3d_price(&query(
|
||
false,
|
||
model3d_add_ons(
|
||
false,
|
||
Model3dTextureQuality::Standard,
|
||
Model3dGeometryQuality::Standard,
|
||
false,
|
||
false,
|
||
false,
|
||
),
|
||
))
|
||
.expect_err("未配置 3D 定价时应拒绝查价");
|
||
assert!(
|
||
matches!(
|
||
error,
|
||
EditorGenerationPricingError::Model3d(Model3dPricingError::NotConfigured)
|
||
),
|
||
"3D 段缺失应以 NotConfigured 收口,实际 {error:?}"
|
||
);
|
||
}
|
||
|
||
/// 受控默认配置里的 3D 泥点价必须严格等于 `ceil(0.8 × Tripo 官方 credit)`:
|
||
/// 底价按「端点 × 模型版本 × 是否有贴图」,add-on 按项叠加。改价必须先改这里的
|
||
/// credit 表,否则这条断言会失败,避免默认价脱离已确认的换算口径。
|
||
#[test]
|
||
fn default_model3d_prices_convert_tripo_credits_at_configured_factor() {
|
||
let config = default_runtime_pricing();
|
||
let model3d = config.model3d.as_ref().expect("默认配置必须带 3D 定价段");
|
||
|
||
// (端点, 模型版本, 无贴图 credit, 带贴图 credit)
|
||
let base_credits = [
|
||
(
|
||
Model3dEndpoint::TextToModel,
|
||
Model3dModelVersion::H31,
|
||
10,
|
||
20,
|
||
),
|
||
(
|
||
Model3dEndpoint::TextToModel,
|
||
Model3dModelVersion::H30,
|
||
10,
|
||
20,
|
||
),
|
||
(
|
||
Model3dEndpoint::TextToModel,
|
||
Model3dModelVersion::H25,
|
||
10,
|
||
20,
|
||
),
|
||
(
|
||
Model3dEndpoint::TextToModel,
|
||
Model3dModelVersion::P1,
|
||
30,
|
||
40,
|
||
),
|
||
(
|
||
Model3dEndpoint::TextToModel,
|
||
Model3dModelVersion::P2,
|
||
100,
|
||
110,
|
||
),
|
||
(
|
||
Model3dEndpoint::ImageToModel,
|
||
Model3dModelVersion::H31,
|
||
20,
|
||
30,
|
||
),
|
||
(
|
||
Model3dEndpoint::ImageToModel,
|
||
Model3dModelVersion::H30,
|
||
20,
|
||
30,
|
||
),
|
||
(
|
||
Model3dEndpoint::ImageToModel,
|
||
Model3dModelVersion::H25,
|
||
20,
|
||
30,
|
||
),
|
||
(
|
||
Model3dEndpoint::ImageToModel,
|
||
Model3dModelVersion::P1,
|
||
40,
|
||
50,
|
||
),
|
||
(
|
||
Model3dEndpoint::ImageToModel,
|
||
Model3dModelVersion::P2,
|
||
100,
|
||
110,
|
||
),
|
||
];
|
||
for (endpoint, model_version, no_texture_credit, texture_credit) in base_credits {
|
||
for (texture, credit) in [(false, no_texture_credit), (true, texture_credit)] {
|
||
let price = config
|
||
.model3d_price(&Model3dPricingQuery {
|
||
endpoint,
|
||
model_version,
|
||
texture,
|
||
add_ons: Model3dAddOnSet::default(),
|
||
})
|
||
.expect("默认配置必须能查出底价");
|
||
assert_eq!(
|
||
price,
|
||
mud_from_credit(credit),
|
||
"{} {model_version:?} texture={texture} 的底价应为 ceil(0.8 × {credit}) 泥点",
|
||
endpoint.as_str(),
|
||
);
|
||
}
|
||
}
|
||
|
||
let add_on_credits = [
|
||
(Model3dAddOn::HdTexture, 10),
|
||
(Model3dAddOn::UltraTexture, 20),
|
||
(Model3dAddOn::HdGeometry, 20),
|
||
(Model3dAddOn::QuadMesh, 5),
|
||
(Model3dAddOn::SmartLowPoly, 10),
|
||
(Model3dAddOn::GenerateParts, 20),
|
||
];
|
||
for endpoint in Model3dEndpoint::ALL {
|
||
for (add_on, credit) in add_on_credits {
|
||
let price = model3d
|
||
.endpoint_pricing(endpoint)
|
||
.add_on_prices
|
||
.get(&add_on)
|
||
.copied()
|
||
.unwrap_or_else(|| {
|
||
panic!("默认配置 {} 缺少 {add_on:?} 的价格", endpoint.as_str())
|
||
});
|
||
assert_eq!(
|
||
price,
|
||
mud_from_credit(credit),
|
||
"{} {add_on:?} 的加价应为 ceil(0.8 × {credit}) 泥点",
|
||
endpoint.as_str()
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn editor_generation_pricing_validates_model3d_section_when_present() {
|
||
let json = pricing_json_with_model3d(model3d_pricing_json());
|
||
let config = parse_editor_generation_pricing_json(&json, "测试配置")
|
||
.expect("完整 3D 定价段应可加载");
|
||
|
||
let add_ons = model3d_add_ons(
|
||
true,
|
||
Model3dTextureQuality::Detailed,
|
||
Model3dGeometryQuality::Standard,
|
||
false,
|
||
false,
|
||
false,
|
||
);
|
||
assert!(add_ons.contains(Model3dAddOn::HdTexture));
|
||
assert_eq!(
|
||
config
|
||
.model3d_price(&query(true, add_ons))
|
||
.expect("底价与 add-on 都在夹具里"),
|
||
20 + 5
|
||
);
|
||
|
||
let mut incomplete = model3d_pricing_json();
|
||
incomplete["imageToModelPricing"]["versionPrices"]
|
||
.as_object_mut()
|
||
.expect("夹具含图片端点底价")
|
||
.remove("P2-20260801");
|
||
let error = parse_editor_generation_pricing_json(
|
||
&pricing_json_with_model3d(incomplete),
|
||
"测试配置",
|
||
)
|
||
.expect_err("缺少任一底价键应加载失败");
|
||
assert!(
|
||
error.to_string().contains("缺少"),
|
||
"报错应说明缺键,实际为:{error}"
|
||
);
|
||
}
|
||
|
||
fn unique_temp_dir(prefix: &str) -> PathBuf {
|
||
std::env::temp_dir().join(format!(
|
||
"{prefix}-{}",
|
||
std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.expect("clock should work")
|
||
.as_nanos()
|
||
))
|
||
}
|
||
|
||
#[test]
|
||
fn editor_generation_pricing_uses_single_model_map_with_units() {
|
||
let config = default_runtime_pricing();
|
||
|
||
assert_eq!(
|
||
config
|
||
.models
|
||
.get("gpt-image-2")
|
||
.expect("gpt-image-2 pricing should exist")
|
||
.unit,
|
||
EditorGenerationPricingUnit::PerGeneration
|
||
);
|
||
assert_eq!(
|
||
config
|
||
.models
|
||
.get("seedance2.0-fast")
|
||
.expect("seedance fast pricing should exist")
|
||
.unit,
|
||
EditorGenerationPricingUnit::PerSecond
|
||
);
|
||
assert!(!config.models.contains_key("spec:gpt-image-2"));
|
||
assert!(
|
||
!config
|
||
.models
|
||
.contains_key("character-animation:seedance2.0-fast")
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn worker_billing_context_freezes_all_editor_generation_prices() {
|
||
let config = default_runtime_pricing();
|
||
let inline_image_price = config.image_model_mud_points(Some("gpt-image-2"), Some("2K"));
|
||
let queued_price = 37;
|
||
|
||
crate::asset_billing::with_external_generation_billing_context(
|
||
"extgen-pricing-test".to_string(),
|
||
queued_price,
|
||
async {
|
||
assert_eq!(
|
||
config.image_model_mud_points(Some("gpt-image-2"), Some("2K")),
|
||
queued_price
|
||
);
|
||
assert_eq!(
|
||
config.spec_model_mud_points(Some("gpt-image-2")),
|
||
queued_price
|
||
);
|
||
assert_eq!(
|
||
config.image_generation_mud_points(
|
||
Some("character"),
|
||
Some("gpt-image-2"),
|
||
Some("2K")
|
||
),
|
||
queued_price
|
||
);
|
||
assert_eq!(
|
||
config.video_model_mud_points(Some("seedance2.0"), "720p", 5),
|
||
queued_price
|
||
);
|
||
assert_eq!(
|
||
config.character_animation_model_mud_points(
|
||
Some("seedance2.0-fast"),
|
||
"720p",
|
||
6
|
||
),
|
||
queued_price
|
||
);
|
||
assert_eq!(
|
||
config
|
||
.sound_effect_model_mud_points(Some(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS)),
|
||
queued_price
|
||
);
|
||
assert_eq!(
|
||
config.background_music_model_mud_points(Some("chirp-v5")),
|
||
queued_price
|
||
);
|
||
},
|
||
)
|
||
.await;
|
||
|
||
assert_eq!(
|
||
config.image_model_mud_points(Some("gpt-image-2"), Some("2K")),
|
||
inline_image_price
|
||
);
|
||
assert_ne!(inline_image_price, queued_price);
|
||
}
|
||
|
||
#[test]
|
||
fn editor_generation_pricing_default_override_path_uses_data_dir() {
|
||
assert_eq!(
|
||
default_editor_generation_pricing_override_path(),
|
||
PathBuf::from(
|
||
"/var/lib/genarrative/editor-generation-pricing/editor-generation-pricing.override.json"
|
||
)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn editor_generation_pricing_default_path_falls_back_to_legacy_override_seed() {
|
||
let temp_dir = unique_temp_dir("genarrative-pricing-legacy-test");
|
||
let primary_path = temp_dir.join("var/lib/editor-generation-pricing.override.json");
|
||
let legacy_path = temp_dir
|
||
.join(".app")
|
||
.join("editor-generation-pricing.override.json");
|
||
std::fs::create_dir_all(legacy_path.parent().expect("legacy parent"))
|
||
.expect("legacy parent should create");
|
||
let mut legacy_config = default_runtime_pricing();
|
||
legacy_config
|
||
.models
|
||
.get_mut("audio1.0")
|
||
.expect("audio model exists")
|
||
.price = Some(17);
|
||
std::fs::write(
|
||
&legacy_path,
|
||
serialize_editor_generation_pricing_json(&legacy_config)
|
||
.expect("legacy config should serialize"),
|
||
)
|
||
.expect("legacy override should write");
|
||
|
||
let loaded = load_editor_generation_pricing_from_candidates(
|
||
Some(primary_path.as_path()),
|
||
Some(legacy_path.as_path()),
|
||
true,
|
||
)
|
||
.expect("legacy override should seed cache");
|
||
|
||
assert_eq!(loaded.sound_effect_model_mud_points(Some("audio1.0")), 17);
|
||
}
|
||
|
||
#[test]
|
||
fn editor_generation_pricing_legacy_override_backfills_new_sfx_model() {
|
||
let temp_dir = unique_temp_dir("genarrative-pricing-legacy-sfx-test");
|
||
std::fs::create_dir_all(&temp_dir).expect("temp dir should create");
|
||
let override_path = temp_dir.join("editor-generation-pricing.override.json");
|
||
let mut legacy_config = default_runtime_pricing();
|
||
legacy_config
|
||
.models
|
||
.remove(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS);
|
||
legacy_config
|
||
.models
|
||
.get_mut(EDITOR_SOUND_EFFECT_MODEL_VIDU)
|
||
.expect("legacy sound effect pricing should exist")
|
||
.price = Some(17);
|
||
std::fs::write(
|
||
&override_path,
|
||
serde_json::to_string(&legacy_config).expect("legacy config should serialize"),
|
||
)
|
||
.expect("legacy override should write");
|
||
|
||
let loaded = load_editor_generation_pricing_from_paths(Some(&override_path))
|
||
.expect("legacy override should backfill the new SFX model");
|
||
|
||
assert_eq!(
|
||
loaded.sound_effect_model_mud_points(Some(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS)),
|
||
5
|
||
);
|
||
assert_eq!(
|
||
loaded.sound_effect_model_mud_points(Some(EDITOR_SOUND_EFFECT_MODEL_VIDU)),
|
||
17
|
||
);
|
||
std::fs::remove_dir_all(&temp_dir).expect("temp dir should remove");
|
||
}
|
||
|
||
#[test]
|
||
fn editor_generation_pricing_legacy_override_backfills_missing_model3d_section() {
|
||
let temp_dir = unique_temp_dir("genarrative-pricing-legacy-model3d-test");
|
||
std::fs::create_dir_all(&temp_dir).expect("temp dir should create");
|
||
let override_path = temp_dir.join("editor-generation-pricing.override.json");
|
||
let mut legacy_config = default_runtime_pricing();
|
||
legacy_config.model3d = None;
|
||
legacy_config
|
||
.models
|
||
.get_mut(EDITOR_SOUND_EFFECT_MODEL_VIDU)
|
||
.expect("legacy sound effect pricing should exist")
|
||
.price = Some(17);
|
||
std::fs::write(
|
||
&override_path,
|
||
serde_json::to_string(&legacy_config).expect("legacy config should serialize"),
|
||
)
|
||
.expect("legacy override should write");
|
||
|
||
let loaded = load_editor_generation_pricing_from_paths(Some(&override_path))
|
||
.expect("legacy override should backfill the 3D section");
|
||
|
||
assert_eq!(
|
||
loaded.model3d,
|
||
default_runtime_pricing().model3d,
|
||
"覆盖文件缺 3D 段时应补上受控默认价"
|
||
);
|
||
assert_eq!(loaded.sound_effect_model_mud_points(Some("audio1.0")), 17);
|
||
std::fs::remove_dir_all(&temp_dir).expect("temp dir should remove");
|
||
}
|
||
|
||
#[test]
|
||
fn editor_generation_pricing_legacy_override_still_rejects_other_missing_models() {
|
||
let temp_dir = unique_temp_dir("genarrative-pricing-legacy-required-model-test");
|
||
std::fs::create_dir_all(&temp_dir).expect("temp dir should create");
|
||
let override_path = temp_dir.join("editor-generation-pricing.override.json");
|
||
let mut legacy_config = default_runtime_pricing();
|
||
legacy_config
|
||
.models
|
||
.remove(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS);
|
||
legacy_config
|
||
.models
|
||
.remove(EDITOR_BACKGROUND_MUSIC_MODEL_SUNO);
|
||
std::fs::write(
|
||
&override_path,
|
||
serde_json::to_string(&legacy_config).expect("legacy config should serialize"),
|
||
)
|
||
.expect("legacy override should write");
|
||
|
||
let error = load_editor_generation_pricing_from_paths(Some(&override_path))
|
||
.expect_err("only the new SFX model may be backfilled");
|
||
|
||
assert!(
|
||
error
|
||
.to_string()
|
||
.contains(EDITOR_BACKGROUND_MUSIC_MODEL_SUNO)
|
||
);
|
||
std::fs::remove_dir_all(&temp_dir).expect("temp dir should remove");
|
||
}
|
||
|
||
#[test]
|
||
fn editor_image_and_spec_prices_share_model_size_rates() {
|
||
assert_eq!(
|
||
editor_image_generation_mud_points(
|
||
Some("image"),
|
||
Some("gemini-3.1-flash-image-preview"),
|
||
Some("0.5K")
|
||
),
|
||
8
|
||
);
|
||
assert_eq!(
|
||
editor_image_generation_mud_points(
|
||
Some("character"),
|
||
Some("gemini-3.1-flash-image-preview"),
|
||
Some("1K")
|
||
),
|
||
12
|
||
);
|
||
assert_eq!(
|
||
editor_image_generation_mud_points(
|
||
Some("scene"),
|
||
Some("gemini-3.1-flash-image-preview"),
|
||
Some("1K")
|
||
),
|
||
12
|
||
);
|
||
assert_eq!(
|
||
editor_image_generation_mud_points(Some("ui-design"), Some("gpt-image-2"), Some("1K")),
|
||
3
|
||
);
|
||
assert_eq!(
|
||
editor_image_generation_mud_points(Some("ui-design"), Some("gpt-image-2"), Some("2K")),
|
||
5
|
||
);
|
||
assert_eq!(
|
||
editor_image_generation_mud_points(Some("spec"), Some("gpt-image-2"), Some("2K")),
|
||
5
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn editor_video_and_character_animation_share_model_resolution_rates() {
|
||
assert_eq!(
|
||
editor_video_model_generation_mud_points(Some("seedance2.0-fast"), "720p", 6),
|
||
120
|
||
);
|
||
assert_eq!(
|
||
editor_character_animation_model_mud_points(Some("seedance2.0-fast"), "720p", 6),
|
||
120
|
||
);
|
||
assert_eq!(
|
||
editor_video_model_generation_mud_points(Some("seedance2.0"), "720p", 5),
|
||
120
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn editor_audio_generation_price_uses_configured_model_rates() {
|
||
assert_eq!(
|
||
editor_sound_effect_model_generation_mud_points(Some(
|
||
EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS
|
||
)),
|
||
5
|
||
);
|
||
assert_eq!(
|
||
editor_sound_effect_model_generation_mud_points(Some(EDITOR_SOUND_EFFECT_MODEL_VIDU)),
|
||
5
|
||
);
|
||
assert_eq!(
|
||
editor_background_music_model_generation_mud_points(Some("chirp-v5")),
|
||
12
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn editor_generation_pricing_can_be_overridden_from_single_model_json_file() {
|
||
let temp_dir = unique_temp_dir("genarrative-pricing-test");
|
||
std::fs::create_dir_all(&temp_dir).expect("temp dir should create");
|
||
let override_path = temp_dir.join("editor-generation-pricing.override.json");
|
||
std::fs::write(
|
||
&override_path,
|
||
r#"{
|
||
"models": {
|
||
"gemini-3.1-flash-image-preview": {
|
||
"unit": "perGeneration",
|
||
"prices": { "0.5K": 9, "1K": 18, "2K": 36 }
|
||
},
|
||
"gpt-image-2": {
|
||
"unit": "perGeneration",
|
||
"prices": { "1K": 31, "2K": 62 }
|
||
},
|
||
"seedance2.0-fast": {
|
||
"unit": "perSecond",
|
||
"prices": { "480p": 11, "720p": 22, "1080p": 44 }
|
||
},
|
||
"seedance2.0": {
|
||
"unit": "perSecond",
|
||
"prices": { "480p": 13, "720p": 26, "1080p": 52 }
|
||
},
|
||
"kling3.0": {
|
||
"unit": "perSecond",
|
||
"prices": { "480p": 16, "720p": 32, "1080p": 64 }
|
||
},
|
||
"kling3.0-omni": {
|
||
"unit": "perSecond",
|
||
"prices": { "480p": 21, "720p": 42, "1080p": 84 }
|
||
},
|
||
"veo3.1": {
|
||
"unit": "perSecond",
|
||
"prices": { "480p": 11, "720p": 22, "1080p": 44 }
|
||
},
|
||
"veo3.1-fast": {
|
||
"unit": "perSecond",
|
||
"prices": { "480p": 11, "720p": 22, "1080p": 44 }
|
||
},
|
||
"audio1.0": { "unit": "perGeneration", "price": 15 },
|
||
"eleven_text_to_sound_v2": { "unit": "perGeneration", "price": 16 },
|
||
"chirp-v5": { "unit": "perGeneration", "price": 9 }
|
||
}
|
||
}"#,
|
||
)
|
||
.expect("override file should write");
|
||
|
||
let config = load_editor_generation_pricing_from_paths(Some(&override_path))
|
||
.expect("pricing should load");
|
||
|
||
assert_eq!(
|
||
config.image_model_mud_points(Some("gpt-image-2"), Some("2K")),
|
||
62
|
||
);
|
||
assert_eq!(config.spec_model_mud_points(Some("gpt-image-2")), 62);
|
||
assert_eq!(
|
||
config.video_model_mud_points(Some("seedance2.0"), "720p", 5),
|
||
130
|
||
);
|
||
assert_eq!(
|
||
config.character_animation_model_mud_points(Some("seedance2.0-fast"), "720p", 6),
|
||
132
|
||
);
|
||
assert_eq!(
|
||
config.sound_effect_model_mud_points(Some(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS)),
|
||
16
|
||
);
|
||
assert_eq!(config.sound_effect_model_mud_points(Some("audio1.0")), 15);
|
||
assert_eq!(
|
||
config.background_music_model_mud_points(Some("chirp-v5")),
|
||
9
|
||
);
|
||
|
||
std::fs::remove_dir_all(&temp_dir).expect("temp dir should remove");
|
||
}
|
||
|
||
#[test]
|
||
fn editor_generation_pricing_rejects_missing_required_image_size() {
|
||
let error = parse_editor_generation_pricing_json(
|
||
r#"{
|
||
"models": {
|
||
"gemini-3.1-flash-image-preview": {
|
||
"unit": "perGeneration",
|
||
"prices": { "0.5K": 8, "1K": 12, "2K": 24 }
|
||
},
|
||
"gpt-image-2": {
|
||
"unit": "perGeneration",
|
||
"prices": { "1K": 20 }
|
||
},
|
||
"seedance2.0-fast": {
|
||
"unit": "perSecond",
|
||
"prices": { "480p": 10, "720p": 20, "1080p": 40 }
|
||
},
|
||
"seedance2.0": {
|
||
"unit": "perSecond",
|
||
"prices": { "480p": 12, "720p": 24, "1080p": 48 }
|
||
},
|
||
"kling3.0": {
|
||
"unit": "perSecond",
|
||
"prices": { "480p": 15, "720p": 30, "1080p": 60 }
|
||
},
|
||
"kling3.0-omni": {
|
||
"unit": "perSecond",
|
||
"prices": { "480p": 20, "720p": 40, "1080p": 80 }
|
||
},
|
||
"veo3.1": {
|
||
"unit": "perSecond",
|
||
"prices": { "480p": 10, "720p": 20, "1080p": 40 }
|
||
},
|
||
"veo3.1-fast": {
|
||
"unit": "perSecond",
|
||
"prices": { "480p": 10, "720p": 20, "1080p": 40 }
|
||
},
|
||
"audio1.0": { "unit": "perGeneration", "price": 10 },
|
||
"eleven_text_to_sound_v2": { "unit": "perGeneration", "price": 5 },
|
||
"chirp-v5": { "unit": "perGeneration", "price": 5 }
|
||
}
|
||
}"#,
|
||
"测试模型定价配置",
|
||
)
|
||
.expect_err("missing 2K price should fail");
|
||
|
||
assert!(error.to_string().contains("gpt-image-2 缺少 2K"));
|
||
}
|
||
}
|