Compare commits

..
Author SHA1 Message Date
Brandon Thomas 67dd65c636 beeble endpoint 2026-06-09 20:12:17 -04:00
Brandon Thomas cc4203ff3e const def 2026-06-09 20:08:16 -04:00
Brandon Thomas 6c053aba7c add a few more clients for testing 2026-06-09 20:04:53 -04:00
Brandon Thomas 671fe5892b add generation platform type (web, desktop, etc.) 2026-06-09 19:10:10 -04:00
135 changed files with 779 additions and 9736 deletions
@@ -1,36 +0,0 @@
{
"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 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 ",
"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 ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "media_file_id: u64",
"name": "membership_id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
"flags": "NOT_NULL | UNIQUE_KEY | UNSIGNED | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
@@ -263,5 +263,5 @@
false
]
},
"hash": "8aca9bc976b980ea1a3b3052bc8a45dd4d8db55c20fd1aede2a4415cc1395cf3"
"hash": "88673127b54626dc38d28693e709dfaeced9a05300a26b3f3c52a17f28171c9a"
}
@@ -1,14 +1,14 @@
{
"db_name": "MySQL",
"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 ",
"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 ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "media_file_id: u64",
"name": "membership_id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
"flags": "NOT_NULL | UNIQUE_KEY | UNSIGNED | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
@@ -263,5 +263,5 @@
false
]
},
"hash": "c0ba9f9aa3184b1cb3d714173b31e530e201a6c24afd29ad534cf7f3954c7316"
"hash": "d2a406bcf08f656fd602aa6ba1124a77d04ad874b15abafca300e8b381c7b211"
}
@@ -1,47 +0,0 @@
{
"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,20 +2377,6 @@ 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,7 +10,6 @@ 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,31 +64,6 @@ pub struct FolderThumbnail {
/// Resizing template URL (with `{{width}}` / `{{height}}` placeholders)
/// when the media class supports it. `None` for media classes without
/// a thumbnail variant. (Video media files instead have video previews,
/// which, in turn, have their own thumbnail templates.)
/// a thumbnail variant.
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,6 +2,5 @@ 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;
@@ -1 +0,0 @@
pub mod moderator_get_user_stripe_customer_ids;
@@ -1,43 +0,0 @@
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,
}
@@ -1,30 +0,0 @@
[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"] }
@@ -1,17 +0,0 @@
/// 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
}
}
@@ -1 +0,0 @@
pub mod comet_api_key;
@@ -1,157 +0,0 @@
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),
}
}
}
}
@@ -1,22 +0,0 @@
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),
}
}
}
@@ -1,26 +0,0 @@
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)
}
}
}
}
@@ -1,71 +0,0 @@
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)
}
}
@@ -1,33 +0,0 @@
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),
}
}
}
@@ -1,23 +0,0 @@
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),
}
}
}
@@ -1,6 +0,0 @@
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;
@@ -1 +0,0 @@
pub mod video;
@@ -1,146 +0,0 @@
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,
}
}
}
@@ -1,146 +0,0 @@
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,
}
}
}
@@ -1,471 +0,0 @@
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);
}
}
}
@@ -1,278 +0,0 @@
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![],
}
}
}
@@ -1,162 +0,0 @@
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![],
}
}
}
@@ -1,162 +0,0 @@
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![],
}
}
}
@@ -1,216 +0,0 @@
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![],
}
}
}
@@ -1,186 +0,0 @@
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![],
}
}
}
@@ -1,13 +0,0 @@
//! 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;
@@ -1,21 +0,0 @@
//! 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;
@@ -1,2 +0,0 @@
/// Base URL for the CometAPI HTTP API.
pub const COMET_API_BASE_URL: &str = "https://api.cometapi.com";
@@ -1,439 +0,0 @@
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);
}
}
}
@@ -1,2 +0,0 @@
pub mod create_video;
pub mod request_types;
@@ -1,77 +0,0 @@
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);
}
}
@@ -1,184 +0,0 @@
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!();
}
}
}
@@ -1,2 +0,0 @@
pub mod get_video_task;
pub mod request_types;
@@ -1,113 +0,0 @@
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());
}
}
@@ -1,5 +0,0 @@
pub mod comet_host;
pub mod create_video;
pub mod get_video_content;
pub mod get_video_task;
pub mod video_task_status;
@@ -1,132 +0,0 @@
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());
}
}
@@ -1,11 +0,0 @@
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,8 +87,7 @@ mod tests {
use crate::requests::image_websocket::messages::websocket_server_message::WebsocketServerMessage;
fn json_path(file_name: &str) -> String {
// NB: Cargo runs tests with the crate root as the working directory.
format!("test_data/websocket_messages/{}", file_name)
format!("/Users/bt/dev/storyteller/storyteller-rust/crates/api_clients/grok_consumer_client/test_data/websocket_messages/{}", file_name)
}
#[test]
File diff suppressed because one or more lines are too long
@@ -1,25 +0,0 @@
{
"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"
}
@@ -1,25 +0,0 @@
{
"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
}
@@ -1,21 +0,0 @@
{
"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
}
}
]
}
}
@@ -1,4 +0,0 @@
{
"type": "other_type",
"foo": "bar"
}
@@ -1 +0,0 @@
{"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}
@@ -1,25 +0,0 @@
{
"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,15 +89,7 @@ mod tests {
let cookies = format!("cookie={}", "a".repeat(10000));
assert_eq!(has_session_cookie(&cookies).unwrap(), SessionCookiePresence::MaybePresent);
// 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";
let cookies = "";
assert_eq!(has_session_cookie(cookies).unwrap(), SessionCookiePresence::Present);
}
@@ -1,4 +1,3 @@
pub mod seedance2pro_bad_request_api_error;
pub mod seedance2pro_client_error;
pub mod seedance2pro_error;
pub mod seedance2pro_generic_api_error;
@@ -1,29 +0,0 @@
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,4 +1,3 @@
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;
@@ -10,7 +9,6 @@ pub enum Seedance2ProError {
Client(Seedance2ProClientError),
ApiSpecific(Seedance2ProSpecificApiError),
ApiGeneric(Seedance2ProGenericApiError),
ApiBadRequest(Seedance2ProBadRequestApiError),
}
impl Seedance2ProError {
@@ -40,7 +38,6 @@ 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),
}
}
}
@@ -62,9 +59,3 @@ impl From<Seedance2ProGenericApiError> for Seedance2ProError {
Self::ApiGeneric(error)
}
}
impl From<Seedance2ProBadRequestApiError> for Seedance2ProError {
fn from(error: Seedance2ProBadRequestApiError) -> Self {
Self::ApiBadRequest(error)
}
}
@@ -16,12 +16,8 @@ pub enum Seedance2ProGenericApiError {
/// Includes the original body.
SerdeParseErrorWithBodyOnNon200(serde_json::Error, 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.
UncategorizedBadResponse(String),
/// An uncategorized bad HTTP response with status code and body.
UncategorizedBadResponseWithStatusAndBody {
@@ -41,7 +37,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::UnexpectedResponseShape { explanation, raw_body } => write!(f, "Unexpected response shape: {}. Body: {}", explanation, raw_body),
Self::UncategorizedBadResponse(msg) => write!(f, "Uncategorized bad response: {}", msg),
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,6 +8,9 @@ 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,
@@ -21,6 +24,7 @@ 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,10 +119,9 @@ pub async fn generate_character(args: GenerateCharacterArgs<'_>) -> Result<Gener
let data = batch_response
.into_iter()
.next()
.ok_or_else(|| Seedance2ProGenericApiError::UnexpectedResponseShape {
explanation: "Empty batch response array".to_string(),
raw_body: response_body.clone(),
})?
.ok_or_else(|| Seedance2ProGenericApiError::UncategorizedBadResponse(
"Empty batch response array".to_string()
))?
.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,16 +285,15 @@ pub async fn generate_image(args: GenerateImageArgs<'_>) -> Result<GenerateImage
let task_data = batch_response
.into_iter()
.next()
.ok_or_else(|| Seedance2ProGenericApiError::UnexpectedResponseShape {
explanation: "Empty batch response array".to_string(),
raw_body: response_body.clone(),
})?
.ok_or_else(|| Seedance2ProGenericApiError::UncategorizedBadResponse(
"Empty batch response array".to_string()
))?
.result
.data
.json;
if task_data.violation_warning {
return Err(Seedance2ProBadRequestApiError::VideoGenerationViolation { raw_body: response_body }.into());
return Err(Seedance2ProSpecificApiError::VideoGenerationViolation(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,16 +403,15 @@ pub async fn generate_video(args: GenerateVideoArgs<'_>) -> Result<GenerateVideo
let task_data = batch_response
.into_iter()
.next()
.ok_or_else(|| Seedance2ProGenericApiError::UnexpectedResponseShape {
explanation: "Empty batch response array".to_string(),
raw_body: response_body.clone(),
})?
.ok_or_else(|| Seedance2ProGenericApiError::UncategorizedBadResponse(
"Empty batch response array".to_string()
))?
.result
.data
.json;
if task_data.violation_warning {
return Err(Seedance2ProBadRequestApiError::VideoGenerationViolation { raw_body: response_body }.into());
return Err(Seedance2ProSpecificApiError::VideoGenerationViolation(response_body).into());
}
Ok(GenerateVideoResponse {
@@ -94,10 +94,9 @@ pub async fn get_user_auth_details(
let json = batch_response
.into_iter()
.next()
.ok_or_else(|| Seedance2ProGenericApiError::UnexpectedResponseShape {
explanation: "Empty batch response array".to_string(),
raw_body: response_body.clone(),
})?
.ok_or_else(|| Seedance2ProGenericApiError::UncategorizedBadResponse(
"Empty batch response array".to_string()
))?
.result
.data
.json;
@@ -181,10 +181,9 @@ pub async fn poll_characters(args: PollCharactersArgs<'_>) -> Result<PollCharact
let data = batch_response
.into_iter()
.next()
.ok_or_else(|| Seedance2ProGenericApiError::UnexpectedResponseShape {
explanation: "Empty batch response array".to_string(),
raw_body: response_body.clone(),
})?
.ok_or_else(|| Seedance2ProGenericApiError::UncategorizedBadResponse(
"Empty batch response array".to_string()
))?
.result
.data
.json;
@@ -201,10 +201,9 @@ pub async fn poll_orders(args: PollOrdersArgs<'_>) -> Result<PollOrdersResponse,
let json = batch_response
.into_iter()
.next()
.ok_or_else(|| Seedance2ProGenericApiError::UnexpectedResponseShape {
explanation: "Empty batch response array".to_string(),
raw_body: response_body.clone(),
})?
.ok_or_else(|| Seedance2ProGenericApiError::UncategorizedBadResponse(
"Empty batch response array".to_string()
))?
.result
.data
.json;
@@ -106,10 +106,9 @@ pub async fn prepare_file_upload(args: PrepareFileUploadArgs<'_>) -> Result<Prep
let upload_url = batch_response
.into_iter()
.next()
.ok_or_else(|| Seedance2ProGenericApiError::UnexpectedResponseShape {
explanation: "Empty batch response array".to_string(),
raw_body: response_body.clone(),
})?
.ok_or_else(|| Seedance2ProGenericApiError::UncategorizedBadResponse(
"Empty batch response array".to_string()
))?
.result
.data
.json;
@@ -102,10 +102,9 @@ pub async fn update_character(args: UpdateCharacterArgs<'_>) -> Result<UpdateCha
let data = batch_response
.into_iter()
.next()
.ok_or_else(|| Seedance2ProGenericApiError::UnexpectedResponseShape {
explanation: "Empty batch response array".to_string(),
raw_body: response_body.clone(),
})?
.ok_or_else(|| Seedance2ProGenericApiError::UncategorizedBadResponse(
"Empty batch response array".to_string()
))?
.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,16 +415,15 @@ pub async fn workflow_run_task(args: WorkflowRunTaskArgs<'_>) -> Result<Workflow
let task_data = batch_response
.into_iter()
.next()
.ok_or_else(|| Seedance2ProGenericApiError::UnexpectedResponseShape {
explanation: "Empty batch response array".to_string(),
raw_body: response_body.clone(),
})?
.ok_or_else(|| Seedance2ProGenericApiError::UncategorizedBadResponse(
"Empty batch response array".to_string()
))?
.result
.data
.json;
if task_data.violation_warning {
return Err(Seedance2ProBadRequestApiError::VideoGenerationViolation { raw_body: response_body }.into());
return Err(Seedance2ProSpecificApiError::VideoGenerationViolation(response_body).into());
}
Ok(WorkflowRunTaskResponse {
@@ -1,6 +1,5 @@
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;
@@ -10,25 +9,11 @@ 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, request
/// validation failures) and returns a specific error variant when possible,
/// falling back to `UncategorizedBadResponseWithStatusAndBody` otherwise.
/// Checks the response body for known patterns (e.g. billing errors) and returns
/// a specific error variant when possible, falling back to
/// `UncategorizedBadResponseWithStatusAndBody` otherwise.
pub fn categorize_seedance2pro_error(
status_code: StatusCode,
body: String,
@@ -40,15 +25,6 @@ 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,
@@ -61,164 +37,75 @@ 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;
mod billing_tests {
use super::*;
#[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");
#[test]
fn real_not_enough_credits_response() {
let body = read_response_body("not_enough_credits.json");
let error = categorize_seedance2pro_error(StatusCode::BAD_REQUEST, body);
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),
}
}
#[test]
fn billing_error_case_insensitive() {
let body = r#"{"error":"Credits Not Enough"}"#.to_string();
let error = categorize_seedance2pro_error(StatusCode::BAD_REQUEST, body);
match error {
Seedance2ProError::ApiSpecific(
Seedance2ProSpecificApiError::BillingError { .. }
) => {}
other => panic!("Expected BillingError, got: {:?}", other),
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),
}
}
mod bad_request_tests {
use super::*;
#[test]
fn billing_error_case_insensitive() {
let body = r#"{"error":"Credits Not Enough"}"#.to_string();
#[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);
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),
}
match error {
Seedance2ProError::ApiSpecific(
Seedance2ProSpecificApiError::BillingError { .. }
) => {}
other => panic!("Expected BillingError, 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();
#[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(),
);
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),
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),
}
}
fn read_response_body(filename: &str) -> String {
std::fs::read_to_string(format!("test_data/responses/{}", filename))
.expect("Failed to read test data file")
#[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),
}
}
}
@@ -1,40 +0,0 @@
[
{
"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
}
}
}
]
@@ -1,40 +0,0 @@
[
{
"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, BASE_URL};
use crate::api::requests::objects::begin_object_image_upload::get_url;
#[test]
fn test_get_url() {
let upload_id = UploadObjectId("foo-bar-baz-bin".to_string());
let expected = format!("{}/foo-bar-baz-bin:upload", BASE_URL);
let expected = "https://marble2-kgw-prod-iac1.wlt-ai.art/api/v1/objects/foo-bar-baz-bin:upload";
assert_eq!(get_url(&upload_id), expected);
}
}
@@ -147,9 +147,6 @@ mod tests {
#[test]
fn test_request() {
let request = RawRequest::for_mime_type(UploadMimeType::ImageJpeg);
// 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");
assert_eq!(request.display_name, "uploaded_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, BASE_URL};
use crate::api::requests::objects::finalize_object_image_upload::{finalize_object_image_upload, get_url, FinalizeObjectImageUploadArgs};
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 = format!("{}/foo-bar-baz-bin:complete", BASE_URL);
let expected = "https://marble2-kgw-prod-iac1.wlt-ai.art/api/v1/objects/foo-bar-baz-bin:complete";
assert_eq!(get_url(&upload_id), expected);
}
@@ -405,10 +405,6 @@ 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() {
@@ -436,88 +432,9 @@ mod tests {
let request = RawRequest::from_args(&args);
// 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);
let json = serde_json::to_string_pretty(&request).unwrap();
println!("{}", json);
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);
}
},
_ => {},
}
assert_eq!(json, r#"{"type":"abcxyz_image_input_id","value":"image_input_id"}"#);
}
}
@@ -411,10 +411,6 @@ 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() {
@@ -450,91 +446,9 @@ mod tests {
let request = RawRequest::from_args(&args);
// 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);
let json = serde_json::to_string_pretty(&request).unwrap();
println!("{}", json);
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);
}
},
_ => {},
}
assert_eq!(json, r#"{"type":"abcxyz_image_input_id","value":"image_input_id"}"#);
}
}
@@ -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, BASE_URL};
use crate::api::requests::worlds::poll_world_status::{get_url, poll_world_status, PollWorldStatusArgs};
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 = format!("{}/d60cfa21-5506-43de-9a9c-0707fc17a5ec", BASE_URL);
let expected = "https://marble2-kgw-prod-iac1.wlt-ai.art/api/v1/worlds/d60cfa21-5506-43de-9a9c-0707fc17a5ec";
assert_eq!(get_url(&world_id), expected);
}
@@ -407,7 +407,6 @@ 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
@@ -430,7 +429,6 @@ 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
@@ -444,7 +442,6 @@ 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,10 +16,8 @@ use tokens::tokens::prompts::PromptToken;
/// and the rest of the typed response shape.
#[derive(Debug, Clone)]
pub struct FolderMediaFileRow {
/// `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,
/// `folder_media_files.id` — used as the pagination cursor.
pub membership_id: u64,
/// When the media file was added to the folder.
pub added_to_folder_at: DateTime<Utc>,
@@ -73,8 +71,7 @@ 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. Newest media files first
/// (`media_files.id` descending — NOT folder-membership order).
/// soft-deleted on the media-file side. Most-recently-added first.
///
/// The caller is expected to have already authorized the folder access.
pub async fn list_folder_media_files<'e, 'c: 'e, E>(
@@ -90,7 +87,7 @@ where
sqlx::query!(
r#"
SELECT
mf.id as `media_file_id: u64`,
fmf.id as `membership_id: u64`,
fmf.created_at as `added_to_folder_at: DateTime<Utc>`,
mf.token as `media_file_token: MediaFileToken`,
@@ -129,8 +126,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 mf.id < ?
ORDER BY mf.id DESC
AND fmf.id < ?
ORDER BY fmf.id DESC
LIMIT ?
"#,
args.folder_token.as_str(),
@@ -141,7 +138,7 @@ LIMIT ?
.await?
.into_iter()
.map(|r| FolderMediaFileRow {
media_file_id: r.media_file_id,
membership_id: r.membership_id,
added_to_folder_at: r.added_to_folder_at,
media_file_token: r.media_file_token,
media_class: r.media_class,
@@ -171,7 +168,7 @@ LIMIT ?
sqlx::query!(
r#"
SELECT
mf.id as `media_file_id: u64`,
fmf.id as `membership_id: u64`,
fmf.created_at as `added_to_folder_at: DateTime<Utc>`,
mf.token as `media_file_token: MediaFileToken`,
@@ -210,7 +207,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 mf.id DESC
ORDER BY fmf.id DESC
LIMIT ?
"#,
args.folder_token.as_str(),
@@ -220,7 +217,7 @@ LIMIT ?
.await?
.into_iter()
.map(|r| FolderMediaFileRow {
media_file_id: r.media_file_id,
membership_id: r.membership_id,
added_to_folder_at: r.added_to_folder_at,
media_file_token: r.media_file_token,
media_class: r.media_class,
@@ -1,50 +0,0 @@
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,3 +1,2 @@
pub mod find_user_stripe_customer_link;
pub mod list_user_stripe_customer_links_for_user;
pub mod upsert_user_stripe_customer_link;
pub mod upsert_user_stripe_customer_link;
@@ -1,40 +0,0 @@
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,8 +1,7 @@
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,15 +222,14 @@ 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,9 +117,7 @@ mod tests {
fn test_serialization() {
assert_serialization(PromptContextSemanticType::VidStartFrame, "vid_start_frame");
assert_serialization(PromptContextSemanticType::VidEndFrame, "vid_end_frame");
// 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::VidRef, "vidref");
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","currency":"usd","default_tax_rates":[],
"current_period_start":1662979188,"customer":"cus_MQ03py0gWUh0Ox","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","currency":"usd","default_tax_rates":[],
"default_payment_method":"pm_1Lh1wtEU5se17MekpirKtMJm","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","currency":"usd","default_tax_rates":[],
"default_payment_method":"pm_1LhA3LEU5se17MekViNvoYAx","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,11 +127,8 @@ 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","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":{},
"current_period_start":1662979188,"customer":"cus_MQ03py0gWUh0Ox","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":{},
"product":"prod_MMxi2J5y69VPbO","recurring":{"interval":"month","interval_count":1,
@@ -175,11 +172,8 @@ 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","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":{},
"default_payment_method":"pm_1Lh1wtEU5se17MekpirKtMJm","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":{},
"product":"prod_MMxi2J5y69VPbO","recurring":{"interval":"month","interval_count":1,
@@ -222,12 +216,9 @@ 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","currency":"usd","default_tax_rates":[],"discounts":[],
"invoice_settings":{"issuer":{"type":"self"}},
"default_payment_method":"pm_1LhA3LEU5se17MekViNvoYAx","default_tax_rates":[],
"ended_at":1662979740,"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,
"deleted":false,"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"},
@@ -292,14 +283,7 @@ mod tests {
],
"description": null,
"discount": null,
"discounts": [
],
"ended_at": null,
"invoice_settings": {
"issuer": {
"type": "self"
}
},
"items": {
"object": "list",
"data": [
@@ -308,9 +292,6 @@ mod tests {
"object": "subscription_item",
"billing_thresholds": null,
"created": 1663430298,
"current_period_end": 1666022298,
"current_period_start": 1663430298,
"discounts": [],
"metadata": {
},
"plan": {
@@ -2,8 +2,6 @@ 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,
@@ -13,8 +11,6 @@ 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,
@@ -26,7 +22,6 @@ 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,7 +117,6 @@ 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::*;
@@ -559,7 +558,6 @@ 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,
@@ -818,10 +816,6 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
ModeratorGetWalletLedgerEntryPathInfo,
ModeratorGetWalletLedgerEntryResponse,
ModeratorGetWalletLedgerEntryDetails,
ModeratorGetUserStripeCustomerIdsPathInfo,
ModeratorGetUserStripeCustomerIdsResponse,
ModeratorUserStripeCustomerIdEntry,
ModeratorStripeCustomerIdSource,
GptImage1EditImageRequest,
GptImage1EditImageImageSize,
GptImage1EditImageNumImages,
@@ -1253,7 +1247,6 @@ 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://cdn-2.fakeyou.com/media/b/u/c/k/e/bucket_hash/image_bucket_hash.png").unwrap()));
Some(Url::parse("https://storage.googleapis.com/vocodes-public/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-2.fakeyou.com/media/b/u/c/k/e/bucket_hash/image_bucket_hash.png").unwrap());
"https://cdn.storyteller.ai/media/b/u/c/k/e/bucket_hash/image_bucket_hash.png").unwrap());
assert_eq!(links.thumbnail_template,
"https://cdn-2.fakeyou.com/cdn-cgi/image/width={WIDTH},quality=95/media/b/u/c/k/e/bucket_hash/image_bucket_hash.png");
"https://cdn.storyteller.ai/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, FolderThumbnailVideoPreviews};
use artcraft_api_defs::folders::common::{FolderInfo, FolderThumbnail};
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,
// and video previews, then pull those fields onto the compact
// Reuse `MediaLinksBuilder` to produce the CDN URL + thumbnail
// template, then pull just those two fields onto the compact
// FolderThumbnail wire shape.
let media_links = MediaLinksBuilder::from_media_path_and_env(
media_domain,
@@ -131,81 +131,11 @@ 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.media_file_id)
server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.membership_id)
}).transpose().map_err(|err| {
warn!("Failed to encode cursor: {:?}", err);
CommonWebError::server_error_with_message("Failed to encode cursor")
@@ -41,11 +41,6 @@ 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());
@@ -55,14 +50,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(EXPECTED_FAILURE_MESSAGE));
assert_eq!(result.frontend_failure_message.as_deref(), Some("This was a simulated failure"));
}
#[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(EXPECTED_FAILURE_MESSAGE));
assert_eq!(result.frontend_failure_message.as_deref(), Some("This was a simulated failure"));
}
#[test]
@@ -1,85 +0,0 @@
//! 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,4 +1,3 @@
pub mod job_failure_test;
pub mod map_seedance2pro_web_errors;
pub mod payments_error_test;
mod test_synthetic_failure_reason;
@@ -1,10 +1,9 @@
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;
@@ -13,7 +12,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, ResponseError};
use actix_web::{web, HttpRequest};
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{
Seedance2p0AspectRatio, Seedance2p0BatchCount, Seedance2p0MultiFunctionVideoGenRequest,
Seedance2p0MultiFunctionVideoGenResponse, Seedance2p0OutputResolution,
@@ -235,19 +234,13 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
}
Err(err) => {
warn!("Regular session fallback also failed for user {:?}: {:?}", user_token, 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);
}
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);
}
@@ -276,19 +269,13 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
Ok(result) => result,
Err(err) => {
warn!("Error calling seedance2pro generate_video: {:?}", 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);
}
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);
}
@@ -626,7 +613,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);
map_seedance2pro_error_to_web_error(err)
CommonWebError::from_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-2.fakeyou.com/media/t/p/k/8/4/tpk848b5s5zwhnwrph75jhyfyja3j42v/fakeyou_tpk848b5s5zwhnwrph75jhyfyja3j42v.wav").unwrap(),
Url::parse("https://cdn.storyteller.ai/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-2.fakeyou.com/tts_inference_output/9/9/7/vocodes_997e710c-d733-406c-9b6e-5e1d0c471816.wav").unwrap(),
Url::parse("https://cdn.fakeyou.com/tts_inference_output/9/9/7/vocodes_997e710c-d733-406c-9b6e-5e1d0c471816.wav").unwrap(),
result.media_links.cdn_url
);
@@ -10,6 +10,5 @@ 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;
@@ -1 +0,0 @@
pub mod moderator_get_user_stripe_customer_ids_handler;
@@ -1,101 +0,0 @@
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,7 +18,6 @@ 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;
@@ -156,7 +155,7 @@ async fn finalize_and_generate(
.await
.map_err(|err| {
warn!("Failed to finalize image v2 draft: {:?}", err);
map_router_error_to_web_error(err)
CommonWebError::from_error(err)
})?
}
};
@@ -165,7 +164,7 @@ async fn finalize_and_generate(
.await
.map_err(|err| {
warn!("v2 image generation failed: {:?}", err);
map_router_error_to_web_error(err)
CommonWebError::from_error(err)
})
}
@@ -19,7 +19,6 @@ 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> {
@@ -171,7 +170,7 @@ async fn upload_and_generate(
.await
.map_err(|err| {
warn!("Failed to finalize v2 draft: {:?}", err);
map_router_error_to_web_error(err)
CommonWebError::from_error(err)
})?
}
};
@@ -180,6 +179,6 @@ async fn upload_and_generate(
.await
.map_err(|err| {
warn!("v2 video generation failed: {:?}", err);
map_router_error_to_web_error(err)
CommonWebError::from_error(err)
})
}
@@ -1,90 +0,0 @@
//! 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