mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-25 03:09:30 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65a1054579 |
@@ -0,0 +1,263 @@
|
||||
use serde_derive::Serialize;
|
||||
|
||||
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
|
||||
use crate::creds::seedance2pro_session::Seedance2ProSession;
|
||||
use crate::error::seedance2pro_error::Seedance2ProError;
|
||||
use crate::requests::kinovi_host::KinoviHost;
|
||||
use crate::requests::workflow_run_task::workflow_run_task::{
|
||||
workflow_run_task_custom, WorkflowRunTaskCustomArgs, WorkflowRunTaskResponse,
|
||||
};
|
||||
|
||||
const BUSINESS_TYPE: &str = "suno-music-generation";
|
||||
const MODEL: &str = "suno-music";
|
||||
const MODEL_VERSION: &str = "chirp-v5-5";
|
||||
|
||||
/// Flat credit price per generation.
|
||||
const CREDITS_PER_GENERATION: u64 = 16;
|
||||
|
||||
// ── Args ──
|
||||
|
||||
pub struct GenerateSunoMusicArgs<'a> {
|
||||
pub request: GenerateSunoMusicRequest,
|
||||
pub session: &'a Seedance2ProSession,
|
||||
pub host_override: Option<KinoviHost>,
|
||||
}
|
||||
|
||||
// ── Request ──
|
||||
|
||||
/// Suno Music: text-to-music generation.
|
||||
///
|
||||
/// NB: the Kinovi UI offers a voice choice (any / male / female), but it is
|
||||
/// never transmitted on the wire — steer the vocalist through the prompt or
|
||||
/// style tags instead.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GenerateSunoMusicRequest {
|
||||
/// What the song should be about.
|
||||
pub prompt: String,
|
||||
|
||||
/// Optional style/genre direction (the UI's "style prompt"), e.g.
|
||||
/// "EDM style meets dance".
|
||||
pub style_tags: Option<String>,
|
||||
|
||||
/// Generate an instrumental-only track (no vocals).
|
||||
pub instrumental: bool,
|
||||
}
|
||||
|
||||
// ── Pricing ──
|
||||
//
|
||||
// Suno Music: flat 16 Kinovi credits per generation, regardless of options.
|
||||
|
||||
impl GenerateSunoMusicRequest {
|
||||
/// Calculate the cost of this generation request, in Kinovi credits and
|
||||
/// USD cents.
|
||||
pub fn calculate_costs(&self) -> KinoviGenerationCost {
|
||||
KinoviGenerationCost::from_kinovi_credits(CREDITS_PER_GENERATION)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Response ──
|
||||
|
||||
pub struct GenerateSunoMusicResponse {
|
||||
pub task_id: String,
|
||||
pub order_id: String,
|
||||
}
|
||||
|
||||
// ── Entry point ──
|
||||
|
||||
pub async fn generate_suno_music(
|
||||
args: GenerateSunoMusicArgs<'_>,
|
||||
) -> Result<GenerateSunoMusicResponse, Seedance2ProError> {
|
||||
let raw_response: WorkflowRunTaskResponse = workflow_run_task_custom(WorkflowRunTaskCustomArgs {
|
||||
business_type: BUSINESS_TYPE,
|
||||
api_params: build_api_params(&args.request),
|
||||
session: args.session,
|
||||
host_override: args.host_override,
|
||||
}).await?;
|
||||
|
||||
Ok(GenerateSunoMusicResponse {
|
||||
task_id: raw_response.task_id,
|
||||
order_id: raw_response.order_id,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Wire payload ──
|
||||
|
||||
/// The `apiParams` shape for `suno-music-generation`. Field order matches the
|
||||
/// captured browser traffic.
|
||||
#[derive(Serialize, Debug)]
|
||||
struct SunoMusicApiParams {
|
||||
model: &'static str,
|
||||
prompt: String,
|
||||
custom: bool,
|
||||
instrumental: bool,
|
||||
mv: &'static str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tags: Option<String>,
|
||||
#[serde(rename = "generateVideo")]
|
||||
generate_video: bool,
|
||||
#[serde(rename = "isStorage")]
|
||||
is_storage: bool,
|
||||
}
|
||||
|
||||
fn build_api_params(request: &GenerateSunoMusicRequest) -> SunoMusicApiParams {
|
||||
SunoMusicApiParams {
|
||||
model: MODEL,
|
||||
prompt: request.prompt.clone(),
|
||||
custom: false,
|
||||
instrumental: request.instrumental,
|
||||
mv: MODEL_VERSION,
|
||||
tags: request.style_tags.clone(),
|
||||
generate_video: false,
|
||||
is_storage: true,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ──
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
mod pricing_tests {
|
||||
use super::*;
|
||||
|
||||
fn base_request() -> GenerateSunoMusicRequest {
|
||||
GenerateSunoMusicRequest {
|
||||
prompt: "A song".to_string(),
|
||||
style_tags: None,
|
||||
instrumental: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_sixteen_credits() {
|
||||
let costs = base_request().calculate_costs();
|
||||
assert_eq!(costs.kinovi_credits, 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents() {
|
||||
// 16 credits; 1600/243 = 6.5843… → up 7¢, down 6¢.
|
||||
let costs = base_request().calculate_costs();
|
||||
assert_eq!(costs.usd_cents_rounded_up, 7);
|
||||
assert_eq!(costs.usd_cents_rounded_down, 6);
|
||||
assert!((costs.usd_cents_fractional - (1600.0 / 243.0)).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_do_not_affect_cost() {
|
||||
let base = base_request().calculate_costs();
|
||||
let styled = GenerateSunoMusicRequest {
|
||||
prompt: "Another song".to_string(),
|
||||
style_tags: Some("EDM".to_string()),
|
||||
instrumental: true,
|
||||
}.calculate_costs();
|
||||
assert_eq!(base, styled);
|
||||
}
|
||||
}
|
||||
|
||||
mod request_shape_tests {
|
||||
use super::*;
|
||||
|
||||
/// Mirrors capture 1_suno_music.txt (no style tags, with vocals).
|
||||
#[test]
|
||||
fn basic_prompt() {
|
||||
let params = build_api_params(&GenerateSunoMusicRequest {
|
||||
prompt: "A song about corgi super heroes fighting shiba super villains.".to_string(),
|
||||
style_tags: None,
|
||||
instrumental: false,
|
||||
});
|
||||
assert_eq!(
|
||||
serde_json::to_string(¶ms).unwrap(),
|
||||
r#"{"model":"suno-music","prompt":"A song about corgi super heroes fighting shiba super villains.","custom":false,"instrumental":false,"mv":"chirp-v5-5","generateVideo":false,"isStorage":true}"#,
|
||||
);
|
||||
}
|
||||
|
||||
/// Mirrors capture 2_suno_music_style_female.txt (style tags present).
|
||||
#[test]
|
||||
fn with_style_tags() {
|
||||
let params = build_api_params(&GenerateSunoMusicRequest {
|
||||
prompt: "A song about Mario and Luigi fighting Bowser".to_string(),
|
||||
style_tags: Some("EDM style meets dance".to_string()),
|
||||
instrumental: false,
|
||||
});
|
||||
assert_eq!(
|
||||
serde_json::to_string(¶ms).unwrap(),
|
||||
r#"{"model":"suno-music","prompt":"A song about Mario and Luigi fighting Bowser","custom":false,"instrumental":false,"mv":"chirp-v5-5","tags":"EDM style meets dance","generateVideo":false,"isStorage":true}"#,
|
||||
);
|
||||
}
|
||||
|
||||
/// Mirrors capture 3_suno_music_instrumental.txt.
|
||||
#[test]
|
||||
fn instrumental() {
|
||||
let params = build_api_params(&GenerateSunoMusicRequest {
|
||||
prompt: "An epic journey for a JRPG".to_string(),
|
||||
style_tags: Some("Japanese JRPG adventure music".to_string()),
|
||||
instrumental: true,
|
||||
});
|
||||
let json = serde_json::to_string(¶ms).unwrap();
|
||||
assert!(json.contains(r#""instrumental":true"#), "{json}");
|
||||
assert!(json.contains(r#""custom":false"#), "{json}");
|
||||
assert!(json.contains(r#""mv":"chirp-v5-5""#), "{json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn business_type() {
|
||||
assert_eq!(BUSINESS_TYPE, "suno-music-generation");
|
||||
}
|
||||
}
|
||||
|
||||
mod live_tests {
|
||||
use super::*;
|
||||
use crate::test_utils::get_test_cookies::get_test_cookies;
|
||||
use crate::test_utils::setup_test_logging::setup_test_logging;
|
||||
use errors::AnyhowResult;
|
||||
use log::LevelFilter;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // Sends a real generation to Kinovi; costs credits.
|
||||
async fn test_music_with_vocals() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let result = generate_suno_music(GenerateSunoMusicArgs {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSunoMusicRequest {
|
||||
prompt: "A song about a corgi who learns to sail the open sea".to_string(),
|
||||
style_tags: Some("Sea shanty, folk".to_string()),
|
||||
instrumental: false,
|
||||
},
|
||||
}).await?;
|
||||
println!("suno music — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert!(!result.order_id.is_empty());
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // Sends a real generation to Kinovi; costs credits.
|
||||
async fn test_music_instrumental() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let result = generate_suno_music(GenerateSunoMusicArgs {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSunoMusicRequest {
|
||||
prompt: "An epic journey across a frozen mountain range".to_string(),
|
||||
style_tags: Some("Cinematic orchestral score".to_string()),
|
||||
instrumental: true,
|
||||
},
|
||||
}).await?;
|
||||
println!("suno music instrumental — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn test_session() -> AnyhowResult<Seedance2ProSession> {
|
||||
let cookies = get_test_cookies()?;
|
||||
Ok(Seedance2ProSession::from_cookies_string(cookies))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
use serde_derive::Serialize;
|
||||
|
||||
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
|
||||
use crate::creds::seedance2pro_session::Seedance2ProSession;
|
||||
use crate::error::seedance2pro_error::Seedance2ProError;
|
||||
use crate::requests::kinovi_host::KinoviHost;
|
||||
use crate::requests::workflow_run_task::workflow_run_task::{
|
||||
workflow_run_task_custom, WorkflowRunTaskCustomArgs, WorkflowRunTaskResponse,
|
||||
};
|
||||
|
||||
const BUSINESS_TYPE: &str = "suno-remix-generation";
|
||||
const MODEL: &str = "suno-remix";
|
||||
const MODEL_VERSION: &str = "chirp-v5-5";
|
||||
|
||||
/// Flat credit price per generation.
|
||||
const CREDITS_PER_GENERATION: u64 = 16;
|
||||
|
||||
// ── Args ──
|
||||
|
||||
pub struct GenerateSunoRemixArgs<'a> {
|
||||
pub request: GenerateSunoRemixRequest,
|
||||
pub session: &'a Seedance2ProSession,
|
||||
pub host_override: Option<KinoviHost>,
|
||||
}
|
||||
|
||||
// ── Request ──
|
||||
|
||||
/// Suno Remix: transform an existing piece of audio.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GenerateSunoRemixRequest {
|
||||
/// How to transform the audio, e.g. "Make this electronic".
|
||||
pub prompt: String,
|
||||
|
||||
/// The audio to remix.
|
||||
pub source: KinoviSunoRemixSource,
|
||||
|
||||
/// Optional style/genre direction (the UI's "style prompt").
|
||||
pub style_tags: Option<String>,
|
||||
|
||||
/// Keep the original lyrics in the remix.
|
||||
pub keep_lyrics: bool,
|
||||
}
|
||||
|
||||
// ── Enums ──
|
||||
|
||||
/// The audio input for a remix: either an uploaded file (by CDN URL) or a
|
||||
/// previously generated Suno track (by its music id).
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum KinoviSunoRemixSource {
|
||||
/// A Kinovi CDN URL to an uploaded audio file.
|
||||
AudioUrl(String),
|
||||
/// The id of a previously generated Suno track
|
||||
/// (e.g. "fea7e70c-5373-452d-9c5e-f079ff837fcd").
|
||||
MusicId(String),
|
||||
}
|
||||
|
||||
// ── Pricing ──
|
||||
//
|
||||
// Suno Remix: flat 16 Kinovi credits per generation, regardless of options.
|
||||
|
||||
impl GenerateSunoRemixRequest {
|
||||
/// Calculate the cost of this generation request, in Kinovi credits and
|
||||
/// USD cents.
|
||||
pub fn calculate_costs(&self) -> KinoviGenerationCost {
|
||||
KinoviGenerationCost::from_kinovi_credits(CREDITS_PER_GENERATION)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Response ──
|
||||
|
||||
pub struct GenerateSunoRemixResponse {
|
||||
pub task_id: String,
|
||||
pub order_id: String,
|
||||
}
|
||||
|
||||
// ── Entry point ──
|
||||
|
||||
pub async fn generate_suno_remix(
|
||||
args: GenerateSunoRemixArgs<'_>,
|
||||
) -> Result<GenerateSunoRemixResponse, Seedance2ProError> {
|
||||
let raw_response: WorkflowRunTaskResponse = workflow_run_task_custom(WorkflowRunTaskCustomArgs {
|
||||
business_type: BUSINESS_TYPE,
|
||||
api_params: build_api_params(&args.request),
|
||||
session: args.session,
|
||||
host_override: args.host_override,
|
||||
}).await?;
|
||||
|
||||
Ok(GenerateSunoRemixResponse {
|
||||
task_id: raw_response.task_id,
|
||||
order_id: raw_response.order_id,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Wire payload ──
|
||||
|
||||
/// The `apiParams` shape for `suno-remix-generation`. Field order matches the
|
||||
/// captured browser traffic. Exactly one of `audio_url` / `music_id` is set.
|
||||
#[derive(Serialize, Debug)]
|
||||
struct SunoRemixApiParams {
|
||||
model: &'static str,
|
||||
prompt: String,
|
||||
#[serde(rename = "audioUrl", skip_serializing_if = "Option::is_none")]
|
||||
audio_url: Option<String>,
|
||||
#[serde(rename = "musicId", skip_serializing_if = "Option::is_none")]
|
||||
music_id: Option<String>,
|
||||
custom: bool,
|
||||
#[serde(rename = "keepLyrics")]
|
||||
keep_lyrics: bool,
|
||||
mv: &'static str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tags: Option<String>,
|
||||
#[serde(rename = "isStorage")]
|
||||
is_storage: bool,
|
||||
}
|
||||
|
||||
fn build_api_params(request: &GenerateSunoRemixRequest) -> SunoRemixApiParams {
|
||||
let (audio_url, music_id) = match &request.source {
|
||||
KinoviSunoRemixSource::AudioUrl(url) => (Some(url.clone()), None),
|
||||
KinoviSunoRemixSource::MusicId(id) => (None, Some(id.clone())),
|
||||
};
|
||||
|
||||
SunoRemixApiParams {
|
||||
model: MODEL,
|
||||
prompt: request.prompt.clone(),
|
||||
audio_url,
|
||||
music_id,
|
||||
custom: false,
|
||||
keep_lyrics: request.keep_lyrics,
|
||||
mv: MODEL_VERSION,
|
||||
tags: request.style_tags.clone(),
|
||||
is_storage: true,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ──
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
mod pricing_tests {
|
||||
use super::*;
|
||||
|
||||
fn base_request() -> GenerateSunoRemixRequest {
|
||||
GenerateSunoRemixRequest {
|
||||
prompt: "Make this electronic".to_string(),
|
||||
source: KinoviSunoRemixSource::AudioUrl("https://example.com/a.mp3".to_string()),
|
||||
style_tags: None,
|
||||
keep_lyrics: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_sixteen_credits() {
|
||||
let costs = base_request().calculate_costs();
|
||||
assert_eq!(costs.kinovi_credits, 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents() {
|
||||
// 16 credits; 1600/243 = 6.5843… → up 7¢, down 6¢.
|
||||
let costs = base_request().calculate_costs();
|
||||
assert_eq!(costs.usd_cents_rounded_up, 7);
|
||||
assert_eq!(costs.usd_cents_rounded_down, 6);
|
||||
assert!((costs.usd_cents_fractional - (1600.0 / 243.0)).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_do_not_affect_cost() {
|
||||
let base = base_request().calculate_costs();
|
||||
let other = GenerateSunoRemixRequest {
|
||||
prompt: "Make this a symphony".to_string(),
|
||||
source: KinoviSunoRemixSource::MusicId("abc".to_string()),
|
||||
style_tags: Some("Classical music".to_string()),
|
||||
keep_lyrics: true,
|
||||
}.calculate_costs();
|
||||
assert_eq!(base, other);
|
||||
}
|
||||
}
|
||||
|
||||
mod request_shape_tests {
|
||||
use super::*;
|
||||
|
||||
/// Mirrors capture 5_suno_remix.txt (uploaded audio URL, no lyric keep).
|
||||
#[test]
|
||||
fn audio_url_source() {
|
||||
let params = build_api_params(&GenerateSunoRemixRequest {
|
||||
prompt: "Make this electronic".to_string(),
|
||||
source: KinoviSunoRemixSource::AudioUrl(
|
||||
"https://static.seedance2-pro.com/materials/20260707/1783399892833-7d9668fb.mp3".to_string(),
|
||||
),
|
||||
style_tags: Some("EDM style".to_string()),
|
||||
keep_lyrics: false,
|
||||
});
|
||||
assert_eq!(
|
||||
serde_json::to_string(¶ms).unwrap(),
|
||||
r#"{"model":"suno-remix","prompt":"Make this electronic","audioUrl":"https://static.seedance2-pro.com/materials/20260707/1783399892833-7d9668fb.mp3","custom":false,"keepLyrics":false,"mv":"chirp-v5-5","tags":"EDM style","isStorage":true}"#,
|
||||
);
|
||||
}
|
||||
|
||||
/// Mirrors capture 6_suno_remix_lyrics.txt (existing track by music id,
|
||||
/// keeping the lyrics).
|
||||
#[test]
|
||||
fn music_id_source_with_kept_lyrics() {
|
||||
let params = build_api_params(&GenerateSunoRemixRequest {
|
||||
prompt: "Make this a symphony".to_string(),
|
||||
source: KinoviSunoRemixSource::MusicId("fea7e70c-5373-452d-9c5e-f079ff837fcd".to_string()),
|
||||
style_tags: Some("Classical music".to_string()),
|
||||
keep_lyrics: true,
|
||||
});
|
||||
assert_eq!(
|
||||
serde_json::to_string(¶ms).unwrap(),
|
||||
r#"{"model":"suno-remix","prompt":"Make this a symphony","musicId":"fea7e70c-5373-452d-9c5e-f079ff837fcd","custom":false,"keepLyrics":true,"mv":"chirp-v5-5","tags":"Classical music","isStorage":true}"#,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn business_type() {
|
||||
assert_eq!(BUSINESS_TYPE, "suno-remix-generation");
|
||||
}
|
||||
}
|
||||
|
||||
mod live_tests {
|
||||
use super::*;
|
||||
use crate::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs};
|
||||
use crate::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
|
||||
use crate::test_utils::get_test_cookies::get_test_cookies;
|
||||
use crate::test_utils::setup_test_logging::setup_test_logging;
|
||||
use errors::AnyhowResult;
|
||||
use log::LevelFilter;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // Sends a real generation to Kinovi; costs credits. Requires a local audio file.
|
||||
async fn test_remix_uploaded_audio() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let audio_url = upload_test_audio(&session).await?;
|
||||
println!("Uploaded audio: {}", audio_url);
|
||||
|
||||
let result = generate_suno_remix(GenerateSunoRemixArgs {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSunoRemixRequest {
|
||||
prompt: "Make this electronic".to_string(),
|
||||
source: KinoviSunoRemixSource::AudioUrl(audio_url),
|
||||
style_tags: Some("EDM style".to_string()),
|
||||
keep_lyrics: false,
|
||||
},
|
||||
}).await?;
|
||||
println!("suno remix — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert!(!result.order_id.is_empty());
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn test_session() -> AnyhowResult<Seedance2ProSession> {
|
||||
let cookies = get_test_cookies()?;
|
||||
Ok(Seedance2ProSession::from_cookies_string(cookies))
|
||||
}
|
||||
|
||||
async fn upload_test_audio(session: &Seedance2ProSession) -> AnyhowResult<String> {
|
||||
let audio_path = test_utils::test_file_path::test_file_path(
|
||||
"test_data/audio/mp3/super_mario_rpg_beware_the_forests_mushrooms.mp3",
|
||||
)?;
|
||||
let audio_bytes = std::fs::read(&audio_path)?;
|
||||
|
||||
let prepare_result = prepare_file_upload(PrepareFileUploadArgs {
|
||||
session,
|
||||
extension: "mp3".to_string(),
|
||||
host_override: None,
|
||||
}).await?;
|
||||
|
||||
let upload_result = upload_file(UploadFileArgs {
|
||||
upload_url: prepare_result.upload_url,
|
||||
file_bytes: audio_bytes,
|
||||
host_override: None,
|
||||
}).await?;
|
||||
|
||||
Ok(upload_result.public_url)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
use serde_derive::Serialize;
|
||||
|
||||
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
|
||||
use crate::creds::seedance2pro_session::Seedance2ProSession;
|
||||
use crate::error::seedance2pro_error::Seedance2ProError;
|
||||
use crate::requests::kinovi_host::KinoviHost;
|
||||
use crate::requests::workflow_run_task::workflow_run_task::{
|
||||
workflow_run_task_custom, WorkflowRunTaskCustomArgs, WorkflowRunTaskResponse,
|
||||
};
|
||||
|
||||
const BUSINESS_TYPE: &str = "suno-sample-generation";
|
||||
const MODEL: &str = "suno-sample";
|
||||
const MODEL_VERSION: &str = "chirp-v5-5";
|
||||
|
||||
/// Flat credit price per generation.
|
||||
const CREDITS_PER_GENERATION: u64 = 16;
|
||||
|
||||
// ── Args ──
|
||||
|
||||
pub struct GenerateSunoSampleArgs<'a> {
|
||||
pub request: GenerateSunoSampleRequest,
|
||||
pub session: &'a Seedance2ProSession,
|
||||
pub host_override: Option<KinoviHost>,
|
||||
}
|
||||
|
||||
// ── Request ──
|
||||
|
||||
/// Suno Sample: build a track from a chopped sample of an uploaded audio file.
|
||||
///
|
||||
/// NB: the Kinovi UI offers a voice choice (male / female / no voice), but it
|
||||
/// is never transmitted on the wire — `instrumental` controls whether vocals
|
||||
/// are added at all; steer the vocalist through the prompt or style tags.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GenerateSunoSampleRequest {
|
||||
/// What to build from the sample, e.g. "Mystical RPG adventure, make it
|
||||
/// have a grand climax".
|
||||
pub prompt: String,
|
||||
|
||||
/// A Kinovi CDN URL to the uploaded audio file to sample.
|
||||
pub audio_url: String,
|
||||
|
||||
/// Start of the sample window within the audio, in seconds.
|
||||
pub chop_sample_start_seconds: u32,
|
||||
|
||||
/// End of the sample window within the audio, in seconds.
|
||||
pub chop_sample_end_seconds: u32,
|
||||
|
||||
/// Optional style/genre direction (the UI's "style prompt").
|
||||
pub style_tags: Option<String>,
|
||||
|
||||
/// Generate an instrumental-only track (no vocals).
|
||||
pub instrumental: bool,
|
||||
}
|
||||
|
||||
// ── Pricing ──
|
||||
//
|
||||
// Suno Sample: flat 16 Kinovi credits per generation, regardless of options.
|
||||
|
||||
impl GenerateSunoSampleRequest {
|
||||
/// Calculate the cost of this generation request, in Kinovi credits and
|
||||
/// USD cents.
|
||||
pub fn calculate_costs(&self) -> KinoviGenerationCost {
|
||||
KinoviGenerationCost::from_kinovi_credits(CREDITS_PER_GENERATION)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Response ──
|
||||
|
||||
pub struct GenerateSunoSampleResponse {
|
||||
pub task_id: String,
|
||||
pub order_id: String,
|
||||
}
|
||||
|
||||
// ── Entry point ──
|
||||
|
||||
pub async fn generate_suno_sample(
|
||||
args: GenerateSunoSampleArgs<'_>,
|
||||
) -> Result<GenerateSunoSampleResponse, Seedance2ProError> {
|
||||
let raw_response: WorkflowRunTaskResponse = workflow_run_task_custom(WorkflowRunTaskCustomArgs {
|
||||
business_type: BUSINESS_TYPE,
|
||||
api_params: build_api_params(&args.request),
|
||||
session: args.session,
|
||||
host_override: args.host_override,
|
||||
}).await?;
|
||||
|
||||
Ok(GenerateSunoSampleResponse {
|
||||
task_id: raw_response.task_id,
|
||||
order_id: raw_response.order_id,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Wire payload ──
|
||||
|
||||
/// The `apiParams` shape for `suno-sample-generation`. Field order matches the
|
||||
/// captured browser traffic.
|
||||
#[derive(Serialize, Debug)]
|
||||
struct SunoSampleApiParams {
|
||||
model: &'static str,
|
||||
prompt: String,
|
||||
#[serde(rename = "audioUrl")]
|
||||
audio_url: String,
|
||||
#[serde(rename = "chopSampleStartS")]
|
||||
chop_sample_start_s: u32,
|
||||
#[serde(rename = "chopSampleEndS")]
|
||||
chop_sample_end_s: u32,
|
||||
custom: bool,
|
||||
instrumental: bool,
|
||||
#[serde(rename = "autoLyrics")]
|
||||
auto_lyrics: bool,
|
||||
mv: &'static str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tags: Option<String>,
|
||||
#[serde(rename = "isStorage")]
|
||||
is_storage: bool,
|
||||
}
|
||||
|
||||
fn build_api_params(request: &GenerateSunoSampleRequest) -> SunoSampleApiParams {
|
||||
SunoSampleApiParams {
|
||||
model: MODEL,
|
||||
prompt: request.prompt.clone(),
|
||||
audio_url: request.audio_url.clone(),
|
||||
chop_sample_start_s: request.chop_sample_start_seconds,
|
||||
chop_sample_end_s: request.chop_sample_end_seconds,
|
||||
custom: false,
|
||||
instrumental: request.instrumental,
|
||||
auto_lyrics: true,
|
||||
mv: MODEL_VERSION,
|
||||
tags: request.style_tags.clone(),
|
||||
is_storage: true,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ──
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
mod pricing_tests {
|
||||
use super::*;
|
||||
|
||||
fn base_request() -> GenerateSunoSampleRequest {
|
||||
GenerateSunoSampleRequest {
|
||||
prompt: "Mystical RPG adventure".to_string(),
|
||||
audio_url: "https://example.com/a.aac".to_string(),
|
||||
chop_sample_start_seconds: 0,
|
||||
chop_sample_end_seconds: 41,
|
||||
style_tags: None,
|
||||
instrumental: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_sixteen_credits() {
|
||||
let costs = base_request().calculate_costs();
|
||||
assert_eq!(costs.kinovi_credits, 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents() {
|
||||
// 16 credits; 1600/243 = 6.5843… → up 7¢, down 6¢.
|
||||
let costs = base_request().calculate_costs();
|
||||
assert_eq!(costs.usd_cents_rounded_up, 7);
|
||||
assert_eq!(costs.usd_cents_rounded_down, 6);
|
||||
assert!((costs.usd_cents_fractional - (1600.0 / 243.0)).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_do_not_affect_cost() {
|
||||
let base = base_request().calculate_costs();
|
||||
let other = GenerateSunoSampleRequest {
|
||||
prompt: "A vocal fantasy track".to_string(),
|
||||
audio_url: "https://example.com/b.aac".to_string(),
|
||||
chop_sample_start_seconds: 10,
|
||||
chop_sample_end_seconds: 30,
|
||||
style_tags: Some("Fantasy video game".to_string()),
|
||||
instrumental: false,
|
||||
}.calculate_costs();
|
||||
assert_eq!(base, other);
|
||||
}
|
||||
}
|
||||
|
||||
mod request_shape_tests {
|
||||
use super::*;
|
||||
|
||||
/// Mirrors capture 11_suno_sample_1.txt (instrumental).
|
||||
#[test]
|
||||
fn instrumental_sample() {
|
||||
let params = build_api_params(&GenerateSunoSampleRequest {
|
||||
prompt: "Mystical RPG adventure, make it have a grand climax".to_string(),
|
||||
audio_url: "https://static.seedance2-pro.com/materials/20260707/1783401649804-e654f994.aac".to_string(),
|
||||
chop_sample_start_seconds: 0,
|
||||
chop_sample_end_seconds: 41,
|
||||
style_tags: Some("Fantasy video game, Motoi Sakuraba, Golden Sun".to_string()),
|
||||
instrumental: true,
|
||||
});
|
||||
assert_eq!(
|
||||
serde_json::to_string(¶ms).unwrap(),
|
||||
r#"{"model":"suno-sample","prompt":"Mystical RPG adventure, make it have a grand climax","audioUrl":"https://static.seedance2-pro.com/materials/20260707/1783401649804-e654f994.aac","chopSampleStartS":0,"chopSampleEndS":41,"custom":false,"instrumental":true,"autoLyrics":true,"mv":"chirp-v5-5","tags":"Fantasy video game, Motoi Sakuraba, Golden Sun","isStorage":true}"#,
|
||||
);
|
||||
}
|
||||
|
||||
/// Mirrors capture 12_suno_sample_2.txt (with vocals).
|
||||
#[test]
|
||||
fn vocal_sample() {
|
||||
let params = build_api_params(&GenerateSunoSampleRequest {
|
||||
prompt: "Give this a lyrics track, a woman singing in a fantasy language".to_string(),
|
||||
audio_url: "https://static.seedance2-pro.com/materials/20260707/1783401649804-e654f994.aac".to_string(),
|
||||
chop_sample_start_seconds: 0,
|
||||
chop_sample_end_seconds: 41,
|
||||
style_tags: Some("Fantasy video game, Motoi Sakuraba, Golden Sun".to_string()),
|
||||
instrumental: false,
|
||||
});
|
||||
let json = serde_json::to_string(¶ms).unwrap();
|
||||
assert!(json.contains(r#""instrumental":false"#), "{json}");
|
||||
assert!(json.contains(r#""autoLyrics":true"#), "{json}");
|
||||
assert!(json.contains(r#""chopSampleStartS":0"#), "{json}");
|
||||
assert!(json.contains(r#""chopSampleEndS":41"#), "{json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn business_type() {
|
||||
assert_eq!(BUSINESS_TYPE, "suno-sample-generation");
|
||||
}
|
||||
}
|
||||
|
||||
mod live_tests {
|
||||
use super::*;
|
||||
use crate::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs};
|
||||
use crate::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
|
||||
use crate::test_utils::get_test_cookies::get_test_cookies;
|
||||
use crate::test_utils::setup_test_logging::setup_test_logging;
|
||||
use errors::AnyhowResult;
|
||||
use log::LevelFilter;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // Sends a real generation to Kinovi; costs credits. Requires a local audio file.
|
||||
async fn test_sample_instrumental() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let audio_url = upload_test_audio(&session).await?;
|
||||
println!("Uploaded audio: {}", audio_url);
|
||||
|
||||
let result = generate_suno_sample(GenerateSunoSampleArgs {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSunoSampleRequest {
|
||||
prompt: "Mystical RPG adventure, make it have a grand climax".to_string(),
|
||||
audio_url,
|
||||
chop_sample_start_seconds: 0,
|
||||
chop_sample_end_seconds: 30,
|
||||
style_tags: Some("Fantasy video game score".to_string()),
|
||||
instrumental: true,
|
||||
},
|
||||
}).await?;
|
||||
println!("suno sample — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert!(!result.order_id.is_empty());
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn test_session() -> AnyhowResult<Seedance2ProSession> {
|
||||
let cookies = get_test_cookies()?;
|
||||
Ok(Seedance2ProSession::from_cookies_string(cookies))
|
||||
}
|
||||
|
||||
async fn upload_test_audio(session: &Seedance2ProSession) -> AnyhowResult<String> {
|
||||
let audio_path = test_utils::test_file_path::test_file_path(
|
||||
"test_data/audio/aac/golden_sun_elemental_stars_cyanne.aac",
|
||||
)?;
|
||||
let audio_bytes = std::fs::read(&audio_path)?;
|
||||
|
||||
let prepare_result = prepare_file_upload(PrepareFileUploadArgs {
|
||||
session,
|
||||
extension: "aac".to_string(),
|
||||
host_override: None,
|
||||
}).await?;
|
||||
|
||||
let upload_result = upload_file(UploadFileArgs {
|
||||
upload_url: prepare_result.upload_url,
|
||||
file_bytes: audio_bytes,
|
||||
host_override: None,
|
||||
}).await?;
|
||||
|
||||
Ok(upload_result.public_url)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
use serde_derive::Serialize;
|
||||
|
||||
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
|
||||
use crate::creds::seedance2pro_session::Seedance2ProSession;
|
||||
use crate::error::seedance2pro_error::Seedance2ProError;
|
||||
use crate::requests::kinovi_host::KinoviHost;
|
||||
use crate::requests::workflow_run_task::workflow_run_task::{
|
||||
workflow_run_task_custom, WorkflowRunTaskCustomArgs, WorkflowRunTaskResponse,
|
||||
};
|
||||
|
||||
const BUSINESS_TYPE: &str = "suno-sounds-generation";
|
||||
const MODEL: &str = "suno-sounds";
|
||||
const MODEL_VERSION: &str = "chirp-v5-5";
|
||||
|
||||
/// Flat credit price per generation.
|
||||
const CREDITS_PER_GENERATION: u64 = 4;
|
||||
|
||||
// ── Args ──
|
||||
|
||||
pub struct GenerateSunoSoundArgs<'a> {
|
||||
pub request: GenerateSunoSoundRequest,
|
||||
pub session: &'a Seedance2ProSession,
|
||||
pub host_override: Option<KinoviHost>,
|
||||
}
|
||||
|
||||
// ── Request ──
|
||||
|
||||
/// Suno Sounds: short sound-effect / sample generation.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GenerateSunoSoundRequest {
|
||||
/// What the sound should be, e.g. "Rain sound effects".
|
||||
pub prompt: String,
|
||||
|
||||
/// Single hit vs seamless loop.
|
||||
pub sound_type: KinoviSunoSoundType,
|
||||
|
||||
/// Optional tempo in BPM.
|
||||
pub bpm: Option<u16>,
|
||||
|
||||
/// Musical key. `Auto` lets Kinovi pick.
|
||||
pub key: KinoviSunoSoundKey,
|
||||
}
|
||||
|
||||
// ── Enums ──
|
||||
|
||||
/// Single hit ("one-shot") vs seamless loop ("loop").
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum KinoviSunoSoundType {
|
||||
SingleHit,
|
||||
Loopable,
|
||||
}
|
||||
|
||||
/// Musical key for the generated sound.
|
||||
///
|
||||
/// NB: the Kinovi UI's "Auto" selection still transmits "C" on the wire
|
||||
/// (observed in captured traffic), so `Auto` and `CMajor` are wire-identical.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum KinoviSunoSoundKey {
|
||||
Auto,
|
||||
CMajor,
|
||||
CMinor,
|
||||
DMajor,
|
||||
DMinor,
|
||||
FMajor,
|
||||
FMinor,
|
||||
GMajor,
|
||||
GMinor,
|
||||
AMajor,
|
||||
AMinor,
|
||||
BMajor,
|
||||
BMinor,
|
||||
}
|
||||
|
||||
// ── Pricing ──
|
||||
//
|
||||
// Suno Sounds: flat 4 Kinovi credits per generation, regardless of options.
|
||||
|
||||
impl GenerateSunoSoundRequest {
|
||||
/// Calculate the cost of this generation request, in Kinovi credits and
|
||||
/// USD cents.
|
||||
pub fn calculate_costs(&self) -> KinoviGenerationCost {
|
||||
KinoviGenerationCost::from_kinovi_credits(CREDITS_PER_GENERATION)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Response ──
|
||||
|
||||
pub struct GenerateSunoSoundResponse {
|
||||
pub task_id: String,
|
||||
pub order_id: String,
|
||||
}
|
||||
|
||||
// ── Entry point ──
|
||||
|
||||
pub async fn generate_suno_sound(
|
||||
args: GenerateSunoSoundArgs<'_>,
|
||||
) -> Result<GenerateSunoSoundResponse, Seedance2ProError> {
|
||||
let raw_response: WorkflowRunTaskResponse = workflow_run_task_custom(WorkflowRunTaskCustomArgs {
|
||||
business_type: BUSINESS_TYPE,
|
||||
api_params: build_api_params(&args.request),
|
||||
session: args.session,
|
||||
host_override: args.host_override,
|
||||
}).await?;
|
||||
|
||||
Ok(GenerateSunoSoundResponse {
|
||||
task_id: raw_response.task_id,
|
||||
order_id: raw_response.order_id,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Wire payload ──
|
||||
|
||||
/// The `apiParams` shape for `suno-sounds-generation`. Field order matches the
|
||||
/// captured browser traffic.
|
||||
#[derive(Serialize, Debug)]
|
||||
struct SunoSoundApiParams {
|
||||
model: &'static str,
|
||||
prompt: String,
|
||||
mv: &'static str,
|
||||
#[serde(rename = "type")]
|
||||
sound_type: &'static str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
bpm: Option<u16>,
|
||||
key: &'static str,
|
||||
#[serde(rename = "isStorage")]
|
||||
is_storage: bool,
|
||||
}
|
||||
|
||||
fn build_api_params(request: &GenerateSunoSoundRequest) -> SunoSoundApiParams {
|
||||
SunoSoundApiParams {
|
||||
model: MODEL,
|
||||
prompt: request.prompt.clone(),
|
||||
mv: MODEL_VERSION,
|
||||
sound_type: sound_type_as_api_str(request.sound_type),
|
||||
bpm: request.bpm,
|
||||
key: key_as_api_str(request.key),
|
||||
is_storage: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn sound_type_as_api_str(sound_type: KinoviSunoSoundType) -> &'static str {
|
||||
match sound_type {
|
||||
KinoviSunoSoundType::SingleHit => "one-shot",
|
||||
KinoviSunoSoundType::Loopable => "loop",
|
||||
}
|
||||
}
|
||||
|
||||
fn key_as_api_str(key: KinoviSunoSoundKey) -> &'static str {
|
||||
match key {
|
||||
// The UI's Auto choice sends "C" on the wire.
|
||||
KinoviSunoSoundKey::Auto => "C",
|
||||
KinoviSunoSoundKey::CMajor => "C",
|
||||
KinoviSunoSoundKey::CMinor => "Cm",
|
||||
KinoviSunoSoundKey::DMajor => "D",
|
||||
KinoviSunoSoundKey::DMinor => "Dm",
|
||||
KinoviSunoSoundKey::FMajor => "F",
|
||||
KinoviSunoSoundKey::FMinor => "Fm",
|
||||
KinoviSunoSoundKey::GMajor => "G",
|
||||
KinoviSunoSoundKey::GMinor => "Gm",
|
||||
KinoviSunoSoundKey::AMajor => "A",
|
||||
KinoviSunoSoundKey::AMinor => "Am",
|
||||
KinoviSunoSoundKey::BMajor => "B",
|
||||
KinoviSunoSoundKey::BMinor => "Bm",
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ──
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
mod pricing_tests {
|
||||
use super::*;
|
||||
|
||||
fn base_request() -> GenerateSunoSoundRequest {
|
||||
GenerateSunoSoundRequest {
|
||||
prompt: "Rain sound effects".to_string(),
|
||||
sound_type: KinoviSunoSoundType::SingleHit,
|
||||
bpm: None,
|
||||
key: KinoviSunoSoundKey::Auto,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_four_credits() {
|
||||
let costs = base_request().calculate_costs();
|
||||
assert_eq!(costs.kinovi_credits, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents() {
|
||||
// 4 credits; 400/243 = 1.6461… → up 2¢, down 1¢.
|
||||
let costs = base_request().calculate_costs();
|
||||
assert_eq!(costs.usd_cents_rounded_up, 2);
|
||||
assert_eq!(costs.usd_cents_rounded_down, 1);
|
||||
assert!((costs.usd_cents_fractional - (400.0 / 243.0)).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_do_not_affect_cost() {
|
||||
let base = base_request().calculate_costs();
|
||||
let other = GenerateSunoSoundRequest {
|
||||
prompt: "Thunder".to_string(),
|
||||
sound_type: KinoviSunoSoundType::Loopable,
|
||||
bpm: Some(180),
|
||||
key: KinoviSunoSoundKey::DMinor,
|
||||
}.calculate_costs();
|
||||
assert_eq!(base, other);
|
||||
}
|
||||
}
|
||||
|
||||
mod request_shape_tests {
|
||||
use super::*;
|
||||
|
||||
/// Mirrors capture 7_suno_sounds_1.txt (single hit, no BPM, auto key —
|
||||
/// which transmits "C").
|
||||
#[test]
|
||||
fn single_hit_no_bpm_auto_key() {
|
||||
let params = build_api_params(&GenerateSunoSoundRequest {
|
||||
prompt: "Rain sound effects".to_string(),
|
||||
sound_type: KinoviSunoSoundType::SingleHit,
|
||||
bpm: None,
|
||||
key: KinoviSunoSoundKey::Auto,
|
||||
});
|
||||
assert_eq!(
|
||||
serde_json::to_string(¶ms).unwrap(),
|
||||
r#"{"model":"suno-sounds","prompt":"Rain sound effects","mv":"chirp-v5-5","type":"one-shot","key":"C","isStorage":true}"#,
|
||||
);
|
||||
}
|
||||
|
||||
/// Mirrors capture 8_suno_sounds_2.txt (loop, 60 BPM, D minor).
|
||||
#[test]
|
||||
fn loop_with_bpm_and_minor_key() {
|
||||
let params = build_api_params(&GenerateSunoSoundRequest {
|
||||
prompt: "Thunder".to_string(),
|
||||
sound_type: KinoviSunoSoundType::Loopable,
|
||||
bpm: Some(60),
|
||||
key: KinoviSunoSoundKey::DMinor,
|
||||
});
|
||||
assert_eq!(
|
||||
serde_json::to_string(¶ms).unwrap(),
|
||||
r#"{"model":"suno-sounds","prompt":"Thunder","mv":"chirp-v5-5","type":"loop","bpm":60,"key":"Dm","isStorage":true}"#,
|
||||
);
|
||||
}
|
||||
|
||||
/// Mirrors capture 9_suno_sounds_3.txt (loop, 180 BPM, F major).
|
||||
#[test]
|
||||
fn loop_with_major_key() {
|
||||
let params = build_api_params(&GenerateSunoSoundRequest {
|
||||
prompt: "Plates crashing in the kitchen".to_string(),
|
||||
sound_type: KinoviSunoSoundType::Loopable,
|
||||
bpm: Some(180),
|
||||
key: KinoviSunoSoundKey::FMajor,
|
||||
});
|
||||
assert_eq!(
|
||||
serde_json::to_string(¶ms).unwrap(),
|
||||
r#"{"model":"suno-sounds","prompt":"Plates crashing in the kitchen","mv":"chirp-v5-5","type":"loop","bpm":180,"key":"F","isStorage":true}"#,
|
||||
);
|
||||
}
|
||||
|
||||
/// Mirrors capture 10_suno_sounds_4.txt (single hit, 100 BPM, C minor).
|
||||
#[test]
|
||||
fn single_hit_with_bpm_and_minor_key() {
|
||||
let params = build_api_params(&GenerateSunoSoundRequest {
|
||||
prompt: "Car crash".to_string(),
|
||||
sound_type: KinoviSunoSoundType::SingleHit,
|
||||
bpm: Some(100),
|
||||
key: KinoviSunoSoundKey::CMinor,
|
||||
});
|
||||
assert_eq!(
|
||||
serde_json::to_string(¶ms).unwrap(),
|
||||
r#"{"model":"suno-sounds","prompt":"Car crash","mv":"chirp-v5-5","type":"one-shot","bpm":100,"key":"Cm","isStorage":true}"#,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_key_wire_value() {
|
||||
let cases = [
|
||||
(KinoviSunoSoundKey::Auto, "C"),
|
||||
(KinoviSunoSoundKey::CMajor, "C"),
|
||||
(KinoviSunoSoundKey::CMinor, "Cm"),
|
||||
(KinoviSunoSoundKey::DMajor, "D"),
|
||||
(KinoviSunoSoundKey::DMinor, "Dm"),
|
||||
(KinoviSunoSoundKey::FMajor, "F"),
|
||||
(KinoviSunoSoundKey::FMinor, "Fm"),
|
||||
(KinoviSunoSoundKey::GMajor, "G"),
|
||||
(KinoviSunoSoundKey::GMinor, "Gm"),
|
||||
(KinoviSunoSoundKey::AMajor, "A"),
|
||||
(KinoviSunoSoundKey::AMinor, "Am"),
|
||||
(KinoviSunoSoundKey::BMajor, "B"),
|
||||
(KinoviSunoSoundKey::BMinor, "Bm"),
|
||||
];
|
||||
for (key, expected) in cases {
|
||||
assert_eq!(key_as_api_str(key), expected, "wire value for {key:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn business_type() {
|
||||
assert_eq!(BUSINESS_TYPE, "suno-sounds-generation");
|
||||
}
|
||||
}
|
||||
|
||||
mod live_tests {
|
||||
use super::*;
|
||||
use crate::test_utils::get_test_cookies::get_test_cookies;
|
||||
use crate::test_utils::setup_test_logging::setup_test_logging;
|
||||
use errors::AnyhowResult;
|
||||
use log::LevelFilter;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // Sends a real generation to Kinovi; costs credits.
|
||||
async fn test_single_hit() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let result = generate_suno_sound(GenerateSunoSoundArgs {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSunoSoundRequest {
|
||||
prompt: "A heavy castle door creaking open".to_string(),
|
||||
sound_type: KinoviSunoSoundType::SingleHit,
|
||||
bpm: None,
|
||||
key: KinoviSunoSoundKey::Auto,
|
||||
},
|
||||
}).await?;
|
||||
println!("suno sound single hit — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert!(!result.order_id.is_empty());
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // Sends a real generation to Kinovi; costs credits.
|
||||
async fn test_loop_with_bpm_and_key() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let result = generate_suno_sound(GenerateSunoSoundArgs {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSunoSoundRequest {
|
||||
prompt: "Lo-fi vinyl crackle groove".to_string(),
|
||||
sound_type: KinoviSunoSoundType::Loopable,
|
||||
bpm: Some(85),
|
||||
key: KinoviSunoSoundKey::AMinor,
|
||||
},
|
||||
}).await?;
|
||||
println!("suno sound loop — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn test_session() -> AnyhowResult<Seedance2ProSession> {
|
||||
let cookies = get_test_cookies()?;
|
||||
Ok(Seedance2ProSession::from_cookies_string(cookies))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod generate_suno_music;
|
||||
pub mod generate_suno_remix;
|
||||
pub mod generate_suno_sample;
|
||||
pub mod generate_suno_sound;
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod audio;
|
||||
pub mod image;
|
||||
pub mod video;
|
||||
|
||||
+48
-6
@@ -274,10 +274,6 @@ pub struct WorkflowRunTaskResponse {
|
||||
// --- Implementation ---
|
||||
|
||||
pub async fn workflow_run_task(args: WorkflowRunTaskArgs<'_>) -> Result<WorkflowRunTaskResponse, Seedance2ProError> {
|
||||
let host = resolve_host(args.host_override.as_ref());
|
||||
let base_url = host.api_base_url();
|
||||
let run_task_url = format!("{}/api/trpc/workflow.runTask?batch=1", base_url);
|
||||
|
||||
let req = args.request;
|
||||
|
||||
info!("Requesting video from Seedance2Pro (v2): {:?}", req);
|
||||
@@ -286,7 +282,53 @@ pub async fn workflow_run_task(args: WorkflowRunTaskArgs<'_>) -> Result<Workflow
|
||||
|
||||
info!("Seedance2pro request (v2): {:?}", request_body);
|
||||
|
||||
let cookie = args.session.cookies.as_str();
|
||||
send_run_task_request(args.session, args.host_override, &request_body).await
|
||||
}
|
||||
|
||||
/// Run a `workflow.runTask` call whose `apiParams` shape differs from the
|
||||
/// standard video request — e.g. the Suno audio models, which carry their own
|
||||
/// parameter sets. The caller provides the tRPC `businessType` and a
|
||||
/// serializable `apiParams` payload; the HTTP plumbing and response handling
|
||||
/// are shared with [`workflow_run_task`].
|
||||
pub async fn workflow_run_task_custom<T: serde::Serialize + std::fmt::Debug>(
|
||||
args: WorkflowRunTaskCustomArgs<'_, T>,
|
||||
) -> Result<WorkflowRunTaskResponse, Seedance2ProError> {
|
||||
info!(
|
||||
"Requesting {} from Seedance2Pro (custom): {:?}",
|
||||
args.business_type, args.api_params,
|
||||
);
|
||||
|
||||
let request_body = serde_json::json!({
|
||||
"0": {
|
||||
"json": {
|
||||
"businessType": args.business_type,
|
||||
"apiParams": args.api_params,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
send_run_task_request(args.session, args.host_override, &request_body).await
|
||||
}
|
||||
|
||||
/// Bundle for [`workflow_run_task_custom`].
|
||||
pub struct WorkflowRunTaskCustomArgs<'a, T: serde::Serialize + std::fmt::Debug> {
|
||||
/// The tRPC `businessType` discriminator (e.g. "suno-music-generation").
|
||||
pub business_type: &'static str,
|
||||
pub api_params: T,
|
||||
pub session: &'a Seedance2ProSession,
|
||||
pub host_override: Option<KinoviHost>,
|
||||
}
|
||||
|
||||
async fn send_run_task_request<B: serde::Serialize>(
|
||||
session: &Seedance2ProSession,
|
||||
host_override: Option<KinoviHost>,
|
||||
request_body: &B,
|
||||
) -> Result<WorkflowRunTaskResponse, Seedance2ProError> {
|
||||
let host = resolve_host(host_override.as_ref());
|
||||
let base_url = host.api_base_url();
|
||||
let run_task_url = format!("{}/api/trpc/workflow.runTask?batch=1", base_url);
|
||||
|
||||
let cookie = session.cookies.as_str();
|
||||
|
||||
let client = Client::builder()
|
||||
.emulation(Emulation::Firefox143)
|
||||
@@ -311,7 +353,7 @@ pub async fn workflow_run_task(args: WorkflowRunTaskArgs<'_>) -> Result<Workflow
|
||||
.header("Sec-Fetch-Site", "same-origin")
|
||||
.header("Priority", "u=4")
|
||||
.header("TE", "trailers")
|
||||
.json(&request_body)
|
||||
.json(request_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| Seedance2ProGenericApiError::WreqError(err))?;
|
||||
|
||||
Reference in New Issue
Block a user