mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-22 01:29:31 -04:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd8fe7f3db | ||
|
|
54b172bf6f | ||
|
|
d7868c7da0 | ||
|
|
d538bc2a68 | ||
|
|
bec8c7ede4 | ||
|
|
0866e63dd4 | ||
|
|
78a5c4ff87 | ||
|
|
d53fad0072 | ||
|
|
2e5e9443a3 | ||
|
|
5bda2a6cb4 | ||
|
|
9cf1b8e066 | ||
|
|
aebc6ab777 | ||
|
|
d3c89face5 | ||
|
|
692e0ecf75 | ||
|
|
94e1aef78d | ||
|
|
9a4bb3b490 | ||
|
|
299d9b8d23 | ||
|
|
89eff46c6f |
@@ -77,3 +77,20 @@ Organize for top-to-bottom reading. Important things first, details later.
|
||||
- Private helpers go *below* the methods that call them
|
||||
- Among helpers: meatier logic above leaf-level formatters
|
||||
- **In test modules**: constants first, then test cases (grouped into sub-modules when 2+), then helper functions last
|
||||
|
||||
## Markdown
|
||||
|
||||
- **Tables must be space-padded so columns align in plain text.** Markdown
|
||||
tables are read raw (terminals, diffs, editors) at least as often as
|
||||
rendered, and condensed tables are unreadable there. Pad every cell to its
|
||||
column width:
|
||||
|
||||
```markdown
|
||||
| Model | Configuration | Credits | Speed | Score |
|
||||
|--------------|---------------|--------------------|---------|
|
||||
| Meshy 6 | text or image | 104 | 80.0 | +24 |
|
||||
| Rodin 2.5 | text or image | 13 | 10.0 | +3 |
|
||||
```
|
||||
|
||||
Not: `| Model | Configuration | Credits | Speed | Score |` packed tight
|
||||
with varying widths per row.
|
||||
|
||||
Generated
+1
@@ -765,6 +765,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"strum",
|
||||
"test_data",
|
||||
"test_utils",
|
||||
"tokens",
|
||||
"tokio",
|
||||
"url_utils",
|
||||
|
||||
+3
@@ -1,2 +1,5 @@
|
||||
pub mod omni_gen_audio_cost_and_generate_request;
|
||||
pub mod omni_gen_image_cost_and_generate_request;
|
||||
pub mod omni_gen_mesh_cost_and_generate_request;
|
||||
pub mod omni_gen_splat_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>,
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use enums::common::generation::common_mesh_model::CommonMeshModel;
|
||||
use enums::common::generation::common_mesh_output_type::CommonMeshOutputType;
|
||||
use enums::common::generation::common_mesh_quality::CommonMeshQuality;
|
||||
use enums::common::generation::common_polygon_type::CommonPolygonType;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
/// Shared request body for both the mesh cost estimate and mesh generation endpoints.
|
||||
#[derive(Clone, Serialize, Deserialize, ToSchema, Debug)]
|
||||
pub struct OmniGenMeshCostAndGenerateRequest {
|
||||
/// 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<CommonMeshModel>,
|
||||
|
||||
/// The text prompt for the mesh generation (text-to-3D).
|
||||
pub prompt: Option<String>,
|
||||
|
||||
/// Reference images (optional).
|
||||
/// The primary/front input image, or the sketch image for sketch-to-3D.
|
||||
pub reference_image_media_tokens: Option<Vec<MediaFileToken>>,
|
||||
|
||||
/// Front view image (optional; multi-view input).
|
||||
/// Can be used instead of `reference_image_media_tokens[0]` when the other
|
||||
/// side views are supplied.
|
||||
pub front_image_media_token: Option<MediaFileToken>,
|
||||
|
||||
/// Back view image (optional; multi-view input).
|
||||
pub back_image_media_token: Option<MediaFileToken>,
|
||||
|
||||
/// Left view image (optional; multi-view input).
|
||||
pub left_image_media_token: Option<MediaFileToken>,
|
||||
|
||||
/// Right view image (optional; multi-view input).
|
||||
pub right_image_media_token: Option<MediaFileToken>,
|
||||
|
||||
/// Input mesh file for mesh-to-mesh models (part splitting, retopology).
|
||||
/// Required by `hunyuan_3d_3p1_part` (FBX) and
|
||||
/// `hunyuan_3d_3p1_topology` (GLB/OBJ).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub input_mesh_media_token: Option<MediaFileToken>,
|
||||
|
||||
/// The mesh output type to generate (normal, low-poly, or geometry-only).
|
||||
pub mesh_output_type: Option<CommonMeshOutputType>,
|
||||
|
||||
/// The polygon type to use for the generated mesh (triangle or quad).
|
||||
pub polygon_type: Option<CommonPolygonType>,
|
||||
|
||||
/// Target face count for the generated mesh.
|
||||
pub face_count: Option<u64>,
|
||||
|
||||
/// Whether to generate PBR (physically based rendering) materials.
|
||||
pub enable_pbr: Option<bool>,
|
||||
|
||||
/// Whether to generate textures. Some models (Tripo3D) can skip texturing
|
||||
/// for a cheaper, faster generation.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub enable_texture: Option<bool>,
|
||||
|
||||
/// Texture quality. `Detailed` costs extra on some models (Tripo3D's HD
|
||||
/// texture tier).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub texture_quality: Option<CommonMeshQuality>,
|
||||
|
||||
/// Geometry quality. `Detailed` costs extra on some models (Tripo3D).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub geometry_quality: Option<CommonMeshQuality>,
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use enums::common::generation::common_splat_model::CommonSplatModel;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
/// Shared request body for both the splat cost estimate and splat generation endpoints.
|
||||
#[derive(Clone, Serialize, Deserialize, ToSchema, Debug)]
|
||||
pub struct OmniGenSplatCostAndGenerateRequest {
|
||||
/// 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<CommonSplatModel>,
|
||||
|
||||
/// The text prompt for the splat generation.
|
||||
pub prompt: Option<String>,
|
||||
|
||||
/// Reference images (optional).
|
||||
/// NOTE: Spherical/panorama coordinates are not yet supported per-image;
|
||||
/// multiple images are treated as multi-view input of the same world.
|
||||
pub reference_image_media_tokens: Option<Vec<MediaFileToken>>,
|
||||
|
||||
/// Reference video (optional).
|
||||
pub reference_video_media_token: Option<MediaFileToken>,
|
||||
|
||||
/// Whether the single reference image is a 360-degree panorama.
|
||||
pub is_panoramic: Option<bool>,
|
||||
|
||||
/// Whether to disable prompt recaptioning.
|
||||
pub disable_recaption: Option<bool>,
|
||||
}
|
||||
@@ -1,2 +1,5 @@
|
||||
pub mod omni_gen_audio_cost_response;
|
||||
pub mod omni_gen_image_cost_response;
|
||||
pub mod omni_gen_mesh_cost_response;
|
||||
pub mod omni_gen_splat_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>,
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// Response body for the omni-gen mesh cost estimate endpoint.
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct OmniGenMeshCostResponse {
|
||||
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>,
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// Response body for the omni-gen splat cost estimate endpoint.
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct OmniGenSplatCostResponse {
|
||||
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
-1
@@ -2,7 +2,7 @@ use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// Response body for the omni-gen video cost estimate endpoint.
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct OmniGenVideoCostResponse {
|
||||
pub success: bool,
|
||||
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
pub mod omni_gen_audio_generate_response;
|
||||
pub mod omni_gen_image_generate_response;
|
||||
pub mod omni_gen_mesh_generate_response;
|
||||
pub mod omni_gen_splat_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>,
|
||||
}
|
||||
+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 mesh generation endpoint.
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct OmniGenMeshGenerateResponse {
|
||||
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>,
|
||||
}
|
||||
+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 splat generation endpoint.
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct OmniGenSplatGenerateResponse {
|
||||
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,5 @@
|
||||
pub mod omni_gen_audio_models;
|
||||
pub mod omni_gen_image_models;
|
||||
pub mod omni_gen_mesh_models;
|
||||
pub mod omni_gen_splat_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,
|
||||
}
|
||||
}
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
use enums::common::generation::common_mesh_model::CommonMeshModel;
|
||||
use enums::common::generation::common_mesh_output_type::CommonMeshOutputType;
|
||||
use enums::common::generation::common_polygon_type::CommonPolygonType;
|
||||
use enums::common::generation::model_creator::ModelCreator;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use serde_derive::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// Mesh model to default to if none is specified
|
||||
const DEFAULT_MESH_MODEL : CommonMeshModel = CommonMeshModel::Hunyuan3d3;
|
||||
|
||||
/// Response body for the mesh models endpoint.
|
||||
#[derive(Serialize, ToSchema, Clone)]
|
||||
pub struct OmniGenMeshModelsResponse {
|
||||
pub success: bool,
|
||||
|
||||
/// A list of all models: details, features, and capabilities
|
||||
pub models: Vec<OmniGenMeshModelDetails>,
|
||||
|
||||
/// Provider-by-provider model offering and capability list,
|
||||
/// with possible capability overrides (future)
|
||||
pub providers: Vec<OmniGenMeshModelProviderDetails>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema, Clone)]
|
||||
pub struct OmniGenMeshModelProviderDetails {
|
||||
pub provider: GenerationProvider,
|
||||
pub models: Vec<OmniGenMeshProviderModelDetails>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema, Clone)]
|
||||
pub struct OmniGenMeshProviderModelDetails {
|
||||
pub model: CommonMeshModel,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub overrides: Option<OmniGenMeshModelDetails>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema, Clone)]
|
||||
pub struct OmniGenMeshModelDetails {
|
||||
|
||||
pub model: CommonMeshModel,
|
||||
|
||||
#[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>,
|
||||
|
||||
/// Whether text-to-3D prompting is supported.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text_prompt_supported: Option<bool>,
|
||||
|
||||
/// Whether image-to-3D input is supported.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub image_input_supported: Option<bool>,
|
||||
|
||||
/// Whether sketch-to-3D input is supported.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sketch_input_supported: Option<bool>,
|
||||
|
||||
/// Whether multi-view (front/back/left/right) image input is supported.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub multi_view_supported: Option<bool>,
|
||||
|
||||
/// Whether the model takes an existing mesh file as input
|
||||
/// (mesh-to-mesh models like part splitting and retopology).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mesh_input_supported: Option<bool>,
|
||||
|
||||
/// The mesh output types the model supports.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mesh_output_types: Option<Vec<CommonMeshOutputType>>,
|
||||
|
||||
/// The polygon types the model supports.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub polygon_types: Option<Vec<CommonPolygonType>>,
|
||||
|
||||
/// Whether a target face count can be specified.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub face_count_supported: Option<bool>,
|
||||
|
||||
/// Whether PBR (physically based rendering) material generation is supported.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pbr_supported: Option<bool>,
|
||||
|
||||
/// Whether texture generation can be toggled off (untextured output).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub texture_toggle_supported: Option<bool>,
|
||||
|
||||
/// Whether a texture quality level (standard/detailed) can be selected.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub texture_quality_supported: Option<bool>,
|
||||
|
||||
/// Whether a geometry quality level (standard/detailed) can be selected.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub geometry_quality_supported: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub is_disabled: Option<bool>,
|
||||
}
|
||||
|
||||
impl Default for OmniGenMeshModelDetails {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
model: DEFAULT_MESH_MODEL,
|
||||
model_creator: None,
|
||||
full_name: None,
|
||||
extra_info: None,
|
||||
extra_info_short: None,
|
||||
text_prompt_supported: None,
|
||||
image_input_supported: None,
|
||||
sketch_input_supported: None,
|
||||
multi_view_supported: None,
|
||||
mesh_input_supported: None,
|
||||
mesh_output_types: None,
|
||||
polygon_types: None,
|
||||
face_count_supported: None,
|
||||
pbr_supported: None,
|
||||
texture_toggle_supported: None,
|
||||
texture_quality_supported: None,
|
||||
geometry_quality_supported: None,
|
||||
is_disabled: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
use enums::common::generation::common_splat_model::CommonSplatModel;
|
||||
use enums::common::generation::model_creator::ModelCreator;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use serde_derive::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// Splat model to default to if none is specified
|
||||
const DEFAULT_SPLAT_MODEL : CommonSplatModel = CommonSplatModel::Marble1p1;
|
||||
|
||||
/// Response body for the splat models endpoint.
|
||||
#[derive(Serialize, ToSchema, Clone)]
|
||||
pub struct OmniGenSplatModelsResponse {
|
||||
pub success: bool,
|
||||
|
||||
/// A list of all models: details, features, and capabilities
|
||||
pub models: Vec<OmniGenSplatModelDetails>,
|
||||
|
||||
/// Provider-by-provider model offering and capability list,
|
||||
/// with possible capability overrides (future)
|
||||
pub providers: Vec<OmniGenSplatModelProviderDetails>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema, Clone)]
|
||||
pub struct OmniGenSplatModelProviderDetails {
|
||||
pub provider: GenerationProvider,
|
||||
pub models: Vec<OmniGenSplatProviderModelDetails>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema, Clone)]
|
||||
pub struct OmniGenSplatProviderModelDetails {
|
||||
pub model: CommonSplatModel,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub overrides: Option<OmniGenSplatModelDetails>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema, Clone)]
|
||||
pub struct OmniGenSplatModelDetails {
|
||||
|
||||
pub model: CommonSplatModel,
|
||||
|
||||
#[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>,
|
||||
|
||||
#[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 a reference video input is supported.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub video_reference_supported: Option<bool>,
|
||||
|
||||
/// Whether a 360-degree panorama reference image is supported.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub panorama_supported: Option<bool>,
|
||||
|
||||
/// Whether the "disable recaption" toggle is supported.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disable_recaption_supported: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub is_disabled: Option<bool>,
|
||||
}
|
||||
|
||||
impl Default for OmniGenSplatModelDetails {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
model: DEFAULT_SPLAT_MODEL,
|
||||
model_creator: None,
|
||||
full_name: None,
|
||||
extra_info: None,
|
||||
extra_info_short: None,
|
||||
text_prompt_supported: None,
|
||||
image_references_supported: None,
|
||||
image_references_max: None,
|
||||
video_reference_supported: None,
|
||||
panorama_supported: None,
|
||||
disable_recaption_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
@@ -0,0 +1 @@
|
||||
pub mod omni_gen_mesh;
|
||||
+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_mesh_cost_and_generate_request::OmniGenMeshCostAndGenerateRequest;
|
||||
use artcraft_api_defs::omni_gen::generate_response::omni_gen_mesh_generate_response::OmniGenMeshGenerateResponse;
|
||||
|
||||
pub const OMNI_GEN_MESH_GENERATE_PATH: &str = "/v1/omni_gen/generate/mesh";
|
||||
|
||||
pub async fn omni_gen_mesh_generate(
|
||||
api_host: &ApiHost,
|
||||
maybe_creds: Option<&StorytellerCredentialSet>,
|
||||
request: OmniGenMeshCostAndGenerateRequest,
|
||||
) -> Result<OmniGenMeshGenerateResponse, StorytellerError> {
|
||||
Ok(basic_json_post_request(
|
||||
api_host,
|
||||
OMNI_GEN_MESH_GENERATE_PATH,
|
||||
maybe_creds,
|
||||
request,
|
||||
).await?)
|
||||
}
|
||||
@@ -1,2 +1,5 @@
|
||||
pub mod audio;
|
||||
pub mod image;
|
||||
pub mod mesh;
|
||||
pub mod splat;
|
||||
pub mod video;
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
pub mod omni_gen_splat;
|
||||
+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_splat_cost_and_generate_request::OmniGenSplatCostAndGenerateRequest;
|
||||
use artcraft_api_defs::omni_gen::generate_response::omni_gen_splat_generate_response::OmniGenSplatGenerateResponse;
|
||||
|
||||
pub const OMNI_GEN_SPLAT_GENERATE_PATH: &str = "/v1/omni_gen/generate/splat";
|
||||
|
||||
pub async fn omni_gen_splat_generate(
|
||||
api_host: &ApiHost,
|
||||
maybe_creds: Option<&StorytellerCredentialSet>,
|
||||
request: OmniGenSplatCostAndGenerateRequest,
|
||||
) -> Result<OmniGenSplatGenerateResponse, StorytellerError> {
|
||||
Ok(basic_json_post_request(
|
||||
api_host,
|
||||
OMNI_GEN_SPLAT_GENERATE_PATH,
|
||||
maybe_creds,
|
||||
request,
|
||||
).await?)
|
||||
}
|
||||
@@ -39,6 +39,7 @@ uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
test_data.workspace = true
|
||||
test_utils.workspace = true
|
||||
strum.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
/// Reference to an input 3D mesh file (e.g. GLB/OBJ/FBX) for mesh-to-mesh
|
||||
/// models like part splitting and retopology.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum MeshRef {
|
||||
MediaFileToken(MediaFileToken),
|
||||
Url(String),
|
||||
}
|
||||
@@ -2,12 +2,16 @@ pub mod audio_list_ref;
|
||||
pub mod character_list_ref;
|
||||
pub mod image_list_ref;
|
||||
pub mod image_ref;
|
||||
pub mod mesh_ref;
|
||||
pub mod router_aspect_ratio;
|
||||
pub mod router_audio_model;
|
||||
pub mod router_bitrate;
|
||||
pub mod router_image_model;
|
||||
pub mod router_mesh_model;
|
||||
pub mod router_provider;
|
||||
pub mod router_quality;
|
||||
pub mod router_resolution;
|
||||
pub mod router_splat_model;
|
||||
pub mod router_video_model;
|
||||
pub mod video_list_ref;
|
||||
pub mod video_ref;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
/// Common mesh (3D object) 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 RouterMeshModel {
|
||||
#[serde(rename = "hunyuan_3d_2p0")]
|
||||
Hunyuan3d2p0,
|
||||
|
||||
#[serde(rename = "hunyuan_3d_2p1")]
|
||||
Hunyuan3d2p1,
|
||||
|
||||
#[serde(rename = "hunyuan_3d_3")]
|
||||
Hunyuan3d3,
|
||||
|
||||
/// Hunyuan 3D v3 in sketch-to-3D mode. Same underlying model as
|
||||
/// [`Self::Hunyuan3d3`], but takes a sketch image plus a prompt as input.
|
||||
#[serde(rename = "hunyuan_3d_3_sketch")]
|
||||
Hunyuan3d3Sketch,
|
||||
|
||||
/// Hunyuan 3D v3.1 Pro: text or (multi-view) image input.
|
||||
#[serde(rename = "hunyuan_3d_3p1_pro")]
|
||||
Hunyuan3d3p1Pro,
|
||||
|
||||
/// Hunyuan 3D v3.1 Rapid: the fast, low-cost tier. Text or single-image
|
||||
/// input.
|
||||
#[serde(rename = "hunyuan_3d_3p1_rapid")]
|
||||
Hunyuan3d3p1Rapid,
|
||||
|
||||
/// Hunyuan 3D v3.1 Part: splits an existing mesh into semantic parts.
|
||||
/// Takes a mesh file as input, not text/images.
|
||||
#[serde(rename = "hunyuan_3d_3p1_part")]
|
||||
Hunyuan3d3p1Part,
|
||||
|
||||
/// Hunyuan 3D v3.1 Smart Topology: retopologizes an existing mesh.
|
||||
/// Takes a mesh file as input, not text/images.
|
||||
#[serde(rename = "hunyuan_3d_3p1_topology")]
|
||||
Hunyuan3d3p1SmartTopology,
|
||||
|
||||
/// Tripo3D H3.1: text, single-image, or multi-view image input.
|
||||
#[serde(rename = "tripo3d_h3p1")]
|
||||
Tripo3dH3p1,
|
||||
|
||||
/// Meshy 6: text or single-image input.
|
||||
#[serde(rename = "meshy_v6")]
|
||||
MeshyV6,
|
||||
|
||||
/// Hyper3D Rodin v2.5 Fast: text or image(s) input.
|
||||
#[serde(rename = "rodin_2p5_fast")]
|
||||
Rodin2p5Fast,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// NB: These strings must match `CommonMeshModel` — the two enums convert
|
||||
// via serde string round-trip.
|
||||
#[test]
|
||||
fn all_variants_serialize_to_common_mesh_model_strings() {
|
||||
assert_serde_round_trip(RouterMeshModel::Hunyuan3d2p0, "hunyuan_3d_2p0");
|
||||
assert_serde_round_trip(RouterMeshModel::Hunyuan3d2p1, "hunyuan_3d_2p1");
|
||||
assert_serde_round_trip(RouterMeshModel::Hunyuan3d3, "hunyuan_3d_3");
|
||||
assert_serde_round_trip(RouterMeshModel::Hunyuan3d3Sketch, "hunyuan_3d_3_sketch");
|
||||
assert_serde_round_trip(RouterMeshModel::Hunyuan3d3p1Pro, "hunyuan_3d_3p1_pro");
|
||||
assert_serde_round_trip(RouterMeshModel::Hunyuan3d3p1Rapid, "hunyuan_3d_3p1_rapid");
|
||||
assert_serde_round_trip(RouterMeshModel::Hunyuan3d3p1Part, "hunyuan_3d_3p1_part");
|
||||
assert_serde_round_trip(RouterMeshModel::Hunyuan3d3p1SmartTopology, "hunyuan_3d_3p1_topology");
|
||||
assert_serde_round_trip(RouterMeshModel::Tripo3dH3p1, "tripo3d_h3p1");
|
||||
assert_serde_round_trip(RouterMeshModel::MeshyV6, "meshy_v6");
|
||||
assert_serde_round_trip(RouterMeshModel::Rodin2p5Fast, "rodin_2p5_fast");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_through_common_mesh_model() {
|
||||
use enums::common::generation::common_mesh_model::CommonMeshModel;
|
||||
|
||||
let cases = [
|
||||
(RouterMeshModel::Hunyuan3d2p0, CommonMeshModel::Hunyuan3d2p0),
|
||||
(RouterMeshModel::Hunyuan3d2p1, CommonMeshModel::Hunyuan3d2p1),
|
||||
(RouterMeshModel::Hunyuan3d3, CommonMeshModel::Hunyuan3d3),
|
||||
(RouterMeshModel::Hunyuan3d3Sketch, CommonMeshModel::Hunyuan3d3Sketch),
|
||||
(RouterMeshModel::Hunyuan3d3p1Pro, CommonMeshModel::Hunyuan3d3p1Pro),
|
||||
(RouterMeshModel::Hunyuan3d3p1Rapid, CommonMeshModel::Hunyuan3d3p1Rapid),
|
||||
(RouterMeshModel::Hunyuan3d3p1Part, CommonMeshModel::Hunyuan3d3p1Part),
|
||||
(RouterMeshModel::Hunyuan3d3p1SmartTopology, CommonMeshModel::Hunyuan3d3p1SmartTopology),
|
||||
(RouterMeshModel::Tripo3dH3p1, CommonMeshModel::Tripo3dH3p1),
|
||||
(RouterMeshModel::MeshyV6, CommonMeshModel::MeshyV6),
|
||||
(RouterMeshModel::Rodin2p5Fast, CommonMeshModel::Rodin2p5Fast),
|
||||
];
|
||||
for (router_model, expected_common) in cases {
|
||||
let json = serde_json::to_string(&router_model).unwrap();
|
||||
let common: CommonMeshModel = serde_json::from_str(&json)
|
||||
.unwrap_or_else(|e| panic!("CommonMeshModel failed to parse {json}: {e}"));
|
||||
assert_eq!(common, expected_common, "for {router_model:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_serde_round_trip(model: RouterMeshModel, expected: &str) {
|
||||
let json = serde_json::to_string(&model).unwrap();
|
||||
assert_eq!(json, format!("\"{expected}\""));
|
||||
let parsed: RouterMeshModel = serde_json::from_str(&json).unwrap();
|
||||
// RouterMeshModel isn't PartialEq, so round-trip back to the wire form.
|
||||
assert_eq!(serde_json::to_string(&parsed).unwrap(), json);
|
||||
}
|
||||
}
|
||||
@@ -9,4 +9,5 @@ pub enum RouterProvider {
|
||||
GmiCloud,
|
||||
GrokApi,
|
||||
Seedance2Pro,
|
||||
WorldLabs,
|
||||
}
|
||||
|
||||
@@ -5,9 +5,68 @@ use serde_derive::{Deserialize, Serialize};
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RouterSplatModel {
|
||||
/// Deprecated upstream; treated as [`Self::Marble1p0Draft`].
|
||||
#[serde(rename = "marble_0p1_mini")]
|
||||
Marble0p1Mini,
|
||||
|
||||
/// Deprecated upstream; treated as [`Self::Marble1p0`].
|
||||
#[serde(rename = "marble_0p1_plus")]
|
||||
Marble0p1Plus,
|
||||
|
||||
#[serde(rename = "marble_1p0")]
|
||||
Marble1p0,
|
||||
|
||||
#[serde(rename = "marble_1p0_draft")]
|
||||
Marble1p0Draft,
|
||||
|
||||
#[serde(rename = "marble_1p1")]
|
||||
Marble1p1,
|
||||
|
||||
#[serde(rename = "marble_1p1_plus")]
|
||||
Marble1p1Plus,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// NB: These strings must match `CommonSplatModel` — the two enums convert
|
||||
// via serde string round-trip.
|
||||
#[test]
|
||||
fn all_variants_serialize_to_common_splat_model_strings() {
|
||||
assert_serde_round_trip(RouterSplatModel::Marble0p1Mini, "marble_0p1_mini");
|
||||
assert_serde_round_trip(RouterSplatModel::Marble0p1Plus, "marble_0p1_plus");
|
||||
assert_serde_round_trip(RouterSplatModel::Marble1p0, "marble_1p0");
|
||||
assert_serde_round_trip(RouterSplatModel::Marble1p0Draft, "marble_1p0_draft");
|
||||
assert_serde_round_trip(RouterSplatModel::Marble1p1, "marble_1p1");
|
||||
assert_serde_round_trip(RouterSplatModel::Marble1p1Plus, "marble_1p1_plus");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_through_common_splat_model() {
|
||||
use enums::common::generation::common_splat_model::CommonSplatModel;
|
||||
|
||||
let cases = [
|
||||
(RouterSplatModel::Marble0p1Mini, CommonSplatModel::Marble0p1Mini),
|
||||
(RouterSplatModel::Marble0p1Plus, CommonSplatModel::Marble0p1Plus),
|
||||
(RouterSplatModel::Marble1p0, CommonSplatModel::Marble1p0),
|
||||
(RouterSplatModel::Marble1p0Draft, CommonSplatModel::Marble1p0Draft),
|
||||
(RouterSplatModel::Marble1p1, CommonSplatModel::Marble1p1),
|
||||
(RouterSplatModel::Marble1p1Plus, CommonSplatModel::Marble1p1Plus),
|
||||
];
|
||||
for (router_model, expected_common) in cases {
|
||||
let json = serde_json::to_string(&router_model).unwrap();
|
||||
let common: CommonSplatModel = serde_json::from_str(&json)
|
||||
.unwrap_or_else(|e| panic!("CommonSplatModel failed to parse {json}: {e}"));
|
||||
assert_eq!(common, expected_common, "for {router_model:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_serde_round_trip(model: RouterSplatModel, expected: &str) {
|
||||
let json = serde_json::to_string(&model).unwrap();
|
||||
assert_eq!(json, format!("\"{expected}\""));
|
||||
let parsed: RouterSplatModel = serde_json::from_str(&json).unwrap();
|
||||
// RouterSplatModel isn't PartialEq, so round-trip back to the wire form.
|
||||
assert_eq!(serde_json::to_string(&parsed).unwrap(), json);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,15 @@ pub enum RouterVideoModel {
|
||||
#[serde(rename = "veo_3p1_fast")]
|
||||
Veo3p1Fast,
|
||||
|
||||
#[serde(rename = "veo_3p1_lite")]
|
||||
Veo3p1Lite,
|
||||
|
||||
#[serde(rename = "vidu_q3")]
|
||||
ViduQ3,
|
||||
|
||||
#[serde(rename = "vidu_q3_turbo")]
|
||||
ViduQ3Turbo,
|
||||
|
||||
#[serde(rename = "preview_model")]
|
||||
PreviewModel,
|
||||
|
||||
@@ -123,4 +132,13 @@ mod tests {
|
||||
assert_serde_round_trip(RouterVideoModel::Seedance2p0BytePlusMini, "seedance_2p0_bp_mini");
|
||||
assert_serde_round_trip(RouterVideoModel::Seedance2p0BytePlusUltraMini, "seedance_2p0_bpu_mini");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn veo_3p1_lite_and_vidu_variants_serialize() {
|
||||
// NB: These strings must match `CommonVideoModel` — the two enums convert
|
||||
// via serde string round-trip in the omni handlers.
|
||||
assert_serde_round_trip(RouterVideoModel::Veo3p1Lite, "veo_3p1_lite");
|
||||
assert_serde_round_trip(RouterVideoModel::ViduQ3, "vidu_q3");
|
||||
assert_serde_round_trip(RouterVideoModel::ViduQ3Turbo, "vidu_q3_turbo");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum VideoRef {
|
||||
MediaFileToken(MediaFileToken),
|
||||
Url(String),
|
||||
}
|
||||
@@ -8,3 +8,4 @@ pub mod router_fal_client;
|
||||
pub mod router_gmicloud_client;
|
||||
pub mod router_grok_api_client;
|
||||
pub mod router_seedance2pro_client;
|
||||
pub mod router_worldlabs_client;
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::client::router_fal_client::RouterFalClient;
|
||||
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
|
||||
use crate::client::router_grok_api_client::RouterGrokApiClient;
|
||||
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
|
||||
use crate::client::router_worldlabs_client::RouterWorldLabsClient;
|
||||
use crate::errors::client_error::{ClientError, ClientType};
|
||||
|
||||
pub struct MultiRouterClient {
|
||||
@@ -11,6 +12,7 @@ pub struct MultiRouterClient {
|
||||
pub(crate) gmicloud_client: Option<RouterGmiCloudClient>,
|
||||
pub(crate) grok_api_client: Option<RouterGrokApiClient>,
|
||||
pub(crate) seedance2pro_client: Option<RouterSeedance2ProClient>,
|
||||
pub(crate) worldlabs_client: Option<RouterWorldLabsClient>,
|
||||
}
|
||||
|
||||
impl MultiRouterClient {
|
||||
@@ -38,4 +40,9 @@ impl MultiRouterClient {
|
||||
self.seedance2pro_client.as_ref()
|
||||
.ok_or(ClientError::ClientNotConfigured(ClientType::Seedance2Pro))
|
||||
}
|
||||
|
||||
pub fn get_worldlabs_client_ref(&self) -> Result<&RouterWorldLabsClient, ClientError> {
|
||||
self.worldlabs_client.as_ref()
|
||||
.ok_or(ClientError::ClientNotConfigured(ClientType::WorldLabs))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::client::router_fal_client::RouterFalClient;
|
||||
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
|
||||
use crate::client::router_grok_api_client::RouterGrokApiClient;
|
||||
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
|
||||
use crate::client::router_worldlabs_client::RouterWorldLabsClient;
|
||||
|
||||
pub struct MultiRouterClientBuilder {
|
||||
artcraft_client: Option<RouterArtcraftClient>,
|
||||
@@ -11,6 +12,7 @@ pub struct MultiRouterClientBuilder {
|
||||
gmicloud_client: Option<RouterGmiCloudClient>,
|
||||
grok_api_client: Option<RouterGrokApiClient>,
|
||||
seedance2pro_client: Option<RouterSeedance2ProClient>,
|
||||
worldlabs_client: Option<RouterWorldLabsClient>,
|
||||
}
|
||||
|
||||
impl MultiRouterClientBuilder {
|
||||
@@ -21,6 +23,7 @@ impl MultiRouterClientBuilder {
|
||||
gmicloud_client: None,
|
||||
grok_api_client: None,
|
||||
seedance2pro_client: None,
|
||||
worldlabs_client: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +52,11 @@ impl MultiRouterClientBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_worldlabs_client(mut self, client: RouterWorldLabsClient) -> Self {
|
||||
self.worldlabs_client = Some(client);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> MultiRouterClient {
|
||||
MultiRouterClient {
|
||||
artcraft_client: self.artcraft_client,
|
||||
@@ -56,6 +64,7 @@ impl MultiRouterClientBuilder {
|
||||
gmicloud_client: self.gmicloud_client,
|
||||
grok_api_client: self.grok_api_client,
|
||||
seedance2pro_client: self.seedance2pro_client,
|
||||
worldlabs_client: self.worldlabs_client,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::client::router_fal_client::RouterFalClient;
|
||||
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
|
||||
use crate::client::router_grok_api_client::RouterGrokApiClient;
|
||||
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
|
||||
use crate::client::router_worldlabs_client::RouterWorldLabsClient;
|
||||
use crate::errors::client_error::{ClientError, ClientType};
|
||||
|
||||
pub enum RouterClient {
|
||||
@@ -13,6 +14,7 @@ pub enum RouterClient {
|
||||
GmiCloud(RouterGmiCloudClient),
|
||||
GrokApi(RouterGrokApiClient),
|
||||
Seedance2Pro(RouterSeedance2ProClient),
|
||||
WorldLabs(RouterWorldLabsClient),
|
||||
}
|
||||
|
||||
impl RouterClient {
|
||||
@@ -55,4 +57,12 @@ impl RouterClient {
|
||||
_ => Err(ClientError::ClientNotConfigured(ClientType::Seedance2Pro)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_worldlabs_client_ref(&self) -> Result<&RouterWorldLabsClient, ClientError> {
|
||||
match self {
|
||||
RouterClient::WorldLabs(client) => Ok(client),
|
||||
RouterClient::Multi(multi) => multi.get_worldlabs_client_ref(),
|
||||
_ => Err(ClientError::ClientNotConfigured(ClientType::WorldLabs)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
use worldlabs_api_client::credentials::world_labs_api_creds::WorldLabsApiCreds;
|
||||
|
||||
/// A client for the official World Labs (Marble) API.
|
||||
pub struct RouterWorldLabsClient {
|
||||
pub(crate) creds: WorldLabsApiCreds,
|
||||
}
|
||||
|
||||
impl RouterWorldLabsClient {
|
||||
pub fn new(creds: WorldLabsApiCreds) -> Self {
|
||||
Self { creds }
|
||||
}
|
||||
|
||||
pub fn new_from_raw_key(api_key: &str) -> Self {
|
||||
Self::new(WorldLabsApiCreds::new(api_key.to_string()))
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ pub enum ClientType {
|
||||
GmiCloud,
|
||||
GrokApi,
|
||||
Seedance2Pro,
|
||||
WorldLabs,
|
||||
}
|
||||
|
||||
impl Display for ClientType {
|
||||
@@ -20,6 +21,7 @@ impl Display for ClientType {
|
||||
Self::GmiCloud => write!(f, "GmiCloud"),
|
||||
Self::GrokApi => write!(f, "GrokApi"),
|
||||
Self::Seedance2Pro => write!(f, "Seedance2Pro"),
|
||||
Self::WorldLabs => write!(f, "WorldLabs"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use fal_client::error::fal_error_plus::FalErrorPlus;
|
||||
use gmicloud_client::error::gmicloud_error::GmiCloudError;
|
||||
use grok_api_client::error::grok_error::GrokError;
|
||||
use seedance2pro_client::error::seedance2pro_error::Seedance2ProError;
|
||||
use worldlabs_api_client::error::world_labs_error::WorldLabsError;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ProviderError {
|
||||
@@ -13,6 +14,7 @@ pub enum ProviderError {
|
||||
GmiCloud(GmiCloudError),
|
||||
Grok(GrokError),
|
||||
Seedance2Pro(Seedance2ProError),
|
||||
WorldLabs(WorldLabsError),
|
||||
}
|
||||
|
||||
impl Error for ProviderError {}
|
||||
@@ -25,6 +27,7 @@ impl Display for ProviderError {
|
||||
Self::GmiCloud(e) => write!(f, "GmiCloud provider error: {}", e),
|
||||
Self::Grok(e) => write!(f, "Grok provider error: {}", e),
|
||||
Self::Seedance2Pro(e) => write!(f, "Seedance2Pro provider error: {}", e),
|
||||
Self::WorldLabs(e) => write!(f, "WorldLabs provider error: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,3 +61,9 @@ impl From<Seedance2ProError> for ProviderError {
|
||||
Self::Seedance2Pro(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<WorldLabsError> for ProviderError {
|
||||
fn from(error: WorldLabsError) -> Self {
|
||||
Self::WorldLabs(error)
|
||||
}
|
||||
}
|
||||
|
||||
+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;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user