diff --git a/Cargo.lock b/Cargo.lock index cbfa66b3d0..40d66accee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9722,6 +9722,7 @@ dependencies = [ "js-sys", "log", "mime", + "mime_guess", "native-tls", "once_cell", "percent-encoding", @@ -11293,6 +11294,7 @@ dependencies = [ "mysql_queries", "native-tls", "once_cell", + "openai_sora_client", "password", "pin-project-lite", "primitives", diff --git a/crates/lib/api_clients/openai_sora_client/Cargo.toml b/crates/lib/api_clients/openai_sora_client/Cargo.toml index 96a2678c14..49acbf41eb 100644 --- a/crates/lib/api_clients/openai_sora_client/Cargo.toml +++ b/crates/lib/api_clients/openai_sora_client/Cargo.toml @@ -24,12 +24,12 @@ subprocess_common = { path = "../../subprocess_common" } # External anyhow = { workspace = true } log = { workspace = true } -reqwest = { workspace = true } +reqwest = { workspace = true, features = ["multipart"] } serde = { workspace = true } serde_derive = { workspace = true } serde_json = { workspace = true } +tokio = { version = "1.38.0", features = ["macros"] } [dev-dependencies] -tokio = { version = "1.38.0", features = ["macros"] } testing = { path = "../../testing" } filesys = { path = "../../files/filesys" } diff --git a/crates/lib/api_clients/openai_sora_client/src/credentials.rs b/crates/lib/api_clients/openai_sora_client/src/credentials.rs index 37106dd19e..15a9da377b 100644 --- a/crates/lib/api_clients/openai_sora_client/src/credentials.rs +++ b/crates/lib/api_clients/openai_sora_client/src/credentials.rs @@ -1,5 +1,5 @@ use reqwest::RequestBuilder; - +use errors::AnyhowResult; // NB: It appears that the sentinel may require a matching user agent. const USER_AGENT : &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36"; @@ -7,7 +7,7 @@ const USER_AGENT : &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit pub struct SoraCredentials { pub bearer_token: String, pub cookie: String, - pub sentinel: String, + pub sentinel: Option, } impl SoraCredentials { @@ -18,19 +18,30 @@ impl SoraCredentials { } } + pub fn from_env() -> AnyhowResult { + let bearer = std::env::var("SORA_BEARER_TOKEN")?; + let cookie = std::env::var("SORA_COOKIE")?; + let sentinel = std::env::var("SORA_SENTINEL").ok(); + + Ok(Self { bearer_token: bearer, cookie, sentinel }) + } + pub fn add_credential_headers_to_request(&self, request: RequestBuilder) -> RequestBuilder { let bearer_header = self.authorization_header_value(); println!(">>> BEARER HEADER = {}", bearer_header); //println!(">>> COOKIE HEADER = {}", self.cookie); - println!(">>> SENTINEL HEADER = {}", self.sentinel); + println!(">>> SENTINEL HEADER = {}", self.sentinel.clone().unwrap_or("None".to_string())); - let request = request - .header("OpenAI-Sentinel-Token", &self.sentinel) + let mut request = request .header("User-Agent", USER_AGENT) .header("Cookie", &self.cookie) .header("Authorization", bearer_header); + if let Some(sentinel) = &self.sentinel { + request = request.header("OpenAI-Sentinel-Token", sentinel); + } + /* Without sentinel token: status: "{\n \"error\": {\n \"message\": \"Hmmm something didn't look right with your request. Please try again later or visit https://help.openai.com if this issue persists.\",\n \"type\": \"invalid_request_error\",\n \"param\": null,\n \"code\": \"sentinel_block\"\n }\n}" diff --git a/crates/lib/api_clients/openai_sora_client/src/image_gen/raw_sora_image_gen.rs b/crates/lib/api_clients/openai_sora_client/src/image_gen/raw_sora_image_gen.rs index b711e36724..851f573b9a 100644 --- a/crates/lib/api_clients/openai_sora_client/src/image_gen/raw_sora_image_gen.rs +++ b/crates/lib/api_clients/openai_sora_client/src/image_gen/raw_sora_image_gen.rs @@ -106,13 +106,16 @@ pub (crate) async fn call_sora_image_gen(sora_request: RawSoraImageGenRequest, c //let request = credentials.add_credential_headers_to_request(request); - let request= client.post(URL) - .header("OpenAI-Sentinel-Token", &credentials.sentinel) + let mut request = client.post(URL) .header("User-Agent", USER_AGENT) .header("Cookie", &credentials.cookie) .header("Authorization", credentials.authorization_header_value()) .header("Content-Type", "application/json"); + if let Some(sentinel) = &credentials.sentinel { + request = request.header("OpenAI-Sentinel-Token", sentinel); + } + let request = request.json(&sora_request).build()?; println!("\n\n >>> raw request = {:?}\n\n", request); diff --git a/crates/lib/api_clients/openai_sora_client/src/image_gen/sora_image_gen_simple.rs b/crates/lib/api_clients/openai_sora_client/src/image_gen/sora_image_gen_simple.rs index 67f601ad40..7b27a0e881 100644 --- a/crates/lib/api_clients/openai_sora_client/src/image_gen/sora_image_gen_simple.rs +++ b/crates/lib/api_clients/openai_sora_client/src/image_gen/sora_image_gen_simple.rs @@ -54,7 +54,7 @@ mod tests { let creds = SoraCredentials { bearer_token: bearer, cookie, - sentinel, + sentinel: Some(sentinel), }; let response = sora_image_gen_simple(SoraImageGenSimpleRequest { diff --git a/crates/lib/api_clients/openai_sora_client/src/job/sora_job_status.rs b/crates/lib/api_clients/openai_sora_client/src/job/sora_job_status.rs index 5ac6e460c9..c33f9541f9 100644 --- a/crates/lib/api_clients/openai_sora_client/src/job/sora_job_status.rs +++ b/crates/lib/api_clients/openai_sora_client/src/job/sora_job_status.rs @@ -52,7 +52,7 @@ mod tests { let creds = SoraCredentials { bearer_token: bearer, cookie, - sentinel, + sentinel: Some(sentinel), }; let task_id = "task_01jqwwrkvgeqp8jsf5mqk1jceh"; diff --git a/crates/lib/api_clients/openai_sora_client/src/lib.rs b/crates/lib/api_clients/openai_sora_client/src/lib.rs index 58936e5268..54f9d2031d 100644 --- a/crates/lib/api_clients/openai_sora_client/src/lib.rs +++ b/crates/lib/api_clients/openai_sora_client/src/lib.rs @@ -1,4 +1,4 @@ -mod credentials; +pub mod credentials; pub mod image_gen; pub mod job; pub mod upload; \ No newline at end of file diff --git a/crates/lib/api_clients/openai_sora_client/src/upload/mod.rs b/crates/lib/api_clients/openai_sora_client/src/upload/mod.rs index 0eec5ad179..9583da973e 100644 --- a/crates/lib/api_clients/openai_sora_client/src/upload/mod.rs +++ b/crates/lib/api_clients/openai_sora_client/src/upload/mod.rs @@ -1 +1,2 @@ -pub mod sora_image_upload; \ No newline at end of file +pub mod sora_image_upload; +pub mod sora_media_upload; \ No newline at end of file diff --git a/crates/lib/api_clients/openai_sora_client/src/upload/sora_media_upload.rs b/crates/lib/api_clients/openai_sora_client/src/upload/sora_media_upload.rs new file mode 100644 index 0000000000..e26a7675a9 --- /dev/null +++ b/crates/lib/api_clients/openai_sora_client/src/upload/sora_media_upload.rs @@ -0,0 +1,116 @@ +use errors::AnyhowResult; +use std::path::Path; +use reqwest::multipart::{Form, Part}; +use reqwest::Client; +use serde::Deserialize; +use tokio::fs::File; +use tokio::io::AsyncReadExt; +use crate::credentials::SoraCredentials; + +const URL: &str = "https://sora.com/backend/uploads"; + +#[derive(Debug, Deserialize)] +pub struct SoraMediaUploadResponse { + 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, + pub height: Option, + pub duration_sec: Option, + pub n_frames: Option, + pub size_bytes: u64, + pub thumbnail_url: Option, +} + +pub struct SoraMediaUploadRequest<'a> { + pub file_path: String, + pub credentials: &'a SoraCredentials, +} + +pub async fn sora_media_upload(request: SoraMediaUploadRequest<'_>) -> AnyhowResult { + let client = Client::new(); + let file_path = Path::new(&request.file_path); + let filename = file_path.file_name() + .ok_or_else(|| anyhow::anyhow!("Invalid file path"))? + .to_string_lossy() + .to_string(); + + // Read file data + let mut file = File::open(&file_path).await?; + let mut buffer = Vec::new(); + file.read_to_end(&mut buffer).await?; + + 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", + }; + + // Create multipart form + let part = Part::bytes(buffer) + .file_name(filename) + .mime_str(mime_type)?; + let form = Form::new().part("file", part); + + // Make API request + let request_builder = client.post(URL).multipart(form); + let request_builder = request.credentials.add_credential_headers_to_request(request_builder); + let response = request_builder.send().await?; + + // Check response status + if !response.status().is_success() { + let status = response.status(); + let text = response.text().await?; + return Err(anyhow::anyhow!("Upload failed with status {}: {}", status, text)); + } + + // Parse response + let upload_response = response.json::().await?; + Ok(upload_response) +} + +#[cfg(test)] +mod tests { + use std::fs::read_to_string; + use errors::AnyhowResult; + use testing::test_file_path::test_file_path; + use crate::credentials::SoraCredentials; + use crate::upload::sora_media_upload::{sora_media_upload, SoraMediaUploadRequest}; + + // #[ignore] + #[tokio::test] + pub async fn test() -> AnyhowResult<()> { + + let cookie = read_to_string(test_file_path("test_data/test_data/temp/cookie.txt")?)?; + let cookie = cookie.trim().to_string(); + + let bearer = read_to_string(test_file_path("test_data/test_data/temp/bearer.txt")?)?; + let bearer = bearer.trim().to_string(); + + let creds = SoraCredentials { + bearer_token: bearer, + cookie, + sentinel: None, + }; + + let response = sora_media_upload(SoraMediaUploadRequest { + file_path: "/Users/kasisnu/Downloads/puppies-test-image.png".to_string(), + credentials: &creds, + }).await?; + + println!("media_id: {}", response.id); + println!("media_url: {}", response.url); + + assert!(response.id.starts_with("media_")); + Ok(()) + } +} \ No newline at end of file diff --git a/crates/lib/mysql_queries/src/payloads/generic_inference_args/generic_inference_args.rs b/crates/lib/mysql_queries/src/payloads/generic_inference_args/generic_inference_args.rs index ce822f98d1..a3710314b2 100644 --- a/crates/lib/mysql_queries/src/payloads/generic_inference_args/generic_inference_args.rs +++ b/crates/lib/mysql_queries/src/payloads/generic_inference_args/generic_inference_args.rs @@ -1,7 +1,7 @@ use crate::payloads::generic_inference_args::inner_payloads::f5_tts_payload::F5TTSPayload; use crate::payloads::generic_inference_args::inner_payloads::face_fusion_payload::FaceFusionPayload; use crate::payloads::generic_inference_args::inner_payloads::gptsovits_payload::GptSovitsPayload; -use crate::payloads::generic_inference_args::inner_payloads::image_generation_payload::StableDiffusionArgs; +use crate::payloads::generic_inference_args::inner_payloads::image_generation_payload::{SoraImageGenArgs, StableDiffusionArgs}; use crate::payloads::generic_inference_args::inner_payloads::lipsync_payload::LipsyncArgs; use crate::payloads::generic_inference_args::inner_payloads::live_portrait_payload::LivePortraitPayload; use crate::payloads::generic_inference_args::inner_payloads::mocap_payload::MocapArgs; @@ -111,7 +111,7 @@ pub enum PolymorphicInferenceArgs { Tts(TTSArgs), // No arguments yet. // It might be best to just not include this when not used. - + /// Voice conversion. (Short name to save space when serializing.) Vc { /// Argument for so-vits-svc @@ -143,6 +143,10 @@ pub enum PolymorphicInferenceArgs { /// Image generation. (Short name to save space when serializing.) Ig(StableDiffusionArgs), + + /// Sora Image Generation. (Short name to save space when serializing.) + Sg(SoraImageGenArgs), + /// Mocap (Short name to save space when serializing.) Mc(MocapArgs), diff --git a/crates/lib/mysql_queries/src/payloads/generic_inference_args/inner_payloads/image_generation_payload.rs b/crates/lib/mysql_queries/src/payloads/generic_inference_args/inner_payloads/image_generation_payload.rs index 06d1b0761b..120766b76f 100644 --- a/crates/lib/mysql_queries/src/payloads/generic_inference_args/inner_payloads/image_generation_payload.rs +++ b/crates/lib/mysql_queries/src/payloads/generic_inference_args/inner_payloads/image_generation_payload.rs @@ -1,3 +1,4 @@ +use tokens::tokens::media_files::MediaFileToken; use tokens::tokens::model_weights::ModelWeightToken; /// Video sources can be one of several: @@ -52,7 +53,7 @@ pub struct StableDiffusionArgs { #[serde(rename = "se")] #[serde(skip_serializing_if = "Option::is_none")] pub maybe_seed: Option, - + #[serde(rename = "mu")] #[serde(skip_serializing_if = "Option::is_none")] pub maybe_upload_path: Option, @@ -60,7 +61,7 @@ pub struct StableDiffusionArgs { #[serde(rename = "cf")] #[serde(skip_serializing_if = "Option::is_none")] pub maybe_cfg_scale: Option, - + #[serde(rename = "lu")] #[serde(skip_serializing_if = "Option::is_none")] pub maybe_lora_upload_path: Option, @@ -90,3 +91,32 @@ pub struct StableDiffusionArgs { } +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct SoraImageGenArgs { + #[serde(rename = "p")] + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt: Option, + + #[serde(rename = "sm")] + pub scene_media_token: Option, + + #[serde(rename = "mf")] + #[serde(skip_serializing_if = "Option::is_none")] + pub maybe_additional_media_file_tokens: Option>, + + #[serde(rename = "sa")] + #[serde(skip_serializing_if = "Option::is_none")] + pub maybe_number_of_samples: Option, + + + #[serde(rename = "su")] + #[serde(skip_serializing_if = "Option::is_none")] + pub maybe_sora_media_upload_tokens: Option>, + + + #[serde(rename = "ss")] + #[serde(skip_serializing_if = "Option::is_none")] + pub maybe_sora_scene_media_token: Option, +} + + diff --git a/crates/lib/testing/src/test_file_path.rs b/crates/lib/testing/src/test_file_path.rs index bc1e86aa13..1804a16fdb 100644 --- a/crates/lib/testing/src/test_file_path.rs +++ b/crates/lib/testing/src/test_file_path.rs @@ -17,7 +17,9 @@ pub fn test_file_path>(path_from_repo_root: P) -> AnyhowResult>> path = {:?}", path); Ok(path.canonicalize()?) + } #[cfg(test)] diff --git a/crates/service/web/storyteller_web/Cargo.toml b/crates/service/web/storyteller_web/Cargo.toml index c25a46596c..1fc7bb6a6f 100644 --- a/crates/service/web/storyteller_web/Cargo.toml +++ b/crates/service/web/storyteller_web/Cargo.toml @@ -69,6 +69,7 @@ tts_common = { path = "../../../lib/tts_common" } url_config = { path = "../../../lib/url_config" } user_input_common = { path = "../../../lib/user_input_common" } videos = { path = "../../../lib/files/videos" } +openai_sora_client = { path = "../../../lib/api_clients/openai_sora_client" } # Internal Components billing_component = { path = "../../plugins/billing" } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/image_gen/enqueue_image_generation.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/image_gen/enqueue_image_generation.rs index 6003600611..c622e47ccf 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/image_gen/enqueue_image_generation.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/image_gen/enqueue_image_generation.rs @@ -507,6 +507,9 @@ fn validate_request( Ok(()) } + + + fn inference_mode_from_http_request(http_request: &HttpRequest) -> Option { inference_mode_from_url_path(http_request.path()) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/image_studio/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/image_studio/mod.rs new file mode 100644 index 0000000000..6b036d2d24 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/image_studio/mod.rs @@ -0,0 +1,2 @@ +pub mod upload; +pub mod prompt; \ No newline at end of file diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/image_studio/prompt/enqueue_studio_image_generation.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/image_studio/prompt/enqueue_studio_image_generation.rs new file mode 100644 index 0000000000..23a00ee703 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/image_studio/prompt/enqueue_studio_image_generation.rs @@ -0,0 +1,489 @@ +#![forbid(unused_mut)] + +use std::fmt::Debug; +use std::fmt::{Display, Formatter}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use actix_web::error::ResponseError; +use actix_web::http::StatusCode; +use actix_web::web::Json; +use actix_web::{web, HttpRequest, HttpResponse}; +use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath; +use cloud_storage::bucket_client::BucketClient; +use log::error; +use log::warn; +use mysql_queries::queries::media_files::get::get_media_file::get_media_file; +use openai_sora_client::upload::sora_media_upload::{sora_media_upload, SoraMediaUploadRequest}; +use openai_sora_client::credentials::SoraCredentials; +use serde::Deserialize; +use serde::Serialize; +use sqlx::MySqlPool; +use utoipa::ToSchema; +use web::Data; + +use enums::by_table::generic_inference_jobs::inference_category::InferenceCategory; +use enums::by_table::generic_inference_jobs::inference_job_type::InferenceJobType; +use enums::by_table::generic_inference_jobs::inference_model_type::InferenceModelType; +use http_server_common::request::get_request_header_optional::get_request_header_optional; +use http_server_common::request::get_request_ip::get_request_ip; +use mysql_queries::payloads::generic_inference_args::generic_inference_args::{ + GenericInferenceArgs, + InferenceCategoryAbbreviated, + PolymorphicInferenceArgs, +}; +use mysql_queries::payloads::generic_inference_args::inner_payloads::image_generation_payload::SoraImageGenArgs; +use mysql_queries::queries::generic_inference::web::insert_generic_inference_job::{ + insert_generic_inference_job, + InsertGenericInferenceArgs, +}; +use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::model_weights::ModelWeightToken; +use tokens::tokens::users::UserToken; + +use crate::configs::plans::get_correct_plan_for_session::get_correct_plan_for_session; +use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format; +use crate::http_server::web_utils::response_error_helpers::to_simple_json_error; +use crate::state::server_state::ServerState; +use crate::util::allowed_studio_access::allowed_studio_access; + +/// This is the number of images (batch size) to generate for each request. +/// We should allow all users to have multiple images generated at once as this +/// is what other providers do. +const MINIMUM_IMAGE_COUNT : u32 = 1; + +/// The maximum number of images (batch size) to generate for each request. +const MAXIMUM_IMAGE_COUNT : u32 = 2; + +/// Debug requests can get routed to special "debug-only" workers, which can +/// be used to trial new code, run debugging, etc. +const DEBUG_HEADER_NAME: &str = "enable-debug-mode"; + +/// The routing tag header can send workloads to particular k8s hosts. +/// This is useful for catching the live logs or intercepting the job. +const ROUTING_TAG_HEADER_NAME: &str = "routing-tag"; + + +#[derive(Deserialize, ToSchema)] +pub struct EnqueueStudioImageGenRequest { + pub uuid_idempotency_token: String, + pub scene_media_token: MediaFileToken, + pub prompt: String, + pub maybe_additional_images: Option>, + pub maybe_number_of_samples: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct EnqueueImageGenRequestSuccessResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} + +#[derive(Debug, ToSchema)] +pub enum EnqueueImageGenRequestError { + BadInput(String), + NotAuthorized, + ServerError, + RateLimited, +} + +impl ResponseError for EnqueueImageGenRequestError { + fn status_code(&self) -> StatusCode { + match *self { + EnqueueImageGenRequestError::BadInput(_) => StatusCode::BAD_REQUEST, + EnqueueImageGenRequestError::NotAuthorized => StatusCode::UNAUTHORIZED, + EnqueueImageGenRequestError::ServerError => StatusCode::INTERNAL_SERVER_ERROR, + EnqueueImageGenRequestError::RateLimited => StatusCode::TOO_MANY_REQUESTS, + } + } + + fn error_response(&self) -> HttpResponse { + let error_reason = match self { + EnqueueImageGenRequestError::BadInput(reason) => reason.to_string(), + EnqueueImageGenRequestError::NotAuthorized => "unauthorized".to_string(), + EnqueueImageGenRequestError::ServerError => "server error".to_string(), + EnqueueImageGenRequestError::RateLimited => "rate limited".to_string(), + }; + + to_simple_json_error(&error_reason, self.status_code()) + } +} + +impl Display for EnqueueImageGenRequestError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self) + } +} + +// Implementation for enqueuing a TTS request +// Reference enqueue_infer_tts_handler.rs for checks: rate limiting / user sessions +// insert generic inference job.rs +// Need to convert it to generic inference job. +#[utoipa::path( + post, + path = "/v1/image_studio/prompt", + responses( + ( + status = 200, + description = "Enqueue TTS generically", + body = EnqueueImageGenRequestSuccessResponse, + ), + (status = 400, description = "Bad input", body = EnqueueImageGenRequestError), + (status = 401, description = "Not authorized", body = EnqueueImageGenRequestError), + (status = 429, description = "Rate limited", body = EnqueueImageGenRequestError), + (status = 500, description = "Server error", body = EnqueueImageGenRequestError) + ), + params(("request" = EnqueueStudioImageGenRequest, description = "Payload for Image Generation Request")) +)] +pub async fn enqueue_studio_image_generation_request( + http_request: HttpRequest, + request: Json, + server_state: Data> +) -> Result { + + + validate_request(&request)?; + + + let mut maybe_user_token: Option = None; + let visbility = enums::common::visibility::Visibility::Public; + + let mut mysql_connection = server_state.mysql_pool.acquire().await.map_err(|err| { + warn!("MySql pool error: {:?}", err); + EnqueueImageGenRequestError::ServerError + })?; + + // ==================== USER SESSION ==================== // + + let maybe_user_session = server_state.session_checker + .maybe_get_user_session_extended_from_connection(&http_request, &mut mysql_connection).await + .map_err(|e| { + warn!("Session checker error: {:?}", e); + EnqueueImageGenRequestError::ServerError + })?; + + + if let Some(user_session) = maybe_user_session.as_ref() { + maybe_user_token = Some(UserToken::new_from_str(&user_session.user_token)); + } + + // ==================== PLANS ==================== // + + // Plan should handle "first anonymous use" and "investor" cases. + let plan = get_correct_plan_for_session( + server_state.server_environment_old, + maybe_user_session.as_ref() + ); + + // Separate priority for animation. + let priority_level = plan.web_vc_base_priority_level(); + + // ==================== DEBUG MODE + ROUTING TAG ==================== // + + let is_debug_request = get_request_header_optional(&http_request, DEBUG_HEADER_NAME).is_some(); + + let maybe_routing_tag = get_request_header_optional(&http_request, ROUTING_TAG_HEADER_NAME).map( + |routing_tag| routing_tag.trim().to_string() + ); + + + // ==================== BANNED USERS ==================== // + + if let Some(ref user) = maybe_user_session { + if user.role.is_banned { + return Err(EnqueueImageGenRequestError::NotAuthorized); + } + } + + // DETECT premium user and queue + + // ==================== RATE LIMIT ==================== // + + let rate_limiter = match maybe_user_session { + None => &server_state.redis_rate_limiters.logged_out, + Some(ref _user) => &server_state.redis_rate_limiters.logged_in, + }; + + if let Err(_err) = rate_limiter.rate_limit_request(&http_request) { + return Err(EnqueueImageGenRequestError::RateLimited); + } + + // Get up IP address + let ip_address = get_request_ip(&http_request); + + + // ==================== HANDLE IDEMPOTENCY ==================== // + + if let Err(reason) = validate_idempotency_token_format(&request.uuid_idempotency_token) { + return Err(EnqueueImageGenRequestError::BadInput(reason)); + } + + insert_idempotency_token(&request.uuid_idempotency_token, &mut *mysql_connection) + .await + .map_err(|err| { + error!("Error inserting idempotency token: {:?}", err); + EnqueueImageGenRequestError::BadInput("invalid idempotency token".to_string()) + })?; + + // ==================== INFERENCE ARGS ==================== // + + + + let work_temp_dir = server_state.temp_dir_creator.new_tempdir("image_studio").unwrap(); + let public_bucket_client = server_state.public_bucket_client.clone(); + let private_bucket_client = server_state.private_bucket_client.clone(); + + + let number_of_samples = match request.maybe_number_of_samples { + Some(val) => if val > MAXIMUM_IMAGE_COUNT { + MAXIMUM_IMAGE_COUNT + } else if val < MINIMUM_IMAGE_COUNT { + MINIMUM_IMAGE_COUNT + } else { + val + }, + None => 1, + }; + + + let additional_images = request.maybe_additional_images.clone().unwrap_or_else(|| vec![]); + let prompt = request.prompt.clone(); + + + let sora_credentials = match SoraCredentials::from_env() { + Ok(creds) => creds, + Err(err) => { + error!("Failed to load Sora credentials from environment: {:?}", err); + return Err(EnqueueImageGenRequestError::ServerError); + } + }; + + let scene_media_token = request.scene_media_token.clone(); + let scene_media_name = scene_media_token.to_string(); + let scene_media_path = work_temp_dir.path().join(scene_media_name.clone()); + let scene_media_path = do_download_media_file( + &scene_media_name, + &scene_media_token, + &scene_media_path, + &public_bucket_client, + &server_state.mysql_pool + ).await.map_err(|err| { + error!("Failed to download scene media file: {:?}", err); + EnqueueImageGenRequestError::ServerError + })?; + + let scene_media_upload_request = SoraMediaUploadRequest { + file_path: scene_media_path.to_string_lossy().to_string(), + credentials: &sora_credentials, + }; + + let scene_media_upload_response = sora_media_upload(scene_media_upload_request).await.map_err(|err| { + error!("Failed to upload scene media to Sora: {:?}", err); + EnqueueImageGenRequestError::ServerError + })?; + + + let sora_scene_media_id = scene_media_upload_response.id; + log::info!("Uploaded scene media to Sora with ID: {}", sora_scene_media_id); + + let mut sora_additional_media_ids: Vec = vec![]; + + for media_file_token in &additional_images { + let media_file_name = media_file_token.to_string(); + let media_file_path = work_temp_dir.path().join(media_file_name.clone()); + let media_file_path = do_download_media_file( + &media_file_name, + &media_file_token, + &media_file_path, + &public_bucket_client, + &server_state.mysql_pool + ).await.map_err(|err| { + error!("Failed to download additional media file: {:?}", err); + EnqueueImageGenRequestError::ServerError + })?; + + let sora_media_upload_request = SoraMediaUploadRequest { + file_path: media_file_path.to_string_lossy().to_string(), + credentials: &sora_credentials, + }; + + let sora_media_upload_response = sora_media_upload(sora_media_upload_request).await.map_err(|err| { + error!("Failed to upload additional media to Sora: {:?}", err); + EnqueueImageGenRequestError::ServerError + })?; + + let sora_media_id = sora_media_upload_response.id; + log::info!("Uploaded additional media to Sora with ID: {}", sora_media_id); + sora_additional_media_ids.push(sora_media_id); + } + + // Store the actual inference args that will go to the database + let inference_args = SoraImageGenArgs { + prompt: Some(prompt), + scene_media_token: Some(request.scene_media_token.clone()), + maybe_additional_media_file_tokens: Some(additional_images), + maybe_number_of_samples: Some(number_of_samples), + maybe_sora_scene_media_token: Some(sora_scene_media_id.clone()), + maybe_sora_media_upload_tokens: Some(sora_additional_media_ids), + }; + + // The Sora API IDs will be used later by the worker from environment variables + log::info!("Sora scene media ID: {}", sora_scene_media_id); + + // create the inference args here + let maybe_avt_token = server_state.avt_cookie_manager.get_avt_token_from_request(&http_request); + + // create the job record here! + let query_result = insert_generic_inference_job(InsertGenericInferenceArgs { + uuid_idempotency_token: &request.uuid_idempotency_token, + job_type: InferenceJobType::StableDiffusion, + maybe_product_category: None, // This is not a product anymore + inference_category: InferenceCategory::ImageGeneration, + maybe_model_type: Some(InferenceModelType::StableDiffusion), // NB: Model is static during inference + maybe_model_token: None, // NB: Model is static during inference + maybe_input_source_token: None, + maybe_input_source_token_type: None, + maybe_download_url: None, + maybe_cover_image_media_file_token: None, + maybe_raw_inference_text: None, + maybe_max_duration_seconds: None, + maybe_inference_args: Some(GenericInferenceArgs { + inference_category: Some(InferenceCategoryAbbreviated::ImageGeneration), + args: Some(PolymorphicInferenceArgs::Sg(inference_args)), + }), + maybe_creator_user_token: maybe_user_token.as_ref(), + maybe_avt_token: maybe_avt_token.as_ref(), + creator_ip_address: &ip_address, + creator_set_visibility: visbility, + priority_level, + requires_keepalive: false, //reverse ... TODO fix this. we set it base on account is premium or not ... + is_debug_request, + maybe_routing_tag: maybe_routing_tag.as_deref(), + mysql_pool: &server_state.mysql_pool, + }).await; + + let job_token = match query_result { + Ok((job_token, _id)) => job_token, + Err(err) => { + warn!("New generic inference job creation DB error: {:?}", err); + if err.had_duplicate_idempotency_token() { + return Err(EnqueueImageGenRequestError::BadInput("Duplicate idempotency token".to_string())); + } + return Err(EnqueueImageGenRequestError::ServerError); + } + }; + + let response: EnqueueImageGenRequestSuccessResponse = EnqueueImageGenRequestSuccessResponse { + success: true, + inference_job_token: job_token, + }; + + let body = serde_json + ::to_string(&response) + .map_err(|_e| EnqueueImageGenRequestError::ServerError)?; + + // Error handling 101 rust result type returned like so. + Ok(HttpResponse::Ok().content_type("application/json").body(body)) +} + +fn validate_request( + request: &Json, +) -> Result<(), EnqueueImageGenRequestError> { + + + + Ok(()) +} + +#[derive(Debug)] +pub enum DownloadMediaFileError { + IoError(std::io::Error), + MediaFileNotFound, + Other(anyhow::Error), +} + +impl From for DownloadMediaFileError { + fn from(err: GetMediaFileError) -> Self { + match err { + GetMediaFileError::Other(e) => DownloadMediaFileError::Other(e), + GetMediaFileError::NotFound => DownloadMediaFileError::MediaFileNotFound, + } + } +} + +impl From for DownloadMediaFileError { + fn from(err: anyhow::Error) -> Self { + DownloadMediaFileError::Other(err) + } +} + +pub async fn do_download_media_file( + media_file_name: &str, + media_file_token: &MediaFileToken, + download_directory: &Path, + bucket_client: &BucketClient, + mysql_pool: &MySqlPool, +) -> Result<(PathBuf), DownloadMediaFileError> { + + let final_download_path = download_directory.join(format!("{}", media_file_name)); + + + if final_download_path.exists() { + log::debug!("Deleting existing file at {:?}", final_download_path.display().to_string()); + std::fs::remove_file(&final_download_path) + .map_err(|err| DownloadMediaFileError::IoError(err))?; + } + + let bucket_object_path = get_media_file_details(&media_file_token, mysql_pool).await?; + + log::info!("Downloading media from bucket path: {:?}", &bucket_object_path); + + bucket_client.download_file_to_disk( + &bucket_object_path, + &final_download_path, + ).await?; + + Ok(final_download_path) +} + + +#[derive(Debug)] +enum GetMediaFileError { + NotFound, + Other(anyhow::Error), +} + + +async fn get_media_file_details(media_file_token: &MediaFileToken, mysql_pool: &MySqlPool) -> Result { + let media_file_result = get_media_file( + &media_file_token, + false, + mysql_pool + ).await; + + let media_file_result = match media_file_result { + Ok(Some(result)) => result, + Ok(None) => { + warn!("could not find media file: {:?}", media_file_token); + return Err(GetMediaFileError::NotFound) + } + Err(e) => { + error!("could not query media file: {:?}", e); + return Err(GetMediaFileError::Other(e)) + } + }; + + let media_file_bucket_path = MediaFileBucketPath::from_object_hash( + &media_file_result.public_bucket_directory_hash, + media_file_result.maybe_public_bucket_prefix.as_deref(), + media_file_result.maybe_public_bucket_extension.as_deref()); + + Ok(media_file_bucket_path.to_full_object_pathbuf()) +} + + +#[cfg(test)] +mod tests { +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/image_studio/prompt/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/image_studio/prompt/mod.rs new file mode 100644 index 0000000000..d14a9c787a --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/image_studio/prompt/mod.rs @@ -0,0 +1 @@ +pub mod enqueue_studio_image_generation; \ No newline at end of file diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/image_studio/upload/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/image_studio/upload/mod.rs new file mode 100644 index 0000000000..e13808ac1d --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/image_studio/upload/mod.rs @@ -0,0 +1 @@ +pub mod upload_image_media_file_handler; \ No newline at end of file diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/image_studio/upload/upload_image_media_file_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/image_studio/upload/upload_image_media_file_handler.rs new file mode 100644 index 0000000000..927f30e3f0 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/image_studio/upload/upload_image_media_file_handler.rs @@ -0,0 +1,297 @@ +use std::collections::HashSet; +use std::io::{BufReader, Bytes, Cursor, Read}; +use std::path::PathBuf; +use std::sync::Arc; + +use actix_multipart::form::tempfile::TempFile; +use actix_multipart::form::text::Text; +use actix_multipart::form::MultipartForm; +use actix_multipart::Multipart; +use actix_web::web::Json; +use actix_web::{web, HttpRequest, HttpResponse}; +use log::{error, info, warn}; +use once_cell::sync::Lazy; +use stripe::CreatePaymentLinkShippingAddressCollectionAllowedCountries::Mf; +use utoipa::ToSchema; + +use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath; +use enums::by_table::media_files::media_file_class::MediaFileClass; +use enums::by_table::media_files::media_file_engine_category::MediaFileEngineCategory; +use enums::by_table::media_files::media_file_type::MediaFileType; +use enums::common::visibility::Visibility; +use enums::no_table::style_transfer::style_transfer_name::StyleTransferName; +use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes; +use http_server_common::request::get_request_ip::get_request_ip; +use media::decode_basic_audio_info::decode_basic_audio_bytes_info; +use mimetypes::mimetype_for_bytes::get_mimetype_for_bytes; +use mimetypes::mimetype_to_extension::mimetype_to_extension; +use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token; +use mysql_queries::queries::media_files::create::specialized_insert::insert_media_file_from_file_upload::{insert_media_file_from_file_upload, InsertMediaFileFromUploadArgs, UploadType}; +use mysql_queries::queries::media_files::get::get_media_file::get_media_file; +use tokens::tokens::media_files::MediaFileToken; + +use crate::http_server::endpoints::media_files::upload::upload_error::MediaFileUploadError; +use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format; +use crate::state::server_state::ServerState; +use crate::util::check_creator_tokens::{check_creator_tokens, CheckCreatorTokenArgs, CheckCreatorTokenResult}; + +/// Form-multipart request fields. +/// +/// IF VIEWING DOCS, PLEASE SEE BOTTOM OF PAGE `UploadImageMediaFileForm` (Under "Schema") FOR DETAILS ON FIELDS AND NULLABILITY. +#[derive(MultipartForm, ToSchema)] +#[multipart(duplicate_field = "deny")] +pub struct UploadImageMediaFileForm { + /// UUID for request idempotency + #[multipart(limit = "2 KiB")] + #[schema(value_type = String, format = Binary)] + uuid_idempotency_token: Text, + + /// The uploaded screenshot + #[multipart(limit = "512 MiB")] + #[schema(value_type = Vec, format = Binary)] + screenshot: TempFile, + + /// Optional: Title (name) of the scene + #[multipart(limit = "2 KiB")] + #[schema(value_type = Option, format = Binary)] + maybe_title: Option>, + + /// Optional: Visibility of the scene + #[multipart(limit = "2 KiB")] + #[schema(value_type = Option, format = Binary)] + maybe_visibility: Option>, + + /// Optional: If an engine scene was used to create this snapshot, provide it here to create a link + #[multipart(limit = "2 KiB")] + #[schema(value_type = Option, format = Binary)] + maybe_scene_source_media_file_token: Option>, +} + +// Unlike the "upload" endpoints, which are pure inserts, these endpoints are *upserts*. +#[derive(Serialize, ToSchema)] +pub struct UploadImageMediaFileSuccessResponse { + pub success: bool, + pub media_file_token: MediaFileToken, +} + +static ALLOWED_MIME_TYPES : Lazy> = Lazy::new(|| { + HashSet::from([ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + ]) +}); + +/// This endpoint is for uploading image files. +#[utoipa::path( + post, + tag = "Media Files (Upload)", + path = "/v1/image_studio/scene_snapshot", + responses( + (status = 200, description = "Success Update", body = UploadImageMediaFileSuccessResponse), + (status = 400, description = "Bad input", body = MediaFileUploadError), + (status = 401, description = "Not authorized", body = MediaFileUploadError), + (status = 429, description = "Too many requests", body = MediaFileUploadError), + (status = 500, description = "Server error", body = MediaFileUploadError), + ), + params( + ( + "request" = UploadImageMediaFileForm, + description = "IF VIEWING DOCS, PLEASE SEE BOTTOM OF PAGE `UploadImageMediaFileForm` (Under 'Schema') FOR DETAILS ON FIELDS AND NULLABILITY." + ), + ) +)] +pub async fn upload_studio_scene_snapshot_handler( + http_request: HttpRequest, + server_state: web::Data>, + MultipartForm(mut form): MultipartForm, +) -> Result, MediaFileUploadError> { + + let mut mysql_connection = server_state.mysql_pool + .acquire() + .await + .map_err(|err| { + error!("MySql pool error: {:?}", err); + MediaFileUploadError::ServerError + })?; + + // ==================== READ SESSION ==================== // + + let maybe_user_session = server_state + .session_checker + .maybe_get_user_session_from_connection(&http_request, &mut mysql_connection) + .await + .map_err(|e| { + error!("Session checker error: {:?}", e); + MediaFileUploadError::ServerError + })?; + + let maybe_user_token = maybe_user_session + .as_ref() + .map(|session| session.get_strongly_typed_user_token()); + + let maybe_avt_token = server_state + .avt_cookie_manager + .get_avt_token_from_request(&http_request); + + // ==================== BANNED USERS ==================== // + + if let Some(ref user) = maybe_user_session { + if user.is_banned { + return Err(MediaFileUploadError::NotAuthorizedVerbose("user is banned".to_string())); + } + } + + // ==================== RATE LIMIT ==================== // + + let rate_limiter = match maybe_user_session { + None => &server_state.redis_rate_limiters.file_upload_logged_out, + Some(ref _session) => &server_state.redis_rate_limiters.file_upload_logged_in, + }; + + if let Err(_err) = rate_limiter.rate_limit_request(&http_request) { + return Err(MediaFileUploadError::RateLimited); + } + + // ==================== HANDLE IDEMPOTENCY ==================== // + + // TODO(bt, 2024-02-26): This should be a transaction. + let uuid_idempotency_token = form.uuid_idempotency_token.as_ref(); + + if let Err(reason) = validate_idempotency_token_format(uuid_idempotency_token) { + return Err(MediaFileUploadError::BadInput(reason)); + } + + insert_idempotency_token(uuid_idempotency_token, &mut *mysql_connection) + .await + .map_err(|err| { + error!("Error inserting idempotency token: {:?}", err); + MediaFileUploadError::BadInput("invalid idempotency token".to_string()) + })?; + + // ==================== UPLOAD METADATA ==================== // + + let maybe_title = form.maybe_title.map(|title| title.to_string()); + + let creator_set_visibility = form.maybe_visibility + .map(|visibility| visibility.0) + .or_else(|| { + maybe_user_session + .as_ref() + .map(|user_session| user_session.preferred_tts_result_visibility) + }) + .unwrap_or(Visibility::default()); + + // ==================== USER DATA ==================== // + + let ip_address = get_request_ip(&http_request); + + // ==================== FILE VALIDATION ==================== // + + let mut file_bytes = Vec::new(); + form.screenshot.file.read_to_end(&mut file_bytes) + .map_err(|e| { + error!("Problem reading file: {:?}", e); + MediaFileUploadError::ServerError + })?; + + let mimetype = get_mimetype_for_bytes(file_bytes.as_ref()) + .map(|mimetype| mimetype.to_string()) + .ok_or_else(|| { + warn!("Could not determine mimetype for file"); + MediaFileUploadError::BadInput("Could not determine mimetype for file".to_string()) + })?; + + if !ALLOWED_MIME_TYPES.contains(mimetype.as_str()) { + // NB: Don't let our error message inject malicious strings + let filtered_mimetype = mimetype + .chars() + .filter(|c| c.is_ascii()) + .filter(|c| c.is_alphanumeric() || *c == '/') + .collect::(); + return Err(MediaFileUploadError::BadInput(format!("unpermitted mime type: {}", &filtered_mimetype))); + } + + // ==================== OTHER FILE METADATA ==================== // + + let maybe_filename = form.screenshot.file_name.as_deref() + .as_deref() + .map(|filename| PathBuf::from(filename)); + + let extension = mimetype_to_extension(&mimetype) + .or_else(|| { + maybe_filename + .as_ref() + .and_then(|filename| filename.extension()) // TODO needs dot prefix + .and_then(|ext| ext.to_str()) + }) + .ok_or_else(|| { + warn!("Could not determine file extension for mimetype: {}", &mimetype); + MediaFileUploadError::ServerError + })?; + + let extension = format!(".{extension}"); // NB: needs dot prefix + + let file_size_bytes = file_bytes.len(); + + let hash = sha256_hash_bytes(&file_bytes) + .map_err(|io_error| { + error!("Problem hashing bytes: {:?}", io_error); + MediaFileUploadError::ServerError + })?; + + // ==================== UPLOAD AND SAVE ==================== // + + const PREFIX : Option<&str> = Some("image_"); + + let public_upload_path = MediaFileBucketPath::generate_new(PREFIX, Some(&extension)); + + info!("Uploading media to bucket path: {}", public_upload_path.get_full_object_path_str()); + + server_state.public_bucket_client.upload_file_with_content_type( + public_upload_path.get_full_object_path_str(), + file_bytes.as_ref(), + &mimetype) + .await + .map_err(|e| { + warn!("Upload media bytes to bucket error: {:?}", e); + MediaFileUploadError::ServerError + })?; + + + let (token, record_id) = insert_media_file_from_file_upload(InsertMediaFileFromUploadArgs { + maybe_media_class: Some(MediaFileClass::Image), + media_file_type: MediaFileType::Image, + maybe_creator_user_token: maybe_user_token.as_ref(), + maybe_creator_anonymous_visitor_token: maybe_avt_token.as_ref(), + creator_ip_address: &ip_address, + creator_set_visibility, + 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_mime_type: Some(&mimetype), + file_size_bytes: file_size_bytes as u64, + maybe_duration_millis: None, + sha256_checksum: &hash, + maybe_title: maybe_title.as_deref(), + maybe_scene_source_media_file_token: None, + is_intermediate_system_file: true, + public_bucket_directory_hash: public_upload_path.get_object_hash(), + maybe_public_bucket_prefix: PREFIX, + maybe_public_bucket_extension: Some(&extension), + pool: &server_state.mysql_pool, + }) + .await + .map_err(|err| { + warn!("New file creation DB error: {:?}", err); + MediaFileUploadError::ServerError + })?; + + info!("new media file id: {} token: {:?}", record_id, &token); + + Ok(Json(UploadImageMediaFileSuccessResponse { + success: true, + media_file_token: token, + })) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/mod.rs index 9300769c41..3107462690 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/mod.rs @@ -40,3 +40,4 @@ pub mod voice_designer; pub mod w2l; pub mod weights; pub mod workflows; +pub mod image_studio; diff --git a/crates/service/web/storyteller_web/src/http_server/routes/add_image_studio_routes.rs b/crates/service/web/storyteller_web/src/http_server/routes/add_image_studio_routes.rs new file mode 100644 index 0000000000..32ade57cdc --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/routes/add_image_studio_routes.rs @@ -0,0 +1,29 @@ +use crate::http_server::endpoints::image_studio::prompt::enqueue_studio_image_generation::enqueue_studio_image_generation_request; +use crate::http_server::endpoints::image_studio::upload::upload_image_media_file_handler::upload_studio_scene_snapshot_handler; +use actix_http::body::MessageBody; +use actix_service::ServiceFactory; +use actix_web::dev::{ServiceRequest, ServiceResponse}; +use actix_web::{web, App, Error, HttpResponse}; + +pub fn add_image_studio_routes (app: App) -> App +where + B: MessageBody, + T: ServiceFactory< + ServiceRequest, + Config = (), + Response = ServiceResponse, + Error = Error, + InitError = (), + >, +{ + app.service(web::scope("/v1/image_studio") + .service(web::resource("/scene_snapshot") + .route(web::post().to(upload_studio_scene_snapshot_handler)) + .route(web::head().to(|| HttpResponse::Ok())) + ) + .service(web::resource("/prompt") + .route(web::post().to(enqueue_studio_image_generation_request)) + .route(web::head().to(|| HttpResponse::Ok())) + ) + ) +} diff --git a/crates/service/web/storyteller_web/src/http_server/routes/add_routes.rs b/crates/service/web/storyteller_web/src/http_server/routes/add_routes.rs index 9242c46e17..773cde26e3 100644 --- a/crates/service/web/storyteller_web/src/http_server/routes/add_routes.rs +++ b/crates/service/web/storyteller_web/src/http_server/routes/add_routes.rs @@ -123,6 +123,7 @@ use crate::http_server::endpoints::w2l::get_w2l_template_use_count::get_w2l_temp use crate::http_server::endpoints::w2l::get_w2l_upload_template_job_status::get_w2l_upload_template_job_status_handler; use crate::http_server::endpoints::w2l::list_w2l_templates::list_w2l_templates_handler; use crate::http_server::endpoints::w2l::set_w2l_template_mod_approval::set_w2l_template_mod_approval_handler; +use crate::http_server::routes::add_image_studio_routes::add_image_studio_routes; use crate::http_server::routes::add_studio_gen2_routes::add_studio_gen2_routes; use crate::http_server::routes::beta_key_routes::add_beta_key_routes; use crate::http_server::routes::job_routes::add_job_routes; @@ -171,6 +172,7 @@ pub fn add_routes (app: App, server_environment: ServerEnvironment) -> app = add_model_download_routes(app); app = add_workflow_routes(app); app = add_studio_gen2_routes(app); + app = add_image_studio_routes(app); app = add_job_routes(app); app = add_engine_routes(app); /* /v1/engine/... */ diff --git a/crates/service/web/storyteller_web/src/http_server/routes/mod.rs b/crates/service/web/storyteller_web/src/http_server/routes/mod.rs index ffeeaab40a..f14031176c 100644 --- a/crates/service/web/storyteller_web/src/http_server/routes/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/routes/mod.rs @@ -8,4 +8,5 @@ pub mod moderation_routes; pub mod user_routes; pub mod weights_routes; pub mod workflow_routes; -mod tag_routes; \ No newline at end of file +mod tag_routes; +mod add_image_studio_routes; \ No newline at end of file