Files
Genarrative/server-rs/crates/api-server/src/editor_generation_config.rs
T
kdletters 187d7735b9 修复编辑器定价持久化与资源预览
将模型定价改为 SpacetimeDB 强类型持久化并兼容旧配置种子迁移
修复快速编辑支付弹窗期间框选显示和交互冻结
补齐后台图片放大以及视频音频资源预览
收紧主站和 External 资源换签授权并记录管理员跨用户审计
固化外部生成入队价格、attempt 钱包结算和最终 lease 失败收口
加固运行时身份轮换、bootstrap secret 与生产构建发布门禁
同步生成绑定、定向测试、运维脚本和项目文档
2026-07-10 18:28:06 +08:00

914 lines
31 KiB
Rust

use std::{
collections::BTreeMap,
fmt, fs, io,
path::{Path, PathBuf},
sync::{Arc, RwLock},
};
use serde::{Deserialize, Serialize};
use crate::asset_billing::current_external_generation_billing_price_mud_points;
/// 图片画布编辑器生成类能力的泥点配置。
///
/// 中文注释:默认值来自独立 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_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>,
}
#[derive(Clone, Debug)]
pub(crate) struct EditorGenerationPricingStore {
current: Arc<RwLock<EditorGenerationPricingConfig>>,
}
#[derive(Debug)]
pub(crate) enum EditorGenerationPricingError {
Io(io::Error),
Json(serde_json::Error),
Invalid(String),
#[cfg_attr(test, allow(dead_code))]
Persistence(String),
LockPoisoned,
}
impl EditorGenerationPricingConfig {
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") => {
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_VIDU);
read_flat_price(
&self.models,
normalized_model,
EDITOR_SOUND_EFFECT_MODEL_VIDU,
)
}
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)?;
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_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)),
})
}
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,
) -> Result<EditorGenerationPricingConfig, EditorGenerationPricingError> {
next.validate()?;
let mut guard = self
.current
.write()
.map_err(|_| EditorGenerationPricingError::LockPoisoned)?;
*guard = next.clone();
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)?;
config = parse_editor_generation_pricing_json(
override_json.as_str(),
path.to_string_lossy().as_ref(),
)?;
}
config.validate()?;
Ok(config)
}
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, "模型定价配置锁已损坏"),
}
}
}
impl std::error::Error for EditorGenerationPricingError {}
#[cfg(test)]
mod tests {
use super::*;
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("audio1.0")),
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_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("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("audio1.0")),
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 },
"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("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 },
"chirp-v5": { "unit": "perGeneration", "price": 5 }
}
}"#,
"测试模型定价配置",
)
.expect_err("missing 2K price should fail");
assert!(error.to_string().contains("gpt-image-2 缺少 2K"));
}
}