Compare commits

..
Author SHA1 Message Date
Brandon Thomas 0405ea901f report more 2026-06-11 10:35:38 -04:00
Brandon Thomas 4c7bb66356 tests 2026-06-11 10:32:21 -04:00
Brandon Thomas 9e1051d16b new tests 2026-06-11 10:24:37 -04:00
Brandon Thomas 36f1dfb522 make bindings better 2026-06-11 09:56:23 -04:00
Brandon Thomas e22585de06 concrete bindings 2026-06-11 09:42:57 -04:00
Brandon Thomas edb5a81e02 first api binding 2026-06-11 09:25:52 -04:00
Brandon Thomas 957db2d07f folders - add video previews / thumbnails (#1628) 2026-06-11 08:50:23 -04:00
Brandon Thomas b82a4e1599 change sorting for media files in folders (#1627) 2026-06-10 23:32:52 -04:00
BFlat bee790b952 feat(library): marquee selection, multi-select UX, mobile polish and … (#1626)
…perf

- Add desktop-style marquee drag selection on the webapp library: drag
on blank space to select covered tiles, click blank space to deselect
all
- Multi-select: clicking anywhere on a tile toggles it (fixes a
double-fire that cancelled the toggle so only the checkbox worked);
selection persists across tabs and folder navigation
- Gallery item menus show "View image" / "View video" / "View 3D" above
Add to Folder; menu panels size to content so labels no longer wrap
- Mobile/touch: no tile dragging or marquee (finger drags scroll),
checkbox and menu buttons always visible (no hover on touch), and the
Add to Folder submenu expands inline as an accordion instead of
overflowing the screen
- Select-mode gallery modal: folder cards hide the options menu and
ignore right-click (picker is browse-only)
- Perf: bulk selection moved to a module store with per-tile
subscriptions so selection changes re-render only flipped tiles, never
the page; marquee rectangle is positioned imperatively with cached tile
rects and commits only when coverage changes; webapp date groups
virtualized via the shared LazyDateGroup for smooth scrolling on large
libraries
- Bulk selection bar extracted to its own component, fixed to the
viewport bottom and centered within the content area
2026-06-11 00:47:42 +07:00
BFlat a2d57e3685 Folders for the library modal (#1309)
- Redesigned the gallery modal with a sidebar replacing the old filter
dropdown - filters now show as a list with item counts
- Added a Folders system - create, rename, and delete folders to
organize assets
- Users can drag items onto folders in the sidebar, including
multi-selected items
- Added "Add to Folder" option in the item right-click menu with a
folder picker submenu
- Added "Add to folder" button in the bulk selection bar with a folder
picker popup
- Clicking a folder in the sidebar opens a folder view with its own
content area and empty state
- Folder names are editable - click the title in the header or
right-click a folder in the sidebar to rename
- Videos are now draggable - previously only images and 3D objects could
be dragged
- Folder data is mocked locally for now - ready for API integration
2026-06-10 22:04:26 +07:00
Brandon Thomas 9104faf806 fix frontend dev scripts (#1625) 2026-06-10 09:28:25 -04:00
Brandon Thomas b011319abd dashboard links (#1624) 2026-06-10 09:03:58 -04:00
Brandon Thomas 1c654bfafa stripe info admin endpoint (#1623) 2026-06-10 08:42:32 -04:00
Brandon Thomas a7c59f7c9b fix test failures (#1622) 2026-06-10 00:52:24 -04:00
Brandon Thomas 780bbbe9cf test fixes (#1621) 2026-06-10 00:37:32 -04:00
Brandon Thomas 6e5cce20fb seedance errors revamp (#1620) 2026-06-09 22:52:03 -04:00
Brandon Thomas b12781056d track platform type (#1619) 2026-06-09 20:19:05 -04:00
135 changed files with 9744 additions and 787 deletions
@@ -0,0 +1,36 @@
{
"db_name": "MySQL",
"query": "\nSELECT DISTINCT\n subscription_namespace as `subscription_namespace: enums::common::payments_namespace::PaymentsNamespace`,\n maybe_stripe_customer_id as `stripe_customer_id!`\n\nFROM user_subscriptions\n\nWHERE\n user_token = ?\n AND maybe_stripe_customer_id IS NOT NULL\n AND deleted_at IS NULL\nORDER BY subscription_namespace ASC, maybe_stripe_customer_id ASC\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "subscription_namespace: enums::common::payments_namespace::PaymentsNamespace",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "stripe_customer_id!",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 1020
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
true
]
},
"hash": "516db34b6a642a2f7d7107f186905a79ad694c3c86f3078efc77bcaae7f39f3d"
}
@@ -1,14 +1,14 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n fmf.id as `membership_id: u64`,\n fmf.created_at as `added_to_folder_at: DateTime<Utc>`,\n\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_class as `media_class: MediaFileClass`,\n mf.media_type as `media_type: MediaFileType`,\n\n mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,\n\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n\n cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,\n cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,\n cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,\n\n mf.creator_set_visibility as `creator_set_visibility: Visibility`,\n mf.is_user_upload as `is_user_upload: bool`,\n mf.is_intermediate_system_file as `is_intermediate_system_file: bool`,\n\n mf.maybe_title,\n mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,\n mf.maybe_origin_filename,\n\n mf.maybe_duration_millis,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n\n mf.created_at,\n mf.updated_at\nFROM folder_media_files fmf\nJOIN media_files mf\n ON mf.token = fmf.media_file_token\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE fmf.folder_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\nORDER BY fmf.id DESC\nLIMIT ?\n ",
"query": "\nSELECT\n mf.id as `media_file_id: u64`,\n fmf.created_at as `added_to_folder_at: DateTime<Utc>`,\n\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_class as `media_class: MediaFileClass`,\n mf.media_type as `media_type: MediaFileType`,\n\n mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,\n\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n\n cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,\n cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,\n cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,\n\n mf.creator_set_visibility as `creator_set_visibility: Visibility`,\n mf.is_user_upload as `is_user_upload: bool`,\n mf.is_intermediate_system_file as `is_intermediate_system_file: bool`,\n\n mf.maybe_title,\n mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,\n mf.maybe_origin_filename,\n\n mf.maybe_duration_millis,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n\n mf.created_at,\n mf.updated_at\nFROM folder_media_files fmf\nJOIN media_files mf\n ON mf.token = fmf.media_file_token\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE fmf.folder_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\nORDER BY mf.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "membership_id: u64",
"name": "media_file_id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | UNIQUE_KEY | UNSIGNED | AUTO_INCREMENT",
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
@@ -263,5 +263,5 @@
false
]
},
"hash": "88673127b54626dc38d28693e709dfaeced9a05300a26b3f3c52a17f28171c9a"
"hash": "8aca9bc976b980ea1a3b3052bc8a45dd4d8db55c20fd1aede2a4415cc1395cf3"
}
@@ -1,14 +1,14 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n fmf.id as `membership_id: u64`,\n fmf.created_at as `added_to_folder_at: DateTime<Utc>`,\n\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_class as `media_class: MediaFileClass`,\n mf.media_type as `media_type: MediaFileType`,\n\n mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,\n\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n\n cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,\n cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,\n cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,\n\n mf.creator_set_visibility as `creator_set_visibility: Visibility`,\n mf.is_user_upload as `is_user_upload: bool`,\n mf.is_intermediate_system_file as `is_intermediate_system_file: bool`,\n\n mf.maybe_title,\n mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,\n mf.maybe_origin_filename,\n\n mf.maybe_duration_millis,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n\n mf.created_at,\n mf.updated_at\nFROM folder_media_files fmf\nJOIN media_files mf\n ON mf.token = fmf.media_file_token\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE fmf.folder_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\n AND fmf.id < ?\nORDER BY fmf.id DESC\nLIMIT ?\n ",
"query": "\nSELECT\n mf.id as `media_file_id: u64`,\n fmf.created_at as `added_to_folder_at: DateTime<Utc>`,\n\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_class as `media_class: MediaFileClass`,\n mf.media_type as `media_type: MediaFileType`,\n\n mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,\n\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n\n cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,\n cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,\n cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,\n\n mf.creator_set_visibility as `creator_set_visibility: Visibility`,\n mf.is_user_upload as `is_user_upload: bool`,\n mf.is_intermediate_system_file as `is_intermediate_system_file: bool`,\n\n mf.maybe_title,\n mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,\n mf.maybe_origin_filename,\n\n mf.maybe_duration_millis,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n\n mf.created_at,\n mf.updated_at\nFROM folder_media_files fmf\nJOIN media_files mf\n ON mf.token = fmf.media_file_token\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE fmf.folder_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\n AND mf.id < ?\nORDER BY mf.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "membership_id: u64",
"name": "media_file_id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | UNIQUE_KEY | UNSIGNED | AUTO_INCREMENT",
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
@@ -263,5 +263,5 @@
false
]
},
"hash": "d2a406bcf08f656fd602aa6ba1124a77d04ad874b15abafca300e8b381c7b211"
"hash": "c0ba9f9aa3184b1cb3d714173b31e530e201a6c24afd29ad534cf7f3954c7316"
}
@@ -0,0 +1,47 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n user_token as `user_token: tokens::tokens::users::UserToken`,\n payments_namespace as `payments_namespace: enums::common::payments_namespace::PaymentsNamespace`,\n stripe_customer_id\n\nFROM user_stripe_customer_links\n\nWHERE user_token = ?\nORDER BY id ASC\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "payments_namespace: enums::common::payments_namespace::PaymentsNamespace",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "stripe_customer_id",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false
]
},
"hash": "ffb5717340f21f21693bfcade201bc055fb3fb8a26da8d0574e3de44b033586c"
}
Generated
+14
View File
@@ -2377,6 +2377,20 @@ dependencies = [
"tokio-util",
]
[[package]]
name = "comet_api_client"
version = "0.0.1"
dependencies = [
"log",
"reqwest 0.12.23",
"serde",
"serde_derive",
"serde_json",
"test_data",
"tokio",
"workspace-build-acceleration",
]
[[package]]
name = "compact_str"
version = "0.7.1"
+1
View File
@@ -10,6 +10,7 @@ members = [
"crates/api_clients/artcraft/artcraft_client",
"crates/api_clients/artcraft/artcraft_router",
"crates/api_clients/cloud_storage",
"crates/api_clients/comet_api_client",
"crates/api_clients/datadog_client",
"crates/api_clients/fal_client",
"crates/api_clients/google_drive_downloader",
@@ -64,6 +64,31 @@ pub struct FolderThumbnail {
/// Resizing template URL (with `{{width}}` / `{{height}}` placeholders)
/// when the media class supports it. `None` for media classes without
/// a thumbnail variant.
/// a thumbnail variant. (Video media files instead have video previews,
/// which, in turn, have their own thumbnail templates.)
pub maybe_thumbnail_template: Option<String>,
/// Video preview images (still and animated gif) for mp4 video files.
/// These are only set for video media files.
pub maybe_video_previews: Option<FolderThumbnailVideoPreviews>,
}
/// Video preview images for a video media file referenced by a folder.
/// Mirrors the shape of `MediaLinks`' video previews, but is its own type
/// so the folder wire shape can evolve independently.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct FolderThumbnailVideoPreviews {
/// A static single frame preview image of the video.
pub still: Url,
/// An animated gif preview of the video.
pub animated: Url,
/// A template used to construct the still thumbnail URL.
/// Replace the string `{WIDTH}` with the desired width.
pub still_thumbnail_template: String,
/// A template used to construct the animated thumbnail URL.
/// Replace the string `{WIDTH}` with the desired width.
pub animated_thumbnail_template: String,
}
@@ -2,5 +2,6 @@ pub mod alerts;
pub mod jobs;
pub mod user;
pub mod user_referrals;
pub mod user_stripe_data;
pub mod wallet_ledger_entries;
pub mod wallets;
@@ -0,0 +1 @@
pub mod moderator_get_user_stripe_customer_ids;
@@ -0,0 +1,43 @@
use enums::common::payments_namespace::PaymentsNamespace;
use serde_derive::{Deserialize, Serialize};
use tokens::tokens::users::UserToken;
use utoipa::ToSchema;
pub const MODERATOR_GET_USER_STRIPE_CUSTOMER_IDS_PATH: &str =
"/v1/moderation/user_stripe_data/{user_token}/customer_ids";
#[derive(Deserialize, ToSchema)]
pub struct ModeratorGetUserStripeCustomerIdsPathInfo {
pub user_token: UserToken,
}
#[derive(Serialize, ToSchema)]
pub struct ModeratorGetUserStripeCustomerIdsResponse {
pub success: bool,
/// All stripe customer ids we have on file for the user, across payments
/// namespaces. A user may have zero or several: a customer link and/or
/// subscriptions per namespace (eg. artcraft), plus legacy fakeyou
/// subscriptions. The same customer id can appear once per source.
pub customer_ids: Vec<ModeratorUserStripeCustomerIdEntry>,
}
#[derive(Serialize, ToSchema)]
pub struct ModeratorUserStripeCustomerIdEntry {
pub stripe_customer_id: String,
pub payments_namespace: PaymentsNamespace,
pub source: ModeratorStripeCustomerIdSource,
/// Link to the customer in the Stripe dashboard for the namespace's account.
pub stripe_dashboard_url: String,
}
/// Which table the customer id was found in.
#[derive(Serialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum ModeratorStripeCustomerIdSource {
/// From the `user_stripe_customer_links` table (1:1 per namespace).
CustomerLink,
/// From the user's rows in the `user_subscriptions` table.
Subscription,
}
@@ -0,0 +1,30 @@
[package]
name = "comet_api_client"
edition = "2024"
version = "0.0.1"
publish = false
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[lib]
name = "comet_api_client"
path = "src/lib.rs"
[dependencies]
# Workspace hack (faster compile times)
workspace-build-acceleration.workspace = true
# External
log.workspace = true
# `multipart` is enabled on top of the workspace feature set because the
# CometAPI video creation endpoint takes `multipart/form-data` (the
# `input_reference` image fields are binary parts).
reqwest = { workspace = true, features = ["multipart"] }
serde.workspace = true
serde_derive.workspace = true
serde_json.workspace = true
[dev-dependencies]
test_data = { path = "../../testing/test_data" }
tokio = { version = "1", features = ["full"] }
@@ -0,0 +1,17 @@
/// A CometAPI key used for Bearer token authentication.
#[derive(Clone)]
pub struct CometApiKey(pub String);
impl CometApiKey {
pub fn new(api_key: String) -> Self {
Self(api_key.trim().to_string())
}
pub fn from_str(api_key: &str) -> Self {
Self(api_key.trim().to_string())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
@@ -0,0 +1 @@
pub mod comet_api_key;
@@ -0,0 +1,157 @@
use reqwest::StatusCode;
use crate::error::comet_bad_request_api_error::CometBadRequestApiError;
use crate::error::comet_error::CometError;
use crate::error::comet_generic_api_error::CometGenericApiError;
use crate::error::comet_specific_api_error::CometSpecificApiError;
/// Marker CometAPI returns from `GET /v1/videos/{id}` for unknown task ids.
const TASK_NOT_EXIST_MARKER: &str = "task_not_exist";
/// Categorize a non-success response from the video creation endpoint
/// (`POST /v1/videos`).
///
/// Per the docs, 400s from this endpoint mean the request itself was invalid
/// (missing prompt, out-of-range duration, image input on an unsupported
/// model) — the caller's fault, surfaced as [`CometBadRequestApiError`].
pub fn categorize_create_video_error(
status_code: StatusCode,
body: String,
) -> CometError {
if status_code == StatusCode::UNAUTHORIZED {
return CometSpecificApiError::UnauthorizedInvalidApiKey { raw_body: body }.into();
}
if status_code == StatusCode::BAD_REQUEST {
return CometBadRequestApiError::InvalidRequestParameters { raw_body: body }.into();
}
CometGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code,
body,
}.into()
}
/// Categorize a non-success response from the poll endpoint
/// (`GET /v1/videos/{id}`).
///
/// Unlike the creation endpoint, a 400 here means the task id is unknown
/// ("task_not_exist") — a specific, well-known cause rather than a
/// user-facing bad request.
pub fn categorize_get_video_task_error(
status_code: StatusCode,
body: String,
task_id: &str,
) -> CometError {
if status_code == StatusCode::UNAUTHORIZED {
return CometSpecificApiError::UnauthorizedInvalidApiKey { raw_body: body }.into();
}
if status_code == StatusCode::BAD_REQUEST && body.contains(TASK_NOT_EXIST_MARKER) {
return CometSpecificApiError::TaskNotFound {
task_id: task_id.to_string(),
raw_body: body,
}.into();
}
CometGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code,
body,
}.into()
}
#[cfg(test)]
mod tests {
use super::*;
mod create_video_tests {
use super::*;
#[test]
fn bad_request_is_caller_fault() {
let body = r#"{"error":{"message":"seconds must be between 4 and 15","type":"invalid_request_error"}}"#.to_string();
let error = categorize_create_video_error(StatusCode::BAD_REQUEST, body.clone());
match error {
CometError::ApiBadRequest(CometBadRequestApiError::InvalidRequestParameters { raw_body }) => {
assert_eq!(raw_body, body);
}
other => panic!("Expected InvalidRequestParameters, got: {:?}", other),
}
}
#[test]
fn unauthorized_is_specific() {
let body = r#"{"error":{"message":"invalid token","type":"cometapi_error"}}"#.to_string();
let error = categorize_create_video_error(StatusCode::UNAUTHORIZED, body);
match error {
CometError::ApiSpecific(CometSpecificApiError::UnauthorizedInvalidApiKey { raw_body }) => {
assert!(raw_body.contains("invalid token"));
}
other => panic!("Expected UnauthorizedInvalidApiKey, got: {:?}", other),
}
}
#[test]
fn server_errors_fall_back_to_uncategorized() {
let error = categorize_create_video_error(
StatusCode::BAD_GATEWAY,
"upstream timeout".to_string(),
);
match &error {
CometError::ApiGeneric(CometGenericApiError::UncategorizedBadResponseWithStatusAndBody { status_code, .. }) => {
assert_eq!(*status_code, StatusCode::BAD_GATEWAY);
}
other => panic!("Expected UncategorizedBadResponseWithStatusAndBody, got: {:?}", other),
}
assert!(error.is_having_downtime_issues());
}
}
mod get_video_task_tests {
use super::*;
#[test]
fn task_not_exist_is_task_not_found() {
let body = r#"{"error":{"message":"task_not_exist","type":"cometapi_error"}}"#.to_string();
let error = categorize_get_video_task_error(StatusCode::BAD_REQUEST, body, "task_abc123");
match error {
CometError::ApiSpecific(CometSpecificApiError::TaskNotFound { task_id, raw_body }) => {
assert_eq!(task_id, "task_abc123");
assert!(raw_body.contains("task_not_exist"));
}
other => panic!("Expected TaskNotFound, got: {:?}", other),
}
}
#[test]
fn other_bad_requests_fall_back_to_uncategorized() {
let body = r#"{"error":{"message":"something else"}}"#.to_string();
let error = categorize_get_video_task_error(StatusCode::BAD_REQUEST, body, "task_abc123");
match error {
CometError::ApiGeneric(CometGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. }) => {}
other => panic!("Expected UncategorizedBadResponseWithStatusAndBody, got: {:?}", other),
}
}
#[test]
fn unauthorized_is_specific() {
let body = r#"{"error":{"message":"invalid token"}}"#.to_string();
let error = categorize_get_video_task_error(StatusCode::UNAUTHORIZED, body, "task_abc123");
match error {
CometError::ApiSpecific(CometSpecificApiError::UnauthorizedInvalidApiKey { .. }) => {}
other => panic!("Expected UnauthorizedInvalidApiKey, got: {:?}", other),
}
}
}
}
@@ -0,0 +1,22 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
/// These represent known "400s" that are the fault of the client sending a
/// bad request. They should surface to the end user as 400s.
#[derive(Debug)]
pub enum CometBadRequestApiError {
/// The video creation request was rejected for invalid parameters (eg.
/// missing prompt, out-of-range duration, an `input_reference` image on a
/// model that doesn't support image input).
InvalidRequestParameters { raw_body: String },
}
impl Error for CometBadRequestApiError {}
impl Display for CometBadRequestApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidRequestParameters { raw_body } => write!(f, "Invalid request parameters: {}", raw_body),
}
}
}
@@ -0,0 +1,26 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum CometClientError {
/// An error was encountered building the reqwest client or the request.
ReqwestClientError(reqwest::Error),
/// A request field couldn't be validated or converted to the value
/// expected on the wire. Carries the field name and the raw input the
/// caller supplied.
InvalidRequestField { field: &'static str, raw_value: String, reason: String },
}
impl Error for CometClientError {}
impl Display for CometClientError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::ReqwestClientError(err) => write!(f, "Reqwest client error: {}", err),
Self::InvalidRequestField { field, raw_value, reason } => {
write!(f, "Invalid value for request field `{}`: {:?} ({})", field, raw_value, reason)
}
}
}
}
@@ -0,0 +1,71 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
use crate::error::comet_bad_request_api_error::CometBadRequestApiError;
use crate::error::comet_client_error::CometClientError;
use crate::error::comet_generic_api_error::CometGenericApiError;
use crate::error::comet_specific_api_error::CometSpecificApiError;
#[derive(Debug)]
pub enum CometError {
/// An error that happened client-side (before or while making the request).
Client(CometClientError),
/// Known "400s" that are the fault of the client sending a bad request.
/// These should surface to the end user as 400s.
ApiBadRequest(CometBadRequestApiError),
/// Server-side errors with specific, well-known causes.
ApiSpecific(CometSpecificApiError),
/// Server-side errors with unknown / grab-bag causes.
ApiGeneric(CometGenericApiError),
}
impl CometError {
pub fn is_having_downtime_issues(&self) -> bool {
match self {
Self::ApiGeneric(CometGenericApiError::UncategorizedBadResponseWithStatusAndBody { status_code, body: _ }) => {
matches!(status_code.as_u16(), 502 | 503 | 504 | 524)
},
_ => false,
}
}
}
impl Error for CometError {}
impl Display for CometError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Client(e) => write!(f, "CometClientError: {:?}", e),
Self::ApiBadRequest(e) => write!(f, "CometBadRequestApiError: {:?}", e),
Self::ApiSpecific(e) => write!(f, "CometSpecificApiError: {:?}", e),
Self::ApiGeneric(e) => write!(f, "CometGenericApiError: {:?}", e),
}
}
}
impl From<CometClientError> for CometError {
fn from(error: CometClientError) -> Self {
Self::Client(error)
}
}
impl From<CometBadRequestApiError> for CometError {
fn from(error: CometBadRequestApiError) -> Self {
Self::ApiBadRequest(error)
}
}
impl From<CometSpecificApiError> for CometError {
fn from(error: CometSpecificApiError) -> Self {
Self::ApiSpecific(error)
}
}
impl From<CometGenericApiError> for CometError {
fn from(error: CometGenericApiError) -> Self {
Self::ApiGeneric(error)
}
}
@@ -0,0 +1,33 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
use reqwest::StatusCode;
/// Server-side errors with unknown / grab-bag causes.
#[derive(Debug)]
pub enum CometGenericApiError {
/// serde_json::Error, likely from a JSON deserialization schema mismatch.
/// Includes the original body.
SerdeResponseParseErrorWithBody(serde_json::Error, String),
/// An uncategorized bad HTTP response with status code and body.
UncategorizedBadResponseWithStatusAndBody {
status_code: StatusCode,
body: String,
},
/// An uncaught error from the HTTP client (network failures, timeouts, etc.)
ReqwestError(reqwest::Error),
}
impl Error for CometGenericApiError {}
impl Display for CometGenericApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::SerdeResponseParseErrorWithBody(err, body) => write!(f, "Failed to parse response body: {:?}. Body: {}", err, body),
Self::UncategorizedBadResponseWithStatusAndBody { status_code, body } => write!(f, "Uncategorized bad response: status code {}, body: {}", status_code, body),
Self::ReqwestError(err) => write!(f, "Reqwest error: {}", err),
}
}
}
@@ -0,0 +1,23 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
/// Server-side errors with specific, well-known causes.
#[derive(Debug)]
pub enum CometSpecificApiError {
/// 401: the API key is missing or invalid.
UnauthorizedInvalidApiKey { raw_body: String },
/// The poll endpoint responded "task_not_exist" for the given task id.
TaskNotFound { task_id: String, raw_body: String },
}
impl Error for CometSpecificApiError {}
impl Display for CometSpecificApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnauthorizedInvalidApiKey { raw_body } => write!(f, "Unauthorized: invalid or missing API key: {}", raw_body),
Self::TaskNotFound { task_id, raw_body } => write!(f, "Task not found: {} ({})", task_id, raw_body),
}
}
}
@@ -0,0 +1,6 @@
pub mod categorize_comet_error;
pub mod comet_bad_request_api_error;
pub mod comet_client_error;
pub mod comet_error;
pub mod comet_generic_api_error;
pub mod comet_specific_api_error;
@@ -0,0 +1 @@
pub mod video;
@@ -0,0 +1,146 @@
use crate::creds::comet_api_key::CometApiKey;
use crate::error::comet_client_error::CometClientError;
use crate::error::comet_error::CometError;
use crate::requests::create_video::create_video::{
create_video, CometVideoModelRaw, CometVideoSize, CreateVideoArgs, CreateVideoRequest,
};
use crate::requests::video_task_status::CometVideoTaskStatus;
/// Seedance 1.0 Pro supports 2-10 second durations.
const MIN_DURATION_SECONDS: u8 = 2;
const MAX_DURATION_SECONDS: u8 = 10;
// ── Args ──
pub struct GenerateDoubaoSeedance1p0ProArgs<'a> {
pub request: GenerateDoubaoSeedance1p0ProRequest,
pub api_key: &'a CometApiKey,
}
// ── Request ──
/// NB: Seedance 1.0 Pro is text-only — `input_reference` images are not
/// supported, so this request deliberately has no image field.
#[derive(Clone, Debug)]
pub struct GenerateDoubaoSeedance1p0ProRequest {
pub prompt: String,
/// 2-10 seconds. `None` uses the API default (5).
pub duration_seconds: Option<u8>,
pub size: Option<DoubaoSeedance1p0ProSize>,
}
// ── Enums ──
#[derive(Debug, Clone, Copy)]
pub enum DoubaoSeedance1p0ProSize {
Landscape16x9,
Standard4x3,
Square1x1,
Portrait3x4,
Portrait9x16,
UltraWide21x9,
Exact { width: u32, height: u32 },
}
// ── Response ──
pub struct GenerateDoubaoSeedance1p0ProResponse {
/// Poll `GET /v1/videos/{task_id}` until terminal.
pub task_id: String,
pub status: CometVideoTaskStatus,
}
// ── Entry point ──
pub async fn generate_doubao_seedance_1p0_pro(
args: GenerateDoubaoSeedance1p0ProArgs<'_>,
) -> Result<GenerateDoubaoSeedance1p0ProResponse, CometError> {
let raw_request = args.request.to_create_video_request()?;
let result = create_video(CreateVideoArgs {
api_key: args.api_key,
request: raw_request,
}).await?;
Ok(GenerateDoubaoSeedance1p0ProResponse {
task_id: result.task_id,
status: result.status,
})
}
impl GenerateDoubaoSeedance1p0ProRequest {
/// Validate and lower to the generic wire request.
pub fn to_create_video_request(&self) -> Result<CreateVideoRequest, CometClientError> {
if let Some(seconds) = self.duration_seconds {
if !(MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&seconds) {
return Err(CometClientError::InvalidRequestField {
field: "duration_seconds",
raw_value: seconds.to_string(),
reason: format!("Seedance 1.0 Pro supports {MIN_DURATION_SECONDS}-{MAX_DURATION_SECONDS} second durations"),
});
}
}
Ok(CreateVideoRequest {
model: CometVideoModelRaw::DoubaoSeedance1p0Pro,
prompt: self.prompt.clone(),
maybe_seconds: self.duration_seconds,
maybe_size: self.size.map(map_size),
input_reference_images: vec![],
})
}
}
fn map_size(size: DoubaoSeedance1p0ProSize) -> CometVideoSize {
match size {
DoubaoSeedance1p0ProSize::Landscape16x9 => CometVideoSize::Landscape16x9,
DoubaoSeedance1p0ProSize::Standard4x3 => CometVideoSize::Standard4x3,
DoubaoSeedance1p0ProSize::Square1x1 => CometVideoSize::Square,
DoubaoSeedance1p0ProSize::Portrait3x4 => CometVideoSize::Portrait3x4,
DoubaoSeedance1p0ProSize::Portrait9x16 => CometVideoSize::Portrait9x16,
DoubaoSeedance1p0ProSize::UltraWide21x9 => CometVideoSize::UltraWide21x9,
DoubaoSeedance1p0ProSize::Exact { width, height } => CometVideoSize::Exact { width, height },
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maps_to_wire_request() {
let request = GenerateDoubaoSeedance1p0ProRequest {
prompt: "time lapse of a city at dusk".to_string(),
duration_seconds: Some(2),
size: Some(DoubaoSeedance1p0ProSize::Square1x1),
};
let raw = request.to_create_video_request().expect("should validate");
assert_eq!(raw.text_form_fields(), vec![
("model", "doubao-seedance-1-0-pro".to_string()),
("prompt", "time lapse of a city at dusk".to_string()),
("seconds", "2".to_string()),
("size", "1:1".to_string()),
]);
assert!(raw.input_reference_images.is_empty());
}
#[test]
fn duration_bounds() {
assert!(request_with_seconds(Some(1)).to_create_video_request().is_err());
assert!(request_with_seconds(Some(2)).to_create_video_request().is_ok());
assert!(request_with_seconds(Some(10)).to_create_video_request().is_ok());
assert!(request_with_seconds(Some(11)).to_create_video_request().is_err());
assert!(request_with_seconds(None).to_create_video_request().is_ok());
}
fn request_with_seconds(duration_seconds: Option<u8>) -> GenerateDoubaoSeedance1p0ProRequest {
GenerateDoubaoSeedance1p0ProRequest {
prompt: "ok".to_string(),
duration_seconds,
size: None,
}
}
}
@@ -0,0 +1,146 @@
use crate::creds::comet_api_key::CometApiKey;
use crate::error::comet_client_error::CometClientError;
use crate::error::comet_error::CometError;
use crate::requests::create_video::create_video::{
create_video, CometVideoModelRaw, CometVideoSize, CreateVideoArgs, CreateVideoRequest,
};
use crate::requests::video_task_status::CometVideoTaskStatus;
/// Seedance 1.5 Pro supports 4-12 second durations.
const MIN_DURATION_SECONDS: u8 = 4;
const MAX_DURATION_SECONDS: u8 = 12;
// ── Args ──
pub struct GenerateDoubaoSeedance1p5ProArgs<'a> {
pub request: GenerateDoubaoSeedance1p5ProRequest,
pub api_key: &'a CometApiKey,
}
// ── Request ──
/// NB: Seedance 1.5 Pro is text-only — `input_reference` images are not
/// supported, so this request deliberately has no image field.
#[derive(Clone, Debug)]
pub struct GenerateDoubaoSeedance1p5ProRequest {
pub prompt: String,
/// 4-12 seconds. `None` uses the API default (5).
pub duration_seconds: Option<u8>,
pub size: Option<DoubaoSeedance1p5ProSize>,
}
// ── Enums ──
#[derive(Debug, Clone, Copy)]
pub enum DoubaoSeedance1p5ProSize {
Landscape16x9,
Standard4x3,
Square1x1,
Portrait3x4,
Portrait9x16,
UltraWide21x9,
Exact { width: u32, height: u32 },
}
// ── Response ──
pub struct GenerateDoubaoSeedance1p5ProResponse {
/// Poll `GET /v1/videos/{task_id}` until terminal.
pub task_id: String,
pub status: CometVideoTaskStatus,
}
// ── Entry point ──
pub async fn generate_doubao_seedance_1p5_pro(
args: GenerateDoubaoSeedance1p5ProArgs<'_>,
) -> Result<GenerateDoubaoSeedance1p5ProResponse, CometError> {
let raw_request = args.request.to_create_video_request()?;
let result = create_video(CreateVideoArgs {
api_key: args.api_key,
request: raw_request,
}).await?;
Ok(GenerateDoubaoSeedance1p5ProResponse {
task_id: result.task_id,
status: result.status,
})
}
impl GenerateDoubaoSeedance1p5ProRequest {
/// Validate and lower to the generic wire request.
pub fn to_create_video_request(&self) -> Result<CreateVideoRequest, CometClientError> {
if let Some(seconds) = self.duration_seconds {
if !(MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&seconds) {
return Err(CometClientError::InvalidRequestField {
field: "duration_seconds",
raw_value: seconds.to_string(),
reason: format!("Seedance 1.5 Pro supports {MIN_DURATION_SECONDS}-{MAX_DURATION_SECONDS} second durations"),
});
}
}
Ok(CreateVideoRequest {
model: CometVideoModelRaw::DoubaoSeedance1p5Pro,
prompt: self.prompt.clone(),
maybe_seconds: self.duration_seconds,
maybe_size: self.size.map(map_size),
input_reference_images: vec![],
})
}
}
fn map_size(size: DoubaoSeedance1p5ProSize) -> CometVideoSize {
match size {
DoubaoSeedance1p5ProSize::Landscape16x9 => CometVideoSize::Landscape16x9,
DoubaoSeedance1p5ProSize::Standard4x3 => CometVideoSize::Standard4x3,
DoubaoSeedance1p5ProSize::Square1x1 => CometVideoSize::Square,
DoubaoSeedance1p5ProSize::Portrait3x4 => CometVideoSize::Portrait3x4,
DoubaoSeedance1p5ProSize::Portrait9x16 => CometVideoSize::Portrait9x16,
DoubaoSeedance1p5ProSize::UltraWide21x9 => CometVideoSize::UltraWide21x9,
DoubaoSeedance1p5ProSize::Exact { width, height } => CometVideoSize::Exact { width, height },
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maps_to_wire_request() {
let request = GenerateDoubaoSeedance1p5ProRequest {
prompt: "a paper boat in a storm drain".to_string(),
duration_seconds: Some(12),
size: Some(DoubaoSeedance1p5ProSize::Portrait9x16),
};
let raw = request.to_create_video_request().expect("should validate");
assert_eq!(raw.text_form_fields(), vec![
("model", "doubao-seedance-1-5-pro".to_string()),
("prompt", "a paper boat in a storm drain".to_string()),
("seconds", "12".to_string()),
("size", "9:16".to_string()),
]);
assert!(raw.input_reference_images.is_empty());
}
#[test]
fn duration_bounds() {
assert!(request_with_seconds(Some(3)).to_create_video_request().is_err());
assert!(request_with_seconds(Some(4)).to_create_video_request().is_ok());
assert!(request_with_seconds(Some(12)).to_create_video_request().is_ok());
assert!(request_with_seconds(Some(13)).to_create_video_request().is_err());
assert!(request_with_seconds(None).to_create_video_request().is_ok());
}
fn request_with_seconds(duration_seconds: Option<u8>) -> GenerateDoubaoSeedance1p5ProRequest {
GenerateDoubaoSeedance1p5ProRequest {
prompt: "ok".to_string(),
duration_seconds,
size: None,
}
}
}
@@ -0,0 +1,471 @@
use crate::creds::comet_api_key::CometApiKey;
use crate::error::comet_client_error::CometClientError;
use crate::error::comet_error::CometError;
use crate::requests::create_video::create_video::{
create_video, CometInputReferenceImage, CometVideoModelRaw, CometVideoSize,
CreateVideoArgs, CreateVideoRequest,
};
use crate::requests::video_task_status::CometVideoTaskStatus;
/// Seedance 2.0 supports 4-15 second durations.
const MIN_DURATION_SECONDS: u8 = 4;
const MAX_DURATION_SECONDS: u8 = 15;
/// The API default duration when `seconds` is omitted.
const DEFAULT_DURATION_SECONDS: u8 = 5;
// ── Args ──
pub struct GenerateDoubaoSeedance2p0Args<'a> {
pub request: GenerateDoubaoSeedance2p0Request,
pub api_key: &'a CometApiKey,
}
// ── Request ──
#[derive(Clone, Debug)]
pub struct GenerateDoubaoSeedance2p0Request {
/// Reference attached images in the prompt as `[Image 1]`, `[Image 2]`, etc.
pub prompt: String,
/// 4-15 seconds. `None` uses the API default (5).
pub duration_seconds: Option<u8>,
/// `None` defaults to 16:9 (when a resolution is set).
pub aspect_ratio: Option<DoubaoSeedance2p0AspectRatio>,
/// `None` defaults to 720p (when an aspect ratio is set).
pub resolution: Option<DoubaoSeedance2p0Resolution>,
/// Reference images (JPEG, PNG, WebP) for image-to-video.
pub input_reference_images: Vec<CometInputReferenceImage>,
}
// ── Enums ──
#[derive(Debug, Clone, Copy)]
pub enum DoubaoSeedance2p0AspectRatio {
Landscape16x9,
Standard4x3,
Square1x1,
Portrait3x4,
Portrait9x16,
UltraWide21x9,
}
#[derive(Debug, Clone, Copy)]
pub enum DoubaoSeedance2p0Resolution {
FourEightyP,
SevenTwentyP,
TenEightyP,
}
// ── Pricing ──
//
// Seedance 2.0 per-second USD pricing (via CometAPI):
//
// | Resolution | $/sec |
// |------------|--------|
// | 480p | 0.063 |
// | 720p | 0.1368 |
// | 1080p | 0.3366 |
//
// Total cost = price per second × video duration (seconds).
impl GenerateDoubaoSeedance2p0Request {
/// Estimate the USD cost in cents for this generation request.
pub fn estimate_cost_in_usd_cents(&self) -> u64 {
let dollars_per_second: f64 = match self.resolution {
Some(DoubaoSeedance2p0Resolution::FourEightyP) => 0.063,
// Default resolution (None) is 720p.
Some(DoubaoSeedance2p0Resolution::SevenTwentyP) | None => 0.1368,
Some(DoubaoSeedance2p0Resolution::TenEightyP) => 0.3366,
};
let seconds = self.duration_seconds.unwrap_or(DEFAULT_DURATION_SECONDS);
(f64::from(seconds) * dollars_per_second * 100.0).round() as u64
}
}
// ── Response ──
pub struct GenerateDoubaoSeedance2p0Response {
/// Poll `GET /v1/videos/{task_id}` until terminal.
pub task_id: String,
pub status: CometVideoTaskStatus,
}
// ── Entry point ──
pub async fn generate_doubao_seedance_2p0(
args: GenerateDoubaoSeedance2p0Args<'_>,
) -> Result<GenerateDoubaoSeedance2p0Response, CometError> {
let raw_request = args.request.to_create_video_request()?;
let result = create_video(CreateVideoArgs {
api_key: args.api_key,
request: raw_request,
}).await?;
Ok(GenerateDoubaoSeedance2p0Response {
task_id: result.task_id,
status: result.status,
})
}
impl GenerateDoubaoSeedance2p0Request {
/// Validate and lower to the generic wire request.
pub fn to_create_video_request(&self) -> Result<CreateVideoRequest, CometClientError> {
if let Some(seconds) = self.duration_seconds {
if !(MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&seconds) {
return Err(CometClientError::InvalidRequestField {
field: "duration_seconds",
raw_value: seconds.to_string(),
reason: format!("Seedance 2.0 supports {MIN_DURATION_SECONDS}-{MAX_DURATION_SECONDS} second durations"),
});
}
}
Ok(CreateVideoRequest {
model: CometVideoModelRaw::DoubaoSeedance2p0,
prompt: self.prompt.clone(),
maybe_seconds: self.duration_seconds,
maybe_size: self.resolve_size(),
input_reference_images: self.input_reference_images.clone(),
})
}
/// The wire `size` is a single exact-dimensions field determined by the
/// (aspect ratio, resolution) pair. Dimensions mirror CometAPI's own
/// playground options for `doubao-seedance-2-0`. When neither knob is
/// set, `size` is omitted and the API picks its default.
fn resolve_size(&self) -> Option<CometVideoSize> {
if self.aspect_ratio.is_none() && self.resolution.is_none() {
return None;
}
let aspect_ratio = self.aspect_ratio.unwrap_or(DoubaoSeedance2p0AspectRatio::Landscape16x9);
let resolution = self.resolution.unwrap_or(DoubaoSeedance2p0Resolution::SevenTwentyP);
use DoubaoSeedance2p0AspectRatio as Ratio;
use DoubaoSeedance2p0Resolution as Res;
let (width, height) = match (resolution, aspect_ratio) {
(Res::FourEightyP, Ratio::Landscape16x9) => (864, 496),
(Res::FourEightyP, Ratio::Standard4x3) => (752, 560),
(Res::FourEightyP, Ratio::Square1x1) => (640, 640),
(Res::FourEightyP, Ratio::Portrait3x4) => (560, 752),
(Res::FourEightyP, Ratio::Portrait9x16) => (496, 864),
(Res::FourEightyP, Ratio::UltraWide21x9) => (992, 432),
(Res::SevenTwentyP, Ratio::Landscape16x9) => (1280, 720),
(Res::SevenTwentyP, Ratio::Standard4x3) => (1112, 834),
(Res::SevenTwentyP, Ratio::Square1x1) => (960, 960),
(Res::SevenTwentyP, Ratio::Portrait3x4) => (834, 1112),
(Res::SevenTwentyP, Ratio::Portrait9x16) => (720, 1280),
(Res::SevenTwentyP, Ratio::UltraWide21x9) => (1470, 630),
(Res::TenEightyP, Ratio::Landscape16x9) => (1920, 1080),
(Res::TenEightyP, Ratio::Standard4x3) => (1664, 1248),
(Res::TenEightyP, Ratio::Square1x1) => (1440, 1440),
(Res::TenEightyP, Ratio::Portrait3x4) => (1248, 1664),
(Res::TenEightyP, Ratio::Portrait9x16) => (1080, 1920),
(Res::TenEightyP, Ratio::UltraWide21x9) => (2206, 946),
};
Some(CometVideoSize::Exact { width, height })
}
}
#[cfg(test)]
mod tests {
use super::*;
use DoubaoSeedance2p0AspectRatio as Ratio;
use DoubaoSeedance2p0Resolution as Res;
#[test]
fn maps_to_wire_request() {
let request = GenerateDoubaoSeedance2p0Request {
prompt: "animate [Image 1]".to_string(),
duration_seconds: Some(8),
aspect_ratio: Some(Ratio::UltraWide21x9),
resolution: Some(Res::TenEightyP),
input_reference_images: vec![],
};
let raw = request.to_create_video_request().expect("should validate");
assert_eq!(raw.text_form_fields(), vec![
("model", "doubao-seedance-2-0".to_string()),
("prompt", "animate [Image 1]".to_string()),
("seconds", "8".to_string()),
("size", "2206x946".to_string()),
]);
}
#[test]
fn size_matrix_matches_cometapi_playground() {
// (resolution, ratio) -> exact size, as offered by CometAPI's own
// playground dropdown for doubao-seedance-2-0.
let cases = [
(Res::FourEightyP, Ratio::Landscape16x9, "864x496"),
(Res::FourEightyP, Ratio::Standard4x3, "752x560"),
(Res::FourEightyP, Ratio::Square1x1, "640x640"),
(Res::FourEightyP, Ratio::Portrait3x4, "560x752"),
(Res::FourEightyP, Ratio::Portrait9x16, "496x864"),
(Res::FourEightyP, Ratio::UltraWide21x9, "992x432"),
(Res::SevenTwentyP, Ratio::Landscape16x9, "1280x720"),
(Res::SevenTwentyP, Ratio::Standard4x3, "1112x834"),
(Res::SevenTwentyP, Ratio::Square1x1, "960x960"),
(Res::SevenTwentyP, Ratio::Portrait3x4, "834x1112"),
(Res::SevenTwentyP, Ratio::Portrait9x16, "720x1280"),
(Res::SevenTwentyP, Ratio::UltraWide21x9, "1470x630"),
(Res::TenEightyP, Ratio::Landscape16x9, "1920x1080"),
(Res::TenEightyP, Ratio::Standard4x3, "1664x1248"),
(Res::TenEightyP, Ratio::Square1x1, "1440x1440"),
(Res::TenEightyP, Ratio::Portrait3x4, "1248x1664"),
(Res::TenEightyP, Ratio::Portrait9x16, "1080x1920"),
(Res::TenEightyP, Ratio::UltraWide21x9, "2206x946"),
];
for (resolution, aspect_ratio, expected) in cases {
let request = base_request(Some(aspect_ratio), Some(resolution));
let size = request.resolve_size().expect("should resolve");
assert_eq!(size.as_api_string(), expected,
"({resolution:?}, {aspect_ratio:?}) should map to {expected}");
}
}
#[test]
fn size_defaults() {
// Neither knob set: omit size, let the API default.
assert!(base_request(None, None).resolve_size().is_none());
// Only ratio set: resolution defaults to 720p.
let size = base_request(Some(Ratio::Portrait9x16), None).resolve_size().expect("should resolve");
assert_eq!(size.as_api_string(), "720x1280");
// Only resolution set: ratio defaults to 16:9.
let size = base_request(None, Some(Res::TenEightyP)).resolve_size().expect("should resolve");
assert_eq!(size.as_api_string(), "1920x1080");
}
#[test]
fn duration_bounds() {
let mut request = base_request(None, None);
for (seconds, ok) in [(Some(3), false), (Some(4), true), (Some(15), true), (Some(16), false), (None, true)] {
request.duration_seconds = seconds;
assert_eq!(request.to_create_video_request().is_ok(), ok, "seconds: {seconds:?}");
}
}
#[test]
fn cost_estimates() {
// 5 seconds at 480p: 5 × $0.063 = $0.315 -> 32¢
let mut request = base_request(None, Some(Res::FourEightyP));
request.duration_seconds = Some(5);
assert_eq!(request.estimate_cost_in_usd_cents(), 32);
// 10 seconds at 1080p: 10 × $0.3366 = $3.366 -> 337¢
request.resolution = Some(Res::TenEightyP);
request.duration_seconds = Some(10);
assert_eq!(request.estimate_cost_in_usd_cents(), 337);
// Defaults (5 seconds, 720p): 5 × $0.1368 = $0.684 -> 68¢
request.resolution = None;
request.duration_seconds = None;
assert_eq!(request.estimate_cost_in_usd_cents(), 68);
}
fn base_request(
aspect_ratio: Option<DoubaoSeedance2p0AspectRatio>,
resolution: Option<DoubaoSeedance2p0Resolution>,
) -> GenerateDoubaoSeedance2p0Request {
GenerateDoubaoSeedance2p0Request {
prompt: "ok".to_string(),
duration_seconds: None,
aspect_ratio,
resolution,
input_reference_images: vec![],
}
}
// ─────────────────────────────────────────────────────────────────────
// ⚠️ EXPENSIVE LIVE TESTS ⚠️
//
// Each test below enqueues a REAL 10-second Seedance 2.0 generation with
// four reference images and polls it to completion. These INCUR REAL
// COSTS (per CometAPI's per-second pricing):
//
// 480p: 10s × $0.063 ≈ $0.63
// 720p: 10s × $0.1368 ≈ $1.37
// 1080p: 10s × $0.3366 ≈ $3.37
//
// They also take a while — generations have been observed to run well
// past 10 minutes. Run one at a time, manually:
//
// cargo test -p comet_api_client live_generate_480p -- --ignored --nocapture
// ─────────────────────────────────────────────────────────────────────
mod live_tests {
use std::time::Duration;
use test_data::web::image_urls::{
ERNEST_GHOST_TREX_IMAGE_URL,
FOREST_BACKDROP_IMAGE_URL,
GHOST_IMAGE_URL,
GRASSY_HILL_TRANSPARENT_IMAGE_URL,
JUNO_AT_LAKE_IMAGE_URL,
MOUNTAIN_TREE_IMAGE_URL,
SUPER_WIDE_FALL_MOUNTAINS_IMAGE_URL,
TALL_CORGI_SHIBA_TREASURE_OCEAN_URL,
TALL_MOCHI_WITH_GLASSES_IMAGE_URL,
TREX_SKELETON_IMAGE_URL,
WHITE_HOUSE_SUNSET_IMAGE_URL,
WIDE_CORGI_SHIBA_TREASURE_OCEAN_URL,
};
use super::*;
use crate::requests::get_video_task::get_video_task::{get_video_task, GetVideoTaskArgs};
use crate::test_utils::load_api_key;
/// ⚠️ EXPENSIVE: real 10s 480p generation (~$0.63) + a long poll.
#[ignore]
#[tokio::test]
async fn live_generate_480p_with_references() {
let input_reference_images = download_reference_images(&[
ERNEST_GHOST_TREX_IMAGE_URL,
GHOST_IMAGE_URL,
TREX_SKELETON_IMAGE_URL,
FOREST_BACKDROP_IMAGE_URL,
]).await;
run_generation_to_completion(GenerateDoubaoSeedance2p0Request {
prompt: "The frightened man from [Image 1] runs through the dark forest \
from [Image 4], chased by the glowing ghost from [Image 2]. The t-rex \
skeleton from [Image 3] rises out of the leaves behind them. Spooky, \
comedic 90s horror movie tone."
.to_string(),
duration_seconds: Some(10),
aspect_ratio: Some(DoubaoSeedance2p0AspectRatio::Landscape16x9),
resolution: Some(DoubaoSeedance2p0Resolution::FourEightyP),
input_reference_images,
}).await;
}
/// ⚠️ EXPENSIVE: real 10s 720p generation (~$1.37) + a long poll.
#[ignore]
#[tokio::test]
async fn live_generate_720p_with_references() {
let input_reference_images = download_reference_images(&[
JUNO_AT_LAKE_IMAGE_URL,
TALL_MOCHI_WITH_GLASSES_IMAGE_URL,
TALL_CORGI_SHIBA_TREASURE_OCEAN_URL,
WIDE_CORGI_SHIBA_TREASURE_OCEAN_URL,
]).await;
run_generation_to_completion(GenerateDoubaoSeedance2p0Request {
prompt: "The dog from [Image 1] and the bespectacled dog from [Image 2] \
join the corgi and shiba from [Image 3] on the beach, digging up the \
treasure chest from [Image 4] as waves roll in. Joyful, sunny \
adventure-film tone."
.to_string(),
duration_seconds: Some(10),
aspect_ratio: Some(DoubaoSeedance2p0AspectRatio::Landscape16x9),
resolution: Some(DoubaoSeedance2p0Resolution::SevenTwentyP),
input_reference_images,
}).await;
}
/// ⚠️ EXPENSIVE: real 10s 1080p generation (~$3.37) + a long poll.
#[ignore]
#[tokio::test]
async fn live_generate_1080p_with_references() {
let input_reference_images = download_reference_images(&[
GRASSY_HILL_TRANSPARENT_IMAGE_URL,
MOUNTAIN_TREE_IMAGE_URL,
SUPER_WIDE_FALL_MOUNTAINS_IMAGE_URL,
WHITE_HOUSE_SUNSET_IMAGE_URL,
]).await;
run_generation_to_completion(GenerateDoubaoSeedance2p0Request {
prompt: "A cinematic drone shot glides over the grassy hill from \
[Image 1], past the lone tree on the mountainside from [Image 2], \
across the sweeping autumn mountain range from [Image 3], and settles \
on the white house at sunset from [Image 4]. Golden hour, slow and \
steady camera movement."
.to_string(),
duration_seconds: Some(10),
aspect_ratio: Some(DoubaoSeedance2p0AspectRatio::Landscape16x9),
resolution: Some(DoubaoSeedance2p0Resolution::TenEightyP),
input_reference_images,
}).await;
}
// ── Helpers ──
async fn download_reference_images(urls: &[&str]) -> Vec<CometInputReferenceImage> {
let mut images = Vec::with_capacity(urls.len());
for url in urls {
images.push(download_reference_image(url).await);
}
images
}
async fn download_reference_image(url: &str) -> CometInputReferenceImage {
let response = reqwest::get(url).await.expect("image download should succeed");
assert!(response.status().is_success(), "image download failed: {} ({})", url, response.status());
let filename = url.rsplit('/').next().expect("url should have a path").to_string();
let maybe_content_type = if filename.ends_with(".png") {
Some("image/png".to_string())
} else {
Some("image/jpeg".to_string())
};
let file_bytes = response.bytes().await.expect("image body should download").to_vec();
println!("Downloaded reference: {} ({} bytes)", filename, file_bytes.len());
CometInputReferenceImage {
file_bytes,
filename,
maybe_content_type,
}
}
/// Enqueue the generation, poll until terminal, and assert success.
async fn run_generation_to_completion(request: GenerateDoubaoSeedance2p0Request) {
// NB: Generations have been observed to run well past 10 minutes.
const POLL_INTERVAL: Duration = Duration::from_secs(15);
const MAX_POLLS: u32 = 120; // ~30 minutes
let api_key = load_api_key();
println!("Estimated cost: {}¢", request.estimate_cost_in_usd_cents());
let created = generate_doubao_seedance_2p0(GenerateDoubaoSeedance2p0Args {
request,
api_key: &api_key,
}).await.expect("generate_doubao_seedance_2p0 should succeed");
println!("Enqueued task: {} (status: {})", created.task_id, created.status);
for poll in 1..=MAX_POLLS {
tokio::time::sleep(POLL_INTERVAL).await;
let task = get_video_task(GetVideoTaskArgs {
api_key: &api_key,
task_id: &created.task_id,
}).await.expect("get_video_task should succeed");
println!("Poll {}: status {} (progress: {:?})", poll, task.status, task.maybe_progress);
if task.status.is_terminal() {
assert!(task.status.is_success(), "task should complete successfully: {:?}", task);
let video_url = task.maybe_video_url.expect("completed task should have a video url");
println!("Video URL: {}", video_url);
return;
}
}
panic!("Task {} did not reach a terminal state in time", created.task_id);
}
}
}
@@ -0,0 +1,278 @@
use crate::creds::comet_api_key::CometApiKey;
use crate::error::comet_client_error::CometClientError;
use crate::error::comet_error::CometError;
use crate::requests::create_video::create_video::{
create_video, CometInputReferenceImage, CometVideoModelRaw, CometVideoSize,
CreateVideoArgs, CreateVideoRequest,
};
use crate::requests::video_task_status::CometVideoTaskStatus;
/// Seedance 2.0 Fast supports 4-15 second durations.
const MIN_DURATION_SECONDS: u8 = 4;
const MAX_DURATION_SECONDS: u8 = 15;
/// The API default duration when `seconds` is omitted.
const DEFAULT_DURATION_SECONDS: u8 = 5;
// ── Args ──
pub struct GenerateDoubaoSeedance2p0FastArgs<'a> {
pub request: GenerateDoubaoSeedance2p0FastRequest,
pub api_key: &'a CometApiKey,
}
// ── Request ──
#[derive(Clone, Debug)]
pub struct GenerateDoubaoSeedance2p0FastRequest {
/// Reference attached images in the prompt as `[Image 1]`, `[Image 2]`, etc.
pub prompt: String,
/// 4-15 seconds. `None` uses the API default (5).
pub duration_seconds: Option<u8>,
/// `None` defaults to 16:9 (when a resolution is set).
pub aspect_ratio: Option<DoubaoSeedance2p0FastAspectRatio>,
/// `None` defaults to 720p (when an aspect ratio is set).
pub resolution: Option<DoubaoSeedance2p0FastResolution>,
/// Reference images (JPEG, PNG, WebP) for image-to-video.
pub input_reference_images: Vec<CometInputReferenceImage>,
}
// ── Enums ──
#[derive(Debug, Clone, Copy)]
pub enum DoubaoSeedance2p0FastAspectRatio {
Landscape16x9,
Standard4x3,
Square1x1,
Portrait3x4,
Portrait9x16,
UltraWide21x9,
}
/// NB: 1080p is available for all Seedance models EXCEPT 2.0 Fast, so this
/// enum deliberately has no 1080p variant.
#[derive(Debug, Clone, Copy)]
pub enum DoubaoSeedance2p0FastResolution {
FourEightyP,
SevenTwentyP,
}
// ── Pricing ──
//
// Seedance 2.0 Fast per-second USD pricing (via CometAPI):
//
// | Resolution | $/sec |
// |------------|--------|
// | 480p | 0.0504 |
// | 720p | 0.108 |
//
// Total cost = price per second × video duration (seconds).
impl GenerateDoubaoSeedance2p0FastRequest {
/// Estimate the USD cost in cents for this generation request.
pub fn estimate_cost_in_usd_cents(&self) -> u64 {
let dollars_per_second: f64 = match self.resolution {
Some(DoubaoSeedance2p0FastResolution::FourEightyP) => 0.0504,
// Default resolution (None) is 720p.
Some(DoubaoSeedance2p0FastResolution::SevenTwentyP) | None => 0.108,
};
let seconds = self.duration_seconds.unwrap_or(DEFAULT_DURATION_SECONDS);
(f64::from(seconds) * dollars_per_second * 100.0).round() as u64
}
}
// ── Response ──
pub struct GenerateDoubaoSeedance2p0FastResponse {
/// Poll `GET /v1/videos/{task_id}` until terminal.
pub task_id: String,
pub status: CometVideoTaskStatus,
}
// ── Entry point ──
pub async fn generate_doubao_seedance_2p0_fast(
args: GenerateDoubaoSeedance2p0FastArgs<'_>,
) -> Result<GenerateDoubaoSeedance2p0FastResponse, CometError> {
let raw_request = args.request.to_create_video_request()?;
let result = create_video(CreateVideoArgs {
api_key: args.api_key,
request: raw_request,
}).await?;
Ok(GenerateDoubaoSeedance2p0FastResponse {
task_id: result.task_id,
status: result.status,
})
}
impl GenerateDoubaoSeedance2p0FastRequest {
/// Validate and lower to the generic wire request.
pub fn to_create_video_request(&self) -> Result<CreateVideoRequest, CometClientError> {
if let Some(seconds) = self.duration_seconds {
if !(MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&seconds) {
return Err(CometClientError::InvalidRequestField {
field: "duration_seconds",
raw_value: seconds.to_string(),
reason: format!("Seedance 2.0 Fast supports {MIN_DURATION_SECONDS}-{MAX_DURATION_SECONDS} second durations"),
});
}
}
Ok(CreateVideoRequest {
model: CometVideoModelRaw::DoubaoSeedance2p0Fast,
prompt: self.prompt.clone(),
maybe_seconds: self.duration_seconds,
maybe_size: self.resolve_size(),
input_reference_images: self.input_reference_images.clone(),
})
}
/// The wire `size` is a single exact-dimensions field determined by the
/// (aspect ratio, resolution) pair. Dimensions mirror CometAPI's own
/// playground options for the Seedance 2.0 family. When neither knob is
/// set, `size` is omitted and the API picks its default.
fn resolve_size(&self) -> Option<CometVideoSize> {
if self.aspect_ratio.is_none() && self.resolution.is_none() {
return None;
}
let aspect_ratio = self.aspect_ratio.unwrap_or(DoubaoSeedance2p0FastAspectRatio::Landscape16x9);
let resolution = self.resolution.unwrap_or(DoubaoSeedance2p0FastResolution::SevenTwentyP);
use DoubaoSeedance2p0FastAspectRatio as Ratio;
use DoubaoSeedance2p0FastResolution as Res;
let (width, height) = match (resolution, aspect_ratio) {
(Res::FourEightyP, Ratio::Landscape16x9) => (864, 496),
(Res::FourEightyP, Ratio::Standard4x3) => (752, 560),
(Res::FourEightyP, Ratio::Square1x1) => (640, 640),
(Res::FourEightyP, Ratio::Portrait3x4) => (560, 752),
(Res::FourEightyP, Ratio::Portrait9x16) => (496, 864),
(Res::FourEightyP, Ratio::UltraWide21x9) => (992, 432),
(Res::SevenTwentyP, Ratio::Landscape16x9) => (1280, 720),
(Res::SevenTwentyP, Ratio::Standard4x3) => (1112, 834),
(Res::SevenTwentyP, Ratio::Square1x1) => (960, 960),
(Res::SevenTwentyP, Ratio::Portrait3x4) => (834, 1112),
(Res::SevenTwentyP, Ratio::Portrait9x16) => (720, 1280),
(Res::SevenTwentyP, Ratio::UltraWide21x9) => (1470, 630),
};
Some(CometVideoSize::Exact { width, height })
}
}
#[cfg(test)]
mod tests {
use super::*;
use DoubaoSeedance2p0FastAspectRatio as Ratio;
use DoubaoSeedance2p0FastResolution as Res;
#[test]
fn maps_to_wire_request() {
let request = GenerateDoubaoSeedance2p0FastRequest {
prompt: "a corgi surfing".to_string(),
duration_seconds: Some(4),
aspect_ratio: Some(Ratio::Landscape16x9),
resolution: Some(Res::FourEightyP),
input_reference_images: vec![],
};
let raw = request.to_create_video_request().expect("should validate");
assert_eq!(raw.text_form_fields(), vec![
("model", "doubao-seedance-2-0-fast".to_string()),
("prompt", "a corgi surfing".to_string()),
("seconds", "4".to_string()),
("size", "864x496".to_string()),
]);
}
#[test]
fn size_matrix_matches_cometapi_playground() {
// (resolution, ratio) -> exact size, as offered by CometAPI's own
// playground dropdown for the Seedance 2.0 family. No 1080p row: Fast
// doesn't support it (enforced by the resolution enum).
let cases = [
(Res::FourEightyP, Ratio::Landscape16x9, "864x496"),
(Res::FourEightyP, Ratio::Standard4x3, "752x560"),
(Res::FourEightyP, Ratio::Square1x1, "640x640"),
(Res::FourEightyP, Ratio::Portrait3x4, "560x752"),
(Res::FourEightyP, Ratio::Portrait9x16, "496x864"),
(Res::FourEightyP, Ratio::UltraWide21x9, "992x432"),
(Res::SevenTwentyP, Ratio::Landscape16x9, "1280x720"),
(Res::SevenTwentyP, Ratio::Standard4x3, "1112x834"),
(Res::SevenTwentyP, Ratio::Square1x1, "960x960"),
(Res::SevenTwentyP, Ratio::Portrait3x4, "834x1112"),
(Res::SevenTwentyP, Ratio::Portrait9x16, "720x1280"),
(Res::SevenTwentyP, Ratio::UltraWide21x9, "1470x630"),
];
for (resolution, aspect_ratio, expected) in cases {
let request = base_request(Some(aspect_ratio), Some(resolution));
let size = request.resolve_size().expect("should resolve");
assert_eq!(size.as_api_string(), expected,
"({resolution:?}, {aspect_ratio:?}) should map to {expected}");
}
}
#[test]
fn size_defaults() {
// Neither knob set: omit size, let the API default.
assert!(base_request(None, None).resolve_size().is_none());
// Only ratio set: resolution defaults to 720p.
let size = base_request(Some(Ratio::Square1x1), None).resolve_size().expect("should resolve");
assert_eq!(size.as_api_string(), "960x960");
// Only resolution set: ratio defaults to 16:9.
let size = base_request(None, Some(Res::FourEightyP)).resolve_size().expect("should resolve");
assert_eq!(size.as_api_string(), "864x496");
}
#[test]
fn duration_bounds() {
let mut request = base_request(None, None);
for (seconds, ok) in [(Some(3), false), (Some(4), true), (Some(15), true), (Some(16), false), (None, true)] {
request.duration_seconds = seconds;
assert_eq!(request.to_create_video_request().is_ok(), ok, "seconds: {seconds:?}");
}
}
#[test]
fn cost_estimates() {
// 5 seconds at 480p: 5 × $0.0504 = $0.252 -> 25¢
let mut request = base_request(None, Some(Res::FourEightyP));
request.duration_seconds = Some(5);
assert_eq!(request.estimate_cost_in_usd_cents(), 25);
// 15 seconds at 720p: 15 × $0.108 = $1.62 -> 162¢
request.resolution = Some(Res::SevenTwentyP);
request.duration_seconds = Some(15);
assert_eq!(request.estimate_cost_in_usd_cents(), 162);
// Defaults (5 seconds, 720p): 5 × $0.108 = $0.54 -> 54¢
request.resolution = None;
request.duration_seconds = None;
assert_eq!(request.estimate_cost_in_usd_cents(), 54);
}
fn base_request(
aspect_ratio: Option<DoubaoSeedance2p0FastAspectRatio>,
resolution: Option<DoubaoSeedance2p0FastResolution>,
) -> GenerateDoubaoSeedance2p0FastRequest {
GenerateDoubaoSeedance2p0FastRequest {
prompt: "ok".to_string(),
duration_seconds: None,
aspect_ratio,
resolution,
input_reference_images: vec![],
}
}
}
@@ -0,0 +1,162 @@
use crate::creds::comet_api_key::CometApiKey;
use crate::error::comet_client_error::CometClientError;
use crate::error::comet_error::CometError;
use crate::requests::create_video::create_video::{
create_video, CometInputReferenceImage, CometVideoModelRaw, CometVideoSize,
CreateVideoArgs, CreateVideoRequest,
};
use crate::requests::video_task_status::CometVideoTaskStatus;
/// Vidu Q3 supports 1-16 second durations.
const MIN_DURATION_SECONDS: u8 = 1;
const MAX_DURATION_SECONDS: u8 = 16;
// ── Args ──
pub struct GenerateViduQ3Args<'a> {
pub request: GenerateViduQ3Request,
pub api_key: &'a CometApiKey,
}
// ── Request ──
#[derive(Clone, Debug)]
pub struct GenerateViduQ3Request {
pub prompt: String,
/// 1-16 seconds. `None` uses the API default (5).
pub duration_seconds: Option<u8>,
pub size: Option<ViduQ3Size>,
/// Reference images for image-to-video / start-end-to-video workflows.
pub input_reference_images: Vec<CometInputReferenceImage>,
}
// ── Enums ──
/// Vidu Q3 renders at 540p/720p/1080p (24 FPS). The API's `size` field takes
/// an aspect ratio preset or exact dimensions (eg. 960x528).
#[derive(Debug, Clone, Copy)]
pub enum ViduQ3Size {
Landscape16x9,
Standard4x3,
Square1x1,
Portrait3x4,
Portrait9x16,
Exact { width: u32, height: u32 },
}
// ── Pricing ──
//
// Vidu Q3 per-second USD pricing (via CometAPI):
//
// | Resolution | $/sec |
// |-------------|---------|
// | 360p/540p | 0.056 |
// | 720p/1080p | 0.1232 |
//
// Total cost = price per second × video duration (seconds).
// NB: Not modeled as an estimator because the `size` field is an aspect
// ratio in the common case, which doesn't determine the billed resolution.
// ── Response ──
pub struct GenerateViduQ3Response {
/// Poll `GET /v1/videos/{task_id}` until terminal.
pub task_id: String,
pub status: CometVideoTaskStatus,
}
// ── Entry point ──
pub async fn generate_vidu_q3(
args: GenerateViduQ3Args<'_>,
) -> Result<GenerateViduQ3Response, CometError> {
let raw_request = args.request.to_create_video_request()?;
let result = create_video(CreateVideoArgs {
api_key: args.api_key,
request: raw_request,
}).await?;
Ok(GenerateViduQ3Response {
task_id: result.task_id,
status: result.status,
})
}
impl GenerateViduQ3Request {
/// Validate and lower to the generic wire request.
pub fn to_create_video_request(&self) -> Result<CreateVideoRequest, CometClientError> {
if let Some(seconds) = self.duration_seconds {
if !(MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&seconds) {
return Err(CometClientError::InvalidRequestField {
field: "duration_seconds",
raw_value: seconds.to_string(),
reason: format!("Vidu Q3 supports {MIN_DURATION_SECONDS}-{MAX_DURATION_SECONDS} second durations"),
});
}
}
Ok(CreateVideoRequest {
model: CometVideoModelRaw::ViduQ3,
prompt: self.prompt.clone(),
maybe_seconds: self.duration_seconds,
maybe_size: self.size.map(map_size),
input_reference_images: self.input_reference_images.clone(),
})
}
}
fn map_size(size: ViduQ3Size) -> CometVideoSize {
match size {
ViduQ3Size::Landscape16x9 => CometVideoSize::Landscape16x9,
ViduQ3Size::Standard4x3 => CometVideoSize::Standard4x3,
ViduQ3Size::Square1x1 => CometVideoSize::Square,
ViduQ3Size::Portrait3x4 => CometVideoSize::Portrait3x4,
ViduQ3Size::Portrait9x16 => CometVideoSize::Portrait9x16,
ViduQ3Size::Exact { width, height } => CometVideoSize::Exact { width, height },
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maps_to_wire_request() {
let request = GenerateViduQ3Request {
prompt: "a lighthouse in fog".to_string(),
duration_seconds: Some(16),
size: Some(ViduQ3Size::Exact { width: 960, height: 528 }),
input_reference_images: vec![],
};
let raw = request.to_create_video_request().expect("should validate");
assert_eq!(raw.text_form_fields(), vec![
("model", "viduq3".to_string()),
("prompt", "a lighthouse in fog".to_string()),
("seconds", "16".to_string()),
("size", "960x528".to_string()),
]);
}
#[test]
fn duration_bounds() {
assert!(request_with_seconds(Some(0)).to_create_video_request().is_err());
assert!(request_with_seconds(Some(1)).to_create_video_request().is_ok());
assert!(request_with_seconds(Some(16)).to_create_video_request().is_ok());
assert!(request_with_seconds(Some(17)).to_create_video_request().is_err());
assert!(request_with_seconds(None).to_create_video_request().is_ok());
}
fn request_with_seconds(duration_seconds: Option<u8>) -> GenerateViduQ3Request {
GenerateViduQ3Request {
prompt: "ok".to_string(),
duration_seconds,
size: None,
input_reference_images: vec![],
}
}
}
@@ -0,0 +1,162 @@
use crate::creds::comet_api_key::CometApiKey;
use crate::error::comet_client_error::CometClientError;
use crate::error::comet_error::CometError;
use crate::requests::create_video::create_video::{
create_video, CometInputReferenceImage, CometVideoModelRaw, CometVideoSize,
CreateVideoArgs, CreateVideoRequest,
};
use crate::requests::video_task_status::CometVideoTaskStatus;
/// Vidu Q3 Turbo supports 1-16 second durations.
const MIN_DURATION_SECONDS: u8 = 1;
const MAX_DURATION_SECONDS: u8 = 16;
// ── Args ──
pub struct GenerateViduQ3TurboArgs<'a> {
pub request: GenerateViduQ3TurboRequest,
pub api_key: &'a CometApiKey,
}
// ── Request ──
#[derive(Clone, Debug)]
pub struct GenerateViduQ3TurboRequest {
pub prompt: String,
/// 1-16 seconds. `None` uses the API default (5).
pub duration_seconds: Option<u8>,
pub size: Option<ViduQ3TurboSize>,
/// Reference images for image-to-video / start-end-to-video workflows.
pub input_reference_images: Vec<CometInputReferenceImage>,
}
// ── Enums ──
/// Vidu Q3 Turbo renders at 540p/720p/1080p (24 FPS). The API's `size` field
/// takes an aspect ratio preset or exact dimensions (eg. 960x528).
#[derive(Debug, Clone, Copy)]
pub enum ViduQ3TurboSize {
Landscape16x9,
Standard4x3,
Square1x1,
Portrait3x4,
Portrait9x16,
Exact { width: u32, height: u32 },
}
// ── Pricing ──
//
// Vidu Q3 Turbo per-second USD pricing (via CometAPI):
//
// | Resolution | $/sec |
// |-------------|---------|
// | 360p/540p | 0.028 |
// | 720p/1080p | 0.0616 |
//
// Total cost = price per second × video duration (seconds).
// NB: Not modeled as an estimator because the `size` field is an aspect
// ratio in the common case, which doesn't determine the billed resolution.
// ── Response ──
pub struct GenerateViduQ3TurboResponse {
/// Poll `GET /v1/videos/{task_id}` until terminal.
pub task_id: String,
pub status: CometVideoTaskStatus,
}
// ── Entry point ──
pub async fn generate_vidu_q3_turbo(
args: GenerateViduQ3TurboArgs<'_>,
) -> Result<GenerateViduQ3TurboResponse, CometError> {
let raw_request = args.request.to_create_video_request()?;
let result = create_video(CreateVideoArgs {
api_key: args.api_key,
request: raw_request,
}).await?;
Ok(GenerateViduQ3TurboResponse {
task_id: result.task_id,
status: result.status,
})
}
impl GenerateViduQ3TurboRequest {
/// Validate and lower to the generic wire request.
pub fn to_create_video_request(&self) -> Result<CreateVideoRequest, CometClientError> {
if let Some(seconds) = self.duration_seconds {
if !(MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&seconds) {
return Err(CometClientError::InvalidRequestField {
field: "duration_seconds",
raw_value: seconds.to_string(),
reason: format!("Vidu Q3 Turbo supports {MIN_DURATION_SECONDS}-{MAX_DURATION_SECONDS} second durations"),
});
}
}
Ok(CreateVideoRequest {
model: CometVideoModelRaw::ViduQ3Turbo,
prompt: self.prompt.clone(),
maybe_seconds: self.duration_seconds,
maybe_size: self.size.map(map_size),
input_reference_images: self.input_reference_images.clone(),
})
}
}
fn map_size(size: ViduQ3TurboSize) -> CometVideoSize {
match size {
ViduQ3TurboSize::Landscape16x9 => CometVideoSize::Landscape16x9,
ViduQ3TurboSize::Standard4x3 => CometVideoSize::Standard4x3,
ViduQ3TurboSize::Square1x1 => CometVideoSize::Square,
ViduQ3TurboSize::Portrait3x4 => CometVideoSize::Portrait3x4,
ViduQ3TurboSize::Portrait9x16 => CometVideoSize::Portrait9x16,
ViduQ3TurboSize::Exact { width, height } => CometVideoSize::Exact { width, height },
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maps_to_wire_request() {
let request = GenerateViduQ3TurboRequest {
prompt: "raindrops on a window".to_string(),
duration_seconds: Some(1),
size: Some(ViduQ3TurboSize::Portrait9x16),
input_reference_images: vec![],
};
let raw = request.to_create_video_request().expect("should validate");
assert_eq!(raw.text_form_fields(), vec![
("model", "viduq3-turbo".to_string()),
("prompt", "raindrops on a window".to_string()),
("seconds", "1".to_string()),
("size", "9:16".to_string()),
]);
}
#[test]
fn duration_bounds() {
assert!(request_with_seconds(Some(0)).to_create_video_request().is_err());
assert!(request_with_seconds(Some(1)).to_create_video_request().is_ok());
assert!(request_with_seconds(Some(16)).to_create_video_request().is_ok());
assert!(request_with_seconds(Some(17)).to_create_video_request().is_err());
assert!(request_with_seconds(None).to_create_video_request().is_ok());
}
fn request_with_seconds(duration_seconds: Option<u8>) -> GenerateViduQ3TurboRequest {
GenerateViduQ3TurboRequest {
prompt: "ok".to_string(),
duration_seconds,
size: None,
input_reference_images: vec![],
}
}
}
@@ -0,0 +1,216 @@
use crate::creds::comet_api_key::CometApiKey;
use crate::error::comet_client_error::CometClientError;
use crate::error::comet_error::CometError;
use crate::requests::create_video::create_video::{
create_video, CometInputReferenceImage, CometVideoModelRaw, CometVideoSize,
CreateVideoArgs, CreateVideoRequest,
};
use crate::requests::video_task_status::CometVideoTaskStatus;
/// Wan 2.6 supports 2-15 second durations.
const MIN_DURATION_SECONDS: u8 = 2;
const MAX_DURATION_SECONDS: u8 = 15;
/// The API default duration when `seconds` is omitted.
const DEFAULT_DURATION_SECONDS: u8 = 5;
/// Wan 2.6 accepts up to 5 references.
const MAX_REFERENCE_IMAGES: usize = 5;
// ── Args ──
pub struct GenerateWan2p6Args<'a> {
pub request: GenerateWan2p6Request,
pub api_key: &'a CometApiKey,
}
// ── Request ──
#[derive(Clone, Debug)]
pub struct GenerateWan2p6Request {
pub prompt: String,
/// 2-15 seconds. `None` uses the API default (5).
pub duration_seconds: Option<u8>,
/// `None` uses the API default (720p).
pub size: Option<Wan2p6Size>,
/// Reference images for image-to-video and reference-to-video workflows.
/// Wan 2.6 accepts up to 5 references.
pub input_reference_images: Vec<CometInputReferenceImage>,
}
// ── Enums ──
/// Wan 2.6 renders at 720p or 1080p.
#[derive(Debug, Clone, Copy)]
pub enum Wan2p6Size {
SevenTwentyP,
TenEightyP,
}
// ── Pricing ──
//
// Wan 2.6 per-second USD pricing (via CometAPI):
//
// | Resolution | $/sec |
// |------------|-------|
// | 720p | 0.08 |
// | 1080p | 0.12 |
//
// Total cost = price per second × video duration (seconds).
impl GenerateWan2p6Request {
/// Estimate the USD cost in cents for this generation request.
pub fn estimate_cost_in_usd_cents(&self) -> u64 {
let cents_per_second: u64 = match self.size {
Some(Wan2p6Size::TenEightyP) => 12,
// Default resolution (None) is 720p.
Some(Wan2p6Size::SevenTwentyP) | None => 8,
};
let seconds = self.duration_seconds.unwrap_or(DEFAULT_DURATION_SECONDS);
u64::from(seconds) * cents_per_second
}
}
// ── Response ──
pub struct GenerateWan2p6Response {
/// Poll `GET /v1/videos/{task_id}` until terminal.
pub task_id: String,
pub status: CometVideoTaskStatus,
}
// ── Entry point ──
pub async fn generate_wan_2p6(
args: GenerateWan2p6Args<'_>,
) -> Result<GenerateWan2p6Response, CometError> {
let raw_request = args.request.to_create_video_request()?;
let result = create_video(CreateVideoArgs {
api_key: args.api_key,
request: raw_request,
}).await?;
Ok(GenerateWan2p6Response {
task_id: result.task_id,
status: result.status,
})
}
impl GenerateWan2p6Request {
/// Validate and lower to the generic wire request.
pub fn to_create_video_request(&self) -> Result<CreateVideoRequest, CometClientError> {
if let Some(seconds) = self.duration_seconds {
if !(MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&seconds) {
return Err(CometClientError::InvalidRequestField {
field: "duration_seconds",
raw_value: seconds.to_string(),
reason: format!("Wan 2.6 supports {MIN_DURATION_SECONDS}-{MAX_DURATION_SECONDS} second durations"),
});
}
}
if self.input_reference_images.len() > MAX_REFERENCE_IMAGES {
return Err(CometClientError::InvalidRequestField {
field: "input_reference_images",
raw_value: self.input_reference_images.len().to_string(),
reason: format!("Wan 2.6 accepts at most {MAX_REFERENCE_IMAGES} references"),
});
}
Ok(CreateVideoRequest {
model: CometVideoModelRaw::Wan2p6,
prompt: self.prompt.clone(),
maybe_seconds: self.duration_seconds,
maybe_size: self.size.map(map_size),
input_reference_images: self.input_reference_images.clone(),
})
}
}
fn map_size(size: Wan2p6Size) -> CometVideoSize {
match size {
Wan2p6Size::SevenTwentyP => CometVideoSize::Exact { width: 1280, height: 720 },
Wan2p6Size::TenEightyP => CometVideoSize::Exact { width: 1920, height: 1080 },
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maps_to_wire_request() {
let request = GenerateWan2p6Request {
prompt: "a steam train crossing a viaduct".to_string(),
duration_seconds: Some(15),
size: Some(Wan2p6Size::TenEightyP),
input_reference_images: vec![],
};
let raw = request.to_create_video_request().expect("should validate");
assert_eq!(raw.text_form_fields(), vec![
("model", "wan2.6".to_string()),
("prompt", "a steam train crossing a viaduct".to_string()),
("seconds", "15".to_string()),
("size", "1920x1080".to_string()),
]);
}
#[test]
fn duration_bounds() {
assert!(request_with_seconds(Some(1)).to_create_video_request().is_err());
assert!(request_with_seconds(Some(2)).to_create_video_request().is_ok());
assert!(request_with_seconds(Some(15)).to_create_video_request().is_ok());
assert!(request_with_seconds(Some(16)).to_create_video_request().is_err());
assert!(request_with_seconds(None).to_create_video_request().is_ok());
}
#[test]
fn too_many_references_rejected() {
let mut request = request_with_seconds(Some(5));
request.input_reference_images = (0..6)
.map(|i| CometInputReferenceImage {
file_bytes: vec![0u8],
filename: format!("ref_{i}.png"),
maybe_content_type: Some("image/png".to_string()),
})
.collect();
let error = request.to_create_video_request().expect_err("should reject 6 references");
assert!(matches!(error, CometClientError::InvalidRequestField { field: "input_reference_images", .. }));
request.input_reference_images.truncate(5);
assert!(request.to_create_video_request().is_ok());
}
#[test]
fn cost_estimates() {
// 5 seconds at 720p: 5 × 8¢ = 40¢
let mut request = request_with_seconds(Some(5));
request.size = Some(Wan2p6Size::SevenTwentyP);
assert_eq!(request.estimate_cost_in_usd_cents(), 40);
// 10 seconds at 1080p: 10 × 12¢ = 120¢
request.duration_seconds = Some(10);
request.size = Some(Wan2p6Size::TenEightyP);
assert_eq!(request.estimate_cost_in_usd_cents(), 120);
// Defaults (5 seconds, 720p): 40¢
request.duration_seconds = None;
request.size = None;
assert_eq!(request.estimate_cost_in_usd_cents(), 40);
}
fn request_with_seconds(duration_seconds: Option<u8>) -> GenerateWan2p6Request {
GenerateWan2p6Request {
prompt: "ok".to_string(),
duration_seconds,
size: None,
input_reference_images: vec![],
}
}
}
@@ -0,0 +1,186 @@
use crate::creds::comet_api_key::CometApiKey;
use crate::error::comet_client_error::CometClientError;
use crate::error::comet_error::CometError;
use crate::requests::create_video::create_video::{
create_video, CometInputReferenceImage, CometVideoModelRaw, CometVideoSize,
CreateVideoArgs, CreateVideoRequest,
};
use crate::requests::video_task_status::CometVideoTaskStatus;
/// Wan 2.7 supports 2-15 second durations.
const MIN_DURATION_SECONDS: u8 = 2;
const MAX_DURATION_SECONDS: u8 = 15;
/// The API default duration when `seconds` is omitted.
const DEFAULT_DURATION_SECONDS: u8 = 5;
// ── Args ──
pub struct GenerateWan2p7Args<'a> {
pub request: GenerateWan2p7Request,
pub api_key: &'a CometApiKey,
}
// ── Request ──
#[derive(Clone, Debug)]
pub struct GenerateWan2p7Request {
pub prompt: String,
/// 2-15 seconds. `None` uses the API default (5).
pub duration_seconds: Option<u8>,
/// `None` uses the API default (720p).
pub size: Option<Wan2p7Size>,
/// Reference images for image-to-video and first/last-frame workflows.
pub input_reference_images: Vec<CometInputReferenceImage>,
}
// ── Enums ──
/// Wan 2.7 renders at 720p or 1080p.
#[derive(Debug, Clone, Copy)]
pub enum Wan2p7Size {
SevenTwentyP,
TenEightyP,
}
// ── Pricing ──
//
// Wan 2.7 per-second USD pricing (via CometAPI):
//
// | Resolution | $/sec |
// |------------|-------|
// | 720p | 0.08 |
// | 1080p | 0.12 |
//
// Total cost = price per second × video duration (seconds).
impl GenerateWan2p7Request {
/// Estimate the USD cost in cents for this generation request.
pub fn estimate_cost_in_usd_cents(&self) -> u64 {
let cents_per_second: u64 = match self.size {
Some(Wan2p7Size::TenEightyP) => 12,
// Default resolution (None) is 720p.
Some(Wan2p7Size::SevenTwentyP) | None => 8,
};
let seconds = self.duration_seconds.unwrap_or(DEFAULT_DURATION_SECONDS);
u64::from(seconds) * cents_per_second
}
}
// ── Response ──
pub struct GenerateWan2p7Response {
/// Poll `GET /v1/videos/{task_id}` until terminal.
pub task_id: String,
pub status: CometVideoTaskStatus,
}
// ── Entry point ──
pub async fn generate_wan_2p7(
args: GenerateWan2p7Args<'_>,
) -> Result<GenerateWan2p7Response, CometError> {
let raw_request = args.request.to_create_video_request()?;
let result = create_video(CreateVideoArgs {
api_key: args.api_key,
request: raw_request,
}).await?;
Ok(GenerateWan2p7Response {
task_id: result.task_id,
status: result.status,
})
}
impl GenerateWan2p7Request {
/// Validate and lower to the generic wire request.
pub fn to_create_video_request(&self) -> Result<CreateVideoRequest, CometClientError> {
if let Some(seconds) = self.duration_seconds {
if !(MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&seconds) {
return Err(CometClientError::InvalidRequestField {
field: "duration_seconds",
raw_value: seconds.to_string(),
reason: format!("Wan 2.7 supports {MIN_DURATION_SECONDS}-{MAX_DURATION_SECONDS} second durations"),
});
}
}
Ok(CreateVideoRequest {
model: CometVideoModelRaw::Wan2p7,
prompt: self.prompt.clone(),
maybe_seconds: self.duration_seconds,
maybe_size: self.size.map(map_size),
input_reference_images: self.input_reference_images.clone(),
})
}
}
fn map_size(size: Wan2p7Size) -> CometVideoSize {
match size {
Wan2p7Size::SevenTwentyP => CometVideoSize::Exact { width: 1280, height: 720 },
Wan2p7Size::TenEightyP => CometVideoSize::Exact { width: 1920, height: 1080 },
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maps_to_wire_request() {
let request = GenerateWan2p7Request {
prompt: "a koi pond at golden hour".to_string(),
duration_seconds: Some(5),
size: Some(Wan2p7Size::SevenTwentyP),
input_reference_images: vec![],
};
let raw = request.to_create_video_request().expect("should validate");
assert_eq!(raw.text_form_fields(), vec![
("model", "wan2.7".to_string()),
("prompt", "a koi pond at golden hour".to_string()),
("seconds", "5".to_string()),
("size", "1280x720".to_string()),
]);
}
#[test]
fn duration_bounds() {
assert!(request_with_seconds(Some(1)).to_create_video_request().is_err());
assert!(request_with_seconds(Some(2)).to_create_video_request().is_ok());
assert!(request_with_seconds(Some(15)).to_create_video_request().is_ok());
assert!(request_with_seconds(Some(16)).to_create_video_request().is_err());
assert!(request_with_seconds(None).to_create_video_request().is_ok());
}
#[test]
fn cost_estimates() {
// 5 seconds at 720p: 5 × 8¢ = 40¢
let mut request = request_with_seconds(Some(5));
request.size = Some(Wan2p7Size::SevenTwentyP);
assert_eq!(request.estimate_cost_in_usd_cents(), 40);
// 10 seconds at 1080p: 10 × 12¢ = 120¢
request.duration_seconds = Some(10);
request.size = Some(Wan2p7Size::TenEightyP);
assert_eq!(request.estimate_cost_in_usd_cents(), 120);
// Defaults (5 seconds, 720p): 40¢
request.duration_seconds = None;
request.size = None;
assert_eq!(request.estimate_cost_in_usd_cents(), 40);
}
fn request_with_seconds(duration_seconds: Option<u8>) -> GenerateWan2p7Request {
GenerateWan2p7Request {
prompt: "ok".to_string(),
duration_seconds,
size: None,
input_reference_images: vec![],
}
}
}
@@ -0,0 +1,13 @@
//! Concrete, strongly-typed per-model bindings over the generic
//! [`crate::requests::create_video`] call. Each module encodes one model's
//! constraints (duration range, size options, image-input support) in the
//! type system and validates before hitting the wire.
pub mod generate_doubao_seedance_1p0_pro;
pub mod generate_doubao_seedance_1p5_pro;
pub mod generate_doubao_seedance_2p0;
pub mod generate_doubao_seedance_2p0_fast;
pub mod generate_vidu_q3;
pub mod generate_vidu_q3_turbo;
pub mod generate_wan_2p6;
pub mod generate_wan_2p7;
@@ -0,0 +1,21 @@
//! comet_api_client
//!
//! HTTP client for CometAPI (https://www.cometapi.com/), which proxies a
//! variety of third-party generative models. We use it for Doubao Seedance 2.0
//! video generation ("Seedance 2.0" and "Seedance 2.0 Fast").
//!
//! Flow: enqueue with [`requests::create_video`], then poll with
//! [`requests::get_video_task`] until the task reaches a terminal status.
//!
//! NB: There is deliberately no webhook module. CometAPI documents callbacks
//! as provider-specific pass-through events with no standardized payload, and
//! the Seedance video endpoint has no documented callback parameter. Comet's
//! own guidance is to "keep polling as the source of truth".
pub mod creds;
pub mod error;
pub mod generate;
pub mod requests;
#[cfg(test)]
mod test_utils;
@@ -0,0 +1,2 @@
/// Base URL for the CometAPI HTTP API.
pub const COMET_API_BASE_URL: &str = "https://api.cometapi.com";
@@ -0,0 +1,439 @@
use std::fmt;
use log::info;
use reqwest::multipart::{Form, Part};
use crate::creds::comet_api_key::CometApiKey;
use crate::error::categorize_comet_error::categorize_create_video_error;
use crate::error::comet_client_error::CometClientError;
use crate::error::comet_error::CometError;
use crate::error::comet_generic_api_error::CometGenericApiError;
use crate::requests::comet_host::COMET_API_BASE_URL;
use crate::requests::create_video::request_types::CreateVideoRawResponse;
use crate::requests::video_task_status::CometVideoTaskStatus;
// ── Public args ──
/// Top-level argument to [`create_video`]. Borrows the API key separately
/// from the request body so callers can log/save [`CreateVideoRequest`]
/// without leaking the credential.
pub struct CreateVideoArgs<'a> {
pub api_key: &'a CometApiKey,
pub request: CreateVideoRequest,
}
/// The material part of a video creation request (`POST /v1/videos`).
///
/// This is the generic, model-agnostic wire shape. Per-model duration and
/// size constraints are NOT enforced here — use the strongly-typed bindings
/// in [`crate::generate::video`] which validate before delegating to this.
#[derive(Clone)]
pub struct CreateVideoRequest {
/// Which video model fulfils the request.
pub model: CometVideoModelRaw,
/// Text description of the desired video. Reference attached images in
/// the prompt as `[Image 1]`, `[Image 2]`, etc.
pub prompt: String,
/// Video duration in seconds. Valid ranges are model-specific. Default: 5.
pub maybe_seconds: Option<u8>,
/// Output size: an aspect-ratio preset or exact dimensions.
pub maybe_size: Option<CometVideoSize>,
/// Optional reference images (JPEG, PNG, WebP) for image-to-video.
/// Each is sent as a repeated `input_reference` multipart field.
/// Not all models support image input.
pub input_reference_images: Vec<CometInputReferenceImage>,
}
/// The video models CometAPI fronts on `POST /v1/videos`.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum CometVideoModelRaw {
/// Seedance 2.0
DoubaoSeedance2p0,
/// Seedance 2.0 Fast
DoubaoSeedance2p0Fast,
/// Seedance 1.5 Pro (text-only)
DoubaoSeedance1p5Pro,
/// Seedance 1.0 Pro (text-only)
DoubaoSeedance1p0Pro,
/// Vidu Q3
ViduQ3,
/// Vidu Q3 Turbo
ViduQ3Turbo,
/// Alibaba Wan 2.7
Wan2p7,
/// Alibaba Wan 2.6
Wan2p6,
}
/// Output video size: a ratio preset or exact pixel dimensions.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum CometVideoSize {
/// 16:9 (landscape)
Landscape16x9,
/// 4:3
Standard4x3,
/// 1:1 (square)
Square,
/// 3:4
Portrait3x4,
/// 9:16 (portrait)
Portrait9x16,
/// 21:9 (ultra-wide)
UltraWide21x9,
/// Exact dimensions, eg. 1280x720.
Exact { width: u32, height: u32 },
}
/// A reference image attached to the request as a binary multipart part.
#[derive(Clone)]
pub struct CometInputReferenceImage {
/// Raw bytes of the image (JPEG, PNG, or WebP). Excluded from `Debug`
/// output — a megabyte-scale blob shouldn't end up in a log line.
pub file_bytes: Vec<u8>,
/// Filename for the multipart `Content-Disposition` header.
pub filename: String,
/// Optional MIME type, eg. "image/png". If `None`, reqwest uses
/// `application/octet-stream`.
pub maybe_content_type: Option<String>,
}
// ── Public response ──
#[derive(Clone, Debug)]
pub struct CreateVideoSuccess {
/// Task identifier. Poll `GET /v1/videos/{task_id}` until terminal.
pub task_id: String,
/// Initial lifecycle status, typically queued.
pub status: CometVideoTaskStatus,
/// The model that will fulfil the task, as echoed by the API.
pub maybe_model: Option<String>,
/// 0-100 completion percentage.
pub maybe_progress: Option<u8>,
/// Unix timestamp (seconds).
pub maybe_created_at: Option<i64>,
}
/// Enqueue a Doubao Seedance 2.0 video generation task.
///
/// CometAPI's video creation endpoint takes `multipart/form-data`. The task
/// id in the response is then polled with
/// [`crate::requests::get_video_task::get_video_task::get_video_task`].
pub async fn create_video(args: CreateVideoArgs<'_>) -> Result<CreateVideoSuccess, CometError> {
let request = &args.request;
request.validate()?;
let url = format!("{COMET_API_BASE_URL}/v1/videos");
let mut form = Form::new();
for (field, value) in request.text_form_fields() {
form = form.text(field, value);
}
for image in &request.input_reference_images {
let mut part = Part::bytes(image.file_bytes.clone())
.file_name(image.filename.clone());
if let Some(content_type) = &image.maybe_content_type {
part = part.mime_str(content_type)
.map_err(|err| CometClientError::InvalidRequestField {
field: "input_reference_images.maybe_content_type",
raw_value: content_type.clone(),
reason: format!("invalid mime type: {err}"),
})?;
}
form = form.part("input_reference", part);
}
let client = reqwest::Client::builder()
.build()
.map_err(CometClientError::ReqwestClientError)?;
let response = client.post(&url)
.bearer_auth(args.api_key.as_str())
.multipart(form)
.send()
.await
.map_err(CometGenericApiError::ReqwestError)?;
let status_code = response.status();
let body = response.text()
.await
.map_err(CometGenericApiError::ReqwestError)?;
if !status_code.is_success() {
return Err(categorize_create_video_error(status_code, body));
}
let raw: CreateVideoRawResponse = serde_json::from_str(&body)
.map_err(|err| CometGenericApiError::SerdeResponseParseErrorWithBody(err, body.clone()))?;
info!("Comet video task enqueued: {} (status: {})", raw.id, raw.status);
Ok(CreateVideoSuccess {
task_id: raw.id,
status: raw.status,
maybe_model: raw.model,
maybe_progress: raw.progress,
maybe_created_at: raw.created_at,
})
}
impl CreateVideoRequest {
/// The text fields of the multipart form, in wire order. Split out from
/// [`create_video`] so the form shape is testable without HTTP.
pub fn text_form_fields(&self) -> Vec<(&'static str, String)> {
let mut fields = vec![
("model", self.model.as_api_str().to_string()),
("prompt", self.prompt.clone()),
];
if let Some(seconds) = self.maybe_seconds {
fields.push(("seconds", seconds.to_string()));
}
if let Some(size) = self.maybe_size {
fields.push(("size", size.as_api_string()));
}
fields
}
fn validate(&self) -> Result<(), CometClientError> {
if self.prompt.trim().is_empty() {
return Err(CometClientError::InvalidRequestField {
field: "prompt",
raw_value: self.prompt.clone(),
reason: "prompt must not be empty".to_string(),
});
}
Ok(())
}
}
impl CometVideoModelRaw {
pub fn as_api_str(&self) -> &'static str {
match self {
Self::DoubaoSeedance2p0 => "doubao-seedance-2-0",
Self::DoubaoSeedance2p0Fast => "doubao-seedance-2-0-fast",
Self::DoubaoSeedance1p5Pro => "doubao-seedance-1-5-pro",
Self::DoubaoSeedance1p0Pro => "doubao-seedance-1-0-pro",
Self::ViduQ3 => "viduq3",
Self::ViduQ3Turbo => "viduq3-turbo",
Self::Wan2p7 => "wan2.7",
Self::Wan2p6 => "wan2.6",
}
}
}
impl CometVideoSize {
pub fn as_api_string(&self) -> String {
match self {
Self::Landscape16x9 => "16:9".to_string(),
Self::Standard4x3 => "4:3".to_string(),
Self::Square => "1:1".to_string(),
Self::Portrait3x4 => "3:4".to_string(),
Self::Portrait9x16 => "9:16".to_string(),
Self::UltraWide21x9 => "21:9".to_string(),
Self::Exact { width, height } => format!("{width}x{height}"),
}
}
}
impl fmt::Debug for CreateVideoRequest {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CreateVideoRequest")
.field("model", &self.model)
.field("prompt", &self.prompt)
.field("maybe_seconds", &self.maybe_seconds)
.field("maybe_size", &self.maybe_size)
.field("input_reference_images", &self.input_reference_images)
.finish()
}
}
impl fmt::Debug for CometInputReferenceImage {
// Print byte-length, not raw bytes — derived Debug on Vec<u8> would dump
// the entire image into the log line.
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CometInputReferenceImage")
.field("file_bytes_len", &self.file_bytes.len())
.field("filename", &self.filename)
.field("maybe_content_type", &self.maybe_content_type)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
mod shape_tests {
use super::*;
#[test]
fn model_api_strings() {
assert_eq!(CometVideoModelRaw::DoubaoSeedance2p0.as_api_str(), "doubao-seedance-2-0");
assert_eq!(CometVideoModelRaw::DoubaoSeedance2p0Fast.as_api_str(), "doubao-seedance-2-0-fast");
assert_eq!(CometVideoModelRaw::DoubaoSeedance1p5Pro.as_api_str(), "doubao-seedance-1-5-pro");
assert_eq!(CometVideoModelRaw::DoubaoSeedance1p0Pro.as_api_str(), "doubao-seedance-1-0-pro");
assert_eq!(CometVideoModelRaw::ViduQ3.as_api_str(), "viduq3");
assert_eq!(CometVideoModelRaw::ViduQ3Turbo.as_api_str(), "viduq3-turbo");
assert_eq!(CometVideoModelRaw::Wan2p7.as_api_str(), "wan2.7");
assert_eq!(CometVideoModelRaw::Wan2p6.as_api_str(), "wan2.6");
}
#[test]
fn size_api_strings() {
assert_eq!(CometVideoSize::Landscape16x9.as_api_string(), "16:9");
assert_eq!(CometVideoSize::Standard4x3.as_api_string(), "4:3");
assert_eq!(CometVideoSize::Square.as_api_string(), "1:1");
assert_eq!(CometVideoSize::Portrait3x4.as_api_string(), "3:4");
assert_eq!(CometVideoSize::Portrait9x16.as_api_string(), "9:16");
assert_eq!(CometVideoSize::UltraWide21x9.as_api_string(), "21:9");
assert_eq!(CometVideoSize::Exact { width: 1280, height: 720 }.as_api_string(), "1280x720");
}
#[test]
fn minimal_form_fields() {
let request = CreateVideoRequest {
model: CometVideoModelRaw::DoubaoSeedance2p0Fast,
prompt: "a corgi running through a field".to_string(),
maybe_seconds: None,
maybe_size: None,
input_reference_images: vec![],
};
assert_eq!(request.text_form_fields(), vec![
("model", "doubao-seedance-2-0-fast".to_string()),
("prompt", "a corgi running through a field".to_string()),
]);
}
#[test]
fn full_form_fields() {
let request = CreateVideoRequest {
model: CometVideoModelRaw::DoubaoSeedance2p0,
prompt: "make [Image 1] come alive".to_string(),
maybe_seconds: Some(10),
maybe_size: Some(CometVideoSize::Portrait9x16),
input_reference_images: vec![],
};
assert_eq!(request.text_form_fields(), vec![
("model", "doubao-seedance-2-0".to_string()),
("prompt", "make [Image 1] come alive".to_string()),
("seconds", "10".to_string()),
("size", "9:16".to_string()),
]);
}
}
mod validation_tests {
use super::*;
#[test]
fn empty_prompt_is_rejected() {
let request = request_with_prompt_and_seconds(" ", None);
let error = request.validate().expect_err("should reject empty prompt");
assert!(matches!(error, CometClientError::InvalidRequestField { field: "prompt", .. }));
}
// NB: Duration ranges are model-specific and validated by the concrete
// bindings in `generate::video`; the generic layer accepts any value.
fn request_with_prompt_and_seconds(prompt: &str, maybe_seconds: Option<u8>) -> CreateVideoRequest {
CreateVideoRequest {
model: CometVideoModelRaw::DoubaoSeedance2p0,
prompt: prompt.to_string(),
maybe_seconds,
maybe_size: None,
input_reference_images: vec![],
}
}
}
mod live_tests {
use super::*;
use crate::test_utils::load_api_key;
/// Live test: enqueues a REAL generation and INCURS COSTS.
/// Run manually with:
/// cargo test -p comet_api_client live_create_video -- --ignored --nocapture
#[ignore]
#[tokio::test]
async fn live_create_video() {
let api_key = load_api_key();
let result = create_video(CreateVideoArgs {
api_key: &api_key,
request: CreateVideoRequest {
model: CometVideoModelRaw::DoubaoSeedance2p0Fast,
prompt: "a corgi running through a sunny meadow, cinematic".to_string(),
maybe_seconds: Some(4),
maybe_size: Some(CometVideoSize::Landscape16x9),
input_reference_images: vec![],
},
}).await.expect("create_video should succeed");
println!("Enqueued task: {} (status: {})", result.task_id, result.status);
assert!(!result.task_id.is_empty());
}
/// Live test: enqueues a REAL generation and polls it to completion.
/// INCURS COSTS and takes a few minutes. Run manually with:
/// cargo test -p comet_api_client live_create_and_poll_video -- --ignored --nocapture
#[ignore]
#[tokio::test]
async fn live_create_and_poll_video() {
use std::time::Duration;
use crate::requests::get_video_task::get_video_task::{get_video_task, GetVideoTaskArgs};
// NB: Comet's docs claim most tasks finish in 1-3 minutes, but tasks
// have been observed to sit at `in_progress` well past 10 minutes.
const POLL_INTERVAL: Duration = Duration::from_secs(15);
const MAX_POLLS: u32 = 120; // ~30 minutes
let api_key = load_api_key();
let created = create_video(CreateVideoArgs {
api_key: &api_key,
request: CreateVideoRequest {
model: CometVideoModelRaw::DoubaoSeedance2p0Fast,
prompt: "a corgi running through a sunny meadow, cinematic".to_string(),
maybe_seconds: Some(4),
maybe_size: Some(CometVideoSize::Landscape16x9),
input_reference_images: vec![],
},
}).await.expect("create_video should succeed");
println!("Enqueued task: {} (status: {})", created.task_id, created.status);
for poll in 1..=MAX_POLLS {
tokio::time::sleep(POLL_INTERVAL).await;
let task = get_video_task(GetVideoTaskArgs {
api_key: &api_key,
task_id: &created.task_id,
}).await.expect("get_video_task should succeed");
println!("Poll {}: status {} (progress: {:?})", poll, task.status, task.maybe_progress);
if task.status.is_terminal() {
assert!(task.status.is_success(), "task should complete successfully: {:?}", task);
let video_url = task.maybe_video_url.expect("completed task should have a video url");
println!("Video URL: {}", video_url);
return;
}
}
panic!("Task {} did not reach a terminal state in time", created.task_id);
}
}
}
@@ -0,0 +1,2 @@
pub mod create_video;
pub mod request_types;
@@ -0,0 +1,77 @@
use serde_derive::Deserialize;
use crate::requests::video_task_status::CometVideoTaskStatus;
/// Over-the-wire response from `POST /v1/videos`.
///
/// The docs show both `id` and `task_id` carrying the same task identifier;
/// `task_id` is treated as an optional mirror of `id`.
#[derive(Clone, Debug, Deserialize)]
pub struct CreateVideoRawResponse {
/// Task identifier, eg. "task_abc123". Use this with the poll endpoint.
pub id: String,
/// Mirror of `id`.
pub task_id: Option<String>,
/// Always "video".
pub object: Option<String>,
/// The model that will fulfil the task, eg. "doubao-seedance-2-0".
pub model: Option<String>,
/// Initial lifecycle status, typically "queued".
pub status: CometVideoTaskStatus,
/// 0-100 completion percentage.
pub progress: Option<u8>,
/// Unix timestamp (seconds).
pub created_at: Option<i64>,
}
#[cfg(test)]
mod tests {
use super::*;
/// Documented response shape from
/// https://apidoc.cometapi.com/api/video/seedance/create.md
const REAL_CREATE_RESPONSE: &str = r#"
{
"id": "task_abc123",
"task_id": "task_abc123",
"object": "video",
"model": "doubao-seedance-2-0",
"status": "queued",
"progress": 0,
"created_at": 1776681149
}
"#;
#[test]
fn parse_real_create_response() {
let response: CreateVideoRawResponse = serde_json::from_str(REAL_CREATE_RESPONSE)
.expect("should parse");
assert_eq!(response.id, "task_abc123");
assert_eq!(response.task_id.as_deref(), Some("task_abc123"));
assert_eq!(response.object.as_deref(), Some("video"));
assert_eq!(response.model.as_deref(), Some("doubao-seedance-2-0"));
assert_eq!(response.status, CometVideoTaskStatus::Queued);
assert_eq!(response.progress, Some(0));
assert_eq!(response.created_at, Some(1776681149));
}
#[test]
fn parse_minimal_response() {
// Only `id` and `status` are load-bearing; everything else is optional
// so schema drift doesn't break enqueuing.
let response: CreateVideoRawResponse = serde_json::from_str(
r#"{"id":"task_xyz","status":"queued"}"#,
).expect("should parse");
assert_eq!(response.id, "task_xyz");
assert_eq!(response.status, CometVideoTaskStatus::Queued);
assert_eq!(response.task_id, None);
}
}
@@ -0,0 +1,184 @@
use log::info;
use crate::creds::comet_api_key::CometApiKey;
use crate::error::categorize_comet_error::categorize_get_video_task_error;
use crate::error::comet_client_error::CometClientError;
use crate::error::comet_error::CometError;
use crate::error::comet_generic_api_error::CometGenericApiError;
use crate::requests::comet_host::COMET_API_BASE_URL;
use crate::requests::get_video_task::request_types::VideoTaskRawResponse;
use crate::requests::video_task_status::CometVideoTaskStatus;
// ── Public args ──
pub struct GetVideoTaskArgs<'a> {
pub api_key: &'a CometApiKey,
/// The task id returned by the creation endpoint, eg. "task_abc123".
pub task_id: &'a str,
}
// ── Public response ──
/// Snapshot of a video task's state.
///
/// Poll every 10-20 seconds until [`CometVideoTaskStatus::is_terminal`];
/// most tasks finish within 1-3 minutes.
#[derive(Clone, Debug)]
pub struct CometVideoTask {
pub task_id: String,
pub status: CometVideoTaskStatus,
/// The Seedance model used, as echoed by the API.
pub maybe_model: Option<String>,
/// 0-100 completion percentage.
pub maybe_progress: Option<u8>,
/// Signed download URL, present only when the task completed. The
/// signature is time-limited — download or re-host the file promptly.
pub maybe_video_url: Option<String>,
/// Unix timestamp (seconds).
pub maybe_created_at: Option<i64>,
/// Unix timestamp (seconds) when the task finished.
pub maybe_completed_at: Option<i64>,
}
/// Fetch the current state of a video task (`GET /v1/videos/{id}`).
pub async fn get_video_task(args: GetVideoTaskArgs<'_>) -> Result<CometVideoTask, CometError> {
let url = format!("{COMET_API_BASE_URL}/v1/videos/{}", args.task_id);
let client = reqwest::Client::builder()
.build()
.map_err(CometClientError::ReqwestClientError)?;
let response = client.get(&url)
.bearer_auth(args.api_key.as_str())
.send()
.await
.map_err(CometGenericApiError::ReqwestError)?;
let status_code = response.status();
let body = response.text()
.await
.map_err(CometGenericApiError::ReqwestError)?;
if !status_code.is_success() {
return Err(categorize_get_video_task_error(status_code, body, args.task_id));
}
let raw: VideoTaskRawResponse = serde_json::from_str(&body)
.map_err(|err| CometGenericApiError::SerdeResponseParseErrorWithBody(err, body.clone()))?;
info!("Comet video task {}: status {} (progress: {:?})", raw.id, raw.status, raw.progress);
Ok(CometVideoTask {
task_id: raw.id,
status: raw.status,
maybe_model: raw.model,
maybe_progress: raw.progress,
maybe_video_url: raw.video_url,
maybe_created_at: raw.created_at,
maybe_completed_at: raw.completed_at,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::comet_specific_api_error::CometSpecificApiError;
use crate::requests::get_video_content::get_video_content::{
probe_video_failure_reason, ProbeVideoFailureReasonArgs,
};
use crate::test_utils::load_api_key;
/// Historical task ids from real generations, kept for diagnosing how
/// CometAPI reports terminal states. Several of these failed content
/// moderation ("VIOLATION").
const KNOWN_TASK_IDS: &[&str] = &[
"task_ZISEWaXgmyqXWiesd287qTyAZLapbwz8",
"task_ZvwV2nhp08ng4krT16CPxtHZ2SgABElj",
"task_GZPVhcnIZKLJBqsCGTBvMiUFN9uDnwJv",
"task_iGnAQ5lwqhMltxhN5k9yATAjs7AgjV39",
"task_hGhShys2Wi2gPf2qUkuQ38VKIytWAw1d",
"task_t84NmrcT3k8s6Il4xJeKtfxXipA3n8RQ",
"task_6OhdGVFZjHNtOKKx8Ewz37mENaVIhC3X",
];
/// Live test: makes a REAL network request (no generation cost — the task
/// id is bogus, so this exercises auth + the TaskNotFound error path).
/// Run manually with:
/// cargo test -p comet_api_client live_get_video_task -- --ignored --nocapture
#[ignore]
#[tokio::test]
async fn live_get_video_task_unknown_id() {
let api_key = load_api_key();
let result = get_video_task(GetVideoTaskArgs {
api_key: &api_key,
task_id: "task_does_not_exist_artcraft_test",
}).await;
match result {
Err(CometError::ApiSpecific(CometSpecificApiError::TaskNotFound { task_id, .. })) => {
assert_eq!(task_id, "task_does_not_exist_artcraft_test");
}
other => panic!("Expected TaskNotFound, got: {:?}", other),
}
}
/// Live diagnostic: polls the known historical tasks and prints their full
/// state, including the failure reason recovered from the content
/// endpoint for failed tasks. Free (read-only GETs, no generation cost),
/// but hits the real API. Run manually with:
/// cargo test -p comet_api_client live_poll_known_task_ids -- --ignored --nocapture
///
/// NB: The poll endpoint reports failures as a bare `status: "failed"`.
/// The ONLY way to learn WHY is `GET /v1/videos/{id}/content`, whose error
/// message leaks the underlying provider status (eg. "VIOLATION" for
/// content-moderation rejections — these run to progress 100 and then
/// fail when the finished output is moderated).
#[ignore]
#[tokio::test]
async fn live_poll_known_task_ids() {
let api_key = load_api_key();
for task_id in KNOWN_TASK_IDS {
println!("===== {task_id}");
let task = match get_video_task(GetVideoTaskArgs { api_key: &api_key, task_id }).await {
Ok(task) => task,
Err(err) => {
println!(" poll error: {err}");
continue;
}
};
println!(" status: {}", task.status);
println!(" model: {}", task.maybe_model.as_deref().unwrap_or("?"));
println!(" progress: {:?}", task.maybe_progress);
println!(" created_at: {:?}", task.maybe_created_at);
println!(" completed_at: {:?}", task.maybe_completed_at);
println!(" video_url: {}", task.maybe_video_url.as_deref().unwrap_or("(none)"));
if task.status.is_failure() {
match probe_video_failure_reason(ProbeVideoFailureReasonArgs { api_key: &api_key, task_id }).await {
Ok(Some(reason)) => {
println!(" failure reason (via /content):");
println!(" raw message: {}", reason.raw_message);
println!(" underlying status: {}", reason.maybe_underlying_status.as_deref().unwrap_or("(unparsed)"));
println!(" content violation: {}", reason.is_content_violation());
println!(" error type: {}", reason.maybe_error_type.as_deref().unwrap_or("(none)"));
println!(" request id: {}", reason.maybe_request_id.as_deref().unwrap_or("(none)"));
}
Ok(None) => println!(" failure probe: content unexpectedly downloadable"),
Err(err) => println!(" failure probe error: {err}"),
}
}
println!();
}
}
}
@@ -0,0 +1,2 @@
pub mod get_video_task;
pub mod request_types;
@@ -0,0 +1,113 @@
use serde_derive::Deserialize;
use crate::requests::video_task_status::CometVideoTaskStatus;
/// Over-the-wire response from `GET /v1/videos/{id}`.
#[derive(Clone, Debug, Deserialize)]
pub struct VideoTaskRawResponse {
/// Task identifier.
pub id: String,
/// Always "video".
pub object: Option<String>,
/// The Seedance model used, eg. "doubao-seedance-2-0".
pub model: Option<String>,
/// Lifecycle status.
pub status: CometVideoTaskStatus,
/// 0-100 completion percentage.
pub progress: Option<u8>,
/// Signed download URL. Present only when `status` is "completed".
/// NB: the signature is time-limited — download or re-host the file
/// before it expires.
pub video_url: Option<String>,
/// Unix timestamp (seconds).
pub created_at: Option<i64>,
/// Unix timestamp (seconds) when the task finished.
pub completed_at: Option<i64>,
}
#[cfg(test)]
mod tests {
use super::*;
// Documented response shapes from
// https://apidoc.cometapi.com/api/video/seedance/query.md
const REAL_IN_PROGRESS_RESPONSE: &str = r#"
{
"id": "task_abc123",
"object": "video",
"model": "doubao-seedance-2-0",
"status": "in_progress",
"progress": 30,
"created_at": 1777385418,
"completed_at": 1777385485
}
"#;
const REAL_COMPLETED_RESPONSE: &str = r#"
{
"id": "task_abc123",
"object": "video",
"model": "doubao-seedance-2-0",
"status": "completed",
"progress": 100,
"created_at": 1777385418,
"completed_at": 1777385526,
"video_url": "https://example.com/seedance-output.mp4"
}
"#;
const REAL_FAILED_RESPONSE: &str = r#"
{
"id": "task_abc123",
"object": "video",
"model": "doubao-seedance-2-0",
"status": "failed",
"progress": 0,
"created_at": 1777385418,
"completed_at": 1777385526
}
"#;
#[test]
fn parse_real_in_progress_response() {
let response: VideoTaskRawResponse = serde_json::from_str(REAL_IN_PROGRESS_RESPONSE)
.expect("should parse");
assert_eq!(response.id, "task_abc123");
assert_eq!(response.status, CometVideoTaskStatus::InProgress);
assert_eq!(response.progress, Some(30));
assert_eq!(response.video_url, None);
assert!(!response.status.is_terminal());
}
#[test]
fn parse_real_completed_response() {
let response: VideoTaskRawResponse = serde_json::from_str(REAL_COMPLETED_RESPONSE)
.expect("should parse");
assert_eq!(response.status, CometVideoTaskStatus::Completed);
assert_eq!(response.progress, Some(100));
assert_eq!(response.video_url.as_deref(), Some("https://example.com/seedance-output.mp4"));
assert_eq!(response.completed_at, Some(1777385526));
assert!(response.status.is_success());
}
#[test]
fn parse_real_failed_response() {
let response: VideoTaskRawResponse = serde_json::from_str(REAL_FAILED_RESPONSE)
.expect("should parse");
assert_eq!(response.status, CometVideoTaskStatus::Failed);
assert_eq!(response.video_url, None);
assert!(response.status.is_terminal());
assert!(response.status.is_failure());
}
}
@@ -0,0 +1,5 @@
pub mod comet_host;
pub mod create_video;
pub mod get_video_content;
pub mod get_video_task;
pub mod video_task_status;
@@ -0,0 +1,132 @@
use std::fmt;
use serde::de::{Deserialize, Deserializer};
use serde::ser::{Serialize, Serializer};
/// Lifecycle status of a CometAPI video task.
///
/// The `Unknown(String)` catch-all future-proofs against CometAPI adding new
/// states — callers should treat unknown states as still-running unless
/// proven otherwise.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CometVideoTaskStatus {
Queued,
InProgress,
Completed,
Failed,
Error,
Unknown(String),
}
impl CometVideoTaskStatus {
pub fn to_str(&self) -> &str {
match self {
Self::Queued => "queued",
Self::InProgress => "in_progress",
Self::Completed => "completed",
Self::Failed => "failed",
Self::Error => "error",
Self::Unknown(value) => value,
}
}
pub fn from_str(value: &str) -> Self {
match value {
"queued" => Self::Queued,
"in_progress" => Self::InProgress,
"completed" => Self::Completed,
"failed" => Self::Failed,
"error" => Self::Error,
other => Self::Unknown(other.to_string()),
}
}
/// Whether the task has reached a terminal state (stop polling).
pub fn is_terminal(&self) -> bool {
matches!(self, Self::Completed | Self::Failed | Self::Error)
}
pub fn is_success(&self) -> bool {
matches!(self, Self::Completed)
}
pub fn is_failure(&self) -> bool {
matches!(self, Self::Failed | Self::Error)
}
}
impl fmt::Display for CometVideoTaskStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.to_str())
}
}
impl<'de> Deserialize<'de> for CometVideoTaskStatus {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = String::deserialize(deserializer)?;
Ok(Self::from_str(&value))
}
}
impl Serialize for CometVideoTaskStatus {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.to_str())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deserialize_known_states() {
let cases = [
("\"queued\"", CometVideoTaskStatus::Queued),
("\"in_progress\"", CometVideoTaskStatus::InProgress),
("\"completed\"", CometVideoTaskStatus::Completed),
("\"failed\"", CometVideoTaskStatus::Failed),
("\"error\"", CometVideoTaskStatus::Error),
];
for (json, expected) in cases {
let status: CometVideoTaskStatus = serde_json::from_str(json).expect("should parse");
assert_eq!(status, expected);
}
}
#[test]
fn deserialize_unknown_state_is_future_proof() {
let status: CometVideoTaskStatus = serde_json::from_str("\"hyper_queued\"").expect("should parse");
assert_eq!(status, CometVideoTaskStatus::Unknown("hyper_queued".to_string()));
assert!(!status.is_terminal());
}
#[test]
fn round_trip() {
for status in [
CometVideoTaskStatus::Queued,
CometVideoTaskStatus::InProgress,
CometVideoTaskStatus::Completed,
CometVideoTaskStatus::Failed,
CometVideoTaskStatus::Error,
CometVideoTaskStatus::Unknown("new_state".to_string()),
] {
let json = serde_json::to_string(&status).expect("should serialize");
let parsed: CometVideoTaskStatus = serde_json::from_str(&json).expect("should parse");
assert_eq!(parsed, status);
}
}
#[test]
fn terminal_states() {
assert!(!CometVideoTaskStatus::Queued.is_terminal());
assert!(!CometVideoTaskStatus::InProgress.is_terminal());
assert!(CometVideoTaskStatus::Completed.is_terminal());
assert!(CometVideoTaskStatus::Failed.is_terminal());
assert!(CometVideoTaskStatus::Error.is_terminal());
assert!(CometVideoTaskStatus::Completed.is_success());
assert!(!CometVideoTaskStatus::Completed.is_failure());
assert!(CometVideoTaskStatus::Failed.is_failure());
assert!(CometVideoTaskStatus::Error.is_failure());
}
}
@@ -0,0 +1,11 @@
use std::fs::read_to_string;
use crate::creds::comet_api_key::CometApiKey;
const API_KEY_PATH: &str = "/Users/bt/Artcraft/credentials/comet_api_key.txt";
pub fn load_api_key() -> CometApiKey {
let secret = read_to_string(API_KEY_PATH)
.expect("Failed to read Comet API key file");
CometApiKey::from_str(&secret)
}
@@ -87,7 +87,8 @@ mod tests {
use crate::requests::image_websocket::messages::websocket_server_message::WebsocketServerMessage;
fn json_path(file_name: &str) -> String {
format!("/Users/bt/dev/storyteller/storyteller-rust/crates/api_clients/grok_consumer_client/test_data/websocket_messages/{}", file_name)
// NB: Cargo runs tests with the crate root as the working directory.
format!("test_data/websocket_messages/{}", file_name)
}
#[test]
File diff suppressed because one or more lines are too long
@@ -0,0 +1,25 @@
{
"type": "json",
"current_status": "completed",
"percentage_complete": 100,
"job_id": "c1bed796-3f05-4716-8ef0-a4d4900dfc6e",
"elapsed": null,
"prompt": "a dog typing on a keyboard",
"confirmation": "Drew a dog typing",
"full_prompt": "a dog typing on a keyboard",
"flags": null,
"is_alteration": false,
"section_id": 0,
"input_type": "text",
"request_id": "fc39cefd-fcf9-4775-b490-7e17ce670060",
"asr_transcript": null,
"asr_accepted": null,
"seed": null,
"image_reference": null,
"is_image_edit": null,
"moderated": false,
"r_rated": false,
"order": 1,
"model_name": "imagine_h_1",
"image_id": "78b3e6ef-defc-41a6-a9ac-b7a370d26ce5"
}
@@ -0,0 +1,25 @@
{
"type": "json",
"current_status": "start_stage",
"percentage_complete": 0,
"job_id": "57baeffe-d5cf-483b-a5bb-7794899f905c",
"elapsed": null,
"prompt": "a dog typing on a keyboard",
"confirmation": "Drew a dog typing",
"full_prompt": "a dog typing on a keyboard",
"flags": null,
"is_alteration": true,
"section_id": 0,
"input_type": "text",
"request_id": "fc39cefd-fcf9-4775-b490-7e17ce670060",
"asr_transcript": null,
"asr_accepted": null,
"seed": null,
"image_reference": null,
"is_image_edit": null,
"moderated": false,
"r_rated": null,
"order": 7,
"model_name": "imagine_h_1",
"image_id": null
}
@@ -0,0 +1,21 @@
{
"type": "conversation.item.create",
"timestamp": 1761010203630,
"item": {
"type": "message",
"content": [
{
"requestId": "fc39cefd-fcf9-4775-b490-7e17ce670060",
"text": "a dog typing on a keyboard",
"type": "input_text",
"properties": {
"section_count": 0,
"is_kids_mode": false,
"enable_nsfw": true,
"skip_upsampler": false,
"is_initial": false
}
}
]
}
}
@@ -0,0 +1,4 @@
{
"type": "other_type",
"foo": "bar"
}
@@ -0,0 +1 @@
{"type":"json","current_status":"start_stage","percentage_complete":0.0,"job_id":"0080eaac-f7f7-4369-90a2-346491939c1b","elapsed":null,"prompt":"a dog riding a motorcycle","confirmation":"Drew a dog riding a motorcycle","full_prompt":"a dog riding a motorcycle","flags":null,"is_alteration":false,"section_id":0,"input_type":"text","request_id":"bbc15402-3257-4415-922e-b156c151f3c2","asr_transcript":null,"asr_accepted":null,"seed":null,"image_reference":null,"is_image_edit":null,"moderated":false,"r_rated":null,"order":0,"model_name":"imagine_h_1","image_id":null}
@@ -0,0 +1,25 @@
{
"type": "json",
"current_status": "start_stage",
"percentage_complete": 0,
"job_id": "0080eaac-f7f7-4369-90a2-346491939c1b",
"elapsed": null,
"prompt": "a dog riding a motorcycle",
"confirmation": "Drew a dog riding a motorcycle",
"full_prompt": "a dog riding a motorcycle",
"flags": null,
"is_alteration": false,
"section_id": 0,
"input_type": "text",
"request_id": "bbc15402-3257-4415-922e-b156c151f3c2",
"asr_transcript": null,
"asr_accepted": null,
"seed": null,
"image_reference": null,
"is_image_edit": null,
"moderated": false,
"r_rated": null,
"order": 0,
"model_name": "imagine_h_1",
"image_id": null
}
@@ -89,7 +89,15 @@ mod tests {
let cookies = format!("cookie={}", "a".repeat(10000));
assert_eq!(has_session_cookie(&cookies).unwrap(), SessionCookiePresence::MaybePresent);
let cookies = "";
// Synthetic fixture (the original real session token was redacted); the session
// cookie name is what matters for detection.
let cookies = "__Host-authjs.csrf-token=abc123; \
__Secure-authjs.callback-url=https%3A%2F%2Fsora.com%2F; \
__Secure-authjs.session-token=fake-session-token-value";
assert_eq!(has_session_cookie(cookies).unwrap(), SessionCookiePresence::Present);
let cookies = "__Secure-next-auth.session-token=fake-session-token-value";
assert_eq!(has_session_cookie(cookies).unwrap(), SessionCookiePresence::Present);
}
@@ -1,3 +1,4 @@
pub mod seedance2pro_bad_request_api_error;
pub mod seedance2pro_client_error;
pub mod seedance2pro_error;
pub mod seedance2pro_generic_api_error;
@@ -0,0 +1,29 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
/// These represent known "400s" that are the fault of the client sending a bad request.
#[derive(Debug)]
pub enum Seedance2ProBadRequestApiError {
/// The video generation request was flagged as a content violation.
VideoGenerationViolation { raw_body: String },
/// The prompt exceeded Kinovi's maximum length (10,000 characters at the
/// time of writing).
PromptIsTooLong { raw_body: String },
/// Too many media URLs were attached to the request (`uploadedUrls` is
/// capped at 9 elements at the time of writing).
TooManyUrls { raw_body: String },
}
impl Error for Seedance2ProBadRequestApiError {}
impl Display for Seedance2ProBadRequestApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::VideoGenerationViolation { raw_body } => write!(f, "Video generation violation: {}", raw_body),
Self::PromptIsTooLong { raw_body } => write!(f, "Prompt is too long: {}", raw_body),
Self::TooManyUrls { raw_body } => write!(f, "Too many urls: {}", raw_body),
}
}
}
@@ -1,3 +1,4 @@
use crate::error::seedance2pro_bad_request_api_error::Seedance2ProBadRequestApiError;
use crate::error::seedance2pro_client_error::Seedance2ProClientError;
use crate::error::seedance2pro_generic_api_error::Seedance2ProGenericApiError;
use crate::error::seedance2pro_specific_api_error::Seedance2ProSpecificApiError;
@@ -9,6 +10,7 @@ pub enum Seedance2ProError {
Client(Seedance2ProClientError),
ApiSpecific(Seedance2ProSpecificApiError),
ApiGeneric(Seedance2ProGenericApiError),
ApiBadRequest(Seedance2ProBadRequestApiError),
}
impl Seedance2ProError {
@@ -38,6 +40,7 @@ impl std::fmt::Display for Seedance2ProError {
Self::Client(e) => write!(f, "Seedance2ProClientError: {:?}", e),
Self::ApiSpecific(e) => write!(f, "Seedance2ProSpecificApiError: {:?}", e),
Self::ApiGeneric(e) => write!(f, "Seedance2ProGenericApiError: {:?}", e),
Self::ApiBadRequest(e) => write!(f, "Seedance2ProBadRequestApiError: {:?}", e),
}
}
}
@@ -59,3 +62,9 @@ impl From<Seedance2ProGenericApiError> for Seedance2ProError {
Self::ApiGeneric(error)
}
}
impl From<Seedance2ProBadRequestApiError> for Seedance2ProError {
fn from(error: Seedance2ProBadRequestApiError) -> Self {
Self::ApiBadRequest(error)
}
}
@@ -16,8 +16,12 @@ pub enum Seedance2ProGenericApiError {
/// Includes the original body.
SerdeParseErrorWithBodyOnNon200(serde_json::Error, String),
/// An uncategorized bad HTTP response.
UncategorizedBadResponse(String),
/// A response that parsed, but didn't have the shape we expected
/// (eg. an empty batch response array).
UnexpectedResponseShape {
explanation: String,
raw_body: String,
},
/// An uncategorized bad HTTP response with status code and body.
UncategorizedBadResponseWithStatusAndBody {
@@ -37,7 +41,7 @@ impl Display for Seedance2ProGenericApiError {
Self::CloudflareError(err) => write!(f, "Cloudflare error: {}", err),
Self::SerdeResponseParseErrorWithBody(err, body) => write!(f, "Failed to parse response body: {:?}. Body: {}", err, body),
Self::SerdeParseErrorWithBodyOnNon200(err, body) => write!(f, "Failed to parse non-200 response body: {:?}. Body: {}", err, body),
Self::UncategorizedBadResponse(msg) => write!(f, "Uncategorized bad response: {}", msg),
Self::UnexpectedResponseShape { explanation, raw_body } => write!(f, "Unexpected response shape: {}. Body: {}", explanation, raw_body),
Self::UncategorizedBadResponseWithStatusAndBody { status_code, body } => write!(f, "Uncategorized bad response: status code {}, body: {}", status_code, body),
Self::WreqError(err) => write!(f, "Wreq client error: {}", err),
}
@@ -8,9 +8,6 @@ pub enum Seedance2ProSpecificApiError {
/// The session cookies are expired or invalid.
UnauthorizedSessionExpired,
/// The video generation request was flagged as a content violation.
VideoGenerationViolation(String),
/// The account does not have enough credits to perform the operation.
BillingError {
status_code: StatusCode,
@@ -24,7 +21,6 @@ impl Display for Seedance2ProSpecificApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnauthorizedSessionExpired => write!(f, "Unauthorized: session cookies expired or invalid."),
Self::VideoGenerationViolation(body) => write!(f, "Video generation violation: {}", body),
Self::BillingError { status_code, body } => write!(f, "Billing error (status {}): {}", status_code, body),
}
}
@@ -119,9 +119,10 @@ pub async fn generate_character(args: GenerateCharacterArgs<'_>) -> Result<Gener
let data = batch_response
.into_iter()
.next()
.ok_or_else(|| Seedance2ProGenericApiError::UncategorizedBadResponse(
"Empty batch response array".to_string()
))?
.ok_or_else(|| Seedance2ProGenericApiError::UnexpectedResponseShape {
explanation: "Empty batch response array".to_string(),
raw_body: response_body.clone(),
})?
.result
.data
.json;
@@ -1,8 +1,8 @@
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_bad_request_api_error::Seedance2ProBadRequestApiError;
use crate::error::seedance2pro_client_error::Seedance2ProClientError;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::error::seedance2pro_generic_api_error::Seedance2ProGenericApiError;
use crate::error::seedance2pro_specific_api_error::Seedance2ProSpecificApiError;
use crate::requests::generate_image::request_types::*;
use crate::requests::kinovi_host::{KinoviHost, resolve_host};
use crate::utils::categorize_seedance2pro_error::categorize_seedance2pro_error;
@@ -285,15 +285,16 @@ pub async fn generate_image(args: GenerateImageArgs<'_>) -> Result<GenerateImage
let task_data = batch_response
.into_iter()
.next()
.ok_or_else(|| Seedance2ProGenericApiError::UncategorizedBadResponse(
"Empty batch response array".to_string()
))?
.ok_or_else(|| Seedance2ProGenericApiError::UnexpectedResponseShape {
explanation: "Empty batch response array".to_string(),
raw_body: response_body.clone(),
})?
.result
.data
.json;
if task_data.violation_warning {
return Err(Seedance2ProSpecificApiError::VideoGenerationViolation(response_body).into());
return Err(Seedance2ProBadRequestApiError::VideoGenerationViolation { raw_body: response_body }.into());
}
Ok(GenerateImageResponse {
@@ -1,8 +1,8 @@
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_bad_request_api_error::Seedance2ProBadRequestApiError;
use crate::error::seedance2pro_client_error::Seedance2ProClientError;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::error::seedance2pro_generic_api_error::Seedance2ProGenericApiError;
use crate::error::seedance2pro_specific_api_error::Seedance2ProSpecificApiError;
use crate::requests::generate_video::request_types::*;
use crate::requests::kinovi_host::{KinoviHost, resolve_host};
use crate::utils::categorize_seedance2pro_error::categorize_seedance2pro_error;
@@ -403,15 +403,16 @@ pub async fn generate_video(args: GenerateVideoArgs<'_>) -> Result<GenerateVideo
let task_data = batch_response
.into_iter()
.next()
.ok_or_else(|| Seedance2ProGenericApiError::UncategorizedBadResponse(
"Empty batch response array".to_string()
))?
.ok_or_else(|| Seedance2ProGenericApiError::UnexpectedResponseShape {
explanation: "Empty batch response array".to_string(),
raw_body: response_body.clone(),
})?
.result
.data
.json;
if task_data.violation_warning {
return Err(Seedance2ProSpecificApiError::VideoGenerationViolation(response_body).into());
return Err(Seedance2ProBadRequestApiError::VideoGenerationViolation { raw_body: response_body }.into());
}
Ok(GenerateVideoResponse {
@@ -94,9 +94,10 @@ pub async fn get_user_auth_details(
let json = batch_response
.into_iter()
.next()
.ok_or_else(|| Seedance2ProGenericApiError::UncategorizedBadResponse(
"Empty batch response array".to_string()
))?
.ok_or_else(|| Seedance2ProGenericApiError::UnexpectedResponseShape {
explanation: "Empty batch response array".to_string(),
raw_body: response_body.clone(),
})?
.result
.data
.json;
@@ -181,9 +181,10 @@ pub async fn poll_characters(args: PollCharactersArgs<'_>) -> Result<PollCharact
let data = batch_response
.into_iter()
.next()
.ok_or_else(|| Seedance2ProGenericApiError::UncategorizedBadResponse(
"Empty batch response array".to_string()
))?
.ok_or_else(|| Seedance2ProGenericApiError::UnexpectedResponseShape {
explanation: "Empty batch response array".to_string(),
raw_body: response_body.clone(),
})?
.result
.data
.json;
@@ -201,9 +201,10 @@ pub async fn poll_orders(args: PollOrdersArgs<'_>) -> Result<PollOrdersResponse,
let json = batch_response
.into_iter()
.next()
.ok_or_else(|| Seedance2ProGenericApiError::UncategorizedBadResponse(
"Empty batch response array".to_string()
))?
.ok_or_else(|| Seedance2ProGenericApiError::UnexpectedResponseShape {
explanation: "Empty batch response array".to_string(),
raw_body: response_body.clone(),
})?
.result
.data
.json;
@@ -106,9 +106,10 @@ pub async fn prepare_file_upload(args: PrepareFileUploadArgs<'_>) -> Result<Prep
let upload_url = batch_response
.into_iter()
.next()
.ok_or_else(|| Seedance2ProGenericApiError::UncategorizedBadResponse(
"Empty batch response array".to_string()
))?
.ok_or_else(|| Seedance2ProGenericApiError::UnexpectedResponseShape {
explanation: "Empty batch response array".to_string(),
raw_body: response_body.clone(),
})?
.result
.data
.json;
@@ -102,9 +102,10 @@ pub async fn update_character(args: UpdateCharacterArgs<'_>) -> Result<UpdateCha
let data = batch_response
.into_iter()
.next()
.ok_or_else(|| Seedance2ProGenericApiError::UncategorizedBadResponse(
"Empty batch response array".to_string()
))?
.ok_or_else(|| Seedance2ProGenericApiError::UnexpectedResponseShape {
explanation: "Empty batch response array".to_string(),
raw_body: response_body.clone(),
})?
.result
.data
.json;
@@ -1,8 +1,8 @@
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_bad_request_api_error::Seedance2ProBadRequestApiError;
use crate::error::seedance2pro_client_error::Seedance2ProClientError;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::error::seedance2pro_generic_api_error::Seedance2ProGenericApiError;
use crate::error::seedance2pro_specific_api_error::Seedance2ProSpecificApiError;
use crate::requests::workflow_run_task::request_types::*;
use crate::requests::kinovi_host::{KinoviHost, resolve_host};
use crate::utils::categorize_seedance2pro_error::categorize_seedance2pro_error;
@@ -415,15 +415,16 @@ pub async fn workflow_run_task(args: WorkflowRunTaskArgs<'_>) -> Result<Workflow
let task_data = batch_response
.into_iter()
.next()
.ok_or_else(|| Seedance2ProGenericApiError::UncategorizedBadResponse(
"Empty batch response array".to_string()
))?
.ok_or_else(|| Seedance2ProGenericApiError::UnexpectedResponseShape {
explanation: "Empty batch response array".to_string(),
raw_body: response_body.clone(),
})?
.result
.data
.json;
if task_data.violation_warning {
return Err(Seedance2ProSpecificApiError::VideoGenerationViolation(response_body).into());
return Err(Seedance2ProBadRequestApiError::VideoGenerationViolation { raw_body: response_body }.into());
}
Ok(WorkflowRunTaskResponse {
@@ -1,5 +1,6 @@
use wreq::StatusCode;
use crate::error::seedance2pro_bad_request_api_error::Seedance2ProBadRequestApiError;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::error::seedance2pro_generic_api_error::Seedance2ProGenericApiError;
use crate::error::seedance2pro_specific_api_error::Seedance2ProSpecificApiError;
@@ -9,11 +10,25 @@ const BILLING_ERROR_MARKERS: &[&str] = &[
"credits not enough",
];
/// Zod validation message for a string field exceeding its maximum length,
/// eg. "参数验证失败 - String must contain at most 10000 character(s)".
const STRING_TOO_LONG_MARKER: &str = "String must contain at most";
/// Zod validation message for an array field exceeding its maximum size,
/// eg. "参数验证失败 - Array must contain at most 9 element(s)".
const ARRAY_TOO_LARGE_MARKER: &str = "Array must contain at most";
/// Zod `fieldErrors` path entry for the prompt field.
const PROMPT_FIELD_MARKER: &str = "\"prompt\"";
/// Zod `fieldErrors` path entry for the uploaded media urls field.
const UPLOADED_URLS_FIELD_MARKER: &str = "\"uploadedUrls\"";
/// Categorize a non-success HTTP response into a specific or generic error.
///
/// Checks the response body for known patterns (e.g. billing errors) and returns
/// a specific error variant when possible, falling back to
/// `UncategorizedBadResponseWithStatusAndBody` otherwise.
/// Checks the response body for known patterns (e.g. billing errors, request
/// validation failures) and returns a specific error variant when possible,
/// falling back to `UncategorizedBadResponseWithStatusAndBody` otherwise.
pub fn categorize_seedance2pro_error(
status_code: StatusCode,
body: String,
@@ -25,6 +40,15 @@ pub fn categorize_seedance2pro_error(
}.into();
}
if status_code == StatusCode::BAD_REQUEST {
if is_prompt_too_long_error(&body) {
return Seedance2ProBadRequestApiError::PromptIsTooLong { raw_body: body }.into();
}
if is_too_many_urls_error(&body) {
return Seedance2ProBadRequestApiError::TooManyUrls { raw_body: body }.into();
}
}
Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code,
body,
@@ -37,75 +61,164 @@ fn is_billing_error(body: &str) -> bool {
BILLING_ERROR_MARKERS.iter().any(|marker| body_lower.contains(marker))
}
/// Check if the response body is a zod validation failure for an over-long prompt.
fn is_prompt_too_long_error(body: &str) -> bool {
body.contains(STRING_TOO_LONG_MARKER) && body.contains(PROMPT_FIELD_MARKER)
}
/// Check if the response body is a zod validation failure for too many uploaded urls.
fn is_too_many_urls_error(body: &str) -> bool {
body.contains(ARRAY_TOO_LARGE_MARKER) && body.contains(UPLOADED_URLS_FIELD_MARKER)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::seedance2pro_error::Seedance2ProError;
#[test]
fn real_not_enough_credits_response() {
let body = std::fs::read_to_string("test_data/responses/not_enough_credits.json")
.expect("Failed to read test data file");
mod billing_tests {
use super::*;
let error = categorize_seedance2pro_error(StatusCode::BAD_REQUEST, body);
#[test]
fn real_not_enough_credits_response() {
let body = read_response_body("not_enough_credits.json");
match error {
Seedance2ProError::ApiSpecific(
Seedance2ProSpecificApiError::BillingError { status_code, body }
) => {
assert_eq!(status_code, StatusCode::BAD_REQUEST);
assert!(body.contains("credits not enough"));
let error = categorize_seedance2pro_error(StatusCode::BAD_REQUEST, body);
match error {
Seedance2ProError::ApiSpecific(
Seedance2ProSpecificApiError::BillingError { status_code, body }
) => {
assert_eq!(status_code, StatusCode::BAD_REQUEST);
assert!(body.contains("credits not enough"));
}
other => panic!("Expected BillingError, got: {:?}", other),
}
other => panic!("Expected BillingError, got: {:?}", other),
}
}
#[test]
fn billing_error_case_insensitive() {
let body = r#"{"error":"Credits Not Enough"}"#.to_string();
#[test]
fn billing_error_case_insensitive() {
let body = r#"{"error":"Credits Not Enough"}"#.to_string();
let error = categorize_seedance2pro_error(StatusCode::BAD_REQUEST, body);
let error = categorize_seedance2pro_error(StatusCode::BAD_REQUEST, body);
match error {
Seedance2ProError::ApiSpecific(
Seedance2ProSpecificApiError::BillingError { .. }
) => {}
other => panic!("Expected BillingError, got: {:?}", other),
}
}
#[test]
fn unknown_error_falls_back_to_uncategorized() {
let body = r#"{"error":"something else went wrong"}"#.to_string();
let error = categorize_seedance2pro_error(
StatusCode::INTERNAL_SERVER_ERROR,
body.clone(),
);
match error {
Seedance2ProError::ApiGeneric(
Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody { status_code, body: b }
) => {
assert_eq!(status_code, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(b, body);
match error {
Seedance2ProError::ApiSpecific(
Seedance2ProSpecificApiError::BillingError { .. }
) => {}
other => panic!("Expected BillingError, got: {:?}", other),
}
other => panic!("Expected UncategorizedBadResponseWithStatusAndBody, got: {:?}", other),
}
}
#[test]
fn empty_body_falls_back_to_uncategorized() {
let error = categorize_seedance2pro_error(
StatusCode::BAD_REQUEST,
String::new(),
);
mod bad_request_tests {
use super::*;
match error {
Seedance2ProError::ApiGeneric(
Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. }
) => {}
other => panic!("Expected UncategorizedBadResponseWithStatusAndBody, got: {:?}", other),
#[test]
fn real_prompt_too_long_response() {
let body = read_response_body("prompt_too_long.json");
let error = categorize_seedance2pro_error(StatusCode::BAD_REQUEST, body);
match error {
Seedance2ProError::ApiBadRequest(
Seedance2ProBadRequestApiError::PromptIsTooLong { raw_body }
) => {
assert!(raw_body.contains("String must contain at most 10000 character(s)"));
assert!(raw_body.contains("\"prompt\""));
}
other => panic!("Expected PromptIsTooLong, got: {:?}", other),
}
}
#[test]
fn real_too_many_urls_response() {
let body = read_response_body("too_many_urls.json");
let error = categorize_seedance2pro_error(StatusCode::BAD_REQUEST, body);
match error {
Seedance2ProError::ApiBadRequest(
Seedance2ProBadRequestApiError::TooManyUrls { raw_body }
) => {
assert!(raw_body.contains("Array must contain at most 9 element(s)"));
assert!(raw_body.contains("\"uploadedUrls\""));
}
other => panic!("Expected TooManyUrls, got: {:?}", other),
}
}
#[test]
fn bad_request_markers_require_400_status() {
let body = read_response_body("prompt_too_long.json");
let error = categorize_seedance2pro_error(StatusCode::INTERNAL_SERVER_ERROR, body);
match error {
Seedance2ProError::ApiGeneric(
Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. }
) => {}
other => panic!("Expected UncategorizedBadResponseWithStatusAndBody, got: {:?}", other),
}
}
#[test]
fn validation_failure_for_other_field_is_not_categorized() {
// Same zod shape, but for a field we don't have a variant for.
let body = r#"[{"error":{"json":{"message":"validation failed - String must contain at most 100 character(s)","code":-32600,"data":{"code":"BAD_REQUEST","httpStatus":400,"zodError":{"formErrors":[],"fieldErrors":{"apiParams":[{"code":"custom","message":"String must contain at most 100 character(s)","path":["apiParams","negativePrompt"],"fatal":true}]}}}}}]"#.to_string();
let error = categorize_seedance2pro_error(StatusCode::BAD_REQUEST, body);
match error {
Seedance2ProError::ApiGeneric(
Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. }
) => {}
other => panic!("Expected UncategorizedBadResponseWithStatusAndBody, got: {:?}", other),
}
}
}
mod fallback_tests {
use super::*;
#[test]
fn unknown_error_falls_back_to_uncategorized() {
let body = r#"{"error":"something else went wrong"}"#.to_string();
let error = categorize_seedance2pro_error(
StatusCode::INTERNAL_SERVER_ERROR,
body.clone(),
);
match error {
Seedance2ProError::ApiGeneric(
Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody { status_code, body: b }
) => {
assert_eq!(status_code, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(b, body);
}
other => panic!("Expected UncategorizedBadResponseWithStatusAndBody, got: {:?}", other),
}
}
#[test]
fn empty_body_falls_back_to_uncategorized() {
let error = categorize_seedance2pro_error(
StatusCode::BAD_REQUEST,
String::new(),
);
match error {
Seedance2ProError::ApiGeneric(
Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. }
) => {}
other => panic!("Expected UncategorizedBadResponseWithStatusAndBody, got: {:?}", other),
}
}
}
fn read_response_body(filename: &str) -> String {
std::fs::read_to_string(format!("test_data/responses/{}", filename))
.expect("Failed to read test data file")
}
}
@@ -0,0 +1,40 @@
[
{
"error": {
"json": {
"message": "[{\"code\": \"custom\",\"message\": \"参数验证失败 - String must contain at most 10000 character(s)\",\"path\": [\"apiParams\",\"prompt\"],\"fatal\": true}]",
"code": -32600,
"data": {
"code": "BAD_REQUEST",
"httpStatus": 400,
"path": "workflow.runTask",
"zodError": {
"formErrors": [],
"fieldErrors": {
"apiParams": [
{
"code": "custom",
"message": "参数验证失败 - String must contain at most 10000 character(s)",
"path": [
"apiParams",
"prompt"
],
"fatal": true
}
]
}
}
},
"stack": null
},
"meta": {
"values": {
"stack": [
"undefined"
]
},
"v": 1
}
}
}
]
@@ -0,0 +1,40 @@
[
{
"error": {
"json": {
"message": "[{\"code\": \"custom\",\"message\": \"参数验证失败 - Array must contain at most 9 element(s)\",\"path\": [\"apiParams\",\"uploadedUrls\"],\"fatal\": true}]",
"code": -32600,
"data": {
"code": "BAD_REQUEST",
"httpStatus": 400,
"path": "workflow.runTask",
"zodError": {
"formErrors": [],
"fieldErrors": {
"apiParams": [
{
"code": "custom",
"message": "参数验证失败 - Array must contain at most 9 element(s)",
"path": [
"apiParams",
"uploadedUrls"
],
"fatal": true
}
]
}
}
},
"stack": null
},
"meta": {
"values": {
"stack": [
"undefined"
]
},
"v": 1
}
}
}
]
@@ -136,12 +136,12 @@ struct RawResponse {
#[cfg(test)]
mod tests {
use crate::api::api_types::upload_object_id::UploadObjectId;
use crate::api::requests::objects::begin_object_image_upload::get_url;
use crate::api::requests::objects::begin_object_image_upload::{get_url, BASE_URL};
#[test]
fn test_get_url() {
let upload_id = UploadObjectId("foo-bar-baz-bin".to_string());
let expected = "https://marble2-kgw-prod-iac1.wlt-ai.art/api/v1/objects/foo-bar-baz-bin:upload";
let expected = format!("{}/foo-bar-baz-bin:upload", BASE_URL);
assert_eq!(get_url(&upload_id), expected);
}
}
@@ -147,6 +147,9 @@ mod tests {
#[test]
fn test_request() {
let request = RawRequest::for_mime_type(UploadMimeType::ImageJpeg);
assert_eq!(request.display_name, "uploaded_image_jpeg");
// The display name embeds a random uuid, eg. "uploaded_image_{uuid}.jpeg"
assert!(request.display_name.starts_with("uploaded_image_"));
assert!(request.display_name.ends_with(".jpeg"));
assert_eq!(request.mime_type, "image/jpeg");
}
}
@@ -135,7 +135,7 @@ struct RawResponse {
#[cfg(test)]
mod tests {
use crate::api::api_types::upload_object_id::UploadObjectId;
use crate::api::requests::objects::finalize_object_image_upload::{finalize_object_image_upload, get_url, FinalizeObjectImageUploadArgs};
use crate::api::requests::objects::finalize_object_image_upload::{finalize_object_image_upload, get_url, FinalizeObjectImageUploadArgs, BASE_URL};
use crate::test_utils::get_test_bearer_token::get_test_bearer_token;
use crate::test_utils::get_test_cookies::get_typed_test_cookies;
use crate::test_utils::setup_test_logging::setup_test_logging;
@@ -144,7 +144,7 @@ mod tests {
#[test]
fn test_get_url() {
let upload_id = UploadObjectId("foo-bar-baz-bin".to_string());
let expected = "https://marble2-kgw-prod-iac1.wlt-ai.art/api/v1/objects/foo-bar-baz-bin:complete";
let expected = format!("{}/foo-bar-baz-bin:complete", BASE_URL);
assert_eq!(get_url(&upload_id), expected);
}
@@ -405,6 +405,10 @@ mod tests {
use crate::api::api_types::pano_object_id::PanoObjectId;
use crate::api::api_types::run_object_id::RunObjectId;
use crate::api::requests::objects::update_run_object_with_upload::{RawRequest, UpdateRunObjectWithUploadPayloadArgs};
use serde_json::{json, Value};
/// Any timestamp from `Utc::now()` should exceed this (2023-11-14 in epoch millis).
const MIN_PLAUSIBLE_EPOCH_MILLIS: u64 = 1_700_000_000_000;
#[test]
fn request_default() {
@@ -432,9 +436,88 @@ mod tests {
let request = RawRequest::from_args(&args);
let json = serde_json::to_string_pretty(&request).unwrap();
println!("{}", json);
// The `createdAt` / `updatedAt` timestamps come from `Utc::now()` at build time,
// so they're nondeterministic. Sanity-check them, then zero them out before
// comparing the rest of the document.
assert!(request.object.metadata.created_at > MIN_PLAUSIBLE_EPOCH_MILLIS);
assert!(request.object.metadata.updated_at >= request.object.metadata.created_at);
assert_eq!(json, r#"{"type":"abcxyz_image_input_id","value":"image_input_id"}"#);
let mut actual = serde_json::to_value(&request).unwrap();
zero_out_timestamps(&mut actual);
let expected = json!({
"object": {
"metadata": {
"version": "0.0.1",
"createdAt": 0,
"updatedAt": 0,
"usesAdvancedEditing": false,
"draftMode": false,
"nodes": {
"bc17252b-811e-49d7-be3e-b7c538df9d30": {
"id": "bc17252b-811e-49d7-be3e-b7c538df9d30",
"type": "input",
"parentId": null,
"source": {
"type": "image",
"image_url": "https://todo.com"
},
"createdAt": 0
},
"82f6b488-8afe-4311-9022-80ad5789ad92": {
"id": "82f6b488-8afe-4311-9022-80ad5789ad92",
"type": "pano",
"parentId": "bc17252b-811e-49d7-be3e-b7c538df9d30",
"source": {
"type": "generate_world",
"world_id": "pending",
"status": "pending"
},
"createdAt": 0
},
"f08ec501-601c-47c6-a104-402d1820b8f0": {
"id": "f08ec501-601c-47c6-a104-402d1820b8f0",
"type": "world",
"parentId": "82f6b488-8afe-4311-9022-80ad5789ad92",
"source": {
"type": "generate_world",
"world_id": "pending",
"posed_cubemaps_url": "",
"minimap_url": "",
"minimap_metadata": "",
"status": "pending"
},
"createdAt": 0
}
}
}
},
"update_mask": [
"metadata"
]
});
assert_eq!(actual, expected);
}
/// Recursively replaces nondeterministic `createdAt` / `updatedAt` values with 0.
fn zero_out_timestamps(value: &mut Value) {
match value {
Value::Object(map) => {
for (key, child) in map.iter_mut() {
if key == "createdAt" || key == "updatedAt" {
*child = json!(0);
} else {
zero_out_timestamps(child);
}
}
},
Value::Array(items) => {
for item in items.iter_mut() {
zero_out_timestamps(item);
}
},
_ => {},
}
}
}
@@ -411,6 +411,10 @@ mod tests {
use crate::api::api_types::meta_world_object_id::MetaWorldObjectId;
use crate::api::api_types::world_id::WorldObjectId;
use crate::api::requests::objects::update_run_object_with_world::{RawRequest, UpdateRunObjectWithWorldPayloadArgs};
use serde_json::{json, Value};
/// Any timestamp from `Utc::now()` should exceed this (2023-11-14 in epoch millis).
const MIN_PLAUSIBLE_EPOCH_MILLIS: u64 = 1_700_000_000_000;
#[test]
fn request_default() {
@@ -446,9 +450,91 @@ mod tests {
let request = RawRequest::from_args(&args);
let json = serde_json::to_string_pretty(&request).unwrap();
println!("{}", json);
// The `createdAt` / `updatedAt` timestamps come from `Utc::now()` at build time,
// so they're nondeterministic. Sanity-check them, then zero them out before
// comparing the rest of the document.
assert!(request.object.metadata.created_at > MIN_PLAUSIBLE_EPOCH_MILLIS);
assert!(request.object.metadata.updated_at >= request.object.metadata.created_at);
assert_eq!(json, r#"{"type":"abcxyz_image_input_id","value":"image_input_id"}"#);
let mut actual = serde_json::to_value(&request).unwrap();
zero_out_timestamps(&mut actual);
let expected = json!({
"object": {
"metadata": {
"version": "0.0.1",
"name": title,
"createdAt": 0,
"updatedAt": 0,
"usesAdvancedEditing": false,
"draftMode": false,
"nodes": {
"bc17252b-811e-49d7-be3e-b7c538df9d30": {
"id": "bc17252b-811e-49d7-be3e-b7c538df9d30",
"type": "input",
"parentId": null,
"source": {
"type": "image",
"image_url": "https://cdn.marble.worldlabs.ai/object/13ca760c-8ef9-4bf5-acc3-5a507fad3abf/asset.jpg"
},
"createdAt": 0,
"title": title,
"caption": caption
},
"82f6b488-8afe-4311-9022-80ad5789ad92": {
"id": "82f6b488-8afe-4311-9022-80ad5789ad92",
"type": "pano",
"parentId": "bc17252b-811e-49d7-be3e-b7c538df9d30",
"source": {
"type": "generate_world",
"world_id": "d60cfa21-5506-43de-9a9c-0707fc17a5ec",
"status": "pending"
},
"createdAt": 0
},
"f08ec501-601c-47c6-a104-402d1820b8f0": {
"id": "f08ec501-601c-47c6-a104-402d1820b8f0",
"type": "world",
"parentId": "82f6b488-8afe-4311-9022-80ad5789ad92",
"source": {
"type": "generate_world",
"world_id": "d60cfa21-5506-43de-9a9c-0707fc17a5ec",
"posed_cubemaps_url": "",
"minimap_url": "",
"minimap_metadata": "",
"status": "pending"
},
"createdAt": 0
}
}
}
},
"update_mask": [
"metadata"
]
});
assert_eq!(actual, expected);
}
/// Recursively replaces nondeterministic `createdAt` / `updatedAt` values with 0.
fn zero_out_timestamps(value: &mut Value) {
match value {
Value::Object(map) => {
for (key, child) in map.iter_mut() {
if key == "createdAt" || key == "updatedAt" {
*child = json!(0);
} else {
zero_out_timestamps(child);
}
}
},
Value::Array(items) => {
for item in items.iter_mut() {
zero_out_timestamps(item);
}
},
_ => {},
}
}
}
@@ -151,7 +151,7 @@ struct SpzUrls {
#[cfg(test)]
mod tests {
use crate::api::api_types::world_id::WorldObjectId;
use crate::api::requests::worlds::poll_world_status::{get_url, poll_world_status, PollWorldStatusArgs};
use crate::api::requests::worlds::poll_world_status::{get_url, poll_world_status, PollWorldStatusArgs, BASE_URL};
use crate::test_utils::get_test_bearer_token::get_test_bearer_token;
use crate::test_utils::get_test_cookies::get_typed_test_cookies;
use crate::test_utils::setup_test_logging::setup_test_logging;
@@ -160,7 +160,7 @@ mod tests {
#[test]
fn test_get_url() {
let world_id = WorldObjectId("d60cfa21-5506-43de-9a9c-0707fc17a5ec".to_string());
let expected = "https://marble2-kgw-prod-iac1.wlt-ai.art/api/v1/worlds/d60cfa21-5506-43de-9a9c-0707fc17a5ec";
let expected = format!("{}/d60cfa21-5506-43de-9a9c-0707fc17a5ec", BASE_URL);
assert_eq!(get_url(&world_id), expected);
}
@@ -407,6 +407,7 @@ mod tests {
}
#[test]
#[ignore] // Transliteration for the time-bombed characters (α, đ, ß) was never implemented.
pub fn most_frequent_failures_2p5k_usages() {
assert_converted("~", "~"); // b'\\uff5e' 2507
time_bomb_ignore(|| assert_converted("â", "a")); // b'\xe2' 2607
@@ -429,6 +430,7 @@ mod tests {
}
#[test]
#[ignore] // Transliteration for the time-bombed characters (ę, ě, ᴾ, ł) was never implemented.
pub fn most_frequent_failures_2k_usages() {
assert_converted("、", ","); // b'\\u3001' 2001
assert_converted("ć", "c"); // b'\\u0107' 2017
@@ -442,6 +444,7 @@ mod tests {
}
#[test]
#[ignore] // Transliteration for the time-bombed characters (ū, ι, ε) was never implemented.
pub fn most_frequent_failures_1p5k_usages() {
assert_converted("·", " "); // b'\xb7' 1514
assert_converted("š", "s"); // b'\\u0161' 1514
@@ -16,8 +16,10 @@ use tokens::tokens::prompts::PromptToken;
/// and the rest of the typed response shape.
#[derive(Debug, Clone)]
pub struct FolderMediaFileRow {
/// `folder_media_files.id` — used as the pagination cursor.
pub membership_id: u64,
/// `media_files.id` — used as the pagination cursor. The list is sorted
/// by this (descending) rather than by folder-membership order so the
/// frontend's group-by-date view doesn't interleave across pages.
pub media_file_id: u64,
/// When the media file was added to the folder.
pub added_to_folder_at: DateTime<Utc>,
@@ -71,7 +73,8 @@ where
/// for the file's own metadata and LEFT JOINs `media_files` again on
/// `maybe_cover_image_media_file_token` so the cover-image bucket fields
/// come back in a single round-trip. Filters out media files that are
/// soft-deleted on the media-file side. Most-recently-added first.
/// soft-deleted on the media-file side. Newest media files first
/// (`media_files.id` descending — NOT folder-membership order).
///
/// The caller is expected to have already authorized the folder access.
pub async fn list_folder_media_files<'e, 'c: 'e, E>(
@@ -87,7 +90,7 @@ where
sqlx::query!(
r#"
SELECT
fmf.id as `membership_id: u64`,
mf.id as `media_file_id: u64`,
fmf.created_at as `added_to_folder_at: DateTime<Utc>`,
mf.token as `media_file_token: MediaFileToken`,
@@ -126,8 +129,8 @@ LEFT JOIN media_files cover
WHERE fmf.folder_token = ?
AND mf.user_deleted_at IS NULL
AND mf.mod_deleted_at IS NULL
AND fmf.id < ?
ORDER BY fmf.id DESC
AND mf.id < ?
ORDER BY mf.id DESC
LIMIT ?
"#,
args.folder_token.as_str(),
@@ -138,7 +141,7 @@ LIMIT ?
.await?
.into_iter()
.map(|r| FolderMediaFileRow {
membership_id: r.membership_id,
media_file_id: r.media_file_id,
added_to_folder_at: r.added_to_folder_at,
media_file_token: r.media_file_token,
media_class: r.media_class,
@@ -168,7 +171,7 @@ LIMIT ?
sqlx::query!(
r#"
SELECT
fmf.id as `membership_id: u64`,
mf.id as `media_file_id: u64`,
fmf.created_at as `added_to_folder_at: DateTime<Utc>`,
mf.token as `media_file_token: MediaFileToken`,
@@ -207,7 +210,7 @@ LEFT JOIN media_files cover
WHERE fmf.folder_token = ?
AND mf.user_deleted_at IS NULL
AND mf.mod_deleted_at IS NULL
ORDER BY fmf.id DESC
ORDER BY mf.id DESC
LIMIT ?
"#,
args.folder_token.as_str(),
@@ -217,7 +220,7 @@ LIMIT ?
.await?
.into_iter()
.map(|r| FolderMediaFileRow {
membership_id: r.membership_id,
media_file_id: r.media_file_id,
added_to_folder_at: r.added_to_folder_at,
media_file_token: r.media_file_token,
media_class: r.media_class,
@@ -0,0 +1,50 @@
use sqlx::pool::PoolConnection;
use sqlx::MySql;
use enums::common::payments_namespace::PaymentsNamespace;
use tokens::tokens::users::UserToken;
use crate::queries::users::user_stripe_customer_links::find_user_stripe_customer_link::UserStripeCustomerLink;
/// List every stripe customer link for a user, across all payments namespaces.
/// Users have at most one link per namespace.
pub async fn list_user_stripe_customer_links_for_user(
user_token: &UserToken,
connection: &mut PoolConnection<MySql>,
) -> Result<Vec<UserStripeCustomerLink>, sqlx::Error> {
let records = sqlx::query_as!(
RawUserStripeCustomerLink,
r#"
SELECT
user_token as `user_token: tokens::tokens::users::UserToken`,
payments_namespace as `payments_namespace: enums::common::payments_namespace::PaymentsNamespace`,
stripe_customer_id
FROM user_stripe_customer_links
WHERE user_token = ?
ORDER BY id ASC
"#,
user_token.as_str(),
)
.fetch_all(&mut **connection)
.await?;
let links = records
.into_iter()
.map(|record| UserStripeCustomerLink {
user_token: record.user_token,
payments_namespace: record.payments_namespace,
stripe_customer_id: record.stripe_customer_id,
})
.collect();
Ok(links)
}
#[derive(sqlx::FromRow)]
struct RawUserStripeCustomerLink {
user_token: UserToken,
payments_namespace: PaymentsNamespace,
stripe_customer_id: String,
}
@@ -1,2 +1,3 @@
pub mod find_user_stripe_customer_link;
pub mod upsert_user_stripe_customer_link;
pub mod list_user_stripe_customer_links_for_user;
pub mod upsert_user_stripe_customer_link;
@@ -0,0 +1,40 @@
use sqlx::pool::PoolConnection;
use sqlx::MySql;
use enums::common::payments_namespace::PaymentsNamespace;
use tokens::tokens::users::UserToken;
/// A distinct stripe customer id attached to one of the user's subscriptions.
pub struct UserSubscriptionStripeCustomerId {
pub subscription_namespace: PaymentsNamespace,
pub stripe_customer_id: String,
}
/// List the distinct stripe customer ids across all of a user's subscriptions
/// (current and historical), across all payments namespaces.
pub async fn list_stripe_customer_ids_for_user_subscriptions(
user_token: &UserToken,
connection: &mut PoolConnection<MySql>,
) -> Result<Vec<UserSubscriptionStripeCustomerId>, sqlx::Error> {
let records = sqlx::query_as!(
UserSubscriptionStripeCustomerId,
r#"
SELECT DISTINCT
subscription_namespace as `subscription_namespace: enums::common::payments_namespace::PaymentsNamespace`,
maybe_stripe_customer_id as `stripe_customer_id!`
FROM user_subscriptions
WHERE
user_token = ?
AND maybe_stripe_customer_id IS NOT NULL
AND deleted_at IS NULL
ORDER BY subscription_namespace ASC, maybe_stripe_customer_id ASC
"#,
user_token.as_str(),
)
.fetch_all(&mut **connection)
.await?;
Ok(records)
}
@@ -1,7 +1,8 @@
pub mod find_possibly_inactive_first_subscription_for_owner_user;
pub mod lookup_users_by_stripe_customer_id;
pub mod find_subscription_for_owner_user;
pub mod get_user_subscription_by_stripe_subscription_id;
pub mod list_active_user_subscriptions;
pub mod list_stripe_customer_ids_for_user_subscriptions;
pub mod lookup_users_by_stripe_customer_id;
pub mod upsert_user_subscription_by_stripe_id;
pub mod upsert_user_subscription_with_invoice_paid_status_by_stripe_id;
@@ -222,14 +222,15 @@ mod tests {
assert_eq!(variants.pop_first(), Some(MediaFileOriginModelType::LivePortrait));
assert_eq!(variants.pop_first(), Some(MediaFileOriginModelType::RvcV2));
assert_eq!(variants.pop_first(), Some(MediaFileOriginModelType::SadTalker));
assert_eq!(variants.pop_first(), Some(MediaFileOriginModelType::SeedVc));
assert_eq!(variants.pop_first(), Some(MediaFileOriginModelType::SoVitsSvc));
assert_eq!(variants.pop_first(), Some(MediaFileOriginModelType::Tacotron2));
assert_eq!(variants.pop_first(), Some(MediaFileOriginModelType::MocapNet));
assert_eq!(variants.pop_first(), Some(MediaFileOriginModelType::SeedVc));
assert_eq!(variants.pop_first(), Some(MediaFileOriginModelType::StyleTTS2));
assert_eq!(variants.pop_first(), Some(MediaFileOriginModelType::StableDiffusion15));
assert_eq!(variants.pop_first(), Some(MediaFileOriginModelType::GptSovits));
assert_eq!(variants.pop_first(), Some(MediaFileOriginModelType::StorytellerStudio));
assert_eq!(variants.pop_first(), Some(MediaFileOriginModelType::StorytellerStudioImageGen));
assert_eq!(variants.pop_first(), Some(MediaFileOriginModelType::VideoStyleTransfer));
assert_eq!(variants.pop_first(), Some(MediaFileOriginModelType::ComfyUi));
assert_eq!(variants.pop_first(), Some(MediaFileOriginModelType::VallEX));
@@ -117,7 +117,9 @@ mod tests {
fn test_serialization() {
assert_serialization(PromptContextSemanticType::VidStartFrame, "vid_start_frame");
assert_serialization(PromptContextSemanticType::VidEndFrame, "vid_end_frame");
assert_serialization(PromptContextSemanticType::VidRef, "vidref");
// NB: serde serialization ("vid_ref") intentionally differs from to_str()/the DB value
// ("vidref"); see the "Current DB values" comment at the top of this file.
assert_serialization(PromptContextSemanticType::VidRef, "vid_ref");
assert_serialization(PromptContextSemanticType::Imgsrc, "imgsrc");
assert_serialization(PromptContextSemanticType::Imgmask, "imgmask");
assert_serialization(PromptContextSemanticType::Imgref, "imgref");
@@ -127,7 +127,7 @@ mod tests {
{"id":"sub_1LhA3MEU5se17MekeWvmTNyk","automatic_tax":{"enabled":false},
"billing_cycle_anchor":1662979188,"cancel_at_period_end":false,
"collection_method":"charge_automatically","created":1662979188,"current_period_end":1665571188,
"current_period_start":1662979188,"customer":"cus_MQ03py0gWUh0Ox","default_tax_rates":[],
"current_period_start":1662979188,"customer":"cus_MQ03py0gWUh0Ox","currency":"usd","default_tax_rates":[],
"items":{"data":[{"id":"si_MQ03hadVNfRnaS","created":1662979188,"deleted":false,"metadata":{},
"price":{"id":"price_1LeDnKEU5se17MekVr1iYYNf","active":true,"billing_scheme":"per_unit",
"created":1662278586,"currency":"usd","deleted":false,"livemode":false,"metadata":{},
@@ -172,7 +172,7 @@ mod tests {
"cancel_at_period_end":false,"collection_method":"charge_automatically","created":1662948037,
"current_period_end":1665540037,"current_period_start":1662948037,
"customer":"cus_MPrgIen5Wh6QKG",
"default_payment_method":"pm_1Lh1wtEU5se17MekpirKtMJm","default_tax_rates":[],
"default_payment_method":"pm_1Lh1wtEU5se17MekpirKtMJm","currency":"usd","default_tax_rates":[],
"items":{"data":[{"id":"si_MPrgTAV333Nq7c","created":1662948037,"deleted":false,"metadata":{},
"price":{"id":"price_1LeDnKEU5se17MekVr1iYYNf","active":true,"billing_scheme":"per_unit",
"created":1662278586,"currency":"usd","deleted":false,"livemode":false,"metadata":{},
@@ -216,7 +216,7 @@ mod tests {
"billing_cycle_anchor":1662979188,"cancel_at_period_end":false,"canceled_at":1662979740,
"collection_method":"charge_automatically","created":1662979188,"current_period_end":1665571188,
"current_period_start":1662979188,"customer":"cus_MQ03py0gWUh0Ox",
"default_payment_method":"pm_1LhA3LEU5se17MekViNvoYAx","default_tax_rates":[],
"default_payment_method":"pm_1LhA3LEU5se17MekViNvoYAx","currency":"usd","default_tax_rates":[],
"ended_at":1662979740,"items":{"data":[{"id":"si_MQ03hadVNfRnaS","created":1662979188,
"deleted":false,"metadata":{},"price":{"id":"price_1LeDnKEU5se17MekVr1iYYNf","active":true,
"billing_scheme":"per_unit","created":1662278586,"currency":"usd","deleted":false,
@@ -127,8 +127,11 @@ mod tests {
{"id":"sub_1LhA3MEU5se17MekeWvmTNyk","automatic_tax":{"enabled":false},
"billing_cycle_anchor":1662979188,"cancel_at_period_end":false,
"collection_method":"charge_automatically","created":1662979188,"current_period_end":1665571188,
"current_period_start":1662979188,"customer":"cus_MQ03py0gWUh0Ox","default_tax_rates":[],
"items":{"data":[{"id":"si_MQ03hadVNfRnaS","created":1662979188,"deleted":false,"metadata":{},
"current_period_start":1662979188,"customer":"cus_MQ03py0gWUh0Ox","currency":"usd","default_tax_rates":[],"discounts":[],
"invoice_settings":{"issuer":{"type":"self"}},
"items":{"data":[{"id":"si_MQ03hadVNfRnaS","created":1662979188,"current_period_end":1665571188,
"current_period_start":1662979188,"deleted":false,"discounts":[],
"plan":{"id":"price_1LeDnKEU5se17MekVr1iYYNf","object":"plan","active":true,"amount":700,"amount_decimal":"700","billing_scheme":"per_unit","created":1662278586,"currency":"usd","interval":"month","interval_count":1,"livemode":false,"metadata":{},"product":"prod_MMxi2J5y69VPbO","usage_type":"licensed"},"metadata":{},
"price":{"id":"price_1LeDnKEU5se17MekVr1iYYNf","active":true,"billing_scheme":"per_unit",
"created":1662278586,"currency":"usd","deleted":false,"livemode":false,"metadata":{},
"product":"prod_MMxi2J5y69VPbO","recurring":{"interval":"month","interval_count":1,
@@ -172,8 +175,11 @@ mod tests {
"cancel_at_period_end":false,"collection_method":"charge_automatically","created":1662948037,
"current_period_end":1665540037,"current_period_start":1662948037,
"customer":"cus_MPrgIen5Wh6QKG",
"default_payment_method":"pm_1Lh1wtEU5se17MekpirKtMJm","default_tax_rates":[],
"items":{"data":[{"id":"si_MPrgTAV333Nq7c","created":1662948037,"deleted":false,"metadata":{},
"default_payment_method":"pm_1Lh1wtEU5se17MekpirKtMJm","currency":"usd","default_tax_rates":[],"discounts":[],
"invoice_settings":{"issuer":{"type":"self"}},
"items":{"data":[{"id":"si_MPrgTAV333Nq7c","created":1662948037,"current_period_end":1665540037,
"current_period_start":1662948037,"deleted":false,"discounts":[],
"plan":{"id":"price_1LeDnKEU5se17MekVr1iYYNf","object":"plan","active":true,"amount":700,"amount_decimal":"700","billing_scheme":"per_unit","created":1662278586,"currency":"usd","interval":"month","interval_count":1,"livemode":false,"metadata":{},"product":"prod_MMxi2J5y69VPbO","usage_type":"licensed"},"metadata":{},
"price":{"id":"price_1LeDnKEU5se17MekVr1iYYNf","active":true,"billing_scheme":"per_unit",
"created":1662278586,"currency":"usd","deleted":false,"livemode":false,"metadata":{},
"product":"prod_MMxi2J5y69VPbO","recurring":{"interval":"month","interval_count":1,
@@ -216,9 +222,12 @@ mod tests {
"billing_cycle_anchor":1662979188,"cancel_at_period_end":false,"canceled_at":1662979740,
"collection_method":"charge_automatically","created":1662979188,"current_period_end":1665571188,
"current_period_start":1662979188,"customer":"cus_MQ03py0gWUh0Ox",
"default_payment_method":"pm_1LhA3LEU5se17MekViNvoYAx","default_tax_rates":[],
"default_payment_method":"pm_1LhA3LEU5se17MekViNvoYAx","currency":"usd","default_tax_rates":[],"discounts":[],
"invoice_settings":{"issuer":{"type":"self"}},
"ended_at":1662979740,"items":{"data":[{"id":"si_MQ03hadVNfRnaS","created":1662979188,
"deleted":false,"metadata":{},"price":{"id":"price_1LeDnKEU5se17MekVr1iYYNf","active":true,
"current_period_end":1665571188,"current_period_start":1662979188,
"deleted":false,"discounts":[],
"plan":{"id":"price_1LeDnKEU5se17MekVr1iYYNf","object":"plan","active":true,"amount":700,"amount_decimal":"700","billing_scheme":"per_unit","created":1662278586,"currency":"usd","interval":"month","interval_count":1,"livemode":false,"metadata":{},"product":"prod_MMxi2J5y69VPbO","usage_type":"licensed"},"metadata":{},"price":{"id":"price_1LeDnKEU5se17MekVr1iYYNf","active":true,
"billing_scheme":"per_unit","created":1662278586,"currency":"usd","deleted":false,
"livemode":false,"metadata":{},"product":"prod_MMxi2J5y69VPbO",
"recurring":{"interval":"month","interval_count":1,"usage_type":"licensed"},
@@ -283,7 +292,14 @@ mod tests {
],
"description": null,
"discount": null,
"discounts": [
],
"ended_at": null,
"invoice_settings": {
"issuer": {
"type": "self"
}
},
"items": {
"object": "list",
"data": [
@@ -292,6 +308,9 @@ mod tests {
"object": "subscription_item",
"billing_thresholds": null,
"created": 1663430298,
"current_period_end": 1666022298,
"current_period_start": 1663430298,
"discounts": [],
"metadata": {
},
"plan": {
@@ -2,6 +2,8 @@ use stripe::Client;
#[derive(Clone)]
pub struct ArtcraftStripeConfig {
/// The Stripe account id (eg. "acct_..."), used to build dashboard links.
pub stripe_account_id: String,
pub secret_key: String,
pub secret_webhook_signing_key: String,
pub checkout_success_url: String,
@@ -11,6 +13,8 @@ pub struct ArtcraftStripeConfig {
#[derive(Clone)]
pub struct ArtcraftStripeConfigWithClient {
/// The Stripe account id (eg. "acct_..."), used to build dashboard links.
pub stripe_account_id: String,
pub secret_key: String,
pub secret_webhook_signing_key: String,
pub checkout_success_url: String,
@@ -22,6 +26,7 @@ pub struct ArtcraftStripeConfigWithClient {
impl ArtcraftStripeConfig {
pub fn to_config_with_client(&self) -> ArtcraftStripeConfigWithClient {
ArtcraftStripeConfigWithClient {
stripe_account_id: self.stripe_account_id.clone(),
secret_key: self.secret_key.clone(),
secret_webhook_signing_key: self.secret_webhook_signing_key.clone(),
checkout_success_url: self.checkout_success_url.clone(),
@@ -117,6 +117,7 @@ use artcraft_api_defs::folders::media_files::*;
use artcraft_api_defs::folders::subfolder::*;
use artcraft_api_defs::moderation::user_referrals::list_global_user_referrals::*;
use artcraft_api_defs::moderation::user_referrals::list_user_referrals_for_user::*;
use artcraft_api_defs::moderation::user_stripe_data::moderator_get_user_stripe_customer_ids::*;
use crate::http_server::endpoints::moderation::user_sessions::moderator_list_user_session_impersonation_requests_for_user_handler::*;
use crate::http_server::endpoints::moderation::user_sessions::moderator_list_user_session_impersonation_requests_handler::*;
use crate::http_server::endpoints::moderation::user_sessions::moderator_user_session_impersonation_request_handler::*;
@@ -558,6 +559,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
crate::http_server::endpoints::moderation::user_bans::moderation_ban_user_handler::moderation_ban_user_handler,
crate::http_server::endpoints::moderation::user_emails::moderator_change_user_email_handler::moderator_change_user_email_handler,
crate::http_server::endpoints::moderation::user_emails::moderator_list_email_address_changes_for_user_handler::moderator_list_email_address_changes_for_user_handler,
crate::http_server::endpoints::moderation::user_stripe_data::moderator_get_user_stripe_customer_ids_handler::moderator_get_user_stripe_customer_ids_handler,
// Folders
crate::http_server::endpoints::folders::folder::create_folder_handler::create_folder_handler,
@@ -816,6 +818,10 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
ModeratorGetWalletLedgerEntryPathInfo,
ModeratorGetWalletLedgerEntryResponse,
ModeratorGetWalletLedgerEntryDetails,
ModeratorGetUserStripeCustomerIdsPathInfo,
ModeratorGetUserStripeCustomerIdsResponse,
ModeratorUserStripeCustomerIdEntry,
ModeratorStripeCustomerIdSource,
GptImage1EditImageRequest,
GptImage1EditImageImageSize,
GptImage1EditImageNumImages,
@@ -1247,6 +1253,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
// Folders
FolderInfo,
FolderThumbnail,
FolderThumbnailVideoPreviews,
FolderMediaFileListItem,
FolderPathInfo,
FolderMediaFilesPathInfo,
@@ -123,14 +123,14 @@ mod tests {
assert_eq!(cover_image.maybe_cover_image_public_bucket_path, Some("/media/b/u/c/k/e/bucket_hash/image_bucket_hash.png".to_string()));
assert_eq!(cover_image.maybe_cover_image_public_bucket_url,
Some(Url::parse("https://storage.googleapis.com/vocodes-public/media/b/u/c/k/e/bucket_hash/image_bucket_hash.png").unwrap()));
Some(Url::parse("https://cdn-2.fakeyou.com/media/b/u/c/k/e/bucket_hash/image_bucket_hash.png").unwrap()));
assert_eq!(cover_image.default_cover.image_index, 18);
let links = cover_image.maybe_links.unwrap();
assert_eq!(links.cdn_url, Url::parse(
"https://cdn.storyteller.ai/media/b/u/c/k/e/bucket_hash/image_bucket_hash.png").unwrap());
"https://cdn-2.fakeyou.com/media/b/u/c/k/e/bucket_hash/image_bucket_hash.png").unwrap());
assert_eq!(links.thumbnail_template,
"https://cdn.storyteller.ai/cdn-cgi/image/width={WIDTH},quality=95/media/b/u/c/k/e/bucket_hash/image_bucket_hash.png");
"https://cdn-2.fakeyou.com/cdn-cgi/image/width={WIDTH},quality=95/media/b/u/c/k/e/bucket_hash/image_bucket_hash.png");
}
}
@@ -3,7 +3,7 @@ use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use artcraft_api_defs::folders::common::{FolderInfo, FolderThumbnail};
use artcraft_api_defs::folders::common::{FolderInfo, FolderThumbnail, FolderThumbnailVideoPreviews};
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use mysql_queries::queries::folders::folder::folder_row::FolderRow;
use mysql_queries::queries::media_files::get::batch_get_media_file_thumbnails_by_tokens::{
@@ -122,8 +122,8 @@ fn build_folder_thumbnail(
row.maybe_public_bucket_extension.as_deref(),
);
// Reuse `MediaLinksBuilder` to produce the CDN URL + thumbnail
// template, then pull just those two fields onto the compact
// Reuse `MediaLinksBuilder` to produce the CDN URL, thumbnail template,
// and video previews, then pull those fields onto the compact
// FolderThumbnail wire shape.
let media_links = MediaLinksBuilder::from_media_path_and_env(
media_domain,
@@ -131,11 +131,81 @@ fn build_folder_thumbnail(
&bucket_path,
);
// Only video media files have previews; `MediaLinksBuilder` returns
// `None` for everything else.
let maybe_video_previews = media_links.maybe_video_previews
.map(|previews| FolderThumbnailVideoPreviews {
still: previews.still,
animated: previews.animated,
still_thumbnail_template: previews.still_thumbnail_template,
animated_thumbnail_template: previews.animated_thumbnail_template,
});
FolderThumbnail {
token: row.token,
media_class: row.media_class,
media_type: row.media_type,
cdn_url: media_links.cdn_url,
maybe_thumbnail_template: media_links.maybe_thumbnail_template,
maybe_video_previews,
}
}
#[cfg(test)]
mod tests {
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_type::MediaFileType;
use super::*;
const PROD_CDN: &str = "https://cdn-2.fakeyou.com";
const OBJECT_HASH: &str = "t6cnyw4g3e8k7carkk2bvrt6nd3fycjv";
#[test]
fn mp4_video_gets_video_previews() {
let thumbnail = build_thumbnail_for_extension(MediaFileClass::Video, MediaFileType::Mp4, ".mp4");
assert_eq!(thumbnail.maybe_thumbnail_template, None);
let previews = thumbnail.maybe_video_previews.expect("video should have previews");
assert_eq!(
previews.still.as_str(),
format!("{PROD_CDN}/media/t/6/c/n/y/{OBJECT_HASH}/storyteller_{OBJECT_HASH}.mp4-thumb.jpg"));
assert_eq!(
previews.animated.as_str(),
format!("{PROD_CDN}/media/t/6/c/n/y/{OBJECT_HASH}/storyteller_{OBJECT_HASH}.mp4-thumb.gif"));
assert_eq!(
previews.still_thumbnail_template,
format!("{PROD_CDN}/cdn-cgi/image/width={{WIDTH}},quality=95/media/t/6/c/n/y/{OBJECT_HASH}/storyteller_{OBJECT_HASH}.mp4-thumb.jpg"));
assert_eq!(
previews.animated_thumbnail_template,
format!("{PROD_CDN}/cdn-cgi/image/width={{WIDTH}},quality=95/media/t/6/c/n/y/{OBJECT_HASH}/storyteller_{OBJECT_HASH}.mp4-thumb.gif"));
}
#[test]
fn png_image_gets_thumbnail_template_but_no_video_previews() {
let thumbnail = build_thumbnail_for_extension(MediaFileClass::Image, MediaFileType::Png, ".png");
assert_eq!(
thumbnail.maybe_thumbnail_template,
Some(format!("{PROD_CDN}/cdn-cgi/image/width={{WIDTH}},quality=95/media/t/6/c/n/y/{OBJECT_HASH}/storyteller_{OBJECT_HASH}.png")));
assert!(thumbnail.maybe_video_previews.is_none());
}
fn build_thumbnail_for_extension(
media_class: MediaFileClass,
media_type: MediaFileType,
extension: &str,
) -> FolderThumbnail {
let row = MediaFileThumbnailRow {
token: MediaFileToken::new_from_str("m_test"),
media_class,
media_type,
public_bucket_directory_hash: OBJECT_HASH.to_string(),
maybe_public_bucket_prefix: Some("storyteller_".to_string()),
maybe_public_bucket_extension: Some(extension.to_string()),
};
build_folder_thumbnail(row, MediaDomain::FakeYou, ServerEnvironment::Production)
}
}
@@ -172,7 +172,7 @@ pub async fn list_folder_media_files_handler(
})?;
let maybe_cursor = rows.last().map(|last| {
server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.membership_id)
server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.media_file_id)
}).transpose().map_err(|err| {
warn!("Failed to encode cursor: {:?}", err);
CommonWebError::server_error_with_message("Failed to encode cursor")
@@ -41,6 +41,11 @@ pub fn is_job_failure_test(prompt: &str) -> bool {
mod tests {
use super::*;
const EXPECTED_FAILURE_MESSAGE: &str =
"This was a simulated failure. This message comes from the backend and \
database to simulate a dynamic failure message. This message could in \
theory be anything.";
#[test]
fn no_trigger_returns_none() {
assert!(test_synthetic_failure_reason("a normal prompt").is_none());
@@ -50,14 +55,14 @@ mod tests {
fn trigger_simulate_with_no_category_defaults_to_generation_failed() {
let result = test_synthetic_failure_reason("simulate_artcraft_failure").unwrap();
assert_eq!(result.frontend_failure_category, FrontendFailureCategory::GenerationFailed);
assert_eq!(result.frontend_failure_message.as_deref(), Some("This was a simulated failure"));
assert_eq!(result.frontend_failure_message.as_deref(), Some(EXPECTED_FAILURE_MESSAGE));
}
#[test]
fn trigger_test_with_no_category_defaults_to_generation_failed() {
let result = test_synthetic_failure_reason("test_artcraft_failure").unwrap();
assert_eq!(result.frontend_failure_category, FrontendFailureCategory::GenerationFailed);
assert_eq!(result.frontend_failure_message.as_deref(), Some("This was a simulated failure"));
assert_eq!(result.frontend_failure_message.as_deref(), Some(EXPECTED_FAILURE_MESSAGE));
}
#[test]
@@ -0,0 +1,85 @@
//! Maps Kinovi (Seedance2Pro) client errors to web errors.
//!
//! Known bad requests (content violations, over-long prompts, too many urls)
//! become 400s with a user-facing message. Everything else is unanticipated
//! and stays a 500.
use seedance2pro_client::error::seedance2pro_bad_request_api_error::Seedance2ProBadRequestApiError;
use seedance2pro_client::error::seedance2pro_error::Seedance2ProError;
use crate::http_server::common_responses::common_web_error::CommonWebError;
/// Map a raw Kinovi client error to a web error.
pub fn map_seedance2pro_error_to_web_error(error: Seedance2ProError) -> CommonWebError {
match error {
Seedance2ProError::ApiBadRequest(bad_request) => {
CommonWebError::BadInputWithSimpleMessage(user_facing_bad_request_message(&bad_request))
}
other => CommonWebError::from_error(other),
}
}
fn user_facing_bad_request_message(error: &Seedance2ProBadRequestApiError) -> String {
match error {
Seedance2ProBadRequestApiError::VideoGenerationViolation { .. } => {
"The generation request was flagged as a content violation. \
Please adjust your prompt or input media and try again.".to_string()
}
Seedance2ProBadRequestApiError::PromptIsTooLong { .. } => {
"The prompt is too long. Please shorten it below 10,000 characters and try again.".to_string()
}
Seedance2ProBadRequestApiError::TooManyUrls { .. } => {
"Too many input files were attached. \
Please remove some and try again.".to_string()
}
}
}
#[cfg(test)]
mod tests {
use actix_web::ResponseError;
use seedance2pro_client::error::seedance2pro_specific_api_error::Seedance2ProSpecificApiError;
use super::*;
#[test]
fn bad_requests_become_400s_with_user_facing_messages() {
let cases = [
(
Seedance2ProBadRequestApiError::VideoGenerationViolation { raw_body: "{}".to_string() },
"content violation",
),
(
Seedance2ProBadRequestApiError::PromptIsTooLong { raw_body: "{}".to_string() },
"prompt is too long",
),
(
Seedance2ProBadRequestApiError::TooManyUrls { raw_body: "{}".to_string() },
"Too many input files",
),
];
for (bad_request, expected_message_fragment) in cases {
let error = map_seedance2pro_error_to_web_error(
Seedance2ProError::ApiBadRequest(bad_request));
assert_eq!(error.status_code().as_u16(), 400);
match error {
CommonWebError::BadInputWithSimpleMessage(message) => {
assert!(
message.contains(expected_message_fragment),
"message {:?} should contain {:?}", message, expected_message_fragment);
}
other => panic!("Expected BadInputWithSimpleMessage, got: {:?}", other),
}
}
}
#[test]
fn unanticipated_errors_stay_500s() {
let error = map_seedance2pro_error_to_web_error(
Seedance2ProError::ApiSpecific(Seedance2ProSpecificApiError::UnauthorizedSessionExpired));
assert_eq!(error.status_code().as_u16(), 500);
}
}
@@ -1,3 +1,4 @@
pub mod job_failure_test;
pub mod map_seedance2pro_web_errors;
pub mod payments_error_test;
mod test_synthetic_failure_reason;
@@ -1,9 +1,10 @@
use std::collections::HashMap;
use std::sync::Arc;
use actix_http::StatusCode;
use crate::billing::wallets::attempt_wallet_deduction::attempt_wallet_deduction_else_common_web_error;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoint_helpers::refund_wallet_after_api_failure::refund_wallet_after_api_failure;
use crate::http_server::endpoints::generate::common::map_seedance2pro_web_errors::map_seedance2pro_error_to_web_error;
use crate::http_server::endpoints::generate::common::payments_error_test::payments_error_test;
use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags;
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
@@ -12,7 +13,7 @@ use crate::state::server_state::ServerState;
use crate::util::http_download_url_to_bytes::http_download_url_to_bytes;
use crate::util::lookup::lookup_media_file_urls_as_map::lookup_media_file_urls_as_map;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use actix_web::{web, HttpRequest, ResponseError};
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{
Seedance2p0AspectRatio, Seedance2p0BatchCount, Seedance2p0MultiFunctionVideoGenRequest,
Seedance2p0MultiFunctionVideoGenResponse, Seedance2p0OutputResolution,
@@ -234,13 +235,19 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
}
Err(err) => {
warn!("Regular session fallback also failed for user {:?}: {:?}", user_token, err);
let notification = NotificationDetailsBuilder::from_boxed_error(err.clone().into())
.set_title("Seedance 2.0 generation failed (whitelist + fallback)".to_string())
.set_urgency(Some(NotificationUrgency::High))
.build();
if let Err(page_err) = server_state.pager.enqueue_page(notification) {
warn!("Failed to enqueue pager alert: {:?}", page_err);
// Client-fault errors (eg. content violations, over-long prompts)
// surface to the user as 400s and shouldn't page.
if err.status_code() != StatusCode::BAD_REQUEST {
let notification = NotificationDetailsBuilder::from_boxed_error(err.clone().into())
.set_title("Seedance 2.0 generation failed (whitelist + fallback)".to_string())
.set_urgency(Some(NotificationUrgency::High))
.build();
if let Err(page_err) = server_state.pager.enqueue_page(notification) {
warn!("Failed to enqueue pager alert: {:?}", page_err);
}
}
refund_wallet_after_api_failure(&deduction_result.ledger_entry_token, &mut mysql_connection).await?;
return Err(err);
}
@@ -269,13 +276,19 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
Ok(result) => result,
Err(err) => {
warn!("Error calling seedance2pro generate_video: {:?}", err);
let notification = NotificationDetailsBuilder::from_boxed_error(err.clone().into())
.set_title("Seedance 2.0 generation failed".to_string())
.set_urgency(Some(NotificationUrgency::High))
.build();
if let Err(page_err) = server_state.pager.enqueue_page(notification) {
warn!("Failed to enqueue pager alert: {:?}", page_err);
// Client-fault errors (eg. content violations, over-long prompts)
// surface to the user as 400s and shouldn't page.
if err.status_code() != StatusCode::BAD_REQUEST {
let notification = NotificationDetailsBuilder::from_boxed_error(err.clone().into())
.set_title("Seedance 2.0 generation failed".to_string())
.set_urgency(Some(NotificationUrgency::High))
.build();
if let Err(page_err) = server_state.pager.enqueue_page(notification) {
warn!("Failed to enqueue pager alert: {:?}", page_err);
}
}
refund_wallet_after_api_failure(&deduction_result.ledger_entry_token, &mut mysql_connection).await?;
return Err(err);
}
@@ -613,7 +626,7 @@ async fn upload_and_generate(
let gen_response = generate_video(video_gen_args).await
.map_err(|err| {
warn!("Error calling seedance2pro generate_video: {:?}", err);
CommonWebError::from_error(err)
map_seedance2pro_error_to_web_error(err)
})?;
Ok(SeedanceGenerationResult {
@@ -394,7 +394,7 @@ mod tests {
let result = payload.maybe_result.expect("should have result");
assert_eq!(
Url::parse("https://cdn.storyteller.ai/media/t/p/k/8/4/tpk848b5s5zwhnwrph75jhyfyja3j42v/fakeyou_tpk848b5s5zwhnwrph75jhyfyja3j42v.wav").unwrap(),
Url::parse("https://cdn-2.fakeyou.com/media/t/p/k/8/4/tpk848b5s5zwhnwrph75jhyfyja3j42v/fakeyou_tpk848b5s5zwhnwrph75jhyfyja3j42v.wav").unwrap(),
result.media_links.cdn_url
);
@@ -433,7 +433,7 @@ mod tests {
let result = payload.maybe_result.expect("should have result");
assert_eq!(
Url::parse("https://cdn.fakeyou.com/tts_inference_output/9/9/7/vocodes_997e710c-d733-406c-9b6e-5e1d0c471816.wav").unwrap(),
Url::parse("https://cdn-2.fakeyou.com/tts_inference_output/9/9/7/vocodes_997e710c-d733-406c-9b6e-5e1d0c471816.wav").unwrap(),
result.media_links.cdn_url
);
@@ -10,5 +10,6 @@ pub mod user_emails;
pub mod user_feature_flags;
pub mod user_referrals;
pub mod user_sessions;
pub mod user_stripe_data;
pub mod wallet_ledger_entries;
pub mod wallets;
@@ -0,0 +1 @@
pub mod moderator_get_user_stripe_customer_ids_handler;
@@ -0,0 +1,101 @@
use std::sync::Arc;
use actix_web::web::{Json, Path};
use actix_web::{web, HttpRequest};
use log::warn;
use artcraft_api_defs::moderation::user_stripe_data::moderator_get_user_stripe_customer_ids::{
ModeratorGetUserStripeCustomerIdsPathInfo,
ModeratorGetUserStripeCustomerIdsResponse,
ModeratorStripeCustomerIdSource,
ModeratorUserStripeCustomerIdEntry,
};
use mysql_queries::queries::users::user_stripe_customer_links::list_user_stripe_customer_links_for_user::list_user_stripe_customer_links_for_user;
use enums::common::payments_namespace::PaymentsNamespace;
use mysql_queries::queries::users::user_subscriptions::list_stripe_customer_ids_for_user_subscriptions::list_stripe_customer_ids_for_user_subscriptions;
use tokens::tokens::users::UserToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::web_utils::user_session::require_moderator::{require_moderator, UseDatabase};
use crate::state::server_state::ServerState;
/// Get all stripe customer ids on file for a user (moderation)
#[utoipa::path(
get,
tag = "Moderation",
path = "/v1/moderation/user_stripe_data/{user_token}/customer_ids",
responses(
(status = 200, description = "Success", body = ModeratorGetUserStripeCustomerIdsResponse),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Server error"),
),
params(
("user_token" = UserToken, Path, description = "User token to look up"),
)
)]
pub async fn moderator_get_user_stripe_customer_ids_handler(
http_request: HttpRequest,
path: Path<ModeratorGetUserStripeCustomerIdsPathInfo>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ModeratorGetUserStripeCustomerIdsResponse>, CommonWebError> {
let mut mysql_connection = server_state.mysql_pool
.acquire()
.await?;
let _moderator_session = require_moderator(
&http_request,
&server_state,
UseDatabase::FromPool(&mut mysql_connection),
)
.await
.map_err(|_| CommonWebError::NotAuthorized)?;
let customer_links = list_user_stripe_customer_links_for_user(&path.user_token, &mut mysql_connection)
.await
.map_err(|err| {
warn!("moderator_get_user_stripe_customer_ids customer link query error: {:?}", err);
CommonWebError::from_error(err)
})?;
let subscription_customer_ids = list_stripe_customer_ids_for_user_subscriptions(&path.user_token, &mut mysql_connection)
.await
.map_err(|err| {
warn!("moderator_get_user_stripe_customer_ids subscription query error: {:?}", err);
CommonWebError::from_error(err)
})?;
let mut customer_ids = Vec::with_capacity(customer_links.len() + subscription_customer_ids.len());
customer_ids.extend(customer_links.into_iter().map(|link| ModeratorUserStripeCustomerIdEntry {
stripe_dashboard_url: stripe_dashboard_customer_url(&server_state, link.payments_namespace, &link.stripe_customer_id),
stripe_customer_id: link.stripe_customer_id,
payments_namespace: link.payments_namespace,
source: ModeratorStripeCustomerIdSource::CustomerLink,
}));
customer_ids.extend(subscription_customer_ids.into_iter().map(|subscription| ModeratorUserStripeCustomerIdEntry {
stripe_dashboard_url: stripe_dashboard_customer_url(&server_state, subscription.subscription_namespace, &subscription.stripe_customer_id),
stripe_customer_id: subscription.stripe_customer_id,
payments_namespace: subscription.subscription_namespace,
source: ModeratorStripeCustomerIdSource::Subscription,
}));
Ok(Json(ModeratorGetUserStripeCustomerIdsResponse {
success: true,
customer_ids,
}))
}
/// Each payments namespace bills through a different Stripe account.
fn stripe_dashboard_customer_url(
server_state: &ServerState,
payments_namespace: PaymentsNamespace,
stripe_customer_id: &str,
) -> String {
let stripe_account_id = match payments_namespace {
PaymentsNamespace::Artcraft => &server_state.stripe_artcraft.stripe_account_id,
PaymentsNamespace::FakeYou => &server_state.stripe.stripe_account_id,
};
format!("https://dashboard.stripe.com/{}/customers/{}", stripe_account_id, stripe_customer_id)
}
@@ -18,6 +18,7 @@ use tokens::tokens::users::UserToken;
use crate::billing::wallets::attempt_wallet_deduction::attempt_wallet_deduction_else_common_web_error;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::omni_gen::generate::image::pipeline_result::ImagePipelineResult;
use crate::http_server::endpoints::omni_gen::shared_utils::map_seedance2pro_router_error::map_router_error_to_web_error;
use crate::state::server_state::ServerState;
use crate::util::lookup::lookup_media_files_as_cdn_url_list_and_map::MediaFilesAsCdnUrlListAndMap;
@@ -155,7 +156,7 @@ async fn finalize_and_generate(
.await
.map_err(|err| {
warn!("Failed to finalize image v2 draft: {:?}", err);
CommonWebError::from_error(err)
map_router_error_to_web_error(err)
})?
}
};
@@ -164,7 +165,7 @@ async fn finalize_and_generate(
.await
.map_err(|err| {
warn!("v2 image generation failed: {:?}", err);
CommonWebError::from_error(err)
map_router_error_to_web_error(err)
})
}
@@ -19,6 +19,7 @@ use crate::http_server::endpoints::omni_gen::generate::video::helpers::build_rou
use crate::http_server::endpoints::omni_gen::generate::video::helpers::pipeline_result::PipelineResult;
use crate::http_server::endpoints::omni_gen::generate::video::helpers::resolve_media_tokens_to_urls::resolve_media_tokens_to_urls;
use crate::http_server::endpoints::omni_gen::generate::video::kinovi_account::KinoviAccount;
use crate::http_server::endpoints::omni_gen::shared_utils::map_seedance2pro_router_error::map_router_error_to_web_error;
use crate::state::server_state::ServerState;
pub struct RunPipelineV2Args<'a> {
@@ -170,7 +171,7 @@ async fn upload_and_generate(
.await
.map_err(|err| {
warn!("Failed to finalize v2 draft: {:?}", err);
CommonWebError::from_error(err)
map_router_error_to_web_error(err)
})?
}
};
@@ -179,6 +180,6 @@ async fn upload_and_generate(
.await
.map_err(|err| {
warn!("v2 video generation failed: {:?}", err);
CommonWebError::from_error(err)
map_router_error_to_web_error(err)
})
}
@@ -0,0 +1,90 @@
//! Maps artcraft router errors to web errors for the omni_gen pipelines.
//!
//! Known Kinovi (Seedance2Pro) bad requests (content violations, over-long
//! prompts, too many urls) become 400s with a user-facing message. Everything
//! else is unanticipated and stays a 500.
use artcraft_router::errors::artcraft_router_error::ArtcraftRouterError;
use artcraft_router::errors::provider_error::ProviderError;
use seedance2pro_client::error::seedance2pro_bad_request_api_error::Seedance2ProBadRequestApiError;
use seedance2pro_client::error::seedance2pro_error::Seedance2ProError;
use crate::http_server::common_responses::common_web_error::CommonWebError;
/// Map an artcraft router error to a web error, unwrapping Kinovi bad requests.
pub fn map_router_error_to_web_error(error: ArtcraftRouterError) -> CommonWebError {
match error {
ArtcraftRouterError::Provider(
ProviderError::Seedance2Pro(Seedance2ProError::ApiBadRequest(bad_request))
) => {
CommonWebError::BadInputWithSimpleMessage(user_facing_bad_request_message(&bad_request))
}
other => CommonWebError::from_error(other),
}
}
fn user_facing_bad_request_message(error: &Seedance2ProBadRequestApiError) -> String {
match error {
Seedance2ProBadRequestApiError::VideoGenerationViolation { .. } => {
"The generation request was flagged as a content violation. \
Please adjust your prompt or input media and try again.".to_string()
}
Seedance2ProBadRequestApiError::PromptIsTooLong { .. } => {
"The prompt is too long. Please shorten it below 10,000 characters and try again.".to_string()
}
Seedance2ProBadRequestApiError::TooManyUrls { .. } => {
"Too many input files were attached. \
Please remove some and try again.".to_string()
}
}
}
#[cfg(test)]
mod tests {
use actix_web::ResponseError;
use super::*;
#[test]
fn kinovi_bad_requests_become_400s_with_user_facing_messages() {
let cases = [
(
Seedance2ProBadRequestApiError::VideoGenerationViolation { raw_body: "{}".to_string() },
"content violation",
),
(
Seedance2ProBadRequestApiError::PromptIsTooLong { raw_body: "{}".to_string() },
"prompt is too long",
),
(
Seedance2ProBadRequestApiError::TooManyUrls { raw_body: "{}".to_string() },
"Too many input files",
),
];
for (bad_request, expected_message_fragment) in cases {
let error = map_router_error_to_web_error(
ArtcraftRouterError::Provider(
ProviderError::Seedance2Pro(
Seedance2ProError::ApiBadRequest(bad_request))));
assert_eq!(error.status_code().as_u16(), 400);
match error {
CommonWebError::BadInputWithSimpleMessage(message) => {
assert!(
message.contains(expected_message_fragment),
"message {:?} should contain {:?}", message, expected_message_fragment);
}
other => panic!("Expected BadInputWithSimpleMessage, got: {:?}", other),
}
}
}
#[test]
fn other_router_errors_stay_500s() {
let error = map_router_error_to_web_error(
ArtcraftRouterError::UnsupportedModel("some_model".to_string()));
assert_eq!(error.status_code().as_u16(), 500);
}
}

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