mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-23 01:59:31 -04:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c319482fc7 | ||
|
|
2145e7d458 | ||
|
|
0b98f7cf71 | ||
|
|
611fcb6d9e | ||
|
|
ab6d30b3ad | ||
|
|
a3b7273b1d | ||
|
|
9687a7fb98 | ||
|
|
ef419e5646 | ||
|
|
d3c7145090 | ||
|
|
a9f420cebd | ||
|
|
d2cf59d2be | ||
|
|
cb4294967e | ||
|
|
61f6d1413a | ||
|
|
e9fc9cd40e | ||
|
|
fc28ec9ceb | ||
|
|
cf89c2bd9e | ||
|
|
64e9244169 | ||
|
|
8a3aff877e | ||
|
|
661a394146 | ||
|
|
19b13e6744 | ||
|
|
79650fd950 | ||
|
|
b7dabdd6c7 |
+35
-7
@@ -27,6 +27,32 @@ use tokens::tokens::model_weights::ModelWeightToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
use uuid::uuid;
|
||||
|
||||
pub struct UploadVideoFromFileArgs<'a, P: AsRef<Path>> {
|
||||
pub api_host: &'a ApiHost,
|
||||
pub maybe_creds: Option<&'a StorytellerCredentialSet>,
|
||||
|
||||
// NB: Path needs to be owned for the request.
|
||||
pub path: P,
|
||||
|
||||
// /// If true, we should hide the image from the user's gallery.
|
||||
// pub is_intermediate_system_file: bool,
|
||||
|
||||
/// If provided, this is the prompt that this image is associated with.
|
||||
/// NOTE: Cannot set `is_intermediate_system_file = true` if this is set.
|
||||
pub maybe_prompt_token: Option<&'a PromptToken>,
|
||||
|
||||
// /// If provided, this is the service provider that created the image.
|
||||
// /// NOTE: Cannot set `is_intermediate_system_file = true` if this is set.
|
||||
// pub maybe_generation_provider: Option<GenerationProvider>,
|
||||
|
||||
// /// If provided, this groups the file into a batch
|
||||
// /// TODO: This shouldn't be set clientside without the backend generating the token
|
||||
// /// and cryptographically securing it. But we need to go fast here.
|
||||
// pub maybe_batch_token: Option<&'a BatchGenerationToken>,
|
||||
}
|
||||
|
||||
|
||||
|
||||
// TODO(bt,2025-04-22): Share API definitions between client and server in common crate.
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
@@ -37,12 +63,10 @@ pub struct UploadVideoMediaFileSuccessResponse {
|
||||
|
||||
/// Upload a video media file from a file.
|
||||
pub async fn upload_video_media_file_from_file<P: AsRef<Path>>(
|
||||
api_host: &ApiHost,
|
||||
maybe_creds: Option<&StorytellerCredentialSet>,
|
||||
path: P,
|
||||
args: UploadVideoFromFileArgs<'_, P>,
|
||||
) -> Result<UploadVideoMediaFileSuccessResponse, ApiError> {
|
||||
|
||||
let url = get_route(api_host);
|
||||
let url = get_route(args.api_host);
|
||||
|
||||
debug!("Requesting {:?}", &url);
|
||||
|
||||
@@ -50,16 +74,20 @@ pub async fn upload_video_media_file_from_file<P: AsRef<Path>>(
|
||||
.gzip(true)
|
||||
.build()?;
|
||||
|
||||
let form = Form::new()
|
||||
let mut form = Form::new()
|
||||
.text("uuid_idempotency_token", generate_random_uuid())
|
||||
.file("file", path)
|
||||
.file("file", args.path)
|
||||
.await?;
|
||||
|
||||
if let Some(prompt_token) = &args.maybe_prompt_token {
|
||||
form = form.text("maybe_prompt_token", prompt_token.to_string());
|
||||
}
|
||||
|
||||
let mut request_builder = client.post(url)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.header("Accept", APPLICATION_JSON);
|
||||
|
||||
if let Some(creds) = maybe_creds {
|
||||
if let Some(creds) = args.maybe_creds {
|
||||
if let Some(header) = &creds.maybe_as_cookie_header() {
|
||||
request_builder = request_builder.header("Cookie", header);
|
||||
}
|
||||
|
||||
+7
-2
@@ -1,7 +1,7 @@
|
||||
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
use crate::endpoints::media_files::upload_image_media_file_from_file::{upload_image_media_file_from_file, UploadImageFromFileArgs};
|
||||
use crate::endpoints::media_files::upload_new_engine_asset_from_file::upload_new_engine_asset_from_file;
|
||||
use crate::endpoints::media_files::upload_video_media_file_from_file::upload_video_media_file_from_file;
|
||||
use crate::endpoints::media_files::upload_video_media_file_from_file::{upload_video_media_file_from_file, UploadVideoFromFileArgs};
|
||||
use crate::error::client_error::ClientError;
|
||||
use crate::error::storyteller_error::StorytellerError;
|
||||
use crate::utils::api_host::ApiHost;
|
||||
@@ -42,7 +42,12 @@ pub async fn upload_media_file_from_file<P: AsRef<Path>>(
|
||||
}
|
||||
}
|
||||
Some(FileExtension::Mp4) => {
|
||||
match upload_video_media_file_from_file(api_host, maybe_creds, path).await {
|
||||
match upload_video_media_file_from_file(UploadVideoFromFileArgs {
|
||||
api_host,
|
||||
maybe_creds,
|
||||
path,
|
||||
maybe_prompt_token
|
||||
}).await {
|
||||
Ok(result) => Ok(UploadMediaFileSuccessResponse {
|
||||
success: result.success,
|
||||
media_file_token: result.media_file_token,
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
#[cfg(test)]
|
||||
pub (crate) mod test_utils;
|
||||
|
||||
// Internal lib
|
||||
pub (crate) mod constants;
|
||||
pub (crate) mod utils_internal;
|
||||
|
||||
// User API
|
||||
pub mod creds;
|
||||
pub mod error;
|
||||
pub mod recipes;
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
use crate::creds::sora_credential_set::SoraCredentialSet;
|
||||
use crate::error::sora_error::SoraError;
|
||||
use crate::recipes::utils::maybe_renew_session_jwt::maybe_renew_session_jwt;
|
||||
use crate::requests::generate_sora2_video::generate_sora2_video::{generate_sora2_video, GenerateSora2VideoArgs, GenerateSora2VideoResponse};
|
||||
|
||||
/// Generate Sora 2 video with session auto-renewal.
|
||||
/// If a new sora credential is returned, replace the old one with the new one.
|
||||
pub async fn generate_sora2_video_with_session_auto_renew(
|
||||
args: GenerateSora2VideoArgs<'_>,
|
||||
) -> Result<(GenerateSora2VideoResponse, Option<SoraCredentialSet>), SoraError> {
|
||||
let mut maybe_new_creds = maybe_renew_session_jwt(&args.credentials).await?;
|
||||
|
||||
let use_creds = maybe_new_creds.as_ref()
|
||||
.unwrap_or_else(|| &args.credentials);
|
||||
|
||||
let mut request = args.clone();
|
||||
request.credentials = use_creds;
|
||||
|
||||
let result = generate_sora2_video(request).await;
|
||||
|
||||
match result {
|
||||
Ok(response) => Ok((response, maybe_new_creds)),
|
||||
Err(err) => Err(err), // TODO: Should we retry?
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::recipes::generate_sora2_video::generate_sora2_video_with_session_auto_renew::generate_sora2_video_with_session_auto_renew;
|
||||
use crate::requests::generate_sora2_video::generate_sora2_video::{GenerateSora2VideoArgs, Orientation};
|
||||
use crate::test_utils::get_test_credentials::get_test_credentials;
|
||||
use errors::AnyhowResult;
|
||||
|
||||
#[ignore] // You can manually run "ignore" tests in the IDE, but they won't run in CI.
|
||||
#[tokio::test]
|
||||
pub async fn manual_test() -> AnyhowResult<()> {
|
||||
let creds = get_test_credentials()?;
|
||||
|
||||
let args = GenerateSora2VideoArgs {
|
||||
prompt: "a dog playing tennis",
|
||||
credentials: &creds,
|
||||
request_timeout: None,
|
||||
orientation: Orientation::Landscape,
|
||||
image_reference_media_ids: None,
|
||||
};
|
||||
|
||||
let (result , creds) =
|
||||
generate_sora2_video_with_session_auto_renew(args).await?;
|
||||
|
||||
println!("result: {:#?}", result);
|
||||
assert_eq!(1, 2);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod generate_sora2_video_with_session_auto_renew;
|
||||
+2
-1
@@ -8,7 +8,8 @@ use crate::requests::image_gen::common::{ImageSize, NumImages, SoraImageGenRespo
|
||||
use crate::requests::image_gen::image_gen_http_request;
|
||||
use crate::requests::image_gen::sora_image_gen_remix::{sora_image_gen_remix, SoraImageGenRemixRequest};
|
||||
use crate::requests::upload::upload_media_from_bytes::sora_media_upload_from_bytes;
|
||||
use crate::requests::upload::upload_media_http_request::{upload_media_http_request, SoraMediaUploadResponse};
|
||||
use crate::requests::upload::upload_media_http_request::upload_media_http_request;
|
||||
use crate::requests::upload::upload_media_http_response::SoraMediaUploadResponse;
|
||||
use anyhow::anyhow;
|
||||
use errors::AnyhowResult;
|
||||
use log::{error, info, warn};
|
||||
|
||||
+2
-1
@@ -3,7 +3,8 @@ use crate::error::sora_error::SoraError;
|
||||
use crate::error::sora_specific_api_error::SoraSpecificApiError;
|
||||
use crate::recipes::maybe_refresh_credentials_on_sora_error::maybe_refresh_credentials_on_sora_error;
|
||||
use crate::requests::upload::upload_media_from_file::sora_media_upload_from_file;
|
||||
use crate::requests::upload::upload_media_http_request::{upload_media_http_request, SoraMediaUploadResponse};
|
||||
use crate::requests::upload::upload_media_http_request::upload_media_http_request;
|
||||
use crate::requests::upload::upload_media_http_response::SoraMediaUploadResponse;
|
||||
use log::{info, warn};
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
use crate::creds::sora_credential_set::SoraCredentialSet;
|
||||
use crate::error::sora_error::SoraError;
|
||||
use crate::recipes::image_upload_from_file_with_session_auto_renew::ImageUploadFromFileAutoRenewRequest;
|
||||
use crate::recipes::maybe_refresh_credentials_on_sora_error::maybe_refresh_credentials_on_sora_error;
|
||||
use crate::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session;
|
||||
use crate::requests::common::task_id::TaskId;
|
||||
use crate::requests::deprecated::job_status::sora_job_status::{get_image_gen_status, StatusRequest, TaskResponse, VideoGenStatusResponse};
|
||||
use crate::requests::list_classic_tasks::list_classic_tasks::{list_classic_tasks, ListTasksResponse};
|
||||
use crate::requests::upload::upload_media_from_file::sora_media_upload_from_file;
|
||||
use anyhow::Error;
|
||||
use log::{info, warn};
|
||||
use std::path::Path;
|
||||
|
||||
/// Check Sora task statuses with session auto-renewal.
|
||||
/// If a new sora credential is returned, replace the old one with the new one.
|
||||
pub async fn list_classic_sora_tasks_with_session_auto_renew(
|
||||
credentials: &SoraCredentialSet,
|
||||
) -> Result<(ListTasksResponse, Option<SoraCredentialSet>), SoraError> {
|
||||
|
||||
let mut maybe_new_creds = maybe_renew_session(&credentials).await?;
|
||||
|
||||
let upgraded_creds = maybe_new_creds.as_ref()
|
||||
.unwrap_or_else(|| &credentials);
|
||||
|
||||
let result = list_classic_tasks(&upgraded_creds).await;
|
||||
|
||||
let err = match result {
|
||||
Ok(response) => return Ok((response, maybe_new_creds)),
|
||||
Err(err) => err,
|
||||
};
|
||||
|
||||
warn!("Task status polling failed: {:?}", err);
|
||||
|
||||
// TODO(bt,2025-05-28): This should only be done if the credentials were known to be bad
|
||||
let new_creds = maybe_refresh_credentials_on_sora_error(&upgraded_creds, err).await?;
|
||||
|
||||
// TODO(bt,2025-05-28): This should only be done if the credentials were actually refreshed.
|
||||
info!("Retrying task polling with new credentials...");
|
||||
|
||||
let result = list_classic_tasks(&new_creds).await?;
|
||||
|
||||
Ok((result, Some(new_creds)))
|
||||
}
|
||||
|
||||
async fn maybe_renew_session(creds: &SoraCredentialSet) -> Result<Option<SoraCredentialSet>, SoraError> {
|
||||
if !creds.jwt_bearer_token.is_none() {
|
||||
// TODO: Handle JWT expired case as well.
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
info!("JWT not set. Upgrading credentials...");
|
||||
let mut updated_creds = creds.clone();
|
||||
|
||||
if let Err(err) = maybe_upgrade_or_renew_session(&mut updated_creds).await {
|
||||
warn!("Failed to upgrade or renew session: {:?}", err);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
Ok(Some(updated_creds))
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
use crate::creds::sora_credential_set::SoraCredentialSet;
|
||||
use crate::error::sora_error::SoraError;
|
||||
use crate::recipes::utils::maybe_renew_session_jwt::maybe_renew_session_jwt;
|
||||
use crate::requests::list_sora2_drafts::list_sora2_drafts::{list_sora2_drafts, ListSora2DraftsResponse};
|
||||
|
||||
/// Check Sora 2 drafts with session auto-renewal.
|
||||
/// If a new sora credential is returned, replace the old one with the new one.
|
||||
pub async fn list_sora2_drafts_with_session_auto_renew(
|
||||
credentials: &SoraCredentialSet,
|
||||
) -> Result<(ListSora2DraftsResponse, Option<SoraCredentialSet>), SoraError> {
|
||||
|
||||
let mut maybe_new_creds = maybe_renew_session_jwt(&credentials).await?;
|
||||
|
||||
let use_creds = maybe_new_creds.as_ref()
|
||||
.unwrap_or_else(|| &credentials);
|
||||
|
||||
let result = list_sora2_drafts(&use_creds).await;
|
||||
|
||||
match result {
|
||||
Ok(response) => Ok((response, maybe_new_creds)),
|
||||
Err(err) => Err(err), // TODO: Should we retry?
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::recipes::list_sora2_drafts::list_sora2_drafts_with_session_auto_renew::list_sora2_drafts_with_session_auto_renew;
|
||||
use crate::test_utils::get_test_credentials::get_test_credentials;
|
||||
use errors::AnyhowResult;
|
||||
|
||||
#[ignore] // You can manually run "ignore" tests in the IDE, but they won't run in CI.
|
||||
#[tokio::test]
|
||||
pub async fn manual_test() -> AnyhowResult<()> {
|
||||
let creds = get_test_credentials()?;
|
||||
|
||||
let (result , creds) =
|
||||
list_sora2_drafts_with_session_auto_renew(&creds).await?;
|
||||
|
||||
println!("result: {:#?}", result);
|
||||
assert_eq!(1, 2);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod list_sora2_drafts_with_session_auto_renew;
|
||||
-63
@@ -1,63 +0,0 @@
|
||||
use crate::creds::sora_credential_set::SoraCredentialSet;
|
||||
use crate::error::sora_error::SoraError;
|
||||
use crate::recipes::image_upload_from_file_with_session_auto_renew::ImageUploadFromFileAutoRenewRequest;
|
||||
use crate::recipes::maybe_refresh_credentials_on_sora_error::maybe_refresh_credentials_on_sora_error;
|
||||
use crate::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session;
|
||||
use crate::requests::common::task_id::TaskId;
|
||||
use crate::requests::job_status::sora_job_status::{get_image_gen_status, StatusRequest, TaskResponse, VideoGenStatusResponse};
|
||||
use crate::requests::upload::upload_media_from_file::sora_media_upload_from_file;
|
||||
use crate::requests::upload::upload_media_http_request::SoraMediaUploadResponse;
|
||||
use anyhow::Error;
|
||||
use log::{info, warn};
|
||||
use std::path::Path;
|
||||
|
||||
pub struct StatusRequestArgs {
|
||||
pub limit: Option<u32>,
|
||||
pub before: Option<TaskId>,
|
||||
pub credentials: SoraCredentialSet,
|
||||
}
|
||||
|
||||
/// Check Sora task statuses with session auto-renewal.
|
||||
/// If a new sora credential is returned, replace the old one with the new one.
|
||||
pub async fn list_sora_task_status_with_session_auto_renew(
|
||||
args: StatusRequestArgs,
|
||||
) -> Result<(VideoGenStatusResponse, Option<SoraCredentialSet>), SoraError> {
|
||||
|
||||
let request = StatusRequest {
|
||||
limit: args.limit,
|
||||
before: args.before.map(|t| t.0),
|
||||
};
|
||||
|
||||
let mut new_creds = None;
|
||||
|
||||
if args.credentials.jwt_bearer_token.is_none() {
|
||||
info!("JWT not set. Upgrading credentials...");
|
||||
let mut updated_creds = args.credentials.clone();
|
||||
if let Err(err) = maybe_upgrade_or_renew_session(&mut updated_creds).await {
|
||||
warn!("Failed to upgrade or renew session: {:?}", err);
|
||||
}
|
||||
new_creds = Some(updated_creds);
|
||||
}
|
||||
|
||||
let upgraded_creds = new_creds.as_ref()
|
||||
.unwrap_or_else(|| &args.credentials);
|
||||
|
||||
let result = get_image_gen_status(&request, &upgraded_creds).await;
|
||||
|
||||
let err = match result {
|
||||
Ok(response) => return Ok((response, new_creds)),
|
||||
Err(err) => err,
|
||||
};
|
||||
|
||||
warn!("Task status polling failed: {:?}", err);
|
||||
|
||||
// TODO(bt,2025-05-28): This should only be done if the credentials were known to be bad
|
||||
let new_creds = maybe_refresh_credentials_on_sora_error(&args.credentials, err).await?;
|
||||
|
||||
// TODO(bt,2025-05-28): This should only be done if the credentials were actually refreshed.
|
||||
info!("Retrying task polling with new credentials...");
|
||||
|
||||
let result = get_image_gen_status(&request, &upgraded_creds).await?;
|
||||
|
||||
Ok((result, Some(new_creds)))
|
||||
}
|
||||
@@ -14,6 +14,7 @@ const EXPIRATION_DEADLINE : TimeDelta = TimeDelta::hours(12);
|
||||
/// Call this at session startup to generate the tokens for the first time.
|
||||
/// Call this periodically to refresh the JWT as it nears its expiration date.
|
||||
/// This will not refresh the sentinel token once it's been generated - we can use other flows to accomplish that.
|
||||
#[deprecated(note="The sentinel appears to be going away. Use `maybe_renew_session_jwt` instead for newer '2.0' code.")]
|
||||
pub async fn maybe_upgrade_or_renew_session(sora_credentials: &mut SoraCredentialSet) -> Result<bool, SoraError> {
|
||||
let mut refresh_jwt = !sora_credentials.jwt_bearer_token.is_some();
|
||||
let refresh_sentinel = !sora_credentials.sora_sentinel.is_some();
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
pub mod generate_sora2_video;
|
||||
pub mod image_remix_with_session_auto_renew;
|
||||
pub mod image_upload_from_bytes_with_session_auto_renew;
|
||||
pub mod image_upload_from_file_with_session_auto_renew;
|
||||
pub mod list_sora_task_status_with_session_auto_renew;
|
||||
pub mod list_classic_sora_tasks_with_session_auto_renew;
|
||||
pub mod list_sora2_drafts;
|
||||
pub mod maybe_refresh_credentials_on_sora_error;
|
||||
pub mod maybe_upgrade_or_renew_session;
|
||||
pub mod simple_image_gen_with_session_auto_renew;
|
||||
pub mod utils;
|
||||
pub mod wait_for_image_gen_status;
|
||||
@@ -0,0 +1,48 @@
|
||||
use crate::creds::sora_credential_set::SoraCredentialSet;
|
||||
use crate::creds::sora_jwt_bearer_token::SoraJwtBearerToken;
|
||||
use crate::error::sora_error::SoraError;
|
||||
use crate::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session;
|
||||
use crate::requests::auth_bearer::generate_bearer_jwt_with_cookie::generate_bearer_jwt_with_cookie;
|
||||
use chrono::{TimeDelta, Utc};
|
||||
use log::{info, warn};
|
||||
use std::ops::Sub;
|
||||
|
||||
const EXPIRATION_DEADLINE : TimeDelta = TimeDelta::hours(12);
|
||||
|
||||
/// Returns a new set of credentials if they were updated, or None if no update was needed.
|
||||
pub async fn maybe_renew_session_jwt(creds: &SoraCredentialSet) -> Result<Option<SoraCredentialSet>, SoraError> {
|
||||
let mut refresh_jwt = false;
|
||||
|
||||
match creds.jwt_bearer_token.as_ref() {
|
||||
None => {
|
||||
info!("JWT not set.");
|
||||
refresh_jwt = true;
|
||||
}
|
||||
Some(jwt) => {
|
||||
let now = Utc::now();
|
||||
let refresh_deadline = now.sub(EXPIRATION_DEADLINE);
|
||||
if jwt.jwt_claims().expiration.lt(&now) {
|
||||
info!("JWT expiration time has elapsed.");
|
||||
refresh_jwt = true;
|
||||
} else if jwt.jwt_claims().expiration.lt(&refresh_deadline) {
|
||||
info!("JWT expiration time is under the expiration deadline.");
|
||||
refresh_jwt = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !refresh_jwt {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
info!("Refreshing JWT...");
|
||||
let token = generate_bearer_jwt_with_cookie(creds.cookies.as_str()).await?;
|
||||
|
||||
info!("Parsing JWT bearer token...");
|
||||
let token = SoraJwtBearerToken::new(token)?;
|
||||
|
||||
let mut updated_creds = creds.clone();
|
||||
updated_creds.jwt_bearer_token = Some(token);
|
||||
|
||||
Ok(Some(updated_creds))
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod maybe_renew_session_jwt;
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::creds::sora_credential_set::SoraCredentialSet;
|
||||
use crate::requests::job_status::sora_job_status::{get_image_gen_status, StatusRequest, TaskResponse, TaskStatus, VideoGenStatusResponse};
|
||||
use crate::requests::deprecated::job_status::sora_job_status::{get_image_gen_status, StatusRequest, TaskResponse, TaskStatus, VideoGenStatusResponse};
|
||||
use errors::AnyhowResult;
|
||||
|
||||
pub async fn wait_for_image_gen_status(task_id: &String, creds: &SoraCredentialSet, retry_limit: Option<u32>) -> AnyhowResult<TaskResponse> {
|
||||
|
||||
-1
@@ -2,7 +2,6 @@ use super::request::GenerateSentinelRefreshRequest;
|
||||
use crate::constants::user_agent::USER_AGENT;
|
||||
use crate::error::sora_error::SoraError;
|
||||
use crate::error::sora_generic_api_error::SoraGenericApiError;
|
||||
use crate::requests::list_media::list_media::ListMediaResponse;
|
||||
use crate::utils_internal::classify_general_http_error::classify_general_http_error;
|
||||
use errors::AnyhowResult;
|
||||
use idempotency::uuid::generate_random_uuid;
|
||||
|
||||
@@ -5,6 +5,28 @@ use serde_derive::{Deserialize, Serialize};
|
||||
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize)]
|
||||
pub struct TaskId(pub String);
|
||||
|
||||
impl TaskId {
|
||||
pub fn from_string(id: String) -> Self {
|
||||
Self(id)
|
||||
}
|
||||
|
||||
pub fn from_str(id: &str) -> Self {
|
||||
Self(id.to_string())
|
||||
}
|
||||
|
||||
pub fn new(id: String) -> Self {
|
||||
Self(id)
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn to_string(&self) -> String {
|
||||
self.0.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for TaskId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
std::fmt::Display::fmt(&self.0, f)
|
||||
|
||||
+3
-1
@@ -15,6 +15,8 @@ use std::{fs::File, io::Write, path::Path};
|
||||
use url::Url;
|
||||
use wreq::Client;
|
||||
|
||||
// NB(2025-10-02): The new URL is:
|
||||
// https://sora.chatgpt.com/backend/v2/list_tasks?limit=20
|
||||
const SORA_STATUS_URL: &str = "https://sora.com/backend/video_gen";
|
||||
|
||||
|
||||
@@ -298,7 +300,7 @@ pub async fn save_generations_to_dir(generations: &[Generation], dir: &str) -> A
|
||||
mod tests {
|
||||
use crate::creds::sora_credential_builder::SoraCredentialBuilder;
|
||||
use crate::recipes::wait_for_image_gen_status::wait_for_image_gen_status;
|
||||
use crate::requests::job_status::sora_job_status::{get_image_gen_status, save_generations_to_dir, StatusRequest, VideoGenStatusResponse};
|
||||
use crate::requests::deprecated::job_status::sora_job_status::{get_image_gen_status, save_generations_to_dir, StatusRequest, VideoGenStatusResponse};
|
||||
use errors::AnyhowResult;
|
||||
use std::fs::read_to_string;
|
||||
use testing::test_file_path::test_file_path;
|
||||
+1
-1
@@ -47,7 +47,7 @@ pub async fn unimplemented_sora_job_status(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::creds::sora_credential_builder::SoraCredentialBuilder;
|
||||
use crate::requests::job_status::unimplemented_sora_job_status::unimplemented_sora_job_status;
|
||||
use crate::requests::deprecated::job_status::unimplemented_sora_job_status::unimplemented_sora_job_status;
|
||||
use errors::AnyhowResult;
|
||||
use std::fs::read_to_string;
|
||||
use testing::test_file_path::test_file_path;
|
||||
+2
-2
@@ -2,14 +2,14 @@ use crate::creds::sora_credential_set::SoraCredentialSet;
|
||||
use crate::error::sora_client_error::SoraClientError;
|
||||
use crate::error::sora_error::SoraError;
|
||||
use crate::error::sora_generic_api_error::SoraGenericApiError;
|
||||
use crate::requests::upload::upload_media_http_request::SoraMediaUploadResponse;
|
||||
use crate::utils_internal::classify_general_http_error::classify_general_http_error;
|
||||
use log::{error, info};
|
||||
use once_cell::sync::Lazy;
|
||||
use serde_derive::Deserialize;
|
||||
use wreq::Client;
|
||||
|
||||
const SORA_MEDIA_LIST_URL: &str = "https://sora.chatgpt.com/backend/video_gen?limit=50";
|
||||
//const SORA_MEDIA_LIST_URL: &str = "https://sora.chatgpt.com/backend/video_gen?limit=50";
|
||||
const SORA_MEDIA_LIST_URL : &str = "https://sora.chatgpt.com/backend/v2/list_tasks?limit=20";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListMediaResponse {
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod job_status;
|
||||
pub mod list_media;
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
use crate::constants::user_agent::USER_AGENT;
|
||||
use crate::creds::sora_credential_set::SoraCredentialSet;
|
||||
use crate::error::sora_client_error::SoraClientError;
|
||||
use crate::error::sora_error::SoraError;
|
||||
use crate::error::sora_generic_api_error::SoraGenericApiError;
|
||||
use crate::requests::common::task_id::TaskId;
|
||||
use crate::requests::generate_sora2_video::http_request::{HttpCreateRequest, InpaintItem};
|
||||
use crate::requests::generate_sora2_video::http_response::HttpCreateResponse;
|
||||
use crate::requests::image_gen::image_gen_http_request::{RawSoraImageGenRequest, RawSoraResponse};
|
||||
use log::error;
|
||||
use std::time::Duration;
|
||||
use wreq::header::{ACCEPT, ACCEPT_LANGUAGE, AUTHORIZATION, CONTENT_TYPE, COOKIE, ORIGIN, REFERER};
|
||||
use wreq::Client;
|
||||
|
||||
const GENERATE_SORA_2_VIDEO_URL: &str = "https://sora.chatgpt.com/backend/nf/create";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct GenerateSora2VideoArgs<'a> {
|
||||
pub prompt: &'a str,
|
||||
pub credentials: &'a SoraCredentialSet,
|
||||
pub request_timeout: Option<Duration>,
|
||||
pub orientation: Orientation,
|
||||
pub image_reference_media_ids: Option<&'a Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum Orientation {
|
||||
Portrait,
|
||||
Landscape,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct GenerateSora2VideoResponse {
|
||||
pub task_id: TaskId,
|
||||
}
|
||||
|
||||
pub (crate) async fn generate_sora2_video(
|
||||
args: GenerateSora2VideoArgs<'_>,
|
||||
) -> Result<GenerateSora2VideoResponse, SoraError> {
|
||||
|
||||
let client = Client::new();
|
||||
|
||||
let authorization_header = args.credentials.jwt_bearer_token.as_ref()
|
||||
.ok_or(SoraClientError::NoBearerTokenForRequest)?
|
||||
.to_authorization_header_value();
|
||||
|
||||
//let sentinel = args.credentials.sora_sentinel.as_ref()
|
||||
// .map(|sentinel| sentinel.get_sentinel().to_string())
|
||||
// .ok_or(SoraClientError::NoSentinelTokenForRequest)?;
|
||||
|
||||
let cookie = args.credentials.cookies.to_string();
|
||||
|
||||
// TODO: Make the sec-* headers match the user agent and platform
|
||||
// TODO: No sentinel?
|
||||
// TODO: device id
|
||||
//-H 'oai-device-id: 7c216860-5b73-4e63-983f-142dbcae1809' \
|
||||
let mut http_request = client.post(GENERATE_SORA_2_VIDEO_URL)
|
||||
//.header("OpenAI-Sentinel-Token", &sentinel);
|
||||
.header(ACCEPT, "*/*")
|
||||
.header("priority", "u=1, i")
|
||||
.header(REFERER, "https://sora.chatgpt.com/explore")
|
||||
.header(ORIGIN, "https://sora.chatgpt.com")
|
||||
.header(ACCEPT_LANGUAGE, "en-US,en;q=0.9")
|
||||
.header(wreq::header::USER_AGENT, USER_AGENT)
|
||||
.header(COOKIE, &cookie)
|
||||
.header(AUTHORIZATION, &authorization_header)
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.header("sec-ch-ua", "\"Chromium\";v=\"140\", \"Not=A?Brand\";v=\"24\", \"Google Chrome\";v=\"140\"")
|
||||
.header("sec-ch-ua-mobile", "?0")
|
||||
.header("sec-ch-ua-platform", "macOS")
|
||||
.header("sec-fetch-dest", "empty")
|
||||
.header("sec-fetch-mode", "cors")
|
||||
.header("sec-fetch-site", "same-origin");
|
||||
|
||||
if let Some(timeout) = args.request_timeout {
|
||||
http_request = http_request.timeout(timeout);
|
||||
}
|
||||
|
||||
let orientation = match args.orientation {
|
||||
Orientation::Portrait => "portrait",
|
||||
Orientation::Landscape => "landscape",
|
||||
};
|
||||
|
||||
let inpaint_items;
|
||||
|
||||
if let Some(media_ids) = args.image_reference_media_ids {
|
||||
inpaint_items = media_ids.iter()
|
||||
.map(|item_id| InpaintItem {
|
||||
kind: "upload".to_string(),
|
||||
upload_id: item_id.to_string(),
|
||||
})
|
||||
.collect();
|
||||
} else {
|
||||
inpaint_items = Vec::new();
|
||||
}
|
||||
|
||||
let request_body = HttpCreateRequest {
|
||||
kind: "video".to_string(),
|
||||
prompt: args.prompt.to_string(),
|
||||
title: None,
|
||||
orientation: orientation.to_string(),
|
||||
size: "small".to_string(),
|
||||
n_frames: 300,
|
||||
inpaint_items,
|
||||
cameo_ids: None,
|
||||
cameo_replacements: None,
|
||||
model: "sy_8".to_string(),
|
||||
style_id: None,
|
||||
audio_caption: None,
|
||||
audio_transcript: None,
|
||||
video_caption: None,
|
||||
storyboard_id: None,
|
||||
};
|
||||
|
||||
let http_request = http_request.json(&request_body).build()
|
||||
.map_err(|err| {
|
||||
error!("Error building Sora image generation HTTP request: {:?}", err);
|
||||
SoraClientError::WreqClientError(err)
|
||||
})?;
|
||||
|
||||
let response = client.execute(http_request)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("Error during Sora image generation request: {:?}", err);
|
||||
SoraClientError::WreqClientError(err)
|
||||
})?;
|
||||
|
||||
let status = response.status();
|
||||
|
||||
let response_body = &response.text().await
|
||||
.map_err(|err| {
|
||||
error!("Error reading Sora image generation response body: {:?}", err);
|
||||
SoraClientError::WreqClientError(err)
|
||||
})?;
|
||||
|
||||
if !status.is_success() {
|
||||
error!("Sora image generation request returned an error (code {}) : {:?}", status.as_u16(), response_body);
|
||||
|
||||
return Err(SoraGenericApiError::UncategorizedBadResponseWithStatusAndBody {
|
||||
status_code: status,
|
||||
body: response_body.to_string(),
|
||||
}.into());
|
||||
}
|
||||
|
||||
let response : HttpCreateResponse = serde_json::from_str(response_body)
|
||||
.map_err(|err| SoraGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?;
|
||||
|
||||
Ok(GenerateSora2VideoResponse {
|
||||
task_id: response.id,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::requests::generate_sora2_video::generate_sora2_video::{generate_sora2_video, GenerateSora2VideoArgs, Orientation};
|
||||
use crate::test_utils::get_test_credentials::get_test_credentials;
|
||||
use errors::AnyhowResult;
|
||||
|
||||
#[ignore] // You can manually run "ignore" tests in the IDE, but they won't run in CI.
|
||||
#[tokio::test]
|
||||
pub async fn manual_test() -> AnyhowResult<()> {
|
||||
let creds = get_test_credentials()?;
|
||||
let request = GenerateSora2VideoArgs {
|
||||
prompt: "A cute corgi wearing glasses, sitting on a picnic blanket and reading a book, digital art",
|
||||
credentials: &creds,
|
||||
request_timeout: None,
|
||||
orientation: Orientation::Landscape,
|
||||
image_reference_media_ids: None,
|
||||
};
|
||||
let result = generate_sora2_video(request).await?;
|
||||
println!("result: {:#?}", result);
|
||||
assert_eq!(1, 2);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+121
File diff suppressed because one or more lines are too long
+60
@@ -0,0 +1,60 @@
|
||||
use serde_derive::Serialize;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub (super) struct HttpCreateRequest {
|
||||
// eg. "video"
|
||||
pub kind: String,
|
||||
|
||||
// User prompt
|
||||
pub prompt: String,
|
||||
|
||||
// NB: sent as null
|
||||
pub title: Option<String>,
|
||||
|
||||
// eg. "portrait"
|
||||
pub orientation: String,
|
||||
|
||||
// eg. "small"
|
||||
pub size: String,
|
||||
|
||||
// eg. 300
|
||||
pub n_frames: u16,
|
||||
|
||||
// NB: sent as empty array
|
||||
pub inpaint_items: Vec<InpaintItem>,
|
||||
|
||||
// NB: Sent as null
|
||||
pub cameo_ids: Option<String>,
|
||||
|
||||
// NB: Sent as null
|
||||
pub cameo_replacements: Option<String>,
|
||||
|
||||
// eg. "sy_8"
|
||||
pub model: String,
|
||||
|
||||
// NB: Sent as null
|
||||
pub style_id: Option<String>,
|
||||
|
||||
// NB: Sent as null
|
||||
pub audio_caption: Option<String>,
|
||||
|
||||
// NB: Sent as null
|
||||
pub audio_transcript: Option<String>,
|
||||
|
||||
// NB: Sent as null
|
||||
pub video_caption: Option<String>,
|
||||
|
||||
// NB: Sent as null
|
||||
pub storyboard_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub (super) struct InpaintItem {
|
||||
// eg. "upload"
|
||||
pub kind: String,
|
||||
|
||||
// eg. "media_01abc..."
|
||||
pub upload_id: String,
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
use crate::requests::common::task_id::TaskId;
|
||||
use serde_derive::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub (super) struct HttpCreateResponse {
|
||||
pub id: TaskId,
|
||||
// NB: Ignoring other fields, like "priority".
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod generate_sora2_video;
|
||||
mod http_request;
|
||||
mod http_response;
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
use crate::constants::user_agent::USER_AGENT;
|
||||
use crate::creds::sora_credential_set::SoraCredentialSet;
|
||||
use crate::error::sora_client_error::SoraClientError;
|
||||
use crate::error::sora_error::SoraError;
|
||||
use crate::error::sora_generic_api_error::SoraGenericApiError;
|
||||
use crate::requests::common::task_id::TaskId;
|
||||
use crate::requests::deprecated::job_status::sora_job_status::{Generation, StatusRequest, VideoGenStatusResponse};
|
||||
use crate::utils_internal::classify_general_http_status_code_and_body::classify_general_http_status_code_and_body;
|
||||
use log::{debug, error, warn};
|
||||
use serde_derive::Deserialize;
|
||||
use url::Url;
|
||||
use wreq::Client;
|
||||
|
||||
const SORA_LIST_CLASSIC_TASKS_URL: &str = "https://sora.chatgpt.com/backend/v2/list_tasks?limit=20";
|
||||
|
||||
|
||||
/// NB: We omit fields we're not using to prevent breakage.
|
||||
#[derive(Deserialize, Clone, Debug)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct ListTasksResponse {
|
||||
pub task_responses: Vec<PartialTaskResponse>,
|
||||
pub last_id: Option<String>,
|
||||
pub has_more: bool,
|
||||
}
|
||||
|
||||
/// NB: We omit fields we're not using to prevent breakage.
|
||||
#[derive(Deserialize, Clone, Debug)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct PartialTaskResponse {
|
||||
pub id: TaskId,
|
||||
pub status: TaskStatus,
|
||||
|
||||
/// Text prompt used to generate the media.
|
||||
pub prompt: Option<String>,
|
||||
|
||||
pub generations: Vec<PartialGeneration>,
|
||||
|
||||
//pub user: String,
|
||||
//pub created_at: String,
|
||||
//pub status: String, // eg. "succeeded"
|
||||
//pub progress_pct: Option<f64>,
|
||||
//pub progress_pos_in_queue: Option<i32>,
|
||||
//pub encodings: Vec<Encoding> // NB: These are webp images.
|
||||
// ... lots of other fields ...
|
||||
}
|
||||
|
||||
|
||||
#[derive(Deserialize, Clone, Debug, Eq, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TaskStatus {
|
||||
Queued,
|
||||
Running,
|
||||
Succeeded,
|
||||
Failed,
|
||||
#[serde(untagged)]
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
/// NB: We omit fields we're not using to prevent breakage.
|
||||
#[derive(Deserialize, Clone, Debug)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct PartialGeneration {
|
||||
// A unique ID for the generation, eg. "gen_01abc..."
|
||||
pub id: String,
|
||||
pub url: String,
|
||||
|
||||
//pub id: String,
|
||||
//pub task_id: TaskId,
|
||||
//pub created_at: String,
|
||||
//pub deleted_at: Option<String>,
|
||||
//pub url: String,
|
||||
//pub seed: Option<i64>,
|
||||
//pub can_download: Option<bool>,
|
||||
// ... lots of other fields ...
|
||||
}
|
||||
|
||||
pub async fn list_classic_tasks(credentials: &SoraCredentialSet) -> Result<ListTasksResponse, SoraError> {
|
||||
|
||||
let bearer_header = match credentials.jwt_bearer_token.as_ref() {
|
||||
Some(bearer) => bearer.to_authorization_header_value(),
|
||||
None => {
|
||||
warn!("No JWT bearer token in client - cannot fetch image gen status!");
|
||||
return Err(SoraClientError::NoBearerTokenForRequest.into());
|
||||
},
|
||||
};
|
||||
|
||||
let client = Client::new();
|
||||
|
||||
let mut url = Url::parse(SORA_LIST_CLASSIC_TASKS_URL)
|
||||
.map_err(|err| {
|
||||
SoraClientError::UrlParseError(err)
|
||||
})?;
|
||||
|
||||
|
||||
// TODO: Future pagination.
|
||||
// Add query parameters
|
||||
//if let Some(limit) = status_request.limit {
|
||||
// url.query_pairs_mut().append_pair("limit", &limit.to_string());
|
||||
//}
|
||||
//if let Some(before) = &status_request.before {
|
||||
// url.query_pairs_mut().append_pair("before", &before);
|
||||
//}
|
||||
|
||||
let http_request = client.get(url.as_str())
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.header("Cookie", credentials.cookies.as_str())
|
||||
.header("Authorization", bearer_header)
|
||||
.header("Content-Type", "application/json");
|
||||
|
||||
let http_request = http_request.build()
|
||||
.map_err(|err| {
|
||||
SoraClientError::WreqClientError(err)
|
||||
})?;
|
||||
|
||||
let response = client.execute(http_request)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("Client failed to fetch sora task list: {:?}", err);
|
||||
SoraGenericApiError::WreqError(err)
|
||||
})?;
|
||||
|
||||
let status = response.status();
|
||||
|
||||
let response_body = &response.text()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("Client failed to read sora task list: {:?}", err);
|
||||
SoraGenericApiError::WreqError(err)
|
||||
})?;
|
||||
|
||||
debug!("response_body: {}", response_body);
|
||||
|
||||
if !status.is_success() {
|
||||
error!("The sora task list request failed; status = {:?} ; response body = {}", status, response_body);
|
||||
let error = classify_general_http_status_code_and_body(status, &response_body).await;
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
let response = serde_json::from_str::<ListTasksResponse>(response_body)
|
||||
.map_err(|err| {
|
||||
error!("Failed to parse status response: {:?}", err);
|
||||
SoraGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string())
|
||||
})?;
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::requests::list_classic_tasks::list_classic_tasks::list_classic_tasks;
|
||||
use crate::test_utils::get_test_credentials::get_test_credentials;
|
||||
use errors::AnyhowResult;
|
||||
|
||||
#[ignore] // You can manually run "ignore" tests in the IDE, but they won't run in CI.
|
||||
#[tokio::test]
|
||||
pub async fn manual_test() -> AnyhowResult<()> {
|
||||
let creds = get_test_credentials()?;
|
||||
let result = list_classic_tasks(&creds).await?;
|
||||
println!("result: {:#?}", result);
|
||||
assert_eq!(1, 2);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+578
@@ -0,0 +1,578 @@
|
||||
{
|
||||
"task_responses": [
|
||||
{
|
||||
"id": "task_01k6kj8hjtezvvknx8fbtjf52q",
|
||||
"user": "user-hQ5fc1AV7vNcQLk2yfEFEUbo",
|
||||
"created_at": "2025-10-02T22:56:36.498766Z",
|
||||
"status": "succeeded",
|
||||
"progress_pct": 0.6719999999999999,
|
||||
"progress_pos_in_queue": null,
|
||||
"estimated_queue_wait_time": null,
|
||||
"queue_status_message": null,
|
||||
"priority": 1,
|
||||
"type": "image_gen",
|
||||
"prompt": "A red apple, photorealistic",
|
||||
"n_variants": 1,
|
||||
"n_frames": 1,
|
||||
"height": 480,
|
||||
"width": 854,
|
||||
"model": null,
|
||||
"operation": "remix",
|
||||
"inpaint_items": [
|
||||
{
|
||||
"crop_bounds": null,
|
||||
"type": "image",
|
||||
"preset_id": null,
|
||||
"generation_id": null,
|
||||
"upload_media_id": "media_01k6kj8fmnfvgvezw42kpps72c",
|
||||
"uploaded_file_id": null,
|
||||
"cameo_file_id": null,
|
||||
"frame_index": 0,
|
||||
"source_start_frame": 0,
|
||||
"source_end_frame": 0
|
||||
}
|
||||
],
|
||||
"preset_id": null,
|
||||
"caption": null,
|
||||
"actions": null,
|
||||
"interpolation": null,
|
||||
"sdedit": null,
|
||||
"remix_config": null,
|
||||
"quality": "high",
|
||||
"size": null,
|
||||
"generations": [
|
||||
{
|
||||
"id": "gen_01k6kj8k2hecrbtak79jy42a84",
|
||||
"task_id": "task_01k6kj8hjtezvvknx8fbtjf52q",
|
||||
"created_at": "2025-10-02T22:58:16.317475Z",
|
||||
"deleted_at": null,
|
||||
"url": "https://videos.openai.com/vg-assets/assets%2Ftask_01k6kj8hjtezvvknx8fbtjf52q%2F1759445854_src_0.png?st=2025-10-03T00%3A31%3A34Z&se=2025-10-09T01%3A31%3A34Z&sks=b&skt=2025-10-03T00%3A31%3A34Z&ske=2025-10-09T01%3A31%3A34Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=aa5ddad1-c91a-4f0a-9aca-e20682cc8969&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=FOW7H2ah4Zxf60v%2BBezoJRNP2dlpo%2Bvg0NN4w1Su3Ak%3D&az=oaivgprodscus",
|
||||
"seed": 943768322,
|
||||
"can_download": true,
|
||||
"download_status": "ready",
|
||||
"is_favorite": false,
|
||||
"is_liked": false,
|
||||
"is_public": false,
|
||||
"is_archived": false,
|
||||
"is_featured": null,
|
||||
"featured_countries": [],
|
||||
"has_feedback": false,
|
||||
"like_count": 0,
|
||||
"num_direct_children": 0,
|
||||
"cloudflare_metadata": null,
|
||||
"cf_thumbnail_url": null,
|
||||
"encodings": {
|
||||
"source": {
|
||||
"path": "https://videos.openai.com/vg-assets/assets%2Ftask_01k6kj8hjtezvvknx8fbtjf52q%2F1759445854_img_0.webp?st=2025-10-03T00%3A31%3A34Z&se=2025-10-09T01%3A31%3A34Z&sks=b&skt=2025-10-03T00%3A31%3A34Z&ske=2025-10-09T01%3A31%3A34Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=aa5ddad1-c91a-4f0a-9aca-e20682cc8969&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=mFtRgicMPOBagoDM%2BQ3mjfjoR2z08EfCWZNGYk7iPtk%3D&az=oaivgprodscus",
|
||||
"size": null,
|
||||
"width": null,
|
||||
"height": null,
|
||||
"duration_secs": null,
|
||||
"ssim": null,
|
||||
"az_path": null
|
||||
},
|
||||
"source_c2pa": null,
|
||||
"md": null,
|
||||
"ld": null,
|
||||
"thumbnail": {
|
||||
"path": "https://videos.openai.com/vg-assets/assets%2Ftask_01k6kj8hjtezvvknx8fbtjf52q%2F1759445854_img_0_thumb.webp?st=2025-10-03T00%3A31%3A34Z&se=2025-10-09T01%3A31%3A34Z&sks=b&skt=2025-10-03T00%3A31%3A34Z&ske=2025-10-09T01%3A31%3A34Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=aa5ddad1-c91a-4f0a-9aca-e20682cc8969&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=tEQWUbyX9nZYa%2Fm6Xk98e2gQSdv%2F3wNEstvdwOnAHFA%3D&az=oaivgprodscus",
|
||||
"size": null,
|
||||
"az_path": null
|
||||
},
|
||||
"spritesheet": null,
|
||||
"gif": null,
|
||||
"mp3": null,
|
||||
"source_wm": null,
|
||||
"md_wm": null,
|
||||
"ld_wm": null,
|
||||
"thumbnail_wm": null,
|
||||
"spritesheet_wm": null,
|
||||
"gif_wm": null
|
||||
},
|
||||
"width": 1536,
|
||||
"height": 1024,
|
||||
"n_frames": 1,
|
||||
"prompt": "A red apple, photorealistic",
|
||||
"title": "Red Apple Drawing",
|
||||
"actions": null,
|
||||
"inpaint_items": [
|
||||
{
|
||||
"crop_bounds": null,
|
||||
"type": "image",
|
||||
"preset_id": null,
|
||||
"generation_id": null,
|
||||
"upload_media_id": "media_01k6kj8fmnfvgvezw42kpps72c",
|
||||
"uploaded_file_id": null,
|
||||
"cameo_file_id": null,
|
||||
"frame_index": 0,
|
||||
"source_start_frame": 0,
|
||||
"source_end_frame": 0
|
||||
}
|
||||
],
|
||||
"interpolation": null,
|
||||
"sdedit": null,
|
||||
"operation": "remix",
|
||||
"model": null,
|
||||
"preset_id": null,
|
||||
"user": {
|
||||
"id": "user-hQ5fc1AV7vNcQLk2yfEFEUbo",
|
||||
"username": "videodesign"
|
||||
},
|
||||
"moderation_result": {
|
||||
"type": "passed",
|
||||
"results_by_frame_index": {},
|
||||
"code": null,
|
||||
"is_output_rejection": false,
|
||||
"task_id": "task_01k6kj8hjtezvvknx8fbtjf52q"
|
||||
},
|
||||
"paragen_status": null,
|
||||
"task_type": "image_gen",
|
||||
"remix_config": null,
|
||||
"quality": "high"
|
||||
}
|
||||
],
|
||||
"num_unsafe_generations": 0,
|
||||
"title": "Red Apple Drawing",
|
||||
"moderation_result": {
|
||||
"type": "passed",
|
||||
"results_by_frame_index": {},
|
||||
"code": null,
|
||||
"is_output_rejection": false,
|
||||
"task_id": "task_01k6kj8hjtezvvknx8fbtjf52q"
|
||||
},
|
||||
"failure_reason": null,
|
||||
"needs_user_review": false
|
||||
},
|
||||
{
|
||||
"id": "task_01k6kj4jcbeneszh3zpddtfv4x",
|
||||
"user": "user-hQ5fc1AV7vNcQLk2yfEFEUbo",
|
||||
"created_at": "2025-10-02T22:54:26.240186Z",
|
||||
"status": "succeeded",
|
||||
"progress_pct": 0.99,
|
||||
"progress_pos_in_queue": null,
|
||||
"estimated_queue_wait_time": null,
|
||||
"queue_status_message": null,
|
||||
"priority": 1,
|
||||
"type": "image_gen",
|
||||
"prompt": "A red apple, photorealistic",
|
||||
"n_variants": 1,
|
||||
"n_frames": 1,
|
||||
"height": 480,
|
||||
"width": 854,
|
||||
"model": null,
|
||||
"operation": "remix",
|
||||
"inpaint_items": [
|
||||
{
|
||||
"crop_bounds": null,
|
||||
"type": "image",
|
||||
"preset_id": null,
|
||||
"generation_id": null,
|
||||
"upload_media_id": "media_01k6kj4g05edbbdz3xf8qtcpcb",
|
||||
"uploaded_file_id": null,
|
||||
"cameo_file_id": null,
|
||||
"frame_index": 0,
|
||||
"source_start_frame": 0,
|
||||
"source_end_frame": 0
|
||||
}
|
||||
],
|
||||
"preset_id": null,
|
||||
"caption": null,
|
||||
"actions": null,
|
||||
"interpolation": null,
|
||||
"sdedit": null,
|
||||
"remix_config": null,
|
||||
"quality": "high",
|
||||
"size": null,
|
||||
"generations": [
|
||||
{
|
||||
"id": "gen_01k6kj4kzhed4sfw1z37p4gde1",
|
||||
"task_id": "task_01k6kj4jcbeneszh3zpddtfv4x",
|
||||
"created_at": "2025-10-02T22:56:24.680138Z",
|
||||
"deleted_at": null,
|
||||
"url": "https://videos.openai.com/vg-assets/assets%2Ftask_01k6kj4jcbeneszh3zpddtfv4x%2F1759445784_src_0.png?st=2025-10-03T00%3A31%3A34Z&se=2025-10-09T01%3A31%3A34Z&sks=b&skt=2025-10-03T00%3A31%3A34Z&ske=2025-10-09T01%3A31%3A34Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=aa5ddad1-c91a-4f0a-9aca-e20682cc8969&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=vckhBG1HfUriqlnNy40r4VSRV%2FHUY0Wym7pJbh%2Fp6Dw%3D&az=oaivgprodscus",
|
||||
"seed": 1780261786,
|
||||
"can_download": true,
|
||||
"download_status": "ready",
|
||||
"is_favorite": false,
|
||||
"is_liked": false,
|
||||
"is_public": false,
|
||||
"is_archived": false,
|
||||
"is_featured": null,
|
||||
"featured_countries": [],
|
||||
"has_feedback": false,
|
||||
"like_count": 0,
|
||||
"num_direct_children": 0,
|
||||
"cloudflare_metadata": null,
|
||||
"cf_thumbnail_url": null,
|
||||
"encodings": {
|
||||
"source": {
|
||||
"path": "https://videos.openai.com/vg-assets/assets%2Ftask_01k6kj4jcbeneszh3zpddtfv4x%2F1759445784_img_0.webp?st=2025-10-03T00%3A31%3A34Z&se=2025-10-09T01%3A31%3A34Z&sks=b&skt=2025-10-03T00%3A31%3A34Z&ske=2025-10-09T01%3A31%3A34Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=aa5ddad1-c91a-4f0a-9aca-e20682cc8969&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=LsT9efFRd9EUtYpTpRWkodCnqLRmEDyx1olQpdrKubI%3D&az=oaivgprodscus",
|
||||
"size": null,
|
||||
"width": null,
|
||||
"height": null,
|
||||
"duration_secs": null,
|
||||
"ssim": null,
|
||||
"az_path": null
|
||||
},
|
||||
"source_c2pa": null,
|
||||
"md": null,
|
||||
"ld": null,
|
||||
"thumbnail": {
|
||||
"path": "https://videos.openai.com/vg-assets/assets%2Ftask_01k6kj4jcbeneszh3zpddtfv4x%2F1759445784_img_0_thumb.webp?st=2025-10-03T00%3A31%3A34Z&se=2025-10-09T01%3A31%3A34Z&sks=b&skt=2025-10-03T00%3A31%3A34Z&ske=2025-10-09T01%3A31%3A34Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=aa5ddad1-c91a-4f0a-9aca-e20682cc8969&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=eqqFQnFLYlpYC9FtZCa5uPwXuwPByTPasocenvd0iOE%3D&az=oaivgprodscus",
|
||||
"size": null,
|
||||
"az_path": null
|
||||
},
|
||||
"spritesheet": null,
|
||||
"gif": null,
|
||||
"mp3": null,
|
||||
"source_wm": null,
|
||||
"md_wm": null,
|
||||
"ld_wm": null,
|
||||
"thumbnail_wm": null,
|
||||
"spritesheet_wm": null,
|
||||
"gif_wm": null
|
||||
},
|
||||
"width": 1536,
|
||||
"height": 1024,
|
||||
"n_frames": 1,
|
||||
"prompt": "A red apple, photorealistic",
|
||||
"title": "Red Apple Sketch",
|
||||
"actions": null,
|
||||
"inpaint_items": [
|
||||
{
|
||||
"crop_bounds": null,
|
||||
"type": "image",
|
||||
"preset_id": null,
|
||||
"generation_id": null,
|
||||
"upload_media_id": "media_01k6kj4g05edbbdz3xf8qtcpcb",
|
||||
"uploaded_file_id": null,
|
||||
"cameo_file_id": null,
|
||||
"frame_index": 0,
|
||||
"source_start_frame": 0,
|
||||
"source_end_frame": 0
|
||||
}
|
||||
],
|
||||
"interpolation": null,
|
||||
"sdedit": null,
|
||||
"operation": "remix",
|
||||
"model": null,
|
||||
"preset_id": null,
|
||||
"user": {
|
||||
"id": "user-hQ5fc1AV7vNcQLk2yfEFEUbo",
|
||||
"username": "videodesign"
|
||||
},
|
||||
"moderation_result": {
|
||||
"type": "passed",
|
||||
"results_by_frame_index": {},
|
||||
"code": null,
|
||||
"is_output_rejection": false,
|
||||
"task_id": "task_01k6kj4jcbeneszh3zpddtfv4x"
|
||||
},
|
||||
"paragen_status": null,
|
||||
"task_type": "image_gen",
|
||||
"remix_config": null,
|
||||
"quality": "high"
|
||||
}
|
||||
],
|
||||
"num_unsafe_generations": 0,
|
||||
"title": "Red Apple Sketch",
|
||||
"moderation_result": {
|
||||
"type": "passed",
|
||||
"results_by_frame_index": {},
|
||||
"code": null,
|
||||
"is_output_rejection": false,
|
||||
"task_id": "task_01k6kj4jcbeneszh3zpddtfv4x"
|
||||
},
|
||||
"failure_reason": null,
|
||||
"needs_user_review": false
|
||||
},
|
||||
{
|
||||
"id": "task_01k6hq8txzeykrhs0p1a5xp4n4",
|
||||
"user": "user-hQ5fc1AV7vNcQLk2yfEFEUbo",
|
||||
"created_at": "2025-10-02T05:45:40.034438Z",
|
||||
"status": "succeeded",
|
||||
"progress_pct": 0.99,
|
||||
"progress_pos_in_queue": null,
|
||||
"estimated_queue_wait_time": null,
|
||||
"queue_status_message": null,
|
||||
"priority": 1,
|
||||
"type": "image_gen",
|
||||
"prompt": "a mountain range with a river in front, make it super photorealistic, daytime",
|
||||
"n_variants": 1,
|
||||
"n_frames": 1,
|
||||
"height": 480,
|
||||
"width": 854,
|
||||
"model": null,
|
||||
"operation": "remix",
|
||||
"inpaint_items": [
|
||||
{
|
||||
"crop_bounds": null,
|
||||
"type": "image",
|
||||
"preset_id": null,
|
||||
"generation_id": null,
|
||||
"upload_media_id": "media_01k6hq8stqfv8t28v8mjzh2b6t",
|
||||
"uploaded_file_id": null,
|
||||
"cameo_file_id": null,
|
||||
"frame_index": 0,
|
||||
"source_start_frame": 0,
|
||||
"source_end_frame": 0
|
||||
}
|
||||
],
|
||||
"preset_id": null,
|
||||
"caption": null,
|
||||
"actions": null,
|
||||
"interpolation": null,
|
||||
"sdedit": null,
|
||||
"remix_config": null,
|
||||
"quality": "high",
|
||||
"size": null,
|
||||
"generations": [
|
||||
{
|
||||
"id": "gen_01k6hq8w1ke6ws0tz0wwq6rm3t",
|
||||
"task_id": "task_01k6hq8txzeykrhs0p1a5xp4n4",
|
||||
"created_at": "2025-10-02T05:47:48.834759Z",
|
||||
"deleted_at": null,
|
||||
"url": "https://videos.openai.com/vg-assets/assets%2Ftask_01k6hq8txzeykrhs0p1a5xp4n4%2F1759384068_src_0.png?st=2025-10-03T00%3A31%3A34Z&se=2025-10-09T01%3A31%3A34Z&sks=b&skt=2025-10-03T00%3A31%3A34Z&ske=2025-10-09T01%3A31%3A34Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=aa5ddad1-c91a-4f0a-9aca-e20682cc8969&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=Lj%2FKVs5VTd97lxtMDcLbrhdJjtCadkz0xL7ls3s%2BoPw%3D&az=oaivgprodscus",
|
||||
"seed": 1780309707,
|
||||
"can_download": true,
|
||||
"download_status": "ready",
|
||||
"is_favorite": false,
|
||||
"is_liked": false,
|
||||
"is_public": false,
|
||||
"is_archived": false,
|
||||
"is_featured": null,
|
||||
"featured_countries": [],
|
||||
"has_feedback": false,
|
||||
"like_count": 0,
|
||||
"num_direct_children": 0,
|
||||
"cloudflare_metadata": null,
|
||||
"cf_thumbnail_url": null,
|
||||
"encodings": {
|
||||
"source": {
|
||||
"path": "https://videos.openai.com/vg-assets/assets%2Ftask_01k6hq8txzeykrhs0p1a5xp4n4%2F1759384068_img_0.webp?st=2025-10-03T00%3A31%3A34Z&se=2025-10-09T01%3A31%3A34Z&sks=b&skt=2025-10-03T00%3A31%3A34Z&ske=2025-10-09T01%3A31%3A34Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=aa5ddad1-c91a-4f0a-9aca-e20682cc8969&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=K64t43YO0em%2FDyLD%2B68bRYJnUlM%2FwjYewUA75Q0eIlE%3D&az=oaivgprodscus",
|
||||
"size": null,
|
||||
"width": null,
|
||||
"height": null,
|
||||
"duration_secs": null,
|
||||
"ssim": null,
|
||||
"az_path": null
|
||||
},
|
||||
"source_c2pa": null,
|
||||
"md": null,
|
||||
"ld": null,
|
||||
"thumbnail": {
|
||||
"path": "https://videos.openai.com/vg-assets/assets%2Ftask_01k6hq8txzeykrhs0p1a5xp4n4%2F1759384068_img_0_thumb.webp?st=2025-10-03T00%3A31%3A34Z&se=2025-10-09T01%3A31%3A34Z&sks=b&skt=2025-10-03T00%3A31%3A34Z&ske=2025-10-09T01%3A31%3A34Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=aa5ddad1-c91a-4f0a-9aca-e20682cc8969&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=aO%2Fp0356BoyZgkZ8CoQu%2ByHONO8VjXS%2Bvj%2F%2FCxxvo10%3D&az=oaivgprodscus",
|
||||
"size": null,
|
||||
"az_path": null
|
||||
},
|
||||
"spritesheet": null,
|
||||
"gif": null,
|
||||
"mp3": null,
|
||||
"source_wm": null,
|
||||
"md_wm": null,
|
||||
"ld_wm": null,
|
||||
"thumbnail_wm": null,
|
||||
"spritesheet_wm": null,
|
||||
"gif_wm": null
|
||||
},
|
||||
"width": 1536,
|
||||
"height": 1024,
|
||||
"n_frames": 1,
|
||||
"prompt": "a mountain range with a river in front, make it super photorealistic, daytime",
|
||||
"title": "Photorealistic Mountains and River",
|
||||
"actions": null,
|
||||
"inpaint_items": [
|
||||
{
|
||||
"crop_bounds": null,
|
||||
"type": "image",
|
||||
"preset_id": null,
|
||||
"generation_id": null,
|
||||
"upload_media_id": "media_01k6hq8stqfv8t28v8mjzh2b6t",
|
||||
"uploaded_file_id": null,
|
||||
"cameo_file_id": null,
|
||||
"frame_index": 0,
|
||||
"source_start_frame": 0,
|
||||
"source_end_frame": 0
|
||||
}
|
||||
],
|
||||
"interpolation": null,
|
||||
"sdedit": null,
|
||||
"operation": "remix",
|
||||
"model": null,
|
||||
"preset_id": null,
|
||||
"user": {
|
||||
"id": "user-hQ5fc1AV7vNcQLk2yfEFEUbo",
|
||||
"username": "videodesign"
|
||||
},
|
||||
"moderation_result": {
|
||||
"type": "passed",
|
||||
"results_by_frame_index": {},
|
||||
"code": null,
|
||||
"is_output_rejection": false,
|
||||
"task_id": "task_01k6hq8txzeykrhs0p1a5xp4n4"
|
||||
},
|
||||
"paragen_status": null,
|
||||
"task_type": "image_gen",
|
||||
"remix_config": null,
|
||||
"quality": "high"
|
||||
}
|
||||
],
|
||||
"num_unsafe_generations": 0,
|
||||
"title": "Photorealistic Mountains and River",
|
||||
"moderation_result": {
|
||||
"type": "passed",
|
||||
"results_by_frame_index": {},
|
||||
"code": null,
|
||||
"is_output_rejection": false,
|
||||
"task_id": "task_01k6hq8txzeykrhs0p1a5xp4n4"
|
||||
},
|
||||
"failure_reason": null,
|
||||
"needs_user_review": false
|
||||
},
|
||||
{
|
||||
"id": "task_01k6hp7tz7fkabg0vyshdnxzvx",
|
||||
"user": "user-hQ5fc1AV7vNcQLk2yfEFEUbo",
|
||||
"created_at": "2025-10-02T05:27:38.730048Z",
|
||||
"status": "succeeded",
|
||||
"progress_pct": 0.6649999999999999,
|
||||
"progress_pos_in_queue": null,
|
||||
"estimated_queue_wait_time": null,
|
||||
"queue_status_message": null,
|
||||
"priority": 1,
|
||||
"type": "image_gen",
|
||||
"prompt": "A green apple",
|
||||
"n_variants": 1,
|
||||
"n_frames": 1,
|
||||
"height": 480,
|
||||
"width": 854,
|
||||
"model": null,
|
||||
"operation": "remix",
|
||||
"inpaint_items": [
|
||||
{
|
||||
"crop_bounds": null,
|
||||
"type": "image",
|
||||
"preset_id": null,
|
||||
"generation_id": null,
|
||||
"upload_media_id": "media_01k6hp7t8bewa904zx1t9j743e",
|
||||
"uploaded_file_id": null,
|
||||
"cameo_file_id": null,
|
||||
"frame_index": 0,
|
||||
"source_start_frame": 0,
|
||||
"source_end_frame": 0
|
||||
}
|
||||
],
|
||||
"preset_id": null,
|
||||
"caption": null,
|
||||
"actions": null,
|
||||
"interpolation": null,
|
||||
"sdedit": null,
|
||||
"remix_config": null,
|
||||
"quality": "high",
|
||||
"size": null,
|
||||
"generations": [
|
||||
{
|
||||
"id": "gen_01k6hp7w03fxzab19j0kzdx01w",
|
||||
"task_id": "task_01k6hp7tz7fkabg0vyshdnxzvx",
|
||||
"created_at": "2025-10-02T05:28:39.448076Z",
|
||||
"deleted_at": null,
|
||||
"url": "https://videos.openai.com/vg-assets/assets%2Ftask_01k6hp7tz7fkabg0vyshdnxzvx%2F1759382919_src_0.png?st=2025-10-03T00%3A31%3A34Z&se=2025-10-09T01%3A31%3A34Z&sks=b&skt=2025-10-03T00%3A31%3A34Z&ske=2025-10-09T01%3A31%3A34Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=aa5ddad1-c91a-4f0a-9aca-e20682cc8969&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=Z6UufywnbsRnyKwFaIfhY8SXsrySCcrV3DUQj7tXc08%3D&az=oaivgprodscus",
|
||||
"seed": 1021399906,
|
||||
"can_download": true,
|
||||
"download_status": "ready",
|
||||
"is_favorite": false,
|
||||
"is_liked": false,
|
||||
"is_public": false,
|
||||
"is_archived": false,
|
||||
"is_featured": null,
|
||||
"featured_countries": [],
|
||||
"has_feedback": false,
|
||||
"like_count": 0,
|
||||
"num_direct_children": 0,
|
||||
"cloudflare_metadata": null,
|
||||
"cf_thumbnail_url": null,
|
||||
"encodings": {
|
||||
"source": {
|
||||
"path": "https://videos.openai.com/vg-assets/assets%2Ftask_01k6hp7tz7fkabg0vyshdnxzvx%2F1759382919_img_0.webp?st=2025-10-03T00%3A31%3A34Z&se=2025-10-09T01%3A31%3A34Z&sks=b&skt=2025-10-03T00%3A31%3A34Z&ske=2025-10-09T01%3A31%3A34Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=aa5ddad1-c91a-4f0a-9aca-e20682cc8969&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=HWeQrcRFn7iG3Cj%2BxkXW0nmQVdb%2BUjxNxz7%2Fnd1Q4Hw%3D&az=oaivgprodscus",
|
||||
"size": null,
|
||||
"width": null,
|
||||
"height": null,
|
||||
"duration_secs": null,
|
||||
"ssim": null,
|
||||
"az_path": null
|
||||
},
|
||||
"source_c2pa": null,
|
||||
"md": null,
|
||||
"ld": null,
|
||||
"thumbnail": {
|
||||
"path": "https://videos.openai.com/vg-assets/assets%2Ftask_01k6hp7tz7fkabg0vyshdnxzvx%2F1759382919_img_0_thumb.webp?st=2025-10-03T00%3A31%3A34Z&se=2025-10-09T01%3A31%3A34Z&sks=b&skt=2025-10-03T00%3A31%3A34Z&ske=2025-10-09T01%3A31%3A34Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=aa5ddad1-c91a-4f0a-9aca-e20682cc8969&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=lMPxMYQ%2FXkUXr7qCGUF4iquf8%2Bj5FUeuQhvuKbfXckw%3D&az=oaivgprodscus",
|
||||
"size": null,
|
||||
"az_path": null
|
||||
},
|
||||
"spritesheet": null,
|
||||
"gif": null,
|
||||
"mp3": null,
|
||||
"source_wm": null,
|
||||
"md_wm": null,
|
||||
"ld_wm": null,
|
||||
"thumbnail_wm": null,
|
||||
"spritesheet_wm": null,
|
||||
"gif_wm": null
|
||||
},
|
||||
"width": 1536,
|
||||
"height": 1024,
|
||||
"n_frames": 1,
|
||||
"prompt": "A green apple",
|
||||
"title": "Green Apple Drawing",
|
||||
"actions": null,
|
||||
"inpaint_items": [
|
||||
{
|
||||
"crop_bounds": null,
|
||||
"type": "image",
|
||||
"preset_id": null,
|
||||
"generation_id": null,
|
||||
"upload_media_id": "media_01k6hp7t8bewa904zx1t9j743e",
|
||||
"uploaded_file_id": null,
|
||||
"cameo_file_id": null,
|
||||
"frame_index": 0,
|
||||
"source_start_frame": 0,
|
||||
"source_end_frame": 0
|
||||
}
|
||||
],
|
||||
"interpolation": null,
|
||||
"sdedit": null,
|
||||
"operation": "remix",
|
||||
"model": null,
|
||||
"preset_id": null,
|
||||
"user": {
|
||||
"id": "user-hQ5fc1AV7vNcQLk2yfEFEUbo",
|
||||
"username": "videodesign"
|
||||
},
|
||||
"moderation_result": {
|
||||
"type": "passed",
|
||||
"results_by_frame_index": {},
|
||||
"code": null,
|
||||
"is_output_rejection": false,
|
||||
"task_id": "task_01k6hp7tz7fkabg0vyshdnxzvx"
|
||||
},
|
||||
"paragen_status": null,
|
||||
"task_type": "image_gen",
|
||||
"remix_config": null,
|
||||
"quality": "high"
|
||||
}
|
||||
],
|
||||
"num_unsafe_generations": 0,
|
||||
"title": "Green Apple Drawing",
|
||||
"moderation_result": {
|
||||
"type": "passed",
|
||||
"results_by_frame_index": {},
|
||||
"code": null,
|
||||
"is_output_rejection": false,
|
||||
"task_id": "task_01k6hp7tz7fkabg0vyshdnxzvx"
|
||||
},
|
||||
"failure_reason": null,
|
||||
"needs_user_review": false
|
||||
}
|
||||
],
|
||||
"last_id": null,
|
||||
"has_more": false
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod list_classic_tasks;
|
||||
@@ -0,0 +1,57 @@
|
||||
use serde_derive::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub (super) struct HttpDraftsResponse {
|
||||
pub items: Vec<PartialItem>,
|
||||
|
||||
// NB: This is nullable.
|
||||
// It's null in my tests, so I don't know if this is a string or integer yet. Probably an opaque string?
|
||||
//pub cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub (super) struct PartialItem {
|
||||
/// The generation id, eg. "gen_01abc..."
|
||||
pub id: String,
|
||||
|
||||
/// The parent task id, eg "task_01abc..."
|
||||
pub task_id: String,
|
||||
|
||||
//pub generation_id: String, // it looks like this matches "id" for now
|
||||
|
||||
/// The kind of draft
|
||||
pub kind: DraftKind,
|
||||
|
||||
// The text prompt
|
||||
pub prompt: String,
|
||||
|
||||
/// URL to the generation
|
||||
/// This will not be present if the request failed, eg. for content reasons.
|
||||
pub url: Option<String>,
|
||||
|
||||
/// "downloadable" URL to the generation
|
||||
/// it looks like this matches "url" for now
|
||||
/// This will not be present if the request failed, eg. for content reasons.
|
||||
pub downloadable_url: Option<String>,
|
||||
|
||||
/// If there was a content violation, this will describe why.
|
||||
pub reason_str: Option<String>,
|
||||
|
||||
// There are other things that might be interesting: "encodings", "width", "height", etc.
|
||||
}
|
||||
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DraftKind {
|
||||
/// Generated video
|
||||
SoraDraft,
|
||||
|
||||
/// Video was blocked due to content
|
||||
/// These failures will have different "reason_str" reasons.
|
||||
SoraContentViolation,
|
||||
|
||||
/// Unknown type.
|
||||
#[serde(untagged)]
|
||||
Unknown(String),
|
||||
}
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
use crate::constants::user_agent::USER_AGENT;
|
||||
use crate::creds::sora_credential_set::SoraCredentialSet;
|
||||
use crate::error::sora_client_error::SoraClientError;
|
||||
use crate::error::sora_error::SoraError;
|
||||
use crate::error::sora_generic_api_error::SoraGenericApiError;
|
||||
use crate::requests::common::task_id::TaskId;
|
||||
use crate::requests::deprecated::job_status::sora_job_status::{Generation, StatusRequest, VideoGenStatusResponse};
|
||||
use crate::requests::list_sora2_drafts::http_response::{DraftKind, HttpDraftsResponse};
|
||||
use crate::utils_internal::classify_general_http_status_code_and_body::classify_general_http_status_code_and_body;
|
||||
use log::{debug, error, info, warn};
|
||||
use serde_derive::Deserialize;
|
||||
use url::Url;
|
||||
use wreq::header::{ACCEPT, ACCEPT_LANGUAGE, AUTHORIZATION, CONTENT_TYPE, COOKIE, ORIGIN, REFERER};
|
||||
use wreq::Client;
|
||||
|
||||
const SORA_LIST_DRAFTS_URL: &str = "https://sora.chatgpt.com/backend/project_y/profile/drafts?limit=15";
|
||||
|
||||
|
||||
/// NB: We omit fields we're not using to prevent breakage.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ListSora2DraftsResponse {
|
||||
pub drafts: Vec<Draft>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Draft {
|
||||
Success(DraftSuccess),
|
||||
Failure(DraftFailure),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DraftSuccess {
|
||||
pub id: String,
|
||||
pub task_id: TaskId,
|
||||
pub url: String,
|
||||
pub prompt: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DraftFailure {
|
||||
pub task_id: TaskId,
|
||||
pub reason_message: String,
|
||||
}
|
||||
|
||||
pub async fn list_sora2_drafts(credentials: &SoraCredentialSet) -> Result<ListSora2DraftsResponse, SoraError> {
|
||||
|
||||
let client = Client::new();
|
||||
|
||||
let authorization_header = credentials.jwt_bearer_token.as_ref()
|
||||
.ok_or(SoraClientError::NoBearerTokenForRequest)?
|
||||
.to_authorization_header_value();
|
||||
|
||||
let cookie = credentials.cookies.to_string();
|
||||
|
||||
// TODO: Make the sec-* headers match the user agent and platform
|
||||
// TODO: device id
|
||||
//-H 'oai-device-id: 7c216860-5b73-4e63-983f-142dbcae1809' \
|
||||
let mut http_request = client.get(SORA_LIST_DRAFTS_URL)
|
||||
//.header("OpenAI-Sentinel-Token", &sentinel);
|
||||
.header(ACCEPT, "*/*")
|
||||
.header(REFERER, "https://sora.chatgpt.com/drafts")
|
||||
.header(ACCEPT_LANGUAGE, "en-US,en;q=0.9")
|
||||
.header(wreq::header::USER_AGENT, USER_AGENT)
|
||||
.header(COOKIE, &cookie)
|
||||
.header(AUTHORIZATION, &authorization_header)
|
||||
.header("priority", "u=1, i")
|
||||
.header("sec-ch-ua", "\"Chromium\";v=\"140\", \"Not=A?Brand\";v=\"24\", \"Google Chrome\";v=\"140\"")
|
||||
.header("sec-ch-ua-mobile", "?0")
|
||||
.header("sec-ch-ua-platform", "macOS")
|
||||
.header("sec-fetch-dest", "empty")
|
||||
.header("sec-fetch-mode", "cors")
|
||||
.header("sec-fetch-site", "same-origin");
|
||||
|
||||
//if let Some(timeout) = args.request_timeout {
|
||||
// http_request = http_request.timeout(timeout);
|
||||
//}
|
||||
|
||||
let http_request = http_request.build()
|
||||
.map_err(|err| {
|
||||
error!("Error building Sora 2 list drafts HTTP request: {:?}", err);
|
||||
SoraClientError::WreqClientError(err)
|
||||
})?;
|
||||
|
||||
let response = client.execute(http_request)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("Error during Sora 2 list drafts request: {:?}", err);
|
||||
SoraClientError::WreqClientError(err)
|
||||
})?;
|
||||
|
||||
let status = response.status();
|
||||
|
||||
let response_body = &response.text().await
|
||||
.map_err(|err| {
|
||||
error!("Error reading Sora 2 list drafts response body: {:?}", err);
|
||||
SoraClientError::WreqClientError(err)
|
||||
})?;
|
||||
|
||||
if !status.is_success() {
|
||||
error!("Sora list drafts request returned an error (code {}) : {:?}", status.as_u16(), response_body);
|
||||
|
||||
// TODO: Categorize failures.
|
||||
// error!("The sora task list request failed; status = {:?} ; response body = {}", status, response_body);
|
||||
// let error = classify_general_http_status_code_and_body(status, &response_body).await;
|
||||
// return Err(error);
|
||||
|
||||
return Err(SoraGenericApiError::UncategorizedBadResponseWithStatusAndBody {
|
||||
status_code: status,
|
||||
body: response_body.to_string(),
|
||||
}.into());
|
||||
}
|
||||
|
||||
let response : HttpDraftsResponse = serde_json::from_str(response_body)
|
||||
.map_err(|err| SoraGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?;
|
||||
|
||||
Ok(ListSora2DraftsResponse {
|
||||
drafts: response.items
|
||||
.into_iter()
|
||||
.map(|draft| {
|
||||
let task_id = TaskId::from_string(draft.task_id);
|
||||
match draft.kind {
|
||||
DraftKind::SoraDraft => {
|
||||
match draft.url {
|
||||
Some(url) => {
|
||||
// Success case
|
||||
Draft::Success(DraftSuccess {
|
||||
id: draft.id,
|
||||
task_id,
|
||||
url,
|
||||
prompt: draft.prompt,
|
||||
})
|
||||
}
|
||||
None => {
|
||||
return Draft::Failure(DraftFailure {
|
||||
task_id,
|
||||
reason_message: "Generation failed. URL missing from the draft.".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
DraftKind::SoraContentViolation => {
|
||||
Draft::Failure(DraftFailure {
|
||||
task_id,
|
||||
reason_message: format!(
|
||||
"Generation failed due to content violation: {}",
|
||||
draft.reason_str
|
||||
.unwrap_or_else(|| "no additional reason".to_string())),
|
||||
})
|
||||
}
|
||||
DraftKind::Unknown(value) => {
|
||||
Draft::Failure(DraftFailure {
|
||||
task_id,
|
||||
reason_message: format!(
|
||||
"Generation failed with unknown failure code: {}. Possible message: {}",
|
||||
value,
|
||||
draft.reason_str
|
||||
.unwrap_or_else(|| "no additional message".to_string())),
|
||||
})
|
||||
}
|
||||
}
|
||||
}).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::requests::list_sora2_drafts::list_sora2_drafts::list_sora2_drafts;
|
||||
use crate::test_utils::get_test_credentials::get_test_credentials;
|
||||
use errors::AnyhowResult;
|
||||
|
||||
#[ignore] // You can manually run "ignore" tests in the IDE, but they won't run in CI.
|
||||
#[tokio::test]
|
||||
pub async fn manual_test() -> AnyhowResult<()> {
|
||||
let creds = get_test_credentials()?;
|
||||
let result = list_sora2_drafts(&creds).await?;
|
||||
println!("result: {:#?}", result);
|
||||
assert_eq!(1, 2);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+16
File diff suppressed because one or more lines are too long
+233
@@ -0,0 +1,233 @@
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "gen_01k6nkg3z3ebxrqd1313hj9a87",
|
||||
"kind": "sora_draft",
|
||||
"url": "https://videos.openai.com/vg-assets/assets%2Ftask_01k6nkbsdvervv5dsfnnpr27jw%2Ftask_01k6nkbsdvervv5dsfnnpr27jw_genid_517dfb8c-cdaa-42e4-9fd4-98743399b160_25_10_03_17_56_604392%2Fvideos%2F00000_wm%2Fsrc.mp4?st=2025-10-03T16%3A53%3A19Z&se=2025-10-09T17%3A53%3A19Z&sks=b&skt=2025-10-03T16%3A53%3A19Z&ske=2025-10-09T17%3A53%3A19Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=aa5ddad1-c91a-4f0a-9aca-e20682cc8969&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=f8BZNAqfO%2B8C6RzIpm66I5rQMH7cSgUfpkVP5ku0Q0U%3D&az=oaivgprodscus",
|
||||
"downloadable_url": "https://videos.openai.com/vg-assets/assets%2Ftask_01k6nkbsdvervv5dsfnnpr27jw%2Ftask_01k6nkbsdvervv5dsfnnpr27jw_genid_517dfb8c-cdaa-42e4-9fd4-98743399b160_25_10_03_17_56_604392%2Fvideos%2F00000_wm%2Fsrc.mp4?st=2025-10-03T16%3A53%3A19Z&se=2025-10-09T17%3A53%3A19Z&sks=b&skt=2025-10-03T16%3A53%3A19Z&ske=2025-10-09T17%3A53%3A19Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=aa5ddad1-c91a-4f0a-9aca-e20682cc8969&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=f8BZNAqfO%2B8C6RzIpm66I5rQMH7cSgUfpkVP5ku0Q0U%3D&az=oaivgprodscus",
|
||||
"width": 352,
|
||||
"height": 640,
|
||||
"created_at": 1759514220.244053,
|
||||
"prompt": "SpongeBob buys Ozemspic for Patrick. Patrick is offended. ",
|
||||
"title": null,
|
||||
"encodings": {
|
||||
"source": {
|
||||
"path": "https://videos.openai.com/vg-assets/assets%2Ftask_01k6nkbsdvervv5dsfnnpr27jw%2Ftask_01k6nkbsdvervv5dsfnnpr27jw_genid_517dfb8c-cdaa-42e4-9fd4-98743399b160_25_10_03_17_56_604392%2Fvideos%2F00000_wm%2Fsrc.mp4?st=2025-10-03T16%3A53%3A19Z&se=2025-10-09T17%3A53%3A19Z&sks=b&skt=2025-10-03T16%3A53%3A19Z&ske=2025-10-09T17%3A53%3A19Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=aa5ddad1-c91a-4f0a-9aca-e20682cc8969&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=f8BZNAqfO%2B8C6RzIpm66I5rQMH7cSgUfpkVP5ku0Q0U%3D&az=oaivgprodscus"
|
||||
},
|
||||
"source_wm": {
|
||||
"path": "https://videos.openai.com/vg-assets/assets%2Ftask_01k6nkbsdvervv5dsfnnpr27jw%2Ftask_01k6nkbsdvervv5dsfnnpr27jw_genid_517dfb8c-cdaa-42e4-9fd4-98743399b160_25_10_03_17_56_604392%2Fvideos%2F00000_wm%2Fsrc.mp4?st=2025-10-03T16%3A53%3A19Z&se=2025-10-09T17%3A53%3A19Z&sks=b&skt=2025-10-03T16%3A53%3A19Z&ske=2025-10-09T17%3A53%3A19Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=aa5ddad1-c91a-4f0a-9aca-e20682cc8969&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=f8BZNAqfO%2B8C6RzIpm66I5rQMH7cSgUfpkVP5ku0Q0U%3D&az=oaivgprodscus"
|
||||
},
|
||||
"thumbnail": {
|
||||
"path": "https://videos.openai.com/vg-assets/assets%2Ftask_01k6nkbsdvervv5dsfnnpr27jw%2Ftask_01k6nkbsdvervv5dsfnnpr27jw_genid_517dfb8c-cdaa-42e4-9fd4-98743399b160_25_10_03_17_56_604392%2Fvideos%2F00000_wm%2Fthumbnail.webp?st=2025-10-03T16%3A53%3A19Z&se=2025-10-09T17%3A53%3A19Z&sks=b&skt=2025-10-03T16%3A53%3A19Z&ske=2025-10-09T17%3A53%3A19Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=aa5ddad1-c91a-4f0a-9aca-e20682cc8969&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=FmCDGzqvDYBOOEHrVP6P8S6Te%2FNXhySDf%2Bbp8aBf5Tw%3D&az=oaivgprodscus"
|
||||
},
|
||||
"unfurl": null,
|
||||
"md": {
|
||||
"path": "https://videos.openai.com/vg-assets/assets%2Ftask_01k6nkbsdvervv5dsfnnpr27jw%2Ftask_01k6nkbsdvervv5dsfnnpr27jw_genid_517dfb8c-cdaa-42e4-9fd4-98743399b160_25_10_03_17_56_604392%2Fvideos%2F00000_wm%2Fmd.mp4?st=2025-10-03T16%3A53%3A19Z&se=2025-10-09T17%3A53%3A19Z&sks=b&skt=2025-10-03T16%3A53%3A19Z&ske=2025-10-09T17%3A53%3A19Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=aa5ddad1-c91a-4f0a-9aca-e20682cc8969&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=05P%2B9ui5Zf9jazG3FeiBBn%2Bi25ZzdrFzha8%2BXmRo3B4%3D&az=oaivgprodscus"
|
||||
},
|
||||
"gif": {
|
||||
"path": "https://videos.openai.com/vg-assets/assets%2Ftask_01k6nkbsdvervv5dsfnnpr27jw%2Ftask_01k6nkbsdvervv5dsfnnpr27jw_genid_517dfb8c-cdaa-42e4-9fd4-98743399b160_25_10_03_17_56_604392%2Fvideos%2F00000_wm%2Fpreview.gif?st=2025-10-03T16%3A53%3A19Z&se=2025-10-09T17%3A53%3A19Z&sks=b&skt=2025-10-03T16%3A53%3A19Z&ske=2025-10-09T17%3A53%3A19Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=aa5ddad1-c91a-4f0a-9aca-e20682cc8969&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=GlTYf3fahHlfAuscTYPPI9i%2BsLfSQensqAox8tebN3E%3D&az=oaivgprodscus"
|
||||
}
|
||||
},
|
||||
"draft_reviewed": false,
|
||||
"task_id": "task_01k6nkbsdvervv5dsfnnpr27jw",
|
||||
"creation_config": {
|
||||
"remix_target_post": null,
|
||||
"style_id": null,
|
||||
"inpaint_image": null,
|
||||
"prompt": "SpongeBob buys Ozemspic for Patrick. Patrick is offended. ",
|
||||
"task_id": null,
|
||||
"cameo_profiles": null,
|
||||
"orientation": null,
|
||||
"n_frames": null
|
||||
},
|
||||
"tags": [],
|
||||
"generation_id": "gen_01k6nkg3z3ebxrqd1313hj9a87",
|
||||
"generation_type": ""
|
||||
},
|
||||
{
|
||||
"id": "gen_01k6njzfhxfafv8kgc4n5bmv38",
|
||||
"kind": "sora_draft",
|
||||
"url": "https://videos.openai.com/vg-assets/assets%2Ftask_01k6njvepzfk68mv77xxz9meca%2Ftask_01k6njvepzfk68mv77xxz9meca_genid_2765ab1f-e4fa-40cb-a33f-6d33f2ba2c04_25_10_03_17_47_588936%2Fvideos%2F00000_wm%2Fsrc.mp4?st=2025-10-03T16%3A53%3A19Z&se=2025-10-09T17%3A53%3A19Z&sks=b&skt=2025-10-03T16%3A53%3A19Z&ske=2025-10-09T17%3A53%3A19Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=aa5ddad1-c91a-4f0a-9aca-e20682cc8969&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=aL0EUJ3H7dP6DIxzYarXIQEFyD0Xtnn0w4TNt0hssPM%3D&az=oaivgprodscus",
|
||||
"downloadable_url": "https://videos.openai.com/vg-assets/assets%2Ftask_01k6njvepzfk68mv77xxz9meca%2Ftask_01k6njvepzfk68mv77xxz9meca_genid_2765ab1f-e4fa-40cb-a33f-6d33f2ba2c04_25_10_03_17_47_588936%2Fvideos%2F00000_wm%2Fsrc.mp4?st=2025-10-03T16%3A53%3A19Z&se=2025-10-09T17%3A53%3A19Z&sks=b&skt=2025-10-03T16%3A53%3A19Z&ske=2025-10-09T17%3A53%3A19Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=aa5ddad1-c91a-4f0a-9aca-e20682cc8969&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=aL0EUJ3H7dP6DIxzYarXIQEFyD0Xtnn0w4TNt0hssPM%3D&az=oaivgprodscus",
|
||||
"width": 352,
|
||||
"height": 640,
|
||||
"created_at": 1759513681.966017,
|
||||
"prompt": "A t-rex walking through manhattan in the morning fog, the pavement is slightly damp, sunlight gleams around the corners of buildings",
|
||||
"title": null,
|
||||
"encodings": {
|
||||
"source": {
|
||||
"path": "https://videos.openai.com/vg-assets/assets%2Ftask_01k6njvepzfk68mv77xxz9meca%2Ftask_01k6njvepzfk68mv77xxz9meca_genid_2765ab1f-e4fa-40cb-a33f-6d33f2ba2c04_25_10_03_17_47_588936%2Fvideos%2F00000_wm%2Fsrc.mp4?st=2025-10-03T16%3A53%3A19Z&se=2025-10-09T17%3A53%3A19Z&sks=b&skt=2025-10-03T16%3A53%3A19Z&ske=2025-10-09T17%3A53%3A19Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=aa5ddad1-c91a-4f0a-9aca-e20682cc8969&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=aL0EUJ3H7dP6DIxzYarXIQEFyD0Xtnn0w4TNt0hssPM%3D&az=oaivgprodscus"
|
||||
},
|
||||
"source_wm": {
|
||||
"path": "https://videos.openai.com/vg-assets/assets%2Ftask_01k6njvepzfk68mv77xxz9meca%2Ftask_01k6njvepzfk68mv77xxz9meca_genid_2765ab1f-e4fa-40cb-a33f-6d33f2ba2c04_25_10_03_17_47_588936%2Fvideos%2F00000_wm%2Fsrc.mp4?st=2025-10-03T16%3A53%3A19Z&se=2025-10-09T17%3A53%3A19Z&sks=b&skt=2025-10-03T16%3A53%3A19Z&ske=2025-10-09T17%3A53%3A19Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=aa5ddad1-c91a-4f0a-9aca-e20682cc8969&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=aL0EUJ3H7dP6DIxzYarXIQEFyD0Xtnn0w4TNt0hssPM%3D&az=oaivgprodscus"
|
||||
},
|
||||
"thumbnail": {
|
||||
"path": "https://videos.openai.com/vg-assets/assets%2Ftask_01k6njvepzfk68mv77xxz9meca%2Ftask_01k6njvepzfk68mv77xxz9meca_genid_2765ab1f-e4fa-40cb-a33f-6d33f2ba2c04_25_10_03_17_47_588936%2Fvideos%2F00000_wm%2Fthumbnail.webp?st=2025-10-03T16%3A53%3A19Z&se=2025-10-09T17%3A53%3A19Z&sks=b&skt=2025-10-03T16%3A53%3A19Z&ske=2025-10-09T17%3A53%3A19Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=aa5ddad1-c91a-4f0a-9aca-e20682cc8969&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=Cj6XzDLw2sgRG9UtJlNWncmcemu%2B7QEhEjKRZUf1MF8%3D&az=oaivgprodscus"
|
||||
},
|
||||
"unfurl": null,
|
||||
"md": {
|
||||
"path": "https://videos.openai.com/vg-assets/assets%2Ftask_01k6njvepzfk68mv77xxz9meca%2Ftask_01k6njvepzfk68mv77xxz9meca_genid_2765ab1f-e4fa-40cb-a33f-6d33f2ba2c04_25_10_03_17_47_588936%2Fvideos%2F00000_wm%2Fmd.mp4?st=2025-10-03T16%3A53%3A19Z&se=2025-10-09T17%3A53%3A19Z&sks=b&skt=2025-10-03T16%3A53%3A19Z&ske=2025-10-09T17%3A53%3A19Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=aa5ddad1-c91a-4f0a-9aca-e20682cc8969&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=Zg%2FbIN48W5p0uqiIC%2FS5GLJQeM5YIpFHpkW7cgi24m8%3D&az=oaivgprodscus"
|
||||
},
|
||||
"gif": {
|
||||
"path": "https://videos.openai.com/vg-assets/assets%2Ftask_01k6njvepzfk68mv77xxz9meca%2Ftask_01k6njvepzfk68mv77xxz9meca_genid_2765ab1f-e4fa-40cb-a33f-6d33f2ba2c04_25_10_03_17_47_588936%2Fvideos%2F00000_wm%2Fpreview.gif?st=2025-10-03T16%3A53%3A19Z&se=2025-10-09T17%3A53%3A19Z&sks=b&skt=2025-10-03T16%3A53%3A19Z&ske=2025-10-09T17%3A53%3A19Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=aa5ddad1-c91a-4f0a-9aca-e20682cc8969&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=zp1iZmWDCPeqy4TNBZfiehyOWw0tncussbtjiMODfpU%3D&az=oaivgprodscus"
|
||||
}
|
||||
},
|
||||
"draft_reviewed": true,
|
||||
"task_id": "task_01k6njvepzfk68mv77xxz9meca",
|
||||
"creation_config": {
|
||||
"remix_target_post": null,
|
||||
"style_id": null,
|
||||
"inpaint_image": null,
|
||||
"prompt": "A t-rex walking through manhattan in the morning fog, the pavement is slightly damp, sunlight gleams around the corners of buildings",
|
||||
"task_id": null,
|
||||
"cameo_profiles": null,
|
||||
"orientation": null,
|
||||
"n_frames": null
|
||||
},
|
||||
"tags": [],
|
||||
"generation_id": "gen_01k6njzfhxfafv8kgc4n5bmv38",
|
||||
"generation_type": ""
|
||||
},
|
||||
{
|
||||
"id": "gen_01k6kaqq83egat15t75wc1zg3t",
|
||||
"kind": "sora_draft",
|
||||
"url": "https://videos.openai.com/vg-assets/assets%2Ftask_01k6kakyc4fk19y7bgfskxjt73%2Ftask_01k6kakyc4fk19y7bgfskxjt73_genid_e30103d1-f44c-4bf3-a2b7-82d0f8105909_25_10_02_20_44_495868%2Fvideos%2F00000_wm%2Fsrc.mp4?st=2025-10-03T16%3A53%3A19Z&se=2025-10-09T17%3A53%3A19Z&sks=b&skt=2025-10-03T16%3A53%3A19Z&ske=2025-10-09T17%3A53%3A19Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=aa5ddad1-c91a-4f0a-9aca-e20682cc8969&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=aVqB7xZo%2FGN9ZIsFaDMfoBoacqwJc355Q%2FVbcWWBi%2B0%3D&az=oaivgprodscus",
|
||||
"downloadable_url": "https://videos.openai.com/vg-assets/assets%2Ftask_01k6kakyc4fk19y7bgfskxjt73%2Ftask_01k6kakyc4fk19y7bgfskxjt73_genid_e30103d1-f44c-4bf3-a2b7-82d0f8105909_25_10_02_20_44_495868%2Fvideos%2F00000_wm%2Fsrc.mp4?st=2025-10-03T16%3A53%3A19Z&se=2025-10-09T17%3A53%3A19Z&sks=b&skt=2025-10-03T16%3A53%3A19Z&ske=2025-10-09T17%3A53%3A19Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=aa5ddad1-c91a-4f0a-9aca-e20682cc8969&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=aVqB7xZo%2FGN9ZIsFaDMfoBoacqwJc355Q%2FVbcWWBi%2B0%3D&az=oaivgprodscus",
|
||||
"width": 352,
|
||||
"height": 640,
|
||||
"created_at": 1759437920.420881,
|
||||
"prompt": "A hipster girl stands in front of a t-rex in a room made of glass. She makes a peace sign with her hands as the t-rex roars. ",
|
||||
"title": null,
|
||||
"encodings": {
|
||||
"source": {
|
||||
"path": "https://videos.openai.com/vg-assets/assets%2Ftask_01k6kakyc4fk19y7bgfskxjt73%2Ftask_01k6kakyc4fk19y7bgfskxjt73_genid_e30103d1-f44c-4bf3-a2b7-82d0f8105909_25_10_02_20_44_495868%2Fvideos%2F00000_wm%2Fsrc.mp4?st=2025-10-03T16%3A53%3A19Z&se=2025-10-09T17%3A53%3A19Z&sks=b&skt=2025-10-03T16%3A53%3A19Z&ske=2025-10-09T17%3A53%3A19Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=aa5ddad1-c91a-4f0a-9aca-e20682cc8969&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=aVqB7xZo%2FGN9ZIsFaDMfoBoacqwJc355Q%2FVbcWWBi%2B0%3D&az=oaivgprodscus"
|
||||
},
|
||||
"source_wm": {
|
||||
"path": "https://videos.openai.com/vg-assets/assets%2Ftask_01k6kakyc4fk19y7bgfskxjt73%2Ftask_01k6kakyc4fk19y7bgfskxjt73_genid_e30103d1-f44c-4bf3-a2b7-82d0f8105909_25_10_02_20_44_495868%2Fvideos%2F00000_wm%2Fsrc.mp4?st=2025-10-03T16%3A53%3A19Z&se=2025-10-09T17%3A53%3A19Z&sks=b&skt=2025-10-03T16%3A53%3A19Z&ske=2025-10-09T17%3A53%3A19Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=aa5ddad1-c91a-4f0a-9aca-e20682cc8969&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=aVqB7xZo%2FGN9ZIsFaDMfoBoacqwJc355Q%2FVbcWWBi%2B0%3D&az=oaivgprodscus"
|
||||
},
|
||||
"thumbnail": {
|
||||
"path": "https://videos.openai.com/vg-assets/assets%2Ftask_01k6kakyc4fk19y7bgfskxjt73%2Ftask_01k6kakyc4fk19y7bgfskxjt73_genid_e30103d1-f44c-4bf3-a2b7-82d0f8105909_25_10_02_20_44_495868%2Fvideos%2F00000_wm%2Fthumbnail.webp?st=2025-10-03T16%3A53%3A19Z&se=2025-10-09T17%3A53%3A19Z&sks=b&skt=2025-10-03T16%3A53%3A19Z&ske=2025-10-09T17%3A53%3A19Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=aa5ddad1-c91a-4f0a-9aca-e20682cc8969&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=cJtvfHJmUUovVomC1MRlc2B4TP1FBN1h%2FcWfVxJGT2A%3D&az=oaivgprodscus"
|
||||
},
|
||||
"unfurl": null,
|
||||
"md": {
|
||||
"path": "https://videos.openai.com/vg-assets/assets%2Ftask_01k6kakyc4fk19y7bgfskxjt73%2Ftask_01k6kakyc4fk19y7bgfskxjt73_genid_e30103d1-f44c-4bf3-a2b7-82d0f8105909_25_10_02_20_44_495868%2Fvideos%2F00000_wm%2Fmd.mp4?st=2025-10-03T16%3A53%3A19Z&se=2025-10-09T17%3A53%3A19Z&sks=b&skt=2025-10-03T16%3A53%3A19Z&ske=2025-10-09T17%3A53%3A19Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=aa5ddad1-c91a-4f0a-9aca-e20682cc8969&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=1ysL0RhapKRjenKQdaH7xmVHSIWFXTPFWCw5CRh20ew%3D&az=oaivgprodscus"
|
||||
},
|
||||
"gif": {
|
||||
"path": "https://videos.openai.com/vg-assets/assets%2Ftask_01k6kakyc4fk19y7bgfskxjt73%2Ftask_01k6kakyc4fk19y7bgfskxjt73_genid_e30103d1-f44c-4bf3-a2b7-82d0f8105909_25_10_02_20_44_495868%2Fvideos%2F00000_wm%2Fpreview.gif?st=2025-10-03T16%3A53%3A19Z&se=2025-10-09T17%3A53%3A19Z&sks=b&skt=2025-10-03T16%3A53%3A19Z&ske=2025-10-09T17%3A53%3A19Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=aa5ddad1-c91a-4f0a-9aca-e20682cc8969&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=IdYqiCUJFkjlD2KOB5SyrCmlCU5KMPcm%2B2Ew6Adbtoo%3D&az=oaivgprodscus"
|
||||
}
|
||||
},
|
||||
"draft_reviewed": true,
|
||||
"task_id": "task_01k6kakyc4fk19y7bgfskxjt73",
|
||||
"creation_config": {
|
||||
"remix_target_post": null,
|
||||
"style_id": null,
|
||||
"inpaint_image": null,
|
||||
"prompt": "A hipster girl stands in front of a t-rex in a room made of glass. She makes a peace sign with her hands as the t-rex roars. ",
|
||||
"task_id": null,
|
||||
"cameo_profiles": null,
|
||||
"orientation": null,
|
||||
"n_frames": null
|
||||
},
|
||||
"tags": [],
|
||||
"generation_id": "gen_01k6kaqq83egat15t75wc1zg3t",
|
||||
"generation_type": ""
|
||||
},
|
||||
{
|
||||
"id": "68dee38aaf3c81918ed72fd2845abebe",
|
||||
"kind": "sora_content_violation",
|
||||
"width": 352,
|
||||
"height": 640,
|
||||
"created_at": null,
|
||||
"prompt": "A man with glasses stretches in front of a t-rex. Butterflies fly by as sun gleams over the horizon. The camera pans, and there are solar punk structures dotting the landscape. A field of pink flowers and an infinite blue sky with clouds. The T-rex roars as the man continues to stretch",
|
||||
"title": null,
|
||||
"reason": null,
|
||||
"reason_str": "This content may violate our guardrails concerning third-party likeness.",
|
||||
"task_id": "task_01k6kahg89fwzb1e40j47kfrfv",
|
||||
"draft_reviewed": false,
|
||||
"creation_config": {
|
||||
"remix_target_post": null,
|
||||
"style_id": null,
|
||||
"inpaint_image": null,
|
||||
"prompt": "A man with glasses stretches in front of a t-rex. Butterflies fly by as sun gleams over the horizon. The camera pans, and there are solar punk structures dotting the landscape. A field of pink flowers and an infinite blue sky with clouds. The T-rex roars as the man continues to stretch",
|
||||
"task_id": null,
|
||||
"cameo_profiles": null,
|
||||
"orientation": null,
|
||||
"n_frames": null
|
||||
},
|
||||
"tags": [],
|
||||
"generation_id": "68dee38aaf3c81918ed72fd2845abebe",
|
||||
"generation_type": ""
|
||||
},
|
||||
{
|
||||
"id": "68dee35e6c0c8191b6c2a65cdccf5812",
|
||||
"kind": "sora_content_violation",
|
||||
"width": 352,
|
||||
"height": 640,
|
||||
"created_at": null,
|
||||
"prompt": "A man with glasses stretches in front of a t-rex. Butterflies fly by as sun gleams over the horizon. The camera pans, and there are solar punk structures dotting the landscape. A field of pink flowers and an infinite blue sky with clouds. The T-rex roars as the man continues to stretch",
|
||||
"title": null,
|
||||
"reason": null,
|
||||
"reason_str": "We currently do not support uploads of images containing photorealistic people.",
|
||||
"task_id": "task_01k6kag470fwrrkf2k14tkv9pt",
|
||||
"draft_reviewed": false,
|
||||
"creation_config": {
|
||||
"remix_target_post": null,
|
||||
"style_id": null,
|
||||
"inpaint_image": null,
|
||||
"prompt": "A man with glasses stretches in front of a t-rex. Butterflies fly by as sun gleams over the horizon. The camera pans, and there are solar punk structures dotting the landscape. A field of pink flowers and an infinite blue sky with clouds. The T-rex roars as the man continues to stretch",
|
||||
"task_id": null,
|
||||
"cameo_profiles": null,
|
||||
"orientation": null,
|
||||
"n_frames": null
|
||||
},
|
||||
"tags": [],
|
||||
"generation_id": "68dee35e6c0c8191b6c2a65cdccf5812",
|
||||
"generation_type": ""
|
||||
},
|
||||
{
|
||||
"id": "gen_01k6ka1aqdfccbwrbre9jzyf68",
|
||||
"kind": "sora_draft",
|
||||
"url": "https://videos.openai.com/vg-assets/assets%2Ftask_01k6k9xez9eyz8aks3hk4fe01m%2Ftask_01k6k9xez9eyz8aks3hk4fe01m_genid_30b0d7ba-79b5-4877-bc4b-97b78eaa3747_25_10_02_20_32_430033%2Fvideos%2F00000_wm%2Fsrc.mp4?st=2025-10-03T16%3A53%3A19Z&se=2025-10-09T17%3A53%3A19Z&sks=b&skt=2025-10-03T16%3A53%3A19Z&ske=2025-10-09T17%3A53%3A19Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=aa5ddad1-c91a-4f0a-9aca-e20682cc8969&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=oIsyZwXazMFaRRP%2FdjPTJOYQVYPzZnB4%2BVp9RAZmjCE%3D&az=oaivgprodscus",
|
||||
"downloadable_url": "https://videos.openai.com/vg-assets/assets%2Ftask_01k6k9xez9eyz8aks3hk4fe01m%2Ftask_01k6k9xez9eyz8aks3hk4fe01m_genid_30b0d7ba-79b5-4877-bc4b-97b78eaa3747_25_10_02_20_32_430033%2Fvideos%2F00000_wm%2Fsrc.mp4?st=2025-10-03T16%3A53%3A19Z&se=2025-10-09T17%3A53%3A19Z&sks=b&skt=2025-10-03T16%3A53%3A19Z&ske=2025-10-09T17%3A53%3A19Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=aa5ddad1-c91a-4f0a-9aca-e20682cc8969&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=oIsyZwXazMFaRRP%2FdjPTJOYQVYPzZnB4%2BVp9RAZmjCE%3D&az=oaivgprodscus",
|
||||
"width": 352,
|
||||
"height": 640,
|
||||
"created_at": 1759437191.395544,
|
||||
"prompt": "The Green Power Ranger fighting the planter peanuts guy, Mr. Peanut. They're fighting in Neo Tokyo at night, and the scene is lit with bright neon lights and holograms in the background. Blue and purple hues. ",
|
||||
"title": null,
|
||||
"encodings": {
|
||||
"source": {
|
||||
"path": "https://videos.openai.com/vg-assets/assets%2Ftask_01k6k9xez9eyz8aks3hk4fe01m%2Ftask_01k6k9xez9eyz8aks3hk4fe01m_genid_30b0d7ba-79b5-4877-bc4b-97b78eaa3747_25_10_02_20_32_430033%2Fvideos%2F00000_wm%2Fsrc.mp4?st=2025-10-03T16%3A53%3A19Z&se=2025-10-09T17%3A53%3A19Z&sks=b&skt=2025-10-03T16%3A53%3A19Z&ske=2025-10-09T17%3A53%3A19Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=aa5ddad1-c91a-4f0a-9aca-e20682cc8969&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=oIsyZwXazMFaRRP%2FdjPTJOYQVYPzZnB4%2BVp9RAZmjCE%3D&az=oaivgprodscus"
|
||||
},
|
||||
"source_wm": {
|
||||
"path": "https://videos.openai.com/vg-assets/assets%2Ftask_01k6k9xez9eyz8aks3hk4fe01m%2Ftask_01k6k9xez9eyz8aks3hk4fe01m_genid_30b0d7ba-79b5-4877-bc4b-97b78eaa3747_25_10_02_20_32_430033%2Fvideos%2F00000_wm%2Fsrc.mp4?st=2025-10-03T16%3A53%3A19Z&se=2025-10-09T17%3A53%3A19Z&sks=b&skt=2025-10-03T16%3A53%3A19Z&ske=2025-10-09T17%3A53%3A19Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=aa5ddad1-c91a-4f0a-9aca-e20682cc8969&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=oIsyZwXazMFaRRP%2FdjPTJOYQVYPzZnB4%2BVp9RAZmjCE%3D&az=oaivgprodscus"
|
||||
},
|
||||
"thumbnail": {
|
||||
"path": "https://videos.openai.com/vg-assets/assets%2Ftask_01k6k9xez9eyz8aks3hk4fe01m%2Ftask_01k6k9xez9eyz8aks3hk4fe01m_genid_30b0d7ba-79b5-4877-bc4b-97b78eaa3747_25_10_02_20_32_430033%2Fvideos%2F00000_wm%2Fthumbnail.webp?st=2025-10-03T16%3A53%3A19Z&se=2025-10-09T17%3A53%3A19Z&sks=b&skt=2025-10-03T16%3A53%3A19Z&ske=2025-10-09T17%3A53%3A19Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=aa5ddad1-c91a-4f0a-9aca-e20682cc8969&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=S1wEgK7fg5bPXZwSv7Wjbn2msJOBcUpw354WckMhfzc%3D&az=oaivgprodscus"
|
||||
},
|
||||
"unfurl": null,
|
||||
"md": {
|
||||
"path": "https://videos.openai.com/vg-assets/assets%2Ftask_01k6k9xez9eyz8aks3hk4fe01m%2Ftask_01k6k9xez9eyz8aks3hk4fe01m_genid_30b0d7ba-79b5-4877-bc4b-97b78eaa3747_25_10_02_20_32_430033%2Fvideos%2F00000_wm%2Fmd.mp4?st=2025-10-03T16%3A53%3A19Z&se=2025-10-09T17%3A53%3A19Z&sks=b&skt=2025-10-03T16%3A53%3A19Z&ske=2025-10-09T17%3A53%3A19Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=aa5ddad1-c91a-4f0a-9aca-e20682cc8969&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=V35PKIIZnbiDd4HOkqssfjGQM%2FzF3Ot%2FP0CqovZSsws%3D&az=oaivgprodscus"
|
||||
},
|
||||
"gif": {
|
||||
"path": "https://videos.openai.com/vg-assets/assets%2Ftask_01k6k9xez9eyz8aks3hk4fe01m%2Ftask_01k6k9xez9eyz8aks3hk4fe01m_genid_30b0d7ba-79b5-4877-bc4b-97b78eaa3747_25_10_02_20_32_430033%2Fvideos%2F00000_wm%2Fpreview.gif?st=2025-10-03T16%3A53%3A19Z&se=2025-10-09T17%3A53%3A19Z&sks=b&skt=2025-10-03T16%3A53%3A19Z&ske=2025-10-09T17%3A53%3A19Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=aa5ddad1-c91a-4f0a-9aca-e20682cc8969&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=O0mQlm%2Bt9baV283VQZfnD%2BRrjxkRuE1Kw5Nmh1Awe3Q%3D&az=oaivgprodscus"
|
||||
}
|
||||
},
|
||||
"draft_reviewed": true,
|
||||
"task_id": "task_01k6k9xez9eyz8aks3hk4fe01m",
|
||||
"creation_config": {
|
||||
"remix_target_post": null,
|
||||
"style_id": null,
|
||||
"inpaint_image": null,
|
||||
"prompt": "The Green Power Ranger fighting the planter peanuts guy, Mr. Peanut. They're fighting in Neo Tokyo at night, and the scene is lit with bright neon lights and holograms in the background. Blue and purple hues. ",
|
||||
"task_id": null,
|
||||
"cameo_profiles": null,
|
||||
"orientation": null,
|
||||
"n_frames": null
|
||||
},
|
||||
"tags": [],
|
||||
"generation_id": "gen_01k6ka1aqdfccbwrbre9jzyf68",
|
||||
"generation_type": ""
|
||||
}
|
||||
],
|
||||
"cursor": null
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod list_sora2_drafts;
|
||||
pub (super) mod http_response;
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
[
|
||||
{
|
||||
"id": "task_01k6nkbsdvervv5dsfnnpr27jw",
|
||||
"user": "user-hQ5fc1AV7vNcQLk2yfEFEUbo",
|
||||
"created_at": "2025-10-03T17:54:20.224561Z",
|
||||
"status": "running",
|
||||
"progress_pct": 0.6465000000000001,
|
||||
"progress_pos_in_queue": null,
|
||||
"estimated_queue_wait_time": null,
|
||||
"queue_status_message": null,
|
||||
"priority": 2,
|
||||
"type": "video_gen",
|
||||
"prompt": "SpongeBob buys Ozemspic for Patrick. Patrick is offended. ",
|
||||
"actions": null,
|
||||
"n_variants": 1,
|
||||
"n_frames": 300,
|
||||
"height": 640,
|
||||
"width": 352,
|
||||
"seed": null,
|
||||
"guidance": null,
|
||||
"inpaint_items": [],
|
||||
"interpolation": null,
|
||||
"sdedit": null,
|
||||
"model": "sy_8",
|
||||
"operation": "simple_compose",
|
||||
"is_storyboard": null,
|
||||
"preset_id": null,
|
||||
"remix_config": null,
|
||||
"organization_id": null,
|
||||
"project_id": null,
|
||||
"request_id": null,
|
||||
"tracking_id": null,
|
||||
"generations": [],
|
||||
"num_unsafe_generations": 0,
|
||||
"title": "New Video",
|
||||
"moderation_result": {
|
||||
"type": "passed",
|
||||
"results_by_frame_index": {},
|
||||
"code": null,
|
||||
"is_output_rejection": false,
|
||||
"task_id": "task_01k6nkbsdvervv5dsfnnpr27jw"
|
||||
},
|
||||
"failure_reason": null,
|
||||
"needs_user_review": false
|
||||
}
|
||||
]
|
||||
+19
File diff suppressed because one or more lines are too long
@@ -1,7 +1,10 @@
|
||||
pub mod auth_bearer;
|
||||
pub mod auth_sentinel;
|
||||
pub mod common;
|
||||
pub mod deprecated;
|
||||
pub mod generate_sora2_video;
|
||||
pub mod image_gen;
|
||||
pub mod job_status;
|
||||
pub mod list_media;
|
||||
pub mod upload;
|
||||
pub mod list_classic_tasks;
|
||||
pub mod list_sora2_drafts;
|
||||
pub mod upload;
|
||||
pub mod upload_sora2;
|
||||
@@ -1,3 +1,5 @@
|
||||
pub (crate) mod upload_media_http_request;
|
||||
pub mod upload_media_http_response;
|
||||
|
||||
pub mod upload_media_from_bytes;
|
||||
pub mod upload_media_from_file;
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
use crate::creds::sora_credential_set::SoraCredentialSet;
|
||||
use crate::error::sora_error::SoraError;
|
||||
use crate::requests::upload::upload_media_http_request::{upload_media_http_request, SoraMediaUploadResponse};
|
||||
use crate::requests::upload::upload_media_http_request::upload_media_http_request;
|
||||
use crate::requests::upload::upload_media_http_response::SoraMediaUploadResponse;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
|
||||
+4
-13
@@ -1,7 +1,8 @@
|
||||
use crate::creds::sora_credential_set::SoraCredentialSet;
|
||||
use crate::error::sora_client_error::SoraClientError;
|
||||
use crate::error::sora_error::SoraError;
|
||||
use crate::requests::upload::upload_media_http_request::{upload_media_http_request, SoraMediaUploadResponse};
|
||||
use crate::requests::upload::upload_media_http_request::upload_media_http_request;
|
||||
use crate::requests::upload::upload_media_http_response::SoraMediaUploadResponse;
|
||||
use log::error;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
@@ -55,28 +56,18 @@ pub async fn sora_media_upload_from_file<P: AsRef<Path>>(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::creds::sora_credential_builder::SoraCredentialBuilder;
|
||||
use crate::requests::upload::upload_media_from_file::sora_media_upload_from_file;
|
||||
use crate::test_utils::get_test_credentials::get_test_credentials;
|
||||
use errors::AnyhowResult;
|
||||
use std::fs::read_to_string;
|
||||
use testing::test_file_path::test_file_path;
|
||||
|
||||
#[ignore] // You can manually run "ignore" tests in the IDE, but they won't run in CI.
|
||||
#[tokio::test]
|
||||
pub async fn manual_test() -> AnyhowResult<()> {
|
||||
let cookie = read_to_string(test_file_path("test_data/temp/cookie.txt")?)?;
|
||||
let cookie = cookie.trim().to_string();
|
||||
|
||||
let bearer = read_to_string(test_file_path("test_data/temp/bearer.txt")?)?;
|
||||
let bearer = bearer.trim().to_string();
|
||||
let creds = get_test_credentials()?;
|
||||
|
||||
let image_path = test_file_path("test_data/image/juno.jpg")?; // media_01jqyqgqpwf40tkcapq5bmaz5d
|
||||
|
||||
let creds = SoraCredentialBuilder::new()
|
||||
.with_cookies(&cookie)
|
||||
.with_jwt_bearer_token(&bearer)
|
||||
.build()?;
|
||||
|
||||
let response = sora_media_upload_from_file(
|
||||
image_path,
|
||||
&creds,
|
||||
|
||||
+17
-54
@@ -3,66 +3,18 @@ use crate::creds::sora_credential_set::SoraCredentialSet;
|
||||
use crate::error::sora_client_error::SoraClientError;
|
||||
use crate::error::sora_error::SoraError;
|
||||
use crate::error::sora_generic_api_error::SoraGenericApiError;
|
||||
use crate::requests::upload::upload_media_http_response::SoraMediaUploadResponse;
|
||||
use crate::utils_internal::classify_general_http_error::classify_general_http_error;
|
||||
use log::{error, info};
|
||||
use serde::Deserialize;
|
||||
use std::io::empty;
|
||||
use std::time::Duration;
|
||||
use wreq::header::{ACCEPT, ACCEPT_LANGUAGE, AUTHORIZATION, COOKIE, ORIGIN, REFERER};
|
||||
use wreq::multipart::{Form, Part};
|
||||
use wreq::Client;
|
||||
|
||||
const SORA_UPLOAD_MEDIA_URL: &str = "https://sora.com/backend/uploads";
|
||||
|
||||
// Content-Disposition: form-data; name="file"; filename="imagename.webp"
|
||||
|
||||
/*
|
||||
Response:
|
||||
{
|
||||
"id": "media_01jqt9vt20erx9zvryf3v1pecx",
|
||||
"type": "image",
|
||||
"created_at": "2025-04-02T03:49:04.185867Z",
|
||||
"filename": "Rith_essa.webp",
|
||||
"extension": "jpeg",
|
||||
"mime_type": "image/jpeg",
|
||||
"url": "https://videos.openai.com/vg-assets/assets%2Fclient_upload%2Fmedia%2Fuser-9wf6JFdgRSJLjvSJ53LNAbV8%2Fmedia_01jqt9vt20erx9zvryf3v1pecx.jpeg?st=2025-04-02T02%3A39%3A01Z&se=2025-04-08T03%3A39%3A01Z&sks=b&skt=2025-04-02T02%3A39%3A01Z&ske=2025-04-08T03%3A39%3A01Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=3d249c53-07fa-4ba4-9b65-0bf8eb4ea46a&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=%2BdnTQyw9OFF2kaVoEReT2cVfTqmKjGt18m7P4wKCWCI%3D&az=oaivgprodscus",
|
||||
"width": 1200,
|
||||
"height": 675,
|
||||
"duration_sec": null,
|
||||
"n_frames": 1,
|
||||
"size_bytes": 118501,
|
||||
"thumbnail_url": "https://videos.openai.com/vg-assets/assets%2Fclient_upload%2Fmedia%2Fuser-9wf6JFdgRSJLjvSJ53LNAbV8%2Fmedia_01jqt9vt20erx9zvryf3v1pecx.jpg?st=2025-04-02T02%3A39%3A01Z&se=2025-04-08T03%3A39%3A01Z&sks=b&skt=2025-04-02T02%3A39%3A01Z&ske=2025-04-08T03%3A39%3A01Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=3d249c53-07fa-4ba4-9b65-0bf8eb4ea46a&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=9aU9xKI0BkhRtrTb0RD05%2BT6prBnmXk1I7nV2nTsFbY%3D&az=oaivgprodscus"
|
||||
}
|
||||
|
||||
Error response:
|
||||
|
||||
Failed to upload scene media to Sora: Upload failed with status 401 Unauthorized: {
|
||||
"error": {
|
||||
"message": "Your authentication token has expired. Please try signing in again.",
|
||||
"type": "invalid_request_error",
|
||||
"param": null,
|
||||
"code": "token_expired"
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SoraMediaUploadResponse {
|
||||
/// This is the media token id.
|
||||
pub id: String,
|
||||
pub r#type: String,
|
||||
pub created_at: String,
|
||||
pub filename: String,
|
||||
pub extension: String,
|
||||
pub mime_type: String,
|
||||
pub url: String,
|
||||
pub width: Option<u32>,
|
||||
pub height: Option<u32>,
|
||||
pub duration_sec: Option<f64>,
|
||||
pub n_frames: Option<u32>,
|
||||
pub size_bytes: u64,
|
||||
pub thumbnail_url: Option<String>,
|
||||
}
|
||||
|
||||
pub (crate) struct SoraMediaUploadRequest<'a> {
|
||||
pub file_path: String,
|
||||
pub credentials: &'a SoraCredentialSet,
|
||||
@@ -94,10 +46,21 @@ pub (crate) async fn upload_media_http_request(
|
||||
let client = Client::new();
|
||||
let mut request_builder = client.post(SORA_UPLOAD_MEDIA_URL)
|
||||
.multipart(form)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.header("Cookie", &cookie)
|
||||
.header("Authorization", &auth_header);
|
||||
|
||||
.header(ACCEPT, "*/*")
|
||||
.header(ACCEPT_LANGUAGE, "en-US,en;q=0.9")
|
||||
.header(AUTHORIZATION, &auth_header)
|
||||
.header(COOKIE, &cookie)
|
||||
.header(ORIGIN, "https://sora.chatgpt.com")
|
||||
.header("user-agent", USER_AGENT)
|
||||
.header(REFERER, "https://sora.chatgpt.com/explore")
|
||||
.header("priority", "u=1, i")
|
||||
.header("sec-ch-ua", "\"Chromium\";v=\"140\", \"Not=A?Brand\";v=\"24\", \"Google Chrome\";v=\"140\"")
|
||||
.header("sec-ch-ua-mobile", "?0")
|
||||
.header("sec-ch-ua-platform", "macOS")
|
||||
.header("sec-fetch-dest", "empty")
|
||||
.header("sec-fetch-mode", "cors")
|
||||
.header("sec-fetch-site", "same-origin");
|
||||
|
||||
if let Some(timeout) = maybe_timeout {
|
||||
request_builder = request_builder.timeout(timeout);
|
||||
}
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
use serde_derive::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SoraMediaUploadResponse {
|
||||
/// This is the media token id.
|
||||
pub id: String,
|
||||
pub r#type: String,
|
||||
pub created_at: String,
|
||||
pub filename: String,
|
||||
pub extension: String,
|
||||
pub mime_type: String,
|
||||
pub url: String,
|
||||
pub width: Option<u32>,
|
||||
pub height: Option<u32>,
|
||||
pub duration_sec: Option<f64>,
|
||||
pub n_frames: Option<u32>,
|
||||
pub size_bytes: u64,
|
||||
pub thumbnail_url: Option<String>,
|
||||
}
|
||||
|
||||
// Content-Disposition: form-data; name="file"; filename="imagename.webp"
|
||||
|
||||
/*
|
||||
Response:
|
||||
{
|
||||
"id": "media_01jqt9vt20erx9zvryf3v1pecx",
|
||||
"type": "image",
|
||||
"created_at": "2025-04-02T03:49:04.185867Z",
|
||||
"filename": "Rith_essa.webp",
|
||||
"extension": "jpeg",
|
||||
"mime_type": "image/jpeg",
|
||||
"url": "https://videos.openai.com/vg-assets/assets%2Fclient_upload%2Fmedia%2Fuser-9wf6JFdgRSJLjvSJ53LNAbV8%2Fmedia_01jqt9vt20erx9zvryf3v1pecx.jpeg?st=2025-04-02T02%3A39%3A01Z&se=2025-04-08T03%3A39%3A01Z&sks=b&skt=2025-04-02T02%3A39%3A01Z&ske=2025-04-08T03%3A39%3A01Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=3d249c53-07fa-4ba4-9b65-0bf8eb4ea46a&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=%2BdnTQyw9OFF2kaVoEReT2cVfTqmKjGt18m7P4wKCWCI%3D&az=oaivgprodscus",
|
||||
"width": 1200,
|
||||
"height": 675,
|
||||
"duration_sec": null,
|
||||
"n_frames": 1,
|
||||
"size_bytes": 118501,
|
||||
"thumbnail_url": "https://videos.openai.com/vg-assets/assets%2Fclient_upload%2Fmedia%2Fuser-9wf6JFdgRSJLjvSJ53LNAbV8%2Fmedia_01jqt9vt20erx9zvryf3v1pecx.jpg?st=2025-04-02T02%3A39%3A01Z&se=2025-04-08T03%3A39%3A01Z&sks=b&skt=2025-04-02T02%3A39%3A01Z&ske=2025-04-08T03%3A39%3A01Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=3d249c53-07fa-4ba4-9b65-0bf8eb4ea46a&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=9aU9xKI0BkhRtrTb0RD05%2BT6prBnmXk1I7nV2nTsFbY%3D&az=oaivgprodscus"
|
||||
}
|
||||
|
||||
Error response:
|
||||
|
||||
Failed to upload scene media to Sora: Upload failed with status 401 Unauthorized: {
|
||||
"error": {
|
||||
"message": "Your authentication token has expired. Please try signing in again.",
|
||||
"type": "invalid_request_error",
|
||||
"param": null,
|
||||
"code": "token_expired"
|
||||
}
|
||||
}
|
||||
*/
|
||||
@@ -0,0 +1,21 @@
|
||||
use serde_derive::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SoraMediaUploadResponse {
|
||||
/// This is the media token id.
|
||||
pub id: String,
|
||||
|
||||
// NB: Don't need these, so commented out:
|
||||
//pub r#type: String,
|
||||
//pub created_at: String,
|
||||
//pub filename: String,
|
||||
//pub extension: String,
|
||||
//pub mime_type: String,
|
||||
//pub url: String,
|
||||
//pub width: Option<u32>,
|
||||
//pub height: Option<u32>,
|
||||
//pub duration_sec: Option<f64>,
|
||||
//pub n_frames: Option<u32>,
|
||||
//pub size_bytes: u64,
|
||||
//pub thumbnail_url: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//pub mod upload_media_sora2_from_bytes;
|
||||
//pub mod upload_media_sora2_from_file;
|
||||
//
|
||||
//pub mod upload_media_sora2_http_request;
|
||||
//mod http_response;
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
use crate::creds::sora_credential_set::SoraCredentialSet;
|
||||
use crate::error::sora_error::SoraError;
|
||||
use crate::requests::upload::upload_media_http_request::{upload_media_http_request, SoraMediaUploadResponse};
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Upload bytes.
|
||||
/// The underlying reqwest lib needs to own the bytes, so we can't pass as a reference.
|
||||
pub async fn sora_media_upload_from_bytes(
|
||||
bytes: Vec<u8>,
|
||||
file_name: String,
|
||||
creds: &SoraCredentialSet,
|
||||
maybe_timeout: Option<Duration>,
|
||||
) -> Result<SoraMediaUploadResponse, SoraError> {
|
||||
let file_path = PathBuf::from(&file_name);
|
||||
|
||||
// TODO: Read file magic bytes first, then fall back to this.
|
||||
let mime_type = match file_path.extension().and_then(|e| e.to_str()) {
|
||||
Some("jpg") | Some("jpeg") => "image/jpeg",
|
||||
Some("png") => "image/png",
|
||||
// Some("webp") => "image/webp",
|
||||
// Some("gif") => "image/gif",
|
||||
// Some("mp4") => "video/mp4",
|
||||
// Some("mov") => "video/quicktime",
|
||||
// Some("webm") => "video/webm",
|
||||
_ => "application/octet-stream",
|
||||
};
|
||||
|
||||
Ok(upload_media_http_request(bytes, file_name, mime_type, creds, maybe_timeout).await?)
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
use crate::creds::sora_credential_set::SoraCredentialSet;
|
||||
use crate::error::sora_client_error::SoraClientError;
|
||||
use crate::error::sora_error::SoraError;
|
||||
use crate::requests::upload::upload_media_http_request::{upload_media_http_request, SoraMediaUploadResponse};
|
||||
use log::error;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use tokio::fs::File;
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
/// Try to prevent buffer reallocations.
|
||||
/// There's a better way to implement this.
|
||||
const INITIAL_BUFFER_SIZE : usize = 1024*1024;
|
||||
|
||||
pub async fn sora_media_upload_from_file<P: AsRef<Path>>(
|
||||
file_path: P,
|
||||
creds: &SoraCredentialSet,
|
||||
maybe_timeout: Option<Duration>,
|
||||
) -> Result<SoraMediaUploadResponse, SoraError> {
|
||||
let mut file = File::open(&file_path).await
|
||||
.map_err(|err| {
|
||||
error!("Failed to open file for upload: {}", err);
|
||||
SoraClientError::FileForUploadReadError(err)
|
||||
})?;
|
||||
|
||||
let mut buffer = Vec::with_capacity(INITIAL_BUFFER_SIZE);
|
||||
|
||||
file.read_to_end(&mut buffer).await
|
||||
.map_err(|err| {
|
||||
error!("Failed to read file for upload: {}", err);
|
||||
SoraClientError::FileForUploadReadError(err)
|
||||
})?;
|
||||
|
||||
let filename = file_path.as_ref().file_name()
|
||||
.ok_or_else(|| SoraClientError::FileForUploadHasInvalidPath)?
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
let maybe_ext = file_path.as_ref().extension().and_then(|e| e.to_str());
|
||||
|
||||
// TODO: Read file magic bytes first, then fall back to this.
|
||||
let mime_type = match maybe_ext {
|
||||
Some("jpg") | Some("jpeg") => "image/jpeg",
|
||||
Some("png") => "image/png",
|
||||
// Some("webp") => "image/webp",
|
||||
// Some("gif") => "image/gif",
|
||||
// Some("mp4") => "video/mp4",
|
||||
// Some("mov") => "video/quicktime",
|
||||
// Some("webm") => "video/webm",
|
||||
_ => "application/octet-stream",
|
||||
};
|
||||
|
||||
Ok(upload_media_http_request(buffer, filename, mime_type, creds, maybe_timeout).await?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::creds::sora_credential_builder::SoraCredentialBuilder;
|
||||
use crate::requests::upload::upload_media_from_file::sora_media_upload_from_file;
|
||||
use errors::AnyhowResult;
|
||||
use std::fs::read_to_string;
|
||||
use testing::test_file_path::test_file_path;
|
||||
|
||||
#[ignore] // You can manually run "ignore" tests in the IDE, but they won't run in CI.
|
||||
#[tokio::test]
|
||||
pub async fn manual_test() -> AnyhowResult<()> {
|
||||
let cookie = read_to_string(test_file_path("test_data/temp/cookie.txt")?)?;
|
||||
let cookie = cookie.trim().to_string();
|
||||
|
||||
let bearer = read_to_string(test_file_path("test_data/temp/bearer.txt")?)?;
|
||||
let bearer = bearer.trim().to_string();
|
||||
|
||||
let image_path = test_file_path("test_data/image/juno.jpg")?; // media_01jqyqgqpwf40tkcapq5bmaz5d
|
||||
|
||||
let creds = SoraCredentialBuilder::new()
|
||||
.with_cookies(&cookie)
|
||||
.with_jwt_bearer_token(&bearer)
|
||||
.build()?;
|
||||
|
||||
let response = sora_media_upload_from_file(
|
||||
image_path,
|
||||
&creds,
|
||||
None,
|
||||
).await?;
|
||||
|
||||
println!("media: {:?}", response);
|
||||
|
||||
println!("media_id: {}", response.id);
|
||||
println!("media_url: {}", response.url);
|
||||
|
||||
assert!(response.id.starts_with("media_"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
use crate::constants::user_agent::USER_AGENT;
|
||||
use crate::creds::sora_credential_set::SoraCredentialSet;
|
||||
use crate::error::sora_client_error::SoraClientError;
|
||||
use crate::error::sora_error::SoraError;
|
||||
use crate::error::sora_generic_api_error::SoraGenericApiError;
|
||||
use crate::utils_internal::classify_general_http_error::classify_general_http_error;
|
||||
use log::{error, info};
|
||||
use serde::Deserialize;
|
||||
use std::io::empty;
|
||||
use std::time::Duration;
|
||||
use wreq::multipart::{Form, Part};
|
||||
use wreq::Client;
|
||||
use crate::requests::list_sora2_drafts::list_sora2_drafts::Draft;
|
||||
|
||||
const SORA_UPLOAD_MEDIA_URL: &str = "https://sora.chatgpt.com/backend/uploads";
|
||||
|
||||
|
||||
|
||||
pub (crate) struct SoraMediaUploadArgs<'a> {
|
||||
pub credentials: &'a SoraCredentialSet,
|
||||
pub file_bytes: Vec<u8>, // NB: Reqwest needs to own the bytes, so we can't pass as a reference.
|
||||
pub filename: String, // NB: Reqwest needs to own the bytes, so we can't pass as a reference.
|
||||
pub mime_type: &'a str,
|
||||
pub maybe_timeout: Option<Duration>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SoraMediaUploadResult {
|
||||
pub drafts: Vec<Draft>,
|
||||
}
|
||||
|
||||
pub (crate) async fn upload_media_sora2_http_request(
|
||||
args: SoraMediaUploadArgs<'_>
|
||||
) -> Result<SoraMediaUploadResult, SoraError> {
|
||||
|
||||
// Create multipart form
|
||||
let part = Part::bytes(args.file_bytes) // NB: Reqwest needs to own the bytes.
|
||||
.file_name(args.filename) // NB: Reqwest needs to own the bytes
|
||||
.mime_str(args.mime_type)
|
||||
.map_err(|e| SoraClientError::MultipartFormError(e))?;
|
||||
|
||||
let form = Form::new().part("file", part);
|
||||
|
||||
let cookie = args.credentials.cookies.to_string();
|
||||
let auth_header = args.credentials.jwt_bearer_token
|
||||
.as_ref()
|
||||
.ok_or(SoraClientError::NoBearerTokenForRequest)?
|
||||
.to_authorization_header_value();
|
||||
|
||||
// Make API request
|
||||
let client = Client::new();
|
||||
let mut request_builder = client.post(SORA_UPLOAD_MEDIA_URL)
|
||||
.multipart(form)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.header("Cookie", &cookie)
|
||||
.header("Authorization", &auth_header);
|
||||
|
||||
if let Some(timeout) = args.maybe_timeout {
|
||||
request_builder = request_builder.timeout(timeout);
|
||||
}
|
||||
|
||||
let response = request_builder.send()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
SoraGenericApiError::WreqError(err)
|
||||
})?;
|
||||
|
||||
// Check response status
|
||||
if !response.status().is_success() {
|
||||
info!("Error uploading image: {:?}", response.status());
|
||||
let error = classify_general_http_error(response).await;
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
let response_body = &response.text().await
|
||||
.map_err(|err| {
|
||||
error!("Error reading response body while attempting file upload: {:?}", err);
|
||||
SoraGenericApiError::WreqError(err)
|
||||
})?;
|
||||
|
||||
let upload_response : SoraMediaUploadResponse = serde_json::from_str(&response_body)
|
||||
.map_err(|err| {
|
||||
error!("Error parsing response body while attempting file upload: {:?}", err);
|
||||
SoraGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string())
|
||||
})?;
|
||||
|
||||
Ok(upload_response)
|
||||
}
|
||||
+21
File diff suppressed because one or more lines are too long
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"id": "media_01k6nscysdfxktrvshw0qmkmv3",
|
||||
"type": "image",
|
||||
"created_at": "2025-10-03T19:39:49.938416Z",
|
||||
"filename": "artcraft_kscc8n1yj6tyqcxm5btfp32v9b412kz9.png",
|
||||
"extension": "png",
|
||||
"mime_type": "image/png",
|
||||
"url": "https://videos.openai.com/vg-assets/assets%2Fclient_upload%2Fmedia%2F984cd7658c4eeb0be057d5d91c4faf3d2e5fd579%2Fmedia_01k6nscysdfxktrvshw0qmkmv3.png?st=2025-10-03T18%3A03%3A28Z&se=2025-10-09T19%3A03%3A28Z&sks=b&skt=2025-10-03T18%3A03%3A28Z&ske=2025-10-09T19%3A03%3A28Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=aa5ddad1-c91a-4f0a-9aca-e20682cc8969&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=bUkwbc6pZTrGs4YiV9Q6I%2FwP7JPgtexoyvC5b2Aa6CE%3D&az=oaivgprodscus",
|
||||
"width": 1248,
|
||||
"height": 832,
|
||||
"duration_sec": null,
|
||||
"n_frames": 1,
|
||||
"size_bytes": 2278064,
|
||||
"thumbnail_url": "https://videos.openai.com/vg-assets/assets%2Fclient_upload%2Fmedia%2F984cd7658c4eeb0be057d5d91c4faf3d2e5fd579%2Fmedia_01k6nscysdfxktrvshw0qmkmv3.jpg?st=2025-10-03T18%3A03%3A28Z&se=2025-10-09T19%3A03%3A28Z&sks=b&skt=2025-10-03T18%3A03%3A28Z&ske=2025-10-09T19%3A03%3A28Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=aa5ddad1-c91a-4f0a-9aca-e20682cc8969&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=XhdbsZRUv0OiVGHf%2Fc9YWCDhWBqXy5YzqO32ahqKc1E%3D&az=oaivgprodscus"
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
use crate::creds::sora_credential_builder::SoraCredentialBuilder;
|
||||
use crate::creds::sora_credential_set::SoraCredentialSet;
|
||||
use errors::AnyhowResult;
|
||||
use std::fs::read_to_string;
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn get_test_credentials() -> AnyhowResult<SoraCredentialSet> {
|
||||
// TODO: This should work on other folks' machines when they exist
|
||||
// This is so brittle...
|
||||
let sentinel = read_to_string("/Users/bt/Artcraft/credentials/sora_sentinel.txt")?;
|
||||
let sentinel = sentinel.trim().to_string();
|
||||
|
||||
let cookie = read_to_string("/Users/bt/Artcraft/credentials/sora_cookies.txt")?;
|
||||
let cookie = cookie.trim().to_string();
|
||||
|
||||
let bearer = read_to_string("/Users/bt/Artcraft/credentials/sora_bearer_token.txt")?;
|
||||
let bearer = bearer.trim().to_string();
|
||||
|
||||
let creds = SoraCredentialBuilder::new()
|
||||
.with_cookies(&cookie)
|
||||
.with_jwt_bearer_token(&bearer)
|
||||
.with_sora_sentinel(&sentinel)
|
||||
.build()?;
|
||||
|
||||
Ok(creds)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
#[cfg(test)]
|
||||
pub (crate) mod get_test_credentials;
|
||||
+48
-14
@@ -1,6 +1,7 @@
|
||||
use crate::core::commands::enqueue::common::notify_frontend_of_errors::notify_frontend_of_errors;
|
||||
use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError, MissingCredentialsReason, ProviderFailureReason};
|
||||
use crate::core::commands::enqueue::image_to_video::generic::handle_video::handle_video;
|
||||
use crate::core::commands::enqueue::image_to_video::sora2::handle_sora2_video::handle_sora2_video;
|
||||
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
|
||||
use crate::core::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus};
|
||||
use crate::core::commands::response::shorthand::Response;
|
||||
@@ -14,6 +15,7 @@ use crate::core::state::provider_priority::{Provider, ProviderPriorityStore};
|
||||
use crate::core::state::task_database::TaskDatabase;
|
||||
use crate::services::fal::state::fal_credential_manager::FalCredentialManager;
|
||||
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
|
||||
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
|
||||
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
|
||||
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
|
||||
use fal_client::requests::queue::image_gen::enqueue_flux_pro_11_ultra_text_to_image::{enqueue_flux_pro_11_ultra_text_to_image, FluxPro11UltraTextToImageArgs};
|
||||
@@ -39,6 +41,9 @@ pub enum VideoModel {
|
||||
#[serde(rename = "seedance_1.0_lite")]
|
||||
Seedance10Lite,
|
||||
|
||||
#[serde(rename = "sora_2")]
|
||||
Sora2,
|
||||
|
||||
#[serde(rename = "veo_2")]
|
||||
Veo2,
|
||||
|
||||
@@ -49,13 +54,13 @@ pub enum VideoModel {
|
||||
Veo3Fast,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct EnqueueImageToVideoRequest {
|
||||
/// REQUIRED.
|
||||
/// The model to use.
|
||||
pub model: Option<VideoModel>,
|
||||
|
||||
/// Currently REQUIRED.
|
||||
/// Currently REQUIRED by some downstream models.
|
||||
/// Image media file; the starting frame of the video.
|
||||
/// TODO: In the future we may support base64 images, URLs, or file paths here.
|
||||
pub image_media_token: Option<MediaFileToken>,
|
||||
@@ -68,6 +73,18 @@ pub struct EnqueueImageToVideoRequest {
|
||||
/// Optional.
|
||||
/// Text prompt used to direct the video.
|
||||
pub prompt: Option<String>,
|
||||
|
||||
/// OPTIONAL.
|
||||
/// Only for Sora2 model currently.
|
||||
pub sora_orientation: Option<SoraOrientation>,
|
||||
}
|
||||
|
||||
// TODO: Not sure how to handle so many different types of video (model) x (services).
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SoraOrientation {
|
||||
Portrait,
|
||||
Landscape,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -95,8 +112,8 @@ pub enum EnqueueImageToVideoErrorType {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn enqueue_image_to_video_command(
|
||||
app: AppHandle,
|
||||
request: EnqueueImageToVideoRequest,
|
||||
app: AppHandle,
|
||||
app_env_configs: State<'_, AppEnvConfigs>,
|
||||
app_data_root: State<'_, AppDataRoot>,
|
||||
provider_priority_store: State<'_, ProviderPriorityStore>,
|
||||
@@ -105,9 +122,10 @@ pub async fn enqueue_image_to_video_command(
|
||||
fal_task_queue: State<'_, FalTaskQueue>,
|
||||
storyteller_creds_manager: State<'_, StorytellerCredentialManager>,
|
||||
sora_task_queue: State<'_, SoraTaskQueue>,
|
||||
sora_creds_manager: State<'_, SoraCredentialManager>,
|
||||
) -> Response<EnqueueImageToVideoSuccessResponse, EnqueueImageToVideoErrorType, ()> {
|
||||
|
||||
info!("enqueue_image_to_video_command called");
|
||||
info!("enqueue_image_to_video_command called, request: {:?}", request);
|
||||
|
||||
let result = handle_request(
|
||||
request,
|
||||
@@ -117,6 +135,7 @@ pub async fn enqueue_image_to_video_command(
|
||||
&provider_priority_store,
|
||||
&task_database,
|
||||
&fal_creds_manager,
|
||||
&sora_creds_manager,
|
||||
&storyteller_creds_manager,
|
||||
&fal_task_queue,
|
||||
).await;
|
||||
@@ -189,20 +208,35 @@ pub async fn handle_request(
|
||||
provider_priority_store: &ProviderPriorityStore,
|
||||
task_database: &TaskDatabase,
|
||||
fal_creds_manager: &FalCredentialManager,
|
||||
sora_creds_manager: &SoraCredentialManager,
|
||||
storyteller_creds_manager: &StorytellerCredentialManager,
|
||||
fal_task_queue: &FalTaskQueue,
|
||||
) -> Result<TaskEnqueueSuccess, GenerateError> {
|
||||
|
||||
let result = handle_video(
|
||||
request,
|
||||
&app,
|
||||
&app_env_configs,
|
||||
&app_data_root,
|
||||
&provider_priority_store,
|
||||
&fal_creds_manager,
|
||||
&storyteller_creds_manager,
|
||||
&fal_task_queue,
|
||||
).await;
|
||||
let result = match request.model {
|
||||
Some(VideoModel::Sora2) => {
|
||||
handle_sora2_video(
|
||||
&request,
|
||||
app,
|
||||
app_data_root,
|
||||
app_env_configs,
|
||||
provider_priority_store,
|
||||
sora_creds_manager,
|
||||
).await
|
||||
}
|
||||
_ => {
|
||||
handle_video(
|
||||
request,
|
||||
app,
|
||||
app_env_configs,
|
||||
app_data_root,
|
||||
provider_priority_store,
|
||||
fal_creds_manager,
|
||||
storyteller_creds_manager,
|
||||
fal_task_queue,
|
||||
).await
|
||||
}
|
||||
};
|
||||
|
||||
let success_event = match result {
|
||||
Err(err) => return Err(err),
|
||||
|
||||
+6
@@ -8,6 +8,7 @@ use crate::core::events::generation_events::generation_enqueue_success_event::Ge
|
||||
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
|
||||
use crate::core::state::data_dir::app_data_root::AppDataRoot;
|
||||
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
|
||||
use anyhow::anyhow;
|
||||
use artcraft_api_defs::generate::video::generate_kling_1_6_pro_image_to_video::{GenerateKling16ProAspectRatio, GenerateKling16ProImageToVideoRequest};
|
||||
use artcraft_api_defs::generate::video::generate_kling_2_1_master_image_to_video::{GenerateKling21MasterAspectRatio, GenerateKling21MasterImageToVideoRequest};
|
||||
use artcraft_api_defs::generate::video::generate_kling_2_1_pro_image_to_video::{GenerateKling21ProAspectRatio, GenerateKling21ProImageToVideoRequest};
|
||||
@@ -73,6 +74,11 @@ pub async fn handle_video_artcraft(
|
||||
None => {
|
||||
return Err(GenerateError::no_model_specified());
|
||||
}
|
||||
Some(VideoModel::Sora2) => {
|
||||
return Err(GenerateError::AnyhowError(
|
||||
anyhow!("wrong logic: another branch should handle this: {:?}",
|
||||
request.model)));
|
||||
}
|
||||
Some(VideoModel::Kling16Pro) => {
|
||||
info!("enqueue Kling 1.6 Pro with Artcraft API");
|
||||
selected_model = Some(GenerationModel::Kling1_6);
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod enqueue_image_to_video_command;
|
||||
mod generic;
|
||||
mod generic;
|
||||
pub mod sora2;
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
use crate::core::commands::enqueue::generate_error::GenerateError;
|
||||
use crate::core::commands::enqueue::image_edit::enqueue_contextual_edit_image_command::EnqueueContextualEditImageCommand;
|
||||
use crate::core::commands::enqueue::image_inpaint::enqueue_image_inpaint_command::EnqueueInpaintImageCommand;
|
||||
use crate::core::commands::enqueue::image_to_video::enqueue_image_to_video_command::EnqueueImageToVideoRequest;
|
||||
use crate::core::commands::enqueue::image_to_video::sora2::handle_sora2_video_sora::handle_sora2_video_sora;
|
||||
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
|
||||
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
|
||||
use crate::core::state::data_dir::app_data_root::AppDataRoot;
|
||||
use crate::core::state::provider_priority::{Provider, ProviderPriorityStore};
|
||||
use crate::services::fal::state::fal_credential_manager::FalCredentialManager;
|
||||
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
|
||||
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
|
||||
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
|
||||
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
|
||||
use log::info;
|
||||
use tauri::AppHandle;
|
||||
|
||||
pub async fn handle_sora2_video(
|
||||
request: &EnqueueImageToVideoRequest,
|
||||
app: &AppHandle,
|
||||
app_data_root: &AppDataRoot,
|
||||
app_env_configs: &AppEnvConfigs,
|
||||
provider_priority_store: &ProviderPriorityStore,
|
||||
sora_creds_manager: &SoraCredentialManager,
|
||||
) -> Result<TaskEnqueueSuccess, GenerateError> {
|
||||
|
||||
let priority = provider_priority_store.get_priority()?;
|
||||
|
||||
// TODO: Check if providers are available before proceeding.
|
||||
|
||||
info!("Providers by priority: {:?}", priority);
|
||||
|
||||
for provider in priority.iter() {
|
||||
match provider {
|
||||
Provider::Fal => {
|
||||
// Fallthrough (for now)
|
||||
}
|
||||
Provider::Artcraft => {
|
||||
// Fallthrough
|
||||
}
|
||||
Provider::Sora => {
|
||||
info!("Dispatching Sora2 via OpenAI Sora...");
|
||||
return handle_sora2_video_sora(
|
||||
request,
|
||||
app,
|
||||
app_data_root,
|
||||
app_env_configs,
|
||||
sora_creds_manager,
|
||||
).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(GenerateError::NoProviderAvailable)
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
use crate::core::commands::enqueue::generate_error::GenerateError;
|
||||
use crate::core::commands::enqueue::image_to_video::enqueue_image_to_video_command::{EnqueueImageToVideoRequest, SoraOrientation};
|
||||
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
|
||||
use crate::core::events::generation_events::common::GenerationModel;
|
||||
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
|
||||
use crate::core::state::data_dir::app_data_root::AppDataRoot;
|
||||
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
|
||||
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
|
||||
use crate::services::sora::utils::upload_images_to_sora::{upload_images_to_sora, UploadImagesToSoraArgs, UploadImagesToSoraResult};
|
||||
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use enums::tauri::tasks::task_type::TaskType;
|
||||
use log::{error, info};
|
||||
use openai_sora_client::recipes::generate_sora2_video::generate_sora2_video_with_session_auto_renew::generate_sora2_video_with_session_auto_renew;
|
||||
use openai_sora_client::recipes::list_sora2_drafts::list_sora2_drafts_with_session_auto_renew::list_sora2_drafts_with_session_auto_renew;
|
||||
use openai_sora_client::requests::generate_sora2_video::generate_sora2_video::{GenerateSora2VideoArgs, Orientation};
|
||||
use tauri::AppHandle;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use crate::core::events::functional_events::show_provider_login_modal_event::ShowProviderLoginModalEvent;
|
||||
|
||||
pub (super) async fn handle_sora2_video_sora(
|
||||
request: &EnqueueImageToVideoRequest,
|
||||
app: &AppHandle,
|
||||
app_data_root: &AppDataRoot,
|
||||
app_env_configs: &AppEnvConfigs,
|
||||
sora_creds_manager: &SoraCredentialManager,
|
||||
) -> Result<TaskEnqueueSuccess, GenerateError> {
|
||||
|
||||
let mut sora_creds = match sora_creds_manager.get_credentials()? {
|
||||
Some(creds) => creds,
|
||||
None => {
|
||||
ShowProviderLoginModalEvent::send_for_provider(GenerationProvider::Sora, &app);
|
||||
return Err(GenerateError::needs_sora_credentials());
|
||||
},
|
||||
};
|
||||
|
||||
// TODO: Handle multiple input images.
|
||||
let image_media_tokens = match &request.image_media_token {
|
||||
Some(token) => vec![token.clone()],
|
||||
None => vec![],
|
||||
};
|
||||
|
||||
let mut image_reference_media_ids = None;
|
||||
|
||||
if !image_media_tokens.is_empty() {
|
||||
let result = upload_images_to_sora(UploadImagesToSoraArgs {
|
||||
storyteller_host: &app_env_configs.storyteller_host,
|
||||
image_media_tokens: &image_media_tokens,
|
||||
app_data_root,
|
||||
sora_creds_manager,
|
||||
}).await?;
|
||||
|
||||
if let Some(creds) = result.maybe_new_sora_credentials {
|
||||
sora_creds = creds;
|
||||
}
|
||||
|
||||
image_reference_media_ids = Some(result.sora_media_tokens);
|
||||
}
|
||||
|
||||
let orientation = match request.sora_orientation {
|
||||
Some(SoraOrientation::Landscape) => Orientation::Landscape,
|
||||
Some(SoraOrientation::Portrait) => Orientation::Portrait,
|
||||
None => Orientation::Landscape,
|
||||
};
|
||||
|
||||
info!("Calling Sora 2 generate...");
|
||||
|
||||
let result = generate_sora2_video_with_session_auto_renew(
|
||||
GenerateSora2VideoArgs {
|
||||
prompt: request.prompt.as_deref().unwrap_or(""),
|
||||
credentials: &sora_creds,
|
||||
request_timeout: None,
|
||||
orientation,
|
||||
image_reference_media_ids: image_reference_media_ids.as_ref(),
|
||||
}
|
||||
).await;
|
||||
|
||||
let job_id = match result {
|
||||
Ok((response, maybe_new_session)) => {
|
||||
info!("Successfully enqueued Sora2 Video: {}", response.task_id);
|
||||
|
||||
if let Some(new_creds) = maybe_new_session {
|
||||
if let Err(err) = sora_creds_manager.set_credentials(&new_creds) {
|
||||
error!("Failed to save renewed Sora credentials: {:?}", err);
|
||||
}
|
||||
}
|
||||
|
||||
response.task_id
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Failed to use Sora2 video: {:?}", err);
|
||||
return Err(GenerateError::from(err));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(TaskEnqueueSuccess {
|
||||
provider: GenerationProvider::Sora,
|
||||
model: Some(GenerationModel::Sora2),
|
||||
provider_job_id: Some(job_id.to_string()),
|
||||
task_type: TaskType::VideoGeneration,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub (super) mod handle_sora2_video;
|
||||
pub (super) mod handle_sora2_video_sora;
|
||||
@@ -70,6 +70,7 @@ impl TaskEnqueueSuccess{
|
||||
Some(GenerationModel::Kling21Pro) => Some(TaskModelType::Kling21Pro),
|
||||
Some(GenerationModel::Kling21Master) => Some(TaskModelType::Kling21Master),
|
||||
Some(GenerationModel::Seedance10Lite) => Some(TaskModelType::Seedance10Lite),
|
||||
Some(GenerationModel::Sora2) => Some(TaskModelType::Sora2),
|
||||
Some(GenerationModel::Veo2) => Some(TaskModelType::Veo2),
|
||||
Some(GenerationModel::Veo3) => Some(TaskModelType::Veo3),
|
||||
Some(GenerationModel::Veo3Fast) => Some(TaskModelType::Veo3Fast),
|
||||
|
||||
@@ -55,6 +55,8 @@ pub enum GenerationModel {
|
||||
Recraft3,
|
||||
#[serde(rename = "sora")]
|
||||
Sora,
|
||||
#[serde(rename = "sora_2")]
|
||||
Sora2,
|
||||
#[serde(rename = "seedance_1.0_lite")]
|
||||
Seedance10Lite,
|
||||
#[serde(rename = "veo_2")]
|
||||
|
||||
@@ -64,6 +64,7 @@ pub async fn handle_tauri_startup(
|
||||
&app,
|
||||
&root,
|
||||
&app_env_configs,
|
||||
&task_database,
|
||||
&sora_credential_manager,
|
||||
&storyteller_creds_manager,
|
||||
&sora_task_queue,
|
||||
|
||||
+4
-1
@@ -1,8 +1,9 @@
|
||||
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
|
||||
use crate::core::state::data_dir::app_data_root::AppDataRoot;
|
||||
use crate::core::state::task_database::TaskDatabase;
|
||||
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
|
||||
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
|
||||
use crate::services::sora::threads::sora_task_polling_thread::sora_task_polling_thread;
|
||||
use crate::services::sora::threads::sora_task_polling::sora_task_polling_thread::sora_task_polling_thread;
|
||||
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
|
||||
use errors::AnyhowResult;
|
||||
use tauri::{AppHandle, Manager};
|
||||
@@ -11,6 +12,7 @@ pub fn spawn_sora_task_polling_thread(
|
||||
app: &AppHandle,
|
||||
root: &AppDataRoot,
|
||||
app_env_configs: &AppEnvConfigs,
|
||||
task_database: &TaskDatabase,
|
||||
sora_credential_manager: &SoraCredentialManager,
|
||||
storyteller_creds_manager: &StorytellerCredentialManager,
|
||||
sora_task_queue: &SoraTaskQueue,
|
||||
@@ -20,6 +22,7 @@ pub fn spawn_sora_task_polling_thread(
|
||||
app.clone(),
|
||||
app_env_configs.clone(),
|
||||
root.clone(),
|
||||
task_database.clone(),
|
||||
sora_credential_manager.clone(),
|
||||
storyteller_creds_manager.clone(),
|
||||
sora_task_queue.clone(),
|
||||
|
||||
@@ -9,4 +9,5 @@ pub mod object_store;
|
||||
pub mod save_base64_image_to_temp_dir;
|
||||
pub mod simple_http_download;
|
||||
pub mod simple_http_download_to_tempfile;
|
||||
pub mod task_database_pending_statuses;
|
||||
pub mod window;
|
||||
@@ -0,0 +1,12 @@
|
||||
use enums::tauri::tasks::task_status::TaskStatus;
|
||||
use once_cell::sync::Lazy;
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// Reused predicate for querying pending SQLite tasks
|
||||
pub static TASK_DATABASE_PENDING_STATUSES: Lazy<HashSet<TaskStatus>> = Lazy::new(|| {
|
||||
let mut statuses = HashSet::new();
|
||||
statuses.insert(TaskStatus::Pending);
|
||||
statuses.insert(TaskStatus::Started);
|
||||
statuses.insert(TaskStatus::AttemptFailed);
|
||||
statuses
|
||||
});
|
||||
@@ -41,12 +41,11 @@ use crate::services::midjourney::commands::midjourney_open_login_command::midjou
|
||||
use crate::services::midjourney::state::midjourney_credential_manager::MidjourneyCredentialManager;
|
||||
use crate::services::sora::commands::check_sora_session_command::check_sora_session_command;
|
||||
use crate::services::sora::commands::open_sora_login_command::open_sora_login_command;
|
||||
use crate::services::sora::commands::sora_image_generation_command::sora_image_generation_command;
|
||||
use crate::services::sora::commands::sora_image_remix_command::sora_image_remix_command;
|
||||
use crate::services::sora::commands::sora_get_credential_info_command::sora_get_credential_info_command;
|
||||
use crate::services::sora::commands::sora_logout_command::sora_logout_command;
|
||||
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
|
||||
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
|
||||
use crate::services::sora::threads::sora_task_polling_thread::sora_task_polling_thread;
|
||||
use crate::services::sora::threads::sora_task_polling::sora_task_polling_thread::sora_task_polling_thread;
|
||||
use crate::services::storyteller::commands::storyteller_get_credits_command::storyteller_get_credits_command;
|
||||
use crate::services::storyteller::commands::storyteller_get_subscription_command::storyteller_get_subscription_command;
|
||||
use crate::services::storyteller::commands::storyteller_purge_credentials_command::storyteller_purge_credentials_command;
|
||||
@@ -200,8 +199,7 @@ pub fn run() {
|
||||
platform_info_command,
|
||||
set_fal_api_key_command,
|
||||
set_provider_order_command,
|
||||
sora_image_generation_command,
|
||||
sora_image_remix_command,
|
||||
sora_get_credential_info_command,
|
||||
sora_logout_command,
|
||||
storyteller_get_credits_command,
|
||||
storyteller_get_subscription_command,
|
||||
|
||||
+6
-13
@@ -5,6 +5,7 @@ use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
|
||||
use crate::core::state::data_dir::app_data_root::AppDataRoot;
|
||||
use crate::core::state::data_dir::trait_data_subdir::DataSubdir;
|
||||
use crate::core::state::task_database::TaskDatabase;
|
||||
use crate::core::utils::task_database_pending_statuses::TASK_DATABASE_PENDING_STATUSES;
|
||||
use crate::services::midjourney::state::midjourney_credential_manager::MidjourneyCredentialManager;
|
||||
use crate::services::midjourney::threads::events::maybe_handle_text_to_image_complete_event::maybe_handle_text_to_image_complete_event;
|
||||
use crate::services::midjourney::utils::download_midjourney_image::download_midjourney_image;
|
||||
@@ -31,22 +32,14 @@ use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use storyteller_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
use storyteller_client::error::api_error::ApiError;
|
||||
use storyteller_client::error::storyteller_error::StorytellerError;
|
||||
use storyteller_client::endpoints::media_files::upload_image_media_file_from_file::{upload_image_media_file_from_file, UploadImageFromFileArgs};
|
||||
use storyteller_client::endpoints::prompts::create_prompt::create_prompt;
|
||||
use storyteller_client::error::api_error::ApiError;
|
||||
use storyteller_client::error::storyteller_error::StorytellerError;
|
||||
use tauri::AppHandle;
|
||||
use tokens::tokens::batch_generations::BatchGenerationToken;
|
||||
use url::Url;
|
||||
|
||||
static PENDING_STATUSES : Lazy<HashSet<TaskStatus>> = Lazy::new(|| {
|
||||
let mut statuses = HashSet::new();
|
||||
statuses.insert(TaskStatus::Pending);
|
||||
statuses.insert(TaskStatus::Started);
|
||||
statuses.insert(TaskStatus::AttemptFailed);
|
||||
statuses
|
||||
});
|
||||
|
||||
/// This thread is responsible for picking up tasks that fell through the cracks of
|
||||
/// the faster websocket thread.
|
||||
pub async fn midjourney_long_polling_thread(
|
||||
@@ -125,10 +118,10 @@ async fn polling_loop(
|
||||
let local_tasks = list_tasks_by_provider_and_status(ListTasksByProviderAndStatusArgs {
|
||||
db: task_database.get_connection(),
|
||||
provider: GenerationProvider::Midjourney,
|
||||
task_statuses: &PENDING_STATUSES,
|
||||
task_statuses: &TASK_DATABASE_PENDING_STATUSES,
|
||||
}).await?;
|
||||
|
||||
check_midjourney_tasks(
|
||||
poll_midjourney_tasks(
|
||||
app_handle,
|
||||
app_env_configs,
|
||||
app_data_root,
|
||||
@@ -143,7 +136,7 @@ async fn polling_loop(
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_midjourney_tasks(
|
||||
async fn poll_midjourney_tasks(
|
||||
app_handle: &AppHandle,
|
||||
app_env_configs: &AppEnvConfigs,
|
||||
app_data_root: &AppDataRoot,
|
||||
|
||||
@@ -3,7 +3,7 @@ use chrono::{TimeDelta, Utc};
|
||||
use errors::AnyhowResult;
|
||||
use log::{error, info};
|
||||
use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session;
|
||||
use openai_sora_client::requests::list_media::list_media::list_media;
|
||||
use openai_sora_client::requests::list_classic_tasks::list_classic_tasks::list_classic_tasks;
|
||||
use serde_derive::Serialize;
|
||||
use std::ops::Add;
|
||||
use tauri::{AppHandle, State};
|
||||
@@ -28,7 +28,8 @@ pub enum SoraSessionState{
|
||||
Valid,
|
||||
}
|
||||
|
||||
|
||||
// TODO: Deprecate this
|
||||
#[deprecated(note="Not 100% deprecated yet - it may still have uses. But use sora_get_credential_info_command instead.")]
|
||||
#[tauri::command]
|
||||
pub async fn check_sora_session_command(
|
||||
app: AppHandle,
|
||||
@@ -87,7 +88,7 @@ async fn do_check(
|
||||
info!("Attempting to upgrade session...");
|
||||
upgraded = maybe_upgrade_or_renew_session(&mut creds).await?;
|
||||
info!("Attempting to check session...");
|
||||
list_media(&creds).await?;
|
||||
list_classic_tasks(&creds).await?; // Intent here is to make sure the upgrade request worked.
|
||||
sora_creds_manager.record_credential_success()?;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
pub mod check_sora_session_command;
|
||||
pub mod open_sora_login_command;
|
||||
pub mod sora_image_generation_command;
|
||||
pub mod sora_image_remix_command;
|
||||
pub mod sora_get_credential_info_command;
|
||||
pub mod sora_logout_command;
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
use crate::core::commands::response::shorthand::{Response, ResponseOrErrorMessage};
|
||||
use crate::core::commands::response::success_response_wrapper::SerializeMarker;
|
||||
use crate::services::fal::state::fal_credential_manager::FalCredentialManager;
|
||||
use crate::services::midjourney::state::midjourney_credential_manager::MidjourneyCredentialManager;
|
||||
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
|
||||
use errors::AnyhowResult;
|
||||
use log::{error, info};
|
||||
use serde_derive::Serialize;
|
||||
use tauri::State;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SoraGetCredentialInfoResponse {
|
||||
pub maybe_email: Option<String>,
|
||||
pub can_clear_state: bool,
|
||||
}
|
||||
|
||||
impl SerializeMarker for SoraGetCredentialInfoResponse {}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn sora_get_credential_info_command(
|
||||
sora_creds_manager: State<'_, SoraCredentialManager>,
|
||||
) -> ResponseOrErrorMessage<SoraGetCredentialInfoResponse> {
|
||||
info!("sora_get_credential_info_command called");
|
||||
|
||||
let info = get_info(&sora_creds_manager)
|
||||
.map_err(|err| {
|
||||
error!("Error getting info: {:?}", err);
|
||||
"error getting info"
|
||||
})?;
|
||||
|
||||
Ok(info.into())
|
||||
}
|
||||
|
||||
fn get_info(
|
||||
sora_creds_manager: &SoraCredentialManager,
|
||||
) -> AnyhowResult<SoraGetCredentialInfoResponse> {
|
||||
|
||||
let maybe_creds = sora_creds_manager.get_credentials()?;
|
||||
|
||||
let mut can_clear_state = maybe_creds.is_some();
|
||||
let mut maybe_email = None;
|
||||
|
||||
if let Some(creds) = maybe_creds {
|
||||
if let Some(jwt) = &creds.jwt_bearer_token {
|
||||
maybe_email = Some(jwt.jwt_claims().email.clone());
|
||||
}
|
||||
|
||||
// TODO: This was cargo culted from Midjourney. Probably not quite right.
|
||||
// We probably need an Option<T> for cookie rather than an empty / partial string as
|
||||
// well as timestamps, invalidation states, etc.
|
||||
if creds.jwt_bearer_token.is_none() && creds.sora_sentinel.is_none() {
|
||||
can_clear_state = false;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(SoraGetCredentialInfoResponse {
|
||||
maybe_email,
|
||||
can_clear_state,
|
||||
})
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
use crate::core::state::data_dir::app_data_root::AppDataRoot;
|
||||
use crate::services::sora::state::read_sora_credentials_from_disk::read_sora_credentials_from_disk;
|
||||
use crate::services::sora::state::sora_credential_holder::SoraCredentialHolder;
|
||||
use base64::prelude::BASE64_STANDARD;
|
||||
use base64::Engine;
|
||||
use errors::AnyhowResult;
|
||||
use log::{error, info};
|
||||
use openai_sora_client::requests::image_gen::common::{ImageSize, NumImages};
|
||||
use openai_sora_client::requests::image_gen::sora_image_gen_remix::{sora_image_gen_remix, SoraImageGenRemixRequest};
|
||||
use openai_sora_client::requests::upload::upload_media_from_bytes::sora_media_upload_from_bytes;
|
||||
use std::time::Duration;
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
const SORA_IMAGE_UPLOAD_TIMEOUT: Duration = Duration::from_millis(1000 * 30); // 30 seconds
|
||||
|
||||
const SORA_IMAGE_REMIX_TIMEOUT: Duration = Duration::from_millis(1000 * 30); // 30 seconds
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn sora_image_generation_command(
|
||||
prompt: &str,
|
||||
image: Option<&str>,
|
||||
_app: AppHandle,
|
||||
app_data_root: State<'_, AppDataRoot>,
|
||||
sora_creds_holder: State<'_, SoraCredentialHolder>,
|
||||
) -> Result<String, String> {
|
||||
info!("image_generation_command called; processing image...");
|
||||
|
||||
generate_image(image, prompt, &app_data_root, &sora_creds_holder)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("error: {:?}", err);
|
||||
"there was an error".to_string()
|
||||
})?;
|
||||
|
||||
Ok("success".to_string())
|
||||
}
|
||||
|
||||
pub async fn generate_image(
|
||||
maybe_image: Option<&str>,
|
||||
prompt: &str,
|
||||
app_data_root: &AppDataRoot,
|
||||
sora_creds_holder: &SoraCredentialHolder,
|
||||
) -> AnyhowResult<()> {
|
||||
|
||||
let creds = read_sora_credentials_from_disk(app_data_root)
|
||||
.map_err(|err| {
|
||||
error!("Failed to read Sora credentials from disk: {:?}", err);
|
||||
err
|
||||
})?;
|
||||
|
||||
let mut sora_media_tokens = vec![];
|
||||
|
||||
if let Some(image) = maybe_image {
|
||||
let image_bytes = BASE64_STANDARD.decode(image)?;
|
||||
|
||||
let filename = "image.png".to_string();
|
||||
|
||||
|
||||
let response = sora_media_upload_from_bytes(
|
||||
image_bytes,
|
||||
filename,
|
||||
&creds,
|
||||
Some(SORA_IMAGE_UPLOAD_TIMEOUT))
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("Failed to upload image to Sora: {:?}", err);
|
||||
err
|
||||
})?;
|
||||
|
||||
sora_media_tokens.push(response.id);
|
||||
}
|
||||
|
||||
let response = sora_image_gen_remix(SoraImageGenRemixRequest {
|
||||
prompt: prompt.to_string(),
|
||||
num_images: NumImages::One,
|
||||
image_size: ImageSize::Square,
|
||||
sora_media_tokens: sora_media_tokens.clone(),
|
||||
credentials: &creds,
|
||||
request_timeout: Some(SORA_IMAGE_REMIX_TIMEOUT),
|
||||
}).await
|
||||
.map_err(|err| {
|
||||
error!("Failed to call Sora image generation: {:?}", err);
|
||||
err
|
||||
})?;
|
||||
|
||||
println!(">> TASK ID: {:?} ", response.task_id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
use crate::core::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus};
|
||||
use crate::core::commands::response::shorthand::Response;
|
||||
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
|
||||
use crate::core::events::generation_events::common::{GenerationAction, GenerationServiceProvider};
|
||||
use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent;
|
||||
use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent;
|
||||
use crate::core::events::sendable_event_trait::SendableEvent;
|
||||
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
|
||||
use crate::core::state::data_dir::app_data_root::AppDataRoot;
|
||||
use crate::core::state::data_dir::trait_data_subdir::DataSubdir;
|
||||
use crate::core::utils::get_url_file_extension::get_url_file_extension;
|
||||
use crate::core::utils::simple_http_download::simple_http_download;
|
||||
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
|
||||
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
|
||||
use errors::AnyhowError;
|
||||
use log::{error, info, warn};
|
||||
use openai_sora_client::error::sora_error::SoraError;
|
||||
use openai_sora_client::error::sora_specific_api_error::SoraSpecificApiError;
|
||||
use openai_sora_client::recipes::image_remix_with_session_auto_renew::{image_remix_with_session_auto_renew, ImageRemixAutoRenewRequest};
|
||||
use openai_sora_client::recipes::image_upload_from_file_with_session_auto_renew::{image_upload_from_file_with_session_auto_renew, ImageUploadFromFileAutoRenewRequest};
|
||||
use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session;
|
||||
use openai_sora_client::requests::image_gen::common::{ImageSize, NumImages};
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
use storyteller_client::endpoints::media_files::get_media_file::get_media_file;
|
||||
use storyteller_client::error::storyteller_error::StorytellerError;
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
const SORA_IMAGE_UPLOAD_TIMEOUT: Duration = Duration::from_millis(1000 * 30); // 30 seconds
|
||||
|
||||
const SORA_IMAGE_REMIX_TIMEOUT: Duration = Duration::from_millis(1000 * 30); // 30 seconds
|
||||
|
||||
const DEFAULT_ASPECT_RATIO : SoraAspectRatio = SoraAspectRatio::Square;
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct SoraImageRemixCommand {
|
||||
/// Image media file; the engine or canvas snapshot (screenshot).
|
||||
pub snapshot_media_token: MediaFileToken,
|
||||
|
||||
/// The user's image generation prompt.
|
||||
pub prompt: String,
|
||||
|
||||
/// Turn off the system prompt.
|
||||
pub disable_system_prompt: Option<bool>,
|
||||
|
||||
/// Additional images to include (optional). Up to nine images.
|
||||
pub maybe_additional_images: Option<Vec<MediaFileToken>>,
|
||||
|
||||
pub maybe_number_of_samples: Option<u32>,
|
||||
|
||||
/// Aspect ratio.
|
||||
pub aspect_ratio: Option<SoraAspectRatio>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Copy, Clone)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SoraAspectRatio {
|
||||
Square,
|
||||
Wide,
|
||||
Tall,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SoraImageRemixErrorType {
|
||||
/// Generic server error
|
||||
ServerError,
|
||||
/// The user is sending too many requests
|
||||
TooManyConcurrentTasks,
|
||||
/// User is not logged into Sora!
|
||||
SoraLoginRequired,
|
||||
/// The user needs to create a Sora account
|
||||
SoraUsernameNotYetCreated,
|
||||
/// The Sora service is having problems. Try again soon.
|
||||
SoraIsHavingProblems,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn sora_image_remix_command(
|
||||
app: AppHandle,
|
||||
request: SoraImageRemixCommand,
|
||||
app_env_configs: State<'_, AppEnvConfigs>,
|
||||
app_data_root: State<'_, AppDataRoot>,
|
||||
sora_creds_manager: State<'_, SoraCredentialManager>,
|
||||
sora_task_queue: State<'_, SoraTaskQueue>,
|
||||
) -> Response<(), SoraImageRemixErrorType, ()> {
|
||||
|
||||
info!("image_generation_command called; scene media token: {:?}, additional images: {:?}, full request: {:?}",
|
||||
&request.snapshot_media_token, &request.maybe_additional_images, &request);
|
||||
|
||||
// TODO(bt,2025-04-24): Better error messages to caller
|
||||
|
||||
let has_credentials = sora_creds_manager
|
||||
.has_apparently_complete_credentials()
|
||||
.unwrap_or(true); // NB: Failures would be lock issues
|
||||
|
||||
if !has_credentials {
|
||||
warn!("No apparently completed credentials found");
|
||||
|
||||
let error_message = "You need to log into Sora to continue. See the settings menu.";
|
||||
|
||||
let event = GenerationEnqueueFailureEvent {
|
||||
action: GenerationAction::GenerateImage,
|
||||
service: GenerationServiceProvider::Sora,
|
||||
model: None,
|
||||
reason: Some(error_message.to_string()),
|
||||
};
|
||||
|
||||
if let Err(err) = event.send(&app) {
|
||||
error!("Failed to emit event: {:?}", err); // Fail open.
|
||||
}
|
||||
|
||||
return Err(CommandErrorResponseWrapper {
|
||||
status: CommandErrorStatus::Unauthorized,
|
||||
error_message: Some(error_message.to_string()),
|
||||
error_type: Some(SoraImageRemixErrorType::SoraLoginRequired),
|
||||
error_details: None,
|
||||
});
|
||||
}
|
||||
|
||||
let result = generate_image(
|
||||
request,
|
||||
&app_env_configs,
|
||||
&app_data_root,
|
||||
&sora_creds_manager,
|
||||
&sora_task_queue
|
||||
).await;
|
||||
|
||||
match result {
|
||||
Err(err) => {
|
||||
error!("Sora image remix error: {:?}", err);
|
||||
|
||||
let mut status = CommandErrorStatus::ServerError;
|
||||
let mut error_type = SoraImageRemixErrorType::ServerError;
|
||||
let mut error_message = "An error occurred. Please try again. If it continues, please tell our staff about the problem.";
|
||||
|
||||
match (err) {
|
||||
InnerError::SoraError(SoraError::ApiSpecific(SoraSpecificApiError::TooManyConcurrentTasks)) => {
|
||||
error_type = SoraImageRemixErrorType::TooManyConcurrentTasks;
|
||||
status = CommandErrorStatus::ServerError;
|
||||
error_message = "You have too many Sora image generation tasks running. Please wait a moment.";
|
||||
},
|
||||
InnerError::SoraError(SoraError::ApiSpecific(SoraSpecificApiError::SoraUsernameNotYetCreated)) => {
|
||||
error_type = SoraImageRemixErrorType::SoraUsernameNotYetCreated;
|
||||
status = CommandErrorStatus::BadRequest;
|
||||
error_message = "Your Sora username is not yet created. Please visit the Sora.com website to create it first.";
|
||||
},
|
||||
InnerError::SoraError(err) => {
|
||||
if err.is_sora_having_downtime_issues() {
|
||||
error_type = SoraImageRemixErrorType::SoraIsHavingProblems;
|
||||
status = CommandErrorStatus::ServerError;
|
||||
error_message = "Sora is having problems. Please try again later.";
|
||||
}
|
||||
}
|
||||
_ => {},
|
||||
}
|
||||
|
||||
let event = GenerationEnqueueFailureEvent {
|
||||
action: GenerationAction::GenerateImage,
|
||||
service: GenerationServiceProvider::Sora,
|
||||
model: None,
|
||||
reason: Some(error_message.to_string()),
|
||||
};
|
||||
|
||||
if let Err(err) = event.send(&app) {
|
||||
error!("Failed to emit event: {:?}", err); // Fail open.
|
||||
}
|
||||
|
||||
Err(CommandErrorResponseWrapper {
|
||||
status,
|
||||
error_message: Some(error_message.to_string()),
|
||||
error_type: Some(error_type),
|
||||
error_details: None,
|
||||
})
|
||||
}
|
||||
Ok(_) => {
|
||||
let event = GenerationEnqueueSuccessEvent {
|
||||
action: GenerationAction::GenerateImage,
|
||||
service: GenerationServiceProvider::Sora,
|
||||
model: None,
|
||||
};
|
||||
|
||||
if let Err(err) = event.send(&app) {
|
||||
error!("Failed to emit event: {:?}", err); // Fail open.
|
||||
}
|
||||
|
||||
Ok(().into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum InnerError {
|
||||
SoraError(SoraError),
|
||||
AnyhowError(AnyhowError),
|
||||
StorytellerError(StorytellerError),
|
||||
}
|
||||
|
||||
impl From<AnyhowError> for InnerError {
|
||||
fn from(value: AnyhowError) -> Self {
|
||||
Self::AnyhowError(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SoraError> for InnerError {
|
||||
fn from(value: SoraError) -> Self {
|
||||
Self::SoraError(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<StorytellerError> for InnerError {
|
||||
fn from(value: StorytellerError) -> Self {
|
||||
Self::StorytellerError(value)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn generate_image(
|
||||
request: SoraImageRemixCommand,
|
||||
app_env_configs: &AppEnvConfigs,
|
||||
app_data_root: &AppDataRoot,
|
||||
sora_creds_manager: &SoraCredentialManager,
|
||||
sora_task_queue: &SoraTaskQueue,
|
||||
) -> Result<(), InnerError> {
|
||||
|
||||
let response = get_media_file(
|
||||
&app_env_configs.storyteller_host,
|
||||
&request.snapshot_media_token
|
||||
).await?;
|
||||
|
||||
let media_file_url = &response.media_file.media_links.cdn_url;
|
||||
let extension_with_dot = get_url_file_extension(media_file_url)
|
||||
.map(|ext| format!(".{}", ext))
|
||||
.unwrap_or_else(|| ".png".to_string());
|
||||
|
||||
let filename = format!("{}{}", response.media_file.token.as_str(), extension_with_dot);
|
||||
let filename = app_data_root.downloads_dir().path().join(&filename);
|
||||
|
||||
simple_http_download(&media_file_url, &filename).await?;
|
||||
|
||||
let files_to_upload = vec![filename];
|
||||
|
||||
let mut creds = sora_creds_manager.get_credentials_required()?;
|
||||
|
||||
let credential_updated = maybe_upgrade_or_renew_session(&mut creds)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("Failed to upgrade or renew session: {:?}", err);
|
||||
err
|
||||
})?;
|
||||
|
||||
if credential_updated {
|
||||
info!("Storing updated credentials");
|
||||
sora_creds_manager.set_credentials(&creds)?;
|
||||
}
|
||||
|
||||
let mut sora_media_tokens = Vec::with_capacity(files_to_upload.len());
|
||||
|
||||
for (i, file_path) in files_to_upload.iter().enumerate() {
|
||||
info!("Uploading image {} of {}...", (i+1), files_to_upload.len());
|
||||
|
||||
let (response, maybe_new_credentials) =
|
||||
image_upload_from_file_with_session_auto_renew(ImageUploadFromFileAutoRenewRequest {
|
||||
file_path,
|
||||
credentials: &creds,
|
||||
request_timeout: Some(SORA_IMAGE_UPLOAD_TIMEOUT),
|
||||
}).await?;
|
||||
|
||||
if let Some(new_creds) = maybe_new_credentials {
|
||||
info!("Storing updated credentials.");
|
||||
sora_creds_manager.set_credentials(&new_creds)?;
|
||||
creds = new_creds;
|
||||
}
|
||||
|
||||
sora_media_tokens.push(response.id);
|
||||
}
|
||||
|
||||
let aspect_ratio = request.aspect_ratio.unwrap_or(DEFAULT_ASPECT_RATIO);
|
||||
|
||||
let aspect_ratio = match aspect_ratio {
|
||||
SoraAspectRatio::Square => ImageSize::Square,
|
||||
SoraAspectRatio::Wide => ImageSize::Wide,
|
||||
SoraAspectRatio::Tall => ImageSize::Tall,
|
||||
};
|
||||
|
||||
info!("Calling image generation...");
|
||||
|
||||
// TODO(bt,2025-04-21): Download media tokens.
|
||||
// Note: This is incredibly inefficient. We should keep a local cache.
|
||||
// Also, if they've already been uploaded to OpenAI, we shouldn't continue to re-upload.
|
||||
|
||||
let (response, maybe_new_creds) =
|
||||
image_remix_with_session_auto_renew(ImageRemixAutoRenewRequest {
|
||||
prompt: request.prompt.to_string(),
|
||||
num_images: NumImages::One,
|
||||
image_size: aspect_ratio,
|
||||
sora_media_tokens: sora_media_tokens.clone(),
|
||||
credentials: &creds,
|
||||
request_timeout: Some(SORA_IMAGE_REMIX_TIMEOUT),
|
||||
}).await?;
|
||||
|
||||
if let Some(new_creds) = maybe_new_creds {
|
||||
info!("Storing updated credentials.");
|
||||
sora_creds_manager.set_credentials(&new_creds)?;
|
||||
}
|
||||
|
||||
info!("New Sora Task ID: {:?} ", response.task_id);
|
||||
|
||||
sora_task_queue.insert(&response.task_id)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -2,4 +2,5 @@ pub mod commands;
|
||||
pub mod events;
|
||||
pub mod state;
|
||||
pub mod threads;
|
||||
pub mod windows;
|
||||
pub mod windows;
|
||||
pub mod utils;
|
||||
@@ -1 +1 @@
|
||||
pub mod sora_task_polling_thread;
|
||||
pub mod sora_task_polling;
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
|
||||
use crate::core::events::generation_events::common::{GenerationAction, GenerationServiceProvider};
|
||||
use crate::core::events::generation_events::generation_failed_event::GenerationFailedEvent;
|
||||
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
|
||||
use crate::core::state::data_dir::app_data_root::AppDataRoot;
|
||||
use crate::core::state::task_database::TaskDatabase;
|
||||
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
|
||||
use crate::services::sora::threads::sora_task_polling::helpers::handle_successful_generations::GenerationItem;
|
||||
use enums::tauri::tasks::task_status;
|
||||
use errors::AnyhowResult;
|
||||
use log::info;
|
||||
use openai_sora_client::requests::common::task_id::TaskId;
|
||||
use openai_sora_client::requests::list_classic_tasks::list_classic_tasks::PartialTaskResponse;
|
||||
use sqlite_tasks::queries::list_tasks_by_provider_and_status::Task;
|
||||
use sqlite_tasks::queries::update_task_status::{update_task_status, UpdateTaskArgs};
|
||||
use std::collections::HashMap;
|
||||
use storyteller_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
use tauri::AppHandle;
|
||||
|
||||
pub struct FailedGeneration {
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn handle_classic_failed_generations(
|
||||
app_handle: &AppHandle,
|
||||
task_database: &TaskDatabase,
|
||||
local_sqlite_tasks_by_sora_task_id: &HashMap<String, Task>,
|
||||
sora_failed_tasks_by_id: &HashMap<TaskId, FailedGeneration>,
|
||||
sora_task_queue: &SoraTaskQueue,
|
||||
) -> AnyhowResult<()> {
|
||||
|
||||
for (task_id, task) in sora_failed_tasks_by_id.iter() {
|
||||
// Emit events for failed tasks.
|
||||
if local_sqlite_tasks_by_sora_task_id.contains_key(task_id.as_str()) {
|
||||
let event = GenerationFailedEvent {
|
||||
action: GenerationAction::GenerateImage,
|
||||
service: GenerationServiceProvider::Sora,
|
||||
model: None,
|
||||
reason: task.reason.clone(),
|
||||
};
|
||||
|
||||
event.send_infallible(&app_handle);
|
||||
}
|
||||
|
||||
// Clear from SQLite task database.
|
||||
if let Some(local_task) = local_sqlite_tasks_by_sora_task_id.get(task_id.as_str()) {
|
||||
info!("Marking local task as failed: {:?}", local_task.id);
|
||||
|
||||
let _updated = update_task_status(UpdateTaskArgs {
|
||||
db: task_database.get_connection(),
|
||||
task_id: &local_task.id,
|
||||
status: task_status::TaskStatus::CompleteFailure,
|
||||
}).await?;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear from in memory DB
|
||||
// TODO: Remove the in-memory queue in favor of SQLite only.
|
||||
let failed_task_ids: Vec<&TaskId> = sora_failed_tasks_by_id.keys().collect();
|
||||
sora_task_queue.remove_list(&failed_task_ids)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
|
||||
use crate::core::events::generation_events::common::{GenerationAction, GenerationServiceProvider};
|
||||
use crate::core::events::generation_events::generation_complete_event::GenerationCompleteEvent;
|
||||
use crate::core::events::generation_events::generation_failed_event::GenerationFailedEvent;
|
||||
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
|
||||
use crate::core::state::data_dir::app_data_root::AppDataRoot;
|
||||
use crate::core::state::data_dir::trait_data_subdir::DataSubdir;
|
||||
use crate::core::state::task_database::TaskDatabase;
|
||||
use crate::core::utils::task_database_pending_statuses::TASK_DATABASE_PENDING_STATUSES;
|
||||
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
|
||||
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
|
||||
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
|
||||
use artcraft_api_defs::prompts::create_prompt::CreatePromptRequest;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use enums::common::model_type::ModelType;
|
||||
use enums::tauri::tasks::task_status;
|
||||
use errors::AnyhowResult;
|
||||
use idempotency::uuid::generate_random_uuid;
|
||||
use log::{error, info, warn};
|
||||
use once_cell::sync::Lazy;
|
||||
use openai_sora_client::creds::sora_credential_set::SoraCredentialSet;
|
||||
use openai_sora_client::recipes::list_classic_sora_tasks_with_session_auto_renew::list_classic_sora_tasks_with_session_auto_renew;
|
||||
use openai_sora_client::requests::common::task_id::TaskId;
|
||||
use openai_sora_client::requests::list_classic_tasks::list_classic_tasks::{PartialGeneration, PartialTaskResponse};
|
||||
use reqwest::Url;
|
||||
use sqlite_tasks::queries::list_tasks_by_provider_and_status::{list_tasks_by_provider_and_status, ListTasksByProviderAndStatusArgs, Task, TaskList};
|
||||
use sqlite_tasks::queries::update_task_status::{update_task_status, UpdateTaskArgs};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use storyteller_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
use storyteller_client::endpoints::media_files::upload_image_media_file_from_file::{upload_image_media_file_from_file, UploadImageFromFileArgs};
|
||||
use storyteller_client::endpoints::media_files::upload_video_media_file_from_file::{upload_video_media_file_from_file, UploadVideoFromFileArgs};
|
||||
use storyteller_client::endpoints::prompts::create_prompt::create_prompt;
|
||||
use tauri::AppHandle;
|
||||
use tempdir::TempDir;
|
||||
|
||||
pub struct SuccessfulGeneration {
|
||||
pub prompt: Option<String>,
|
||||
pub items: Vec<GenerationItem>,
|
||||
pub model_type: ModelType,
|
||||
}
|
||||
|
||||
pub struct GenerationItem {
|
||||
pub item_id: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Eq, PartialEq)]
|
||||
enum GenerationType {
|
||||
Image,
|
||||
Video,
|
||||
}
|
||||
|
||||
pub async fn handle_classic_successful_generations(
|
||||
app_handle: &AppHandle,
|
||||
app_data_root: &AppDataRoot,
|
||||
app_env_configs: &AppEnvConfigs,
|
||||
task_database: &TaskDatabase,
|
||||
storyteller_creds: &StorytellerCredentialSet,
|
||||
succeeded_tasks_by_id: &HashMap<TaskId, SuccessfulGeneration>,
|
||||
sqlite_tasks_by_sora_task_id: &HashMap<String, Task>,
|
||||
) -> AnyhowResult<()> {
|
||||
|
||||
for (task_id, generation) in succeeded_tasks_by_id.iter() {
|
||||
if !sqlite_tasks_by_sora_task_id.contains_key(task_id.as_str()) {
|
||||
continue; // Task is irrelevant - previously completed, generated elsewhere, etc.
|
||||
}
|
||||
|
||||
info!("Task succeeded: {:?}", task_id);
|
||||
|
||||
let generation_type = match generation.model_type {
|
||||
ModelType::GptImage1 => GenerationType::Image,
|
||||
ModelType::Sora2 => GenerationType::Video,
|
||||
_ => {
|
||||
// Fallback
|
||||
warn!("Unexpected model type: {:?}", generation.model_type);
|
||||
GenerationType::Image
|
||||
},
|
||||
};
|
||||
|
||||
let prompt_request = CreatePromptRequest {
|
||||
uuid_idempotency_token: generate_random_uuid(),
|
||||
positive_prompt: generation.prompt.clone(),
|
||||
negative_prompt: None,
|
||||
model_type: Some(generation.model_type),
|
||||
generation_provider: Some(GenerationProvider::Sora),
|
||||
};
|
||||
|
||||
let prompt_response = create_prompt(
|
||||
&app_env_configs.storyteller_host,
|
||||
Some(&storyteller_creds),
|
||||
prompt_request
|
||||
).await?;
|
||||
|
||||
info!("Created prompt: {:?}", &prompt_response.prompt_token);
|
||||
|
||||
for (_i, item) in generation.items.iter().enumerate() {
|
||||
info!("Downloading generated file...");
|
||||
let download_path = download_generation_item(item, &app_data_root).await?;
|
||||
|
||||
info!("Uploading to backend...");
|
||||
|
||||
match generation_type {
|
||||
GenerationType::Image => {
|
||||
let result = upload_image_media_file_from_file(UploadImageFromFileArgs {
|
||||
api_host: &app_env_configs.storyteller_host,
|
||||
maybe_creds: Some(&storyteller_creds),
|
||||
path: download_path,
|
||||
is_intermediate_system_file: false,
|
||||
maybe_prompt_token: Some(&prompt_response.prompt_token),
|
||||
maybe_batch_token: None, // TODO: This should be added soon.
|
||||
}).await?;
|
||||
|
||||
info!("Uploaded image to API backend: {:?}", result.media_file_token);
|
||||
}
|
||||
GenerationType::Video => {
|
||||
let result = upload_video_media_file_from_file(UploadVideoFromFileArgs {
|
||||
api_host: &app_env_configs.storyteller_host,
|
||||
maybe_creds: Some(&storyteller_creds),
|
||||
path: download_path,
|
||||
maybe_prompt_token: Some(&prompt_response.prompt_token),
|
||||
}).await?;
|
||||
|
||||
info!("Uploaded video to API backend: {:?}", result.media_file_token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear from SQLite task database.
|
||||
if let Some(local_task) = sqlite_tasks_by_sora_task_id.get(task_id.as_str()) {
|
||||
info!("Marking local task as failed: {:?}", local_task.id);
|
||||
|
||||
let updated = update_task_status(UpdateTaskArgs {
|
||||
db: task_database.get_connection(),
|
||||
task_id: &local_task.id,
|
||||
status: task_status::TaskStatus::CompleteSuccess,
|
||||
}).await?;
|
||||
|
||||
if updated {
|
||||
// If anything breaks with queries, don't spam events.
|
||||
let event = GenerationCompleteEvent {
|
||||
action: Some(match generation_type {
|
||||
GenerationType::Image => GenerationAction::GenerateImage,
|
||||
GenerationType::Video => GenerationAction::GenerateVideo,
|
||||
}),
|
||||
service: GenerationServiceProvider::Sora,
|
||||
model: None,
|
||||
};
|
||||
|
||||
event.send_infallible(&app_handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
async fn download_generation_item(generation: &GenerationItem, app_data_root: &AppDataRoot) -> AnyhowResult<PathBuf> {
|
||||
let url = Url::parse(&generation.url)?;
|
||||
|
||||
let response = reqwest::get(&generation.url).await?;
|
||||
let image_bytes = response.bytes().await?;
|
||||
|
||||
let ext = url.path().split(".").last().unwrap_or("png");
|
||||
|
||||
let tempdir = app_data_root.temp_dir().path();
|
||||
let download_filename = format!("{}.{}", generation.item_id, ext);
|
||||
let download_path = tempdir.join(download_filename);
|
||||
|
||||
let mut file = File::create(&download_path)?;
|
||||
file.write_all(&image_bytes)?;
|
||||
|
||||
Ok(download_path)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub (super) mod handle_successful_generations;
|
||||
pub (super) mod handle_failed_generations;
|
||||
pub (super) mod poll_classic_sora_tasks;
|
||||
pub (super) mod poll_sora_2_tasks;
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
|
||||
use crate::core::events::generation_events::common::{GenerationAction, GenerationServiceProvider};
|
||||
use crate::core::events::generation_events::generation_complete_event::GenerationCompleteEvent;
|
||||
use crate::core::events::generation_events::generation_failed_event::GenerationFailedEvent;
|
||||
use crate::core::events::sendable_event_trait::SendableEvent;
|
||||
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
|
||||
use crate::core::state::data_dir::app_data_root::AppDataRoot;
|
||||
use crate::core::state::data_dir::trait_data_subdir::DataSubdir;
|
||||
use crate::core::state::task_database::TaskDatabase;
|
||||
use crate::core::utils::task_database_pending_statuses::TASK_DATABASE_PENDING_STATUSES;
|
||||
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
|
||||
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
|
||||
use crate::services::sora::threads::sora_task_polling::helpers::handle_failed_generations::{handle_classic_failed_generations, FailedGeneration};
|
||||
use crate::services::sora::threads::sora_task_polling::helpers::handle_successful_generations::{handle_classic_successful_generations, GenerationItem, SuccessfulGeneration};
|
||||
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
|
||||
use artcraft_api_defs::prompts::create_prompt::CreatePromptRequest;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use enums::common::model_type::ModelType;
|
||||
use enums::tauri::tasks::task_status;
|
||||
use errors::AnyhowResult;
|
||||
use idempotency::uuid::generate_random_uuid;
|
||||
use log::{error, info, warn};
|
||||
use once_cell::sync::Lazy;
|
||||
use openai_sora_client::creds::sora_credential_set::SoraCredentialSet;
|
||||
use openai_sora_client::recipes::list_classic_sora_tasks_with_session_auto_renew::list_classic_sora_tasks_with_session_auto_renew;
|
||||
use openai_sora_client::requests::common::task_id::TaskId;
|
||||
use openai_sora_client::requests::list_classic_tasks::list_classic_tasks::TaskStatus;
|
||||
use reqwest::Url;
|
||||
use sqlite_tasks::queries::list_tasks_by_provider_and_status::{list_tasks_by_provider_and_status, ListTasksByProviderAndStatusArgs, Task, TaskList};
|
||||
use sqlite_tasks::queries::update_task_status::{update_task_status, UpdateTaskArgs};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use storyteller_client::endpoints::media_files::upload_image_media_file_from_file::{upload_image_media_file_from_file, UploadImageFromFileArgs};
|
||||
use storyteller_client::endpoints::prompts::create_prompt::create_prompt;
|
||||
use tauri::AppHandle;
|
||||
use tempdir::TempDir;
|
||||
|
||||
pub async fn poll_classic_sora_tasks(
|
||||
app_handle: &AppHandle,
|
||||
app_env_configs: &AppEnvConfigs,
|
||||
task_database: &TaskDatabase,
|
||||
sora_creds_manager: &SoraCredentialManager,
|
||||
sora_creds: &SoraCredentialSet,
|
||||
storyteller_creds_manager: &StorytellerCredentialManager,
|
||||
sora_task_queue: &SoraTaskQueue,
|
||||
app_data_root: &AppDataRoot,
|
||||
local_sqlite_tasks_by_sora_task_id: &HashMap<String, Task>,
|
||||
) -> AnyhowResult<()> {
|
||||
|
||||
let (sora_response, maybe_new_creds) =
|
||||
list_classic_sora_tasks_with_session_auto_renew(&sora_creds).await?;
|
||||
|
||||
if let Some(new_creds) = maybe_new_creds {
|
||||
info!("Saving new credentials.");
|
||||
sora_creds_manager.set_credentials(&new_creds)?;
|
||||
}
|
||||
|
||||
let sora_items = sora_response.task_responses;
|
||||
|
||||
let mut sora_succeeded_tasks_by_id = HashMap::new();
|
||||
let mut sora_failed_tasks_by_id = HashMap::new();
|
||||
|
||||
let storyteller_creds = storyteller_creds_manager.get_credentials_required()?;
|
||||
|
||||
for task in sora_items.iter() {
|
||||
|
||||
match &task.status {
|
||||
TaskStatus::Succeeded => {
|
||||
sora_succeeded_tasks_by_id.insert(
|
||||
task.id.clone(),
|
||||
SuccessfulGeneration {
|
||||
prompt: task.prompt.clone(),
|
||||
model_type: ModelType::GptImage1,
|
||||
items: task.generations.iter()
|
||||
.map(|gen| {
|
||||
GenerationItem {
|
||||
item_id: gen.id.clone(),
|
||||
url: gen.url.clone(),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
});
|
||||
}
|
||||
TaskStatus::Failed => {
|
||||
sora_failed_tasks_by_id.insert(
|
||||
task.id.clone(),
|
||||
FailedGeneration {
|
||||
reason: None, // TODO: Add reason if available.
|
||||
}
|
||||
);
|
||||
}
|
||||
TaskStatus::Queued => {}
|
||||
TaskStatus::Running => {}
|
||||
TaskStatus::Unknown(unknown_status) => {
|
||||
warn!("Unknown task status: {:?}", unknown_status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear dead tasks.
|
||||
handle_classic_failed_generations(
|
||||
&app_handle,
|
||||
&task_database,
|
||||
&local_sqlite_tasks_by_sora_task_id,
|
||||
&sora_failed_tasks_by_id,
|
||||
&sora_task_queue,
|
||||
).await?;
|
||||
|
||||
// Process succeeded tasks.
|
||||
handle_classic_successful_generations(
|
||||
&app_handle,
|
||||
&app_data_root,
|
||||
&app_env_configs,
|
||||
&task_database,
|
||||
&storyteller_creds,
|
||||
&sora_succeeded_tasks_by_id,
|
||||
&local_sqlite_tasks_by_sora_task_id,
|
||||
).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
|
||||
use crate::core::events::generation_events::common::{GenerationAction, GenerationServiceProvider};
|
||||
use crate::core::events::generation_events::generation_complete_event::GenerationCompleteEvent;
|
||||
use crate::core::events::generation_events::generation_failed_event::GenerationFailedEvent;
|
||||
use crate::core::events::sendable_event_trait::SendableEvent;
|
||||
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
|
||||
use crate::core::state::data_dir::app_data_root::AppDataRoot;
|
||||
use crate::core::state::data_dir::trait_data_subdir::DataSubdir;
|
||||
use crate::core::state::task_database::TaskDatabase;
|
||||
use crate::core::utils::task_database_pending_statuses::TASK_DATABASE_PENDING_STATUSES;
|
||||
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
|
||||
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
|
||||
use crate::services::sora::threads::sora_task_polling::helpers::handle_failed_generations::{handle_classic_failed_generations, FailedGeneration};
|
||||
use crate::services::sora::threads::sora_task_polling::helpers::handle_successful_generations::{handle_classic_successful_generations, GenerationItem, SuccessfulGeneration};
|
||||
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
|
||||
use artcraft_api_defs::prompts::create_prompt::CreatePromptRequest;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use enums::common::model_type::ModelType;
|
||||
use enums::tauri::tasks::task_status;
|
||||
use errors::AnyhowResult;
|
||||
use idempotency::uuid::generate_random_uuid;
|
||||
use log::{error, info, warn};
|
||||
use once_cell::sync::Lazy;
|
||||
use openai_sora_client::creds::sora_credential_set::SoraCredentialSet;
|
||||
use openai_sora_client::recipes::list_classic_sora_tasks_with_session_auto_renew::list_classic_sora_tasks_with_session_auto_renew;
|
||||
use openai_sora_client::recipes::list_sora2_drafts::list_sora2_drafts_with_session_auto_renew::list_sora2_drafts_with_session_auto_renew;
|
||||
use openai_sora_client::requests::common::task_id::TaskId;
|
||||
use openai_sora_client::requests::list_classic_tasks::list_classic_tasks::TaskStatus;
|
||||
use openai_sora_client::requests::list_sora2_drafts::list_sora2_drafts::Draft;
|
||||
use reqwest::Url;
|
||||
use sqlite_tasks::queries::list_tasks_by_provider_and_status::{list_tasks_by_provider_and_status, ListTasksByProviderAndStatusArgs, Task, TaskList};
|
||||
use sqlite_tasks::queries::update_task_status::{update_task_status, UpdateTaskArgs};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use storyteller_client::endpoints::media_files::upload_image_media_file_from_file::{upload_image_media_file_from_file, UploadImageFromFileArgs};
|
||||
use storyteller_client::endpoints::prompts::create_prompt::create_prompt;
|
||||
use tauri::AppHandle;
|
||||
use tempdir::TempDir;
|
||||
|
||||
pub async fn poll_sora_2_tasks(
|
||||
app_handle: &AppHandle,
|
||||
app_env_configs: &AppEnvConfigs,
|
||||
task_database: &TaskDatabase,
|
||||
sora_creds_manager: &SoraCredentialManager,
|
||||
sora_creds: &SoraCredentialSet,
|
||||
storyteller_creds_manager: &StorytellerCredentialManager,
|
||||
sora_task_queue: &SoraTaskQueue,
|
||||
app_data_root: &AppDataRoot,
|
||||
local_sqlite_tasks_by_sora_task_id: &HashMap<String, Task>,
|
||||
) -> AnyhowResult<()> {
|
||||
|
||||
let (sora_response, maybe_new_creds) =
|
||||
list_sora2_drafts_with_session_auto_renew(&sora_creds).await?;
|
||||
|
||||
if let Some(new_creds) = maybe_new_creds {
|
||||
info!("Saving new credentials.");
|
||||
sora_creds_manager.set_credentials(&new_creds)?;
|
||||
}
|
||||
|
||||
let drafts = sora_response.drafts;
|
||||
|
||||
let mut sora_succeeded_drafts_by_id = HashMap::new();
|
||||
let mut sora_failed_drafts_by_id = HashMap::new();
|
||||
|
||||
let storyteller_creds = storyteller_creds_manager.get_credentials_required()?;
|
||||
|
||||
for draft in drafts.into_iter() {
|
||||
match draft {
|
||||
Draft::Success(success_draft) => {
|
||||
sora_succeeded_drafts_by_id.insert(
|
||||
success_draft.task_id,
|
||||
SuccessfulGeneration {
|
||||
prompt: Some(success_draft.prompt),
|
||||
model_type: ModelType::Sora2,
|
||||
items: vec![GenerationItem {
|
||||
item_id: success_draft.id,
|
||||
url: success_draft.url,
|
||||
}]
|
||||
});
|
||||
}
|
||||
Draft::Failure(failed_draft) => {
|
||||
sora_failed_drafts_by_id.insert(
|
||||
failed_draft.task_id,
|
||||
FailedGeneration {
|
||||
reason: Some(failed_draft.reason_message)
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear dead tasks.
|
||||
handle_classic_failed_generations(
|
||||
&app_handle,
|
||||
&task_database,
|
||||
&local_sqlite_tasks_by_sora_task_id,
|
||||
&sora_failed_drafts_by_id,
|
||||
&sora_task_queue,
|
||||
).await?;
|
||||
|
||||
|
||||
// Process succeeded tasks.
|
||||
handle_classic_successful_generations(
|
||||
&app_handle,
|
||||
&app_data_root,
|
||||
&app_env_configs,
|
||||
&task_database,
|
||||
&storyteller_creds,
|
||||
&sora_succeeded_drafts_by_id,
|
||||
&local_sqlite_tasks_by_sora_task_id,
|
||||
).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod sora_task_polling_thread;
|
||||
pub mod helpers;
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
|
||||
use crate::core::events::generation_events::common::{GenerationAction, GenerationServiceProvider};
|
||||
use crate::core::events::generation_events::generation_complete_event::GenerationCompleteEvent;
|
||||
use crate::core::events::generation_events::generation_failed_event::GenerationFailedEvent;
|
||||
use crate::core::events::sendable_event_trait::SendableEvent;
|
||||
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
|
||||
use crate::core::state::data_dir::app_data_root::AppDataRoot;
|
||||
use crate::core::state::data_dir::trait_data_subdir::DataSubdir;
|
||||
use crate::core::state::task_database::TaskDatabase;
|
||||
use crate::core::utils::task_database_pending_statuses::TASK_DATABASE_PENDING_STATUSES;
|
||||
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
|
||||
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
|
||||
use crate::services::sora::threads::sora_task_polling::helpers::handle_failed_generations::handle_classic_failed_generations;
|
||||
use crate::services::sora::threads::sora_task_polling::helpers::handle_successful_generations::handle_classic_successful_generations;
|
||||
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
|
||||
use artcraft_api_defs::prompts::create_prompt::CreatePromptRequest;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use enums::common::model_type::ModelType;
|
||||
use enums::tauri::tasks::task_status;
|
||||
use errors::AnyhowResult;
|
||||
use idempotency::uuid::generate_random_uuid;
|
||||
use log::{error, info, warn};
|
||||
use once_cell::sync::Lazy;
|
||||
use openai_sora_client::creds::sora_credential_set::SoraCredentialSet;
|
||||
use openai_sora_client::recipes::list_classic_sora_tasks_with_session_auto_renew::list_classic_sora_tasks_with_session_auto_renew;
|
||||
use openai_sora_client::requests::common::task_id::TaskId;
|
||||
use openai_sora_client::requests::list_classic_tasks::list_classic_tasks::TaskStatus;
|
||||
use reqwest::Url;
|
||||
use sqlite_tasks::queries::list_tasks_by_provider_and_status::{list_tasks_by_provider_and_status, ListTasksByProviderAndStatusArgs, Task, TaskList};
|
||||
use sqlite_tasks::queries::update_task_status::{update_task_status, UpdateTaskArgs};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use storyteller_client::endpoints::media_files::upload_image_media_file_from_file::{upload_image_media_file_from_file, UploadImageFromFileArgs};
|
||||
use storyteller_client::endpoints::prompts::create_prompt::create_prompt;
|
||||
use tauri::AppHandle;
|
||||
use tempdir::TempDir;
|
||||
use crate::services::sora::threads::sora_task_polling::helpers::poll_classic_sora_tasks::poll_classic_sora_tasks;
|
||||
use crate::services::sora::threads::sora_task_polling::helpers::poll_sora_2_tasks::poll_sora_2_tasks;
|
||||
|
||||
pub async fn sora_task_polling_thread(
|
||||
app_handle: AppHandle,
|
||||
app_env_configs: AppEnvConfigs,
|
||||
app_data_root: AppDataRoot,
|
||||
task_database: TaskDatabase,
|
||||
sora_creds_manager: SoraCredentialManager,
|
||||
storyteller_creds_manager: StorytellerCredentialManager,
|
||||
sora_task_queue: SoraTaskQueue,
|
||||
) -> ! {
|
||||
loop {
|
||||
let res = local_task_polling_loop(
|
||||
&app_handle,
|
||||
&app_env_configs,
|
||||
&task_database,
|
||||
&sora_creds_manager,
|
||||
&storyteller_creds_manager,
|
||||
&sora_task_queue,
|
||||
&app_data_root,
|
||||
).await;
|
||||
if let Err(err) = res {
|
||||
error!("An error occurred: {:?}", err);
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(30_000)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn local_task_polling_loop(
|
||||
app_handle: &AppHandle,
|
||||
app_env_configs: &AppEnvConfigs,
|
||||
task_database: &TaskDatabase,
|
||||
sora_creds_manager: &SoraCredentialManager,
|
||||
storyteller_creds_manager: &StorytellerCredentialManager,
|
||||
sora_task_queue: &SoraTaskQueue,
|
||||
app_data_root: &AppDataRoot,
|
||||
) -> AnyhowResult<()> {
|
||||
loop {
|
||||
let local_sqlite_tasks = list_tasks_by_provider_and_status(ListTasksByProviderAndStatusArgs {
|
||||
db: task_database.get_connection(),
|
||||
provider: GenerationProvider::Sora,
|
||||
task_statuses: &TASK_DATABASE_PENDING_STATUSES,
|
||||
}).await?;
|
||||
|
||||
if local_sqlite_tasks.tasks.is_empty() {
|
||||
// No need to poll if we don't have pending tasks.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(2_000)).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
let creds = sora_creds_manager.get_credentials_required()?;
|
||||
|
||||
// Map of Sora Task ID to Local Task.
|
||||
let local_sqlite_tasks_by_sora_task_id = local_sqlite_tasks.tasks.iter()
|
||||
.filter_map(|task| {
|
||||
if let Some(provider_job_id) = &task.provider_job_id {
|
||||
Some((provider_job_id.clone(), task.clone()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<HashMap<String, Task>>();
|
||||
|
||||
// TODO: Only poll if we have classic items
|
||||
|
||||
poll_classic_sora_tasks(
|
||||
&app_handle,
|
||||
&app_env_configs,
|
||||
&task_database,
|
||||
&sora_creds_manager,
|
||||
&creds,
|
||||
&storyteller_creds_manager,
|
||||
&sora_task_queue,
|
||||
&app_data_root,
|
||||
&local_sqlite_tasks_by_sora_task_id,
|
||||
).await?;
|
||||
|
||||
// TODO: Only poll if we have new items
|
||||
|
||||
poll_sora_2_tasks(
|
||||
&app_handle,
|
||||
&app_env_configs,
|
||||
&task_database,
|
||||
&sora_creds_manager,
|
||||
&creds,
|
||||
&storyteller_creds_manager,
|
||||
&sora_task_queue,
|
||||
&app_data_root,
|
||||
&local_sqlite_tasks_by_sora_task_id,
|
||||
).await?;
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(2_000)).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
|
||||
use crate::core::events::generation_events::common::{GenerationAction, GenerationServiceProvider};
|
||||
use crate::core::events::generation_events::generation_complete_event::GenerationCompleteEvent;
|
||||
use crate::core::events::generation_events::generation_failed_event::GenerationFailedEvent;
|
||||
use crate::core::events::sendable_event_trait::SendableEvent;
|
||||
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
|
||||
use crate::core::state::data_dir::app_data_root::AppDataRoot;
|
||||
use crate::core::state::data_dir::trait_data_subdir::DataSubdir;
|
||||
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
|
||||
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
|
||||
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
|
||||
use errors::AnyhowResult;
|
||||
use log::{error, info};
|
||||
use openai_sora_client::recipes::list_sora_task_status_with_session_auto_renew::{list_sora_task_status_with_session_auto_renew, StatusRequestArgs};
|
||||
use openai_sora_client::requests::common::task_id::TaskId;
|
||||
use openai_sora_client::requests::job_status::sora_job_status::{Generation, TaskStatus};
|
||||
use reqwest::Url;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use storyteller_client::endpoints::media_files::upload_image_media_file_from_file::{upload_image_media_file_from_file, UploadImageFromFileArgs};
|
||||
use tauri::AppHandle;
|
||||
use tempdir::TempDir;
|
||||
|
||||
pub async fn sora_task_polling_thread(
|
||||
app_handle: AppHandle,
|
||||
app_env_configs: AppEnvConfigs,
|
||||
app_data_root: AppDataRoot,
|
||||
sora_creds_manager: SoraCredentialManager,
|
||||
storyteller_creds_manager: StorytellerCredentialManager,
|
||||
sora_task_queue: SoraTaskQueue,
|
||||
) -> ! {
|
||||
loop {
|
||||
let res = polling_loop(
|
||||
&app_handle,
|
||||
&app_env_configs,
|
||||
&sora_creds_manager,
|
||||
&storyteller_creds_manager,
|
||||
&sora_task_queue,
|
||||
&app_data_root,
|
||||
).await;
|
||||
if let Err(err) = res {
|
||||
error!("An error occurred: {:?}", err);
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(30_000)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn polling_loop(
|
||||
app_handle: &AppHandle,
|
||||
app_env_configs: &AppEnvConfigs,
|
||||
sora_creds_manager: &SoraCredentialManager,
|
||||
storyteller_creds_manager: &StorytellerCredentialManager,
|
||||
sora_task_queue: &SoraTaskQueue,
|
||||
app_data_root: &AppDataRoot,
|
||||
) -> AnyhowResult<()> {
|
||||
loop {
|
||||
if sora_task_queue.is_empty()? {
|
||||
// No need to poll if we don't have pending tasks.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1_000)).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
info!("Task queue has {} pending tasks.", sora_task_queue.len()?);
|
||||
|
||||
let creds = sora_creds_manager.get_credentials_required()?;
|
||||
|
||||
let (response, maybe_new_creds) = list_sora_task_status_with_session_auto_renew(StatusRequestArgs {
|
||||
limit: None,
|
||||
// TODO: How can we use the task id to poll better? Our existing code doesn't seem to illuminate this.
|
||||
before: None,
|
||||
credentials: creds,
|
||||
}).await?;
|
||||
|
||||
if let Some(new_creds) = maybe_new_creds {
|
||||
info!("Saving new credentials.");
|
||||
sora_creds_manager.set_credentials(&new_creds)?;
|
||||
}
|
||||
|
||||
let mut succeeded_tasks = Vec::new();
|
||||
let mut failed_tasks = Vec::new();
|
||||
|
||||
// TODO: The cursoring logic likely needs to improve.
|
||||
for task in response.task_responses {
|
||||
let status = TaskStatus::from_str(&task.status);
|
||||
|
||||
match status {
|
||||
TaskStatus::Succeeded => {
|
||||
succeeded_tasks.push(task);
|
||||
}
|
||||
TaskStatus::Failed => {
|
||||
failed_tasks.push(task);
|
||||
}
|
||||
TaskStatus::Queued => {}
|
||||
TaskStatus::Running => {}
|
||||
TaskStatus::Unknown(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
for task in failed_tasks.iter() {
|
||||
if sora_task_queue.contains_key(&task.id)? {
|
||||
let event = GenerationFailedEvent {
|
||||
action: GenerationAction::GenerateImage,
|
||||
service: GenerationServiceProvider::Sora,
|
||||
model: None,
|
||||
reason: None,
|
||||
// prompt: task.prompt
|
||||
// .as_deref()
|
||||
// .map(|s| s.to_string())
|
||||
// .unwrap_or_else(||"".to_string()),
|
||||
};
|
||||
if let Err(err) = event.send(&app_handle) {
|
||||
error!("Failed to send GenerationFailedEvent: {:?}", err); // Fail open
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let failed_task_ids: Vec<&TaskId> = failed_tasks
|
||||
.iter()
|
||||
.map(|task| &task.id)
|
||||
.collect();
|
||||
|
||||
sora_task_queue.remove_list(&failed_task_ids)?;
|
||||
|
||||
let creds = storyteller_creds_manager.get_credentials_required()?;
|
||||
|
||||
for task in succeeded_tasks.iter() {
|
||||
if !sora_task_queue.contains_key(&task.id)? {
|
||||
continue;
|
||||
}
|
||||
info!("Task succeeded: {:?}", task.id);
|
||||
|
||||
for (i, generation) in task.generations.iter().enumerate() {
|
||||
info!("Downloading generated file...");
|
||||
let download_path = download_generation(generation, &app_data_root).await?;
|
||||
|
||||
info!("Uploading to backend...");
|
||||
|
||||
let result = upload_image_media_file_from_file(UploadImageFromFileArgs {
|
||||
api_host: &app_env_configs.storyteller_host,
|
||||
maybe_creds: Some(&creds),
|
||||
path: download_path,
|
||||
is_intermediate_system_file: false,
|
||||
maybe_prompt_token: None, // TODO: this should be added soon.
|
||||
maybe_batch_token: None, // TODO: This should be added soon.
|
||||
}).await?;
|
||||
|
||||
info!("Uploaded to API backend: {:?}", result.media_file_token);
|
||||
|
||||
let event = GenerationCompleteEvent {
|
||||
//media_file_token: result.media_file_token,
|
||||
action: Some(GenerationAction::GenerateImage),
|
||||
service: GenerationServiceProvider::Sora,
|
||||
model: None,
|
||||
};
|
||||
|
||||
if let Err(err) = event.send(&app_handle) {
|
||||
error!("Failed to send GenerationCompleteEvent: {:?}", err); // Fail open
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let succeeded_task_ids : Vec<&TaskId> = succeeded_tasks
|
||||
.iter()
|
||||
.map(|task| &task.id)
|
||||
.collect();
|
||||
|
||||
sora_task_queue.remove_list(&succeeded_task_ids)?;
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(6_000)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn download_generation(generation: &Generation, app_data_root: &AppDataRoot) -> AnyhowResult<PathBuf> {
|
||||
let url = Url::parse(&generation.url)?;
|
||||
|
||||
let response = reqwest::get(&generation.url).await?;
|
||||
let image_bytes = response.bytes().await?;
|
||||
|
||||
let ext = url.path().split(".").last().unwrap_or("png");
|
||||
|
||||
let tempdir = app_data_root.temp_dir().path();
|
||||
let download_filename = format!("{}.{}", generation.id, ext);
|
||||
let download_path = tempdir.join(download_filename);
|
||||
|
||||
let mut file = File::create(&download_path)?;
|
||||
file.write_all(&image_bytes)?;
|
||||
|
||||
Ok(download_path)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod upload_images_to_sora;
|
||||
@@ -0,0 +1,114 @@
|
||||
use crate::core::state::app_env_configs::app_env_configs_serializeable::StorytellerApiHost;
|
||||
use crate::core::state::data_dir::app_data_root::AppDataRoot;
|
||||
use crate::core::state::data_dir::trait_data_subdir::DataSubdir;
|
||||
use crate::core::utils::get_url_file_extension::get_url_file_extension;
|
||||
use crate::core::utils::simple_http_download::simple_http_download;
|
||||
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
|
||||
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
|
||||
use errors::AnyhowResult;
|
||||
use log::{error, info};
|
||||
use openai_sora_client::creds::sora_credential_set::SoraCredentialSet;
|
||||
use openai_sora_client::recipes::image_upload_from_file_with_session_auto_renew::{image_upload_from_file_with_session_auto_renew, ImageUploadFromFileAutoRenewRequest};
|
||||
use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session;
|
||||
use std::time::Duration;
|
||||
use storyteller_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
use storyteller_client::endpoints::media_files::get_media_file::get_media_file;
|
||||
use storyteller_client::utils::api_host::ApiHost;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
const SORA_IMAGE_UPLOAD_TIMEOUT: Duration = Duration::from_millis(1000 * 30); // 30 seconds
|
||||
|
||||
pub struct UploadImagesToSoraArgs<'a> {
|
||||
pub storyteller_host: &'a ApiHost,
|
||||
pub image_media_tokens: &'a [MediaFileToken],
|
||||
|
||||
pub app_data_root: &'a AppDataRoot,
|
||||
pub sora_creds_manager: &'a SoraCredentialManager,
|
||||
}
|
||||
|
||||
pub struct UploadImagesToSoraResult {
|
||||
pub sora_media_tokens: Vec<String>,
|
||||
pub maybe_new_sora_credentials: Option<SoraCredentialSet>,
|
||||
}
|
||||
|
||||
pub async fn upload_images_to_sora(
|
||||
args: UploadImagesToSoraArgs<'_>,
|
||||
) -> AnyhowResult<UploadImagesToSoraResult> {
|
||||
|
||||
let mut updated_sora_creds = false;
|
||||
|
||||
info!("Calling get media file API: {:?}", args.storyteller_host);
|
||||
|
||||
let mut files_to_upload_to_sora = Vec::with_capacity(10);
|
||||
|
||||
// TODO(bt,2025-07-07): This is inefficient. Cache and parallelize this.
|
||||
for media_token in args.image_media_tokens.iter() {
|
||||
info!("Using media token: {:?}", media_token);
|
||||
|
||||
let response = get_media_file(
|
||||
args.storyteller_host,
|
||||
media_token
|
||||
).await?;
|
||||
|
||||
let media_file_url = &response.media_file.media_links.cdn_url;
|
||||
let extension_with_dot = get_url_file_extension(media_file_url)
|
||||
.map(|ext| format!(".{}", ext))
|
||||
.unwrap_or_else(|| ".png".to_string());
|
||||
|
||||
let filename = format!("{}{}", response.media_file.token.as_str(), extension_with_dot);
|
||||
let filename = args.app_data_root.downloads_dir().path().join(&filename);
|
||||
|
||||
simple_http_download(&media_file_url, &filename).await?;
|
||||
|
||||
files_to_upload_to_sora.push(filename);
|
||||
}
|
||||
|
||||
let mut sora_creds = args.sora_creds_manager.get_credentials_required()?;
|
||||
|
||||
// TODO: We don't need sentinel anymore?
|
||||
let credential_updated = maybe_upgrade_or_renew_session(&mut sora_creds)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("Failed to upgrade or renew session: {:?}", err);
|
||||
err
|
||||
})?;
|
||||
|
||||
if credential_updated {
|
||||
info!("Storing updated credentials");
|
||||
args.sora_creds_manager.set_credentials(&sora_creds)?;
|
||||
updated_sora_creds = true;
|
||||
}
|
||||
|
||||
let mut sora_media_tokens = Vec::with_capacity(files_to_upload_to_sora.len());
|
||||
|
||||
for (i, file_path) in files_to_upload_to_sora.iter().enumerate() {
|
||||
info!("Uploading image {} of {}...", (i+1), files_to_upload_to_sora.len());
|
||||
|
||||
let (response, maybe_new_credentials) =
|
||||
image_upload_from_file_with_session_auto_renew(ImageUploadFromFileAutoRenewRequest {
|
||||
file_path,
|
||||
credentials: &sora_creds,
|
||||
request_timeout: Some(SORA_IMAGE_UPLOAD_TIMEOUT), // TODO: Centralize and make configurable.
|
||||
}).await?;
|
||||
|
||||
if let Some(new_creds) = maybe_new_credentials {
|
||||
info!("Storing updated credentials.");
|
||||
args.sora_creds_manager.set_credentials(&new_creds)?;
|
||||
sora_creds = new_creds;
|
||||
updated_sora_creds = true;
|
||||
}
|
||||
|
||||
sora_media_tokens.push(response.id);
|
||||
}
|
||||
|
||||
let maybe_new_sora_credentials = if updated_sora_creds {
|
||||
Some(sora_creds)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(UploadImagesToSoraResult {
|
||||
sora_media_tokens,
|
||||
maybe_new_sora_credentials,
|
||||
})
|
||||
}
|
||||
+18
-4
@@ -1,3 +1,5 @@
|
||||
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
|
||||
use crate::core::events::functional_events::refresh_account_state_event::RefreshAccountStateEvent;
|
||||
use crate::core::events::sendable_event_trait::SendableEvent;
|
||||
use crate::core::state::data_dir::app_data_root::AppDataRoot;
|
||||
use crate::core::utils::window::get_webview_window_hostname::get_webview_window_hostname;
|
||||
@@ -6,6 +8,7 @@ use crate::services::sora::state::sora_credential_manager::SoraCredentialManager
|
||||
use crate::services::sora::windows::sora_login_window::extract_sora_webview_cookies::extract_sora_webview_cookies;
|
||||
use crate::services::sora::windows::sora_login_window::open_sora_login_window::LOGIN_WINDOW_NAME;
|
||||
use anyhow::anyhow;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use errors::AnyhowResult;
|
||||
use log::{error, info};
|
||||
use openai_sora_client::creds::sora_credential_set::SoraCredentialSet;
|
||||
@@ -129,13 +132,24 @@ async fn check_login_window(
|
||||
sora_credential_manager.set_credentials(&new_credentials)?;
|
||||
sora_credential_manager.persist_all_to_disk()?;
|
||||
|
||||
// NB: Event sent to the frontend for the login flow. We shouldn't rely on just this
|
||||
send_frontend_login_events(app_handle);
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn send_frontend_login_events(app_handle: &AppHandle) {
|
||||
let event = RefreshAccountStateEvent {
|
||||
provider: Some(GenerationProvider::Sora),
|
||||
};
|
||||
|
||||
event.send_infallible(app_handle);
|
||||
|
||||
// TODO: Remove this legacy event when we're done with it.
|
||||
// NB: Event sent to the frontend for the login flow. We shouldn't rely on just this
|
||||
// alone as it could be brittle if the events aren't caught.
|
||||
let event = SoraLoginSuccessEvent {};
|
||||
|
||||
|
||||
if let Err(err) = event.send(app_handle) {
|
||||
error!("Error sending Sora login success event: {:?}", err);
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
@@ -71,6 +71,8 @@ pub enum ModelType {
|
||||
Seedance10Lite,
|
||||
#[serde(rename = "seedance_1p0_pro")]
|
||||
Seedance10Pro,
|
||||
#[serde(rename = "sora_2")]
|
||||
Sora2,
|
||||
#[serde(rename = "veo_2")]
|
||||
Veo2,
|
||||
#[serde(rename = "veo_3")]
|
||||
@@ -122,6 +124,7 @@ impl ModelType {
|
||||
Self::Kling21Master => "kling_2p1_master",
|
||||
Self::Seedance10Lite => "seedance_1p0_lite",
|
||||
Self::Seedance10Pro => "seedance_1p0_pro",
|
||||
Self::Sora2 => "sora_2",
|
||||
Self::Veo2 => "veo_2",
|
||||
Self::Veo3 => "veo_3",
|
||||
Self::Veo3Fast => "veo_3_fast",
|
||||
@@ -162,6 +165,7 @@ impl ModelType {
|
||||
"kling_2p1_master" => Ok(Self::Kling21Master),
|
||||
"seedance_1p0_lite" => Ok(Self::Seedance10Lite),
|
||||
"seedance_1p0_pro" => Ok(Self::Seedance10Pro),
|
||||
"sora_2" => Ok(Self::Sora2),
|
||||
"veo_2" => Ok(Self::Veo2),
|
||||
"veo_3" => Ok(Self::Veo3),
|
||||
"veo_3_fast" => Ok(Self::Veo3Fast),
|
||||
@@ -206,6 +210,7 @@ impl ModelType {
|
||||
Self::Kling21Master,
|
||||
Self::Seedance10Lite,
|
||||
Self::Seedance10Pro,
|
||||
Self::Sora2,
|
||||
Self::Veo2,
|
||||
Self::Veo3,
|
||||
Self::Veo3Fast,
|
||||
@@ -254,6 +259,7 @@ mod tests {
|
||||
assert_serialization(ModelType::Kling21Master, "kling_2p1_master");
|
||||
assert_serialization(ModelType::Seedance10Lite, "seedance_1p0_lite");
|
||||
assert_serialization(ModelType::Seedance10Pro, "seedance_1p0_pro");
|
||||
assert_serialization(ModelType::Sora2, "sora_2");
|
||||
assert_serialization(ModelType::Veo2, "veo_2");
|
||||
assert_serialization(ModelType::Veo3, "veo_3");
|
||||
assert_serialization(ModelType::Veo3Fast, "veo_3_fast");
|
||||
@@ -292,6 +298,7 @@ mod tests {
|
||||
assert_eq!(ModelType::Kling21Master.to_str(), "kling_2p1_master");
|
||||
assert_eq!(ModelType::Seedance10Lite.to_str(), "seedance_1p0_lite");
|
||||
assert_eq!(ModelType::Seedance10Pro.to_str(), "seedance_1p0_pro");
|
||||
assert_eq!(ModelType::Sora2.to_str(), "sora_2");
|
||||
assert_eq!(ModelType::Veo2.to_str(), "veo_2");
|
||||
assert_eq!(ModelType::Veo3.to_str(), "veo_3");
|
||||
assert_eq!(ModelType::Veo3Fast.to_str(), "veo_3_fast");
|
||||
@@ -330,6 +337,7 @@ mod tests {
|
||||
assert_eq!(ModelType::from_str("kling_2p1_master").unwrap(), ModelType::Kling21Master);
|
||||
assert_eq!(ModelType::from_str("seedance_1p0_lite").unwrap(), ModelType::Seedance10Lite);
|
||||
assert_eq!(ModelType::from_str("seedance_1p0_pro").unwrap(), ModelType::Seedance10Pro);
|
||||
assert_eq!(ModelType::from_str("sora_2").unwrap(), ModelType::Sora2);
|
||||
assert_eq!(ModelType::from_str("veo_2").unwrap(), ModelType::Veo2);
|
||||
assert_eq!(ModelType::from_str("veo_3").unwrap(), ModelType::Veo3);
|
||||
assert_eq!(ModelType::from_str("veo_3_fast").unwrap(), ModelType::Veo3Fast);
|
||||
@@ -341,7 +349,7 @@ mod tests {
|
||||
#[test]
|
||||
fn all_variants() {
|
||||
let mut variants = ModelType::all_variants();
|
||||
assert_eq!(variants.len(), 30);
|
||||
assert_eq!(variants.len(), 31);
|
||||
// Image models
|
||||
assert_eq!(variants.pop_first(), Some(ModelType::Flux1Dev));
|
||||
assert_eq!(variants.pop_first(), Some(ModelType::Flux1Schnell));
|
||||
@@ -369,6 +377,7 @@ mod tests {
|
||||
assert_eq!(variants.pop_first(), Some(ModelType::Kling21Master));
|
||||
assert_eq!(variants.pop_first(), Some(ModelType::Seedance10Lite));
|
||||
assert_eq!(variants.pop_first(), Some(ModelType::Seedance10Pro));
|
||||
assert_eq!(variants.pop_first(), Some(ModelType::Sora2));
|
||||
assert_eq!(variants.pop_first(), Some(ModelType::Veo2));
|
||||
assert_eq!(variants.pop_first(), Some(ModelType::Veo3));
|
||||
assert_eq!(variants.pop_first(), Some(ModelType::Veo3Fast));
|
||||
|
||||
@@ -46,6 +46,8 @@ pub enum TaskModelType {
|
||||
Kling21Master,
|
||||
#[serde(rename = "seedance_1.0_lite")]
|
||||
Seedance10Lite,
|
||||
#[serde(rename = "sora_2")]
|
||||
Sora2,
|
||||
#[serde(rename = "veo_2")]
|
||||
Veo2,
|
||||
#[serde(rename = "veo_3")]
|
||||
@@ -86,6 +88,7 @@ impl TaskModelType {
|
||||
Self::Kling21Pro => "kling_2.1_pro",
|
||||
Self::Kling21Master => "kling_2.1_master",
|
||||
Self::Seedance10Lite => "seedance_1.0_lite",
|
||||
Self::Sora2 => "sora_2",
|
||||
Self::Veo2 => "veo_2",
|
||||
Self::Veo3 => "veo_3",
|
||||
Self::Veo3Fast => "veo_3_fast",
|
||||
@@ -114,6 +117,7 @@ impl TaskModelType {
|
||||
"kling_2.1_pro" => Ok(Self::Kling21Pro),
|
||||
"kling_2.1_master" => Ok(Self::Kling21Master),
|
||||
"seedance_1.0_lite" => Ok(Self::Seedance10Lite),
|
||||
"sora_2" => Ok(Self::Sora2),
|
||||
"veo_2" => Ok(Self::Veo2),
|
||||
"veo_3" => Ok(Self::Veo3),
|
||||
"veo_3_fast" => Ok(Self::Veo3Fast),
|
||||
@@ -145,6 +149,7 @@ impl TaskModelType {
|
||||
Self::Kling21Pro,
|
||||
Self::Kling21Master,
|
||||
Self::Seedance10Lite,
|
||||
Self::Sora2,
|
||||
Self::Veo2,
|
||||
Self::Veo3,
|
||||
Self::Veo3Fast,
|
||||
@@ -182,6 +187,7 @@ mod tests {
|
||||
assert_serialization(TaskModelType::Kling21Pro, "kling_2.1_pro");
|
||||
assert_serialization(TaskModelType::Kling21Master, "kling_2.1_master");
|
||||
assert_serialization(TaskModelType::Seedance10Lite, "seedance_1.0_lite");
|
||||
assert_serialization(TaskModelType::Sora2, "sora_2");
|
||||
assert_serialization(TaskModelType::Veo2, "veo_2");
|
||||
assert_serialization(TaskModelType::Veo3, "veo_3");
|
||||
assert_serialization(TaskModelType::Veo3Fast, "veo_3_fast");
|
||||
@@ -209,6 +215,7 @@ mod tests {
|
||||
assert_eq!(TaskModelType::Kling21Pro.to_str(), "kling_2.1_pro");
|
||||
assert_eq!(TaskModelType::Kling21Master.to_str(), "kling_2.1_master");
|
||||
assert_eq!(TaskModelType::Seedance10Lite.to_str(), "seedance_1.0_lite");
|
||||
assert_eq!(TaskModelType::Sora2.to_str(), "sora_2");
|
||||
assert_eq!(TaskModelType::Veo2.to_str(), "veo_2");
|
||||
assert_eq!(TaskModelType::Veo3.to_str(), "veo_3");
|
||||
assert_eq!(TaskModelType::Veo3Fast.to_str(), "veo_3_fast");
|
||||
@@ -236,6 +243,7 @@ mod tests {
|
||||
assert_eq!(TaskModelType::from_str("kling_2.1_pro").unwrap(), TaskModelType::Kling21Pro);
|
||||
assert_eq!(TaskModelType::from_str("kling_2.1_master").unwrap(), TaskModelType::Kling21Master);
|
||||
assert_eq!(TaskModelType::from_str("seedance_1.0_lite").unwrap(), TaskModelType::Seedance10Lite);
|
||||
assert_eq!(TaskModelType::from_str("sora_2").unwrap(), TaskModelType::Sora2);
|
||||
assert_eq!(TaskModelType::from_str("veo_2").unwrap(), TaskModelType::Veo2);
|
||||
assert_eq!(TaskModelType::from_str("veo_3").unwrap(), TaskModelType::Veo3);
|
||||
assert_eq!(TaskModelType::from_str("veo_3_fast").unwrap(), TaskModelType::Veo3Fast);
|
||||
@@ -247,7 +255,7 @@ mod tests {
|
||||
#[test]
|
||||
fn all_variants() {
|
||||
let mut variants = TaskModelType::all_variants();
|
||||
assert_eq!(variants.len(), 20);
|
||||
assert_eq!(variants.len(), 21);
|
||||
// Image models
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Flux1Dev));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Flux1Schnell));
|
||||
@@ -265,6 +273,7 @@ mod tests {
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Kling21Pro));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Kling21Master));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Seedance10Lite));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Sora2));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Veo2));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Veo3));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Veo3Fast));
|
||||
|
||||
+23
-1
@@ -27,6 +27,7 @@ use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_
|
||||
use mysql_queries::queries::media_files::create::specialized_insert::insert_media_file_from_file_upload::{insert_media_file_from_file_upload, InsertMediaFileFromUploadArgs, UploadType};
|
||||
use thumbnail_generator::task_client::thumbnail_task::{ThumbnailTaskBuilder, ThumbnailTaskInputMimeType};
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
use videos::ffprobe_get_info::ffprobe_get_info;
|
||||
|
||||
use crate::http_server::endpoints::media_files::upload::upload_error::MediaFileUploadError;
|
||||
@@ -66,6 +67,12 @@ pub struct UploadNewVideoMediaFileForm {
|
||||
#[schema(value_type = Option<Visibility>, format = Binary)]
|
||||
maybe_visibility: Option<Text<Visibility>>,
|
||||
|
||||
/// Optional: Prompt associated with this image
|
||||
/// NOTE: Cannot set `is_intermediate_system_file = true` if this is set.
|
||||
#[multipart(limit = "2 KiB")]
|
||||
#[schema(value_type = Option<String>, format = Binary)]
|
||||
maybe_prompt_token: Option<Text<PromptToken>>,
|
||||
|
||||
/// Optional: If an engine scene was used to generate this video, provide it here to create a link.
|
||||
#[multipart(limit = "2 KiB")]
|
||||
#[schema(value_type = Option<MediaFileToken>, format = Binary)]
|
||||
@@ -355,6 +362,10 @@ pub async fn upload_new_video_media_file_handler(
|
||||
.as_ref()
|
||||
.map(|token| &token.0);
|
||||
|
||||
let maybe_prompt_token = form.maybe_prompt_token
|
||||
.as_ref()
|
||||
.map(|token| token.0.clone());
|
||||
|
||||
// NB: If we're uploading a video file that references an engine scene, then this is an engine
|
||||
// render video, and we should mark it as a system (hidden) file.
|
||||
let is_intermediate_system_file =
|
||||
@@ -371,7 +382,7 @@ pub async fn upload_new_video_media_file_handler(
|
||||
upload_type: UploadType::Filesystem, // TODO(bt,2024-05-02): This should be a parameter and a well-known enum.
|
||||
maybe_engine_category: None,
|
||||
maybe_animation_type: None,
|
||||
maybe_prompt_token: None,
|
||||
maybe_prompt_token: maybe_prompt_token.as_ref(),
|
||||
maybe_batch_token: None,
|
||||
maybe_mime_type: Some(&mimetype),
|
||||
file_size_bytes: file_size_bytes as u64,
|
||||
@@ -434,5 +445,16 @@ fn fast_form_validations(form: &UploadNewVideoMediaFileForm) -> Result<(), Media
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
let is_intermediate_system_file = form.is_intermediate_system_file
|
||||
.as_ref()
|
||||
.map(|b| b.0)
|
||||
.unwrap_or(false);
|
||||
|
||||
if is_intermediate_system_file && form.maybe_prompt_token.is_some() {
|
||||
return Err(MediaFileUploadError::BadInput(
|
||||
"Cannot set `is_intermediate_system_file` to true if `maybe_prompt_token` is provided."
|
||||
.to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -212,6 +212,22 @@ export const GalleryModal = React.memo(
|
||||
}
|
||||
};
|
||||
|
||||
const getLabel = (item: any) => {
|
||||
if (!!item.maybe_title) {
|
||||
return item.maybe_title;
|
||||
}
|
||||
switch (item.media_class) {
|
||||
case "image":
|
||||
return "Image Generation";
|
||||
case "video":
|
||||
return "Video Generation";
|
||||
case "dimensional":
|
||||
return "3D Object Generation";
|
||||
default:
|
||||
return "Generation";
|
||||
}
|
||||
};
|
||||
|
||||
const loadItems = async (reset = false) => {
|
||||
if (!username) return;
|
||||
setLoading(true);
|
||||
@@ -240,7 +256,7 @@ export const GalleryModal = React.memo(
|
||||
)
|
||||
.map((item: any) => ({
|
||||
id: item.token,
|
||||
label: item.maybe_title || "Image Generation",
|
||||
label: getLabel(item),
|
||||
thumbnail:
|
||||
item.media_class === "video"
|
||||
? item.media_links.maybe_video_previews.animated
|
||||
|
||||
@@ -6,19 +6,20 @@ import { PopoverMenu, PopoverItem } from "@storyteller/ui-popover";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import { Button, ToggleButton } from "@storyteller/ui-button";
|
||||
import { Modal } from "@storyteller/ui-modal";
|
||||
import { EnqueueImageToVideo } from "@storyteller/tauri-api";
|
||||
import { EnqueueImageToVideo, EnqueueImageToVideoRequest } from "@storyteller/tauri-api";
|
||||
import {
|
||||
faMessageXmark,
|
||||
faMessageCheck,
|
||||
faSparkles,
|
||||
faSpinnerThird,
|
||||
faSquare,
|
||||
faPortrait,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { faRectangle } from "@fortawesome/pro-regular-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { IconProp } from "@fortawesome/fontawesome-svg-core";
|
||||
import { IsDesktopApp } from "@storyteller/tauri-utils";
|
||||
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
|
||||
import { VideoModel } from "@storyteller/model-list";
|
||||
import { SizeIconOption, SizeOption, VideoModel } from "@storyteller/model-list";
|
||||
import { usePromptVideoStore, RefImage } from "./promptStore";
|
||||
import { gtagEvent } from "@storyteller/google-analytics";
|
||||
import { ImagePromptRow } from "./ImagePromptRow";
|
||||
@@ -26,6 +27,19 @@ import type { UploadImageFn } from "./ImagePromptRow";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { toast } from "@storyteller/ui-toaster";
|
||||
|
||||
const DEFAULT_RESOLUTIONS : SizeOption[] = [
|
||||
{
|
||||
tauriValue: "720p",
|
||||
textLabel: "720p",
|
||||
icon: SizeIconOption.Landscape,
|
||||
},
|
||||
{
|
||||
tauriValue: "480p",
|
||||
textLabel: "480p",
|
||||
icon: SizeIconOption.Landscape,
|
||||
},
|
||||
];
|
||||
|
||||
interface PromptBoxVideoProps {
|
||||
useJobContext: () => JobContextType;
|
||||
onEnqueuePressed?: (
|
||||
@@ -63,7 +77,6 @@ export const PromptBoxVideo = ({
|
||||
}
|
||||
}, [imageMediaId, url]);
|
||||
|
||||
console.log("Is this a desktop app?", IsDesktopApp());
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [content, setContent] = useState<React.ReactNode>(null);
|
||||
const prompt = usePromptVideoStore((s) => s.prompt);
|
||||
@@ -89,18 +102,63 @@ export const PromptBoxVideo = ({
|
||||
showImagePrompts ||
|
||||
referenceImages.length > 0 ||
|
||||
uploadingImages.length > 0;
|
||||
const [resolutionList, setResolutionList] = useState<PopoverItem[]>([
|
||||
{
|
||||
label: "720p",
|
||||
selected: resolution === "720p",
|
||||
icon: <FontAwesomeIcon icon={faRectangle} className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
label: "480p",
|
||||
selected: resolution === "480p",
|
||||
icon: <FontAwesomeIcon icon={faRectangle} className="h-4 w-4" />,
|
||||
},
|
||||
]);
|
||||
|
||||
// TODO: Get rid of default resolutions. Just disable it if not present.
|
||||
let resolutionOptions: PopoverItem[];
|
||||
|
||||
if (!!selectedModel?.sizeOptions && selectedModel.sizeOptions.length > 0) {
|
||||
// When switching to a new model, the existing resolution might not be correct.
|
||||
// This is a gross and nasty hack to handle the case where the resolution is not found.
|
||||
const resolutionExists = selectedModel.sizeOptions.some((option) => option.textLabel === resolution);
|
||||
const useFirstOption = !resolutionExists;
|
||||
|
||||
resolutionOptions = selectedModel.sizeOptions.map((option, index) => {
|
||||
let faIcon = faRectangle;
|
||||
switch (option.icon) {
|
||||
case SizeIconOption.Landscape:
|
||||
faIcon = faRectangle;
|
||||
break;
|
||||
case SizeIconOption.Portrait:
|
||||
faIcon = faPortrait;
|
||||
break;
|
||||
case SizeIconOption.Square:
|
||||
faIcon = faSquare;
|
||||
break;
|
||||
}
|
||||
const icon = <FontAwesomeIcon icon={faIcon} className="h-4 w-4" />;
|
||||
return {
|
||||
label: option.textLabel,
|
||||
selected: option.textLabel === resolution || (useFirstOption && index === 0),
|
||||
icon: icon,
|
||||
};
|
||||
});
|
||||
} else {
|
||||
const resolutionExists = DEFAULT_RESOLUTIONS.some((option) => option.textLabel === resolution);
|
||||
const useFirstOption = !resolutionExists;
|
||||
|
||||
resolutionOptions = DEFAULT_RESOLUTIONS.map((option, index) => {
|
||||
let faIcon = faRectangle;
|
||||
switch (option.icon) {
|
||||
case SizeIconOption.Landscape:
|
||||
faIcon = faRectangle;
|
||||
break;
|
||||
case SizeIconOption.Portrait:
|
||||
faIcon = faPortrait;
|
||||
break;
|
||||
case SizeIconOption.Square:
|
||||
faIcon = faSquare;
|
||||
break;
|
||||
}
|
||||
const icon = <FontAwesomeIcon icon={faIcon} className="h-4 w-4" />;
|
||||
return {
|
||||
label: option.textLabel,
|
||||
selected: option.textLabel === resolution || (useFirstOption && index === 0),
|
||||
icon: icon,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const [resolutionList, setResolutionList] = useState<PopoverItem[]>(resolutionOptions);
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
@@ -130,7 +188,7 @@ export const PromptBoxVideo = ({
|
||||
const handleResolutionSelect = (selectedItem: PopoverItem) => {
|
||||
setResolution(selectedItem.label as any);
|
||||
setResolutionList((prev) =>
|
||||
prev.map((item) => ({
|
||||
resolutionOptions.map((item) => ({
|
||||
...item,
|
||||
selected: item.label === selectedItem.label,
|
||||
}))
|
||||
@@ -158,11 +216,7 @@ export const PromptBoxVideo = ({
|
||||
const handleEnqueue = async () => {
|
||||
if (!prompt.trim()) {
|
||||
console.warn("Cannot generate video: prompt is empty");
|
||||
return;
|
||||
}
|
||||
|
||||
if (referenceImages.length === 0) {
|
||||
console.warn("Cannot generate video: no reference image provided");
|
||||
toast.error("Please enter a prompt to generate video");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -171,10 +225,26 @@ export const PromptBoxVideo = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedModel?.requiresImage && referenceImages.length === 0) {
|
||||
console.warn("Cannot generate video: no reference image provided");
|
||||
toast.error("Please choose a starting frame image to generate video");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsEnqueueing(true);
|
||||
|
||||
gtagEvent("enqueue_video");
|
||||
|
||||
const subscriberId = crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: Math.random().toString(36).slice(2);
|
||||
|
||||
let imageMediaToken = undefined;
|
||||
|
||||
if (referenceImages.length > 0) {
|
||||
imageMediaToken = referenceImages[0].mediaToken;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
// TODO(bt,2025-05-08): This is a hack so we don't accidentally wind up with a permanently disabled prompt box if
|
||||
// the backend hangs on a given request.
|
||||
@@ -182,18 +252,20 @@ export const PromptBoxVideo = ({
|
||||
setIsEnqueueing(false);
|
||||
}, 10000);
|
||||
|
||||
const subscriberId = crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: Math.random().toString(36).slice(2);
|
||||
|
||||
await EnqueueImageToVideo({
|
||||
let request : EnqueueImageToVideoRequest = {
|
||||
model: selectedModel,
|
||||
image_media_token: referenceImages[0].mediaToken,
|
||||
image_media_token: imageMediaToken,
|
||||
prompt: prompt,
|
||||
end_frame_image_media_token: endFrameImage?.mediaToken,
|
||||
frontend_caller: "image_to_video",
|
||||
frontend_subscriber_id: subscriberId,
|
||||
});
|
||||
};
|
||||
|
||||
if (selectedModel?.tauriId === "sora_2") {
|
||||
request.sora_orientation = resolution === "720p" ? "landscape" : "portrait";
|
||||
}
|
||||
|
||||
await EnqueueImageToVideo(request);
|
||||
|
||||
onEnqueuePressed?.(prompt, subscriberId);
|
||||
|
||||
@@ -211,8 +283,11 @@ export const PromptBoxVideo = ({
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
|
||||
if (prompt.trim() && referenceImages.length === 0) {
|
||||
toast.error("Please choose a starting frame image to generate video");
|
||||
if (selectedModel?.requiresImage && referenceImages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!prompt.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -222,6 +297,10 @@ export const PromptBoxVideo = ({
|
||||
|
||||
const [isGalleryModalOpen, setIsGalleryModalOpen] = useState(false);
|
||||
|
||||
const modelNeedsAnImageButNoneAreSelected =
|
||||
selectedModel?.requiresImage &&
|
||||
referenceImages.length === 0;
|
||||
|
||||
// Hide/clear ending frame if model doesn't support it
|
||||
useEffect(() => {
|
||||
if (selectedModel && !selectedModel.endFrame && endFrameImage) {
|
||||
@@ -329,7 +408,7 @@ export const PromptBoxVideo = ({
|
||||
closeOnClick={true}
|
||||
>
|
||||
<PopoverMenu
|
||||
items={resolutionList}
|
||||
items={resolutionOptions}
|
||||
onSelect={handleResolutionSelect}
|
||||
mode="toggle"
|
||||
panelTitle="Resolution"
|
||||
@@ -368,7 +447,7 @@ export const PromptBoxVideo = ({
|
||||
disabled={
|
||||
isEnqueueing ||
|
||||
!prompt.trim() ||
|
||||
referenceImages.length === 0 ||
|
||||
modelNeedsAnImageButNoneAreSelected ||
|
||||
!selectedModel
|
||||
}
|
||||
>
|
||||
|
||||
@@ -85,16 +85,16 @@ export const usePromptImageStore = create<PromptImageStore>()((set) => ({
|
||||
}));
|
||||
|
||||
// ----- Video Prompt Box Store -----
|
||||
type Resolution = "720p" | "480p";
|
||||
type Resolution = "720p" | "480p"; // TODO: This needs to be relaxed.
|
||||
|
||||
interface PromptVideoStore {
|
||||
prompt: string;
|
||||
resolution: Resolution;
|
||||
resolution: Resolution | string;
|
||||
useSystemPrompt: boolean;
|
||||
referenceImages: RefImage[];
|
||||
endFrameImage?: RefImage;
|
||||
setPrompt: (prompt: string) => void;
|
||||
setResolution: (resolution: Resolution) => void;
|
||||
setResolution: (resolution: Resolution | string) => void;
|
||||
setUseSystemPrompt: (value: boolean) => void;
|
||||
setReferenceImages: (images: RefImage[]) => void;
|
||||
setEndFrameImage: (image?: RefImage) => void;
|
||||
|
||||
@@ -29,7 +29,15 @@ export function ProviderSetupModal({
|
||||
const modalTitle = `Set up ${serviceProviderName}`;
|
||||
const modalSubTitle = `Add your ${serviceProviderName} account to ArtCraft!`;
|
||||
|
||||
const modalDescription = `You can add your ${serviceProviderName} account to ArtCraft by simply logging in. Use your credits and account directly within Artcraft. You can add all of your AI accounts to Artcraft to use them all in one place and build the ultimate AI art tool.`;
|
||||
let modalDescription;
|
||||
switch (provider) {
|
||||
case GenerationProvider.Sora:
|
||||
modalDescription = `You can add your ${serviceProviderName} account to ArtCraft by simply logging in. Use can then use it directly within Artcraft. You can add all of your AI accounts to Artcraft to use them all in one place and build the ultimate AI art tool.`;
|
||||
break;
|
||||
default:
|
||||
modalDescription = `You can add your ${serviceProviderName} account to ArtCraft by simply logging in. Use your credits and account directly within Artcraft. You can add all of your AI accounts to Artcraft to use them all in one place and build the ultimate AI art tool.`;
|
||||
break;
|
||||
}
|
||||
|
||||
const modalButtonText = `Set up ${serviceProviderName}`;
|
||||
|
||||
|
||||
+9
-9
@@ -32,15 +32,6 @@ export const MidjourneyAccountBlock = () => {
|
||||
fetchSession();
|
||||
});
|
||||
|
||||
const handleMidjourneyButton = async () => {
|
||||
if (midjourneySession?.payload?.can_clear_state) {
|
||||
await clearState();
|
||||
setMidjourneySession(undefined);
|
||||
} else {
|
||||
await openLogin();
|
||||
}
|
||||
};
|
||||
|
||||
const clearState = async() => {
|
||||
try {
|
||||
await invoke("midjourney_clear_credentials_command");
|
||||
@@ -57,6 +48,15 @@ export const MidjourneyAccountBlock = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleMidjourneyButton = async () => {
|
||||
if (midjourneySession?.payload?.can_clear_state) {
|
||||
await clearState();
|
||||
setMidjourneySession(undefined);
|
||||
} else {
|
||||
await openLogin();
|
||||
}
|
||||
};
|
||||
|
||||
return(
|
||||
<div className="flex justify-between items-center">
|
||||
<span>Midjourney Account:</span>
|
||||
|
||||
+46
-70
@@ -3,99 +3,75 @@ import { Button } from "@storyteller/ui-button";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
|
||||
import {
|
||||
CheckSoraSession,
|
||||
SoraGetCredentialInfo,
|
||||
SoraSessionState,
|
||||
LogoutSoraSession,
|
||||
useSoraLoginListener,
|
||||
CheckSoraSessionResult,
|
||||
SoraGetCredentialInfoSuccess,
|
||||
} from "@storyteller/tauri-api";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { RefreshAccountStateEvent, useRefreshAccountStateEvent } from "libs/tauri-events/src/lib/events/functional/RefreshAccountStateEvent";
|
||||
|
||||
const DEFAULT_CACHE_TTL_MS = 1 * 60 * 1000; // 1 minute
|
||||
|
||||
interface SoraAccountBlockProps {
|
||||
cacheKey?: string;
|
||||
cacheTtlMs?: number;
|
||||
}
|
||||
|
||||
export const SoraAccountBlock = ({
|
||||
cacheKey = "soraSettingsSessionCache",
|
||||
cacheTtlMs = DEFAULT_CACHE_TTL_MS,
|
||||
}: SoraAccountBlockProps) => {
|
||||
const [soraSession, setSoraSession] = useState<CheckSoraSessionResult| undefined>(undefined);
|
||||
export const SoraAccountBlock = () => {
|
||||
const [soraSession, setSoraSession] = useState<SoraGetCredentialInfoSuccess| undefined>(undefined);
|
||||
const [isCheckingSoraSession, setIsCheckingSoraSession] = useState(false);
|
||||
|
||||
useSoraLoginListener(() => {
|
||||
const fetchSession = async () => {
|
||||
setIsCheckingSoraSession(true);
|
||||
CheckSoraSession()
|
||||
.then((result) => {
|
||||
setSoraSession(result);
|
||||
localStorage.setItem(
|
||||
cacheKey,
|
||||
JSON.stringify({ session: result, timestamp: Date.now() })
|
||||
);
|
||||
})
|
||||
.finally(() => setIsCheckingSoraSession(false));
|
||||
});
|
||||
try {
|
||||
const result = await SoraGetCredentialInfo();
|
||||
setSoraSession(result);
|
||||
} catch (e) {
|
||||
console.error("Error fetching Sora session", e);
|
||||
setSoraSession(undefined);
|
||||
} finally {
|
||||
setIsCheckingSoraSession(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSession = async () => {
|
||||
setIsCheckingSoraSession(true);
|
||||
try {
|
||||
const cached = localStorage.getItem(cacheKey);
|
||||
if (cached) {
|
||||
const { session, timestamp } = JSON.parse(cached);
|
||||
if (Date.now() - timestamp < cacheTtlMs) {
|
||||
setSoraSession(session);
|
||||
setIsCheckingSoraSession(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const result = await CheckSoraSession();
|
||||
setSoraSession(result);
|
||||
localStorage.setItem(
|
||||
cacheKey,
|
||||
JSON.stringify({ session: result, timestamp: Date.now() })
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Error fetching Sora session", e);
|
||||
setSoraSession(undefined);
|
||||
} finally {
|
||||
setIsCheckingSoraSession(false);
|
||||
}
|
||||
};
|
||||
fetchSession();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cacheKey, cacheTtlMs]);
|
||||
}, []);
|
||||
|
||||
useRefreshAccountStateEvent(async (event: RefreshAccountStateEvent) => {
|
||||
fetchSession();
|
||||
});
|
||||
|
||||
const clearState = async() => {
|
||||
try {
|
||||
await LogoutSoraSession();
|
||||
//await invoke("sora_clear_credentials_command"); // TODO: Wrong command name
|
||||
} catch (e) {
|
||||
console.error("Error clearing Sora credentials", e);
|
||||
}
|
||||
}
|
||||
|
||||
const openLogin = async() => {
|
||||
try {
|
||||
await invoke("open_sora_login_command");
|
||||
} catch (e) {
|
||||
console.error("Error opening Sora login", e);
|
||||
}
|
||||
}
|
||||
|
||||
const handleSoraButton = async () => {
|
||||
if (isCheckingSoraSession) return;
|
||||
if (soraSession?.state === SoraSessionState.Valid) {
|
||||
setIsCheckingSoraSession(true);
|
||||
await LogoutSoraSession();
|
||||
setSoraSession({state: SoraSessionState.NotSetUp});
|
||||
localStorage.removeItem(cacheKey);
|
||||
setIsCheckingSoraSession(false);
|
||||
if (soraSession?.payload?.can_clear_state) {
|
||||
await clearState();
|
||||
setSoraSession(undefined);
|
||||
} else {
|
||||
setIsCheckingSoraSession(true);
|
||||
try {
|
||||
await invoke("open_sora_login_command");
|
||||
} catch (e) {
|
||||
} finally {
|
||||
setIsCheckingSoraSession(false);
|
||||
}
|
||||
await openLogin();
|
||||
}
|
||||
};
|
||||
|
||||
return(
|
||||
<div className="flex justify-between items-center">
|
||||
<span>OpenAI / Sora Account:</span>
|
||||
<pre>{soraSession?.maybe_account_email || ""}</pre>
|
||||
<pre>{soraSession?.payload?.maybe_email || "Not logged in"}</pre>
|
||||
<Button
|
||||
variant={
|
||||
soraSession?.state === SoraSessionState.Valid && !isCheckingSoraSession
|
||||
soraSession?.payload?.can_clear_state && !isCheckingSoraSession
|
||||
? "destructive"
|
||||
: soraSession?.state === SoraSessionState.NotSetUp
|
||||
: soraSession?.payload?.can_clear_state
|
||||
? "primary"
|
||||
: "secondary"
|
||||
}
|
||||
@@ -108,7 +84,7 @@ export const SoraAccountBlock = ({
|
||||
icon={faSpinnerThird}
|
||||
className="animate-spin text-sm"
|
||||
/>
|
||||
) : soraSession?.state === SoraSessionState.Valid ? (
|
||||
) : soraSession?.payload?.can_clear_state ? (
|
||||
"Disconnect"
|
||||
) : (
|
||||
"Connect"
|
||||
|
||||
-112
@@ -1,112 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
|
||||
import {
|
||||
CheckSoraSession,
|
||||
SoraSessionState,
|
||||
LogoutSoraSession,
|
||||
useSoraLoginListener,
|
||||
} from "@storyteller/tauri-api";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
interface SoraAccountButtonProps {
|
||||
cacheKey?: string;
|
||||
cacheTtlMs?: number;
|
||||
}
|
||||
|
||||
export const SoraAccountButton = ({
|
||||
cacheKey = "soraSettingsSessionCache",
|
||||
cacheTtlMs = 5 * 60 * 1000,
|
||||
}: SoraAccountButtonProps) => {
|
||||
const [soraSession, setSoraSession] = useState<SoraSessionState | null>(null);
|
||||
const [isCheckingSoraSession, setIsCheckingSoraSession] = useState(false);
|
||||
|
||||
useSoraLoginListener(() => {
|
||||
setIsCheckingSoraSession(true);
|
||||
CheckSoraSession()
|
||||
.then((result) => {
|
||||
setSoraSession(result.state);
|
||||
localStorage.setItem(
|
||||
cacheKey,
|
||||
JSON.stringify({ state: result.state, timestamp: Date.now() })
|
||||
);
|
||||
})
|
||||
.finally(() => setIsCheckingSoraSession(false));
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSession = async () => {
|
||||
setIsCheckingSoraSession(true);
|
||||
try {
|
||||
const cached = localStorage.getItem(cacheKey);
|
||||
if (cached) {
|
||||
const { state, timestamp } = JSON.parse(cached);
|
||||
if (Date.now() - timestamp < cacheTtlMs) {
|
||||
setSoraSession(state);
|
||||
setIsCheckingSoraSession(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const result = await CheckSoraSession();
|
||||
setSoraSession(result.state);
|
||||
localStorage.setItem(
|
||||
cacheKey,
|
||||
JSON.stringify({ state: result.state, timestamp: Date.now() })
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Error fetching Sora session", e);
|
||||
setSoraSession(null);
|
||||
} finally {
|
||||
setIsCheckingSoraSession(false);
|
||||
}
|
||||
};
|
||||
fetchSession();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cacheKey, cacheTtlMs]);
|
||||
|
||||
const handleSoraButton = async () => {
|
||||
if (isCheckingSoraSession) return;
|
||||
if (soraSession === SoraSessionState.Valid) {
|
||||
setIsCheckingSoraSession(true);
|
||||
await LogoutSoraSession();
|
||||
setSoraSession(SoraSessionState.NotSetUp);
|
||||
localStorage.removeItem(cacheKey);
|
||||
setIsCheckingSoraSession(false);
|
||||
} else {
|
||||
setIsCheckingSoraSession(true);
|
||||
try {
|
||||
await invoke("open_sora_login_command");
|
||||
} catch (e) {
|
||||
} finally {
|
||||
setIsCheckingSoraSession(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={
|
||||
soraSession === SoraSessionState.Valid && !isCheckingSoraSession
|
||||
? "destructive"
|
||||
: soraSession === SoraSessionState.NotSetUp
|
||||
? "primary"
|
||||
: "secondary"
|
||||
}
|
||||
className="h-[30px]"
|
||||
onClick={handleSoraButton}
|
||||
disabled={isCheckingSoraSession}
|
||||
>
|
||||
{isCheckingSoraSession ? (
|
||||
<FontAwesomeIcon
|
||||
icon={faSpinnerThird}
|
||||
className="animate-spin text-sm"
|
||||
/>
|
||||
) : soraSession === SoraSessionState.Valid ? (
|
||||
"Disconnect"
|
||||
) : (
|
||||
"Connect"
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -5,6 +5,7 @@ export * from "./lib/classes/metadata/ModelCreator.js";
|
||||
export * from "./lib/classes/metadata/ModelCreatorIcons.js";
|
||||
export * from "./lib/classes/metadata/ModelMapping.js";
|
||||
export * from "./lib/classes/metadata/ModelTag.js";
|
||||
export * from "./lib/classes/metadata/SizeOption.js";
|
||||
export * from "./lib/legacy/ModelConfig.js";
|
||||
export * from "./lib/legacy/ModelInfo.js";
|
||||
export * from "./lib/legacy/Models.js";
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ModelCreator } from "src/index.js";
|
||||
import { Model, ModelKind } from "./Model.js";
|
||||
import { ModelCategory } from "../legacy/ModelConfig.js";
|
||||
import { ModelTag } from "./metadata/ModelTag.js";
|
||||
import { SizeOption } from "./metadata/SizeOption.js";
|
||||
|
||||
export class VideoModel extends Model {
|
||||
// Typescript type discriminator property
|
||||
@@ -15,6 +16,12 @@ export class VideoModel extends Model {
|
||||
// Whether the model supports image ending frames
|
||||
readonly endFrame: boolean;
|
||||
|
||||
// Whether the model requires an image
|
||||
readonly requiresImage: boolean;
|
||||
|
||||
// The size options for the model
|
||||
readonly sizeOptions: SizeOption[];
|
||||
|
||||
constructor(args: {
|
||||
id: string;
|
||||
tauriId: string;
|
||||
@@ -26,10 +33,14 @@ export class VideoModel extends Model {
|
||||
selectorBadges: string[];
|
||||
startFrame: boolean;
|
||||
endFrame: boolean;
|
||||
requiresImage: boolean;
|
||||
tags?: ModelTag[];
|
||||
sizeOptions?: SizeOption[];
|
||||
}) {
|
||||
super(args);
|
||||
this.startFrame = args.startFrame;
|
||||
this.endFrame = args.endFrame;
|
||||
this.requiresImage = args.requiresImage;
|
||||
this.sizeOptions = args.sizeOptions ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export const MODEL_TYPE_TO_CREATOR: Record<string, ModelCreator> = {
|
||||
kling_2p1_pro: ModelCreator.Kling,
|
||||
kling_2p1_master: ModelCreator.Kling,
|
||||
seedance_1p0_lite: ModelCreator.Bytedance,
|
||||
sora_2: ModelCreator.OpenAi,
|
||||
veo_2: ModelCreator.Google,
|
||||
recraft_3: ModelCreator.Recraft,
|
||||
hunyuan_3d: ModelCreator.Tencent,
|
||||
@@ -96,6 +97,7 @@ export const getModelDisplayName = (modelType: string): string => {
|
||||
kling_2p1_master: "Kling 2.1 Master",
|
||||
seedance_1p0_lite: "Seedance 1.0 Lite",
|
||||
veo_2: "Veo 2",
|
||||
sora_2: "Sora 2",
|
||||
recraft_3: "Recraft 3",
|
||||
hunyuan_3d_2p0: "Hunyuan 3D 2.0",
|
||||
hunyuan_3d_2p1: "Hunyuan 3D 2.1",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
|
||||
export enum SizeIconOption {
|
||||
Landscape,
|
||||
Portrait,
|
||||
Square,
|
||||
}
|
||||
|
||||
export interface SizeOption {
|
||||
textLabel: string;
|
||||
tauriValue: string;
|
||||
icon: SizeIconOption;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user