Compare commits

...
94 changed files with 2437 additions and 407 deletions
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n id,\n task_status,\n task_type,\n model_type,\n provider,\n provider_job_id,\n frontend_caller,\n frontend_subscriber_id,\n frontend_subscriber_payload,\n created_at as \"created_at: DateTime<Utc>\",\n updated_at as \"updated_at: DateTime<Utc>\",\n completed_at as \"completed_at: DateTime<Utc>\"\n FROM tasks\n WHERE is_dismissed_by_user == 0\n ",
"query": "\n SELECT\n id,\n task_status,\n task_type,\n model_type,\n provider,\n provider_job_id,\n frontend_caller,\n frontend_subscriber_id,\n frontend_subscriber_payload,\n on_complete_primary_media_file_token,\n on_complete_primary_media_file_class,\n on_complete_batch_token,\n on_complete_primary_media_file_cdn_url,\n on_complete_primary_media_file_thumbnail_url_template,\n created_at as \"created_at: DateTime<Utc>\",\n updated_at as \"updated_at: DateTime<Utc>\",\n completed_at as \"completed_at: DateTime<Utc>\"\n FROM tasks\n WHERE is_dismissed_by_user == 0\n ",
"describe": {
"columns": [
{
@@ -49,18 +49,43 @@
"type_info": "Text"
},
{
"name": "created_at: DateTime<Utc>",
"name": "on_complete_primary_media_file_token",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "on_complete_primary_media_file_class",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "on_complete_batch_token",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "on_complete_primary_media_file_cdn_url",
"ordinal": 12,
"type_info": "Text"
},
{
"name": "on_complete_primary_media_file_thumbnail_url_template",
"ordinal": 13,
"type_info": "Text"
},
{
"name": "created_at: DateTime<Utc>",
"ordinal": 14,
"type_info": "Int64"
},
{
"name": "updated_at: DateTime<Utc>",
"ordinal": 10,
"ordinal": 15,
"type_info": "Int64"
},
{
"name": "completed_at: DateTime<Utc>",
"ordinal": 11,
"ordinal": 16,
"type_info": "Int64"
}
],
@@ -77,10 +102,15 @@
true,
true,
true,
true,
true,
true,
true,
true,
false,
false,
true
]
},
"hash": "a142dc3fdf5b6e741c268d8f2e9996a28c214f5835681297d58a9431da8de939"
"hash": "47f94c127abc20683b25c257aed3c2190b9bd29eb829d61392f08e10fd986767"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n UPDATE tasks\n SET\n task_status = ?,\n on_complete_batch_token = ?,\n on_complete_primary_media_file_token = ?,\n on_complete_primary_media_file_class = ?,\n on_complete_primary_media_file_cdn_url = ?,\n on_complete_primary_media_file_thumbnail_url_template = ?\n WHERE id = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "50a2449b87abed7df3302a74ecd0e8ab87940077a247636680cb09b2fe488321"
}
Generated
+9
View File
@@ -694,6 +694,7 @@ dependencies = [
"tokio",
"tokio-util",
"url",
"url_utils",
"web_base64",
"workspace-build-acceleration",
]
@@ -12457,6 +12458,14 @@ dependencies = [
"workspace-build-acceleration",
]
[[package]]
name = "url_utils"
version = "0.0.1"
dependencies = [
"url",
"workspace-build-acceleration",
]
[[package]]
name = "urlencoding"
version = "2.1.3"
+2
View File
@@ -84,6 +84,7 @@ members = [
"crates/lib/testing",
"crates/lib/tts_common",
"crates/lib/url_config",
"crates/lib/url_utils",
"crates/lib/user_input_common",
"crates/lib/web_scrapers",
#"crates/lib/api_clients/fakeyou_client",
@@ -210,6 +211,7 @@ subprocess_common = { path = "crates/lib/subprocess_common" }
test_data = { path = "crates/lib/test_data" }
testing = { path = "crates/lib/testing" }
tokens = { path = "crates/schema/public/tokens" }
url_utils = { path = "crates/lib/url_utils" }
web_base64 = { path = "crates/lib/data/web_base64" }
# External libs
@@ -10,6 +10,13 @@
-- tasks_v2.sqlite - added model_type (nullable)
-- tasks_v3.sqlite - added frontend_caller, comments
-- tasks_v4.sqlite - added is_dismissed_by_user
-- tasks_v5.sqlite - added five fields:
-- on_complete_batch_token,
-- on_complete_primary_media_file_token,
-- on_complete_primary_media_file_class,
-- on_complete_primary_media_file_cdn_url,
-- on_complete_primary_media_file_thumbnail_url_template,
--
CREATE TABLE tasks (
-- Task auto-incrementing primary key.
@@ -54,6 +61,29 @@ CREATE TABLE tasks (
-- Whether the user has dismissed the task from view.
is_dismissed_by_user INTEGER NOT NULL DEFAULT 0,
-- OPTIONAL.
-- When the generation completes, the batch token (if any).
-- This will point to a collection of multiple files.
on_complete_batch_token TEXT,
-- OPTIONAL.
-- When the generation completes, the media token of the "primary" media file.
-- We'll probably always generate homogenous types, but just in case this is the first or primary item in a batch.
on_complete_primary_media_file_token TEXT,
-- OPTIONAL.
-- When the generation completes, the file type of the primary media file.
-- We'll probably always generate homogenous types, but just in case this is the first or primary item in a batch.
on_complete_primary_media_file_class TEXT,
-- OPTIONAL.
-- The URL to the media file.
on_complete_primary_media_file_cdn_url TEXT,
-- OPTIONAL.
-- The URL template for the thumbnail of the primary media file.
on_complete_primary_media_file_thumbnail_url_template TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch('now')),
updated_at INTEGER NOT NULL DEFAULT (unixepoch('now')),
completed_at INTEGER DEFAULT NULL
@@ -6,4 +6,5 @@ pub mod jobs;
pub mod media_file;
pub mod prompts;
pub mod stripe_artcraft;
pub mod subscriptions;
pub mod subscriptions;
pub mod utils;
@@ -0,0 +1,12 @@
use crate::common::responses::media_links::MediaLinks;
/// Get the thumbnail template for either images or videos.
pub fn media_links_to_thumbnail_template(links: &MediaLinks) -> Option<&str> {
// NB: We only populate video previews for video tasks.
if let Some(video) = links.maybe_video_previews.as_ref() {
return Some(&video.animated_thumbnail_template);
}
// Image case - use the image thumbnail template if it exists.
links.maybe_thumbnail_template.as_deref()
}
@@ -0,0 +1 @@
pub mod media_links_to_thumbnail_template;
@@ -3,7 +3,7 @@
/// This user agent is tied to the sentinel generation.
/// If we need to change it, we may need to change sentinel generation too.
pub (crate) const USER_AGENT : &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36";
pub (crate) const CLIENT_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36";
// NB: from sentinel token request -
@@ -2,4 +2,5 @@ pub mod sora_cookies;
pub mod sora_credential_builder;
pub mod sora_credential_set;
pub mod sora_jwt_bearer_token;
pub mod sora_sentinel;
pub mod sora_sentinel;
pub mod sora_sentinel_token;
@@ -2,13 +2,20 @@ use crate::creds::sora_cookies::SoraCookies;
use crate::creds::sora_credential_set::SoraCredentialSet;
use crate::creds::sora_jwt_bearer_token::SoraJwtBearerToken;
use crate::creds::sora_sentinel::SoraSentinel;
use crate::creds::sora_sentinel_token::SoraSentinelToken;
use crate::error::sora_client_error::SoraClientError;
use crate::error::sora_error::SoraError;
pub struct SoraCredentialBuilder {
cookies: Option<String>,
jwt_bearer_token: Option<String>,
#[deprecated(note="use sora_sentinel_token instead")]
sora_sentinel: Option<String>,
/// This is the newer sentinel token. Gradually phase out the older one.
sora_sentinel_token: Option<SoraSentinelToken>,
}
impl SoraCredentialBuilder {
@@ -17,6 +24,7 @@ impl SoraCredentialBuilder {
cookies: None,
jwt_bearer_token: None,
sora_sentinel: None,
sora_sentinel_token: None,
}
}
@@ -37,6 +45,11 @@ impl SoraCredentialBuilder {
self
}
pub fn with_sora_sentinel_token(mut self, sentinel_token: &SoraSentinelToken) -> Self {
self.sora_sentinel_token = Some(sentinel_token.clone());
self
}
// Mutable methods
pub fn set_cookies(&mut self, cookies: &str) {
@@ -50,6 +63,10 @@ impl SoraCredentialBuilder {
pub fn set_sora_sentinel(&mut self, sentinel: &str) {
self.sora_sentinel = Some(sentinel.to_string());
}
pub fn set_sora_sentinel_token(&mut self, sentinel_token: &SoraSentinelToken) {
self.sora_sentinel_token = Some(sentinel_token.clone());
}
pub fn build(self) -> Result<SoraCredentialSet, SoraError> {
let cookies = match self.cookies {
@@ -67,10 +84,16 @@ impl SoraCredentialBuilder {
None => None,
};
let sora_sentinel_token = match self.sora_sentinel_token {
Some(s) => Some(s),
None => None,
};
Ok(SoraCredentialSet {
cookies,
jwt_bearer_token,
sora_sentinel,
sora_sentinel_token,
})
}
}
@@ -2,24 +2,32 @@ use crate::creds::sora_cookies::SoraCookies;
use crate::creds::sora_credential_builder::SoraCredentialBuilder;
use crate::creds::sora_jwt_bearer_token::SoraJwtBearerToken;
use crate::creds::sora_sentinel::SoraSentinel;
use crate::creds::sora_sentinel_token::SoraSentinelToken;
#[derive(Clone)]
pub struct SoraCredentialSet {
pub cookies: SoraCookies,
pub jwt_bearer_token: Option<SoraJwtBearerToken>,
#[deprecated(note="use sora_sentinel_token instead")]
pub sora_sentinel: Option<SoraSentinel>,
// NB: This is the newer sentinel token. Gradually phase out the older one.
pub sora_sentinel_token: Option<SoraSentinelToken>,
}
impl SoraCredentialSet {
pub fn initialize(
cookies: SoraCookies,
bearer: Option<SoraJwtBearerToken>,
sentinel: Option<SoraSentinel>
sentinel: Option<SoraSentinel>,
sentinel_token: Option<SoraSentinelToken>,
) -> Self {
Self {
cookies,
jwt_bearer_token: bearer,
sora_sentinel: sentinel,
sora_sentinel_token: sentinel_token,
}
}
@@ -28,6 +36,7 @@ impl SoraCredentialSet {
cookies,
jwt_bearer_token: None,
sora_sentinel: None,
sora_sentinel_token: None,
}
}
@@ -46,6 +55,9 @@ impl SoraCredentialSet {
if let Some(sentinel) = &self.sora_sentinel {
builder = builder.with_sora_sentinel(sentinel.as_str());
}
if let Some(sentinel_token) = &self.sora_sentinel_token {
builder = builder.with_sora_sentinel_token(sentinel_token);
}
builder
}
}
@@ -1,6 +1,7 @@
/// Sora Sentinel tokens can be generated without cookies or JWTs, and are needed for some API calls.
#[derive(Clone)]
#[deprecated(note="use SoraSentinelToken instead")]
pub struct SoraSentinel {
sentinel: String,
@@ -13,14 +14,17 @@ impl SoraSentinel {
SoraSentinel { sentinel }
}
#[deprecated(note="use SoraSentinelToken instead")]
pub fn get_sentinel(&self) -> &str {
&self.sentinel
}
#[deprecated(note="use SoraSentinelToken instead")]
pub fn as_str(&self) -> &str {
&self.sentinel
}
#[deprecated(note="use SoraSentinelToken instead")]
pub fn as_bytes(&self) -> &[u8] {
self.sentinel.as_bytes()
}
@@ -0,0 +1,99 @@
use crate::error::sora_client_error::SoraClientError;
use chrono::{DateTime, TimeDelta, Utc};
use log::{error, warn};
use serde_derive::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SoraSentinelToken {
/// The token sent over the wire.
/// (The other fields in this struct are metadata.)
pub token: RawSoraSentinelToken,
/// User agent used to get the token.
pub browser_user_agent: String,
/// Clientside time when it was generated (client-side).
pub generated_at: DateTime<Utc>,
/// Seconds of validity (reported by the server, or our best guess).
/// If the server doesn't tell us, we'll fill it in with our best guess.
pub expires_in_seconds: u32,
}
/// This is what is sent over the wire in requests.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RawSoraSentinelToken {
/// "Proof of Work" token (we generated this clientside)
#[serde(rename = "p")]
pub p: String,
/// Random UUID (we generated this clientside)
#[serde(rename = "id")]
pub id: String,
/// Frontend UI flow (this is the website flow used)
#[serde(rename = "flow")]
pub flow: String,
/// Turnstile dx from the response.
/// response["turnstile"]["dx"]
#[serde(rename = "t")]
pub t: String,
/// Token from the response.
/// response["token"]
#[serde(rename = "c")]
pub c: String,
}
impl SoraSentinelToken {
/// Check to see if the sentinel token is expired and in need of a refresh.
pub fn is_expired(&self) -> bool {
let now = Utc::now();
let maybe_expires_at = self.generated_at
.checked_add_signed(TimeDelta::seconds(self.expires_in_seconds as i64));
let expires_at = match maybe_expires_at {
Some(expires_at) => expires_at,
None => {
error!("Could not compute expires_at for sentinel token!");
return false;
}
};
if now >= expires_at {
return true;
}
if expires_at.signed_duration_since(now).num_seconds() < 30 {
warn!("Less than 30 seconds remaining on sentinel token, treating as expired.");
return true;
}
false
}
/// Convert to JSON, which is the raw over-the-wire format Sora consumes.
pub fn to_request_header_json(&self) -> Result<String, SoraClientError> {
self.token.to_request_header_json()
}
pub fn to_persistent_storage_json(&self) -> Result<String, SoraClientError> {
serde_json::to_string(self)
.map_err(|err| SoraClientError::CouldNotSerializeSentinelTokenStore(err))
}
pub fn from_persistent_storage_json(json: &str) -> Result<Self, SoraClientError> {
serde_json::from_str(json)
.map_err(|err| SoraClientError::CouldNotDeserializeSentinelTokenStore { error: err, raw_json: json.to_owned() })
}
}
impl RawSoraSentinelToken {
/// Convert to JSON, which is the raw over-the-wire format Sora consumes.
pub fn to_request_header_json(&self) -> Result<String, SoraClientError> {
serde_json::to_string(self)
.map_err(|err| SoraClientError::CouldNotSerializeSentinelToken(err))
}
}
@@ -32,6 +32,18 @@ pub enum SoraClientError {
/// An error was encountered in building the Wreq client
WreqClientError(wreq::Error),
/// Error serializing the sentinel token to JSON
CouldNotSerializeSentinelToken(serde_json::Error),
/// Error serializing the sentinel store token to JSON (typically for persistent storage).
CouldNotSerializeSentinelTokenStore(serde_json::Error),
/// Error deserializing the sentinel token from JSON (typically for persistent storage).
CouldNotDeserializeSentinelTokenStore {
error: serde_json::Error,
raw_json: String
},
}
impl Error for SoraClientError {}
@@ -48,6 +60,11 @@ impl Display for SoraClientError {
Self::SoraCredentialBuilderError(msg) => write!(f, "Sora Credential Builder error: {}", msg),
Self::UrlParseError(err) => write!(f, "URL parse error: {}", err),
Self::WreqClientError(err) => write!(f, "Wreq client error (during client creation): {}", err),
Self::CouldNotSerializeSentinelToken(err) => write!(f, "Could not serialize sentinel token to JSON: {}", err),
Self::CouldNotSerializeSentinelTokenStore(err) => write!(f, "Could not serialize sentinel token store to JSON: {}", err),
Self::CouldNotDeserializeSentinelTokenStore { error, raw_json} => {
write!(f, "Could not deserialize sentinel token store from JSON: {:?}, raw JSON: {}", error, raw_json)
},
}
}
}
@@ -39,6 +39,13 @@ pub enum SoraSpecificApiError {
/// }
/// }
UnauthorizedCookieOrBearerExpired,
/// The Sora sentinel token response is missing fields
SentinelResponseIsMissingFields {
missing_token: bool,
missing_turnstile: bool,
raw_response: String,
},
}
impl Error for SoraSpecificApiError {}
@@ -53,6 +60,9 @@ impl Display for SoraSpecificApiError {
Self::TokenExpiredError => write!(f, "JWT token has expired. Please refresh the token."),
Self::TooManyConcurrentTasks => write!(f, "Too many concurrent tasks. Please wait."),
Self::UnauthorizedCookieOrBearerExpired => write!(f, "Unauthorized: cookie and/or bearer token expired"),
Self::SentinelResponseIsMissingFields { missing_token: token_missing, missing_turnstile: turnstile_missing, raw_response } => {
write!(f, "Sentinel response is missing fields. token_missing: {}, turnstile_missing: {}. Raw response: {}", token_missing, turnstile_missing, raw_response)
},
}
}
}
@@ -1,15 +1,23 @@
use crate::creds::sora_credential_set::SoraCredentialSet;
use crate::error::sora_error::SoraError;
use crate::recipes::utils::maybe_renew_sentinel_token::maybe_renew_sentinel_token;
use crate::recipes::utils::maybe_renew_session_jwt::maybe_renew_session_jwt;
use crate::requests::generate_sora2_video::generate_sora2_video::{generate_sora2_video, GenerateSora2VideoArgs, GenerateSora2VideoResponse};
use std::io::Write;
/// Generate Sora 2 video with session auto-renewal.
/// If a new sora credential is returned, replace the old one with the new one.
pub async fn generate_sora2_video_with_session_auto_renew(
args: GenerateSora2VideoArgs<'_>,
) -> Result<(GenerateSora2VideoResponse, Option<SoraCredentialSet>), SoraError> {
let mut maybe_new_creds = maybe_renew_session_jwt(&args.credentials).await?;
let use_creds = maybe_new_creds.as_ref()
.unwrap_or_else(|| &args.credentials);
let mut maybe_new_creds = maybe_renew_sentinel_token(use_creds).await?;
let use_creds = maybe_new_creds.as_ref()
.unwrap_or_else(|| &args.credentials);
@@ -37,7 +45,7 @@ mod tests {
let creds = get_test_credentials()?;
let args = GenerateSora2VideoArgs {
prompt: "a dog playing tennis",
prompt: "a Golden Retriever playing basketball at Venice Beach during sunset",
credentials: &creds,
request_timeout: None,
orientation: Orientation::Landscape,
@@ -4,12 +4,13 @@ use crate::creds::sora_sentinel::SoraSentinel;
use crate::error::sora_error::SoraError;
use crate::requests::auth_bearer::generate_bearer_jwt_with_cookie::generate_bearer_jwt_with_cookie;
use crate::requests::auth_sentinel::generate_sentinel_token::generate_sentinel_token;
use crate::requests::auth_sentinel_2::generate_sentinel_token_2::generate_sentinel_token_2;
use chrono::{DateTime, TimeDelta, Utc};
use errors::AnyhowResult;
use log::info;
use std::ops::Sub;
const EXPIRATION_DEADLINE : TimeDelta = TimeDelta::hours(12);
const JWT_EXPIRATION_DEADLINE: TimeDelta = TimeDelta::hours(12);
/// Call this at session startup to generate the tokens for the first time.
/// Call this periodically to refresh the JWT as it nears its expiration date.
@@ -17,11 +18,10 @@ const EXPIRATION_DEADLINE : TimeDelta = TimeDelta::hours(12);
#[deprecated(note="The sentinel appears to be going away. Use `maybe_renew_session_jwt` instead for newer '2.0' code.")]
pub async fn maybe_upgrade_or_renew_session(sora_credentials: &mut SoraCredentialSet) -> Result<bool, SoraError> {
let mut refresh_jwt = !sora_credentials.jwt_bearer_token.is_some();
let refresh_sentinel = !sora_credentials.sora_sentinel.is_some();
if let Some(jwt) = &sora_credentials.jwt_bearer_token {
let now = Utc::now();
let refresh_deadline = now.sub(EXPIRATION_DEADLINE);
let refresh_deadline = now.sub(JWT_EXPIRATION_DEADLINE);
if jwt.jwt_claims().expiration.lt(&now) {
info!("JWT expiration time has elapsed.");
refresh_jwt = true;
@@ -34,6 +34,7 @@ pub async fn maybe_upgrade_or_renew_session(sora_credentials: &mut SoraCredentia
let mut credential_updated = false;
if refresh_jwt {
info!("Refreshing JWT...");
let cookies = sora_credentials.cookies.as_str();
let token = generate_bearer_jwt_with_cookie(cookies).await?;
@@ -43,11 +44,25 @@ pub async fn maybe_upgrade_or_renew_session(sora_credentials: &mut SoraCredentia
credential_updated = true;
}
if refresh_sentinel {
info!("Generating new sentinel...");
let token = generate_sentinel_token().await?;
let token = SoraSentinel::new(token);
sora_credentials.sora_sentinel = Some(token);
//let refresh_sentinel = !sora_credentials.sora_sentinel.is_some();
//if refresh_sentinel {
// info!("Generating new sentinel...");
// let token = generate_sentinel_token().await?;
// let token = SoraSentinel::new(token);
// sora_credentials.sora_sentinel = Some(token);
// credential_updated = true;
//}
let refresh_sentinel_token = sora_credentials.sora_sentinel_token
.as_ref()
.map(|t| t.is_expired())
.unwrap_or(true);
if refresh_sentinel_token {
info!("Refreshing sentinel token...");
let token = generate_sentinel_token_2().await?;
sora_credentials.sora_sentinel = Some(SoraSentinel::new(token.to_request_header_json()?)); // Legacy
sora_credentials.sora_sentinel_token = Some(token); // New
credential_updated = true;
}
@@ -0,0 +1,35 @@
use crate::creds::sora_credential_set::SoraCredentialSet;
use crate::creds::sora_sentinel_token::SoraSentinelToken;
use crate::error::sora_error::SoraError;
use crate::requests::auth_sentinel_2::generate_sentinel_token_2::generate_sentinel_token_2;
use log::info;
/// Returns a new sentinel token if one was generated, or None if no update was needed.
pub async fn maybe_renew_sentinel_token(creds: &SoraCredentialSet) -> Result<Option<SoraCredentialSet>, SoraError> {
let mut refresh_sentinel = false;
match creds.sora_sentinel_token.as_ref() {
None => {
info!("Sentinel token not set. It needs to be generated...");
refresh_sentinel = true;
}
Some(sentinel) => {
let expired = sentinel.is_expired();
if expired {
info!("Sentinel token is expired. It needs to be renewed...");
refresh_sentinel = true;
}
}
}
if !refresh_sentinel {
return Ok(None);
}
let sentinel = generate_sentinel_token_2().await?;
let mut new_creds = creds.clone();
new_creds.sora_sentinel_token = Some(sentinel);
Ok(Some(new_creds))
}
@@ -1,10 +1,10 @@
use crate::creds::sora_credential_set::SoraCredentialSet;
use crate::creds::sora_jwt_bearer_token::SoraJwtBearerToken;
use crate::error::sora_error::SoraError;
use crate::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session;
use crate::requests::auth_bearer::generate_bearer_jwt_with_cookie::generate_bearer_jwt_with_cookie;
use chrono::{TimeDelta, Utc};
use log::{info, warn};
use std::io::Write;
use std::ops::Sub;
const EXPIRATION_DEADLINE : TimeDelta = TimeDelta::hours(12);
@@ -15,17 +15,17 @@ pub async fn maybe_renew_session_jwt(creds: &SoraCredentialSet) -> Result<Option
match creds.jwt_bearer_token.as_ref() {
None => {
info!("JWT not set.");
info!("JWT not set. It needs to be generated...");
refresh_jwt = true;
}
Some(jwt) => {
let now = Utc::now();
let refresh_deadline = now.sub(EXPIRATION_DEADLINE);
if jwt.jwt_claims().expiration.lt(&now) {
info!("JWT expiration time has elapsed.");
info!("JWT expiration time has elapsed. It needs to be renewed...");
refresh_jwt = true;
} else if jwt.jwt_claims().expiration.lt(&refresh_deadline) {
info!("JWT expiration time is under the expiration deadline.");
info!("JWT expiration time is under the expiration deadline. It needs to be renewed...");
refresh_jwt = true;
}
}
@@ -34,13 +34,13 @@ pub async fn maybe_renew_session_jwt(creds: &SoraCredentialSet) -> Result<Option
if !refresh_jwt {
return Ok(None);
}
info!("Refreshing JWT...");
let token = generate_bearer_jwt_with_cookie(creds.cookies.as_str()).await?;
info!("Parsing JWT bearer token...");
let token = SoraJwtBearerToken::new(token)?;
let mut updated_creds = creds.clone();
updated_creds.jwt_bearer_token = Some(token);
@@ -1 +1,2 @@
pub mod maybe_renew_session_jwt;
pub mod maybe_renew_session_jwt;
pub mod maybe_renew_sentinel_token;
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
use super::request::GenerateSentinelRefreshRequest;
use crate::constants::user_agent::USER_AGENT;
use crate::constants::user_agent::CLIENT_USER_AGENT;
use crate::error::sora_error::SoraError;
use crate::error::sora_generic_api_error::SoraGenericApiError;
use crate::utils_internal::classify_general_http_error::classify_general_http_error;
@@ -43,12 +43,16 @@ pub struct SentinelResponse {
}
/// Note: This looks like an incomplete implementation of the original Python implementation.
/// Curiously, it worked with Sora 1.0 (and continues to work), but it fails for Sora 2.0 while the
/// unchanged Python implementation works. We'll be deprecating this.
#[deprecated(note="use new sentinel token flow")]
pub async fn generate_sentinel_token() -> Result<String, SoraError> {
let (_request, base64_request) = GenerateSentinelRefreshRequest::new().with_fourth_and_tenth();
let request = SentinelRequest::new(base64_request);
let client = Client::new();
let response = client.post(SORA_IMAGE_GEN_URL)
.header("User-Agent", USER_AGENT)
.header("User-Agent", CLIENT_USER_AGENT)
.header("Content-Type", "application/json")
.json(&request)
.send()
@@ -85,6 +89,7 @@ mod tests {
use super::*;
#[tokio::test]
#[ignore] // Only manually trigger this
async fn test_generate_token() {
let token = generate_sentinel_token().await.unwrap();
println!("{}", token);
@@ -0,0 +1,62 @@
use crate::requests::auth_sentinel_2::response::SentinelResponse;
use chrono::{DateTime, TimeDelta, Utc};
use log::{info, warn};
use std::f32::consts::E;
// Sora's servers typically tell us to expire in nine minutes (540 seconds).
const DEFAULT_EXPIRE_SECONDS : u32 = 60 * 3; // 3 minutes.
pub (super) struct ExtractedExpiry {
pub (super) generated_at: DateTime<Utc>,
pub (super) expires_in_seconds: u32,
}
pub (super) fn extract_expiry(generated_at: DateTime<Utc>, response: &SentinelResponse) -> ExtractedExpiry {
info!("Sentinel expires at: {:?} (in {:?} seconds)", response.expire_at, response.expire_after);
const LOWER_BOUNDS_SECONDS: u32 = 60;
const UPPER_BOUNDS_SECONDS: u32 = 60 * 60;
// Trust the `expire_at` timestamp first.
let mut maybe_expires_at = response.expire_at
.map(|timestamp| DateTime::from_timestamp(timestamp as i64, 0))
.flatten();
if let Some(expires_at) = maybe_expires_at {
let seconds = expires_at.signed_duration_since(generated_at).num_seconds();
const LOWER_BOUNDS_SECONDS_I64: i64 = LOWER_BOUNDS_SECONDS as i64;
const UPPER_BOUNDS_SECONDS_I64: i64 = UPPER_BOUNDS_SECONDS as i64;
if seconds.is_positive()
&& seconds > LOWER_BOUNDS_SECONDS_I64
&& seconds < UPPER_BOUNDS_SECONDS_I64
{
info!("Using sentinel expire_at timestamp, expires in {} seconds (at {})", seconds, expires_at);
return ExtractedExpiry {
generated_at,
expires_in_seconds: seconds as u32,
}
}
}
let mut expires_in_seconds = response.expire_after
.unwrap_or_else(|| {
warn!("No default expire_after from sentinel response, defaulting to {} seconds", DEFAULT_EXPIRE_SECONDS);
DEFAULT_EXPIRE_SECONDS
});
if expires_in_seconds < LOWER_BOUNDS_SECONDS {
warn!("Warning: sentinel token expires very soon: {} seconds, defaulting to {} seconds", expires_in_seconds, DEFAULT_EXPIRE_SECONDS);
expires_in_seconds = DEFAULT_EXPIRE_SECONDS;
} else if expires_in_seconds > UPPER_BOUNDS_SECONDS {
warn!("Warning: sentinel token expires very late: {} seconds, defaulting to {} seconds", expires_in_seconds, DEFAULT_EXPIRE_SECONDS);
expires_in_seconds = DEFAULT_EXPIRE_SECONDS;
}
ExtractedExpiry {
generated_at,
expires_in_seconds,
}
}
@@ -0,0 +1,169 @@
use crate::constants::user_agent::CLIENT_USER_AGENT;
use crate::creds::sora_sentinel_token::{RawSoraSentinelToken, SoraSentinelToken};
use crate::error::sora_error::SoraError;
use crate::error::sora_generic_api_error::SoraGenericApiError;
use crate::error::sora_specific_api_error::SoraSpecificApiError;
use crate::requests::auth_sentinel::request::GenerateSentinelRefreshRequest;
use crate::requests::auth_sentinel_2::extract_expiry::extract_expiry;
use crate::requests::auth_sentinel_2::request::SentinelRequest;
use crate::requests::auth_sentinel_2::response::SentinelResponse;
use crate::utils_internal::classify_general_http_error::classify_general_http_error;
use chrono::{DateTime, NaiveDateTime, TimeDelta, TimeZone, Utc};
use errors::AnyhowResult;
use idempotency::uuid::generate_random_uuid;
use log::{debug, error, info};
use serde_derive::{Deserialize, Serialize};
use std::io::Write;
use thiserror::Error;
use wreq::header::{ACCEPT, ACCEPT_LANGUAGE, AUTHORIZATION, CONTENT_TYPE, COOKIE, ORIGIN, REFERER, USER_AGENT};
use wreq::Client;
const SORA_SENTINEL_ENDPOINT: &str = "https://chatgpt.com/backend-api/sentinel/req";
/// This generates a Sentinel Token that works with Sora 1.0 and Sora 2.0 consumer products.
pub async fn generate_sentinel_token_2() -> Result<SoraSentinelToken, SoraError> {
let (_request, base64_request) = GenerateSentinelRefreshRequest::new().with_fourth_and_tenth();
let request = SentinelRequest::new(base64_request);
let client = Client::new();
let request_builder = client.post(SORA_SENTINEL_ENDPOINT)
.header(USER_AGENT, CLIENT_USER_AGENT)
.header(ACCEPT, "*/*")
.header(ACCEPT_LANGUAGE, "en-US,en;q=0.9")
// TODO: Accept-Encoding
// TODO: Cookie
.header(REFERER, "https://chatgpt.com/backend-api/sentinel/frame.html")
.header(CONTENT_TYPE, "application/json")
.header(ORIGIN, "https://chatgpt.com")
.header("sec-gpc", "1")
.header("sec-fetch-dest", "empty")
.header("sec-fetch-mode", "cors")
.header("sec-fetch-site", "same-origin")
.header("priority", "u=4")
.header("te", "trailers");
let response = request_builder
.json(&request)
.send()
.await
.map_err(|err| {
error!("Client failed to generate sentinel token: {:?}", err);
SoraGenericApiError::WreqError(err)
})?;
if !response.status().is_success() {
error!("Failed to generate sentinel: {}", response.status());
let error = classify_general_http_error(response).await;
return Err(error);
}
let text_body = &response.text().await
.map_err(|err| {
error!("sora error reading sentinel token text body: {}", err);
SoraGenericApiError::WreqError(err)
})?;
debug!("Sentinel endpoint response body: {}", text_body);
let response = serde_json::from_str::<SentinelResponse>(&text_body)
.map_err(|err| {
error!("Failed to parse sentinel token response: {}", err);
SoraGenericApiError::SerdeResponseParseErrorWithBody(err, text_body.to_string())
})?;
let sentinel_token = into_sora_sentinel_token(request, response, text_body)?;
Ok(sentinel_token)
}
fn into_sora_sentinel_token(request: SentinelRequest, response: SentinelResponse, raw_body: &str) -> Result<SoraSentinelToken, SoraSpecificApiError> {
let expiry = extract_expiry(Utc::now(), &response);
let maybe_token = response.token;
let maybe_turnstile = response.turnstile
.map(|turn| turn.dx)
.flatten();
let mut missing_token = false;
let mut missing_turnstile = false;
let (token, turnstile) = {
let maybe_items = match (maybe_token, maybe_turnstile) {
(Some(token), Some(turnstile)) => {
Some((token, turnstile))
}
(None, None) => {
missing_token = true;
missing_turnstile = true;
None
}
(None, Some(_turnstile)) => {
missing_token = true;
None
}
(Some(_token), None) => {
missing_turnstile = true;
None
}
};
match maybe_items {
Some((token, turnstile)) => (token, turnstile),
_ => return Err(SoraSpecificApiError::SentinelResponseIsMissingFields {
missing_token,
missing_turnstile,
raw_response: raw_body.to_string(),
}),
}
};
let sentinel_raw = RawSoraSentinelToken {
p: request.p,
id: request.id,
flow: request.flow,
t: turnstile,
c: token,
};
let sentinel = SoraSentinelToken {
token: sentinel_raw,
browser_user_agent: CLIENT_USER_AGENT.to_string(),
generated_at: expiry.generated_at,
expires_in_seconds: expiry.expires_in_seconds,
};
Ok(sentinel)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::write;
#[tokio::test]
#[ignore] // Only manually trigger this
async fn test_generate_token_2() -> AnyhowResult<()> {
let token = generate_sentinel_token_2().await?;
println!("{:?}", token);
assert_eq!(1, 2);
Ok(())
}
#[tokio::test]
#[ignore] // Only manually trigger this
async fn write_token_to_disk() -> AnyhowResult<()> {
let filename = "/Users/bt/Artcraft/credentials/sora_sentinel_token_store.json";
let token = generate_sentinel_token_2().await?;
let json = token.to_persistent_storage_json()?;
write(filename, &json)?;
assert_eq!(1, 2);
Ok(())
}
}
@@ -0,0 +1,357 @@
cores = [8, 16, 24, 32]
cached_scripts = ["https://cdn.oaistatic.com/_next/static/cXh69klOLzS0Gy2joLDRS/_ssgManifest.js?dpl=453ebaec0d44c2decab71692e1bfe39be35a24b3"]
cached_dpl = ["prod-f501fe933b3edf57aea882da888e1a544df99840"]
navigator_key = [
"registerProtocolHandler−function registerProtocolHandler() { [native code] }",
"storage−[object StorageManager]",
"locks−[object LockManager]",
"appCodeName−Mozilla",
"permissions−[object Permissions]",
"share−function share() { [native code] }",
"webdriver−false",
"managed−[object NavigatorManagedData]",
"canShare−function canShare() { [native code] }",
"vendor−Google Inc.",
"vendor−Google Inc.",
"mediaDevices−[object MediaDevices]",
"vibrate−function vibrate() { [native code] }",
"storageBuckets−[object StorageBucketManager]",
"mediaCapabilities−[object MediaCapabilities]",
"getGamepads−function getGamepads() { [native code] }",
"bluetooth−[object Bluetooth]",
"share−function share() { [native code] }",
"cookieEnabled−true",
"virtualKeyboard−[object VirtualKeyboard]",
"product−Gecko",
"mediaDevices−[object MediaDevices]",
"canShare−function canShare() { [native code] }",
"getGamepads−function getGamepads() { [native code] }",
"product−Gecko",
"xr−[object XRSystem]",
"clipboard−[object Clipboard]",
"storageBuckets−[object StorageBucketManager]",
"unregisterProtocolHandler−function unregisterProtocolHandler() { [native code] }",
"productSub−20030107",
"login−[object NavigatorLogin]",
"vendorSub−",
"login−[object NavigatorLogin]",
"getInstalledRelatedApps−function getInstalledRelatedApps() { [native code] }",
"mediaDevices−[object MediaDevices]",
"locks−[object LockManager]",
"webkitGetUserMedia−function webkitGetUserMedia() { [native code] }",
"vendor−Google Inc.",
"xr−[object XRSystem]",
"mediaDevices−[object MediaDevices]",
"virtualKeyboard−[object VirtualKeyboard]",
"virtualKeyboard−[object VirtualKeyboard]",
"appName−Netscape",
"storageBuckets−[object StorageBucketManager]",
"presentation−[object Presentation]",
"onLine−true",
"mimeTypes−[object MimeTypeArray]",
"credentials−[object CredentialsContainer]",
"presentation−[object Presentation]",
"getGamepads−function getGamepads() { [native code] }",
"vendorSub−",
"virtualKeyboard−[object VirtualKeyboard]",
"serviceWorker−[object ServiceWorkerContainer]",
"xr−[object XRSystem]",
"product−Gecko",
"keyboard−[object Keyboard]",
"gpu−[object GPU]",
"getInstalledRelatedApps−function getInstalledRelatedApps() { [native code] }",
"webkitPersistentStorage−[object DeprecatedStorageQuota]",
"doNotTrack",
"clearAppBadge−function clearAppBadge() { [native code] }",
"presentation−[object Presentation]",
"serial−[object Serial]",
"locks−[object LockManager]",
"requestMIDIAccess−function requestMIDIAccess() { [native code] }",
"locks−[object LockManager]",
"requestMediaKeySystemAccess−function requestMediaKeySystemAccess() { [native code] }",
"vendor−Google Inc.",
"pdfViewerEnabled−true",
"language−zh-CN",
"setAppBadge−function setAppBadge() { [native code] }",
"geolocation−[object Geolocation]",
"userAgentData−[object NavigatorUAData]",
"mediaCapabilities−[object MediaCapabilities]",
"requestMIDIAccess−function requestMIDIAccess() { [native code] }",
"getUserMedia−function getUserMedia() { [native code] }",
"mediaDevices−[object MediaDevices]",
"webkitPersistentStorage−[object DeprecatedStorageQuota]",
"sendBeacon−function sendBeacon() { [native code] }",
"hardwareConcurrency−32",
"credentials−[object CredentialsContainer]",
"storage−[object StorageManager]",
"cookieEnabled−true",
"pdfViewerEnabled−true",
"windowControlsOverlay−[object WindowControlsOverlay]",
"scheduling−[object Scheduling]",
"pdfViewerEnabled−true",
"hardwareConcurrency−32",
"xr−[object XRSystem]",
"webdriver−false",
"getInstalledRelatedApps−function getInstalledRelatedApps() { [native code] }",
"getInstalledRelatedApps−function getInstalledRelatedApps() { [native code] }",
"bluetooth−[object Bluetooth]"
]
document_key = ['_reactListeningo743lnnpvdg', 'location']
window_key = [
"0",
"window",
"self",
"document",
"name",
"location",
"customElements",
"history",
"navigation",
"locationbar",
"menubar",
"personalbar",
"scrollbars",
"statusbar",
"toolbar",
"status",
"closed",
"frames",
"length",
"top",
"opener",
"parent",
"frameElement",
"navigator",
"origin",
"external",
"screen",
"innerWidth",
"innerHeight",
"scrollX",
"pageXOffset",
"scrollY",
"pageYOffset",
"visualViewport",
"screenX",
"screenY",
"outerWidth",
"outerHeight",
"devicePixelRatio",
"clientInformation",
"screenLeft",
"screenTop",
"styleMedia",
"onsearch",
"isSecureContext",
"trustedTypes",
"performance",
"onappinstalled",
"onbeforeinstallprompt",
"crypto",
"indexedDB",
"sessionStorage",
"localStorage",
"onbeforexrselect",
"onabort",
"onbeforeinput",
"onbeforematch",
"onbeforetoggle",
"onblur",
"oncancel",
"oncanplay",
"oncanplaythrough",
"onchange",
"onclick",
"onclose",
"oncontentvisibilityautostatechange",
"oncontextlost",
"oncontextmenu",
"oncontextrestored",
"oncuechange",
"ondblclick",
"ondrag",
"ondragend",
"ondragenter",
"ondragleave",
"ondragover",
"ondragstart",
"ondrop",
"ondurationchange",
"onemptied",
"onended",
"onerror",
"onfocus",
"onformdata",
"oninput",
"oninvalid",
"onkeydown",
"onkeypress",
"onkeyup",
"onload",
"onloadeddata",
"onloadedmetadata",
"onloadstart",
"onmousedown",
"onmouseenter",
"onmouseleave",
"onmousemove",
"onmouseout",
"onmouseover",
"onmouseup",
"onmousewheel",
"onpause",
"onplay",
"onplaying",
"onprogress",
"onratechange",
"onreset",
"onresize",
"onscroll",
"onsecuritypolicyviolation",
"onseeked",
"onseeking",
"onselect",
"onslotchange",
"onstalled",
"onsubmit",
"onsuspend",
"ontimeupdate",
"ontoggle",
"onvolumechange",
"onwaiting",
"onwebkitanimationend",
"onwebkitanimationiteration",
"onwebkitanimationstart",
"onwebkittransitionend",
"onwheel",
"onauxclick",
"ongotpointercapture",
"onlostpointercapture",
"onpointerdown",
"onpointermove",
"onpointerrawupdate",
"onpointerup",
"onpointercancel",
"onpointerover",
"onpointerout",
"onpointerenter",
"onpointerleave",
"onselectstart",
"onselectionchange",
"onanimationend",
"onanimationiteration",
"onanimationstart",
"ontransitionrun",
"ontransitionstart",
"ontransitionend",
"ontransitioncancel",
"onafterprint",
"onbeforeprint",
"onbeforeunload",
"onhashchange",
"onlanguagechange",
"onmessage",
"onmessageerror",
"onoffline",
"ononline",
"onpagehide",
"onpageshow",
"onpopstate",
"onrejectionhandled",
"onstorage",
"onunhandledrejection",
"onunload",
"crossOriginIsolated",
"scheduler",
"alert",
"atob",
"blur",
"btoa",
"cancelAnimationFrame",
"cancelIdleCallback",
"captureEvents",
"clearInterval",
"clearTimeout",
"close",
"confirm",
"createImageBitmap",
"fetch",
"find",
"focus",
"getComputedStyle",
"getSelection",
"matchMedia",
"moveBy",
"moveTo",
"open",
"postMessage",
"print",
"prompt",
"queueMicrotask",
"releaseEvents",
"reportError",
"requestAnimationFrame",
"requestIdleCallback",
"resizeBy",
"resizeTo",
"scroll",
"scrollBy",
"scrollTo",
"setInterval",
"setTimeout",
"stop",
"structuredClone",
"webkitCancelAnimationFrame",
"webkitRequestAnimationFrame",
"chrome",
"caches",
"cookieStore",
"ondevicemotion",
"ondeviceorientation",
"ondeviceorientationabsolute",
"launchQueue",
"documentPictureInPicture",
"getScreenDetails",
"queryLocalFonts",
"showDirectoryPicker",
"showOpenFilePicker",
"showSaveFilePicker",
"originAgentCluster",
"onpageswap",
"onpagereveal",
"credentialless",
"speechSynthesis",
"onscrollend",
"webkitRequestFileSystem",
"webkitResolveLocalFileSystemURL",
"sendMsgToSolverCS",
"webpackChunk_N_E",
"__next_set_public_path__",
"next",
"__NEXT_DATA__",
"__SSG_MANIFEST_CB",
"__NEXT_P",
"_N_E",
"regeneratorRuntime",
"__REACT_INTL_CONTEXT__",
"DD_RUM",
"_",
"filterCSS",
"filterXSS",
"__SEGMENT_INSPECTOR__",
"__NEXT_PRELOADREADY",
"Intercom",
"__MIDDLEWARE_MATCHERS",
"__STATSIG_SDK__",
"__STATSIG_JS_SDK__",
"__STATSIG_RERENDER_OVERRIDE__",
"_oaiHandleSessionExpired",
"__BUILD_MANIFEST",
"__SSG_MANIFEST",
"__intercomAssignLocation",
"__intercomReloadLocation"
]
@@ -0,0 +1,71 @@
import hashlib, json, random, time, uuid, pybase64
from datetime import datetime, timedelta, timezone
from config import cores, cached_scripts, cached_dpl, navigator_key, document_key, window_key
MAX_ITERATION = 500000
def get_parse_time():
now = datetime.now(timezone(timedelta(hours=-5)))
return now.strftime("%a %b %d %Y %H:%M:%") + " GMT-0500 (Eastern Standard Time)"
def get_config(user_agent):
config = [
random.choice([1920 + 1080, 2560 + 1440, 1920 + 1200, 2560 + 1600]),
get_parse_time(),
4294705152,
0,
user_agent,
random.choice(cached_scripts) if cached_scripts else "",
cached_dpl,
"en-US",
"en-US,es-US,en,es",
0,
random.choice(navigator_key),
random.choice(document_key),
random.choice(window_key),
time.perf_counter() * 1000,
str(uuid.uuid4()),
"",
random.choice(cores),
time.time() * 1000 - (time.perf_counter() * 1000),
]
return config
def generate_answer(seed, diff, config):
diff_len = len(diff)
seed_encoded = seed.encode()
static_config_part1 = (json.dumps(config[:3], separators=(',', ':'), ensure_ascii=False)[:-1] + ',').encode()
static_config_part2 = (',' + json.dumps(config[4:9], separators=(',', ':'), ensure_ascii=False)[1:-1] + ',').encode()
static_config_part3 = (',' + json.dumps(config[10:], separators=(',', ':'), ensure_ascii=False)[1:]).encode()
target_diff = bytes.fromhex(diff)
for i in range(MAX_ITERATION):
dynamic_json_i = str(i).encode()
dynamic_json_j = str(i >> 1).encode()
final_json_bytes = static_config_part1 + dynamic_json_i + static_config_part2 + dynamic_json_j + static_config_part3
base_encode = pybase64.b64encode(final_json_bytes)
hash_value = hashlib.sha3_512(seed_encoded + base_encode).digest()
if hash_value[:diff_len] <= target_diff:
return base_encode.decode(), True
return "wQ8Lk5FbGpA2NcR9dShT6gYjU7VxZ4D" + pybase64.b64encode(f'"{seed}"'.encode()).decode(), False
def get_requirements_token(config):
seed = format(random.random())
diff = "0fffff"
solution, found = generate_answer(seed, diff, config)
return 'gAAAAAC' + solution
def get_pow_token():
config = get_config("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36")
return get_requirements_token(config)
if __name__ == "__main__":
print(get_pow_token())
@@ -0,0 +1,100 @@
# OpenAI Sentinel Token Generator
This repository provides a Python-based implementation to generate **Proof of Work (PoW) tokens** and **Sentinel tokens** for bypassing security mechanisms in OpenAI's ChatGPT and Sora private APIs. By reverse engineering the browser's token generation process, this project enables programmatic access to these APIs without requiring manual interaction or browser-based token generation.
## Table of Contents
- [Overview](#overview)
- [Features](#features)
- [How It Works](#how-it-works)
- [Installation](#installation)
- [Usage](#usage)
- [File Structure](#file-structure)
- [Disclaimer](#disclaimer)
---
## Overview
OpenAI's private APIs, such as ChatGPT and Sora, use advanced security mechanisms like Proof of Work (PoW) and Sentinel tokens to prevent unauthorized access. These tokens are typically generated in the browser using a combination of cryptographic hashing, unique identifiers, and environmental data.
This project reverse-engineers the token generation process to allow developers to programmatically generate these tokens and interact with the APIs directly. This can be useful for automation, testing, or research purposes.
---
## Features
- **Proof of Work Token Generation**: Implements a hashing-based PoW mechanism to generate valid tokens.
- **Sentinel Token Generation**: Creates Sentinel tokens required for API authentication.
- **Reverse-Engineered Browser Behavior**: Mimics browser-specific configurations and environmental data.
- **Customizable**: Easily modify configurations like user agents, navigator keys, and more.
---
## How It Works
### Proof of Work (PoW) Token Generation
The PoW token is generated by solving a computational challenge. The process involves:
1. Generating a random seed and difficulty level.
2. Iteratively hashing the seed with encoded configuration data until the hash meets the difficulty criteria.
3. Returning the solution as a base64-encoded string.
The implementation is in [`proof_of_work.py`](proof_of_work.py), which uses cryptographic hashing (`sha3_512`) and base64 encoding to generate the token.
### Sentinel Token Generation
The Sentinel token is generated by:
1. Fetching API requirements using the PoW token.
2. Parsing the API response to extract necessary fields like `turnstile` and `token`.
3. Constructing a payload with the extracted data and generating the final Sentinel token.
The implementation is in [`sentinel_token.py`](sentinel_token.py), which handles API communication and token generation.
---
## Installation
1. Clone the repository:
```bash
git clone https://github.com/your-username/openai-sentinel.git
cd openai-sentinel
```
2. Install the required dependencies:
```bash
pip install -r requirements.txt
```
Ensure you have Python 3.8 or higher installed.
___
## Usage
### Generating a Proof of Work (PoW) Token
To generate a PoW token, run the following command:
```bash
python proof_of_work.py
```
This will output a valid PoW token that can be used for API authentication.
### Generating a Sentinel Token
To generate a Sentinel token, run the following command:
```bash
python sentinel_token.py
```
This will output a valid Sentinel token that can be used for API authentication.
---
## File Structure
```plaintext
openai-sentinel/
├── proof_of_work.py
├── sentinel_token.py
├── config.py
├── requirements.txt
├── README.md
```
### Key Files:
- `proof_of_work.py`: Contains the implementation for generating PoW tokens.
- `sentinel_token.py`: Contains the implementation for generating Sentinel tokens.
- `config.py`: Configuration file for user agents, navigator keys, and other settings.
- `requirements.txt`: Lists the required Python packages.
- `README.md`: This documentation file.
---
## Disclaimer
This project is intended for educational and research purposes only. The use of this code to bypass security mechanisms or access APIs without authorization may violate the terms of service of OpenAI or other third-party services. Use at your own risk, and ensure compliance with all applicable laws and regulations.
```
Please do not use this code for malicious purposes or to infringe on the rights of others. The author and contributors are not responsible for any misuse or damage caused by the use of this code.
```
@@ -0,0 +1,55 @@
import requests, json
from uuid import uuid4
from proof_of_work import get_pow_token
current_parameters = None
def generate_id():
return str(uuid4())
def generate_payload(data, flow):
data['id'] = generate_id()
data['flow'] = flow
return json.dumps(data)
def fetch_requirements(flow, pow_token):
max_retries = 3
for attempt in range(max_retries):
try:
response = requests.post(
url = "https://chatgpt.com/backend-api/sentinel/req",
data = generate_payload({'p': pow_token}, flow),
)
result = response.json()
return result, True
except requests.exceptions.RequestException as err:
if attempt >= 2:
return generate_payload({'e': str(err)}, flow), False
def refresh_token(flow):
pow_token = get_pow_token()
response,_ = fetch_requirements(flow, pow_token)
if not _:
return response
try:
payload = generate_payload({
'p': pow_token,
't': response.get("turnstile", {}).get('dx', ""),
'c': response.get('token')
}, flow)
return payload
except Exception as err:
failure = generate_payload({'e': str(err), 'p': pow_token}, flow)
return failure
def get_sentinel_token():
flow = 'sora_create_task'
token = refresh_token(flow) #This can be used now in 'OpenAI-Sentinel-Token' header
return token
if __name__ == "__main__":
print(get_sentinel_token())
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,4 @@
pub (super) mod extract_expiry;
pub (super) mod request;
pub (super) mod response;
pub mod generate_sentinel_token_2;
@@ -0,0 +1,32 @@
use idempotency::uuid::generate_random_uuid;
use serde_derive::Serialize;
// TODO: Support more of these.
const SENTINEL_FLOW: &str = "sora_init";
const SENTINEL_FLOW_OLD: &str = "sora_create_task";
#[derive(Debug, Serialize)]
pub struct SentinelRequest {
/// "Proof of Work" token
#[serde(rename = "p")]
pub (crate) p: String,
/// Random UUID
#[serde(rename = "id")]
pub (crate) id: String,
/// Frontend UI flow
#[serde(rename = "flow")]
pub (crate) flow: String,
}
impl SentinelRequest {
pub fn new(pow_token: String) -> Self {
let id = generate_random_uuid();
Self {
p: pow_token,
id,
flow: SENTINEL_FLOW.to_string()
}
}
}
@@ -0,0 +1,21 @@
use chrono::{DateTime, Utc};
use serde_derive::Deserialize;
#[derive(Debug, Deserialize)]
pub (crate) struct SentinelResponse {
pub (crate) token: Option<String>,
pub (crate) turnstile: Option<SentinelTurnstile>,
// NB: appears to be integer seconds.
// Not required for the sentinel token flow, but helps with renew.
pub (crate) expire_after: Option<u32>,
// NB: appears to be Unix timestamp in seconds.
// Not required for the sentinel token flow, but helps with renew.
pub (crate) expire_at: Option<u64>,
}
#[derive(Debug, Deserialize)]
pub (crate) struct SentinelTurnstile {
pub (crate) dx: Option<String>,
}
@@ -1,4 +1,4 @@
use crate::constants::user_agent::USER_AGENT;
use crate::constants::user_agent::CLIENT_USER_AGENT;
use crate::creds::sora_credential_set::SoraCredentialSet;
use crate::creds::sora_jwt_bearer_token::SoraJwtBearerToken;
use crate::error::sora_client_error::SoraClientError;
@@ -223,7 +223,7 @@ pub async fn get_image_gen_status(status_request: &StatusRequest, credentials: &
}
let http_request = client.get(url.as_str())
.header("User-Agent", USER_AGENT)
.header("User-Agent", CLIENT_USER_AGENT)
.header("Cookie", credentials.cookies.as_str())
.header("Authorization", bearer_header)
.header("Content-Type", "application/json");
@@ -253,7 +253,7 @@ pub async fn get_image_gen_status(status_request: &StatusRequest, credentials: &
if !status.is_success() {
error!("The sora status request failed; status = {:?} ; response body = {}", status, response_body);
let error = classify_general_http_status_code_and_body(status, &response_body).await;
let error = classify_general_http_status_code_and_body(status, &response_body);
return Err(error);
}
@@ -1,4 +1,4 @@
use crate::constants::user_agent::USER_AGENT;
use crate::constants::user_agent::CLIENT_USER_AGENT;
use crate::creds::sora_credential_set::SoraCredentialSet;
use crate::error::sora_client_error::SoraClientError;
use crate::error::sora_error::SoraError;
@@ -25,7 +25,7 @@ pub async fn unimplemented_sora_job_status(
let request = client
.get(url)
.header("Authorization", &authorization_header)
.header("User-Agent", USER_AGENT)
.header("User-Agent", CLIENT_USER_AGENT)
.header("Cookie", &cookie);
let response = request.send()
@@ -1,15 +1,18 @@
use crate::constants::user_agent::USER_AGENT;
use crate::constants::user_agent::CLIENT_USER_AGENT;
use crate::creds::sora_credential_set::SoraCredentialSet;
use crate::error::sora_client_error::SoraClientError;
use crate::error::sora_error::SoraError;
use crate::error::sora_generic_api_error::SoraGenericApiError;
use crate::requests::auth_sentinel_2::generate_sentinel_token_2::generate_sentinel_token_2;
use crate::requests::common::task_id::TaskId;
use crate::requests::generate_sora2_video::http_request::{HttpCreateRequest, InpaintItem};
use crate::requests::generate_sora2_video::http_response::HttpCreateResponse;
use crate::requests::image_gen::image_gen_http_request::{RawSoraImageGenRequest, RawSoraResponse};
use crate::utils_internal::classify_general_http_status_code_and_body::classify_general_http_status_code_and_body;
use log::error;
use std::io::Write;
use std::time::Duration;
use wreq::header::{ACCEPT, ACCEPT_LANGUAGE, AUTHORIZATION, CONTENT_TYPE, COOKIE, ORIGIN, REFERER};
use wreq::header::{ACCEPT, ACCEPT_LANGUAGE, AUTHORIZATION, CONTENT_TYPE, COOKIE, ORIGIN, REFERER, USER_AGENT};
use wreq::Client;
const GENERATE_SORA_2_VIDEO_URL: &str = "https://sora.chatgpt.com/backend/nf/create";
@@ -44,25 +47,26 @@ pub (crate) async fn generate_sora2_video(
.ok_or(SoraClientError::NoBearerTokenForRequest)?
.to_authorization_header_value();
//let sentinel = args.credentials.sora_sentinel.as_ref()
// .map(|sentinel| sentinel.get_sentinel().to_string())
// .ok_or(SoraClientError::NoSentinelTokenForRequest)?;
let sentinel_token = args.credentials.sora_sentinel_token
.as_ref()
.ok_or(SoraClientError::NoSentinelTokenForRequest)?;
let sentinel_token = sentinel_token.to_request_header_json()?;
let cookie = args.credentials.cookies.to_string();
// TODO: Make the sec-* headers match the user agent and platform
// TODO: No sentinel?
// TODO: device id
//-H 'oai-device-id: 7c216860-5b73-4e63-983f-142dbcae1809' \
let mut http_request = client.post(GENERATE_SORA_2_VIDEO_URL)
//.header("OpenAI-Sentinel-Token", &sentinel);
let mut request_builder = client.post(GENERATE_SORA_2_VIDEO_URL)
.header(ACCEPT, "*/*")
.header("priority", "u=1, i")
.header(REFERER, "https://sora.chatgpt.com/explore")
.header(ORIGIN, "https://sora.chatgpt.com")
.header(ACCEPT_LANGUAGE, "en-US,en;q=0.9")
.header(wreq::header::USER_AGENT, USER_AGENT)
.header(USER_AGENT, CLIENT_USER_AGENT)
.header(COOKIE, &cookie)
.header("OpenAI-Sentinel-Token", sentinel_token)
.header(AUTHORIZATION, &authorization_header)
.header(CONTENT_TYPE, "application/json")
.header("sec-ch-ua", "\"Chromium\";v=\"140\", \"Not=A?Brand\";v=\"24\", \"Google Chrome\";v=\"140\"")
@@ -73,7 +77,7 @@ pub (crate) async fn generate_sora2_video(
.header("sec-fetch-site", "same-origin");
if let Some(timeout) = args.request_timeout {
http_request = http_request.timeout(timeout);
request_builder = request_builder.timeout(timeout);
}
let orientation = match args.orientation {
@@ -112,7 +116,8 @@ pub (crate) async fn generate_sora2_video(
storyboard_id: None,
};
let http_request = http_request.json(&request_body).build()
let http_request = request_builder.json(&request_body)
.build()
.map_err(|err| {
error!("Error building Sora image generation HTTP request: {:?}", err);
SoraClientError::WreqClientError(err)
@@ -135,11 +140,7 @@ pub (crate) async fn generate_sora2_video(
if !status.is_success() {
error!("Sora image generation request returned an error (code {}) : {:?}", status.as_u16(), response_body);
return Err(SoraGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: status,
body: response_body.to_string(),
}.into());
return Err(classify_general_http_status_code_and_body(status, response_body));
}
let response : HttpCreateResponse = serde_json::from_str(response_body)
@@ -161,7 +162,8 @@ mod tests {
pub async fn manual_test() -> AnyhowResult<()> {
let creds = get_test_credentials()?;
let request = GenerateSora2VideoArgs {
prompt: "A cute corgi wearing glasses, sitting on a picnic blanket and reading a book, digital art",
//prompt: "A cute corgi wearing glasses, sitting on a picnic blanket and reading a book, digital art",
prompt: "A dog eating pancakes in an iHop",
credentials: &creds,
request_timeout: None,
orientation: Orientation::Landscape,
@@ -1,4 +1,4 @@
use crate::constants::user_agent::USER_AGENT;
use crate::constants::user_agent::CLIENT_USER_AGENT;
use crate::creds::sora_credential_set::SoraCredentialSet;
use crate::error::sora_client_error::SoraClientError;
use crate::error::sora_error::SoraError;
@@ -7,6 +7,7 @@ use crate::error::sora_specific_api_error::SoraSpecificApiError;
use crate::requests::common::task_id::TaskId;
use log::{error, warn};
use serde_derive::{Deserialize, Serialize};
use std::io::Write;
use std::time::Duration;
use thiserror::Error;
use wreq::{Client, StatusCode};
@@ -145,18 +146,22 @@ pub (crate) async fn image_gen_http_request(
.ok_or(SoraClientError::NoBearerTokenForRequest)?
.to_authorization_header_value();
let sentinel = credentials.sora_sentinel.as_ref()
.map(|sentinel| sentinel.get_sentinel().to_string())
let sentinel = credentials.sora_sentinel_token.as_ref()
.map(|sentinel| sentinel.to_request_header_json())
.transpose()?
.ok_or(SoraClientError::NoSentinelTokenForRequest)?;
//let sentinel = credentials.sora_sentinel.as_ref()
// .map(|sentinel| sentinel.get_sentinel().to_string())
// .ok_or(SoraClientError::NoSentinelTokenForRequest)?;
let mut http_request = client.post(SORA_IMAGE_GEN_URL)
.header("User-Agent", USER_AGENT)
.header("User-Agent", CLIENT_USER_AGENT)
.header("Cookie", &cookie)
.header("Authorization", &authorization_header)
.header("Content-Type", "application/json")
.header("OpenAI-Sentinel-Token", &sentinel);
if let Some(timeout) = request_timeout {
http_request = http_request.timeout(timeout);
}
@@ -236,6 +241,7 @@ mod tests {
use errors::AnyhowResult;
#[test]
#[ignore] // Run manually
fn deserialize_task_id() -> AnyhowResult<()> {
let json = "{\"id\": \"task_foobarbaz\"}";
let response : RawSoraResponse = serde_json::from_str(json)?;
File diff suppressed because one or more lines are too long
@@ -37,33 +37,18 @@ pub async fn sora_image_gen_simple(args: SoraImageGenSimpleRequest<'_>) -> Resul
#[cfg(test)]
mod tests {
use crate::creds::sora_credential_builder::SoraCredentialBuilder;
use crate::requests::image_gen::common::{ImageSize, NumImages};
use crate::requests::image_gen::sora_image_gen_simple::{sora_image_gen_simple, SoraImageGenSimpleRequest};
use crate::test_utils::get_test_credentials::get_test_credentials;
use errors::AnyhowResult;
use std::fs::read_to_string;
use testing::test_file_path::test_file_path;
#[ignore] // You can manually run "ignore" tests in the IDE, but they won't run in CI.
#[tokio::test]
pub async fn manual_test() -> AnyhowResult<()> {
let sentinel = read_to_string(test_file_path("test_data/temp/sentinel.txt")?)?;
let sentinel = sentinel.trim().to_string();
let cookie = read_to_string(test_file_path("test_data/temp/cookie.txt")?)?;
let cookie = cookie.trim().to_string();
let bearer = read_to_string(test_file_path("test_data/temp/bearer.txt")?)?;
let bearer = bearer.trim().to_string();
let creds = SoraCredentialBuilder::new()
.with_cookies(&cookie)
.with_jwt_bearer_token(&bearer)
.with_sora_sentinel(&sentinel)
.build()?;
let creds = get_test_credentials()?;
let response = sora_image_gen_simple(SoraImageGenSimpleRequest {
prompt: "A pirate and a ninja fight in a battle inside a UFO. Fully photo realistic, lifelike, lens flare".to_string(),
prompt: "A pirate ship catches on fire as an alien ufo attacks it, photorealistic".to_string(),
num_images: NumImages::One,
image_size: ImageSize::Square,
credentials: &creds,
@@ -73,6 +58,7 @@ mod tests {
println!("task_id: {}", response.task_id);
assert!(response.task_id.0.starts_with("task_"));
assert_eq!(1, 2);
Ok(())
}
}
@@ -1,4 +1,4 @@
use crate::constants::user_agent::USER_AGENT;
use crate::constants::user_agent::CLIENT_USER_AGENT;
use crate::creds::sora_credential_set::SoraCredentialSet;
use crate::error::sora_client_error::SoraClientError;
use crate::error::sora_error::SoraError;
@@ -102,7 +102,7 @@ pub async fn list_classic_tasks(credentials: &SoraCredentialSet) -> Result<ListT
//}
let http_request = client.get(url.as_str())
.header("User-Agent", USER_AGENT)
.header("User-Agent", CLIENT_USER_AGENT)
.header("Cookie", credentials.cookies.as_str())
.header("Authorization", bearer_header)
.header("Content-Type", "application/json");
@@ -132,7 +132,7 @@ pub async fn list_classic_tasks(credentials: &SoraCredentialSet) -> Result<ListT
if !status.is_success() {
error!("The sora task list request failed; status = {:?} ; response body = {}", status, response_body);
let error = classify_general_http_status_code_and_body(status, &response_body).await;
let error = classify_general_http_status_code_and_body(status, &response_body);
return Err(error);
}
@@ -1,4 +1,4 @@
use crate::constants::user_agent::USER_AGENT;
use crate::constants::user_agent::CLIENT_USER_AGENT;
use crate::creds::sora_credential_set::SoraCredentialSet;
use crate::error::sora_client_error::SoraClientError;
use crate::error::sora_error::SoraError;
@@ -60,7 +60,7 @@ pub async fn list_sora2_drafts(credentials: &SoraCredentialSet) -> Result<ListSo
.header(ACCEPT, "*/*")
.header(REFERER, "https://sora.chatgpt.com/drafts")
.header(ACCEPT_LANGUAGE, "en-US,en;q=0.9")
.header(wreq::header::USER_AGENT, USER_AGENT)
.header(wreq::header::USER_AGENT, CLIENT_USER_AGENT)
.header(COOKIE, &cookie)
.header(AUTHORIZATION, &authorization_header)
.header("priority", "u=1, i")
@@ -1,5 +1,6 @@
pub mod auth_bearer;
pub mod auth_sentinel;
pub mod auth_sentinel_2;
pub mod common;
pub mod deprecated;
pub mod generate_sora2_video;
@@ -1,4 +1,4 @@
use crate::constants::user_agent::USER_AGENT;
use crate::constants::user_agent::CLIENT_USER_AGENT;
use crate::creds::sora_credential_set::SoraCredentialSet;
use crate::error::sora_client_error::SoraClientError;
use crate::error::sora_error::SoraError;
@@ -51,7 +51,7 @@ pub (crate) async fn upload_media_http_request(
.header(AUTHORIZATION, &auth_header)
.header(COOKIE, &cookie)
.header(ORIGIN, "https://sora.chatgpt.com")
.header("user-agent", USER_AGENT)
.header("user-agent", CLIENT_USER_AGENT)
.header(REFERER, "https://sora.chatgpt.com/explore")
.header("priority", "u=1, i")
.header("sec-ch-ua", "\"Chromium\";v=\"140\", \"Not=A?Brand\";v=\"24\", \"Google Chrome\";v=\"140\"")
@@ -1,5 +1,6 @@
use crate::creds::sora_credential_builder::SoraCredentialBuilder;
use crate::creds::sora_credential_set::SoraCredentialSet;
use crate::creds::sora_sentinel_token::SoraSentinelToken;
use errors::AnyhowResult;
use std::fs::read_to_string;
@@ -7,8 +8,13 @@ use std::fs::read_to_string;
pub fn get_test_credentials() -> AnyhowResult<SoraCredentialSet> {
// TODO: This should work on other folks' machines when they exist
// This is so brittle...
let sentinel_token = {
let sentinel_json = read_to_string("/Users/bt/Artcraft/credentials/sora_sentinel_token_store.json")?;
let sentinel_json = sentinel_json.trim().to_string();
SoraSentinelToken::from_persistent_storage_json(&sentinel_json)?
};
let sentinel = read_to_string("/Users/bt/Artcraft/credentials/sora_sentinel.txt")?;
let sentinel = sentinel.trim().to_string();
let cookie = read_to_string("/Users/bt/Artcraft/credentials/sora_cookies.txt")?;
let cookie = cookie.trim().to_string();
@@ -20,7 +26,8 @@ pub fn get_test_credentials() -> AnyhowResult<SoraCredentialSet> {
.with_cookies(&cookie)
.with_jwt_bearer_token(&bearer)
.with_sora_sentinel(&sentinel)
.with_sora_sentinel_token(&sentinel_token)
.build()?;
Ok(creds)
}
}
@@ -11,5 +11,5 @@ pub async fn classify_general_http_error(response: wreq::Response) -> SoraError
Err(err) => return SoraGenericApiError::WreqError(err).into(),
};
classify_general_http_status_code_and_body(status, &message).await
classify_general_http_status_code_and_body(status, &message)
}
@@ -31,7 +31,7 @@ Message: {
/// Classify the type of error that occurred.
/// This first assumes the request failed and returned a non-200.
pub async fn classify_general_http_status_code_and_body(status: StatusCode, response_body: &str) -> SoraError {
pub fn classify_general_http_status_code_and_body(status: StatusCode, response_body: &str) -> SoraError {
let message = response_body.to_string();
// TODO: I *think* this is just the bearer token, not the cookie.
+1
View File
@@ -42,6 +42,7 @@ primitives.workspace = true
sqlite_tasks.workspace = true
storyteller_client.workspace = true
tokens.workspace = true
url_utils.workspace = true
web_base64.workspace = true
# External
@@ -18,6 +18,8 @@ use serde_derive::{Deserialize, Serialize};
use sqlite_tasks::queries::list_tasks_for_frontend::list_tasks_for_frontend;
use storyteller_client::endpoints::media_files::delete_media_file::delete_media_file;
use tauri::{AppHandle, State};
use enums::tauri::tasks::task_media_file_class::TaskMediaFileClass;
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::sqlite::tasks::TaskId;
@@ -34,11 +36,33 @@ pub struct TaskQueueItem {
pub model_type: Option<TaskModelType>,
pub provider: Option<GenerationProvider>,
pub provider_job_id: Option<String>,
pub completed_item: Option<CompletedItemData>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub completed_at: Option<DateTime<Utc>>,
}
#[derive(Serialize)]
pub struct CompletedItemData {
pub primary_media_file: MediaFileData,
/// The type of file(s) generated.
pub media_file_class: Option<TaskMediaFileClass>,
/// If generated in a batch, we probably have a batch token we can query.
pub maybe_batch_token: Option<BatchGenerationToken>,
}
#[derive(Serialize)]
pub struct MediaFileData {
pub token: MediaFileToken,
pub cdn_url: String,
pub maybe_thumbnail_url_template: Option<String>,
// NB: The frontend wants this.
pub created_at: DateTime<Utc>,
}
impl SerializeMarker for GetTaskQueueCommandResponse {}
#[tauri::command]
@@ -74,17 +98,45 @@ pub async fn handle_request(
let tasks = list_tasks_for_frontend(task_database.get_connection())
.await?;
Ok(tasks.tasks.into_iter()
.map(|task| TaskQueueItem {
id: task.id,
task_status: task.status,
task_type: task.task_type,
model_type: task.model_type,
provider: task.provider,
provider_job_id: task.provider_job_id,
created_at: task.created_at,
updated_at: task.updated_at,
completed_at: task.completed_at,
})
.collect())
let mut transformed_tasks = Vec::with_capacity(tasks.tasks.len());
for task in tasks.tasks.into_iter() {
let mut completed_item = None;
if task.status == TaskStatus::CompleteSuccess {
let token_and_url = task.on_complete_primary_media_file_token
.zip(task.on_complete_primary_media_file_cdn_url);
if let Some((primary_media_file_token, media_file_url)) = token_and_url {
completed_item = Some(CompletedItemData {
primary_media_file: MediaFileData {
token: primary_media_file_token,
cdn_url: media_file_url.clone(),
maybe_thumbnail_url_template: task.on_complete_primary_media_file_thumbnail_url_template.clone(),
// NB: This isn't the exact completion date. Also, fallback to now if missing.
created_at: task.completed_at.unwrap_or_else(Utc::now),
},
media_file_class: task.on_complete_primary_media_file_class,
maybe_batch_token: task.on_complete_batch_token,
});
} else {
warn!("Task {} is marked complete but has no primary media file token or URL.", task.id);
}
}
transformed_tasks.push(TaskQueueItem {
id: task.id,
task_status: task.status,
task_type: task.task_type,
model_type: task.model_type,
provider: task.provider,
provider_job_id: task.provider_job_id,
created_at: task.created_at,
updated_at: task.updated_at,
completed_at: task.completed_at,
completed_item,
})
}
Ok(transformed_tasks)
}
@@ -29,10 +29,15 @@ impl AppCredentialsDir {
self.path.join("sora_bearer_token.txt")
}
pub fn get_sora_sentinel_file_path(&self) -> PathBuf {
#[deprecated(note="use the new sentinel token methods")]
pub fn get_sora_legacy_sentinel_file_path(&self) -> PathBuf {
self.path.join("sora_sentinel.txt")
}
pub fn get_sora_sentinel_token_file_path(&self) -> PathBuf {
self.path.join("sora_sentinel_token_store.json")
}
pub fn get_storyteller_avt_cookie_file_path(&self) -> PathBuf {
self.path.join("artcraft_avt.txt")
}
@@ -132,8 +132,12 @@ impl AppDataRoot {
self.credentials_dir().get_sora_bearer_token_file_path()
}
pub fn get_sora_sentinel_file_path(&self) -> PathBuf {
self.credentials_dir().get_sora_sentinel_file_path()
pub fn get_sora_legacy_sentinel_file_path(&self) -> PathBuf {
self.credentials_dir().get_sora_legacy_sentinel_file_path()
}
pub fn get_sora_sentinel_token_file_path(&self) -> PathBuf {
self.credentials_dir().get_sora_sentinel_token_file_path()
}
pub fn get_window_size_config_file(&self) -> PathBuf {
@@ -7,4 +7,4 @@
/// We increment this whenever we make a change to the database schema.
/// This prevents deadlock on startup if the schema has changed from the
/// previous version.
pub const TASK_DATABASE_VERSION: u32 = 4;
pub const TASK_DATABASE_VERSION: u32 = 5;
@@ -11,10 +11,12 @@ use crate::services::midjourney::threads::events::maybe_handle_text_to_image_com
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 idempotency::uuid::generate_random_uuid;
@@ -26,12 +28,14 @@ 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 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::prompts::create_prompt::create_prompt;
use storyteller_client::error::api_error::ApiError;
@@ -264,6 +268,8 @@ async fn upload_midjourney_batch(
info!("Using synthetic batch token: {:?}", &batch_token);
let mut maybe_primary_media_file_token = None;
for index in 0..4 {
info!("Downloading generated Midjourney file...");
@@ -302,8 +308,11 @@ async fn upload_midjourney_batch(
}).await;
match result {
Ok(media_file) => {
info!("Successfully uploaded to backend: {:?}", media_file);
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(StorytellerError::Api(ApiError::TooManyRequests(_))) => {
@@ -326,10 +335,36 @@ async fn upload_midjourney_batch(
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
let updated = update_task_status(UpdateTaskArgs {
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,
status: TaskStatus::CompleteSuccess,
maybe_batch_token: Some(&batch_token),
maybe_primary_media_file_token: maybe_primary_media_file_token.as_ref(),
maybe_primary_media_file_class: Some(TaskMediaFileClass::Image), // TODO: Just images for now.
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 {
@@ -6,11 +6,13 @@ use openai_sora_client::creds::sora_credential_set::SoraCredentialSet;
use openai_sora_client::creds::sora_jwt_bearer_token::SoraJwtBearerToken;
use openai_sora_client::creds::sora_sentinel::SoraSentinel;
use std::fs::read_to_string;
use openai_sora_client::creds::sora_sentinel_token::SoraSentinelToken;
pub fn read_sora_credentials_from_disk(app_data_root: &AppDataRoot) -> AnyhowResult<SoraCredentialSet> {
let cookie_file = app_data_root.get_sora_cookie_file_path();
let bearer_file = app_data_root.get_sora_bearer_token_file_path();
let sentinel_file = app_data_root.get_sora_sentinel_file_path();
let legacy_sentinel_file = app_data_root.get_sora_legacy_sentinel_file_path();
let sentinel_token_file = app_data_root.get_sora_sentinel_token_file_path();
if !cookie_file.exists() {
return Err(anyhow!("Cookie file does not exist: {:?}", cookie_file));
@@ -24,6 +26,7 @@ pub fn read_sora_credentials_from_disk(app_data_root: &AppDataRoot) -> AnyhowRes
let mut bearer = None;
let mut sentinel = None;
let mut sentinel_token = None;
if bearer_file.exists() {
let value = read_to_string(&bearer_file)?
@@ -32,17 +35,26 @@ pub fn read_sora_credentials_from_disk(app_data_root: &AppDataRoot) -> AnyhowRes
bearer = Some(SoraJwtBearerToken::new(value)?);
}
if sentinel_file.exists() {
let value = read_to_string(&sentinel_file)?
if legacy_sentinel_file.exists() {
let value = read_to_string(&legacy_sentinel_file)?
.trim()
.to_string();
sentinel = Some(SoraSentinel::new(value));
}
if sentinel_token_file.exists() {
let value = read_to_string(&sentinel_token_file)?
.trim()
.to_string();
sentinel_token = Some(SoraSentinelToken::from_persistent_storage_json(&value)?);
}
Ok(SoraCredentialSet::initialize(
cookie,
bearer,
sentinel,
sentinel_token,
))
}
@@ -6,6 +6,7 @@ use openai_sora_client::creds::sora_cookies::SoraCookies;
use openai_sora_client::creds::sora_credential_set::SoraCredentialSet;
use openai_sora_client::creds::sora_jwt_bearer_token::SoraJwtBearerToken;
use openai_sora_client::creds::sora_sentinel::SoraSentinel;
use openai_sora_client::creds::sora_sentinel_token::SoraSentinelToken;
use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session;
use std::fs::{read_to_string, OpenOptions};
use std::io::Write;
@@ -13,7 +14,8 @@ use std::io::Write;
pub async fn read_sora_credentials_from_disk_with_upgrade(app_data_root: &AppDataRoot) -> AnyhowResult<SoraCredentialSet> {
let cookie_file = app_data_root.get_sora_cookie_file_path();
let bearer_file = app_data_root.get_sora_bearer_token_file_path();
let sentinel_file = app_data_root.get_sora_sentinel_file_path();
let legacy_sentinel_file = app_data_root.get_sora_legacy_sentinel_file_path();
let sentinel_token_file = app_data_root.get_sora_sentinel_token_file_path();
if !cookie_file.exists() {
return Err(anyhow!("Cookie file does not exist: {:?}", cookie_file));
@@ -27,6 +29,7 @@ pub async fn read_sora_credentials_from_disk_with_upgrade(app_data_root: &AppDat
let mut bearer = None;
let mut sentinel = None;
let mut sentinel_token = None;
if bearer_file.exists() {
let value = read_to_string(&bearer_file)?
@@ -35,18 +38,27 @@ pub async fn read_sora_credentials_from_disk_with_upgrade(app_data_root: &AppDat
bearer = Some(SoraJwtBearerToken::new(value)?);
}
if sentinel_file.exists() {
let value = read_to_string(&sentinel_file)?
if legacy_sentinel_file.exists() {
let value = read_to_string(&legacy_sentinel_file)?
.trim()
.to_string();
sentinel = Some(SoraSentinel::new(value));
}
if sentinel_token_file.exists() {
let value = read_to_string(&sentinel_token_file)?
.trim()
.to_string();
sentinel_token = Some(SoraSentinelToken::from_persistent_storage_json(&value)?);
}
let mut credentials = SoraCredentialSet::initialize(
cookie,
bearer,
sentinel,
sentinel_token,
);
let response = maybe_upgrade_or_renew_session(&mut credentials).await;
@@ -72,7 +84,7 @@ pub async fn read_sora_credentials_from_disk_with_upgrade(app_data_root: &AppDat
}
}
if !sentinel_file.exists() {
if !legacy_sentinel_file.exists() {
if let Some(sentinel) = &credentials.sora_sentinel {
info!("Persisting sentinel value...");
let value = sentinel.get_sentinel();
@@ -81,7 +93,23 @@ pub async fn read_sora_credentials_from_disk_with_upgrade(app_data_root: &AppDat
.create(true)
.write(true)
.truncate(true)
.open(&sentinel_file)?;
.open(&legacy_sentinel_file)?;
file.write_all(value.as_bytes())?;
file.flush()?;
}
}
if !sentinel_token_file.exists() {
if let Some(sentinel_token) = &credentials.sora_sentinel_token {
info!("Persisting sentinel token...");
let value = sentinel_token.to_persistent_storage_json()?;
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&sentinel_token_file)?;
file.write_all(value.as_bytes())?;
file.flush()?;
@@ -90,7 +90,7 @@ impl SoraCredentialManager {
remove_file_if_exists(creds_dir.get_sora_cookie_file_path())?;
remove_file_if_exists(creds_dir.get_sora_bearer_token_file_path())?;
remove_file_if_exists(creds_dir.get_sora_sentinel_file_path())?;
remove_file_if_exists(creds_dir.get_sora_legacy_sentinel_file_path())?;
Ok(())
}
@@ -104,7 +104,7 @@ impl SoraCredentialManager {
if let Err(err) = fs::remove_file(creds_dir.get_sora_bearer_token_file_path()) {
error!("Failed to remove bearer token file: {:?}", err);
}
if let Err(err) = fs::remove_file(creds_dir.get_sora_sentinel_file_path()) {
if let Err(err) = fs::remove_file(creds_dir.get_sora_legacy_sentinel_file_path()) {
error!("Failed to remove sentinel file: {:?}", err);
}
}
@@ -166,7 +166,7 @@ impl SoraCredentialManager {
.create(true)
.write(true)
.truncate(true)
.open(self.app_data_root.get_sora_sentinel_file_path())?;
.open(self.app_data_root.get_sora_legacy_sentinel_file_path())?;
file.write_all(sentinel.as_bytes())?;
file.flush()?;
@@ -206,7 +206,7 @@ fn persist_jwt_bearer_to_disk(bearer: &SoraJwtBearerToken, app_data_root: &AppDa
}
fn persist_sentinel_to_disk(sentinel: &SoraSentinel, app_data_root: &AppDataRoot) -> AnyhowResult<()> {
let filename = app_data_root.get_sora_sentinel_file_path();
let filename = app_data_root.get_sora_legacy_sentinel_file_path();
let mut file = OpenOptions::new()
.create(true)
@@ -0,0 +1,15 @@
#[derive(Clone, Copy)]
pub enum DownloadExtension {
Png,
Mp4,
}
impl DownloadExtension {
pub fn as_extension_without_period(&self) -> &'static str {
match self {
DownloadExtension::Png => "png",
DownloadExtension::Mp4 => "mp4",
}
}
}
@@ -0,0 +1,6 @@
#[derive(Copy, Clone, Eq, PartialEq)]
pub (super) enum GenerationType {
Image,
Video,
}
@@ -9,10 +9,15 @@ use crate::core::state::task_database::TaskDatabase;
use crate::core::utils::task_database_pending_statuses::TASK_DATABASE_PENDING_STATUSES;
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::sora::threads::sora_task_polling::helpers::download_extension::DownloadExtension;
use crate::services::sora::threads::sora_task_polling::helpers::generation_type::GenerationType;
use crate::services::sora::threads::sora_task_polling::helpers::upload_generation_to_backend::{upload_generation_to_backend, UploadGenerationToBackendArgs};
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 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;
use errors::AnyhowResult;
use idempotency::uuid::generate_random_uuid;
@@ -24,17 +29,20 @@ use openai_sora_client::requests::common::task_id::TaskId;
use openai_sora_client::requests::list_classic_tasks::list_classic_tasks::{PartialGeneration, PartialTaskResponse};
use reqwest::Url;
use sqlite_tasks::queries::list_tasks_by_provider_and_status::{list_tasks_by_provider_and_status, ListTasksByProviderAndStatusArgs, Task, TaskList};
use sqlite_tasks::queries::update_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 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 tauri::AppHandle;
use tempdir::TempDir;
use url_utils::extension::extract_download_extension_from_url::{extract_download_extension_from_url, extract_download_extension_from_url_str};
pub struct SuccessfulGeneration {
pub prompt: Option<String>,
@@ -47,11 +55,6 @@ pub struct GenerationItem {
pub url: String,
}
#[derive(Copy, Clone, Eq, PartialEq)]
enum GenerationType {
Image,
Video,
}
pub async fn handle_classic_successful_generations(
app_handle: &AppHandle,
@@ -61,6 +64,7 @@ pub async fn handle_classic_successful_generations(
storyteller_creds: &StorytellerCredentialSet,
succeeded_tasks_by_id: &HashMap<TaskId, SuccessfulGeneration>,
sqlite_tasks_by_sora_task_id: &HashMap<String, Task>,
recommended_download_extension: DownloadExtension,
) -> AnyhowResult<()> {
for (task_id, generation) in succeeded_tasks_by_id.iter() {
@@ -96,46 +100,67 @@ pub async fn handle_classic_successful_generations(
info!("Created prompt: {:?}", &prompt_response.prompt_token);
let mut maybe_primary_media_file_token = None;
for (_i, item) in generation.items.iter().enumerate() {
info!("Downloading generated file...");
let download_path = download_generation_item(item, &app_data_root).await?;
let download_path = download_generation_item(item, &app_data_root, recommended_download_extension).await?;
info!("Uploading to backend...");
match generation_type {
GenerationType::Image => {
let result = upload_image_media_file_from_file(UploadImageFromFileArgs {
api_host: &app_env_configs.storyteller_host,
maybe_creds: Some(&storyteller_creds),
path: download_path,
is_intermediate_system_file: false,
maybe_prompt_token: Some(&prompt_response.prompt_token),
maybe_batch_token: None, // TODO: This should be added soon.
}).await?;
let media_token = upload_generation_to_backend(UploadGenerationToBackendArgs {
storyteller_api_host: &app_env_configs.storyteller_host,
storyteller_creds: &storyteller_creds,
upload_path: download_path,
maybe_prompt_token: Some(&prompt_response.prompt_token),
maybe_batch_token: None, // TODO: This should be added soon.
generation_type,
}).await?;
info!("Uploaded image to API backend: {:?}", result.media_file_token);
}
GenerationType::Video => {
let result = upload_video_media_file_from_file(UploadVideoFromFileArgs {
api_host: &app_env_configs.storyteller_host,
maybe_creds: Some(&storyteller_creds),
path: download_path,
maybe_prompt_token: Some(&prompt_response.prompt_token),
}).await?;
info!("Uploaded video to API backend: {:?}", result.media_file_token);
}
if maybe_primary_media_file_token.is_none() {
maybe_primary_media_file_token = Some(media_token.clone());
}
}
// Clear from SQLite task database.
if let Some(local_task) = sqlite_tasks_by_sora_task_id.get(task_id.as_str()) {
info!("Marking local task as failed: {:?}", local_task.id);
info!("Marking local task as succeeded: {:?}", local_task.id);
let updated = update_task_status(UpdateTaskArgs {
let generation_class = match generation_type {
GenerationType::Image => TaskMediaFileClass::Image,
GenerationType::Video => TaskMediaFileClass::Video,
};
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,
status: task_status::TaskStatus::CompleteSuccess,
maybe_batch_token: None, // TODO: Support when we have batches of items.
maybe_primary_media_file_token: maybe_primary_media_file_token.as_ref(),
maybe_primary_media_file_class: Some(generation_class),
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 {
@@ -158,18 +183,26 @@ pub async fn handle_classic_successful_generations(
}
async fn download_generation_item(generation: &GenerationItem, app_data_root: &AppDataRoot) -> AnyhowResult<PathBuf> {
let url = Url::parse(&generation.url)?;
async fn download_generation_item(
generation: &GenerationItem,
app_data_root: &AppDataRoot,
recommended_download_extension: DownloadExtension
) -> AnyhowResult<PathBuf> {
info!("Downloading generation item from URL: {}", generation.url.as_str());
let response = reqwest::get(&generation.url).await?;
let image_bytes = response.bytes().await?;
let ext = url.path().split(".").last().unwrap_or("png");
let extension = extract_download_extension_from_url_str(&generation.url)
.map(|ext| ext.as_extension_without_period())
.unwrap_or_else(|| recommended_download_extension.as_extension_without_period());
let tempdir = app_data_root.temp_dir().path();
let download_filename = format!("{}.{}", generation.item_id, ext);
let download_filename = format!("{}.{}", generation.item_id, extension);
let download_path = tempdir.join(download_filename);
info!("Writing to path: {:?}", download_path);
let mut file = File::create(&download_path)?;
file.write_all(&image_bytes)?;
@@ -2,3 +2,7 @@ pub (super) mod handle_successful_generations;
pub (super) mod handle_failed_generations;
pub (super) mod poll_classic_sora_tasks;
pub (super) mod poll_sora_2_tasks;
mod download_extension;
mod upload_metadata;
mod upload_generation_to_backend;
mod generation_type;
@@ -36,6 +36,7 @@ use storyteller_client::endpoints::media_files::upload_image_media_file_from_fil
use storyteller_client::endpoints::prompts::create_prompt::create_prompt;
use tauri::AppHandle;
use tempdir::TempDir;
use crate::services::sora::threads::sora_task_polling::helpers::download_extension::DownloadExtension;
pub async fn poll_classic_sora_tasks(
app_handle: &AppHandle,
@@ -117,6 +118,7 @@ pub async fn poll_classic_sora_tasks(
&storyteller_creds,
&sora_succeeded_tasks_by_id,
&local_sqlite_tasks_by_sora_task_id,
DownloadExtension::Png,
).await?;
Ok(())
@@ -38,6 +38,7 @@ use storyteller_client::endpoints::media_files::upload_image_media_file_from_fil
use storyteller_client::endpoints::prompts::create_prompt::create_prompt;
use tauri::AppHandle;
use tempdir::TempDir;
use crate::services::sora::threads::sora_task_polling::helpers::download_extension::DownloadExtension;
pub async fn poll_sora_2_tasks(
app_handle: &AppHandle,
@@ -110,6 +111,7 @@ pub async fn poll_sora_2_tasks(
&storyteller_creds,
&sora_succeeded_drafts_by_id,
&local_sqlite_tasks_by_sora_task_id,
DownloadExtension::Mp4,
).await?;
Ok(())
@@ -0,0 +1,69 @@
use crate::services::sora::threads::sora_task_polling::helpers::generation_type::GenerationType;
use errors::AnyhowResult;
use log::info;
use std::path::Path;
use storyteller_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use storyteller_client::endpoints::media_files::upload_image_media_file_from_file::{upload_image_media_file_from_file, UploadImageFromFileArgs};
use storyteller_client::endpoints::media_files::upload_video_media_file_from_file::{upload_video_media_file_from_file, UploadVideoFromFileArgs};
use storyteller_client::utils::api_host::ApiHost;
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::prompts::PromptToken;
pub(super) struct UploadGenerationToBackendArgs<'a, P: AsRef<Path>> {
pub storyteller_api_host: &'a ApiHost,
pub storyteller_creds: &'a StorytellerCredentialSet,
/// The file we're uploading.
/// NB: Path needs to be owned for the request.
pub upload_path: P,
/// If provided, this is the prompt that this image is associated with.
/// NOTE: Cannot set `is_intermediate_system_file = true` if this is set.
pub maybe_prompt_token: Option<&'a PromptToken>,
// /// If provided, this is the service provider that created the image.
// /// NOTE: Cannot set `is_intermediate_system_file = true` if this is set.
// pub maybe_generation_provider: Option<GenerationProvider>,
/// If provided, this groups the file into a batch
/// TODO: This shouldn't be set clientside without the backend generating the token
/// and cryptographically securing it. But we need to go fast here.
pub maybe_batch_token: Option<&'a BatchGenerationToken>,
/// The type of generation (image or video).
pub generation_type: GenerationType,
}
pub(super) async fn upload_generation_to_backend<P: AsRef<Path>>(args: UploadGenerationToBackendArgs<'_, P>) -> AnyhowResult<MediaFileToken> {
let media_token;
match args.generation_type {
GenerationType::Image => {
let result = upload_image_media_file_from_file(UploadImageFromFileArgs {
api_host: &args.storyteller_api_host,
maybe_creds: Some(&args.storyteller_creds),
path: args.upload_path,
is_intermediate_system_file: false,
maybe_prompt_token: args.maybe_prompt_token,
maybe_batch_token: None, // TODO: This should be added soon.
}).await?;
info!("Uploaded image to API backend: {:?}", result.media_file_token);
media_token = result.media_file_token;
}
GenerationType::Video => {
let result = upload_video_media_file_from_file(UploadVideoFromFileArgs {
api_host: &args.storyteller_api_host,
maybe_creds: Some(&args.storyteller_creds),
path: args.upload_path,
maybe_prompt_token: args.maybe_prompt_token,
}).await?;
info!("Uploaded video to API backend: {:?}", result.media_file_token);
media_token = result.media_file_token;
}
}
Ok(media_token)
}
@@ -12,22 +12,16 @@ use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder};
/// Name of the window
pub (super) const LOGIN_WINDOW_NAME: &str = "login_window";
pub (super) const START_URL_STR: &str = "https://google.com";
pub (super) static START_URL: Lazy<Url> = Lazy::new(|| {
Url::parse(START_URL_STR).expect("URL should parse")
Url::parse("https://google.com").expect("URL should parse")
});
pub (super) const SORA_HOMEPAGE_URL_STR: &str = "https://sora.com/";
pub (super) static SORA_HOMEPAGE_URL : Lazy<Url> = Lazy::new(|| {
Url::parse(SORA_HOMEPAGE_URL_STR).expect("URL should parse")
Url::parse("https://sora.com/").expect("URL should parse")
});
pub (super) const SORA_LOGIN_URL_STR: &str = "https://chatgpt.com/auth/login?next=%2Fsora%2F";
pub (super) static SORA_LOGIN_URL: Lazy<Url> = Lazy::new(|| {
Url::parse(SORA_LOGIN_URL_STR).expect("URL should parse")
Url::parse("https://chatgpt.com/auth/login?next=%2Fsora%2F").expect("URL should parse")
});
//pub const SORA_ROOT_URL_STR: &str = "https://sora.com/";
@@ -17,26 +17,31 @@ use crate::services::storyteller::threads::events::maybe_handle_text_to_image_co
use crate::services::storyteller::threads::events::maybe_send_background_removal_complete_event::maybe_send_background_removal_complete_event;
use anyhow::anyhow;
use artcraft_api_defs::jobs::list_session_jobs::ListSessionJobsItem;
use artcraft_api_defs::utils::media_links_to_thumbnail_template::media_links_to_thumbnail_template;
use enums::by_table::generic_inference_jobs::inference_category::InferenceCategory;
use enums::common::generation_provider::GenerationProvider;
use enums::common::job_status_plus::JobStatusPlus;
use enums::tauri::tasks::task_media_file_class::TaskMediaFileClass;
use enums::tauri::tasks::task_status::TaskStatus;
use enums::tauri::tasks::task_type::TaskType;
use errors::AnyhowResult;
use log::{error, info};
use reqwest::Url;
use sqlite_tasks::queries::list_tasks_by_provider_and_tokens::{list_tasks_by_provider_and_tokens, ListTasksArgs, Task};
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;
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
use storyteller_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use storyteller_client::error::api_error::ApiError;
use storyteller_client::error::storyteller_error::StorytellerError;
use storyteller_client::endpoints::jobs::list_session_jobs::{list_session_jobs, States};
use storyteller_client::endpoints::media_files::upload_image_media_file_from_file::upload_image_media_file_from_file;
use storyteller_client::error::api_error::ApiError;
use storyteller_client::error::storyteller_error::StorytellerError;
use tauri::AppHandle;
use tempdir::TempDir;
use tokens::tokens::media_files::MediaFileToken;
pub async fn storyteller_task_polling_thread(
app_handle: AppHandle,
@@ -134,10 +139,23 @@ async fn polling_loop(
_ => {} // Fall-through.
}
let updated = update_task_status(UpdateTaskArgs {
let maybe_primary_media_file_token = job.maybe_result
.as_ref()
.map(|result| MediaFileToken::new_from_str(&result.entity_token));
let updated = update_successful_task_status_with_metadata(UpdateSuccessfulTaskArgs {
db: task_database.get_connection(),
task_id: &task.id,
status: TaskStatus::CompleteSuccess,
maybe_batch_token: job.maybe_result
.as_ref()
.map(|result| result.maybe_batch_token.as_ref())
.flatten(),
maybe_primary_media_file_token: maybe_primary_media_file_token.as_ref(),
maybe_primary_media_file_class: get_media_file_class(&job),
maybe_primary_media_file_thumbnail_url_template: get_thumbnail_template(&job),
maybe_primary_media_file_cdn_url: job.maybe_result
.as_ref()
.map(|result| result.media_links.cdn_url.as_str()),
}).await?;
if !updated {
@@ -212,3 +230,54 @@ async fn send_additional_events(
error!("Failed to send background removal complete event: {:?}", err);
}
}
fn get_thumbnail_template<'a>(job: &'a ListSessionJobsItem) -> Option<&'a str> {
let links = match job.maybe_result.as_ref() {
None => return None,
Some(result) => &result.media_links,
};
media_links_to_thumbnail_template(links)
}
fn get_media_file_class<'a>(job: &'a ListSessionJobsItem) -> Option<TaskMediaFileClass> {
match job.request.inference_category {
InferenceCategory::BackgroundRemoval => return Some(TaskMediaFileClass::Image),
InferenceCategory::ImageGeneration => return Some(TaskMediaFileClass::Image),
InferenceCategory::VideoGeneration => return Some(TaskMediaFileClass::Video),
InferenceCategory::ObjectGeneration => return Some(TaskMediaFileClass::Dimensional),
_ => {}, // Fall-through
}
let result = match job.maybe_result.as_ref() {
None => return None,
Some(result) => result,
};
let url = result.media_links.cdn_url.as_str();
if url.ends_with("jpg")
|| url.ends_with("jpeg")
|| url.ends_with("png")
{
return Some(TaskMediaFileClass::Image);
}
if url.ends_with("mp4")
|| url.ends_with("webm")
{
return Some(TaskMediaFileClass::Video);
}
if url.ends_with("glb") {
return Some(TaskMediaFileClass::Dimensional);
}
if url.ends_with("wav")
|| url.ends_with("mp3")
{
return Some(TaskMediaFileClass::Audio);
}
None
}
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "url_utils"
edition = "2024"
version = "0.0.1"
publish = false
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[lib]
name = "url_utils"
path = "src/lib.rs"
[dependencies]
# Workspace hack (faster compile times)
workspace-build-acceleration.workspace = true
# Internal
# (None)
# External
url.workspace = true
[dev-dependencies]
# (None)
@@ -0,0 +1,71 @@
/// When downloading a remote file, it makes sense to try to match the same file extension (with reasonable safeguards).
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
pub enum DownloadExtension {
// Image
Jpg,
Png,
// Video
Mp4,
Webm,
// Audio
Mp3,
Wav,
// Text
Json,
Txt,
}
impl DownloadExtension {
pub fn as_extension_without_period(&self) -> &'static str {
match self {
// Image
Self::Jpg => "jpg",
Self::Png => "png",
// Video
Self::Mp4 => "mp4",
Self::Webm => "webm",
// Audio
Self::Mp3 => "mp3",
Self::Wav => "wav",
// Text
Self::Json => "json",
Self::Txt => "txt",
}
}
pub fn as_extension_with_period(&self) -> &'static str {
match self {
// Image
Self::Jpg => ".jpg",
Self::Png => ".png",
// Video
Self::Mp4 => ".mp4",
Self::Webm => ".webm",
// Audio
Self::Mp3 => ".mp3",
Self::Wav => ".wav",
// Text
Self::Json => ".json",
Self::Txt => ".txt",
}
}
pub fn try_from_str(extension: &str) -> Option<Self> {
match extension.to_lowercase().as_str() {
// Image
"jpg" | "jpeg" => Some(Self::Jpg),
"png" => Some(Self::Png),
// Video
"mp4" => Some(Self::Mp4),
"webm" => Some(Self::Webm),
// Audio
"mp3" => Some(Self::Mp3),
"wav" => Some(Self::Wav),
// Text
"json" => Some(Self::Json),
"txt" => Some(Self::Txt),
_ => None,
}
}
}
@@ -0,0 +1,37 @@
use crate::extension::download_extension::DownloadExtension;
use url::Url;
/// This should be used for naming files on the local file system based on their URL.
pub fn extract_download_extension_from_url_str(url: &str) -> Option<DownloadExtension> {
let parsed_url = Url::parse(url).ok()?;
extract_download_extension_from_url(&parsed_url)
}
/// This should be used for naming files on the local file system based on their URL.
pub fn extract_download_extension_from_url(url: &Url) -> Option<DownloadExtension> {
let path = url.path();
let extension = std::path::Path::new(path)
.extension()
.and_then(|ext| ext.to_str())?;
DownloadExtension::try_from_str(extension)
}
#[cfg(test)]
mod tests {
use crate::extension::download_extension::DownloadExtension;
use crate::extension::extract_download_extension_from_url::extract_download_extension_from_url_str;
#[test]
fn success_case() {
let url = "https://storyteller.ai/example.jpg";
let maybe_extension= extract_download_extension_from_url_str(url);
assert_eq!(maybe_extension, Some(DownloadExtension::Jpg));
}
#[test]
fn difficult_url() {
let url = "https://videos.youtube.com/foo/bar/files/00000000-f19a-1275-a092-caa60b38f8d2%2Fraw?se=2025-11-21T22%3A26%3A53Z&sp=r&sv=2024-08-09&sr=b&skoid=8cfff87a-01fc-27c1-9091-32999dcd1389&sktid=aa8ccaf6-e6da-484e-ab11-9c7496c21c53&skt=2025-11-17T21%3A09%3A08Z&ske=2025-10-23T21%3A14%3A08Z&sks=b&skv=2024-08-04&sig=T2U6%2Brw9WJdJagBkCL4/GlFr72KhZGP3QPlobIO7JlU%3D&ac=oaisdsorprnorthcentralus";
let maybe_extension= extract_download_extension_from_url_str(url);
assert!(maybe_extension.is_none());
}
}
@@ -0,0 +1,2 @@
pub mod download_extension;
pub mod extract_download_extension_from_url;
+1
View File
@@ -0,0 +1 @@
pub mod extension;
@@ -2,10 +2,13 @@ use crate::connection::TaskDbConnection;
use crate::error::SqliteTasksError;
use chrono::{DateTime, Utc};
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_media_file_class::TaskMediaFileClass;
use enums::tauri::tasks::task_model_type::TaskModelType;
use enums::tauri::tasks::task_status::TaskStatus;
use enums::tauri::tasks::task_type::TaskType;
use enums::tauri::ux::tauri_command_caller::TauriCommandCaller;
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::sqlite::tasks::TaskId;
pub struct TaskList {
@@ -22,6 +25,11 @@ pub struct TaskItem {
pub frontend_caller: Option<TauriCommandCaller>,
pub frontend_subscriber_id: Option<String>,
pub frontend_subscriber_payload: Option<String>,
pub on_complete_primary_media_file_token: Option<MediaFileToken>,
pub on_complete_primary_media_file_class: Option<TaskMediaFileClass>,
pub on_complete_batch_token: Option<BatchGenerationToken>,
pub on_complete_primary_media_file_cdn_url: Option<String>,
pub on_complete_primary_media_file_thumbnail_url_template: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub completed_at: Option<DateTime<Utc>>,
@@ -43,6 +51,11 @@ pub async fn list_tasks_for_frontend(
frontend_caller,
frontend_subscriber_id,
frontend_subscriber_payload,
on_complete_primary_media_file_token,
on_complete_primary_media_file_class,
on_complete_batch_token,
on_complete_primary_media_file_cdn_url,
on_complete_primary_media_file_thumbnail_url_template,
created_at as "created_at: DateTime<Utc>",
updated_at as "updated_at: DateTime<Utc>",
completed_at as "completed_at: DateTime<Utc>"
@@ -72,6 +85,13 @@ pub async fn list_tasks_for_frontend(
.transpose()?,
frontend_subscriber_id: raw.frontend_subscriber_id,
frontend_subscriber_payload: raw.frontend_subscriber_payload,
on_complete_primary_media_file_token: raw.on_complete_primary_media_file_token.map(|t| MediaFileToken::new_from_str(&t)),
on_complete_primary_media_file_class: raw.on_complete_primary_media_file_class
.map(|c| TaskMediaFileClass::from_str(&c))
.transpose()?,
on_complete_batch_token: raw.on_complete_batch_token.map(|t| BatchGenerationToken::new_from_str(&t)),
on_complete_primary_media_file_cdn_url: raw.on_complete_primary_media_file_cdn_url,
on_complete_primary_media_file_thumbnail_url_template: raw.on_complete_primary_media_file_thumbnail_url_template,
created_at: raw.created_at,
updated_at: raw.updated_at,
completed_at: raw.completed_at,
@@ -93,6 +113,11 @@ struct TaskItemRaw {
frontend_caller: Option<String>,
frontend_subscriber_id: Option<String>,
frontend_subscriber_payload: Option<String>,
on_complete_primary_media_file_token: Option<String>,
on_complete_primary_media_file_class: Option<String>,
on_complete_batch_token: Option<String>,
on_complete_primary_media_file_cdn_url: Option<String>,
on_complete_primary_media_file_thumbnail_url_template: Option<String>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
completed_at: Option<DateTime<Utc>>,
@@ -3,4 +3,5 @@ pub mod list_tasks_by_provider_and_status;
pub mod list_tasks_by_provider_and_tokens;
pub mod list_tasks_for_frontend;
pub mod mark_task_as_dismissed;
pub mod update_successful_task_status_with_metadata;
pub mod update_task_status;
@@ -0,0 +1,59 @@
use crate::connection::TaskDbConnection;
use crate::error::SqliteTasksError;
use enums::tauri::tasks::task_media_file_class::TaskMediaFileClass;
use enums::tauri::tasks::task_status::TaskStatus;
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::sqlite::tasks::TaskId;
const SUCCESSFUL_STATUS : &str = TaskStatus::CompleteSuccess.to_str();
pub struct UpdateSuccessfulTaskArgs<'a> {
pub db: &'a TaskDbConnection,
pub task_id: &'a TaskId,
pub maybe_batch_token: Option<&'a BatchGenerationToken>,
pub maybe_primary_media_file_token: Option<&'a MediaFileToken>,
pub maybe_primary_media_file_class: Option<TaskMediaFileClass>,
pub maybe_primary_media_file_cdn_url: Option<&'a str>,
pub maybe_primary_media_file_thumbnail_url_template: Option<&'a str>,
}
/// Returns true if rows were updated.
pub async fn update_successful_task_status_with_metadata(
args: UpdateSuccessfulTaskArgs<'_>,
) -> Result<bool, SqliteTasksError> {
// TODO(bt,2025-07-12): Fix this. The sqlx mysql queries never required temporaries
let task_id_temp = args.task_id.as_str();
let maybe_batch_token_temp = args.maybe_batch_token.map(|t| t.as_str());
let maybe_primary_media_token_temp = args.maybe_primary_media_file_token.map(|t| t.as_str());
let maybe_primary_media_class = args.maybe_primary_media_file_class.map(|c| c.to_str());
// TODO(bt,2025-07-15): We can't set a LIMIT without a certain compiler flag for SQLite ?
let query = sqlx::query!(r#"
UPDATE tasks
SET
task_status = ?,
on_complete_batch_token = ?,
on_complete_primary_media_file_token = ?,
on_complete_primary_media_file_class = ?,
on_complete_primary_media_file_cdn_url = ?,
on_complete_primary_media_file_thumbnail_url_template = ?
WHERE id = ?
"#,
SUCCESSFUL_STATUS,
maybe_batch_token_temp,
maybe_primary_media_token_temp,
maybe_primary_media_class,
args.maybe_primary_media_file_cdn_url,
args.maybe_primary_media_file_thumbnail_url_template,
task_id_temp,
);
// info!("query: {:?}", query.sql());
let res = query.execute(args.db.get_pool()).await?;
let rows_updated = res.rows_affected() > 0;
Ok(rows_updated)
}
@@ -1,3 +1,4 @@
pub mod task_media_file_class;
pub mod task_model_type;
pub mod task_status;
pub mod task_type;
@@ -0,0 +1,140 @@
use std::collections::BTreeSet;
use crate::error::enum_error::EnumError;
#[cfg(test)]
use strum::EnumCount;
#[cfg(test)]
use strum::EnumIter;
use utoipa::ToSchema;
#[cfg_attr(test, derive(EnumIter, EnumCount))]
#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum TaskMediaFileClass {
/// Audio files: wav, mp3, etc.
Audio,
/// Image files: png, jpeg, etc.
Image,
/// Video files: mp4, etc.
Video,
/// 3D engine data: glb, gltf, etc.
Dimensional,
}
impl_enum_display_and_debug_using_to_str!(TaskMediaFileClass);
//impl_mysql_enum_coders!(TaskMediaFileType);
//impl_mysql_from_row!(TaskMediaFileType);
// NB: We can derive `sqlx::Type` instead of using `impl_mysql_enum_coders`
impl TaskMediaFileClass {
pub fn to_str(&self) -> &'static str {
match self {
Self::Audio => "audio",
Self::Image => "image",
Self::Video => "video",
Self::Dimensional => "dimensional",
}
}
pub fn from_str(value: &str) -> Result<Self, EnumError> {
match value {
"audio" => Ok(Self::Audio),
"image" => Ok(Self::Image),
"video" => Ok(Self::Video),
"dimensional" => Ok(Self::Dimensional),
_ => Err(EnumError::CouldNotConvertFromString(value.to_string())),
}
}
pub fn all_variants() -> BTreeSet<Self> {
// NB: BTreeSet is sorted
// NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc.
BTreeSet::from([
Self::Audio,
Self::Image,
Self::Video,
Self::Dimensional,
])
}
}
#[cfg(test)]
mod tests {
use crate::tauri::tasks::task_media_file_class::TaskMediaFileClass;
use crate::test_helpers::assert_serialization;
mod explicit_checks {
use super::*;
use crate::error::enum_error::EnumError;
#[test]
fn test_serialization() {
assert_serialization(TaskMediaFileClass::Audio, "audio");
assert_serialization(TaskMediaFileClass::Image, "image");
assert_serialization(TaskMediaFileClass::Video, "video");
assert_serialization(TaskMediaFileClass::Dimensional, "dimensional");
}
#[test]
fn to_str() {
assert_eq!(TaskMediaFileClass::Audio.to_str(), "audio");
assert_eq!(TaskMediaFileClass::Image.to_str(), "image");
assert_eq!(TaskMediaFileClass::Video.to_str(), "video");
assert_eq!(TaskMediaFileClass::Dimensional.to_str(), "dimensional");
}
#[test]
fn from_str() {
assert_eq!(TaskMediaFileClass::from_str("audio").unwrap(), TaskMediaFileClass::Audio);
assert_eq!(TaskMediaFileClass::from_str("image").unwrap(), TaskMediaFileClass::Image);
assert_eq!(TaskMediaFileClass::from_str("video").unwrap(), TaskMediaFileClass::Video);
assert_eq!(TaskMediaFileClass::from_str("dimensional").unwrap(), TaskMediaFileClass::Dimensional);
}
#[test]
fn from_str_err() {
let result = TaskMediaFileClass::from_str("asdf");
assert!(result.is_err());
if let Err(EnumError::CouldNotConvertFromString(value)) = result {
assert_eq!(value, "asdf");
} else {
panic!("Expected EnumError::CouldNotConvertFromString");
}
}
#[test]
fn all_variants() {
let mut variants = TaskMediaFileClass::all_variants();
assert_eq!(variants.len(), 4);
assert_eq!(variants.pop_first(), Some(TaskMediaFileClass::Audio));
assert_eq!(variants.pop_first(), Some(TaskMediaFileClass::Image));
assert_eq!(variants.pop_first(), Some(TaskMediaFileClass::Video));
assert_eq!(variants.pop_first(), Some(TaskMediaFileClass::Dimensional));
assert_eq!(variants.pop_first(), None);
}
}
mod mechanical_checks {
use super::*;
#[test]
fn variant_length() {
use strum::IntoEnumIterator;
assert_eq!(TaskMediaFileClass::all_variants().len(), TaskMediaFileClass::iter().len());
}
#[test]
fn round_trip() {
for variant in TaskMediaFileClass::all_variants() {
// Test to_str(), from_str(), Display, and Debug.
assert_eq!(variant, TaskMediaFileClass::from_str(variant.to_str()).unwrap());
assert_eq!(variant, TaskMediaFileClass::from_str(&format!("{}", variant)).unwrap());
assert_eq!(variant, TaskMediaFileClass::from_str(&format!("{:?}", variant)).unwrap());
}
}
}
}
@@ -35,7 +35,7 @@ impl Default for TaskStatus {
}
impl TaskStatus {
pub fn to_str(&self) -> &'static str {
pub const fn to_str(&self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Started => "started",
@@ -1,130 +1,9 @@
use utoipa::OpenApi;
use crate::http_server::common_requests::auto_product_category::AutoProductCategory;
use crate::http_server::common_requests::media_file_token_path_info::MediaFileTokenPathInfo;
use crate::http_server::common_responses::media::cover_image_links::CoverImageLinks;
use crate::http_server::common_responses::media::media_file_cover_image_details::MediaFileCoverImageDetails;
use crate::http_server::common_responses::media::media_file_cover_image_details::MediaFileDefaultCover;
use crate::http_server::common_responses::media::weights_cover_image_details::*;
use crate::http_server::common_responses::media_file_origin_details::*;
use crate::http_server::common_responses::media_file_social_meta_lite::MediaFileSocialMetaLight;
use crate::http_server::common_responses::pagination_cursors::PaginationCursors;
use crate::http_server::common_responses::pagination_page::PaginationPage;
use crate::http_server::common_responses::simple_entity_stats::SimpleEntityStats;
use crate::http_server::common_responses::simple_response::SimpleResponse;
use crate::http_server::common_responses::tag_info::TagInfo;
use crate::http_server::common_responses::user_details_lite::{UserDefaultAvatarInfo, UserDetailsLight};
use crate::http_server::deprecated_endpoints::conversion::enqueue_fbx_to_gltf_handler::*;
use crate::http_server::deprecated_endpoints::engine::create_scene_handler::*;
use crate::http_server::deprecated_endpoints::workflows::enqueue::enqueue_face_fusion_workflow_handler::*;
use crate::http_server::deprecated_endpoints::workflows::enqueue::enqueue_live_portrait_workflow_handler::*;
use crate::http_server::deprecated_endpoints::workflows::enqueue::vst_common::vst_error::*;
use crate::http_server::deprecated_endpoints::workflows::enqueue::vst_common::vst_request::*;
use crate::http_server::deprecated_endpoints::workflows::enqueue::vst_common::vst_response::*;
use crate::http_server::deprecated_endpoints::workflows::enqueue_video_style_transfer_handler::*;
use crate::http_server::endpoints::analytics::log_browser_session_handler::*;
use crate::http_server::endpoints::app_state::components::get_permissions::AppStateLegacyPermissionFlags;
use crate::http_server::endpoints::app_state::components::get_permissions::AppStatePermissions;
use crate::http_server::endpoints::app_state::components::get_premium_info::AppStatePremiumInfo;
use crate::http_server::endpoints::app_state::components::get_premium_info::AppStateSubscriptionProductKey;
use crate::http_server::endpoints::app_state::components::get_server_info::AppStateServerInfo;
use crate::http_server::endpoints::app_state::components::get_status_alert::AppStateStatusAlertCategory;
use crate::http_server::endpoints::app_state::components::get_status_alert::AppStateStatusAlertInfo;
use crate::http_server::endpoints::app_state::components::get_user_info::AppStateUserInfo;
use crate::http_server::endpoints::app_state::components::get_user_locale::AppStateUserLocale;
use crate::http_server::endpoints::app_state::get_app_state_handler::*;
use crate::http_server::endpoints::beta_keys::create_beta_keys_handler::*;
use crate::http_server::endpoints::beta_keys::edit_beta_key_distributed_flag_handler::*;
use crate::http_server::endpoints::beta_keys::edit_beta_key_note_handler::*;
use crate::http_server::endpoints::beta_keys::list_beta_keys_handler::*;
use crate::http_server::endpoints::beta_keys::redeem_beta_key_handler::*;
use crate::http_server::endpoints::comments::create_comment_handler::*;
use crate::http_server::endpoints::comments::delete_comment_handler::*;
use crate::http_server::endpoints::comments::list_comments_handler::*;
use crate::http_server::endpoints::featured_items::create_featured_item_handler::*;
use crate::http_server::endpoints::featured_items::delete_featured_item_handler::*;
use crate::http_server::endpoints::featured_items::get_is_featured_item_handler::*;
use crate::http_server::endpoints::image_studio::prompt::enqueue_studio_image_generation_handler::*;
use crate::http_server::endpoints::image_studio::upload::upload_snapshot_media_file_handler::*;
use crate::http_server::endpoints::inference_job::delete::dismiss_finished_session_jobs_handler::*;
use crate::http_server::endpoints::inference_job::delete::terminate_inference_job_handler::*;
use crate::http_server::endpoints::inference_job::get::batch_get_inference_job_status_handler::*;
use crate::http_server::endpoints::inference_job::get::get_inference_job_status_handler::*;
use crate::http_server::endpoints::inference_job::list::list_session_jobs_handler::*;
use crate::http_server::endpoints::media_files::common_responses::live_portrait::MediaFileLivePortraitDetails;
use crate::http_server::endpoints::media_files::delete::delete_media_file_handler::*;
use crate::http_server::endpoints::media_files::edit::change_media_file_animation_type_handler::*;
use crate::http_server::endpoints::media_files::edit::change_media_file_engine_category_handler::*;
use crate::http_server::endpoints::media_files::edit::change_media_file_visibility_handler::*;
use crate::http_server::endpoints::media_files::edit::rename_media_file_handler::*;
use crate::http_server::endpoints::media_files::edit::set_media_file_cover_image_handler::*;
use crate::http_server::endpoints::media_files::get::batch_get_media_files_handler::*;
use crate::http_server::endpoints::media_files::get::get_media_file_handler::*;
use crate::http_server::endpoints::media_files::list::list_featured_media_files_handler::*;
use crate::http_server::endpoints::media_files::list::list_media_files_by_batch_token_handler::*;
use crate::http_server::endpoints::media_files::list::list_media_files_for_user_handler::*;
use crate::http_server::endpoints::media_files::list::list_media_files_handler::*;
use crate::http_server::endpoints::media_files::list::list_pinned_media_files_handler::*;
use crate::http_server::endpoints::media_files::search::search_featured_media_files_handler::*;
use crate::http_server::endpoints::media_files::search::search_session_media_files_handler::*;
use crate::http_server::endpoints::media_files::upload::upload_audio_media_file_handler::*;
use crate::http_server::endpoints::media_files::upload::upload_engine_asset::upload_engine_asset_media_file_handler::*;
use crate::http_server::endpoints::media_files::upload::upload_error::MediaFileUploadError;
use crate::http_server::endpoints::media_files::upload::upload_generic::upload_media_file_handler::*;
use crate::http_server::endpoints::media_files::upload::upload_image_media_file_handler::*;
use crate::http_server::endpoints::media_files::upload::upload_new_engine_asset_media_file_handler::*;
use crate::http_server::endpoints::media_files::upload::upload_new_scene_media_file_handler::*;
use crate::http_server::endpoints::media_files::upload::upload_pmx::upload_pmx_media_file_handler::*;
use crate::http_server::endpoints::media_files::upload::upload_saved_scene_media_file_handler::*;
use crate::http_server::endpoints::media_files::upload::upload_scene_snapshot_media_file_handler::*;
use crate::http_server::endpoints::media_files::upload::upload_studio_shot::upload_studio_shot_media_file_handler::*;
use crate::http_server::endpoints::media_files::upload::upload_video_new::upload_new_video_media_file_handler::*;
use crate::http_server::endpoints::media_files::upload::upload_video_old::upload_video_media_file_handler::*;
use crate::http_server::endpoints::media_files::upsert_upload::write_engine_asset::write_engine_asset_media_file_handler::*;
use crate::http_server::endpoints::media_files::upsert_upload::write_error::MediaFileWriteError;
use crate::http_server::endpoints::media_files::upsert_upload::write_scene_file::write_scene_file_media_file_handler::*;
use crate::http_server::endpoints::model_download::enqueue_gptsovits_model_download_handler::*;
use crate::http_server::endpoints::moderation::user_feature_flags::edit_user_feature_flags_handler::*;
use crate::http_server::endpoints::prompts::create_prompt_handler::*;
use crate::http_server::endpoints::prompts::get_prompt_handler::*;
use crate::http_server::endpoints::service::status_alert_handler::*;
use crate::http_server::endpoints::stats::get_unified_queue_stats_handler::*;
use crate::http_server::endpoints::studio_gen2::enqueue_studio_gen2_handler::*;
use crate::http_server::endpoints::tags::list_tags_for_entity_handler::*;
use crate::http_server::endpoints::tags::set_tags_for_entity_handler::*;
use crate::http_server::endpoints::tts::enqueue_infer_tts_handler::enqueue_infer_tts_handler::*;
use crate::http_server::endpoints::user_bookmarks::batch_get_user_bookmarks_handler::*;
use crate::http_server::endpoints::user_bookmarks::create_user_bookmark_handler::*;
use crate::http_server::endpoints::user_bookmarks::delete_user_bookmark_handler::*;
use crate::http_server::endpoints::user_bookmarks::list_user_bookmarks_for_entity_handler::*;
use crate::http_server::endpoints::user_bookmarks::list_user_bookmarks_for_user_handler::*;
use crate::http_server::endpoints::user_ratings::batch_get_user_rating_handler::*;
use crate::http_server::endpoints::user_ratings::get_user_rating_handler::*;
use crate::http_server::endpoints::user_ratings::set_user_rating_handler::*;
use crate::http_server::endpoints::users::create_account_handler::*;
use crate::http_server::endpoints::users::edit_username_handler::*;
use crate::http_server::endpoints::users::get_profile_handler::*;
use crate::http_server::endpoints::users::google_sso::google_sso_handler::*;
use crate::http_server::endpoints::users::login_handler::*;
use crate::http_server::endpoints::users::logout_handler::*;
use crate::http_server::endpoints::users::session_info_handler::*;
use crate::http_server::endpoints::users::session_token_info_handler::*;
use crate::http_server::endpoints::voice_conversion::enqueue_voice_conversion_inference_handler::*;
use crate::http_server::endpoints::voice_designer::inference::enqueue_tts_request::*;
use crate::http_server::endpoints::voice_designer::voice_datasets::list_datasets_by_user::*;
use crate::http_server::endpoints::weights::delete::delete_weight_handler::*;
use crate::http_server::endpoints::weights::get::get_weight_handler::*;
use crate::http_server::endpoints::weights::list::list_available_weights_handler::*;
use crate::http_server::endpoints::weights::list::list_featured_weights_handler::*;
use crate::http_server::endpoints::weights::list::list_pinned_weights_handler::*;
use crate::http_server::endpoints::weights::list::list_weights_by_user_handler::*;
use crate::http_server::endpoints::weights::search::search_model_weights_impl::*;
use crate::http_server::endpoints::weights::update::set_model_weight_cover_image_handler::*;
use crate::http_server::endpoints::weights::update::update_weight_handler::*;
use crate::http_server::web_utils::response_success_helpers::*;
use artcraft_api_defs::common::responses::job_details::JobDetailsLipsyncRequest;
use artcraft_api_defs::common::responses::job_details::JobDetailsLivePortraitRequest;
use artcraft_api_defs::common::responses::media_links::*;
use artcraft_api_defs::common::responses::simple_generic_json_success::SimpleGenericJsonSuccess;
use artcraft_api_defs::generate::image::edit::gpt_image_1_edit_image::*;
use artcraft_api_defs::generate::image::generate_flux_1_dev_text_to_image::GenerateFlux1DevTextToImageAspectRatio;
use artcraft_api_defs::generate::image::generate_flux_1_dev_text_to_image::GenerateFlux1DevTextToImageNumImages;
@@ -174,11 +53,134 @@ use artcraft_api_defs::generate::video::generate_veo_2_image_to_video::GenerateV
use artcraft_api_defs::generate::video::generate_veo_2_image_to_video::GenerateVeo2ImageToVideoRequest;
use artcraft_api_defs::generate::video::generate_veo_2_image_to_video::GenerateVeo2ImageToVideoResponse;
use artcraft_api_defs::jobs::list_session_jobs::*;
use artcraft_api_defs::media_file::delete_media_file::DeleteMediaFilePathInfo;
use artcraft_api_defs::media_file::delete_media_file::DeleteMediaFileRequest;
use artcraft_api_defs::prompts::create_prompt::CreatePromptRequest;
use artcraft_api_defs::prompts::create_prompt::CreatePromptResponse;
use billing_component::stripe::http_endpoints::checkout::create::stripe_create_checkout_session_error::CreateCheckoutSessionError;
use billing_component::stripe::http_endpoints::checkout::create::stripe_create_checkout_session_json_handler::*;
use billing_component::users::http_endpoints::list_active_user_subscriptions_handler::*;
use crate::http_server::common_requests::auto_product_category::AutoProductCategory;
use crate::http_server::common_requests::media_file_token_path_info::MediaFileTokenPathInfo;
use crate::http_server::common_responses::media::cover_image_links::CoverImageLinks;
use crate::http_server::common_responses::media::media_file_cover_image_details::MediaFileCoverImageDetails;
use crate::http_server::common_responses::media::media_file_cover_image_details::MediaFileDefaultCover;
use crate::http_server::common_responses::media::weights_cover_image_details::*;
use crate::http_server::common_responses::media_file_origin_details::*;
use crate::http_server::common_responses::media_file_social_meta_lite::MediaFileSocialMetaLight;
use crate::http_server::common_responses::pagination_cursors::PaginationCursors;
use crate::http_server::common_responses::pagination_page::PaginationPage;
use crate::http_server::common_responses::simple_entity_stats::SimpleEntityStats;
use crate::http_server::common_responses::simple_response::SimpleResponse;
use crate::http_server::common_responses::tag_info::TagInfo;
use crate::http_server::common_responses::user_details_lite::{UserDefaultAvatarInfo, UserDetailsLight};
use crate::http_server::deprecated_endpoints::conversion::enqueue_fbx_to_gltf_handler::*;
use crate::http_server::deprecated_endpoints::engine::create_scene_handler::*;
use crate::http_server::deprecated_endpoints::image_gen::enqueue_image_generation::EnqueueImageGenRequestError;
use crate::http_server::deprecated_endpoints::image_gen::enqueue_image_generation::EnqueueImageGenRequestSuccessResponse;
use crate::http_server::deprecated_endpoints::workflows::enqueue::enqueue_face_fusion_workflow_handler::*;
use crate::http_server::deprecated_endpoints::workflows::enqueue::enqueue_live_portrait_workflow_handler::*;
use crate::http_server::deprecated_endpoints::workflows::enqueue::vst_common::vst_error::*;
use crate::http_server::deprecated_endpoints::workflows::enqueue::vst_common::vst_request::*;
use crate::http_server::deprecated_endpoints::workflows::enqueue::vst_common::vst_response::*;
use crate::http_server::deprecated_endpoints::workflows::enqueue_video_style_transfer_handler::*;
use crate::http_server::endpoints::analytics::log_browser_session_handler::*;
use crate::http_server::endpoints::app_state::components::get_permissions::AppStateLegacyPermissionFlags;
use crate::http_server::endpoints::app_state::components::get_permissions::AppStatePermissions;
use crate::http_server::endpoints::app_state::components::get_premium_info::AppStatePremiumInfo;
use crate::http_server::endpoints::app_state::components::get_premium_info::AppStateSubscriptionProductKey;
use crate::http_server::endpoints::app_state::components::get_server_info::AppStateServerInfo;
use crate::http_server::endpoints::app_state::components::get_status_alert::AppStateStatusAlertCategory;
use crate::http_server::endpoints::app_state::components::get_status_alert::AppStateStatusAlertInfo;
use crate::http_server::endpoints::app_state::components::get_user_info::AppStateUserInfo;
use crate::http_server::endpoints::app_state::components::get_user_locale::AppStateUserLocale;
use crate::http_server::endpoints::app_state::get_app_state_handler::*;
use crate::http_server::endpoints::beta_keys::create_beta_keys_handler::*;
use crate::http_server::endpoints::beta_keys::edit_beta_key_distributed_flag_handler::*;
use crate::http_server::endpoints::beta_keys::edit_beta_key_note_handler::*;
use crate::http_server::endpoints::beta_keys::list_beta_keys_handler::*;
use crate::http_server::endpoints::beta_keys::redeem_beta_key_handler::*;
use crate::http_server::endpoints::comments::create_comment_handler::*;
use crate::http_server::endpoints::comments::delete_comment_handler::*;
use crate::http_server::endpoints::comments::list_comments_handler::*;
use crate::http_server::endpoints::featured_items::create_featured_item_handler::*;
use crate::http_server::endpoints::featured_items::delete_featured_item_handler::*;
use crate::http_server::endpoints::featured_items::get_is_featured_item_handler::*;
use crate::http_server::endpoints::image_studio::upload::upload_snapshot_media_file_handler::*;
use crate::http_server::endpoints::inference_job::delete::dismiss_finished_session_jobs_handler::*;
use crate::http_server::endpoints::inference_job::delete::terminate_inference_job_handler::*;
use crate::http_server::endpoints::inference_job::get::batch_get_inference_job_status_handler::*;
use crate::http_server::endpoints::inference_job::get::get_inference_job_status_handler::*;
use crate::http_server::endpoints::inference_job::list::list_session_jobs_handler::*;
use crate::http_server::endpoints::media_files::common_responses::live_portrait::MediaFileLivePortraitDetails;
use crate::http_server::endpoints::media_files::edit::change_media_file_animation_type_handler::*;
use crate::http_server::endpoints::media_files::edit::change_media_file_engine_category_handler::*;
use crate::http_server::endpoints::media_files::edit::change_media_file_visibility_handler::*;
use crate::http_server::endpoints::media_files::edit::rename_media_file_handler::*;
use crate::http_server::endpoints::media_files::edit::set_media_file_cover_image_handler::*;
use crate::http_server::endpoints::media_files::get::batch_get_media_files_handler::*;
use crate::http_server::endpoints::media_files::get::get_media_file_handler::*;
use crate::http_server::endpoints::media_files::list::list_featured_media_files_handler::*;
use crate::http_server::endpoints::media_files::list::list_media_files_by_batch_token_handler::*;
use crate::http_server::endpoints::media_files::list::list_media_files_for_user_handler::*;
use crate::http_server::endpoints::media_files::list::list_media_files_handler::*;
use crate::http_server::endpoints::media_files::list::list_pinned_media_files_handler::*;
use crate::http_server::endpoints::media_files::search::search_featured_media_files_handler::*;
use crate::http_server::endpoints::media_files::search::search_session_media_files_handler::*;
use crate::http_server::endpoints::media_files::upload::upload_audio_media_file_handler::*;
use crate::http_server::endpoints::media_files::upload::upload_engine_asset::upload_engine_asset_media_file_handler::*;
use crate::http_server::endpoints::media_files::upload::upload_error::MediaFileUploadError;
use crate::http_server::endpoints::media_files::upload::upload_generic::upload_media_file_handler::*;
use crate::http_server::endpoints::media_files::upload::upload_image_media_file_handler::*;
use crate::http_server::endpoints::media_files::upload::upload_new_engine_asset_media_file_handler::*;
use crate::http_server::endpoints::media_files::upload::upload_new_scene_media_file_handler::*;
use crate::http_server::endpoints::media_files::upload::upload_pmx::upload_pmx_media_file_handler::*;
use crate::http_server::endpoints::media_files::upload::upload_saved_scene_media_file_handler::*;
use crate::http_server::endpoints::media_files::upload::upload_scene_snapshot_media_file_handler::*;
use crate::http_server::endpoints::media_files::upload::upload_studio_shot::upload_studio_shot_media_file_handler::*;
use crate::http_server::endpoints::media_files::upload::upload_video_new::upload_new_video_media_file_handler::*;
use crate::http_server::endpoints::media_files::upload::upload_video_old::upload_video_media_file_handler::*;
use crate::http_server::endpoints::media_files::upsert_upload::write_engine_asset::write_engine_asset_media_file_handler::*;
use crate::http_server::endpoints::media_files::upsert_upload::write_error::MediaFileWriteError;
use crate::http_server::endpoints::media_files::upsert_upload::write_scene_file::write_scene_file_media_file_handler::*;
use crate::http_server::endpoints::model_download::enqueue_gptsovits_model_download_handler::*;
use crate::http_server::endpoints::moderation::user_feature_flags::edit_user_feature_flags_handler::*;
use crate::http_server::endpoints::prompts::get_prompt_handler::*;
use crate::http_server::endpoints::service::status_alert_handler::*;
use crate::http_server::endpoints::stats::get_unified_queue_stats_handler::*;
use crate::http_server::endpoints::studio_gen2::enqueue_studio_gen2_handler::*;
use crate::http_server::endpoints::studio_gen2::enqueue_studio_gen2_handler::EnqueueStudioGen2Request;
use crate::http_server::endpoints::tags::list_tags_for_entity_handler::*;
use crate::http_server::endpoints::tags::set_tags_for_entity_handler::*;
use crate::http_server::endpoints::tts::enqueue_infer_tts_handler::enqueue_infer_tts_handler::*;
use crate::http_server::endpoints::user_bookmarks::batch_get_user_bookmarks_handler::*;
use crate::http_server::endpoints::user_bookmarks::create_user_bookmark_handler::*;
use crate::http_server::endpoints::user_bookmarks::delete_user_bookmark_handler::*;
use crate::http_server::endpoints::user_bookmarks::list_user_bookmarks_for_entity_handler::*;
use crate::http_server::endpoints::user_bookmarks::list_user_bookmarks_for_user_handler::*;
use crate::http_server::endpoints::user_ratings::batch_get_user_rating_handler::*;
use crate::http_server::endpoints::user_ratings::get_user_rating_handler::*;
use crate::http_server::endpoints::user_ratings::set_user_rating_handler::*;
use crate::http_server::endpoints::users::create_account_handler::*;
use crate::http_server::endpoints::users::edit_username_handler::*;
use crate::http_server::endpoints::users::get_profile_handler::*;
use crate::http_server::endpoints::users::google_sso::google_sso_handler::*;
use crate::http_server::endpoints::users::login_handler::*;
use crate::http_server::endpoints::users::logout_handler::*;
use crate::http_server::endpoints::users::session_info_handler::*;
use crate::http_server::endpoints::users::session_token_info_handler::*;
use crate::http_server::endpoints::voice_conversion::enqueue_voice_conversion_inference_handler::*;
use crate::http_server::endpoints::voice_designer::inference::enqueue_tts_request::*;
use crate::http_server::endpoints::voice_designer::voice_datasets::list_datasets_by_user::*;
use crate::http_server::endpoints::weights::delete::delete_weight_handler::*;
use crate::http_server::endpoints::weights::get::get_weight_handler::*;
use crate::http_server::endpoints::weights::list::list_available_weights_handler::*;
use crate::http_server::endpoints::weights::list::list_featured_weights_handler::*;
use crate::http_server::endpoints::weights::list::list_pinned_weights_handler::*;
use crate::http_server::endpoints::weights::list::list_weights_by_user_handler::*;
use crate::http_server::endpoints::weights::search::search_model_weights_impl::*;
use crate::http_server::endpoints::weights::update::set_model_weight_cover_image_handler::*;
use crate::http_server::endpoints::weights::update::update_weight_handler::*;
use enums::by_table::beta_keys::beta_key_product::BetaKeyProduct;
use enums::by_table::comments::comment_entity_type::CommentEntityType;
use enums::by_table::featured_items::featured_item_entity_type::FeaturedItemEntityType;
@@ -256,7 +258,6 @@ use tokens::tokens::zs_voice_datasets::*;
crate::http_server::endpoints::generate::video::generate_kling_2_1_pro_video_handler::generate_kling_2_1_pro_video_handler,
crate::http_server::endpoints::generate::video::generate_seedance_1_0_lite_image_to_video_handler::generate_seedance_1_0_lite_image_to_video_handler,
crate::http_server::endpoints::generate::video::generate_veo_2_image_to_video_handler::generate_veo_2_image_to_video_handler,
crate::http_server::endpoints::image_studio::prompt::enqueue_studio_image_generation_handler::enqueue_studio_image_generation_handler,
crate::http_server::endpoints::image_studio::upload::upload_snapshot_media_file_handler::upload_snapshot_media_file_handler,
crate::http_server::endpoints::inference_job::delete::dismiss_finished_session_jobs_handler::dismiss_finished_session_jobs_handler,
crate::http_server::endpoints::inference_job::delete::terminate_inference_job_handler::terminate_inference_job_handler,
@@ -478,7 +479,6 @@ use tokens::tokens::zs_voice_datasets::*;
DeleteCommentRequest,
DeleteFeaturedItemError,
DeleteFeaturedItemRequest,
DeleteMediaFileError,
DeleteMediaFilePathInfo,
DeleteMediaFileRequest,
DeleteUserBookmarkError,
@@ -528,7 +528,6 @@ use tokens::tokens::zs_voice_datasets::*;
EnqueueStudioGen2Error,
EnqueueStudioGen2Request,
EnqueueStudioGen2Response,
EnqueueStudioImageGenRequest,
EnqueueTTSRequest,
EnqueueTTSRequestError,
EnqueueTTSRequestSuccessResponse,
@@ -29,16 +29,16 @@ import {
getProviderDisplayName,
getModelDisplayName,
} from "@storyteller/model-list";
import { MediaFilesApi } from "@storyteller/api";
import { CloseButton } from "@storyteller/ui-close-button";
import { ActionReminderModal } from "@storyteller/ui-action-reminder-modal";
import { TaskMediaFileClass } from "@storyteller/api-enums";
type InProgressTask = {
id: string;
title: string;
subtitle?: string;
progress: number;
updatedAt?: string;
updatedAt?: Date;
canDismiss?: boolean;
};
@@ -47,10 +47,11 @@ type CompletedTask = {
title: string;
subtitle?: string;
thumbnailUrl?: string;
completedAt?: string;
updatedAt?: string;
completedAt?: Date;
updatedAt?: Date;
imageUrls?: string[];
mediaTokens?: string[];
mediaFileClass?: TaskMediaFileClass;
};
const InProgressCard = ({
@@ -129,6 +130,16 @@ const CompletedCard = ({
<img
src={task.thumbnailUrl}
alt={task.title}
onError={(e) => {
console.log("Failed to load thumbnail", e);
let errorPlaceholder = "/resources/placeholders/placeholder.png";
switch (task.mediaFileClass) {
case TaskMediaFileClass.Video:
errorPlaceholder = "/resources/placeholders/placeholder_play.png";
break;
}
e.currentTarget.src = errorPlaceholder;
}}
className="h-full w-full object-cover"
/>
) : (
@@ -148,7 +159,7 @@ const CompletedCard = ({
)}
{task.completedAt && (
<div className="text-base-fg text-xs opacity-60">
{task.completedAt}
{task.completedAt.toISOString()}
</div>
)}
</div>
@@ -219,43 +230,11 @@ export const TaskQueue = () => {
const load = async () => {
try {
const result = await GetTaskQueue();
if (cancelled) return;
console.log("TaskQueue:GetTaskQueue result", result);
const { tasks } = result;
// Fetch media files for thumbnails, using dummy tokens from API for now
const tokens = result.results as string[] | undefined;
let imageUrls: string[] = [];
let mediaTokens: string[] | undefined = undefined;
if (tokens && tokens.length > 0) {
try {
const api = new MediaFilesApi();
const batchToken = tokens.find((t) => t.startsWith("batch_"));
if (batchToken) {
const resp = await api.GetMediaFilesByBatchToken({
batchToken,
});
if (resp.success && resp.data) {
imageUrls = resp.data
.filter((m) => !!m?.media_links?.cdn_url)
.map((m) => m.media_links.cdn_url);
mediaTokens = resp.data.map((m) => m.token);
}
} else {
const resp = await api.ListMediaFilesByTokens({
mediaTokens: tokens,
});
if (resp.success && resp.data) {
imageUrls = resp.data
.filter((m) => !!m?.media_links?.cdn_url)
.map((m) => m.media_links.cdn_url);
mediaTokens = resp.data.map((m) => m.token);
}
}
} catch (_) {
// ignore
}
}
const { tasks } = result;
const now = Date.now();
const inProg = tasks
@@ -286,7 +265,7 @@ export const TaskQueue = () => {
title: `Generating ${parts.kind || "Task"}...`,
subtitle: parts.subtitle,
progress,
updatedAt: t.updated_at?.toISOString(),
updatedAt: t.updated_at,
canDismiss,
};
});
@@ -309,11 +288,17 @@ export const TaskQueue = () => {
.map((t: TaskQueueItem) => ({
id: t.id,
...formatTitleParts(t),
thumbnailUrl: imageUrls?.[0],
completedAt: t.completed_at?.toISOString(),
updatedAt: t.updated_at?.toISOString(),
imageUrls: imageUrls?.length ? imageUrls : undefined,
mediaTokens: mediaTokens?.length ? mediaTokens : undefined,
thumbnailUrl: t.completed_item?.primary_media_file?.maybe_thumbnail_url_template ?
t.completed_item?.primary_media_file?.maybe_thumbnail_url_template.replace("{WIDTH}", "250") :
undefined,
imageUrls: t.completed_item?.primary_media_file?.cdn_url ?
[t.completed_item?.primary_media_file?.cdn_url] :
[],
mediaTokens: t.completed_item?.primary_media_file?.token ?
[t.completed_item?.primary_media_file?.token] :
[],
completedAt: t.completed_at,
updatedAt: t.updated_at,
}));
setInProgress(inProg);
@@ -478,8 +463,8 @@ export const TaskQueue = () => {
id: firstMediaToken,
label: t.title,
thumbnail: t.thumbnailUrl || null,
fullImage: t.thumbnailUrl || null,
createdAt: new Date().toISOString(),
fullImage: t.imageUrls?.[0] || null,
createdAt: (t.completedAt || new Date()).toISOString(),
mediaClass: "image",
} as GalleryItem;
galleryModalLightboxMediaId.value = item.id;
@@ -600,9 +585,9 @@ export const TaskQueue = () => {
id: t.id,
label: t.title,
thumbnail: t.thumbnailUrl || null,
fullImage: t.thumbnailUrl || null,
createdAt: new Date().toISOString(),
mediaClass: "image",
fullImage: t.imageUrls?.[0] || null,
createdAt: (t.completedAt || new Date()).toISOString(),
mediaClass: t.mediaFileClass,
} as GalleryItem;
galleryModalLightboxMediaId.value = item.id;
galleryModalLightboxImage.value = item as GalleryItem;
@@ -369,7 +369,7 @@ const PageDraw = () => {
input.click();
}}
onDelete={(): void => {
store.deleteSelectedItems();
store.RESET();
}}
activeToolId={store.activeTool}
currentShape={store.currentShape}
@@ -196,6 +196,7 @@ interface SceneState {
// Action for deleting selected items
deleteSelectedItems: () => void;
RESET: () => void;
// Clipboard actions
copySelectedItems: () => void;
@@ -234,10 +235,8 @@ interface SceneState {
sendBackward: (nodeIds: string[]) => void;
// Start background removal (enqueues task with Tauri)
beginRemoveBackground: (
nodeIds: string[],
) => Promise<void>;
beginRemoveBackground: (nodeIds: string[]) => Promise<void>;
// Finish background removal (handoff back from a Tauri-sent event)
finishRemoveBackground: (
nodeId: string,
@@ -273,7 +272,7 @@ const getNextZIndex = (nodes: Node[], lineNodes: LineNode[]): number => {
return allZIndices.length > 0 ? Math.max(...allZIndices) + 1 : 1;
};
export const useSceneStore = create<SceneState>((set, get) => ({
export const useSceneStore = create<SceneState>((set, get, store) => ({
// Initial state
nodes: [],
lineNodes: [],
@@ -289,7 +288,7 @@ export const useSceneStore = create<SceneState>((set, get) => ({
fillColor: "white",
currentShape: null,
shapeColor: "#4d79b3",
// Cursor initial state
cursorPosition: null,
cursorVisible: false,
@@ -337,7 +336,6 @@ export const useSceneStore = create<SceneState>((set, get) => ({
}
},
updateNode: (
id: string,
updates: Partial<Node>,
@@ -838,6 +836,10 @@ export const useSceneStore = create<SceneState>((set, get) => ({
}
},
RESET: () => {
set(store.getInitialState());
},
// Clipboard actions
copySelectedItems: () => {
set((state) => {
@@ -1437,9 +1439,7 @@ export const useSceneStore = create<SceneState>((set, get) => ({
});
get().saveState();
},
beginRemoveBackground: async (
nodeIds: string[],
) => {
beginRemoveBackground: async (nodeIds: string[]) => {
const hasImageNodes = nodeIds.some((id) => {
const node = get().nodes.find((n) => n.id === id);
return node ? node.type === "image" : false;
@@ -1455,7 +1455,7 @@ export const useSceneStore = create<SceneState>((set, get) => ({
}
try {
const base64Image = await convertFileToBase64(firstNode.imageFile);
// NB: This handler is async, so we subscribe (externally) and
// NB: This handler is async, so we subscribe (externally) and
// wait for the bg removal to complete.
const _response = await EnqueueImageBgRemoval({
base64_image: base64Image,
@@ -664,19 +664,20 @@ class Editor {
this.mouse_controls.sceneManager = this.sceneManager;
}
if (this.outliner_feature_flag) {
const result = this.sceneManager?.render_outliner(
this.timeline.characters,
);
if (result) outlinerState.items.value = result.items;
}
// Attach freecam state controller
this.mouseOverEventHandler = this.mouseOverEventHandler.bind(this);
const onloadCallback = () => {
console.log("Setting Scene is loaded");
this._isEngineDataLoaded = true;
if (this.outliner_feature_flag) {
const result = this.sceneManager?.render_outliner(
this.timeline.characters,
);
if (result) outlinerState.items.value = result.items;
}
setIs3DSceneLoaded(true);
};
@@ -1,3 +1,4 @@
export * from "./task_media_file_class.js";
export * from "./task_model_type.js";
export * from "./task_status.js";
export * from "./task_type.js";
@@ -0,0 +1,10 @@
// NOTE: These are defined in Rust (as the source of truth) and duplicated in the frontend.
// In the future, we should use code gen (protobufs or similar) to keep the two sides in sync.
export enum TaskMediaFileClass {
Audio = "audio",
Image = "image",
Video = "video",
Dimensional = "dimensional",
}
@@ -26,7 +26,7 @@ interface GalleryDraggableItemProps {
activeFilter: string;
selected: boolean;
onClick: () => void;
onImageError: () => void;
onImageError: (e: React.SyntheticEvent<HTMLImageElement>) => void;
disableTooltipAndBadge?: boolean;
imageFit?: "cover" | "contain";
onDeleted?: (id: string) => void;
@@ -637,9 +637,11 @@ export const GalleryModal = React.memo(
activeFilter={activeFilter}
selected={selectedItemIds.includes(item.id)}
onClick={() => handleItemClick(item)}
onImageError={() =>
handleImageError(item.thumbnail!)
}
onImageError={(e) => {
// NB: Replace the broken thumbnail with a placeholder.
e.currentTarget.src = "/resources/placeholders/placeholder.png";
handleImageError(item.thumbnail!);
}}
disableTooltipAndBadge={mode === "select"}
imageFit={imageFit}
onDeleted={handleItemDeleted}
@@ -393,6 +393,11 @@ export const ImagePromptRow = ({
"absolute -top-[72px] left-0 glass w-[730px] rounded-t-xl flex",
className
)}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
onMouseUp={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onPointerUp={(e) => e.stopPropagation()}
>
<div
className={twMerge(
@@ -478,6 +478,11 @@ export const PromptBox3D = ({
isImageRowVisible &&
"rounded-t-none"
)}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
onMouseUp={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onPointerUp={(e) => e.stopPropagation()}
>
<div className="flex justify-center gap-2">
{selectedImageModel?.canUseImagePrompt && (
@@ -4,12 +4,12 @@ import {
TaskStatus,
TaskType,
TaskModelType,
TaskMediaFileClass,
GenerationProvider,
} from "@storyteller/api-enums";
interface GetTaskQueueResponse {
tasks: TaskQueueItem[];
results?: string[];
}
export interface TaskQueueItem {
@@ -19,11 +19,25 @@ export interface TaskQueueItem {
model_type?: TaskModelType;
provider?: GenerationProvider;
provider_job_id?: string;
completed_item?: TaskQueueCompletedItem;
created_at: Date;
updated_at: Date;
completed_at?: Date;
}
export interface TaskQueueCompletedItem {
primary_media_file: MediaFileData,
media_file_class?: TaskMediaFileClass,
maybe_batch_token?: string,
}
export interface MediaFileData {
token: string,
cdn_url: string,
maybe_thumbnail_url_template?: string,
created_at: Date,
}
interface GetTaskQueueSuccess extends CommandResult {
payload: GetTaskQueueResponse;
}
@@ -37,6 +51,12 @@ export const GetTaskQueue = async (): Promise<GetTaskQueueResponse> => {
// Convert timestamps to Date objects
const newTasks: TaskQueueItem[] = tasks.map((task) => {
let completed_item = task.completed_item;
if (!!completed_item) {
completed_item.primary_media_file.created_at = new Date(completed_item.primary_media_file.created_at);
}
return {
id: task.id,
task_status: task.task_status,
@@ -44,18 +64,15 @@ export const GetTaskQueue = async (): Promise<GetTaskQueueResponse> => {
model_type: task.model_type,
provider: task.provider,
provider_job_id: task.provider_job_id,
completed_item: completed_item,
created_at: new Date(task.created_at),
updated_at: new Date(task.updated_at),
completed_at: task.completed_at ? new Date(task.completed_at) : undefined,
};
});
// Dummy tokens until backend provides real ones
const dummyTokens: string[] = ["batch_g_4r8m6kx41yxm7yvs0kqapm3e"];
return {
tasks: newTasks,
results: dummyTokens,
};
};