Compare commits

..
Author SHA1 Message Date
Brandon Thomas 2f38d71e8e codgen 2026-04-14 03:18:41 -04:00
Brandon Thomas 079ccacd51 ban users handler 2026-04-14 03:18:25 -04:00
181 changed files with 1749 additions and 10099 deletions
Generated
-2
View File
@@ -770,7 +770,6 @@ dependencies = [
"anyhow",
"artcraft_api_defs",
"artcraft_client",
"enums",
"fal_client",
"log",
"muapi_client",
@@ -14793,7 +14792,6 @@ dependencies = [
"serde",
"serde_json",
"strum",
"test_data",
"tokio",
"uuid 1.22.0",
"workspace-build-acceleration",
@@ -15,7 +15,7 @@ body:
type: json
content: |
{
"user_token": "user_rtdhen9kz73kx", //
"user_token": "user_jg7ng4kdt2x5y", // newuser946
"payments_namespace": "artcraft"
}
order: 4100053395280358
@@ -16,8 +16,7 @@ pub struct GenerateVeo2ImageToVideoRequest {
/// Optional text prompt.
pub prompt: Option<String>,
/// **NB(bt): ASPECT RATIO IS ACTUALLY NOT USED IN VEO 2 IMAGE-TO-VIDEO**
/// Optional: aspect ratio of the generated video.
pub aspect_ratio: Option<GenerateVeo2AspectRatio>,
@@ -21,9 +21,6 @@ pub struct GenerateVeo3FastImageToVideoRequest {
pub resolution: Option<GenerateVeo3FastResolution>,
/// Optional: aspect ratio of the generated video.
pub aspect_ratio: Option<GenerateVeo3FastAspectRatio>,
/// Optional: duration of the generated video.
pub duration: Option<GenerateVeo3FastDuration>,
/// Optional: Generate an audio track along with the video
@@ -37,14 +34,6 @@ pub enum GenerateVeo3FastResolution {
TenEightyP, // 1080p
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum GenerateVeo3FastAspectRatio {
Auto,
SixteenByNine,
NineBySixteen,
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum GenerateVeo3FastDuration {
@@ -42,10 +42,6 @@ pub struct GenerateVeo3ImageToVideoRequest {
pub enum GenerateVeo3AspectRatio {
WideSixteenNine, // 16:9
TallNineSixteen, // 9:16
/// NB: DO NOT REMOVE. This is an unsupported resolution for Veo 3, but we can't
/// impact existing callers by removing it. The handler will convert this to a
/// supported resolution or 400 the request instead.
Square, // 1:1
}
@@ -2,7 +2,6 @@ use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_image_model::CommonImageModel;
use enums::common::generation::common_quality::CommonQuality;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation::model_creator::ModelCreator;
use enums::common::generation_provider::GenerationProvider;
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
@@ -57,9 +56,6 @@ pub struct OmniGenImageProviderModelDetails {
pub struct OmniGenImageModelDetails {
pub model: CommonImageModel,
#[serde(skip_serializing_if = "Option::is_none")]
pub model_creator: Option<ModelCreator>,
#[serde(skip_serializing_if = "Option::is_none")]
pub full_name: Option<String>,
@@ -124,16 +120,12 @@ pub struct OmniGenImageModelDetails {
#[serde(skip_serializing_if = "Option::is_none")]
pub batch_size_default: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_disabled: Option<bool>,
}
impl Default for OmniGenImageModelDetails {
fn default() -> Self {
Self {
model: CommonImageModel::NanoBananaPro,
model_creator: None,
full_name: None,
text_prompt_supported: None,
text_prompt_max_length: None,
@@ -153,7 +145,6 @@ impl Default for OmniGenImageModelDetails {
batch_size_max: None,
batch_size_options: None,
batch_size_default: None,
is_disabled: None,
}
}
}
@@ -2,7 +2,6 @@ use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_quality::CommonQuality;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation::common_video_model::CommonVideoModel;
use enums::common::generation::model_creator::ModelCreator;
use enums::common::generation_provider::GenerationProvider;
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
@@ -66,9 +65,6 @@ pub struct OmniGenVideoModelDetails {
pub model: CommonVideoModel,
#[serde(skip_serializing_if = "Option::is_none")]
pub model_creator: Option<ModelCreator>,
#[serde(skip_serializing_if = "Option::is_none")]
pub full_name: Option<String>,
@@ -167,16 +163,12 @@ pub struct OmniGenVideoModelDetails {
#[serde(skip_serializing_if = "Option::is_none")]
pub batch_size_default: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_disabled: Option<bool>,
}
impl Default for OmniGenVideoModelDetails {
fn default() -> Self {
Self {
model: DEFAULT_VIDEO_MODEL,
model_creator: None,
full_name: None,
text_prompt_supported: None,
text_prompt_max_length: None,
@@ -210,7 +202,6 @@ impl Default for OmniGenVideoModelDetails {
batch_size_max: None,
batch_size_options: None,
batch_size_default: None,
is_disabled: None,
}
}
}
@@ -2,7 +2,6 @@ pub mod analytics;
pub mod credits;
pub mod generate;
pub mod jobs;
pub mod omni_gen;
pub mod media_files;
pub mod prompts;
pub mod stripe_artcraft;
@@ -1 +0,0 @@
pub mod video;
@@ -1 +0,0 @@
pub mod omni_gen_video;
@@ -1,21 +0,0 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::basic_json_post_request::basic_json_post_request;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use artcraft_api_defs::omni_gen::generate_response::omni_gen_video_generate_response::OmniGenVideoGenerateResponse;
pub const OMNI_GEN_VIDEO_GENERATE_PATH: &str = "/v1/omni_gen/generate/video";
pub async fn omni_gen_video_generate(
api_host: &ApiHost,
maybe_creds: Option<&StorytellerCredentialSet>,
request: OmniGenVideoCostAndGenerateRequest,
) -> Result<OmniGenVideoGenerateResponse, StorytellerError> {
Ok(basic_json_post_request(
api_host,
OMNI_GEN_VIDEO_GENERATE_PATH,
maybe_creds,
request,
).await?)
}
@@ -1 +0,0 @@
pub mod generate;
@@ -17,7 +17,6 @@ workspace-build-acceleration.workspace = true
# Internal
artcraft_api_defs.workspace = true
enums.workspace = true
artcraft_client.workspace = true
fal_client.workspace = true
muapi_client.workspace = true
@@ -38,9 +38,6 @@ pub enum CommonVideoModel {
#[serde(rename = "seedance_2p0")]
Seedance2p0,
#[serde(rename = "seedance_2p0_fast")]
Seedance2p0Fast,
#[serde(rename = "sora_2")]
Sora2,
@@ -1,164 +0,0 @@
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0BatchCount;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro_client::requests::generate_video::generate_video::{BatchCount, GenerateVideoArgs, ModelType, 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_fast(
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,
};
let dummy_session = Seedance2ProSession::from_cookies_string(String::new());
let args = GenerateVideoArgs {
session: &dummy_session,
model_type: ModelType::Seedance2Fast, // <-- Fast, not Pro
prompt: String::new(),
resolution: Resolution::Square1x1,
duration_seconds,
batch_count,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
host_override: 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::generate_video_request::GenerateVideoRequest;
fn estimate_usd_cents(duration_seconds: u16, video_batch_count: u16) -> u64 {
let request = GenerateVideoRequest {
model: CommonVideoModel::Seedance2p0Fast,
provider: Provider::Artcraft,
prompt: None,
negative_prompt: None,
start_frame: None,
end_frame: None,
reference_images: None,
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution: None,
aspect_ratio: None,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
generate_audio: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
idempotency_token: None,
};
request.build()
.expect("build should succeed")
.estimate_costs()
.cost_in_usd_cents
.expect("cost_in_usd_cents should be present")
}
fn estimate_pro_usd_cents(duration_seconds: u16, video_batch_count: u16) -> u64 {
let request = GenerateVideoRequest {
model: CommonVideoModel::Seedance2p0,
provider: Provider::Artcraft,
prompt: None,
negative_prompt: None,
start_frame: None,
end_frame: None,
reference_images: None,
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution: None,
aspect_ratio: None,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
generate_audio: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
idempotency_token: None,
};
request.build()
.expect("build should succeed")
.estimate_costs()
.cost_in_usd_cents
.expect("cost_in_usd_cents should be present")
}
#[test]
fn cost_is_present() {
assert!(estimate_usd_cents(5, 1) > 0);
}
#[test]
fn cost_batch_1() {
assert_eq!(estimate_usd_cents(4, 1), 51);
assert_eq!(estimate_usd_cents(5, 1), 64);
assert_eq!(estimate_usd_cents(10, 1), 127);
assert_eq!(estimate_usd_cents(15, 1), 191);
}
#[test]
fn cost_batch_2() {
assert_eq!(estimate_usd_cents(5, 2), 127);
}
#[test]
fn cost_batch_4() {
assert_eq!(estimate_usd_cents(5, 4), 255);
}
#[test]
fn fast_is_cheaper_than_pro() {
for duration in [4, 5, 10, 15] {
let fast = estimate_usd_cents(duration, 1);
let pro = estimate_pro_usd_cents(duration, 1);
assert!(
fast < pro,
"Fast ({}) should be cheaper than Pro ({}) at {}s",
fast, pro, duration,
);
}
}
#[test]
fn cost_scales_with_duration() {
let c4 = estimate_usd_cents(4, 1);
let c10 = estimate_usd_cents(10, 1);
let c15 = estimate_usd_cents(15, 1);
assert!(c4 < c10);
assert!(c10 < c15);
}
#[test]
fn cost_scales_with_batch() {
let b1 = estimate_usd_cents(5, 1);
let b2 = estimate_usd_cents(5, 2);
let b4 = estimate_usd_cents(5, 4);
assert!(b1 < b2);
assert!(b2 < b4);
}
}
@@ -8,7 +8,6 @@ pub mod estimate_video_cost_artcraft_kling3p0_standard;
pub mod estimate_video_cost_artcraft_seedance_1_0_lite;
pub mod estimate_video_cost_artcraft_seedance1p5_pro;
pub mod estimate_video_cost_artcraft_seedance2p0;
pub mod estimate_video_cost_artcraft_seedance2p0_fast;
pub mod estimate_video_cost_artcraft_sora_2;
pub mod estimate_video_cost_artcraft_sora_2_pro;
pub mod estimate_video_cost_artcraft_veo_2;
@@ -1,129 +0,0 @@
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro_client::requests::generate_video::generate_video::{GenerateVideoArgs, ModelType, Resolution};
use crate::generate::generate_video::plan::seedance2pro::plan_generate_video_seedance2pro_seedance2p0_fast::PlanSeedance2proSeedance2p0Fast;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
pub(crate) fn estimate_video_cost_seedance2pro_seedance2p0_fast(
plan: &PlanSeedance2proSeedance2p0Fast,
) -> VideoGenerationCostEstimate {
// 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,
model_type: ModelType::Seedance2Fast, // <-- Fast, not Pro
prompt: String::new(),
resolution: Resolution::Square1x1, // Resolution does not affect cost
duration_seconds: plan.duration_seconds,
batch_count: plan.batch_count,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
host_override: None,
};
let cost_in_credits = args.estimate_credits();
let cost_in_usd_cents = args.estimate_cost_in_usd_cents();
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_credits as u64),
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::generate_video_request::GenerateVideoRequest;
fn estimate_usd_cents(duration_seconds: u16, video_batch_count: u16) -> u64 {
let request = GenerateVideoRequest {
model: CommonVideoModel::Seedance2p0Fast,
provider: Provider::Seedance2Pro,
prompt: None,
negative_prompt: None,
start_frame: None,
end_frame: None,
reference_images: None,
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution: None,
aspect_ratio: None,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
generate_audio: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
idempotency_token: None,
};
request.build()
.expect("build should succeed")
.estimate_costs()
.cost_in_usd_cents
.expect("cost_in_usd_cents should be present")
}
#[test]
fn cost_is_present() {
let cents = estimate_usd_cents(5, 1);
assert!(cents > 0);
}
#[test]
fn cost_batch_1() {
// Seedance2Fast: 28 credits/sec, 220 credits/$1 (with rounding)
assert_eq!(estimate_usd_cents(4, 1), 51);
assert_eq!(estimate_usd_cents(5, 1), 64);
assert_eq!(estimate_usd_cents(10, 1), 127);
assert_eq!(estimate_usd_cents(15, 1), 191);
}
#[test]
fn cost_batch_2() {
assert_eq!(estimate_usd_cents(5, 2), 127);
}
#[test]
fn cost_batch_4() {
assert_eq!(estimate_usd_cents(5, 4), 255);
}
#[test]
fn fast_is_cheaper_than_pro() {
let fast = estimate_usd_cents(5, 1);
let pro_request = GenerateVideoRequest {
model: CommonVideoModel::Seedance2p0,
provider: Provider::Seedance2Pro,
prompt: None,
negative_prompt: None,
start_frame: None,
end_frame: None,
reference_images: None,
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution: None,
aspect_ratio: None,
duration_seconds: Some(5),
video_batch_count: Some(1),
generate_audio: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
idempotency_token: None,
};
let pro = pro_request.build().unwrap().estimate_costs().cost_in_usd_cents.unwrap();
assert!(fast < pro, "Fast ({}) should be cheaper than Pro ({})", fast, pro);
}
}
@@ -1,2 +1 @@
pub mod estimate_video_cost_seedance2pro_seedance2p0;
pub mod estimate_video_cost_seedance2pro_seedance2p0_fast;
@@ -1,65 +0,0 @@
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_video::generate_video_response::{
ArtcraftVideoResponsePayload, GenerateVideoResponse,
};
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance2p0::PlanArtcraftSeedance2p0;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use artcraft_client::endpoints::omni_gen::generate::video::omni_gen_video::omni_gen_video_generate;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_video_model::CommonVideoModel;
/// Execute Seedance 2.0 Fast via the Artcraft omni-gen video endpoint.
///
/// Unlike Seedance 2.0 Pro (which uses the legacy dedicated endpoint),
/// Seedance 2.0 Fast routes through the omni-gen unified video endpoint.
pub async fn execute_artcraft_seedance2p0_fast(
plan: &PlanArtcraftSeedance2p0<'_>,
artcraft_client: &RouterArtcraftClient,
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let aspect_ratio = plan.aspect_ratio.map(|ar| match ar {
artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0AspectRatio::Landscape16x9 => CommonAspectRatio::WideSixteenByNine,
artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0AspectRatio::Portrait9x16 => CommonAspectRatio::TallNineBySixteen,
artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0AspectRatio::Square1x1 => CommonAspectRatio::Square,
artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0AspectRatio::Standard4x3 => CommonAspectRatio::WideFourByThree,
artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0AspectRatio::Portrait3x4 => CommonAspectRatio::TallThreeByFour,
});
let request = OmniGenVideoCostAndGenerateRequest {
idempotency_token: Some(plan.idempotency_token.clone()),
model: Some(CommonVideoModel::Seedance2p0Fast),
prompt: plan.prompt.map(|p| p.to_string()),
negative_prompt: None,
start_frame_image_media_token: plan.start_frame.cloned(),
end_frame_image_media_token: plan.end_frame.cloned(),
reference_image_media_tokens: plan.reference_images.cloned(),
reference_video_media_tokens: plan.reference_videos.cloned(),
reference_audio_media_tokens: plan.reference_audio.cloned(),
reference_character_tokens: None,
resolution: None,
aspect_ratio,
quality: None,
duration_seconds: plan.duration_seconds.map(|d| d as u16),
video_batch_count: Some(match plan.batch_count {
artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0BatchCount::One => 1,
artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0BatchCount::Two => 2,
artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0BatchCount::Four => 4,
}),
generate_audio: None,
};
let response = omni_gen_video_generate(
&artcraft_client.api_host,
Some(&artcraft_client.credentials),
request,
)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
let all_tokens = vec![response.inference_job_token.clone()];
Ok(GenerateVideoResponse::Artcraft(ArtcraftVideoResponsePayload {
inference_job_token: response.inference_job_token,
all_inference_job_tokens: all_tokens,
}))
}
@@ -6,72 +6,31 @@ use crate::generate::generate_video::generate_video_response::{
};
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_veo_2::PlanArtcraftVeo2;
use artcraft_api_defs::generate::video::generate_veo_2_image_to_video::GenerateVeo2ImageToVideoRequest;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use artcraft_client::endpoints::generate::video::generate_veo_2_image_to_video::generate_veo_2_image_to_video;
use artcraft_client::endpoints::omni_gen::generate::video::omni_gen_video::omni_gen_video_generate;
use enums::common::generation::common_video_model::CommonVideoModel;
pub async fn execute_artcraft_veo_2(
plan: &PlanArtcraftVeo2<'_>,
artcraft_client: &RouterArtcraftClient,
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let inference_job_token = match plan.start_frame {
Some(media_file_token) => {
// Image-to-video: use the legacy dedicated endpoint.
let request = GenerateVeo2ImageToVideoRequest {
uuid_idempotency_token: plan.idempotency_token.clone(),
media_file_token: Some(media_file_token.to_owned()),
prompt: plan.prompt.map(|p| p.to_string()),
aspect_ratio: plan.aspect_ratio.clone(),
duration: plan.duration.clone(),
};
let response = generate_veo_2_image_to_video(
&artcraft_client.api_host,
Some(&artcraft_client.credentials),
request,
)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
response.inference_job_token
}
None => {
// Text-to-video: use the omni-gen unified endpoint.
let request = OmniGenVideoCostAndGenerateRequest {
idempotency_token: Some(plan.idempotency_token.clone()),
model: Some(CommonVideoModel::Veo2),
prompt: plan.prompt.map(|p| p.to_string()),
negative_prompt: None,
start_frame_image_media_token: None,
end_frame_image_media_token: None,
reference_image_media_tokens: None,
reference_video_media_tokens: None,
reference_audio_media_tokens: None,
reference_character_tokens: None,
resolution: None,
aspect_ratio: None,
quality: None,
duration_seconds: None,
video_batch_count: None,
generate_audio: None,
};
let response = omni_gen_video_generate(
&artcraft_client.api_host,
Some(&artcraft_client.credentials),
request,
)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
response.inference_job_token
}
let request = GenerateVeo2ImageToVideoRequest {
uuid_idempotency_token: plan.idempotency_token.clone(),
media_file_token: Some(plan.start_frame.to_owned()),
prompt: plan.prompt.map(|p| p.to_string()),
aspect_ratio: plan.aspect_ratio.clone(),
duration: plan.duration.clone(),
};
let all_tokens = vec![inference_job_token.clone()];
let response = generate_veo_2_image_to_video(
&artcraft_client.api_host,
Some(&artcraft_client.credentials),
request,
)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
let all_tokens = vec![response.inference_job_token.clone()];
Ok(GenerateVideoResponse::Artcraft(ArtcraftVideoResponsePayload {
inference_job_token,
inference_job_token: response.inference_job_token,
all_inference_job_tokens: all_tokens,
}))
}
@@ -6,74 +6,33 @@ use crate::generate::generate_video::generate_video_response::{
};
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_veo_3::PlanArtcraftVeo3;
use artcraft_api_defs::generate::video::generate_veo_3_image_to_video::GenerateVeo3ImageToVideoRequest;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use artcraft_client::endpoints::generate::video::generate_veo_3_image_to_video::generate_veo_3_image_to_video;
use artcraft_client::endpoints::omni_gen::generate::video::omni_gen_video::omni_gen_video_generate;
use enums::common::generation::common_video_model::CommonVideoModel;
pub async fn execute_artcraft_veo_3(
plan: &PlanArtcraftVeo3<'_>,
artcraft_client: &RouterArtcraftClient,
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let inference_job_token = match plan.start_frame {
Some(media_file_token) => {
// Image-to-video: use the legacy dedicated endpoint.
let request = GenerateVeo3ImageToVideoRequest {
uuid_idempotency_token: plan.idempotency_token.clone(),
media_file_token: Some(media_file_token.to_owned()),
prompt: plan.prompt.map(|p| p.to_string()),
aspect_ratio: plan.aspect_ratio,
resolution: plan.resolution,
duration: plan.duration,
generate_audio: plan.generate_audio,
};
let response = generate_veo_3_image_to_video(
&artcraft_client.api_host,
Some(&artcraft_client.credentials),
request,
)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
response.inference_job_token
}
None => {
// Text-to-video: use the omni-gen unified endpoint.
let request = OmniGenVideoCostAndGenerateRequest {
idempotency_token: Some(plan.idempotency_token.clone()),
model: Some(CommonVideoModel::Veo3),
prompt: plan.prompt.map(|p| p.to_string()),
negative_prompt: None,
start_frame_image_media_token: None,
end_frame_image_media_token: None,
reference_image_media_tokens: None,
reference_video_media_tokens: None,
reference_audio_media_tokens: None,
reference_character_tokens: None,
resolution: None,
aspect_ratio: None,
quality: None,
duration_seconds: None,
video_batch_count: None,
generate_audio: plan.generate_audio,
};
let response = omni_gen_video_generate(
&artcraft_client.api_host,
Some(&artcraft_client.credentials),
request,
)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
response.inference_job_token
}
let request = GenerateVeo3ImageToVideoRequest {
uuid_idempotency_token: plan.idempotency_token.clone(),
media_file_token: Some(plan.start_frame.to_owned()),
prompt: plan.prompt.map(|p| p.to_string()),
aspect_ratio: plan.aspect_ratio,
resolution: plan.resolution,
duration: plan.duration,
generate_audio: plan.generate_audio,
};
let all_tokens = vec![inference_job_token.clone()];
let response = generate_veo_3_image_to_video(
&artcraft_client.api_host,
Some(&artcraft_client.credentials),
request,
)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
let all_tokens = vec![response.inference_job_token.clone()];
Ok(GenerateVideoResponse::Artcraft(ArtcraftVideoResponsePayload {
inference_job_token,
inference_job_token: response.inference_job_token,
all_inference_job_tokens: all_tokens,
}))
}
@@ -14,9 +14,8 @@ pub async fn execute_artcraft_veo_3_fast(
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let request = GenerateVeo3FastImageToVideoRequest {
uuid_idempotency_token: plan.idempotency_token.clone(),
media_file_token: plan.start_frame.map(|t| t.to_owned()),
media_file_token: Some(plan.start_frame.to_owned()),
prompt: plan.prompt.map(|p| p.to_string()),
aspect_ratio: plan.aspect_ratio,
resolution: plan.resolution,
duration: plan.duration,
generate_audio: plan.generate_audio,
@@ -8,7 +8,6 @@ pub mod generate_video_artcraft_kling3p0_standard;
pub mod generate_video_artcraft_seedance_1_0_lite;
pub mod generate_video_artcraft_seedance1p5_pro;
pub mod generate_video_artcraft_seedance2p0;
pub mod generate_video_artcraft_seedance2p0_fast;
pub mod generate_video_artcraft_sora_2;
pub mod generate_video_artcraft_sora_2_pro;
pub mod generate_video_artcraft_veo_2;
@@ -4,50 +4,28 @@ use crate::errors::provider_error::ProviderError;
use crate::generate::generate_video::generate_video_response::{
FalVideoResponsePayload, GenerateVideoResponse,
};
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_veo_2::{
FalVeo2Mode, PlanFalVeo2,
};
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_veo_2::PlanFalVeo2;
use fal_client::requests::webhook::video::image::enqueue_veo_2_image_to_video_webhook::{
enqueue_veo_2_image_to_video_webhook, Veo2Args, Veo2AspectRatio,
};
use fal_client::requests::webhook::video::text::enqueue_veo_2_text_to_video_webhook::{
enqueue_veo_2_text_to_video_webhook, Veo2TextToVideoArgs,
enqueue_veo_2_image_to_video_webhook, Veo2Args,
};
pub async fn execute_fal_veo_2(
plan: &PlanFalVeo2,
fal_client: &RouterFalClient,
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let webhook_response = match &plan.mode {
FalVeo2Mode::TextToVideo => {
let args = Veo2TextToVideoArgs {
prompt: plan.prompt.as_str(),
negative_prompt: plan.negative_prompt.as_deref(),
api_key: &fal_client.api_key,
duration: plan.duration,
aspect_ratio: plan.aspect_ratio.unwrap_or(Veo2AspectRatio::Auto),
webhook_url: fal_client.webhook_url.as_str(),
};
enqueue_veo_2_text_to_video_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
}
FalVeo2Mode::ImageToVideo { image_url } => {
// Image-to-video does not support aspect_ratio — the output
// inherits the source image's aspect ratio.
let args = Veo2Args {
image_url: image_url.as_str(),
prompt: plan.prompt.as_str(),
api_key: &fal_client.api_key,
duration: plan.duration,
webhook_url: fal_client.webhook_url.as_str(),
};
enqueue_veo_2_image_to_video_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
}
let args = Veo2Args {
image_url: plan.start_frame_url.as_str(),
prompt: plan.prompt.as_str(),
api_key: &fal_client.api_key,
duration: plan.duration,
aspect_ratio: plan.aspect_ratio,
webhook_url: fal_client.webhook_url.as_str(),
};
let webhook_response = enqueue_veo_2_image_to_video_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
@@ -4,106 +4,30 @@ use crate::errors::provider_error::ProviderError;
use crate::generate::generate_video::generate_video_response::{
FalVideoResponsePayload, GenerateVideoResponse,
};
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_veo_3::{
FalVeo3Duration, FalVeo3I2vAspectRatio, FalVeo3Mode, FalVeo3Resolution,
FalVeo3T2vAspectRatio, PlanFalVeo3,
};
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_veo_3::PlanFalVeo3;
use fal_client::requests::webhook::video::image::enqueue_veo_3_image_to_video_webhook::{
enqueue_veo_3_image_to_video_webhook, Veo3Args, Veo3I2vAspectRatio, Veo3I2vDuration,
Veo3I2vResolution,
enqueue_veo_3_image_to_video_webhook, Veo3Args,
};
use fal_client::requests::webhook::video::text::enqueue_veo_3_text_to_video_webhook::{
enqueue_veo_3_text_to_video_webhook, Veo3T2vAspectRatio, Veo3T2vDuration, Veo3T2vResolution,
Veo3TextToVideoArgs,
};
fn to_i2v_duration(d: FalVeo3Duration) -> Veo3I2vDuration {
match d {
FalVeo3Duration::Default => Veo3I2vDuration::Default,
FalVeo3Duration::FourSeconds => Veo3I2vDuration::FourSeconds,
FalVeo3Duration::SixSeconds => Veo3I2vDuration::SixSeconds,
FalVeo3Duration::EightSeconds => Veo3I2vDuration::EightSeconds,
}
}
fn to_t2v_duration(d: FalVeo3Duration) -> Veo3T2vDuration {
match d {
FalVeo3Duration::Default => Veo3T2vDuration::Default,
FalVeo3Duration::FourSeconds => Veo3T2vDuration::FourSeconds,
FalVeo3Duration::SixSeconds => Veo3T2vDuration::SixSeconds,
FalVeo3Duration::EightSeconds => Veo3T2vDuration::EightSeconds,
}
}
fn to_i2v_resolution(r: FalVeo3Resolution) -> Veo3I2vResolution {
match r {
FalVeo3Resolution::Default => Veo3I2vResolution::Default,
FalVeo3Resolution::SevenTwentyP => Veo3I2vResolution::SevenTwentyP,
FalVeo3Resolution::TenEightyP => Veo3I2vResolution::TenEightyP,
}
}
fn to_t2v_resolution(r: FalVeo3Resolution) -> Veo3T2vResolution {
match r {
FalVeo3Resolution::Default => Veo3T2vResolution::Default,
FalVeo3Resolution::SevenTwentyP => Veo3T2vResolution::SevenTwentyP,
FalVeo3Resolution::TenEightyP => Veo3T2vResolution::TenEightyP,
}
}
fn to_t2v_aspect_ratio(ar: FalVeo3T2vAspectRatio) -> Veo3T2vAspectRatio {
match ar {
FalVeo3T2vAspectRatio::Default => Veo3T2vAspectRatio::Default,
FalVeo3T2vAspectRatio::WideSixteenNine => Veo3T2vAspectRatio::WideSixteenNine,
FalVeo3T2vAspectRatio::TallNineSixteen => Veo3T2vAspectRatio::TallNineSixteen,
}
}
fn to_i2v_aspect_ratio(ar: FalVeo3I2vAspectRatio) -> Veo3I2vAspectRatio {
match ar {
FalVeo3I2vAspectRatio::Auto => Veo3I2vAspectRatio::Auto,
FalVeo3I2vAspectRatio::WideSixteenNine => Veo3I2vAspectRatio::WideSixteenNine,
FalVeo3I2vAspectRatio::TallNineSixteen => Veo3I2vAspectRatio::TallNineSixteen,
}
}
pub async fn execute_fal_veo_3(
plan: &PlanFalVeo3,
fal_client: &RouterFalClient,
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let webhook_response = match &plan.mode {
FalVeo3Mode::TextToVideo => {
let args = Veo3TextToVideoArgs {
prompt: plan.prompt.as_str(),
negative_prompt: plan.negative_prompt.as_deref(),
api_key: &fal_client.api_key,
duration: to_t2v_duration(plan.duration),
aspect_ratio: plan.t2v_aspect_ratio.map(to_t2v_aspect_ratio).unwrap_or(Veo3T2vAspectRatio::Default),
resolution: to_t2v_resolution(plan.resolution),
generate_audio: plan.generate_audio,
webhook_url: fal_client.webhook_url.as_str(),
};
enqueue_veo_3_text_to_video_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
}
FalVeo3Mode::ImageToVideo { image_url } => {
let args = Veo3Args {
image_url: image_url.as_str(),
prompt: plan.prompt.as_str(),
duration: to_i2v_duration(plan.duration),
aspect_ratio: plan.i2v_aspect_ratio.map(to_i2v_aspect_ratio).unwrap_or(Veo3I2vAspectRatio::Auto),
resolution: to_i2v_resolution(plan.resolution),
generate_audio: plan.generate_audio,
api_key: &fal_client.api_key,
webhook_url: fal_client.webhook_url.as_str(),
};
enqueue_veo_3_image_to_video_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
}
let args = Veo3Args {
image_url: plan.start_frame_url.as_str(),
prompt: plan.prompt.as_str(),
duration: plan.duration,
aspect_ratio: plan.aspect_ratio,
resolution: plan.resolution,
generate_audio: plan.generate_audio,
api_key: &fal_client.api_key,
webhook_url: fal_client.webhook_url.as_str(),
};
let webhook_response = enqueue_veo_3_image_to_video_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
@@ -16,7 +16,6 @@ pub async fn execute_fal_veo_3_fast(
let args = Veo3FastArgs {
prompt: plan.prompt.as_str(),
image_url: plan.start_frame_url.as_str(),
aspect_ratio: plan.aspect_ratio,
duration: plan.duration,
api_key: &fal_client.api_key,
resolution: plan.resolution,
@@ -60,7 +60,7 @@ pub async fn execute_seedance2pro_seedance2p0(
}
/// Downloads a file from a source URL and re-uploads it to seedance2pro CDN.
pub(crate) async fn upload_to_seedance2pro(
async fn upload_to_seedance2pro(
session: &seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession,
source_url: &str,
) -> Result<String, ArtcraftRouterError> {
@@ -89,7 +89,7 @@ pub(crate) async fn upload_to_seedance2pro(
Ok(upload_response.public_url)
}
pub(crate) async fn upload_optional_url(
async fn upload_optional_url(
session: &seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession,
url: Option<&str>,
) -> Result<Option<String>, ArtcraftRouterError> {
@@ -99,7 +99,7 @@ pub(crate) async fn upload_optional_url(
}
}
pub(crate) async fn upload_optional_url_list(
async fn upload_optional_url_list(
session: &seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession,
urls: Option<&[String]>,
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
@@ -1,57 +0,0 @@
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_video::generate_video_response::{
GenerateVideoResponse, Seedance2proVideoResponsePayload,
};
use crate::generate::generate_video::plan::seedance2pro::plan_generate_video_seedance2pro_seedance2p0_fast::PlanSeedance2proSeedance2p0Fast;
use crate::generate::generate_video::execute::seedance2pro::execute_seedance2pro_seedance2p0::{
upload_optional_url, upload_optional_url_list,
};
use seedance2pro_client::requests::generate_video::generate_video::{
generate_video, GenerateVideoArgs, ModelType,
};
/// Execute Seedance 2.0 Fast via the Seedance2Pro/Kinovi provider.
///
/// Identical to the Seedance 2.0 Pro executor except it uses
/// `ModelType::Seedance2Fast` instead of `ModelType::Seedance2Pro`.
pub async fn execute_seedance2pro_seedance2p0_fast(
plan: &PlanSeedance2proSeedance2p0Fast,
seedance2pro_client: &RouterSeedance2ProClient,
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let session = &seedance2pro_client.session;
// Upload media files to seedance2pro CDN
let start_frame_url = upload_optional_url(session, plan.start_frame_url.as_deref()).await?;
let end_frame_url = upload_optional_url(session, plan.end_frame_url.as_deref()).await?;
let reference_image_urls = upload_optional_url_list(session, plan.reference_image_urls.as_deref()).await?;
let reference_video_urls = upload_optional_url_list(session, plan.reference_video_urls.as_deref()).await?;
let reference_audio_urls = upload_optional_url_list(session, plan.reference_audio_urls.as_deref()).await?;
let args = GenerateVideoArgs {
session,
model_type: ModelType::Seedance2Fast, // <-- Fast, not Pro
prompt: plan.prompt.clone().unwrap_or_default(),
resolution: plan.resolution,
duration_seconds: plan.duration_seconds,
batch_count: plan.batch_count,
start_frame_url,
end_frame_url,
reference_image_urls,
reference_video_urls,
reference_audio_urls,
character_ids: None,
use_face_blur_hack: None,
host_override: None,
};
let response = generate_video(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
Ok(GenerateVideoResponse::Seedance2Pro(Seedance2proVideoResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
}))
}
@@ -1,2 +1 @@
pub mod execute_seedance2pro_seedance2p0;
pub mod execute_seedance2pro_seedance2p0_fast;
@@ -19,7 +19,6 @@ use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraf
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance_1_0_lite::plan_generate_video_artcraft_seedance_1_0_lite;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance1p5_pro::plan_generate_video_artcraft_seedance1p5_pro;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance2p0::plan_generate_video_artcraft_seedance2p0;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance2p0_fast::plan_generate_video_artcraft_seedance2p0_fast;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_sora_2::plan_generate_video_artcraft_sora_2;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_sora_2_pro::plan_generate_video_artcraft_sora_2_pro;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_veo_2::plan_generate_video_artcraft_veo_2;
@@ -45,7 +44,6 @@ use crate::generate::generate_video::plan::fal::plan_generate_video_fal_veo_3p1:
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_veo_3p1_fast::plan_generate_video_fal_veo_3p1_fast;
use crate::generate::generate_video::plan::muapi::plan_generate_video_muapi_seedance2p0::plan_generate_video_muapi_seedance2p0;
use crate::generate::generate_video::plan::seedance2pro::plan_generate_video_seedance2pro_seedance2p0::plan_generate_video_seedance2pro_seedance2p0;
use crate::generate::generate_video::plan::seedance2pro::plan_generate_video_seedance2pro_seedance2p0_fast::plan_generate_video_seedance2pro_seedance2p0_fast;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
/// Plan to either (1) generate a video or (2) determine how much it costs to generate that video.
@@ -133,7 +131,6 @@ impl<'a> GenerateVideoRequest<'a> {
CommonVideoModel::Seedance10Lite => plan_generate_video_artcraft_seedance_1_0_lite(self),
CommonVideoModel::Seedance1p5Pro => plan_generate_video_artcraft_seedance1p5_pro(self),
CommonVideoModel::Seedance2p0 => plan_generate_video_artcraft_seedance2p0(self),
CommonVideoModel::Seedance2p0Fast => plan_generate_video_artcraft_seedance2p0_fast(self),
CommonVideoModel::Sora2 => plan_generate_video_artcraft_sora_2(self),
CommonVideoModel::Sora2Pro => plan_generate_video_artcraft_sora_2_pro(self),
CommonVideoModel::Veo2 => plan_generate_video_artcraft_veo_2(self),
@@ -177,7 +174,6 @@ impl<'a> GenerateVideoRequest<'a> {
fn build_seedance2pro(&self) -> Result<VideoGenerationPlan<'_>, ArtcraftRouterError> {
match self.model {
CommonVideoModel::Seedance2p0 => plan_generate_video_seedance2pro_seedance2p0(self),
CommonVideoModel::Seedance2p0Fast => plan_generate_video_seedance2pro_seedance2p0_fast(self),
_ => Err(ArtcraftRouterError::UnsupportedModel(format!("{:?}", self.model))),
}
}
@@ -8,7 +8,6 @@ pub mod plan_generate_video_artcraft_kling3p0_standard;
pub mod plan_generate_video_artcraft_seedance_1_0_lite;
pub mod plan_generate_video_artcraft_seedance1p5_pro;
pub mod plan_generate_video_artcraft_seedance2p0;
pub mod plan_generate_video_artcraft_seedance2p0_fast;
pub mod plan_generate_video_artcraft_sora_2;
pub mod plan_generate_video_artcraft_sora_2_pro;
pub mod plan_generate_video_artcraft_veo_2;
@@ -1,339 +0,0 @@
use crate::api::audio_list_ref::AudioListRef;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::video_list_ref::VideoListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance2p0::PlanArtcraftSeedance2p0;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{
Seedance2p0AspectRatio, Seedance2p0BatchCount,
};
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
pub fn plan_generate_video_artcraft_seedance2p0_fast<'a>(
request: &'a GenerateVideoRequest<'a>,
) -> Result<VideoGenerationPlan<'a>, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
let start_frame = resolve_image_ref(request.start_frame)?;
let end_frame = resolve_image_ref(request.end_frame)?;
let reference_images = resolve_image_list_ref(request.reference_images)?;
let reference_videos = resolve_video_list_ref(request.reference_videos)?;
let reference_audio = resolve_audio_list_ref(request.reference_audio)?;
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
let batch_count = plan_batch_count(request.video_batch_count, strategy)?;
let duration_seconds = plan_duration(request.duration_seconds, strategy)?;
Ok(VideoGenerationPlan::ArtcraftSeedance2p0Fast(PlanArtcraftSeedance2p0 {
prompt: request.prompt,
start_frame,
end_frame,
reference_images,
reference_videos,
reference_audio,
reference_characters: resolve_character_list_ref(request.reference_character_tokens),
aspect_ratio,
duration_seconds,
batch_count,
idempotency_token: request.get_or_generate_idempotency_token(),
}))
}
fn resolve_character_list_ref<'a>(
character_list_ref: Option<crate::api::character_list_ref::CharacterListRef<'a>>,
) -> Option<&'a Vec<CharacterToken>> {
match character_list_ref {
None => None,
Some(crate::api::character_list_ref::CharacterListRef::CharacterTokens(tokens)) => Some(tokens),
}
}
fn resolve_image_ref<'a>(
image_ref: Option<ImageRef<'a>>,
) -> Result<Option<&'a MediaFileToken>, ArtcraftRouterError> {
match image_ref {
None => Ok(None),
Some(ImageRef::MediaFileToken(t)) => Ok(Some(t)),
Some(ImageRef::Url(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
}
}
fn resolve_image_list_ref<'a>(
image_list_ref: Option<ImageListRef<'a>>,
) -> Result<Option<&'a Vec<MediaFileToken>>, ArtcraftRouterError> {
match image_list_ref {
None => Ok(None),
Some(ImageListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
Some(ImageListRef::Urls(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
}
}
fn resolve_video_list_ref<'a>(
video_list_ref: Option<VideoListRef<'a>>,
) -> Result<Option<&'a Vec<MediaFileToken>>, ArtcraftRouterError> {
match video_list_ref {
None => Ok(None),
Some(VideoListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
Some(VideoListRef::Urls(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
}
}
fn resolve_audio_list_ref<'a>(
audio_list_ref: Option<AudioListRef<'a>>,
) -> Result<Option<&'a Vec<MediaFileToken>>, ArtcraftRouterError> {
match audio_list_ref {
None => Ok(None),
Some(AudioListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
Some(AudioListRef::Urls(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
}
}
fn plan_aspect_ratio(
aspect_ratio: Option<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<Seedance2p0AspectRatio>, ArtcraftRouterError> {
match aspect_ratio {
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto4k) => Ok(None),
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => {
Ok(Some(Seedance2p0AspectRatio::Landscape16x9))
}
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => {
Ok(Some(Seedance2p0AspectRatio::Portrait9x16))
}
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => {
Ok(Some(Seedance2p0AspectRatio::Square1x1))
}
Some(CommonAspectRatio::WideFourByThree) => Ok(Some(Seedance2p0AspectRatio::Standard4x3)),
Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(Seedance2p0AspectRatio::Portrait3x4)),
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "aspect_ratio",
value: format!("{:?}", unsupported),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(Some(nearest_aspect_ratio(unsupported)))
}
},
}
}
fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> Seedance2p0AspectRatio {
match aspect_ratio {
CommonAspectRatio::WideFiveByFour => Seedance2p0AspectRatio::Standard4x3,
CommonAspectRatio::WideThreeByTwo => Seedance2p0AspectRatio::Standard4x3,
CommonAspectRatio::WideTwentyOneByNine => Seedance2p0AspectRatio::Landscape16x9,
CommonAspectRatio::TallFourByFive => Seedance2p0AspectRatio::Portrait3x4,
CommonAspectRatio::TallTwoByThree => Seedance2p0AspectRatio::Portrait3x4,
CommonAspectRatio::TallNineByTwentyOne => Seedance2p0AspectRatio::Portrait9x16,
_ => Seedance2p0AspectRatio::Square1x1,
}
}
fn plan_batch_count(
video_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Seedance2p0BatchCount, ArtcraftRouterError> {
let count = video_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(Seedance2p0BatchCount::One),
2 => Ok(Seedance2p0BatchCount::Two),
4 => Ok(Seedance2p0BatchCount::Four),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "video_batch_count",
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
Ok(if count < 4 { Seedance2p0BatchCount::Four } else { Seedance2p0BatchCount::Four })
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(if count < 4 { Seedance2p0BatchCount::Two } else { Seedance2p0BatchCount::Four })
}
},
}
}
fn plan_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<u8>, ArtcraftRouterError> {
const MIN: u16 = 4;
const MAX: u16 = 15;
match duration_seconds {
None => Ok(None),
Some(d) if d >= MIN && d <= MAX => Ok(Some(d as u8)),
Some(d) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "duration_seconds",
value: format!("{}", d),
}))
}
_ => Ok(Some(d.clamp(MIN, MAX) as u8)),
},
}
}
#[cfg(test)]
mod tests {
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_video_model::CommonVideoModel;
use crate::api::image_ref::ImageRef;
use crate::api::provider::Provider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{
Seedance2p0AspectRatio, Seedance2p0BatchCount,
};
fn base_request() -> GenerateVideoRequest<'static> {
GenerateVideoRequest {
model: CommonVideoModel::Seedance2p0Fast,
provider: Provider::Artcraft,
prompt: Some("a cat in space"),
negative_prompt: None,
start_frame: None,
end_frame: None,
reference_images: None,
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution: None,
aspect_ratio: None,
duration_seconds: None,
video_batch_count: None,
generate_audio: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
idempotency_token: None,
}
}
#[test]
fn produces_fast_variant() {
let req = base_request();
let plan = req.build().unwrap();
assert!(matches!(plan, VideoGenerationPlan::ArtcraftSeedance2p0Fast(_)));
}
#[test]
fn prompt_is_passed_through() {
let req = base_request();
let plan = req.build().unwrap();
if let VideoGenerationPlan::ArtcraftSeedance2p0Fast(p) = plan {
assert_eq!(p.prompt, Some("a cat in space"));
} else { panic!("wrong variant"); }
}
#[test]
fn aspect_ratio_16x9() {
let req = GenerateVideoRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..base_request()
};
let plan = req.build().unwrap();
let VideoGenerationPlan::ArtcraftSeedance2p0Fast(p) = plan else { panic!("wrong variant") };
assert!(matches!(p.aspect_ratio, Some(Seedance2p0AspectRatio::Landscape16x9)));
}
#[test]
fn aspect_ratio_9x16() {
let req = GenerateVideoRequest {
aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen),
..base_request()
};
let plan = req.build().unwrap();
let VideoGenerationPlan::ArtcraftSeedance2p0Fast(p) = plan else { panic!("wrong variant") };
assert!(matches!(p.aspect_ratio, Some(Seedance2p0AspectRatio::Portrait9x16)));
}
#[test]
fn aspect_ratio_square() {
let req = GenerateVideoRequest {
aspect_ratio: Some(CommonAspectRatio::Square),
..base_request()
};
let plan = req.build().unwrap();
let VideoGenerationPlan::ArtcraftSeedance2p0Fast(p) = plan else { panic!("wrong variant") };
assert!(matches!(p.aspect_ratio, Some(Seedance2p0AspectRatio::Square1x1)));
}
#[test]
fn batch_count_defaults_to_one() {
let req = base_request();
let plan = req.build().unwrap();
let VideoGenerationPlan::ArtcraftSeedance2p0Fast(p) = plan else { panic!("wrong variant") };
assert!(matches!(p.batch_count, Seedance2p0BatchCount::One));
}
#[test]
fn batch_count_2() {
let req = GenerateVideoRequest { video_batch_count: Some(2), ..base_request() };
let plan = req.build().unwrap();
let VideoGenerationPlan::ArtcraftSeedance2p0Fast(p) = plan else { panic!("wrong variant") };
assert!(matches!(p.batch_count, Seedance2p0BatchCount::Two));
}
#[test]
fn batch_count_4() {
let req = GenerateVideoRequest { video_batch_count: Some(4), ..base_request() };
let plan = req.build().unwrap();
let VideoGenerationPlan::ArtcraftSeedance2p0Fast(p) = plan else { panic!("wrong variant") };
assert!(matches!(p.batch_count, Seedance2p0BatchCount::Four));
}
#[test]
fn duration_in_range() {
let req = GenerateVideoRequest { duration_seconds: Some(10), ..base_request() };
let plan = req.build().unwrap();
let VideoGenerationPlan::ArtcraftSeedance2p0Fast(p) = plan else { panic!("wrong variant") };
assert_eq!(p.duration_seconds, Some(10));
}
#[test]
fn duration_clamped_to_max() {
let req = GenerateVideoRequest { duration_seconds: Some(99), ..base_request() };
let plan = req.build().unwrap();
let VideoGenerationPlan::ArtcraftSeedance2p0Fast(p) = plan else { panic!("wrong variant") };
assert_eq!(p.duration_seconds, Some(15));
}
#[test]
fn url_image_ref_returns_error() {
let req = GenerateVideoRequest {
start_frame: Some(ImageRef::Url("https://example.com/image.jpg")),
..base_request()
};
assert!(matches!(
req.build(),
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
));
}
}
@@ -13,10 +13,7 @@ use tokens::tokens::media_files::MediaFileToken;
#[derive(Debug, Clone)]
pub struct PlanArtcraftVeo2<'a> {
pub prompt: Option<&'a str>,
/// Required for execution. May be None on the cost path when the omni-gen
/// distillation hydrated the media token to a URL (which the Artcraft
/// provider can't use but cost estimation doesn't need).
pub start_frame: Option<&'a MediaFileToken>,
pub start_frame: &'a MediaFileToken,
pub aspect_ratio: Option<GenerateVeo2AspectRatio>,
pub duration: Option<GenerateVeo2Duration>,
pub idempotency_token: String,
@@ -28,13 +25,16 @@ pub fn plan_generate_video_artcraft_veo_2<'a>(
let strategy = request.request_mismatch_mitigation_strategy;
let start_frame = match request.start_frame {
Some(ImageRef::MediaFileToken(t)) => Some(t),
// Omni-gen distillation hydrates media tokens to URLs before running the
// Artcraft cost path. Cost only depends on duration, so URL-form refs are
// accepted and dropped.
Some(ImageRef::Url(_)) => None,
// No start_frame = text-to-video mode.
None => None,
Some(ImageRef::MediaFileToken(t)) => t,
Some(ImageRef::Url(_)) => {
return Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
None => {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "start_frame",
value: "Veo 2 requires a starting frame".to_string(),
}))
}
};
if request.end_frame.is_some() {
@@ -44,14 +44,7 @@ pub fn plan_generate_video_artcraft_veo_2<'a>(
}));
}
// Aspect ratio only applies to text-to-video; image-to-video inherits
// the source frame's aspect ratio.
let is_image_mode = request.start_frame.is_some();
let aspect_ratio = if is_image_mode {
None
} else {
plan_aspect_ratio(request.aspect_ratio, strategy)?
};
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
let duration = plan_duration(request.duration_seconds, strategy)?;
Ok(VideoGenerationPlan::ArtcraftVeo2(PlanArtcraftVeo2 {
@@ -14,16 +14,10 @@ use tokens::tokens::media_files::MediaFileToken;
#[derive(Debug, Clone)]
pub struct PlanArtcraftVeo3<'a> {
pub prompt: Option<&'a str>,
/// Required for image-to-video. None for text-to-video or when the
/// omni-gen distillation hydrated the token to a URL.
pub start_frame: Option<&'a MediaFileToken>,
pub start_frame: &'a MediaFileToken,
pub aspect_ratio: Option<GenerateVeo3AspectRatio>,
pub resolution: Option<GenerateVeo3Resolution>,
pub duration: Option<GenerateVeo3Duration>,
/// The raw requested duration in seconds, preserved for cost estimation.
/// The `duration` field above maps everything to `EightSeconds` for the
/// legacy endpoint, but cost should reflect the actual requested value.
pub duration_seconds_raw: Option<u16>,
pub generate_audio: Option<bool>,
pub idempotency_token: String,
}
@@ -34,13 +28,16 @@ pub fn plan_generate_video_artcraft_veo_3<'a>(
let strategy = request.request_mismatch_mitigation_strategy;
let start_frame = match request.start_frame {
Some(ImageRef::MediaFileToken(t)) => Some(t),
// Omni-gen distillation hydrates media tokens to URLs before running the
// Artcraft cost path. Cost only depends on duration/audio, so URL-form
// refs are accepted and dropped.
Some(ImageRef::Url(_)) => None,
// No start_frame = text-to-video mode.
None => None,
Some(ImageRef::MediaFileToken(t)) => t,
Some(ImageRef::Url(_)) => {
return Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
None => {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "start_frame",
value: "Veo 3 requires a starting frame".to_string(),
}))
}
};
if request.end_frame.is_some() {
@@ -50,14 +47,7 @@ pub fn plan_generate_video_artcraft_veo_3<'a>(
}));
}
// Aspect ratio only applies to text-to-video; image-to-video inherits
// the source frame's aspect ratio.
let is_image_mode = request.start_frame.is_some();
let aspect_ratio = if is_image_mode {
None
} else {
plan_aspect_ratio(request.aspect_ratio, strategy)?
};
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
let resolution = plan_resolution(request.resolution, strategy)?;
let duration = plan_duration(request.duration_seconds, strategy)?;
@@ -67,7 +57,6 @@ pub fn plan_generate_video_artcraft_veo_3<'a>(
aspect_ratio,
resolution,
duration,
duration_seconds_raw: request.duration_seconds,
generate_audio: request.generate_audio,
idempotency_token: request.get_or_generate_idempotency_token(),
}))
@@ -123,19 +112,14 @@ fn plan_resolution(
}
}
// The legacy storyteller endpoint for Veo 3 only exposes EightSeconds, but
// the artcraft plan stores the raw duration_seconds from the omni request so
// that cost estimation works for all supported durations (4/6/8).
// Legacy storyteller endpoint for Veo 3 only exposes EightSeconds.
fn plan_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<GenerateVeo3Duration>, ArtcraftRouterError> {
match duration_seconds {
None => Ok(None),
// The api_defs enum only has EightSeconds; all durations map to it for
// the legacy endpoint. The actual seconds are tracked via
// `duration_seconds_raw` below for cost estimation.
Some(4) | Some(6) | Some(8) => Ok(Some(GenerateVeo3Duration::EightSeconds)),
Some(8) => Ok(Some(GenerateVeo3Duration::EightSeconds)),
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
@@ -143,25 +127,18 @@ fn plan_duration(
value: format!("{}", other),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(GenerateVeo3Duration::EightSeconds)),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(GenerateVeo3Duration::EightSeconds)),
_ => Ok(Some(GenerateVeo3Duration::EightSeconds)),
},
}
}
impl PlanArtcraftVeo3<'_> {
/// Duration in seconds for cost estimation. Uses the raw requested
/// duration, clamped to {4, 6, 8}, defaulting to 8s.
/// Mirrors the Fal client default (8s) used by the legacy generate handler.
pub fn duration_seconds_for_cost(&self) -> u64 {
match self.duration_seconds_raw {
Some(s) if s <= 4 => 4,
Some(s) if s <= 6 => 6,
_ => 8, // None, 7, 8, or above → 8s
}
8
}
/// Legacy Veo 3 generate handler defaults `generate_audio` to false.
/// The omni path defaults to true (matching the fal plan builder).
pub fn generate_audio_for_cost(&self) -> bool {
self.generate_audio.unwrap_or(false)
}
@@ -1,4 +1,3 @@
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
use crate::api::image_ref::ImageRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
@@ -7,15 +6,14 @@ use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::generate_veo_3_fast_image_to_video::{
GenerateVeo3FastAspectRatio, GenerateVeo3FastDuration, GenerateVeo3FastResolution,
GenerateVeo3FastDuration, GenerateVeo3FastResolution,
};
use tokens::tokens::media_files::MediaFileToken;
#[derive(Debug, Clone)]
pub struct PlanArtcraftVeo3Fast<'a> {
pub prompt: Option<&'a str>,
pub start_frame: Option<&'a MediaFileToken>,
pub aspect_ratio: Option<GenerateVeo3FastAspectRatio>,
pub start_frame: &'a MediaFileToken,
pub resolution: Option<GenerateVeo3FastResolution>,
pub duration: Option<GenerateVeo3FastDuration>,
pub generate_audio: Option<bool>,
@@ -28,9 +26,16 @@ pub fn plan_generate_video_artcraft_veo_3_fast<'a>(
let strategy = request.request_mismatch_mitigation_strategy;
let start_frame = match request.start_frame {
Some(ImageRef::MediaFileToken(t)) => Some(t),
Some(ImageRef::Url(_)) => None,
None => None,
Some(ImageRef::MediaFileToken(t)) => t,
Some(ImageRef::Url(_)) => {
return Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
None => {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "start_frame",
value: "Veo 3 Fast requires a starting frame".to_string(),
}))
}
};
if request.end_frame.is_some() {
@@ -40,14 +45,12 @@ pub fn plan_generate_video_artcraft_veo_3_fast<'a>(
}));
}
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
let resolution = plan_resolution(request.resolution, strategy)?;
let duration = plan_duration(request.duration_seconds, strategy)?;
Ok(VideoGenerationPlan::ArtcraftVeo3Fast(PlanArtcraftVeo3Fast {
prompt: request.prompt,
start_frame,
aspect_ratio,
resolution,
duration,
generate_audio: request.generate_audio,
@@ -55,33 +58,6 @@ pub fn plan_generate_video_artcraft_veo_3_fast<'a>(
}))
}
fn plan_aspect_ratio(
aspect_ratio: Option<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<GenerateVeo3FastAspectRatio>, ArtcraftRouterError> {
use GenerateVeo3FastAspectRatio as Ar;
match aspect_ratio {
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto3k)
| Some(CommonAspectRatio::Auto4k) => Ok(Some(Ar::Auto)),
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)),
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => Ok(Some(Ar::NineBySixteen)),
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "aspect_ratio",
value: format!("{:?}", unsupported),
}))
}
_ => Ok(Some(Ar::Auto)),
},
}
}
fn plan_resolution(
resolution: Option<CommonResolution>,
strategy: RequestMismatchMitigationStrategy,
@@ -9,22 +9,11 @@ use fal_client::requests::webhook::video::image::enqueue_veo_2_image_to_video_we
Veo2AspectRatio, Veo2Duration,
};
#[derive(Debug, Clone)]
pub enum FalVeo2Mode {
TextToVideo,
ImageToVideo {
image_url: String,
},
}
#[derive(Debug, Clone)]
pub struct PlanFalVeo2 {
pub prompt: String,
pub negative_prompt: Option<String>,
pub mode: FalVeo2Mode,
/// Only set for text-to-video. Image-to-video inherits the source frame's
/// aspect ratio and doesn't accept this parameter.
pub aspect_ratio: Option<Veo2AspectRatio>,
pub start_frame_url: String,
pub aspect_ratio: Veo2AspectRatio,
pub duration: Veo2Duration,
}
@@ -33,37 +22,35 @@ pub fn plan_generate_video_fal_veo_2<'a>(
) -> Result<VideoGenerationPlan<'a>, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
let start_frame_url = resolve_required_image_url(request.start_frame, "start_frame")?;
if request.end_frame.is_some() {
return Err(unsupported("end_frame", "Veo 2 does not support an ending frame"));
}
let mode = match request.start_frame {
Some(ImageRef::Url(url)) => FalVeo2Mode::ImageToVideo {
image_url: url.to_string(),
},
Some(ImageRef::MediaFileToken(_)) => {
return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls));
}
None => FalVeo2Mode::TextToVideo,
};
// Aspect ratio only applies to text-to-video; image-to-video inherits
// the source frame's aspect ratio.
let aspect_ratio = match &mode {
FalVeo2Mode::TextToVideo => Some(plan_aspect_ratio(request.aspect_ratio, strategy)?),
FalVeo2Mode::ImageToVideo { .. } => None,
};
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
let duration = plan_duration(request.duration_seconds, strategy)?;
Ok(VideoGenerationPlan::FalVeo2(PlanFalVeo2 {
prompt: request.prompt.unwrap_or("").to_string(),
negative_prompt: request.negative_prompt.map(|s| s.to_string()),
mode,
start_frame_url,
aspect_ratio,
duration,
}))
}
fn resolve_required_image_url(
image_ref: Option<ImageRef<'_>>,
field: &'static str,
) -> Result<String, ArtcraftRouterError> {
match image_ref {
Some(ImageRef::Url(url)) => Ok(url.to_string()),
Some(ImageRef::MediaFileToken(_)) => {
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
}
None => Err(unsupported(field, "Veo 2 requires a starting frame")),
}
}
fn plan_aspect_ratio(
aspect_ratio: Option<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
@@ -73,7 +60,6 @@ fn plan_aspect_ratio(
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto3k)
| Some(CommonAspectRatio::Auto4k) => Ok(Ar::Auto),
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => Ok(Ar::WideSixteenNine),
@@ -7,65 +7,16 @@ use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use fal_client::requests::webhook::video::image::enqueue_veo_3_image_to_video_webhook::{
Veo3I2vAspectRatio, Veo3I2vDuration, Veo3I2vResolution,
Veo3AspectRatio, Veo3Duration, Veo3Resolution,
};
use fal_client::requests::webhook::video::text::enqueue_veo_3_text_to_video_webhook::{
Veo3T2vAspectRatio, Veo3T2vDuration, Veo3T2vResolution,
};
#[derive(Debug, Clone)]
pub enum FalVeo3Mode {
TextToVideo,
ImageToVideo {
image_url: String,
},
}
/// Router-level duration shared between both modes.
#[derive(Debug, Clone, Copy)]
pub enum FalVeo3Duration {
Default,
FourSeconds,
SixSeconds,
EightSeconds,
}
/// Router-level resolution shared between both modes.
#[derive(Debug, Clone, Copy)]
pub enum FalVeo3Resolution {
Default,
SevenTwentyP,
TenEightyP,
}
/// Router-level aspect ratio for text-to-video only (no Auto, no Square).
#[derive(Debug, Clone, Copy)]
pub enum FalVeo3T2vAspectRatio {
Default,
WideSixteenNine,
TallNineSixteen,
}
/// Router-level aspect ratio for image-to-video only (has Auto, no Square).
#[derive(Debug, Clone, Copy)]
pub enum FalVeo3I2vAspectRatio {
Auto,
WideSixteenNine,
TallNineSixteen,
}
#[derive(Debug, Clone)]
pub struct PlanFalVeo3 {
pub prompt: String,
pub negative_prompt: Option<String>,
pub mode: FalVeo3Mode,
/// Only set for text-to-video. Image-to-video inherits the source frame's
/// aspect ratio and uses `i2v_aspect_ratio` instead.
pub t2v_aspect_ratio: Option<FalVeo3T2vAspectRatio>,
/// Only set for image-to-video.
pub i2v_aspect_ratio: Option<FalVeo3I2vAspectRatio>,
pub resolution: FalVeo3Resolution,
pub duration: FalVeo3Duration,
pub start_frame_url: String,
pub aspect_ratio: Veo3AspectRatio,
pub resolution: Veo3Resolution,
pub duration: Veo3Duration,
pub generate_audio: bool,
}
@@ -74,88 +25,58 @@ pub fn plan_generate_video_fal_veo_3<'a>(
) -> Result<VideoGenerationPlan<'a>, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
let start_frame_url = resolve_required_image_url(request.start_frame)?;
if request.end_frame.is_some() {
return Err(unsupported("end_frame", "Veo 3 does not support an ending frame"));
}
let mode = match request.start_frame {
Some(ImageRef::Url(url)) => FalVeo3Mode::ImageToVideo {
image_url: url.to_string(),
},
Some(ImageRef::MediaFileToken(_)) => {
return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls));
}
None => FalVeo3Mode::TextToVideo,
};
let (t2v_aspect_ratio, i2v_aspect_ratio) = match &mode {
FalVeo3Mode::TextToVideo => {
(Some(plan_t2v_aspect_ratio(request.aspect_ratio, strategy)?), None)
}
FalVeo3Mode::ImageToVideo { .. } => {
(None, Some(plan_i2v_aspect_ratio(request.aspect_ratio, strategy)?))
}
};
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
let resolution = plan_resolution(request.resolution, strategy)?;
let duration = plan_duration(request.duration_seconds, strategy)?;
let generate_audio = request.generate_audio.unwrap_or(true);
Ok(VideoGenerationPlan::FalVeo3(PlanFalVeo3 {
prompt: request.prompt.unwrap_or("").to_string(),
negative_prompt: request.negative_prompt.map(|s| s.to_string()),
mode,
t2v_aspect_ratio,
i2v_aspect_ratio,
start_frame_url,
aspect_ratio,
resolution,
duration,
generate_audio,
}))
}
/// Text-to-video: only 16:9 and 9:16 (no Auto, no Square).
fn plan_t2v_aspect_ratio(
aspect_ratio: Option<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<FalVeo3T2vAspectRatio, ArtcraftRouterError> {
use FalVeo3T2vAspectRatio as Ar;
match aspect_ratio {
None => Ok(Ar::Default),
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => Ok(Ar::WideSixteenNine),
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => Ok(Ar::TallNineSixteen),
// Everything else (Auto*, Square, unsupported) → Default (16:9).
Some(_) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(unsupported("aspect_ratio", &format!("{:?}", aspect_ratio.unwrap())))
}
_ => Ok(Ar::Default),
},
fn resolve_required_image_url(
image_ref: Option<ImageRef<'_>>,
) -> Result<String, ArtcraftRouterError> {
match image_ref {
Some(ImageRef::Url(url)) => Ok(url.to_string()),
Some(ImageRef::MediaFileToken(_)) => {
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
}
None => Err(unsupported("start_frame", "Veo 3 requires a starting frame")),
}
}
/// Image-to-video: Auto, 16:9, 9:16 (no Square).
fn plan_i2v_aspect_ratio(
fn plan_aspect_ratio(
aspect_ratio: Option<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<FalVeo3I2vAspectRatio, ArtcraftRouterError> {
use FalVeo3I2vAspectRatio as Ar;
) -> Result<Veo3AspectRatio, ArtcraftRouterError> {
use Veo3AspectRatio as Ar;
match aspect_ratio {
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto3k)
| Some(CommonAspectRatio::Auto4k) => Ok(Ar::Auto),
| Some(CommonAspectRatio::Auto4k) => Ok(Ar::Default),
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => Ok(Ar::Square),
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => Ok(Ar::WideSixteenNine),
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => Ok(Ar::TallNineSixteen),
// Everything else (Square, unsupported) → Auto.
Some(_) => match strategy {
Some(unsupported_ar) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(unsupported("aspect_ratio", &format!("{:?}", aspect_ratio.unwrap())))
Err(unsupported("aspect_ratio", &format!("{:?}", unsupported_ar)))
}
_ => Ok(Ar::Auto),
_ => Ok(Ar::Default),
},
}
}
@@ -163,8 +84,8 @@ fn plan_i2v_aspect_ratio(
fn plan_resolution(
resolution: Option<CommonResolution>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<FalVeo3Resolution, ArtcraftRouterError> {
use FalVeo3Resolution as R;
) -> Result<Veo3Resolution, ArtcraftRouterError> {
use Veo3Resolution as R;
match resolution {
None => Ok(R::Default),
Some(CommonResolution::SevenTwentyP) => Ok(R::SevenTwentyP),
@@ -182,18 +103,18 @@ fn plan_resolution(
fn plan_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<FalVeo3Duration, ArtcraftRouterError> {
) -> Result<Veo3Duration, ArtcraftRouterError> {
match duration_seconds {
None => Ok(FalVeo3Duration::Default),
Some(4) => Ok(FalVeo3Duration::FourSeconds),
Some(6) => Ok(FalVeo3Duration::SixSeconds),
Some(8) => Ok(FalVeo3Duration::EightSeconds),
None => Ok(Veo3Duration::Default),
Some(4) => Ok(Veo3Duration::FourSeconds),
Some(6) => Ok(Veo3Duration::SixSeconds),
Some(8) => Ok(Veo3Duration::EightSeconds),
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(unsupported("duration_seconds", &format!("{}", other)))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(FalVeo3Duration::EightSeconds),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(FalVeo3Duration::FourSeconds),
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Veo3Duration::EightSeconds),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Veo3Duration::FourSeconds),
},
}
}
@@ -208,9 +129,9 @@ fn unsupported(field: &'static str, value: &str) -> ArtcraftRouterError {
impl PlanFalVeo3 {
pub fn duration_seconds_for_cost(&self) -> u64 {
match self.duration {
FalVeo3Duration::Default | FalVeo3Duration::EightSeconds => 8,
FalVeo3Duration::SixSeconds => 6,
FalVeo3Duration::FourSeconds => 4,
Veo3Duration::Default | Veo3Duration::EightSeconds => 8,
Veo3Duration::SixSeconds => 6,
Veo3Duration::FourSeconds => 4,
}
}
}
@@ -5,16 +5,14 @@ use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use fal_client::requests::webhook::video::image::enqueue_veo_3_fast_image_to_video_webhook::{
Veo3FastAspectRatio, Veo3FastDuration, Veo3FastResolution,
Veo3FastDuration, Veo3FastResolution,
};
#[derive(Debug, Clone)]
pub struct PlanFalVeo3Fast {
pub prompt: String,
pub start_frame_url: String,
pub aspect_ratio: Veo3FastAspectRatio,
pub resolution: Veo3FastResolution,
pub duration: Veo3FastDuration,
pub generate_audio: bool,
@@ -30,7 +28,6 @@ pub fn plan_generate_video_fal_veo_3_fast<'a>(
return Err(unsupported("end_frame", "Veo 3 Fast does not support an ending frame"));
}
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
let resolution = plan_resolution(request.resolution, strategy)?;
let duration = plan_duration(request.duration_seconds, strategy)?;
let generate_audio = request.generate_audio.unwrap_or(true);
@@ -38,7 +35,6 @@ pub fn plan_generate_video_fal_veo_3_fast<'a>(
Ok(VideoGenerationPlan::FalVeo3Fast(PlanFalVeo3Fast {
prompt: request.prompt.unwrap_or("").to_string(),
start_frame_url,
aspect_ratio,
resolution,
duration,
generate_audio,
@@ -57,30 +53,6 @@ fn resolve_required_image_url(
}
}
fn plan_aspect_ratio(
aspect_ratio: Option<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Veo3FastAspectRatio, ArtcraftRouterError> {
use Veo3FastAspectRatio as Ar;
match aspect_ratio {
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto3k)
| Some(CommonAspectRatio::Auto4k) => Ok(Ar::Auto),
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => Ok(Ar::WideSixteenNine),
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => Ok(Ar::TallNineSixteen),
Some(unsupported_ar) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(unsupported("aspect_ratio", &format!("{:?}", unsupported_ar)))
}
_ => Ok(Ar::Auto),
},
}
}
fn plan_resolution(
resolution: Option<CommonResolution>,
strategy: RequestMismatchMitigationStrategy,
@@ -1,2 +1 @@
pub mod plan_generate_video_seedance2pro_seedance2p0;
pub mod plan_generate_video_seedance2pro_seedance2p0_fast;
@@ -1,198 +0,0 @@
use crate::api::audio_list_ref::AudioListRef;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::video_list_ref::VideoListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use seedance2pro_client::requests::generate_video::generate_video::{BatchCount, Resolution};
#[derive(Debug, Clone)]
pub struct PlanSeedance2proSeedance2p0Fast {
pub prompt: Option<String>,
pub start_frame_url: Option<String>,
pub end_frame_url: Option<String>,
pub reference_image_urls: Option<Vec<String>>,
pub reference_video_urls: Option<Vec<String>>,
pub reference_audio_urls: Option<Vec<String>>,
pub resolution: Resolution,
pub duration_seconds: u8,
pub batch_count: BatchCount,
}
pub fn plan_generate_video_seedance2pro_seedance2p0_fast<'a>(
request: &'a GenerateVideoRequest<'a>,
) -> Result<VideoGenerationPlan<'a>, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
let start_frame_url = resolve_image_ref_url(request.start_frame)?;
let end_frame_url = resolve_image_ref_url(request.end_frame)?;
let reference_image_urls = resolve_image_list_ref_urls(request.reference_images)?;
let reference_video_urls = resolve_video_list_ref_urls(request.reference_videos)?;
let reference_audio_urls = resolve_audio_list_ref_urls(request.reference_audio)?;
let resolution = plan_resolution(request.aspect_ratio, strategy)?;
let batch_count = plan_batch_count(request.video_batch_count, strategy)?;
let duration_seconds = plan_duration(request.duration_seconds, strategy)?;
Ok(VideoGenerationPlan::Seedance2proSeedance2p0Fast(PlanSeedance2proSeedance2p0Fast {
prompt: request.prompt.map(|p| p.to_string()),
start_frame_url,
end_frame_url,
reference_image_urls,
reference_video_urls,
reference_audio_urls,
resolution,
duration_seconds,
batch_count,
}))
}
fn resolve_image_ref_url(
image_ref: Option<ImageRef<'_>>,
) -> Result<Option<String>, ArtcraftRouterError> {
match image_ref {
None => Ok(None),
Some(ImageRef::Url(url)) => Ok(Some(url.to_string())),
Some(ImageRef::MediaFileToken(_)) => {
Err(ArtcraftRouterError::Client(ClientError::Seedance2ProOnlySupportsUrls))
}
}
}
fn resolve_image_list_ref_urls(
image_list_ref: Option<ImageListRef<'_>>,
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
match image_list_ref {
None => Ok(None),
Some(ImageListRef::Urls(urls)) => Ok(Some(urls.clone())),
Some(ImageListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::Seedance2ProOnlySupportsUrls))
}
}
}
fn resolve_video_list_ref_urls(
video_list_ref: Option<VideoListRef<'_>>,
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
match video_list_ref {
None => Ok(None),
Some(VideoListRef::Urls(urls)) => Ok(Some(urls.clone())),
Some(VideoListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::Seedance2ProOnlySupportsUrls))
}
}
}
fn resolve_audio_list_ref_urls(
audio_list_ref: Option<AudioListRef<'_>>,
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
match audio_list_ref {
None => Ok(None),
Some(AudioListRef::Urls(urls)) => Ok(Some(urls.clone())),
Some(AudioListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::Seedance2ProOnlySupportsUrls))
}
}
}
fn plan_resolution(
aspect_ratio: Option<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Resolution, ArtcraftRouterError> {
match aspect_ratio {
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto4k) => Ok(Resolution::Landscape16x9),
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => {
Ok(Resolution::Landscape16x9)
}
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => {
Ok(Resolution::Portrait9x16)
}
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => {
Ok(Resolution::Square1x1)
}
Some(CommonAspectRatio::WideFourByThree) => Ok(Resolution::Standard4x3),
Some(CommonAspectRatio::TallThreeByFour) => Ok(Resolution::Portrait3x4),
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "aspect_ratio",
value: format!("{:?}", unsupported),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(nearest_resolution(unsupported))
}
},
}
}
fn nearest_resolution(aspect_ratio: CommonAspectRatio) -> Resolution {
match aspect_ratio {
CommonAspectRatio::WideFiveByFour => Resolution::Standard4x3,
CommonAspectRatio::WideThreeByTwo => Resolution::Standard4x3,
CommonAspectRatio::WideTwentyOneByNine => Resolution::Landscape16x9,
CommonAspectRatio::TallFourByFive => Resolution::Portrait3x4,
CommonAspectRatio::TallTwoByThree => Resolution::Portrait3x4,
CommonAspectRatio::TallNineByTwentyOne => Resolution::Portrait9x16,
_ => Resolution::Square1x1,
}
}
fn plan_batch_count(
video_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<BatchCount, ArtcraftRouterError> {
let count = video_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(BatchCount::One),
2 => Ok(BatchCount::Two),
4 => Ok(BatchCount::Four),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "video_batch_count",
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
Ok(if count < 4 { BatchCount::Four } else { BatchCount::Four })
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(if count < 4 { BatchCount::Two } else { BatchCount::Four })
}
},
}
}
fn plan_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<u8, ArtcraftRouterError> {
const MIN: u16 = 4;
const MAX: u16 = 15;
const DEFAULT: u8 = 5;
match duration_seconds {
None => Ok(DEFAULT),
Some(d) if d >= MIN && d <= MAX => Ok(d as u8),
Some(d) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "duration_seconds",
value: format!("{}", d),
}))
}
_ => Ok(d.clamp(MIN, MAX) as u8),
},
}
}
@@ -10,7 +10,6 @@ use crate::generate::generate_video::cost::artcraft::estimate_video_cost_artcraf
use crate::generate::generate_video::cost::artcraft::estimate_video_cost_artcraft_seedance_1_0_lite::estimate_video_cost_artcraft_seedance_1_0_lite;
use crate::generate::generate_video::cost::artcraft::estimate_video_cost_artcraft_seedance1p5_pro::estimate_video_cost_artcraft_seedance1p5_pro;
use crate::generate::generate_video::cost::artcraft::estimate_video_cost_artcraft_seedance2p0::estimate_video_cost_artcraft_seedance2p0;
use crate::generate::generate_video::cost::artcraft::estimate_video_cost_artcraft_seedance2p0_fast::estimate_video_cost_artcraft_seedance2p0_fast;
use crate::generate::generate_video::cost::artcraft::estimate_video_cost_artcraft_sora_2::estimate_video_cost_artcraft_sora_2;
use crate::generate::generate_video::cost::artcraft::estimate_video_cost_artcraft_sora_2_pro::estimate_video_cost_artcraft_sora_2_pro;
use crate::generate::generate_video::cost::artcraft::estimate_video_cost_artcraft_veo_2::estimate_video_cost_artcraft_veo_2;
@@ -36,7 +35,6 @@ use crate::generate::generate_video::cost::fal::estimate_video_cost_fal_veo_3p1:
use crate::generate::generate_video::cost::fal::estimate_video_cost_fal_veo_3p1_fast::estimate_video_cost_fal_veo_3p1_fast;
use crate::generate::generate_video::cost::muapi::estimate_video_cost_muapi_seedance2p0::estimate_video_cost_muapi_seedance2p0;
use crate::generate::generate_video::cost::seedance2pro::estimate_video_cost_seedance2pro_seedance2p0::estimate_video_cost_seedance2pro_seedance2p0;
use crate::generate::generate_video::cost::seedance2pro::estimate_video_cost_seedance2pro_seedance2p0_fast::estimate_video_cost_seedance2pro_seedance2p0_fast;
use crate::generate::generate_video::execute::artcraft::generate_video_artcraft_kling_1_6_pro::execute_artcraft_kling_1_6_pro;
use crate::generate::generate_video::execute::artcraft::generate_video_artcraft_kling_2_1_master::execute_artcraft_kling_2_1_master;
use crate::generate::generate_video::execute::artcraft::generate_video_artcraft_kling_2_1_pro::execute_artcraft_kling_2_1_pro;
@@ -47,7 +45,6 @@ use crate::generate::generate_video::execute::artcraft::generate_video_artcraft_
use crate::generate::generate_video::execute::artcraft::generate_video_artcraft_seedance_1_0_lite::execute_artcraft_seedance_1_0_lite;
use crate::generate::generate_video::execute::artcraft::generate_video_artcraft_seedance1p5_pro::execute_artcraft_seedance1p5_pro;
use crate::generate::generate_video::execute::artcraft::generate_video_artcraft_seedance2p0::execute_artcraft_seedance2p0;
use crate::generate::generate_video::execute::artcraft::generate_video_artcraft_seedance2p0_fast::execute_artcraft_seedance2p0_fast;
use crate::generate::generate_video::execute::artcraft::generate_video_artcraft_sora_2::execute_artcraft_sora_2;
use crate::generate::generate_video::execute::artcraft::generate_video_artcraft_sora_2_pro::execute_artcraft_sora_2_pro;
use crate::generate::generate_video::execute::artcraft::generate_video_artcraft_veo_2::execute_artcraft_veo_2;
@@ -73,7 +70,6 @@ use crate::generate::generate_video::execute::fal::generate_video_fal_veo_3p1::e
use crate::generate::generate_video::execute::fal::generate_video_fal_veo_3p1_fast::execute_fal_veo_3p1_fast;
use crate::generate::generate_video::execute::muapi::execute_muapi_seedance2p0::execute_muapi_seedance2p0;
use crate::generate::generate_video::execute::seedance2pro::execute_seedance2pro_seedance2p0::execute_seedance2pro_seedance2p0;
use crate::generate::generate_video::execute::seedance2pro::execute_seedance2pro_seedance2p0_fast::execute_seedance2pro_seedance2p0_fast;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_kling_1_6_pro::PlanArtcraftKling16Pro;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_kling_2_1_master::PlanArtcraftKling21Master;
@@ -110,7 +106,6 @@ use crate::generate::generate_video::plan::fal::plan_generate_video_fal_veo_3p1:
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_veo_3p1_fast::PlanFalVeo3p1Fast;
use crate::generate::generate_video::plan::muapi::plan_generate_video_muapi_seedance2p0::PlanMuapiSeedance2p0;
use crate::generate::generate_video::plan::seedance2pro::plan_generate_video_seedance2pro_seedance2p0::PlanSeedance2proSeedance2p0;
use crate::generate::generate_video::plan::seedance2pro::plan_generate_video_seedance2pro_seedance2p0_fast::PlanSeedance2proSeedance2p0Fast;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
#[derive(Debug)]
@@ -125,7 +120,6 @@ pub enum VideoGenerationPlan<'a> {
ArtcraftSeedance10Lite(PlanArtcraftSeedance10Lite<'a>),
ArtcraftSeedance1p5Pro(PlanArtcraftSeedance1p5Pro<'a>),
ArtcraftSeedance2p0(PlanArtcraftSeedance2p0<'a>),
ArtcraftSeedance2p0Fast(PlanArtcraftSeedance2p0<'a>),
ArtcraftSora2(PlanArtcraftSora2<'a>),
ArtcraftSora2Pro(PlanArtcraftSora2Pro<'a>),
ArtcraftVeo2(PlanArtcraftVeo2<'a>),
@@ -135,7 +129,6 @@ pub enum VideoGenerationPlan<'a> {
ArtcraftVeo3p1Fast(PlanArtcraftVeo3p1Fast<'a>),
MuapiSeedance2p0(PlanMuapiSeedance2p0),
Seedance2proSeedance2p0(PlanSeedance2proSeedance2p0),
Seedance2proSeedance2p0Fast(PlanSeedance2proSeedance2p0Fast),
FalVeo2(PlanFalVeo2),
FalVeo3(PlanFalVeo3),
FalVeo3Fast(PlanFalVeo3Fast),
@@ -200,10 +193,6 @@ impl<'a> VideoGenerationPlan<'a> {
let artcraft_client = client.get_artcraft_client_ref()?;
execute_artcraft_seedance2p0(plan, artcraft_client).await
}
VideoGenerationPlan::ArtcraftSeedance2p0Fast(plan) => {
let artcraft_client = client.get_artcraft_client_ref()?;
execute_artcraft_seedance2p0_fast(plan, artcraft_client).await
}
VideoGenerationPlan::ArtcraftSora2(plan) => {
let artcraft_client = client.get_artcraft_client_ref()?;
execute_artcraft_sora_2(plan, artcraft_client).await
@@ -240,10 +229,6 @@ impl<'a> VideoGenerationPlan<'a> {
let seedance2pro_client = client.get_seedance2pro_client_ref()?;
execute_seedance2pro_seedance2p0(plan, seedance2pro_client).await
}
VideoGenerationPlan::Seedance2proSeedance2p0Fast(plan) => {
let seedance2pro_client = client.get_seedance2pro_client_ref()?;
execute_seedance2pro_seedance2p0_fast(plan, seedance2pro_client).await
}
VideoGenerationPlan::FalVeo2(plan) => {
let fal_client = client.get_fal_client_ref()?;
execute_fal_veo_2(plan, fal_client).await
@@ -333,9 +318,6 @@ impl<'a> VideoGenerationPlan<'a> {
VideoGenerationPlan::ArtcraftSeedance2p0(plan) => {
estimate_video_cost_artcraft_seedance2p0(plan)
}
VideoGenerationPlan::ArtcraftSeedance2p0Fast(plan) => {
estimate_video_cost_artcraft_seedance2p0_fast(plan)
}
VideoGenerationPlan::ArtcraftSora2(plan) => {
estimate_video_cost_artcraft_sora_2(plan)
}
@@ -353,9 +335,6 @@ impl<'a> VideoGenerationPlan<'a> {
VideoGenerationPlan::Seedance2proSeedance2p0(plan) => {
estimate_video_cost_seedance2pro_seedance2p0(plan)
}
VideoGenerationPlan::Seedance2proSeedance2p0Fast(plan) => {
estimate_video_cost_seedance2pro_seedance2p0_fast(plan)
}
VideoGenerationPlan::FalVeo2(plan) => estimate_video_cost_fal_veo_2(plan),
VideoGenerationPlan::FalVeo3(plan) => estimate_video_cost_fal_veo_3(plan),
VideoGenerationPlan::FalVeo3Fast(plan) => estimate_video_cost_fal_veo_3_fast(plan),
@@ -7,6 +7,10 @@ pub struct Veo2ImageToVideoInput {
pub prompt: String,
/// Options: "auto", "16:9", "9:16"
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Options: "4s", "6s", "8s"
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
@@ -7,10 +7,6 @@ pub struct Veo3FastImageToVideoInput {
pub prompt: String,
/// Options: "auto", "16:9", "9:16"
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Options: "720p", "1080p"
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
@@ -1,34 +0,0 @@
use crate::requests::api::fal_request::FalRequest;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Veo2TextToVideoInput {
pub prompt: String,
/// Options: "16:9", "9:16", "1:1"
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Options: "5s", "6s", "7s", "8s"
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo2TextToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo2TextToVideoOutput {
pub video: Veo2TextToVideoVideoFile,
}
pub fn veo_2_text_to_video(
params: Veo2TextToVideoInput,
) -> FalRequest<Veo2TextToVideoInput, Veo2TextToVideoOutput> {
FalRequest::new("fal-ai/veo2", params)
}
@@ -1,41 +0,0 @@
use crate::requests::api::fal_request::FalRequest;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Veo3TextToVideoInput {
pub prompt: String,
/// Options: "16:9", "9:16"
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Options: "720p", "1080p"
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Options: "4s", "6s", "8s"
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3TextToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3TextToVideoOutput {
pub video: Veo3TextToVideoVideoFile,
}
pub fn veo_3_text_to_video(
params: Veo3TextToVideoInput,
) -> FalRequest<Veo3TextToVideoInput, Veo3TextToVideoOutput> {
FalRequest::new("fal-ai/veo3", params)
}
@@ -7,7 +7,5 @@ pub mod http_pixverse_v5_text_to_video;
pub mod http_seedance_1p5_pro_text_to_video;
pub mod http_sora_2_pro_text_to_video;
pub mod http_sora_2_text_to_video;
pub mod http_veo_2_text_to_video;
pub mod http_veo_3_text_to_video;
pub mod http_veo_3p1_fast_text_to_video;
pub mod http_veo_3p1_text_to_video;
@@ -1,22 +1,21 @@
use crate::creds::fal_api_key::FalApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::webhook_response::WebhookResponse;
use crate::requests::http::video::image::http_veo_2_image_to_video::{veo_2_image_to_video, Veo2ImageToVideoInput};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
use crate::requests::http::video::image::http_veo_2_image_to_video::{veo_2_image_to_video, Veo2ImageToVideoInput};
use crate::requests::api::webhook_response::WebhookResponse;
use reqwest::IntoUrl;
/// Args for Veo 2 image-to-video. Note: image-to-video does NOT support
/// aspect_ratio — the output inherits the source image's aspect ratio.
pub struct Veo2Args<'a, U: IntoUrl, V: IntoUrl> {
pub image_url: U,
pub webhook_url: V,
pub prompt: &'a str,
pub api_key: &'a FalApiKey,
pub duration: Veo2Duration,
pub aspect_ratio: Veo2AspectRatio,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
#[derive(Copy, Clone, Debug)]
pub enum Veo2Duration {
Default, // NB: Default is 5 seconds
FiveSeconds,
@@ -25,9 +24,6 @@ pub enum Veo2Duration {
EightSeconds,
}
/// Aspect ratio enum — used by the text-to-video endpoint only.
/// Kept here because it's the canonical location that the text-to-video
/// webhook and the router plan both re-export from.
#[derive(Copy, Clone, Debug)]
pub enum Veo2AspectRatio {
Auto,
@@ -63,13 +59,22 @@ pub async fn enqueue_veo_2_image_to_video_webhook<U: IntoUrl, V: IntoUrl>(
Veo2Duration::SevenSeconds => Some("7s".to_string()),
Veo2Duration::EightSeconds => Some("8s".to_string()),
};
let aspect_ratio = match args.aspect_ratio {
Veo2AspectRatio::Auto => Some("auto".to_string()),
Veo2AspectRatio::AutoPreferPortrait => Some("auto_prefer_portrait".to_string()),
Veo2AspectRatio::WideSixteenNine => Some("16:9".to_string()),
Veo2AspectRatio::TallNineSixteen => Some("9:16".to_string()),
};
let image_url = args.image_url.as_str().to_string();
let request = Veo2ImageToVideoInput {
image_url,
prompt: args.prompt.to_string(),
aspect_ratio,
duration,
// Maybe expose these later
};
let result = veo_2_image_to_video(request)
@@ -84,24 +89,26 @@ pub async fn enqueue_veo_2_image_to_video_webhook<U: IntoUrl, V: IntoUrl>(
#[cfg(test)]
mod tests {
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::webhook::video::image::enqueue_veo_2_image_to_video_webhook::{enqueue_veo_2_image_to_video_webhook, Veo2Args, Veo2Duration};
use crate::requests::webhook::video::image::enqueue_veo_2_image_to_video_webhook::{enqueue_veo_2_image_to_video_webhook, Veo2Args, Veo2AspectRatio, Veo2Duration};
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::MOUNTAIN_TREE_IMAGE_URL;
#[tokio::test]
#[ignore]
async fn test_veo_2_image_to_video() -> AnyhowResult<()> {
async fn test_kling21_pro_video() -> AnyhowResult<()> {
let image_url = "https://cdn-2.fakeyou.com/media/3/4/h/f/s/34hfsmt8e38rvne6mwa4pwbxr6292sgy/image_34hfsmt8e38rvne6mwa4pwbxr6292sgy.png";
// XXX: Don't commit secrets!
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let args = Veo2Args {
image_url: MOUNTAIN_TREE_IMAGE_URL,
image_url: image_url,
prompt: "a shot of the mountains as the sun sets and reveals the moon and stars",
api_key: &api_key,
duration: Veo2Duration::Default,
aspect_ratio: Veo2AspectRatio::WideSixteenNine,
webhook_url: "https://example.com/webhook",
};
@@ -9,7 +9,6 @@ use reqwest::IntoUrl;
pub struct Veo3FastArgs<'a, U: IntoUrl, V: IntoUrl> {
pub prompt: &'a str,
pub image_url: U,
pub aspect_ratio: Veo3FastAspectRatio,
pub duration: Veo3FastDuration,
pub api_key: &'a FalApiKey,
pub resolution: Veo3FastResolution,
@@ -25,13 +24,6 @@ pub enum Veo3FastDuration {
EightSeconds,
}
#[derive(Copy, Clone, Debug)]
pub enum Veo3FastAspectRatio {
Auto,
WideSixteenNine, // 16:9
TallNineSixteen, // 9:16
}
#[derive(Copy, Clone, Debug)]
pub enum Veo3FastResolution {
Default,
@@ -70,13 +62,7 @@ pub async fn enqueue_veo_3_fast_image_to_video_webhook<U: IntoUrl, V: IntoUrl>(
Veo3FastDuration::EightSeconds => Some("8s".to_string()),
};
let aspect_ratio = match args.aspect_ratio {
Veo3FastAspectRatio::Auto => Some("auto".to_string()),
Veo3FastAspectRatio::WideSixteenNine => Some("16:9".to_string()),
Veo3FastAspectRatio::TallNineSixteen => Some("9:16".to_string()),
};
let resolution = match args.resolution {
let resolution= match args.resolution {
Veo3FastResolution::Default => None,
Veo3FastResolution::SevenTwentyP => Some("720p".to_string()),
Veo3FastResolution::TenEightyP => Some("1080p".to_string()),
@@ -87,7 +73,6 @@ pub async fn enqueue_veo_3_fast_image_to_video_webhook<U: IntoUrl, V: IntoUrl>(
let request = Veo3FastImageToVideoInput {
image_url,
prompt: args.prompt.to_string(),
aspect_ratio,
resolution,
duration,
generate_audio: Some(args.generate_audio),
@@ -105,7 +90,7 @@ pub async fn enqueue_veo_3_fast_image_to_video_webhook<U: IntoUrl, V: IntoUrl>(
#[cfg(test)]
mod tests {
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::webhook::video::image::enqueue_veo_3_fast_image_to_video_webhook::{enqueue_veo_3_fast_image_to_video_webhook, Veo3FastArgs, Veo3FastAspectRatio, Veo3FastDuration, Veo3FastResolution};
use crate::requests::webhook::video::image::enqueue_veo_3_fast_image_to_video_webhook::{enqueue_veo_3_fast_image_to_video_webhook, Veo3FastArgs, Veo3FastDuration, Veo3FastResolution};
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::ERNEST_GHOST_TREX_IMAGE_URL;
@@ -124,7 +109,6 @@ mod tests {
image_url: image_url,
prompt: "man is standing next to a ghost and t-rex, they begin to chase him as the camera pulls back to show the wider scene",
api_key: &api_key,
aspect_ratio: Veo3FastAspectRatio::WideSixteenNine,
duration: Veo3FastDuration::EightSeconds,
generate_audio: true,
resolution: Veo3FastResolution::TenEightyP,
@@ -9,35 +9,32 @@ use reqwest::IntoUrl;
pub struct Veo3Args<'a, U: IntoUrl, V: IntoUrl> {
pub image_url: U,
pub prompt: &'a str,
pub duration: Veo3I2vDuration,
pub aspect_ratio: Veo3I2vAspectRatio,
pub resolution: Veo3I2vResolution,
pub duration: Veo3Duration,
pub aspect_ratio: Veo3AspectRatio,
pub resolution: Veo3Resolution,
pub generate_audio: bool,
pub api_key: &'a FalApiKey,
pub webhook_url: V,
}
/// Duration for Veo 3 image-to-video. Default is 8 seconds.
#[derive(Copy, Clone, Debug)]
pub enum Veo3I2vDuration {
pub enum Veo3Duration {
Default, // Default is 8 seconds
FourSeconds,
SixSeconds,
EightSeconds,
}
/// Aspect ratio for Veo 3 image-to-video.
/// Supports Auto (inherit source), 16:9, and 9:16. No Square.
#[derive(Copy, Clone, Debug)]
pub enum Veo3I2vAspectRatio {
Auto,
WideSixteenNine, // 16:9
TallNineSixteen, // 9:16
pub enum Veo3AspectRatio {
Default,
WideSixteenNine,
TallNineSixteen,
Square,
}
/// Resolution for Veo 3 image-to-video.
#[derive(Copy, Clone, Debug)]
pub enum Veo3I2vResolution {
pub enum Veo3Resolution {
Default,
SevenTwentyP,
TenEightyP,
@@ -51,14 +48,14 @@ impl <U: IntoUrl, V: IntoUrl> FalRequestCostCalculator for Veo3Args<'_, U, V> {
// For example, a 5s video with audio on will cost $2."
match (self.duration, self.generate_audio) {
(Veo3I2vDuration::FourSeconds, false) => 80,
(Veo3I2vDuration::SixSeconds, false) => 120,
(Veo3I2vDuration::EightSeconds, false) => 160,
(Veo3I2vDuration::Default, false) => 160,
(Veo3I2vDuration::FourSeconds, true) => 160,
(Veo3I2vDuration::SixSeconds, true) => 240,
(Veo3I2vDuration::EightSeconds, true) => 320,
(Veo3I2vDuration::Default, true) => 320,
(Veo3Duration::FourSeconds, false) => 80,
(Veo3Duration::SixSeconds, false) => 120,
(Veo3Duration::EightSeconds, false) => 160,
(Veo3Duration::Default, false) => 160, // NB: Default is 8 seconds
(Veo3Duration::FourSeconds, true) => 160,
(Veo3Duration::SixSeconds, true) => 240,
(Veo3Duration::EightSeconds, true) => 320,
(Veo3Duration::Default, true) => 320, // NB: Default is 8 seconds320
}
}
}
@@ -70,22 +67,23 @@ pub async fn enqueue_veo_3_image_to_video_webhook<U: IntoUrl, V: IntoUrl>(
args: Veo3Args<'_, U, V>
) -> Result<WebhookResponse, FalErrorPlus> {
let duration = match args.duration {
Veo3I2vDuration::Default => None,
Veo3I2vDuration::FourSeconds => Some("4s".to_string()),
Veo3I2vDuration::SixSeconds => Some("6s".to_string()),
Veo3I2vDuration::EightSeconds => Some("8s".to_string()),
Veo3Duration::Default => None,
Veo3Duration::FourSeconds => Some("4s".to_string()),
Veo3Duration::SixSeconds => Some("6s".to_string()),
Veo3Duration::EightSeconds => Some("8s".to_string()),
};
let aspect_ratio = match args.aspect_ratio {
Veo3I2vAspectRatio::Auto => Some("auto".to_string()),
Veo3I2vAspectRatio::WideSixteenNine => Some("16:9".to_string()),
Veo3I2vAspectRatio::TallNineSixteen => Some("9:16".to_string()),
Veo3AspectRatio::Default => None,
Veo3AspectRatio::WideSixteenNine => Some("16:9".to_string()),
Veo3AspectRatio::TallNineSixteen => Some("9:16".to_string()),
Veo3AspectRatio::Square => Some("1:1".to_string()),
};
let resolution = match args.resolution {
Veo3I2vResolution::Default => None,
Veo3I2vResolution::SevenTwentyP => Some("720p".to_string()),
Veo3I2vResolution::TenEightyP => Some("1080p".to_string()),
let resolution= match args.resolution {
Veo3Resolution::Default => None,
Veo3Resolution::SevenTwentyP => Some("720p".to_string()),
Veo3Resolution::TenEightyP => Some("1080p".to_string()),
};
let image_url = args.image_url.as_str().to_string();
@@ -111,10 +109,7 @@ pub async fn enqueue_veo_3_image_to_video_webhook<U: IntoUrl, V: IntoUrl>(
#[cfg(test)]
mod tests {
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::webhook::video::image::enqueue_veo_3_image_to_video_webhook::{
enqueue_veo_3_image_to_video_webhook, Veo3Args, Veo3I2vAspectRatio, Veo3I2vDuration,
Veo3I2vResolution,
};
use crate::requests::webhook::video::image::enqueue_veo_3_image_to_video_webhook::{enqueue_veo_3_image_to_video_webhook, Veo3Args, Veo3AspectRatio, Veo3Duration, Veo3Resolution};
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::TREX_SKELETON_IMAGE_URL;
@@ -122,21 +117,24 @@ mod tests {
#[tokio::test]
#[ignore]
async fn test() -> AnyhowResult<()> {
// XXX: Don't commit secrets!
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let args = Veo3Args {
image_url: TREX_SKELETON_IMAGE_URL,
prompt: "the t-rex skeleton starts walking towards the camera and roars",
api_key: &api_key,
duration: Veo3I2vDuration::EightSeconds,
aspect_ratio: Veo3I2vAspectRatio::WideSixteenNine,
resolution: Veo3I2vResolution::TenEightyP,
duration: Veo3Duration::EightSeconds,
aspect_ratio: Veo3AspectRatio::WideSixteenNine,
resolution: Veo3Resolution::TenEightyP,
generate_audio: true,
webhook_url: "https://example.com/webhook",
};
let _result = enqueue_veo_3_image_to_video_webhook(args).await?;
let result = enqueue_veo_3_image_to_video_webhook(args).await?;
Ok(())
}
}
@@ -1,103 +0,0 @@
use crate::creds::fal_api_key::FalApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::webhook_response::WebhookResponse;
use crate::requests::http::video::text::http_veo_2_text_to_video::{
veo_2_text_to_video, Veo2TextToVideoInput,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{
FalRequestCostCalculator, UsdCents,
};
// Re-use the same duration/aspect-ratio enums as image-to-video.
pub use crate::requests::webhook::video::image::enqueue_veo_2_image_to_video_webhook::{
Veo2AspectRatio, Veo2Duration,
};
use reqwest::IntoUrl;
pub struct Veo2TextToVideoArgs<'a, V: IntoUrl> {
pub prompt: &'a str,
pub negative_prompt: Option<&'a str>,
pub duration: Veo2Duration,
pub aspect_ratio: Veo2AspectRatio,
pub webhook_url: V,
pub api_key: &'a FalApiKey,
}
impl<V: IntoUrl> FalRequestCostCalculator for Veo2TextToVideoArgs<'_, V> {
fn calculate_cost_in_cents(&self) -> UsdCents {
// Same pricing as image-to-video:
// "For 5s video your request will cost $2.50.
// For every additional second you will be charged $0.50."
match self.duration {
Veo2Duration::Default => 250,
Veo2Duration::FiveSeconds => 250,
Veo2Duration::SixSeconds => 300,
Veo2Duration::SevenSeconds => 350,
Veo2Duration::EightSeconds => 400,
}
}
}
/// Veo 2 Text-to-Video
/// https://fal.ai/models/fal-ai/veo2
pub async fn enqueue_veo_2_text_to_video_webhook<V: IntoUrl>(
args: Veo2TextToVideoArgs<'_, V>,
) -> Result<WebhookResponse, FalErrorPlus> {
let duration = match args.duration {
Veo2Duration::Default => None,
Veo2Duration::FiveSeconds => Some("5s".to_string()),
Veo2Duration::SixSeconds => Some("6s".to_string()),
Veo2Duration::SevenSeconds => Some("7s".to_string()),
Veo2Duration::EightSeconds => Some("8s".to_string()),
};
let aspect_ratio = match args.aspect_ratio {
Veo2AspectRatio::Auto => None, // Let the API default (16:9)
Veo2AspectRatio::AutoPreferPortrait => Some("9:16".to_string()),
Veo2AspectRatio::WideSixteenNine => Some("16:9".to_string()),
Veo2AspectRatio::TallNineSixteen => Some("9:16".to_string()),
};
let request = Veo2TextToVideoInput {
prompt: args.prompt.to_string(),
aspect_ratio,
duration,
negative_prompt: args.negative_prompt.map(|s| s.to_string()),
};
let result = veo_2_text_to_video(request)
.with_api_key(&args.api_key.0)
.queue_webhook(args.webhook_url)
.await;
result.map_err(|err| classify_fal_error(err))
}
#[cfg(test)]
mod tests {
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::webhook::video::text::enqueue_veo_2_text_to_video_webhook::{
enqueue_veo_2_text_to_video_webhook, Veo2TextToVideoArgs, Veo2AspectRatio, Veo2Duration,
};
use errors::AnyhowResult;
use std::fs::read_to_string;
#[tokio::test]
#[ignore]
async fn test_veo_2_text_to_video() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let args = Veo2TextToVideoArgs {
prompt: "a drone shot of a coastal sunset with waves crashing on rocks",
negative_prompt: None,
api_key: &api_key,
duration: Veo2Duration::FiveSeconds,
aspect_ratio: Veo2AspectRatio::WideSixteenNine,
webhook_url: "https://example.com/webhook",
};
let _result = enqueue_veo_2_text_to_video_webhook(args).await?;
Ok(())
}
}
@@ -1,137 +0,0 @@
use crate::creds::fal_api_key::FalApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::webhook_response::WebhookResponse;
use crate::requests::http::video::text::http_veo_3_text_to_video::{
veo_3_text_to_video, Veo3TextToVideoInput,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{
FalRequestCostCalculator, UsdCents,
};
use reqwest::IntoUrl;
pub struct Veo3TextToVideoArgs<'a, V: IntoUrl> {
pub prompt: &'a str,
pub negative_prompt: Option<&'a str>,
pub duration: Veo3T2vDuration,
pub aspect_ratio: Veo3T2vAspectRatio,
pub resolution: Veo3T2vResolution,
pub generate_audio: bool,
pub webhook_url: V,
pub api_key: &'a FalApiKey,
}
/// Duration for Veo 3 text-to-video. Default is 8 seconds.
#[derive(Copy, Clone, Debug)]
pub enum Veo3T2vDuration {
Default, // Default is 8 seconds
FourSeconds,
SixSeconds,
EightSeconds,
}
/// Aspect ratio for Veo 3 text-to-video.
/// Only 16:9 and 9:16 are supported. No Auto, no Square.
#[derive(Copy, Clone, Debug)]
pub enum Veo3T2vAspectRatio {
Default, // Default is 16:9
WideSixteenNine, // 16:9
TallNineSixteen, // 9:16
}
/// Resolution for Veo 3 text-to-video.
#[derive(Copy, Clone, Debug)]
pub enum Veo3T2vResolution {
Default,
SevenTwentyP,
TenEightyP,
}
impl<V: IntoUrl> FalRequestCostCalculator for Veo3TextToVideoArgs<'_, V> {
fn calculate_cost_in_cents(&self) -> UsdCents {
// $0.20/sec audio off, $0.40/sec audio on.
match (self.duration, self.generate_audio) {
(Veo3T2vDuration::FourSeconds, false) => 80,
(Veo3T2vDuration::SixSeconds, false) => 120,
(Veo3T2vDuration::EightSeconds, false) => 160,
(Veo3T2vDuration::Default, false) => 160,
(Veo3T2vDuration::FourSeconds, true) => 160,
(Veo3T2vDuration::SixSeconds, true) => 240,
(Veo3T2vDuration::EightSeconds, true) => 320,
(Veo3T2vDuration::Default, true) => 320,
}
}
}
/// Veo 3 Text-to-Video
/// https://fal.ai/models/fal-ai/veo3
pub async fn enqueue_veo_3_text_to_video_webhook<V: IntoUrl>(
args: Veo3TextToVideoArgs<'_, V>,
) -> Result<WebhookResponse, FalErrorPlus> {
let duration = match args.duration {
Veo3T2vDuration::Default => None,
Veo3T2vDuration::FourSeconds => Some("4s".to_string()),
Veo3T2vDuration::SixSeconds => Some("6s".to_string()),
Veo3T2vDuration::EightSeconds => Some("8s".to_string()),
};
let aspect_ratio = match args.aspect_ratio {
Veo3T2vAspectRatio::Default => None,
Veo3T2vAspectRatio::WideSixteenNine => Some("16:9".to_string()),
Veo3T2vAspectRatio::TallNineSixteen => Some("9:16".to_string()),
};
let resolution = match args.resolution {
Veo3T2vResolution::Default => None,
Veo3T2vResolution::SevenTwentyP => Some("720p".to_string()),
Veo3T2vResolution::TenEightyP => Some("1080p".to_string()),
};
let request = Veo3TextToVideoInput {
prompt: args.prompt.to_string(),
aspect_ratio,
resolution,
duration,
generate_audio: Some(args.generate_audio),
negative_prompt: args.negative_prompt.map(|s| s.to_string()),
};
let result = veo_3_text_to_video(request)
.with_api_key(&args.api_key.0)
.queue_webhook(args.webhook_url)
.await;
result.map_err(|err| classify_fal_error(err))
}
#[cfg(test)]
mod tests {
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::webhook::video::text::enqueue_veo_3_text_to_video_webhook::{
enqueue_veo_3_text_to_video_webhook, Veo3TextToVideoArgs, Veo3T2vAspectRatio,
Veo3T2vDuration, Veo3T2vResolution,
};
use errors::AnyhowResult;
use std::fs::read_to_string;
#[tokio::test]
#[ignore]
async fn test_veo_3_text_to_video() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let args = Veo3TextToVideoArgs {
prompt: "a drone shot of a coastal sunset with waves crashing on rocks",
negative_prompt: None,
api_key: &api_key,
duration: Veo3T2vDuration::EightSeconds,
aspect_ratio: Veo3T2vAspectRatio::WideSixteenNine,
resolution: Veo3T2vResolution::TenEightyP,
generate_audio: true,
webhook_url: "https://example.com/webhook",
};
let _result = enqueue_veo_3_text_to_video_webhook(args).await?;
Ok(())
}
}
@@ -7,7 +7,5 @@ pub mod enqueue_pixverse_v5_text_to_video_webhook;
pub mod enqueue_seedance_1p5_pro_text_to_video_webhook;
pub mod enqueue_sora_2_pro_text_to_video_webhook;
pub mod enqueue_sora_2_text_to_video_webhook;
pub mod enqueue_veo_2_text_to_video_webhook;
pub mod enqueue_veo_3_text_to_video_webhook;
pub mod enqueue_veo_3p1_fast_text_to_video_webhook;
pub mod enqueue_veo_3p1_text_to_video_webhook;
@@ -277,8 +277,6 @@ pub async fn generate_video(args: GenerateVideoArgs<'_>) -> Result<GenerateVideo
info!("Seedance2pro request : {:?}", request_body);
println!("\n\nSeedance2pro request : \n\n {:?}\n\n", request_body);
let cookie = args.session.cookies.as_str();
let client = Client::builder()
@@ -34,4 +34,3 @@ env_logger.workspace = true
env.workspace = true
chrono.workspace = true
strum.workspace = true
test_data.workspace = true
@@ -4,26 +4,12 @@ pub enum WorldLabsModel {
/// `Marble 0.1-mini` is good for quick drafts
/// Generation time: 30-45 seconds
/// Cost: 150-330 credits
#[deprecated(note="Marble 0.1-mini is deprecated. Use `marble-1.0-draft` instead.")]
Marble0p1Mini,
/// `Marble 0.1-plus` is best for final renders
/// Generation time: ~5 minutes,
/// Cost: 1500-1600 credits
#[deprecated(note="Marble 0.1-plus is deprecated. Use `marble-1.0` instead.")]
Marble0p1Plus,
/// marble-1.0
Marble1p0,
/// marble-1.0-draft
Marble1p0Draft,
/// marble-1.1
Marble1p1,
/// marble-1.1-plus
Marble1p1Plus,
}
impl WorldLabsModel {
@@ -32,159 +18,6 @@ impl WorldLabsModel {
match self {
Self::Marble0p1Mini => "Marble 0.1-mini",
Self::Marble0p1Plus => "Marble 0.1-plus",
Self::Marble1p0 => "marble-1.0",
Self::Marble1p0Draft => "marble-1.0-draft",
Self::Marble1p1 => "marble-1.1",
Self::Marble1p1Plus => "marble-1.1-plus",
}
}
pub fn is_deprecated(&self) -> bool {
match self {
Self::Marble0p1Mini | Self::Marble0p1Plus => true,
_ => false,
}
}
pub fn to_new_value(self) -> Self {
match self {
Self::Marble0p1Mini => Self::Marble1p0Draft,
Self::Marble0p1Plus => Self::Marble1p0,
_ => self,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
mod api_name {
use super::*;
#[allow(deprecated)]
#[test]
fn marble_0p1_mini() {
assert_eq!(WorldLabsModel::Marble0p1Mini.get_model_api_name_str(), "Marble 0.1-mini");
}
#[allow(deprecated)]
#[test]
fn marble_0p1_plus() {
assert_eq!(WorldLabsModel::Marble0p1Plus.get_model_api_name_str(), "Marble 0.1-plus");
}
#[test]
fn marble_1p0() {
assert_eq!(WorldLabsModel::Marble1p0.get_model_api_name_str(), "marble-1.0");
}
#[test]
fn marble_1p0_draft() {
assert_eq!(WorldLabsModel::Marble1p0Draft.get_model_api_name_str(), "marble-1.0-draft");
}
#[test]
fn marble_1p1() {
assert_eq!(WorldLabsModel::Marble1p1.get_model_api_name_str(), "marble-1.1");
}
#[test]
fn marble_1p1_plus() {
assert_eq!(WorldLabsModel::Marble1p1Plus.get_model_api_name_str(), "marble-1.1-plus");
}
}
mod is_deprecated {
use super::*;
#[allow(deprecated)]
#[test]
fn marble_0p1_mini_is_deprecated() {
assert!(WorldLabsModel::Marble0p1Mini.is_deprecated());
}
#[allow(deprecated)]
#[test]
fn marble_0p1_plus_is_deprecated() {
assert!(WorldLabsModel::Marble0p1Plus.is_deprecated());
}
#[test]
fn marble_1p0_is_not_deprecated() {
assert!(!WorldLabsModel::Marble1p0.is_deprecated());
}
#[test]
fn marble_1p0_draft_is_not_deprecated() {
assert!(!WorldLabsModel::Marble1p0Draft.is_deprecated());
}
#[test]
fn marble_1p1_is_not_deprecated() {
assert!(!WorldLabsModel::Marble1p1.is_deprecated());
}
#[test]
fn marble_1p1_plus_is_not_deprecated() {
assert!(!WorldLabsModel::Marble1p1Plus.is_deprecated());
}
}
mod to_new_value {
use super::*;
#[allow(deprecated)]
#[test]
fn marble_0p1_mini_maps_to_1p0_draft() {
let new = WorldLabsModel::Marble0p1Mini.to_new_value();
assert_eq!(new.get_model_api_name_str(), "marble-1.0-draft");
assert!(!new.is_deprecated());
}
#[allow(deprecated)]
#[test]
fn marble_0p1_plus_maps_to_1p0() {
let new = WorldLabsModel::Marble0p1Plus.to_new_value();
assert_eq!(new.get_model_api_name_str(), "marble-1.0");
assert!(!new.is_deprecated());
}
#[test]
fn marble_1p0_stays_as_1p0() {
let new = WorldLabsModel::Marble1p0.to_new_value();
assert_eq!(new.get_model_api_name_str(), "marble-1.0");
}
#[test]
fn marble_1p0_draft_stays_as_1p0_draft() {
let new = WorldLabsModel::Marble1p0Draft.to_new_value();
assert_eq!(new.get_model_api_name_str(), "marble-1.0-draft");
}
#[test]
fn marble_1p1_stays_as_1p1() {
let new = WorldLabsModel::Marble1p1.to_new_value();
assert_eq!(new.get_model_api_name_str(), "marble-1.1");
}
#[test]
fn marble_1p1_plus_stays_as_1p1_plus() {
let new = WorldLabsModel::Marble1p1Plus.to_new_value();
assert_eq!(new.get_model_api_name_str(), "marble-1.1-plus");
}
#[test]
fn new_models_are_not_deprecated_after_conversion() {
for model in [
WorldLabsModel::Marble1p0,
WorldLabsModel::Marble1p0Draft,
WorldLabsModel::Marble1p1,
WorldLabsModel::Marble1p1Plus,
] {
let new = model.to_new_value();
assert!(!new.is_deprecated(), "{:?} should not be deprecated after to_new_value", model);
}
}
}
}
@@ -33,15 +33,10 @@ pub struct GenerateWorldResponse {
pub async fn generate_world(args: GenerateWorldArgs<'_>) -> Result<GenerateWorldResponse, WorldLabsError> {
let client = Client::new();
let model_name = args.model
.to_new_value()
.get_model_api_name_str()
.to_string();
let payload = RawRequest {
world_prompt: args.world_prompt,
display_name: args.display_name,
model: Some(model_name),
model: Some(args.model.get_model_api_name_str().to_string()),
seed: args.seed,
tags: args.tags,
permission: args.permission,
@@ -95,11 +90,8 @@ pub async fn generate_world(args: GenerateWorldArgs<'_>) -> Result<GenerateWorld
mod tests {
use super::*;
use crate::api::requests::generate_world::http_request::{ContentReference, WorldPrompt};
use crate::api::requests::prepare_upload::prepare_upload::{prepare_upload, PrepareUploadArgs, MediaAssetKind};
use crate::api::requests::upload_to_signed_url::upload_to_signed_url::{upload_to_signed_url, UploadToSignedUrlArgs};
use crate::test_utils::get_test_api_key::get_test_api_key;
use crate::test_utils::setup_test_logging::setup_test_logging;
use filesys::file_read_bytes::file_read_bytes;
use log::LevelFilter;
#[tokio::test]
@@ -159,291 +151,4 @@ mod tests {
assert_eq!(1, 2);
}
/// End-to-end: upload a safe image step by step (prepare → upload →
/// generate_world), printing each step's result for debugging.
///
/// Uses a synthetic gradient PNG from test_data/.
#[tokio::test]
#[ignore]
async fn test_generate_world_for_safe_image() {
setup_test_logging(LevelFilter::Debug);
let creds = get_test_api_key().unwrap();
// --- Read file ---
let file_bytes = file_read_bytes("/Users/bt/dev/storyteller/artcraft/test_data/image/mochi.jpg")
.expect("failed to read safe test image");
println!("Read {} bytes from safe test image", file_bytes.len());
// --- Step 1: prepare_upload ---
println!("\n=== Step 1: prepare_upload ===");
let prepare_result = prepare_upload(PrepareUploadArgs {
creds: &creds,
file_name: "mochi.jpg",
kind: MediaAssetKind::Image,
request_timeout: None,
}).await;
println!("prepare_upload result: {:?}", prepare_result.as_ref().map(|r| &r.media_asset_id));
let prepare_response = match prepare_result {
Ok(r) => {
println!(" Media Asset ID: {}", r.media_asset_id.as_str());
println!(" Upload URL: {}", r.upload_url);
println!(" Required headers: {:?}", r.required_headers);
r
}
Err(err) => {
println!(" ERROR at prepare_upload: {:?}", err);
panic!("prepare_upload failed: {:?}", err);
}
};
// --- Step 2: upload_to_signed_url ---
println!("\n=== Step 2: upload_to_signed_url ===");
let upload_result = upload_to_signed_url(UploadToSignedUrlArgs {
upload_url: &prepare_response.upload_url,
file_bytes,
required_headers: &prepare_response.required_headers,
content_type: "image/jpeg",
request_timeout: None,
}).await;
match &upload_result {
Ok(()) => println!(" Upload succeeded."),
Err(err) => {
println!(" ERROR at upload_to_signed_url: {:?}", err);
panic!("upload_to_signed_url failed: {:?}", err);
}
}
// --- Step 3: generate_world ---
println!("\n=== Step 3: generate_world ===");
let generate_result = generate_world(GenerateWorldArgs {
creds: &creds,
world_prompt: WorldPrompt::Image {
image_prompt: ContentReference::MediaAsset {
media_asset_id: prepare_response.media_asset_id.as_str().to_string(),
},
text_prompt: Some("A colorful gradient landscape".to_string()),
is_pano: None,
disable_recaption: None,
},
display_name: None,
model: WorldLabsModel::Marble0p1Mini,
seed: None,
tags: None,
permission: None,
request_timeout: None,
}).await;
match &generate_result {
Ok(r) => {
println!(" SUCCESS — safe image");
println!(" Operation ID: {}", r.operation_id.as_str());
println!(" Done: {}", r.done);
}
Err(err) => {
println!(" ERROR at generate_world: {:?}", err);
}
}
assert_eq!(1, 2, "Inspect output above");
}
/// End-to-end: upload an NSFW image step by step (prepare → upload →
/// generate_world), printing each step's result for debugging.
///
/// We expect the NSFW rejection to happen at one of these steps.
/// This test helps us identify *which* step returns the moderation error
/// and what the error response body looks like.
///
/// NB: Place the NSFW test image at `test_data/images/nsfw_test_image.png`.
/// This file is NOT checked into git.
#[tokio::test]
#[ignore]
async fn test_generate_world_for_nsfw_image() {
setup_test_logging(LevelFilter::Debug);
let creds = get_test_api_key().unwrap();
// --- Read file ---
let file_bytes = file_read_bytes("/Users/bt/Pictures/nsfw_test/nsfw_test_1.jpg")
.expect("failed to read NSFW test image");
println!("Read {} bytes from NSFW test image", file_bytes.len());
// --- Step 1: prepare_upload ---
println!("\n=== Step 1: prepare_upload ===");
let prepare_result = prepare_upload(PrepareUploadArgs {
creds: &creds,
file_name: "nsfw_test_1.jpg",
kind: MediaAssetKind::Image,
request_timeout: None,
}).await;
let prepare_response = match prepare_result {
Ok(r) => {
println!(" OK — Media Asset ID: {}", r.media_asset_id.as_str());
println!(" Upload URL: {}", r.upload_url);
r
}
Err(err) => {
println!(" NSFW CAUGHT AT STEP 1 (prepare_upload)!");
println!(" Error (Debug): {:?}", err);
println!(" Error (Display): {}", err);
assert_eq!(1, 2, "NSFW caught at prepare_upload — inspect output above");
unreachable!();
}
};
// --- Step 2: upload_to_signed_url ---
println!("\n=== Step 2: upload_to_signed_url ===");
let upload_result = upload_to_signed_url(UploadToSignedUrlArgs {
upload_url: &prepare_response.upload_url,
file_bytes,
required_headers: &prepare_response.required_headers,
content_type: "image/jpeg",
request_timeout: None,
}).await;
match &upload_result {
Ok(()) => println!(" OK — upload succeeded."),
Err(err) => {
println!(" NSFW CAUGHT AT STEP 2 (upload_to_signed_url)!");
println!(" Error (Debug): {:?}", err);
println!(" Error (Display): {}", err);
assert_eq!(1, 2, "NSFW caught at upload — inspect output above");
}
}
// --- Step 3: generate_world ---
println!("\n=== Step 3: generate_world ===");
let generate_result = generate_world(GenerateWorldArgs {
creds: &creds,
world_prompt: WorldPrompt::Image {
image_prompt: ContentReference::MediaAsset {
media_asset_id: prepare_response.media_asset_id.as_str().to_string(),
},
text_prompt: None,
is_pano: None,
disable_recaption: None,
},
display_name: None,
model: WorldLabsModel::Marble0p1Mini,
seed: None,
tags: None,
permission: None,
request_timeout: None,
}).await;
match &generate_result {
Ok(r) => {
println!(" UNEXPECTED SUCCESS — NSFW image was NOT rejected at generate_world!");
println!(" Operation ID: {}", r.operation_id.as_str());
println!(" Done: {}", r.done);
}
Err(err) => {
println!(" NSFW CAUGHT AT STEP 3 (generate_world)!");
println!(" Error (Debug): {:?}", err);
println!(" Error (Display): {}", err);
}
}
assert_eq!(1, 2, "Inspect output above");
}
/// Marble 1.1: generate world from a mountain/tree image via URI.
#[tokio::test]
#[ignore]
async fn test_generate_world_marble_1p1_image_uri() {
setup_test_logging(LevelFilter::Debug);
let creds = get_test_api_key().unwrap();
let image_url = test_data::web::image_urls::MOUNTAIN_TREE_IMAGE_URL;
println!("=== generate_world with Marble 1.1 ===");
println!("Image URL: {}", image_url);
let result = generate_world(GenerateWorldArgs {
creds: &creds,
world_prompt: WorldPrompt::Image {
image_prompt: ContentReference::Uri {
uri: image_url.to_string(),
},
text_prompt: Some("Mountain landscape with a tree".to_string()),
is_pano: None,
disable_recaption: None,
},
display_name: None,
model: WorldLabsModel::Marble1p1,
seed: None,
tags: None,
permission: None,
request_timeout: None,
}).await;
match &result {
Ok(r) => {
println!("SUCCESS — Marble 1.1");
println!(" Operation ID: {}", r.operation_id.as_str());
println!(" Done: {}", r.done);
}
Err(err) => {
println!("ERROR — Marble 1.1");
println!(" Error (Debug): {:?}", err);
println!(" Error (Display): {}", err);
}
}
assert_eq!(1, 2, "Inspect output above");
}
/// Marble 1.1-plus: generate world from a wide fall mountains image via URI.
#[tokio::test]
#[ignore]
async fn test_generate_world_marble_1p1_plus_image_uri() {
setup_test_logging(LevelFilter::Debug);
let creds = get_test_api_key().unwrap();
let image_url = test_data::web::image_urls::SUPER_WIDE_FALL_MOUNTAINS_IMAGE_URL;
println!("=== generate_world with Marble 1.1-plus ===");
println!("Image URL: {}", image_url);
let result = generate_world(GenerateWorldArgs {
creds: &creds,
world_prompt: WorldPrompt::Image {
image_prompt: ContentReference::Uri {
uri: image_url.to_string(),
},
text_prompt: Some("Autumn mountain panorama".to_string()),
is_pano: None,
disable_recaption: None,
},
display_name: None,
model: WorldLabsModel::Marble1p1Plus,
seed: None,
tags: None,
permission: None,
request_timeout: None,
}).await;
match &result {
Ok(r) => {
println!("SUCCESS — Marble 1.1-plus");
println!(" Operation ID: {}", r.operation_id.as_str());
println!(" Done: {}", r.done);
}
Err(err) => {
println!("ERROR — Marble 1.1-plus");
println!(" Error (Debug): {:?}", err);
println!(" Error (Display): {}", err);
}
}
assert_eq!(1, 2, "Inspect output above");
}
}
@@ -195,8 +195,7 @@ mod tests {
let creds = get_test_api_key().unwrap();
//let operation_id = OperationId("1fab3bf1-05a1-4929-907e-c6df07c539e2".to_string());
let operation_id = OperationId("a0894fa6-594d-4680-8f3a-5109f22664b1".to_string());
let operation_id = OperationId("1fab3bf1-05a1-4929-907e-c6df07c539e2".to_string());
let response = get_operation(GetOperationArgs {
creds: &creds,
@@ -16,11 +16,6 @@ pub fn filter_world_labs_http_error(status_code: StatusCode, maybe_body: Option<
_ => {},
}
// Check for NSFW content policy rejection (403 with nsfw/sexuality keywords).
if let Some(nsfw_error) = check_nsfw_content_rejection(status_code, maybe_body) {
return Err(nsfw_error.into());
}
if let Some(body) = maybe_body {
return Err(WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code,
@@ -33,163 +28,3 @@ pub fn filter_world_labs_http_error(status_code: StatusCode, maybe_body: Option<
body: String::new(),
}.into())
}
/// Check if a 403 response is an NSFW content policy rejection.
/// Returns `Some(WorldLabsSpecificApiError)` if it matches, `None` otherwise.
fn check_nsfw_content_rejection(
status_code: StatusCode,
maybe_body: Option<&str>,
) -> Option<WorldLabsSpecificApiError> {
if status_code != StatusCode::FORBIDDEN {
return None;
}
let body = maybe_body?;
let body_lower = body.to_lowercase();
if body_lower.contains("nsfw") || body_lower.contains("sexuality") {
Some(WorldLabsSpecificApiError::NsfwContentPolicyRejected {
message: Some(body.to_string()),
})
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn success_200_returns_ok() {
let result = filter_world_labs_http_error(StatusCode::OK, None);
assert!(result.is_ok());
}
#[test]
fn success_200_with_body_returns_ok() {
let result = filter_world_labs_http_error(StatusCode::OK, Some("{}"));
assert!(result.is_ok());
}
// --- NSFW 403 ---
#[test]
fn nsfw_403_real_response() {
let body = r#"{"detail":"NSFW Content is not permitted by policy. Sexuality"}"#;
let result = filter_world_labs_http_error(StatusCode::FORBIDDEN, Some(body));
let err = result.unwrap_err();
assert!(err.is_403_forbidden());
assert!(matches!(err, WorldLabsError::ApiSpecific(
WorldLabsSpecificApiError::NsfwContentPolicyRejected { .. }
)));
}
#[test]
fn nsfw_403_preserves_message() {
let body = r#"{"detail":"NSFW Content is not permitted by policy. Sexuality"}"#;
let result = filter_world_labs_http_error(StatusCode::FORBIDDEN, Some(body));
let err = result.unwrap_err();
if let WorldLabsError::ApiSpecific(WorldLabsSpecificApiError::NsfwContentPolicyRejected { message }) = err {
assert_eq!(message.as_deref(), Some(body));
} else {
panic!("expected NsfwContentPolicyRejected, got: {:?}", err);
}
}
#[test]
fn nsfw_403_case_insensitive_nsfw() {
let body = r#"{"detail":"nsfw content detected"}"#;
let result = filter_world_labs_http_error(StatusCode::FORBIDDEN, Some(body));
assert!(matches!(result.unwrap_err(), WorldLabsError::ApiSpecific(
WorldLabsSpecificApiError::NsfwContentPolicyRejected { .. }
)));
}
#[test]
fn nsfw_403_case_insensitive_sexuality() {
let body = r#"{"detail":"Rejected due to Sexuality content"}"#;
let result = filter_world_labs_http_error(StatusCode::FORBIDDEN, Some(body));
assert!(matches!(result.unwrap_err(), WorldLabsError::ApiSpecific(
WorldLabsSpecificApiError::NsfwContentPolicyRejected { .. }
)));
}
// --- Non-NSFW 403 ---
#[test]
fn non_nsfw_403_is_generic_error() {
let body = r#"{"detail":"Forbidden: invalid API key"}"#;
let result = filter_world_labs_http_error(StatusCode::FORBIDDEN, Some(body));
let err = result.unwrap_err();
assert!(err.is_403_forbidden());
assert!(matches!(err, WorldLabsError::ApiGeneric(
WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. }
)));
}
#[test]
fn non_nsfw_403_no_body_is_generic_error() {
let result = filter_world_labs_http_error(StatusCode::FORBIDDEN, None);
let err = result.unwrap_err();
assert!(err.is_403_forbidden());
assert!(matches!(err, WorldLabsError::ApiGeneric(
WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. }
)));
}
// --- 402 Payment Required ---
#[test]
fn payment_required_402() {
let result = filter_world_labs_http_error(StatusCode::PAYMENT_REQUIRED, Some("{}"));
let err = result.unwrap_err();
assert!(matches!(err, WorldLabsError::ApiSpecific(
WorldLabsSpecificApiError::InsufficientCredits
)));
assert!(!err.is_403_forbidden());
}
// --- Other errors ---
#[test]
fn server_error_500_is_generic() {
let body = r#"{"detail":"Internal server error"}"#;
let result = filter_world_labs_http_error(StatusCode::INTERNAL_SERVER_ERROR, Some(body));
let err = result.unwrap_err();
assert!(!err.is_403_forbidden());
assert!(matches!(err, WorldLabsError::ApiGeneric(
WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. }
)));
}
#[test]
fn bad_request_400_is_generic() {
let body = r#"{"detail":"Bad request"}"#;
let result = filter_world_labs_http_error(StatusCode::BAD_REQUEST, Some(body));
assert!(matches!(result.unwrap_err(), WorldLabsError::ApiGeneric(
WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. }
)));
}
#[test]
fn nsfw_keyword_in_non_403_is_not_nsfw_error() {
// Even if the body mentions "nsfw", a 400 should NOT be classified as NSFW rejection.
let body = r#"{"detail":"nsfw check failed"}"#;
let result = filter_world_labs_http_error(StatusCode::BAD_REQUEST, Some(body));
assert!(matches!(result.unwrap_err(), WorldLabsError::ApiGeneric(
WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. }
)));
}
#[test]
fn error_500_no_body() {
let result = filter_world_labs_http_error(StatusCode::INTERNAL_SERVER_ERROR, None);
assert!(matches!(result.unwrap_err(), WorldLabsError::ApiGeneric(
WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody { body, .. }
) if body.is_empty()));
}
}
@@ -10,16 +10,6 @@ pub enum WorldLabsError {
ApiGeneric(WorldLabsGenericApiError),
}
impl WorldLabsError {
pub fn is_403_forbidden(&self) -> bool {
match self {
Self::ApiSpecific(e) => e.is_403_forbidden(),
Self::ApiGeneric(e) => e.is_403_forbidden(),
Self::Client(_) => false,
}
}
}
impl Error for WorldLabsError {}
impl std::fmt::Display for WorldLabsError {
@@ -24,18 +24,6 @@ pub enum WorldLabsGenericApiError {
WreqError(wreq::Error),
}
impl WorldLabsGenericApiError {
pub fn is_403_forbidden(&self) -> bool {
match self {
Self::UncategorizedBadResponseWithStatusAndBody { status_code, .. }
| Self::GoogleUploadFailed { status_code, .. } => {
*status_code == StatusCode::FORBIDDEN
}
_ => false,
}
}
}
impl Error for WorldLabsGenericApiError {}
impl Display for WorldLabsGenericApiError {
@@ -5,20 +5,6 @@ use std::fmt::{Display, Formatter};
pub enum WorldLabsSpecificApiError {
/// There aren't sufficient funds for generation.
InsufficientCredits,
/// The content was rejected by WorldLabs' NSFW / content policy filter.
NsfwContentPolicyRejected {
message: Option<String>,
},
}
impl WorldLabsSpecificApiError {
pub fn is_403_forbidden(&self) -> bool {
match self {
Self::NsfwContentPolicyRejected { .. } => true,
_ => false,
}
}
}
impl Error for WorldLabsSpecificApiError {}
@@ -27,13 +13,6 @@ impl Display for WorldLabsSpecificApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::InsufficientCredits => write!(f, "Insufficient credits"),
Self::NsfwContentPolicyRejected { message } => {
write!(f, "NSFW content policy rejection")?;
if let Some(msg) = message {
write!(f, ": {}", msg)?;
}
Ok(())
}
}
}
}
@@ -42,38 +42,6 @@ fn credits_for(model: WorldLabsModel, input_type: InputType) -> u32 {
(WorldLabsModel::Marble0p1Plus, InputType::ImageNonPanorama) => 1580,
(WorldLabsModel::Marble0p1Plus, InputType::MultiImage) => 1600,
(WorldLabsModel::Marble0p1Plus, InputType::Video) => 1600,
// Marble 1.0 (successor to 0.1-plus)
(WorldLabsModel::Marble1p0, InputType::ImagePanorama) => 1500,
(WorldLabsModel::Marble1p0, InputType::Text) => 1580,
(WorldLabsModel::Marble1p0, InputType::ImageNonPanorama) => 1580,
(WorldLabsModel::Marble1p0, InputType::MultiImage) => 1600,
(WorldLabsModel::Marble1p0, InputType::Video) => 1600,
// Marble 1.0-draft (successor to 0.1-mini)
(WorldLabsModel::Marble1p0Draft, InputType::ImagePanorama) => 150,
(WorldLabsModel::Marble1p0Draft, InputType::Text) => 230,
(WorldLabsModel::Marble1p0Draft, InputType::ImageNonPanorama) => 230,
(WorldLabsModel::Marble1p0Draft, InputType::MultiImage) => 250,
(WorldLabsModel::Marble1p0Draft, InputType::Video) => 250,
// Marble 1.1
(WorldLabsModel::Marble1p1, InputType::ImagePanorama) => 1500,
(WorldLabsModel::Marble1p1, InputType::Text) => 1580,
(WorldLabsModel::Marble1p1, InputType::ImageNonPanorama) => 1580,
(WorldLabsModel::Marble1p1, InputType::MultiImage) => 1600,
(WorldLabsModel::Marble1p1, InputType::Video) => 1600,
// TODO: Looks like there's no way for us to know what the price will be
// for us to bill this accurately, we'll have to bill this after the fact.
// also - they don't have an API for us to ascertain this??! Wat.
// Marble 1.1-plus (range pricing — we use the max to charge enough upfront)
// Actual range: 1500–3000 / 1580–3080 / 1600–3100
(WorldLabsModel::Marble1p1Plus, InputType::ImagePanorama) => 3000,
(WorldLabsModel::Marble1p1Plus, InputType::Text) => 3080,
(WorldLabsModel::Marble1p1Plus, InputType::ImageNonPanorama) => 3080,
(WorldLabsModel::Marble1p1Plus, InputType::MultiImage) => 3100,
(WorldLabsModel::Marble1p1Plus, InputType::Video) => 3100,
}
}
@@ -96,10 +64,6 @@ mod tests {
let models = [
WorldLabsModel::Marble0p1Mini,
WorldLabsModel::Marble0p1Plus,
WorldLabsModel::Marble1p0,
WorldLabsModel::Marble1p0Draft,
WorldLabsModel::Marble1p1,
WorldLabsModel::Marble1p1Plus,
];
println!("\n{:<20} {:<20} {:>10} {:>12}", "Model", "Input Type", "Credits", "USD Cents");
@@ -137,59 +101,6 @@ mod tests {
assert_eq!(calculate_cost(WorldLabsModel::Marble0p1Plus, InputType::Video).worldlabs_credits, 1600);
}
#[test]
fn test_marble_1p0_draft_credits() {
// Same as Marble 0.1-mini (its successor)
assert_eq!(calculate_cost(WorldLabsModel::Marble1p0Draft, InputType::Text).worldlabs_credits, 230);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p0Draft, InputType::ImageNonPanorama).worldlabs_credits, 230);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p0Draft, InputType::ImagePanorama).worldlabs_credits, 150);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p0Draft, InputType::MultiImage).worldlabs_credits, 250);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p0Draft, InputType::Video).worldlabs_credits, 250);
}
#[test]
fn test_marble_1p0_credits() {
// Standard model, same pricing as Marble 0.1-plus
assert_eq!(calculate_cost(WorldLabsModel::Marble1p0, InputType::Text).worldlabs_credits, 1580);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p0, InputType::ImageNonPanorama).worldlabs_credits, 1580);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p0, InputType::ImagePanorama).worldlabs_credits, 1500);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p0, InputType::MultiImage).worldlabs_credits, 1600);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p0, InputType::Video).worldlabs_credits, 1600);
}
#[test]
fn test_marble_1p1_credits() {
// Standard model, same pricing tier as 1.0
assert_eq!(calculate_cost(WorldLabsModel::Marble1p1, InputType::Text).worldlabs_credits, 1580);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p1, InputType::ImageNonPanorama).worldlabs_credits, 1580);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p1, InputType::ImagePanorama).worldlabs_credits, 1500);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p1, InputType::MultiImage).worldlabs_credits, 1600);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p1, InputType::Video).worldlabs_credits, 1600);
}
#[test]
fn test_marble_1p1_plus_credits() {
// Plus model uses max of the range pricing (1500–3000, 1580–3080, 1600–3100)
assert_eq!(calculate_cost(WorldLabsModel::Marble1p1Plus, InputType::Text).worldlabs_credits, 3080);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p1Plus, InputType::ImageNonPanorama).worldlabs_credits, 3080);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p1Plus, InputType::ImagePanorama).worldlabs_credits, 3000);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p1Plus, InputType::MultiImage).worldlabs_credits, 3100);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p1Plus, InputType::Video).worldlabs_credits, 3100);
}
#[test]
fn test_marble_1p1_plus_is_more_expensive_than_standard() {
for input_type in InputType::iter() {
let standard = calculate_cost(WorldLabsModel::Marble1p1, input_type);
let plus = calculate_cost(WorldLabsModel::Marble1p1Plus, input_type);
assert!(
plus.worldlabs_credits > standard.worldlabs_credits,
"1.1-plus should cost more than 1.1 for {:?}: {} vs {}",
input_type, plus.worldlabs_credits, standard.worldlabs_credits,
);
}
}
#[test]
fn test_dollar_conversion() {
// 1250 credits = $1.00 = 100 cents
@@ -209,10 +120,6 @@ mod tests {
let models = [
WorldLabsModel::Marble0p1Mini,
WorldLabsModel::Marble0p1Plus,
WorldLabsModel::Marble1p0,
WorldLabsModel::Marble1p0Draft,
WorldLabsModel::Marble1p1,
WorldLabsModel::Marble1p1Plus,
];
for model in &models {
@@ -41,7 +41,6 @@ pub (super) async fn handle_artcraft_veo3_fast(
generate_audio: request.generate_audio,
duration: None, // TODO: Parameterize
resolution: None, // TODO: Parameterize
aspect_ratio: None, // TODO: Parameterize
};
let result = generate_veo_3_fast_image_to_video(
+1 -1
View File
@@ -1,4 +1,4 @@
/// The version of the ArtCraft desktop app reported in the UI and via Analytics.
/// This should match the CI builds.
pub const ARTCRAFT_VERSION: &str = "0.28.0";
pub const ARTCRAFT_VERSION: &str = "0.27.0";
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
"productName": "ArtCraft",
"version": "0.28.0",
"version": "0.27.0",
"identifier": "ai.artcraft.app",
"build": {
"frontendDist": "../../../frontend/apps/artcraft/dist",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
"productName": "ArtCraft",
"version": "0.28.0",
"version": "0.27.0",
"identifier": "ai.artcraft.app",
"build": {
"frontendDist": "../../../frontend/apps/artcraft/dist",
@@ -24,10 +24,6 @@ pub struct NotificationDetails {
/// Whether this notification originated from an error.
pub(crate) is_from_error: bool,
/// Extra side-channel messages, eg. if we know we're sending an error/exception,
/// but want to include an additional message. (The "description" field is messy atm.)
pub(crate) extra_message: Option<String>,
/// HTTP method associated with the event, if any.
pub(crate) http_method: Option<String>,
@@ -94,30 +90,25 @@ impl NotificationDetails {
sections.push(desc.clone());
}
// 2. Extra message
if let Some(message) = &self.extra_message {
sections.push(message.clone());
}
// 3. Error chain
// 2. Error chain
if let Some(error_section) = self.format_error_chain() {
sections.push(error_section);
}
// 4. Event time
// 3. Event time
sections.push(self.format_event_time());
// 5. HTTP context
// 4. HTTP context
if let Some(http_section) = self.format_http_context() {
sections.push(http_section);
}
// 6. Service tokens
// 5. Service tokens
if let Some(tokens_section) = self.format_service_tokens() {
sections.push(tokens_section);
}
// 7. Application/service identity and hostname
// 6. Application/service identity and hostname
if let Some(identity_section) = Self::format_app_identity(application_name, service_id, hostname) {
sections.push(identity_section);
}
@@ -14,10 +14,6 @@ pub struct NotificationDetailsBuilder {
pub(crate) maybe_error: Option<Arc<dyn std::error::Error + Send + Sync + 'static>>,
pub(crate) is_from_error: bool,
/// Extra side-channel messages, eg. if we know we're sending an error/exception,
/// but want to include an additional message. (The "description" field is messy atm.)
pub(crate) extra_message: Option<String>,
pub(crate) http_method: Option<String>,
pub(crate) http_path: Option<String>,
pub(crate) http_status_code: Option<u16>,
@@ -45,7 +41,6 @@ impl NotificationDetailsBuilder {
event_time: Utc::now(),
maybe_error: None,
is_from_error: false,
extra_message: None,
http_method: None,
http_path: None,
http_status_code: None,
@@ -75,7 +70,6 @@ impl NotificationDetailsBuilder {
event_time: Utc::now(),
maybe_error: Some(error),
is_from_error: true,
extra_message: None,
http_method: None,
http_path: None,
http_status_code: None,
@@ -112,11 +106,6 @@ impl NotificationDetailsBuilder {
self
}
pub fn set_extra_message(mut self, extra_message: Option<String>) -> Self {
self.extra_message = extra_message;
self
}
pub fn set_http_method(mut self, http_method: Option<String>) -> Self {
self.http_method = http_method;
self
@@ -187,7 +176,6 @@ impl NotificationDetailsBuilder {
event_time: self.event_time,
maybe_error: self.maybe_error,
is_from_error: self.is_from_error,
extra_message: self.extra_message,
http_method: self.http_method,
http_path: self.http_path,
http_status_code: self.http_status_code,
@@ -28,14 +28,6 @@ pub enum StaffAuditAction {
/// Staff unbanned a user.
#[serde(rename = "unban_user")]
UnbanUser,
/// Staff added banked balance to a wallet.
#[serde(rename = "add_wallet_banked_balance")]
AddWalletBankedBalance,
/// Staff sent a manual alert via the pager system.
#[serde(rename = "send_alert")]
SendAlert,
}
impl_enum_display_and_debug_using_to_str!(StaffAuditAction);
@@ -49,8 +41,6 @@ impl StaffAuditAction {
Self::ImpersonateUserRedeem => "impersonate_user_redeem",
Self::BanUser => "ban_user",
Self::UnbanUser => "unban_user",
Self::AddWalletBankedBalance => "add_wallet_banked_balance",
Self::SendAlert => "send_alert",
}
}
@@ -60,8 +50,6 @@ impl StaffAuditAction {
"impersonate_user_redeem" => Ok(Self::ImpersonateUserRedeem),
"ban_user" => Ok(Self::BanUser),
"unban_user" => Ok(Self::UnbanUser),
"add_wallet_banked_balance" => Ok(Self::AddWalletBankedBalance),
"send_alert" => Ok(Self::SendAlert),
_ => Err(format!("invalid StaffAuditAction value: {:?}", value)),
}
}
@@ -72,8 +60,6 @@ impl StaffAuditAction {
Self::ImpersonateUserRedeem,
Self::BanUser,
Self::UnbanUser,
Self::AddWalletBankedBalance,
Self::SendAlert,
])
}
}
@@ -92,8 +78,6 @@ mod tests {
assert_serialization(StaffAuditAction::ImpersonateUserRedeem, "impersonate_user_redeem");
assert_serialization(StaffAuditAction::BanUser, "ban_user");
assert_serialization(StaffAuditAction::UnbanUser, "unban_user");
assert_serialization(StaffAuditAction::AddWalletBankedBalance, "add_wallet_banked_balance");
assert_serialization(StaffAuditAction::SendAlert, "send_alert");
}
#[test]
@@ -102,8 +86,6 @@ mod tests {
assert_eq!(StaffAuditAction::ImpersonateUserRedeem.to_str(), "impersonate_user_redeem");
assert_eq!(StaffAuditAction::BanUser.to_str(), "ban_user");
assert_eq!(StaffAuditAction::UnbanUser.to_str(), "unban_user");
assert_eq!(StaffAuditAction::AddWalletBankedBalance.to_str(), "add_wallet_banked_balance");
assert_eq!(StaffAuditAction::SendAlert.to_str(), "send_alert");
}
#[test]
@@ -112,14 +94,12 @@ mod tests {
assert_eq!(StaffAuditAction::from_str("impersonate_user_redeem").unwrap(), StaffAuditAction::ImpersonateUserRedeem);
assert_eq!(StaffAuditAction::from_str("ban_user").unwrap(), StaffAuditAction::BanUser);
assert_eq!(StaffAuditAction::from_str("unban_user").unwrap(), StaffAuditAction::UnbanUser);
assert_eq!(StaffAuditAction::from_str("add_wallet_banked_balance").unwrap(), StaffAuditAction::AddWalletBankedBalance);
assert_eq!(StaffAuditAction::from_str("send_alert").unwrap(), StaffAuditAction::SendAlert);
assert!(StaffAuditAction::from_str("invalid").is_err());
}
#[test]
fn all_variants() {
const EXPECTED_COUNT: usize = 6;
const EXPECTED_COUNT: usize = 4;
assert_eq!(StaffAuditAction::all_variants().len(), EXPECTED_COUNT);
}
}
@@ -16,10 +16,6 @@ pub enum StaffAuditEntityType {
/// A user account.
#[serde(rename = "user")]
User,
/// A wallet.
#[serde(rename = "wallet")]
Wallet,
}
impl_enum_display_and_debug_using_to_str!(StaffAuditEntityType);
@@ -30,14 +26,12 @@ impl StaffAuditEntityType {
pub fn to_str(&self) -> &'static str {
match self {
Self::User => "user",
Self::Wallet => "wallet",
}
}
pub fn from_str(value: &str) -> Result<Self, String> {
match value {
"user" => Ok(Self::User),
"wallet" => Ok(Self::Wallet),
_ => Err(format!("invalid StaffAuditEntityType value: {:?}", value)),
}
}
@@ -45,7 +39,6 @@ impl StaffAuditEntityType {
pub fn all_variants() -> BTreeSet<Self> {
BTreeSet::from([
Self::User,
Self::Wallet,
])
}
}
@@ -61,25 +54,22 @@ mod tests {
#[test]
fn test_serialization() {
assert_serialization(StaffAuditEntityType::User, "user");
assert_serialization(StaffAuditEntityType::Wallet, "wallet");
}
#[test]
fn to_str() {
assert_eq!(StaffAuditEntityType::User.to_str(), "user");
assert_eq!(StaffAuditEntityType::Wallet.to_str(), "wallet");
}
#[test]
fn from_str() {
assert_eq!(StaffAuditEntityType::from_str("user").unwrap(), StaffAuditEntityType::User);
assert_eq!(StaffAuditEntityType::from_str("wallet").unwrap(), StaffAuditEntityType::Wallet);
assert!(StaffAuditEntityType::from_str("invalid").is_err());
}
#[test]
fn all_variants() {
const EXPECTED_COUNT: usize = 2;
const EXPECTED_COUNT: usize = 1;
assert_eq!(StaffAuditEntityType::all_variants().len(), EXPECTED_COUNT);
}
}
@@ -124,8 +124,6 @@ pub enum CommonModelType {
Seedance1p5Pro,
#[serde(rename = "seedance_2p0")]
Seedance2p0,
#[serde(rename = "seedance_2p0_fast")]
Seedance2p0Fast,
#[serde(rename = "sora_2")]
Sora2,
#[serde(rename = "sora_2_pro")]
@@ -210,7 +208,6 @@ impl CommonModelType {
Self::Seedance10Pro => "seedance_1p0_pro",
Self::Seedance1p5Pro => "seedance_1p5_pro",
Self::Seedance2p0 => "seedance_2p0",
Self::Seedance2p0Fast => "seedance_2p0_fast",
Self::Sora2 => "sora_2",
Self::Sora2Pro => "sora_2_pro",
Self::Veo2 => "veo_2",
@@ -277,7 +274,6 @@ impl CommonModelType {
"seedance_1p0_pro" => Ok(Self::Seedance10Pro),
"seedance_1p5_pro" => Ok(Self::Seedance1p5Pro),
"seedance_2p0" => Ok(Self::Seedance2p0),
"seedance_2p0_fast" => Ok(Self::Seedance2p0Fast),
"sora_2" => Ok(Self::Sora2),
"sora_2_pro" => Ok(Self::Sora2Pro),
"veo_2" => Ok(Self::Veo2),
@@ -348,7 +344,6 @@ impl CommonModelType {
Self::Seedance10Pro,
Self::Seedance1p5Pro,
Self::Seedance2p0,
Self::Seedance2p0Fast,
Self::Sora2,
Self::Sora2Pro,
Self::Veo2,
@@ -416,7 +411,6 @@ impl CommonModelType {
Self::Seedance10Pro => CommonModelClass::Video,
Self::Seedance1p5Pro => CommonModelClass::Video,
Self::Seedance2p0 => CommonModelClass::Video,
Self::Seedance2p0Fast => CommonModelClass::Video,
Self::Sora2 => CommonModelClass::Video,
Self::Sora2Pro => CommonModelClass::Video,
Self::Veo2 => CommonModelClass::Video,
@@ -493,7 +487,6 @@ mod tests {
assert_serialization(CommonModelType::Seedance10Pro, "seedance_1p0_pro");
assert_serialization(CommonModelType::Seedance1p5Pro, "seedance_1p5_pro");
assert_serialization(CommonModelType::Seedance2p0, "seedance_2p0");
assert_serialization(CommonModelType::Seedance2p0Fast, "seedance_2p0_fast");
assert_serialization(CommonModelType::Sora2, "sora_2");
assert_serialization(CommonModelType::Sora2Pro, "sora_2_pro");
assert_serialization(CommonModelType::Veo2, "veo_2");
@@ -557,7 +550,6 @@ mod tests {
assert_eq!(CommonModelType::Seedance10Pro.to_str(), "seedance_1p0_pro");
assert_eq!(CommonModelType::Seedance1p5Pro.to_str(), "seedance_1p5_pro");
assert_eq!(CommonModelType::Seedance2p0.to_str(), "seedance_2p0");
assert_eq!(CommonModelType::Seedance2p0Fast.to_str(), "seedance_2p0_fast");
assert_eq!(CommonModelType::Sora2.to_str(), "sora_2");
assert_eq!(CommonModelType::Sora2Pro.to_str(), "sora_2_pro");
assert_eq!(CommonModelType::Veo2.to_str(), "veo_2");
@@ -621,7 +613,6 @@ mod tests {
assert_eq!(CommonModelType::from_str("seedance_1p0_pro").unwrap(), CommonModelType::Seedance10Pro);
assert_eq!(CommonModelType::from_str("seedance_1p5_pro").unwrap(), CommonModelType::Seedance1p5Pro);
assert_eq!(CommonModelType::from_str("seedance_2p0").unwrap(), CommonModelType::Seedance2p0);
assert_eq!(CommonModelType::from_str("seedance_2p0_fast").unwrap(), CommonModelType::Seedance2p0Fast);
assert_eq!(CommonModelType::from_str("sora_2").unwrap(), CommonModelType::Sora2);
assert_eq!(CommonModelType::from_str("sora_2_pro").unwrap(), CommonModelType::Sora2Pro);
assert_eq!(CommonModelType::from_str("veo_2").unwrap(), CommonModelType::Veo2);
@@ -642,7 +633,7 @@ mod tests {
#[test]
fn all_variants() {
let mut variants = CommonModelType::all_variants();
assert_eq!(variants.len(), 55);
assert_eq!(variants.len(), 54);
// Image models
assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Dev));
assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Schnell));
@@ -687,7 +678,6 @@ mod tests {
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance10Pro));
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance1p5Pro));
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance2p0));
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance2p0Fast));
assert_eq!(variants.pop_first(), Some(CommonModelType::Sora2));
assert_eq!(variants.pop_first(), Some(CommonModelType::Sora2Pro));
assert_eq!(variants.pop_first(), Some(CommonModelType::Veo2));
@@ -38,9 +38,6 @@ pub enum CommonVideoModel {
#[serde(rename = "seedance_2p0")]
Seedance2p0,
#[serde(rename = "seedance_2p0_fast")]
Seedance2p0Fast,
#[serde(rename = "sora_2")]
Sora2,
@@ -78,7 +75,6 @@ impl CommonVideoModel {
Self::Seedance10Lite => CommonModelType::Seedance10Lite,
Self::Seedance1p5Pro => CommonModelType::Seedance1p5Pro,
Self::Seedance2p0 => CommonModelType::Seedance2p0,
Self::Seedance2p0Fast => CommonModelType::Seedance2p0Fast,
Self::Sora2 => CommonModelType::Sora2,
Self::Sora2Pro => CommonModelType::Sora2Pro,
Self::Veo2 => CommonModelType::Veo2,
@@ -109,7 +105,6 @@ mod tests {
(CommonVideoModel::Seedance10Lite, CommonModelType::Seedance10Lite),
(CommonVideoModel::Seedance1p5Pro, CommonModelType::Seedance1p5Pro),
(CommonVideoModel::Seedance2p0, CommonModelType::Seedance2p0),
(CommonVideoModel::Seedance2p0Fast, CommonModelType::Seedance2p0Fast),
(CommonVideoModel::Sora2, CommonModelType::Sora2),
(CommonVideoModel::Sora2Pro, CommonModelType::Sora2Pro),
(CommonVideoModel::Veo2, CommonModelType::Veo2),
@@ -4,7 +4,6 @@ pub mod common_image_model;
pub mod common_quality;
pub mod common_model_class;
pub mod common_model_type;
pub mod model_creator;
pub mod common_resolution;
pub mod common_splat_model;
pub mod common_video_model;
@@ -1,280 +0,0 @@
use std::collections::BTreeSet;
use utoipa::ToSchema;
#[cfg(test)]
use strum::EnumCount;
#[cfg(test)]
use strum::EnumIter;
/// The company or organization that created a model.
#[cfg_attr(test, derive(EnumIter, EnumCount))]
#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize, Deserialize, ToSchema)]
pub enum ModelCreator {
#[serde(rename = "alibaba")]
Alibaba,
#[serde(rename = "artcraft")]
ArtCraft,
#[serde(rename = "black_forest_labs")]
BlackForestLabs,
#[serde(rename = "bytedance")]
Bytedance,
#[serde(rename = "fal")]
Fal,
#[serde(rename = "google")]
Google,
#[serde(rename = "grok")]
Grok,
#[serde(rename = "hailuo")]
Hailuo,
#[serde(rename = "higgsfield")]
Higgsfield,
#[serde(rename = "kling")]
Kling,
#[serde(rename = "krea")]
Krea,
#[serde(rename = "midjourney")]
Midjourney,
#[serde(rename = "open_ai")]
OpenAi,
#[serde(rename = "open_art")]
OpenArt,
#[serde(rename = "recraft")]
Recraft,
#[serde(rename = "replicate")]
Replicate,
#[serde(rename = "runway")]
Runway,
#[serde(rename = "stability")]
Stability,
#[serde(rename = "tencent")]
Tencent,
#[serde(rename = "tensor_art")]
TensorArt,
#[serde(rename = "vidu")]
Vidu,
#[serde(rename = "world_labs")]
WorldLabs,
}
impl ModelCreator {
/// Returns the properly formatted human-readable name.
pub fn get_name(&self) -> &'static str {
match self {
Self::Alibaba => "Alibaba",
Self::ArtCraft => "ArtCraft",
Self::BlackForestLabs => "Black Forest Labs",
Self::Bytedance => "ByteDance",
Self::Fal => "fal",
Self::Google => "Google",
Self::Grok => "Grok",
Self::Hailuo => "Hailuo",
Self::Higgsfield => "Higgsfield",
Self::Kling => "Kling",
Self::Krea => "Krea",
Self::Midjourney => "Midjourney",
Self::OpenAi => "OpenAI",
Self::OpenArt => "OpenArt",
Self::Recraft => "Recraft",
Self::Replicate => "Replicate",
Self::Runway => "Runway",
Self::Stability => "Stability AI",
Self::Tencent => "Tencent",
Self::TensorArt => "TensorArt",
Self::Vidu => "Vidu",
Self::WorldLabs => "World Labs",
}
}
pub fn to_str(&self) -> &'static str {
match self {
Self::Alibaba => "alibaba",
Self::ArtCraft => "artcraft",
Self::BlackForestLabs => "black_forest_labs",
Self::Bytedance => "bytedance",
Self::Fal => "fal",
Self::Google => "google",
Self::Grok => "grok",
Self::Hailuo => "hailuo",
Self::Higgsfield => "higgsfield",
Self::Kling => "kling",
Self::Krea => "krea",
Self::Midjourney => "midjourney",
Self::OpenAi => "open_ai",
Self::OpenArt => "open_art",
Self::Recraft => "recraft",
Self::Replicate => "replicate",
Self::Runway => "runway",
Self::Stability => "stability",
Self::Tencent => "tencent",
Self::TensorArt => "tensor_art",
Self::Vidu => "vidu",
Self::WorldLabs => "world_labs",
}
}
pub fn from_str(value: &str) -> Result<Self, String> {
match value {
"alibaba" => Ok(Self::Alibaba),
"artcraft" => Ok(Self::ArtCraft),
"black_forest_labs" => Ok(Self::BlackForestLabs),
"bytedance" => Ok(Self::Bytedance),
"fal" => Ok(Self::Fal),
"google" => Ok(Self::Google),
"grok" => Ok(Self::Grok),
"hailuo" => Ok(Self::Hailuo),
"higgsfield" => Ok(Self::Higgsfield),
"kling" => Ok(Self::Kling),
"krea" => Ok(Self::Krea),
"midjourney" => Ok(Self::Midjourney),
"open_ai" => Ok(Self::OpenAi),
"open_art" => Ok(Self::OpenArt),
"recraft" => Ok(Self::Recraft),
"replicate" => Ok(Self::Replicate),
"runway" => Ok(Self::Runway),
"stability" => Ok(Self::Stability),
"tencent" => Ok(Self::Tencent),
"tensor_art" => Ok(Self::TensorArt),
"vidu" => Ok(Self::Vidu),
"world_labs" => Ok(Self::WorldLabs),
_ => Err(format!("invalid ModelCreator value: {:?}", value)),
}
}
pub fn all_variants() -> BTreeSet<Self> {
BTreeSet::from([
Self::Alibaba,
Self::ArtCraft,
Self::BlackForestLabs,
Self::Bytedance,
Self::Fal,
Self::Google,
Self::Grok,
Self::Hailuo,
Self::Higgsfield,
Self::Kling,
Self::Krea,
Self::Midjourney,
Self::OpenAi,
Self::OpenArt,
Self::Recraft,
Self::Replicate,
Self::Runway,
Self::Stability,
Self::Tencent,
Self::TensorArt,
Self::Vidu,
Self::WorldLabs,
])
}
}
impl_enum_display_and_debug_using_to_str!(ModelCreator);
#[cfg(test)]
mod tests {
use crate::common::generation::model_creator::ModelCreator;
use crate::test_helpers::assert_serialization;
mod explicit_checks {
use super::*;
#[test]
fn test_serialization() {
assert_serialization(ModelCreator::Alibaba, "alibaba");
assert_serialization(ModelCreator::ArtCraft, "artcraft");
assert_serialization(ModelCreator::BlackForestLabs, "black_forest_labs");
assert_serialization(ModelCreator::Bytedance, "bytedance");
assert_serialization(ModelCreator::Fal, "fal");
assert_serialization(ModelCreator::Google, "google");
assert_serialization(ModelCreator::Grok, "grok");
assert_serialization(ModelCreator::Hailuo, "hailuo");
assert_serialization(ModelCreator::Higgsfield, "higgsfield");
assert_serialization(ModelCreator::Kling, "kling");
assert_serialization(ModelCreator::Krea, "krea");
assert_serialization(ModelCreator::Midjourney, "midjourney");
assert_serialization(ModelCreator::OpenAi, "open_ai");
assert_serialization(ModelCreator::OpenArt, "open_art");
assert_serialization(ModelCreator::Recraft, "recraft");
assert_serialization(ModelCreator::Replicate, "replicate");
assert_serialization(ModelCreator::Runway, "runway");
assert_serialization(ModelCreator::Stability, "stability");
assert_serialization(ModelCreator::Tencent, "tencent");
assert_serialization(ModelCreator::TensorArt, "tensor_art");
assert_serialization(ModelCreator::Vidu, "vidu");
assert_serialization(ModelCreator::WorldLabs, "world_labs");
}
#[test]
fn to_str() {
assert_eq!(ModelCreator::Alibaba.to_str(), "alibaba");
assert_eq!(ModelCreator::ArtCraft.to_str(), "artcraft");
assert_eq!(ModelCreator::BlackForestLabs.to_str(), "black_forest_labs");
assert_eq!(ModelCreator::OpenAi.to_str(), "open_ai");
assert_eq!(ModelCreator::WorldLabs.to_str(), "world_labs");
}
#[test]
fn from_str() {
assert_eq!(ModelCreator::from_str("alibaba").unwrap(), ModelCreator::Alibaba);
assert_eq!(ModelCreator::from_str("artcraft").unwrap(), ModelCreator::ArtCraft);
assert_eq!(ModelCreator::from_str("black_forest_labs").unwrap(), ModelCreator::BlackForestLabs);
assert_eq!(ModelCreator::from_str("open_ai").unwrap(), ModelCreator::OpenAi);
assert_eq!(ModelCreator::from_str("world_labs").unwrap(), ModelCreator::WorldLabs);
assert!(ModelCreator::from_str("invalid").is_err());
}
#[test]
fn get_name() {
assert_eq!(ModelCreator::Alibaba.get_name(), "Alibaba");
assert_eq!(ModelCreator::ArtCraft.get_name(), "ArtCraft");
assert_eq!(ModelCreator::BlackForestLabs.get_name(), "Black Forest Labs");
assert_eq!(ModelCreator::Bytedance.get_name(), "ByteDance");
assert_eq!(ModelCreator::Fal.get_name(), "fal");
assert_eq!(ModelCreator::Google.get_name(), "Google");
assert_eq!(ModelCreator::OpenAi.get_name(), "OpenAI");
assert_eq!(ModelCreator::Stability.get_name(), "Stability AI");
assert_eq!(ModelCreator::WorldLabs.get_name(), "World Labs");
}
#[test]
fn all_variants() {
const EXPECTED_COUNT: usize = 22;
assert_eq!(ModelCreator::all_variants().len(), EXPECTED_COUNT);
}
}
mod mechanical_checks {
use super::*;
#[test]
fn variant_length() {
use strum::IntoEnumIterator;
assert_eq!(ModelCreator::all_variants().len(), ModelCreator::iter().len());
}
#[test]
fn round_trip() {
for variant in ModelCreator::all_variants() {
assert_eq!(variant, ModelCreator::from_str(variant.to_str()).unwrap());
}
}
#[test]
fn serialized_length_ok_for_database() {
const MAX_LENGTH: usize = 32;
for variant in ModelCreator::all_variants() {
let serialized = variant.to_str();
assert!(!serialized.is_empty());
assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long for VARCHAR({})", variant, MAX_LENGTH);
}
}
#[test]
fn every_variant_has_a_human_name() {
for variant in ModelCreator::all_variants() {
let name = variant.get_name();
assert!(!name.is_empty(), "variant {:?} has empty name", variant);
}
}
}
}
@@ -9,9 +9,3 @@ ENABLE_PAGING=true
# Doesn't make sense for jobs:
# ENABLE_PAGING_FOR_500S=true
# We don't need to look back that far in development
MAX_JOB_AGE_THRESHOLD_HOURS=5
# Don't collide with storyteller-web in development
HTTP_BIND_ADDRESS=0.0.0.0:11223
@@ -2,6 +2,3 @@
# Development
SERVER_ENVIRONMENT='development'
# Don't collide with storyteller-web in development
HTTP_BIND_ADDRESS=0.0.0.0:13333
@@ -9,8 +9,8 @@ COOKIE_DOMAIN=.jungle.horse
WEBSITE_HOMEPAGE_REDIRECT=http://jungle.horse
# Paging
ENABLE_PAGING=false
ENABLE_PAGING_FOR_500S=false
ENABLE_PAGING=true
ENABLE_PAGING_FOR_500S=true
# Test the rate limiter bypass (obviously these are dummy values)
AI_STREAMER_USERNAMES="PewDiePie,xQc"
@@ -2,7 +2,6 @@ use artcraft_api_defs::omni_gen::models::omni_gen_image_models::{OmniGenImageMod
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_image_model::CommonImageModel;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation::model_creator::ModelCreator;
use enums::common::generation_provider::GenerationProvider;
use once_cell::sync::Lazy;
use enums::common::generation::common_quality::CommonQuality;
@@ -22,8 +21,7 @@ fn build_omni_gen_image_models() -> Vec<OmniGenImageModelDetails> {
models.push(OmniGenImageModelDetails {
model: CommonImageModel::Flux1Dev,
model_creator: Some(ModelCreator::BlackForestLabs),
full_name: Some("FLUX.1 [dev]".to_string()),
full_name: None,
text_prompt_supported: Some(true),
aspect_ratio_options: Some(vec![
CommonAspectRatio::SquareHd,
@@ -42,8 +40,7 @@ fn build_omni_gen_image_models() -> Vec<OmniGenImageModelDetails> {
models.push(OmniGenImageModelDetails {
model: CommonImageModel::Flux1Schnell,
model_creator: Some(ModelCreator::BlackForestLabs),
full_name: Some("FLUX.1 [schnell]".to_string()),
full_name: None,
text_prompt_supported: Some(true),
aspect_ratio_options: Some(vec![
CommonAspectRatio::SquareHd,
@@ -62,7 +59,6 @@ fn build_omni_gen_image_models() -> Vec<OmniGenImageModelDetails> {
models.push(OmniGenImageModelDetails {
model: CommonImageModel::FluxPro11,
model_creator: Some(ModelCreator::BlackForestLabs),
full_name: Some("FLUX 1.1 [pro]".to_string()),
text_prompt_supported: Some(true),
aspect_ratio_options: Some(vec![
@@ -82,7 +78,6 @@ fn build_omni_gen_image_models() -> Vec<OmniGenImageModelDetails> {
models.push(OmniGenImageModelDetails {
model: CommonImageModel::FluxPro11Ultra,
model_creator: Some(ModelCreator::BlackForestLabs),
full_name: Some("FLUX 1.1 [pro] ultra".to_string()),
text_prompt_supported: Some(true),
aspect_ratio_options: Some(vec![
@@ -105,8 +100,7 @@ fn build_omni_gen_image_models() -> Vec<OmniGenImageModelDetails> {
models.push(OmniGenImageModelDetails {
model: CommonImageModel::NanoBanana, // NB: currently Gemini25Flash in our system
model_creator: Some(ModelCreator::ArtCraft),
full_name: Some("Nano Banana".to_string()),
full_name: None,
text_prompt_supported: Some(true),
image_refs_supported: Some(true),
aspect_ratio_options: Some(vec![
@@ -132,8 +126,7 @@ fn build_omni_gen_image_models() -> Vec<OmniGenImageModelDetails> {
models.push(OmniGenImageModelDetails {
model: CommonImageModel::NanoBanana2,
model_creator: Some(ModelCreator::ArtCraft),
full_name: Some("Nano Banana 2".to_string()),
full_name: None,
text_prompt_supported: Some(true),
image_refs_supported: Some(true),
aspect_ratio_options: Some(vec![
@@ -166,8 +159,7 @@ fn build_omni_gen_image_models() -> Vec<OmniGenImageModelDetails> {
models.push(OmniGenImageModelDetails {
model: CommonImageModel::NanoBananaPro,
model_creator: Some(ModelCreator::ArtCraft),
full_name: Some("Nano Banana Pro".to_string()),
full_name: None,
text_prompt_supported: Some(true),
image_refs_supported: Some(true),
aspect_ratio_options: Some(vec![
@@ -199,7 +191,6 @@ fn build_omni_gen_image_models() -> Vec<OmniGenImageModelDetails> {
models.push(OmniGenImageModelDetails {
model: CommonImageModel::GptImage1,
model_creator: Some(ModelCreator::OpenAi),
full_name: Some("GPT Image 1".to_string()),
text_prompt_supported: Some(true),
image_refs_supported: Some(true),
@@ -223,7 +214,6 @@ fn build_omni_gen_image_models() -> Vec<OmniGenImageModelDetails> {
models.push(OmniGenImageModelDetails {
model: CommonImageModel::GptImage1p5,
model_creator: Some(ModelCreator::OpenAi),
full_name: Some("GPT Image 1.5".to_string()),
text_prompt_supported: Some(true),
image_refs_supported: Some(true),
@@ -247,7 +237,6 @@ fn build_omni_gen_image_models() -> Vec<OmniGenImageModelDetails> {
models.push(OmniGenImageModelDetails {
model: CommonImageModel::Seedream4,
model_creator: Some(ModelCreator::Bytedance),
full_name: Some("Seedream 4".to_string()),
text_prompt_supported: Some(true),
image_refs_supported: Some(true),
@@ -272,7 +261,6 @@ fn build_omni_gen_image_models() -> Vec<OmniGenImageModelDetails> {
models.push(OmniGenImageModelDetails {
model: CommonImageModel::Seedream4p5,
model_creator: Some(ModelCreator::Bytedance),
full_name: Some("Seedream 4.5".to_string()),
text_prompt_supported: Some(true),
image_refs_supported: Some(true),
@@ -296,7 +284,6 @@ fn build_omni_gen_image_models() -> Vec<OmniGenImageModelDetails> {
models.push(OmniGenImageModelDetails {
model: CommonImageModel::Seedream5Lite,
model_creator: Some(ModelCreator::Bytedance),
full_name: Some("Seedream 5 Lite".to_string()),
text_prompt_supported: Some(true),
image_refs_supported: Some(true),
@@ -2,7 +2,6 @@ use artcraft_api_defs::omni_gen::models::omni_gen_video_models::{OmniGenVideoMod
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation::common_video_model::CommonVideoModel;
use enums::common::generation::model_creator::ModelCreator;
use enums::common::generation_provider::GenerationProvider;
use once_cell::sync::Lazy;
@@ -21,8 +20,7 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
models.push(OmniGenVideoModelDetails {
model: CommonVideoModel::Seedance1p5Pro,
model_creator: Some(ModelCreator::Bytedance),
full_name: Some("Seedance 1.5 Pro".to_string()),
full_name: None,
text_prompt_supported: Some(true),
starting_keyframe_supported: Some(true),
ending_keyframe_supported: Some(true),
@@ -51,8 +49,7 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
models.push(OmniGenVideoModelDetails {
model: CommonVideoModel::Seedance2p0,
model_creator: Some(ModelCreator::Bytedance),
full_name: Some("Seedance 2.0".to_string()),
full_name: None,
text_prompt_supported: Some(true),
starting_keyframe_supported: Some(true),
ending_keyframe_supported: Some(true),
@@ -83,47 +80,10 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
});
models.push(OmniGenVideoModelDetails {
model: CommonVideoModel::Seedance2p0Fast,
model_creator: Some(ModelCreator::Bytedance),
full_name: Some("Seedance 2.0 Fast".to_string()),
text_prompt_supported: Some(true),
starting_keyframe_supported: Some(true),
ending_keyframe_supported: Some(true),
image_references_supported: Some(true),
image_references_max: Some(9),
audio_references_supported: Some(true),
audio_references_max: Some(3),
audio_references_max_total_duration_seconds: Some(15),
video_references_supported: Some(true),
video_references_max: Some(3),
video_references_max_total_duration_seconds: Some(15),
character_references_supported: Some(true),
character_references_max: Some(9),
aspect_ratio_options: Some(vec![
CommonAspectRatio::WideSixteenByNine,
CommonAspectRatio::WideFourByThree,
CommonAspectRatio::Square,
CommonAspectRatio::TallThreeByFour,
CommonAspectRatio::TallNineBySixteen,
]),
aspect_ratio_default: Some(CommonAspectRatio::WideSixteenByNine),
batch_size_options: Some(vec![1, 2, 4]),
batch_size_default: Some(1),
duration_seconds_min: Some(4),
duration_seconds_max: Some(15),
duration_seconds_default: Some(5),
..Default::default()
});
// TODO(bt,2026-04-10): Veo 2 image-to-video doesn't support aspect ratio
models.push(OmniGenVideoModelDetails {
is_disabled: Some(true), // TODO: Temporarily disable
model: CommonVideoModel::Veo2,
model_creator: Some(ModelCreator::Google),
full_name: Some("Veo 2".to_string()),
full_name: Some("Google Veo 2".to_string()),
text_prompt_supported: Some(true),
starting_keyframe_supported: Some(true),
// TODO(bt,2026-04-10): Veo 2 image-to-video doesn't support aspect ratio
aspect_ratio_options: Some(vec![
CommonAspectRatio::Auto,
CommonAspectRatio::WideSixteenByNine,
@@ -137,19 +97,16 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
});
models.push(OmniGenVideoModelDetails {
is_disabled: Some(true), // TODO: Temporarily disable
model: CommonVideoModel::Veo3,
model_creator: Some(ModelCreator::Google),
full_name: Some("Veo 3".to_string()),
full_name: Some("Google Veo 3".to_string()),
text_prompt_supported: Some(true),
starting_keyframe_supported: Some(true),
show_generate_with_sound_toggle: Some(true),
aspect_ratio_options: Some(vec![
CommonAspectRatio::WideSixteenByNine,
CommonAspectRatio::TallNineBySixteen,
CommonAspectRatio::Auto, // TODO: Only for image-to-video
CommonAspectRatio::Square,
]),
// TODO: image-to-video aspect ratio options
aspect_ratio_default: Some(CommonAspectRatio::WideSixteenByNine),
resolution_options: Some(vec![
CommonResolution::SevenTwentyP,
@@ -164,8 +121,7 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
models.push(OmniGenVideoModelDetails {
model: CommonVideoModel::Veo3Fast,
model_creator: Some(ModelCreator::Google),
full_name: Some("Veo 3 Fast".to_string()),
full_name: Some("Google Veo 3 Fast".to_string()),
text_prompt_supported: Some(true),
starting_keyframe_supported: Some(true),
show_generate_with_sound_toggle: Some(true),
@@ -181,10 +137,8 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
});
models.push(OmniGenVideoModelDetails {
is_disabled: Some(true), // TODO: Temporarily disable
model: CommonVideoModel::Veo3p1,
model_creator: Some(ModelCreator::Google),
full_name: Some("Veo 3.1".to_string()),
full_name: Some("Google Veo 3.1".to_string()),
text_prompt_supported: Some(true),
starting_keyframe_supported: Some(true),
ending_keyframe_supported: Some(true),
@@ -207,10 +161,8 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
});
models.push(OmniGenVideoModelDetails {
is_disabled: Some(true), // TODO: Temporarily disable
model: CommonVideoModel::Veo3p1Fast,
model_creator: Some(ModelCreator::Google),
full_name: Some("Veo 3.1 Fast".to_string()),
full_name: Some("Google Veo 3.1 Fast".to_string()),
text_prompt_supported: Some(true),
starting_keyframe_supported: Some(true),
ending_keyframe_supported: Some(true),
@@ -233,9 +185,7 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
});
models.push(OmniGenVideoModelDetails {
is_disabled: Some(true), // TODO: Temporarily disable
model: CommonVideoModel::Kling16Pro,
model_creator: Some(ModelCreator::Kling),
full_name: Some("Kling 1.6 Pro".to_string()),
starting_keyframe_supported: Some(true),
ending_keyframe_supported: Some(true),
@@ -251,9 +201,7 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
});
models.push(OmniGenVideoModelDetails {
is_disabled: Some(true), // TODO: Temporarily disable
model: CommonVideoModel::Kling21Pro,
model_creator: Some(ModelCreator::Kling),
full_name: Some("Kling 2.1 Pro".to_string()),
starting_keyframe_supported: Some(true),
ending_keyframe_supported: Some(true),
@@ -269,9 +217,7 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
});
models.push(OmniGenVideoModelDetails {
is_disabled: Some(true), // TODO: Temporarily disable
model: CommonVideoModel::Kling21Master,
model_creator: Some(ModelCreator::Kling),
full_name: Some("Kling 2.1 Master".to_string()),
starting_keyframe_supported: Some(true),
aspect_ratio_options: Some(vec![
@@ -286,9 +232,7 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
});
models.push(OmniGenVideoModelDetails {
is_disabled: Some(true), // TODO: Temporarily disable
model: CommonVideoModel::Kling2p5TurboPro,
model_creator: Some(ModelCreator::Kling),
full_name: Some("Kling 2.5 Turbo Pro".to_string()),
text_prompt_supported: Some(true),
starting_keyframe_supported: Some(true),
@@ -305,9 +249,7 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
});
models.push(OmniGenVideoModelDetails {
is_disabled: Some(true), // TODO: Temporarily disable
model: CommonVideoModel::Kling2p6Pro,
model_creator: Some(ModelCreator::Kling),
full_name: Some("Kling 2.6 Pro".to_string()),
text_prompt_supported: Some(true),
starting_keyframe_supported: Some(true),
@@ -324,9 +266,7 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
});
models.push(OmniGenVideoModelDetails {
is_disabled: Some(true), // TODO: Temporarily disable
model: CommonVideoModel::Kling3p0Pro,
model_creator: Some(ModelCreator::Kling),
full_name: Some("Kling 3.0 Pro".to_string()),
text_prompt_supported: Some(true),
starting_keyframe_supported: Some(true),
@@ -345,9 +285,7 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
});
models.push(OmniGenVideoModelDetails {
is_disabled: Some(true), // TODO: Temporarily disable
model: CommonVideoModel::Kling3p0Standard,
model_creator: Some(ModelCreator::Kling),
full_name: Some("Kling 3.0 Standard".to_string()),
text_prompt_supported: Some(true),
starting_keyframe_supported: Some(true),
@@ -366,9 +304,7 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
});
models.push(OmniGenVideoModelDetails {
is_disabled: Some(true), // TODO: Temporarily disable
model: CommonVideoModel::Seedance10Lite,
model_creator: Some(ModelCreator::Bytedance),
full_name: Some("Seedance 1.0 Lite".to_string()),
starting_keyframe_supported: Some(true),
ending_keyframe_supported: Some(true),
@@ -394,9 +330,7 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
});
models.push(OmniGenVideoModelDetails {
is_disabled: Some(true), // TODO: Temporarily disable
model: CommonVideoModel::Sora2,
model_creator: Some(ModelCreator::OpenAi),
full_name: Some("Sora 2".to_string()),
text_prompt_supported: Some(true),
starting_keyframe_supported: Some(true),
@@ -413,9 +347,7 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
});
models.push(OmniGenVideoModelDetails {
is_disabled: Some(true), // TODO: Temporarily disable
model: CommonVideoModel::Sora2Pro,
model_creator: Some(ModelCreator::OpenAi),
full_name: Some("Sora 2 Pro".to_string()),
text_prompt_supported: Some(true),
starting_keyframe_supported: Some(true),
@@ -36,25 +36,13 @@ use anyhow::anyhow;
pub enum AdvancedCommonWebError {
/// 400 Bad Request with a user-facing message.
BadInputWithSimpleMessage(String),
/// 401 Unauthorized.
NotAuthorized,
/// 404 Not Found.
NotFound,
/// 402 Payment Required.
PaymentRequired,
/// 403 Forbidden.
Forbidden,
/// 403 Forbidden — content was rejected by a content policy filter (e.g. NSFW).
ContentPolicyRejected,
/// 403 Forbidden — content was rejected, with a user-facing message.
ContentPolicyRejectedWithMessage(String),
/// Uncaught errors are always 500 Internal Server Error.
/// The user will never see the error cause or message, but our
/// middleware will handle alerting, logging, etc.
@@ -62,12 +50,6 @@ pub enum AdvancedCommonWebError {
/// Stored in `Arc` so the error alerting middleware can clone it for paging
/// without consuming the original.
UncaughtServerError(Arc<dyn std::error::Error + Send + Sync + 'static>),
/// Same as above, with a specified extra message string
UncaughtServerErrorWithInternalMessage {
internal_message: String,
error: Arc<dyn std::error::Error + Send + Sync + 'static>
}
}
// =============== Public accessors ===============
@@ -78,13 +60,6 @@ impl AdvancedCommonWebError {
Self::UncaughtServerError(Arc::new(error))
}
pub fn from_error_with_message(message: String, error: impl std::error::Error + Send + Sync + 'static) -> Self {
Self::UncaughtServerErrorWithInternalMessage {
internal_message: message,
error: Arc::new(error)
}
}
/// Wrap an `anyhow::Error` as an `UncaughtServerError`.
/// (`anyhow::Error` doesn't implement `std::error::Error`, so `from_error` can't accept it.)
pub fn from_anyhow_error(error: anyhow::Error) -> Self {
@@ -100,7 +75,6 @@ impl AdvancedCommonWebError {
pub fn cause(&self) -> Option<&(dyn std::error::Error + Send + Sync + 'static)> {
match self {
Self::UncaughtServerError(err) => Some(err.as_ref()),
Self::UncaughtServerErrorWithInternalMessage { error, .. } => Some(error.as_ref()),
_ => None,
}
}
@@ -110,18 +84,13 @@ impl AdvancedCommonWebError {
pub fn clone_cause_arc(&self) -> Option<Arc<dyn std::error::Error + Send + Sync + 'static>> {
match self {
Self::UncaughtServerError(err) => Some(Arc::clone(err)),
Self::UncaughtServerErrorWithInternalMessage { error, .. } => Some(Arc::clone(error)),
_ => None,
}
}
/// Whether this is a server error (500).
pub fn is_server_error(&self) -> bool {
match self {
Self::UncaughtServerError(_) => true,
Self::UncaughtServerErrorWithInternalMessage { .. } => true,
_ => false,
}
matches!(self, Self::UncaughtServerError(_))
}
}
@@ -134,13 +103,7 @@ impl Display for AdvancedCommonWebError {
Self::NotAuthorized => write!(f, "Not authorized"),
Self::NotFound => write!(f, "Not found"),
Self::PaymentRequired => write!(f, "Payment required"),
Self::Forbidden => write!(f, "Forbidden"),
Self::ContentPolicyRejected => write!(f, "Content policy rejected"),
Self::ContentPolicyRejectedWithMessage(msg) => write!(f, "Content policy rejected: {}", msg),
Self::UncaughtServerError(err) => write!(f, "Server error: {}", err),
Self::UncaughtServerErrorWithInternalMessage { internal_message, error } => {
write!(f, "Server error: {}: {}", internal_message, error)
}
}
}
}
@@ -152,13 +115,7 @@ impl std::fmt::Debug for AdvancedCommonWebError {
Self::NotAuthorized => write!(f, "NotAuthorized"),
Self::NotFound => write!(f, "NotFound"),
Self::PaymentRequired => write!(f, "PaymentRequired"),
Self::Forbidden => write!(f, "Forbidden"),
Self::ContentPolicyRejected => write!(f, "ContentPolicyRejected"),
Self::ContentPolicyRejectedWithMessage(msg) => write!(f, "ContentPolicyRejectedWithMessage({:?})", msg),
Self::UncaughtServerError(err) => write!(f, "UncaughtServerError({:?})", err),
Self::UncaughtServerErrorWithInternalMessage { internal_message, error } => {
write!(f, "UncaughtServerErrorWithInternalMessage({:?}, {:?})", internal_message, error)
}
}
}
}
@@ -167,7 +124,6 @@ impl std::error::Error for AdvancedCommonWebError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::UncaughtServerError(err) => Some(err.as_ref()),
Self::UncaughtServerErrorWithInternalMessage { error, .. } => Some(error.as_ref()),
_ => None,
}
}
@@ -182,11 +138,7 @@ impl ResponseError for AdvancedCommonWebError {
Self::NotAuthorized => StatusCode::UNAUTHORIZED,
Self::NotFound => StatusCode::NOT_FOUND,
Self::PaymentRequired => StatusCode::PAYMENT_REQUIRED,
Self::Forbidden => StatusCode::FORBIDDEN,
Self::ContentPolicyRejected => StatusCode::FORBIDDEN,
Self::ContentPolicyRejectedWithMessage(_) => StatusCode::FORBIDDEN,
Self::UncaughtServerError(_) => StatusCode::INTERNAL_SERVER_ERROR,
Self::UncaughtServerErrorWithInternalMessage { .. } => StatusCode::INTERNAL_SERVER_ERROR,
}
}
@@ -203,24 +155,6 @@ impl ResponseError for AdvancedCommonWebError {
message: msg,
})
}
Self::ContentPolicyRejected => {
HttpResponseBuilder::new(status)
.json(JsonErrorWithMessage {
success: false,
error_code: status.as_u16(),
error_code_str: status.canonical_reason(),
message: "Content rejected by policy",
})
}
Self::ContentPolicyRejectedWithMessage(msg) => {
HttpResponseBuilder::new(status)
.json(JsonErrorWithMessage {
success: false,
error_code: status.as_u16(),
error_code_str: status.canonical_reason(),
message: msg,
})
}
_ => {
HttpResponseBuilder::new(status)
.json(JsonErrorWithoutMessage {
@@ -498,35 +432,5 @@ mod tests {
assert!(std::error::Error::source(&AdvancedCommonWebError::NotFound).is_none());
assert!(std::error::Error::source(&AdvancedCommonWebError::NotAuthorized).is_none());
assert!(std::error::Error::source(&AdvancedCommonWebError::PaymentRequired).is_none());
assert!(std::error::Error::source(&AdvancedCommonWebError::Forbidden).is_none());
assert!(std::error::Error::source(&AdvancedCommonWebError::ContentPolicyRejected).is_none());
assert!(std::error::Error::source(&AdvancedCommonWebError::ContentPolicyRejectedWithMessage("test".to_string())).is_none());
}
#[test]
fn forbidden_returns_403() {
let error = AdvancedCommonWebError::Forbidden;
assert_eq!(error.status_code(), StatusCode::FORBIDDEN);
assert!(!error.is_server_error());
}
#[test]
fn content_policy_rejected_returns_403() {
let error = AdvancedCommonWebError::ContentPolicyRejected;
assert_eq!(error.status_code(), StatusCode::FORBIDDEN);
assert!(!error.is_server_error());
}
#[test]
fn content_policy_rejected_with_message_returns_403() {
let error = AdvancedCommonWebError::ContentPolicyRejectedWithMessage("NSFW detected".to_string());
assert_eq!(error.status_code(), StatusCode::FORBIDDEN);
assert!(!error.is_server_error());
let response = error.error_response();
let bytes = response.into_body().try_into_bytes().unwrap();
let body = String::from_utf8(bytes.to_vec()).unwrap();
assert!(body.contains("\"message\":\"NSFW detected\""));
assert!(body.contains("\"error_code\":403"));
}
}
@@ -114,10 +114,7 @@ pub async fn edit_character_handler(
.await
.map_err(|err| {
error!("Error updating character on Kinovi: {:?}", err);
AdvancedCommonWebError::from_error_with_message(
"Error Updating Kinovi Character API".to_string(),
err,
)
AdvancedCommonWebError::from_error(err)
})?;
info!("Updated character {} on Kinovi (name='{}')", kinovi_id, final_name);
@@ -140,7 +140,6 @@ fn map_video_model(model: CommonVideoModel) -> Result<RouterVideoModel, HandlerE
CommonVideoModel::Seedance10Lite => RouterVideoModel::Seedance10Lite,
CommonVideoModel::Seedance1p5Pro => RouterVideoModel::Seedance1p5Pro,
CommonVideoModel::Seedance2p0 => RouterVideoModel::Seedance2p0,
CommonVideoModel::Seedance2p0Fast => RouterVideoModel::Seedance2p0Fast,
CommonVideoModel::Sora2 => RouterVideoModel::Sora2,
CommonVideoModel::Sora2Pro => RouterVideoModel::Sora2Pro,
CommonVideoModel::Veo2 => RouterVideoModel::Veo2,
@@ -1,7 +1,7 @@
use std::sync::Arc;
use crate::billing::wallets::attempt_wallet_deduction::attempt_wallet_deduction_else_common_web_error;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoint_helpers::refund_wallet_after_api_failure::refund_wallet_after_api_failure;
use crate::http_server::endpoints::generate::common::payments_error_test::payments_error_test;
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
@@ -12,10 +12,10 @@ use actix_web::{web, HttpRequest};
use artcraft_api_defs::generate::splat::generate_worldlabs_marble_0p1_mini_splat::{GenerateWorldlabsMarble0p1MiniSplatRequest, GenerateWorldlabsMarble0p1MiniSplatResponse};
use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType;
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use enums::common::generation::common_model_type::CommonModelType;
use enums::common::generation_provider::GenerationProvider;
use enums::common::generation::common_model_type::CommonModelType;
use enums::common::visibility::Visibility;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use http_server_common::request::get_request_ip::get_request_ip;
use log::{error, info, warn};
use mysql_queries::queries::generic_inference::worldlabs::insert_generic_inference_job_for_worldlabs_queue_with_apriori_job_token::{insert_generic_inference_job_for_worldlabs_queue_with_apriori_job_token, InsertGenericInferenceForWorldlabsWithAprioriJobTokenArgs};
@@ -28,8 +28,6 @@ use worldlabs_api_client::api::api_types::world_labs_model::WorldLabsModel;
use worldlabs_api_client::api::requests::generate_world::generate_world::{generate_world, GenerateWorldArgs};
use worldlabs_api_client::api::requests::generate_world::http_request::{ContentReference, WorldPrompt};
use worldlabs_api_client::credentials::world_labs_api_creds::WorldLabsApiCreds;
use worldlabs_api_client::error::world_labs_error::WorldLabsError;
use worldlabs_api_client::error::world_labs_specific_api_error::WorldLabsSpecificApiError;
use worldlabs_api_client::pricing::check_pricing::{calculate_cost, InputType};
/// World Labs Marble 0.1-mini Splat Generation
@@ -48,18 +46,18 @@ pub async fn generate_worldlabs_marble_0p1_mini_splat_handler(
http_request: HttpRequest,
request: Json<GenerateWorldlabsMarble0p1MiniSplatRequest>,
server_state: web::Data<Arc<ServerState>>
) -> Result<Json<GenerateWorldlabsMarble0p1MiniSplatResponse>, AdvancedCommonWebError> {
) -> Result<Json<GenerateWorldlabsMarble0p1MiniSplatResponse>, CommonWebError> {
payments_error_test(&request.prompt.as_deref().unwrap_or(""))?;
if request.image_media_file_token.is_none() && request.prompt.is_none() {
return Err(AdvancedCommonWebError::BadInputWithSimpleMessage(
return Err(CommonWebError::BadInputWithSimpleMessage(
"At least one of image_media_file_token or prompt must be provided".to_string()
));
}
if let Err(reason) = validate_idempotency_token_format(&request.uuid_idempotency_token) {
return Err(AdvancedCommonWebError::BadInputWithSimpleMessage(reason));
return Err(CommonWebError::BadInputWithSimpleMessage(reason));
}
let mut mysql_connection = server_state.mysql_pool
@@ -72,7 +70,7 @@ pub async fn generate_worldlabs_marble_0p1_mini_splat_handler(
.await
.map_err(|e| {
warn!("Session checker error: {:?}", e);
AdvancedCommonWebError::from(e)
CommonWebError::ServerError
})?;
let maybe_avt_token = server_state
@@ -82,7 +80,7 @@ pub async fn generate_worldlabs_marble_0p1_mini_splat_handler(
let user_token = match maybe_user_session.as_ref() {
Some(session) => &session.user_token,
None => {
return Err(AdvancedCommonWebError::NotAuthorized);
return Err(CommonWebError::NotAuthorized);
}
};
@@ -90,7 +88,7 @@ pub async fn generate_worldlabs_marble_0p1_mini_splat_handler(
.await
.map_err(|err| {
error!("Error inserting idempotency token: {:?}", err);
AdvancedCommonWebError::BadInputWithSimpleMessage("repeated idempotency token".to_string())
CommonWebError::BadInputWithSimpleMessage("repeated idempotency token".to_string())
})?;
let apriori_job_token = InferenceJobToken::generate();
@@ -126,7 +124,7 @@ pub async fn generate_worldlabs_marble_0p1_mini_splat_handler(
let cdn_url = image_urls.get(image_token)
.ok_or_else(|| {
warn!("Image token not found in lookup results: {:?}", image_token);
AdvancedCommonWebError::server_error_with_message("Image token was not found in lookup results")
CommonWebError::ServerError
})?;
WorldPrompt::Image {
@@ -159,7 +157,7 @@ pub async fn generate_worldlabs_marble_0p1_mini_splat_handler(
Err(err) => {
warn!("World Labs generate_world error: {:?}", err);
refund_wallet_after_api_failure(&wallet_deduction.ledger_entry_token, &mut mysql_connection).await?;
return Err(classify_worldlabs_error(err));
return Err(CommonWebError::ServerError);
}
};
@@ -170,7 +168,7 @@ pub async fn generate_worldlabs_marble_0p1_mini_splat_handler(
.await
.map_err(|err| {
error!("Error starting MySQL transaction: {:?}", err);
AdvancedCommonWebError::from_error(err)
CommonWebError::ServerError
})?;
let prompt_result = insert_prompt(InsertPromptArgs {
@@ -239,7 +237,7 @@ pub async fn generate_worldlabs_marble_0p1_mini_splat_handler(
Ok(token) => token,
Err(err) => {
warn!("Error inserting generic inference job for WorldLabs queue: {:?}", err);
return Err(AdvancedCommonWebError::from_error(err));
return Err(CommonWebError::ServerError);
}
};
@@ -248,7 +246,7 @@ pub async fn generate_worldlabs_marble_0p1_mini_splat_handler(
.await
.map_err(|err| {
error!("Error committing MySQL transaction: {:?}", err);
AdvancedCommonWebError::from_error(err)
CommonWebError::ServerError
})?;
Ok(Json(GenerateWorldlabsMarble0p1MiniSplatResponse {
@@ -256,17 +254,3 @@ pub async fn generate_worldlabs_marble_0p1_mini_splat_handler(
inference_job_token: job_token,
}))
}
fn classify_worldlabs_error(err: WorldLabsError) -> AdvancedCommonWebError {
if let WorldLabsError::ApiSpecific(WorldLabsSpecificApiError::NsfwContentPolicyRejected { message }) = &err {
return AdvancedCommonWebError::ContentPolicyRejectedWithMessage(
message.clone().unwrap_or_else(|| "Content rejected by policy".to_string())
);
}
if err.is_403_forbidden() {
return AdvancedCommonWebError::Forbidden;
}
AdvancedCommonWebError::from_error(err)
}
@@ -1,7 +1,7 @@
use std::sync::Arc;
use crate::billing::wallets::attempt_wallet_deduction::attempt_wallet_deduction_else_common_web_error;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoint_helpers::refund_wallet_after_api_failure::refund_wallet_after_api_failure;
use crate::http_server::endpoints::generate::common::payments_error_test::payments_error_test;
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
@@ -12,10 +12,10 @@ use actix_web::{web, HttpRequest};
use artcraft_api_defs::generate::splat::generate_worldlabs_marble_0p1_plus_splat::{GenerateWorldlabsMarble0p1PlusSplatRequest, GenerateWorldlabsMarble0p1PlusSplatResponse};
use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType;
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use enums::common::generation::common_model_type::CommonModelType;
use enums::common::generation_provider::GenerationProvider;
use enums::common::generation::common_model_type::CommonModelType;
use enums::common::visibility::Visibility;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use http_server_common::request::get_request_ip::get_request_ip;
use log::{error, info, warn};
use mysql_queries::queries::generic_inference::worldlabs::insert_generic_inference_job_for_worldlabs_queue_with_apriori_job_token::{insert_generic_inference_job_for_worldlabs_queue_with_apriori_job_token, InsertGenericInferenceForWorldlabsWithAprioriJobTokenArgs};
@@ -28,8 +28,6 @@ use worldlabs_api_client::api::api_types::world_labs_model::WorldLabsModel;
use worldlabs_api_client::api::requests::generate_world::generate_world::{generate_world, GenerateWorldArgs};
use worldlabs_api_client::api::requests::generate_world::http_request::{ContentReference, WorldPrompt};
use worldlabs_api_client::credentials::world_labs_api_creds::WorldLabsApiCreds;
use worldlabs_api_client::error::world_labs_error::WorldLabsError;
use worldlabs_api_client::error::world_labs_specific_api_error::WorldLabsSpecificApiError;
use worldlabs_api_client::pricing::check_pricing::{calculate_cost, InputType};
/// World Labs Marble 0.1-plus Splat Generation
@@ -48,18 +46,18 @@ pub async fn generate_worldlabs_marble_0p1_plus_splat_handler(
http_request: HttpRequest,
request: Json<GenerateWorldlabsMarble0p1PlusSplatRequest>,
server_state: web::Data<Arc<ServerState>>
) -> Result<Json<GenerateWorldlabsMarble0p1PlusSplatResponse>, AdvancedCommonWebError> {
) -> Result<Json<GenerateWorldlabsMarble0p1PlusSplatResponse>, CommonWebError> {
payments_error_test(&request.prompt.as_deref().unwrap_or(""))?;
if request.image_media_file_token.is_none() && request.prompt.is_none() {
return Err(AdvancedCommonWebError::BadInputWithSimpleMessage(
return Err(CommonWebError::BadInputWithSimpleMessage(
"At least one of image_media_file_token or prompt must be provided".to_string()
));
}
if let Err(reason) = validate_idempotency_token_format(&request.uuid_idempotency_token) {
return Err(AdvancedCommonWebError::BadInputWithSimpleMessage(reason));
return Err(CommonWebError::BadInputWithSimpleMessage(reason));
}
let mut mysql_connection = server_state.mysql_pool
@@ -72,7 +70,7 @@ pub async fn generate_worldlabs_marble_0p1_plus_splat_handler(
.await
.map_err(|e| {
warn!("Session checker error: {:?}", e);
AdvancedCommonWebError::from(e)
CommonWebError::ServerError
})?;
let maybe_avt_token = server_state
@@ -82,7 +80,7 @@ pub async fn generate_worldlabs_marble_0p1_plus_splat_handler(
let user_token = match maybe_user_session.as_ref() {
Some(session) => &session.user_token,
None => {
return Err(AdvancedCommonWebError::NotAuthorized);
return Err(CommonWebError::NotAuthorized);
}
};
@@ -90,7 +88,7 @@ pub async fn generate_worldlabs_marble_0p1_plus_splat_handler(
.await
.map_err(|err| {
error!("Error inserting idempotency token: {:?}", err);
AdvancedCommonWebError::BadInputWithSimpleMessage("repeated idempotency token".to_string())
CommonWebError::BadInputWithSimpleMessage("repeated idempotency token".to_string())
})?;
let apriori_job_token = InferenceJobToken::generate();
@@ -126,7 +124,7 @@ pub async fn generate_worldlabs_marble_0p1_plus_splat_handler(
let cdn_url = image_urls.get(image_token)
.ok_or_else(|| {
warn!("Image token not found in lookup results: {:?}", image_token);
AdvancedCommonWebError::server_error_with_message("Image token no found in lookup result")
CommonWebError::ServerError
})?;
WorldPrompt::Image {
@@ -159,7 +157,7 @@ pub async fn generate_worldlabs_marble_0p1_plus_splat_handler(
Err(err) => {
warn!("World Labs generate_world error: {:?}", err);
refund_wallet_after_api_failure(&wallet_deduction.ledger_entry_token, &mut mysql_connection).await?;
return Err(classify_worldlabs_error(err));
return Err(CommonWebError::ServerError);
}
};
@@ -170,7 +168,7 @@ pub async fn generate_worldlabs_marble_0p1_plus_splat_handler(
.await
.map_err(|err| {
error!("Error starting MySQL transaction: {:?}", err);
AdvancedCommonWebError::from_error(err)
CommonWebError::ServerError
})?;
let prompt_result = insert_prompt(InsertPromptArgs {
@@ -239,7 +237,7 @@ pub async fn generate_worldlabs_marble_0p1_plus_splat_handler(
Ok(token) => token,
Err(err) => {
warn!("Error inserting generic inference job for WorldLabs queue: {:?}", err);
return Err(AdvancedCommonWebError::from_error(err));
return Err(CommonWebError::ServerError);
}
};
@@ -248,7 +246,7 @@ pub async fn generate_worldlabs_marble_0p1_plus_splat_handler(
.await
.map_err(|err| {
error!("Error committing MySQL transaction: {:?}", err);
AdvancedCommonWebError::from_error(err)
CommonWebError::ServerError
})?;
Ok(Json(GenerateWorldlabsMarble0p1PlusSplatResponse {
@@ -256,18 +254,3 @@ pub async fn generate_worldlabs_marble_0p1_plus_splat_handler(
inference_job_token: job_token,
}))
}
fn classify_worldlabs_error(err: WorldLabsError) -> AdvancedCommonWebError {
if let WorldLabsError::ApiSpecific(WorldLabsSpecificApiError::NsfwContentPolicyRejected { message }) = &err {
return AdvancedCommonWebError::ContentPolicyRejectedWithMessage(
message.clone().unwrap_or_else(|| "Content rejected by policy".to_string())
);
}
if err.is_403_forbidden() {
return AdvancedCommonWebError::Forbidden;
}
AdvancedCommonWebError::from_error(err)
}
@@ -148,9 +148,13 @@ pub async fn generate_veo_2_image_to_video_handler(
.map(|prompt| prompt.trim())
.unwrap_or_else(|| "");
if let Some(aspect_ratio) = &request.aspect_ratio {
warn!("Aspect ratio is not read in Veo 2 image-to-video");
}
let aspect_ratio = match &request.aspect_ratio {
Some(GenerateVeo2AspectRatio::Auto) => Veo2AspectRatio::Auto,
Some(GenerateVeo2AspectRatio::AutoPreferPortrait) => Veo2AspectRatio::AutoPreferPortrait,
Some(GenerateVeo2AspectRatio::WideSixteenNine) => Veo2AspectRatio::WideSixteenNine,
Some(GenerateVeo2AspectRatio::TallNineSixteen) => Veo2AspectRatio::TallNineSixteen,
None => Veo2AspectRatio::WideSixteenNine, // Default to 16:9
};
let duration = match &request.duration {
Some(GenerateVeo2Duration::FiveSeconds) => Veo2Duration::FiveSeconds,
@@ -165,6 +169,7 @@ pub async fn generate_veo_2_image_to_video_handler(
webhook_url: &server_state.fal.webhook_url,
duration,
prompt,
aspect_ratio,
api_key: &server_state.fal.api_key,
};
@@ -217,7 +222,12 @@ pub async fn generate_veo_2_image_to_video_handler(
maybe_negative_prompt: None,
maybe_other_args: None,
maybe_generation_mode: Some(CommonGenerationMode::Keyframe), // TODO: This endpoint only supports keyframes for now
maybe_aspect_ratio: None, // NB: The aspect ratio is not read for Veo 2 image-to-video
maybe_aspect_ratio: request.aspect_ratio.as_ref().map(|ar| match ar {
GenerateVeo2AspectRatio::Auto => CommonAspectRatio::Auto,
GenerateVeo2AspectRatio::AutoPreferPortrait => CommonAspectRatio::Auto,
GenerateVeo2AspectRatio::WideSixteenNine => CommonAspectRatio::WideSixteenByNine,
GenerateVeo2AspectRatio::TallNineSixteen => CommonAspectRatio::TallNineBySixteen,
}),
maybe_resolution: None,
maybe_batch_count: None,
maybe_generate_audio: None,
@@ -11,7 +11,7 @@ use crate::state::server_state::ServerState;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use anyhow::anyhow;
use artcraft_api_defs::generate::video::generate_veo_3_fast_image_to_video::{GenerateVeo3FastAspectRatio, GenerateVeo3FastDuration, GenerateVeo3FastImageToVideoRequest, GenerateVeo3FastImageToVideoResponse, GenerateVeo3FastResolution};
use artcraft_api_defs::generate::video::generate_veo_3_fast_image_to_video::{GenerateVeo3FastDuration, GenerateVeo3FastImageToVideoRequest, GenerateVeo3FastImageToVideoResponse, GenerateVeo3FastResolution};
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::generation_provider::GenerationProvider;
@@ -19,7 +19,7 @@ use enums::common::generation::common_model_type::CommonModelType;
use enums::common::visibility::Visibility;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
use fal_client::requests::webhook::video::image::enqueue_veo_3_fast_image_to_video_webhook::{enqueue_veo_3_fast_image_to_video_webhook, Veo3FastArgs, Veo3FastAspectRatio, Veo3FastDuration, Veo3FastResolution};
use fal_client::requests::webhook::video::image::enqueue_veo_3_fast_image_to_video_webhook::{enqueue_veo_3_fast_image_to_video_webhook, Veo3FastArgs, Veo3FastDuration, Veo3FastResolution};
use http_server_common::request::get_request_ip::get_request_ip;
use log::{error, info, warn};
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue::insert_generic_inference_job_for_fal_queue;
@@ -141,13 +141,6 @@ pub async fn generate_veo_3_fast_image_to_video_handler(
.map(|prompt| prompt.trim())
.unwrap_or_else(|| "");
let aspect_ratio = match &request.aspect_ratio {
Some(GenerateVeo3FastAspectRatio::Auto) => Veo3FastAspectRatio::Auto,
Some(GenerateVeo3FastAspectRatio::SixteenByNine) => Veo3FastAspectRatio::WideSixteenNine,
Some(GenerateVeo3FastAspectRatio::NineBySixteen) => Veo3FastAspectRatio::TallNineSixteen,
None => Veo3FastAspectRatio::Auto,
};
let resolution = match &request.resolution {
Some(GenerateVeo3FastResolution::SevenTwentyP) => Veo3FastResolution::SevenTwentyP,
Some(GenerateVeo3FastResolution::TenEightyP) => Veo3FastResolution::TenEightyP,
@@ -165,7 +158,6 @@ pub async fn generate_veo_3_fast_image_to_video_handler(
let args = Veo3FastArgs {
image_url: media_links.cdn_url,
prompt,
aspect_ratio,
duration,
resolution,
generate_audio,
@@ -222,11 +214,7 @@ pub async fn generate_veo_3_fast_image_to_video_handler(
maybe_negative_prompt: None,
maybe_other_args: None,
maybe_generation_mode: Some(CommonGenerationMode::Keyframe), // TODO: This endpoint only supports keyframes for now
maybe_aspect_ratio: request.aspect_ratio.as_ref().map(|ar| match ar {
GenerateVeo3FastAspectRatio::Auto => enums::common::generation::common_aspect_ratio::CommonAspectRatio::Auto,
GenerateVeo3FastAspectRatio::SixteenByNine => enums::common::generation::common_aspect_ratio::CommonAspectRatio::WideSixteenByNine,
GenerateVeo3FastAspectRatio::NineBySixteen => enums::common::generation::common_aspect_ratio::CommonAspectRatio::TallNineBySixteen,
}),
maybe_aspect_ratio: None,
maybe_resolution: None,
maybe_batch_count: None,
maybe_generate_audio: Some(generate_audio),
@@ -14,13 +14,13 @@ use anyhow::anyhow;
use artcraft_api_defs::generate::video::generate_veo_3_image_to_video::{GenerateVeo3AspectRatio, GenerateVeo3Duration, GenerateVeo3ImageToVideoRequest, GenerateVeo3ImageToVideoResponse, GenerateVeo3Resolution};
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::generation_provider::GenerationProvider;
use enums::common::generation::common_model_type::CommonModelType;
use enums::common::visibility::Visibility;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use enums::common::generation::common_model_type::CommonModelType;
use enums::common::generation_provider::GenerationProvider;
use enums::common::visibility::Visibility;
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
use fal_client::requests::webhook::video::image::enqueue_veo_3_image_to_video_webhook::{enqueue_veo_3_image_to_video_webhook, Veo3Args, Veo3I2vAspectRatio, Veo3I2vDuration, Veo3I2vResolution};
use fal_client::requests::webhook::video::image::enqueue_veo_3_image_to_video_webhook::{enqueue_veo_3_image_to_video_webhook, Veo3Args, Veo3AspectRatio, Veo3Duration, Veo3Resolution};
use http_server_common::request::get_request_ip::get_request_ip;
use log::{error, info, warn};
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue::insert_generic_inference_job_for_fal_queue;
@@ -143,22 +143,21 @@ pub async fn generate_veo_3_image_to_video_handler(
.unwrap_or_else(|| "");
let aspect_ratio = match &request.aspect_ratio {
Some(GenerateVeo3AspectRatio::WideSixteenNine) => Veo3I2vAspectRatio::WideSixteenNine,
Some(GenerateVeo3AspectRatio::TallNineSixteen) => Veo3I2vAspectRatio::TallNineSixteen,
// Square is not supported by Veo 3 i2v — fall back to Auto.
Some(GenerateVeo3AspectRatio::Square) => Veo3I2vAspectRatio::Auto,
None => Veo3I2vAspectRatio::WideSixteenNine,
Some(GenerateVeo3AspectRatio::WideSixteenNine) => Veo3AspectRatio::WideSixteenNine,
Some(GenerateVeo3AspectRatio::TallNineSixteen) => Veo3AspectRatio::TallNineSixteen,
Some(GenerateVeo3AspectRatio::Square) => Veo3AspectRatio::Square,
None => Veo3AspectRatio::WideSixteenNine,
};
let resolution = match &request.resolution {
Some(GenerateVeo3Resolution::SevenTwentyP) => Veo3I2vResolution::SevenTwentyP,
Some(GenerateVeo3Resolution::TenEightyP) => Veo3I2vResolution::TenEightyP,
None => Veo3I2vResolution::Default,
Some(GenerateVeo3Resolution::SevenTwentyP) => Veo3Resolution::SevenTwentyP,
Some(GenerateVeo3Resolution::TenEightyP) => Veo3Resolution::TenEightyP,
None => Veo3Resolution::Default,
};
let duration = match &request.duration {
Some(GenerateVeo3Duration::EightSeconds) => Veo3I2vDuration::EightSeconds,
None => Veo3I2vDuration::EightSeconds,
Some(GenerateVeo3Duration::EightSeconds) => Veo3Duration::EightSeconds,
None => Veo3Duration::EightSeconds,
};
let generate_audio = request.generate_audio
@@ -1,4 +1,3 @@
use std::marker::PhantomData;
use std::sync::Arc;
use actix_web::web::Json;
@@ -6,14 +5,11 @@ use actix_web::{web, HttpRequest};
use log::warn;
use artcraft_api_defs::moderation::alerts::moderation_send_alert::{ModerationSendAlertRequest, ModerationSendAlertResponse, ModerationSendAlertUrgency};
use enums::by_table::staff_audit_logs::staff_audit_action::StaffAuditAction;
use http_server_common::request::get_request_ip::get_request_ip;
use mysql_queries::queries::staff_audit_logs::insert_staff_audit_log::{
insert_staff_audit_log, InsertStaffAuditLogArgs,
};
use pager::client::pager::Pager;
use pager::notification::notification_details::NotificationDetails;
use pager::notification::notification_details_builder::NotificationDetailsBuilder;
use pager::notification::notification_urgency::NotificationUrgency;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::web_utils::user_session::require_moderator::{require_moderator, UseDatabase};
use crate::state::server_state::ServerState;
@@ -33,16 +29,11 @@ pub async fn moderation_send_alert_handler(
http_request: HttpRequest,
request: Json<ModerationSendAlertRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ModerationSendAlertResponse>, AdvancedCommonWebError> {
) -> Result<Json<ModerationSendAlertResponse>, CommonWebError> {
let user_session = require_moderator(&http_request, &server_state, UseDatabase::GrabNewConnection)
.await
.map_err(|err| {
warn!("Moderator check failed: {:?}", err);
AdvancedCommonWebError::NotAuthorized
})?;
let ip_address = get_request_ip(&http_request);
.map_err(|_| CommonWebError::NotAuthorized)?;
let title = request.title.clone()
.unwrap_or_else(|| "Moderation Alert".to_string());
@@ -77,23 +68,9 @@ pub async fn moderation_send_alert_handler(
.enqueue_page(notification)
.map_err(|err| {
warn!("moderation_send_alert error: {:?}", err);
AdvancedCommonWebError::from_error(err)
CommonWebError::ServerError
})?;
// Insert staff audit log.
let _audit_token = insert_staff_audit_log(InsertStaffAuditLogArgs {
audit_action: StaffAuditAction::SendAlert,
maybe_entity_type: None,
maybe_entity_token: None,
staff_user_token: &user_session.user_token,
actor_ip_address: &ip_address,
mysql_executor: &server_state.mysql_pool,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("Failed to insert staff audit log: {:?}", err);
AdvancedCommonWebError::from_error(err)
})?;
Ok(Json(ModerationSendAlertResponse {
success: true,
}))
@@ -1,26 +1,19 @@
use std::marker::PhantomData;
use std::sync::Arc;
use actix_web::web::{Json, Path};
use actix_web::{web, HttpRequest};
use log::{info, warn};
use log::warn;
use artcraft_api_defs::moderation::wallets::moderator_add_banked_balance_to_wallet::{
ModeratorAddBankedBalanceToWalletPathInfo,
ModeratorAddBankedBalanceToWalletRequest,
ModeratorAddBankedBalanceToWalletResponse,
};
use enums::by_table::staff_audit_logs::staff_audit_action::StaffAuditAction;
use enums::by_table::staff_audit_logs::staff_audit_entity_type::StaffAuditEntityType;
use enums::by_table::wallet_ledger_entries::wallet_ledger_entry_type::WalletLedgerEntryType;
use http_server_common::request::get_request_ip::get_request_ip;
use mysql_queries::queries::staff_audit_logs::insert_staff_audit_log::{
insert_staff_audit_log, InsertStaffAuditLogArgs,
};
use mysql_queries::queries::wallets::add_durable_banked_balance_to_wallet::add_durable_banked_balance_to_wallet;
use tokens::tokens::wallets::WalletToken;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::web_utils::user_session::require_moderator::{require_moderator, UseDatabase};
use crate::state::server_state::ServerState;
@@ -44,29 +37,17 @@ pub async fn moderator_add_banked_balance_to_wallet_handler(
path: Path<ModeratorAddBankedBalanceToWalletPathInfo>,
request: Json<ModeratorAddBankedBalanceToWalletRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ModeratorAddBankedBalanceToWalletResponse>, AdvancedCommonWebError> {
) -> Result<Json<ModeratorAddBankedBalanceToWalletResponse>, CommonWebError> {
let user_session = require_moderator(&http_request, &server_state, UseDatabase::GrabNewConnection)
let _user_session = require_moderator(&http_request, &server_state, UseDatabase::GrabNewConnection)
.await
.map_err(|err| {
warn!("Moderator check failed: {:?}", err);
AdvancedCommonWebError::NotAuthorized
})?;
let ip_address = get_request_ip(&http_request);
info!(
"Moderator {} adding {} banked credits to wallet {}",
user_session.user_token.as_str(),
request.credits,
path.wallet_token.as_str(),
);
.map_err(|_| CommonWebError::NotAuthorized)?;
let mut transaction = server_state.mysql_pool.begin()
.await
.map_err(|err| {
warn!("moderator_add_banked_balance_to_wallet transaction begin error: {:?}", err);
AdvancedCommonWebError::from_error(err)
CommonWebError::ServerError
})?;
let _result = add_durable_banked_balance_to_wallet(
@@ -79,28 +60,14 @@ pub async fn moderator_add_banked_balance_to_wallet_handler(
.await
.map_err(|err| {
warn!("moderator_add_banked_balance_to_wallet error: {:?}", err);
AdvancedCommonWebError::from_anyhow_error(err)
CommonWebError::ServerError
})?;
// Insert staff audit log.
let _audit_token = insert_staff_audit_log(InsertStaffAuditLogArgs {
audit_action: StaffAuditAction::AddWalletBankedBalance,
maybe_entity_type: Some(StaffAuditEntityType::Wallet),
maybe_entity_token: Some(path.wallet_token.as_str()),
staff_user_token: &user_session.user_token,
actor_ip_address: &ip_address,
mysql_executor: &mut *transaction,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("Failed to insert staff audit log: {:?}", err);
AdvancedCommonWebError::from_error(err)
})?;
transaction.commit()
.await
.map_err(|err| {
warn!("moderator_add_banked_balance_to_wallet commit error: {:?}", err);
AdvancedCommonWebError::from_error(err)
CommonWebError::ServerError
})?;
Ok(Json(ModeratorAddBankedBalanceToWalletResponse {
@@ -134,6 +134,9 @@ pub async fn omni_gen_image_generate_handler(
let distilled = distill_image_request(&request, media_file_hydration_map.as_ref())?;
info!(">>> Distilled cost: {:?}", distilled.cost);
info!(">>> Distilled plan: {:?}", distilled.plan());
// ==================== BILLING ==================== //
let cost = distilled.cost.cost_in_credits.unwrap_or(0);
@@ -167,6 +170,8 @@ pub async fn omni_gen_image_generate_handler(
AdvancedCommonWebError::from_error(e)
})?;
info!(">>> GENERATED!!");
let external_job_id = match &generation_response {
artcraft_router::generate::generate_image::generate_image_response::GenerateImageResponse::Artcraft(p) => {
p.inference_job_token.as_str().to_string()
@@ -54,11 +54,8 @@ pub struct DistilledVideoRequest {
/// Cost estimate as computed by the Artcraft provider — this is what we bill on.
pub cost: VideoGenerationCostEstimate,
/// Execution plan as computed by the execution provider — what we hand to the router.
/// Execution plan as computed by the Fal provider — what we hand to the router.
plan: VideoGenerationPlan<'static>,
/// The provider used for execution (Fal, Seedance2Pro, etc.).
pub execution_provider: Provider,
}
impl DistilledVideoRequest {
@@ -79,7 +76,6 @@ impl DistilledVideoRequest {
pub fn distill_video_request(
request: &OmniGenVideoCostAndGenerateRequest,
media_file_hydration_map: Option<&HashMap<MediaFileToken, Url>>,
execution_provider: Provider,
) -> Result<DistilledVideoRequest, AdvancedCommonWebError> {
// 1. Convert the raw API request into a router request.
let initial = hydrate_to_router_request(request)?;
@@ -113,8 +109,9 @@ pub fn distill_video_request(
media_file_hydration_map,
)?;
// 3. Build static lifetime references for text fields.
// SAFETY: each box lives in `Self` for the full lifetime of `DistilledVideoRequest`.
// 3. Build a `GenerateVideoRequest<'static>` whose borrows point into the
// owned boxes. SAFETY: same invariant as the image version — each box
// lives in `Self` for the full lifetime of `DistilledVideoRequest`.
let prompt_static: Option<&'static str> = owned_prompt.as_deref().map(|s: &String| {
let raw: *const str = s.as_str();
unsafe { &*raw }
@@ -130,23 +127,6 @@ pub fn distill_video_request(
unsafe { &*raw }
});
// 4. Cost estimate (Artcraft provider).
// `initial` already has media fields in token form (from hydrate_to_router_request),
// which is exactly what the Artcraft cost builder needs. Just swap the provider.
let cost: VideoGenerationCostEstimate = {
let cost_request = GenerateVideoRequest {
provider: Provider::Artcraft,
..initial
};
let cost_plan = cost_request.build().map_err(|e| {
warn!("Failed to build cost plan during video distillation: {}", e);
AdvancedCommonWebError::from_error(e)
})?;
cost_plan.estimate_costs()
};
// 5. Execution plan (execution provider).
// The execution plan uses URL-form media references (resolved from the hydration map).
let start_frame_static: Option<ImageRef<'static>> =
owned_start_frame_url.as_deref().map(|s: &String| {
let raw: *const str = s.as_str();
@@ -175,7 +155,7 @@ pub fn distill_video_request(
let request_static: GenerateVideoRequest<'static> = GenerateVideoRequest {
model: initial.model,
provider: execution_provider,
provider: Provider::Fal,
prompt: prompt_static,
negative_prompt: negative_prompt_static,
start_frame: start_frame_static,
@@ -193,6 +173,20 @@ pub fn distill_video_request(
idempotency_token: idempotency_static,
};
// 4. Cost estimate (Artcraft provider).
let cost_request = GenerateVideoRequest {
provider: Provider::Artcraft,
..request_static
};
let cost_plan = cost_request.build().map_err(|e| {
warn!("Failed to build cost plan during video distillation: {}", e);
AdvancedCommonWebError::from_error(e)
})?;
let cost: VideoGenerationCostEstimate = cost_plan.estimate_costs();
drop(cost_plan);
drop(cost_request);
// 5. Execution plan (Fal provider).
let plan: VideoGenerationPlan<'static> = {
let request_ref: &'static GenerateVideoRequest<'static> = unsafe {
let raw: *const GenerateVideoRequest<'static> = &request_static;
@@ -216,7 +210,6 @@ pub fn distill_video_request(
request: request_static,
cost,
plan,
execution_provider,
})
}
@@ -1,311 +0,0 @@
//! Execution helpers for the omni-gen video generation handler.
//!
//! Routes generation to the appropriate provider (Fal or Seedance2Pro/Kinovi)
//! based on the distilled request's `execution_provider`.
use std::collections::HashMap;
use log::{info, warn};
use url::Url;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use artcraft_router::api::provider::Provider;
use artcraft_router::generate::generate_video::generate_video_response::GenerateVideoResponse;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use enums::common::generation::common_video_model::CommonVideoModel;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro_client::requests::generate_video::generate_video::{
generate_video, BatchCount, GenerateVideoArgs, ModelType, Resolution,
};
use seedance2pro_client::requests::prepare_file_upload::prepare_file_upload::{
prepare_file_upload, PrepareFileUploadArgs,
};
use seedance2pro_client::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
use tokens::tokens::media_files::MediaFileToken;
use url_utils::extension::extract_extension_from_url::{
extract_extension_from_url, ExtractExtensions,
};
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::state::server_state::ServerState;
use crate::util::http_download_url_to_bytes::http_download_url_to_bytes;
use super::distill_video_request::DistilledVideoRequest;
/// Result of a successful generation, regardless of provider.
pub struct GenerationResult {
/// The external job ID used to track the generation (first order_id for Seedance).
pub external_job_id: String,
/// Whether this is a Seedance2Pro generation (changes DB insertion path).
pub is_seedance2pro: bool,
/// For Seedance2Pro batch jobs, the list of all order IDs.
pub maybe_seedance_order_ids: Option<Vec<String>>,
/// The generation mode (Text, Keyframe, Reference).
pub generation_mode: CommonGenerationMode,
}
/// Execute video generation via the appropriate provider.
pub async fn execute_generation(
distilled: &DistilledVideoRequest,
request: &OmniGenVideoCostAndGenerateRequest,
server_state: &ServerState,
media_file_hydration_map: Option<&HashMap<MediaFileToken, Url>>,
) -> Result<GenerationResult, AdvancedCommonWebError> {
match distilled.execution_provider {
Provider::Seedance2Pro => {
execute_generation_kinovi(
distilled,
request,
server_state,
media_file_hydration_map,
).await
}
_ => {
execute_generation_fal(
distilled,
request,
server_state,
).await
}
}
}
/// Execute generation via Fal (the existing path).
async fn execute_generation_fal(
distilled: &DistilledVideoRequest,
request: &OmniGenVideoCostAndGenerateRequest,
server_state: &ServerState,
) -> Result<GenerationResult, AdvancedCommonWebError> {
let fal_client = artcraft_router::client::router_fal_client::RouterFalClient::new(
server_state.fal.api_key.clone(),
server_state.fal.webhook_url.clone(),
);
let router_client = artcraft_router::client::router_client::RouterClient::Fal(fal_client);
let generation_response = distilled.plan().generate_video(&router_client)
.await
.map_err(|e| {
warn!("Video generation failed (Fal): {:?}", e);
AdvancedCommonWebError::from_error(e)
})?;
let external_job_id = match &generation_response {
GenerateVideoResponse::Artcraft(p) => p.inference_job_token.as_str().to_string(),
GenerateVideoResponse::Muapi(p) => p.request_id.as_str().to_string(),
GenerateVideoResponse::Seedance2Pro(p) => p.order_id.clone(),
GenerateVideoResponse::Fal(p) => p.request_id.clone().unwrap_or_default(),
};
let generation_mode = if request.start_frame_image_media_token.is_some() {
CommonGenerationMode::Keyframe
} else {
CommonGenerationMode::Text
};
Ok(GenerationResult {
external_job_id,
is_seedance2pro: false,
maybe_seedance_order_ids: None,
generation_mode,
})
}
/// Execute generation via Seedance2Pro/Kinovi.
///
/// Downloads all referenced media from our CDN, re-uploads to Seedance2Pro's
/// CDN, then calls the Seedance2Pro generate endpoint.
async fn execute_generation_kinovi(
distilled: &DistilledVideoRequest,
request: &OmniGenVideoCostAndGenerateRequest,
server_state: &ServerState,
media_file_hydration_map: Option<&HashMap<MediaFileToken, Url>>,
) -> Result<GenerationResult, AdvancedCommonWebError> {
let session = Seedance2ProSession::from_cookies_string(
server_state.seedance2pro.cookies.clone()
);
let empty_map = HashMap::new();
let hydration_map = media_file_hydration_map.unwrap_or(&empty_map);
// Upload each media reference to Seedance2Pro CDN.
let start_frame_url = upload_token_to_seedance2pro(
&session, hydration_map, request.start_frame_image_media_token.as_ref(),
).await?;
let end_frame_url = upload_token_to_seedance2pro(
&session, hydration_map, request.end_frame_image_media_token.as_ref(),
).await?;
let reference_image_urls = upload_tokens_to_seedance2pro(
&session, hydration_map, request.reference_image_media_tokens.as_deref(),
).await?;
let reference_video_urls = upload_tokens_to_seedance2pro(
&session, hydration_map, request.reference_video_media_tokens.as_deref(),
).await?;
let reference_audio_urls = upload_tokens_to_seedance2pro(
&session, hydration_map, request.reference_audio_media_tokens.as_deref(),
).await?;
// Determine generation mode.
let is_keyframe = request.start_frame_image_media_token.is_some()
|| request.end_frame_image_media_token.is_some();
let is_reference = request.reference_image_media_tokens.as_ref().map_or(false, |t| !t.is_empty())
|| request.reference_video_media_tokens.as_ref().map_or(false, |t| !t.is_empty())
|| request.reference_audio_media_tokens.as_ref().map_or(false, |t| !t.is_empty());
let generation_mode = if is_keyframe {
CommonGenerationMode::Keyframe
} else if is_reference {
CommonGenerationMode::Reference
} else {
CommonGenerationMode::Text
};
// Map aspect ratio / duration / batch from the request.
let resolution = Resolution::Landscape16x9; // TODO: Map from request.aspect_ratio
let duration_seconds = request.duration_seconds.unwrap_or(5).clamp(4, 15) as u8;
let batch_count = match request.video_batch_count {
Some(2) => BatchCount::Two,
Some(4) => BatchCount::Four,
_ => BatchCount::One,
};
let prompt = request.prompt.clone().unwrap_or_default();
let model_type = match request.model {
Some(CommonVideoModel::Seedance2p0Fast) => ModelType::Seedance2Fast,
_ => ModelType::Seedance2Pro,
};
let video_gen_args = GenerateVideoArgs {
session: &session,
model_type,
prompt,
resolution,
duration_seconds,
batch_count,
start_frame_url,
end_frame_url,
reference_image_urls,
reference_video_urls,
reference_audio_urls,
character_ids: None, // TODO: resolve_kinovi_character_ids when character support is added
use_face_blur_hack: None,
host_override: None,
};
let gen_response = generate_video(video_gen_args).await
.map_err(|err| {
warn!("Seedance2Pro generate_video failed: {:?}", err);
AdvancedCommonWebError::from_error(err)
})?;
info!(
"Seedance2Pro generation: task_id={}, order_id={}",
gen_response.task_id, gen_response.order_id
);
let order_ids: Vec<String> = match gen_response.order_ids {
Some(ids) if !ids.is_empty() => ids,
_ => vec![gen_response.order_id.clone()],
};
Ok(GenerationResult {
external_job_id: gen_response.order_id,
is_seedance2pro: true,
maybe_seedance_order_ids: Some(order_ids),
generation_mode,
})
}
// --- Upload helpers ---
/// Upload a single media token (if present) to Seedance2Pro CDN.
async fn upload_token_to_seedance2pro(
session: &Seedance2ProSession,
hydration_map: &HashMap<MediaFileToken, Url>,
maybe_token: Option<&MediaFileToken>,
) -> Result<Option<String>, AdvancedCommonWebError> {
let token = match maybe_token {
None => return Ok(None),
Some(t) => t,
};
let url = hydration_map.get(token).ok_or_else(|| {
AdvancedCommonWebError::BadInputWithSimpleMessage(
format!("Media token not found in hydration map: {:?}", token))
})?;
let uploaded = upload_url_to_seedance2pro(session, url).await?;
Ok(Some(uploaded))
}
/// Upload a list of media tokens (if present) to Seedance2Pro CDN.
async fn upload_tokens_to_seedance2pro(
session: &Seedance2ProSession,
hydration_map: &HashMap<MediaFileToken, Url>,
maybe_tokens: Option<&[MediaFileToken]>,
) -> Result<Option<Vec<String>>, AdvancedCommonWebError> {
let tokens = match maybe_tokens {
None => return Ok(None),
Some(tokens) if tokens.is_empty() => return Ok(None),
Some(tokens) => tokens,
};
let mut urls = Vec::with_capacity(tokens.len());
for token in tokens {
let url = hydration_map.get(token).ok_or_else(|| {
AdvancedCommonWebError::BadInputWithSimpleMessage(
format!("Media token not found in hydration map: {:?}", token))
})?;
urls.push(upload_url_to_seedance2pro(session, url).await?);
}
Ok(Some(urls))
}
/// Download from our CDN and re-upload to Seedance2Pro.
async fn upload_url_to_seedance2pro(
session: &Seedance2ProSession,
our_cdn_url: &Url,
) -> Result<String, AdvancedCommonWebError> {
let extension = extract_extension_from_url(our_cdn_url, &ExtractExtensions::All)
.map(|ext| ext.without_period().to_string())
.unwrap_or_else(|| "png".to_string());
let file_bytes = http_download_url_to_bytes(our_cdn_url.as_str())
.await
.map_err(|err| {
warn!("Error downloading media from CDN for Seedance2Pro upload: {:?}", err);
AdvancedCommonWebError::from_error(err)
})?
.to_vec();
let prepare_result = prepare_file_upload(PrepareFileUploadArgs {
session,
extension,
host_override: None,
})
.await
.map_err(|err| {
warn!("Error preparing seedance2pro file upload: {:?}", err);
AdvancedCommonWebError::from_error(err)
})?;
let upload_result = upload_file(UploadFileArgs {
upload_url: prepare_result.upload_url,
file_bytes,
host_override: None,
})
.await
.map_err(|err| {
warn!("Error uploading file to seedance2pro: {:?}", err);
AdvancedCommonWebError::from_error(err)
})?;
Ok(upload_result.public_url)
}
@@ -1,7 +1,6 @@
pub mod omni_gen_video_generate_handler;
pub mod distill_video_request;
pub(crate) mod distill_helper;
mod execute_generation;
#[cfg(test)]
mod tests;
@@ -9,10 +9,9 @@ use url::Url;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use artcraft_api_defs::omni_gen::generate_response::omni_gen_video_generate_response::OmniGenVideoGenerateResponse;
use artcraft_router::api::provider::Provider;
use enums::common::generation::common_video_model::CommonVideoModel;
use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType;
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use enums::common::generation::common_model_type::CommonModelType;
use enums::common::generation_provider::GenerationProvider;
use enums::common::visibility::Visibility;
@@ -22,10 +21,6 @@ use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job
insert_generic_inference_job_for_fal_queue_with_apriori_job_token,
InsertGenericInferenceForFalWithAprioriJobTokenArgs,
};
use mysql_queries::queries::generic_inference::seedance2pro::insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token::{
insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token,
InsertGenericInferenceForSeedance2ProWithAprioriJobTokenArgs,
};
use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token;
use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_items::{
insert_batch_prompt_context_items, InsertBatchArgs, PromptContextItem,
@@ -42,7 +37,6 @@ use crate::state::server_state::ServerState;
use crate::util::lookup::lookup_image_urls_as_map::lookup_image_urls_as_map;
use super::distill_video_request::distill_video_request;
use super::execute_generation::execute_generation;
/// Generate a video using the omni-gen unified endpoint.
#[utoipa::path(
@@ -155,17 +149,12 @@ pub async fn omni_gen_video_generate_handler(
}
};
// ==================== DETERMINE PROVIDER ==================== //
let execution_provider = match request.model {
Some(CommonVideoModel::Seedance2p0) => Provider::Seedance2Pro,
Some(CommonVideoModel::Seedance2p0Fast) => Provider::Seedance2Pro,
_ => Provider::Fal,
};
// ==================== DISTILL ==================== //
let distilled = distill_video_request(&request, media_file_hydration_map.as_ref(), execution_provider)?;
let distilled = distill_video_request(&request, media_file_hydration_map.as_ref())?;
info!(">>> Distilled cost: {:?}", distilled.cost);
info!(">>> Distilled plan: {:?}", distilled.plan());
// ==================== BILLING ==================== //
@@ -186,12 +175,33 @@ pub async fn omni_gen_video_generate_handler(
// ==================== EXECUTE GENERATION ==================== //
let gen_result = execute_generation(
&distilled,
&request,
&server_state,
media_file_hydration_map.as_ref(),
).await?;
let fal_client = artcraft_router::client::router_fal_client::RouterFalClient::new(
server_state.fal.api_key.clone(),
server_state.fal.webhook_url.clone(),
);
let router_client = artcraft_router::client::router_client::RouterClient::Fal(fal_client);
let generation_response = distilled.plan().generate_video(&router_client)
.await
.map_err(|e| {
warn!("Video generation failed: {:?}", e);
AdvancedCommonWebError::from_error(e)
})?;
let external_job_id = match &generation_response {
artcraft_router::generate::generate_video::generate_video_response::GenerateVideoResponse::Artcraft(p) => {
p.inference_job_token.as_str().to_string()
}
artcraft_router::generate::generate_video::generate_video_response::GenerateVideoResponse::Muapi(p) => {
p.request_id.as_str().to_string()
}
artcraft_router::generate::generate_video::generate_video_response::GenerateVideoResponse::Seedance2Pro(p) => {
p.order_id.clone()
}
artcraft_router::generate::generate_video::generate_video_response::GenerateVideoResponse::Fal(p) => {
p.request_id.clone().unwrap_or_default()
}
};
// ==================== DB TRANSACTION ==================== //
@@ -207,6 +217,12 @@ pub async fn omni_gen_video_generate_handler(
// -- Prompt --
let generation_mode = if request.start_frame_image_media_token.is_some() {
CommonGenerationMode::Keyframe
} else {
CommonGenerationMode::Text
};
let prompt_result = insert_prompt(InsertPromptArgs {
maybe_apriori_prompt_token: None,
prompt_type: PromptType::ArtcraftApp,
@@ -216,7 +232,7 @@ pub async fn omni_gen_video_generate_handler(
maybe_positive_prompt: request.prompt.as_deref(),
maybe_negative_prompt: request.negative_prompt.as_deref(),
maybe_other_args: None,
maybe_generation_mode: Some(gen_result.generation_mode),
maybe_generation_mode: Some(generation_mode),
maybe_aspect_ratio: None,
maybe_resolution: None,
maybe_batch_count: request.video_batch_count.map(|c| c as u8),
@@ -285,88 +301,31 @@ pub async fn omni_gen_video_generate_handler(
// -- Inference job --
let job_token = if gen_result.is_seedance2pro {
// Seedance2Pro path: insert one job per order_id (batch support).
let fallback_ids = vec![gen_result.external_job_id.clone()];
let order_ids = gen_result.maybe_seedance_order_ids
.as_deref()
.unwrap_or(&fallback_ids);
let mut all_job_tokens: Vec<InferenceJobToken> = Vec::with_capacity(order_ids.len());
for (i, order_id) in order_ids.iter().enumerate() {
let job_token = if i == 0 {
apriori_job_token.clone()
} else {
InferenceJobToken::generate()
};
let idempotency_str = if i == 0 {
idempotency_token.clone()
} else {
format!("{}-batch-{}", idempotency_token, i)
};
let db_result = insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token(
InsertGenericInferenceForSeedance2ProWithAprioriJobTokenArgs {
apriori_job_token: &job_token,
uuid_idempotency_token: &idempotency_str,
maybe_external_third_party_id: order_id,
maybe_inference_args: None,
maybe_prompt_token: prompt_token.as_ref(),
maybe_wallet_ledger_entry_token: None,
maybe_creator_user_token: Some(user_token),
maybe_avt_token: maybe_avt_token.as_ref(),
creator_ip_address: &ip_address,
creator_set_visibility: Visibility::Public,
mysql_executor: &mut *transaction,
phantom: Default::default(),
}
).await;
match db_result {
Ok(token) => all_job_tokens.push(token),
Err(err) => {
warn!("Error inserting seedance2pro inference job (order_id={}): {:?}", order_id, err);
if i == 0 {
return Err(AdvancedCommonWebError::from_error(err));
}
}
}
let db_result = insert_generic_inference_job_for_fal_queue_with_apriori_job_token(
InsertGenericInferenceForFalWithAprioriJobTokenArgs {
apriori_job_token: &apriori_job_token,
uuid_idempotency_token: &idempotency_token,
maybe_external_third_party_id: &external_job_id,
fal_category: FalCategory::VideoGeneration,
maybe_inference_args: None,
maybe_prompt_token: prompt_token.as_ref(),
maybe_creator_user_token: Some(user_token),
maybe_avt_token: maybe_avt_token.as_ref(),
creator_ip_address: &ip_address,
creator_set_visibility: Visibility::Public,
mysql_executor: &mut *transaction,
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
phantom: Default::default(),
}
).await;
all_job_tokens.first().cloned().ok_or_else(|| {
error!("No inference job token was created");
AdvancedCommonWebError::server_error_with_message("No inference job token was created")
})?
} else {
// Fal / other providers path.
let db_result = insert_generic_inference_job_for_fal_queue_with_apriori_job_token(
InsertGenericInferenceForFalWithAprioriJobTokenArgs {
apriori_job_token: &apriori_job_token,
uuid_idempotency_token: &idempotency_token,
maybe_external_third_party_id: &gen_result.external_job_id,
fal_category: FalCategory::VideoGeneration,
maybe_inference_args: None,
maybe_prompt_token: prompt_token.as_ref(),
maybe_creator_user_token: Some(user_token),
maybe_avt_token: maybe_avt_token.as_ref(),
creator_ip_address: &ip_address,
creator_set_visibility: Visibility::Public,
mysql_executor: &mut *transaction,
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
phantom: Default::default(),
}
).await;
match db_result {
Ok(token) => token,
Err(err) => {
warn!("Error inserting inference job: {:?}", err);
return Err(AdvancedCommonWebError::from_error(err));
}
let job_token = match db_result {
Ok(token) => token,
Err(err) => {
warn!("Error inserting inference job: {:?}", err);
return Err(AdvancedCommonWebError::from_error(err));
}
};
@@ -1 +0,0 @@
mod test_kinovi_seedance_2p0;
@@ -1,459 +0,0 @@
//! Tests for omni-gen video against the Seedance 2.0 model (Kinovi/Seedance2Pro provider).
//!
//! Seedance 2.0 is special: it uses the Seedance2Pro/Kinovi provider for execution
//! instead of Fal. The cost estimation uses Artcraft provider with media-token-form
//! references, while the execution plan uses URL-form references.
//!
//! Pricing: 16¢ per second × batch count.
//! - 4s/batch 1 = 64¢, 5s/batch 1 = 80¢, 15s/batch 1 = 240¢
//! - Batch 2 = 2×, Batch 4 = 4×
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use artcraft_router::api::provider::Provider;
use artcraft_router::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_video_model::CommonVideoModel;
use tokens::tokens::media_files::MediaFileToken;
use url::Url;
use crate::http_server::endpoints::omni_gen::generate::video::distill_video_request::{
distill_video_request, DistilledVideoRequest,
};
// ── Helpers ────────────────────────────────────────────────────────────────
fn make_request(
prompt: Option<&str>,
aspect_ratio: Option<CommonAspectRatio>,
duration_seconds: Option<u16>,
video_batch_count: Option<u16>,
start_frame_token: Option<MediaFileToken>,
end_frame_token: Option<MediaFileToken>,
reference_image_tokens: Option<Vec<MediaFileToken>>,
reference_video_tokens: Option<Vec<MediaFileToken>>,
reference_audio_tokens: Option<Vec<MediaFileToken>>,
) -> OmniGenVideoCostAndGenerateRequest {
OmniGenVideoCostAndGenerateRequest {
idempotency_token: Some("11111111-1111-1111-1111-111111111111".to_string()),
model: Some(CommonVideoModel::Seedance2p0),
prompt: prompt.map(|s| s.to_string()),
negative_prompt: None,
start_frame_image_media_token: start_frame_token,
end_frame_image_media_token: end_frame_token,
reference_image_media_tokens: reference_image_tokens,
reference_video_media_tokens: reference_video_tokens,
reference_audio_media_tokens: reference_audio_tokens,
reference_character_tokens: None,
resolution: None,
aspect_ratio,
quality: None,
duration_seconds,
video_batch_count,
generate_audio: None,
}
}
fn text_only_request(
duration_seconds: Option<u16>,
video_batch_count: Option<u16>,
) -> OmniGenVideoCostAndGenerateRequest {
make_request(
Some("a cat dancing"),
None,
duration_seconds,
video_batch_count,
None, None, None, None, None,
)
}
fn fake_token(name: &str) -> MediaFileToken {
MediaFileToken::new_from_str(name)
}
fn build_hydration_map(tokens: &[&MediaFileToken]) -> HashMap<MediaFileToken, Url> {
tokens.iter().enumerate().map(|(i, t)| {
let url = Url::parse(&format!("https://cdn.example.com/media/{}.png", i)).unwrap();
((*t).clone(), url)
}).collect()
}
fn distill_text(request: &OmniGenVideoCostAndGenerateRequest) -> DistilledVideoRequest {
let empty: HashMap<MediaFileToken, Url> = HashMap::new();
distill_video_request(request, Some(&empty), Provider::Seedance2Pro)
.expect("distill should succeed for Seedance 2.0 (text)")
}
fn distill_with_map(
request: &OmniGenVideoCostAndGenerateRequest,
hydration: &HashMap<MediaFileToken, Url>,
) -> DistilledVideoRequest {
distill_video_request(request, Some(hydration), Provider::Seedance2Pro)
.expect("distill should succeed for Seedance 2.0 (with media)")
}
// ═══════════════════════════════════════════════════════════════════════════
// COST TESTS
// ═══════════════════════════════════════════════════════════════════════════
mod cost_tests {
use super::*;
#[test]
fn cost_is_present() {
let d = distill_text(&text_only_request(Some(5), None));
assert!(d.cost.cost_in_credits.is_some());
assert!(d.cost.cost_in_usd_cents.is_some());
}
#[test]
fn cost_is_not_free() {
let d = distill_text(&text_only_request(Some(5), None));
assert!(!d.cost.is_free);
}
// Pricing: 16¢/sec × batch_count
#[test]
fn cost_4s_batch_1() {
let d = distill_text(&text_only_request(Some(4), Some(1)));
assert_eq!(d.cost.cost_in_usd_cents, Some(64));
}
#[test]
fn cost_5s_batch_1() {
let d = distill_text(&text_only_request(Some(5), Some(1)));
assert_eq!(d.cost.cost_in_usd_cents, Some(80));
}
#[test]
fn cost_6s_batch_1() {
let d = distill_text(&text_only_request(Some(6), Some(1)));
assert_eq!(d.cost.cost_in_usd_cents, Some(96));
}
#[test]
fn cost_10s_batch_1() {
let d = distill_text(&text_only_request(Some(10), Some(1)));
assert_eq!(d.cost.cost_in_usd_cents, Some(160));
}
#[test]
fn cost_15s_batch_1() {
let d = distill_text(&text_only_request(Some(15), Some(1)));
assert_eq!(d.cost.cost_in_usd_cents, Some(240));
}
#[test]
fn cost_5s_batch_2() {
let d = distill_text(&text_only_request(Some(5), Some(2)));
assert_eq!(d.cost.cost_in_usd_cents, Some(160));
}
#[test]
fn cost_5s_batch_4() {
let d = distill_text(&text_only_request(Some(5), Some(4)));
assert_eq!(d.cost.cost_in_usd_cents, Some(320));
}
#[test]
fn cost_15s_batch_4() {
let d = distill_text(&text_only_request(Some(15), Some(4)));
assert_eq!(d.cost.cost_in_usd_cents, Some(960));
}
#[test]
fn cost_same_with_or_without_start_frame() {
// Cost should not change based on media presence — it's duration × batch.
let text_d = distill_text(&text_only_request(Some(5), Some(1)));
let start_token = fake_token("start_frame");
let map = build_hydration_map(&[&start_token]);
let req = make_request(
Some("a cat"), None, Some(5), Some(1),
Some(start_token), None, None, None, None,
);
let frame_d = distill_with_map(&req, &map);
assert_eq!(text_d.cost.cost_in_usd_cents, frame_d.cost.cost_in_usd_cents);
}
#[test]
fn cost_estimation_uses_media_tokens_not_urls() {
// The Artcraft cost plan should use token-form references.
// If it used URLs, the Artcraft plan builder would reject them.
// This test passes if distillation succeeds at all — the Artcraft
// builder would error out on URL-form refs.
let start_token = fake_token("start_frame");
let ref_img = fake_token("ref_img_1");
let ref_vid = fake_token("ref_vid_1");
let ref_aud = fake_token("ref_aud_1");
let map = build_hydration_map(&[&start_token, &ref_img, &ref_vid, &ref_aud]);
let req = make_request(
Some("test"), None, Some(5), Some(1),
Some(start_token), None,
Some(vec![ref_img]),
Some(vec![ref_vid]),
Some(vec![ref_aud]),
);
// If this succeeds, the Artcraft cost path handled tokens correctly.
let d = distill_with_map(&req, &map);
assert!(d.cost.cost_in_credits.is_some());
}
#[test]
fn default_duration_produces_valid_cost() {
// When duration_seconds is None, the model should use a default.
let d = distill_text(&text_only_request(None, None));
assert!(d.cost.cost_in_usd_cents.unwrap_or(0) > 0);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// GENERATION PLAN TESTS
// ═══════════════════════════════════════════════════════════════════════════
mod generation_plan_tests {
use super::*;
#[test]
fn provider_is_seedance2pro() {
let d = distill_text(&text_only_request(Some(5), None));
assert!(
matches!(d.execution_provider, Provider::Seedance2Pro),
"expected Seedance2Pro provider, got {:?}", d.execution_provider
);
}
#[test]
fn plan_is_seedance2pro_variant() {
let d = distill_text(&text_only_request(Some(5), None));
assert!(
matches!(d.plan(), VideoGenerationPlan::Seedance2proSeedance2p0(_)),
"expected Seedance2proSeedance2p0 plan variant, got {:?}", d.plan()
);
}
#[test]
fn text_only_has_no_media_urls() {
let d = distill_text(&text_only_request(Some(5), None));
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
assert!(plan.start_frame_url.is_none());
assert!(plan.end_frame_url.is_none());
assert!(plan.reference_image_urls.is_none());
assert!(plan.reference_video_urls.is_none());
assert!(plan.reference_audio_urls.is_none());
} else {
panic!("wrong plan variant");
}
}
#[test]
fn text_only_prompt_is_passed_through() {
let d = distill_text(&text_only_request(Some(5), None));
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
assert_eq!(plan.prompt.as_deref(), Some("a cat dancing"));
} else {
panic!("wrong plan variant");
}
}
#[test]
fn start_frame_uses_url_not_token() {
let start_token = fake_token("start_frame");
let map = build_hydration_map(&[&start_token]);
let req = make_request(
Some("test"), None, Some(5), None,
Some(start_token), None, None, None, None,
);
let d = distill_with_map(&req, &map);
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
let url = plan.start_frame_url.as_ref().expect("start_frame_url should be set");
assert!(url.starts_with("https://"), "expected URL, got: {}", url);
assert!(!url.contains("m_"), "URL should not look like a media token");
} else {
panic!("wrong plan variant");
}
}
#[test]
fn end_frame_uses_url_not_token() {
let end_token = fake_token("end_frame");
let map = build_hydration_map(&[&end_token]);
let req = make_request(
Some("test"), None, Some(5), None,
None, Some(end_token), None, None, None,
);
let d = distill_with_map(&req, &map);
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
let url = plan.end_frame_url.as_ref().expect("end_frame_url should be set");
assert!(url.starts_with("https://"), "expected URL, got: {}", url);
} else {
panic!("wrong plan variant");
}
}
#[test]
fn start_and_end_frame_both_resolved() {
let start = fake_token("sf");
let end = fake_token("ef");
let map = build_hydration_map(&[&start, &end]);
let req = make_request(
Some("test"), None, Some(5), None,
Some(start), Some(end), None, None, None,
);
let d = distill_with_map(&req, &map);
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
assert!(plan.start_frame_url.is_some());
assert!(plan.end_frame_url.is_some());
} else {
panic!("wrong plan variant");
}
}
#[test]
fn reference_images_use_urls() {
let img1 = fake_token("img1");
let img2 = fake_token("img2");
let map = build_hydration_map(&[&img1, &img2]);
let req = make_request(
Some("test"), None, Some(5), None,
None, None,
Some(vec![img1, img2]),
None, None,
);
let d = distill_with_map(&req, &map);
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
let urls = plan.reference_image_urls.as_ref().expect("reference_image_urls should be set");
assert_eq!(urls.len(), 2);
for url in urls {
assert!(url.starts_with("https://"), "expected URL, got: {}", url);
}
} else {
panic!("wrong plan variant");
}
}
#[test]
fn reference_videos_use_urls() {
let vid1 = fake_token("vid1");
let map = build_hydration_map(&[&vid1]);
let req = make_request(
Some("test"), None, Some(5), None,
None, None, None,
Some(vec![vid1]),
None,
);
let d = distill_with_map(&req, &map);
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
let urls = plan.reference_video_urls.as_ref().expect("reference_video_urls should be set");
assert_eq!(urls.len(), 1);
assert!(urls[0].starts_with("https://"));
} else {
panic!("wrong plan variant");
}
}
#[test]
fn reference_audio_use_urls() {
let aud1 = fake_token("aud1");
let map = build_hydration_map(&[&aud1]);
let req = make_request(
Some("test"), None, Some(5), None,
None, None, None, None,
Some(vec![aud1]),
);
let d = distill_with_map(&req, &map);
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
let urls = plan.reference_audio_urls.as_ref().expect("reference_audio_urls should be set");
assert_eq!(urls.len(), 1);
assert!(urls[0].starts_with("https://"));
} else {
panic!("wrong plan variant");
}
}
#[test]
fn all_reference_types_together() {
let sf = fake_token("sf");
let ef = fake_token("ef");
let img = fake_token("img");
let vid = fake_token("vid");
let aud = fake_token("aud");
let map = build_hydration_map(&[&sf, &ef, &img, &vid, &aud]);
let req = make_request(
Some("full test"), None, Some(8), Some(2),
Some(sf), Some(ef),
Some(vec![img]),
Some(vec![vid]),
Some(vec![aud]),
);
let d = distill_with_map(&req, &map);
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
assert!(plan.start_frame_url.is_some());
assert!(plan.end_frame_url.is_some());
assert_eq!(plan.reference_image_urls.as_ref().map(|v| v.len()), Some(1));
assert_eq!(plan.reference_video_urls.as_ref().map(|v| v.len()), Some(1));
assert_eq!(plan.reference_audio_urls.as_ref().map(|v| v.len()), Some(1));
assert!(plan.prompt.as_deref() == Some("full test"));
} else {
panic!("wrong plan variant");
}
}
#[test]
fn duration_is_clamped_to_valid_range() {
// Duration below 4 should clamp to 4.
let d = distill_text(&text_only_request(Some(1), None));
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
assert!(plan.duration_seconds >= 4, "expected >= 4, got {}", plan.duration_seconds);
} else {
panic!("wrong plan variant");
}
// Duration above 15 should clamp to 15.
let d = distill_text(&text_only_request(Some(99), None));
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
assert!(plan.duration_seconds <= 15, "expected <= 15, got {}", plan.duration_seconds);
} else {
panic!("wrong plan variant");
}
}
#[test]
fn missing_prompt_defaults_to_empty() {
let req = make_request(
None, None, Some(5), None,
None, None, None, None, None,
);
let d = distill_text(&req);
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
// Prompt should be None or empty string.
let prompt = plan.prompt.as_deref().unwrap_or("");
assert!(prompt.is_empty() || plan.prompt.is_none());
} else {
panic!("wrong plan variant");
}
}
}
}
@@ -1,4 +1 @@
mod test_seedance_1p5_pro;
mod test_veo_2;
mod test_veo_3;
mod kinovi;
@@ -1,4 +1,4 @@
//! Tests for omni-gen video against the Seedance 1.5 Pro model.
//! Tests for omni-gen video distillation against the Seedance 1.5 Pro model.
//!
//! Seedance 1.5 Pro supports both text-to-video and image-to-video (via
//! start_frame / end_frame). Pricing is based on resolution × duration ×
@@ -19,7 +19,6 @@ mod tests {
use tokens::tokens::media_files::MediaFileToken;
use url::Url;
use artcraft_router::api::provider::Provider;
use crate::http_server::endpoints::omni_gen::generate::video::distill_video_request::{
distill_video_request, DistilledVideoRequest,
};
@@ -76,7 +75,7 @@ mod tests {
fn distill_text(request: &OmniGenVideoCostAndGenerateRequest) -> DistilledVideoRequest {
let empty: HashMap<MediaFileToken, Url> = HashMap::new();
distill_video_request(request, Some(&empty), Provider::Fal)
distill_video_request(request, Some(&empty))
.expect("distill_video_request should succeed for Seedance 1.5 Pro (text)")
}
@@ -84,7 +83,7 @@ mod tests {
request: &OmniGenVideoCostAndGenerateRequest,
hydration: &HashMap<MediaFileToken, Url>,
) -> DistilledVideoRequest {
distill_video_request(request, Some(hydration), Provider::Fal)
distill_video_request(request, Some(hydration))
.expect("distill_video_request should succeed for Seedance 1.5 Pro (image)")
}
@@ -1,443 +0,0 @@
//! Tests for omni-gen video against the Veo 2 model.
//!
//! Veo 2 supports both text-to-video and image-to-video (via start_frame).
//!
//! Pricing: $2.50 for the first 5 seconds, +$0.50 per additional second.
//! 5s → 250¢, 6s → 300¢, 7s → 350¢, 8s → 400¢
//!
//! Supported aspect ratios: Auto, WideSixteenNine, TallNineSixteen.
//! Unsupported ratios fall back to Auto under PayMoreUpgrade.
//!
//! Supported durations: 5–8 seconds. Over-max clamps to 8s (PayMoreUpgrade).
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_video_model::CommonVideoModel;
use tokens::tokens::media_files::MediaFileToken;
use url::Url;
use artcraft_router::api::provider::Provider;
use crate::http_server::endpoints::omni_gen::generate::video::distill_video_request::{
distill_video_request, DistilledVideoRequest,
};
// ── Helpers ────────────────────────────────────────────────────────────────
fn make_request(
prompt: Option<&str>,
aspect_ratio: Option<CommonAspectRatio>,
duration_seconds: Option<u16>,
start_frame_token: Option<MediaFileToken>,
) -> OmniGenVideoCostAndGenerateRequest {
OmniGenVideoCostAndGenerateRequest {
idempotency_token: Some("11111111-1111-1111-1111-111111111111".to_string()),
model: Some(CommonVideoModel::Veo2),
prompt: prompt.map(|s| s.to_string()),
negative_prompt: None,
start_frame_image_media_token: start_frame_token,
end_frame_image_media_token: None,
reference_image_media_tokens: None,
reference_video_media_tokens: None,
reference_audio_media_tokens: None,
reference_character_tokens: None,
resolution: None,
aspect_ratio,
quality: None,
duration_seconds,
video_batch_count: None,
generate_audio: None,
}
}
fn fake_token(name: &str) -> (MediaFileToken, HashMap<MediaFileToken, Url>) {
let token = MediaFileToken::new_from_str(name);
let url = Url::parse(&format!("https://fake.example.com/{}.png", name)).unwrap();
let mut map = HashMap::new();
map.insert(token.clone(), url);
(token, map)
}
fn distill_text(request: &OmniGenVideoCostAndGenerateRequest) -> DistilledVideoRequest {
let empty: HashMap<MediaFileToken, Url> = HashMap::new();
distill_video_request(request, Some(&empty), Provider::Fal)
.expect("distill_video_request should succeed for Veo 2 (text)")
}
fn distill_image(
request: &OmniGenVideoCostAndGenerateRequest,
hydration: &HashMap<MediaFileToken, Url>,
) -> DistilledVideoRequest {
distill_video_request(request, Some(hydration), Provider::Fal)
.expect("distill_video_request should succeed for Veo 2 (image)")
}
// ────────────────────────────────────────────────────────────────────────────
// COST
// ────────────────────────────────────────────────────────────────────────────
mod cost {
use super::*;
mod text {
use super::*;
fn cost(duration: Option<u16>) -> u64 {
let request = make_request(Some("p"), None, duration, None);
distill_text(&request).cost.cost_in_usd_cents.unwrap()
}
#[test]
fn default_duration_costs_250() { assert_eq!(cost(None), 250); }
#[test]
fn five_seconds_costs_250() { assert_eq!(cost(Some(5)), 250); }
#[test]
fn six_seconds_costs_300() { assert_eq!(cost(Some(6)), 300); }
#[test]
fn seven_seconds_costs_350() { assert_eq!(cost(Some(7)), 350); }
#[test]
fn eight_seconds_costs_400() { assert_eq!(cost(Some(8)), 400); }
#[test]
fn duration_above_8_clamps_to_400() { assert_eq!(cost(Some(15)), 400); }
#[test]
fn cost_independent_of_aspect_ratio() {
let ars = [
None,
Some(CommonAspectRatio::Auto),
Some(CommonAspectRatio::WideSixteenByNine),
Some(CommonAspectRatio::TallNineBySixteen),
Some(CommonAspectRatio::Square),
];
for ar in ars {
let request = make_request(Some("p"), ar, None, None);
assert_eq!(distill_text(&request).cost.cost_in_usd_cents.unwrap(), 250);
}
}
#[test]
fn cost_metadata_flags_are_default() {
let d = distill_text(&make_request(Some("p"), None, None, None));
assert!(!d.cost.is_free);
assert!(!d.cost.is_unlimited);
assert!(!d.cost.is_rate_limited);
assert!(!d.cost.has_watermark);
}
}
mod image {
use super::*;
fn cost(duration: Option<u16>) -> u64 {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), None, duration, Some(start));
distill_image(&request, &map).cost.cost_in_usd_cents.unwrap()
}
#[test]
fn default_duration_costs_250() { assert_eq!(cost(None), 250); }
#[test]
fn five_seconds_costs_250() { assert_eq!(cost(Some(5)), 250); }
#[test]
fn six_seconds_costs_300() { assert_eq!(cost(Some(6)), 300); }
#[test]
fn seven_seconds_costs_350() { assert_eq!(cost(Some(7)), 350); }
#[test]
fn eight_seconds_costs_400() { assert_eq!(cost(Some(8)), 400); }
#[test]
fn duration_above_8_clamps_to_400() { assert_eq!(cost(Some(15)), 400); }
#[test]
fn image_and_text_cost_match() {
for dur in [5u16, 6, 7, 8] {
let text = distill_text(&make_request(Some("p"), None, Some(dur), None))
.cost.cost_in_usd_cents.unwrap();
let (start, map) = fake_token("mf_start0000000000000000000000");
let img = distill_image(
&make_request(Some("p"), None, Some(dur), Some(start)),
&map,
).cost.cost_in_usd_cents.unwrap();
assert_eq!(text, img, "text/image cost diverged at dur={}", dur);
}
}
}
}
// ────────────────────────────────────────────────────────────────────────────
// PLAN
// ────────────────────────────────────────────────────────────────────────────
mod plan {
use super::*;
use artcraft_router::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_router::generate::generate_video::plan::fal::plan_generate_video_fal_veo_2::{
FalVeo2Mode, PlanFalVeo2,
};
use fal_client::requests::webhook::video::image::enqueue_veo_2_image_to_video_webhook::{
Veo2AspectRatio, Veo2Duration,
};
fn with_text_plan<F: FnOnce(&PlanFalVeo2)>(
request: &OmniGenVideoCostAndGenerateRequest,
assertion: F,
) {
let distilled = distill_text(request);
match distilled.plan() {
VideoGenerationPlan::FalVeo2(plan) => assertion(plan),
other => panic!("expected FalVeo2, got {:?}", other),
}
}
fn with_image_plan<F: FnOnce(&PlanFalVeo2)>(
request: &OmniGenVideoCostAndGenerateRequest,
hydration: &HashMap<MediaFileToken, Url>,
assertion: F,
) {
let distilled = distill_image(request, hydration);
match distilled.plan() {
VideoGenerationPlan::FalVeo2(plan) => assertion(plan),
other => panic!("expected FalVeo2, got {:?}", other),
}
}
mod text {
use super::*;
// ── Mode detection ────────────────────────────────────────────────────
#[test]
fn text_mode_is_text_to_video() {
with_text_plan(&make_request(Some("p"), None, None, None), |plan| {
assert!(matches!(plan.mode, FalVeo2Mode::TextToVideo));
});
}
// ── Prompt passthrough ────────────────────────────────────────────────
#[test]
fn prompt_is_passed_through() {
with_text_plan(&make_request(Some("a horse on mars"), None, None, None), |plan| {
assert_eq!(plan.prompt, "a horse on mars");
});
}
#[test]
fn missing_prompt_is_empty_string() {
with_text_plan(&make_request(None, None, None, None), |plan| {
assert_eq!(plan.prompt, "");
});
}
// ── Aspect ratio mappings ─────────────────────────────────────────────
#[test]
fn default_aspect_ratio_is_auto() {
with_text_plan(&make_request(Some("p"), None, None, None), |plan| {
assert!(matches!(plan.aspect_ratio, Some(Veo2AspectRatio::Auto)));
});
}
#[test]
fn auto_yields_auto() {
for ar in [CommonAspectRatio::Auto, CommonAspectRatio::Auto2k, CommonAspectRatio::Auto4k] {
with_text_plan(&make_request(Some("p"), Some(ar), None, None), |plan| {
assert!(matches!(plan.aspect_ratio, Some(Veo2AspectRatio::Auto)));
});
}
}
#[test]
fn wide_16x9_yields_wide() {
for ar in [CommonAspectRatio::WideSixteenByNine, CommonAspectRatio::Wide] {
with_text_plan(&make_request(Some("p"), Some(ar), None, None), |plan| {
assert!(matches!(plan.aspect_ratio, Some(Veo2AspectRatio::WideSixteenNine)));
});
}
}
#[test]
fn tall_9x16_yields_tall() {
for ar in [CommonAspectRatio::TallNineBySixteen, CommonAspectRatio::Tall] {
with_text_plan(&make_request(Some("p"), Some(ar), None, None), |plan| {
assert!(matches!(plan.aspect_ratio, Some(Veo2AspectRatio::TallNineSixteen)));
});
}
}
#[test]
fn unsupported_aspect_ratio_falls_back_to_auto() {
for ar in [
CommonAspectRatio::Square, CommonAspectRatio::SquareHd,
CommonAspectRatio::WideFourByThree, CommonAspectRatio::TallThreeByFour,
] {
with_text_plan(&make_request(Some("p"), Some(ar), None, None), |plan| {
assert!(matches!(plan.aspect_ratio, Some(Veo2AspectRatio::Auto)));
});
}
}
// ── Duration mappings ─────────────────────────────────────────────────
#[test]
fn default_duration_is_default() {
with_text_plan(&make_request(Some("p"), None, None, None), |plan| {
assert!(matches!(plan.duration, Veo2Duration::Default));
});
}
#[test]
fn duration_5_yields_five() {
with_text_plan(&make_request(Some("p"), None, Some(5), None), |plan| {
assert!(matches!(plan.duration, Veo2Duration::FiveSeconds));
});
}
#[test]
fn duration_6_yields_six() {
with_text_plan(&make_request(Some("p"), None, Some(6), None), |plan| {
assert!(matches!(plan.duration, Veo2Duration::SixSeconds));
});
}
#[test]
fn duration_7_yields_seven() {
with_text_plan(&make_request(Some("p"), None, Some(7), None), |plan| {
assert!(matches!(plan.duration, Veo2Duration::SevenSeconds));
});
}
#[test]
fn duration_8_yields_eight() {
with_text_plan(&make_request(Some("p"), None, Some(8), None), |plan| {
assert!(matches!(plan.duration, Veo2Duration::EightSeconds));
});
}
#[test]
fn duration_above_8_clamps_to_eight() {
with_text_plan(&make_request(Some("p"), None, Some(20), None), |plan| {
assert!(matches!(plan.duration, Veo2Duration::EightSeconds));
});
}
}
mod image {
use super::*;
// ── Mode detection / start frame hydration ────────────────────────────
#[test]
fn start_frame_triggers_image_to_video() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), None, None, Some(start));
with_image_plan(&request, &map, |plan| {
match &plan.mode {
FalVeo2Mode::ImageToVideo { image_url } => {
assert!(image_url.starts_with("https://fake.example.com/"));
}
other => panic!("expected ImageToVideo, got {:?}", other),
}
});
}
#[test]
fn start_frame_url_matches_hydration_map_exactly() {
let token = MediaFileToken::new_from_str("mf_myframe0000000000000000000000");
let expected_url = "https://cdn.example.com/frames/my_frame.png";
let mut map = HashMap::new();
map.insert(token.clone(), Url::parse(expected_url).unwrap());
let request = make_request(Some("p"), None, None, Some(token));
with_image_plan(&request, &map, |plan| {
match &plan.mode {
FalVeo2Mode::ImageToVideo { image_url } => {
assert_eq!(image_url, expected_url);
}
other => panic!("expected ImageToVideo, got {:?}", other),
}
});
}
// ── Prompt passthrough in image mode ──────────────────────────────────
#[test]
fn prompt_is_passed_through_in_image_mode() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("a horse on mars"), None, None, Some(start));
with_image_plan(&request, &map, |plan| {
assert_eq!(plan.prompt, "a horse on mars");
});
}
// ── Aspect ratio is None in image mode (i2v inherits source AR) ────────
#[test]
fn aspect_ratio_is_none_in_image_mode() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), Some(CommonAspectRatio::WideSixteenByNine), None, Some(start));
with_image_plan(&request, &map, |plan| {
assert!(plan.aspect_ratio.is_none(), "image-to-video should not have aspect_ratio");
});
}
#[test]
fn aspect_ratio_is_none_in_image_mode_even_with_tall() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), Some(CommonAspectRatio::TallNineBySixteen), None, Some(start));
with_image_plan(&request, &map, |plan| {
assert!(plan.aspect_ratio.is_none());
});
}
// ── Duration pass-throughs in image mode ──────────────────────────────
#[test]
fn duration_5_in_image_mode() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), None, Some(5), Some(start));
with_image_plan(&request, &map, |plan| {
assert!(matches!(plan.duration, Veo2Duration::FiveSeconds));
});
}
#[test]
fn duration_8_in_image_mode() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), None, Some(8), Some(start));
with_image_plan(&request, &map, |plan| {
assert!(matches!(plan.duration, Veo2Duration::EightSeconds));
});
}
#[test]
fn duration_above_8_clamps_in_image_mode() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), None, Some(20), Some(start));
with_image_plan(&request, &map, |plan| {
assert!(matches!(plan.duration, Veo2Duration::EightSeconds));
});
}
#[test]
fn default_duration_in_image_mode() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), None, None, Some(start));
with_image_plan(&request, &map, |plan| {
assert!(matches!(plan.duration, Veo2Duration::Default));
});
}
}
}
}
@@ -1,590 +0,0 @@
//! Tests for omni-gen video against the Veo 3 model.
//!
//! Veo 3 supports both text-to-video and image-to-video (via start_frame).
//!
//! Pricing: $0.20/sec (audio off) or $0.40/sec (audio on).
//! Default: 8s, audio off (artcraft legacy) → 160¢.
//! 4s no-audio → 80¢, 6s no-audio → 120¢, 8s no-audio → 160¢
//! 4s audio → 160¢, 6s audio → 240¢, 8s audio → 320¢
//!
//! Supported aspect ratios (text mode only): Default, 16:9, 9:16, Square.
//! Image-to-video inherits the source frame's aspect ratio.
//!
//! Supported resolutions: 720p, 1080p.
//! Supported durations: 4s, 6s, 8s. Over-max clamps to 8s (PayMoreUpgrade).
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation::common_video_model::CommonVideoModel;
use tokens::tokens::media_files::MediaFileToken;
use url::Url;
use artcraft_router::api::provider::Provider;
use crate::http_server::endpoints::omni_gen::generate::video::distill_video_request::{
distill_video_request, DistilledVideoRequest,
};
// ── Helpers ────────────────────────────────────────────────────────────────
fn make_request(
prompt: Option<&str>,
aspect_ratio: Option<CommonAspectRatio>,
resolution: Option<CommonResolution>,
duration_seconds: Option<u16>,
generate_audio: Option<bool>,
start_frame_token: Option<MediaFileToken>,
) -> OmniGenVideoCostAndGenerateRequest {
OmniGenVideoCostAndGenerateRequest {
idempotency_token: Some("11111111-1111-1111-1111-111111111111".to_string()),
model: Some(CommonVideoModel::Veo3),
prompt: prompt.map(|s| s.to_string()),
negative_prompt: None,
start_frame_image_media_token: start_frame_token,
end_frame_image_media_token: None,
reference_image_media_tokens: None,
reference_video_media_tokens: None,
reference_audio_media_tokens: None,
reference_character_tokens: None,
resolution,
aspect_ratio,
quality: None,
duration_seconds,
video_batch_count: None,
generate_audio,
}
}
fn fake_token(name: &str) -> (MediaFileToken, HashMap<MediaFileToken, Url>) {
let token = MediaFileToken::new_from_str(name);
let url = Url::parse(&format!("https://fake.example.com/{}.png", name)).unwrap();
let mut map = HashMap::new();
map.insert(token.clone(), url);
(token, map)
}
fn distill_text(request: &OmniGenVideoCostAndGenerateRequest) -> DistilledVideoRequest {
let empty: HashMap<MediaFileToken, Url> = HashMap::new();
distill_video_request(request, Some(&empty), Provider::Fal)
.expect("distill should succeed for Veo 3 (text)")
}
fn distill_image(
request: &OmniGenVideoCostAndGenerateRequest,
hydration: &HashMap<MediaFileToken, Url>,
) -> DistilledVideoRequest {
distill_video_request(request, Some(hydration), Provider::Fal)
.expect("distill should succeed for Veo 3 (image)")
}
// ────────────────────────────────────────────────────────────────────────────
// COST
// ────────────────────────────────────────────────────────────────────────────
//
// Artcraft cost path: always 8s (legacy). $0.20/sec no-audio, $0.40/sec audio.
// Default audio = false (artcraft legacy).
mod cost {
use super::*;
mod text {
use super::*;
fn cost(duration: Option<u16>, audio: Option<bool>) -> u64 {
let request = make_request(Some("p"), None, None, duration, audio, None);
distill_text(&request).cost.cost_in_usd_cents.unwrap()
}
// ── No audio: $0.20/sec ──────────────────────────────────────────────
#[test]
fn default_no_audio_costs_160() { assert_eq!(cost(None, None), 160); } // 8s × 20¢
#[test]
fn four_seconds_no_audio_costs_80() { assert_eq!(cost(Some(4), Some(false)), 80); }
#[test]
fn six_seconds_no_audio_costs_120() { assert_eq!(cost(Some(6), Some(false)), 120); }
#[test]
fn eight_seconds_no_audio_costs_160() { assert_eq!(cost(Some(8), Some(false)), 160); }
// ── Audio on: $0.40/sec ───────────────────────────────────────────────
#[test]
fn four_seconds_audio_on_costs_160() { assert_eq!(cost(Some(4), Some(true)), 160); }
#[test]
fn six_seconds_audio_on_costs_240() { assert_eq!(cost(Some(6), Some(true)), 240); }
#[test]
fn eight_seconds_audio_on_costs_320() { assert_eq!(cost(Some(8), Some(true)), 320); }
#[test]
fn default_duration_audio_on_costs_320() { assert_eq!(cost(None, Some(true)), 320); }
// ── Over-max clamps to 8s ─────────────────────────────────────────────
#[test]
fn duration_above_8_clamps_to_160_no_audio() { assert_eq!(cost(Some(20), Some(false)), 160); }
#[test]
fn duration_above_8_clamps_to_320_audio() { assert_eq!(cost(Some(20), Some(true)), 320); }
// ── Cost is independent of aspect ratio ───────────────────────────────
#[test]
fn cost_independent_of_aspect_ratio() {
for ar in [None, Some(CommonAspectRatio::Auto), Some(CommonAspectRatio::WideSixteenByNine), Some(CommonAspectRatio::TallNineBySixteen)] {
let request = make_request(Some("p"), ar, None, None, None, None);
assert_eq!(distill_text(&request).cost.cost_in_usd_cents.unwrap(), 160);
}
}
// ── Cost is independent of resolution ─────────────────────────────────
#[test]
fn cost_independent_of_resolution() {
for res in [None, Some(CommonResolution::SevenTwentyP), Some(CommonResolution::TenEightyP)] {
let request = make_request(Some("p"), None, res, None, None, None);
assert_eq!(distill_text(&request).cost.cost_in_usd_cents.unwrap(), 160);
}
}
// ── Metadata flags ────────────────────────────────────────────────────
#[test]
fn cost_metadata_flags_are_default() {
let d = distill_text(&make_request(Some("p"), None, None, None, None, None));
assert!(!d.cost.is_free);
assert!(!d.cost.is_unlimited);
assert!(!d.cost.is_rate_limited);
assert!(!d.cost.has_watermark);
}
}
mod image {
use super::*;
fn cost_img(duration: Option<u16>, audio: Option<bool>) -> u64 {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), None, None, duration, audio, Some(start));
distill_image(&request, &map).cost.cost_in_usd_cents.unwrap()
}
#[test]
fn default_costs_160() { assert_eq!(cost_img(None, None), 160); }
#[test]
fn four_seconds_no_audio_costs_80() { assert_eq!(cost_img(Some(4), Some(false)), 80); }
#[test]
fn six_seconds_audio_on_costs_240() { assert_eq!(cost_img(Some(6), Some(true)), 240); }
#[test]
fn eight_seconds_audio_on_costs_320() { assert_eq!(cost_img(Some(8), Some(true)), 320); }
#[test]
fn image_and_text_cost_match() {
let text = distill_text(&make_request(Some("p"), None, None, None, None, None))
.cost.cost_in_usd_cents.unwrap();
let (start, map) = fake_token("mf_start0000000000000000000000");
let img = distill_image(
&make_request(Some("p"), None, None, None, None, Some(start)),
&map,
).cost.cost_in_usd_cents.unwrap();
assert_eq!(text, img);
}
}
}
// ────────────────────────────────────────────────────────────────────────────
// PLAN
// ────────────────────────────────────────────────────────────────────────────
mod plan {
use super::*;
use artcraft_router::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_router::generate::generate_video::plan::fal::plan_generate_video_fal_veo_3::{
FalVeo3Duration, FalVeo3I2vAspectRatio, FalVeo3Mode, FalVeo3Resolution,
FalVeo3T2vAspectRatio, PlanFalVeo3,
};
fn with_text_plan<F: FnOnce(&PlanFalVeo3)>(
request: &OmniGenVideoCostAndGenerateRequest,
assertion: F,
) {
let distilled = distill_text(request);
match distilled.plan() {
VideoGenerationPlan::FalVeo3(plan) => assertion(plan),
other => panic!("expected FalVeo3, got {:?}", other),
}
}
fn with_image_plan<F: FnOnce(&PlanFalVeo3)>(
request: &OmniGenVideoCostAndGenerateRequest,
hydration: &HashMap<MediaFileToken, Url>,
assertion: F,
) {
let distilled = distill_image(request, hydration);
match distilled.plan() {
VideoGenerationPlan::FalVeo3(plan) => assertion(plan),
other => panic!("expected FalVeo3, got {:?}", other),
}
}
mod text {
use super::*;
// ── Mode detection ────────────────────────────────────────────────────
#[test]
fn text_mode_is_text_to_video() {
with_text_plan(&make_request(Some("p"), None, None, None, None, None), |plan| {
assert!(matches!(plan.mode, FalVeo3Mode::TextToVideo));
});
}
// ── Prompt passthrough ────────────────────────────────────────────────
#[test]
fn prompt_is_passed_through() {
with_text_plan(&make_request(Some("a horse on mars"), None, None, None, None, None), |plan| {
assert_eq!(plan.prompt, "a horse on mars");
});
}
#[test]
fn missing_prompt_is_empty_string() {
with_text_plan(&make_request(None, None, None, None, None, None), |plan| {
assert_eq!(plan.prompt, "");
});
}
// ── Aspect ratio mappings (text mode only) ────────────────────────────
#[test]
fn default_aspect_ratio_is_default() {
with_text_plan(&make_request(Some("p"), None, None, None, None, None), |plan| {
assert!(matches!(plan.t2v_aspect_ratio, Some(FalVeo3T2vAspectRatio::Default)));
});
}
#[test]
fn wide_16x9_yields_wide() {
for ar in [CommonAspectRatio::WideSixteenByNine, CommonAspectRatio::Wide] {
with_text_plan(&make_request(Some("p"), Some(ar), None, None, None, None), |plan| {
assert!(matches!(plan.t2v_aspect_ratio, Some(FalVeo3T2vAspectRatio::WideSixteenNine)));
});
}
}
#[test]
fn tall_9x16_yields_tall() {
for ar in [CommonAspectRatio::TallNineBySixteen, CommonAspectRatio::Tall] {
with_text_plan(&make_request(Some("p"), Some(ar), None, None, None, None), |plan| {
assert!(matches!(plan.t2v_aspect_ratio, Some(FalVeo3T2vAspectRatio::TallNineSixteen)));
});
}
}
#[test]
fn square_falls_back_to_default() {
// Veo 3 t2v has no Square — falls back to Default (16:9).
for ar in [CommonAspectRatio::Square, CommonAspectRatio::SquareHd] {
with_text_plan(&make_request(Some("p"), Some(ar), None, None, None, None), |plan| {
assert!(matches!(plan.t2v_aspect_ratio, Some(FalVeo3T2vAspectRatio::Default)));
});
}
}
#[test]
fn auto_falls_back_to_default() {
// Veo 3 t2v has no Auto — falls back to Default (16:9).
for ar in [CommonAspectRatio::Auto, CommonAspectRatio::Auto2k, CommonAspectRatio::Auto4k] {
with_text_plan(&make_request(Some("p"), Some(ar), None, None, None, None), |plan| {
assert!(matches!(plan.t2v_aspect_ratio, Some(FalVeo3T2vAspectRatio::Default)));
});
}
}
#[test]
fn unsupported_aspect_ratio_falls_back_to_default() {
for ar in [CommonAspectRatio::WideFourByThree, CommonAspectRatio::TallThreeByFour, CommonAspectRatio::WideFiveByFour] {
with_text_plan(&make_request(Some("p"), Some(ar), None, None, None, None), |plan| {
assert!(matches!(plan.t2v_aspect_ratio, Some(FalVeo3T2vAspectRatio::Default)));
});
}
}
// ── Duration mappings ─────────────────────────────────────────────────
#[test]
fn default_duration_is_default() {
with_text_plan(&make_request(Some("p"), None, None, None, None, None), |plan| {
assert!(matches!(plan.duration, FalVeo3Duration::Default));
});
}
#[test]
fn duration_4_yields_four() {
with_text_plan(&make_request(Some("p"), None, None, Some(4), None, None), |plan| {
assert!(matches!(plan.duration, FalVeo3Duration::FourSeconds));
});
}
#[test]
fn duration_6_yields_six() {
with_text_plan(&make_request(Some("p"), None, None, Some(6), None, None), |plan| {
assert!(matches!(plan.duration, FalVeo3Duration::SixSeconds));
});
}
#[test]
fn duration_8_yields_eight() {
with_text_plan(&make_request(Some("p"), None, None, Some(8), None, None), |plan| {
assert!(matches!(plan.duration, FalVeo3Duration::EightSeconds));
});
}
#[test]
fn duration_above_8_clamps_to_eight() {
with_text_plan(&make_request(Some("p"), None, None, Some(20), None, None), |plan| {
assert!(matches!(plan.duration, FalVeo3Duration::EightSeconds));
});
}
// ── Resolution mappings ───────────────────────────────────────────────
#[test]
fn default_resolution_is_default() {
with_text_plan(&make_request(Some("p"), None, None, None, None, None), |plan| {
assert!(matches!(plan.resolution, FalVeo3Resolution::Default));
});
}
#[test]
fn seven_twenty_p_yields_seven_twenty_p() {
with_text_plan(&make_request(Some("p"), None, Some(CommonResolution::SevenTwentyP), None, None, None), |plan| {
assert!(matches!(plan.resolution, FalVeo3Resolution::SevenTwentyP));
});
}
#[test]
fn ten_eighty_p_yields_ten_eighty_p() {
with_text_plan(&make_request(Some("p"), None, Some(CommonResolution::TenEightyP), None, None, None), |plan| {
assert!(matches!(plan.resolution, FalVeo3Resolution::TenEightyP));
});
}
// ── Generate audio passthrough ────────────────────────────────────────
#[test]
fn default_audio_is_true() {
// Fal plan defaults generate_audio to true.
with_text_plan(&make_request(Some("p"), None, None, None, None, None), |plan| {
assert!(plan.generate_audio);
});
}
#[test]
fn audio_true_passes_through() {
with_text_plan(&make_request(Some("p"), None, None, None, Some(true), None), |plan| {
assert!(plan.generate_audio);
});
}
#[test]
fn audio_false_passes_through() {
with_text_plan(&make_request(Some("p"), None, None, None, Some(false), None), |plan| {
assert!(!plan.generate_audio);
});
}
}
mod image {
use super::*;
// ── Mode detection / start frame hydration ────────────────────────────
#[test]
fn start_frame_triggers_image_to_video() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), None, None, None, None, Some(start));
with_image_plan(&request, &map, |plan| {
match &plan.mode {
FalVeo3Mode::ImageToVideo { image_url } => {
assert!(image_url.starts_with("https://fake.example.com/"));
}
other => panic!("expected ImageToVideo, got {:?}", other),
}
});
}
#[test]
fn start_frame_url_matches_hydration_map_exactly() {
let token = MediaFileToken::new_from_str("mf_myframe0000000000000000000000");
let expected_url = "https://cdn.example.com/frames/my_frame.png";
let mut map = HashMap::new();
map.insert(token.clone(), Url::parse(expected_url).unwrap());
let request = make_request(Some("p"), None, None, None, None, Some(token));
with_image_plan(&request, &map, |plan| {
match &plan.mode {
FalVeo3Mode::ImageToVideo { image_url } => {
assert_eq!(image_url, expected_url);
}
other => panic!("expected ImageToVideo, got {:?}", other),
}
});
}
// ── Aspect ratio is None in image mode ────────────────────────────────
// ── Aspect ratio in image mode: uses i2v (has Auto, no Square) ─────────
#[test]
fn t2v_aspect_ratio_is_none_in_image_mode() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), Some(CommonAspectRatio::WideSixteenByNine), None, None, None, Some(start));
with_image_plan(&request, &map, |plan| {
assert!(plan.t2v_aspect_ratio.is_none());
});
}
#[test]
fn i2v_default_aspect_ratio_is_auto() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), None, None, None, None, Some(start));
with_image_plan(&request, &map, |plan| {
assert!(matches!(plan.i2v_aspect_ratio, Some(FalVeo3I2vAspectRatio::Auto)));
});
}
#[test]
fn i2v_wide_16x9_yields_wide() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), Some(CommonAspectRatio::WideSixteenByNine), None, None, None, Some(start));
with_image_plan(&request, &map, |plan| {
assert!(matches!(plan.i2v_aspect_ratio, Some(FalVeo3I2vAspectRatio::WideSixteenNine)));
});
}
#[test]
fn i2v_tall_9x16_yields_tall() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), Some(CommonAspectRatio::TallNineBySixteen), None, None, None, Some(start));
with_image_plan(&request, &map, |plan| {
assert!(matches!(plan.i2v_aspect_ratio, Some(FalVeo3I2vAspectRatio::TallNineSixteen)));
});
}
#[test]
fn i2v_auto_yields_auto() {
for ar in [CommonAspectRatio::Auto, CommonAspectRatio::Auto2k, CommonAspectRatio::Auto4k] {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), Some(ar), None, None, None, Some(start));
with_image_plan(&request, &map, |plan| {
assert!(matches!(plan.i2v_aspect_ratio, Some(FalVeo3I2vAspectRatio::Auto)));
});
}
}
#[test]
fn i2v_square_falls_back_to_auto() {
for ar in [CommonAspectRatio::Square, CommonAspectRatio::SquareHd] {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), Some(ar), None, None, None, Some(start));
with_image_plan(&request, &map, |plan| {
assert!(matches!(plan.i2v_aspect_ratio, Some(FalVeo3I2vAspectRatio::Auto)));
});
}
}
#[test]
fn i2v_unsupported_falls_back_to_auto() {
for ar in [CommonAspectRatio::WideFourByThree, CommonAspectRatio::TallThreeByFour] {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), Some(ar), None, None, None, Some(start));
with_image_plan(&request, &map, |plan| {
assert!(matches!(plan.i2v_aspect_ratio, Some(FalVeo3I2vAspectRatio::Auto)));
});
}
}
// ── Prompt passthrough in image mode ──────────────────────────────────
#[test]
fn prompt_is_passed_through_in_image_mode() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("a horse on mars"), None, None, None, None, Some(start));
with_image_plan(&request, &map, |plan| {
assert_eq!(plan.prompt, "a horse on mars");
});
}
// ── Duration pass-throughs in image mode ──────────────────────────────
#[test]
fn duration_4_in_image_mode() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), None, None, Some(4), None, Some(start));
with_image_plan(&request, &map, |plan| {
assert!(matches!(plan.duration, FalVeo3Duration::FourSeconds));
});
}
#[test]
fn duration_8_in_image_mode() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), None, None, Some(8), None, Some(start));
with_image_plan(&request, &map, |plan| {
assert!(matches!(plan.duration, FalVeo3Duration::EightSeconds));
});
}
#[test]
fn default_duration_in_image_mode() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), None, None, None, None, Some(start));
with_image_plan(&request, &map, |plan| {
assert!(matches!(plan.duration, FalVeo3Duration::Default));
});
}
// ── Resolution in image mode ──────────────────────────────────────────
#[test]
fn resolution_passes_through_in_image_mode() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), None, Some(CommonResolution::TenEightyP), None, None, Some(start));
with_image_plan(&request, &map, |plan| {
assert!(matches!(plan.resolution, FalVeo3Resolution::TenEightyP));
});
}
// ── Audio in image mode ───────────────────────────────────────────────
#[test]
fn audio_passes_through_in_image_mode() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), None, None, None, Some(false), Some(start));
with_image_plan(&request, &map, |plan| {
assert!(!plan.generate_audio);
});
}
#[test]
fn default_audio_is_true_in_image_mode() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), None, None, None, None, Some(start));
with_image_plan(&request, &map, |plan| {
assert!(plan.generate_audio);
});
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More