diff --git a/Cargo.lock b/Cargo.lock index d414b354f2..0e9594bcd6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -662,6 +662,7 @@ dependencies = [ "errors", "filesys", "futures 0.3.31", + "grok_client", "idempotency", "image", "images", @@ -1693,6 +1694,17 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "bucket_paths" version = "0.0.1" @@ -4715,6 +4727,7 @@ version = "0.0.1" dependencies = [ "anyhow", "base64 0.22.1", + "bstr", "byteorder", "chrono", "cloudflare_errors", @@ -12157,9 +12170,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.12" +version = "0.7.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61e7c3654c13bcd040d4a03abee2c75b1d14a37b423cf5a813ceae1cc903ec6a" +checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" dependencies = [ "bytes 1.10.1", "futures-core", @@ -14179,6 +14192,7 @@ dependencies = [ "tokio", "tokio-boring2", "tokio-tungstenite", + "tokio-util", "tower 0.5.2", "tower-http", "want", @@ -14188,9 +14202,9 @@ dependencies = [ [[package]] name = "wreq-util" -version = "3.0.0-rc.5" +version = "3.0.0-rc.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a38c2ade0a913ec9e1f7f394de8ebdb78d7123e45f015f9d02fed5bdc9db67b" +checksum = "05af16fb18a8b955ce166747c588ff64a39d49266e486211116dc5c38a73c13d" dependencies = [ "typed-builder", "wreq", diff --git a/Cargo.toml b/Cargo.toml index f0f1e68499..4922216a02 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -198,6 +198,7 @@ errors = { path = "crates/lib/errors" } fal_client = { path = "crates/api_clients/fal_client" } file_to_base64_url = { path = "crates/lib/files/file_to_base64_url" } filesys = { path = "crates/lib/files/filesys" } +grok_client = { path = "crates/api_clients/grok_client" } idempotency = { path = "crates/lib/idempotency" } images = { path = "crates/lib/files/images" } jwt_light = { path = "crates/lib/jwt_light" } @@ -232,6 +233,7 @@ actix-web-actors = "4.3.1+deprecated" actix-web-lab = "0.20.2" anyhow = "1.0.98" base64 = "0.22.1" +bstr = "1.12.1" bytes = "1.10.1" chrono = { version = "0.4.41", features = ["serde"] } cookie = "0.18.1" @@ -275,5 +277,5 @@ uuid = "1.18.1" # choco install pkgconfiglite -y # choco install llvm -y # choco install nasm -y -wreq = { version = "6.0.0-rc.20", features = ["json", "multipart", "cookies", "ws"] } -wreq-util = "3.0.0-rc.5" +wreq = { version = "6.0.0-rc.20", features = ["json", "multipart", "cookies", "ws", "stream"] } +wreq-util = "3.0.0-rc.6" diff --git a/crates/api_clients/grok_client/Cargo.toml b/crates/api_clients/grok_client/Cargo.toml index 38fb3aa4b9..a567af0527 100644 --- a/crates/api_clients/grok_client/Cargo.toml +++ b/crates/api_clients/grok_client/Cargo.toml @@ -24,6 +24,7 @@ errors.workspace = true # External anyhow.workspace = true base64.workspace = true +bstr.workspace = true byteorder = "1.5.0" chrono.workspace = true futures.workspace = true diff --git a/crates/api_clients/grok_client/src/credentials/grok_full_credentials.rs b/crates/api_clients/grok_client/src/credentials/grok_full_credentials.rs index 6afdfa676e..0b37a18a2d 100644 --- a/crates/api_clients/grok_client/src/credentials/grok_full_credentials.rs +++ b/crates/api_clients/grok_client/src/credentials/grok_full_credentials.rs @@ -1,5 +1,6 @@ use crate::credentials::grok_client_secrets::GrokClientSecrets; use crate::credentials::grok_cookies::GrokCookies; +use crate::datatypes::api::user_id::UserId; /// Includes the cookies and all the magic other bits needed to call the API. #[derive(Clone)] @@ -22,4 +23,8 @@ impl GrokFullCredentials { client_secrets, } } + + pub fn get_user_id_ref(&self) -> &UserId { + &self.client_secrets.user_id + } } diff --git a/crates/api_clients/grok_client/src/credentials/grok_user_data.rs b/crates/api_clients/grok_client/src/credentials/grok_user_data.rs new file mode 100644 index 0000000000..8b9f28b563 --- /dev/null +++ b/crates/api_clients/grok_client/src/credentials/grok_user_data.rs @@ -0,0 +1,22 @@ +use crate::datatypes::api::user_email::UserEmail; +use crate::datatypes::api::user_id::UserId; + +#[derive(Clone)] +pub struct GrokUserData { + /// From index.html + /// Not strictly needed to sign requests, but typically needed to generate URLs. + pub user_id: UserId, + + /// From index.html + /// Not needed, but returned alongside other details. + pub user_email: Option, +} + +impl GrokUserData { + pub fn from_id_and_email(id: String, email: Option) -> Self { + Self { + user_id: UserId(id), + user_email: email.map(|email| UserEmail(email)), + } + } +} diff --git a/crates/api_clients/grok_client/src/credentials/mod.rs b/crates/api_clients/grok_client/src/credentials/mod.rs index a7eb1c1fa3..4d7ea21059 100644 --- a/crates/api_clients/grok_client/src/credentials/mod.rs +++ b/crates/api_clients/grok_client/src/credentials/mod.rs @@ -1,3 +1,4 @@ +pub mod grok_client_secrets; pub mod grok_cookies; pub mod grok_full_credentials; -pub mod grok_client_secrets; +pub mod grok_user_data; diff --git a/crates/api_clients/grok_client/src/datatypes/api/mod.rs b/crates/api_clients/grok_client/src/datatypes/api/mod.rs index f330c012f5..77d75655b2 100644 --- a/crates/api_clients/grok_client/src/datatypes/api/mod.rs +++ b/crates/api_clients/grok_client/src/datatypes/api/mod.rs @@ -1,5 +1,6 @@ pub mod baggage; pub mod file_id; +pub mod post_id; pub mod request_id; pub mod sentry_trace; pub mod svg_path_data; diff --git a/crates/api_clients/grok_client/src/datatypes/api/post_id.rs b/crates/api_clients/grok_client/src/datatypes/api/post_id.rs new file mode 100644 index 0000000000..3315dc9bb3 --- /dev/null +++ b/crates/api_clients/grok_client/src/datatypes/api/post_id.rs @@ -0,0 +1,12 @@ + +/// Type for Post IDs. +/// Post IDs are UUIds. +/// These are often shared with media/image uploads' `FileId`s, but seldom video generation `FileId`s +#[derive(Clone, Debug)] +pub struct PostId(pub String); + +impl PostId { + pub fn to_string(&self) -> String { + self.0.clone() + } +} diff --git a/crates/api_clients/grok_client/src/datatypes/api/user_email.rs b/crates/api_clients/grok_client/src/datatypes/api/user_email.rs index 8524151d88..a6ba535a04 100644 --- a/crates/api_clients/grok_client/src/datatypes/api/user_email.rs +++ b/crates/api_clients/grok_client/src/datatypes/api/user_email.rs @@ -1,3 +1,9 @@ /// Type for User Emails #[derive(Clone, Debug)] pub struct UserEmail(pub String); + +impl UserEmail { + pub fn to_string(&self) -> String { + self.0.clone() + } +} diff --git a/crates/api_clients/grok_client/src/datatypes/api/user_id.rs b/crates/api_clients/grok_client/src/datatypes/api/user_id.rs index 61581f17bb..c7e522fddc 100644 --- a/crates/api_clients/grok_client/src/datatypes/api/user_id.rs +++ b/crates/api_clients/grok_client/src/datatypes/api/user_id.rs @@ -3,3 +3,10 @@ /// User IDs are UUIDs. #[derive(Clone, Debug)] pub struct UserId(pub String); + +impl UserId { + pub fn to_string(&self) -> String { + self.0.clone() + } +} + diff --git a/crates/api_clients/grok_client/src/error/grok_client_error.rs b/crates/api_clients/grok_client/src/error/grok_client_error.rs index 2efa87fdee..52f419f1ef 100644 --- a/crates/api_clients/grok_client/src/error/grok_client_error.rs +++ b/crates/api_clients/grok_client/src/error/grok_client_error.rs @@ -26,6 +26,12 @@ pub enum GrokClientError { /// Can't read a local file for uploading. CannotReadLocalFileForUpload(std::io::Error), + /// Couldn't write to the file + CannotOpenLocalFileForWriting(std::io::Error), + + /// Couldn't write to the file + CannotWriteLocalFile(std::io::Error), + /// File for upload has an invalid path. FileForUploadHasInvalidPath, @@ -47,6 +53,9 @@ pub enum GrokClientError { /// Something is broken with timeout math TimeoutMathBroken, + /// Can't make request because cookies aren't present + NoCookiesPresent, + // /// An error reading the file for upload. // FileForUploadReadError(std::io::Error), // @@ -96,6 +105,8 @@ impl Display for GrokClientError { Self::WebsocketSendError(err) => write!(f, "Websocket send error: {}", err), Self::CannotOpenLocalFileForUpload(err) => write!(f, "Cannot open local file for upload: {}", err), Self::CannotReadLocalFileForUpload(err) => write!(f, "Cannot read local file for upload: {}", err), + Self::CannotOpenLocalFileForWriting(err) => write!(f, "Cannot open local file for writing: {}", err), + Self::CannotWriteLocalFile(err) => write!(f, "Cannot write local file: {}", err), Self::FileForUploadHasInvalidPath => write!(f, "File for upload has invalid path"), Self::HtmlParsingError => write!(f, "Html parsing error"), Self::FailedToDecodeVerificationToken(err) => write!(f, "Failed to decode verification token: {}", err), @@ -103,6 +114,7 @@ impl Display for GrokClientError { Self::ScriptLogicOutOfDate => write!(f, "Script logic out of date"), Self::BadSignatureInputs => write!(f, "Bad signature inputs"), Self::TimeoutMathBroken => write!(f, "Timeout math is broken"), + Self::NoCookiesPresent => write!(f, "No cookies present"), //Self::FileForUploadReadError(err) => write!(f, "Error reading file for upload: {}", err), //Self::FileForUploadHasInvalidPath => write!(f, "The file path provided for upload is invalid."), diff --git a/crates/api_clients/grok_client/src/recipes/mod.rs b/crates/api_clients/grok_client/src/recipes/mod.rs index 1d09adac78..e774ecd05b 100644 --- a/crates/api_clients/grok_client/src/recipes/mod.rs +++ b/crates/api_clients/grok_client/src/recipes/mod.rs @@ -1,2 +1,3 @@ pub mod request_client_secrets; -mod upload_image_and_generate_video; \ No newline at end of file +pub mod upload_image_and_generate_video; +pub mod upload_image_and_generate_video_with_retry; \ No newline at end of file diff --git a/crates/api_clients/grok_client/src/recipes/upload_image_and_generate_video.rs b/crates/api_clients/grok_client/src/recipes/upload_image_and_generate_video.rs index ea406c87d6..5da136d57c 100644 --- a/crates/api_clients/grok_client/src/recipes/upload_image_and_generate_video.rs +++ b/crates/api_clients/grok_client/src/recipes/upload_image_and_generate_video.rs @@ -1,10 +1,11 @@ use crate::credentials::grok_full_credentials::GrokFullCredentials; use crate::datatypes::api::file_id::FileId; +use crate::datatypes::api::post_id::PostId; use crate::datatypes::file_upload_spec::FileUploadSpec; use crate::error::grok_error::GrokError; use crate::error::grok_generic_api_error::GrokGenericApiError; -use crate::requests::create_media_post::grok_create_media_post::{GrokCreateMediaPost, MediaPostType}; -use crate::requests::like_media::grok_like_media::GrokLikeMediaPost; +use crate::requests::media_posts::create_media_post::grok_create_media_post::{GrokCreateMediaPost, MediaPostType}; +use crate::requests::media_posts::like_media_post::grok_like_media::GrokLikeMediaPost; use crate::requests::upload_file::grok_upload_file::GrokUploadFile; use crate::requests::video_chat::grok_video_gen_chat_conversation::{GrokVideoGenChatConversationBuilder, VideoMediaPostType}; use crate::utils::user_and_file_id_to_image_url::user_and_file_id_to_image_url; @@ -19,17 +20,44 @@ pub struct UploadImageAndGenerateVideo<'a, P: AsRef> { // NB: Must be owned. pub file: FileUploadSpec

, + /// Video generation prompt pub prompt: Option<&'a str>, + /// Wait for the full video to be generated before returning + /// By default, the endpoint stays open for 30-ish seconds while + /// the video generates and streams back JSON progress updates. + /// If we set this to false, we'll wait for the video ID and exit + /// the request early, before the video is finished asynchronously + /// generating. + pub wait_for_generation: bool, + pub individual_request_timeout: Option, } -pub struct ImageUploadResult { +#[derive(Debug, Clone)] +pub struct ImageUploadAndGenerateVideoResult { + /// The ID of the "post" + /// This often is equivalent to the upload id / `image_file_id`, + /// and this is in the user-facing URL for website navigation to the video. + pub post_id: PostId, + + /// The ID of the file we uploaded + pub image_file_id: FileId, + + /// The ID of the video we're generating, which may or may not exist yet. pub video_file_id: Option, + + /// The URL for the video, which may or may not exist yet. pub video_url: Option, + + /// Whether the video generation has already completed. + /// Based on request `wait_for_generation`. + pub generation_is_complete: bool, } -pub async fn upload_image_and_generate_video>(args: UploadImageAndGenerateVideo<'_, P>) -> Result { +pub async fn upload_image_and_generate_video>(args: UploadImageAndGenerateVideo<'_, P>) + -> Result +{ info!("Uploading file to Grok..."); @@ -44,7 +72,7 @@ pub async fn upload_image_and_generate_video>(args: UploadImageAn info!("File URI: {:?}", upload_result.file_uri); info!("File Metadata ID: {:?}", upload_result.file_id); - let upload_file_id = match &upload_result.file_id { + let upload_file_id = match upload_result.file_id { Some(id) => id, None => { error!("Previous file upload failed. Cannot continue."); @@ -63,13 +91,13 @@ pub async fn upload_image_and_generate_video>(args: UploadImageAn let request = GrokCreateMediaPost { user_id: &args.full_credentials.client_secrets.user_id, - file_id: upload_file_id, + file_id: &upload_file_id, media_type: MediaPostType::UserUploadedImage, cookie: args.full_credentials.cookies.as_str(), request_timeout: args.individual_request_timeout, }; - let _post_result = request.send().await?; + let post_result = request.send().await?; info!("Posted Image."); @@ -89,10 +117,20 @@ pub async fn upload_image_and_generate_video>(args: UploadImageAn numbers: &args.full_credentials.client_secrets.numbers, }; - // TODO: Get URL - let video_gen_result = request.send().await?; + let maybe_video_file_id; + let generation_is_complete; - let maybe_video_file_id = video_gen_result.video_file_id; + if args.wait_for_generation { + info!("Waiting for video generation..."); + let video_gen_result = request.send().await?; + maybe_video_file_id = video_gen_result.video_file_id; + generation_is_complete = true; + } else { + info!("Streaming response partially..."); + let video_gen_result = request.stream_only_video_id().await?; + maybe_video_file_id = video_gen_result.video_file_id; + generation_is_complete = false; + } let maybe_video_url = maybe_video_file_id .as_ref() @@ -119,9 +157,12 @@ pub async fn upload_image_and_generate_video>(args: UploadImageAn info!("Media Liked"); - Ok(ImageUploadResult { + Ok(ImageUploadAndGenerateVideoResult { + post_id: post_result.post_id, + image_file_id: upload_file_id, video_file_id: maybe_video_file_id, video_url: maybe_video_url, + generation_is_complete, }) } @@ -146,32 +187,38 @@ mod tests { async fn test_upload_image_and_generate_video() -> AnyhowResult<()> { setup_test_logging(LevelFilter::Info); + //let image_path = "/Users/bt/Pictures/Zelda 64 Art/FCgYX6tWEAEhpsy.jpg"; + //let prompt = "our hero link plunges the sword into the pedestal, the temple is glowing with a blue aura"; + + let image_path = "/Users/bt/Pictures/Midjourney/hipster_girl.png"; + let maybe_prompt = Some("an anime girl runs away from a giant t-rex"); + let cookies = get_typed_test_cookies()?; let secrets = request_client_secrets(RequestClientSecretsArgs { cookies: &cookies, }).await?; - let image_path = "/Users/bt/Pictures/Zelda 64 Art/FCgYX6tWEAEhpsy.jpg"; - let prompt = "our hero link plunges the sword into the pedestal, the temple is glowing with a blue aura"; - - println!("Verification Token: {:?}", secrets.verification_token); - println!("Sentry Trace: {:?}", secrets.sentry_trace); - println!("Numbers: {:?}", secrets.numbers); - println!("Svg Path: {:?}", secrets.svg_path_data); - println!("Baggage: {:?}", secrets.baggage); + println!("[test] Verification Token: {:?}", secrets.verification_token); + println!("[test] Sentry Trace: {:?}", secrets.sentry_trace); + println!("[test] Numbers: {:?}", secrets.numbers); + println!("[test] Svg Path: {:?}", secrets.svg_path_data); + println!("[test] Baggage: {:?}", secrets.baggage); let credentials = GrokFullCredentials::from_cookies_and_client_secrets(cookies, secrets); let video_result = upload_image_and_generate_video(UploadImageAndGenerateVideo { full_credentials: &credentials, file: FileUploadSpec::Path(image_path), - prompt: Some(prompt), + prompt: maybe_prompt, individual_request_timeout: None, + wait_for_generation: false, }).await?; - println!("Video File ID: {:?}", video_result.video_file_id); - println!("Video URL: {:?}", video_result.video_url); + println!("[test] Post ID: {:?}", video_result.post_id); + println!("[test] Image File ID: {:?}", video_result.image_file_id); + println!("[test] Video File ID: {:?}", video_result.video_file_id); + println!("[test] Video URL: {:?}", video_result.video_url); assert_eq!(1, 2); diff --git a/crates/api_clients/grok_client/src/recipes/upload_image_and_generate_video_with_retry.rs b/crates/api_clients/grok_client/src/recipes/upload_image_and_generate_video_with_retry.rs new file mode 100644 index 0000000000..b7f336515b --- /dev/null +++ b/crates/api_clients/grok_client/src/recipes/upload_image_and_generate_video_with_retry.rs @@ -0,0 +1,24 @@ +use std::path::Path; +use std::time::Duration; +use crate::credentials::grok_full_credentials::GrokFullCredentials; +use crate::datatypes::file_upload_spec::FileUploadSpec; + +pub struct UploadImageAndGenerateVideoWithRetry<'a, P: AsRef> { + pub credentials: &'a GrokFullCredentials, + + // NB: Must be owned. + pub file: FileUploadSpec

, + + /// Video generation prompt + pub prompt: Option<&'a str>, + + /// Wait for the full video to be generated before returning + /// By default, the endpoint stays open for 30-ish seconds while + /// the video generates and streams back JSON progress updates. + /// If we set this to false, we'll wait for the video ID and exit + /// the request early, before the video is finished asynchronously + /// generating. + pub wait_for_generation: bool, + + pub individual_request_timeout: Option, +} diff --git a/crates/api_clients/grok_client/src/requests/create_media_post/mod.rs b/crates/api_clients/grok_client/src/requests/create_media_post/mod.rs deleted file mode 100644 index a8e788f5b4..0000000000 --- a/crates/api_clients/grok_client/src/requests/create_media_post/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod grok_create_media_post; -pub (super) mod request; \ No newline at end of file diff --git a/crates/api_clients/grok_client/src/requests/download_file/mod.rs b/crates/api_clients/grok_client/src/requests/download_file/mod.rs deleted file mode 100644 index a078360084..0000000000 --- a/crates/api_clients/grok_client/src/requests/download_file/mod.rs +++ /dev/null @@ -1 +0,0 @@ -mod download_video_file; \ No newline at end of file diff --git a/crates/api_clients/grok_client/src/requests/download_file/_metadata/firefox_download_video_curl_request.txt b/crates/api_clients/grok_client/src/requests/download_video_file/_metadata/firefox_download_video_curl_request.txt similarity index 100% rename from crates/api_clients/grok_client/src/requests/download_file/_metadata/firefox_download_video_curl_request.txt rename to crates/api_clients/grok_client/src/requests/download_video_file/_metadata/firefox_download_video_curl_request.txt diff --git a/crates/api_clients/grok_client/src/requests/download_file/download_video_file.rs b/crates/api_clients/grok_client/src/requests/download_video_file/download_video_file.rs similarity index 97% rename from crates/api_clients/grok_client/src/requests/download_file/download_video_file.rs rename to crates/api_clients/grok_client/src/requests/download_video_file/download_video_file.rs index ba34864e02..c27f19d141 100644 --- a/crates/api_clients/grok_client/src/requests/download_file/download_video_file.rs +++ b/crates/api_clients/grok_client/src/requests/download_video_file/download_video_file.rs @@ -85,7 +85,7 @@ pub async fn download_video_file(args: DownloadVideoFileArgs<'_>) -> Result { + pub cookies: &'a str, + pub user_id: &'a UserId, + pub file_id: &'a FileId, + pub request_timeout: Option, +} + +pub struct DownloadedVideoBytes { + pub bytes: Vec, +} + +impl <'a> GrokDownloadVideo<'a> { + + pub async fn download_to_path>(&self, path: P) -> Result<(), GrokError> { + let response = self.send_request().await?; + + let mut stream = response.bytes_stream(); + + let mut file = File::create(&path) + .await + .map_err(|err| GrokClientError::CannotOpenLocalFileForWriting(err))?; + + //let _r = stream.for_each_concurrent(None, |chunk_result| async { + // match chunk_result { + // Ok(chunk) => { + // file; + // //if let Err(e) = file.write_all(&chunk).await { + // // //error!("Error writing to file: {}", e); + // //} + // } + // Err(e) => { + // //error!("Error in stream: {}", e); + // } + // } + //}).await; + + while let Some(chunk) = stream.try_next() + .await + .map_err(|err| GrokGenericApiError::WreqError(err))? + { + file.write_all(&chunk) + .await + .map_err(|err| GrokClientError::CannotOpenLocalFileForWriting(err))? + } + + file.flush().await.unwrap(); + + Ok(()) + } + + pub async fn download_bytes(&self) -> Result { + let response = self.send_request().await?; + + let download_bytes = response.bytes() + .await + .map_err(|err| { + error!("Error reading Grok create media response body: {:?}", err); + GrokGenericApiError::WreqError(err) + })?; + + Ok(DownloadedVideoBytes { + bytes: download_bytes.to_vec(), + }) + } + + async fn send_request(&self) -> Result { + let client = Client::builder() + .emulation(Emulation::Firefox143) + .build() + .map_err(|err| GrokClientError::WreqClientError(err))?; + + let video_url = user_and_file_id_to_video_url(&self.user_id, &self.file_id, false); + + info!("Video file URL: {}", video_url); + + let mut request_builder = client.get(&video_url) + .header(USER_AGENT, FIREFOX_143_MAC_USER_AGENT) + .header(ACCEPT, "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + .header(ACCEPT_LANGUAGE, "en-US,en;q=0.5") + .header(ACCEPT_ENCODING, "gzip, deflate, br, zstd") + .header(REFERER, "https://grok.com/") + .header("Sec-GPC", "1") + .header(CONNECTION, "keep-alive") + .header(COOKIE, self.cookies.to_string()) + .header(UPGRADE_INSECURE_REQUESTS, "1") + .header("sec-fetch-dest", "document") + .header("sec-fetch-mode", "navigate") + .header("sec-fetch-site", "same-site") + .header("sec-fetch-user", "?1") + .header("priority", "u=0, i"); + + if let Some(timeout) = self.request_timeout { + request_builder = request_builder.timeout(timeout); + } + + let response = request_builder.send() + .await + .map_err(|err| GrokGenericApiError::WreqError(err))?; + + let status = response.status(); + + // TODO: Classify errors + if !status.is_success() { + warn!("Not successful liking media (status: {:?})", status); + // error!("Upload file request returned an error (code {}) : {:?}", status.as_u16(), response_body); + // return Err(classify_general_http_status_code_and_body(status, response_body)); + } + + Ok(response) + } +} + +#[cfg(test)] +mod tests { + use crate::datatypes::api::file_id::FileId; + use crate::recipes::request_client_secrets::{request_client_secrets, RequestClientSecretsArgs}; + use crate::requests::download_video_file::download_video_file::{download_video_file, DownloadVideoFileArgs}; + use crate::test_utils::get_test_cookies::get_typed_test_cookies; + use errors::AnyhowResult; + + #[tokio::test] + #[ignore] // Client side tests only + async fn test_download() -> AnyhowResult<()> { + //setup_test_logging(LevelFilter::Trace); + + let cookies = get_typed_test_cookies()?; + + let file_id = FileId("d9b300c6-9562-4e24-a87a-3ede2a53f0bc".to_string()); // Ernest + + let secrets = request_client_secrets(RequestClientSecretsArgs { + cookies: &cookies, + }).await?; + + println!("Verification Token: {:?}", secrets.verification_token); + println!("Sentry Trace: {:?}", secrets.sentry_trace); + println!("Numbers: {:?}", secrets.numbers); + println!("Svg Path: {:?}", secrets.svg_path_data); + println!("Baggage: {:?}", secrets.baggage); + + // TDOO: This implementation is untested. + //let download = download_video_file(DownloadVideoFileArgs { + // cookies: cookies.as_str(), + // user_id: &secrets.user_id, + // file_id: &file_id, + // request_timeout: None, + //}).await?; + + //println!("Video byte length: {:?}", download.bytes.len()); + + assert_eq!(1, 2); + Ok(()) + } +} \ No newline at end of file diff --git a/crates/api_clients/grok_client/src/requests/download_video_file/mod.rs b/crates/api_clients/grok_client/src/requests/download_video_file/mod.rs new file mode 100644 index 0000000000..bb83484e8a --- /dev/null +++ b/crates/api_clients/grok_client/src/requests/download_video_file/mod.rs @@ -0,0 +1,2 @@ +pub mod download_video_file; +pub mod grok_download_video; \ No newline at end of file diff --git a/crates/api_clients/grok_client/src/requests/media_list/mod.rs b/crates/api_clients/grok_client/src/requests/media_list/mod.rs deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/crates/api_clients/grok_client/src/requests/create_media_post/_metadata/create_media_curl_request.txt b/crates/api_clients/grok_client/src/requests/media_posts/create_media_post/_metadata/create_media_curl_request.txt similarity index 100% rename from crates/api_clients/grok_client/src/requests/create_media_post/_metadata/create_media_curl_request.txt rename to crates/api_clients/grok_client/src/requests/media_posts/create_media_post/_metadata/create_media_curl_request.txt diff --git a/crates/api_clients/grok_client/src/requests/create_media_post/_metadata/create_media_notes.txt b/crates/api_clients/grok_client/src/requests/media_posts/create_media_post/_metadata/create_media_notes.txt similarity index 100% rename from crates/api_clients/grok_client/src/requests/create_media_post/_metadata/create_media_notes.txt rename to crates/api_clients/grok_client/src/requests/media_posts/create_media_post/_metadata/create_media_notes.txt diff --git a/crates/api_clients/grok_client/src/requests/create_media_post/_metadata/create_media_post_response.json b/crates/api_clients/grok_client/src/requests/media_posts/create_media_post/_metadata/create_media_post_response.json similarity index 100% rename from crates/api_clients/grok_client/src/requests/create_media_post/_metadata/create_media_post_response.json rename to crates/api_clients/grok_client/src/requests/media_posts/create_media_post/_metadata/create_media_post_response.json diff --git a/crates/api_clients/grok_client/src/requests/create_media_post/_metadata/create_media_post_to_generate_video_from_generated_image_curl_request.txt b/crates/api_clients/grok_client/src/requests/media_posts/create_media_post/_metadata/create_media_post_to_generate_video_from_generated_image_curl_request.txt similarity index 100% rename from crates/api_clients/grok_client/src/requests/create_media_post/_metadata/create_media_post_to_generate_video_from_generated_image_curl_request.txt rename to crates/api_clients/grok_client/src/requests/media_posts/create_media_post/_metadata/create_media_post_to_generate_video_from_generated_image_curl_request.txt diff --git a/crates/api_clients/grok_client/src/requests/create_media_post/_metadata/create_media_post_to_generate_video_from_generated_image_response.json b/crates/api_clients/grok_client/src/requests/media_posts/create_media_post/_metadata/create_media_post_to_generate_video_from_generated_image_response.json similarity index 100% rename from crates/api_clients/grok_client/src/requests/create_media_post/_metadata/create_media_post_to_generate_video_from_generated_image_response.json rename to crates/api_clients/grok_client/src/requests/media_posts/create_media_post/_metadata/create_media_post_to_generate_video_from_generated_image_response.json diff --git a/crates/api_clients/grok_client/src/requests/media_posts/create_media_post/_metadata/oct_30_rust_response_from_file_upload.json b/crates/api_clients/grok_client/src/requests/media_posts/create_media_post/_metadata/oct_30_rust_response_from_file_upload.json new file mode 100644 index 0000000000..b571258ca2 --- /dev/null +++ b/crates/api_clients/grok_client/src/requests/media_posts/create_media_post/_metadata/oct_30_rust_response_from_file_upload.json @@ -0,0 +1,13 @@ +{ + "post": { + "id": "3c896963-8e6b-4e71-83e1-4253b2bf2ac8", + "userId":"85980643-ffab-4984-a3de-59a608c47d7f", + "createTime":"2025-10-30T23:22:19.528702688Z", + "prompt":"", + "mediaType":"MEDIA_POST_TYPE_IMAGE", + "mediaUrl":"https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/3c896963-8e6b-4e71-83e1-4253b2bf2ac8/content", + "mimeType":"image/jpeg", + "audioUrls":[], + "childPosts":[] + } +} \ No newline at end of file diff --git a/crates/api_clients/grok_client/src/requests/create_media_post/grok_create_media_post.rs b/crates/api_clients/grok_client/src/requests/media_posts/create_media_post/grok_create_media_post.rs similarity index 84% rename from crates/api_clients/grok_client/src/requests/create_media_post/grok_create_media_post.rs rename to crates/api_clients/grok_client/src/requests/media_posts/create_media_post/grok_create_media_post.rs index 87d16f3f5f..a0ce3d97de 100644 --- a/crates/api_clients/grok_client/src/requests/create_media_post/grok_create_media_post.rs +++ b/crates/api_clients/grok_client/src/requests/media_posts/create_media_post/grok_create_media_post.rs @@ -1,10 +1,12 @@ use crate::client::browser_user_agents::FIREFOX_143_MAC_USER_AGENT; use crate::datatypes::api::file_id::FileId; +use crate::datatypes::api::post_id::PostId; use crate::datatypes::api::user_id::UserId; use crate::error::grok_client_error::GrokClientError; use crate::error::grok_error::GrokError; use crate::error::grok_generic_api_error::GrokGenericApiError; -use crate::requests::create_media_post::request::CreateMediaPostWireRequest; +use crate::requests::media_posts::create_media_post::request::CreateMediaPostWireRequest; +use crate::requests::media_posts::create_media_post::response::CreateMediaPostRawResponse; use crate::requests::upload_file::grok_upload_file::{GrokUploadFile, GrokUploadFileResponse}; use crate::utils::user_and_file_id_to_image_url::user_and_file_id_to_image_url; use log::{error, info}; @@ -32,8 +34,9 @@ pub enum MediaPostType { } /// Response type -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct GrokCreateMediaPostResponse { + pub post_id: PostId, } impl <'a> GrokCreateMediaPost<'a> { @@ -47,9 +50,17 @@ impl <'a> GrokCreateMediaPost<'a> { // TODO: Headers were from Chromium, not Firefox. Partial implementation. let mut request_builder = client.post(CREATE_MEDIA_POST_URL) + // TODO: Missing header "baggage" + // TODO: Missing header "sentry-trace" + // TODO: Missing header "traceparent" + // TODO: Missing header "traceparent" + // TODO: Missing header "x-statsig-id" + // TODO: Missing header "x-xai-request-id" + //.header("sec-ch-ua", "") // NB: Not present in firefox + //.header("sec-ch-ua-mobile", "") // NB: Not present in firefox + //.header("sec-ch-ua-platform", "") // NB: Not present in firefox .header(ACCEPT, "*/*") .header(ACCEPT_LANGUAGE, "en-US,en;q=0.5") - // TODO: Missing header "baggage" .header(CACHE_CONTROL, "no-cache") .header(CONTENT_TYPE, "application/json") .header(COOKIE, self.cookie.to_string()) @@ -57,17 +68,9 @@ impl <'a> GrokCreateMediaPost<'a> { .header(PRAGMA, "no-cache") .header("priority", "u=1, i") .header(REFERER, "https://grok.com/imagine/favorites") - //.header("sec-ch-ua", "") // TODO - //.header("sec-ch-ua-mobile", "") // TODO - //.header("sec-ch-ua-platform", "") // TODO .header("sec-fetch-dest", "empty") .header("sec-fetch-mode", "cors") .header("sec-fetch-site", "same-origin") - // TODO: Missing header "sentry-trace" - // TODO: Missing header "traceparent" - // TODO: Missing header "traceparent" - // TODO: Missing header "x-statsig-id" - // TODO: Missing header "x-xai-request-id" .header(USER_AGENT, FIREFOX_143_MAC_USER_AGENT); //info!("Sending..."); @@ -106,23 +109,24 @@ impl <'a> GrokCreateMediaPost<'a> { let status = response.status(); - let response_body = &response.text() + let response_body = response.text() .await .map_err(|err| { error!("Error reading Grok create media response body: {:?}", err); GrokGenericApiError::WreqError(err) })?; - // TODO: + // TODO: Handle errors (Cloudflare, Grok, etc.) if !status.is_success() { error!("Upload file request returned an error (code {}) : {:?}", status.as_u16(), response_body); //return Err(classify_general_http_status_code_and_body(status, response_body)); } - //let response : GrokApiUploadFileResponse = serde_json::from_str(response_body) - // .map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?; + let response : CreateMediaPostRawResponse = serde_json::from_str(&response_body) + .map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?; - Ok(GrokCreateMediaPostResponse{ + Ok(GrokCreateMediaPostResponse { + post_id: PostId(response.post.id), }) } } @@ -153,6 +157,8 @@ mod tests { let result = request.send().await?; println!("Result: {:?}", result); + println!("Post ID: {:?}", result.post_id); + assert_eq!(1, 2); Ok(()) } diff --git a/crates/api_clients/grok_client/src/requests/media_posts/create_media_post/mod.rs b/crates/api_clients/grok_client/src/requests/media_posts/create_media_post/mod.rs new file mode 100644 index 0000000000..9fc4c9c3f9 --- /dev/null +++ b/crates/api_clients/grok_client/src/requests/media_posts/create_media_post/mod.rs @@ -0,0 +1,3 @@ +pub mod grok_create_media_post; +pub (super) mod request; +mod response; \ No newline at end of file diff --git a/crates/api_clients/grok_client/src/requests/create_media_post/request.rs b/crates/api_clients/grok_client/src/requests/media_posts/create_media_post/request.rs similarity index 100% rename from crates/api_clients/grok_client/src/requests/create_media_post/request.rs rename to crates/api_clients/grok_client/src/requests/media_posts/create_media_post/request.rs diff --git a/crates/api_clients/grok_client/src/requests/media_posts/create_media_post/response.rs b/crates/api_clients/grok_client/src/requests/media_posts/create_media_post/response.rs new file mode 100644 index 0000000000..feb01ffc9a --- /dev/null +++ b/crates/api_clients/grok_client/src/requests/media_posts/create_media_post/response.rs @@ -0,0 +1,28 @@ +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct CreateMediaPostRawResponse { + pub post: PostData, +} + +#[derive(Deserialize)] +pub struct PostData { + /// The identifier for the post + pub id: String, +} + +/* +{ + "post": { + "id": "3c896963-8e6b-4e71-83e1-4253b2bf2ac8", + "userId":"85980643-ffab-4984-a3de-59a608c47d7f", + "createTime":"2025-10-30T23:22:19.528702688Z", + "prompt":"", + "mediaType":"MEDIA_POST_TYPE_IMAGE", + "mediaUrl":"https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/3c896963-8e6b-4e71-83e1-4253b2bf2ac8/content", + "mimeType":"image/jpeg", + "audioUrls":[], + "childPosts":[] + } +} +*/ diff --git a/crates/api_clients/grok_client/src/requests/like_media/_metadata/favorite_media_curl_request.txt b/crates/api_clients/grok_client/src/requests/media_posts/like_media_post/_metadata/favorite_media_curl_request.txt similarity index 100% rename from crates/api_clients/grok_client/src/requests/like_media/_metadata/favorite_media_curl_request.txt rename to crates/api_clients/grok_client/src/requests/media_posts/like_media_post/_metadata/favorite_media_curl_request.txt diff --git a/crates/api_clients/grok_client/src/requests/like_media/grok_like_media.rs b/crates/api_clients/grok_client/src/requests/media_posts/like_media_post/grok_like_media.rs similarity index 98% rename from crates/api_clients/grok_client/src/requests/like_media/grok_like_media.rs rename to crates/api_clients/grok_client/src/requests/media_posts/like_media_post/grok_like_media.rs index 86cb725123..20aeabf9af 100644 --- a/crates/api_clients/grok_client/src/requests/like_media/grok_like_media.rs +++ b/crates/api_clients/grok_client/src/requests/media_posts/like_media_post/grok_like_media.rs @@ -1,9 +1,15 @@ use crate::client::browser_user_agents::FIREFOX_143_MAC_USER_AGENT; +use crate::datatypes::api::baggage::Baggage; +use crate::datatypes::api::file_id::FileId; +use crate::datatypes::api::sentry_trace::SentryTrace; +use crate::datatypes::api::svg_path_data::SvgPathData; +use crate::datatypes::api::verification_token::VerificationToken; +use crate::datatypes::api::xsid_numbers::XsidNumbers; use crate::error::grok_client_error::GrokClientError; use crate::error::grok_error::GrokError; use crate::error::grok_generic_api_error::GrokGenericApiError; use crate::requests::index_page::signature::generate_xsid::{generate_xsid, GenerateXsidArgs}; -use crate::requests::like_media::request::LikeMediaWireRequest; +use crate::requests::media_posts::like_media_post::request::LikeMediaWireRequest; use crate::requests::upload_file::grok_upload_file::{GrokUploadFile, GrokUploadFileResponse}; use crate::utils::user_and_file_id_to_image_url::user_and_file_id_to_image_url; use log::{error, info, warn}; @@ -12,12 +18,6 @@ use uuid::Uuid; use wreq::header::{ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, CACHE_CONTROL, CONNECTION, CONTENT_TYPE, COOKIE, ORIGIN, PRAGMA, REFERER, TE, USER_AGENT}; use wreq::Client; use wreq_util::Emulation; -use crate::datatypes::api::baggage::Baggage; -use crate::datatypes::api::file_id::FileId; -use crate::datatypes::api::sentry_trace::SentryTrace; -use crate::datatypes::api::svg_path_data::SvgPathData; -use crate::datatypes::api::verification_token::VerificationToken; -use crate::datatypes::api::xsid_numbers::XsidNumbers; const LIKE_MEDIA_POST_URL: &str = "https://grok.com/rest/media/post/like"; diff --git a/crates/api_clients/grok_client/src/requests/like_media/mod.rs b/crates/api_clients/grok_client/src/requests/media_posts/like_media_post/mod.rs similarity index 100% rename from crates/api_clients/grok_client/src/requests/like_media/mod.rs rename to crates/api_clients/grok_client/src/requests/media_posts/like_media_post/mod.rs diff --git a/crates/api_clients/grok_client/src/requests/like_media/request.rs b/crates/api_clients/grok_client/src/requests/media_posts/like_media_post/request.rs similarity index 100% rename from crates/api_clients/grok_client/src/requests/like_media/request.rs rename to crates/api_clients/grok_client/src/requests/media_posts/like_media_post/request.rs diff --git a/crates/api_clients/grok_client/src/requests/media_posts/list_media_posts/_metadata/firefox_media_post_list_curl_request.txt b/crates/api_clients/grok_client/src/requests/media_posts/list_media_posts/_metadata/firefox_media_post_list_curl_request.txt new file mode 100644 index 0000000000..ca4e9025ee --- /dev/null +++ b/crates/api_clients/grok_client/src/requests/media_posts/list_media_posts/_metadata/firefox_media_post_list_curl_request.txt @@ -0,0 +1,24 @@ +curl 'https://grok.com/rest/media/post/list' \ + --compressed \ + -X POST \ + -H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:144.0) Gecko/20100101 Firefox/144.0' \ + -H 'Accept: */*' \ + -H 'Accept-Language: en-US,en;q=0.5' \ + -H 'Accept-Encoding: gzip, deflate, br, zstd' \ + -H 'Referer: https://grok.com/imagine/favorites' \ + -H 'traceparent: 00-b28237cfadf176bbd0c36fb0ff06a3c9-f2940af12cf53770-00' \ + -H 'Content-Type: application/json' \ + -H 'x-xai-request-id: 91522ffe-bbfc-48d4-87eb-a3063f0c1476' \ + -H 'x-statsig-id: q15NyzGsJCh4SnigNL2jHcMQJgKTHwFmu61q0oPTNseHkzmbNBhJhTPfx95XHBhselA9H69n4PoJ7/Zrb9kj645kDoROqA' \ + -H 'sentry-trace: 00461630c637f9a69baf30bb37c0ecd4-8f3078bbbac51430-0' \ + -H 'baggage: sentry-environment=production,sentry-public_key=b311e0f2690c81f25e2c4cf6d4f7ce1c,sentry-trace_id=00461630c637f9a69baf30bb37c0ecd4,sentry-org_id=4508179396558848,sentry-sampled=false,sentry-sample_rand=0.4352105572006608,sentry-sample_rate=0' \ + -H 'Origin: https://grok.com' \ + -H 'Sec-GPC: 1' \ + -H 'Connection: keep-alive' \ + -H 'Cookie: stblid=59d7cf53-ffeb-42ad-8ba3-ab300f12d741; cf_clearance=1XFYsysnawAmx3PrMOKmdczAfFiwkQhC75XLkCAsqUU-1761868196-1.2.1.1-XO.t8iWRNDMvVqIrQrnvedcdxqhTqoDyjvMkU6XT3IOUm3Hs4iIBFPPdzOEvjR1NxeGDJRr20dbZ2B1Xcuwzl78dDLJn_hbQeugOlxG7K7QNrctY.zbdDcEECtmbaghnPOsq2x_Gn6n4t72kaij10m.9EqMrAPwPB.n87Z14APKS_7XXYoF96mByVnckLcM71_WiJUA7eXvNl4j5Kc65XlrgXi.sku3cX52WFZkmBNg; i18nextLng=en; x-anonuserid=41f3614a-1a08-4c9b-81b4-0c2085f6b8d8; x-challenge=tmsr%2B%2BbUg2pjTWyAOGqqFe%2BW38g66vDImJiVuiLtmSmNpaQm%2B46WajkdQoflNC1CyRKRcOLOc1ylzYI%2BerCVbWLrHr022ZWcTJqcN0o1499%2FcjPAAspeAKPV%2F6OAaHmYGu8eSXR4dj27qXSWxLixSctJLIQZVpKXX7njuuSkvKDQCwBgt%2BY%3D; x-signature=GqtNLJQw2%2BOk1rO6sby9vQT3N5%2BiX8g%2B8Bqrndiv51dSYQFC4v2%2FlrkKmH1utjqExbfvVjtks0%2Bn6RC3q61%2FRA%3D%3D; sso=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzZXNzaW9uX2lkIjoiMmM1OGEyMjQtMDViZi00NjllLWFkYjUtNmY3ODdmZDU1MTc2In0.rnUG2UvKv32YBGSyYWrWh_1KaLiTix-6H4TZe4ZywfU; sso-rw=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzZXNzaW9uX2lkIjoiMmM1OGEyMjQtMDViZi00NjllLWFkYjUtNmY3ODdmZDU1MTc2In0.rnUG2UvKv32YBGSyYWrWh_1KaLiTix-6H4TZe4ZywfU; mp_ea93da913ddb66b6372b89d97b1029ac_mixpanel=%7B%22distinct_id%22%3A%2285980643-ffab-4984-a3de-59a608c47d7f%22%2C%22%24device_id%22%3A%22c02bd50a-db0e-4f35-903e-b03f32f28b5f%22%2C%22%24initial_referrer%22%3A%22%24direct%22%2C%22%24initial_referring_domain%22%3A%22%24direct%22%2C%22__mps%22%3A%7B%7D%2C%22__mpso%22%3A%7B%7D%2C%22__mpus%22%3A%7B%7D%2C%22__mpa%22%3A%7B%7D%2C%22__mpu%22%3A%7B%7D%2C%22__mpr%22%3A%5B%5D%2C%22__mpap%22%3A%5B%5D%2C%22%24user_id%22%3A%2285980643-ffab-4984-a3de-59a608c47d7f%22%7D' \ + -H 'Sec-Fetch-Dest: empty' \ + -H 'Sec-Fetch-Mode: cors' \ + -H 'Sec-Fetch-Site: same-origin' \ + -H 'Priority: u=4' \ + -H 'TE: trailers' \ + --data-raw '{"limit":40,"filter":{"source":"MEDIA_POST_SOURCE_LIKED"}}' \ No newline at end of file diff --git a/crates/api_clients/grok_client/src/requests/media_posts/list_media_posts/_metadata/oct_30_media_post_list_response.json b/crates/api_clients/grok_client/src/requests/media_posts/list_media_posts/_metadata/oct_30_media_post_list_response.json new file mode 100644 index 0000000000..f3e8aa2b59 --- /dev/null +++ b/crates/api_clients/grok_client/src/requests/media_posts/list_media_posts/_metadata/oct_30_media_post_list_response.json @@ -0,0 +1,703 @@ +{ + "posts": [ + { + "id": "abd9331b-c5ac-49cc-8bec-c410c7efde98", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-30T23:49:37.583477Z", + "prompt": "", + "mediaType": "MEDIA_POST_TYPE_IMAGE", + "mediaUrl": "https://imagine-public.x.ai/imagine-public/share-images/abd9331b-c5ac-49cc-8bec-c410c7efde98.jpg", + "mimeType": "image/jpeg", + "audioUrls": [], + "childPosts": [ + { + "id": "ecec690e-15bd-4f5f-b4e9-9b8733c3fd69", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-30T23:50:04.862487Z", + "prompt": "", + "mediaType": "MEDIA_POST_TYPE_VIDEO", + "mediaUrl": "https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/generated/ecec690e-15bd-4f5f-b4e9-9b8733c3fd69/generated_video.mp4", + "mimeType": "video/mp4", + "originalPostId": "abd9331b-c5ac-49cc-8bec-c410c7efde98", + "audioUrls": [], + "childPosts": [], + "originalPrompt": "an anime girl runs away from a giant t-rex", + "mode": "custom", + "modelName": "imagine_xdit_1" + } + ], + "originalPrompt": "", + "userInteractionStatus": { + "likeStatus": true + }, + "modelName": "imagine_h_1", + "thumbnailImageUrl": "https://imagine-public.x.ai/cdn-cgi/image/width=500,fit=scale-down,format=auto/imagine-public/share-images/abd9331b-c5ac-49cc-8bec-c410c7efde98.jpg" + }, + { + "id": "3c896963-8e6b-4e71-83e1-4253b2bf2ac8", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-30T23:22:19.528702Z", + "prompt": "", + "mediaType": "MEDIA_POST_TYPE_IMAGE", + "mediaUrl": "https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/3c896963-8e6b-4e71-83e1-4253b2bf2ac8/content", + "mimeType": "image/jpeg", + "audioUrls": [], + "childPosts": [ + { + "id": "b3929c27-0f02-4a17-bfb1-867ac555a72a", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-30T23:22:46.680830Z", + "prompt": "", + "mediaType": "MEDIA_POST_TYPE_VIDEO", + "mediaUrl": "https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/generated/b3929c27-0f02-4a17-bfb1-867ac555a72a/generated_video.mp4", + "mimeType": "video/mp4", + "originalPostId": "3c896963-8e6b-4e71-83e1-4253b2bf2ac8", + "audioUrls": [], + "childPosts": [], + "originalPrompt": "an anime girl peacefully stands in front of a t-rex in a solar punk field. the t-rex roars and the girl streches and yawns", + "mode": "custom", + "modelName": "imagine_xdit_1", + "thumbnailImageUrl": "https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/generated/b3929c27-0f02-4a17-bfb1-867ac555a72a/preview_image.jpg" + } + ], + "originalPrompt": "", + "userInteractionStatus": { + "likeStatus": true + }, + "modelName": "imagine_h_1" + }, + { + "id": "6272020b-bb05-4b61-ae72-0df4500d813c", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-30T22:55:11.977212Z", + "prompt": "", + "mediaType": "MEDIA_POST_TYPE_IMAGE", + "mediaUrl": "https://imagine-public.x.ai/imagine-public/share-images/6272020b-bb05-4b61-ae72-0df4500d813c.jpg", + "mimeType": "image/jpeg", + "audioUrls": [], + "childPosts": [ + { + "id": "e3785eeb-0b18-4179-a3ba-878a59b5a230", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-30T22:55:37.558045Z", + "prompt": "", + "mediaType": "MEDIA_POST_TYPE_VIDEO", + "mediaUrl": "https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/generated/e3785eeb-0b18-4179-a3ba-878a59b5a230/generated_video.mp4", + "mimeType": "video/mp4", + "originalPostId": "6272020b-bb05-4b61-ae72-0df4500d813c", + "audioUrls": [], + "childPosts": [], + "originalPrompt": "the mountains are digitial and lights flicker up and down the topography. the camera pans gently by", + "mode": "custom", + "modelName": "imagine_xdit_1" + } + ], + "originalPrompt": "", + "userInteractionStatus": { + "likeStatus": true + }, + "modelName": "imagine_h_1", + "thumbnailImageUrl": "https://imagine-public.x.ai/cdn-cgi/image/width=500,fit=scale-down,format=auto/imagine-public/share-images/6272020b-bb05-4b61-ae72-0df4500d813c.jpg" + }, + { + "id": "e40ee84c-fbc5-4fd0-9618-0a5b80f46a47", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-30T22:03:09.166736Z", + "prompt": "", + "mediaType": "MEDIA_POST_TYPE_IMAGE", + "mediaUrl": "https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/e40ee84c-fbc5-4fd0-9618-0a5b80f46a47/content", + "mimeType": "image/jpeg", + "audioUrls": [], + "childPosts": [ + { + "id": "ffa3304d-0c79-4f3b-ab8e-a56b6a603b30", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-30T22:03:34.259468Z", + "prompt": "", + "mediaType": "MEDIA_POST_TYPE_VIDEO", + "mediaUrl": "https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/generated/ffa3304d-0c79-4f3b-ab8e-a56b6a603b30/generated_video.mp4", + "mimeType": "video/mp4", + "originalPostId": "e40ee84c-fbc5-4fd0-9618-0a5b80f46a47", + "audioUrls": [], + "childPosts": [], + "originalPrompt": "The hero shoots an arrow and it hits a skeleton monster", + "mode": "custom", + "modelName": "imagine_xdit_1" + } + ], + "originalPrompt": "", + "userInteractionStatus": { + "likeStatus": true + }, + "modelName": "imagine_h_1" + }, + { + "id": "ba25cc83-c986-49bd-9e98-edbad66457a3", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-30T21:52:05.585904Z", + "prompt": "", + "mediaType": "MEDIA_POST_TYPE_IMAGE", + "mediaUrl": "https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/ba25cc83-c986-49bd-9e98-edbad66457a3/content", + "mimeType": "image/jpeg", + "audioUrls": [], + "childPosts": [ + { + "id": "432ffd25-686e-4970-9bae-5089a8993234", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-30T21:52:29.182159Z", + "prompt": "", + "mediaType": "MEDIA_POST_TYPE_VIDEO", + "mediaUrl": "https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/generated/432ffd25-686e-4970-9bae-5089a8993234/generated_video.mp4", + "mimeType": "video/mp4", + "originalPostId": "ba25cc83-c986-49bd-9e98-edbad66457a3", + "audioUrls": [], + "childPosts": [], + "originalPrompt": "The hero shoots an arrow and the camera zooms out to follow the arrow", + "mode": "custom", + "modelName": "imagine_xdit_1" + } + ], + "originalPrompt": "", + "userInteractionStatus": { + "likeStatus": true + }, + "modelName": "imagine_h_1" + }, + { + "id": "3ce7350b-323a-48c3-b8a0-798fd11e176c", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-30T19:25:03.911369Z", + "prompt": "", + "mediaType": "MEDIA_POST_TYPE_IMAGE", + "mediaUrl": "https://imagine-public.x.ai/imagine-public/share-images/3ce7350b-323a-48c3-b8a0-798fd11e176c.jpg", + "mimeType": "image/jpeg", + "audioUrls": [], + "childPosts": [ + { + "id": "ff98ccba-7879-478d-b8a1-089de60b23f1", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-30T19:25:30.649705Z", + "prompt": "", + "mediaType": "MEDIA_POST_TYPE_VIDEO", + "mediaUrl": "https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/generated/ff98ccba-7879-478d-b8a1-089de60b23f1/generated_video.mp4", + "mimeType": "video/mp4", + "originalPostId": "3ce7350b-323a-48c3-b8a0-798fd11e176c", + "audioUrls": [], + "childPosts": [], + "originalPrompt": "our hero link plunges the sword into the pedestal, the temple is glowing with a blue aura", + "mode": "custom", + "modelName": "imagine_xdit_1" + } + ], + "originalPrompt": "", + "userInteractionStatus": { + "likeStatus": true + }, + "modelName": "imagine_h_1", + "thumbnailImageUrl": "https://imagine-public.x.ai/cdn-cgi/image/width=500,fit=scale-down,format=auto/imagine-public/share-images/3ce7350b-323a-48c3-b8a0-798fd11e176c.jpg" + }, + { + "id": "eabcc2a5-8a9b-4382-82d2-020b4b286e87", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-30T19:04:27.442590Z", + "prompt": "", + "mediaType": "MEDIA_POST_TYPE_IMAGE", + "mediaUrl": "https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/eabcc2a5-8a9b-4382-82d2-020b4b286e87/content", + "mimeType": "image/jpeg", + "audioUrls": [], + "childPosts": [ + { + "id": "34fa0313-3cde-4693-b046-319093374dbe", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-30T19:04:52.479768Z", + "prompt": "", + "mediaType": "MEDIA_POST_TYPE_VIDEO", + "mediaUrl": "https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/generated/34fa0313-3cde-4693-b046-319093374dbe/generated_video.mp4", + "mimeType": "video/mp4", + "originalPostId": "eabcc2a5-8a9b-4382-82d2-020b4b286e87", + "audioUrls": [], + "childPosts": [], + "originalPrompt": "our hero link on horseback galloping through the fantasy world, bright sun behind him. a dormant volcano has a circling ring of smoke. the camera pulls back and follows our hero", + "mode": "custom", + "modelName": "imagine_xdit_1", + "thumbnailImageUrl": "https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/generated/34fa0313-3cde-4693-b046-319093374dbe/preview_image.jpg" + } + ], + "originalPrompt": "", + "userInteractionStatus": { + "likeStatus": true + }, + "modelName": "imagine_h_1" + }, + { + "id": "187f207f-7e4f-4491-a90c-a5ce7efa0586", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-30T18:31:07.586547Z", + "prompt": "", + "mediaType": "MEDIA_POST_TYPE_IMAGE", + "mediaUrl": "https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/187f207f-7e4f-4491-a90c-a5ce7efa0586/content", + "mimeType": "image/jpeg", + "audioUrls": [], + "childPosts": [ + { + "id": "d9b300c6-9562-4e24-a87a-3ede2a53f0bc", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-30T18:31:30.976720Z", + "prompt": "", + "mediaType": "MEDIA_POST_TYPE_VIDEO", + "mediaUrl": "https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/generated/d9b300c6-9562-4e24-a87a-3ede2a53f0bc/generated_video.mp4", + "mimeType": "video/mp4", + "originalPostId": "187f207f-7e4f-4491-a90c-a5ce7efa0586", + "audioUrls": [], + "childPosts": [], + "originalPrompt": "A man in the forest cuts down a tree", + "mode": "custom", + "modelName": "imagine_xdit_1" + } + ], + "originalPrompt": "", + "userInteractionStatus": { + "likeStatus": true + }, + "modelName": "imagine_h_1" + }, + { + "id": "def0be3d-d1dc-4511-98fe-0b71663cd689", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-29T07:36:42.981250Z", + "prompt": "", + "mediaType": "MEDIA_POST_TYPE_IMAGE", + "mediaUrl": "https://imagine-public.x.ai/imagine-public/share-images/def0be3d-d1dc-4511-98fe-0b71663cd689.jpg", + "mimeType": "image/jpeg", + "audioUrls": [], + "childPosts": [ + { + "id": "eb05dc4b-c243-4ff5-ba84-9bbea708cfb6", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-29T07:37:08.105827Z", + "prompt": "", + "mediaType": "MEDIA_POST_TYPE_VIDEO", + "mediaUrl": "https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/generated/eb05dc4b-c243-4ff5-ba84-9bbea708cfb6/generated_video.mp4", + "mimeType": "video/mp4", + "originalPostId": "def0be3d-d1dc-4511-98fe-0b71663cd689", + "audioUrls": [], + "childPosts": [], + "originalPrompt": "A man in the forest runs away from a spooky ghost", + "mode": "custom", + "modelName": "imagine_xdit_1", + "thumbnailImageUrl": "https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/generated/eb05dc4b-c243-4ff5-ba84-9bbea708cfb6/preview_image.jpg" + } + ], + "originalPrompt": "", + "userInteractionStatus": { + "likeStatus": true + }, + "modelName": "imagine_h_1", + "thumbnailImageUrl": "https://imagine-public.x.ai/cdn-cgi/image/width=500,fit=scale-down,format=auto/imagine-public/share-images/def0be3d-d1dc-4511-98fe-0b71663cd689.jpg" + }, + { + "id": "d6d73ffd-ce87-40a3-9cc2-3432d7192652", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-29T06:27:31.563653Z", + "prompt": "", + "mediaType": "MEDIA_POST_TYPE_IMAGE", + "mediaUrl": "https://imagine-public.x.ai/imagine-public/share-images/d6d73ffd-ce87-40a3-9cc2-3432d7192652.jpg", + "mimeType": "image/jpeg", + "audioUrls": [], + "childPosts": [ + { + "id": "3768b6db-9694-4630-b547-4d4fa061ac9b", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-29T06:49:30.755029Z", + "prompt": "", + "mediaType": "MEDIA_POST_TYPE_VIDEO", + "mediaUrl": "https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/generated/3768b6db-9694-4630-b547-4d4fa061ac9b/generated_video.mp4", + "mimeType": "video/mp4", + "originalPostId": "d6d73ffd-ce87-40a3-9cc2-3432d7192652", + "audioUrls": [], + "childPosts": [], + "originalPrompt": "building gets hit by tornado", + "mode": "custom", + "modelName": "imagine_xdit_1", + "thumbnailImageUrl": "https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/generated/3768b6db-9694-4630-b547-4d4fa061ac9b/preview_image.jpg" + }, + { + "id": "2e68bc4b-ceb7-4eee-be13-8423fc9595b0", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-29T06:27:58.455499Z", + "prompt": "", + "mediaType": "MEDIA_POST_TYPE_VIDEO", + "mediaUrl": "https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/generated/2e68bc4b-ceb7-4eee-be13-8423fc9595b0/generated_video.mp4", + "mimeType": "video/mp4", + "originalPostId": "d6d73ffd-ce87-40a3-9cc2-3432d7192652", + "audioUrls": [], + "childPosts": [], + "originalPrompt": "", + "mode": "normal", + "modelName": "imagine_xdit_1" + } + ], + "originalPrompt": "", + "userInteractionStatus": { + "likeStatus": true + }, + "modelName": "imagine_h_1", + "thumbnailImageUrl": "https://imagine-public.x.ai/cdn-cgi/image/width=500,fit=scale-down,format=auto/imagine-public/share-images/d6d73ffd-ce87-40a3-9cc2-3432d7192652.jpg" + }, + { + "id": "0e1b621b-2d45-491b-91b0-e9cf6c6daaac", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-29T00:37:15.507858Z", + "prompt": "", + "mediaType": "MEDIA_POST_TYPE_IMAGE", + "mediaUrl": "https://imagine-public.x.ai/imagine-public/share-images/0e1b621b-2d45-491b-91b0-e9cf6c6daaac.jpg", + "mimeType": "image/jpeg", + "audioUrls": [], + "childPosts": [ + { + "id": "55ef6c27-87bc-4fff-a1a9-72acb06e53e5", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-29T00:37:42.725023Z", + "prompt": "", + "mediaType": "MEDIA_POST_TYPE_VIDEO", + "mediaUrl": "https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/generated/55ef6c27-87bc-4fff-a1a9-72acb06e53e5/generated_video.mp4", + "mimeType": "video/mp4", + "originalPostId": "0e1b621b-2d45-491b-91b0-e9cf6c6daaac", + "audioUrls": [], + "childPosts": [], + "originalPrompt": "", + "mode": "normal", + "modelName": "imagine_xdit_1", + "thumbnailImageUrl": "https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/generated/55ef6c27-87bc-4fff-a1a9-72acb06e53e5/preview_image.jpg" + } + ], + "originalPrompt": "", + "userInteractionStatus": { + "likeStatus": true + }, + "modelName": "imagine_h_1", + "thumbnailImageUrl": "https://imagine-public.x.ai/cdn-cgi/image/width=500,fit=scale-down,format=auto/imagine-public/share-images/0e1b621b-2d45-491b-91b0-e9cf6c6daaac.jpg" + }, + { + "id": "5088adf9-85fd-4f51-a514-005aae2e1027", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-22T17:27:33.154910Z", + "prompt": "A serene pathway lined with trees and benches, bathed in soft sunlight, with fallen leaves scattered on the ground and a fence along the side.", + "mediaType": "MEDIA_POST_TYPE_IMAGE", + "mediaUrl": "https://imagine-public.x.ai/imagine-public/share-images/5088adf9-85fd-4f51-a514-005aae2e1027.jpg", + "mimeType": "image/jpeg", + "audioUrls": [], + "childPosts": [ + { + "id": "23b90ea3-3236-4d88-81d3-f978e8f0a29c", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-22T17:33:13.785269Z", + "prompt": "", + "mediaType": "MEDIA_POST_TYPE_VIDEO", + "mediaUrl": "https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/generated/23b90ea3-3236-4d88-81d3-f978e8f0a29c/generated_video.mp4", + "mimeType": "video/mp4", + "originalPostId": "5088adf9-85fd-4f51-a514-005aae2e1027", + "audioUrls": [], + "childPosts": [], + "originalPrompt": "an explosion topples the trees and benches in the park", + "mode": "custom", + "modelName": "imagine_xdit_1" + }, + { + "id": "d5d9a592-250b-4f0e-85a4-c51463852834", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-22T17:31:48.884685Z", + "prompt": "", + "mediaType": "MEDIA_POST_TYPE_VIDEO", + "mediaUrl": "https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/generated/d5d9a592-250b-4f0e-85a4-c51463852834/generated_video.mp4", + "mimeType": "video/mp4", + "originalPostId": "5088adf9-85fd-4f51-a514-005aae2e1027", + "audioUrls": [], + "childPosts": [], + "originalPrompt": "", + "mode": "normal", + "modelName": "imagine_xdit_1" + }, + { + "id": "ddb08c24-1ad2-44dc-b9fe-a7850d0e512a", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-22T17:29:16.206510Z", + "prompt": "", + "mediaType": "MEDIA_POST_TYPE_VIDEO", + "mediaUrl": "https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/generated/ddb08c24-1ad2-44dc-b9fe-a7850d0e512a/generated_video.mp4", + "mimeType": "video/mp4", + "originalPostId": "5088adf9-85fd-4f51-a514-005aae2e1027", + "audioUrls": [], + "childPosts": [], + "originalPrompt": "", + "mode": "normal", + "modelName": "imagine_xdit_1" + }, + { + "id": "be350818-6610-461b-8103-e0a5e0548639", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-22T17:28:00.140298Z", + "prompt": "", + "mediaType": "MEDIA_POST_TYPE_VIDEO", + "mediaUrl": "https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/generated/be350818-6610-461b-8103-e0a5e0548639/generated_video.mp4", + "mimeType": "video/mp4", + "originalPostId": "5088adf9-85fd-4f51-a514-005aae2e1027", + "audioUrls": [], + "childPosts": [], + "originalPrompt": "", + "mode": "normal", + "modelName": "imagine_xdit_1" + } + ], + "originalPrompt": "", + "userInteractionStatus": { + "likeStatus": true + }, + "modelName": "imagine_h_1", + "thumbnailImageUrl": "https://imagine-public.x.ai/cdn-cgi/image/width=500,fit=scale-down,format=auto/imagine-public/share-images/5088adf9-85fd-4f51-a514-005aae2e1027.jpg" + }, + { + "id": "2d8260d7-3b7c-4bc1-84ca-6e82dd892c39", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-22T00:14:15.635290Z", + "prompt": "an alien in the hallway", + "mediaType": "MEDIA_POST_TYPE_IMAGE", + "mediaUrl": "https://imagine-public.x.ai/imagine-public/images/2d8260d7-3b7c-4bc1-84ca-6e82dd892c39.png", + "mimeType": "image/png", + "audioUrls": [], + "childPosts": [ + { + "id": "6128c2c7-d353-426f-a7e0-de052a014381", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-22T17:23:14.118415Z", + "prompt": "", + "mediaType": "MEDIA_POST_TYPE_VIDEO", + "mediaUrl": "https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/generated/6128c2c7-d353-426f-a7e0-de052a014381/generated_video.mp4", + "mimeType": "video/mp4", + "originalPostId": "2d8260d7-3b7c-4bc1-84ca-6e82dd892c39", + "audioUrls": [], + "childPosts": [], + "originalPrompt": "alien runs down the hallway chasing after humans", + "mode": "custom", + "modelName": "imagine_xdit_1" + }, + { + "id": "8f7cf34b-a44a-4f1f-9199-47ad2a4fbfcc", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-22T17:21:45.452694Z", + "prompt": "", + "mediaType": "MEDIA_POST_TYPE_VIDEO", + "mediaUrl": "https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/generated/8f7cf34b-a44a-4f1f-9199-47ad2a4fbfcc/generated_video.mp4", + "mimeType": "video/mp4", + "originalPostId": "2d8260d7-3b7c-4bc1-84ca-6e82dd892c39", + "audioUrls": [], + "childPosts": [], + "originalPrompt": "alien dances in the hallway", + "mode": "custom", + "modelName": "imagine_xdit_1" + }, + { + "id": "019a9e76-e68b-4243-a479-7842f9d6ec0f", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-22T00:14:39.537709Z", + "prompt": "", + "mediaType": "MEDIA_POST_TYPE_VIDEO", + "mediaUrl": "https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/generated/019a9e76-e68b-4243-a479-7842f9d6ec0f/generated_video.mp4", + "mimeType": "video/mp4", + "originalPostId": "2d8260d7-3b7c-4bc1-84ca-6e82dd892c39", + "audioUrls": [], + "childPosts": [], + "originalPrompt": "alien walks down the hallway", + "mode": "custom", + "modelName": "imagine_xdit_1" + } + ], + "originalPrompt": "an alien in the hallway", + "userInteractionStatus": { + "likeStatus": true + }, + "modelName": "imagine_h_1", + "thumbnailImageUrl": "https://imagine-public.x.ai/cdn-cgi/image/width=500,fit=scale-down,format=auto/imagine-public/images/2d8260d7-3b7c-4bc1-84ca-6e82dd892c39.png" + }, + { + "id": "990ddf90-8f34-42b1-81a5-39c509d62ff7", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-22T17:00:16.178428Z", + "prompt": "A dog wearing oversized glasses is held by a hand, with another dog nuzzling it near a window with a city view.", + "mediaType": "MEDIA_POST_TYPE_IMAGE", + "mediaUrl": "https://imagine-public.x.ai/imagine-public/share-images/990ddf90-8f34-42b1-81a5-39c509d62ff7.jpg", + "mimeType": "image/jpeg", + "audioUrls": [], + "childPosts": [ + { + "id": "640ddff1-e090-46e9-9595-b956cdd38775", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-22T17:17:34.830503Z", + "prompt": "", + "mediaType": "MEDIA_POST_TYPE_VIDEO", + "mediaUrl": "https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/generated/640ddff1-e090-46e9-9595-b956cdd38775/generated_video.mp4", + "mimeType": "video/mp4", + "originalPostId": "990ddf90-8f34-42b1-81a5-39c509d62ff7", + "audioUrls": [], + "childPosts": [], + "originalPrompt": "", + "mode": "normal", + "modelName": "imagine_xdit_1" + }, + { + "id": "a1057c67-399f-4a21-8e3d-eb7f48cf8765", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-22T17:14:22.396685Z", + "prompt": "", + "mediaType": "MEDIA_POST_TYPE_VIDEO", + "mediaUrl": "https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/generated/a1057c67-399f-4a21-8e3d-eb7f48cf8765/generated_video.mp4", + "mimeType": "video/mp4", + "originalPostId": "990ddf90-8f34-42b1-81a5-39c509d62ff7", + "audioUrls": [], + "childPosts": [], + "originalPrompt": "", + "mode": "normal", + "modelName": "imagine_xdit_1" + } + ], + "originalPrompt": "A dog wearing oversized glasses is held by a hand, with another dog nuzzling it near a window with a city view.", + "userInteractionStatus": { + "likeStatus": true + }, + "modelName": "imagine_h_1", + "thumbnailImageUrl": "https://imagine-public.x.ai/cdn-cgi/image/width=500,fit=scale-down,format=auto/imagine-public/share-images/990ddf90-8f34-42b1-81a5-39c509d62ff7.jpg" + }, + { + "id": "5ef078a9-e4af-4354-a469-abd5d60a3882", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-22T04:24:17.856448Z", + "prompt": "A rainy train platform with a sleek train, illuminated street lamps, wet cobblestones, and scattered autumn leaves, creating a moody, atmospheric scene.", + "mediaType": "MEDIA_POST_TYPE_IMAGE", + "mediaUrl": "https://imagine-public.x.ai/imagine-public/share-images/5ef078a9-e4af-4354-a469-abd5d60a3882.jpg", + "mimeType": "image/jpeg", + "audioUrls": [], + "childPosts": [ + { + "id": "45c32947-3431-458f-82cf-1a1f1e5288ee", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-22T04:24:43.661840Z", + "prompt": "", + "mediaType": "MEDIA_POST_TYPE_VIDEO", + "mediaUrl": "https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/generated/45c32947-3431-458f-82cf-1a1f1e5288ee/generated_video.mp4", + "mimeType": "video/mp4", + "originalPostId": "5ef078a9-e4af-4354-a469-abd5d60a3882", + "audioUrls": [], + "childPosts": [], + "originalPrompt": "", + "mode": "normal", + "modelName": "imagine_xdit_1" + } + ], + "originalPrompt": "", + "userInteractionStatus": { + "likeStatus": true + }, + "modelName": "imagine_h_1", + "thumbnailImageUrl": "https://imagine-public.x.ai/cdn-cgi/image/width=500,fit=scale-down,format=auto/imagine-public/share-images/5ef078a9-e4af-4354-a469-abd5d60a3882.jpg" + }, + { + "id": "21a79085-e206-4b0b-88ac-5f2b7a453e45", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-21T23:40:41.993980Z", + "prompt": "A black panther lounges on an ornate chandelier in a lavish, gilded room with multiple chandeliers and elegant decor.", + "mediaType": "MEDIA_POST_TYPE_IMAGE", + "mediaUrl": "https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/21a79085-e206-4b0b-88ac-5f2b7a453e45/content", + "mimeType": "image/jpeg", + "audioUrls": [], + "childPosts": [ + { + "id": "8e340f1f-f477-4b52-9337-58d1a6696ff6", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-21T23:41:07.708119Z", + "prompt": "", + "mediaType": "MEDIA_POST_TYPE_VIDEO", + "mediaUrl": "https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/generated/8e340f1f-f477-4b52-9337-58d1a6696ff6/generated_video.mp4", + "mimeType": "video/mp4", + "originalPostId": "21a79085-e206-4b0b-88ac-5f2b7a453e45", + "audioUrls": [], + "childPosts": [], + "originalPrompt": "", + "mode": "normal", + "modelName": "imagine_xdit_1" + } + ], + "originalPrompt": "", + "userInteractionStatus": { + "likeStatus": true + }, + "modelName": "imagine_h_1" + }, + { + "id": "6136f31a-4aaf-48e0-ab86-fe9871feca91", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-21T23:33:38.986385Z", + "prompt": "a photorealistic pirate ship sailing", + "mediaType": "MEDIA_POST_TYPE_IMAGE", + "mediaUrl": "https://imagine-public.x.ai/imagine-public/images/6136f31a-4aaf-48e0-ab86-fe9871feca91.png", + "mimeType": "image/png", + "audioUrls": [], + "childPosts": [ + { + "id": "30e27f72-da40-48ff-8ddf-2a064434da99", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-21T23:34:05.919219Z", + "prompt": "", + "mediaType": "MEDIA_POST_TYPE_VIDEO", + "mediaUrl": "https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/generated/30e27f72-da40-48ff-8ddf-2a064434da99/generated_video.mp4", + "mimeType": "video/mp4", + "originalPostId": "6136f31a-4aaf-48e0-ab86-fe9871feca91", + "audioUrls": [], + "childPosts": [], + "originalPrompt": "the pirate ship is sailing and suddenly gets attacked by a sea monster", + "mode": "custom", + "modelName": "imagine_xdit_1" + } + ], + "originalPrompt": "a photorealistic pirate ship sailing", + "userInteractionStatus": { + "likeStatus": true + }, + "modelName": "imagine_h_1", + "thumbnailImageUrl": "https://imagine-public.x.ai/cdn-cgi/image/width=500,fit=scale-down,format=auto/imagine-public/images/6136f31a-4aaf-48e0-ab86-fe9871feca91.png" + }, + { + "id": "dcecdbec-12b1-4614-bf7a-edf86fd10fbc", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-21T03:47:54.420776Z", + "prompt": "a video of a dog eating a cake", + "mediaType": "MEDIA_POST_TYPE_IMAGE", + "mediaUrl": "https://imagine-public.x.ai/imagine-public/images/dcecdbec-12b1-4614-bf7a-edf86fd10fbc.png", + "mimeType": "image/png", + "audioUrls": [], + "childPosts": [ + { + "id": "f54b0aef-b320-4987-8f31-f89152f501a6", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-21T03:48:20.217760Z", + "prompt": "", + "mediaType": "MEDIA_POST_TYPE_VIDEO", + "mediaUrl": "https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/generated/f54b0aef-b320-4987-8f31-f89152f501a6/generated_video.mp4", + "mimeType": "video/mp4", + "originalPostId": "dcecdbec-12b1-4614-bf7a-edf86fd10fbc", + "audioUrls": [], + "childPosts": [], + "originalPrompt": "", + "mode": "extremely-crazy", + "modelName": "imagine_xdit_1" + } + ], + "originalPrompt": "a video of a dog eating a cake", + "userInteractionStatus": { + "likeStatus": true + }, + "modelName": "imagine_h_1", + "thumbnailImageUrl": "https://imagine-public.x.ai/cdn-cgi/image/width=500,fit=scale-down,format=auto/imagine-public/images/dcecdbec-12b1-4614-bf7a-edf86fd10fbc.png" + } + ], + "nextCursor": "1761018527111" +} diff --git a/crates/api_clients/grok_client/src/requests/media_list/metadata/media_list_request_curl.txt b/crates/api_clients/grok_client/src/requests/media_posts/list_media_posts/_metadata/older/media_list_request_curl.txt similarity index 100% rename from crates/api_clients/grok_client/src/requests/media_list/metadata/media_list_request_curl.txt rename to crates/api_clients/grok_client/src/requests/media_posts/list_media_posts/_metadata/older/media_list_request_curl.txt diff --git a/crates/api_clients/grok_client/src/requests/media_list/metadata/media_list_request_page2_cursor_curl.txt b/crates/api_clients/grok_client/src/requests/media_posts/list_media_posts/_metadata/older/media_list_request_page2_cursor_curl.txt similarity index 100% rename from crates/api_clients/grok_client/src/requests/media_list/metadata/media_list_request_page2_cursor_curl.txt rename to crates/api_clients/grok_client/src/requests/media_posts/list_media_posts/_metadata/older/media_list_request_page2_cursor_curl.txt diff --git a/crates/api_clients/grok_client/src/requests/media_list/metadata/media_list_response.json b/crates/api_clients/grok_client/src/requests/media_posts/list_media_posts/_metadata/older/media_list_response.json similarity index 100% rename from crates/api_clients/grok_client/src/requests/media_list/metadata/media_list_response.json rename to crates/api_clients/grok_client/src/requests/media_posts/list_media_posts/_metadata/older/media_list_response.json diff --git a/crates/api_clients/grok_client/src/requests/media_list/metadata/media_list_response_empty.json b/crates/api_clients/grok_client/src/requests/media_posts/list_media_posts/_metadata/older/media_list_response_empty.json similarity index 100% rename from crates/api_clients/grok_client/src/requests/media_list/metadata/media_list_response_empty.json rename to crates/api_clients/grok_client/src/requests/media_posts/list_media_posts/_metadata/older/media_list_response_empty.json diff --git a/crates/api_clients/grok_client/src/requests/media_posts/list_media_posts/grok_list_media_posts.rs b/crates/api_clients/grok_client/src/requests/media_posts/list_media_posts/grok_list_media_posts.rs new file mode 100644 index 0000000000..7c78ecbc28 --- /dev/null +++ b/crates/api_clients/grok_client/src/requests/media_posts/list_media_posts/grok_list_media_posts.rs @@ -0,0 +1,224 @@ +use crate::client::browser_user_agents::FIREFOX_143_MAC_USER_AGENT; +use crate::datatypes::api::file_id::FileId; +use crate::datatypes::api::post_id::PostId; +use crate::datatypes::api::user_id::UserId; +use crate::error::grok_client_error::GrokClientError; +use crate::error::grok_error::GrokError; +use crate::error::grok_generic_api_error::GrokGenericApiError; +use crate::requests::media_posts::list_media_posts::request::{FilterData, MediaPostListRawRequest}; +use crate::requests::media_posts::list_media_posts::response::{PostItem, PostListRawResponse}; +use crate::requests::upload_file::grok_upload_file::{GrokUploadFile, GrokUploadFileResponse}; +use crate::utils::user_and_file_id_to_image_url::user_and_file_id_to_image_url; +use log::{error, info}; +use std::time::Duration; +use wreq::header::{ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, CACHE_CONTROL, CONNECTION, CONTENT_TYPE, COOKIE, ORIGIN, PRAGMA, REFERER, TE, USER_AGENT}; +use wreq::Client; +use wreq_util::Emulation; + +const MEDIA_LIST_URL: &str = "https://grok.com/rest/media/post/list"; + +/// Request builder +pub struct GrokMediaPostListRequest<'a> { + pub cookie: &'a str, + + /// Optional cursor + pub cursor: Option, + + pub request_timeout: Option, +} + +/// Response type +#[derive(Debug, Clone)] +pub struct GrokMediaPostList { + /// List of posts + pub posts: Vec, + + /// Cursor for the next items. + pub next_cursor: Option, +} + +#[derive(Debug, Clone)] +pub struct MediaPost { + pub post_id: PostId, + + /// Only populated if there's a video and all the video data is present. + pub video_data: Option +} + +#[derive(Debug, Clone)] +pub struct VideoData { + pub file_id: FileId, + pub video_url: String, + pub prompt: Option, +} + +impl <'a> GrokMediaPostListRequest<'a> { + + pub async fn send(&self) -> Result { + info!("Configuring client..."); + + let client = Client::builder() + .emulation(Emulation::Firefox143) + .build() + .map_err(|err| GrokClientError::WreqClientError(err))?; + + // TODO: Headers were from Chromium, not Firefox. Partial implementation. + let mut request_builder = client.post(MEDIA_LIST_URL) + // TODO: Missing header "baggage" + // TODO: Missing header "sentry-trace" + // TODO: Missing header "traceparent" + // TODO: Missing header "x-statsig-id" + // TODO: Missing header "x-xai-request-id" + //.header(CACHE_CONTROL, "no-cache") // NB: Not present in firefox + //.header(PRAGMA, "no-cache") // NB: Not present in firefox + //.header("sec-ch-ua", "") // NB: Not present in firefox + //.header("sec-ch-ua-mobile", "") // NB: Not present in firefox + //.header("sec-ch-ua-platform", "") // NB: Not present in firefox + .header(USER_AGENT, FIREFOX_143_MAC_USER_AGENT) + .header(ACCEPT, "*/*") + .header(ACCEPT_LANGUAGE, "en-US,en;q=0.5") + .header(ACCEPT_ENCODING, "gzip, deflate, br, zstd") + .header(REFERER, "https://grok.com/imagine/favorites") + .header(CONTENT_TYPE, "application/json") + .header(ORIGIN, "https://grok.com") + .header("Sec-GPC", "1") + .header(CONNECTION, "keep-alive") + .header(COOKIE, self.cookie.to_string()) + .header("sec-fetch-dest", "empty") + .header("sec-fetch-mode", "cors") + .header("sec-fetch-site", "same-origin") + .header("Priority", "u=4") + .header(TE, "trailers"); + + if let Some(timeout) = self.request_timeout { + request_builder = request_builder.timeout(timeout); + } + + let request_body = MediaPostListRawRequest { + limit: 40, + filter: FilterData { + source: "MEDIA_POST_SOURCE_LIKED".to_string(), + }, + }; + + let http_request = request_builder.json(&request_body) + .build() + .map_err(|err| { + error!("Error building create media request: {:?}", err); + GrokClientError::WreqClientError(err) + })?; + + let response = client.execute(http_request) + .await + .map_err(|err| { + error!("Error during create media: {:?}", err); + GrokGenericApiError::WreqError(err) + })?; + + let status = response.status(); + + let response_body = response.text() + .await + .map_err(|err| { + error!("Error reading Grok create media response body: {:?}", err); + GrokGenericApiError::WreqError(err) + })?; + + // TODO: Handle errors (Cloudflare, Grok, etc.) + if !status.is_success() { + error!("Upload file request returned an error (code {}) : {:?}", status.as_u16(), response_body); + //return Err(classify_general_http_status_code_and_body(status, response_body)); + } + + let response : PostListRawResponse = serde_json::from_str(&response_body) + .map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?; + + let mut posts = Vec::new(); + + for post in response.posts.into_iter() { + let post_id = post.id; + + // If the child video doesn't have a prompt, this parent prompt could + // be a VLM-based auto-prompt we can copy to the video. + let maybe_parent_prompt = empty_to_none(post.prompt); + + let mut video_data = None; + + for child in post.child_posts.into_iter() { + // Maybe children can have more than one video? + if let Some(url) = child.media_url { + let mut has_video = false; + if url.to_lowercase().contains(".mp4") { + has_video = true; + } + if has_video { + let mut maybe_video_prompt = empty_to_none(child.original_prompt); + + if maybe_video_prompt.is_none() { + maybe_video_prompt = maybe_parent_prompt.clone(); + } + + video_data = Some(VideoData { + file_id: FileId(child.id), + video_url: url, + prompt: maybe_video_prompt, + }); + } + } + } + + posts.push(MediaPost { + post_id: PostId(post_id), + video_data, + }) + } + + Ok(GrokMediaPostList { + posts, + next_cursor: response.next_cursor, + }) + } +} + +pub fn empty_to_none(opt: Option) -> Option { + match opt.as_deref() { + None => None, + Some("") => None, + _ => opt, + } +} + + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::get_test_cookies::get_test_cookies; + use crate::test_utils::setup_test_logging::setup_test_logging; + use errors::AnyhowResult; + use log::LevelFilter; + + #[tokio::test] + #[ignore] // Client side tests only + async fn create_media_post() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Info); + + let cookies = get_test_cookies()?; + + let request = GrokMediaPostListRequest { + cookie: &cookies, + request_timeout: None, + cursor: None, + }; + + let result = request.send().await?; + + println!("[test] Next Cursor: {:?}", result.next_cursor); + + for post in result.posts { + println!("[test] Post : {:?}", post); + } + + assert_eq!(1, 2); + Ok(()) + } +} diff --git a/crates/api_clients/grok_client/src/requests/media_posts/list_media_posts/mod.rs b/crates/api_clients/grok_client/src/requests/media_posts/list_media_posts/mod.rs new file mode 100644 index 0000000000..2f3476f8e8 --- /dev/null +++ b/crates/api_clients/grok_client/src/requests/media_posts/list_media_posts/mod.rs @@ -0,0 +1,3 @@ +mod response; +mod request; +pub mod grok_list_media_posts; \ No newline at end of file diff --git a/crates/api_clients/grok_client/src/requests/media_posts/list_media_posts/request.rs b/crates/api_clients/grok_client/src/requests/media_posts/list_media_posts/request.rs new file mode 100644 index 0000000000..89d9a47123 --- /dev/null +++ b/crates/api_clients/grok_client/src/requests/media_posts/list_media_posts/request.rs @@ -0,0 +1,24 @@ +use serde::Serialize; + +#[derive(Serialize)] +pub struct MediaPostListRawRequest { + /// eg. 40 + pub limit: usize, + pub filter: FilterData, +} + +#[derive(Serialize)] +pub struct FilterData { + /// eg. "MEDIA_POST_SOURCE_LIKED" + pub source: String, +} + +/* +{ + "limit":40, + "filter":{ + "source":"MEDIA_POST_SOURCE_LIKED" + } +} +*/ + diff --git a/crates/api_clients/grok_client/src/requests/media_posts/list_media_posts/response.rs b/crates/api_clients/grok_client/src/requests/media_posts/list_media_posts/response.rs new file mode 100644 index 0000000000..2d6eac94cc --- /dev/null +++ b/crates/api_clients/grok_client/src/requests/media_posts/list_media_posts/response.rs @@ -0,0 +1,88 @@ +use serde::Deserialize; + +#[derive(Deserialize, Debug)] +pub (crate) struct PostListRawResponse { + pub posts: Vec, + + #[serde(rename = "nextCursor")] + pub next_cursor: Option, +} + +#[derive(Deserialize, Debug)] +pub (crate) struct PostItem { + /// The "id" of the post. + /// This is how we assert video task completion + pub id: String, + + /// Sometimes this is a VLM-generated prompt + /// This might become the prompt of the video if the video has no prompt. + pub prompt: Option, + + /// eg. `MEDIA_POST_TYPE_IMAGE` + #[serde(rename = "mediaType")] + pub media_type: Option, + + /// eg. "https://imagine-public.x.ai/imagine-public/share-images/abd9331b-c5ac-49cc-8bec-c410c7efde98.jpg" + #[serde(rename = "mediaUrl")] + pub media_url: Option, + + #[serde(rename = "childPosts")] + pub child_posts: Vec, +} + +#[derive(Deserialize, Debug)] +pub (crate) struct ChildPostItem { + pub id: String, + + /// eg. `MEDIA_POST_TYPE_VIDEO` + #[serde(rename = "mediaType")] + pub media_type: Option, + + /// eg. "https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/generated/ecec690e-15bd-4f5f-b4e9-9b8733c3fd69/generated_video.mp4" + #[serde(rename = "mediaUrl")] + pub media_url: Option, + + /// The unmodified user prompt + #[serde(rename = "originalPrompt")] + pub original_prompt: Option, +} + +/* +{ + "posts": [ + { + "id": "abd9331b-c5ac-49cc-8bec-c410c7efde98", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-30T23:49:37.583477Z", + "prompt": "", + "mediaType": "MEDIA_POST_TYPE_IMAGE", + "mediaUrl": "https://imagine-public.x.ai/imagine-public/share-images/abd9331b-c5ac-49cc-8bec-c410c7efde98.jpg", + "mimeType": "image/jpeg", + "audioUrls": [], + "childPosts": [ + { + "id": "ecec690e-15bd-4f5f-b4e9-9b8733c3fd69", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-30T23:50:04.862487Z", + "prompt": "", + "mediaType": "MEDIA_POST_TYPE_VIDEO", + "mediaUrl": "https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/generated/ecec690e-15bd-4f5f-b4e9-9b8733c3fd69/generated_video.mp4", + "mimeType": "video/mp4", + "originalPostId": "abd9331b-c5ac-49cc-8bec-c410c7efde98", + "audioUrls": [], + "childPosts": [], + "originalPrompt": "an anime girl runs away from a giant t-rex", + "mode": "custom", + "modelName": "imagine_xdit_1" + } + ], + "originalPrompt": "", + "userInteractionStatus": { + "likeStatus": true + }, + "modelName": "imagine_h_1", + "thumbnailImageUrl": "https://imagine-public.x.ai/cdn-cgi/image/width=500,fit=scale-down,format=auto/imagine-public/share-images/abd9331b-c5ac-49cc-8bec-c410c7efde98.jpg" + }, + { + "id": "3c896963-8e6b-4e71-83e1-4253b2bf2ac8", + */ \ No newline at end of file diff --git a/crates/api_clients/grok_client/src/requests/media_posts/mod.rs b/crates/api_clients/grok_client/src/requests/media_posts/mod.rs new file mode 100644 index 0000000000..4e09b02490 --- /dev/null +++ b/crates/api_clients/grok_client/src/requests/media_posts/mod.rs @@ -0,0 +1,3 @@ +pub mod create_media_post; +pub mod like_media_post; +pub mod list_media_posts; diff --git a/crates/api_clients/grok_client/src/requests/mod.rs b/crates/api_clients/grok_client/src/requests/mod.rs index 7e60330501..cc125f1174 100644 --- a/crates/api_clients/grok_client/src/requests/mod.rs +++ b/crates/api_clients/grok_client/src/requests/mod.rs @@ -1,7 +1,6 @@ -pub mod create_media_post; -pub mod download_file; +pub mod download_video_file; pub mod image_websocket; pub mod index_page; -pub mod like_media; +pub mod media_posts; pub mod upload_file; pub mod video_chat; diff --git a/crates/api_clients/grok_client/src/requests/video_chat/_metadata/oct_30/requests_from_rust.txt b/crates/api_clients/grok_client/src/requests/video_chat/_metadata/oct_30/requests_from_rust.txt new file mode 100644 index 0000000000..1cd895b207 --- /dev/null +++ b/crates/api_clients/grok_client/src/requests/video_chat/_metadata/oct_30/requests_from_rust.txt @@ -0,0 +1,14 @@ +{"result":{"conversation":{"conversationId":"02ff7c84-32b2-4e0c-9f24-303f1ffd3ede","title":"New conversation","starred":false,"createTime":"2025-10-30T21:44:37.430582Z","modifyTime":"2025-10-30T21:44:37.430898Z","systemPromptName":"","temporary":true,"mediaTypes":[],"workspaces":[],"taskResult":null}}} +{"result":{"response":{"userResponse":{"responseId":"afb171f2-0425-4b0c-b7c4-3321e450d7e4","message":"https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/47f9ebba-7709-4b00-b9cb-7404bf84e720/content The hero shoots an arrow and the camera zooms out to follow the arrow --mode=custom","sender":"human","createTime":"2025-10-30T21:44:37.454212976Z","manual":false,"partial":false,"shared":false,"query":"","queryType":"","webSearchResults":[],"xpostIds":[],"xposts":[],"generatedImageUrls":[],"imageAttachments":[],"fileAttachments":["47f9ebba-7709-4b00-b9cb-7404bf84e720"],"cardAttachmentsJson":[],"fileUris":[],"fileAttachmentsMetadata":[],"isControl":false,"steps":[],"imageEditUris":[],"mediaTypes":[],"webpageUrls":[],"metadata":{"modelConfigOverride":{"modelMap":{"videoGenModelConfig":{"parentPostId":"47f9ebba-7709-4b00-b9cb-7404bf84e720"}}}},"citedWebSearchResults":[],"toolResponses":[],"model":"grok-3","ragResults":[],"citedRagResults":[],"searchProductResults":[]},"isThinking":false,"isSoftStop":false,"responseId":"afb171f2-0425-4b0c-b7c4-3321e450d7e4"}}} +{"result":{"response":{"streamingVideoGenerationResponse":{"videoId":"c2d026ea-7fed-4e7e-b081-e358fa0257dd","progress":1,"audioUrls":[],"videoPrompt":"The hero shoots an arrow and the camera zooms out to follow the arrow","imageReference":"https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/47f9ebba-7709-4b00-b9cb-7404bf84e720/content","audioTranscripts":[],"moderated":false,"mode":"custom","modelName":"imagine_xdit_1"},"isThinking":false,"isSoftStop":false,"responseId":"708d847f-4516-4dee-8d8d-8b59c4afca6d"}}} +{"result":{"response":{"streamingVideoGenerationResponse":{"videoId":"c2d026ea-7fed-4e7e-b081-e358fa0257dd","progress":5,"audioUrls":[],"videoPrompt":"The hero shoots an arrow and the camera zooms out to follow the arrow","imageReference":"https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/47f9ebba-7709-4b00-b9cb-7404bf84e720/content","audioTranscripts":[],"moderated":false,"mode":"custom","modelName":"imagine_xdit_1"},"isThinking":false,"isSoftStop":false,"responseId":"708d847f-4516-4dee-8d8d-8b59c4afca6d"}}} +{"result":{"response":{"streamingVideoGenerationResponse":{"videoId":"c2d026ea-7fed-4e7e-b081-e358fa0257dd","progress":14,"audioUrls":[],"videoPrompt":"The hero shoots an arrow and the camera zooms out to follow the arrow","imageReference":"https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/47f9ebba-7709-4b00-b9cb-7404bf84e720/content","audioTranscripts":[],"moderated":false,"mode":"custom","modelName":"imagine_xdit_1"},"isThinking":false,"isSoftStop":false,"responseId":"708d847f-4516-4dee-8d8d-8b59c4afca6d"}}} +{"result":{"response":{"streamingVideoGenerationResponse":{"videoId":"c2d026ea-7fed-4e7e-b081-e358fa0257dd","progress":27,"audioUrls":[],"videoPrompt":"The hero shoots an arrow and the camera zooms out to follow the arrow","imageReference":"https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/47f9ebba-7709-4b00-b9cb-7404bf84e720/content","audioTranscripts":[],"moderated":false,"mode":"custom","modelName":"imagine_xdit_1"},"isThinking":false,"isSoftStop":false,"responseId":"708d847f-4516-4dee-8d8d-8b59c4afca6d"}}} +{"result":{"response":{"streamingVideoGenerationResponse":{"videoId":"c2d026ea-7fed-4e7e-b081-e358fa0257dd","progress":40,"audioUrls":[],"videoPrompt":"The hero shoots an arrow and the camera zooms out to follow the arrow","imageReference":"https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/47f9ebba-7709-4b00-b9cb-7404bf84e720/content","audioTranscripts":[],"moderated":false,"mode":"custom","modelName":"imagine_xdit_1"},"isThinking":false,"isSoftStop":false,"responseId":"708d847f-4516-4dee-8d8d-8b59c4afca6d"}}} +{"result":{"response":{"streamingVideoGenerationResponse":{"videoId":"c2d026ea-7fed-4e7e-b081-e358fa0257dd","progress":53,"audioUrls":[],"videoPrompt":"The hero shoots an arrow and the camera zooms out to follow the arrow","imageReference":"https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/47f9ebba-7709-4b00-b9cb-7404bf84e720/content","audioTranscripts":[],"moderated":false,"mode":"custom","modelName":"imagine_xdit_1"},"isThinking":false,"isSoftStop":false,"responseId":"708d847f-4516-4dee-8d8d-8b59c4afca6d"}}} +{"result":{"response":{"streamingVideoGenerationResponse":{"videoId":"c2d026ea-7fed-4e7e-b081-e358fa0257dd","progress":63,"audioUrls":[],"videoPrompt":"The hero shoots an arrow and the camera zooms out to follow the arrow","imageReference":"https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/47f9ebba-7709-4b00-b9cb-7404bf84e720/content","audioTranscripts":[],"moderated":false,"mode":"custom","modelName":"imagine_xdit_1"},"isThinking":false,"isSoftStop":false,"responseId":"708d847f-4516-4dee-8d8d-8b59c4afca6d"}}} +{"result":{"response":{"streamingVideoGenerationResponse":{"videoId":"c2d026ea-7fed-4e7e-b081-e358fa0257dd","progress":95,"audioUrls":[],"videoPrompt":"The hero shoots an arrow and the camera zooms out to follow the arrow","imageReference":"https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/47f9ebba-7709-4b00-b9cb-7404bf84e720/content","audioTranscripts":[],"moderated":false,"mode":"custom","modelName":"imagine_xdit_1"},"isThinking":false,"isSoftStop":false,"responseId":"708d847f-4516-4dee-8d8d-8b59c4afca6d"}}} +{"result":{"response":{"token":"I generated a video with the prompt: 'The hero shoots an arrow and the camera zooms out to follow the arrow'","isThinking":false,"isSoftStop":false,"responseId":"708d847f-4516-4dee-8d8d-8b59c4afca6d"}}} +{"result":{"response":{"token":"","isThinking":false,"isSoftStop":true,"responseId":"708d847f-4516-4dee-8d8d-8b59c4afca6d"}}} +{"result":{"response":{"modelResponse":{"responseId":"708d847f-4516-4dee-8d8d-8b59c4afca6d","message":"I generated a video with the prompt: 'The hero shoots an arrow and the camera zooms out to follow the arrow'","sender":"ASSISTANT","createTime":"2025-10-30T21:45:00.017434210Z","parentResponseId":"afb171f2-0425-4b0c-b7c4-3321e450d7e4","manual":false,"partial":false,"shared":false,"query":"","queryType":"","webSearchResults":[],"xpostIds":[],"xposts":[],"generatedImageUrls":[],"imageAttachments":[],"fileAttachments":[],"cardAttachmentsJson":[],"fileUris":[],"fileAttachmentsMetadata":[],"isControl":false,"steps":[],"imageEditUris":[],"mediaTypes":[],"webpageUrls":[],"metadata":{"deepsearchPreset":"","request_metadata":{"effort":"low","mode":"fast","model":"grok-3"},"request_trace_id":"4d76908d4346cdfd3285735eaf5ef4cc"},"citedWebSearchResults":[],"toolResponses":[],"model":"grok-3","requestMetadata":{"model":"grok-3","mode":"MODEL_MODE_FAST","effort":"LOW"},"ragResults":[],"citedRagResults":[],"searchProductResults":[]},"isThinking":false,"isSoftStop":false,"responseId":"708d847f-4516-4dee-8d8d-8b59c4afca6d"}}} +{"result":{"title":{"newTitle":"Video: Hero Shoots Arrow, Camera Zooms"}}} \ No newline at end of file diff --git a/crates/api_clients/grok_client/src/requests/video_chat/grok_video_gen_chat_conversation.rs b/crates/api_clients/grok_client/src/requests/video_chat/grok_video_gen_chat_conversation.rs index fc41068e23..ed294841e8 100644 --- a/crates/api_clients/grok_client/src/requests/video_chat/grok_video_gen_chat_conversation.rs +++ b/crates/api_clients/grok_client/src/requests/video_chat/grok_video_gen_chat_conversation.rs @@ -12,18 +12,21 @@ use crate::error::grok_generic_api_error::GrokGenericApiError; use crate::requests::index_page::signature::generate_xsid::{generate_xsid, GenerateXsidArgs}; use crate::requests::upload_file::grok_upload_file::{GrokUploadFile, GrokUploadFileResponse}; use crate::requests::video_chat::parse_video_id::parse_video_id; +use crate::requests::video_chat::parse_video_id_from_partial_byte_stream_buffer::parse_video_id_from_partial_byte_stream_buffer; use crate::requests::video_chat::request::{CreateChatConversationWireRequest, ModelConfigOverride, ModelMap, ResponseMetadata, ToolOverrides, VideoGenModelConfig}; use crate::utils::user_and_file_id_to_image_url::user_and_file_id_to_image_url; +use futures::StreamExt; use log::{debug, error, info, warn}; use std::time::Duration; use uuid::Uuid; use wreq::header::{ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, CACHE_CONTROL, CONNECTION, CONTENT_TYPE, COOKIE, ORIGIN, PRAGMA, REFERER, TE, USER_AGENT}; -use wreq::Client; +use wreq::{Client, Request, RequestBuilder, Response}; use wreq_util::Emulation; const CHAT_CONVERSATION_URL: &str = "https://grok.com/rest/app-chat/conversations/new"; /// Request builder +/// NOTE: Without sending a "like" request after generation, the video will likely never render! pub struct GrokVideoGenChatConversationBuilder<'a> { pub file_id: &'a FileId, pub media_type: VideoMediaPostType, @@ -54,15 +57,123 @@ pub struct GrokVideoGenChatConversationResponse { pub video_file_id: Option, } -impl <'a> GrokVideoGenChatConversationBuilder<'a> { - pub async fn send(&self) -> Result { - info!("Configuring client..."); +/// Partial response type +/// This is from an incomplete stream +#[derive(Debug)] +pub struct GrokVideoGenChatConversationPartialResponse { + pub video_file_id: Option, +} - let client = Client::builder() +impl <'a> GrokVideoGenChatConversationBuilder<'a> { + + /// Send the request and only wait long enough to get the video ID. + pub async fn stream_only_video_id(&self) -> Result { + info!("Configuring client and request..."); + let client = Self::build_client()?; + let http_request = self.build_request(&client)?; + + info!("Sending request..."); + let response = Self::send_request(&client, http_request).await?; + + info!("Reading status..."); + let status = response.status(); + + info!("Video Generation Enqueue Status: {:?}", status); + + let mut stream = response.bytes_stream(); + + let mut buffer = Vec::with_capacity(1024 * 1024); + + let mut file_id = None; + + while let Some(chunk) = stream.next().await { + if let Ok(bytes) = chunk { + buffer.extend(bytes); + } + + if let Some(id) = parse_video_id_from_partial_byte_stream_buffer(&buffer) { + file_id = Some(id); + info!("Got video id; ending stream early..."); + break; + } + } + + if file_id.is_none() { + warn!("Request ended without video id!"); + if let Some(id) = parse_video_id_from_partial_byte_stream_buffer(&buffer) { + file_id = Some(id); + } + } + + Ok(GrokVideoGenChatConversationPartialResponse { + video_file_id: file_id, + }) + } + + + pub async fn send(&self) -> Result { + info!("Configuring client and request..."); + let client = Self::build_client()?; + let http_request = self.build_request(&client)?; + + info!("Sending request..."); + let response = Self::send_request(&client, http_request).await?; + + info!("Reading status..."); + let status = response.status(); + + info!("Video Generation Enqueue Status: {:?}", status); + + info!("Reading body..."); + + /* + Reading the body is what takes time. + 18:03:09.085612 [INFO] - Configuring client... + 18:03:09.089810 [INFO] - Sending request... + 18:03:09.089872 [INFO] - Pending response... + 18:03:09.274033 [INFO] - Reading status... + 18:03:09.274262 [INFO] - Video Generation Enqueue Status: 200 + 18:03:09.274304 [INFO] - Reading body... <-- This is what takes a while. + 18:03:35.186344 [INFO] - Read body. + */ + + /// TODO: Handle anti-bot detection + /// Body: {"error":{"code":7,"message":"Request rejected by anti-bot rules.","details":[]}} + let response_body = &response.text() + .await + .map_err(|err| { + error!("Error reading Grok create media response body: {:?}", err); + GrokGenericApiError::WreqError(err) + })?; + + info!("Read body."); + + // TODO: Handle unsuccessful request, cloudflare, etc. + if !status.is_success() { + warn!("Not successful enqueuing video gen (code: {}) : {:?}", status.as_u16(), response_body); + // error!("Upload file request returned an error (code {}) : {:?}", status.as_u16(), response_body); + // return Err(classify_general_http_status_code_and_body(status, response_body)); + } + + // TODO: Just for now... + println!("Video Body: {:?}", response_body); + + let file_id = parse_video_id(&response_body); + + Ok(GrokVideoGenChatConversationResponse { + video_file_id: file_id, + }) + } + + + fn build_client() -> Result { + Ok(Client::builder() .emulation(Emulation::Firefox143) .build() - .map_err(|err| GrokClientError::WreqClientError(err))?; + .map_err(|err| GrokClientError::WreqClientError(err))?) + } + fn build_request(&self, client: &Client) -> Result { let xai_request_id = Uuid::new_v4().to_string(); let sentry_trace_header = self.sentry_trace.to_http_request_header(); @@ -83,24 +194,23 @@ impl <'a> GrokVideoGenChatConversationBuilder<'a> { let mut request_builder = client.post(CHAT_CONVERSATION_URL) //.header(PRAGMA, "no-cache") // Not on firefox //.header(CACHE_CONTROL, "no-cache") // Not on firefox + //.header("priority", "u=1, i") // Different on firefox + //.header("sec-ch-ua", "") // NB: NOT IN FIREFOX + //.header("sec-ch-ua-mobile", "") // NB: NOT IN FIREFOX + //.header("sec-ch-ua-platform", "") // NB: NOT IN FIREFOX + //.header("traceparent", "") // TODO: Not sure where this originates .header(USER_AGENT, FIREFOX_143_MAC_USER_AGENT) .header(ACCEPT, "*/*") .header(ACCEPT_LANGUAGE, "en-US,en;q=0.5") .header(ACCEPT_ENCODING, "gzip, deflate, br, zstd") .header(REFERER, "https://grok.com/imagine/favorites") - //.header("traceparent", "") // TODO ?? .header(CONTENT_TYPE, "application/json") .header("x-xai-request-id", xai_request_id) .header("x-statsig-id", x_statsig_id) .header("sentry-trace", sentry_trace_header) .header("baggage", &self.baggage.0) - // TODO: Missing header "traceparent" **** .header(ORIGIN, "https://grok.com") - //.header("priority", "u=1, i") // Different on firefox .header("priority", "u=4") - //.header("sec-ch-ua", "") // TODO / NB: NOT IN FIREFOX - //.header("sec-ch-ua-mobile", "") // TODO / NB: NOT IN FIREFOX - //.header("sec-ch-ua-platform", "") // TODO / NB: NOT IN FIREFOX .header("sec-fetch-dest", "empty") .header("sec-fetch-mode", "cors") .header("sec-fetch-site", "same-origin") @@ -153,40 +263,16 @@ impl <'a> GrokVideoGenChatConversationBuilder<'a> { GrokClientError::WreqClientError(err) })?; - let response = client.execute(http_request) + Ok(http_request) + } + + async fn send_request(client: &Client, request: Request) -> Result { + Ok(client.execute(request) .await .map_err(|err| { error!("Error during create media: {:?}", err); GrokGenericApiError::WreqError(err) - })?; - - let status = response.status(); - - info!("Video Generation Enqueue Status: {:?}", status); - - /// Body: {"error":{"code":7,"message":"Request rejected by anti-bot rules.","details":[]}} - let response_body = &response.text() - .await - .map_err(|err| { - error!("Error reading Grok create media response body: {:?}", err); - GrokGenericApiError::WreqError(err) - })?; - - // TODO: Handle unsuccessful request - if !status.is_success() { - warn!("Not successful enqueuing video gen (code: {}) : {:?}", status.as_u16(), response_body); - // error!("Upload file request returned an error (code {}) : {:?}", status.as_u16(), response_body); - // return Err(classify_general_http_status_code_and_body(status, response_body)); - } - - // TODO: Just for now... - info!("Video Body: {:?}", response_body); - - let file_id = parse_video_id(&response_body); - - Ok(GrokVideoGenChatConversationResponse { - video_file_id: file_id, - }) + })?) } } @@ -196,12 +282,20 @@ mod tests { use crate::datatypes::file_upload_spec::FileUploadSpec; use crate::recipes::request_client_secrets::{request_client_secrets, RequestClientSecretsArgs}; use crate::test_utils::get_test_cookies::get_typed_test_cookies; + use crate::test_utils::setup_test_logging::setup_test_logging; + use crate::utils::user_and_file_id_to_video_url::user_and_file_id_to_video_url; use errors::AnyhowResult; + use log::LevelFilter; #[tokio::test] #[ignore] async fn create_video() -> AnyhowResult<()> { - //setup_test_logging(LevelFilter::Trace); + setup_test_logging(LevelFilter::Info); + + //let file_id = FileId("990ddf90-8f34-42b1-81a5-39c509d62ff7".to_string()); // Mochi + + let image_path = "/Users/bt/Pictures/Zelda 64 Art/7j8baxv9m8u61.jpg"; + let maybe_prompt = Some("The hero shoots an arrow and it hits a skeleton monster"); let cookies = get_typed_test_cookies()?; @@ -209,10 +303,8 @@ mod tests { cookies: &cookies, }).await?; - //let file_id = FileId("990ddf90-8f34-42b1-81a5-39c509d62ff7".to_string()); // Mochi - let upload_request = GrokUploadFile { - file: FileUploadSpec::Path("/Users/bt/dev/storyteller/storyteller-rust/test_data/image/mochi.jpg"), + file: FileUploadSpec::Path(image_path), cookie: cookies.to_string(), request_timeout: None, }; @@ -230,7 +322,7 @@ mod tests { let request = GrokVideoGenChatConversationBuilder { file_id: &file_id, media_type: VideoMediaPostType::UserUploadedImage, - prompt: Some("dog shakes the glasses off"), + prompt: maybe_prompt, cookie: cookies.as_str(), user_id: &secrets.user_id, @@ -246,6 +338,76 @@ mod tests { let result = request.send().await?; println!("Result: {:?}", result); + + let video_id = result.video_file_id.expect("should have video id"); + + // NOTE: WITHOUT A "LIKE" REQUEST, THIS FILE WILL LIKELY CEASE TO EXIST. + let video_url = user_and_file_id_to_video_url(&secrets.user_id, &video_id, false); + + println!("Video URL: {:?}", video_url); + + assert_eq!(1, 2); + Ok(()) + } + + + #[tokio::test] + #[ignore] + async fn stream_video_id() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Info); + + let image_path = "/Users/bt/Pictures/Zelda 64 Art/7j8baxv9m8u61.jpg"; + let maybe_prompt = Some("The hero puts down his bow"); + + let cookies = get_typed_test_cookies()?; + + let secrets = request_client_secrets(RequestClientSecretsArgs { + cookies: &cookies, + }).await?; + + let upload_request = GrokUploadFile { + file: FileUploadSpec::Path(image_path), + cookie: cookies.to_string(), + request_timeout: None, + }; + + let upload_result = upload_request.upload().await?; + + let file_id = upload_result.file_id.expect("upload should have file_id"); + + println!("Verification Token: {:?}", secrets.verification_token); + println!("Sentry Trace: {:?}", secrets.sentry_trace); + println!("Numbers: {:?}", secrets.numbers); + println!("Svg Path: {:?}", secrets.svg_path_data); + println!("Baggage: {:?}", secrets.baggage); + + let request = GrokVideoGenChatConversationBuilder { + file_id: &file_id, + media_type: VideoMediaPostType::UserUploadedImage, + prompt: maybe_prompt, + + cookie: cookies.as_str(), + user_id: &secrets.user_id, + baggage: &secrets.baggage, + sentry_trace: &secrets.sentry_trace, + verification_token: &secrets.verification_token, + svg_data: &secrets.svg_path_data, + numbers: &secrets.numbers, + + request_timeout: None, + }; + + let result = request.stream_only_video_id().await?; + + println!("Result: {:?}", result); + + let video_id = result.video_file_id.expect("should have video id"); + + // NOTE: WITHOUT A "LIKE" REQUEST, THIS FILE WILL LIKELY CEASE TO EXIST. + let video_url = user_and_file_id_to_video_url(&secrets.user_id, &video_id, false); + + println!("Video URL: {:?}", video_url); + assert_eq!(1, 2); Ok(()) } diff --git a/crates/api_clients/grok_client/src/requests/video_chat/mod.rs b/crates/api_clients/grok_client/src/requests/video_chat/mod.rs index 495393e973..075805f744 100644 --- a/crates/api_clients/grok_client/src/requests/video_chat/mod.rs +++ b/crates/api_clients/grok_client/src/requests/video_chat/mod.rs @@ -1,3 +1,4 @@ pub mod grok_video_gen_chat_conversation; pub (super) mod request; -mod parse_video_id; \ No newline at end of file +mod parse_video_id; +mod parse_video_id_from_partial_byte_stream_buffer; \ No newline at end of file diff --git a/crates/api_clients/grok_client/src/requests/video_chat/parse_video_id.rs b/crates/api_clients/grok_client/src/requests/video_chat/parse_video_id.rs index c21ac33051..e104d6816c 100644 --- a/crates/api_clients/grok_client/src/requests/video_chat/parse_video_id.rs +++ b/crates/api_clients/grok_client/src/requests/video_chat/parse_video_id.rs @@ -3,7 +3,7 @@ use once_cell::sync::Lazy; use regex::Regex; /// Find the video id in the response (which is streamed, appended JSON) -// {\"videoId\":\"34fa0313-3cde-4693-b046-319093374dbe\" +/// eg. {\"videoId\":\"34fa0313-3cde-4693-b046-319093374dbe\" static JSON_REGEX: Lazy = Lazy::new(|| { // NB: Should be a 36 character UUID. Regex::new(r#"\\?"videoI[dD]\\?":\s*\\?"([a-zA-Z0-9-]{25,})\\?",?"#) diff --git a/crates/api_clients/grok_client/src/requests/video_chat/parse_video_id_from_partial_byte_stream_buffer.rs b/crates/api_clients/grok_client/src/requests/video_chat/parse_video_id_from_partial_byte_stream_buffer.rs new file mode 100644 index 0000000000..66c2da434b --- /dev/null +++ b/crates/api_clients/grok_client/src/requests/video_chat/parse_video_id_from_partial_byte_stream_buffer.rs @@ -0,0 +1,69 @@ +use crate::datatypes::api::file_id::FileId; +use bstr::ByteSlice; +use once_cell::sync::Lazy; +use regex::Regex; + +/// Find the video id in the response (which is streamed, appended JSON) +/// eg. {\"videoId\":\"34fa0313-3cde-4693-b046-319093374dbe\" +static JSON_REGEX: Lazy = Lazy::new(|| { + // NB: Should be a 36 character UUID. + Regex::new(r#"\\?"videoI[dD]\\?":\s*\\?"([a-zA-Z0-9-]{25,})\\?",?"#) + .expect("Regex should parse") +}); + +/// Look for this in the incomplete byte stream to try parsing it out. +const VIDEO_BYTES : &[u8] = b"videoI"; + +/// Parse the video ID from the streamed response +pub fn parse_video_id_from_partial_byte_stream_buffer(bytes: &[u8]) -> Option { + let found = bytes.find(VIDEO_BYTES).is_some(); + if !found { + return None; + } + + let partial_json = bytes.to_str_lossy(); + let maybe_meta = scrape_video_id_via_regex(&partial_json); + + if let Some(meta) = maybe_meta { + return Some(FileId(meta)) + } + + None +} + +fn scrape_video_id_via_regex(html: &str) -> Option { + let captures = JSON_REGEX.captures(html)?; + let capture = captures.get(1)?; + Some(capture.as_str().to_string()) +} + +#[cfg(test)] +mod tests { + use crate::requests::video_chat::parse_video_id_from_partial_byte_stream_buffer::parse_video_id_from_partial_byte_stream_buffer; + use errors::AnyhowResult; + + #[test] + fn present_in_partial_bytes() -> AnyhowResult<()> { + let partial_bytes = b"}}}\n{\"result\":{\"response\":{\"streamingVideoGenerationResponse\":{\"videoId\":\"34fa0313-3cde-4693-b046-319093374dbe\",\"pro"; + let video_id = parse_video_id_from_partial_byte_stream_buffer(partial_bytes).unwrap(); + let expected = "34fa0313-3cde-4693-b046-319093374dbe"; + assert_eq!(&video_id.0, expected); + Ok(()) + } + + #[test] + fn field_present_but_data_incomplete() -> AnyhowResult<()> { + let partial_bytes = b"Response\":{\"videoId\":\"34fa0313-3cde-4693-b046-"; + let result = parse_video_id_from_partial_byte_stream_buffer(partial_bytes); + assert!(result.is_none()); + Ok(()) + } + + #[test] + fn not_present() -> AnyhowResult<()> { + let partial_bytes = b"}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}]"; + let result = parse_video_id_from_partial_byte_stream_buffer(partial_bytes); + assert!(result.is_none()); + Ok(()) + } +} diff --git a/crates/desktop/artcraft/Cargo.toml b/crates/desktop/artcraft/Cargo.toml index 81025533cc..053bd3a9be 100644 --- a/crates/desktop/artcraft/Cargo.toml +++ b/crates/desktop/artcraft/Cargo.toml @@ -32,6 +32,7 @@ cookie_store.workspace = true enums.workspace = true errors.workspace = true filesys.workspace = true +grok_client.workspace = true idempotency.workspace = true images.workspace = true memory_store.workspace = true diff --git a/crates/desktop/artcraft/src/core/artcraft_error.rs b/crates/desktop/artcraft/src/core/artcraft_error.rs index e6efa82d41..8830a77973 100644 --- a/crates/desktop/artcraft/src/core/artcraft_error.rs +++ b/crates/desktop/artcraft/src/core/artcraft_error.rs @@ -1,3 +1,4 @@ +use grok_client::error::grok_error::GrokError; use std::error::Error; use std::fmt::{Display, Formatter}; use storyteller_client::error::storyteller_error::StorytellerError; @@ -7,6 +8,8 @@ pub enum ArtcraftError { AnyhowError(anyhow::Error), DecodeError(base64::DecodeError), IoError(std::io::Error), + // Service errors + GrokError(GrokError), StorytellerError(StorytellerError), } @@ -18,6 +21,7 @@ impl Display for ArtcraftError { Self::AnyhowError(e) => write!(f, "AnyhowError: {:?}", e), Self::DecodeError(e) => write!(f, "DecodeError: {:?}", e), Self::IoError(e) => write!(f, "IoError: {:?}", e), + Self::GrokError(e) => write!(f, "GrokError: {:?}", e), Self::StorytellerError(e) => write!(f, "StorytellerError: {:?}", e), } } @@ -41,8 +45,15 @@ impl From for ArtcraftError { } } +impl From for ArtcraftError { + fn from(value: GrokError) -> Self { + Self::GrokError(value) + } +} + impl From for ArtcraftError { fn from(value: StorytellerError) -> Self { Self::StorytellerError(value) } } + diff --git a/crates/desktop/artcraft/src/core/commands/enqueue/generate_error.rs b/crates/desktop/artcraft/src/core/commands/enqueue/generate_error.rs index 000f236273..5f16dd9b5e 100644 --- a/crates/desktop/artcraft/src/core/commands/enqueue/generate_error.rs +++ b/crates/desktop/artcraft/src/core/commands/enqueue/generate_error.rs @@ -1,6 +1,7 @@ use crate::core::artcraft_error::ArtcraftError; use base64::DecodeError; use errors::AnyhowError; +use grok_client::error::grok_error::GrokError; use midjourney_client::error::midjourney_error::MidjourneyError; use openai_sora_client::error::sora_error::SoraError; use storyteller_client::error::storyteller_error::StorytellerError; @@ -53,6 +54,7 @@ pub enum BadInputReason { #[derive(Debug)] pub enum MissingCredentialsReason { + NeedsGrokCredentials, NeedsFalApiKey, NeedsMidjourneyCredentials, NeedsMidjourneyUserId, @@ -76,6 +78,7 @@ pub enum BillingProvider { #[derive(Debug)] pub enum ProviderFailureReason { + GrokError(GrokError), //Fal(FalErrorPlus), MidjourneyError(MidjourneyError), /// NB: The midjourney client doesn't categorize all errors, so we have to do so on our end. @@ -114,6 +117,10 @@ impl GenerateError { Self::MissingCredentials(MissingCredentialsReason::NeedsFalApiKey) } + pub fn needs_grok_credentials() -> Self { + Self::MissingCredentials(MissingCredentialsReason::NeedsGrokCredentials) + } + pub fn needs_midjourney_credentials() -> Self { Self::MissingCredentials(MissingCredentialsReason::NeedsMidjourneyCredentials) } @@ -139,6 +146,7 @@ impl From for GenerateError { ArtcraftError::AnyhowError(e) => Self::AnyhowError(e), ArtcraftError::DecodeError(e) => Self::DecodeError(e), ArtcraftError::IoError(e) => Self::IoError(e), + ArtcraftError::GrokError(e) => Self::ProviderFailure(ProviderFailureReason::GrokError(e)), ArtcraftError::StorytellerError(e) => Self::ProviderFailure(ProviderFailureReason::StorytellerError(e)), } } @@ -150,6 +158,12 @@ impl From for GenerateError { // } //} +impl From for GenerateError { + fn from(value: GrokError) -> Self { + Self::ProviderFailure(ProviderFailureReason::GrokError(value)) + } +} + impl From for GenerateError { fn from(value: MidjourneyError) -> Self { Self::ProviderFailure(ProviderFailureReason::MidjourneyError(value)) diff --git a/crates/desktop/artcraft/src/core/commands/enqueue/image_to_video/enqueue_image_to_video_command.rs b/crates/desktop/artcraft/src/core/commands/enqueue/image_to_video/enqueue_image_to_video_command.rs index e4a2b19eac..c3565a7f5b 100644 --- a/crates/desktop/artcraft/src/core/commands/enqueue/image_to_video/enqueue_image_to_video_command.rs +++ b/crates/desktop/artcraft/src/core/commands/enqueue/image_to_video/enqueue_image_to_video_command.rs @@ -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::grok::handle_grok_video::handle_grok_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}; @@ -20,12 +21,16 @@ use log::{error, info, warn}; use serde_derive::{Deserialize, Serialize}; use tauri::{AppHandle, State}; use tokens::tokens::media_files::MediaFileToken; +use crate::services::grok::state::grok_credential_manager::GrokCredentialManager; /// This is used in the Tauri command bridge. /// Don't change the serializations without coordinating with the frontend. #[derive(Deserialize, Debug, Copy, Clone)] #[serde(rename_all = "snake_case")] pub enum VideoModel { + #[serde(rename = "grok")] + Grok, + #[serde(rename = "kling_1.6_pro")] Kling16Pro, @@ -115,6 +120,7 @@ pub async fn enqueue_image_to_video_command( app_data_root: State<'_, AppDataRoot>, provider_priority_store: State<'_, ProviderPriorityStore>, task_database: State<'_, TaskDatabase>, + grok_creds_manager: State<'_, GrokCredentialManager>, storyteller_creds_manager: State<'_, StorytellerCredentialManager>, sora_task_queue: State<'_, SoraTaskQueue>, sora_creds_manager: State<'_, SoraCredentialManager>, @@ -129,6 +135,7 @@ pub async fn enqueue_image_to_video_command( &app_data_root, &provider_priority_store, &task_database, + &grok_creds_manager, &sora_creds_manager, &storyteller_creds_manager, ).await; @@ -200,11 +207,21 @@ pub async fn handle_request( app_data_root: &AppDataRoot, provider_priority_store: &ProviderPriorityStore, task_database: &TaskDatabase, + grok_creds_manager: &GrokCredentialManager, sora_creds_manager: &SoraCredentialManager, storyteller_creds_manager: &StorytellerCredentialManager, ) -> Result { let result = match request.model { + Some(VideoModel::Grok) => { + handle_grok_video( + &request, + app, + app_data_root, + app_env_configs, + grok_creds_manager, + ).await + } Some(VideoModel::Sora2) => { handle_sora2_video( &request, diff --git a/crates/desktop/artcraft/src/core/commands/enqueue/image_to_video/generic/handle_video_artcraft.rs b/crates/desktop/artcraft/src/core/commands/enqueue/image_to_video/generic/handle_video_artcraft.rs index d9112d57af..b8c6a9aaa4 100644 --- a/crates/desktop/artcraft/src/core/commands/enqueue/image_to_video/generic/handle_video_artcraft.rs +++ b/crates/desktop/artcraft/src/core/commands/enqueue/image_to_video/generic/handle_video_artcraft.rs @@ -71,6 +71,11 @@ pub async fn handle_video_artcraft( None => { return Err(GenerateError::no_model_specified()); } + Some(VideoModel::Grok) => { + return Err(GenerateError::AnyhowError( + anyhow!("wrong logic: another branch should handle this: {:?}", + request.model))); + } Some(VideoModel::Sora2) => { return Err(GenerateError::AnyhowError( anyhow!("wrong logic: another branch should handle this: {:?}", diff --git a/crates/desktop/artcraft/src/core/commands/enqueue/image_to_video/grok/handle_grok_video.rs b/crates/desktop/artcraft/src/core/commands/enqueue/image_to_video/grok/handle_grok_video.rs new file mode 100644 index 0000000000..ab798a0e77 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/enqueue/image_to_video/grok/handle_grok_video.rs @@ -0,0 +1,125 @@ +use crate::core::commands::enqueue::generate_error::{BadInputReason, 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, SoraOrientation}; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::events::functional_events::show_provider_login_modal_event::ShowProviderLoginModalEvent; +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::core::state::provider_priority::{Provider, ProviderPriorityStore}; +use crate::core::utils::download_media_file_to_temp_dir::download_media_file_to_temp_dir; +use crate::services::grok::state::grok_credential_manager::GrokCredentialManager; +use crate::services::grok::util::upload_image_media_file_to_grok::{upload_image_media_file_to_grok, UploadImageMediaFileToGrok}; +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}; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::tasks::task_type::TaskType; +use grok_client::credentials::grok_cookies::GrokCookies; +use grok_client::credentials::grok_full_credentials::GrokFullCredentials; +use grok_client::datatypes::file_upload_spec::FileUploadSpec; +use grok_client::recipes::request_client_secrets::{request_client_secrets, RequestClientSecretsArgs}; +use grok_client::recipes::upload_image_and_generate_video::{upload_image_and_generate_video, UploadImageAndGenerateVideo}; +use log::{error, info, warn}; +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::requests::generate_sora2_video::generate_sora2_video::{GenerateSora2VideoArgs, Orientation}; +use std::time::Duration; +use tauri::AppHandle; + +const GROK_IMAGE_UPLOAD_TIMEOUT: Duration = Duration::from_millis(1000 * 30); // 30 seconds + +pub async fn handle_grok_video( + request: &EnqueueImageToVideoRequest, + app: &AppHandle, + app_data_root: &AppDataRoot, + app_env_configs: &AppEnvConfigs, + grok_credential_manager: &GrokCredentialManager, +) -> Result { + + let creds = get_grok_creds(app, grok_credential_manager).await?; + + let image_token = match request.image_media_token.as_ref() { + Some(token) => token, + None => { + return Err(GenerateError::BadInput(BadInputReason::InvalidNumberOfInputImages { min: 1, max: 1, provided: 0 })) + } + }; + + info!("Downloading image media file..."); + + let local_image_file = download_media_file_to_temp_dir( + &app_env_configs, + &app_data_root, + image_token, + ).await?; + + info!("Calling Grok Video generate..."); + + let upload = upload_image_and_generate_video(UploadImageAndGenerateVideo { + full_credentials: &creds, + file: FileUploadSpec::Path(local_image_file.path()), + prompt: request.prompt.as_deref(), + wait_for_generation: false, + individual_request_timeout: Some(GROK_IMAGE_UPLOAD_TIMEOUT) + }).await; + + let post_id = match upload { + Err(err) => { + error!("Failed to use Grok video: {:?}", err); + return Err(GenerateError::from(err)); + } + Ok(result) => { + info!("Successfully enqueued Grok Video: post_id = {:?}", result.post_id); + result.post_id + } + }; + + Ok(TaskEnqueueSuccess { + provider: GenerationProvider::Grok, + model: Some(GenerationModel::GrokVideo), + provider_job_id: Some(post_id.to_string()), + task_type: TaskType::VideoGeneration, + }) +} + +async fn get_grok_creds(app: &AppHandle, grok_credential_manager: &GrokCredentialManager) -> Result { + if let Some(creds) = grok_credential_manager.maybe_copy_full_credentials()? { + return Ok(creds); + } + + let cookies = match grok_credential_manager.maybe_copy_cookie_store()? { + Some(cookies) => cookies.to_cookie_string(), + None => { + warn!("No Grok Cookie stored. Must login."); + ShowProviderLoginModalEvent::send_for_provider(GenerationProvider::Grok, &app); + return Err(GenerateError::needs_grok_credentials()) + } + }; + + let cookies = GrokCookies::new(cookies); + + info!("Requesting Grok client secrets..."); + + let upgraded = request_client_secrets(RequestClientSecretsArgs { + cookies: &cookies, + }).await; + + match upgraded { + Err(err) => { + error!("Failed to fetch Grok client secrets: {}", err); // NB: Fall-through + } + Ok(secrets) => { + let full_creds = GrokFullCredentials::from_cookies_and_client_secrets(cookies, secrets); + grok_credential_manager.replace_full_credentials(full_creds.clone())?; + grok_credential_manager.persist_to_disk()?; + return Ok(full_creds) + } + } + + warn!("Grok upgrade failed. Try logging in again..."); + + ShowProviderLoginModalEvent::send_for_provider(GenerationProvider::Grok, &app); + Err(GenerateError::needs_grok_credentials()) +} diff --git a/crates/desktop/artcraft/src/core/commands/enqueue/image_to_video/grok/mod.rs b/crates/desktop/artcraft/src/core/commands/enqueue/image_to_video/grok/mod.rs new file mode 100644 index 0000000000..045dcc9439 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/enqueue/image_to_video/grok/mod.rs @@ -0,0 +1 @@ +pub (super) mod handle_grok_video; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/commands/enqueue/image_to_video/mod.rs b/crates/desktop/artcraft/src/core/commands/enqueue/image_to_video/mod.rs index 48074cb5ee..895bd733e0 100644 --- a/crates/desktop/artcraft/src/core/commands/enqueue/image_to_video/mod.rs +++ b/crates/desktop/artcraft/src/core/commands/enqueue/image_to_video/mod.rs @@ -1,3 +1,4 @@ pub mod enqueue_image_to_video_command; mod generic; -pub mod sora2; \ No newline at end of file +pub mod sora2; +mod grok; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/commands/enqueue/task_enqueue_success.rs b/crates/desktop/artcraft/src/core/commands/enqueue/task_enqueue_success.rs index 4ea87ff7c7..2d3297f03e 100644 --- a/crates/desktop/artcraft/src/core/commands/enqueue/task_enqueue_success.rs +++ b/crates/desktop/artcraft/src/core/commands/enqueue/task_enqueue_success.rs @@ -68,6 +68,7 @@ impl TaskEnqueueSuccess{ Some(GenerationModel::Gemini25Flash) => Some(TaskModelType::Gemini25Flash), Some(GenerationModel::GptImage1) => Some(TaskModelType::GptImage1), Some(GenerationModel::Recraft3) => Some(TaskModelType::Recraft3), + Some(GenerationModel::GrokVideo) => Some(TaskModelType::GrokVideo), Some(GenerationModel::Kling21Pro) => Some(TaskModelType::Kling21Pro), Some(GenerationModel::Kling21Master) => Some(TaskModelType::Kling21Master), Some(GenerationModel::Seedance10Lite) => Some(TaskModelType::Seedance10Lite), diff --git a/crates/desktop/artcraft/src/core/events/generation_events/common.rs b/crates/desktop/artcraft/src/core/events/generation_events/common.rs index c704aa87f1..688164d404 100644 --- a/crates/desktop/artcraft/src/core/events/generation_events/common.rs +++ b/crates/desktop/artcraft/src/core/events/generation_events/common.rs @@ -42,10 +42,12 @@ pub enum GenerationModel { #[serde(rename = "midjourney")] Midjourney, + // Generic Grok video model, version unknown. + #[serde(rename = "grok_video")] + GrokVideo, // TODO: Should be Kling16Pro #[serde(rename = "kling_1.6")] Kling1_6, - #[serde(rename = "kling_2.0")] Kling2_0, #[serde(rename = "kling_2.1_master")] diff --git a/crates/desktop/artcraft/src/core/lifecycle/startup/handle_tauri_startup.rs b/crates/desktop/artcraft/src/core/lifecycle/startup/handle_tauri_startup.rs index 5464035eb4..967c3c0b60 100644 --- a/crates/desktop/artcraft/src/core/lifecycle/startup/handle_tauri_startup.rs +++ b/crates/desktop/artcraft/src/core/lifecycle/startup/handle_tauri_startup.rs @@ -10,6 +10,7 @@ use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; use crate::core::state::artcraft_platform_info::ArtcraftPlatformInfo; use crate::core::state::data_dir::app_data_root::AppDataRoot; use crate::services::grok::state::grok_credential_manager::GrokCredentialManager; +use crate::services::grok::threads::grok_video_task_polling::grok_video_task_polling_thread::grok_video_task_polling_thread; use crate::services::midjourney::state::midjourney_credential_manager::MidjourneyCredentialManager; use crate::services::midjourney::threads::midjourney_long_polling_thread::midjourney_long_polling_thread; use crate::services::sora::state::sora_credential_manager::SoraCredentialManager; @@ -67,6 +68,15 @@ pub async fn handle_tauri_startup( &sora_task_queue, )?; + tauri::async_runtime::spawn(grok_video_task_polling_thread( + app.clone(), + app_env_configs.clone(), + root.clone(), + task_database.clone(), + grok_creds_manager.clone(), + storyteller_creds_manager.clone(), + )); + tauri::async_runtime::spawn(midjourney_long_polling_thread( app.clone(), app_env_configs.clone(), diff --git a/crates/desktop/artcraft/src/services/grok/mod.rs b/crates/desktop/artcraft/src/services/grok/mod.rs index 132c03a929..285ecaa7e1 100644 --- a/crates/desktop/artcraft/src/services/grok/mod.rs +++ b/crates/desktop/artcraft/src/services/grok/mod.rs @@ -1,3 +1,5 @@ pub mod commands; pub mod state; +pub mod threads; +pub mod util; pub mod windows; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/services/grok/state/grok_credential_holder.rs b/crates/desktop/artcraft/src/services/grok/state/grok_credential_holder.rs new file mode 100644 index 0000000000..119d36f764 --- /dev/null +++ b/crates/desktop/artcraft/src/services/grok/state/grok_credential_holder.rs @@ -0,0 +1,36 @@ +use cookie_store::cookie_store::CookieStore; +use grok_client::credentials::grok_client_secrets::GrokClientSecrets; +use grok_client::credentials::grok_full_credentials::GrokFullCredentials; +use grok_client::credentials::grok_user_data::GrokUserData; + +#[derive(Clone)] +pub struct GrokCredentialHolder { + /// Directly off the Tauri browser session. + /// Read once, write to disk + /// The Grok client consumes a string-only form (rather than this cookie jar) + pub browser_cookies: Option, + + /// Full credentials. + /// NOT PERSISTED TO DISK. + pub grok_full_credentials: Option, + + /// Email, user id, etc. + /// These can be persisted. + pub grok_user_data: Option, + + /// These have a different lifecycle than the other pieces and are + /// subject to change when the website changes. + /// We won't persist these. + pub grok_client_secrets: Option, +} + +impl GrokCredentialHolder { + pub fn empty() -> Self { + Self { + browser_cookies: None, + grok_full_credentials: None, + grok_user_data: None, + grok_client_secrets: None, + } + } +} diff --git a/crates/desktop/artcraft/src/services/grok/state/grok_credential_manager.rs b/crates/desktop/artcraft/src/services/grok/state/grok_credential_manager.rs index 66c8e06bb3..f8728fc060 100644 --- a/crates/desktop/artcraft/src/services/grok/state/grok_credential_manager.rs +++ b/crates/desktop/artcraft/src/services/grok/state/grok_credential_manager.rs @@ -1,7 +1,12 @@ use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::services::grok::state::grok_credential_holder::GrokCredentialHolder; use crate::services::grok::state::grok_serializable_state::{GrokSerializableState, SERIALIZABLE_GROK_STATE_VERSION}; use cookie_store::cookie_store::CookieStore; use errors::AnyhowResult; +use grok_client::credentials::grok_client_secrets::GrokClientSecrets; +use grok_client::credentials::grok_cookies::GrokCookies; +use grok_client::credentials::grok_full_credentials::GrokFullCredentials; +use grok_client::credentials::grok_user_data::GrokUserData; use log::{info, warn}; use std::fs::read_to_string; use std::sync::{Arc, RwLock}; @@ -9,7 +14,8 @@ use std::sync::{Arc, RwLock}; #[derive(Clone)] pub struct GrokCredentialManager { // TODO: Put last write/last read timestamps on these. - cookies: Arc>>, + credential_data: Arc>, + //user_info: Arc>>, app_data_root: AppDataRoot, } @@ -17,153 +23,182 @@ pub struct GrokCredentialManager { impl GrokCredentialManager { pub fn initialize_empty(app_data_root: &AppDataRoot) -> Self { Self { - cookies: Arc::new(RwLock::new(None)), + credential_data: Arc::new(RwLock::new(GrokCredentialHolder::empty())), app_data_root: app_data_root.clone(), } } pub fn initialize_from_disk_infallible(app_data_root: &AppDataRoot) -> Self { - let mut cookies; - //let mut user_info; + let mut credential_data; let result = GrokSerializableState::read_from_disk(app_data_root); match result { Err(err) => { warn!("Failed to read grok state from disk: {:?}", err); - cookies = Arc::new(RwLock::new(None)); - //user_info = Arc::new(RwLock::new(None)); + credential_data = Arc::new(RwLock::new(GrokCredentialHolder::empty())); }, Ok(None) => { - cookies = Arc::new(RwLock::new(None)); - //user_info = Arc::new(RwLock::new(None)); + credential_data = Arc::new(RwLock::new(GrokCredentialHolder::empty())); } Ok(Some(state)) => { let maybe_cookies = state.user_cookies .map(|cookies| cookies.to_cookie_store()); - //let maybe_user_info = state.user_info - // .map(|info| info.to_user_info()); - cookies = Arc::new(RwLock::new(maybe_cookies)); - //user_info = Arc::new(RwLock::new(maybe_user_info)); + + let mut user_data = None; + if let Some(user_id) = state.user_id { + user_data = Some(GrokUserData::from_id_and_email(user_id, state.user_email)); + } + + credential_data = Arc::new(RwLock::new(GrokCredentialHolder { + browser_cookies: maybe_cookies, + grok_user_data: user_data, + grok_full_credentials: None, // NB: We don't want to keep this on disk. It goes stale. + grok_client_secrets: None, // NB: We don't want to keep this on disk. It goes stale. + })); } }; Self { - cookies, + credential_data, app_data_root: app_data_root.clone(), } } pub fn maybe_copy_cookie_store(&self) -> anyhow::Result> { - match self.cookies.read() { + match self.credential_data.read() { Err(err) => Err(anyhow::anyhow!("Failed to acquire write lock: {:?}", err)), - Ok(store) => Ok(store.clone()), + Ok(holder) => Ok(holder.browser_cookies.clone()), } } - //pub fn maybe_copy_user_info(&self) -> anyhow::Result> { - // match self.user_info.read() { - // Err(err) => Err(anyhow::anyhow!("Failed to acquire read lock: {:?}", err)), - // Ok(info) => Ok(info.clone()), - // } - //} + pub fn maybe_copy_cookie_header_string(&self) -> anyhow::Result> { + let maybe_cookies = self.maybe_copy_cookie_store()?; + let maybe_cookies = maybe_cookies.map(|cookies| { + cookies.to_cookie_string() + }); + Ok(maybe_cookies) + } + + pub fn maybe_copy_typed_cookies(&self) -> anyhow::Result> { + let maybe_cookies = self.maybe_copy_cookie_header_string()?; + let maybe_cookies = maybe_cookies.map(|cookies| { + GrokCookies::new(cookies) + }); + Ok(maybe_cookies) + } + + pub fn maybe_copy_full_credentials(&self) -> anyhow::Result> { + match self.credential_data.read() { + Err(err) => Err(anyhow::anyhow!("Failed to acquire read lock: {:?}", err)), + Ok(holder) => Ok(holder.grok_full_credentials.clone()), + } + } + + pub fn maybe_copy_client_secrets(&self) -> anyhow::Result> { + match self.credential_data.read() { + Err(err) => Err(anyhow::anyhow!("Failed to acquire read lock: {:?}", err)), + Ok(holder) => Ok(holder.grok_client_secrets.clone()), + } + } pub fn replace_cookie_store(&self, store: CookieStore) -> anyhow::Result<()> { - match self.cookies.write() { + match self.credential_data.write() { Err(err) => Err(anyhow::anyhow!("Failed to acquire write lock: {:?}", err)), - Ok(mut current_store) => { - *current_store = Some(store); + Ok(mut holder) => { + holder.browser_cookies = Some(store); Ok(()) } } } - //pub fn replace_user_info(&self, user_info: MidjourneyUserInfo) -> anyhow::Result<()> { - // match self.user_info.write() { - // Err(err) => Err(anyhow::anyhow!("Failed to acquire write lock: {:?}", err)), - // Ok(mut current_info) => { - // *current_info = Some(user_info); - // Ok(()) - // } - // } - //} + pub fn replace_full_credentials(&self, creds: GrokFullCredentials) -> anyhow::Result<()> { + match self.credential_data.write() { + Err(err) => Err(anyhow::anyhow!("Failed to acquire write lock: {:?}", err)), + Ok(mut holder) => { + holder.grok_full_credentials = Some(creds); + Ok(()) + } + } + } + + pub fn replace_client_secrets(&self, secrets: GrokClientSecrets) -> anyhow::Result<()> { + match self.credential_data.write() { + Err(err) => Err(anyhow::anyhow!("Failed to acquire write lock: {:?}", err)), + Ok(mut holder) => { + holder.grok_client_secrets = Some(secrets); + Ok(()) + } + } + } + + // NB: This is just a heuristic. We'll add better checks later. + pub fn do_task_polling(&self) -> anyhow::Result { + self.session_appears_active() + } // NB: This is just a heuristic. We'll add better checks later. pub fn session_appears_active(&self) -> anyhow::Result { - let maybe_cookies = match self.cookies.read() { + let holder = match self.credential_data.read() { Err(err) => return Err(anyhow::anyhow!("Failed to acquire read lock: {:?}", err)), Ok(store) => store.clone(), }; - //let maybe_user_info = match self.user_info.read() { - // Err(err) => return Err(anyhow::anyhow!("Failed to acquire read lock: {:?}", err)), - // Ok(info) => info.clone(), - //}; + if holder.grok_user_data.is_some() { + return Ok(true); + } - if maybe_cookies.is_none() { + if holder.browser_cookies.is_none() { return Ok(false); } - let cookies = maybe_cookies.unwrap(); - //let user_info = maybe_user_info.unwrap(); + // TODO: Heuristic + Ok(true) - // NB: We consider the session active if we have auth cookies and a user id. - //let has_user_id = user_info.user_id.is_some(); - //let maybe_has_auth_cookies = cookie_store_has_auth_cookies(&cookies); - let maybe_has_auth_cookies = true; // TODO TODO TODO - fix this - - // Misc cookies without login cookies are ~1055 length - // AUTH_I is ~1500 length - // AUTH_R is ~500 length - let maybe_has_big_enough_cookie = cookies.calculate_approx_cookie_character_length() > 2100; - - // TODO: Heuristic should count. - // TODO: Consolidate with "login window thread" logic. - // TODO: Check timestamp of last valid request. - Ok(maybe_has_auth_cookies && maybe_has_big_enough_cookie && maybe_has_big_enough_cookie) + //let cookies = match holder.browser_cookies.as_ref() { + // None => return Ok(false), + // Some(cookies) => cookies, + //}; + //// NB: We consider the session active if we have auth cookies and a user id. + ////let has_user_id = user_info.user_id.is_some(); + ////let maybe_has_auth_cookies = cookie_store_has_auth_cookies(&cookies); + //let maybe_has_auth_cookies = true; // TODO TODO TODO - fix this + //// Misc cookies without login cookies are ~1055 length + //// AUTH_I is ~1500 length + //// AUTH_R is ~500 length + //let maybe_has_big_enough_cookie = cookies.calculate_approx_cookie_character_length() > 2100; + //// TODO: Heuristic should count. + //// TODO: Consolidate with "login window thread" logic. + //// TODO: Check timestamp of last valid request. + //Ok((maybe_has_auth_cookies && maybe_has_big_enough_cookie && maybe_has_big_enough_cookie)) } pub fn clear_credentials(&self) -> anyhow::Result<()> { - match self.cookies.write() { + match self.credential_data.write() { Err(err) => return Err(anyhow::anyhow!("Failed to acquire write lock: {:?}", err)), - Ok(mut store) => *store = None, + Ok(mut store) => *store = GrokCredentialHolder::empty(), } - //match self.user_info.write() { - // Err(err) => return Err(anyhow::anyhow!("Failed to acquire write lock: {:?}", err)), - // Ok(mut info) => *info = None, - //} Ok(()) } pub fn persist_to_disk(&self) -> anyhow::Result<()> { - let maybe_cookies = match self.cookies.read() { + let creds = match self.credential_data.read() { Err(err) => return Err(anyhow::anyhow!("Failed to acquire read lock: {:?}", err)), Ok(store) => store.clone(), }; - //let maybe_user_info = match self.user_info.read() { - // Err(err) => return Err(anyhow::anyhow!("Failed to acquire read lock: {:?}", err)), - // Ok(info) => info.clone(), - //}; - - let maybe_cookies_header= maybe_cookies - .as_ref() - .map(|cookies| cookies.to_cookie_string()); - - let maybe_cookies= maybe_cookies - .map(|cookies| cookies.to_serializable()); - - //let maybe_user_info= maybe_user_info - // .map(|info| info.to_serializable()); - - if maybe_cookies.is_none() { - info!("Nothing to write to disk, skipping."); - return Ok(()); - - } - let state = GrokSerializableState { version: SERIALIZABLE_GROK_STATE_VERSION, - user_cookies: maybe_cookies, + user_cookies: creds.browser_cookies + .as_ref() + .map(|cookies| cookies.to_serializable()), + user_id: creds.grok_user_data + .as_ref() + .map(|data| data.user_id.to_string()), + user_email: creds.grok_user_data + .as_ref() + .map(|data| data.user_email.as_ref()) + .flatten() + .map(|email| email.to_string()), }; let path = self.app_data_root.credentials_dir().get_grok_state_path(); @@ -172,7 +207,8 @@ impl GrokCredentialManager { std::fs::write(path, serialized)?; // TODO: This is just for building the client and testing. - if let Some(cookies_header) = maybe_cookies_header { + if let Some(cookies) = creds.browser_cookies.as_ref() { + let cookies_header = cookies.to_cookie_string(); let path = self.app_data_root.credentials_dir().get_grok_cookies_path(); std::fs::write(path, cookies_header)?; } diff --git a/crates/desktop/artcraft/src/services/grok/state/grok_serializable_state.rs b/crates/desktop/artcraft/src/services/grok/state/grok_serializable_state.rs index 0121fb9c4e..cb1aed6817 100644 --- a/crates/desktop/artcraft/src/services/grok/state/grok_serializable_state.rs +++ b/crates/desktop/artcraft/src/services/grok/state/grok_serializable_state.rs @@ -1,55 +1,36 @@ -use std::fs::read_to_string; +use crate::core::state::data_dir::app_data_root::AppDataRoot; use cookie_store::serialized_cookie_store::SerializableCookieStore; use serde_derive::{Deserialize, Serialize}; -use crate::core::state::data_dir::app_data_root::AppDataRoot; +use std::fs::read_to_string; pub (super) const SERIALIZABLE_GROK_STATE_VERSION: u32 = 1; +/// We only need to serialize Grok cookies. +/// Most of the other state (verification_token, etc.) can be pulled at runtime. +/// We will cache the `user_id` and `user_email`. #[derive(Serialize, Deserialize)] pub (super) struct GrokSerializableState { pub (super) version: u32, #[serde(skip_serializing_if = "Option::is_none")] pub (super) user_cookies: Option, - - //#[serde(skip_serializing_if = "Option::is_none")] - //pub (super) user_info: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub (super) user_id: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub (super) user_email: Option, } -//#[derive(Serialize, Deserialize)] -//pub (super) struct SerializableMidjourneyUserInfo { -// #[serde(skip_serializing_if = "Option::is_none")] -// pub (super) user_id: Option, -// -// #[serde(skip_serializing_if = "Option::is_none")] -// pub (super) email: Option, -// -// #[serde(skip_serializing_if = "Option::is_none")] -// pub (super) websocket_token: Option, -//} - -//impl SerializableMidjourneyUserInfo { -// pub fn to_user_info(&self) -> MidjourneyUserInfo { -// MidjourneyUserInfo { -// user_id: self.user_id -// .as_deref() -// .map(|id| MidjourneyUserId::from_str(id)), -// email: self.email.clone(), -// websocket_token: self.websocket_token.clone(), -// } -// } -//} - - impl GrokSerializableState { pub fn read_from_disk(root: &AppDataRoot) -> anyhow::Result> { - let midjourney_state_path= root.credentials_dir().get_grok_state_path(); + let state_path = root.credentials_dir().get_grok_state_path(); - if !midjourney_state_path.exists() { + if !state_path.exists() { return Ok(None); } - let contents = read_to_string(&midjourney_state_path)? + let contents = read_to_string(&state_path)? .trim() .to_string(); @@ -57,4 +38,4 @@ impl GrokSerializableState { Ok(Some(state)) } -} \ No newline at end of file +} diff --git a/crates/desktop/artcraft/src/services/grok/state/mod.rs b/crates/desktop/artcraft/src/services/grok/state/mod.rs index dc62d868a1..9ea2b969bd 100644 --- a/crates/desktop/artcraft/src/services/grok/state/mod.rs +++ b/crates/desktop/artcraft/src/services/grok/state/mod.rs @@ -1,2 +1,3 @@ pub mod grok_credential_manager; -pub mod grok_serializable_state; \ No newline at end of file +pub mod grok_serializable_state; +mod grok_credential_holder; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/services/grok/threads/grok_video_task_polling/grok_video_task_polling_thread.rs b/crates/desktop/artcraft/src/services/grok/threads/grok_video_task_polling/grok_video_task_polling_thread.rs new file mode 100644 index 0000000000..503c63764f --- /dev/null +++ b/crates/desktop/artcraft/src/services/grok/threads/grok_video_task_polling/grok_video_task_polling_thread.rs @@ -0,0 +1,364 @@ +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::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::grok::state::grok_credential_manager::GrokCredentialManager; +use crate::services::grok::util::get_or_upgrade_grok_full_credentials::get_or_update_grok_full_credentials; +use crate::services::midjourney::state::midjourney_credential_manager::MidjourneyCredentialManager; +use crate::services::midjourney::utils::download_midjourney_image::download_midjourney_image; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use artcraft_api_defs::prompts::create_prompt::CreatePromptRequest; +use artcraft_api_defs::utils::media_links_to_thumbnail_template::media_links_to_thumbnail_template; +use cookie_store::cookie_store::CookieStore; +use enums::by_table::prompts::prompt_type::PromptType; +use enums::common::generation_provider::GenerationProvider; +use enums::common::model_type::ModelType; +use enums::tauri::tasks::task_media_file_class::TaskMediaFileClass; +use enums::tauri::tasks::task_status::TaskStatus; +use errors::AnyhowResult; +use grok_client::credentials::grok_full_credentials::GrokFullCredentials; +use grok_client::error::grok_error::GrokError; +use grok_client::requests::download_video_file::download_video_file::{download_video_file, DownloadVideoFileArgs}; +use grok_client::requests::download_video_file::grok_download_video::GrokDownloadVideo; +use grok_client::requests::media_posts::list_media_posts::grok_list_media_posts::{GrokMediaPostList, GrokMediaPostListRequest, VideoData}; +use idempotency::uuid::generate_random_uuid; +use log::{error, info}; +use midjourney_client::client::midjourney_hostname::MidjourneyHostname; +use midjourney_client::credentials::midjourney_user_id::MidjourneyUserId; +use midjourney_client::endpoints::imagine::{imagine, ImagineItem, ImagineRequest, MidjourneyJobType}; +use midjourney_client::utils::get_image_url::get_image_url; +use midjourney_client::utils::image_downloader_client::ImageDownloaderClient; +use once_cell::sync::Lazy; +use sqlite_tasks::queries::list_tasks_by_provider_and_status::{list_tasks_by_provider_and_status, ListTasksByProviderAndStatusArgs, Task, TaskList}; +use sqlite_tasks::queries::update_successful_task_status_with_metadata::{update_successful_task_status_with_metadata, UpdateSuccessfulTaskArgs}; +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 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::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 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; + +pub async fn grok_video_task_polling_thread( + app_handle: AppHandle, + app_env_configs: AppEnvConfigs, + app_data_root: AppDataRoot, + task_database: TaskDatabase, + creds: GrokCredentialManager, + storyteller_creds_manager: StorytellerCredentialManager, +) -> ! { + loop { + let res = polling_loop( + &app_handle, + &app_env_configs, + &app_data_root, + &task_database, + &creds, + &storyteller_creds_manager, + ).await; + if let Err(err) = res { + error!("An error occurred: {:?}", err); + } + // NB: Only sleep if an error occurs. + tokio::time::sleep(std::time::Duration::from_millis(30_000)).await; + } +} + +async fn polling_loop( + app_handle: &AppHandle, + app_env_configs: &AppEnvConfigs, + app_data_root: &AppDataRoot, + task_database: &TaskDatabase, + grok_creds: &GrokCredentialManager, + storyteller_creds_manager: &StorytellerCredentialManager, +) -> AnyhowResult<()> { + loop { + if !grok_creds.do_task_polling()? { + tokio::time::sleep(std::time::Duration::from_millis(10_000)).await; + continue; + } + + // TODO: Graceful wait, fix this long function body + let storyteller_creds = match storyteller_creds_manager.get_credentials()? { + Some(creds) => creds, + None => { + error!("No Storyteller credentials found. Cannot proceed with Grok polling."); + tokio::time::sleep(std::time::Duration::from_millis(5_000)).await; + continue; + } + }; + + let grok_full_creds = match get_or_update_grok_full_credentials(&grok_creds).await { + Ok(creds) => creds, + Err(err) => { + info!("No full grok credentials: {:?}", err); + tokio::time::sleep(std::time::Duration::from_millis(30_000)).await; + continue; + } + }; + + let local_tasks = list_tasks_by_provider_and_status(ListTasksByProviderAndStatusArgs { + db: task_database.get_connection(), + provider: GenerationProvider::Grok, + task_statuses: &TASK_DATABASE_PENDING_STATUSES, + }).await?; + + poll_grok_tasks( + app_handle, + app_env_configs, + app_data_root, + task_database, + &grok_full_creds, + &storyteller_creds, + local_tasks, + ).await?; + + tokio::time::sleep(std::time::Duration::from_millis(2_000)).await; + } +} + +async fn poll_grok_tasks( + app_handle: &AppHandle, + app_env_configs: &AppEnvConfigs, + app_data_root: &AppDataRoot, + task_database: &TaskDatabase, + grok_full_creds: &GrokFullCredentials, + storyteller_creds: &StorytellerCredentialSet, + local_tasks: TaskList, +) -> AnyhowResult<()> { + let local_tasks = local_tasks.tasks; + + if local_tasks.is_empty() { + return Ok(()) + } + + info!("Grok tasks waiting: {:?}", local_tasks.len()); + + // Map of Grok Post ID to Local Task. + let local_tasks_by_grok_post_id = local_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::>(); + + let list_media_request = GrokMediaPostListRequest { + cookie: grok_full_creds.cookies.as_str(), + cursor: None, + request_timeout: Some(Duration::from_millis(20_000)), + }; + + let list_result = list_media_request.send().await?; + + let grok_posts = list_result.posts; + + let grok_video_posts_by_id = { + let mut hash = HashMap::new(); + for post in grok_posts.iter() { + if let Some(video_data) = &post.video_data { + hash.insert(post.post_id.to_string(), video_data.clone()); + } + } + hash + }; + + for (grok_post_id, local_task) in local_tasks_by_grok_post_id.iter() { + // TODO: Copy prompt from this. + let grok_video_data = match grok_video_posts_by_id.get(grok_post_id) { + Some(video_data) => video_data, + None => continue, + }; + + upload_grok_video( + &app_handle, + &app_env_configs, + app_data_root, + task_database, + &storyteller_creds, + grok_full_creds, + grok_post_id, + &local_task, + grok_video_data + ).await?; + + tokio::time::sleep(std::time::Duration::from_millis(2_000)).await; + } + + tokio::time::sleep(std::time::Duration::from_millis(60_000)).await; + + Ok(()) +} + +async fn upload_grok_video( + app_handle: &AppHandle, + app_env_configs: &AppEnvConfigs, + app_data_root: &AppDataRoot, + task_database: &TaskDatabase, + storyteller_creds: &StorytellerCredentialSet, + grok_full_creds: &GrokFullCredentials, + grok_post_id: &String, + local_task: &Task, + grok_video_post: &VideoData +) -> AnyhowResult<()> { + + let request = CreatePromptRequest { + uuid_idempotency_token: generate_random_uuid(), + positive_prompt: grok_video_post.prompt.clone(), + negative_prompt: None, + model_type: Some(ModelType::Midjourney), // TODO: TEMP + generation_provider: Some(GenerationProvider::Midjourney), // TODO: TEMP + }; + + let prompt_response = create_prompt( + &app_env_configs.storyteller_host, + Some(storyteller_creds), + request + ).await?; + + info!("Created prompt: {:?}", &prompt_response.prompt_token); + + let mut maybe_primary_media_file_token = None; + + info!("Downloading generated Grok video ..."); + + let tempdir = app_data_root.temp_dir().path(); + let download_filename = format!("{}.mp4", grok_post_id); + let download_path = tempdir.join(download_filename); + + let download = GrokDownloadVideo { + cookies: grok_full_creds.cookies.as_str(), + user_id: grok_full_creds.get_user_id_ref(), + file_id: &grok_video_post.file_id, + request_timeout: None, + }; + + download.download_to_path(&download_path).await?; + + let mut wait_delay = 0; + + loop { + info!("Uploading to backend..."); + + // TODO: media_files.origin_category + // TODO: media_files.maybe_prompt_token + // TODO: media_files.maybe_generation_provider + // TODO: media_files.maybe_origin_model_type + // TODO: media_files.maybe_origin_model_token (sref?) + // TODO: media_files.is_batch_generated + // TODO: media_files.maybe_batch_token + // TODO: media_files.is_user_upload + + // TODO: batch_generations.token + // TODO: batch_generations.entity_type + // TODO: batch_generations.entity_token + + 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; + + match result { + Ok(result) => { + info!("Successfully uploaded to backend: {:?}", result.media_file_token); + if maybe_primary_media_file_token.is_none() { + maybe_primary_media_file_token = Some(result.media_file_token); + } + break; + }, + Err(ApiError::TooManyRequests(_)) => { + error!("Too many requests, retrying upload after delay..."); + // If we hit a rate limit, we can retry after a short delay. + wait_delay += 10; + if wait_delay > 60 { + wait_delay = 60; + } + tokio::time::sleep(std::time::Duration::from_secs(wait_delay)).await; + continue; // Retry the upload. + } + Err(err) => { + error!("Failed to upload to backend: {:?}", err); + return Err(err.into()) + }, + } + } // End loop + + let mut maybe_cdn_url = None; + let mut maybe_thumbnail_url_template = None; + + if let Some(media_file_token) = maybe_primary_media_file_token.as_ref() { + info!("Looking up file to grab CDN and thumbnail URLs: {:?} ...", media_file_token); + + let lookup_result = get_media_file( + &app_env_configs.storyteller_host, + media_file_token, + ).await; + match lookup_result { + Ok(response) => { + maybe_cdn_url = Some(response.media_file.media_links.cdn_url.to_string()); + maybe_thumbnail_url_template = media_links_to_thumbnail_template(&response.media_file.media_links) + .map(|s| s.to_string()); + } + Err(err) => { + error!("Failed to look up media file after upload: {:?} (failing open)", err); + } + } + } + + let updated = update_successful_task_status_with_metadata(UpdateSuccessfulTaskArgs { + db: task_database.get_connection(), + task_id: &local_task.id, + maybe_batch_token: None, + maybe_primary_media_file_token: maybe_primary_media_file_token.as_ref(), + maybe_primary_media_file_class: Some(TaskMediaFileClass::Video), + maybe_primary_media_file_thumbnail_url_template: maybe_thumbnail_url_template.as_deref(), + maybe_primary_media_file_cdn_url: maybe_cdn_url.as_deref(), + }).await?; + + if !updated { + return Ok(()); // If anything breaks with queries, don't spam events. + } + + let event = GenerationCompleteEvent { + //media_file_token: result.media_file_token, + action: Some(GenerationAction::GenerateVideo), + service: GenerationServiceProvider::Midjourney, // TODO + model: None, + }; + + if let Err(err) = event.send(&app_handle) { + error!("Failed to send GenerationCompleteEvent: {:?}", err); // Fail open + } + + //let result = maybe_handle_text_to_image_complete_event( + // app_handle, + // app_env_configs, + // Some(storyteller_creds), + // local_task, + // &batch_token, + //).await; + + //if let Err(err) = result { + // error!("Failed to send text-to-image complete event: {:?}", err); + //} + + Ok(()) +} + diff --git a/crates/desktop/artcraft/src/services/grok/threads/grok_video_task_polling/mod.rs b/crates/desktop/artcraft/src/services/grok/threads/grok_video_task_polling/mod.rs new file mode 100644 index 0000000000..8a7f640414 --- /dev/null +++ b/crates/desktop/artcraft/src/services/grok/threads/grok_video_task_polling/mod.rs @@ -0,0 +1 @@ +pub mod grok_video_task_polling_thread; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/services/grok/threads/mod.rs b/crates/desktop/artcraft/src/services/grok/threads/mod.rs new file mode 100644 index 0000000000..5239c8592a --- /dev/null +++ b/crates/desktop/artcraft/src/services/grok/threads/mod.rs @@ -0,0 +1 @@ +pub mod grok_video_task_polling; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/services/grok/util/get_or_upgrade_grok_full_credentials.rs b/crates/desktop/artcraft/src/services/grok/util/get_or_upgrade_grok_full_credentials.rs new file mode 100644 index 0000000000..3aa366c887 --- /dev/null +++ b/crates/desktop/artcraft/src/services/grok/util/get_or_upgrade_grok_full_credentials.rs @@ -0,0 +1,48 @@ +use crate::core::commands::enqueue::generate_error::GenerateError; +use crate::core::events::functional_events::show_provider_login_modal_event::ShowProviderLoginModalEvent; +use crate::services::grok::state::grok_credential_manager::GrokCredentialManager; +use enums::common::generation_provider::GenerationProvider; +use grok_client::credentials::grok_cookies::GrokCookies; +use grok_client::credentials::grok_full_credentials::GrokFullCredentials; +use grok_client::error::grok_client_error::GrokClientError; +use grok_client::error::grok_error::GrokError; +use grok_client::recipes::request_client_secrets::{request_client_secrets, RequestClientSecretsArgs}; +use log::{error, info, warn}; +use tauri::AppHandle; +use crate::core::artcraft_error::ArtcraftError; + +pub async fn get_or_update_grok_full_credentials(grok_credential_manager: &GrokCredentialManager) -> Result { + if let Some(creds) = grok_credential_manager.maybe_copy_full_credentials()? { + return Ok(creds); + } + + let cookies = match grok_credential_manager.maybe_copy_cookie_store()? { + Some(cookies) => cookies.to_cookie_string(), + None => { + return Err(GrokError::Client(GrokClientError::NoCookiesPresent).into()) + } + }; + + let cookies = GrokCookies::new(cookies); + + info!("Requesting Grok client secrets..."); + + let upgraded = request_client_secrets(RequestClientSecretsArgs { + cookies: &cookies, + }).await; + + match upgraded { + Err(err) => { + error!("Failed to fetch Grok client secrets: {}", err); // NB: Fall-through + return Err(ArtcraftError::from(err)); + } + Ok(secrets) => { + let full_creds = GrokFullCredentials::from_cookies_and_client_secrets(cookies, secrets); + grok_credential_manager.replace_full_credentials(full_creds.clone())?; + grok_credential_manager.persist_to_disk()?; + return Ok(full_creds) + } + } + + warn!("Grok upgrade failed. Try logging in again..."); +} diff --git a/crates/desktop/artcraft/src/services/grok/util/mod.rs b/crates/desktop/artcraft/src/services/grok/util/mod.rs new file mode 100644 index 0000000000..c3ec90aebb --- /dev/null +++ b/crates/desktop/artcraft/src/services/grok/util/mod.rs @@ -0,0 +1,2 @@ +pub mod get_or_upgrade_grok_full_credentials; +pub mod upload_image_media_file_to_grok; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/services/grok/util/upload_image_media_file_to_grok.rs b/crates/desktop/artcraft/src/services/grok/util/upload_image_media_file_to_grok.rs new file mode 100644 index 0000000000..fb120177d8 --- /dev/null +++ b/crates/desktop/artcraft/src/services/grok/util/upload_image_media_file_to_grok.rs @@ -0,0 +1,80 @@ +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::grok::state::grok_credential_manager::GrokCredentialManager; +use crate::services::sora::state::sora_credential_manager::SoraCredentialManager; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use anyhow::anyhow; +use errors::AnyhowResult; +use grok_client::datatypes::api::file_id::FileId; +use grok_client::datatypes::file_upload_spec::FileUploadSpec; +use grok_client::requests::upload_file::grok_upload_file::GrokUploadFile; +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 GROK_IMAGE_UPLOAD_TIMEOUT: Duration = Duration::from_millis(1000 * 30); // 30 seconds + +pub struct UploadImageMediaFileToGrok<'a> { + pub storyteller_host: &'a ApiHost, + pub image_media_token: &'a MediaFileToken, + + pub app_data_root: &'a AppDataRoot, + pub grok_creds_manager: &'a GrokCredentialManager, +} + +pub struct UploadImageMediaFileToGrokResult { + pub grok_file_id: FileId, +} + +pub async fn upload_image_media_file_to_grok( + args: UploadImageMediaFileToGrok<'_>, +) -> AnyhowResult { + + info!("Calling get media file API: {:?}", args.storyteller_host); + + info!("Using media token: {:?}", args.image_media_token); + + let response = get_media_file( + args.storyteller_host, + args.image_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?; + + let cookies = args.grok_creds_manager.maybe_copy_cookie_header_string()? + .ok_or_else(|| anyhow!("Missing Grok cookies"))?; + + info!("Uploading image to Grok..."); + + let upload = GrokUploadFile { + file: FileUploadSpec::Path(filename), + cookie: cookies, + request_timeout: Some(GROK_IMAGE_UPLOAD_TIMEOUT), + }; + + let response = upload.upload().await?; + + let file_id = response.file_id + .ok_or_else(|| anyhow!("Media upload did not produce a file_id!"))?; + + Ok(UploadImageMediaFileToGrokResult { + grok_file_id: file_id, + }) +} diff --git a/crates/schema/public/enums/src/tauri/tasks/task_model_type.rs b/crates/schema/public/enums/src/tauri/tasks/task_model_type.rs index c7cd1cda3b..93b00b6c9b 100644 --- a/crates/schema/public/enums/src/tauri/tasks/task_model_type.rs +++ b/crates/schema/public/enums/src/tauri/tasks/task_model_type.rs @@ -39,6 +39,8 @@ pub enum TaskModelType { Midjourney, // Video models + #[serde(rename = "grok_video")] + GrokVideo, // Video version unspecified/unknown #[serde(rename = "kling_1.6_pro")] Kling16Pro, #[serde(rename = "kling_2.1_pro")] @@ -85,6 +87,7 @@ impl TaskModelType { Self::Recraft3 => "recraft_3", Self::Midjourney => "midjourney", // Video models + Self::GrokVideo => "grok_video", Self::Kling16Pro => "kling_1.6_pro", Self::Kling21Pro => "kling_2.1_pro", Self::Kling21Master => "kling_2.1_master", @@ -114,6 +117,7 @@ impl TaskModelType { "recraft_3" => Ok(Self::Recraft3), "midjourney" => Ok(Self::Midjourney), // Video models + "grok_video" => Ok(Self::GrokVideo), "kling_1.6_pro" => Ok(Self::Kling16Pro), "kling_2.1_pro" => Ok(Self::Kling21Pro), "kling_2.1_master" => Ok(Self::Kling21Master), @@ -146,6 +150,7 @@ impl TaskModelType { Self::Recraft3, Self::Midjourney, // Video models + Self::GrokVideo, Self::Kling16Pro, Self::Kling21Pro, Self::Kling21Master, @@ -185,6 +190,7 @@ mod tests { assert_serialization(TaskModelType::Recraft3, "recraft_3"); assert_serialization(TaskModelType::Midjourney, "midjourney"); // Video models + assert_serialization(TaskModelType::GrokVideo, "grok_video"); assert_serialization(TaskModelType::Kling16Pro, "kling_1.6_pro"); assert_serialization(TaskModelType::Kling21Pro, "kling_2.1_pro"); assert_serialization(TaskModelType::Kling21Master, "kling_2.1_master"); @@ -213,6 +219,7 @@ mod tests { assert_eq!(TaskModelType::Recraft3.to_str(), "recraft_3"); assert_eq!(TaskModelType::Midjourney.to_str(), "midjourney"); // Video models + assert_eq!(TaskModelType::GrokVideo.to_str(), "grok_video"); assert_eq!(TaskModelType::Kling16Pro.to_str(), "kling_1.6_pro"); assert_eq!(TaskModelType::Kling21Pro.to_str(), "kling_2.1_pro"); assert_eq!(TaskModelType::Kling21Master.to_str(), "kling_2.1_master"); @@ -241,6 +248,7 @@ mod tests { assert_eq!(TaskModelType::from_str("recraft_3").unwrap(), TaskModelType::Recraft3); assert_eq!(TaskModelType::from_str("midjourney").unwrap(), TaskModelType::Midjourney); // Video models + assert_eq!(TaskModelType::from_str("grok_video").unwrap(), TaskModelType::GrokVideo); assert_eq!(TaskModelType::from_str("kling_1.6_pro").unwrap(), TaskModelType::Kling16Pro); assert_eq!(TaskModelType::from_str("kling_2.1_pro").unwrap(), TaskModelType::Kling21Pro); assert_eq!(TaskModelType::from_str("kling_2.1_master").unwrap(), TaskModelType::Kling21Master); @@ -268,7 +276,7 @@ mod tests { #[test] fn all_variants() { let mut variants = TaskModelType::all_variants(); - assert_eq!(variants.len(), 21); + assert_eq!(variants.len(), 22); // Image models assert_eq!(variants.pop_first(), Some(TaskModelType::Flux1Dev)); assert_eq!(variants.pop_first(), Some(TaskModelType::Flux1Schnell)); @@ -282,6 +290,7 @@ mod tests { assert_eq!(variants.pop_first(), Some(TaskModelType::Recraft3)); assert_eq!(variants.pop_first(), Some(TaskModelType::Midjourney)); // Video models + assert_eq!(variants.pop_first(), Some(TaskModelType::GrokVideo)); assert_eq!(variants.pop_first(), Some(TaskModelType::Kling16Pro)); assert_eq!(variants.pop_first(), Some(TaskModelType::Kling21Pro)); assert_eq!(variants.pop_first(), Some(TaskModelType::Kling21Master)); diff --git a/frontend/libs/common/src/lib/enums/GenerationProvider.ts b/frontend/libs/common/src/lib/enums/GenerationProvider.ts index e25d9f1a46..2bdd04368c 100644 --- a/frontend/libs/common/src/lib/enums/GenerationProvider.ts +++ b/frontend/libs/common/src/lib/enums/GenerationProvider.ts @@ -1,5 +1,6 @@ export enum GenerationProvider { Artcraft = "artcraft", + Grok = "grok", Fal = "fal", Midjourney = "midjourney", Sora = "sora", diff --git a/frontend/libs/components/provider-setup-modal/src/lib/provider-setup-modal.tsx b/frontend/libs/components/provider-setup-modal/src/lib/provider-setup-modal.tsx index b5c6cdf1ca..8585bf5431 100644 --- a/frontend/libs/components/provider-setup-modal/src/lib/provider-setup-modal.tsx +++ b/frontend/libs/components/provider-setup-modal/src/lib/provider-setup-modal.tsx @@ -31,6 +31,7 @@ export function ProviderSetupModal({ let modalDescription; switch (provider) { + case GenerationProvider.Grok: 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; @@ -43,6 +44,9 @@ export function ProviderSetupModal({ const buttonOnClick = async () => { switch (provider) { + case GenerationProvider.Grok: + await invoke("grok_open_login_command"); // TODO: Rename in Tauri + break; case GenerationProvider.Midjourney: await invoke("midjourney_open_login_command"); break; @@ -111,12 +115,14 @@ export function ProviderSetupModal({ function getServiceProviderName(provider: GenerationProvider) : string { switch (provider) { - case GenerationProvider.Sora: - return "Sora"; + case GenerationProvider.Grok: + return "Grok"; case GenerationProvider.Fal: return "Fal"; case GenerationProvider.Midjourney: return "Midjourney"; + case GenerationProvider.Sora: + return "Sora"; case GenerationProvider.Artcraft: default: return "Artcraft"; diff --git a/frontend/libs/model-list/src/lib/lists/VideoModels.ts b/frontend/libs/model-list/src/lib/lists/VideoModels.ts index c47bb588de..ed409c0f67 100644 --- a/frontend/libs/model-list/src/lib/lists/VideoModels.ts +++ b/frontend/libs/model-list/src/lib/lists/VideoModels.ts @@ -3,6 +3,20 @@ import { ModelCreator } from "../classes/metadata/ModelCreator.js"; import { SizeIconOption } from "../classes/metadata/SizeOption.js"; export const VIDEO_MODELS: VideoModel[] = [ + new VideoModel({ + id: "grok", + tauriId: "grok", + fullName: "Grok Video", + category: "video", + creator: ModelCreator.Kling, + selectorName: "Grok Video", + selectorDescription: "Fastest video model", + selectorBadges: ["20 sec."], + startFrame: true, + endFrame: false, + requiresImage: true, + progressBarTime: 50000, + }), new VideoModel({ id: "kling_1_6_pro", tauriId: "kling_1.6_pro",