Compare commits

..
Author SHA1 Message Date
Brandon Thomas 3dbc540fb2 restore artcraft tauri portions 2026-02-22 09:24:48 +07:00
Brandon Thomas f3943818d9 remove wreq openssl hack 2026-02-22 09:20:35 +07:00
Brandon Thomas 0a99b77a6a ugh 2026-02-22 08:49:33 +07:00
Brandon Thomas 9cd6685f02 re-add seedance2-pro-job 2026-02-22 08:42:06 +07:00
Brandon Thomas 89e8346fa6 re-add seedance job 2026-02-22 08:39:11 +07:00
Brandon Thomas 79a85e1b32 configure rustls 2026-02-22 08:28:24 +07:00
Brandon Thomas fe1235a93c goodbye openssl 2026-02-22 08:20:01 +07:00
Brandon Thomas 5217e9ff07 upgrade rust 2026-02-22 07:42:35 +07:00
Brandon Thomas bb375e405f this appears to work 2026-02-22 07:20:24 +07:00
Brandon Thomas 0a44642c95 prefix-symbols is in a future release 2026-02-21 14:02:45 +07:00
Brandon Thomas 0b68e04b5f perhaps this bypasses the conflict with openssl 2026-02-21 13:58:27 +07:00
Brandon Thomas a0e6b5721b add seedance2pro lib. works on mac, maybe fails on linux 2026-02-21 10:32:04 +07:00
Brandon Thomas 8ffa1de27f fixup, should build now 2026-02-21 10:00:02 +07:00
Brandon Thomas 1f0eb35ff1 shouldn't be impacting 2026-02-21 09:55:00 +07:00
Brandon Thomas 43ec1ae5d7 shouldn't be impacting 2026-02-21 09:54:41 +07:00
Brandon Thomas 625ba41423 shouldn't be impacting 2026-02-21 09:54:14 +07:00
Brandon Thomas 2a62bcc7f8 query codegen 2026-02-21 09:53:21 +07:00
Brandon Thomas 5c92adff8f not relevant to our test 2026-02-21 09:52:55 +07:00
Brandon Thomas 1276df9f29 force a re-build 2026-02-21 09:08:30 +07:00
Brandon Thomas 026d356d9c run on test branch 2026-02-21 09:02:36 +07:00
142 changed files with 976 additions and 4018 deletions
-3
View File
@@ -68,9 +68,6 @@ __pycache__/
# We use diesel for DB migrations only. We don't care about its codgen.
_ignored_schema.rs
# Diesel lock file
.diesel_lock
# AI Chat Sidecar has its own schema managed by diesel, but we don't care for diesel codegen.
crates/programs/aichatbot-sidecar/_ignored_schema.rs
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nUPDATE wallets\nSET\n banked_credits = ?,\n version = version + 1\nWHERE token = ?\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "01f6369e0ab7b0fa5b95b4595827dcf15897f3b50cce5b1dc421101b157b7008"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO generic_inference_jobs\nSET\n token = ?,\n uuid_idempotency_token = ?,\n\n job_type = ?,\n\n maybe_external_third_party = ?,\n maybe_external_third_party_id = ?,\n\n product_category = ?,\n inference_category = ?,\n\n maybe_model_type = NULL,\n maybe_model_token = NULL,\n\n maybe_input_source_token = NULL,\n maybe_input_source_token_type = NULL,\n\n maybe_download_url = NULL,\n maybe_cover_image_media_file_token = NULL,\n\n maybe_prompt_token = ?,\n\n maybe_raw_inference_text = NULL,\n\n maybe_inference_args = ?,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n creator_ip_address = ?,\n creator_set_visibility = ?,\n\n priority_level = 0,\n is_keepalive_required = FALSE,\n max_duration_seconds = 0,\n\n is_debug_request = FALSE,\n maybe_routing_tag = NULL,\n\n status = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 14
},
"nullable": []
},
"hash": "33aa8a1cf85bf1b8b68f4e2ab46541f25d7e8bb5eeb503c5ec14cb5e115f69ce"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nUPDATE wallet_ledger_entries\nSET\n is_refunded = TRUE,\n maybe_linked_refund_ledger_token = ?\nWHERE token = ?\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "6701a7adad65bc351e4600dad3ebf0f5dbdefa7dc9a0f890790cfaf368bf572d"
}
@@ -1,102 +0,0 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n wallet_token as `wallet_token: tokens::tokens::wallets::WalletToken`,\n entry_type,\n is_refunded as `is_refunded: bool`,\n credits_delta,\n banked_credits_before,\n banked_credits_after,\n monthly_credits_before,\n monthly_credits_after\nFROM wallet_ledger_entries\nWHERE token = ?\nLIMIT 1\nFOR UPDATE\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "wallet_token: tokens::tokens::wallets::WalletToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "entry_type",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "is_refunded: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 3,
"name": "credits_delta",
"type_info": {
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
}
},
{
"ordinal": 4,
"name": "banked_credits_before",
"type_info": {
"type": "Long",
"flags": "NOT_NULL | UNSIGNED",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 5,
"name": "banked_credits_after",
"type_info": {
"type": "Long",
"flags": "NOT_NULL | UNSIGNED",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 6,
"name": "monthly_credits_before",
"type_info": {
"type": "Long",
"flags": "NOT_NULL | UNSIGNED",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 7,
"name": "monthly_credits_after",
"type_info": {
"type": "Long",
"flags": "NOT_NULL | UNSIGNED",
"char_set": 63,
"max_size": 10
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "a7365998006f4d40ad26efb42e6958a7aed689562a4c5b8c91cbe0033d0270e7"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n jobs.maybe_external_third_party_id as `order_id`,\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 jobs.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,\n jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n jobs.maybe_wallet_ledger_entry_token as `maybe_wallet_ledger_entry_token: tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken`\n\nFROM generic_inference_jobs as jobs\n\nWHERE jobs.maybe_external_third_party = ?\n AND jobs.status NOT IN ('complete_success', 'complete_failure')\n AND jobs.maybe_external_third_party_id IS NOT NULL\n\nLIMIT 25000\n ",
"query": "\nSELECT\n jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n jobs.maybe_external_third_party_id as `order_id`,\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 jobs.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,\n jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`\n\nFROM generic_inference_jobs as jobs\n\nWHERE jobs.maybe_external_third_party = ?\n AND jobs.status NOT IN ('complete_success', 'complete_failure')\n AND jobs.maybe_external_third_party_id IS NOT NULL\n\nLIMIT 25000\n ",
"describe": {
"columns": [
{
@@ -72,16 +72,6 @@
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 7,
"name": "maybe_wallet_ledger_entry_token: tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
}
],
"parameters": {
@@ -94,9 +84,8 @@
true,
false,
false,
true,
true
]
},
"hash": "cb33fd483f0fef7ec2a1f5950218f312a19e38c7971b88db949ccaa05bf7b7c4"
"hash": "ca96942d8214edb84f163cf40131ceed873209e86718054e1502219fc81d2dc6"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO generic_inference_jobs\nSET\n token = ?,\n uuid_idempotency_token = ?,\n\n job_type = ?,\n\n maybe_external_third_party = ?,\n maybe_external_third_party_id = ?,\n\n product_category = ?,\n inference_category = ?,\n\n maybe_model_type = NULL,\n maybe_model_token = NULL,\n\n maybe_input_source_token = NULL,\n maybe_input_source_token_type = NULL,\n\n maybe_download_url = NULL,\n maybe_cover_image_media_file_token = NULL,\n\n maybe_prompt_token = ?,\n\n maybe_wallet_ledger_entry_token = ?,\n\n maybe_raw_inference_text = NULL,\n\n maybe_inference_args = ?,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n creator_ip_address = ?,\n creator_set_visibility = ?,\n\n priority_level = 0,\n is_keepalive_required = FALSE,\n max_duration_seconds = 0,\n\n is_debug_request = FALSE,\n maybe_routing_tag = NULL,\n\n status = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 15
},
"nullable": []
},
"hash": "ee228ab4a079fde6b3683b6cde2a2384d3def27a5d09baa5caf00a521d5d2bcd"
}
Generated
+2 -22
View File
@@ -677,11 +677,10 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "artcraft"
version = "0.8.0"
version = "0.6.0"
dependencies = [
"anyhow",
"artcraft_api_defs",
"artcraft_router",
"base64 0.22.1",
"build_metadata",
"bytes",
@@ -738,7 +737,6 @@ dependencies = [
name = "artcraft_api_defs"
version = "0.0.1"
dependencies = [
"anyhow",
"chrono",
"enums",
"enums_public",
@@ -751,23 +749,6 @@ dependencies = [
"workspace-build-acceleration",
]
[[package]]
name = "artcraft_router"
version = "0.0.1"
dependencies = [
"anyhow",
"artcraft_api_defs",
"log",
"seedance2pro",
"serde",
"serde_derive",
"storyteller_client",
"tokens",
"tokio",
"uuid 1.18.1",
"workspace-build-acceleration",
]
[[package]]
name = "ashpd"
version = "0.9.2"
@@ -11478,7 +11459,6 @@ dependencies = [
"actix_helpers",
"anyhow",
"artcraft_api_defs",
"artcraft_router",
"async-stripe 0.26.0",
"async-trait",
"base64 0.22.1",
@@ -11540,6 +11520,7 @@ dependencies = [
"redis_common",
"redis_schema",
"regex",
"reqwest 0.12.23",
"resend-rs",
"reusable_types",
"rust-s3",
@@ -11571,7 +11552,6 @@ dependencies = [
"utoipa-swagger-ui",
"videos",
"workspace-build-acceleration",
"wreq",
"zip 0.6.6",
]
+2 -4
View File
@@ -72,7 +72,6 @@ members = [
"crates/lib/files/videos",
"crates/lib/http_headers",
"crates/lib/idempotency",
"crates/lib/artcraft_router",
"crates/lib/image_algorithms",
"crates/lib/jobs_common",
"crates/lib/jwt_light",
@@ -208,7 +207,6 @@ workspace-build-acceleration = { path = "crates/workspace-build-acceleration" }
# Setting paths here ensures we don't need to do so in every crate's Cargo.toml
# It makes refactoring much easier.
actix_artcraft = { path = "crates/lib/actix_artcraft" }
artcraft_router = { path = "crates/lib/artcraft_router" }
actix_helpers = { path = "crates/lib/actix_helpers" }
artcraft_api_defs = { path = "crates/api_clients/artcraft/artcraft_api_defs" }
build_metadata = { path = "crates/lib/build_metadata" }
@@ -302,7 +300,7 @@ toml = "0.8.23"
url = { version = "2.5.4", features = ["serde"] }
uuid = "1.18.1"
# NB: wreq gets past fingerprinting, but we have to deal with platform-specific build issues.
# NB(1): wreq gets past fingerprinting, but we have to deal with platform-specific build issues.
#
# Mac:
# brew install --formula cmake pkg-config llvm
@@ -313,5 +311,5 @@ uuid = "1.18.1"
# choco install llvm -y
# choco install nasm -y
wreq-util = "3.0.0-rc.10"
wreq = { version = "6.0.0-rc.28", features = ["json", "query", "form", "multipart", "cookies", "ws", "stream", "gzip", "brotli", "zstd", "deflate"] }
wreq = { version = "6.0.0-rc.28", features = ["json", "query", "multipart", "cookies", "ws", "stream", "gzip", "form"] }
+1 -1
View File
@@ -55,7 +55,7 @@ We haven't abandoned text-to-asset generation for quick prototyping and ideation
| Provider | Features |
|------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Artcraft | Nano Banana, Nano Banana Pro, GPT-Image-1 / 1.5, Seedance 2 / Seedream 4 / 4.5, Flux 1.1 / Kontext, Veo 2 / 3 / 3.1, Kling 1.6 / 2.1 / 2.5 / 2.6, Seedance, Sora 2 / Pro, Hunyuan 3d 2 / 3 |
| Artcraft | Nano Banana, Nano Banana Pro, GPT-Image-1 / 1.5, Seedream 4 / 4.5, Flux 1.1 / Kontext, Veo 2 / 3 / 3.1, Kling 1.6 / 2.1 / 2.5 / 2.6, Seedance, Sora 2 / Pro, Hunyuan 3d 2 / 3 |
| Grok | Grok Imagine, Grok Video |
| Midjourney | Image Gen (all versions) |
| Sora | Sora 1, Sora 2, GPT-Image-1 |
@@ -1,7 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE generic_inference_jobs
DROP COLUMN
maybe_wallet_ledger_entry_token;
@@ -1,7 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE generic_inference_jobs
ADD COLUMN
maybe_wallet_ledger_entry_token VARCHAR(32) DEFAULT NULL;
@@ -1,7 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE wallet_ledger_entries
DROP COLUMN
is_refunded;
@@ -1,7 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE wallet_ledger_entries
ADD COLUMN
is_refunded BOOLEAN NOT NULL DEFAULT FALSE;
@@ -1,7 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE wallet_ledger_entries
DROP COLUMN
maybe_linked_refund_ledger_token;
@@ -1,7 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE wallet_ledger_entries
ADD COLUMN
maybe_linked_refund_ledger_token VARCHAR(32) DEFAULT NULL;
@@ -182,9 +182,6 @@ CREATE TABLE generic_inference_jobs (
is_from_api_user BOOLEAN NOT NULL DEFAULT FALSE,
is_for_twitch BOOLEAN NOT NULL DEFAULT FALSE,
-- If credits were spent for this job, this references the corresponding wallet ledger entry.
maybe_wallet_ledger_entry_token VARCHAR(32) DEFAULT NULL,
-- ========== DEVELOPMENT AND DEBUGGING METADATA ==========
-- If true, the request gets a "debug" flag, which may do different
@@ -40,12 +40,6 @@ CREATE TABLE wallet_ledger_entries (
-- Balance of monthly credits after the event.
monthly_credits_after INTEGER UNSIGNED NOT NULL DEFAULT 0,
-- Whether this ledger entry has been refunded.
is_refunded BOOLEAN NOT NULL DEFAULT FALSE,
-- If this entry was refunded, this points to the refund ledger entry.
maybe_linked_refund_ledger_token VARCHAR(32) DEFAULT NULL,
-- ========== RECORD TIMESTAMPS ==========
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-1
View File
@@ -202,7 +202,6 @@ RUN echo -n ${GIT_SHA} > GIT_SHA
# Copy all the binaries (except those that need a GPU):
COPY --from=builder /tmp/target/release/storyteller-web /
COPY --from=builder /tmp/target/release/seedance2-pro-job /
COPY --from=builder /tmp/target/release/dummy-service /
COPY --from=builder /tmp/target/release/analytics-job /
COPY --from=builder /tmp/target/release/email-sender-job /
@@ -36,4 +36,3 @@ serde_json.workspace = true
utoipa = { version = "4", features = ["chrono", "actix_extras"] }
[dev-dependencies]
anyhow.workspace = true
@@ -1,122 +0,0 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_video_model::CommonVideoModel;
use enums::common::generation::common_video_resolution::CommonVideoResolution;
use enums::common::generation_provider::GenerationProvider;
pub const ESTIMATE_VIDEO_COST_PATH: &str = "/v1/generate/cost_estimate/video";
/// Request body for the video cost estimate endpoint.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct EstimateVideoCostRequest {
/// The video model to estimate costs for.
pub model: CommonVideoModel,
/// The provider to route the generation through.
pub provider: GenerationProvider,
/// The type of generation (determines how many input images are involved).
/// This is a tagged enum, so it looks like:
/// "generation_mode": {"type": "text_to_video"}
/// "generation_mode": {"type": "reference_image_to_video", "count": 1}
pub generation_mode: GenerationMode,
/// Optional aspect ratio.
pub aspect_ratio: Option<CommonAspectRatio>,
/// Optional resolution.
pub resolution: Option<CommonVideoResolution>,
/// Duration in seconds.
pub duration_seconds: Option<u16>,
/// Number of videos to generate in parallel.
pub video_batch_count: Option<u16>,
}
/// Describes the type of video generation being requested.
#[derive(Serialize, Deserialize, ToSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum GenerationMode {
TextToVideo,
StartFrameToVideo,
StartAndEndFrameToVideo,
ReferenceImageToVideo { count: u32 },
}
/// Response body for the video cost estimate endpoint.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct EstimateVideoCostResponse {
pub success: bool,
/// Estimated cost in credits.
pub cost_in_credits: Option<u64>,
/// Estimated cost in USD cents.
pub cost_in_usd_cents: Option<u64>,
/// Whether the generation is free for this user/plan.
pub is_free: bool,
/// Whether the user has unlimited generations.
pub is_unlimited: bool,
/// Whether the user is rate limited.
pub is_rate_limited: bool,
/// Whether the output will have a watermark.
pub has_watermark: bool,
}
/// Error response for the video cost estimate endpoint.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct EstimateVideoCostError {
pub success: bool,
pub error_type: EstimateVideoCostErrorType,
pub error_message: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum EstimateVideoCostErrorType {
InvalidProviderForModel,
InvalidInput,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_serialization_tagging1() {
let request = EstimateVideoCostRequest {
model: CommonVideoModel::GrokVideo,
provider: GenerationProvider::Artcraft,
generation_mode: GenerationMode::TextToVideo,
aspect_ratio: None,
resolution: None,
duration_seconds: None,
video_batch_count: None,
};
let serialized = serde_json::to_string(&request).unwrap();
assert!(serialized.contains("\"generation_mode\":{\"type\":\"text_to_video\"}"));
//assert_eq!(serialized, "{}");
}
#[test]
fn test_serialization_tagging2() {
let request = EstimateVideoCostRequest {
model: CommonVideoModel::GrokVideo,
provider: GenerationProvider::Artcraft,
generation_mode: GenerationMode::ReferenceImageToVideo { count: 1 },
aspect_ratio: None,
resolution: None,
duration_seconds: None,
video_batch_count: None,
};
let serialized = serde_json::to_string(&request).unwrap();
assert!(serialized.contains("\"generation_mode\":{\"type\":\"reference_image_to_video\",\"count\":1}"));
}
}
@@ -1 +0,0 @@
pub mod estimate_video_cost;
@@ -1,4 +1,3 @@
pub mod cost_estimate;
pub mod image;
pub mod object;
pub mod video;
@@ -33,7 +33,7 @@ pub struct Seedance2p0MultiFunctionVideoGenRequest {
pub batch_count: Option<Seedance2p0BatchCount>,
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[derive(Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum Seedance2p0AspectRatio {
/// 16:9 landscape (1280x720)
@@ -48,7 +48,7 @@ pub enum Seedance2p0AspectRatio {
Portrait3x4,
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[derive(Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum Seedance2p0BatchCount {
One,
@@ -66,20 +66,3 @@ pub struct Seedance2p0MultiFunctionVideoGenResponse {
/// All inference job tokens for the batch (includes `inference_job_token` as the first entry).
pub all_inference_job_tokens: Vec<InferenceJobToken>,
}
#[cfg(test)]
mod tests {
use super::*;
fn aspect_to_string(input: Seedance2p0AspectRatio) -> anyhow::Result<String> {
let val = serde_json::to_string(&input)?;
Ok(val.replace("\"", ""))
}
#[test]
fn just_testing_snake_case() -> anyhow::Result<()> {
// Ugh, not sure what these are...
assert_eq!(aspect_to_string(Seedance2p0AspectRatio::Landscape16x9)?, "landscape16x9");
Ok(())
}
}
@@ -1,4 +1,3 @@
pub mod parse_multi_cookie_header;
pub mod storyteller_avt_cookie;
pub mod storyteller_credential_set;
pub mod storyteller_session_cookie;
@@ -1,35 +0,0 @@
use crate::credentials::storyteller_avt_cookie::StorytellerAvtCookie;
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::credentials::storyteller_session_cookie::StorytellerSessionCookie;
use cookie::Cookie;
/// Parses a multi-cookie header string (e.g. `"visitor=abc; session=xyz"`) and extracts
/// the known Storyteller cookies by name.
///
/// Returns `Ok(Some(...))` if at least one recognised cookie is present,
/// `Ok(None)` if the input is empty or contains no recognised cookies,
/// and `Err` if any individual cookie segment fails to parse.
pub fn parse_multi_cookie_header(header: &str) -> Result<Option<StorytellerCredentialSet>, cookie::ParseError> {
let mut avt: Option<StorytellerAvtCookie> = None;
let mut session: Option<StorytellerSessionCookie> = None;
for segment in header.split(';') {
let segment = segment.trim();
if segment.is_empty() {
continue;
}
let cookie = Cookie::parse(segment.to_owned())?;
if avt.is_none() {
avt = StorytellerAvtCookie::maybe_from_cookie(&cookie);
}
if session.is_none() {
session = StorytellerSessionCookie::maybe_from_cookie(&cookie);
}
}
if avt.is_none() && session.is_none() {
return Ok(None);
}
Ok(Some(StorytellerCredentialSet::initialize(avt, session)))
}
@@ -1,4 +1,3 @@
use crate::credentials::parse_multi_cookie_header::parse_multi_cookie_header;
use crate::credentials::storyteller_avt_cookie::StorytellerAvtCookie;
use crate::credentials::storyteller_session_cookie::StorytellerSessionCookie;
@@ -26,10 +25,6 @@ impl StorytellerCredentialSet {
}
}
pub fn parse_multi_cookie_header(header: &str) -> Result<Option<Self>, cookie::ParseError> {
parse_multi_cookie_header(header)
}
pub fn initialize_with_just_cookie(session: StorytellerSessionCookie) -> Self {
Self {
avt: None,
@@ -18,9 +18,11 @@ use enums::no_table::style_transfer::style_transfer_name::StyleTransferName;
use enums_public::by_table::model_weights::public_weights_types::PublicWeightsType;
use idempotency::uuid::generate_random_uuid;
use log::debug;
use reqwest::multipart::{Form, Part};
use reqwest::multipart::Form;
use reqwest::Client;
use serde_derive::{Deserialize, Serialize};
use std::fs::File;
use std::io::Read;
use std::path::Path;
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::media_files::MediaFileToken;
@@ -71,13 +73,11 @@ pub async fn legacy_upload_media_file_from_file<P: AsRef<Path>>(
.build()
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
let file_bytes = std::fs::read(args.path.as_ref())
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
let file_name = args.path.as_ref().file_name()
.and_then(|n| n.to_str()).unwrap_or("file").to_string();
let mut form = Form::new()
.text("uuid_idempotency_token", generate_random_uuid())
.part("file", Part::bytes(file_bytes).file_name(file_name));
.file("file", args.path)
.await
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
//if args.is_intermediate_system_file {
// form = form.text("is_intermediate_system_file", "true");
@@ -18,9 +18,11 @@ use enums::no_table::style_transfer::style_transfer_name::StyleTransferName;
use enums_public::by_table::model_weights::public_weights_types::PublicWeightsType;
use idempotency::uuid::generate_random_uuid;
use log::debug;
use reqwest::multipart::{Form, Part};
use reqwest::multipart::Form;
use reqwest::Client;
use serde_derive::{Deserialize, Serialize};
use std::fs::File;
use std::io::Read;
use std::path::Path;
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::media_files::MediaFileToken;
@@ -71,13 +73,11 @@ pub async fn upload_image_media_file_from_file<P: AsRef<Path>>(
.build()
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
let file_bytes = std::fs::read(args.path.as_ref())
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
let file_name = args.path.as_ref().file_name()
.and_then(|n| n.to_str()).unwrap_or("file").to_string();
let mut form = Form::new()
.text("uuid_idempotency_token", generate_random_uuid())
.part("file", Part::bytes(file_bytes).file_name(file_name));
.file("file", args.path)
.await
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
if args.is_intermediate_system_file {
form = form.text("is_intermediate_system_file", "true");
@@ -15,9 +15,11 @@ use enums::no_table::style_transfer::style_transfer_name::StyleTransferName;
use enums_public::by_table::model_weights::public_weights_types::PublicWeightsType;
use idempotency::uuid::generate_random_uuid;
use log::debug;
use reqwest::multipart::{Form, Part};
use reqwest::multipart::Form;
use reqwest::Client;
use serde_derive::{Deserialize, Serialize};
use std::fs::File;
use std::io::Read;
use std::path::Path;
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::media_files::MediaFileToken;
@@ -42,13 +44,11 @@ pub async fn upload_new_engine_asset_from_file<P: AsRef<Path>>(
.gzip(true)
.build()?;
let file_bytes = std::fs::read(path.as_ref())?;
let file_name = path.as_ref().file_name()
.and_then(|n| n.to_str()).unwrap_or("file").to_string();
let form = Form::new()
.text("uuid_idempotency_token", generate_random_uuid())
.text("engine_category", "object") // TODO: Which type should we use?
.part("file", Part::bytes(file_bytes).file_name(file_name));
.text("engine_category", "object") // TODO: Which type should we use?
.file("file", path)
.await?;
let mut request_builder = client.post(url)
.header("User-Agent", USER_AGENT)
@@ -15,9 +15,11 @@ use enums::no_table::style_transfer::style_transfer_name::StyleTransferName;
use enums_public::by_table::model_weights::public_weights_types::PublicWeightsType;
use idempotency::uuid::generate_random_uuid;
use log::debug;
use reqwest::multipart::{Form, Part};
use reqwest::multipart::Form;
use reqwest::Client;
use serde_derive::{Deserialize, Serialize};
use std::fs::File;
use std::io::Read;
use std::path::Path;
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::media_files::MediaFileToken;
@@ -72,12 +74,10 @@ pub async fn upload_video_media_file_from_file<P: AsRef<Path>>(
.gzip(true)
.build()?;
let file_bytes = std::fs::read(args.path.as_ref())?;
let file_name = args.path.as_ref().file_name()
.and_then(|n| n.to_str()).unwrap_or("file").to_string();
let mut form = Form::new()
.text("uuid_idempotency_token", generate_random_uuid())
.part("file", Part::bytes(file_bytes).file_name(file_name));
.file("file", args.path)
.await?;
if let Some(prompt_token) = &args.maybe_prompt_token {
form = form.text("maybe_prompt_token", prompt_token.to_string());
@@ -48,16 +48,6 @@ pub async fn basic_json_delete_request<Req: Serialize, Res: DeserializeOwned>(
.await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
let hostname = response.headers().get("x-backend-hostname")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_owned());
let build_sha = response.headers().get("x-build-sha")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_owned());
debug!("[server] x-backend-hostname: {:?}, x-build-sha: {:?}", hostname, build_sha);
let response = filter_bad_response(response)
.await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
@@ -5,7 +5,7 @@ use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::filter_bad_response::filter_bad_response;
use log::{debug, info};
use log::debug;
use reqwest::Client;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
@@ -40,16 +40,6 @@ pub async fn basic_json_get_request<Res: DeserializeOwned>(
.await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
let hostname = response.headers().get("x-backend-hostname")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_owned());
let build_sha = response.headers().get("x-build-sha")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_owned());
debug!("[server] x-backend-hostname: {:?}, x-build-sha: {:?}", hostname, build_sha);
let response = filter_bad_response(response)
.await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
@@ -48,16 +48,6 @@ pub async fn basic_json_post_request<Req: Serialize, Res: DeserializeOwned>(
.await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
let hostname = response.headers().get("x-backend-hostname")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_owned());
let build_sha = response.headers().get("x-build-sha")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_owned());
debug!("[server] x-backend-hostname: {:?}, x-build-sha: {:?}", hostname, build_sha);
let response = filter_bad_response(response)
.await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
@@ -46,16 +46,6 @@ pub async fn basic_query_string_post_request<Res: DeserializeOwned>(
.await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
let hostname = response.headers().get("x-backend-hostname")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_owned());
let build_sha = response.headers().get("x-build-sha")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_owned());
debug!("[server] x-backend-hostname: {:?}, x-build-sha: {:?}", hostname, build_sha);
let response = filter_bad_response(response)
.await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
@@ -160,10 +160,10 @@ pub async fn generate_video(args: GenerateVideoArgs<'_>) -> Result<GenerateVideo
api_params: ApiParams {
prompt: args.prompt,
resolution: args.resolution.as_str().to_string(),
content_mode: "normal",
mode: "normal",
model: "seedance-20",
duration,
mode: video_input_mode,
video_input_mode,
uploaded_urls,
batch_count,
},
@@ -344,7 +344,7 @@ mod tests {
session: &session,
prompt: "A dog shakes the glasses off its head. The camera pans out as the shiba shakes. The shiba barks.".to_string(),
resolution: Resolution::Landscape16x9,
duration_seconds: 5,
duration_seconds: 15,
batch_count: BatchCount::One,
start_frame_url: Some("https://static.seedance2-pro.com/materials/20260219/1771496300184-fb32e08c.jpg".to_string()),
end_frame_url: None,
@@ -365,7 +365,7 @@ mod tests {
let session = test_session()?;
let args = GenerateVideoArgs {
session: &session,
prompt: "The dog in @2 is in the office at @1 without the man. The office is dark and moonlight streams in through the windows. Particles of dust gleam in the moon beams. Suddenly, the dog jumps walks in front of the desk and barks.".to_string(),
prompt: "The dog in @2 is in the office at @1 without the man. The office is dark and moonlight streams in through the windows. Particles of dust gleam in the moon beams. Suddenly, the dog jumps on the desk and barks.".to_string(),
resolution: Resolution::Standard4x3,
duration_seconds: 10,
batch_count: BatchCount::One,
@@ -23,11 +23,11 @@ pub (super) struct BatchRequestJson {
pub (super) struct ApiParams {
pub prompt: String,
pub resolution: String,
#[serde(rename = "contentMode")]
pub content_mode: &'static str,
pub mode: &'static str,
pub model: &'static str,
pub duration: String,
pub mode: &'static str,
#[serde(rename = "videoInputMode")]
pub video_input_mode: &'static str,
#[serde(rename = "uploadedUrls", skip_serializing_if = "Option::is_none")]
pub uploaded_urls: Option<Vec<String>>,
#[serde(rename = "batchCount", skip_serializing_if = "Option::is_none")]
@@ -79,9 +79,8 @@ pub struct VideoResult {
pub url: String,
pub width: u32,
pub height: u32,
// NB: We don't need these.
// /// Width / height ratio (e.g. 1.777… for 16:9). `None` when the server returns null (e.g. width/height are 0).
// pub ratio: Option<f64>,
/// Width / height ratio (e.g. 1.777… for 16:9).
pub ratio: f64,
}
/// The status of one order (one video generation task).
@@ -180,7 +179,7 @@ pub async fn poll_orders(args: PollOrdersArgs<'_>) -> Result<PollOrdersResponse,
url: r.url,
width: r.width,
height: r.height,
// ratio: r.ratio,
ratio: r.ratio,
}).collect(),
fail_reason: o.fail_reason,
created_at: o.created_at,
@@ -38,5 +38,5 @@ pub(super) struct RawVideoResult {
pub url: String,
pub width: u32,
pub height: u32,
// pub ratio: Option<f64>,
pub ratio: f64,
}
+1 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "artcraft"
version = "0.8.0"
version = "0.6.0"
description = "Artcraft"
authors = ["you"]
license = ""
@@ -27,7 +27,6 @@ workspace-build-acceleration.workspace = true
# Internal
artcraft_api_defs.workspace = true
artcraft_router.workspace = true
build_metadata.workspace = true
cookie_store.workspace = true
enums.workspace = true
@@ -1,7 +1,5 @@
use crate::core::artcraft_error::ArtcraftError;
use anyhow::anyhow;
use artcraft_router::errors::artcraft_router_error::ArtcraftRouterError;
use artcraft_router::errors::provider_error::ProviderError;
use base64::DecodeError;
use enums::common::generation_provider::GenerationProvider;
use enums::common::model_type::ModelType;
@@ -213,16 +211,3 @@ impl From<WorldLabsError> for GenerateError {
Self::ProviderFailure(ProviderFailureReason::WorldLabsError(value))
}
}
impl From<ArtcraftRouterError> for GenerateError {
fn from(value: ArtcraftRouterError) -> Self {
match value {
ArtcraftRouterError::Client(e) => Self::BadInput(BadInputReason::WrongImageArguments(e.to_string())),
ArtcraftRouterError::UnsupportedModel(model) => Self::NotYetImplemented(format!("Unsupported model: {}", model)),
ArtcraftRouterError::InvalidInput(msg) => Self::BadInput(BadInputReason::WrongImageArguments(msg)),
ArtcraftRouterError::Provider(ProviderError::Storyteller(e)) => {
Self::ProviderFailure(ProviderFailureReason::StorytellerError(e))
}
}
}
}
@@ -1,59 +1,51 @@
use crate::core::commands::enqueue::generate_error::GenerateError;
use crate::core::commands::enqueue::image_to_video::enqueue_image_to_video_command::EnqueueImageToVideoRequest;
use crate::core::commands::enqueue::image_to_video::enqueue_image_to_video_command::{EnqueueImageToVideoRequest, SoraOrientation};
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::events::generation_events::common::GenerationModel;
use crate::core::events::generation_events::common::{GenerationModel};
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use artcraft_router::api::common_video_model::CommonVideoModel;
use artcraft_router::api::image_list_ref::ImageListRef;
use artcraft_router::api::image_ref::ImageRef;
use artcraft_router::api::provider::Provider;
use artcraft_router::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use artcraft_router::client::router_artcraft_client::RouterArtcraftClient;
use artcraft_router::client::router_client::RouterClient;
use artcraft_router::generate::generate_video::begin_video_generation::begin_video_generation;
use artcraft_router::generate::generate_video::generate_video_request::GenerateVideoRequest;
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{Seedance2p0AspectRatio, Seedance2p0MultiFunctionVideoGenRequest};
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_type::TaskType;
use idempotency::uuid::generate_random_uuid;
use log::{error, info};
use storyteller_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use storyteller_client::endpoints::generate::video::multi_function::seedance_2p0_multi_function_video_gen::seedance_2p0_multi_function_video_gen;
pub(super) async fn handle_artcraft_seedance_2p0(
pub (super) async fn handle_artcraft_seedance_2p0(
request: &EnqueueImageToVideoRequest,
app_env_configs: &AppEnvConfigs,
creds: &StorytellerCredentialSet,
) -> Result<TaskEnqueueSuccess, GenerateError> {
let client = RouterClient::Artcraft(RouterArtcraftClient::new(
app_env_configs.storyteller_host.clone(),
creds.clone(),
));
let start_frame = request.image_media_token.as_ref().map(ImageRef::MediaFileToken);
let end_frame = request.end_frame_image_media_token.as_ref().map(ImageRef::MediaFileToken);
let reference_images = request.reference_image_media_tokens.as_ref().map(ImageListRef::MediaFileTokens);
let uuid_idempotency_token = generate_random_uuid();
let router_request = GenerateVideoRequest {
model: CommonVideoModel::Seedance2p0,
provider: Provider::Artcraft,
prompt: request.prompt.as_deref(),
start_frame,
end_frame,
reference_images,
resolution: None,
aspect_ratio: request.aspect_ratio,
duration_seconds: request.duration_seconds,
video_batch_count: request.video_batch_count,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
idempotency_token: None,
let aspect_ratio = match request.sora_orientation {
Some(SoraOrientation::Portrait) => Seedance2p0AspectRatio::Portrait9x16,
Some(SoraOrientation::Landscape) => Seedance2p0AspectRatio::Landscape16x9,
None => Seedance2p0AspectRatio::Landscape16x9,
};
let plan = begin_video_generation(&router_request)?;
info!("Video Generation Plan: {:?}", plan);
let request = Seedance2p0MultiFunctionVideoGenRequest {
uuid_idempotency_token,
prompt: request.prompt.clone(),
start_frame_media_token: request.image_media_token.clone(),
end_frame_media_token: request.end_frame_image_media_token.clone(),
reference_image_media_tokens: None,
aspect_ratio: Some(aspect_ratio),
duration_seconds: None, // TODO: Parameterize
batch_count: None,
};
let response = match plan.generate_video(&client).await {
Ok(resp) => {
let result = seedance_2p0_multi_function_video_gen(
&app_env_configs.storyteller_host,
Some(&creds),
request,
).await;
let job_token = match result {
Ok(enqueued) => {
info!("Successfully enqueued.");
resp
enqueued.inference_job_token
}
Err(err) => {
error!("Failed to enqueue: {:?}", err);
@@ -65,6 +57,6 @@ pub(super) async fn handle_artcraft_seedance_2p0(
task_type: TaskType::VideoGeneration,
model: Some(GenerationModel::Seedance2p0),
provider: GenerationProvider::Artcraft,
provider_job_id: Some(response.inference_job_token.to_string()),
provider_job_id: Some(job_token.to_string()),
})
}
@@ -22,7 +22,6 @@ use crate::services::grok::state::grok_credential_manager::GrokCredentialManager
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use artcraft_router::api::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::ux::tauri_command_caller::TauriCommandCaller;
use log::{error, info, warn};
@@ -102,10 +101,6 @@ pub struct EnqueueImageToVideoRequest {
/// TODO: In the future we may support base64 images, URLs, or file paths here.
pub end_frame_image_media_token: Option<MediaFileToken>,
/// OPTIONAL.
/// Reference images for the video generation (e.g. character consistency, props, etc.)
pub reference_image_media_tokens: Option<Vec<MediaFileToken>>,
/// Optional.
/// Text prompt used to direct the video.
pub prompt: Option<String>,
@@ -115,18 +110,6 @@ pub struct EnqueueImageToVideoRequest {
/// Typically, this costs money when presented as an option, so we default to off.
pub generate_audio: Option<bool>,
/// OPTIONAL.
/// Aspect ratio for the generated video.
pub aspect_ratio: Option<CommonAspectRatio>,
/// OPTIONAL.
/// Duration of the generated video in seconds.
pub duration_seconds: Option<u16>,
/// OPTIONAL.
/// Number of videos to generate in one batch.
pub video_batch_count: Option<u16>,
/// OPTIONAL.
/// Only for Sora2 model currently.
pub sora_orientation: Option<SoraOrientation>,
+1 -1
View File
@@ -1,4 +1,4 @@
/// The version of the ArtCraft desktop app reported in the UI and via Analytics.
/// This should match the CI builds.
pub const ARTCRAFT_VERSION: &str = "0.8.0";
pub const ARTCRAFT_VERSION: &str = "0.6.0";
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
"productName": "ArtCraft",
"version": "0.8.0",
"version": "0.6.0",
"identifier": "ai.artcraft.app",
"build": {
"frontendDist": "../../../frontend/apps/artcraft/dist",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
"productName": "ArtCraft",
"version": "0.8.0",
"version": "0.6.0",
"identifier": "ai.artcraft.app",
"build": {
"frontendDist": "../../../frontend/apps/artcraft/dist",
-30
View File
@@ -1,30 +0,0 @@
[package]
name = "artcraft_router"
edition = "2024"
version = "0.0.1"
publish = false
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[lib]
name = "artcraft_router"
path = "src/lib.rs"
[dependencies]
# cargo-hakari workspace hack (faster compile times)
workspace-build-acceleration.workspace = true
# Internal
artcraft_api_defs.workspace = true
seedance2pro.workspace = true
storyteller_client.workspace = true
tokens.workspace = true
# External
anyhow.workspace = true
log.workspace = true
serde.workspace = true
serde_derive.workspace = true
tokio.workspace = true
uuid.workspace = true
@@ -1,42 +0,0 @@
use serde_derive::{Deserialize, Serialize};
/// This is a comprehensive list of common aspect ratios you can specify when enqueuing a generation.
/// Not every model will support every aspect ratio.
/// In the case a model doesn't support the aspect ratio, gracefully pick the nearest option.
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CommonAspectRatio {
// Auto (eg. for image editing to use the source; used by Nano Banana Pro edit image, but not text-to-image)
Auto,
// Square
Square,
// Wide
WideThreeByTwo,
WideFourByThree,
WideFiveByFour,
WideSixteenByNine,
WideTwentyOneByNine,
// Tall
TallTwoByThree,
TallThreeByFour,
TallFourByFive,
TallNineBySixteen,
TallNineByTwentyOne,
// Imprecise semantic values that we probably remap to other meanings
// on a model-by-model basis.
Wide,
Tall,
// Auto values that bake in resolution
// These are from the Seedream models
Auto2k,
Auto4k,
// Defined aspect ratios that bake in resolution
// These are from the Seedream models
SquareHd,
}
@@ -1,17 +0,0 @@
use serde_derive::{Deserialize, Serialize};
/// This is a comprehensive list of common resolutions you can specify when enqueuing a generation.
/// Not every model will support every resolution.
/// In the case a model doesn't support the resolution, gracefully pick the nearest option.
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CommonVideoResolution {
/// Models: Nano Banana Pro
OneK,
/// Models: Nano Banana Pro
TwoK,
/// Models: (None)
ThreeK,
/// Models: Nano Banana Pro
FourK,
}
@@ -1,52 +0,0 @@
use serde_derive::{Deserialize, Serialize};
/// Common video models supported by the router.
/// Not all models are available through all providers.
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CommonVideoModel {
#[serde(rename = "grok_video")]
GrokVideo,
#[serde(rename = "kling_1p6_pro")]
Kling16Pro,
#[serde(rename = "kling_2p1_pro")]
Kling21Pro,
#[serde(rename = "kling_2p1_master")]
Kling21Master,
#[serde(rename = "kling_2p5_turbo_pro")]
Kling2p5TurboPro,
#[serde(rename = "kling_2p6_pro")]
Kling2p6Pro,
#[serde(rename = "seedance_1p0_lite")]
Seedance10Lite,
#[serde(rename = "seedance_2p0")]
Seedance2p0,
#[serde(rename = "sora_2")]
Sora2,
#[serde(rename = "sora_2_pro")]
Sora2Pro,
#[serde(rename = "veo_2")]
Veo2,
#[serde(rename = "veo_3")]
Veo3,
#[serde(rename = "veo_3_fast")]
Veo3Fast,
#[serde(rename = "veo_3p1")]
Veo3p1,
#[serde(rename = "veo_3p1_fast")]
Veo3p1Fast,
}
@@ -1,7 +0,0 @@
use tokens::tokens::media_files::MediaFileToken;
#[derive(Copy, Clone)]
pub enum ImageListRef<'a> {
MediaFileTokens(&'a Vec<MediaFileToken>),
Urls(&'a Vec<String>),
}
@@ -1,7 +0,0 @@
use tokens::tokens::media_files::MediaFileToken;
#[derive(Copy, Clone)]
pub enum ImageRef<'a> {
MediaFileToken(&'a MediaFileToken),
Url(&'a str),
}
@@ -1,6 +0,0 @@
pub mod common_aspect_ratio;
pub mod common_resolution;
pub mod common_video_model;
pub mod image_list_ref;
pub mod image_ref;
pub mod provider;
@@ -1,8 +0,0 @@
use serde_derive::{Deserialize, Serialize};
/// The provider to route a generation request to.
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Provider {
Artcraft,
}
@@ -1,5 +0,0 @@
pub mod multi_router_client;
pub mod multi_router_client_builder;
pub mod request_mismatch_mitigation_strategy;
pub mod router_artcraft_client;
pub mod router_client;
@@ -1,13 +0,0 @@
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::client_error::{ClientError, ClientType};
pub struct MultiRouterClient {
pub(crate) artcraft_client: Option<RouterArtcraftClient>,
}
impl MultiRouterClient {
pub fn get_artcraft_client_ref(&self) -> Result<&RouterArtcraftClient, ClientError> {
self.artcraft_client.as_ref()
.ok_or(ClientError::ClientNotConfigured(ClientType::Artcraft))
}
}
@@ -1,25 +0,0 @@
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::multi_router_client::MultiRouterClient;
pub struct MultiRouterClientBuilder {
artcraft_client: Option<RouterArtcraftClient>,
}
impl MultiRouterClientBuilder {
pub fn new() -> Self {
Self {
artcraft_client: None,
}
}
pub fn set_artcraft_client(mut self, client: RouterArtcraftClient) -> Self {
self.artcraft_client = Some(client);
self
}
pub fn build(self) -> MultiRouterClient {
MultiRouterClient {
artcraft_client: self.artcraft_client,
}
}
}
@@ -1,12 +0,0 @@
#[derive(Copy, Clone, Debug)]
pub enum RequestMismatchMitigationStrategy {
/// Upgrade the request to pay more.
PayMoreUpgrade,
/// Downgrade the request to pay less
PayLessDowngrade,
/// Fail the request.
ErrorOut,
}
@@ -1,13 +0,0 @@
use storyteller_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use storyteller_client::utils::api_host::ApiHost;
pub struct RouterArtcraftClient {
pub(crate) api_host: ApiHost,
pub(crate) credentials: StorytellerCredentialSet,
}
impl RouterArtcraftClient {
pub fn new(api_host: ApiHost, credentials: StorytellerCredentialSet) -> Self {
Self { api_host, credentials }
}
}
@@ -1,17 +0,0 @@
use crate::client::multi_router_client::MultiRouterClient;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::client_error::ClientError;
pub enum RouterClient {
Multi(MultiRouterClient),
Artcraft(RouterArtcraftClient),
}
impl RouterClient {
pub fn get_artcraft_client_ref(&self) -> Result<&RouterArtcraftClient, ClientError> {
match self {
RouterClient::Artcraft(client) => Ok(client),
RouterClient::Multi(multi) => multi.get_artcraft_client_ref(),
}
}
}
@@ -1,44 +0,0 @@
use crate::errors::client_error::ClientError;
use crate::errors::provider_error::ProviderError;
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum ArtcraftRouterError {
/// A client configuration error.
Client(ClientError),
/// The requested model is not yet supported by the router.
UnsupportedModel(String),
/// Invalid or missing input arguments.
InvalidInput(String),
/// An error from an underlying provider.
Provider(ProviderError),
}
impl Error for ArtcraftRouterError {}
impl Display for ArtcraftRouterError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Client(e) => write!(f, "Client error: {}", e),
Self::UnsupportedModel(model) => write!(f, "Unsupported model: {}", model),
Self::InvalidInput(msg) => write!(f, "Invalid input: {}", msg),
Self::Provider(e) => write!(f, "Provider error: {}", e),
}
}
}
impl From<ClientError> for ArtcraftRouterError {
fn from(error: ClientError) -> Self {
Self::Client(error)
}
}
impl From<ProviderError> for ArtcraftRouterError {
fn from(error: ProviderError) -> Self {
Self::Provider(error)
}
}
@@ -1,52 +0,0 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug, Clone, Copy)]
pub enum ClientType {
Artcraft,
}
impl Display for ClientType {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Artcraft => write!(f, "Artcraft"),
}
}
}
#[derive(Debug)]
pub enum ClientError {
/// The requested client is not configured on the RouterClient.
ClientNotConfigured(ClientType),
/// The model does not support the given option value.
/// `field` is the request field name, `value` is what was requested.
ModelDoesNotSupportOption { field: &'static str, value: String },
/// The caller requested zero generations, which is never valid.
UserRequestedZeroGenerations,
/// ArtCraft only accepts media tokens for image inputs, not raw URLs.
ArtcraftOnlySupportsMediaTokens,
}
impl Error for ClientError {}
impl Display for ClientError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::ClientNotConfigured(client_type) => {
write!(f, "{} client is not configured on the RouterClient", client_type)
}
Self::ModelDoesNotSupportOption { field, value } => {
write!(f, "Model does not support '{}' for field '{}'", value, field)
}
Self::UserRequestedZeroGenerations => {
write!(f, "Cannot request zero generations")
}
Self::ArtcraftOnlySupportsMediaTokens => {
write!(f, "ArtCraft only supports media tokens for image inputs; upload the image first to obtain a media token")
}
}
}
}
@@ -1,3 +0,0 @@
pub mod artcraft_router_error;
pub mod client_error;
pub mod provider_error;
@@ -1,24 +0,0 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
use storyteller_client::error::storyteller_error::StorytellerError;
#[derive(Debug)]
pub enum ProviderError {
Storyteller(StorytellerError),
}
impl Error for ProviderError {}
impl Display for ProviderError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Storyteller(e) => write!(f, "Storyteller provider error: {}", e),
}
}
}
impl From<StorytellerError> for ProviderError {
fn from(error: StorytellerError) -> Self {
Self::Storyteller(error)
}
}
@@ -1,20 +0,0 @@
use crate::api::common_video_model::CommonVideoModel;
use crate::api::provider::Provider;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance2p0::plan_generate_video_artcraft_seedance2p0;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
/// Read the video generation request, construct a plan, then yield a means to execute it.
pub fn begin_video_generation<'a>(
request: &'a GenerateVideoRequest<'a>,
) -> Result<VideoGenerationPlan<'a>, ArtcraftRouterError> {
match request.provider {
Provider::Artcraft => match request.model {
CommonVideoModel::Seedance2p0 => {
plan_generate_video_artcraft_seedance2p0(request).map(VideoGenerationPlan::ArtcraftSeedance2p0)
}
_ => Err(ArtcraftRouterError::UnsupportedModel(format!("{:?}", request.model))),
},
}
}
@@ -1,97 +0,0 @@
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0BatchCount;
use seedance2pro::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro::requests::generate_video::generate_video::{BatchCount, GenerateVideoArgs, Resolution};
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance2p0::PlanArtcraftSeedance2p0;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
pub (crate) fn estimate_video_cost_artcraft_seedance2p0(
plan: &PlanArtcraftSeedance2p0<'_>,
) -> VideoGenerationCostEstimate {
let duration_seconds = plan.duration_seconds.unwrap_or(5).clamp(4, 15);
let batch_count = match plan.batch_count {
Seedance2p0BatchCount::One => BatchCount::One,
Seedance2p0BatchCount::Two => BatchCount::Two,
Seedance2p0BatchCount::Four => BatchCount::Four,
};
// TODO: Make a better client that doesn't require this.
// A dummy session is sufficient — cost estimation does not make any network calls.
let dummy_session = Seedance2ProSession::from_cookies_string(String::new());
let args = GenerateVideoArgs {
session: &dummy_session,
prompt: String::new(),
resolution: Resolution::Square1x1,
duration_seconds,
batch_count,
// NB: These do not contribute to costs in the Seedance2 integration
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
};
let cost_in_usd_cents = args.estimate_cost_in_usd_cents();
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
}
}
#[cfg(test)]
mod tests {
use crate::api::common_video_model::CommonVideoModel;
use crate::api::provider::Provider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_video::begin_video_generation::begin_video_generation;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
fn estimate_usd_cents(duration_seconds: u16, video_batch_count: u16) -> u64 {
let request = GenerateVideoRequest {
model: CommonVideoModel::Seedance2p0,
provider: Provider::Artcraft,
prompt: None,
start_frame: None,
end_frame: None,
reference_images: None,
resolution: None,
aspect_ratio: None,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
idempotency_token: None,
};
begin_video_generation(&request)
.expect("begin_video_generation should succeed")
.estimate_costs()
.cost_in_usd_cents
.expect("cost_in_usd_cents should be present")
}
#[test]
fn test_estimate_cost_usd_cents() {
// Batch 1:
assert_eq!(estimate_usd_cents(4, 1), 64);
assert_eq!(estimate_usd_cents(5, 1), 80);
assert_eq!(estimate_usd_cents(6, 1), 96);
assert_eq!(estimate_usd_cents(7, 1), 112);
assert_eq!(estimate_usd_cents(15, 1), 240);
// Batch 2 = 2×
assert_eq!(estimate_usd_cents(4, 2), 128);
assert_eq!(estimate_usd_cents(5, 2), 160);
assert_eq!(estimate_usd_cents(15, 2), 480);
// Batch 4 = 3× (not 4×)
assert_eq!(estimate_usd_cents(4, 4), 192);
assert_eq!(estimate_usd_cents(5, 4), 240);
assert_eq!(estimate_usd_cents(15, 4), 720);
}
}
@@ -1 +0,0 @@
pub mod estimate_video_cost_artcraft_seedance2p0;
@@ -1 +0,0 @@
pub (crate) mod artcraft;
@@ -1,36 +0,0 @@
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance2p0::PlanArtcraftSeedance2p0;
use crate::generate::generate_video::video_generation_plan::GenerateVideoResponse;
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0MultiFunctionVideoGenRequest;
use storyteller_client::endpoints::generate::video::multi_function::seedance_2p0_multi_function_video_gen::seedance_2p0_multi_function_video_gen;
pub async fn execute_artcraft_seedance2p0(
plan: &PlanArtcraftSeedance2p0<'_>,
artcraft_client: &RouterArtcraftClient,
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let request = Seedance2p0MultiFunctionVideoGenRequest {
uuid_idempotency_token: plan.idempotency_token.clone(),
prompt: plan.prompt.map(|p| p.to_string()),
start_frame_media_token: plan.start_frame.map(|t| t.to_owned()),
end_frame_media_token: plan.end_frame.map(|t| t.to_owned()),
reference_image_media_tokens: plan.reference_images.map(|tokens| tokens.to_owned()),
aspect_ratio: plan.aspect_ratio,
duration_seconds: plan.duration_seconds,
batch_count: Some(plan.batch_count),
};
let response = seedance_2p0_multi_function_video_gen(
&artcraft_client.api_host,
Some(&artcraft_client.credentials),
request,
)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
Ok(GenerateVideoResponse {
inference_job_token: response.inference_job_token,
all_inference_job_tokens: response.all_inference_job_tokens,
})
}
@@ -1 +0,0 @@
pub mod generate_video_artcraft_seedance2p0;
@@ -1 +0,0 @@
pub mod artcraft;
@@ -1,60 +0,0 @@
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonVideoResolution;
use crate::api::common_video_model::CommonVideoModel;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::provider::Provider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
pub struct GenerateVideoRequest<'a> {
/// Which model to use.
pub model: CommonVideoModel,
/// Which provider to use.
pub provider: Provider,
/// The prompt for the video generation
pub prompt: Option<&'a str>,
// /// Some models support negative prompts
// pub negative_prompt: Option<String>,
/// Starting keyframe (optional).
pub start_frame: Option<ImageRef<'a>>,
/// Ending keyframe (optional).
pub end_frame: Option<ImageRef<'a>>,
/// Reference images (optional).
pub reference_images: Option<ImageListRef<'a>>,
/// The resolution to use
pub resolution: Option<CommonVideoResolution>,
/// The aspect ratio to use
pub aspect_ratio: Option<CommonAspectRatio>,
/// How many seconds to generate.
pub duration_seconds: Option<u16>,
/// How many videos to generate.
pub video_batch_count: Option<u16>,
// /// Whether to turn on/off audio.
// /// Not all models support audio, not all models have a choice.
// pub generate_audio: Option<bool>,
/// If the request is a mismatch with the (model/provider), how to mitigate it.
pub request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy,
/// Some providers support idempotency.
/// If not supplied, we'll generate one for the required providers.
pub idempotency_token: Option<&'a str>,
}
impl <'a> GenerateVideoRequest<'a> {
pub fn get_or_generate_idempotency_token(&self) -> String {
self.idempotency_token.map(|t| t.to_string())
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
}
}
@@ -1,7 +0,0 @@
pub mod begin_video_generation;
pub mod cost;
pub mod execute;
pub mod generate_video_request;
pub mod plan;
pub mod video_generation_cost_estimate;
pub mod video_generation_plan;
@@ -1 +0,0 @@
pub mod plan_generate_video_artcraft_seedance2p0;
@@ -1,183 +0,0 @@
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{
Seedance2p0AspectRatio, Seedance2p0BatchCount,
};
use tokens::tokens::media_files::MediaFileToken;
#[derive(Debug, Clone)]
pub struct PlanArtcraftSeedance2p0<'a> {
pub prompt: Option<&'a str>,
pub start_frame: Option<&'a MediaFileToken>,
pub end_frame: Option<&'a MediaFileToken>,
pub reference_images: Option<&'a Vec<MediaFileToken>>,
pub aspect_ratio: Option<Seedance2p0AspectRatio>,
pub duration_seconds: Option<u8>,
pub batch_count: Seedance2p0BatchCount,
pub idempotency_token: String,
}
pub fn plan_generate_video_artcraft_seedance2p0<'a>(
request: &'a GenerateVideoRequest<'a>,
) -> Result<PlanArtcraftSeedance2p0<'a>, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
let start_frame = resolve_image_ref(request.start_frame)?;
let end_frame = resolve_image_ref(request.end_frame)?;
let reference_images = resolve_image_list_ref(request.reference_images)?;
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
let batch_count = plan_batch_count(request.video_batch_count, strategy)?;
let duration_seconds = plan_duration(request.duration_seconds, strategy)?;
Ok(PlanArtcraftSeedance2p0 {
prompt: request.prompt,
start_frame,
end_frame,
reference_images,
aspect_ratio,
duration_seconds,
batch_count,
idempotency_token: request.get_or_generate_idempotency_token(),
})
}
fn resolve_image_ref<'a>(
image_ref: Option<ImageRef<'a>>,
) -> Result<Option<&'a MediaFileToken>, ArtcraftRouterError> {
match image_ref {
None => Ok(None),
Some(ImageRef::MediaFileToken(t)) => Ok(Some(t)),
Some(ImageRef::Url(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
}
}
fn resolve_image_list_ref<'a>(
image_list_ref: Option<ImageListRef<'a>>,
) -> Result<Option<&'a Vec<MediaFileToken>>, ArtcraftRouterError> {
match image_list_ref {
None => Ok(None),
Some(ImageListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
Some(ImageListRef::Urls(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
}
}
// Supported aspect ratios and their AR values (width / height):
// Portrait9x16 = 0.5625, Portrait3x4 = 0.75, Square1x1 = 1.0, Standard4x3 = 1.33, Landscape16x9 = 1.78
//
// All supported ratios cost the same, so PayMoreUpgrade and PayLessDowngrade both
// select the nearest match rather than rounding in a specific direction.
fn plan_aspect_ratio(
aspect_ratio: Option<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<Seedance2p0AspectRatio>, ArtcraftRouterError> {
match aspect_ratio {
// No preference or auto — let the model decide
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto4k) => Ok(None),
// Direct mappings
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => {
Ok(Some(Seedance2p0AspectRatio::Landscape16x9))
}
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => {
Ok(Some(Seedance2p0AspectRatio::Portrait9x16))
}
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => {
Ok(Some(Seedance2p0AspectRatio::Square1x1))
}
Some(CommonAspectRatio::WideFourByThree) => Ok(Some(Seedance2p0AspectRatio::Standard4x3)),
Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(Seedance2p0AspectRatio::Portrait3x4)),
// Mismatches — apply strategy
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "aspect_ratio",
value: format!("{:?}", unsupported),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(Some(nearest_aspect_ratio(unsupported)))
}
},
}
}
/// Pick the nearest supported aspect ratio by AR value (width / height).
/// All Seedance2p0 aspect ratios cost the same, so this is used for both upgrade and downgrade.
fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> Seedance2p0AspectRatio {
match aspect_ratio {
CommonAspectRatio::WideFiveByFour => Seedance2p0AspectRatio::Standard4x3, // 1.25, nearest 1.33
CommonAspectRatio::WideThreeByTwo => Seedance2p0AspectRatio::Standard4x3, // 1.50, nearest 1.33
CommonAspectRatio::WideTwentyOneByNine => Seedance2p0AspectRatio::Landscape16x9, // 2.33, nearest 1.78
CommonAspectRatio::TallFourByFive => Seedance2p0AspectRatio::Portrait3x4, // 0.80, nearest 0.75
CommonAspectRatio::TallTwoByThree => Seedance2p0AspectRatio::Portrait3x4, // 0.67, nearest 0.75
CommonAspectRatio::TallNineByTwentyOne => Seedance2p0AspectRatio::Portrait9x16, // 0.43, nearest 0.56
_ => Seedance2p0AspectRatio::Square1x1,
}
}
// Seedance2p0 supports batch counts of 1, 2, and 4 only.
fn plan_batch_count(
video_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Seedance2p0BatchCount, ArtcraftRouterError> {
let count = video_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(Seedance2p0BatchCount::One),
2 => Ok(Seedance2p0BatchCount::Two),
4 => Ok(Seedance2p0BatchCount::Four),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "video_batch_count",
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
// Round up: 3 → Four; anything > 4 clamps to Four
Ok(if count < 4 { Seedance2p0BatchCount::Four } else { Seedance2p0BatchCount::Four })
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
// Round down: 3 → Two; anything > 4 clamps to Four (max available)
Ok(if count < 4 { Seedance2p0BatchCount::Two } else { Seedance2p0BatchCount::Four })
}
},
}
}
// Seedance2p0 supports duration of 4–15 seconds.
fn plan_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<u8>, ArtcraftRouterError> {
const MIN: u16 = 4;
const MAX: u16 = 15;
match duration_seconds {
None => Ok(None),
Some(d) if d >= MIN && d <= MAX => Ok(Some(d as u8)),
Some(d) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "duration_seconds",
value: format!("{}", d),
}))
}
_ => Ok(Some(d.clamp(MIN, MAX) as u8)),
},
}
}
@@ -1 +0,0 @@
pub mod artcraft;
@@ -1,9 +0,0 @@
/// The result of estimating the cost of a video generation plan.
pub struct VideoGenerationCostEstimate {
pub cost_in_credits: Option<u64>,
pub cost_in_usd_cents: Option<u64>,
pub is_free: bool,
pub is_unlimited: bool,
pub is_rate_limited: bool,
pub has_watermark: bool,
}
@@ -1,40 +0,0 @@
use crate::client::router_client::RouterClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::cost::artcraft::estimate_video_cost_artcraft_seedance2p0::estimate_video_cost_artcraft_seedance2p0;
use crate::generate::generate_video::execute::artcraft::generate_video_artcraft_seedance2p0::execute_artcraft_seedance2p0;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance2p0::PlanArtcraftSeedance2p0;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
#[derive(Clone, Debug)]
pub struct GenerateVideoResponse {
pub inference_job_token: InferenceJobToken,
pub all_inference_job_tokens: Vec<InferenceJobToken>,
}
#[derive(Debug)]
pub enum VideoGenerationPlan<'a> {
ArtcraftSeedance2p0(PlanArtcraftSeedance2p0<'a>),
}
impl<'a> VideoGenerationPlan<'a> {
pub async fn generate_video(
&self,
client: &RouterClient,
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
match self {
VideoGenerationPlan::ArtcraftSeedance2p0(plan) => {
let artcraft_client = client.get_artcraft_client_ref()?;
execute_artcraft_seedance2p0(plan, artcraft_client).await
}
}
}
pub fn estimate_costs(&self) -> VideoGenerationCostEstimate {
match self {
VideoGenerationPlan::ArtcraftSeedance2p0(plan) => {
estimate_video_cost_artcraft_seedance2p0(plan)
}
}
}
}
@@ -1 +0,0 @@
pub mod generate_video;
-4
View File
@@ -1,4 +0,0 @@
pub mod api;
pub mod client;
pub mod errors;
pub mod generate;
@@ -1,233 +0,0 @@
use artcraft_router::api::common_aspect_ratio::CommonAspectRatio;
use artcraft_router::api::common_video_model::CommonVideoModel;
use artcraft_router::api::image_ref::ImageRef;
use artcraft_router::api::provider::Provider;
use artcraft_router::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use artcraft_router::client::router_artcraft_client::RouterArtcraftClient;
use artcraft_router::client::router_client::RouterClient;
use artcraft_router::errors::artcraft_router_error::ArtcraftRouterError;
use artcraft_router::errors::client_error::ClientError;
use artcraft_router::generate::generate_video::begin_video_generation::begin_video_generation;
use artcraft_router::generate::generate_video::generate_video_request::GenerateVideoRequest;
use artcraft_router::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{
Seedance2p0AspectRatio, Seedance2p0BatchCount,
};
use storyteller_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use storyteller_client::credentials::storyteller_session_cookie::StorytellerSessionCookie;
use storyteller_client::utils::api_host::ApiHost;
fn get_artcraft_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt")
.expect("Failed to read /Users/bt/Artcraft/credentials/artcraft_cookies.txt");
let cookies = cookies.trim().to_string();
let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies)
.expect("Failed to parse cookies")
.expect("No credentials found");
RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials))
}
fn base_request() -> GenerateVideoRequest<'static> {
GenerateVideoRequest {
model: CommonVideoModel::Seedance2p0,
provider: Provider::Artcraft,
prompt: Some("a cat in space"),
start_frame: None,
end_frame: None,
reference_images: None,
resolution: None,
aspect_ratio: None,
duration_seconds: None,
video_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
idempotency_token: None,
}
}
mod plan_tests {
use super::*;
#[test]
fn aspect_ratio_direct_16x9() {
let request = GenerateVideoRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..base_request()
};
let VideoGenerationPlan::ArtcraftSeedance2p0(plan) = begin_video_generation(&request).unwrap();
assert!(matches!(plan.aspect_ratio, Some(Seedance2p0AspectRatio::Landscape16x9)));
}
#[test]
fn aspect_ratio_direct_9x16() {
let request = GenerateVideoRequest {
aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen),
..base_request()
};
let VideoGenerationPlan::ArtcraftSeedance2p0(plan) = begin_video_generation(&request).unwrap();
assert!(matches!(plan.aspect_ratio, Some(Seedance2p0AspectRatio::Portrait9x16)));
}
#[test]
fn aspect_ratio_direct_square() {
let request = GenerateVideoRequest {
aspect_ratio: Some(CommonAspectRatio::Square),
..base_request()
};
let VideoGenerationPlan::ArtcraftSeedance2p0(plan) = begin_video_generation(&request).unwrap();
assert!(matches!(plan.aspect_ratio, Some(Seedance2p0AspectRatio::Square1x1)));
}
#[test]
fn aspect_ratio_nearest_match_both_strategies() {
// WideThreeByTwo (AR 1.5) is nearest to Standard4x3 (1.33), not Landscape16x9 (1.78)
for strategy in [
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateVideoRequest {
aspect_ratio: Some(CommonAspectRatio::WideThreeByTwo),
request_mismatch_mitigation_strategy: strategy,
..base_request()
};
let VideoGenerationPlan::ArtcraftSeedance2p0(plan) = begin_video_generation(&request).unwrap();
assert!(
matches!(plan.aspect_ratio, Some(Seedance2p0AspectRatio::Standard4x3)),
"Expected Standard4x3 for WideThreeByTwo with {:?}", strategy,
);
}
}
#[test]
fn aspect_ratio_error_out_on_unsupported() {
let request = GenerateVideoRequest {
aspect_ratio: Some(CommonAspectRatio::WideThreeByTwo),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_request()
};
let result = begin_video_generation(&request);
assert!(matches!(
result,
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. }))
));
}
#[test]
fn batch_count_zero_is_always_error() {
for strategy in [
RequestMismatchMitigationStrategy::ErrorOut,
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateVideoRequest {
video_batch_count: Some(0),
request_mismatch_mitigation_strategy: strategy,
..base_request()
};
let result = begin_video_generation(&request);
assert!(
matches!(result, Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations))),
"Expected UserRequestedZeroGenerations with {:?}", strategy,
);
}
}
#[test]
fn batch_count_direct_mapping() {
let req = GenerateVideoRequest { video_batch_count: Some(1), ..base_request() };
let VideoGenerationPlan::ArtcraftSeedance2p0(plan) = begin_video_generation(&req).unwrap();
assert!(matches!(plan.batch_count, Seedance2p0BatchCount::One));
let req = GenerateVideoRequest { video_batch_count: Some(2), ..base_request() };
let VideoGenerationPlan::ArtcraftSeedance2p0(plan) = begin_video_generation(&req).unwrap();
assert!(matches!(plan.batch_count, Seedance2p0BatchCount::Two));
let req = GenerateVideoRequest { video_batch_count: Some(4), ..base_request() };
let VideoGenerationPlan::ArtcraftSeedance2p0(plan) = begin_video_generation(&req).unwrap();
assert!(matches!(plan.batch_count, Seedance2p0BatchCount::Four));
}
#[test]
fn batch_count_three_upgrade_rounds_to_four() {
let request = GenerateVideoRequest {
video_batch_count: Some(3),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..base_request()
};
let VideoGenerationPlan::ArtcraftSeedance2p0(plan) = begin_video_generation(&request).unwrap();
assert!(matches!(plan.batch_count, Seedance2p0BatchCount::Four));
}
#[test]
fn batch_count_three_downgrade_rounds_to_two() {
let request = GenerateVideoRequest {
video_batch_count: Some(3),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade,
..base_request()
};
let VideoGenerationPlan::ArtcraftSeedance2p0(plan) = begin_video_generation(&request).unwrap();
assert!(matches!(plan.batch_count, Seedance2p0BatchCount::Two));
}
#[test]
fn url_image_ref_returns_error() {
let request = GenerateVideoRequest {
start_frame: Some(ImageRef::Url("https://example.com/image.jpg")),
..base_request()
};
let result = begin_video_generation(&request);
assert!(matches!(
result,
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
));
}
}
mod real_requests {
use super::*;
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_text_to_video_seedance_2p0() {
let client = get_artcraft_client();
let request = GenerateVideoRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
video_batch_count: Some(1),
prompt: Some("a cat walking through a cyberpunk city at night"),
..base_request()
};
let plan = begin_video_generation(&request).unwrap();
let result = plan.generate_video(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_video request failed");
println!("Job token: {:?}", response.inference_job_token);
println!("All job tokens: {:?}", response.all_inference_job_tokens);
assert_eq!(1, 2); // NB: Intentional failure to inspect the response above.
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_text_to_video_seedance_2p0_batch_two() {
let client = get_artcraft_client();
let request = GenerateVideoRequest {
aspect_ratio: Some(CommonAspectRatio::Square),
video_batch_count: Some(2),
prompt: Some("a dog surfing a wave, cinematic"),
..base_request()
};
let plan = begin_video_generation(&request).unwrap();
let result = plan.generate_video(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_video request failed");
println!("Job tokens ({} total):", response.all_inference_job_tokens.len());
for token in &response.all_inference_job_tokens {
println!(" {:?}", token);
}
assert_eq!(1, 2); // NB: Intentional failure to inspect the response above.
}
}
@@ -22,5 +22,8 @@ workspace-build-acceleration.workspace = true
once_cell.workspace = true
wreq.workspace = true
[target.'cfg(target_os = "linux")'.dependencies]
wreq = { workspace = true, features = ["prefix-symbols"] }
[dev-dependencies]
# (None)
+4 -4
View File
@@ -16,11 +16,11 @@ path = "src/lib.rs"
normal = ["workspace-build-acceleration"]
[dependencies]
# cargo-hakari workspace hack (faster compile times)
workspace-build-acceleration.workspace = true
# Internal
errors.workspace = true
errors = { path = "../errors" }
# Workspace hack (faster compile times)
workspace-build-acceleration = { version = "0.1", path = "../../workspace-build-acceleration" }
# External
# (None yet)
@@ -13,7 +13,6 @@ use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::prompts::PromptToken;
use tokens::tokens::users::UserToken;
use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken;
use crate::errors::database_query_error::DatabaseQueryError;
use crate::payloads::generic_inference_args::generic_inference_args::GenericInferenceArgs;
@@ -33,8 +32,6 @@ pub struct InsertGenericInferenceForSeedance2ProWithAprioriJobTokenArgs<'e, 'c,
pub maybe_prompt_token: Option<&'e PromptToken>,
pub maybe_wallet_ledger_entry_token: Option<&'e WalletLedgerEntryToken>,
pub maybe_creator_user_token: Option<&'e UserToken>,
pub maybe_avt_token: Option<&'e AnonymousVisitorTrackingToken>,
pub creator_ip_address: &'e str,
@@ -85,8 +82,6 @@ SET
maybe_prompt_token = ?,
maybe_wallet_ledger_entry_token = ?,
maybe_raw_inference_text = NULL,
maybe_inference_args = ?,
@@ -118,8 +113,6 @@ SET
args.maybe_prompt_token.map(|t| t.to_string()),
args.maybe_wallet_ledger_entry_token.map(|t| t.to_string()),
serialized_args_payload,
args.maybe_creator_user_token.map(|t| t.to_string()),
@@ -9,7 +9,6 @@ use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::prompts::PromptToken;
use tokens::tokens::users::UserToken;
use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken;
/// A Seedance2Pro job that is waiting for a result from the external API.
#[derive(Debug)]
@@ -25,8 +24,6 @@ pub struct PendingSeedance2ProJob {
pub creator_set_visibility: Visibility,
pub maybe_prompt_token: Option<PromptToken>,
pub maybe_wallet_ledger_entry_token: Option<WalletLedgerEntryToken>,
}
#[derive(Debug, Default)]
@@ -38,7 +35,6 @@ struct RawRecord {
creator_ip_address: String,
creator_set_visibility: Visibility,
maybe_prompt_token: Option<PromptToken>,
maybe_wallet_ledger_entry_token: Option<WalletLedgerEntryToken>,
}
/// Returns all non-terminal Seedance2Pro jobs that have an associated order_id.
@@ -53,8 +49,7 @@ SELECT
jobs.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,
jobs.creator_ip_address,
jobs.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,
jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,
jobs.maybe_wallet_ledger_entry_token as `maybe_wallet_ledger_entry_token: tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken`
jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`
FROM generic_inference_jobs as jobs
@@ -89,7 +84,6 @@ LIMIT 25000
creator_ip_address: record.creator_ip_address,
creator_set_visibility: record.creator_set_visibility,
maybe_prompt_token: record.maybe_prompt_token,
maybe_wallet_ledger_entry_token: record.maybe_wallet_ledger_entry_token,
})
})
.collect();
@@ -21,7 +21,7 @@ pub (crate) struct InsertWalletLedgerEntry<'a> {
/// An optional reference for this entry. Could be an internal ID (eg. job) or an
/// external ID (eg. Stripe payment intent, invoice, etc.)
pub maybe_entity_ref: Option<String>,
/// Change in credits (positive or negative) across banked and/or monthly credits.
pub credits_delta: i64,
@@ -40,35 +40,41 @@ pub (crate) struct InsertWalletLedgerEntry<'a> {
impl <'a> InsertWalletLedgerEntry<'a> {
pub async fn upsert_with_pool(&'a self, mysql_pool: &MySqlPool) -> AnyhowResult<WalletLedgerEntryToken> {
pub async fn upsert(&'a self, mysql_pool: &MySqlPool) -> AnyhowResult<()> {
let mut conn = mysql_pool.acquire().await?;
self.upsert_with_connection(&mut conn).await
}
pub async fn upsert_with_connection(&'a self, mysql_connection: &mut PoolConnection<MySql>) -> AnyhowResult<WalletLedgerEntryToken> {
let ledger_token = WalletLedgerEntryToken::generate();
let query_result = self.query_with_token(&ledger_token).execute(&mut **mysql_connection).await;
pub async fn upsert_with_connection(&'a self, mysql_connection: &mut PoolConnection<MySql>) -> AnyhowResult<()> {
let query = self.query();
let query_result = query.execute(&mut **mysql_connection).await;
match query_result {
Ok(_) => Ok(ledger_token),
Err(err) => Err(anyhow!("Error upserting wallet ledger entry record: {:?}", err)),
}
let _record_id = match query_result {
Ok(res) => res.last_insert_id(),
Err(err) => return Err(anyhow!("Error upserting wallet ledger entry record: {:?}", err)),
};
Ok(())
}
pub async fn upsert_with_transaction(&'a self, transaction: &mut Transaction<'_, MySql>) -> Result<WalletLedgerEntryToken, sqlx::Error> {
let ledger_token = WalletLedgerEntryToken::generate();
let query_result = self.query_with_token(&ledger_token).execute(&mut **transaction).await;
pub async fn upsert_with_transaction(&'a self, transaction: &mut Transaction<'_, MySql>) -> Result<(), sqlx::Error> {
let query = self.query();
let query_result = query.execute(&mut **transaction).await;
match query_result {
Ok(_) => Ok(ledger_token),
let _record_id = match query_result {
Ok(res) => res.last_insert_id(),
Err(err) => {
error!("Error upserting wallet ledger entry record: {:?}", err);
Err(err)
return Err(err);
},
}
};
Ok(())
}
fn query_with_token(&self, ledger_token: &WalletLedgerEntryToken) -> Query<MySql, MySqlArguments> {
fn query(&self) -> Query<MySql, MySqlArguments> {
let token = WalletLedgerEntryToken::generate().to_string();
sqlx::query!(
r#"
INSERT INTO wallet_ledger_entries
@@ -83,7 +89,7 @@ SET
monthly_credits_before = ?,
monthly_credits_after = ?
"#,
ledger_token.as_str(),
token.as_str(),
self.wallet_token.as_str(),
self.entry_type.to_str(),
self.maybe_entity_ref,
@@ -52,12 +52,11 @@ pub async fn add_durable_banked_balance_to_wallet(
monthly_credits_after: wallet.monthly_credits,
};
let wallet_ledger_entry_token = record.upsert_with_transaction(transaction).await?;
record.upsert_with_transaction(transaction).await?;
Ok(WalletUpdateSummary {
wallet_token: wallet.token,
wallet_ledger_entry_token,
token: wallet.token,
namespace: wallet.namespace,
owner_user_token: wallet.owner_user_token,
@@ -4,6 +4,5 @@ pub mod create_new_artcraft_wallet_for_owner_user;
pub mod find_primary_wallet_for_owner;
pub mod find_primary_wallet_token_for_owner;
pub mod refill_monthly_credits_balance_on_wallet;
pub mod refund;
pub mod spend;
pub mod wallet_update_summary;
@@ -51,12 +51,11 @@ pub async fn refill_monthly_credits_balance_on_wallet(
banked_credits_after: wallet.banked_credits,
};
let wallet_ledger_entry_token = record.upsert_with_transaction(transaction).await?;
record.upsert_with_transaction(transaction).await?;
Ok(WalletUpdateSummary {
wallet_token: wallet.token,
wallet_ledger_entry_token,
token: wallet.token,
namespace: wallet.namespace,
owner_user_token: wallet.owner_user_token,
@@ -1,2 +0,0 @@
pub mod try_to_refund_ledger_entry;
pub mod wallet_refund_error;
@@ -1,219 +0,0 @@
use log::info;
use sqlx::MySql;
use enums::by_table::wallet_ledger_entries::wallet_ledger_entry_type::WalletLedgerEntryType;
use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken;
use tokens::tokens::wallets::WalletToken;
use crate::queries::wallet_ledger_entries::internal_insert_wallet_ledger_entry::InsertWalletLedgerEntry;
use crate::queries::wallets::internal_select_wallet_balance_for_update::internal_select_wallet_balance_for_update;
use crate::queries::wallets::refund::wallet_refund_error::WalletRefundError;
pub enum WalletRefundOutcome {
/// The refund was successfully applied.
Refunded(WalletRefundSummary),
/// The ledger entry was already marked as refunded; no action was taken.
/// This is not an error — it is safe to call refund multiple times.
AlreadyRefunded,
}
pub struct WalletRefundSummary {
pub wallet_token: WalletToken,
pub original_ledger_entry_token: WalletLedgerEntryToken,
pub refund_ledger_entry_token: WalletLedgerEntryToken,
pub refund_amount: u64,
pub banked_credits_before: u64,
pub banked_credits_after: u64,
}
/// Refund a wallet ledger entry, crediting the amount back as banked (durable) credits.
///
/// NB: BE VERY CAREFUL WITH THIS FUNCTION!
/// It locks both the ledger entry row and the wallet row for the duration of the transaction.
///
/// We always refund into banked credits rather than monthly credits. Refunding into monthly
/// credits near a billing cycle cutoff could create race conditions with the monthly refill job,
/// so banked credits are the safer, permanent choice.
pub async fn try_to_refund_ledger_entry(
ledger_entry_token: &WalletLedgerEntryToken,
transaction: &mut sqlx::Transaction<'_, MySql>,
) -> Result<WalletRefundOutcome, WalletRefundError> {
// Step 1: Lock the original ledger entry for the duration of this transaction.
let original_entry = select_ledger_entry_for_update(ledger_entry_token, transaction).await?;
// Step 2: Guard — bail out gracefully if it was already refunded.
if original_entry.is_refunded {
return Ok(WalletRefundOutcome::AlreadyRefunded);
}
// Step 3: Guard — only deduct-type entries may be refunded.
let is_deduct_type = matches!(
original_entry.entry_type,
WalletLedgerEntryType::DeductMixed
| WalletLedgerEntryType::DeductBanked
| WalletLedgerEntryType::DeductMonthly,
);
if !is_deduct_type {
return Err(WalletRefundError::NotADeductEntry(original_entry.entry_type));
}
// Step 4: Lock the wallet row so our balance arithmetic is atomic.
let wallet = internal_select_wallet_balance_for_update(
&original_entry.wallet_token,
transaction,
).await.map_err(|_| WalletRefundError::WalletNotFound)?;
// credits_delta was negative for a deduction (e.g. -100). The refund amount is its magnitude.
let refund_amount = original_entry.credits_delta.unsigned_abs();
let banked_credits_before = wallet.banked_credits;
let banked_credits_after = banked_credits_before.saturating_add(refund_amount);
// Step 5: Insert the refund ledger entry.
//
// We record the original ledger entry token as the entity_ref so there is a clear audit trail
// linking the refund back to the original spend. The new entry itself has is_refunded = false
// because it is the refund, not the thing being refunded.
let refund_ledger_record = InsertWalletLedgerEntry {
wallet_token: &original_entry.wallet_token,
entry_type: WalletLedgerEntryType::RefundBanked,
maybe_entity_ref: Some(ledger_entry_token.to_string()),
credits_delta: refund_amount as i64,
banked_credits_before,
banked_credits_after,
// Monthly credits are untouched by a banked refund.
monthly_credits_before: wallet.monthly_credits,
monthly_credits_after: wallet.monthly_credits,
};
let refund_ledger_entry_token = refund_ledger_record
.upsert_with_transaction(transaction)
.await?;
// Step 6: Credit the wallet's banked balance.
sqlx::query!(
r#"
UPDATE wallets
SET
banked_credits = ?,
version = version + 1
WHERE token = ?
LIMIT 1
"#,
banked_credits_after,
original_entry.wallet_token.as_str(),
).execute(&mut **transaction).await?;
// Step 7: Mark the original entry as refunded and link it to the new refund record.
sqlx::query!(
r#"
UPDATE wallet_ledger_entries
SET
is_refunded = TRUE,
maybe_linked_refund_ledger_token = ?
WHERE token = ?
LIMIT 1
"#,
refund_ledger_entry_token.as_str(),
ledger_entry_token.as_str(),
).execute(&mut **transaction).await?;
info!(
"Refunded ledger entry {} → new refund entry {}; wallet {} banked credits: {} → {}",
ledger_entry_token.as_str(),
refund_ledger_entry_token.as_str(),
original_entry.wallet_token.as_str(),
banked_credits_before,
banked_credits_after,
);
Ok(WalletRefundOutcome::Refunded(WalletRefundSummary {
wallet_token: original_entry.wallet_token,
original_ledger_entry_token: ledger_entry_token.clone(),
refund_ledger_entry_token,
refund_amount,
banked_credits_before,
banked_credits_after,
}))
}
// ===== Internal helpers =====
struct LedgerEntryForUpdate {
wallet_token: WalletToken,
entry_type: WalletLedgerEntryType,
is_refunded: bool,
/// Negative for deductions, positive for credits.
credits_delta: i64,
banked_credits_before: u64,
banked_credits_after: u64,
monthly_credits_before: u64,
monthly_credits_after: u64,
}
/// SELECT ... FOR UPDATE on wallet_ledger_entries.
/// Locks the row for the duration of the enclosing transaction.
async fn select_ledger_entry_for_update(
ledger_entry_token: &WalletLedgerEntryToken,
transaction: &mut sqlx::Transaction<'_, MySql>,
) -> Result<LedgerEntryForUpdate, WalletRefundError> {
struct RawLedgerEntryForUpdate {
wallet_token: WalletToken,
entry_type: String,
is_refunded: bool,
credits_delta: i32,
banked_credits_before: u32,
banked_credits_after: u32,
monthly_credits_before: u32,
monthly_credits_after: u32,
}
let raw = sqlx::query_as!(
RawLedgerEntryForUpdate,
r#"
SELECT
wallet_token as `wallet_token: tokens::tokens::wallets::WalletToken`,
entry_type,
is_refunded as `is_refunded: bool`,
credits_delta,
banked_credits_before,
banked_credits_after,
monthly_credits_before,
monthly_credits_after
FROM wallet_ledger_entries
WHERE token = ?
LIMIT 1
FOR UPDATE
"#,
ledger_entry_token.as_str(),
)
.fetch_one(&mut **transaction)
.await
.map_err(|e| match e {
sqlx::Error::RowNotFound => WalletRefundError::LedgerEntryNotFound,
err => WalletRefundError::SqlxError(err),
})?;
let entry_type = WalletLedgerEntryType::from_str(&raw.entry_type)
.map_err(|_| WalletRefundError::SqlxError(sqlx::Error::Decode(
format!("unknown wallet_ledger_entry entry_type: {:?}", raw.entry_type).into()
)))?;
Ok(LedgerEntryForUpdate {
wallet_token: raw.wallet_token,
entry_type,
is_refunded: raw.is_refunded,
credits_delta: raw.credits_delta as i64,
banked_credits_before: raw.banked_credits_before as u64,
banked_credits_after: raw.banked_credits_after as u64,
monthly_credits_before: raw.monthly_credits_before as u64,
monthly_credits_after: raw.monthly_credits_after as u64,
})
}
@@ -1,51 +0,0 @@
use crate::errors::select_exactly_one_error::SelectExactlyOneError;
use enums::by_table::wallet_ledger_entries::wallet_ledger_entry_type::WalletLedgerEntryType;
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum WalletRefundError {
/// The wallet ledger entry to refund was not found.
LedgerEntryNotFound,
/// The wallet referenced by the ledger entry was not found.
WalletNotFound,
/// The ledger entry is not a deduct-type entry; only DeductMixed, DeductBanked,
/// and DeductMonthly entries can be refunded.
NotADeductEntry(WalletLedgerEntryType),
/// Error selecting a record.
SelectError(SelectExactlyOneError),
/// Underlying database error.
SqlxError(sqlx::Error),
}
impl Error for WalletRefundError {}
impl Display for WalletRefundError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
WalletRefundError::LedgerEntryNotFound => write!(f, "Ledger entry not found"),
WalletRefundError::WalletNotFound => write!(f, "Wallet not found"),
WalletRefundError::NotADeductEntry(entry_type) => {
write!(f, "Cannot refund a non-deduct ledger entry: {:?}", entry_type)
}
WalletRefundError::SelectError(err) => write!(f, "Database select error: {}", err),
WalletRefundError::SqlxError(err) => write!(f, "Database error: {}", err),
}
}
}
impl From<SelectExactlyOneError> for WalletRefundError {
fn from(err: SelectExactlyOneError) -> Self {
WalletRefundError::SelectError(err)
}
}
impl From<sqlx::Error> for WalletRefundError {
fn from(err: sqlx::Error) -> Self {
WalletRefundError::SqlxError(err)
}
}
@@ -115,11 +115,10 @@ pub async fn try_to_spend_wallet_balance(
banked_credits_after: updated_banked_balance
};
let wallet_ledger_entry_token = record.upsert_with_transaction(transaction).await?;
record.upsert_with_transaction(transaction).await?;
Ok(WalletUpdateSummary {
wallet_token: wallet.token,
wallet_ledger_entry_token,
token: wallet.token,
namespace: wallet.namespace,
owner_user_token: wallet.owner_user_token,
@@ -1,12 +1,9 @@
use enums::common::payments_namespace::PaymentsNamespace;
use tokens::tokens::users::UserToken;
use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken;
use tokens::tokens::wallets::WalletToken;
pub struct WalletUpdateSummary {
pub wallet_token: WalletToken,
pub wallet_ledger_entry_token: WalletLedgerEntryToken,
pub token: WalletToken,
pub namespace: PaymentsNamespace,
@@ -36,18 +36,6 @@ pub enum WalletLedgerEntryType {
/// Deduct monthly credits
#[serde(rename = "deduct_monthly")]
DeductMonthly,
/// Refund banked credits
#[serde(rename = "refund_banked")]
RefundBanked,
// TODO: No clean way to do "mixed" refunds yet, and if we
// refund close to the cutoff it might be unfair. Let's
// just not do monthly refunds yet and instead credit our
// users with banked credits.
// /// Refund monthly credits
// #[serde(rename = "refund_monthly")]
// RefundMonthly,
}
// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests!
@@ -65,7 +53,6 @@ impl WalletLedgerEntryType {
Self::DeductMixed => "deduct_mixed",
Self::DeductBanked => "deduct_banked",
Self::DeductMonthly => "deduct_monthly",
Self::RefundBanked => "refund_banked",
}
}
@@ -77,7 +64,6 @@ impl WalletLedgerEntryType {
"deduct_mixed" => Ok(Self::DeductMixed),
"deduct_banked" => Ok(Self::DeductBanked),
"deduct_monthly" => Ok(Self::DeductMonthly),
"refund_banked" => Ok(Self::RefundBanked),
_ => Err(format!("invalid value: {:?}", value)),
}
}
@@ -92,7 +78,6 @@ impl WalletLedgerEntryType {
Self::DeductMixed,
Self::DeductBanked,
Self::DeductMonthly,
Self::RefundBanked,
])
}
}
@@ -113,7 +98,6 @@ mod tests {
assert_serialization(WalletLedgerEntryType::DeductMixed, "deduct_mixed");
assert_serialization(WalletLedgerEntryType::DeductBanked, "deduct_banked");
assert_serialization(WalletLedgerEntryType::DeductMonthly, "deduct_monthly");
assert_serialization(WalletLedgerEntryType::RefundBanked, "refund_banked");
}
}
@@ -128,7 +112,6 @@ mod tests {
assert_eq!(WalletLedgerEntryType::DeductMixed.to_str(), "deduct_mixed");
assert_eq!(WalletLedgerEntryType::DeductBanked.to_str(), "deduct_banked");
assert_eq!(WalletLedgerEntryType::DeductMonthly.to_str(), "deduct_monthly");
assert_eq!(WalletLedgerEntryType::RefundBanked.to_str(), "refund_banked");
}
#[test]
@@ -139,7 +122,6 @@ mod tests {
assert_eq!(WalletLedgerEntryType::from_str("deduct_mixed").unwrap(), WalletLedgerEntryType::DeductMixed);
assert_eq!(WalletLedgerEntryType::from_str("deduct_banked").unwrap(), WalletLedgerEntryType::DeductBanked);
assert_eq!(WalletLedgerEntryType::from_str("deduct_monthly").unwrap(), WalletLedgerEntryType::DeductMonthly);
assert_eq!(WalletLedgerEntryType::from_str("refund_banked").unwrap(), WalletLedgerEntryType::RefundBanked);
assert!(WalletLedgerEntryType::from_str("foo").is_err());
}
}
@@ -150,14 +132,13 @@ mod tests {
#[test]
fn all_variants() {
let mut variants = WalletLedgerEntryType::all_variants();
assert_eq!(variants.len(), 7);
assert_eq!(variants.len(), 6);
assert_eq!(variants.pop_first(), Some(WalletLedgerEntryType::Create));
assert_eq!(variants.pop_first(), Some(WalletLedgerEntryType::CreditBanked));
assert_eq!(variants.pop_first(), Some(WalletLedgerEntryType::CreditMonthly));
assert_eq!(variants.pop_first(), Some(WalletLedgerEntryType::DeductMixed));
assert_eq!(variants.pop_first(), Some(WalletLedgerEntryType::DeductBanked));
assert_eq!(variants.pop_first(), Some(WalletLedgerEntryType::DeductMonthly));
assert_eq!(variants.pop_first(), Some(WalletLedgerEntryType::RefundBanked));
assert_eq!(variants.pop_first(), None);
}
}
@@ -1,25 +0,0 @@
use utoipa::ToSchema;
/// Common aspect ratios for video generation.
/// Mirrors artcraft_router::api::common_aspect_ratio::CommonAspectRatio.
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum CommonAspectRatio {
Auto,
Square,
WideThreeByTwo,
WideFourByThree,
WideFiveByFour,
WideSixteenByNine,
WideTwentyOneByNine,
TallTwoByThree,
TallThreeByFour,
TallFourByFive,
TallNineBySixteen,
TallNineByTwentyOne,
Wide,
Tall,
Auto2k,
Auto4k,
SquareHd,
}
@@ -1,52 +0,0 @@
use utoipa::ToSchema;
/// Video models available for generation.
/// Mirrors artcraft_router::api::common_video_model::CommonVideoModel.
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum CommonVideoModel {
#[serde(rename = "grok_video")]
GrokVideo,
#[serde(rename = "kling_1p6_pro")]
Kling16Pro,
#[serde(rename = "kling_2p1_pro")]
Kling21Pro,
#[serde(rename = "kling_2p1_master")]
Kling21Master,
#[serde(rename = "kling_2p5_turbo_pro")]
Kling2p5TurboPro,
#[serde(rename = "kling_2p6_pro")]
Kling2p6Pro,
#[serde(rename = "seedance_1p0_lite")]
Seedance10Lite,
#[serde(rename = "seedance_2p0")]
Seedance2p0,
#[serde(rename = "sora_2")]
Sora2,
#[serde(rename = "sora_2_pro")]
Sora2Pro,
#[serde(rename = "veo_2")]
Veo2,
#[serde(rename = "veo_3")]
Veo3,
#[serde(rename = "veo_3_fast")]
Veo3Fast,
#[serde(rename = "veo_3p1")]
Veo3p1,
#[serde(rename = "veo_3p1_fast")]
Veo3p1Fast,
}
@@ -1,12 +0,0 @@
use utoipa::ToSchema;
/// Common video resolutions.
/// Mirrors artcraft_router::api::common_resolution::CommonVideoResolution.
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum CommonVideoResolution {
OneK,
TwoK,
ThreeK,
FourK,
}
@@ -1,3 +0,0 @@
pub mod common_aspect_ratio;
pub mod common_video_model;
pub mod common_video_resolution;
@@ -1,10 +1,3 @@
//! This is an important enum.
//!
//! It's used in storyteller-web inference generation, the cost estimate handler,
//! the ArtCraft Tauri app, and more.
//!
//! Do not change the values here without cause or care.
use std::collections::BTreeSet;
use crate::error::enum_error::EnumError;

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