diff --git a/async-openai/README.md b/async-openai/README.md index f5bd50b4..9b1fdcab 100644 --- a/async-openai/README.md +++ b/async-openai/README.md @@ -38,6 +38,7 @@ - [x] Realtime (Beta) (partially implemented) - [x] Responses (partially implemented) - [x] Uploads + - [x] Videos - Bring your own custom types for Request or Response objects. - SSE streaming on available APIs - Requests (except SSE streaming) including form submissions are retried with exponential backoff when [rate limited](https://platform.openai.com/docs/guides/rate-limits). diff --git a/async-openai/src/client.rs b/async-openai/src/client.rs index 28d6c3d4..d73a2329 100644 --- a/async-openai/src/client.rs +++ b/async-openai/src/client.rs @@ -14,7 +14,7 @@ use crate::{ moderation::Moderations, traits::AsyncTryFrom, Assistants, Audio, AuditLogs, Batches, Chat, Completions, Embeddings, FineTuning, Invites, - Models, Projects, Responses, Threads, Uploads, Users, VectorStores, + Models, Projects, Responses, Threads, Uploads, Users, VectorStores, Videos, }; #[derive(Debug, Clone, Default)] @@ -122,6 +122,11 @@ impl Client { Audio::new(self) } + /// To call [Videos] group related APIs using this client. + pub fn videos(&self) -> Videos { + Videos::new(self) + } + /// To call [Assistants] group related APIs using this client. pub fn assistants(&self) -> Assistants { Assistants::new(self) @@ -238,6 +243,27 @@ impl Client { self.execute_raw(request_maker).await } + pub(crate) async fn get_raw_with_query( + &self, + path: &str, + query: &Q, + ) -> Result + where + Q: Serialize + ?Sized, + { + let request_maker = || async { + Ok(self + .http_client + .get(self.config.url(path)) + .query(&self.config.query()) + .query(query) + .headers(self.config.headers()) + .build()?) + }; + + self.execute_raw(request_maker).await + } + /// Make a POST request to {path} and return the response body pub(crate) async fn post_raw(&self, path: &str, request: I) -> Result where diff --git a/async-openai/src/lib.rs b/async-openai/src/lib.rs index c94bc495..3d1b8360 100644 --- a/async-openai/src/lib.rs +++ b/async-openai/src/lib.rs @@ -174,6 +174,7 @@ mod util; mod vector_store_file_batches; mod vector_store_files; mod vector_stores; +mod video; pub use assistants::Assistants; pub use audio::Audio; @@ -203,3 +204,4 @@ pub use users::Users; pub use vector_store_file_batches::VectorStoreFileBatches; pub use vector_store_files::VectorStoreFiles; pub use vector_stores::VectorStores; +pub use video::Videos; diff --git a/async-openai/src/types/impls.rs b/async-openai/src/types/impls.rs index af559127..b566dc7d 100644 --- a/async-openai/src/types/impls.rs +++ b/async-openai/src/types/impls.rs @@ -7,7 +7,7 @@ use crate::{ download::{download_url, save_b64}, error::OpenAIError, traits::AsyncTryFrom, - types::InputSource, + types::{InputSource, VideoSize}, util::{create_all_dir, create_file_part}, }; @@ -26,9 +26,9 @@ use super::{ ChatCompletionRequestUserMessage, ChatCompletionRequestUserMessageContent, ChatCompletionRequestUserMessageContentPart, ChatCompletionToolChoiceOption, CreateFileRequest, CreateImageEditRequest, CreateImageVariationRequest, CreateMessageRequestContent, - CreateSpeechResponse, CreateTranscriptionRequest, CreateTranslationRequest, DallE2ImageSize, - EmbeddingInput, FileExpiresAfterAnchor, FileInput, FilePurpose, FunctionName, Image, - ImageInput, ImageModel, ImageResponseFormat, ImageSize, ImageUrl, ImagesResponse, + CreateSpeechResponse, CreateTranscriptionRequest, CreateTranslationRequest, CreateVideoRequest, + DallE2ImageSize, EmbeddingInput, FileExpiresAfterAnchor, FileInput, FilePurpose, FunctionName, + Image, ImageInput, ImageModel, ImageResponseFormat, ImageSize, ImageUrl, ImagesResponse, ModerationInput, Prompt, Role, Stop, TimestampGranularity, }; @@ -161,6 +161,21 @@ impl_input!(AudioInput); impl_input!(FileInput); impl_input!(ImageInput); +impl Display for VideoSize { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + Self::S720x1280 => "720x1280", + Self::S1280x720 => "1280x720", + Self::S1024x1792 => "1024x1792", + Self::S1792x1024 => "1792x1024", + } + ) + } +} + impl Display for ImageSize { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( @@ -1005,6 +1020,31 @@ impl AsyncTryFrom for reqwest::multipart::Form { } } +impl AsyncTryFrom for reqwest::multipart::Form { + type Error = OpenAIError; + + async fn try_from(request: CreateVideoRequest) -> Result { + let mut form = reqwest::multipart::Form::new().text("model", request.model); + + form = form.text("prompt", request.prompt); + + if request.size.is_some() { + form = form.text("size", request.size.unwrap().to_string()); + } + + if request.seconds.is_some() { + form = form.text("seconds", request.seconds.unwrap()); + } + + if request.input_reference.is_some() { + let image_part = create_file_part(request.input_reference.unwrap().source).await?; + form = form.part("input_reference", image_part); + } + + Ok(form) + } +} + // end: types to multipart form impl Default for Input { diff --git a/async-openai/src/types/mod.rs b/async-openai/src/types/mod.rs index 4b8ccb6f..c1cd4cb5 100644 --- a/async-openai/src/types/mod.rs +++ b/async-openai/src/types/mod.rs @@ -31,6 +31,7 @@ mod thread; mod upload; mod users; mod vector_store; +mod video; pub use assistant::*; pub use assistant_stream::*; @@ -58,6 +59,7 @@ pub use thread::*; pub use upload::*; pub use users::*; pub use vector_store::*; +pub use video::*; mod impls; use derive_builder::UninitializedFieldError; diff --git a/async-openai/src/types/video.rs b/async-openai/src/types/video.rs new file mode 100644 index 00000000..787255e8 --- /dev/null +++ b/async-openai/src/types/video.rs @@ -0,0 +1,115 @@ +use derive_builder::Builder; +use serde::{Deserialize, Serialize}; + +use crate::{error::OpenAIError, types::ImageInput}; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub enum VideoSize { + #[default] + #[serde(rename = "720x1280")] + S720x1280, + #[serde(rename = "1280x720")] + S1280x720, + #[serde(rename = "1024x1792")] + S1024x1792, + #[serde(rename = "1792x1024")] + S1792x1024, +} + +#[derive(Clone, Default, Debug, Builder, PartialEq)] +#[builder(name = "CreateVideoRequestArgs")] +#[builder(pattern = "mutable")] +#[builder(setter(into, strip_option), default)] +#[builder(derive(Debug))] +#[builder(build_fn(error = "OpenAIError"))] +pub struct CreateVideoRequest { + /// ID of the model to use. + pub model: String, + + /// The prompt to generate video from. + pub prompt: String, + + pub size: Option, + + pub seconds: Option, + + pub input_reference: Option, +} + +#[derive(Clone, Default, Debug, Builder, PartialEq, Serialize)] +#[builder(name = "RemixVideoRequestArgs")] +#[builder(pattern = "mutable")] +#[builder(setter(into, strip_option), default)] +#[builder(derive(Debug))] +#[builder(build_fn(error = "OpenAIError"))] +pub struct RemixVideoRequest { + pub prompt: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VideoJobError { + pub code: String, + pub message: String, +} + +/// Structured information describing a generated video job. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VideoJob { + /// Unix timestamp (seconds) for when the job completed, if finished. + pub completed_at: Option, + + /// Unix timestamp (seconds) for when the job was created. + pub created_at: u32, + + /// Error payload that explains why generation failed, if applicable. + pub error: Option, + + /// Unix timestamp (seconds) for when the downloadable assets expire, if set. + pub expires_at: Option, + + /// Unique identifier for the video job. + pub id: String, + + /// The video generation model that produced the job. + pub model: String, + + /// The object type, which is always video. + pub object: String, + + /// Approximate completion percentage for the generation task. + pub progress: u8, + + /// Identifier of the source video if this video is a remix. + pub remixed_from_video_id: Option, + + /// Duration of the generated clip in seconds. + pub seconds: String, + + /// The resolution of the generated video. + pub size: String, + + /// Current lifecycle status of the video job. + pub status: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VideoJobMetadata { + pub id: String, + pub object: String, + pub deleted: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListVideosResponse { + pub data: Vec, + pub object: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum VideoVariant { + #[default] + Video, + Thumbnail, + Spritesheet, +} diff --git a/async-openai/src/video.rs b/async-openai/src/video.rs new file mode 100644 index 00000000..8d73fd01 --- /dev/null +++ b/async-openai/src/video.rs @@ -0,0 +1,80 @@ +use crate::{ + config::Config, + error::OpenAIError, + types::{ + CreateVideoRequest, ListVideosResponse, RemixVideoRequest, VideoJob, VideoJobMetadata, + VideoVariant, + }, + Client, +}; +use bytes::Bytes; +use serde::Serialize; + +/// Video generation with Sora +/// Related guide: [Video generation](https://platform.openai.com/docs/guides/video-generation) +pub struct Videos<'c, C: Config> { + client: &'c Client, +} + +impl<'c, C: Config> Videos<'c, C> { + pub fn new(client: &'c Client) -> Self { + Self { client } + } + + /// Create a video + #[crate::byot( + T0 = Clone, + R = serde::de::DeserializeOwned, + where_clause = "reqwest::multipart::Form: crate::traits::AsyncTryFrom", + )] + pub async fn create(&self, request: CreateVideoRequest) -> Result { + self.client.post_form("/videos", request).await + } + + /// Create a video remix + #[crate::byot(T0 = std::fmt::Display, T1 = serde::Serialize, R = serde::de::DeserializeOwned)] + pub async fn remix( + &self, + video_id: &str, + request: RemixVideoRequest, + ) -> Result { + self.client + .post(&format!("/videos/{video_id}/remix"), request) + .await + } + + /// Retrieves a video by its ID. + #[crate::byot(T0 = std::fmt::Display, R = serde::de::DeserializeOwned)] + pub async fn retrieve(&self, video_id: &str) -> Result { + self.client.get(&format!("/videos/{}", video_id)).await + } + + /// Delete a Video + #[crate::byot(T0 = std::fmt::Display, R = serde::de::DeserializeOwned)] + pub async fn delete(&self, video_id: &str) -> Result { + self.client.delete(&format!("/videos/{}", video_id)).await + } + + /// List Videos + #[crate::byot(T0 = serde::Serialize, R = serde::de::DeserializeOwned)] + pub async fn list(&self, query: &Q) -> Result + where + Q: Serialize + ?Sized, + { + self.client.get_with_query("/videos", &query).await + } + + /// Download video content + pub async fn download_content( + &self, + video_id: &str, + variant: VideoVariant, + ) -> Result { + self.client + .get_raw_with_query( + &format!("/videos/{video_id}/content"), + &[("variant", variant)], + ) + .await + } +} diff --git a/examples/video/Cargo.toml b/examples/video/Cargo.toml new file mode 100644 index 00000000..601bd03d --- /dev/null +++ b/examples/video/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "video" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +async-openai = {path = "../../async-openai"} +tokio = { version = "1.43.0", features = ["full"] } +bytes = "1.9.0" + diff --git a/examples/video/README.md b/examples/video/README.md new file mode 100644 index 00000000..c2d7a3ce --- /dev/null +++ b/examples/video/README.md @@ -0,0 +1,10 @@ +Image Credits: + +https://cdn.openai.com/API/docs/images/sora/monster_original_720p.jpeg + +### Output + +Prompt: "Fridge opens, cat walks out, and celebrates a birthday party" + +https://github.com/user-attachments/assets/90dfe49d-4435-4e2a-8ea8-cb556c764414 + diff --git a/examples/video/input/monster_original_720p.jpeg b/examples/video/input/monster_original_720p.jpeg new file mode 100644 index 00000000..09096892 Binary files /dev/null and b/examples/video/input/monster_original_720p.jpeg differ diff --git a/examples/video/src/main.rs b/examples/video/src/main.rs new file mode 100644 index 00000000..d9e553c4 --- /dev/null +++ b/examples/video/src/main.rs @@ -0,0 +1,103 @@ +use async_openai::{ + config::OpenAIConfig, + types::{CreateVideoRequestArgs, VideoJob, VideoSize, VideoVariant}, + Client, +}; +use bytes::Bytes; +use std::error::Error; + +pub async fn save>( + bytes: Bytes, + file_path: P, +) -> Result<(), Box> { + let dir = file_path.as_ref().parent(); + if let Some(dir) = dir { + tokio::fs::create_dir_all(dir).await?; + } + + tokio::fs::write(file_path, &bytes).await?; + + Ok(()) +} + +async fn create_video(client: &Client) -> Result> { + let request = CreateVideoRequestArgs::default() + .model("sora-2") + .prompt("Fridge opens, cat walks out, and celebrates a birthday party") + .input_reference("./input/monster_original_720p.jpeg") + .size(VideoSize::S1280x720) // size of input image + .build()?; + + println!("Generating video..."); + let response = client.videos().create(request).await?; + + println!("Video generation started!"); + println!("Video ID: {}", response.id); + println!("Status: {}", response.status); + println!("Model: {}", response.model); + println!("Size: {}", response.size); + println!("Duration: {} seconds", response.seconds); + + // Poll for completion + let video_id = &response.id; + loop { + let status_response = client.videos().retrieve(video_id).await?; + println!("Current status: {}", status_response.status); + + match status_response.status.as_str() { + "completed" => { + println!("Video generation completed!"); + break; + } + "failed" => { + println!("Video generation failed!"); + if let Some(error) = status_response.error { + println!("Error: {:?}", error); + } + return Err("Video generation failed".into()); + } + _ => { + println!("Progress: {}%", status_response.progress); + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + } + } + } + + Ok(response) +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let client = Client::new(); + let _video = create_video(&client).await?; + // wait for above video to be "completed" + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + let videos = client.videos().list(&[("limit", "100")]).await?; + + for video in &videos.data { + println!("Video: {:#?}", video); + + if video.status == "completed" { + let content = client + .videos() + .download_content(&video.id, VideoVariant::Video) + .await; + if let Ok(content) = content { + let output_path = &format!("./data/{}.mp4", video.id); + save(content, output_path).await?; + println!("Video saved to {}", output_path); + } else { + println!("cannot download video: {:?}", content); + } + } + } + + for video in videos.data { + println!( + "\nVideo deleted: {:?}", + client.videos().delete(&video.id).await? + ); + } + + Ok(()) +}