mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-25 18:49:38 +00:00
Merge remote-tracking branch 'origin/main' into refactor/3d-componentize
# Conflicts: # frontend/apps/artcraft/app/src/pages/PageEnigma/PageEditor.tsx # frontend/apps/artcraft/tsconfig.tsbuildinfo # frontend/tsconfig.json
This commit is contained in:
+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";
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -576,6 +576,8 @@ export const TopBar = ({ pageName }: Props) => {
|
||||
return "Image to 3D World";
|
||||
case "APPS":
|
||||
return "ArtCraft Apps";
|
||||
case "BACKGROUND_CHANGE":
|
||||
return "Background Change";
|
||||
default:
|
||||
return "Artcraft";
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
faWandMagicSparkles,
|
||||
faPenNib,
|
||||
faCrosshairs,
|
||||
faSparkles,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
@@ -31,7 +32,8 @@ export type AppId =
|
||||
| "IMAGE_TO_3D_WORLD"
|
||||
| "REMOVE_BACKGROUND"
|
||||
| "ANGLES"
|
||||
| "STORYBOARD";
|
||||
| "STORYBOARD"
|
||||
| "BACKGROUND_CHANGE";
|
||||
|
||||
export interface AppDescriptor {
|
||||
id: AppId;
|
||||
@@ -185,6 +187,16 @@ export const ALL_APPS: FullAppItem[] = [
|
||||
color: "bg-fuchsia-600/40",
|
||||
badge: "NEW",
|
||||
},
|
||||
{
|
||||
id: "background-change",
|
||||
label: "Background Change",
|
||||
description: "Swap the backdrop of a video using a reference image",
|
||||
icon: faSparkles,
|
||||
category: "edit",
|
||||
action: "BACKGROUND_CHANGE",
|
||||
color: "bg-orange-500/40",
|
||||
badge: "NEW",
|
||||
},
|
||||
{
|
||||
id: "2d-canvas",
|
||||
label: "Image Editor",
|
||||
@@ -214,9 +226,12 @@ export const useVisibleApps = (): FullAppItem[] => {
|
||||
const storyboardEnabled = useStoryboardPageEnabled();
|
||||
return useMemo(
|
||||
() =>
|
||||
ALL_APPS.filter((app) =>
|
||||
app.action === "STORYBOARD" ? storyboardEnabled : true,
|
||||
),
|
||||
ALL_APPS.filter((app) => {
|
||||
// Background Change is hidden in the desktop app for now.
|
||||
if (app.action === "BACKGROUND_CHANGE") return false;
|
||||
if (app.action === "STORYBOARD") return storyboardEnabled;
|
||||
return true;
|
||||
}),
|
||||
[storyboardEnabled],
|
||||
);
|
||||
};
|
||||
@@ -266,6 +281,7 @@ export const goToApp = (action?: string) => {
|
||||
"REMOVE_BACKGROUND",
|
||||
"ANGLES",
|
||||
"STORYBOARD",
|
||||
"BACKGROUND_CHANGE",
|
||||
].includes(action)
|
||||
) {
|
||||
if (action === "STORYBOARD") {
|
||||
|
||||
@@ -71,6 +71,7 @@ import { ImageTo3DWorld } from "./PageImageTo3DWorld";
|
||||
import { RemoveBackground } from "./PageRemoveBackground";
|
||||
import { Angles } from "./PageAngles";
|
||||
import { Storyboard } from "./PageStoryboard";
|
||||
import { PageBackgroundChange } from "./PageBackgroundChange";
|
||||
import { PageScene } from "./PageScene";
|
||||
import {
|
||||
topNavMediaId,
|
||||
@@ -251,6 +252,8 @@ const TabBody = ({ sceneToken }: { sceneToken?: string }) => {
|
||||
return <div><Angles /></div>;
|
||||
case "STORYBOARD":
|
||||
return storyboardPageEnabled ? <div><Storyboard /></div> : null;
|
||||
case "BACKGROUND_CHANGE":
|
||||
return <div><PageBackgroundChange /></div>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faSparkles } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { toast } from "@storyteller/ui-toaster";
|
||||
import { UploadImageMedia, UploadVideoMedia } from "@storyteller/api";
|
||||
import {
|
||||
PromptBoxVFX,
|
||||
SubTabStrip,
|
||||
TruchetPattern,
|
||||
VFXResultCard,
|
||||
VFXShowcaseView,
|
||||
newIdempotencyToken,
|
||||
submitVFXJob,
|
||||
useVFXStore,
|
||||
VFX_NOT_AVAILABLE_ERROR,
|
||||
VFX_SHOWCASE,
|
||||
} from "@storyteller/ui-vfx";
|
||||
|
||||
export const PageBackgroundChange = () => {
|
||||
const subTab = useVFXStore((s) => s.subTab);
|
||||
const setSubTab = useVFXStore((s) => s.setSubTab);
|
||||
const history = useVFXStore((s) => s.history);
|
||||
const startResult = useVFXStore((s) => s.startResult);
|
||||
const failResult = useVFXStore((s) => s.failResult);
|
||||
const dismissResult = useVFXStore((s) => s.dismissResult);
|
||||
const loadFromShowcase = useVFXStore((s) => s.loadFromShowcase);
|
||||
const selectedShowcaseId = useVFXStore((s) => s.selectedShowcaseId);
|
||||
const source = useVFXStore((s) => s.source);
|
||||
const mask = useVFXStore((s) => s.mask);
|
||||
const reference = useVFXStore((s) => s.reference);
|
||||
const prompt = useVFXStore((s) => s.prompt);
|
||||
const resolution = useVFXStore((s) => s.resolution);
|
||||
|
||||
const promptBoxRef = useRef<HTMLDivElement>(null);
|
||||
const [promptBoxHeight, setPromptBoxHeight] = useState(96);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = promptBoxRef.current;
|
||||
if (!el || typeof ResizeObserver === "undefined") return;
|
||||
const update = () => setPromptBoxHeight(el.offsetHeight);
|
||||
update();
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!source || !reference || isSubmitting) return;
|
||||
setIsSubmitting(true);
|
||||
const id = startResult();
|
||||
const trimmed = prompt.trim();
|
||||
const result = await submitVFXJob({
|
||||
source_video_media_token: source.mediaToken,
|
||||
reference_image_media_token: reference.mediaToken,
|
||||
prompt: trimmed.length > 0 ? trimmed : null,
|
||||
uuid_idempotency_token: newIdempotencyToken(),
|
||||
});
|
||||
setIsSubmitting(false);
|
||||
|
||||
if (!result.success) {
|
||||
const isExpected = result.error_code_str === VFX_NOT_AVAILABLE_ERROR;
|
||||
const message = isExpected
|
||||
? "Background change backend coming soon. Your inputs are saved."
|
||||
: result.error_message || "Failed to submit background change job.";
|
||||
failResult(id, message);
|
||||
if (isExpected) toast(message);
|
||||
else toast.error(message);
|
||||
}
|
||||
}, [source, reference, prompt, isSubmitting, startResult, failResult]);
|
||||
|
||||
const handleTryShowcase = useCallback(() => {
|
||||
const entry = VFX_SHOWCASE.find((e) => e.id === selectedShowcaseId);
|
||||
if (!entry) return;
|
||||
loadFromShowcase({
|
||||
prompt: entry.prompt,
|
||||
resolution: entry.resolution,
|
||||
source: {
|
||||
id: entry.source.mediaToken,
|
||||
url: entry.source.url,
|
||||
mediaToken: entry.source.mediaToken,
|
||||
},
|
||||
mask: entry.mask
|
||||
? {
|
||||
id: entry.mask.mediaToken,
|
||||
url: entry.mask.url,
|
||||
mediaToken: entry.mask.mediaToken,
|
||||
}
|
||||
: undefined,
|
||||
reference: entry.reference
|
||||
? {
|
||||
id: entry.reference.mediaToken,
|
||||
url: entry.reference.url,
|
||||
mediaToken: entry.reference.mediaToken,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
toast("Loaded showcase. Edit and Generate.");
|
||||
}, [selectedShowcaseId, loadFromShowcase]);
|
||||
|
||||
return (
|
||||
<div className="relative flex h-[calc(100vh-56px)] w-full flex-col bg-ui-background">
|
||||
<SubTabStrip activeTab={subTab} onChange={setSubTab} />
|
||||
|
||||
{subTab === "showcase" ? (
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-hidden"
|
||||
style={{ paddingBottom: Math.max(promptBoxHeight + 36, 240) }}
|
||||
>
|
||||
<VFXShowcaseView onTryThis={handleTryShowcase} />
|
||||
</div>
|
||||
) : history.length === 0 ? (
|
||||
<div
|
||||
className="relative flex flex-1 items-center justify-center px-6"
|
||||
style={{ paddingBottom: Math.max(promptBoxHeight + 36, 240) }}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 z-0"
|
||||
style={{
|
||||
maskImage:
|
||||
"radial-gradient(ellipse 60% 60% at 50% 45%, black 30%, transparent 85%)",
|
||||
WebkitMaskImage:
|
||||
"radial-gradient(ellipse 60% 60% at 50% 45%, black 30%, transparent 85%)",
|
||||
}}
|
||||
>
|
||||
<TruchetPattern
|
||||
intensity={0.8}
|
||||
className="absolute inset-0 h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative z-10">
|
||||
<EmptyState
|
||||
title="No background changes yet"
|
||||
subtitle="Upload a source video and a reference image, then optionally add a prompt."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex-1 overflow-y-auto"
|
||||
style={{ paddingBottom: Math.max(promptBoxHeight + 36, 240) }}
|
||||
>
|
||||
<div className="flex flex-col gap-10 px-6 pt-6">
|
||||
{history.map((r) => (
|
||||
<VFXResultCard
|
||||
key={r.id}
|
||||
data={{
|
||||
prompt: r.prompt,
|
||||
resolution: r.resolution,
|
||||
source: r.source,
|
||||
mask: r.mask,
|
||||
reference: r.reference,
|
||||
outputUrl: r.outputUrl,
|
||||
status: r.status,
|
||||
failureReason: r.failureReason,
|
||||
}}
|
||||
onDismiss={() => dismissResult(r.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
aria-hidden
|
||||
className="via-ui-background/85 pointer-events-none fixed bottom-0 left-0 right-0 z-20 h-72 bg-gradient-to-t from-ui-background to-transparent"
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={promptBoxRef}
|
||||
className="pointer-events-none fixed bottom-4 left-1/2 z-30 -translate-x-1/2"
|
||||
>
|
||||
<div className="pointer-events-auto">
|
||||
<PromptBoxVFX
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={isSubmitting}
|
||||
uploadVideo={UploadVideoMedia}
|
||||
uploadImage={UploadImageMedia}
|
||||
onError={(msg) => toast.error(msg)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface EmptyStateProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
const EmptyState = ({ title, subtitle }: EmptyStateProps) => (
|
||||
<div className="flex max-w-md flex-col items-center gap-4 text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-base-fg/5 ring-1 ring-base-fg/10">
|
||||
<FontAwesomeIcon icon={faSparkles} className="text-2xl" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-base-fg">{title}</h3>
|
||||
<p className="max-w-xs text-sm text-base-fg/60">{subtitle}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default PageBackgroundChange;
|
||||
@@ -0,0 +1 @@
|
||||
export { PageBackgroundChange } from "./PageBackgroundChange";
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo } from "react";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
PageDraw as PageDrawLib,
|
||||
useSceneStore,
|
||||
@@ -97,7 +97,7 @@ const useTauriAdapter = (): PageDrawAdapter => {
|
||||
enqueueEditImage: async (req) => {
|
||||
const request: GenerateImageRequest = {
|
||||
model: req.model,
|
||||
scene_image_media_token: req.sceneImageMediaToken,
|
||||
canvas_image_media_token: req.canvasImageMediaToken,
|
||||
image_media_tokens: req.imageMediaTokens,
|
||||
prompt: req.prompt,
|
||||
enable_system_prompt:
|
||||
|
||||
@@ -15,7 +15,8 @@ export type TabId =
|
||||
| "IMAGE_TO_3D_WORLD"
|
||||
| "REMOVE_BACKGROUND"
|
||||
| "ANGLES"
|
||||
| "STORYBOARD";
|
||||
| "STORYBOARD"
|
||||
| "BACKGROUND_CHANGE";
|
||||
|
||||
const DEFAULT_TAB: TabId = "IMAGE";
|
||||
|
||||
|
||||
@@ -55,6 +55,9 @@
|
||||
{
|
||||
"path": "../../libs/components/promptbox"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/vfx"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/menu-icon-selector"
|
||||
},
|
||||
|
||||
@@ -4,10 +4,15 @@ import { FilterEngineCategories } from "./enums/QueryFilters.js";
|
||||
import { Visibility } from "./enums/Visibility.js";
|
||||
import { EIntermediateFile } from "./enums/EIntermediateFile.js";
|
||||
import { UploaderStates, UploaderState } from "@storyteller/common";
|
||||
|
||||
type GetSessionTokenForUploadStuioShotResult =
|
||||
| { success: true; data?: string }
|
||||
| { success: false; errorMessage?: any };
|
||||
|
||||
export class MediaUploadApi extends ApiManager {
|
||||
private sessionToken = "";
|
||||
|
||||
private async getSessionTokenForUploadStudioShot() {
|
||||
private async getSessionTokenForUploadStudioShot(): Promise<GetSessionTokenForUploadStuioShotResult> {
|
||||
const endpoint = `${this.getApiSchemeAndHost()}/v1/session_token`;
|
||||
return await this.get<{
|
||||
maybe_signed_session: string;
|
||||
|
||||
@@ -106,7 +106,6 @@ export class PromptsApi extends ApiManager {
|
||||
console.log(response);
|
||||
|
||||
const success = response.success ?? false;
|
||||
const status = response.status ?? "";
|
||||
const errorMessage = response.BadInput;
|
||||
|
||||
return {
|
||||
|
||||
@@ -39,6 +39,12 @@ export class StorytellerApiHostStore {
|
||||
return instance;
|
||||
}
|
||||
|
||||
/** Convert an API path to a full URL using the configured scheme and host. */
|
||||
public pathToFullUrl(path: string): string {
|
||||
const trimmedPath = path.startsWith("/") ? path.slice(1) : path;
|
||||
return `${this.apiSchemeAndHost}/${trimmedPath}`;
|
||||
}
|
||||
|
||||
/** Get the API scheme and host. */
|
||||
public getApiSchemeAndHost(): string {
|
||||
console.debug("StorytellerApiHostStore.getApiSchemeAndHost()", this.apiSchemeAndHost, this.constructor.name);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { USER_FEATURE_FLAGS } from "../../libs/api/src/lib/enums/UserFeatures";
|
||||
import { USER_FEATURE_FLAGS } from "../enums/UserFeatures";
|
||||
|
||||
export interface CoreInfo {
|
||||
default_avatar: {
|
||||
|
||||
@@ -17,7 +17,7 @@ export const GenerateButton = ({
|
||||
return (
|
||||
<Button
|
||||
className={twMerge(
|
||||
"group flex items-center justify-center gap-2 pl-3 pr-2",
|
||||
"group flex items-center justify-center gap-2 h-[34px]",
|
||||
className,
|
||||
)}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -62,7 +62,7 @@ export const Button = ({
|
||||
);
|
||||
|
||||
const className = twMerge(
|
||||
"w-fit text-sm font-medium rounded-lg px-3 py-2 border border-transparent shadow-sm focus-visible:outline focus-visible:outline-0 focus-visible:outline-offset-0 transition-all duration-150 flex gap-2 items-center justify-center active:scale-95 transform",
|
||||
"w-fit text-sm font-medium rounded-lg px-3 py-1.5 border border-transparent shadow-sm focus-visible:outline focus-visible:outline-0 focus-visible:outline-offset-0 transition-all duration-150 flex gap-2 items-center justify-center active:scale-95 transform",
|
||||
getVariantClassNames(propsVariant),
|
||||
propsClassName,
|
||||
disabledClass
|
||||
|
||||
@@ -105,7 +105,11 @@ export const IMAGE_TO_VIDEO_PAGE_MODEL_LIST: ModelList =
|
||||
buildItems(
|
||||
(function (): Model[] {
|
||||
const set: Set<Model> = new Set();
|
||||
VIDEO_MODELS.forEach((m) => set.add(m));
|
||||
// SwitchX is a VFX/background-change model with its own page; keep it
|
||||
// out of the general video selector.
|
||||
VIDEO_MODELS.filter((m) => m.id !== "switch_x").forEach((m) =>
|
||||
set.add(m),
|
||||
);
|
||||
const list = Array.from(set);
|
||||
list.sort((a, b) => a.selectorName?.localeCompare(b.selectorName));
|
||||
return list;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user