mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-24 18:59:28 -04:00
Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6c2b59876 | ||
|
|
369c552096 | ||
|
|
13a5dad3a5 | ||
|
|
f8be6ea4c9 | ||
|
|
d659f8485c | ||
|
|
aec5e50f54 | ||
|
|
edd900d29b | ||
|
|
1ea94e4b42 | ||
|
|
32a7eb67dd | ||
|
|
5f70776afe | ||
|
|
a883f79c6d | ||
|
|
5c5a7d1182 | ||
|
|
d0e5a05253 | ||
|
|
72e052fe7e | ||
|
|
7471259d28 | ||
|
|
42e1787576 | ||
|
|
d3b0a0d78c | ||
|
|
8556ef0a6b | ||
|
|
6067849ef5 | ||
|
|
8836346150 | ||
|
|
86be8461ec | ||
|
|
3e7c67abce | ||
|
|
05097945f6 | ||
|
|
f8cd171b34 | ||
|
|
ffde0b06c0 | ||
|
|
3ff43fcf99 | ||
|
|
ebb0341347 | ||
|
|
ca4baf243c | ||
|
|
60e944d22b | ||
|
|
41cc84d99c | ||
|
|
7dda05e980 | ||
|
|
7f502e0cc2 | ||
|
|
3b839d50a0 | ||
|
|
6fffb3a734 | ||
|
|
e01b02e463 | ||
|
|
e2f1b218bc | ||
|
|
2482413a4a | ||
|
|
8250aa0a05 | ||
|
|
3d646b8f19 | ||
|
|
9ee43d4e4a | ||
|
|
40a6644c35 | ||
|
|
c6a0b43324 | ||
|
|
cdb16ff9f4 | ||
|
|
bd18c3ec64 | ||
|
|
4db39881ee | ||
|
|
b4da753346 | ||
|
|
3a8f44b0d1 | ||
|
|
7465d2a533 | ||
|
|
ca713ba1f9 | ||
|
|
312c603cef | ||
|
|
bdfa31421f | ||
|
|
c78442cf71 | ||
|
|
e7dca6eedc |
+135
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
+23
@@ -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
|
||||
|
||||
+20
@@ -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;
|
||||
|
||||
+20
@@ -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;
|
||||
+72
@@ -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;
|
||||
+134
@@ -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,
|
||||
})
|
||||
}
|
||||
+168
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+11
-1
@@ -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);
|
||||
|
||||
+12
-1
@@ -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);
|
||||
|
||||
+11
-1
@@ -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);
|
||||
|
||||
+11
-1
@@ -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,
|
||||
|
||||
+345
@@ -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,
|
||||
}))
|
||||
}
|
||||
+1
@@ -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;
|
||||
+427
@@ -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;
|
||||
+2
-2
@@ -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;
|
||||
+1
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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;
|
||||
|
||||
+7
@@ -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()))
|
||||
|
||||
+6
-1
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -343,10 +343,10 @@
|
||||
"markdownDescription": "Denies the fetch_send command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures what kind of\nfetch operations are available from the http plugin.\n\nThis enables all fetch operations but does not\nallow explicitly any origins to be fetched. This needs to\nbe manually configured before usage.\n\n#### Granted Permissions\n\nAll fetch operations are enabled.\n\n\n#### This default permission set includes:\n\n- `allow-fetch`\n- `allow-fetch-cancel`\n- `allow-fetch-read-body`\n- `allow-fetch-send`",
|
||||
"description": "This permission set configures what kind of\r\nfetch operations are available from the http plugin.\r\n\r\nThis enables all fetch operations but does not\r\nallow explicitly any origins to be fetched. This needs to\r\nbe manually configured before usage.\r\n\r\n#### Granted Permissions\r\n\r\nAll fetch operations are enabled.\r\n\r\n\n#### This default permission set includes:\n\n- `allow-fetch`\n- `allow-fetch-cancel`\n- `allow-fetch-read-body`\n- `allow-fetch-send`",
|
||||
"type": "string",
|
||||
"const": "default",
|
||||
"markdownDescription": "This permission set configures what kind of\nfetch operations are available from the http plugin.\n\nThis enables all fetch operations but does not\nallow explicitly any origins to be fetched. This needs to\nbe manually configured before usage.\n\n#### Granted Permissions\n\nAll fetch operations are enabled.\n\n\n#### This default permission set includes:\n\n- `allow-fetch`\n- `allow-fetch-cancel`\n- `allow-fetch-read-body`\n- `allow-fetch-send`"
|
||||
"markdownDescription": "This permission set configures what kind of\r\nfetch operations are available from the http plugin.\r\n\r\nThis enables all fetch operations but does not\r\nallow explicitly any origins to be fetched. This needs to\r\nbe manually configured before usage.\r\n\r\n#### Granted Permissions\r\n\r\nAll fetch operations are enabled.\r\n\r\n\n#### This default permission set includes:\n\n- `allow-fetch`\n- `allow-fetch-cancel`\n- `allow-fetch-read-body`\n- `allow-fetch-send`"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
+310
@@ -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"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ApiManager, ApiResponse } from "./ApiManager";
|
||||
import { BetaKey } from "~/models";
|
||||
import { Pagination } from "~/pages/PageEnigma/models";
|
||||
import type { Pagination } from "@storyteller/ui-pagescene";
|
||||
|
||||
export class BetaKeysApi extends ApiManager {
|
||||
public CreateBetaKey({
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
import type {
|
||||
MediaFile,
|
||||
MediaInfo,
|
||||
Pagination,
|
||||
PaginationInfinite,
|
||||
} from "~/pages/PageEnigma/models";
|
||||
} from "@storyteller/ui-pagescene";
|
||||
import { ApiManager, ApiResponse } from "./ApiManager";
|
||||
import { authentication } from "~/signals";
|
||||
import type { Property } from "csstype";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiManager, ApiResponse } from "./ApiManager";
|
||||
import { Prompts } from "~/pages/PageEnigma/models";
|
||||
import type { Prompts } from "@storyteller/ui-pagescene";
|
||||
|
||||
export class PromptsApi extends ApiManager {
|
||||
public GetPromptsByToken({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiManager, ApiResponse } from "./ApiManager";
|
||||
import { GenerateTtsAudioErrorType } from "~/pages/PageEnigma/enums";
|
||||
import { GenerateTtsAudioErrorType } from "@storyteller/ui-pagescene";
|
||||
import { Visibility } from "~/enums";
|
||||
|
||||
export interface GenerateTtsAudioRequest {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { ApiManager, ApiResponse } from "./ApiManager";
|
||||
import {
|
||||
import type {
|
||||
Pagination,
|
||||
UserBookmarkBatch,
|
||||
UserBookmarkByEntity,
|
||||
UserBookmarkByUser,
|
||||
} from "~/pages/PageEnigma/models";
|
||||
} from "@storyteller/ui-pagescene";
|
||||
import { authentication } from "~/signals";
|
||||
|
||||
export enum ScopedEntityTypes {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiManager, ApiResponse } from "./ApiManager";
|
||||
import { Pagination } from "~/pages/PageEnigma/models";
|
||||
import type { Pagination } from "@storyteller/ui-pagescene";
|
||||
import { authentication } from "~/signals";
|
||||
import { ZsDataset } from "~/models/Dataset";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ApiManager, ApiResponse } from "./ApiManager";
|
||||
import { authentication } from "~/signals";
|
||||
import { Weight } from "~/models";
|
||||
import { Pagination, PaginationInfinite } from "~/pages/PageEnigma/models";
|
||||
import type { Pagination, PaginationInfinite } from "@storyteller/ui-pagescene";
|
||||
import {
|
||||
FilterEngineCategories,
|
||||
FilterMediaClasses,
|
||||
|
||||
@@ -25,10 +25,9 @@ import {
|
||||
import { toast } from "react-hot-toast";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { useTabStore } from "../../pages/Stores/TabState";
|
||||
import { addObject } from "../../pages/PageEnigma/signals/objectGroup/addObject";
|
||||
import { set3DPageMounted } from "../../pages/PageEnigma/Editor/editor";
|
||||
import { addObject, getActiveEditor } from "@storyteller/ui-pagescene";
|
||||
import type { MediaItem } from "@storyteller/ui-pagescene";
|
||||
import { AssetType } from "~/enums";
|
||||
import type { MediaItem } from "../../pages/PageEnigma/models";
|
||||
import { SPLAT_MODELS } from "@storyteller/model-list";
|
||||
import {
|
||||
ClassyModelSelector,
|
||||
@@ -974,7 +973,6 @@ export const ImageTo3DExperience = ({
|
||||
variant="primary"
|
||||
className="min-w-[120px]"
|
||||
onClick={() => {
|
||||
set3DPageMounted(true);
|
||||
useTabStore.getState().setActiveTab("3D");
|
||||
setTimeout(() => {
|
||||
const mediaItem = {
|
||||
@@ -986,8 +984,13 @@ export const ImageTo3DExperience = ({
|
||||
} as MediaItem & {
|
||||
position: { x: number; y: number; z: number };
|
||||
};
|
||||
addObject(mediaItem);
|
||||
toast.success("Added to 3D scene");
|
||||
const editor = getActiveEditor();
|
||||
if (editor) {
|
||||
void addObject(editor, mediaItem);
|
||||
toast.success("Added to 3D scene");
|
||||
} else {
|
||||
toast.error("3D editor isn't ready yet");
|
||||
}
|
||||
}, 500);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -73,17 +73,6 @@ export function UploadModal3D(props: Props) {
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (uploaderState.status === UploaderStates.success) {
|
||||
// Automatically open the global Gallery modal after a successful upload
|
||||
galleryModalVisibleViewMode.value = true;
|
||||
galleryModalVisibleDuringDrag.value = true;
|
||||
|
||||
onSuccess(selectedCategory);
|
||||
onClose();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [uploaderState.status]);
|
||||
|
||||
const UploaderModalContent = () => {
|
||||
switch (uploaderState.status) {
|
||||
@@ -130,8 +119,12 @@ export function UploadModal3D(props: Props) {
|
||||
<UploadSuccess
|
||||
title="3D model"
|
||||
onOk={() => {
|
||||
onClose();
|
||||
// Open the gallery so the user can drag the freshly
|
||||
// uploaded model onto the canvas.
|
||||
galleryModalVisibleViewMode.value = true;
|
||||
galleryModalVisibleDuringDrag.value = true;
|
||||
onSuccess(selectedCategory);
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -172,7 +165,8 @@ export function UploadModal3D(props: Props) {
|
||||
className="max-w-xl"
|
||||
showClose={true}
|
||||
>
|
||||
<UploaderModalContent />
|
||||
{/* Inline call — `<Comp />` would be a fresh component reference each render, remounting the dropzone mid-click and breaking the file picker. */}
|
||||
{UploaderModalContent()}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
+7
-13
@@ -48,17 +48,6 @@ export function UploadModalImage(props: Props) {
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (uploaderState.status === UploaderStates.success) {
|
||||
// Automatically open the global Gallery modal after a successful upload
|
||||
galleryModalVisibleViewMode.value = true;
|
||||
galleryModalVisibleDuringDrag.value = true;
|
||||
|
||||
onSuccess(selectedCategory);
|
||||
onClose();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [uploaderState.status]);
|
||||
|
||||
const UploaderModalContent = () => {
|
||||
switch (uploaderState.status) {
|
||||
@@ -94,8 +83,12 @@ export function UploadModalImage(props: Props) {
|
||||
<UploadSuccess
|
||||
title="Image"
|
||||
onOk={() => {
|
||||
onClose();
|
||||
// Open the gallery so the user can drag the freshly
|
||||
// uploaded image onto the canvas.
|
||||
galleryModalVisibleViewMode.value = true;
|
||||
galleryModalVisibleDuringDrag.value = true;
|
||||
onSuccess(selectedCategory);
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -136,7 +129,8 @@ export function UploadModalImage(props: Props) {
|
||||
className="max-w-xl"
|
||||
showClose={true}
|
||||
>
|
||||
<UploaderModalContent />
|
||||
{/* Inline call — `<Comp />` would be a fresh component reference each render, remounting the dropzone mid-click and breaking the file picker. */}
|
||||
{UploaderModalContent()}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
+2
-1
@@ -113,7 +113,8 @@ export function UploadImageMediaModal({
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={title}>
|
||||
<UploaderModalContent />
|
||||
{/* Inline call — `<Comp />` would be a fresh component reference each render, remounting the dropzone mid-click and breaking the file picker. */}
|
||||
{UploaderModalContent()}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
+2
-1
@@ -100,7 +100,8 @@ export function UploadModalMedia({
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={title}>
|
||||
<UploaderModalContent />
|
||||
{/* Inline call — `<Comp />` would be a fresh component reference each render, remounting the dropzone mid-click and breaking the file picker. */}
|
||||
{UploaderModalContent()}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
-1
@@ -40,7 +40,6 @@ interface Props {
|
||||
};
|
||||
onClose: () => void;
|
||||
onUploadProgress: (newState: UploaderState) => void;
|
||||
onLocalBytes: (bytes: ArrayBuffer, shouldFlip: boolean) => void;
|
||||
}
|
||||
|
||||
export const UploadFilesSplat = ({
|
||||
|
||||
+8
-15
@@ -18,7 +18,7 @@ import { UploadFilesSplat } from "./UploadFilesSplat";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onSuccess: (splatArrayBuffer: ArrayBuffer, shouldFlip: boolean) => void;
|
||||
onSuccess: (category: FilterEngineCategories) => void;
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
titleIcon: IconDefinition;
|
||||
@@ -52,17 +52,6 @@ export function UploadModalSplat(props: Props) {
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (uploaderState.status === UploaderStates.success) {
|
||||
// Automatically open the global Gallery modal after a successful upload
|
||||
// galleryModalVisibleViewMode.value = true;
|
||||
// galleryModalVisibleDuringDrag.value = true;
|
||||
|
||||
// onSuccess(selectedCategory);
|
||||
onClose();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [uploaderState.status]);
|
||||
|
||||
const UploaderModalContent = () => {
|
||||
switch (uploaderState.status) {
|
||||
@@ -79,7 +68,6 @@ export function UploadModalSplat(props: Props) {
|
||||
}}
|
||||
onClose={onClose}
|
||||
onUploadProgress={updateUploaderState}
|
||||
onLocalBytes={(buffer, shouldFlip) => { onSuccess(buffer, shouldFlip); }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -103,8 +91,12 @@ export function UploadModalSplat(props: Props) {
|
||||
<UploadSuccess
|
||||
title="Splat"
|
||||
onOk={() => {
|
||||
// Open the gallery so the user can drag the freshly
|
||||
// uploaded splat onto the canvas.
|
||||
galleryModalVisibleViewMode.value = true;
|
||||
galleryModalVisibleDuringDrag.value = true;
|
||||
onSuccess(selectedCategory);
|
||||
onClose();
|
||||
// onSuccess(selectedCategory);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -145,7 +137,8 @@ export function UploadModalSplat(props: Props) {
|
||||
className="max-w-xl"
|
||||
showClose={true}
|
||||
>
|
||||
<UploaderModalContent />
|
||||
{/* Inline call — `<Comp />` would be a fresh component reference each render, remounting the dropzone mid-click and breaking the file picker. */}
|
||||
{UploaderModalContent()}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user