Compare commits

..
Author SHA1 Message Date
Brandon Thomas e9883887b3 codegen 2026-06-20 09:09:27 -04:00
Brandon Thomas 50d655c77a update with filename 2026-06-20 09:08:52 -04:00
Brandon Thomas aa47795441 send filename 2026-06-20 09:02:13 -04:00
Brandon Thomas e54fe12125 migration 2026-06-20 08:53:23 -04:00
489 changed files with 2309 additions and 48879 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,
}
@@ -65,15 +65,6 @@ pub enum RouterVideoModel {
#[serde(rename = "seedance_2p0_bpu_fast")]
Seedance2p0BytePlusUltraFast,
#[serde(rename = "seedance_2p0_mini")]
Seedance2p0Mini,
#[serde(rename = "seedance_2p0_bp_mini")]
Seedance2p0BytePlusMini,
#[serde(rename = "seedance_2p0_bpu_mini")]
Seedance2p0BytePlusUltraMini,
#[serde(rename = "happy_horse_1p0")]
HappyHorse1p0,
@@ -104,23 +95,3 @@ pub enum RouterVideoModel {
#[serde(rename = "preview_model_fast")]
PreviewModelFast,
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_serde_round_trip(model: RouterVideoModel, expected: &str) {
let json = serde_json::to_string(&model).unwrap();
assert_eq!(json, format!("\"{expected}\""));
let parsed: RouterVideoModel = serde_json::from_str(&json).unwrap();
// RouterVideoModel isn't PartialEq, so round-trip back to the wire form.
assert_eq!(serde_json::to_string(&parsed).unwrap(), json);
}
#[test]
fn seedance_2p0_mini_variants_serialize() {
assert_serde_round_trip(RouterVideoModel::Seedance2p0Mini, "seedance_2p0_mini");
assert_serde_round_trip(RouterVideoModel::Seedance2p0BytePlusMini, "seedance_2p0_bp_mini");
assert_serde_round_trip(RouterVideoModel::Seedance2p0BytePlusUltraMini, "seedance_2p0_bpu_mini");
}
}
@@ -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;
@@ -32,9 +31,6 @@ use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp::build
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_fast::build::build_artcraft_seedance_2p0_bp_fast;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu::build::build_artcraft_seedance_2p0_bpu;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_fast::build::build_artcraft_seedance_2p0_bpu_fast;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_mini::build::build_artcraft_seedance_2p0_mini;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_mini::build::build_artcraft_seedance_2p0_bp_mini;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_mini::build::build_artcraft_seedance_2p0_bpu_mini;
use crate::generate::generate_video::providers::artcraft::sora_2::build::build_artcraft_sora_2;
use crate::generate::generate_video::providers::artcraft::sora_2_pro::build::build_artcraft_sora_2_pro;
use crate::generate::generate_video::providers::artcraft::veo_2::build::build_artcraft_veo_2;
@@ -65,7 +61,6 @@ use crate::generate::generate_video::providers::fal::veo_3_fast::build::build_fa
use crate::generate::generate_video::providers::fal::veo_3p1::build::build_fal_veo_3p1;
use crate::generate::generate_video::providers::fal::veo_3p1_fast::build::build_fal_veo_3p1_fast;
use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::build::build_kinovi_seedance_2p0_fast;
use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::build::build_kinovi_seedance_2p0_mini;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
/// RouterProvider-agnostic video generation request. Distilled by `build2()` into a
@@ -108,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>,
@@ -148,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,
@@ -180,9 +170,6 @@ impl GenerateVideoRequestBuilder {
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0BytePlusFast) => build_artcraft_seedance_2p0_bp_fast(self),
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0BytePlusUltra) => build_artcraft_seedance_2p0_bpu(self),
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0BytePlusUltraFast) => build_artcraft_seedance_2p0_bpu_fast(self),
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0Mini) => build_artcraft_seedance_2p0_mini(self),
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0BytePlusMini) => build_artcraft_seedance_2p0_bp_mini(self),
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0BytePlusUltraMini) => build_artcraft_seedance_2p0_bpu_mini(self),
(RouterProvider::Artcraft, RouterVideoModel::PreviewModel) => build_artcraft_preview_model(self),
(RouterProvider::Artcraft, RouterVideoModel::PreviewModelFast) => build_artcraft_preview_model_fast(self),
(RouterProvider::Artcraft, RouterVideoModel::Seedance10Lite) => build_artcraft_seedance_1p0_lite(self),
@@ -221,9 +208,6 @@ impl GenerateVideoRequestBuilder {
(RouterProvider::Seedance2Pro, RouterVideoModel::HappyHorse1p0) => build_kinovi_happy_horse_1p0(self),
(RouterProvider::Seedance2Pro, RouterVideoModel::Seedance2p0) => build_kinovi_seedance_2p0(self),
(RouterProvider::Seedance2Pro, RouterVideoModel::Seedance2p0Fast) => build_kinovi_seedance_2p0_fast(self),
(RouterProvider::Seedance2Pro, RouterVideoModel::Seedance2p0Mini) => build_kinovi_seedance_2p0_mini(self),
(RouterProvider::Seedance2Pro, RouterVideoModel::Seedance2p0BytePlusMini) => build_kinovi_seedance_2p0_mini(self),
(RouterProvider::Seedance2Pro, RouterVideoModel::Seedance2p0BytePlusUltraMini) => build_kinovi_seedance_2p0_mini(self),
_ => self.unsupported_provider_and_model(),
}
}
@@ -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;
@@ -21,8 +19,6 @@ use crate::generate::generate_video::providers::artcraft::resolve::{
pub enum SupportedResolutions {
/// 480p, 720p, 1080p
Full,
/// 480p, 720p, 1080p, 4K
FullWith4k,
/// 480p, 720p only (1080p downgrades to 720p)
Fast,
/// 720p, 1080p only (480p upgrades)
@@ -53,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())?;
@@ -76,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,
@@ -159,7 +153,7 @@ fn plan_output_resolution(
None => Ok(None),
Some(RouterResolution::FourEightyP) => match supported {
SupportedResolutions::Full | SupportedResolutions::FullWith4k | SupportedResolutions::Fast => {
SupportedResolutions::Full | SupportedResolutions::Fast => {
Ok(Some(CommonResolutionEnum::FourEightyP))
}
SupportedResolutions::NoFourEightyP => match strategy {
@@ -177,7 +171,7 @@ fn plan_output_resolution(
Some(RouterResolution::SevenTwentyP) => Ok(Some(CommonResolutionEnum::SevenTwentyP)),
Some(RouterResolution::TenEightyP) => match supported {
SupportedResolutions::Full | SupportedResolutions::FullWith4k | SupportedResolutions::NoFourEightyP => {
SupportedResolutions::Full | SupportedResolutions::NoFourEightyP => {
Ok(Some(CommonResolutionEnum::TenEightyP))
}
SupportedResolutions::Fast => match strategy {
@@ -191,20 +185,6 @@ fn plan_output_resolution(
},
},
// 4K is only available on models flagged `FullWith4k` (the non-Fast Seedance
// 2.0 family). Other models error out or fall back to their best resolution.
Some(RouterResolution::FourK) => match (supported, strategy) {
(SupportedResolutions::FullWith4k, _) => Ok(Some(CommonResolutionEnum::FourK)),
(_, RequestMismatchMitigationStrategy::ErrorOut) => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "resolution",
value: format!("{:?}", RouterResolution::FourK),
}))
}
(SupportedResolutions::Fast, _) => Ok(Some(CommonResolutionEnum::SevenTwentyP)),
(_, _) => Ok(Some(CommonResolutionEnum::TenEightyP)),
},
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
@@ -254,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>,
@@ -288,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));
}
}
}
@@ -1,5 +1,4 @@
pub(crate) mod build_common;
pub(crate) mod seedance_common;
#[cfg(test)]
mod parity_tests;
pub mod grok_imagine_video;
@@ -26,9 +25,6 @@ pub mod seedance_2p0_bp;
pub mod seedance_2p0_bp_fast;
pub mod seedance_2p0_bpu;
pub mod seedance_2p0_bpu_fast;
pub mod seedance_2p0_mini;
pub mod seedance_2p0_bp_mini;
pub mod seedance_2p0_bpu_mini;
pub mod sora_2;
pub mod sora_2_pro;
pub mod veo_2;
@@ -13,7 +13,7 @@ pub fn build_artcraft_seedance_2p0(builder: GenerateVideoRequestBuilder) -> Resu
let request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::Seedance2p0,
SupportedResolutions::FullWith4k,
SupportedResolutions::Full,
UltraWideSupport::Supported,
)?;
let state = ArtcraftSeedance2p0RequestState { request };
@@ -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 ──
@@ -105,12 +97,6 @@ mod tests {
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::TenEightyP));
}
#[test]
fn res_4k() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourK); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourK));
}
#[test]
fn none_stays_none() {
let req = unwrap_request(make_builder(|_| {}));
@@ -1,8 +1,7 @@
use enums::common::generation::common_resolution::CommonResolution;
use crate::generate::generate_video::providers::artcraft::seedance_common::seedance_2p0_four_k_usd_cents;
use crate::generate::generate_video::providers::artcraft::seedance_2p0::request::ArtcraftSeedance2p0RequestState;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::artcraft::seedance_2p0::request::ArtcraftSeedance2p0RequestState;
// ── Pricing constants ──
//
@@ -48,23 +47,6 @@ impl ArtcraftSeedance2p0CostState {
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
if self.resolution == CommonResolution::FourK {
let usd_cents = seedance_2p0_four_k_usd_cents(
self.duration_seconds,
self.batch_count,
self.has_video_reference,
);
return VideoGenerationCostEstimate {
cost_in_credits: Some(usd_cents),
cost_in_usd_cents: Some(usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
};
}
let cents_per_second = match self.resolution {
CommonResolution::FourEightyP => CENTS_PER_SECOND_480P,
CommonResolution::TenEightyP => CENTS_PER_SECOND_1080P,
@@ -89,8 +71,8 @@ impl ArtcraftSeedance2p0CostState {
#[cfg(test)]
mod tests {
use crate::api::router_provider::RouterProvider;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
// -- 720p pricing --
@@ -175,40 +157,6 @@ mod tests {
}
}
// -- 4K pricing --
mod four_k_pricing {
use enums::common::generation::common_resolution::CommonResolution;
fn artcraft_4k_cents(duration_seconds: u16, batch_count: u16, has_video_reference: bool) -> u64 {
super::super::ArtcraftSeedance2p0CostState {
resolution: CommonResolution::FourK,
duration_seconds,
batch_count,
has_video_reference,
}
.estimate_cost()
.cost_in_usd_cents
.unwrap()
}
#[test]
fn explicit_4k_without_video_reference() {
assert_eq!(artcraft_4k_cents(4, 1, false), 347);
assert_eq!(artcraft_4k_cents(5, 1, false), 433);
assert_eq!(artcraft_4k_cents(10, 1, false), 866);
assert_eq!(artcraft_4k_cents(15, 1, false), 1299);
}
#[test]
fn explicit_4k_with_video_reference() {
assert_eq!(artcraft_4k_cents(4, 1, true), 416);
assert_eq!(artcraft_4k_cents(5, 1, true), 519);
assert_eq!(artcraft_4k_cents(10, 1, true), 1038);
assert_eq!(artcraft_4k_cents(15, 1, true), 1557);
}
}
// -- Relative pricing --
mod relative_pricing_tests {
@@ -223,13 +171,6 @@ mod tests {
assert!(c720 < c1080);
}
#[test]
fn cost_4k_more_expensive_than_1080p() {
let c1080 = cost_cents(Some(RouterResolution::TenEightyP), 5, 1);
let c4k = cost_cents(Some(RouterResolution::FourK), 5, 1);
assert!(c4k > c1080);
}
#[test]
fn cost_scales_with_duration() {
let c4 = cost_cents(Some(RouterResolution::SevenTwentyP), 4, 1);
@@ -260,7 +201,6 @@ mod tests {
Some(RouterResolution::FourEightyP),
Some(RouterResolution::SevenTwentyP),
Some(RouterResolution::TenEightyP),
Some(RouterResolution::FourK),
None,
];
for res in resolutions {
@@ -13,7 +13,7 @@ pub fn build_artcraft_seedance_2p0_bp(builder: GenerateVideoRequestBuilder) -> R
let request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::Seedance2p0BytePlus,
SupportedResolutions::FullWith4k,
SupportedResolutions::Full,
UltraWideSupport::Supported,
)?;
let state = ArtcraftSeedance2p0BytePlusRequestState { request };
@@ -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 {
@@ -101,12 +93,6 @@ mod tests {
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::TenEightyP));
}
#[test]
fn res_4k() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourK); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourK));
}
#[test]
fn none_stays_none() {
let req = unwrap_request(make_builder(|_| {}));
@@ -1,8 +1,7 @@
use enums::common::generation::common_resolution::CommonResolution;
use crate::generate::generate_video::providers::artcraft::seedance_common::seedance_2p0_four_k_usd_cents;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp::request::ArtcraftSeedance2p0BytePlusRequestState;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp::request::ArtcraftSeedance2p0BytePlusRequestState;
/// USD cents per second by resolution:
/// 480p: $0.10/s = 10.0 ¢/s
@@ -16,7 +15,6 @@ pub struct ArtcraftSeedance2p0BytePlusCostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
pub batch_count: u16,
pub has_video_reference: bool,
}
impl ArtcraftSeedance2p0BytePlusCostState {
@@ -25,30 +23,10 @@ impl ArtcraftSeedance2p0BytePlusCostState {
.unwrap_or(CommonResolution::SevenTwentyP);
let duration_seconds = request.request.duration_seconds.unwrap_or(5);
let batch_count = request.request.video_batch_count.unwrap_or(1);
let has_video_reference = request.request.reference_video_media_tokens
.as_ref()
.is_some_and(|tokens| !tokens.is_empty());
Self { resolution, duration_seconds, batch_count, has_video_reference }
Self { resolution, duration_seconds, batch_count }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
if self.resolution == CommonResolution::FourK {
let usd_cents = seedance_2p0_four_k_usd_cents(
self.duration_seconds,
self.batch_count,
self.has_video_reference,
);
return VideoGenerationCostEstimate {
cost_in_credits: Some(usd_cents),
cost_in_usd_cents: Some(usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
};
}
let cents_per_second = match self.resolution {
CommonResolution::FourEightyP => CENTS_PER_SECOND_480P,
CommonResolution::TenEightyP => CENTS_PER_SECOND_1080P,
@@ -71,9 +49,9 @@ impl ArtcraftSeedance2p0BytePlusCostState {
#[cfg(test)]
mod tests {
use crate::api::router_provider::RouterProvider;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
mod pricing_720p {
@@ -147,38 +125,6 @@ mod tests {
}
}
mod four_k_pricing {
use enums::common::generation::common_resolution::CommonResolution;
fn artcraft_4k_cents(duration_seconds: u16, batch_count: u16, has_video_reference: bool) -> u64 {
super::super::ArtcraftSeedance2p0BytePlusCostState {
resolution: CommonResolution::FourK,
duration_seconds,
batch_count,
has_video_reference,
}
.estimate_cost()
.cost_in_usd_cents
.unwrap()
}
#[test]
fn explicit_4k_without_video_reference() {
assert_eq!(artcraft_4k_cents(4, 1, false), 347);
assert_eq!(artcraft_4k_cents(5, 1, false), 433);
assert_eq!(artcraft_4k_cents(10, 1, false), 866);
assert_eq!(artcraft_4k_cents(15, 1, false), 1299);
}
#[test]
fn explicit_4k_with_video_reference() {
assert_eq!(artcraft_4k_cents(4, 1, true), 416);
assert_eq!(artcraft_4k_cents(5, 1, true), 519);
assert_eq!(artcraft_4k_cents(10, 1, true), 1038);
assert_eq!(artcraft_4k_cents(15, 1, true), 1557);
}
}
mod relative_pricing_tests {
use super::*;
@@ -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 {
@@ -1,225 +0,0 @@
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::artcraft::build_common::{
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
};
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_mini::request::ArtcraftSeedance2p0BytePlusMiniRequestState;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_artcraft_seedance_2p0_bp_mini(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
// Seedance 2.0 Mini supports 480p and 720p only (higher resolutions downgrade
// to 720p), and all six aspect ratios including 21:9.
let request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::Seedance2p0BytePlusMini,
SupportedResolutions::Fast,
UltraWideSupport::Supported,
)?;
let state = ArtcraftSeedance2p0BytePlusMiniRequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0BytePlusMini(state)))
}
#[cfg(test)]
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 tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
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;
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
use super::*;
mod field_conversions {
use super::*;
#[test]
fn prompt_is_passed_through() {
let req = unwrap_request(builder_with(|b| { b.prompt = Some("test".to_string()); }));
assert_eq!(req.request.prompt, Some("test".to_string()));
}
#[test]
fn model_is_correct() {
let req = unwrap_request(builder_with(|_| {}));
assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p0BytePlusMini)));
}
#[test]
fn duration_passed_through() {
let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(10); }));
assert_eq!(req.request.duration_seconds, Some(10));
}
#[test]
fn duration_clamped_to_max() {
let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(99); }));
assert_eq!(req.request.duration_seconds, Some(15));
}
#[test]
fn batch_count_passed_through() {
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(4); }));
assert_eq!(req.request.video_batch_count, Some(4));
}
#[test]
fn batch_count_clamped_to_platform_max() {
// The ArtCraft platform caps batch at 4 and downgrades higher requests.
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(8); }));
assert_eq!(req.request.video_batch_count, Some(4));
}
#[test]
fn bitrate_passed_through() {
let req = unwrap_request(builder_with(|b| { b.bitrate = Some(RouterBitrate::High); }));
assert_eq!(req.request.bitrate, Some(CommonBitrateEnum::High));
}
}
mod aspect_ratio_tests {
use super::*;
#[test]
fn wide() {
let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideSixteenByNine); }));
assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::WideSixteenByNine));
}
#[test]
fn ultra_wide() {
let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideTwentyOneByNine); }));
assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::WideTwentyOneByNine));
}
#[test]
fn tall() {
let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::TallNineBySixteen); }));
assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::TallNineBySixteen));
}
#[test]
fn square() {
let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::Square); }));
assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::Square));
}
}
mod resolution_tests {
use super::*;
#[test]
fn res_480p() {
let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::FourEightyP); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP));
}
#[test]
fn res_720p() {
let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::SevenTwentyP); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
}
#[test]
fn res_1080p_downgrades_to_720p() {
let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::TenEightyP); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
}
#[test]
fn res_1080p_error_out() {
let result = build_artcraft_seedance_2p0_bp_mini(GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0BytePlusMini,
provider: RouterProvider::Artcraft,
resolution: Some(RouterResolution::TenEightyP),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..Default::default()
});
assert!(result.is_err());
}
#[test]
fn none_stays_none() {
let req = unwrap_request(builder_with(|_| {}));
assert!(req.request.resolution.is_none());
}
}
mod media_token_tests {
use super::*;
#[test]
fn start_frame_token_passed_through() {
let token = MediaFileToken::new("mf_start".to_string());
let req = unwrap_request(builder_with(|b| { b.start_frame = Some(ImageRef::MediaFileToken(token.clone())); }));
assert_eq!(req.request.start_frame_image_media_token, Some(token));
}
#[test]
fn url_start_frame_rejected() {
let result = build_artcraft_seedance_2p0_bp_mini(GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0BytePlusMini,
provider: RouterProvider::Artcraft,
start_frame: Some(ImageRef::Url("https://example.com".to_string())),
..Default::default()
});
assert!(result.is_err());
}
#[test]
fn reference_image_tokens_passed_through() {
let tokens = vec![
MediaFileToken::new("mf_a".to_string()),
MediaFileToken::new("mf_b".to_string()),
];
let req = unwrap_request(builder_with(|b| { b.reference_images = Some(ImageListRef::MediaFileTokens(tokens.clone())); }));
assert_eq!(req.request.reference_image_media_tokens, Some(tokens));
}
#[test]
fn character_tokens_passed_through() {
let tokens = vec![
CharacterToken::new("char_a".to_string()),
CharacterToken::new("char_b".to_string()),
];
let req = unwrap_request(builder_with(|b| { b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone())); }));
assert_eq!(req.request.reference_character_tokens, Some(tokens));
}
}
fn builder_with(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
let mut builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0BytePlusMini,
provider: RouterProvider::Artcraft,
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
};
f(&mut builder);
builder
}
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p0BytePlusMiniRequestState {
let result = build_artcraft_seedance_2p0_bp_mini(builder).expect("build should succeed");
match result {
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0BytePlusMini(state)) => state,
_ => panic!("expected ArtcraftSeedance2p0BytePlusMini request"),
}
}
}
@@ -1,176 +0,0 @@
use enums::common::generation::common_resolution::CommonResolution;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::artcraft::seedance_common::seedance_2p0_byteplus_mini_usd_cents;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_mini::request::ArtcraftSeedance2p0BytePlusMiniRequestState;
pub struct ArtcraftSeedance2p0BytePlusMiniCostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
pub batch_count: u16,
pub has_video_reference: bool,
}
impl ArtcraftSeedance2p0BytePlusMiniCostState {
pub fn from_request(request: &ArtcraftSeedance2p0BytePlusMiniRequestState) -> Self {
let resolution = request.request.resolution
.unwrap_or(CommonResolution::SevenTwentyP);
let duration_seconds = request.request.duration_seconds.unwrap_or(5);
let batch_count = request.request.video_batch_count.unwrap_or(1);
let has_video_reference = request.request.reference_video_media_tokens
.as_ref()
.is_some_and(|tokens| !tokens.is_empty());
Self { resolution, duration_seconds, batch_count, has_video_reference }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// BytePlus / BytePlus Ultra Mini share a higher-margin rate set; see
// seedance_common::seedance_2p0_byteplus_mini_usd_cents.
let usd_cents = seedance_2p0_byteplus_mini_usd_cents(
self.resolution,
self.duration_seconds,
self.batch_count,
self.has_video_reference,
);
// ArtCraft credits: 100 credits = $1.00, so credits = cents.
VideoGenerationCostEstimate {
cost_in_credits: Some(usd_cents),
cost_in_usd_cents: Some(usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
#[cfg(test)]
mod tests {
use tokens::tokens::media_files::MediaFileToken;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::router_provider::RouterProvider;
use crate::api::video_list_ref::VideoListRef;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
// Every combination, driven through the full build pipeline. These prices are
// the supplier's per-combo cost plus a 5% margin, rounded up to whole cents.
mod pricing_480p {
use super::*;
#[test]
fn without_video_reference() {
assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, false), 14);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 17);
assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, false), 33);
assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, false), 50);
}
#[test]
fn with_video_reference() {
assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, true), 17);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, true), 21);
assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, true), 42);
assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, true), 63);
}
}
mod pricing_720p {
use super::*;
#[test]
fn without_video_reference() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, false), 35);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 44);
assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, false), 88);
assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, false), 131);
}
#[test]
fn with_video_reference() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, true), 42);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, true), 53);
assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, true), 105);
assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, true), 158);
}
#[test]
fn none_defaults_to_720p() {
assert_eq!(cents_no_ref(None, 5, 1), cents(RouterResolution::SevenTwentyP, 5, 1, false));
}
}
mod credits_tests {
use super::*;
#[test]
fn credits_equal_usd_cents_all_combos() {
let resolutions = [
Some(RouterResolution::FourEightyP),
Some(RouterResolution::SevenTwentyP),
None,
];
for res in resolutions {
for dur in [4u16, 5, 10, 15] {
for batch in [1u16, 2, 4] {
for has_ref in [false, true] {
let cost = build_cost(res, dur, batch, has_ref);
assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents);
}
}
}
}
}
}
fn build_cost(
resolution: Option<RouterResolution>,
duration_seconds: u16,
video_batch_count: u16,
with_video_reference: bool,
) -> VideoGenerationCostEstimate {
let reference_videos = with_video_reference.then(|| {
VideoListRef::MediaFileTokens(vec![MediaFileToken::new("mf_ref".to_string())])
});
let builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0BytePlusMini,
provider: RouterProvider::Artcraft,
resolution,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
reference_videos,
..Default::default()
};
builder.build2()
.expect("build2 should succeed")
.estimate_cost()
.expect("estimate_cost should succeed")
}
fn cents(
resolution: RouterResolution,
duration_seconds: u16,
video_batch_count: u16,
with_video_reference: bool,
) -> u64 {
build_cost(Some(resolution), duration_seconds, video_batch_count, with_video_reference)
.cost_in_usd_cents
.unwrap()
}
fn cents_no_ref(
resolution: Option<RouterResolution>,
duration_seconds: u16,
video_batch_count: u16,
) -> u64 {
build_cost(resolution, duration_seconds, video_batch_count, false)
.cost_in_usd_cents
.unwrap()
}
}
@@ -1,3 +0,0 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -1,86 +0,0 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request;
#[derive(Clone, Debug)]
pub struct ArtcraftSeedance2p0BytePlusMiniRequestState {
pub request: OmniGenVideoCostAndGenerateRequest,
}
impl ArtcraftSeedance2p0BytePlusMiniRequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
send_artcraft_omni_video_request(&self.request, client).await
}
}
#[cfg(test)]
mod tests {
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::router_provider::RouterProvider;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_client::RouterClient;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::utils::api_host::ApiHost;
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn text_to_video() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()),
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
..mini_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn res_480p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A shiba inu playing in autumn leaves.".to_string()),
resolution: Some(RouterResolution::FourEightyP),
..mini_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
fn mini_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0BytePlusMini,
provider: RouterProvider::Artcraft,
duration_seconds: Some(4),
video_batch_count: Some(1),
..Default::default()
}
}
fn get_artcraft_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt")
.expect("Failed to read artcraft cookies");
let cookies = cookies.trim().to_string();
let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies)
.expect("Failed to parse cookies")
.expect("No credentials found");
RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials))
}
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
let client = get_artcraft_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
VideoGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant (Artcraft skips draft)"),
};
request.send_request(&client).await.expect("send_request should succeed")
}
}
@@ -13,7 +13,7 @@ pub fn build_artcraft_seedance_2p0_bpu(builder: GenerateVideoRequestBuilder) ->
let request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::Seedance2p0BytePlusUltra,
SupportedResolutions::FullWith4k,
SupportedResolutions::Full,
UltraWideSupport::Supported,
)?;
let state = ArtcraftSeedance2p0BytePlusUltraRequestState { request };
@@ -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 {
@@ -101,12 +93,6 @@ mod tests {
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::TenEightyP));
}
#[test]
fn res_4k() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourK); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourK));
}
#[test]
fn none_stays_none() {
let req = unwrap_request(make_builder(|_| {}));
@@ -1,8 +1,7 @@
use enums::common::generation::common_resolution::CommonResolution;
use crate::generate::generate_video::providers::artcraft::seedance_common::seedance_2p0_four_k_usd_cents;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu::request::ArtcraftSeedance2p0BytePlusUltraRequestState;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu::request::ArtcraftSeedance2p0BytePlusUltraRequestState;
/// USD cents per second by resolution:
/// 480p: $0.10/s = 10.0 ¢/s
@@ -16,7 +15,6 @@ pub struct ArtcraftSeedance2p0BytePlusUltraCostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
pub batch_count: u16,
pub has_video_reference: bool,
}
impl ArtcraftSeedance2p0BytePlusUltraCostState {
@@ -25,30 +23,10 @@ impl ArtcraftSeedance2p0BytePlusUltraCostState {
.unwrap_or(CommonResolution::SevenTwentyP);
let duration_seconds = request.request.duration_seconds.unwrap_or(5);
let batch_count = request.request.video_batch_count.unwrap_or(1);
let has_video_reference = request.request.reference_video_media_tokens
.as_ref()
.is_some_and(|tokens| !tokens.is_empty());
Self { resolution, duration_seconds, batch_count, has_video_reference }
Self { resolution, duration_seconds, batch_count }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
if self.resolution == CommonResolution::FourK {
let usd_cents = seedance_2p0_four_k_usd_cents(
self.duration_seconds,
self.batch_count,
self.has_video_reference,
);
return VideoGenerationCostEstimate {
cost_in_credits: Some(usd_cents),
cost_in_usd_cents: Some(usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
};
}
let cents_per_second = match self.resolution {
CommonResolution::FourEightyP => CENTS_PER_SECOND_480P,
CommonResolution::TenEightyP => CENTS_PER_SECOND_1080P,
@@ -71,9 +49,9 @@ impl ArtcraftSeedance2p0BytePlusUltraCostState {
#[cfg(test)]
mod tests {
use crate::api::router_provider::RouterProvider;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
mod pricing_720p {
@@ -147,38 +125,6 @@ mod tests {
}
}
mod four_k_pricing {
use enums::common::generation::common_resolution::CommonResolution;
fn artcraft_4k_cents(duration_seconds: u16, batch_count: u16, has_video_reference: bool) -> u64 {
super::super::ArtcraftSeedance2p0BytePlusUltraCostState {
resolution: CommonResolution::FourK,
duration_seconds,
batch_count,
has_video_reference,
}
.estimate_cost()
.cost_in_usd_cents
.unwrap()
}
#[test]
fn explicit_4k_without_video_reference() {
assert_eq!(artcraft_4k_cents(4, 1, false), 347);
assert_eq!(artcraft_4k_cents(5, 1, false), 433);
assert_eq!(artcraft_4k_cents(10, 1, false), 866);
assert_eq!(artcraft_4k_cents(15, 1, false), 1299);
}
#[test]
fn explicit_4k_with_video_reference() {
assert_eq!(artcraft_4k_cents(4, 1, true), 416);
assert_eq!(artcraft_4k_cents(5, 1, true), 519);
assert_eq!(artcraft_4k_cents(10, 1, true), 1038);
assert_eq!(artcraft_4k_cents(15, 1, true), 1557);
}
}
mod relative_pricing_tests {
use super::*;
@@ -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 {
@@ -1,225 +0,0 @@
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::artcraft::build_common::{
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
};
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_mini::request::ArtcraftSeedance2p0BytePlusUltraMiniRequestState;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_artcraft_seedance_2p0_bpu_mini(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
// Seedance 2.0 Mini supports 480p and 720p only (higher resolutions downgrade
// to 720p), and all six aspect ratios including 21:9.
let request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::Seedance2p0BytePlusUltraMini,
SupportedResolutions::Fast,
UltraWideSupport::Supported,
)?;
let state = ArtcraftSeedance2p0BytePlusUltraMiniRequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltraMini(state)))
}
#[cfg(test)]
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 tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
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;
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
use super::*;
mod field_conversions {
use super::*;
#[test]
fn prompt_is_passed_through() {
let req = unwrap_request(builder_with(|b| { b.prompt = Some("test".to_string()); }));
assert_eq!(req.request.prompt, Some("test".to_string()));
}
#[test]
fn model_is_correct() {
let req = unwrap_request(builder_with(|_| {}));
assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p0BytePlusUltraMini)));
}
#[test]
fn duration_passed_through() {
let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(10); }));
assert_eq!(req.request.duration_seconds, Some(10));
}
#[test]
fn duration_clamped_to_max() {
let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(99); }));
assert_eq!(req.request.duration_seconds, Some(15));
}
#[test]
fn batch_count_passed_through() {
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(4); }));
assert_eq!(req.request.video_batch_count, Some(4));
}
#[test]
fn batch_count_clamped_to_platform_max() {
// The ArtCraft platform caps batch at 4 and downgrades higher requests.
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(8); }));
assert_eq!(req.request.video_batch_count, Some(4));
}
#[test]
fn bitrate_passed_through() {
let req = unwrap_request(builder_with(|b| { b.bitrate = Some(RouterBitrate::High); }));
assert_eq!(req.request.bitrate, Some(CommonBitrateEnum::High));
}
}
mod aspect_ratio_tests {
use super::*;
#[test]
fn wide() {
let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideSixteenByNine); }));
assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::WideSixteenByNine));
}
#[test]
fn ultra_wide() {
let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideTwentyOneByNine); }));
assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::WideTwentyOneByNine));
}
#[test]
fn tall() {
let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::TallNineBySixteen); }));
assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::TallNineBySixteen));
}
#[test]
fn square() {
let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::Square); }));
assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::Square));
}
}
mod resolution_tests {
use super::*;
#[test]
fn res_480p() {
let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::FourEightyP); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP));
}
#[test]
fn res_720p() {
let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::SevenTwentyP); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
}
#[test]
fn res_1080p_downgrades_to_720p() {
let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::TenEightyP); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
}
#[test]
fn res_1080p_error_out() {
let result = build_artcraft_seedance_2p0_bpu_mini(GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0BytePlusUltraMini,
provider: RouterProvider::Artcraft,
resolution: Some(RouterResolution::TenEightyP),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..Default::default()
});
assert!(result.is_err());
}
#[test]
fn none_stays_none() {
let req = unwrap_request(builder_with(|_| {}));
assert!(req.request.resolution.is_none());
}
}
mod media_token_tests {
use super::*;
#[test]
fn start_frame_token_passed_through() {
let token = MediaFileToken::new("mf_start".to_string());
let req = unwrap_request(builder_with(|b| { b.start_frame = Some(ImageRef::MediaFileToken(token.clone())); }));
assert_eq!(req.request.start_frame_image_media_token, Some(token));
}
#[test]
fn url_start_frame_rejected() {
let result = build_artcraft_seedance_2p0_bpu_mini(GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0BytePlusUltraMini,
provider: RouterProvider::Artcraft,
start_frame: Some(ImageRef::Url("https://example.com".to_string())),
..Default::default()
});
assert!(result.is_err());
}
#[test]
fn reference_image_tokens_passed_through() {
let tokens = vec![
MediaFileToken::new("mf_a".to_string()),
MediaFileToken::new("mf_b".to_string()),
];
let req = unwrap_request(builder_with(|b| { b.reference_images = Some(ImageListRef::MediaFileTokens(tokens.clone())); }));
assert_eq!(req.request.reference_image_media_tokens, Some(tokens));
}
#[test]
fn character_tokens_passed_through() {
let tokens = vec![
CharacterToken::new("char_a".to_string()),
CharacterToken::new("char_b".to_string()),
];
let req = unwrap_request(builder_with(|b| { b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone())); }));
assert_eq!(req.request.reference_character_tokens, Some(tokens));
}
}
fn builder_with(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
let mut builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0BytePlusUltraMini,
provider: RouterProvider::Artcraft,
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
};
f(&mut builder);
builder
}
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p0BytePlusUltraMiniRequestState {
let result = build_artcraft_seedance_2p0_bpu_mini(builder).expect("build should succeed");
match result {
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltraMini(state)) => state,
_ => panic!("expected ArtcraftSeedance2p0BytePlusUltraMini request"),
}
}
}
@@ -1,176 +0,0 @@
use enums::common::generation::common_resolution::CommonResolution;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::artcraft::seedance_common::seedance_2p0_byteplus_mini_usd_cents;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_mini::request::ArtcraftSeedance2p0BytePlusUltraMiniRequestState;
pub struct ArtcraftSeedance2p0BytePlusUltraMiniCostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
pub batch_count: u16,
pub has_video_reference: bool,
}
impl ArtcraftSeedance2p0BytePlusUltraMiniCostState {
pub fn from_request(request: &ArtcraftSeedance2p0BytePlusUltraMiniRequestState) -> Self {
let resolution = request.request.resolution
.unwrap_or(CommonResolution::SevenTwentyP);
let duration_seconds = request.request.duration_seconds.unwrap_or(5);
let batch_count = request.request.video_batch_count.unwrap_or(1);
let has_video_reference = request.request.reference_video_media_tokens
.as_ref()
.is_some_and(|tokens| !tokens.is_empty());
Self { resolution, duration_seconds, batch_count, has_video_reference }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// BytePlus / BytePlus Ultra Mini share a higher-margin rate set; see
// seedance_common::seedance_2p0_byteplus_mini_usd_cents.
let usd_cents = seedance_2p0_byteplus_mini_usd_cents(
self.resolution,
self.duration_seconds,
self.batch_count,
self.has_video_reference,
);
// ArtCraft credits: 100 credits = $1.00, so credits = cents.
VideoGenerationCostEstimate {
cost_in_credits: Some(usd_cents),
cost_in_usd_cents: Some(usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
#[cfg(test)]
mod tests {
use tokens::tokens::media_files::MediaFileToken;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::router_provider::RouterProvider;
use crate::api::video_list_ref::VideoListRef;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
// Every combination, driven through the full build pipeline. These prices are
// the supplier's per-combo cost plus a 5% margin, rounded up to whole cents.
mod pricing_480p {
use super::*;
#[test]
fn without_video_reference() {
assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, false), 14);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 17);
assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, false), 33);
assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, false), 50);
}
#[test]
fn with_video_reference() {
assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, true), 17);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, true), 21);
assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, true), 42);
assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, true), 63);
}
}
mod pricing_720p {
use super::*;
#[test]
fn without_video_reference() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, false), 35);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 44);
assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, false), 88);
assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, false), 131);
}
#[test]
fn with_video_reference() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, true), 42);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, true), 53);
assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, true), 105);
assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, true), 158);
}
#[test]
fn none_defaults_to_720p() {
assert_eq!(cents_no_ref(None, 5, 1), cents(RouterResolution::SevenTwentyP, 5, 1, false));
}
}
mod credits_tests {
use super::*;
#[test]
fn credits_equal_usd_cents_all_combos() {
let resolutions = [
Some(RouterResolution::FourEightyP),
Some(RouterResolution::SevenTwentyP),
None,
];
for res in resolutions {
for dur in [4u16, 5, 10, 15] {
for batch in [1u16, 2, 4] {
for has_ref in [false, true] {
let cost = build_cost(res, dur, batch, has_ref);
assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents);
}
}
}
}
}
}
fn build_cost(
resolution: Option<RouterResolution>,
duration_seconds: u16,
video_batch_count: u16,
with_video_reference: bool,
) -> VideoGenerationCostEstimate {
let reference_videos = with_video_reference.then(|| {
VideoListRef::MediaFileTokens(vec![MediaFileToken::new("mf_ref".to_string())])
});
let builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0BytePlusUltraMini,
provider: RouterProvider::Artcraft,
resolution,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
reference_videos,
..Default::default()
};
builder.build2()
.expect("build2 should succeed")
.estimate_cost()
.expect("estimate_cost should succeed")
}
fn cents(
resolution: RouterResolution,
duration_seconds: u16,
video_batch_count: u16,
with_video_reference: bool,
) -> u64 {
build_cost(Some(resolution), duration_seconds, video_batch_count, with_video_reference)
.cost_in_usd_cents
.unwrap()
}
fn cents_no_ref(
resolution: Option<RouterResolution>,
duration_seconds: u16,
video_batch_count: u16,
) -> u64 {
build_cost(resolution, duration_seconds, video_batch_count, false)
.cost_in_usd_cents
.unwrap()
}
}
@@ -1,3 +0,0 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -1,86 +0,0 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request;
#[derive(Clone, Debug)]
pub struct ArtcraftSeedance2p0BytePlusUltraMiniRequestState {
pub request: OmniGenVideoCostAndGenerateRequest,
}
impl ArtcraftSeedance2p0BytePlusUltraMiniRequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
send_artcraft_omni_video_request(&self.request, client).await
}
}
#[cfg(test)]
mod tests {
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::router_provider::RouterProvider;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_client::RouterClient;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::utils::api_host::ApiHost;
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn text_to_video() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()),
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
..mini_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn res_480p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A shiba inu playing in autumn leaves.".to_string()),
resolution: Some(RouterResolution::FourEightyP),
..mini_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
fn mini_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0BytePlusUltraMini,
provider: RouterProvider::Artcraft,
duration_seconds: Some(4),
video_batch_count: Some(1),
..Default::default()
}
}
fn get_artcraft_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt")
.expect("Failed to read artcraft cookies");
let cookies = cookies.trim().to_string();
let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies)
.expect("Failed to parse cookies")
.expect("No credentials found");
RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials))
}
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
let client = get_artcraft_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
VideoGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant (Artcraft skips draft)"),
};
request.send_request(&client).await.expect("send_request should succeed")
}
}
@@ -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,225 +0,0 @@
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::artcraft::build_common::{
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
};
use crate::generate::generate_video::providers::artcraft::seedance_2p0_mini::request::ArtcraftSeedance2p0MiniRequestState;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_artcraft_seedance_2p0_mini(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
// Seedance 2.0 Mini supports 480p and 720p only (higher resolutions downgrade
// to 720p), and all six aspect ratios including 21:9.
let request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::Seedance2p0Mini,
SupportedResolutions::Fast,
UltraWideSupport::Supported,
)?;
let state = ArtcraftSeedance2p0MiniRequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0Mini(state)))
}
#[cfg(test)]
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 tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
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;
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
use super::*;
mod field_conversions {
use super::*;
#[test]
fn prompt_is_passed_through() {
let req = unwrap_request(builder_with(|b| { b.prompt = Some("test".to_string()); }));
assert_eq!(req.request.prompt, Some("test".to_string()));
}
#[test]
fn model_is_correct() {
let req = unwrap_request(builder_with(|_| {}));
assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p0Mini)));
}
#[test]
fn duration_passed_through() {
let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(10); }));
assert_eq!(req.request.duration_seconds, Some(10));
}
#[test]
fn duration_clamped_to_max() {
let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(99); }));
assert_eq!(req.request.duration_seconds, Some(15));
}
#[test]
fn batch_count_passed_through() {
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(4); }));
assert_eq!(req.request.video_batch_count, Some(4));
}
#[test]
fn batch_count_clamped_to_platform_max() {
// The ArtCraft platform caps batch at 4 and downgrades higher requests.
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(8); }));
assert_eq!(req.request.video_batch_count, Some(4));
}
#[test]
fn bitrate_passed_through() {
let req = unwrap_request(builder_with(|b| { b.bitrate = Some(RouterBitrate::High); }));
assert_eq!(req.request.bitrate, Some(CommonBitrateEnum::High));
}
}
mod aspect_ratio_tests {
use super::*;
#[test]
fn wide() {
let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideSixteenByNine); }));
assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::WideSixteenByNine));
}
#[test]
fn ultra_wide() {
let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideTwentyOneByNine); }));
assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::WideTwentyOneByNine));
}
#[test]
fn tall() {
let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::TallNineBySixteen); }));
assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::TallNineBySixteen));
}
#[test]
fn square() {
let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::Square); }));
assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::Square));
}
}
mod resolution_tests {
use super::*;
#[test]
fn res_480p() {
let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::FourEightyP); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP));
}
#[test]
fn res_720p() {
let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::SevenTwentyP); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
}
#[test]
fn res_1080p_downgrades_to_720p() {
let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::TenEightyP); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
}
#[test]
fn res_1080p_error_out() {
let result = build_artcraft_seedance_2p0_mini(GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0Mini,
provider: RouterProvider::Artcraft,
resolution: Some(RouterResolution::TenEightyP),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..Default::default()
});
assert!(result.is_err());
}
#[test]
fn none_stays_none() {
let req = unwrap_request(builder_with(|_| {}));
assert!(req.request.resolution.is_none());
}
}
mod media_token_tests {
use super::*;
#[test]
fn start_frame_token_passed_through() {
let token = MediaFileToken::new("mf_start".to_string());
let req = unwrap_request(builder_with(|b| { b.start_frame = Some(ImageRef::MediaFileToken(token.clone())); }));
assert_eq!(req.request.start_frame_image_media_token, Some(token));
}
#[test]
fn url_start_frame_rejected() {
let result = build_artcraft_seedance_2p0_mini(GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0Mini,
provider: RouterProvider::Artcraft,
start_frame: Some(ImageRef::Url("https://example.com".to_string())),
..Default::default()
});
assert!(result.is_err());
}
#[test]
fn reference_image_tokens_passed_through() {
let tokens = vec![
MediaFileToken::new("mf_a".to_string()),
MediaFileToken::new("mf_b".to_string()),
];
let req = unwrap_request(builder_with(|b| { b.reference_images = Some(ImageListRef::MediaFileTokens(tokens.clone())); }));
assert_eq!(req.request.reference_image_media_tokens, Some(tokens));
}
#[test]
fn character_tokens_passed_through() {
let tokens = vec![
CharacterToken::new("char_a".to_string()),
CharacterToken::new("char_b".to_string()),
];
let req = unwrap_request(builder_with(|b| { b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone())); }));
assert_eq!(req.request.reference_character_tokens, Some(tokens));
}
}
fn builder_with(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
let mut builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0Mini,
provider: RouterProvider::Artcraft,
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
};
f(&mut builder);
builder
}
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p0MiniRequestState {
let result = build_artcraft_seedance_2p0_mini(builder).expect("build should succeed");
match result {
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0Mini(state)) => state,
_ => panic!("expected ArtcraftSeedance2p0Mini request"),
}
}
}
@@ -1,176 +0,0 @@
use enums::common::generation::common_resolution::CommonResolution;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::artcraft::seedance_common::seedance_2p0_mini_usd_cents;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_mini::request::ArtcraftSeedance2p0MiniRequestState;
pub struct ArtcraftSeedance2p0MiniCostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
pub batch_count: u16,
pub has_video_reference: bool,
}
impl ArtcraftSeedance2p0MiniCostState {
pub fn from_request(request: &ArtcraftSeedance2p0MiniRequestState) -> Self {
let resolution = request.request.resolution
.unwrap_or(CommonResolution::SevenTwentyP);
let duration_seconds = request.request.duration_seconds.unwrap_or(5);
let batch_count = request.request.video_batch_count.unwrap_or(1);
let has_video_reference = request.request.reference_video_media_tokens
.as_ref()
.is_some_and(|tokens| !tokens.is_empty());
Self { resolution, duration_seconds, batch_count, has_video_reference }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Seedance 2.0 Mini is priced uniformly across its variants; see
// seedance_common::seedance_2p0_mini_usd_cents.
let usd_cents = seedance_2p0_mini_usd_cents(
self.resolution,
self.duration_seconds,
self.batch_count,
self.has_video_reference,
);
// ArtCraft credits: 100 credits = $1.00, so credits = cents.
VideoGenerationCostEstimate {
cost_in_credits: Some(usd_cents),
cost_in_usd_cents: Some(usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
#[cfg(test)]
mod tests {
use tokens::tokens::media_files::MediaFileToken;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::router_provider::RouterProvider;
use crate::api::video_list_ref::VideoListRef;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
// Every combination, driven through the full build pipeline. These prices are
// the supplier's per-combo cost plus a 5% margin, rounded up to whole cents.
mod pricing_480p {
use super::*;
#[test]
fn without_video_reference() {
assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, false), 13);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 17);
assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, false), 33);
assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, false), 49);
}
#[test]
fn with_video_reference() {
assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, true), 17);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, true), 21);
assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, true), 42);
assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, true), 62);
}
}
mod pricing_720p {
use super::*;
#[test]
fn without_video_reference() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, false), 35);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 44);
assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, false), 87);
assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, false), 130);
}
#[test]
fn with_video_reference() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, true), 42);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, true), 52);
assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, true), 104);
assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, true), 156);
}
#[test]
fn none_defaults_to_720p() {
assert_eq!(cents_no_ref(None, 5, 1), cents(RouterResolution::SevenTwentyP, 5, 1, false));
}
}
mod credits_tests {
use super::*;
#[test]
fn credits_equal_usd_cents_all_combos() {
let resolutions = [
Some(RouterResolution::FourEightyP),
Some(RouterResolution::SevenTwentyP),
None,
];
for res in resolutions {
for dur in [4u16, 5, 10, 15] {
for batch in [1u16, 2, 4] {
for has_ref in [false, true] {
let cost = build_cost(res, dur, batch, has_ref);
assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents);
}
}
}
}
}
}
fn build_cost(
resolution: Option<RouterResolution>,
duration_seconds: u16,
video_batch_count: u16,
with_video_reference: bool,
) -> VideoGenerationCostEstimate {
let reference_videos = with_video_reference.then(|| {
VideoListRef::MediaFileTokens(vec![MediaFileToken::new("mf_ref".to_string())])
});
let builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0Mini,
provider: RouterProvider::Artcraft,
resolution,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
reference_videos,
..Default::default()
};
builder.build2()
.expect("build2 should succeed")
.estimate_cost()
.expect("estimate_cost should succeed")
}
fn cents(
resolution: RouterResolution,
duration_seconds: u16,
video_batch_count: u16,
with_video_reference: bool,
) -> u64 {
build_cost(Some(resolution), duration_seconds, video_batch_count, with_video_reference)
.cost_in_usd_cents
.unwrap()
}
fn cents_no_ref(
resolution: Option<RouterResolution>,
duration_seconds: u16,
video_batch_count: u16,
) -> u64 {
build_cost(resolution, duration_seconds, video_batch_count, false)
.cost_in_usd_cents
.unwrap()
}
}
@@ -1,3 +0,0 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -1,86 +0,0 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request;
#[derive(Clone, Debug)]
pub struct ArtcraftSeedance2p0MiniRequestState {
pub request: OmniGenVideoCostAndGenerateRequest,
}
impl ArtcraftSeedance2p0MiniRequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
send_artcraft_omni_video_request(&self.request, client).await
}
}
#[cfg(test)]
mod tests {
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::router_provider::RouterProvider;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_client::RouterClient;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::utils::api_host::ApiHost;
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn text_to_video() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()),
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
..mini_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn res_480p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A shiba inu playing in autumn leaves.".to_string()),
resolution: Some(RouterResolution::FourEightyP),
..mini_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
fn mini_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0Mini,
provider: RouterProvider::Artcraft,
duration_seconds: Some(4),
video_batch_count: Some(1),
..Default::default()
}
}
fn get_artcraft_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt")
.expect("Failed to read artcraft cookies");
let cookies = cookies.trim().to_string();
let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies)
.expect("Failed to parse cookies")
.expect("No credentials found");
RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials))
}
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
let client = get_artcraft_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
VideoGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant (Artcraft skips draft)"),
};
request.send_request(&client).await.expect("send_request should succeed")
}
}
@@ -13,7 +13,7 @@ pub fn build_artcraft_seedance_2p0_u(builder: GenerateVideoRequestBuilder) -> Re
let request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::Seedance2p0Ultra,
SupportedResolutions::FullWith4k,
SupportedResolutions::Full,
UltraWideSupport::Supported,
)?;
let state = ArtcraftSeedance2p0UltraRequestState { request };
@@ -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 ──
@@ -105,12 +97,6 @@ mod tests {
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::TenEightyP));
}
#[test]
fn res_4k() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourK); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourK));
}
#[test]
fn none_stays_none() {
let req = unwrap_request(make_builder(|_| {}));
@@ -1,8 +1,7 @@
use enums::common::generation::common_resolution::CommonResolution;
use crate::generate::generate_video::providers::artcraft::seedance_common::seedance_2p0_four_k_usd_cents;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_u::request::ArtcraftSeedance2p0UltraRequestState;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_u::request::ArtcraftSeedance2p0UltraRequestState;
// ── Pricing constants ──
@@ -14,7 +13,6 @@ pub struct ArtcraftSeedance2p0UltraCostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
pub batch_count: u16,
pub has_video_reference: bool,
}
impl ArtcraftSeedance2p0UltraCostState {
@@ -23,31 +21,11 @@ impl ArtcraftSeedance2p0UltraCostState {
.unwrap_or(CommonResolution::SevenTwentyP);
let duration_seconds = request.request.duration_seconds.unwrap_or(5);
let batch_count = request.request.video_batch_count.unwrap_or(1);
let has_video_reference = request.request.reference_video_media_tokens
.as_ref()
.is_some_and(|tokens| !tokens.is_empty());
Self { resolution, duration_seconds, batch_count, has_video_reference }
Self { resolution, duration_seconds, batch_count }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
if self.resolution == CommonResolution::FourK {
let usd_cents = seedance_2p0_four_k_usd_cents(
self.duration_seconds,
self.batch_count,
self.has_video_reference,
);
return VideoGenerationCostEstimate {
cost_in_credits: Some(usd_cents),
cost_in_usd_cents: Some(usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
};
}
let cents_per_second = match self.resolution {
CommonResolution::FourEightyP => CENTS_PER_SECOND_480P,
CommonResolution::TenEightyP => CENTS_PER_SECOND_1080P,
@@ -71,9 +49,9 @@ impl ArtcraftSeedance2p0UltraCostState {
#[cfg(test)]
mod tests {
use crate::api::router_provider::RouterProvider;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
mod pricing_720p {
@@ -144,38 +122,6 @@ mod tests {
}
}
mod four_k_pricing {
use enums::common::generation::common_resolution::CommonResolution;
fn artcraft_4k_cents(duration_seconds: u16, batch_count: u16, has_video_reference: bool) -> u64 {
super::super::ArtcraftSeedance2p0UltraCostState {
resolution: CommonResolution::FourK,
duration_seconds,
batch_count,
has_video_reference,
}
.estimate_cost()
.cost_in_usd_cents
.unwrap()
}
#[test]
fn explicit_4k_without_video_reference() {
assert_eq!(artcraft_4k_cents(4, 1, false), 347);
assert_eq!(artcraft_4k_cents(5, 1, false), 433);
assert_eq!(artcraft_4k_cents(10, 1, false), 866);
assert_eq!(artcraft_4k_cents(15, 1, false), 1299);
}
#[test]
fn explicit_4k_with_video_reference() {
assert_eq!(artcraft_4k_cents(4, 1, true), 416);
assert_eq!(artcraft_4k_cents(5, 1, true), 519);
assert_eq!(artcraft_4k_cents(10, 1, true), 1038);
assert_eq!(artcraft_4k_cents(15, 1, true), 1557);
}
}
mod relative_pricing {
use super::*;
@@ -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,328 +0,0 @@
//! Shared pricing for the Seedance 2.0 family of ArtCraft providers.
//!
//! ArtCraft credits equal USD cents (100 credits = $1.00).
use enums::common::generation::common_resolution::CommonResolution;
// 4K is priced uniformly across the non-Fast Seedance 2.0 models. Rates are held
// in hundredths of a USD cent per second so the math is exact integer arithmetic
// (no floating point), then rounded up to whole cents.
/// 4K output price, in hundredths of a USD cent per second (86.60 ¢/s).
const FOUR_K_CENTI_CENTS_PER_SECOND: u64 = 8660;
/// Extra hundredths of a USD cent per second when a reference video is attached
/// at 4K (17.20 ¢/s surcharge).
const FOUR_K_VIDEO_REFERENCE_SURCHARGE_CENTI_CENTS_PER_SECOND: u64 = 1720;
/// ArtCraft's user-facing price (USD cents) for Seedance 2.0 at 4K.
///
/// A reference video adds a per-second surcharge (4K with and without a video
/// reference are priced differently). The cost scales with duration and batch
/// count, rounded up to whole cents.
pub fn seedance_2p0_four_k_usd_cents(
duration_seconds: u16,
batch_count: u16,
has_video_reference: bool,
) -> u64 {
let mut centi_cents_per_second = FOUR_K_CENTI_CENTS_PER_SECOND;
if has_video_reference {
centi_cents_per_second += FOUR_K_VIDEO_REFERENCE_SURCHARGE_CENTI_CENTS_PER_SECOND;
}
let total_centi_cents =
centi_cents_per_second * duration_seconds as u64 * batch_count as u64;
// Round up to whole cents.
total_centi_cents.div_ceil(100)
}
// ── Seedance 2.0 Mini pricing ──
//
// Mini offers only 480p and 720p. The rates below are the per-second USER price
// in USD cents (margin already included); a reference video adds a per-second
// surcharge. Rates are fractional, so the total is rounded UP to a whole cent
// once at the end (after multiplying by duration × batch).
//
// The regular Mini and the BytePlus / BytePlus Ultra Minis carry DIFFERENT
// margins, so they have separate rate sets and helpers below.
/// Regular Mini — 480p price, USD cents per second.
const MINI_CENTS_PER_SECOND_480P: f64 = 3.24074074;
/// Regular Mini — 480p reference-video surcharge, USD cents per second.
const MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_480P: f64 = 0.86419753;
/// Regular Mini — 720p price, USD cents per second.
const MINI_CENTS_PER_SECOND_720P: f64 = 8.64197531;
/// Regular Mini — 720p reference-video surcharge, USD cents per second.
const MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_720P: f64 = 1.72839506;
/// BytePlus / BytePlus Ultra Mini — 480p price, USD cents per second.
const BYTEPLUS_MINI_CENTS_PER_SECOND_480P: f64 = 3.27160494;
/// BytePlus / BytePlus Ultra Mini — 480p reference-video surcharge, USD cents per second.
const BYTEPLUS_MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_480P: f64 = 0.87242798;
/// BytePlus / BytePlus Ultra Mini — 720p price, USD cents per second.
const BYTEPLUS_MINI_CENTS_PER_SECOND_720P: f64 = 8.72427984;
/// BytePlus / BytePlus Ultra Mini — 720p reference-video surcharge, USD cents per second.
const BYTEPLUS_MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_720P: f64 = 1.74485597;
/// ArtCraft's user-facing price (USD cents) for the regular Seedance 2.0 Mini.
///
/// Only 480p and 720p are offered; any other resolution prices at 720p. A
/// reference video adds the per-second surcharge. The fractional total is
/// rounded UP to a whole cent.
pub fn seedance_2p0_mini_usd_cents(
resolution: CommonResolution,
duration_seconds: u16,
batch_count: u16,
has_video_reference: bool,
) -> u64 {
mini_usd_cents(
resolution,
duration_seconds,
batch_count,
has_video_reference,
MINI_CENTS_PER_SECOND_480P,
MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_480P,
MINI_CENTS_PER_SECOND_720P,
MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_720P,
)
}
/// ArtCraft's user-facing price (USD cents) for the Seedance 2.0 BytePlus Mini
/// and BytePlus Ultra Mini (which share the same rates — a higher margin than
/// the regular Mini).
///
/// Only 480p and 720p are offered; any other resolution prices at 720p. A
/// reference video adds the per-second surcharge. The fractional total is
/// rounded UP to a whole cent.
pub fn seedance_2p0_byteplus_mini_usd_cents(
resolution: CommonResolution,
duration_seconds: u16,
batch_count: u16,
has_video_reference: bool,
) -> u64 {
mini_usd_cents(
resolution,
duration_seconds,
batch_count,
has_video_reference,
BYTEPLUS_MINI_CENTS_PER_SECOND_480P,
BYTEPLUS_MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_480P,
BYTEPLUS_MINI_CENTS_PER_SECOND_720P,
BYTEPLUS_MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_720P,
)
}
#[allow(clippy::too_many_arguments)]
fn mini_usd_cents(
resolution: CommonResolution,
duration_seconds: u16,
batch_count: u16,
has_video_reference: bool,
cents_per_second_480p: f64,
video_reference_surcharge_per_second_480p: f64,
cents_per_second_720p: f64,
video_reference_surcharge_per_second_720p: f64,
) -> u64 {
let (base_per_second, video_reference_surcharge_per_second) = match resolution {
CommonResolution::FourEightyP => (cents_per_second_480p, video_reference_surcharge_per_second_480p),
// Everything else (including 720p and unsupported resolutions) prices at 720p.
_ => (cents_per_second_720p, video_reference_surcharge_per_second_720p),
};
let mut cents_per_second = base_per_second;
if has_video_reference {
cents_per_second += video_reference_surcharge_per_second;
}
(cents_per_second * duration_seconds as f64 * batch_count as f64).ceil() as u64
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn four_k_without_video_reference() {
assert_eq!(seedance_2p0_four_k_usd_cents(4, 1, false), 347);
assert_eq!(seedance_2p0_four_k_usd_cents(5, 1, false), 433);
assert_eq!(seedance_2p0_four_k_usd_cents(10, 1, false), 866);
assert_eq!(seedance_2p0_four_k_usd_cents(15, 1, false), 1299);
}
#[test]
fn four_k_with_video_reference() {
assert_eq!(seedance_2p0_four_k_usd_cents(4, 1, true), 416);
assert_eq!(seedance_2p0_four_k_usd_cents(5, 1, true), 519);
assert_eq!(seedance_2p0_four_k_usd_cents(10, 1, true), 1038);
assert_eq!(seedance_2p0_four_k_usd_cents(15, 1, true), 1557);
}
#[test]
fn video_reference_always_costs_more() {
for &duration in &[4u16, 5, 10, 15] {
assert!(
seedance_2p0_four_k_usd_cents(duration, 1, true)
> seedance_2p0_four_k_usd_cents(duration, 1, false),
"video reference should cost more at {duration}s",
);
}
}
#[test]
fn batch_count_multiplies() {
assert_eq!(seedance_2p0_four_k_usd_cents(5, 2, false), 866);
assert_eq!(seedance_2p0_four_k_usd_cents(5, 4, false), 1732);
assert_eq!(seedance_2p0_four_k_usd_cents(5, 2, true), 1038);
}
// ── Seedance 2.0 Mini ──
//
// Every combination (480p/720p × with/without video reference × 4/5/10/15s)
// at batch 1. These are the supplier's per-combo cost plus a 5% margin,
// rounded up to whole cents.
mod mini {
use super::*;
fn cents(res: CommonResolution, dur: u16, has_ref: bool) -> u64 {
seedance_2p0_mini_usd_cents(res, dur, 1, has_ref)
}
#[test]
fn four_eighty_p_without_video_reference() {
assert_eq!(cents(CommonResolution::FourEightyP, 4, false), 13);
assert_eq!(cents(CommonResolution::FourEightyP, 5, false), 17);
assert_eq!(cents(CommonResolution::FourEightyP, 10, false), 33);
assert_eq!(cents(CommonResolution::FourEightyP, 15, false), 49);
}
#[test]
fn four_eighty_p_with_video_reference() {
assert_eq!(cents(CommonResolution::FourEightyP, 4, true), 17);
assert_eq!(cents(CommonResolution::FourEightyP, 5, true), 21);
assert_eq!(cents(CommonResolution::FourEightyP, 10, true), 42);
assert_eq!(cents(CommonResolution::FourEightyP, 15, true), 62);
}
#[test]
fn seven_twenty_p_without_video_reference() {
assert_eq!(cents(CommonResolution::SevenTwentyP, 4, false), 35);
assert_eq!(cents(CommonResolution::SevenTwentyP, 5, false), 44);
assert_eq!(cents(CommonResolution::SevenTwentyP, 10, false), 87);
assert_eq!(cents(CommonResolution::SevenTwentyP, 15, false), 130);
}
#[test]
fn seven_twenty_p_with_video_reference() {
assert_eq!(cents(CommonResolution::SevenTwentyP, 4, true), 42);
assert_eq!(cents(CommonResolution::SevenTwentyP, 5, true), 52);
assert_eq!(cents(CommonResolution::SevenTwentyP, 10, true), 104);
assert_eq!(cents(CommonResolution::SevenTwentyP, 15, true), 156);
}
#[test]
fn video_reference_always_costs_more() {
for res in [CommonResolution::FourEightyP, CommonResolution::SevenTwentyP] {
for dur in [4u16, 5, 10, 15] {
assert!(
cents(res, dur, true) > cents(res, dur, false),
"video ref should cost more at {res:?} {dur}s",
);
}
}
}
#[test]
fn unsupported_resolution_prices_at_720p() {
assert_eq!(
seedance_2p0_mini_usd_cents(CommonResolution::TenEightyP, 5, 1, false),
seedance_2p0_mini_usd_cents(CommonResolution::SevenTwentyP, 5, 1, false),
);
}
#[test]
fn batch_count_scales_the_total() {
// Batch is baked in before the single round-up, so batched totals can be
// a cent under N× the single price.
assert_eq!(seedance_2p0_mini_usd_cents(CommonResolution::SevenTwentyP, 5, 2, false), 87);
assert_eq!(seedance_2p0_mini_usd_cents(CommonResolution::SevenTwentyP, 5, 4, false), 173);
assert_eq!(seedance_2p0_mini_usd_cents(CommonResolution::FourEightyP, 5, 2, false), 33);
}
}
// ── Seedance 2.0 BytePlus / BytePlus Ultra Mini ──
//
// Same combinations as the regular Mini, but at a higher margin (6% vs 5%),
// so the prices are equal-or-higher than the regular Mini.
mod byteplus_mini {
use super::*;
fn cents(res: CommonResolution, dur: u16, has_ref: bool) -> u64 {
seedance_2p0_byteplus_mini_usd_cents(res, dur, 1, has_ref)
}
#[test]
fn four_eighty_p_without_video_reference() {
assert_eq!(cents(CommonResolution::FourEightyP, 4, false), 14);
assert_eq!(cents(CommonResolution::FourEightyP, 5, false), 17);
assert_eq!(cents(CommonResolution::FourEightyP, 10, false), 33);
assert_eq!(cents(CommonResolution::FourEightyP, 15, false), 50);
}
#[test]
fn four_eighty_p_with_video_reference() {
assert_eq!(cents(CommonResolution::FourEightyP, 4, true), 17);
assert_eq!(cents(CommonResolution::FourEightyP, 5, true), 21);
assert_eq!(cents(CommonResolution::FourEightyP, 10, true), 42);
assert_eq!(cents(CommonResolution::FourEightyP, 15, true), 63);
}
#[test]
fn seven_twenty_p_without_video_reference() {
assert_eq!(cents(CommonResolution::SevenTwentyP, 4, false), 35);
assert_eq!(cents(CommonResolution::SevenTwentyP, 5, false), 44);
assert_eq!(cents(CommonResolution::SevenTwentyP, 10, false), 88);
assert_eq!(cents(CommonResolution::SevenTwentyP, 15, false), 131);
}
#[test]
fn seven_twenty_p_with_video_reference() {
assert_eq!(cents(CommonResolution::SevenTwentyP, 4, true), 42);
assert_eq!(cents(CommonResolution::SevenTwentyP, 5, true), 53);
assert_eq!(cents(CommonResolution::SevenTwentyP, 10, true), 105);
assert_eq!(cents(CommonResolution::SevenTwentyP, 15, true), 158);
}
#[test]
fn never_cheaper_than_regular_mini() {
for res in [CommonResolution::FourEightyP, CommonResolution::SevenTwentyP] {
for dur in [4u16, 5, 10, 15] {
for has_ref in [false, true] {
assert!(
seedance_2p0_byteplus_mini_usd_cents(res, dur, 1, has_ref)
>= seedance_2p0_mini_usd_cents(res, dur, 1, has_ref),
"byteplus mini (6%) should be >= regular mini (5%) at {res:?} {dur}s ref={has_ref}",
);
}
}
}
}
#[test]
fn unsupported_resolution_prices_at_720p() {
assert_eq!(
seedance_2p0_byteplus_mini_usd_cents(CommonResolution::TenEightyP, 5, 1, false),
seedance_2p0_byteplus_mini_usd_cents(CommonResolution::SevenTwentyP, 5, 1, false),
);
}
#[test]
fn batch_count_scales_the_total() {
assert_eq!(seedance_2p0_byteplus_mini_usd_cents(CommonResolution::SevenTwentyP, 5, 2, false), 88);
assert_eq!(seedance_2p0_byteplus_mini_usd_cents(CommonResolution::SevenTwentyP, 5, 4, false), 175);
assert_eq!(seedance_2p0_byteplus_mini_usd_cents(CommonResolution::FourEightyP, 5, 2, false), 33);
}
}
}
@@ -1,6 +1,5 @@
pub mod happy_horse_1p0;
pub mod seedance_2p0;
pub mod seedance_2p0_fast;
pub mod seedance_2p0_mini;
pub(crate) mod resolve;
pub(crate) mod upload;
@@ -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),
})
@@ -111,7 +107,7 @@ fn nearest_aspect_ratio(aspect_ratio: RouterAspectRatio) -> KinoviAspectRatio {
}
}
// Seedance 2.0 Pro supports output resolutions: 480p, 720p, 1080p, 4K.
// Seedance 2.0 Pro supports output resolutions: 480p, 720p, 1080p.
fn plan_output_resolution(
resolution: Option<RouterResolution>,
strategy: RequestMismatchMitigationStrategy,
@@ -123,7 +119,6 @@ fn plan_output_resolution(
Some(RouterResolution::FourEightyP) => Ok(Some(KinoviOutputResolution::FourEightyP)),
Some(RouterResolution::SevenTwentyP) => Ok(Some(KinoviOutputResolution::SevenTwentyP)),
Some(RouterResolution::TenEightyP) => Ok(Some(KinoviOutputResolution::TenEightyP)),
Some(RouterResolution::FourK) => Ok(Some(KinoviOutputResolution::FourK)),
// Mismatches
Some(unsupported) => match strategy {
@@ -200,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::{
@@ -377,16 +362,6 @@ mod tests {
assert!(matches!(draft.resolution, Some(KinoviOutputResolution::TenEightyP)));
}
#[test]
fn resolution_4k() {
let builder = GenerateVideoRequestBuilder {
resolution: Some(RouterResolution::FourK),
..seedance2pro_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p0(builder));
assert!(matches!(draft.resolution, Some(KinoviOutputResolution::FourK)));
}
#[test]
fn resolution_none() {
let builder = GenerateVideoRequestBuilder { resolution: None, ..seedance2pro_builder() };
@@ -397,7 +372,7 @@ mod tests {
#[test]
fn unsupported_resolution_error_out() {
let builder = GenerateVideoRequestBuilder {
resolution: Some(RouterResolution::ThreeK),
resolution: Some(RouterResolution::FourK),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..seedance2pro_builder()
};
@@ -407,7 +382,7 @@ mod tests {
#[test]
fn unsupported_resolution_rounds_up() {
let builder = GenerateVideoRequestBuilder {
resolution: Some(RouterResolution::ThreeK),
resolution: Some(RouterResolution::FourK),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..seedance2pro_builder()
};
@@ -416,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();
@@ -175,121 +174,6 @@ mod tests {
}
}
// ── 4K pricing ──
//
// Routes to the seedance2pro_client: 4K base is 200 credits/s (vs 90 at 1080p).
// Credits → cents at 243 credits/$1, rounded up.
mod pricing_4k {
use super::*;
#[test]
fn cost_4k_batch_1() {
assert_eq!(usd_cents(KinoviOutputResolution::FourK, 4, KinoviBatchCount::One), 330);
assert_eq!(usd_cents(KinoviOutputResolution::FourK, 5, KinoviBatchCount::One), 412);
assert_eq!(usd_cents(KinoviOutputResolution::FourK, 10, KinoviBatchCount::One), 824);
assert_eq!(usd_cents(KinoviOutputResolution::FourK, 15, KinoviBatchCount::One), 1235);
}
#[test]
fn cost_4k_batch_2() {
assert_eq!(usd_cents(KinoviOutputResolution::FourK, 5, KinoviBatchCount::Two), 824);
}
#[test]
fn cost_4k_batch_4() {
assert_eq!(usd_cents(KinoviOutputResolution::FourK, 5, KinoviBatchCount::Four), 1647);
}
}
// ── 4K: router cost must match the seedance2pro_client binding ──
//
// The router delegates to GenerateSeedance2p0Request::calculate_costs(), so these
// assert the exact 4K numbers AND cross-check the router against the binding for
// every duration (4/5/10/15s) with and without a video reference.
//
// 4K base = 200 credits/s; with a video reference = 240 credits/s (+40 surcharge).
// USD cents = ceil(total_credits / 243 * 100).
mod four_k_matches_binding {
use super::*;
use seedance2pro_client::generate::video::generate_seedance_2p0::GenerateSeedance2p0Request;
/// Router-side 4K cost (batch 1) for a duration / video-reference combo.
fn router(duration_seconds: u8, has_video_reference: bool) -> VideoGenerationCostEstimate {
KinoviSeedance2p0CostState {
resolution: Some(KinoviOutputResolution::FourK),
duration_seconds,
batch_count: Some(KinoviBatchCount::One),
has_video_reference,
}
.estimate_cost()
}
/// The seedance2pro_client binding's own 4K cost for the same inputs.
/// Returns (kinovi_credits, usd_cents_rounded_up).
fn binding(duration_seconds: u8, has_video_reference: bool) -> (u64, u64) {
let reference_video_urls = if has_video_reference {
Some(vec!["pricing-placeholder".to_string()])
} else {
None
};
let costs = GenerateSeedance2p0Request {
output_resolution: Some(KinoviOutputResolution::FourK),
duration_seconds,
batch_count: Some(KinoviBatchCount::One),
reference_video_urls,
prompt: String::new(),
aspect_ratio: None,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
}
.calculate_costs();
(costs.total_cost.kinovi_credits, costs.total_cost.usd_cents_rounded_up)
}
#[test]
fn explicit_values_without_video_reference() {
// 200 credits/s.
assert_eq!((router(4, false).cost_in_credits, router(4, false).cost_in_usd_cents), (Some(800), Some(330)));
assert_eq!((router(5, false).cost_in_credits, router(5, false).cost_in_usd_cents), (Some(1000), Some(412)));
assert_eq!((router(10, false).cost_in_credits, router(10, false).cost_in_usd_cents), (Some(2000), Some(824)));
assert_eq!((router(15, false).cost_in_credits, router(15, false).cost_in_usd_cents), (Some(3000), Some(1235)));
}
#[test]
fn explicit_values_with_video_reference() {
// 240 credits/s (200 base + 40 surcharge).
assert_eq!((router(4, true).cost_in_credits, router(4, true).cost_in_usd_cents), (Some(960), Some(396)));
assert_eq!((router(5, true).cost_in_credits, router(5, true).cost_in_usd_cents), (Some(1200), Some(494)));
assert_eq!((router(10, true).cost_in_credits, router(10, true).cost_in_usd_cents), (Some(2400), Some(988)));
assert_eq!((router(15, true).cost_in_credits, router(15, true).cost_in_usd_cents), (Some(3600), Some(1482)));
}
#[test]
fn router_matches_binding_for_every_duration_and_video_ref() {
for &duration in &[4u8, 5, 10, 15] {
for &has_ref in &[false, true] {
let r = router(duration, has_ref);
let (b_credits, b_cents) = binding(duration, has_ref);
assert_eq!(
r.cost_in_credits, Some(b_credits),
"credits differ at {duration}s has_video_reference={has_ref}",
);
assert_eq!(
r.cost_in_usd_cents, Some(b_cents),
"usd cents differ at {duration}s has_video_reference={has_ref}",
);
}
}
}
}
// ── Relative pricing ──
mod relative_pricing_tests {
@@ -304,13 +188,6 @@ mod tests {
assert!(c720 < c1080, "720p ({}) should be cheaper than 1080p ({})", c720, c1080);
}
#[test]
fn cost_1080p_cheaper_than_4k() {
let c1080 = usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::One);
let c4k = usd_cents(KinoviOutputResolution::FourK, 5, KinoviBatchCount::One);
assert!(c1080 < c4k, "1080p ({}) should be cheaper than 4K ({})", c1080, c4k);
}
#[test]
fn cost_scales_with_duration() {
let c4 = usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One);
@@ -428,24 +305,6 @@ mod tests {
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(31));
}
#[test]
fn from_draft_4k() {
let draft = make_draft(5, 1, Some(RouterResolution::FourK), false);
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::FourK)));
assert_eq!(cost.estimate_cost().cost_in_credits, Some(1000));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(412));
}
#[test]
fn from_draft_4k_with_video_reference() {
let draft = make_draft(5, 1, Some(RouterResolution::FourK), true);
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
// 4K base 200 + video-ref surcharge 40 = 240 credits/s -> 1200 credits for 5s.
assert_eq!(cost.estimate_cost().cost_in_credits, Some(1200));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(494));
}
#[test]
fn from_draft_1080p_batch_4() {
let draft = make_draft(5, 4, Some(RouterResolution::TenEightyP), false);
@@ -528,11 +387,6 @@ mod tests {
fn credits_1080p() {
assert_eq!(credits(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::One), 450);
}
#[test]
fn credits_4k() {
assert_eq!(credits(KinoviOutputResolution::FourK, 5, KinoviBatchCount::One), 1000);
}
}
// ── Helpers ──
@@ -622,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 })
@@ -1,510 +0,0 @@
use seedance2pro_client::generate::video::generate_seedance_2p0_mini::{
KinoviSeedance2p0MiniAspectRatio as KinoviAspectRatio,
KinoviSeedance2p0MiniBitrate as KinoviBitrate,
KinoviSeedance2p0MiniBatchCount as KinoviBatchCount,
KinoviSeedance2p0MiniOutputResolution 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;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::draft::{KinoviSeedance2p0MiniDraftState, KinoviSeedance2p0MiniRemainingItems};
use crate::generate::generate_video::video_generation_draft::VideoGenerationDraftRequest;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
pub fn build_kinovi_seedance_2p0_mini(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let draft = do_build_kinovi_seedance_2p0_mini(builder)?;
Ok(VideoGenerationDraftOrRequest::Draft(VideoGenerationDraftRequest::KinoviSeedance2p0Mini(draft)))
}
fn do_build_kinovi_seedance_2p0_mini(mut builder: GenerateVideoRequestBuilder) -> Result<KinoviSeedance2p0MiniDraftState, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?;
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 = KinoviSeedance2p0MiniRemainingItems {
start_frame: builder.start_frame.take(),
end_frame: builder.end_frame.take(),
reference_images: builder.reference_images.take(),
reference_videos: builder.reference_videos.take(),
reference_audio: builder.reference_audio.take(),
reference_character_tokens: builder.reference_character_tokens.take(),
};
Ok(KinoviSeedance2p0MiniDraftState {
aspect_ratio,
resolution,
batch_count,
duration_seconds,
bitrate,
prompt,
unhandled_request_state: Some(unhandled_request_state),
})
}
// ── Plan helpers ──
// Seedance 2.0 Mini supports all six aspect ratios:
// 16:9, 21:9, 9:16, 1:1, 4:3, 3:4. All supported ratios cost the same, so
// both upgrade and downgrade pick the nearest match.
fn plan_aspect_ratio(
aspect_ratio: Option<RouterAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<KinoviAspectRatio, ArtcraftRouterError> {
match aspect_ratio {
// No preference or auto — default to landscape
None
| Some(RouterAspectRatio::Auto)
| Some(RouterAspectRatio::Auto2k)
| Some(RouterAspectRatio::Auto4k) => Ok(KinoviAspectRatio::Landscape16x9),
// Direct mappings
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => {
Ok(KinoviAspectRatio::Landscape16x9)
}
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => {
Ok(KinoviAspectRatio::Portrait9x16)
}
Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => {
Ok(KinoviAspectRatio::Square1x1)
}
Some(RouterAspectRatio::WideFourByThree) => Ok(KinoviAspectRatio::Standard4x3),
Some(RouterAspectRatio::TallThreeByFour) => Ok(KinoviAspectRatio::Portrait3x4),
Some(RouterAspectRatio::WideTwentyOneByNine) => Ok(KinoviAspectRatio::UltraWide21x9),
// Mismatches — apply strategy
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "aspect_ratio",
value: format!("{:?}", unsupported),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(nearest_aspect_ratio(unsupported))
}
},
}
}
/// Pick the nearest supported aspect ratio by AR value (width / height).
fn nearest_aspect_ratio(aspect_ratio: RouterAspectRatio) -> KinoviAspectRatio {
match aspect_ratio {
RouterAspectRatio::WideFiveByFour => KinoviAspectRatio::Standard4x3, // 1.25, nearest 1.33
RouterAspectRatio::WideThreeByTwo => KinoviAspectRatio::Standard4x3, // 1.50, nearest 1.33
RouterAspectRatio::TallFourByFive => KinoviAspectRatio::Portrait3x4, // 0.80, nearest 0.75
RouterAspectRatio::TallTwoByThree => KinoviAspectRatio::Portrait3x4, // 0.67, nearest 0.75
RouterAspectRatio::TallNineByTwentyOne => KinoviAspectRatio::Portrait9x16, // 0.43, nearest 0.56
_ => KinoviAspectRatio::Square1x1,
}
}
// Seedance 2.0 Mini supports output resolutions: 480p and 720p only.
// 1080p (and higher) is NOT supported — downgrade to 720p or error based on strategy.
fn plan_output_resolution(
resolution: Option<RouterResolution>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<KinoviOutputResolution>, ArtcraftRouterError> {
match resolution {
None => Ok(None),
// Direct mappings
Some(RouterResolution::FourEightyP) => Ok(Some(KinoviOutputResolution::FourEightyP)),
Some(RouterResolution::SevenTwentyP) => Ok(Some(KinoviOutputResolution::SevenTwentyP)),
// 1080p is not supported for Mini — handle via strategy
Some(RouterResolution::TenEightyP) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "resolution",
value: format!("{:?}", RouterResolution::TenEightyP),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
// 1080p not available — downgrade to 720p (highest supported)
Ok(Some(KinoviOutputResolution::SevenTwentyP))
}
},
// Other unsupported resolutions
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "resolution",
value: format!("{:?}", unsupported),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(Some(match unsupported {
RouterResolution::HalfK => KinoviOutputResolution::FourEightyP,
_ => KinoviOutputResolution::SevenTwentyP,
}))
}
},
}
}
// Seedance 2.0 Mini supports batch counts of 1–8.
fn plan_batch_count(
video_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<KinoviBatchCount, ArtcraftRouterError> {
let count = video_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(KinoviBatchCount::One),
2 => Ok(KinoviBatchCount::Two),
3 => Ok(KinoviBatchCount::Three),
4 => Ok(KinoviBatchCount::Four),
5 => Ok(KinoviBatchCount::Five),
6 => Ok(KinoviBatchCount::Six),
7 => Ok(KinoviBatchCount::Seven),
8 => Ok(KinoviBatchCount::Eight),
// Over the maximum of 8.
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "video_batch_count",
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(KinoviBatchCount::Eight),
},
}
}
// Seedance 2.0 Mini supports duration of 4–15 seconds.
fn plan_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<u8, ArtcraftRouterError> {
const MIN: u16 = 4;
const MAX: u16 = 15;
const DEFAULT: u8 = 5;
match duration_seconds {
None => Ok(DEFAULT),
Some(d) if d >= MIN && d <= MAX => Ok(d as u8),
Some(d) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "duration_seconds",
value: format!("{}", d),
}))
}
_ => Ok(d.clamp(MIN, MAX) as u8),
},
}
}
// Seedance 2.0 Mini bitrate: "standard" (default, field omitted) or "high".
// Bitrate does not affect cost. `Normal` and unset both map to standard (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_mini::{
KinoviSeedance2p0MiniAspectRatio as KinoviAspectRatio,
KinoviSeedance2p0MiniBatchCount as KinoviBatchCount,
KinoviSeedance2p0MiniOutputResolution as KinoviOutputResolution,
};
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
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_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
use crate::api::video_list_ref::VideoListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::draft::KinoviSeedance2p0MiniDraftState;
use crate::generate::generate_video::video_generation_draft::VideoGenerationDraftRequest;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use super::*;
mod materialized_field_conversions {
use super::*;
#[test]
fn prompt_is_passed_through() {
let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(mini_builder()));
assert_eq!(draft.prompt, "a cat dancing");
}
#[test]
fn prompt_defaults_to_empty() {
let builder = GenerateVideoRequestBuilder { prompt: None, ..mini_builder() };
let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder));
assert_eq!(draft.prompt, "");
}
#[test]
fn duration_seconds_converted() {
let builder = GenerateVideoRequestBuilder { duration_seconds: Some(10), ..mini_builder() };
let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder));
assert_eq!(draft.duration_seconds, 10);
}
#[test]
fn duration_defaults_to_5() {
let builder = GenerateVideoRequestBuilder { duration_seconds: None, ..mini_builder() };
let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder));
assert_eq!(draft.duration_seconds, 5);
}
#[test]
fn duration_clamped_to_max() {
let builder = GenerateVideoRequestBuilder { duration_seconds: Some(99), ..mini_builder() };
let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder));
assert_eq!(draft.duration_seconds, 15);
}
}
mod batch_count_conversions {
use super::*;
#[test]
fn batch_counts_one_through_eight() {
let expected = [
(1u16, KinoviBatchCount::One),
(2, KinoviBatchCount::Two),
(3, KinoviBatchCount::Three),
(4, KinoviBatchCount::Four),
(5, KinoviBatchCount::Five),
(6, KinoviBatchCount::Six),
(7, KinoviBatchCount::Seven),
(8, KinoviBatchCount::Eight),
];
for (count, variant) in expected {
let builder = GenerateVideoRequestBuilder { video_batch_count: Some(count), ..mini_builder() };
let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder));
assert!(
std::mem::discriminant(&draft.batch_count) == std::mem::discriminant(&variant),
"batch {count} mapped wrong",
);
}
}
#[test]
fn batch_over_max_downgrades_to_eight() {
let builder = GenerateVideoRequestBuilder {
video_batch_count: Some(12),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade,
..mini_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder));
assert!(matches!(draft.batch_count, KinoviBatchCount::Eight));
}
#[test]
fn batch_over_max_error_out() {
let builder = GenerateVideoRequestBuilder {
video_batch_count: Some(12),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..mini_builder()
};
assert!(build_kinovi_seedance_2p0_mini(builder).is_err());
}
#[test]
fn batch_zero_errors() {
let builder = GenerateVideoRequestBuilder { video_batch_count: Some(0), ..mini_builder() };
assert!(build_kinovi_seedance_2p0_mini(builder).is_err());
}
}
mod aspect_ratio_conversions {
use super::*;
#[test]
fn aspect_ratio_wide() {
let builder = GenerateVideoRequestBuilder { aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), ..mini_builder() };
let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder));
assert!(matches!(draft.aspect_ratio, KinoviAspectRatio::Landscape16x9));
}
#[test]
fn aspect_ratio_ultra_wide() {
let builder = GenerateVideoRequestBuilder { aspect_ratio: Some(RouterAspectRatio::WideTwentyOneByNine), ..mini_builder() };
let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder));
assert!(matches!(draft.aspect_ratio, KinoviAspectRatio::UltraWide21x9));
}
#[test]
fn aspect_ratio_tall() {
let builder = GenerateVideoRequestBuilder { aspect_ratio: Some(RouterAspectRatio::TallNineBySixteen), ..mini_builder() };
let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder));
assert!(matches!(draft.aspect_ratio, KinoviAspectRatio::Portrait9x16));
}
#[test]
fn aspect_ratio_square() {
let builder = GenerateVideoRequestBuilder { aspect_ratio: Some(RouterAspectRatio::Square), ..mini_builder() };
let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder));
assert!(matches!(draft.aspect_ratio, KinoviAspectRatio::Square1x1));
}
#[test]
fn aspect_ratio_four_by_three() {
let builder = GenerateVideoRequestBuilder { aspect_ratio: Some(RouterAspectRatio::WideFourByThree), ..mini_builder() };
let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder));
assert!(matches!(draft.aspect_ratio, KinoviAspectRatio::Standard4x3));
}
#[test]
fn aspect_ratio_three_by_four() {
let builder = GenerateVideoRequestBuilder { aspect_ratio: Some(RouterAspectRatio::TallThreeByFour), ..mini_builder() };
let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder));
assert!(matches!(draft.aspect_ratio, KinoviAspectRatio::Portrait3x4));
}
#[test]
fn aspect_ratio_defaults_to_landscape() {
let builder = GenerateVideoRequestBuilder { aspect_ratio: None, ..mini_builder() };
let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder));
assert!(matches!(draft.aspect_ratio, KinoviAspectRatio::Landscape16x9));
}
}
mod resolution_conversions {
use super::*;
#[test]
fn resolution_480p() {
let builder = GenerateVideoRequestBuilder { resolution: Some(RouterResolution::FourEightyP), ..mini_builder() };
let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder));
assert!(matches!(draft.resolution, Some(KinoviOutputResolution::FourEightyP)));
}
#[test]
fn resolution_720p() {
let builder = GenerateVideoRequestBuilder { resolution: Some(RouterResolution::SevenTwentyP), ..mini_builder() };
let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder));
assert!(matches!(draft.resolution, Some(KinoviOutputResolution::SevenTwentyP)));
}
#[test]
fn resolution_1080p_downgrades_to_720p() {
let builder = GenerateVideoRequestBuilder {
resolution: Some(RouterResolution::TenEightyP),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade,
..mini_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder));
assert!(matches!(draft.resolution, Some(KinoviOutputResolution::SevenTwentyP)));
}
#[test]
fn resolution_1080p_error_out() {
let builder = GenerateVideoRequestBuilder {
resolution: Some(RouterResolution::TenEightyP),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..mini_builder()
};
assert!(build_kinovi_seedance_2p0_mini(builder).is_err());
}
#[test]
fn resolution_none() {
let builder = GenerateVideoRequestBuilder { resolution: None, ..mini_builder() };
let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder));
assert!(draft.resolution.is_none());
}
}
mod unhandled_request_state {
use super::*;
#[test]
fn media_refs_placed_in_unhandled() {
let builder = GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::Url("https://example.com/start.jpg".to_string())),
end_frame: Some(ImageRef::Url("https://example.com/end.jpg".to_string())),
reference_images: Some(ImageListRef::Urls(vec!["https://example.com/ref.jpg".to_string()])),
reference_videos: Some(VideoListRef::Urls(vec!["https://example.com/vid.mp4".to_string()])),
reference_audio: Some(AudioListRef::Urls(vec!["https://example.com/audio.mp3".to_string()])),
reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![
CharacterToken::new("char_abc".to_string()),
])),
..mini_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder));
let remaining = draft.unhandled_request_state.unwrap();
assert!(remaining.start_frame.is_some());
assert!(remaining.end_frame.is_some());
assert!(remaining.reference_images.is_some());
assert!(remaining.reference_videos.is_some());
assert!(remaining.reference_audio.is_some());
assert!(remaining.reference_character_tokens.is_some());
}
#[test]
fn start_frame_media_token_placed_in_unhandled() {
let builder = GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_test123".to_string()))),
..mini_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder));
let remaining = draft.unhandled_request_state.unwrap();
assert!(matches!(remaining.start_frame, Some(ImageRef::MediaFileToken(t)) if t.as_str() == "mf_test123"));
}
#[test]
fn empty_refs_are_none_in_unhandled() {
let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(mini_builder()));
let remaining = draft.unhandled_request_state.unwrap();
assert!(remaining.start_frame.is_none());
assert!(remaining.end_frame.is_none());
assert!(remaining.reference_images.is_none());
assert!(remaining.reference_videos.is_none());
assert!(remaining.reference_audio.is_none());
assert!(remaining.reference_character_tokens.is_none());
}
}
// ── Helpers ──
fn mini_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0Mini,
provider: RouterProvider::Seedance2Pro,
prompt: Some("a cat dancing".to_string()),
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
}
}
fn unwrap_draft(result: Result<VideoGenerationDraftOrRequest, ArtcraftRouterError>) -> KinoviSeedance2p0MiniDraftState {
match result.expect("build should succeed") {
VideoGenerationDraftOrRequest::Draft(
VideoGenerationDraftRequest::KinoviSeedance2p0Mini(draft)
) => draft,
_ => panic!("expected KinoviSeedance2p0Mini draft"),
}
}
}
@@ -1,378 +0,0 @@
use seedance2pro_client::generate::video::generate_seedance_2p0_mini::{
GenerateSeedance2p0MiniRequest, KinoviSeedance2p0MiniBatchCount,
KinoviSeedance2p0MiniOutputResolution,
};
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::draft::KinoviSeedance2p0MiniDraftState;
use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::request::KinoviSeedance2p0MiniRequestState;
pub struct KinoviSeedance2p0MiniCostState {
pub resolution: Option<KinoviSeedance2p0MiniOutputResolution>,
pub duration_seconds: u8,
pub batch_count: Option<KinoviSeedance2p0MiniBatchCount>,
pub has_video_reference: bool,
}
impl KinoviSeedance2p0MiniCostState {
pub fn from_request(request: &KinoviSeedance2p0MiniRequestState) -> Self {
Self {
resolution: request.request.output_resolution,
duration_seconds: request.request.duration_seconds,
batch_count: request.request.batch_count,
has_video_reference: request.request.reference_video_urls
.as_ref()
.is_some_and(|urls| !urls.is_empty()),
}
}
pub fn from_draft(draft: &KinoviSeedance2p0MiniDraftState) -> Self {
let has_video_reference = draft.unhandled_request_state
.as_ref()
.and_then(|rem| rem.reference_videos.as_ref())
.is_some();
Self {
resolution: draft.resolution,
duration_seconds: draft.duration_seconds,
batch_count: Some(draft.batch_count),
has_video_reference,
}
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
let pricing_request = GenerateSeedance2p0MiniRequest {
output_resolution: self.resolution,
duration_seconds: self.duration_seconds,
batch_count: self.batch_count,
// PRESENCE of reference videos changes the price (per-second
// surcharge); the URL contents don't.
reference_video_urls: if self.has_video_reference {
Some(vec!["pricing-placeholder".to_string()])
} else {
None
},
// No impact on price
prompt: String::new(),
aspect_ratio: None,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
};
let costs = pricing_request.calculate_costs();
// Mini credits can be fractional (e.g. 480p odd durations land on 37.5).
// The router's credit field is an integer, so round to the nearest credit;
// the USD cents (the authoritative charge) are already rounded up.
let cost_in_credits = costs.total_cost.kinovi_credits.round() as u64;
let cost_in_usd_cents = costs.total_cost.usd_cents_rounded_up;
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_credits),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::router_resolution::RouterResolution;
use seedance2pro_client::generate::video::generate_seedance_2p0_mini::{
KinoviSeedance2p0MiniOutputResolution as KinoviOutputResolution,
KinoviSeedance2p0MiniBatchCount as KinoviBatchCount,
};
use crate::api::router_video_model::RouterVideoModel;
use crate::api::router_provider::RouterProvider;
use crate::api::video_list_ref::VideoListRef;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_draft::VideoGenerationDraftRequest;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
// ── USD cents: every combination (480p/720p × with/without video ref) ──
mod pricing_480p {
use super::*;
#[test]
fn without_video_reference() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One, false), 13);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One, false), 16);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One, false), 31);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One, false), 47);
}
#[test]
fn with_video_reference() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One, true), 16);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One, true), 20);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One, true), 40);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One, true), 59);
}
}
mod pricing_720p {
use super::*;
#[test]
fn without_video_reference() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One, false), 33);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One, false), 42);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One, false), 83);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One, false), 124);
}
#[test]
fn with_video_reference() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One, true), 40);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One, true), 50);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One, true), 99);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One, true), 149);
}
}
// ── Credits (mini bills fractional credits, rounded for the integer field) ──
mod credits_tests {
use super::*;
#[test]
fn credits_480p() {
// 7.5 credits/s; odd durations are fractional (37.5 → 38, 112.5 → 113).
assert_eq!(credits(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One, false), 30);
assert_eq!(credits(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One, false), 38);
assert_eq!(credits(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One, false), 75);
assert_eq!(credits(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One, false), 113);
}
#[test]
fn credits_720p() {
assert_eq!(credits(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One, false), 100);
assert_eq!(credits(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One, false), 300);
}
}
// ── Relative pricing ──
mod relative_pricing_tests {
use super::*;
#[test]
fn cost_480p_cheaper_than_720p() {
let c480 = usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One, false);
let c720 = usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One, false);
assert!(c480 < c720, "480p ({c480}) should be cheaper than 720p ({c720})");
}
#[test]
fn video_reference_costs_more() {
for res in [KinoviOutputResolution::FourEightyP, KinoviOutputResolution::SevenTwentyP] {
for dur in [4u8, 5, 10, 15] {
let without = usd_cents(res, dur, KinoviBatchCount::One, false);
let with = usd_cents(res, dur, KinoviBatchCount::One, true);
assert!(with > without, "video ref should cost more at {res:?} {dur}s");
}
}
}
#[test]
fn cost_scales_with_duration_and_batch() {
let c4 = usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One, false);
let c15 = usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One, false);
assert!(c4 < c15);
let b1 = usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One, false);
let b4 = usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four, false);
assert!(b1 < b4);
}
}
// ── from_request() ──
mod from_request_tests {
use super::*;
#[test]
fn from_request_720p() {
let req = make_request_state(Some(KinoviOutputResolution::SevenTwentyP), 5, KinoviBatchCount::One, false);
let cost = KinoviSeedance2p0MiniCostState::from_request(&req);
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::SevenTwentyP)));
assert_eq!(cost.duration_seconds, 5);
assert!(!cost.has_video_reference);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(42));
}
#[test]
fn from_request_480p_with_video_reference() {
let req = make_request_state(Some(KinoviOutputResolution::FourEightyP), 5, KinoviBatchCount::One, true);
let cost = KinoviSeedance2p0MiniCostState::from_request(&req);
assert!(cost.has_video_reference);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(20));
}
#[test]
fn from_request_none_defaults_to_720p() {
let req = make_request_state(None, 5, KinoviBatchCount::One, false);
let cost = KinoviSeedance2p0MiniCostState::from_request(&req);
assert!(cost.resolution.is_none());
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(42));
}
}
// ── from_draft() ──
mod from_draft_tests {
use super::*;
#[test]
fn from_draft_480p() {
let draft = make_draft(5, 1, Some(RouterResolution::FourEightyP), false);
let cost = KinoviSeedance2p0MiniCostState::from_draft(&draft);
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::FourEightyP)));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(16));
}
#[test]
fn from_draft_with_video_reference() {
let draft = make_draft(5, 1, Some(RouterResolution::SevenTwentyP), true);
let cost = KinoviSeedance2p0MiniCostState::from_draft(&draft);
assert!(cost.has_video_reference);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(50));
}
#[test]
fn from_draft_batch_8() {
// Mini supports up to batch 8: 720p 5s × 8 = 800 credits → 80000/243 → 330¢.
let draft = make_draft(5, 8, Some(RouterResolution::SevenTwentyP), false);
let cost = KinoviSeedance2p0MiniCostState::from_draft(&draft);
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::Eight)));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(330));
}
}
// ── Cross-check: from_draft matches from_request ──
mod cross_check_tests {
use super::*;
#[test]
fn draft_and_request_produce_same_cost() {
let draft = make_draft(10, 2, Some(RouterResolution::FourEightyP), true);
let req = make_request_state(Some(KinoviOutputResolution::FourEightyP), 10, KinoviBatchCount::Two, true);
let draft_cost = KinoviSeedance2p0MiniCostState::from_draft(&draft).estimate_cost();
let req_cost = KinoviSeedance2p0MiniCostState::from_request(&req).estimate_cost();
assert_eq!(draft_cost.cost_in_usd_cents, req_cost.cost_in_usd_cents);
assert_eq!(draft_cost.cost_in_credits, req_cost.cost_in_credits);
}
}
// ── Helpers ──
fn usd_cents(
resolution: KinoviOutputResolution,
duration_seconds: u8,
batch_count: KinoviBatchCount,
has_video_reference: bool,
) -> u64 {
KinoviSeedance2p0MiniCostState {
resolution: Some(resolution),
duration_seconds,
batch_count: Some(batch_count),
has_video_reference,
}
.estimate_cost()
.cost_in_usd_cents
.unwrap()
}
fn credits(
resolution: KinoviOutputResolution,
duration_seconds: u8,
batch_count: KinoviBatchCount,
has_video_reference: bool,
) -> u64 {
KinoviSeedance2p0MiniCostState {
resolution: Some(resolution),
duration_seconds,
batch_count: Some(batch_count),
has_video_reference,
}
.estimate_cost()
.cost_in_credits
.unwrap()
}
/// Build a draft via the builder to test from_draft().
fn make_draft(
duration_seconds: u16,
video_batch_count: u16,
resolution: Option<RouterResolution>,
with_video_ref: bool,
) -> KinoviSeedance2p0MiniDraftState {
let reference_videos = if with_video_ref {
Some(VideoListRef::Urls(vec!["https://example.com/video.mp4".to_string()]))
} else {
None
};
let builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0Mini,
provider: RouterProvider::Seedance2Pro,
resolution,
reference_videos,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
..Default::default()
};
match builder.build2().expect("build2 should succeed") {
VideoGenerationDraftOrRequest::Draft(
VideoGenerationDraftRequest::KinoviSeedance2p0Mini(draft)
) => draft,
_ => panic!("expected KinoviSeedance2p0Mini draft"),
}
}
/// Build a request state for from_request() tests.
fn make_request_state(
resolution: Option<KinoviOutputResolution>,
duration_seconds: u8,
batch_count: KinoviBatchCount,
with_video_ref: bool,
) -> KinoviSeedance2p0MiniRequestState {
let reference_video_urls = if with_video_ref {
Some(vec!["https://cdn.seedance2-pro.com/video.mp4".to_string()])
} else {
None
};
KinoviSeedance2p0MiniRequestState {
request: GenerateSeedance2p0MiniRequest {
prompt: "test".to_string(),
aspect_ratio: None,
output_resolution: resolution,
duration_seconds,
batch_count: Some(batch_count),
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
},
}
}
}
@@ -1,99 +0,0 @@
use seedance2pro_client::generate::video::generate_seedance_2p0_mini::{
GenerateSeedance2p0MiniRequest, KinoviSeedance2p0MiniAspectRatio, KinoviSeedance2p0MiniBitrate,
KinoviSeedance2p0MiniBatchCount, KinoviSeedance2p0MiniOutputResolution,
};
use crate::api::audio_list_ref::AudioListRef;
use crate::api::character_list_ref::CharacterListRef;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::video_list_ref::VideoListRef;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::providers::kinovi::resolve::{
audio_list_ref_into_urls_or_tokens, image_list_ref_into_urls_or_tokens,
resolve_and_upload_list, resolve_and_upload_single, resolve_character_tokens,
video_list_ref_into_urls_or_tokens,
};
use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::request::KinoviSeedance2p0MiniRequestState;
use crate::generate::generate_video::video_generation_draft_context::VideoGenerationDraftContext;
#[derive(Debug, Clone)]
pub struct KinoviSeedance2p0MiniDraftState {
pub prompt: String,
pub aspect_ratio: KinoviSeedance2p0MiniAspectRatio,
pub resolution: Option<KinoviSeedance2p0MiniOutputResolution>,
pub duration_seconds: u8,
pub batch_count: KinoviSeedance2p0MiniBatchCount,
pub bitrate: Option<KinoviSeedance2p0MiniBitrate>,
pub unhandled_request_state: Option<KinoviSeedance2p0MiniRemainingItems>,
}
#[derive(Debug, Clone)]
pub struct KinoviSeedance2p0MiniRemainingItems {
pub start_frame: Option<ImageRef>,
pub end_frame: Option<ImageRef>,
pub reference_images: Option<ImageListRef>,
pub reference_videos: Option<VideoListRef>,
pub reference_audio: Option<AudioListRef>,
pub reference_character_tokens: Option<CharacterListRef>,
}
impl KinoviSeedance2p0MiniDraftState {
pub async fn to_request(
&mut self,
draft_context: &VideoGenerationDraftContext<'_>,
) -> Result<KinoviSeedance2p0MiniRequestState, ArtcraftRouterError> {
let client = draft_context.get_seedance2pro_client_ref()?;
let session = &client.session;
let mut start_frame_url = None;
let mut end_frame_url = None;
let mut reference_image_urls = None;
let mut reference_video_urls = None;
let mut reference_audio_urls = None;
let mut character_ids = None;
if let Some(remaining) = self.unhandled_request_state.take() {
let map = draft_context.media_file_to_artcraft_url_map;
start_frame_url = resolve_and_upload_single(session, remaining.start_frame, map).await?;
end_frame_url = resolve_and_upload_single(session, remaining.end_frame, map).await?;
reference_image_urls = resolve_and_upload_list(
session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map,
).await?;
reference_video_urls = resolve_and_upload_list(
session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map,
).await?;
reference_audio_urls = resolve_and_upload_list(
session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map,
).await?;
character_ids = resolve_character_tokens(
remaining.reference_character_tokens.as_ref(),
draft_context,
)?;
}
let request = GenerateSeedance2p0MiniRequest {
prompt: self.prompt.clone(),
aspect_ratio: Some(self.aspect_ratio),
output_resolution: self.resolution,
duration_seconds: self.duration_seconds,
batch_count: Some(self.batch_count),
start_frame_url,
end_frame_url,
reference_image_urls,
reference_video_urls,
reference_audio_urls,
character_ids,
use_face_blur_hack: None,
bitrate: self.bitrate,
};
Ok(KinoviSeedance2p0MiniRequestState { request })
}
}
@@ -1,4 +0,0 @@
pub mod build;
pub mod draft;
pub mod request;
pub mod cost;
@@ -1,37 +0,0 @@
use seedance2pro_client::generate::video::generate_seedance_2p0_mini::{
generate_seedance_2p0_mini, GenerateSeedance2p0MiniArgs, GenerateSeedance2p0MiniRequest,
};
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_video::generate_video_response::{GenerateVideoResponse, Seedance2proVideoResponsePayload};
#[derive(Debug, Clone)]
pub struct KinoviSeedance2p0MiniRequestState {
/// Final materialized request; ready to fire.
pub request: GenerateSeedance2p0MiniRequest,
}
impl KinoviSeedance2p0MiniRequestState {
pub async fn send(&self, client: &RouterSeedance2ProClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let session = &client.session;
let args = GenerateSeedance2p0MiniArgs {
session,
host_override: None,
request: self.request.clone(),
};
let response = generate_seedance_2p0_mini(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
Ok(GenerateVideoResponse::Seedance2Pro(Seedance2proVideoResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
maybe_order_ids: response.order_ids,
maybe_task_ids: response.task_ids,
}))
}
}
@@ -7,8 +7,6 @@ use crate::generate::generate_video::providers::kinovi::seedance_2p0::cost::Kino
use crate::generate::generate_video::providers::kinovi::seedance_2p0::draft::KinoviSeedance2p0DraftState;
use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::cost::KinoviSeedance2p0FastCostState;
use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::draft::KinoviSeedance2p0FastDraftState;
use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::cost::KinoviSeedance2p0MiniCostState;
use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::draft::KinoviSeedance2p0MiniDraftState;
use crate::generate::generate_video::video_generation_draft_context::VideoGenerationDraftContext;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
@@ -20,7 +18,6 @@ pub enum VideoGenerationDraftRequest {
KinoviHappyHorse1p0(KinoviHappyHorse1p0DraftState),
KinoviSeedance2p0(KinoviSeedance2p0DraftState),
KinoviSeedance2p0Fast(KinoviSeedance2p0FastDraftState),
KinoviSeedance2p0Mini(KinoviSeedance2p0MiniDraftState),
}
impl VideoGenerationDraftRequest {
@@ -30,7 +27,6 @@ impl VideoGenerationDraftRequest {
Self::KinoviHappyHorse1p0(_) => RouterProvider::Seedance2Pro,
Self::KinoviSeedance2p0(_) => RouterProvider::Seedance2Pro,
Self::KinoviSeedance2p0Fast(_) => RouterProvider::Seedance2Pro,
Self::KinoviSeedance2p0Mini(_) => RouterProvider::Seedance2Pro,
}
}
@@ -40,7 +36,6 @@ impl VideoGenerationDraftRequest {
VideoGenerationDraftRequest::KinoviHappyHorse1p0(draft) => Ok(KinoviHappyHorse1p0CostState::from_draft(draft).estimate_cost()),
VideoGenerationDraftRequest::KinoviSeedance2p0(draft) => Ok(KinoviSeedance2p0CostState::from_draft(draft).estimate_cost()),
VideoGenerationDraftRequest::KinoviSeedance2p0Fast(draft) => Ok(KinoviSeedance2p0FastCostState::from_draft(draft).estimate_cost()),
VideoGenerationDraftRequest::KinoviSeedance2p0Mini(draft) => Ok(KinoviSeedance2p0MiniCostState::from_draft(draft).estimate_cost()),
}
}
@@ -60,10 +55,6 @@ impl VideoGenerationDraftRequest {
let result = draft.to_request(&draft_context).await?;
Ok(VideoGenerationRequest::KinoviSeedance2p0Fast(result))
},
VideoGenerationDraftRequest::KinoviSeedance2p0Mini(mut draft) => {
let result = draft.to_request(&draft_context).await?;
Ok(VideoGenerationRequest::KinoviSeedance2p0Mini(result))
},
}
}
}
@@ -47,12 +47,6 @@ use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu::cost
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu::request::ArtcraftSeedance2p0BytePlusUltraRequestState;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_fast::cost::ArtcraftSeedance2p0BytePlusUltraFastCostState;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_fast::request::ArtcraftSeedance2p0BytePlusUltraFastRequestState;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_mini::cost::ArtcraftSeedance2p0MiniCostState;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_mini::request::ArtcraftSeedance2p0MiniRequestState;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_mini::cost::ArtcraftSeedance2p0BytePlusMiniCostState;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_mini::request::ArtcraftSeedance2p0BytePlusMiniRequestState;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_mini::cost::ArtcraftSeedance2p0BytePlusUltraMiniCostState;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_mini::request::ArtcraftSeedance2p0BytePlusUltraMiniRequestState;
use crate::generate::generate_video::providers::artcraft::sora_2::cost::ArtcraftSora2CostState;
use crate::generate::generate_video::providers::artcraft::sora_2::request::ArtcraftSora2RequestState;
use crate::generate::generate_video::providers::artcraft::sora_2_pro::cost::ArtcraftSora2ProCostState;
@@ -113,8 +107,6 @@ use crate::generate::generate_video::providers::kinovi::seedance_2p0::cost::Kino
use crate::generate::generate_video::providers::kinovi::seedance_2p0::request::KinoviSeedance2p0RequestState;
use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::cost::KinoviSeedance2p0FastCostState;
use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::request::KinoviSeedance2p0FastRequestState;
use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::cost::KinoviSeedance2p0MiniCostState;
use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::request::KinoviSeedance2p0MiniRequestState;
#[derive(Clone, Debug)]
pub enum VideoGenerationRequest {
@@ -140,9 +132,6 @@ pub enum VideoGenerationRequest {
ArtcraftSeedance2p0BytePlusFast(ArtcraftSeedance2p0BytePlusFastRequestState),
ArtcraftSeedance2p0BytePlusUltra(ArtcraftSeedance2p0BytePlusUltraRequestState),
ArtcraftSeedance2p0BytePlusUltraFast(ArtcraftSeedance2p0BytePlusUltraFastRequestState),
ArtcraftSeedance2p0Mini(ArtcraftSeedance2p0MiniRequestState),
ArtcraftSeedance2p0BytePlusMini(ArtcraftSeedance2p0BytePlusMiniRequestState),
ArtcraftSeedance2p0BytePlusUltraMini(ArtcraftSeedance2p0BytePlusUltraMiniRequestState),
ArtcraftSora2(ArtcraftSora2RequestState),
ArtcraftSora2Pro(ArtcraftSora2ProRequestState),
ArtcraftVeo2(ArtcraftVeo2RequestState),
@@ -173,7 +162,6 @@ pub enum VideoGenerationRequest {
KinoviHappyHorse1p0(KinoviHappyHorse1p0RequestState),
KinoviSeedance2p0(KinoviSeedance2p0RequestState),
KinoviSeedance2p0Fast(KinoviSeedance2p0FastRequestState),
KinoviSeedance2p0Mini(KinoviSeedance2p0MiniRequestState),
}
impl VideoGenerationRequest {
@@ -202,9 +190,6 @@ impl VideoGenerationRequest {
Self::ArtcraftSeedance2p0BytePlusFast(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedance2p0BytePlusUltra(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedance2p0BytePlusUltraFast(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedance2p0Mini(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedance2p0BytePlusMini(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedance2p0BytePlusUltraMini(_) => RouterProvider::Artcraft,
Self::ArtcraftSora2(_) => RouterProvider::Artcraft,
Self::ArtcraftSora2Pro(_) => RouterProvider::Artcraft,
Self::ArtcraftVeo2(_) => RouterProvider::Artcraft,
@@ -235,7 +220,6 @@ impl VideoGenerationRequest {
Self::KinoviHappyHorse1p0(_) => RouterProvider::Seedance2Pro,
Self::KinoviSeedance2p0(_) => RouterProvider::Seedance2Pro,
Self::KinoviSeedance2p0Fast(_) => RouterProvider::Seedance2Pro,
Self::KinoviSeedance2p0Mini(_) => RouterProvider::Seedance2Pro,
}
}
@@ -264,9 +248,6 @@ impl VideoGenerationRequest {
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusFast(request) => Ok(ArtcraftSeedance2p0BytePlusFastCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltra(request) => Ok(ArtcraftSeedance2p0BytePlusUltraCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltraFast(request) => Ok(ArtcraftSeedance2p0BytePlusUltraFastCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftSeedance2p0Mini(request) => Ok(ArtcraftSeedance2p0MiniCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusMini(request) => Ok(ArtcraftSeedance2p0BytePlusMiniCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltraMini(request) => Ok(ArtcraftSeedance2p0BytePlusUltraMiniCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftSora2(request) => Ok(ArtcraftSora2CostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftSora2Pro(request) => Ok(ArtcraftSora2ProCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftVeo2(request) => Ok(ArtcraftVeo2CostState::from_request(request).estimate_cost()),
@@ -297,7 +278,6 @@ impl VideoGenerationRequest {
VideoGenerationRequest::KinoviHappyHorse1p0(request) => Ok(KinoviHappyHorse1p0CostState::from_request(request).estimate_cost()),
VideoGenerationRequest::KinoviSeedance2p0(request) => Ok(KinoviSeedance2p0CostState::from_request(request).estimate_cost()),
VideoGenerationRequest::KinoviSeedance2p0Fast(request) => Ok(KinoviSeedance2p0FastCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::KinoviSeedance2p0Mini(request) => Ok(KinoviSeedance2p0MiniCostState::from_request(request).estimate_cost()),
}
}
@@ -393,18 +373,6 @@ impl VideoGenerationRequest {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::ArtcraftSeedance2p0Mini(request) => {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusMini(request) => {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltraMini(request) => {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::ArtcraftSora2(request) => {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
@@ -525,10 +493,6 @@ impl VideoGenerationRequest {
let client_ref = client.get_seedance2pro_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::KinoviSeedance2p0Mini(request) => {
let client_ref = client.get_seedance2pro_client_ref()?;
request.send(client_ref).await
},
}
}
}
@@ -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,
@@ -1,85 +0,0 @@
use crate::cost::constants::CREDITS_PER_DOLLAR;
/// The cost of a Kinovi generation that bills FRACTIONAL credits, in both
/// native credits and USD cents.
///
/// This is the fractional-credit counterpart of
/// [`crate::cost::kinovi_generation_cost::KinoviGenerationCost`] (which uses
/// `u64`). Some Kinovi models bill non-integer credits — e.g. Seedance 2.0
/// Mini at 480p bills 7.5 credits/sec, landing on half-credits like 37.5 at
/// odd durations. Any model with a fractional credit rate uses this type;
/// whole-credit models use the cheaper `u64` `KinoviGenerationCost`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct KinoviFractionalGenerationCost {
/// The cost of the generation in Kinovi credits. May be fractional (e.g.
/// 37.5 for Seedance 2.0 Mini 480p at 5 seconds).
pub kinovi_credits: f64,
/// Estimated cost in cents, rounded up to the nearest whole cent.
/// This does not account for discounts, prorations, etc.
pub usd_cents_rounded_up: u64,
/// Estimated cost in cents, rounded down to the nearest whole cent.
pub usd_cents_rounded_down: u64,
/// Estimated cost in cents without rounding (the exact fractional value).
pub usd_cents_fractional: f64,
}
impl KinoviFractionalGenerationCost {
/// Build a cost from a (possibly fractional) Kinovi credit amount, deriving
/// the USD cents via the credit-package rate.
///
/// The conversion is done on integer hundredths-of-a-credit, so the integer
/// cent results are exact (no floating-point drift). Credit amounts are
/// rounded to the nearest hundredth of a credit, which covers every Kinovi
/// rate seen so far (e.g. Mini's half-credits).
pub fn from_kinovi_credits(kinovi_credits: f64) -> Self {
// usd_cents == credits × 100 / CREDITS_PER_DOLLAR == hundredths / CREDITS_PER_DOLLAR.
let hundredths_of_credit = (kinovi_credits * 100.0).round() as u64;
Self {
kinovi_credits,
usd_cents_rounded_up: hundredths_of_credit.div_ceil(CREDITS_PER_DOLLAR),
usd_cents_rounded_down: hundredths_of_credit / CREDITS_PER_DOLLAR,
usd_cents_fractional: hundredths_of_credit as f64 / CREDITS_PER_DOLLAR as f64,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const FLOAT_TOLERANCE: f64 = 1e-9;
#[test]
fn fractional_credits_convert_to_usd() {
// 37.5 credits; 3750/243 = 15.4320 cents.
let cost = KinoviFractionalGenerationCost::from_kinovi_credits(37.5);
assert_eq!(cost.kinovi_credits, 37.5);
assert_eq!(cost.usd_cents_rounded_up, 16);
assert_eq!(cost.usd_cents_rounded_down, 15);
assert!((cost.usd_cents_fractional - (3750.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
#[test]
fn whole_credits_convert_to_usd() {
// 100 credits; 10000/243 = 41.1522 cents.
let cost = KinoviFractionalGenerationCost::from_kinovi_credits(100.0);
assert_eq!(cost.kinovi_credits, 100.0);
assert_eq!(cost.usd_cents_rounded_up, 42);
assert_eq!(cost.usd_cents_rounded_down, 41);
assert!((cost.usd_cents_fractional - (10000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
#[test]
fn rounded_bounds_bracket_the_fractional_value() {
for half_credits in [0u64, 1, 15, 75, 95, 200, 225, 600, 720] {
let credits = half_credits as f64 / 2.0;
let cost = KinoviFractionalGenerationCost::from_kinovi_credits(credits);
assert!(cost.usd_cents_rounded_down as f64 <= cost.usd_cents_fractional);
assert!(cost.usd_cents_fractional <= cost.usd_cents_rounded_up as f64);
assert!(cost.usd_cents_rounded_up - cost.usd_cents_rounded_down <= 1);
}
}
}
@@ -3,11 +3,6 @@ use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
/// The cost of a Kinovi Seedance generation, with the video-reference
/// surcharge broken out from the base price.
///
/// Used by the WHOLE-credit Seedance video models (regular + Fast). Seedance
/// 2.0 Mini bills fractional credits and has its own `f64` twin,
/// [`crate::cost::kinovi_seedance_mini_generation_cost::KinoviSeedanceMiniGenerationCost`]
/// — see that file for why they aren't merged.
///
/// `total_cost` covers base + surcharge. NB: the total's USD conversions are
/// computed from the SUMMED credits (rounded once), so they may differ by a
/// cent from adding the parts' rounded USD values together.
@@ -1,91 +0,0 @@
use crate::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
// ─────────────────────────────────────────────────────────────────────────────
// WHY THIS TYPE EXISTS (don't merge it into KinoviSeedanceGenerationCost):
//
// This is the Mini twin of `KinoviSeedanceGenerationCost`. It is structurally
// identical (base + optional video-reference surcharge = total) and prices the
// SAME way conceptually — but its credits are fractional
// (`KinoviFractionalGenerationCost`, `f64`) rather than whole
// (`KinoviGenerationCost`, `u64`).
//
// Seedance 2.0 Mini at 480p bills 7.5 credits/sec, so odd durations land on
// FRACTIONAL credits (5s = 37.5, 15s = 112.5). Every OTHER whole-credit
// Seedance video model uses `KinoviSeedanceGenerationCost` (u64 credits).
//
// Making the shared `u64` credit type fractional would ripple `f64` through all
// of those whole-credit models AND `artcraft_router` (which stores credits as
// `u64`), forcing hundreds of integer-literal edits for one model's benefit.
// Keeping Mini's fractional credits in their own small type is the cheaper,
// better-isolated choice. The inner `KinoviFractionalGenerationCost` is generic
// (not Mini-specific) — other fractional-credit models can reuse it.
// ─────────────────────────────────────────────────────────────────────────────
/// The cost of a Kinovi Seedance 2.0 Mini generation, with the video-reference
/// surcharge broken out from the base price.
///
/// Mirrors [`crate::cost::kinovi_seedance_generation_cost::KinoviSeedanceGenerationCost`],
/// but with FRACTIONAL credits (see the module note above).
///
/// `total_cost` covers base + surcharge. NB: the total's USD conversions are
/// computed from the SUMMED credits (rounded once), so they may differ by a
/// cent from adding the parts' rounded USD values together.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct KinoviSeedanceMiniGenerationCost {
/// The full cost (base + any surcharge), in credits and USD cents.
pub total_cost: KinoviFractionalGenerationCost,
/// The base generation cost (resolution rate × output duration × batch
/// count), excluding any surcharges.
pub base_cost: KinoviFractionalGenerationCost,
/// The video-reference surcharge, when one or more reference videos are
/// attached. `None` when no reference videos are attached.
pub video_reference_surcharge_cost: Option<KinoviFractionalGenerationCost>,
}
impl KinoviSeedanceMiniGenerationCost {
/// Build a cost from base credits plus an optional video-reference
/// surcharge (both in Kinovi credits, possibly fractional). The per-part
/// and total USD conversions are derived.
pub fn from_base_and_surcharge(
base_credits: f64,
maybe_video_reference_surcharge_credits: Option<f64>,
) -> Self {
let total_credits = base_credits + maybe_video_reference_surcharge_credits.unwrap_or(0.0);
Self {
total_cost: KinoviFractionalGenerationCost::from_kinovi_credits(total_credits),
base_cost: KinoviFractionalGenerationCost::from_kinovi_credits(base_credits),
video_reference_surcharge_cost: maybe_video_reference_surcharge_credits
.map(KinoviFractionalGenerationCost::from_kinovi_credits),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn without_surcharge_total_equals_base() {
let cost = KinoviSeedanceMiniGenerationCost::from_base_and_surcharge(37.5, None);
assert!(cost.video_reference_surcharge_cost.is_none());
assert_eq!(cost.base_cost.kinovi_credits, 37.5);
assert_eq!(cost.total_cost, cost.base_cost);
}
#[test]
fn with_surcharge_sums_into_total() {
// 480p 5s with video ref: 37.5 base + 10 surcharge = 47.5 total.
let cost = KinoviSeedanceMiniGenerationCost::from_base_and_surcharge(37.5, Some(10.0));
assert_eq!(cost.base_cost.kinovi_credits, 37.5);
assert_eq!(
cost.video_reference_surcharge_cost.map(|c| c.kinovi_credits),
Some(10.0),
);
assert_eq!(cost.total_cost.kinovi_credits, 47.5);
// 47.5 credits; 4750/243 = 19.5473 → 20¢.
assert_eq!(cost.total_cost.usd_cents_rounded_up, 20);
assert_eq!(cost.total_cost.usd_cents_rounded_down, 19);
}
}
@@ -1,5 +1,3 @@
pub mod constants;
pub mod kinovi_fractional_generation_cost;
pub mod kinovi_generation_cost;
pub mod kinovi_seedance_generation_cost;
pub mod kinovi_seedance_mini_generation_cost;
@@ -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 ──
@@ -54,7 +51,6 @@ pub enum KinoviSeedance2p0OutputResolution {
FourEightyP,
SevenTwentyP,
TenEightyP,
FourK,
}
#[derive(Debug, Clone, Copy)]
@@ -64,11 +60,6 @@ pub enum KinoviSeedance2p0BatchCount {
Four,
}
#[derive(Debug, Clone, Copy)]
pub enum KinoviSeedance2p0Bitrate {
High,
}
// ── Pricing ──
//
// Seedance 2.0 Pro credit pricing:
@@ -78,7 +69,6 @@ pub enum KinoviSeedance2p0Bitrate {
// | 480p | 15 |
// | 720p | 40 |
// | 1080p | 90 |
// | 4K | 200 |
//
// Default resolution (None) is 720p.
// Batch count multiplies the total cost.
@@ -95,7 +85,6 @@ impl GenerateSeedance2p0Request {
Some(KinoviSeedance2p0OutputResolution::FourEightyP) => 15,
Some(KinoviSeedance2p0OutputResolution::SevenTwentyP) | None => 40,
Some(KinoviSeedance2p0OutputResolution::TenEightyP) => 90,
Some(KinoviSeedance2p0OutputResolution::FourK) => 200,
};
// Video-reference surcharge, billed per second of OUTPUT duration
@@ -106,7 +95,6 @@ impl GenerateSeedance2p0Request {
// | 480p | 4 |
// | 720p | 8 |
// | 1080p | 18 |
// | 4K | 40 |
//
// NB: Assumed flat per generation regardless of how many reference
// videos are attached (Kinovi's pricing page only shows one).
@@ -115,7 +103,6 @@ impl GenerateSeedance2p0Request {
Some(KinoviSeedance2p0OutputResolution::FourEightyP) => 4,
Some(KinoviSeedance2p0OutputResolution::SevenTwentyP) | None => 8,
Some(KinoviSeedance2p0OutputResolution::TenEightyP) => 18,
Some(KinoviSeedance2p0OutputResolution::FourK) => 40,
}
} else {
0
@@ -191,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 {
@@ -227,7 +213,6 @@ fn map_output_resolution(res: KinoviSeedance2p0OutputResolution) -> KinoviOutput
KinoviSeedance2p0OutputResolution::FourEightyP => KinoviOutputResolutionRaw::FourEightyP,
KinoviSeedance2p0OutputResolution::SevenTwentyP => KinoviOutputResolutionRaw::SevenTwentyP,
KinoviSeedance2p0OutputResolution::TenEightyP => KinoviOutputResolutionRaw::TenEightyP,
KinoviSeedance2p0OutputResolution::FourK => KinoviOutputResolutionRaw::FourK,
}
}
@@ -239,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)]
@@ -278,7 +256,6 @@ mod tests {
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
}
}
@@ -294,141 +271,6 @@ mod tests {
build_request(dur, Some(KinoviSeedance2p0OutputResolution::TenEightyP), None)
}
/// A 4K request with NO video reference.
fn r4k(dur: u8) -> GenerateSeedance2p0Request {
build_request(dur, Some(KinoviSeedance2p0OutputResolution::FourK), None)
}
/// A 4K request WITH a video reference (adds the per-second surcharge).
fn r4k_with_video_ref(dur: u8) -> GenerateSeedance2p0Request {
let mut request = r4k(dur);
request.reference_video_urls = Some(vec!["https://example.com/ref.mp4".to_string()]);
request
}
fn total_credits(request: &GenerateSeedance2p0Request) -> u64 {
request.calculate_costs().total_cost.kinovi_credits
}
fn total_usd_cents(request: &GenerateSeedance2p0Request) -> u64 {
request.calculate_costs().total_cost.usd_cents_rounded_up
}
// ── 4K pricing (Seedance 2.0 only) ──
//
// Base: 200 credits/sec. With a video reference: 240 credits/sec
// (200 base + 40/sec surcharge). Every duration × video-ref combination
// is asserted below against Kinovi's published 4K numbers.
mod four_k_pricing {
use super::*;
// ── No video reference: 200 credits/sec ──
#[test]
fn four_k_no_video_ref_4_seconds_is_800_credits() {
assert_eq!(total_credits(&r4k(4)), 800);
}
#[test]
fn four_k_no_video_ref_5_seconds_is_1000_credits() {
assert_eq!(total_credits(&r4k(5)), 1000);
}
#[test]
fn four_k_no_video_ref_10_seconds_is_2000_credits() {
assert_eq!(total_credits(&r4k(10)), 2000);
}
#[test]
fn four_k_no_video_ref_15_seconds_is_3000_credits() {
assert_eq!(total_credits(&r4k(15)), 3000);
}
// ── With video reference: 240 credits/sec (200 base + 40 surcharge) ──
#[test]
fn four_k_with_video_ref_4_seconds_is_960_credits() {
assert_eq!(total_credits(&r4k_with_video_ref(4)), 960);
}
#[test]
fn four_k_with_video_ref_5_seconds_is_1200_credits() {
assert_eq!(total_credits(&r4k_with_video_ref(5)), 1200);
}
#[test]
fn four_k_with_video_ref_10_seconds_is_2400_credits() {
assert_eq!(total_credits(&r4k_with_video_ref(10)), 2400);
}
#[test]
fn four_k_with_video_ref_15_seconds_is_3600_credits() {
assert_eq!(total_credits(&r4k_with_video_ref(15)), 3600);
}
// ── Total cost in USD cents — no video reference ──
//
// 200 credits/sec; usd_cents = ceil(total_credits / 243 * 100).
#[test]
fn four_k_no_video_ref_4_seconds_is_330_cents() {
assert_eq!(total_usd_cents(&r4k(4)), 330);
}
#[test]
fn four_k_no_video_ref_5_seconds_is_412_cents() {
assert_eq!(total_usd_cents(&r4k(5)), 412);
}
#[test]
fn four_k_no_video_ref_10_seconds_is_824_cents() {
assert_eq!(total_usd_cents(&r4k(10)), 824);
}
#[test]
fn four_k_no_video_ref_15_seconds_is_1235_cents() {
assert_eq!(total_usd_cents(&r4k(15)), 1235);
}
// ── Total cost in USD cents — with video reference ──
//
// 240 credits/sec (200 base + 40 surcharge); usd_cents = ceil(total_credits / 243 * 100).
#[test]
fn four_k_with_video_ref_4_seconds_is_396_cents() {
assert_eq!(total_usd_cents(&r4k_with_video_ref(4)), 396);
}
#[test]
fn four_k_with_video_ref_5_seconds_is_494_cents() {
assert_eq!(total_usd_cents(&r4k_with_video_ref(5)), 494);
}
#[test]
fn four_k_with_video_ref_10_seconds_is_988_cents() {
assert_eq!(total_usd_cents(&r4k_with_video_ref(10)), 988);
}
#[test]
fn four_k_with_video_ref_15_seconds_is_1482_cents() {
assert_eq!(total_usd_cents(&r4k_with_video_ref(15)), 1482);
}
// ── Base / surcharge breakdown (5 seconds) ──
#[test]
fn four_k_with_video_ref_breaks_base_and_surcharge_apart() {
let costs = r4k_with_video_ref(5).calculate_costs();
assert_eq!(costs.base_cost.kinovi_credits, 1000); // 5s × 200
assert_eq!(
costs.video_reference_surcharge_cost.as_ref().map(|c| c.kinovi_credits),
Some(200), // 5s × 40
);
assert_eq!(costs.total_cost.kinovi_credits, 1200);
}
}
// ── Comprehensive per-resolution coverage ──
//
// Seedance 2.0: 480p 15/s (+4/s video ref), 720p 40/s (+8/s), 1080p 90/s (+18/s).
@@ -968,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,
@@ -976,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};
@@ -1023,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);
@@ -1054,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);
@@ -1088,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);
@@ -1120,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);
@@ -1156,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);
@@ -1189,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);
@@ -1226,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);
@@ -1234,42 +1053,6 @@ mod tests {
assert_eq!(1, 2);
Ok(())
}
/// 4K with image references (Seedance 2.0 only), 5-second clip.
#[tokio::test]
#[ignore]
async fn test_4k_image_references() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let img1 = upload_test_image(&session, test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL).await?;
let img2 = upload_test_image(&session, test_data::web::image_urls::ERNEST_SCARED_STUPID_IMAGE_URL).await?;
let img3 = upload_test_image(&session, test_data::web::image_urls::FOREST_BACKDROP_IMAGE_URL).await?;
let result = generate_seedance_2p0(GenerateSeedance2p0Args {
session: &session,
host_override: None,
request: GenerateSeedance2p0Request {
prompt: "The dog in @1 explores the scenery in @3 and meets the friendly man in @2. Cinematic 4K detail.".to_string(),
aspect_ratio: Some(KinoviSeedance2p0AspectRatio::Landscape16x9),
output_resolution: Some(KinoviSeedance2p0OutputResolution::FourK),
batch_count: None,
duration_seconds: 5,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: Some(vec![img1, img2, img3]),
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
},
}).await?;
println!("4K image ref — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert!(!result.order_id.is_empty());
assert_eq!(1, 2, "Inspect output above");
Ok(())
}
}
mod video_reference {
@@ -1299,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);
@@ -1334,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);
@@ -1365,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);
@@ -1,755 +0,0 @@
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::cost::kinovi_seedance_mini_generation_cost::KinoviSeedanceMiniGenerationCost;
use crate::requests::kinovi_host::KinoviHost;
use crate::requests::workflow_run_task::workflow_run_task::{
workflow_run_task, KinoviAspectRatioRaw, KinoviBatchCountRaw, KinoviBitrateRaw,
KinoviModelTypeRaw, KinoviOutputResolutionRaw, WorkflowRunTaskArgs,
WorkflowRunTaskRequest,
};
// ── Args ──
pub struct GenerateSeedance2p0MiniArgs<'a> {
pub request: GenerateSeedance2p0MiniRequest,
pub session: &'a Seedance2ProSession,
pub host_override: Option<KinoviHost>,
}
// ── Request ──
#[derive(Clone, Debug)]
pub struct GenerateSeedance2p0MiniRequest {
pub prompt: String,
pub aspect_ratio: Option<KinoviSeedance2p0MiniAspectRatio>,
pub output_resolution: Option<KinoviSeedance2p0MiniOutputResolution>,
pub duration_seconds: u8,
pub batch_count: Option<KinoviSeedance2p0MiniBatchCount>,
pub start_frame_url: Option<String>,
pub end_frame_url: Option<String>,
pub reference_image_urls: Option<Vec<String>>,
pub reference_video_urls: Option<Vec<String>>,
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 "normal"; `High` requests a
/// higher bitrate. Does not affect cost.
pub bitrate: Option<KinoviSeedance2p0MiniBitrate>,
}
// ── Enums ──
#[derive(Debug, Clone, Copy)]
pub enum KinoviSeedance2p0MiniAspectRatio {
Landscape16x9,
UltraWide21x9,
Portrait9x16,
Square1x1,
Standard4x3,
Portrait3x4,
}
/// Output resolution. Mini supports only 480p and 720p (no 1080p / 4K).
#[derive(Debug, Clone, Copy)]
pub enum KinoviSeedance2p0MiniOutputResolution {
FourEightyP,
SevenTwentyP,
}
/// Number of videos to generate in one request. Mini allows 1–8.
#[derive(Debug, Clone, Copy)]
pub enum KinoviSeedance2p0MiniBatchCount {
One,
Two,
Three,
Four,
Five,
Six,
Seven,
Eight,
}
#[derive(Debug, Clone, Copy)]
pub enum KinoviSeedance2p0MiniBitrate {
High,
}
// ── Pricing ──
//
// Seedance 2.0 Mini credit pricing (per second of OUTPUT duration):
//
// | Resolution | Credits/sec | Video-ref surcharge/sec |
// |------------|-------------|-------------------------|
// | 480p | 7.5 | 2 |
// | 720p | 20 | 4 |
//
// 480p credits can be FRACTIONAL (7.5/sec lands on half-credits at odd
// durations, e.g. 5s = 37.5). Default resolution (None) is 720p. Batch count
// multiplies the total. Attaching reference VIDEOS adds the surcharge;
// reference images and audio are free.
//
// Credit package: 525,000 credits for $2,159.0909 (~243 credits/$1).
impl GenerateSeedance2p0MiniRequest {
/// Calculate the cost of this generation request, in Kinovi credits
/// (possibly fractional) and USD cents.
pub fn calculate_costs(&self) -> KinoviSeedanceMiniGenerationCost {
let credits_per_second: f64 = match self.output_resolution {
Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP) => 7.5,
Some(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP) | None => 20.0,
};
let video_reference_surcharge_per_second: f64 = if self.has_video_reference() {
match self.output_resolution {
Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP) => 2.0,
Some(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP) | None => 4.0,
}
} else {
0.0
};
let batch_multiplier = f64::from(self.batch_multiplier());
let duration = f64::from(self.duration_seconds);
let base_credits = duration * credits_per_second * batch_multiplier;
let maybe_video_reference_surcharge_credits = if self.has_video_reference() {
Some(duration * video_reference_surcharge_per_second * batch_multiplier)
} else {
None
};
KinoviSeedanceMiniGenerationCost::from_base_and_surcharge(
base_credits,
maybe_video_reference_surcharge_credits,
)
}
fn has_video_reference(&self) -> bool {
self.reference_video_urls
.as_ref()
.is_some_and(|urls| !urls.is_empty())
}
fn batch_multiplier(&self) -> u8 {
match self.batch_count {
None | Some(KinoviSeedance2p0MiniBatchCount::One) => 1,
Some(KinoviSeedance2p0MiniBatchCount::Two) => 2,
Some(KinoviSeedance2p0MiniBatchCount::Three) => 3,
Some(KinoviSeedance2p0MiniBatchCount::Four) => 4,
Some(KinoviSeedance2p0MiniBatchCount::Five) => 5,
Some(KinoviSeedance2p0MiniBatchCount::Six) => 6,
Some(KinoviSeedance2p0MiniBatchCount::Seven) => 7,
Some(KinoviSeedance2p0MiniBatchCount::Eight) => 8,
}
}
}
// ── Response ──
pub struct GenerateSeedance2p0MiniResponse {
pub task_id: String,
pub order_id: String,
pub task_ids: Option<Vec<String>>,
pub order_ids: Option<Vec<String>>,
}
// ── Entry point ──
pub async fn generate_seedance_2p0_mini(
args: GenerateSeedance2p0MiniArgs<'_>,
) -> Result<GenerateSeedance2p0MiniResponse, Seedance2ProError> {
let raw_response = workflow_run_task(WorkflowRunTaskArgs {
request: to_raw_request(args.request),
session: args.session,
host_override: args.host_override,
}).await?;
Ok(GenerateSeedance2p0MiniResponse {
task_id: raw_response.task_id,
order_id: raw_response.order_id,
task_ids: raw_response.task_ids,
order_ids: raw_response.order_ids,
})
}
// ── Mapping helpers ──
fn to_raw_request(req: GenerateSeedance2p0MiniRequest) -> WorkflowRunTaskRequest {
WorkflowRunTaskRequest {
model_type: KinoviModelTypeRaw::Seedance2Mini,
prompt: req.prompt,
aspect_ratio: map_aspect_ratio(req.aspect_ratio),
output_resolution: req.output_resolution.map(map_output_resolution),
batch_count: map_batch_count(req.batch_count),
duration_seconds: req.duration_seconds,
start_frame_url: req.start_frame_url,
end_frame_url: req.end_frame_url,
reference_image_urls: req.reference_image_urls,
reference_video_urls: req.reference_video_urls,
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),
}
}
fn map_aspect_ratio(ar: Option<KinoviSeedance2p0MiniAspectRatio>) -> KinoviAspectRatioRaw {
match ar {
Some(KinoviSeedance2p0MiniAspectRatio::Landscape16x9) => KinoviAspectRatioRaw::Landscape16x9,
Some(KinoviSeedance2p0MiniAspectRatio::UltraWide21x9) => KinoviAspectRatioRaw::UltraWide21x9,
Some(KinoviSeedance2p0MiniAspectRatio::Portrait9x16) => KinoviAspectRatioRaw::Portrait9x16,
Some(KinoviSeedance2p0MiniAspectRatio::Square1x1) => KinoviAspectRatioRaw::Square1x1,
Some(KinoviSeedance2p0MiniAspectRatio::Standard4x3) => KinoviAspectRatioRaw::Landscape4x3,
Some(KinoviSeedance2p0MiniAspectRatio::Portrait3x4) => KinoviAspectRatioRaw::Portrait3x4,
None => KinoviAspectRatioRaw::Landscape16x9,
}
}
fn map_output_resolution(res: KinoviSeedance2p0MiniOutputResolution) -> KinoviOutputResolutionRaw {
match res {
KinoviSeedance2p0MiniOutputResolution::FourEightyP => KinoviOutputResolutionRaw::FourEightyP,
KinoviSeedance2p0MiniOutputResolution::SevenTwentyP => KinoviOutputResolutionRaw::SevenTwentyP,
}
}
fn map_batch_count(bc: Option<KinoviSeedance2p0MiniBatchCount>) -> KinoviBatchCountRaw {
match bc {
Some(KinoviSeedance2p0MiniBatchCount::One) | None => KinoviBatchCountRaw::One,
Some(KinoviSeedance2p0MiniBatchCount::Two) => KinoviBatchCountRaw::Two,
Some(KinoviSeedance2p0MiniBatchCount::Three) => KinoviBatchCountRaw::Three,
Some(KinoviSeedance2p0MiniBatchCount::Four) => KinoviBatchCountRaw::Four,
Some(KinoviSeedance2p0MiniBatchCount::Five) => KinoviBatchCountRaw::Five,
Some(KinoviSeedance2p0MiniBatchCount::Six) => KinoviBatchCountRaw::Six,
Some(KinoviSeedance2p0MiniBatchCount::Seven) => KinoviBatchCountRaw::Seven,
Some(KinoviSeedance2p0MiniBatchCount::Eight) => KinoviBatchCountRaw::Eight,
}
}
fn map_bitrate(bitrate: Option<KinoviSeedance2p0MiniBitrate>) -> Option<KinoviBitrateRaw> {
match bitrate {
Some(KinoviSeedance2p0MiniBitrate::High) => Some(KinoviBitrateRaw::High),
None => None,
}
}
// ── Tests ──
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::test_utils::get_test_cookies::get_test_cookies;
use crate::test_utils::setup_test_logging::setup_test_logging;
use errors::AnyhowResult;
use log::LevelFilter;
mod pricing_tests {
use super::*;
const FLOAT_TOLERANCE: f64 = 1e-9;
fn build_request(
duration_seconds: u8,
output_resolution: Option<KinoviSeedance2p0MiniOutputResolution>,
batch_count: Option<KinoviSeedance2p0MiniBatchCount>,
) -> GenerateSeedance2p0MiniRequest {
GenerateSeedance2p0MiniRequest {
prompt: String::new(),
aspect_ratio: None,
output_resolution,
batch_count,
duration_seconds,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
}
}
fn r480(dur: u8) -> GenerateSeedance2p0MiniRequest {
build_request(dur, Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP), None)
}
fn r720(dur: u8) -> GenerateSeedance2p0MiniRequest {
build_request(dur, None, None)
}
fn with_video_ref(mut request: GenerateSeedance2p0MiniRequest) -> GenerateSeedance2p0MiniRequest {
request.reference_video_urls = Some(vec!["https://example.com/ref.mp4".to_string()]);
request
}
// ── Full pricing table (credits) ──
//
// The exact table provided by Kinovi for Seedance 2.0 Mini. 480p produces
// fractional credits at odd durations (37.5, 112.5, …).
mod kinovi_pricing_table {
use super::*;
#[test]
fn table_480p_without_video_reference() {
assert_eq!(r480(4).calculate_costs().total_cost.kinovi_credits, 30.0);
assert_eq!(r480(5).calculate_costs().total_cost.kinovi_credits, 37.5);
assert_eq!(r480(10).calculate_costs().total_cost.kinovi_credits, 75.0);
assert_eq!(r480(15).calculate_costs().total_cost.kinovi_credits, 112.5);
}
#[test]
fn table_720p_without_video_reference() {
assert_eq!(r720(4).calculate_costs().total_cost.kinovi_credits, 80.0);
assert_eq!(r720(5).calculate_costs().total_cost.kinovi_credits, 100.0);
assert_eq!(r720(10).calculate_costs().total_cost.kinovi_credits, 200.0);
assert_eq!(r720(15).calculate_costs().total_cost.kinovi_credits, 300.0);
}
#[test]
fn table_480p_with_video_reference() {
assert_eq!(with_video_ref(r480(4)).calculate_costs().total_cost.kinovi_credits, 38.0);
assert_eq!(with_video_ref(r480(5)).calculate_costs().total_cost.kinovi_credits, 47.5);
assert_eq!(with_video_ref(r480(10)).calculate_costs().total_cost.kinovi_credits, 95.0);
assert_eq!(with_video_ref(r480(15)).calculate_costs().total_cost.kinovi_credits, 142.5);
}
#[test]
fn table_720p_with_video_reference() {
assert_eq!(with_video_ref(r720(4)).calculate_costs().total_cost.kinovi_credits, 96.0);
assert_eq!(with_video_ref(r720(5)).calculate_costs().total_cost.kinovi_credits, 120.0);
assert_eq!(with_video_ref(r720(10)).calculate_costs().total_cost.kinovi_credits, 240.0);
assert_eq!(with_video_ref(r720(15)).calculate_costs().total_cost.kinovi_credits, 360.0);
}
}
// ── Base / surcharge breakdown ──
mod video_reference_surcharge {
use super::*;
/// The surcharge is the difference between the with-ref total and the
/// base: 480p +2/s, 720p +4/s.
#[test]
fn surcharge_table() {
// (request builder, duration, base credits, surcharge credits)
let cases: &[(fn(u8) -> GenerateSeedance2p0MiniRequest, u8, f64, f64)] = &[
// 480p: 7.5/s base, +2/s surcharge
(r480, 4, 30.0, 8.0),
(r480, 5, 37.5, 10.0),
(r480, 10, 75.0, 20.0),
(r480, 15, 112.5, 30.0),
// 720p: 20/s base, +4/s surcharge
(r720, 4, 80.0, 16.0),
(r720, 5, 100.0, 20.0),
(r720, 10, 200.0, 40.0),
(r720, 15, 300.0, 60.0),
];
for (make, duration, base, surcharge) in cases {
let costs = with_video_ref(make(*duration)).calculate_costs();
assert_eq!(costs.base_cost.kinovi_credits, *base, "base at {duration}s");
assert_eq!(
costs.video_reference_surcharge_cost.map(|c| c.kinovi_credits),
Some(*surcharge),
"surcharge at {duration}s",
);
assert_eq!(costs.total_cost.kinovi_credits, base + surcharge, "total at {duration}s");
}
}
#[test]
fn no_video_reference_has_no_surcharge() {
let costs = r480(5).calculate_costs();
assert!(costs.video_reference_surcharge_cost.is_none());
assert_eq!(costs.total_cost, costs.base_cost);
}
#[test]
fn empty_video_reference_list_has_no_surcharge() {
let mut request = r720(5);
request.reference_video_urls = Some(vec![]);
let costs = request.calculate_costs();
assert_eq!(costs.total_cost.kinovi_credits, 100.0);
assert!(costs.video_reference_surcharge_cost.is_none());
}
/// The surcharge applies per generated video, so batches multiply it.
#[test]
fn batch_multiplies_surcharge() {
let request = with_video_ref(build_request(
5,
Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP),
Some(KinoviSeedance2p0MiniBatchCount::Two),
));
// (37.5 base + 10 surcharge) × 2
let costs = request.calculate_costs();
assert_eq!(costs.base_cost.kinovi_credits, 75.0);
assert_eq!(costs.video_reference_surcharge_cost.map(|c| c.kinovi_credits), Some(20.0));
assert_eq!(costs.total_cost.kinovi_credits, 95.0);
}
}
// ── USD cents conversion ──
mod usd_cents {
use super::*;
#[test]
fn fractional_480p_5s() {
// 37.5 credits; 3750/243 = 15.43 → 16¢ up, 15¢ down.
let costs = r480(5).calculate_costs();
assert_eq!(costs.total_cost.kinovi_credits, 37.5);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 16);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 15);
assert!((costs.total_cost.usd_cents_fractional - (3750.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
#[test]
fn whole_720p_5s() {
// 100 credits; 10000/243 = 41.15 → 42¢ up, 41¢ down.
let costs = r720(5).calculate_costs();
assert_eq!(costs.total_cost.kinovi_credits, 100.0);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 42);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 41);
assert!((costs.total_cost.usd_cents_fractional - (10000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
#[test]
fn fractional_480p_15s_with_video_reference() {
// 142.5 credits; 14250/243 = 58.64 → 59¢ up, 58¢ down.
let costs = with_video_ref(r480(15)).calculate_costs();
assert_eq!(costs.total_cost.kinovi_credits, 142.5);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 59);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 58);
assert!((costs.total_cost.usd_cents_fractional - (14250.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
}
// ── Batch multiplier ──
mod batch_tests {
use super::*;
#[test]
fn batch_scales_total_linearly() {
let base = r720(5).calculate_costs().total_cost.kinovi_credits;
let by_count = [
(KinoviSeedance2p0MiniBatchCount::One, 1.0),
(KinoviSeedance2p0MiniBatchCount::Two, 2.0),
(KinoviSeedance2p0MiniBatchCount::Three, 3.0),
(KinoviSeedance2p0MiniBatchCount::Four, 4.0),
(KinoviSeedance2p0MiniBatchCount::Five, 5.0),
(KinoviSeedance2p0MiniBatchCount::Six, 6.0),
(KinoviSeedance2p0MiniBatchCount::Seven, 7.0),
(KinoviSeedance2p0MiniBatchCount::Eight, 8.0),
];
for (count, multiplier) in by_count {
let credits = build_request(5, None, Some(count)).calculate_costs().total_cost.kinovi_credits;
assert_eq!(credits, base * multiplier, "batch {count:?}");
}
}
#[test]
fn batch_eight_480p_with_video_reference() {
let costs = with_video_ref(build_request(
5,
Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP),
Some(KinoviSeedance2p0MiniBatchCount::Eight),
)).calculate_costs();
// (37.5 + 10) × 8 = 380
assert_eq!(costs.total_cost.kinovi_credits, 380.0);
}
}
// ── Defaults & invariants ──
#[test]
fn default_resolution_is_720p() {
let default = r720(5).calculate_costs().total_cost.kinovi_credits;
let explicit = build_request(5, Some(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP), None)
.calculate_costs().total_cost.kinovi_credits;
assert_eq!(default, explicit);
assert_eq!(default, 100.0);
}
#[test]
fn cost_scales_with_duration() {
let c4 = r720(4).calculate_costs().total_cost.kinovi_credits;
let c10 = r720(10).calculate_costs().total_cost.kinovi_credits;
let c15 = r720(15).calculate_costs().total_cost.kinovi_credits;
assert!(c4 < c10);
assert!(c10 < c15);
}
#[test]
fn resolution_480p_cheaper_than_720p() {
for dur in 4..=15u8 {
let c480 = r480(dur).calculate_costs().total_cost.kinovi_credits;
let c720 = r720(dur).calculate_costs().total_cost.kinovi_credits;
assert!(c480 < c720, "480p should be cheaper than 720p at {dur}s");
}
}
#[test]
fn aspect_ratio_does_not_affect_credits() {
let baseline = r720(5).calculate_costs().total_cost.kinovi_credits;
let ratios = [
KinoviSeedance2p0MiniAspectRatio::Landscape16x9,
KinoviSeedance2p0MiniAspectRatio::UltraWide21x9,
KinoviSeedance2p0MiniAspectRatio::Portrait9x16,
KinoviSeedance2p0MiniAspectRatio::Square1x1,
KinoviSeedance2p0MiniAspectRatio::Standard4x3,
KinoviSeedance2p0MiniAspectRatio::Portrait3x4,
];
for ar in ratios {
let mut req = r720(5);
req.aspect_ratio = Some(ar);
assert_eq!(req.calculate_costs().total_cost.kinovi_credits, baseline, "{ar:?}");
}
}
#[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(KinoviSeedance2p0MiniBitrate::High);
assert_eq!(high.calculate_costs().total_cost.kinovi_credits, baseline);
}
}
// ── Request shape (mapping to the raw Kinovi request) ──
mod request_shape_tests {
use super::*;
fn sample() -> GenerateSeedance2p0MiniRequest {
GenerateSeedance2p0MiniRequest {
prompt: "a corgi".to_string(),
aspect_ratio: Some(KinoviSeedance2p0MiniAspectRatio::Standard4x3),
output_resolution: Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP),
duration_seconds: 10,
batch_count: Some(KinoviSeedance2p0MiniBatchCount::Eight),
start_frame_url: None,
end_frame_url: None,
reference_image_urls: Some(vec!["https://example.com/a.png".to_string()]),
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
bitrate: Some(KinoviSeedance2p0MiniBitrate::High),
}
}
#[test]
fn maps_to_mini_model_type() {
let raw = to_raw_request(sample());
assert!(matches!(raw.model_type, KinoviModelTypeRaw::Seedance2Mini));
}
#[test]
fn maps_resolution_aspect_and_batch() {
let raw = to_raw_request(sample());
assert!(matches!(raw.aspect_ratio, KinoviAspectRatioRaw::Landscape4x3));
assert!(matches!(raw.output_resolution, Some(KinoviOutputResolutionRaw::FourEightyP)));
assert!(matches!(raw.batch_count, KinoviBatchCountRaw::Eight));
assert!(matches!(raw.bitrate, Some(KinoviBitrateRaw::High)));
}
#[test]
fn passes_through_prompt_duration_and_references() {
let raw = to_raw_request(sample());
assert_eq!(raw.prompt, "a corgi");
assert_eq!(raw.duration_seconds, 10);
assert_eq!(raw.reference_image_urls, Some(vec!["https://example.com/a.png".to_string()]));
}
#[test]
fn default_resolution_maps_to_none() {
let mut req = sample();
req.output_resolution = None;
let raw = to_raw_request(req);
assert!(raw.output_resolution.is_none());
}
#[test]
fn all_batch_counts_map() {
let expected: &[(KinoviSeedance2p0MiniBatchCount, u8)] = &[
(KinoviSeedance2p0MiniBatchCount::One, 1),
(KinoviSeedance2p0MiniBatchCount::Two, 2),
(KinoviSeedance2p0MiniBatchCount::Three, 3),
(KinoviSeedance2p0MiniBatchCount::Four, 4),
(KinoviSeedance2p0MiniBatchCount::Five, 5),
(KinoviSeedance2p0MiniBatchCount::Six, 6),
(KinoviSeedance2p0MiniBatchCount::Seven, 7),
(KinoviSeedance2p0MiniBatchCount::Eight, 8),
];
for (count, n) in expected {
let mut req = sample();
req.batch_count = Some(*count);
let raw = to_raw_request(req);
// The mapped raw batch matches the requested count's multiplier.
let mut probe = build_probe();
probe.batch_count = Some(*count);
assert_eq!(probe.batch_multiplier(), *n, "{count:?}");
// And the raw enum is the corresponding variant.
assert!(!matches!(raw.model_type, KinoviModelTypeRaw::Seedance2Pro));
}
}
fn build_probe() -> GenerateSeedance2p0MiniRequest {
GenerateSeedance2p0MiniRequest {
prompt: String::new(),
aspect_ratio: None,
output_resolution: None,
duration_seconds: 5,
batch_count: None,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
}
}
}
// ── Live API tests (manual; require Kinovi cookies) ──
fn test_session() -> AnyhowResult<Seedance2ProSession> {
let cookies = get_test_cookies()?;
Ok(Seedance2ProSession::from_cookies_string(cookies))
}
mod live {
use super::*;
#[tokio::test]
#[ignore]
async fn test_text_to_video_720p() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_seedance_2p0_mini(GenerateSeedance2p0MiniArgs {
session: &session,
host_override: None,
request: GenerateSeedance2p0MiniRequest {
prompt: "A giant evil teddy bear steps on people in the city.".to_string(),
aspect_ratio: Some(KinoviSeedance2p0MiniAspectRatio::Landscape16x9),
output_resolution: None,
batch_count: None,
duration_seconds: 5,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
},
}).await?;
println!("mini t2v 720p — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert!(!result.order_id.is_empty());
assert_eq!(1, 2);
Ok(())
}
#[tokio::test]
#[ignore]
async fn test_text_to_video_480p() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_seedance_2p0_mini(GenerateSeedance2p0MiniArgs {
session: &session,
host_override: None,
request: GenerateSeedance2p0MiniRequest {
prompt: "A snowman fights a bear".to_string(),
aspect_ratio: Some(KinoviSeedance2p0MiniAspectRatio::Standard4x3),
output_resolution: Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP),
batch_count: None,
duration_seconds: 10,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
},
}).await?;
println!("mini t2v 480p — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2);
Ok(())
}
#[tokio::test]
#[ignore]
async fn test_reference_to_video_720p() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_seedance_2p0_mini(GenerateSeedance2p0MiniArgs {
session: &session,
host_override: None,
request: GenerateSeedance2p0MiniRequest {
prompt: "Girl walks around in the city, giving a historical tour".to_string(),
aspect_ratio: Some(KinoviSeedance2p0MiniAspectRatio::UltraWide21x9),
output_resolution: None,
batch_count: None,
duration_seconds: 15,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: Some(vec![
"https://static.seedance2-pro.com/materials/20260624/1782342102687-53fe5496.png".to_string(),
]),
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
},
}).await?;
println!("mini reference-to-video 720p — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2);
Ok(())
}
#[tokio::test]
#[ignore]
async fn test_video_reference_480p() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_seedance_2p0_mini(GenerateSeedance2p0MiniArgs {
session: &session,
host_override: None,
request: GenerateSeedance2p0MiniRequest {
prompt: "Change @video1 to night time.".to_string(),
aspect_ratio: Some(KinoviSeedance2p0MiniAspectRatio::Landscape16x9),
output_resolution: Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP),
batch_count: None,
duration_seconds: 5,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: Some(vec![
"https://static.seedance2-pro.com/materials/20260315/1773594284659-3a46d231.mp4".to_string(),
]),
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
},
}).await?;
println!("mini video ref 480p — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2);
Ok(())
}
}
}
@@ -1,4 +1,3 @@
pub mod generate_happy_horse_1p0;
pub mod generate_seedance_2p0;
pub mod generate_seedance_2p0_fast;
pub mod generate_seedance_2p0_mini;
@@ -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>,
}
@@ -22,27 +22,12 @@ pub (super) struct BatchRequestJson {
#[derive(Serialize, Debug)]
pub (super) struct ApiParams {
pub prompt: String,
/// Aspect ratio as pixel dimensions (e.g. "1280x720"). Most Kinovi
/// models carry the aspect ratio here. None for models that use the
/// `aspectRatio` field instead (Seedance 2.0 Mini).
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Aspect ratio as a ratio string (e.g. "16:9"). Used by Seedance 2.0
/// Mini instead of `resolution`. None for models that use `resolution`.
#[serde(rename = "aspectRatio", skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<&'static str>,
pub resolution: String,
#[serde(rename = "contentMode")]
pub content_mode: &'static str,
pub model: &'static str,
pub duration: String,
/// Generation mode ("keyframe" / "reference"). Omitted for models that use
/// `happyhorseMode` instead (Happy Horse).
#[serde(skip_serializing_if = "Option::is_none")]
pub mode: Option<&'static str>,
/// Happy Horse generation mode ("t2v" / "i2v"). Used instead of `mode` by
/// Happy Horse; None for every other model.
#[serde(rename = "happyhorseMode", skip_serializing_if = "Option::is_none")]
pub happyhorse_mode: Option<&'static str>,
pub mode: &'static str,
#[serde(rename = "outputResolution", skip_serializing_if = "Option::is_none")]
pub output_resolution: Option<&'static str>,
#[serde(rename = "faceBlurMode", skip_serializing_if = "Option::is_none")]
@@ -55,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)]
@@ -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,
@@ -74,9 +74,6 @@ fn video_model_to_common_model_type(model: RouterVideoModel) -> Option<CommonMod
RouterVideoModel::Seedance2p0UltraFast => Some(CommonModelType::Seedance2p0UltraFast),
RouterVideoModel::Seedance2p0BytePlusUltra => Some(CommonModelType::Seedance2p0BytePlusUltra),
RouterVideoModel::Seedance2p0BytePlusUltraFast => Some(CommonModelType::Seedance2p0BytePlusUltraFast),
RouterVideoModel::Seedance2p0Mini => Some(CommonModelType::Seedance2p0Mini),
RouterVideoModel::Seedance2p0BytePlusMini => Some(CommonModelType::Seedance2p0BytePlusMini),
RouterVideoModel::Seedance2p0BytePlusUltraMini => Some(CommonModelType::Seedance2p0BytePlusUltraMini),
RouterVideoModel::PreviewModel => Some(CommonModelType::PreviewModel),
RouterVideoModel::PreviewModelFast => Some(CommonModelType::PreviewModelFast),
RouterVideoModel::GrokImagineVideo => Some(CommonModelType::GrokImagineVideo),
@@ -149,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,

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