Compare commits

..
Author SHA1 Message Date
Brandon Thomas 2481f88a01 fix resolution 2026-06-20 10:08:06 -04:00
Brandon Thomas 61dceffd3e resolution 2026-06-20 09:53:08 -04:00
440 changed files with 1108 additions and 42942 deletions
@@ -48,7 +48,7 @@
"name": "maybe_entity_token",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
@@ -132,7 +132,7 @@
false,
false,
true,
true,
false,
true,
true,
true,
@@ -1,25 +0,0 @@
{
"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"
}
@@ -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_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 ",
"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 ",
"describe": {
"columns": [
{
@@ -105,16 +105,6 @@
},
{
"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",
@@ -124,7 +114,7 @@
}
},
{
"ordinal": 12,
"ordinal": 11,
"name": "maybe_generate_audio: bool",
"type_info": {
"type": "Tiny",
@@ -134,7 +124,7 @@
}
},
{
"ordinal": 13,
"ordinal": 12,
"name": "maybe_duration_seconds: u32",
"type_info": {
"type": "Long",
@@ -144,7 +134,7 @@
}
},
{
"ordinal": 14,
"ordinal": 13,
"name": "maybe_other_args",
"type_info": {
"type": "Blob",
@@ -154,7 +144,7 @@
}
},
{
"ordinal": 15,
"ordinal": 14,
"name": "creator_ip_address",
"type_info": {
"type": "VarString",
@@ -164,7 +154,7 @@
}
},
{
"ordinal": 16,
"ordinal": 15,
"name": "created_at",
"type_info": {
"type": "Timestamp",
@@ -192,10 +182,9 @@
true,
true,
true,
true,
false,
false
]
},
"hash": "1b69f192c3bb51bd249f97d37f209ae271d498bb46c978351b2c1d8759338bab"
"hash": "4a50b7bf06061f05e769a41e547cb927bd2b3a6f67c8ceb37883b744237ba34c"
}
@@ -48,7 +48,7 @@
"name": "maybe_entity_token",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
@@ -132,7 +132,7 @@
false,
false,
true,
true,
false,
true,
true,
true,
@@ -1,25 +0,0 @@
{
"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"
}
@@ -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_bitrate = ?,\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_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": 16
"Right": 15
},
"nullable": []
},
"hash": "7519c1dcc2946f4c31a8aad995bbb66a5519f6ee571797a6c0f597c9e57986a7"
"hash": "ccf92ebe0328d3019ac6b01be4b442df706a183362fa7e5c331f5a4bf1a74a00"
}
@@ -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 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 ",
"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 ",
"describe": {
"columns": [
{
@@ -92,16 +92,6 @@
"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": {
@@ -116,9 +106,8 @@
false,
true,
true,
true,
false
true
]
},
"hash": "1703bc63ce65d98cbcad81d515cbcf211a943c533e38e887d2c12713c78b687d"
"hash": "ddbbd3886c00268edd3993508f635549fc8ddc877bb059fd58f14b8c07586fc9"
}
Generated
-1
View File
@@ -10516,7 +10516,6 @@ dependencies = [
"log",
"mysql_queries",
"pager",
"rand 0.9.2",
"reqwest 0.12.23",
"rootly_client",
"rootly_config",
@@ -1,6 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE prompts
DROP COLUMN maybe_bitrate;
@@ -1,8 +0,0 @@
-- 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;
@@ -1,8 +0,0 @@
-- 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;
@@ -1,9 +0,0 @@
-- 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,10 +72,6 @@ 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) DEFAULT NULL,
maybe_entity_token VARCHAR(32) NOT NULL,
-- The staff user who performed the action.
staff_user_token VARCHAR(32) DEFAULT NULL,
-625
View File
@@ -1,625 +0,0 @@
# 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.
@@ -1,2 +0,0 @@
pub mod omni_api_image_generate_request;
pub mod omni_api_video_generate_request;
@@ -1,58 +0,0 @@
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>,
}
@@ -1,96 +0,0 @@
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>,
}
@@ -1,3 +0,0 @@
pub mod omni_api_batch_get_job_status;
pub mod omni_api_get_job_status;
pub mod omni_api_job_status_payload;
@@ -1,25 +0,0 @@
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>,
}
@@ -1,19 +0,0 @@
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,
}
@@ -1,69 +0,0 @@
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,5 +1,3 @@
pub mod generate_requests;
pub mod job_status;
pub mod omni_upload_audio;
pub mod omni_upload_image;
pub mod omni_upload_video;
@@ -2,7 +2,6 @@ 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;
@@ -51,10 +50,6 @@ 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>,
@@ -6,7 +6,6 @@ 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;
@@ -151,12 +150,6 @@ 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>>,
@@ -227,8 +220,6 @@ 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,7 +2,6 @@ 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;
@@ -56,9 +55,6 @@ 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,7 +3,6 @@ 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;
@@ -60,9 +59,6 @@ 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,7 +3,6 @@ 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;
@@ -1,10 +0,0 @@
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,
}
@@ -1,7 +1,6 @@
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;
@@ -104,10 +103,6 @@ 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>,
@@ -144,7 +139,6 @@ 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,
@@ -1,11 +1,9 @@
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;
@@ -51,7 +49,6 @@ 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())?;
@@ -74,7 +71,6 @@ 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,
@@ -238,19 +234,6 @@ 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>,
@@ -272,27 +255,3 @@ 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));
}
}
}
@@ -24,8 +24,6 @@ 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;
@@ -74,12 +72,6 @@ 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 ──
@@ -24,8 +24,6 @@ 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;
@@ -72,12 +70,6 @@ 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 {
@@ -24,8 +24,6 @@ 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;
@@ -74,12 +72,6 @@ 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 {
@@ -24,8 +24,6 @@ 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;
@@ -72,12 +70,6 @@ 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 {
@@ -24,8 +24,6 @@ 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;
@@ -74,12 +72,6 @@ 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 {
@@ -25,8 +25,6 @@ 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;
@@ -85,12 +83,6 @@ 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 ──
@@ -24,8 +24,6 @@ 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;
@@ -74,12 +72,6 @@ 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 ──
@@ -25,8 +25,6 @@ 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;
@@ -85,12 +83,6 @@ 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 ──
@@ -1,12 +1,10 @@
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;
@@ -28,7 +26,6 @@ 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 {
@@ -45,7 +42,6 @@ fn do_build_kinovi_seedance_2p0(mut builder: GenerateVideoRequestBuilder) -> Res
resolution,
batch_count,
duration_seconds,
bitrate,
prompt,
unhandled_request_state: Some(unhandled_request_state),
})
@@ -199,16 +195,6 @@ 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::{
@@ -405,39 +391,6 @@ 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 {
@@ -63,7 +63,6 @@ impl KinoviSeedance2p0CostState {
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
};
let costs = pricing_request.calculate_costs();
@@ -477,7 +476,6 @@ mod tests {
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
},
}
}
@@ -1,5 +1,5 @@
use seedance2pro_client::generate::video::generate_seedance_2p0::{
GenerateSeedance2p0Request, KinoviSeedance2p0AspectRatio, KinoviSeedance2p0Bitrate,
GenerateSeedance2p0Request, KinoviSeedance2p0AspectRatio,
KinoviSeedance2p0BatchCount, KinoviSeedance2p0OutputResolution,
};
@@ -26,7 +26,6 @@ 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>,
@@ -94,7 +93,6 @@ impl KinoviSeedance2p0DraftState {
reference_audio_urls,
character_ids,
use_face_blur_hack: None,
bitrate: self.bitrate,
};
Ok(KinoviSeedance2p0RequestState { request })
@@ -1,12 +1,10 @@
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;
@@ -28,7 +26,6 @@ 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 {
@@ -45,7 +42,6 @@ fn do_build_kinovi_seedance_2p0_fast(mut builder: GenerateVideoRequestBuilder) -
resolution,
batch_count,
duration_seconds,
bitrate,
prompt,
unhandled_request_state: Some(unhandled_request_state),
})
@@ -214,16 +210,6 @@ 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::{
@@ -444,39 +430,6 @@ 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 {
@@ -63,7 +63,6 @@ impl KinoviSeedance2p0FastCostState {
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
};
let costs = pricing_request.calculate_costs();
@@ -429,7 +428,6 @@ mod tests {
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
},
}
}
@@ -1,5 +1,5 @@
use seedance2pro_client::generate::video::generate_seedance_2p0_fast::{
GenerateSeedance2p0FastRequest, KinoviSeedance2p0FastAspectRatio, KinoviSeedance2p0FastBitrate,
GenerateSeedance2p0FastRequest, KinoviSeedance2p0FastAspectRatio,
KinoviSeedance2p0FastBatchCount, KinoviSeedance2p0FastOutputResolution,
};
@@ -24,7 +24,6 @@ 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>,
}
@@ -91,7 +90,6 @@ impl KinoviSeedance2p0FastDraftState {
reference_audio_urls,
character_ids,
use_face_blur_hack: None,
bitrate: self.bitrate,
};
Ok(KinoviSeedance2p0FastRequestState { request })
@@ -351,7 +351,6 @@ 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,7 +129,6 @@ 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, KinoviBitrateRaw,
workflow_run_task, KinoviAspectRatioRaw, KinoviBatchCountRaw,
KinoviModelTypeRaw, KinoviOutputResolutionRaw, WorkflowRunTaskArgs,
WorkflowRunTaskRequest,
};
@@ -32,9 +32,6 @@ 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 ──
@@ -63,11 +60,6 @@ pub enum KinoviSeedance2p0BatchCount {
Four,
}
#[derive(Debug, Clone, Copy)]
pub enum KinoviSeedance2p0Bitrate {
High,
}
// ── Pricing ──
//
// Seedance 2.0 Pro credit pricing:
@@ -186,7 +178,6 @@ 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 {
@@ -233,13 +224,6 @@ 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)]
@@ -272,7 +256,6 @@ mod tests {
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
}
}
@@ -827,7 +810,6 @@ 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,
@@ -835,21 +817,6 @@ 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};
@@ -882,7 +849,6 @@ 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);
@@ -913,7 +879,6 @@ 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);
@@ -947,7 +912,6 @@ 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);
@@ -979,7 +943,6 @@ 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);
@@ -1015,7 +978,6 @@ 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);
@@ -1048,7 +1010,6 @@ 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);
@@ -1085,7 +1046,6 @@ 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);
@@ -1122,7 +1082,6 @@ 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);
@@ -1157,7 +1116,6 @@ 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);
@@ -1188,7 +1146,6 @@ 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);
@@ -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, KinoviBitrateRaw,
workflow_run_task, KinoviAspectRatioRaw, KinoviBatchCountRaw,
KinoviModelTypeRaw, KinoviOutputResolutionRaw, WorkflowRunTaskArgs,
WorkflowRunTaskRequest,
};
@@ -32,9 +32,6 @@ 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 ──
@@ -62,11 +59,6 @@ pub enum KinoviSeedance2p0FastBatchCount {
Four,
}
#[derive(Debug, Clone, Copy)]
pub enum KinoviSeedance2p0FastBitrate {
High,
}
// ── Pricing ──
//
// Seedance 2.0 Fast credit pricing:
@@ -181,7 +173,6 @@ 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 {
@@ -227,13 +218,6 @@ 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)]
@@ -266,7 +250,6 @@ mod tests {
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
}
}
@@ -717,7 +700,6 @@ 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,
@@ -725,21 +707,6 @@ 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};
@@ -772,7 +739,6 @@ 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);
@@ -803,7 +769,6 @@ 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);
@@ -837,7 +802,6 @@ 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);
@@ -869,7 +833,6 @@ 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);
@@ -905,7 +868,6 @@ 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);
@@ -942,7 +904,6 @@ 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);
@@ -979,7 +940,6 @@ 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);
@@ -1014,7 +974,6 @@ 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);
@@ -1045,7 +1004,6 @@ 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,41 +35,6 @@ 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 {
@@ -105,18 +70,19 @@ 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 MediaResult {
pub struct VideoResult {
pub url: String,
/// 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>,
pub width: u32,
pub height: u32,
// NB: We don't need these.
// /// Width / height ratio (e.g. 1.777… for 16:9). `None` when the server returns null.
// /// Width / height ratio (e.g. 1.777… for 16:9). `None` when the server returns null (e.g. width/height are 0).
// 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`.
///
@@ -142,6 +108,41 @@ 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> {
@@ -233,10 +234,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| MediaResult {
results: o.results.into_iter().map(|r| VideoResult {
url: r.url,
maybe_width: r.maybe_width,
maybe_height: r.maybe_height,
width: r.width,
height: r.height,
}).collect(),
fail_reason,
created_at: o.created_at,
@@ -424,8 +425,8 @@ mod tests {
order_id: o.order_id,
task_status,
result_url: o.result_url,
results: o.results.into_iter().map(|r| MediaResult {
url: r.url, maybe_width: r.maybe_width, maybe_height: r.maybe_height,
results: o.results.into_iter().map(|r| VideoResult {
url: r.url, width: r.width, height: r.height,
}).collect(),
fail_reason,
created_at: o.created_at,
@@ -475,52 +476,11 @@ 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].maybe_width, Some(569));
assert_eq!(orders[0].results[0].maybe_height, Some(1023));
assert_eq!(orders[0].results[0].width, 569);
assert_eq!(orders[0].results[0].height, 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]
@@ -674,84 +634,4 @@ 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_opt_u32_int_or_float, de_opt_u64_int_or_float, de_u32_int_or_float,
};
#[derive(Deserialize, Debug)]
@@ -51,11 +51,9 @@ pub(super) struct RawOrder {
#[derive(Deserialize, Debug)]
pub(super) struct RawVideoResult {
pub url: String,
/// 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>,
#[serde(deserialize_with = "de_u32_int_or_float")]
pub width: u32,
#[serde(deserialize_with = "de_u32_int_or_float")]
pub height: u32,
// pub ratio: Option<f64>,
}
@@ -40,8 +40,6 @@ 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)]
@@ -69,10 +69,6 @@ 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 {
@@ -91,7 +87,6 @@ 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()
}
}
@@ -273,23 +268,6 @@ 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 {
@@ -383,7 +361,6 @@ 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()),
},
},
},
@@ -493,7 +470,6 @@ mod tests {
character_ids: None,
output_resolution,
use_face_blur_hack: None,
bitrate: None,
}
}
@@ -659,7 +635,6 @@ mod tests {
character_ids: None,
output_resolution: None,
use_face_blur_hack: None,
bitrate: None,
};
assert_eq!(
req.estimate_credits(), baseline,
@@ -763,49 +738,6 @@ 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::*;
@@ -835,7 +767,6 @@ mod tests {
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
},
};
@@ -869,7 +800,6 @@ mod tests {
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
},
};
@@ -905,7 +835,6 @@ mod tests {
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
},
};
@@ -940,7 +869,6 @@ mod tests {
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
},
};
@@ -977,7 +905,6 @@ mod tests {
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
},
};
@@ -1031,7 +958,6 @@ mod tests {
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
},
};
@@ -1084,7 +1010,6 @@ mod tests {
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
},
};
@@ -1137,7 +1062,6 @@ mod tests {
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
},
};
@@ -1199,7 +1123,6 @@ mod tests {
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
},
};
@@ -1254,7 +1177,6 @@ mod tests {
reference_audio_urls: Some(vec![upload_result.public_url]),
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
},
};
@@ -1294,7 +1216,6 @@ 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,
},
};
@@ -1328,7 +1249,6 @@ mod tests {
reference_audio_urls: None,
character_ids: Some(vec![MOCHI_ID.to_string()]),
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
},
};
@@ -1364,7 +1284,6 @@ 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,
},
};
@@ -1401,7 +1320,6 @@ mod tests {
MOCHI_ID.to_string(),
]),
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
},
};
@@ -1439,7 +1357,6 @@ mod tests {
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: Some(false),
bitrate: None,
output_resolution: Some(KinoviOutputResolutionRaw::TenEightyP),
},
};
@@ -1491,7 +1408,6 @@ mod tests {
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: Some(false),
bitrate: None,
output_resolution: None,
},
};
@@ -1543,7 +1459,6 @@ mod tests {
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: Some(false),
bitrate: None,
output_resolution: Some(KinoviOutputResolutionRaw::TenEightyP),
},
};
@@ -1588,7 +1503,6 @@ mod tests {
character_ids: None,
output_resolution,
use_face_blur_hack: None,
bitrate: None,
},
}
}
@@ -119,7 +119,6 @@ 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,
@@ -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.maybe_width,
"height": r.maybe_height,
"width": r.width,
"height": r.height,
})
}).collect::<Vec<_>>(),
})
@@ -427,7 +427,6 @@ async fn generate(
reference_audio_urls: media.reference_audio_urls,
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
},
session,
host_override: None,
@@ -457,7 +456,6 @@ async fn generate(
reference_audio_urls: media.reference_audio_urls,
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
},
session,
host_override: None,
@@ -146,7 +146,6 @@ mod tests {
reference_character_tokens: None,
resolution: None,
aspect_ratio: None,
bitrate: None,
duration_seconds: None,
video_batch_count: None,
generate_audio: None,
@@ -54,7 +54,6 @@ 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,10 +9,6 @@ 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")
@@ -1,4 +1,3 @@
use chrono::{DateTime, Utc};
use log::warn;
use sqlx::MySqlPool;
@@ -13,7 +12,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, Clone)]
#[derive(Debug)]
pub struct PendingSeedance2ProJob {
pub job_token: InferenceJobToken,
@@ -30,13 +29,9 @@ 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)]
#[derive(Debug, Default)]
struct RawRecord {
job_token: InferenceJobToken,
order_id: Option<String>,
@@ -47,7 +42,6 @@ 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.
@@ -68,8 +62,7 @@ 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.created_at
jobs.platform_type as `maybe_platform_type: enums::common::platform_type::PlatformType`
FROM generic_inference_jobs as jobs
@@ -107,7 +100,6 @@ 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();
@@ -47,28 +47,6 @@ 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,4 +7,3 @@ 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;
@@ -1,35 +0,0 @@
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))
}
@@ -1,27 +0,0 @@
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))
}
@@ -1,60 +0,0 @@
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,7 +1,6 @@
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;
@@ -10,5 +9,4 @@ 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_with_executor;
pub mod mark_generic_inference_job_successfully_done_by_token;
@@ -5,7 +5,6 @@ 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;
@@ -24,7 +23,6 @@ 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>,
@@ -48,8 +46,6 @@ 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")?,
@@ -77,7 +73,6 @@ 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,7 +8,6 @@ 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;
@@ -39,7 +38,6 @@ 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>,
@@ -69,7 +67,6 @@ 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>,
@@ -117,7 +114,6 @@ 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,
@@ -153,7 +149,6 @@ 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,7 +7,6 @@ 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;
@@ -37,7 +36,6 @@ 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>,
@@ -91,7 +89,6 @@ SET
maybe_generation_mode = ?,
maybe_aspect_ratio = ?,
maybe_resolution = ?,
maybe_bitrate = ?,
maybe_batch_count = ?,
maybe_generate_audio = ?,
maybe_duration_seconds = ?,
@@ -110,7 +107,6 @@ 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,
@@ -1,162 +0,0 @@
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,5 +1,4 @@
pub mod common_aspect_ratio;
pub mod common_bitrate;
pub mod common_generation_mode;
pub mod common_image_model;
pub mod common_quality;
@@ -332,7 +332,6 @@ 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(),
@@ -287,7 +287,6 @@ 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,7 +43,6 @@ 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,7 +1,6 @@
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;
@@ -32,6 +31,11 @@ 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.
@@ -57,8 +61,4 @@ 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,4 +1,3 @@
pub mod character_polling_job;
pub mod credits_checking_job;
pub mod order_polling_job;
pub mod order_processing_job;
pub mod video_polling_job;
@@ -1 +0,0 @@
pub mod order_polling_main_loop;
@@ -1,376 +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};
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,
)
}
@@ -1,8 +0,0 @@
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)
}
@@ -1,6 +0,0 @@
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;
@@ -1,96 +0,0 @@
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() => {}
}
}
@@ -1,60 +0,0 @@
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(())
}
}
}
@@ -0,0 +1,4 @@
pub mod alert_on_error;
pub mod video_polling_main_loop;
pub mod process_job;
pub mod process_orders_batch;
@@ -0,0 +1,3 @@
pub mod process_failed_job;
pub mod process_successful_image_job;
pub mod process_successful_job;
@@ -1,9 +1,8 @@
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;
@@ -11,16 +10,7 @@ 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,
@@ -32,77 +22,12 @@ pub async fn process_failed_job(
.map(|fr| fr.reason.as_str())
.unwrap_or("unknown failure reason");
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) ──
// --- Step 1: Attempt the refund before touching the job status. ---
//
// 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.
// 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 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.
@@ -112,6 +37,18 @@ 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!(
@@ -121,13 +58,23 @@ 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!(
@@ -139,7 +86,7 @@ pub async fn process_failed_job(
);
let notification = NotificationDetailsBuilder::from_boxed_error(err.into())
.set_title("Kinovi refund failed".to_string())
.set_title("Seedance2Pro 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()))
@@ -157,18 +104,36 @@ 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()
);
if let Err(err) = mark_job_failed_by_token_with_executor(
&mut *transaction,
&job.job_token,
Some(reason),
reason,
frontend_failure_category,
).await {
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 {
error!(
"Error marking job {} as failed: {:?}",
job.job_token.as_str(),
@@ -176,23 +141,12 @@ pub async fn process_failed_job(
);
let notification = NotificationDetailsBuilder::from_boxed_error(err.into())
.set_title("Kinovi mark job failed error".to_string())
.set_title("Seedance2Pro 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,
);
}
}
@@ -11,16 +11,14 @@ 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::generic_inference::web::mark_generic_inference_job_successfully_done_by_token::mark_generic_inference_job_successfully_done_by_token;
use mysql_queries::queries::media_files::create::insert_builder::media_file_insert_builder::MediaFileInsertBuilder;
use seedance2pro_client::requests::poll_orders::poll_orders::{OrderStatus, MediaResult};
use seedance2pro_client::requests::poll_orders::poll_orders::{OrderStatus, VideoResult};
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::order_processing_job::is_job_status_terminal::is_job_status_terminal;
use crate::jobs::video_polling_job::alert_on_error::alert_pager_and_return_err;
const PREFIX: &str = "artcraft_";
const SUFFIX: &str = ".png";
@@ -92,78 +90,30 @@ pub async fn process_successful_image_job(
})?;
info!(
"Created {} media file(s) for order {} (primary={}). Finalizing job {}.",
"Created {} media file(s) for order {} (primary={}). Marking job {} complete.",
created_tokens.len(),
order.order_id,
primary_token.as_str(),
job.job_token.as_str(),
);
// 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,
},
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,
).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,
"Kinovi image job completion update failed",
"Seedance2Pro 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(())
@@ -175,7 +125,7 @@ async fn download_and_store_one_image(
deps: &JobDependencies,
job: &PendingSeedance2ProJob,
order: &OrderStatus,
result: &MediaResult,
result: &VideoResult,
index: usize,
maybe_batch_token: Option<&BatchGenerationToken>,
) -> AnyhowResult<MediaFileToken> {
@@ -224,8 +174,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(result.maybe_height)
.maybe_frame_width(result.maybe_width)
.maybe_frame_height(Some(result.height))
.maybe_frame_width(Some(result.width))
.maybe_generation_provider(Some(GenerationProvider::Artcraft))
.maybe_prompt_token(job.maybe_prompt_token.as_ref())
.maybe_platform_type(job.maybe_platform_type)
@@ -1,5 +1,5 @@
use anyhow::anyhow;
use log::{error, info, warn};
use log::{error, info};
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,20 +11,18 @@ 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::alert_on_error::alert_pager_and_return_err;
use crate::jobs::video_polling_job::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_video_job(
pub async fn process_successful_job(
deps: &JobDependencies,
job: &PendingSeedance2ProJob,
order: &OrderStatus,
@@ -59,7 +57,7 @@ pub async fn process_successful_video_job(
error!("Error reading video bytes for order {}: {:?}", order.order_id, err);
return alert_pager_and_return_err(
&deps.pager,
"Kinovi video download failed",
"Seedance2Pro video download failed",
anyhow!("error reading video bytes: {:?}", err),
Some(job),
);
@@ -69,7 +67,7 @@ pub async fn process_successful_video_job(
error!("Error downloading video for order {}: {:?}", order.order_id, err);
return alert_pager_and_return_err(
&deps.pager,
"Kinovi video download failed",
"Seedance2Pro video download failed",
anyhow!("reqwest error downloading video: {:?}", err),
Some(job),
);
@@ -106,7 +104,7 @@ pub async fn process_successful_video_job(
error!("Error uploading video for order {}: {:?}", order.order_id, err);
return alert_pager_and_return_err(
&deps.pager,
"Kinovi bucket upload failed",
"Seedance2Pro bucket upload failed",
anyhow!("error uploading video to bucket: {:?}", err),
Some(job),
);
@@ -118,9 +116,8 @@ pub async fn process_successful_video_job(
);
// Optionally extract frame dimensions from the order results.
// 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);
let maybe_frame_width = order.results.first().map(|r| r.width);
let maybe_frame_height = order.results.first().map(|r| r.height);
// Insert media file record.
let media_file_result = MediaFileInsertBuilder::new()
@@ -150,7 +147,7 @@ pub async fn process_successful_video_job(
error!("Error inserting media file record for order {}: {:?}", order.order_id, err);
return alert_pager_and_return_err(
&deps.pager,
"Kinovi media file insert failed",
"Seedance2Pro media file insert failed",
anyhow!("error inserting media file record: {:?}", err),
Some(job),
);
@@ -158,89 +155,30 @@ pub async fn process_successful_video_job(
};
info!(
"Created media file {} for order {}. Finalizing job {}.",
"Created media file {} for order {}. Marking job {} complete.",
media_file_token.as_str(),
order.order_id,
job.job_token.as_str()
);
// 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,
},
// 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,
).await {
let _ = transaction.rollback().await;
error!("Error marking job {} done: {:?}", job.job_token.as_str(), err);
return alert_pager_and_return_err(
&deps.pager,
"Kinovi job completion update failed",
"Seedance2Pro 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(())
@@ -0,0 +1,127 @@
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
);
}
@@ -0,0 +1,287 @@
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,19 +30,15 @@ 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::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::jobs::video_polling_job::video_polling_main_loop::video_polling_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
@@ -116,6 +112,12 @@ 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);
@@ -158,9 +160,6 @@ 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 {
@@ -177,6 +176,7 @@ 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,7 +184,6 @@ 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.
@@ -215,18 +214,12 @@ async fn main() -> AnyhowResult<()> {
}
});
// 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();
// Spawn all polling loops as concurrent tasks.
let video_deps = job_dependencies.clone();
let credits_deps = job_dependencies.clone();
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 video_handle = tokio::spawn(async move {
video_polling_main_loop(video_deps).await;
});
let credits_handle = tokio::spawn(async move {
@@ -238,11 +231,11 @@ async fn main() -> AnyhowResult<()> {
let character_handle = tokio::spawn(async move {
character_polling_main_loop(character_deps).await;
});
let _ = tokio::join!(polling_handle, processing_handle, character_handle, credits_handle);
let _ = tokio::join!(video_handle, character_handle, credits_handle);
} else {
// skip character polling entirely.
info!("Alternate mode: character polling is disabled.");
let _ = tokio::join!(polling_handle, processing_handle, credits_handle);
let _ = tokio::join!(video_handle, credits_handle);
}
info!("Shutting down pager worker...");
@@ -1,112 +0,0 @@
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,7 +5,6 @@ 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();
@@ -247,11 +246,6 @@ 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),
@@ -293,11 +287,6 @@ 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),
@@ -340,11 +329,6 @@ 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),
@@ -386,11 +370,6 @@ 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),
@@ -433,11 +412,6 @@ 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),
@@ -479,10 +453,6 @@ 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,7 +268,6 @@ 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;
@@ -339,8 +338,6 @@ 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::*;
@@ -349,9 +346,6 @@ 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::*;
@@ -576,8 +570,6 @@ 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,
@@ -676,7 +668,6 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
CommonModelClass,
CommonModelType,
CommonAspectRatio,
CommonBitrate,
CommonGenerationMode,
CommonResolution,
PromptContextSemanticType,
@@ -831,14 +822,6 @@ 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,
@@ -51,7 +51,6 @@ 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,
@@ -172,7 +172,6 @@ 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),
@@ -173,7 +173,6 @@ 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),
@@ -220,7 +220,6 @@ 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
@@ -208,7 +208,6 @@ 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),
@@ -240,7 +240,6 @@ 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