mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-24 18:59:28 -04:00
Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e3f6628c1 | ||
|
|
214eda6818 | ||
|
|
47648b19ae | ||
|
|
379e528e0a | ||
|
|
9965e2544d | ||
|
|
61a0880c80 | ||
|
|
c5c68d301a | ||
|
|
81de7838d3 | ||
|
|
9653e9082e | ||
|
|
ee6c064f4f | ||
|
|
630b132ae7 | ||
|
|
5742e638ed | ||
|
|
dc33a99d75 | ||
|
|
f6c5ea94eb | ||
|
|
123fe83a3d | ||
|
|
654f9164d3 | ||
|
|
539824564b | ||
|
|
fef420129d | ||
|
|
39dd4dee9a | ||
|
|
02ac2319a1 | ||
|
|
6af1493c1c | ||
|
|
7c7e050d02 | ||
|
|
fb93d297d3 | ||
|
|
38d0642904 | ||
|
|
2f9ffd4575 | ||
|
|
e8b19148fa | ||
|
|
31e912c4be | ||
|
|
ceb9d402cf | ||
|
|
ac2e728000 | ||
|
|
ef255f4ee6 | ||
|
|
0c11a95669 |
+14
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n jobs.maybe_external_third_party_id as `order_id`,\n jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n jobs.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n jobs.creator_ip_address,\n jobs.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,\n jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n jobs.maybe_wallet_ledger_entry_token as `maybe_wallet_ledger_entry_token: tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken`,\n jobs.platform_type as `maybe_platform_type: enums::common::platform_type::PlatformType`\n\nFROM generic_inference_jobs as jobs\n\nWHERE jobs.maybe_external_third_party = ?\n AND jobs.job_type = ?\n AND jobs.status NOT IN ('complete_success', 'complete_failure')\n AND jobs.maybe_external_third_party_id IS NOT NULL\n\nLIMIT 25000\n ",
|
||||
"query": "\nSELECT\n jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n jobs.maybe_external_third_party_id as `order_id`,\n jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n jobs.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n jobs.creator_ip_address,\n jobs.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,\n jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n jobs.maybe_wallet_ledger_entry_token as `maybe_wallet_ledger_entry_token: tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken`,\n jobs.platform_type as `maybe_platform_type: enums::common::platform_type::PlatformType`,\n jobs.created_at\n\nFROM generic_inference_jobs as jobs\n\nWHERE jobs.maybe_external_third_party = ?\n AND jobs.job_type = ?\n AND jobs.status NOT IN ('complete_success', 'complete_failure')\n AND jobs.maybe_external_third_party_id IS NOT NULL\n\nLIMIT 25000\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -92,6 +92,16 @@
|
||||
"char_set": 224,
|
||||
"max_size": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "created_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -106,8 +116,9 @@
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "ddbbd3886c00268edd3993508f635549fc8ddc877bb059fd58f14b8c07586fc9"
|
||||
"hash": "1703bc63ce65d98cbcad81d515cbcf211a943c533e38e887d2c12713c78b687d"
|
||||
}
|
||||
+18
-7
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n p.token as `token: tokens::tokens::prompts::PromptToken`,\n\n p.prompt_type as `prompt_type: enums::by_table::prompts::prompt_type::PromptType`,\n\n p.maybe_model_type as `maybe_model_type: enums::common::generation::common_model_type::CommonModelType`,\n p.maybe_generation_provider as `maybe_generation_provider: enums::common::generation_provider::GenerationProvider`,\n\n p.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n\n p.maybe_positive_prompt,\n p.maybe_negative_prompt,\n\n p.maybe_generation_mode as `maybe_generation_mode: enums::common::generation::common_generation_mode::CommonGenerationMode`,\n p.maybe_aspect_ratio as `maybe_aspect_ratio: enums::common::generation::common_aspect_ratio::CommonAspectRatio`,\n p.maybe_resolution as `maybe_resolution: enums::common::generation::common_resolution::CommonResolution`,\n p.maybe_batch_count as `maybe_batch_count: u8`,\n p.maybe_generate_audio as `maybe_generate_audio: bool`,\n p.maybe_duration_seconds as `maybe_duration_seconds: u32`,\n\n p.maybe_other_args,\n\n p.creator_ip_address,\n p.created_at\n\nFROM prompts as p\nWHERE\n p.token = ?\n ",
|
||||
"query": "\nSELECT\n p.token as `token: tokens::tokens::prompts::PromptToken`,\n\n p.prompt_type as `prompt_type: enums::by_table::prompts::prompt_type::PromptType`,\n\n p.maybe_model_type as `maybe_model_type: enums::common::generation::common_model_type::CommonModelType`,\n p.maybe_generation_provider as `maybe_generation_provider: enums::common::generation_provider::GenerationProvider`,\n\n p.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n\n p.maybe_positive_prompt,\n p.maybe_negative_prompt,\n\n p.maybe_generation_mode as `maybe_generation_mode: enums::common::generation::common_generation_mode::CommonGenerationMode`,\n p.maybe_aspect_ratio as `maybe_aspect_ratio: enums::common::generation::common_aspect_ratio::CommonAspectRatio`,\n p.maybe_resolution as `maybe_resolution: enums::common::generation::common_resolution::CommonResolution`,\n p.maybe_bitrate as `maybe_bitrate: enums::common::generation::common_bitrate::CommonBitrate`,\n p.maybe_batch_count as `maybe_batch_count: u8`,\n p.maybe_generate_audio as `maybe_generate_audio: bool`,\n p.maybe_duration_seconds as `maybe_duration_seconds: u32`,\n\n p.maybe_other_args,\n\n p.creator_ip_address,\n p.created_at\n\nFROM prompts as p\nWHERE\n p.token = ?\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -105,6 +105,16 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "maybe_bitrate: enums::common::generation::common_bitrate::CommonBitrate",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "maybe_batch_count: u8",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
@@ -114,7 +124,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"ordinal": 12,
|
||||
"name": "maybe_generate_audio: bool",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
@@ -124,7 +134,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"ordinal": 13,
|
||||
"name": "maybe_duration_seconds: u32",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
@@ -134,7 +144,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"ordinal": 14,
|
||||
"name": "maybe_other_args",
|
||||
"type_info": {
|
||||
"type": "Blob",
|
||||
@@ -144,7 +154,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"ordinal": 15,
|
||||
"name": "creator_ip_address",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
@@ -154,7 +164,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"ordinal": 16,
|
||||
"name": "created_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
@@ -182,9 +192,10 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "4a50b7bf06061f05e769a41e547cb927bd2b3a6f67c8ceb37883b744237ba34c"
|
||||
"hash": "1b69f192c3bb51bd249f97d37f209ae271d498bb46c978351b2c1d8759338bab"
|
||||
}
|
||||
+2
-2
@@ -48,7 +48,7 @@
|
||||
"name": "maybe_entity_token",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
@@ -132,7 +132,7 @@
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT status as `status: enums::common::job_status_plus::JobStatusPlus`\nFROM generic_inference_jobs\nWHERE token = ?\nFOR UPDATE\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "status: enums::common::job_status_plus::JobStatusPlus",
|
||||
"type_info": {
|
||||
"type": "String",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | ENUM",
|
||||
"char_set": 224,
|
||||
"max_size": 76
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "489c6ef00507330fedbaf2b6e024ecf409a920efd85f75456829a8ed412d9bc3"
|
||||
}
|
||||
+3
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nINSERT INTO prompts\nSET\n token = ?,\n prompt_type = ?,\n\n maybe_creator_user_token = ?,\n \n maybe_model_type = ?,\n maybe_generation_provider = ?,\n\n maybe_positive_prompt = ?,\n maybe_negative_prompt = ?,\n\n maybe_generation_mode = ?,\n maybe_aspect_ratio = ?,\n maybe_resolution = ?,\n maybe_batch_count = ?,\n maybe_generate_audio = ?,\n maybe_duration_seconds = ?,\n\n maybe_other_args = ?,\n\n creator_ip_address = ?\n ",
|
||||
"query": "\nINSERT INTO prompts\nSET\n token = ?,\n prompt_type = ?,\n\n maybe_creator_user_token = ?,\n \n maybe_model_type = ?,\n maybe_generation_provider = ?,\n\n maybe_positive_prompt = ?,\n maybe_negative_prompt = ?,\n\n maybe_generation_mode = ?,\n maybe_aspect_ratio = ?,\n maybe_resolution = ?,\n maybe_bitrate = ?,\n maybe_batch_count = ?,\n maybe_generate_audio = ?,\n maybe_duration_seconds = ?,\n\n maybe_other_args = ?,\n\n creator_ip_address = ?\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 15
|
||||
"Right": 16
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "ccf92ebe0328d3019ac6b01be4b442df706a183362fa7e5c331f5a4bf1a74a00"
|
||||
"hash": "7519c1dcc2946f4c31a8aad995bbb66a5519f6ee571797a6c0f597c9e57986a7"
|
||||
}
|
||||
+2
-2
@@ -48,7 +48,7 @@
|
||||
"name": "maybe_entity_token",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
@@ -132,7 +132,7 @@
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT status as `status: enums::common::job_status_plus::JobStatusPlus`\nFROM generic_inference_jobs\nWHERE token = ?\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "status: enums::common::job_status_plus::JobStatusPlus",
|
||||
"type_info": {
|
||||
"type": "String",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | ENUM",
|
||||
"char_set": 224,
|
||||
"max_size": 76
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "9312694d1651235b06d1744d904b59f64d66449b8927c1606bb08bd5fdc15c7e"
|
||||
}
|
||||
Generated
+1
@@ -10516,6 +10516,7 @@ dependencies = [
|
||||
"log",
|
||||
"mysql_queries",
|
||||
"pager",
|
||||
"rand 0.9.2",
|
||||
"reqwest 0.12.23",
|
||||
"rootly_client",
|
||||
"rootly_config",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
ALTER TABLE prompts
|
||||
DROP COLUMN maybe_bitrate;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
-- Add bitrate parameter to prompts table.
|
||||
-- Represented by the enum `CommonBitrate` (normal, high).
|
||||
ALTER TABLE prompts
|
||||
ADD COLUMN maybe_bitrate VARCHAR(16) DEFAULT NULL;
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
-- Revert `maybe_entity_token` back to NOT NULL.
|
||||
-- NB: This will fail if any rows have a null `maybe_entity_token`.
|
||||
ALTER TABLE staff_audit_logs
|
||||
MODIFY COLUMN maybe_entity_token VARCHAR(32) NOT NULL;
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
-- `maybe_entity_token` is optional (many audited actions have no entity), but it
|
||||
-- was mistakenly created as NOT NULL, so inserts with a null entity token fail
|
||||
-- with "Column 'maybe_entity_token' cannot be null". Make it nullable.
|
||||
ALTER TABLE staff_audit_logs
|
||||
MODIFY COLUMN maybe_entity_token VARCHAR(32) DEFAULT NULL;
|
||||
@@ -72,6 +72,10 @@ CREATE TABLE prompts (
|
||||
-- The quality setting (eg. high, medium, low)
|
||||
maybe_quality VARCHAR(16) DEFAULT NULL,
|
||||
|
||||
-- Represented by the enum `CommonBitrate`.
|
||||
-- The output bitrate setting (eg. normal, high)
|
||||
maybe_bitrate VARCHAR(16) DEFAULT NULL,
|
||||
|
||||
-- The duration in seconds
|
||||
maybe_duration_seconds INT UNSIGNED DEFAULT NULL,
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ CREATE TABLE staff_audit_logs (
|
||||
maybe_entity_type VARCHAR(16) DEFAULT NULL,
|
||||
|
||||
-- The token of the entity mutated (if any)
|
||||
maybe_entity_token VARCHAR(32) NOT NULL,
|
||||
maybe_entity_token VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- The staff user who performed the action.
|
||||
staff_user_token VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
@@ -0,0 +1,625 @@
|
||||
# ArtCraft Omni API
|
||||
|
||||
> # 📚 Full API reference: **<https://storyteller-docs.netlify.app/>**
|
||||
>
|
||||
> **Every ArtCraft API endpoint is documented there.** This guide covers only the Omni API video
|
||||
> endpoints — for everything else, start at the link above.
|
||||
|
||||
The **Omni API** is the API-key–authenticated surface for ArtCraft generation. It mirrors the
|
||||
in-app "Omni Gen" endpoints but is designed for programmatic use: you authenticate with an API key
|
||||
in the `Authorization` header instead of a browser session cookie.
|
||||
|
||||
This guide covers **video generation from URL inputs** — passing image, video, and audio reference
|
||||
URLs directly. The server downloads each URL, stores it as a media file on your account, and then
|
||||
runs generation against it. You never have to upload files yourself.
|
||||
|
||||
> All examples send **no cookies**. The Omni API is authenticated purely by your API key. If you
|
||||
> send session cookies they are ignored.
|
||||
|
||||
---
|
||||
|
||||
## Base URLs
|
||||
|
||||
| Environment | Base URL |
|
||||
| ------------- | ------------------------------ |
|
||||
| Development | `http://localhost:12345` |
|
||||
| Production | `https://api.storyteller.ai` |
|
||||
|
||||
All paths below are relative to the base URL (e.g. `POST /v1/omni_api/generate/video`).
|
||||
|
||||
The examples in this guide use the **development** base URL. To run against production, swap
|
||||
`http://localhost:12345` for `https://api.storyteller.ai`.
|
||||
|
||||
Note: Our APIs are hosted on "`api.storyteller.ai`", not "`api.getartcraft.com`". This is not a
|
||||
typo. Our Cloudflare CDN URLs are `cdn-2.fakeyou.com`.
|
||||
|
||||
---
|
||||
|
||||
## Getting an API key
|
||||
|
||||
Create and manage API keys from the ArtCraft web app:
|
||||
|
||||
1. Go to **<https://app.getartcraft.com/>**
|
||||
2. Click **Account → Settings → API Keys** (your account must have API keys enabled from our staff)
|
||||
3. Create a key and copy it. The secret is shown **once** — store it somewhere safe.
|
||||
|
||||
> **Don't see the API Keys section?** API access is gated per account. Contact the Artcraft team
|
||||
> to enable it.
|
||||
|
||||
### What an API key looks like
|
||||
|
||||
- Prefix: `artcraft_api_`
|
||||
- Followed by 40 random lowercase Crockford-base32 characters (`0-9 a-z`, excluding `i l o u`)
|
||||
- Total length: **53 characters**, e.g. `artcraft_api_3k7q9w0xv2hs5n8m4d6r1t8y0p2s4f6h8j0k2m4n6`
|
||||
|
||||
Treat the key like a password: never commit it to source control or paste it into logs.
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
Send the secret key in the `Authorization` header. Three forms are accepted:
|
||||
|
||||
```
|
||||
Authorization: Bearer artcraft_api_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
Authorization: Key artcraft_api_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
Authorization: artcraft_api_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
```
|
||||
|
||||
`Bearer` is recommended. **Do not send cookies.**
|
||||
|
||||
---
|
||||
|
||||
## Video generation — `POST /v1/omni_api/generate/video`
|
||||
|
||||
Generates a video and returns an inference job token you can poll for results.
|
||||
|
||||
### Request body
|
||||
|
||||
| Field | Type | Notes |
|
||||
| ---------------------------- | --------------- | ----- |
|
||||
| `model` | string | Generation model, e.g. `seedance_2p0`. **Required.** |
|
||||
| `prompt` | string | Text prompt. |
|
||||
| `duration_seconds` | integer | Length of the output video, e.g. `6`. |
|
||||
| `aspect_ratio` | string | e.g. `wide_sixteen_by_nine`, `tall_nine_by_sixteen`, `square`. |
|
||||
| `idempotency_token` | string (UUID) | **Required.** A fresh UUID per request; prevents accidental duplicates. |
|
||||
| `negative_prompt` | string | Optional. |
|
||||
| `resolution` | string | Optional, e.g. `seven_twenty_p`, `ten_eighty_p`. |
|
||||
| `generate_audio` | boolean | Optional. |
|
||||
| **URL inputs** (this guide) | | |
|
||||
| `start_frame_image_url` | string (URL) | Starting keyframe image. |
|
||||
| `end_frame_image_url` | string (URL) | Ending keyframe image. |
|
||||
| `reference_image_urls` | array of URL | Reference images. |
|
||||
| `reference_video_urls` | array of URL | Reference videos (`mp4`). |
|
||||
| `reference_audio_urls` | array of URL | Reference audio (`wav`, `mp3`, `ogg`, …). |
|
||||
| **Media-token inputs** | | |
|
||||
| `start_frame_image_media_token`, `end_frame_image_media_token`, `reference_image_media_tokens`, `reference_video_media_tokens`, `reference_audio_media_tokens` | token(s) | Use these instead if you've already uploaded media. |
|
||||
|
||||
#### Rules for URL inputs
|
||||
|
||||
- Each URL field and its media-token counterpart are **mutually exclusive**. Sending both
|
||||
`reference_image_urls` and `reference_image_media_tokens`, for example, returns:
|
||||
`Either reference_image_media_tokens or reference_image_urls must be set, not both`.
|
||||
- Every URL must start with `http://` or `https://`, otherwise:
|
||||
`URL must start with http:// or https://, bad URL: <url>`.
|
||||
- Redirects are followed (up to 10), so CDN/redirecting URLs work.
|
||||
- The file type is detected from the downloaded bytes. Images must be a real image
|
||||
(`jpeg`/`png`/`gif`/`webp`), reference videos must be `mp4`, reference audio must be a
|
||||
supported audio type (`wav`, `mp3`, `aac`, `ogg`, `flac`, …).
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"inference_job_token": "jinf_xxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
"all_job_tokens": ["jinf_xxxxxxxxxxxxxxxxxxxxxxxxx"]
|
||||
}
|
||||
```
|
||||
|
||||
Use `inference_job_token` to poll for the finished video. `all_job_tokens` lists every job created
|
||||
(batch requests can create more than one).
|
||||
|
||||
---
|
||||
|
||||
## Examples
|
||||
|
||||
The examples reuse these public sample URLs:
|
||||
|
||||
- Images (Wikimedia Commons): an Atlantic puffin, the Matterhorn, a toco toucan
|
||||
- Video: a short sample `mp4`
|
||||
- Audio: a short sample `wav`
|
||||
|
||||
Replace `YOUR_API_KEY` with your `artcraft_api_…` secret, and generate a fresh `idempotency_token`
|
||||
(UUID) for every request.
|
||||
|
||||
---
|
||||
|
||||
### Example 1 — Starting-frame Seedance (`start_frame_image_url`)
|
||||
|
||||
Animate outward from a single starting image.
|
||||
|
||||
#### bash
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:12345/v1/omni_api/generate/video \
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "seedance_2p0",
|
||||
"prompt": "An Atlantic puffin stands on a windy cliff edge and turns its head as morning light catches its colorful beak.",
|
||||
"duration_seconds": 6,
|
||||
"aspect_ratio": "wide_sixteen_by_nine",
|
||||
"idempotency_token": "'"$(uuidgen)"'",
|
||||
"start_frame_image_url": "https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900"
|
||||
}'
|
||||
```
|
||||
|
||||
#### python
|
||||
|
||||
```python
|
||||
import uuid
|
||||
import requests
|
||||
|
||||
BASE_URL = "http://localhost:12345" # production: https://api.storyteller.ai
|
||||
API_KEY = "YOUR_API_KEY"
|
||||
|
||||
resp = requests.post(
|
||||
f"{BASE_URL}/v1/omni_api/generate/video",
|
||||
headers={"Authorization": f"Bearer {API_KEY}"},
|
||||
json={
|
||||
"model": "seedance_2p0",
|
||||
"prompt": "An Atlantic puffin stands on a windy cliff edge and turns its head as morning light catches its colorful beak.",
|
||||
"duration_seconds": 6,
|
||||
"aspect_ratio": "wide_sixteen_by_nine",
|
||||
"idempotency_token": str(uuid.uuid4()),
|
||||
"start_frame_image_url": "https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900",
|
||||
},
|
||||
timeout=240,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
print(resp.json())
|
||||
```
|
||||
|
||||
#### javascript
|
||||
|
||||
```javascript
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
const BASE_URL = "http://localhost:12345"; // production: https://api.storyteller.ai
|
||||
const API_KEY = "YOUR_API_KEY";
|
||||
|
||||
const resp = await fetch(`${BASE_URL}/v1/omni_api/generate/video`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "seedance_2p0",
|
||||
prompt:
|
||||
"An Atlantic puffin stands on a windy cliff edge and turns its head as morning light catches its colorful beak.",
|
||||
duration_seconds: 6,
|
||||
aspect_ratio: "wide_sixteen_by_nine",
|
||||
idempotency_token: randomUUID(),
|
||||
start_frame_image_url:
|
||||
"https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900",
|
||||
}),
|
||||
});
|
||||
|
||||
console.log(resp.status, await resp.json());
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Example 2 — Image-reference Seedance (`reference_image_urls`)
|
||||
|
||||
Guide the generation with one or more reference images.
|
||||
|
||||
#### bash
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:12345/v1/omni_api/generate/video \
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "seedance_2p0",
|
||||
"prompt": "A whimsical journey: an Atlantic puffin perches on a cliff beneath the snow-capped Matterhorn while a brilliant toco toucan watches from a misty rainforest canopy.",
|
||||
"duration_seconds": 6,
|
||||
"aspect_ratio": "wide_sixteen_by_nine",
|
||||
"idempotency_token": "'"$(uuidgen)"'",
|
||||
"reference_image_urls": [
|
||||
"https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900",
|
||||
"https://commons.wikimedia.org/wiki/Special:FilePath/Matterhorn_from_Domh%C3%BCtte_-_2.jpg?width=900",
|
||||
"https://commons.wikimedia.org/wiki/Special:FilePath/Toco_toucan.jpg?width=900"
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
#### python
|
||||
|
||||
```python
|
||||
import uuid
|
||||
import requests
|
||||
|
||||
BASE_URL = "http://localhost:12345" # production: https://api.storyteller.ai
|
||||
API_KEY = "YOUR_API_KEY"
|
||||
|
||||
resp = requests.post(
|
||||
f"{BASE_URL}/v1/omni_api/generate/video",
|
||||
headers={"Authorization": f"Bearer {API_KEY}"},
|
||||
json={
|
||||
"model": "seedance_2p0",
|
||||
"prompt": "A whimsical journey: an Atlantic puffin perches on a cliff beneath the snow-capped Matterhorn while a brilliant toco toucan watches from a misty rainforest canopy.",
|
||||
"duration_seconds": 6,
|
||||
"aspect_ratio": "wide_sixteen_by_nine",
|
||||
"idempotency_token": str(uuid.uuid4()),
|
||||
"reference_image_urls": [
|
||||
"https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900",
|
||||
"https://commons.wikimedia.org/wiki/Special:FilePath/Matterhorn_from_Domh%C3%BCtte_-_2.jpg?width=900",
|
||||
"https://commons.wikimedia.org/wiki/Special:FilePath/Toco_toucan.jpg?width=900",
|
||||
],
|
||||
},
|
||||
timeout=240,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
print(resp.json())
|
||||
```
|
||||
|
||||
#### javascript
|
||||
|
||||
```javascript
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
const BASE_URL = "http://localhost:12345"; // production: https://api.storyteller.ai
|
||||
const API_KEY = "YOUR_API_KEY";
|
||||
|
||||
const resp = await fetch(`${BASE_URL}/v1/omni_api/generate/video`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "seedance_2p0",
|
||||
prompt:
|
||||
"A whimsical journey: an Atlantic puffin perches on a cliff beneath the snow-capped Matterhorn while a brilliant toco toucan watches from a misty rainforest canopy.",
|
||||
duration_seconds: 6,
|
||||
aspect_ratio: "wide_sixteen_by_nine",
|
||||
idempotency_token: randomUUID(),
|
||||
reference_image_urls: [
|
||||
"https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900",
|
||||
"https://commons.wikimedia.org/wiki/Special:FilePath/Matterhorn_from_Domh%C3%BCtte_-_2.jpg?width=900",
|
||||
"https://commons.wikimedia.org/wiki/Special:FilePath/Toco_toucan.jpg?width=900",
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
console.log(resp.status, await resp.json());
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Example 3 — Image + video reference Seedance (`reference_image_urls` + `reference_video_urls`)
|
||||
|
||||
Combine reference images with a reference video (and, optionally, reference audio). This is the
|
||||
fullest URL-driven request.
|
||||
|
||||
#### bash
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:12345/v1/omni_api/generate/video \
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "seedance_2p0",
|
||||
"prompt": "An Atlantic puffin stands on a windy cliff edge; the scene sweeps up to the snow-capped Matterhorn at sunrise before settling on a brilliant toco toucan in a misty rainforest canopy. The camera glides with the motion of the reference video, set to the mood of the reference audio.",
|
||||
"duration_seconds": 6,
|
||||
"aspect_ratio": "wide_sixteen_by_nine",
|
||||
"idempotency_token": "'"$(uuidgen)"'",
|
||||
"reference_image_urls": [
|
||||
"https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900",
|
||||
"https://commons.wikimedia.org/wiki/Special:FilePath/Matterhorn_from_Domh%C3%BCtte_-_2.jpg?width=900",
|
||||
"https://commons.wikimedia.org/wiki/Special:FilePath/Toco_toucan.jpg?width=900"
|
||||
],
|
||||
"reference_video_urls": ["https://download.samplelib.com/mp4/sample-5s.mp4"],
|
||||
"reference_audio_urls": ["https://download.samplelib.com/wav/sample-6s.wav"]
|
||||
}'
|
||||
```
|
||||
|
||||
#### python
|
||||
|
||||
```python
|
||||
import uuid
|
||||
import requests
|
||||
|
||||
BASE_URL = "http://localhost:12345" # production: https://api.storyteller.ai
|
||||
API_KEY = "YOUR_API_KEY"
|
||||
|
||||
resp = requests.post(
|
||||
f"{BASE_URL}/v1/omni_api/generate/video",
|
||||
headers={"Authorization": f"Bearer {API_KEY}"},
|
||||
json={
|
||||
"model": "seedance_2p0",
|
||||
"prompt": "An Atlantic puffin stands on a windy cliff edge; the scene sweeps up to the snow-capped Matterhorn at sunrise before settling on a brilliant toco toucan in a misty rainforest canopy. The camera glides with the motion of the reference video, set to the mood of the reference audio.",
|
||||
"duration_seconds": 6,
|
||||
"aspect_ratio": "wide_sixteen_by_nine",
|
||||
"idempotency_token": str(uuid.uuid4()),
|
||||
"reference_image_urls": [
|
||||
"https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900",
|
||||
"https://commons.wikimedia.org/wiki/Special:FilePath/Matterhorn_from_Domh%C3%BCtte_-_2.jpg?width=900",
|
||||
"https://commons.wikimedia.org/wiki/Special:FilePath/Toco_toucan.jpg?width=900",
|
||||
],
|
||||
"reference_video_urls": ["https://download.samplelib.com/mp4/sample-5s.mp4"],
|
||||
"reference_audio_urls": ["https://download.samplelib.com/wav/sample-6s.wav"],
|
||||
},
|
||||
timeout=240,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
print(resp.json())
|
||||
```
|
||||
|
||||
#### javascript
|
||||
|
||||
```javascript
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
const BASE_URL = "http://localhost:12345"; // production: https://api.storyteller.ai
|
||||
const API_KEY = "YOUR_API_KEY";
|
||||
|
||||
const resp = await fetch(`${BASE_URL}/v1/omni_api/generate/video`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "seedance_2p0",
|
||||
prompt:
|
||||
"An Atlantic puffin stands on a windy cliff edge; the scene sweeps up to the snow-capped Matterhorn at sunrise before settling on a brilliant toco toucan in a misty rainforest canopy. The camera glides with the motion of the reference video, set to the mood of the reference audio.",
|
||||
duration_seconds: 6,
|
||||
aspect_ratio: "wide_sixteen_by_nine",
|
||||
idempotency_token: randomUUID(),
|
||||
reference_image_urls: [
|
||||
"https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900",
|
||||
"https://commons.wikimedia.org/wiki/Special:FilePath/Matterhorn_from_Domh%C3%BCtte_-_2.jpg?width=900",
|
||||
"https://commons.wikimedia.org/wiki/Special:FilePath/Toco_toucan.jpg?width=900",
|
||||
],
|
||||
reference_video_urls: ["https://download.samplelib.com/mp4/sample-5s.mp4"],
|
||||
reference_audio_urls: ["https://download.samplelib.com/wav/sample-6s.wav"],
|
||||
}),
|
||||
});
|
||||
|
||||
console.log(resp.status, await resp.json());
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checking job status
|
||||
|
||||
Generation is asynchronous. The generate call returns an `inference_job_token`; poll the job-status
|
||||
endpoint until the job reaches a terminal state, then read the result URL.
|
||||
|
||||
### Endpoints
|
||||
|
||||
- **Single job:** `GET /v1/omni_api/job_status/job/{token}`
|
||||
- **Batch (many jobs):** `GET /v1/omni_api/job_status/batch?tokens=jinf_aaa&tokens=jinf_bbb`
|
||||
|
||||
Both require the same `Authorization: Bearer …` header (no cookies).
|
||||
|
||||
### Response shape
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"state": {
|
||||
"job_token": "jinf_xxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
"request": {
|
||||
"inference_category": "video_generation",
|
||||
"maybe_prompt_token": "prompt_xxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
"maybe_model_type": "seedance_2p0",
|
||||
"maybe_model_token": null
|
||||
},
|
||||
"status": {
|
||||
"status": "pending",
|
||||
"maybe_first_started_at": null,
|
||||
"maybe_failure_category": null,
|
||||
"progress_percentage": 0
|
||||
},
|
||||
"maybe_result": null,
|
||||
"created_at": "2026-06-24T04:06:14Z",
|
||||
"updated_at": "2026-06-24T04:06:14Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `state.status.status` — the job state. Keep polling while it's `pending`, `started`, or
|
||||
`attempt_failed`. **Terminal** states are `complete_success`, `complete_failure`, `dead`,
|
||||
`cancelled_by_user`, and `cancelled_by_system`.
|
||||
- `state.status.progress_percentage` — an integer 0–100.
|
||||
- `state.maybe_result` — `null` until the job succeeds. On `complete_success` it contains the
|
||||
finished media; the playable/downloadable URL is **`state.maybe_result.media_links.cdn_url`**.
|
||||
For video, `media_links.maybe_video_previews` also carries still/animated preview URLs.
|
||||
|
||||
The batch endpoint returns `{ "success": true, "job_states": [ … ] }` where each entry is the same
|
||||
`state` payload as above.
|
||||
|
||||
### Where the result video / image lives
|
||||
|
||||
When `state.status.status` is `complete_success`, the finished media is under `state.maybe_result`:
|
||||
|
||||
- **`state.maybe_result.media_links.cdn_url`** — the primary, playable/downloadable URL of the
|
||||
finished asset. **This is the field you want.** For a video job it's the `.mp4`; for an image job
|
||||
it's the image file (`.jpg` / `.png` / etc.).
|
||||
- `state.maybe_result.entity_token` — the `m_…` media-file token for the result.
|
||||
- `state.maybe_result.media_links.maybe_video_previews` — **video jobs only**. Contains a `still`
|
||||
(poster `.jpg`) and an `animated` (`.gif`) preview URL, plus `{WIDTH}` thumbnail templates. This
|
||||
is `null` for image jobs.
|
||||
- `state.maybe_result.media_links.maybe_thumbnail_template` — **image jobs only**. A URL template;
|
||||
replace `{WIDTH}` with a pixel width to fetch a resized thumbnail. `null` for video jobs.
|
||||
|
||||
**Example — completed video job:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"state": {
|
||||
"job_token": "jinf_9fcn2qv4dv3vvbrrxbnmfgen06v",
|
||||
"request": {
|
||||
"inference_category": "video_generation",
|
||||
"maybe_prompt_token": "prompt_q53tp4rh97qh8q159p21tazzt",
|
||||
"maybe_model_type": "seedance_2p0",
|
||||
"maybe_model_token": null
|
||||
},
|
||||
"status": {
|
||||
"status": "complete_success",
|
||||
"maybe_first_started_at": "2026-06-24T04:07:11Z",
|
||||
"maybe_failure_category": null,
|
||||
"progress_percentage": 100
|
||||
},
|
||||
"maybe_result": {
|
||||
"entity_type": "media_file",
|
||||
"entity_token": "m_ss682134tx19yhqs5y0nbnd0bd59a6",
|
||||
"media_links": {
|
||||
"cdn_url": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4",
|
||||
"maybe_thumbnail_template": null,
|
||||
"maybe_video_previews": {
|
||||
"still": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4-thumb.jpg",
|
||||
"animated": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4-thumb.gif",
|
||||
"still_thumbnail_template": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4-thumb-{WIDTH}.jpg",
|
||||
"animated_thumbnail_template": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4-thumb-{WIDTH}.gif"
|
||||
}
|
||||
},
|
||||
"maybe_successfully_completed_at": "2026-06-24T04:17:30Z"
|
||||
},
|
||||
"created_at": "2026-06-24T04:06:14Z",
|
||||
"updated_at": "2026-06-24T04:17:30Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
→ The video is at
|
||||
`https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4`
|
||||
(example, not a live URL).
|
||||
|
||||
**Example — completed image job** (from `POST /v1/omni_api/generate/image`):
|
||||
|
||||
```json
|
||||
{
|
||||
"maybe_result": {
|
||||
"entity_type": "media_file",
|
||||
"entity_token": "m_3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6",
|
||||
"media_links": {
|
||||
"cdn_url": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/3/k/q/9/w/3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6/artcraft_3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6.jpg",
|
||||
"maybe_thumbnail_template": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/3/k/q/9/w/3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6/artcraft_3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6.jpg-thumb-{WIDTH}.jpg",
|
||||
"maybe_video_previews": null
|
||||
},
|
||||
"maybe_successfully_completed_at": "2026-06-24T04:09:02Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
→ The image is at
|
||||
`https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/3/k/q/9/w/3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6/artcraft_3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6.jpg`
|
||||
(example, not a live URL).
|
||||
|
||||
### Poll until complete
|
||||
|
||||
#### bash
|
||||
|
||||
```bash
|
||||
JOB_TOKEN="jinf_xxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
|
||||
while :; do
|
||||
resp=$(curl -s "http://localhost:12345/v1/omni_api/job_status/job/$JOB_TOKEN" \
|
||||
-H "Authorization: Bearer YOUR_API_KEY")
|
||||
status=$(echo "$resp" | python3 -c "import sys,json;print(json.load(sys.stdin)['state']['status']['status'])")
|
||||
echo "status=$status"
|
||||
case "$status" in
|
||||
complete_success)
|
||||
echo "$resp" | python3 -c "import sys,json;print(json.load(sys.stdin)['state']['maybe_result']['media_links']['cdn_url'])"
|
||||
break ;;
|
||||
complete_failure|dead|cancelled_by_user|cancelled_by_system)
|
||||
echo "job ended without a result: $status"; break ;;
|
||||
esac
|
||||
sleep 5
|
||||
done
|
||||
```
|
||||
|
||||
#### python
|
||||
|
||||
```python
|
||||
import time
|
||||
import requests
|
||||
|
||||
BASE_URL = "http://localhost:12345" # production: https://api.storyteller.ai
|
||||
API_KEY = "YOUR_API_KEY"
|
||||
JOB_TOKEN = "jinf_xxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
|
||||
TERMINAL_FAILURES = {"complete_failure", "dead", "cancelled_by_user", "cancelled_by_system"}
|
||||
|
||||
while True:
|
||||
resp = requests.get(
|
||||
f"{BASE_URL}/v1/omni_api/job_status/job/{JOB_TOKEN}",
|
||||
headers={"Authorization": f"Bearer {API_KEY}"},
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
state = resp.json()["state"]
|
||||
status = state["status"]["status"]
|
||||
print("status:", status, state["status"]["progress_percentage"], "%")
|
||||
|
||||
if status == "complete_success":
|
||||
print("result URL:", state["maybe_result"]["media_links"]["cdn_url"])
|
||||
break
|
||||
if status in TERMINAL_FAILURES:
|
||||
print("job ended without a result:", status)
|
||||
break
|
||||
time.sleep(5)
|
||||
```
|
||||
|
||||
#### javascript
|
||||
|
||||
```javascript
|
||||
const BASE_URL = "http://localhost:12345"; // production: https://api.storyteller.ai
|
||||
const API_KEY = "YOUR_API_KEY";
|
||||
const JOB_TOKEN = "jinf_xxxxxxxxxxxxxxxxxxxxxxxxx";
|
||||
|
||||
const TERMINAL_FAILURES = new Set([
|
||||
"complete_failure", "dead", "cancelled_by_user", "cancelled_by_system",
|
||||
]);
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
while (true) {
|
||||
const resp = await fetch(`${BASE_URL}/v1/omni_api/job_status/job/${JOB_TOKEN}`, {
|
||||
headers: { Authorization: `Bearer ${API_KEY}` },
|
||||
});
|
||||
const { state } = await resp.json();
|
||||
const status = state.status.status;
|
||||
console.log("status:", status, state.status.progress_percentage, "%");
|
||||
|
||||
if (status === "complete_success") {
|
||||
console.log("result URL:", state.maybe_result.media_links.cdn_url);
|
||||
break;
|
||||
}
|
||||
if (TERMINAL_FAILURES.has(status)) {
|
||||
console.log("job ended without a result:", status);
|
||||
break;
|
||||
}
|
||||
await sleep(5000);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes & troubleshooting
|
||||
|
||||
- **Always send a fresh `idempotency_token`** (UUID). Reusing one is rejected as a duplicate.
|
||||
- **No cookies.** The endpoint authenticates only via the `Authorization` header.
|
||||
- **URL must be `http(s)`** and reachable by the server; redirects are followed.
|
||||
- **Type mismatches** (e.g. a `.webm` passed as a reference video, which requires `mp4`) are
|
||||
rejected with a `400` describing the unpermitted type.
|
||||
- **`401 Unauthorized`** — missing/invalid API key, or the key's owner is banned.
|
||||
- **`402 Payment required`** — insufficient credits/balance.
|
||||
- There is a companion image endpoint, `POST /v1/omni_api/generate/image`, which accepts
|
||||
`image_urls` (mutually exclusive with `image_media_tokens`) following the same rules.
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod omni_api_image_generate_request;
|
||||
pub mod omni_api_video_generate_request;
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
|
||||
use enums::common::generation::common_image_model::CommonImageModel;
|
||||
use enums::common::generation::common_quality::CommonQuality;
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
/// Request body for the Omni API image generation endpoint.
|
||||
///
|
||||
/// Forked from `OmniGenImageCostAndGenerateRequest` so the Omni API's request
|
||||
/// shape can evolve independently without affecting the product-critical Omni
|
||||
/// Gen endpoints.
|
||||
#[derive(Clone, Serialize, Deserialize, ToSchema, Debug)]
|
||||
pub struct OmniApiImageGenerateRequest {
|
||||
/// REQUIRED (even if marked optional)
|
||||
/// Idempotency token to prevent duplicate requests.
|
||||
/// If not supplied, we'll generate one for the required providers.
|
||||
pub idempotency_token: Option<String>,
|
||||
|
||||
/// REQUIRED (even if marked optional)
|
||||
/// Which model to use.
|
||||
pub model: Option<CommonImageModel>,
|
||||
|
||||
/// The prompt for the image generation.
|
||||
pub prompt: Option<String>,
|
||||
|
||||
/// Input images for image editing.
|
||||
/// If present, we're doing image editing (image-to-image).
|
||||
/// If absent, we're doing text-to-image.
|
||||
pub image_media_tokens: Option<Vec<MediaFileToken>>,
|
||||
|
||||
/// URL alternative to `image_media_tokens`. Mutually exclusive with it.
|
||||
/// Each URL is downloaded and stored as a media file before generation.
|
||||
pub image_urls: Option<Vec<String>>,
|
||||
|
||||
/// The resolution to use.
|
||||
pub resolution: Option<CommonResolution>,
|
||||
|
||||
/// The aspect ratio to use.
|
||||
pub aspect_ratio: Option<CommonAspectRatio>,
|
||||
|
||||
/// The quality to use.
|
||||
pub quality: Option<CommonQuality>,
|
||||
|
||||
/// How many images to generate.
|
||||
pub image_batch_count: Option<u16>,
|
||||
|
||||
/// Only for angle manipulation models.
|
||||
pub adjust_horizontal_angle: Option<f64>,
|
||||
|
||||
/// Only for angle manipulation models.
|
||||
pub adjust_vertical_angle: Option<f64>,
|
||||
|
||||
/// Only for angle manipulation models.
|
||||
pub adjust_zoom: Option<f64>,
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
|
||||
use enums::common::generation::common_bitrate::CommonBitrate;
|
||||
use enums::common::generation::common_quality::CommonQuality;
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel;
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
/// Request body for the Omni API video generation endpoint.
|
||||
///
|
||||
/// Forked from `OmniGenVideoCostAndGenerateRequest` so the Omni API's request
|
||||
/// shape can evolve independently without affecting the product-critical Omni
|
||||
/// Gen endpoints.
|
||||
#[derive(Clone, Serialize, Deserialize, ToSchema, Debug)]
|
||||
pub struct OmniApiVideoGenerateRequest {
|
||||
/// REQUIRED (even if marked optional)
|
||||
/// Idempotency token to prevent duplicate requests.
|
||||
pub idempotency_token: Option<String>,
|
||||
|
||||
/// REQUIRED (even if marked optional)
|
||||
/// Which model to use.
|
||||
pub model: Option<CommonVideoModel>,
|
||||
|
||||
/// The text prompt for the video generation.
|
||||
pub prompt: Option<String>,
|
||||
|
||||
/// Some models support negative text prompts.
|
||||
pub negative_prompt: Option<String>,
|
||||
|
||||
/// Starting keyframe (optional).
|
||||
pub start_frame_image_media_token: Option<MediaFileToken>,
|
||||
|
||||
/// URL alternative to `start_frame_image_media_token`. Mutually exclusive with it.
|
||||
/// Downloaded and stored as a media file before generation.
|
||||
pub start_frame_image_url: Option<String>,
|
||||
|
||||
/// Ending keyframe (optional).
|
||||
pub end_frame_image_media_token: Option<MediaFileToken>,
|
||||
|
||||
/// URL alternative to `end_frame_image_media_token`. Mutually exclusive with it.
|
||||
/// Downloaded and stored as a media file before generation.
|
||||
pub end_frame_image_url: Option<String>,
|
||||
|
||||
/// Reference images (optional).
|
||||
pub reference_image_media_tokens: Option<Vec<MediaFileToken>>,
|
||||
|
||||
/// URL alternative to `reference_image_media_tokens`. Mutually exclusive with it.
|
||||
/// Each URL is downloaded and stored as a media file before generation.
|
||||
pub reference_image_urls: Option<Vec<String>>,
|
||||
|
||||
/// Reference videos (optional).
|
||||
pub reference_video_media_tokens: Option<Vec<MediaFileToken>>,
|
||||
|
||||
/// URL alternative to `reference_video_media_tokens`. Mutually exclusive with it.
|
||||
/// Each URL is downloaded and stored as a media file before generation.
|
||||
pub reference_video_urls: Option<Vec<String>>,
|
||||
|
||||
/// Reference audio (optional).
|
||||
pub reference_audio_media_tokens: Option<Vec<MediaFileToken>>,
|
||||
|
||||
/// URL alternative to `reference_audio_media_tokens`. Mutually exclusive with it.
|
||||
/// Each URL is downloaded and stored as a media file before generation.
|
||||
pub reference_audio_urls: Option<Vec<String>>,
|
||||
|
||||
/// Optional character tokens to reference in the prompt.
|
||||
/// Characters are referenced in prompts as @CharacterName.
|
||||
pub reference_character_tokens: Option<Vec<CharacterToken>>,
|
||||
|
||||
/// The resolution to use.
|
||||
pub resolution: Option<CommonResolution>,
|
||||
|
||||
/// The aspect ratio to use.
|
||||
pub aspect_ratio: Option<CommonAspectRatio>,
|
||||
|
||||
/// The output bitrate to use.
|
||||
/// Not all models support this; models that don't simply ignore it.
|
||||
pub bitrate: Option<CommonBitrate>,
|
||||
|
||||
/// The quality to use.
|
||||
pub quality: Option<CommonQuality>,
|
||||
|
||||
/// How many seconds to generate.
|
||||
pub duration_seconds: Option<u16>,
|
||||
|
||||
/// How many videos to generate.
|
||||
pub video_batch_count: Option<u16>,
|
||||
|
||||
/// Whether to turn on/off audio.
|
||||
/// Not all models support audio, not all models have a choice.
|
||||
/// Some models will default this to true, others will default it to false,
|
||||
/// so it's best to be explicit.
|
||||
pub generate_audio: Option<bool>,
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod omni_api_batch_get_job_status;
|
||||
pub mod omni_api_get_job_status;
|
||||
pub mod omni_api_job_status_payload;
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
use crate::omni_api::job_status::omni_api_job_status_payload::OmniApiJobStatusPayload;
|
||||
|
||||
/// Query params for `GET /v1/omni_api/job_status/batch`.
|
||||
#[derive(Deserialize, ToSchema, IntoParams)]
|
||||
pub struct OmniApiBatchGetJobStatusQueryParams {
|
||||
/// A grab bag of job tokens. Each token family generally has its own prefix,
|
||||
/// so a flat set is sufficient.
|
||||
///
|
||||
/// NB: handlers parse this with `actix_web_lab`'s `Query<T>` because the
|
||||
/// default actix-web `Query<T>` doesn't decode repeated/sequence params yet.
|
||||
/// See https://github.com/actix/actix-web/issues/1301
|
||||
pub tokens: HashSet<String>,
|
||||
}
|
||||
|
||||
/// Success body for `GET /v1/omni_api/job_status/batch`.
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct OmniApiBatchGetJobStatusSuccessResponse {
|
||||
pub success: bool,
|
||||
pub job_states: Vec<OmniApiJobStatusPayload>,
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
|
||||
use crate::omni_api::job_status::omni_api_job_status_payload::OmniApiJobStatusPayload;
|
||||
|
||||
/// Path params for `GET /v1/omni_api/job_status/job/{token}`.
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct OmniApiGetJobStatusPathInfo {
|
||||
pub token: InferenceJobToken,
|
||||
}
|
||||
|
||||
/// Success body for `GET /v1/omni_api/job_status/job/{token}`.
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct OmniApiGetJobStatusSuccessResponse {
|
||||
pub success: bool,
|
||||
pub state: OmniApiJobStatusPayload,
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory;
|
||||
use enums::by_table::generic_inference_jobs::inference_category::InferenceCategory;
|
||||
use enums::common::job_status_plus::JobStatusPlus;
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
|
||||
use crate::common::responses::media_links::MediaLinks;
|
||||
|
||||
/// Full status payload for a single inference job.
|
||||
///
|
||||
/// Shared by both the single (`/v1/omni_api/job_status/job/{token}`) and batch
|
||||
/// (`/v1/omni_api/job_status/batch`) Omni API status endpoints. Forked from the
|
||||
/// cookie-authenticated `/v1/jobs/*` response types so the Omni API surface can
|
||||
/// evolve independently.
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct OmniApiJobStatusPayload {
|
||||
pub job_token: InferenceJobToken,
|
||||
|
||||
pub request: OmniApiJobRequestDetails,
|
||||
pub status: OmniApiJobStatusDetails,
|
||||
pub maybe_result: Option<OmniApiJobResultDetails>,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Details about what the user requested for generation.
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct OmniApiJobRequestDetails {
|
||||
pub inference_category: InferenceCategory,
|
||||
|
||||
pub maybe_prompt_token: Option<PromptToken>,
|
||||
|
||||
pub maybe_model_type: Option<String>,
|
||||
pub maybe_model_token: Option<String>,
|
||||
}
|
||||
|
||||
/// Details about the ongoing job status.
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct OmniApiJobStatusDetails {
|
||||
/// Primary status from the database (a state machine).
|
||||
pub status: JobStatusPlus,
|
||||
|
||||
pub maybe_first_started_at: Option<DateTime<Utc>>,
|
||||
|
||||
/// An enum the frontend can use to display localized/I18N error
|
||||
/// messages. These pertain to both transient and permanent failures.
|
||||
pub maybe_failure_category: Option<FrontendFailureCategory>,
|
||||
|
||||
/// This is an integer number between 0 and 100 (both inclusive) that
|
||||
/// reports the completeness.
|
||||
pub progress_percentage: u8,
|
||||
}
|
||||
|
||||
/// Details about the completed result (if any).
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct OmniApiJobResultDetails {
|
||||
pub entity_type: String,
|
||||
pub entity_token: String,
|
||||
|
||||
/// Rich CDN links to the media, including thumbnails, previews, and more.
|
||||
pub media_links: MediaLinks,
|
||||
|
||||
pub maybe_successfully_completed_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
pub mod generate_requests;
|
||||
pub mod job_status;
|
||||
pub mod omni_upload_audio;
|
||||
pub mod omni_upload_image;
|
||||
pub mod omni_upload_video;
|
||||
|
||||
+5
@@ -2,6 +2,7 @@ use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
|
||||
use enums::common::generation::common_bitrate::CommonBitrate;
|
||||
use enums::common::generation::common_quality::CommonQuality;
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel;
|
||||
@@ -50,6 +51,10 @@ pub struct OmniGenVideoCostAndGenerateRequest {
|
||||
/// The aspect ratio to use.
|
||||
pub aspect_ratio: Option<CommonAspectRatio>,
|
||||
|
||||
/// The output bitrate to use.
|
||||
/// Not all models support this; models that don't simply ignore it.
|
||||
pub bitrate: Option<CommonBitrate>,
|
||||
|
||||
/// The quality to use.
|
||||
pub quality: Option<CommonQuality>,
|
||||
|
||||
|
||||
+9
@@ -6,6 +6,7 @@ use enums::common::generation::model_creator::ModelCreator;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
use enums::common::generation::common_bitrate::CommonBitrate;
|
||||
|
||||
/// Video model to default to if none is specified
|
||||
const DEFAULT_VIDEO_MODEL : CommonVideoModel = CommonVideoModel::Seedance2p0;
|
||||
@@ -150,6 +151,12 @@ pub struct OmniGenVideoModelDetails {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resolution_default: Option<CommonResolution>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bitrate_options: Option<Vec<CommonBitrate>>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bitrate_default: Option<CommonBitrate>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub quality_options: Option<Vec<CommonQuality>>,
|
||||
|
||||
@@ -220,6 +227,8 @@ impl Default for OmniGenVideoModelDetails {
|
||||
aspect_ratio_default: None,
|
||||
resolution_options: None,
|
||||
resolution_default: None,
|
||||
bitrate_options: None,
|
||||
bitrate_default: None,
|
||||
quality_options: None,
|
||||
default_quality: None,
|
||||
duration_seconds_min: None,
|
||||
|
||||
@@ -2,6 +2,7 @@ use crate::prompts::get_prompt::GetPromptImageContextItem;
|
||||
use chrono::{DateTime, Utc};
|
||||
use enums::by_table::prompts::prompt_type::PromptType;
|
||||
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
|
||||
use enums::common::generation::common_bitrate::CommonBitrate;
|
||||
use enums::common::generation::common_generation_mode::CommonGenerationMode;
|
||||
use enums::common::generation::common_model_class::CommonModelClass;
|
||||
use enums::common::generation::common_model_type::CommonModelType;
|
||||
@@ -55,6 +56,9 @@ pub struct BatchPromptInfo {
|
||||
/// OPTIONAL. The resolution (eg. one_k, two_k, four_k, etc.)
|
||||
pub maybe_resolution: Option<CommonResolution>,
|
||||
|
||||
/// OPTIONAL. The output bitrate (eg. normal, high).
|
||||
pub maybe_bitrate: Option<CommonBitrate>,
|
||||
|
||||
/// OPTIONAL. The number of outputs to generate (0-255).
|
||||
pub maybe_batch_count: Option<u8>,
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ use chrono::{DateTime, Utc};
|
||||
use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType;
|
||||
use enums::by_table::prompts::prompt_type::PromptType;
|
||||
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
|
||||
use enums::common::generation::common_bitrate::CommonBitrate;
|
||||
use enums::common::generation::common_generation_mode::CommonGenerationMode;
|
||||
use enums::common::generation::common_model_type::CommonModelType;
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
@@ -59,6 +60,9 @@ pub struct PromptInfo {
|
||||
/// OPTIONAL. The resolution (eg. one_k, two_k, four_k, etc.)
|
||||
pub maybe_resolution: Option<CommonResolution>,
|
||||
|
||||
/// OPTIONAL. The output bitrate (eg. normal, high).
|
||||
pub maybe_bitrate: Option<CommonBitrate>,
|
||||
|
||||
/// OPTIONAL. The number of outputs to generate (0-255).
|
||||
pub maybe_batch_count: Option<u8>,
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ pub mod character_list_ref;
|
||||
pub mod image_list_ref;
|
||||
pub mod image_ref;
|
||||
pub mod router_aspect_ratio;
|
||||
pub mod router_bitrate;
|
||||
pub mod router_image_model;
|
||||
pub mod router_provider;
|
||||
pub mod router_quality;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
/// Output bitrate levels you can specify when enqueuing a generation.
|
||||
/// Not every model will use this; models that don't simply ignore it.
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RouterBitrate {
|
||||
Normal,
|
||||
High,
|
||||
}
|
||||
+6
@@ -1,6 +1,7 @@
|
||||
use crate::api::audio_list_ref::AudioListRef;
|
||||
use crate::api::character_list_ref::CharacterListRef;
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_bitrate::RouterBitrate;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
@@ -103,6 +104,10 @@ pub struct GenerateVideoRequestBuilder {
|
||||
/// The aspect ratio to use
|
||||
pub aspect_ratio: Option<RouterAspectRatio>,
|
||||
|
||||
/// The output bitrate to use.
|
||||
/// Not all models support this; models that don't simply ignore it.
|
||||
pub bitrate: Option<RouterBitrate>,
|
||||
|
||||
/// How many seconds to generate.
|
||||
pub duration_seconds: Option<u16>,
|
||||
|
||||
@@ -139,6 +144,7 @@ impl Default for GenerateVideoRequestBuilder {
|
||||
reference_character_tokens: None,
|
||||
resolution: None,
|
||||
aspect_ratio: None,
|
||||
bitrate: None,
|
||||
duration_seconds: None,
|
||||
video_batch_count: None,
|
||||
generate_audio: None,
|
||||
|
||||
+41
@@ -1,9 +1,11 @@
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
|
||||
use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioEnum;
|
||||
use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum;
|
||||
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_bitrate::RouterBitrate;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
@@ -49,6 +51,7 @@ pub fn build_artcraft_omni_video_request(
|
||||
let resolution = plan_output_resolution(builder.resolution.take(), strategy, resolutions)?;
|
||||
let batch_count = plan_batch_count(builder.video_batch_count.take(), strategy)?;
|
||||
let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?;
|
||||
let bitrate = plan_bitrate(builder.bitrate.take());
|
||||
let prompt = builder.prompt.take();
|
||||
|
||||
let start_frame = resolve_image_ref(builder.start_frame.take())?;
|
||||
@@ -71,6 +74,7 @@ pub fn build_artcraft_omni_video_request(
|
||||
reference_character_tokens: reference_characters,
|
||||
resolution,
|
||||
aspect_ratio,
|
||||
bitrate,
|
||||
duration_seconds: duration_seconds.map(|d| d as u16),
|
||||
video_batch_count: Some(batch_count),
|
||||
negative_prompt: None,
|
||||
@@ -234,6 +238,19 @@ pub fn plan_batch_count(
|
||||
}
|
||||
}
|
||||
|
||||
/// Translate the router-facing bitrate into the API `CommonBitrate`.
|
||||
///
|
||||
/// Bitrate does not affect cost. An unset value leaves the field `None` (the
|
||||
/// API applies its default); `Normal` and `High` map to their `CommonBitrate`
|
||||
/// counterparts.
|
||||
fn plan_bitrate(bitrate: Option<RouterBitrate>) -> Option<CommonBitrateEnum> {
|
||||
match bitrate {
|
||||
None => None,
|
||||
Some(RouterBitrate::Normal) => Some(CommonBitrateEnum::Normal),
|
||||
Some(RouterBitrate::High) => Some(CommonBitrateEnum::High),
|
||||
}
|
||||
}
|
||||
|
||||
/// Duration: 4-15 seconds.
|
||||
pub fn plan_duration(
|
||||
duration_seconds: Option<u16>,
|
||||
@@ -255,3 +272,27 @@ pub fn plan_duration(
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
mod bitrate_translation {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn none_stays_none() {
|
||||
assert_eq!(plan_bitrate(None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_maps_to_common_normal() {
|
||||
assert_eq!(plan_bitrate(Some(RouterBitrate::Normal)), Some(CommonBitrateEnum::Normal));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_maps_to_common_high() {
|
||||
assert_eq!(plan_bitrate(Some(RouterBitrate::High)), Some(CommonBitrateEnum::High));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
@@ -24,6 +24,8 @@ pub fn build_artcraft_seedance_2p0(builder: GenerateVideoRequestBuilder) -> Resu
|
||||
mod tests {
|
||||
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum;
|
||||
use crate::api::router_bitrate::RouterBitrate;
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
@@ -72,6 +74,12 @@ mod tests {
|
||||
let req = unwrap_request(make_builder(|b| { b.video_batch_count = Some(4); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitrate_passed_through() {
|
||||
let req = unwrap_request(make_builder(|b| { b.bitrate = Some(RouterBitrate::High); }));
|
||||
assert_eq!(req.request.bitrate, Some(CommonBitrateEnum::High));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Resolution ──
|
||||
|
||||
+8
@@ -24,6 +24,8 @@ pub fn build_artcraft_seedance_2p0_bp(builder: GenerateVideoRequestBuilder) -> R
|
||||
mod tests {
|
||||
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum;
|
||||
use crate::api::router_bitrate::RouterBitrate;
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
@@ -70,6 +72,12 @@ mod tests {
|
||||
let req = unwrap_request(make_builder(|b| { b.video_batch_count = Some(4); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitrate_passed_through() {
|
||||
let req = unwrap_request(make_builder(|b| { b.bitrate = Some(RouterBitrate::High); }));
|
||||
assert_eq!(req.request.bitrate, Some(CommonBitrateEnum::High));
|
||||
}
|
||||
}
|
||||
|
||||
mod resolution_tests {
|
||||
|
||||
+8
@@ -24,6 +24,8 @@ pub fn build_artcraft_seedance_2p0_bp_fast(builder: GenerateVideoRequestBuilder)
|
||||
mod tests {
|
||||
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum;
|
||||
use crate::api::router_bitrate::RouterBitrate;
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
@@ -72,6 +74,12 @@ mod tests {
|
||||
let req = unwrap_request(make_builder(|b| { b.video_batch_count = Some(4); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitrate_passed_through() {
|
||||
let req = unwrap_request(make_builder(|b| { b.bitrate = Some(RouterBitrate::High); }));
|
||||
assert_eq!(req.request.bitrate, Some(CommonBitrateEnum::High));
|
||||
}
|
||||
}
|
||||
|
||||
mod resolution_tests {
|
||||
|
||||
+8
@@ -24,6 +24,8 @@ pub fn build_artcraft_seedance_2p0_bpu(builder: GenerateVideoRequestBuilder) ->
|
||||
mod tests {
|
||||
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum;
|
||||
use crate::api::router_bitrate::RouterBitrate;
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
@@ -70,6 +72,12 @@ mod tests {
|
||||
let req = unwrap_request(make_builder(|b| { b.video_batch_count = Some(4); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitrate_passed_through() {
|
||||
let req = unwrap_request(make_builder(|b| { b.bitrate = Some(RouterBitrate::High); }));
|
||||
assert_eq!(req.request.bitrate, Some(CommonBitrateEnum::High));
|
||||
}
|
||||
}
|
||||
|
||||
mod resolution_tests {
|
||||
|
||||
+8
@@ -24,6 +24,8 @@ pub fn build_artcraft_seedance_2p0_bpu_fast(builder: GenerateVideoRequestBuilder
|
||||
mod tests {
|
||||
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum;
|
||||
use crate::api::router_bitrate::RouterBitrate;
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
@@ -72,6 +74,12 @@ mod tests {
|
||||
let req = unwrap_request(make_builder(|b| { b.video_batch_count = Some(4); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitrate_passed_through() {
|
||||
let req = unwrap_request(make_builder(|b| { b.bitrate = Some(RouterBitrate::High); }));
|
||||
assert_eq!(req.request.bitrate, Some(CommonBitrateEnum::High));
|
||||
}
|
||||
}
|
||||
|
||||
mod resolution_tests {
|
||||
|
||||
+8
@@ -25,6 +25,8 @@ mod tests {
|
||||
use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioEnum;
|
||||
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum;
|
||||
use crate::api::router_bitrate::RouterBitrate;
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
@@ -83,6 +85,12 @@ mod tests {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| { b.video_batch_count = Some(4); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitrate_passed_through() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| { b.bitrate = Some(RouterBitrate::High); }));
|
||||
assert_eq!(req.request.bitrate, Some(CommonBitrateEnum::High));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Aspect ratio ──
|
||||
|
||||
+8
@@ -24,6 +24,8 @@ pub fn build_artcraft_seedance_2p0_u(builder: GenerateVideoRequestBuilder) -> Re
|
||||
mod tests {
|
||||
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum;
|
||||
use crate::api::router_bitrate::RouterBitrate;
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
@@ -72,6 +74,12 @@ mod tests {
|
||||
let req = unwrap_request(make_builder(|b| { b.video_batch_count = Some(4); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitrate_passed_through() {
|
||||
let req = unwrap_request(make_builder(|b| { b.bitrate = Some(RouterBitrate::High); }));
|
||||
assert_eq!(req.request.bitrate, Some(CommonBitrateEnum::High));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Resolution ──
|
||||
|
||||
+8
@@ -25,6 +25,8 @@ mod tests {
|
||||
use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioEnum;
|
||||
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum;
|
||||
use crate::api::router_bitrate::RouterBitrate;
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
@@ -83,6 +85,12 @@ mod tests {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| { b.video_batch_count = Some(4); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitrate_passed_through() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| { b.bitrate = Some(RouterBitrate::High); }));
|
||||
assert_eq!(req.request.bitrate, Some(CommonBitrateEnum::High));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Aspect ratio ──
|
||||
|
||||
+47
@@ -1,10 +1,12 @@
|
||||
use seedance2pro_client::generate::video::generate_seedance_2p0::{
|
||||
KinoviSeedance2p0AspectRatio as KinoviAspectRatio,
|
||||
KinoviSeedance2p0Bitrate as KinoviBitrate,
|
||||
KinoviSeedance2p0BatchCount as KinoviBatchCount,
|
||||
KinoviSeedance2p0OutputResolution as KinoviOutputResolution,
|
||||
};
|
||||
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_bitrate::RouterBitrate;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
@@ -26,6 +28,7 @@ fn do_build_kinovi_seedance_2p0(mut builder: GenerateVideoRequestBuilder) -> Res
|
||||
let resolution = plan_output_resolution(builder.resolution.take(), strategy)?;
|
||||
let batch_count = plan_batch_count(builder.video_batch_count.take(), strategy)?;
|
||||
let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?;
|
||||
let bitrate = plan_bitrate(builder.bitrate.take());
|
||||
let prompt = builder.prompt.take().unwrap_or_default();
|
||||
|
||||
let unhandled_request_state = KinoviSeedance2p0RemainingItems {
|
||||
@@ -42,6 +45,7 @@ fn do_build_kinovi_seedance_2p0(mut builder: GenerateVideoRequestBuilder) -> Res
|
||||
resolution,
|
||||
batch_count,
|
||||
duration_seconds,
|
||||
bitrate,
|
||||
prompt,
|
||||
unhandled_request_state: Some(unhandled_request_state),
|
||||
})
|
||||
@@ -195,6 +199,16 @@ fn plan_duration(
|
||||
}
|
||||
}
|
||||
|
||||
// Seedance 2.0 Pro bitrate: "standard" (default, field omitted) or "high".
|
||||
// Bitrate does not affect cost. `Normal` and an unset value both map to the
|
||||
// standard bitrate (None); only `High` requests the higher bitrate.
|
||||
fn plan_bitrate(bitrate: Option<RouterBitrate>) -> Option<KinoviBitrate> {
|
||||
match bitrate {
|
||||
Some(RouterBitrate::High) => Some(KinoviBitrate::High),
|
||||
Some(RouterBitrate::Normal) | None => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use seedance2pro_client::generate::video::generate_seedance_2p0::{
|
||||
@@ -391,6 +405,39 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bitrate conversions ──
|
||||
|
||||
mod bitrate_conversions {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bitrate_high() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
bitrate: Some(RouterBitrate::High),
|
||||
..seedance2pro_builder()
|
||||
};
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p0(builder));
|
||||
assert!(matches!(draft.bitrate, Some(KinoviBitrate::High)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitrate_normal_maps_to_standard() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
bitrate: Some(RouterBitrate::Normal),
|
||||
..seedance2pro_builder()
|
||||
};
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p0(builder));
|
||||
assert!(draft.bitrate.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitrate_none_maps_to_standard() {
|
||||
let builder = GenerateVideoRequestBuilder { bitrate: None, ..seedance2pro_builder() };
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p0(builder));
|
||||
assert!(draft.bitrate.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
// ── unhandled_request_state: media refs are placed there ──
|
||||
|
||||
mod unhandled_request_state {
|
||||
|
||||
+2
@@ -63,6 +63,7 @@ impl KinoviSeedance2p0CostState {
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
};
|
||||
|
||||
let costs = pricing_request.calculate_costs();
|
||||
@@ -476,6 +477,7 @@ mod tests {
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -1,5 +1,5 @@
|
||||
use seedance2pro_client::generate::video::generate_seedance_2p0::{
|
||||
GenerateSeedance2p0Request, KinoviSeedance2p0AspectRatio,
|
||||
GenerateSeedance2p0Request, KinoviSeedance2p0AspectRatio, KinoviSeedance2p0Bitrate,
|
||||
KinoviSeedance2p0BatchCount, KinoviSeedance2p0OutputResolution,
|
||||
};
|
||||
|
||||
@@ -26,6 +26,7 @@ pub struct KinoviSeedance2p0DraftState {
|
||||
pub resolution: Option<KinoviSeedance2p0OutputResolution>,
|
||||
pub duration_seconds: u8,
|
||||
pub batch_count: KinoviSeedance2p0BatchCount,
|
||||
pub bitrate: Option<KinoviSeedance2p0Bitrate>,
|
||||
|
||||
// Pending types that need to be queried.
|
||||
pub unhandled_request_state: Option<KinoviSeedance2p0RemainingItems>,
|
||||
@@ -93,6 +94,7 @@ impl KinoviSeedance2p0DraftState {
|
||||
reference_audio_urls,
|
||||
character_ids,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: self.bitrate,
|
||||
};
|
||||
|
||||
Ok(KinoviSeedance2p0RequestState { request })
|
||||
|
||||
+47
@@ -1,10 +1,12 @@
|
||||
use seedance2pro_client::generate::video::generate_seedance_2p0_fast::{
|
||||
KinoviSeedance2p0FastAspectRatio as KinoviAspectRatio,
|
||||
KinoviSeedance2p0FastBitrate as KinoviBitrate,
|
||||
KinoviSeedance2p0FastBatchCount as KinoviBatchCount,
|
||||
KinoviSeedance2p0FastOutputResolution as KinoviOutputResolution,
|
||||
};
|
||||
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_bitrate::RouterBitrate;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
@@ -26,6 +28,7 @@ fn do_build_kinovi_seedance_2p0_fast(mut builder: GenerateVideoRequestBuilder) -
|
||||
let resolution = plan_output_resolution(builder.resolution.take(), strategy)?;
|
||||
let batch_count = plan_batch_count(builder.video_batch_count.take(), strategy)?;
|
||||
let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?;
|
||||
let bitrate = plan_bitrate(builder.bitrate.take());
|
||||
let prompt = builder.prompt.take().unwrap_or_default();
|
||||
|
||||
let unhandled_request_state = KinoviSeedance2p0FastRemainingItems {
|
||||
@@ -42,6 +45,7 @@ fn do_build_kinovi_seedance_2p0_fast(mut builder: GenerateVideoRequestBuilder) -
|
||||
resolution,
|
||||
batch_count,
|
||||
duration_seconds,
|
||||
bitrate,
|
||||
prompt,
|
||||
unhandled_request_state: Some(unhandled_request_state),
|
||||
})
|
||||
@@ -210,6 +214,16 @@ fn plan_duration(
|
||||
}
|
||||
}
|
||||
|
||||
// Seedance 2.0 Fast bitrate: "standard" (default, field omitted) or "high".
|
||||
// Bitrate does not affect cost. `Normal` and an unset value both map to the
|
||||
// standard bitrate (None); only `High` requests the higher bitrate.
|
||||
fn plan_bitrate(bitrate: Option<RouterBitrate>) -> Option<KinoviBitrate> {
|
||||
match bitrate {
|
||||
Some(RouterBitrate::High) => Some(KinoviBitrate::High),
|
||||
Some(RouterBitrate::Normal) | None => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use seedance2pro_client::generate::video::generate_seedance_2p0_fast::{
|
||||
@@ -430,6 +444,39 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bitrate conversions ──
|
||||
|
||||
mod bitrate_conversions {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bitrate_high() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
bitrate: Some(RouterBitrate::High),
|
||||
..seedance2pro_fast_builder()
|
||||
};
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder));
|
||||
assert!(matches!(draft.bitrate, Some(KinoviBitrate::High)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitrate_normal_maps_to_standard() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
bitrate: Some(RouterBitrate::Normal),
|
||||
..seedance2pro_fast_builder()
|
||||
};
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder));
|
||||
assert!(draft.bitrate.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitrate_none_maps_to_standard() {
|
||||
let builder = GenerateVideoRequestBuilder { bitrate: None, ..seedance2pro_fast_builder() };
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder));
|
||||
assert!(draft.bitrate.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
// ── unhandled_request_state: media refs are placed there ──
|
||||
|
||||
mod unhandled_request_state {
|
||||
|
||||
+2
@@ -63,6 +63,7 @@ impl KinoviSeedance2p0FastCostState {
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
};
|
||||
|
||||
let costs = pricing_request.calculate_costs();
|
||||
@@ -428,6 +429,7 @@ mod tests {
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -1,5 +1,5 @@
|
||||
use seedance2pro_client::generate::video::generate_seedance_2p0_fast::{
|
||||
GenerateSeedance2p0FastRequest, KinoviSeedance2p0FastAspectRatio,
|
||||
GenerateSeedance2p0FastRequest, KinoviSeedance2p0FastAspectRatio, KinoviSeedance2p0FastBitrate,
|
||||
KinoviSeedance2p0FastBatchCount, KinoviSeedance2p0FastOutputResolution,
|
||||
};
|
||||
|
||||
@@ -24,6 +24,7 @@ pub struct KinoviSeedance2p0FastDraftState {
|
||||
pub resolution: Option<KinoviSeedance2p0FastOutputResolution>,
|
||||
pub duration_seconds: u8,
|
||||
pub batch_count: KinoviSeedance2p0FastBatchCount,
|
||||
pub bitrate: Option<KinoviSeedance2p0FastBitrate>,
|
||||
|
||||
pub unhandled_request_state: Option<KinoviSeedance2p0FastRemainingItems>,
|
||||
}
|
||||
@@ -90,6 +91,7 @@ impl KinoviSeedance2p0FastDraftState {
|
||||
reference_audio_urls,
|
||||
character_ids,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: self.bitrate,
|
||||
};
|
||||
|
||||
Ok(KinoviSeedance2p0FastRequestState { request })
|
||||
|
||||
@@ -351,6 +351,7 @@ pub fn base_video_request() -> GenerateVideoRequestBuilder {
|
||||
reference_character_tokens: None,
|
||||
resolution: None,
|
||||
aspect_ratio: None,
|
||||
bitrate: None,
|
||||
duration_seconds: None,
|
||||
video_batch_count: None,
|
||||
generate_audio: None,
|
||||
|
||||
@@ -129,6 +129,7 @@ pub async fn generate_happy_horse_1p0(
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: Some(false),
|
||||
bitrate: None,
|
||||
};
|
||||
|
||||
let raw_response = workflow_run_task(WorkflowRunTaskArgs {
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::error::seedance2pro_error::Seedance2ProError;
|
||||
use crate::cost::kinovi_seedance_generation_cost::KinoviSeedanceGenerationCost;
|
||||
use crate::requests::kinovi_host::KinoviHost;
|
||||
use crate::requests::workflow_run_task::workflow_run_task::{
|
||||
workflow_run_task, KinoviAspectRatioRaw, KinoviBatchCountRaw,
|
||||
workflow_run_task, KinoviAspectRatioRaw, KinoviBatchCountRaw, KinoviBitrateRaw,
|
||||
KinoviModelTypeRaw, KinoviOutputResolutionRaw, WorkflowRunTaskArgs,
|
||||
WorkflowRunTaskRequest,
|
||||
};
|
||||
@@ -32,6 +32,9 @@ pub struct GenerateSeedance2p0Request {
|
||||
pub reference_audio_urls: Option<Vec<String>>,
|
||||
pub character_ids: Option<Vec<String>>,
|
||||
pub use_face_blur_hack: Option<bool>,
|
||||
/// Output video bitrate. None defaults to "standard"; `High` requests a
|
||||
/// higher bitrate. Does not affect cost.
|
||||
pub bitrate: Option<KinoviSeedance2p0Bitrate>,
|
||||
}
|
||||
|
||||
// ── Enums ──
|
||||
@@ -60,6 +63,11 @@ pub enum KinoviSeedance2p0BatchCount {
|
||||
Four,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum KinoviSeedance2p0Bitrate {
|
||||
High,
|
||||
}
|
||||
|
||||
// ── Pricing ──
|
||||
//
|
||||
// Seedance 2.0 Pro credit pricing:
|
||||
@@ -178,6 +186,7 @@ pub async fn generate_seedance_2p0(
|
||||
reference_audio_urls: req.reference_audio_urls,
|
||||
character_ids: req.character_ids,
|
||||
use_face_blur_hack: req.use_face_blur_hack,
|
||||
bitrate: map_bitrate(req.bitrate),
|
||||
};
|
||||
|
||||
let raw_response = workflow_run_task(WorkflowRunTaskArgs {
|
||||
@@ -224,6 +233,13 @@ fn map_batch_count(bc: Option<KinoviSeedance2p0BatchCount>) -> KinoviBatchCountR
|
||||
}
|
||||
}
|
||||
|
||||
fn map_bitrate(bitrate: Option<KinoviSeedance2p0Bitrate>) -> Option<KinoviBitrateRaw> {
|
||||
match bitrate {
|
||||
Some(KinoviSeedance2p0Bitrate::High) => Some(KinoviBitrateRaw::High),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ──
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -256,6 +272,7 @@ mod tests {
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -810,6 +827,7 @@ mod tests {
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
};
|
||||
assert_eq!(
|
||||
req.calculate_costs().total_cost.kinovi_credits, baseline,
|
||||
@@ -817,6 +835,21 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bitrate doesn't affect cost ──
|
||||
|
||||
#[test]
|
||||
fn high_bitrate_does_not_affect_credits() {
|
||||
let baseline = r720(5).calculate_costs().total_cost.kinovi_credits;
|
||||
|
||||
let mut high = r720(5);
|
||||
high.bitrate = Some(KinoviSeedance2p0Bitrate::High);
|
||||
|
||||
assert_eq!(
|
||||
high.calculate_costs().total_cost.kinovi_credits, baseline,
|
||||
"High bitrate should not change credits from baseline {}", baseline,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
use crate::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs};
|
||||
@@ -849,6 +882,7 @@ mod tests {
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("t2v default — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
@@ -879,6 +913,7 @@ mod tests {
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("t2v 1080p — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
@@ -912,6 +947,7 @@ mod tests {
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("t2v 21:9 — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
@@ -943,6 +979,7 @@ mod tests {
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("keyframe 21:9 — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
@@ -978,6 +1015,7 @@ mod tests {
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("keyframe — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
@@ -1010,6 +1048,7 @@ mod tests {
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("keyframe start+end — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
@@ -1046,6 +1085,7 @@ mod tests {
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("image ref — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
@@ -1082,6 +1122,7 @@ mod tests {
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("video ref — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
@@ -1116,6 +1157,7 @@ mod tests {
|
||||
reference_audio_urls: None,
|
||||
character_ids: Some(vec![STEAMPUNK_CLOWN_ID.to_string()]),
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("character ref — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
@@ -1146,6 +1188,7 @@ mod tests {
|
||||
reference_audio_urls: None,
|
||||
character_ids: Some(vec![STEAMPUNK_CLOWN_ID.to_string(), MOCHI_ID.to_string()]),
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("two characters — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
|
||||
+43
-1
@@ -3,7 +3,7 @@ use crate::error::seedance2pro_error::Seedance2ProError;
|
||||
use crate::cost::kinovi_seedance_generation_cost::KinoviSeedanceGenerationCost;
|
||||
use crate::requests::kinovi_host::KinoviHost;
|
||||
use crate::requests::workflow_run_task::workflow_run_task::{
|
||||
workflow_run_task, KinoviAspectRatioRaw, KinoviBatchCountRaw,
|
||||
workflow_run_task, KinoviAspectRatioRaw, KinoviBatchCountRaw, KinoviBitrateRaw,
|
||||
KinoviModelTypeRaw, KinoviOutputResolutionRaw, WorkflowRunTaskArgs,
|
||||
WorkflowRunTaskRequest,
|
||||
};
|
||||
@@ -32,6 +32,9 @@ pub struct GenerateSeedance2p0FastRequest {
|
||||
pub reference_audio_urls: Option<Vec<String>>,
|
||||
pub character_ids: Option<Vec<String>>,
|
||||
pub use_face_blur_hack: Option<bool>,
|
||||
/// Output video bitrate. None defaults to "standard"; `High` requests a
|
||||
/// higher bitrate. Does not affect cost.
|
||||
pub bitrate: Option<KinoviSeedance2p0FastBitrate>,
|
||||
}
|
||||
|
||||
// ── Enums ──
|
||||
@@ -59,6 +62,11 @@ pub enum KinoviSeedance2p0FastBatchCount {
|
||||
Four,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum KinoviSeedance2p0FastBitrate {
|
||||
High,
|
||||
}
|
||||
|
||||
// ── Pricing ──
|
||||
//
|
||||
// Seedance 2.0 Fast credit pricing:
|
||||
@@ -173,6 +181,7 @@ pub async fn generate_seedance_2p0_fast(
|
||||
reference_audio_urls: req.reference_audio_urls,
|
||||
character_ids: req.character_ids,
|
||||
use_face_blur_hack: req.use_face_blur_hack,
|
||||
bitrate: map_bitrate(req.bitrate),
|
||||
};
|
||||
|
||||
let raw_response = workflow_run_task(WorkflowRunTaskArgs {
|
||||
@@ -218,6 +227,13 @@ fn map_batch_count(bc: Option<KinoviSeedance2p0FastBatchCount>) -> KinoviBatchCo
|
||||
}
|
||||
}
|
||||
|
||||
fn map_bitrate(bitrate: Option<KinoviSeedance2p0FastBitrate>) -> Option<KinoviBitrateRaw> {
|
||||
match bitrate {
|
||||
Some(KinoviSeedance2p0FastBitrate::High) => Some(KinoviBitrateRaw::High),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ──
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -250,6 +266,7 @@ mod tests {
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -700,6 +717,7 @@ mod tests {
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
};
|
||||
assert_eq!(
|
||||
req.calculate_costs().total_cost.kinovi_credits, baseline,
|
||||
@@ -707,6 +725,21 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bitrate doesn't affect cost ──
|
||||
|
||||
#[test]
|
||||
fn high_bitrate_does_not_affect_credits() {
|
||||
let baseline = r720(5).calculate_costs().total_cost.kinovi_credits;
|
||||
|
||||
let mut high = r720(5);
|
||||
high.bitrate = Some(KinoviSeedance2p0FastBitrate::High);
|
||||
|
||||
assert_eq!(
|
||||
high.calculate_costs().total_cost.kinovi_credits, baseline,
|
||||
"High bitrate should not change credits from baseline {}", baseline,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
use crate::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs};
|
||||
@@ -739,6 +772,7 @@ mod tests {
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("t2v fast default — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
@@ -769,6 +803,7 @@ mod tests {
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("t2v fast 480p — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
@@ -802,6 +837,7 @@ mod tests {
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("fast t2v 21:9 480p — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
@@ -833,6 +869,7 @@ mod tests {
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("fast keyframe 21:9 — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
@@ -868,6 +905,7 @@ mod tests {
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("fast keyframe — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
@@ -904,6 +942,7 @@ mod tests {
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("fast image ref — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
@@ -940,6 +979,7 @@ mod tests {
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("fast video ref — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
@@ -974,6 +1014,7 @@ mod tests {
|
||||
reference_audio_urls: None,
|
||||
character_ids: Some(vec![MOCHI_ID.to_string()]),
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("fast character — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
@@ -1004,6 +1045,7 @@ mod tests {
|
||||
reference_audio_urls: None,
|
||||
character_ids: Some(vec![STEAMPUNK_CLOWN_ID.to_string(), MOCHI_ID.to_string()]),
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("fast two characters — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
|
||||
@@ -35,6 +35,41 @@ pub struct PollOrdersResponse {
|
||||
|
||||
// --- Public types ---
|
||||
|
||||
/// The status of one order (one generation task — video or image).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OrderStatus {
|
||||
pub order_id: String,
|
||||
|
||||
pub task_status: TaskStatus,
|
||||
|
||||
/// Top-level result URL (video file for video orders, the first image of
|
||||
/// the 4-image grid for Midjourney image orders). Populated when
|
||||
/// `task_status` is `Completed`.
|
||||
pub result_url: Option<String>,
|
||||
|
||||
/// Detailed result entries. One entry per video frame (video orders), or
|
||||
/// four entries per Midjourney task (image orders).
|
||||
pub results: Vec<MediaResult>,
|
||||
|
||||
/// Structured failure reason. Populated when `task_status` is `Failed` or `fail_reason` is present.
|
||||
pub fail_reason: Option<FailureReason>,
|
||||
|
||||
/// ISO 8601 creation timestamp (e.g. `"2026-02-19T01:20:50.398Z"`).
|
||||
pub created_at: String,
|
||||
|
||||
/// Parsed `created_at` as a `DateTime<Utc>`. `None` if the raw string could not be parsed.
|
||||
pub created_at_utc: Option<DateTime<Utc>>,
|
||||
|
||||
/// Whether this order produced an image or a video. `None` for older
|
||||
/// polling responses that didn't include the field — those came from the
|
||||
/// video-only era and can be treated as video by callers that need to.
|
||||
pub media_type: Option<OrderMediaType>,
|
||||
|
||||
/// The Kinovi credits charged for the order (the API's `totalCredits`).
|
||||
/// `None` for older polling responses that didn't include the field.
|
||||
pub total_credits: Option<u32>,
|
||||
}
|
||||
|
||||
/// The lifecycle status of a video generation task.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum TaskStatus {
|
||||
@@ -70,19 +105,18 @@ impl TaskStatus {
|
||||
/// orders, or one of Midjourney's 4 generated images for image orders.
|
||||
/// (Originally named for the video-only days; the underlying shape is shared.)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VideoResult {
|
||||
pub struct MediaResult {
|
||||
pub url: String,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Frame width in pixels. `None` when the API returns `null`/omits it
|
||||
/// (it does so intermittently); callers should persist NULL, not a sentinel.
|
||||
pub maybe_width: Option<u32>,
|
||||
/// Frame height in pixels. `None` when the API returns `null`/omits it.
|
||||
pub maybe_height: Option<u32>,
|
||||
// NB: We don't need these.
|
||||
// /// Width / height ratio (e.g. 1.777… for 16:9). `None` when the server returns null (e.g. width/height are 0).
|
||||
// /// Width / height ratio (e.g. 1.777… for 16:9). `None` when the server returns null.
|
||||
// pub ratio: Option<f64>,
|
||||
}
|
||||
|
||||
/// Type-neutral alias for [`VideoResult`]. Prefer this name in new
|
||||
/// callers that deal with both image and video orders.
|
||||
pub type MediaResult = VideoResult;
|
||||
|
||||
/// Media type of an order. Midjourney orders are `Image`; the various
|
||||
/// Seedance/keyframe/reference flows are `Video`.
|
||||
///
|
||||
@@ -108,41 +142,6 @@ impl OrderMediaType {
|
||||
pub fn is_video(&self) -> bool { matches!(self, Self::Video) }
|
||||
}
|
||||
|
||||
/// The status of one order (one generation task — video or image).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OrderStatus {
|
||||
pub order_id: String,
|
||||
|
||||
pub task_status: TaskStatus,
|
||||
|
||||
/// Top-level result URL (video file for video orders, the first image of
|
||||
/// the 4-image grid for Midjourney image orders). Populated when
|
||||
/// `task_status` is `Completed`.
|
||||
pub result_url: Option<String>,
|
||||
|
||||
/// Detailed result entries. One entry per video frame (video orders), or
|
||||
/// four entries per Midjourney task (image orders).
|
||||
pub results: Vec<VideoResult>,
|
||||
|
||||
/// Structured failure reason. Populated when `task_status` is `Failed` or `fail_reason` is present.
|
||||
pub fail_reason: Option<FailureReason>,
|
||||
|
||||
/// ISO 8601 creation timestamp (e.g. `"2026-02-19T01:20:50.398Z"`).
|
||||
pub created_at: String,
|
||||
|
||||
/// Parsed `created_at` as a `DateTime<Utc>`. `None` if the raw string could not be parsed.
|
||||
pub created_at_utc: Option<DateTime<Utc>>,
|
||||
|
||||
/// Whether this order produced an image or a video. `None` for older
|
||||
/// polling responses that didn't include the field — those came from the
|
||||
/// video-only era and can be treated as video by callers that need to.
|
||||
pub media_type: Option<OrderMediaType>,
|
||||
|
||||
/// The Kinovi credits charged for the order (the API's `totalCredits`).
|
||||
/// `None` for older polling responses that didn't include the field.
|
||||
pub total_credits: Option<u32>,
|
||||
}
|
||||
|
||||
// --- Implementation ---
|
||||
|
||||
pub async fn poll_orders(args: PollOrdersArgs<'_>) -> Result<PollOrdersResponse, Seedance2ProError> {
|
||||
@@ -234,10 +233,10 @@ pub async fn poll_orders(args: PollOrdersArgs<'_>) -> Result<PollOrdersResponse,
|
||||
order_id: o.order_id,
|
||||
task_status,
|
||||
result_url: o.result_url,
|
||||
results: o.results.into_iter().map(|r| VideoResult {
|
||||
results: o.results.into_iter().map(|r| MediaResult {
|
||||
url: r.url,
|
||||
width: r.width,
|
||||
height: r.height,
|
||||
maybe_width: r.maybe_width,
|
||||
maybe_height: r.maybe_height,
|
||||
}).collect(),
|
||||
fail_reason,
|
||||
created_at: o.created_at,
|
||||
@@ -425,8 +424,8 @@ mod tests {
|
||||
order_id: o.order_id,
|
||||
task_status,
|
||||
result_url: o.result_url,
|
||||
results: o.results.into_iter().map(|r| VideoResult {
|
||||
url: r.url, width: r.width, height: r.height,
|
||||
results: o.results.into_iter().map(|r| MediaResult {
|
||||
url: r.url, maybe_width: r.maybe_width, maybe_height: r.maybe_height,
|
||||
}).collect(),
|
||||
fail_reason,
|
||||
created_at: o.created_at,
|
||||
@@ -476,11 +475,52 @@ mod tests {
|
||||
let orders = parse_orders(body);
|
||||
assert_eq!(orders.len(), 1);
|
||||
assert_eq!(orders[0].results.len(), 1);
|
||||
assert_eq!(orders[0].results[0].width, 569);
|
||||
assert_eq!(orders[0].results[0].height, 1023);
|
||||
assert_eq!(orders[0].results[0].maybe_width, Some(569));
|
||||
assert_eq!(orders[0].results[0].maybe_height, Some(1023));
|
||||
assert_eq!(orders[0].total_credits, Some(13));
|
||||
}
|
||||
|
||||
/// Regression (paging outage): the Kinovi API started returning result
|
||||
/// `width`/`height` as `null`, which blew up the whole poll with
|
||||
/// "invalid type: null, expected a JSON number" and stalled the queue.
|
||||
/// They must parse as `None` and let the rest of the response parse.
|
||||
#[test]
|
||||
fn null_dimensions_parse_as_none() {
|
||||
let body = r#"[{"result":{"data":{"json":{"orders":[{
|
||||
"orderId":"ord_foo",
|
||||
"resultUrl":"https://static.seedance2-pro.com/videos/seedance_video.mp4",
|
||||
"taskStatus":"COMPLETED",
|
||||
"results":[{"url":"https://static.seedance2-pro.com/videos/seedance_video.mp4","width":null,"height":null}],
|
||||
"failReason":null,
|
||||
"createdAt":"2026-02-19T01:20:50.398Z",
|
||||
"mediaType":"video"
|
||||
}],"nextCursor":null}}}}]"#;
|
||||
let orders = parse_orders(body);
|
||||
assert_eq!(orders.len(), 1);
|
||||
assert_eq!(orders[0].results.len(), 1);
|
||||
assert_eq!(orders[0].results[0].maybe_width, None);
|
||||
assert_eq!(orders[0].results[0].maybe_height, None);
|
||||
assert_eq!(orders[0].task_status, TaskStatus::Completed);
|
||||
}
|
||||
|
||||
/// Omitted `width`/`height` keys (not just `null`) also parse as `None`.
|
||||
#[test]
|
||||
fn missing_dimensions_parse_as_none() {
|
||||
let body = r#"[{"result":{"data":{"json":{"orders":[{
|
||||
"orderId":"ord_no_dims",
|
||||
"resultUrl":"https://example.com/v.mp4",
|
||||
"taskStatus":"COMPLETED",
|
||||
"results":[{"url":"https://example.com/v.mp4"}],
|
||||
"failReason":null,
|
||||
"createdAt":"2026-02-19T01:20:50.398Z",
|
||||
"mediaType":"video"
|
||||
}],"nextCursor":null}}}}]"#;
|
||||
let orders = parse_orders(body);
|
||||
assert_eq!(orders.len(), 1);
|
||||
assert_eq!(orders[0].results[0].maybe_width, None);
|
||||
assert_eq!(orders[0].results[0].maybe_height, None);
|
||||
}
|
||||
|
||||
/// Unrecognised mediaType values shouldn't crash deserialisation;
|
||||
/// they end up as `Unknown(_)`.
|
||||
#[test]
|
||||
@@ -634,4 +674,84 @@ mod tests {
|
||||
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Live diagnostic for the paging outage: try EACH Kinovi account's cookies
|
||||
/// from the job's secrets env file and cursor across several pages, reporting
|
||||
/// which account(s) parse cleanly and which blow up. Never prints cookies.
|
||||
///
|
||||
/// cargo test -p seedance2pro_client live_cursor_all_accounts -- --ignored --nocapture
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn live_cursor_all_accounts() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Info);
|
||||
|
||||
const SECRETS_ENV: &str = concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../../crates/service/job/seedance2_pro_job/config/seedance2-pro-job.development-secrets.env",
|
||||
);
|
||||
const ACCOUNTS: &[&str] = &[
|
||||
"SEEDANCE2PRO_VOLCENGINE_COOKIES",
|
||||
"SEEDANCE2PRO_BYTEPLUS_COOKIES",
|
||||
"SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES",
|
||||
];
|
||||
const MAX_PAGES: usize = 5;
|
||||
|
||||
let env_contents = std::fs::read_to_string(SECRETS_ENV)
|
||||
.unwrap_or_else(|e| panic!("could not read secrets env at {}: {}", SECRETS_ENV, e));
|
||||
|
||||
for account in ACCOUNTS {
|
||||
let Some(cookies) = read_env_value(&env_contents, account) else {
|
||||
println!("[{}] not present in secrets env — skipping", account);
|
||||
continue;
|
||||
};
|
||||
if cookies.is_empty() {
|
||||
println!("[{}] empty — skipping", account);
|
||||
continue;
|
||||
}
|
||||
|
||||
let session = Seedance2ProSession::from_cookies_string(cookies);
|
||||
let mut cursor: Option<u64> = None;
|
||||
let mut pages = 0usize;
|
||||
let mut total = 0usize;
|
||||
let mut ok = true;
|
||||
|
||||
for _ in 0..MAX_PAGES {
|
||||
match poll_orders(PollOrdersArgs { session: &session, cursor, host_override: None }).await {
|
||||
Ok(result) => {
|
||||
pages += 1;
|
||||
total += result.orders.len();
|
||||
cursor = result.next_cursor;
|
||||
if cursor.is_none() { break; }
|
||||
}
|
||||
Err(err) => {
|
||||
ok = false;
|
||||
println!("[{}] FAILED on page {}: {:?}", account, pages + 1, err);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!(
|
||||
"[{}] {} — {} page(s), {} order(s) parsed cleanly",
|
||||
account, if ok { "OK" } else { "ERROR" }, pages, total,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Minimal dotenv value reader: returns the value for `KEY=...` from raw env
|
||||
/// file contents, trimming surrounding quotes. Used so the diagnostic can try
|
||||
/// each account without depending on process-wide env being loaded.
|
||||
fn read_env_value(contents: &str, key: &str) -> Option<String> {
|
||||
for line in contents.lines() {
|
||||
let line = line.trim();
|
||||
if line.starts_with('#') { continue; }
|
||||
let Some((k, v)) = line.split_once('=') else { continue; };
|
||||
if k.trim() != key { continue; }
|
||||
let v = v.trim().trim_matches('"').trim_matches('\'').to_string();
|
||||
return Some(v);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use serde_derive::Deserialize;
|
||||
|
||||
use crate::utils::number_coercion::{
|
||||
de_opt_u32_int_or_float, de_opt_u64_int_or_float, de_u32_int_or_float,
|
||||
de_opt_u32_int_or_float, de_opt_u64_int_or_float,
|
||||
};
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
@@ -51,9 +51,11 @@ pub(super) struct RawOrder {
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(super) struct RawVideoResult {
|
||||
pub url: String,
|
||||
#[serde(deserialize_with = "de_u32_int_or_float")]
|
||||
pub width: u32,
|
||||
#[serde(deserialize_with = "de_u32_int_or_float")]
|
||||
pub height: u32,
|
||||
/// The API intermittently returns `null` (or omits) these dimensions, so
|
||||
/// they're optional rather than coerced to a sentinel.
|
||||
#[serde(rename = "width", default, deserialize_with = "de_opt_u32_int_or_float")]
|
||||
pub maybe_width: Option<u32>,
|
||||
#[serde(rename = "height", default, deserialize_with = "de_opt_u32_int_or_float")]
|
||||
pub maybe_height: Option<u32>,
|
||||
// pub ratio: Option<f64>,
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ pub (super) struct ApiParams {
|
||||
pub audio_urls: Option<Vec<String>>,
|
||||
#[serde(rename = "batchCount", skip_serializing_if = "Option::is_none")]
|
||||
pub batch_count: Option<u8>,
|
||||
#[serde(rename = "bitrate_mode", skip_serializing_if = "Option::is_none")]
|
||||
pub bitrate_mode: Option<&'static str>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
|
||||
+86
@@ -69,6 +69,10 @@ pub struct WorkflowRunTaskRequest {
|
||||
|
||||
/// Controls the `faceBlurMode` field: true sends "on", false sends "off", None omits it.
|
||||
pub use_face_blur_hack: Option<bool>,
|
||||
|
||||
/// Output video bitrate. None defaults to "standard" (the field is omitted);
|
||||
/// `High` sends `bitrate_mode: "high"`. Does not affect cost.
|
||||
pub bitrate: Option<KinoviBitrateRaw>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for WorkflowRunTaskRequest {
|
||||
@@ -87,6 +91,7 @@ impl std::fmt::Debug for WorkflowRunTaskRequest {
|
||||
.field("character_ids", &self.character_ids)
|
||||
.field("output_resolution", &self.output_resolution)
|
||||
.field("use_face_blur_hack", &self.use_face_blur_hack)
|
||||
.field("bitrate", &self.bitrate)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -268,6 +273,23 @@ impl KinoviModelTypeRaw {
|
||||
}
|
||||
}
|
||||
|
||||
/// Output video bitrate. When omitted, defaults to "standard".
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum KinoviBitrateRaw {
|
||||
/// High bitrate (`bitrate_mode: "high"`).
|
||||
High,
|
||||
}
|
||||
|
||||
impl KinoviBitrateRaw {
|
||||
/// Returns the API string to send, or None for "standard" (the default,
|
||||
/// which is expressed by omitting the field entirely).
|
||||
pub fn as_api_str(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::High => Some("high"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Response ---
|
||||
|
||||
pub struct WorkflowRunTaskResponse {
|
||||
@@ -361,6 +383,7 @@ pub async fn workflow_run_task(args: WorkflowRunTaskArgs<'_>) -> Result<Workflow
|
||||
uploaded_urls,
|
||||
audio_urls,
|
||||
batch_count,
|
||||
bitrate_mode: req.bitrate.and_then(|bitrate| bitrate.as_api_str()),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -470,6 +493,7 @@ mod tests {
|
||||
character_ids: None,
|
||||
output_resolution,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -635,6 +659,7 @@ mod tests {
|
||||
character_ids: None,
|
||||
output_resolution: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
};
|
||||
assert_eq!(
|
||||
req.estimate_credits(), baseline,
|
||||
@@ -738,6 +763,49 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bitrate serialization ──
|
||||
//
|
||||
// The optional `bitrate_mode` field is sent only when `High` is requested;
|
||||
// the standard (default) bitrate omits the field entirely.
|
||||
|
||||
mod bitrate_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn high_bitrate_serializes_to_high() {
|
||||
assert_eq!(KinoviBitrateRaw::High.as_api_str(), Some("high"));
|
||||
|
||||
let api_params = base_api_params(Some("high"));
|
||||
let json = serde_json::to_string(&api_params).unwrap();
|
||||
assert!(json.contains(r#""bitrate_mode":"high""#), "expected bitrate_mode in {json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_bitrate_omits_field() {
|
||||
let api_params = base_api_params(None);
|
||||
let json = serde_json::to_string(&api_params).unwrap();
|
||||
assert!(!json.contains("bitrate_mode"), "expected no bitrate_mode in {json}");
|
||||
}
|
||||
|
||||
fn base_api_params(bitrate_mode: Option<&'static str>) -> ApiParams {
|
||||
ApiParams {
|
||||
prompt: "a corgi".to_string(),
|
||||
resolution: "1280x720".to_string(),
|
||||
content_mode: "normal",
|
||||
model: "seedance-20",
|
||||
duration: "5s".to_string(),
|
||||
mode: "keyframe",
|
||||
output_resolution: None,
|
||||
face_blur_mode: None,
|
||||
character_ids: None,
|
||||
uploaded_urls: None,
|
||||
audio_urls: None,
|
||||
batch_count: None,
|
||||
bitrate_mode,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod real_requests {
|
||||
use super::*;
|
||||
|
||||
@@ -767,6 +835,7 @@ mod tests {
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
output_resolution: None,
|
||||
},
|
||||
};
|
||||
@@ -800,6 +869,7 @@ mod tests {
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
output_resolution: None,
|
||||
},
|
||||
};
|
||||
@@ -835,6 +905,7 @@ mod tests {
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
output_resolution: None,
|
||||
},
|
||||
};
|
||||
@@ -869,6 +940,7 @@ mod tests {
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
output_resolution: None,
|
||||
},
|
||||
};
|
||||
@@ -905,6 +977,7 @@ mod tests {
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
output_resolution: None,
|
||||
},
|
||||
};
|
||||
@@ -958,6 +1031,7 @@ mod tests {
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
output_resolution: None,
|
||||
},
|
||||
};
|
||||
@@ -1010,6 +1084,7 @@ mod tests {
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
output_resolution: None,
|
||||
},
|
||||
};
|
||||
@@ -1062,6 +1137,7 @@ mod tests {
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
output_resolution: None,
|
||||
},
|
||||
};
|
||||
@@ -1123,6 +1199,7 @@ mod tests {
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
output_resolution: None,
|
||||
},
|
||||
};
|
||||
@@ -1177,6 +1254,7 @@ mod tests {
|
||||
reference_audio_urls: Some(vec![upload_result.public_url]),
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
output_resolution: None,
|
||||
},
|
||||
};
|
||||
@@ -1216,6 +1294,7 @@ mod tests {
|
||||
reference_audio_urls: None,
|
||||
character_ids: Some(vec![STEAMPUNK_CLOWN_ID.to_string()]),
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
output_resolution: None,
|
||||
},
|
||||
};
|
||||
@@ -1249,6 +1328,7 @@ mod tests {
|
||||
reference_audio_urls: None,
|
||||
character_ids: Some(vec![MOCHI_ID.to_string()]),
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
output_resolution: None,
|
||||
},
|
||||
};
|
||||
@@ -1284,6 +1364,7 @@ mod tests {
|
||||
reference_audio_urls: None,
|
||||
character_ids: Some(vec![STEAMPUNK_CLOWN_ID.to_string()]),
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
output_resolution: None,
|
||||
},
|
||||
};
|
||||
@@ -1320,6 +1401,7 @@ mod tests {
|
||||
MOCHI_ID.to_string(),
|
||||
]),
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
output_resolution: None,
|
||||
},
|
||||
};
|
||||
@@ -1357,6 +1439,7 @@ mod tests {
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: Some(false),
|
||||
bitrate: None,
|
||||
output_resolution: Some(KinoviOutputResolutionRaw::TenEightyP),
|
||||
},
|
||||
};
|
||||
@@ -1408,6 +1491,7 @@ mod tests {
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: Some(false),
|
||||
bitrate: None,
|
||||
output_resolution: None,
|
||||
},
|
||||
};
|
||||
@@ -1459,6 +1543,7 @@ mod tests {
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: Some(false),
|
||||
bitrate: None,
|
||||
output_resolution: Some(KinoviOutputResolutionRaw::TenEightyP),
|
||||
},
|
||||
};
|
||||
@@ -1503,6 +1588,7 @@ mod tests {
|
||||
character_ids: None,
|
||||
output_resolution,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +119,7 @@ pub async fn run(state: &ArtcraftState, args: GenerateVideoArgs) -> anyhow::Resu
|
||||
reference_character_tokens: None,
|
||||
resolution: None,
|
||||
aspect_ratio: None,
|
||||
bitrate: None,
|
||||
quality: None,
|
||||
duration_seconds: Some(duration),
|
||||
video_batch_count: None,
|
||||
|
||||
+2
-2
@@ -60,8 +60,8 @@ fn order_to_json(order: &seedance2pro_client::requests::poll_orders::poll_orders
|
||||
"results": order.results.iter().map(|r| {
|
||||
serde_json::json!({
|
||||
"url": r.url,
|
||||
"width": r.width,
|
||||
"height": r.height,
|
||||
"width": r.maybe_width,
|
||||
"height": r.maybe_height,
|
||||
})
|
||||
}).collect::<Vec<_>>(),
|
||||
})
|
||||
|
||||
+2
@@ -427,6 +427,7 @@ async fn generate(
|
||||
reference_audio_urls: media.reference_audio_urls,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
},
|
||||
session,
|
||||
host_override: None,
|
||||
@@ -456,6 +457,7 @@ async fn generate(
|
||||
reference_audio_urls: media.reference_audio_urls,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
},
|
||||
session,
|
||||
host_override: None,
|
||||
|
||||
+1
@@ -146,6 +146,7 @@ mod tests {
|
||||
reference_character_tokens: None,
|
||||
resolution: None,
|
||||
aspect_ratio: None,
|
||||
bitrate: None,
|
||||
duration_seconds: None,
|
||||
video_batch_count: None,
|
||||
generate_audio: None,
|
||||
|
||||
+1
@@ -54,6 +54,7 @@ pub(super) async fn handle_artcraft_video_via_router(
|
||||
reference_character_tokens,
|
||||
resolution: request.resolution,
|
||||
aspect_ratio: request.aspect_ratio,
|
||||
bitrate: None,
|
||||
duration_seconds: request.duration_seconds,
|
||||
video_batch_count: request.video_batch_count,
|
||||
generate_audio: request.generate_audio,
|
||||
|
||||
@@ -9,6 +9,10 @@ pub fn add_artcraft_website(cors: Cors, _is_production: bool) -> Cors {
|
||||
// Hypothetical domains
|
||||
.allowed_origin("https://artcraft.ai")
|
||||
.allowed_origin("https://www.artcraft.ai")
|
||||
// Development against production
|
||||
.allowed_origin("http://localhost:4200")
|
||||
.allowed_origin("http://localhost:4201")
|
||||
.allowed_origin("http://localhost:4202")
|
||||
// Netlify project
|
||||
.allowed_origin_fn(|origin, _req_head| {
|
||||
netlify_branch_domain_matches(origin, "artcraft-website.netlify.app")
|
||||
|
||||
+11
-3
@@ -1,3 +1,4 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use log::warn;
|
||||
use sqlx::MySqlPool;
|
||||
|
||||
@@ -12,7 +13,7 @@ use tokens::tokens::users::UserToken;
|
||||
use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken;
|
||||
|
||||
/// A Seedance2Pro job that is waiting for a result from the external API.
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PendingSeedance2ProJob {
|
||||
pub job_token: InferenceJobToken,
|
||||
|
||||
@@ -29,9 +30,13 @@ pub struct PendingSeedance2ProJob {
|
||||
pub maybe_wallet_ledger_entry_token: Option<WalletLedgerEntryToken>,
|
||||
|
||||
pub maybe_platform_type: Option<PlatformType>,
|
||||
|
||||
/// When the job row was created. Useful for surfacing how long the oldest
|
||||
/// pending job has been waiting.
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
#[derive(Debug)]
|
||||
struct RawRecord {
|
||||
job_token: InferenceJobToken,
|
||||
order_id: Option<String>,
|
||||
@@ -42,6 +47,7 @@ struct RawRecord {
|
||||
maybe_prompt_token: Option<PromptToken>,
|
||||
maybe_wallet_ledger_entry_token: Option<WalletLedgerEntryToken>,
|
||||
maybe_platform_type: Option<PlatformType>,
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Returns all non-terminal Seedance2Pro video jobs that have an associated order_id.
|
||||
@@ -62,7 +68,8 @@ SELECT
|
||||
jobs.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,
|
||||
jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,
|
||||
jobs.maybe_wallet_ledger_entry_token as `maybe_wallet_ledger_entry_token: tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken`,
|
||||
jobs.platform_type as `maybe_platform_type: enums::common::platform_type::PlatformType`
|
||||
jobs.platform_type as `maybe_platform_type: enums::common::platform_type::PlatformType`,
|
||||
jobs.created_at
|
||||
|
||||
FROM generic_inference_jobs as jobs
|
||||
|
||||
@@ -100,6 +107,7 @@ LIMIT 25000
|
||||
maybe_prompt_token: record.maybe_prompt_token,
|
||||
maybe_wallet_ledger_entry_token: record.maybe_wallet_ledger_entry_token,
|
||||
maybe_platform_type: record.maybe_platform_type,
|
||||
created_at: record.created_at,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
+22
@@ -47,6 +47,28 @@ pub async fn mark_job_failed_by_token_from_connection(args: MarkJobFailedByToken
|
||||
}).await
|
||||
}
|
||||
|
||||
/// Permanently mark an inference job as failed using an arbitrary executor.
|
||||
/// Pass `&mut *transaction` to write inside an existing transaction (e.g.
|
||||
/// alongside a refund, after a `SELECT ... FOR UPDATE` re-check).
|
||||
pub async fn mark_job_failed_by_token_with_executor<'e, E>(
|
||||
executor: E,
|
||||
job_token: &InferenceJobToken,
|
||||
maybe_public_failure_reason: Option<&str>,
|
||||
internal_debugging_failure_reason: &str,
|
||||
maybe_frontend_failure_category: Option<FrontendFailureCategory>,
|
||||
) -> AnyhowResult<()>
|
||||
where
|
||||
E: sqlx::Executor<'e, Database = MySql>,
|
||||
{
|
||||
execute_mark_failed(ExecuteMarkJobFailedByTokenArgs {
|
||||
executor,
|
||||
job_token,
|
||||
maybe_public_failure_reason,
|
||||
internal_debugging_failure_reason,
|
||||
maybe_frontend_failure_category,
|
||||
}).await
|
||||
}
|
||||
|
||||
struct ExecuteMarkJobFailedByTokenArgs<'a, E> {
|
||||
executor: E,
|
||||
job_token: &'a InferenceJobToken,
|
||||
|
||||
@@ -7,3 +7,4 @@ pub mod mark_job_failed_by_token;
|
||||
pub mod mark_generic_inference_job_pending_and_grab_lock;
|
||||
pub mod mark_generic_inference_job_successfully_done;
|
||||
pub mod reap_stale_fakeyou_jobs;
|
||||
pub mod select_inference_job_status_for_update;
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use enums::common::job_status_plus::JobStatusPlus;
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
|
||||
/// `SELECT ... FOR UPDATE` the `status` of an inference job, locking the row
|
||||
/// for the duration of the surrounding transaction.
|
||||
///
|
||||
/// Pass `&mut *transaction` as the executor. The row lock prevents a concurrent
|
||||
/// finalizer (e.g. another poller, the web cancel path) from changing the
|
||||
/// job's terminal state between this read and the caller's subsequent write,
|
||||
/// so the caller can safely read-then-write the conclusion exactly once.
|
||||
///
|
||||
/// Returns `Ok(None)` when no row matches the token.
|
||||
pub async fn select_inference_job_status_for_update<'e, E>(
|
||||
executor: E,
|
||||
job_token: &InferenceJobToken,
|
||||
) -> Result<Option<JobStatusPlus>, sqlx::Error>
|
||||
where
|
||||
E: Executor<'e, Database = MySql>,
|
||||
{
|
||||
let maybe_row = sqlx::query!(
|
||||
r#"
|
||||
SELECT status as `status: enums::common::job_status_plus::JobStatusPlus`
|
||||
FROM generic_inference_jobs
|
||||
WHERE token = ?
|
||||
FOR UPDATE
|
||||
"#,
|
||||
job_token.as_str(),
|
||||
)
|
||||
.fetch_optional(executor)
|
||||
.await?;
|
||||
|
||||
Ok(maybe_row.map(|row| row.status))
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
use sqlx::MySqlPool;
|
||||
|
||||
use enums::common::job_status_plus::JobStatusPlus;
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
|
||||
/// Lightweight lookup of just an inference job's `status` by token.
|
||||
///
|
||||
/// Unlike [`get_inference_job_status`](super::get_inference_job_status), this
|
||||
/// avoids the large multi-table join — it reads a single column. Returns
|
||||
/// `Ok(None)` when no row matches the token.
|
||||
pub async fn get_inference_job_status_value(
|
||||
pool: &MySqlPool,
|
||||
job_token: &InferenceJobToken,
|
||||
) -> Result<Option<JobStatusPlus>, sqlx::Error> {
|
||||
let maybe_row = sqlx::query!(
|
||||
r#"
|
||||
SELECT status as `status: enums::common::job_status_plus::JobStatusPlus`
|
||||
FROM generic_inference_jobs
|
||||
WHERE token = ?
|
||||
"#,
|
||||
job_token.as_str(),
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(maybe_row.map(|row| row.status))
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use enums::by_table::generic_inference_jobs::inference_result_type::InferenceResultType;
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
|
||||
pub struct MarkGenericInferenceJobSuccessfullyDoneByTokenWithExecutorArgs<'a, E> {
|
||||
pub executor: E,
|
||||
pub token: &'a InferenceJobToken,
|
||||
pub maybe_entity_type: Option<InferenceResultType>,
|
||||
pub maybe_entity_token: Option<&'a str>,
|
||||
pub total_job_duration: Option<Duration>,
|
||||
pub inference_duration: Option<Duration>,
|
||||
}
|
||||
|
||||
/// Mark a job complete using an arbitrary executor. Pass `&mut *transaction`
|
||||
/// as the executor to perform the write inside an existing transaction (e.g.
|
||||
/// right after a `SELECT ... FOR UPDATE` re-check, to finalize exactly once).
|
||||
pub async fn mark_generic_inference_job_successfully_done_by_token_with_executor<'e, 'a, E>(
|
||||
args: MarkGenericInferenceJobSuccessfullyDoneByTokenWithExecutorArgs<'a, E>,
|
||||
) -> Result<(), sqlx::Error>
|
||||
where
|
||||
E: Executor<'e, Database = MySql>,
|
||||
{
|
||||
// NB: MySql's unsigned int (32 bits) can store integers up to 4,294,967,295.
|
||||
// Given milliseconds, this is ~49.71 days, which should be plenty for us.
|
||||
let truncated_total_job_execution_millis = args.total_job_duration
|
||||
.map(|duration| duration.as_millis() as u32)
|
||||
.unwrap_or(0);
|
||||
let truncated_inference_execution_millis = args.inference_duration
|
||||
.map(|duration| duration.as_millis() as u32)
|
||||
.unwrap_or(0);
|
||||
|
||||
sqlx::query!(
|
||||
r#"
|
||||
UPDATE generic_inference_jobs
|
||||
SET
|
||||
status = "complete_success",
|
||||
on_success_result_entity_type = ?,
|
||||
on_success_result_entity_token = ?,
|
||||
failure_reason = NULL,
|
||||
internal_debugging_failure_reason = NULL,
|
||||
success_execution_millis = ?,
|
||||
success_inference_execution_millis = ?,
|
||||
retry_at = NULL,
|
||||
successfully_completed_at = NOW()
|
||||
WHERE token = ?
|
||||
"#,
|
||||
args.maybe_entity_type,
|
||||
args.maybe_entity_token,
|
||||
truncated_total_job_execution_millis,
|
||||
truncated_inference_execution_millis,
|
||||
args.token.as_str(),
|
||||
)
|
||||
.execute(args.executor)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod batch_get_inference_job_status;
|
||||
pub mod dismiss_finished_jobs_for_user;
|
||||
pub mod get_inference_job_status;
|
||||
pub mod get_inference_job_status_value;
|
||||
pub mod get_pending_inference_job_count;
|
||||
pub mod insert_generic_inference_job;
|
||||
pub mod job_status;
|
||||
@@ -9,4 +10,5 @@ pub mod list_user_jobs_for_moderation;
|
||||
pub mod kill_jobs_in_development;
|
||||
pub mod list_session_jobs;
|
||||
pub mod mark_generic_inference_job_cancelled_by_user;
|
||||
pub mod mark_generic_inference_job_successfully_done_by_token;
|
||||
pub mod mark_generic_inference_job_successfully_done_by_token;
|
||||
pub mod mark_generic_inference_job_successfully_done_by_token_with_executor;
|
||||
@@ -5,6 +5,7 @@ use sqlx::{FromRow, MySql, QueryBuilder, Row};
|
||||
|
||||
use enums::by_table::prompts::prompt_type::PromptType;
|
||||
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
|
||||
use enums::common::generation::common_bitrate::CommonBitrate;
|
||||
use enums::common::generation::common_generation_mode::CommonGenerationMode;
|
||||
use enums::common::generation::common_model_type::CommonModelType;
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
@@ -23,6 +24,7 @@ pub struct BatchPromptResult {
|
||||
pub maybe_generation_mode: Option<CommonGenerationMode>,
|
||||
pub maybe_aspect_ratio: Option<CommonAspectRatio>,
|
||||
pub maybe_resolution: Option<CommonResolution>,
|
||||
pub maybe_bitrate: Option<CommonBitrate>,
|
||||
pub maybe_batch_count: Option<u8>,
|
||||
pub maybe_generate_audio: Option<bool>,
|
||||
pub maybe_duration_seconds: Option<u32>,
|
||||
@@ -46,6 +48,8 @@ impl FromRow<'_, MySqlRow> for BatchPromptResult {
|
||||
.and_then(|s| CommonAspectRatio::from_str(&s).ok()),
|
||||
maybe_resolution: row.try_get::<Option<String>, _>("maybe_resolution")?
|
||||
.and_then(|s| CommonResolution::from_str(&s).ok()),
|
||||
maybe_bitrate: row.try_get::<Option<String>, _>("maybe_bitrate")?
|
||||
.and_then(|s| CommonBitrate::from_str(&s).ok()),
|
||||
maybe_batch_count: row.try_get("maybe_batch_count")?,
|
||||
maybe_generate_audio: row.try_get("maybe_generate_audio")?,
|
||||
maybe_duration_seconds: row.try_get("maybe_duration_seconds")?,
|
||||
@@ -73,6 +77,7 @@ SELECT
|
||||
p.maybe_generation_mode,
|
||||
p.maybe_aspect_ratio,
|
||||
p.maybe_resolution,
|
||||
p.maybe_bitrate,
|
||||
p.maybe_batch_count,
|
||||
p.maybe_generate_audio,
|
||||
p.maybe_duration_seconds,
|
||||
|
||||
@@ -8,6 +8,7 @@ use chrono::{DateTime, Utc};
|
||||
use enums::by_table::prompts::prompt_type::PromptType;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
|
||||
use enums::common::generation::common_bitrate::CommonBitrate;
|
||||
use enums::common::generation::common_generation_mode::CommonGenerationMode;
|
||||
use enums::common::generation::common_model_type::CommonModelType;
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
@@ -38,6 +39,7 @@ pub struct Prompt {
|
||||
pub maybe_generation_mode: Option<CommonGenerationMode>,
|
||||
pub maybe_aspect_ratio: Option<CommonAspectRatio>,
|
||||
pub maybe_resolution: Option<CommonResolution>,
|
||||
pub maybe_bitrate: Option<CommonBitrate>,
|
||||
pub maybe_batch_count: Option<u8>,
|
||||
pub maybe_generate_audio: Option<bool>,
|
||||
pub maybe_duration_seconds: Option<u32>,
|
||||
@@ -67,6 +69,7 @@ pub struct PromptRaw {
|
||||
pub maybe_generation_mode: Option<CommonGenerationMode>,
|
||||
pub maybe_aspect_ratio: Option<CommonAspectRatio>,
|
||||
pub maybe_resolution: Option<CommonResolution>,
|
||||
pub maybe_bitrate: Option<CommonBitrate>,
|
||||
pub maybe_batch_count: Option<u8>,
|
||||
pub maybe_generate_audio: Option<bool>,
|
||||
pub maybe_duration_seconds: Option<u32>,
|
||||
@@ -114,6 +117,7 @@ pub async fn get_prompt_from_connection(
|
||||
maybe_generation_mode: record.maybe_generation_mode,
|
||||
maybe_aspect_ratio: record.maybe_aspect_ratio,
|
||||
maybe_resolution: record.maybe_resolution,
|
||||
maybe_bitrate: record.maybe_bitrate,
|
||||
maybe_batch_count: record.maybe_batch_count,
|
||||
maybe_generate_audio: record.maybe_generate_audio,
|
||||
maybe_duration_seconds: record.maybe_duration_seconds,
|
||||
@@ -149,6 +153,7 @@ SELECT
|
||||
p.maybe_generation_mode as `maybe_generation_mode: enums::common::generation::common_generation_mode::CommonGenerationMode`,
|
||||
p.maybe_aspect_ratio as `maybe_aspect_ratio: enums::common::generation::common_aspect_ratio::CommonAspectRatio`,
|
||||
p.maybe_resolution as `maybe_resolution: enums::common::generation::common_resolution::CommonResolution`,
|
||||
p.maybe_bitrate as `maybe_bitrate: enums::common::generation::common_bitrate::CommonBitrate`,
|
||||
p.maybe_batch_count as `maybe_batch_count: u8`,
|
||||
p.maybe_generate_audio as `maybe_generate_audio: bool`,
|
||||
p.maybe_duration_seconds as `maybe_duration_seconds: u32`,
|
||||
|
||||
@@ -7,6 +7,7 @@ use sqlx::{Executor, MySql};
|
||||
|
||||
use enums::by_table::prompts::prompt_type::PromptType;
|
||||
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
|
||||
use enums::common::generation::common_bitrate::CommonBitrate;
|
||||
use enums::common::generation::common_generation_mode::CommonGenerationMode;
|
||||
use enums::common::generation::common_model_type::CommonModelType;
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
@@ -36,6 +37,7 @@ pub struct InsertPromptArgs<'e, 'c, E>
|
||||
pub maybe_generation_mode: Option<CommonGenerationMode>,
|
||||
pub maybe_aspect_ratio: Option<CommonAspectRatio>,
|
||||
pub maybe_resolution: Option<CommonResolution>,
|
||||
pub maybe_bitrate: Option<CommonBitrate>,
|
||||
pub maybe_batch_count: Option<u8>,
|
||||
pub maybe_generate_audio: Option<bool>,
|
||||
pub maybe_duration_seconds: Option<u32>,
|
||||
@@ -89,6 +91,7 @@ SET
|
||||
maybe_generation_mode = ?,
|
||||
maybe_aspect_ratio = ?,
|
||||
maybe_resolution = ?,
|
||||
maybe_bitrate = ?,
|
||||
maybe_batch_count = ?,
|
||||
maybe_generate_audio = ?,
|
||||
maybe_duration_seconds = ?,
|
||||
@@ -107,6 +110,7 @@ SET
|
||||
args.maybe_generation_mode.map(|m| m.to_str()),
|
||||
args.maybe_aspect_ratio.map(|a| a.to_str()),
|
||||
args.maybe_resolution.map(|r| r.to_str()),
|
||||
args.maybe_bitrate.map(|b| b.to_str()),
|
||||
args.maybe_batch_count,
|
||||
args.maybe_generate_audio,
|
||||
args.maybe_duration_seconds,
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[cfg(test)]
|
||||
use strum::EnumCount;
|
||||
#[cfg(test)]
|
||||
use strum::EnumIter;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::error::enum_error::EnumError;
|
||||
|
||||
/// Maximum serialized string length for database storage.
|
||||
pub const MAX_LENGTH: usize = 16;
|
||||
|
||||
/// Common output bitrate levels for generation.
|
||||
///
|
||||
/// Most models default to `Normal`; `High` requests a higher bitrate where the
|
||||
/// model supports it. Models that don't support it simply ignore the value.
|
||||
///
|
||||
/// NB: Keep the max serialized length to 16 characters.
|
||||
#[cfg_attr(test, derive(EnumIter, EnumCount))]
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CommonBitrate {
|
||||
Normal,
|
||||
High,
|
||||
}
|
||||
|
||||
impl_enum_display_and_debug_using_to_str!(CommonBitrate);
|
||||
impl_mysql_enum_coders!(CommonBitrate);
|
||||
impl_mysql_from_row!(CommonBitrate);
|
||||
|
||||
impl CommonBitrate {
|
||||
pub fn to_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Normal => "normal",
|
||||
Self::High => "high",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(value: &str) -> Result<Self, EnumError> {
|
||||
match value {
|
||||
"normal" => Ok(Self::Normal),
|
||||
"high" => Ok(Self::High),
|
||||
_ => Err(EnumError::CouldNotConvertFromString(value.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all_variants() -> BTreeSet<Self> {
|
||||
BTreeSet::from([
|
||||
Self::Normal,
|
||||
Self::High,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::common::generation::common_bitrate::CommonBitrate;
|
||||
use crate::common::generation::common_bitrate::MAX_LENGTH;
|
||||
use crate::test_helpers::assert_serialization;
|
||||
|
||||
mod explicit_checks {
|
||||
use super::*;
|
||||
use crate::error::enum_error::EnumError;
|
||||
|
||||
#[test]
|
||||
fn test_serialization() {
|
||||
assert_serialization(CommonBitrate::Normal, "normal");
|
||||
assert_serialization(CommonBitrate::High, "high");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_str() {
|
||||
assert_eq!(CommonBitrate::Normal.to_str(), "normal");
|
||||
assert_eq!(CommonBitrate::High.to_str(), "high");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_str() {
|
||||
assert_eq!(CommonBitrate::from_str("normal").unwrap(), CommonBitrate::Normal);
|
||||
assert_eq!(CommonBitrate::from_str("high").unwrap(), CommonBitrate::High);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_str_err() {
|
||||
let result = CommonBitrate::from_str("invalid");
|
||||
assert!(result.is_err());
|
||||
if let Err(EnumError::CouldNotConvertFromString(value)) = result {
|
||||
assert_eq!(value, "invalid");
|
||||
} else {
|
||||
panic!("Expected EnumError::CouldNotConvertFromString");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_variants() {
|
||||
let mut variants = CommonBitrate::all_variants();
|
||||
assert_eq!(variants.len(), 2);
|
||||
assert_eq!(variants.pop_first(), Some(CommonBitrate::Normal));
|
||||
assert_eq!(variants.pop_first(), Some(CommonBitrate::High));
|
||||
assert_eq!(variants.pop_first(), None);
|
||||
}
|
||||
}
|
||||
|
||||
mod mechanical_checks {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn variant_length() {
|
||||
use strum::IntoEnumIterator;
|
||||
assert_eq!(CommonBitrate::all_variants().len(), CommonBitrate::iter().len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip() {
|
||||
for variant in CommonBitrate::all_variants() {
|
||||
assert_eq!(variant, CommonBitrate::from_str(variant.to_str()).unwrap());
|
||||
assert_eq!(variant, CommonBitrate::from_str(&format!("{}", variant)).unwrap());
|
||||
assert_eq!(variant, CommonBitrate::from_str(&format!("{:?}", variant)).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialized_length_ok_for_database() {
|
||||
for variant in CommonBitrate::all_variants() {
|
||||
let serialized = variant.to_str();
|
||||
assert!(serialized.len() > 0, "variant {:?} is too short", variant);
|
||||
assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long via to_str()", variant);
|
||||
}
|
||||
for variant in CommonBitrate::all_variants() {
|
||||
let json = serde_json::to_string(&variant).unwrap().replace('"', "");
|
||||
assert!(json.len() <= MAX_LENGTH, "variant {:?} is too long via JSON: {:?}", variant, json);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialized_names_must_not_contain_dots() {
|
||||
for variant in CommonBitrate::all_variants() {
|
||||
let to_str_value = variant.to_str();
|
||||
assert!(!to_str_value.contains('.'), "to_str() for {:?} contains a dot: {:?}", variant, to_str_value);
|
||||
|
||||
let json_value = serde_json::to_string(&variant).unwrap().replace('"', "");
|
||||
assert!(!json_value.contains('.'), "JSON serialization for {:?} contains a dot: {:?}", variant, json_value);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialized_names_must_only_contain_lowercase_alphanumeric_and_underscore() {
|
||||
let valid_pattern = regex::Regex::new(r"^[a-z0-9_]+$").unwrap();
|
||||
|
||||
for variant in CommonBitrate::all_variants() {
|
||||
let to_str_value = variant.to_str();
|
||||
assert!(valid_pattern.is_match(to_str_value),
|
||||
"to_str() for {:?} contains invalid characters: {:?} (only a-z, 0-9, _ allowed)", variant, to_str_value);
|
||||
|
||||
let json_value = serde_json::to_string(&variant).unwrap().replace('"', "");
|
||||
assert!(valid_pattern.is_match(&json_value),
|
||||
"JSON serialization for {:?} contains invalid characters: {:?} (only a-z, 0-9, _ allowed)", variant, json_value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod common_aspect_ratio;
|
||||
pub mod common_bitrate;
|
||||
pub mod common_generation_mode;
|
||||
pub mod common_image_model;
|
||||
pub mod common_quality;
|
||||
|
||||
+1
@@ -332,6 +332,7 @@ pub async fn process_job_inference(
|
||||
|
||||
// NB: Don't fail the job if the query fails.
|
||||
let prompt_result = insert_prompt(InsertPromptArgs {
|
||||
maybe_bitrate: None,
|
||||
maybe_apriori_prompt_token: Some(&prompt_token),
|
||||
prompt_type: PromptType::StableDiffusion,
|
||||
maybe_creator_user_token: job.maybe_creator_user_token_typed.as_ref(),
|
||||
|
||||
+1
@@ -287,6 +287,7 @@ pub async fn validate_and_save_results(args: SaveResultsArgs<'_>) -> Result<Medi
|
||||
|
||||
// NB: Don't fail the job if the query fails.
|
||||
let prompt_result = insert_prompt(InsertPromptArgs {
|
||||
maybe_bitrate: None,
|
||||
maybe_apriori_prompt_token: Some(&prompt_token),
|
||||
prompt_type: PromptType::ComfyUi,
|
||||
maybe_creator_user_token: args.job.maybe_creator_user_token_typed.as_ref(),
|
||||
|
||||
@@ -43,6 +43,7 @@ tokens.workspace = true
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
log.workspace = true
|
||||
rand.workspace = true
|
||||
|
||||
sqlx = { version = "0.7.4", features = [ "mysql", "runtime-tokio-rustls", "chrono" ] }
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::kinovi_version::KinoviVersion;
|
||||
use crate::order_reconciler::OrderReconciler;
|
||||
use chrono::Duration;
|
||||
use cloud_storage::legacy_bucket_client::LegacyBucketClient;
|
||||
use concurrency::relaxed_atomic_bool::RelaxedAtomicBool;
|
||||
@@ -31,11 +32,6 @@ pub struct JobDependencies {
|
||||
/// How long to sleep between poll iterations (milliseconds).
|
||||
pub poll_interval_millis: u64,
|
||||
|
||||
/// If set, process jobs in batches of this many pages instead of
|
||||
/// exhausting all pages before processing. This prevents starvation
|
||||
/// when the order list is very long.
|
||||
pub maybe_pages_per_batch: Option<u32>,
|
||||
|
||||
/// If set, stop paginating backwards through orders once we encounter
|
||||
/// an order older than this duration. This prevents endlessly scanning
|
||||
/// ancient orders that will never match a pending job.
|
||||
@@ -61,4 +57,8 @@ pub struct JobDependencies {
|
||||
|
||||
/// Pager client for sending alerts.
|
||||
pub pager: Pager,
|
||||
|
||||
/// Hand-off between the polling loop (producer) and the processing loop
|
||||
/// (consumer): finished Kinovi orders staged for reconciliation into our DB.
|
||||
pub order_reconciler: OrderReconciler,
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod character_polling_job;
|
||||
pub mod credits_checking_job;
|
||||
pub mod video_polling_job;
|
||||
pub mod order_polling_job;
|
||||
pub mod order_processing_job;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
pub mod order_polling_main_loop;
|
||||
+376
@@ -0,0 +1,376 @@
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use chrono::Utc;
|
||||
use enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty;
|
||||
use enums::by_table::generic_inference_jobs::inference_job_type::InferenceJobType;
|
||||
use log::{error, info, warn};
|
||||
use mysql_queries::queries::generic_inference::api_providers::seedance2pro::list_pending_seedance2pro_video_jobs::{list_pending_seedance2pro_video_jobs, PendingSeedance2ProJob};
|
||||
use pager::notification::notification_details_builder::NotificationDetailsBuilder;
|
||||
use pager::notification::notification_urgency::NotificationUrgency;
|
||||
use seedance2pro_client::requests::poll_orders::poll_orders::{poll_orders, OrderStatus, PollOrdersArgs, PollOrdersResponse};
|
||||
|
||||
use crate::alert_on_error::alert_pager_and_return_err;
|
||||
use crate::job_dependencies::JobDependencies;
|
||||
use crate::kinovi_version::KinoviVersion;
|
||||
|
||||
const POLL_ALERT_THRESHOLD: Duration = Duration::from_mins(6);
|
||||
|
||||
/// How many of the newest order pages to re-scan when we periodically revisit
|
||||
/// the head of the list. Recently-submitted jobs finish soonest, so checking
|
||||
/// the first few pages more often shortens their tail latency without waiting
|
||||
/// for a full deep walk to complete.
|
||||
const HEAD_PAGE_COUNT: u32 = 2;
|
||||
|
||||
/// After this many pages of the deep (long-tail) walk, pause and re-scan the
|
||||
/// head pages before continuing onward.
|
||||
const DEEP_PAGES_BETWEEN_HEAD_RECHECKS: u32 = 5;
|
||||
|
||||
/// The producer half of the pipeline.
|
||||
///
|
||||
/// Walks Kinovi's order list looking for orders that have *finished* (completed
|
||||
/// or failed) and match one of our pending database jobs, and stages each into
|
||||
/// the shared [`OrderReconciler`](crate::order_reconciler::OrderReconciler).
|
||||
/// It does no downloading, uploading, or job settling itself — that's the
|
||||
/// processing loop's job. Staging happens the moment a finished order is seen,
|
||||
/// so the processor can pick it up immediately rather than waiting for a whole
|
||||
/// batch (or the whole walk) to complete.
|
||||
pub async fn order_polling_main_loop(job_dependencies: JobDependencies) {
|
||||
while !job_dependencies.application_shutdown.get() {
|
||||
let start = Instant::now();
|
||||
|
||||
let result = run_poll_iteration(&job_dependencies).await;
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
if let Err(err) = result {
|
||||
error!("Error in poll iteration: {:?}", err);
|
||||
let _ = alert_pager_and_return_err::<()>(&job_dependencies.pager, "Kinovi poll iteration error", err, None);
|
||||
let _ = job_dependencies.job_stats.increment_failure_count();
|
||||
}
|
||||
|
||||
if elapsed > POLL_ALERT_THRESHOLD {
|
||||
warn!("Poll iteration took {:.1}s (threshold: {}s)", elapsed.as_secs_f64(), POLL_ALERT_THRESHOLD.as_secs());
|
||||
|
||||
let notification = NotificationDetailsBuilder::from_title(
|
||||
"Kinovi poll iteration slow".to_string())
|
||||
.set_description(Some(format!(
|
||||
"Poll iteration took {:.1} seconds, exceeding the threshold.",
|
||||
elapsed.as_secs_f64(),
|
||||
)))
|
||||
.set_urgency(Some(NotificationUrgency::Medium))
|
||||
.build();
|
||||
|
||||
if let Err(pager_err) = job_dependencies.pager.enqueue_page(notification) {
|
||||
error!("Failed to enqueue slow iteration alert: {:?}", pager_err);
|
||||
}
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(Duration::from_millis(job_dependencies.poll_interval_millis)) => {}
|
||||
_ = job_dependencies.shutdown_notify.notified() => {}
|
||||
}
|
||||
}
|
||||
|
||||
warn!("Kinovi order polling main loop is shut down.");
|
||||
}
|
||||
|
||||
async fn run_poll_iteration(deps: &JobDependencies) -> anyhow::Result<()> {
|
||||
info!("Querying database jobs for type: {:?}", deps.kinovi_version);
|
||||
|
||||
let (job_type, third_party) = match deps.kinovi_version {
|
||||
KinoviVersion::Volcengine => (
|
||||
InferenceJobType::Seedance2ProQueue,
|
||||
InferenceJobExternalThirdParty::Seedance2Pro,
|
||||
),
|
||||
KinoviVersion::BytePlus => (
|
||||
InferenceJobType::Seedance2ProAltQueue,
|
||||
InferenceJobExternalThirdParty::Seedance2ProAlt,
|
||||
),
|
||||
KinoviVersion::BytePlusUltra => (
|
||||
InferenceJobType::Seedance2ProBytePlusUltraQueue,
|
||||
InferenceJobExternalThirdParty::Seedance2ProBytePlusUltra,
|
||||
),
|
||||
};
|
||||
|
||||
// 1. Query all (limit 25,000) non-terminal Seedance2Pro jobs from the DB.
|
||||
let pending_jobs = match list_pending_seedance2pro_video_jobs(&deps.mysql_pool, third_party, job_type).await {
|
||||
Ok(jobs) => jobs,
|
||||
Err(err) => {
|
||||
error!("Failed to list pending database jobs: {:?}", err);
|
||||
return alert_pager_and_return_err(&deps.pager, "Jobs DB query failed", err.into(), None);
|
||||
}
|
||||
};
|
||||
|
||||
if pending_jobs.is_empty() {
|
||||
info!("No pending database jobs found.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!("Found {} pending database job(s).", pending_jobs.len());
|
||||
|
||||
let result = walk_orders(deps, pending_jobs).await?;
|
||||
|
||||
info!(
|
||||
"Order poll iteration complete: {} pages seen, {} orders seen, {} finished orders staged. \
|
||||
Reconciler currently holds {} order(s) awaiting processing.",
|
||||
result.pages_seen,
|
||||
result.orders_seen,
|
||||
result.orders_staged,
|
||||
deps.order_reconciler.len(),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct WalkResult {
|
||||
pages_seen: u32,
|
||||
orders_seen: u32,
|
||||
orders_staged: u32,
|
||||
}
|
||||
|
||||
/// Walk Kinovi's order pages newest-first, staging finished orders that match a
|
||||
/// pending job. Periodically pauses the deep walk to re-scan the head pages so
|
||||
/// recent jobs are caught with lower latency than the long tail.
|
||||
async fn walk_orders(
|
||||
deps: &JobDependencies,
|
||||
pending_jobs: Vec<PendingSeedance2ProJob>,
|
||||
) -> anyhow::Result<WalkResult> {
|
||||
let job_by_order_id: HashMap<String, PendingSeedance2ProJob> = pending_jobs
|
||||
.into_iter()
|
||||
.map(|job| (job.order_id.clone(), job))
|
||||
.collect();
|
||||
|
||||
let pending_db_job_count = job_by_order_id.len();
|
||||
let oldest_pending_created_at = job_by_order_id.values().map(|job| job.created_at).min();
|
||||
|
||||
let iteration_start = Instant::now();
|
||||
let mut result = WalkResult::default();
|
||||
let mut cursor: Option<u64> = None;
|
||||
let mut pages_since_head_recheck: u32 = 0;
|
||||
|
||||
loop {
|
||||
if deps.application_shutdown.get() {
|
||||
info!("Shutdown requested during pagination. Stopping early.");
|
||||
break;
|
||||
}
|
||||
|
||||
// Log the iteration state up front (before the poll) so that, if the poll
|
||||
// itself fails, we still have the surrounding context in the logs.
|
||||
let iteration_elapsed = iteration_start.elapsed();
|
||||
|
||||
let pending_db_jobs_display = match oldest_pending_created_at {
|
||||
Some(oldest) if pending_db_job_count > 0 => format!(
|
||||
"{} (oldest: {} ago)",
|
||||
pending_db_job_count,
|
||||
format_duration_ago(Utc::now() - oldest),
|
||||
),
|
||||
_ => pending_db_job_count.to_string(),
|
||||
};
|
||||
|
||||
info!(
|
||||
"Requesting Kinovi page:\n \
|
||||
kinovi page: {page}\n \
|
||||
kinovi cursor: {cursor:?}\n \
|
||||
orders seen so far: {orders_seen}\n \
|
||||
pending DB jobs: {pending_db}\n \
|
||||
reconciler backlog: {backlog} order(s) awaiting processing\n \
|
||||
iteration elapsed: {secs}s ({millis}ms)",
|
||||
page = result.pages_seen,
|
||||
cursor = cursor,
|
||||
orders_seen = result.orders_seen,
|
||||
pending_db = pending_db_jobs_display,
|
||||
backlog = deps.order_reconciler.len(),
|
||||
secs = iteration_elapsed.as_secs(),
|
||||
millis = iteration_elapsed.as_millis(),
|
||||
);
|
||||
|
||||
let response = poll_orders_with_retry(deps, cursor).await?;
|
||||
|
||||
let page_summary = stage_finished_orders(deps, &response.orders, &job_by_order_id);
|
||||
|
||||
result.pages_seen += 1;
|
||||
result.orders_seen += response.orders.len() as u32;
|
||||
result.orders_staged += page_summary.staged;
|
||||
|
||||
let maybe_oldest_created_at = response.orders
|
||||
.iter()
|
||||
.filter_map(|order| order.created_at_utc)
|
||||
.last();
|
||||
|
||||
info!(
|
||||
"Polled Kinovi page {}: {} orders (oldest on page created at {:?}); {} finished orders staged from this page.",
|
||||
result.pages_seen,
|
||||
response.orders.len(),
|
||||
maybe_oldest_created_at,
|
||||
page_summary.staged,
|
||||
);
|
||||
|
||||
cursor = response.next_cursor;
|
||||
|
||||
let exceeded_max_age = match (deps.maybe_max_job_age, maybe_oldest_created_at) {
|
||||
(Some(max_age), Some(oldest)) => {
|
||||
let age = Utc::now() - oldest;
|
||||
if age > max_age {
|
||||
info!(
|
||||
"Oldest order on page {} is {} hours old (threshold {} hours). Stopping deep walk.",
|
||||
result.pages_seen, age.num_hours(), max_age.num_hours(),
|
||||
);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if cursor.is_none() || exceeded_max_age {
|
||||
break;
|
||||
}
|
||||
|
||||
// Periodically revisit the head of the list mid-walk so freshly-finished
|
||||
// recent orders don't have to wait for the entire long tail to be scanned.
|
||||
pages_since_head_recheck += 1;
|
||||
if pages_since_head_recheck >= DEEP_PAGES_BETWEEN_HEAD_RECHECKS {
|
||||
pages_since_head_recheck = 0;
|
||||
let head = recheck_head_pages(deps, &job_by_order_id).await?;
|
||||
result.pages_seen += head.pages_seen;
|
||||
result.orders_seen += head.orders_seen;
|
||||
result.orders_staged += head.orders_staged;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Re-scan the first [`HEAD_PAGE_COUNT`] pages (newest orders) and stage any
|
||||
/// finished matches. Used mid-walk to keep recent-job latency low.
|
||||
async fn recheck_head_pages(
|
||||
deps: &JobDependencies,
|
||||
job_by_order_id: &HashMap<String, PendingSeedance2ProJob>,
|
||||
) -> anyhow::Result<WalkResult> {
|
||||
info!("Re-checking the {} newest order page(s).", HEAD_PAGE_COUNT);
|
||||
|
||||
let mut result = WalkResult::default();
|
||||
let mut cursor: Option<u64> = None;
|
||||
|
||||
for _ in 0..HEAD_PAGE_COUNT {
|
||||
if deps.application_shutdown.get() {
|
||||
break;
|
||||
}
|
||||
|
||||
let response = poll_orders_with_retry(deps, cursor).await?;
|
||||
|
||||
let page_summary = stage_finished_orders(deps, &response.orders, job_by_order_id);
|
||||
|
||||
result.pages_seen += 1;
|
||||
result.orders_seen += response.orders.len() as u32;
|
||||
result.orders_staged += page_summary.staged;
|
||||
|
||||
cursor = response.next_cursor;
|
||||
if cursor.is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
struct PageStageSummary {
|
||||
staged: u32,
|
||||
}
|
||||
|
||||
/// Stage every finished (completed/failed) order on this page that matches a
|
||||
/// pending job. Orders still in progress are ignored — we'll see them again on
|
||||
/// a later poll. Staging is idempotent: the reconciler drops duplicates.
|
||||
fn stage_finished_orders(
|
||||
deps: &JobDependencies,
|
||||
orders: &[OrderStatus],
|
||||
job_by_order_id: &HashMap<String, PendingSeedance2ProJob>,
|
||||
) -> PageStageSummary {
|
||||
let mut staged = 0u32;
|
||||
|
||||
for order in orders {
|
||||
let job = match job_by_order_id.get(&order.order_id) {
|
||||
Some(job) => job,
|
||||
None => continue, // Not one of our pending jobs.
|
||||
};
|
||||
|
||||
if !order.task_status.is_terminal() {
|
||||
continue; // Still pending/processing — check again next poll.
|
||||
}
|
||||
|
||||
let newly_staged = deps.order_reconciler.push_order(
|
||||
order.order_id.clone(),
|
||||
order.clone(),
|
||||
job.clone(),
|
||||
);
|
||||
|
||||
if newly_staged {
|
||||
staged += 1;
|
||||
info!(
|
||||
"Staged finished order {} (status {:?}) for job {}.",
|
||||
order.order_id, order.task_status, job.job_token.as_str(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
PageStageSummary { staged }
|
||||
}
|
||||
|
||||
/// Format a positive elapsed duration as `"{h} hours {m} minutes {s} seconds"`
|
||||
/// (hours are unbounded; e.g. a 50-hour-old job reads `"50 hours ..."`).
|
||||
/// Negative durations clamp to zero.
|
||||
fn format_duration_ago(elapsed: chrono::Duration) -> String {
|
||||
let total_seconds = elapsed.num_seconds().max(0);
|
||||
let hours = total_seconds / 3600;
|
||||
let minutes = (total_seconds % 3600) / 60;
|
||||
let seconds = total_seconds % 60;
|
||||
format!("{} hours {} minutes {} seconds", hours, minutes, seconds)
|
||||
}
|
||||
|
||||
/// Poll orders from Kinovi with retries. On transient failures, waits with
|
||||
/// increasing delay (attempt × 2s, capped at `poll_retry_max_delay_millis`).
|
||||
/// After exhausting retries, alerts the pager and returns an error.
|
||||
async fn poll_orders_with_retry(
|
||||
deps: &JobDependencies,
|
||||
cursor: Option<u64>,
|
||||
) -> anyhow::Result<PollOrdersResponse> {
|
||||
let max_retries = deps.poll_max_retries;
|
||||
|
||||
for attempt in 1..=max_retries {
|
||||
match poll_orders(PollOrdersArgs {
|
||||
session: &deps.seedance2pro_session,
|
||||
cursor,
|
||||
host_override: None,
|
||||
}).await {
|
||||
Ok(response) => return Ok(response),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"Error polling Kinovi orders (attempt {}/{}): {:?}",
|
||||
attempt, max_retries, err
|
||||
);
|
||||
|
||||
if attempt >= max_retries {
|
||||
return alert_pager_and_return_err(
|
||||
&deps.pager,
|
||||
"Kinovi API polling failed after retries",
|
||||
anyhow::anyhow!("poll_orders failed after {} attempts: {:?}", attempt, err),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
let delay_millis = (attempt as u64 * 2_000).min(deps.poll_retry_max_delay_millis);
|
||||
tokio::time::sleep(Duration::from_millis(delay_millis)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
alert_pager_and_return_err(
|
||||
&deps.pager,
|
||||
"Kinovi API polling failed after max retries",
|
||||
anyhow::anyhow!("poll_orders failed after {} attempts", max_retries),
|
||||
None,
|
||||
)
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
use enums::common::job_status_plus::JobStatusPlus;
|
||||
|
||||
/// Whether a job's status means it has already been fully settled and must not
|
||||
/// be re-processed. Matches the terminal set excluded by the pending-jobs
|
||||
/// lister (`complete_success` / `complete_failure`).
|
||||
pub fn is_job_status_terminal(status: JobStatusPlus) -> bool {
|
||||
matches!(status, JobStatusPlus::CompleteSuccess | JobStatusPlus::CompleteFailure)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod is_job_status_terminal;
|
||||
pub mod order_processing_main_loop;
|
||||
pub mod process_one_order;
|
||||
pub mod process_failed_job;
|
||||
pub mod process_successful_image_job;
|
||||
pub mod process_successful_video_job;
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use log::{error, info, warn};
|
||||
use mysql_queries::queries::generic_inference::web::get_inference_job_status_value::get_inference_job_status_value;
|
||||
|
||||
use crate::job_dependencies::JobDependencies;
|
||||
use crate::jobs::order_processing_job::is_job_status_terminal::is_job_status_terminal;
|
||||
use crate::jobs::order_processing_job::process_one_order::process_one_order;
|
||||
|
||||
/// How long to nap when the reconciler is empty before peeking again.
|
||||
const IDLE_SLEEP: Duration = Duration::from_millis(500);
|
||||
|
||||
/// How long to back off after a transient error before retrying.
|
||||
const ERROR_BACKOFF: Duration = Duration::from_secs(2);
|
||||
|
||||
/// The consumer half of the pipeline.
|
||||
///
|
||||
/// Drains finished orders staged by the polling loop and reconciles each into
|
||||
/// our system: a lightweight DB pre-check (skip jobs already settled before
|
||||
/// downloading anything), then download + upload, then a `SELECT ... FOR
|
||||
/// UPDATE` transaction that re-checks the job is still pending before writing
|
||||
/// the conclusion. Runs independently of polling, so an order is handled as
|
||||
/// soon as it's staged rather than after a whole batch.
|
||||
pub async fn order_processing_main_loop(deps: JobDependencies) {
|
||||
while !deps.application_shutdown.get() {
|
||||
let details = match deps.order_reconciler.peek_random() {
|
||||
Some(details) => details,
|
||||
None => {
|
||||
nap(&deps, IDLE_SLEEP).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let order_id = details.kinovi_record.order_id.clone();
|
||||
let job_token = details.database_record.job_token.clone();
|
||||
|
||||
// Lightweight pre-check: if the DB already considers the job terminal, drop
|
||||
// the staged order without doing any expensive download/upload work.
|
||||
match get_inference_job_status_value(&deps.mysql_pool, &job_token).await {
|
||||
Ok(Some(status)) if is_job_status_terminal(status) => {
|
||||
info!(
|
||||
"Job {} is already terminal ({:?}); dropping staged order {}.",
|
||||
job_token.as_str(), status, order_id,
|
||||
);
|
||||
deps.order_reconciler.remove(&order_id);
|
||||
continue;
|
||||
}
|
||||
Ok(Some(_)) => {
|
||||
// Still pending — proceed to process.
|
||||
}
|
||||
Ok(None) => {
|
||||
warn!(
|
||||
"Job {} for staged order {} no longer exists in the DB; dropping.",
|
||||
job_token.as_str(), order_id,
|
||||
);
|
||||
deps.order_reconciler.remove(&order_id);
|
||||
continue;
|
||||
}
|
||||
Err(err) => {
|
||||
error!(
|
||||
"Failed to read status for job {} (order {}): {:?}. Leaving staged; backing off.",
|
||||
job_token.as_str(), order_id, err,
|
||||
);
|
||||
nap(&deps, ERROR_BACKOFF).await;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Commit to this order: pop it so we don't peek it again while working.
|
||||
deps.order_reconciler.remove(&order_id);
|
||||
|
||||
match process_one_order(&deps, &details).await {
|
||||
Ok(()) => {
|
||||
let _ = deps.job_stats.increment_success_count();
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"Error processing order {} (job {}): {:?}. It will be re-staged on a \
|
||||
later poll if the job is still pending.",
|
||||
order_id, job_token.as_str(), err,
|
||||
);
|
||||
let _ = deps.job_stats.increment_failure_count();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
warn!("Kinovi order processing main loop is shut down.");
|
||||
}
|
||||
|
||||
/// Sleep, but wake immediately on shutdown.
|
||||
async fn nap(deps: &JobDependencies, duration: Duration) {
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(duration) => {}
|
||||
_ = deps.shutdown_notify.notified() => {}
|
||||
}
|
||||
}
|
||||
+101
-55
@@ -1,8 +1,9 @@
|
||||
use log::{error, info, warn};
|
||||
|
||||
use enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory;
|
||||
use mysql_queries::queries::generic_inference::job::mark_job_failed_by_token::{mark_job_failed_by_token, MarkJobFailedByTokenArgs};
|
||||
use mysql_queries::queries::generic_inference::api_providers::seedance2pro::list_pending_seedance2pro_video_jobs::PendingSeedance2ProJob;
|
||||
use mysql_queries::queries::generic_inference::job::mark_job_failed_by_token::mark_job_failed_by_token_with_executor;
|
||||
use mysql_queries::queries::generic_inference::job::select_inference_job_status_for_update::select_inference_job_status_for_update;
|
||||
use mysql_queries::queries::wallets::refund::try_to_refund_ledger_entry::{try_to_refund_ledger_entry, WalletRefundOutcome};
|
||||
use pager::notification::notification_details_builder::NotificationDetailsBuilder;
|
||||
use pager::notification::notification_urgency::NotificationUrgency;
|
||||
@@ -10,7 +11,16 @@ use seedance2pro_client::requests::poll_orders::failure_type::FailureType;
|
||||
use seedance2pro_client::requests::poll_orders::poll_orders::OrderStatus;
|
||||
|
||||
use crate::job_dependencies::JobDependencies;
|
||||
use crate::jobs::order_processing_job::is_job_status_terminal::is_job_status_terminal;
|
||||
|
||||
/// Reconcile a failed order: refund the user (if billed) and mark the job
|
||||
/// failed — atomically, in one transaction.
|
||||
///
|
||||
/// The whole thing runs under a `SELECT ... FOR UPDATE` row lock: we re-read
|
||||
/// the job's status, bail if another finalizer already settled it, and
|
||||
/// otherwise refund + mark-failed + commit together. Because refund and the
|
||||
/// failure write share a transaction, a crash can never leave a failed job
|
||||
/// without its refund (or vice versa), and the refund itself is idempotent.
|
||||
pub async fn process_failed_job(
|
||||
deps: &JobDependencies,
|
||||
job: &PendingSeedance2ProJob,
|
||||
@@ -22,12 +32,77 @@ pub async fn process_failed_job(
|
||||
.map(|fr| fr.reason.as_str())
|
||||
.unwrap_or("unknown failure reason");
|
||||
|
||||
// --- Step 1: Attempt the refund before touching the job status. ---
|
||||
//
|
||||
// We do this first so that a crash or error between the refund and the status update
|
||||
// can never result in a failed job with no refund. If the refund itself fails we bail
|
||||
// out early: the job stays pending and will be retried on the next poll cycle.
|
||||
let frontend_failure_category = order.fail_reason.as_ref().map(|fr| {
|
||||
match fr.failure_type {
|
||||
FailureType::RuleBansUserImage => FrontendFailureCategory::RuleBansUserImage,
|
||||
FailureType::RuleBansUserImageWithFaces => FrontendFailureCategory::RuleBansUserImageWithFaces,
|
||||
FailureType::RuleBansUserTextPrompt => FrontendFailureCategory::RuleBansUserTextPrompt,
|
||||
FailureType::RuleBansUserContent => FrontendFailureCategory::RuleBansUserContent,
|
||||
FailureType::RuleBansGeneratedVideo => FrontendFailureCategory::RuleBansGeneratedVideo,
|
||||
FailureType::RuleBansGeneratedAudio => FrontendFailureCategory::RuleBansGeneratedAudio,
|
||||
FailureType::RuleBansGeneratedContent => FrontendFailureCategory::RuleBansGeneratedContent,
|
||||
FailureType::GenerationFailed => FrontendFailureCategory::GenerationFailed,
|
||||
FailureType::OtherUnknownReason => FrontendFailureCategory::GenerationFailed,
|
||||
}
|
||||
});
|
||||
|
||||
let mut transaction = match deps.mysql_pool.begin().await {
|
||||
Ok(tx) => tx,
|
||||
Err(err) => {
|
||||
error!(
|
||||
"Failed to begin failure transaction for job {}: {:?}. \
|
||||
Job will NOT be marked failed yet and will be retried next poll.",
|
||||
job.job_token.as_str(), err,
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// ── Terminal-state guard (do NOT remove) ──
|
||||
//
|
||||
// Bail unless the job is still pending. Another finalizer may have already
|
||||
// settled (and refunded) this job between the processing loop's pre-check and
|
||||
// this locked re-read. If we don't stop here we could double-refund the user.
|
||||
// This is the single most important check in this function, so it's a
|
||||
// discrete, early-returning step.
|
||||
|
||||
let maybe_status = match select_inference_job_status_for_update(&mut *transaction, &job.job_token).await {
|
||||
Ok(maybe_status) => maybe_status,
|
||||
Err(err) => {
|
||||
error!(
|
||||
"Failed to lock job {} for failure finalize: {:?}. Will retry next poll.",
|
||||
job.job_token.as_str(), err,
|
||||
);
|
||||
let _ = transaction.rollback().await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let status = match maybe_status {
|
||||
Some(status) => status,
|
||||
None => {
|
||||
warn!(
|
||||
"Job {} vanished before failure finalize (order {}); skipping.",
|
||||
job.job_token.as_str(), order.order_id,
|
||||
);
|
||||
let _ = transaction.rollback().await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if is_job_status_terminal(status) {
|
||||
info!(
|
||||
"Job {} is already terminal ({:?}) before failure finalize (order {}); skipping.",
|
||||
job.job_token.as_str(), status, order.order_id,
|
||||
);
|
||||
let _ = transaction.rollback().await;
|
||||
return;
|
||||
}
|
||||
|
||||
// Still pending — refund and mark failed within the locked transaction.
|
||||
//
|
||||
// Refund first (within the same transaction), so the refund and the failure
|
||||
// write commit or roll back together.
|
||||
match &job.maybe_wallet_ledger_entry_token {
|
||||
None => {
|
||||
// No ledger token recorded — job was likely submitted before billing was wired up.
|
||||
@@ -37,18 +112,6 @@ pub async fn process_failed_job(
|
||||
);
|
||||
}
|
||||
Some(ledger_token) => {
|
||||
let mut transaction = match deps.mysql_pool.begin().await {
|
||||
Ok(tx) => tx,
|
||||
Err(err) => {
|
||||
error!(
|
||||
"Failed to begin refund transaction for job {} (ledger {}): {:?}. \
|
||||
Job will NOT be marked failed yet and will be retried next poll.",
|
||||
job.job_token.as_str(), ledger_token.as_str(), err
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match try_to_refund_ledger_entry(ledger_token, &mut transaction).await {
|
||||
Ok(WalletRefundOutcome::Refunded(summary)) => {
|
||||
info!(
|
||||
@@ -58,23 +121,13 @@ pub async fn process_failed_job(
|
||||
ledger_token.as_str(),
|
||||
summary.refund_ledger_entry_token.as_str(),
|
||||
);
|
||||
if let Err(err) = transaction.commit().await {
|
||||
error!(
|
||||
"Failed to commit refund transaction for job {} (ledger {}): {:?}. \
|
||||
Job will NOT be marked failed yet and will be retried next poll.",
|
||||
job.job_token.as_str(), ledger_token.as_str(), err
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
Ok(WalletRefundOutcome::AlreadyRefunded) => {
|
||||
// Idempotent — nothing to do, safe to proceed.
|
||||
info!(
|
||||
"Ledger entry {} for job {} was already refunded; proceeding to mark job failed.",
|
||||
ledger_token.as_str(),
|
||||
job.job_token.as_str(),
|
||||
);
|
||||
let _ = transaction.rollback().await;
|
||||
}
|
||||
Err(err) => {
|
||||
error!(
|
||||
@@ -86,7 +139,7 @@ pub async fn process_failed_job(
|
||||
);
|
||||
|
||||
let notification = NotificationDetailsBuilder::from_boxed_error(err.into())
|
||||
.set_title("Seedance2Pro refund failed".to_string())
|
||||
.set_title("Kinovi refund failed".to_string())
|
||||
.set_inference_job_token(Some(job.job_token.to_string()))
|
||||
.set_third_party_id(Some(job.order_id.to_string()))
|
||||
.set_user_token(job.maybe_creator_user_token.as_ref().map(|t| t.to_string()))
|
||||
@@ -104,36 +157,18 @@ pub async fn process_failed_job(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Step 2: Mark the job record as failed. ---
|
||||
|
||||
let frontend_failure_category = order.fail_reason.as_ref().map(|fr| {
|
||||
match fr.failure_type {
|
||||
FailureType::RuleBansUserImage => FrontendFailureCategory::RuleBansUserImage,
|
||||
FailureType::RuleBansUserImageWithFaces => FrontendFailureCategory::RuleBansUserImageWithFaces,
|
||||
FailureType::RuleBansUserTextPrompt => FrontendFailureCategory::RuleBansUserTextPrompt,
|
||||
FailureType::RuleBansUserContent => FrontendFailureCategory::RuleBansUserContent,
|
||||
FailureType::RuleBansGeneratedVideo => FrontendFailureCategory::RuleBansGeneratedVideo,
|
||||
FailureType::RuleBansGeneratedAudio => FrontendFailureCategory::RuleBansGeneratedAudio,
|
||||
FailureType::RuleBansGeneratedContent => FrontendFailureCategory::RuleBansGeneratedContent,
|
||||
FailureType::GenerationFailed => FrontendFailureCategory::GenerationFailed,
|
||||
FailureType::OtherUnknownReason => FrontendFailureCategory::GenerationFailed,
|
||||
}
|
||||
});
|
||||
|
||||
warn!(
|
||||
"Order {} failed: {}. Marking job {} failed.",
|
||||
order.order_id, reason, job.job_token.as_str()
|
||||
);
|
||||
|
||||
let mark_failed_result = mark_job_failed_by_token(MarkJobFailedByTokenArgs {
|
||||
pool: &deps.mysql_pool,
|
||||
job_token: &job.job_token,
|
||||
maybe_public_failure_reason: Some(reason),
|
||||
internal_debugging_failure_reason: reason,
|
||||
maybe_frontend_failure_category: frontend_failure_category,
|
||||
}).await;
|
||||
|
||||
if let Err(err) = mark_failed_result {
|
||||
if let Err(err) = mark_job_failed_by_token_with_executor(
|
||||
&mut *transaction,
|
||||
&job.job_token,
|
||||
Some(reason),
|
||||
reason,
|
||||
frontend_failure_category,
|
||||
).await {
|
||||
error!(
|
||||
"Error marking job {} as failed: {:?}",
|
||||
job.job_token.as_str(),
|
||||
@@ -141,12 +176,23 @@ pub async fn process_failed_job(
|
||||
);
|
||||
|
||||
let notification = NotificationDetailsBuilder::from_boxed_error(err.into())
|
||||
.set_title("Seedance2Pro mark job failed error".to_string())
|
||||
.set_title("Kinovi mark job failed error".to_string())
|
||||
.set_urgency(Some(NotificationUrgency::Medium))
|
||||
.build();
|
||||
|
||||
if let Err(pager_err) = deps.pager.enqueue_page(notification) {
|
||||
error!("Failed to enqueue pager alert: {:?}", pager_err);
|
||||
}
|
||||
|
||||
let _ = transaction.rollback().await;
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(err) = transaction.commit().await {
|
||||
error!(
|
||||
"Failed to commit failure transaction for job {}: {:?}. \
|
||||
Will be retried next poll.",
|
||||
job.job_token.as_str(), err,
|
||||
);
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
use log::warn;
|
||||
|
||||
use errors::AnyhowResult;
|
||||
use seedance2pro_client::requests::poll_orders::poll_orders::{OrderMediaType, TaskStatus};
|
||||
|
||||
use crate::job_dependencies::JobDependencies;
|
||||
use crate::jobs::order_processing_job::process_failed_job::process_failed_job;
|
||||
use crate::jobs::order_processing_job::process_successful_image_job::process_successful_image_job;
|
||||
use crate::jobs::order_processing_job::process_successful_video_job::process_successful_video_job;
|
||||
use crate::order_reconciler::OrderDetails;
|
||||
|
||||
/// Reconcile a single finished order into our system, dispatching on its
|
||||
/// terminal status and media type.
|
||||
pub async fn process_one_order(
|
||||
deps: &JobDependencies,
|
||||
details: &OrderDetails,
|
||||
) -> AnyhowResult<()> {
|
||||
let job = &details.database_record;
|
||||
let order = &details.kinovi_record;
|
||||
|
||||
match &order.task_status {
|
||||
TaskStatus::Completed => {
|
||||
// Dispatch on media type: Midjourney image orders return ~4 PNGs and need
|
||||
// different download/insert handling than the video path. `None` (older
|
||||
// response shapes that pre-date the `mediaType` field) and `Some(Video)`
|
||||
// both fall to the video handler for back-compat.
|
||||
match &order.media_type {
|
||||
Some(OrderMediaType::Image) => process_successful_image_job(deps, job, order).await,
|
||||
Some(OrderMediaType::Video) | None => process_successful_video_job(deps, job, order).await,
|
||||
Some(OrderMediaType::Unknown(other)) => {
|
||||
warn!(
|
||||
"Order {} has unrecognised media_type {:?}; treating as video.",
|
||||
order.order_id, other,
|
||||
);
|
||||
process_successful_video_job(deps, job, order).await
|
||||
}
|
||||
}
|
||||
}
|
||||
TaskStatus::Failed => {
|
||||
process_failed_job(deps, job, order).await;
|
||||
Ok(())
|
||||
}
|
||||
// The poller only stages terminal orders, so these shouldn't occur — but be
|
||||
// defensive rather than mis-process a non-final order.
|
||||
TaskStatus::Pending | TaskStatus::Processing => {
|
||||
warn!(
|
||||
"Staged order {} is unexpectedly still in progress ({:?}); skipping.",
|
||||
order.order_id, order.task_status,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
TaskStatus::Unknown(status) => {
|
||||
warn!(
|
||||
"Staged order {} has unknown status {:?}; skipping.",
|
||||
order.order_id, status,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
+65
-15
@@ -11,14 +11,16 @@ use enums::common::generation_provider::GenerationProvider;
|
||||
use errors::AnyhowResult;
|
||||
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
|
||||
use mysql_queries::queries::generic_inference::api_providers::seedance2pro::list_pending_seedance2pro_video_jobs::PendingSeedance2ProJob;
|
||||
use mysql_queries::queries::generic_inference::web::mark_generic_inference_job_successfully_done_by_token::mark_generic_inference_job_successfully_done_by_token;
|
||||
use mysql_queries::queries::generic_inference::job::select_inference_job_status_for_update::select_inference_job_status_for_update;
|
||||
use mysql_queries::queries::generic_inference::web::mark_generic_inference_job_successfully_done_by_token_with_executor::{mark_generic_inference_job_successfully_done_by_token_with_executor, MarkGenericInferenceJobSuccessfullyDoneByTokenWithExecutorArgs};
|
||||
use mysql_queries::queries::media_files::create::insert_builder::media_file_insert_builder::MediaFileInsertBuilder;
|
||||
use seedance2pro_client::requests::poll_orders::poll_orders::{OrderStatus, VideoResult};
|
||||
use seedance2pro_client::requests::poll_orders::poll_orders::{OrderStatus, MediaResult};
|
||||
use tokens::tokens::batch_generations::BatchGenerationToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::alert_on_error::alert_pager_and_return_err;
|
||||
use crate::job_dependencies::JobDependencies;
|
||||
use crate::jobs::video_polling_job::alert_on_error::alert_pager_and_return_err;
|
||||
use crate::jobs::order_processing_job::is_job_status_terminal::is_job_status_terminal;
|
||||
|
||||
const PREFIX: &str = "artcraft_";
|
||||
const SUFFIX: &str = ".png";
|
||||
@@ -90,30 +92,78 @@ pub async fn process_successful_image_job(
|
||||
})?;
|
||||
|
||||
info!(
|
||||
"Created {} media file(s) for order {} (primary={}). Marking job {} complete.",
|
||||
"Created {} media file(s) for order {} (primary={}). Finalizing job {}.",
|
||||
created_tokens.len(),
|
||||
order.order_id,
|
||||
primary_token.as_str(),
|
||||
job.job_token.as_str(),
|
||||
);
|
||||
|
||||
if let Err(err) = mark_generic_inference_job_successfully_done_by_token(
|
||||
&deps.mysql_pool,
|
||||
&job.job_token,
|
||||
Some(InferenceResultType::MediaFile),
|
||||
Some(primary_token.as_str()),
|
||||
None,
|
||||
None,
|
||||
// Finalize inside a transaction: re-check the job is still pending under a row
|
||||
// lock, then mark it complete. See `process_successful_video_job` for the rationale.
|
||||
let mut transaction = deps.mysql_pool.begin().await.map_err(|err| {
|
||||
anyhow!("error beginning finalize transaction for job {}: {:?}", job.job_token.as_str(), err)
|
||||
})?;
|
||||
|
||||
let maybe_status = select_inference_job_status_for_update(&mut *transaction, &job.job_token)
|
||||
.await
|
||||
.map_err(|err| anyhow!("error locking job {} for finalize: {:?}", job.job_token.as_str(), err))?;
|
||||
|
||||
// ── Terminal-state guard (do NOT remove) ──
|
||||
//
|
||||
// Bail unless the job is still pending. A concurrent finalizer (another poll,
|
||||
// a web cancel) may have settled it between the processing loop's pre-check
|
||||
// and this locked re-read. If we don't stop here we'd re-mark a finished job
|
||||
// and leak the just-uploaded media files. This is the single most important
|
||||
// check in this function, so it's a discrete, early-returning step.
|
||||
|
||||
let status = match maybe_status {
|
||||
Some(status) => status,
|
||||
None => {
|
||||
let _ = transaction.rollback().await;
|
||||
return Err(anyhow!(
|
||||
"Job {} vanished before finalize (order {})",
|
||||
job.job_token.as_str(), order.order_id,
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
if is_job_status_terminal(status) {
|
||||
warn!(
|
||||
"Job {} is already terminal ({:?}); skipping mark-done (order {}). \
|
||||
{} media file(s) may be orphaned.",
|
||||
job.job_token.as_str(), status, order.order_id, created_tokens.len(),
|
||||
);
|
||||
let _ = transaction.rollback().await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Still pending — mark it done within the locked transaction.
|
||||
|
||||
if let Err(err) = mark_generic_inference_job_successfully_done_by_token_with_executor(
|
||||
MarkGenericInferenceJobSuccessfullyDoneByTokenWithExecutorArgs {
|
||||
executor: &mut *transaction,
|
||||
token: &job.job_token,
|
||||
maybe_entity_type: Some(InferenceResultType::MediaFile),
|
||||
maybe_entity_token: Some(primary_token.as_str()),
|
||||
total_job_duration: None,
|
||||
inference_duration: None,
|
||||
},
|
||||
).await {
|
||||
let _ = transaction.rollback().await;
|
||||
error!("Error marking image job {} done: {:?}", job.job_token.as_str(), err);
|
||||
return alert_pager_and_return_err(
|
||||
&deps.pager,
|
||||
"Seedance2Pro image job completion update failed",
|
||||
"Kinovi image job completion update failed",
|
||||
anyhow!("error marking job done: {:?}", err),
|
||||
Some(job),
|
||||
);
|
||||
}
|
||||
|
||||
transaction.commit().await.map_err(|err| {
|
||||
anyhow!("error committing finalize transaction for job {}: {:?}", job.job_token.as_str(), err)
|
||||
})?;
|
||||
|
||||
info!("Image job {} completed successfully.", job.job_token.as_str());
|
||||
|
||||
Ok(())
|
||||
@@ -125,7 +175,7 @@ async fn download_and_store_one_image(
|
||||
deps: &JobDependencies,
|
||||
job: &PendingSeedance2ProJob,
|
||||
order: &OrderStatus,
|
||||
result: &VideoResult,
|
||||
result: &MediaResult,
|
||||
index: usize,
|
||||
maybe_batch_token: Option<&BatchGenerationToken>,
|
||||
) -> AnyhowResult<MediaFileToken> {
|
||||
@@ -174,8 +224,8 @@ async fn download_and_store_one_image(
|
||||
.maybe_batch_generation_token(maybe_batch_token)
|
||||
.maybe_creator_anonymous_visitor(job.maybe_creator_anonymous_visitor_token.as_ref())
|
||||
.maybe_creator_user(job.maybe_creator_user_token.as_ref())
|
||||
.maybe_frame_height(Some(result.height))
|
||||
.maybe_frame_width(Some(result.width))
|
||||
.maybe_frame_height(result.maybe_height)
|
||||
.maybe_frame_width(result.maybe_width)
|
||||
.maybe_generation_provider(Some(GenerationProvider::Artcraft))
|
||||
.maybe_prompt_token(job.maybe_prompt_token.as_ref())
|
||||
.maybe_platform_type(job.maybe_platform_type)
|
||||
+82
-20
@@ -1,5 +1,5 @@
|
||||
use anyhow::anyhow;
|
||||
use log::{error, info};
|
||||
use log::{error, info, warn};
|
||||
|
||||
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
|
||||
use enums::by_table::generic_inference_jobs::inference_result_type::InferenceResultType;
|
||||
@@ -11,18 +11,20 @@ use enums::common::generation_provider::GenerationProvider;
|
||||
use errors::AnyhowResult;
|
||||
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
|
||||
use mysql_queries::queries::generic_inference::api_providers::seedance2pro::list_pending_seedance2pro_video_jobs::PendingSeedance2ProJob;
|
||||
use mysql_queries::queries::generic_inference::job::select_inference_job_status_for_update::select_inference_job_status_for_update;
|
||||
use mysql_queries::queries::generic_inference::web::mark_generic_inference_job_successfully_done_by_token_with_executor::{mark_generic_inference_job_successfully_done_by_token_with_executor, MarkGenericInferenceJobSuccessfullyDoneByTokenWithExecutorArgs};
|
||||
use mysql_queries::queries::media_files::create::insert_builder::media_file_insert_builder::MediaFileInsertBuilder;
|
||||
use mysql_queries::queries::generic_inference::web::mark_generic_inference_job_successfully_done_by_token::mark_generic_inference_job_successfully_done_by_token;
|
||||
use seedance2pro_client::requests::poll_orders::poll_orders::OrderStatus;
|
||||
|
||||
use crate::jobs::video_polling_job::alert_on_error::alert_pager_and_return_err;
|
||||
use crate::alert_on_error::alert_pager_and_return_err;
|
||||
use crate::job_dependencies::JobDependencies;
|
||||
use crate::jobs::order_processing_job::is_job_status_terminal::is_job_status_terminal;
|
||||
|
||||
const PREFIX: &str = "artcraft_";
|
||||
const SUFFIX: &str = ".mp4";
|
||||
|
||||
/// Download the completed video, upload to bucket, create media file record, and mark job done.
|
||||
pub async fn process_successful_job(
|
||||
pub async fn process_successful_video_job(
|
||||
deps: &JobDependencies,
|
||||
job: &PendingSeedance2ProJob,
|
||||
order: &OrderStatus,
|
||||
@@ -57,7 +59,7 @@ pub async fn process_successful_job(
|
||||
error!("Error reading video bytes for order {}: {:?}", order.order_id, err);
|
||||
return alert_pager_and_return_err(
|
||||
&deps.pager,
|
||||
"Seedance2Pro video download failed",
|
||||
"Kinovi video download failed",
|
||||
anyhow!("error reading video bytes: {:?}", err),
|
||||
Some(job),
|
||||
);
|
||||
@@ -67,7 +69,7 @@ pub async fn process_successful_job(
|
||||
error!("Error downloading video for order {}: {:?}", order.order_id, err);
|
||||
return alert_pager_and_return_err(
|
||||
&deps.pager,
|
||||
"Seedance2Pro video download failed",
|
||||
"Kinovi video download failed",
|
||||
anyhow!("reqwest error downloading video: {:?}", err),
|
||||
Some(job),
|
||||
);
|
||||
@@ -104,7 +106,7 @@ pub async fn process_successful_job(
|
||||
error!("Error uploading video for order {}: {:?}", order.order_id, err);
|
||||
return alert_pager_and_return_err(
|
||||
&deps.pager,
|
||||
"Seedance2Pro bucket upload failed",
|
||||
"Kinovi bucket upload failed",
|
||||
anyhow!("error uploading video to bucket: {:?}", err),
|
||||
Some(job),
|
||||
);
|
||||
@@ -116,8 +118,9 @@ pub async fn process_successful_job(
|
||||
);
|
||||
|
||||
// Optionally extract frame dimensions from the order results.
|
||||
let maybe_frame_width = order.results.first().map(|r| r.width);
|
||||
let maybe_frame_height = order.results.first().map(|r| r.height);
|
||||
// Persist the integer dimensions when the API provides them, NULL otherwise.
|
||||
let maybe_frame_width = order.results.first().and_then(|r| r.maybe_width);
|
||||
let maybe_frame_height = order.results.first().and_then(|r| r.maybe_height);
|
||||
|
||||
// Insert media file record.
|
||||
let media_file_result = MediaFileInsertBuilder::new()
|
||||
@@ -147,7 +150,7 @@ pub async fn process_successful_job(
|
||||
error!("Error inserting media file record for order {}: {:?}", order.order_id, err);
|
||||
return alert_pager_and_return_err(
|
||||
&deps.pager,
|
||||
"Seedance2Pro media file insert failed",
|
||||
"Kinovi media file insert failed",
|
||||
anyhow!("error inserting media file record: {:?}", err),
|
||||
Some(job),
|
||||
);
|
||||
@@ -155,30 +158,89 @@ pub async fn process_successful_job(
|
||||
};
|
||||
|
||||
info!(
|
||||
"Created media file {} for order {}. Marking job {} complete.",
|
||||
"Created media file {} for order {}. Finalizing job {}.",
|
||||
media_file_token.as_str(),
|
||||
order.order_id,
|
||||
job.job_token.as_str()
|
||||
);
|
||||
|
||||
// Mark inference job as successfully completed.
|
||||
if let Err(err) = mark_generic_inference_job_successfully_done_by_token(
|
||||
&deps.mysql_pool,
|
||||
&job.job_token,
|
||||
Some(InferenceResultType::MediaFile),
|
||||
Some(media_file_token.as_str()),
|
||||
None,
|
||||
None,
|
||||
// Finalize inside a transaction: re-check the job is still pending under a row
|
||||
// lock, then mark it complete. This guards against a concurrent finalizer
|
||||
// (another poll, a web cancel) having already settled the job between our
|
||||
// pre-check and now. If it has, the freshly-uploaded media file is left
|
||||
// orphaned (a rare, harmless cost).
|
||||
finalize_success(deps, job, order, media_file_token.as_str()).await
|
||||
}
|
||||
|
||||
async fn finalize_success(
|
||||
deps: &JobDependencies,
|
||||
job: &PendingSeedance2ProJob,
|
||||
order: &OrderStatus,
|
||||
media_file_token: &str,
|
||||
) -> AnyhowResult<()> {
|
||||
let mut transaction = deps.mysql_pool.begin().await.map_err(|err| {
|
||||
anyhow!("error beginning finalize transaction for job {}: {:?}", job.job_token.as_str(), err)
|
||||
})?;
|
||||
|
||||
let maybe_status = select_inference_job_status_for_update(&mut *transaction, &job.job_token)
|
||||
.await
|
||||
.map_err(|err| anyhow!("error locking job {} for finalize: {:?}", job.job_token.as_str(), err))?;
|
||||
|
||||
// ── Terminal-state guard (do NOT remove) ──
|
||||
//
|
||||
// Bail unless the job is still pending. A concurrent finalizer (another poll,
|
||||
// a web cancel) may have settled it between the processing loop's pre-check
|
||||
// and this locked re-read. If we don't stop here we'd re-mark a finished job
|
||||
// and leak the just-uploaded media file. This is the single most important
|
||||
// check in this function, so it's a discrete, early-returning step.
|
||||
|
||||
let status = match maybe_status {
|
||||
Some(status) => status,
|
||||
None => {
|
||||
let _ = transaction.rollback().await;
|
||||
return Err(anyhow!(
|
||||
"Job {} vanished before finalize (order {})",
|
||||
job.job_token.as_str(), order.order_id,
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
if is_job_status_terminal(status) {
|
||||
warn!(
|
||||
"Job {} is already terminal ({:?}); skipping mark-done (order {}). \
|
||||
Media file {} may be orphaned.",
|
||||
job.job_token.as_str(), status, order.order_id, media_file_token,
|
||||
);
|
||||
let _ = transaction.rollback().await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Still pending — mark it done within the locked transaction.
|
||||
|
||||
if let Err(err) = mark_generic_inference_job_successfully_done_by_token_with_executor(
|
||||
MarkGenericInferenceJobSuccessfullyDoneByTokenWithExecutorArgs {
|
||||
executor: &mut *transaction,
|
||||
token: &job.job_token,
|
||||
maybe_entity_type: Some(InferenceResultType::MediaFile),
|
||||
maybe_entity_token: Some(media_file_token),
|
||||
total_job_duration: None,
|
||||
inference_duration: None,
|
||||
},
|
||||
).await {
|
||||
let _ = transaction.rollback().await;
|
||||
error!("Error marking job {} done: {:?}", job.job_token.as_str(), err);
|
||||
return alert_pager_and_return_err(
|
||||
&deps.pager,
|
||||
"Seedance2Pro job completion update failed",
|
||||
"Kinovi job completion update failed",
|
||||
anyhow!("error marking job done: {:?}", err),
|
||||
Some(job),
|
||||
);
|
||||
}
|
||||
|
||||
transaction.commit().await.map_err(|err| {
|
||||
anyhow!("error committing finalize transaction for job {}: {:?}", job.job_token.as_str(), err)
|
||||
})?;
|
||||
|
||||
info!("Job {} completed successfully.", job.job_token.as_str());
|
||||
|
||||
Ok(())
|
||||
@@ -1,4 +0,0 @@
|
||||
pub mod alert_on_error;
|
||||
pub mod video_polling_main_loop;
|
||||
pub mod process_job;
|
||||
pub mod process_orders_batch;
|
||||
@@ -1,3 +0,0 @@
|
||||
pub mod process_failed_job;
|
||||
pub mod process_successful_image_job;
|
||||
pub mod process_successful_job;
|
||||
-127
@@ -1,127 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use log::{info, warn};
|
||||
use mysql_queries::queries::generic_inference::api_providers::seedance2pro::list_pending_seedance2pro_video_jobs::PendingSeedance2ProJob;
|
||||
use seedance2pro_client::requests::poll_orders::poll_orders::{OrderMediaType, OrderStatus, TaskStatus};
|
||||
|
||||
use crate::job_dependencies::JobDependencies;
|
||||
use crate::jobs::video_polling_job::process_job::process_failed_job::process_failed_job;
|
||||
use crate::jobs::video_polling_job::process_job::process_successful_image_job::process_successful_image_job;
|
||||
use crate::jobs::video_polling_job::process_job::process_successful_job::process_successful_job;
|
||||
|
||||
/// Process a batch of polled orders against the pending jobs map.
|
||||
///
|
||||
/// Removes matched order_ids from `job_by_order_id` so they are not
|
||||
/// processed again in subsequent batches.
|
||||
pub async fn process_orders_batch(
|
||||
deps: &JobDependencies,
|
||||
kinovi_orders: &[OrderStatus],
|
||||
job_by_order_id: &mut HashMap<String, PendingSeedance2ProJob>,
|
||||
pages_in_current_batch: u32,
|
||||
) {
|
||||
log_batch_summary(&kinovi_orders, pages_in_current_batch);
|
||||
|
||||
let pending_job_count = job_by_order_id.len();
|
||||
|
||||
let mut batch_succeeded = 0u32;
|
||||
let mut batch_failed = 0u32;
|
||||
let mut batch_in_progress = 0u32;
|
||||
let mut batch_matched = 0u32;
|
||||
|
||||
for order in kinovi_orders {
|
||||
let job = match job_by_order_id.remove(&order.order_id) {
|
||||
Some(j) => j,
|
||||
None => continue, // Not one of our pending jobs.
|
||||
};
|
||||
|
||||
batch_matched += 1;
|
||||
|
||||
match &order.task_status {
|
||||
TaskStatus::Completed => {
|
||||
info!(
|
||||
"Order {} completed (media_type={:?}), processing job {}",
|
||||
order.order_id,
|
||||
order.media_type,
|
||||
job.job_token.as_str()
|
||||
);
|
||||
|
||||
// Dispatch on media type: Midjourney image orders return ~4 PNGs
|
||||
// and need different download/insert handling than the video
|
||||
// path. `None` (older response shapes that pre-date the
|
||||
// `mediaType` field) and `Some(Video)` both fall to the video
|
||||
// handler for back-compat with the seedance video flow.
|
||||
let result = match &order.media_type {
|
||||
Some(OrderMediaType::Image) => process_successful_image_job(deps, &job, order).await,
|
||||
Some(OrderMediaType::Video) | None => process_successful_job(deps, &job, order).await,
|
||||
Some(OrderMediaType::Unknown(other)) => {
|
||||
warn!(
|
||||
"Order {} has unrecognised media_type {:?}; treating as video",
|
||||
order.order_id, other,
|
||||
);
|
||||
process_successful_job(deps, &job, order).await
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = result {
|
||||
warn!(
|
||||
"Error processing completed order {}: {:?}",
|
||||
order.order_id, err
|
||||
);
|
||||
let _ = deps.job_stats.increment_failure_count();
|
||||
} else {
|
||||
let _ = deps.job_stats.increment_success_count();
|
||||
batch_succeeded += 1;
|
||||
}
|
||||
}
|
||||
TaskStatus::Failed => {
|
||||
process_failed_job(deps, &job, order).await;
|
||||
batch_failed += 1;
|
||||
}
|
||||
TaskStatus::Pending | TaskStatus::Processing => {
|
||||
// Still in progress — check again next poll.
|
||||
batch_in_progress += 1;
|
||||
}
|
||||
TaskStatus::Unknown(unknown_status) => {
|
||||
warn!("Unknown order status: {:?}", unknown_status);
|
||||
batch_in_progress += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
"Batch processing done: \
|
||||
{} kinovi orders in polled batch, \
|
||||
{} pending db jobs in batch at start, \
|
||||
{} kinovi orders in batch matched pending jobs \
|
||||
(orders succeeded={}, orders failed={}, orders in_progress={}), \
|
||||
{} pending db jobs in batch remaining",
|
||||
kinovi_orders.len(),
|
||||
pending_job_count,
|
||||
batch_matched,
|
||||
batch_succeeded,
|
||||
batch_failed,
|
||||
batch_in_progress,
|
||||
job_by_order_id.len(),
|
||||
);
|
||||
}
|
||||
|
||||
fn log_batch_summary(orders: &[OrderStatus], pages_in_batch: u32) {
|
||||
let mut succeeded = 0u32;
|
||||
let mut failed = 0u32;
|
||||
let mut in_progress = 0u32;
|
||||
let mut unknown = 0u32;
|
||||
|
||||
for order in orders {
|
||||
match &order.task_status {
|
||||
TaskStatus::Completed => succeeded += 1,
|
||||
TaskStatus::Failed => failed += 1,
|
||||
TaskStatus::Pending | TaskStatus::Processing => in_progress += 1,
|
||||
TaskStatus::Unknown(_) => unknown += 1,
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
"Processing batch of {} Kinovi order pages, {} total orders (succeeded={}, failed={}, in_progress={}, unknown={})",
|
||||
pages_in_batch, orders.len(), succeeded, failed, in_progress, unknown
|
||||
);
|
||||
}
|
||||
-287
@@ -1,287 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use chrono::Utc;
|
||||
use enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty;
|
||||
use enums::by_table::generic_inference_jobs::inference_job_type::InferenceJobType;
|
||||
use log::{error, info, warn};
|
||||
use mysql_queries::queries::generic_inference::api_providers::seedance2pro::list_pending_seedance2pro_video_jobs::{list_pending_seedance2pro_video_jobs, PendingSeedance2ProJob};
|
||||
use pager::notification::notification_details_builder::NotificationDetailsBuilder;
|
||||
use pager::notification::notification_urgency::NotificationUrgency;
|
||||
use seedance2pro_client::requests::poll_orders::poll_orders::{poll_orders, OrderStatus, PollOrdersArgs, PollOrdersResponse, TaskStatus};
|
||||
|
||||
use crate::job_dependencies::JobDependencies;
|
||||
use crate::jobs::video_polling_job::alert_on_error::alert_pager_and_return_err;
|
||||
use crate::jobs::video_polling_job::process_orders_batch::process_orders_batch;
|
||||
use crate::kinovi_version::KinoviVersion;
|
||||
|
||||
const POLL_ALERT_THRESHOLD: Duration = Duration::from_mins(6);
|
||||
|
||||
pub async fn video_polling_main_loop(job_dependencies: JobDependencies) {
|
||||
while !job_dependencies.application_shutdown.get() {
|
||||
let start = Instant::now();
|
||||
|
||||
//
|
||||
// Run a single polling iteration and alert if it takes too long
|
||||
//
|
||||
|
||||
let result = run_poll_iteration(&job_dependencies).await;
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
if let Err(err) = result {
|
||||
error!("Error in poll iteration: {:?}", err);
|
||||
let _ = alert_pager_and_return_err::<()>(&job_dependencies.pager, "Kinovi poll iteration error", err, None);
|
||||
let _ = job_dependencies.job_stats.increment_failure_count();
|
||||
}
|
||||
|
||||
if elapsed > POLL_ALERT_THRESHOLD {
|
||||
warn!("Poll iteration took {:.1}s (threshold: {}s)", elapsed.as_secs_f64(), POLL_ALERT_THRESHOLD.as_secs());
|
||||
|
||||
let notification = NotificationDetailsBuilder::from_title(
|
||||
"Kinovi poll iteration slow".to_string())
|
||||
.set_description(Some(format!(
|
||||
"Poll iteration took {:.1} seconds, exceeding the 10-minute threshold.",
|
||||
elapsed.as_secs_f64(),
|
||||
)))
|
||||
.set_urgency(Some(NotificationUrgency::Medium))
|
||||
.build();
|
||||
|
||||
if let Err(pager_err) = job_dependencies.pager.enqueue_page(notification) {
|
||||
error!("Failed to enqueue slow iteration alert: {:?}", pager_err);
|
||||
}
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(Duration::from_millis(job_dependencies.poll_interval_millis)) => {}
|
||||
_ = job_dependencies.shutdown_notify.notified() => {}
|
||||
}
|
||||
}
|
||||
|
||||
warn!("Kinovi job runner main loop is shut down.");
|
||||
}
|
||||
|
||||
async fn run_poll_iteration(deps: &JobDependencies) -> anyhow::Result<()> {
|
||||
info!("Querying database jobs for type: {:?}", deps.kinovi_version);
|
||||
|
||||
let (
|
||||
job_type,
|
||||
third_party,
|
||||
) = match deps.kinovi_version {
|
||||
KinoviVersion::Volcengine => (
|
||||
InferenceJobType::Seedance2ProQueue,
|
||||
InferenceJobExternalThirdParty::Seedance2Pro,
|
||||
),
|
||||
KinoviVersion::BytePlus => (
|
||||
InferenceJobType::Seedance2ProAltQueue,
|
||||
InferenceJobExternalThirdParty::Seedance2ProAlt,
|
||||
),
|
||||
KinoviVersion::BytePlusUltra => (
|
||||
InferenceJobType::Seedance2ProBytePlusUltraQueue,
|
||||
InferenceJobExternalThirdParty::Seedance2ProBytePlusUltra,
|
||||
),
|
||||
};
|
||||
|
||||
// 1. Query all (limit 25,000) non-terminal Seedance2Pro jobs from DB.
|
||||
// This is all non-(complete_success, complete_failure) jobs.
|
||||
let pending_jobs = match list_pending_seedance2pro_video_jobs(&deps.mysql_pool, third_party, job_type).await {
|
||||
Ok(jobs) => jobs,
|
||||
Err(err) => {
|
||||
error!("Failed to list pending database jobs: {:?}", err);
|
||||
return alert_pager_and_return_err(&deps.pager, "Jobs DB query failed", err.into(), None);
|
||||
}
|
||||
};
|
||||
|
||||
let total_pending_jobs = pending_jobs.len();
|
||||
|
||||
if pending_jobs.is_empty() {
|
||||
info!("No pending database jobs found in the database.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!("Found {} pending database job(s).", pending_jobs.len());
|
||||
|
||||
let result = website_polling_loop(&deps, pending_jobs).await?;
|
||||
|
||||
info!(
|
||||
"Database + Kinovi poll iteration complete: \
|
||||
{} total database jobs, \
|
||||
{} total Kinovi pages, \
|
||||
{} total Kinovi orders seen.",
|
||||
total_pending_jobs,
|
||||
result.total_pages_seen,
|
||||
result.total_orders_seen
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct WebsitePollingResult {
|
||||
total_pages_seen: u32,
|
||||
total_orders_seen: u32,
|
||||
}
|
||||
|
||||
async fn website_polling_loop(
|
||||
deps: &&JobDependencies,
|
||||
pending_jobs: Vec<PendingSeedance2ProJob>
|
||||
) -> anyhow::Result<WebsitePollingResult> {
|
||||
|
||||
// Build a lookup: order_id -> job. This is mutated as batches are processed
|
||||
// so that each order is handled at most once.
|
||||
let mut job_by_order_id: HashMap<String, _> = pending_jobs
|
||||
.into_iter()
|
||||
.map(|job| (job.order_id.clone(), job))
|
||||
.collect();
|
||||
|
||||
// 2. Poll orders from Kinovi API, optionally processing in chunks.
|
||||
let mut cursor: Option<u64> = None;
|
||||
let mut total_pages_seen: u32 = 0;
|
||||
let mut total_orders_seen: u32 = 0;
|
||||
|
||||
// Accumulates orders for the current batch (when chunking is enabled)
|
||||
// or all orders (when chunking is disabled).
|
||||
let mut batch_orders: Vec<OrderStatus> = Vec::new();
|
||||
let mut pages_in_current_batch: u32 = 0;
|
||||
|
||||
loop {
|
||||
if deps.application_shutdown.get() {
|
||||
info!("Shutdown requested during pagination. Stopping early.");
|
||||
break;
|
||||
}
|
||||
|
||||
info!(
|
||||
"Beginning request for Kinovi page {}... (cursor: {:?}, total orders seen thus far: {})",
|
||||
total_pages_seen, cursor, total_orders_seen
|
||||
);
|
||||
|
||||
let response = poll_orders_with_retry(deps, cursor).await?;
|
||||
|
||||
let page_orders_count = response.orders.len() as u32;
|
||||
|
||||
total_pages_seen += 1;
|
||||
total_orders_seen += page_orders_count;
|
||||
|
||||
pages_in_current_batch += 1;
|
||||
|
||||
let maybe_last_order_created_at = response.orders
|
||||
.iter()
|
||||
.filter(|order| order.created_at_utc.is_some())
|
||||
.last()
|
||||
.and_then(|order| order.created_at_utc);
|
||||
|
||||
info!(
|
||||
"Done polling Kinovi orders page {}. \
|
||||
Got {} orders on this page (oldest on page created at {:?}).",
|
||||
total_pages_seen,
|
||||
page_orders_count,
|
||||
maybe_last_order_created_at,
|
||||
);
|
||||
|
||||
// Check if the last (oldest) order in this page exceeds the max age threshold.
|
||||
// Orders are returned newest-first, so the last order is the oldest.
|
||||
let mut exceeded_max_age = false;
|
||||
|
||||
if let Some(ref max_age) = deps.maybe_max_job_age {
|
||||
if let Some(last_order_created_at) = maybe_last_order_created_at {
|
||||
let order_age = Utc::now() - last_order_created_at;
|
||||
let too_old = order_age > *max_age;
|
||||
if too_old {
|
||||
info!(
|
||||
"Last order on Kinovi order page {} is {} hours old \
|
||||
(staleness threshold: {} hours). Stopping Kinovi pagination.",
|
||||
total_pages_seen,
|
||||
order_age.num_hours(),
|
||||
max_age.num_hours()
|
||||
);
|
||||
exceeded_max_age = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
batch_orders.extend(response.orders);
|
||||
|
||||
cursor = response.next_cursor;
|
||||
|
||||
let reached_end = cursor.is_none() || exceeded_max_age;
|
||||
|
||||
let batch_is_full = deps.maybe_pages_per_batch
|
||||
.map(|batch_page_limit| pages_in_current_batch >= batch_page_limit)
|
||||
.unwrap_or(false);
|
||||
|
||||
// Determine if we should process the current batch now.
|
||||
let should_process_batch_now = reached_end || batch_is_full;
|
||||
|
||||
if should_process_batch_now && !batch_orders.is_empty() {
|
||||
process_orders_batch(
|
||||
deps,
|
||||
&batch_orders,
|
||||
&mut job_by_order_id,
|
||||
pages_in_current_batch
|
||||
).await;
|
||||
|
||||
batch_orders.clear();
|
||||
pages_in_current_batch = 0;
|
||||
|
||||
// If all pending jobs have been matched, no need to keep paging.
|
||||
if job_by_order_id.is_empty() {
|
||||
info!("All pending database jobs have been matched to Kinovi orders and processed. Stopping pagination early.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if reached_end {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(WebsitePollingResult {
|
||||
total_pages_seen,
|
||||
total_orders_seen
|
||||
})
|
||||
}
|
||||
|
||||
/// Poll orders from Kinovi with retries. On transient failures, waits with
|
||||
/// increasing delay (attempt × 2s, capped at `poll_retry_max_delay_millis`).
|
||||
/// After exhausting retries, alerts the pager and returns an error.
|
||||
async fn poll_orders_with_retry(
|
||||
deps: &JobDependencies,
|
||||
cursor: Option<u64>,
|
||||
) -> anyhow::Result<PollOrdersResponse> {
|
||||
let max_retries = deps.poll_max_retries;
|
||||
|
||||
for attempt in 1..=max_retries {
|
||||
match poll_orders(PollOrdersArgs {
|
||||
session: &deps.seedance2pro_session,
|
||||
cursor,
|
||||
host_override: None,
|
||||
}).await {
|
||||
Ok(response) => return Ok(response),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"Error polling Kinovi orders (attempt {}/{}): {:?}",
|
||||
attempt, max_retries, err
|
||||
);
|
||||
|
||||
if attempt >= max_retries {
|
||||
return alert_pager_and_return_err(
|
||||
&deps.pager,
|
||||
"Kinovi API polling failed after retries",
|
||||
anyhow::anyhow!("poll_orders failed after {} attempts: {:?}", attempt, err),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
let delay_millis = (attempt as u64 * 2_000).min(deps.poll_retry_max_delay_millis);
|
||||
tokio::time::sleep(Duration::from_millis(delay_millis)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
alert_pager_and_return_err(
|
||||
&deps.pager,
|
||||
"Kinovi API polling failed after max retries",
|
||||
anyhow::anyhow!("poll_orders failed after {} attempts", max_retries),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -30,15 +30,19 @@ use shared_env_var_config::mysql::env_get_mysql_connection_string_or_default;
|
||||
use crate::http_server::run_http_server::{launch_http_server, CreateServerArgs};
|
||||
use crate::jobs::character_polling_job::character_polling_main_loop::character_polling_main_loop;
|
||||
use crate::jobs::credits_checking_job::credits_checking_main_loop::credits_checking_main_loop;
|
||||
use crate::jobs::video_polling_job::video_polling_main_loop::video_polling_main_loop;
|
||||
use crate::jobs::order_polling_job::order_polling_main_loop::order_polling_main_loop;
|
||||
use crate::jobs::order_processing_job::order_processing_main_loop::order_processing_main_loop;
|
||||
use crate::job_dependencies::JobDependencies;
|
||||
use crate::order_reconciler::OrderReconciler;
|
||||
use crate::startup::build_pager::build_pager;
|
||||
use crate::startup::kinovi_setup::{get_kinovi_session, get_kinovi_version};
|
||||
|
||||
pub mod alert_on_error;
|
||||
pub mod http_server;
|
||||
pub mod job_dependencies;
|
||||
pub mod jobs;
|
||||
pub mod kinovi_version;
|
||||
pub mod order_reconciler;
|
||||
pub mod startup;
|
||||
|
||||
// Bucket config
|
||||
@@ -112,12 +116,6 @@ async fn main() -> AnyhowResult<()> {
|
||||
5_000,
|
||||
)?;
|
||||
|
||||
let maybe_pages_per_batch: Option<u32> = easyenv::try_get_env_num_optional("BATCH_PAGE_COUNT")?;
|
||||
|
||||
if let Some(count) = maybe_pages_per_batch {
|
||||
info!("Batch page count: {}", count);
|
||||
}
|
||||
|
||||
let maybe_max_job_age = easyenv::try_get_env_num_optional::<i64>(ENV_MAX_JOB_AGE_THRESHOLD_HOURS)?
|
||||
.and_then(chrono::Duration::try_hours);
|
||||
|
||||
@@ -160,6 +158,9 @@ async fn main() -> AnyhowResult<()> {
|
||||
let shutdown_notify = Arc::new(Notify::new());
|
||||
let job_stats = JobStats::new();
|
||||
|
||||
// Shared hand-off between the polling loop (producer) and processing loop (consumer).
|
||||
let order_reconciler = OrderReconciler::new();
|
||||
|
||||
let pager_for_shutdown = pager.clone();
|
||||
|
||||
let create_server_args = CreateServerArgs {
|
||||
@@ -176,7 +177,6 @@ async fn main() -> AnyhowResult<()> {
|
||||
server_environment,
|
||||
job_stats,
|
||||
poll_interval_millis,
|
||||
maybe_pages_per_batch,
|
||||
maybe_max_job_age,
|
||||
poll_max_retries,
|
||||
poll_retry_max_delay_millis,
|
||||
@@ -184,6 +184,7 @@ async fn main() -> AnyhowResult<()> {
|
||||
application_shutdown: application_shutdown.clone(),
|
||||
shutdown_notify: shutdown_notify.clone(),
|
||||
pager,
|
||||
order_reconciler,
|
||||
};
|
||||
|
||||
// HTTP server runs on a separate OS thread with its own actix System.
|
||||
@@ -214,12 +215,18 @@ async fn main() -> AnyhowResult<()> {
|
||||
}
|
||||
});
|
||||
|
||||
// Spawn all polling loops as concurrent tasks.
|
||||
let video_deps = job_dependencies.clone();
|
||||
// Spawn all loops as concurrent tasks. Order polling (producer) and order
|
||||
// processing (consumer) run independently and hand off via the reconciler.
|
||||
let polling_deps = job_dependencies.clone();
|
||||
let processing_deps = job_dependencies.clone();
|
||||
let credits_deps = job_dependencies.clone();
|
||||
|
||||
let video_handle = tokio::spawn(async move {
|
||||
video_polling_main_loop(video_deps).await;
|
||||
let polling_handle = tokio::spawn(async move {
|
||||
order_polling_main_loop(polling_deps).await;
|
||||
});
|
||||
|
||||
let processing_handle = tokio::spawn(async move {
|
||||
order_processing_main_loop(processing_deps).await;
|
||||
});
|
||||
|
||||
let credits_handle = tokio::spawn(async move {
|
||||
@@ -231,11 +238,11 @@ async fn main() -> AnyhowResult<()> {
|
||||
let character_handle = tokio::spawn(async move {
|
||||
character_polling_main_loop(character_deps).await;
|
||||
});
|
||||
let _ = tokio::join!(video_handle, character_handle, credits_handle);
|
||||
let _ = tokio::join!(polling_handle, processing_handle, character_handle, credits_handle);
|
||||
} else {
|
||||
// skip character polling entirely.
|
||||
info!("Alternate mode: character polling is disabled.");
|
||||
let _ = tokio::join!(video_handle, credits_handle);
|
||||
let _ = tokio::join!(polling_handle, processing_handle, credits_handle);
|
||||
}
|
||||
|
||||
info!("Shutting down pager worker...");
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use rand::Rng;
|
||||
|
||||
use mysql_queries::queries::generic_inference::api_providers::seedance2pro::list_pending_seedance2pro_video_jobs::PendingSeedance2ProJob;
|
||||
use seedance2pro_client::requests::poll_orders::poll_orders::OrderStatus;
|
||||
|
||||
/// Kinovi's `order_id`. Used as the key into the reconciler.
|
||||
pub type OrderId = String;
|
||||
|
||||
/// A finished external order (Kinovi reports it `Completed` or `Failed`) paired
|
||||
/// with our database job record, staged for reconciliation into our own system.
|
||||
#[derive(Clone)]
|
||||
pub struct OrderDetails {
|
||||
/// The order payload fetched from Kinovi (success or failure details).
|
||||
pub kinovi_record: OrderStatus,
|
||||
/// Our database job that this order fulfills.
|
||||
pub database_record: PendingSeedance2ProJob,
|
||||
}
|
||||
|
||||
/// Hand-off point between the polling loop and the processing loop.
|
||||
///
|
||||
/// Metaphor: reconciliation. Kinovi is an external ledger of orders; once an
|
||||
/// order is *finished* there (success or failure), our system still has to be
|
||||
/// brought into agreement — download/upload the result, write the media file,
|
||||
/// settle the job row, refund on failure. The polling loop discovers finished
|
||||
/// orders and stages them here; the processing loop drains them and reconciles
|
||||
/// each one into our database.
|
||||
///
|
||||
/// Cheaply [`Clone`]able: clones share the same underlying map.
|
||||
#[derive(Clone)]
|
||||
pub struct OrderReconciler {
|
||||
// NB: A std (sync) RwLock is intentional. All critical sections are tiny,
|
||||
// in-memory map operations with no `.await` held across the guard, so there
|
||||
// is no risk of blocking the async runtime.
|
||||
inner: Arc<RwLock<HashMap<OrderId, OrderDetails>>>,
|
||||
}
|
||||
|
||||
impl OrderReconciler {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(RwLock::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stage a finished order for reconciliation, but only if it isn't already
|
||||
/// staged. Returns `true` if it was newly inserted, `false` if an entry for
|
||||
/// this `order_id` was already present (so the poller doesn't re-enqueue an
|
||||
/// order the processor is mid-way through, or one it already staged).
|
||||
pub fn push_order(
|
||||
&self,
|
||||
order_id: OrderId,
|
||||
kinovi_order: OrderStatus,
|
||||
database_record: PendingSeedance2ProJob,
|
||||
) -> bool {
|
||||
let mut map = self.inner.write().expect("order reconciler lock poisoned");
|
||||
|
||||
if map.contains_key(&order_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
map.insert(order_id, OrderDetails { kinovi_record: kinovi_order, database_record });
|
||||
true
|
||||
}
|
||||
|
||||
/// Clone out one *randomly chosen* staged order without removing it, so the
|
||||
/// caller can consult the database before committing to (and popping) it.
|
||||
/// Returns `None` when nothing is staged.
|
||||
///
|
||||
/// Selection is random on purpose: if we always handed back the same
|
||||
/// (deterministically ordered) entry, a single order that keeps failing to
|
||||
/// process — and keeps getting re-staged by the poller — could monopolize the
|
||||
/// consumer and starve everything behind it. Random pick spreads attempts
|
||||
/// across the whole backlog so one poison order can't wedge the queue.
|
||||
pub fn peek_random(&self) -> Option<OrderDetails> {
|
||||
let map = self.inner.read().expect("order reconciler lock poisoned");
|
||||
|
||||
if map.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let index = rand::rng().random_range(0..map.len());
|
||||
map.values().nth(index).cloned()
|
||||
}
|
||||
|
||||
/// Remove and return a staged order by id.
|
||||
pub fn remove(&self, order_id: &str) -> Option<OrderDetails> {
|
||||
let mut map = self.inner.write().expect("order reconciler lock poisoned");
|
||||
map.remove(order_id)
|
||||
}
|
||||
|
||||
pub fn contains(&self, order_id: &str) -> bool {
|
||||
let map = self.inner.read().expect("order reconciler lock poisoned");
|
||||
map.contains_key(order_id)
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
let map = self.inner.read().expect("order reconciler lock poisoned");
|
||||
map.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for OrderReconciler {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ use enums::common::generation::common_video_model::CommonVideoModel;
|
||||
use enums::common::generation::model_creator::ModelCreator;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use once_cell::sync::Lazy;
|
||||
use enums::common::generation::common_bitrate::CommonBitrate;
|
||||
|
||||
pub const OMNI_GEN_VIDEO_MODELS_AND_PROVIDERS: Lazy<OmniGenVideoModelsResponse> = Lazy::new(|| {
|
||||
let models = build_omni_gen_video_models();
|
||||
@@ -246,6 +247,11 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
|
||||
CommonResolution::TenEightyP,
|
||||
]),
|
||||
resolution_default: Some(CommonResolution::SevenTwentyP),
|
||||
bitrate_options: Some(vec![
|
||||
CommonBitrate::Normal,
|
||||
CommonBitrate::High,
|
||||
]),
|
||||
bitrate_default: Some(CommonBitrate::Normal),
|
||||
batch_size_options: Some(vec![1, 2, 4]),
|
||||
batch_size_default: Some(1),
|
||||
duration_seconds_min: Some(4),
|
||||
@@ -287,6 +293,11 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
|
||||
CommonResolution::SevenTwentyP,
|
||||
]),
|
||||
resolution_default: Some(CommonResolution::SevenTwentyP),
|
||||
bitrate_options: Some(vec![
|
||||
CommonBitrate::Normal,
|
||||
CommonBitrate::High,
|
||||
]),
|
||||
bitrate_default: Some(CommonBitrate::Normal),
|
||||
batch_size_options: Some(vec![1, 2, 4]),
|
||||
batch_size_default: Some(1),
|
||||
duration_seconds_min: Some(4),
|
||||
@@ -329,6 +340,11 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
|
||||
CommonResolution::TenEightyP,
|
||||
]),
|
||||
resolution_default: Some(CommonResolution::SevenTwentyP),
|
||||
bitrate_options: Some(vec![
|
||||
CommonBitrate::Normal,
|
||||
CommonBitrate::High,
|
||||
]),
|
||||
bitrate_default: Some(CommonBitrate::Normal),
|
||||
batch_size_options: Some(vec![1, 2, 4]),
|
||||
batch_size_default: Some(1),
|
||||
duration_seconds_min: Some(4),
|
||||
@@ -370,6 +386,11 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
|
||||
CommonResolution::SevenTwentyP,
|
||||
]),
|
||||
resolution_default: Some(CommonResolution::SevenTwentyP),
|
||||
bitrate_options: Some(vec![
|
||||
CommonBitrate::Normal,
|
||||
CommonBitrate::High,
|
||||
]),
|
||||
bitrate_default: Some(CommonBitrate::Normal),
|
||||
batch_size_options: Some(vec![1, 2, 4]),
|
||||
batch_size_default: Some(1),
|
||||
duration_seconds_min: Some(4),
|
||||
@@ -412,6 +433,11 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
|
||||
CommonResolution::TenEightyP,
|
||||
]),
|
||||
resolution_default: Some(CommonResolution::SevenTwentyP),
|
||||
bitrate_options: Some(vec![
|
||||
CommonBitrate::Normal,
|
||||
CommonBitrate::High,
|
||||
]),
|
||||
bitrate_default: Some(CommonBitrate::Normal),
|
||||
batch_size_options: Some(vec![1, 2, 4]),
|
||||
batch_size_default: Some(1),
|
||||
duration_seconds_min: Some(4),
|
||||
@@ -453,6 +479,10 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
|
||||
CommonResolution::SevenTwentyP,
|
||||
]),
|
||||
resolution_default: Some(CommonResolution::SevenTwentyP),
|
||||
bitrate_options: Some(vec![
|
||||
CommonBitrate::Normal,
|
||||
CommonBitrate::High,
|
||||
]),
|
||||
batch_size_options: Some(vec![1, 2, 4]),
|
||||
batch_size_default: Some(1),
|
||||
duration_seconds_min: Some(4),
|
||||
|
||||
@@ -268,6 +268,7 @@ use enums::by_table::users::user_feature_flag::UserFeatureFlag;
|
||||
use enums::common::generation::common_model_class::CommonModelClass;
|
||||
use enums::common::generation::common_model_type::CommonModelType;
|
||||
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
|
||||
use enums::common::generation::common_bitrate::CommonBitrate;
|
||||
use enums::common::generation::common_generation_mode::CommonGenerationMode;
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
@@ -338,6 +339,8 @@ use artcraft_api_defs::characters::edit_character::*;
|
||||
use artcraft_api_defs::characters::get_character::*;
|
||||
use artcraft_api_defs::characters::list_characters::*;
|
||||
use artcraft_api_defs::moderation::alerts::moderation_send_alert::*;
|
||||
use artcraft_api_defs::omni_api::generate_requests::omni_api_image_generate_request::*;
|
||||
use artcraft_api_defs::omni_api::generate_requests::omni_api_video_generate_request::*;
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::*;
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::*;
|
||||
use artcraft_api_defs::omni_gen::cost_response::omni_gen_image_cost_response::*;
|
||||
@@ -346,6 +349,9 @@ use artcraft_api_defs::omni_gen::generate_response::omni_gen_image_generate_resp
|
||||
use artcraft_api_defs::omni_gen::generate_response::omni_gen_video_generate_response::*;
|
||||
use artcraft_api_defs::omni_gen::models::omni_gen_image_models::*;
|
||||
use artcraft_api_defs::omni_gen::models::omni_gen_video_models::*;
|
||||
use artcraft_api_defs::omni_api::job_status::omni_api_batch_get_job_status::*;
|
||||
use artcraft_api_defs::omni_api::job_status::omni_api_get_job_status::*;
|
||||
use artcraft_api_defs::omni_api::job_status::omni_api_job_status_payload::*;
|
||||
use artcraft_api_defs::omni_api::omni_upload_audio::*;
|
||||
use artcraft_api_defs::omni_api::omni_upload_image::*;
|
||||
use artcraft_api_defs::omni_api::omni_upload_video::*;
|
||||
@@ -570,6 +576,8 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
crate::http_server::endpoints::omni_api::upload::omni_upload_audio_media_file_handler::omni_upload_audio_media_file_handler,
|
||||
crate::http_server::endpoints::omni_api::upload::omni_upload_image_media_file_handler::omni_upload_image_media_file_handler,
|
||||
crate::http_server::endpoints::omni_api::upload::omni_upload_video_media_file_handler::omni_upload_video_media_file_handler,
|
||||
crate::http_server::endpoints::omni_api::job_status::get_job_status_handler::omni_api_get_job_status_handler,
|
||||
crate::http_server::endpoints::omni_api::job_status::batch_get_job_status_handler::omni_api_batch_get_job_status_handler,
|
||||
// Moderation
|
||||
crate::http_server::endpoints::moderation::alerts::moderation_send_alert_handler::moderation_send_alert_handler,
|
||||
crate::http_server::endpoints::moderation::info::moderator_token_info_handler::moderator_get_token_info_handler,
|
||||
@@ -668,6 +676,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
CommonModelClass,
|
||||
CommonModelType,
|
||||
CommonAspectRatio,
|
||||
CommonBitrate,
|
||||
CommonGenerationMode,
|
||||
CommonResolution,
|
||||
PromptContextSemanticType,
|
||||
@@ -822,6 +831,14 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
GetCharacterResponse,
|
||||
ListCharactersEntry,
|
||||
ListCharactersResponse,
|
||||
OmniApiBatchGetJobStatusSuccessResponse,
|
||||
OmniApiGetJobStatusSuccessResponse,
|
||||
OmniApiImageGenerateRequest,
|
||||
OmniApiJobRequestDetails,
|
||||
OmniApiJobResultDetails,
|
||||
OmniApiJobStatusDetails,
|
||||
OmniApiJobStatusPayload,
|
||||
OmniApiVideoGenerateRequest,
|
||||
OmniGenImageCostAndGenerateRequest,
|
||||
OmniGenImageCostResponse,
|
||||
OmniGenImageGenerateResponse,
|
||||
|
||||
+1
@@ -51,6 +51,7 @@ pub async fn estimate_video_cost_handler(
|
||||
reference_character_tokens: None,
|
||||
resolution: router_resolution,
|
||||
aspect_ratio: router_aspect_ratio,
|
||||
bitrate: None,
|
||||
duration_seconds: request.duration_seconds,
|
||||
video_batch_count: request.video_batch_count,
|
||||
generate_audio: request.generate_audio,
|
||||
|
||||
+1
@@ -172,6 +172,7 @@ pub async fn flux_2_lora_edit_image_angle_handler(
|
||||
})?;
|
||||
|
||||
let prompt_result = insert_prompt(InsertPromptArgs {
|
||||
maybe_bitrate: None,
|
||||
maybe_apriori_prompt_token: None,
|
||||
prompt_type: PromptType::ArtcraftApp,
|
||||
maybe_creator_user_token: Some(&user_token),
|
||||
|
||||
+1
@@ -173,6 +173,7 @@ pub async fn qwen_edit_2511_edit_image_angle_handler(
|
||||
})?;
|
||||
|
||||
let prompt_result = insert_prompt(InsertPromptArgs {
|
||||
maybe_bitrate: None,
|
||||
maybe_apriori_prompt_token: None,
|
||||
prompt_type: PromptType::ArtcraftApp,
|
||||
maybe_creator_user_token: Some(&user_token),
|
||||
|
||||
+1
@@ -220,6 +220,7 @@ pub async fn flux_pro_kontext_max_edit_image_handler(
|
||||
};
|
||||
|
||||
let prompt_result = insert_prompt(InsertPromptArgs {
|
||||
maybe_bitrate: None,
|
||||
maybe_apriori_prompt_token: None,
|
||||
prompt_type: PromptType::ArtcraftApp,
|
||||
maybe_creator_user_token: maybe_user_session
|
||||
|
||||
+1
@@ -208,6 +208,7 @@ pub async fn gemini_25_flash_edit_image_handler(
|
||||
};
|
||||
|
||||
let prompt_result = insert_prompt(InsertPromptArgs {
|
||||
maybe_bitrate: None,
|
||||
maybe_apriori_prompt_token: None,
|
||||
prompt_type: PromptType::ArtcraftApp,
|
||||
maybe_creator_user_token: Some(&user_token),
|
||||
|
||||
+1
@@ -240,6 +240,7 @@ pub async fn gpt_image_1_edit_image_handler(
|
||||
};
|
||||
|
||||
let prompt_result = insert_prompt(InsertPromptArgs {
|
||||
maybe_bitrate: None,
|
||||
maybe_apriori_prompt_token: None,
|
||||
prompt_type: PromptType::ArtcraftApp,
|
||||
maybe_creator_user_token: Some(&user_token),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user