Merge remote-tracking branch 'origin/main' into refactor/3d-componentize

# Conflicts:
#	frontend/apps/artcraft/app/src/pages/PageEnigma/PageEditor.tsx
#	frontend/apps/artcraft/tsconfig.tsbuildinfo
#	frontend/tsconfig.json
This commit is contained in:
Tanish Manku
2026-05-09 13:55:47 -07:00
130 changed files with 5934 additions and 259 deletions
@@ -0,0 +1,135 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n\n jobs.status as `status: enums::common::job_status_plus::JobStatusPlus`,\n\n jobs.maybe_external_third_party as `external_third_party: enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty`,\n jobs.maybe_external_third_party_id as `external_third_party_id`,\n\n jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n jobs.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n jobs.creator_ip_address,\n\n jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n jobs.maybe_debug_log_event_token as `maybe_debug_log_event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken`,\n\n jobs.created_at,\n jobs.updated_at\n\nFROM generic_inference_jobs as jobs\n\nWHERE jobs.maybe_external_third_party = ?\nAND jobs.maybe_external_third_party_id = ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "status: enums::common::job_status_plus::JobStatusPlus",
"type_info": {
"type": "String",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | ENUM",
"char_set": 224,
"max_size": 76
}
},
{
"ordinal": 2,
"name": "external_third_party: enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 3,
"name": "external_third_party_id",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 256
}
},
{
"ordinal": 4,
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 6,
"name": "creator_ip_address",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 7,
"name": "maybe_prompt_token: tokens::tokens::prompts::PromptToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 8,
"name": "maybe_debug_log_event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 9,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 10,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
true,
true,
true,
true,
false,
true,
true,
false,
false
]
},
"hash": "31d85cc0013f29e6db2d272ee6e709cca79b46496a2c222960f1a1419175f00b"
}
Generated
+17
View File
@@ -1404,6 +1404,22 @@ dependencies = [
"zeroize",
]
[[package]]
name = "beeble_client"
version = "0.0.1"
dependencies = [
"anyhow",
"errors",
"log",
"reqwest 0.12.23",
"serde",
"serde_derive",
"serde_json",
"test_data",
"tokio",
"workspace-build-acceleration",
]
[[package]]
name = "bigdecimal"
version = "0.3.1"
@@ -11234,6 +11250,7 @@ dependencies = [
"async-stripe 0.26.0",
"async-trait",
"base64 0.22.1",
"beeble_client",
"billing_artcraft_component",
"billing_component",
"bootstrap",
+2
View File
@@ -16,6 +16,7 @@ members = [
"crates/api_clients/grok_client",
"crates/api_clients/midjourney_client",
"crates/api_clients/muapi_client",
"crates/api_clients/beeble_client",
"crates/api_clients/openai_sora_client",
"crates/api_clients/openart_client",
"crates/api_clients/rootly_client",
@@ -244,6 +245,7 @@ fal_client = { path = "crates/api_clients/fal_client" }
ffmpeg_utils = { path = "crates/lib/ffmpeg_utils" }
file_to_base64_url = { path = "crates/lib/files/file_to_base64_url" }
filesys = { path = "crates/lib/files/filesys" }
beeble_client = { path = "crates/api_clients/beeble_client" }
grok_client = { path = "crates/api_clients/grok_client" }
rootly_client = { path = "crates/api_clients/rootly_client" }
rootly_config = { path = "crates/schema/config/rootly_config" }
@@ -0,0 +1,3 @@
$kind: collection
name: edit
order: 312
@@ -0,0 +1,23 @@
$kind: http-request
name: Beeble SwitchX Edit Video
url: "{{api_host}}/v1/generate/video/edit/beeble_switchx"
method: POST
headers:
- key: Accept
value: application/json
- key: Session
value: ""
disabled: true
body:
type: json
content: >-
{
"uuid_idempotency_token": "{{$randomUUID}}",
//"source_video_media_token": "m_6vve5gffn2r4rajyvy8z2380x15kqc", // two corgis (dev)
"source_video_media_token": "m_rkgdk7qw0a7gz4mrzfv0q78ax57kbx", // corgi cowboy (dev)
//"source_video_media_token": "m_1k19k6dgdny253zag8g3jqf3bgemss", // corgi watermellon (prod)
"reference_image_media_token": "m_pf310vdhc4v98n7r8mz70bz9geest3" // tornado (dev)
//"reference_image_media_token": "m_z4y097t9pdmbyatf70z0qb8zvfphvm" // cactus (prod)
//"prompt": "Stormy skies, there's a rotating tornado in the background"
}
order: 1778262498846
@@ -9,7 +9,7 @@ body:
// hanashi : password (dev)
//"username_or_email": "hanashi",
//"password": "hanashi"
"username_or_email": "newuser2",
"password": "newuser2"
"username_or_email": "newuser4",
"password": "newuser4"
}
order: 1000
@@ -0,0 +1,20 @@
use serde_derive::{Deserialize, Serialize};
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::media_files::MediaFileToken;
use utoipa::ToSchema;
pub const BEEBLE_SWITCHX_EDIT_VIDEO_PATH: &str = "/v1/generate/video/edit/beeble_switchx";
#[derive(Serialize, Deserialize, ToSchema, Debug)]
pub struct BeebleSwitchXEditVideoRequest {
pub uuid_idempotency_token: String,
pub source_video_media_token: Option<MediaFileToken>,
pub reference_image_media_token: Option<MediaFileToken>,
pub prompt: Option<String>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct BeebleSwitchXEditVideoResponse {
pub success: bool,
pub inference_job_token: InferenceJobToken,
}
@@ -0,0 +1 @@
pub mod beeble_switchx_edit_video;
@@ -1,3 +1,4 @@
pub mod edit;
pub mod generate_kling_1_6_pro_image_to_video;
pub mod generate_kling_2_1_master_image_to_video;
pub mod generate_kling_2_1_pro_image_to_video;
@@ -0,0 +1,20 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::basic_json_post_request::basic_json_post_request;
use artcraft_api_defs::generate::video::edit::beeble_switchx_edit_video::{
BeebleSwitchXEditVideoRequest, BeebleSwitchXEditVideoResponse, BEEBLE_SWITCHX_EDIT_VIDEO_PATH,
};
pub async fn beeble_switchx_edit_video(
api_host: &ApiHost,
maybe_creds: Option<&StorytellerCredentialSet>,
request: BeebleSwitchXEditVideoRequest,
) -> Result<BeebleSwitchXEditVideoResponse, StorytellerError> {
Ok(basic_json_post_request(
api_host,
BEEBLE_SWITCHX_EDIT_VIDEO_PATH,
maybe_creds,
request,
).await?)
}
@@ -0,0 +1 @@
pub mod beeble_switchx_edit_video;
@@ -1,3 +1,4 @@
pub mod edit;
pub mod generate_kling_16_pro_image_to_video;
pub mod generate_kling_21_master_image_to_video;
pub mod generate_kling_21_pro_image_to_video;
@@ -0,0 +1,24 @@
# beeble_client
HTTP client for the Beeble SwitchX compositing API.
## API Reference
- Docs: https://developer.beeble.ai/docs
- OpenAPI: https://api.beeble.ai/developer-api-docs/openapi.json
- Auth: `x-api-key` header
## Endpoints
- `create_upload_url` — POST /v1/uploads — get a presigned PUT URL for uploading media
- `start_generation` — POST /v1/switchx/generations — start an image or video compositing job
- `get_job_status` — GET /v1/switchx/generations/{job_id} — poll job status until completed/failed
## Testing
```bash
cargo test -p beeble_client # run non-ignored tests (API shape only)
cargo test -p beeble_client -- --ignored # run live API tests (requires key, incurs costs)
```
Live tests require: `/Users/bt/Artcraft/credentials/beeble_api_key.txt`
@@ -0,0 +1,31 @@
[package]
name = "beeble_client"
edition = "2024"
version = "0.0.1"
publish = false
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[lib]
name = "beeble_client"
path = "src/lib.rs"
[dependencies]
# Workspace hack (faster compile times)
workspace-build-acceleration.workspace = true
# Internal
errors.workspace = true
# External
log.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_derive.workspace = true
serde_json.workspace = true
[dev-dependencies]
anyhow.workspace = true
test_data.workspace = true
tokio = { version = "1", features = ["full"] }
@@ -0,0 +1,24 @@
use std::fmt;
/// Holds the API key for authenticating with the Beeble API.
/// Sent as the `x-api-key` header.
#[derive(Clone)]
pub struct BeebleApiKey {
pub(crate) api_key: String,
}
impl BeebleApiKey {
pub fn new(api_key: String) -> Self {
Self { api_key }
}
}
impl fmt::Debug for BeebleApiKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.api_key.len() > 0 {
write!(f, "BeebleApiKey(***)")
} else {
write!(f, "BeebleApiKey(EMPTY)")
}
}
}
@@ -0,0 +1 @@
pub mod beeble_api_key;
@@ -0,0 +1,26 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum BeebleClientError {
/// No API key is present.
NoApiKeyPresent,
/// An error was encountered in building the reqwest client.
ReqwestClientError(reqwest::Error),
/// A serialization error with the request.
SerializationError(serde_json::Error),
}
impl Error for BeebleClientError {}
impl Display for BeebleClientError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoApiKeyPresent => write!(f, "No API key present."),
Self::ReqwestClientError(err) => write!(f, "Reqwest client error: {}", err),
Self::SerializationError(err) => write!(f, "Serialization error: {}", err),
}
}
}
@@ -0,0 +1,41 @@
use crate::error::beeble_client_error::BeebleClientError;
use crate::error::beeble_generic_api_error::BeebleGenericApiError;
use crate::error::beeble_specific_api_error::BeebleSpecificApiError;
use std::error::Error;
#[derive(Debug)]
pub enum BeebleError {
Client(BeebleClientError),
ApiSpecific(BeebleSpecificApiError),
ApiGeneric(BeebleGenericApiError),
}
impl Error for BeebleError {}
impl std::fmt::Display for BeebleError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Client(e) => write!(f, "BeebleClientError: {:?}", e),
Self::ApiSpecific(e) => write!(f, "BeebleSpecificApiError: {:?}", e),
Self::ApiGeneric(e) => write!(f, "BeebleGenericApiError: {:?}", e),
}
}
}
impl From<BeebleClientError> for BeebleError {
fn from(error: BeebleClientError) -> Self {
Self::Client(error)
}
}
impl From<BeebleSpecificApiError> for BeebleError {
fn from(error: BeebleSpecificApiError) -> Self {
Self::ApiSpecific(error)
}
}
impl From<BeebleGenericApiError> for BeebleError {
fn from(error: BeebleGenericApiError) -> Self {
Self::ApiGeneric(error)
}
}
@@ -0,0 +1,34 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
/// Catch-all for API errors that don't fit a specific variant.
#[derive(Debug)]
pub enum BeebleGenericApiError {
/// An HTTP transport error from reqwest.
ReqwestError(reqwest::Error),
/// Failed to parse the response body as JSON.
SerdeResponseParseError(serde_json::Error, String),
/// The API returned a non-success status code.
UncategorizedBadResponseWithStatusAndBody {
status_code: reqwest::StatusCode,
body: String,
},
}
impl Error for BeebleGenericApiError {}
impl Display for BeebleGenericApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::ReqwestError(err) => write!(f, "Beeble reqwest error: {}", err),
Self::SerdeResponseParseError(err, body) => {
write!(f, "Beeble response parse error: {} (body: {})", err, body)
}
Self::UncategorizedBadResponseWithStatusAndBody { status_code, body } => {
write!(f, "Beeble API error: status={}, body={}", status_code, body)
}
}
}
}
@@ -0,0 +1,54 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
/// Well-known API errors from the Beeble service.
#[derive(Debug)]
pub enum BeebleSpecificApiError {
/// 401 Unauthorized — invalid or missing API key.
Unauthorized,
/// 402 Payment Required — insufficient credits.
InsufficientCredits,
/// 409 Conflict — idempotency key reused with different parameters.
IdempotencyConflict,
/// 429 Too Many Requests — rate limit exceeded.
RateLimited,
/// The generation job failed with a reported error message.
GenerationFailed(String),
/// 400 Bad Request — the callback_url is not a valid publicly-reachable HTTPS URL.
BadWebhookUrl {
message: String,
webhook_url: String,
},
/// 400 Bad Request — the video has too many frames.
VideoHasTooManyFrames {
max_frames: u64,
detected_frames: u64,
message: String,
},
}
impl Error for BeebleSpecificApiError {}
impl Display for BeebleSpecificApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Unauthorized => write!(f, "Beeble API: Unauthorized (invalid or missing API key)"),
Self::InsufficientCredits => write!(f, "Beeble API: Insufficient credits"),
Self::IdempotencyConflict => write!(f, "Beeble API: Idempotency conflict"),
Self::RateLimited => write!(f, "Beeble API: Rate limited"),
Self::GenerationFailed(msg) => write!(f, "Beeble API: Generation failed: {}", msg),
Self::BadWebhookUrl { message, webhook_url } => {
write!(f, "Beeble API: Bad webhook URL '{}': {}", webhook_url, message)
}
Self::VideoHasTooManyFrames { max_frames, detected_frames, message } => {
write!(f, "Beeble API: Video has too many frames ({} detected, max {}): {}", detected_frames, max_frames, message)
}
}
}
}
@@ -0,0 +1,4 @@
pub mod beeble_client_error;
pub mod beeble_error;
pub mod beeble_generic_api_error;
pub mod beeble_specific_api_error;
@@ -0,0 +1,8 @@
#[cfg(test)]
pub(crate) mod test_utils;
// Public API
pub mod creds;
pub mod error;
pub mod requests;
pub mod webhook_api;
@@ -0,0 +1,126 @@
use log::info;
use crate::creds::beeble_api_key::BeebleApiKey;
use crate::error::beeble_client_error::BeebleClientError;
use crate::error::beeble_error::BeebleError;
use crate::error::beeble_generic_api_error::BeebleGenericApiError;
use crate::error::beeble_specific_api_error::BeebleSpecificApiError;
use crate::requests::create_upload_url::request_types::*;
const BEEBLE_API_BASE_URL: &str = "https://api.beeble.ai/v1";
// ── Public args ──
pub struct CreateUploadUrlArgs {
pub api_key: BeebleApiKey,
pub filename: String,
}
// ── Public response ──
#[derive(Debug, Clone)]
pub struct CreateUploadUrlSuccess {
/// Upload ID (upload_...).
pub id: String,
/// Presigned PUT URL for uploading. Expires after 1 hour.
pub upload_url: String,
/// beeble:// URI to reference this upload in generation calls.
pub beeble_uri: String,
}
// ── Implementation ──
/// Create a presigned upload URL for a media file.
///
/// After receiving the URL, PUT your file bytes directly to `upload_url`.
/// Then use `beeble_uri` as the source_uri, reference_image_uri, or alpha_uri
/// in generation calls.
pub async fn create_upload_url(args: CreateUploadUrlArgs) -> Result<CreateUploadUrlSuccess, BeebleError> {
let url = format!("{}/uploads", BEEBLE_API_BASE_URL);
info!("Creating Beeble upload URL for filename: {}", args.filename);
let request_body = CreateUploadUrlRequestBody {
filename: args.filename,
};
let client = reqwest::Client::builder()
.build()
.map_err(|err| BeebleClientError::ReqwestClientError(err))?;
let response = client.post(&url)
.header("x-api-key", &args.api_key.api_key)
.header("Content-Type", "application/json")
.json(&request_body)
.send()
.await
.map_err(|err| BeebleGenericApiError::ReqwestError(err))?;
let status = response.status();
let response_body = response.text()
.await
.map_err(|err| BeebleGenericApiError::ReqwestError(err))?;
info!("Beeble create upload URL response: status={}", status);
if status == reqwest::StatusCode::UNAUTHORIZED {
return Err(BeebleSpecificApiError::Unauthorized.into());
}
if !status.is_success() {
return Err(BeebleGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: status,
body: response_body,
}.into());
}
let parsed: CreateUploadUrlResponseBody = serde_json::from_str(&response_body)
.map_err(|err| BeebleGenericApiError::SerdeResponseParseError(err, response_body.clone()))?;
Ok(CreateUploadUrlSuccess {
id: parsed.id,
upload_url: parsed.upload_url,
beeble_uri: parsed.beeble_uri,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::get_test_api_key::get_test_api_key;
use errors::AnyhowResult;
#[test]
fn request_body_serializes() {
let body = CreateUploadUrlRequestBody {
filename: "test_video.mp4".to_string(),
};
let json = serde_json::to_string(&body).unwrap();
assert!(json.contains("test_video.mp4"));
}
#[test]
fn response_body_deserializes() {
let json = r#"{"id":"upload_abc123","upload_url":"https://example.com/put","beeble_uri":"beeble://upload_abc123/video.mp4"}"#;
let parsed: CreateUploadUrlResponseBody = serde_json::from_str(json).unwrap();
assert_eq!(parsed.id, "upload_abc123");
assert_eq!(parsed.beeble_uri, "beeble://upload_abc123/video.mp4");
}
#[tokio::test]
#[ignore] // manually test — requires real API key
async fn test_create_upload_url() -> AnyhowResult<()> {
let api_key = get_test_api_key()?;
let result = create_upload_url(CreateUploadUrlArgs {
api_key,
filename: "test_image.png".to_string(),
}).await?;
println!("Upload ID: {}", result.id);
println!("Upload URL: {}", result.upload_url);
println!("Beeble URI: {}", result.beeble_uri);
assert!(result.id.starts_with("upload_"));
assert!(result.beeble_uri.starts_with("beeble://"));
Ok(())
}
}
@@ -0,0 +1,3 @@
pub mod create_upload_url;
mod request_types;
pub mod upload_bytes;
@@ -0,0 +1,13 @@
use serde_derive::{Deserialize, Serialize};
#[derive(Serialize)]
pub struct CreateUploadUrlRequestBody {
pub filename: String,
}
#[derive(Deserialize, Debug)]
pub struct CreateUploadUrlResponseBody {
pub id: String,
pub upload_url: String,
pub beeble_uri: String,
}
@@ -0,0 +1,50 @@
use log::info;
use crate::creds::beeble_api_key::BeebleApiKey;
use crate::error::beeble_client_error::BeebleClientError;
use crate::error::beeble_error::BeebleError;
use crate::error::beeble_generic_api_error::BeebleGenericApiError;
use crate::requests::create_upload_url::create_upload_url::{
create_upload_url, CreateUploadUrlArgs, CreateUploadUrlSuccess,
};
/// Upload bytes to Beeble in one call (create presigned URL + PUT bytes).
///
/// Returns the upload success with the `beeble_uri` to use in generation calls.
pub async fn upload_bytes_to_beeble(
api_key: &BeebleApiKey,
filename: &str,
content_type: &str,
bytes: Vec<u8>,
) -> Result<CreateUploadUrlSuccess, BeebleError> {
info!("Uploading {} bytes to Beeble as {}", bytes.len(), filename);
// 1. Get presigned URL.
let upload = create_upload_url(CreateUploadUrlArgs {
api_key: api_key.clone(),
filename: filename.to_string(),
}).await?;
info!("Beeble upload URL obtained: {}", upload.beeble_uri);
// 2. PUT bytes to presigned URL.
let client = reqwest::Client::new();
let put_response = client.put(&upload.upload_url)
.header("Content-Type", content_type)
.body(bytes)
.send()
.await
.map_err(|err| BeebleGenericApiError::ReqwestError(err))?;
let status = put_response.status();
if !status.is_success() {
let body = put_response.text().await.unwrap_or_default();
return Err(BeebleGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: status,
body,
}.into());
}
info!("Beeble upload PUT succeeded: {}", upload.beeble_uri);
Ok(upload)
}
@@ -0,0 +1,104 @@
use log::info;
use crate::creds::beeble_api_key::BeebleApiKey;
use crate::error::beeble_client_error::BeebleClientError;
use crate::error::beeble_error::BeebleError;
use crate::error::beeble_generic_api_error::BeebleGenericApiError;
use crate::error::beeble_specific_api_error::BeebleSpecificApiError;
use crate::requests::start_generation::request_types::GenerationJobResponseBody;
use crate::requests::start_generation::start_generation::{map_job_response, StartGenerationSuccess};
const BEEBLE_API_BASE_URL: &str = "https://api.beeble.ai/v1";
// ── Public args ──
pub struct GetJobStatusArgs {
pub api_key: BeebleApiKey,
/// The job ID (swx_...) to poll.
pub job_id: String,
}
// ── Implementation ──
/// Poll the status of a SwitchX generation job.
///
/// Returns the same response shape as start_generation.
/// Poll until `status` is "completed" or "failed".
pub async fn get_job_status(args: GetJobStatusArgs) -> Result<StartGenerationSuccess, BeebleError> {
let url = format!("{}/switchx/generations/{}", BEEBLE_API_BASE_URL, args.job_id);
info!("Polling Beeble job status: job_id={}", args.job_id);
let client = reqwest::Client::builder()
.build()
.map_err(|err| BeebleClientError::ReqwestClientError(err))?;
let response = client.get(&url)
.header("x-api-key", &args.api_key.api_key)
.send()
.await
.map_err(|err| BeebleGenericApiError::ReqwestError(err))?;
let status = response.status();
let response_body = response.text()
.await
.map_err(|err| BeebleGenericApiError::ReqwestError(err))?;
info!("Beeble get job status response: status={}", status);
match status.as_u16() {
401 => return Err(BeebleSpecificApiError::Unauthorized.into()),
429 => return Err(BeebleSpecificApiError::RateLimited.into()),
_ if !status.is_success() => {
return Err(BeebleGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: status,
body: response_body,
}.into());
}
_ => {}
}
let parsed: GenerationJobResponseBody = serde_json::from_str(&response_body)
.map_err(|err| BeebleGenericApiError::SerdeResponseParseError(err, response_body.clone()))?;
Ok(map_job_response(parsed))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::get_test_api_key::get_test_api_key;
//const TEST_JOB_ID: &str = "swx_uZAcwMjRZpDesqfp3pKkF"; // Production
//const TEST_JOB_ID: &str = "swx_Ontb6KfbfdY24Cz8DZ9c8"; // Dev
//const TEST_JOB_ID: &str = "swx_k5SmgdFcesuE05BE8VqFb"; // Dev
//const TEST_JOB_ID: &str = "swx_snkg7Et8EOvWaQ0QgoZlZ"; // Dev
const TEST_JOB_ID: &str = "swx_a1GBhEHwIfhzXfWEkEbcb"; // Dev
//const TEST_JOB_ID: &str = "swx_9Cbr7coiW9ZOayIrKGe0v"; // Prod
#[tokio::test]
#[ignore] // manually test — requires real API key
async fn test_poll_known_job() -> errors::AnyhowResult<()> {
let api_key = get_test_api_key()?;
let result = get_job_status(GetJobStatusArgs {
api_key,
job_id: TEST_JOB_ID.to_string(),
}).await?;
println!("Job ID: {}", result.id);
println!("Status: {}", result.status);
println!("Progress: {:?}", result.progress);
println!("Generation type: {:?}", result.generation_type);
println!("Alpha mode: {:?}", result.alpha_mode);
println!("Error: {:?}", result.error);
println!("Created at: {:?}", result.created_at);
if let Some(output) = &result.output {
println!("Render URL: {:?}", output.render);
println!("Source URL: {:?}", output.source);
println!("Alpha URL: {:?}", output.alpha);
}
assert_eq!(result.id, TEST_JOB_ID);
Ok(())
}
}
@@ -0,0 +1 @@
pub mod get_job_status;
@@ -0,0 +1,3 @@
pub mod create_upload_url;
pub mod get_job_status;
pub mod start_generation;
@@ -0,0 +1,72 @@
use reqwest::StatusCode;
use crate::error::beeble_error::BeebleError;
use crate::error::beeble_generic_api_error::BeebleGenericApiError;
use crate::error::beeble_specific_api_error::BeebleSpecificApiError;
/// Additional context for error handling that depends on the original request.
pub struct ErrorContext <'a> {
pub maybe_callback_url: Option<&'a str>,
}
/// Classify a non-success Beeble API response into the appropriate error.
///
/// Only call this when `!status.is_success()`. Always returns `Err`.
pub fn handle_error_response<T>(
status: StatusCode,
response_body: &str,
context: &ErrorContext,
) -> Result<T, BeebleError> {
match status.as_u16() {
401 => Err(BeebleSpecificApiError::Unauthorized.into()),
402 => Err(BeebleSpecificApiError::InsufficientCredits.into()),
400 if response_body.contains("INVALID_CALLBACK_URL") => {
let message = extract_error_message(response_body)
.unwrap_or_else(|| "callback_url must be a valid, publicly-reachable HTTPS URL".to_string());
Err(BeebleSpecificApiError::BadWebhookUrl {
message,
webhook_url: context.maybe_callback_url.unwrap_or_default().to_string(),
}.into())
}
400 if response_body.contains("VIDEO_TOO_MANY_FRAMES") => {
let message = extract_error_message(response_body)
.unwrap_or_else(|| "Video has too many frames".to_string());
let (detected, max) = extract_frame_counts(&message);
Err(BeebleSpecificApiError::VideoHasTooManyFrames {
max_frames: max,
detected_frames: detected,
message,
}.into())
}
409 => Err(BeebleSpecificApiError::IdempotencyConflict.into()),
429 => Err(BeebleSpecificApiError::RateLimited.into()),
_ => {
Err(BeebleGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: status,
body: response_body.to_string(),
}.into())
}
}
}
/// Try to extract the "message" field from a Beeble error response body.
/// Beeble errors look like: `{"error":{"message":"...","code":"..."}}`
fn extract_error_message(body: &str) -> Option<String> {
let parsed: serde_json::Value = serde_json::from_str(body).ok()?;
parsed.get("error")?.get("message")?.as_str().map(|s| s.to_string())
}
/// Parse frame counts from a message like "Video has 419 frames, which exceeds the maximum of 240."
fn extract_frame_counts(message: &str) -> (u64, u64) {
let numbers: Vec<u64> = message
.split(|c: char| !c.is_ascii_digit())
.filter(|s| !s.is_empty())
.filter_map(|s| s.parse().ok())
.collect();
match numbers.as_slice() {
[detected, max, ..] => (*detected, *max),
[detected] => (*detected, 0),
_ => (0, 0),
}
}
@@ -0,0 +1,3 @@
pub(crate) mod handle_error_response;
pub(crate) mod request_types;
pub mod start_generation;
@@ -0,0 +1,55 @@
use serde_derive::{Deserialize, Serialize};
#[derive(Serialize)]
pub struct StartGenerationRequestBody {
pub generation_type: String,
pub source_uri: String,
pub alpha_mode: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reference_image_uri: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub alpha_uri: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_resolution: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub callback_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub idempotency_key: Option<String>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct GenerationJobResponseBody {
pub id: String,
pub status: String,
pub progress: Option<u8>,
pub generation_type: Option<String>,
pub alpha_mode: Option<String>,
pub output: Option<GenerationOutput>,
pub error: Option<String>,
pub created_at: Option<String>,
pub modified_at: Option<String>,
pub completed_at: Option<String>,
pub webhook: Option<WebhookStatus>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct GenerationOutput {
pub render: Option<String>,
pub source: Option<String>,
pub alpha: Option<String>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct WebhookStatus {
pub status: Option<String>,
pub attempts: Option<u32>,
pub last_error: Option<String>,
}
@@ -0,0 +1,395 @@
use log::info;
use crate::creds::beeble_api_key::BeebleApiKey;
use crate::error::beeble_client_error::BeebleClientError;
use crate::error::beeble_error::BeebleError;
use crate::error::beeble_generic_api_error::BeebleGenericApiError;
use crate::requests::start_generation::handle_error_response::{handle_error_response, ErrorContext};
use crate::requests::start_generation::request_types::*;
const BEEBLE_API_BASE_URL: &str = "https://api.beeble.ai/v1";
// ── Public types ──
#[derive(Clone, Debug)]
pub struct StartGenerationArgs {
pub api_key: BeebleApiKey,
pub request: StartGenerationRequest,
}
#[derive(Clone, Debug)]
pub struct StartGenerationRequest {
/// "image" or "video".
pub generation_type: BeebleGenerationType,
/// Source image/video URI. Supports beeble://, https://, data: base64.
pub source_uri: String,
/// Alpha matte mode.
pub alpha_mode: BeebleAlphaMode,
/// Text description of desired output (max 2000 chars).
pub prompt: Option<String>,
/// URI for style transfer reference image.
pub reference_image_uri: Option<String>,
/// Custom alpha matte URI. Required for Custom and Select modes.
pub alpha_uri: Option<String>,
/// Maximum output resolution: 720 or 1080. Default 1080.
pub max_resolution: Option<u16>,
/// HTTPS webhook URL for completion notification.
pub callback_url: Option<String>,
/// Safe retry key preventing duplicate jobs (max 256 chars).
pub idempotency_key: Option<String>,
}
#[derive(Copy, Clone, Debug)]
pub enum BeebleGenerationType {
Image,
Video,
}
#[derive(Copy, Clone, Debug)]
pub enum BeebleAlphaMode {
/// Automatic alpha detection.
Auto,
/// Fill the entire frame.
Fill,
/// Use a custom alpha matte (requires alpha_uri).
Custom,
/// Select regions from the source (requires alpha_uri).
Select,
}
// ── Public response ──
#[derive(Debug, Clone)]
pub struct StartGenerationSuccess {
/// Job identifier (swx_...).
pub id: String,
/// in_queue, processing, completed, or failed.
pub status: String,
/// 0-100 progress percentage.
pub progress: Option<u8>,
pub generation_type: Option<String>,
pub alpha_mode: Option<String>,
pub output: Option<GenerationOutputResult>,
pub error: Option<String>,
pub created_at: Option<String>,
}
#[derive(Debug, Clone)]
pub struct GenerationOutputResult {
/// URL to the rendered output. Expires after 72 hours.
pub render: Option<String>,
/// URL to the source. Expires after 72 hours.
pub source: Option<String>,
/// URL to the alpha matte. Expires after 72 hours.
pub alpha: Option<String>,
}
// ── Implementation ──
/// Start a SwitchX compositing generation job.
pub async fn start_generation(args: StartGenerationArgs) -> Result<StartGenerationSuccess, BeebleError> {
let url = format!("{}/switchx/generations", BEEBLE_API_BASE_URL);
let req = args.request;
info!("Starting Beeble generation: type={:?}, alpha_mode={:?}", req.generation_type, req.alpha_mode);
let request_body = StartGenerationRequestBody {
generation_type: match req.generation_type {
BeebleGenerationType::Image => "image".to_string(),
BeebleGenerationType::Video => "video".to_string(),
},
source_uri: req.source_uri,
alpha_mode: match req.alpha_mode {
BeebleAlphaMode::Auto => "auto".to_string(),
BeebleAlphaMode::Fill => "fill".to_string(),
BeebleAlphaMode::Custom => "custom".to_string(),
BeebleAlphaMode::Select => "select".to_string(),
},
prompt: req.prompt,
reference_image_uri: req.reference_image_uri,
alpha_uri: req.alpha_uri,
max_resolution: req.max_resolution,
callback_url: req.callback_url.clone(),
idempotency_key: req.idempotency_key,
};
let client = reqwest::Client::builder()
.build()
.map_err(|err| BeebleClientError::ReqwestClientError(err))?;
let response = client.post(&url)
.header("x-api-key", &args.api_key.api_key)
.header("Content-Type", "application/json")
.json(&request_body)
.send()
.await
.map_err(|err| BeebleGenericApiError::ReqwestError(err))?;
let status = response.status();
let response_body = response.text()
.await
.map_err(|err| BeebleGenericApiError::ReqwestError(err))?;
info!("Beeble start generation response: status={}", status);
if !status.is_success() {
return handle_error_response(status, &response_body, &ErrorContext {
maybe_callback_url: req.callback_url.as_deref(),
});
}
let parsed: GenerationJobResponseBody = serde_json::from_str(&response_body)
.map_err(|err| BeebleGenericApiError::SerdeResponseParseError(err, response_body.clone()))?;
Ok(map_job_response(parsed))
}
pub(crate) fn map_job_response(parsed: GenerationJobResponseBody) -> StartGenerationSuccess {
StartGenerationSuccess {
id: parsed.id,
status: parsed.status,
progress: parsed.progress,
generation_type: parsed.generation_type,
alpha_mode: parsed.alpha_mode,
output: parsed.output.map(|o| GenerationOutputResult {
render: o.render,
source: o.source,
alpha: o.alpha,
}),
error: parsed.error,
created_at: parsed.created_at,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::requests::start_generation::request_types::GenerationJobResponseBody;
#[test]
fn request_body_serializes_minimal() {
let body = StartGenerationRequestBody {
generation_type: "image".to_string(),
source_uri: "beeble://upload_abc/photo.jpg".to_string(),
alpha_mode: "auto".to_string(),
prompt: None,
reference_image_uri: None,
alpha_uri: None,
max_resolution: None,
callback_url: None,
idempotency_key: None,
};
let json = serde_json::to_string(&body).unwrap();
assert!(json.contains("\"generation_type\":\"image\""));
assert!(json.contains("\"alpha_mode\":\"auto\""));
// Optional fields should not be present
assert!(!json.contains("prompt"));
assert!(!json.contains("reference_image_uri"));
}
#[test]
fn request_body_serializes_full() {
let body = StartGenerationRequestBody {
generation_type: "video".to_string(),
source_uri: "https://example.com/video.mp4".to_string(),
alpha_mode: "custom".to_string(),
prompt: Some("A sunny beach".to_string()),
reference_image_uri: Some("beeble://upload_ref/ref.jpg".to_string()),
alpha_uri: Some("beeble://upload_alpha/mask.png".to_string()),
max_resolution: Some(1080),
callback_url: Some("https://example.com/webhook".to_string()),
idempotency_key: Some("my-key-123".to_string()),
};
let json = serde_json::to_string(&body).unwrap();
assert!(json.contains("\"prompt\":\"A sunny beach\""));
assert!(json.contains("\"max_resolution\":1080"));
assert!(json.contains("\"idempotency_key\":\"my-key-123\""));
}
#[test]
fn response_body_deserializes_queued() {
let json = r#"{
"id": "swx_abc123",
"status": "in_queue",
"progress": null,
"generation_type": "image",
"alpha_mode": "auto",
"output": null,
"error": null,
"created_at": "2026-05-08T00:00:00Z",
"modified_at": "2026-05-08T00:00:00Z",
"completed_at": null,
"webhook": null
}"#;
let parsed: GenerationJobResponseBody = serde_json::from_str(json).unwrap();
assert_eq!(parsed.id, "swx_abc123");
assert_eq!(parsed.status, "in_queue");
assert!(parsed.output.is_none());
}
#[test]
fn response_body_deserializes_completed() {
let json = r#"{
"id": "swx_abc123",
"status": "completed",
"progress": 100,
"generation_type": "image",
"alpha_mode": "auto",
"output": {
"render": "https://cdn.beeble.ai/render.png",
"source": "https://cdn.beeble.ai/source.png",
"alpha": "https://cdn.beeble.ai/alpha.png"
},
"error": null,
"created_at": "2026-05-08T00:00:00Z",
"modified_at": "2026-05-08T00:00:01Z",
"completed_at": "2026-05-08T00:00:01Z",
"webhook": null
}"#;
let parsed: GenerationJobResponseBody = serde_json::from_str(json).unwrap();
assert_eq!(parsed.status, "completed");
assert_eq!(parsed.progress, Some(100));
let output = parsed.output.unwrap();
assert!(output.render.unwrap().contains("render.png"));
}
#[tokio::test]
#[ignore] // manually test — requires real API key and incurs costs
async fn test_start_image_generation() -> errors::AnyhowResult<()> {
use crate::test_utils::get_test_api_key::get_test_api_key;
let api_key = get_test_api_key()?;
let result = start_generation(StartGenerationArgs {
api_key,
request: StartGenerationRequest {
generation_type: BeebleGenerationType::Image,
source_uri: "https://images.unsplash.com/photo-1506748686214-e9df14d4d9d0?w=800".to_string(),
alpha_mode: BeebleAlphaMode::Auto,
prompt: Some("A tropical beach at sunset".to_string()),
reference_image_uri: None,
alpha_uri: None,
max_resolution: Some(720),
callback_url: None,
idempotency_key: None,
},
}).await?;
println!("Job ID: {}", result.id);
println!("Status: {}", result.status);
println!("Progress: {:?}", result.progress);
assert!(result.id.starts_with("swx_"));
Ok(())
}
// ── Helpers for end-to-end tests ──
async fn download_bytes(url: &str) -> errors::AnyhowResult<Vec<u8>> {
let response = reqwest::get(url).await?;
let bytes = response.bytes().await?;
Ok(bytes.to_vec())
}
fn guess_content_type(filename: &str) -> &'static str {
if filename.ends_with(".mp4") { "video/mp4" }
else if filename.ends_with(".mov") { "video/quicktime" }
else if filename.ends_with(".png") { "image/png" }
else if filename.ends_with(".jpg") || filename.ends_with(".jpeg") { "image/jpeg" }
else if filename.ends_with(".webp") { "image/webp" }
else { "application/octet-stream" }
}
async fn upload_asset_bytes(
api_key: &crate::creds::beeble_api_key::BeebleApiKey,
filename: &str,
bytes: Vec<u8>,
) -> errors::AnyhowResult<crate::requests::create_upload_url::create_upload_url::CreateUploadUrlSuccess> {
use crate::requests::create_upload_url::create_upload_url::{
create_upload_url, CreateUploadUrlArgs,
};
// 1. Get presigned upload URL.
let upload = create_upload_url(CreateUploadUrlArgs {
api_key: api_key.clone(),
filename: filename.to_string(),
}).await.map_err(|e| anyhow::anyhow!("create_upload_url failed: {:?}", e))?;
println!("Upload ID: {}", upload.id);
println!("Upload URL: {}", upload.upload_url);
println!("Beeble URI: {}", upload.beeble_uri);
// 2. PUT file bytes to the presigned URL with correct Content-Type.
let content_type = guess_content_type(filename);
println!("Uploading {} bytes with Content-Type: {}", bytes.len(), content_type);
let client = reqwest::Client::new();
let put_response = client.put(&upload.upload_url)
.header("Content-Type", content_type)
.body(bytes)
.send()
.await?;
let put_status = put_response.status();
let put_body = put_response.text().await.unwrap_or_default();
println!("PUT upload status: {} body: {}", put_status, put_body);
assert!(put_status.is_success(), "PUT upload failed: {} body: {}", put_status, put_body);
Ok(upload)
}
#[tokio::test]
#[ignore] // manually test — requires real API key, downloads files, incurs costs
async fn test_end_to_end_video_generation() -> errors::AnyhowResult<()> {
use crate::test_utils::get_test_api_key::get_test_api_key;
use test_data::web::image_urls::FOREST_BACKDROP_IMAGE_URL;
use test_data::web::video_urls::ANGRY_SHIBA_VIDEO_URL;
let api_key = get_test_api_key()?;
// 1. Download the source video.
println!("Downloading video...");
let video_bytes = download_bytes(ANGRY_SHIBA_VIDEO_URL).await?;
println!("Downloaded video: {} bytes", video_bytes.len());
// 2. Download the reference image.
println!("Downloading image...");
let image_bytes = download_bytes(FOREST_BACKDROP_IMAGE_URL).await?;
println!("Downloaded image: {} bytes", image_bytes.len());
// 3. Upload the video.
println!("Uploading video...");
let video_upload = upload_asset_bytes(&api_key, "shiba.mp4", video_bytes).await?;
// 4. Upload the reference image.
println!("Uploading image...");
let image_upload = upload_asset_bytes(&api_key, "forest.jpg", image_bytes).await?;
// 5. Start generation.
println!("Starting generation...");
let result = start_generation(StartGenerationArgs {
api_key,
request: StartGenerationRequest {
generation_type: BeebleGenerationType::Video,
source_uri: video_upload.beeble_uri,
alpha_mode: BeebleAlphaMode::Auto,
prompt: Some("Place the dog in a forest setting".to_string()),
reference_image_uri: Some(image_upload.beeble_uri),
alpha_uri: None,
max_resolution: Some(720),
callback_url: None,
idempotency_key: None,
},
}).await.map_err(|e| anyhow::anyhow!("start_generation failed: {:?}", e))?;
println!("Job ID: {}", result.id);
println!("Status: {}", result.status);
println!("Progress: {:?}", result.progress);
assert!(result.id.starts_with("swx_"));
// NB: We don't poll to completion here — that would take too long for a test.
// Use get_job_status to poll manually if needed.
Ok(())
}
}
@@ -0,0 +1,11 @@
use errors::AnyhowResult;
use std::fs::read_to_string;
use crate::creds::beeble_api_key::BeebleApiKey;
#[cfg(test)]
pub fn get_test_api_key() -> AnyhowResult<BeebleApiKey> {
let api_key = read_to_string("/Users/bt/Artcraft/credentials/beeble_api_key.txt")?;
let api_key = api_key.trim().to_string();
Ok(BeebleApiKey::new(api_key))
}
@@ -0,0 +1 @@
pub mod get_test_api_key;
@@ -0,0 +1,115 @@
use serde_derive::Deserialize;
/// Raw deserialized webhook payload from Beeble.
#[derive(Deserialize, Debug, Clone)]
pub struct BeebleWebhookPayload {
pub id: String,
pub status: BeebleWebhookStatus,
pub output: Option<BeebleWebhookOutput>,
pub error: Option<String>,
pub completed_at: Option<String>,
}
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum BeebleWebhookStatus {
Completed,
Failed,
/// Catch unknown variants gracefully.
#[serde(other)]
Unknown,
}
#[derive(Deserialize, Debug, Clone)]
pub struct BeebleWebhookOutput {
pub render: Option<String>,
pub source: Option<String>,
pub alpha: Option<String>,
}
pub fn parse_beeble_webhook(raw_body: &str) -> Result<BeebleWebhookPayload, serde_json::Error> {
serde_json::from_str(raw_body)
}
#[cfg(test)]
mod tests {
use super::*;
const SUCCESS_PAYLOAD: &str = r#"{
"id": "swx_abc123",
"status": "completed",
"output": {
"render": "https://cdn.beeble.ai/render.mp4",
"source": "https://cdn.beeble.ai/source.mp4",
"alpha": "https://cdn.beeble.ai/alpha.mp4"
},
"completed_at": "2026-02-23T10:05:00Z"
}"#;
const FAILURE_PAYLOAD: &str = r#"{
"id": "swx_abc123",
"status": "failed",
"completed_at": "2026-02-23T10:05:00Z",
"error": "Processing failed"
}"#;
const UNKNOWN_STATUS_PAYLOAD: &str = r#"{
"id": "swx_xyz789",
"status": "processing",
"completed_at": null
}"#;
const NULL_OUTPUT_PAYLOAD: &str = r#"{
"id": "swx_abc123",
"status": "completed",
"output": null,
"completed_at": "2026-02-23T10:05:00Z"
}"#;
mod parse_tests {
use super::*;
#[test]
fn parse_success_payload() {
let payload = parse_beeble_webhook(SUCCESS_PAYLOAD).unwrap();
assert_eq!(payload.id, "swx_abc123");
assert_eq!(payload.status, BeebleWebhookStatus::Completed);
assert!(payload.error.is_none());
let output = payload.output.unwrap();
assert_eq!(output.render.as_deref(), Some("https://cdn.beeble.ai/render.mp4"));
assert_eq!(output.source.as_deref(), Some("https://cdn.beeble.ai/source.mp4"));
assert_eq!(output.alpha.as_deref(), Some("https://cdn.beeble.ai/alpha.mp4"));
}
#[test]
fn parse_failure_payload() {
let payload = parse_beeble_webhook(FAILURE_PAYLOAD).unwrap();
assert_eq!(payload.id, "swx_abc123");
assert_eq!(payload.status, BeebleWebhookStatus::Failed);
assert_eq!(payload.error.as_deref(), Some("Processing failed"));
assert!(payload.output.is_none());
}
#[test]
fn parse_unknown_status() {
let payload = parse_beeble_webhook(UNKNOWN_STATUS_PAYLOAD).unwrap();
assert_eq!(payload.id, "swx_xyz789");
assert_eq!(payload.status, BeebleWebhookStatus::Unknown);
}
#[test]
fn parse_null_output() {
let payload = parse_beeble_webhook(NULL_OUTPUT_PAYLOAD).unwrap();
assert_eq!(payload.id, "swx_abc123");
assert_eq!(payload.status, BeebleWebhookStatus::Completed);
assert!(payload.output.is_none());
}
#[test]
fn parse_invalid_json() {
let result = parse_beeble_webhook("not valid json");
assert!(result.is_err());
}
}
}
@@ -0,0 +1 @@
pub mod beeble_webhook_payload;
@@ -0,0 +1,134 @@
use anyhow::anyhow;
use chrono::{DateTime, Utc};
use log::warn;
use sqlx::pool::PoolConnection;
use sqlx::{MySql, MySqlPool};
use enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty;
use enums::common::job_status_plus::JobStatusPlus;
use errors::AnyhowResult;
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use tokens::tokens::prompts::PromptToken;
use tokens::tokens::users::UserToken;
#[derive(Debug, Default)]
pub struct BeebleJobDetails {
pub job_token: InferenceJobToken,
pub status: JobStatusPlus,
pub external_third_party: InferenceJobExternalThirdParty,
pub external_third_party_id: String,
pub maybe_creator_user_token: Option<UserToken>,
pub maybe_creator_anonymous_visitor_token: Option<AnonymousVisitorTrackingToken>,
pub creator_ip_address: String,
pub maybe_prompt_token: Option<PromptToken>,
pub maybe_debug_log_event_token: Option<DebugLogEventToken>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Default)]
struct RawJobRecord {
job_token: InferenceJobToken,
status: JobStatusPlus,
// NB: Nullable, but required to be filled for this query
external_third_party: Option<InferenceJobExternalThirdParty>,
// NB: Nullable, but required to be filled for this query
external_third_party_id: Option<String>,
maybe_creator_user_token: Option<UserToken>,
maybe_creator_anonymous_visitor_token: Option<AnonymousVisitorTrackingToken>,
creator_ip_address: String,
maybe_prompt_token: Option<PromptToken>,
maybe_debug_log_event_token: Option<DebugLogEventToken>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
/// Returns Ok(None) when the record cannot be found.
pub async fn get_inference_job_by_beeble_id(beeble_id: &str, mysql_pool: &MySqlPool)
-> AnyhowResult<Option<BeebleJobDetails>>
{
let mut connection = mysql_pool.acquire().await?;
get_inference_job_by_beeble_id_from_connection(beeble_id, &mut connection).await
}
/// Returns Ok(None) when the record cannot be found.
pub async fn get_inference_job_by_beeble_id_from_connection(beeble_id: &str, mysql_connection: &mut PoolConnection<MySql>)
-> AnyhowResult<Option<BeebleJobDetails>>
{
let maybe_status = sqlx::query_as!(
RawJobRecord,
r#"
SELECT
jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,
jobs.status as `status: enums::common::job_status_plus::JobStatusPlus`,
jobs.maybe_external_third_party as `external_third_party: enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty`,
jobs.maybe_external_third_party_id as `external_third_party_id`,
jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,
jobs.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,
jobs.creator_ip_address,
jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,
jobs.maybe_debug_log_event_token as `maybe_debug_log_event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken`,
jobs.created_at,
jobs.updated_at
FROM generic_inference_jobs as jobs
WHERE jobs.maybe_external_third_party = ?
AND jobs.maybe_external_third_party_id = ?
"#,
InferenceJobExternalThirdParty::Beeble.to_str(),
beeble_id
)
.fetch_one(&mut **mysql_connection)
.await;
let record = match maybe_status {
Ok(record) => record,
Err(err) => match err {
sqlx::Error::RowNotFound => return Ok(None),
_ => {
warn!("error querying job record: {:?}", err);
return Err(anyhow!("error querying job record: {:?}", err));
}
}
};
let record = raw_record_to_public_result(record)?;
Ok(Some(record))
}
fn raw_record_to_public_result(record: RawJobRecord) -> AnyhowResult<BeebleJobDetails> {
Ok(BeebleJobDetails {
job_token: record.job_token,
status: record.status,
external_third_party: record.external_third_party.ok_or_else(|| anyhow!("missing external_third_party"))?,
external_third_party_id: record.external_third_party_id.ok_or_else(|| anyhow!("missing external_third_party_id"))?,
maybe_creator_user_token: record.maybe_creator_user_token,
maybe_creator_anonymous_visitor_token: record.maybe_creator_anonymous_visitor_token,
creator_ip_address: record.creator_ip_address,
maybe_prompt_token: record.maybe_prompt_token,
maybe_debug_log_event_token: record.maybe_debug_log_event_token,
created_at: record.created_at,
updated_at: record.updated_at,
})
}
@@ -0,0 +1,168 @@
use anyhow::anyhow;
use log::info;
use sqlx::{Executor, MySql};
use std::marker::PhantomData;
use enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory;
use enums::by_table::generic_inference_jobs::inference_category::InferenceCategory;
use enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty;
use enums::by_table::generic_inference_jobs::inference_job_product_category::InferenceJobProductCategory;
use enums::by_table::generic_inference_jobs::inference_job_type::InferenceJobType;
use enums::common::job_status_plus::JobStatusPlus;
use enums::common::visibility::Visibility;
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use tokens::tokens::prompts::PromptToken;
use tokens::tokens::users::UserToken;
use crate::errors::database_query_error::DatabaseQueryError;
use crate::payloads::generic_inference_args::generic_inference_args::GenericInferenceArgs;
pub struct InsertGenericInferenceForBeebleWithAprioriJobTokenArgs<'e, 'c, E>
where E: 'e + Executor<'c, Database = MySql>
{
pub uuid_idempotency_token: &'e str,
// NOTE: We'll generate this ahead of time so we can save it with billing info!
pub apriori_job_token: &'e InferenceJobToken,
/// The external primary key identifier for the job.
pub maybe_external_third_party_id: &'e str,
pub maybe_inference_args: Option<GenericInferenceArgs>,
pub maybe_prompt_token: Option<&'e PromptToken>,
pub maybe_creator_user_token: Option<&'e UserToken>,
pub maybe_avt_token: Option<&'e AnonymousVisitorTrackingToken>,
pub creator_ip_address: &'e str,
pub creator_set_visibility: Visibility,
pub maybe_debug_log_event_token: Option<&'e DebugLogEventToken>,
/// Override the initial job status. Defaults to `Pending` when `None`.
/// Set to `Some(JobStatusPlus::CompleteFailure)` for mock/test failure jobs.
pub starting_job_status_override: Option<JobStatusPlus>,
pub maybe_frontend_failure_category: Option<FrontendFailureCategory>,
pub maybe_failure_reason: Option<&'e str>,
pub mysql_executor: E,
// TODO: Not sure if this works to tell the compiler we need the lifetime annotation.
// See: https://doc.rust-lang.org/std/marker/struct.PhantomData.html#unused-lifetime-parameters
pub phantom: PhantomData<&'c E>,
}
pub async fn insert_generic_inference_job_for_beeble_queue_with_apriori_job_token<'e, 'c : 'e, E>(
args: InsertGenericInferenceForBeebleWithAprioriJobTokenArgs<'e, 'c, E>
) -> Result<InferenceJobToken, DatabaseQueryError>
where E: 'e + Executor<'c, Database = MySql>
{
let serialized_args_payload = serde_json::ser::to_string(&args.maybe_inference_args)
.map_err(|_e| anyhow!("could not encode inference args"))?;
const JOB_TYPE: InferenceJobType = InferenceJobType::BeebleQueue;
const INFERENCE_CATEGORY: InferenceCategory = InferenceCategory::VideoGeneration;
const PRODUCT_CATEGORY: InferenceJobProductCategory = InferenceJobProductCategory::BeebleVideo;
const EXTERNAL_THIRD_PARTY: InferenceJobExternalThirdParty = InferenceJobExternalThirdParty::Beeble;
let status = args.starting_job_status_override.unwrap_or(JobStatusPlus::Pending);
let maybe_frontend_failure_category_str = args.maybe_frontend_failure_category
.map(|c| c.to_str());
let maybe_truncated_failure_reason = args.maybe_failure_reason
.map(|s| if s.len() > 255 { &s[..255] } else { s });
let query = sqlx::query!(
r#"
INSERT INTO generic_inference_jobs
SET
token = ?,
uuid_idempotency_token = ?,
job_type = ?,
maybe_external_third_party = ?,
maybe_external_third_party_id = ?,
product_category = ?,
inference_category = ?,
maybe_model_type = NULL,
maybe_model_token = NULL,
maybe_input_source_token = NULL,
maybe_input_source_token_type = NULL,
maybe_download_url = NULL,
maybe_cover_image_media_file_token = NULL,
maybe_prompt_token = ?,
maybe_raw_inference_text = NULL,
maybe_inference_args = ?,
maybe_creator_user_token = ?,
maybe_creator_anonymous_visitor_token = ?,
creator_ip_address = ?,
creator_set_visibility = ?,
priority_level = 0,
is_keepalive_required = FALSE,
max_duration_seconds = 0,
is_debug_request = FALSE,
maybe_routing_tag = NULL,
maybe_debug_log_event_token = ?,
frontend_failure_category = ?,
failure_reason = ?,
status = ?
"#,
args.apriori_job_token.as_str(),
args.uuid_idempotency_token,
JOB_TYPE.to_str(),
EXTERNAL_THIRD_PARTY.to_str(),
args.maybe_external_third_party_id,
PRODUCT_CATEGORY.to_str(),
INFERENCE_CATEGORY.to_str(),
args.maybe_prompt_token.map(|t| t.to_string()),
serialized_args_payload,
args.maybe_creator_user_token.map(|t| t.to_string()),
args.maybe_avt_token.map(|t| t.to_string()),
args.creator_ip_address,
args.creator_set_visibility.to_str(),
args.maybe_debug_log_event_token.map(|t| t.as_str()),
maybe_frontend_failure_category_str,
maybe_truncated_failure_reason,
status.to_str(),
);
let query_result = query.execute(args.mysql_executor)
.await;
let record_id = match query_result {
Err(err) => return Err(DatabaseQueryError::from(err)),
Ok(res) => res.last_insert_id(),
};
info!("Insert generic inference job for Beeble queue: {} with record ID {}", args.apriori_job_token, record_id);
Ok(args.apriori_job_token.clone())
}
@@ -0,0 +1,2 @@
pub mod get_inference_job_by_beeble_id;
pub mod insert_generic_inference_job_for_beeble_queue_with_apriori_job_token;
@@ -1,3 +1,4 @@
pub mod beeble;
pub mod fal;
pub mod job;
pub mod moderation;
@@ -22,6 +22,7 @@ pub enum DebugLogType {
FalRequest,
KinoviRequest,
FalWebhook,
BeebleWebhook,
}
impl_enum_display_and_debug_using_to_str!(DebugLogType);
@@ -35,6 +36,7 @@ impl DebugLogType {
Self::FalRequest => "fal_request",
Self::KinoviRequest => "kinovi_request",
Self::FalWebhook => "fal_webhook",
Self::BeebleWebhook => "beeble_webhook",
}
}
@@ -44,6 +46,7 @@ impl DebugLogType {
"fal_request" => Ok(Self::FalRequest),
"kinovi_request" => Ok(Self::KinoviRequest),
"fal_webhook" => Ok(Self::FalWebhook),
"beeble_webhook" => Ok(Self::BeebleWebhook),
_ => Err(EnumError::CouldNotConvertFromString(value.to_string())),
}
}
@@ -54,6 +57,7 @@ impl DebugLogType {
Self::FalRequest,
Self::KinoviRequest,
Self::FalWebhook,
Self::BeebleWebhook,
])
}
}
@@ -74,6 +78,7 @@ mod tests {
assert_serialization(DebugLogType::FalRequest, "fal_request");
assert_serialization(DebugLogType::KinoviRequest, "kinovi_request");
assert_serialization(DebugLogType::FalWebhook, "fal_webhook");
assert_serialization(DebugLogType::BeebleWebhook, "beeble_webhook");
}
#[test]
@@ -82,6 +87,7 @@ mod tests {
assert_eq!(DebugLogType::FalRequest.to_str(), "fal_request");
assert_eq!(DebugLogType::KinoviRequest.to_str(), "kinovi_request");
assert_eq!(DebugLogType::FalWebhook.to_str(), "fal_webhook");
assert_eq!(DebugLogType::BeebleWebhook.to_str(), "beeble_webhook");
}
#[test]
@@ -90,6 +96,7 @@ mod tests {
assert_eq!(DebugLogType::from_str("fal_request").unwrap(), DebugLogType::FalRequest);
assert_eq!(DebugLogType::from_str("kinovi_request").unwrap(), DebugLogType::KinoviRequest);
assert_eq!(DebugLogType::from_str("fal_webhook").unwrap(), DebugLogType::FalWebhook);
assert_eq!(DebugLogType::from_str("beeble_webhook").unwrap(), DebugLogType::BeebleWebhook);
}
#[test]
@@ -106,11 +113,12 @@ mod tests {
#[test]
fn all_variants() {
let mut variants = DebugLogType::all_variants();
assert_eq!(variants.len(), 4);
assert_eq!(variants.len(), 5);
assert_eq!(variants.pop_first(), Some(DebugLogType::HttpRequest));
assert_eq!(variants.pop_first(), Some(DebugLogType::FalRequest));
assert_eq!(variants.pop_first(), Some(DebugLogType::KinoviRequest));
assert_eq!(variants.pop_first(), Some(DebugLogType::FalWebhook));
assert_eq!(variants.pop_first(), Some(DebugLogType::BeebleWebhook));
assert_eq!(variants.pop_first(), None);
}
}
@@ -12,6 +12,10 @@ use strum::EnumIter;
#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, Default, utoipa::ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum InferenceJobExternalThirdParty {
/// Beeble jobs
#[serde(rename = "beeble")]
Beeble,
/// Fal jobs
#[serde(rename = "fal")]
#[default]
@@ -34,6 +38,7 @@ impl_mysql_enum_coders!(InferenceJobExternalThirdParty);
impl InferenceJobExternalThirdParty {
pub fn to_str(&self) -> &'static str {
match self {
Self::Beeble => "beeble",
Self::Fal => "fal",
Self::Seedance2Pro => "seedance2pro",
Self::Worldlabs => "worldlabs",
@@ -42,6 +47,7 @@ impl InferenceJobExternalThirdParty {
pub fn from_str(value: &str) -> Result<Self, String> {
match value {
"beeble" => Ok(Self::Beeble),
"fal" => Ok(Self::Fal),
"seedance2pro" => Ok(Self::Seedance2Pro),
"worldlabs" => Ok(Self::Worldlabs),
@@ -53,6 +59,7 @@ impl InferenceJobExternalThirdParty {
// NB: BTreeSet is sorted
// NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc.
BTreeSet::from([
Self::Beeble,
Self::Fal,
Self::Seedance2Pro,
Self::Worldlabs,
@@ -70,6 +77,7 @@ mod tests {
#[test]
fn test_serialization() {
assert_serialization(InferenceJobExternalThirdParty::Beeble, "beeble");
assert_serialization(InferenceJobExternalThirdParty::Fal, "fal");
assert_serialization(InferenceJobExternalThirdParty::Seedance2Pro, "seedance2pro");
assert_serialization(InferenceJobExternalThirdParty::Worldlabs, "worldlabs");
@@ -77,6 +85,7 @@ mod tests {
#[test]
fn to_str() {
assert_eq!(InferenceJobExternalThirdParty::Beeble.to_str(), "beeble");
assert_eq!(InferenceJobExternalThirdParty::Fal.to_str(), "fal");
assert_eq!(InferenceJobExternalThirdParty::Seedance2Pro.to_str(), "seedance2pro");
assert_eq!(InferenceJobExternalThirdParty::Worldlabs.to_str(), "worldlabs");
@@ -84,6 +93,7 @@ mod tests {
#[test]
fn from_str() {
assert_eq!(InferenceJobExternalThirdParty::from_str("beeble").unwrap(), InferenceJobExternalThirdParty::Beeble);
assert_eq!(InferenceJobExternalThirdParty::from_str("fal").unwrap(), InferenceJobExternalThirdParty::Fal);
assert_eq!(InferenceJobExternalThirdParty::from_str("seedance2pro").unwrap(), InferenceJobExternalThirdParty::Seedance2Pro);
assert_eq!(InferenceJobExternalThirdParty::from_str("worldlabs").unwrap(), InferenceJobExternalThirdParty::Worldlabs);
@@ -92,7 +102,7 @@ mod tests {
#[test]
fn all_variants() {
// Static check
const EXPECTED_COUNT : usize = 3;
const EXPECTED_COUNT : usize = 4;
assert_eq!(InferenceJobExternalThirdParty::all_variants().len(), EXPECTED_COUNT);
assert_eq!(InferenceJobExternalThirdParty::iter().len(), EXPECTED_COUNT);
@@ -30,6 +30,11 @@ pub enum InferenceJobProductCategory {
FalObject,
FalBgRemoval,
// =============== BEEBLE ===============
#[serde(rename = "beeble_video")]
BeebleVideo,
// =============== SEEDANCE 2 PRO ===============
#[serde(rename = "seedance2pro_video")]
@@ -139,6 +144,7 @@ impl InferenceJobProductCategory {
Self::FalVideo => "fal_video",
Self::FalObject => "fal_object",
Self::FalBgRemoval => "fal_bg_removal",
Self::BeebleVideo => "beeble_video",
Self::Seedance2ProVideo => "seedance2pro_video",
Self::Seedance2ProCharacter => "seedance2pro_character",
Self::WorldlabsSplat => "worldlabs_splat",
@@ -174,6 +180,7 @@ impl InferenceJobProductCategory {
"fal_video" => Ok(Self::FalVideo),
"fal_object" => Ok(Self::FalObject),
"fal_bg_removal" => Ok(Self::FalBgRemoval),
"beeble_video" => Ok(Self::BeebleVideo),
"seedance2pro_video" => Ok(Self::Seedance2ProVideo),
"seedance2pro_character" => Ok(Self::Seedance2ProCharacter),
"worldlabs_splat" => Ok(Self::WorldlabsSplat),
@@ -212,6 +219,7 @@ impl InferenceJobProductCategory {
Self::FalVideo,
Self::FalObject,
Self::FalBgRemoval,
Self::BeebleVideo,
Self::Seedance2ProVideo,
Self::Seedance2ProCharacter,
Self::WorldlabsSplat,
@@ -256,6 +264,7 @@ mod tests {
assert_serialization(InferenceJobProductCategory::FalVideo, "fal_video");
assert_serialization(InferenceJobProductCategory::FalObject, "fal_object");
assert_serialization(InferenceJobProductCategory::FalBgRemoval, "fal_bg_removal");
assert_serialization(InferenceJobProductCategory::BeebleVideo, "beeble_video");
assert_serialization(InferenceJobProductCategory::Seedance2ProVideo, "seedance2pro_video");
assert_serialization(InferenceJobProductCategory::Seedance2ProCharacter, "seedance2pro_character");
assert_serialization(InferenceJobProductCategory::WorldlabsSplat, "worldlabs_splat");
@@ -290,6 +299,7 @@ mod tests {
assert_eq!(InferenceJobProductCategory::FalVideo.to_str(), "fal_video");
assert_eq!(InferenceJobProductCategory::FalObject.to_str(), "fal_object");
assert_eq!(InferenceJobProductCategory::FalBgRemoval.to_str(), "fal_bg_removal");
assert_eq!(InferenceJobProductCategory::BeebleVideo.to_str(), "beeble_video");
assert_eq!(InferenceJobProductCategory::Seedance2ProVideo.to_str(), "seedance2pro_video");
assert_eq!(InferenceJobProductCategory::Seedance2ProCharacter.to_str(), "seedance2pro_character");
assert_eq!(InferenceJobProductCategory::WorldlabsSplat.to_str(), "worldlabs_splat");
@@ -323,6 +333,7 @@ mod tests {
assert_eq!(InferenceJobProductCategory::from_str("fal_video").unwrap(), InferenceJobProductCategory::FalVideo);
assert_eq!(InferenceJobProductCategory::from_str("fal_object").unwrap(), InferenceJobProductCategory::FalObject);
assert_eq!(InferenceJobProductCategory::from_str("fal_bg_removal").unwrap(), InferenceJobProductCategory::FalBgRemoval);
assert_eq!(InferenceJobProductCategory::from_str("beeble_video").unwrap(), InferenceJobProductCategory::BeebleVideo);
assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_video").unwrap(), InferenceJobProductCategory::Seedance2ProVideo);
assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_character").unwrap(), InferenceJobProductCategory::Seedance2ProCharacter);
assert_eq!(InferenceJobProductCategory::from_str("worldlabs_splat").unwrap(), InferenceJobProductCategory::WorldlabsSplat);
@@ -352,7 +363,7 @@ mod tests {
#[test]
fn all_variants() {
// Static check
const EXPECTED_COUNT : usize = 30;
const EXPECTED_COUNT : usize = 31;
assert_eq!(InferenceJobProductCategory::all_variants().len(), EXPECTED_COUNT);
assert_eq!(InferenceJobProductCategory::iter().len(), EXPECTED_COUNT);
@@ -27,6 +27,10 @@ pub enum InferenceJobType {
#[serde(rename = "seedance2pro_character")]
Seedance2ProCharacter,
/// Beeble jobs. We poll for results.
#[serde(rename = "beeble_queue")]
BeebleQueue,
/// World Labs jobs. We poll for results.
#[serde(rename = "worldlabs_queue")]
WorldlabsQueue,
@@ -122,6 +126,7 @@ impl InferenceJobType {
pub fn to_str(&self) -> &'static str {
match self {
Self::FalQueue => "fal_queue",
Self::BeebleQueue => "beeble_queue",
Self::Seedance2ProQueue => "seedance2pro_queue",
Self::Seedance2ProCharacter => "seedance2pro_character",
Self::WorldlabsQueue => "worldlabs_queue",
@@ -151,6 +156,7 @@ impl InferenceJobType {
pub fn from_str(value: &str) -> Result<Self, String> {
match value {
"fal_queue" => Ok(Self::FalQueue),
"beeble_queue" => Ok(Self::BeebleQueue),
"seedance2pro_queue" => Ok(Self::Seedance2ProQueue),
"seedance2pro_character" => Ok(Self::Seedance2ProCharacter),
"worldlabs_queue" => Ok(Self::WorldlabsQueue),
@@ -183,6 +189,7 @@ impl InferenceJobType {
// NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc.
BTreeSet::from([
Self::FalQueue,
Self::BeebleQueue,
Self::Seedance2ProQueue,
Self::Seedance2ProCharacter,
Self::WorldlabsQueue,
@@ -226,6 +233,7 @@ mod tests {
#[test]
fn test_serialization() {
assert_serialization(InferenceJobType::FalQueue, "fal_queue");
assert_serialization(InferenceJobType::BeebleQueue, "beeble_queue");
assert_serialization(InferenceJobType::Seedance2ProQueue, "seedance2pro_queue");
assert_serialization(InferenceJobType::Seedance2ProCharacter, "seedance2pro_character");
assert_serialization(InferenceJobType::WorldlabsQueue, "worldlabs_queue");
@@ -254,6 +262,7 @@ mod tests {
#[test]
fn to_str() {
assert_eq!(InferenceJobType::FalQueue.to_str(), "fal_queue");
assert_eq!(InferenceJobType::BeebleQueue.to_str(), "beeble_queue");
assert_eq!(InferenceJobType::Seedance2ProQueue.to_str(), "seedance2pro_queue");
assert_eq!(InferenceJobType::Seedance2ProCharacter.to_str(), "seedance2pro_character");
assert_eq!(InferenceJobType::WorldlabsQueue.to_str(), "worldlabs_queue");
@@ -282,6 +291,7 @@ mod tests {
#[test]
fn from_str() {
assert_eq!(InferenceJobType::from_str("fal_queue").unwrap(), InferenceJobType::FalQueue);
assert_eq!(InferenceJobType::from_str("beeble_queue").unwrap(), InferenceJobType::BeebleQueue);
assert_eq!(InferenceJobType::from_str("seedance2pro_queue").unwrap(), InferenceJobType::Seedance2ProQueue);
assert_eq!(InferenceJobType::from_str("seedance2pro_character").unwrap(), InferenceJobType::Seedance2ProCharacter);
assert_eq!(InferenceJobType::from_str("worldlabs_queue").unwrap(), InferenceJobType::WorldlabsQueue);
@@ -310,7 +320,7 @@ mod tests {
#[test]
fn all_variants() {
// Static check
const EXPECTED_COUNT : usize = 24;
const EXPECTED_COUNT : usize = 25;
assert_eq!(InferenceJobType::all_variants().len(), EXPECTED_COUNT);
assert_eq!(InferenceJobType::iter().len(), EXPECTED_COUNT);
@@ -69,6 +69,9 @@ pub enum MediaFileOriginProductCategory {
#[serde(rename = "video_gen")]
VideoGeneration,
#[serde(rename = "video_edit")]
VideoEdit,
#[serde(rename = "world_gen")]
WorldGeneration,
@@ -105,6 +108,7 @@ impl MediaFileOriginProductCategory {
Self::Mocap => "mocap",
Self::ImageGeneration => "image_gen",
Self::VideoGeneration => "video_gen",
Self::VideoEdit => "video_edit",
Self::WorldGeneration => "world_gen",
Self::VideoFilter => "video_filter",
Self::Workflow => "workflow",
@@ -126,6 +130,7 @@ impl MediaFileOriginProductCategory {
"mocap" => Ok(Self::Mocap),
"image_gen" => Ok(Self::ImageGeneration),
"video_gen" => Ok(Self::VideoGeneration),
"video_edit" => Ok(Self::VideoEdit),
"world_gen" => Ok(Self::WorldGeneration),
"video_filter" => Ok(Self::VideoFilter),
"workflow" => Ok(Self::Workflow),
@@ -150,6 +155,7 @@ impl MediaFileOriginProductCategory {
Self::Mocap,
Self::ImageGeneration,
Self::VideoGeneration,
Self::VideoEdit,
Self::WorldGeneration,
Self::VideoFilter,
Self::Workflow,
@@ -180,6 +186,7 @@ mod tests {
assert_serialization(MediaFileOriginProductCategory::Mocap, "mocap");
assert_serialization(MediaFileOriginProductCategory::ImageGeneration, "image_gen");
assert_serialization(MediaFileOriginProductCategory::VideoGeneration, "video_gen");
assert_serialization(MediaFileOriginProductCategory::VideoEdit, "video_edit");
assert_serialization(MediaFileOriginProductCategory::WorldGeneration, "world_gen");
assert_serialization(MediaFileOriginProductCategory::VideoFilter, "video_filter");
assert_serialization(MediaFileOriginProductCategory::Workflow, "workflow");
@@ -200,6 +207,7 @@ mod tests {
assert_eq!(MediaFileOriginProductCategory::Mocap.to_str(), "mocap");
assert_eq!(MediaFileOriginProductCategory::ImageGeneration.to_str(), "image_gen");
assert_eq!(MediaFileOriginProductCategory::VideoGeneration.to_str(), "video_gen");
assert_eq!(MediaFileOriginProductCategory::VideoEdit.to_str(), "video_edit");
assert_eq!(MediaFileOriginProductCategory::WorldGeneration.to_str(), "world_gen");
assert_eq!(MediaFileOriginProductCategory::VideoFilter.to_str(), "video_filter");
assert_eq!(MediaFileOriginProductCategory::Workflow.to_str(), "workflow");
@@ -220,6 +228,7 @@ mod tests {
assert_eq!(MediaFileOriginProductCategory::from_str("mocap").unwrap(), MediaFileOriginProductCategory::Mocap);
assert_eq!(MediaFileOriginProductCategory::from_str("image_gen").unwrap(),MediaFileOriginProductCategory::ImageGeneration);
assert_eq!(MediaFileOriginProductCategory::from_str("video_gen").unwrap(), MediaFileOriginProductCategory::VideoGeneration);
assert_eq!(MediaFileOriginProductCategory::from_str("video_edit").unwrap(), MediaFileOriginProductCategory::VideoEdit);
assert_eq!(MediaFileOriginProductCategory::from_str("world_gen").unwrap(), MediaFileOriginProductCategory::WorldGeneration);
assert_eq!(MediaFileOriginProductCategory::from_str("video_filter").unwrap(), MediaFileOriginProductCategory::VideoFilter);
assert_eq!(MediaFileOriginProductCategory::from_str("workflow").unwrap(), MediaFileOriginProductCategory::Workflow);
@@ -228,7 +237,7 @@ mod tests {
#[test]
fn all_variants() {
let mut variants = MediaFileOriginProductCategory::all_variants();
assert_eq!(variants.len(), 16);
assert_eq!(variants.len(), 17);
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::Unknown));
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::FaceAnimator));
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::FaceFusion));
@@ -242,6 +251,7 @@ mod tests {
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::Mocap));
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::ImageGeneration));
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::VideoGeneration));
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::VideoEdit));
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::WorldGeneration));
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::VideoFilter));
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::Workflow));
@@ -144,6 +144,8 @@ pub enum CommonModelType {
Veo3p1,
#[serde(rename = "veo_3p1_fast")]
Veo3p1Fast,
#[serde(rename = "switch_x")]
SwitchX,
// 3D Object generation models
#[serde(rename = "hunyuan_3d_2p0")]
@@ -224,6 +226,7 @@ impl CommonModelType {
Self::Veo3Fast => "veo_3_fast",
Self::Veo3p1 => "veo_3p1",
Self::Veo3p1Fast => "veo_3p1_fast",
Self::SwitchX => "switch_x",
// 3D Object generation models
Self::Hunyuan3d2_0 => "hunyuan_3d_2p0",
@@ -293,6 +296,7 @@ impl CommonModelType {
"veo_3_fast" => Ok(Self::Veo3Fast),
"veo_3p1" => Ok(Self::Veo3p1),
"veo_3p1_fast" => Ok(Self::Veo3p1Fast),
"switch_x" => Ok(Self::SwitchX),
// 3D Object generation models
"hunyuan_3d_2p0" => Ok(Self::Hunyuan3d2_0),
@@ -366,6 +370,7 @@ impl CommonModelType {
Self::Veo3Fast,
Self::Veo3p1,
Self::Veo3p1Fast,
Self::SwitchX,
// 3D Object generation models
Self::Hunyuan3d2_0,
@@ -436,6 +441,7 @@ impl CommonModelType {
Self::Veo3Fast => CommonModelClass::Video,
Self::Veo3p1 => CommonModelClass::Video,
Self::Veo3p1Fast => CommonModelClass::Video,
Self::SwitchX => CommonModelClass::Video,
// 3D Object generation models (mesh)
Self::Hunyuan3d2_0 => CommonModelClass::DimensionalMesh,
@@ -515,6 +521,7 @@ mod tests {
assert_serialization(CommonModelType::Veo3Fast, "veo_3_fast");
assert_serialization(CommonModelType::Veo3p1, "veo_3p1");
assert_serialization(CommonModelType::Veo3p1Fast, "veo_3p1_fast");
assert_serialization(CommonModelType::SwitchX, "switch_x");
// 3D Object generation models
assert_serialization(CommonModelType::Hunyuan3d2_0, "hunyuan_3d_2p0");
assert_serialization(CommonModelType::Hunyuan3d2_1, "hunyuan_3d_2p1");
@@ -581,6 +588,7 @@ mod tests {
assert_eq!(CommonModelType::Veo3Fast.to_str(), "veo_3_fast");
assert_eq!(CommonModelType::Veo3p1.to_str(), "veo_3p1");
assert_eq!(CommonModelType::Veo3p1Fast.to_str(), "veo_3p1_fast");
assert_eq!(CommonModelType::SwitchX.to_str(), "switch_x");
// 3D Object generation models
assert_eq!(CommonModelType::Hunyuan3d2_0.to_str(), "hunyuan_3d_2p0");
@@ -647,7 +655,8 @@ mod tests {
assert_eq!(CommonModelType::from_str("veo_3_fast").unwrap(), CommonModelType::Veo3Fast);
assert_eq!(CommonModelType::from_str("veo_3p1").unwrap(), CommonModelType::Veo3p1);
assert_eq!(CommonModelType::from_str("veo_3p1_fast").unwrap(), CommonModelType::Veo3p1Fast);
assert_eq!(CommonModelType::from_str("switch_x").unwrap(), CommonModelType::SwitchX);
// 3D Object generation models
assert_eq!(CommonModelType::from_str("hunyuan_3d_2p0").unwrap(), CommonModelType::Hunyuan3d2_0);
assert_eq!(CommonModelType::from_str("hunyuan_3d_2p1").unwrap(), CommonModelType::Hunyuan3d2_1);
@@ -660,7 +669,7 @@ mod tests {
#[test]
fn all_variants() {
let mut variants = CommonModelType::all_variants();
assert_eq!(variants.len(), 57);
assert_eq!(variants.len(), 58);
// Image models
assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Dev));
assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Schnell));
@@ -715,6 +724,7 @@ mod tests {
assert_eq!(variants.pop_first(), Some(CommonModelType::Veo3Fast));
assert_eq!(variants.pop_first(), Some(CommonModelType::Veo3p1));
assert_eq!(variants.pop_first(), Some(CommonModelType::Veo3p1Fast));
assert_eq!(variants.pop_first(), Some(CommonModelType::SwitchX));
// 3D Object generation models
assert_eq!(variants.pop_first(), Some(CommonModelType::Hunyuan3d2_0));
assert_eq!(variants.pop_first(), Some(CommonModelType::Hunyuan3d2_1));
@@ -50,6 +50,7 @@ datetimes = { path = "../../../lib/data/datetimes" }
easyenv.workspace = true
elasticsearch_schema = { path = "../../../schema/database/elasticsearch_schema" }
enums.workspace = true
beeble_client.workspace = true
error_utils.workspace = true
errors.workspace = true
fal_client.workspace = true
@@ -51,6 +51,8 @@ FAL_WEBHOOK_URL="https://brandons-macbook-pro.taild62114.ts.net/v1/webhooks/fal"
#FAL_WEBHOOK_URL="https://halide.tailce84f.ts.net/v1/webhooks/fal"
#FAL_WEBHOOK_URL="https://s1.pancake-chimaera.ts.net/api/fal/webhook"
BEEBLE_WEBHOOK_URL="https://brandons-macbook-pro.taild62114.ts.net/v1/webhooks/beeble"
STRIPE_ARTCRAFT_CHECKOUT_SUCCESS_URL="https://getartcraft.com/checkout_success"
STRIPE_ARTCRAFT_CHECKOUT_CANCEL_URL="https://getartcraft.com/checkout_cancel"
STRIPE_ARTCRAFT_PORTAL_RETURN_URL="https://getartcraft.com/portal_closed"
@@ -85,6 +85,7 @@ use crate::http_server::endpoints::media_files::upsert_upload::write_engine_asse
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 artcraft_api_defs::generate::video::edit::beeble_switchx_edit_video::*;
use crate::http_server::endpoints::moderation::debug_logs::moderation_list_debug_logs_for_token_handler::*;
use crate::http_server::endpoints::moderation::jobs::moderation_get_job_by_token_handler::*;
use crate::http_server::endpoints::moderation::user_feature_flags::moderator_edit_user_feature_flags_handler::*;
@@ -365,6 +366,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
crate::http_server::endpoints::generate::video::image::generate_kling_2_1_master_video_handler::generate_kling_2_1_master_video_handler,
crate::http_server::endpoints::generate::video::image::generate_kling_2_1_pro_video_handler::generate_kling_2_1_pro_video_handler,
crate::http_server::endpoints::generate::video::image::generate_seedance_1_0_lite_image_to_video_handler::generate_seedance_1_0_lite_image_to_video_handler,
crate::http_server::endpoints::generate::video::edit::beeble_switchx_edit_video_gen_handler::beeble_switchx_edit_video_gen_handler,
crate::http_server::endpoints::generate::video::image::generate_veo_2_image_to_video_handler::generate_veo_2_image_to_video_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,
@@ -860,6 +862,8 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
GenerateSeedance10LiteImageToVideoResponse,
GenerateSeedance10LiteResolution,
GenerateVeo2AspectRatio,
BeebleSwitchXEditVideoRequest,
BeebleSwitchXEditVideoResponse,
GenerateVeo2Duration,
GenerateVeo2ImageToVideoRequest,
GenerateVeo2ImageToVideoResponse,
@@ -0,0 +1,345 @@
use std::sync::Arc;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use log::{error, info, warn};
use sqlx::Acquire;
use artcraft_api_defs::generate::video::edit::beeble_switchx_edit_video::{
BeebleSwitchXEditVideoRequest, BeebleSwitchXEditVideoResponse,
};
use beeble_client::error::beeble_error::BeebleError;
use beeble_client::error::beeble_specific_api_error::BeebleSpecificApiError;
use beeble_client::requests::create_upload_url::upload_bytes::upload_bytes_to_beeble;
use beeble_client::requests::start_generation::start_generation::{
start_generation, BeebleAlphaMode, BeebleGenerationType, StartGenerationArgs,
StartGenerationRequest,
};
use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType;
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use enums::common::generation::common_model_type::CommonModelType;
use enums::common::generation_provider::GenerationProvider;
use enums::common::visibility::Visibility;
use http_server_common::request::get_request_ip::get_request_ip;
use mysql_queries::queries::generic_inference::beeble::insert_generic_inference_job_for_beeble_queue_with_apriori_job_token::{
insert_generic_inference_job_for_beeble_queue_with_apriori_job_token,
InsertGenericInferenceForBeebleWithAprioriJobTokenArgs,
};
use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token;
use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_items::{
insert_batch_prompt_context_items, InsertBatchArgs, PromptContextItem,
};
use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPromptArgs};
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::media_files::MediaFileToken;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::endpoints::generate::common::payments_error_test::payments_error_test;
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
use crate::state::server_state::ServerState;
use crate::util::http_download_url_to_bytes::http_download_url_to_bytes;
use crate::util::lookup::lookup_image_urls_as_map::lookup_image_urls_as_map;
/// Beeble SwitchX Video Edit
#[utoipa::path(
post,
tag = "Generate Videos",
path = "/v1/generate/video/edit/beeble_switchx",
request_body = BeebleSwitchXEditVideoRequest,
responses(
(status = 200, description = "Success", body = BeebleSwitchXEditVideoResponse),
(status = 400, description = "Bad input"),
(status = 401, description = "Unauthorized"),
(status = 402, description = "Payment required"),
(status = 500, description = "Server error"),
),
)]
pub async fn beeble_switchx_edit_video_gen_handler(
http_request: HttpRequest,
request: Json<BeebleSwitchXEditVideoRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<BeebleSwitchXEditVideoResponse>, AdvancedCommonWebError> {
payments_error_test(&request.prompt.as_deref().unwrap_or(""))?;
// ==================== SESSION ==================== //
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
let maybe_user_session = server_state
.session_checker
.maybe_get_user_session_from_connection(&http_request, &mut mysql_connection)
.await
.map_err(|e| {
warn!("Session checker error: {:?}", e);
AdvancedCommonWebError::from(e)
})?;
let user_token = match maybe_user_session.as_ref() {
Some(session) => &session.user_token,
None => return Err(AdvancedCommonWebError::NotAuthorized),
};
let maybe_avt_token = server_state
.avt_cookie_manager
.get_avt_token_from_request(&http_request);
// ==================== VALIDATE INPUT ==================== //
let source_video_media_token = request.source_video_media_token.as_ref()
.ok_or_else(|| {
AdvancedCommonWebError::BadInputWithSimpleMessage("source_video_media_token is required".to_string())
})?;
if let Err(reason) = validate_idempotency_token_format(&request.uuid_idempotency_token) {
return Err(AdvancedCommonWebError::BadInputWithSimpleMessage(reason));
}
insert_idempotency_token(&request.uuid_idempotency_token, &mut *mysql_connection)
.await
.map_err(|err| {
error!("Error inserting idempotency token: {:?}", err);
AdvancedCommonWebError::BadInputWithSimpleMessage("repeated idempotency token".to_string())
})?;
// ==================== LOOKUP MEDIA FILES (BATCH) ==================== //
let mut tokens_to_lookup: Vec<MediaFileToken> = vec![source_video_media_token.clone()];
if let Some(ref_token) = &request.reference_image_media_token {
tokens_to_lookup.push(ref_token.clone());
}
let cdn_url_map = lookup_image_urls_as_map(
&http_request,
&mut mysql_connection,
server_state.server_environment,
&tokens_to_lookup,
).await.map_err(|err| {
warn!("Error looking up media files: {:?}", err);
AdvancedCommonWebError::from(err)
})?;
let source_video_cdn_url = cdn_url_map.get(source_video_media_token)
.ok_or_else(|| {
warn!("Source video media file not found: {:?}", source_video_media_token);
AdvancedCommonWebError::NotFound
})?
.clone();
let maybe_reference_image_cdn_url = request.reference_image_media_token.as_ref()
.map(|ref_token| {
cdn_url_map.get(ref_token)
.cloned()
.ok_or_else(|| {
warn!("Reference image media file not found: {:?}", ref_token);
AdvancedCommonWebError::NotFound
})
})
.transpose()?;
// ==================== DOWNLOAD & UPLOAD TO BEEBLE ==================== //
let beeble_api_key = &server_state.beeble.api_key;
// Download + upload source video
info!("Downloading source video from CDN: {}", source_video_cdn_url);
let video_bytes = http_download_url_to_bytes(&source_video_cdn_url).await
.map_err(|err| {
error!("Failed to download source video: {:?}", err);
AdvancedCommonWebError::from_error(err)
})?;
info!("Downloaded source video: {} bytes", video_bytes.len());
let video_extension = std::path::Path::new(&source_video_cdn_url)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("mp4");
let video_filename = format!("{}.{}", source_video_media_token.as_str(), video_extension);
let video_content_type = match video_extension {
"mp4" => "video/mp4",
"mov" => "video/quicktime",
_ => "application/octet-stream",
};
let video_upload = upload_bytes_to_beeble(
beeble_api_key, &video_filename, video_content_type, video_bytes.to_vec(),
).await.map_err(|err| {
error!("Beeble video upload failed: {:?}", err);
AdvancedCommonWebError::from_error(err)
})?;
info!("Source video uploaded to Beeble: {}", video_upload.beeble_uri);
// Download + upload reference image (if provided)
let maybe_reference_beeble_uri = if let Some(ref_cdn_url) = &maybe_reference_image_cdn_url {
info!("Downloading reference image from CDN: {}", ref_cdn_url);
let image_bytes = http_download_url_to_bytes(ref_cdn_url).await
.map_err(|err| {
error!("Failed to download reference image: {:?}", err);
AdvancedCommonWebError::from_error(err)
})?;
info!("Downloaded reference image: {} bytes", image_bytes.len());
let ref_token = request.reference_image_media_token.as_ref().unwrap();
let image_filename = format!("{}.jpg", ref_token.as_str());
let image_upload = upload_bytes_to_beeble(
beeble_api_key, &image_filename, "image/jpeg", image_bytes.to_vec(),
).await.map_err(|err| {
error!("Beeble image upload failed: {:?}", err);
AdvancedCommonWebError::from_error(err)
})?;
info!("Reference image uploaded to Beeble: {}", image_upload.beeble_uri);
Some(image_upload.beeble_uri)
} else {
None
};
// ==================== CALL BEEBLE GENERATE ==================== //
let apriori_job_token = InferenceJobToken::generate();
let beeble_result = start_generation(StartGenerationArgs {
api_key: beeble_api_key.clone(),
request: StartGenerationRequest {
generation_type: BeebleGenerationType::Video,
source_uri: video_upload.beeble_uri,
alpha_mode: BeebleAlphaMode::Auto,
prompt: request.prompt.clone(),
reference_image_uri: maybe_reference_beeble_uri,
alpha_uri: None,
max_resolution: Some(1080),
callback_url: Some(server_state.beeble.webhook_url.clone()),
idempotency_key: Some(request.uuid_idempotency_token.clone()),
},
}).await.map_err(|err| {
warn!("Beeble start_generation failed: {:?}", err);
match err {
BeebleError::ApiSpecific(BeebleSpecificApiError::VideoHasTooManyFrames {
max_frames,
detected_frames,
ref message,
}) => {
AdvancedCommonWebError::BadInputWithSimpleMessage(format!(
"Video has too many frames: {} (max: {}); {}",
detected_frames, max_frames, message
))
},
_ => {
AdvancedCommonWebError::from_error(err)
}
}
})?;
let external_job_id = &beeble_result.id;
info!("Beeble job started: id={}, status={}", external_job_id, beeble_result.status);
// ==================== DB TRANSACTION ==================== //
let ip_address = get_request_ip(&http_request);
let mut transaction = mysql_connection.begin().await.map_err(|err| {
error!("Error starting MySQL transaction: {:?}", err);
AdvancedCommonWebError::from_error(err)
})?;
// -- Prompt --
let prompt_token = match insert_prompt(InsertPromptArgs {
maybe_apriori_prompt_token: None,
prompt_type: PromptType::ArtcraftApp,
maybe_creator_user_token: Some(user_token),
maybe_model_type: Some(CommonModelType::SwitchX),
maybe_generation_provider: Some(GenerationProvider::Artcraft),
maybe_positive_prompt: request.prompt.as_deref(),
maybe_negative_prompt: None,
maybe_other_args: None,
maybe_generation_mode: Some(CommonGenerationMode::Edit),
maybe_aspect_ratio: None,
maybe_resolution: None,
maybe_batch_count: None,
maybe_generate_audio: None,
maybe_duration_seconds: None,
creator_ip_address: &ip_address,
mysql_executor: &mut *transaction,
phantom: Default::default(),
}).await {
Ok(token) => Some(token),
Err(err) => {
warn!("Error inserting prompt: {:?}", err);
None
}
};
// -- Prompt context items --
if let Some(token) = prompt_token.as_ref() {
let mut context_items: Vec<PromptContextItem> = Vec::new();
context_items.push(PromptContextItem {
media_token: source_video_media_token.clone(),
context_semantic_type: PromptContextSemanticType::VidRef,
});
if let Some(ref_token) = &request.reference_image_media_token {
context_items.push(PromptContextItem {
media_token: ref_token.clone(),
context_semantic_type: PromptContextSemanticType::Imgref,
});
}
if !context_items.is_empty() {
if let Err(err) = insert_batch_prompt_context_items(InsertBatchArgs {
prompt_token: token.clone(),
items: context_items,
transaction: &mut transaction,
}).await {
warn!("Error inserting batch prompt context items: {:?}", err);
}
}
}
// -- Inference job --
let db_result = insert_generic_inference_job_for_beeble_queue_with_apriori_job_token(
InsertGenericInferenceForBeebleWithAprioriJobTokenArgs {
apriori_job_token: &apriori_job_token,
uuid_idempotency_token: &request.uuid_idempotency_token,
maybe_external_third_party_id: external_job_id,
maybe_inference_args: None,
maybe_prompt_token: prompt_token.as_ref(),
maybe_creator_user_token: Some(user_token),
maybe_avt_token: maybe_avt_token.as_ref(),
creator_ip_address: &ip_address,
creator_set_visibility: Visibility::Public,
maybe_debug_log_event_token: None,
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
mysql_executor: &mut *transaction,
phantom: Default::default(),
}
).await;
let job_token = match db_result {
Ok(token) => token,
Err(err) => {
error!("Error inserting inference job: {:?}", err);
return Err(AdvancedCommonWebError::from_error(err));
}
};
transaction.commit().await.map_err(|err| {
error!("Error committing transaction: {:?}", err);
AdvancedCommonWebError::from_error(err)
})?;
Ok(Json(BeebleSwitchXEditVideoResponse {
success: true,
inference_job_token: job_token,
}))
}
@@ -0,0 +1 @@
pub mod beeble_switchx_edit_video_gen_handler;
@@ -1,2 +1,3 @@
pub mod edit;
pub mod image;
pub mod multi_function;
@@ -0,0 +1,427 @@
use std::io::Write;
use std::sync::Arc;
use actix_web::web::Bytes;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use log::{error, info, warn};
use sqlx::pool::PoolConnection;
use sqlx::MySql;
use tempfile::NamedTempFile;
use beeble_client::webhook_api::beeble_webhook_payload::{
parse_beeble_webhook, BeebleWebhookPayload, BeebleWebhookStatus,
};
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use enums::by_table::debug_logs::debug_log_type::DebugLogType;
use enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use ffmpeg_utils::ffprobe::ffprobe_get_info::ffprobe_get_info;
use filesys::path_to_string::path_to_string;
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
use http_server_common::response::response_success_helpers::SimpleGenericJsonSuccess;
use mysql_queries::queries::debug_logs::insert_debug_log::{insert_debug_log, InsertDebugLogArgs};
use mysql_queries::queries::generic_inference::beeble::get_inference_job_by_beeble_id::{
get_inference_job_by_beeble_id_from_connection, BeebleJobDetails,
};
use mysql_queries::queries::generic_inference::fal::mark_fal_generic_inference_job_successfully_done::{
mark_fal_generic_inference_job_successfully_done, MarkJobArgs,
};
use mysql_queries::queries::generic_inference::job::mark_job_failed_by_token::{
mark_job_failed_by_token_from_connection, MarkJobFailedByTokenFromConnectionArgs,
};
use mysql_queries::queries::media_files::create::insert_builder::media_file_insert_builder::MediaFileInsertBuilder;
use pager::notification::notification_details_builder::NotificationDetailsBuilder;
use pager::notification::notification_urgency::NotificationUrgency;
use thumbnail_generator::task_client::thumbnail_task::{ThumbnailTaskBuilder, ThumbnailTaskInputMimeType};
use tokens::tokens::media_files::MediaFileToken;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::endpoints::webhooks::fal::process_success::resolve_file_metadata::resolve_file_metadata;
use crate::state::server_state::ServerState;
use crate::util::http_download_url_to_bytes::http_download_url_to_bytes;
const VIDEO_BUCKET_PREFIX: Option<&str> = Some("artcraft_");
// =============== Handler ===============
pub async fn beeble_webhook_handler(
http_request: HttpRequest,
request_body_bytes: Bytes,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<SimpleGenericJsonSuccess>, AdvancedCommonWebError> {
// Step 1: Parse bytes into a UTF-8 string and log it.
let raw_body = String::from_utf8(request_body_bytes.to_vec())
.map_err(|err| {
error!("Beeble webhook: could not decode request body to UTF-8: {:?}", err);
enqueue_parse_error_alert(&server_state, &http_request, "UTF-8 decode failed", &err, None);
AdvancedCommonWebError::from_error(err)
})?;
info!("Received Beeble webhook body: {}", raw_body);
// Step 2: Parse into BeebleWebhookPayload.
let payload = parse_beeble_webhook(&raw_body)
.map_err(|err| {
error!("Beeble webhook: could not parse webhook payload: {:?}", err);
enqueue_parse_error_alert(&server_state, &http_request, "JSON parse failed", &err, Some(&raw_body));
AdvancedCommonWebError::from_error(err)
})?;
let job_id = payload.id.as_str();
info!("Beeble webhook job_id: {} (status: {:?})", job_id, payload.status);
// Step 3: Acquire a MySQL connection.
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
// Step 4: Look up the job by external third-party ID.
let job = match get_inference_job_by_beeble_id_from_connection(job_id, &mut mysql_connection).await {
Ok(Some(record)) => record,
Ok(None) => {
warn!("Could not find job record by Beeble job_id: {}", job_id);
return Err(AdvancedCommonWebError::NotFound);
}
Err(err) => {
error!("Error querying job record for Beeble job_id {}: {:?}", job_id, err);
return Err(AdvancedCommonWebError::from_anyhow_error(err));
}
};
info!("Beeble webhook job record for job_id {}: {:?}", job_id, job);
// Insert debug log for the webhook payload.
if let Some(debug_log_event_token) = &job.maybe_debug_log_event_token {
if let Err(err) = insert_debug_log(InsertDebugLogArgs {
apriori_debug_log_event_token: Some(debug_log_event_token),
maybe_creator_user_token: job.maybe_creator_user_token.as_ref(),
debug_log_type: DebugLogType::BeebleWebhook,
message: &raw_body,
mysql_executor: &mut *mysql_connection,
phantom: Default::default(),
}).await {
warn!("Failed to insert Beeble webhook debug log: {:?}", err);
}
}
// Step 5: Branch on status.
let result = match payload.status {
BeebleWebhookStatus::Completed => {
handle_completed(&server_state, &mut mysql_connection, job_id, &payload, &job).await
}
BeebleWebhookStatus::Failed => {
handle_failed(&mut mysql_connection, job_id, &payload, &job).await
}
BeebleWebhookStatus::Unknown => {
warn!("Beeble webhook received unknown status for job_id {}", job_id);
Err(AdvancedCommonWebError::server_error_with_message(
&format!("Beeble webhook received unknown status for job_id {}", job_id),
))
}
};
if let Err(ref err) = result {
if err.is_server_error() {
error!("Beeble webhook error for job_id {}: {:?}", job_id, err);
let description = format!(
"Beeble webhook failed for job_id: {}\n\nError: {:?}\n\nWebhook JSON Payload: {}",
job_id, err, raw_body,
);
let mut builder = if let Some(cause_arc) = err.clone_cause_arc() {
NotificationDetailsBuilder::from_error(cause_arc)
.set_title(format!("Beeble webhook processing failed (job_id: {})", job_id))
} else {
NotificationDetailsBuilder::from_title(
format!("Beeble webhook processing failed (job_id: {})", job_id)
)
};
builder = builder
.set_description(Some(description))
.set_third_party_id(Some(job_id.to_string()))
.set_urgency(Some(NotificationUrgency::High))
.set_http_method(Some(http_request.method().to_string()))
.set_http_path(Some(http_request.path().to_string()));
if let Err(pager_err) = server_state.pager.enqueue_page(builder.build()) {
error!("Failed to enqueue Beeble webhook pager alert: {:?}", pager_err);
}
}
}
result
}
// =============== Private helpers ===============
async fn handle_completed(
server_state: &ServerState,
mysql_connection: &mut PoolConnection<MySql>,
job_id: &str,
payload: &BeebleWebhookPayload,
job: &BeebleJobDetails,
) -> Result<Json<SimpleGenericJsonSuccess>, AdvancedCommonWebError> {
let output = payload.output.as_ref().ok_or_else(|| {
warn!("Beeble webhook completed but no output for job_id {}", job_id);
AdvancedCommonWebError::server_error_with_message("Beeble webhook completed but output is missing")
})?;
let render_url = output.render.as_deref().ok_or_else(|| {
warn!("Beeble webhook completed but no render URL for job_id {}", job_id);
AdvancedCommonWebError::server_error_with_message("Beeble webhook completed but render URL is missing")
})?;
info!("Downloading Beeble render video from {} for job_id {}", render_url, job_id);
// Download and process the primary render video.
let render_media_token = download_and_upload_video(render_url, job, server_state).await?;
info!("Render video uploaded with token {} for job_id {}", render_media_token, job_id);
// Download and process the alpha video if present.
if let Some(ref alpha_url) = output.alpha {
info!("Downloading Beeble alpha video from {} for job_id {}", alpha_url, job_id);
match download_and_upload_video(alpha_url, job, server_state).await {
Ok(alpha_token) => {
info!("Alpha video uploaded with token {} for job_id {}", alpha_token, job_id);
}
Err(err) => {
// Fail open for alpha — the render is the primary artifact.
warn!("Failed to process alpha video for job_id {}: {:?}", job_id, err);
}
}
}
// Mark the job as successfully done with the render video's media token.
mark_fal_generic_inference_job_successfully_done(MarkJobArgs {
job_token: &job.job_token,
media_file_token: &render_media_token,
maybe_batch_token: None,
mysql_executor: &mut **mysql_connection,
phantom: Default::default(),
}).await.map_err(|err| {
warn!("Error marking job as successfully done for job_id {}: {:?}", job_id, err);
AdvancedCommonWebError::from_anyhow_error(err)
})?;
info!("Job {} marked as successfully done for Beeble job_id {}", job.job_token, job_id);
Ok(SimpleGenericJsonSuccess::wrapped(true))
}
async fn handle_failed(
mysql_connection: &mut PoolConnection<MySql>,
job_id: &str,
payload: &BeebleWebhookPayload,
job: &BeebleJobDetails,
) -> Result<Json<SimpleGenericJsonSuccess>, AdvancedCommonWebError> {
let public_failure_reason = payload.error
.as_deref()
.unwrap_or("Unknown Beeble error")
.to_string();
let internal_failure_reason = format!(
"Beeble job_id={}, error={:?}",
job_id,
payload.error,
);
info!(
"Marking job {} as failed for Beeble job_id {}. Reason: {}",
job.job_token.as_str(),
job_id,
public_failure_reason,
);
if let Err(err) = mark_job_failed_by_token_from_connection(MarkJobFailedByTokenFromConnectionArgs {
mysql_connection,
job_token: &job.job_token,
maybe_public_failure_reason: Some(&public_failure_reason),
internal_debugging_failure_reason: &internal_failure_reason,
maybe_frontend_failure_category: Some(FrontendFailureCategory::GenerationFailed),
}).await {
error!(
"Error marking job {} as failed for Beeble job_id {}: {:?}",
job.job_token.as_str(),
job_id,
err,
);
return Err(AdvancedCommonWebError::from_anyhow_error(err));
}
info!(
"Job {} marked as failed for Beeble job_id {}.",
job.job_token.as_str(),
job_id,
);
Ok(SimpleGenericJsonSuccess::wrapped(true))
}
async fn download_and_upload_video(
video_url: &str,
job: &BeebleJobDetails,
server_state: &ServerState,
) -> Result<MediaFileToken, AdvancedCommonWebError> {
// Download with a retry if the first attempt returns suspiciously few bytes.
let mut file_bytes = http_download_url_to_bytes(video_url)
.await
.map_err(|err| {
warn!("Failed to download video from {}: {:?}", video_url, err);
AdvancedCommonWebError::from_error(err)
})?;
if file_bytes.len() <= 10 {
warn!(
"Downloaded only {} bytes from {} — retrying once",
file_bytes.len(),
video_url,
);
file_bytes = http_download_url_to_bytes(video_url)
.await
.map_err(|err| {
warn!("Failed to download video on retry from {}: {:?}", video_url, err);
AdvancedCommonWebError::from_error(err)
})?;
}
// Resolve mime type from magic bytes, with no fallback content_type from Beeble.
let metadata = resolve_file_metadata(&file_bytes, None)
.ok_or_else(|| {
warn!(
"Could not determine file type for Beeble video (bytes: {})",
file_bytes.len(),
);
AdvancedCommonWebError::server_error_with_message(
&format!("Could not determine file type for Beeble video (bytes: {})", file_bytes.len()))
})?;
info!("File type: {}, extension: {:?}, source: {:?}",
metadata.mime_type, metadata.file_extension, metadata.source);
let mime_type = metadata.mime_type.as_str();
let media_file_type = MediaFileType::try_from_mime_type(mime_type)
.ok_or_else(|| {
warn!("Unsupported media file type: {}", mime_type);
AdvancedCommonWebError::server_error_with_message(
&format!("Unsupported media file type: {}", mime_type))
})?;
let extension_with_period = metadata.file_extension.extension_with_period();
let file_size_bytes = file_bytes.len();
let file_hash = sha256_hash_bytes(&file_bytes)
.map_err(|e| {
warn!("Failed to hash video bytes: {:?}", e);
AdvancedCommonWebError::from_anyhow_error(e)
})?;
let mut maybe_duration_millis = None;
let mut maybe_frame_width = None;
let mut maybe_frame_height = None;
if let Ok(mut file) = NamedTempFile::new() {
let _r = file.write_all(&file_bytes);
if let Ok(video_info) = ffprobe_get_info(&file.path()) {
maybe_duration_millis = video_info.duration
.as_ref()
.map(|duration| duration.millis as u64);
maybe_frame_width = video_info.dimensions
.as_ref()
.map(|dims| dims.width as u32);
maybe_frame_height = video_info.dimensions
.as_ref()
.map(|dims| dims.height as u32);
}
}
let public_upload_path = MediaFileBucketPath::generate_new(VIDEO_BUCKET_PREFIX, Some(&extension_with_period));
info!("Uploading media to bucket path: {}", public_upload_path.get_full_object_path_str());
server_state.public_bucket_client.upload_file_with_content_type_process(
public_upload_path.get_full_object_path_str(),
file_bytes.as_ref(),
mime_type)
.await
.map_err(|e| {
warn!("Failed to upload video to bucket: {:?}", e);
AdvancedCommonWebError::from_anyhow_error(e)
})?;
let media_token = MediaFileInsertBuilder::new()
.maybe_creator_user(job.maybe_creator_user_token.as_ref())
.maybe_creator_anonymous_visitor(job.maybe_creator_anonymous_visitor_token.as_ref())
.creator_ip_address(&job.creator_ip_address)
.public_bucket_directory_hash(&public_upload_path)
.media_file_class(MediaFileClass::Video)
.media_file_type(media_file_type)
.media_file_origin_category(MediaFileOriginCategory::Inference)
.media_file_origin_product_category(MediaFileOriginProductCategory::VideoEdit)
.mime_type(mime_type)
.file_size_bytes(file_size_bytes as u64)
.maybe_frame_width(maybe_frame_width)
.maybe_frame_height(maybe_frame_height)
.maybe_duration_millis(maybe_duration_millis)
.checksum_sha2(&file_hash)
.maybe_prompt_token(job.maybe_prompt_token.as_ref())
.insert_pool(&server_state.mysql_pool)
.await
.map_err(|e| {
warn!("Failed to insert video media file record: {:?}", e);
AdvancedCommonWebError::from_error(e)
})?;
info!("Video media file uploaded with token: {}", media_token);
// let thumbnail_task_result =
// ThumbnailTaskBuilder::new_for_source_mimetype(ThumbnailTaskInputMimeType::MP4)
// .with_bucket(server_state.public_bucket_client.bucket_name().as_str())
// .with_path(&*path_to_string(public_upload_path.to_full_object_pathbuf()))
// .with_output_suffix("thumb")
// .with_event_id(&media_token.to_string())
// .send_all()
// .await;
// if let Err(err) = thumbnail_task_result {
// // Fail open
// error!("Failed to create some/all thumbnail tasks: {:?}", err);
// }
Ok(media_token)
}
/// Send a pager alert for early parse failures (before we have a job_id).
fn enqueue_parse_error_alert<E: std::fmt::Debug>(
server_state: &ServerState,
http_request: &HttpRequest,
context: &str,
err: &E,
maybe_raw_body: Option<&str>,
) {
let description = match maybe_raw_body {
Some(body) => format!("Error: {:?}\n\nBeeble Webhook JSON Payload: {}", err, body),
None => format!("Beeble Error: {:?}", err),
};
let notification = NotificationDetailsBuilder::from_title(
format!("Beeble webhook parse failure: {}", context))
.set_description(Some(description))
.set_urgency(Some(NotificationUrgency::High))
.set_http_method(Some(http_request.method().to_string()))
.set_http_path(Some(http_request.path().to_string()))
.build();
if let Err(pager_err) = server_state.pager.enqueue_page(notification) {
error!("Failed to enqueue Beeble webhook parse error alert: {:?}", pager_err);
}
}
@@ -0,0 +1 @@
pub mod beeble_webhook_handler;
@@ -1,8 +1,8 @@
use std::sync::Arc;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::endpoints::webhooks::process_failure::handle_failed_fal_webhook::handle_failed_fal_webhook;
use crate::http_server::endpoints::webhooks::process_success::handle_successful_fal_webhook::handle_successful_fal_webhook;
use crate::http_server::endpoints::webhooks::fal::process_failure::handle_failed_fal_webhook::handle_failed_fal_webhook;
use crate::http_server::endpoints::webhooks::fal::process_success::handle_successful_fal_webhook::handle_successful_fal_webhook;
use crate::state::server_state::ServerState;
use actix_web::web::Bytes;
use actix_web::web::Json;
@@ -0,0 +1,3 @@
pub mod fal_webhook_handler;
pub(super) mod process_failure;
pub(super) mod process_success;
@@ -4,4 +4,4 @@ mod process_images_payload;
mod process_model_glb_payload;
mod process_model_mesh_payload;
mod process_video_payload;
mod resolve_file_metadata;
pub(in crate::http_server::endpoints::webhooks) mod resolve_file_metadata;
@@ -1,5 +1,5 @@
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::endpoints::webhooks::process_success::resolve_file_metadata::resolve_file_metadata;
use crate::http_server::endpoints::webhooks::fal::process_success::resolve_file_metadata::resolve_file_metadata;
use crate::state::server_state::ServerState;
use crate::util::http_download_url_to_bytes::http_download_url_to_bytes;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
@@ -1,5 +1,5 @@
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::endpoints::webhooks::process_success::resolve_file_metadata::resolve_file_metadata;
use crate::http_server::endpoints::webhooks::fal::process_success::resolve_file_metadata::resolve_file_metadata;
use crate::state::server_state::ServerState;
use crate::util::http_download_url_to_bytes::http_download_url_to_bytes;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
@@ -1,5 +1,5 @@
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::endpoints::webhooks::process_success::resolve_file_metadata::resolve_file_metadata;
use crate::http_server::endpoints::webhooks::fal::process_success::resolve_file_metadata::resolve_file_metadata;
use crate::state::server_state::ServerState;
use crate::util::http_download_url_to_bytes::http_download_url_to_bytes;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
@@ -1,5 +1,5 @@
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::endpoints::webhooks::process_success::resolve_file_metadata::resolve_file_metadata;
use crate::http_server::endpoints::webhooks::fal::process_success::resolve_file_metadata::resolve_file_metadata;
use crate::state::server_state::ServerState;
use crate::util::http_download_url_to_bytes::http_download_url_to_bytes;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
@@ -1,5 +1,5 @@
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::endpoints::webhooks::process_success::resolve_file_metadata::resolve_file_metadata;
use crate::http_server::endpoints::webhooks::fal::process_success::resolve_file_metadata::resolve_file_metadata;
use crate::state::server_state::ServerState;
use crate::util::http_download_url_to_bytes::http_download_url_to_bytes;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
@@ -1,3 +1,2 @@
pub mod fal_webhook_handler;
pub (super) mod process_failure;
pub (super) mod process_success;
pub mod beeble;
pub mod fal;
@@ -1,3 +1,4 @@
use crate::http_server::endpoints::generate::video::edit::beeble_switchx_edit_video_gen_handler::beeble_switchx_edit_video_gen_handler;
use crate::http_server::endpoints::generate::cost_estimate::image::estimate_image_cost_handler::estimate_image_cost_handler;
use crate::http_server::endpoints::generate::cost_estimate::splat::estimate_splat_cost_handler::estimate_splat_cost_handler;
use crate::http_server::endpoints::generate::cost_estimate::video::estimate_video_cost_handler::estimate_video_cost_handler;
@@ -218,6 +219,12 @@ where
.route(web::head().to(|| HttpResponse::Ok()))
)
)
.service(web::scope("/edit")
.service(web::resource("/beeble_switchx")
.route(web::post().to(beeble_switchx_edit_video_gen_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
)
.service(web::resource("/kling_1.6_pro_image_to_video")
.route(web::post().to(generate_kling_1_6_pro_video_handler))
.route(web::head().to(|| HttpResponse::Ok()))
@@ -1,4 +1,5 @@
use crate::http_server::endpoints::webhooks::fal_webhook_handler::fal_webhook_handler;
use crate::http_server::endpoints::webhooks::beeble::beeble_webhook_handler::beeble_webhook_handler;
use crate::http_server::endpoints::webhooks::fal::fal_webhook_handler::fal_webhook_handler;
use actix_http::body::MessageBody;
use actix_service::ServiceFactory;
use actix_web::dev::{ServiceRequest, ServiceResponse};
@@ -20,5 +21,9 @@ where
.route(web::post().to(fal_webhook_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
.service(web::resource("/beeble")
.route(web::post().to(beeble_webhook_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
)
}
@@ -80,7 +80,7 @@ use crate::http_server::web_utils::scoped_temp_dir_creator::ScopedTempDirCreator
use crate::startup::build_pager::build_pager;
use crate::state::certs::google_sign_in_cert::GoogleSignInCert;
use crate::state::memory_cache::model_token_to_info_cache::ModelTokenToInfoCache;
use crate::state::server_state::{DurableInMemoryCaches, EnvConfig, EphemeralInMemoryCaches, FalData, InMemoryCaches, OpenAiData, ResendData, Seedance2ProData, ServerInfo, ServerState, StaticFeatureFlags, StripeSettings, TrollBans, WorldLabsData};
use crate::state::server_state::{BeebleData, DurableInMemoryCaches, EnvConfig, EphemeralInMemoryCaches, FalData, InMemoryCaches, OpenAiData, ResendData, Seedance2ProData, ServerInfo, ServerState, StaticFeatureFlags, StripeSettings, TrollBans, WorldLabsData};
use crate::threads::db_health_checker_thread::db_health_check_status::HealthCheckStatus;
use crate::threads::db_health_checker_thread::db_health_checker_thread::db_health_checker_thread;
use crate::threads::poll_ip_banlist_thread::poll_ip_bans;
@@ -475,6 +475,12 @@ async fn main() -> AnyhowResult<()> {
sort_key_crypto,
opaque_cursors: opaque_cursor_encoder,
static_api_token_set,
beeble: BeebleData {
api_key: beeble_client::creds::beeble_api_key::BeebleApiKey::new(
easyenv::get_env_string_required("BEEBLE_API_KEY")?,
),
webhook_url: easyenv::get_env_string_required("BEEBLE_WEBHOOK_URL")?,
},
fal: FalData {
api_key: fal_api_key,
webhook_url: fal_webhook_url,
@@ -25,6 +25,7 @@ use billing_component::stripe::stripe_config::StripeConfig;
use chrono::{DateTime, Utc};
use cloud_storage::bucket_client::BucketClient;
use elasticsearch::Elasticsearch;
use beeble_client::creds::beeble_api_key::BeebleApiKey;
use fal_client::creds::fal_api_key::FalApiKey;
use memory_caching::arc_ttl_sieve::ArcTtlSieve;
use memory_caching::single_item_ttl_cache::SingleItemTtlCache;
@@ -95,6 +96,8 @@ pub struct ServerState {
pub fal: FalData,
pub beeble: BeebleData,
pub seedance2pro: Seedance2ProData,
pub openai: OpenAiData,
@@ -321,6 +324,13 @@ pub struct FalData {
pub webhook_url: String,
}
/// Beeble SwitchX integration
#[derive(Clone)]
pub struct BeebleData {
pub api_key: BeebleApiKey,
pub webhook_url: String,
}
/// Seedance 2 Pro integration
#[derive(Clone)]
pub struct Seedance2ProData {
+2 -1
View File
@@ -1,3 +1,4 @@
pub mod character_tokens;
pub mod image_media_tokens;
pub mod image_urls;
pub mod image_urls;
pub mod video_urls;
@@ -0,0 +1,5 @@
// media token: m_fz9r14yhfe05ffg9wqda5291p7mp75
pub const ANGRY_SHIBA_VIDEO_URL : &str = "https://cdn-2.fakeyou.com/media/q/0/1/e/9/q01e9xsye0scamh5mrskr2emsv401x14/artcraft_q01e9xsye0scamh5mrskr2emsv401x14.mp4";
@@ -1,3 +1,7 @@
/background-change /index.html 200
/background-change/ /index.html 200
/background-change/* /index.html 200
/checkout_cancel /index.html 200
/checkout_cancel/ /index.html 200
/checkout_cancel/* /index.html 200
@@ -13,6 +13,7 @@ import Navbar from "../components/navbar";
import { ToastContainer } from "../components/toast/toast";
import CreateImage from "../pages/create-image";
import CreateVideo from "../pages/create-video";
import CreateVFX from "../pages/create-vfx";
//import Landing2 from "../pages/landing2";
import Landing3 from "../pages/landing3";
import LandingSD2 from "../pages/landing-sd2";
@@ -54,6 +55,7 @@ export function App() {
<Route path="/landing3" element={<Landing3 />} />
<Route path="/create-image" element={<CreateImage />} />
<Route path="/create-video" element={<CreateVideo />} />
<Route path="/background-change" element={<CreateVFX />} />
<Route path="/seedance-2" element={<LandingSD2 />} />
<Route path="/download" element={<Download />} />
<Route path="/media" element={<Media />} />
@@ -42,6 +42,7 @@ const NAV_ITEMS: NavEntry[] = [
{ name: "Home", href: "/" },
{ name: "Image", href: "/create-image" },
{ name: "Video", href: "/create-video" },
{ name: "BG Change", href: "/background-change" },
{
name: "Resources",
children: [
@@ -72,6 +73,71 @@ function isEntryActive(pathname: string, entry: NavEntry): boolean {
return isPathActive(pathname, entry.href);
}
function CreditsChip({
credits,
align,
onBuyCredits,
onUpgrade,
}: {
credits: number;
align: "center" | "end";
onBuyCredits: () => void;
onUpgrade: () => void;
}) {
return (
<PopoverMenu
position="bottom"
align={align}
triggerIcon={
<FontAwesomeIcon icon={faCoins} className="text-primary text-[11px]" />
}
triggerLabel={
<span className="whitespace-nowrap text-sm font-medium">
{credits.toLocaleString()}
</span>
}
buttonClassName="h-8 px-3 ps-2.5 bg-white/[0.04] hover:bg-white/[0.08] border border-white/[0.08] shadow-none text-white/80 rounded-lg gap-1.5"
panelClassName="mt-2 bg-[#1a1a1a] border border-white/[0.08] text-white rounded-xl"
>
{(close) => (
<div className="w-72 max-w-[calc(100vw-24px)] p-3 text-white">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium text-white/70">
Your credit balance
</span>
<button
className="text-sm font-medium text-primary hover:text-primary-300 transition-colors"
onClick={() => {
close();
onBuyCredits();
}}
>
Buy credits
</button>
</div>
<div className="flex items-center gap-2 text-3xl font-semibold text-white tracking-tight">
<FontAwesomeIcon icon={faCoins} className="text-xl text-primary" />
{credits.toLocaleString()}
</div>
<div className="mt-3 flex gap-2">
<Button
variant="primary"
className="h-9 grow"
onClick={() => {
close();
onUpgrade();
}}
icon={faGem}
>
Upgrade
</Button>
</div>
</div>
)}
</PopoverMenu>
);
}
async function fetchCredits(): Promise<number | null> {
try {
const api = new CreditsApi();
@@ -281,62 +347,12 @@ export default function Navbar() {
</Link>
{credits !== null && (
<PopoverMenu
position="bottom"
<CreditsChip
credits={credits}
align="center"
triggerIcon={
<FontAwesomeIcon
icon={faCoins}
className="text-primary text-[11px]"
/>
}
triggerLabel={
<span className="whitespace-nowrap text-sm font-medium">
{credits.toLocaleString()}
</span>
}
buttonClassName="h-8 px-3 ps-2.5 bg-white/[0.04] hover:bg-white/[0.08] border border-white/[0.08] shadow-none text-white/80 rounded-lg gap-1.5"
panelClassName="mt-2 bg-[#1a1a1a] border border-white/[0.08] text-white rounded-xl"
>
{(close) => (
<div className="w-72 p-3 text-white">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium text-white/70">
Your credit balance
</span>
<button
className="text-sm font-medium text-primary hover:text-primary-300 transition-colors"
onClick={() => {
close();
setCreditsModalOpen(true);
}}
>
Buy credits
</button>
</div>
<div className="flex items-center gap-2 text-3xl font-medium text-white tracking-tight">
<FontAwesomeIcon
icon={faCoins}
className="text-xl text-primary"
/>
{credits.toLocaleString()}
</div>
<div className="mt-3 flex gap-2">
<Button
variant="primary"
className="h-9 grow"
onClick={() => {
close();
navigate("/pricing");
}}
icon={faGem}
>
Upgrade
</Button>
</div>
</div>
)}
</PopoverMenu>
onBuyCredits={() => setCreditsModalOpen(true)}
onUpgrade={() => navigate("/pricing")}
/>
)}
{hasPaidPlan === false && (
@@ -488,8 +504,16 @@ export default function Navbar() {
</div>
)}
{/* Mobile: hamburger + task queue */}
<div className="flex items-center gap-2 lg:hidden">
{/* Mobile: credits + task queue + hamburger */}
<div className="flex items-center gap-1.5 lg:hidden">
{user && credits !== null && (
<CreditsChip
credits={credits}
align="end"
onBuyCredits={() => setCreditsModalOpen(true)}
onUpgrade={() => navigate("/pricing")}
/>
)}
{user && <TaskQueue />}
<DisclosureButton className="flex h-8 w-8 items-center justify-center rounded-lg text-white/70 hover:text-white hover:bg-white/[0.06] transition-colors">
<span className="sr-only">Open main menu</span>
@@ -640,15 +664,6 @@ export default function Navbar() {
/>
Discord
</DisclosureButton>
{credits !== null && (
<span className="flex items-center gap-1.5 ml-auto text-[12px] font-medium text-white/80">
<FontAwesomeIcon
icon={faCoins}
className="text-primary text-[10px]"
/>
{credits.toLocaleString()}
</span>
)}
</div>
<div className="flex items-center gap-2">
<img
@@ -23,7 +23,7 @@ import { Button } from "@storyteller/ui-button";
import {
getProviderDisplayName,
getModelDisplayName,
ALL_MODELS_LIST,
findModelByKey,
} from "@storyteller/model-list";
import { CloseButton } from "@storyteller/ui-close-button";
import dayjs from "dayjs";
@@ -157,10 +157,10 @@ const PromptLine = ({
style={
marqueePlaying
? {
animation: "marquee 6.5s linear infinite",
animationDelay: "0.5s",
animationFillMode: "both",
}
animation: "marquee 6.5s linear infinite",
animationDelay: "0.5s",
animationFillMode: "both",
}
: undefined
}
>
@@ -468,9 +468,7 @@ function getPrompt(job: Job, promptsMap?: Map<string, Prompts>): string {
? (promptsMap?.get(promptToken) ?? getCachedPrompt(promptToken))
: undefined;
return (
cached?.maybe_positive_prompt ||
job.request.maybe_raw_inference_text ||
""
cached?.maybe_positive_prompt || job.request.maybe_raw_inference_text || ""
);
}
@@ -483,8 +481,7 @@ function formatTitleParts(job: Job, promptsMap?: Map<string, Prompts>) {
const taskTypeStr = job.request.inference_category?.toLowerCase() ?? "";
const modelTypeStr =
cachedPrompt?.maybe_model_type ?? job.request.maybe_model_type ?? "";
const providerKey =
cachedPrompt?.maybe_generation_provider ?? modelTypeStr;
const providerKey = cachedPrompt?.maybe_generation_provider ?? modelTypeStr;
let kind: string | undefined;
if (taskTypeStr.includes("video")) {
@@ -532,18 +529,24 @@ function jobsToInProgress(
const parts = formatTitleParts(j, promptsMap);
const isVideo =
j.request.inference_category?.toLowerCase().includes("video") ?? false;
const modelType = j.request.maybe_model_type;
// Look up per-model estimated duration, cache per task
const promptToken = j.request.maybe_prompt_token;
const cachedPrompt = promptToken
? (promptsMap.get(promptToken) ?? getCachedPrompt(promptToken))
: undefined;
const modelType =
cachedPrompt?.maybe_model_type ?? j.request.maybe_model_type;
// Look up per-model estimated duration from the model list, cache per
// task. Cache only once `modelType` is resolved so a transient
// "prompt not loaded yet" render doesn't lock in the wrong fallback.
let duration = taskDurationCache.get(j.job_token);
if (!duration) {
const model = modelType
? ALL_MODELS_LIST.find(
(m) => m.tauriId === modelType || m.id === modelType,
)
: undefined;
if (duration === undefined) {
const model = findModelByKey(modelType);
duration = model?.progressBarTime ?? (isVideo ? 900000 : 30000);
taskDurationCache.set(j.job_token, duration);
if (modelType) {
taskDurationCache.set(j.job_token, duration);
}
}
// Always use time-based fake progress (matching desktop app behavior)
@@ -663,7 +666,8 @@ export const TaskQueue = () => {
const promptTokens = useMemo(() => {
const tokens: string[] = [];
for (const j of jobs) {
if (j.request.maybe_prompt_token) tokens.push(j.request.maybe_prompt_token);
if (j.request.maybe_prompt_token)
tokens.push(j.request.maybe_prompt_token);
}
return tokens;
}, [jobs]);
@@ -710,7 +714,7 @@ export const TaskQueue = () => {
primaryActionText: "",
primaryActionIcon: faTrashAlt,
primaryActionBtnClassName: "",
onConfirm: async () => { },
onConfirm: async () => {},
});
// ── Confirmation handlers ──────────────────────────────────────────
@@ -814,7 +818,9 @@ export const TaskQueue = () => {
const failedJobs = fetchedJobs.filter((j) =>
FAILED_STATUSES.has(j.status.status),
);
const newCompletedIdSet = new Set(completedJobs.map((j) => j.job_token));
const newCompletedIdSet = new Set(
completedJobs.map((j) => j.job_token),
);
const newFailedIdSet = new Set(failedJobs.map((j) => j.job_token));
if (!initialLoadDoneRef.current) {
@@ -1,4 +1,4 @@
import { MediaUploadApi } from "@storyteller/api";
import { EIntermediateFile, MediaUploadApi } from "@storyteller/api";
import { UploaderState, UploaderStates } from "@storyteller/common";
export type UploadImageFn = (args: {
@@ -21,6 +21,9 @@ export const uploadImage: UploadImageFn = async ({
blob: assetFile,
fileName: assetFile.name || `reference-${Date.now()}`,
maybe_title: `ref_image_${title}`,
// User-uploaded reference images should appear in their library, not be
// hidden as intermediate system files.
is_intermediate_system_file: EIntermediateFile.false,
});
if (!response?.success || !response.data) {
+1 -1
View File
@@ -18,7 +18,7 @@ if (import.meta.env.DEV) {
window.location.origin,
);
// NB: This is for Brandon to test with storyteller-web locally:
StorytellerApiHostStore.getInstance().setDevelopment();
//StorytellerApiHostStore.getInstance().setDevelopment();
} catch (e) {
console.warn("Failed to set dev API host override", e);
}
@@ -0,0 +1,400 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSpinnerThird, faSparkles } from "@fortawesome/pro-solid-svg-icons";
import { Button } from "@storyteller/ui-button";
import {
PromptBoxVFX,
TruchetPattern,
VFXResultCard,
useVFXStore,
} from "@storyteller/ui-vfx";
import Seo from "../../components/seo";
import { useAuthCheck } from "../../components/generation-gallery";
import { toast } from "../../components/toast/toast";
import { uploadImage } from "../../components/prompt-box/upload-image";
import { uploadVideo } from "../../components/prompt-box/upload-media";
import {
enqueueBackgroundChangeGeneration,
listSessionBackgroundChangeJobs,
startBackgroundChangePolling,
} from "./generate-background-change-api";
export default function CreateVFX() {
const { user, authChecked } = useAuthCheck();
const history = useVFXStore((s) => s.history);
const startResult = useVFXStore((s) => s.startResult);
const attachJobToken = useVFXStore((s) => s.attachJobToken);
const completeResult = useVFXStore((s) => s.completeResult);
const failResult = useVFXStore((s) => s.failResult);
const dismissResult = useVFXStore((s) => s.dismissResult);
const seedFromSession = useVFXStore((s) => s.seedFromSession);
const updateMediaForResult = useVFXStore((s) => s.updateMediaForResult);
const source = useVFXStore((s) => s.source);
const reference = useVFXStore((s) => s.reference);
const prompt = useVFXStore((s) => s.prompt);
const promptBoxRef = useRef<HTMLDivElement>(null);
const [promptBoxHeight, setPromptBoxHeight] = useState(96);
const [isSubmitting, setIsSubmitting] = useState(false);
const pollersRef = useRef(new Map<string, () => void>());
useEffect(() => {
const el = promptBoxRef.current;
if (!el || typeof ResizeObserver === "undefined") return;
const update = () => setPromptBoxHeight(el.offsetHeight);
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, []);
useEffect(() => {
const pollers = pollersRef.current;
return () => {
pollers.forEach((cancel) => cancel());
pollers.clear();
};
}, []);
// On mount, reconcile against the server: pull recent SwitchX jobs from the
// session so the page shows in-progress / completed / failed cards across
// refreshes and devices, not just from localStorage. Then resume polling
// for anything that's still pending.
useEffect(() => {
if (!user) return;
let cancelled = false;
const pollers = pollersRef.current;
const startPollFor = (
resultId: string,
jobToken: string,
) => {
if (pollers.has(resultId)) return;
const cancel = startBackgroundChangePolling(
jobToken,
(output) => {
completeResult(resultId, output.cdn_url);
pollers.delete(resultId);
window.dispatchEvent(new Event("task-queue-update"));
},
(reason) => {
failResult(resultId, reason);
pollers.delete(resultId);
window.dispatchEvent(new Event("task-queue-update"));
},
);
pollers.set(resultId, cancel);
};
(async () => {
try {
const sessionJobs = await listSessionBackgroundChangeJobs();
if (cancelled) return;
const existing = useVFXStore.getState().history;
const existingByToken = new Map(
existing
.filter((r) => r.inferenceJobToken)
.map((r) => [r.inferenceJobToken!, r] as const),
);
for (const job of sessionJobs) {
const found = existingByToken.get(job.jobToken);
if (found) {
// Server may know more than we do (e.g. completed while we were
// away). Reconcile terminal states.
if (found.status === "pending" && job.status === "complete" && job.outputUrl) {
completeResult(found.id, job.outputUrl);
} else if (found.status === "pending" && job.status === "failed") {
failResult(found.id, job.failureReason ?? "Generation failed");
}
// Patch in CDN media + prompt from the batch-prompt response so
// dead blob: URLs (from a prior session) are replaced.
updateMediaForResult(found.id, {
source: job.source
? {
id: job.source.mediaToken,
url: job.source.url,
mediaToken: job.source.mediaToken,
}
: undefined,
reference: job.reference
? {
id: job.reference.mediaToken,
url: job.reference.url,
mediaToken: job.reference.mediaToken,
}
: undefined,
prompt: job.prompt,
});
continue;
}
// Missing locally — seed it.
const id =
typeof crypto !== "undefined" && crypto.randomUUID
? crypto.randomUUID()
: Math.random().toString(36).slice(2);
seedFromSession({
id,
status: job.status,
prompt: job.prompt,
resolution: "720p",
source: job.source
? {
id: job.source.mediaToken,
url: job.source.url,
mediaToken: job.source.mediaToken,
}
: undefined,
reference: job.reference
? {
id: job.reference.mediaToken,
url: job.reference.url,
mediaToken: job.reference.mediaToken,
}
: undefined,
inferenceJobToken: job.jobToken,
outputUrl: job.outputUrl,
failureReason: job.failureReason,
createdAt: job.createdAt,
});
}
if (cancelled) return;
// Resume polling for everything still pending (local + freshly seeded).
const pending = useVFXStore
.getState()
.history.filter((r) => r.status === "pending" && r.inferenceJobToken);
for (const item of pending) {
startPollFor(item.id, item.inferenceJobToken!);
}
} catch {
// Network errors during session reconciliation are non-fatal — fall
// back to local-only behavior by polling whatever localStorage had.
if (cancelled) return;
const pending = useVFXStore
.getState()
.history.filter((r) => r.status === "pending" && r.inferenceJobToken);
for (const item of pending) {
startPollFor(item.id, item.inferenceJobToken!);
}
}
})();
return () => {
cancelled = true;
};
}, [user, completeResult, failResult, seedFromSession, updateMediaForResult]);
const handleSubmit = useCallback(async () => {
if (!source || !reference || isSubmitting) return;
setIsSubmitting(true);
const id = startResult();
const enqueueResult = await enqueueBackgroundChangeGeneration({
sourceVideoMediaToken: source.mediaToken,
referenceImageMediaToken: reference.mediaToken,
prompt: prompt.trim() || null,
});
setIsSubmitting(false);
if (!enqueueResult.success) {
const message = enqueueResult.backendUnavailable
? "Background change backend coming soon. Your inputs are saved."
: enqueueResult.error;
failResult(id, message);
if (enqueueResult.backendUnavailable) {
toast.success(message);
} else {
toast.error(message);
}
return;
}
attachJobToken(id, enqueueResult.jobToken);
window.dispatchEvent(new Event("task-queue-update"));
const cancel = startBackgroundChangePolling(
enqueueResult.jobToken,
(output) => {
completeResult(id, output.cdn_url);
pollersRef.current.delete(id);
window.dispatchEvent(new Event("task-queue-update"));
},
(reason) => {
failResult(id, reason);
pollersRef.current.delete(id);
window.dispatchEvent(new Event("task-queue-update"));
},
);
pollersRef.current.set(id, cancel);
}, [
source,
reference,
prompt,
isSubmitting,
startResult,
attachJobToken,
completeResult,
failResult,
]);
if (!authChecked) {
return (
<div className="flex h-screen items-center justify-center bg-[#101014]">
<FontAwesomeIcon
icon={faSpinnerThird}
className="animate-spin text-4xl text-primary/80"
/>
</div>
);
}
if (!user) {
return (
<div className="relative min-h-screen overflow-x-hidden bg-[#101014] text-white">
<Seo
title="Background Change - ArtCraft"
description="Swap the backdrop of a video using a reference image."
/>
<div className="pointer-events-none absolute inset-x-0 top-0 z-0 flex justify-center">
<div className="h-[600px] w-[600px] rounded-full bg-gradient-to-br from-primary/30 via-blue-500/20 to-teal-400/10 opacity-40 blur-[120px]" />
</div>
<div className="relative z-10 flex min-h-screen flex-col items-center justify-center px-4">
<FontAwesomeIcon
icon={faSparkles}
className="mb-6 text-5xl text-white/20"
/>
<h1 className="mb-3 text-4xl font-bold">Background Change</h1>
<p className="mb-8 max-w-md text-center text-lg text-white/60">
Sign in to swap the backdrop of a video using a reference image.
</p>
<div className="flex gap-3">
<Link to="/login">
<Button
variant="primary"
className="bg-white px-6 py-2.5 font-semibold text-black shadow-md hover:bg-white/90"
>
Login
</Button>
</Link>
<Link to="/signup">
<Button
variant="primary"
className="px-6 py-2.5 font-semibold shadow-md"
>
Sign up
</Button>
</Link>
</div>
</div>
</div>
);
}
return (
<div className="relative min-h-screen w-full bg-[#101014] text-white">
<Seo
title="Background Change - ArtCraft"
description="Swap the backdrop of a video using a reference image."
/>
{/* Truchet pattern is fixed to the viewport so it stays put as the
page scrolls behind the (also fixed) navbar. */}
<div
aria-hidden
className="pointer-events-none fixed inset-0 z-0"
style={{
maskImage:
"radial-gradient(ellipse 70% 60% at 50% 45%, black 25%, transparent 85%)",
WebkitMaskImage:
"radial-gradient(ellipse 70% 60% at 50% 45%, black 25%, transparent 85%)",
}}
>
<TruchetPattern
intensity={0.7}
className="absolute inset-0 h-full w-full"
/>
</div>
{history.length === 0 ? (
<div
className="relative z-10 flex min-h-screen items-center justify-center px-3 sm:px-6"
style={{ paddingBottom: Math.max(promptBoxHeight + 32, 240) }}
>
<EmptyState
title="No background changes yet"
subtitle="Upload a source video and a reference image, then optionally add a prompt."
/>
</div>
) : (
<div
className="relative z-10"
style={{
paddingTop: 60 + 24,
paddingBottom: Math.max(promptBoxHeight + 32, 240),
}}
>
<div className="flex flex-col items-center gap-10 px-3 sm:px-6 sm:pt-[18px]">
{history.map((r) => (
<VFXResultCard
key={r.id}
data={{
prompt: r.prompt,
resolution: r.resolution,
source: r.source,
mask: r.mask,
reference: r.reference,
outputUrl: r.outputUrl,
status: r.status,
failureReason: r.failureReason,
}}
onDismiss={() => dismissResult(r.id)}
className="w-[min(960px,calc(100vw-32px))]"
/>
))}
</div>
</div>
)}
<div
aria-hidden
className="pointer-events-none fixed bottom-0 left-0 right-0 z-20 h-72 bg-gradient-to-t from-[#101014] via-[#101014]/85 to-transparent"
/>
<div
ref={promptBoxRef}
className="animate-fade-in-up pointer-events-none fixed bottom-2 left-1/2 z-30 -translate-x-1/2 sm:bottom-3"
style={{ animationDelay: "150ms" }}
>
<div className="pointer-events-auto w-[min(620px,calc(100vw-32px))]">
<PromptBoxVFX
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
uploadVideo={uploadVideo}
uploadImage={uploadImage}
onError={(msg) => toast.error(msg)}
hideResolution
/>
</div>
</div>
</div>
);
}
interface EmptyStateProps {
title: string;
subtitle: string;
}
const EmptyState = ({ title, subtitle }: EmptyStateProps) => (
<div className="flex max-w-md flex-col items-center gap-4 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-white/5 ring-1 ring-white/10">
<FontAwesomeIcon icon={faSparkles} className="text-2xl text-white/40" />
</div>
<h3 className="text-2xl font-bold text-white">{title}</h3>
<p className="text-sm text-white/60 max-w-xs">{subtitle}</p>
</div>
);
@@ -0,0 +1,310 @@
import { JobsApi, PromptsApi } from "@storyteller/api";
import {
newIdempotencyToken,
submitVFXJob,
VFX_NOT_AVAILABLE_ERROR,
} from "@storyteller/ui-vfx";
export interface GeneratedBackgroundChange {
cdn_url: string;
}
// ── Request params ───────────────────────────────────────────────────────
export interface GenerateBackgroundChangeParams {
sourceVideoMediaToken: string;
referenceImageMediaToken?: string | null;
prompt?: string | null;
}
export type EnqueueResult =
| { success: true; jobToken: string }
| { success: false; error: string; backendUnavailable?: boolean };
// ── Enqueue generation ───────────────────────────────────────────────────
export async function enqueueBackgroundChangeGeneration(
params: GenerateBackgroundChangeParams,
): Promise<EnqueueResult> {
const trimmedPrompt = params.prompt?.trim();
const response = await submitVFXJob({
source_video_media_token: params.sourceVideoMediaToken,
reference_image_media_token: params.referenceImageMediaToken ?? null,
prompt: trimmedPrompt && trimmedPrompt.length > 0 ? trimmedPrompt : null,
uuid_idempotency_token: newIdempotencyToken(),
});
if (response.success) {
return { success: true, jobToken: response.inference_job_token };
}
return {
success: false,
error: response.error_message ?? "Failed to submit background change job.",
backendUnavailable: response.error_code_str === VFX_NOT_AVAILABLE_ERROR,
};
}
// ── Poll for completion ──────────────────────────────────────────────────
export async function pollBackgroundChangeJobResult(
jobToken: string,
): Promise<{
status: "pending" | "complete" | "failed";
output?: GeneratedBackgroundChange;
error?: string;
}> {
const jobsApi = new JobsApi();
const response = await jobsApi.GetJobByToken({ token: jobToken });
if (!response.success || !response.data) {
return { status: "pending" };
}
const state = response.data;
const statusStr = state.status?.status?.toLowerCase() ?? "";
if (statusStr === "complete_success" || statusStr === "complete") {
const result = state.maybe_result as Record<string, unknown> | undefined;
const cdnUrl =
((result as any)?.media_links?.cdn_url as string | undefined) ??
((result as any)?.cdn_url as string | undefined) ??
undefined;
if (cdnUrl) {
return { status: "complete", output: { cdn_url: cdnUrl } };
}
return {
status: "failed",
error: "Generation finished but returned no output URL.",
};
}
if (
statusStr.includes("fail") ||
statusStr.includes("error") ||
statusStr === "dead"
) {
return {
status: "failed",
error:
state.status?.maybe_failure_message ??
state.status?.maybe_extra_status_description ??
"Generation failed",
};
}
return { status: "pending" };
}
// ── Session sync ─────────────────────────────────────────────────────────
/**
* Heuristic match for any Beeble SwitchX model variant the backend may emit.
* Matches: `switch_x`, `switchx`, `beeble_switchx`, `beeble_switch_x`,
* `Beeble.SwitchX`, etc.
*/
const isSwitchXModel = (modelType?: string | null): boolean => {
if (!modelType) return false;
const normalized = modelType.toLowerCase().replace(/\./g, "_");
return normalized.includes("switchx") || normalized.includes("switch_x");
};
export interface SessionMediaRef {
url: string;
mediaToken: string;
}
export interface SessionBackgroundChangeJob {
jobToken: string;
status: "pending" | "complete" | "failed";
prompt: string;
source?: SessionMediaRef;
reference?: SessionMediaRef;
outputUrl?: string;
failureReason?: string;
createdAt: number;
}
/**
* Fetch the user's recent jobs from `/v1/jobs/session` and filter down to
* Beeble SwitchX (background-change) jobs. Used on mount so the page reflects
* server-side state across refreshes and devices, not just localStorage.
*
* The model type may live on `job.request.maybe_model_type` OR on the prompt
* (looked up by `maybe_prompt_token`) — that's the same fallback chain the
* task queue / video page use, so we match here too.
*/
export async function listSessionBackgroundChangeJobs(): Promise<
SessionBackgroundChangeJob[]
> {
const jobsApi = new JobsApi();
const response = await jobsApi.ListRecentJobs();
if (!response.success || !response.data) return [];
// Resolve any missing model_types via the prompts API.
const promptTokens = Array.from(
new Set(
response.data
.map((j) => j.request.maybe_prompt_token)
.filter((t): t is string => !!t),
),
);
const promptsByToken = new Map<
string,
{
maybe_model_type?: string | null;
maybe_positive_prompt?: string | null;
maybe_context_images?: Array<{
media_token: string;
media_links: {
cdn_url: string;
maybe_video_previews?: unknown;
};
semantic: string;
}> | null;
}
>();
if (promptTokens.length > 0) {
try {
const promptsApi = new PromptsApi();
const promptsResponse = await promptsApi.BatchGetPrompts({
tokens: promptTokens,
});
if (promptsResponse.success && promptsResponse.data) {
for (const p of promptsResponse.data) {
promptsByToken.set(p.token, p);
}
}
} catch {
// Non-fatal: fall back to job.request.maybe_model_type only.
}
}
const out: SessionBackgroundChangeJob[] = [];
for (const job of response.data) {
const promptToken = job.request.maybe_prompt_token;
const cachedPrompt = promptToken
? promptsByToken.get(promptToken)
: undefined;
const modelType =
cachedPrompt?.maybe_model_type ?? job.request.maybe_model_type;
if (!isSwitchXModel(modelType)) continue;
const statusStr = job.status?.status?.toLowerCase() ?? "";
let status: SessionBackgroundChangeJob["status"];
let outputUrl: string | undefined;
let failureReason: string | undefined;
if (statusStr === "complete_success" || statusStr === "complete") {
status = "complete";
const result = job.maybe_result as unknown as
| Record<string, unknown>
| undefined;
outputUrl =
((result as any)?.media_links?.cdn_url as string | undefined) ??
((result as any)?.cdn_url as string | undefined);
} else if (
statusStr.includes("fail") ||
statusStr.includes("error") ||
statusStr === "dead" ||
statusStr.includes("cancel")
) {
status = "failed";
failureReason =
job.status?.maybe_failure_message ??
job.status?.maybe_extra_status_description ??
"Generation failed";
} else {
status = "pending";
}
// Extract source video + reference image from the prompt's context images.
// We don't depend on exact semantic strings — videos are the items whose
// media_links carry video previews, images are everything else.
let source: SessionMediaRef | undefined;
let reference: SessionMediaRef | undefined;
const ctxImages = cachedPrompt?.maybe_context_images ?? [];
for (const ci of ctxImages) {
const isVideo = !!ci.media_links?.maybe_video_previews;
if (isVideo && !source) {
source = { url: ci.media_links.cdn_url, mediaToken: ci.media_token };
} else if (!isVideo && !reference) {
reference = {
url: ci.media_links.cdn_url,
mediaToken: ci.media_token,
};
}
if (source && reference) break;
}
const resolvedPrompt =
cachedPrompt?.maybe_positive_prompt ??
job.request.maybe_raw_inference_text ??
"";
out.push({
jobToken: job.job_token,
status,
prompt: resolvedPrompt,
source,
reference,
outputUrl,
failureReason,
createdAt: new Date(job.created_at).getTime(),
});
}
return out;
}
// ── Polling controller ───────────────────────────────────────────────────
export function startBackgroundChangePolling(
jobToken: string,
onComplete: (output: GeneratedBackgroundChange) => void,
onError: (reason: string) => void,
intervalMs = 4000,
maxAttempts = 75, // ~5 min at 4s — Beeble SwitchX expected runtime
): () => void {
let attempts = 0;
let stopped = false;
const poll = async () => {
if (stopped) return;
attempts++;
try {
const result = await pollBackgroundChangeJobResult(jobToken);
if (stopped) return;
if (result.status === "complete" && result.output) {
onComplete(result.output);
return;
}
if (result.status === "failed") {
onError(result.error ?? "Generation failed");
return;
}
if (attempts >= maxAttempts) {
onError("Generation timed out");
return;
}
setTimeout(poll, intervalMs);
} catch {
if (!stopped && attempts < maxAttempts) {
setTimeout(poll, intervalMs * 2);
} else if (!stopped) {
onError("Network error during polling");
}
}
};
// Start first poll after a short delay (video jobs take time to start)
setTimeout(poll, 3000);
return () => {
stopped = true;
};
}
@@ -0,0 +1 @@
export { default } from "./create-vfx";
@@ -93,6 +93,9 @@
},
{
"path": "../../libs/api/tsconfig.lib.json"
},
{
"path": "../../libs/components/vfx/tsconfig.lib.json"
}
]
}
@@ -5,6 +5,9 @@
{
"path": "../../libs/components/slider-v2"
},
{
"path": "../../libs/components/vfx"
},
{
"path": "../../libs/components/gallery-modal"
},
@@ -576,6 +576,8 @@ export const TopBar = ({ pageName }: Props) => {
return "Image to 3D World";
case "APPS":
return "ArtCraft Apps";
case "BACKGROUND_CHANGE":
return "Background Change";
default:
return "Artcraft";
}
@@ -10,6 +10,7 @@ import {
faWandMagicSparkles,
faPenNib,
faCrosshairs,
faSparkles,
} from "@fortawesome/pro-solid-svg-icons";
import { useMemo } from "react";
import {
@@ -31,7 +32,8 @@ export type AppId =
| "IMAGE_TO_3D_WORLD"
| "REMOVE_BACKGROUND"
| "ANGLES"
| "STORYBOARD";
| "STORYBOARD"
| "BACKGROUND_CHANGE";
export interface AppDescriptor {
id: AppId;
@@ -185,6 +187,16 @@ export const ALL_APPS: FullAppItem[] = [
color: "bg-fuchsia-600/40",
badge: "NEW",
},
{
id: "background-change",
label: "Background Change",
description: "Swap the backdrop of a video using a reference image",
icon: faSparkles,
category: "edit",
action: "BACKGROUND_CHANGE",
color: "bg-orange-500/40",
badge: "NEW",
},
{
id: "2d-canvas",
label: "Image Editor",
@@ -214,9 +226,12 @@ export const useVisibleApps = (): FullAppItem[] => {
const storyboardEnabled = useStoryboardPageEnabled();
return useMemo(
() =>
ALL_APPS.filter((app) =>
app.action === "STORYBOARD" ? storyboardEnabled : true,
),
ALL_APPS.filter((app) => {
// Background Change is hidden in the desktop app for now.
if (app.action === "BACKGROUND_CHANGE") return false;
if (app.action === "STORYBOARD") return storyboardEnabled;
return true;
}),
[storyboardEnabled],
);
};
@@ -266,6 +281,7 @@ export const goToApp = (action?: string) => {
"REMOVE_BACKGROUND",
"ANGLES",
"STORYBOARD",
"BACKGROUND_CHANGE",
].includes(action)
) {
if (action === "STORYBOARD") {
@@ -71,6 +71,7 @@ import { ImageTo3DWorld } from "./PageImageTo3DWorld";
import { RemoveBackground } from "./PageRemoveBackground";
import { Angles } from "./PageAngles";
import { Storyboard } from "./PageStoryboard";
import { PageBackgroundChange } from "./PageBackgroundChange";
import { PageScene } from "./PageScene";
import {
topNavMediaId,
@@ -251,6 +252,8 @@ const TabBody = ({ sceneToken }: { sceneToken?: string }) => {
return <div><Angles /></div>;
case "STORYBOARD":
return storyboardPageEnabled ? <div><Storyboard /></div> : null;
case "BACKGROUND_CHANGE":
return <div><PageBackgroundChange /></div>;
default:
return null;
}
@@ -0,0 +1,203 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSparkles } from "@fortawesome/pro-solid-svg-icons";
import { toast } from "@storyteller/ui-toaster";
import { UploadImageMedia, UploadVideoMedia } from "@storyteller/api";
import {
PromptBoxVFX,
SubTabStrip,
TruchetPattern,
VFXResultCard,
VFXShowcaseView,
newIdempotencyToken,
submitVFXJob,
useVFXStore,
VFX_NOT_AVAILABLE_ERROR,
VFX_SHOWCASE,
} from "@storyteller/ui-vfx";
export const PageBackgroundChange = () => {
const subTab = useVFXStore((s) => s.subTab);
const setSubTab = useVFXStore((s) => s.setSubTab);
const history = useVFXStore((s) => s.history);
const startResult = useVFXStore((s) => s.startResult);
const failResult = useVFXStore((s) => s.failResult);
const dismissResult = useVFXStore((s) => s.dismissResult);
const loadFromShowcase = useVFXStore((s) => s.loadFromShowcase);
const selectedShowcaseId = useVFXStore((s) => s.selectedShowcaseId);
const source = useVFXStore((s) => s.source);
const mask = useVFXStore((s) => s.mask);
const reference = useVFXStore((s) => s.reference);
const prompt = useVFXStore((s) => s.prompt);
const resolution = useVFXStore((s) => s.resolution);
const promptBoxRef = useRef<HTMLDivElement>(null);
const [promptBoxHeight, setPromptBoxHeight] = useState(96);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
const el = promptBoxRef.current;
if (!el || typeof ResizeObserver === "undefined") return;
const update = () => setPromptBoxHeight(el.offsetHeight);
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, []);
const handleSubmit = useCallback(async () => {
if (!source || !reference || isSubmitting) return;
setIsSubmitting(true);
const id = startResult();
const trimmed = prompt.trim();
const result = await submitVFXJob({
source_video_media_token: source.mediaToken,
reference_image_media_token: reference.mediaToken,
prompt: trimmed.length > 0 ? trimmed : null,
uuid_idempotency_token: newIdempotencyToken(),
});
setIsSubmitting(false);
if (!result.success) {
const isExpected = result.error_code_str === VFX_NOT_AVAILABLE_ERROR;
const message = isExpected
? "Background change backend coming soon. Your inputs are saved."
: result.error_message || "Failed to submit background change job.";
failResult(id, message);
if (isExpected) toast(message);
else toast.error(message);
}
}, [source, reference, prompt, isSubmitting, startResult, failResult]);
const handleTryShowcase = useCallback(() => {
const entry = VFX_SHOWCASE.find((e) => e.id === selectedShowcaseId);
if (!entry) return;
loadFromShowcase({
prompt: entry.prompt,
resolution: entry.resolution,
source: {
id: entry.source.mediaToken,
url: entry.source.url,
mediaToken: entry.source.mediaToken,
},
mask: entry.mask
? {
id: entry.mask.mediaToken,
url: entry.mask.url,
mediaToken: entry.mask.mediaToken,
}
: undefined,
reference: entry.reference
? {
id: entry.reference.mediaToken,
url: entry.reference.url,
mediaToken: entry.reference.mediaToken,
}
: undefined,
});
toast("Loaded showcase. Edit and Generate.");
}, [selectedShowcaseId, loadFromShowcase]);
return (
<div className="relative flex h-[calc(100vh-56px)] w-full flex-col bg-ui-background">
<SubTabStrip activeTab={subTab} onChange={setSubTab} />
{subTab === "showcase" ? (
<div
className="min-h-0 flex-1 overflow-hidden"
style={{ paddingBottom: Math.max(promptBoxHeight + 36, 240) }}
>
<VFXShowcaseView onTryThis={handleTryShowcase} />
</div>
) : history.length === 0 ? (
<div
className="relative flex flex-1 items-center justify-center px-6"
style={{ paddingBottom: Math.max(promptBoxHeight + 36, 240) }}
>
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-0"
style={{
maskImage:
"radial-gradient(ellipse 60% 60% at 50% 45%, black 30%, transparent 85%)",
WebkitMaskImage:
"radial-gradient(ellipse 60% 60% at 50% 45%, black 30%, transparent 85%)",
}}
>
<TruchetPattern
intensity={0.8}
className="absolute inset-0 h-full w-full"
/>
</div>
<div className="relative z-10">
<EmptyState
title="No background changes yet"
subtitle="Upload a source video and a reference image, then optionally add a prompt."
/>
</div>
</div>
) : (
<div
className="flex-1 overflow-y-auto"
style={{ paddingBottom: Math.max(promptBoxHeight + 36, 240) }}
>
<div className="flex flex-col gap-10 px-6 pt-6">
{history.map((r) => (
<VFXResultCard
key={r.id}
data={{
prompt: r.prompt,
resolution: r.resolution,
source: r.source,
mask: r.mask,
reference: r.reference,
outputUrl: r.outputUrl,
status: r.status,
failureReason: r.failureReason,
}}
onDismiss={() => dismissResult(r.id)}
/>
))}
</div>
</div>
)}
<div
aria-hidden
className="via-ui-background/85 pointer-events-none fixed bottom-0 left-0 right-0 z-20 h-72 bg-gradient-to-t from-ui-background to-transparent"
/>
<div
ref={promptBoxRef}
className="pointer-events-none fixed bottom-4 left-1/2 z-30 -translate-x-1/2"
>
<div className="pointer-events-auto">
<PromptBoxVFX
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
uploadVideo={UploadVideoMedia}
uploadImage={UploadImageMedia}
onError={(msg) => toast.error(msg)}
/>
</div>
</div>
</div>
);
};
interface EmptyStateProps {
title: string;
subtitle: string;
}
const EmptyState = ({ title, subtitle }: EmptyStateProps) => (
<div className="flex max-w-md flex-col items-center gap-4 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-base-fg/5 ring-1 ring-base-fg/10">
<FontAwesomeIcon icon={faSparkles} className="text-2xl" />
</div>
<h3 className="text-2xl font-bold text-base-fg">{title}</h3>
<p className="max-w-xs text-sm text-base-fg/60">{subtitle}</p>
</div>
);
export default PageBackgroundChange;
@@ -0,0 +1 @@
export { PageBackgroundChange } from "./PageBackgroundChange";
@@ -1,4 +1,4 @@
import React, { useMemo } from "react";
import { useMemo } from "react";
import {
PageDraw as PageDrawLib,
useSceneStore,
@@ -97,7 +97,7 @@ const useTauriAdapter = (): PageDrawAdapter => {
enqueueEditImage: async (req) => {
const request: GenerateImageRequest = {
model: req.model,
scene_image_media_token: req.sceneImageMediaToken,
canvas_image_media_token: req.canvasImageMediaToken,
image_media_tokens: req.imageMediaTokens,
prompt: req.prompt,
enable_system_prompt:
@@ -15,7 +15,8 @@ export type TabId =
| "IMAGE_TO_3D_WORLD"
| "REMOVE_BACKGROUND"
| "ANGLES"
| "STORYBOARD";
| "STORYBOARD"
| "BACKGROUND_CHANGE";
const DEFAULT_TAB: TabId = "IMAGE";
+3
View File
@@ -55,6 +55,9 @@
{
"path": "../../libs/components/promptbox"
},
{
"path": "../../libs/components/vfx"
},
{
"path": "../../libs/components/menu-icon-selector"
},
+6 -1
View File
@@ -4,10 +4,15 @@ import { FilterEngineCategories } from "./enums/QueryFilters.js";
import { Visibility } from "./enums/Visibility.js";
import { EIntermediateFile } from "./enums/EIntermediateFile.js";
import { UploaderStates, UploaderState } from "@storyteller/common";
type GetSessionTokenForUploadStuioShotResult =
| { success: true; data?: string }
| { success: false; errorMessage?: any };
export class MediaUploadApi extends ApiManager {
private sessionToken = "";
private async getSessionTokenForUploadStudioShot() {
private async getSessionTokenForUploadStudioShot(): Promise<GetSessionTokenForUploadStuioShotResult> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/session_token`;
return await this.get<{
maybe_signed_session: string;
-1
View File
@@ -106,7 +106,6 @@ export class PromptsApi extends ApiManager {
console.log(response);
const success = response.success ?? false;
const status = response.status ?? "";
const errorMessage = response.BadInput;
return {
@@ -39,6 +39,12 @@ export class StorytellerApiHostStore {
return instance;
}
/** Convert an API path to a full URL using the configured scheme and host. */
public pathToFullUrl(path: string): string {
const trimmedPath = path.startsWith("/") ? path.slice(1) : path;
return `${this.apiSchemeAndHost}/${trimmedPath}`;
}
/** Get the API scheme and host. */
public getApiSchemeAndHost(): string {
console.debug("StorytellerApiHostStore.getApiSchemeAndHost()", this.apiSchemeAndHost, this.constructor.name);
+1 -1
View File
@@ -1,4 +1,4 @@
import { USER_FEATURE_FLAGS } from "../../libs/api/src/lib/enums/UserFeatures";
import { USER_FEATURE_FLAGS } from "../enums/UserFeatures";
export interface CoreInfo {
default_avatar: {
@@ -17,7 +17,7 @@ export const GenerateButton = ({
return (
<Button
className={twMerge(
"group flex items-center justify-center gap-2 pl-3 pr-2",
"group flex items-center justify-center gap-2 h-[34px]",
className,
)}
disabled={disabled}
@@ -62,7 +62,7 @@ export const Button = ({
);
const className = twMerge(
"w-fit text-sm font-medium rounded-lg px-3 py-2 border border-transparent shadow-sm focus-visible:outline focus-visible:outline-0 focus-visible:outline-offset-0 transition-all duration-150 flex gap-2 items-center justify-center active:scale-95 transform",
"w-fit text-sm font-medium rounded-lg px-3 py-1.5 border border-transparent shadow-sm focus-visible:outline focus-visible:outline-0 focus-visible:outline-offset-0 transition-all duration-150 flex gap-2 items-center justify-center active:scale-95 transform",
getVariantClassNames(propsVariant),
propsClassName,
disabledClass
@@ -105,7 +105,11 @@ export const IMAGE_TO_VIDEO_PAGE_MODEL_LIST: ModelList =
buildItems(
(function (): Model[] {
const set: Set<Model> = new Set();
VIDEO_MODELS.forEach((m) => set.add(m));
// SwitchX is a VFX/background-change model with its own page; keep it
// out of the general video selector.
VIDEO_MODELS.filter((m) => m.id !== "switch_x").forEach((m) =>
set.add(m),
);
const list = Array.from(set);
list.sort((a, b) => a.selectorName?.localeCompare(b.selectorName));
return list;

Some files were not shown because too many files have changed in this diff Show More