Compare commits

...
Author SHA1 Message Date
Brandon Thomas f93d979691 tests 2026-02-28 00:19:08 -05:00
Brandon Thomas a3d863b16e tests 2026-02-27 10:42:52 -05:00
Brandon Thomas c383237e11 plumb cost estimate 2026-02-27 08:30:28 -05:00
Brandon Thomas 27452b3bce move errors 2026-02-27 06:10:03 -05:00
Brandon Thomas 8b3c3857b1 error type 2026-02-27 06:09:37 -05:00
Brandon Thomas 0749f3d530 cost estimate handler 2026-02-27 05:24:36 -05:00
23 changed files with 537 additions and 3 deletions
Generated
+2
View File
@@ -758,6 +758,7 @@ dependencies = [
"anyhow",
"artcraft_api_defs",
"log",
"seedance2pro",
"serde",
"serde_derive",
"storyteller_client",
@@ -11477,6 +11478,7 @@ dependencies = [
"actix_helpers",
"anyhow",
"artcraft_api_defs",
"artcraft_router",
"async-stripe 0.26.0",
"async-trait",
"base64 0.22.1",
@@ -0,0 +1,122 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_video_model::CommonVideoModel;
use enums::common::generation::common_video_resolution::CommonVideoResolution;
use enums::common::generation_provider::GenerationProvider;
pub const ESTIMATE_VIDEO_COST_PATH: &str = "/v1/generate/cost_estimate/video";
/// Request body for the video cost estimate endpoint.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct EstimateVideoCostRequest {
/// The video model to estimate costs for.
pub model: CommonVideoModel,
/// The provider to route the generation through.
pub provider: GenerationProvider,
/// The type of generation (determines how many input images are involved).
/// This is a tagged enum, so it looks like:
/// "generation_mode": {"type": "text_to_video"}
/// "generation_mode": {"type": "reference_image_to_video", "count": 1}
pub generation_mode: GenerationMode,
/// Optional aspect ratio.
pub aspect_ratio: Option<CommonAspectRatio>,
/// Optional resolution.
pub resolution: Option<CommonVideoResolution>,
/// Duration in seconds.
pub duration_seconds: Option<u16>,
/// Number of videos to generate in parallel.
pub video_batch_count: Option<u16>,
}
/// Describes the type of video generation being requested.
#[derive(Serialize, Deserialize, ToSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum GenerationMode {
TextToVideo,
StartFrameToVideo,
StartAndEndFrameToVideo,
ReferenceImageToVideo { count: u32 },
}
/// Response body for the video cost estimate endpoint.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct EstimateVideoCostResponse {
pub success: bool,
/// Estimated cost in credits.
pub cost_in_credits: Option<u64>,
/// Estimated cost in USD cents.
pub cost_in_usd_cents: Option<u64>,
/// Whether the generation is free for this user/plan.
pub is_free: bool,
/// Whether the user has unlimited generations.
pub is_unlimited: bool,
/// Whether the user is rate limited.
pub is_rate_limited: bool,
/// Whether the output will have a watermark.
pub has_watermark: bool,
}
/// Error response for the video cost estimate endpoint.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct EstimateVideoCostError {
pub success: bool,
pub error_type: EstimateVideoCostErrorType,
pub error_message: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum EstimateVideoCostErrorType {
InvalidProviderForModel,
InvalidInput,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_serialization_tagging1() {
let request = EstimateVideoCostRequest {
model: CommonVideoModel::GrokVideo,
provider: GenerationProvider::Artcraft,
generation_mode: GenerationMode::TextToVideo,
aspect_ratio: None,
resolution: None,
duration_seconds: None,
video_batch_count: None,
};
let serialized = serde_json::to_string(&request).unwrap();
assert!(serialized.contains("\"generation_mode\":{\"type\":\"text_to_video\"}"));
//assert_eq!(serialized, "{}");
}
#[test]
fn test_serialization_tagging2() {
let request = EstimateVideoCostRequest {
model: CommonVideoModel::GrokVideo,
provider: GenerationProvider::Artcraft,
generation_mode: GenerationMode::ReferenceImageToVideo { count: 1 },
aspect_ratio: None,
resolution: None,
duration_seconds: None,
video_batch_count: None,
};
let serialized = serde_json::to_string(&request).unwrap();
assert!(serialized.contains("\"generation_mode\":{\"type\":\"reference_image_to_video\",\"count\":1}"));
}
}
@@ -0,0 +1 @@
pub mod estimate_video_cost;
@@ -1,3 +1,4 @@
pub mod cost_estimate;
pub mod image;
pub mod object;
pub mod video;
+1
View File
@@ -17,6 +17,7 @@ workspace-build-acceleration.workspace = true
# Internal
artcraft_api_defs.workspace = true
seedance2pro.workspace = true
storyteller_client.workspace = true
tokens.workspace = true
@@ -0,0 +1,97 @@
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0BatchCount;
use seedance2pro::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro::requests::generate_video::generate_video::{BatchCount, GenerateVideoArgs, Resolution};
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance2p0::PlanArtcraftSeedance2p0;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
pub (crate) fn estimate_video_cost_artcraft_seedance2p0(
plan: &PlanArtcraftSeedance2p0<'_>,
) -> VideoGenerationCostEstimate {
let duration_seconds = plan.duration_seconds.unwrap_or(5).clamp(4, 15);
let batch_count = match plan.batch_count {
Seedance2p0BatchCount::One => BatchCount::One,
Seedance2p0BatchCount::Two => BatchCount::Two,
Seedance2p0BatchCount::Four => BatchCount::Four,
};
// TODO: Make a better client that doesn't require this.
// A dummy session is sufficient — cost estimation does not make any network calls.
let dummy_session = Seedance2ProSession::from_cookies_string(String::new());
let args = GenerateVideoArgs {
session: &dummy_session,
prompt: String::new(),
resolution: Resolution::Square1x1,
duration_seconds,
batch_count,
// NB: These do not contribute to costs in the Seedance2 integration
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
};
let cost_in_usd_cents = args.estimate_cost_in_usd_cents();
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
}
}
#[cfg(test)]
mod tests {
use crate::api::common_video_model::CommonVideoModel;
use crate::api::provider::Provider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_video::begin_video_generation::begin_video_generation;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
fn estimate_usd_cents(duration_seconds: u16, video_batch_count: u16) -> u64 {
let request = GenerateVideoRequest {
model: CommonVideoModel::Seedance2p0,
provider: Provider::Artcraft,
prompt: None,
start_frame: None,
end_frame: None,
reference_images: None,
resolution: None,
aspect_ratio: None,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
idempotency_token: None,
};
begin_video_generation(&request)
.expect("begin_video_generation should succeed")
.estimate_costs()
.cost_in_usd_cents
.expect("cost_in_usd_cents should be present")
}
#[test]
fn test_estimate_cost_usd_cents() {
// Batch 1:
assert_eq!(estimate_usd_cents(4, 1), 64);
assert_eq!(estimate_usd_cents(5, 1), 80);
assert_eq!(estimate_usd_cents(6, 1), 96);
assert_eq!(estimate_usd_cents(7, 1), 112);
assert_eq!(estimate_usd_cents(15, 1), 240);
// Batch 2 = 2×
assert_eq!(estimate_usd_cents(4, 2), 128);
assert_eq!(estimate_usd_cents(5, 2), 160);
assert_eq!(estimate_usd_cents(15, 2), 480);
// Batch 4 = 3× (not 4×)
assert_eq!(estimate_usd_cents(4, 4), 192);
assert_eq!(estimate_usd_cents(5, 4), 240);
assert_eq!(estimate_usd_cents(15, 4), 720);
}
}
@@ -0,0 +1 @@
pub mod estimate_video_cost_artcraft_seedance2p0;
@@ -0,0 +1 @@
pub (crate) mod artcraft;
@@ -1,5 +1,7 @@
pub mod begin_video_generation;
pub mod cost;
pub mod execute;
pub mod generate_video_request;
pub mod plan;
pub mod begin_video_generation;
pub mod video_generation_cost_estimate;
pub mod video_generation_plan;
@@ -0,0 +1,9 @@
/// The result of estimating the cost of a video generation plan.
pub struct VideoGenerationCostEstimate {
pub cost_in_credits: Option<u64>,
pub cost_in_usd_cents: Option<u64>,
pub is_free: bool,
pub is_unlimited: bool,
pub is_rate_limited: bool,
pub has_watermark: bool,
}
@@ -1,7 +1,9 @@
use crate::client::router_client::RouterClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::cost::artcraft::estimate_video_cost_artcraft_seedance2p0::estimate_video_cost_artcraft_seedance2p0;
use crate::generate::generate_video::execute::artcraft::generate_video_artcraft_seedance2p0::execute_artcraft_seedance2p0;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance2p0::PlanArtcraftSeedance2p0;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
#[derive(Clone, Debug)]
@@ -27,4 +29,12 @@ impl<'a> VideoGenerationPlan<'a> {
}
}
}
pub fn estimate_costs(&self) -> VideoGenerationCostEstimate {
match self {
VideoGenerationPlan::ArtcraftSeedance2p0(plan) => {
estimate_video_cost_artcraft_seedance2p0(plan)
}
}
}
}
@@ -0,0 +1,25 @@
use utoipa::ToSchema;
/// Common aspect ratios for video generation.
/// Mirrors artcraft_router::api::common_aspect_ratio::CommonAspectRatio.
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum CommonAspectRatio {
Auto,
Square,
WideThreeByTwo,
WideFourByThree,
WideFiveByFour,
WideSixteenByNine,
WideTwentyOneByNine,
TallTwoByThree,
TallThreeByFour,
TallFourByFive,
TallNineBySixteen,
TallNineByTwentyOne,
Wide,
Tall,
Auto2k,
Auto4k,
SquareHd,
}
@@ -0,0 +1,52 @@
use utoipa::ToSchema;
/// Video models available for generation.
/// Mirrors artcraft_router::api::common_video_model::CommonVideoModel.
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum CommonVideoModel {
#[serde(rename = "grok_video")]
GrokVideo,
#[serde(rename = "kling_1p6_pro")]
Kling16Pro,
#[serde(rename = "kling_2p1_pro")]
Kling21Pro,
#[serde(rename = "kling_2p1_master")]
Kling21Master,
#[serde(rename = "kling_2p5_turbo_pro")]
Kling2p5TurboPro,
#[serde(rename = "kling_2p6_pro")]
Kling2p6Pro,
#[serde(rename = "seedance_1p0_lite")]
Seedance10Lite,
#[serde(rename = "seedance_2p0")]
Seedance2p0,
#[serde(rename = "sora_2")]
Sora2,
#[serde(rename = "sora_2_pro")]
Sora2Pro,
#[serde(rename = "veo_2")]
Veo2,
#[serde(rename = "veo_3")]
Veo3,
#[serde(rename = "veo_3_fast")]
Veo3Fast,
#[serde(rename = "veo_3p1")]
Veo3p1,
#[serde(rename = "veo_3p1_fast")]
Veo3p1Fast,
}
@@ -0,0 +1,12 @@
use utoipa::ToSchema;
/// Common video resolutions.
/// Mirrors artcraft_router::api::common_resolution::CommonVideoResolution.
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum CommonVideoResolution {
OneK,
TwoK,
ThreeK,
FourK,
}
@@ -0,0 +1,3 @@
pub mod common_aspect_ratio;
pub mod common_video_model;
pub mod common_video_resolution;
@@ -1,3 +1,10 @@
//! This is an important enum.
//!
//! It's used in storyteller-web inference generation, the cost estimate handler,
//! the ArtCraft Tauri app, and more.
//!
//! Do not change the values here without cause or care.
use std::collections::BTreeSet;
use crate::error::enum_error::EnumError;
@@ -1,5 +1,6 @@
pub mod artcraft_credits_pack_slug;
pub mod artcraft_subscription_slug;
pub mod generation;
pub mod generation_provider;
pub mod job_status;
pub mod job_status_plus;
@@ -36,6 +36,7 @@ actix_artcraft.workspace = true
actix_cors_configs = { path = "../../../lib/actix_cors_configs" }
actix_helpers.workspace = true
artcraft_api_defs.workspace = true
artcraft_router.workspace = true
bootstrap = { path = "../../../lib/bootstrap" }
bucket_paths = { path = "../../../lib/bucket_paths" }
cloud_storage.workspace = true
@@ -0,0 +1,176 @@
use std::fmt::{Display, Formatter};
use actix_http::StatusCode;
use actix_web::web::Json;
use actix_web::{HttpResponse, ResponseError};
use artcraft_api_defs::generate::cost_estimate::estimate_video_cost::{
EstimateVideoCostError, EstimateVideoCostErrorType, EstimateVideoCostRequest,
EstimateVideoCostResponse,
};
use artcraft_router::api::common_aspect_ratio::CommonAspectRatio as RouterAspectRatio;
use artcraft_router::api::common_resolution::CommonVideoResolution as RouterResolution;
use artcraft_router::api::common_video_model::CommonVideoModel as RouterVideoModel;
use artcraft_router::api::provider::Provider as RouterProvider;
use artcraft_router::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use artcraft_router::generate::generate_video::begin_video_generation::begin_video_generation;
use artcraft_router::generate::generate_video::generate_video_request::GenerateVideoRequest;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_video_model::CommonVideoModel;
use enums::common::generation::common_video_resolution::CommonVideoResolution;
use enums::common::generation_provider::GenerationProvider;
/// Estimate the credit and USD cost of a video generation request.
/// Does not require authentication and does not charge any credits.
#[utoipa::path(
post,
tag = "Cost Estimate",
path = "/v1/generate/cost_estimate/video",
responses(
(status = 200, description = "Cost estimate", body = EstimateVideoCostResponse),
(status = 400, description = "Invalid request", body = EstimateVideoCostError),
),
)]
pub async fn estimate_video_cost_handler(
request: Json<EstimateVideoCostRequest>,
) -> Result<Json<EstimateVideoCostResponse>, HandlerError> {
let router_provider = map_provider(request.provider, request.model)?;
let router_model = map_video_model(request.model)?;
let router_aspect_ratio = request.aspect_ratio.map(map_aspect_ratio);
let router_resolution = request.resolution.map(map_resolution);
let router_request = GenerateVideoRequest {
model: router_model,
provider: router_provider,
prompt: None,
start_frame: None,
end_frame: None,
reference_images: None,
resolution: router_resolution,
aspect_ratio: router_aspect_ratio,
duration_seconds: request.duration_seconds,
video_batch_count: request.video_batch_count,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade,
idempotency_token: None,
};
let plan = begin_video_generation(&router_request)
.map_err(|e| HandlerError::InvalidInput(format!("{}", e)))?;
let estimate = plan.estimate_costs();
Ok(Json(EstimateVideoCostResponse {
success: true,
cost_in_credits: estimate.cost_in_credits,
cost_in_usd_cents: estimate.cost_in_usd_cents,
is_free: estimate.is_free,
is_unlimited: estimate.is_unlimited,
is_rate_limited: estimate.is_rate_limited,
has_watermark: estimate.has_watermark,
}))
}
/// Local error type — wraps the serializable API error struct so we can implement
/// ResponseError (orphan rules prevent implementing it directly on the foreign type).
#[derive(Debug)]
pub enum HandlerError {
InvalidProviderForModel { provider: String, model: String },
InvalidInput(String),
}
impl std::error::Error for HandlerError {}
impl Display for HandlerError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self)
}
}
impl ResponseError for HandlerError {
fn status_code(&self) -> StatusCode {
StatusCode::BAD_REQUEST
}
fn error_response(&self) -> HttpResponse {
let (error_type, error_message) = match self {
HandlerError::InvalidProviderForModel { provider, model } => (
EstimateVideoCostErrorType::InvalidProviderForModel,
format!("Provider '{}' is not supported for model '{}'", provider, model),
),
HandlerError::InvalidInput(msg) => (
EstimateVideoCostErrorType::InvalidInput,
msg.clone(),
),
};
HttpResponse::BadRequest().json(EstimateVideoCostError {
success: false,
error_type,
error_message,
})
}
}
fn map_provider(
provider: GenerationProvider,
model: CommonVideoModel,
) -> Result<RouterProvider, HandlerError> {
match provider {
GenerationProvider::Artcraft => Ok(RouterProvider::Artcraft),
other => Err(HandlerError::InvalidProviderForModel {
provider: format!("{:?}", other),
model: format!("{:?}", model),
}),
}
}
fn map_video_model(model: CommonVideoModel) -> Result<RouterVideoModel, HandlerError> {
let router_model = match model {
CommonVideoModel::GrokVideo => RouterVideoModel::GrokVideo,
CommonVideoModel::Kling16Pro => RouterVideoModel::Kling16Pro,
CommonVideoModel::Kling21Pro => RouterVideoModel::Kling21Pro,
CommonVideoModel::Kling21Master => RouterVideoModel::Kling21Master,
CommonVideoModel::Kling2p5TurboPro => RouterVideoModel::Kling2p5TurboPro,
CommonVideoModel::Kling2p6Pro => RouterVideoModel::Kling2p6Pro,
CommonVideoModel::Seedance10Lite => RouterVideoModel::Seedance10Lite,
CommonVideoModel::Seedance2p0 => RouterVideoModel::Seedance2p0,
CommonVideoModel::Sora2 => RouterVideoModel::Sora2,
CommonVideoModel::Sora2Pro => RouterVideoModel::Sora2Pro,
CommonVideoModel::Veo2 => RouterVideoModel::Veo2,
CommonVideoModel::Veo3 => RouterVideoModel::Veo3,
CommonVideoModel::Veo3Fast => RouterVideoModel::Veo3Fast,
CommonVideoModel::Veo3p1 => RouterVideoModel::Veo3p1,
CommonVideoModel::Veo3p1Fast => RouterVideoModel::Veo3p1Fast,
};
Ok(router_model)
}
fn map_aspect_ratio(ar: CommonAspectRatio) -> RouterAspectRatio {
match ar {
CommonAspectRatio::Auto => RouterAspectRatio::Auto,
CommonAspectRatio::Square => RouterAspectRatio::Square,
CommonAspectRatio::WideThreeByTwo => RouterAspectRatio::WideThreeByTwo,
CommonAspectRatio::WideFourByThree => RouterAspectRatio::WideFourByThree,
CommonAspectRatio::WideFiveByFour => RouterAspectRatio::WideFiveByFour,
CommonAspectRatio::WideSixteenByNine => RouterAspectRatio::WideSixteenByNine,
CommonAspectRatio::WideTwentyOneByNine => RouterAspectRatio::WideTwentyOneByNine,
CommonAspectRatio::TallTwoByThree => RouterAspectRatio::TallTwoByThree,
CommonAspectRatio::TallThreeByFour => RouterAspectRatio::TallThreeByFour,
CommonAspectRatio::TallFourByFive => RouterAspectRatio::TallFourByFive,
CommonAspectRatio::TallNineBySixteen => RouterAspectRatio::TallNineBySixteen,
CommonAspectRatio::TallNineByTwentyOne => RouterAspectRatio::TallNineByTwentyOne,
CommonAspectRatio::Wide => RouterAspectRatio::Wide,
CommonAspectRatio::Tall => RouterAspectRatio::Tall,
CommonAspectRatio::Auto2k => RouterAspectRatio::Auto2k,
CommonAspectRatio::Auto4k => RouterAspectRatio::Auto4k,
CommonAspectRatio::SquareHd => RouterAspectRatio::SquareHd,
}
}
fn map_resolution(res: CommonVideoResolution) -> RouterResolution {
match res {
CommonVideoResolution::OneK => RouterResolution::OneK,
CommonVideoResolution::TwoK => RouterResolution::TwoK,
CommonVideoResolution::ThreeK => RouterResolution::ThreeK,
CommonVideoResolution::FourK => RouterResolution::FourK,
}
}
@@ -0,0 +1 @@
pub mod estimate_video_cost_handler;
@@ -1,4 +1,5 @@
pub mod common;
pub mod cost_estimate;
pub mod image;
pub mod object;
pub mod video;
pub mod common;
pub mod video;
@@ -1,3 +1,4 @@
use crate::http_server::endpoints::generate::cost_estimate::video::estimate_video_cost_handler::estimate_video_cost_handler;
use crate::http_server::endpoints::generate::image::edit::flux_pro_kontext_max_edit_image_handler::flux_pro_kontext_max_edit_image_handler;
use crate::http_server::endpoints::generate::image::edit::gemini_25_flash_edit_image_handler::gemini_25_flash_edit_image_handler;
use crate::http_server::endpoints::generate::image::edit::gpt_image_1_edit_image_handler::gpt_image_1_edit_image_handler;
@@ -51,6 +52,12 @@ where
>,
{
app.service(web::scope("/v1/generate")
.service(web::scope("/cost_estimate")
.service(web::resource("/video")
.route(web::post().to(estimate_video_cost_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
)
.service(web::scope("/image")
.service(web::scope("/multi_function")
.service(web::resource("/bytedance_seedream_4")