Skip to main content

openai_tools/videos/
request.rs

1//! OpenAI Videos API Request Module
2//!
3//! This module provides the functionality to interact with the OpenAI Videos API
4//! (`/v1/videos`) for generating video clips with the Sora models.
5//!
6//! # Key Features
7//!
8//! - **Create**: Queue a generation job from a text prompt
9//! - **Retrieve**: Poll a job for status and progress
10//! - **List**: Page through recently generated videos
11//! - **Delete**: Remove a video and its assets
12//! - **Content**: Download the rendered video, thumbnail or spritesheet
13//! - **Remix**: Re-generate an existing video with an updated prompt
14//!
15//! # Quick Start
16//!
17//! ```rust,no_run
18//! use openai_tools::videos::request::{Videos, CreateVideoOptions};
19//!
20//! #[tokio::main]
21//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
22//!     let videos = Videos::new()?;
23//!
24//!     let job = videos.create("A red balloon over Tokyo", CreateVideoOptions::default()).await?;
25//!     println!("Queued: {}", job.id);
26//!
27//!     Ok(())
28//! }
29//! ```
30
31use crate::common::auth::{AuthProvider, OpenAIAuth};
32use crate::common::client::create_http_client;
33use crate::common::errors::{ErrorResponse, OpenAIToolError, Result};
34use crate::videos::response::{DeleteVideoResponse, Video, VideoListResponse};
35use serde::{Deserialize, Serialize};
36use std::time::Duration;
37
38/// Default API path for Videos
39const VIDEOS_PATH: &str = "videos";
40
41/// Video generation models.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
43pub enum VideoModel {
44    /// Sora 2 - standard video generation model
45    #[serde(rename = "sora-2")]
46    #[default]
47    Sora2,
48    /// Sora 2 Pro - higher quality, more expensive
49    #[serde(rename = "sora-2-pro")]
50    Sora2Pro,
51}
52
53impl VideoModel {
54    /// Returns the model identifier string.
55    pub fn as_str(&self) -> &'static str {
56        match self {
57            Self::Sora2 => "sora-2",
58            Self::Sora2Pro => "sora-2-pro",
59        }
60    }
61}
62
63impl std::fmt::Display for VideoModel {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        write!(f, "{}", self.as_str())
66    }
67}
68
69/// Output resolutions supported by the Videos API.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
71pub enum VideoSize {
72    /// 720x1280 - portrait (default)
73    #[serde(rename = "720x1280")]
74    #[default]
75    Size720x1280,
76    /// 1280x720 - landscape
77    #[serde(rename = "1280x720")]
78    Size1280x720,
79    /// 1024x1792 - tall portrait
80    #[serde(rename = "1024x1792")]
81    Size1024x1792,
82    /// 1792x1024 - wide landscape
83    #[serde(rename = "1792x1024")]
84    Size1792x1024,
85}
86
87impl VideoSize {
88    /// Returns the size string.
89    pub fn as_str(&self) -> &'static str {
90        match self {
91            Self::Size720x1280 => "720x1280",
92            Self::Size1280x720 => "1280x720",
93            Self::Size1024x1792 => "1024x1792",
94            Self::Size1792x1024 => "1792x1024",
95        }
96    }
97}
98
99impl std::fmt::Display for VideoSize {
100    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101        write!(f, "{}", self.as_str())
102    }
103}
104
105/// Clip durations supported by the Videos API.
106///
107/// The API requires this value as a *string*; sending an integer is rejected
108/// with `invalid_type`. The `serde` rename below produces the string form.
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
110pub enum VideoSeconds {
111    /// 4 second clip (default)
112    #[serde(rename = "4")]
113    #[default]
114    Four,
115    /// 8 second clip
116    #[serde(rename = "8")]
117    Eight,
118    /// 12 second clip
119    #[serde(rename = "12")]
120    Twelve,
121}
122
123impl VideoSeconds {
124    /// Returns the duration string as the API expects it.
125    pub fn as_str(&self) -> &'static str {
126        match self {
127            Self::Four => "4",
128            Self::Eight => "8",
129            Self::Twelve => "12",
130        }
131    }
132}
133
134impl std::fmt::Display for VideoSeconds {
135    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136        write!(f, "{}", self.as_str())
137    }
138}
139
140/// Downloadable asset variants for a completed video.
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
142#[serde(rename_all = "lowercase")]
143pub enum VideoVariant {
144    /// The rendered video file (default)
145    #[default]
146    Video,
147    /// A single still frame
148    Thumbnail,
149    /// A grid of frames
150    Spritesheet,
151}
152
153impl VideoVariant {
154    /// Returns the variant string.
155    pub fn as_str(&self) -> &'static str {
156        match self {
157            Self::Video => "video",
158            Self::Thumbnail => "thumbnail",
159            Self::Spritesheet => "spritesheet",
160        }
161    }
162}
163
164impl std::fmt::Display for VideoVariant {
165    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166        write!(f, "{}", self.as_str())
167    }
168}
169
170/// A reference image guiding generation.
171///
172/// Supply either an uploaded file or an image URL - never both.
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
174pub struct InputReference {
175    /// ID of a file uploaded via the Files API
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub file_id: Option<String>,
178    /// A fully qualified URL, or a base64-encoded data URL
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub image_url: Option<String>,
181}
182
183impl InputReference {
184    /// Builds a reference from an uploaded file ID.
185    pub fn file_id<S: Into<String>>(file_id: S) -> Self {
186        Self { file_id: Some(file_id.into()), image_url: None }
187    }
188
189    /// Builds a reference from an image URL or data URL.
190    pub fn image_url<S: Into<String>>(image_url: S) -> Self {
191        Self { file_id: None, image_url: Some(image_url.into()) }
192    }
193}
194
195/// Optional settings for [`Videos::create`].
196///
197/// Unset fields are omitted from the request so the API applies its own
198/// defaults (`sora-2`, `720x1280`, 4 seconds).
199#[derive(Debug, Clone, Default)]
200pub struct CreateVideoOptions {
201    /// The generation model
202    pub model: Option<VideoModel>,
203    /// Clip duration
204    pub seconds: Option<VideoSeconds>,
205    /// Output resolution
206    pub size: Option<VideoSize>,
207    /// Reference image guiding generation
208    pub input_reference: Option<InputReference>,
209}
210
211impl CreateVideoOptions {
212    /// Combines these options with a prompt into a serializable request body.
213    pub fn into_request<S: Into<String>>(self, prompt: S) -> CreateVideoRequest {
214        CreateVideoRequest { prompt: prompt.into(), model: self.model, seconds: self.seconds, size: self.size, input_reference: self.input_reference }
215    }
216}
217
218/// Request body for `POST /v1/videos`.
219#[derive(Debug, Clone, Serialize)]
220pub struct CreateVideoRequest {
221    /// Describes the video to generate
222    pub prompt: String,
223    /// The generation model
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub model: Option<VideoModel>,
226    /// Clip duration
227    #[serde(skip_serializing_if = "Option::is_none")]
228    pub seconds: Option<VideoSeconds>,
229    /// Output resolution
230    #[serde(skip_serializing_if = "Option::is_none")]
231    pub size: Option<VideoSize>,
232    /// Reference image guiding generation
233    #[serde(skip_serializing_if = "Option::is_none")]
234    pub input_reference: Option<InputReference>,
235}
236
237/// Request body for `POST /v1/videos/{video_id}/remix`.
238#[derive(Debug, Clone, Serialize)]
239struct RemixVideoRequest {
240    prompt: String,
241}
242
243/// Sort order for [`Videos::list`].
244#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
245pub enum SortOrder {
246    /// Oldest first
247    Asc,
248    /// Newest first (default)
249    #[default]
250    Desc,
251}
252
253impl SortOrder {
254    /// Returns the order string.
255    pub fn as_str(&self) -> &'static str {
256        match self {
257            Self::Asc => "asc",
258            Self::Desc => "desc",
259        }
260    }
261}
262
263/// OpenAI Videos API client.
264///
265/// Video generation is a long-running job: [`create`](Videos::create) queues
266/// the work and returns immediately, so poll [`retrieve`](Videos::retrieve)
267/// until the job settles before downloading with [`content`](Videos::content).
268///
269/// # Example
270///
271/// ```rust,no_run
272/// use openai_tools::videos::request::{Videos, CreateVideoOptions, VideoSize};
273///
274/// #[tokio::main]
275/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
276///     let videos = Videos::new()?;
277///
278///     let options = CreateVideoOptions { size: Some(VideoSize::Size1280x720), ..Default::default() };
279///     let job = videos.create("A red balloon over Tokyo", options).await?;
280///     println!("Queued: {}", job.id);
281///
282///     Ok(())
283/// }
284/// ```
285pub struct Videos {
286    /// Authentication provider (OpenAI or Azure)
287    auth: AuthProvider,
288    /// Optional request timeout duration
289    timeout: Option<Duration>,
290}
291
292impl Videos {
293    /// Creates a new Videos client from the `OPENAI_API_KEY` environment
294    /// variable.
295    pub fn new() -> Result<Self> {
296        let auth = AuthProvider::openai_from_env()?;
297        Ok(Self { auth, timeout: None })
298    }
299
300    /// Creates a new Videos client with a custom authentication provider.
301    pub fn with_auth(auth: AuthProvider) -> Self {
302        Self { auth, timeout: None }
303    }
304
305    /// Creates a new Videos client for Azure OpenAI API.
306    pub fn azure() -> Result<Self> {
307        let auth = AuthProvider::azure_from_env()?;
308        Ok(Self { auth, timeout: None })
309    }
310
311    /// Creates a new Videos client by auto-detecting the provider.
312    pub fn detect_provider() -> Result<Self> {
313        let auth = AuthProvider::from_env()?;
314        Ok(Self { auth, timeout: None })
315    }
316
317    /// Creates a new Videos client with URL-based provider detection.
318    pub fn with_url<S: Into<String>>(base_url: S, api_key: S) -> Self {
319        let auth = AuthProvider::from_url_with_key(base_url, api_key);
320        Self { auth, timeout: None }
321    }
322
323    /// Creates a new Videos client from a URL using environment credentials.
324    pub fn from_url<S: Into<String>>(url: S) -> Result<Self> {
325        let auth = AuthProvider::from_url(url)?;
326        Ok(Self { auth, timeout: None })
327    }
328
329    /// Returns the authentication provider.
330    pub fn auth(&self) -> &AuthProvider {
331        &self.auth
332    }
333
334    /// Sets a custom API endpoint URL (OpenAI only).
335    pub fn base_url<T: AsRef<str>>(&mut self, url: T) -> &mut Self {
336        if let AuthProvider::OpenAI(ref openai_auth) = self.auth {
337            let new_auth = OpenAIAuth::new(openai_auth.api_key()).with_base_url(url.as_ref());
338            self.auth = AuthProvider::OpenAI(new_auth);
339        } else {
340            tracing::warn!("base_url() is only supported for OpenAI provider. Use azure() or with_auth() for Azure.");
341        }
342        self
343    }
344
345    /// Sets the request timeout duration.
346    ///
347    /// Generation jobs are queued asynchronously, so the default (no timeout)
348    /// is usually fine; a longer timeout mainly matters for
349    /// [`content`](Videos::content) downloads.
350    pub fn timeout(&mut self, timeout: Duration) -> &mut Self {
351        self.timeout = Some(timeout);
352        self
353    }
354
355    /// Creates the HTTP client with default headers.
356    fn create_client(&self) -> Result<(request::Client, request::header::HeaderMap)> {
357        let client = create_http_client(self.timeout)?;
358        let mut headers = request::header::HeaderMap::new();
359        self.auth.apply_headers(&mut headers)?;
360        headers.insert("Content-Type", request::header::HeaderValue::from_static("application/json"));
361        headers.insert("User-Agent", request::header::HeaderValue::from_static("openai-tools-rust"));
362        Ok((client, headers))
363    }
364
365    /// Reads a response body, turning API errors into [`OpenAIToolError`].
366    async fn read_json<T: serde::de::DeserializeOwned>(response: request::Response) -> Result<T> {
367        let status = response.status();
368        let content = response.text().await.map_err(OpenAIToolError::RequestError)?;
369
370        if cfg!(test) {
371            tracing::info!("Response content: {}", content);
372        }
373
374        if !status.is_success() {
375            if let Ok(error_resp) = serde_json::from_str::<ErrorResponse>(&content) {
376                return Err(OpenAIToolError::Error(error_resp.error.message.unwrap_or_default()));
377            }
378            return Err(OpenAIToolError::Error(format!("API error ({}): {}", status, content)));
379        }
380
381        serde_json::from_str::<T>(&content).map_err(OpenAIToolError::SerdeJsonError)
382    }
383
384    /// Queues a video generation job.
385    ///
386    /// Returns as soon as the job is accepted - the returned [`Video`] will be
387    /// [`Queued`](crate::videos::response::VideoStatus::Queued), not finished.
388    ///
389    /// # Arguments
390    ///
391    /// * `prompt` - Describes the video to generate
392    /// * `options` - Model, duration, size and reference image
393    ///
394    /// # Example
395    ///
396    /// ```rust,no_run
397    /// use openai_tools::videos::request::{Videos, CreateVideoOptions, VideoSeconds};
398    ///
399    /// #[tokio::main]
400    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
401    ///     let videos = Videos::new()?;
402    ///
403    ///     let options = CreateVideoOptions { seconds: Some(VideoSeconds::Eight), ..Default::default() };
404    ///     let job = videos.create("A red balloon over Tokyo", options).await?;
405    ///     println!("{} -> {:?}", job.id, job.status);
406    ///     Ok(())
407    /// }
408    /// ```
409    pub async fn create<S: Into<String>>(&self, prompt: S, options: CreateVideoOptions) -> Result<Video> {
410        let (client, headers) = self.create_client()?;
411        let url = self.auth.endpoint(VIDEOS_PATH);
412        let body = serde_json::to_string(&options.into_request(prompt))?;
413
414        let response = client.post(&url).headers(headers).body(body).send().await.map_err(OpenAIToolError::RequestError)?;
415
416        Self::read_json(response).await
417    }
418
419    /// Retrieves the current state of a video generation job.
420    ///
421    /// # Arguments
422    ///
423    /// * `video_id` - The video identifier
424    pub async fn retrieve(&self, video_id: &str) -> Result<Video> {
425        let (client, headers) = self.create_client()?;
426        let url = format!("{}/{}", self.auth.endpoint(VIDEOS_PATH), video_id);
427
428        let response = client.get(&url).headers(headers).send().await.map_err(OpenAIToolError::RequestError)?;
429
430        Self::read_json(response).await
431    }
432
433    /// Lists recently generated videos for the current project.
434    ///
435    /// # Arguments
436    ///
437    /// * `limit` - Maximum number of videos to return
438    /// * `after` - Pagination cursor: return items after this video ID
439    /// * `order` - Sort order by creation time
440    pub async fn list(&self, limit: Option<u32>, after: Option<&str>, order: Option<SortOrder>) -> Result<VideoListResponse> {
441        let (client, headers) = self.create_client()?;
442
443        let mut query: Vec<String> = Vec::new();
444        if let Some(limit) = limit {
445            query.push(format!("limit={}", limit));
446        }
447        if let Some(after) = after {
448            query.push(format!("after={}", after));
449        }
450        if let Some(order) = order {
451            query.push(format!("order={}", order.as_str()));
452        }
453
454        let endpoint = self.auth.endpoint(VIDEOS_PATH);
455        let url = if query.is_empty() { endpoint } else { format!("{}?{}", endpoint, query.join("&")) };
456
457        let response = client.get(&url).headers(headers).send().await.map_err(OpenAIToolError::RequestError)?;
458
459        Self::read_json(response).await
460    }
461
462    /// Deletes a completed or failed video along with its assets.
463    ///
464    /// # Arguments
465    ///
466    /// * `video_id` - The video identifier
467    pub async fn delete(&self, video_id: &str) -> Result<DeleteVideoResponse> {
468        let (client, headers) = self.create_client()?;
469        let url = format!("{}/{}", self.auth.endpoint(VIDEOS_PATH), video_id);
470
471        let response = client.delete(&url).headers(headers).send().await.map_err(OpenAIToolError::RequestError)?;
472
473        Self::read_json(response).await
474    }
475
476    /// Downloads the bytes of a generated asset.
477    ///
478    /// # Arguments
479    ///
480    /// * `video_id` - The video identifier
481    /// * `variant` - Which asset to download; defaults to
482    ///   [`VideoVariant::Video`]
483    ///
484    /// # Example
485    ///
486    /// ```rust,no_run
487    /// use openai_tools::videos::request::{Videos, VideoVariant};
488    ///
489    /// #[tokio::main]
490    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
491    ///     let videos = Videos::new()?;
492    ///
493    ///     let mp4 = videos.content("video_abc123", None).await?;
494    ///     std::fs::write("out.mp4", mp4)?;
495    ///
496    ///     let thumbnail = videos.content("video_abc123", Some(VideoVariant::Thumbnail)).await?;
497    ///     std::fs::write("thumb.jpg", thumbnail)?;
498    ///     Ok(())
499    /// }
500    /// ```
501    pub async fn content(&self, video_id: &str, variant: Option<VideoVariant>) -> Result<Vec<u8>> {
502        let (client, headers) = self.create_client()?;
503        let url = match variant {
504            Some(variant) => format!("{}/{}/content?variant={}", self.auth.endpoint(VIDEOS_PATH), video_id, variant.as_str()),
505            None => format!("{}/{}/content", self.auth.endpoint(VIDEOS_PATH), video_id),
506        };
507
508        let response = client.get(&url).headers(headers).send().await.map_err(OpenAIToolError::RequestError)?;
509
510        let status = response.status();
511        if !status.is_success() {
512            let content = response.text().await.map_err(OpenAIToolError::RequestError)?;
513            if let Ok(error_resp) = serde_json::from_str::<ErrorResponse>(&content) {
514                return Err(OpenAIToolError::Error(error_resp.error.message.unwrap_or_default()));
515            }
516            return Err(OpenAIToolError::Error(format!("API error ({}): {}", status, content)));
517        }
518
519        let bytes = response.bytes().await.map_err(OpenAIToolError::RequestError)?;
520        Ok(bytes.to_vec())
521    }
522
523    /// Creates a remix of an existing video using an updated prompt.
524    ///
525    /// # Arguments
526    ///
527    /// * `video_id` - The source video identifier
528    /// * `prompt` - Updated prompt directing the remix
529    pub async fn remix<S: Into<String>>(&self, video_id: &str, prompt: S) -> Result<Video> {
530        let (client, headers) = self.create_client()?;
531        let url = format!("{}/{}/remix", self.auth.endpoint(VIDEOS_PATH), video_id);
532        let body = serde_json::to_string(&RemixVideoRequest { prompt: prompt.into() })?;
533
534        let response = client.post(&url).headers(headers).body(body).send().await.map_err(OpenAIToolError::RequestError)?;
535
536        Self::read_json(response).await
537    }
538}