implement fal seedance 1.0 / 1.5

This commit is contained in:
Brandon Thomas
2026-05-26 04:55:19 -04:00
parent 94077a3ecb
commit cc3a6c2f10
13 changed files with 975 additions and 0 deletions
@@ -62,6 +62,8 @@ use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0::build::
use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_g::build::build_gmicloud_seedance_2p0_u;
use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_fast_g::build::build_gmicloud_seedance_2p0_u_fast;
use crate::generate::generate_video_v2::providers::grok_api::grok_imagine_video::build::build_grok_api_grok_imagine_video;
use crate::generate::generate_video_v2::providers::fal::seedance_1p0_lite::build::build_fal_seedance_1p0_lite;
use crate::generate::generate_video_v2::providers::fal::seedance_1p5_pro::build::build_fal_seedance_1p5_pro;
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0_fast::build::build_kinovi_seedance_2p0_fast;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
@@ -165,6 +167,9 @@ impl GenerateVideoRequestBuilder {
(Provider::Artcraft, CommonVideoModel::Seedance2p0BytePlusFast) => true,
(Provider::Artcraft, CommonVideoModel::PreviewModel) => true,
(Provider::Artcraft, CommonVideoModel::PreviewModelFast) => true,
// Fal
(Provider::Fal, CommonVideoModel::Seedance10Lite) => true,
(Provider::Fal, CommonVideoModel::Seedance1p5Pro) => true,
// GmiCloud
(Provider::GmiCloud, CommonVideoModel::Seedance2p0Ultra) => true,
(Provider::GmiCloud, CommonVideoModel::Seedance2p0UltraFast) => true,
@@ -192,6 +197,9 @@ impl GenerateVideoRequestBuilder {
(Provider::Artcraft, CommonVideoModel::Seedance2p0BytePlusFast) => build_artcraft_seedance_2p0_bp_fast(self),
(Provider::Artcraft, CommonVideoModel::PreviewModel) => build_artcraft_preview_model(self),
(Provider::Artcraft, CommonVideoModel::PreviewModelFast) => build_artcraft_preview_model_fast(self),
// Fal
(Provider::Fal, CommonVideoModel::Seedance10Lite) => build_fal_seedance_1p0_lite(self),
(Provider::Fal, CommonVideoModel::Seedance1p5Pro) => build_fal_seedance_1p5_pro(self),
// GmiCloud
(Provider::GmiCloud, CommonVideoModel::Seedance2p0Ultra) => build_gmicloud_seedance_2p0_u(self),
(Provider::GmiCloud, CommonVideoModel::Seedance2p0UltraFast) => build_gmicloud_seedance_2p0_u_fast(self),
@@ -0,0 +1,288 @@
//! Combinatorial cost-parity tests between v1 (`build().estimate_cost()`) and v2
//! (`build2().estimate_cost()`) for the Fal-hosted Seedance video models.
//!
//! The goal: every request that produces a price in v1 must produce the
//! identical price in v2. If any callsite picks a different price after the
//! port, billing diverges. These tests close that gap.
//!
//! Both pipelines ultimately delegate to the same `FalRequestCostCalculator`
//! impls in `fal_client`, so parity should hold by construction — but we
//! exercise a wide variety of inputs anyway to catch any drift in the request
//! plumbing (e.g. resolution/duration mapping errors, default-value mismatches,
//! aspect-ratio fallthroughs).
#![cfg(test)]
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
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::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
const DUMMY_IMAGE: &str = "https://example.com/dummy.png";
const DUMMY_END_FRAME: &str = "https://example.com/dummy_end.png";
// ── seedance 1.0 lite ──
mod seedance_1p0_lite {
use super::*;
fn base_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance10Lite,
provider: Provider::Fal,
// 1.0 lite is image-to-video only — a start_frame is required.
start_frame: Some(ImageRef::Url(DUMMY_IMAGE.to_string())),
..Default::default()
}
}
fn all_resolutions() -> &'static [Option<CommonResolution>] {
&[
None,
Some(CommonResolution::FourEightyP),
Some(CommonResolution::SevenTwentyP),
Some(CommonResolution::TenEightyP),
]
}
fn all_durations() -> &'static [Option<u16>] {
&[None, Some(5), Some(10)]
}
fn supported_aspect_ratios() -> &'static [Option<CommonAspectRatio>] {
&[
None,
Some(CommonAspectRatio::Auto),
Some(CommonAspectRatio::Square),
Some(CommonAspectRatio::WideFourByThree),
Some(CommonAspectRatio::WideSixteenByNine),
Some(CommonAspectRatio::Wide),
Some(CommonAspectRatio::WideTwentyOneByNine),
Some(CommonAspectRatio::TallThreeByFour),
Some(CommonAspectRatio::TallNineBySixteen),
Some(CommonAspectRatio::Tall),
]
}
#[test]
fn cost_parity_full_combinatorial() {
let mut combos_checked = 0;
let mut combos_with_end_frame = 0;
for &resolution in all_resolutions() {
for &duration in all_durations() {
for &aspect_ratio in supported_aspect_ratios() {
for include_end_frame in [false, true] {
let mut builder = base_builder();
builder.resolution = resolution;
builder.duration_seconds = duration;
builder.aspect_ratio = aspect_ratio;
if include_end_frame {
builder.end_frame = Some(ImageRef::Url(DUMMY_END_FRAME.to_string()));
combos_with_end_frame += 1;
}
let v1 = v1_cost(&builder);
let v2 = v2_cost(builder.clone());
assert_eq!(
v1, v2,
"1.0 lite cost mismatch: res={:?} dur={:?} ar={:?} end={} → v1={:?} v2={:?}",
resolution, duration, aspect_ratio, include_end_frame, v1, v2,
);
combos_checked += 1;
}
}
}
}
// Sanity: we should be checking a meaningful number of combinations.
assert!(combos_checked >= 240, "expected ≥240 combos, got {}", combos_checked);
assert!(combos_with_end_frame > 0, "expected at least one with end_frame");
}
}
// ── seedance 1.5 pro ──
mod seedance_1p5_pro {
use super::*;
fn t2v_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance1p5Pro,
provider: Provider::Fal,
// No start_frame → text-to-video mode.
..Default::default()
}
}
fn i2v_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance1p5Pro,
provider: Provider::Fal,
start_frame: Some(ImageRef::Url(DUMMY_IMAGE.to_string())),
..Default::default()
}
}
fn all_resolutions() -> &'static [Option<CommonResolution>] {
&[
None,
Some(CommonResolution::FourEightyP),
Some(CommonResolution::SevenTwentyP),
Some(CommonResolution::TenEightyP),
]
}
fn all_durations() -> &'static [Option<u16>] {
&[None, Some(4), Some(5), Some(6), Some(7), Some(8), Some(9), Some(10), Some(11), Some(12)]
}
fn supported_aspect_ratios() -> &'static [Option<CommonAspectRatio>] {
&[
None,
Some(CommonAspectRatio::Auto),
Some(CommonAspectRatio::Square),
Some(CommonAspectRatio::WideFourByThree),
Some(CommonAspectRatio::WideSixteenByNine),
Some(CommonAspectRatio::Wide),
Some(CommonAspectRatio::WideTwentyOneByNine),
Some(CommonAspectRatio::TallThreeByFour),
Some(CommonAspectRatio::TallNineBySixteen),
Some(CommonAspectRatio::Tall),
]
}
fn audio_options() -> &'static [Option<bool>] {
&[None, Some(true), Some(false)]
}
#[test]
fn cost_parity_t2v_combinatorial() {
let mut combos_checked = 0;
for &resolution in all_resolutions() {
for &duration in all_durations() {
for &aspect_ratio in supported_aspect_ratios() {
for &generate_audio in audio_options() {
let mut builder = t2v_builder();
builder.resolution = resolution;
builder.duration_seconds = duration;
builder.aspect_ratio = aspect_ratio;
builder.generate_audio = generate_audio;
let v1 = v1_cost(&builder);
let v2 = v2_cost(builder.clone());
assert_eq!(
v1, v2,
"1.5 pro t2v cost mismatch: res={:?} dur={:?} ar={:?} audio={:?} → v1={:?} v2={:?}",
resolution, duration, aspect_ratio, generate_audio, v1, v2,
);
combos_checked += 1;
}
}
}
}
assert!(combos_checked >= 1000, "expected ≥1000 combos, got {}", combos_checked);
}
#[test]
fn cost_parity_i2v_combinatorial() {
let mut combos_checked = 0;
let mut combos_with_end_frame = 0;
for &resolution in all_resolutions() {
for &duration in all_durations() {
for &aspect_ratio in supported_aspect_ratios() {
for &generate_audio in audio_options() {
for include_end_frame in [false, true] {
let mut builder = i2v_builder();
builder.resolution = resolution;
builder.duration_seconds = duration;
builder.aspect_ratio = aspect_ratio;
builder.generate_audio = generate_audio;
if include_end_frame {
builder.end_frame = Some(ImageRef::Url(DUMMY_END_FRAME.to_string()));
combos_with_end_frame += 1;
}
let v1 = v1_cost(&builder);
let v2 = v2_cost(builder.clone());
assert_eq!(
v1, v2,
"1.5 pro i2v cost mismatch: res={:?} dur={:?} ar={:?} audio={:?} end={} → v1={:?} v2={:?}",
resolution, duration, aspect_ratio, generate_audio, include_end_frame, v1, v2,
);
combos_checked += 1;
}
}
}
}
}
assert!(combos_checked >= 2000, "expected ≥2000 combos, got {}", combos_checked);
assert!(combos_with_end_frame > 0, "expected at least one with end_frame");
}
#[test]
fn cost_parity_with_pay_less_downgrade_strategy() {
// Sanity: even when the mismatch strategy downgrades unsupported
// resolutions/durations, v1 and v2 should pick the same fallback.
let oddities: &[(Option<CommonResolution>, Option<u16>)] = &[
(Some(CommonResolution::OneK), None),
(Some(CommonResolution::TwoK), Some(5)),
(Some(CommonResolution::FourK), Some(12)),
(None, Some(13)),
];
for &(resolution, duration) in oddities {
let mut builder = t2v_builder();
builder.resolution = resolution;
builder.duration_seconds = duration;
builder.request_mismatch_mitigation_strategy =
RequestMismatchMitigationStrategy::PayLessDowngrade;
let v1 = v1_cost(&builder);
let v2 = v2_cost(builder.clone());
assert_eq!(v1, v2, "PayLessDowngrade mismatch at res={:?} dur={:?}", resolution, duration);
}
}
#[test]
fn cost_parity_with_pay_more_upgrade_strategy() {
let oddities: &[(Option<CommonResolution>, Option<u16>)] = &[
(Some(CommonResolution::OneK), None),
(Some(CommonResolution::TwoK), Some(5)),
(Some(CommonResolution::FourK), Some(13)),
(None, Some(20)),
];
for &(resolution, duration) in oddities {
let mut builder = t2v_builder();
builder.resolution = resolution;
builder.duration_seconds = duration;
builder.request_mismatch_mitigation_strategy =
RequestMismatchMitigationStrategy::PayMoreUpgrade;
let v1 = v1_cost(&builder);
let v2 = v2_cost(builder.clone());
assert_eq!(v1, v2, "PayMoreUpgrade mismatch at res={:?} dur={:?}", resolution, duration);
}
}
}
// ── helpers ──
/// Returns `Some(cost_in_usd_cents)` from the v1 pipeline. If v1 errors out
/// (e.g. for an unsupported resolution with `ErrorOut` strategy), returns `None`
/// so the parity test can compare apples to apples.
fn v1_cost(builder: &GenerateVideoRequestBuilder) -> Option<u64> {
builder
.build()
.ok()
.and_then(|plan| plan.estimate_costs().cost_in_usd_cents)
}
/// Same as `v1_cost`, but for the v2 pipeline.
fn v2_cost(builder: GenerateVideoRequestBuilder) -> Option<u64> {
builder
.build2()
.ok()
.and_then(|dor| dor.estimate_cost().ok())
.and_then(|estimate| estimate.cost_in_usd_cents)
}
@@ -0,0 +1,5 @@
pub mod seedance_1p0_lite;
pub mod seedance_1p5_pro;
#[cfg(test)]
mod cost_parity_tests;
@@ -0,0 +1,155 @@
use fal_client::requests::webhook::video::image::enqueue_seedance_1_lite_image_to_video_webhook::{
Seedance1LiteAspectRatio, Seedance1LiteDuration, Seedance1LiteRequest, Seedance1LiteResolution,
};
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;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video_v2::providers::fal::seedance_1p0_lite::request::FalSeedance10LiteRequestState;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest;
pub fn build_fal_seedance_1p0_lite(
mut builder: GenerateVideoRequestBuilder,
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
let image_url = require_url(
builder.start_frame.take(),
"start_frame",
"Seedance 1.0 Lite requires a starting frame",
)?;
let end_frame_image_url = optional_url(builder.end_frame.take())?;
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?;
let resolution = plan_resolution(builder.resolution.take(), strategy)?;
let duration = plan_duration(builder.duration_seconds.take(), strategy)?;
let prompt = builder.prompt.take().unwrap_or_default();
let request = Seedance1LiteRequest {
image_url,
end_frame_image_url,
prompt,
duration,
resolution,
aspect_ratio,
camera_fixed: false,
seed: None,
};
let state = FalSeedance10LiteRequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalSeedance10Lite(state)))
}
// ── Plan helpers (copies of v1 logic; kept in sync intentionally) ──
fn require_url(
start_frame: Option<ImageRef>,
field: &'static str,
msg: &'static str,
) -> Result<String, ArtcraftRouterError> {
match start_frame {
Some(ImageRef::Url(url)) => Ok(url),
Some(ImageRef::MediaFileToken(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field,
value: "Fal only supports image URLs, not media file tokens".to_string(),
}))
}
None => Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field,
value: msg.to_string(),
})),
}
}
fn optional_url(image_ref: Option<ImageRef>) -> Result<Option<String>, ArtcraftRouterError> {
match image_ref {
None => Ok(None),
Some(ImageRef::Url(url)) => Ok(Some(url)),
Some(ImageRef::MediaFileToken(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "end_frame",
value: "Fal only supports image URLs, not media file tokens".to_string(),
}))
}
}
}
fn plan_aspect_ratio(
aspect_ratio: Option<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<Seedance1LiteAspectRatio>, ArtcraftRouterError> {
use Seedance1LiteAspectRatio as Ar;
match aspect_ratio {
None => Ok(None),
Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto4k) => Ok(Some(Ar::Auto)),
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => Ok(Some(Ar::Square)),
Some(CommonAspectRatio::WideFourByThree) => Ok(Some(Ar::FourByThree)),
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)),
Some(CommonAspectRatio::WideTwentyOneByNine) => Ok(Some(Ar::TwentyOneByNine)),
Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(Ar::ThreeByFour)),
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,
) -> Result<Seedance1LiteResolution, ArtcraftRouterError> {
use Seedance1LiteResolution as R;
match resolution {
None => Ok(R::SevenTwentyP),
Some(CommonResolution::FourEightyP) => Ok(R::FourEightyP),
Some(CommonResolution::SevenTwentyP) => Ok(R::SevenTwentyP),
Some(CommonResolution::TenEightyP) => Ok(R::TenEightyP),
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "resolution",
value: format!("{:?}", other),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(R::TenEightyP),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(R::FourEightyP),
},
}
}
fn plan_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Seedance1LiteDuration, ArtcraftRouterError> {
match duration_seconds {
None => Ok(Seedance1LiteDuration::FiveSeconds),
Some(5) => Ok(Seedance1LiteDuration::FiveSeconds),
Some(10) => Ok(Seedance1LiteDuration::TenSeconds),
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "duration_seconds",
value: format!("{}", other),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Seedance1LiteDuration::TenSeconds),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Seedance1LiteDuration::FiveSeconds),
},
}
}
@@ -0,0 +1,29 @@
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video_v2::providers::fal::seedance_1p0_lite::request::FalSeedance10LiteRequestState;
pub struct FalSeedance10LiteCostState {
request: FalSeedance10LiteRequestState,
}
impl FalSeedance10LiteCostState {
pub fn from_request(request: &FalSeedance10LiteRequestState) -> Self {
Self { request: request.clone() }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Mirrors v1 — delegate to the Fal client's cost calculator for billing parity.
let cost_in_usd_cents = self.request.request.calculate_cost_in_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,
failures_are_refunded: None,
}
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,40 @@
use std::fmt::Debug;
use std::sync::Arc;
use fal_client::requests::webhook::video::image::enqueue_seedance_1_lite_image_to_video_webhook::{
enqueue_seedance_1_lite_image_to_video_webhook, Seedance1LiteArgs, Seedance1LiteRequest,
};
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_video::generate_video_response::{
FalVideoResponsePayload, GenerateVideoResponse,
};
#[derive(Clone, Debug)]
pub struct FalSeedance10LiteRequestState {
pub request: Seedance1LiteRequest,
}
impl FalSeedance10LiteRequestState {
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let outbound_request: Arc<dyn Debug + Send + Sync> = Arc::new(self.request.clone());
let args = Seedance1LiteArgs {
request: self.request.clone(),
api_key: &client.api_key,
webhook_url: client.webhook_url.as_str(),
};
let webhook_response = enqueue_seedance_1_lite_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,
maybe_outbound_request: Some(outbound_request),
}))
}
}
@@ -0,0 +1,286 @@
use fal_client::requests::webhook::video::image::enqueue_seedance_1p5_pro_image_to_video_webhook::{
EnqueueSeedance1p5ProImageToVideoAspectRatio, EnqueueSeedance1p5ProImageToVideoDuration,
EnqueueSeedance1p5ProImageToVideoRequest, EnqueueSeedance1p5ProImageToVideoResolution,
};
use fal_client::requests::webhook::video::text::enqueue_seedance_1p5_pro_text_to_video_webhook::{
EnqueueSeedance1p5ProTextToVideoAspectRatio, EnqueueSeedance1p5ProTextToVideoDuration,
EnqueueSeedance1p5ProTextToVideoRequest, EnqueueSeedance1p5ProTextToVideoResolution,
};
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;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video_v2::providers::fal::seedance_1p5_pro::request::{
FalSeedance1p5ProMode, FalSeedance1p5ProRequestState,
};
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest;
// Resolution / duration / aspect-ratio variants used for planning. Mirrors the
// v1 plan types, but lives entirely inside this module so the v2 dir is self-contained.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
enum PlanResolution {
FourEightyP,
SevenTwentyP,
TenEightyP,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
enum PlanDuration {
Four,
Five,
Six,
Seven,
Eight,
Nine,
Ten,
Eleven,
Twelve,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
enum PlanAspectRatio {
Auto,
TwentyOneByNine,
SixteenByNine,
FourByThree,
Square,
ThreeByFour,
NineBySixteen,
}
pub fn build_fal_seedance_1p5_pro(
mut builder: GenerateVideoRequestBuilder,
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
// Decide t2v vs i2v based on whether a start_frame was provided.
let image_url = optional_url(builder.start_frame.take())?;
let end_image_url = optional_url(builder.end_frame.take())?;
if image_url.is_none() && end_image_url.is_some() {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "end_frame",
value: "Seedance 1.5 Pro requires a start_frame when end_frame is provided".to_string(),
}));
}
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?;
let resolution = plan_resolution(builder.resolution.take(), strategy)?;
let duration = plan_duration(builder.duration_seconds.take(), strategy)?;
let prompt = builder.prompt.take().unwrap_or_default();
let generate_audio = builder.generate_audio.take();
let mode = match image_url {
None => FalSeedance1p5ProMode::TextToVideo(EnqueueSeedance1p5ProTextToVideoRequest {
prompt,
resolution: resolution.map(to_t2v_resolution),
duration: duration.map(to_t2v_duration),
aspect_ratio: aspect_ratio.map(to_t2v_aspect_ratio),
generate_audio,
}),
Some(image_url) => FalSeedance1p5ProMode::ImageToVideo(EnqueueSeedance1p5ProImageToVideoRequest {
prompt,
image_url,
end_image_url,
resolution: resolution.map(to_i2v_resolution),
duration: duration.map(to_i2v_duration),
aspect_ratio: aspect_ratio.map(to_i2v_aspect_ratio),
generate_audio,
}),
};
let state = FalSeedance1p5ProRequestState { mode };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalSeedance1p5Pro(state)))
}
// ── Plan helpers (kept in sync with v1 plan_generate_video_fal_seedance_1p5_pro.rs) ──
fn optional_url(image_ref: Option<ImageRef>) -> Result<Option<String>, ArtcraftRouterError> {
match image_ref {
None => Ok(None),
Some(ImageRef::Url(url)) => Ok(Some(url)),
Some(ImageRef::MediaFileToken(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "start_frame/end_frame",
value: "Fal only supports image URLs, not media file tokens".to_string(),
}))
}
}
}
fn plan_aspect_ratio(
aspect_ratio: Option<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<PlanAspectRatio>, ArtcraftRouterError> {
use PlanAspectRatio as Ar;
match aspect_ratio {
None => Ok(None),
Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto4k) => Ok(Some(Ar::Auto)),
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => Ok(Some(Ar::Square)),
Some(CommonAspectRatio::WideFourByThree) => Ok(Some(Ar::FourByThree)),
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)),
Some(CommonAspectRatio::WideTwentyOneByNine) => Ok(Some(Ar::TwentyOneByNine)),
Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(Ar::ThreeByFour)),
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(nearest_aspect_ratio(unsupported))),
},
}
}
/// Pick the nearest supported aspect ratio for unsupported inputs.
fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> PlanAspectRatio {
use PlanAspectRatio as Ar;
match aspect_ratio {
CommonAspectRatio::WideFiveByFour => Ar::FourByThree,
CommonAspectRatio::WideThreeByTwo => Ar::FourByThree,
CommonAspectRatio::TallFourByFive => Ar::ThreeByFour,
CommonAspectRatio::TallTwoByThree => Ar::ThreeByFour,
CommonAspectRatio::TallNineByTwentyOne => Ar::NineBySixteen,
_ => Ar::Square,
}
}
fn plan_resolution(
resolution: Option<CommonResolution>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<PlanResolution>, ArtcraftRouterError> {
use PlanResolution as R;
match resolution {
None => Ok(None),
Some(CommonResolution::FourEightyP) => Ok(Some(R::FourEightyP)),
Some(CommonResolution::SevenTwentyP) => Ok(Some(R::SevenTwentyP)),
Some(CommonResolution::TenEightyP) => Ok(Some(R::TenEightyP)),
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "resolution",
value: format!("{:?}", other),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(R::TenEightyP)),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(R::FourEightyP)),
},
}
}
fn plan_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<PlanDuration>, ArtcraftRouterError> {
use PlanDuration as D;
match duration_seconds {
None => Ok(None),
Some(4) => Ok(Some(D::Four)),
Some(5) => Ok(Some(D::Five)),
Some(6) => Ok(Some(D::Six)),
Some(7) => Ok(Some(D::Seven)),
Some(8) => Ok(Some(D::Eight)),
Some(9) => Ok(Some(D::Nine)),
Some(10) => Ok(Some(D::Ten)),
Some(11) => Ok(Some(D::Eleven)),
Some(12) => Ok(Some(D::Twelve)),
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "duration_seconds",
value: format!("{}", other),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(D::Twelve)),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(D::Four)),
},
}
}
// ── PlanXxx → fal_client request enum mapping ──
fn to_t2v_resolution(r: PlanResolution) -> EnqueueSeedance1p5ProTextToVideoResolution {
use EnqueueSeedance1p5ProTextToVideoResolution as R;
match r {
PlanResolution::FourEightyP => R::FourEightyP,
PlanResolution::SevenTwentyP => R::SevenTwentyP,
PlanResolution::TenEightyP => R::TenEightyP,
}
}
fn to_t2v_duration(d: PlanDuration) -> EnqueueSeedance1p5ProTextToVideoDuration {
use EnqueueSeedance1p5ProTextToVideoDuration as D;
match d {
PlanDuration::Four => D::FourSeconds,
PlanDuration::Five => D::FiveSeconds,
PlanDuration::Six => D::SixSeconds,
PlanDuration::Seven => D::SevenSeconds,
PlanDuration::Eight => D::EightSeconds,
PlanDuration::Nine => D::NineSeconds,
PlanDuration::Ten => D::TenSeconds,
PlanDuration::Eleven => D::ElevenSeconds,
PlanDuration::Twelve => D::TwelveSeconds,
}
}
fn to_t2v_aspect_ratio(a: PlanAspectRatio) -> EnqueueSeedance1p5ProTextToVideoAspectRatio {
use EnqueueSeedance1p5ProTextToVideoAspectRatio as Ar;
match a {
PlanAspectRatio::Auto => Ar::Auto,
PlanAspectRatio::TwentyOneByNine => Ar::TwentyOneByNine,
PlanAspectRatio::SixteenByNine => Ar::SixteenByNine,
PlanAspectRatio::FourByThree => Ar::FourByThree,
PlanAspectRatio::Square => Ar::Square,
PlanAspectRatio::ThreeByFour => Ar::ThreeByFour,
PlanAspectRatio::NineBySixteen => Ar::NineBySixteen,
}
}
fn to_i2v_resolution(r: PlanResolution) -> EnqueueSeedance1p5ProImageToVideoResolution {
use EnqueueSeedance1p5ProImageToVideoResolution as R;
match r {
PlanResolution::FourEightyP => R::FourEightyP,
PlanResolution::SevenTwentyP => R::SevenTwentyP,
PlanResolution::TenEightyP => R::TenEightyP,
}
}
fn to_i2v_duration(d: PlanDuration) -> EnqueueSeedance1p5ProImageToVideoDuration {
use EnqueueSeedance1p5ProImageToVideoDuration as D;
match d {
PlanDuration::Four => D::FourSeconds,
PlanDuration::Five => D::FiveSeconds,
PlanDuration::Six => D::SixSeconds,
PlanDuration::Seven => D::SevenSeconds,
PlanDuration::Eight => D::EightSeconds,
PlanDuration::Nine => D::NineSeconds,
PlanDuration::Ten => D::TenSeconds,
PlanDuration::Eleven => D::ElevenSeconds,
PlanDuration::Twelve => D::TwelveSeconds,
}
}
fn to_i2v_aspect_ratio(a: PlanAspectRatio) -> EnqueueSeedance1p5ProImageToVideoAspectRatio {
use EnqueueSeedance1p5ProImageToVideoAspectRatio as Ar;
match a {
PlanAspectRatio::Auto => Ar::Auto,
PlanAspectRatio::TwentyOneByNine => Ar::TwentyOneByNine,
PlanAspectRatio::SixteenByNine => Ar::SixteenByNine,
PlanAspectRatio::FourByThree => Ar::FourByThree,
PlanAspectRatio::Square => Ar::Square,
PlanAspectRatio::ThreeByFour => Ar::ThreeByFour,
PlanAspectRatio::NineBySixteen => Ar::NineBySixteen,
}
}
@@ -0,0 +1,76 @@
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
use fal_client::requests::webhook::video::text::enqueue_seedance_1p5_pro_text_to_video_webhook::{
EnqueueSeedance1p5ProTextToVideoDuration, EnqueueSeedance1p5ProTextToVideoRequest,
EnqueueSeedance1p5ProTextToVideoResolution,
};
use fal_client::requests::webhook::video::image::enqueue_seedance_1p5_pro_image_to_video_webhook::{
EnqueueSeedance1p5ProImageToVideoDuration, EnqueueSeedance1p5ProImageToVideoResolution,
};
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video_v2::providers::fal::seedance_1p5_pro::request::{
FalSeedance1p5ProMode, FalSeedance1p5ProRequestState,
};
pub struct FalSeedance1p5ProCostState {
request: FalSeedance1p5ProRequestState,
}
impl FalSeedance1p5ProCostState {
pub fn from_request(request: &FalSeedance1p5ProRequestState) -> Self {
Self { request: request.clone() }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Seedance 1.5 Pro t2v and i2v have identical pricing. v1 delegates both
// to the t2v calculator; v2 does the same to guarantee billing parity.
let t2v_request = match &self.request.mode {
FalSeedance1p5ProMode::TextToVideo(req) => req.clone(),
FalSeedance1p5ProMode::ImageToVideo(req) => EnqueueSeedance1p5ProTextToVideoRequest {
prompt: String::new(),
resolution: req.resolution.map(i2v_to_t2v_resolution),
duration: req.duration.map(i2v_to_t2v_duration),
aspect_ratio: None,
generate_audio: req.generate_audio,
},
};
let cost_in_usd_cents = t2v_request.calculate_cost_in_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,
failures_are_refunded: None,
}
}
}
fn i2v_to_t2v_resolution(r: EnqueueSeedance1p5ProImageToVideoResolution) -> EnqueueSeedance1p5ProTextToVideoResolution {
use EnqueueSeedance1p5ProImageToVideoResolution as I;
use EnqueueSeedance1p5ProTextToVideoResolution as T;
match r {
I::FourEightyP => T::FourEightyP,
I::SevenTwentyP => T::SevenTwentyP,
I::TenEightyP => T::TenEightyP,
}
}
fn i2v_to_t2v_duration(d: EnqueueSeedance1p5ProImageToVideoDuration) -> EnqueueSeedance1p5ProTextToVideoDuration {
use EnqueueSeedance1p5ProImageToVideoDuration as I;
use EnqueueSeedance1p5ProTextToVideoDuration as T;
match d {
I::FourSeconds => T::FourSeconds,
I::FiveSeconds => T::FiveSeconds,
I::SixSeconds => T::SixSeconds,
I::SevenSeconds => T::SevenSeconds,
I::EightSeconds => T::EightSeconds,
I::NineSeconds => T::NineSeconds,
I::TenSeconds => T::TenSeconds,
I::ElevenSeconds => T::ElevenSeconds,
I::TwelveSeconds => T::TwelveSeconds,
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,63 @@
use std::fmt::Debug;
use std::sync::Arc;
use fal_client::requests::webhook::video::image::enqueue_seedance_1p5_pro_image_to_video_webhook::{
enqueue_seedance_1p5_pro_image_to_video_webhook, EnqueueSeedance1p5ProImageToVideoArgs,
EnqueueSeedance1p5ProImageToVideoRequest,
};
use fal_client::requests::webhook::video::text::enqueue_seedance_1p5_pro_text_to_video_webhook::{
enqueue_seedance_1p5_pro_text_to_video_webhook, EnqueueSeedance1p5ProTextToVideoArgs,
EnqueueSeedance1p5ProTextToVideoRequest,
};
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_video::generate_video_response::{
FalVideoResponsePayload, GenerateVideoResponse,
};
#[derive(Clone, Debug)]
pub enum FalSeedance1p5ProMode {
TextToVideo(EnqueueSeedance1p5ProTextToVideoRequest),
ImageToVideo(EnqueueSeedance1p5ProImageToVideoRequest),
}
#[derive(Clone, Debug)]
pub struct FalSeedance1p5ProRequestState {
pub mode: FalSeedance1p5ProMode,
}
impl FalSeedance1p5ProRequestState {
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let (webhook_response, outbound_request): (_, Arc<dyn Debug + Send + Sync>) = match &self.mode {
FalSeedance1p5ProMode::TextToVideo(request) => {
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = EnqueueSeedance1p5ProTextToVideoArgs {
request: request.clone(),
webhook_url: client.webhook_url.as_str(),
api_key: &client.api_key,
};
(enqueue_seedance_1p5_pro_text_to_video_webhook(args).await, outbound)
}
FalSeedance1p5ProMode::ImageToVideo(request) => {
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = EnqueueSeedance1p5ProImageToVideoArgs {
request: request.clone(),
webhook_url: client.webhook_url.as_str(),
api_key: &client.api_key,
};
(enqueue_seedance_1p5_pro_image_to_video_webhook(args).await, outbound)
}
};
let webhook_response = webhook_response
.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,
maybe_outbound_request: Some(outbound_request),
}))
}
}
@@ -1,4 +1,5 @@
pub mod artcraft;
pub mod fal;
pub mod gmicloud;
pub mod grok_api;
pub mod kinovi;
@@ -23,6 +23,10 @@ use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_bp::co
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_bp::request::ArtcraftSeedance2p0BytePlusRequestState;
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_bp_fast::cost::ArtcraftSeedance2p0BytePlusFastCostState;
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_bp_fast::request::ArtcraftSeedance2p0BytePlusFastRequestState;
use crate::generate::generate_video_v2::providers::fal::seedance_1p0_lite::cost::FalSeedance10LiteCostState;
use crate::generate::generate_video_v2::providers::fal::seedance_1p0_lite::request::FalSeedance10LiteRequestState;
use crate::generate::generate_video_v2::providers::fal::seedance_1p5_pro::cost::FalSeedance1p5ProCostState;
use crate::generate::generate_video_v2::providers::fal::seedance_1p5_pro::request::FalSeedance1p5ProRequestState;
use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_g::cost::GmiCloudSeedance2p0UltraCostState;
use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_g::request::GmiCloudSeedance2p0UltraRequestState;
use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_fast_g::cost::GmiCloudSeedance2p0UltraFastCostState;
@@ -48,6 +52,8 @@ pub enum VideoGenerationRequest {
ArtcraftSeedance2p0UltraFast(ArtcraftSeedance2p0UltraFastRequestState),
ArtcraftSeedance2p0BytePlus(ArtcraftSeedance2p0BytePlusRequestState),
ArtcraftSeedance2p0BytePlusFast(ArtcraftSeedance2p0BytePlusFastRequestState),
FalSeedance10Lite(FalSeedance10LiteRequestState),
FalSeedance1p5Pro(FalSeedance1p5ProRequestState),
GmiCloudSeedance2p0Ultra(GmiCloudSeedance2p0UltraRequestState),
GmiCloudSeedance2p0UltraFast(GmiCloudSeedance2p0UltraFastRequestState),
GrokApiGrokImagineVideo(GrokApiGrokImagineVideoRequestState),
@@ -70,6 +76,8 @@ impl VideoGenerationRequest {
Self::ArtcraftSeedance2p0UltraFast(_) => Provider::Artcraft,
Self::ArtcraftSeedance2p0BytePlus(_) => Provider::Artcraft,
Self::ArtcraftSeedance2p0BytePlusFast(_) => Provider::Artcraft,
Self::FalSeedance10Lite(_) => Provider::Fal,
Self::FalSeedance1p5Pro(_) => Provider::Fal,
Self::GmiCloudSeedance2p0Ultra(_) => Provider::GmiCloud,
Self::GmiCloudSeedance2p0UltraFast(_) => Provider::GmiCloud,
Self::GrokApiGrokImagineVideo(_) => Provider::GrokApi,
@@ -92,6 +100,8 @@ impl VideoGenerationRequest {
VideoGenerationRequest::ArtcraftSeedance2p0UltraFast(request) => Ok(ArtcraftSeedance2p0UltraFastCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftSeedance2p0BytePlus(request) => Ok(ArtcraftSeedance2p0BytePlusCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusFast(request) => Ok(ArtcraftSeedance2p0BytePlusFastCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::FalSeedance10Lite(request) => Ok(FalSeedance10LiteCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::FalSeedance1p5Pro(request) => Ok(FalSeedance1p5ProCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::GmiCloudSeedance2p0Ultra(request) => Ok(GmiCloudSeedance2p0UltraCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::GmiCloudSeedance2p0UltraFast(request) => Ok(GmiCloudSeedance2p0UltraFastCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::GrokApiGrokImagineVideo(request) => Ok(GrokApiGrokImagineVideoCostState::from_request(request).estimate_cost()),
@@ -145,6 +155,14 @@ impl VideoGenerationRequest {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::FalSeedance10Lite(request) => {
let client_ref = client.get_fal_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::FalSeedance1p5Pro(request) => {
let client_ref = client.get_fal_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::GmiCloudSeedance2p0Ultra(request) => {
let client_ref = client.get_gmicloud_client_ref()?;
request.send(client_ref).await