diff --git a/crates/api_clients/muapi_client/src/requests/mod.rs b/crates/api_clients/muapi_client/src/requests/mod.rs index e69de29bb2..33e7d41fd3 100644 --- a/crates/api_clients/muapi_client/src/requests/mod.rs +++ b/crates/api_clients/muapi_client/src/requests/mod.rs @@ -0,0 +1,2 @@ +pub mod poll_prediction_result; +pub mod seedance_2p0_image_to_video; diff --git a/crates/api_clients/muapi_client/src/requests/poll_prediction_result/mod.rs b/crates/api_clients/muapi_client/src/requests/poll_prediction_result/mod.rs new file mode 100644 index 0000000000..57d475090c --- /dev/null +++ b/crates/api_clients/muapi_client/src/requests/poll_prediction_result/mod.rs @@ -0,0 +1,2 @@ +pub mod poll_prediction_result; +mod request_types; diff --git a/crates/api_clients/muapi_client/src/requests/poll_prediction_result/poll_prediction_result.rs b/crates/api_clients/muapi_client/src/requests/poll_prediction_result/poll_prediction_result.rs new file mode 100644 index 0000000000..9ecece21c6 --- /dev/null +++ b/crates/api_clients/muapi_client/src/requests/poll_prediction_result/poll_prediction_result.rs @@ -0,0 +1,134 @@ +use crate::creds::muapi_session::MuapiSession; +use crate::error::muapi_client_error::MuapiClientError; +use crate::error::muapi_error::MuapiError; +use crate::error::muapi_generic_api_error::MuapiGenericApiError; +use crate::requests::poll_prediction_result::request_types::*; +use log::info; +use wreq::Client; + +const PREDICTIONS_BASE_URL: &str = "https://api.muapi.ai/api/v1/predictions"; + +// --- Public types --- + +/// The lifecycle status of a prediction. +#[derive(Debug, Clone, PartialEq)] +pub enum PredictionStatus { + /// The task is queued and has not started yet. + Pending, + /// The task is actively being processed. + Processing, + /// The task finished successfully. `outputs` will be populated. + Completed, + /// The task failed. `error` will contain the reason. + Failed, + /// An unrecognised status string was returned by the server. + Unknown(String), +} + +impl PredictionStatus { + fn from_str(s: &str) -> Self { + match s { + "pending" => Self::Pending, + "processing" => Self::Processing, + "completed" => Self::Completed, + "failed" => Self::Failed, + other => Self::Unknown(other.to_string()), + } + } + + pub fn is_terminal(&self) -> bool { + matches!(self, Self::Completed | Self::Failed) + } +} + +// --- Args & response --- + +pub struct PollPredictionResultArgs<'a> { + pub session: &'a MuapiSession, + + /// The request_id returned from a submission endpoint. + pub request_id: &'a str, +} + +pub struct PollPredictionResultApiResponse { + /// The current status of the prediction. + pub status: PredictionStatus, + + /// Output URLs. Populated when `status` is `Completed`. + pub outputs: Option>, + + /// Error message. Populated when `status` is `Failed`. + pub error: Option, +} + +// --- Implementation --- + +pub async fn poll_prediction_result( + args: PollPredictionResultArgs<'_>, +) -> Result { + let url = format!("{}/{}/result", PREDICTIONS_BASE_URL, args.request_id); + + info!("Polling Muapi prediction result: {}", url); + + let api_key = args.session.api_key.as_str(); + + let client = Client::builder() + .build() + .map_err(|err| MuapiClientError::WreqClientError(err))?; + + let response = client.get(&url) + .header("Content-Type", "application/json") + .header("x-api-key", api_key) + .send() + .await + .map_err(|err| MuapiGenericApiError::WreqError(err))?; + + let status = response.status(); + let response_body = response.text() + .await + .map_err(|err| MuapiGenericApiError::WreqError(err))?; + + info!("Muapi poll response status: {}, body: {}", status, response_body); + + if !status.is_success() { + return Err(MuapiGenericApiError::UncategorizedBadResponseWithStatusAndBody { + status_code: status, + body: response_body, + }.into()); + } + + let parsed: PollPredictionResultResponse = serde_json::from_str(&response_body) + .map_err(|err| MuapiGenericApiError::SerdeResponseParseErrorWithBody(err, response_body))?; + + Ok(PollPredictionResultApiResponse { + status: PredictionStatus::from_str(&parsed.status), + outputs: parsed.outputs, + error: parsed.error, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::get_test_api_key::get_test_api_key; + use crate::test_utils::setup_test_logging::setup_test_logging; + use errors::AnyhowResult; + use log::LevelFilter; + + #[tokio::test] + #[ignore] // manually test — requires real API key and a valid request_id + async fn test_poll_prediction_result() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = get_test_api_key()?; + let request_id = "REPLACE_WITH_REAL_REQUEST_ID"; + let result = poll_prediction_result(PollPredictionResultArgs { + session: &session, + request_id, + }).await?; + println!("Status: {:?}", result.status); + println!("Outputs: {:?}", result.outputs); + println!("Error: {:?}", result.error); + assert_eq!(1, 2); // NB: Intentional failure to inspect output. + Ok(()) + } +} diff --git a/crates/api_clients/muapi_client/src/requests/poll_prediction_result/request_types.rs b/crates/api_clients/muapi_client/src/requests/poll_prediction_result/request_types.rs new file mode 100644 index 0000000000..9d205b8748 --- /dev/null +++ b/crates/api_clients/muapi_client/src/requests/poll_prediction_result/request_types.rs @@ -0,0 +1,8 @@ +use serde_derive::Deserialize; + +#[derive(Deserialize, Debug)] +pub(super) struct PollPredictionResultResponse { + pub status: String, + pub outputs: Option>, + pub error: Option, +} diff --git a/crates/api_clients/muapi_client/src/requests/seedance_2p0_image_to_video/mod.rs b/crates/api_clients/muapi_client/src/requests/seedance_2p0_image_to_video/mod.rs new file mode 100644 index 0000000000..8c0968d5bb --- /dev/null +++ b/crates/api_clients/muapi_client/src/requests/seedance_2p0_image_to_video/mod.rs @@ -0,0 +1,2 @@ +pub mod seedance_2p0_image_to_video; +mod request_types; diff --git a/crates/api_clients/muapi_client/src/requests/seedance_2p0_image_to_video/request_types.rs b/crates/api_clients/muapi_client/src/requests/seedance_2p0_image_to_video/request_types.rs new file mode 100644 index 0000000000..39344d9add --- /dev/null +++ b/crates/api_clients/muapi_client/src/requests/seedance_2p0_image_to_video/request_types.rs @@ -0,0 +1,19 @@ +use serde_derive::{Deserialize, Serialize}; + +// --- Request --- + +#[derive(Serialize, Debug)] +pub(super) struct Seedance2p0I2vRequest { + pub prompt: String, + pub images_list: Vec, + pub aspect_ratio: &'static str, + pub duration: u8, + pub quality: &'static str, +} + +// --- Response --- + +#[derive(Deserialize, Debug)] +pub(super) struct Seedance2p0I2vResponse { + pub request_id: String, +} diff --git a/crates/api_clients/muapi_client/src/requests/seedance_2p0_image_to_video/seedance_2p0_image_to_video.rs b/crates/api_clients/muapi_client/src/requests/seedance_2p0_image_to_video/seedance_2p0_image_to_video.rs new file mode 100644 index 0000000000..58a8bf7544 --- /dev/null +++ b/crates/api_clients/muapi_client/src/requests/seedance_2p0_image_to_video/seedance_2p0_image_to_video.rs @@ -0,0 +1,192 @@ +use crate::creds::muapi_session::MuapiSession; +use crate::error::muapi_client_error::MuapiClientError; +use crate::error::muapi_error::MuapiError; +use crate::error::muapi_generic_api_error::MuapiGenericApiError; +use crate::requests::seedance_2p0_image_to_video::request_types::*; +use log::info; +use wreq::Client; + +const SEEDANCE_2P0_I2V_URL: &str = "https://api.muapi.ai/api/v1/seedance-v2.0-i2v"; + +// --- Public enums --- + +/// Video aspect ratio. +#[derive(Debug, Clone, Copy)] +pub enum AspectRatio { + /// 16:9 landscape + Landscape16x9, + /// 9:16 portrait + Portrait9x16, + /// 1:1 square + Square1x1, +} + +impl AspectRatio { + fn as_str(&self) -> &'static str { + match self { + Self::Landscape16x9 => "16:9", + Self::Portrait9x16 => "9:16", + Self::Square1x1 => "1:1", + } + } +} + +/// Video quality tier. +#[derive(Debug, Clone, Copy)] +pub enum Quality { + Basic, + High, +} + +impl Quality { + fn as_str(&self) -> &'static str { + match self { + Self::Basic => "basic", + Self::High => "high", + } + } +} + +// --- Args & response --- + +pub struct Seedance2p0ImageToVideoArgs<'a> { + pub session: &'a MuapiSession, + + /// The prompt describing the desired video. + pub prompt: String, + + /// One or more image URLs to use as input frames. + pub images_list: Vec, + + /// The aspect ratio for the output video. + pub aspect_ratio: AspectRatio, + + /// Duration in seconds. + pub duration: u8, + + /// Quality tier. + pub quality: Quality, +} + +impl std::fmt::Debug for Seedance2p0ImageToVideoArgs<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Seedance2p0ImageToVideoArgs") + .field("prompt", &self.prompt) + .field("images_list", &self.images_list) + .field("aspect_ratio", &self.aspect_ratio) + .field("duration", &self.duration) + .field("quality", &self.quality) + .finish() + } +} + +pub struct Seedance2p0ImageToVideoResponse { + /// The request ID used to poll for results. + pub request_id: String, +} + +// --- Implementation --- + +pub async fn seedance_2p0_image_to_video( + args: Seedance2p0ImageToVideoArgs<'_>, +) -> Result { + info!("Submitting Seedance 2.0 i2v task to Muapi: {:?}", args); + + let request_body = Seedance2p0I2vRequest { + prompt: args.prompt, + images_list: args.images_list, + aspect_ratio: args.aspect_ratio.as_str(), + duration: args.duration, + quality: args.quality.as_str(), + }; + + info!("Muapi request body: {:?}", request_body); + + let api_key = args.session.api_key.as_str(); + + let client = Client::builder() + .build() + .map_err(|err| MuapiClientError::WreqClientError(err))?; + + let response = client.post(SEEDANCE_2P0_I2V_URL) + .header("Content-Type", "application/json") + .header("x-api-key", api_key) + .json(&request_body) + .send() + .await + .map_err(|err| MuapiGenericApiError::WreqError(err))?; + + let status = response.status(); + let response_body = response.text() + .await + .map_err(|err| MuapiGenericApiError::WreqError(err))?; + + info!("Muapi response status: {}, body: {}", status, response_body); + + if !status.is_success() { + return Err(MuapiGenericApiError::UncategorizedBadResponseWithStatusAndBody { + status_code: status, + body: response_body, + }.into()); + } + + let parsed: Seedance2p0I2vResponse = serde_json::from_str(&response_body) + .map_err(|err| MuapiGenericApiError::SerdeResponseParseErrorWithBody(err, response_body))?; + + Ok(Seedance2p0ImageToVideoResponse { + request_id: parsed.request_id, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::get_test_api_key::get_test_api_key; + use crate::test_utils::setup_test_logging::setup_test_logging; + use errors::AnyhowResult; + use log::LevelFilter; + + #[tokio::test] + #[ignore] // manually test — requires real API key + async fn test_seedance_2p0_image_to_video() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = get_test_api_key()?; + let args = Seedance2p0ImageToVideoArgs { + session: &session, + prompt: "The lightbulb suddenly rockets across the room like a missile, smashing through curtains while water spins violently inside.".to_string(), + images_list: vec![ + "https://d3adwkbyhxyrtq.cloudfront.net/webassets/videomodels/seedance-v2.0-i2v.jpg".to_string(), + ], + aspect_ratio: AspectRatio::Landscape16x9, + duration: 5, + quality: Quality::Basic, + }; + let result = seedance_2p0_image_to_video(args).await?; + println!("Request ID: {}", result.request_id); + assert!(!result.request_id.is_empty()); + assert_eq!(1, 2); // NB: Intentional failure to inspect output. + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real API key + async fn test_seedance_2p0_image_to_video_portrait() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = get_test_api_key()?; + let args = Seedance2p0ImageToVideoArgs { + session: &session, + prompt: "A cat slowly blinks and yawns, stretching its paws forward.".to_string(), + images_list: vec![ + "https://d3adwkbyhxyrtq.cloudfront.net/webassets/videomodels/seedance-v2.0-i2v.jpg".to_string(), + ], + aspect_ratio: AspectRatio::Portrait9x16, + duration: 5, + quality: Quality::High, + }; + let result = seedance_2p0_image_to_video(args).await?; + println!("Request ID: {}", result.request_id); + assert!(!result.request_id.is_empty()); + assert_eq!(1, 2); // NB: Intentional failure to inspect output. + Ok(()) + } +}