Compare commits

..
Author SHA1 Message Date
Brandon Thomas d0d17756fd codegen 2026-07-06 16:11:16 -04:00
Brandon Thomas 84bf2d934e executor 2026-07-06 16:10:56 -04:00
Brandon Thomas aba271e413 combined query 2026-07-06 15:58:55 -04:00
297 changed files with 2481 additions and 19716 deletions
Generated
-1
View File
@@ -765,7 +765,6 @@ dependencies = [
"serde_json",
"strum",
"test_data",
"test_utils",
"tokens",
"tokio",
"url_utils",
@@ -1,3 +1,2 @@
pub mod omni_gen_audio_cost_and_generate_request;
pub mod omni_gen_image_cost_and_generate_request;
pub mod omni_gen_video_cost_and_generate_request;
@@ -1,59 +0,0 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use enums::common::generation::common_audio_model::CommonAudioModel;
use enums::common::generation::common_musical_key::CommonMusicalKey;
use tokens::tokens::media_files::MediaFileToken;
/// Shared request body for both the audio cost estimate and audio generation endpoints.
#[derive(Clone, Serialize, Deserialize, ToSchema, Debug)]
pub struct OmniGenAudioCostAndGenerateRequest {
/// REQUIRED (even if marked optional)
/// Idempotency token to prevent duplicate requests.
pub idempotency_token: Option<String>,
/// REQUIRED (even if marked optional)
/// Which model to use.
pub model: Option<CommonAudioModel>,
/// The text prompt for the audio generation.
pub prompt: Option<String>,
/// Style/genre direction (Suno's "tags").
pub style_prompt: Option<String>,
/// Reference audio (optional).
/// The remix/sample source, or Seed Audio @Audio references (up to 3).
pub audio_media_tokens: Option<Vec<MediaFileToken>>,
/// Reference image (optional).
/// Seed Audio supports a single reference image; cannot be combined with audio references.
pub image_media_tokens: Option<Vec<MediaFileToken>>,
/// Whether to keep the original lyrics (Suno Remix).
pub keep_lyrics: Option<bool>,
/// Whether to generate instrumental-only audio (Suno Music / Sample).
pub is_instrumental: Option<bool>,
/// Whether the sound should loop vs a single hit (Suno Sounds).
pub is_loopable: Option<bool>,
/// Beats per minute (Suno Sounds).
pub bpm: Option<u16>,
/// The musical key to use (Suno Sounds).
pub musical_key: Option<CommonMusicalKey>,
/// Output sample rate in Hz (Seed Audio: 8000/16000/24000/32000/44100/48000).
pub sample_rate_hz: Option<u32>,
/// Playback speed multiplier (Seed Audio: 0.5–2.0).
pub speed: Option<f64>,
/// Volume multiplier (Seed Audio: 0.5–2.0).
pub volume: Option<f64>,
/// Pitch shift in semitones (Seed Audio: -12..=12).
pub pitch: Option<f64>,
}
@@ -1,3 +1,2 @@
pub mod omni_gen_audio_cost_response;
pub mod omni_gen_image_cost_response;
pub mod omni_gen_video_cost_response;
@@ -1,32 +0,0 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
/// Response body for the omni-gen audio cost estimate endpoint.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct OmniGenAudioCostResponse {
pub success: bool,
/// Estimated cost in credits.
pub cost_in_credits: Option<u64>,
/// Estimated cost in USD cents.
pub cost_in_usd_cents: Option<u64>,
/// Whether the generation is free for this user/plan.
pub is_free: bool,
/// Whether the user has unlimited generations.
pub is_unlimited: bool,
/// Whether the user is rate-limited.
pub is_rate_limited: bool,
/// Whether the output will have a watermark.
pub has_watermark: bool,
/// Whether failures are refunded.
/// True: 100% yes
/// False: 100% no
/// None: Unknown or variable
pub failures_are_refunded: Option<bool>,
}
@@ -1,3 +1,2 @@
pub mod omni_gen_audio_generate_response;
pub mod omni_gen_image_generate_response;
pub mod omni_gen_video_generate_response;
@@ -1,16 +0,0 @@
use serde_derive::{Deserialize, Serialize};
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use utoipa::ToSchema;
/// Response body for the omni-gen audio generation endpoint.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct OmniGenAudioGenerateResponse {
pub success: bool,
pub inference_job_token: InferenceJobToken,
/// All job tokens created by this request (including the primary).
/// For single-job requests this will contain just one element matching
/// `inference_job_token`. For batch requests this will contain all of them.
pub all_job_tokens: Vec<InferenceJobToken>,
}
@@ -1,3 +1,2 @@
pub mod omni_gen_audio_models;
pub mod omni_gen_image_models;
pub mod omni_gen_video_models;
@@ -1,139 +0,0 @@
use enums::common::generation::common_audio_model::CommonAudioModel;
use enums::common::generation::model_creator::ModelCreator;
use enums::common::generation_provider::GenerationProvider;
use serde_derive::Serialize;
use utoipa::ToSchema;
/// Audio model to default to if none is specified
const DEFAULT_AUDIO_MODEL : CommonAudioModel = CommonAudioModel::SunoMusic;
/// Response body for the audio models endpoint.
#[derive(Serialize, ToSchema, Clone)]
pub struct OmniGenAudioModelsResponse {
pub success: bool,
/// A list of all models: details, features, and capabilities
pub models: Vec<OmniGenAudioModelDetails>,
/// Provider-by-provider model offering and capability list,
/// with possible capability overrides (future)
pub providers: Vec<OmniGenAudioModelProviderDetails>,
}
#[derive(Serialize, ToSchema, Clone)]
pub struct OmniGenAudioModelProviderDetails {
pub provider: GenerationProvider,
pub models: Vec<OmniGenAudioProviderModelDetails>,
}
#[derive(Serialize, ToSchema, Clone)]
pub struct OmniGenAudioProviderModelDetails {
pub model: CommonAudioModel,
#[serde(skip_serializing_if = "Option::is_none")]
pub overrides: Option<OmniGenAudioModelDetails>,
}
#[derive(Serialize, ToSchema, Clone)]
pub struct OmniGenAudioModelDetails {
pub model: CommonAudioModel,
#[serde(skip_serializing_if = "Option::is_none")]
pub model_creator: Option<ModelCreator>,
#[serde(skip_serializing_if = "Option::is_none")]
pub full_name: Option<String>,
/// Additional details about the model.
#[serde(skip_serializing_if = "Option::is_none")]
pub extra_info: Option<String>,
/// Additional details about the model. (Brief; only a few words.)
#[serde(skip_serializing_if = "Option::is_none")]
pub extra_info_short: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text_prompt_supported: Option<bool>,
/// Whether a style/genre prompt (Suno's "tags") is supported.
#[serde(skip_serializing_if = "Option::is_none")]
pub style_prompt_supported: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub audio_references_supported: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub audio_references_max: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image_references_supported: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image_references_max: Option<u16>,
/// Whether the "keep lyrics" toggle is supported (Suno Remix).
#[serde(skip_serializing_if = "Option::is_none")]
pub keep_lyrics_supported: Option<bool>,
/// Whether the instrumental-only toggle is supported (Suno Music / Sample).
#[serde(skip_serializing_if = "Option::is_none")]
pub instrumental_toggle_supported: Option<bool>,
/// Whether the loop vs single-hit toggle is supported (Suno Sounds).
#[serde(skip_serializing_if = "Option::is_none")]
pub loopable_toggle_supported: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bpm_supported: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub musical_key_supported: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sample_rate_hz_options: Option<Vec<u32>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sample_rate_hz_default: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub speed_supported: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub volume_supported: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pitch_supported: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_disabled: Option<bool>,
}
impl Default for OmniGenAudioModelDetails {
fn default() -> Self {
Self {
model: DEFAULT_AUDIO_MODEL,
model_creator: None,
full_name: None,
extra_info: None,
extra_info_short: None,
text_prompt_supported: None,
style_prompt_supported: None,
audio_references_supported: None,
audio_references_max: None,
image_references_supported: None,
image_references_max: None,
keep_lyrics_supported: None,
instrumental_toggle_supported: None,
loopable_toggle_supported: None,
bpm_supported: None,
musical_key_supported: None,
sample_rate_hz_options: None,
sample_rate_hz_default: None,
speed_supported: None,
volume_supported: None,
pitch_supported: None,
is_disabled: None,
}
}
}
@@ -1 +0,0 @@
pub mod omni_gen_audio;
@@ -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_audio_cost_and_generate_request::OmniGenAudioCostAndGenerateRequest;
use artcraft_api_defs::omni_gen::generate_response::omni_gen_audio_generate_response::OmniGenAudioGenerateResponse;
pub const OMNI_GEN_AUDIO_GENERATE_PATH: &str = "/v1/omni_gen/generate/audio";
pub async fn omni_gen_audio_generate(
api_host: &ApiHost,
maybe_creds: Option<&StorytellerCredentialSet>,
request: OmniGenAudioCostAndGenerateRequest,
) -> Result<OmniGenAudioGenerateResponse, StorytellerError> {
Ok(basic_json_post_request(
api_host,
OMNI_GEN_AUDIO_GENERATE_PATH,
maybe_creds,
request,
).await?)
}
@@ -1,3 +1,2 @@
pub mod audio;
pub mod image;
pub mod video;
@@ -39,7 +39,6 @@ uuid.workspace = true
[dev-dependencies]
test_data.workspace = true
test_utils.workspace = true
strum.workspace = true
serde_json.workspace = true
@@ -3,7 +3,6 @@ pub mod character_list_ref;
pub mod image_list_ref;
pub mod image_ref;
pub mod router_aspect_ratio;
pub mod router_audio_model;
pub mod router_bitrate;
pub mod router_image_model;
pub mod router_provider;
@@ -1,65 +0,0 @@
use serde_derive::{Deserialize, Serialize};
/// Common audio models supported by the router.
/// Not all models are available through all providers.
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RouterAudioModel {
#[serde(rename = "suno_music")]
SunoMusic,
#[serde(rename = "suno_remix")]
SunoRemix,
#[serde(rename = "suno_sounds")]
SunoSounds,
#[serde(rename = "suno_sample")]
SunoSample,
#[serde(rename = "seed_audio_1p0")]
SeedAudio1p0,
}
#[cfg(test)]
mod tests {
use super::*;
// NB: These strings must match `CommonAudioModel` — the two enums convert
// via serde string round-trip.
#[test]
fn all_variants_serialize_to_common_audio_model_strings() {
assert_serde_round_trip(RouterAudioModel::SunoMusic, "suno_music");
assert_serde_round_trip(RouterAudioModel::SunoRemix, "suno_remix");
assert_serde_round_trip(RouterAudioModel::SunoSounds, "suno_sounds");
assert_serde_round_trip(RouterAudioModel::SunoSample, "suno_sample");
assert_serde_round_trip(RouterAudioModel::SeedAudio1p0, "seed_audio_1p0");
}
#[test]
fn round_trips_through_common_audio_model() {
use enums::common::generation::common_audio_model::CommonAudioModel;
let cases = [
(RouterAudioModel::SunoMusic, CommonAudioModel::SunoMusic),
(RouterAudioModel::SunoRemix, CommonAudioModel::SunoRemix),
(RouterAudioModel::SunoSounds, CommonAudioModel::SunoSounds),
(RouterAudioModel::SunoSample, CommonAudioModel::SunoSample),
(RouterAudioModel::SeedAudio1p0, CommonAudioModel::SeedAudio1p0),
];
for (router_model, expected_common) in cases {
let json = serde_json::to_string(&router_model).unwrap();
let common: CommonAudioModel = serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("CommonAudioModel failed to parse {json}: {e}"));
assert_eq!(common, expected_common, "for {router_model:?}");
}
}
fn assert_serde_round_trip(model: RouterAudioModel, expected: &str) {
let json = serde_json::to_string(&model).unwrap();
assert_eq!(json, format!("\"{expected}\""));
let parsed: RouterAudioModel = serde_json::from_str(&json).unwrap();
// RouterAudioModel isn't PartialEq, so round-trip back to the wire form.
assert_eq!(serde_json::to_string(&parsed).unwrap(), json);
}
}
@@ -98,15 +98,6 @@ pub enum RouterVideoModel {
#[serde(rename = "veo_3p1_fast")]
Veo3p1Fast,
#[serde(rename = "veo_3p1_lite")]
Veo3p1Lite,
#[serde(rename = "vidu_q3")]
ViduQ3,
#[serde(rename = "vidu_q3_turbo")]
ViduQ3Turbo,
#[serde(rename = "preview_model")]
PreviewModel,
@@ -132,13 +123,4 @@ mod tests {
assert_serde_round_trip(RouterVideoModel::Seedance2p0BytePlusMini, "seedance_2p0_bp_mini");
assert_serde_round_trip(RouterVideoModel::Seedance2p0BytePlusUltraMini, "seedance_2p0_bpu_mini");
}
#[test]
fn veo_3p1_lite_and_vidu_variants_serialize() {
// NB: These strings must match `CommonVideoModel` — the two enums convert
// via serde string round-trip in the omni handlers.
assert_serde_round_trip(RouterVideoModel::Veo3p1Lite, "veo_3p1_lite");
assert_serde_round_trip(RouterVideoModel::ViduQ3, "vidu_q3");
assert_serde_round_trip(RouterVideoModel::ViduQ3Turbo, "vidu_q3_turbo");
}
}
@@ -1,16 +0,0 @@
/// The result of estimating the cost of an audio generation plan.
#[derive(Clone, Debug)]
pub struct AudioGenerationCostEstimate {
pub cost_in_credits: Option<u64>,
pub cost_in_usd_cents: Option<u64>,
pub is_free: bool,
pub is_unlimited: bool,
pub is_rate_limited: bool,
pub has_watermark: bool,
/// Whether failures are refunded.
/// True: 100% yes
/// False: 100% no
/// None: Unknown or variable
pub failures_are_refunded: Option<bool>,
}
@@ -1,56 +0,0 @@
use crate::api::router_provider::RouterProvider;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_audio::audio_generation_cost_estimate::AudioGenerationCostEstimate;
use crate::generate::generate_audio::audio_generation_draft_context::AudioGenerationDraftContext;
use crate::generate::generate_audio::audio_generation_request::AudioGenerationRequest;
use crate::generate::generate_audio::providers::kinovi::suno_remix::cost::KinoviSunoRemixCostState;
use crate::generate::generate_audio::providers::kinovi::suno_remix::draft::KinoviSunoRemixDraftState;
use crate::generate::generate_audio::providers::kinovi::suno_sample::cost::KinoviSunoSampleCostState;
use crate::generate::generate_audio::providers::kinovi::suno_sample::draft::KinoviSunoSampleDraftState;
/**
* Wrapper for all audio generation draft requests.
*
* Only the Kinovi remix/sample models need a draft phase: they take a user
* audio reference that must be downloaded from our CDN and re-uploaded to the
* Kinovi CDN before the request can be sent. All other audio providers return
* a `Request` directly from `build2()`.
*/
#[derive(Clone, Debug)]
pub enum AudioGenerationDraftRequest {
KinoviSunoRemix(KinoviSunoRemixDraftState),
KinoviSunoSample(KinoviSunoSampleDraftState),
}
impl AudioGenerationDraftRequest {
pub fn get_provider(&self) -> RouterProvider {
match self {
Self::KinoviSunoRemix(_) => RouterProvider::Seedance2Pro,
Self::KinoviSunoSample(_) => RouterProvider::Seedance2Pro,
}
}
/// Return a cost estimate to fulfill the request.
pub fn estimate_cost(&self) -> Result<AudioGenerationCostEstimate, ArtcraftRouterError> {
match self {
AudioGenerationDraftRequest::KinoviSunoRemix(draft) => Ok(KinoviSunoRemixCostState::from_draft(draft).estimate_cost()),
AudioGenerationDraftRequest::KinoviSunoSample(draft) => Ok(KinoviSunoSampleCostState::from_draft(draft).estimate_cost()),
}
}
/// Finalize the draft request before generation
/// This may involve uploading media to the provider.
pub async fn finalize(self, draft_context: AudioGenerationDraftContext<'_>) -> Result<AudioGenerationRequest, ArtcraftRouterError> {
match self {
AudioGenerationDraftRequest::KinoviSunoRemix(mut draft) => {
let result = draft.to_request(&draft_context).await?;
Ok(AudioGenerationRequest::KinoviSunoRemix(result))
},
AudioGenerationDraftRequest::KinoviSunoSample(mut draft) => {
let result = draft.to_request(&draft_context).await?;
Ok(AudioGenerationRequest::KinoviSunoSample(result))
},
}
}
}
@@ -1,36 +0,0 @@
use std::collections::HashMap;
use std::fmt::Debug;
use tokens::tokens::media_files::MediaFileToken;
use crate::client::router_client::RouterClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
#[derive(Clone, Default)]
pub struct AudioGenerationDraftContext<'a> {
/// Optional: the router client, needed for providers that require authentication.
pub client: Option<&'a RouterClient>,
/// Optional context: a map of Media File Tokens to their ArtCraft URLs
/// Only needed if we have to fetch these assets and upload them to another provider.
pub media_file_to_artcraft_url_map: Option<&'a HashMap<MediaFileToken, String>>,
}
impl <'a> AudioGenerationDraftContext<'a> {
pub fn get_seedance2pro_client_ref(&self) -> Result<&RouterSeedance2ProClient, ArtcraftRouterError> {
let client = self.client.ok_or(ArtcraftRouterError::Client(ClientError::RouterClientNotProvided))?;
client.get_seedance2pro_client_ref()
.map_err(|err| ArtcraftRouterError::Client(err))
}
}
impl Debug for AudioGenerationDraftContext<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AudioGenerationDraftContext")
.field("client", &self.client.is_some())
.field("media_file_to_artcraft_url_map", &self.media_file_to_artcraft_url_map)
.finish()
}
}
@@ -1,28 +0,0 @@
use crate::api::router_provider::RouterProvider;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_audio::audio_generation_cost_estimate::AudioGenerationCostEstimate;
use crate::generate::generate_audio::audio_generation_draft::AudioGenerationDraftRequest;
use crate::generate::generate_audio::audio_generation_request::AudioGenerationRequest;
#[derive(Debug, Clone)]
pub enum AudioGenerationDraftOrRequest {
Draft(AudioGenerationDraftRequest),
Request(AudioGenerationRequest),
}
impl AudioGenerationDraftOrRequest {
pub fn get_provider(&self) -> RouterProvider {
match self {
Self::Draft(draft) => draft.get_provider(),
Self::Request(request) => request.get_provider(),
}
}
pub fn estimate_cost(&self) -> Result<AudioGenerationCostEstimate, ArtcraftRouterError> {
match self {
AudioGenerationDraftOrRequest::Draft(draft) => draft.estimate_cost(),
AudioGenerationDraftOrRequest::Request(request) => request.estimate_cost(),
}
}
}
@@ -1,120 +0,0 @@
use crate::api::router_provider::RouterProvider;
use crate::client::router_client::RouterClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_audio::audio_generation_cost_estimate::AudioGenerationCostEstimate;
use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse;
use crate::generate::generate_audio::providers::artcraft::seed_audio_1p0::cost::ArtcraftSeedAudio1p0CostState;
use crate::generate::generate_audio::providers::artcraft::seed_audio_1p0::request::ArtcraftSeedAudio1p0RequestState;
use crate::generate::generate_audio::providers::artcraft::suno_music::cost::ArtcraftSunoMusicCostState;
use crate::generate::generate_audio::providers::artcraft::suno_music::request::ArtcraftSunoMusicRequestState;
use crate::generate::generate_audio::providers::artcraft::suno_remix::cost::ArtcraftSunoRemixCostState;
use crate::generate::generate_audio::providers::artcraft::suno_remix::request::ArtcraftSunoRemixRequestState;
use crate::generate::generate_audio::providers::artcraft::suno_sample::cost::ArtcraftSunoSampleCostState;
use crate::generate::generate_audio::providers::artcraft::suno_sample::request::ArtcraftSunoSampleRequestState;
use crate::generate::generate_audio::providers::artcraft::suno_sounds::cost::ArtcraftSunoSoundsCostState;
use crate::generate::generate_audio::providers::artcraft::suno_sounds::request::ArtcraftSunoSoundsRequestState;
use crate::generate::generate_audio::providers::fal::seed_audio_1p0::cost::FalSeedAudio1p0CostState;
use crate::generate::generate_audio::providers::fal::seed_audio_1p0::request::FalSeedAudio1p0RequestState;
use crate::generate::generate_audio::providers::kinovi::suno_music::cost::KinoviSunoMusicCostState;
use crate::generate::generate_audio::providers::kinovi::suno_music::request::KinoviSunoMusicRequestState;
use crate::generate::generate_audio::providers::kinovi::suno_remix::cost::KinoviSunoRemixCostState;
use crate::generate::generate_audio::providers::kinovi::suno_remix::request::KinoviSunoRemixRequestState;
use crate::generate::generate_audio::providers::kinovi::suno_sample::cost::KinoviSunoSampleCostState;
use crate::generate::generate_audio::providers::kinovi::suno_sample::request::KinoviSunoSampleRequestState;
use crate::generate::generate_audio::providers::kinovi::suno_sounds::cost::KinoviSunoSoundsCostState;
use crate::generate::generate_audio::providers::kinovi::suno_sounds::request::KinoviSunoSoundsRequestState;
#[derive(Clone, Debug)]
pub enum AudioGenerationRequest {
ArtcraftSunoMusic(ArtcraftSunoMusicRequestState),
ArtcraftSunoRemix(ArtcraftSunoRemixRequestState),
ArtcraftSunoSounds(ArtcraftSunoSoundsRequestState),
ArtcraftSunoSample(ArtcraftSunoSampleRequestState),
ArtcraftSeedAudio1p0(ArtcraftSeedAudio1p0RequestState),
FalSeedAudio1p0(FalSeedAudio1p0RequestState),
KinoviSunoMusic(KinoviSunoMusicRequestState),
KinoviSunoRemix(KinoviSunoRemixRequestState),
KinoviSunoSounds(KinoviSunoSoundsRequestState),
KinoviSunoSample(KinoviSunoSampleRequestState),
}
impl AudioGenerationRequest {
pub fn get_provider(&self) -> RouterProvider {
match self {
Self::ArtcraftSunoMusic(_) => RouterProvider::Artcraft,
Self::ArtcraftSunoRemix(_) => RouterProvider::Artcraft,
Self::ArtcraftSunoSounds(_) => RouterProvider::Artcraft,
Self::ArtcraftSunoSample(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedAudio1p0(_) => RouterProvider::Artcraft,
Self::FalSeedAudio1p0(_) => RouterProvider::Fal,
Self::KinoviSunoMusic(_) => RouterProvider::Seedance2Pro,
Self::KinoviSunoRemix(_) => RouterProvider::Seedance2Pro,
Self::KinoviSunoSounds(_) => RouterProvider::Seedance2Pro,
Self::KinoviSunoSample(_) => RouterProvider::Seedance2Pro,
}
}
/// Return a cost estimate to fulfill the request.
pub fn estimate_cost(&self) -> Result<AudioGenerationCostEstimate, ArtcraftRouterError> {
match self {
AudioGenerationRequest::ArtcraftSunoMusic(request) => Ok(ArtcraftSunoMusicCostState::from_request(request).estimate_cost()),
AudioGenerationRequest::ArtcraftSunoRemix(request) => Ok(ArtcraftSunoRemixCostState::from_request(request).estimate_cost()),
AudioGenerationRequest::ArtcraftSunoSounds(request) => Ok(ArtcraftSunoSoundsCostState::from_request(request).estimate_cost()),
AudioGenerationRequest::ArtcraftSunoSample(request) => Ok(ArtcraftSunoSampleCostState::from_request(request).estimate_cost()),
AudioGenerationRequest::ArtcraftSeedAudio1p0(request) => Ok(ArtcraftSeedAudio1p0CostState::from_request(request).estimate_cost()),
AudioGenerationRequest::FalSeedAudio1p0(request) => Ok(FalSeedAudio1p0CostState::from_request(request).estimate_cost()),
AudioGenerationRequest::KinoviSunoMusic(request) => Ok(KinoviSunoMusicCostState::from_request(request).estimate_cost()),
AudioGenerationRequest::KinoviSunoRemix(request) => Ok(KinoviSunoRemixCostState::from_request(request).estimate_cost()),
AudioGenerationRequest::KinoviSunoSounds(request) => Ok(KinoviSunoSoundsCostState::from_request(request).estimate_cost()),
AudioGenerationRequest::KinoviSunoSample(request) => Ok(KinoviSunoSampleCostState::from_request(request).estimate_cost()),
}
}
/// Send the audio generation request
/// If successful, returns the job IDs.
pub async fn send_request(&self, client: &RouterClient) -> Result<GenerateAudioResponse, ArtcraftRouterError> {
match self {
AudioGenerationRequest::ArtcraftSunoMusic(request) => {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
},
AudioGenerationRequest::ArtcraftSunoRemix(request) => {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
},
AudioGenerationRequest::ArtcraftSunoSounds(request) => {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
},
AudioGenerationRequest::ArtcraftSunoSample(request) => {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
},
AudioGenerationRequest::ArtcraftSeedAudio1p0(request) => {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
},
AudioGenerationRequest::FalSeedAudio1p0(request) => {
let client_ref = client.get_fal_client_ref()?;
request.send(client_ref).await
},
AudioGenerationRequest::KinoviSunoMusic(request) => {
let client_ref = client.get_seedance2pro_client_ref()?;
request.send(client_ref).await
},
AudioGenerationRequest::KinoviSunoRemix(request) => {
let client_ref = client.get_seedance2pro_client_ref()?;
request.send(client_ref).await
},
AudioGenerationRequest::KinoviSunoSounds(request) => {
let client_ref = client.get_seedance2pro_client_ref()?;
request.send(client_ref).await
},
AudioGenerationRequest::KinoviSunoSample(request) => {
let client_ref = client.get_seedance2pro_client_ref()?;
request.send(client_ref).await
},
}
}
}
@@ -1,302 +0,0 @@
use enums::common::generation::common_musical_key::CommonMusicalKey;
use crate::api::audio_list_ref::AudioListRef;
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::providers::artcraft::seed_audio_1p0::build::build_artcraft_seed_audio_1p0;
use crate::generate::generate_audio::providers::artcraft::suno_music::build::build_artcraft_suno_music;
use crate::generate::generate_audio::providers::artcraft::suno_remix::build::build_artcraft_suno_remix;
use crate::generate::generate_audio::providers::artcraft::suno_sample::build::build_artcraft_suno_sample;
use crate::generate::generate_audio::providers::artcraft::suno_sounds::build::build_artcraft_suno_sounds;
use crate::generate::generate_audio::providers::fal::seed_audio_1p0::build::build_fal_seed_audio_1p0;
use crate::generate::generate_audio::providers::kinovi::suno_music::build::build_kinovi_suno_music;
use crate::generate::generate_audio::providers::kinovi::suno_remix::build::build_kinovi_suno_remix;
use crate::generate::generate_audio::providers::kinovi::suno_sample::build::build_kinovi_suno_sample;
use crate::generate::generate_audio::providers::kinovi::suno_sounds::build::build_kinovi_suno_sounds;
/// RouterProvider-agnostic audio generation request. Distilled by `build2()`
/// into an `AudioGenerationDraftOrRequest` for the selected (provider, model)
/// pair.
#[derive(Clone, Debug)]
pub struct GenerateAudioRequestBuilder {
/// Which model to use.
pub model: RouterAudioModel,
/// Which provider to use.
pub provider: RouterProvider,
/// The prompt for the audio generation.
pub prompt: Option<String>,
/// Style/genre direction (Suno's "tags"), e.g. "EDM style meets dance".
pub style_prompt: Option<String>,
/// Reference audio (optional).
/// The remix/sample source, or Seed Audio @Audio references (up to 3).
pub audio_references: Option<AudioListRef>,
/// Reference images (optional).
/// Seed Audio supports a single reference image; it cannot be combined
/// with audio references.
pub image_references: Option<ImageListRef>,
/// Whether to keep the original lyrics (Suno Remix).
pub keep_lyrics: Option<bool>,
/// Whether to generate instrumental-only audio (Suno Music / Sample).
pub is_instrumental: Option<bool>,
/// Whether the sound should loop vs a single hit (Suno Sounds).
pub is_loopable: Option<bool>,
/// Beats per minute (Suno Sounds).
pub bpm: Option<u16>,
/// The musical key to use (Suno Sounds).
pub musical_key: Option<CommonMusicalKey>,
/// Output sample rate in Hz (Seed Audio).
pub sample_rate_hz: Option<u32>,
/// Playback speed multiplier (Seed Audio: 0.5–2.0).
pub speed: Option<f64>,
/// Volume multiplier (Seed Audio: 0.5–2.0).
pub volume: Option<f64>,
/// Pitch shift in semitones (Seed Audio: -12..=12).
pub pitch: Option<f64>,
/// If the request is a mismatch with the (model/provider), how to mitigate it.
pub request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy,
/// Some providers support idempotency.
/// If not supplied, we'll generate one for the required providers.
pub idempotency_token: Option<String>,
}
impl Default for GenerateAudioRequestBuilder {
fn default() -> Self {
Self {
model: RouterAudioModel::SunoMusic,
provider: RouterProvider::Artcraft,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
prompt: None,
style_prompt: None,
audio_references: None,
image_references: None,
keep_lyrics: None,
is_instrumental: None,
is_loopable: None,
bpm: None,
musical_key: None,
sample_rate_hz: None,
speed: None,
volume: None,
pitch: None,
idempotency_token: None,
}
}
}
impl GenerateAudioRequestBuilder {
pub fn build2(self) -> Result<AudioGenerationDraftOrRequest, ArtcraftRouterError> {
match (self.provider, self.model) {
// Artcraft
(RouterProvider::Artcraft, RouterAudioModel::SunoMusic) => build_artcraft_suno_music(self),
(RouterProvider::Artcraft, RouterAudioModel::SunoRemix) => build_artcraft_suno_remix(self),
(RouterProvider::Artcraft, RouterAudioModel::SunoSounds) => build_artcraft_suno_sounds(self),
(RouterProvider::Artcraft, RouterAudioModel::SunoSample) => build_artcraft_suno_sample(self),
(RouterProvider::Artcraft, RouterAudioModel::SeedAudio1p0) => build_artcraft_seed_audio_1p0(self),
// Fal
(RouterProvider::Fal, RouterAudioModel::SeedAudio1p0) => build_fal_seed_audio_1p0(self),
// Kinovi
(RouterProvider::Seedance2Pro, RouterAudioModel::SunoMusic) => build_kinovi_suno_music(self),
(RouterProvider::Seedance2Pro, RouterAudioModel::SunoRemix) => build_kinovi_suno_remix(self),
(RouterProvider::Seedance2Pro, RouterAudioModel::SunoSounds) => build_kinovi_suno_sounds(self),
(RouterProvider::Seedance2Pro, RouterAudioModel::SunoSample) => build_kinovi_suno_sample(self),
_ => self.unsupported_provider_and_model(),
}
}
pub fn get_or_generate_idempotency_token(&self) -> String {
self.idempotency_token.clone()
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
}
fn unsupported_provider_and_model(&self) -> Result<AudioGenerationDraftOrRequest, ArtcraftRouterError> {
Err(ArtcraftRouterError::UnsupportedProviderAndModelForNewApi(
format!("Audio generation for model `{:?}` is not supported for provider {:?}", self.model, self.provider)
))
}
}
#[cfg(test)]
mod tests {
use crate::generate::generate_audio::audio_generation_draft::AudioGenerationDraftRequest;
use crate::generate::generate_audio::audio_generation_request::AudioGenerationRequest;
use super::*;
mod dispatch_tests {
use super::*;
#[test]
fn artcraft_suno_music_dispatches() {
let result = builder(RouterProvider::Artcraft, RouterAudioModel::SunoMusic).build2().expect("build");
assert!(matches!(
result,
AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::ArtcraftSunoMusic(_))
));
}
#[test]
fn artcraft_suno_remix_dispatches() {
let result = builder_with_audio_token_ref(RouterProvider::Artcraft, RouterAudioModel::SunoRemix).build2().expect("build");
assert!(matches!(
result,
AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::ArtcraftSunoRemix(_))
));
}
#[test]
fn artcraft_suno_sounds_dispatches() {
let result = builder(RouterProvider::Artcraft, RouterAudioModel::SunoSounds).build2().expect("build");
assert!(matches!(
result,
AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::ArtcraftSunoSounds(_))
));
}
#[test]
fn artcraft_suno_sample_dispatches() {
let result = builder_with_audio_token_ref(RouterProvider::Artcraft, RouterAudioModel::SunoSample).build2().expect("build");
assert!(matches!(
result,
AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::ArtcraftSunoSample(_))
));
}
#[test]
fn artcraft_seed_audio_1p0_dispatches() {
let result = builder(RouterProvider::Artcraft, RouterAudioModel::SeedAudio1p0).build2().expect("build");
assert!(matches!(
result,
AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::ArtcraftSeedAudio1p0(_))
));
}
#[test]
fn fal_seed_audio_1p0_dispatches() {
let result = builder(RouterProvider::Fal, RouterAudioModel::SeedAudio1p0).build2().expect("build");
assert!(matches!(
result,
AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::FalSeedAudio1p0(_))
));
}
#[test]
fn kinovi_suno_music_dispatches_to_request() {
let result = builder(RouterProvider::Seedance2Pro, RouterAudioModel::SunoMusic).build2().expect("build");
assert!(matches!(
result,
AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::KinoviSunoMusic(_))
));
}
#[test]
fn kinovi_suno_sounds_dispatches_to_request() {
let result = builder(RouterProvider::Seedance2Pro, RouterAudioModel::SunoSounds).build2().expect("build");
assert!(matches!(
result,
AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::KinoviSunoSounds(_))
));
}
#[test]
fn kinovi_suno_remix_dispatches_to_draft() {
let result = builder_with_audio_ref(RouterProvider::Seedance2Pro, RouterAudioModel::SunoRemix).build2().expect("build");
assert!(matches!(
result,
AudioGenerationDraftOrRequest::Draft(AudioGenerationDraftRequest::KinoviSunoRemix(_))
));
}
#[test]
fn kinovi_suno_sample_dispatches_to_draft() {
let result = builder_with_audio_ref(RouterProvider::Seedance2Pro, RouterAudioModel::SunoSample).build2().expect("build");
assert!(matches!(
result,
AudioGenerationDraftOrRequest::Draft(AudioGenerationDraftRequest::KinoviSunoSample(_))
));
}
}
mod unsupported_combo_tests {
use super::*;
#[test]
fn kinovi_seed_audio_1p0_is_unsupported() {
let result = builder(RouterProvider::Seedance2Pro, RouterAudioModel::SeedAudio1p0).build2();
assert!(matches!(result, Err(ArtcraftRouterError::UnsupportedProviderAndModelForNewApi(_))));
}
#[test]
fn fal_suno_models_are_unsupported() {
for model in [
RouterAudioModel::SunoMusic,
RouterAudioModel::SunoRemix,
RouterAudioModel::SunoSounds,
RouterAudioModel::SunoSample,
] {
let result = builder(RouterProvider::Fal, model).build2();
assert!(
matches!(result, Err(ArtcraftRouterError::UnsupportedProviderAndModelForNewApi(_))),
"expected unsupported error for Fal + {model:?}",
);
}
}
#[test]
fn gmicloud_and_grok_are_unsupported() {
for provider in [RouterProvider::GmiCloud, RouterProvider::GrokApi] {
let result = builder(provider, RouterAudioModel::SunoMusic).build2();
assert!(
matches!(result, Err(ArtcraftRouterError::UnsupportedProviderAndModelForNewApi(_))),
"expected unsupported error for {provider:?}",
);
}
}
}
// ── Helpers ──
fn builder(provider: RouterProvider, model: RouterAudioModel) -> GenerateAudioRequestBuilder {
GenerateAudioRequestBuilder {
provider,
model,
prompt: Some("a song about corgis".to_string()),
..Default::default()
}
}
fn builder_with_audio_ref(provider: RouterProvider, model: RouterAudioModel) -> GenerateAudioRequestBuilder {
GenerateAudioRequestBuilder {
audio_references: Some(AudioListRef::Urls(vec!["https://example.com/a.mp3".to_string()])),
..builder(provider, model)
}
}
fn builder_with_audio_token_ref(provider: RouterProvider, model: RouterAudioModel) -> GenerateAudioRequestBuilder {
use tokens::tokens::media_files::MediaFileToken;
GenerateAudioRequestBuilder {
audio_references: Some(AudioListRef::MediaFileTokens(vec![MediaFileToken::new("mf_test123".to_string())])),
..builder(provider, model)
}
}
}
@@ -1,65 +0,0 @@
use std::fmt::Debug;
use std::sync::Arc;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
#[derive(Clone, Debug)]
pub struct ArtcraftAudioResponsePayload {
pub inference_job_token: InferenceJobToken,
pub all_inference_job_tokens: Vec<InferenceJobToken>,
}
#[derive(Clone, Debug)]
pub struct Seedance2proAudioResponsePayload {
pub order_id: String,
pub task_id: String,
}
#[derive(Clone, Debug)]
pub struct FalAudioResponsePayload {
pub request_id: Option<String>,
pub gateway_request_id: Option<String>,
/// Queue-mode status URL (fal's queue/polling flow). `None` for webhook
/// dispatch — the webhook callback drives status updates instead.
pub maybe_status_url: Option<String>,
/// Queue-mode response URL (fal's queue/polling flow). `None` for webhook
/// dispatch.
pub maybe_response_url: Option<String>,
/// The outbound request that was sent to Fal.
/// Stored as a trait object so any Request type can be captured.
/// Use `format!("{:?}", ...)` or `format!("{:#?}", ...)` to print.
pub maybe_outbound_request: Option<Arc<dyn Debug + Send + Sync>>,
}
#[derive(Clone, Debug)]
pub enum GenerateAudioResponse {
Artcraft(ArtcraftAudioResponsePayload),
Seedance2Pro(Seedance2proAudioResponsePayload),
Fal(FalAudioResponsePayload),
}
impl GenerateAudioResponse {
pub fn get_artcraft_payload(&self) -> Option<ArtcraftAudioResponsePayload> {
match self {
Self::Artcraft(p) => Some(p.clone()),
_ => None,
}
}
pub fn get_seedance2pro_payload(&self) -> Option<Seedance2proAudioResponsePayload> {
match self {
Self::Seedance2Pro(p) => Some(p.clone()),
_ => None,
}
}
pub fn get_fal_payload(&self) -> Option<FalAudioResponsePayload> {
match self {
Self::Fal(p) => Some(p.clone()),
_ => None,
}
}
}
@@ -1,8 +0,0 @@
pub mod audio_generation_cost_estimate;
pub mod audio_generation_draft;
pub mod audio_generation_draft_context;
pub mod audio_generation_draft_or_request;
pub mod audio_generation_request;
pub mod generate_audio_request_builder;
pub mod generate_audio_response;
pub mod providers;
@@ -1,143 +0,0 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_audio_cost_and_generate_request::OmniGenAudioCostAndGenerateRequest;
use enums::common::generation::common_audio_model::CommonAudioModel as CommonAudioModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::providers::artcraft::resolve::{
resolve_audio_list_ref, resolve_image_list_ref,
};
/// Build an `OmniGenAudioCostAndGenerateRequest` from the builder. The
/// Artcraft provider is a thin forwarding shim: the storyteller-web omni
/// audio endpoint owns per-model validation and option mapping, so the
/// builder's options are passed through as-is.
pub fn build_artcraft_omni_audio_request(
mut builder: GenerateAudioRequestBuilder,
model: CommonAudioModelEnum,
) -> Result<OmniGenAudioCostAndGenerateRequest, ArtcraftRouterError> {
let audio_media_tokens = resolve_audio_list_ref(builder.audio_references.take())?;
let image_media_tokens = resolve_image_list_ref(builder.image_references.take())?;
let idempotency_token = builder.get_or_generate_idempotency_token();
Ok(OmniGenAudioCostAndGenerateRequest {
idempotency_token: Some(idempotency_token),
model: Some(model),
prompt: builder.prompt.take(),
style_prompt: builder.style_prompt.take(),
audio_media_tokens,
image_media_tokens,
keep_lyrics: builder.keep_lyrics,
is_instrumental: builder.is_instrumental,
is_loopable: builder.is_loopable,
bpm: builder.bpm,
musical_key: builder.musical_key,
sample_rate_hz: builder.sample_rate_hz,
speed: builder.speed,
volume: builder.volume,
pitch: builder.pitch,
})
}
#[cfg(test)]
mod tests {
use tokens::tokens::media_files::MediaFileToken;
use crate::api::audio_list_ref::AudioListRef;
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::errors::client_error::ClientError;
use super::*;
#[test]
fn all_fields_are_forwarded() {
use enums::common::generation::common_musical_key::CommonMusicalKey;
let builder = GenerateAudioRequestBuilder {
prompt: Some("a song".to_string()),
style_prompt: Some("EDM".to_string()),
audio_references: Some(AudioListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_audio".to_string()),
])),
image_references: Some(ImageListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_image".to_string()),
])),
keep_lyrics: Some(true),
is_instrumental: Some(false),
is_loopable: Some(true),
bpm: Some(120),
musical_key: Some(CommonMusicalKey::AMinor),
sample_rate_hz: Some(48_000),
speed: Some(1.5),
volume: Some(0.8),
pitch: Some(-2.0),
idempotency_token: Some("test-token".to_string()),
..base_builder()
};
let request = build_artcraft_omni_audio_request(builder, CommonAudioModelEnum::SunoMusic)
.expect("build");
assert_eq!(request.idempotency_token.as_deref(), Some("test-token"));
assert_eq!(request.model, Some(CommonAudioModelEnum::SunoMusic));
assert_eq!(request.prompt.as_deref(), Some("a song"));
assert_eq!(request.style_prompt.as_deref(), Some("EDM"));
assert_eq!(request.audio_media_tokens.as_ref().map(|t| t.len()), Some(1));
assert_eq!(request.image_media_tokens.as_ref().map(|t| t.len()), Some(1));
assert_eq!(request.keep_lyrics, Some(true));
assert_eq!(request.is_instrumental, Some(false));
assert_eq!(request.is_loopable, Some(true));
assert_eq!(request.bpm, Some(120));
assert_eq!(request.musical_key, Some(CommonMusicalKey::AMinor));
assert_eq!(request.sample_rate_hz, Some(48_000));
assert_eq!(request.speed, Some(1.5));
assert_eq!(request.volume, Some(0.8));
assert_eq!(request.pitch, Some(-2.0));
}
#[test]
fn idempotency_token_is_generated_when_missing() {
let request = build_artcraft_omni_audio_request(base_builder(), CommonAudioModelEnum::SunoMusic)
.expect("build");
assert!(request.idempotency_token.is_some());
assert!(!request.idempotency_token.unwrap().is_empty());
}
#[test]
fn audio_url_references_are_rejected() {
let builder = GenerateAudioRequestBuilder {
audio_references: Some(AudioListRef::Urls(vec!["https://example.com/a.mp3".to_string()])),
..base_builder()
};
let result = build_artcraft_omni_audio_request(builder, CommonAudioModelEnum::SunoRemix);
assert!(matches!(
result,
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
));
}
#[test]
fn image_url_references_are_rejected() {
let builder = GenerateAudioRequestBuilder {
image_references: Some(ImageListRef::Urls(vec!["https://example.com/a.png".to_string()])),
..base_builder()
};
let result = build_artcraft_omni_audio_request(builder, CommonAudioModelEnum::SeedAudio1p0);
assert!(matches!(
result,
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
));
}
// ── Helpers ──
fn base_builder() -> GenerateAudioRequestBuilder {
GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoMusic,
provider: RouterProvider::Artcraft,
prompt: Some("a song".to_string()),
..Default::default()
}
}
}
@@ -1,8 +0,0 @@
pub(crate) mod build_common;
pub(crate) mod request_common;
pub(crate) mod resolve;
pub mod seed_audio_1p0;
pub mod suno_music;
pub mod suno_remix;
pub mod suno_sample;
pub mod suno_sounds;
@@ -1,29 +0,0 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_audio_cost_and_generate_request::OmniGenAudioCostAndGenerateRequest;
use artcraft_client::endpoints::omni_gen::generate::audio::omni_gen_audio::omni_gen_audio_generate;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_audio::generate_audio_response::{
ArtcraftAudioResponsePayload, GenerateAudioResponse,
};
/// Send an audio generation request to the Artcraft omni-gen endpoint.
/// All Artcraft model request states delegate to this function.
pub async fn send_artcraft_omni_audio_request(
request: &OmniGenAudioCostAndGenerateRequest,
client: &RouterArtcraftClient,
) -> Result<GenerateAudioResponse, ArtcraftRouterError> {
let response = omni_gen_audio_generate(
&client.api_host,
Some(&client.credentials),
request.clone(),
)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
Ok(GenerateAudioResponse::Artcraft(ArtcraftAudioResponsePayload {
inference_job_token: response.inference_job_token.clone(),
all_inference_job_tokens: vec![response.inference_job_token],
}))
}
@@ -1,34 +0,0 @@
//! Shared resolve helpers for the Artcraft audio provider.
//!
//! Artcraft only accepts media file tokens — raw URLs are rejected.
use tokens::tokens::media_files::MediaFileToken;
use crate::api::audio_list_ref::AudioListRef;
use crate::api::image_list_ref::ImageListRef;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
pub(super) fn resolve_audio_list_ref(
audio_list_ref: Option<AudioListRef>,
) -> Result<Option<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))
}
}
}
pub(super) fn resolve_image_list_ref(
image_list_ref: Option<ImageListRef>,
) -> Result<Option<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))
}
}
}
@@ -1,14 +0,0 @@
use enums::common::generation::common_audio_model::CommonAudioModel as CommonAudioModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::audio_generation_request::AudioGenerationRequest;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::providers::artcraft::build_common::build_artcraft_omni_audio_request;
use crate::generate::generate_audio::providers::artcraft::seed_audio_1p0::request::ArtcraftSeedAudio1p0RequestState;
pub fn build_artcraft_seed_audio_1p0(builder: GenerateAudioRequestBuilder) -> Result<AudioGenerationDraftOrRequest, ArtcraftRouterError> {
let request = build_artcraft_omni_audio_request(builder, CommonAudioModelEnum::SeedAudio1p0)?;
let state = ArtcraftSeedAudio1p0RequestState { request };
Ok(AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::ArtcraftSeedAudio1p0(state)))
}
@@ -1,51 +0,0 @@
use crate::generate::generate_audio::audio_generation_cost_estimate::AudioGenerationCostEstimate;
use crate::generate::generate_audio::providers::artcraft::seed_audio_1p0::request::ArtcraftSeedAudio1p0RequestState;
const COST_IN_USD_CENTS: u64 = 21;
/// Seed Audio 1.0 via Artcraft is flat priced, so the cost state carries no
/// request fields.
#[derive(Clone, Debug)]
pub struct ArtcraftSeedAudio1p0CostState;
impl ArtcraftSeedAudio1p0CostState {
pub fn from_request(_request: &ArtcraftSeedAudio1p0RequestState) -> Self {
Self
}
pub fn estimate_cost(&self) -> AudioGenerationCostEstimate {
AudioGenerationCostEstimate {
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,
}
}
}
#[cfg(test)]
mod tests {
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use super::*;
#[test]
fn flat_cost_is_twenty_one_cents() {
let builder = GenerateAudioRequestBuilder {
model: RouterAudioModel::SeedAudio1p0,
provider: RouterProvider::Artcraft,
prompt: Some("a suspense radio drama".to_string()),
..Default::default()
};
let estimate = builder.build2().unwrap().estimate_cost().unwrap();
assert_eq!(estimate.cost_in_usd_cents, Some(21));
assert_eq!(estimate.cost_in_credits, Some(21));
// Direct cost-state path matches the builder path.
assert_eq!(ArtcraftSeedAudio1p0CostState.estimate_cost().cost_in_usd_cents, Some(21));
}
}
@@ -1,3 +0,0 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -1,78 +0,0 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_audio_cost_and_generate_request::OmniGenAudioCostAndGenerateRequest;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse;
use crate::generate::generate_audio::providers::artcraft::request_common::send_artcraft_omni_audio_request;
#[derive(Clone, Debug)]
pub struct ArtcraftSeedAudio1p0RequestState {
pub request: OmniGenAudioCostAndGenerateRequest,
}
impl ArtcraftSeedAudio1p0RequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateAudioResponse, ArtcraftRouterError> {
send_artcraft_omni_audio_request(&self.request, client).await
}
}
#[cfg(test)]
mod tests {
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::utils::api_host::ApiHost;
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_client::RouterClient;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse;
#[tokio::test]
#[ignore] // sends a real generation to the Artcraft backend, incurs cost
async fn text_to_speech_drama() {
let response = run_pipeline(GenerateAudioRequestBuilder {
model: RouterAudioModel::SeedAudio1p0,
provider: RouterProvider::Artcraft,
prompt: Some("A calm narrator describes ocean waves rolling onto a moonlit beach.".to_string()),
..Default::default()
}).await;
assert!(matches!(response, GenerateAudioResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
// ── Helpers ──
fn get_artcraft_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt")
.expect("Failed to read artcraft cookies");
let cookies = cookies.trim().to_string();
let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies)
.expect("Failed to parse cookies")
.expect("No credentials found");
RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials))
}
async fn run_pipeline(builder: GenerateAudioRequestBuilder) -> GenerateAudioResponse {
let client = get_artcraft_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
AudioGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant (Artcraft skips draft)"),
};
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateAudioResponse::Artcraft(p) => {
println!("inference_job_token={:?}", p.inference_job_token);
println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens);
}
other => println!("response: {:?}", other),
}
response
}
}
@@ -1,14 +0,0 @@
use enums::common::generation::common_audio_model::CommonAudioModel as CommonAudioModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::audio_generation_request::AudioGenerationRequest;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::providers::artcraft::build_common::build_artcraft_omni_audio_request;
use crate::generate::generate_audio::providers::artcraft::suno_music::request::ArtcraftSunoMusicRequestState;
pub fn build_artcraft_suno_music(builder: GenerateAudioRequestBuilder) -> Result<AudioGenerationDraftOrRequest, ArtcraftRouterError> {
let request = build_artcraft_omni_audio_request(builder, CommonAudioModelEnum::SunoMusic)?;
let state = ArtcraftSunoMusicRequestState { request };
Ok(AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::ArtcraftSunoMusic(state)))
}
@@ -1,51 +0,0 @@
use crate::generate::generate_audio::audio_generation_cost_estimate::AudioGenerationCostEstimate;
use crate::generate::generate_audio::providers::artcraft::suno_music::request::ArtcraftSunoMusicRequestState;
const COST_IN_USD_CENTS: u64 = 8;
/// Suno Music via Artcraft is flat priced, so the cost state carries no
/// request fields.
#[derive(Clone, Debug)]
pub struct ArtcraftSunoMusicCostState;
impl ArtcraftSunoMusicCostState {
pub fn from_request(_request: &ArtcraftSunoMusicRequestState) -> Self {
Self
}
pub fn estimate_cost(&self) -> AudioGenerationCostEstimate {
AudioGenerationCostEstimate {
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,
}
}
}
#[cfg(test)]
mod tests {
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use super::*;
#[test]
fn flat_cost_is_eight_cents() {
let builder = GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoMusic,
provider: RouterProvider::Artcraft,
prompt: Some("a song".to_string()),
..Default::default()
};
let estimate = builder.build2().unwrap().estimate_cost().unwrap();
assert_eq!(estimate.cost_in_usd_cents, Some(8));
assert_eq!(estimate.cost_in_credits, Some(8));
// Direct cost-state path matches the builder path.
assert_eq!(ArtcraftSunoMusicCostState.estimate_cost().cost_in_usd_cents, Some(8));
}
}
@@ -1,3 +0,0 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -1,79 +0,0 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_audio_cost_and_generate_request::OmniGenAudioCostAndGenerateRequest;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse;
use crate::generate::generate_audio::providers::artcraft::request_common::send_artcraft_omni_audio_request;
#[derive(Clone, Debug)]
pub struct ArtcraftSunoMusicRequestState {
pub request: OmniGenAudioCostAndGenerateRequest,
}
impl ArtcraftSunoMusicRequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateAudioResponse, ArtcraftRouterError> {
send_artcraft_omni_audio_request(&self.request, client).await
}
}
#[cfg(test)]
mod tests {
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::utils::api_host::ApiHost;
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_client::RouterClient;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse;
#[tokio::test]
#[ignore] // sends a real generation to the Artcraft backend, incurs cost
async fn text_to_music() {
let response = run_pipeline(GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoMusic,
provider: RouterProvider::Artcraft,
prompt: Some("A song about a corgi who learns to sail the open sea".to_string()),
style_prompt: Some("Sea shanty, folk".to_string()),
..Default::default()
}).await;
assert!(matches!(response, GenerateAudioResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
// ── Helpers ──
fn get_artcraft_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt")
.expect("Failed to read artcraft cookies");
let cookies = cookies.trim().to_string();
let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies)
.expect("Failed to parse cookies")
.expect("No credentials found");
RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials))
}
async fn run_pipeline(builder: GenerateAudioRequestBuilder) -> GenerateAudioResponse {
let client = get_artcraft_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
AudioGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant (Artcraft skips draft)"),
};
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateAudioResponse::Artcraft(p) => {
println!("inference_job_token={:?}", p.inference_job_token);
println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens);
}
other => println!("response: {:?}", other),
}
response
}
}
@@ -1,14 +0,0 @@
use enums::common::generation::common_audio_model::CommonAudioModel as CommonAudioModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::audio_generation_request::AudioGenerationRequest;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::providers::artcraft::build_common::build_artcraft_omni_audio_request;
use crate::generate::generate_audio::providers::artcraft::suno_remix::request::ArtcraftSunoRemixRequestState;
pub fn build_artcraft_suno_remix(builder: GenerateAudioRequestBuilder) -> Result<AudioGenerationDraftOrRequest, ArtcraftRouterError> {
let request = build_artcraft_omni_audio_request(builder, CommonAudioModelEnum::SunoRemix)?;
let state = ArtcraftSunoRemixRequestState { request };
Ok(AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::ArtcraftSunoRemix(state)))
}
@@ -1,57 +0,0 @@
use crate::generate::generate_audio::audio_generation_cost_estimate::AudioGenerationCostEstimate;
use crate::generate::generate_audio::providers::artcraft::suno_remix::request::ArtcraftSunoRemixRequestState;
const COST_IN_USD_CENTS: u64 = 8;
/// Suno Remix via Artcraft is flat priced, so the cost state carries no
/// request fields.
#[derive(Clone, Debug)]
pub struct ArtcraftSunoRemixCostState;
impl ArtcraftSunoRemixCostState {
pub fn from_request(_request: &ArtcraftSunoRemixRequestState) -> Self {
Self
}
pub fn estimate_cost(&self) -> AudioGenerationCostEstimate {
AudioGenerationCostEstimate {
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,
}
}
}
#[cfg(test)]
mod tests {
use tokens::tokens::media_files::MediaFileToken;
use crate::api::audio_list_ref::AudioListRef;
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use super::*;
#[test]
fn flat_cost_is_eight_cents() {
let builder = GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoRemix,
provider: RouterProvider::Artcraft,
prompt: Some("make this electronic".to_string()),
audio_references: Some(AudioListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_test123".to_string()),
])),
..Default::default()
};
let estimate = builder.build2().unwrap().estimate_cost().unwrap();
assert_eq!(estimate.cost_in_usd_cents, Some(8));
assert_eq!(estimate.cost_in_credits, Some(8));
// Direct cost-state path matches the builder path.
assert_eq!(ArtcraftSunoRemixCostState.estimate_cost().cost_in_usd_cents, Some(8));
}
}
@@ -1,3 +0,0 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -1,88 +0,0 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_audio_cost_and_generate_request::OmniGenAudioCostAndGenerateRequest;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse;
use crate::generate::generate_audio::providers::artcraft::request_common::send_artcraft_omni_audio_request;
#[derive(Clone, Debug)]
pub struct ArtcraftSunoRemixRequestState {
pub request: OmniGenAudioCostAndGenerateRequest,
}
impl ArtcraftSunoRemixRequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateAudioResponse, ArtcraftRouterError> {
send_artcraft_omni_audio_request(&self.request, client).await
}
}
#[cfg(test)]
mod tests {
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::utils::api_host::ApiHost;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::audio_list_ref::AudioListRef;
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_client::RouterClient;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse;
/// Replace with a real uploaded audio media token before running.
/// (test_data doesn't have a production audio media token yet.)
const AUDIO_MEDIA_TOKEN: &str = "m_replace_with_uploaded_audio_token";
#[tokio::test]
#[ignore] // sends a real generation to the Artcraft backend, incurs cost
async fn remix_uploaded_audio() {
let response = run_pipeline(GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoRemix,
provider: RouterProvider::Artcraft,
prompt: Some("Make this electronic".to_string()),
style_prompt: Some("EDM style".to_string()),
audio_references: Some(AudioListRef::MediaFileTokens(vec![
MediaFileToken::new(AUDIO_MEDIA_TOKEN.to_string()),
])),
..Default::default()
}).await;
assert!(matches!(response, GenerateAudioResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
// ── Helpers ──
fn get_artcraft_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt")
.expect("Failed to read artcraft cookies");
let cookies = cookies.trim().to_string();
let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies)
.expect("Failed to parse cookies")
.expect("No credentials found");
RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials))
}
async fn run_pipeline(builder: GenerateAudioRequestBuilder) -> GenerateAudioResponse {
let client = get_artcraft_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
AudioGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant (Artcraft skips draft)"),
};
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateAudioResponse::Artcraft(p) => {
println!("inference_job_token={:?}", p.inference_job_token);
println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens);
}
other => println!("response: {:?}", other),
}
response
}
}
@@ -1,14 +0,0 @@
use enums::common::generation::common_audio_model::CommonAudioModel as CommonAudioModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::audio_generation_request::AudioGenerationRequest;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::providers::artcraft::build_common::build_artcraft_omni_audio_request;
use crate::generate::generate_audio::providers::artcraft::suno_sample::request::ArtcraftSunoSampleRequestState;
pub fn build_artcraft_suno_sample(builder: GenerateAudioRequestBuilder) -> Result<AudioGenerationDraftOrRequest, ArtcraftRouterError> {
let request = build_artcraft_omni_audio_request(builder, CommonAudioModelEnum::SunoSample)?;
let state = ArtcraftSunoSampleRequestState { request };
Ok(AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::ArtcraftSunoSample(state)))
}
@@ -1,57 +0,0 @@
use crate::generate::generate_audio::audio_generation_cost_estimate::AudioGenerationCostEstimate;
use crate::generate::generate_audio::providers::artcraft::suno_sample::request::ArtcraftSunoSampleRequestState;
const COST_IN_USD_CENTS: u64 = 8;
/// Suno Sample via Artcraft is flat priced, so the cost state carries no
/// request fields.
#[derive(Clone, Debug)]
pub struct ArtcraftSunoSampleCostState;
impl ArtcraftSunoSampleCostState {
pub fn from_request(_request: &ArtcraftSunoSampleRequestState) -> Self {
Self
}
pub fn estimate_cost(&self) -> AudioGenerationCostEstimate {
AudioGenerationCostEstimate {
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,
}
}
}
#[cfg(test)]
mod tests {
use tokens::tokens::media_files::MediaFileToken;
use crate::api::audio_list_ref::AudioListRef;
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use super::*;
#[test]
fn flat_cost_is_eight_cents() {
let builder = GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoSample,
provider: RouterProvider::Artcraft,
prompt: Some("mystical RPG adventure".to_string()),
audio_references: Some(AudioListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_test123".to_string()),
])),
..Default::default()
};
let estimate = builder.build2().unwrap().estimate_cost().unwrap();
assert_eq!(estimate.cost_in_usd_cents, Some(8));
assert_eq!(estimate.cost_in_credits, Some(8));
// Direct cost-state path matches the builder path.
assert_eq!(ArtcraftSunoSampleCostState.estimate_cost().cost_in_usd_cents, Some(8));
}
}
@@ -1,3 +0,0 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -1,89 +0,0 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_audio_cost_and_generate_request::OmniGenAudioCostAndGenerateRequest;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse;
use crate::generate::generate_audio::providers::artcraft::request_common::send_artcraft_omni_audio_request;
#[derive(Clone, Debug)]
pub struct ArtcraftSunoSampleRequestState {
pub request: OmniGenAudioCostAndGenerateRequest,
}
impl ArtcraftSunoSampleRequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateAudioResponse, ArtcraftRouterError> {
send_artcraft_omni_audio_request(&self.request, client).await
}
}
#[cfg(test)]
mod tests {
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::utils::api_host::ApiHost;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::audio_list_ref::AudioListRef;
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_client::RouterClient;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse;
/// Replace with a real uploaded audio media token before running.
/// (test_data doesn't have a production audio media token yet.)
const AUDIO_MEDIA_TOKEN: &str = "m_replace_with_uploaded_audio_token";
#[tokio::test]
#[ignore] // sends a real generation to the Artcraft backend, incurs cost
async fn sample_uploaded_audio() {
let response = run_pipeline(GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoSample,
provider: RouterProvider::Artcraft,
prompt: Some("Mystical RPG adventure, make it have a grand climax".to_string()),
style_prompt: Some("Fantasy video game score".to_string()),
is_instrumental: Some(true),
audio_references: Some(AudioListRef::MediaFileTokens(vec![
MediaFileToken::new(AUDIO_MEDIA_TOKEN.to_string()),
])),
..Default::default()
}).await;
assert!(matches!(response, GenerateAudioResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
// ── Helpers ──
fn get_artcraft_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt")
.expect("Failed to read artcraft cookies");
let cookies = cookies.trim().to_string();
let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies)
.expect("Failed to parse cookies")
.expect("No credentials found");
RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials))
}
async fn run_pipeline(builder: GenerateAudioRequestBuilder) -> GenerateAudioResponse {
let client = get_artcraft_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
AudioGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant (Artcraft skips draft)"),
};
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateAudioResponse::Artcraft(p) => {
println!("inference_job_token={:?}", p.inference_job_token);
println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens);
}
other => println!("response: {:?}", other),
}
response
}
}
@@ -1,14 +0,0 @@
use enums::common::generation::common_audio_model::CommonAudioModel as CommonAudioModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::audio_generation_request::AudioGenerationRequest;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::providers::artcraft::build_common::build_artcraft_omni_audio_request;
use crate::generate::generate_audio::providers::artcraft::suno_sounds::request::ArtcraftSunoSoundsRequestState;
pub fn build_artcraft_suno_sounds(builder: GenerateAudioRequestBuilder) -> Result<AudioGenerationDraftOrRequest, ArtcraftRouterError> {
let request = build_artcraft_omni_audio_request(builder, CommonAudioModelEnum::SunoSounds)?;
let state = ArtcraftSunoSoundsRequestState { request };
Ok(AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::ArtcraftSunoSounds(state)))
}
@@ -1,51 +0,0 @@
use crate::generate::generate_audio::audio_generation_cost_estimate::AudioGenerationCostEstimate;
use crate::generate::generate_audio::providers::artcraft::suno_sounds::request::ArtcraftSunoSoundsRequestState;
const COST_IN_USD_CENTS: u64 = 3;
/// Suno Sounds via Artcraft is flat priced, so the cost state carries no
/// request fields.
#[derive(Clone, Debug)]
pub struct ArtcraftSunoSoundsCostState;
impl ArtcraftSunoSoundsCostState {
pub fn from_request(_request: &ArtcraftSunoSoundsRequestState) -> Self {
Self
}
pub fn estimate_cost(&self) -> AudioGenerationCostEstimate {
AudioGenerationCostEstimate {
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,
}
}
}
#[cfg(test)]
mod tests {
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use super::*;
#[test]
fn flat_cost_is_three_cents() {
let builder = GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoSounds,
provider: RouterProvider::Artcraft,
prompt: Some("rain sound effects".to_string()),
..Default::default()
};
let estimate = builder.build2().unwrap().estimate_cost().unwrap();
assert_eq!(estimate.cost_in_usd_cents, Some(3));
assert_eq!(estimate.cost_in_credits, Some(3));
// Direct cost-state path matches the builder path.
assert_eq!(ArtcraftSunoSoundsCostState.estimate_cost().cost_in_usd_cents, Some(3));
}
}
@@ -1,3 +0,0 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -1,82 +0,0 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_audio_cost_and_generate_request::OmniGenAudioCostAndGenerateRequest;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse;
use crate::generate::generate_audio::providers::artcraft::request_common::send_artcraft_omni_audio_request;
#[derive(Clone, Debug)]
pub struct ArtcraftSunoSoundsRequestState {
pub request: OmniGenAudioCostAndGenerateRequest,
}
impl ArtcraftSunoSoundsRequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateAudioResponse, ArtcraftRouterError> {
send_artcraft_omni_audio_request(&self.request, client).await
}
}
#[cfg(test)]
mod tests {
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::utils::api_host::ApiHost;
use enums::common::generation::common_musical_key::CommonMusicalKey;
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_client::RouterClient;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse;
#[tokio::test]
#[ignore] // sends a real generation to the Artcraft backend, incurs cost
async fn loopable_sound_effect() {
let response = run_pipeline(GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoSounds,
provider: RouterProvider::Artcraft,
prompt: Some("Lo-fi vinyl crackle groove".to_string()),
is_loopable: Some(true),
bpm: Some(85),
musical_key: Some(CommonMusicalKey::AMinor),
..Default::default()
}).await;
assert!(matches!(response, GenerateAudioResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
// ── Helpers ──
fn get_artcraft_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt")
.expect("Failed to read artcraft cookies");
let cookies = cookies.trim().to_string();
let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies)
.expect("Failed to parse cookies")
.expect("No credentials found");
RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials))
}
async fn run_pipeline(builder: GenerateAudioRequestBuilder) -> GenerateAudioResponse {
let client = get_artcraft_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
AudioGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant (Artcraft skips draft)"),
};
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateAudioResponse::Artcraft(p) => {
println!("inference_job_token={:?}", p.inference_job_token);
println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens);
}
other => println!("response: {:?}", other),
}
response
}
}
@@ -1 +0,0 @@
pub mod seed_audio_1p0;
@@ -1,588 +0,0 @@
use fal_client::requests::api::audio::omni::seed_audio_1p0::api::{
SeedAudio1p0OutputFormat, SeedAudio1p0Request, SeedAudio1p0SampleRate,
};
use crate::api::audio_list_ref::AudioListRef;
use crate::api::image_list_ref::ImageListRef;
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_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::audio_generation_request::AudioGenerationRequest;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::providers::fal::seed_audio_1p0::request::FalSeedAudio1p0RequestState;
use crate::generate::generate_audio::providers::reject_unsupported::reject_unsupported_option;
const MAX_AUDIO_REFERENCES: usize = 3;
const MIN_SPEED: f64 = 0.5;
const MAX_SPEED: f64 = 2.0;
const MIN_VOLUME: f64 = 0.5;
const MAX_VOLUME: f64 = 2.0;
const MIN_PITCH: i8 = -12;
const MAX_PITCH: i8 = 12;
pub fn build_fal_seed_audio_1p0(builder: GenerateAudioRequestBuilder) -> Result<AudioGenerationDraftOrRequest, ArtcraftRouterError> {
let state = build_fal_seed_audio_1p0_state(builder)?;
Ok(AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::FalSeedAudio1p0(state)))
}
pub(crate) fn build_fal_seed_audio_1p0_state(
mut builder: GenerateAudioRequestBuilder,
) -> Result<FalSeedAudio1p0RequestState, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
// Options Seed Audio has no equivalent for.
reject_unsupported_option("style_prompt", builder.style_prompt.as_ref(), strategy)?;
reject_unsupported_option("keep_lyrics", builder.keep_lyrics.as_ref(), strategy)?;
reject_unsupported_option("is_instrumental", builder.is_instrumental.as_ref(), strategy)?;
reject_unsupported_option("is_loopable", builder.is_loopable.as_ref(), strategy)?;
reject_unsupported_option("bpm", builder.bpm.as_ref(), strategy)?;
reject_unsupported_option("musical_key", builder.musical_key.as_ref(), strategy)?;
let prompt = builder.prompt.take().ok_or_else(|| {
ArtcraftRouterError::InvalidInput("A prompt is required for Seed Audio 1.0".to_string())
})?;
let audio_urls = plan_audio_urls(builder.audio_references.take(), strategy)?;
let image_url = plan_image_url(builder.image_references.take(), strategy)?;
// fal rejects requests that combine an image reference with audio
// references, so the router always rejects the combination too.
if audio_urls.is_some() && image_url.is_some() {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_references",
value: "Seed Audio 1.0 cannot combine an image reference with audio references".to_string(),
}));
}
let request = SeedAudio1p0Request {
prompt,
// The omni audio API doesn't expose voice selection yet, so the model
// always picks the voice.
voice: None,
audio_urls,
image_url,
output_format: Some(SeedAudio1p0OutputFormat::Mp3),
sample_rate: plan_sample_rate(builder.sample_rate_hz, strategy)?,
speed: plan_multiplier("speed", builder.speed, MIN_SPEED, MAX_SPEED, strategy)?,
volume: plan_multiplier("volume", builder.volume, MIN_VOLUME, MAX_VOLUME, strategy)?,
pitch: plan_pitch(builder.pitch, strategy)?,
};
Ok(FalSeedAudio1p0RequestState { request })
}
// ── Plan helpers ──
/// Seed Audio takes up to 3 reference audio URLs. Fal only accepts URLs (not
/// media tokens). More than 3 references reject under `ErrorOut`; the other
/// strategies keep the first 3.
fn plan_audio_urls(
audio_references: Option<AudioListRef>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
let mut urls = match audio_references {
None => return Ok(None),
Some(AudioListRef::Urls(urls)) => urls,
Some(AudioListRef::MediaFileTokens(tokens)) if tokens.is_empty() => return Ok(None),
Some(AudioListRef::MediaFileTokens(_)) => {
return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls));
}
};
if urls.is_empty() {
return Ok(None);
}
if urls.len() > MAX_AUDIO_REFERENCES {
match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "audio_references",
value: format!(
"Seed Audio 1.0 supports at most {} audio references, got {}",
MAX_AUDIO_REFERENCES,
urls.len(),
),
}));
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
urls.truncate(MAX_AUDIO_REFERENCES);
}
}
}
Ok(Some(urls))
}
/// Seed Audio takes a single reference image URL. Fal only accepts URLs (not
/// media tokens). More than 1 reference rejects under `ErrorOut`; the other
/// strategies keep the first.
fn plan_image_url(
image_references: Option<ImageListRef>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<String>, ArtcraftRouterError> {
let urls = match image_references {
None => return Ok(None),
Some(ImageListRef::Urls(urls)) => urls,
Some(ImageListRef::MediaFileTokens(tokens)) if tokens.is_empty() => return Ok(None),
Some(ImageListRef::MediaFileTokens(_)) => {
return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls));
}
};
if urls.is_empty() {
return Ok(None);
}
if urls.len() > 1 {
if let RequestMismatchMitigationStrategy::ErrorOut = strategy {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_references",
value: format!("Seed Audio 1.0 supports at most 1 image reference, got {}", urls.len()),
}));
}
}
Ok(urls.into_iter().next())
}
/// Map a requested sample rate to Seed Audio's supported rates. An exact
/// match is required under `ErrorOut`; the other strategies pick the nearest
/// supported rate (ties resolve to the lower rate).
fn plan_sample_rate(
sample_rate_hz: Option<u32>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<SeedAudio1p0SampleRate>, ArtcraftRouterError> {
const SUPPORTED: [(u32, SeedAudio1p0SampleRate); 6] = [
(8_000, SeedAudio1p0SampleRate::Hz8000),
(16_000, SeedAudio1p0SampleRate::Hz16000),
(24_000, SeedAudio1p0SampleRate::Hz24000),
(32_000, SeedAudio1p0SampleRate::Hz32000),
(44_100, SeedAudio1p0SampleRate::Hz44100),
(48_000, SeedAudio1p0SampleRate::Hz48000),
];
let Some(requested) = sample_rate_hz else {
return Ok(None);
};
if let Some((_, rate)) = SUPPORTED.iter().find(|(hz, _)| *hz == requested) {
return Ok(Some(*rate));
}
match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "sample_rate_hz",
value: format!("{}", requested),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
let (_, nearest) = SUPPORTED.iter()
.min_by_key(|(hz, _)| requested.abs_diff(*hz))
.expect("SUPPORTED is non-empty");
Ok(Some(*nearest))
}
}
}
/// Clamp a 0.5–2.0 multiplier (speed / volume) per strategy: out-of-range
/// values reject under `ErrorOut` and clamp otherwise.
fn plan_multiplier(
field: &'static str,
value: Option<f64>,
min: f64,
max: f64,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<f64>, ArtcraftRouterError> {
let Some(value) = value else {
return Ok(None);
};
if value >= min && value <= max {
return Ok(Some(value));
}
match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field,
value: format!("{}", value),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(value.clamp(min, max))),
}
}
/// Pitch is a semitone shift: round to the nearest whole semitone, then
/// reject (under `ErrorOut`) or clamp values outside ±12.
fn plan_pitch(
pitch: Option<f64>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<i8>, ArtcraftRouterError> {
let Some(pitch) = pitch else {
return Ok(None);
};
let rounded = pitch.round();
if rounded >= MIN_PITCH as f64 && rounded <= MAX_PITCH as f64 {
return Ok(Some(rounded as i8));
}
match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "pitch",
value: format!("{}", pitch),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(Some(rounded.clamp(MIN_PITCH as f64, MAX_PITCH as f64) as i8))
}
}
}
#[cfg(test)]
mod tests {
use tokens::tokens::media_files::MediaFileToken;
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use super::*;
const AUDIO_URL: &str = "https://example.com/a.mp3";
const IMAGE_URL: &str = "https://example.com/scene.png";
mod basics {
use super::*;
#[test]
fn prompt_is_passed_through() {
let state = build_fal_seed_audio_1p0_state(base_builder()).expect("build");
assert_eq!(state.request.prompt, "a suspense radio drama");
}
#[test]
fn missing_prompt_is_rejected() {
let builder = GenerateAudioRequestBuilder { prompt: None, ..base_builder() };
assert!(build_fal_seed_audio_1p0_state(builder).is_err());
}
#[test]
fn voice_is_always_none() {
// The omni audio API doesn't expose voice selection.
let state = build_fal_seed_audio_1p0_state(base_builder()).expect("build");
assert!(state.request.voice.is_none());
}
#[test]
fn output_format_is_mp3() {
let state = build_fal_seed_audio_1p0_state(base_builder()).expect("build");
assert_eq!(state.request.output_format, Some(SeedAudio1p0OutputFormat::Mp3));
}
}
mod reference_tests {
use super::*;
#[test]
fn audio_urls_pass_through() {
let builder = GenerateAudioRequestBuilder {
audio_references: Some(AudioListRef::Urls(vec![AUDIO_URL.to_string()])),
..base_builder()
};
let state = build_fal_seed_audio_1p0_state(builder).expect("build");
assert_eq!(state.request.audio_urls, Some(vec![AUDIO_URL.to_string()]));
}
#[test]
fn three_audio_urls_are_accepted() {
let builder = builder_with_audio_urls(3);
let state = build_fal_seed_audio_1p0_state(builder).expect("build");
assert_eq!(state.request.audio_urls.as_ref().map(|u| u.len()), Some(3));
}
#[test]
fn four_audio_urls_error_out() {
let mut builder = builder_with_audio_urls(4);
builder.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
assert!(build_fal_seed_audio_1p0_state(builder).is_err());
}
#[test]
fn four_audio_urls_truncate_under_lenient_strategies() {
let mut builder = builder_with_audio_urls(4);
builder.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayMoreUpgrade;
let state = build_fal_seed_audio_1p0_state(builder).expect("build");
assert_eq!(state.request.audio_urls.as_ref().map(|u| u.len()), Some(3));
}
#[test]
fn audio_media_tokens_are_rejected() {
let builder = GenerateAudioRequestBuilder {
audio_references: Some(AudioListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_test123".to_string()),
])),
..base_builder()
};
assert!(matches!(
build_fal_seed_audio_1p0_state(builder),
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
));
}
#[test]
fn image_url_passes_through() {
let builder = GenerateAudioRequestBuilder {
image_references: Some(ImageListRef::Urls(vec![IMAGE_URL.to_string()])),
..base_builder()
};
let state = build_fal_seed_audio_1p0_state(builder).expect("build");
assert_eq!(state.request.image_url.as_deref(), Some(IMAGE_URL));
}
#[test]
fn two_image_urls_error_out() {
let builder = GenerateAudioRequestBuilder {
image_references: Some(ImageListRef::Urls(vec![
IMAGE_URL.to_string(),
"https://example.com/other.png".to_string(),
])),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_builder()
};
assert!(build_fal_seed_audio_1p0_state(builder).is_err());
}
#[test]
fn combining_image_and_audio_references_is_always_rejected() {
// fal rejects the combination, so the router rejects it under every
// mitigation strategy.
for strategy in [
RequestMismatchMitigationStrategy::ErrorOut,
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let builder = GenerateAudioRequestBuilder {
audio_references: Some(AudioListRef::Urls(vec![AUDIO_URL.to_string()])),
image_references: Some(ImageListRef::Urls(vec![IMAGE_URL.to_string()])),
request_mismatch_mitigation_strategy: strategy,
..base_builder()
};
assert!(
build_fal_seed_audio_1p0_state(builder).is_err(),
"expected rejection under {strategy:?}",
);
}
}
}
mod sample_rate_tests {
use super::*;
#[test]
fn exact_rates_map_directly() {
let cases = [
(8_000, SeedAudio1p0SampleRate::Hz8000),
(16_000, SeedAudio1p0SampleRate::Hz16000),
(24_000, SeedAudio1p0SampleRate::Hz24000),
(32_000, SeedAudio1p0SampleRate::Hz32000),
(44_100, SeedAudio1p0SampleRate::Hz44100),
(48_000, SeedAudio1p0SampleRate::Hz48000),
];
for (hz, expected) in cases {
let builder = GenerateAudioRequestBuilder {
sample_rate_hz: Some(hz),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_builder()
};
let state = build_fal_seed_audio_1p0_state(builder).expect("build");
assert_eq!(state.request.sample_rate, Some(expected), "for {hz} Hz");
}
}
#[test]
fn inexact_rate_errors_out_under_error_out() {
let builder = GenerateAudioRequestBuilder {
sample_rate_hz: Some(22_050),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_builder()
};
assert!(build_fal_seed_audio_1p0_state(builder).is_err());
}
#[test]
fn inexact_rate_maps_to_nearest_under_lenient_strategies() {
let cases = [
(7_000, SeedAudio1p0SampleRate::Hz8000),
(22_050, SeedAudio1p0SampleRate::Hz24000),
(40_000, SeedAudio1p0SampleRate::Hz44100),
(96_000, SeedAudio1p0SampleRate::Hz48000),
];
for (hz, expected) in cases {
let builder = GenerateAudioRequestBuilder {
sample_rate_hz: Some(hz),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..base_builder()
};
let state = build_fal_seed_audio_1p0_state(builder).expect("build");
assert_eq!(state.request.sample_rate, Some(expected), "for {hz} Hz");
}
}
#[test]
fn no_rate_stays_none() {
let state = build_fal_seed_audio_1p0_state(base_builder()).expect("build");
assert!(state.request.sample_rate.is_none());
}
}
mod speed_and_volume_tests {
use super::*;
#[test]
fn in_range_values_pass_through() {
let builder = GenerateAudioRequestBuilder {
speed: Some(1.5),
volume: Some(0.5),
..base_builder()
};
let state = build_fal_seed_audio_1p0_state(builder).expect("build");
assert_eq!(state.request.speed, Some(1.5));
assert_eq!(state.request.volume, Some(0.5));
}
#[test]
fn out_of_range_speed_errors_out() {
let builder = GenerateAudioRequestBuilder {
speed: Some(3.0),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_builder()
};
assert!(build_fal_seed_audio_1p0_state(builder).is_err());
}
#[test]
fn out_of_range_speed_clamps_under_lenient_strategies() {
let builder = GenerateAudioRequestBuilder {
speed: Some(3.0),
volume: Some(0.1),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade,
..base_builder()
};
let state = build_fal_seed_audio_1p0_state(builder).expect("build");
assert_eq!(state.request.speed, Some(2.0));
assert_eq!(state.request.volume, Some(0.5));
}
}
mod pitch_tests {
use super::*;
#[test]
fn pitch_rounds_to_nearest_semitone() {
let cases = [
(2.4, 2i8),
(2.5, 3i8),
(-2.4, -2i8),
(-2.5, -3i8),
(0.0, 0i8),
];
for (input, expected) in cases {
let builder = GenerateAudioRequestBuilder {
pitch: Some(input),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_builder()
};
let state = build_fal_seed_audio_1p0_state(builder).expect("build");
assert_eq!(state.request.pitch, Some(expected), "for pitch {input}");
}
}
#[test]
fn out_of_range_pitch_errors_out() {
for pitch in [13.0, -13.0] {
let builder = GenerateAudioRequestBuilder {
pitch: Some(pitch),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_builder()
};
assert!(build_fal_seed_audio_1p0_state(builder).is_err(), "for pitch {pitch}");
}
}
#[test]
fn out_of_range_pitch_clamps_under_lenient_strategies() {
let cases = [(20.0, 12i8), (-20.0, -12i8)];
for (input, expected) in cases {
let builder = GenerateAudioRequestBuilder {
pitch: Some(input),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..base_builder()
};
let state = build_fal_seed_audio_1p0_state(builder).expect("build");
assert_eq!(state.request.pitch, Some(expected), "for pitch {input}");
}
}
#[test]
fn boundary_pitch_is_accepted() {
for (input, expected) in [(12.0, 12i8), (-12.0, -12i8)] {
let builder = GenerateAudioRequestBuilder {
pitch: Some(input),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_builder()
};
let state = build_fal_seed_audio_1p0_state(builder).expect("build");
assert_eq!(state.request.pitch, Some(expected));
}
}
}
mod unsupported_options {
use super::*;
#[test]
fn unsupported_options_error_out() {
use enums::common::generation::common_musical_key::CommonMusicalKey;
let cases: Vec<GenerateAudioRequestBuilder> = vec![
GenerateAudioRequestBuilder { style_prompt: Some("EDM".to_string()), ..base_builder() },
GenerateAudioRequestBuilder { keep_lyrics: Some(true), ..base_builder() },
GenerateAudioRequestBuilder { is_instrumental: Some(true), ..base_builder() },
GenerateAudioRequestBuilder { is_loopable: Some(true), ..base_builder() },
GenerateAudioRequestBuilder { bpm: Some(120), ..base_builder() },
GenerateAudioRequestBuilder { musical_key: Some(CommonMusicalKey::CMajor), ..base_builder() },
];
for mut builder in cases {
builder.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
assert!(build_fal_seed_audio_1p0_state(builder).is_err());
}
}
#[test]
fn unsupported_options_are_dropped_under_lenient_strategies() {
let builder = GenerateAudioRequestBuilder {
style_prompt: Some("EDM".to_string()),
keep_lyrics: Some(true),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..base_builder()
};
assert!(build_fal_seed_audio_1p0_state(builder).is_ok());
}
}
// ── Helpers ──
fn base_builder() -> GenerateAudioRequestBuilder {
GenerateAudioRequestBuilder {
model: RouterAudioModel::SeedAudio1p0,
provider: RouterProvider::Fal,
prompt: Some("a suspense radio drama".to_string()),
..Default::default()
}
}
fn builder_with_audio_urls(count: usize) -> GenerateAudioRequestBuilder {
let urls = (0..count).map(|i| format!("https://example.com/a{i}.mp3")).collect();
GenerateAudioRequestBuilder {
audio_references: Some(AudioListRef::Urls(urls)),
..base_builder()
}
}
}
@@ -1,78 +0,0 @@
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
use crate::generate::generate_audio::audio_generation_cost_estimate::AudioGenerationCostEstimate;
use crate::generate::generate_audio::providers::fal::seed_audio_1p0::request::FalSeedAudio1p0RequestState;
#[derive(Clone, Debug)]
pub struct FalSeedAudio1p0CostState {
pub cost_in_usd_cents: u64,
}
impl FalSeedAudio1p0CostState {
pub fn from_request(request: &FalSeedAudio1p0RequestState) -> Self {
// Cost math is owned by fal_client's per-endpoint
// `FalRequestCostCalculator` implementations. The router state just
// forwards the result so router cost ≡ fal_client cost by construction.
Self {
cost_in_usd_cents: request.request.calculate_cost_in_cents(),
}
}
pub fn estimate_cost(&self) -> AudioGenerationCostEstimate {
AudioGenerationCostEstimate {
cost_in_credits: Some(self.cost_in_usd_cents),
cost_in_usd_cents: Some(self.cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
#[cfg(test)]
mod tests {
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::providers::fal::seed_audio_1p0::build::build_fal_seed_audio_1p0_state;
use super::*;
#[test]
fn estimate_is_nineteen_cents() {
let state = build_fal_seed_audio_1p0_state(base_builder()).expect("build");
let estimate = FalSeedAudio1p0CostState::from_request(&state).estimate_cost();
assert_eq!(estimate.cost_in_usd_cents, Some(19));
assert_eq!(estimate.cost_in_credits, Some(19));
}
#[test]
fn options_do_not_change_the_estimate() {
let tuned = GenerateAudioRequestBuilder {
sample_rate_hz: Some(48_000),
speed: Some(2.0),
volume: Some(0.5),
pitch: Some(-12.0),
..base_builder()
};
let base_state = build_fal_seed_audio_1p0_state(base_builder()).expect("build");
let tuned_state = build_fal_seed_audio_1p0_state(tuned).expect("build");
assert_eq!(
FalSeedAudio1p0CostState::from_request(&base_state).cost_in_usd_cents,
FalSeedAudio1p0CostState::from_request(&tuned_state).cost_in_usd_cents,
);
}
// ── Helpers ──
fn base_builder() -> GenerateAudioRequestBuilder {
GenerateAudioRequestBuilder {
model: RouterAudioModel::SeedAudio1p0,
provider: RouterProvider::Fal,
prompt: Some("a calm narrator describes ocean waves".to_string()),
..Default::default()
}
}
}
@@ -1,3 +0,0 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -1,118 +0,0 @@
use std::fmt::Debug;
use std::sync::Arc;
use fal_client::requests::api::audio::omni::seed_audio_1p0::api::SeedAudio1p0Request;
use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_audio::generate_audio_response::{
FalAudioResponsePayload, GenerateAudioResponse,
};
#[derive(Clone, Debug)]
pub struct FalSeedAudio1p0RequestState {
/// Final materialized request; ready to fire.
pub request: SeedAudio1p0Request,
}
impl FalSeedAudio1p0RequestState {
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateAudioResponse, ArtcraftRouterError> {
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(self.request.clone());
let payload = if let Some(webhook_url) = &client.webhook_url {
let response = self.request.send_webhook_request(&client.api_key, webhook_url).await?;
FalAudioResponsePayload {
request_id: response.request_id,
gateway_request_id: response.gateway_request_id,
maybe_status_url: None,
maybe_response_url: None,
maybe_outbound_request: Some(outbound),
}
} else {
let response = self.request.send_queue_request(&client.api_key).await?;
FalAudioResponsePayload {
request_id: Some(response.request_id),
gateway_request_id: None,
maybe_status_url: Some(response.status_url),
maybe_response_url: Some(response.response_url),
maybe_outbound_request: Some(outbound),
}
};
Ok(GenerateAudioResponse::Fal(payload))
}
}
#[cfg(test)]
mod tests {
use fal_client::creds::fal_api_key::FalApiKey;
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::client::router_client::RouterClient;
use crate::client::router_fal_client::RouterFalClient;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse;
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn send_seed_audio_webhook() {
let response = run_pipeline(GenerateAudioRequestBuilder {
prompt: Some("A calm narrator describes ocean waves rolling onto a moonlit beach.".to_string()),
..seed_audio_builder()
}).await;
let payload = response.get_fal_payload().expect("expected Fal payload");
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn send_seed_audio_tuned() {
let response = run_pipeline(GenerateAudioRequestBuilder {
prompt: Some("Welcome aboard the midnight express. Please keep your arms inside the train.".to_string()),
sample_rate_hz: Some(44_100),
speed: Some(0.9),
volume: Some(1.2),
pitch: Some(-2.0),
..seed_audio_builder()
}).await;
let payload = response.get_fal_payload().expect("expected Fal payload");
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
assert_eq!(1, 2, "Inspect output above");
}
// ── Helpers ──
fn seed_audio_builder() -> GenerateAudioRequestBuilder {
GenerateAudioRequestBuilder {
model: RouterAudioModel::SeedAudio1p0,
provider: RouterProvider::Fal,
..Default::default()
}
}
fn get_fal_client() -> RouterClient {
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")
.expect("Failed to read fal_api_key.txt");
let api_key = FalApiKey::from_str(secret.trim());
let webhook_url = "https://example.com/fal-webhook-test".to_string();
RouterClient::Fal(RouterFalClient::new_with_webhook(api_key, webhook_url))
}
async fn run_pipeline(builder: GenerateAudioRequestBuilder) -> GenerateAudioResponse {
let client = get_fal_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
AudioGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant (Seed Audio skips draft)"),
};
let response = request.send_request(&client).await.expect("send_request should succeed");
println!("response: {:?}", response);
response
}
}
@@ -1,5 +0,0 @@
pub mod suno_music;
pub mod suno_remix;
pub mod suno_sample;
pub mod suno_sounds;
pub(crate) mod resolve;
@@ -1,133 +0,0 @@
//! Shared resolve helpers for the Kinovi audio provider.
//!
//! The remix/sample models take exactly one user audio reference, which must
//! be downloaded from our CDN and re-uploaded to the Kinovi CDN during the
//! draft `finalize()` phase. The upload plumbing is shared with the Kinovi
//! video provider (`generate_video::providers::kinovi::upload`).
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::audio_list_ref::AudioListRef;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_audio::audio_generation_draft_context::AudioGenerationDraftContext;
use crate::generate::generate_video::providers::kinovi::upload::upload_to_seedance2pro;
/// A single unresolved audio reference: either an ArtCraft/public URL or a
/// media file token that resolves to one via the draft context's map.
#[derive(Debug, Clone)]
pub enum SingleAudioRef {
Url(String),
MediaFileToken(MediaFileToken),
}
/// Extract exactly one audio reference from the builder's audio list.
/// The Kinovi remix/sample models operate on a single source track, so zero
/// or multiple references are rejected regardless of mitigation strategy.
pub(crate) fn require_single_audio_ref(
audio_references: Option<AudioListRef>,
) -> Result<SingleAudioRef, ArtcraftRouterError> {
let refs = match audio_references {
None => {
return Err(ArtcraftRouterError::InvalidInput(
"Exactly one audio reference is required, but none was provided".to_string(),
));
}
Some(AudioListRef::Urls(urls)) => urls.into_iter().map(SingleAudioRef::Url).collect::<Vec<_>>(),
Some(AudioListRef::MediaFileTokens(tokens)) => {
tokens.into_iter().map(SingleAudioRef::MediaFileToken).collect::<Vec<_>>()
}
};
match refs.len() {
0 => Err(ArtcraftRouterError::InvalidInput(
"Exactly one audio reference is required, but none was provided".to_string(),
)),
1 => Ok(refs.into_iter().next().expect("length checked above")),
count => Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "audio_references",
value: format!("Exactly one audio reference is required, got {}", count),
})),
}
}
/// Resolve a single audio reference to a source URL and re-upload it to the
/// Kinovi CDN, returning the Kinovi CDN URL.
pub(crate) async fn resolve_and_upload_audio_ref(
session: &Seedance2ProSession,
audio_ref: &SingleAudioRef,
draft_context: &AudioGenerationDraftContext<'_>,
) -> Result<String, ArtcraftRouterError> {
let source_url = match audio_ref {
SingleAudioRef::Url(url) => url.clone(),
SingleAudioRef::MediaFileToken(token) => {
let map = draft_context.media_file_to_artcraft_url_map
.ok_or(ArtcraftRouterError::Client(ClientError::MediaFileToUrlMapNotProvided))?;
map.get(token).cloned().ok_or_else(|| {
ArtcraftRouterError::Client(ClientError::MediaFileTokenNotFoundInMap {
token: token.clone(),
})
})?
}
};
upload_to_seedance2pro(session, &source_url).await
}
#[cfg(test)]
mod tests {
use super::*;
mod require_single_audio_ref_tests {
use super::*;
#[test]
fn none_is_rejected() {
assert!(require_single_audio_ref(None).is_err());
}
#[test]
fn empty_url_list_is_rejected() {
assert!(require_single_audio_ref(Some(AudioListRef::Urls(vec![]))).is_err());
}
#[test]
fn empty_token_list_is_rejected() {
assert!(require_single_audio_ref(Some(AudioListRef::MediaFileTokens(vec![]))).is_err());
}
#[test]
fn single_url_is_accepted() {
let result = require_single_audio_ref(Some(AudioListRef::Urls(vec![
"https://example.com/a.mp3".to_string(),
]))).expect("should accept a single URL");
assert!(matches!(result, SingleAudioRef::Url(url) if url == "https://example.com/a.mp3"));
}
#[test]
fn single_token_is_accepted() {
let result = require_single_audio_ref(Some(AudioListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_test123".to_string()),
]))).expect("should accept a single token");
assert!(matches!(result, SingleAudioRef::MediaFileToken(t) if t.as_str() == "mf_test123"));
}
#[test]
fn two_urls_are_rejected() {
let result = require_single_audio_ref(Some(AudioListRef::Urls(vec![
"https://example.com/a.mp3".to_string(),
"https://example.com/b.mp3".to_string(),
])));
assert!(result.is_err());
}
#[test]
fn two_tokens_are_rejected() {
let result = require_single_audio_ref(Some(AudioListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_a".to_string()),
MediaFileToken::new("mf_b".to_string()),
])));
assert!(result.is_err());
}
}
}
@@ -1,128 +0,0 @@
use seedance2pro_client::generate::audio::generate_suno_music::GenerateSunoMusicRequest;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::audio_generation_request::AudioGenerationRequest;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::providers::kinovi::suno_music::request::KinoviSunoMusicRequestState;
use crate::generate::generate_audio::providers::reject_unsupported::{
reject_unsupported_audio_references, reject_unsupported_image_references,
reject_unsupported_option,
};
pub fn build_kinovi_suno_music(builder: GenerateAudioRequestBuilder) -> Result<AudioGenerationDraftOrRequest, ArtcraftRouterError> {
let state = build_kinovi_suno_music_state(builder)?;
Ok(AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::KinoviSunoMusic(state)))
}
pub(crate) fn build_kinovi_suno_music_state(
mut builder: GenerateAudioRequestBuilder,
) -> Result<KinoviSunoMusicRequestState, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
// Options Suno Music has no equivalent for.
reject_unsupported_option("keep_lyrics", builder.keep_lyrics.as_ref(), strategy)?;
reject_unsupported_option("is_loopable", builder.is_loopable.as_ref(), strategy)?;
reject_unsupported_option("bpm", builder.bpm.as_ref(), strategy)?;
reject_unsupported_option("musical_key", builder.musical_key.as_ref(), strategy)?;
reject_unsupported_option("sample_rate_hz", builder.sample_rate_hz.as_ref(), strategy)?;
reject_unsupported_option("speed", builder.speed.as_ref(), strategy)?;
reject_unsupported_option("volume", builder.volume.as_ref(), strategy)?;
reject_unsupported_option("pitch", builder.pitch.as_ref(), strategy)?;
reject_unsupported_audio_references(builder.audio_references.as_ref(), strategy)?;
reject_unsupported_image_references(builder.image_references.as_ref(), strategy)?;
let prompt = builder.prompt.take().ok_or_else(|| {
ArtcraftRouterError::InvalidInput("A prompt is required for Suno Music".to_string())
})?;
let request = GenerateSunoMusicRequest {
prompt,
style_tags: builder.style_prompt.take(),
instrumental: builder.is_instrumental.unwrap_or(false),
};
Ok(KinoviSunoMusicRequestState { request })
}
#[cfg(test)]
mod tests {
use crate::api::audio_list_ref::AudioListRef;
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use super::*;
#[test]
fn prompt_and_style_are_passed_through() {
let state = build_kinovi_suno_music_state(base_builder()).expect("build");
assert_eq!(state.request.prompt, "a song about corgis");
assert_eq!(state.request.style_tags.as_deref(), Some("sea shanty"));
}
#[test]
fn missing_prompt_is_rejected() {
let builder = GenerateAudioRequestBuilder { prompt: None, ..base_builder() };
assert!(build_kinovi_suno_music_state(builder).is_err());
}
#[test]
fn instrumental_defaults_to_false() {
let state = build_kinovi_suno_music_state(base_builder()).expect("build");
assert!(!state.request.instrumental);
}
#[test]
fn instrumental_passes_through() {
let builder = GenerateAudioRequestBuilder { is_instrumental: Some(true), ..base_builder() };
let state = build_kinovi_suno_music_state(builder).expect("build");
assert!(state.request.instrumental);
}
#[test]
fn unsupported_options_error_out() {
let error_out = RequestMismatchMitigationStrategy::ErrorOut;
let cases: Vec<GenerateAudioRequestBuilder> = vec![
GenerateAudioRequestBuilder { keep_lyrics: Some(true), ..base_builder() },
GenerateAudioRequestBuilder { is_loopable: Some(true), ..base_builder() },
GenerateAudioRequestBuilder { bpm: Some(120), ..base_builder() },
GenerateAudioRequestBuilder { sample_rate_hz: Some(48_000), ..base_builder() },
GenerateAudioRequestBuilder { speed: Some(1.5), ..base_builder() },
GenerateAudioRequestBuilder { volume: Some(0.5), ..base_builder() },
GenerateAudioRequestBuilder { pitch: Some(2.0), ..base_builder() },
GenerateAudioRequestBuilder {
audio_references: Some(AudioListRef::Urls(vec!["https://example.com/a.mp3".to_string()])),
..base_builder()
},
];
for mut builder in cases {
builder.request_mismatch_mitigation_strategy = error_out;
assert!(build_kinovi_suno_music_state(builder).is_err());
}
}
#[test]
fn unsupported_options_are_dropped_under_lenient_strategies() {
let builder = GenerateAudioRequestBuilder {
keep_lyrics: Some(true),
bpm: Some(120),
speed: Some(1.5),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..base_builder()
};
assert!(build_kinovi_suno_music_state(builder).is_ok());
}
// ── Helpers ──
fn base_builder() -> GenerateAudioRequestBuilder {
GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoMusic,
provider: RouterProvider::Seedance2Pro,
prompt: Some("a song about corgis".to_string()),
style_prompt: Some("sea shanty".to_string()),
..Default::default()
}
}
}
@@ -1,65 +0,0 @@
use seedance2pro_client::generate::audio::generate_suno_music::GenerateSunoMusicRequest;
use crate::generate::generate_audio::audio_generation_cost_estimate::AudioGenerationCostEstimate;
use crate::generate::generate_audio::providers::kinovi::suno_music::request::KinoviSunoMusicRequestState;
/// Suno Music is flat priced, so the cost state carries no request fields.
#[derive(Clone, Debug)]
pub struct KinoviSunoMusicCostState;
impl KinoviSunoMusicCostState {
pub fn from_request(_request: &KinoviSunoMusicRequestState) -> Self {
Self
}
pub fn estimate_cost(&self) -> AudioGenerationCostEstimate {
// Cost math is owned by seedance2pro_client's binding — the router just
// forwards the result so router cost ≡ binding cost by construction.
let pricing_request = GenerateSunoMusicRequest {
prompt: String::new(),
style_tags: None,
instrumental: false,
};
let costs = pricing_request.calculate_costs();
AudioGenerationCostEstimate {
cost_in_credits: Some(costs.kinovi_credits),
cost_in_usd_cents: Some(costs.usd_cents_rounded_up),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
#[cfg(test)]
mod tests {
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::providers::kinovi::suno_music::build::build_kinovi_suno_music_state;
use super::*;
#[test]
fn flat_cost_is_seven_cents() {
let estimate = KinoviSunoMusicCostState.estimate_cost();
assert_eq!(estimate.cost_in_usd_cents, Some(7));
assert_eq!(estimate.cost_in_credits, Some(16));
}
#[test]
fn cost_from_built_request_is_seven_cents() {
let builder = GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoMusic,
provider: RouterProvider::Seedance2Pro,
prompt: Some("a song".to_string()),
..Default::default()
};
let state = build_kinovi_suno_music_state(builder).expect("build");
let estimate = KinoviSunoMusicCostState::from_request(&state).estimate_cost();
assert_eq!(estimate.cost_in_usd_cents, Some(7));
}
}
@@ -1,3 +0,0 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -1,113 +0,0 @@
use seedance2pro_client::generate::audio::generate_suno_music::{
generate_suno_music, GenerateSunoMusicArgs, GenerateSunoMusicRequest,
};
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_audio::generate_audio_response::{
GenerateAudioResponse, Seedance2proAudioResponsePayload,
};
#[derive(Debug, Clone)]
pub struct KinoviSunoMusicRequestState {
/// Final materialized request; ready to fire.
pub request: GenerateSunoMusicRequest,
}
impl KinoviSunoMusicRequestState {
pub async fn send(&self, client: &RouterSeedance2ProClient) -> Result<GenerateAudioResponse, ArtcraftRouterError> {
let session = &client.session;
let args = GenerateSunoMusicArgs {
session,
host_override: None,
request: self.request.clone(),
};
let response = generate_suno_music(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
Ok(GenerateAudioResponse::Seedance2Pro(Seedance2proAudioResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
}))
}
}
#[cfg(test)]
mod tests {
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::client::router_client::RouterClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse;
#[tokio::test]
#[ignore] // Sends a real generation to Kinovi; costs credits.
async fn music_with_vocals() {
let response = run_pipeline(GenerateAudioRequestBuilder {
prompt: Some("A song about a corgi who learns to sail the open sea".to_string()),
style_prompt: Some("Sea shanty, folk".to_string()),
..suno_music_builder()
}).await;
assert!(matches!(response, GenerateAudioResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // Sends a real generation to Kinovi; costs credits.
async fn music_instrumental() {
let response = run_pipeline(GenerateAudioRequestBuilder {
prompt: Some("An epic journey across a frozen mountain range".to_string()),
style_prompt: Some("Cinematic orchestral score".to_string()),
is_instrumental: Some(true),
..suno_music_builder()
}).await;
assert!(matches!(response, GenerateAudioResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
// ── Helpers ──
fn suno_music_builder() -> GenerateAudioRequestBuilder {
GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoMusic,
provider: RouterProvider::Seedance2Pro,
..Default::default()
}
}
fn get_seedance2pro_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt")
.expect("Failed to read seedance2pro cookies");
let session = Seedance2ProSession::from_cookies_string(cookies.trim().to_string());
RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session))
}
async fn run_pipeline(builder: GenerateAudioRequestBuilder) -> GenerateAudioResponse {
let client = get_seedance2pro_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
AudioGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant (Suno Music skips draft)"),
};
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateAudioResponse::Seedance2Pro(p) => {
println!("task_id={}, order_id={}", p.task_id, p.order_id);
}
other => println!("response: {:?}", other),
}
response
}
}
@@ -1,178 +0,0 @@
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_audio::audio_generation_draft::AudioGenerationDraftRequest;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::providers::kinovi::resolve::require_single_audio_ref;
use crate::generate::generate_audio::providers::kinovi::suno_remix::draft::KinoviSunoRemixDraftState;
use crate::generate::generate_audio::providers::reject_unsupported::{
reject_unsupported_image_references, reject_unsupported_option,
};
pub fn build_kinovi_suno_remix(builder: GenerateAudioRequestBuilder) -> Result<AudioGenerationDraftOrRequest, ArtcraftRouterError> {
let draft = build_kinovi_suno_remix_draft(builder)?;
Ok(AudioGenerationDraftOrRequest::Draft(AudioGenerationDraftRequest::KinoviSunoRemix(draft)))
}
pub(crate) fn build_kinovi_suno_remix_draft(
mut builder: GenerateAudioRequestBuilder,
) -> Result<KinoviSunoRemixDraftState, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
// Options Suno Remix has no equivalent for.
reject_unsupported_option("is_instrumental", builder.is_instrumental.as_ref(), strategy)?;
reject_unsupported_option("is_loopable", builder.is_loopable.as_ref(), strategy)?;
reject_unsupported_option("bpm", builder.bpm.as_ref(), strategy)?;
reject_unsupported_option("musical_key", builder.musical_key.as_ref(), strategy)?;
reject_unsupported_option("sample_rate_hz", builder.sample_rate_hz.as_ref(), strategy)?;
reject_unsupported_option("speed", builder.speed.as_ref(), strategy)?;
reject_unsupported_option("volume", builder.volume.as_ref(), strategy)?;
reject_unsupported_option("pitch", builder.pitch.as_ref(), strategy)?;
reject_unsupported_image_references(builder.image_references.as_ref(), strategy)?;
let prompt = builder.prompt.take().ok_or_else(|| {
ArtcraftRouterError::InvalidInput("A prompt is required for Suno Remix".to_string())
})?;
let audio_source = require_single_audio_ref(builder.audio_references.take())?;
Ok(KinoviSunoRemixDraftState {
prompt,
style_tags: builder.style_prompt.take(),
keep_lyrics: builder.keep_lyrics.unwrap_or(false),
audio_source: Some(audio_source),
})
}
#[cfg(test)]
mod tests {
use tokens::tokens::media_files::MediaFileToken;
use crate::api::audio_list_ref::AudioListRef;
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_audio::providers::kinovi::resolve::SingleAudioRef;
use super::*;
mod field_conversions {
use super::*;
#[test]
fn prompt_and_style_are_passed_through() {
let draft = build_kinovi_suno_remix_draft(base_builder()).expect("build");
assert_eq!(draft.prompt, "make this electronic");
assert_eq!(draft.style_tags.as_deref(), Some("EDM style"));
}
#[test]
fn missing_prompt_is_rejected() {
let builder = GenerateAudioRequestBuilder { prompt: None, ..base_builder() };
assert!(build_kinovi_suno_remix_draft(builder).is_err());
}
#[test]
fn keep_lyrics_defaults_to_false() {
let draft = build_kinovi_suno_remix_draft(base_builder()).expect("build");
assert!(!draft.keep_lyrics);
}
#[test]
fn keep_lyrics_passes_through() {
let builder = GenerateAudioRequestBuilder { keep_lyrics: Some(true), ..base_builder() };
let draft = build_kinovi_suno_remix_draft(builder).expect("build");
assert!(draft.keep_lyrics);
}
}
mod audio_reference_requirements {
use super::*;
#[test]
fn exactly_one_url_reference_is_accepted() {
let draft = build_kinovi_suno_remix_draft(base_builder()).expect("build");
assert!(matches!(
draft.audio_source,
Some(SingleAudioRef::Url(url)) if url == "https://example.com/a.mp3"
));
}
#[test]
fn a_media_token_reference_is_accepted() {
let builder = GenerateAudioRequestBuilder {
audio_references: Some(AudioListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_test123".to_string()),
])),
..base_builder()
};
let draft = build_kinovi_suno_remix_draft(builder).expect("build");
assert!(matches!(
draft.audio_source,
Some(SingleAudioRef::MediaFileToken(t)) if t.as_str() == "mf_test123"
));
}
#[test]
fn zero_references_are_rejected() {
let builder = GenerateAudioRequestBuilder { audio_references: None, ..base_builder() };
assert!(build_kinovi_suno_remix_draft(builder).is_err());
}
#[test]
fn two_references_are_rejected() {
let builder = GenerateAudioRequestBuilder {
audio_references: Some(AudioListRef::Urls(vec![
"https://example.com/a.mp3".to_string(),
"https://example.com/b.mp3".to_string(),
])),
..base_builder()
};
assert!(build_kinovi_suno_remix_draft(builder).is_err());
}
}
mod unsupported_options {
use super::*;
#[test]
fn unsupported_options_error_out() {
let cases: Vec<GenerateAudioRequestBuilder> = vec![
GenerateAudioRequestBuilder { is_instrumental: Some(true), ..base_builder() },
GenerateAudioRequestBuilder { is_loopable: Some(true), ..base_builder() },
GenerateAudioRequestBuilder { bpm: Some(120), ..base_builder() },
GenerateAudioRequestBuilder { sample_rate_hz: Some(48_000), ..base_builder() },
GenerateAudioRequestBuilder { speed: Some(1.5), ..base_builder() },
GenerateAudioRequestBuilder { volume: Some(0.5), ..base_builder() },
GenerateAudioRequestBuilder { pitch: Some(2.0), ..base_builder() },
];
for mut builder in cases {
builder.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
assert!(build_kinovi_suno_remix_draft(builder).is_err());
}
}
#[test]
fn unsupported_options_are_dropped_under_lenient_strategies() {
let builder = GenerateAudioRequestBuilder {
is_instrumental: Some(true),
bpm: Some(120),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..base_builder()
};
assert!(build_kinovi_suno_remix_draft(builder).is_ok());
}
}
// ── Helpers ──
fn base_builder() -> GenerateAudioRequestBuilder {
GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoRemix,
provider: RouterProvider::Seedance2Pro,
prompt: Some("make this electronic".to_string()),
style_prompt: Some("EDM style".to_string()),
audio_references: Some(AudioListRef::Urls(vec!["https://example.com/a.mp3".to_string()])),
..Default::default()
}
}
}
@@ -1,75 +0,0 @@
use seedance2pro_client::generate::audio::generate_suno_remix::{
GenerateSunoRemixRequest, KinoviSunoRemixSource,
};
use crate::generate::generate_audio::audio_generation_cost_estimate::AudioGenerationCostEstimate;
use crate::generate::generate_audio::providers::kinovi::suno_remix::draft::KinoviSunoRemixDraftState;
use crate::generate::generate_audio::providers::kinovi::suno_remix::request::KinoviSunoRemixRequestState;
/// Suno Remix is flat priced, so the cost state carries no request fields.
#[derive(Clone, Debug)]
pub struct KinoviSunoRemixCostState;
impl KinoviSunoRemixCostState {
pub fn from_request(_request: &KinoviSunoRemixRequestState) -> Self {
Self
}
pub fn from_draft(_draft: &KinoviSunoRemixDraftState) -> Self {
Self
}
pub fn estimate_cost(&self) -> AudioGenerationCostEstimate {
// Cost math is owned by seedance2pro_client's binding — the router just
// forwards the result so router cost ≡ binding cost by construction.
let pricing_request = GenerateSunoRemixRequest {
prompt: String::new(),
source: KinoviSunoRemixSource::AudioUrl("pricing-placeholder".to_string()),
style_tags: None,
keep_lyrics: false,
};
let costs = pricing_request.calculate_costs();
AudioGenerationCostEstimate {
cost_in_credits: Some(costs.kinovi_credits),
cost_in_usd_cents: Some(costs.usd_cents_rounded_up),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
#[cfg(test)]
mod tests {
use crate::api::audio_list_ref::AudioListRef;
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::providers::kinovi::suno_remix::build::build_kinovi_suno_remix_draft;
use super::*;
#[test]
fn flat_cost_is_seven_cents() {
let estimate = KinoviSunoRemixCostState.estimate_cost();
assert_eq!(estimate.cost_in_usd_cents, Some(7));
assert_eq!(estimate.cost_in_credits, Some(16));
}
#[test]
fn cost_from_draft_is_seven_cents() {
let builder = GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoRemix,
provider: RouterProvider::Seedance2Pro,
prompt: Some("make this electronic".to_string()),
audio_references: Some(AudioListRef::Urls(vec!["https://example.com/a.mp3".to_string()])),
..Default::default()
};
let draft = build_kinovi_suno_remix_draft(builder).expect("build");
let estimate = KinoviSunoRemixCostState::from_draft(&draft).estimate_cost();
assert_eq!(estimate.cost_in_usd_cents, Some(7));
}
}
@@ -1,49 +0,0 @@
use seedance2pro_client::generate::audio::generate_suno_remix::{
GenerateSunoRemixRequest, KinoviSunoRemixSource,
};
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_audio::audio_generation_draft_context::AudioGenerationDraftContext;
use crate::generate::generate_audio::providers::kinovi::resolve::{
resolve_and_upload_audio_ref, SingleAudioRef,
};
use crate::generate::generate_audio::providers::kinovi::suno_remix::request::KinoviSunoRemixRequestState;
#[derive(Debug, Clone)]
pub struct KinoviSunoRemixDraftState {
// Materialized / finalized types
pub prompt: String,
pub style_tags: Option<String>,
pub keep_lyrics: bool,
// Pending types that need to be resolved and uploaded to the Kinovi CDN.
pub audio_source: Option<SingleAudioRef>,
}
impl KinoviSunoRemixDraftState {
pub async fn to_request(
&mut self,
draft_context: &AudioGenerationDraftContext<'_>,
) -> Result<KinoviSunoRemixRequestState, ArtcraftRouterError> {
let client = draft_context.get_seedance2pro_client_ref()?;
let session = &client.session;
let audio_source = self.audio_source.take().ok_or_else(|| {
ArtcraftRouterError::InvalidInput(
"Suno Remix draft has no audio source (already finalized?)".to_string(),
)
})?;
let kinovi_audio_url = resolve_and_upload_audio_ref(session, &audio_source, draft_context).await?;
let request = GenerateSunoRemixRequest {
prompt: self.prompt.clone(),
source: KinoviSunoRemixSource::AudioUrl(kinovi_audio_url),
style_tags: self.style_tags.clone(),
keep_lyrics: self.keep_lyrics,
};
Ok(KinoviSunoRemixRequestState { request })
}
}
@@ -1,4 +0,0 @@
pub mod build;
pub mod cost;
pub mod draft;
pub mod request;
@@ -1,134 +0,0 @@
use seedance2pro_client::generate::audio::generate_suno_remix::{
generate_suno_remix, GenerateSunoRemixArgs, GenerateSunoRemixRequest,
};
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_audio::generate_audio_response::{
GenerateAudioResponse, Seedance2proAudioResponsePayload,
};
#[derive(Debug, Clone)]
pub struct KinoviSunoRemixRequestState {
/// Final materialized request; ready to fire. The audio source has been
/// re-uploaded to the Kinovi CDN by the draft's `to_request()`.
pub request: GenerateSunoRemixRequest,
}
impl KinoviSunoRemixRequestState {
pub async fn send(&self, client: &RouterSeedance2ProClient) -> Result<GenerateAudioResponse, ArtcraftRouterError> {
let session = &client.session;
let args = GenerateSunoRemixArgs {
session,
host_override: None,
request: self.request.clone(),
};
let response = generate_suno_remix(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
Ok(GenerateAudioResponse::Seedance2Pro(Seedance2proAudioResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
}))
}
}
#[cfg(test)]
mod tests {
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
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 test_utils::test_file_path::test_file_path;
use crate::api::audio_list_ref::AudioListRef;
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::client::router_client::RouterClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::generate::generate_audio::audio_generation_draft_context::AudioGenerationDraftContext;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse;
const TEST_AUDIO_PATH: &str = "test_data/audio/mp3/super_mario_rpg_beware_the_forests_mushrooms.mp3";
#[tokio::test]
#[ignore] // Sends a real generation to Kinovi; costs credits. Requires a local audio file.
async fn remix_uploaded_audio() {
let client = get_seedance2pro_client();
let audio_url = upload_test_audio(&client).await;
println!("Uploaded audio: {}", audio_url);
let builder = GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoRemix,
provider: RouterProvider::Seedance2Pro,
prompt: Some("Make this electronic".to_string()),
style_prompt: Some("EDM style".to_string()),
audio_references: Some(AudioListRef::Urls(vec![audio_url])),
..Default::default()
};
let response = run_pipeline(&client, builder).await;
assert!(matches!(response, GenerateAudioResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
// ── Helpers ──
fn get_seedance2pro_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt")
.expect("Failed to read seedance2pro cookies");
let session = Seedance2ProSession::from_cookies_string(cookies.trim().to_string());
RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session))
}
async fn run_pipeline(client: &RouterClient, builder: GenerateAudioRequestBuilder) -> GenerateAudioResponse {
let draft_or_request = builder.build2().expect("build2 should succeed");
let draft = match draft_or_request {
AudioGenerationDraftOrRequest::Draft(d) => d,
_ => panic!("expected Draft variant (Suno Remix uses the draft phase)"),
};
let draft_context = AudioGenerationDraftContext {
client: Some(client),
..Default::default()
};
let request = draft.finalize(draft_context).await.expect("finalize should succeed");
let response = request.send_request(client).await.expect("send_request should succeed");
match &response {
GenerateAudioResponse::Seedance2Pro(p) => {
println!("task_id={}, order_id={}", p.task_id, p.order_id);
}
other => println!("response: {:?}", other),
}
response
}
async fn upload_test_audio(client: &RouterClient) -> String {
let session = &client.get_seedance2pro_client_ref().expect("seedance2pro client").session;
let audio_path = test_file_path(TEST_AUDIO_PATH).expect("test audio should exist");
let audio_bytes = std::fs::read(&audio_path).expect("read test audio");
let prepare_result = prepare_file_upload(PrepareFileUploadArgs {
session,
extension: "mp3".to_string(),
host_override: None,
}).await.expect("prepare upload");
let upload_result = upload_file(UploadFileArgs {
upload_url: prepare_result.upload_url,
file_bytes: audio_bytes,
host_override: None,
}).await.expect("upload");
upload_result.public_url
}
}
@@ -1,172 +0,0 @@
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_audio::audio_generation_draft::AudioGenerationDraftRequest;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::providers::kinovi::resolve::require_single_audio_ref;
use crate::generate::generate_audio::providers::kinovi::suno_sample::draft::KinoviSunoSampleDraftState;
use crate::generate::generate_audio::providers::reject_unsupported::{
reject_unsupported_image_references, reject_unsupported_option,
};
/// The omni audio API doesn't carry a sample window yet, so every sample
/// generation chops the first 30 seconds of the source audio.
pub(crate) const DEFAULT_CHOP_SAMPLE_START_SECONDS: u32 = 0;
pub(crate) const DEFAULT_CHOP_SAMPLE_END_SECONDS: u32 = 30;
pub fn build_kinovi_suno_sample(builder: GenerateAudioRequestBuilder) -> Result<AudioGenerationDraftOrRequest, ArtcraftRouterError> {
let draft = build_kinovi_suno_sample_draft(builder)?;
Ok(AudioGenerationDraftOrRequest::Draft(AudioGenerationDraftRequest::KinoviSunoSample(draft)))
}
pub(crate) fn build_kinovi_suno_sample_draft(
mut builder: GenerateAudioRequestBuilder,
) -> Result<KinoviSunoSampleDraftState, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
// Options Suno Sample has no equivalent for.
reject_unsupported_option("keep_lyrics", builder.keep_lyrics.as_ref(), strategy)?;
reject_unsupported_option("is_loopable", builder.is_loopable.as_ref(), strategy)?;
reject_unsupported_option("bpm", builder.bpm.as_ref(), strategy)?;
reject_unsupported_option("musical_key", builder.musical_key.as_ref(), strategy)?;
reject_unsupported_option("sample_rate_hz", builder.sample_rate_hz.as_ref(), strategy)?;
reject_unsupported_option("speed", builder.speed.as_ref(), strategy)?;
reject_unsupported_option("volume", builder.volume.as_ref(), strategy)?;
reject_unsupported_option("pitch", builder.pitch.as_ref(), strategy)?;
reject_unsupported_image_references(builder.image_references.as_ref(), strategy)?;
let prompt = builder.prompt.take().ok_or_else(|| {
ArtcraftRouterError::InvalidInput("A prompt is required for Suno Sample".to_string())
})?;
let audio_source = require_single_audio_ref(builder.audio_references.take())?;
Ok(KinoviSunoSampleDraftState {
prompt,
style_tags: builder.style_prompt.take(),
instrumental: builder.is_instrumental.unwrap_or(false),
chop_sample_start_seconds: DEFAULT_CHOP_SAMPLE_START_SECONDS,
chop_sample_end_seconds: DEFAULT_CHOP_SAMPLE_END_SECONDS,
audio_source: Some(audio_source),
})
}
#[cfg(test)]
mod tests {
use crate::api::audio_list_ref::AudioListRef;
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_audio::providers::kinovi::resolve::SingleAudioRef;
use super::*;
mod field_conversions {
use super::*;
#[test]
fn prompt_and_style_are_passed_through() {
let draft = build_kinovi_suno_sample_draft(base_builder()).expect("build");
assert_eq!(draft.prompt, "mystical RPG adventure");
assert_eq!(draft.style_tags.as_deref(), Some("fantasy video game"));
}
#[test]
fn missing_prompt_is_rejected() {
let builder = GenerateAudioRequestBuilder { prompt: None, ..base_builder() };
assert!(build_kinovi_suno_sample_draft(builder).is_err());
}
#[test]
fn instrumental_defaults_to_false() {
let draft = build_kinovi_suno_sample_draft(base_builder()).expect("build");
assert!(!draft.instrumental);
}
#[test]
fn instrumental_passes_through() {
let builder = GenerateAudioRequestBuilder { is_instrumental: Some(true), ..base_builder() };
let draft = build_kinovi_suno_sample_draft(builder).expect("build");
assert!(draft.instrumental);
}
#[test]
fn chop_window_defaults_to_first_thirty_seconds() {
let draft = build_kinovi_suno_sample_draft(base_builder()).expect("build");
assert_eq!(draft.chop_sample_start_seconds, 0);
assert_eq!(draft.chop_sample_end_seconds, 30);
}
}
mod audio_reference_requirements {
use super::*;
#[test]
fn exactly_one_reference_is_accepted() {
let draft = build_kinovi_suno_sample_draft(base_builder()).expect("build");
assert!(matches!(draft.audio_source, Some(SingleAudioRef::Url(_))));
}
#[test]
fn zero_references_are_rejected() {
let builder = GenerateAudioRequestBuilder { audio_references: None, ..base_builder() };
assert!(build_kinovi_suno_sample_draft(builder).is_err());
}
#[test]
fn two_references_are_rejected() {
let builder = GenerateAudioRequestBuilder {
audio_references: Some(AudioListRef::Urls(vec![
"https://example.com/a.mp3".to_string(),
"https://example.com/b.mp3".to_string(),
])),
..base_builder()
};
assert!(build_kinovi_suno_sample_draft(builder).is_err());
}
}
mod unsupported_options {
use super::*;
#[test]
fn unsupported_options_error_out() {
let cases: Vec<GenerateAudioRequestBuilder> = vec![
GenerateAudioRequestBuilder { keep_lyrics: Some(true), ..base_builder() },
GenerateAudioRequestBuilder { is_loopable: Some(true), ..base_builder() },
GenerateAudioRequestBuilder { bpm: Some(120), ..base_builder() },
GenerateAudioRequestBuilder { sample_rate_hz: Some(48_000), ..base_builder() },
GenerateAudioRequestBuilder { speed: Some(1.5), ..base_builder() },
GenerateAudioRequestBuilder { volume: Some(0.5), ..base_builder() },
GenerateAudioRequestBuilder { pitch: Some(2.0), ..base_builder() },
];
for mut builder in cases {
builder.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
assert!(build_kinovi_suno_sample_draft(builder).is_err());
}
}
#[test]
fn unsupported_options_are_dropped_under_lenient_strategies() {
let builder = GenerateAudioRequestBuilder {
keep_lyrics: Some(true),
speed: Some(1.5),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..base_builder()
};
assert!(build_kinovi_suno_sample_draft(builder).is_ok());
}
}
// ── Helpers ──
fn base_builder() -> GenerateAudioRequestBuilder {
GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoSample,
provider: RouterProvider::Seedance2Pro,
prompt: Some("mystical RPG adventure".to_string()),
style_prompt: Some("fantasy video game".to_string()),
audio_references: Some(AudioListRef::Urls(vec!["https://example.com/a.aac".to_string()])),
..Default::default()
}
}
}
@@ -1,75 +0,0 @@
use seedance2pro_client::generate::audio::generate_suno_sample::GenerateSunoSampleRequest;
use crate::generate::generate_audio::audio_generation_cost_estimate::AudioGenerationCostEstimate;
use crate::generate::generate_audio::providers::kinovi::suno_sample::draft::KinoviSunoSampleDraftState;
use crate::generate::generate_audio::providers::kinovi::suno_sample::request::KinoviSunoSampleRequestState;
/// Suno Sample is flat priced, so the cost state carries no request fields.
#[derive(Clone, Debug)]
pub struct KinoviSunoSampleCostState;
impl KinoviSunoSampleCostState {
pub fn from_request(_request: &KinoviSunoSampleRequestState) -> Self {
Self
}
pub fn from_draft(_draft: &KinoviSunoSampleDraftState) -> Self {
Self
}
pub fn estimate_cost(&self) -> AudioGenerationCostEstimate {
// Cost math is owned by seedance2pro_client's binding — the router just
// forwards the result so router cost ≡ binding cost by construction.
let pricing_request = GenerateSunoSampleRequest {
prompt: String::new(),
audio_url: "pricing-placeholder".to_string(),
chop_sample_start_seconds: 0,
chop_sample_end_seconds: 30,
style_tags: None,
instrumental: false,
};
let costs = pricing_request.calculate_costs();
AudioGenerationCostEstimate {
cost_in_credits: Some(costs.kinovi_credits),
cost_in_usd_cents: Some(costs.usd_cents_rounded_up),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
#[cfg(test)]
mod tests {
use crate::api::audio_list_ref::AudioListRef;
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::providers::kinovi::suno_sample::build::build_kinovi_suno_sample_draft;
use super::*;
#[test]
fn flat_cost_is_seven_cents() {
let estimate = KinoviSunoSampleCostState.estimate_cost();
assert_eq!(estimate.cost_in_usd_cents, Some(7));
assert_eq!(estimate.cost_in_credits, Some(16));
}
#[test]
fn cost_from_draft_is_seven_cents() {
let builder = GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoSample,
provider: RouterProvider::Seedance2Pro,
prompt: Some("mystical RPG adventure".to_string()),
audio_references: Some(AudioListRef::Urls(vec!["https://example.com/a.aac".to_string()])),
..Default::default()
};
let draft = build_kinovi_suno_sample_draft(builder).expect("build");
let estimate = KinoviSunoSampleCostState::from_draft(&draft).estimate_cost();
assert_eq!(estimate.cost_in_usd_cents, Some(7));
}
}
@@ -1,59 +0,0 @@
use seedance2pro_client::generate::audio::generate_suno_sample::GenerateSunoSampleRequest;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_audio::audio_generation_draft_context::AudioGenerationDraftContext;
use crate::generate::generate_audio::providers::kinovi::resolve::{
resolve_and_upload_audio_ref, SingleAudioRef,
};
use crate::generate::generate_audio::providers::kinovi::suno_sample::request::KinoviSunoSampleRequestState;
#[derive(Debug, Clone)]
pub struct KinoviSunoSampleDraftState {
// Materialized / finalized types
pub prompt: String,
pub style_tags: Option<String>,
pub instrumental: bool,
/// Start of the sample window within the audio, in seconds.
/// NB: the omni audio API doesn't carry a sample window yet, so builds
/// default this to 0.
pub chop_sample_start_seconds: u32,
/// End of the sample window within the audio, in seconds.
/// NB: the omni audio API doesn't carry a sample window yet, so builds
/// default this to 30.
pub chop_sample_end_seconds: u32,
// Pending types that need to be resolved and uploaded to the Kinovi CDN.
pub audio_source: Option<SingleAudioRef>,
}
impl KinoviSunoSampleDraftState {
pub async fn to_request(
&mut self,
draft_context: &AudioGenerationDraftContext<'_>,
) -> Result<KinoviSunoSampleRequestState, ArtcraftRouterError> {
let client = draft_context.get_seedance2pro_client_ref()?;
let session = &client.session;
let audio_source = self.audio_source.take().ok_or_else(|| {
ArtcraftRouterError::InvalidInput(
"Suno Sample draft has no audio source (already finalized?)".to_string(),
)
})?;
let kinovi_audio_url = resolve_and_upload_audio_ref(session, &audio_source, draft_context).await?;
let request = GenerateSunoSampleRequest {
prompt: self.prompt.clone(),
audio_url: kinovi_audio_url,
chop_sample_start_seconds: self.chop_sample_start_seconds,
chop_sample_end_seconds: self.chop_sample_end_seconds,
style_tags: self.style_tags.clone(),
instrumental: self.instrumental,
};
Ok(KinoviSunoSampleRequestState { request })
}
}
@@ -1,4 +0,0 @@
pub mod build;
pub mod cost;
pub mod draft;
pub mod request;
@@ -1,135 +0,0 @@
use seedance2pro_client::generate::audio::generate_suno_sample::{
generate_suno_sample, GenerateSunoSampleArgs, GenerateSunoSampleRequest,
};
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_audio::generate_audio_response::{
GenerateAudioResponse, Seedance2proAudioResponsePayload,
};
#[derive(Debug, Clone)]
pub struct KinoviSunoSampleRequestState {
/// Final materialized request; ready to fire. The audio source has been
/// re-uploaded to the Kinovi CDN by the draft's `to_request()`.
pub request: GenerateSunoSampleRequest,
}
impl KinoviSunoSampleRequestState {
pub async fn send(&self, client: &RouterSeedance2ProClient) -> Result<GenerateAudioResponse, ArtcraftRouterError> {
let session = &client.session;
let args = GenerateSunoSampleArgs {
session,
host_override: None,
request: self.request.clone(),
};
let response = generate_suno_sample(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
Ok(GenerateAudioResponse::Seedance2Pro(Seedance2proAudioResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
}))
}
}
#[cfg(test)]
mod tests {
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
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 test_utils::test_file_path::test_file_path;
use crate::api::audio_list_ref::AudioListRef;
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::client::router_client::RouterClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::generate::generate_audio::audio_generation_draft_context::AudioGenerationDraftContext;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse;
const TEST_AUDIO_PATH: &str = "test_data/audio/aac/golden_sun_elemental_stars_cyanne.aac";
#[tokio::test]
#[ignore] // Sends a real generation to Kinovi; costs credits. Requires a local audio file.
async fn sample_instrumental() {
let client = get_seedance2pro_client();
let audio_url = upload_test_audio(&client).await;
println!("Uploaded audio: {}", audio_url);
let builder = GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoSample,
provider: RouterProvider::Seedance2Pro,
prompt: Some("Mystical RPG adventure, make it have a grand climax".to_string()),
style_prompt: Some("Fantasy video game score".to_string()),
is_instrumental: Some(true),
audio_references: Some(AudioListRef::Urls(vec![audio_url])),
..Default::default()
};
let response = run_pipeline(&client, builder).await;
assert!(matches!(response, GenerateAudioResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
// ── Helpers ──
fn get_seedance2pro_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt")
.expect("Failed to read seedance2pro cookies");
let session = Seedance2ProSession::from_cookies_string(cookies.trim().to_string());
RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session))
}
async fn run_pipeline(client: &RouterClient, builder: GenerateAudioRequestBuilder) -> GenerateAudioResponse {
let draft_or_request = builder.build2().expect("build2 should succeed");
let draft = match draft_or_request {
AudioGenerationDraftOrRequest::Draft(d) => d,
_ => panic!("expected Draft variant (Suno Sample uses the draft phase)"),
};
let draft_context = AudioGenerationDraftContext {
client: Some(client),
..Default::default()
};
let request = draft.finalize(draft_context).await.expect("finalize should succeed");
let response = request.send_request(client).await.expect("send_request should succeed");
match &response {
GenerateAudioResponse::Seedance2Pro(p) => {
println!("task_id={}, order_id={}", p.task_id, p.order_id);
}
other => println!("response: {:?}", other),
}
response
}
async fn upload_test_audio(client: &RouterClient) -> String {
let session = &client.get_seedance2pro_client_ref().expect("seedance2pro client").session;
let audio_path = test_file_path(TEST_AUDIO_PATH).expect("test audio should exist");
let audio_bytes = std::fs::read(&audio_path).expect("read test audio");
let prepare_result = prepare_file_upload(PrepareFileUploadArgs {
session,
extension: "aac".to_string(),
host_override: None,
}).await.expect("prepare upload");
let upload_result = upload_file(UploadFileArgs {
upload_url: prepare_result.upload_url,
file_bytes: audio_bytes,
host_override: None,
}).await.expect("upload");
upload_result.public_url
}
}
@@ -1,209 +0,0 @@
use enums::common::generation::common_musical_key::CommonMusicalKey;
use seedance2pro_client::generate::audio::generate_suno_sound::{
GenerateSunoSoundRequest, KinoviSunoSoundKey, KinoviSunoSoundType,
};
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::audio_generation_request::AudioGenerationRequest;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::providers::kinovi::suno_sounds::request::KinoviSunoSoundsRequestState;
use crate::generate::generate_audio::providers::reject_unsupported::{
reject_unsupported_audio_references, reject_unsupported_image_references,
reject_unsupported_option,
};
pub fn build_kinovi_suno_sounds(builder: GenerateAudioRequestBuilder) -> Result<AudioGenerationDraftOrRequest, ArtcraftRouterError> {
let state = build_kinovi_suno_sounds_state(builder)?;
Ok(AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::KinoviSunoSounds(state)))
}
pub(crate) fn build_kinovi_suno_sounds_state(
mut builder: GenerateAudioRequestBuilder,
) -> Result<KinoviSunoSoundsRequestState, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
// Options Suno Sounds has no equivalent for.
reject_unsupported_option("style_prompt", builder.style_prompt.as_ref(), strategy)?;
reject_unsupported_option("keep_lyrics", builder.keep_lyrics.as_ref(), strategy)?;
reject_unsupported_option("is_instrumental", builder.is_instrumental.as_ref(), strategy)?;
reject_unsupported_option("sample_rate_hz", builder.sample_rate_hz.as_ref(), strategy)?;
reject_unsupported_option("speed", builder.speed.as_ref(), strategy)?;
reject_unsupported_option("volume", builder.volume.as_ref(), strategy)?;
reject_unsupported_option("pitch", builder.pitch.as_ref(), strategy)?;
reject_unsupported_audio_references(builder.audio_references.as_ref(), strategy)?;
reject_unsupported_image_references(builder.image_references.as_ref(), strategy)?;
let prompt = builder.prompt.take().ok_or_else(|| {
ArtcraftRouterError::InvalidInput("A prompt is required for Suno Sounds".to_string())
})?;
let request = GenerateSunoSoundRequest {
prompt,
sound_type: plan_sound_type(builder.is_loopable),
bpm: builder.bpm,
key: plan_key(builder.musical_key),
};
Ok(KinoviSunoSoundsRequestState { request })
}
// ── Plan helpers ──
fn plan_sound_type(is_loopable: Option<bool>) -> KinoviSunoSoundType {
match is_loopable {
Some(true) => KinoviSunoSoundType::Loopable,
Some(false) | None => KinoviSunoSoundType::SingleHit,
}
}
fn plan_key(musical_key: Option<CommonMusicalKey>) -> KinoviSunoSoundKey {
match musical_key {
None | Some(CommonMusicalKey::Auto) => KinoviSunoSoundKey::Auto,
Some(CommonMusicalKey::CMajor) => KinoviSunoSoundKey::CMajor,
Some(CommonMusicalKey::CMinor) => KinoviSunoSoundKey::CMinor,
Some(CommonMusicalKey::DMajor) => KinoviSunoSoundKey::DMajor,
Some(CommonMusicalKey::DMinor) => KinoviSunoSoundKey::DMinor,
Some(CommonMusicalKey::FMajor) => KinoviSunoSoundKey::FMajor,
Some(CommonMusicalKey::FMinor) => KinoviSunoSoundKey::FMinor,
Some(CommonMusicalKey::GMajor) => KinoviSunoSoundKey::GMajor,
Some(CommonMusicalKey::GMinor) => KinoviSunoSoundKey::GMinor,
Some(CommonMusicalKey::AMajor) => KinoviSunoSoundKey::AMajor,
Some(CommonMusicalKey::AMinor) => KinoviSunoSoundKey::AMinor,
Some(CommonMusicalKey::BMajor) => KinoviSunoSoundKey::BMajor,
Some(CommonMusicalKey::BMinor) => KinoviSunoSoundKey::BMinor,
}
}
#[cfg(test)]
mod tests {
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use super::*;
mod field_conversions {
use super::*;
#[test]
fn prompt_is_passed_through() {
let state = build_kinovi_suno_sounds_state(base_builder()).expect("build");
assert_eq!(state.request.prompt, "rain sound effects");
}
#[test]
fn missing_prompt_is_rejected() {
let builder = GenerateAudioRequestBuilder { prompt: None, ..base_builder() };
assert!(build_kinovi_suno_sounds_state(builder).is_err());
}
#[test]
fn bpm_passes_through() {
let builder = GenerateAudioRequestBuilder { bpm: Some(120), ..base_builder() };
let state = build_kinovi_suno_sounds_state(builder).expect("build");
assert_eq!(state.request.bpm, Some(120));
}
#[test]
fn loopable_true_maps_to_loop() {
let builder = GenerateAudioRequestBuilder { is_loopable: Some(true), ..base_builder() };
let state = build_kinovi_suno_sounds_state(builder).expect("build");
assert!(matches!(state.request.sound_type, KinoviSunoSoundType::Loopable));
}
#[test]
fn loopable_false_maps_to_single_hit() {
let builder = GenerateAudioRequestBuilder { is_loopable: Some(false), ..base_builder() };
let state = build_kinovi_suno_sounds_state(builder).expect("build");
assert!(matches!(state.request.sound_type, KinoviSunoSoundType::SingleHit));
}
#[test]
fn loopable_none_defaults_to_single_hit() {
let state = build_kinovi_suno_sounds_state(base_builder()).expect("build");
assert!(matches!(state.request.sound_type, KinoviSunoSoundType::SingleHit));
}
}
mod key_mapping {
use super::*;
#[test]
fn every_common_key_maps_to_the_kinovi_key() {
let cases = [
(Some(CommonMusicalKey::Auto), KinoviSunoSoundKey::Auto),
(Some(CommonMusicalKey::CMajor), KinoviSunoSoundKey::CMajor),
(Some(CommonMusicalKey::CMinor), KinoviSunoSoundKey::CMinor),
(Some(CommonMusicalKey::DMajor), KinoviSunoSoundKey::DMajor),
(Some(CommonMusicalKey::DMinor), KinoviSunoSoundKey::DMinor),
(Some(CommonMusicalKey::FMajor), KinoviSunoSoundKey::FMajor),
(Some(CommonMusicalKey::FMinor), KinoviSunoSoundKey::FMinor),
(Some(CommonMusicalKey::GMajor), KinoviSunoSoundKey::GMajor),
(Some(CommonMusicalKey::GMinor), KinoviSunoSoundKey::GMinor),
(Some(CommonMusicalKey::AMajor), KinoviSunoSoundKey::AMajor),
(Some(CommonMusicalKey::AMinor), KinoviSunoSoundKey::AMinor),
(Some(CommonMusicalKey::BMajor), KinoviSunoSoundKey::BMajor),
(Some(CommonMusicalKey::BMinor), KinoviSunoSoundKey::BMinor),
];
assert_eq!(cases.len(), 13, "expected all 13 CommonMusicalKey variants covered");
for (common_key, expected) in cases {
let builder = GenerateAudioRequestBuilder { musical_key: common_key, ..base_builder() };
let state = build_kinovi_suno_sounds_state(builder).expect("build");
assert!(
matches!(state.request.key, k if format!("{k:?}") == format!("{expected:?}")),
"expected {expected:?} for {common_key:?}, got {:?}", state.request.key,
);
}
}
#[test]
fn missing_key_defaults_to_auto() {
let state = build_kinovi_suno_sounds_state(base_builder()).expect("build");
assert!(matches!(state.request.key, KinoviSunoSoundKey::Auto));
}
}
mod unsupported_options {
use super::*;
#[test]
fn unsupported_options_error_out() {
let cases: Vec<GenerateAudioRequestBuilder> = vec![
GenerateAudioRequestBuilder { style_prompt: Some("EDM".to_string()), ..base_builder() },
GenerateAudioRequestBuilder { keep_lyrics: Some(true), ..base_builder() },
GenerateAudioRequestBuilder { is_instrumental: Some(true), ..base_builder() },
GenerateAudioRequestBuilder { sample_rate_hz: Some(48_000), ..base_builder() },
GenerateAudioRequestBuilder { speed: Some(1.5), ..base_builder() },
GenerateAudioRequestBuilder { volume: Some(0.5), ..base_builder() },
GenerateAudioRequestBuilder { pitch: Some(2.0), ..base_builder() },
];
for mut builder in cases {
builder.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
assert!(build_kinovi_suno_sounds_state(builder).is_err());
}
}
#[test]
fn unsupported_options_are_dropped_under_lenient_strategies() {
let builder = GenerateAudioRequestBuilder {
style_prompt: Some("EDM".to_string()),
is_instrumental: Some(true),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade,
..base_builder()
};
assert!(build_kinovi_suno_sounds_state(builder).is_ok());
}
}
// ── Helpers ──
fn base_builder() -> GenerateAudioRequestBuilder {
GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoSounds,
provider: RouterProvider::Seedance2Pro,
prompt: Some("rain sound effects".to_string()),
..Default::default()
}
}
}
@@ -1,70 +0,0 @@
use seedance2pro_client::generate::audio::generate_suno_sound::{
GenerateSunoSoundRequest, KinoviSunoSoundKey, KinoviSunoSoundType,
};
use crate::generate::generate_audio::audio_generation_cost_estimate::AudioGenerationCostEstimate;
use crate::generate::generate_audio::providers::kinovi::suno_sounds::request::KinoviSunoSoundsRequestState;
/// Suno Sounds is flat priced, so the cost state carries no request fields.
#[derive(Clone, Debug)]
pub struct KinoviSunoSoundsCostState;
impl KinoviSunoSoundsCostState {
pub fn from_request(_request: &KinoviSunoSoundsRequestState) -> Self {
Self
}
pub fn estimate_cost(&self) -> AudioGenerationCostEstimate {
// Cost math is owned by seedance2pro_client's binding — the router just
// forwards the result so router cost ≡ binding cost by construction.
let pricing_request = GenerateSunoSoundRequest {
prompt: String::new(),
sound_type: KinoviSunoSoundType::SingleHit,
bpm: None,
key: KinoviSunoSoundKey::Auto,
};
let costs = pricing_request.calculate_costs();
AudioGenerationCostEstimate {
cost_in_credits: Some(costs.kinovi_credits),
cost_in_usd_cents: Some(costs.usd_cents_rounded_up),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
#[cfg(test)]
mod tests {
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::providers::kinovi::suno_sounds::build::build_kinovi_suno_sounds_state;
use super::*;
#[test]
fn flat_cost_is_two_cents() {
let estimate = KinoviSunoSoundsCostState.estimate_cost();
assert_eq!(estimate.cost_in_usd_cents, Some(2));
assert_eq!(estimate.cost_in_credits, Some(4));
}
#[test]
fn cost_from_built_request_is_two_cents() {
let builder = GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoSounds,
provider: RouterProvider::Seedance2Pro,
prompt: Some("thunder".to_string()),
is_loopable: Some(true),
bpm: Some(60),
..Default::default()
};
let state = build_kinovi_suno_sounds_state(builder).expect("build");
let estimate = KinoviSunoSoundsCostState::from_request(&state).estimate_cost();
assert_eq!(estimate.cost_in_usd_cents, Some(2));
}
}
@@ -1,3 +0,0 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -1,114 +0,0 @@
use seedance2pro_client::generate::audio::generate_suno_sound::{
generate_suno_sound, GenerateSunoSoundArgs, GenerateSunoSoundRequest,
};
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_audio::generate_audio_response::{
GenerateAudioResponse, Seedance2proAudioResponsePayload,
};
#[derive(Debug, Clone)]
pub struct KinoviSunoSoundsRequestState {
/// Final materialized request; ready to fire.
pub request: GenerateSunoSoundRequest,
}
impl KinoviSunoSoundsRequestState {
pub async fn send(&self, client: &RouterSeedance2ProClient) -> Result<GenerateAudioResponse, ArtcraftRouterError> {
let session = &client.session;
let args = GenerateSunoSoundArgs {
session,
host_override: None,
request: self.request.clone(),
};
let response = generate_suno_sound(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
Ok(GenerateAudioResponse::Seedance2Pro(Seedance2proAudioResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
}))
}
}
#[cfg(test)]
mod tests {
use enums::common::generation::common_musical_key::CommonMusicalKey;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::client::router_client::RouterClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse;
#[tokio::test]
#[ignore] // Sends a real generation to Kinovi; costs credits.
async fn single_hit() {
let response = run_pipeline(GenerateAudioRequestBuilder {
prompt: Some("A heavy castle door creaking open".to_string()),
..suno_sounds_builder()
}).await;
assert!(matches!(response, GenerateAudioResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // Sends a real generation to Kinovi; costs credits.
async fn loop_with_bpm_and_key() {
let response = run_pipeline(GenerateAudioRequestBuilder {
prompt: Some("Lo-fi vinyl crackle groove".to_string()),
is_loopable: Some(true),
bpm: Some(85),
musical_key: Some(CommonMusicalKey::AMinor),
..suno_sounds_builder()
}).await;
assert!(matches!(response, GenerateAudioResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
// ── Helpers ──
fn suno_sounds_builder() -> GenerateAudioRequestBuilder {
GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoSounds,
provider: RouterProvider::Seedance2Pro,
..Default::default()
}
}
fn get_seedance2pro_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt")
.expect("Failed to read seedance2pro cookies");
let session = Seedance2ProSession::from_cookies_string(cookies.trim().to_string());
RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session))
}
async fn run_pipeline(builder: GenerateAudioRequestBuilder) -> GenerateAudioResponse {
let client = get_seedance2pro_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
AudioGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant (Suno Sounds skips draft)"),
};
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateAudioResponse::Seedance2Pro(p) => {
println!("task_id={}, order_id={}", p.task_id, p.order_id);
}
other => println!("response: {:?}", other),
}
response
}
}
@@ -1,4 +0,0 @@
pub mod artcraft;
pub mod fal;
pub mod kinovi;
pub(crate) mod reject_unsupported;
@@ -1,69 +0,0 @@
//! Shared helpers for rejecting builder options that a model does not support.
//!
//! Per the request-mismatch mitigation strategy: `ErrorOut` rejects the
//! request when an unsupported option is set; the other strategies silently
//! drop the option.
use std::fmt::Debug;
use crate::api::audio_list_ref::AudioListRef;
use crate::api::image_list_ref::ImageListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
/// Reject (under `ErrorOut`) or silently drop a set option the model doesn't
/// support.
pub(crate) fn reject_unsupported_option<T: Debug>(
field: &'static str,
value: Option<&T>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<(), ArtcraftRouterError> {
let Some(value) = value else {
return Ok(());
};
match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field,
value: format!("{:?}", value),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(()),
}
}
/// Reject (under `ErrorOut`) or silently drop audio references for models
/// that take none. Empty lists are treated as absent.
pub(crate) fn reject_unsupported_audio_references(
refs: Option<&AudioListRef>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<(), ArtcraftRouterError> {
let has_refs = match refs {
None => false,
Some(AudioListRef::Urls(urls)) => !urls.is_empty(),
Some(AudioListRef::MediaFileTokens(tokens)) => !tokens.is_empty(),
};
if has_refs {
return reject_unsupported_option("audio_references", refs, strategy);
}
Ok(())
}
/// Reject (under `ErrorOut`) or silently drop image references for models
/// that take none. Empty lists are treated as absent.
pub(crate) fn reject_unsupported_image_references(
refs: Option<&ImageListRef>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<(), ArtcraftRouterError> {
let has_refs = match refs {
None => false,
Some(ImageListRef::Urls(urls)) => !urls.is_empty(),
Some(ImageListRef::MediaFileTokens(tokens)) => !tokens.is_empty(),
};
if has_refs {
return reject_unsupported_option("image_references", refs, strategy);
}
Ok(())
}
@@ -1,5 +1,6 @@
use fal_client::requests::api::image::angle::flux_2_lora_edit_image_angle::api::{
Flux2LoraAngleImageSize, Flux2LoraAngleNumImages, Flux2LoraEditImageAngleRequest,
use fal_client::requests_old::webhook::image::angle::enqueue_flux_2_lora_edit_image_angle_webhook::{
EnqueueFlux2LoraAngleImageSize, EnqueueFlux2LoraAngleNumImages,
EnqueueFlux2LoraEditImageAngleRequest,
};
use crate::api::router_aspect_ratio::RouterAspectRatio;
@@ -26,7 +27,7 @@ pub fn build_fal_flux_2_lora_angles(
let num_images = plan_num_images(builder.image_batch_count, strategy)?;
let image_size = plan_image_size(builder.aspect_ratio, strategy)?;
let request = Flux2LoraEditImageAngleRequest {
let request = EnqueueFlux2LoraEditImageAngleRequest {
image_urls,
horizontal_angle: builder.horizontal_angle,
vertical_angle: builder.vertical_angle.map(|v| v.clamp(0.0, 60.0)),
@@ -56,8 +57,8 @@ fn resolve_image_urls(image_inputs: Option<ImageListRef>) -> Result<Vec<String>,
fn plan_num_images(
image_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Flux2LoraAngleNumImages, ArtcraftRouterError> {
use Flux2LoraAngleNumImages as N;
) -> Result<EnqueueFlux2LoraAngleNumImages, ArtcraftRouterError> {
use EnqueueFlux2LoraAngleNumImages as N;
let count = image_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
@@ -80,8 +81,8 @@ fn plan_num_images(
fn plan_image_size(
aspect_ratio: Option<RouterAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<Flux2LoraAngleImageSize>, ArtcraftRouterError> {
use Flux2LoraAngleImageSize as S;
) -> Result<Option<EnqueueFlux2LoraAngleImageSize>, ArtcraftRouterError> {
use EnqueueFlux2LoraAngleImageSize as S;
match aspect_ratio {
None => Ok(None),
@@ -117,6 +118,34 @@ mod tests {
use crate::api::router_image_model::RouterImageModel;
use crate::api::router_provider::RouterProvider;
fn base() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::Flux2LoraAngles,
provider: RouterProvider::Fal,
prompt: None,
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/x.jpg".to_string()])),
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
horizontal_angle: Some(45.0),
vertical_angle: Some(-15.0),
zoom: Some(2.0),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
}
}
fn unwrap_request(result: Result<ImageGenerationDraftOrRequest, ArtcraftRouterError>) -> EnqueueFlux2LoraEditImageAngleRequest {
let ImageGenerationDraftOrRequest::Request(
ImageGenerationRequest::FalFlux2LoraAngles(state)
) = result.expect("build should succeed") else {
panic!("expected FalFlux2LoraAngles variant")
};
state.request
}
#[test]
fn passes_through_camera_params() {
let builder = GenerateImageRequestBuilder {
@@ -178,32 +207,4 @@ mod tests {
Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations))
));
}
fn base() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::Flux2LoraAngles,
provider: RouterProvider::Fal,
prompt: None,
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/x.jpg".to_string()])),
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
horizontal_angle: Some(45.0),
vertical_angle: Some(-15.0),
zoom: Some(2.0),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
}
}
fn unwrap_request(result: Result<ImageGenerationDraftOrRequest, ArtcraftRouterError>) -> Flux2LoraEditImageAngleRequest {
let ImageGenerationDraftOrRequest::Request(
ImageGenerationRequest::FalFlux2LoraAngles(state)
) = result.expect("build should succeed") else {
panic!("expected FalFlux2LoraAngles variant")
};
state.request
}
}
@@ -32,33 +32,13 @@ impl FalFlux2LoraAnglesCostState {
#[cfg(test)]
mod tests {
use super::*;
use fal_client::requests::api::image::angle::flux_2_lora_edit_image_angle::api::{
Flux2LoraAngleNumImages, Flux2LoraEditImageAngleRequest,
use fal_client::requests_old::webhook::image::angle::enqueue_flux_2_lora_edit_image_angle_webhook::{
EnqueueFlux2LoraAngleNumImages, EnqueueFlux2LoraEditImageAngleRequest,
};
#[test]
fn one_image_is_2_cents() {
assert_eq!(cost_for(Flux2LoraAngleNumImages::One).cost_in_usd_cents, Some(2));
}
#[test]
fn two_images_is_4_cents() {
assert_eq!(cost_for(Flux2LoraAngleNumImages::Two).cost_in_usd_cents, Some(4));
}
#[test]
fn three_images_is_6_cents() {
assert_eq!(cost_for(Flux2LoraAngleNumImages::Three).cost_in_usd_cents, Some(6));
}
#[test]
fn four_images_is_8_cents() {
assert_eq!(cost_for(Flux2LoraAngleNumImages::Four).cost_in_usd_cents, Some(8));
}
fn cost_for(n: Flux2LoraAngleNumImages) -> ImageGenerationCostEstimate {
fn cost_for(n: EnqueueFlux2LoraAngleNumImages) -> ImageGenerationCostEstimate {
let state = FalFlux2LoraAnglesCostState::from_request(&FalFlux2LoraAnglesRequestState {
request: Flux2LoraEditImageAngleRequest {
request: EnqueueFlux2LoraEditImageAngleRequest {
image_urls: vec!["https://example.com/x.jpg".to_string()],
horizontal_angle: None,
vertical_angle: None,
@@ -72,4 +52,24 @@ mod tests {
});
state.estimate_cost()
}
#[test]
fn one_image_is_2_cents() {
assert_eq!(cost_for(EnqueueFlux2LoraAngleNumImages::One).cost_in_usd_cents, Some(2));
}
#[test]
fn two_images_is_4_cents() {
assert_eq!(cost_for(EnqueueFlux2LoraAngleNumImages::Two).cost_in_usd_cents, Some(4));
}
#[test]
fn three_images_is_6_cents() {
assert_eq!(cost_for(EnqueueFlux2LoraAngleNumImages::Three).cost_in_usd_cents, Some(6));
}
#[test]
fn four_images_is_8_cents() {
assert_eq!(cost_for(EnqueueFlux2LoraAngleNumImages::Four).cost_in_usd_cents, Some(8));
}
}
@@ -1,100 +1,55 @@
use std::fmt::Debug;
use std::sync::Arc;
use fal_client::requests::api::image::angle::flux_2_lora_edit_image_angle::api::Flux2LoraEditImageAngleRequest;
use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint;
use fal_client::requests_old::webhook::image::angle::enqueue_flux_2_lora_edit_image_angle_webhook::{
enqueue_flux_2_lora_edit_image_angle_webhook, EnqueueFlux2LoraEditImageAngleArgs,
EnqueueFlux2LoraEditImageAngleRequest,
};
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_image::generate_image_response::{
FalImageResponsePayload, GenerateImageResponse,
};
#[derive(Clone, Debug)]
pub struct FalFlux2LoraAnglesRequestState {
pub request: Flux2LoraEditImageAngleRequest,
pub request: EnqueueFlux2LoraEditImageAngleRequest,
}
impl FalFlux2LoraAnglesRequestState {
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let webhook_url = client.webhook_url.as_deref()
.ok_or(ArtcraftRouterError::Client(ClientError::WebhookUrlRequired))?;
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(self.request.clone());
let payload = send_fal_request(&self.request, client).await?;
let args = EnqueueFlux2LoraEditImageAngleArgs {
request: self.request.clone(),
webhook_url,
api_key: &client.api_key,
};
let webhook_response = enqueue_flux_2_lora_edit_image_angle_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
Ok(GenerateImageResponse::Fal(FalImageResponsePayload {
request_id: payload.request_id,
gateway_request_id: payload.gateway_request_id,
maybe_status_url: payload.status_url,
maybe_response_url: payload.response_url,
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
maybe_status_url: None,
maybe_response_url: None,
maybe_outbound_request: Some(outbound),
}))
}
}
// ── Helpers ──
struct FalResponseIds {
request_id: Option<String>,
gateway_request_id: Option<String>,
status_url: Option<String>,
response_url: Option<String>,
}
async fn send_fal_request<T: FalEndpoint>(
request: &T,
client: &RouterFalClient,
) -> Result<FalResponseIds, ArtcraftRouterError> {
if let Some(webhook_url) = &client.webhook_url {
let response = request
.send_webhook_request(&client.api_key, webhook_url)
.await?;
Ok(FalResponseIds {
request_id: response.request_id,
gateway_request_id: response.gateway_request_id,
status_url: None,
response_url: None,
})
} else {
let response = request
.send_queue_request(&client.api_key)
.await?;
Ok(FalResponseIds {
request_id: Some(response.request_id),
gateway_request_id: None,
status_url: Some(response.status_url),
response_url: Some(response.response_url),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use fal_client::creds::fal_api_key::FalApiKey;
use fal_client::requests::api::image::angle::flux_2_lora_edit_image_angle::api::{
Flux2LoraAngleImageSize, Flux2LoraAngleNumImages,
use fal_client::requests_old::webhook::image::angle::enqueue_flux_2_lora_edit_image_angle_webhook::{
EnqueueFlux2LoraAngleImageSize, EnqueueFlux2LoraAngleNumImages,
};
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn send_via_webhook() {
let client = client_with_webhook();
let state = FalFlux2LoraAnglesRequestState {
request: Flux2LoraEditImageAngleRequest {
image_urls: vec![JUNO_AT_LAKE_IMAGE_URL.to_string()],
horizontal_angle: Some(45.0),
vertical_angle: Some(15.0),
zoom: Some(2.0),
num_images: Some(Flux2LoraAngleNumImages::One),
image_size: Some(Flux2LoraAngleImageSize::SquareHd),
lora_scale: None,
guidance_scale: None,
num_inference_steps: None,
},
};
let response = state.send(&client).await.expect("send should succeed");
let payload = response.get_fal_payload().expect("expected Fal payload");
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
}
fn read_fal_api_key() -> FalApiKey {
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")
@@ -108,4 +63,26 @@ mod tests {
"https://example.com/fal-webhook-test".to_string(),
)
}
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn send_via_webhook() {
let client = client_with_webhook();
let state = FalFlux2LoraAnglesRequestState {
request: EnqueueFlux2LoraEditImageAngleRequest {
image_urls: vec!["https://example.com/test.jpg".to_string()],
horizontal_angle: Some(45.0),
vertical_angle: Some(-15.0),
zoom: Some(2.0),
num_images: Some(EnqueueFlux2LoraAngleNumImages::One),
image_size: Some(EnqueueFlux2LoraAngleImageSize::SquareHd),
lora_scale: None,
guidance_scale: None,
num_inference_steps: None,
},
};
let response = state.send(&client).await.expect("send should succeed");
let payload = response.get_fal_payload().expect("expected Fal payload");
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
}
}
@@ -1,5 +1,6 @@
use fal_client::requests::api::image::angle::qwen_edit_2511_edit_image_angle::api::{
QwenEdit2511AngleImageSize, QwenEdit2511AngleNumImages, QwenEdit2511EditImageAngleRequest,
use fal_client::requests_old::webhook::image::angle::enqueue_qwen_edit_2511_edit_image_angle_webhook::{
EnqueueQwenEdit2511AngleImageSize, EnqueueQwenEdit2511AngleNumImages,
EnqueueQwenEdit2511EditImageAngleRequest,
};
use crate::api::router_aspect_ratio::RouterAspectRatio;
@@ -26,7 +27,7 @@ pub fn build_fal_qwen_edit_2511_angles(
let num_images = plan_num_images(builder.image_batch_count, strategy)?;
let image_size = plan_image_size(builder.aspect_ratio, strategy)?;
let request = QwenEdit2511EditImageAngleRequest {
let request = EnqueueQwenEdit2511EditImageAngleRequest {
image_urls,
horizontal_angle: builder.horizontal_angle,
vertical_angle: builder.vertical_angle,
@@ -57,8 +58,8 @@ fn resolve_image_urls(image_inputs: Option<ImageListRef>) -> Result<Vec<String>,
fn plan_num_images(
image_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<QwenEdit2511AngleNumImages, ArtcraftRouterError> {
use QwenEdit2511AngleNumImages as N;
) -> Result<EnqueueQwenEdit2511AngleNumImages, ArtcraftRouterError> {
use EnqueueQwenEdit2511AngleNumImages as N;
let count = image_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
@@ -81,8 +82,8 @@ fn plan_num_images(
fn plan_image_size(
aspect_ratio: Option<RouterAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<QwenEdit2511AngleImageSize>, ArtcraftRouterError> {
use QwenEdit2511AngleImageSize as S;
) -> Result<Option<EnqueueQwenEdit2511AngleImageSize>, ArtcraftRouterError> {
use EnqueueQwenEdit2511AngleImageSize as S;
match aspect_ratio {
None => Ok(None),
@@ -118,6 +119,34 @@ mod tests {
use crate::api::router_image_model::RouterImageModel;
use crate::api::router_provider::RouterProvider;
fn base() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::QwenEdit2511Angles,
provider: RouterProvider::Fal,
prompt: Some("test".to_string()),
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/x.jpg".to_string()])),
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
horizontal_angle: Some(45.0),
vertical_angle: Some(-15.0),
zoom: Some(2.0),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
}
}
fn unwrap_request(result: Result<ImageGenerationDraftOrRequest, ArtcraftRouterError>) -> EnqueueQwenEdit2511EditImageAngleRequest {
let ImageGenerationDraftOrRequest::Request(
ImageGenerationRequest::FalQwenEdit2511Angles(state)
) = result.expect("build should succeed") else {
panic!("expected FalQwenEdit2511Angles variant")
};
state.request
}
#[test]
fn passes_through_camera_params() {
let req = unwrap_request(build_fal_qwen_edit_2511_angles(base()));
@@ -148,7 +177,7 @@ mod tests {
#[test]
fn default_num_images_is_one() {
let req = unwrap_request(build_fal_qwen_edit_2511_angles(base()));
assert!(matches!(req.num_images, Some(QwenEdit2511AngleNumImages::One)));
assert!(matches!(req.num_images, Some(EnqueueQwenEdit2511AngleNumImages::One)));
}
#[test]
@@ -168,34 +197,6 @@ mod tests {
..base()
};
let req = unwrap_request(build_fal_qwen_edit_2511_angles(builder));
assert!(matches!(req.num_images, Some(QwenEdit2511AngleNumImages::Four)));
}
fn base() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::QwenEdit2511Angles,
provider: RouterProvider::Fal,
prompt: Some("test".to_string()),
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/x.jpg".to_string()])),
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
horizontal_angle: Some(45.0),
vertical_angle: Some(-15.0),
zoom: Some(2.0),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
}
}
fn unwrap_request(result: Result<ImageGenerationDraftOrRequest, ArtcraftRouterError>) -> QwenEdit2511EditImageAngleRequest {
let ImageGenerationDraftOrRequest::Request(
ImageGenerationRequest::FalQwenEdit2511Angles(state)
) = result.expect("build should succeed") else {
panic!("expected FalQwenEdit2511Angles variant")
};
state.request
assert!(matches!(req.num_images, Some(EnqueueQwenEdit2511AngleNumImages::Four)));
}
}
@@ -32,39 +32,13 @@ impl FalQwenEdit2511AnglesCostState {
#[cfg(test)]
mod tests {
use super::*;
use fal_client::requests::api::image::angle::qwen_edit_2511_edit_image_angle::api::{
QwenEdit2511AngleNumImages, QwenEdit2511EditImageAngleRequest,
use fal_client::requests_old::webhook::image::angle::enqueue_qwen_edit_2511_edit_image_angle_webhook::{
EnqueueQwenEdit2511AngleNumImages, EnqueueQwenEdit2511EditImageAngleRequest,
};
#[test]
fn one_image_is_4_cents() {
assert_eq!(cost_for(QwenEdit2511AngleNumImages::One).cost_in_usd_cents, Some(4));
}
#[test]
fn two_images_is_8_cents() {
assert_eq!(cost_for(QwenEdit2511AngleNumImages::Two).cost_in_usd_cents, Some(8));
}
#[test]
fn three_images_is_12_cents() {
assert_eq!(cost_for(QwenEdit2511AngleNumImages::Three).cost_in_usd_cents, Some(12));
}
#[test]
fn four_images_is_16_cents() {
assert_eq!(cost_for(QwenEdit2511AngleNumImages::Four).cost_in_usd_cents, Some(16));
}
#[test]
fn credits_equal_cents() {
let cost = cost_for(QwenEdit2511AngleNumImages::Two);
assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents);
}
fn cost_for(n: QwenEdit2511AngleNumImages) -> ImageGenerationCostEstimate {
fn cost_for(n: EnqueueQwenEdit2511AngleNumImages) -> ImageGenerationCostEstimate {
let state = FalQwenEdit2511AnglesCostState::from_request(&FalQwenEdit2511AnglesRequestState {
request: QwenEdit2511EditImageAngleRequest {
request: EnqueueQwenEdit2511EditImageAngleRequest {
image_urls: vec!["https://example.com/x.jpg".to_string()],
horizontal_angle: None,
vertical_angle: None,
@@ -79,4 +53,30 @@ mod tests {
});
state.estimate_cost()
}
#[test]
fn one_image_is_4_cents() {
assert_eq!(cost_for(EnqueueQwenEdit2511AngleNumImages::One).cost_in_usd_cents, Some(4));
}
#[test]
fn two_images_is_8_cents() {
assert_eq!(cost_for(EnqueueQwenEdit2511AngleNumImages::Two).cost_in_usd_cents, Some(8));
}
#[test]
fn three_images_is_12_cents() {
assert_eq!(cost_for(EnqueueQwenEdit2511AngleNumImages::Three).cost_in_usd_cents, Some(12));
}
#[test]
fn four_images_is_16_cents() {
assert_eq!(cost_for(EnqueueQwenEdit2511AngleNumImages::Four).cost_in_usd_cents, Some(16));
}
#[test]
fn credits_equal_cents() {
let cost = cost_for(EnqueueQwenEdit2511AngleNumImages::Two);
assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents);
}
}
@@ -1,101 +1,55 @@
use std::fmt::Debug;
use std::sync::Arc;
use fal_client::requests::api::image::angle::qwen_edit_2511_edit_image_angle::api::QwenEdit2511EditImageAngleRequest;
use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint;
use fal_client::requests_old::webhook::image::angle::enqueue_qwen_edit_2511_edit_image_angle_webhook::{
enqueue_qwen_edit_2511_edit_image_angle_webhook, EnqueueQwenEdit2511EditImageAngleArgs,
EnqueueQwenEdit2511EditImageAngleRequest,
};
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_image::generate_image_response::{
FalImageResponsePayload, GenerateImageResponse,
};
#[derive(Clone, Debug)]
pub struct FalQwenEdit2511AnglesRequestState {
pub request: QwenEdit2511EditImageAngleRequest,
pub request: EnqueueQwenEdit2511EditImageAngleRequest,
}
impl FalQwenEdit2511AnglesRequestState {
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let webhook_url = client.webhook_url.as_deref()
.ok_or(ArtcraftRouterError::Client(ClientError::WebhookUrlRequired))?;
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(self.request.clone());
let payload = send_fal_request(&self.request, client).await?;
let args = EnqueueQwenEdit2511EditImageAngleArgs {
request: self.request.clone(),
webhook_url,
api_key: &client.api_key,
};
let webhook_response = enqueue_qwen_edit_2511_edit_image_angle_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
Ok(GenerateImageResponse::Fal(FalImageResponsePayload {
request_id: payload.request_id,
gateway_request_id: payload.gateway_request_id,
maybe_status_url: payload.status_url,
maybe_response_url: payload.response_url,
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
maybe_status_url: None,
maybe_response_url: None,
maybe_outbound_request: Some(outbound),
}))
}
}
// ── Helpers ──
struct FalResponseIds {
request_id: Option<String>,
gateway_request_id: Option<String>,
status_url: Option<String>,
response_url: Option<String>,
}
async fn send_fal_request<T: FalEndpoint>(
request: &T,
client: &RouterFalClient,
) -> Result<FalResponseIds, ArtcraftRouterError> {
if let Some(webhook_url) = &client.webhook_url {
let response = request
.send_webhook_request(&client.api_key, webhook_url)
.await?;
Ok(FalResponseIds {
request_id: response.request_id,
gateway_request_id: response.gateway_request_id,
status_url: None,
response_url: None,
})
} else {
let response = request
.send_queue_request(&client.api_key)
.await?;
Ok(FalResponseIds {
request_id: Some(response.request_id),
gateway_request_id: None,
status_url: Some(response.status_url),
response_url: Some(response.response_url),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use fal_client::creds::fal_api_key::FalApiKey;
use fal_client::requests::api::image::angle::qwen_edit_2511_edit_image_angle::api::{
QwenEdit2511AngleImageSize, QwenEdit2511AngleNumImages,
use fal_client::requests_old::webhook::image::angle::enqueue_qwen_edit_2511_edit_image_angle_webhook::{
EnqueueQwenEdit2511AngleImageSize, EnqueueQwenEdit2511AngleNumImages,
};
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn send_via_webhook() {
let client = client_with_webhook();
let state = FalQwenEdit2511AnglesRequestState {
request: QwenEdit2511EditImageAngleRequest {
image_urls: vec![JUNO_AT_LAKE_IMAGE_URL.to_string()],
horizontal_angle: Some(45.0),
vertical_angle: Some(-15.0),
zoom: Some(2.0),
additional_prompt: Some("cinematic".to_string()),
num_images: Some(QwenEdit2511AngleNumImages::One),
image_size: Some(QwenEdit2511AngleImageSize::SquareHd),
lora_scale: None,
guidance_scale: None,
num_inference_steps: None,
},
};
let response = state.send(&client).await.expect("send should succeed");
let payload = response.get_fal_payload().expect("expected Fal payload");
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
}
fn read_fal_api_key() -> FalApiKey {
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")
@@ -109,4 +63,27 @@ mod tests {
"https://example.com/fal-webhook-test".to_string(),
)
}
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn send_via_webhook() {
let client = client_with_webhook();
let state = FalQwenEdit2511AnglesRequestState {
request: EnqueueQwenEdit2511EditImageAngleRequest {
image_urls: vec!["https://example.com/test.jpg".to_string()],
horizontal_angle: Some(45.0),
vertical_angle: Some(-15.0),
zoom: Some(2.0),
additional_prompt: Some("cinematic".to_string()),
num_images: Some(EnqueueQwenEdit2511AngleNumImages::One),
image_size: Some(EnqueueQwenEdit2511AngleImageSize::SquareHd),
lora_scale: None,
guidance_scale: None,
num_inference_steps: None,
},
};
let response = state.send(&client).await.expect("send should succeed");
let payload = response.get_fal_payload().expect("expected Fal payload");
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
}
}
@@ -42,9 +42,6 @@ use crate::generate::generate_video::providers::artcraft::veo_3::build::build_ar
use crate::generate::generate_video::providers::artcraft::veo_3_fast::build::build_artcraft_veo_3_fast;
use crate::generate::generate_video::providers::artcraft::veo_3p1::build::build_artcraft_veo_3p1;
use crate::generate::generate_video::providers::artcraft::veo_3p1_fast::build::build_artcraft_veo_3p1_fast;
use crate::generate::generate_video::providers::artcraft::veo_3p1_lite::build::build_artcraft_veo_3p1_lite;
use crate::generate::generate_video::providers::artcraft::vidu_q3::build::build_artcraft_vidu_q3;
use crate::generate::generate_video::providers::artcraft::vidu_q3_turbo::build::build_artcraft_vidu_q3_turbo;
use crate::generate::generate_video::providers::kinovi::happy_horse_1p0::build::build_kinovi_happy_horse_1p0;
use crate::generate::generate_video::providers::kinovi::seedance_2p0::build::build_kinovi_seedance_2p0;
use crate::generate::generate_video::providers::gmicloud::seedance_2p0_g::build::build_gmicloud_seedance_2p0_u;
@@ -67,9 +64,6 @@ use crate::generate::generate_video::providers::fal::veo_3::build::build_fal_veo
use crate::generate::generate_video::providers::fal::veo_3_fast::build::build_fal_veo_3_fast;
use crate::generate::generate_video::providers::fal::veo_3p1::build::build_fal_veo_3p1;
use crate::generate::generate_video::providers::fal::veo_3p1_fast::build::build_fal_veo_3p1_fast;
use crate::generate::generate_video::providers::fal::veo_3p1_lite::build::build_fal_veo_3p1_lite;
use crate::generate::generate_video::providers::fal::vidu_q3::build::build_fal_vidu_q3;
use crate::generate::generate_video::providers::fal::vidu_q3_turbo::build::build_fal_vidu_q3_turbo;
use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::build::build_kinovi_seedance_2p0_fast;
use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::build::build_kinovi_seedance_2p0_mini;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
@@ -200,9 +194,6 @@ impl GenerateVideoRequestBuilder {
(RouterProvider::Artcraft, RouterVideoModel::Veo3Fast) => build_artcraft_veo_3_fast(self),
(RouterProvider::Artcraft, RouterVideoModel::Veo3p1) => build_artcraft_veo_3p1(self),
(RouterProvider::Artcraft, RouterVideoModel::Veo3p1Fast) => build_artcraft_veo_3p1_fast(self),
(RouterProvider::Artcraft, RouterVideoModel::Veo3p1Lite) => build_artcraft_veo_3p1_lite(self),
(RouterProvider::Artcraft, RouterVideoModel::ViduQ3) => build_artcraft_vidu_q3(self),
(RouterProvider::Artcraft, RouterVideoModel::ViduQ3Turbo) => build_artcraft_vidu_q3_turbo(self),
// Fal
(RouterProvider::Fal, RouterVideoModel::Kling16Pro) => build_fal_kling_1_6_pro(self),
(RouterProvider::Fal, RouterVideoModel::Kling21Master) => build_fal_kling_2_1_master(self),
@@ -220,9 +211,6 @@ impl GenerateVideoRequestBuilder {
(RouterProvider::Fal, RouterVideoModel::Veo3Fast) => build_fal_veo_3_fast(self),
(RouterProvider::Fal, RouterVideoModel::Veo3p1) => build_fal_veo_3p1(self),
(RouterProvider::Fal, RouterVideoModel::Veo3p1Fast) => build_fal_veo_3p1_fast(self),
(RouterProvider::Fal, RouterVideoModel::Veo3p1Lite) => build_fal_veo_3p1_lite(self),
(RouterProvider::Fal, RouterVideoModel::ViduQ3) => build_fal_vidu_q3(self),
(RouterProvider::Fal, RouterVideoModel::ViduQ3Turbo) => build_fal_vidu_q3_turbo(self),
// GmiCloud
(RouterProvider::GmiCloud, RouterVideoModel::Seedance2p0Ultra) => build_gmicloud_seedance_2p0_u(self),
(RouterProvider::GmiCloud, RouterVideoModel::Seedance2p0UltraFast) => build_gmicloud_seedance_2p0_u_fast(self),
@@ -36,7 +36,4 @@ pub mod veo_3;
pub mod veo_3_fast;
pub mod veo_3p1;
pub mod veo_3p1_fast;
pub mod veo_3p1_lite;
pub mod vidu_q3;
pub mod vidu_q3_turbo;
@@ -15,9 +15,9 @@ impl ArtcraftVeo2CostState {
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// 5s = $2.75, +$0.55/s above 5s.
// Mirrors fal_client veo_2: 5s = $2.50, +$0.50/s above 5s.
let extra = self.duration_seconds.saturating_sub(5);
let cost_in_usd_cents = 275 + extra * 55;
let cost_in_usd_cents = 250 + extra * 50;
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -49,17 +49,17 @@ mod tests {
}
#[test]
fn default_is_5s_priced_at_275() { assert_eq!(cost_cents(None), 275); }
fn default_is_5s_priced_at_250() { assert_eq!(cost_cents(None), 250); }
#[test]
fn five_seconds_is_275() { assert_eq!(cost_cents(Some(5)), 275); }
fn five_seconds_is_250() { assert_eq!(cost_cents(Some(5)), 250); }
#[test]
fn six_seconds_is_330() { assert_eq!(cost_cents(Some(6)), 330); }
fn six_seconds_is_300() { assert_eq!(cost_cents(Some(6)), 300); }
#[test]
fn seven_seconds_is_385() { assert_eq!(cost_cents(Some(7)), 385); }
fn seven_seconds_is_350() { assert_eq!(cost_cents(Some(7)), 350); }
#[test]
fn eight_seconds_is_440() { assert_eq!(cost_cents(Some(8)), 440); }
fn eight_seconds_is_400() { assert_eq!(cost_cents(Some(8)), 400); }
}
@@ -17,8 +17,8 @@ impl ArtcraftVeo3CostState {
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// $0.24/sec audio off, $0.48/sec audio on.
let per_second_cents: u64 = if self.generate_audio { 48 } else { 24 };
// Mirrors fal_client veo_3: $0.20/sec audio off, $0.40/sec audio on.
let per_second_cents: u64 = if self.generate_audio { 40 } else { 20 };
let cost_in_usd_cents = per_second_cents * self.duration_seconds;
VideoGenerationCostEstimate {
@@ -61,19 +61,19 @@ mod tests {
}
#[test]
fn audio_off_4s_is_96() { assert_eq!(cost_cents(Some(4), Some(false)), 96); }
fn audio_off_4s_is_80() { assert_eq!(cost_cents(Some(4), Some(false)), 80); }
#[test]
fn audio_off_6s_is_144() { assert_eq!(cost_cents(Some(6), Some(false)), 144); }
fn audio_off_6s_is_120() { assert_eq!(cost_cents(Some(6), Some(false)), 120); }
#[test]
fn audio_off_8s_is_192() { assert_eq!(cost_cents(Some(8), Some(false)), 192); }
fn audio_off_8s_is_160() { assert_eq!(cost_cents(Some(8), Some(false)), 160); }
#[test]
fn audio_on_4s_is_192() { assert_eq!(cost_cents(Some(4), Some(true)), 192); }
fn audio_on_4s_is_160() { assert_eq!(cost_cents(Some(4), Some(true)), 160); }
#[test]
fn audio_on_8s_is_384() { assert_eq!(cost_cents(Some(8), Some(true)), 384); }
fn audio_on_8s_is_320() { assert_eq!(cost_cents(Some(8), Some(true)), 320); }
#[test]
fn default_duration_is_8s() {
@@ -15,8 +15,10 @@ impl ArtcraftVeo3FastCostState {
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Flat per-video price: 88¢ audio off, 132¢ audio on.
let cost_in_usd_cents: u64 = if self.generate_audio { 132 } else { 88 };
// Mirrors fal_client veo_3_fast: $0.10/sec audio off, $0.15/sec audio on.
// Legacy handler always bills 8s.
let per_second_cents: u64 = if self.generate_audio { 15 } else { 10 };
let cost_in_usd_cents = per_second_cents * 8;
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -49,22 +51,22 @@ mod tests {
}
#[test]
fn audio_off_is_88() { assert_eq!(cost_cents(Some(8), Some(false)), 88); }
fn audio_off_is_80() { assert_eq!(cost_cents(Some(8), Some(false)), 80); }
#[test]
fn audio_on_is_132() { assert_eq!(cost_cents(Some(8), Some(true)), 132); }
fn audio_on_is_120() { assert_eq!(cost_cents(Some(8), Some(true)), 120); }
#[test]
fn audio_default_is_off() {
assert_eq!(cost_cents(Some(8), None), 88);
assert_eq!(cost_cents(Some(8), None), 80);
}
#[test]
fn duration_does_not_affect_cost() {
// v1 always bills 8s regardless of duration_seconds.
assert_eq!(cost_cents(Some(4), Some(false)), 88);
assert_eq!(cost_cents(Some(6), Some(false)), 88);
assert_eq!(cost_cents(Some(8), Some(false)), 88);
assert_eq!(cost_cents(None, Some(false)), 88);
assert_eq!(cost_cents(Some(4), Some(false)), 80);
assert_eq!(cost_cents(Some(6), Some(false)), 80);
assert_eq!(cost_cents(Some(8), Some(false)), 80);
assert_eq!(cost_cents(None, Some(false)), 80);
}
}
@@ -23,7 +23,7 @@ pub fn build_artcraft_veo_3p1(mut builder: GenerateVideoRequestBuilder) -> Resul
let mut request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::Veo3p1,
SupportedResolutions::FullWith4k,
SupportedResolutions::Full,
UltraWideSupport::Unsupported,
)?;
request.generate_audio = generate_audio;
@@ -1,5 +1,3 @@
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::artcraft::veo_3p1::request::ArtcraftVeo3p1RequestState;
@@ -7,7 +5,6 @@ use crate::generate::generate_video::providers::artcraft::veo_3p1::request::Artc
pub struct ArtcraftVeo3p1CostState {
pub duration_seconds: u64,
pub generate_audio: bool,
pub is_4k: bool,
}
impl ArtcraftVeo3p1CostState {
@@ -17,19 +14,12 @@ impl ArtcraftVeo3p1CostState {
duration_seconds: request.request.duration_seconds.map(u64::from).unwrap_or(6),
// v1 legacy Veo 3.1 handler defaults generate_audio to true.
generate_audio: request.request.generate_audio.unwrap_or(true),
is_4k: request.request.resolution == Some(CommonResolutionEnum::FourK),
}
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// $0.24/sec audio off, $0.48/sec audio on; 4K renders bill a premium:
// $0.48/sec audio off, $0.72/sec audio on.
let per_second_cents: u64 = match (self.is_4k, self.generate_audio) {
(false, false) => 24,
(false, true) => 48,
(true, false) => 48,
(true, true) => 72,
};
// Mirrors fal_client veo_3p1: $0.20/sec audio off, $0.40/sec audio on.
let per_second_cents: u64 = if self.generate_audio { 40 } else { 20 };
let cost_in_usd_cents = per_second_cents * self.duration_seconds;
VideoGenerationCostEstimate {
@@ -46,22 +36,12 @@ impl ArtcraftVeo3p1CostState {
#[cfg(test)]
mod tests {
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
fn cost_cents(duration_seconds: Option<u16>, generate_audio: Option<bool>) -> u64 {
cost_cents_at(duration_seconds, generate_audio, None)
}
fn cost_cents_at(
duration_seconds: Option<u16>,
generate_audio: Option<bool>,
resolution: Option<RouterResolution>,
) -> u64 {
let b = GenerateVideoRequestBuilder {
resolution,
model: RouterVideoModel::Veo3p1,
provider: RouterProvider::Artcraft,
prompt: Some("test".to_string()),
@@ -73,42 +53,24 @@ mod tests {
}
#[test]
fn audio_on_4s_is_192() { assert_eq!(cost_cents(Some(4), Some(true)), 192); }
fn audio_on_4s_is_160() { assert_eq!(cost_cents(Some(4), Some(true)), 160); }
#[test]
fn audio_on_6s_is_288() { assert_eq!(cost_cents(Some(6), Some(true)), 288); }
fn audio_on_6s_is_240() { assert_eq!(cost_cents(Some(6), Some(true)), 240); }
#[test]
fn audio_on_8s_is_384() { assert_eq!(cost_cents(Some(8), Some(true)), 384); }
fn audio_on_8s_is_320() { assert_eq!(cost_cents(Some(8), Some(true)), 320); }
#[test]
fn audio_off_4s_is_96() { assert_eq!(cost_cents(Some(4), Some(false)), 96); }
fn audio_off_4s_is_80() { assert_eq!(cost_cents(Some(4), Some(false)), 80); }
#[test]
fn default_duration_is_6s() {
assert_eq!(cost_cents(None, Some(true)), 288);
assert_eq!(cost_cents(None, Some(true)), 240);
}
#[test]
fn audio_default_is_on() {
assert_eq!(cost_cents(Some(6), None), 288);
}
#[test]
fn four_k_audio_off_4s_is_192() {
assert_eq!(cost_cents_at(Some(4), Some(false), Some(RouterResolution::FourK)), 192);
}
#[test]
fn four_k_audio_on_4s_is_288() {
assert_eq!(cost_cents_at(Some(4), Some(true), Some(RouterResolution::FourK)), 288);
}
#[test]
fn non_four_k_resolutions_share_the_base_rate() {
assert_eq!(
cost_cents_at(Some(8), Some(true), Some(RouterResolution::SevenTwentyP)),
cost_cents_at(Some(8), Some(true), Some(RouterResolution::TenEightyP)),
);
assert_eq!(cost_cents(Some(6), None), 240);
}
}
@@ -20,7 +20,7 @@ pub fn build_artcraft_veo_3p1_fast(mut builder: GenerateVideoRequestBuilder) ->
let mut request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::Veo3p1Fast,
SupportedResolutions::FullWith4k,
SupportedResolutions::Full,
UltraWideSupport::Unsupported,
)?;
request.generate_audio = generate_audio;
@@ -1,5 +1,3 @@
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::artcraft::veo_3p1_fast::request::ArtcraftVeo3p1FastRequestState;
@@ -7,7 +5,6 @@ use crate::generate::generate_video::providers::artcraft::veo_3p1_fast::request:
pub struct ArtcraftVeo3p1FastCostState {
pub duration_seconds: u64,
pub generate_audio: bool,
pub is_4k: bool,
}
impl ArtcraftVeo3p1FastCostState {
@@ -17,20 +14,13 @@ impl ArtcraftVeo3p1FastCostState {
duration_seconds: request.request.duration_seconds.map(u64::from).unwrap_or(6),
// v1 legacy Veo 3.1 Fast handler defaults generate_audio to true.
generate_audio: request.request.generate_audio.unwrap_or(true),
is_4k: request.request.resolution == Some(CommonResolutionEnum::FourK),
}
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Per-second rates in hundredths of a US cent; 4K renders bill a premium.
let rate_hundredth_cents: u64 = match (self.is_4k, self.generate_audio) {
(false, false) => 1_100,
(false, true) => 1_650,
(true, false) => 3_300,
(true, true) => 3_850,
};
// Round up to the next whole cent.
let cost_in_usd_cents = (rate_hundredth_cents * self.duration_seconds).div_ceil(100);
// Mirrors fal_client veo_3p1_fast: $0.10/sec audio off, $0.15/sec audio on.
let per_second_cents: u64 = if self.generate_audio { 15 } else { 10 };
let cost_in_usd_cents = per_second_cents * self.duration_seconds;
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -46,22 +36,12 @@ impl ArtcraftVeo3p1FastCostState {
#[cfg(test)]
mod tests {
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
fn cost_cents(duration_seconds: Option<u16>, generate_audio: Option<bool>) -> u64 {
cost_cents_at(duration_seconds, generate_audio, None)
}
fn cost_cents_at(
duration_seconds: Option<u16>,
generate_audio: Option<bool>,
resolution: Option<RouterResolution>,
) -> u64 {
let b = GenerateVideoRequestBuilder {
resolution,
model: RouterVideoModel::Veo3p1Fast,
provider: RouterProvider::Artcraft,
prompt: Some("test".to_string()),
@@ -73,42 +53,24 @@ mod tests {
}
#[test]
fn audio_on_4s_is_66() { assert_eq!(cost_cents(Some(4), Some(true)), 66); }
fn audio_on_4s_is_60() { assert_eq!(cost_cents(Some(4), Some(true)), 60); }
#[test]
fn audio_on_6s_is_99() { assert_eq!(cost_cents(Some(6), Some(true)), 99); }
fn audio_on_6s_is_90() { assert_eq!(cost_cents(Some(6), Some(true)), 90); }
#[test]
fn audio_on_8s_is_132() { assert_eq!(cost_cents(Some(8), Some(true)), 132); }
fn audio_on_8s_is_120() { assert_eq!(cost_cents(Some(8), Some(true)), 120); }
#[test]
fn audio_off_4s_is_44() { assert_eq!(cost_cents(Some(4), Some(false)), 44); }
fn audio_off_4s_is_40() { assert_eq!(cost_cents(Some(4), Some(false)), 40); }
#[test]
fn default_duration_is_6s() {
assert_eq!(cost_cents(None, Some(true)), 99);
assert_eq!(cost_cents(None, Some(true)), 90);
}
#[test]
fn audio_default_is_on() {
assert_eq!(cost_cents(Some(6), None), 99);
}
#[test]
fn four_k_audio_off_4s_is_132() {
assert_eq!(cost_cents_at(Some(4), Some(false), Some(RouterResolution::FourK)), 132);
}
#[test]
fn four_k_audio_on_4s_is_154() {
assert_eq!(cost_cents_at(Some(4), Some(true), Some(RouterResolution::FourK)), 154);
}
#[test]
fn non_four_k_resolutions_share_the_base_rate() {
assert_eq!(
cost_cents_at(Some(8), Some(true), Some(RouterResolution::SevenTwentyP)),
cost_cents_at(Some(8), Some(true), Some(RouterResolution::TenEightyP)),
);
assert_eq!(cost_cents(Some(6), None), 90);
}
}
@@ -1,29 +0,0 @@
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::artcraft::build_common::{
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
};
use crate::generate::generate_video::providers::artcraft::veo_3p1_lite::request::ArtcraftVeo3p1LiteRequestState;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_artcraft_veo_3p1_lite(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let state = build_artcraft_veo_3p1_lite_state(builder)?;
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftVeo3p1Lite(state)))
}
pub(crate) fn build_artcraft_veo_3p1_lite_state(builder: GenerateVideoRequestBuilder) -> Result<ArtcraftVeo3p1LiteRequestState, ArtcraftRouterError> {
// Veo 3.1 Lite supports `generate_audio` — preserve it so cost can read it back.
// build_artcraft_omni_video_request hardcodes generate_audio = None on its output.
let generate_audio = builder.generate_audio;
let mut request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::Veo3p1Lite,
SupportedResolutions::NoFourEightyP,
UltraWideSupport::Unsupported,
)?;
request.generate_audio = generate_audio;
Ok(ArtcraftVeo3p1LiteRequestState { request })
}
@@ -1,119 +0,0 @@
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::artcraft::veo_3p1_lite::request::ArtcraftVeo3p1LiteRequestState;
/// Per-second rates in hundredths of a US cent.
const SEVEN_TWENTY_P_AUDIO_OFF_RATE: u64 = 315;
const SEVEN_TWENTY_P_AUDIO_ON_RATE: u64 = 525;
const TEN_EIGHTY_P_AUDIO_OFF_RATE: u64 = 525;
const TEN_EIGHTY_P_AUDIO_ON_RATE: u64 = 840;
#[derive(Clone, Debug)]
pub struct ArtcraftVeo3p1LiteCostState {
pub duration_seconds: u64,
pub is_1080p: bool,
pub generate_audio: bool,
}
impl ArtcraftVeo3p1LiteCostState {
pub fn from_request(request: &ArtcraftVeo3p1LiteRequestState) -> Self {
Self {
// Veo 3.1 Lite defaults None → 8s.
duration_seconds: request.request.duration_seconds.map(u64::from).unwrap_or(8),
is_1080p: is_1080p(request.request.resolution),
// Audio is on when unspecified.
generate_audio: request.request.generate_audio.unwrap_or(true),
}
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
let rate = match (self.is_1080p, self.generate_audio) {
(false, false) => SEVEN_TWENTY_P_AUDIO_OFF_RATE,
(false, true) => SEVEN_TWENTY_P_AUDIO_ON_RATE,
(true, false) => TEN_EIGHTY_P_AUDIO_OFF_RATE,
(true, true) => TEN_EIGHTY_P_AUDIO_ON_RATE,
};
// Round up to the next whole cent.
let cost_in_usd_cents = (rate * self.duration_seconds).div_ceil(100);
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,
}
}
}
/// Veo 3.1 Lite renders 720p (the default) or 1080p.
fn is_1080p(resolution: Option<CommonResolutionEnum>) -> bool {
matches!(
resolution,
Some(CommonResolutionEnum::TenEightyP)
| Some(CommonResolutionEnum::TwoK)
| Some(CommonResolutionEnum::ThreeK)
| Some(CommonResolutionEnum::FourK)
)
}
#[cfg(test)]
mod tests {
use crate::api::router_provider::RouterProvider;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::artcraft::veo_3p1_lite::build::build_artcraft_veo_3p1_lite_state;
use crate::generate::generate_video::providers::artcraft::veo_3p1_lite::cost::ArtcraftVeo3p1LiteCostState;
fn cost_cents(
duration_seconds: Option<u16>,
resolution: Option<RouterResolution>,
generate_audio: Option<bool>,
) -> u64 {
let b = GenerateVideoRequestBuilder {
model: RouterVideoModel::Veo3p1Lite,
provider: RouterProvider::Artcraft,
prompt: Some("test".to_string()),
duration_seconds,
resolution,
generate_audio,
..Default::default()
};
let state = build_artcraft_veo_3p1_lite_state(b).unwrap();
ArtcraftVeo3p1LiteCostState::from_request(&state).estimate_cost().cost_in_usd_cents.unwrap()
}
#[test]
fn defaults_8s_720p_audio_on_is_42() { assert_eq!(cost_cents(None, None, None), 42); }
#[test]
fn audio_off_8s_720p_is_26() {
// 315 * 8 = 2520 hundredth-cents → 26 cents.
assert_eq!(cost_cents(Some(8), None, Some(false)), 26);
}
#[test]
fn audio_on_8s_1080p_is_68() {
// 840 * 8 = 6720 hundredth-cents → 68 cents.
assert_eq!(cost_cents(Some(8), Some(RouterResolution::TenEightyP), Some(true)), 68);
}
#[test]
fn audio_off_8s_1080p_is_42() {
assert_eq!(cost_cents(Some(8), Some(RouterResolution::TenEightyP), Some(false)), 42);
}
#[test]
fn audio_on_4s_720p_is_21() {
assert_eq!(cost_cents(Some(4), None, Some(true)), 21);
}
#[test]
fn audio_default_is_on() {
assert_eq!(cost_cents(Some(8), None, None), cost_cents(Some(8), None, Some(true)));
}
}
@@ -1,3 +0,0 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -1,104 +0,0 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request;
#[derive(Clone, Debug)]
pub struct ArtcraftVeo3p1LiteRequestState {
pub request: OmniGenVideoCostAndGenerateRequest,
}
impl ArtcraftVeo3p1LiteRequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
send_artcraft_omni_video_request(&self.request, client).await
}
}
#[cfg(test)]
mod tests {
use tokens::tokens::media_files::MediaFileToken;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::utils::api_host::ApiHost;
use test_data::web::image_media_tokens::JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN;
use crate::api::image_ref::ImageRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_provider::RouterProvider;
use crate::api::router_video_model::RouterVideoModel;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_client::RouterClient;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
#[tokio::test]
#[ignore] // sends a real generation to the Artcraft backend, incurs cost
async fn text_to_video() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A lighthouse beam sweeping across a foggy bay at night.".to_string()),
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // sends a real generation to the Artcraft backend, incurs cost
async fn keyframe_start_frame() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("The dog turns its head toward the camera.".to_string()),
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()))),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
// ── Helpers ──
fn artcraft_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Veo3p1Lite,
provider: RouterProvider::Artcraft,
duration_seconds: Some(8),
video_batch_count: Some(1),
..Default::default()
}
}
fn get_artcraft_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt")
.expect("Failed to read artcraft cookies");
let cookies = cookies.trim().to_string();
let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies)
.expect("Failed to parse cookies")
.expect("No credentials found");
RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials))
}
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
let client = get_artcraft_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
VideoGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant (Artcraft skips draft)"),
};
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateVideoResponse::Artcraft(p) => {
println!("inference_job_token={:?}", p.inference_job_token);
println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens);
}
other => println!("response: {:?}", other),
}
response
}
}
@@ -1,29 +0,0 @@
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::artcraft::build_common::{
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
};
use crate::generate::generate_video::providers::artcraft::vidu_q3::request::ArtcraftViduQ3RequestState;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_artcraft_vidu_q3(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let state = build_artcraft_vidu_q3_state(builder)?;
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftViduQ3(state)))
}
pub(crate) fn build_artcraft_vidu_q3_state(builder: GenerateVideoRequestBuilder) -> Result<ArtcraftViduQ3RequestState, ArtcraftRouterError> {
// Vidu Q3 supports `audio` — preserve it so cost can read it back.
// build_artcraft_omni_video_request hardcodes generate_audio = None on its output.
let generate_audio = builder.generate_audio;
let mut request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::ViduQ3,
SupportedResolutions::Full,
UltraWideSupport::Unsupported,
)?;
request.generate_audio = generate_audio;
Ok(ArtcraftViduQ3RequestState { request })
}
@@ -1,118 +0,0 @@
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::artcraft::vidu_q3::request::ArtcraftViduQ3RequestState;
/// Per-second rates in hundredths of a US cent.
const LOW_RES_RATE_HUNDREDTH_CENTS_PER_SEC: u64 = 735;
const HIGH_RES_RATE_HUNDREDTH_CENTS_PER_SEC: u64 = 1_634;
#[derive(Clone, Debug)]
pub struct ArtcraftViduQ3CostState {
pub duration_seconds: u64,
pub is_high_res: bool,
}
impl ArtcraftViduQ3CostState {
pub fn from_request(request: &ArtcraftViduQ3RequestState) -> Self {
Self {
// Vidu Q3 defaults None → 5s.
duration_seconds: request.request.duration_seconds.map(u64::from).unwrap_or(5),
is_high_res: is_high_res(request.request.resolution),
}
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
let rate = if self.is_high_res {
HIGH_RES_RATE_HUNDREDTH_CENTS_PER_SEC
} else {
LOW_RES_RATE_HUNDREDTH_CENTS_PER_SEC
};
// Round up to the next whole cent.
let cost_in_usd_cents = (rate * self.duration_seconds).div_ceil(100);
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,
}
}
}
/// Vidu prices 720p/1080p at a higher tier than 360p/540p. The default (unset)
/// resolution is 720p.
fn is_high_res(resolution: Option<CommonResolutionEnum>) -> bool {
match resolution {
None => true, // defaults to 720p
Some(CommonResolutionEnum::SevenTwentyP)
| Some(CommonResolutionEnum::TenEightyP)
| Some(CommonResolutionEnum::OneK)
| Some(CommonResolutionEnum::TwoK)
| Some(CommonResolutionEnum::ThreeK)
| Some(CommonResolutionEnum::FourK) => true,
Some(CommonResolutionEnum::HalfK)
| Some(CommonResolutionEnum::FourEightyP) => false,
}
}
#[cfg(test)]
mod tests {
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use crate::api::router_provider::RouterProvider;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::artcraft::vidu_q3::build::build_artcraft_vidu_q3_state;
use crate::generate::generate_video::providers::artcraft::vidu_q3::cost::ArtcraftViduQ3CostState;
fn cost_cents(duration_seconds: Option<u16>, resolution: Option<RouterResolution>) -> u64 {
let b = GenerateVideoRequestBuilder {
model: RouterVideoModel::ViduQ3,
provider: RouterProvider::Artcraft,
prompt: Some("test".to_string()),
duration_seconds,
resolution,
..Default::default()
};
let state = build_artcraft_vidu_q3_state(b).unwrap();
ArtcraftViduQ3CostState::from_request(&state).estimate_cost().cost_in_usd_cents.unwrap()
}
#[test]
fn default_resolution_5s_is_82() { assert_eq!(cost_cents(Some(5), None), 82); }
#[test]
fn default_duration_is_5s() { assert_eq!(cost_cents(None, None), 82); }
#[test]
fn high_res_10s_is_164() { assert_eq!(cost_cents(Some(10), Some(RouterResolution::TenEightyP)), 164); }
#[test]
fn low_res_5s_is_37() { assert_eq!(cost_cents(Some(5), Some(RouterResolution::FourEightyP)), 37); }
#[test]
fn low_res_10s_is_74() { assert_eq!(cost_cents(Some(10), Some(RouterResolution::FourEightyP)), 74); }
#[test]
fn seven_twenty_p_is_high_res_tier() {
assert_eq!(cost_cents(Some(8), Some(RouterResolution::SevenTwentyP)), 131);
}
#[test]
fn odd_duration_rounds_up_to_whole_cents() {
// 7s high res: 1634 * 7 = 11438 hundredth-cents → 115 cents.
assert_eq!(cost_cents(Some(7), None), 115);
}
#[test]
fn resolution_classifier_defaults_high() {
assert!(super::is_high_res(None));
assert!(super::is_high_res(Some(CommonResolutionEnum::TenEightyP)));
assert!(!super::is_high_res(Some(CommonResolutionEnum::FourEightyP)));
}
}
@@ -1,3 +0,0 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -1,117 +0,0 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request;
#[derive(Clone, Debug)]
pub struct ArtcraftViduQ3RequestState {
pub request: OmniGenVideoCostAndGenerateRequest,
}
impl ArtcraftViduQ3RequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
send_artcraft_omni_video_request(&self.request, client).await
}
}
#[cfg(test)]
mod tests {
use tokens::tokens::media_files::MediaFileToken;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::utils::api_host::ApiHost;
use test_data::web::image_media_tokens::JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN;
use crate::api::image_ref::ImageRef;
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_provider::RouterProvider;
use crate::api::router_video_model::RouterVideoModel;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_client::RouterClient;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
#[tokio::test]
#[ignore] // sends a real generation to the Artcraft backend, incurs cost
async fn text_to_video() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A red kite soaring over coastal cliffs, cinematic.".to_string()),
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // sends a real generation to the Artcraft backend, incurs cost
async fn keyframe_start_frame() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("The dog stands up and walks toward the lake.".to_string()),
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()))),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // sends a real generation to the Artcraft backend, incurs cost
async fn reference_images_to_video() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("The dog explores a mossy forest clearing.".to_string()),
reference_images: Some(ImageListRef::MediaFileTokens(vec![MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string())])),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
// ── Helpers ──
fn artcraft_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::ViduQ3,
provider: RouterProvider::Artcraft,
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
}
}
fn get_artcraft_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt")
.expect("Failed to read artcraft cookies");
let cookies = cookies.trim().to_string();
let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies)
.expect("Failed to parse cookies")
.expect("No credentials found");
RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials))
}
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
let client = get_artcraft_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
VideoGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant (Artcraft skips draft)"),
};
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateVideoResponse::Artcraft(p) => {
println!("inference_job_token={:?}", p.inference_job_token);
println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens);
}
other => println!("response: {:?}", other),
}
response
}
}
@@ -1,29 +0,0 @@
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::artcraft::build_common::{
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
};
use crate::generate::generate_video::providers::artcraft::vidu_q3_turbo::request::ArtcraftViduQ3TurboRequestState;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_artcraft_vidu_q3_turbo(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let state = build_artcraft_vidu_q3_turbo_state(builder)?;
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftViduQ3Turbo(state)))
}
pub(crate) fn build_artcraft_vidu_q3_turbo_state(builder: GenerateVideoRequestBuilder) -> Result<ArtcraftViduQ3TurboRequestState, ArtcraftRouterError> {
// Vidu Q3 Turbo supports `audio` — preserve it so cost can read it back.
// build_artcraft_omni_video_request hardcodes generate_audio = None on its output.
let generate_audio = builder.generate_audio;
let mut request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::ViduQ3Turbo,
SupportedResolutions::Full,
UltraWideSupport::Unsupported,
)?;
request.generate_audio = generate_audio;
Ok(ArtcraftViduQ3TurboRequestState { request })
}
@@ -1,104 +0,0 @@
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::artcraft::vidu_q3_turbo::request::ArtcraftViduQ3TurboRequestState;
/// Per-second rates in hundredths of a US cent.
const LOW_RES_RATE_HUNDREDTH_CENTS_PER_SEC: u64 = 401;
const HIGH_RES_RATE_HUNDREDTH_CENTS_PER_SEC: u64 = 834;
#[derive(Clone, Debug)]
pub struct ArtcraftViduQ3TurboCostState {
pub duration_seconds: u64,
pub is_high_res: bool,
}
impl ArtcraftViduQ3TurboCostState {
pub fn from_request(request: &ArtcraftViduQ3TurboRequestState) -> Self {
Self {
// Vidu Q3 Turbo defaults None → 5s.
duration_seconds: request.request.duration_seconds.map(u64::from).unwrap_or(5),
is_high_res: is_high_res(request.request.resolution),
}
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
let rate = if self.is_high_res {
HIGH_RES_RATE_HUNDREDTH_CENTS_PER_SEC
} else {
LOW_RES_RATE_HUNDREDTH_CENTS_PER_SEC
};
// Round up to the next whole cent.
let cost_in_usd_cents = (rate * self.duration_seconds).div_ceil(100);
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,
}
}
}
/// Vidu prices 720p/1080p at a higher tier than 360p/540p. The default (unset)
/// resolution is 720p.
fn is_high_res(resolution: Option<CommonResolutionEnum>) -> bool {
match resolution {
None => true, // defaults to 720p
Some(CommonResolutionEnum::SevenTwentyP)
| Some(CommonResolutionEnum::TenEightyP)
| Some(CommonResolutionEnum::OneK)
| Some(CommonResolutionEnum::TwoK)
| Some(CommonResolutionEnum::ThreeK)
| Some(CommonResolutionEnum::FourK) => true,
Some(CommonResolutionEnum::HalfK)
| Some(CommonResolutionEnum::FourEightyP) => false,
}
}
#[cfg(test)]
mod tests {
use crate::api::router_provider::RouterProvider;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::artcraft::vidu_q3_turbo::build::build_artcraft_vidu_q3_turbo_state;
use crate::generate::generate_video::providers::artcraft::vidu_q3_turbo::cost::ArtcraftViduQ3TurboCostState;
fn cost_cents(duration_seconds: Option<u16>, resolution: Option<RouterResolution>) -> u64 {
let b = GenerateVideoRequestBuilder {
model: RouterVideoModel::ViduQ3Turbo,
provider: RouterProvider::Artcraft,
prompt: Some("test".to_string()),
duration_seconds,
resolution,
..Default::default()
};
let state = build_artcraft_vidu_q3_turbo_state(b).unwrap();
ArtcraftViduQ3TurboCostState::from_request(&state).estimate_cost().cost_in_usd_cents.unwrap()
}
#[test]
fn default_resolution_5s_is_42() { assert_eq!(cost_cents(Some(5), None), 42); }
#[test]
fn default_duration_is_5s() { assert_eq!(cost_cents(None, None), 42); }
#[test]
fn high_res_10s_is_84() { assert_eq!(cost_cents(Some(10), Some(RouterResolution::TenEightyP)), 84); }
#[test]
fn low_res_5s_is_21() { assert_eq!(cost_cents(Some(5), Some(RouterResolution::FourEightyP)), 21); }
#[test]
fn low_res_10s_is_41() { assert_eq!(cost_cents(Some(10), Some(RouterResolution::FourEightyP)), 41); }
#[test]
fn odd_duration_rounds_up_to_whole_cents() {
// 7s high res: 834 * 7 = 5838 hundredth-cents → 59 cents.
assert_eq!(cost_cents(Some(7), None), 59);
}
}

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