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