mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
Add omni audio! router, web, etc. - seed audio, suno, etc. (#1747)
This commit is contained in:
Generated
+1
@@ -765,6 +765,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"strum",
|
||||
"test_data",
|
||||
"test_utils",
|
||||
"tokens",
|
||||
"tokio",
|
||||
"url_utils",
|
||||
|
||||
+1
@@ -1,2 +1,3 @@
|
||||
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;
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
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,2 +1,3 @@
|
||||
pub mod omni_gen_audio_cost_response;
|
||||
pub mod omni_gen_image_cost_response;
|
||||
pub mod omni_gen_video_cost_response;
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
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,2 +1,3 @@
|
||||
pub mod omni_gen_audio_generate_response;
|
||||
pub mod omni_gen_image_generate_response;
|
||||
pub mod omni_gen_video_generate_response;
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
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,2 +1,3 @@
|
||||
pub mod omni_gen_audio_models;
|
||||
pub mod omni_gen_image_models;
|
||||
pub mod omni_gen_video_models;
|
||||
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
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 +1 @@
|
||||
pub mod omni_gen_audio;
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
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,2 +1,3 @@
|
||||
pub mod audio;
|
||||
pub mod image;
|
||||
pub mod video;
|
||||
|
||||
@@ -39,6 +39,7 @@ uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
test_data.workspace = true
|
||||
test_utils.workspace = true
|
||||
strum.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ 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;
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
/// 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>,
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
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))
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
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
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
+302
@@ -0,0 +1,302 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
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;
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
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;
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
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],
|
||||
}))
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
//! 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
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)))
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
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)))
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
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)))
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
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)))
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
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)))
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
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 +1 @@
|
||||
pub mod seed_audio_1p0;
|
||||
+588
@@ -0,0 +1,588 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
pub mod suno_music;
|
||||
pub mod suno_remix;
|
||||
pub mod suno_sample;
|
||||
pub mod suno_sounds;
|
||||
pub(crate) mod resolve;
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
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 })
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod draft;
|
||||
pub mod request;
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
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 })
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod draft;
|
||||
pub mod request;
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
pub mod artcraft;
|
||||
pub mod fal;
|
||||
pub mod kinovi;
|
||||
pub(crate) mod reject_unsupported;
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
//! 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,3 +1,4 @@
|
||||
pub mod generate_audio;
|
||||
pub mod generate_image;
|
||||
pub mod generate_splat;
|
||||
pub mod generate_video;
|
||||
|
||||
+1
@@ -17,6 +17,7 @@ use crate::queries::generic_inference::api_providers::fal::insert_generic_infere
|
||||
pub enum FalCategory {
|
||||
ImageGeneration,
|
||||
VideoGeneration,
|
||||
AudioGeneration,
|
||||
BackgroundRemoval,
|
||||
ObjectGeneration,
|
||||
}
|
||||
|
||||
+4
@@ -87,6 +87,10 @@ pub async fn insert_generic_inference_job_for_fal_queue_with_apriori_job_token<'
|
||||
InferenceCategory::VideoGeneration,
|
||||
InferenceJobProductCategory::FalVideo
|
||||
),
|
||||
FalCategory::AudioGeneration => (
|
||||
InferenceCategory::AudioGeneration,
|
||||
InferenceJobProductCategory::FalAudio
|
||||
),
|
||||
FalCategory::BackgroundRemoval => (
|
||||
InferenceCategory::BackgroundRemoval,
|
||||
InferenceJobProductCategory::FalBgRemoval
|
||||
|
||||
+13
-2
@@ -6,6 +6,7 @@ use enums::by_table::generic_inference_jobs::inference_category::InferenceCatego
|
||||
use enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty;
|
||||
use enums::by_table::generic_inference_jobs::inference_job_product_category::InferenceJobProductCategory;
|
||||
use enums::by_table::generic_inference_jobs::inference_job_type::InferenceJobType;
|
||||
use enums::common::generation::common_model_class::CommonModelClass;
|
||||
use enums::common::generation::common_model_type::CommonModelType;
|
||||
use enums::common::job_status_plus::JobStatusPlus;
|
||||
use enums::common::platform_type::PlatformType;
|
||||
@@ -85,10 +86,14 @@ pub async fn insert_generic_inference_job_for_seedance2pro_queue_with_apriori_jo
|
||||
_ => false,
|
||||
};
|
||||
|
||||
let is_audio_model = args.maybe_model_type
|
||||
.map(|model_type| model_type.get_model_class() == CommonModelClass::Audio)
|
||||
.unwrap_or(false);
|
||||
|
||||
let (
|
||||
job_type,
|
||||
external_third_party,
|
||||
product_category
|
||||
mut product_category
|
||||
) = match (args.kinovi_version, is_image_model) {
|
||||
(KinoviVersion::Volcengine, true) => (
|
||||
InferenceJobType::Seedance2ProQueue,
|
||||
@@ -112,7 +117,13 @@ pub async fn insert_generic_inference_job_for_seedance2pro_queue_with_apriori_jo
|
||||
),
|
||||
};
|
||||
|
||||
let inference_category = if is_image_model {
|
||||
if is_audio_model {
|
||||
product_category = InferenceJobProductCategory::Seedance2ProAudio;
|
||||
}
|
||||
|
||||
let inference_category = if is_audio_model {
|
||||
InferenceCategory::AudioGeneration
|
||||
} else if is_image_model {
|
||||
InferenceCategory::ImageGeneration
|
||||
} else {
|
||||
InferenceCategory::VideoGeneration
|
||||
|
||||
+2
@@ -179,6 +179,7 @@ fn raw_records_to_public_result(records: Vec<RawGenericInferenceJobStatus>) -> V
|
||||
InferenceCategory::VideoFilter => Some("Video Filter"),
|
||||
InferenceCategory::ImageGeneration => Some("Image Generation"),
|
||||
InferenceCategory::VideoGeneration => Some("Video Generation"),
|
||||
InferenceCategory::AudioGeneration => Some("Audio Generation"),
|
||||
InferenceCategory::ObjectGeneration => Some("Object Generation"),
|
||||
InferenceCategory::BackgroundRemoval => Some("Background Removal"),
|
||||
InferenceCategory::Mocap => Some("Mocap"),
|
||||
@@ -204,6 +205,7 @@ fn raw_records_to_public_result(records: Vec<RawGenericInferenceJobStatus>) -> V
|
||||
InferenceCategory::VideoFilter => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
|
||||
InferenceCategory::ImageGeneration => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
|
||||
InferenceCategory::VideoGeneration => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
|
||||
InferenceCategory::AudioGeneration => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
|
||||
InferenceCategory::ObjectGeneration => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
|
||||
InferenceCategory::BackgroundRemoval => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
|
||||
InferenceCategory::Mocap => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
|
||||
|
||||
+2
@@ -154,6 +154,7 @@ fn raw_record_to_public_result(record: RawGenericInferenceJobStatus) -> GenericI
|
||||
InferenceCategory::VideoFilter => Some("Video Filter"),
|
||||
InferenceCategory::ImageGeneration => Some("Image Generation"),
|
||||
InferenceCategory::VideoGeneration => Some("Video Generation"),
|
||||
InferenceCategory::AudioGeneration => Some("Audio Generation"),
|
||||
InferenceCategory::ObjectGeneration => Some("Object Generation"),
|
||||
InferenceCategory::BackgroundRemoval => Some("Background Removal"),
|
||||
InferenceCategory::Mocap => Some("Mocap"),
|
||||
@@ -179,6 +180,7 @@ fn raw_record_to_public_result(record: RawGenericInferenceJobStatus) -> GenericI
|
||||
InferenceCategory::VideoFilter => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
|
||||
InferenceCategory::ImageGeneration => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
|
||||
InferenceCategory::VideoGeneration => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
|
||||
InferenceCategory::AudioGeneration => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
|
||||
InferenceCategory::ObjectGeneration => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
|
||||
InferenceCategory::BackgroundRemoval => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
|
||||
InferenceCategory::Mocap => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
|
||||
|
||||
+2
@@ -246,6 +246,7 @@ fn raw_records_to_public_result(records: Vec<RawGenericInferenceJobStatus>) -> V
|
||||
InferenceCategory::VideoFilter => Some("Video Filter"),
|
||||
InferenceCategory::ImageGeneration => Some("Image Generation"),
|
||||
InferenceCategory::VideoGeneration => Some("Video Generation"),
|
||||
InferenceCategory::AudioGeneration => Some("Audio Generation"),
|
||||
InferenceCategory::ObjectGeneration => Some("Object Generation"),
|
||||
InferenceCategory::BackgroundRemoval => Some("Background Removal"),
|
||||
InferenceCategory::Mocap => Some("Mocap"),
|
||||
@@ -270,6 +271,7 @@ fn raw_records_to_public_result(records: Vec<RawGenericInferenceJobStatus>) -> V
|
||||
InferenceCategory::VideoFilter => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
|
||||
InferenceCategory::ImageGeneration => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
|
||||
InferenceCategory::VideoGeneration => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
|
||||
InferenceCategory::AudioGeneration => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
|
||||
InferenceCategory::ObjectGeneration => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
|
||||
InferenceCategory::BackgroundRemoval => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
|
||||
InferenceCategory::Mocap => (true, record.maybe_media_file_public_bucket_directory_hash.as_deref()),
|
||||
|
||||
+12
-2
@@ -43,7 +43,11 @@ pub enum InferenceCategory {
|
||||
/// (Also Seedance2-Pro.com)
|
||||
#[serde(rename = "video_generation")]
|
||||
VideoGeneration,
|
||||
|
||||
|
||||
/// Audio generation (eg. Suno music, Seed Audio)
|
||||
#[serde(rename = "audio_generation")]
|
||||
AudioGeneration,
|
||||
|
||||
/// FAL-powered 3D object generation
|
||||
#[serde(rename = "object_generation")]
|
||||
ObjectGeneration,
|
||||
@@ -111,6 +115,7 @@ impl InferenceCategory {
|
||||
Self::VoiceConversion => "voice_conversion",
|
||||
Self::ImageGeneration => "image_generation",
|
||||
Self::VideoGeneration => "video_generation",
|
||||
Self::AudioGeneration => "audio_generation",
|
||||
Self::ObjectGeneration => "object_generation",
|
||||
Self::SplatGeneration => "splat_generation",
|
||||
Self::CharacterGeneration => "character_generation",
|
||||
@@ -134,6 +139,7 @@ impl InferenceCategory {
|
||||
"voice_conversion" => Ok(Self::VoiceConversion),
|
||||
"image_generation" => Ok(Self::ImageGeneration),
|
||||
"video_generation" => Ok(Self::VideoGeneration),
|
||||
"audio_generation" => Ok(Self::AudioGeneration),
|
||||
"object_generation" => Ok(Self::ObjectGeneration),
|
||||
"splat_generation" => Ok(Self::SplatGeneration),
|
||||
"character_generation" => Ok(Self::CharacterGeneration),
|
||||
@@ -163,6 +169,7 @@ impl InferenceCategory {
|
||||
Self::SplatGeneration,
|
||||
Self::CharacterGeneration,
|
||||
Self::VideoGeneration,
|
||||
Self::AudioGeneration,
|
||||
Self::BackgroundRemoval,
|
||||
Self::Mocap,
|
||||
Self::F5TTS,
|
||||
@@ -192,6 +199,7 @@ mod tests {
|
||||
assert_serialization(InferenceCategory::VoiceConversion, "voice_conversion");
|
||||
assert_serialization(InferenceCategory::ImageGeneration, "image_generation");
|
||||
assert_serialization(InferenceCategory::VideoGeneration, "video_generation");
|
||||
assert_serialization(InferenceCategory::AudioGeneration, "audio_generation");
|
||||
assert_serialization(InferenceCategory::ObjectGeneration, "object_generation");
|
||||
assert_serialization(InferenceCategory::SplatGeneration, "splat_generation");
|
||||
assert_serialization(InferenceCategory::CharacterGeneration, "character_generation");
|
||||
@@ -214,6 +222,7 @@ mod tests {
|
||||
assert_eq!(InferenceCategory::VoiceConversion.to_str(), "voice_conversion");
|
||||
assert_eq!(InferenceCategory::ImageGeneration.to_str(), "image_generation");
|
||||
assert_eq!(InferenceCategory::VideoGeneration.to_str(), "video_generation");
|
||||
assert_eq!(InferenceCategory::AudioGeneration.to_str(), "audio_generation");
|
||||
assert_eq!(InferenceCategory::ObjectGeneration.to_str(), "object_generation");
|
||||
assert_eq!(InferenceCategory::SplatGeneration.to_str(), "splat_generation");
|
||||
assert_eq!(InferenceCategory::CharacterGeneration.to_str(), "character_generation");
|
||||
@@ -236,6 +245,7 @@ mod tests {
|
||||
assert_eq!(InferenceCategory::from_str("voice_conversion").unwrap(), InferenceCategory::VoiceConversion);
|
||||
assert_eq!(InferenceCategory::from_str("image_generation").unwrap(), InferenceCategory::ImageGeneration);
|
||||
assert_eq!(InferenceCategory::from_str("video_generation").unwrap(), InferenceCategory::VideoGeneration);
|
||||
assert_eq!(InferenceCategory::from_str("audio_generation").unwrap(), InferenceCategory::AudioGeneration);
|
||||
assert_eq!(InferenceCategory::from_str("object_generation").unwrap(), InferenceCategory::ObjectGeneration);
|
||||
assert_eq!(InferenceCategory::from_str("splat_generation").unwrap(), InferenceCategory::SplatGeneration);
|
||||
assert_eq!(InferenceCategory::from_str("character_generation").unwrap(), InferenceCategory::CharacterGeneration);
|
||||
@@ -253,7 +263,7 @@ mod tests {
|
||||
#[test]
|
||||
fn all_variants() {
|
||||
// Static check
|
||||
const EXPECTED_COUNT : usize = 18;
|
||||
const EXPECTED_COUNT : usize = 19;
|
||||
|
||||
assert_eq!(InferenceCategory::all_variants().len(), EXPECTED_COUNT);
|
||||
assert_eq!(InferenceCategory::iter().len(), EXPECTED_COUNT);
|
||||
|
||||
+18
-1
@@ -26,6 +26,7 @@ pub enum InferenceJobProductCategory {
|
||||
|
||||
FalImage,
|
||||
FalVideo,
|
||||
FalAudio,
|
||||
/// Fal: 3D Object Generation
|
||||
FalObject,
|
||||
FalBgRemoval,
|
||||
@@ -57,6 +58,10 @@ pub enum InferenceJobProductCategory {
|
||||
#[serde(rename = "seedance2pro_image")]
|
||||
Seedance2ProImage,
|
||||
|
||||
/// Seedance 2 Pro / Kinovi: Suno audio generation.
|
||||
#[serde(rename = "seedance2pro_audio")]
|
||||
Seedance2ProAudio,
|
||||
|
||||
// =============== SEEDANCE 2 PRO ALT ===============
|
||||
|
||||
#[serde(rename = "seedance2pro_video_alt")]
|
||||
@@ -166,6 +171,7 @@ impl InferenceJobProductCategory {
|
||||
Self::DownloadGptSoVits => "download_gpt_so_vits",
|
||||
Self::FalImage => "fal_image",
|
||||
Self::FalVideo => "fal_video",
|
||||
Self::FalAudio => "fal_audio",
|
||||
Self::FalObject => "fal_object",
|
||||
Self::FalBgRemoval => "fal_bg_removal",
|
||||
Self::GmiCloudVideo => "gmicloud_video",
|
||||
@@ -174,6 +180,7 @@ impl InferenceJobProductCategory {
|
||||
Self::Seedance2ProVideo => "seedance2pro_video",
|
||||
Self::Seedance2ProCharacter => "seedance2pro_character",
|
||||
Self::Seedance2ProImage => "seedance2pro_image",
|
||||
Self::Seedance2ProAudio => "seedance2pro_audio",
|
||||
Self::Seedance2ProVideoAlt => "seedance2pro_video_alt",
|
||||
Self::Seedance2ProVideoBytePlusUltra => "seedance2pro_video_bpu",
|
||||
Self::WorldlabsSplat => "worldlabs_splat",
|
||||
@@ -207,6 +214,7 @@ impl InferenceJobProductCategory {
|
||||
"download_gpt_so_vits" => Ok(Self::DownloadGptSoVits),
|
||||
"fal_image" => Ok(Self::FalImage),
|
||||
"fal_video" => Ok(Self::FalVideo),
|
||||
"fal_audio" => Ok(Self::FalAudio),
|
||||
"fal_object" => Ok(Self::FalObject),
|
||||
"fal_bg_removal" => Ok(Self::FalBgRemoval),
|
||||
"gmicloud_video" => Ok(Self::GmiCloudVideo),
|
||||
@@ -215,6 +223,7 @@ impl InferenceJobProductCategory {
|
||||
"seedance2pro_video" => Ok(Self::Seedance2ProVideo),
|
||||
"seedance2pro_character" => Ok(Self::Seedance2ProCharacter),
|
||||
"seedance2pro_image" => Ok(Self::Seedance2ProImage),
|
||||
"seedance2pro_audio" => Ok(Self::Seedance2ProAudio),
|
||||
"seedance2pro_video_alt" => Ok(Self::Seedance2ProVideoAlt),
|
||||
"seedance2pro_video_bpu" => Ok(Self::Seedance2ProVideoBytePlusUltra),
|
||||
"worldlabs_splat" => Ok(Self::WorldlabsSplat),
|
||||
@@ -251,6 +260,7 @@ impl InferenceJobProductCategory {
|
||||
Self::DownloadGptSoVits,
|
||||
Self::FalImage,
|
||||
Self::FalVideo,
|
||||
Self::FalAudio,
|
||||
Self::FalObject,
|
||||
Self::FalBgRemoval,
|
||||
Self::GmiCloudVideo,
|
||||
@@ -259,6 +269,7 @@ impl InferenceJobProductCategory {
|
||||
Self::Seedance2ProVideo,
|
||||
Self::Seedance2ProCharacter,
|
||||
Self::Seedance2ProImage,
|
||||
Self::Seedance2ProAudio,
|
||||
Self::Seedance2ProVideoAlt,
|
||||
Self::Seedance2ProVideoBytePlusUltra,
|
||||
Self::WorldlabsSplat,
|
||||
@@ -301,6 +312,7 @@ mod tests {
|
||||
assert_serialization(InferenceJobProductCategory::DownloadGptSoVits, "download_gpt_so_vits");
|
||||
assert_serialization(InferenceJobProductCategory::FalImage, "fal_image");
|
||||
assert_serialization(InferenceJobProductCategory::FalVideo, "fal_video");
|
||||
assert_serialization(InferenceJobProductCategory::FalAudio, "fal_audio");
|
||||
assert_serialization(InferenceJobProductCategory::FalObject, "fal_object");
|
||||
assert_serialization(InferenceJobProductCategory::FalBgRemoval, "fal_bg_removal");
|
||||
assert_serialization(InferenceJobProductCategory::GmiCloudVideo, "gmicloud_video");
|
||||
@@ -309,6 +321,7 @@ mod tests {
|
||||
assert_serialization(InferenceJobProductCategory::Seedance2ProVideo, "seedance2pro_video");
|
||||
assert_serialization(InferenceJobProductCategory::Seedance2ProCharacter, "seedance2pro_character");
|
||||
assert_serialization(InferenceJobProductCategory::Seedance2ProImage, "seedance2pro_image");
|
||||
assert_serialization(InferenceJobProductCategory::Seedance2ProAudio, "seedance2pro_audio");
|
||||
assert_serialization(InferenceJobProductCategory::Seedance2ProVideoAlt, "seedance2pro_video_alt");
|
||||
assert_serialization(InferenceJobProductCategory::Seedance2ProVideoBytePlusUltra, "seedance2pro_video_bpu");
|
||||
assert_serialization(InferenceJobProductCategory::WorldlabsSplat, "worldlabs_splat");
|
||||
@@ -341,6 +354,7 @@ mod tests {
|
||||
assert_eq!(InferenceJobProductCategory::DownloadGptSoVits.to_str(), "download_gpt_so_vits");
|
||||
assert_eq!(InferenceJobProductCategory::FalImage.to_str(), "fal_image");
|
||||
assert_eq!(InferenceJobProductCategory::FalVideo.to_str(), "fal_video");
|
||||
assert_eq!(InferenceJobProductCategory::FalAudio.to_str(), "fal_audio");
|
||||
assert_eq!(InferenceJobProductCategory::FalObject.to_str(), "fal_object");
|
||||
assert_eq!(InferenceJobProductCategory::FalBgRemoval.to_str(), "fal_bg_removal");
|
||||
assert_eq!(InferenceJobProductCategory::GmiCloudVideo.to_str(), "gmicloud_video");
|
||||
@@ -349,6 +363,7 @@ mod tests {
|
||||
assert_eq!(InferenceJobProductCategory::Seedance2ProVideo.to_str(), "seedance2pro_video");
|
||||
assert_eq!(InferenceJobProductCategory::Seedance2ProCharacter.to_str(), "seedance2pro_character");
|
||||
assert_eq!(InferenceJobProductCategory::Seedance2ProImage.to_str(), "seedance2pro_image");
|
||||
assert_eq!(InferenceJobProductCategory::Seedance2ProAudio.to_str(), "seedance2pro_audio");
|
||||
assert_eq!(InferenceJobProductCategory::Seedance2ProVideoAlt.to_str(), "seedance2pro_video_alt");
|
||||
assert_eq!(InferenceJobProductCategory::Seedance2ProVideoBytePlusUltra.to_str(), "seedance2pro_video_bpu");
|
||||
assert_eq!(InferenceJobProductCategory::WorldlabsSplat.to_str(), "worldlabs_splat");
|
||||
@@ -380,6 +395,7 @@ mod tests {
|
||||
assert_eq!(InferenceJobProductCategory::from_str("download_gpt_so_vits").unwrap(), InferenceJobProductCategory::DownloadGptSoVits);
|
||||
assert_eq!(InferenceJobProductCategory::from_str("fal_image").unwrap(), InferenceJobProductCategory::FalImage);
|
||||
assert_eq!(InferenceJobProductCategory::from_str("fal_video").unwrap(), InferenceJobProductCategory::FalVideo);
|
||||
assert_eq!(InferenceJobProductCategory::from_str("fal_audio").unwrap(), InferenceJobProductCategory::FalAudio);
|
||||
assert_eq!(InferenceJobProductCategory::from_str("fal_object").unwrap(), InferenceJobProductCategory::FalObject);
|
||||
assert_eq!(InferenceJobProductCategory::from_str("fal_bg_removal").unwrap(), InferenceJobProductCategory::FalBgRemoval);
|
||||
assert_eq!(InferenceJobProductCategory::from_str("gmicloud_video").unwrap(), InferenceJobProductCategory::GmiCloudVideo);
|
||||
@@ -388,6 +404,7 @@ mod tests {
|
||||
assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_video").unwrap(), InferenceJobProductCategory::Seedance2ProVideo);
|
||||
assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_character").unwrap(), InferenceJobProductCategory::Seedance2ProCharacter);
|
||||
assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_image").unwrap(), InferenceJobProductCategory::Seedance2ProImage);
|
||||
assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_audio").unwrap(), InferenceJobProductCategory::Seedance2ProAudio);
|
||||
assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_video_alt").unwrap(), InferenceJobProductCategory::Seedance2ProVideoAlt);
|
||||
assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_video_bpu").unwrap(), InferenceJobProductCategory::Seedance2ProVideoBytePlusUltra);
|
||||
assert_eq!(InferenceJobProductCategory::from_str("worldlabs_splat").unwrap(), InferenceJobProductCategory::WorldlabsSplat);
|
||||
@@ -417,7 +434,7 @@ mod tests {
|
||||
#[test]
|
||||
fn all_variants() {
|
||||
// Static check
|
||||
const EXPECTED_COUNT : usize = 36;
|
||||
const EXPECTED_COUNT : usize = 38;
|
||||
|
||||
assert_eq!(InferenceJobProductCategory::all_variants().len(), EXPECTED_COUNT);
|
||||
assert_eq!(InferenceJobProductCategory::iter().len(), EXPECTED_COUNT);
|
||||
|
||||
+65
@@ -224,6 +224,19 @@ pub enum InferenceModelType {
|
||||
ViduQ3,
|
||||
#[serde(rename = "vidu_q3_turbo")]
|
||||
ViduQ3Turbo,
|
||||
|
||||
// Audio models
|
||||
#[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,
|
||||
|
||||
#[serde(rename = "preview_model")]
|
||||
PreviewModel,
|
||||
#[serde(rename = "preview_model_fast")]
|
||||
@@ -345,6 +358,14 @@ impl InferenceModelType {
|
||||
Self::Veo3p1Lite => "veo_3p1_lite",
|
||||
Self::ViduQ3 => "vidu_q3",
|
||||
Self::ViduQ3Turbo => "vidu_q3_turbo",
|
||||
|
||||
// Audio models
|
||||
Self::SunoMusic => "suno_music",
|
||||
Self::SunoRemix => "suno_remix",
|
||||
Self::SunoSounds => "suno_sounds",
|
||||
Self::SunoSample => "suno_sample",
|
||||
Self::SeedAudio1p0 => "seed_audio_1p0",
|
||||
|
||||
Self::PreviewModel => "preview_model",
|
||||
Self::PreviewModelFast => "preview_model_fast",
|
||||
Self::SwitchX => "switch_x",
|
||||
@@ -453,6 +474,14 @@ impl InferenceModelType {
|
||||
"veo_3p1_lite" => Ok(Self::Veo3p1Lite),
|
||||
"vidu_q3" => Ok(Self::ViduQ3),
|
||||
"vidu_q3_turbo" => Ok(Self::ViduQ3Turbo),
|
||||
|
||||
// Audio models
|
||||
"suno_music" => Ok(Self::SunoMusic),
|
||||
"suno_remix" => Ok(Self::SunoRemix),
|
||||
"suno_sounds" => Ok(Self::SunoSounds),
|
||||
"suno_sample" => Ok(Self::SunoSample),
|
||||
"seed_audio_1p0" => Ok(Self::SeedAudio1p0),
|
||||
|
||||
"preview_model" => Ok(Self::PreviewModel),
|
||||
"preview_model_fast" => Ok(Self::PreviewModelFast),
|
||||
"switch_x" => Ok(Self::SwitchX),
|
||||
@@ -565,6 +594,14 @@ impl InferenceModelType {
|
||||
Self::Veo3p1Lite,
|
||||
Self::ViduQ3,
|
||||
Self::ViduQ3Turbo,
|
||||
|
||||
// Audio models
|
||||
Self::SunoMusic,
|
||||
Self::SunoRemix,
|
||||
Self::SunoSounds,
|
||||
Self::SunoSample,
|
||||
Self::SeedAudio1p0,
|
||||
|
||||
Self::PreviewModel,
|
||||
Self::PreviewModelFast,
|
||||
Self::SwitchX,
|
||||
@@ -660,6 +697,14 @@ impl InferenceModelType {
|
||||
CommonModelType::Veo3p1Lite => Self::Veo3p1Lite,
|
||||
CommonModelType::ViduQ3 => Self::ViduQ3,
|
||||
CommonModelType::ViduQ3Turbo => Self::ViduQ3Turbo,
|
||||
|
||||
// Audio models
|
||||
CommonModelType::SunoMusic => Self::SunoMusic,
|
||||
CommonModelType::SunoRemix => Self::SunoRemix,
|
||||
CommonModelType::SunoSounds => Self::SunoSounds,
|
||||
CommonModelType::SunoSample => Self::SunoSample,
|
||||
CommonModelType::SeedAudio1p0 => Self::SeedAudio1p0,
|
||||
|
||||
CommonModelType::PreviewModel => Self::PreviewModel,
|
||||
CommonModelType::PreviewModelFast => Self::PreviewModelFast,
|
||||
CommonModelType::SwitchX => Self::SwitchX,
|
||||
@@ -778,6 +823,12 @@ mod tests {
|
||||
assert_serialization(InferenceModelType::Veo3p1Lite, "veo_3p1_lite");
|
||||
assert_serialization(InferenceModelType::ViduQ3, "vidu_q3");
|
||||
assert_serialization(InferenceModelType::ViduQ3Turbo, "vidu_q3_turbo");
|
||||
// Audio models
|
||||
assert_serialization(InferenceModelType::SunoMusic, "suno_music");
|
||||
assert_serialization(InferenceModelType::SunoRemix, "suno_remix");
|
||||
assert_serialization(InferenceModelType::SunoSounds, "suno_sounds");
|
||||
assert_serialization(InferenceModelType::SunoSample, "suno_sample");
|
||||
assert_serialization(InferenceModelType::SeedAudio1p0, "seed_audio_1p0");
|
||||
assert_serialization(InferenceModelType::PreviewModel, "preview_model");
|
||||
assert_serialization(InferenceModelType::PreviewModelFast, "preview_model_fast");
|
||||
assert_serialization(InferenceModelType::SwitchX, "switch_x");
|
||||
@@ -885,6 +936,14 @@ mod tests {
|
||||
assert_eq!(InferenceModelType::Veo3p1Lite.to_str(), "veo_3p1_lite");
|
||||
assert_eq!(InferenceModelType::ViduQ3.to_str(), "vidu_q3");
|
||||
assert_eq!(InferenceModelType::ViduQ3Turbo.to_str(), "vidu_q3_turbo");
|
||||
|
||||
// Audio models
|
||||
assert_eq!(InferenceModelType::SunoMusic.to_str(), "suno_music");
|
||||
assert_eq!(InferenceModelType::SunoRemix.to_str(), "suno_remix");
|
||||
assert_eq!(InferenceModelType::SunoSounds.to_str(), "suno_sounds");
|
||||
assert_eq!(InferenceModelType::SunoSample.to_str(), "suno_sample");
|
||||
assert_eq!(InferenceModelType::SeedAudio1p0.to_str(), "seed_audio_1p0");
|
||||
|
||||
assert_eq!(InferenceModelType::PreviewModel.to_str(), "preview_model");
|
||||
assert_eq!(InferenceModelType::PreviewModelFast.to_str(), "preview_model_fast");
|
||||
assert_eq!(InferenceModelType::SwitchX.to_str(), "switch_x");
|
||||
@@ -992,6 +1051,12 @@ mod tests {
|
||||
assert_eq!(InferenceModelType::from_str("veo_3p1_lite").unwrap(), InferenceModelType::Veo3p1Lite);
|
||||
assert_eq!(InferenceModelType::from_str("vidu_q3").unwrap(), InferenceModelType::ViduQ3);
|
||||
assert_eq!(InferenceModelType::from_str("vidu_q3_turbo").unwrap(), InferenceModelType::ViduQ3Turbo);
|
||||
// Audio models
|
||||
assert_eq!(InferenceModelType::from_str("suno_music").unwrap(), InferenceModelType::SunoMusic);
|
||||
assert_eq!(InferenceModelType::from_str("suno_remix").unwrap(), InferenceModelType::SunoRemix);
|
||||
assert_eq!(InferenceModelType::from_str("suno_sounds").unwrap(), InferenceModelType::SunoSounds);
|
||||
assert_eq!(InferenceModelType::from_str("suno_sample").unwrap(), InferenceModelType::SunoSample);
|
||||
assert_eq!(InferenceModelType::from_str("seed_audio_1p0").unwrap(), InferenceModelType::SeedAudio1p0);
|
||||
assert_eq!(InferenceModelType::from_str("preview_model").unwrap(), InferenceModelType::PreviewModel);
|
||||
assert_eq!(InferenceModelType::from_str("preview_model_fast").unwrap(), InferenceModelType::PreviewModelFast);
|
||||
assert_eq!(InferenceModelType::from_str("switch_x").unwrap(), InferenceModelType::SwitchX);
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// Audio models available for generation.
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CommonAudioModel {
|
||||
#[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,
|
||||
}
|
||||
|
||||
impl CommonAudioModel {
|
||||
pub fn to_common_model_type(&self) -> crate::common::generation::common_model_type::CommonModelType {
|
||||
use crate::common::generation::common_model_type::CommonModelType;
|
||||
match self {
|
||||
Self::SunoMusic => CommonModelType::SunoMusic,
|
||||
Self::SunoRemix => CommonModelType::SunoRemix,
|
||||
Self::SunoSounds => CommonModelType::SunoSounds,
|
||||
Self::SunoSample => CommonModelType::SunoSample,
|
||||
Self::SeedAudio1p0 => CommonModelType::SeedAudio1p0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::common::generation::common_model_type::CommonModelType;
|
||||
use crate::test_helpers::assert_serialization;
|
||||
|
||||
#[test]
|
||||
fn test_serialization() {
|
||||
assert_serialization(CommonAudioModel::SunoMusic, "suno_music");
|
||||
assert_serialization(CommonAudioModel::SunoRemix, "suno_remix");
|
||||
assert_serialization(CommonAudioModel::SunoSounds, "suno_sounds");
|
||||
assert_serialization(CommonAudioModel::SunoSample, "suno_sample");
|
||||
assert_serialization(CommonAudioModel::SeedAudio1p0, "seed_audio_1p0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialization() {
|
||||
let cases = [
|
||||
("suno_music", CommonAudioModel::SunoMusic),
|
||||
("suno_remix", CommonAudioModel::SunoRemix),
|
||||
("suno_sounds", CommonAudioModel::SunoSounds),
|
||||
("suno_sample", CommonAudioModel::SunoSample),
|
||||
("seed_audio_1p0", CommonAudioModel::SeedAudio1p0),
|
||||
];
|
||||
for (json_str, expected) in cases {
|
||||
let json = format!("\"{}\"", json_str);
|
||||
let deserialized: CommonAudioModel = serde_json::from_str(&json)
|
||||
.unwrap_or_else(|e| panic!("Failed to deserialize {:?}: {}", json_str, e));
|
||||
assert_eq!(deserialized, expected, "Failed for {:?}", json_str);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_round_trip() {
|
||||
let all = [
|
||||
CommonAudioModel::SunoMusic,
|
||||
CommonAudioModel::SunoRemix,
|
||||
CommonAudioModel::SunoSounds,
|
||||
CommonAudioModel::SunoSample,
|
||||
CommonAudioModel::SeedAudio1p0,
|
||||
];
|
||||
for variant in all {
|
||||
let json = serde_json::to_string(&variant).unwrap();
|
||||
let deserialized: CommonAudioModel = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(variant, deserialized, "Round-trip failed for {:?}", variant);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_audio_models_convert_to_common_model_type() {
|
||||
let models = [
|
||||
(CommonAudioModel::SunoMusic, CommonModelType::SunoMusic),
|
||||
(CommonAudioModel::SunoRemix, CommonModelType::SunoRemix),
|
||||
(CommonAudioModel::SunoSounds, CommonModelType::SunoSounds),
|
||||
(CommonAudioModel::SunoSample, CommonModelType::SunoSample),
|
||||
(CommonAudioModel::SeedAudio1p0, CommonModelType::SeedAudio1p0),
|
||||
];
|
||||
for (audio_model, expected) in models {
|
||||
assert_eq!(audio_model.to_common_model_type(), expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,6 +193,19 @@ pub enum CommonModelType {
|
||||
ViduQ3,
|
||||
#[serde(rename = "vidu_q3_turbo")]
|
||||
ViduQ3Turbo,
|
||||
|
||||
// Audio models
|
||||
#[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,
|
||||
|
||||
#[serde(rename = "preview_model")]
|
||||
PreviewModel,
|
||||
#[serde(rename = "preview_model_fast")]
|
||||
@@ -298,6 +311,14 @@ impl CommonModelType {
|
||||
Self::Veo3p1Lite => "veo_3p1_lite",
|
||||
Self::ViduQ3 => "vidu_q3",
|
||||
Self::ViduQ3Turbo => "vidu_q3_turbo",
|
||||
|
||||
// Audio models
|
||||
Self::SunoMusic => "suno_music",
|
||||
Self::SunoRemix => "suno_remix",
|
||||
Self::SunoSounds => "suno_sounds",
|
||||
Self::SunoSample => "suno_sample",
|
||||
Self::SeedAudio1p0 => "seed_audio_1p0",
|
||||
|
||||
Self::PreviewModel => "preview_model",
|
||||
Self::PreviewModelFast => "preview_model_fast",
|
||||
Self::SwitchX => "switch_x",
|
||||
@@ -389,6 +410,14 @@ impl CommonModelType {
|
||||
"veo_3p1_lite" => Ok(Self::Veo3p1Lite),
|
||||
"vidu_q3" => Ok(Self::ViduQ3),
|
||||
"vidu_q3_turbo" => Ok(Self::ViduQ3Turbo),
|
||||
|
||||
// Audio models
|
||||
"suno_music" => Ok(Self::SunoMusic),
|
||||
"suno_remix" => Ok(Self::SunoRemix),
|
||||
"suno_sounds" => Ok(Self::SunoSounds),
|
||||
"suno_sample" => Ok(Self::SunoSample),
|
||||
"seed_audio_1p0" => Ok(Self::SeedAudio1p0),
|
||||
|
||||
"preview_model" => Ok(Self::PreviewModel),
|
||||
"preview_model_fast" => Ok(Self::PreviewModelFast),
|
||||
"switch_x" => Ok(Self::SwitchX),
|
||||
@@ -484,6 +513,14 @@ impl CommonModelType {
|
||||
Self::Veo3p1Lite,
|
||||
Self::ViduQ3,
|
||||
Self::ViduQ3Turbo,
|
||||
|
||||
// Audio models
|
||||
Self::SunoMusic,
|
||||
Self::SunoRemix,
|
||||
Self::SunoSounds,
|
||||
Self::SunoSample,
|
||||
Self::SeedAudio1p0,
|
||||
|
||||
Self::PreviewModel,
|
||||
Self::PreviewModelFast,
|
||||
Self::SwitchX,
|
||||
@@ -576,6 +613,14 @@ impl CommonModelType {
|
||||
Self::Veo3p1Lite => CommonModelClass::Video,
|
||||
Self::ViduQ3 => CommonModelClass::Video,
|
||||
Self::ViduQ3Turbo => CommonModelClass::Video,
|
||||
|
||||
// Audio models
|
||||
Self::SunoMusic => CommonModelClass::Audio,
|
||||
Self::SunoRemix => CommonModelClass::Audio,
|
||||
Self::SunoSounds => CommonModelClass::Audio,
|
||||
Self::SunoSample => CommonModelClass::Audio,
|
||||
Self::SeedAudio1p0 => CommonModelClass::Audio,
|
||||
|
||||
Self::PreviewModel => CommonModelClass::Video,
|
||||
Self::PreviewModelFast => CommonModelClass::Video,
|
||||
Self::SwitchX => CommonModelClass::Video,
|
||||
@@ -677,6 +722,12 @@ mod tests {
|
||||
assert_serialization(CommonModelType::Veo3p1Lite, "veo_3p1_lite");
|
||||
assert_serialization(CommonModelType::ViduQ3, "vidu_q3");
|
||||
assert_serialization(CommonModelType::ViduQ3Turbo, "vidu_q3_turbo");
|
||||
// Audio models
|
||||
assert_serialization(CommonModelType::SunoMusic, "suno_music");
|
||||
assert_serialization(CommonModelType::SunoRemix, "suno_remix");
|
||||
assert_serialization(CommonModelType::SunoSounds, "suno_sounds");
|
||||
assert_serialization(CommonModelType::SunoSample, "suno_sample");
|
||||
assert_serialization(CommonModelType::SeedAudio1p0, "seed_audio_1p0");
|
||||
assert_serialization(CommonModelType::PreviewModel, "preview_model");
|
||||
assert_serialization(CommonModelType::PreviewModelFast, "preview_model_fast");
|
||||
assert_serialization(CommonModelType::SwitchX, "switch_x");
|
||||
@@ -765,6 +816,14 @@ mod tests {
|
||||
assert_eq!(CommonModelType::Veo3p1Lite.to_str(), "veo_3p1_lite");
|
||||
assert_eq!(CommonModelType::ViduQ3.to_str(), "vidu_q3");
|
||||
assert_eq!(CommonModelType::ViduQ3Turbo.to_str(), "vidu_q3_turbo");
|
||||
|
||||
// Audio models
|
||||
assert_eq!(CommonModelType::SunoMusic.to_str(), "suno_music");
|
||||
assert_eq!(CommonModelType::SunoRemix.to_str(), "suno_remix");
|
||||
assert_eq!(CommonModelType::SunoSounds.to_str(), "suno_sounds");
|
||||
assert_eq!(CommonModelType::SunoSample.to_str(), "suno_sample");
|
||||
assert_eq!(CommonModelType::SeedAudio1p0.to_str(), "seed_audio_1p0");
|
||||
|
||||
assert_eq!(CommonModelType::PreviewModel.to_str(), "preview_model");
|
||||
assert_eq!(CommonModelType::PreviewModelFast.to_str(), "preview_model_fast");
|
||||
assert_eq!(CommonModelType::SwitchX.to_str(), "switch_x");
|
||||
@@ -853,6 +912,12 @@ mod tests {
|
||||
assert_eq!(CommonModelType::from_str("veo_3p1_lite").unwrap(), CommonModelType::Veo3p1Lite);
|
||||
assert_eq!(CommonModelType::from_str("vidu_q3").unwrap(), CommonModelType::ViduQ3);
|
||||
assert_eq!(CommonModelType::from_str("vidu_q3_turbo").unwrap(), CommonModelType::ViduQ3Turbo);
|
||||
// Audio models
|
||||
assert_eq!(CommonModelType::from_str("suno_music").unwrap(), CommonModelType::SunoMusic);
|
||||
assert_eq!(CommonModelType::from_str("suno_remix").unwrap(), CommonModelType::SunoRemix);
|
||||
assert_eq!(CommonModelType::from_str("suno_sounds").unwrap(), CommonModelType::SunoSounds);
|
||||
assert_eq!(CommonModelType::from_str("suno_sample").unwrap(), CommonModelType::SunoSample);
|
||||
assert_eq!(CommonModelType::from_str("seed_audio_1p0").unwrap(), CommonModelType::SeedAudio1p0);
|
||||
assert_eq!(CommonModelType::from_str("preview_model").unwrap(), CommonModelType::PreviewModel);
|
||||
assert_eq!(CommonModelType::from_str("preview_model_fast").unwrap(), CommonModelType::PreviewModelFast);
|
||||
assert_eq!(CommonModelType::from_str("switch_x").unwrap(), CommonModelType::SwitchX);
|
||||
@@ -869,7 +934,7 @@ mod tests {
|
||||
#[test]
|
||||
fn all_variants() {
|
||||
let mut variants = CommonModelType::all_variants();
|
||||
assert_eq!(variants.len(), 79);
|
||||
assert_eq!(variants.len(), 84);
|
||||
// Image models
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Dev));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Schnell));
|
||||
@@ -943,6 +1008,12 @@ mod tests {
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Veo3p1Lite));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::ViduQ3));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::ViduQ3Turbo));
|
||||
// Audio models
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::SunoMusic));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::SunoRemix));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::SunoSounds));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::SunoSample));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::SeedAudio1p0));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::PreviewModel));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::PreviewModelFast));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::SwitchX));
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// Musical keys available for audio generation (eg. Suno Sounds).
|
||||
///
|
||||
/// NB: There are intentionally no E keys, per product spec.
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CommonMusicalKey {
|
||||
/// Let the model pick the key.
|
||||
#[serde(rename = "auto")]
|
||||
Auto,
|
||||
|
||||
#[serde(rename = "c_major")]
|
||||
CMajor,
|
||||
|
||||
#[serde(rename = "c_minor")]
|
||||
CMinor,
|
||||
|
||||
#[serde(rename = "d_major")]
|
||||
DMajor,
|
||||
|
||||
#[serde(rename = "d_minor")]
|
||||
DMinor,
|
||||
|
||||
#[serde(rename = "f_major")]
|
||||
FMajor,
|
||||
|
||||
#[serde(rename = "f_minor")]
|
||||
FMinor,
|
||||
|
||||
#[serde(rename = "g_major")]
|
||||
GMajor,
|
||||
|
||||
#[serde(rename = "g_minor")]
|
||||
GMinor,
|
||||
|
||||
#[serde(rename = "a_major")]
|
||||
AMajor,
|
||||
|
||||
#[serde(rename = "a_minor")]
|
||||
AMinor,
|
||||
|
||||
#[serde(rename = "b_major")]
|
||||
BMajor,
|
||||
|
||||
#[serde(rename = "b_minor")]
|
||||
BMinor,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_helpers::assert_serialization;
|
||||
|
||||
#[test]
|
||||
fn test_serialization() {
|
||||
assert_serialization(CommonMusicalKey::Auto, "auto");
|
||||
assert_serialization(CommonMusicalKey::CMajor, "c_major");
|
||||
assert_serialization(CommonMusicalKey::CMinor, "c_minor");
|
||||
assert_serialization(CommonMusicalKey::DMajor, "d_major");
|
||||
assert_serialization(CommonMusicalKey::DMinor, "d_minor");
|
||||
assert_serialization(CommonMusicalKey::FMajor, "f_major");
|
||||
assert_serialization(CommonMusicalKey::FMinor, "f_minor");
|
||||
assert_serialization(CommonMusicalKey::GMajor, "g_major");
|
||||
assert_serialization(CommonMusicalKey::GMinor, "g_minor");
|
||||
assert_serialization(CommonMusicalKey::AMajor, "a_major");
|
||||
assert_serialization(CommonMusicalKey::AMinor, "a_minor");
|
||||
assert_serialization(CommonMusicalKey::BMajor, "b_major");
|
||||
assert_serialization(CommonMusicalKey::BMinor, "b_minor");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialization() {
|
||||
let cases = [
|
||||
("auto", CommonMusicalKey::Auto),
|
||||
("c_major", CommonMusicalKey::CMajor),
|
||||
("c_minor", CommonMusicalKey::CMinor),
|
||||
("d_major", CommonMusicalKey::DMajor),
|
||||
("d_minor", CommonMusicalKey::DMinor),
|
||||
("f_major", CommonMusicalKey::FMajor),
|
||||
("f_minor", CommonMusicalKey::FMinor),
|
||||
("g_major", CommonMusicalKey::GMajor),
|
||||
("g_minor", CommonMusicalKey::GMinor),
|
||||
("a_major", CommonMusicalKey::AMajor),
|
||||
("a_minor", CommonMusicalKey::AMinor),
|
||||
("b_major", CommonMusicalKey::BMajor),
|
||||
("b_minor", CommonMusicalKey::BMinor),
|
||||
];
|
||||
for (json_str, expected) in cases {
|
||||
let json = format!("\"{}\"", json_str);
|
||||
let deserialized: CommonMusicalKey = serde_json::from_str(&json)
|
||||
.unwrap_or_else(|e| panic!("Failed to deserialize {:?}: {}", json_str, e));
|
||||
assert_eq!(deserialized, expected, "Failed for {:?}", json_str);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_round_trip() {
|
||||
let all = [
|
||||
CommonMusicalKey::Auto,
|
||||
CommonMusicalKey::CMajor,
|
||||
CommonMusicalKey::CMinor,
|
||||
CommonMusicalKey::DMajor,
|
||||
CommonMusicalKey::DMinor,
|
||||
CommonMusicalKey::FMajor,
|
||||
CommonMusicalKey::FMinor,
|
||||
CommonMusicalKey::GMajor,
|
||||
CommonMusicalKey::GMinor,
|
||||
CommonMusicalKey::AMajor,
|
||||
CommonMusicalKey::AMinor,
|
||||
CommonMusicalKey::BMajor,
|
||||
CommonMusicalKey::BMinor,
|
||||
];
|
||||
for variant in all {
|
||||
let json = serde_json::to_string(&variant).unwrap();
|
||||
let deserialized: CommonMusicalKey = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(variant, deserialized, "Round-trip failed for {:?}", variant);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
pub mod common_aspect_ratio;
|
||||
pub mod common_audio_model;
|
||||
pub mod common_bitrate;
|
||||
pub mod common_generation_mode;
|
||||
pub mod common_image_model;
|
||||
pub mod common_musical_key;
|
||||
pub mod common_quality;
|
||||
pub mod common_model_class;
|
||||
pub mod common_model_type;
|
||||
|
||||
@@ -46,6 +46,8 @@ pub enum ModelCreator {
|
||||
Runway,
|
||||
#[serde(rename = "stability")]
|
||||
Stability,
|
||||
#[serde(rename = "suno")]
|
||||
Suno,
|
||||
#[serde(rename = "tencent")]
|
||||
Tencent,
|
||||
#[serde(rename = "tensor_art")]
|
||||
@@ -78,6 +80,7 @@ impl ModelCreator {
|
||||
Self::Replicate => "Replicate",
|
||||
Self::Runway => "Runway",
|
||||
Self::Stability => "Stability AI",
|
||||
Self::Suno => "Suno",
|
||||
Self::Tencent => "Tencent",
|
||||
Self::TensorArt => "TensorArt",
|
||||
Self::Vidu => "Vidu",
|
||||
@@ -105,6 +108,7 @@ impl ModelCreator {
|
||||
Self::Replicate => "replicate",
|
||||
Self::Runway => "runway",
|
||||
Self::Stability => "stability",
|
||||
Self::Suno => "suno",
|
||||
Self::Tencent => "tencent",
|
||||
Self::TensorArt => "tensor_art",
|
||||
Self::Vidu => "vidu",
|
||||
@@ -132,6 +136,7 @@ impl ModelCreator {
|
||||
"replicate" => Ok(Self::Replicate),
|
||||
"runway" => Ok(Self::Runway),
|
||||
"stability" => Ok(Self::Stability),
|
||||
"suno" => Ok(Self::Suno),
|
||||
"tencent" => Ok(Self::Tencent),
|
||||
"tensor_art" => Ok(Self::TensorArt),
|
||||
"vidu" => Ok(Self::Vidu),
|
||||
@@ -160,6 +165,7 @@ impl ModelCreator {
|
||||
Self::Replicate,
|
||||
Self::Runway,
|
||||
Self::Stability,
|
||||
Self::Suno,
|
||||
Self::Tencent,
|
||||
Self::TensorArt,
|
||||
Self::Vidu,
|
||||
@@ -198,6 +204,7 @@ mod tests {
|
||||
assert_serialization(ModelCreator::Replicate, "replicate");
|
||||
assert_serialization(ModelCreator::Runway, "runway");
|
||||
assert_serialization(ModelCreator::Stability, "stability");
|
||||
assert_serialization(ModelCreator::Suno, "suno");
|
||||
assert_serialization(ModelCreator::Tencent, "tencent");
|
||||
assert_serialization(ModelCreator::TensorArt, "tensor_art");
|
||||
assert_serialization(ModelCreator::Vidu, "vidu");
|
||||
@@ -210,6 +217,7 @@ mod tests {
|
||||
assert_eq!(ModelCreator::ArtCraft.to_str(), "artcraft");
|
||||
assert_eq!(ModelCreator::BlackForestLabs.to_str(), "black_forest_labs");
|
||||
assert_eq!(ModelCreator::OpenAi.to_str(), "open_ai");
|
||||
assert_eq!(ModelCreator::Suno.to_str(), "suno");
|
||||
assert_eq!(ModelCreator::WorldLabs.to_str(), "world_labs");
|
||||
}
|
||||
|
||||
@@ -219,6 +227,7 @@ mod tests {
|
||||
assert_eq!(ModelCreator::from_str("artcraft").unwrap(), ModelCreator::ArtCraft);
|
||||
assert_eq!(ModelCreator::from_str("black_forest_labs").unwrap(), ModelCreator::BlackForestLabs);
|
||||
assert_eq!(ModelCreator::from_str("open_ai").unwrap(), ModelCreator::OpenAi);
|
||||
assert_eq!(ModelCreator::from_str("suno").unwrap(), ModelCreator::Suno);
|
||||
assert_eq!(ModelCreator::from_str("world_labs").unwrap(), ModelCreator::WorldLabs);
|
||||
assert!(ModelCreator::from_str("invalid").is_err());
|
||||
}
|
||||
@@ -233,12 +242,13 @@ mod tests {
|
||||
assert_eq!(ModelCreator::Google.get_name(), "Google");
|
||||
assert_eq!(ModelCreator::OpenAi.get_name(), "OpenAI");
|
||||
assert_eq!(ModelCreator::Stability.get_name(), "Stability AI");
|
||||
assert_eq!(ModelCreator::Suno.get_name(), "Suno");
|
||||
assert_eq!(ModelCreator::WorldLabs.get_name(), "World Labs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_variants() {
|
||||
const EXPECTED_COUNT: usize = 22;
|
||||
const EXPECTED_COUNT: usize = 23;
|
||||
assert_eq!(ModelCreator::all_variants().len(), EXPECTED_COUNT);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,6 +359,7 @@ async fn old_dispatch(
|
||||
anyhow!("invalid job category for dispatch: {:?}", job.inference_category)))
|
||||
}
|
||||
InferenceCategory::VideoGeneration
|
||||
| InferenceCategory::AudioGeneration
|
||||
| InferenceCategory::BackgroundRemoval
|
||||
| InferenceCategory::ObjectGeneration
|
||||
| InferenceCategory::SplatGeneration
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
use artcraft_api_defs::omni_gen::models::omni_gen_audio_models::{
|
||||
OmniGenAudioModelDetails,
|
||||
OmniGenAudioModelProviderDetails,
|
||||
OmniGenAudioModelsResponse,
|
||||
OmniGenAudioProviderModelDetails,
|
||||
};
|
||||
use enums::common::generation::common_audio_model::CommonAudioModel;
|
||||
use enums::common::generation::model_creator::ModelCreator;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
pub static OMNI_GEN_AUDIO_MODELS_AND_PROVIDERS: Lazy<OmniGenAudioModelsResponse> = Lazy::new(|| {
|
||||
let models = build_omni_gen_audio_models();
|
||||
let providers = build_omni_gen_audio_model_providers();
|
||||
OmniGenAudioModelsResponse {
|
||||
success: true,
|
||||
models,
|
||||
providers,
|
||||
}
|
||||
});
|
||||
|
||||
fn build_omni_gen_audio_models() -> Vec<OmniGenAudioModelDetails> {
|
||||
let mut models = Vec::new();
|
||||
|
||||
// Suno Music: full songs from a text prompt with optional style direction.
|
||||
models.push(OmniGenAudioModelDetails {
|
||||
model: CommonAudioModel::SunoMusic,
|
||||
model_creator: Some(ModelCreator::Suno),
|
||||
full_name: Some("Suno Music".to_string()),
|
||||
text_prompt_supported: Some(true),
|
||||
style_prompt_supported: Some(true),
|
||||
instrumental_toggle_supported: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Suno Remix: remix an existing track (exactly one audio reference).
|
||||
models.push(OmniGenAudioModelDetails {
|
||||
model: CommonAudioModel::SunoRemix,
|
||||
model_creator: Some(ModelCreator::Suno),
|
||||
full_name: Some("Suno Remix".to_string()),
|
||||
text_prompt_supported: Some(true),
|
||||
style_prompt_supported: Some(true),
|
||||
keep_lyrics_supported: Some(true),
|
||||
audio_references_supported: Some(true),
|
||||
audio_references_max: Some(1),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Suno Sounds: sound effects with loop, BPM, and musical key controls.
|
||||
models.push(OmniGenAudioModelDetails {
|
||||
model: CommonAudioModel::SunoSounds,
|
||||
model_creator: Some(ModelCreator::Suno),
|
||||
full_name: Some("Suno Sounds".to_string()),
|
||||
text_prompt_supported: Some(true),
|
||||
loopable_toggle_supported: Some(true),
|
||||
bpm_supported: Some(true),
|
||||
musical_key_supported: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Suno Sample: build a song from a sample (exactly one audio reference).
|
||||
models.push(OmniGenAudioModelDetails {
|
||||
model: CommonAudioModel::SunoSample,
|
||||
model_creator: Some(ModelCreator::Suno),
|
||||
full_name: Some("Suno Sample".to_string()),
|
||||
text_prompt_supported: Some(true),
|
||||
style_prompt_supported: Some(true),
|
||||
instrumental_toggle_supported: Some(true),
|
||||
audio_references_supported: Some(true),
|
||||
audio_references_max: Some(1),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Seed Audio 1.0 (ByteDance): sound generation with audio/image references
|
||||
// and output shaping controls.
|
||||
models.push(OmniGenAudioModelDetails {
|
||||
model: CommonAudioModel::SeedAudio1p0,
|
||||
model_creator: Some(ModelCreator::Bytedance),
|
||||
full_name: Some("Seed Audio 1.0".to_string()),
|
||||
text_prompt_supported: Some(true),
|
||||
audio_references_supported: Some(true),
|
||||
audio_references_max: Some(3),
|
||||
image_references_supported: Some(true),
|
||||
image_references_max: Some(1),
|
||||
sample_rate_hz_options: Some(vec![8000, 16000, 24000, 32000, 44100, 48000]),
|
||||
sample_rate_hz_default: Some(24000),
|
||||
speed_supported: Some(true),
|
||||
volume_supported: Some(true),
|
||||
pitch_supported: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
models
|
||||
}
|
||||
|
||||
fn build_omni_gen_audio_model_providers() -> Vec<OmniGenAudioModelProviderDetails> {
|
||||
let mut providers = Vec::new();
|
||||
|
||||
providers.push(OmniGenAudioModelProviderDetails {
|
||||
provider: GenerationProvider::Artcraft,
|
||||
models: vec![
|
||||
OmniGenAudioProviderModelDetails {
|
||||
model: CommonAudioModel::SunoMusic,
|
||||
overrides: None,
|
||||
},
|
||||
OmniGenAudioProviderModelDetails {
|
||||
model: CommonAudioModel::SunoRemix,
|
||||
overrides: None,
|
||||
},
|
||||
OmniGenAudioProviderModelDetails {
|
||||
model: CommonAudioModel::SunoSounds,
|
||||
overrides: None,
|
||||
},
|
||||
OmniGenAudioProviderModelDetails {
|
||||
model: CommonAudioModel::SunoSample,
|
||||
overrides: None,
|
||||
},
|
||||
OmniGenAudioProviderModelDetails {
|
||||
model: CommonAudioModel::SeedAudio1p0,
|
||||
overrides: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
providers
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod audio_models;
|
||||
pub mod image_models;
|
||||
pub mod video_models;
|
||||
pub mod video_models;
|
||||
|
||||
@@ -344,12 +344,16 @@ use artcraft_api_defs::characters::list_characters::*;
|
||||
use artcraft_api_defs::moderation::alerts::moderation_send_alert::*;
|
||||
use artcraft_api_defs::omni_api::generate_requests::omni_api_image_generate_request::*;
|
||||
use artcraft_api_defs::omni_api::generate_requests::omni_api_video_generate_request::*;
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_audio_cost_and_generate_request::*;
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::*;
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::*;
|
||||
use artcraft_api_defs::omni_gen::cost_response::omni_gen_audio_cost_response::*;
|
||||
use artcraft_api_defs::omni_gen::cost_response::omni_gen_image_cost_response::*;
|
||||
use artcraft_api_defs::omni_gen::cost_response::omni_gen_video_cost_response::*;
|
||||
use artcraft_api_defs::omni_gen::generate_response::omni_gen_audio_generate_response::*;
|
||||
use artcraft_api_defs::omni_gen::generate_response::omni_gen_image_generate_response::*;
|
||||
use artcraft_api_defs::omni_gen::generate_response::omni_gen_video_generate_response::*;
|
||||
use artcraft_api_defs::omni_gen::models::omni_gen_audio_models::*;
|
||||
use artcraft_api_defs::omni_gen::models::omni_gen_image_models::*;
|
||||
use artcraft_api_defs::omni_gen::models::omni_gen_video_models::*;
|
||||
use artcraft_api_defs::omni_api::job_status::omni_api_batch_get_job_status::*;
|
||||
@@ -566,10 +570,13 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
crate::http_server::endpoints::characters::edit_character_handler::edit_character_handler,
|
||||
crate::http_server::endpoints::characters::delete_character_handler::delete_character_handler,
|
||||
// Omni Gen
|
||||
crate::http_server::endpoints::omni_gen::cost::audio::omni_gen_audio_cost_handler::omni_gen_audio_cost_handler,
|
||||
crate::http_server::endpoints::omni_gen::cost::image::omni_gen_image_cost_handler::omni_gen_image_cost_handler,
|
||||
crate::http_server::endpoints::omni_gen::cost::video::omni_gen_video_cost_handler::omni_gen_video_cost_handler,
|
||||
crate::http_server::endpoints::omni_gen::generate::audio::omni_gen_audio_generate_handler::omni_gen_audio_generate_handler,
|
||||
crate::http_server::endpoints::omni_gen::generate::image::omni_gen_image_generate_handler::omni_gen_image_generate_handler,
|
||||
crate::http_server::endpoints::omni_gen::generate::video::omni_gen_video_generate_handler::omni_gen_video_generate_handler,
|
||||
crate::http_server::endpoints::omni_gen::models::audio::omni_gen_audio_models_handler::omni_gen_audio_models_handler,
|
||||
crate::http_server::endpoints::omni_gen::models::image::omni_gen_image_models_handler::omni_gen_image_models_handler,
|
||||
crate::http_server::endpoints::omni_gen::models::video::omni_gen_video_models_handler::omni_gen_video_models_handler,
|
||||
// Omni API (API-key authenticated generate)
|
||||
@@ -857,6 +864,13 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
OmniApiJobStatusDetails,
|
||||
OmniApiJobStatusPayload,
|
||||
OmniApiVideoGenerateRequest,
|
||||
OmniGenAudioCostAndGenerateRequest,
|
||||
OmniGenAudioCostResponse,
|
||||
OmniGenAudioGenerateResponse,
|
||||
OmniGenAudioModelDetails,
|
||||
OmniGenAudioModelProviderDetails,
|
||||
OmniGenAudioModelsResponse,
|
||||
OmniGenAudioProviderModelDetails,
|
||||
OmniGenImageCostAndGenerateRequest,
|
||||
OmniGenImageCostResponse,
|
||||
OmniGenImageGenerateResponse,
|
||||
|
||||
+1
@@ -72,6 +72,7 @@ pub fn estimate_job_progress(job: &GenericInferenceJobStatus, maybe_args: Option
|
||||
|
||||
// TODO: Better estimates for FAL, etc.
|
||||
InferenceCategory::VideoGeneration => percent(duration_seconds, 60*5),
|
||||
InferenceCategory::AudioGeneration => percent(duration_seconds, 60*2),
|
||||
InferenceCategory::BackgroundRemoval => percent(duration_seconds, 10),
|
||||
InferenceCategory::ObjectGeneration => percent(duration_seconds, 60*2),
|
||||
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
pub mod omni_gen_audio_cost_handler;
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::Json;
|
||||
use actix_web::{web, HttpRequest};
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_audio_cost_and_generate_request::OmniGenAudioCostAndGenerateRequest;
|
||||
use artcraft_api_defs::omni_gen::cost_response::omni_gen_audio_cost_response::OmniGenAudioCostResponse;
|
||||
use artcraft_router::api::router_provider::RouterProvider;
|
||||
use log::warn;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::omni_gen::generate::audio::helpers::hydrate_router_request::hydrate_to_router_request;
|
||||
use crate::http_server::endpoints::omni_gen::shared_utils::audio::validate_audio_request::validate_audio_request;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// Estimate the cost of an audio generation.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
tag = "Omni Gen",
|
||||
path = "/v1/omni_gen/cost/audio",
|
||||
request_body = OmniGenAudioCostAndGenerateRequest,
|
||||
responses(
|
||||
(status = 200, description = "Success", body = OmniGenAudioCostResponse),
|
||||
(status = 400, description = "Bad input"),
|
||||
(status = 500, description = "Server error"),
|
||||
),
|
||||
)]
|
||||
pub async fn omni_gen_audio_cost_handler(
|
||||
_http_request: HttpRequest,
|
||||
request: Json<OmniGenAudioCostAndGenerateRequest>,
|
||||
_server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<OmniGenAudioCostResponse>, CommonWebError> {
|
||||
validate_audio_request(&request)?;
|
||||
|
||||
let mut builder = hydrate_to_router_request(&request)?;
|
||||
|
||||
builder.provider = RouterProvider::Artcraft;
|
||||
|
||||
let estimate = builder.build2()
|
||||
.map_err(|e| {
|
||||
warn!("Failed to build audio cost estimate: {}", e);
|
||||
CommonWebError::from_error(e)
|
||||
})?
|
||||
.estimate_cost()
|
||||
.map_err(|e| {
|
||||
warn!("Failed to estimate audio cost: {}", e);
|
||||
CommonWebError::from_error(e)
|
||||
})?;
|
||||
|
||||
Ok(Json(OmniGenAudioCostResponse {
|
||||
success: true,
|
||||
cost_in_credits: estimate.cost_in_credits,
|
||||
cost_in_usd_cents: estimate.cost_in_usd_cents,
|
||||
is_free: estimate.is_free,
|
||||
is_unlimited: estimate.is_unlimited,
|
||||
is_rate_limited: estimate.is_rate_limited,
|
||||
has_watermark: estimate.has_watermark,
|
||||
failures_are_refunded: estimate.failures_are_refunded,
|
||||
}))
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod audio;
|
||||
pub mod image;
|
||||
pub mod video;
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_audio_cost_and_generate_request::OmniGenAudioCostAndGenerateRequest;
|
||||
use artcraft_router::api::audio_list_ref::AudioListRef;
|
||||
use artcraft_router::api::image_list_ref::ImageListRef;
|
||||
use artcraft_router::api::router_audio_model::RouterAudioModel;
|
||||
use artcraft_router::api::router_provider::RouterProvider;
|
||||
use artcraft_router::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use artcraft_router::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
|
||||
use enums::common::generation::common_audio_model::CommonAudioModel as CommonAudioModelEnum;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
|
||||
pub fn hydrate_to_router_request(
|
||||
request: &OmniGenAudioCostAndGenerateRequest,
|
||||
) -> Result<GenerateAudioRequestBuilder, CommonWebError> {
|
||||
let api_model = request.model
|
||||
.as_ref()
|
||||
.ok_or_else(|| CommonWebError::BadInputWithSimpleMessage(
|
||||
"model is required".to_string(),
|
||||
))?;
|
||||
|
||||
let model = convert_model(api_model)?;
|
||||
|
||||
Ok(GenerateAudioRequestBuilder {
|
||||
model,
|
||||
provider: RouterProvider::Artcraft,
|
||||
prompt: request.prompt.clone(),
|
||||
style_prompt: request.style_prompt.clone(),
|
||||
audio_references: request.audio_media_tokens.clone()
|
||||
.map(AudioListRef::MediaFileTokens),
|
||||
image_references: request.image_media_tokens.clone()
|
||||
.map(ImageListRef::MediaFileTokens),
|
||||
keep_lyrics: request.keep_lyrics,
|
||||
is_instrumental: request.is_instrumental,
|
||||
is_loopable: request.is_loopable,
|
||||
bpm: request.bpm,
|
||||
musical_key: request.musical_key,
|
||||
sample_rate_hz: request.sample_rate_hz,
|
||||
speed: request.speed,
|
||||
volume: request.volume,
|
||||
pitch: request.pitch,
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
|
||||
idempotency_token: request.idempotency_token.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn convert_model(
|
||||
model: &CommonAudioModelEnum,
|
||||
) -> Result<RouterAudioModel, CommonWebError> {
|
||||
let json = serde_json::to_string(model)?;
|
||||
serde_json::from_str(&json).map_err(|e| {
|
||||
CommonWebError::BadInputWithSimpleMessage(
|
||||
format!("Unsupported audio model: {}", e),
|
||||
)
|
||||
})
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub(crate) mod hydrate_router_request;
|
||||
pub mod pipeline_result;
|
||||
pub mod resolve_media_tokens_to_urls;
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
use artcraft_router::generate::generate_audio::generate_audio_response::GenerateAudioResponse;
|
||||
use mysql_queries::queries::generic_inference::common::job_cost_estimates::JobCostEstimates;
|
||||
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::helpers::bill_wallet::BillWalletResult;
|
||||
|
||||
/// The pipeline produces this, then the handler suffix handles DB writes.
|
||||
pub struct PipelineResult {
|
||||
pub billing: BillWalletResult,
|
||||
pub response: GenerateAudioResponse,
|
||||
|
||||
/// System (user-facing) and provider-side cost estimates, written onto the
|
||||
/// generic_inference_jobs rows.
|
||||
pub cost_estimates: JobCostEstimates,
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user