mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-21 01:12:40 -04:00
Compare commits
49
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9ee643bf6 | ||
|
|
07e9a35044 | ||
|
|
0eddb659fa | ||
|
|
17c12836ad | ||
|
|
7654e95636 | ||
|
|
1bb31f42d7 | ||
|
|
bc4b3a6c1f | ||
|
|
13b5e7b5c2 | ||
|
|
4ff5bdf475 | ||
|
|
e1b16cf2de | ||
|
|
2d7bf65a23 | ||
|
|
6ee5518c55 | ||
|
|
f708402e8a | ||
|
|
ffa86f0ba8 | ||
|
|
b741cdd76c | ||
|
|
9fbbfb5877 | ||
|
|
a49426b77d | ||
|
|
9f28c525ec | ||
|
|
d4239a3516 | ||
|
|
109d7081db | ||
|
|
5faa94a9dd | ||
|
|
9c83c20c46 | ||
|
|
cb4374ea70 | ||
|
|
eb59e7fcd7 | ||
|
|
185bf835a1 | ||
|
|
65463ae14c | ||
|
|
fc61c6efd1 | ||
|
|
4788ee9a23 | ||
|
|
77cee77258 | ||
|
|
9f69eb162a | ||
|
|
46d05808cc | ||
|
|
dbe38a588f | ||
|
|
58e2d08c76 | ||
|
|
f991eafd01 | ||
|
|
283b4ab04f | ||
|
|
0003cad179 | ||
|
|
863bd1c4e1 | ||
|
|
79b7e31ec5 | ||
|
|
eed865e57c | ||
|
|
348a603cae | ||
|
|
9898c6dbcf | ||
|
|
e0047d9410 | ||
|
|
a79ee6ad57 | ||
|
|
2a9e71f5b8 | ||
|
|
7dffc5fb5c | ||
|
|
bc0174ac7a | ||
|
|
9cc9d4b9cf | ||
|
|
dc5a4f8dd9 | ||
|
|
11ddeb05b5 |
@@ -1,3 +1,8 @@
|
||||
# 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
|
||||
|
||||
@@ -104,3 +104,6 @@ windows/sqlite/sqlite3.lib
|
||||
crates/desktop/src-tauri/*.dll
|
||||
windows_dlls/
|
||||
|
||||
|
||||
# Portable local dev stack (script/bootstrap) - MySQL/Redis binaries, data, logs
|
||||
.devstack/
|
||||
|
||||
-12
@@ -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": "1c74e9c2dcc9205e2c9fe60a6de0828208e497e68560246e588092bafd03eb59"
|
||||
}
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
+12
@@ -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": "f5bc4a956a20f6c0998585092a0922301d53c95ae8ea9cb808ae2dbcf8d0bb32"
|
||||
}
|
||||
@@ -28,7 +28,7 @@ Text-to-image is great, but artists *need control*. It's important to know what
|
||||
| **3D Image Compositing** |  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** |  Use images, background removal, layers, and simple drawing tools to precisely compose a scene. |
|
||||
| **Image to 3D Mesh** |  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** |  You can dynamically pose your characters to achieve the precise character, scene, and camera blocking before calling "action". |
|
||||
| **Character Posing** |  You can dynamically pose your characters to achieve the precise character, scene, and camera blocking before calling "action". |
|
||||
| **Scene Blocking w/ Kit Bashing** |  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** |  Use mannequins as simple 3D ControlNets for posing any character. |
|
||||
| **Background Removal** |  Remove backgrounds from images to make them useful in 2D or 3D compositing. They can be props, layers, or backdrops. |
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
# 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,6 +1,7 @@
|
||||
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;
|
||||
|
||||
@@ -22,6 +23,28 @@ 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)]
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
//! 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>,
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
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,
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
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,
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod list_session_common;
|
||||
pub mod list_session_mesh_media_files;
|
||||
pub mod list_session_splat_media_files;
|
||||
@@ -0,0 +1 @@
|
||||
pub mod by_type;
|
||||
@@ -1,4 +1,5 @@
|
||||
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;
|
||||
|
||||
@@ -41,6 +41,10 @@ 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")]
|
||||
@@ -73,4 +77,14 @@ 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");
|
||||
}
|
||||
}
|
||||
|
||||
+8
-1
@@ -26,6 +26,8 @@ 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;
|
||||
@@ -44,6 +46,7 @@ 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 {
|
||||
@@ -119,6 +122,8 @@ 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),
|
||||
@@ -141,10 +146,12 @@ 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)
|
||||
// Kinovi / Seedance2Pro (Midjourney + Seedream)
|
||||
(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(),
|
||||
}
|
||||
|
||||
+10
-1
@@ -9,12 +9,14 @@ 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 Kinovi/Midjourney needs this — when the caller
|
||||
/// be sent. Today only the Kinovi models need this — when the caller
|
||||
/// supplies reference images, we must upload them to the Seedance2Pro
|
||||
/// CDN before sending the request.
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -22,6 +24,7 @@ pub enum ImageGenerationDraftRequest {
|
||||
KinoviMidjourney7(KinoviMidjourney7DraftState),
|
||||
KinoviMidjourney7Niji(KinoviMidjourney7NijiDraftState),
|
||||
KinoviMidjourney8(KinoviMidjourney8DraftState),
|
||||
KinoviSeedream5p0Pro(KinoviSeedream5p0ProDraftState),
|
||||
}
|
||||
|
||||
impl ImageGenerationDraftRequest {
|
||||
@@ -30,6 +33,7 @@ impl ImageGenerationDraftRequest {
|
||||
Self::KinoviMidjourney7(_) => RouterProvider::Seedance2Pro,
|
||||
Self::KinoviMidjourney7Niji(_) => RouterProvider::Seedance2Pro,
|
||||
Self::KinoviMidjourney8(_) => RouterProvider::Seedance2Pro,
|
||||
Self::KinoviSeedream5p0Pro(_) => RouterProvider::Seedance2Pro,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +42,7 @@ 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()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +60,10 @@ 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+34
-2
@@ -39,6 +39,10 @@ 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;
|
||||
@@ -75,6 +79,8 @@ 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 {
|
||||
@@ -92,6 +98,8 @@ pub enum ImageGenerationRequest {
|
||||
ArtcraftSeedream4(ArtcraftSeedream4RequestState),
|
||||
ArtcraftSeedream4p5(ArtcraftSeedream4p5RequestState),
|
||||
ArtcraftSeedream5Lite(ArtcraftSeedream5LiteRequestState),
|
||||
ArtcraftSeedream5p0Pro(ArtcraftSeedream5p0ProRequestState),
|
||||
ArtcraftSeedream5p0ProUltra(ArtcraftSeedream5p0ProUltraRequestState),
|
||||
ArtcraftQwenEdit2511Angles(ArtcraftQwenEdit2511AnglesRequestState),
|
||||
ArtcraftFlux2LoraAngles(ArtcraftFlux2LoraAnglesRequestState),
|
||||
ArtcraftMidjourney7(ArtcraftMidjourney7RequestState),
|
||||
@@ -115,10 +123,11 @@ pub enum ImageGenerationRequest {
|
||||
FalQwenEdit2511Angles(FalQwenEdit2511AnglesRequestState),
|
||||
FalFlux2LoraAngles(FalFlux2LoraAnglesRequestState),
|
||||
|
||||
// ── Kinovi / Seedance2Pro provider (Midjourney image generation) ──
|
||||
// ── Kinovi / Seedance2Pro provider (Midjourney + Seedream image generation) ──
|
||||
KinoviMidjourney7(KinoviMidjourney7RequestState),
|
||||
KinoviMidjourney7Niji(KinoviMidjourney7NijiRequestState),
|
||||
KinoviMidjourney8(KinoviMidjourney8RequestState),
|
||||
KinoviSeedream5p0Pro(KinoviSeedream5p0ProRequestState),
|
||||
}
|
||||
|
||||
impl ImageGenerationRequest {
|
||||
@@ -137,6 +146,8 @@ 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,
|
||||
@@ -162,6 +173,7 @@ impl ImageGenerationRequest {
|
||||
Self::KinoviMidjourney7(_) => RouterProvider::Seedance2Pro,
|
||||
Self::KinoviMidjourney7Niji(_) => RouterProvider::Seedance2Pro,
|
||||
Self::KinoviMidjourney8(_) => RouterProvider::Seedance2Pro,
|
||||
Self::KinoviSeedream5p0Pro(_) => RouterProvider::Seedance2Pro,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,6 +219,12 @@ 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())
|
||||
}
|
||||
@@ -243,6 +261,7 @@ 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()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,6 +320,14 @@ 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
|
||||
@@ -389,7 +416,7 @@ impl ImageGenerationRequest {
|
||||
request.send(fal_client).await
|
||||
}
|
||||
|
||||
// ── Kinovi / Seedance2Pro (Midjourney) ──
|
||||
// ── Kinovi / Seedance2Pro (Midjourney + Seedream) ──
|
||||
Self::KinoviMidjourney7(request) => {
|
||||
let seedance_client = client.get_seedance2pro_client_ref()
|
||||
.map_err(ArtcraftRouterError::Client)?;
|
||||
@@ -405,6 +432,11 @@ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -19,3 +19,5 @@ 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;
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
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 }),
|
||||
))
|
||||
}
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
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 }),
|
||||
))
|
||||
}
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
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
@@ -1,6 +1,7 @@
|
||||
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;
|
||||
|
||||
+408
@@ -0,0 +1,408 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
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 })
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod draft;
|
||||
pub mod request;
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
+17
@@ -43,6 +43,9 @@ 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>,
|
||||
|
||||
@@ -108,6 +111,20 @@ 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.
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
+6
-3
@@ -1,3 +1,4 @@
|
||||
pub mod extract_audio_payload;
|
||||
pub mod extract_image_payload;
|
||||
pub mod extract_images_payload;
|
||||
pub mod extract_video_payload;
|
||||
@@ -18,8 +19,8 @@ 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`, `model_glb`, `model_glb_pbr`, `model_urls`, `model_mesh`,
|
||||
/// `model_obj`, `result_files`, `thumbnail`, `rendered_image`,
|
||||
/// `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
|
||||
/// `Some(ExtractedContents)` with copies of the relevant values. Multiple
|
||||
/// keys can be populated simultaneously. Returns `None` if the payload is
|
||||
@@ -30,6 +31,7 @@ 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);
|
||||
@@ -40,7 +42,7 @@ pub fn extract_contents_from_payload(payload: &Value) -> Option<ExtractedContent
|
||||
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()
|
||||
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()
|
||||
@@ -53,6 +55,7 @@ pub fn extract_contents_from_payload(payload: &Value) -> Option<ExtractedContent
|
||||
image,
|
||||
images,
|
||||
video,
|
||||
audio,
|
||||
model_glb,
|
||||
model_glb_pbr,
|
||||
model_urls,
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod get_billing_payments_history;
|
||||
pub mod get_credits_history;
|
||||
+620
@@ -0,0 +1,620 @@
|
||||
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(¶ms).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(¶ms).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(¶ms).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(¶ms).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(¶ms).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(¶ms).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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod generate_midjourney_v7;
|
||||
pub mod generate_midjourney_v7_niji;
|
||||
pub mod generate_midjourney_v8;
|
||||
pub mod generate_seedream_5p0_pro;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
pub (crate) mod test_utils;
|
||||
|
||||
// User API
|
||||
pub mod billing;
|
||||
pub mod cost;
|
||||
pub mod creds;
|
||||
pub mod error;
|
||||
|
||||
@@ -19,7 +19,9 @@ pub fn setup_test_logging(level: LevelFilter) {
|
||||
|
||||
println!("Log level: {:?}", env::var(RUST_LOG));
|
||||
|
||||
Builder::new()
|
||||
// try_init: several #[ignore] live tests may run in one process, and only
|
||||
// the first can install the logger.
|
||||
let _ = Builder::new()
|
||||
.is_test(true)
|
||||
.format(|buf, record| {
|
||||
writeln!(buf,
|
||||
@@ -31,7 +33,7 @@ pub fn setup_test_logging(level: LevelFilter) {
|
||||
})
|
||||
.filter(None, level)
|
||||
.filter_level(level)
|
||||
.init();
|
||||
.try_init();
|
||||
|
||||
trace!("Test trace log");
|
||||
debug!("Test debug log");
|
||||
|
||||
@@ -28,6 +28,20 @@ fn coerce_to_u32(number: &Number) -> u32 {
|
||||
coerce_to_u64(number).min(u32::MAX as u64) as u32
|
||||
}
|
||||
|
||||
/// Coerce a parsed JSON number to `i64`. Exact integers pass through; floats
|
||||
/// are rounded; non-finite values become `0`.
|
||||
fn coerce_to_i64(number: &Number) -> i64 {
|
||||
if let Some(signed) = number.as_i64() {
|
||||
return signed;
|
||||
}
|
||||
if let Some(float) = number.as_f64() {
|
||||
if float.is_finite() {
|
||||
return float.round() as i64;
|
||||
}
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
/// `#[serde(deserialize_with = "...")]` for a required `u64` field that may
|
||||
/// arrive as an integer or a float.
|
||||
pub fn de_u64_int_or_float<'de, D>(deserializer: D) -> Result<u64, D::Error>
|
||||
@@ -38,6 +52,16 @@ where
|
||||
Ok(coerce_to_u64(&number))
|
||||
}
|
||||
|
||||
/// `#[serde(deserialize_with = "...")]` for a required `i64` field that may
|
||||
/// arrive as an integer or a float (e.g. signed credit deltas).
|
||||
pub fn de_i64_int_or_float<'de, D>(deserializer: D) -> Result<i64, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let number = Number::deserialize(deserializer)?;
|
||||
Ok(coerce_to_i64(&number))
|
||||
}
|
||||
|
||||
/// `#[serde(deserialize_with = "...")]` for an optional `u64` field (handles
|
||||
/// `null` / missing as `None`).
|
||||
pub fn de_opt_u64_int_or_float<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
|
||||
@@ -90,6 +114,12 @@ mod tests {
|
||||
value: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct I64Holder {
|
||||
#[serde(deserialize_with = "de_i64_int_or_float")]
|
||||
value: i64,
|
||||
}
|
||||
|
||||
fn parse_u32(json: &str) -> u32 {
|
||||
serde_json::from_str::<U32Holder>(json).unwrap().value
|
||||
}
|
||||
@@ -98,6 +128,10 @@ mod tests {
|
||||
serde_json::from_str::<U64Holder>(json).unwrap().value
|
||||
}
|
||||
|
||||
fn parse_i64(json: &str) -> i64 {
|
||||
serde_json::from_str::<I64Holder>(json).unwrap().value
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn u32_accepts_integer() {
|
||||
assert_eq!(parse_u32(r#"{"value": 568}"#), 568);
|
||||
@@ -126,6 +160,13 @@ mod tests {
|
||||
assert_eq!(parse_u64(r#"{"value": -5.5}"#), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn i64_accepts_negative_integers_and_floats() {
|
||||
assert_eq!(parse_i64(r#"{"value": -454}"#), -454);
|
||||
assert_eq!(parse_i64(r#"{"value": -454.6}"#), -455);
|
||||
assert_eq!(parse_i64(r#"{"value": 25000}"#), 25000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_handles_null_missing_and_values() {
|
||||
assert_eq!(serde_json::from_str::<OptU32Holder>(r#"{"value": null}"#).unwrap().value, None);
|
||||
|
||||
@@ -7,6 +7,10 @@ use super::subcommands;
|
||||
/// All canonical subcommand names for this module.
|
||||
/// Used by the underscore-insensitive arg normalizer.
|
||||
pub const SUBCOMMAND_NAMES: &[&str] = &[
|
||||
"account_info",
|
||||
"audit_credits",
|
||||
"audit_orders",
|
||||
"audit_payments",
|
||||
"failed_job_histogram",
|
||||
"find_job",
|
||||
"generate_video",
|
||||
@@ -15,6 +19,21 @@ pub const SUBCOMMAND_NAMES: &[&str] = &[
|
||||
#[derive(Subcommand)]
|
||||
#[command(rename_all = "snake_case")]
|
||||
pub enum Seedance2proCommand {
|
||||
/// Print the account's current credit balances as one CSV line
|
||||
AccountInfo(subcommands::account_info::AccountInfoArgs),
|
||||
|
||||
/// Dump the account's credits ledger (credits.getCreditHistory) to CSV
|
||||
/// back to a start date, for refund-entry auditing
|
||||
AuditCredits(subcommands::audit_credits::AuditCreditsArgs),
|
||||
|
||||
/// Dump Kinovi's per-order billing records (totalCredits) to CSV for a
|
||||
/// date window, to audit whether failed orders are charged
|
||||
AuditOrders(subcommands::audit_orders::AuditOrdersArgs),
|
||||
|
||||
/// Dump the account's billing payments history (credit-package
|
||||
/// purchases) to CSV, for reconciling refills against invoices
|
||||
AuditPayments(subcommands::audit_payments::AuditPaymentsArgs),
|
||||
|
||||
/// Find a job by its order ID across all pages
|
||||
FindJob(subcommands::find_job::FindJobArgs),
|
||||
|
||||
@@ -32,6 +51,10 @@ pub async fn run(command: Seedance2proCommand) -> anyhow::Result<()> {
|
||||
let state = Seedance2ProState { cookies };
|
||||
|
||||
match command {
|
||||
Seedance2proCommand::AccountInfo(args) => subcommands::account_info::run(&state, args).await,
|
||||
Seedance2proCommand::AuditCredits(args) => subcommands::audit_credits::run(&state, args).await,
|
||||
Seedance2proCommand::AuditOrders(args) => subcommands::audit_orders::run(&state, args).await,
|
||||
Seedance2proCommand::AuditPayments(args) => subcommands::audit_payments::run(&state, args).await,
|
||||
Seedance2proCommand::FindJob(args) => subcommands::find_job::run(&state, args).await,
|
||||
Seedance2proCommand::FailedJobHistogram => subcommands::failed_job_histogram::run(&state).await,
|
||||
Seedance2proCommand::GenerateVideo(args) => subcommands::generate_video::run(&state, args).await,
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
use anyhow::anyhow;
|
||||
use clap::Args;
|
||||
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
|
||||
use seedance2pro_client::requests::get_user_auth_details::get_user_auth_details::{
|
||||
get_user_auth_details, GetUserAuthDetailsArgs,
|
||||
};
|
||||
|
||||
use super::super::state::Seedance2ProState;
|
||||
|
||||
/// Print the account's current credit balances (auth.user endpoint) as a
|
||||
/// single CSV line: account,email,credits,available_credits.
|
||||
#[derive(Args)]
|
||||
pub struct AccountInfoArgs {
|
||||
/// Read session cookies from this env var instead of SEEDANCE2PRO_COOKIES.
|
||||
#[arg(long)]
|
||||
pub cookies_env: Option<String>,
|
||||
|
||||
/// Account label for the output line. Defaults to the cookies env var name.
|
||||
#[arg(long)]
|
||||
pub account: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn run(state: &Seedance2ProState, args: AccountInfoArgs) -> anyhow::Result<()> {
|
||||
let (cookies, account_label) = match &args.cookies_env {
|
||||
Some(var) => (
|
||||
easyenv::get_env_string_required(var)
|
||||
.map_err(|err| anyhow!("Missing {} env var: {:?}", var, err))?,
|
||||
args.account.clone().unwrap_or_else(|| var.clone()),
|
||||
),
|
||||
None => (
|
||||
state.cookies.clone(),
|
||||
args.account.clone().unwrap_or_else(|| "SEEDANCE2PRO_COOKIES".to_string()),
|
||||
),
|
||||
};
|
||||
let session = Seedance2ProSession::from_cookies_string(cookies);
|
||||
|
||||
let details = get_user_auth_details(GetUserAuthDetailsArgs {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
}).await
|
||||
.map_err(|err| anyhow!("Error fetching auth details: {:?}", err))?;
|
||||
|
||||
println!("{},{},{},{}",
|
||||
account_label, details.email, details.credits, details.available_credits);
|
||||
Ok(())
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use clap::Args;
|
||||
use log::info;
|
||||
use seedance2pro_client::billing::get_credits_history::{
|
||||
get_credits_history, CreditHistoryEntry, GetCreditsHistoryArgs,
|
||||
};
|
||||
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
|
||||
|
||||
use super::super::state::Seedance2ProState;
|
||||
|
||||
/// Dump the account's credits ledger (credits.getCreditHistory) to CSV,
|
||||
/// newest-first, back to a start date. The ledger is offset-paginated and
|
||||
/// grows while paging (new rows push older rows to higher offsets), which
|
||||
/// causes re-reads rather than gaps - consumers should dedupe by entry id.
|
||||
#[derive(Args)]
|
||||
pub struct AuditCreditsArgs {
|
||||
/// Output CSV path. Appends if the file exists.
|
||||
#[arg(long)]
|
||||
pub out: String,
|
||||
|
||||
/// Stop paging once a whole page is older than this UTC date (YYYY-MM-DD).
|
||||
#[arg(long)]
|
||||
pub start_date: Option<String>,
|
||||
|
||||
/// Page size to request (the server may cap it; paging advances by rows
|
||||
/// actually returned, so a cap cannot skip rows).
|
||||
#[arg(long, default_value_t = 100)]
|
||||
pub limit: u32,
|
||||
|
||||
/// Maximum pages to fetch this run.
|
||||
#[arg(long, default_value_t = 10_000)]
|
||||
pub max_pages: usize,
|
||||
|
||||
/// Resume from a row offset (e.g. after an interrupted run).
|
||||
#[arg(long, default_value_t = 0)]
|
||||
pub offset: u64,
|
||||
|
||||
/// Delay between page fetches, in milliseconds.
|
||||
#[arg(long, default_value_t = 250)]
|
||||
pub delay_ms: u64,
|
||||
|
||||
/// Read session cookies from this env var instead of SEEDANCE2PRO_COOKIES.
|
||||
#[arg(long)]
|
||||
pub cookies_env: Option<String>,
|
||||
|
||||
/// Account label written into each CSV row. Defaults to the cookies env
|
||||
/// var name.
|
||||
#[arg(long)]
|
||||
pub account: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn run(state: &Seedance2ProState, args: AuditCreditsArgs) -> anyhow::Result<()> {
|
||||
let (cookies, account_label) = match &args.cookies_env {
|
||||
Some(var) => (
|
||||
easyenv::get_env_string_required(var)
|
||||
.map_err(|err| anyhow!("Missing {} env var: {:?}", var, err))?,
|
||||
args.account.clone().unwrap_or_else(|| var.clone()),
|
||||
),
|
||||
None => (
|
||||
state.cookies.clone(),
|
||||
args.account.clone().unwrap_or_else(|| "SEEDANCE2PRO_COOKIES".to_string()),
|
||||
),
|
||||
};
|
||||
let session = Seedance2ProSession::from_cookies_string(cookies);
|
||||
|
||||
let file_is_new = std::fs::metadata(&args.out).map(|m| m.len() == 0).unwrap_or(true);
|
||||
let mut out = OpenOptions::new().create(true).append(true).open(&args.out)
|
||||
.map_err(|err| anyhow!("Cannot open {}: {}", args.out, err))?;
|
||||
if file_is_new {
|
||||
writeln!(out, "account,entry_id,created_at,entry_type,credit_delta,order_id,product_name")?;
|
||||
}
|
||||
|
||||
let mut offset = args.offset;
|
||||
let mut rows = 0u64;
|
||||
let mut type_tallies: HashMap<String, (u64, i64)> = HashMap::new();
|
||||
let mut last_reported_total = 0u64;
|
||||
|
||||
for page_number in 1..=args.max_pages {
|
||||
let page = get_credits_history(GetCreditsHistoryArgs {
|
||||
session: &session,
|
||||
limit: args.limit,
|
||||
offset,
|
||||
host_override: None,
|
||||
}).await
|
||||
.map_err(|err| anyhow!(
|
||||
"Error fetching credits page {} (resume with --offset {}): {:?}",
|
||||
page_number, offset, err))?;
|
||||
|
||||
last_reported_total = page.total;
|
||||
if page.entries.is_empty() {
|
||||
info!("Page {} returned no entries; reached the end of the ledger.", page_number);
|
||||
break;
|
||||
}
|
||||
|
||||
let newest_day = page.entries.first().map(|e| day_of(e)).unwrap_or_default();
|
||||
let oldest_day = page.entries.last().map(|e| day_of(e)).unwrap_or_default();
|
||||
|
||||
for entry in &page.entries {
|
||||
write_entry_row(&mut out, &account_label, entry)?;
|
||||
rows += 1;
|
||||
let tally = type_tallies.entry(entry.entry_type.as_str().to_string()).or_insert((0, 0));
|
||||
tally.0 += 1;
|
||||
tally.1 += entry.credit_delta;
|
||||
}
|
||||
out.flush()?;
|
||||
|
||||
if page_number % 25 == 0 || page_number == 1 {
|
||||
info!(
|
||||
"Page {} (offset {}): {} entries ({} .. {}), written {} (server total {})",
|
||||
page_number, offset, page.entries.len(), newest_day, oldest_day, rows, page.total,
|
||||
);
|
||||
}
|
||||
|
||||
// Newest-first: once a whole page predates the window, everything
|
||||
// after it does too.
|
||||
if args.start_date.as_deref().is_some_and(|start| oldest_day.as_str() < start) {
|
||||
info!("Reached entries older than {}; done.", args.start_date.as_deref().unwrap());
|
||||
break;
|
||||
}
|
||||
|
||||
offset += page.entries.len() as u64;
|
||||
tokio::time::sleep(Duration::from_millis(args.delay_ms)).await;
|
||||
}
|
||||
|
||||
println!("── audit_credits summary [{}] ──", account_label);
|
||||
println!("entries written: {} (server reports ledger total {}; duplicates possible", rows, last_reported_total);
|
||||
println!(" under live growth - dedupe by entry_id downstream)");
|
||||
let mut tallies: Vec<_> = type_tallies.iter().collect();
|
||||
tallies.sort_by_key(|(_, (count, _))| std::cmp::Reverse(*count));
|
||||
for (entry_type, (count, delta_sum)) in tallies {
|
||||
println!(" {:<28} n={:<9} credit_sum={}", entry_type, count, delta_sum);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// UTC day prefix ("YYYY-MM-DD"); ISO strings compare lexicographically.
|
||||
fn day_of(entry: &CreditHistoryEntry) -> String {
|
||||
entry.created_at.get(..10).unwrap_or("").to_string()
|
||||
}
|
||||
|
||||
fn write_entry_row(out: &mut impl Write, account: &str, entry: &CreditHistoryEntry) -> anyhow::Result<()> {
|
||||
writeln!(
|
||||
out,
|
||||
"{},{},{},{},{},{},{}",
|
||||
account,
|
||||
entry.id,
|
||||
entry.created_at,
|
||||
entry.entry_type.as_str(),
|
||||
entry.credit_delta,
|
||||
entry.maybe_order_id.as_deref().unwrap_or(""),
|
||||
csv_quote(&entry.product_name),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Minimal CSV quoting for the one free-text field.
|
||||
fn csv_quote(field: &str) -> String {
|
||||
if field.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
format!("\"{}\"", field.replace('"', "\"\"").replace('\n', " "))
|
||||
}
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use clap::Args;
|
||||
use log::{info, warn};
|
||||
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
|
||||
use seedance2pro_client::requests::poll_orders::poll_orders::{
|
||||
poll_orders, OrderStatus, PollOrdersArgs, TaskStatus,
|
||||
};
|
||||
|
||||
use super::super::state::Seedance2ProState;
|
||||
|
||||
/// Dump Kinovi's own per-order billing records (`totalCredits`) to CSV for a
|
||||
/// date window, by paging the getOrders API newest-to-oldest. Used to audit
|
||||
/// whether failed orders carry charges.
|
||||
#[derive(Args)]
|
||||
pub struct AuditOrdersArgs {
|
||||
/// Output CSV path. Appends if the file exists (resume-friendly).
|
||||
#[arg(long)]
|
||||
pub out: String,
|
||||
|
||||
/// Stop paging once a whole page is older than this UTC date (YYYY-MM-DD).
|
||||
/// Without it, paging stops at --max_pages.
|
||||
#[arg(long)]
|
||||
pub start_date: Option<String>,
|
||||
|
||||
/// Skip (do not write) orders on days after this UTC date (YYYY-MM-DD,
|
||||
/// inclusive). Pages still have to be fetched to reach older orders.
|
||||
#[arg(long)]
|
||||
pub end_date: Option<String>,
|
||||
|
||||
/// Maximum pages to fetch this run (30 orders per page).
|
||||
#[arg(long, default_value_t = 200)]
|
||||
pub max_pages: usize,
|
||||
|
||||
/// Resume from a cursor logged by a previous run.
|
||||
#[arg(long)]
|
||||
pub cursor: Option<u64>,
|
||||
|
||||
/// Delay between page fetches, in milliseconds.
|
||||
#[arg(long, default_value_t = 250)]
|
||||
pub delay_ms: u64,
|
||||
|
||||
/// Read session cookies from this env var instead of SEEDANCE2PRO_COOKIES
|
||||
/// (e.g. SEEDANCE2PRO_VOLCENGINE_COOKIES / SEEDANCE2PRO_BYTEPLUS_COOKIES /
|
||||
/// SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES to audit each Kinovi account).
|
||||
#[arg(long)]
|
||||
pub cookies_env: Option<String>,
|
||||
|
||||
/// Account label written into each CSV row. Defaults to the cookies env
|
||||
/// var name (or "SEEDANCE2PRO_COOKIES").
|
||||
#[arg(long)]
|
||||
pub account: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn run(state: &Seedance2ProState, args: AuditOrdersArgs) -> anyhow::Result<()> {
|
||||
let (cookies, account_label) = match &args.cookies_env {
|
||||
Some(var) => (
|
||||
easyenv::get_env_string_required(var)
|
||||
.map_err(|err| anyhow!("Missing {} env var: {:?}", var, err))?,
|
||||
args.account.clone().unwrap_or_else(|| var.clone()),
|
||||
),
|
||||
None => (
|
||||
state.cookies.clone(),
|
||||
args.account.clone().unwrap_or_else(|| "SEEDANCE2PRO_COOKIES".to_string()),
|
||||
),
|
||||
};
|
||||
let session = Seedance2ProSession::from_cookies_string(cookies);
|
||||
|
||||
let file_is_new = std::fs::metadata(&args.out).map(|m| m.len() == 0).unwrap_or(true);
|
||||
let mut out = OpenOptions::new().create(true).append(true).open(&args.out)
|
||||
.map_err(|err| anyhow!("Cannot open {}: {}", args.out, err))?;
|
||||
if file_is_new {
|
||||
writeln!(out, "account,order_id,created_at,task_status,media_type,total_credits,result_count,fail_reason")?;
|
||||
}
|
||||
|
||||
let mut cursor = args.cursor;
|
||||
let mut stats = AuditStats::default();
|
||||
|
||||
for page in 1..=args.max_pages {
|
||||
let result = poll_orders(PollOrdersArgs {
|
||||
session: &session,
|
||||
cursor,
|
||||
host_override: None,
|
||||
}).await
|
||||
.map_err(|err| anyhow!(
|
||||
"Error polling page {} (resume with --cursor {:?}): {:?}", page, cursor, err))?;
|
||||
|
||||
if result.orders.is_empty() {
|
||||
info!("Page {} returned no orders; stopping.", page);
|
||||
break;
|
||||
}
|
||||
|
||||
let newest = order_day(result.orders.first().unwrap());
|
||||
let oldest = order_day(result.orders.last().unwrap());
|
||||
|
||||
for order in &result.orders {
|
||||
let day = order_day(order);
|
||||
if args.end_date.as_deref().is_some_and(|end| day > end) {
|
||||
stats.skipped_after_window += 1;
|
||||
continue;
|
||||
}
|
||||
if args.start_date.as_deref().is_some_and(|start| day < start) {
|
||||
stats.skipped_before_window += 1;
|
||||
continue;
|
||||
}
|
||||
write_order_row(&mut out, &account_label, order)?;
|
||||
stats.record(order);
|
||||
}
|
||||
out.flush()?;
|
||||
|
||||
info!(
|
||||
"Page {}: {} orders ({} .. {}), written so far: {}, next cursor: {:?}",
|
||||
page, result.orders.len(), newest, oldest, stats.written, result.next_cursor,
|
||||
);
|
||||
|
||||
// The API pages newest-to-oldest: once the whole page predates the
|
||||
// window, everything after it will too.
|
||||
if args.start_date.as_deref().is_some_and(|start| oldest < start) {
|
||||
info!("Reached orders older than {}; done.", args.start_date.as_deref().unwrap());
|
||||
cursor = result.next_cursor;
|
||||
break;
|
||||
}
|
||||
|
||||
cursor = result.next_cursor;
|
||||
if cursor.is_none() {
|
||||
info!("No further pages; reached the end of the account's history.");
|
||||
break;
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(args.delay_ms)).await;
|
||||
}
|
||||
|
||||
stats.print_summary(&account_label, cursor);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct AuditStats {
|
||||
written: u64,
|
||||
skipped_after_window: u64,
|
||||
skipped_before_window: u64,
|
||||
completed: u64,
|
||||
completed_credits: u64,
|
||||
completed_missing_credits: u64,
|
||||
failed: u64,
|
||||
failed_credits: u64,
|
||||
failed_with_nonzero_credits: u64,
|
||||
in_flight: u64,
|
||||
in_flight_credits: u64,
|
||||
}
|
||||
|
||||
impl AuditStats {
|
||||
fn record(&mut self, order: &OrderStatus) {
|
||||
self.written += 1;
|
||||
let credits = u64::from(order.total_credits.unwrap_or(0));
|
||||
match order.task_status {
|
||||
TaskStatus::Completed => {
|
||||
self.completed += 1;
|
||||
self.completed_credits += credits;
|
||||
if order.total_credits.is_none() {
|
||||
self.completed_missing_credits += 1;
|
||||
}
|
||||
}
|
||||
TaskStatus::Failed => {
|
||||
self.failed += 1;
|
||||
self.failed_credits += credits;
|
||||
if credits > 0 {
|
||||
self.failed_with_nonzero_credits += 1;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.in_flight += 1;
|
||||
self.in_flight_credits += credits;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn print_summary(&self, account: &str, resume_cursor: Option<u64>) {
|
||||
// Final results are intentionally println: they ARE the tool's output.
|
||||
println!("── audit_orders summary [{}] ──", account);
|
||||
println!("orders written: {}", self.written);
|
||||
println!(" completed: {} ({} credits; {} missing totalCredits)",
|
||||
self.completed, self.completed_credits, self.completed_missing_credits);
|
||||
println!(" failed: {} ({} credits; {} orders with credits > 0)",
|
||||
self.failed, self.failed_credits, self.failed_with_nonzero_credits);
|
||||
println!(" pending/processing: {} ({} credits)", self.in_flight, self.in_flight_credits);
|
||||
println!("skipped (newer than window): {}", self.skipped_after_window);
|
||||
println!("skipped (older than window): {}", self.skipped_before_window);
|
||||
match resume_cursor {
|
||||
Some(c) => println!("resume cursor: {}", c),
|
||||
None => println!("resume cursor: (none - history exhausted or window done)"),
|
||||
}
|
||||
if self.failed_with_nonzero_credits > 0 {
|
||||
warn!(
|
||||
"{} FAILED orders carry non-zero totalCredits ({} credits total) — \
|
||||
Kinovi's own records say failures were charged.",
|
||||
self.failed_with_nonzero_credits, self.failed_credits,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// UTC day prefix ("YYYY-MM-DD") of the order's ISO 8601 created_at.
|
||||
/// ISO strings compare lexicographically, so no date parsing is needed.
|
||||
fn order_day(order: &OrderStatus) -> &str {
|
||||
order.created_at.get(..10).unwrap_or("")
|
||||
}
|
||||
|
||||
fn write_order_row(out: &mut impl Write, account: &str, order: &OrderStatus) -> anyhow::Result<()> {
|
||||
let media_type = order.media_type.as_ref()
|
||||
.map(|m| format!("{:?}", m))
|
||||
.unwrap_or_default();
|
||||
let total_credits = order.total_credits
|
||||
.map(|c| c.to_string())
|
||||
.unwrap_or_default();
|
||||
let fail_reason = order.fail_reason.as_ref()
|
||||
.map(|fr| fr.reason.as_str())
|
||||
.unwrap_or("");
|
||||
writeln!(
|
||||
out,
|
||||
"{},{},{},{:?},{},{},{},{}",
|
||||
account,
|
||||
order.order_id,
|
||||
order.created_at,
|
||||
order.task_status,
|
||||
media_type,
|
||||
total_credits,
|
||||
order.results.len(),
|
||||
csv_quote(fail_reason),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Minimal CSV quoting for the one free-text field.
|
||||
fn csv_quote(field: &str) -> String {
|
||||
if field.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
format!("\"{}\"", field.replace('"', "\"\"").replace('\n', " "))
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use clap::Args;
|
||||
use log::info;
|
||||
use seedance2pro_client::billing::get_billing_payments_history::{
|
||||
get_billing_payments_history, BillingPayment, GetBillingPaymentsHistoryArgs,
|
||||
DEFAULT_BILLING_PAGE_LIMIT,
|
||||
};
|
||||
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
|
||||
|
||||
use super::super::state::Seedance2ProState;
|
||||
|
||||
/// Dump the account's full billing payments history (credit-package
|
||||
/// purchases) to CSV, for reconciling refill charges against invoices.
|
||||
#[derive(Args)]
|
||||
pub struct AuditPaymentsArgs {
|
||||
/// Output CSV path. Appends if the file exists.
|
||||
#[arg(long)]
|
||||
pub out: String,
|
||||
|
||||
/// Maximum pages to fetch (20 payments per page).
|
||||
#[arg(long, default_value_t = 500)]
|
||||
pub max_pages: usize,
|
||||
|
||||
/// Delay between page fetches, in milliseconds.
|
||||
#[arg(long, default_value_t = 250)]
|
||||
pub delay_ms: u64,
|
||||
|
||||
/// Read session cookies from this env var instead of SEEDANCE2PRO_COOKIES.
|
||||
#[arg(long)]
|
||||
pub cookies_env: Option<String>,
|
||||
|
||||
/// Account label written into each CSV row. Defaults to the cookies env
|
||||
/// var name.
|
||||
#[arg(long)]
|
||||
pub account: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn run(state: &Seedance2ProState, args: AuditPaymentsArgs) -> anyhow::Result<()> {
|
||||
let (cookies, account_label) = match &args.cookies_env {
|
||||
Some(var) => (
|
||||
easyenv::get_env_string_required(var)
|
||||
.map_err(|err| anyhow!("Missing {} env var: {:?}", var, err))?,
|
||||
args.account.clone().unwrap_or_else(|| var.clone()),
|
||||
),
|
||||
None => (
|
||||
state.cookies.clone(),
|
||||
args.account.clone().unwrap_or_else(|| "SEEDANCE2PRO_COOKIES".to_string()),
|
||||
),
|
||||
};
|
||||
let session = Seedance2ProSession::from_cookies_string(cookies);
|
||||
|
||||
let file_is_new = std::fs::metadata(&args.out).map(|m| m.len() == 0).unwrap_or(true);
|
||||
let mut out = OpenOptions::new().create(true).append(true).open(&args.out)
|
||||
.map_err(|err| anyhow!("Cannot open {}: {}", args.out, err))?;
|
||||
if file_is_new {
|
||||
writeln!(out, "account,payment_id,created_at,amount_usd,credits_earned,status,payment_type,product_name,transaction_id")?;
|
||||
}
|
||||
|
||||
let mut offset: u64 = 0;
|
||||
let mut rows = 0u64;
|
||||
let mut total_amount = 0f64;
|
||||
let mut total_credits = 0u64;
|
||||
let mut last_reported_total = 0u64;
|
||||
|
||||
for page_number in 1..=args.max_pages {
|
||||
let page = get_billing_payments_history(GetBillingPaymentsHistoryArgs {
|
||||
session: &session,
|
||||
limit: DEFAULT_BILLING_PAGE_LIMIT,
|
||||
offset,
|
||||
host_override: None,
|
||||
}).await
|
||||
.map_err(|err| anyhow!(
|
||||
"Error fetching payments page {} (offset {}): {:?}", page_number, offset, err))?;
|
||||
|
||||
last_reported_total = page.total;
|
||||
if page.payments.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
for payment in &page.payments {
|
||||
write_payment_row(&mut out, &account_label, payment)?;
|
||||
rows += 1;
|
||||
total_amount += payment.amount_usd;
|
||||
total_credits += payment.credits_earned;
|
||||
}
|
||||
out.flush()?;
|
||||
|
||||
info!(
|
||||
"Page {} (offset {}): {} payments; running ${:.2} / {} credits (server total {})",
|
||||
page_number, offset, page.payments.len(), total_amount, total_credits, page.total,
|
||||
);
|
||||
|
||||
offset += page.payments.len() as u64;
|
||||
tokio::time::sleep(Duration::from_millis(args.delay_ms)).await;
|
||||
}
|
||||
|
||||
println!("── audit_payments summary [{}] ──", account_label);
|
||||
println!("payments written: {} (server reports total {})", rows, last_reported_total);
|
||||
println!("lifetime charged: ${:.2}", total_amount);
|
||||
println!("lifetime credits: {}", total_credits);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_payment_row(out: &mut impl Write, account: &str, payment: &BillingPayment) -> anyhow::Result<()> {
|
||||
writeln!(
|
||||
out,
|
||||
"{},{},{},{},{},{:?},{:?},{},{}",
|
||||
account,
|
||||
payment.id,
|
||||
payment.created_at,
|
||||
payment.amount_usd,
|
||||
payment.credits_earned,
|
||||
payment.status,
|
||||
payment.payment_type,
|
||||
csv_quote(&payment.product_name),
|
||||
payment.transaction_id,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Minimal CSV quoting for the one free-text field.
|
||||
fn csv_quote(field: &str) -> String {
|
||||
if field.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
format!("\"{}\"", field.replace('"', "\"\"").replace('\n', " "))
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
pub mod account_info;
|
||||
pub mod audit_credits;
|
||||
pub mod audit_orders;
|
||||
pub mod audit_payments;
|
||||
pub mod failed_job_histogram;
|
||||
pub mod find_job;
|
||||
pub mod generate_video;
|
||||
|
||||
+2
@@ -54,6 +54,8 @@ fn image_model_to_common_model_type(model: RouterImageModel) -> Option<CommonMod
|
||||
RouterImageModel::Seedream4 => Some(CommonModelType::Seedream4),
|
||||
RouterImageModel::Seedream4p5 => Some(CommonModelType::Seedream4p5),
|
||||
RouterImageModel::Seedream5Lite => Some(CommonModelType::Seedream5Lite),
|
||||
RouterImageModel::Seedream5p0Pro => Some(CommonModelType::Seedream5p0Pro),
|
||||
RouterImageModel::Seedream5p0ProUltra => Some(CommonModelType::Seedream5p0ProUltra),
|
||||
RouterImageModel::QwenEdit2511Angles => Some(CommonModelType::QwenEdit2511Angles),
|
||||
RouterImageModel::Flux2LoraAngles => Some(CommonModelType::Flux2LoraAngles),
|
||||
RouterImageModel::GrokImagineImage => Some(CommonModelType::GrokImagineImage),
|
||||
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
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::batch_generations::BatchGenerationToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::queries::media_files::list::media_file_list_row::MediaFileListRow;
|
||||
|
||||
pub struct ListMediaFilesWithoutFolderArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub owner_user_token: &'e UserToken,
|
||||
|
||||
/// Optional filter on the coarse media class (image / video / mesh / ...).
|
||||
pub maybe_filter_media_class: Option<MediaFileClass>,
|
||||
|
||||
pub maybe_cursor_id: Option<u64>,
|
||||
pub limit: u32,
|
||||
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Paginated list of the user's media files that sit in no folder at all.
|
||||
/// Memberships whose folder was soft-deleted (but whose link row wasn't
|
||||
/// cleaned up) count as unfoldered. Excludes soft-deleted and intermediate
|
||||
/// system files. Newest first (`media_files.id` descending; the id doubles
|
||||
/// as the cursor).
|
||||
///
|
||||
/// The NOT EXISTS probe is a point lookup on
|
||||
/// `folder_media_files.index_media_file_token` plus a primary-key lookup
|
||||
/// on `folders` for the soft-delete check — cheap per candidate row.
|
||||
pub async fn list_media_files_without_folder<'e, 'c: 'e, E>(
|
||||
args: ListMediaFilesWithoutFolderArgs<'e, 'c, E>,
|
||||
) -> Result<Vec<MediaFileListRow>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
// No-cursor requests use a sentinel above every possible id, which
|
||||
// keeps this to a single static query per filter variant.
|
||||
// (`media_files.id` is a signed BIGINT, so the sentinel is i64::MAX.)
|
||||
let cursor_id = args.maybe_cursor_id.unwrap_or(i64::MAX as u64);
|
||||
let limit = args.limit as i64;
|
||||
|
||||
match args.maybe_filter_media_class {
|
||||
Some(media_class) => {
|
||||
select_page_with_class_filter(
|
||||
args.owner_user_token,
|
||||
media_class,
|
||||
cursor_id,
|
||||
limit,
|
||||
args.mysql_executor,
|
||||
).await
|
||||
}
|
||||
None => {
|
||||
select_page_all_classes(
|
||||
args.owner_user_token,
|
||||
cursor_id,
|
||||
limit,
|
||||
args.mysql_executor,
|
||||
).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NB: The two queries below are intentionally duplicated so that sqlx can
|
||||
// check them at compile time (no QueryBuilder). Keep the SELECT lists and
|
||||
// predicates in sync; they differ only in the `media_class = ?` filter.
|
||||
|
||||
async fn select_page_all_classes<'e, 'c: 'e, E>(
|
||||
owner_user_token: &'e UserToken,
|
||||
cursor_id: u64,
|
||||
limit: i64,
|
||||
mysql_executor: E,
|
||||
) -> Result<Vec<MediaFileListRow>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
sqlx::query_as!(
|
||||
MediaFileListRow,
|
||||
r#"
|
||||
SELECT
|
||||
mf.id as `media_file_id: u64`,
|
||||
|
||||
mf.token as `media_file_token: MediaFileToken`,
|
||||
mf.media_class as `media_class: MediaFileClass`,
|
||||
mf.media_type as `media_type: MediaFileType`,
|
||||
|
||||
mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,
|
||||
|
||||
mf.public_bucket_directory_hash,
|
||||
mf.maybe_public_bucket_prefix,
|
||||
mf.maybe_public_bucket_extension,
|
||||
|
||||
cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,
|
||||
cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,
|
||||
cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,
|
||||
|
||||
mf.creator_set_visibility as `creator_set_visibility: Visibility`,
|
||||
mf.is_user_upload as `is_user_upload: bool`,
|
||||
|
||||
mf.maybe_title,
|
||||
mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,
|
||||
mf.maybe_origin_filename,
|
||||
|
||||
mf.maybe_duration_millis,
|
||||
mf.maybe_frame_width,
|
||||
mf.maybe_frame_height,
|
||||
|
||||
mf.created_at,
|
||||
mf.updated_at
|
||||
FROM media_files mf
|
||||
LEFT JOIN media_files cover
|
||||
ON cover.token = mf.maybe_cover_image_media_file_token
|
||||
WHERE mf.maybe_creator_user_token = ?
|
||||
AND mf.user_deleted_at IS NULL
|
||||
AND mf.mod_deleted_at IS NULL
|
||||
AND NOT mf.is_intermediate_system_file
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM folder_media_files fmf
|
||||
JOIN folders f
|
||||
ON f.token = fmf.folder_token
|
||||
AND f.maybe_deleted_at IS NULL
|
||||
WHERE fmf.media_file_token = mf.token
|
||||
)
|
||||
AND mf.id < ?
|
||||
ORDER BY mf.id DESC
|
||||
LIMIT ?
|
||||
"#,
|
||||
owner_user_token.as_str(),
|
||||
cursor_id,
|
||||
limit,
|
||||
)
|
||||
.fetch_all(mysql_executor)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn select_page_with_class_filter<'e, 'c: 'e, E>(
|
||||
owner_user_token: &'e UserToken,
|
||||
media_class: MediaFileClass,
|
||||
cursor_id: u64,
|
||||
limit: i64,
|
||||
mysql_executor: E,
|
||||
) -> Result<Vec<MediaFileListRow>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
sqlx::query_as!(
|
||||
MediaFileListRow,
|
||||
r#"
|
||||
SELECT
|
||||
mf.id as `media_file_id: u64`,
|
||||
|
||||
mf.token as `media_file_token: MediaFileToken`,
|
||||
mf.media_class as `media_class: MediaFileClass`,
|
||||
mf.media_type as `media_type: MediaFileType`,
|
||||
|
||||
mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,
|
||||
|
||||
mf.public_bucket_directory_hash,
|
||||
mf.maybe_public_bucket_prefix,
|
||||
mf.maybe_public_bucket_extension,
|
||||
|
||||
cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,
|
||||
cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,
|
||||
cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,
|
||||
|
||||
mf.creator_set_visibility as `creator_set_visibility: Visibility`,
|
||||
mf.is_user_upload as `is_user_upload: bool`,
|
||||
|
||||
mf.maybe_title,
|
||||
mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,
|
||||
mf.maybe_origin_filename,
|
||||
|
||||
mf.maybe_duration_millis,
|
||||
mf.maybe_frame_width,
|
||||
mf.maybe_frame_height,
|
||||
|
||||
mf.created_at,
|
||||
mf.updated_at
|
||||
FROM media_files mf
|
||||
LEFT JOIN media_files cover
|
||||
ON cover.token = mf.maybe_cover_image_media_file_token
|
||||
WHERE mf.maybe_creator_user_token = ?
|
||||
AND mf.media_class = ?
|
||||
AND mf.user_deleted_at IS NULL
|
||||
AND mf.mod_deleted_at IS NULL
|
||||
AND NOT mf.is_intermediate_system_file
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM folder_media_files fmf
|
||||
JOIN folders f
|
||||
ON f.token = fmf.folder_token
|
||||
AND f.maybe_deleted_at IS NULL
|
||||
WHERE fmf.media_file_token = mf.token
|
||||
)
|
||||
AND mf.id < ?
|
||||
ORDER BY mf.id DESC
|
||||
LIMIT ?
|
||||
"#,
|
||||
owner_user_token.as_str(),
|
||||
media_class.to_str(),
|
||||
cursor_id,
|
||||
limit,
|
||||
)
|
||||
.fetch_all(mysql_executor)
|
||||
.await
|
||||
}
|
||||
@@ -2,4 +2,5 @@ pub mod bulk_delete_folder_media_files;
|
||||
pub mod bulk_insert_folder_media_files;
|
||||
pub mod filter_existing_media_file_tokens;
|
||||
pub mod list_folder_media_files;
|
||||
pub mod list_media_files_without_folder;
|
||||
pub mod recompute_folder_last_media_files;
|
||||
|
||||
+9
-2
@@ -82,7 +82,9 @@ pub async fn insert_generic_inference_job_for_seedance2pro_queue_with_apriori_jo
|
||||
let is_image_model = match args.maybe_model_type {
|
||||
Some(CommonModelType::Midjourney7) |
|
||||
Some(CommonModelType::Midjourney7Niji) |
|
||||
Some(CommonModelType::Midjourney8) => true,
|
||||
Some(CommonModelType::Midjourney8) |
|
||||
Some(CommonModelType::Seedream5p0Pro) |
|
||||
Some(CommonModelType::Seedream5p0ProUltra) => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
@@ -110,7 +112,12 @@ pub async fn insert_generic_inference_job_for_seedance2pro_queue_with_apriori_jo
|
||||
InferenceJobExternalThirdParty::Seedance2ProAlt,
|
||||
InferenceJobProductCategory::Seedance2ProVideoAlt,
|
||||
),
|
||||
(KinoviVersion::BytePlusUltra, _) => (
|
||||
(KinoviVersion::BytePlusUltra, true) => (
|
||||
InferenceJobType::Seedance2ProBytePlusUltraQueue,
|
||||
InferenceJobExternalThirdParty::Seedance2ProBytePlusUltra,
|
||||
InferenceJobProductCategory::Seedance2ProImageBytePlusUltra,
|
||||
),
|
||||
(KinoviVersion::BytePlusUltra, false) => (
|
||||
InferenceJobType::Seedance2ProBytePlusUltraQueue,
|
||||
InferenceJobExternalThirdParty::Seedance2ProBytePlusUltra,
|
||||
InferenceJobProductCategory::Seedance2ProVideoBytePlusUltra,
|
||||
|
||||
+50
-143
@@ -1,5 +1,4 @@
|
||||
use anyhow::anyhow;
|
||||
use sqlx;
|
||||
use sqlx::MySqlPool;
|
||||
|
||||
use enums::by_table::generic_synthetic_ids::id_category::IdCategory;
|
||||
@@ -22,6 +21,7 @@ use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::payloads::media_file_extra_info::media_file_extra_info::MediaFileExtraInfo;
|
||||
use crate::queries::generic_synthetic_ids::transactional_increment_generic_synthetic_id::transactional_increment_generic_synthetic_id;
|
||||
use crate::queries::media_files::create::generic_insert::insert_media_file_generic_executor::{insert_media_file_generic_executor, InsertMediaFileGenericExecutorArgs};
|
||||
|
||||
pub struct InsertArgs<'a> {
|
||||
pub pool: &'a MySqlPool,
|
||||
@@ -54,7 +54,7 @@ pub struct InsertArgs<'a> {
|
||||
pub maybe_frame_width: Option<u32>,
|
||||
pub maybe_frame_height: Option<u32>,
|
||||
pub checksum_sha2: &'a str,
|
||||
|
||||
|
||||
// Media info for certain product areas
|
||||
pub maybe_engine_category: Option<MediaFileEngineCategory>, // TODO: Deprecate
|
||||
|
||||
@@ -104,12 +104,13 @@ pub struct InsertArgs<'a> {
|
||||
pub maybe_mod_user_token: Option<&'a UserToken>,
|
||||
}
|
||||
|
||||
/// Insert a media file record, allocating the creator's synthetic IDs in the
|
||||
/// same transaction. Wraps [`insert_media_file_generic_executor`], which runs
|
||||
/// the actual INSERT.
|
||||
pub async fn insert_media_file_generic(
|
||||
args: InsertArgs<'_>
|
||||
) -> AnyhowResult<(MediaFileToken, u64)>
|
||||
{
|
||||
let result_token = MediaFileToken::generate();
|
||||
|
||||
let extra_file_modification_info = args
|
||||
.maybe_extra_media_info.map(|extra| extra.to_json_string())
|
||||
.transpose()?;
|
||||
@@ -117,24 +118,8 @@ pub async fn insert_media_file_generic(
|
||||
let mut maybe_creator_file_synthetic_id : Option<u64> = None;
|
||||
let mut maybe_creator_category_synthetic_id : Option<u64> = None;
|
||||
|
||||
let mut maybe_generation_provider_str = None;
|
||||
let mut is_intermediate_system_file = args.is_intermediate_system_file;
|
||||
let mut is_user_upload = args.is_user_upload;
|
||||
let mut origin_category = args.origin_category;
|
||||
|
||||
if let Some(generation_provider) = args.maybe_generation_provider {
|
||||
// Overrides if we're using a generation provider
|
||||
maybe_generation_provider_str = Some(generation_provider.to_str());
|
||||
is_intermediate_system_file = false;
|
||||
is_user_upload = false;
|
||||
if generation_provider != GenerationProvider::Artcraft {
|
||||
origin_category = MediaFileOriginCategory::ThirdPartyInference;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let mut transaction = args.pool.begin().await?;
|
||||
|
||||
|
||||
if let Some(user_token) = args.maybe_creator_user_token.as_deref() {
|
||||
let next_media_file_id = transactional_increment_generic_synthetic_id(
|
||||
user_token,
|
||||
@@ -152,131 +137,53 @@ pub async fn insert_media_file_generic(
|
||||
maybe_creator_category_synthetic_id = Some(category_id);
|
||||
}
|
||||
|
||||
let query_result = sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO media_files
|
||||
SET
|
||||
token = ?,
|
||||
|
||||
media_class = ?,
|
||||
media_type = ?,
|
||||
|
||||
is_user_upload = ?,
|
||||
is_intermediate_system_file = ?,
|
||||
|
||||
origin_category = ?,
|
||||
origin_product_category = ?,
|
||||
maybe_origin_model_type = ?,
|
||||
maybe_origin_model_token = ?,
|
||||
maybe_origin_filename = ?,
|
||||
|
||||
maybe_batch_token = ?,
|
||||
|
||||
maybe_mime_type = ?,
|
||||
file_size_bytes = ?,
|
||||
maybe_duration_millis = ?,
|
||||
maybe_audio_encoding = ?,
|
||||
maybe_video_encoding = ?,
|
||||
maybe_frame_width = ?,
|
||||
maybe_frame_height = ?,
|
||||
maybe_prompt_token = ?,
|
||||
checksum_sha2 = ?,
|
||||
|
||||
maybe_engine_category = ?,
|
||||
|
||||
maybe_title = ?,
|
||||
maybe_text_transcript = ?,
|
||||
|
||||
maybe_scene_source_media_file_token = ?,
|
||||
|
||||
public_bucket_directory_hash = ?,
|
||||
maybe_public_bucket_prefix = ?,
|
||||
maybe_public_bucket_extension = ?,
|
||||
|
||||
maybe_creator_user_token = ?,
|
||||
maybe_creator_anonymous_visitor_token = ?,
|
||||
|
||||
creator_ip_address = ?,
|
||||
creator_set_visibility = ?,
|
||||
|
||||
maybe_creator_file_synthetic_id = ?,
|
||||
maybe_creator_category_synthetic_id = ?,
|
||||
|
||||
extra_file_modification_info = ?,
|
||||
|
||||
maybe_generation_provider = ?,
|
||||
|
||||
platform_type = ?,
|
||||
|
||||
maybe_cover_image_media_file_token = ?,
|
||||
|
||||
maybe_mod_user_token = ?,
|
||||
is_generated_on_prem = ?,
|
||||
generated_by_worker = ?,
|
||||
generated_by_cluster = ?
|
||||
"#,
|
||||
result_token,
|
||||
|
||||
args.media_class.to_str(),
|
||||
args.media_type.to_str(),
|
||||
|
||||
is_user_upload,
|
||||
is_intermediate_system_file,
|
||||
|
||||
origin_category.to_str(),
|
||||
args.origin_product_category.to_str(),
|
||||
args.maybe_origin_model_type.map(|e| e.to_str()),
|
||||
args.maybe_origin_model_token.map(|t| t.to_string()),
|
||||
args.maybe_origin_filename,
|
||||
|
||||
args.maybe_batch_token.map(|t| t.as_str()),
|
||||
|
||||
args.maybe_mime_type,
|
||||
args.file_size_bytes,
|
||||
args.maybe_duration_millis,
|
||||
args.maybe_audio_encoding,
|
||||
args.maybe_video_encoding,
|
||||
args.maybe_frame_width,
|
||||
args.maybe_frame_height,
|
||||
args.maybe_prompt_token.map(|t| t.as_str()),
|
||||
args.checksum_sha2,
|
||||
|
||||
args.maybe_engine_category.map(|e| e.to_str()),
|
||||
|
||||
args.maybe_title,
|
||||
args.maybe_text_transcript,
|
||||
|
||||
args.maybe_scene_source_media_file_token.map(|t| t.as_str()),
|
||||
|
||||
args.public_bucket_directory_hash,
|
||||
args.maybe_public_bucket_prefix,
|
||||
args.maybe_public_bucket_extension,
|
||||
|
||||
args.maybe_creator_user_token.map(|t| t.as_str()),
|
||||
args.maybe_creator_anonymous_visitor_token.map(|t| t.as_str()),
|
||||
|
||||
args.creator_ip_address,
|
||||
args.creator_set_visibility.to_str(),
|
||||
|
||||
let query_result = insert_media_file_generic_executor(InsertMediaFileGenericExecutorArgs {
|
||||
maybe_creator_user_token: args.maybe_creator_user_token,
|
||||
maybe_creator_anonymous_visitor_token: args.maybe_creator_anonymous_visitor_token,
|
||||
creator_ip_address: args.creator_ip_address,
|
||||
creator_set_visibility: args.creator_set_visibility,
|
||||
media_class: args.media_class,
|
||||
media_type: args.media_type,
|
||||
is_user_upload: args.is_user_upload,
|
||||
is_intermediate_system_file: args.is_intermediate_system_file,
|
||||
origin_category: args.origin_category,
|
||||
origin_product_category: args.origin_product_category,
|
||||
maybe_origin_model_type: args.maybe_origin_model_type,
|
||||
maybe_origin_model_token: args.maybe_origin_model_token,
|
||||
maybe_origin_filename: args.maybe_origin_filename,
|
||||
maybe_mime_type: args.maybe_mime_type,
|
||||
file_size_bytes: args.file_size_bytes,
|
||||
maybe_duration_millis: args.maybe_duration_millis,
|
||||
maybe_audio_encoding: args.maybe_audio_encoding,
|
||||
maybe_video_encoding: args.maybe_video_encoding,
|
||||
maybe_frame_width: args.maybe_frame_width,
|
||||
maybe_frame_height: args.maybe_frame_height,
|
||||
checksum_sha2: args.checksum_sha2,
|
||||
maybe_engine_category: args.maybe_engine_category,
|
||||
maybe_title: args.maybe_title,
|
||||
maybe_text_transcript: args.maybe_text_transcript,
|
||||
maybe_scene_source_media_file_token: args.maybe_scene_source_media_file_token,
|
||||
maybe_prompt_token: args.maybe_prompt_token,
|
||||
maybe_batch_token: args.maybe_batch_token,
|
||||
public_bucket_directory_hash: args.public_bucket_directory_hash,
|
||||
maybe_public_bucket_prefix: args.maybe_public_bucket_prefix,
|
||||
maybe_public_bucket_extension: args.maybe_public_bucket_extension,
|
||||
maybe_creator_file_synthetic_id,
|
||||
maybe_creator_category_synthetic_id,
|
||||
maybe_extra_file_modification_info: extra_file_modification_info,
|
||||
is_generated_on_prem: args.is_generated_on_prem,
|
||||
generated_by_worker: args.generated_by_worker,
|
||||
generated_by_cluster: args.generated_by_cluster,
|
||||
maybe_generation_provider: args.maybe_generation_provider,
|
||||
maybe_platform_type: args.maybe_platform_type,
|
||||
maybe_cover_image_media_file_token: args.maybe_cover_image_media_file_token,
|
||||
maybe_mod_user_token: args.maybe_mod_user_token,
|
||||
mysql_executor: &mut *transaction,
|
||||
phantom: Default::default(),
|
||||
}).await;
|
||||
|
||||
extra_file_modification_info,
|
||||
|
||||
maybe_generation_provider_str,
|
||||
|
||||
args.maybe_platform_type.map(|p| p.to_str()),
|
||||
|
||||
args.maybe_cover_image_media_file_token.map(|t| t.as_str()),
|
||||
|
||||
args.maybe_mod_user_token,
|
||||
args.is_generated_on_prem,
|
||||
args.generated_by_worker,
|
||||
args.generated_by_cluster
|
||||
).execute(&mut *transaction).await;
|
||||
|
||||
let record_id = match query_result {
|
||||
Ok(res) => res.last_insert_id(),
|
||||
let (result_token, record_id) = match query_result {
|
||||
Ok(result) => result,
|
||||
Err(err) => {
|
||||
// TODO: handle better
|
||||
//transaction.rollback().await?;
|
||||
|
||||
+267
@@ -0,0 +1,267 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx;
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use enums::by_table::media_files::media_file_class::MediaFileClass;
|
||||
use enums::by_table::media_files::media_file_engine_category::MediaFileEngineCategory;
|
||||
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
|
||||
use enums::by_table::media_files::media_file_origin_model_type::MediaFileOriginModelType;
|
||||
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
|
||||
use enums::by_table::media_files::media_file_type::MediaFileType;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use enums::common::platform_type::PlatformType;
|
||||
use enums::common::visibility::Visibility;
|
||||
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
|
||||
use tokens::tokens::batch_generations::BatchGenerationToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::model_weights::ModelWeightToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
/// Arguments for [`insert_media_file_generic_executor`].
|
||||
///
|
||||
/// This is the executor-generic core of `insert_media_file_generic`: it runs
|
||||
/// the single INSERT statement and nothing else. Synthetic-ID increments and
|
||||
/// extra-info serialization stay with the caller (see
|
||||
/// `insert_media_file_generic`, which wraps this in a transaction).
|
||||
pub struct InsertMediaFileGenericExecutorArgs<'a, 'c, E>
|
||||
where
|
||||
E: 'a + Executor<'c, Database = MySql>,
|
||||
{
|
||||
// Creator info
|
||||
pub maybe_creator_user_token: Option<&'a UserToken>,
|
||||
pub maybe_creator_anonymous_visitor_token: Option<&'a AnonymousVisitorTrackingToken>,
|
||||
pub creator_ip_address: &'a str,
|
||||
pub creator_set_visibility: Visibility,
|
||||
|
||||
// Important database indices
|
||||
pub media_class: MediaFileClass,
|
||||
pub media_type: MediaFileType,
|
||||
pub is_user_upload: bool,
|
||||
pub is_intermediate_system_file: bool,
|
||||
|
||||
// Product and other origination information
|
||||
pub origin_category: MediaFileOriginCategory,
|
||||
pub origin_product_category: MediaFileOriginProductCategory,
|
||||
pub maybe_origin_model_type: Option<MediaFileOriginModelType>,
|
||||
pub maybe_origin_model_token: Option<&'a ModelWeightToken>,
|
||||
pub maybe_origin_filename: Option<String>,
|
||||
|
||||
// Media info
|
||||
pub maybe_mime_type: Option<&'a str>,
|
||||
pub file_size_bytes: u64,
|
||||
pub maybe_duration_millis: Option<u64>,
|
||||
pub maybe_audio_encoding: Option<&'a str>,
|
||||
pub maybe_video_encoding: Option<&'a str>,
|
||||
pub maybe_frame_width: Option<u32>,
|
||||
pub maybe_frame_height: Option<u32>,
|
||||
pub checksum_sha2: &'a str,
|
||||
|
||||
// Media info for certain product areas
|
||||
pub maybe_engine_category: Option<MediaFileEngineCategory>, // TODO: Deprecate
|
||||
|
||||
// User text information
|
||||
pub maybe_title: Option<&'a str>,
|
||||
pub maybe_text_transcript: Option<&'a str>,
|
||||
|
||||
// If generated from a scene, this is the scene media file token.
|
||||
pub maybe_scene_source_media_file_token: Option<&'a MediaFileToken>,
|
||||
|
||||
// If additional prompt details are stored, this is the prompt token.
|
||||
pub maybe_prompt_token: Option<&'a PromptToken>,
|
||||
|
||||
// If batch generated, this is the batch token.
|
||||
pub maybe_batch_token: Option<&'a BatchGenerationToken>,
|
||||
|
||||
// Storage details
|
||||
pub public_bucket_directory_hash: &'a str,
|
||||
pub maybe_public_bucket_prefix: Option<&'a str>,
|
||||
pub maybe_public_bucket_extension: Option<&'a str>,
|
||||
|
||||
// Counters, pre-computed by the caller (see
|
||||
// `transactional_increment_generic_synthetic_id`).
|
||||
pub maybe_creator_file_synthetic_id: Option<u64>,
|
||||
pub maybe_creator_category_synthetic_id: Option<u64>,
|
||||
|
||||
/// Extra polymorphic data stored in `extra_file_modification_info`,
|
||||
/// pre-serialized to JSON by the caller.
|
||||
pub maybe_extra_file_modification_info: Option<String>,
|
||||
|
||||
// Worker generation info
|
||||
pub is_generated_on_prem: bool,
|
||||
pub generated_by_worker: Option<&'a str>,
|
||||
pub generated_by_cluster: Option<&'a str>,
|
||||
|
||||
/// If provided, the third-party provider that generated this file.
|
||||
pub maybe_generation_provider: Option<GenerationProvider>,
|
||||
|
||||
/// The platform the creating request came from, inferred from its User-Agent.
|
||||
/// For inference results, this is copied from the originating job.
|
||||
pub maybe_platform_type: Option<PlatformType>,
|
||||
|
||||
// Cover image (e.g. thumbnail for 3D splats)
|
||||
pub maybe_cover_image_media_file_token: Option<&'a MediaFileToken>,
|
||||
|
||||
// Moderation details (deprecated)
|
||||
pub maybe_mod_user_token: Option<&'a UserToken>,
|
||||
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Insert a media file record using any executor (pool, connection, or open
|
||||
/// transaction). This is the single-statement core shared by
|
||||
/// `insert_media_file_generic` and the specialized inserts.
|
||||
pub async fn insert_media_file_generic_executor<'a, 'c: 'a, E>(
|
||||
args: InsertMediaFileGenericExecutorArgs<'a, 'c, E>,
|
||||
) -> Result<(MediaFileToken, u64), sqlx::Error>
|
||||
where
|
||||
E: 'a + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let result_token = MediaFileToken::generate();
|
||||
|
||||
let mut maybe_generation_provider_str = None;
|
||||
let is_intermediate_system_file = args.is_intermediate_system_file;
|
||||
let mut is_user_upload = args.is_user_upload;
|
||||
let mut origin_category = args.origin_category;
|
||||
|
||||
if let Some(generation_provider) = args.maybe_generation_provider {
|
||||
// Overrides if we're using a generation provider.
|
||||
// NB: `is_intermediate_system_file` is deliberately NOT reset here:
|
||||
// generated cover images / thumbnails set both a generation provider
|
||||
// and the intermediate flag, and clobbering the flag made them appear
|
||||
// in users' asset collections. The flag defaults to false at call
|
||||
// sites, so honoring the explicit setting is always correct.
|
||||
maybe_generation_provider_str = Some(generation_provider.to_str());
|
||||
is_user_upload = false;
|
||||
if generation_provider != GenerationProvider::Artcraft {
|
||||
origin_category = MediaFileOriginCategory::ThirdPartyInference;
|
||||
}
|
||||
}
|
||||
|
||||
let query_result = sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO media_files
|
||||
SET
|
||||
token = ?,
|
||||
|
||||
media_class = ?,
|
||||
media_type = ?,
|
||||
|
||||
is_user_upload = ?,
|
||||
is_intermediate_system_file = ?,
|
||||
|
||||
origin_category = ?,
|
||||
origin_product_category = ?,
|
||||
maybe_origin_model_type = ?,
|
||||
maybe_origin_model_token = ?,
|
||||
maybe_origin_filename = ?,
|
||||
|
||||
maybe_batch_token = ?,
|
||||
|
||||
maybe_mime_type = ?,
|
||||
file_size_bytes = ?,
|
||||
maybe_duration_millis = ?,
|
||||
maybe_audio_encoding = ?,
|
||||
maybe_video_encoding = ?,
|
||||
maybe_frame_width = ?,
|
||||
maybe_frame_height = ?,
|
||||
maybe_prompt_token = ?,
|
||||
checksum_sha2 = ?,
|
||||
|
||||
maybe_engine_category = ?,
|
||||
|
||||
maybe_title = ?,
|
||||
maybe_text_transcript = ?,
|
||||
|
||||
maybe_scene_source_media_file_token = ?,
|
||||
|
||||
public_bucket_directory_hash = ?,
|
||||
maybe_public_bucket_prefix = ?,
|
||||
maybe_public_bucket_extension = ?,
|
||||
|
||||
maybe_creator_user_token = ?,
|
||||
maybe_creator_anonymous_visitor_token = ?,
|
||||
|
||||
creator_ip_address = ?,
|
||||
creator_set_visibility = ?,
|
||||
|
||||
maybe_creator_file_synthetic_id = ?,
|
||||
maybe_creator_category_synthetic_id = ?,
|
||||
|
||||
extra_file_modification_info = ?,
|
||||
|
||||
maybe_generation_provider = ?,
|
||||
|
||||
platform_type = ?,
|
||||
|
||||
maybe_cover_image_media_file_token = ?,
|
||||
|
||||
maybe_mod_user_token = ?,
|
||||
is_generated_on_prem = ?,
|
||||
generated_by_worker = ?,
|
||||
generated_by_cluster = ?
|
||||
"#,
|
||||
result_token,
|
||||
|
||||
args.media_class.to_str(),
|
||||
args.media_type.to_str(),
|
||||
|
||||
is_user_upload,
|
||||
is_intermediate_system_file,
|
||||
|
||||
origin_category.to_str(),
|
||||
args.origin_product_category.to_str(),
|
||||
args.maybe_origin_model_type.map(|e| e.to_str()),
|
||||
args.maybe_origin_model_token.map(|t| t.to_string()),
|
||||
args.maybe_origin_filename,
|
||||
|
||||
args.maybe_batch_token.map(|t| t.as_str()),
|
||||
|
||||
args.maybe_mime_type,
|
||||
args.file_size_bytes,
|
||||
args.maybe_duration_millis,
|
||||
args.maybe_audio_encoding,
|
||||
args.maybe_video_encoding,
|
||||
args.maybe_frame_width,
|
||||
args.maybe_frame_height,
|
||||
args.maybe_prompt_token.map(|t| t.as_str()),
|
||||
args.checksum_sha2,
|
||||
|
||||
args.maybe_engine_category.map(|e| e.to_str()),
|
||||
|
||||
args.maybe_title,
|
||||
args.maybe_text_transcript,
|
||||
|
||||
args.maybe_scene_source_media_file_token.map(|t| t.as_str()),
|
||||
|
||||
args.public_bucket_directory_hash,
|
||||
args.maybe_public_bucket_prefix,
|
||||
args.maybe_public_bucket_extension,
|
||||
|
||||
args.maybe_creator_user_token.map(|t| t.as_str()),
|
||||
args.maybe_creator_anonymous_visitor_token.map(|t| t.as_str()),
|
||||
|
||||
args.creator_ip_address,
|
||||
args.creator_set_visibility.to_str(),
|
||||
|
||||
args.maybe_creator_file_synthetic_id,
|
||||
args.maybe_creator_category_synthetic_id,
|
||||
|
||||
args.maybe_extra_file_modification_info,
|
||||
|
||||
maybe_generation_provider_str,
|
||||
|
||||
args.maybe_platform_type.map(|p| p.to_str()),
|
||||
|
||||
args.maybe_cover_image_media_file_token.map(|t| t.as_str()),
|
||||
|
||||
args.maybe_mod_user_token,
|
||||
args.is_generated_on_prem,
|
||||
args.generated_by_worker,
|
||||
args.generated_by_cluster
|
||||
).execute(args.mysql_executor).await?;
|
||||
|
||||
Ok((result_token, query_result.last_insert_id()))
|
||||
}
|
||||
+1
@@ -1,2 +1,3 @@
|
||||
pub mod insert_media_file_generic;
|
||||
pub mod insert_media_file_generic_executor;
|
||||
pub mod insert_media_file_generic_from_job;
|
||||
|
||||
+7
-4
@@ -302,19 +302,22 @@ impl MediaFileInsertBuilder {
|
||||
"Origin category is required".to_string()))?;
|
||||
|
||||
let mut maybe_generation_provider_str = None;
|
||||
let mut is_intermediate_system_file = self.is_intermediate_system_file;
|
||||
let is_intermediate_system_file = self.is_intermediate_system_file;
|
||||
let mut is_user_upload = self.is_user_upload;
|
||||
|
||||
if let Some(generation_provider) = self.maybe_generation_provider {
|
||||
// Overrides if we're using a generation provider
|
||||
// Overrides if we're using a generation provider.
|
||||
// NB: `is_intermediate_system_file` is deliberately NOT reset here:
|
||||
// generated cover images / thumbnails set both a generation provider
|
||||
// and the intermediate flag, and clobbering the flag made them appear
|
||||
// in users' asset collections. The flag defaults to false, so honoring
|
||||
// the explicit setter is always correct.
|
||||
maybe_generation_provider_str = Some(generation_provider.to_str());
|
||||
is_intermediate_system_file = false;
|
||||
is_user_upload = false;
|
||||
if generation_provider != GenerationProvider::Artcraft {
|
||||
origin_category = MediaFileOriginCategory::ThirdPartyInference;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let result = insert_media_file_generic(InsertArgs {
|
||||
pool: mysql_pool,
|
||||
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
|
||||
use enums::by_table::media_files::media_file_class::MediaFileClass;
|
||||
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
|
||||
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
|
||||
use enums::by_table::media_files::media_file_type::MediaFileType;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use enums::common::platform_type::PlatformType;
|
||||
use enums::common::visibility::Visibility;
|
||||
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::queries::media_files::create::generic_insert::insert_media_file_generic_executor::{insert_media_file_generic_executor, InsertMediaFileGenericExecutorArgs};
|
||||
|
||||
pub struct InsertCoverMediaFileArgs<'a, 'c, E>
|
||||
where
|
||||
E: 'a + Executor<'c, Database = MySql>,
|
||||
{
|
||||
// Creator info (copied from the generation job).
|
||||
pub maybe_creator_user_token: Option<&'a UserToken>,
|
||||
pub maybe_creator_anonymous_visitor_token: Option<&'a AnonymousVisitorTrackingToken>,
|
||||
pub creator_ip_address: &'a str,
|
||||
|
||||
/// The image format of the cover (png, jpg, webp, ...).
|
||||
pub media_file_type: MediaFileType,
|
||||
pub mime_type: &'a str,
|
||||
pub file_size_bytes: u64,
|
||||
pub checksum_sha2: &'a str,
|
||||
pub public_bucket_path: &'a MediaFileBucketPath,
|
||||
|
||||
/// Product category of the generation the cover belongs to (e.g.
|
||||
/// `WorldGeneration` for gaussian splat covers).
|
||||
pub maybe_origin_product_category: Option<MediaFileOriginProductCategory>,
|
||||
pub maybe_prompt_token: Option<&'a PromptToken>,
|
||||
pub maybe_platform_type: Option<PlatformType>,
|
||||
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Insert a cover image media file record (e.g. a mesh/splat thumbnail
|
||||
/// downloaded from a generation provider).
|
||||
///
|
||||
/// Cover images are always `media_class = image` and — critically — always
|
||||
/// `is_intermediate_system_file = TRUE`, so they never appear in the user's
|
||||
/// asset collection. They are low-quality preview renders, not assets the
|
||||
/// user paid to generate.
|
||||
///
|
||||
/// Cover images deliberately do not consume the creator's synthetic file IDs:
|
||||
/// those number the user's visible assets, and covers are hidden.
|
||||
pub async fn insert_cover_media_file<'a, 'c: 'a, E>(
|
||||
args: InsertCoverMediaFileArgs<'a, 'c, E>,
|
||||
) -> Result<MediaFileToken, sqlx::Error>
|
||||
where
|
||||
E: 'a + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let (media_token, _record_id) = insert_media_file_generic_executor(InsertMediaFileGenericExecutorArgs {
|
||||
// User details
|
||||
maybe_creator_user_token: args.maybe_creator_user_token,
|
||||
maybe_creator_anonymous_visitor_token: args.maybe_creator_anonymous_visitor_token,
|
||||
creator_ip_address: args.creator_ip_address,
|
||||
maybe_platform_type: args.maybe_platform_type,
|
||||
|
||||
// Storage
|
||||
public_bucket_directory_hash: args.public_bucket_path.get_object_hash(),
|
||||
maybe_public_bucket_prefix: args.public_bucket_path.get_optional_prefix(),
|
||||
maybe_public_bucket_extension: args.public_bucket_path.get_optional_extension(),
|
||||
|
||||
// File details
|
||||
checksum_sha2: args.checksum_sha2,
|
||||
file_size_bytes: args.file_size_bytes,
|
||||
maybe_mime_type: Some(args.mime_type),
|
||||
media_class: MediaFileClass::Image,
|
||||
|
||||
// Additional foreign keys and misc
|
||||
maybe_generation_provider: Some(GenerationProvider::Artcraft),
|
||||
maybe_prompt_token: args.maybe_prompt_token,
|
||||
media_type: args.media_file_type,
|
||||
origin_product_category: args.maybe_origin_product_category // TODO: Not useful here.
|
||||
.unwrap_or(MediaFileOriginProductCategory::Unknown),
|
||||
|
||||
// Essential - this is a hidden file
|
||||
is_intermediate_system_file: true,
|
||||
creator_set_visibility: Visibility::Hidden,
|
||||
|
||||
// Other Constants
|
||||
generated_by_cluster: None,
|
||||
generated_by_worker: None,
|
||||
is_generated_on_prem: false,
|
||||
is_user_upload: false,
|
||||
maybe_audio_encoding: None,
|
||||
maybe_batch_token: None,
|
||||
maybe_cover_image_media_file_token: None,
|
||||
maybe_creator_category_synthetic_id: None,
|
||||
maybe_creator_file_synthetic_id: None,
|
||||
maybe_duration_millis: None,
|
||||
maybe_engine_category: None,
|
||||
maybe_extra_file_modification_info: None,
|
||||
maybe_frame_height: None,
|
||||
maybe_frame_width: None,
|
||||
maybe_mod_user_token: None,
|
||||
maybe_origin_filename: None,
|
||||
maybe_origin_model_token: None,
|
||||
maybe_origin_model_type: None,
|
||||
maybe_scene_source_media_file_token: None,
|
||||
maybe_text_transcript: None,
|
||||
maybe_title: None,
|
||||
maybe_video_encoding: None,
|
||||
origin_category: MediaFileOriginCategory::Inference, // TODO: Not useful here.
|
||||
|
||||
// Connection
|
||||
mysql_executor: args.mysql_executor,
|
||||
phantom: Default::default(),
|
||||
}).await?;
|
||||
|
||||
Ok(media_token)
|
||||
}
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
pub mod insert_cover_media_file;
|
||||
pub mod insert_media_file_from_cli_tool;
|
||||
pub mod insert_media_file_from_comfy_ui;
|
||||
pub mod insert_media_file_from_f5_tts;
|
||||
@@ -9,4 +10,4 @@ pub mod insert_media_file_from_seed_vc;
|
||||
pub mod insert_media_file_from_studio_gen2;
|
||||
pub mod insert_media_file_from_tacotron2;
|
||||
pub mod insert_media_file_from_voice_conversion;
|
||||
pub mod insert_media_file_from_zero_shot_tts;
|
||||
pub mod insert_media_file_from_zero_shot_tts;
|
||||
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
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 tokens::tokens::users::UserToken;
|
||||
|
||||
pub struct SessionMediaFileListPage {
|
||||
pub records: Vec<SessionMediaFileListItem>,
|
||||
|
||||
/// ID of the first record (for building the "previous" cursor).
|
||||
pub first_id: Option<u64>,
|
||||
|
||||
/// ID of the last record (for building the "next" cursor).
|
||||
pub last_id: Option<u64>,
|
||||
}
|
||||
|
||||
pub struct SessionMediaFileListItem {
|
||||
pub id: u64,
|
||||
pub token: MediaFileToken,
|
||||
|
||||
pub media_class: MediaFileClass,
|
||||
pub media_type: MediaFileType,
|
||||
|
||||
pub public_bucket_directory_hash: String,
|
||||
pub maybe_public_bucket_prefix: Option<String>,
|
||||
pub maybe_public_bucket_extension: Option<String>,
|
||||
|
||||
pub maybe_creator_user_token: Option<UserToken>,
|
||||
pub maybe_creator_username: Option<String>,
|
||||
pub maybe_creator_display_name: Option<String>,
|
||||
pub maybe_creator_gravatar_hash: Option<String>,
|
||||
|
||||
pub maybe_file_cover_image_public_bucket_hash: Option<String>,
|
||||
pub maybe_file_cover_image_public_bucket_prefix: Option<String>,
|
||||
pub maybe_file_cover_image_public_bucket_extension: Option<String>,
|
||||
|
||||
pub creator_set_visibility: Visibility,
|
||||
|
||||
pub maybe_prompt_token: Option<PromptToken>,
|
||||
pub maybe_title: Option<String>,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub struct ListSessionMediaFilesByClassArgs<'a, 'c, E>
|
||||
where
|
||||
E: 'a + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub user_token: &'a UserToken,
|
||||
|
||||
/// The media class to list (e.g. `mesh` or `splat`).
|
||||
pub media_class: MediaFileClass,
|
||||
|
||||
pub limit: usize,
|
||||
|
||||
/// Keyset cursor: a `media_files.id` from a previous page.
|
||||
pub maybe_cursor_id: Option<u64>,
|
||||
pub cursor_is_reversed: bool,
|
||||
pub sort_ascending: bool,
|
||||
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// List the session user's media files of a single class (e.g. `mesh` or
|
||||
/// `splat`), newest first by default, with keyset pagination on `id`.
|
||||
pub async fn list_session_media_files_by_class<'a, 'c: 'a, E>(
|
||||
args: ListSessionMediaFilesByClassArgs<'a, 'c, E>,
|
||||
) -> Result<SessionMediaFileListPage, sqlx::Error>
|
||||
where
|
||||
E: 'a + Executor<'c, Database = MySql>,
|
||||
{
|
||||
// A reversed cursor walks the opposite direction of the sort, so the effective
|
||||
// scan direction is the XOR of the two flags (same scheme as `list_media_files`).
|
||||
let ascending = args.sort_ascending != args.cursor_is_reversed;
|
||||
|
||||
let limit = args.limit as u64;
|
||||
|
||||
let records = if ascending {
|
||||
// NB: IDs start at 1, so a zero sentinel means "no cursor".
|
||||
let cursor_id = args.maybe_cursor_id.unwrap_or(0);
|
||||
select_page_ascending(
|
||||
args.user_token,
|
||||
args.media_class,
|
||||
cursor_id,
|
||||
limit,
|
||||
args.mysql_executor,
|
||||
).await?
|
||||
} else {
|
||||
let cursor_id = args.maybe_cursor_id.unwrap_or(u64::MAX);
|
||||
select_page_descending(
|
||||
args.user_token,
|
||||
args.media_class,
|
||||
cursor_id,
|
||||
limit,
|
||||
args.mysql_executor,
|
||||
).await?
|
||||
};
|
||||
|
||||
let first_id = records.first().map(|record| record.id);
|
||||
let last_id = records.last().map(|record| record.id);
|
||||
|
||||
Ok(SessionMediaFileListPage {
|
||||
records,
|
||||
first_id,
|
||||
last_id,
|
||||
})
|
||||
}
|
||||
|
||||
// NB: The two queries below are intentionally duplicated so that sqlx can check
|
||||
// them at compile time (no QueryBuilder). Keep the SELECT lists and predicates
|
||||
// in sync; they differ only in the cursor comparison direction and sort order.
|
||||
|
||||
async fn select_page_ascending<'a, 'c: 'a, E>(
|
||||
user_token: &'a UserToken,
|
||||
media_class: MediaFileClass,
|
||||
cursor_id: u64,
|
||||
limit: u64,
|
||||
mysql_executor: E,
|
||||
) -> Result<Vec<SessionMediaFileListItem>, sqlx::Error>
|
||||
where
|
||||
E: 'a + Executor<'c, Database = MySql>,
|
||||
{
|
||||
sqlx::query_as!(
|
||||
SessionMediaFileListItem,
|
||||
r#"
|
||||
SELECT
|
||||
m.id as `id: u64`,
|
||||
m.token as `token: tokens::tokens::media_files::MediaFileToken`,
|
||||
|
||||
m.media_class as `media_class: enums::by_table::media_files::media_file_class::MediaFileClass`,
|
||||
m.media_type as `media_type: enums::by_table::media_files::media_file_type::MediaFileType`,
|
||||
|
||||
m.public_bucket_directory_hash,
|
||||
m.maybe_public_bucket_prefix,
|
||||
m.maybe_public_bucket_extension,
|
||||
|
||||
users.token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,
|
||||
users.username as maybe_creator_username,
|
||||
users.display_name as maybe_creator_display_name,
|
||||
users.email_gravatar_hash as maybe_creator_gravatar_hash,
|
||||
|
||||
media_file_cover_image.public_bucket_directory_hash as maybe_file_cover_image_public_bucket_hash,
|
||||
media_file_cover_image.maybe_public_bucket_prefix as maybe_file_cover_image_public_bucket_prefix,
|
||||
media_file_cover_image.maybe_public_bucket_extension as maybe_file_cover_image_public_bucket_extension,
|
||||
|
||||
m.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,
|
||||
|
||||
m.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,
|
||||
m.maybe_title,
|
||||
|
||||
m.created_at,
|
||||
m.updated_at
|
||||
|
||||
FROM media_files AS m FORCE INDEX (fk_maybe_creator_user_token)
|
||||
LEFT OUTER JOIN users
|
||||
ON m.maybe_creator_user_token = users.token
|
||||
LEFT OUTER JOIN media_files as media_file_cover_image
|
||||
ON media_file_cover_image.token = m.maybe_cover_image_media_file_token
|
||||
WHERE
|
||||
m.maybe_creator_user_token = ?
|
||||
AND m.media_class = ?
|
||||
AND m.user_deleted_at IS NULL
|
||||
AND m.mod_deleted_at IS NULL
|
||||
AND NOT m.is_intermediate_system_file
|
||||
AND m.id > ?
|
||||
ORDER BY m.id ASC
|
||||
LIMIT ?
|
||||
"#,
|
||||
user_token,
|
||||
media_class.to_str(),
|
||||
cursor_id,
|
||||
limit,
|
||||
)
|
||||
.fetch_all(mysql_executor)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn select_page_descending<'a, 'c: 'a, E>(
|
||||
user_token: &'a UserToken,
|
||||
media_class: MediaFileClass,
|
||||
cursor_id: u64,
|
||||
limit: u64,
|
||||
mysql_executor: E,
|
||||
) -> Result<Vec<SessionMediaFileListItem>, sqlx::Error>
|
||||
where
|
||||
E: 'a + Executor<'c, Database = MySql>,
|
||||
{
|
||||
sqlx::query_as!(
|
||||
SessionMediaFileListItem,
|
||||
r#"
|
||||
SELECT
|
||||
m.id as `id: u64`,
|
||||
m.token as `token: tokens::tokens::media_files::MediaFileToken`,
|
||||
|
||||
m.media_class as `media_class: enums::by_table::media_files::media_file_class::MediaFileClass`,
|
||||
m.media_type as `media_type: enums::by_table::media_files::media_file_type::MediaFileType`,
|
||||
|
||||
m.public_bucket_directory_hash,
|
||||
m.maybe_public_bucket_prefix,
|
||||
m.maybe_public_bucket_extension,
|
||||
|
||||
users.token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,
|
||||
users.username as maybe_creator_username,
|
||||
users.display_name as maybe_creator_display_name,
|
||||
users.email_gravatar_hash as maybe_creator_gravatar_hash,
|
||||
|
||||
media_file_cover_image.public_bucket_directory_hash as maybe_file_cover_image_public_bucket_hash,
|
||||
media_file_cover_image.maybe_public_bucket_prefix as maybe_file_cover_image_public_bucket_prefix,
|
||||
media_file_cover_image.maybe_public_bucket_extension as maybe_file_cover_image_public_bucket_extension,
|
||||
|
||||
m.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,
|
||||
|
||||
m.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,
|
||||
m.maybe_title,
|
||||
|
||||
m.created_at,
|
||||
m.updated_at
|
||||
|
||||
FROM media_files AS m FORCE INDEX (fk_maybe_creator_user_token)
|
||||
LEFT OUTER JOIN users
|
||||
ON m.maybe_creator_user_token = users.token
|
||||
LEFT OUTER JOIN media_files as media_file_cover_image
|
||||
ON media_file_cover_image.token = m.maybe_cover_image_media_file_token
|
||||
WHERE
|
||||
m.maybe_creator_user_token = ?
|
||||
AND m.media_class = ?
|
||||
AND m.user_deleted_at IS NULL
|
||||
AND m.mod_deleted_at IS NULL
|
||||
AND NOT m.is_intermediate_system_file
|
||||
AND m.id < ?
|
||||
ORDER BY m.id DESC
|
||||
LIMIT ?
|
||||
"#,
|
||||
user_token,
|
||||
media_class.to_str(),
|
||||
cursor_id,
|
||||
limit,
|
||||
)
|
||||
.fetch_all(mysql_executor)
|
||||
.await
|
||||
}
|
||||
@@ -4,5 +4,6 @@ pub mod list_media_files;
|
||||
pub mod list_media_files_by_batch_token;
|
||||
pub mod list_media_files_for_elastic_search_backfill_using_cursor;
|
||||
pub mod list_media_files_for_user;
|
||||
pub mod list_session_media_files_by_class;
|
||||
pub mod list_session_project_media_files;
|
||||
pub mod media_file_list_row;
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
|
||||
pub struct GetFirstImagerefForPromptArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub prompt_token: &'e PromptToken,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Find the first (oldest-attached) non-deleted image reference attached to a
|
||||
/// prompt. Used as a cover-image fallback for mesh/splat generations whose
|
||||
/// webhook payload carries no thumbnail: the image the user generated the
|
||||
/// model from stands in as the cover.
|
||||
pub async fn get_first_imageref_for_prompt<'e, 'c: 'e, E>(
|
||||
args: GetFirstImagerefForPromptArgs<'e, 'c, E>,
|
||||
) -> Result<Option<MediaFileToken>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
pci.media_token as `media_token: MediaFileToken`
|
||||
FROM prompt_context_items pci
|
||||
JOIN media_files m
|
||||
ON pci.media_token = m.token
|
||||
WHERE pci.prompt_token = ?
|
||||
AND m.media_class = 'image'
|
||||
AND m.user_deleted_at IS NULL
|
||||
AND m.mod_deleted_at IS NULL
|
||||
ORDER BY pci.id ASC
|
||||
LIMIT 1
|
||||
"#,
|
||||
args.prompt_token.as_str(),
|
||||
)
|
||||
.fetch_optional(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(result.map(|r| r.media_token))
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod batch_list_prompt_context_items;
|
||||
pub mod get_first_imageref_for_prompt;
|
||||
pub mod insert_batch_prompt_context_items;
|
||||
pub mod list_prompt_context_items;
|
||||
pub mod list_prompt_context_items;
|
||||
|
||||
+11
-1
@@ -72,6 +72,10 @@ pub enum InferenceJobProductCategory {
|
||||
#[serde(rename = "seedance2pro_video_bpu")]
|
||||
Seedance2ProVideoBytePlusUltra,
|
||||
|
||||
/// Seedance 2 Pro / Kinovi (BytePlus Ultra account): image generation.
|
||||
#[serde(rename = "seedance2pro_image_bpu")]
|
||||
Seedance2ProImageBytePlusUltra,
|
||||
|
||||
// =============== WORLD LABS ===============
|
||||
|
||||
/// World Labs: Gaussian Splat Generation
|
||||
@@ -183,6 +187,7 @@ impl InferenceJobProductCategory {
|
||||
Self::Seedance2ProAudio => "seedance2pro_audio",
|
||||
Self::Seedance2ProVideoAlt => "seedance2pro_video_alt",
|
||||
Self::Seedance2ProVideoBytePlusUltra => "seedance2pro_video_bpu",
|
||||
Self::Seedance2ProImageBytePlusUltra => "seedance2pro_image_bpu",
|
||||
Self::WorldlabsSplat => "worldlabs_splat",
|
||||
Self::TtsGptSoVits => "tts_gpt_so_vits",
|
||||
Self::TtsStyleTts2 => "tts_style_tts2",
|
||||
@@ -226,6 +231,7 @@ impl InferenceJobProductCategory {
|
||||
"seedance2pro_audio" => Ok(Self::Seedance2ProAudio),
|
||||
"seedance2pro_video_alt" => Ok(Self::Seedance2ProVideoAlt),
|
||||
"seedance2pro_video_bpu" => Ok(Self::Seedance2ProVideoBytePlusUltra),
|
||||
"seedance2pro_image_bpu" => Ok(Self::Seedance2ProImageBytePlusUltra),
|
||||
"worldlabs_splat" => Ok(Self::WorldlabsSplat),
|
||||
"tts_gpt_so_vits" => Ok(Self::TtsGptSoVits),
|
||||
"tts_style_tts2" => Ok(Self::TtsStyleTts2),
|
||||
@@ -272,6 +278,7 @@ impl InferenceJobProductCategory {
|
||||
Self::Seedance2ProAudio,
|
||||
Self::Seedance2ProVideoAlt,
|
||||
Self::Seedance2ProVideoBytePlusUltra,
|
||||
Self::Seedance2ProImageBytePlusUltra,
|
||||
Self::WorldlabsSplat,
|
||||
Self::TtsGptSoVits,
|
||||
Self::TtsStyleTts2,
|
||||
@@ -324,6 +331,7 @@ mod tests {
|
||||
assert_serialization(InferenceJobProductCategory::Seedance2ProAudio, "seedance2pro_audio");
|
||||
assert_serialization(InferenceJobProductCategory::Seedance2ProVideoAlt, "seedance2pro_video_alt");
|
||||
assert_serialization(InferenceJobProductCategory::Seedance2ProVideoBytePlusUltra, "seedance2pro_video_bpu");
|
||||
assert_serialization(InferenceJobProductCategory::Seedance2ProImageBytePlusUltra, "seedance2pro_image_bpu");
|
||||
assert_serialization(InferenceJobProductCategory::WorldlabsSplat, "worldlabs_splat");
|
||||
assert_serialization(InferenceJobProductCategory::TtsGptSoVits, "tts_gpt_so_vits");
|
||||
assert_serialization(InferenceJobProductCategory::TtsStyleTts2, "tts_style_tts2");
|
||||
@@ -366,6 +374,7 @@ mod tests {
|
||||
assert_eq!(InferenceJobProductCategory::Seedance2ProAudio.to_str(), "seedance2pro_audio");
|
||||
assert_eq!(InferenceJobProductCategory::Seedance2ProVideoAlt.to_str(), "seedance2pro_video_alt");
|
||||
assert_eq!(InferenceJobProductCategory::Seedance2ProVideoBytePlusUltra.to_str(), "seedance2pro_video_bpu");
|
||||
assert_eq!(InferenceJobProductCategory::Seedance2ProImageBytePlusUltra.to_str(), "seedance2pro_image_bpu");
|
||||
assert_eq!(InferenceJobProductCategory::WorldlabsSplat.to_str(), "worldlabs_splat");
|
||||
assert_eq!(InferenceJobProductCategory::TtsGptSoVits.to_str(), "tts_gpt_so_vits");
|
||||
assert_eq!(InferenceJobProductCategory::TtsStyleTts2.to_str(), "tts_style_tts2");
|
||||
@@ -407,6 +416,7 @@ mod tests {
|
||||
assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_audio").unwrap(), InferenceJobProductCategory::Seedance2ProAudio);
|
||||
assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_video_alt").unwrap(), InferenceJobProductCategory::Seedance2ProVideoAlt);
|
||||
assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_video_bpu").unwrap(), InferenceJobProductCategory::Seedance2ProVideoBytePlusUltra);
|
||||
assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_image_bpu").unwrap(), InferenceJobProductCategory::Seedance2ProImageBytePlusUltra);
|
||||
assert_eq!(InferenceJobProductCategory::from_str("worldlabs_splat").unwrap(), InferenceJobProductCategory::WorldlabsSplat);
|
||||
assert_eq!(InferenceJobProductCategory::from_str("tts_gpt_so_vits").unwrap(), InferenceJobProductCategory::TtsGptSoVits);
|
||||
assert_eq!(InferenceJobProductCategory::from_str("tts_style_tts2").unwrap(), InferenceJobProductCategory::TtsStyleTts2);
|
||||
@@ -434,7 +444,7 @@ mod tests {
|
||||
#[test]
|
||||
fn all_variants() {
|
||||
// Static check
|
||||
const EXPECTED_COUNT : usize = 38;
|
||||
const EXPECTED_COUNT : usize = 39;
|
||||
|
||||
assert_eq!(InferenceJobProductCategory::all_variants().len(), EXPECTED_COUNT);
|
||||
assert_eq!(InferenceJobProductCategory::iter().len(), EXPECTED_COUNT);
|
||||
|
||||
+15
@@ -127,6 +127,10 @@ pub enum InferenceModelType {
|
||||
Seedream4p5,
|
||||
#[serde(rename = "seedream_5_lite")]
|
||||
Seedream5Lite,
|
||||
#[serde(rename = "seedream_5p0_pro")]
|
||||
Seedream5p0Pro,
|
||||
#[serde(rename = "seedream_5p0_pro_u")]
|
||||
Seedream5p0ProUltra,
|
||||
#[serde(rename = "midjourney")]
|
||||
Midjourney,
|
||||
#[serde(rename = "midjourney_v6")]
|
||||
@@ -334,6 +338,8 @@ impl InferenceModelType {
|
||||
Self::Seedream4 => "seedream_4",
|
||||
Self::Seedream4p5 => "seedream_4p5",
|
||||
Self::Seedream5Lite => "seedream_5_lite",
|
||||
Self::Seedream5p0Pro => "seedream_5p0_pro",
|
||||
Self::Seedream5p0ProUltra => "seedream_5p0_pro_u",
|
||||
Self::Midjourney => "midjourney",
|
||||
Self::MidjourneyV6 => "midjourney_v6",
|
||||
Self::MidjourneyV6p1 => "midjourney_v6p1",
|
||||
@@ -462,6 +468,8 @@ impl InferenceModelType {
|
||||
"seedream_4" => Ok(Self::Seedream4),
|
||||
"seedream_4p5" => Ok(Self::Seedream4p5),
|
||||
"seedream_5_lite" => Ok(Self::Seedream5Lite),
|
||||
"seedream_5p0_pro" => Ok(Self::Seedream5p0Pro),
|
||||
"seedream_5p0_pro_u" => Ok(Self::Seedream5p0ProUltra),
|
||||
"midjourney" => Ok(Self::Midjourney),
|
||||
"midjourney_v6" => Ok(Self::MidjourneyV6),
|
||||
"midjourney_v6p1" => Ok(Self::MidjourneyV6p1),
|
||||
@@ -594,6 +602,8 @@ impl InferenceModelType {
|
||||
Self::Seedream4,
|
||||
Self::Seedream4p5,
|
||||
Self::Seedream5Lite,
|
||||
Self::Seedream5p0Pro,
|
||||
Self::Seedream5p0ProUltra,
|
||||
Self::Midjourney,
|
||||
Self::MidjourneyV6,
|
||||
Self::MidjourneyV6p1,
|
||||
@@ -709,6 +719,8 @@ impl InferenceModelType {
|
||||
CommonModelType::Seedream4 => Self::Seedream4,
|
||||
CommonModelType::Seedream4p5 => Self::Seedream4p5,
|
||||
CommonModelType::Seedream5Lite => Self::Seedream5Lite,
|
||||
CommonModelType::Seedream5p0Pro => Self::Seedream5p0Pro,
|
||||
CommonModelType::Seedream5p0ProUltra => Self::Seedream5p0ProUltra,
|
||||
CommonModelType::Midjourney => Self::Midjourney,
|
||||
CommonModelType::MidjourneyV6 => Self::MidjourneyV6,
|
||||
CommonModelType::MidjourneyV6p1 => Self::MidjourneyV6p1,
|
||||
@@ -847,6 +859,7 @@ mod tests {
|
||||
assert_serialization(InferenceModelType::Seedream4, "seedream_4");
|
||||
assert_serialization(InferenceModelType::Seedream4p5, "seedream_4p5");
|
||||
assert_serialization(InferenceModelType::Seedream5Lite, "seedream_5_lite");
|
||||
assert_serialization(InferenceModelType::Seedream5p0Pro, "seedream_5p0_pro");
|
||||
assert_serialization(InferenceModelType::Midjourney, "midjourney");
|
||||
assert_serialization(InferenceModelType::MidjourneyV6, "midjourney_v6");
|
||||
assert_serialization(InferenceModelType::MidjourneyV6p1, "midjourney_v6p1");
|
||||
@@ -972,6 +985,7 @@ mod tests {
|
||||
assert_eq!(InferenceModelType::Seedream4.to_str(), "seedream_4");
|
||||
assert_eq!(InferenceModelType::Seedream4p5.to_str(), "seedream_4p5");
|
||||
assert_eq!(InferenceModelType::Seedream5Lite.to_str(), "seedream_5_lite");
|
||||
assert_eq!(InferenceModelType::Seedream5p0Pro.to_str(), "seedream_5p0_pro");
|
||||
assert_eq!(InferenceModelType::Midjourney.to_str(), "midjourney");
|
||||
assert_eq!(InferenceModelType::MidjourneyV6.to_str(), "midjourney_v6");
|
||||
assert_eq!(InferenceModelType::MidjourneyV6p1.to_str(), "midjourney_v6p1");
|
||||
@@ -1099,6 +1113,7 @@ mod tests {
|
||||
assert_eq!(InferenceModelType::from_str("seedream_4").unwrap(), InferenceModelType::Seedream4);
|
||||
assert_eq!(InferenceModelType::from_str("seedream_4p5").unwrap(), InferenceModelType::Seedream4p5);
|
||||
assert_eq!(InferenceModelType::from_str("seedream_5_lite").unwrap(), InferenceModelType::Seedream5Lite);
|
||||
assert_eq!(InferenceModelType::from_str("seedream_5p0_pro").unwrap(), InferenceModelType::Seedream5p0Pro);
|
||||
assert_eq!(InferenceModelType::from_str("midjourney").unwrap(), InferenceModelType::Midjourney);
|
||||
assert_eq!(InferenceModelType::from_str("midjourney_v6").unwrap(), InferenceModelType::MidjourneyV6);
|
||||
assert_eq!(InferenceModelType::from_str("midjourney_v6p1").unwrap(), InferenceModelType::MidjourneyV6p1);
|
||||
|
||||
@@ -47,6 +47,10 @@ pub enum CommonImageModel {
|
||||
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")]
|
||||
@@ -75,6 +79,8 @@ impl CommonImageModel {
|
||||
Self::Seedream4 => CommonModelType::Seedream4,
|
||||
Self::Seedream4p5 => CommonModelType::Seedream4p5,
|
||||
Self::Seedream5Lite => CommonModelType::Seedream5Lite,
|
||||
Self::Seedream5p0Pro => CommonModelType::Seedream5p0Pro,
|
||||
Self::Seedream5p0ProUltra => CommonModelType::Seedream5p0ProUltra,
|
||||
Self::QwenEdit2511Angles => CommonModelType::QwenEdit2511Angles,
|
||||
Self::Flux2LoraAngles => CommonModelType::Flux2LoraAngles,
|
||||
}
|
||||
@@ -111,6 +117,8 @@ mod tests {
|
||||
assert_serialization(CommonImageModel::Seedream4, "seedream_4");
|
||||
assert_serialization(CommonImageModel::Seedream4p5, "seedream_4p5");
|
||||
assert_serialization(CommonImageModel::Seedream5Lite, "seedream_5_lite");
|
||||
assert_serialization(CommonImageModel::Seedream5p0Pro, "seedream_5p0_pro");
|
||||
assert_serialization(CommonImageModel::Seedream5p0ProUltra, "seedream_5p0_pro_u");
|
||||
assert_serialization(CommonImageModel::QwenEdit2511Angles, "qwen_edit_2511_angles");
|
||||
assert_serialization(CommonImageModel::Flux2LoraAngles, "flux_2_lora_angles");
|
||||
}
|
||||
@@ -136,6 +144,8 @@ mod tests {
|
||||
(CommonImageModel::Seedream4, CommonModelType::Seedream4),
|
||||
(CommonImageModel::Seedream4p5, CommonModelType::Seedream4p5),
|
||||
(CommonImageModel::Seedream5Lite, CommonModelType::Seedream5Lite),
|
||||
(CommonImageModel::Seedream5p0Pro, CommonModelType::Seedream5p0Pro),
|
||||
(CommonImageModel::Seedream5p0ProUltra, CommonModelType::Seedream5p0ProUltra),
|
||||
(CommonImageModel::QwenEdit2511Angles, CommonModelType::QwenEdit2511Angles),
|
||||
(CommonImageModel::Flux2LoraAngles, CommonModelType::Flux2LoraAngles),
|
||||
];
|
||||
@@ -164,7 +174,7 @@ mod tests {
|
||||
#[test]
|
||||
fn variant_count() {
|
||||
use strum::IntoEnumIterator;
|
||||
assert_eq!(CommonImageModel::iter().len(), 20);
|
||||
assert_eq!(CommonImageModel::iter().len(), 22);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -82,6 +82,12 @@ pub enum CommonModelType {
|
||||
Seedream4p5,
|
||||
#[serde(rename = "seedream_5_lite")]
|
||||
Seedream5Lite,
|
||||
/// Seedream 5.0 Pro as routed via kinovi
|
||||
#[serde(rename = "seedream_5p0_pro")]
|
||||
Seedream5p0Pro,
|
||||
/// Seedream 5.0 Pro as routed via the kinovi ultra account
|
||||
#[serde(rename = "seedream_5p0_pro_u")]
|
||||
Seedream5p0ProUltra,
|
||||
|
||||
/// Midjourney without distinguishing a model type or version
|
||||
#[serde(rename = "midjourney")]
|
||||
@@ -287,6 +293,8 @@ impl CommonModelType {
|
||||
Self::Seedream4 => "seedream_4",
|
||||
Self::Seedream4p5 => "seedream_4p5",
|
||||
Self::Seedream5Lite => "seedream_5_lite",
|
||||
Self::Seedream5p0Pro => "seedream_5p0_pro",
|
||||
Self::Seedream5p0ProUltra => "seedream_5p0_pro_u",
|
||||
Self::Midjourney => "midjourney",
|
||||
Self::MidjourneyV6 => "midjourney_v6",
|
||||
Self::MidjourneyV6p1 => "midjourney_v6p1",
|
||||
@@ -398,6 +406,8 @@ impl CommonModelType {
|
||||
"seedream_4" => Ok(Self::Seedream4),
|
||||
"seedream_4p5" => Ok(Self::Seedream4p5),
|
||||
"seedream_5_lite" => Ok(Self::Seedream5Lite),
|
||||
"seedream_5p0_pro" => Ok(Self::Seedream5p0Pro),
|
||||
"seedream_5p0_pro_u" => Ok(Self::Seedream5p0ProUltra),
|
||||
"midjourney" => Ok(Self::Midjourney),
|
||||
"midjourney_v6" => Ok(Self::MidjourneyV6),
|
||||
"midjourney_v6p1" => Ok(Self::MidjourneyV6p1),
|
||||
@@ -513,6 +523,8 @@ impl CommonModelType {
|
||||
Self::Seedream4,
|
||||
Self::Seedream4p5,
|
||||
Self::Seedream5Lite,
|
||||
Self::Seedream5p0Pro,
|
||||
Self::Seedream5p0ProUltra,
|
||||
Self::Midjourney,
|
||||
Self::MidjourneyV6,
|
||||
Self::MidjourneyV6p1,
|
||||
@@ -625,6 +637,8 @@ impl CommonModelType {
|
||||
Self::Seedream4 => CommonModelClass::Image,
|
||||
Self::Seedream4p5 => CommonModelClass::Image,
|
||||
Self::Seedream5Lite => CommonModelClass::Image,
|
||||
Self::Seedream5p0Pro => CommonModelClass::Image,
|
||||
Self::Seedream5p0ProUltra => CommonModelClass::Image,
|
||||
Self::Midjourney => CommonModelClass::Image,
|
||||
Self::MidjourneyV6 => CommonModelClass::Image,
|
||||
Self::MidjourneyV6p1 => CommonModelClass::Image,
|
||||
@@ -747,6 +761,8 @@ mod tests {
|
||||
assert_serialization(CommonModelType::Seedream4, "seedream_4");
|
||||
assert_serialization(CommonModelType::Seedream4p5, "seedream_4p5");
|
||||
assert_serialization(CommonModelType::Seedream5Lite, "seedream_5_lite");
|
||||
assert_serialization(CommonModelType::Seedream5p0Pro, "seedream_5p0_pro");
|
||||
assert_serialization(CommonModelType::Seedream5p0ProUltra, "seedream_5p0_pro_u");
|
||||
assert_serialization(CommonModelType::Midjourney, "midjourney");
|
||||
assert_serialization(CommonModelType::MidjourneyV6, "midjourney_v6");
|
||||
assert_serialization(CommonModelType::MidjourneyV6p1, "midjourney_v6p1");
|
||||
@@ -852,6 +868,8 @@ mod tests {
|
||||
assert_eq!(CommonModelType::Seedream4.to_str(), "seedream_4");
|
||||
assert_eq!(CommonModelType::Seedream4p5.to_str(), "seedream_4p5");
|
||||
assert_eq!(CommonModelType::Seedream5Lite.to_str(), "seedream_5_lite");
|
||||
assert_eq!(CommonModelType::Seedream5p0Pro.to_str(), "seedream_5p0_pro");
|
||||
assert_eq!(CommonModelType::Seedream5p0ProUltra.to_str(), "seedream_5p0_pro_u");
|
||||
assert_eq!(CommonModelType::Midjourney.to_str(), "midjourney");
|
||||
assert_eq!(CommonModelType::MidjourneyV6.to_str(), "midjourney_v6");
|
||||
assert_eq!(CommonModelType::MidjourneyV6p1.to_str(), "midjourney_v6p1");
|
||||
@@ -961,6 +979,8 @@ mod tests {
|
||||
assert_eq!(CommonModelType::from_str("seedream_4").unwrap(), CommonModelType::Seedream4);
|
||||
assert_eq!(CommonModelType::from_str("seedream_4p5").unwrap(), CommonModelType::Seedream4p5);
|
||||
assert_eq!(CommonModelType::from_str("seedream_5_lite").unwrap(), CommonModelType::Seedream5Lite);
|
||||
assert_eq!(CommonModelType::from_str("seedream_5p0_pro").unwrap(), CommonModelType::Seedream5p0Pro);
|
||||
assert_eq!(CommonModelType::from_str("seedream_5p0_pro_u").unwrap(), CommonModelType::Seedream5p0ProUltra);
|
||||
assert_eq!(CommonModelType::from_str("midjourney").unwrap(), CommonModelType::Midjourney);
|
||||
assert_eq!(CommonModelType::from_str("midjourney_v6").unwrap(), CommonModelType::MidjourneyV6);
|
||||
assert_eq!(CommonModelType::from_str("midjourney_v6p1").unwrap(), CommonModelType::MidjourneyV6p1);
|
||||
@@ -1042,7 +1062,7 @@ mod tests {
|
||||
#[test]
|
||||
fn all_variants() {
|
||||
let mut variants = CommonModelType::all_variants();
|
||||
assert_eq!(variants.len(), 96);
|
||||
assert_eq!(variants.len(), 98);
|
||||
// Image models
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Dev));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Schnell));
|
||||
@@ -1069,6 +1089,8 @@ mod tests {
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedream4));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedream4p5));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedream5Lite));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedream5p0Pro));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedream5p0ProUltra));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Midjourney));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::MidjourneyV6));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::MidjourneyV6p1));
|
||||
|
||||
@@ -439,6 +439,68 @@ fn build_omni_gen_image_models() -> Vec<OmniGenImageModelDetails> {
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
models.push(OmniGenImageModelDetails {
|
||||
model: CommonImageModel::Seedream5p0Pro,
|
||||
model_creator: Some(ModelCreator::Bytedance),
|
||||
full_name: Some("Seedream 5.0 Pro".to_string()),
|
||||
text_prompt_supported: Some(true),
|
||||
image_refs_supported: Some(true),
|
||||
image_refs_max: Some(14),
|
||||
aspect_ratio_options: Some(vec![
|
||||
CommonAspectRatio::Auto,
|
||||
CommonAspectRatio::WideTwentyOneByNine,
|
||||
CommonAspectRatio::WideSixteenByNine,
|
||||
CommonAspectRatio::WideThreeByTwo,
|
||||
CommonAspectRatio::WideFourByThree,
|
||||
CommonAspectRatio::Square,
|
||||
CommonAspectRatio::TallThreeByFour,
|
||||
CommonAspectRatio::TallTwoByThree,
|
||||
CommonAspectRatio::TallNineBySixteen,
|
||||
]),
|
||||
aspect_ratio_default: Some(CommonAspectRatio::Square),
|
||||
aspect_ratio_default_when_editing: Some(CommonAspectRatio::Auto),
|
||||
resolution_options: Some(vec![
|
||||
CommonResolution::OneK,
|
||||
CommonResolution::TwoK,
|
||||
]),
|
||||
resolution_default: Some(CommonResolution::TwoK),
|
||||
batch_size_min: Some(1),
|
||||
batch_size_max: Some(8),
|
||||
batch_size_default: Some(1),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
models.push(OmniGenImageModelDetails {
|
||||
model: CommonImageModel::Seedream5p0ProUltra,
|
||||
model_creator: Some(ModelCreator::Bytedance),
|
||||
full_name: Some("Seedream 5.0 Pro Ultra".to_string()),
|
||||
text_prompt_supported: Some(true),
|
||||
image_refs_supported: Some(true),
|
||||
image_refs_max: Some(14),
|
||||
aspect_ratio_options: Some(vec![
|
||||
CommonAspectRatio::Auto,
|
||||
CommonAspectRatio::WideTwentyOneByNine,
|
||||
CommonAspectRatio::WideSixteenByNine,
|
||||
CommonAspectRatio::WideThreeByTwo,
|
||||
CommonAspectRatio::WideFourByThree,
|
||||
CommonAspectRatio::Square,
|
||||
CommonAspectRatio::TallThreeByFour,
|
||||
CommonAspectRatio::TallTwoByThree,
|
||||
CommonAspectRatio::TallNineBySixteen,
|
||||
]),
|
||||
aspect_ratio_default: Some(CommonAspectRatio::Square),
|
||||
aspect_ratio_default_when_editing: Some(CommonAspectRatio::Auto),
|
||||
resolution_options: Some(vec![
|
||||
CommonResolution::OneK,
|
||||
CommonResolution::TwoK,
|
||||
]),
|
||||
resolution_default: Some(CommonResolution::TwoK),
|
||||
batch_size_min: Some(1),
|
||||
batch_size_max: Some(8),
|
||||
batch_size_default: Some(1),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
models
|
||||
}
|
||||
|
||||
@@ -484,6 +546,14 @@ fn build_omni_gen_image_model_providers() -> Vec<OmniGenImageModelProviderDetail
|
||||
model: CommonImageModel::Seedream5Lite,
|
||||
overrides: None,
|
||||
},
|
||||
OmniGenImageProviderModelDetails {
|
||||
model: CommonImageModel::Seedream5p0Pro,
|
||||
overrides: None,
|
||||
},
|
||||
OmniGenImageProviderModelDetails {
|
||||
model: CommonImageModel::Seedream5p0ProUltra,
|
||||
overrides: None,
|
||||
},
|
||||
OmniGenImageProviderModelDetails {
|
||||
model: CommonImageModel::NanoBanana,
|
||||
overrides: None,
|
||||
|
||||
@@ -95,6 +95,7 @@ use artcraft_api_defs::moderation::debug_logs::debug_log_entry::*;
|
||||
use artcraft_api_defs::moderation::debug_logs::moderation_list_all_debug_logs::*;
|
||||
use artcraft_api_defs::moderation::debug_logs::moderation_list_debug_logs_for_token::*;
|
||||
use artcraft_api_defs::moderation::debug_logs::moderation_list_debug_logs_for_user::*;
|
||||
use crate::http_server::endpoints::moderation::dashboards::moderator_list_databox_dashboards_handler::*;
|
||||
use crate::http_server::endpoints::moderation::jobs::moderation_get_job_by_token_handler::*;
|
||||
use crate::http_server::endpoints::moderation::user_feature_flags::moderator_edit_user_feature_flags_handler::*;
|
||||
use crate::http_server::endpoints::moderation::user_feature_flags::moderator_list_all_available_user_feature_flags_handler::*;
|
||||
@@ -130,6 +131,7 @@ use crate::http_server::endpoints::folders::media_files::bulk_add_folder_media_f
|
||||
use crate::http_server::endpoints::folders::media_files::bulk_move_folder_media_files_handler::*;
|
||||
use crate::http_server::endpoints::folders::media_files::bulk_remove_folder_media_files_handler::*;
|
||||
use crate::http_server::endpoints::folders::media_files::list_folder_media_files_handler::*;
|
||||
use crate::http_server::endpoints::folders::media_files::list_media_files_without_folder_handler::*;
|
||||
use crate::http_server::endpoints::folders::subfolder::bulk_add_subfolders_handler::*;
|
||||
use crate::http_server::endpoints::folders::subfolder::bulk_remove_subfolders_handler::*;
|
||||
use crate::http_server::endpoints::folders::subfolder::list_subfolders_handler::*;
|
||||
@@ -353,6 +355,9 @@ use artcraft_api_defs::analytics::log_active_user::*;
|
||||
use artcraft_api_defs::credits::get_session_credits::*;
|
||||
use artcraft_api_defs::subscriptions::get_session_subscription::*;
|
||||
use artcraft_api_defs::media_file::list_batch_generated_media_files::*;
|
||||
use artcraft_api_defs::media_file::list::by_type::list_session_common::*;
|
||||
use artcraft_api_defs::media_file::list::by_type::list_session_mesh_media_files::*;
|
||||
use artcraft_api_defs::media_file::list::by_type::list_session_splat_media_files::*;
|
||||
use artcraft_api_defs::media_file::list_session_project_media_files::*;
|
||||
use artcraft_api_defs::media_file::project::upload_updated_mood_board_project::*;
|
||||
use artcraft_api_defs::media_file::project::upload_new_mood_board_project::*;
|
||||
@@ -495,6 +500,8 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
crate::http_server::endpoints::media_files::list::list_media_files_for_user_handler::list_media_files_for_user_handler,
|
||||
crate::http_server::endpoints::media_files::list::list_media_files_handler::list_media_files_handler,
|
||||
crate::http_server::endpoints::media_files::list::list_pinned_media_files_handler::list_pinned_media_files_handler,
|
||||
crate::http_server::endpoints::media_files::list::by_type::list_session_mesh_media_files_handler::list_session_mesh_media_files_handler,
|
||||
crate::http_server::endpoints::media_files::list::by_type::list_session_splat_media_files_handler::list_session_splat_media_files_handler,
|
||||
crate::http_server::endpoints::media_files::list::list_session_project_media_files_handler::list_session_project_media_files_handler,
|
||||
crate::http_server::endpoints::media_files::search::search_featured_media_files_handler::search_featured_media_files_handler,
|
||||
crate::http_server::endpoints::media_files::search::search_session_media_files_handler::search_session_media_files_handler,
|
||||
@@ -634,6 +641,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
crate::http_server::endpoints::omni_api::job_status::batch_get_job_status_handler::omni_api_batch_get_job_status_handler,
|
||||
// Moderation
|
||||
crate::http_server::endpoints::moderation::alerts::moderation_send_alert_handler::moderation_send_alert_handler,
|
||||
crate::http_server::endpoints::moderation::dashboards::moderator_list_databox_dashboards_handler::moderator_list_databox_dashboards_handler,
|
||||
crate::http_server::endpoints::moderation::info::moderator_token_info_handler::moderator_get_token_info_handler,
|
||||
crate::http_server::endpoints::moderation::user::moderator_list_subscribing_users_by_signup_date::moderator_list_subscribing_users_by_signup_date_handler,
|
||||
crate::http_server::endpoints::moderation::user::moderator_list_users_by_signup_date::moderator_list_users_by_signup_date_handler,
|
||||
@@ -678,6 +686,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
crate::http_server::endpoints::folders::subfolder::bulk_add_subfolders_handler::bulk_add_subfolders_handler,
|
||||
crate::http_server::endpoints::folders::subfolder::bulk_remove_subfolders_handler::bulk_remove_subfolders_handler,
|
||||
crate::http_server::endpoints::folders::media_files::list_folder_media_files_handler::list_folder_media_files_handler,
|
||||
crate::http_server::endpoints::folders::media_files::list_media_files_without_folder_handler::list_media_files_without_folder_handler,
|
||||
crate::http_server::endpoints::folders::media_files::bulk_add_folder_media_files_handler::bulk_add_folder_media_files_handler,
|
||||
crate::http_server::endpoints::folders::media_files::bulk_move_folder_media_files_handler::bulk_move_folder_media_files_handler,
|
||||
crate::http_server::endpoints::folders::media_files::bulk_remove_folder_media_files_handler::bulk_remove_folder_media_files_handler,
|
||||
@@ -717,6 +726,8 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
crate::http_server::endpoints::image_studio::update_gpt_image_job_status_handler::update_gpt_image_job_status_handler,
|
||||
),
|
||||
components(schemas(
|
||||
DataboxDashboardEntry,
|
||||
ListDataboxDashboardsResponse,
|
||||
ModeratorGetUserSpendSummaryResponse,
|
||||
UserSpendSummaryView,
|
||||
ModeratorReengagementListResponse,
|
||||
@@ -1124,7 +1135,9 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
ListPinnedWeightsSuccessResponse,
|
||||
ListSessionJobsItem,
|
||||
ListSessionJobsQueryParams,
|
||||
ListSessionMeshMediaFilesSuccessResponse,
|
||||
ListSessionProjectMediaFilesSuccessResponse,
|
||||
ListSessionSplatMediaFilesSuccessResponse,
|
||||
ListSessionJobsSuccessResponse,
|
||||
ListSessionRequestDetailsResponse,
|
||||
ListSessionResultDetailsResponse,
|
||||
@@ -1168,6 +1181,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
PinnedMediaFile,
|
||||
PinnedModelWeightForList,
|
||||
ProjectMediaFileInfo,
|
||||
SessionMediaFileInfo,
|
||||
PromptInfo,
|
||||
RatingRow,
|
||||
RedeemBetaKeyRequest,
|
||||
@@ -1476,6 +1490,8 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
BulkRemoveSubfoldersSuccessResponse,
|
||||
ListFolderMediaFilesQueryParams,
|
||||
ListFolderMediaFilesSuccessResponse,
|
||||
ListMediaFilesWithoutFolderQueryParams,
|
||||
ListMediaFilesWithoutFolderSuccessResponse,
|
||||
BulkAddFolderMediaFilesRequest,
|
||||
BulkAddFolderMediaFilesSuccessResponse,
|
||||
BulkRemoveFolderMediaFilesRequest,
|
||||
@@ -1517,6 +1533,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
SetMediaFileTagsSuccessResponse,
|
||||
TagDetails,
|
||||
TagMediaFileListItem,
|
||||
UnfolderedMediaFileListItem,
|
||||
))
|
||||
)]
|
||||
pub struct ApiDoc;
|
||||
|
||||
-10
@@ -10,7 +10,6 @@ use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use mysql_queries::queries::api_keys::insert_api_key::{insert_api_key, InsertApiKeyArgs};
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_feature_flags::UserSessionFeatureFlags;
|
||||
use crate::http_server::user_lookup::user_session::require_user_session::require_user_session;
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::util::generate_api_key::generate_api_key;
|
||||
@@ -30,7 +29,6 @@ const MAX_DESCRIPTION_LEN: usize = 512;
|
||||
(status = 200, body = CreateApiKeySuccessResponse),
|
||||
(status = 400, body = CommonWebError),
|
||||
(status = 401, body = CommonWebError),
|
||||
(status = 403, body = CommonWebError),
|
||||
(status = 500, body = CommonWebError),
|
||||
),
|
||||
)]
|
||||
@@ -46,14 +44,6 @@ pub async fn create_api_key_handler(
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
|
||||
// API key creation is gated behind the `api_key` feature flag.
|
||||
let feature_flags = UserSessionFeatureFlags::new(user_session.maybe_feature_flags.as_deref());
|
||||
|
||||
if !feature_flags.can_create_api_key() {
|
||||
warn!("User {:?} attempted to create an API key without the api_key feature flag", user_session.user_token.as_str());
|
||||
return Err(CommonWebError::Forbidden);
|
||||
}
|
||||
|
||||
let name = request.name.trim().to_string();
|
||||
|
||||
let maybe_description = request.maybe_description.as_ref().map(|s| s.trim().to_string());
|
||||
|
||||
+11
-13
@@ -55,13 +55,7 @@ pub async fn list_folders_handler(
|
||||
let maybe_cursor_id = match &query.cursor {
|
||||
None => None,
|
||||
Some(cursor_str) => {
|
||||
let decoded = server_state.opaque_cursors
|
||||
.decode_cursor_expecting_name(CURSOR_NAME, cursor_str)
|
||||
.map_err(|err| {
|
||||
warn!("Failed to decode cursor: {:?}", err);
|
||||
CommonWebError::BadInputWithSimpleMessage("Invalid cursor".to_string())
|
||||
})?;
|
||||
decoded.last_id
|
||||
Some(server_state.opaque_cursors.decode_last_id_cursor(CURSOR_NAME, cursor_str)?)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -76,12 +70,16 @@ pub async fn list_folders_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let maybe_cursor = rows.last().map(|last| {
|
||||
server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.id)
|
||||
}).transpose().map_err(|err| {
|
||||
warn!("Failed to encode cursor: {:?}", err);
|
||||
CommonWebError::server_error_with_message("Failed to encode cursor")
|
||||
})?;
|
||||
// Only hand out a next-page cursor when this page was full. A short page
|
||||
// means the list is exhausted, and emitting a cursor anyway would make
|
||||
// clients fetch one guaranteed-empty trailing page.
|
||||
let maybe_cursor = if rows.len() == limit as usize {
|
||||
rows.last()
|
||||
.map(|last| server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.id))
|
||||
.transpose()?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let media_domain = get_media_domain(&http_request);
|
||||
let server_environment = server_state.server_environment;
|
||||
|
||||
+11
-13
@@ -148,13 +148,7 @@ pub async fn list_folder_media_files_handler(
|
||||
let maybe_cursor_id = match &query.cursor {
|
||||
None => None,
|
||||
Some(cursor_str) => {
|
||||
let decoded = server_state.opaque_cursors
|
||||
.decode_cursor_expecting_name(CURSOR_NAME, cursor_str)
|
||||
.map_err(|err| {
|
||||
warn!("Failed to decode cursor: {:?}", err);
|
||||
CommonWebError::BadInputWithSimpleMessage("Invalid cursor".to_string())
|
||||
})?;
|
||||
decoded.last_id
|
||||
Some(server_state.opaque_cursors.decode_last_id_cursor(CURSOR_NAME, cursor_str)?)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -169,12 +163,16 @@ pub async fn list_folder_media_files_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let maybe_cursor = rows.last().map(|last| {
|
||||
server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.media_file_id)
|
||||
}).transpose().map_err(|err| {
|
||||
warn!("Failed to encode cursor: {:?}", err);
|
||||
CommonWebError::server_error_with_message("Failed to encode cursor")
|
||||
})?;
|
||||
// Only hand out a next-page cursor when this page was full. A short page
|
||||
// means the list is exhausted, and emitting a cursor anyway would make
|
||||
// clients fetch one guaranteed-empty trailing page.
|
||||
let maybe_cursor = if rows.len() == limit as usize {
|
||||
rows.last()
|
||||
.map(|last| server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.media_file_id))
|
||||
.transpose()?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let media_domain = get_media_domain(&http_request);
|
||||
let server_environment = server_state.server_environment;
|
||||
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::{Json, Query};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use chrono::{DateTime, Utc};
|
||||
use log::warn;
|
||||
use serde_derive::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use artcraft_api_defs::common::responses::media_links::MediaLinks;
|
||||
use artcraft_api_defs::folders::media_files::ListMediaFilesWithoutFolderQueryParams;
|
||||
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 mysql_queries::queries::folders::media_files::list_media_files_without_folder::{
|
||||
list_media_files_without_folder, ListMediaFilesWithoutFolderArgs,
|
||||
};
|
||||
use mysql_queries::queries::media_files::list::media_file_list_row::MediaFileListRow;
|
||||
use tokens::tokens::batch_generations::BatchGenerationToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::common_responses::media::media_domain::MediaDomain;
|
||||
use crate::http_server::common_responses::media::media_file_cover_image_details::MediaFileCoverImageDetails;
|
||||
use crate::http_server::common_responses::media::media_file_list_conversion::build_media_links_and_cover;
|
||||
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
|
||||
use crate::http_server::user_lookup::user_session::require_user_session::require_user_session;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
const CURSOR_NAME: &str = "folders_unfoldered_mf";
|
||||
const DEFAULT_LIMIT: u32 = 100;
|
||||
const MAX_LIMIT: u32 = 1000;
|
||||
|
||||
// ── Response shape ──
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ListMediaFilesWithoutFolderSuccessResponse {
|
||||
pub success: bool,
|
||||
pub media_files: Vec<UnfolderedMediaFileListItem>,
|
||||
|
||||
/// Cursor for the next page. Only present when this page was full —
|
||||
/// a short page means the list is exhausted.
|
||||
pub maybe_cursor: Option<String>,
|
||||
}
|
||||
|
||||
/// One media file as it appears in the unfoldered list. Same lean shape as
|
||||
/// the folder / tag list items. Intermediate system files are filtered out
|
||||
/// by the query, so the flag isn't part of the wire shape.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct UnfolderedMediaFileListItem {
|
||||
pub token: MediaFileToken,
|
||||
|
||||
/// Coarse-grained class (image / video / audio / mesh / splat / ...).
|
||||
pub media_class: MediaFileClass,
|
||||
|
||||
/// Specific format (jpg, png, mp4, glb, etc.) — closer to a MIME type.
|
||||
pub media_type: MediaFileType,
|
||||
|
||||
/// Link to the prompt
|
||||
pub maybe_prompt_token: Option<PromptToken>,
|
||||
|
||||
/// If this file was generated as part of a batch, the batch token —
|
||||
/// useful for showing "siblings" in the UI.
|
||||
pub maybe_batch_token: Option<BatchGenerationToken>,
|
||||
|
||||
/// Rich CDN links to the media itself (full URL, thumbnail template,
|
||||
/// video previews when applicable).
|
||||
pub media_links: MediaLinks,
|
||||
|
||||
/// Cover image details. For files that don't carry their own cover
|
||||
/// image, this still gives the deterministic default-cover spec
|
||||
/// (image_index + color_index) the frontend uses for placeholders.
|
||||
pub cover_image: MediaFileCoverImageDetails,
|
||||
|
||||
pub maybe_title: Option<String>,
|
||||
pub maybe_original_filename: Option<String>,
|
||||
|
||||
/// Original pixel width / height for image and video files when known.
|
||||
pub maybe_frame_width: Option<i32>,
|
||||
pub maybe_frame_height: Option<i32>,
|
||||
|
||||
/// Duration for audio and video files, if available. Milliseconds.
|
||||
pub maybe_duration_millis: Option<u64>,
|
||||
|
||||
pub creator_set_visibility: Visibility,
|
||||
pub is_user_upload: bool,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
// ── Handler ──
|
||||
|
||||
/// Paginated list of the logged-in user's media files that sit in no folder
|
||||
/// at all (memberships pointing at soft-deleted folders count as
|
||||
/// unfoldered). Newest first.
|
||||
///
|
||||
/// Use `filter_media_class` to scope the results to a single media class.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
tag = "Folders (Media File Management)",
|
||||
path = "/v1/folders/media_files_without_folder",
|
||||
params(ListMediaFilesWithoutFolderQueryParams),
|
||||
responses(
|
||||
(status = 200, body = ListMediaFilesWithoutFolderSuccessResponse),
|
||||
(status = 400, body = CommonWebError),
|
||||
(status = 401, body = CommonWebError),
|
||||
(status = 500, body = CommonWebError),
|
||||
),
|
||||
)]
|
||||
pub async fn list_media_files_without_folder_handler(
|
||||
http_request: HttpRequest,
|
||||
query: Query<ListMediaFilesWithoutFolderQueryParams>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ListMediaFilesWithoutFolderSuccessResponse>, CommonWebError> {
|
||||
let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| {
|
||||
warn!("MySQL pool error: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
|
||||
let limit = query.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
|
||||
|
||||
let maybe_cursor_id = match &query.cursor {
|
||||
None => None,
|
||||
Some(cursor_str) => {
|
||||
Some(server_state.opaque_cursors.decode_last_id_cursor(CURSOR_NAME, cursor_str)?)
|
||||
}
|
||||
};
|
||||
|
||||
let rows = list_media_files_without_folder(ListMediaFilesWithoutFolderArgs {
|
||||
owner_user_token: &user_session.user_token,
|
||||
maybe_filter_media_class: query.filter_media_class,
|
||||
maybe_cursor_id,
|
||||
limit,
|
||||
mysql_executor: &mut *conn,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("list_media_files_without_folder failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
// Only hand out a next-page cursor when this page was full. A short page
|
||||
// means the list is exhausted, and emitting a cursor anyway would make
|
||||
// clients fetch one guaranteed-empty trailing page.
|
||||
let maybe_cursor = if rows.len() == limit as usize {
|
||||
rows.last()
|
||||
.map(|last| server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.media_file_id))
|
||||
.transpose()?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let media_domain = get_media_domain(&http_request);
|
||||
let server_environment = server_state.server_environment;
|
||||
|
||||
let media_files = rows.into_iter()
|
||||
.map(|row| unfoldered_media_file_row_to_list_item(row, media_domain, server_environment))
|
||||
.collect();
|
||||
|
||||
Ok(Json(ListMediaFilesWithoutFolderSuccessResponse {
|
||||
success: true,
|
||||
media_files,
|
||||
maybe_cursor,
|
||||
}))
|
||||
}
|
||||
|
||||
fn unfoldered_media_file_row_to_list_item(
|
||||
row: MediaFileListRow,
|
||||
media_domain: MediaDomain,
|
||||
server_environment: server_environment::ServerEnvironment,
|
||||
) -> UnfolderedMediaFileListItem {
|
||||
let (media_links, cover_image) =
|
||||
build_media_links_and_cover(&row, media_domain, server_environment);
|
||||
|
||||
UnfolderedMediaFileListItem {
|
||||
token: row.media_file_token,
|
||||
media_class: row.media_class,
|
||||
media_type: row.media_type,
|
||||
maybe_batch_token: row.maybe_batch_token,
|
||||
media_links,
|
||||
cover_image,
|
||||
creator_set_visibility: row.creator_set_visibility,
|
||||
is_user_upload: row.is_user_upload,
|
||||
maybe_title: row.maybe_title,
|
||||
maybe_prompt_token: row.maybe_prompt_token,
|
||||
maybe_original_filename: row.maybe_origin_filename,
|
||||
// Schema stores `INT(10)`; widen to u64 for the wire shape.
|
||||
maybe_duration_millis: row.maybe_duration_millis.map(|n| n as u64),
|
||||
maybe_frame_width: row.maybe_frame_width,
|
||||
maybe_frame_height: row.maybe_frame_height,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
}
|
||||
}
|
||||
+1
@@ -2,3 +2,4 @@ pub mod bulk_add_folder_media_files_handler;
|
||||
pub mod bulk_move_folder_media_files_handler;
|
||||
pub mod bulk_remove_folder_media_files_handler;
|
||||
pub mod list_folder_media_files_handler;
|
||||
pub mod list_media_files_without_folder_handler;
|
||||
|
||||
+11
-13
@@ -77,13 +77,7 @@ pub async fn list_subfolders_handler(
|
||||
let maybe_cursor_id = match &query.cursor {
|
||||
None => None,
|
||||
Some(cursor_str) => {
|
||||
let decoded = server_state.opaque_cursors
|
||||
.decode_cursor_expecting_name(CURSOR_NAME, cursor_str)
|
||||
.map_err(|err| {
|
||||
warn!("Failed to decode cursor: {:?}", err);
|
||||
CommonWebError::BadInputWithSimpleMessage("Invalid cursor".to_string())
|
||||
})?;
|
||||
decoded.last_id
|
||||
Some(server_state.opaque_cursors.decode_last_id_cursor(CURSOR_NAME, cursor_str)?)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -99,12 +93,16 @@ pub async fn list_subfolders_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let maybe_cursor = rows.last().map(|last| {
|
||||
server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.id)
|
||||
}).transpose().map_err(|err| {
|
||||
warn!("Failed to encode cursor: {:?}", err);
|
||||
CommonWebError::server_error_with_message("Failed to encode cursor")
|
||||
})?;
|
||||
// Only hand out a next-page cursor when this page was full. A short page
|
||||
// means the list is exhausted, and emitting a cursor anyway would make
|
||||
// clients fetch one guaranteed-empty trailing page.
|
||||
let maybe_cursor = if rows.len() == limit as usize {
|
||||
rows.last()
|
||||
.map(|last| server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.id))
|
||||
.transpose()?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let media_domain = get_media_domain(&http_request);
|
||||
let server_environment = server_state.server_environment;
|
||||
|
||||
+2
@@ -141,6 +141,8 @@ fn map_image_model(model: CommonImageModel) -> Result<RouterImageModel, HandlerE
|
||||
CommonImageModel::Seedream4 => RouterImageModel::Seedream4,
|
||||
CommonImageModel::Seedream4p5 => RouterImageModel::Seedream4p5,
|
||||
CommonImageModel::Seedream5Lite => RouterImageModel::Seedream5Lite,
|
||||
CommonImageModel::Seedream5p0Pro => RouterImageModel::Seedream5p0Pro,
|
||||
CommonImageModel::Seedream5p0ProUltra => RouterImageModel::Seedream5p0ProUltra,
|
||||
CommonImageModel::GrokImagineImage => RouterImageModel::GrokImagineImage,
|
||||
CommonImageModel::GrokImagineImageQuality => RouterImageModel::GrokImagineImageQuality,
|
||||
CommonImageModel::QwenEdit2511Angles => RouterImageModel::QwenEdit2511Angles,
|
||||
|
||||
+3
-3
@@ -174,9 +174,9 @@ fn db_record_to_response_payload(
|
||||
|
||||
let progress_percentage = estimate_job_progress(&record, maybe_polymorphic_args.as_ref());
|
||||
|
||||
/// If the job is currently running, this is how long it has been running in seconds.
|
||||
/// This is heuristic estimate since we don't record the precise start time across runs.
|
||||
/// If the job's updated_at timestamp is updated elsewhere, then this is not accurate at all.
|
||||
// If the job is currently running, this is how long it has been running in seconds.
|
||||
// This is heuristic estimate since we don't record the precise start time across runs.
|
||||
// If the job's updated_at timestamp is updated elsewhere, then this is not accurate at all.
|
||||
let maybe_current_execution_duration_seconds = match record.status {
|
||||
JobStatusPlus::Started => {
|
||||
let now = Utc::now();
|
||||
|
||||
-4
@@ -215,15 +215,11 @@ pub async fn batch_get_media_files_handler(
|
||||
.await?;
|
||||
|
||||
let mut show_deleted_results = false;
|
||||
let mut is_moderator = false;
|
||||
|
||||
if let Some(user_session) = maybe_user_session {
|
||||
// NB: Moderators can see deleted results.
|
||||
// Original creators cannot see them (unless they're moderators!)
|
||||
show_deleted_results = user_session.can_delete_other_users_tts_results;
|
||||
// Moderators get to see all the fields.
|
||||
is_moderator = user_session.can_delete_other_users_tts_results
|
||||
|| user_session.can_edit_other_users_tts_models;
|
||||
}
|
||||
|
||||
let tokens = query.tokens.iter()
|
||||
|
||||
-4
@@ -220,15 +220,11 @@ pub async fn get_media_file_handler(
|
||||
.await?;
|
||||
|
||||
let mut show_deleted_results = false;
|
||||
let mut is_moderator = false;
|
||||
|
||||
if let Some(user_session) = maybe_user_session {
|
||||
// NB: Moderators can see deleted results.
|
||||
// Original creators cannot see them (unless they're moderators!)
|
||||
show_deleted_results = user_session.can_delete_other_users_tts_results;
|
||||
// Moderators get to see all the fields.
|
||||
is_moderator = user_session.can_delete_other_users_tts_results
|
||||
|| user_session.can_edit_other_users_tts_models;
|
||||
}
|
||||
|
||||
let media_domain = get_media_domain(&http_request);
|
||||
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
//! Shared logic for the by-class session media file list endpoints
|
||||
//! (`/v1/media_files/mesh/list`, `/v1/media_files/splat/list`).
|
||||
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::HttpRequest;
|
||||
use log::{error, warn};
|
||||
|
||||
use artcraft_api_defs::common::responses::pagination_cursors::PaginationCursors;
|
||||
use artcraft_api_defs::media_file::list::by_type::list_session_common::{ListSessionMediaFilesByTypeQueryParams, SessionMediaFileInfo};
|
||||
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
|
||||
use enums::by_table::media_files::media_file_class::MediaFileClass;
|
||||
use mysql_queries::queries::media_files::list::list_session_media_files_by_class::{list_session_media_files_by_class, ListSessionMediaFilesByClassArgs};
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::common_responses::media::media_file_cover_image_details_builder::MediaFileCoverImageDetailsBuilder;
|
||||
use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder;
|
||||
use crate::http_server::common_responses::user_details_lite_builder::UserDetailsLightBuilder;
|
||||
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
|
||||
use crate::http_server::user_lookup::user_session::require_user_session::require_user_session;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
const DEFAULT_PAGE_SIZE: usize = 100;
|
||||
const MAX_PAGE_SIZE: usize = 1000;
|
||||
|
||||
/// List the session user's media files of a single class (paginated).
|
||||
/// The shared core of the mesh and splat list handlers.
|
||||
pub async fn list_session_media_files_of_class(
|
||||
http_request: &HttpRequest,
|
||||
query: &ListSessionMediaFilesByTypeQueryParams,
|
||||
server_state: &Arc<ServerState>,
|
||||
media_class: MediaFileClass,
|
||||
) -> Result<(Vec<SessionMediaFileInfo>, PaginationCursors), CommonWebError> {
|
||||
|
||||
let mut mysql_connection = server_state.mysql_pool
|
||||
.acquire()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("MySql pool error: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(
|
||||
http_request,
|
||||
&server_state.session_checker,
|
||||
&mut *mysql_connection,
|
||||
).await?;
|
||||
|
||||
let limit = query.page_size
|
||||
.unwrap_or(DEFAULT_PAGE_SIZE)
|
||||
.min(MAX_PAGE_SIZE);
|
||||
|
||||
let sort_ascending = query.sort_ascending.unwrap_or(false);
|
||||
let cursor_is_reversed = query.cursor_is_reversed.unwrap_or(false);
|
||||
|
||||
let maybe_cursor_id = if let Some(cursor) = query.cursor.as_deref() {
|
||||
Some(server_state.sort_key_crypto.decrypt_id(cursor)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let results_page = list_session_media_files_by_class(ListSessionMediaFilesByClassArgs {
|
||||
user_token: user_session.get_user_token(),
|
||||
media_class,
|
||||
limit,
|
||||
maybe_cursor_id,
|
||||
cursor_is_reversed,
|
||||
sort_ascending,
|
||||
mysql_executor: &mut *mysql_connection,
|
||||
phantom: PhantomData,
|
||||
})
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!("List session {:?} media files query error: {:?}", media_class, err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let maybe_next = if let Some(id) = results_page.last_id {
|
||||
Some(server_state.sort_key_crypto.encrypt_id(id)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let maybe_previous = if let Some(id) = results_page.first_id {
|
||||
Some(server_state.sort_key_crypto.encrypt_id(id)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let media_domain = get_media_domain(http_request);
|
||||
|
||||
let results = results_page.records.into_iter()
|
||||
.map(|record| {
|
||||
let public_bucket_path = MediaFileBucketPath::from_object_hash(
|
||||
&record.public_bucket_directory_hash,
|
||||
record.maybe_public_bucket_prefix.as_deref(),
|
||||
record.maybe_public_bucket_extension.as_deref(),
|
||||
);
|
||||
SessionMediaFileInfo {
|
||||
media_links: MediaLinksBuilder::from_media_path_and_env(
|
||||
media_domain,
|
||||
server_state.server_environment,
|
||||
&public_bucket_path,
|
||||
),
|
||||
cover_image: MediaFileCoverImageDetailsBuilder::from_optional_db_fields(
|
||||
&record.token,
|
||||
media_domain,
|
||||
server_state.server_environment,
|
||||
record.maybe_file_cover_image_public_bucket_hash.as_deref(),
|
||||
record.maybe_file_cover_image_public_bucket_prefix.as_deref(),
|
||||
record.maybe_file_cover_image_public_bucket_extension.as_deref(),
|
||||
),
|
||||
maybe_creator_user: UserDetailsLightBuilder::from_optional_db_fields_owned(
|
||||
record.maybe_creator_user_token,
|
||||
record.maybe_creator_username,
|
||||
record.maybe_creator_display_name,
|
||||
record.maybe_creator_gravatar_hash,
|
||||
),
|
||||
token: record.token,
|
||||
media_class: record.media_class,
|
||||
media_type: record.media_type,
|
||||
creator_set_visibility: record.creator_set_visibility,
|
||||
maybe_prompt_token: record.maybe_prompt_token,
|
||||
maybe_title: record.maybe_title,
|
||||
created_at: record.created_at,
|
||||
updated_at: record.updated_at,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let pagination = PaginationCursors {
|
||||
maybe_next,
|
||||
maybe_previous,
|
||||
cursor_is_reversed,
|
||||
};
|
||||
|
||||
Ok((results, pagination))
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
pub mod list_session_common;
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::{Json, Query};
|
||||
use actix_web::{web, HttpRequest};
|
||||
|
||||
use artcraft_api_defs::media_file::list::by_type::list_session_common::ListSessionMediaFilesByTypeQueryParams;
|
||||
use artcraft_api_defs::media_file::list::by_type::list_session_mesh_media_files::ListSessionMeshMediaFilesSuccessResponse;
|
||||
use enums::by_table::media_files::media_file_class::MediaFileClass;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::media_files::list::by_type::common::list_session_common::list_session_media_files_of_class;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// List the session user's mesh files (`media_class = 'mesh'`), paginated.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
tag = "Media Files",
|
||||
path = "/v1/media_files/mesh/list",
|
||||
params(ListSessionMediaFilesByTypeQueryParams),
|
||||
responses(
|
||||
(status = 200, description = "List the session user's mesh files", body = ListSessionMeshMediaFilesSuccessResponse),
|
||||
(status = 401, description = "Not authorized"),
|
||||
(status = 500, description = "Server error"),
|
||||
),
|
||||
)]
|
||||
pub async fn list_session_mesh_media_files_handler(
|
||||
http_request: HttpRequest,
|
||||
query: Query<ListSessionMediaFilesByTypeQueryParams>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ListSessionMeshMediaFilesSuccessResponse>, CommonWebError> {
|
||||
|
||||
let (results, pagination) = list_session_media_files_of_class(
|
||||
&http_request,
|
||||
&query,
|
||||
&server_state,
|
||||
MediaFileClass::Mesh,
|
||||
).await?;
|
||||
|
||||
Ok(Json(ListSessionMeshMediaFilesSuccessResponse {
|
||||
success: true,
|
||||
results,
|
||||
pagination,
|
||||
}))
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::{Json, Query};
|
||||
use actix_web::{web, HttpRequest};
|
||||
|
||||
use artcraft_api_defs::media_file::list::by_type::list_session_common::ListSessionMediaFilesByTypeQueryParams;
|
||||
use artcraft_api_defs::media_file::list::by_type::list_session_splat_media_files::ListSessionSplatMediaFilesSuccessResponse;
|
||||
use enums::by_table::media_files::media_file_class::MediaFileClass;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::media_files::list::by_type::common::list_session_common::list_session_media_files_of_class;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// List the session user's gaussian splat files (`media_class = 'splat'`), paginated.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
tag = "Media Files",
|
||||
path = "/v1/media_files/splat/list",
|
||||
params(ListSessionMediaFilesByTypeQueryParams),
|
||||
responses(
|
||||
(status = 200, description = "List the session user's splat files", body = ListSessionSplatMediaFilesSuccessResponse),
|
||||
(status = 401, description = "Not authorized"),
|
||||
(status = 500, description = "Server error"),
|
||||
),
|
||||
)]
|
||||
pub async fn list_session_splat_media_files_handler(
|
||||
http_request: HttpRequest,
|
||||
query: Query<ListSessionMediaFilesByTypeQueryParams>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ListSessionSplatMediaFilesSuccessResponse>, CommonWebError> {
|
||||
|
||||
let (results, pagination) = list_session_media_files_of_class(
|
||||
&http_request,
|
||||
&query,
|
||||
&server_state,
|
||||
MediaFileClass::Splat,
|
||||
).await?;
|
||||
|
||||
Ok(Json(ListSessionSplatMediaFilesSuccessResponse {
|
||||
success: true,
|
||||
results,
|
||||
pagination,
|
||||
}))
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod common;
|
||||
pub mod list_session_mesh_media_files_handler;
|
||||
pub mod list_session_splat_media_files_handler;
|
||||
-4
@@ -51,15 +51,11 @@ pub async fn list_batch_generated_redux_media_files_handler(
|
||||
.await?;
|
||||
|
||||
let mut show_deleted_results = false;
|
||||
let mut is_moderator = false;
|
||||
|
||||
if let Some(user_session) = maybe_user_session {
|
||||
// NB: Moderators can see deleted results.
|
||||
// Original creators cannot see them (unless they're moderators!)
|
||||
show_deleted_results = user_session.can_delete_other_users_tts_results;
|
||||
// Moderators get to see all the fields.
|
||||
is_moderator = user_session.can_delete_other_users_tts_results
|
||||
|| user_session.can_edit_other_users_tts_models;
|
||||
}
|
||||
|
||||
// NB(bt,2024-03-24): I'm sorry, this is gross. We're not respecting sorting, input ordering,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod by_type;
|
||||
pub mod list_batch_generated_redux_media_files_handler;
|
||||
pub mod list_featured_media_files_handler;
|
||||
pub mod list_media_files_by_batch_token_handler;
|
||||
|
||||
-2
@@ -259,7 +259,6 @@ pub async fn upload_audio_media_file_handler(
|
||||
};
|
||||
|
||||
let mut maybe_duration_millis = None;
|
||||
let mut maybe_codec_name = None;
|
||||
|
||||
if do_audio_decode {
|
||||
let basic_info = decode_basic_audio_bytes_info(
|
||||
@@ -272,7 +271,6 @@ pub async fn upload_audio_media_file_handler(
|
||||
})?;
|
||||
|
||||
maybe_duration_millis = basic_info.duration_millis;
|
||||
maybe_codec_name = basic_info.codec_name;
|
||||
}
|
||||
|
||||
// ==================== OTHER FILE METADATA ==================== //
|
||||
|
||||
+1
-2
@@ -170,7 +170,6 @@ pub async fn process_upload_media_file(
|
||||
let file_size_bytes = bytes.len();
|
||||
|
||||
let mut maybe_duration_millis = None;
|
||||
let mut maybe_codec_name = None;
|
||||
let mut media_file_type = None;
|
||||
|
||||
let mut is_spz = false;
|
||||
@@ -258,7 +257,6 @@ pub async fn process_upload_media_file(
|
||||
})?;
|
||||
|
||||
maybe_duration_millis = basic_info.duration_millis;
|
||||
maybe_codec_name = basic_info.codec_name;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,6 +365,7 @@ pub async fn process_upload_media_file(
|
||||
.creator_set_visibility(creator_set_visibility)
|
||||
.mime_type(mime_type)
|
||||
.file_size_bytes(file_size_bytes as u64)
|
||||
.maybe_duration_millis(maybe_duration_millis)
|
||||
.checksum_sha2(&hash)
|
||||
.maybe_title(upload_media_request.title.as_deref())
|
||||
.maybe_origin_filename(upload_media_request.file_name.as_deref())
|
||||
|
||||
+3
-7
@@ -244,20 +244,16 @@ fn keep_only_largest_pmx_file(mut entries: Vec<PmxZipEntryDetail>) -> Result<Vec
|
||||
|
||||
// Some zip files have entries with useless leading directories. This will remove them.
|
||||
fn remove_useless_leading_directories(mut entries: Vec<PmxZipEntryDetail>) -> Result<Vec<PmxZipEntryDetail>, PmxError> {
|
||||
let mut maybe_parent_directory_to_remove = None;
|
||||
|
||||
{
|
||||
let maybe_parent_directory_to_remove = {
|
||||
let pmx_entries = entries.iter()
|
||||
.filter(|entry| entry.is_pmx)
|
||||
.collect::<Vec<&PmxZipEntryDetail>>();
|
||||
|
||||
match pmx_entries.get(0) {
|
||||
None => return Err(PmxError::NoPmxFile),
|
||||
Some(pmx_file) => {
|
||||
maybe_parent_directory_to_remove = pmx_file.enclosed_name.parent().map(|p| p.to_path_buf());
|
||||
}
|
||||
Some(pmx_file) => pmx_file.enclosed_name.parent().map(|p| p.to_path_buf()),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(parent) = maybe_parent_directory_to_remove {
|
||||
info!("Common parent: {:?}", parent);
|
||||
|
||||
+4
-4
@@ -291,8 +291,8 @@ pub async fn upload_new_video_media_file_handler(
|
||||
|
||||
// Tempdirs we need to keep alive across the upload step. They get dropped at
|
||||
// function exit which cleans up the on-disk files.
|
||||
let mut transcode_tempdir_ref = None;
|
||||
let mut resample_tempdir_ref = None;
|
||||
let mut _transcode_tempdir_ref = None;
|
||||
let mut _resample_tempdir_ref = None;
|
||||
|
||||
let mut final_upload_file_path = form.file.file.path().to_path_buf();
|
||||
|
||||
@@ -324,7 +324,7 @@ pub async fn upload_new_video_media_file_handler(
|
||||
})?;
|
||||
mimetype = "video/mp4".to_string();
|
||||
final_upload_file_path = transcoded_path;
|
||||
transcode_tempdir_ref = Some(transcode_tempdir);
|
||||
_transcode_tempdir_ref = Some(transcode_tempdir);
|
||||
}
|
||||
|
||||
// ==================== OPTIONAL VIDEO RESAMPLE ==================== //
|
||||
@@ -374,7 +374,7 @@ pub async fn upload_new_video_media_file_handler(
|
||||
})?;
|
||||
|
||||
final_upload_file_path = video_output_path;
|
||||
resample_tempdir_ref = Some(frame_temp_dir); // NB: Keep from going out of scope
|
||||
_resample_tempdir_ref = Some(frame_temp_dir); // NB: Keep from going out of scope
|
||||
}
|
||||
|
||||
// True if we touched the user's file in any way (transcoded, resampled, or both).
|
||||
|
||||
@@ -4,7 +4,7 @@ use crate::state::server_state::ServerState;
|
||||
|
||||
pub const ALPHA_COOKIE_NAME : &str = "enable-alpha";
|
||||
|
||||
pub fn alpha_cookie(server_state: &ServerState) -> Cookie {
|
||||
pub fn alpha_cookie(server_state: &ServerState) -> Cookie<'_> {
|
||||
Cookie::build(ALPHA_COOKIE_NAME, "true")
|
||||
.domain(&server_state.env_config.cookie_domain)
|
||||
.secure(server_state.env_config.cookie_secure) // HTTPS-only
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
pub mod moderator_list_databox_dashboards_handler;
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::Json;
|
||||
use actix_web::{web, HttpRequest};
|
||||
use serde::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::user_lookup::user_session::require_moderator::require_moderator;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ListDataboxDashboardsResponse {
|
||||
pub success: bool,
|
||||
|
||||
/// Configured Databox datawalls, in display order.
|
||||
pub databoards: Vec<DataboxDashboardEntry>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct DataboxDashboardEntry {
|
||||
/// Human-readable dashboard name.
|
||||
pub name: String,
|
||||
|
||||
/// Databox datawall id.
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
/// List the Databox metrics dashboards configured for the admin dashboard.
|
||||
/// Dashboards whose env vars aren't set are omitted.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
tag = "Moderation",
|
||||
path = "/v1/moderation/dashboards/databox",
|
||||
responses(
|
||||
(status = 200, description = "Success", body = ListDataboxDashboardsResponse),
|
||||
(status = 401, description = "Not authorized", body = CommonWebError),
|
||||
(status = 500, description = "Server error", body = CommonWebError),
|
||||
),
|
||||
)]
|
||||
pub async fn moderator_list_databox_dashboards_handler(
|
||||
http_request: HttpRequest,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ListDataboxDashboardsResponse>, CommonWebError> {
|
||||
|
||||
require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
|
||||
|
||||
let databox = &server_state.dashboards.databox;
|
||||
|
||||
let databoards = [
|
||||
("DAUs", databox.daus_id.as_ref()),
|
||||
("Daily Generations", databox.daily_generations_id.as_ref()),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(name, maybe_id)| {
|
||||
maybe_id.map(|id| DataboxDashboardEntry {
|
||||
name: name.to_string(),
|
||||
id: id.clone(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(ListDataboxDashboardsResponse {
|
||||
success: true,
|
||||
databoards,
|
||||
}))
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod alerts;
|
||||
pub mod dashboards;
|
||||
pub mod debug_logs;
|
||||
pub mod info;
|
||||
pub mod ip_bans;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user