Compare commits

..
Author SHA1 Message Date
Tanish Manku 2bf5611391 Frontend: remove dead API client code targeting unregistered endpoints
Deletes dormant legacy API surface (zero live callers, verified against the
full backend route table):

- VoiceDesignerApi, VoiceConversionApi, AnalyticsApi (/v1/analytics/log_session
  was never registered) - both @storyteller/api and the Tauri app's ApiManager
  copies, plus their tests
- PromptsApi/EngineApi: /v1/image_studio/prompt, /v1/image_studio/session_jobs,
  /v1/jobs/session/{token} methods (routes do not exist)
- UserBookmarksApi: broken-path list methods (/list/{user} and /list/{et}/{etok};
  backend shapes are /list/user/{u} and /list/entity/{et}/{etok}) and the
  FakeYou-era scoped enums that only they consumed
- Dashboard UsersApi: RequestPasswordReset/ResetPassword pointing at removed
  /v1/request_password_reset + /v1/reset_password (live pages use
  PasswordResetApi -> /v1/password_reset/* correctly)

Fixes WeightsApi cover-image path to the registered route:
/v1/weights/weight/{token}/cover_image (both copies).
2026-07-13 14:12:17 -07:00
Brandon Thomas 0003cad179 seedream5 (#1784) 2026-07-13 11:10:21 -04:00
Brandon Thomas 863bd1c4e1 Sort folders (#1782) 2026-07-12 18:13:04 -04:00
Brandon Thomas 79b7e31ec5 unfoldered navigation (frontend) (#1781) 2026-07-12 15:47:28 -04:00
Brandon Thomas eed865e57c cursor behavior fixes (#1780) 2026-07-12 15:28:34 -04:00
Brandon Thomas 348a603cae folderless endpoint (#1779) 2026-07-12 15:23:58 -04:00
Brandon Thomas 9898c6dbcf view meshes and splats in 3d modal (#1778) 2026-07-12 04:34:36 -04:00
Tanish Mankuandbflatastic e0047d9410 3D PageScene priming for scene builder (#1746)
Co-authored-by: bflatastic <bombayapirat@gmail.com>
2026-07-12 03:45:35 -04:00
Brandon Thomas a79ee6ad57 misc fixes for storyteller-web (#1777) 2026-07-12 03:21:23 -04:00
Brandon Thomas 2a9e71f5b8 fix pagination cursors (#1776) 2026-07-11 23:48:34 -04:00
Brandon Thomas 7dffc5fb5c list meshes and splats (#1774) 2026-07-12 09:32:35 +07:00
BFlat bc0174ac7a Webapp: video ref pick from library (#1775)
- The "+" button on the Video Ref row now shows options on hover:
**Upload** or **Pick from library** (on mobile it opens an "Add video"
sheet)
- Picking from library opens the gallery in select mode, filtered to
videos only
- Library videos respect the same limits as uploads (max count and total
duration, with the same error toasts)
- Reused the existing add-button component from the image row so all
reference rows look and behave the same
2026-07-12 09:12:14 +07:00
Brandon Thomas 9cc9d4b9cf Fix cover images (#1773) 2026-07-11 15:32:15 -04:00
Brandon Thomas dc5a4f8dd9 backup for cover images when absent 2026-07-10 17:14:13 -04:00
Brandon Thomas 11ddeb05b5 handle audio results (#1772) 2026-07-10 16:58:34 -04:00
Brandon Thomas 73b40d321e fal 3d fixes (#1771) 2026-07-10 16:41:59 -04:00
Brandon Thomas cb6d0fe27c new tags system (replacing the old one) (#1770) 2026-07-10 05:21:49 -04:00
Brandon Thomas 0914ddc2f5 new tags schemas (#1769) 2026-07-10 03:02:09 -04:00
Brandon Thomas d3585e7734 drop tags tables (we're going to rebuild them) (#1768) 2026-07-10 02:49:40 -04:00
Brandon Thomas 698c330734 remove old tags code (#1767) 2026-07-10 02:31:37 -04:00
339 changed files with 17387 additions and 3076 deletions
@@ -0,0 +1,25 @@
{
"db_name": "MySQL",
"query": "\nSELECT DISTINCT tag_token as `tag_token: TagToken`\nFROM media_file_tags\nWHERE media_file_token = ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "tag_token: TagToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "05033f7f4588cc2d68d9db22236f2be323731ee120217192fa29fe60af776e19"
}
@@ -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"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "DELETE FROM media_file_tags WHERE tag_token = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "3495f03338e7ee4c2ae9787204d521aefa787312463d392e45d02a693d9afb83"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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 EXISTS (\n SELECT 1 FROM media_file_tags mft\n WHERE mft.media_file_token = mf.token\n )\n AND mf.id < ?\nORDER BY mf.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "media_file_id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "media_file_token: MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "media_class: MediaFileClass",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 3,
"name": "media_type: MediaFileType",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 4,
"name": "maybe_batch_token: BatchGenerationToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "public_bucket_directory_hash",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 6,
"name": "maybe_public_bucket_prefix",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 7,
"name": "maybe_public_bucket_extension",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 8,
"name": "maybe_cover_public_bucket_directory_hash?",
"type_info": {
"type": "VarString",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 9,
"name": "maybe_cover_public_bucket_prefix?",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 10,
"name": "maybe_cover_public_bucket_extension?",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 11,
"name": "creator_set_visibility: Visibility",
"type_info": {
"type": "String",
"flags": "NOT_NULL | BINARY | ENUM",
"char_set": 224,
"max_size": 28
}
},
{
"ordinal": 12,
"name": "is_user_upload: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL | MULTIPLE_KEY",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 13,
"name": "maybe_title",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 14,
"name": "maybe_prompt_token: PromptToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 15,
"name": "maybe_origin_filename",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 16,
"name": "maybe_duration_millis",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 17,
"name": "maybe_frame_width",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 5
}
},
{
"ordinal": 18,
"name": "maybe_frame_height",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 5
}
},
{
"ordinal": 19,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 20,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 3
},
"nullable": [
false,
false,
false,
false,
true,
false,
true,
true,
true,
true,
true,
false,
false,
true,
true,
true,
true,
true,
true,
false,
false
]
},
"hash": "76bcf698f40d852558f071a6909b05b1c960c1cf236831ae009f90de46d3aefb"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE tags\nSET tag_value = ?,\n tag_value_lowercase = ?\nWHERE token = ?\n AND creator_user_token = ?\n AND maybe_deleted_at IS NULL\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "8938fc564dda214e383a8d30532db6ef964c03f7e1fd6d9561bca5e8b56834cf"
}
@@ -0,0 +1,36 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n maybe_creator_user_token as `maybe_creator_user_token: UserToken`,\n creator_set_visibility as `creator_set_visibility: Visibility`\nFROM media_files\nWHERE token = ?\n AND user_deleted_at IS NULL\n AND mod_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "maybe_creator_user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "creator_set_visibility: Visibility",
"type_info": {
"type": "String",
"flags": "NOT_NULL | BINARY | ENUM",
"char_set": 224,
"max_size": 28
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false
]
},
"hash": "a114fab486689fd2b16197cbe3a3ebb41d3d10937542832e15dd652200b6d419"
}
@@ -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"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE tags\nSET maybe_deleted_at = NOW(),\n use_count = 0\nWHERE token = ?\n AND creator_user_token = ?\n AND maybe_deleted_at IS NULL\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "a58fc9050c1fd160e03d6e4932c1b777ea7ab8cbfddca5834faab3e2963d816d"
}
@@ -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 FROM media_file_tags mft\n WHERE mft.media_file_token = mf.token\n )\n AND mf.id < ?\nORDER BY mf.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "media_file_id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "media_file_token: MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "media_class: MediaFileClass",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 3,
"name": "media_type: MediaFileType",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 4,
"name": "maybe_batch_token: BatchGenerationToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "public_bucket_directory_hash",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 6,
"name": "maybe_public_bucket_prefix",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 7,
"name": "maybe_public_bucket_extension",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 8,
"name": "maybe_cover_public_bucket_directory_hash?",
"type_info": {
"type": "VarString",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 9,
"name": "maybe_cover_public_bucket_prefix?",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 10,
"name": "maybe_cover_public_bucket_extension?",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 11,
"name": "creator_set_visibility: Visibility",
"type_info": {
"type": "String",
"flags": "NOT_NULL | BINARY | ENUM",
"char_set": 224,
"max_size": 28
}
},
{
"ordinal": 12,
"name": "is_user_upload: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL | MULTIPLE_KEY",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 13,
"name": "maybe_title",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 14,
"name": "maybe_prompt_token: PromptToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 15,
"name": "maybe_origin_filename",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 16,
"name": "maybe_duration_millis",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 17,
"name": "maybe_frame_width",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 5
}
},
{
"ordinal": 18,
"name": "maybe_frame_height",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 5
}
},
{
"ordinal": 19,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 20,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 3
},
"nullable": [
false,
false,
false,
false,
true,
false,
true,
true,
true,
true,
true,
false,
false,
true,
true,
true,
true,
true,
true,
false,
false
]
},
"hash": "aa1e693babe6997e75935ec893e6eed46351ec72ce02a9144fd11d2fcfd37fdf"
}
@@ -0,0 +1,69 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n t.id as `id: u64`,\n t.token as `token: TagToken`,\n t.tag_value,\n t.tag_value_lowercase,\n t.use_count as `use_count: u32`\nFROM media_file_tags mft\nJOIN tags t\n ON t.token = mft.tag_token\nWHERE mft.media_file_token = ?\n AND t.maybe_deleted_at IS NULL\nORDER BY t.tag_value_lowercase ASC\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "token: TagToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "tag_value",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 3,
"name": "tag_value_lowercase",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 4,
"name": "use_count: u32",
"type_info": {
"type": "Long",
"flags": "NOT_NULL | UNSIGNED",
"char_set": 63,
"max_size": 10
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "bbfed9e861b1a7cd0cdb2413e2a985aaa79aa9c885408bf5e104d1ea80bdaf49"
}
@@ -0,0 +1,25 @@
{
"db_name": "MySQL",
"query": "\nSELECT token as `token: TagToken`\nFROM tags\nWHERE creator_user_token = ?\n AND tag_value_lowercase = ?\n AND maybe_deleted_at IS NOT NULL\nLIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token: TagToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false
]
},
"hash": "cbfe9172d6227175aa6dfd276a4d1a6676d1d1b6bdc3a35927a5d71be38219c0"
}
@@ -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"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nDELETE FROM tags\nWHERE token = ?\n AND creator_user_token = ?\n AND maybe_deleted_at IS NOT NULL\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "e50d11df0606a12059e13f36daa41fc5aa59d700168425c406e545b535d6d2ba"
}
@@ -0,0 +1,69 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n id as `id: u64`,\n token as `token: TagToken`,\n tag_value,\n tag_value_lowercase,\n use_count as `use_count: u32`\nFROM tags\nWHERE creator_user_token = ?\n AND maybe_deleted_at IS NULL\n AND id < ?\nORDER BY id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "token: TagToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "tag_value",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 3,
"name": "tag_value_lowercase",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 4,
"name": "use_count: u32",
"type_info": {
"type": "Long",
"flags": "NOT_NULL | UNSIGNED",
"char_set": 63,
"max_size": 10
}
}
],
"parameters": {
"Right": 3
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "eafaf1a9ed2e27dec201ea66d23a65662492ca2dbdac6333629b82defc4b5111"
}
@@ -0,0 +1,69 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n id as `id: u64`,\n token as `token: TagToken`,\n tag_value,\n tag_value_lowercase,\n use_count as `use_count: u32`\nFROM tags\nWHERE token = ?\n AND creator_user_token = ?\n AND maybe_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "token: TagToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "tag_value",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 3,
"name": "tag_value_lowercase",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 4,
"name": "use_count: u32",
"type_info": {
"type": "Long",
"flags": "NOT_NULL | UNSIGNED",
"char_set": 63,
"max_size": 10
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "ecc3848cd186f1298a2a60c810c69b7a8715719a0c7622122d7e1c5747fab06a"
}
@@ -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_file_tags mft\nJOIN media_files mf\n ON mf.token = mft.media_file_token\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE mft.tag_token = ?\n AND mft.user_token = ?\n AND mf.maybe_creator_user_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\n AND NOT mf.is_intermediate_system_file\n AND mf.id < ?\nORDER BY mf.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "media_file_id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "media_file_token: MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "media_class: MediaFileClass",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 3,
"name": "media_type: MediaFileType",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 4,
"name": "maybe_batch_token: BatchGenerationToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "public_bucket_directory_hash",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 6,
"name": "maybe_public_bucket_prefix",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 7,
"name": "maybe_public_bucket_extension",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 8,
"name": "maybe_cover_public_bucket_directory_hash?",
"type_info": {
"type": "VarString",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 9,
"name": "maybe_cover_public_bucket_prefix?",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 10,
"name": "maybe_cover_public_bucket_extension?",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 11,
"name": "creator_set_visibility: Visibility",
"type_info": {
"type": "String",
"flags": "NOT_NULL | BINARY | ENUM",
"char_set": 224,
"max_size": 28
}
},
{
"ordinal": 12,
"name": "is_user_upload: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL | MULTIPLE_KEY",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 13,
"name": "maybe_title",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 14,
"name": "maybe_prompt_token: PromptToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 15,
"name": "maybe_origin_filename",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 16,
"name": "maybe_duration_millis",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 17,
"name": "maybe_frame_width",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 5
}
},
{
"ordinal": 18,
"name": "maybe_frame_height",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 5
}
},
{
"ordinal": 19,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 20,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 5
},
"nullable": [
false,
false,
false,
false,
true,
false,
true,
true,
true,
true,
true,
false,
false,
true,
true,
true,
true,
true,
true,
false,
false
]
},
"hash": "f36c7238231353882b5b2ac39616dcdc6adebf7c7e1198d9c2af4674ba5d982c"
}
@@ -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"
}
@@ -0,0 +1,35 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE tag_uses (
-- Not used for anything except replication.
id BIGINT(20) NOT NULL AUTO_INCREMENT,
-- ========== TAG LINK ==========
-- Token of the tag
tag_token VARCHAR(32) NOT NULL,
-- ========== LINKED ENTITY ==========
-- Entity the tag is linked to (composite key)
entity_type VARCHAR(32) NOT NULL,
-- Entity the tag is linked to (composite key)
entity_token VARCHAR(32) NOT NULL,
-- ========== TIMESTAMPS ==========
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
maybe_deleted_at TIMESTAMP NULL,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (tag_token, entity_type, entity_token),
KEY index_tag_token (tag_token),
KEY index_entity_type_and_token (entity_type, entity_token),
KEY index_maybe_deleted_at (maybe_deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE tag_uses;
@@ -0,0 +1,30 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE tags (
-- Not used for anything except replication.
id BIGINT(20) NOT NULL AUTO_INCREMENT,
-- ========== PRIMARY FUNCTIONALITY ==========
-- Effective "primary key" of the beta key.
token VARCHAR(32) NOT NULL,
-- The actual value of the tag.
-- The code will normalize this.
tag_value VARCHAR(255) NOT NULL,
-- ========== TIMESTAMPS ==========
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
maybe_deleted_at TIMESTAMP NULL,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (token),
UNIQUE KEY (tag_value),
KEY index_maybe_deleted_at (maybe_deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE tags;
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE IF EXISTS tags;
@@ -0,0 +1,38 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE tags (
-- Not used for anything except replication.
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- Effective "primary key".
token VARCHAR(32) NOT NULL,
-- The display value of the tag, as entered by the creator.
tag_value VARCHAR(255) NOT NULL,
-- Lowercased copy of `tag_value` for case-insensitive uniqueness and lookup.
-- (The table collation is utf8mb4_bin, so the code must lowercase this.)
tag_value_lowercase VARCHAR(255) NOT NULL,
-- The user that created the tag.
creator_user_token VARCHAR(32) NOT NULL,
-- Rollup statistic for usage counts.
use_count INT(10) UNSIGNED NOT NULL DEFAULT 0,
-- ========== TIMESTAMPS ==========
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
maybe_deleted_at TIMESTAMP NULL DEFAULT NULL,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (token),
UNIQUE KEY (tag_value_lowercase, creator_user_token),
KEY index_creator_user_token (creator_user_token)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE IF EXISTS media_file_tags;
@@ -0,0 +1,32 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE media_file_tags (
-- Not used for anything except replication.
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- The media file being tagged.
media_file_token VARCHAR(32) NOT NULL,
-- The tag applied to the media file.
tag_token VARCHAR(32) NOT NULL,
-- The user that owns this tagging.
user_token VARCHAR(32) NOT NULL,
-- ========== TIMESTAMPS ==========
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (media_file_token, tag_token, user_token),
KEY index_media_file_token_and_user_token (media_file_token, user_token),
KEY index_tag_token_and_user_token (tag_token, user_token),
KEY index_media_file_token (media_file_token),
KEY index_tag_token (tag_token),
KEY index_user_token (user_token)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,36 @@
-- NB: This is a manually squashed view of all the CREATE and ALTER statements,
-- with comments attached to the fields for centralized documentation.
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
-- N:M mapping between `media_files` and `tags`, scoped to the owning user.
CREATE TABLE media_file_tags (
-- Not used for anything except replication.
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- The media file being tagged.
media_file_token VARCHAR(32) NOT NULL,
-- The tag applied to the media file.
tag_token VARCHAR(32) NOT NULL,
-- The user that owns this tagging.
user_token VARCHAR(32) NOT NULL,
-- ========== TIMESTAMPS ==========
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (media_file_token, tag_token, user_token),
KEY index_media_file_token_and_user_token (media_file_token, user_token),
KEY index_tag_token_and_user_token (tag_token, user_token),
KEY index_media_file_token (media_file_token),
KEY index_tag_token (tag_token),
KEY index_user_token (user_token)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,44 @@
-- NB: This is a manually squashed view of all the CREATE and ALTER statements,
-- with comments attached to the fields for centralized documentation.
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
-- User-created tags for media files. Tags are scoped per-user: two users can
-- each have their own tag with the same value. Applied to media files via the
-- `media_file_tags` join table.
CREATE TABLE tags (
-- Not used for anything except replication.
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- Effective "primary key".
token VARCHAR(32) NOT NULL,
-- The display value of the tag, as entered by the creator.
tag_value VARCHAR(255) NOT NULL,
-- Lowercased copy of `tag_value` for case-insensitive uniqueness and lookup.
-- (The table collation is utf8mb4_bin, so the code must lowercase this.)
tag_value_lowercase VARCHAR(255) NOT NULL,
-- The user that created the tag.
creator_user_token VARCHAR(32) NOT NULL,
-- Rollup statistic for usage counts.
use_count INT(10) UNSIGNED NOT NULL DEFAULT 0,
-- ========== TIMESTAMPS ==========
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
maybe_deleted_at TIMESTAMP NULL DEFAULT NULL,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (token),
UNIQUE KEY (tag_value_lowercase, creator_user_token),
KEY index_creator_user_token (creator_user_token)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -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)]
@@ -13,6 +13,7 @@ pub mod omni_gen;
pub mod prompts;
pub mod stripe_artcraft;
pub mod subscriptions;
pub mod tags;
pub mod user_referral_codes;
pub mod users;
pub mod utils;
@@ -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>,
}
@@ -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,
}
@@ -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;
@@ -0,0 +1,35 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::media_files::MediaFileToken;
use crate::tags::common::TagDetails;
// ── POST /v1/tags/media_file/add/{media_file_token} ──
#[derive(Deserialize, ToSchema)]
pub struct AddMediaFileTagsPathInfo {
pub media_file_token: MediaFileToken,
}
/// Exactly one of `maybe_tags` / `maybe_tags_list` must be set (400 if
/// both or neither). Tag text is trimmed and deduped case-insensitively;
/// empty entries are dropped. Must sanitize to at least one tag (400
/// otherwise).
#[derive(Deserialize, ToSchema)]
pub struct AddMediaFileTagsRequest {
/// Comma-separated tags, e.g. `"cats, Sci-Fi, wallpaper"`.
pub maybe_tags: Option<String>,
/// Tags as a list. Entries are still trimmed.
pub maybe_tags_list: Option<Vec<String>>,
}
#[derive(Serialize, ToSchema)]
pub struct AddMediaFileTagsSuccessResponse {
pub success: bool,
/// The tags from this request after upsert, with canonical tokens and
/// fresh use counts. Tags already on the file are absorbed (no error).
pub tags: Vec<TagDetails>,
}
@@ -0,0 +1,38 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::media_files::MediaFileToken;
use crate::tags::common::TagDetails;
// ── POST /v1/tags/bulk_add ──
/// Exactly one of `maybe_tags` / `maybe_tags_list` must be set (400 if
/// both or neither). Tag text is trimmed and deduped case-insensitively;
/// empty entries are dropped. Must sanitize to at least one tag (400
/// otherwise). Media file tokens are deduped; tokens the user doesn't
/// own (or that are deleted) are silently skipped.
#[derive(Deserialize, ToSchema)]
pub struct BulkAddTagsRequest {
/// Media files to tag.
pub media_file_tokens: Vec<MediaFileToken>,
/// Comma-separated tags, e.g. `"cats, Sci-Fi, wallpaper"`.
pub maybe_tags: Option<String>,
/// Tags as a list. Entries are still trimmed.
pub maybe_tags_list: Option<Vec<String>>,
}
#[derive(Serialize, ToSchema)]
pub struct BulkAddTagsSuccessResponse {
pub success: bool,
/// The subset of the input tokens that were actually tagged: files
/// that exist, aren't deleted, and are owned by the caller.
pub accepted_media_file_tokens: Vec<MediaFileToken>,
/// The tags from this request after upsert, with canonical tokens and
/// fresh use counts.
pub tags: Vec<TagDetails>,
}
@@ -0,0 +1,33 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::media_files::MediaFileToken;
use crate::tags::common::TagDetails;
// ── POST /v1/tags/media_files/bulk_list_tags ──
//
// POST rather than GET: the token list belongs in a request body, and
// GET request bodies are non-standard. The operation is still a pure
// read.
#[derive(Deserialize, ToSchema)]
pub struct BulkListMediaFileTagsRequest {
/// Media files to look up. Tokens with no tags come back with an
/// empty `tags` list.
pub media_file_tokens: Vec<MediaFileToken>,
}
#[derive(Serialize, ToSchema)]
pub struct MediaFileTagsEntry {
pub media_file_token: MediaFileToken,
pub tags: Vec<TagDetails>,
}
#[derive(Serialize, ToSchema)]
pub struct BulkListMediaFileTagsSuccessResponse {
pub success: bool,
/// One entry per requested media file token (deduped), in request order.
pub media_files: Vec<MediaFileTagsEntry>,
}
@@ -0,0 +1,44 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::media_files::MediaFileToken;
use crate::tags::common::TagDetails;
// ── POST /v1/tags/bulk_set ──
/// Exactly one of `maybe_tags` / `maybe_tags_list` must be set (400 if
/// both or neither). Tag text is trimmed and deduped case-insensitively;
/// empty entries are dropped. Unlike `bulk_add`, sanitizing down to zero
/// tags is allowed — it clears all tags from the listed media files.
/// Media file tokens are deduped; tokens the user doesn't own (or that
/// are deleted) are silently skipped.
#[derive(Deserialize, ToSchema)]
pub struct BulkSetTagsRequest {
/// Media files whose tag sets will be replaced.
pub media_file_tokens: Vec<MediaFileToken>,
/// Comma-separated tags, e.g. `"cats, Sci-Fi, wallpaper"`.
pub maybe_tags: Option<String>,
/// Tags as a list. Entries are still trimmed.
pub maybe_tags_list: Option<Vec<String>>,
}
#[derive(Serialize, ToSchema)]
pub struct BulkSetTagsSuccessResponse {
pub success: bool,
/// The subset of the input tokens that were actually updated: files
/// that exist, aren't deleted, and are owned by the caller.
pub accepted_media_file_tokens: Vec<MediaFileToken>,
/// The full tag set now on every accepted media file, with canonical
/// tokens and fresh use counts.
pub tags: Vec<TagDetails>,
/// How many previously-attached tag links were removed across all the
/// accepted media files because they weren't mentioned in the request.
/// (Orphaned tags are not deleted.)
pub removed_count: u64,
}
@@ -0,0 +1,20 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::media_files::MediaFileToken;
// ── POST /v1/tags/media_file/clear/{media_file_token} ──
#[derive(Deserialize, ToSchema)]
pub struct ClearMediaFileTagsPathInfo {
pub media_file_token: MediaFileToken,
}
#[derive(Serialize, ToSchema)]
pub struct ClearMediaFileTagsSuccessResponse {
pub success: bool,
/// How many tag links were removed from the media file. (Orphaned
/// tags are not deleted.)
pub removed_count: u64,
}
@@ -0,0 +1,20 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::tags::TagToken;
/// One tag as returned by every tags endpoint.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct TagDetails {
pub tag_token: TagToken,
/// The display value of the tag, as entered by its creator.
pub tag_value: String,
/// Lowercased form of `tag_value`. This is the tag's unique key
/// within the creator's account.
pub tag_value_lowercase: String,
/// Rollup statistic: how many media files currently carry this tag.
pub use_count: u32,
}
@@ -0,0 +1,20 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::tags::TagToken;
// ── DELETE /v1/tags/{tag_token} ──
#[derive(Deserialize, ToSchema)]
pub struct DeleteTagPathInfo {
pub tag_token: TagToken,
}
#[derive(Serialize, ToSchema)]
pub struct DeleteTagSuccessResponse {
pub success: bool,
/// How many media-file links were hard-deleted along with the tag.
/// (The tag record itself is soft-deleted.)
pub removed_link_count: u64,
}
@@ -0,0 +1,21 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::media_files::MediaFileToken;
use crate::tags::common::TagDetails;
// ── GET /v1/tags/media_file/list/{media_file_token} ──
#[derive(Deserialize, ToSchema)]
pub struct ListMediaFileTagsPathInfo {
pub media_file_token: MediaFileToken,
}
#[derive(Serialize, ToSchema)]
pub struct ListMediaFileTagsSuccessResponse {
pub success: bool,
/// All (live) tags on the media file, sorted by tag value.
pub tags: Vec<TagDetails>,
}
@@ -0,0 +1,22 @@
use serde_derive::Deserialize;
use utoipa::{IntoParams, ToSchema};
use tokens::tokens::tags::TagToken;
// ── GET /v1/tags/media_files/with_tag/{tag_token} ──
//
// NB: The success response lives in `storyteller_web`'s handler because
// the wire shape embeds `MediaLinks` / `MediaFileCoverImageDetails`
// constructors that depend on the request's `MediaDomain` +
// `ServerEnvironment`.
#[derive(Deserialize, ToSchema)]
pub struct ListMediaFilesWithTagPathInfo {
pub tag_token: TagToken,
}
#[derive(Deserialize, ToSchema, IntoParams)]
pub struct ListMediaFilesWithTagQueryParams {
pub cursor: Option<String>,
pub limit: Option<u32>,
}
@@ -0,0 +1,15 @@
use serde_derive::Deserialize;
use utoipa::{IntoParams, ToSchema};
// ── GET /v1/tags/media_files/list_tagged ──
//
// NB: The success response lives in `storyteller_web`'s handler because
// the wire shape embeds `MediaLinks` / `MediaFileCoverImageDetails`
// constructors that depend on the request's `MediaDomain` +
// `ServerEnvironment`.
#[derive(Deserialize, ToSchema, IntoParams)]
pub struct ListTaggedMediaFilesQueryParams {
pub cursor: Option<String>,
pub limit: Option<u32>,
}
@@ -0,0 +1,23 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use crate::tags::common::TagDetails;
// ── GET /v1/tags/list ──
#[derive(Deserialize, ToSchema, IntoParams)]
pub struct ListTagsQueryParams {
pub cursor: Option<String>,
pub limit: Option<u32>,
}
#[derive(Serialize, ToSchema)]
pub struct ListTagsSuccessResponse {
pub success: bool,
/// The logged-in user's (live) tags, newest first.
pub tags: Vec<TagDetails>,
/// Present when there may be more results; pass back as `cursor`.
pub maybe_cursor: Option<String>,
}
@@ -0,0 +1,15 @@
use serde_derive::Deserialize;
use utoipa::{IntoParams, ToSchema};
// ── GET /v1/tags/media_files/list_untagged ──
//
// NB: The success response lives in `storyteller_web`'s handler because
// the wire shape embeds `MediaLinks` / `MediaFileCoverImageDetails`
// constructors that depend on the request's `MediaDomain` +
// `ServerEnvironment`.
#[derive(Deserialize, ToSchema, IntoParams)]
pub struct ListUntaggedMediaFilesQueryParams {
pub cursor: Option<String>,
pub limit: Option<u32>,
}
@@ -0,0 +1,14 @@
pub mod add_media_file_tags;
pub mod bulk_add_tags;
pub mod bulk_list_media_file_tags;
pub mod bulk_set_tags;
pub mod clear_media_file_tags;
pub mod common;
pub mod delete_tag;
pub mod list_media_file_tags;
pub mod list_media_files_with_tag;
pub mod list_tagged_media_files;
pub mod list_tags;
pub mod list_untagged_media_files;
pub mod rename_tag;
pub mod set_media_file_tags;
@@ -0,0 +1,29 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::tags::TagToken;
use crate::tags::common::TagDetails;
// ── PUT /v1/tags/rename/{tag_token} ──
#[derive(Deserialize, ToSchema)]
pub struct RenameTagPathInfo {
pub tag_token: TagToken,
}
#[derive(Deserialize, ToSchema)]
pub struct RenameTagRequest {
/// The new tag text (trimmed server-side). Both case-only changes and
/// wholesale renames are allowed. Fails with 400 if the caller already
/// has a different tag with the same lowercased value.
pub new_tag_value: String,
}
#[derive(Serialize, ToSchema)]
pub struct RenameTagSuccessResponse {
pub success: bool,
/// The tag after the rename.
pub tag: TagDetails,
}
@@ -0,0 +1,39 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::media_files::MediaFileToken;
use crate::tags::common::TagDetails;
// ── POST /v1/tags/media_file/set/{media_file_token} ──
#[derive(Deserialize, ToSchema)]
pub struct SetMediaFileTagsPathInfo {
pub media_file_token: MediaFileToken,
}
/// Exactly one of `maybe_tags` / `maybe_tags_list` must be set (400 if
/// both or neither). Tag text is trimmed and deduped case-insensitively;
/// empty entries are dropped. Unlike `add`, sanitizing down to zero tags
/// is allowed — it clears all tags from the media file.
#[derive(Deserialize, ToSchema)]
pub struct SetMediaFileTagsRequest {
/// Comma-separated tags, e.g. `"cats, Sci-Fi, wallpaper"`.
pub maybe_tags: Option<String>,
/// Tags as a list. Entries are still trimmed.
pub maybe_tags_list: Option<Vec<String>>,
}
#[derive(Serialize, ToSchema)]
pub struct SetMediaFileTagsSuccessResponse {
pub success: bool,
/// The media file's full tag set after the operation, with canonical
/// tokens and fresh use counts.
pub tags: Vec<TagDetails>,
/// How many previously-attached tag links were removed because they
/// weren't mentioned in the request. (Orphaned tags are not deleted.)
pub removed_count: u64,
}
@@ -41,6 +41,8 @@ pub enum RouterImageModel {
Seedream4p5,
#[serde(rename = "seedream_5_lite")]
Seedream5Lite,
#[serde(rename = "seedream_5p0_pro")]
Seedream5p0Pro,
#[serde(rename = "qwen_edit_2511_angles")]
QwenEdit2511Angles,
#[serde(rename = "flux_2_lora_angles")]
@@ -73,4 +75,9 @@ 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");
}
}
@@ -26,6 +26,7 @@ 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::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 +45,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 +121,7 @@ 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::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 +144,11 @@ 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),
_ => self.unsupported_provider_and_model(),
}
@@ -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))
}
}
}
}
@@ -39,6 +39,8 @@ 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::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 +77,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 +96,7 @@ pub enum ImageGenerationRequest {
ArtcraftSeedream4(ArtcraftSeedream4RequestState),
ArtcraftSeedream4p5(ArtcraftSeedream4p5RequestState),
ArtcraftSeedream5Lite(ArtcraftSeedream5LiteRequestState),
ArtcraftSeedream5p0Pro(ArtcraftSeedream5p0ProRequestState),
ArtcraftQwenEdit2511Angles(ArtcraftQwenEdit2511AnglesRequestState),
ArtcraftFlux2LoraAngles(ArtcraftFlux2LoraAnglesRequestState),
ArtcraftMidjourney7(ArtcraftMidjourney7RequestState),
@@ -115,10 +120,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 +143,7 @@ impl ImageGenerationRequest {
Self::ArtcraftSeedream4(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedream4p5(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedream5Lite(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedream5p0Pro(_) => RouterProvider::Artcraft,
Self::ArtcraftQwenEdit2511Angles(_) => RouterProvider::Artcraft,
Self::ArtcraftFlux2LoraAngles(_) => RouterProvider::Artcraft,
Self::ArtcraftMidjourney7(_) => RouterProvider::Artcraft,
@@ -162,6 +169,7 @@ impl ImageGenerationRequest {
Self::KinoviMidjourney7(_) => RouterProvider::Seedance2Pro,
Self::KinoviMidjourney7Niji(_) => RouterProvider::Seedance2Pro,
Self::KinoviMidjourney8(_) => RouterProvider::Seedance2Pro,
Self::KinoviSeedream5p0Pro(_) => RouterProvider::Seedance2Pro,
}
}
@@ -207,6 +215,9 @@ impl ImageGenerationRequest {
Self::ArtcraftSeedream5Lite(request) => {
Ok(ArtcraftSeedream5LiteCostState::from_request(request).estimate_cost())
}
Self::ArtcraftSeedream5p0Pro(request) => {
Ok(ArtcraftSeedream5p0ProCostState::from_request(request).estimate_cost())
}
Self::ArtcraftQwenEdit2511Angles(request) => {
Ok(ArtcraftQwenEdit2511AnglesCostState::from_request(request).estimate_cost())
}
@@ -243,6 +254,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 +313,10 @@ 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::ArtcraftQwenEdit2511Angles(request) => {
let artcraft_client = client.get_artcraft_client_ref()?;
request.send(artcraft_client).await
@@ -389,7 +405,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 +421,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
}
}
}
}
@@ -19,3 +19,4 @@ pub(crate) mod request_common;
pub mod seedream_4;
pub mod seedream_4p5;
pub mod seedream_5_lite;
pub mod seedream_5p0_pro;
@@ -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 }),
))
}
@@ -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);
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -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
}
}
@@ -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;
@@ -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);
}
}
}
@@ -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
}
}
@@ -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 })
}
}
@@ -0,0 +1,4 @@
pub mod build;
pub mod cost;
pub mod draft;
pub mod request;
@@ -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());
}
}
@@ -182,6 +182,46 @@ mod tests {
}
}
#[test]
fn hunyuan_3d_part_format_not_supported() {
let webhook = load_test_webhook("failure/hunyuan_3d_3p1_part_fail_format_not_supported.json");
let result = hydrate_webhook_contents(&webhook);
match result {
HydratedWebhookContents::Error(data) => {
assert_eq!(
data.error_type,
Some(WebhookErrorType::ValueError),
);
assert_eq!(
data.message.as_deref(),
Some("Part generation only supports FBX format. Please provide an FBX file or use /convert-format to convert your model to FBX first."),
);
}
other => panic!("Expected HydratedWebhookContents::Error, got {:?}", other),
}
}
#[test]
fn triposplat_no_foreground_subject() {
let webhook = load_test_webhook("failure/triposplat_fail_no_foreground_subject.json");
let result = hydrate_webhook_contents(&webhook);
match result {
HydratedWebhookContents::Error(data) => {
assert_eq!(
data.error_type,
Some(WebhookErrorType::InputValueError),
);
assert_eq!(
data.message.as_deref(),
Some("No foreground subject could be detected in the input image after background removal. Provide an image with a clear, visible subject against a plain or distinct background."),
);
}
other => panic!("Expected HydratedWebhookContents::Error, got {:?}", other),
}
}
#[test]
fn payload_error_case() {
let webhook = RawWebhookPayload {
@@ -43,10 +43,23 @@ pub struct ExtractedContents {
/// Parsed from `payload.video`.
pub video: Option<VideoData>,
/// Parsed from `payload.audio` (e.g. Seed Audio 1.0 speech results).
pub audio: Option<AudioData>,
/// Parsed from `payload.model_glb`.
pub model_glb: Option<ModelGlbData>,
/// Parsed from `payload.model_glb_pbr` (e.g. Hunyuan 3D 2.1's PBR-textured
/// GLB variant, sent alongside `model_glb`).
pub model_glb_pbr: Option<ModelGlbData>,
/// Parsed from `payload.model_urls` (e.g. Hunyuan 3D 3.0's per-format file
/// map, sent alongside `model_glb`).
pub model_urls: Option<ModelUrlsData>,
/// Parsed from `payload.model_mesh`.
/// NB: `triposplat` ply gaussian splat files also arrive via this payload handler.
/// These are decidedly *not* "mesh" files!
pub model_mesh: Option<ModelMeshData>,
/// Parsed from `payload.model_obj` (e.g. Hunyuan 3D v3.1 Rapid's OBJ output).
@@ -57,6 +70,14 @@ pub struct ExtractedContents {
/// Parsed from `payload.thumbnail`.
pub thumbnail: Option<ThumbnailData>,
/// Parsed from `payload.rendered_image` (e.g. Tripo 3D's preview image,
/// used as a cover image like `thumbnail`).
pub rendered_image: Option<ThumbnailData>,
/// Parsed from `payload.preprocessed_image` (e.g. TripoSplat's segmented
/// input image, usable as a cover image for the splat result).
pub preprocessed_image: Option<PreprocessedImageData>,
}
/// Data under `payload.image`:
@@ -90,7 +111,22 @@ pub struct VideoData {
pub file_size: Option<u64>,
}
/// Data under `payload.model_glb` (there may be other sibling keys too)
/// 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)]
pub struct ModelGlbData {
pub content_type: Option<String>,
@@ -100,6 +136,8 @@ pub struct ModelGlbData {
}
/// Data under `payload.model_mesh` (there may be other sibling keys too)
/// NB: `triposplat` ply gaussian splat files also arrive via this payload.
/// These are decidedly *not* "mesh" files!
#[derive(Debug, Deserialize)]
pub struct ModelMeshData {
pub content_type: Option<String>,
@@ -108,6 +146,31 @@ pub struct ModelMeshData {
pub url: Option<String>,
}
/// Data under `payload.model_urls` (e.g. Hunyuan 3D 3.0 / 3.1, Tripo 3D): a
/// map of the generation's output files by format. Any slot may be null or
/// absent. The `glb` entry frequently duplicates the top-level model key
/// (same URL), but may point to a different file. Slots may also duplicate
/// each other's URLs (e.g. Tripo 3D's `glb` and `pbr_model`).
#[derive(Debug, Deserialize)]
pub struct ModelUrlsData {
/// Untextured base model (Tripo 3D).
pub base_model: Option<ModelGlbData>,
/// Blender project file (Meshy).
pub blend: Option<ModelGlbData>,
pub fbx: Option<ModelGlbData>,
pub glb: Option<ModelGlbData>,
/// OBJ material file (Hunyuan 3D 3.1).
pub mtl: Option<ModelGlbData>,
pub obj: Option<ModelGlbData>,
/// PBR-textured model (Tripo 3D).
pub pbr_model: Option<ModelGlbData>,
/// STL model (Meshy).
pub stl: Option<ModelGlbData>,
/// PBR texture image for the OBJ (Hunyuan 3D 3.1).
pub texture: Option<ModelGlbData>,
pub usdz: Option<ModelGlbData>,
}
/// Data under `payload.model_obj` (there may be other sibling keys too).
/// Some endpoints (e.g. Hunyuan 3D v3.1 Rapid text-to-3d) return an OBJ
/// model instead of (or alongside) a GLB.
@@ -132,6 +195,7 @@ pub struct ResultFileData {
/// Data under `payload.thumbnail` (there may be other sibling keys too)
/// Frequently seen together with `model_glb`.
/// Also used for `payload.rendered_image` (Tripo 3D), which has the same shape.
#[derive(Debug, Deserialize)]
pub struct ThumbnailData {
pub content_type: Option<String>,
@@ -140,6 +204,19 @@ pub struct ThumbnailData {
pub url: Option<String>,
}
/// Data under `payload.preprocessed_image` (there may be other sibling keys too).
/// Seen together with `model_mesh` in TripoSplat results: the segmented input
/// image, which we use as a cover image for the splat.
#[derive(Debug, Deserialize)]
pub struct PreprocessedImageData {
pub content_type: Option<String>,
pub file_name: Option<String>,
pub file_size: Option<usize>,
pub height: Option<u64>,
pub url: Option<String>,
pub width: Option<u64>,
}
#[derive(Debug)]
pub struct ErrorData {
/// The first human-readable message from `payload.detail[].msg`, if any.
@@ -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());
}
}
@@ -0,0 +1,87 @@
use serde_json::{Map, Value};
use crate::webhook_payload::hydrated::hydrated_webhook_contents::ModelGlbData;
/// Extract and deserialize the `model_glb_pbr` key from a webhook success
/// payload. Sent alongside `model_glb` by e.g. Hunyuan 3D 2.1; same shape.
pub(crate) fn extract_model_glb_pbr(obj: &Map<String, Value>) -> Option<ModelGlbData> {
let value = obj.get("model_glb_pbr")?;
serde_json::from_value(value.clone()).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::webhook_payload::hydrate_webhook_contents::hydrate_webhook_contents;
use crate::webhook_payload::hydrated::hydrated_webhook_contents::HydratedWebhookContents;
use crate::webhook_payload::raw::raw_webhook_payload::RawWebhookPayload;
fn load_test_webhook(filename: &str) -> RawWebhookPayload {
let path = format!("test_data/webhooks/{}", filename);
let json = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("Failed to read {}: {}", path, e));
serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("Failed to parse {}: {}", path, e))
}
#[test]
fn model_glbs_from_hunyuan_3d_2p1_test_file() {
let webhook = load_test_webhook("success/hunyuan_3d_2p1_model_glbs_payload_1.json");
let result = hydrate_webhook_contents(&webhook);
let HydratedWebhookContents::Success(data) = result else {
panic!("Expected Success, got {:?}", result);
};
let contents = data.extracted_contents
.expect("extracted_contents should be Some");
let glb = contents.model_glb.expect("model_glb should be Some");
assert_eq!(glb.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1b916/QKdBTd8WJW_rtPB3IwlEN_demo_textured.glb"));
assert_eq!(glb.content_type.as_deref(), Some("application/octet-stream"));
assert_eq!(glb.file_name.as_deref(), Some("demo_textured.glb"));
assert_eq!(glb.file_size, Some(1574216));
let glb_pbr = contents.model_glb_pbr.expect("model_glb_pbr should be Some");
assert_eq!(glb_pbr.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1b921/SJw7AARzkc4l9QG5Lrvmu_demo_textured_pbr.glb"));
assert_eq!(glb_pbr.content_type.as_deref(), Some("application/octet-stream"));
assert_eq!(glb_pbr.file_name.as_deref(), Some("demo_textured_pbr.glb"));
assert_eq!(glb_pbr.file_size, Some(7512760));
// The payload also carries a `model_mesh` zip archive of the whole
// generation. It parses out, but the webhook handler prefers the GLBs.
let mesh = contents.model_mesh.expect("model_mesh should be Some");
assert_eq!(mesh.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1b915/B25DrTdiZJNV-60RaUh4u_3d_model.zip"));
assert_eq!(mesh.file_name.as_deref(), Some("3d_model.zip"));
assert!(contents.thumbnail.is_none());
assert!(contents.preprocessed_image.is_none());
}
#[test]
fn synthetic_model_glb_pbr_payload() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"model_glb_pbr": {
"url": "https://cdn.example.com/model_pbr.glb",
"content_type": "model/gltf-binary",
"file_name": "model_pbr.glb",
"file_size": 7654321
}
}"#).unwrap();
let glb_pbr = extract_model_glb_pbr(&obj).expect("should extract model_glb_pbr");
assert_eq!(glb_pbr.url.as_deref(), Some("https://cdn.example.com/model_pbr.glb"));
assert_eq!(glb_pbr.content_type.as_deref(), Some("model/gltf-binary"));
assert_eq!(glb_pbr.file_name.as_deref(), Some("model_pbr.glb"));
assert_eq!(glb_pbr.file_size, Some(7654321));
}
#[test]
fn missing_model_glb_pbr_key_returns_none() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"model_glb": {"url": "https://example.com/model.glb"}
}"#).unwrap();
assert!(extract_model_glb_pbr(&obj).is_none());
}
}
@@ -49,6 +49,33 @@ mod tests {
assert!(contents.thumbnail.is_none());
}
#[test]
fn triposplat_ply_splat_from_test_file() {
// TripoSplat sends its PLY gaussian splat under `model_mesh`.
let webhook = load_test_webhook("success/triposplat_splat_payload_1.json");
let result = hydrate_webhook_contents(&webhook);
let HydratedWebhookContents::Success(data) = result else {
panic!("Expected Success, got {:?}", result);
};
let contents = data.extracted_contents
.expect("extracted_contents should be Some");
let mesh = contents.model_mesh.expect("model_mesh should be Some");
assert_eq!(mesh.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1b77c/hEzg5xn9A-1qrLsnywjN4_output.ply"));
assert_eq!(mesh.content_type.as_deref(), Some("application/octet-stream"));
assert_eq!(mesh.file_name.as_deref(), Some("output.ply"));
assert_eq!(mesh.file_size, Some(17826208));
// The segmented input image rides along as `preprocessed_image`.
assert!(contents.preprocessed_image.is_some());
assert!(contents.model_glb.is_none());
assert!(contents.model_glb_pbr.is_none());
assert!(contents.thumbnail.is_none());
}
#[test]
fn synthetic_model_mesh_payload() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
@@ -0,0 +1,327 @@
use serde_json::{Map, Value};
use crate::webhook_payload::hydrated::hydrated_webhook_contents::ModelUrlsData;
/// Extract and deserialize the `model_urls` key from a webhook success
/// payload (e.g. Hunyuan 3D 3.0's per-format file map).
pub(crate) fn extract_model_urls(obj: &Map<String, Value>) -> Option<ModelUrlsData> {
let value = obj.get("model_urls")?;
serde_json::from_value(value.clone()).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::webhook_payload::hydrate_webhook_contents::hydrate_webhook_contents;
use crate::webhook_payload::hydrated::hydrated_webhook_contents::HydratedWebhookContents;
use crate::webhook_payload::raw::raw_webhook_payload::RawWebhookPayload;
fn load_test_webhook(filename: &str) -> RawWebhookPayload {
let path = format!("test_data/webhooks/{}", filename);
let json = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("Failed to read {}: {}", path, e));
serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("Failed to parse {}: {}", path, e))
}
#[test]
fn model_urls_from_hunyuan_3d_3p0_test_file() {
let webhook = load_test_webhook("success/hunyuan_3d_3p0_model_urls_payload_1.json");
let result = hydrate_webhook_contents(&webhook);
let HydratedWebhookContents::Success(data) = result else {
panic!("Expected Success, got {:?}", result);
};
let contents = data.extracted_contents
.expect("extracted_contents should be Some");
let glb = contents.model_glb.expect("model_glb should be Some");
assert_eq!(glb.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1b969/k5FZWmTsKxHH71404bBR__model.glb"));
assert_eq!(glb.content_type.as_deref(), Some("model/gltf-binary"));
assert_eq!(glb.file_name.as_deref(), Some("model.glb"));
assert_eq!(glb.file_size, Some(51001160));
let model_urls = contents.model_urls.expect("model_urls should be Some");
// The `glb` slot duplicates `model_glb` in this payload (same URL).
let urls_glb = model_urls.glb.expect("model_urls.glb should be Some");
assert_eq!(urls_glb.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1b969/k5FZWmTsKxHH71404bBR__model.glb"));
let urls_obj = model_urls.obj.expect("model_urls.obj should be Some");
assert_eq!(urls_obj.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1b968/xngvKxLugXUlNh89Fp0K4_model.obj"));
assert_eq!(urls_obj.content_type.as_deref(), Some("text/plain"));
assert_eq!(urls_obj.file_name.as_deref(), Some("model.obj"));
assert_eq!(urls_obj.file_size, Some(41107268));
assert!(model_urls.fbx.is_none());
assert!(model_urls.mtl.is_none());
assert!(model_urls.texture.is_none());
assert!(model_urls.usdz.is_none());
let thumbnail = contents.thumbnail.expect("thumbnail should be Some");
assert_eq!(thumbnail.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1b969/EPgha8Kee7YZCAccjFUU7_preview.png"));
assert_eq!(thumbnail.content_type.as_deref(), Some("image/png"));
assert_eq!(thumbnail.file_name.as_deref(), Some("preview.png"));
assert_eq!(thumbnail.file_size, Some(136476));
assert!(contents.model_glb_pbr.is_none());
assert!(contents.model_mesh.is_none());
assert!(contents.preprocessed_image.is_none());
}
#[test]
fn model_urls_and_thumbnail_from_hunyuan_3d_3p1_test_file() {
let webhook = load_test_webhook("success/hunyuan_3d_3p1_model_urls_thumbnail_payload_1.json");
let result = hydrate_webhook_contents(&webhook);
let HydratedWebhookContents::Success(data) = result else {
panic!("Expected Success, got {:?}", result);
};
let contents = data.extracted_contents
.expect("extracted_contents should be Some");
let glb = contents.model_glb.expect("model_glb should be Some");
assert_eq!(glb.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1ba44/OdgIv8M9EkaBP_Mh4TVAB_model.glb"));
assert_eq!(glb.content_type.as_deref(), Some("model/gltf-binary"));
assert_eq!(glb.file_name.as_deref(), Some("model.glb"));
assert_eq!(glb.file_size, Some(64261364));
let model_urls = contents.model_urls.expect("model_urls should be Some");
// The `glb` slot duplicates `model_glb` in this payload (same URL).
let urls_glb = model_urls.glb.expect("model_urls.glb should be Some");
assert_eq!(urls_glb.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1ba44/OdgIv8M9EkaBP_Mh4TVAB_model.glb"));
let urls_obj = model_urls.obj.expect("model_urls.obj should be Some");
assert_eq!(urls_obj.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1ba43/WxPMNbzsDxaGRCmGNgNAL_c3a871e997ecd5f889bf671630de23b4.obj"));
assert_eq!(urls_obj.content_type.as_deref(), Some("model/obj"));
assert_eq!(urls_obj.file_size, Some(34563830));
// 3.1 additionally ships the OBJ's material and PBR texture files.
let urls_mtl = model_urls.mtl.expect("model_urls.mtl should be Some");
assert_eq!(urls_mtl.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1ba42/1T5VCp_xD0zCJlTLE96iK_material.mtl"));
assert_eq!(urls_mtl.content_type.as_deref(), Some("text/plain"));
assert_eq!(urls_mtl.file_name.as_deref(), Some("material.mtl"));
assert_eq!(urls_mtl.file_size, Some(245));
let urls_texture = model_urls.texture.expect("model_urls.texture should be Some");
assert_eq!(urls_texture.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1ba43/aKp3mMB9nrhlKfV2KVf0S_texture_pbr_20250901.png"));
assert_eq!(urls_texture.content_type.as_deref(), Some("image/png"));
assert_eq!(urls_texture.file_name.as_deref(), Some("texture_pbr_20250901.png"));
assert_eq!(urls_texture.file_size, Some(22794575));
assert!(model_urls.fbx.is_none());
assert!(model_urls.usdz.is_none());
// The thumbnail is attached to the GLB as its cover image downstream.
let thumbnail = contents.thumbnail.expect("thumbnail should be Some");
assert_eq!(thumbnail.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1ba44/njq8zYhYahJNNQkGGiEZW_preview.png"));
assert_eq!(thumbnail.content_type.as_deref(), Some("image/png"));
assert_eq!(thumbnail.file_name.as_deref(), Some("preview.png"));
assert_eq!(thumbnail.file_size, Some(141254));
assert!(contents.model_glb_pbr.is_none());
assert!(contents.model_mesh.is_none());
assert!(contents.preprocessed_image.is_none());
}
#[test]
fn obj_and_thumbnail_from_hunyuan_3d_3p1_rapid_test_file() {
let webhook = load_test_webhook("success/hunyuan_3d_3p1_rapid_obj_payload_1.json");
let result = hydrate_webhook_contents(&webhook);
let HydratedWebhookContents::Success(data) = result else {
panic!("Expected Success, got {:?}", result);
};
let contents = data.extracted_contents
.expect("extracted_contents should be Some");
// NB: Hunyuan 3D 3.1 Rapid puts an OBJ under the `model_glb` key. The
// key name doesn't matter downstream: the webhook handler resolves the
// real file type from content_type / file name, so it uploads as an OBJ
// mesh.
let glb = contents.model_glb.expect("model_glb should be Some");
assert_eq!(glb.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bae3/22zStkYp8-q6npG9dsMs5_23dce35fc95b34722f15a7329061d5df.obj"));
assert_eq!(glb.content_type.as_deref(), Some("model/obj"));
assert_eq!(glb.file_name.as_deref(), Some("23dce35fc95b34722f15a7329061d5df.obj"));
assert_eq!(glb.file_size, Some(3258750));
let model_urls = contents.model_urls.expect("model_urls should be Some");
// The `glb` slot is null here; `obj` duplicates `model_glb` (same URL).
assert!(model_urls.glb.is_none());
assert!(model_urls.fbx.is_none());
assert!(model_urls.usdz.is_none());
let urls_obj = model_urls.obj.expect("model_urls.obj should be Some");
assert_eq!(urls_obj.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bae3/22zStkYp8-q6npG9dsMs5_23dce35fc95b34722f15a7329061d5df.obj"));
assert_eq!(urls_obj.content_type.as_deref(), Some("model/obj"));
assert_eq!(urls_obj.file_size, Some(3258750));
let urls_mtl = model_urls.mtl.expect("model_urls.mtl should be Some");
assert_eq!(urls_mtl.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bae3/5cbE-q28i44GUOO6YeZPH_material.mtl"));
assert_eq!(urls_mtl.file_size, Some(229));
let urls_texture = model_urls.texture.expect("model_urls.texture should be Some");
assert_eq!(urls_texture.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bae3/7eBRddTHf6U8SUg2QOIHw_texture_pbr_v128.png"));
assert_eq!(urls_texture.file_name.as_deref(), Some("texture_pbr_v128.png"));
assert_eq!(urls_texture.file_size, Some(5503289));
// The thumbnail is attached to the mesh as its cover image downstream.
let thumbnail = contents.thumbnail.expect("thumbnail should be Some");
assert_eq!(thumbnail.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bae3/IO3aspD4ijEadoFxSF504_preview.png"));
assert_eq!(thumbnail.content_type.as_deref(), Some("image/png"));
assert_eq!(thumbnail.file_name.as_deref(), Some("preview.png"));
assert_eq!(thumbnail.file_size, Some(120530));
assert!(contents.model_glb_pbr.is_none());
assert!(contents.model_mesh.is_none());
assert!(contents.model_obj.is_none());
assert!(contents.preprocessed_image.is_none());
}
#[test]
fn model_urls_and_rendered_image_from_tripo3d_test_file() {
let webhook = load_test_webhook("success/tripo3d_model_urls_rendered_image_payload_1.json");
let result = hydrate_webhook_contents(&webhook);
let HydratedWebhookContents::Success(data) = result else {
panic!("Expected Success, got {:?}", result);
};
let contents = data.extracted_contents
.expect("extracted_contents should be Some");
// Tripo 3D sends its GLB under `model_mesh` (not `model_glb`); the
// webhook handler picks `model_urls.glb` as the primary GLB instead.
assert!(contents.model_glb.is_none());
let mesh = contents.model_mesh.expect("model_mesh should be Some");
assert_eq!(mesh.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbdb/d8ATWvvXYHY-8zj-lQecf_model.glb"));
assert_eq!(mesh.content_type.as_deref(), Some("model/gltf-binary"));
let model_urls = contents.model_urls.expect("model_urls should be Some");
// `glb` and `pbr_model` duplicate each other (same URL) in this payload;
// the webhook handler dedupes by URL and uploads the file only once.
let urls_glb = model_urls.glb.expect("model_urls.glb should be Some");
assert_eq!(urls_glb.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbdb/d8ATWvvXYHY-8zj-lQecf_model.glb"));
assert_eq!(urls_glb.file_size, Some(43981280));
let urls_pbr_model = model_urls.pbr_model.expect("model_urls.pbr_model should be Some");
assert_eq!(urls_pbr_model.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbdb/d8ATWvvXYHY-8zj-lQecf_model.glb"));
assert!(model_urls.base_model.is_none());
assert!(model_urls.fbx.is_none());
assert!(model_urls.mtl.is_none());
assert!(model_urls.obj.is_none());
assert!(model_urls.texture.is_none());
assert!(model_urls.usdz.is_none());
// The preview arrives as `rendered_image` (no `thumbnail` key) and is
// attached to the mesh as its cover image downstream.
let rendered_image = contents.rendered_image.expect("rendered_image should be Some");
assert_eq!(rendered_image.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbd0/MblV2S5R6CeskRABMS7-V_preview.png"));
assert!(contents.thumbnail.is_none());
assert!(contents.model_glb_pbr.is_none());
assert!(contents.preprocessed_image.is_none());
}
#[test]
fn model_urls_and_thumbnail_from_meshy_test_file() {
let webhook = load_test_webhook("success/meshy_model_urls_thumbnail_payload_1.json");
let result = hydrate_webhook_contents(&webhook);
let HydratedWebhookContents::Success(data) = result else {
panic!("Expected Success, got {:?}", result);
};
let contents = data.extracted_contents
.expect("extracted_contents should be Some");
let glb = contents.model_glb.expect("model_glb should be Some");
assert_eq!(glb.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbe1/0vrf9NUhVxKZO4GScA0EI_model.glb"));
assert_eq!(glb.content_type.as_deref(), Some("model/gltf-binary"));
assert_eq!(glb.file_name.as_deref(), Some("model.glb"));
assert_eq!(glb.file_size, Some(15943768));
let model_urls = contents.model_urls.expect("model_urls should be Some");
// The `glb` slot duplicates `model_glb` (same URL); the webhook handler
// dedupes by URL so the file uploads only once.
let urls_glb = model_urls.glb.expect("model_urls.glb should be Some");
assert_eq!(urls_glb.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbe1/0vrf9NUhVxKZO4GScA0EI_model.glb"));
// Alternate formats of the same model; parsed but not uploaded.
let urls_fbx = model_urls.fbx.expect("model_urls.fbx should be Some");
assert_eq!(urls_fbx.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbed/yKO-HOjBnWA-LZpRLHHSB_model.fbx"));
assert_eq!(urls_fbx.file_size, Some(13665964));
let urls_obj = model_urls.obj.expect("model_urls.obj should be Some");
assert_eq!(urls_obj.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbe2/NBmqIWBS8LuY_BPYWkOOf_model.obj"));
let urls_stl = model_urls.stl.expect("model_urls.stl should be Some");
assert_eq!(urls_stl.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbe2/3wtOE6iEeVHZFvwAGN6Rl_model.stl"));
assert_eq!(urls_stl.content_type.as_deref(), Some("application/octet-stream"));
assert_eq!(urls_stl.file_size, Some(1508384));
let urls_usdz = model_urls.usdz.expect("model_urls.usdz should be Some");
assert_eq!(urls_usdz.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbed/BsniPBo6Uev85V4hLFACR_model.usdz"));
assert_eq!(urls_usdz.content_type.as_deref(), Some("model/vnd.usdz+zip"));
assert!(model_urls.base_model.is_none());
assert!(model_urls.blend.is_none());
assert!(model_urls.mtl.is_none());
assert!(model_urls.pbr_model.is_none());
assert!(model_urls.texture.is_none());
// The thumbnail is attached to the GLB as its cover image downstream.
let thumbnail = contents.thumbnail.expect("thumbnail should be Some");
assert_eq!(thumbnail.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbe3/tn1qfOr12oGXecA4mRtlI_preview.png"));
assert_eq!(thumbnail.content_type.as_deref(), Some("image/png"));
assert_eq!(thumbnail.file_name.as_deref(), Some("preview.png"));
assert_eq!(thumbnail.file_size, Some(192910));
assert!(contents.model_glb_pbr.is_none());
assert!(contents.model_mesh.is_none());
assert!(contents.rendered_image.is_none());
assert!(contents.preprocessed_image.is_none());
}
#[test]
fn synthetic_model_urls_payload_with_distinct_glb() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"model_urls": {
"fbx": null,
"glb": {
"url": "https://cdn.example.com/model_alt.glb",
"content_type": "model/gltf-binary",
"file_name": "model_alt.glb",
"file_size": 1234567
},
"obj": null,
"usdz": null
}
}"#).unwrap();
let model_urls = extract_model_urls(&obj).expect("should extract model_urls");
let glb = model_urls.glb.expect("glb slot should be Some");
assert_eq!(glb.url.as_deref(), Some("https://cdn.example.com/model_alt.glb"));
assert_eq!(glb.file_size, Some(1234567));
assert!(model_urls.fbx.is_none());
assert!(model_urls.obj.is_none());
assert!(model_urls.usdz.is_none());
}
#[test]
fn missing_model_urls_key_returns_none() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"model_glb": {"url": "https://example.com/model.glb"}
}"#).unwrap();
assert!(extract_model_urls(&obj).is_none());
}
}
@@ -0,0 +1,84 @@
use serde_json::{Map, Value};
use crate::webhook_payload::hydrated::hydrated_webhook_contents::PreprocessedImageData;
/// Extract and deserialize the `preprocessed_image` key from a webhook success payload.
pub(crate) fn extract_preprocessed_image(obj: &Map<String, Value>) -> Option<PreprocessedImageData> {
let value = obj.get("preprocessed_image")?;
serde_json::from_value(value.clone()).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::webhook_payload::hydrate_webhook_contents::hydrate_webhook_contents;
use crate::webhook_payload::hydrated::hydrated_webhook_contents::HydratedWebhookContents;
use crate::webhook_payload::raw::raw_webhook_payload::RawWebhookPayload;
fn load_test_webhook(filename: &str) -> RawWebhookPayload {
let path = format!("test_data/webhooks/{}", filename);
let json = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("Failed to read {}: {}", path, e));
serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("Failed to parse {}: {}", path, e))
}
#[test]
fn preprocessed_image_from_triposplat_test_file() {
let webhook = load_test_webhook("success/triposplat_splat_payload_1.json");
let result = hydrate_webhook_contents(&webhook);
let HydratedWebhookContents::Success(data) = result else {
panic!("Expected Success, got {:?}", result);
};
let contents = data.extracted_contents
.expect("extracted_contents should be Some");
let image = contents.preprocessed_image.expect("preprocessed_image should be Some");
assert_eq!(image.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1b77c/YyJ19g7lJsRNXmvZvahTE_61fc73dbe84446b08b1fcce2167b71d5.png"));
assert_eq!(image.content_type.as_deref(), Some("image/png"));
assert_eq!(image.file_name.as_deref(), Some("61fc73dbe84446b08b1fcce2167b71d5.png"));
assert_eq!(image.file_size, Some(952754));
assert_eq!(image.width, Some(1024));
assert_eq!(image.height, Some(1024));
// The splat itself arrives under `model_mesh`.
let mesh = contents.model_mesh.expect("model_mesh should be Some");
assert_eq!(mesh.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1b77c/hEzg5xn9A-1qrLsnywjN4_output.ply"));
assert_eq!(mesh.content_type.as_deref(), Some("application/octet-stream"));
assert_eq!(mesh.file_name.as_deref(), Some("output.ply"));
assert_eq!(mesh.file_size, Some(17826208));
}
#[test]
fn synthetic_preprocessed_image_payload() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"preprocessed_image": {
"url": "https://cdn.example.com/segmented.png",
"content_type": "image/png",
"file_name": "segmented.png",
"file_size": 12345,
"width": 1024,
"height": 1024
}
}"#).unwrap();
let image = extract_preprocessed_image(&obj).expect("should extract preprocessed_image");
assert_eq!(image.url.as_deref(), Some("https://cdn.example.com/segmented.png"));
assert_eq!(image.content_type.as_deref(), Some("image/png"));
assert_eq!(image.file_name.as_deref(), Some("segmented.png"));
assert_eq!(image.file_size, Some(12345));
assert_eq!(image.width, Some(1024));
assert_eq!(image.height, Some(1024));
}
#[test]
fn missing_preprocessed_image_key_returns_none() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"model_mesh": {"url": "https://example.com/output.ply"}
}"#).unwrap();
assert!(extract_preprocessed_image(&obj).is_none());
}
}
@@ -0,0 +1,76 @@
use serde_json::{Map, Value};
use crate::webhook_payload::hydrated::hydrated_webhook_contents::ThumbnailData;
/// Extract and deserialize the `rendered_image` key from a webhook success
/// payload (e.g. Tripo 3D's preview image). Same shape as `thumbnail`, and
/// used the same way: as a cover image for the model.
pub(crate) fn extract_rendered_image(obj: &Map<String, Value>) -> Option<ThumbnailData> {
let value = obj.get("rendered_image")?;
serde_json::from_value(value.clone()).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::webhook_payload::hydrate_webhook_contents::hydrate_webhook_contents;
use crate::webhook_payload::hydrated::hydrated_webhook_contents::HydratedWebhookContents;
use crate::webhook_payload::raw::raw_webhook_payload::RawWebhookPayload;
fn load_test_webhook(filename: &str) -> RawWebhookPayload {
let path = format!("test_data/webhooks/{}", filename);
let json = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("Failed to read {}: {}", path, e));
serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("Failed to parse {}: {}", path, e))
}
#[test]
fn rendered_image_from_tripo3d_test_file() {
let webhook = load_test_webhook("success/tripo3d_model_urls_rendered_image_payload_1.json");
let result = hydrate_webhook_contents(&webhook);
let HydratedWebhookContents::Success(data) = result else {
panic!("Expected Success, got {:?}", result);
};
let contents = data.extracted_contents
.expect("extracted_contents should be Some");
let rendered_image = contents.rendered_image.expect("rendered_image should be Some");
assert_eq!(rendered_image.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbd0/MblV2S5R6CeskRABMS7-V_preview.png"));
assert_eq!(rendered_image.content_type.as_deref(), Some("image/png"));
assert_eq!(rendered_image.file_name.as_deref(), Some("preview.png"));
assert_eq!(rendered_image.file_size, Some(30970));
// Tripo has no `thumbnail` key; the preview only arrives here.
assert!(contents.thumbnail.is_none());
}
#[test]
fn synthetic_rendered_image_payload() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"rendered_image": {
"url": "https://cdn.example.com/preview.png",
"content_type": "image/png",
"file_name": "preview.png",
"file_size": 12345
}
}"#).unwrap();
let image = extract_rendered_image(&obj).expect("should extract rendered_image");
assert_eq!(image.url.as_deref(), Some("https://cdn.example.com/preview.png"));
assert_eq!(image.content_type.as_deref(), Some("image/png"));
assert_eq!(image.file_name.as_deref(), Some("preview.png"));
assert_eq!(image.file_size, Some(12345));
}
#[test]
fn missing_rendered_image_key_returns_none() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"thumbnail": {"url": "https://example.com/preview.png"}
}"#).unwrap();
assert!(extract_rendered_image(&obj).is_none());
}
}
@@ -1,9 +1,14 @@
pub mod extract_audio_payload;
pub mod extract_image_payload;
pub mod extract_images_payload;
pub mod extract_video_payload;
pub mod extract_model_glb_payload;
pub mod extract_model_glb_pbr_payload;
pub mod extract_model_mesh_payload;
pub mod extract_model_obj_payload;
pub mod extract_model_urls_payload;
pub mod extract_preprocessed_image_payload;
pub mod extract_rendered_image_payload;
pub mod extract_result_files_payload;
pub mod extract_thumbnail_payload;
@@ -14,7 +19,9 @@ 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_mesh`. If at least one is found, returns
/// `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
/// not an object or none of the known keys are present.
@@ -24,15 +31,22 @@ pub fn extract_contents_from_payload(payload: &Value) -> Option<ExtractedContent
let image = extract_image_payload::extract_image(obj);
let images = extract_images_payload::extract_images(obj);
let video = extract_video_payload::extract_video(obj);
let audio = extract_audio_payload::extract_audio(obj);
let model_glb = extract_model_glb_payload::extract_model_glb(obj);
let model_glb_pbr = extract_model_glb_pbr_payload::extract_model_glb_pbr(obj);
let model_urls = extract_model_urls_payload::extract_model_urls(obj);
let model_mesh = extract_model_mesh_payload::extract_model_mesh(obj);
let model_obj = extract_model_obj_payload::extract_model_obj(obj);
let result_files = extract_result_files_payload::extract_result_files(obj);
let thumbnail = extract_thumbnail_payload::extract_thumbnail(obj);
let rendered_image = extract_rendered_image_payload::extract_rendered_image(obj);
let preprocessed_image = extract_preprocessed_image_payload::extract_preprocessed_image(obj);
if image.is_none() && images.is_none() && video.is_none()
&& model_glb.is_none() && model_mesh.is_none() && model_obj.is_none()
&& result_files.is_none() && thumbnail.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()
&& preprocessed_image.is_none()
{
return None;
}
@@ -41,10 +55,15 @@ pub fn extract_contents_from_payload(payload: &Value) -> Option<ExtractedContent
image,
images,
video,
audio,
model_glb,
model_glb_pbr,
model_urls,
model_mesh,
model_obj,
result_files,
thumbnail,
rendered_image,
preprocessed_image,
})
}
@@ -19,6 +19,12 @@ pub enum WebhookErrorType {
ImageLoadError,
FileDownloadError,
FaceDetectionError,
/// Generic input validation error. The `msg` field carries the specifics
/// (e.g. TripoSplat's "No foreground subject could be detected...").
InputValueError,
/// Generic validation error. The `msg` field carries the specifics (e.g.
/// Hunyuan 3D Part's "Part generation only supports FBX format...").
ValueError,
GreaterThan,
GreaterThanEqual,
LessThan,
@@ -64,6 +70,8 @@ mod tests {
("\"image_load_error\"", WebhookErrorType::ImageLoadError),
("\"file_download_error\"", WebhookErrorType::FileDownloadError),
("\"face_detection_error\"", WebhookErrorType::FaceDetectionError),
("\"input_value_error\"", WebhookErrorType::InputValueError),
("\"value_error\"", WebhookErrorType::ValueError),
("\"greater_than\"", WebhookErrorType::GreaterThan),
("\"greater_than_equal\"", WebhookErrorType::GreaterThanEqual),
("\"less_than\"", WebhookErrorType::LessThan),
@@ -95,6 +103,8 @@ mod tests {
fn from_str_known() {
assert_eq!(WebhookErrorType::from_str("content_policy_violation"), WebhookErrorType::ContentPolicyViolation);
assert_eq!(WebhookErrorType::from_str("face_detection_error"), WebhookErrorType::FaceDetectionError);
assert_eq!(WebhookErrorType::from_str("input_value_error"), WebhookErrorType::InputValueError);
assert_eq!(WebhookErrorType::from_str("value_error"), WebhookErrorType::ValueError);
}
#[test]
@@ -0,0 +1,18 @@
{
"error": "Unexpected status code: 422",
"gateway_request_id": "019f4dad-7ff7-7902-b206-cb11bf1ef843",
"payload": {
"detail": [
{
"loc": [
"body",
"input_file_url"
],
"msg": "Part generation only supports FBX format. Please provide an FBX file or use /convert-format to convert your model to FBX first.",
"type": "value_error"
}
]
},
"request_id": "019f4dad-7ff7-7902-b206-cb11bf1ef843",
"status": "ERROR"
}
@@ -0,0 +1,19 @@
{
"error": "Unexpected status code: 422",
"gateway_request_id": "019f4d0e-3f44-7ed1-bb12-f2f834bdde7b",
"payload": {
"detail": [
{
"input": "https://cdn-2.fakeyou.com/media/f/d/6/f/6/fd6f6czv21gkrdqhptz6nj072wh7w06y/artcraft_fd6f6czv21gkrdqhptz6nj072wh7w06y.png",
"loc": [
"body"
],
"msg": "No foreground subject could be detected in the input image after background removal. Provide an image with a clear, visible subject against a plain or distinct background.",
"type": "input_value_error",
"url": "https://docs.fal.ai/errors#input_value_error"
}
]
},
"request_id": "019f4d0e-3f44-7ed1-bb12-f2f834bdde7b",
"status": "ERROR"
}
@@ -0,0 +1,27 @@
{
"error": null,
"gateway_request_id": "019f4d49-6859-77b3-aaac-3b91b248481a",
"payload": {
"model_glb": {
"content_type": "application/octet-stream",
"file_name": "demo_textured.glb",
"file_size": 1574216,
"url": "https://v3b.fal.media/files/b/0aa1b916/QKdBTd8WJW_rtPB3IwlEN_demo_textured.glb"
},
"model_glb_pbr": {
"content_type": "application/octet-stream",
"file_name": "demo_textured_pbr.glb",
"file_size": 7512760,
"url": "https://v3b.fal.media/files/b/0aa1b921/SJw7AARzkc4l9QG5Lrvmu_demo_textured_pbr.glb"
},
"model_mesh": {
"content_type": "application/octet-stream",
"file_name": "3d_model.zip",
"file_size": 16332900,
"url": "https://v3b.fal.media/files/b/0aa1b915/B25DrTdiZJNV-60RaUh4u_3d_model.zip"
},
"seed": 283904
},
"request_id": "019f4d49-6859-77b3-aaac-3b91b248481a",
"status": "OK"
}
@@ -0,0 +1,37 @@
{
"error": null,
"gateway_request_id": "019f4d58-0e11-7aa0-aa79-e0294bf9ff16",
"payload": {
"model_glb": {
"content_type": "model/gltf-binary",
"file_name": "model.glb",
"file_size": 51001160,
"url": "https://v3b.fal.media/files/b/0aa1b969/k5FZWmTsKxHH71404bBR__model.glb"
},
"model_urls": {
"fbx": null,
"glb": {
"content_type": "model/gltf-binary",
"file_name": "model.glb",
"file_size": 51001160,
"url": "https://v3b.fal.media/files/b/0aa1b969/k5FZWmTsKxHH71404bBR__model.glb"
},
"obj": {
"content_type": "text/plain",
"file_name": "model.obj",
"file_size": 41107268,
"url": "https://v3b.fal.media/files/b/0aa1b968/xngvKxLugXUlNh89Fp0K4_model.obj"
},
"usdz": null
},
"seed": null,
"thumbnail": {
"content_type": "image/png",
"file_name": "preview.png",
"file_size": 136476,
"url": "https://v3b.fal.media/files/b/0aa1b969/EPgha8Kee7YZCAccjFUU7_preview.png"
}
},
"request_id": "019f4d58-0e11-7aa0-aa79-e0294bf9ff16",
"status": "OK"
}
@@ -0,0 +1,49 @@
{
"error": null,
"gateway_request_id": "019f4d79-35fd-7462-85be-359c8232f56e",
"payload": {
"model_glb": {
"content_type": "model/gltf-binary",
"file_name": "model.glb",
"file_size": 64261364,
"url": "https://v3b.fal.media/files/b/0aa1ba44/OdgIv8M9EkaBP_Mh4TVAB_model.glb"
},
"model_urls": {
"fbx": null,
"glb": {
"content_type": "model/gltf-binary",
"file_name": "model.glb",
"file_size": 64261364,
"url": "https://v3b.fal.media/files/b/0aa1ba44/OdgIv8M9EkaBP_Mh4TVAB_model.glb"
},
"mtl": {
"content_type": "text/plain",
"file_name": "material.mtl",
"file_size": 245,
"url": "https://v3b.fal.media/files/b/0aa1ba42/1T5VCp_xD0zCJlTLE96iK_material.mtl"
},
"obj": {
"content_type": "model/obj",
"file_name": "c3a871e997ecd5f889bf671630de23b4.obj",
"file_size": 34563830,
"url": "https://v3b.fal.media/files/b/0aa1ba43/WxPMNbzsDxaGRCmGNgNAL_c3a871e997ecd5f889bf671630de23b4.obj"
},
"texture": {
"content_type": "image/png",
"file_name": "texture_pbr_20250901.png",
"file_size": 22794575,
"url": "https://v3b.fal.media/files/b/0aa1ba43/aKp3mMB9nrhlKfV2KVf0S_texture_pbr_20250901.png"
},
"usdz": null
},
"seed": null,
"thumbnail": {
"content_type": "image/png",
"file_name": "preview.png",
"file_size": 141254,
"url": "https://v3b.fal.media/files/b/0aa1ba44/njq8zYhYahJNNQkGGiEZW_preview.png"
}
},
"request_id": "019f4d79-35fd-7462-85be-359c8232f56e",
"status": "OK"
}
@@ -0,0 +1,55 @@
{
"error": null,
"gateway_request_id": "019f4d93-5a68-7a23-b2e7-8a1bc55e50f8",
"payload": {
"material_mtl": {
"content_type": "text/plain",
"file_name": "material.mtl",
"file_size": 229,
"url": "https://v3b.fal.media/files/b/0aa1bae3/5cbE-q28i44GUOO6YeZPH_material.mtl"
},
"model_glb": {
"content_type": "model/obj",
"file_name": "23dce35fc95b34722f15a7329061d5df.obj",
"file_size": 3258750,
"url": "https://v3b.fal.media/files/b/0aa1bae3/22zStkYp8-q6npG9dsMs5_23dce35fc95b34722f15a7329061d5df.obj"
},
"model_urls": {
"fbx": null,
"glb": null,
"mtl": {
"content_type": "text/plain",
"file_name": "material.mtl",
"file_size": 229,
"url": "https://v3b.fal.media/files/b/0aa1bae3/5cbE-q28i44GUOO6YeZPH_material.mtl"
},
"obj": {
"content_type": "model/obj",
"file_name": "23dce35fc95b34722f15a7329061d5df.obj",
"file_size": 3258750,
"url": "https://v3b.fal.media/files/b/0aa1bae3/22zStkYp8-q6npG9dsMs5_23dce35fc95b34722f15a7329061d5df.obj"
},
"texture": {
"content_type": "image/png",
"file_name": "texture_pbr_v128.png",
"file_size": 5503289,
"url": "https://v3b.fal.media/files/b/0aa1bae3/7eBRddTHf6U8SUg2QOIHw_texture_pbr_v128.png"
},
"usdz": null
},
"texture": {
"content_type": "image/png",
"file_name": "texture_pbr_v128.png",
"file_size": 5503289,
"url": "https://v3b.fal.media/files/b/0aa1bae3/7eBRddTHf6U8SUg2QOIHw_texture_pbr_v128.png"
},
"thumbnail": {
"content_type": "image/png",
"file_name": "preview.png",
"file_size": 120530,
"url": "https://v3b.fal.media/files/b/0aa1bae3/IO3aspD4ijEadoFxSF504_preview.png"
}
},
"request_id": "019f4d93-5a68-7a23-b2e7-8a1bc55e50f8",
"status": "OK"
}
@@ -0,0 +1,88 @@
{
"error": null,
"gateway_request_id": "019f4db7-e55c-78d1-9719-0303ca50a961",
"payload": {
"animation_fbx": null,
"animation_glb": null,
"basic_animations": null,
"model_glb": {
"content_type": "model/gltf-binary",
"file_name": "model.glb",
"file_size": 15943768,
"url": "https://v3b.fal.media/files/b/0aa1bbe1/0vrf9NUhVxKZO4GScA0EI_model.glb"
},
"model_urls": {
"blend": null,
"fbx": {
"content_type": "application/octet-stream",
"file_name": "model.fbx",
"file_size": 13665964,
"url": "https://v3b.fal.media/files/b/0aa1bbed/yKO-HOjBnWA-LZpRLHHSB_model.fbx"
},
"glb": {
"content_type": "model/gltf-binary",
"file_name": "model.glb",
"file_size": 15943768,
"url": "https://v3b.fal.media/files/b/0aa1bbe1/0vrf9NUhVxKZO4GScA0EI_model.glb"
},
"obj": {
"content_type": "text/plain",
"file_name": "model.obj",
"file_size": 3083671,
"url": "https://v3b.fal.media/files/b/0aa1bbe2/NBmqIWBS8LuY_BPYWkOOf_model.obj"
},
"stl": {
"content_type": "application/octet-stream",
"file_name": "model.stl",
"file_size": 1508384,
"url": "https://v3b.fal.media/files/b/0aa1bbe2/3wtOE6iEeVHZFvwAGN6Rl_model.stl"
},
"usdz": {
"content_type": "model/vnd.usdz+zip",
"file_name": "model.usdz",
"file_size": 16861118,
"url": "https://v3b.fal.media/files/b/0aa1bbed/BsniPBo6Uev85V4hLFACR_model.usdz"
}
},
"rig_task_id": null,
"rigged_character_fbx": null,
"rigged_character_glb": null,
"seed": 274321742,
"texture_urls": [
{
"base_color": {
"content_type": "image/png",
"file_name": "texture_0.png",
"file_size": 7539838,
"url": "https://v3b.fal.media/files/b/0aa1bbe3/EXWiAuAYPobYiJPnW66mQ_texture_0.png"
},
"metallic": {
"content_type": "image/png",
"file_name": "texture_0_metallic.png",
"file_size": 858941,
"url": "https://v3b.fal.media/files/b/0aa1bbee/RTZIZRCDQTvN7jdl4EL_C_texture_0_metallic.png"
},
"normal": {
"content_type": "image/png",
"file_name": "texture_0_normal.png",
"file_size": 8236038,
"url": "https://v3b.fal.media/files/b/0aa1bbe3/yNk-S5Dhw2xc9fb3fIjwD_texture_0_normal.png"
},
"roughness": {
"content_type": "image/png",
"file_name": "texture_0_roughness.png",
"file_size": 1184908,
"url": "https://v3b.fal.media/files/b/0aa1bbe3/nnT25g7wgul2oDTRlI_jX_texture_0_roughness.png"
}
}
],
"thumbnail": {
"content_type": "image/png",
"file_name": "preview.png",
"file_size": 192910,
"url": "https://v3b.fal.media/files/b/0aa1bbe3/tn1qfOr12oGXecA4mRtlI_preview.png"
}
},
"request_id": "019f4db7-e55c-78d1-9719-0303ca50a961",
"status": "OK"
}
@@ -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,36 @@
{
"error": null,
"gateway_request_id": "019f4db5-aadd-76c3-8122-ff1ba53fe48c",
"payload": {
"model_mesh": {
"content_type": "model/gltf-binary",
"file_name": "model.glb",
"file_size": 43981280,
"url": "https://v3b.fal.media/files/b/0aa1bbdb/d8ATWvvXYHY-8zj-lQecf_model.glb"
},
"model_urls": {
"base_model": null,
"fbx": null,
"glb": {
"content_type": "model/gltf-binary",
"file_name": "model.glb",
"file_size": 43981280,
"url": "https://v3b.fal.media/files/b/0aa1bbdb/d8ATWvvXYHY-8zj-lQecf_model.glb"
},
"pbr_model": {
"content_type": "model/gltf-binary",
"file_name": "model.glb",
"file_size": 43981280,
"url": "https://v3b.fal.media/files/b/0aa1bbdb/d8ATWvvXYHY-8zj-lQecf_model.glb"
}
},
"rendered_image": {
"content_type": "image/png",
"file_name": "preview.png",
"file_size": 30970,
"url": "https://v3b.fal.media/files/b/0aa1bbd0/MblV2S5R6CeskRABMS7-V_preview.png"
}
},
"request_id": "019f4db5-aadd-76c3-8122-ff1ba53fe48c",
"status": "OK"
}
@@ -0,0 +1,28 @@
{
"error": null,
"gateway_request_id": "019f4d0f-4cf2-76d3-b99b-c400641b3844",
"payload": {
"model_mesh": {
"content_type": "application/octet-stream",
"file_name": "output.ply",
"file_size": 17826208,
"url": "https://v3b.fal.media/files/b/0aa1b77c/hEzg5xn9A-1qrLsnywjN4_output.ply"
},
"num_gaussians": 262144,
"preprocessed_image": {
"content_type": "image/png",
"file_name": "61fc73dbe84446b08b1fcce2167b71d5.png",
"file_size": 952754,
"height": 1024,
"url": "https://v3b.fal.media/files/b/0aa1b77c/YyJ19g7lJsRNXmvZvahTE_61fc73dbe84446b08b1fcce2167b71d5.png",
"width": 1024
},
"seed": 1637204116,
"timings": {
"export": 0.794984320000367,
"inference": 4.911052849000043
}
},
"request_id": "019f4d0f-4cf2-76d3-b99b-c400641b3844",
"status": "OK"
}
@@ -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(&params).unwrap(),
r#"{"prompt":"An anime girl riding a dinosaur","aspectRatio":"auto","resolution":"2k","model":"seedream-5.0-pro"}"#,
);
}
/// Mirrors capture 2_seedream_5_21x9.txt (21:9, 2k, single image).
#[test]
fn ultra_wide_21x9_2k() {
let params = build_api_params(&GenerateSeedream5p0ProRequest {
prompt: "a pirate sailing a pirate ship".to_string(),
aspect_ratio: KinoviSeedream5p0ProAspectRatio::UltraWide21x9,
resolution: KinoviSeedream5p0ProResolution::TwoK,
batch_count: KinoviSeedream5p0ProBatchCount::One,
reference_image_urls: None,
}).unwrap();
assert_eq!(
serde_json::to_string(&params).unwrap(),
r#"{"prompt":"a pirate sailing a pirate ship","aspectRatio":"21:9","resolution":"2k","model":"seedream-5.0-pro"}"#,
);
}
/// Mirrors capture 3_seedream_5_9x16_image_refs.txt (9:16, 1k, single
/// image, two reference images).
#[test]
fn portrait_9x16_1k_with_image_references() {
let params = build_api_params(&GenerateSeedream5p0ProRequest {
prompt: "The man in @Image1 is staring out the door in @Image2 ".to_string(),
aspect_ratio: KinoviSeedream5p0ProAspectRatio::Portrait9x16,
resolution: KinoviSeedream5p0ProResolution::OneK,
batch_count: KinoviSeedream5p0ProBatchCount::One,
reference_image_urls: Some(vec![
"https://static.seedance2-pro.com/materials/20260713/1783904468032-0a803390.jpg".to_string(),
"https://static.seedance2-pro.com/materials/20260713/1783904481112-4a3fa0d2.png".to_string(),
]),
}).unwrap();
assert_eq!(
serde_json::to_string(&params).unwrap(),
r#"{"prompt":"The man in @Image1 is staring out the door in @Image2 ","aspectRatio":"9:16","resolution":"1k","model":"seedream-5.0-pro","uploadedUrls":["https://static.seedance2-pro.com/materials/20260713/1783904468032-0a803390.jpg","https://static.seedance2-pro.com/materials/20260713/1783904481112-4a3fa0d2.png"]}"#,
);
}
/// Mirrors capture 4_seedream_5_misc.txt (2:3, 2k, batch of 4, two
/// reference images).
#[test]
fn tall_2x3_2k_batch_four_with_image_references() {
let params = build_api_params(&GenerateSeedream5p0ProRequest {
prompt: "An anime girl is standing in the door @Image1 Outside the door, there's the galaxy and windmill @Image2 ".to_string(),
aspect_ratio: KinoviSeedream5p0ProAspectRatio::Tall2x3,
resolution: KinoviSeedream5p0ProResolution::TwoK,
batch_count: KinoviSeedream5p0ProBatchCount::Four,
reference_image_urls: Some(vec![
"https://static.seedance2-pro.com/materials/20260713/1783904481112-4a3fa0d2.png".to_string(),
"https://static.seedance2-pro.com/materials/20260713/1783904588294-5c713908.jpg".to_string(),
]),
}).unwrap();
assert_eq!(
serde_json::to_string(&params).unwrap(),
r#"{"prompt":"An anime girl is standing in the door @Image1 Outside the door, there's the galaxy and windmill @Image2 ","aspectRatio":"2:3","resolution":"2k","model":"seedream-5.0-pro","batchCount":4,"uploadedUrls":["https://static.seedance2-pro.com/materials/20260713/1783904481112-4a3fa0d2.png","https://static.seedance2-pro.com/materials/20260713/1783904588294-5c713908.jpg"]}"#,
);
}
/// Mirrors capture 5_seedream_5_misc2.txt (1:1, 1k, batch of 8, two
/// reference images).
#[test]
fn square_1x1_1k_batch_eight_with_image_references() {
let params = build_api_params(&GenerateSeedream5p0ProRequest {
prompt: "The mirror from @Image2 is in front of the windmill in @Image1 . The sky and galaxy are lit up at night. ".to_string(),
aspect_ratio: KinoviSeedream5p0ProAspectRatio::Square1x1,
resolution: KinoviSeedream5p0ProResolution::OneK,
batch_count: KinoviSeedream5p0ProBatchCount::Eight,
reference_image_urls: Some(vec![
"https://static.seedance2-pro.com/materials/20260713/1783904715554-1a7ce415.jpg".to_string(),
"https://static.seedance2-pro.com/materials/20260713/1783904719453-1d10e81f.png".to_string(),
]),
}).unwrap();
assert_eq!(
serde_json::to_string(&params).unwrap(),
r#"{"prompt":"The mirror from @Image2 is in front of the windmill in @Image1 . The sky and galaxy are lit up at night. ","aspectRatio":"1:1","resolution":"1k","model":"seedream-5.0-pro","batchCount":8,"uploadedUrls":["https://static.seedance2-pro.com/materials/20260713/1783904715554-1a7ce415.jpg","https://static.seedance2-pro.com/materials/20260713/1783904719453-1d10e81f.png"]}"#,
);
}
#[test]
fn every_aspect_ratio_wire_value() {
let cases = [
(KinoviSeedream5p0ProAspectRatio::Auto, "auto"),
(KinoviSeedream5p0ProAspectRatio::Square1x1, "1:1"),
(KinoviSeedream5p0ProAspectRatio::Standard4x3, "4:3"),
(KinoviSeedream5p0ProAspectRatio::Portrait3x4, "3:4"),
(KinoviSeedream5p0ProAspectRatio::Landscape16x9, "16:9"),
(KinoviSeedream5p0ProAspectRatio::Portrait9x16, "9:16"),
(KinoviSeedream5p0ProAspectRatio::Wide3x2, "3:2"),
(KinoviSeedream5p0ProAspectRatio::Tall2x3, "2:3"),
(KinoviSeedream5p0ProAspectRatio::UltraWide21x9, "21:9"),
];
for (variant, expected) in cases {
assert_eq!(variant.as_api_str(), expected, "wire value for {variant:?}");
}
}
#[test]
fn every_resolution_wire_value() {
assert_eq!(KinoviSeedream5p0ProResolution::OneK.as_api_str(), "1k");
assert_eq!(KinoviSeedream5p0ProResolution::TwoK.as_api_str(), "2k");
}
#[test]
fn every_batch_count_value() {
let cases = [
(KinoviSeedream5p0ProBatchCount::One, 1),
(KinoviSeedream5p0ProBatchCount::Two, 2),
(KinoviSeedream5p0ProBatchCount::Three, 3),
(KinoviSeedream5p0ProBatchCount::Four, 4),
(KinoviSeedream5p0ProBatchCount::Five, 5),
(KinoviSeedream5p0ProBatchCount::Six, 6),
(KinoviSeedream5p0ProBatchCount::Seven, 7),
(KinoviSeedream5p0ProBatchCount::Eight, 8),
];
for (variant, expected) in cases {
assert_eq!(variant.as_u8(), expected, "value for {variant:?}");
}
}
#[test]
fn batch_count_one_omits_field() {
let params = build_api_params(&base_request()).unwrap();
assert_eq!(params.batch_count, None);
}
#[test]
fn empty_reference_list_omits_uploaded_urls() {
let params = build_api_params(&GenerateSeedream5p0ProRequest {
reference_image_urls: Some(vec![]),
..base_request()
}).unwrap();
let json = serde_json::to_string(&params).unwrap();
assert!(!json.contains("uploadedUrls"), "expected no uploadedUrls in {json}");
}
#[test]
fn business_type() {
assert_eq!(BUSINESS_TYPE, "seedream-5-pro-image-generation");
}
}
mod reference_image_limit_tests {
use super::*;
#[test]
fn fourteen_references_is_allowed() {
let params = build_api_params(&request_with_n_references(MAX_REFERENCE_IMAGES)).unwrap();
assert_eq!(params.uploaded_urls.map(|urls| urls.len()), Some(MAX_REFERENCE_IMAGES));
}
#[test]
fn fifteen_references_is_rejected() {
let result = build_api_params(&request_with_n_references(MAX_REFERENCE_IMAGES + 1));
match result {
Err(Seedance2ProClientError::InvalidRequestField { field, .. }) => {
assert_eq!(field, "reference_image_urls");
}
other => panic!("expected InvalidRequestField, got {other:?}"),
}
}
fn request_with_n_references(count: usize) -> GenerateSeedream5p0ProRequest {
GenerateSeedream5p0ProRequest {
reference_image_urls: Some(
(0..count).map(|i| format!("https://example.com/ref{i}.jpg")).collect(),
),
..base_request()
}
}
}
// ── Pricing ──
//
// 1k = 14 credits/image, 2k = 24 credits/image; batch count multiplies.
// Verified against captured traffic: 2k x 4 images = 96 credits
// (4_seedream_5_misc.txt), 1k x 8 images = 112 credits
// (5_seedream_5_misc2.txt).
mod pricing_tests {
use super::*;
#[test]
fn one_1k_image_is_fourteen_credits() {
let costs = request(KinoviSeedream5p0ProResolution::OneK, KinoviSeedream5p0ProBatchCount::One).calculate_costs();
assert_eq!(costs.kinovi_credits, 14);
}
#[test]
fn one_2k_image_is_twentyfour_credits() {
let costs = request(KinoviSeedream5p0ProResolution::TwoK, KinoviSeedream5p0ProBatchCount::One).calculate_costs();
assert_eq!(costs.kinovi_credits, 24);
}
/// Mirrors capture 4_seedream_5_misc.txt: 2k, batch of 4 → 96 credits.
#[test]
fn four_2k_images_is_ninetysix_credits() {
let costs = request(KinoviSeedream5p0ProResolution::TwoK, KinoviSeedream5p0ProBatchCount::Four).calculate_costs();
assert_eq!(costs.kinovi_credits, 96);
}
/// Mirrors capture 5_seedream_5_misc2.txt: 1k, batch of 8 → 112 credits.
#[test]
fn eight_1k_images_is_one_hundred_twelve_credits() {
let costs = request(KinoviSeedream5p0ProResolution::OneK, KinoviSeedream5p0ProBatchCount::Eight).calculate_costs();
assert_eq!(costs.kinovi_credits, 112);
}
#[test]
fn usd_cents_one_1k_image() {
// 14 credits; 1400/243 = 5.7613… → up 6¢, down 5¢.
let costs = request(KinoviSeedream5p0ProResolution::OneK, KinoviSeedream5p0ProBatchCount::One).calculate_costs();
assert_eq!(costs.usd_cents_rounded_up, 6);
assert_eq!(costs.usd_cents_rounded_down, 5);
assert!((costs.usd_cents_fractional - (1400.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn usd_cents_one_2k_image() {
// 24 credits; 2400/243 = 9.8765… → up 10¢, down 9¢.
let costs = request(KinoviSeedream5p0ProResolution::TwoK, KinoviSeedream5p0ProBatchCount::One).calculate_costs();
assert_eq!(costs.usd_cents_rounded_up, 10);
assert_eq!(costs.usd_cents_rounded_down, 9);
assert!((costs.usd_cents_fractional - (2400.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn usd_cents_eight_1k_images() {
// 112 credits; 11200/243 = 46.0905… → up 47¢, down 46¢.
let costs = request(KinoviSeedream5p0ProResolution::OneK, KinoviSeedream5p0ProBatchCount::Eight).calculate_costs();
assert_eq!(costs.usd_cents_rounded_up, 47);
assert_eq!(costs.usd_cents_rounded_down, 46);
assert!((costs.usd_cents_fractional - (11200.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn reference_images_do_not_affect_cost() {
let without_refs = request(KinoviSeedream5p0ProResolution::TwoK, KinoviSeedream5p0ProBatchCount::Four).calculate_costs();
let with_refs = GenerateSeedream5p0ProRequest {
reference_image_urls: Some(vec![
"https://example.com/ref1.jpg".to_string(),
"https://example.com/ref2.jpg".to_string(),
]),
..request(KinoviSeedream5p0ProResolution::TwoK, KinoviSeedream5p0ProBatchCount::Four)
}.calculate_costs();
assert_eq!(without_refs, with_refs);
}
#[test]
fn aspect_ratio_does_not_affect_cost() {
let auto = request(KinoviSeedream5p0ProResolution::OneK, KinoviSeedream5p0ProBatchCount::One).calculate_costs();
let ultra_wide = GenerateSeedream5p0ProRequest {
aspect_ratio: KinoviSeedream5p0ProAspectRatio::UltraWide21x9,
..request(KinoviSeedream5p0ProResolution::OneK, KinoviSeedream5p0ProBatchCount::One)
}.calculate_costs();
assert_eq!(auto, ultra_wide);
}
fn request(
resolution: KinoviSeedream5p0ProResolution,
batch_count: KinoviSeedream5p0ProBatchCount,
) -> GenerateSeedream5p0ProRequest {
GenerateSeedream5p0ProRequest {
resolution,
batch_count,
..base_request()
}
}
}
// ── Live usage tests ──
mod live_tests {
use super::*;
use crate::test_utils::get_test_cookies::get_test_cookies;
use crate::test_utils::setup_test_logging::setup_test_logging;
use errors::AnyhowResult;
use log::LevelFilter;
#[tokio::test]
#[ignore] // Sends a real generation to Kinovi; costs 24 credits.
async fn test_text_to_image_2k() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_seedream_5p0_pro(GenerateSeedream5p0ProArgs {
session: &session,
host_override: None,
request: GenerateSeedream5p0ProRequest {
prompt: "A corgi wearing a wizard hat, studio lighting".to_string(),
aspect_ratio: KinoviSeedream5p0ProAspectRatio::Auto,
resolution: KinoviSeedream5p0ProResolution::TwoK,
batch_count: KinoviSeedream5p0ProBatchCount::One,
reference_image_urls: None,
},
}).await?;
println!("seedream 5.0 pro 2k — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert!(!result.order_id.is_empty());
assert_eq!(1, 2, "Inspect output above");
Ok(())
}
#[tokio::test]
#[ignore] // Sends a real generation to Kinovi; costs 14 credits.
async fn test_text_to_image_1k_with_references() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_seedream_5p0_pro(GenerateSeedream5p0ProArgs {
session: &session,
host_override: None,
request: GenerateSeedream5p0ProRequest {
prompt: "The man in @Image1 is staring out the door in @Image2".to_string(),
aspect_ratio: KinoviSeedream5p0ProAspectRatio::Portrait9x16,
resolution: KinoviSeedream5p0ProResolution::OneK,
batch_count: KinoviSeedream5p0ProBatchCount::One,
reference_image_urls: Some(vec![
"https://static.seedance2-pro.com/materials/20260713/1783904468032-0a803390.jpg".to_string(),
"https://static.seedance2-pro.com/materials/20260713/1783904481112-4a3fa0d2.png".to_string(),
]),
},
}).await?;
println!("seedream 5.0 pro 1k refs — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2, "Inspect output above");
Ok(())
}
#[tokio::test]
#[ignore] // Sends a real generation to Kinovi; costs 56 credits.
async fn test_batch_four_1k() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_seedream_5p0_pro(GenerateSeedream5p0ProArgs {
session: &session,
host_override: None,
request: GenerateSeedream5p0ProRequest {
prompt: "A lighthouse on a cliff at golden hour".to_string(),
aspect_ratio: KinoviSeedream5p0ProAspectRatio::Landscape16x9,
resolution: KinoviSeedream5p0ProResolution::OneK,
batch_count: KinoviSeedream5p0ProBatchCount::Four,
reference_image_urls: None,
},
}).await?;
println!("seedream 5.0 pro batch-4 — task_id={}, order_id={}", result.task_id, result.order_id);
assert_eq!(result.task_ids.as_ref().map(|ids| ids.len()), Some(4));
assert_eq!(result.order_ids.as_ref().map(|ids| ids.len()), Some(4));
assert_eq!(1, 2, "Inspect output above");
Ok(())
}
fn test_session() -> AnyhowResult<Seedance2ProSession> {
let cookies = get_test_cookies()?;
Ok(Seedance2ProSession::from_cookies_string(cookies))
}
}
}
@@ -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;
@@ -55,7 +55,7 @@ pub async fn delete_all_anonymous_user_images(_args: &Args, mysql: &Pool<MySql>)
}
info!("Deleting image file: {:?}", &file.token);
delete_media_file_as_mod(&file.token, ECHELON_USER_TOKEN, &mysql).await?;
delete_media_file_as_mod(&file.token, ECHELON_USER_TOKEN, mysql).await?;
}
page_index += 1;
@@ -60,7 +60,7 @@ pub async fn delete_all_files(username: &str, mysql: &Pool<MySql>) -> AnyhowResu
for file in media_files.records {
info!("Deleting file: {:?}", &file.token);
delete_media_file_as_mod(&file.token, ECHELON_USER_TOKEN, &mysql).await?;
delete_media_file_as_mod(&file.token, ECHELON_USER_TOKEN, mysql).await?;
}
page_index += 1;
@@ -54,6 +54,7 @@ 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::QwenEdit2511Angles => Some(CommonModelType::QwenEdit2511Angles),
RouterImageModel::Flux2LoraAngles => Some(CommonModelType::Flux2LoraAngles),
RouterImageModel::GrokImagineImage => Some(CommonModelType::GrokImagineImage),
@@ -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;
@@ -82,7 +82,8 @@ 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) => true,
_ => false,
};
@@ -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?;
@@ -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,2 +1,3 @@
pub mod insert_media_file_generic;
pub mod insert_media_file_generic_executor;
pub mod insert_media_file_generic_from_job;
@@ -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,

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