Compare commits

..
Author SHA1 Message Date
Brandon Thomas 37c446185e remove old tags code 2026-07-10 02:30:47 -04:00
568 changed files with 5282 additions and 38107 deletions
-5
View File
@@ -1,8 +1,3 @@
# Shell scripts and dotenv config files are executed/parsed on Linux (WSL2, CI)
# even when the repo is cloned on Windows — they must stay LF in the worktree.
*.sh text eol=lf
*.env text eol=lf
# Ignore certain code for Github language statistics
# https://github.com/github/linguist/blob/master/docs/overrides.md
old_code/trumpet-trumped.com/dictionary/cmudict-0.7b linguist-vendored
-3
View File
@@ -104,6 +104,3 @@ windows/sqlite/sqlite3.lib
crates/desktop/src-tauri/*.dll
windows_dlls/
# Portable local dev stack (script/bootstrap) - MySQL/Redis binaries, data, logs
.devstack/
@@ -1,25 +0,0 @@
{
"db_name": "MySQL",
"query": "\nSELECT DISTINCT tag_token as `tag_token: TagToken`\nFROM media_file_tags\nWHERE media_file_token = ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "tag_token: TagToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "05033f7f4588cc2d68d9db22236f2be323731ee120217192fa29fe60af776e19"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\n INSERT INTO media_files\n SET\n token = ?,\n\n media_class = ?,\n media_type = ?,\n\n is_user_upload = ?,\n is_intermediate_system_file = ?,\n\n origin_category = ?, \n origin_product_category = ?, \n maybe_origin_model_type = ?, \n maybe_origin_model_token = ?, \n maybe_origin_filename = ?,\n\n maybe_batch_token = ?,\n\n maybe_mime_type = ?,\n file_size_bytes = ?,\n maybe_duration_millis = ?,\n maybe_audio_encoding = ?, \n maybe_video_encoding = ?, \n maybe_frame_width = ?, \n maybe_frame_height = ?,\n maybe_prompt_token = ?,\n checksum_sha2 = ?,\n \n maybe_engine_category = ?,\n\n maybe_title = ?,\n maybe_text_transcript = ?,\n\n maybe_scene_source_media_file_token = ?,\n\n public_bucket_directory_hash = ?, \n maybe_public_bucket_prefix = ?, \n maybe_public_bucket_extension = ?, \n\n maybe_creator_user_token = ?, \n maybe_creator_anonymous_visitor_token = ?, \n\n creator_ip_address = ?, \n creator_set_visibility = ?, \n\n maybe_creator_file_synthetic_id = ?, \n maybe_creator_category_synthetic_id = ?,\n\n extra_file_modification_info = ?,\n\n maybe_generation_provider = ?,\n\n platform_type = ?,\n\n maybe_cover_image_media_file_token = ?,\n\n maybe_mod_user_token = ?,\n is_generated_on_prem = ?,\n generated_by_worker = ?,\n generated_by_cluster = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 41
},
"nullable": []
},
"hash": "1c74e9c2dcc9205e2c9fe60a6de0828208e497e68560246e588092bafd03eb59"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "DELETE FROM media_file_tags WHERE tag_token = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "3495f03338e7ee4c2ae9787204d521aefa787312463d392e45d02a693d9afb83"
}
@@ -1,245 +0,0 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n mf.id as `media_file_id: u64`,\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\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 media_files mf\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE mf.maybe_creator_user_token = ?\n AND mf.media_class = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\n AND NOT mf.is_intermediate_system_file\n AND NOT EXISTS (\n SELECT 1\n FROM folder_media_files fmf\n JOIN folders f\n ON f.token = fmf.folder_token\n AND f.maybe_deleted_at IS NULL\n WHERE fmf.media_file_token = mf.token\n )\n AND mf.id < ?\nORDER BY mf.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "media_file_id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "media_file_token: MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "media_class: MediaFileClass",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 3,
"name": "media_type: MediaFileType",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 4,
"name": "maybe_batch_token: BatchGenerationToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "public_bucket_directory_hash",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 6,
"name": "maybe_public_bucket_prefix",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 7,
"name": "maybe_public_bucket_extension",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 8,
"name": "maybe_cover_public_bucket_directory_hash?",
"type_info": {
"type": "VarString",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 9,
"name": "maybe_cover_public_bucket_prefix?",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 10,
"name": "maybe_cover_public_bucket_extension?",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 11,
"name": "creator_set_visibility: Visibility",
"type_info": {
"type": "String",
"flags": "NOT_NULL | BINARY | ENUM",
"char_set": 224,
"max_size": 28
}
},
{
"ordinal": 12,
"name": "is_user_upload: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL | MULTIPLE_KEY",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 13,
"name": "maybe_title",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 14,
"name": "maybe_prompt_token: PromptToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 15,
"name": "maybe_origin_filename",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 16,
"name": "maybe_duration_millis",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 17,
"name": "maybe_frame_width",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 5
}
},
{
"ordinal": 18,
"name": "maybe_frame_height",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 5
}
},
{
"ordinal": 19,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 20,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 4
},
"nullable": [
false,
false,
false,
false,
true,
false,
true,
true,
true,
true,
true,
false,
false,
true,
true,
true,
true,
true,
true,
false,
false
]
},
"hash": "3681e931cc87714d71c95da44d6dd92568ce58aea252164ab3d5f8e96491169c"
}
@@ -1,245 +0,0 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n mf.id as `media_file_id: u64`,\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\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 media_files mf\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE mf.maybe_creator_user_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\n AND NOT mf.is_intermediate_system_file\n AND NOT EXISTS (\n SELECT 1\n FROM folder_media_files fmf\n JOIN folders f\n ON f.token = fmf.folder_token\n AND f.maybe_deleted_at IS NULL\n WHERE fmf.media_file_token = mf.token\n )\n AND mf.id < ?\nORDER BY mf.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "media_file_id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "media_file_token: MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "media_class: MediaFileClass",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 3,
"name": "media_type: MediaFileType",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 4,
"name": "maybe_batch_token: BatchGenerationToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "public_bucket_directory_hash",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 6,
"name": "maybe_public_bucket_prefix",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 7,
"name": "maybe_public_bucket_extension",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 8,
"name": "maybe_cover_public_bucket_directory_hash?",
"type_info": {
"type": "VarString",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 9,
"name": "maybe_cover_public_bucket_prefix?",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 10,
"name": "maybe_cover_public_bucket_extension?",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 11,
"name": "creator_set_visibility: Visibility",
"type_info": {
"type": "String",
"flags": "NOT_NULL | BINARY | ENUM",
"char_set": 224,
"max_size": 28
}
},
{
"ordinal": 12,
"name": "is_user_upload: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL | MULTIPLE_KEY",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 13,
"name": "maybe_title",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 14,
"name": "maybe_prompt_token: PromptToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 15,
"name": "maybe_origin_filename",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 16,
"name": "maybe_duration_millis",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 17,
"name": "maybe_frame_width",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 5
}
},
{
"ordinal": 18,
"name": "maybe_frame_height",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 5
}
},
{
"ordinal": 19,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 20,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 3
},
"nullable": [
false,
false,
false,
false,
true,
false,
true,
true,
true,
true,
true,
false,
false,
true,
true,
true,
true,
true,
true,
false,
false
]
},
"hash": "6f077c5d35b19319aba4e9f4c59610822334fd304ba9133ba8e099056c426f2a"
}
@@ -1,223 +0,0 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n m.id as `id: u64`,\n m.token as `token: tokens::tokens::media_files::MediaFileToken`,\n\n m.media_class as `media_class: enums::by_table::media_files::media_file_class::MediaFileClass`,\n m.media_type as `media_type: enums::by_table::media_files::media_file_type::MediaFileType`,\n\n m.public_bucket_directory_hash,\n m.maybe_public_bucket_prefix,\n m.maybe_public_bucket_extension,\n\n users.token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n users.username as maybe_creator_username,\n users.display_name as maybe_creator_display_name,\n users.email_gravatar_hash as maybe_creator_gravatar_hash,\n\n media_file_cover_image.public_bucket_directory_hash as maybe_file_cover_image_public_bucket_hash,\n media_file_cover_image.maybe_public_bucket_prefix as maybe_file_cover_image_public_bucket_prefix,\n media_file_cover_image.maybe_public_bucket_extension as maybe_file_cover_image_public_bucket_extension,\n\n m.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,\n\n m.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n m.maybe_title,\n\n m.created_at,\n m.updated_at\n\nFROM media_files AS m FORCE INDEX (fk_maybe_creator_user_token)\nLEFT OUTER JOIN users\n ON m.maybe_creator_user_token = users.token\nLEFT OUTER JOIN media_files as media_file_cover_image\n ON media_file_cover_image.token = m.maybe_cover_image_media_file_token\nWHERE\n m.maybe_creator_user_token = ?\n AND m.media_class = ?\n AND m.user_deleted_at IS NULL\n AND m.mod_deleted_at IS NULL\n AND NOT m.is_intermediate_system_file\n AND m.id < ?\nORDER BY m.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "token: tokens::tokens::media_files::MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "media_class: enums::by_table::media_files::media_file_class::MediaFileClass",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 3,
"name": "media_type: enums::by_table::media_files::media_file_type::MediaFileType",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 4,
"name": "public_bucket_directory_hash",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "maybe_public_bucket_prefix",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 6,
"name": "maybe_public_bucket_extension",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 7,
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
"flags": "UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 8,
"name": "maybe_creator_username",
"type_info": {
"type": "VarString",
"flags": "UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 9,
"name": "maybe_creator_display_name",
"type_info": {
"type": "VarString",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 10,
"name": "maybe_creator_gravatar_hash",
"type_info": {
"type": "String",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 11,
"name": "maybe_file_cover_image_public_bucket_hash",
"type_info": {
"type": "VarString",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 12,
"name": "maybe_file_cover_image_public_bucket_prefix",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 13,
"name": "maybe_file_cover_image_public_bucket_extension",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 14,
"name": "creator_set_visibility: enums::common::visibility::Visibility",
"type_info": {
"type": "String",
"flags": "NOT_NULL | BINARY | ENUM",
"char_set": 224,
"max_size": 28
}
},
{
"ordinal": 15,
"name": "maybe_prompt_token: tokens::tokens::prompts::PromptToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 16,
"name": "maybe_title",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 17,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 18,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 4
},
"nullable": [
false,
false,
false,
false,
false,
true,
true,
true,
true,
true,
true,
true,
true,
true,
false,
true,
true,
false,
false
]
},
"hash": "73540435892b7b4dbc11c6c63712abdb18fd8b3db2bcd56b8518c968b8169de7"
}
@@ -1,245 +0,0 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n mf.id as `media_file_id: u64`,\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\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 media_files mf\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE mf.maybe_creator_user_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\n AND NOT mf.is_intermediate_system_file\n AND EXISTS (\n SELECT 1 FROM media_file_tags mft\n WHERE mft.media_file_token = mf.token\n )\n AND mf.id < ?\nORDER BY mf.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "media_file_id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "media_file_token: MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "media_class: MediaFileClass",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 3,
"name": "media_type: MediaFileType",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 4,
"name": "maybe_batch_token: BatchGenerationToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "public_bucket_directory_hash",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 6,
"name": "maybe_public_bucket_prefix",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 7,
"name": "maybe_public_bucket_extension",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 8,
"name": "maybe_cover_public_bucket_directory_hash?",
"type_info": {
"type": "VarString",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 9,
"name": "maybe_cover_public_bucket_prefix?",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 10,
"name": "maybe_cover_public_bucket_extension?",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 11,
"name": "creator_set_visibility: Visibility",
"type_info": {
"type": "String",
"flags": "NOT_NULL | BINARY | ENUM",
"char_set": 224,
"max_size": 28
}
},
{
"ordinal": 12,
"name": "is_user_upload: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL | MULTIPLE_KEY",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 13,
"name": "maybe_title",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 14,
"name": "maybe_prompt_token: PromptToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 15,
"name": "maybe_origin_filename",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 16,
"name": "maybe_duration_millis",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 17,
"name": "maybe_frame_width",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 5
}
},
{
"ordinal": 18,
"name": "maybe_frame_height",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 5
}
},
{
"ordinal": 19,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 20,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 3
},
"nullable": [
false,
false,
false,
false,
true,
false,
true,
true,
true,
true,
true,
false,
false,
true,
true,
true,
true,
true,
true,
false,
false
]
},
"hash": "76bcf698f40d852558f071a6909b05b1c960c1cf236831ae009f90de46d3aefb"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nUPDATE tags\nSET tag_value = ?,\n tag_value_lowercase = ?\nWHERE token = ?\n AND creator_user_token = ?\n AND maybe_deleted_at IS NULL\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "8938fc564dda214e383a8d30532db6ef964c03f7e1fd6d9561bca5e8b56834cf"
}
@@ -1,36 +0,0 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n maybe_creator_user_token as `maybe_creator_user_token: UserToken`,\n creator_set_visibility as `creator_set_visibility: Visibility`\nFROM media_files\nWHERE token = ?\n AND user_deleted_at IS NULL\n AND mod_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "maybe_creator_user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "creator_set_visibility: Visibility",
"type_info": {
"type": "String",
"flags": "NOT_NULL | BINARY | ENUM",
"char_set": 224,
"max_size": 28
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false
]
},
"hash": "a114fab486689fd2b16197cbe3a3ebb41d3d10937542832e15dd652200b6d419"
}
@@ -1,223 +0,0 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n m.id as `id: u64`,\n m.token as `token: tokens::tokens::media_files::MediaFileToken`,\n\n m.media_class as `media_class: enums::by_table::media_files::media_file_class::MediaFileClass`,\n m.media_type as `media_type: enums::by_table::media_files::media_file_type::MediaFileType`,\n\n m.public_bucket_directory_hash,\n m.maybe_public_bucket_prefix,\n m.maybe_public_bucket_extension,\n\n users.token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n users.username as maybe_creator_username,\n users.display_name as maybe_creator_display_name,\n users.email_gravatar_hash as maybe_creator_gravatar_hash,\n\n media_file_cover_image.public_bucket_directory_hash as maybe_file_cover_image_public_bucket_hash,\n media_file_cover_image.maybe_public_bucket_prefix as maybe_file_cover_image_public_bucket_prefix,\n media_file_cover_image.maybe_public_bucket_extension as maybe_file_cover_image_public_bucket_extension,\n\n m.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,\n\n m.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n m.maybe_title,\n\n m.created_at,\n m.updated_at\n\nFROM media_files AS m FORCE INDEX (fk_maybe_creator_user_token)\nLEFT OUTER JOIN users\n ON m.maybe_creator_user_token = users.token\nLEFT OUTER JOIN media_files as media_file_cover_image\n ON media_file_cover_image.token = m.maybe_cover_image_media_file_token\nWHERE\n m.maybe_creator_user_token = ?\n AND m.media_class = ?\n AND m.user_deleted_at IS NULL\n AND m.mod_deleted_at IS NULL\n AND NOT m.is_intermediate_system_file\n AND m.id > ?\nORDER BY m.id ASC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "token: tokens::tokens::media_files::MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "media_class: enums::by_table::media_files::media_file_class::MediaFileClass",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 3,
"name": "media_type: enums::by_table::media_files::media_file_type::MediaFileType",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 4,
"name": "public_bucket_directory_hash",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "maybe_public_bucket_prefix",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 6,
"name": "maybe_public_bucket_extension",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 7,
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
"flags": "UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 8,
"name": "maybe_creator_username",
"type_info": {
"type": "VarString",
"flags": "UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 9,
"name": "maybe_creator_display_name",
"type_info": {
"type": "VarString",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 10,
"name": "maybe_creator_gravatar_hash",
"type_info": {
"type": "String",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 11,
"name": "maybe_file_cover_image_public_bucket_hash",
"type_info": {
"type": "VarString",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 12,
"name": "maybe_file_cover_image_public_bucket_prefix",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 13,
"name": "maybe_file_cover_image_public_bucket_extension",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 14,
"name": "creator_set_visibility: enums::common::visibility::Visibility",
"type_info": {
"type": "String",
"flags": "NOT_NULL | BINARY | ENUM",
"char_set": 224,
"max_size": 28
}
},
{
"ordinal": 15,
"name": "maybe_prompt_token: tokens::tokens::prompts::PromptToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 16,
"name": "maybe_title",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 17,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 18,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 4
},
"nullable": [
false,
false,
false,
false,
false,
true,
true,
true,
true,
true,
true,
true,
true,
true,
false,
true,
true,
false,
false
]
},
"hash": "a52cc2d6e86607c430173553230b3794a4c1ada07274a975c04128e47d3b7f83"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nUPDATE tags\nSET maybe_deleted_at = NOW(),\n use_count = 0\nWHERE token = ?\n AND creator_user_token = ?\n AND maybe_deleted_at IS NULL\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "a58fc9050c1fd160e03d6e4932c1b777ea7ab8cbfddca5834faab3e2963d816d"
}
@@ -1,245 +0,0 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n mf.id as `media_file_id: u64`,\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\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 media_files mf\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE mf.maybe_creator_user_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\n AND NOT mf.is_intermediate_system_file\n AND NOT EXISTS (\n SELECT 1 FROM media_file_tags mft\n WHERE mft.media_file_token = mf.token\n )\n AND mf.id < ?\nORDER BY mf.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "media_file_id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "media_file_token: MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "media_class: MediaFileClass",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 3,
"name": "media_type: MediaFileType",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 4,
"name": "maybe_batch_token: BatchGenerationToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "public_bucket_directory_hash",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 6,
"name": "maybe_public_bucket_prefix",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 7,
"name": "maybe_public_bucket_extension",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 8,
"name": "maybe_cover_public_bucket_directory_hash?",
"type_info": {
"type": "VarString",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 9,
"name": "maybe_cover_public_bucket_prefix?",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 10,
"name": "maybe_cover_public_bucket_extension?",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 11,
"name": "creator_set_visibility: Visibility",
"type_info": {
"type": "String",
"flags": "NOT_NULL | BINARY | ENUM",
"char_set": 224,
"max_size": 28
}
},
{
"ordinal": 12,
"name": "is_user_upload: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL | MULTIPLE_KEY",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 13,
"name": "maybe_title",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 14,
"name": "maybe_prompt_token: PromptToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 15,
"name": "maybe_origin_filename",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 16,
"name": "maybe_duration_millis",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 17,
"name": "maybe_frame_width",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 5
}
},
{
"ordinal": 18,
"name": "maybe_frame_height",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 5
}
},
{
"ordinal": 19,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 20,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 3
},
"nullable": [
false,
false,
false,
false,
true,
false,
true,
true,
true,
true,
true,
false,
false,
true,
true,
true,
true,
true,
true,
false,
false
]
},
"hash": "aa1e693babe6997e75935ec893e6eed46351ec72ce02a9144fd11d2fcfd37fdf"
}
@@ -1,69 +0,0 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n t.id as `id: u64`,\n t.token as `token: TagToken`,\n t.tag_value,\n t.tag_value_lowercase,\n t.use_count as `use_count: u32`\nFROM media_file_tags mft\nJOIN tags t\n ON t.token = mft.tag_token\nWHERE mft.media_file_token = ?\n AND t.maybe_deleted_at IS NULL\nORDER BY t.tag_value_lowercase ASC\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "token: TagToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "tag_value",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 3,
"name": "tag_value_lowercase",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 4,
"name": "use_count: u32",
"type_info": {
"type": "Long",
"flags": "NOT_NULL | UNSIGNED",
"char_set": 63,
"max_size": 10
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "bbfed9e861b1a7cd0cdb2413e2a985aaa79aa9c885408bf5e104d1ea80bdaf49"
}
@@ -1,25 +0,0 @@
{
"db_name": "MySQL",
"query": "\nSELECT token as `token: TagToken`\nFROM tags\nWHERE creator_user_token = ?\n AND tag_value_lowercase = ?\n AND maybe_deleted_at IS NOT NULL\nLIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token: TagToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false
]
},
"hash": "cbfe9172d6227175aa6dfd276a4d1a6676d1d1b6bdc3a35927a5d71be38219c0"
}
@@ -1,25 +0,0 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n pci.media_token as `media_token: MediaFileToken`\nFROM prompt_context_items pci\nJOIN media_files m\n ON pci.media_token = m.token\nWHERE pci.prompt_token = ?\n AND m.media_class = 'image'\n AND m.user_deleted_at IS NULL\n AND m.mod_deleted_at IS NULL\nORDER BY pci.id ASC\nLIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "media_token: MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "d6dfca49327b2b31078517c5926ae51cc2490b17d8c94a97fefd3decdf8e5664"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nDELETE FROM tags\nWHERE token = ?\n AND creator_user_token = ?\n AND maybe_deleted_at IS NOT NULL\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "e50d11df0606a12059e13f36daa41fc5aa59d700168425c406e545b535d6d2ba"
}
@@ -1,69 +0,0 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n id as `id: u64`,\n token as `token: TagToken`,\n tag_value,\n tag_value_lowercase,\n use_count as `use_count: u32`\nFROM tags\nWHERE creator_user_token = ?\n AND maybe_deleted_at IS NULL\n AND id < ?\nORDER BY id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "token: TagToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "tag_value",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 3,
"name": "tag_value_lowercase",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 4,
"name": "use_count: u32",
"type_info": {
"type": "Long",
"flags": "NOT_NULL | UNSIGNED",
"char_set": 63,
"max_size": 10
}
}
],
"parameters": {
"Right": 3
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "eafaf1a9ed2e27dec201ea66d23a65662492ca2dbdac6333629b82defc4b5111"
}
@@ -1,69 +0,0 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n id as `id: u64`,\n token as `token: TagToken`,\n tag_value,\n tag_value_lowercase,\n use_count as `use_count: u32`\nFROM tags\nWHERE token = ?\n AND creator_user_token = ?\n AND maybe_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "token: TagToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "tag_value",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 3,
"name": "tag_value_lowercase",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 4,
"name": "use_count: u32",
"type_info": {
"type": "Long",
"flags": "NOT_NULL | UNSIGNED",
"char_set": 63,
"max_size": 10
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "ecc3848cd186f1298a2a60c810c69b7a8715719a0c7622122d7e1c5747fab06a"
}
@@ -1,245 +0,0 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n mf.id as `media_file_id: u64`,\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\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 media_file_tags mft\nJOIN media_files mf\n ON mf.token = mft.media_file_token\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE mft.tag_token = ?\n AND mft.user_token = ?\n AND mf.maybe_creator_user_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\n AND NOT mf.is_intermediate_system_file\n AND mf.id < ?\nORDER BY mf.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "media_file_id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "media_file_token: MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "media_class: MediaFileClass",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 3,
"name": "media_type: MediaFileType",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 4,
"name": "maybe_batch_token: BatchGenerationToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "public_bucket_directory_hash",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 6,
"name": "maybe_public_bucket_prefix",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 7,
"name": "maybe_public_bucket_extension",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 8,
"name": "maybe_cover_public_bucket_directory_hash?",
"type_info": {
"type": "VarString",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 9,
"name": "maybe_cover_public_bucket_prefix?",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 10,
"name": "maybe_cover_public_bucket_extension?",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 11,
"name": "creator_set_visibility: Visibility",
"type_info": {
"type": "String",
"flags": "NOT_NULL | BINARY | ENUM",
"char_set": 224,
"max_size": 28
}
},
{
"ordinal": 12,
"name": "is_user_upload: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL | MULTIPLE_KEY",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 13,
"name": "maybe_title",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 14,
"name": "maybe_prompt_token: PromptToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 15,
"name": "maybe_origin_filename",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 16,
"name": "maybe_duration_millis",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 17,
"name": "maybe_frame_width",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 5
}
},
{
"ordinal": 18,
"name": "maybe_frame_height",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 5
}
},
{
"ordinal": 19,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 20,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 5
},
"nullable": [
false,
false,
false,
false,
true,
false,
true,
true,
true,
true,
true,
false,
false,
true,
true,
true,
true,
true,
true,
false,
false
]
},
"hash": "f36c7238231353882b5b2ac39616dcdc6adebf7c7e1198d9c2af4674ba5d982c"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\n INSERT INTO media_files\n SET\n token = ?,\n\n media_class = ?,\n media_type = ?,\n\n is_user_upload = ?,\n is_intermediate_system_file = ?,\n\n origin_category = ?,\n origin_product_category = ?,\n maybe_origin_model_type = ?,\n maybe_origin_model_token = ?,\n maybe_origin_filename = ?,\n\n maybe_batch_token = ?,\n\n maybe_mime_type = ?,\n file_size_bytes = ?,\n maybe_duration_millis = ?,\n maybe_audio_encoding = ?,\n maybe_video_encoding = ?,\n maybe_frame_width = ?,\n maybe_frame_height = ?,\n maybe_prompt_token = ?,\n checksum_sha2 = ?,\n\n maybe_engine_category = ?,\n\n maybe_title = ?,\n maybe_text_transcript = ?,\n\n maybe_scene_source_media_file_token = ?,\n\n public_bucket_directory_hash = ?,\n maybe_public_bucket_prefix = ?,\n maybe_public_bucket_extension = ?,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n\n creator_ip_address = ?,\n creator_set_visibility = ?,\n\n maybe_creator_file_synthetic_id = ?,\n maybe_creator_category_synthetic_id = ?,\n\n extra_file_modification_info = ?,\n\n maybe_generation_provider = ?,\n\n platform_type = ?,\n\n maybe_cover_image_media_file_token = ?,\n\n maybe_mod_user_token = ?,\n is_generated_on_prem = ?,\n generated_by_worker = ?,\n generated_by_cluster = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 41
},
"nullable": []
},
"hash": "f5bc4a956a20f6c0998585092a0922301d53c95ae8ea9cb808ae2dbcf8d0bb32"
}
+1 -1
View File
@@ -28,7 +28,7 @@ Text-to-image is great, but artists *need control*. It's important to know what
| **3D Image Compositing** | ![Video](https://github.com/user-attachments/assets/f93a616f-571d-474e-bcc0-53736de7303d) Use images (backdrops, foreground elements, props, etc.) in scenes with depth and blend them naturally together. Just a couple of images usually leads to great compositions. |
| **2D Image Compositing** | ![Video](https://github.com/user-attachments/assets/d6f99391-e496-4c62-9e37-29734ba5f899) Use images, background removal, layers, and simple drawing tools to precisely compose a scene. |
| **Image to 3D Mesh** | ![Video](https://github.com/user-attachments/assets/600a405c-e360-48c1-9b42-6e657ae6243b) It's almost impossible to lay out complicated objects or block complicated scenes; turning images into 3D helps position elements exactingly and intentionally. |
| **Character Posing** | ![Video](https://raw.githubusercontent.com/storytold/github-media/22a27373707b13f48fc56405f1f89ca143c43d5d/character-posing.webp) You can dynamically pose your characters to achieve the precise character, scene, and camera blocking before calling "action". |
| **Character Posing** | ![Video](https://github.com/user-attachments/assets/52a8e983-7c8f-42d2-be8b-25296ab9ed57) You can dynamically pose your characters to achieve the precise character, scene, and camera blocking before calling "action". |
| **Scene Blocking w/ Kit Bashing** | ![Video](https://github.com/user-attachments/assets/eef025ac-0346-4a46-a023-d48e23629eb5) Use 3D asset kits to precisely block out your scene: get the correct angles, object positions, and rich depth layering you can't with text prompting. |
| **Character Identity Transfer** | ![Video](https://github.com/user-attachments/assets/629119ee-8c76-4a83-9827-8c6c995a3ec1) Use mannequins as simple 3D ControlNets for posing any character. |
| **Background Removal** | ![Video](https://github.com/user-attachments/assets/90c65057-5531-404f-83af-b34e66e24ec1) Remove backgrounds from images to make them useful in 2D or 3D compositing. They can be props, layers, or backdrops. |
@@ -1,35 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE tag_uses (
-- Not used for anything except replication.
id BIGINT(20) NOT NULL AUTO_INCREMENT,
-- ========== TAG LINK ==========
-- Token of the tag
tag_token VARCHAR(32) NOT NULL,
-- ========== LINKED ENTITY ==========
-- Entity the tag is linked to (composite key)
entity_type VARCHAR(32) NOT NULL,
-- Entity the tag is linked to (composite key)
entity_token VARCHAR(32) NOT NULL,
-- ========== TIMESTAMPS ==========
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
maybe_deleted_at TIMESTAMP NULL,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (tag_token, entity_type, entity_token),
KEY index_tag_token (tag_token),
KEY index_entity_type_and_token (entity_type, entity_token),
KEY index_maybe_deleted_at (maybe_deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -1,5 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE tag_uses;
@@ -1,30 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE tags (
-- Not used for anything except replication.
id BIGINT(20) NOT NULL AUTO_INCREMENT,
-- ========== PRIMARY FUNCTIONALITY ==========
-- Effective "primary key" of the beta key.
token VARCHAR(32) NOT NULL,
-- The actual value of the tag.
-- The code will normalize this.
tag_value VARCHAR(255) NOT NULL,
-- ========== TIMESTAMPS ==========
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
maybe_deleted_at TIMESTAMP NULL,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (token),
UNIQUE KEY (tag_value),
KEY index_maybe_deleted_at (maybe_deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -1,5 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE tags;
@@ -1,5 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE IF EXISTS tags;
@@ -1,38 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE tags (
-- Not used for anything except replication.
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- Effective "primary key".
token VARCHAR(32) NOT NULL,
-- The display value of the tag, as entered by the creator.
tag_value VARCHAR(255) NOT NULL,
-- Lowercased copy of `tag_value` for case-insensitive uniqueness and lookup.
-- (The table collation is utf8mb4_bin, so the code must lowercase this.)
tag_value_lowercase VARCHAR(255) NOT NULL,
-- The user that created the tag.
creator_user_token VARCHAR(32) NOT NULL,
-- Rollup statistic for usage counts.
use_count INT(10) UNSIGNED NOT NULL DEFAULT 0,
-- ========== TIMESTAMPS ==========
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
maybe_deleted_at TIMESTAMP NULL DEFAULT NULL,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (token),
UNIQUE KEY (tag_value_lowercase, creator_user_token),
KEY index_creator_user_token (creator_user_token)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -1,5 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE IF EXISTS media_file_tags;
@@ -1,32 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE media_file_tags (
-- Not used for anything except replication.
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- The media file being tagged.
media_file_token VARCHAR(32) NOT NULL,
-- The tag applied to the media file.
tag_token VARCHAR(32) NOT NULL,
-- The user that owns this tagging.
user_token VARCHAR(32) NOT NULL,
-- ========== TIMESTAMPS ==========
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (media_file_token, tag_token, user_token),
KEY index_media_file_token_and_user_token (media_file_token, user_token),
KEY index_tag_token_and_user_token (tag_token, user_token),
KEY index_media_file_token (media_file_token),
KEY index_tag_token (tag_token),
KEY index_user_token (user_token)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -1,36 +0,0 @@
-- NB: This is a manually squashed view of all the CREATE and ALTER statements,
-- with comments attached to the fields for centralized documentation.
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
-- N:M mapping between `media_files` and `tags`, scoped to the owning user.
CREATE TABLE media_file_tags (
-- Not used for anything except replication.
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- The media file being tagged.
media_file_token VARCHAR(32) NOT NULL,
-- The tag applied to the media file.
tag_token VARCHAR(32) NOT NULL,
-- The user that owns this tagging.
user_token VARCHAR(32) NOT NULL,
-- ========== TIMESTAMPS ==========
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (media_file_token, tag_token, user_token),
KEY index_media_file_token_and_user_token (media_file_token, user_token),
KEY index_tag_token_and_user_token (tag_token, user_token),
KEY index_media_file_token (media_file_token),
KEY index_tag_token (tag_token),
KEY index_user_token (user_token)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -1,44 +0,0 @@
-- NB: This is a manually squashed view of all the CREATE and ALTER statements,
-- with comments attached to the fields for centralized documentation.
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
-- User-created tags for media files. Tags are scoped per-user: two users can
-- each have their own tag with the same value. Applied to media files via the
-- `media_file_tags` join table.
CREATE TABLE tags (
-- Not used for anything except replication.
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- Effective "primary key".
token VARCHAR(32) NOT NULL,
-- The display value of the tag, as entered by the creator.
tag_value VARCHAR(255) NOT NULL,
-- Lowercased copy of `tag_value` for case-insensitive uniqueness and lookup.
-- (The table collation is utf8mb4_bin, so the code must lowercase this.)
tag_value_lowercase VARCHAR(255) NOT NULL,
-- The user that created the tag.
creator_user_token VARCHAR(32) NOT NULL,
-- Rollup statistic for usage counts.
use_count INT(10) UNSIGNED NOT NULL DEFAULT 0,
-- ========== TIMESTAMPS ==========
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
maybe_deleted_at TIMESTAMP NULL DEFAULT NULL,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (token),
UNIQUE KEY (tag_value_lowercase, creator_user_token),
KEY index_creator_user_token (creator_user_token)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-293
View File
@@ -1,293 +0,0 @@
# Local Dev Stack — Requirements & Bootstrap
How to run `storyteller-web` on your own machine against a local MySQL and
Redis — no shared dev database, no Docker, no admin rights. This is for **work
on the backend itself**.
> **Working on the frontend?** You probably want
> `frontend/apps/fake-storyteller-web` instead — an in-memory stand-in for this
> API that needs no database, no build, and no setup. Everything below is
> unnecessary for webapp work. Come back here when you need the real server.
These scripts install and supervise infrastructure. They change no application
behaviour, and nothing in them is compiled into any binary: the backend they
start is the same one that ships. Integrations they cannot provide locally
(object storage, generation providers, email) get placeholder credentials, so
the endpoints that use them fail when exercised. Swap in a real dev credential
for whichever one you're actually working on.
The automated path on **Windows** (primary platform, native — no WSL, no
Docker, no admin rights for the stack itself):
```powershell
.\script\bootstrap\windows\bootstrap_dev_stack.ps1 # one-time setup, idempotent, re-run anytime
.\script\bootstrap\windows\run_backend_dev.ps1 # terminal 1: API on http://localhost:12345
.\script\bootstrap\windows\seed_demo_user.ps1 # terminal 2: create demo login (localdev1 / localdev1pass)
$env:VITE_DEV_API_HOST = "http://localhost:12345" # terminal 2: aim the webapp at your backend
cd frontend; npx nx dev artcraft-webapp # webapp on http://localhost:4201
.\script\bootstrap\windows\dev_stack_doctor.ps1 # health check, any time
.\script\bootstrap\windows\stop_dev_services.ps1 # stop MySQL/Redis when done
```
The equivalent on **Linux** (Ubuntu 22.04+ — native, WSL2, or CI):
```bash
./script/bootstrap/bootstrap_dev_stack.sh # one-time setup, idempotent, re-run anytime
./script/bootstrap/run_backend_dev.sh # terminal 1: API on http://localhost:12345
./script/bootstrap/seed_demo_user.sh # terminal 2: create demo login (localdev1 / localdev1pass)
cd frontend && VITE_DEV_API_HOST=http://localhost:12345 npx nx dev artcraft-webapp # terminal 2: webapp on :4201
./script/bootstrap/dev_stack_doctor.sh # health check, any time
```
This document records the underlying requirements so the scripts aren't a
black box, and corrects some stale claims in older docs.
## Supported platforms
| Platform | Status |
|---------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Windows 10/11 | Primary. `script/bootstrap/windows/*.ps1` run a **portable stack**: MySQL 8.4 (official zip) and Redis live under the gitignored `.devstack/` and run as plain user processes — no admin, no services, no WSL, no Docker. Build tools (cmake, perl, nasm, llvm) are offered via winget |
| Ubuntu 22.04+ | Supported by `script/bootstrap/*.sh` (native, WSL2, or CI runner/container); services via apt |
| macOS | Not covered by the scripts; follow `_docs/dev_setup_server.md` manually |
### Windows specifics
- Everything stateful lives in `.devstack/` (gitignored): `mysql/` (extracted
zip), `mysql-data/`, `redis/`, `logs/`, `pids/`, `downloads/` (cached zips),
`my.ini`. Delete the directory to fully reset; re-run bootstrap to rebuild.
- Pinned services: MySQL **8.4.11** from `cdn.mysql.com` (the 8.x series is
required; only some patch versions exist on the CDN) and Redis **5.0.14.1**
(tporadowski build — old but sufficient for the backend's keepalive/TTL/
rate-limit usage; override `REDIS_ZIP_URL` to use Memurai/Garnet instead).
- The portable MySQL root user has **no password** (`--initialize-insecure`) —
local dev only, bound to 127.0.0.1.
- diesel-cli is compiled against the portable MySQL's own client library
(`MYSQLCLIENT_LIB_DIR`/`MYSQLCLIENT_VERSION`); `libmysql.dll` must be on
`PATH` to *run* it — the scripts handle both.
- Compiling the backend needs cmake, Strawberry Perl, NASM, and LLVM/libclang
(the `wreq` HTTP client builds BoringSSL, and `aws-lc-sys`/`ring` need
NASM/clang on MSVC). The bootstrap checks and offers winget installs.
- The generated secrets file additionally sets `TEMP_DIR` (the upload temp-dir
helper defaults to `/tmp`, which doesn't exist on Windows).
## Requirements matrix
| Requirement | Version / detail | Needed for |
|-------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------|
| MySQL server | 8.x series (9.x unsupported by tooling) | Backend boot (eager connect) + migrations |
| Redis server | any recent | Backend boot (r2d2 pool connects eagerly) |
| Rust (stable) | no pinned MSRV; Docker builds use 1.93.0 | Building `storyteller-web`, diesel_cli |
| diesel_cli | `cargo install diesel_cli --no-default-features --features mysql,sqlite` | Running the 281 migrations |
| Node.js | 20+ (enforced by `script/common/frontend_preflight.sh`) | Frontend (Nx 21 / Vite 6) |
| npm | ships with Node; **pnpm must not be used** | Frontend install (`frontend/package-lock.json`) |
| System libs (apt) | build-essential, cmake, libssl-dev, libclang-dev, libfontconfig1-dev, fontconfig, perl, pkg-config, ffmpeg, libmysqlclient-dev, libsqlite3-dev, curl, jq | Compiling the workspace + diesel_cli; seeding scripts |
| Elasticsearch | optional (7.x) | Only 4 search endpoints; server boots without it |
| Docker | **not required** — no docker-compose exists | — |
Database identity (used consistently by docs, `.env`, and defaults):
database `storyteller`, user `storyteller`, password `password`, host
`localhost`.
## Backend: what `storyteller-web` needs to boot
Binary: `storyteller-web` in `crates/service/web/storyteller_web`
(binaries: `storyteller-web`, `docs` — Swagger UI on :8989, `docs-cli` — emits
`api.json`). Binds `0.0.0.0:12345` (`BIND_ADDRESS`), plain HTTP.
**Run it from the repo root** — the config search path and `includes/` paths
are cwd-relative.
### Config loading (corrected facts)
- Mode is selected by the `SERVER_ENVIRONMENT` **process env var only**
(default: Development). Setting it inside an env file does nothing — it is
read before any file loads.
- In Development mode, files load from
`crates/service/web/storyteller_web/config/` in this order:
`storyteller-web.common.env` → `storyteller-web.development.env` →
`storyteller-web.development-secrets.env` (gitignored; the bootstrap
generates it).
- **Precedence is first-value-wins** (dotenv never overrides an existing key):
real process env > common.env > development.env > development-secrets.env.
The secrets file therefore cannot override anything already set in
`development.env`.
- The repo-root `.env` is **NOT loaded by the server** (it passes
`ignore_legacy_dot_env_file: true`). Root `.env`'s `DATABASE_URL` feeds
diesel-cli/sqlx-cli only; the server reads **`MYSQL_URL`** (default
`mysql://storyteller:password@localhost/storyteller`).
### Hard requirements at boot
| Requirement | Behavior if missing |
|-------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------|
| MySQL reachable via `MYSQL_URL` | Boot fails (sqlx pool connects eagerly; the DB must exist and be migrated — health-check and ban-poller threads query immediately) |
| Redis reachable via `REDIS_0_URL` (default `redis://localhost/0`) | Boot fails (r2d2 pool connects eagerly) |
| ~30 `get_env_string_required` vars present | Boot fails on the first absent var |
Of the required-present vars, 19 are supplied by **no** checked-in file and
live in the generated secrets file: `ACCESS_KEY`, `SECRET_KEY`, `REGION_NAME`,
`W2L_PRIVATE_DOWNLOAD_BUCKET_NAME`, `W2L_PUBLIC_DOWNLOAD_BUCKET_NAME`,
`RESEND_API_KEY`, `FAL_API_KEY`, `GMICLOUD_API_KEY`, `GROK_API_KEY`,
`BEEBLE_API_KEY`, `OPENAI_API_KEY`, `WORLDLABS_API_KEY`,
`SEEDANCE2PRO_COOKIES`, `SEEDANCE2PRO_WHITELIST_COOKIES`,
`SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES`, `STRIPE_FAKEYOU_ACCOUNT_ID`,
`STRIPE_ARTCRAFT_ACCOUNT_ID`, `STRIPE_ARTCRAFT_SECRET_KEY`,
`STRIPE_ARTCRAFT_SECRET_WEBHOOK_KEY`.
**Placeholder values are safe at boot**: none of these services is contacted
at boot (clients are constructed offline; Elasticsearch, S3/R2, Stripe,
Resend, and Google-cert fetches are all lazy). But two earlier claims here
were wrong and are corrected:
- **The omni_gen endpoints call providers synchronously in the HTTP handler**
(`/v1/omni_gen/generate/{image,video}` — the path the webapp uses). With
placeholder keys the provider call fails and the submit returns HTTP 500
*after* billing the wallet (the image pipeline has no refund; video refunds
only Kinovi). Only the legacy polling providers (GmiCloud, Grok, Kinovi
orders, WorldLabs) are worker-driven.
- **Uploads fail hard with placeholder S3 creds** — every media upload endpoint
uploads to the bucket *before* inserting the DB row, and `rust-s3` is built
with `fail-on-err`.
### What does and doesn't work locally
| Area | Works with placeholder credentials? |
|----------------------------------------------|-------------------------------------|
| Login, sessions, account, profile | Yes |
| Wallets, credits, billing reads | Yes |
| Folders, tags, prompts, characters, projects | Yes |
| Media listing (rows) | Yes — but the images 404, see below |
| Media uploads | No — needs real R2/S3 credentials |
| Image/video/mesh/splat generation | No — needs a real provider key |
| Transactional email | No — needs a real Resend key |
| Elasticsearch-backed search | No — needs a local ES + reindex |
The rows-but-no-images case is the one that surprises people: `media_files`
rows carry a bucket path, and the URL the API builds from it points at the
shared R2 bucket, which does not have your local objects. Put real dev R2
credentials in the secrets file if you need media to render, or use
`fake-storyteller-web`, which serves its own bytes.
### Auth on localhost
- `POST /v1/login` takes `{"username_or_email": "...", "password": "..."}`;
success returns `{"success": true, "signed_session": "<jwt>"}` **and** a
`session` cookie.
- With the checked-in dev config (`COOKIE_DOMAIN=.jungle.horse`), the session
cookie is issued non-Secure and host-only (the Domain attribute is never
set), so plain-HTTP localhost login works as-is. The frontend additionally
stores `signed_session` in localStorage and sends it as a `session` header.
- Dev-mode CORS allows any `localhost`/`127.0.0.1` origin on any port, with
credentials.
- Health endpoint for scripts/CI: `GET /_status`.
### Build
- Compile with `SQLX_OFFLINE=true` (uses the checked-in `.sqlx/` cache; 483
queries). Without it, the sqlx macros open a **live DB connection at compile
time** using `DATABASE_URL` from the root `.env`. `SQLX_OFFLINE` has no
runtime effect — ignore older notes saying not to set it when running.
- First build takes several minutes; the crate has ~370 pre-existing warnings.
## Database: migrations and seed data
- 281 Diesel migrations in `_database/sql/migrations/` (`diesel.toml` at repo
root points there). Run from the repo root: `diesel migration run`.
`"Encountered unknown type for Mysql: enum"` warnings are harmless.
`migrations_squashed/` is documentation only — not executable.
- Seed data: `_database/sql/seed/sql/system_roles.sql` (roles `user`, `mod`,
`admin`) and `user_badges.sql`. **The `user` role is mandatory** — account
creation hardcodes `user_role_slug='user'`. The legacy wrapper
`bootstrap_inserts_roles_etc.sh` must run with cwd `_database/sql/` and is
not idempotent; the bootstrap script applies the same SQL files with
idempotency guards instead.
- Demo user: created through **`POST /v1/create_account`** (no captcha, no
invite code, no email-verification gate; password just needs 6+ chars). This
exercises the real signup path instead of hand-rolled SQL. Gotcha: usernames
like `demo`, `admin`, `test`, `dev`, `artcraft` are reserved
(`includes/binary_includes/usernames/reserved_usernames.txt`) — the default
demo user is `localdev1`. The Rust seeder
(`cargo run --bin dev-database-seed`) exists but hard-requires a
`.env-secrets` file and also seeds model weights, so the HTTP path is
preferred.
- Demo credits: signup creates **no wallet**, and the webapp gates the
generate button on wallet balance — so `seed_demo_user` also seeds an
`artcraft`-namespace wallet with `DEMO_CREDITS` (default 100,000) banked
credits, plus matching ledger rows. Idempotent: re-runs top the balance
back up to `DEMO_CREDITS` but never lower it. (Raw SQL, because the only
real credit-granting paths are Stripe webhooks and moderator tooling.)
- Demo gallery: **not seeded**. Media rows without reachable bytes render as
broken tiles, so seeding them locally would be worse than an empty library.
Use `fake-storyteller-web` for a populated one.
## Frontend: `artcraft-webapp`
- Nx workspace at repo-root `frontend/` (npm workspaces; `npm install` from
`frontend/`). The FontAwesome Pro registry token is committed in
`frontend/.npmrc` — no manual auth step.
- Serve: `npx nx dev artcraft-webapp` → `http://localhost:4201`. No lib
pre-build is needed in dev (libs resolve to TS source via the
`"development"` export condition); production `nx build` does need lib
builds.
- **By default the dev webapp targets production.** `src/main.tsx` points the
API host at the Vite dev origin, and Vite proxies `/v1` to
`https://api.storyteller.ai`. The adjacent `setDevelopment()` call — which
would point it at `localhost:12345` — is commented out. To aim the webapp at
your local backend, set `VITE_DEV_API_HOST=http://localhost:12345` before
`nx dev` (that override ships with the `fake-storyteller-web` change; until
it lands, uncomment `setDevelopment()` locally instead).
- Optional: `VITE_GOOGLE_CLIENT_ID` in `frontend/apps/artcraft-webapp/.env`
enables the Google SSO button; email/password login needs nothing.
- Other dev ports if you run more apps: website 4200, dashboard 4202 (separate
pnpm app, excluded from Nx), video-info-website 4250, genhub 4300, Tauri
frontend 5173.
## What still touches production (known, deliberate seams)
The bootstrap changes no runtime behaviour, so these remain open. Each is
closed by supplying a real credential, not by a code path:
| Seam | Today |
|---------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Media/CDN URLs in API responses | The compiled-in `cdn_link.rs` constants point at the public `pub-….r2.dev` bucket, so a locally-created row's media URL resolves to an object that isn't there |
| Media uploads | 500 with placeholder credentials; supply real dev R2/S3 keys to exercise them |
| Generation | 500 with placeholder keys, *after* the wallet is billed; supply a real key for the provider under test |
| Vite `/v1` proxy in webapp | Targets `https://api.storyteller.ai`, so a dev webapp with no host override reads and writes production |
| Elasticsearch-backed search | `search_featured` / `search_session` media files, `weights/search`, legacy `tts/search` fail without a local ES + `elasticsearch-cli` reindex (optional) |
## CI usage
The same bootstrap works on a fresh Ubuntu runner/container:
```bash
export MYSQL_ROOT_PASSWORD=root # GitHub Actions' preinstalled MySQL uses password auth
./script/bootstrap/bootstrap_dev_stack.sh --yes
./script/bootstrap/run_backend_dev.sh &
./script/bootstrap/seed_demo_user.sh # waits for /_status
./script/bootstrap/dev_stack_doctor.sh
```
`--yes` answers all prompts; `--skip-frontend` / `--skip-rust-build` /
`--skip-packages` slice the work for focused jobs.
## Verification checklist
- `./script/bootstrap/dev_stack_doctor.sh` exits 0 with no FAILs.
- `POST /v1/login` with the demo user returns `success: true` plus a
`signed_session` (done automatically by `seed_demo_user.sh`).
- `GET /_status` returns 200, and MySQL has the full migration set applied.
- With `VITE_DEV_API_HOST` pointed at `localhost:12345`, the webapp logs in as
`localdev1` and shows its credits in the top bar. The library is empty and
generation 500s — both expected, see the table above.
## Corrections to older docs
- `crates/service/web/storyteller_web/CLAUDE.md`: the repo-root `.env` is
*not* auto-loaded by the server, and `SQLX_OFFLINE=true` is fine (and
recommended) when running — it only affects compile-time query checking.
- `_docs/dev_setup_server.md`: `./script/dev_mysql_connect.sh` doesn't exist —
it's `./script/dev_database_connect.sh`.
- `diesel.toml`'s comment about needing an unreleased diesel CLI is stale —
any current `diesel_cli` works.
- `TESTING_INFRA_HANDOFF.md` (untracked): the server env var is `MYSQL_URL`,
not `DATABASE_URL`; migration count is 281; crate path is
`crates/service/web/storyteller_web`.
@@ -1,7 +1,6 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use enums::by_table::media_files::media_file_class::MediaFileClass;
use tokens::tokens::folders::FolderToken;
use tokens::tokens::media_files::MediaFileToken;
@@ -23,28 +22,6 @@ pub struct ListFolderMediaFilesQueryParams {
pub limit: Option<u32>,
}
// ── GET /v1/folders/media_files_without_folder ──
//
// NB: `ListMediaFilesWithoutFolderSuccessResponse` and its list item live in
// `storyteller_web`'s handler because the wire shape embeds
// `MediaFileCoverImageDetails` and `MediaLinks` constructors that depend
// on the request's `MediaDomain` + `ServerEnvironment`.
#[derive(Deserialize, ToSchema, IntoParams)]
pub struct ListMediaFilesWithoutFolderQueryParams {
pub cursor: Option<String>,
pub limit: Option<u32>,
/// Optional filter on the coarse media class.
///
/// Usage:
/// - `?filter_media_class=image`
/// - `?filter_media_class=video`
/// - `?filter_media_class=mesh`
/// - `?filter_media_class=splat`
pub filter_media_class: Option<MediaFileClass>,
}
// ── PUT /v1/folders/media_files/{folder_token}/bulk_add ──
#[derive(Deserialize, ToSchema)]
@@ -13,7 +13,6 @@ pub mod omni_gen;
pub mod prompts;
pub mod stripe_artcraft;
pub mod subscriptions;
pub mod tags;
pub mod user_referral_codes;
pub mod users;
pub mod utils;
@@ -1,64 +0,0 @@
//! Shared types for the by-class session media file list endpoints
//! (`/v1/media_files/mesh/list`, `/v1/media_files/splat/list`).
use chrono::{DateTime, Utc};
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::visibility::Visibility;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::prompts::PromptToken;
use crate::common::responses::media_file_cover_image_details::MediaFileCoverImageDetails;
use crate::common::responses::media_links::MediaLinks;
use crate::common::responses::user_details_light::UserDetailsLight;
/// Query string parameters for the by-class session media file lists.
/// Pagination only — the media class is fixed by the endpoint.
#[derive(Deserialize, IntoParams)]
pub struct ListSessionMediaFilesByTypeQueryParams {
pub sort_ascending: Option<bool>,
pub page_size: Option<usize>,
pub cursor: Option<String>,
pub cursor_is_reversed: Option<bool>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct SessionMediaFileInfo {
/// Primary key identifier
pub token: MediaFileToken,
/// The coarse-grained class of media file.
/// Fixed per endpoint (`mesh` or `splat`).
pub media_class: MediaFileClass,
/// Type of media will dictate which fields are populated and what
/// the frontend should display (eg. video player vs audio player).
/// This is closer in meaning to a "mime type".
pub media_type: MediaFileType,
/// Rich CDN links to the media, including thumbnails, previews, and more.
pub media_links: MediaLinks,
/// Information about the cover image. Many media files do not require a cover image,
/// e.g. image files, video files with thumbnails, audio files, etc.
/// 3D files require them.
pub cover_image: MediaFileCoverImageDetails,
/// User info
pub maybe_creator_user: Option<UserDetailsLight>,
pub creator_set_visibility: Visibility,
/// The generation's prompt record, when there is one. Clients resolve it
/// to display the original prompt and model.
pub maybe_prompt_token: Option<PromptToken>,
/// The name or title of the media file (optional)
pub maybe_title: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
@@ -1,14 +0,0 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::common::responses::pagination_cursors::PaginationCursors;
use crate::media_file::list::by_type::list_session_common::SessionMediaFileInfo;
pub const LIST_SESSION_MESH_MEDIA_FILES_URL_PATH: &str = "/v1/media_files/mesh/list";
#[derive(Serialize, Deserialize, ToSchema)]
pub struct ListSessionMeshMediaFilesSuccessResponse {
pub success: bool,
pub results: Vec<SessionMediaFileInfo>,
pub pagination: PaginationCursors,
}
@@ -1,14 +0,0 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::common::responses::pagination_cursors::PaginationCursors;
use crate::media_file::list::by_type::list_session_common::SessionMediaFileInfo;
pub const LIST_SESSION_SPLAT_MEDIA_FILES_URL_PATH: &str = "/v1/media_files/splat/list";
#[derive(Serialize, Deserialize, ToSchema)]
pub struct ListSessionSplatMediaFilesSuccessResponse {
pub success: bool,
pub results: Vec<SessionMediaFileInfo>,
pub pagination: PaginationCursors,
}
@@ -1,3 +0,0 @@
pub mod list_session_common;
pub mod list_session_mesh_media_files;
pub mod list_session_splat_media_files;
@@ -1 +0,0 @@
pub mod by_type;
@@ -1,5 +1,4 @@
pub mod delete_media_file;
pub mod list;
pub mod list_batch_generated_media_files;
pub mod list_session_project_media_files;
pub mod project;
pub mod project;
@@ -1,35 +0,0 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::media_files::MediaFileToken;
use crate::tags::common::TagDetails;
// ── POST /v1/tags/media_file/add/{media_file_token} ──
#[derive(Deserialize, ToSchema)]
pub struct AddMediaFileTagsPathInfo {
pub media_file_token: MediaFileToken,
}
/// Exactly one of `maybe_tags` / `maybe_tags_list` must be set (400 if
/// both or neither). Tag text is trimmed and deduped case-insensitively;
/// empty entries are dropped. Must sanitize to at least one tag (400
/// otherwise).
#[derive(Deserialize, ToSchema)]
pub struct AddMediaFileTagsRequest {
/// Comma-separated tags, e.g. `"cats, Sci-Fi, wallpaper"`.
pub maybe_tags: Option<String>,
/// Tags as a list. Entries are still trimmed.
pub maybe_tags_list: Option<Vec<String>>,
}
#[derive(Serialize, ToSchema)]
pub struct AddMediaFileTagsSuccessResponse {
pub success: bool,
/// The tags from this request after upsert, with canonical tokens and
/// fresh use counts. Tags already on the file are absorbed (no error).
pub tags: Vec<TagDetails>,
}
@@ -1,38 +0,0 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::media_files::MediaFileToken;
use crate::tags::common::TagDetails;
// ── POST /v1/tags/bulk_add ──
/// Exactly one of `maybe_tags` / `maybe_tags_list` must be set (400 if
/// both or neither). Tag text is trimmed and deduped case-insensitively;
/// empty entries are dropped. Must sanitize to at least one tag (400
/// otherwise). Media file tokens are deduped; tokens the user doesn't
/// own (or that are deleted) are silently skipped.
#[derive(Deserialize, ToSchema)]
pub struct BulkAddTagsRequest {
/// Media files to tag.
pub media_file_tokens: Vec<MediaFileToken>,
/// Comma-separated tags, e.g. `"cats, Sci-Fi, wallpaper"`.
pub maybe_tags: Option<String>,
/// Tags as a list. Entries are still trimmed.
pub maybe_tags_list: Option<Vec<String>>,
}
#[derive(Serialize, ToSchema)]
pub struct BulkAddTagsSuccessResponse {
pub success: bool,
/// The subset of the input tokens that were actually tagged: files
/// that exist, aren't deleted, and are owned by the caller.
pub accepted_media_file_tokens: Vec<MediaFileToken>,
/// The tags from this request after upsert, with canonical tokens and
/// fresh use counts.
pub tags: Vec<TagDetails>,
}
@@ -1,33 +0,0 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::media_files::MediaFileToken;
use crate::tags::common::TagDetails;
// ── POST /v1/tags/media_files/bulk_list_tags ──
//
// POST rather than GET: the token list belongs in a request body, and
// GET request bodies are non-standard. The operation is still a pure
// read.
#[derive(Deserialize, ToSchema)]
pub struct BulkListMediaFileTagsRequest {
/// Media files to look up. Tokens with no tags come back with an
/// empty `tags` list.
pub media_file_tokens: Vec<MediaFileToken>,
}
#[derive(Serialize, ToSchema)]
pub struct MediaFileTagsEntry {
pub media_file_token: MediaFileToken,
pub tags: Vec<TagDetails>,
}
#[derive(Serialize, ToSchema)]
pub struct BulkListMediaFileTagsSuccessResponse {
pub success: bool,
/// One entry per requested media file token (deduped), in request order.
pub media_files: Vec<MediaFileTagsEntry>,
}
@@ -1,44 +0,0 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::media_files::MediaFileToken;
use crate::tags::common::TagDetails;
// ── POST /v1/tags/bulk_set ──
/// Exactly one of `maybe_tags` / `maybe_tags_list` must be set (400 if
/// both or neither). Tag text is trimmed and deduped case-insensitively;
/// empty entries are dropped. Unlike `bulk_add`, sanitizing down to zero
/// tags is allowed — it clears all tags from the listed media files.
/// Media file tokens are deduped; tokens the user doesn't own (or that
/// are deleted) are silently skipped.
#[derive(Deserialize, ToSchema)]
pub struct BulkSetTagsRequest {
/// Media files whose tag sets will be replaced.
pub media_file_tokens: Vec<MediaFileToken>,
/// Comma-separated tags, e.g. `"cats, Sci-Fi, wallpaper"`.
pub maybe_tags: Option<String>,
/// Tags as a list. Entries are still trimmed.
pub maybe_tags_list: Option<Vec<String>>,
}
#[derive(Serialize, ToSchema)]
pub struct BulkSetTagsSuccessResponse {
pub success: bool,
/// The subset of the input tokens that were actually updated: files
/// that exist, aren't deleted, and are owned by the caller.
pub accepted_media_file_tokens: Vec<MediaFileToken>,
/// The full tag set now on every accepted media file, with canonical
/// tokens and fresh use counts.
pub tags: Vec<TagDetails>,
/// How many previously-attached tag links were removed across all the
/// accepted media files because they weren't mentioned in the request.
/// (Orphaned tags are not deleted.)
pub removed_count: u64,
}
@@ -1,20 +0,0 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::media_files::MediaFileToken;
// ── POST /v1/tags/media_file/clear/{media_file_token} ──
#[derive(Deserialize, ToSchema)]
pub struct ClearMediaFileTagsPathInfo {
pub media_file_token: MediaFileToken,
}
#[derive(Serialize, ToSchema)]
pub struct ClearMediaFileTagsSuccessResponse {
pub success: bool,
/// How many tag links were removed from the media file. (Orphaned
/// tags are not deleted.)
pub removed_count: u64,
}
@@ -1,20 +0,0 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::tags::TagToken;
/// One tag as returned by every tags endpoint.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct TagDetails {
pub tag_token: TagToken,
/// The display value of the tag, as entered by its creator.
pub tag_value: String,
/// Lowercased form of `tag_value`. This is the tag's unique key
/// within the creator's account.
pub tag_value_lowercase: String,
/// Rollup statistic: how many media files currently carry this tag.
pub use_count: u32,
}
@@ -1,20 +0,0 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::tags::TagToken;
// ── DELETE /v1/tags/{tag_token} ──
#[derive(Deserialize, ToSchema)]
pub struct DeleteTagPathInfo {
pub tag_token: TagToken,
}
#[derive(Serialize, ToSchema)]
pub struct DeleteTagSuccessResponse {
pub success: bool,
/// How many media-file links were hard-deleted along with the tag.
/// (The tag record itself is soft-deleted.)
pub removed_link_count: u64,
}
@@ -1,21 +0,0 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::media_files::MediaFileToken;
use crate::tags::common::TagDetails;
// ── GET /v1/tags/media_file/list/{media_file_token} ──
#[derive(Deserialize, ToSchema)]
pub struct ListMediaFileTagsPathInfo {
pub media_file_token: MediaFileToken,
}
#[derive(Serialize, ToSchema)]
pub struct ListMediaFileTagsSuccessResponse {
pub success: bool,
/// All (live) tags on the media file, sorted by tag value.
pub tags: Vec<TagDetails>,
}
@@ -1,22 +0,0 @@
use serde_derive::Deserialize;
use utoipa::{IntoParams, ToSchema};
use tokens::tokens::tags::TagToken;
// ── GET /v1/tags/media_files/with_tag/{tag_token} ──
//
// NB: The success response lives in `storyteller_web`'s handler because
// the wire shape embeds `MediaLinks` / `MediaFileCoverImageDetails`
// constructors that depend on the request's `MediaDomain` +
// `ServerEnvironment`.
#[derive(Deserialize, ToSchema)]
pub struct ListMediaFilesWithTagPathInfo {
pub tag_token: TagToken,
}
#[derive(Deserialize, ToSchema, IntoParams)]
pub struct ListMediaFilesWithTagQueryParams {
pub cursor: Option<String>,
pub limit: Option<u32>,
}
@@ -1,15 +0,0 @@
use serde_derive::Deserialize;
use utoipa::{IntoParams, ToSchema};
// ── GET /v1/tags/media_files/list_tagged ──
//
// NB: The success response lives in `storyteller_web`'s handler because
// the wire shape embeds `MediaLinks` / `MediaFileCoverImageDetails`
// constructors that depend on the request's `MediaDomain` +
// `ServerEnvironment`.
#[derive(Deserialize, ToSchema, IntoParams)]
pub struct ListTaggedMediaFilesQueryParams {
pub cursor: Option<String>,
pub limit: Option<u32>,
}
@@ -1,23 +0,0 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use crate::tags::common::TagDetails;
// ── GET /v1/tags/list ──
#[derive(Deserialize, ToSchema, IntoParams)]
pub struct ListTagsQueryParams {
pub cursor: Option<String>,
pub limit: Option<u32>,
}
#[derive(Serialize, ToSchema)]
pub struct ListTagsSuccessResponse {
pub success: bool,
/// The logged-in user's (live) tags, newest first.
pub tags: Vec<TagDetails>,
/// Present when there may be more results; pass back as `cursor`.
pub maybe_cursor: Option<String>,
}
@@ -1,15 +0,0 @@
use serde_derive::Deserialize;
use utoipa::{IntoParams, ToSchema};
// ── GET /v1/tags/media_files/list_untagged ──
//
// NB: The success response lives in `storyteller_web`'s handler because
// the wire shape embeds `MediaLinks` / `MediaFileCoverImageDetails`
// constructors that depend on the request's `MediaDomain` +
// `ServerEnvironment`.
#[derive(Deserialize, ToSchema, IntoParams)]
pub struct ListUntaggedMediaFilesQueryParams {
pub cursor: Option<String>,
pub limit: Option<u32>,
}
@@ -1,14 +0,0 @@
pub mod add_media_file_tags;
pub mod bulk_add_tags;
pub mod bulk_list_media_file_tags;
pub mod bulk_set_tags;
pub mod clear_media_file_tags;
pub mod common;
pub mod delete_tag;
pub mod list_media_file_tags;
pub mod list_media_files_with_tag;
pub mod list_tagged_media_files;
pub mod list_tags;
pub mod list_untagged_media_files;
pub mod rename_tag;
pub mod set_media_file_tags;
@@ -1,29 +0,0 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::tags::TagToken;
use crate::tags::common::TagDetails;
// ── PUT /v1/tags/rename/{tag_token} ──
#[derive(Deserialize, ToSchema)]
pub struct RenameTagPathInfo {
pub tag_token: TagToken,
}
#[derive(Deserialize, ToSchema)]
pub struct RenameTagRequest {
/// The new tag text (trimmed server-side). Both case-only changes and
/// wholesale renames are allowed. Fails with 400 if the caller already
/// has a different tag with the same lowercased value.
pub new_tag_value: String,
}
#[derive(Serialize, ToSchema)]
pub struct RenameTagSuccessResponse {
pub success: bool,
/// The tag after the rename.
pub tag: TagDetails,
}
@@ -1,39 +0,0 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::media_files::MediaFileToken;
use crate::tags::common::TagDetails;
// ── POST /v1/tags/media_file/set/{media_file_token} ──
#[derive(Deserialize, ToSchema)]
pub struct SetMediaFileTagsPathInfo {
pub media_file_token: MediaFileToken,
}
/// Exactly one of `maybe_tags` / `maybe_tags_list` must be set (400 if
/// both or neither). Tag text is trimmed and deduped case-insensitively;
/// empty entries are dropped. Unlike `add`, sanitizing down to zero tags
/// is allowed — it clears all tags from the media file.
#[derive(Deserialize, ToSchema)]
pub struct SetMediaFileTagsRequest {
/// Comma-separated tags, e.g. `"cats, Sci-Fi, wallpaper"`.
pub maybe_tags: Option<String>,
/// Tags as a list. Entries are still trimmed.
pub maybe_tags_list: Option<Vec<String>>,
}
#[derive(Serialize, ToSchema)]
pub struct SetMediaFileTagsSuccessResponse {
pub success: bool,
/// The media file's full tag set after the operation, with canonical
/// tokens and fresh use counts.
pub tags: Vec<TagDetails>,
/// How many previously-attached tag links were removed because they
/// weren't mentioned in the request. (Orphaned tags are not deleted.)
pub removed_count: u64,
}
@@ -41,10 +41,6 @@ pub enum RouterImageModel {
Seedream4p5,
#[serde(rename = "seedream_5_lite")]
Seedream5Lite,
#[serde(rename = "seedream_5p0_pro")]
Seedream5p0Pro,
#[serde(rename = "seedream_5p0_pro_u")]
Seedream5p0ProUltra,
#[serde(rename = "qwen_edit_2511_angles")]
QwenEdit2511Angles,
#[serde(rename = "flux_2_lora_angles")]
@@ -77,14 +73,4 @@ mod tests {
fn midjourney_8_serializes_to_midjourney_8() {
assert_round_trip(RouterImageModel::Midjourney8, "midjourney_8");
}
#[test]
fn seedream_5p0_pro_serializes_to_seedream_5p0_pro() {
assert_round_trip(RouterImageModel::Seedream5p0Pro, "seedream_5p0_pro");
}
#[test]
fn seedream_5p0_pro_ultra_serializes_to_seedream_5p0_pro_u() {
assert_round_trip(RouterImageModel::Seedream5p0ProUltra, "seedream_5p0_pro_u");
}
}
@@ -26,8 +26,6 @@ use crate::generate::generate_image::providers::artcraft::qwen_edit_2511_angles:
use crate::generate::generate_image::providers::artcraft::seedream_4::build::build_artcraft_seedream_4;
use crate::generate::generate_image::providers::artcraft::seedream_4p5::build::build_artcraft_seedream_4p5;
use crate::generate::generate_image::providers::artcraft::seedream_5_lite::build::build_artcraft_seedream_5_lite;
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro::build::build_artcraft_seedream_5p0_pro;
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro_ultra::build::build_artcraft_seedream_5p0_pro_ultra;
use crate::generate::generate_image::providers::fal::flux_1_dev::build::build_fal_flux_1_dev;
use crate::generate::generate_image::providers::fal::flux_1_schnell::build::build_fal_flux_1_schnell;
use crate::generate::generate_image::providers::fal::flux_2_lora_angles::build::build_fal_flux_2_lora_angles;
@@ -46,7 +44,6 @@ use crate::generate::generate_image::providers::fal::seedream_5_lite::build::bui
use crate::generate::generate_image::providers::kinovi::midjourney_7::build::build_kinovi_midjourney_7;
use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::build::build_kinovi_midjourney_7_niji;
use crate::generate::generate_image::providers::kinovi::midjourney_8::build::build_kinovi_midjourney_8;
use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::build::build_kinovi_seedream_5p0_pro;
#[derive(Clone, Debug)]
pub struct GenerateImageRequestBuilder {
@@ -122,8 +119,6 @@ impl GenerateImageRequestBuilder {
(RouterProvider::Artcraft, RouterImageModel::Seedream4) => build_artcraft_seedream_4(self),
(RouterProvider::Artcraft, RouterImageModel::Seedream4p5) => build_artcraft_seedream_4p5(self),
(RouterProvider::Artcraft, RouterImageModel::Seedream5Lite) => build_artcraft_seedream_5_lite(self),
(RouterProvider::Artcraft, RouterImageModel::Seedream5p0Pro) => build_artcraft_seedream_5p0_pro(self),
(RouterProvider::Artcraft, RouterImageModel::Seedream5p0ProUltra) => build_artcraft_seedream_5p0_pro_ultra(self),
(RouterProvider::Artcraft, RouterImageModel::QwenEdit2511Angles) => build_artcraft_qwen_edit_2511_angles(self),
(RouterProvider::Artcraft, RouterImageModel::Flux2LoraAngles) => build_artcraft_flux_2_lora_angles(self),
(RouterProvider::Artcraft, RouterImageModel::Midjourney7) => build_artcraft_midjourney_7(self),
@@ -146,12 +141,10 @@ impl GenerateImageRequestBuilder {
(RouterProvider::Fal, RouterImageModel::QwenEdit2511Angles) => build_fal_qwen_edit_2511_angles(self),
(RouterProvider::Fal, RouterImageModel::Flux2LoraAngles) => build_fal_flux_2_lora_angles(self),
// Kinovi / Seedance2Pro (Midjourney + Seedream)
// Kinovi / Seedance2Pro (Midjourney)
(RouterProvider::Seedance2Pro, RouterImageModel::Midjourney7) => build_kinovi_midjourney_7(self),
(RouterProvider::Seedance2Pro, RouterImageModel::Midjourney7Niji) => build_kinovi_midjourney_7_niji(self),
(RouterProvider::Seedance2Pro, RouterImageModel::Midjourney8) => build_kinovi_midjourney_8(self),
(RouterProvider::Seedance2Pro, RouterImageModel::Seedream5p0Pro) => build_kinovi_seedream_5p0_pro(self),
(RouterProvider::Seedance2Pro, RouterImageModel::Seedream5p0ProUltra) => build_kinovi_seedream_5p0_pro(self),
_ => self.unsupported_provider_and_model(),
}
@@ -9,14 +9,12 @@ use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::cost:
use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::draft::KinoviMidjourney7NijiDraftState;
use crate::generate::generate_image::providers::kinovi::midjourney_8::cost::KinoviMidjourney8CostState;
use crate::generate::generate_image::providers::kinovi::midjourney_8::draft::KinoviMidjourney8DraftState;
use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::cost::KinoviSeedream5p0ProCostState;
use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::draft::KinoviSeedream5p0ProDraftState;
/// Wrapper for all image generation draft requests.
///
/// Drafts hold validated/planned parameters but may still need media
/// resolution (e.g. uploading local files to a provider) before they can
/// be sent. Today only the Kinovi models need this — when the caller
/// be sent. Today only Kinovi/Midjourney needs this — when the caller
/// supplies reference images, we must upload them to the Seedance2Pro
/// CDN before sending the request.
#[derive(Clone, Debug)]
@@ -24,7 +22,6 @@ pub enum ImageGenerationDraftRequest {
KinoviMidjourney7(KinoviMidjourney7DraftState),
KinoviMidjourney7Niji(KinoviMidjourney7NijiDraftState),
KinoviMidjourney8(KinoviMidjourney8DraftState),
KinoviSeedream5p0Pro(KinoviSeedream5p0ProDraftState),
}
impl ImageGenerationDraftRequest {
@@ -33,7 +30,6 @@ impl ImageGenerationDraftRequest {
Self::KinoviMidjourney7(_) => RouterProvider::Seedance2Pro,
Self::KinoviMidjourney7Niji(_) => RouterProvider::Seedance2Pro,
Self::KinoviMidjourney8(_) => RouterProvider::Seedance2Pro,
Self::KinoviSeedream5p0Pro(_) => RouterProvider::Seedance2Pro,
}
}
@@ -42,7 +38,6 @@ impl ImageGenerationDraftRequest {
Self::KinoviMidjourney7(draft) => Ok(KinoviMidjourney7CostState::from_draft(draft).estimate_cost()),
Self::KinoviMidjourney7Niji(draft) => Ok(KinoviMidjourney7NijiCostState::from_draft(draft).estimate_cost()),
Self::KinoviMidjourney8(draft) => Ok(KinoviMidjourney8CostState::from_draft(draft).estimate_cost()),
Self::KinoviSeedream5p0Pro(draft) => Ok(KinoviSeedream5p0ProCostState::from_draft(draft).estimate_cost()),
}
}
@@ -60,10 +55,6 @@ impl ImageGenerationDraftRequest {
let result = draft.to_request(&draft_context).await?;
Ok(ImageGenerationRequest::KinoviMidjourney8(result))
}
Self::KinoviSeedream5p0Pro(mut draft) => {
let result = draft.to_request(&draft_context).await?;
Ok(ImageGenerationRequest::KinoviSeedream5p0Pro(result))
}
}
}
}
@@ -39,10 +39,6 @@ use crate::generate::generate_image::providers::artcraft::seedream_4p5::cost::Ar
use crate::generate::generate_image::providers::artcraft::seedream_4p5::request::ArtcraftSeedream4p5RequestState;
use crate::generate::generate_image::providers::artcraft::seedream_5_lite::cost::ArtcraftSeedream5LiteCostState;
use crate::generate::generate_image::providers::artcraft::seedream_5_lite::request::ArtcraftSeedream5LiteRequestState;
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro::cost::ArtcraftSeedream5p0ProCostState;
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro::request::ArtcraftSeedream5p0ProRequestState;
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro_ultra::cost::ArtcraftSeedream5p0ProUltraCostState;
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro_ultra::request::ArtcraftSeedream5p0ProUltraRequestState;
use crate::generate::generate_image::providers::fal::flux_1_dev::cost::FalFlux1DevCostState;
use crate::generate::generate_image::providers::fal::flux_1_dev::request::FalFlux1DevRequestState;
use crate::generate::generate_image::providers::fal::flux_1_schnell::cost::FalFlux1SchnellCostState;
@@ -79,8 +75,6 @@ use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::cost:
use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::request::KinoviMidjourney7NijiRequestState;
use crate::generate::generate_image::providers::kinovi::midjourney_8::cost::KinoviMidjourney8CostState;
use crate::generate::generate_image::providers::kinovi::midjourney_8::request::KinoviMidjourney8RequestState;
use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::cost::KinoviSeedream5p0ProCostState;
use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::request::KinoviSeedream5p0ProRequestState;
#[derive(Clone, Debug)]
pub enum ImageGenerationRequest {
@@ -98,8 +92,6 @@ pub enum ImageGenerationRequest {
ArtcraftSeedream4(ArtcraftSeedream4RequestState),
ArtcraftSeedream4p5(ArtcraftSeedream4p5RequestState),
ArtcraftSeedream5Lite(ArtcraftSeedream5LiteRequestState),
ArtcraftSeedream5p0Pro(ArtcraftSeedream5p0ProRequestState),
ArtcraftSeedream5p0ProUltra(ArtcraftSeedream5p0ProUltraRequestState),
ArtcraftQwenEdit2511Angles(ArtcraftQwenEdit2511AnglesRequestState),
ArtcraftFlux2LoraAngles(ArtcraftFlux2LoraAnglesRequestState),
ArtcraftMidjourney7(ArtcraftMidjourney7RequestState),
@@ -123,11 +115,10 @@ pub enum ImageGenerationRequest {
FalQwenEdit2511Angles(FalQwenEdit2511AnglesRequestState),
FalFlux2LoraAngles(FalFlux2LoraAnglesRequestState),
// ── Kinovi / Seedance2Pro provider (Midjourney + Seedream image generation) ──
// ── Kinovi / Seedance2Pro provider (Midjourney image generation) ──
KinoviMidjourney7(KinoviMidjourney7RequestState),
KinoviMidjourney7Niji(KinoviMidjourney7NijiRequestState),
KinoviMidjourney8(KinoviMidjourney8RequestState),
KinoviSeedream5p0Pro(KinoviSeedream5p0ProRequestState),
}
impl ImageGenerationRequest {
@@ -146,8 +137,6 @@ impl ImageGenerationRequest {
Self::ArtcraftSeedream4(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedream4p5(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedream5Lite(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedream5p0Pro(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedream5p0ProUltra(_) => RouterProvider::Artcraft,
Self::ArtcraftQwenEdit2511Angles(_) => RouterProvider::Artcraft,
Self::ArtcraftFlux2LoraAngles(_) => RouterProvider::Artcraft,
Self::ArtcraftMidjourney7(_) => RouterProvider::Artcraft,
@@ -173,7 +162,6 @@ impl ImageGenerationRequest {
Self::KinoviMidjourney7(_) => RouterProvider::Seedance2Pro,
Self::KinoviMidjourney7Niji(_) => RouterProvider::Seedance2Pro,
Self::KinoviMidjourney8(_) => RouterProvider::Seedance2Pro,
Self::KinoviSeedream5p0Pro(_) => RouterProvider::Seedance2Pro,
}
}
@@ -219,12 +207,6 @@ impl ImageGenerationRequest {
Self::ArtcraftSeedream5Lite(request) => {
Ok(ArtcraftSeedream5LiteCostState::from_request(request).estimate_cost())
}
Self::ArtcraftSeedream5p0Pro(request) => {
Ok(ArtcraftSeedream5p0ProCostState::from_request(request).estimate_cost())
}
Self::ArtcraftSeedream5p0ProUltra(request) => {
Ok(ArtcraftSeedream5p0ProUltraCostState::from_request(request).estimate_cost())
}
Self::ArtcraftQwenEdit2511Angles(request) => {
Ok(ArtcraftQwenEdit2511AnglesCostState::from_request(request).estimate_cost())
}
@@ -261,7 +243,6 @@ impl ImageGenerationRequest {
Self::KinoviMidjourney7(request) => Ok(KinoviMidjourney7CostState::from_request(request).estimate_cost()),
Self::KinoviMidjourney7Niji(request) => Ok(KinoviMidjourney7NijiCostState::from_request(request).estimate_cost()),
Self::KinoviMidjourney8(request) => Ok(KinoviMidjourney8CostState::from_request(request).estimate_cost()),
Self::KinoviSeedream5p0Pro(request) => Ok(KinoviSeedream5p0ProCostState::from_request(request).estimate_cost()),
}
}
@@ -320,14 +301,6 @@ impl ImageGenerationRequest {
let artcraft_client = client.get_artcraft_client_ref()?;
request.send(artcraft_client).await
}
Self::ArtcraftSeedream5p0Pro(request) => {
let artcraft_client = client.get_artcraft_client_ref()?;
request.send(artcraft_client).await
}
Self::ArtcraftSeedream5p0ProUltra(request) => {
let artcraft_client = client.get_artcraft_client_ref()?;
request.send(artcraft_client).await
}
Self::ArtcraftQwenEdit2511Angles(request) => {
let artcraft_client = client.get_artcraft_client_ref()?;
request.send(artcraft_client).await
@@ -416,7 +389,7 @@ impl ImageGenerationRequest {
request.send(fal_client).await
}
// ── Kinovi / Seedance2Pro (Midjourney + Seedream) ──
// ── Kinovi / Seedance2Pro (Midjourney) ──
Self::KinoviMidjourney7(request) => {
let seedance_client = client.get_seedance2pro_client_ref()
.map_err(ArtcraftRouterError::Client)?;
@@ -432,11 +405,6 @@ impl ImageGenerationRequest {
.map_err(ArtcraftRouterError::Client)?;
request.send(seedance_client).await
}
Self::KinoviSeedream5p0Pro(request) => {
let seedance_client = client.get_seedance2pro_client_ref()
.map_err(ArtcraftRouterError::Client)?;
request.send(seedance_client).await
}
}
}
}
@@ -19,5 +19,3 @@ pub(crate) mod request_common;
pub mod seedream_4;
pub mod seedream_4p5;
pub mod seedream_5_lite;
pub mod seedream_5p0_pro;
pub mod seedream_5p0_pro_ultra;
@@ -1,17 +0,0 @@
use enums::common::generation::common_image_model::CommonImageModel as CommonImageModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
use crate::generate::generate_image::image_generation_request::ImageGenerationRequest;
use crate::generate::generate_image::providers::artcraft::build_common::build_artcraft_omni_image_request;
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro::request::ArtcraftSeedream5p0ProRequestState;
pub fn build_artcraft_seedream_5p0_pro(
builder: GenerateImageRequestBuilder,
) -> Result<ImageGenerationDraftOrRequest, ArtcraftRouterError> {
let request = build_artcraft_omni_image_request(builder, CommonImageModelEnum::Seedream5p0Pro)?;
Ok(ImageGenerationDraftOrRequest::Request(
ImageGenerationRequest::ArtcraftSeedream5p0Pro(ArtcraftSeedream5p0ProRequestState { request }),
))
}
@@ -1,187 +0,0 @@
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro::request::ArtcraftSeedream5p0ProRequestState;
/// Customer-facing price per image at 1k resolution (the default), in USD cents.
const USD_CENTS_PER_IMAGE_1K: u64 = 7;
/// Customer-facing price per image at 2k resolution, in USD cents.
const USD_CENTS_PER_IMAGE_2K: u64 = 12;
/// Cost state for Artcraft Seedream 5.0 Pro. Pricing is per image and
/// resolution-dependent:
///
/// 1K (default) → 7¢, 2K → 12¢. 3K/4K fall back to 2K pricing; 0.5K and
/// legacy video resolutions (480p/720p/1080p) fall back to 1K.
#[derive(Clone, Debug)]
pub struct ArtcraftSeedream5p0ProCostState {
pub resolution: Option<CommonResolutionEnum>,
pub num_images: u16,
}
impl ArtcraftSeedream5p0ProCostState {
pub fn from_request(request: &ArtcraftSeedream5p0ProRequestState) -> Self {
Self {
resolution: request.request.resolution,
num_images: request.request.image_batch_count.unwrap_or(1),
}
}
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
let cost_per_image: u64 = cost_per_image_in_cents(self.resolution);
let cost_in_usd_cents = cost_per_image * self.num_images as u64;
ImageGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
fn cost_per_image_in_cents(resolution: Option<CommonResolutionEnum>) -> u64 {
use CommonResolutionEnum as R;
match resolution {
None
| Some(R::HalfK)
| Some(R::OneK)
| Some(R::FourEightyP)
| Some(R::SevenTwentyP)
| Some(R::TenEightyP) => USD_CENTS_PER_IMAGE_1K,
Some(R::TwoK) | Some(R::ThreeK) | Some(R::FourK) => USD_CENTS_PER_IMAGE_2K,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::router_image_model::RouterImageModel;
use crate::api::router_provider::RouterProvider;
use crate::api::router_resolution::RouterResolution;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
fn estimate(resolution: Option<RouterResolution>, image_batch_count: u16) -> ImageGenerationCostEstimate {
let builder = GenerateImageRequestBuilder {
model: RouterImageModel::Seedream5p0Pro,
provider: RouterProvider::Artcraft,
prompt: Some("an anime girl riding a dinosaur".to_string()),
image_inputs: None,
resolution,
aspect_ratio: None,
quality: None,
image_batch_count: Some(image_batch_count),
horizontal_angle: None,
vertical_angle: None,
zoom: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
};
builder.build2().unwrap().estimate_cost().unwrap()
}
fn cost_cents(resolution: Option<RouterResolution>, image_batch_count: u16) -> u64 {
estimate(resolution, image_batch_count).cost_in_usd_cents.unwrap()
}
// ── Default / 1K (7¢) ──
#[test]
fn default_resolution_one_image_is_7c() { assert_eq!(cost_cents(None, 1), 7); }
#[test]
fn one_k_one_image_is_7c() { assert_eq!(cost_cents(Some(RouterResolution::OneK), 1), 7); }
#[test]
fn one_k_four_images_is_28c() { assert_eq!(cost_cents(Some(RouterResolution::OneK), 4), 28); }
// ── 2K (12¢) ──
#[test]
fn two_k_one_image_is_12c() { assert_eq!(cost_cents(Some(RouterResolution::TwoK), 1), 12); }
#[test]
fn two_k_four_images_is_48c() { assert_eq!(cost_cents(Some(RouterResolution::TwoK), 4), 48); }
// ── Fallbacks ──
#[test]
fn four_k_falls_back_to_two_k_pricing() {
assert_eq!(
ArtcraftSeedream5p0ProCostState {
resolution: Some(CommonResolutionEnum::FourK),
num_images: 1,
}.estimate_cost().cost_in_usd_cents,
Some(12),
);
}
#[test]
fn half_k_falls_back_to_one_k_pricing() {
assert_eq!(
ArtcraftSeedream5p0ProCostState {
resolution: Some(CommonResolutionEnum::HalfK),
num_images: 1,
}.estimate_cost().cost_in_usd_cents,
Some(7),
);
}
// ── Credits track USD cents exactly (1 credit = 1 cent) ──
#[test]
fn credits_equal_usd_cents() {
for batch in 1..=4u16 {
let one_k = estimate(Some(RouterResolution::OneK), batch);
assert_eq!(one_k.cost_in_credits, one_k.cost_in_usd_cents, "1k batch {}", batch);
let two_k = estimate(Some(RouterResolution::TwoK), batch);
assert_eq!(two_k.cost_in_credits, two_k.cost_in_usd_cents, "2k batch {}", batch);
}
}
// ── The Artcraft price must cover the Kinovi provider cost ──
#[test]
fn price_covers_kinovi_cost() {
use seedance2pro_client::generate::image::generate_seedream_5p0_pro::{
KinoviSeedream5p0ProBatchCount, KinoviSeedream5p0ProResolution,
};
use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::cost::KinoviSeedream5p0ProCostState;
let cases = [
(RouterResolution::OneK, KinoviSeedream5p0ProResolution::OneK, 1u16, KinoviSeedream5p0ProBatchCount::One),
(RouterResolution::OneK, KinoviSeedream5p0ProResolution::OneK, 4u16, KinoviSeedream5p0ProBatchCount::Four),
(RouterResolution::TwoK, KinoviSeedream5p0ProResolution::TwoK, 1u16, KinoviSeedream5p0ProBatchCount::One),
(RouterResolution::TwoK, KinoviSeedream5p0ProResolution::TwoK, 4u16, KinoviSeedream5p0ProBatchCount::Four),
];
for (router_res, kinovi_res, batch, kinovi_batch) in cases {
let artcraft_cents = cost_cents(Some(router_res), batch);
let kinovi_cents = KinoviSeedream5p0ProCostState {
resolution: kinovi_res,
batch_count: kinovi_batch,
}.estimate_cost().cost_in_usd_cents.unwrap();
assert!(
artcraft_cents > kinovi_cents,
"artcraft price {}¢ must exceed kinovi cost {}¢ ({:?} batch {})",
artcraft_cents, kinovi_cents, router_res, batch,
);
}
}
// ── Flags ──
#[test]
fn cost_flags_are_correct() {
let cost = ArtcraftSeedream5p0ProCostState { resolution: None, num_images: 1 }.estimate_cost();
assert!(!cost.is_free);
assert!(!cost.is_unlimited);
assert!(!cost.is_rate_limited);
assert!(!cost.has_watermark);
assert_eq!(cost.failures_are_refunded, None);
}
}
@@ -1,3 +0,0 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -1,17 +0,0 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_image::generate_image_response::GenerateImageResponse;
use crate::generate::generate_image::providers::artcraft::request_common::send_artcraft_omni_image_request;
#[derive(Clone, Debug)]
pub struct ArtcraftSeedream5p0ProRequestState {
pub request: OmniGenImageCostAndGenerateRequest,
}
impl ArtcraftSeedream5p0ProRequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateImageResponse, ArtcraftRouterError> {
send_artcraft_omni_image_request(&self.request, client).await
}
}
@@ -1,17 +0,0 @@
use enums::common::generation::common_image_model::CommonImageModel as CommonImageModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
use crate::generate::generate_image::image_generation_request::ImageGenerationRequest;
use crate::generate::generate_image::providers::artcraft::build_common::build_artcraft_omni_image_request;
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro_ultra::request::ArtcraftSeedream5p0ProUltraRequestState;
pub fn build_artcraft_seedream_5p0_pro_ultra(
builder: GenerateImageRequestBuilder,
) -> Result<ImageGenerationDraftOrRequest, ArtcraftRouterError> {
let request = build_artcraft_omni_image_request(builder, CommonImageModelEnum::Seedream5p0ProUltra)?;
Ok(ImageGenerationDraftOrRequest::Request(
ImageGenerationRequest::ArtcraftSeedream5p0ProUltra(ArtcraftSeedream5p0ProUltraRequestState { request }),
))
}
@@ -1,208 +0,0 @@
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro_ultra::request::ArtcraftSeedream5p0ProUltraRequestState;
/// Customer-facing price per image at 1k resolution (the default), in USD cents.
const USD_CENTS_PER_IMAGE_1K: u64 = 18;
/// Customer-facing price per image at 2k resolution, in USD cents.
const USD_CENTS_PER_IMAGE_2K: u64 = 30;
/// Cost state for Artcraft Seedream 5.0 Pro Ultra. Pricing is per image and
/// resolution-dependent:
///
/// 1K (default) → 18¢, 2K → 30¢. 3K/4K fall back to 2K pricing; 0.5K and
/// legacy video resolutions (480p/720p/1080p) fall back to 1K.
#[derive(Clone, Debug)]
pub struct ArtcraftSeedream5p0ProUltraCostState {
pub resolution: Option<CommonResolutionEnum>,
pub num_images: u16,
}
impl ArtcraftSeedream5p0ProUltraCostState {
pub fn from_request(request: &ArtcraftSeedream5p0ProUltraRequestState) -> Self {
Self {
resolution: request.request.resolution,
num_images: request.request.image_batch_count.unwrap_or(1),
}
}
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
let cost_per_image: u64 = cost_per_image_in_cents(self.resolution);
let cost_in_usd_cents = cost_per_image * self.num_images as u64;
ImageGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
fn cost_per_image_in_cents(resolution: Option<CommonResolutionEnum>) -> u64 {
use CommonResolutionEnum as R;
match resolution {
None
| Some(R::HalfK)
| Some(R::OneK)
| Some(R::FourEightyP)
| Some(R::SevenTwentyP)
| Some(R::TenEightyP) => USD_CENTS_PER_IMAGE_1K,
Some(R::TwoK) | Some(R::ThreeK) | Some(R::FourK) => USD_CENTS_PER_IMAGE_2K,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::router_image_model::RouterImageModel;
use crate::api::router_provider::RouterProvider;
use crate::api::router_resolution::RouterResolution;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
fn estimate(resolution: Option<RouterResolution>, image_batch_count: u16) -> ImageGenerationCostEstimate {
let builder = GenerateImageRequestBuilder {
model: RouterImageModel::Seedream5p0ProUltra,
provider: RouterProvider::Artcraft,
prompt: Some("an anime girl riding a dinosaur".to_string()),
image_inputs: None,
resolution,
aspect_ratio: None,
quality: None,
image_batch_count: Some(image_batch_count),
horizontal_angle: None,
vertical_angle: None,
zoom: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
};
builder.build2().unwrap().estimate_cost().unwrap()
}
fn cost_cents(resolution: Option<RouterResolution>, image_batch_count: u16) -> u64 {
estimate(resolution, image_batch_count).cost_in_usd_cents.unwrap()
}
// ── Default / 1K (18¢) ──
#[test]
fn default_resolution_one_image_is_18c() { assert_eq!(cost_cents(None, 1), 18); }
#[test]
fn one_k_one_image_is_18c() { assert_eq!(cost_cents(Some(RouterResolution::OneK), 1), 18); }
#[test]
fn one_k_four_images_is_72c() { assert_eq!(cost_cents(Some(RouterResolution::OneK), 4), 72); }
// ── 2K (30¢) ──
#[test]
fn two_k_one_image_is_30c() { assert_eq!(cost_cents(Some(RouterResolution::TwoK), 1), 30); }
#[test]
fn two_k_four_images_is_120c() { assert_eq!(cost_cents(Some(RouterResolution::TwoK), 4), 120); }
// ── Fallbacks ──
#[test]
fn four_k_falls_back_to_two_k_pricing() {
assert_eq!(
ArtcraftSeedream5p0ProUltraCostState {
resolution: Some(CommonResolutionEnum::FourK),
num_images: 1,
}.estimate_cost().cost_in_usd_cents,
Some(30),
);
}
#[test]
fn half_k_falls_back_to_one_k_pricing() {
assert_eq!(
ArtcraftSeedream5p0ProUltraCostState {
resolution: Some(CommonResolutionEnum::HalfK),
num_images: 1,
}.estimate_cost().cost_in_usd_cents,
Some(18),
);
}
// ── Credits track USD cents exactly (1 credit = 1 cent) ──
#[test]
fn credits_equal_usd_cents() {
for batch in 1..=4u16 {
let one_k = estimate(Some(RouterResolution::OneK), batch);
assert_eq!(one_k.cost_in_credits, one_k.cost_in_usd_cents, "1k batch {}", batch);
let two_k = estimate(Some(RouterResolution::TwoK), batch);
assert_eq!(two_k.cost_in_credits, two_k.cost_in_usd_cents, "2k batch {}", batch);
}
}
// ── The price must exceed the base Seedream 5.0 Pro price ──
#[test]
fn price_exceeds_base_seedream_5p0_pro_price() {
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro::cost::ArtcraftSeedream5p0ProCostState;
for resolution in [None, Some(CommonResolutionEnum::OneK), Some(CommonResolutionEnum::TwoK)] {
for num_images in [1u16, 4] {
let ultra_cents = ArtcraftSeedream5p0ProUltraCostState { resolution, num_images }
.estimate_cost().cost_in_usd_cents.unwrap();
let base_cents = ArtcraftSeedream5p0ProCostState { resolution, num_images }
.estimate_cost().cost_in_usd_cents.unwrap();
assert!(
ultra_cents > base_cents,
"ultra price {}¢ must exceed base price {}¢ ({:?} batch {})",
ultra_cents, base_cents, resolution, num_images,
);
}
}
}
// ── The Artcraft price must cover the Kinovi provider cost ──
#[test]
fn price_covers_kinovi_cost() {
use seedance2pro_client::generate::image::generate_seedream_5p0_pro::{
KinoviSeedream5p0ProBatchCount, KinoviSeedream5p0ProResolution,
};
use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::cost::KinoviSeedream5p0ProCostState;
let cases = [
(RouterResolution::OneK, KinoviSeedream5p0ProResolution::OneK, 1u16, KinoviSeedream5p0ProBatchCount::One),
(RouterResolution::OneK, KinoviSeedream5p0ProResolution::OneK, 4u16, KinoviSeedream5p0ProBatchCount::Four),
(RouterResolution::TwoK, KinoviSeedream5p0ProResolution::TwoK, 1u16, KinoviSeedream5p0ProBatchCount::One),
(RouterResolution::TwoK, KinoviSeedream5p0ProResolution::TwoK, 4u16, KinoviSeedream5p0ProBatchCount::Four),
];
for (router_res, kinovi_res, batch, kinovi_batch) in cases {
let artcraft_cents = cost_cents(Some(router_res), batch);
let kinovi_cents = KinoviSeedream5p0ProCostState {
resolution: kinovi_res,
batch_count: kinovi_batch,
}.estimate_cost().cost_in_usd_cents.unwrap();
assert!(
artcraft_cents > kinovi_cents,
"artcraft price {}¢ must exceed kinovi cost {}¢ ({:?} batch {})",
artcraft_cents, kinovi_cents, router_res, batch,
);
}
}
// ── Flags ──
#[test]
fn cost_flags_are_correct() {
let cost = ArtcraftSeedream5p0ProUltraCostState { resolution: None, num_images: 1 }.estimate_cost();
assert!(!cost.is_free);
assert!(!cost.is_unlimited);
assert!(!cost.is_rate_limited);
assert!(!cost.has_watermark);
assert_eq!(cost.failures_are_refunded, None);
}
}
@@ -1,3 +0,0 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -1,17 +0,0 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_image::generate_image_response::GenerateImageResponse;
use crate::generate::generate_image::providers::artcraft::request_common::send_artcraft_omni_image_request;
#[derive(Clone, Debug)]
pub struct ArtcraftSeedream5p0ProUltraRequestState {
pub request: OmniGenImageCostAndGenerateRequest,
}
impl ArtcraftSeedream5p0ProUltraRequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateImageResponse, ArtcraftRouterError> {
send_artcraft_omni_image_request(&self.request, client).await
}
}
@@ -1,7 +1,6 @@
pub mod midjourney_7;
pub mod midjourney_7_niji;
pub mod midjourney_8;
pub mod seedream_5p0_pro;
pub(crate) mod resolve;
pub(crate) mod upload;
@@ -1,408 +0,0 @@
use seedance2pro_client::generate::image::generate_seedream_5p0_pro::{
GenerateSeedream5p0ProRequest, KinoviSeedream5p0ProAspectRatio, KinoviSeedream5p0ProBatchCount,
KinoviSeedream5p0ProResolution, MAX_REFERENCE_IMAGES,
};
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_resolution::RouterResolution;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest;
use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
use crate::generate::generate_image::image_generation_request::ImageGenerationRequest;
use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::draft::{
KinoviSeedream5p0ProDraftState, KinoviSeedream5p0ProRemainingItems,
};
use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::request::KinoviSeedream5p0ProRequestState;
pub fn build_kinovi_seedream_5p0_pro(
mut builder: GenerateImageRequestBuilder,
) -> Result<ImageGenerationDraftOrRequest, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?;
let resolution = plan_resolution(builder.resolution.take(), strategy)?;
let batch_count = plan_batch_count(builder.image_batch_count.take(), strategy)?;
let prompt = builder.prompt.take().unwrap_or_default();
let image_inputs = builder.image_inputs.take();
validate_reference_image_count(image_inputs.as_ref())?;
if !has_image_inputs(image_inputs.as_ref()) {
let request = GenerateSeedream5p0ProRequest {
prompt,
aspect_ratio,
resolution,
batch_count,
reference_image_urls: None,
};
return Ok(ImageGenerationDraftOrRequest::Request(
ImageGenerationRequest::KinoviSeedream5p0Pro(KinoviSeedream5p0ProRequestState { request }),
));
}
let draft = KinoviSeedream5p0ProDraftState {
prompt,
aspect_ratio,
resolution,
batch_count,
unhandled_request_state: Some(KinoviSeedream5p0ProRemainingItems {
reference_images: image_inputs,
}),
};
Ok(ImageGenerationDraftOrRequest::Draft(
ImageGenerationDraftRequest::KinoviSeedream5p0Pro(draft),
))
}
fn has_image_inputs(image_inputs: Option<&ImageListRef>) -> bool {
match image_inputs {
None => false,
Some(ImageListRef::Urls(urls)) => !urls.is_empty(),
Some(ImageListRef::MediaFileTokens(tokens)) => !tokens.is_empty(),
}
}
/// Reject over-limit reference lists before any uploads happen — the
/// Seedance2Pro client would reject them anyway, but only after the draft
/// stage has already uploaded every image to the Kinovi CDN.
fn validate_reference_image_count(
image_inputs: Option<&ImageListRef>,
) -> Result<(), ArtcraftRouterError> {
let count = match image_inputs {
None => 0,
Some(ImageListRef::Urls(urls)) => urls.len(),
Some(ImageListRef::MediaFileTokens(tokens)) => tokens.len(),
};
if count > MAX_REFERENCE_IMAGES {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_inputs",
value: format!("{} reference images (max {})", count, MAX_REFERENCE_IMAGES),
}));
}
Ok(())
}
// ── Plan helpers ──
pub(crate) fn plan_aspect_ratio(
aspect_ratio: Option<RouterAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<KinoviSeedream5p0ProAspectRatio, ArtcraftRouterError> {
use KinoviSeedream5p0ProAspectRatio as Ar;
match aspect_ratio {
None
| Some(RouterAspectRatio::Auto)
| Some(RouterAspectRatio::Auto2k)
| Some(RouterAspectRatio::Auto3k)
| Some(RouterAspectRatio::Auto4k) => Ok(Ar::Auto),
Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Ar::Square1x1),
Some(RouterAspectRatio::WideFourByThree) => Ok(Ar::Standard4x3),
Some(RouterAspectRatio::TallThreeByFour) => Ok(Ar::Portrait3x4),
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Ar::Landscape16x9),
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Ar::Portrait9x16),
Some(RouterAspectRatio::WideThreeByTwo) => Ok(Ar::Wide3x2),
Some(RouterAspectRatio::TallTwoByThree) => Ok(Ar::Tall2x3),
Some(RouterAspectRatio::WideTwentyOneByNine) => Ok(Ar::UltraWide21x9),
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "aspect_ratio",
value: format!("{:?}", unsupported),
}))
}
_ => Ok(Ar::Auto),
},
}
}
pub(crate) fn plan_resolution(
resolution: Option<RouterResolution>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<KinoviSeedream5p0ProResolution, ArtcraftRouterError> {
use KinoviSeedream5p0ProResolution as Res;
match resolution {
None | Some(RouterResolution::OneK) => Ok(Res::OneK),
Some(RouterResolution::TwoK) => Ok(Res::TwoK),
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "resolution",
value: format!("{:?}", unsupported),
}))
}
// Snap to the nearest supported tier: sub-1k inputs (0.5K, legacy
// video resolutions) land on 1k; 3K/4K land on 2k.
_ => match unsupported {
RouterResolution::ThreeK | RouterResolution::FourK => Ok(Res::TwoK),
_ => Ok(Res::OneK),
},
},
}
}
pub(crate) fn plan_batch_count(
image_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<KinoviSeedream5p0ProBatchCount, ArtcraftRouterError> {
let count = image_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(KinoviSeedream5p0ProBatchCount::One),
2 => Ok(KinoviSeedream5p0ProBatchCount::Two),
3 => Ok(KinoviSeedream5p0ProBatchCount::Three),
4 => Ok(KinoviSeedream5p0ProBatchCount::Four),
5 => Ok(KinoviSeedream5p0ProBatchCount::Five),
6 => Ok(KinoviSeedream5p0ProBatchCount::Six),
7 => Ok(KinoviSeedream5p0ProBatchCount::Seven),
8 => Ok(KinoviSeedream5p0ProBatchCount::Eight),
other => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_batch_count",
value: format!("{}", other),
}))
}
// Kinovi supports batches of 1–8; only counts of 9+ reach here, so both
// mitigation strategies clamp to the maximum supported batch.
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(KinoviSeedream5p0ProBatchCount::Eight)
}
},
}
}
#[cfg(test)]
mod tests {
use tokens::tokens::media_files::MediaFileToken;
use crate::api::router_image_model::RouterImageModel;
use crate::api::router_provider::RouterProvider;
use super::*;
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::Seedream5p0Pro,
provider: RouterProvider::Seedance2Pro,
prompt: Some("an anime girl riding a dinosaur".to_string()),
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
}
}
fn build_request(builder: GenerateImageRequestBuilder) -> KinoviSeedream5p0ProRequestState {
match build_kinovi_seedream_5p0_pro(builder).expect("build") {
ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviSeedream5p0Pro(r)) => r,
_ => panic!("expected Request"),
}
}
mod routing_tests {
use super::*;
#[test]
fn no_image_inputs_returns_request_directly() {
let result = build_kinovi_seedream_5p0_pro(base_builder()).expect("build");
assert!(matches!(
result,
ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviSeedream5p0Pro(_)),
));
}
#[test]
fn image_inputs_returns_draft() {
let builder = GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/ref.png".to_string()])),
..base_builder()
};
let result = build_kinovi_seedream_5p0_pro(builder).expect("build");
assert!(matches!(
result,
ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviSeedream5p0Pro(_)),
));
}
#[test]
fn media_file_tokens_route_through_draft() {
let builder = GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_x".to_string()),
])),
..base_builder()
};
let draft = match build_kinovi_seedream_5p0_pro(builder).expect("build") {
ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviSeedream5p0Pro(d)) => d,
_ => panic!("expected Draft"),
};
assert!(draft.unhandled_request_state.is_some());
}
#[test]
fn fourteen_reference_images_is_accepted() {
let urls: Vec<String> = (0..MAX_REFERENCE_IMAGES)
.map(|i| format!("https://example.com/ref{i}.png"))
.collect();
let builder = GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::Urls(urls)),
..base_builder()
};
assert!(build_kinovi_seedream_5p0_pro(builder).is_ok());
}
#[test]
fn fifteen_reference_images_is_rejected() {
let urls: Vec<String> = (0..MAX_REFERENCE_IMAGES + 1)
.map(|i| format!("https://example.com/ref{i}.png"))
.collect();
let builder = GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::Urls(urls)),
..base_builder()
};
assert!(build_kinovi_seedream_5p0_pro(builder).is_err());
}
}
mod plan_tests {
use super::*;
#[test]
fn default_aspect_ratio_is_auto() {
let req = build_request(base_builder());
assert_eq!(req.request.aspect_ratio, KinoviSeedream5p0ProAspectRatio::Auto);
}
#[test]
fn every_supported_aspect_ratio_maps() {
let cases = [
(RouterAspectRatio::Auto, KinoviSeedream5p0ProAspectRatio::Auto),
(RouterAspectRatio::Square, KinoviSeedream5p0ProAspectRatio::Square1x1),
(RouterAspectRatio::WideFourByThree, KinoviSeedream5p0ProAspectRatio::Standard4x3),
(RouterAspectRatio::TallThreeByFour, KinoviSeedream5p0ProAspectRatio::Portrait3x4),
(RouterAspectRatio::WideSixteenByNine, KinoviSeedream5p0ProAspectRatio::Landscape16x9),
(RouterAspectRatio::TallNineBySixteen, KinoviSeedream5p0ProAspectRatio::Portrait9x16),
(RouterAspectRatio::WideThreeByTwo, KinoviSeedream5p0ProAspectRatio::Wide3x2),
(RouterAspectRatio::TallTwoByThree, KinoviSeedream5p0ProAspectRatio::Tall2x3),
(RouterAspectRatio::WideTwentyOneByNine, KinoviSeedream5p0ProAspectRatio::UltraWide21x9),
];
for (input, expected) in cases {
let planned = plan_aspect_ratio(Some(input), RequestMismatchMitigationStrategy::ErrorOut)
.expect("plan");
assert_eq!(planned, expected, "input={:?}", input);
}
}
#[test]
fn unsupported_aspect_ratio_errors_out() {
let result = plan_aspect_ratio(
Some(RouterAspectRatio::TallNineByTwentyOne),
RequestMismatchMitigationStrategy::ErrorOut,
);
assert!(result.is_err());
}
#[test]
fn unsupported_aspect_ratio_falls_back_to_auto() {
let planned = plan_aspect_ratio(
Some(RouterAspectRatio::TallNineByTwentyOne),
RequestMismatchMitigationStrategy::PayLessDowngrade,
).expect("plan");
assert_eq!(planned, KinoviSeedream5p0ProAspectRatio::Auto);
}
#[test]
fn default_resolution_is_1k() {
let req = build_request(base_builder());
assert_eq!(req.request.resolution, KinoviSeedream5p0ProResolution::OneK);
}
#[test]
fn two_k_resolution_maps() {
let builder = GenerateImageRequestBuilder {
resolution: Some(RouterResolution::TwoK),
..base_builder()
};
let req = build_request(builder);
assert_eq!(req.request.resolution, KinoviSeedream5p0ProResolution::TwoK);
}
#[test]
fn four_k_resolution_errors_out() {
let result = plan_resolution(
Some(RouterResolution::FourK),
RequestMismatchMitigationStrategy::ErrorOut,
);
assert!(result.is_err());
}
#[test]
fn four_k_resolution_downgrades_to_2k() {
let planned = plan_resolution(
Some(RouterResolution::FourK),
RequestMismatchMitigationStrategy::PayLessDowngrade,
).expect("plan");
assert_eq!(planned, KinoviSeedream5p0ProResolution::TwoK);
}
#[test]
fn half_k_resolution_snaps_to_1k() {
let planned = plan_resolution(
Some(RouterResolution::HalfK),
RequestMismatchMitigationStrategy::PayMoreUpgrade,
).expect("plan");
assert_eq!(planned, KinoviSeedream5p0ProResolution::OneK);
}
#[test]
fn batch_counts_one_through_eight_are_accepted() {
let cases = [
(1, KinoviSeedream5p0ProBatchCount::One),
(2, KinoviSeedream5p0ProBatchCount::Two),
(3, KinoviSeedream5p0ProBatchCount::Three),
(4, KinoviSeedream5p0ProBatchCount::Four),
(5, KinoviSeedream5p0ProBatchCount::Five),
(6, KinoviSeedream5p0ProBatchCount::Six),
(7, KinoviSeedream5p0ProBatchCount::Seven),
(8, KinoviSeedream5p0ProBatchCount::Eight),
];
for (input, expected) in cases {
let planned = plan_batch_count(Some(input), RequestMismatchMitigationStrategy::ErrorOut)
.expect("plan");
assert_eq!(planned, expected, "input={}", input);
}
}
#[test]
fn batch_count_zero_is_rejected() {
assert!(plan_batch_count(Some(0), RequestMismatchMitigationStrategy::ErrorOut).is_err());
}
#[test]
fn batch_count_nine_errors_out() {
assert!(plan_batch_count(Some(9), RequestMismatchMitigationStrategy::ErrorOut).is_err());
}
#[test]
fn batch_count_nine_clamps_to_eight() {
let planned = plan_batch_count(Some(9), RequestMismatchMitigationStrategy::PayLessDowngrade)
.expect("plan");
assert_eq!(planned, KinoviSeedream5p0ProBatchCount::Eight);
}
}
}
@@ -1,100 +0,0 @@
use seedance2pro_client::generate::image::generate_seedream_5p0_pro::{
GenerateSeedream5p0ProRequest, KinoviSeedream5p0ProAspectRatio, KinoviSeedream5p0ProBatchCount,
KinoviSeedream5p0ProResolution,
};
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::draft::KinoviSeedream5p0ProDraftState;
use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::request::KinoviSeedream5p0ProRequestState;
/// Seedream 5.0 Pro (via Kinovi) cost state. Pricing is per image and
/// resolution-dependent (1k vs 2k); batch count multiplies.
pub struct KinoviSeedream5p0ProCostState {
pub resolution: KinoviSeedream5p0ProResolution,
pub batch_count: KinoviSeedream5p0ProBatchCount,
}
impl KinoviSeedream5p0ProCostState {
pub fn from_request(request: &KinoviSeedream5p0ProRequestState) -> Self {
Self {
resolution: request.request.resolution,
batch_count: request.request.batch_count,
}
}
pub fn from_draft(draft: &KinoviSeedream5p0ProDraftState) -> Self {
Self {
resolution: draft.resolution,
batch_count: draft.batch_count,
}
}
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
let pricing_request = GenerateSeedream5p0ProRequest {
prompt: String::new(),
aspect_ratio: KinoviSeedream5p0ProAspectRatio::Auto,
resolution: self.resolution,
batch_count: self.batch_count,
reference_image_urls: None,
};
let costs = pricing_request.calculate_costs();
ImageGenerationCostEstimate {
cost_in_credits: Some(costs.kinovi_credits),
cost_in_usd_cents: Some(costs.usd_cents_rounded_up),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn one_1k_image_is_fourteen_credits_and_six_cents() {
let cost = KinoviSeedream5p0ProCostState {
resolution: KinoviSeedream5p0ProResolution::OneK,
batch_count: KinoviSeedream5p0ProBatchCount::One,
}.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(14));
assert_eq!(cost.cost_in_usd_cents, Some(6)); // 1400/243 = 5.76 -> rounds UP
}
#[test]
fn one_2k_image_is_twentyfour_credits_and_ten_cents() {
let cost = KinoviSeedream5p0ProCostState {
resolution: KinoviSeedream5p0ProResolution::TwoK,
batch_count: KinoviSeedream5p0ProBatchCount::One,
}.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(24));
assert_eq!(cost.cost_in_usd_cents, Some(10)); // 2400/243 = 9.88 -> rounds UP
}
/// Mirrors capture 4_seedream_5_misc.txt: 2k, batch of 4 → 96 credits.
#[test]
fn four_2k_images_is_ninetysix_credits() {
let cost = KinoviSeedream5p0ProCostState {
resolution: KinoviSeedream5p0ProResolution::TwoK,
batch_count: KinoviSeedream5p0ProBatchCount::Four,
}.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(96));
assert_eq!(cost.cost_in_usd_cents, Some(40)); // 9600/243 = 39.51 -> rounds UP
}
/// Mirrors capture 5_seedream_5_misc2.txt: 1k, batch of 8 → 112 credits.
#[test]
fn eight_1k_images_is_one_hundred_twelve_credits() {
let cost = KinoviSeedream5p0ProCostState {
resolution: KinoviSeedream5p0ProResolution::OneK,
batch_count: KinoviSeedream5p0ProBatchCount::Eight,
}.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(112));
assert_eq!(cost.cost_in_usd_cents, Some(47)); // 11200/243 = 46.09 -> rounds UP
}
}
@@ -1,53 +0,0 @@
use seedance2pro_client::generate::image::generate_seedream_5p0_pro::{
GenerateSeedream5p0ProRequest, KinoviSeedream5p0ProAspectRatio, KinoviSeedream5p0ProBatchCount,
KinoviSeedream5p0ProResolution,
};
use crate::api::image_list_ref::ImageListRef;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext;
use crate::generate::generate_image::providers::kinovi::resolve::resolve_and_upload_image_list;
use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::request::KinoviSeedream5p0ProRequestState;
#[derive(Debug, Clone)]
pub struct KinoviSeedream5p0ProDraftState {
pub prompt: String,
pub aspect_ratio: KinoviSeedream5p0ProAspectRatio,
pub resolution: KinoviSeedream5p0ProResolution,
pub batch_count: KinoviSeedream5p0ProBatchCount,
pub unhandled_request_state: Option<KinoviSeedream5p0ProRemainingItems>,
}
#[derive(Debug, Clone)]
pub struct KinoviSeedream5p0ProRemainingItems {
pub reference_images: Option<ImageListRef>,
}
impl KinoviSeedream5p0ProDraftState {
pub async fn to_request(
&mut self,
draft_context: &ImageGenerationDraftContext<'_>,
) -> Result<KinoviSeedream5p0ProRequestState, ArtcraftRouterError> {
let client = draft_context.get_seedance2pro_client_ref()?;
let session = &client.session;
let mut reference_image_urls = None;
if let Some(remaining) = self.unhandled_request_state.take() {
let map = draft_context.media_file_to_artcraft_url_map;
reference_image_urls = resolve_and_upload_image_list(
session, remaining.reference_images, map,
).await?;
}
let request = GenerateSeedream5p0ProRequest {
prompt: self.prompt.clone(),
aspect_ratio: self.aspect_ratio,
resolution: self.resolution,
batch_count: self.batch_count,
reference_image_urls,
};
Ok(KinoviSeedream5p0ProRequestState { request })
}
}
@@ -1,4 +0,0 @@
pub mod build;
pub mod cost;
pub mod draft;
pub mod request;
@@ -1,141 +0,0 @@
use seedance2pro_client::generate::image::generate_seedream_5p0_pro::{
generate_seedream_5p0_pro, GenerateSeedream5p0ProArgs, GenerateSeedream5p0ProRequest,
};
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_image::generate_image_response::{
GenerateImageResponse, Seedance2proImageResponsePayload,
};
#[derive(Debug, Clone)]
pub struct KinoviSeedream5p0ProRequestState {
pub request: GenerateSeedream5p0ProRequest,
}
impl KinoviSeedream5p0ProRequestState {
pub async fn send(
&self,
client: &RouterSeedance2ProClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let args = GenerateSeedream5p0ProArgs {
session: &client.session,
host_override: None,
request: self.request.clone(),
};
let response = generate_seedream_5p0_pro(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
Ok(GenerateImageResponse::Seedance2Pro(Seedance2proImageResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
maybe_order_ids: response.order_ids,
maybe_task_ids: response.task_ids,
}))
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_image_model::RouterImageModel;
use crate::api::router_provider::RouterProvider;
use crate::api::router_resolution::RouterResolution;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::client::router_client::RouterClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest;
use crate::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext;
use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
use crate::generate::generate_image::image_generation_request::ImageGenerationRequest;
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::Seedream5p0Pro,
provider: RouterProvider::Seedance2Pro,
prompt: Some("an anime girl riding a dinosaur through a neon city".to_string()),
image_inputs: None,
resolution: Some(RouterResolution::OneK),
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
quality: None,
image_batch_count: Some(1),
horizontal_angle: None,
vertical_angle: None,
zoom: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
}
}
fn get_kinovi_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt")
.expect("Failed to read seedance2pro cookies");
let session = Seedance2ProSession::from_cookies_string(cookies.trim().to_string());
RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session))
}
#[tokio::test]
#[ignore] // manually test — requires real cookies; costs credits
async fn text_to_image_round_trip() {
let client = get_kinovi_client();
let result = base_builder().build2().expect("build2");
let request = match result {
ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviSeedream5p0Pro(r)) => r,
_ => panic!("expected direct Request for text-to-image"),
};
let response = request.send(client.get_seedance2pro_client_ref().unwrap())
.await.expect("send should succeed");
let payload = response.get_seedance2pro_payload().expect("expected seedance2pro payload");
println!("seedream 5.0 pro t2i — task_id={}, order_id={}", payload.task_id, payload.order_id);
assert!(!payload.task_id.is_empty());
}
#[tokio::test]
#[ignore] // manually test — requires real cookies; costs credits
async fn image_reference_round_trip_via_draft() {
let client = get_kinovi_client();
let token = MediaFileToken::new("mf_test_juno_seedream5".to_string());
let mut map = HashMap::new();
map.insert(token.clone(), JUNO_AT_LAKE_IMAGE_URL.to_string());
let builder = GenerateImageRequestBuilder {
prompt: Some("The dog in @Image1 wearing a tiny crown in a royal palace".to_string()),
image_inputs: Some(ImageListRef::MediaFileTokens(vec![token])),
..base_builder()
};
let draft_or_request = builder.build2().expect("build2");
let draft = match draft_or_request {
ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviSeedream5p0Pro(d)) => d,
_ => panic!("expected Draft for image-input flow"),
};
let ctx = ImageGenerationDraftContext {
client: Some(&client),
media_file_to_artcraft_url_map: Some(&map),
};
let request = ImageGenerationDraftRequest::KinoviSeedream5p0Pro(draft)
.finalize(ctx).await.expect("finalize");
let response = request.send_request(&client).await.expect("send_request");
let payload = response.get_seedance2pro_payload().expect("expected seedance2pro payload");
println!("seedream 5.0 pro with ref — task_id={}, order_id={}", payload.task_id, payload.order_id);
assert!(!payload.task_id.is_empty());
}
}
@@ -182,46 +182,6 @@ mod tests {
}
}
#[test]
fn hunyuan_3d_part_format_not_supported() {
let webhook = load_test_webhook("failure/hunyuan_3d_3p1_part_fail_format_not_supported.json");
let result = hydrate_webhook_contents(&webhook);
match result {
HydratedWebhookContents::Error(data) => {
assert_eq!(
data.error_type,
Some(WebhookErrorType::ValueError),
);
assert_eq!(
data.message.as_deref(),
Some("Part generation only supports FBX format. Please provide an FBX file or use /convert-format to convert your model to FBX first."),
);
}
other => panic!("Expected HydratedWebhookContents::Error, got {:?}", other),
}
}
#[test]
fn triposplat_no_foreground_subject() {
let webhook = load_test_webhook("failure/triposplat_fail_no_foreground_subject.json");
let result = hydrate_webhook_contents(&webhook);
match result {
HydratedWebhookContents::Error(data) => {
assert_eq!(
data.error_type,
Some(WebhookErrorType::InputValueError),
);
assert_eq!(
data.message.as_deref(),
Some("No foreground subject could be detected in the input image after background removal. Provide an image with a clear, visible subject against a plain or distinct background."),
);
}
other => panic!("Expected HydratedWebhookContents::Error, got {:?}", other),
}
}
#[test]
fn payload_error_case() {
let webhook = RawWebhookPayload {
@@ -43,23 +43,10 @@ pub struct ExtractedContents {
/// Parsed from `payload.video`.
pub video: Option<VideoData>,
/// Parsed from `payload.audio` (e.g. Seed Audio 1.0 speech results).
pub audio: Option<AudioData>,
/// Parsed from `payload.model_glb`.
pub model_glb: Option<ModelGlbData>,
/// Parsed from `payload.model_glb_pbr` (e.g. Hunyuan 3D 2.1's PBR-textured
/// GLB variant, sent alongside `model_glb`).
pub model_glb_pbr: Option<ModelGlbData>,
/// Parsed from `payload.model_urls` (e.g. Hunyuan 3D 3.0's per-format file
/// map, sent alongside `model_glb`).
pub model_urls: Option<ModelUrlsData>,
/// Parsed from `payload.model_mesh`.
/// NB: `triposplat` ply gaussian splat files also arrive via this payload handler.
/// These are decidedly *not* "mesh" files!
pub model_mesh: Option<ModelMeshData>,
/// Parsed from `payload.model_obj` (e.g. Hunyuan 3D v3.1 Rapid's OBJ output).
@@ -70,14 +57,6 @@ pub struct ExtractedContents {
/// Parsed from `payload.thumbnail`.
pub thumbnail: Option<ThumbnailData>,
/// Parsed from `payload.rendered_image` (e.g. Tripo 3D's preview image,
/// used as a cover image like `thumbnail`).
pub rendered_image: Option<ThumbnailData>,
/// Parsed from `payload.preprocessed_image` (e.g. TripoSplat's segmented
/// input image, usable as a cover image for the splat result).
pub preprocessed_image: Option<PreprocessedImageData>,
}
/// Data under `payload.image`:
@@ -111,22 +90,7 @@ pub struct VideoData {
pub file_size: Option<u64>,
}
/// Data under `payload.audio` (e.g. Seed Audio 1.0):
#[derive(Debug, Deserialize)]
pub struct AudioData {
pub url: Option<String>,
pub content_type: Option<String>,
pub file_name: Option<String>,
pub file_size: Option<u64>,
pub bitrate: Option<u64>,
pub channels: Option<u64>,
/// Duration in seconds.
pub duration: Option<f64>,
pub sample_rate: Option<u64>,
}
/// Data under `payload.model_glb` (there may be other sibling keys too).
/// Also used for `payload.model_glb_pbr`, which has the same shape.
/// Data under `payload.model_glb` (there may be other sibling keys too)
#[derive(Debug, Deserialize)]
pub struct ModelGlbData {
pub content_type: Option<String>,
@@ -136,8 +100,6 @@ pub struct ModelGlbData {
}
/// Data under `payload.model_mesh` (there may be other sibling keys too)
/// NB: `triposplat` ply gaussian splat files also arrive via this payload.
/// These are decidedly *not* "mesh" files!
#[derive(Debug, Deserialize)]
pub struct ModelMeshData {
pub content_type: Option<String>,
@@ -146,31 +108,6 @@ pub struct ModelMeshData {
pub url: Option<String>,
}
/// Data under `payload.model_urls` (e.g. Hunyuan 3D 3.0 / 3.1, Tripo 3D): a
/// map of the generation's output files by format. Any slot may be null or
/// absent. The `glb` entry frequently duplicates the top-level model key
/// (same URL), but may point to a different file. Slots may also duplicate
/// each other's URLs (e.g. Tripo 3D's `glb` and `pbr_model`).
#[derive(Debug, Deserialize)]
pub struct ModelUrlsData {
/// Untextured base model (Tripo 3D).
pub base_model: Option<ModelGlbData>,
/// Blender project file (Meshy).
pub blend: Option<ModelGlbData>,
pub fbx: Option<ModelGlbData>,
pub glb: Option<ModelGlbData>,
/// OBJ material file (Hunyuan 3D 3.1).
pub mtl: Option<ModelGlbData>,
pub obj: Option<ModelGlbData>,
/// PBR-textured model (Tripo 3D).
pub pbr_model: Option<ModelGlbData>,
/// STL model (Meshy).
pub stl: Option<ModelGlbData>,
/// PBR texture image for the OBJ (Hunyuan 3D 3.1).
pub texture: Option<ModelGlbData>,
pub usdz: Option<ModelGlbData>,
}
/// Data under `payload.model_obj` (there may be other sibling keys too).
/// Some endpoints (e.g. Hunyuan 3D v3.1 Rapid text-to-3d) return an OBJ
/// model instead of (or alongside) a GLB.
@@ -195,7 +132,6 @@ pub struct ResultFileData {
/// Data under `payload.thumbnail` (there may be other sibling keys too)
/// Frequently seen together with `model_glb`.
/// Also used for `payload.rendered_image` (Tripo 3D), which has the same shape.
#[derive(Debug, Deserialize)]
pub struct ThumbnailData {
pub content_type: Option<String>,
@@ -204,19 +140,6 @@ pub struct ThumbnailData {
pub url: Option<String>,
}
/// Data under `payload.preprocessed_image` (there may be other sibling keys too).
/// Seen together with `model_mesh` in TripoSplat results: the segmented input
/// image, which we use as a cover image for the splat.
#[derive(Debug, Deserialize)]
pub struct PreprocessedImageData {
pub content_type: Option<String>,
pub file_name: Option<String>,
pub file_size: Option<usize>,
pub height: Option<u64>,
pub url: Option<String>,
pub width: Option<u64>,
}
#[derive(Debug)]
pub struct ErrorData {
/// The first human-readable message from `payload.detail[].msg`, if any.
@@ -1,103 +0,0 @@
use serde_json::{Map, Value};
use crate::webhook_payload::hydrated::hydrated_webhook_contents::AudioData;
/// Extract and deserialize the `audio` key from a webhook success payload
/// (e.g. Seed Audio 1.0 speech results).
pub(crate) fn extract_audio(obj: &Map<String, Value>) -> Option<AudioData> {
let value = obj.get("audio")?;
serde_json::from_value(value.clone()).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::webhook_payload::hydrate_webhook_contents::hydrate_webhook_contents;
use crate::webhook_payload::hydrated::hydrated_webhook_contents::HydratedWebhookContents;
use crate::webhook_payload::raw::raw_webhook_payload::RawWebhookPayload;
fn load_test_webhook(filename: &str) -> RawWebhookPayload {
let path = format!("test_data/webhooks/{}", filename);
let json = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("Failed to read {}: {}", path, e));
serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("Failed to parse {}: {}", path, e))
}
#[test]
fn audio_from_seed_audio_1p0_test_file() {
let webhook = load_test_webhook("success/seed_audio_1p0_audio_payload_1.json");
let result = hydrate_webhook_contents(&webhook);
let HydratedWebhookContents::Success(data) = result else {
panic!("Expected Success, got {:?}", result);
};
let contents = data.extracted_contents
.expect("extracted_contents should be Some");
let audio = contents.audio.expect("audio should be Some");
assert_eq!(audio.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bc36/vOSCcxsbibkZIiXR9BIZn_speech.mp3"));
assert_eq!(audio.content_type.as_deref(), Some("audio/mpeg"));
assert_eq!(audio.file_name.as_deref(), Some("speech.mp3"));
assert_eq!(audio.file_size, Some(53612));
assert_eq!(audio.duration, Some(6.63995));
assert_eq!(audio.sample_rate, Some(24000));
assert!(audio.bitrate.is_none());
assert!(audio.channels.is_none());
// No other content types in this payload.
assert!(contents.image.is_none());
assert!(contents.images.is_none());
assert!(contents.video.is_none());
assert!(contents.model_glb.is_none());
assert!(contents.thumbnail.is_none());
}
#[test]
fn synthetic_audio_payload() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"audio": {
"url": "https://cdn.example.com/speech.mp3",
"content_type": "audio/mpeg",
"file_name": "speech.mp3",
"file_size": 12345,
"bitrate": 128000,
"channels": 2,
"duration": 3.5,
"sample_rate": 44100
}
}"#).unwrap();
let audio = extract_audio(&obj).expect("should extract audio");
assert_eq!(audio.url.as_deref(), Some("https://cdn.example.com/speech.mp3"));
assert_eq!(audio.content_type.as_deref(), Some("audio/mpeg"));
assert_eq!(audio.file_name.as_deref(), Some("speech.mp3"));
assert_eq!(audio.file_size, Some(12345));
assert_eq!(audio.bitrate, Some(128000));
assert_eq!(audio.channels, Some(2));
assert_eq!(audio.duration, Some(3.5));
assert_eq!(audio.sample_rate, Some(44100));
}
#[test]
fn audio_url_only() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"audio": {"url": "https://cdn.example.com/speech.wav"}
}"#).unwrap();
let audio = extract_audio(&obj).expect("should extract audio");
assert_eq!(audio.url.as_deref(), Some("https://cdn.example.com/speech.wav"));
assert!(audio.content_type.is_none());
assert!(audio.duration.is_none());
}
#[test]
fn missing_audio_key_returns_none() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"video": {"url": "https://example.com/video.mp4"}
}"#).unwrap();
assert!(extract_audio(&obj).is_none());
}
}
@@ -1,87 +0,0 @@
use serde_json::{Map, Value};
use crate::webhook_payload::hydrated::hydrated_webhook_contents::ModelGlbData;
/// Extract and deserialize the `model_glb_pbr` key from a webhook success
/// payload. Sent alongside `model_glb` by e.g. Hunyuan 3D 2.1; same shape.
pub(crate) fn extract_model_glb_pbr(obj: &Map<String, Value>) -> Option<ModelGlbData> {
let value = obj.get("model_glb_pbr")?;
serde_json::from_value(value.clone()).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::webhook_payload::hydrate_webhook_contents::hydrate_webhook_contents;
use crate::webhook_payload::hydrated::hydrated_webhook_contents::HydratedWebhookContents;
use crate::webhook_payload::raw::raw_webhook_payload::RawWebhookPayload;
fn load_test_webhook(filename: &str) -> RawWebhookPayload {
let path = format!("test_data/webhooks/{}", filename);
let json = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("Failed to read {}: {}", path, e));
serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("Failed to parse {}: {}", path, e))
}
#[test]
fn model_glbs_from_hunyuan_3d_2p1_test_file() {
let webhook = load_test_webhook("success/hunyuan_3d_2p1_model_glbs_payload_1.json");
let result = hydrate_webhook_contents(&webhook);
let HydratedWebhookContents::Success(data) = result else {
panic!("Expected Success, got {:?}", result);
};
let contents = data.extracted_contents
.expect("extracted_contents should be Some");
let glb = contents.model_glb.expect("model_glb should be Some");
assert_eq!(glb.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1b916/QKdBTd8WJW_rtPB3IwlEN_demo_textured.glb"));
assert_eq!(glb.content_type.as_deref(), Some("application/octet-stream"));
assert_eq!(glb.file_name.as_deref(), Some("demo_textured.glb"));
assert_eq!(glb.file_size, Some(1574216));
let glb_pbr = contents.model_glb_pbr.expect("model_glb_pbr should be Some");
assert_eq!(glb_pbr.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1b921/SJw7AARzkc4l9QG5Lrvmu_demo_textured_pbr.glb"));
assert_eq!(glb_pbr.content_type.as_deref(), Some("application/octet-stream"));
assert_eq!(glb_pbr.file_name.as_deref(), Some("demo_textured_pbr.glb"));
assert_eq!(glb_pbr.file_size, Some(7512760));
// The payload also carries a `model_mesh` zip archive of the whole
// generation. It parses out, but the webhook handler prefers the GLBs.
let mesh = contents.model_mesh.expect("model_mesh should be Some");
assert_eq!(mesh.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1b915/B25DrTdiZJNV-60RaUh4u_3d_model.zip"));
assert_eq!(mesh.file_name.as_deref(), Some("3d_model.zip"));
assert!(contents.thumbnail.is_none());
assert!(contents.preprocessed_image.is_none());
}
#[test]
fn synthetic_model_glb_pbr_payload() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"model_glb_pbr": {
"url": "https://cdn.example.com/model_pbr.glb",
"content_type": "model/gltf-binary",
"file_name": "model_pbr.glb",
"file_size": 7654321
}
}"#).unwrap();
let glb_pbr = extract_model_glb_pbr(&obj).expect("should extract model_glb_pbr");
assert_eq!(glb_pbr.url.as_deref(), Some("https://cdn.example.com/model_pbr.glb"));
assert_eq!(glb_pbr.content_type.as_deref(), Some("model/gltf-binary"));
assert_eq!(glb_pbr.file_name.as_deref(), Some("model_pbr.glb"));
assert_eq!(glb_pbr.file_size, Some(7654321));
}
#[test]
fn missing_model_glb_pbr_key_returns_none() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"model_glb": {"url": "https://example.com/model.glb"}
}"#).unwrap();
assert!(extract_model_glb_pbr(&obj).is_none());
}
}
@@ -49,33 +49,6 @@ mod tests {
assert!(contents.thumbnail.is_none());
}
#[test]
fn triposplat_ply_splat_from_test_file() {
// TripoSplat sends its PLY gaussian splat under `model_mesh`.
let webhook = load_test_webhook("success/triposplat_splat_payload_1.json");
let result = hydrate_webhook_contents(&webhook);
let HydratedWebhookContents::Success(data) = result else {
panic!("Expected Success, got {:?}", result);
};
let contents = data.extracted_contents
.expect("extracted_contents should be Some");
let mesh = contents.model_mesh.expect("model_mesh should be Some");
assert_eq!(mesh.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1b77c/hEzg5xn9A-1qrLsnywjN4_output.ply"));
assert_eq!(mesh.content_type.as_deref(), Some("application/octet-stream"));
assert_eq!(mesh.file_name.as_deref(), Some("output.ply"));
assert_eq!(mesh.file_size, Some(17826208));
// The segmented input image rides along as `preprocessed_image`.
assert!(contents.preprocessed_image.is_some());
assert!(contents.model_glb.is_none());
assert!(contents.model_glb_pbr.is_none());
assert!(contents.thumbnail.is_none());
}
#[test]
fn synthetic_model_mesh_payload() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
@@ -1,327 +0,0 @@
use serde_json::{Map, Value};
use crate::webhook_payload::hydrated::hydrated_webhook_contents::ModelUrlsData;
/// Extract and deserialize the `model_urls` key from a webhook success
/// payload (e.g. Hunyuan 3D 3.0's per-format file map).
pub(crate) fn extract_model_urls(obj: &Map<String, Value>) -> Option<ModelUrlsData> {
let value = obj.get("model_urls")?;
serde_json::from_value(value.clone()).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::webhook_payload::hydrate_webhook_contents::hydrate_webhook_contents;
use crate::webhook_payload::hydrated::hydrated_webhook_contents::HydratedWebhookContents;
use crate::webhook_payload::raw::raw_webhook_payload::RawWebhookPayload;
fn load_test_webhook(filename: &str) -> RawWebhookPayload {
let path = format!("test_data/webhooks/{}", filename);
let json = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("Failed to read {}: {}", path, e));
serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("Failed to parse {}: {}", path, e))
}
#[test]
fn model_urls_from_hunyuan_3d_3p0_test_file() {
let webhook = load_test_webhook("success/hunyuan_3d_3p0_model_urls_payload_1.json");
let result = hydrate_webhook_contents(&webhook);
let HydratedWebhookContents::Success(data) = result else {
panic!("Expected Success, got {:?}", result);
};
let contents = data.extracted_contents
.expect("extracted_contents should be Some");
let glb = contents.model_glb.expect("model_glb should be Some");
assert_eq!(glb.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1b969/k5FZWmTsKxHH71404bBR__model.glb"));
assert_eq!(glb.content_type.as_deref(), Some("model/gltf-binary"));
assert_eq!(glb.file_name.as_deref(), Some("model.glb"));
assert_eq!(glb.file_size, Some(51001160));
let model_urls = contents.model_urls.expect("model_urls should be Some");
// The `glb` slot duplicates `model_glb` in this payload (same URL).
let urls_glb = model_urls.glb.expect("model_urls.glb should be Some");
assert_eq!(urls_glb.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1b969/k5FZWmTsKxHH71404bBR__model.glb"));
let urls_obj = model_urls.obj.expect("model_urls.obj should be Some");
assert_eq!(urls_obj.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1b968/xngvKxLugXUlNh89Fp0K4_model.obj"));
assert_eq!(urls_obj.content_type.as_deref(), Some("text/plain"));
assert_eq!(urls_obj.file_name.as_deref(), Some("model.obj"));
assert_eq!(urls_obj.file_size, Some(41107268));
assert!(model_urls.fbx.is_none());
assert!(model_urls.mtl.is_none());
assert!(model_urls.texture.is_none());
assert!(model_urls.usdz.is_none());
let thumbnail = contents.thumbnail.expect("thumbnail should be Some");
assert_eq!(thumbnail.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1b969/EPgha8Kee7YZCAccjFUU7_preview.png"));
assert_eq!(thumbnail.content_type.as_deref(), Some("image/png"));
assert_eq!(thumbnail.file_name.as_deref(), Some("preview.png"));
assert_eq!(thumbnail.file_size, Some(136476));
assert!(contents.model_glb_pbr.is_none());
assert!(contents.model_mesh.is_none());
assert!(contents.preprocessed_image.is_none());
}
#[test]
fn model_urls_and_thumbnail_from_hunyuan_3d_3p1_test_file() {
let webhook = load_test_webhook("success/hunyuan_3d_3p1_model_urls_thumbnail_payload_1.json");
let result = hydrate_webhook_contents(&webhook);
let HydratedWebhookContents::Success(data) = result else {
panic!("Expected Success, got {:?}", result);
};
let contents = data.extracted_contents
.expect("extracted_contents should be Some");
let glb = contents.model_glb.expect("model_glb should be Some");
assert_eq!(glb.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1ba44/OdgIv8M9EkaBP_Mh4TVAB_model.glb"));
assert_eq!(glb.content_type.as_deref(), Some("model/gltf-binary"));
assert_eq!(glb.file_name.as_deref(), Some("model.glb"));
assert_eq!(glb.file_size, Some(64261364));
let model_urls = contents.model_urls.expect("model_urls should be Some");
// The `glb` slot duplicates `model_glb` in this payload (same URL).
let urls_glb = model_urls.glb.expect("model_urls.glb should be Some");
assert_eq!(urls_glb.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1ba44/OdgIv8M9EkaBP_Mh4TVAB_model.glb"));
let urls_obj = model_urls.obj.expect("model_urls.obj should be Some");
assert_eq!(urls_obj.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1ba43/WxPMNbzsDxaGRCmGNgNAL_c3a871e997ecd5f889bf671630de23b4.obj"));
assert_eq!(urls_obj.content_type.as_deref(), Some("model/obj"));
assert_eq!(urls_obj.file_size, Some(34563830));
// 3.1 additionally ships the OBJ's material and PBR texture files.
let urls_mtl = model_urls.mtl.expect("model_urls.mtl should be Some");
assert_eq!(urls_mtl.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1ba42/1T5VCp_xD0zCJlTLE96iK_material.mtl"));
assert_eq!(urls_mtl.content_type.as_deref(), Some("text/plain"));
assert_eq!(urls_mtl.file_name.as_deref(), Some("material.mtl"));
assert_eq!(urls_mtl.file_size, Some(245));
let urls_texture = model_urls.texture.expect("model_urls.texture should be Some");
assert_eq!(urls_texture.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1ba43/aKp3mMB9nrhlKfV2KVf0S_texture_pbr_20250901.png"));
assert_eq!(urls_texture.content_type.as_deref(), Some("image/png"));
assert_eq!(urls_texture.file_name.as_deref(), Some("texture_pbr_20250901.png"));
assert_eq!(urls_texture.file_size, Some(22794575));
assert!(model_urls.fbx.is_none());
assert!(model_urls.usdz.is_none());
// The thumbnail is attached to the GLB as its cover image downstream.
let thumbnail = contents.thumbnail.expect("thumbnail should be Some");
assert_eq!(thumbnail.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1ba44/njq8zYhYahJNNQkGGiEZW_preview.png"));
assert_eq!(thumbnail.content_type.as_deref(), Some("image/png"));
assert_eq!(thumbnail.file_name.as_deref(), Some("preview.png"));
assert_eq!(thumbnail.file_size, Some(141254));
assert!(contents.model_glb_pbr.is_none());
assert!(contents.model_mesh.is_none());
assert!(contents.preprocessed_image.is_none());
}
#[test]
fn obj_and_thumbnail_from_hunyuan_3d_3p1_rapid_test_file() {
let webhook = load_test_webhook("success/hunyuan_3d_3p1_rapid_obj_payload_1.json");
let result = hydrate_webhook_contents(&webhook);
let HydratedWebhookContents::Success(data) = result else {
panic!("Expected Success, got {:?}", result);
};
let contents = data.extracted_contents
.expect("extracted_contents should be Some");
// NB: Hunyuan 3D 3.1 Rapid puts an OBJ under the `model_glb` key. The
// key name doesn't matter downstream: the webhook handler resolves the
// real file type from content_type / file name, so it uploads as an OBJ
// mesh.
let glb = contents.model_glb.expect("model_glb should be Some");
assert_eq!(glb.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bae3/22zStkYp8-q6npG9dsMs5_23dce35fc95b34722f15a7329061d5df.obj"));
assert_eq!(glb.content_type.as_deref(), Some("model/obj"));
assert_eq!(glb.file_name.as_deref(), Some("23dce35fc95b34722f15a7329061d5df.obj"));
assert_eq!(glb.file_size, Some(3258750));
let model_urls = contents.model_urls.expect("model_urls should be Some");
// The `glb` slot is null here; `obj` duplicates `model_glb` (same URL).
assert!(model_urls.glb.is_none());
assert!(model_urls.fbx.is_none());
assert!(model_urls.usdz.is_none());
let urls_obj = model_urls.obj.expect("model_urls.obj should be Some");
assert_eq!(urls_obj.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bae3/22zStkYp8-q6npG9dsMs5_23dce35fc95b34722f15a7329061d5df.obj"));
assert_eq!(urls_obj.content_type.as_deref(), Some("model/obj"));
assert_eq!(urls_obj.file_size, Some(3258750));
let urls_mtl = model_urls.mtl.expect("model_urls.mtl should be Some");
assert_eq!(urls_mtl.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bae3/5cbE-q28i44GUOO6YeZPH_material.mtl"));
assert_eq!(urls_mtl.file_size, Some(229));
let urls_texture = model_urls.texture.expect("model_urls.texture should be Some");
assert_eq!(urls_texture.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bae3/7eBRddTHf6U8SUg2QOIHw_texture_pbr_v128.png"));
assert_eq!(urls_texture.file_name.as_deref(), Some("texture_pbr_v128.png"));
assert_eq!(urls_texture.file_size, Some(5503289));
// The thumbnail is attached to the mesh as its cover image downstream.
let thumbnail = contents.thumbnail.expect("thumbnail should be Some");
assert_eq!(thumbnail.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bae3/IO3aspD4ijEadoFxSF504_preview.png"));
assert_eq!(thumbnail.content_type.as_deref(), Some("image/png"));
assert_eq!(thumbnail.file_name.as_deref(), Some("preview.png"));
assert_eq!(thumbnail.file_size, Some(120530));
assert!(contents.model_glb_pbr.is_none());
assert!(contents.model_mesh.is_none());
assert!(contents.model_obj.is_none());
assert!(contents.preprocessed_image.is_none());
}
#[test]
fn model_urls_and_rendered_image_from_tripo3d_test_file() {
let webhook = load_test_webhook("success/tripo3d_model_urls_rendered_image_payload_1.json");
let result = hydrate_webhook_contents(&webhook);
let HydratedWebhookContents::Success(data) = result else {
panic!("Expected Success, got {:?}", result);
};
let contents = data.extracted_contents
.expect("extracted_contents should be Some");
// Tripo 3D sends its GLB under `model_mesh` (not `model_glb`); the
// webhook handler picks `model_urls.glb` as the primary GLB instead.
assert!(contents.model_glb.is_none());
let mesh = contents.model_mesh.expect("model_mesh should be Some");
assert_eq!(mesh.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbdb/d8ATWvvXYHY-8zj-lQecf_model.glb"));
assert_eq!(mesh.content_type.as_deref(), Some("model/gltf-binary"));
let model_urls = contents.model_urls.expect("model_urls should be Some");
// `glb` and `pbr_model` duplicate each other (same URL) in this payload;
// the webhook handler dedupes by URL and uploads the file only once.
let urls_glb = model_urls.glb.expect("model_urls.glb should be Some");
assert_eq!(urls_glb.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbdb/d8ATWvvXYHY-8zj-lQecf_model.glb"));
assert_eq!(urls_glb.file_size, Some(43981280));
let urls_pbr_model = model_urls.pbr_model.expect("model_urls.pbr_model should be Some");
assert_eq!(urls_pbr_model.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbdb/d8ATWvvXYHY-8zj-lQecf_model.glb"));
assert!(model_urls.base_model.is_none());
assert!(model_urls.fbx.is_none());
assert!(model_urls.mtl.is_none());
assert!(model_urls.obj.is_none());
assert!(model_urls.texture.is_none());
assert!(model_urls.usdz.is_none());
// The preview arrives as `rendered_image` (no `thumbnail` key) and is
// attached to the mesh as its cover image downstream.
let rendered_image = contents.rendered_image.expect("rendered_image should be Some");
assert_eq!(rendered_image.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbd0/MblV2S5R6CeskRABMS7-V_preview.png"));
assert!(contents.thumbnail.is_none());
assert!(contents.model_glb_pbr.is_none());
assert!(contents.preprocessed_image.is_none());
}
#[test]
fn model_urls_and_thumbnail_from_meshy_test_file() {
let webhook = load_test_webhook("success/meshy_model_urls_thumbnail_payload_1.json");
let result = hydrate_webhook_contents(&webhook);
let HydratedWebhookContents::Success(data) = result else {
panic!("Expected Success, got {:?}", result);
};
let contents = data.extracted_contents
.expect("extracted_contents should be Some");
let glb = contents.model_glb.expect("model_glb should be Some");
assert_eq!(glb.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbe1/0vrf9NUhVxKZO4GScA0EI_model.glb"));
assert_eq!(glb.content_type.as_deref(), Some("model/gltf-binary"));
assert_eq!(glb.file_name.as_deref(), Some("model.glb"));
assert_eq!(glb.file_size, Some(15943768));
let model_urls = contents.model_urls.expect("model_urls should be Some");
// The `glb` slot duplicates `model_glb` (same URL); the webhook handler
// dedupes by URL so the file uploads only once.
let urls_glb = model_urls.glb.expect("model_urls.glb should be Some");
assert_eq!(urls_glb.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbe1/0vrf9NUhVxKZO4GScA0EI_model.glb"));
// Alternate formats of the same model; parsed but not uploaded.
let urls_fbx = model_urls.fbx.expect("model_urls.fbx should be Some");
assert_eq!(urls_fbx.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbed/yKO-HOjBnWA-LZpRLHHSB_model.fbx"));
assert_eq!(urls_fbx.file_size, Some(13665964));
let urls_obj = model_urls.obj.expect("model_urls.obj should be Some");
assert_eq!(urls_obj.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbe2/NBmqIWBS8LuY_BPYWkOOf_model.obj"));
let urls_stl = model_urls.stl.expect("model_urls.stl should be Some");
assert_eq!(urls_stl.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbe2/3wtOE6iEeVHZFvwAGN6Rl_model.stl"));
assert_eq!(urls_stl.content_type.as_deref(), Some("application/octet-stream"));
assert_eq!(urls_stl.file_size, Some(1508384));
let urls_usdz = model_urls.usdz.expect("model_urls.usdz should be Some");
assert_eq!(urls_usdz.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbed/BsniPBo6Uev85V4hLFACR_model.usdz"));
assert_eq!(urls_usdz.content_type.as_deref(), Some("model/vnd.usdz+zip"));
assert!(model_urls.base_model.is_none());
assert!(model_urls.blend.is_none());
assert!(model_urls.mtl.is_none());
assert!(model_urls.pbr_model.is_none());
assert!(model_urls.texture.is_none());
// The thumbnail is attached to the GLB as its cover image downstream.
let thumbnail = contents.thumbnail.expect("thumbnail should be Some");
assert_eq!(thumbnail.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbe3/tn1qfOr12oGXecA4mRtlI_preview.png"));
assert_eq!(thumbnail.content_type.as_deref(), Some("image/png"));
assert_eq!(thumbnail.file_name.as_deref(), Some("preview.png"));
assert_eq!(thumbnail.file_size, Some(192910));
assert!(contents.model_glb_pbr.is_none());
assert!(contents.model_mesh.is_none());
assert!(contents.rendered_image.is_none());
assert!(contents.preprocessed_image.is_none());
}
#[test]
fn synthetic_model_urls_payload_with_distinct_glb() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"model_urls": {
"fbx": null,
"glb": {
"url": "https://cdn.example.com/model_alt.glb",
"content_type": "model/gltf-binary",
"file_name": "model_alt.glb",
"file_size": 1234567
},
"obj": null,
"usdz": null
}
}"#).unwrap();
let model_urls = extract_model_urls(&obj).expect("should extract model_urls");
let glb = model_urls.glb.expect("glb slot should be Some");
assert_eq!(glb.url.as_deref(), Some("https://cdn.example.com/model_alt.glb"));
assert_eq!(glb.file_size, Some(1234567));
assert!(model_urls.fbx.is_none());
assert!(model_urls.obj.is_none());
assert!(model_urls.usdz.is_none());
}
#[test]
fn missing_model_urls_key_returns_none() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"model_glb": {"url": "https://example.com/model.glb"}
}"#).unwrap();
assert!(extract_model_urls(&obj).is_none());
}
}
@@ -1,84 +0,0 @@
use serde_json::{Map, Value};
use crate::webhook_payload::hydrated::hydrated_webhook_contents::PreprocessedImageData;
/// Extract and deserialize the `preprocessed_image` key from a webhook success payload.
pub(crate) fn extract_preprocessed_image(obj: &Map<String, Value>) -> Option<PreprocessedImageData> {
let value = obj.get("preprocessed_image")?;
serde_json::from_value(value.clone()).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::webhook_payload::hydrate_webhook_contents::hydrate_webhook_contents;
use crate::webhook_payload::hydrated::hydrated_webhook_contents::HydratedWebhookContents;
use crate::webhook_payload::raw::raw_webhook_payload::RawWebhookPayload;
fn load_test_webhook(filename: &str) -> RawWebhookPayload {
let path = format!("test_data/webhooks/{}", filename);
let json = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("Failed to read {}: {}", path, e));
serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("Failed to parse {}: {}", path, e))
}
#[test]
fn preprocessed_image_from_triposplat_test_file() {
let webhook = load_test_webhook("success/triposplat_splat_payload_1.json");
let result = hydrate_webhook_contents(&webhook);
let HydratedWebhookContents::Success(data) = result else {
panic!("Expected Success, got {:?}", result);
};
let contents = data.extracted_contents
.expect("extracted_contents should be Some");
let image = contents.preprocessed_image.expect("preprocessed_image should be Some");
assert_eq!(image.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1b77c/YyJ19g7lJsRNXmvZvahTE_61fc73dbe84446b08b1fcce2167b71d5.png"));
assert_eq!(image.content_type.as_deref(), Some("image/png"));
assert_eq!(image.file_name.as_deref(), Some("61fc73dbe84446b08b1fcce2167b71d5.png"));
assert_eq!(image.file_size, Some(952754));
assert_eq!(image.width, Some(1024));
assert_eq!(image.height, Some(1024));
// The splat itself arrives under `model_mesh`.
let mesh = contents.model_mesh.expect("model_mesh should be Some");
assert_eq!(mesh.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1b77c/hEzg5xn9A-1qrLsnywjN4_output.ply"));
assert_eq!(mesh.content_type.as_deref(), Some("application/octet-stream"));
assert_eq!(mesh.file_name.as_deref(), Some("output.ply"));
assert_eq!(mesh.file_size, Some(17826208));
}
#[test]
fn synthetic_preprocessed_image_payload() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"preprocessed_image": {
"url": "https://cdn.example.com/segmented.png",
"content_type": "image/png",
"file_name": "segmented.png",
"file_size": 12345,
"width": 1024,
"height": 1024
}
}"#).unwrap();
let image = extract_preprocessed_image(&obj).expect("should extract preprocessed_image");
assert_eq!(image.url.as_deref(), Some("https://cdn.example.com/segmented.png"));
assert_eq!(image.content_type.as_deref(), Some("image/png"));
assert_eq!(image.file_name.as_deref(), Some("segmented.png"));
assert_eq!(image.file_size, Some(12345));
assert_eq!(image.width, Some(1024));
assert_eq!(image.height, Some(1024));
}
#[test]
fn missing_preprocessed_image_key_returns_none() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"model_mesh": {"url": "https://example.com/output.ply"}
}"#).unwrap();
assert!(extract_preprocessed_image(&obj).is_none());
}
}
@@ -1,76 +0,0 @@
use serde_json::{Map, Value};
use crate::webhook_payload::hydrated::hydrated_webhook_contents::ThumbnailData;
/// Extract and deserialize the `rendered_image` key from a webhook success
/// payload (e.g. Tripo 3D's preview image). Same shape as `thumbnail`, and
/// used the same way: as a cover image for the model.
pub(crate) fn extract_rendered_image(obj: &Map<String, Value>) -> Option<ThumbnailData> {
let value = obj.get("rendered_image")?;
serde_json::from_value(value.clone()).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::webhook_payload::hydrate_webhook_contents::hydrate_webhook_contents;
use crate::webhook_payload::hydrated::hydrated_webhook_contents::HydratedWebhookContents;
use crate::webhook_payload::raw::raw_webhook_payload::RawWebhookPayload;
fn load_test_webhook(filename: &str) -> RawWebhookPayload {
let path = format!("test_data/webhooks/{}", filename);
let json = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("Failed to read {}: {}", path, e));
serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("Failed to parse {}: {}", path, e))
}
#[test]
fn rendered_image_from_tripo3d_test_file() {
let webhook = load_test_webhook("success/tripo3d_model_urls_rendered_image_payload_1.json");
let result = hydrate_webhook_contents(&webhook);
let HydratedWebhookContents::Success(data) = result else {
panic!("Expected Success, got {:?}", result);
};
let contents = data.extracted_contents
.expect("extracted_contents should be Some");
let rendered_image = contents.rendered_image.expect("rendered_image should be Some");
assert_eq!(rendered_image.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbd0/MblV2S5R6CeskRABMS7-V_preview.png"));
assert_eq!(rendered_image.content_type.as_deref(), Some("image/png"));
assert_eq!(rendered_image.file_name.as_deref(), Some("preview.png"));
assert_eq!(rendered_image.file_size, Some(30970));
// Tripo has no `thumbnail` key; the preview only arrives here.
assert!(contents.thumbnail.is_none());
}
#[test]
fn synthetic_rendered_image_payload() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"rendered_image": {
"url": "https://cdn.example.com/preview.png",
"content_type": "image/png",
"file_name": "preview.png",
"file_size": 12345
}
}"#).unwrap();
let image = extract_rendered_image(&obj).expect("should extract rendered_image");
assert_eq!(image.url.as_deref(), Some("https://cdn.example.com/preview.png"));
assert_eq!(image.content_type.as_deref(), Some("image/png"));
assert_eq!(image.file_name.as_deref(), Some("preview.png"));
assert_eq!(image.file_size, Some(12345));
}
#[test]
fn missing_rendered_image_key_returns_none() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"thumbnail": {"url": "https://example.com/preview.png"}
}"#).unwrap();
assert!(extract_rendered_image(&obj).is_none());
}
}
@@ -1,14 +1,9 @@
pub mod extract_audio_payload;
pub mod extract_image_payload;
pub mod extract_images_payload;
pub mod extract_video_payload;
pub mod extract_model_glb_payload;
pub mod extract_model_glb_pbr_payload;
pub mod extract_model_mesh_payload;
pub mod extract_model_obj_payload;
pub mod extract_model_urls_payload;
pub mod extract_preprocessed_image_payload;
pub mod extract_rendered_image_payload;
pub mod extract_result_files_payload;
pub mod extract_thumbnail_payload;
@@ -19,9 +14,7 @@ use crate::webhook_payload::hydrated::hydrated_webhook_contents::ExtractedConten
/// Try to extract known content keys from a success payload.
///
/// Checks the payload (as a JSON object) for any of: `image`, `images`,
/// `video`, `audio`, `model_glb`, `model_glb_pbr`, `model_urls`,
/// `model_mesh`, `model_obj`, `result_files`, `thumbnail`, `rendered_image`,
/// `preprocessed_image`. If at least one is found, returns
/// `video`, `model_glb`, `model_mesh`. If at least one is found, returns
/// `Some(ExtractedContents)` with copies of the relevant values. Multiple
/// keys can be populated simultaneously. Returns `None` if the payload is
/// not an object or none of the known keys are present.
@@ -31,22 +24,15 @@ pub fn extract_contents_from_payload(payload: &Value) -> Option<ExtractedContent
let image = extract_image_payload::extract_image(obj);
let images = extract_images_payload::extract_images(obj);
let video = extract_video_payload::extract_video(obj);
let audio = extract_audio_payload::extract_audio(obj);
let model_glb = extract_model_glb_payload::extract_model_glb(obj);
let model_glb_pbr = extract_model_glb_pbr_payload::extract_model_glb_pbr(obj);
let model_urls = extract_model_urls_payload::extract_model_urls(obj);
let model_mesh = extract_model_mesh_payload::extract_model_mesh(obj);
let model_obj = extract_model_obj_payload::extract_model_obj(obj);
let result_files = extract_result_files_payload::extract_result_files(obj);
let thumbnail = extract_thumbnail_payload::extract_thumbnail(obj);
let rendered_image = extract_rendered_image_payload::extract_rendered_image(obj);
let preprocessed_image = extract_preprocessed_image_payload::extract_preprocessed_image(obj);
if image.is_none() && images.is_none() && video.is_none() && audio.is_none()
&& model_glb.is_none() && model_glb_pbr.is_none() && model_urls.is_none()
&& model_mesh.is_none() && model_obj.is_none()
&& result_files.is_none() && thumbnail.is_none() && rendered_image.is_none()
&& preprocessed_image.is_none()
if image.is_none() && images.is_none() && video.is_none()
&& model_glb.is_none() && model_mesh.is_none() && model_obj.is_none()
&& result_files.is_none() && thumbnail.is_none()
{
return None;
}
@@ -55,15 +41,10 @@ pub fn extract_contents_from_payload(payload: &Value) -> Option<ExtractedContent
image,
images,
video,
audio,
model_glb,
model_glb_pbr,
model_urls,
model_mesh,
model_obj,
result_files,
thumbnail,
rendered_image,
preprocessed_image,
})
}
@@ -19,12 +19,6 @@ pub enum WebhookErrorType {
ImageLoadError,
FileDownloadError,
FaceDetectionError,
/// Generic input validation error. The `msg` field carries the specifics
/// (e.g. TripoSplat's "No foreground subject could be detected...").
InputValueError,
/// Generic validation error. The `msg` field carries the specifics (e.g.
/// Hunyuan 3D Part's "Part generation only supports FBX format...").
ValueError,
GreaterThan,
GreaterThanEqual,
LessThan,
@@ -70,8 +64,6 @@ mod tests {
("\"image_load_error\"", WebhookErrorType::ImageLoadError),
("\"file_download_error\"", WebhookErrorType::FileDownloadError),
("\"face_detection_error\"", WebhookErrorType::FaceDetectionError),
("\"input_value_error\"", WebhookErrorType::InputValueError),
("\"value_error\"", WebhookErrorType::ValueError),
("\"greater_than\"", WebhookErrorType::GreaterThan),
("\"greater_than_equal\"", WebhookErrorType::GreaterThanEqual),
("\"less_than\"", WebhookErrorType::LessThan),
@@ -103,8 +95,6 @@ mod tests {
fn from_str_known() {
assert_eq!(WebhookErrorType::from_str("content_policy_violation"), WebhookErrorType::ContentPolicyViolation);
assert_eq!(WebhookErrorType::from_str("face_detection_error"), WebhookErrorType::FaceDetectionError);
assert_eq!(WebhookErrorType::from_str("input_value_error"), WebhookErrorType::InputValueError);
assert_eq!(WebhookErrorType::from_str("value_error"), WebhookErrorType::ValueError);
}
#[test]
@@ -1,18 +0,0 @@
{
"error": "Unexpected status code: 422",
"gateway_request_id": "019f4dad-7ff7-7902-b206-cb11bf1ef843",
"payload": {
"detail": [
{
"loc": [
"body",
"input_file_url"
],
"msg": "Part generation only supports FBX format. Please provide an FBX file or use /convert-format to convert your model to FBX first.",
"type": "value_error"
}
]
},
"request_id": "019f4dad-7ff7-7902-b206-cb11bf1ef843",
"status": "ERROR"
}
@@ -1,19 +0,0 @@
{
"error": "Unexpected status code: 422",
"gateway_request_id": "019f4d0e-3f44-7ed1-bb12-f2f834bdde7b",
"payload": {
"detail": [
{
"input": "https://cdn-2.fakeyou.com/media/f/d/6/f/6/fd6f6czv21gkrdqhptz6nj072wh7w06y/artcraft_fd6f6czv21gkrdqhptz6nj072wh7w06y.png",
"loc": [
"body"
],
"msg": "No foreground subject could be detected in the input image after background removal. Provide an image with a clear, visible subject against a plain or distinct background.",
"type": "input_value_error",
"url": "https://docs.fal.ai/errors#input_value_error"
}
]
},
"request_id": "019f4d0e-3f44-7ed1-bb12-f2f834bdde7b",
"status": "ERROR"
}
@@ -1,27 +0,0 @@
{
"error": null,
"gateway_request_id": "019f4d49-6859-77b3-aaac-3b91b248481a",
"payload": {
"model_glb": {
"content_type": "application/octet-stream",
"file_name": "demo_textured.glb",
"file_size": 1574216,
"url": "https://v3b.fal.media/files/b/0aa1b916/QKdBTd8WJW_rtPB3IwlEN_demo_textured.glb"
},
"model_glb_pbr": {
"content_type": "application/octet-stream",
"file_name": "demo_textured_pbr.glb",
"file_size": 7512760,
"url": "https://v3b.fal.media/files/b/0aa1b921/SJw7AARzkc4l9QG5Lrvmu_demo_textured_pbr.glb"
},
"model_mesh": {
"content_type": "application/octet-stream",
"file_name": "3d_model.zip",
"file_size": 16332900,
"url": "https://v3b.fal.media/files/b/0aa1b915/B25DrTdiZJNV-60RaUh4u_3d_model.zip"
},
"seed": 283904
},
"request_id": "019f4d49-6859-77b3-aaac-3b91b248481a",
"status": "OK"
}
@@ -1,37 +0,0 @@
{
"error": null,
"gateway_request_id": "019f4d58-0e11-7aa0-aa79-e0294bf9ff16",
"payload": {
"model_glb": {
"content_type": "model/gltf-binary",
"file_name": "model.glb",
"file_size": 51001160,
"url": "https://v3b.fal.media/files/b/0aa1b969/k5FZWmTsKxHH71404bBR__model.glb"
},
"model_urls": {
"fbx": null,
"glb": {
"content_type": "model/gltf-binary",
"file_name": "model.glb",
"file_size": 51001160,
"url": "https://v3b.fal.media/files/b/0aa1b969/k5FZWmTsKxHH71404bBR__model.glb"
},
"obj": {
"content_type": "text/plain",
"file_name": "model.obj",
"file_size": 41107268,
"url": "https://v3b.fal.media/files/b/0aa1b968/xngvKxLugXUlNh89Fp0K4_model.obj"
},
"usdz": null
},
"seed": null,
"thumbnail": {
"content_type": "image/png",
"file_name": "preview.png",
"file_size": 136476,
"url": "https://v3b.fal.media/files/b/0aa1b969/EPgha8Kee7YZCAccjFUU7_preview.png"
}
},
"request_id": "019f4d58-0e11-7aa0-aa79-e0294bf9ff16",
"status": "OK"
}
@@ -1,49 +0,0 @@
{
"error": null,
"gateway_request_id": "019f4d79-35fd-7462-85be-359c8232f56e",
"payload": {
"model_glb": {
"content_type": "model/gltf-binary",
"file_name": "model.glb",
"file_size": 64261364,
"url": "https://v3b.fal.media/files/b/0aa1ba44/OdgIv8M9EkaBP_Mh4TVAB_model.glb"
},
"model_urls": {
"fbx": null,
"glb": {
"content_type": "model/gltf-binary",
"file_name": "model.glb",
"file_size": 64261364,
"url": "https://v3b.fal.media/files/b/0aa1ba44/OdgIv8M9EkaBP_Mh4TVAB_model.glb"
},
"mtl": {
"content_type": "text/plain",
"file_name": "material.mtl",
"file_size": 245,
"url": "https://v3b.fal.media/files/b/0aa1ba42/1T5VCp_xD0zCJlTLE96iK_material.mtl"
},
"obj": {
"content_type": "model/obj",
"file_name": "c3a871e997ecd5f889bf671630de23b4.obj",
"file_size": 34563830,
"url": "https://v3b.fal.media/files/b/0aa1ba43/WxPMNbzsDxaGRCmGNgNAL_c3a871e997ecd5f889bf671630de23b4.obj"
},
"texture": {
"content_type": "image/png",
"file_name": "texture_pbr_20250901.png",
"file_size": 22794575,
"url": "https://v3b.fal.media/files/b/0aa1ba43/aKp3mMB9nrhlKfV2KVf0S_texture_pbr_20250901.png"
},
"usdz": null
},
"seed": null,
"thumbnail": {
"content_type": "image/png",
"file_name": "preview.png",
"file_size": 141254,
"url": "https://v3b.fal.media/files/b/0aa1ba44/njq8zYhYahJNNQkGGiEZW_preview.png"
}
},
"request_id": "019f4d79-35fd-7462-85be-359c8232f56e",
"status": "OK"
}
@@ -1,55 +0,0 @@
{
"error": null,
"gateway_request_id": "019f4d93-5a68-7a23-b2e7-8a1bc55e50f8",
"payload": {
"material_mtl": {
"content_type": "text/plain",
"file_name": "material.mtl",
"file_size": 229,
"url": "https://v3b.fal.media/files/b/0aa1bae3/5cbE-q28i44GUOO6YeZPH_material.mtl"
},
"model_glb": {
"content_type": "model/obj",
"file_name": "23dce35fc95b34722f15a7329061d5df.obj",
"file_size": 3258750,
"url": "https://v3b.fal.media/files/b/0aa1bae3/22zStkYp8-q6npG9dsMs5_23dce35fc95b34722f15a7329061d5df.obj"
},
"model_urls": {
"fbx": null,
"glb": null,
"mtl": {
"content_type": "text/plain",
"file_name": "material.mtl",
"file_size": 229,
"url": "https://v3b.fal.media/files/b/0aa1bae3/5cbE-q28i44GUOO6YeZPH_material.mtl"
},
"obj": {
"content_type": "model/obj",
"file_name": "23dce35fc95b34722f15a7329061d5df.obj",
"file_size": 3258750,
"url": "https://v3b.fal.media/files/b/0aa1bae3/22zStkYp8-q6npG9dsMs5_23dce35fc95b34722f15a7329061d5df.obj"
},
"texture": {
"content_type": "image/png",
"file_name": "texture_pbr_v128.png",
"file_size": 5503289,
"url": "https://v3b.fal.media/files/b/0aa1bae3/7eBRddTHf6U8SUg2QOIHw_texture_pbr_v128.png"
},
"usdz": null
},
"texture": {
"content_type": "image/png",
"file_name": "texture_pbr_v128.png",
"file_size": 5503289,
"url": "https://v3b.fal.media/files/b/0aa1bae3/7eBRddTHf6U8SUg2QOIHw_texture_pbr_v128.png"
},
"thumbnail": {
"content_type": "image/png",
"file_name": "preview.png",
"file_size": 120530,
"url": "https://v3b.fal.media/files/b/0aa1bae3/IO3aspD4ijEadoFxSF504_preview.png"
}
},
"request_id": "019f4d93-5a68-7a23-b2e7-8a1bc55e50f8",
"status": "OK"
}
@@ -1,88 +0,0 @@
{
"error": null,
"gateway_request_id": "019f4db7-e55c-78d1-9719-0303ca50a961",
"payload": {
"animation_fbx": null,
"animation_glb": null,
"basic_animations": null,
"model_glb": {
"content_type": "model/gltf-binary",
"file_name": "model.glb",
"file_size": 15943768,
"url": "https://v3b.fal.media/files/b/0aa1bbe1/0vrf9NUhVxKZO4GScA0EI_model.glb"
},
"model_urls": {
"blend": null,
"fbx": {
"content_type": "application/octet-stream",
"file_name": "model.fbx",
"file_size": 13665964,
"url": "https://v3b.fal.media/files/b/0aa1bbed/yKO-HOjBnWA-LZpRLHHSB_model.fbx"
},
"glb": {
"content_type": "model/gltf-binary",
"file_name": "model.glb",
"file_size": 15943768,
"url": "https://v3b.fal.media/files/b/0aa1bbe1/0vrf9NUhVxKZO4GScA0EI_model.glb"
},
"obj": {
"content_type": "text/plain",
"file_name": "model.obj",
"file_size": 3083671,
"url": "https://v3b.fal.media/files/b/0aa1bbe2/NBmqIWBS8LuY_BPYWkOOf_model.obj"
},
"stl": {
"content_type": "application/octet-stream",
"file_name": "model.stl",
"file_size": 1508384,
"url": "https://v3b.fal.media/files/b/0aa1bbe2/3wtOE6iEeVHZFvwAGN6Rl_model.stl"
},
"usdz": {
"content_type": "model/vnd.usdz+zip",
"file_name": "model.usdz",
"file_size": 16861118,
"url": "https://v3b.fal.media/files/b/0aa1bbed/BsniPBo6Uev85V4hLFACR_model.usdz"
}
},
"rig_task_id": null,
"rigged_character_fbx": null,
"rigged_character_glb": null,
"seed": 274321742,
"texture_urls": [
{
"base_color": {
"content_type": "image/png",
"file_name": "texture_0.png",
"file_size": 7539838,
"url": "https://v3b.fal.media/files/b/0aa1bbe3/EXWiAuAYPobYiJPnW66mQ_texture_0.png"
},
"metallic": {
"content_type": "image/png",
"file_name": "texture_0_metallic.png",
"file_size": 858941,
"url": "https://v3b.fal.media/files/b/0aa1bbee/RTZIZRCDQTvN7jdl4EL_C_texture_0_metallic.png"
},
"normal": {
"content_type": "image/png",
"file_name": "texture_0_normal.png",
"file_size": 8236038,
"url": "https://v3b.fal.media/files/b/0aa1bbe3/yNk-S5Dhw2xc9fb3fIjwD_texture_0_normal.png"
},
"roughness": {
"content_type": "image/png",
"file_name": "texture_0_roughness.png",
"file_size": 1184908,
"url": "https://v3b.fal.media/files/b/0aa1bbe3/nnT25g7wgul2oDTRlI_jX_texture_0_roughness.png"
}
}
],
"thumbnail": {
"content_type": "image/png",
"file_name": "preview.png",
"file_size": 192910,
"url": "https://v3b.fal.media/files/b/0aa1bbe3/tn1qfOr12oGXecA4mRtlI_preview.png"
}
},
"request_id": "019f4db7-e55c-78d1-9719-0303ca50a961",
"status": "OK"
}
@@ -1,18 +0,0 @@
{
"error": null,
"gateway_request_id": "019f4dc7-e98b-7f90-94c5-a8b131ff2660",
"payload": {
"audio": {
"bitrate": null,
"channels": null,
"content_type": "audio/mpeg",
"duration": 6.63995,
"file_name": "speech.mp3",
"file_size": 53612,
"sample_rate": 24000,
"url": "https://v3b.fal.media/files/b/0aa1bc36/vOSCcxsbibkZIiXR9BIZn_speech.mp3"
}
},
"request_id": "019f4dc7-e98b-7f90-94c5-a8b131ff2660",
"status": "OK"
}
@@ -1,36 +0,0 @@
{
"error": null,
"gateway_request_id": "019f4db5-aadd-76c3-8122-ff1ba53fe48c",
"payload": {
"model_mesh": {
"content_type": "model/gltf-binary",
"file_name": "model.glb",
"file_size": 43981280,
"url": "https://v3b.fal.media/files/b/0aa1bbdb/d8ATWvvXYHY-8zj-lQecf_model.glb"
},
"model_urls": {
"base_model": null,
"fbx": null,
"glb": {
"content_type": "model/gltf-binary",
"file_name": "model.glb",
"file_size": 43981280,
"url": "https://v3b.fal.media/files/b/0aa1bbdb/d8ATWvvXYHY-8zj-lQecf_model.glb"
},
"pbr_model": {
"content_type": "model/gltf-binary",
"file_name": "model.glb",
"file_size": 43981280,
"url": "https://v3b.fal.media/files/b/0aa1bbdb/d8ATWvvXYHY-8zj-lQecf_model.glb"
}
},
"rendered_image": {
"content_type": "image/png",
"file_name": "preview.png",
"file_size": 30970,
"url": "https://v3b.fal.media/files/b/0aa1bbd0/MblV2S5R6CeskRABMS7-V_preview.png"
}
},
"request_id": "019f4db5-aadd-76c3-8122-ff1ba53fe48c",
"status": "OK"
}
@@ -1,28 +0,0 @@
{
"error": null,
"gateway_request_id": "019f4d0f-4cf2-76d3-b99b-c400641b3844",
"payload": {
"model_mesh": {
"content_type": "application/octet-stream",
"file_name": "output.ply",
"file_size": 17826208,
"url": "https://v3b.fal.media/files/b/0aa1b77c/hEzg5xn9A-1qrLsnywjN4_output.ply"
},
"num_gaussians": 262144,
"preprocessed_image": {
"content_type": "image/png",
"file_name": "61fc73dbe84446b08b1fcce2167b71d5.png",
"file_size": 952754,
"height": 1024,
"url": "https://v3b.fal.media/files/b/0aa1b77c/YyJ19g7lJsRNXmvZvahTE_61fc73dbe84446b08b1fcce2167b71d5.png",
"width": 1024
},
"seed": 1637204116,
"timings": {
"export": 0.794984320000367,
"inference": 4.911052849000043
}
},
"request_id": "019f4d0f-4cf2-76d3-b99b-c400641b3844",
"status": "OK"
}
@@ -1,421 +0,0 @@
use chrono::{DateTime, Utc};
use log::info;
use serde::Deserialize;
use wreq::Client;
use wreq_util::Emulation;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_client_error::Seedance2ProClientError;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::error::seedance2pro_generic_api_error::Seedance2ProGenericApiError;
use crate::requests::kinovi_host::{KinoviHost, resolve_host};
use crate::utils::common_headers::FIREFOX_USER_AGENT;
use crate::utils::number_coercion::de_u64_int_or_float;
/// Page size the kinovi.ai billing dashboard uses.
pub const DEFAULT_BILLING_PAGE_LIMIT: u32 = 20;
// --- Args & response ---
pub struct GetBillingPaymentsHistoryArgs<'a> {
pub session: &'a Seedance2ProSession,
/// Page size. The web dashboard uses [`DEFAULT_BILLING_PAGE_LIMIT`].
pub limit: u32,
/// Zero-based row offset. Pagination is offset-based: advance by the
/// number of payments actually returned until a page comes back empty.
pub offset: u64,
/// Override the default host (kinovi.ai).
pub host_override: Option<KinoviHost>,
}
pub struct BillingPaymentsHistoryPage {
/// Payments on this page, newest first.
pub payments: Vec<BillingPayment>,
/// Cursor of the row after this page (`None` on the last page). The
/// dashboard paginates by limit/offset; this is informational.
pub maybe_next_cursor: Option<String>,
/// Total payment rows on the account, per the server.
pub total: u64,
}
// --- Public types ---
/// One payment on the Kinovi billing dashboard (a credit-package purchase).
#[derive(Debug, Clone)]
pub struct BillingPayment {
pub id: String,
/// Payment amount in US dollars (the API returns dollars: `2159`, `99.99`).
pub amount_usd: f64,
pub status: BillingPaymentStatus,
pub payment_type: BillingPaymentType,
/// Kinovi credits granted by this payment (e.g. 525,000).
pub credits_earned: u64,
/// e.g. "Credits Package"
pub product_name: String,
pub transaction_id: String,
pub payment_id: String,
pub product_id: String,
/// ISO 8601 creation timestamp (e.g. `"2026-07-13T20:40:49.405Z"`).
pub created_at: String,
/// Parsed `created_at`. `None` if the raw string could not be parsed.
pub created_at_utc: Option<DateTime<Utc>>,
pub updated_at: String,
}
/// Payment status. Only `PAID` has been observed; unrecognised values are
/// preserved in `Unknown` so future statuses (refunds, chargebacks, pending)
/// don't break deserialization.
#[derive(Debug, Clone, PartialEq)]
pub enum BillingPaymentStatus {
Paid,
Unknown(String),
}
impl BillingPaymentStatus {
fn from_str(s: &str) -> Self {
match s {
"PAID" => Self::Paid,
other => Self::Unknown(other.to_string()),
}
}
}
/// Payment type. Only `ONE_TIME` has been observed.
#[derive(Debug, Clone, PartialEq)]
pub enum BillingPaymentType {
OneTime,
Unknown(String),
}
impl BillingPaymentType {
fn from_str(s: &str) -> Self {
match s {
"ONE_TIME" => Self::OneTime,
other => Self::Unknown(other.to_string()),
}
}
}
// --- Implementation ---
pub async fn get_billing_payments_history(
args: GetBillingPaymentsHistoryArgs<'_>,
) -> Result<BillingPaymentsHistoryPage, Seedance2ProError> {
let host = resolve_host(args.host_override.as_ref());
let base_url = host.api_base_url();
let url = format!("{}/api/trpc/billing.getPayments", base_url);
info!("Fetching billing payments (limit: {}, offset: {})...", args.limit, args.offset);
let input_json = build_input_json(args.limit, args.offset);
let client = Client::builder()
.emulation(Emulation::Firefox143)
.build()
.map_err(|err| Seedance2ProClientError::WreqClientError(err))?;
let cookie = args.session.cookies.as_str();
let referer = format!("{}/app/dashboard/billing", base_url);
let request = client.get(&url)
.query(&[("batch", "1"), ("input", input_json.as_str())])
.header("User-Agent", FIREFOX_USER_AGENT)
.header("Accept", "*/*")
.header("Accept-Language", "en-US,en;q=0.9")
.header("Accept-Encoding", "gzip, deflate, br, zstd")
.header("Referer", &referer)
.header("content-type", "application/json")
.header("x-trpc-source", "client")
.header("Connection", "keep-alive")
.header("Cookie", cookie)
.header("Sec-Fetch-Dest", "empty")
.header("Sec-Fetch-Mode", "cors")
.header("Sec-Fetch-Site", "same-origin")
.header("Priority", "u=4")
.header("TE", "trailers")
.build()
.map_err(|err| Seedance2ProClientError::WreqClientError(err))?;
let response = client.execute(request)
.await
.map_err(|err| Seedance2ProGenericApiError::WreqError(err))?;
let status = response.status();
let response_body = response.text()
.await
.map_err(|err| Seedance2ProGenericApiError::WreqError(err))?;
info!("Billing payments response status: {}", status);
if !status.is_success() {
return Err(Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: status,
body: response_body,
}.into());
}
parse_response_body(&response_body)
}
fn parse_response_body(response_body: &str) -> Result<BillingPaymentsHistoryPage, Seedance2ProError> {
let batch_response: Vec<BatchResponseItem> = serde_json::from_str(response_body)
.map_err(|err| Seedance2ProGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?;
let json = batch_response
.into_iter()
.next()
.ok_or_else(|| Seedance2ProGenericApiError::UnexpectedResponseShape {
explanation: "Empty batch response array".to_string(),
raw_body: response_body.to_string(),
})?
.result
.data
.json;
let payments = json.payments
.into_iter()
.map(|p| {
let created_at_utc = DateTime::parse_from_rfc3339(&p.created_at)
.map(|dt| dt.with_timezone(&Utc))
.ok();
BillingPayment {
id: p.id,
amount_usd: p.amount,
status: BillingPaymentStatus::from_str(&p.payment_status),
payment_type: BillingPaymentType::from_str(&p.payment_type),
credits_earned: p.credits_earned,
product_name: p.product_name,
transaction_id: p.transaction_id,
payment_id: p.payment_id,
product_id: p.product_id,
created_at: p.created_at,
created_at_utc,
updated_at: p.updated_at,
}
})
.collect();
Ok(BillingPaymentsHistoryPage {
payments,
maybe_next_cursor: json.next_cursor,
total: json.total,
})
}
/// Builds the tRPC `input` JSON for the billing.getPayments endpoint.
fn build_input_json(limit: u32, offset: u64) -> String {
format!(r#"{{"0":{{"json":{{"limit":{limit},"offset":{offset}}}}}}}"#)
}
// --- Raw response types ---
#[derive(Deserialize, Debug)]
struct BatchResponseItem {
result: RawResult,
}
#[derive(Deserialize, Debug)]
struct RawResult {
data: RawData,
}
#[derive(Deserialize, Debug)]
struct RawData {
json: RawJson,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct RawJson {
payments: Vec<RawPayment>,
#[serde(default)]
next_cursor: Option<String>,
#[serde(deserialize_with = "de_u64_int_or_float")]
total: u64,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct RawPayment {
id: String,
amount: f64,
payment_status: String,
#[serde(default)]
transaction_id: String,
#[serde(default)]
payment_id: String,
#[serde(default)]
product_id: String,
#[serde(rename = "type")]
payment_type: String,
#[serde(deserialize_with = "de_u64_int_or_float")]
credits_earned: u64,
#[serde(default)]
product_name: String,
created_at: String,
#[serde(default)]
updated_at: String,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::test_utils::get_test_cookies::get_test_cookies;
use crate::test_utils::setup_test_logging::setup_test_logging;
use errors::AnyhowResult;
use log::LevelFilter;
mod offline_parsing {
use super::*;
/// Trimmed verbatim from a captured billing.getPayments response
/// (external/requests/sites/kinovi.ai/2026-07-13-billing/).
const CAPTURED_PAGE: &str = r#"[{"result":{"data":{"json":{"payments":[
{"id":"31722","amount":2159,"paymentStatus":"PAID","transactionId":"","paymentId":"","createdAt":"2026-07-13T20:40:49.405Z","updatedAt":"2026-07-13T20:40:49.405Z","productId":"","type":"ONE_TIME","creditsEarned":525000,"productName":"Credits Package"},
{"id":"11491","amount":99.99,"paymentStatus":"PAID","transactionId":"202603141400154949180","paymentId":"202603141400154949180","createdAt":"2026-03-14T14:00:15.782Z","updatedAt":"2026-03-14T14:01:02.630Z","productId":"","type":"ONE_TIME","creditsEarned":22000,"productName":"Credits Package"}
],"nextCursor":"29564","total":997}}}}]"#;
#[test]
fn parses_captured_page() {
let page = parse_response_body(CAPTURED_PAGE).expect("parse");
assert_eq!(page.payments.len(), 2);
assert_eq!(page.maybe_next_cursor.as_deref(), Some("29564"));
assert_eq!(page.total, 997);
let first = &page.payments[0];
assert_eq!(first.id, "31722");
assert_eq!(first.amount_usd, 2159.0);
assert_eq!(first.status, BillingPaymentStatus::Paid);
assert_eq!(first.payment_type, BillingPaymentType::OneTime);
assert_eq!(first.credits_earned, 525_000);
assert!(first.created_at_utc.is_some());
let second = &page.payments[1];
assert_eq!(second.amount_usd, 99.99);
assert_eq!(second.transaction_id, "202603141400154949180");
}
#[test]
fn last_page_has_no_next_cursor() {
let body = r#"[{"result":{"data":{"json":{"payments":[],"nextCursor":null,"total":997}}}}]"#;
let page = parse_response_body(body).expect("parse");
assert!(page.payments.is_empty());
assert_eq!(page.maybe_next_cursor, None);
}
#[test]
fn unknown_status_and_type_are_preserved() {
let body = r#"[{"result":{"data":{"json":{"payments":[
{"id":"1","amount":5,"paymentStatus":"REFUNDED","type":"SUBSCRIPTION","creditsEarned":0,"createdAt":"2026-07-13T20:40:49.405Z"}
],"nextCursor":null,"total":1}}}}]"#;
let page = parse_response_body(body).expect("parse");
assert_eq!(page.payments[0].status, BillingPaymentStatus::Unknown("REFUNDED".to_string()));
assert_eq!(page.payments[0].payment_type, BillingPaymentType::Unknown("SUBSCRIPTION".to_string()));
}
}
mod live {
use super::*;
#[tokio::test]
#[ignore] // manual test — requires real cookies
async fn live_fetch_first_payments_page() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Info);
let session = test_session()?;
let page = get_billing_payments_history(GetBillingPaymentsHistoryArgs {
session: &session,
limit: DEFAULT_BILLING_PAGE_LIMIT,
offset: 0,
host_override: None,
}).await?;
println!("payments: {}, total: {}, next_cursor: {:?}",
page.payments.len(), page.total, page.maybe_next_cursor);
for payment in &page.payments {
println!(" {} | {} | ${:.2} | {} credits | {:?} | {:?}",
payment.id, payment.created_at, payment.amount_usd,
payment.credits_earned, payment.status, payment.payment_type);
}
assert!(!page.payments.is_empty());
Ok(())
}
/// Pages through the ENTIRE payments history with throttling and prints
/// lifetime totals. ~1,000 rows / 50 pages as of 2026-07.
/// cargo test -p seedance2pro_client live_paginate_entire_payments_history -- --ignored --nocapture
#[tokio::test]
#[ignore] // manual test — requires real cookies; fetches every page
async fn live_paginate_entire_payments_history() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Info);
let session = test_session()?;
const PAGE_DELAY_MS: u64 = 250;
const MAX_PAGES: usize = 2_000; // runaway guard
let mut offset: u64 = 0;
let mut pages = 0usize;
let mut total_rows = 0u64;
let mut total_amount_usd = 0f64;
let mut total_credits = 0u64;
let mut last_reported_total = 0u64;
for _ in 0..MAX_PAGES {
let page = get_billing_payments_history(GetBillingPaymentsHistoryArgs {
session: &session,
limit: DEFAULT_BILLING_PAGE_LIMIT,
offset,
host_override: None,
}).await?;
pages += 1;
last_reported_total = page.total;
if page.payments.is_empty() {
break;
}
for payment in &page.payments {
total_rows += 1;
total_amount_usd += payment.amount_usd;
total_credits += payment.credits_earned;
}
println!("page {} (offset {}): {} payments, running total ${:.2}",
pages, offset, page.payments.len(), total_amount_usd);
// Advance by what was actually returned so a server-side limit cap
// can never skip rows.
offset += page.payments.len() as u64;
tokio::time::sleep(std::time::Duration::from_millis(PAGE_DELAY_MS)).await;
}
println!("── payments history totals ──");
println!("pages: {}", pages);
println!("payments: {} (server reports total {})", total_rows, last_reported_total);
println!("lifetime spend: ${:.2}", total_amount_usd);
println!("lifetime credits: {}", total_credits);
assert!(total_rows > 0);
Ok(())
}
fn test_session() -> AnyhowResult<Seedance2ProSession> {
let cookies = get_test_cookies()?;
Ok(Seedance2ProSession::from_cookies_string(cookies))
}
}
}
@@ -1,415 +0,0 @@
use chrono::{DateTime, Utc};
use log::info;
use serde::Deserialize;
use wreq::Client;
use wreq_util::Emulation;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_client_error::Seedance2ProClientError;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::error::seedance2pro_generic_api_error::Seedance2ProGenericApiError;
use crate::requests::kinovi_host::{KinoviHost, resolve_host};
use crate::utils::common_headers::FIREFOX_USER_AGENT;
use crate::utils::number_coercion::{de_i64_int_or_float, de_u64_int_or_float};
/// Page size the kinovi.ai billing dashboard uses.
pub const DEFAULT_CREDITS_PAGE_LIMIT: u32 = 20;
// --- Args & response ---
pub struct GetCreditsHistoryArgs<'a> {
pub session: &'a Seedance2ProSession,
/// Page size. The web dashboard uses [`DEFAULT_CREDITS_PAGE_LIMIT`].
pub limit: u32,
/// Zero-based row offset. Pagination is offset-based: advance by the
/// number of entries actually returned until a page comes back empty.
/// NB: the ledger grows while you page (offsets shift under live traffic),
/// so dedupe by entry `id` when sweeping the full history.
pub offset: u64,
/// Override the default host (kinovi.ai).
pub host_override: Option<KinoviHost>,
}
pub struct CreditsHistoryPage {
/// Ledger entries on this page, newest first.
pub entries: Vec<CreditHistoryEntry>,
/// Total ledger rows on the account, per the server.
pub total: u64,
}
// --- Public types ---
/// One row of the Kinovi credits ledger: a signed credit delta attributed to
/// a generation order (consumption) or a grant (purchase, registration bonus).
#[derive(Debug, Clone)]
pub struct CreditHistoryEntry {
pub id: u64,
pub entry_type: CreditHistoryEntryType,
/// Signed credit delta: negative for consumption, positive for grants.
pub credit_delta: i64,
/// The generation order this entry bills, when applicable (`ord_...`).
/// `None` for grants (purchases, registration bonuses).
pub maybe_order_id: Option<String>,
/// e.g. "Seedance 2.0 Video", "Credit Package - xl (25000 credits)"
pub product_name: String,
/// ISO 8601 creation timestamp (e.g. `"2026-07-14T00:31:12.820Z"`).
pub created_at: String,
/// Parsed `created_at`. `None` if the raw string could not be parsed.
pub created_at_utc: Option<DateTime<Utc>>,
}
/// Ledger entry type. Observed values are mapped; anything new (refunds,
/// failure credits, expirations, ...) lands in `Unknown` so deserialization
/// never breaks.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum CreditHistoryEntryType {
/// Credits consumed by a successful generation (negative delta).
ConsumptionSuccess,
/// Credits granted by a package purchase (positive delta).
PurchasedCredits,
/// Sign-up bonus credits (positive delta).
NewUserRegistration,
Unknown(String),
}
impl CreditHistoryEntryType {
pub fn as_str(&self) -> &str {
match self {
Self::ConsumptionSuccess => "CONSUMPTION_SUCCESS",
Self::PurchasedCredits => "PURCHASED_CREDITS",
Self::NewUserRegistration => "NEW_USER_REGISTRATION",
Self::Unknown(other) => other.as_str(),
}
}
fn from_str(s: &str) -> Self {
match s {
"CONSUMPTION_SUCCESS" => Self::ConsumptionSuccess,
"PURCHASED_CREDITS" => Self::PurchasedCredits,
"NEW_USER_REGISTRATION" => Self::NewUserRegistration,
other => Self::Unknown(other.to_string()),
}
}
}
// --- Implementation ---
pub async fn get_credits_history(
args: GetCreditsHistoryArgs<'_>,
) -> Result<CreditsHistoryPage, Seedance2ProError> {
let host = resolve_host(args.host_override.as_ref());
let base_url = host.api_base_url();
let url = format!("{}/api/trpc/credits.getCreditHistory", base_url);
info!("Fetching credits history (limit: {}, offset: {})...", args.limit, args.offset);
let input_json = build_input_json(args.limit, args.offset);
let client = Client::builder()
.emulation(Emulation::Firefox143)
.build()
.map_err(|err| Seedance2ProClientError::WreqClientError(err))?;
let cookie = args.session.cookies.as_str();
let referer = format!("{}/app/dashboard/billing", base_url);
let request = client.get(&url)
.query(&[("batch", "1"), ("input", input_json.as_str())])
.header("User-Agent", FIREFOX_USER_AGENT)
.header("Accept", "*/*")
.header("Accept-Language", "en-US,en;q=0.9")
.header("Accept-Encoding", "gzip, deflate, br, zstd")
.header("Referer", &referer)
.header("content-type", "application/json")
.header("x-trpc-source", "client")
.header("Connection", "keep-alive")
.header("Cookie", cookie)
.header("Sec-Fetch-Dest", "empty")
.header("Sec-Fetch-Mode", "cors")
.header("Sec-Fetch-Site", "same-origin")
.header("Priority", "u=4")
.header("TE", "trailers")
.build()
.map_err(|err| Seedance2ProClientError::WreqClientError(err))?;
let response = client.execute(request)
.await
.map_err(|err| Seedance2ProGenericApiError::WreqError(err))?;
let status = response.status();
let response_body = response.text()
.await
.map_err(|err| Seedance2ProGenericApiError::WreqError(err))?;
info!("Credits history response status: {}", status);
if !status.is_success() {
return Err(Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: status,
body: response_body,
}.into());
}
parse_response_body(&response_body)
}
fn parse_response_body(response_body: &str) -> Result<CreditsHistoryPage, Seedance2ProError> {
let batch_response: Vec<BatchResponseItem> = serde_json::from_str(response_body)
.map_err(|err| Seedance2ProGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?;
let json = batch_response
.into_iter()
.next()
.ok_or_else(|| Seedance2ProGenericApiError::UnexpectedResponseShape {
explanation: "Empty batch response array".to_string(),
raw_body: response_body.to_string(),
})?
.result
.data
.json;
let entries = json.credits
.into_iter()
.map(|entry| {
let created_at_utc = DateTime::parse_from_rfc3339(&entry.created_at)
.map(|dt| dt.with_timezone(&Utc))
.ok();
CreditHistoryEntry {
id: entry.id,
entry_type: CreditHistoryEntryType::from_str(&entry.entry_type),
credit_delta: entry.credit,
maybe_order_id: entry.order_id,
product_name: entry.product_name,
created_at: entry.created_at,
created_at_utc,
}
})
.collect();
Ok(CreditsHistoryPage {
entries,
total: json.total,
})
}
/// Builds the tRPC `input` JSON for the credits.getCreditHistory endpoint.
fn build_input_json(limit: u32, offset: u64) -> String {
format!(r#"{{"0":{{"json":{{"limit":{limit},"offset":{offset}}}}}}}"#)
}
// --- Raw response types ---
#[derive(Deserialize, Debug)]
struct BatchResponseItem {
result: RawResult,
}
#[derive(Deserialize, Debug)]
struct RawResult {
data: RawData,
}
#[derive(Deserialize, Debug)]
struct RawData {
json: RawJson,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct RawJson {
credits: Vec<RawCreditEntry>,
#[serde(deserialize_with = "de_u64_int_or_float")]
total: u64,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct RawCreditEntry {
#[serde(deserialize_with = "de_u64_int_or_float")]
id: u64,
#[serde(rename = "type")]
entry_type: String,
#[serde(deserialize_with = "de_i64_int_or_float")]
credit: i64,
#[serde(default)]
order_id: Option<String>,
created_at: String,
#[serde(default)]
product_name: String,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::test_utils::get_test_cookies::get_test_cookies;
use crate::test_utils::setup_test_logging::setup_test_logging;
use errors::AnyhowResult;
use log::LevelFilter;
mod offline_parsing {
use super::*;
/// Trimmed verbatim from a captured credits.getCreditHistory response
/// (external/requests/sites/kinovi.ai/2026-07-13-billing/).
const CAPTURED_PAGE: &str = r#"[{"result":{"data":{"json":{"credits":[
{"id":2750659,"type":"CONSUMPTION_SUCCESS","credit":-454,"orderId":"ord_mff2cjk2feczmzqweizcdal2","createdAt":"2026-07-14T00:31:12.820Z","productName":"Seedance 2.0 Video"},
{"id":591799,"type":"PURCHASED_CREDITS","credit":25000,"orderId":null,"createdAt":"2026-02-19T08:16:58.524Z","productName":"Credit Package - xl (25000 credits)"},
{"id":563856,"type":"NEW_USER_REGISTRATION","credit":200,"orderId":null,"createdAt":"2026-02-18T01:02:52.747Z","productName":"unknown"}
],"total":304617}}}}]"#;
#[test]
fn parses_captured_page() {
let page = parse_response_body(CAPTURED_PAGE).expect("parse");
assert_eq!(page.entries.len(), 3);
assert_eq!(page.total, 304_617);
let consumption = &page.entries[0];
assert_eq!(consumption.id, 2_750_659);
assert_eq!(consumption.entry_type, CreditHistoryEntryType::ConsumptionSuccess);
assert_eq!(consumption.credit_delta, -454);
assert_eq!(consumption.maybe_order_id.as_deref(), Some("ord_mff2cjk2feczmzqweizcdal2"));
assert!(consumption.created_at_utc.is_some());
let purchase = &page.entries[1];
assert_eq!(purchase.entry_type, CreditHistoryEntryType::PurchasedCredits);
assert_eq!(purchase.credit_delta, 25_000);
assert_eq!(purchase.maybe_order_id, None);
let registration = &page.entries[2];
assert_eq!(registration.entry_type, CreditHistoryEntryType::NewUserRegistration);
assert_eq!(registration.credit_delta, 200);
}
#[test]
fn unknown_entry_type_is_preserved() {
let body = r#"[{"result":{"data":{"json":{"credits":[
{"id":1,"type":"CONSUMPTION_REFUND","credit":454,"orderId":"ord_x","createdAt":"2026-07-14T00:31:12.820Z","productName":"Seedance 2.0 Video"}
],"total":1}}}}]"#;
let page = parse_response_body(body).expect("parse");
assert_eq!(
page.entries[0].entry_type,
CreditHistoryEntryType::Unknown("CONSUMPTION_REFUND".to_string()),
);
assert_eq!(page.entries[0].credit_delta, 454);
}
#[test]
fn entry_type_round_trips_as_str() {
for raw in ["CONSUMPTION_SUCCESS", "PURCHASED_CREDITS", "NEW_USER_REGISTRATION", "SOMETHING_NEW"] {
assert_eq!(CreditHistoryEntryType::from_str(raw).as_str(), raw);
}
}
}
mod live {
use super::*;
use std::collections::HashMap;
use std::collections::HashSet;
#[tokio::test]
#[ignore] // manual test — requires real cookies
async fn live_fetch_first_credits_page() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Info);
let session = test_session()?;
let page = get_credits_history(GetCreditsHistoryArgs {
session: &session,
limit: DEFAULT_CREDITS_PAGE_LIMIT,
offset: 0,
host_override: None,
}).await?;
println!("entries: {}, total: {}", page.entries.len(), page.total);
for entry in &page.entries {
println!(" {} | {} | {:?} | {} | {:?}",
entry.id, entry.created_at, entry.entry_type, entry.credit_delta,
entry.maybe_order_id);
}
assert!(!page.entries.is_empty());
Ok(())
}
/// Pages through the ENTIRE credits ledger with throttling and prints
/// per-type tallies (this is where any refund-like entry types will
/// surface). ~300k rows as of 2026-07 — expect a long run; the ledger
/// also grows while paging, so entries are deduped by id.
/// cargo test -p seedance2pro_client live_paginate_entire_credits_history -- --ignored --nocapture
#[tokio::test]
#[ignore] // manual test — requires real cookies; fetches every page
async fn live_paginate_entire_credits_history() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Info);
let session = test_session()?;
const PAGE_LIMIT: u32 = 100;
const PAGE_DELAY_MS: u64 = 250;
const MAX_PAGES: usize = 50_000; // runaway guard
let mut offset: u64 = 0;
let mut pages = 0usize;
let mut seen_ids: HashSet<u64> = HashSet::new();
let mut type_counts: HashMap<CreditHistoryEntryType, (u64, i64)> = HashMap::new();
let mut last_reported_total = 0u64;
for _ in 0..MAX_PAGES {
let page = get_credits_history(GetCreditsHistoryArgs {
session: &session,
limit: PAGE_LIMIT,
offset,
host_override: None,
}).await?;
pages += 1;
last_reported_total = page.total;
if page.entries.is_empty() {
break;
}
for entry in &page.entries {
if !seen_ids.insert(entry.id) {
continue; // offset shifted under live traffic; already counted
}
let tally = type_counts.entry(entry.entry_type.clone()).or_insert((0, 0));
tally.0 += 1;
tally.1 += entry.credit_delta;
}
if pages % 25 == 0 {
println!("page {} (offset {}): {} unique entries so far (server total {})",
pages, offset, seen_ids.len(), page.total);
}
// Advance by what was actually returned so a server-side limit cap
// can never skip rows.
offset += page.entries.len() as u64;
tokio::time::sleep(std::time::Duration::from_millis(PAGE_DELAY_MS)).await;
}
println!("── credits ledger totals ──");
println!("pages: {}", pages);
println!("unique entries: {} (server reports total {})", seen_ids.len(), last_reported_total);
let mut types: Vec<_> = type_counts.iter().collect();
types.sort_by_key(|(_, (count, _))| std::cmp::Reverse(*count));
for (entry_type, (count, delta_sum)) in types {
println!(" {:<28} n={:<8} credit_sum={}", entry_type.as_str(), count, delta_sum);
}
assert!(!seen_ids.is_empty());
Ok(())
}
fn test_session() -> AnyhowResult<Seedance2ProSession> {
let cookies = get_test_cookies()?;
Ok(Seedance2ProSession::from_cookies_string(cookies))
}
}
}
@@ -1,2 +0,0 @@
pub mod get_billing_payments_history;
pub mod get_credits_history;
@@ -1,620 +0,0 @@
use serde_derive::Serialize;
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_client_error::Seedance2ProClientError;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::requests::kinovi_host::KinoviHost;
use crate::requests::workflow_run_task::workflow_run_task::{
workflow_run_task_custom, WorkflowRunTaskCustomArgs, WorkflowRunTaskResponse,
};
const BUSINESS_TYPE: &str = "seedream-5-pro-image-generation";
const MODEL: &str = "seedream-5.0-pro";
/// Kinovi allows at most this many reference images per request.
pub const MAX_REFERENCE_IMAGES: usize = 14;
/// Credits per generated image at 1k resolution.
const CREDITS_PER_IMAGE_1K: u64 = 14;
/// Credits per generated image at 2k resolution.
const CREDITS_PER_IMAGE_2K: u64 = 24;
// ── Args ──
pub struct GenerateSeedream5p0ProArgs<'a> {
pub request: GenerateSeedream5p0ProRequest,
pub session: &'a Seedance2ProSession,
pub host_override: Option<KinoviHost>,
}
// ── Request ──
/// Seedream 5.0 Pro: text-to-image (optionally with reference images).
#[derive(Clone, Debug)]
pub struct GenerateSeedream5p0ProRequest {
/// Reference images are cited in the prompt as @Image1, @Image2, etc.
pub prompt: String,
pub aspect_ratio: KinoviSeedream5p0ProAspectRatio,
pub resolution: KinoviSeedream5p0ProResolution,
/// Number of images to generate in a single request.
pub batch_count: KinoviSeedream5p0ProBatchCount,
/// Optional reference image URLs, up to [`MAX_REFERENCE_IMAGES`].
/// Referenced in prompts as @Image1, @Image2, etc.
pub reference_image_urls: Option<Vec<String>>,
}
// ── Enums ──
/// Aspect ratios supported by Seedream 5.0 Pro.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum KinoviSeedream5p0ProAspectRatio {
/// Let the model pick (useful with reference images).
Auto,
Square1x1,
Standard4x3,
Portrait3x4,
Landscape16x9,
Portrait9x16,
Wide3x2,
Tall2x3,
UltraWide21x9,
}
/// Output resolution. Determines the per-image credit price.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum KinoviSeedream5p0ProResolution {
/// 1k (14 credits per image)
OneK,
/// 2k (24 credits per image)
TwoK,
}
/// Number of images to generate in a single request.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum KinoviSeedream5p0ProBatchCount {
One,
Two,
Three,
Four,
Five,
Six,
Seven,
Eight,
}
// ── Pricing ──
//
// Seedream 5.0 Pro credit pricing (per image; batch count multiplies):
//
// | Resolution | Credits/image |
// |------------|---------------|
// | 1k | 14 |
// | 2k | 24 |
//
// Reference images do not affect cost.
// Credit package: 525,000 credits for $2,159.0909 (~243.16 credits/$1, rounded down to 243).
impl GenerateSeedream5p0ProRequest {
/// Calculate the cost of this generation request, in Kinovi credits and
/// USD cents.
pub fn calculate_costs(&self) -> KinoviGenerationCost {
let credits_per_image = match self.resolution {
KinoviSeedream5p0ProResolution::OneK => CREDITS_PER_IMAGE_1K,
KinoviSeedream5p0ProResolution::TwoK => CREDITS_PER_IMAGE_2K,
};
let total_credits = credits_per_image * u64::from(self.batch_count.as_u8());
KinoviGenerationCost::from_kinovi_credits(total_credits)
}
}
impl KinoviSeedream5p0ProBatchCount {
fn as_u8(&self) -> u8 {
match self {
Self::One => 1,
Self::Two => 2,
Self::Three => 3,
Self::Four => 4,
Self::Five => 5,
Self::Six => 6,
Self::Seven => 7,
Self::Eight => 8,
}
}
}
impl KinoviSeedream5p0ProAspectRatio {
fn as_api_str(&self) -> &'static str {
match self {
Self::Auto => "auto",
Self::Square1x1 => "1:1",
Self::Standard4x3 => "4:3",
Self::Portrait3x4 => "3:4",
Self::Landscape16x9 => "16:9",
Self::Portrait9x16 => "9:16",
Self::Wide3x2 => "3:2",
Self::Tall2x3 => "2:3",
Self::UltraWide21x9 => "21:9",
}
}
}
impl KinoviSeedream5p0ProResolution {
fn as_api_str(&self) -> &'static str {
match self {
Self::OneK => "1k",
Self::TwoK => "2k",
}
}
}
// ── Response ──
pub struct GenerateSeedream5p0ProResponse {
pub task_id: String,
pub order_id: String,
/// Present when batch_count > 1.
pub task_ids: Option<Vec<String>>,
/// Present when batch_count > 1.
pub order_ids: Option<Vec<String>>,
}
// ── Entry point ──
pub async fn generate_seedream_5p0_pro(
args: GenerateSeedream5p0ProArgs<'_>,
) -> Result<GenerateSeedream5p0ProResponse, Seedance2ProError> {
let api_params = build_api_params(&args.request)?;
let raw_response: WorkflowRunTaskResponse = workflow_run_task_custom(WorkflowRunTaskCustomArgs {
business_type: BUSINESS_TYPE,
api_params,
session: args.session,
host_override: args.host_override,
}).await?;
Ok(GenerateSeedream5p0ProResponse {
task_id: raw_response.task_id,
order_id: raw_response.order_id,
task_ids: raw_response.task_ids,
order_ids: raw_response.order_ids,
})
}
// ── Wire payload ──
/// The `apiParams` shape for `seedream-5-pro-image-generation`. Field order
/// matches the captured browser traffic.
#[derive(Serialize, Debug)]
struct Seedream5p0ProApiParams {
prompt: String,
#[serde(rename = "aspectRatio")]
aspect_ratio: &'static str,
resolution: &'static str,
model: &'static str,
/// Omitted when generating a single image.
#[serde(rename = "batchCount", skip_serializing_if = "Option::is_none")]
batch_count: Option<u8>,
/// Omitted when no reference images are attached.
#[serde(rename = "uploadedUrls", skip_serializing_if = "Option::is_none")]
uploaded_urls: Option<Vec<String>>,
}
fn build_api_params(
request: &GenerateSeedream5p0ProRequest,
) -> Result<Seedream5p0ProApiParams, Seedance2ProClientError> {
let uploaded_urls = match &request.reference_image_urls {
Some(urls) if urls.len() > MAX_REFERENCE_IMAGES => {
return Err(Seedance2ProClientError::InvalidRequestField {
field: "reference_image_urls",
raw_value: format!("{} urls", urls.len()),
reason: format!("At most {} reference images are allowed", MAX_REFERENCE_IMAGES),
});
}
Some(urls) if !urls.is_empty() => Some(urls.clone()),
_ => None,
};
let batch_count_value = request.batch_count.as_u8();
let batch_count = if batch_count_value > 1 { Some(batch_count_value) } else { None };
Ok(Seedream5p0ProApiParams {
prompt: request.prompt.clone(),
aspect_ratio: request.aspect_ratio.as_api_str(),
resolution: request.resolution.as_api_str(),
model: MODEL,
batch_count,
uploaded_urls,
})
}
// ── Tests ──
#[cfg(test)]
mod tests {
use super::*;
fn base_request() -> GenerateSeedream5p0ProRequest {
GenerateSeedream5p0ProRequest {
prompt: "An anime girl riding a dinosaur".to_string(),
aspect_ratio: KinoviSeedream5p0ProAspectRatio::Auto,
resolution: KinoviSeedream5p0ProResolution::TwoK,
batch_count: KinoviSeedream5p0ProBatchCount::One,
reference_image_urls: None,
}
}
mod request_shape_tests {
use super::*;
/// Mirrors capture 1_seedream_5.txt (auto aspect ratio, 2k, single image,
/// no references).
#[test]
fn auto_2k_single_image() {
let params = build_api_params(&base_request()).unwrap();
assert_eq!(
serde_json::to_string(&params).unwrap(),
r#"{"prompt":"An anime girl riding a dinosaur","aspectRatio":"auto","resolution":"2k","model":"seedream-5.0-pro"}"#,
);
}
/// Mirrors capture 2_seedream_5_21x9.txt (21:9, 2k, single image).
#[test]
fn ultra_wide_21x9_2k() {
let params = build_api_params(&GenerateSeedream5p0ProRequest {
prompt: "a pirate sailing a pirate ship".to_string(),
aspect_ratio: KinoviSeedream5p0ProAspectRatio::UltraWide21x9,
resolution: KinoviSeedream5p0ProResolution::TwoK,
batch_count: KinoviSeedream5p0ProBatchCount::One,
reference_image_urls: None,
}).unwrap();
assert_eq!(
serde_json::to_string(&params).unwrap(),
r#"{"prompt":"a pirate sailing a pirate ship","aspectRatio":"21:9","resolution":"2k","model":"seedream-5.0-pro"}"#,
);
}
/// Mirrors capture 3_seedream_5_9x16_image_refs.txt (9:16, 1k, single
/// image, two reference images).
#[test]
fn portrait_9x16_1k_with_image_references() {
let params = build_api_params(&GenerateSeedream5p0ProRequest {
prompt: "The man in @Image1 is staring out the door in @Image2 ".to_string(),
aspect_ratio: KinoviSeedream5p0ProAspectRatio::Portrait9x16,
resolution: KinoviSeedream5p0ProResolution::OneK,
batch_count: KinoviSeedream5p0ProBatchCount::One,
reference_image_urls: Some(vec![
"https://static.seedance2-pro.com/materials/20260713/1783904468032-0a803390.jpg".to_string(),
"https://static.seedance2-pro.com/materials/20260713/1783904481112-4a3fa0d2.png".to_string(),
]),
}).unwrap();
assert_eq!(
serde_json::to_string(&params).unwrap(),
r#"{"prompt":"The man in @Image1 is staring out the door in @Image2 ","aspectRatio":"9:16","resolution":"1k","model":"seedream-5.0-pro","uploadedUrls":["https://static.seedance2-pro.com/materials/20260713/1783904468032-0a803390.jpg","https://static.seedance2-pro.com/materials/20260713/1783904481112-4a3fa0d2.png"]}"#,
);
}
/// Mirrors capture 4_seedream_5_misc.txt (2:3, 2k, batch of 4, two
/// reference images).
#[test]
fn tall_2x3_2k_batch_four_with_image_references() {
let params = build_api_params(&GenerateSeedream5p0ProRequest {
prompt: "An anime girl is standing in the door @Image1 Outside the door, there's the galaxy and windmill @Image2 ".to_string(),
aspect_ratio: KinoviSeedream5p0ProAspectRatio::Tall2x3,
resolution: KinoviSeedream5p0ProResolution::TwoK,
batch_count: KinoviSeedream5p0ProBatchCount::Four,
reference_image_urls: Some(vec![
"https://static.seedance2-pro.com/materials/20260713/1783904481112-4a3fa0d2.png".to_string(),
"https://static.seedance2-pro.com/materials/20260713/1783904588294-5c713908.jpg".to_string(),
]),
}).unwrap();
assert_eq!(
serde_json::to_string(&params).unwrap(),
r#"{"prompt":"An anime girl is standing in the door @Image1 Outside the door, there's the galaxy and windmill @Image2 ","aspectRatio":"2:3","resolution":"2k","model":"seedream-5.0-pro","batchCount":4,"uploadedUrls":["https://static.seedance2-pro.com/materials/20260713/1783904481112-4a3fa0d2.png","https://static.seedance2-pro.com/materials/20260713/1783904588294-5c713908.jpg"]}"#,
);
}
/// Mirrors capture 5_seedream_5_misc2.txt (1:1, 1k, batch of 8, two
/// reference images).
#[test]
fn square_1x1_1k_batch_eight_with_image_references() {
let params = build_api_params(&GenerateSeedream5p0ProRequest {
prompt: "The mirror from @Image2 is in front of the windmill in @Image1 . The sky and galaxy are lit up at night. ".to_string(),
aspect_ratio: KinoviSeedream5p0ProAspectRatio::Square1x1,
resolution: KinoviSeedream5p0ProResolution::OneK,
batch_count: KinoviSeedream5p0ProBatchCount::Eight,
reference_image_urls: Some(vec![
"https://static.seedance2-pro.com/materials/20260713/1783904715554-1a7ce415.jpg".to_string(),
"https://static.seedance2-pro.com/materials/20260713/1783904719453-1d10e81f.png".to_string(),
]),
}).unwrap();
assert_eq!(
serde_json::to_string(&params).unwrap(),
r#"{"prompt":"The mirror from @Image2 is in front of the windmill in @Image1 . The sky and galaxy are lit up at night. ","aspectRatio":"1:1","resolution":"1k","model":"seedream-5.0-pro","batchCount":8,"uploadedUrls":["https://static.seedance2-pro.com/materials/20260713/1783904715554-1a7ce415.jpg","https://static.seedance2-pro.com/materials/20260713/1783904719453-1d10e81f.png"]}"#,
);
}
#[test]
fn every_aspect_ratio_wire_value() {
let cases = [
(KinoviSeedream5p0ProAspectRatio::Auto, "auto"),
(KinoviSeedream5p0ProAspectRatio::Square1x1, "1:1"),
(KinoviSeedream5p0ProAspectRatio::Standard4x3, "4:3"),
(KinoviSeedream5p0ProAspectRatio::Portrait3x4, "3:4"),
(KinoviSeedream5p0ProAspectRatio::Landscape16x9, "16:9"),
(KinoviSeedream5p0ProAspectRatio::Portrait9x16, "9:16"),
(KinoviSeedream5p0ProAspectRatio::Wide3x2, "3:2"),
(KinoviSeedream5p0ProAspectRatio::Tall2x3, "2:3"),
(KinoviSeedream5p0ProAspectRatio::UltraWide21x9, "21:9"),
];
for (variant, expected) in cases {
assert_eq!(variant.as_api_str(), expected, "wire value for {variant:?}");
}
}
#[test]
fn every_resolution_wire_value() {
assert_eq!(KinoviSeedream5p0ProResolution::OneK.as_api_str(), "1k");
assert_eq!(KinoviSeedream5p0ProResolution::TwoK.as_api_str(), "2k");
}
#[test]
fn every_batch_count_value() {
let cases = [
(KinoviSeedream5p0ProBatchCount::One, 1),
(KinoviSeedream5p0ProBatchCount::Two, 2),
(KinoviSeedream5p0ProBatchCount::Three, 3),
(KinoviSeedream5p0ProBatchCount::Four, 4),
(KinoviSeedream5p0ProBatchCount::Five, 5),
(KinoviSeedream5p0ProBatchCount::Six, 6),
(KinoviSeedream5p0ProBatchCount::Seven, 7),
(KinoviSeedream5p0ProBatchCount::Eight, 8),
];
for (variant, expected) in cases {
assert_eq!(variant.as_u8(), expected, "value for {variant:?}");
}
}
#[test]
fn batch_count_one_omits_field() {
let params = build_api_params(&base_request()).unwrap();
assert_eq!(params.batch_count, None);
}
#[test]
fn empty_reference_list_omits_uploaded_urls() {
let params = build_api_params(&GenerateSeedream5p0ProRequest {
reference_image_urls: Some(vec![]),
..base_request()
}).unwrap();
let json = serde_json::to_string(&params).unwrap();
assert!(!json.contains("uploadedUrls"), "expected no uploadedUrls in {json}");
}
#[test]
fn business_type() {
assert_eq!(BUSINESS_TYPE, "seedream-5-pro-image-generation");
}
}
mod reference_image_limit_tests {
use super::*;
#[test]
fn fourteen_references_is_allowed() {
let params = build_api_params(&request_with_n_references(MAX_REFERENCE_IMAGES)).unwrap();
assert_eq!(params.uploaded_urls.map(|urls| urls.len()), Some(MAX_REFERENCE_IMAGES));
}
#[test]
fn fifteen_references_is_rejected() {
let result = build_api_params(&request_with_n_references(MAX_REFERENCE_IMAGES + 1));
match result {
Err(Seedance2ProClientError::InvalidRequestField { field, .. }) => {
assert_eq!(field, "reference_image_urls");
}
other => panic!("expected InvalidRequestField, got {other:?}"),
}
}
fn request_with_n_references(count: usize) -> GenerateSeedream5p0ProRequest {
GenerateSeedream5p0ProRequest {
reference_image_urls: Some(
(0..count).map(|i| format!("https://example.com/ref{i}.jpg")).collect(),
),
..base_request()
}
}
}
// ── Pricing ──
//
// 1k = 14 credits/image, 2k = 24 credits/image; batch count multiplies.
// Verified against captured traffic: 2k x 4 images = 96 credits
// (4_seedream_5_misc.txt), 1k x 8 images = 112 credits
// (5_seedream_5_misc2.txt).
mod pricing_tests {
use super::*;
#[test]
fn one_1k_image_is_fourteen_credits() {
let costs = request(KinoviSeedream5p0ProResolution::OneK, KinoviSeedream5p0ProBatchCount::One).calculate_costs();
assert_eq!(costs.kinovi_credits, 14);
}
#[test]
fn one_2k_image_is_twentyfour_credits() {
let costs = request(KinoviSeedream5p0ProResolution::TwoK, KinoviSeedream5p0ProBatchCount::One).calculate_costs();
assert_eq!(costs.kinovi_credits, 24);
}
/// Mirrors capture 4_seedream_5_misc.txt: 2k, batch of 4 → 96 credits.
#[test]
fn four_2k_images_is_ninetysix_credits() {
let costs = request(KinoviSeedream5p0ProResolution::TwoK, KinoviSeedream5p0ProBatchCount::Four).calculate_costs();
assert_eq!(costs.kinovi_credits, 96);
}
/// Mirrors capture 5_seedream_5_misc2.txt: 1k, batch of 8 → 112 credits.
#[test]
fn eight_1k_images_is_one_hundred_twelve_credits() {
let costs = request(KinoviSeedream5p0ProResolution::OneK, KinoviSeedream5p0ProBatchCount::Eight).calculate_costs();
assert_eq!(costs.kinovi_credits, 112);
}
#[test]
fn usd_cents_one_1k_image() {
// 14 credits; 1400/243 = 5.7613… → up 6¢, down 5¢.
let costs = request(KinoviSeedream5p0ProResolution::OneK, KinoviSeedream5p0ProBatchCount::One).calculate_costs();
assert_eq!(costs.usd_cents_rounded_up, 6);
assert_eq!(costs.usd_cents_rounded_down, 5);
assert!((costs.usd_cents_fractional - (1400.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn usd_cents_one_2k_image() {
// 24 credits; 2400/243 = 9.8765… → up 10¢, down 9¢.
let costs = request(KinoviSeedream5p0ProResolution::TwoK, KinoviSeedream5p0ProBatchCount::One).calculate_costs();
assert_eq!(costs.usd_cents_rounded_up, 10);
assert_eq!(costs.usd_cents_rounded_down, 9);
assert!((costs.usd_cents_fractional - (2400.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn usd_cents_eight_1k_images() {
// 112 credits; 11200/243 = 46.0905… → up 47¢, down 46¢.
let costs = request(KinoviSeedream5p0ProResolution::OneK, KinoviSeedream5p0ProBatchCount::Eight).calculate_costs();
assert_eq!(costs.usd_cents_rounded_up, 47);
assert_eq!(costs.usd_cents_rounded_down, 46);
assert!((costs.usd_cents_fractional - (11200.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn reference_images_do_not_affect_cost() {
let without_refs = request(KinoviSeedream5p0ProResolution::TwoK, KinoviSeedream5p0ProBatchCount::Four).calculate_costs();
let with_refs = GenerateSeedream5p0ProRequest {
reference_image_urls: Some(vec![
"https://example.com/ref1.jpg".to_string(),
"https://example.com/ref2.jpg".to_string(),
]),
..request(KinoviSeedream5p0ProResolution::TwoK, KinoviSeedream5p0ProBatchCount::Four)
}.calculate_costs();
assert_eq!(without_refs, with_refs);
}
#[test]
fn aspect_ratio_does_not_affect_cost() {
let auto = request(KinoviSeedream5p0ProResolution::OneK, KinoviSeedream5p0ProBatchCount::One).calculate_costs();
let ultra_wide = GenerateSeedream5p0ProRequest {
aspect_ratio: KinoviSeedream5p0ProAspectRatio::UltraWide21x9,
..request(KinoviSeedream5p0ProResolution::OneK, KinoviSeedream5p0ProBatchCount::One)
}.calculate_costs();
assert_eq!(auto, ultra_wide);
}
fn request(
resolution: KinoviSeedream5p0ProResolution,
batch_count: KinoviSeedream5p0ProBatchCount,
) -> GenerateSeedream5p0ProRequest {
GenerateSeedream5p0ProRequest {
resolution,
batch_count,
..base_request()
}
}
}
// ── Live usage tests ──
mod live_tests {
use super::*;
use crate::test_utils::get_test_cookies::get_test_cookies;
use crate::test_utils::setup_test_logging::setup_test_logging;
use errors::AnyhowResult;
use log::LevelFilter;
#[tokio::test]
#[ignore] // Sends a real generation to Kinovi; costs 24 credits.
async fn test_text_to_image_2k() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_seedream_5p0_pro(GenerateSeedream5p0ProArgs {
session: &session,
host_override: None,
request: GenerateSeedream5p0ProRequest {
prompt: "A corgi wearing a wizard hat, studio lighting".to_string(),
aspect_ratio: KinoviSeedream5p0ProAspectRatio::Auto,
resolution: KinoviSeedream5p0ProResolution::TwoK,
batch_count: KinoviSeedream5p0ProBatchCount::One,
reference_image_urls: None,
},
}).await?;
println!("seedream 5.0 pro 2k — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert!(!result.order_id.is_empty());
assert_eq!(1, 2, "Inspect output above");
Ok(())
}
#[tokio::test]
#[ignore] // Sends a real generation to Kinovi; costs 14 credits.
async fn test_text_to_image_1k_with_references() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_seedream_5p0_pro(GenerateSeedream5p0ProArgs {
session: &session,
host_override: None,
request: GenerateSeedream5p0ProRequest {
prompt: "The man in @Image1 is staring out the door in @Image2".to_string(),
aspect_ratio: KinoviSeedream5p0ProAspectRatio::Portrait9x16,
resolution: KinoviSeedream5p0ProResolution::OneK,
batch_count: KinoviSeedream5p0ProBatchCount::One,
reference_image_urls: Some(vec![
"https://static.seedance2-pro.com/materials/20260713/1783904468032-0a803390.jpg".to_string(),
"https://static.seedance2-pro.com/materials/20260713/1783904481112-4a3fa0d2.png".to_string(),
]),
},
}).await?;
println!("seedream 5.0 pro 1k refs — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2, "Inspect output above");
Ok(())
}
#[tokio::test]
#[ignore] // Sends a real generation to Kinovi; costs 56 credits.
async fn test_batch_four_1k() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_seedream_5p0_pro(GenerateSeedream5p0ProArgs {
session: &session,
host_override: None,
request: GenerateSeedream5p0ProRequest {
prompt: "A lighthouse on a cliff at golden hour".to_string(),
aspect_ratio: KinoviSeedream5p0ProAspectRatio::Landscape16x9,
resolution: KinoviSeedream5p0ProResolution::OneK,
batch_count: KinoviSeedream5p0ProBatchCount::Four,
reference_image_urls: None,
},
}).await?;
println!("seedream 5.0 pro batch-4 — task_id={}, order_id={}", result.task_id, result.order_id);
assert_eq!(result.task_ids.as_ref().map(|ids| ids.len()), Some(4));
assert_eq!(result.order_ids.as_ref().map(|ids| ids.len()), Some(4));
assert_eq!(1, 2, "Inspect output above");
Ok(())
}
fn test_session() -> AnyhowResult<Seedance2ProSession> {
let cookies = get_test_cookies()?;
Ok(Seedance2ProSession::from_cookies_string(cookies))
}
}
}

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