Compare commits

..
Author SHA1 Message Date
Hanashi 7d3955efe7 artcraft router 2026-08-04 05:15:49 -04:00
Hanashi 85d263cff8 add minimax to fal 2026-08-04 04:42:27 -04:00
hanashi 60b4148179 web + API for omni gen endpoints (#1836) 2026-08-03 16:22:16 -04:00
hanashi a4d9a4d199 this fixes another set of negative margins (#1834) 2026-08-02 18:22:04 -04:00
hanashi 2b8cc5ef8d margins for video references were costing us money, fixing (#1833) 2026-08-02 17:29:06 -04:00
hanashi 9122be6752 we're losing money on seedance in this configuration (need to fix) (#1832) 2026-08-02 17:02:49 -04:00
hanashi 31054141cc costs for seedance 2.5 are costing us money (#1831)
we're losing money on this model, gotta plug it (there are other holes
to fix too)
2026-08-02 15:58:08 -04:00
BFlat 6b1b4c7b0d temporarily hide downloads (#1830) 2026-08-02 10:57:54 +07:00
BFlat 5f46ee0e7e Added missing model names & icons mappings (#1829)
- Added missing display names for Seedance 2.5 Preview and ~25 other
models (Vidu Q3, Veo 3.1 Lite, Seedream 5.0 Pro, new Hunyuan 3D 3.1
variants, Marble 1.x, and more) so the task queue and history show
proper names instead of raw IDs.
- Vidu, Recraft, and SeedEdit models now show their brand icons instead
of the generic one.
2026-08-02 07:47:43 +07:00
BFlat 9cdad367c0 model selector 2.5 coming soon removal and reference deplicated tokens bug fix (#1828)
## Fixes & improvements

### Model picker
- Removed the "Seedance 2.5 - SOON" placeholder from the video model
picker; it was confusing now that Seedance 2.5 Preview is live.

### "Not all media files could be found" error
- Fixed generations failing when the same media file ended up twice in
one reference list (e.g. added as two references, or the same file
uploaded twice).
- Requests now automatically drop duplicate media references before
sending - applies to image, video, audio, mesh, and splat generation on
both the web app and the desktop app.

### Gallery picker UX
- Media already added to a slot is now greyed out with an "Added" badge
in the library picker, so the same file can't be added twice to the same
field.
- Applies per slot across the create pages (video, image, audio, 3D
object, world) on web and desktop - references, start frame, end frame,
video refs, audio refs, and multi-view slots.
- Reusing the same image across different slots (e.g. start *and* end
frame, or two 3D views) is still allowed.
2026-08-02 07:32:41 +07:00
hanashi c026a7c572 seedance 2.5 preview in Omni API (#1827) 2026-08-01 16:07:05 -04:00
hanashi 62051aa49f create material (#1826) 2026-08-01 10:48:19 -04:00
hanashi a71a4b9613 seedance2p5 (#1825) 2026-07-31 18:12:27 -04:00
hanashi 9737f0ef88 rename client (#1824) 2026-07-31 15:10:30 -04:00
371 changed files with 7214 additions and 3989 deletions
-5
View File
@@ -1,8 +1,3 @@
# Shell scripts and dotenv config files are executed/parsed on Linux (WSL2, CI)
# even when the repo is cloned on Windows — they must stay LF in the worktree.
*.sh text eol=lf
*.env text eol=lf
# Ignore certain code for Github language statistics
# https://github.com/github/linguist/blob/master/docs/overrides.md
old_code/trumpet-trumped.com/dictionary/cmudict-0.7b linguist-vendored
-3
View File
@@ -104,6 +104,3 @@ windows/sqlite/sqlite3.lib
crates/desktop/src-tauri/*.dll
windows_dlls/
# Portable local dev stack (script/bootstrap) - MySQL/Redis binaries, data, logs
.devstack/
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n users.token as `user_token: UserToken`,\n users.username,\n users.display_name,\n users.email_address,\n users.user_role_slug,\n users.is_banned,\n user_roles.can_ban_users\nFROM user_sessions\nINNER JOIN users\n ON users.token = user_sessions.user_token\nLEFT OUTER JOIN user_roles\n ON users.user_role_slug = user_roles.slug\nWHERE user_sessions.token = ?\n AND user_sessions.deleted_at IS NULL\n AND users.user_deleted_at IS NULL\n AND users.mod_deleted_at IS NULL\nLIMIT 1\n ",
"query": "\nSELECT\n users.token as `user_token: UserToken`,\n users.username,\n users.display_name,\n users.email_address,\n users.user_role_slug,\n users.is_banned,\n user_roles.can_ban_users,\n users.maybe_feature_flags,\n api_keys.token as `api_key_token: ApiKeyToken`\nFROM api_keys\nINNER JOIN users\n ON users.token = api_keys.owner_user_token\nLEFT OUTER JOIN user_roles\n ON users.user_role_slug = user_roles.slug\nWHERE api_keys.api_key = ?\n AND api_keys.maybe_deleted_at IS NULL\n AND users.user_deleted_at IS NULL\n AND users.mod_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [
{
@@ -72,6 +72,26 @@
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 7,
"name": "maybe_feature_flags",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 8,
"name": "api_key_token: ApiKeyToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
}
],
"parameters": {
@@ -84,8 +104,10 @@
false,
false,
false,
true
true,
true,
false
]
},
"hash": "c77126adb36f9e2f0248720eb4d8c1cd9d14719f64a02678d8021e26addd2c18"
}
"hash": "62b3e2e058331a6819acd98c634b3bf2d10aa38d43b49ab69fb8637497beec2f"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n users.token as `user_token: UserToken`,\n users.username,\n users.display_name,\n users.email_address,\n users.user_role_slug,\n users.is_banned,\n user_roles.can_ban_users,\n api_keys.token as `api_key_token: ApiKeyToken`\nFROM api_keys\nINNER JOIN users\n ON users.token = api_keys.owner_user_token\nLEFT OUTER JOIN user_roles\n ON users.user_role_slug = user_roles.slug\nWHERE api_keys.api_key = ?\n AND api_keys.maybe_deleted_at IS NULL\n AND users.user_deleted_at IS NULL\n AND users.mod_deleted_at IS NULL\nLIMIT 1\n ",
"query": "\nSELECT\n users.token as `user_token: UserToken`,\n users.username,\n users.display_name,\n users.email_address,\n users.user_role_slug,\n users.is_banned,\n user_roles.can_ban_users,\n users.maybe_feature_flags\nFROM user_sessions\nINNER JOIN users\n ON users.token = user_sessions.user_token\nLEFT OUTER JOIN user_roles\n ON users.user_role_slug = user_roles.slug\nWHERE user_sessions.token = ?\n AND user_sessions.deleted_at IS NULL\n AND users.user_deleted_at IS NULL\n AND users.mod_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [
{
@@ -75,12 +75,12 @@
},
{
"ordinal": 7,
"name": "api_key_token: ApiKeyToken",
"name": "maybe_feature_flags",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
"max_size": 1020
}
}
],
@@ -95,8 +95,8 @@
false,
false,
true,
false
true
]
},
"hash": "9188e235caa439d50628333c2f4722b9f1a8f575c192d4d36a2039709c412ac0"
}
"hash": "cf2794c87868e77dfaa45bdbe630ae276dbb8c2d041442ba465af0b7a6d61af6"
}
Generated
+27 -27
View File
@@ -757,9 +757,9 @@ dependencies = [
"fal_client",
"gmicloud_client",
"grok_api_client",
"kinovi_web_client",
"log",
"reqwest 0.12.23",
"seedance2pro_client",
"serde",
"serde_derive",
"serde_json",
@@ -6359,6 +6359,29 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "kinovi_web_client"
version = "0.0.1"
dependencies = [
"chrono",
"cloudflare_errors",
"env",
"env_logger 0.11.9",
"errors",
"log",
"rand 0.9.2",
"serde",
"serde_derive",
"serde_json",
"test_data",
"test_utils",
"tokio",
"url",
"workspace-build-acceleration",
"wreq",
"wreq-util",
]
[[package]]
name = "kubectl-pod-cleanup"
version = "0.0.1"
@@ -10519,6 +10542,7 @@ dependencies = [
"errors",
"hashing",
"jobs_common",
"kinovi_web_client",
"log",
"mysql_queries",
"pager",
@@ -10526,7 +10550,6 @@ dependencies = [
"reqwest 0.12.23",
"rootly_client",
"rootly_config",
"seedance2pro_client",
"serde",
"serde_derive",
"serde_json",
@@ -10538,29 +10561,6 @@ dependencies = [
"workspace-build-acceleration",
]
[[package]]
name = "seedance2pro_client"
version = "0.0.1"
dependencies = [
"chrono",
"cloudflare_errors",
"env",
"env_logger 0.11.9",
"errors",
"log",
"rand 0.9.2",
"serde",
"serde_derive",
"serde_json",
"test_data",
"test_utils",
"tokio",
"url",
"workspace-build-acceleration",
"wreq",
"wreq-util",
]
[[package]]
name = "selectors"
version = "0.24.0"
@@ -11632,6 +11632,7 @@ dependencies = [
"images",
"infer 0.16.0",
"jwt_signer",
"kinovi_web_client",
"language-tags",
"lexical-sort",
"limitation",
@@ -11663,7 +11664,6 @@ dependencies = [
"rootly_config",
"rust-s3",
"rustls 0.23.36",
"seedance2pro_client",
"serde",
"serde_derive",
"serde_json",
@@ -11867,8 +11867,8 @@ dependencies = [
"easyenv",
"enums",
"errors",
"kinovi_web_client",
"log",
"seedance2pro_client",
"serde_json",
"tokens",
"tokio",
+2 -2
View File
@@ -27,7 +27,7 @@ members = [
"crates/api_clients/openart_client",
"crates/api_clients/rootly_client",
"crates/api_clients/runwayml_client",
"crates/api_clients/seedance2pro_client",
"crates/api_clients/kinovi_web_client",
"crates/api_clients/thumbnail_generator",
"crates/api_clients/worldlabs_api_client",
"crates/api_clients/worldlabs_consumer_client",
@@ -285,7 +285,7 @@ mysql_queries = { path = "crates/schema/database/mysql_queries" }
pager = { path = "crates/lib/pager" }
openai_sora_client = { path = "crates/api_clients/openai_sora_client" }
primitives = { path = "crates/lib/data/primitives" }
seedance2pro_client = { path = "crates/api_clients/seedance2pro_client" }
kinovi_web_client = { path = "crates/api_clients/kinovi_web_client" }
server_environment = { path = "crates/lib/server_environment" }
sha1_hash = { path = "crates/lib/sha1_hash" }
shared_service_components = { path = "crates/lib/shared_service_components" }
-293
View File
@@ -1,293 +0,0 @@
# Local Dev Stack — Requirements & Bootstrap
How to run `storyteller-web` on your own machine against a local MySQL and
Redis — no shared dev database, no Docker, no admin rights. This is for **work
on the backend itself**.
> **Working on the frontend?** You probably want
> `frontend/apps/fake-storyteller-web` instead — an in-memory stand-in for this
> API that needs no database, no build, and no setup. Everything below is
> unnecessary for webapp work. Come back here when you need the real server.
These scripts install and supervise infrastructure. They change no application
behaviour, and nothing in them is compiled into any binary: the backend they
start is the same one that ships. Integrations they cannot provide locally
(object storage, generation providers, email) get placeholder credentials, so
the endpoints that use them fail when exercised. Swap in a real dev credential
for whichever one you're actually working on.
The automated path on **Windows** (primary platform, native — no WSL, no
Docker, no admin rights for the stack itself):
```powershell
.\script\bootstrap\windows\bootstrap_dev_stack.ps1 # one-time setup, idempotent, re-run anytime
.\script\bootstrap\windows\run_backend_dev.ps1 # terminal 1: API on http://localhost:12345
.\script\bootstrap\windows\seed_demo_user.ps1 # terminal 2: create demo login (localdev1 / localdev1pass)
$env:VITE_DEV_API_HOST = "http://localhost:12345" # terminal 2: aim the webapp at your backend
cd frontend; npx nx dev artcraft-webapp # webapp on http://localhost:4201
.\script\bootstrap\windows\dev_stack_doctor.ps1 # health check, any time
.\script\bootstrap\windows\stop_dev_services.ps1 # stop MySQL/Redis when done
```
The equivalent on **Linux** (Ubuntu 22.04+ — native, WSL2, or CI):
```bash
./script/bootstrap/bootstrap_dev_stack.sh # one-time setup, idempotent, re-run anytime
./script/bootstrap/run_backend_dev.sh # terminal 1: API on http://localhost:12345
./script/bootstrap/seed_demo_user.sh # terminal 2: create demo login (localdev1 / localdev1pass)
cd frontend && VITE_DEV_API_HOST=http://localhost:12345 npx nx dev artcraft-webapp # terminal 2: webapp on :4201
./script/bootstrap/dev_stack_doctor.sh # health check, any time
```
This document records the underlying requirements so the scripts aren't a
black box, and corrects some stale claims in older docs.
## Supported platforms
| Platform | Status |
|---------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Windows 10/11 | Primary. `script/bootstrap/windows/*.ps1` run a **portable stack**: MySQL 8.4 (official zip) and Redis live under the gitignored `.devstack/` and run as plain user processes — no admin, no services, no WSL, no Docker. Build tools (cmake, perl, nasm, llvm) are offered via winget |
| Ubuntu 22.04+ | Supported by `script/bootstrap/*.sh` (native, WSL2, or CI runner/container); services via apt |
| macOS | Not covered by the scripts; follow `_docs/dev_setup_server.md` manually |
### Windows specifics
- Everything stateful lives in `.devstack/` (gitignored): `mysql/` (extracted
zip), `mysql-data/`, `redis/`, `logs/`, `pids/`, `downloads/` (cached zips),
`my.ini`. Delete the directory to fully reset; re-run bootstrap to rebuild.
- Pinned services: MySQL **8.4.11** from `cdn.mysql.com` (the 8.x series is
required; only some patch versions exist on the CDN) and Redis **5.0.14.1**
(tporadowski build — old but sufficient for the backend's keepalive/TTL/
rate-limit usage; override `REDIS_ZIP_URL` to use Memurai/Garnet instead).
- The portable MySQL root user has **no password** (`--initialize-insecure`) —
local dev only, bound to 127.0.0.1.
- diesel-cli is compiled against the portable MySQL's own client library
(`MYSQLCLIENT_LIB_DIR`/`MYSQLCLIENT_VERSION`); `libmysql.dll` must be on
`PATH` to *run* it — the scripts handle both.
- Compiling the backend needs cmake, Strawberry Perl, NASM, and LLVM/libclang
(the `wreq` HTTP client builds BoringSSL, and `aws-lc-sys`/`ring` need
NASM/clang on MSVC). The bootstrap checks and offers winget installs.
- The generated secrets file additionally sets `TEMP_DIR` (the upload temp-dir
helper defaults to `/tmp`, which doesn't exist on Windows).
## Requirements matrix
| Requirement | Version / detail | Needed for |
|-------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------|
| MySQL server | 8.x series (9.x unsupported by tooling) | Backend boot (eager connect) + migrations |
| Redis server | any recent | Backend boot (r2d2 pool connects eagerly) |
| Rust (stable) | no pinned MSRV; Docker builds use 1.93.0 | Building `storyteller-web`, diesel_cli |
| diesel_cli | `cargo install diesel_cli --no-default-features --features mysql,sqlite` | Running the 281 migrations |
| Node.js | 20+ (enforced by `script/common/frontend_preflight.sh`) | Frontend (Nx 21 / Vite 6) |
| npm | ships with Node; **pnpm must not be used** | Frontend install (`frontend/package-lock.json`) |
| System libs (apt) | build-essential, cmake, libssl-dev, libclang-dev, libfontconfig1-dev, fontconfig, perl, pkg-config, ffmpeg, libmysqlclient-dev, libsqlite3-dev, curl, jq | Compiling the workspace + diesel_cli; seeding scripts |
| Elasticsearch | optional (7.x) | Only 4 search endpoints; server boots without it |
| Docker | **not required** — no docker-compose exists | — |
Database identity (used consistently by docs, `.env`, and defaults):
database `storyteller`, user `storyteller`, password `password`, host
`localhost`.
## Backend: what `storyteller-web` needs to boot
Binary: `storyteller-web` in `crates/service/web/storyteller_web`
(binaries: `storyteller-web`, `docs` — Swagger UI on :8989, `docs-cli` — emits
`api.json`). Binds `0.0.0.0:12345` (`BIND_ADDRESS`), plain HTTP.
**Run it from the repo root** — the config search path and `includes/` paths
are cwd-relative.
### Config loading (corrected facts)
- Mode is selected by the `SERVER_ENVIRONMENT` **process env var only**
(default: Development). Setting it inside an env file does nothing — it is
read before any file loads.
- In Development mode, files load from
`crates/service/web/storyteller_web/config/` in this order:
`storyteller-web.common.env` → `storyteller-web.development.env` →
`storyteller-web.development-secrets.env` (gitignored; the bootstrap
generates it).
- **Precedence is first-value-wins** (dotenv never overrides an existing key):
real process env > common.env > development.env > development-secrets.env.
The secrets file therefore cannot override anything already set in
`development.env`.
- The repo-root `.env` is **NOT loaded by the server** (it passes
`ignore_legacy_dot_env_file: true`). Root `.env`'s `DATABASE_URL` feeds
diesel-cli/sqlx-cli only; the server reads **`MYSQL_URL`** (default
`mysql://storyteller:password@localhost/storyteller`).
### Hard requirements at boot
| Requirement | Behavior if missing |
|-------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------|
| MySQL reachable via `MYSQL_URL` | Boot fails (sqlx pool connects eagerly; the DB must exist and be migrated — health-check and ban-poller threads query immediately) |
| Redis reachable via `REDIS_0_URL` (default `redis://localhost/0`) | Boot fails (r2d2 pool connects eagerly) |
| ~30 `get_env_string_required` vars present | Boot fails on the first absent var |
Of the required-present vars, 19 are supplied by **no** checked-in file and
live in the generated secrets file: `ACCESS_KEY`, `SECRET_KEY`, `REGION_NAME`,
`W2L_PRIVATE_DOWNLOAD_BUCKET_NAME`, `W2L_PUBLIC_DOWNLOAD_BUCKET_NAME`,
`RESEND_API_KEY`, `FAL_API_KEY`, `GMICLOUD_API_KEY`, `GROK_API_KEY`,
`BEEBLE_API_KEY`, `OPENAI_API_KEY`, `WORLDLABS_API_KEY`,
`SEEDANCE2PRO_COOKIES`, `SEEDANCE2PRO_WHITELIST_COOKIES`,
`SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES`, `STRIPE_FAKEYOU_ACCOUNT_ID`,
`STRIPE_ARTCRAFT_ACCOUNT_ID`, `STRIPE_ARTCRAFT_SECRET_KEY`,
`STRIPE_ARTCRAFT_SECRET_WEBHOOK_KEY`.
**Placeholder values are safe at boot**: none of these services is contacted
at boot (clients are constructed offline; Elasticsearch, S3/R2, Stripe,
Resend, and Google-cert fetches are all lazy). But two earlier claims here
were wrong and are corrected:
- **The omni_gen endpoints call providers synchronously in the HTTP handler**
(`/v1/omni_gen/generate/{image,video}` — the path the webapp uses). With
placeholder keys the provider call fails and the submit returns HTTP 500
*after* billing the wallet (the image pipeline has no refund; video refunds
only Kinovi). Only the legacy polling providers (GmiCloud, Grok, Kinovi
orders, WorldLabs) are worker-driven.
- **Uploads fail hard with placeholder S3 creds** — every media upload endpoint
uploads to the bucket *before* inserting the DB row, and `rust-s3` is built
with `fail-on-err`.
### What does and doesn't work locally
| Area | Works with placeholder credentials? |
|----------------------------------------------|-------------------------------------|
| Login, sessions, account, profile | Yes |
| Wallets, credits, billing reads | Yes |
| Folders, tags, prompts, characters, projects | Yes |
| Media listing (rows) | Yes — but the images 404, see below |
| Media uploads | No — needs real R2/S3 credentials |
| Image/video/mesh/splat generation | No — needs a real provider key |
| Transactional email | No — needs a real Resend key |
| Elasticsearch-backed search | No — needs a local ES + reindex |
The rows-but-no-images case is the one that surprises people: `media_files`
rows carry a bucket path, and the URL the API builds from it points at the
shared R2 bucket, which does not have your local objects. Put real dev R2
credentials in the secrets file if you need media to render, or use
`fake-storyteller-web`, which serves its own bytes.
### Auth on localhost
- `POST /v1/login` takes `{"username_or_email": "...", "password": "..."}`;
success returns `{"success": true, "signed_session": "<jwt>"}` **and** a
`session` cookie.
- With the checked-in dev config (`COOKIE_DOMAIN=.jungle.horse`), the session
cookie is issued non-Secure and host-only (the Domain attribute is never
set), so plain-HTTP localhost login works as-is. The frontend additionally
stores `signed_session` in localStorage and sends it as a `session` header.
- Dev-mode CORS allows any `localhost`/`127.0.0.1` origin on any port, with
credentials.
- Health endpoint for scripts/CI: `GET /_status`.
### Build
- Compile with `SQLX_OFFLINE=true` (uses the checked-in `.sqlx/` cache; 483
queries). Without it, the sqlx macros open a **live DB connection at compile
time** using `DATABASE_URL` from the root `.env`. `SQLX_OFFLINE` has no
runtime effect — ignore older notes saying not to set it when running.
- First build takes several minutes; the crate has ~370 pre-existing warnings.
## Database: migrations and seed data
- 281 Diesel migrations in `_database/sql/migrations/` (`diesel.toml` at repo
root points there). Run from the repo root: `diesel migration run`.
`"Encountered unknown type for Mysql: enum"` warnings are harmless.
`migrations_squashed/` is documentation only — not executable.
- Seed data: `_database/sql/seed/sql/system_roles.sql` (roles `user`, `mod`,
`admin`) and `user_badges.sql`. **The `user` role is mandatory** — account
creation hardcodes `user_role_slug='user'`. The legacy wrapper
`bootstrap_inserts_roles_etc.sh` must run with cwd `_database/sql/` and is
not idempotent; the bootstrap script applies the same SQL files with
idempotency guards instead.
- Demo user: created through **`POST /v1/create_account`** (no captcha, no
invite code, no email-verification gate; password just needs 6+ chars). This
exercises the real signup path instead of hand-rolled SQL. Gotcha: usernames
like `demo`, `admin`, `test`, `dev`, `artcraft` are reserved
(`includes/binary_includes/usernames/reserved_usernames.txt`) — the default
demo user is `localdev1`. The Rust seeder
(`cargo run --bin dev-database-seed`) exists but hard-requires a
`.env-secrets` file and also seeds model weights, so the HTTP path is
preferred.
- Demo credits: signup creates **no wallet**, and the webapp gates the
generate button on wallet balance — so `seed_demo_user` also seeds an
`artcraft`-namespace wallet with `DEMO_CREDITS` (default 100,000) banked
credits, plus matching ledger rows. Idempotent: re-runs top the balance
back up to `DEMO_CREDITS` but never lower it. (Raw SQL, because the only
real credit-granting paths are Stripe webhooks and moderator tooling.)
- Demo gallery: **not seeded**. Media rows without reachable bytes render as
broken tiles, so seeding them locally would be worse than an empty library.
Use `fake-storyteller-web` for a populated one.
## Frontend: `artcraft-webapp`
- Nx workspace at repo-root `frontend/` (npm workspaces; `npm install` from
`frontend/`). The FontAwesome Pro registry token is committed in
`frontend/.npmrc` — no manual auth step.
- Serve: `npx nx dev artcraft-webapp` → `http://localhost:4201`. No lib
pre-build is needed in dev (libs resolve to TS source via the
`"development"` export condition); production `nx build` does need lib
builds.
- **By default the dev webapp targets production.** `src/main.tsx` points the
API host at the Vite dev origin, and Vite proxies `/v1` to
`https://api.storyteller.ai`. The adjacent `setDevelopment()` call — which
would point it at `localhost:12345` — is commented out. To aim the webapp at
your local backend, set `VITE_DEV_API_HOST=http://localhost:12345` before
`nx dev` (that override ships with the `fake-storyteller-web` change; until
it lands, uncomment `setDevelopment()` locally instead).
- Optional: `VITE_GOOGLE_CLIENT_ID` in `frontend/apps/artcraft-webapp/.env`
enables the Google SSO button; email/password login needs nothing.
- Other dev ports if you run more apps: website 4200, dashboard 4202 (separate
pnpm app, excluded from Nx), video-info-website 4250, genhub 4300, Tauri
frontend 5173.
## What still touches production (known, deliberate seams)
The bootstrap changes no runtime behaviour, so these remain open. Each is
closed by supplying a real credential, not by a code path:
| Seam | Today |
|---------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Media/CDN URLs in API responses | The compiled-in `cdn_link.rs` constants point at the public `pub-….r2.dev` bucket, so a locally-created row's media URL resolves to an object that isn't there |
| Media uploads | 500 with placeholder credentials; supply real dev R2/S3 keys to exercise them |
| Generation | 500 with placeholder keys, *after* the wallet is billed; supply a real key for the provider under test |
| Vite `/v1` proxy in webapp | Targets `https://api.storyteller.ai`, so a dev webapp with no host override reads and writes production |
| Elasticsearch-backed search | `search_featured` / `search_session` media files, `weights/search`, legacy `tts/search` fail without a local ES + `elasticsearch-cli` reindex (optional) |
## CI usage
The same bootstrap works on a fresh Ubuntu runner/container:
```bash
export MYSQL_ROOT_PASSWORD=root # GitHub Actions' preinstalled MySQL uses password auth
./script/bootstrap/bootstrap_dev_stack.sh --yes
./script/bootstrap/run_backend_dev.sh &
./script/bootstrap/seed_demo_user.sh # waits for /_status
./script/bootstrap/dev_stack_doctor.sh
```
`--yes` answers all prompts; `--skip-frontend` / `--skip-rust-build` /
`--skip-packages` slice the work for focused jobs.
## Verification checklist
- `./script/bootstrap/dev_stack_doctor.sh` exits 0 with no FAILs.
- `POST /v1/login` with the demo user returns `success: true` plus a
`signed_session` (done automatically by `seed_demo_user.sh`).
- `GET /_status` returns 200, and MySQL has the full migration set applied.
- With `VITE_DEV_API_HOST` pointed at `localhost:12345`, the webapp logs in as
`localdev1` and shows its credits in the top bar. The library is empty and
generation 500s — both expected, see the table above.
## Corrections to older docs
- `crates/service/web/storyteller_web/CLAUDE.md`: the repo-root `.env` is
*not* auto-loaded by the server, and `SQLX_OFFLINE=true` is fine (and
recommended) when running — it only affects compile-time query checking.
- `_docs/dev_setup_server.md`: `./script/dev_mysql_connect.sh` doesn't exist —
it's `./script/dev_database_connect.sh`.
- `diesel.toml`'s comment about needing an unreleased diesel CLI is stale —
any current `diesel_cli` works.
- `TESTING_INFRA_HANDOFF.md` (untracked): the server env var is `MYSQL_URL`,
not `DATABASE_URL`; migration count is 281; crate path is
`crates/service/web/storyteller_web`.
+2 -2
View File
@@ -51,9 +51,9 @@ helpers go in the parent `mod tests` so sub-modules can `use super::*`.
- `storyteller_web` — main HTTP API monolith (actix-web)
- `artcraft` (desktop) — Tauri desktop app
- `artcraft_router` — provider routing for image/video generation (Artcraft, Fal, Seedance2Pro, Muapi)
- `artcraft_router` — provider routing for image/video generation (Artcraft, Fal, KinoviWeb, Muapi)
- `artcraft_api_defs` — shared HTTP API type definitions
- `seedance2pro_client` — HTTP client for the Kinovi/Seedance2Pro video generation service
- `kinovi_web_client` — HTTP client for the Kinovi/KinoviWeb video generation service
- `enums` — database-backed enums stored as VARCHAR fields
- `tokens` — primary key identifiers with Stripe-like prefixes (e.g. `user_`, `mf_`)
- `mysql_queries` — SQLx MySQL queries for the backend
@@ -11,7 +11,7 @@ pub struct OmniGenVideoGenerateResponse {
/// All job tokens created by this request (including the primary).
/// For single-job requests this will contain just one element matching
/// `inference_job_token`. For batch requests (eg. Seedance2Pro with
/// `inference_job_token`. For batch requests (eg. KinoviWeb with
/// multiple order IDs) this will contain all of them.
pub all_job_tokens: Vec<InferenceJobToken>,
}
@@ -22,7 +22,7 @@ artcraft_client.workspace = true
fal_client.workspace = true
gmicloud_client.workspace = true
grok_api_client.workspace = true
seedance2pro_client.workspace = true
kinovi_web_client.workspace = true
tokens.workspace = true
url_utils.workspace = true
worldlabs_api_client.workspace = true
@@ -8,6 +8,6 @@ pub enum RouterProvider {
Fal,
GmiCloud,
GrokApi,
Seedance2Pro,
KinoviWeb,
WorldLabs,
}
@@ -35,6 +35,9 @@ pub enum RouterVideoModel {
#[serde(rename = "kling_3p0_pro")]
Kling3p0Pro,
#[serde(rename = "minimax_h3")]
MinimaxH3,
#[serde(rename = "seedance_1p0_lite")]
Seedance10Lite,
@@ -74,6 +77,9 @@ pub enum RouterVideoModel {
#[serde(rename = "seedance_2p0_bpu_mini")]
Seedance2p0BytePlusUltraMini,
#[serde(rename = "seedance_2p5_preview")]
Seedance2p5Preview,
#[serde(rename = "happy_horse_1p0")]
HappyHorse1p0,
@@ -133,6 +139,11 @@ mod tests {
assert_serde_round_trip(RouterVideoModel::Seedance2p0BytePlusUltraMini, "seedance_2p0_bpu_mini");
}
#[test]
fn seedance_2p5_preview_serializes() {
assert_serde_round_trip(RouterVideoModel::Seedance2p5Preview, "seedance_2p5_preview");
}
#[test]
fn veo_3p1_lite_and_vidu_variants_serialize() {
// NB: These strings must match `CommonVideoModel` — the two enums convert
@@ -141,4 +152,11 @@ mod tests {
assert_serde_round_trip(RouterVideoModel::ViduQ3, "vidu_q3");
assert_serde_round_trip(RouterVideoModel::ViduQ3Turbo, "vidu_q3_turbo");
}
#[test]
fn minimax_h3_serializes() {
// NB: This string must match `CommonVideoModel` — the two enums convert
// via serde string round-trip in the omni handlers.
assert_serde_round_trip(RouterVideoModel::MinimaxH3, "minimax_h3");
}
}
@@ -7,5 +7,5 @@ pub mod router_client;
pub mod router_fal_client;
pub mod router_gmicloud_client;
pub mod router_grok_api_client;
pub mod router_seedance2pro_client;
pub mod router_kinovi_web_client;
pub mod router_worldlabs_client;
@@ -2,7 +2,7 @@ use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_fal_client::RouterFalClient;
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
use crate::client::router_grok_api_client::RouterGrokApiClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::client::router_worldlabs_client::RouterWorldLabsClient;
use crate::errors::client_error::{ClientError, ClientType};
@@ -11,7 +11,7 @@ pub struct MultiRouterClient {
pub(crate) fal_client: Option<RouterFalClient>,
pub(crate) gmicloud_client: Option<RouterGmiCloudClient>,
pub(crate) grok_api_client: Option<RouterGrokApiClient>,
pub(crate) seedance2pro_client: Option<RouterSeedance2ProClient>,
pub(crate) kinovi_web_client: Option<RouterKinoviWebClient>,
pub(crate) worldlabs_client: Option<RouterWorldLabsClient>,
}
@@ -36,9 +36,9 @@ impl MultiRouterClient {
.ok_or(ClientError::ClientNotConfigured(ClientType::GrokApi))
}
pub fn get_seedance2pro_client_ref(&self) -> Result<&RouterSeedance2ProClient, ClientError> {
self.seedance2pro_client.as_ref()
.ok_or(ClientError::ClientNotConfigured(ClientType::Seedance2Pro))
pub fn get_kinovi_web_client_ref(&self) -> Result<&RouterKinoviWebClient, ClientError> {
self.kinovi_web_client.as_ref()
.ok_or(ClientError::ClientNotConfigured(ClientType::KinoviWeb))
}
pub fn get_worldlabs_client_ref(&self) -> Result<&RouterWorldLabsClient, ClientError> {
@@ -3,7 +3,7 @@ use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_fal_client::RouterFalClient;
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
use crate::client::router_grok_api_client::RouterGrokApiClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::client::router_worldlabs_client::RouterWorldLabsClient;
pub struct MultiRouterClientBuilder {
@@ -11,7 +11,7 @@ pub struct MultiRouterClientBuilder {
fal_client: Option<RouterFalClient>,
gmicloud_client: Option<RouterGmiCloudClient>,
grok_api_client: Option<RouterGrokApiClient>,
seedance2pro_client: Option<RouterSeedance2ProClient>,
kinovi_web_client: Option<RouterKinoviWebClient>,
worldlabs_client: Option<RouterWorldLabsClient>,
}
@@ -22,7 +22,7 @@ impl MultiRouterClientBuilder {
fal_client: None,
gmicloud_client: None,
grok_api_client: None,
seedance2pro_client: None,
kinovi_web_client: None,
worldlabs_client: None,
}
}
@@ -47,8 +47,8 @@ impl MultiRouterClientBuilder {
self
}
pub fn set_seedance2pro_client(mut self, client: RouterSeedance2ProClient) -> Self {
self.seedance2pro_client = Some(client);
pub fn set_kinovi_web_client(mut self, client: RouterKinoviWebClient) -> Self {
self.kinovi_web_client = Some(client);
self
}
@@ -63,7 +63,7 @@ impl MultiRouterClientBuilder {
fal_client: self.fal_client,
gmicloud_client: self.gmicloud_client,
grok_api_client: self.grok_api_client,
seedance2pro_client: self.seedance2pro_client,
kinovi_web_client: self.kinovi_web_client,
worldlabs_client: self.worldlabs_client,
}
}
@@ -3,7 +3,7 @@ use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_fal_client::RouterFalClient;
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
use crate::client::router_grok_api_client::RouterGrokApiClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::client::router_worldlabs_client::RouterWorldLabsClient;
use crate::errors::client_error::{ClientError, ClientType};
@@ -13,7 +13,7 @@ pub enum RouterClient {
Fal(RouterFalClient),
GmiCloud(RouterGmiCloudClient),
GrokApi(RouterGrokApiClient),
Seedance2Pro(RouterSeedance2ProClient),
KinoviWeb(RouterKinoviWebClient),
WorldLabs(RouterWorldLabsClient),
}
@@ -50,11 +50,11 @@ impl RouterClient {
}
}
pub fn get_seedance2pro_client_ref(&self) -> Result<&RouterSeedance2ProClient, ClientError> {
pub fn get_kinovi_web_client_ref(&self) -> Result<&RouterKinoviWebClient, ClientError> {
match self {
RouterClient::Seedance2Pro(client) => Ok(client),
RouterClient::Multi(multi) => multi.get_seedance2pro_client_ref(),
_ => Err(ClientError::ClientNotConfigured(ClientType::Seedance2Pro)),
RouterClient::KinoviWeb(client) => Ok(client),
RouterClient::Multi(multi) => multi.get_kinovi_web_client_ref(),
_ => Err(ClientError::ClientNotConfigured(ClientType::KinoviWeb)),
}
}
@@ -0,0 +1,11 @@
use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession;
pub struct RouterKinoviWebClient {
pub(crate) session: KinoviWebSession,
}
impl RouterKinoviWebClient {
pub fn new(session: KinoviWebSession) -> Self {
RouterKinoviWebClient { session }
}
}
@@ -1,11 +0,0 @@
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
pub struct RouterSeedance2ProClient {
pub(crate) session: Seedance2ProSession,
}
impl RouterSeedance2ProClient {
pub fn new(session: Seedance2ProSession) -> Self {
RouterSeedance2ProClient { session }
}
}
@@ -2,8 +2,8 @@ use crate::errors::client_error::ClientError;
use crate::errors::download_error::DownloadError;
use crate::errors::provider_error::ProviderError;
use fal_client::error::fal_error_plus::FalErrorPlus;
use seedance2pro_client::error::seedance2pro_error::Seedance2ProError;
use seedance2pro_client::error::seedance2pro_specific_api_error::Seedance2ProSpecificApiError;
use kinovi_web_client::error::kinovi_web_error::KinoviWebError;
use kinovi_web_client::error::kinovi_web_specific_api_error::KinoviWebSpecificApiError;
use std::error::Error;
use std::fmt::{Display, Formatter};
use artcraft_client::error::api_error::ApiError;
@@ -65,7 +65,7 @@ impl From<ProviderError> for ArtcraftRouterError {
fn from(error: ProviderError) -> Self {
let is_billing_error = match &error {
ProviderError::Fal(FalErrorPlus::FalBillingError(_)) => true,
ProviderError::Seedance2Pro(Seedance2ProError::ApiSpecific(Seedance2ProSpecificApiError::BillingError { .. })) => true,
ProviderError::KinoviWeb(KinoviWebError::ApiSpecific(KinoviWebSpecificApiError::BillingError { .. })) => true,
ProviderError::Storyteller(StorytellerError::Api(ApiError::PaymentRequired(_))) => true,
_ => false,
};
@@ -9,7 +9,7 @@ pub enum ClientType {
Fal,
GmiCloud,
GrokApi,
Seedance2Pro,
KinoviWeb,
WorldLabs,
}
@@ -20,7 +20,7 @@ impl Display for ClientType {
Self::Fal => write!(f, "Fal"),
Self::GmiCloud => write!(f, "GmiCloud"),
Self::GrokApi => write!(f, "GrokApi"),
Self::Seedance2Pro => write!(f, "Seedance2Pro"),
Self::KinoviWeb => write!(f, "KinoviWeb"),
Self::WorldLabs => write!(f, "WorldLabs"),
}
}
@@ -47,8 +47,8 @@ pub enum ClientError {
/// Fal only accepts image URLs for image inputs, not media tokens.
FalOnlySupportsUrls,
/// Seedance2Pro only accepts URLs for media inputs, not media tokens.
Seedance2ProOnlySupportsUrls,
/// KinoviWeb only accepts URLs for media inputs, not media tokens.
KinoviWebOnlySupportsUrls,
/// The Fal endpoint requires a webhook URL but the caller built the client
/// in polling/queue mode (no webhook URL). Returned by webhook-only
@@ -91,8 +91,8 @@ impl Display for ClientError {
Self::FalOnlySupportsUrls => {
write!(f, "Fal only supports image URLs for image inputs, not media tokens")
}
Self::Seedance2ProOnlySupportsUrls => {
write!(f, "Seedance2Pro only supports URLs for media inputs; resolve media tokens to URLs before calling this provider")
Self::KinoviWebOnlySupportsUrls => {
write!(f, "KinoviWeb only supports URLs for media inputs; resolve media tokens to URLs before calling this provider")
}
Self::WebhookUrlRequired => {
write!(f, "This Fal endpoint only supports webhook dispatch; the caller built RouterFalClient in polling-only mode (no webhook URL)")
@@ -4,7 +4,7 @@ use artcraft_client::error::storyteller_error::StorytellerError;
use fal_client::error::fal_error_plus::FalErrorPlus;
use gmicloud_client::error::gmicloud_error::GmiCloudError;
use grok_api_client::error::grok_error::GrokError;
use seedance2pro_client::error::seedance2pro_error::Seedance2ProError;
use kinovi_web_client::error::kinovi_web_error::KinoviWebError;
use worldlabs_api_client::error::world_labs_error::WorldLabsError;
#[derive(Debug)]
@@ -13,7 +13,7 @@ pub enum ProviderError {
Fal(FalErrorPlus),
GmiCloud(GmiCloudError),
Grok(GrokError),
Seedance2Pro(Seedance2ProError),
KinoviWeb(KinoviWebError),
WorldLabs(WorldLabsError),
}
@@ -26,7 +26,7 @@ impl Display for ProviderError {
Self::Fal(e) => write!(f, "Fal provider error: {}", e),
Self::GmiCloud(e) => write!(f, "GmiCloud provider error: {}", e),
Self::Grok(e) => write!(f, "Grok provider error: {}", e),
Self::Seedance2Pro(e) => write!(f, "Seedance2Pro provider error: {}", e),
Self::KinoviWeb(e) => write!(f, "KinoviWeb provider error: {}", e),
Self::WorldLabs(e) => write!(f, "WorldLabs provider error: {}", e),
}
}
@@ -56,9 +56,9 @@ impl From<GrokError> for ProviderError {
}
}
impl From<Seedance2ProError> for ProviderError {
fn from(error: Seedance2ProError) -> Self {
Self::Seedance2Pro(error)
impl From<KinoviWebError> for ProviderError {
fn from(error: KinoviWebError) -> Self {
Self::KinoviWeb(error)
}
}
@@ -26,8 +26,8 @@ impl AudioGenerationDraftRequest {
pub fn get_provider(&self) -> RouterProvider {
match self {
Self::KinoviSunoRemix(_) => RouterProvider::Seedance2Pro,
Self::KinoviSunoSample(_) => RouterProvider::Seedance2Pro,
Self::KinoviSunoRemix(_) => RouterProvider::KinoviWeb,
Self::KinoviSunoSample(_) => RouterProvider::KinoviWeb,
}
}
@@ -4,7 +4,7 @@ use std::fmt::Debug;
use tokens::tokens::media_files::MediaFileToken;
use crate::client::router_client::RouterClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
@@ -19,9 +19,9 @@ pub struct AudioGenerationDraftContext<'a> {
}
impl <'a> AudioGenerationDraftContext<'a> {
pub fn get_seedance2pro_client_ref(&self) -> Result<&RouterSeedance2ProClient, ArtcraftRouterError> {
pub fn get_kinovi_web_client_ref(&self) -> Result<&RouterKinoviWebClient, ArtcraftRouterError> {
let client = self.client.ok_or(ArtcraftRouterError::Client(ClientError::RouterClientNotProvided))?;
client.get_seedance2pro_client_ref()
client.get_kinovi_web_client_ref()
.map_err(|err| ArtcraftRouterError::Client(err))
}
}
@@ -48,10 +48,10 @@ impl AudioGenerationRequest {
Self::ArtcraftSunoSample(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedAudio1p0(_) => RouterProvider::Artcraft,
Self::FalSeedAudio1p0(_) => RouterProvider::Fal,
Self::KinoviSunoMusic(_) => RouterProvider::Seedance2Pro,
Self::KinoviSunoRemix(_) => RouterProvider::Seedance2Pro,
Self::KinoviSunoSounds(_) => RouterProvider::Seedance2Pro,
Self::KinoviSunoSample(_) => RouterProvider::Seedance2Pro,
Self::KinoviSunoMusic(_) => RouterProvider::KinoviWeb,
Self::KinoviSunoRemix(_) => RouterProvider::KinoviWeb,
Self::KinoviSunoSounds(_) => RouterProvider::KinoviWeb,
Self::KinoviSunoSample(_) => RouterProvider::KinoviWeb,
}
}
@@ -100,19 +100,19 @@ impl AudioGenerationRequest {
request.send(client_ref).await
},
AudioGenerationRequest::KinoviSunoMusic(request) => {
let client_ref = client.get_seedance2pro_client_ref()?;
let client_ref = client.get_kinovi_web_client_ref()?;
request.send(client_ref).await
},
AudioGenerationRequest::KinoviSunoRemix(request) => {
let client_ref = client.get_seedance2pro_client_ref()?;
let client_ref = client.get_kinovi_web_client_ref()?;
request.send(client_ref).await
},
AudioGenerationRequest::KinoviSunoSounds(request) => {
let client_ref = client.get_seedance2pro_client_ref()?;
let client_ref = client.get_kinovi_web_client_ref()?;
request.send(client_ref).await
},
AudioGenerationRequest::KinoviSunoSample(request) => {
let client_ref = client.get_seedance2pro_client_ref()?;
let client_ref = client.get_kinovi_web_client_ref()?;
request.send(client_ref).await
},
}
@@ -116,10 +116,10 @@ impl GenerateAudioRequestBuilder {
// Fal
(RouterProvider::Fal, RouterAudioModel::SeedAudio1p0) => build_fal_seed_audio_1p0(self),
// Kinovi
(RouterProvider::Seedance2Pro, RouterAudioModel::SunoMusic) => build_kinovi_suno_music(self),
(RouterProvider::Seedance2Pro, RouterAudioModel::SunoRemix) => build_kinovi_suno_remix(self),
(RouterProvider::Seedance2Pro, RouterAudioModel::SunoSounds) => build_kinovi_suno_sounds(self),
(RouterProvider::Seedance2Pro, RouterAudioModel::SunoSample) => build_kinovi_suno_sample(self),
(RouterProvider::KinoviWeb, RouterAudioModel::SunoMusic) => build_kinovi_suno_music(self),
(RouterProvider::KinoviWeb, RouterAudioModel::SunoRemix) => build_kinovi_suno_remix(self),
(RouterProvider::KinoviWeb, RouterAudioModel::SunoSounds) => build_kinovi_suno_sounds(self),
(RouterProvider::KinoviWeb, RouterAudioModel::SunoSample) => build_kinovi_suno_sample(self),
_ => self.unsupported_provider_and_model(),
}
}
@@ -202,7 +202,7 @@ mod tests {
#[test]
fn kinovi_suno_music_dispatches_to_request() {
let result = builder(RouterProvider::Seedance2Pro, RouterAudioModel::SunoMusic).build2().expect("build");
let result = builder(RouterProvider::KinoviWeb, RouterAudioModel::SunoMusic).build2().expect("build");
assert!(matches!(
result,
AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::KinoviSunoMusic(_))
@@ -211,7 +211,7 @@ mod tests {
#[test]
fn kinovi_suno_sounds_dispatches_to_request() {
let result = builder(RouterProvider::Seedance2Pro, RouterAudioModel::SunoSounds).build2().expect("build");
let result = builder(RouterProvider::KinoviWeb, RouterAudioModel::SunoSounds).build2().expect("build");
assert!(matches!(
result,
AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::KinoviSunoSounds(_))
@@ -220,7 +220,7 @@ mod tests {
#[test]
fn kinovi_suno_remix_dispatches_to_draft() {
let result = builder_with_audio_ref(RouterProvider::Seedance2Pro, RouterAudioModel::SunoRemix).build2().expect("build");
let result = builder_with_audio_ref(RouterProvider::KinoviWeb, RouterAudioModel::SunoRemix).build2().expect("build");
assert!(matches!(
result,
AudioGenerationDraftOrRequest::Draft(AudioGenerationDraftRequest::KinoviSunoRemix(_))
@@ -229,7 +229,7 @@ mod tests {
#[test]
fn kinovi_suno_sample_dispatches_to_draft() {
let result = builder_with_audio_ref(RouterProvider::Seedance2Pro, RouterAudioModel::SunoSample).build2().expect("build");
let result = builder_with_audio_ref(RouterProvider::KinoviWeb, RouterAudioModel::SunoSample).build2().expect("build");
assert!(matches!(
result,
AudioGenerationDraftOrRequest::Draft(AudioGenerationDraftRequest::KinoviSunoSample(_))
@@ -242,7 +242,7 @@ mod tests {
#[test]
fn kinovi_seed_audio_1p0_is_unsupported() {
let result = builder(RouterProvider::Seedance2Pro, RouterAudioModel::SeedAudio1p0).build2();
let result = builder(RouterProvider::KinoviWeb, RouterAudioModel::SeedAudio1p0).build2();
assert!(matches!(result, Err(ArtcraftRouterError::UnsupportedProviderAndModelForNewApi(_))));
}
@@ -10,7 +10,7 @@ pub struct ArtcraftAudioResponsePayload {
}
#[derive(Clone, Debug)]
pub struct Seedance2proAudioResponsePayload {
pub struct KinoviWebAudioResponsePayload {
pub order_id: String,
pub task_id: String,
}
@@ -37,7 +37,7 @@ pub struct FalAudioResponsePayload {
#[derive(Clone, Debug)]
pub enum GenerateAudioResponse {
Artcraft(ArtcraftAudioResponsePayload),
Seedance2Pro(Seedance2proAudioResponsePayload),
KinoviWeb(KinoviWebAudioResponsePayload),
Fal(FalAudioResponsePayload),
}
@@ -49,9 +49,9 @@ impl GenerateAudioResponse {
}
}
pub fn get_seedance2pro_payload(&self) -> Option<Seedance2proAudioResponsePayload> {
pub fn get_kinovi_web_payload(&self) -> Option<KinoviWebAudioResponsePayload> {
match self {
Self::Seedance2Pro(p) => Some(p.clone()),
Self::KinoviWeb(p) => Some(p.clone()),
_ => None,
}
}
@@ -5,14 +5,14 @@
//! draft `finalize()` phase. The upload plumbing is shared with the Kinovi
//! video provider (`generate_video::providers::kinovi::upload`).
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::audio_list_ref::AudioListRef;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_audio::audio_generation_draft_context::AudioGenerationDraftContext;
use crate::generate::generate_video::providers::kinovi::upload::upload_to_seedance2pro;
use crate::generate::generate_video::providers::kinovi::upload::upload_to_kinovi_web;
/// A single unresolved audio reference: either an ArtCraft/public URL or a
/// media file token that resolves to one via the draft context's map.
@@ -55,7 +55,7 @@ pub(crate) fn require_single_audio_ref(
/// Resolve a single audio reference to a source URL and re-upload it to the
/// Kinovi CDN, returning the Kinovi CDN URL.
pub(crate) async fn resolve_and_upload_audio_ref(
session: &Seedance2ProSession,
session: &KinoviWebSession,
audio_ref: &SingleAudioRef,
draft_context: &AudioGenerationDraftContext<'_>,
) -> Result<String, ArtcraftRouterError> {
@@ -71,7 +71,7 @@ pub(crate) async fn resolve_and_upload_audio_ref(
})?
}
};
upload_to_seedance2pro(session, &source_url).await
upload_to_kinovi_web(session, &source_url).await
}
#[cfg(test)]
@@ -1,4 +1,4 @@
use seedance2pro_client::generate::audio::generate_suno_music::GenerateSunoMusicRequest;
use kinovi_web_client::generate::audio::generate_suno_music::GenerateSunoMusicRequest;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
@@ -119,7 +119,7 @@ mod tests {
fn base_builder() -> GenerateAudioRequestBuilder {
GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoMusic,
provider: RouterProvider::Seedance2Pro,
provider: RouterProvider::KinoviWeb,
prompt: Some("a song about corgis".to_string()),
style_prompt: Some("sea shanty".to_string()),
..Default::default()
@@ -1,4 +1,4 @@
use seedance2pro_client::generate::audio::generate_suno_music::GenerateSunoMusicRequest;
use kinovi_web_client::generate::audio::generate_suno_music::GenerateSunoMusicRequest;
use crate::generate::generate_audio::audio_generation_cost_estimate::AudioGenerationCostEstimate;
use crate::generate::generate_audio::providers::kinovi::suno_music::request::KinoviSunoMusicRequestState;
@@ -13,7 +13,7 @@ impl KinoviSunoMusicCostState {
}
pub fn estimate_cost(&self) -> AudioGenerationCostEstimate {
// Cost math is owned by seedance2pro_client's binding — the router just
// Cost math is owned by kinovi_web_client's binding — the router just
// forwards the result so router cost ≡ binding cost by construction.
let pricing_request = GenerateSunoMusicRequest {
prompt: String::new(),
@@ -54,7 +54,7 @@ mod tests {
fn cost_from_built_request_is_seven_cents() {
let builder = GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoMusic,
provider: RouterProvider::Seedance2Pro,
provider: RouterProvider::KinoviWeb,
prompt: Some("a song".to_string()),
..Default::default()
};
@@ -1,12 +1,12 @@
use seedance2pro_client::generate::audio::generate_suno_music::{
use kinovi_web_client::generate::audio::generate_suno_music::{
generate_suno_music, GenerateSunoMusicArgs, GenerateSunoMusicRequest,
};
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_audio::generate_audio_response::{
GenerateAudioResponse, Seedance2proAudioResponsePayload,
GenerateAudioResponse, KinoviWebAudioResponsePayload,
};
#[derive(Debug, Clone)]
@@ -16,7 +16,7 @@ pub struct KinoviSunoMusicRequestState {
}
impl KinoviSunoMusicRequestState {
pub async fn send(&self, client: &RouterSeedance2ProClient) -> Result<GenerateAudioResponse, ArtcraftRouterError> {
pub async fn send(&self, client: &RouterKinoviWebClient) -> Result<GenerateAudioResponse, ArtcraftRouterError> {
let session = &client.session;
let args = GenerateSunoMusicArgs {
@@ -27,9 +27,9 @@ impl KinoviSunoMusicRequestState {
let response = generate_suno_music(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?;
Ok(GenerateAudioResponse::Seedance2Pro(Seedance2proAudioResponsePayload {
Ok(GenerateAudioResponse::KinoviWeb(KinoviWebAudioResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
}))
@@ -38,12 +38,12 @@ impl KinoviSunoMusicRequestState {
#[cfg(test)]
mod tests {
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession;
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::client::router_client::RouterClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse;
@@ -56,7 +56,7 @@ mod tests {
style_prompt: Some("Sea shanty, folk".to_string()),
..suno_music_builder()
}).await;
assert!(matches!(response, GenerateAudioResponse::Seedance2Pro(_)));
assert!(matches!(response, GenerateAudioResponse::KinoviWeb(_)));
assert_eq!(1, 2, "Inspect output above");
}
@@ -69,7 +69,7 @@ mod tests {
is_instrumental: Some(true),
..suno_music_builder()
}).await;
assert!(matches!(response, GenerateAudioResponse::Seedance2Pro(_)));
assert!(matches!(response, GenerateAudioResponse::KinoviWeb(_)));
assert_eq!(1, 2, "Inspect output above");
}
@@ -78,20 +78,20 @@ mod tests {
fn suno_music_builder() -> GenerateAudioRequestBuilder {
GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoMusic,
provider: RouterProvider::Seedance2Pro,
provider: RouterProvider::KinoviWeb,
..Default::default()
}
}
fn get_seedance2pro_client() -> RouterClient {
fn get_kinovi_web_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt")
.expect("Failed to read seedance2pro cookies");
let session = Seedance2ProSession::from_cookies_string(cookies.trim().to_string());
RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session))
.expect("Failed to read kinovi_web cookies");
let session = KinoviWebSession::from_cookies_string(cookies.trim().to_string());
RouterClient::KinoviWeb(RouterKinoviWebClient::new(session))
}
async fn run_pipeline(builder: GenerateAudioRequestBuilder) -> GenerateAudioResponse {
let client = get_seedance2pro_client();
let client = get_kinovi_web_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
@@ -102,7 +102,7 @@ mod tests {
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateAudioResponse::Seedance2Pro(p) => {
GenerateAudioResponse::KinoviWeb(p) => {
println!("task_id={}, order_id={}", p.task_id, p.order_id);
}
other => println!("response: {:?}", other),
@@ -168,7 +168,7 @@ mod tests {
fn base_builder() -> GenerateAudioRequestBuilder {
GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoRemix,
provider: RouterProvider::Seedance2Pro,
provider: RouterProvider::KinoviWeb,
prompt: Some("make this electronic".to_string()),
style_prompt: Some("EDM style".to_string()),
audio_references: Some(AudioListRef::Urls(vec!["https://example.com/a.mp3".to_string()])),
@@ -1,4 +1,4 @@
use seedance2pro_client::generate::audio::generate_suno_remix::{
use kinovi_web_client::generate::audio::generate_suno_remix::{
GenerateSunoRemixRequest, KinoviSunoRemixSource,
};
@@ -20,7 +20,7 @@ impl KinoviSunoRemixCostState {
}
pub fn estimate_cost(&self) -> AudioGenerationCostEstimate {
// Cost math is owned by seedance2pro_client's binding — the router just
// Cost math is owned by kinovi_web_client's binding — the router just
// forwards the result so router cost ≡ binding cost by construction.
let pricing_request = GenerateSunoRemixRequest {
prompt: String::new(),
@@ -63,7 +63,7 @@ mod tests {
fn cost_from_draft_is_seven_cents() {
let builder = GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoRemix,
provider: RouterProvider::Seedance2Pro,
provider: RouterProvider::KinoviWeb,
prompt: Some("make this electronic".to_string()),
audio_references: Some(AudioListRef::Urls(vec!["https://example.com/a.mp3".to_string()])),
..Default::default()
@@ -1,4 +1,4 @@
use seedance2pro_client::generate::audio::generate_suno_remix::{
use kinovi_web_client::generate::audio::generate_suno_remix::{
GenerateSunoRemixRequest, KinoviSunoRemixSource,
};
@@ -26,7 +26,7 @@ impl KinoviSunoRemixDraftState {
&mut self,
draft_context: &AudioGenerationDraftContext<'_>,
) -> Result<KinoviSunoRemixRequestState, ArtcraftRouterError> {
let client = draft_context.get_seedance2pro_client_ref()?;
let client = draft_context.get_kinovi_web_client_ref()?;
let session = &client.session;
let audio_source = self.audio_source.take().ok_or_else(|| {
@@ -1,12 +1,12 @@
use seedance2pro_client::generate::audio::generate_suno_remix::{
use kinovi_web_client::generate::audio::generate_suno_remix::{
generate_suno_remix, GenerateSunoRemixArgs, GenerateSunoRemixRequest,
};
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_audio::generate_audio_response::{
GenerateAudioResponse, Seedance2proAudioResponsePayload,
GenerateAudioResponse, KinoviWebAudioResponsePayload,
};
#[derive(Debug, Clone)]
@@ -17,7 +17,7 @@ pub struct KinoviSunoRemixRequestState {
}
impl KinoviSunoRemixRequestState {
pub async fn send(&self, client: &RouterSeedance2ProClient) -> Result<GenerateAudioResponse, ArtcraftRouterError> {
pub async fn send(&self, client: &RouterKinoviWebClient) -> Result<GenerateAudioResponse, ArtcraftRouterError> {
let session = &client.session;
let args = GenerateSunoRemixArgs {
@@ -28,9 +28,9 @@ impl KinoviSunoRemixRequestState {
let response = generate_suno_remix(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?;
Ok(GenerateAudioResponse::Seedance2Pro(Seedance2proAudioResponsePayload {
Ok(GenerateAudioResponse::KinoviWeb(KinoviWebAudioResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
}))
@@ -39,16 +39,16 @@ impl KinoviSunoRemixRequestState {
#[cfg(test)]
mod tests {
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro_client::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs};
use seedance2pro_client::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession;
use kinovi_web_client::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs};
use kinovi_web_client::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
use test_utils::test_file_path::test_file_path;
use crate::api::audio_list_ref::AudioListRef;
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::client::router_client::RouterClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::generate::generate_audio::audio_generation_draft_context::AudioGenerationDraftContext;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
@@ -59,13 +59,13 @@ mod tests {
#[tokio::test]
#[ignore] // Sends a real generation to Kinovi; costs credits. Requires a local audio file.
async fn remix_uploaded_audio() {
let client = get_seedance2pro_client();
let client = get_kinovi_web_client();
let audio_url = upload_test_audio(&client).await;
println!("Uploaded audio: {}", audio_url);
let builder = GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoRemix,
provider: RouterProvider::Seedance2Pro,
provider: RouterProvider::KinoviWeb,
prompt: Some("Make this electronic".to_string()),
style_prompt: Some("EDM style".to_string()),
audio_references: Some(AudioListRef::Urls(vec![audio_url])),
@@ -73,17 +73,17 @@ mod tests {
};
let response = run_pipeline(&client, builder).await;
assert!(matches!(response, GenerateAudioResponse::Seedance2Pro(_)));
assert!(matches!(response, GenerateAudioResponse::KinoviWeb(_)));
assert_eq!(1, 2, "Inspect output above");
}
// ── Helpers ──
fn get_seedance2pro_client() -> RouterClient {
fn get_kinovi_web_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt")
.expect("Failed to read seedance2pro cookies");
let session = Seedance2ProSession::from_cookies_string(cookies.trim().to_string());
RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session))
.expect("Failed to read kinovi_web cookies");
let session = KinoviWebSession::from_cookies_string(cookies.trim().to_string());
RouterClient::KinoviWeb(RouterKinoviWebClient::new(session))
}
async fn run_pipeline(client: &RouterClient, builder: GenerateAudioRequestBuilder) -> GenerateAudioResponse {
@@ -102,7 +102,7 @@ mod tests {
let response = request.send_request(client).await.expect("send_request should succeed");
match &response {
GenerateAudioResponse::Seedance2Pro(p) => {
GenerateAudioResponse::KinoviWeb(p) => {
println!("task_id={}, order_id={}", p.task_id, p.order_id);
}
other => println!("response: {:?}", other),
@@ -112,7 +112,7 @@ mod tests {
}
async fn upload_test_audio(client: &RouterClient) -> String {
let session = &client.get_seedance2pro_client_ref().expect("seedance2pro client").session;
let session = &client.get_kinovi_web_client_ref().expect("kinovi_web client").session;
let audio_path = test_file_path(TEST_AUDIO_PATH).expect("test audio should exist");
let audio_bytes = std::fs::read(&audio_path).expect("read test audio");
@@ -162,7 +162,7 @@ mod tests {
fn base_builder() -> GenerateAudioRequestBuilder {
GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoSample,
provider: RouterProvider::Seedance2Pro,
provider: RouterProvider::KinoviWeb,
prompt: Some("mystical RPG adventure".to_string()),
style_prompt: Some("fantasy video game".to_string()),
audio_references: Some(AudioListRef::Urls(vec!["https://example.com/a.aac".to_string()])),
@@ -1,4 +1,4 @@
use seedance2pro_client::generate::audio::generate_suno_sample::GenerateSunoSampleRequest;
use kinovi_web_client::generate::audio::generate_suno_sample::GenerateSunoSampleRequest;
use crate::generate::generate_audio::audio_generation_cost_estimate::AudioGenerationCostEstimate;
use crate::generate::generate_audio::providers::kinovi::suno_sample::draft::KinoviSunoSampleDraftState;
@@ -18,7 +18,7 @@ impl KinoviSunoSampleCostState {
}
pub fn estimate_cost(&self) -> AudioGenerationCostEstimate {
// Cost math is owned by seedance2pro_client's binding — the router just
// Cost math is owned by kinovi_web_client's binding — the router just
// forwards the result so router cost ≡ binding cost by construction.
let pricing_request = GenerateSunoSampleRequest {
prompt: String::new(),
@@ -63,7 +63,7 @@ mod tests {
fn cost_from_draft_is_seven_cents() {
let builder = GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoSample,
provider: RouterProvider::Seedance2Pro,
provider: RouterProvider::KinoviWeb,
prompt: Some("mystical RPG adventure".to_string()),
audio_references: Some(AudioListRef::Urls(vec!["https://example.com/a.aac".to_string()])),
..Default::default()
@@ -1,4 +1,4 @@
use seedance2pro_client::generate::audio::generate_suno_sample::GenerateSunoSampleRequest;
use kinovi_web_client::generate::audio::generate_suno_sample::GenerateSunoSampleRequest;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_audio::audio_generation_draft_context::AudioGenerationDraftContext;
@@ -34,7 +34,7 @@ impl KinoviSunoSampleDraftState {
&mut self,
draft_context: &AudioGenerationDraftContext<'_>,
) -> Result<KinoviSunoSampleRequestState, ArtcraftRouterError> {
let client = draft_context.get_seedance2pro_client_ref()?;
let client = draft_context.get_kinovi_web_client_ref()?;
let session = &client.session;
let audio_source = self.audio_source.take().ok_or_else(|| {
@@ -1,12 +1,12 @@
use seedance2pro_client::generate::audio::generate_suno_sample::{
use kinovi_web_client::generate::audio::generate_suno_sample::{
generate_suno_sample, GenerateSunoSampleArgs, GenerateSunoSampleRequest,
};
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_audio::generate_audio_response::{
GenerateAudioResponse, Seedance2proAudioResponsePayload,
GenerateAudioResponse, KinoviWebAudioResponsePayload,
};
#[derive(Debug, Clone)]
@@ -17,7 +17,7 @@ pub struct KinoviSunoSampleRequestState {
}
impl KinoviSunoSampleRequestState {
pub async fn send(&self, client: &RouterSeedance2ProClient) -> Result<GenerateAudioResponse, ArtcraftRouterError> {
pub async fn send(&self, client: &RouterKinoviWebClient) -> Result<GenerateAudioResponse, ArtcraftRouterError> {
let session = &client.session;
let args = GenerateSunoSampleArgs {
@@ -28,9 +28,9 @@ impl KinoviSunoSampleRequestState {
let response = generate_suno_sample(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?;
Ok(GenerateAudioResponse::Seedance2Pro(Seedance2proAudioResponsePayload {
Ok(GenerateAudioResponse::KinoviWeb(KinoviWebAudioResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
}))
@@ -39,16 +39,16 @@ impl KinoviSunoSampleRequestState {
#[cfg(test)]
mod tests {
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro_client::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs};
use seedance2pro_client::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession;
use kinovi_web_client::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs};
use kinovi_web_client::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
use test_utils::test_file_path::test_file_path;
use crate::api::audio_list_ref::AudioListRef;
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::client::router_client::RouterClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::generate::generate_audio::audio_generation_draft_context::AudioGenerationDraftContext;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
@@ -59,13 +59,13 @@ mod tests {
#[tokio::test]
#[ignore] // Sends a real generation to Kinovi; costs credits. Requires a local audio file.
async fn sample_instrumental() {
let client = get_seedance2pro_client();
let client = get_kinovi_web_client();
let audio_url = upload_test_audio(&client).await;
println!("Uploaded audio: {}", audio_url);
let builder = GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoSample,
provider: RouterProvider::Seedance2Pro,
provider: RouterProvider::KinoviWeb,
prompt: Some("Mystical RPG adventure, make it have a grand climax".to_string()),
style_prompt: Some("Fantasy video game score".to_string()),
is_instrumental: Some(true),
@@ -74,17 +74,17 @@ mod tests {
};
let response = run_pipeline(&client, builder).await;
assert!(matches!(response, GenerateAudioResponse::Seedance2Pro(_)));
assert!(matches!(response, GenerateAudioResponse::KinoviWeb(_)));
assert_eq!(1, 2, "Inspect output above");
}
// ── Helpers ──
fn get_seedance2pro_client() -> RouterClient {
fn get_kinovi_web_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt")
.expect("Failed to read seedance2pro cookies");
let session = Seedance2ProSession::from_cookies_string(cookies.trim().to_string());
RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session))
.expect("Failed to read kinovi_web cookies");
let session = KinoviWebSession::from_cookies_string(cookies.trim().to_string());
RouterClient::KinoviWeb(RouterKinoviWebClient::new(session))
}
async fn run_pipeline(client: &RouterClient, builder: GenerateAudioRequestBuilder) -> GenerateAudioResponse {
@@ -103,7 +103,7 @@ mod tests {
let response = request.send_request(client).await.expect("send_request should succeed");
match &response {
GenerateAudioResponse::Seedance2Pro(p) => {
GenerateAudioResponse::KinoviWeb(p) => {
println!("task_id={}, order_id={}", p.task_id, p.order_id);
}
other => println!("response: {:?}", other),
@@ -113,7 +113,7 @@ mod tests {
}
async fn upload_test_audio(client: &RouterClient) -> String {
let session = &client.get_seedance2pro_client_ref().expect("seedance2pro client").session;
let session = &client.get_kinovi_web_client_ref().expect("kinovi_web client").session;
let audio_path = test_file_path(TEST_AUDIO_PATH).expect("test audio should exist");
let audio_bytes = std::fs::read(&audio_path).expect("read test audio");
@@ -1,5 +1,5 @@
use enums::common::generation::common_musical_key::CommonMusicalKey;
use seedance2pro_client::generate::audio::generate_suno_sound::{
use kinovi_web_client::generate::audio::generate_suno_sound::{
GenerateSunoSoundRequest, KinoviSunoSoundKey, KinoviSunoSoundType,
};
@@ -201,7 +201,7 @@ mod tests {
fn base_builder() -> GenerateAudioRequestBuilder {
GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoSounds,
provider: RouterProvider::Seedance2Pro,
provider: RouterProvider::KinoviWeb,
prompt: Some("rain sound effects".to_string()),
..Default::default()
}
@@ -1,4 +1,4 @@
use seedance2pro_client::generate::audio::generate_suno_sound::{
use kinovi_web_client::generate::audio::generate_suno_sound::{
GenerateSunoSoundRequest, KinoviSunoSoundKey, KinoviSunoSoundType,
};
@@ -15,7 +15,7 @@ impl KinoviSunoSoundsCostState {
}
pub fn estimate_cost(&self) -> AudioGenerationCostEstimate {
// Cost math is owned by seedance2pro_client's binding — the router just
// Cost math is owned by kinovi_web_client's binding — the router just
// forwards the result so router cost ≡ binding cost by construction.
let pricing_request = GenerateSunoSoundRequest {
prompt: String::new(),
@@ -57,7 +57,7 @@ mod tests {
fn cost_from_built_request_is_two_cents() {
let builder = GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoSounds,
provider: RouterProvider::Seedance2Pro,
provider: RouterProvider::KinoviWeb,
prompt: Some("thunder".to_string()),
is_loopable: Some(true),
bpm: Some(60),
@@ -1,12 +1,12 @@
use seedance2pro_client::generate::audio::generate_suno_sound::{
use kinovi_web_client::generate::audio::generate_suno_sound::{
generate_suno_sound, GenerateSunoSoundArgs, GenerateSunoSoundRequest,
};
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_audio::generate_audio_response::{
GenerateAudioResponse, Seedance2proAudioResponsePayload,
GenerateAudioResponse, KinoviWebAudioResponsePayload,
};
#[derive(Debug, Clone)]
@@ -16,7 +16,7 @@ pub struct KinoviSunoSoundsRequestState {
}
impl KinoviSunoSoundsRequestState {
pub async fn send(&self, client: &RouterSeedance2ProClient) -> Result<GenerateAudioResponse, ArtcraftRouterError> {
pub async fn send(&self, client: &RouterKinoviWebClient) -> Result<GenerateAudioResponse, ArtcraftRouterError> {
let session = &client.session;
let args = GenerateSunoSoundArgs {
@@ -27,9 +27,9 @@ impl KinoviSunoSoundsRequestState {
let response = generate_suno_sound(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?;
Ok(GenerateAudioResponse::Seedance2Pro(Seedance2proAudioResponsePayload {
Ok(GenerateAudioResponse::KinoviWeb(KinoviWebAudioResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
}))
@@ -39,12 +39,12 @@ impl KinoviSunoSoundsRequestState {
#[cfg(test)]
mod tests {
use enums::common::generation::common_musical_key::CommonMusicalKey;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession;
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::client::router_client::RouterClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse;
@@ -56,7 +56,7 @@ mod tests {
prompt: Some("A heavy castle door creaking open".to_string()),
..suno_sounds_builder()
}).await;
assert!(matches!(response, GenerateAudioResponse::Seedance2Pro(_)));
assert!(matches!(response, GenerateAudioResponse::KinoviWeb(_)));
assert_eq!(1, 2, "Inspect output above");
}
@@ -70,7 +70,7 @@ mod tests {
musical_key: Some(CommonMusicalKey::AMinor),
..suno_sounds_builder()
}).await;
assert!(matches!(response, GenerateAudioResponse::Seedance2Pro(_)));
assert!(matches!(response, GenerateAudioResponse::KinoviWeb(_)));
assert_eq!(1, 2, "Inspect output above");
}
@@ -79,20 +79,20 @@ mod tests {
fn suno_sounds_builder() -> GenerateAudioRequestBuilder {
GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoSounds,
provider: RouterProvider::Seedance2Pro,
provider: RouterProvider::KinoviWeb,
..Default::default()
}
}
fn get_seedance2pro_client() -> RouterClient {
fn get_kinovi_web_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt")
.expect("Failed to read seedance2pro cookies");
let session = Seedance2ProSession::from_cookies_string(cookies.trim().to_string());
RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session))
.expect("Failed to read kinovi_web cookies");
let session = KinoviWebSession::from_cookies_string(cookies.trim().to_string());
RouterClient::KinoviWeb(RouterKinoviWebClient::new(session))
}
async fn run_pipeline(builder: GenerateAudioRequestBuilder) -> GenerateAudioResponse {
let client = get_seedance2pro_client();
let client = get_kinovi_web_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
@@ -103,7 +103,7 @@ mod tests {
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateAudioResponse::Seedance2Pro(p) => {
GenerateAudioResponse::KinoviWeb(p) => {
println!("task_id={}, order_id={}", p.task_id, p.order_id);
}
other => println!("response: {:?}", other),
@@ -146,12 +146,12 @@ impl GenerateImageRequestBuilder {
(RouterProvider::Fal, RouterImageModel::QwenEdit2511Angles) => build_fal_qwen_edit_2511_angles(self),
(RouterProvider::Fal, RouterImageModel::Flux2LoraAngles) => build_fal_flux_2_lora_angles(self),
// Kinovi / Seedance2Pro (Midjourney + Seedream)
(RouterProvider::Seedance2Pro, RouterImageModel::Midjourney7) => build_kinovi_midjourney_7(self),
(RouterProvider::Seedance2Pro, RouterImageModel::Midjourney7Niji) => build_kinovi_midjourney_7_niji(self),
(RouterProvider::Seedance2Pro, RouterImageModel::Midjourney8) => build_kinovi_midjourney_8(self),
(RouterProvider::Seedance2Pro, RouterImageModel::Seedream5p0Pro) => build_kinovi_seedream_5p0_pro(self),
(RouterProvider::Seedance2Pro, RouterImageModel::Seedream5p0ProUltra) => build_kinovi_seedream_5p0_pro(self),
// Kinovi / KinoviWeb (Midjourney + Seedream)
(RouterProvider::KinoviWeb, RouterImageModel::Midjourney7) => build_kinovi_midjourney_7(self),
(RouterProvider::KinoviWeb, RouterImageModel::Midjourney7Niji) => build_kinovi_midjourney_7_niji(self),
(RouterProvider::KinoviWeb, RouterImageModel::Midjourney8) => build_kinovi_midjourney_8(self),
(RouterProvider::KinoviWeb, RouterImageModel::Seedream5p0Pro) => build_kinovi_seedream_5p0_pro(self),
(RouterProvider::KinoviWeb, RouterImageModel::Seedream5p0ProUltra) => build_kinovi_seedream_5p0_pro(self),
_ => self.unsupported_provider_and_model(),
}
@@ -25,11 +25,11 @@ pub struct FalImageResponsePayload {
pub maybe_outbound_request: Option<Arc<dyn Debug + Send + Sync>>,
}
/// Response from the Seedance2Pro/Kinovi provider (used for Midjourney
/// image generation). Mirrors `Seedance2proVideoResponsePayload` on the
/// Response from the KinoviWeb/Kinovi provider (used for Midjourney
/// image generation). Mirrors `KinoviWebVideoResponsePayload` on the
/// video side.
#[derive(Clone, Debug)]
pub struct Seedance2proImageResponsePayload {
pub struct KinoviWebImageResponsePayload {
pub order_id: String,
pub task_id: String,
pub maybe_order_ids: Option<Vec<String>>,
@@ -40,7 +40,7 @@ pub struct Seedance2proImageResponsePayload {
pub enum GenerateImageResponse {
Artcraft(ArtcraftImageResponsePayload),
Fal(FalImageResponsePayload),
Seedance2Pro(Seedance2proImageResponsePayload),
KinoviWeb(KinoviWebImageResponsePayload),
}
impl GenerateImageResponse {
@@ -58,9 +58,9 @@ impl GenerateImageResponse {
}
}
pub fn get_seedance2pro_payload(&self) -> Option<Seedance2proImageResponsePayload> {
pub fn get_kinovi_web_payload(&self) -> Option<KinoviWebImageResponsePayload> {
match self {
Self::Seedance2Pro(p) => Some(p.clone()),
Self::KinoviWeb(p) => Some(p.clone()),
_ => None,
}
}
@@ -17,7 +17,7 @@ use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::draft:
/// Drafts hold validated/planned parameters but may still need media
/// resolution (e.g. uploading local files to a provider) before they can
/// be sent. Today only the Kinovi models need this — when the caller
/// supplies reference images, we must upload them to the Seedance2Pro
/// supplies reference images, we must upload them to the KinoviWeb
/// CDN before sending the request.
#[derive(Clone, Debug)]
pub enum ImageGenerationDraftRequest {
@@ -30,10 +30,10 @@ pub enum ImageGenerationDraftRequest {
impl ImageGenerationDraftRequest {
pub fn get_provider(&self) -> RouterProvider {
match self {
Self::KinoviMidjourney7(_) => RouterProvider::Seedance2Pro,
Self::KinoviMidjourney7Niji(_) => RouterProvider::Seedance2Pro,
Self::KinoviMidjourney8(_) => RouterProvider::Seedance2Pro,
Self::KinoviSeedream5p0Pro(_) => RouterProvider::Seedance2Pro,
Self::KinoviMidjourney7(_) => RouterProvider::KinoviWeb,
Self::KinoviMidjourney7Niji(_) => RouterProvider::KinoviWeb,
Self::KinoviMidjourney8(_) => RouterProvider::KinoviWeb,
Self::KinoviSeedream5p0Pro(_) => RouterProvider::KinoviWeb,
}
}
@@ -4,21 +4,21 @@ use std::fmt::Debug;
use tokens::tokens::media_files::MediaFileToken;
use crate::client::router_client::RouterClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
/// Context passed when finalizing an image draft into a concrete request.
///
/// Drafts only exist for providers that need to upload assets before
/// sending the request. Today that's the Seedance2Pro/Kinovi pipeline
/// sending the request. Today that's the KinoviWeb/Kinovi pipeline
/// (used for Midjourney image generation with reference images); other
/// providers — Artcraft, Fal — accept image URLs directly and skip the
/// draft phase entirely.
#[derive(Clone, Default)]
pub struct ImageGenerationDraftContext<'a> {
/// Optional: the router client, needed for providers that require
/// authenticated calls (e.g. Seedance2Pro/Kinovi uploads).
/// authenticated calls (e.g. KinoviWeb/Kinovi uploads).
pub client: Option<&'a RouterClient>,
/// Optional: a map of Media File Tokens to their ArtCraft URLs.
@@ -29,9 +29,9 @@ pub struct ImageGenerationDraftContext<'a> {
}
impl<'a> ImageGenerationDraftContext<'a> {
pub fn get_seedance2pro_client_ref(&self) -> Result<&RouterSeedance2ProClient, ArtcraftRouterError> {
pub fn get_kinovi_web_client_ref(&self) -> Result<&RouterKinoviWebClient, ArtcraftRouterError> {
let client = self.client.ok_or(ArtcraftRouterError::Client(ClientError::RouterClientNotProvided))?;
client.get_seedance2pro_client_ref()
client.get_kinovi_web_client_ref()
.map_err(ArtcraftRouterError::Client)
}
}
@@ -123,7 +123,7 @@ pub enum ImageGenerationRequest {
FalQwenEdit2511Angles(FalQwenEdit2511AnglesRequestState),
FalFlux2LoraAngles(FalFlux2LoraAnglesRequestState),
// ── Kinovi / Seedance2Pro provider (Midjourney + Seedream image generation) ──
// ── Kinovi / KinoviWeb provider (Midjourney + Seedream image generation) ──
KinoviMidjourney7(KinoviMidjourney7RequestState),
KinoviMidjourney7Niji(KinoviMidjourney7NijiRequestState),
KinoviMidjourney8(KinoviMidjourney8RequestState),
@@ -170,10 +170,10 @@ impl ImageGenerationRequest {
Self::FalQwenEdit2511Angles(_) => RouterProvider::Fal,
Self::FalFlux2LoraAngles(_) => RouterProvider::Fal,
Self::KinoviMidjourney7(_) => RouterProvider::Seedance2Pro,
Self::KinoviMidjourney7Niji(_) => RouterProvider::Seedance2Pro,
Self::KinoviMidjourney8(_) => RouterProvider::Seedance2Pro,
Self::KinoviSeedream5p0Pro(_) => RouterProvider::Seedance2Pro,
Self::KinoviMidjourney7(_) => RouterProvider::KinoviWeb,
Self::KinoviMidjourney7Niji(_) => RouterProvider::KinoviWeb,
Self::KinoviMidjourney8(_) => RouterProvider::KinoviWeb,
Self::KinoviSeedream5p0Pro(_) => RouterProvider::KinoviWeb,
}
}
@@ -416,24 +416,24 @@ impl ImageGenerationRequest {
request.send(fal_client).await
}
// ── Kinovi / Seedance2Pro (Midjourney + Seedream) ──
// ── Kinovi / KinoviWeb (Midjourney + Seedream) ──
Self::KinoviMidjourney7(request) => {
let seedance_client = client.get_seedance2pro_client_ref()
let seedance_client = client.get_kinovi_web_client_ref()
.map_err(ArtcraftRouterError::Client)?;
request.send(seedance_client).await
}
Self::KinoviMidjourney7Niji(request) => {
let seedance_client = client.get_seedance2pro_client_ref()
let seedance_client = client.get_kinovi_web_client_ref()
.map_err(ArtcraftRouterError::Client)?;
request.send(seedance_client).await
}
Self::KinoviMidjourney8(request) => {
let seedance_client = client.get_seedance2pro_client_ref()
let seedance_client = client.get_kinovi_web_client_ref()
.map_err(ArtcraftRouterError::Client)?;
request.send(seedance_client).await
}
Self::KinoviSeedream5p0Pro(request) => {
let seedance_client = client.get_seedance2pro_client_ref()
let seedance_client = client.get_kinovi_web_client_ref()
.map_err(ArtcraftRouterError::Client)?;
request.send(seedance_client).await
}
@@ -2,8 +2,7 @@ use crate::generate::generate_image::image_generation_cost_estimate::ImageGenera
use crate::generate::generate_image::providers::artcraft::flux_2_lora_angles::request::ArtcraftFlux2LoraAnglesRequestState;
/// Cost state for Artcraft Flux 2 LoRA Angles. Pricing mirrors v1
/// (`estimate_image_cost_artcraft_flux_2_lora_angles`): 4¢ per output image
/// (Artcraft markup over Fal's 2¢/image base rate).
/// (`estimate_image_cost_artcraft_flux_2_lora_angles`): 4¢ per output image.
#[derive(Clone, Debug)]
pub struct ArtcraftFlux2LoraAnglesCostState {
pub num_images: u16,
@@ -16,7 +16,9 @@ impl ArtcraftQwenEdit2511AnglesCostState {
}
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
let cost_in_usd_cents = 4u64 * self.num_images as u64;
// 4.6¢ per output image (held in hundredths of a cent, rounded up to
// whole cents across the batch).
let cost_in_usd_cents = (460u64 * self.num_images as u64).div_ceil(100);
ImageGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
@@ -59,14 +61,14 @@ mod tests {
}
#[test]
fn one_image_costs_4_cents() { assert_eq!(cost_cents(1), 4); }
fn one_image_costs_5_cents() { assert_eq!(cost_cents(1), 5); }
#[test]
fn two_images_costs_8_cents() { assert_eq!(cost_cents(2), 8); }
fn two_images_costs_10_cents() { assert_eq!(cost_cents(2), 10); }
#[test]
fn three_images_costs_12_cents() { assert_eq!(cost_cents(3), 12); }
fn three_images_costs_14_cents() { assert_eq!(cost_cents(3), 14); }
#[test]
fn four_images_costs_16_cents() { assert_eq!(cost_cents(4), 16); }
fn four_images_costs_19_cents() { assert_eq!(cost_cents(4), 19); }
}
@@ -148,7 +148,7 @@ mod tests {
#[test]
fn price_covers_kinovi_cost() {
use seedance2pro_client::generate::image::generate_seedream_5p0_pro::{
use kinovi_web_client::generate::image::generate_seedream_5p0_pro::{
KinoviSeedream5p0ProBatchCount, KinoviSeedream5p0ProResolution,
};
use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::cost::KinoviSeedream5p0ProCostState;
@@ -169,7 +169,7 @@ mod tests {
#[test]
fn price_covers_kinovi_cost() {
use seedance2pro_client::generate::image::generate_seedream_5p0_pro::{
use kinovi_web_client::generate::image::generate_seedream_5p0_pro::{
KinoviSeedream5p0ProBatchCount, KinoviSeedream5p0ProResolution,
};
use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::cost::KinoviSeedream5p0ProCostState;
@@ -1,4 +1,4 @@
use seedance2pro_client::generate::image::generate_midjourney_v7::{
use kinovi_web_client::generate::image::generate_midjourney_v7::{
GenerateMidjourneyV7AspectRatio, GenerateMidjourneyV7Quality,
GenerateMidjourneyV7Request, KinoviMidjourneyBatchCount,
};
@@ -50,7 +50,7 @@ pub fn build_kinovi_midjourney_7(
}
// Image inputs present → must go through the draft phase so we can
// upload each image to the Seedance2Pro CDN before sending.
// upload each image to the KinoviWeb CDN before sending.
let draft = KinoviMidjourney7DraftState {
prompt,
aspect_ratio,
@@ -159,7 +159,7 @@ pub(crate) fn plan_batch_count(
#[cfg(test)]
mod tests {
use seedance2pro_client::generate::image::generate_midjourney_v7::KinoviMidjourneyBatchCount;
use kinovi_web_client::generate::image::generate_midjourney_v7::KinoviMidjourneyBatchCount;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
@@ -178,7 +178,7 @@ mod tests {
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::Midjourney7,
provider: RouterProvider::Seedance2Pro,
provider: RouterProvider::KinoviWeb,
prompt: Some("a corgi astronaut".to_string()),
image_inputs: None,
resolution: None,
@@ -291,7 +291,7 @@ mod tests {
mod aspect_ratio_tests {
use super::*;
use seedance2pro_client::generate::image::generate_midjourney_v7::GenerateMidjourneyV7AspectRatio as Ar;
use kinovi_web_client::generate::image::generate_midjourney_v7::GenerateMidjourneyV7AspectRatio as Ar;
fn aspect_ratio_for(input: Option<RouterAspectRatio>) -> Ar {
let builder = GenerateImageRequestBuilder { aspect_ratio: input, ..base_builder() };
@@ -334,7 +334,7 @@ mod tests {
mod quality_tests {
use super::*;
use seedance2pro_client::generate::image::generate_midjourney_v7::GenerateMidjourneyV7Quality as Q;
use kinovi_web_client::generate::image::generate_midjourney_v7::GenerateMidjourneyV7Quality as Q;
fn quality_for(input: Option<RouterQuality>) -> Option<Q> {
let builder = GenerateImageRequestBuilder { quality: input, ..base_builder() };
@@ -1,4 +1,4 @@
use seedance2pro_client::generate::image::generate_midjourney_v7::{
use kinovi_web_client::generate::image::generate_midjourney_v7::{
GenerateMidjourneyV7AspectRatio, GenerateMidjourneyV7Request, KinoviMidjourneyBatchCount,
};
@@ -171,7 +171,7 @@ mod tests {
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::Midjourney7,
provider: RouterProvider::Seedance2Pro,
provider: RouterProvider::KinoviWeb,
prompt: Some("test".to_string()),
image_inputs: None,
resolution: None,
@@ -1,4 +1,4 @@
use seedance2pro_client::generate::image::generate_midjourney_v7::{
use kinovi_web_client::generate::image::generate_midjourney_v7::{
GenerateMidjourneyV7AspectRatio, GenerateMidjourneyV7Quality,
GenerateMidjourneyV7Request, KinoviMidjourneyBatchCount,
};
@@ -17,7 +17,7 @@ pub struct KinoviMidjourney7DraftState {
pub quality: Option<GenerateMidjourneyV7Quality>,
pub batch_count: KinoviMidjourneyBatchCount,
/// Image references that still need to be uploaded to Seedance2Pro CDN
/// Image references that still need to be uploaded to KinoviWeb CDN
/// before the request is sendable. `None` once `to_request()` has run.
pub unhandled_request_state: Option<KinoviMidjourney7RemainingItems>,
}
@@ -32,7 +32,7 @@ impl KinoviMidjourney7DraftState {
&mut self,
draft_context: &ImageGenerationDraftContext<'_>,
) -> Result<KinoviMidjourney7RequestState, ArtcraftRouterError> {
let client = draft_context.get_seedance2pro_client_ref()?;
let client = draft_context.get_kinovi_web_client_ref()?;
let session = &client.session;
let mut reference_image_urls = None;
@@ -63,10 +63,10 @@ impl KinoviMidjourney7DraftState {
#[cfg(test)]
mod tests {
use super::*;
use seedance2pro_client::generate::image::generate_midjourney_v7::GenerateMidjourneyV7AspectRatio;
use kinovi_web_client::generate::image::generate_midjourney_v7::GenerateMidjourneyV7AspectRatio;
// Note: most behavioral tests live in `build.rs` and `cost.rs`. The
// `to_request()` method requires a live Seedance2Pro client to upload
// `to_request()` method requires a live KinoviWeb client to upload
// images, so its end-to-end behavior is exercised by the `request.rs`
// #[ignore]'d integration tests rather than unit-tested here.
@@ -1,12 +1,12 @@
use seedance2pro_client::generate::image::generate_midjourney_v7::{
use kinovi_web_client::generate::image::generate_midjourney_v7::{
generate_midjourney_v7, GenerateMidjourneyV7Args, GenerateMidjourneyV7Request,
};
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_image::generate_image_response::{
GenerateImageResponse, Seedance2proImageResponsePayload,
GenerateImageResponse, KinoviWebImageResponsePayload,
};
#[derive(Debug, Clone)]
@@ -18,7 +18,7 @@ pub struct KinoviMidjourney7RequestState {
impl KinoviMidjourney7RequestState {
pub async fn send(
&self,
client: &RouterSeedance2ProClient,
client: &RouterKinoviWebClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let args = GenerateMidjourneyV7Args {
session: &client.session,
@@ -28,9 +28,9 @@ impl KinoviMidjourney7RequestState {
let response = generate_midjourney_v7(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?;
Ok(GenerateImageResponse::Seedance2Pro(Seedance2proImageResponsePayload {
Ok(GenerateImageResponse::KinoviWeb(KinoviWebImageResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
maybe_order_ids: response.order_ids,
@@ -43,7 +43,7 @@ impl KinoviMidjourney7RequestState {
mod tests {
use std::collections::HashMap;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
@@ -52,7 +52,7 @@ mod tests {
use crate::api::router_provider::RouterProvider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::client::router_client::RouterClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image::generate_image_response::GenerateImageResponse;
use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest;
@@ -64,7 +64,7 @@ mod tests {
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::Midjourney7,
provider: RouterProvider::Seedance2Pro,
provider: RouterProvider::KinoviWeb,
prompt: Some("a serene mountain lake at sunrise, cinematic".to_string()),
image_inputs: None,
resolution: None,
@@ -82,9 +82,9 @@ mod tests {
fn get_kinovi_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt")
.expect("Failed to read seedance2pro cookies");
let session = Seedance2ProSession::from_cookies_string(cookies.trim().to_string());
RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session))
.expect("Failed to read kinovi_web cookies");
let session = KinoviWebSession::from_cookies_string(cookies.trim().to_string());
RouterClient::KinoviWeb(RouterKinoviWebClient::new(session))
}
#[tokio::test]
@@ -98,9 +98,9 @@ mod tests {
_ => panic!("expected direct Request for text-to-image"),
};
let response = request.send(client.get_seedance2pro_client_ref().unwrap())
let response = request.send(client.get_kinovi_web_client_ref().unwrap())
.await.expect("send should succeed");
let payload = response.get_seedance2pro_payload().expect("expected seedance2pro payload");
let payload = response.get_kinovi_web_payload().expect("expected kinovi_web payload");
println!("v7 t2i — task_id={}, order_id={}", payload.task_id, payload.order_id);
assert!(!payload.task_id.is_empty());
}
@@ -135,7 +135,7 @@ mod tests {
.finalize(ctx).await.expect("finalize");
let response = request.send_request(&client).await.expect("send_request");
let payload = response.get_seedance2pro_payload().expect("expected seedance2pro payload");
let payload = response.get_kinovi_web_payload().expect("expected kinovi_web payload");
println!("v7 with ref — task_id={}, order_id={}", payload.task_id, payload.order_id);
assert!(!payload.task_id.is_empty());
}
@@ -1,4 +1,4 @@
use seedance2pro_client::generate::image::generate_midjourney_v7_niji::{
use kinovi_web_client::generate::image::generate_midjourney_v7_niji::{
GenerateMidjourneyV7NijiAspectRatio, GenerateMidjourneyV7NijiQuality,
GenerateMidjourneyV7NijiRequest, KinoviMidjourneyBatchCount,
};
@@ -147,7 +147,7 @@ pub(crate) fn plan_batch_count(
#[cfg(test)]
mod tests {
use seedance2pro_client::generate::image::generate_midjourney_v7_niji::KinoviMidjourneyBatchCount;
use kinovi_web_client::generate::image::generate_midjourney_v7_niji::KinoviMidjourneyBatchCount;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
@@ -166,7 +166,7 @@ mod tests {
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::Midjourney7Niji,
provider: RouterProvider::Seedance2Pro,
provider: RouterProvider::KinoviWeb,
prompt: Some("a magical anime forest scene".to_string()),
image_inputs: None,
resolution: None,
@@ -235,7 +235,7 @@ mod tests {
#[test]
fn aspect_ratio_passes_through_to_niji_enum() {
use seedance2pro_client::generate::image::generate_midjourney_v7_niji::GenerateMidjourneyV7NijiAspectRatio as Ar;
use kinovi_web_client::generate::image::generate_midjourney_v7_niji::GenerateMidjourneyV7NijiAspectRatio as Ar;
let builder = GenerateImageRequestBuilder {
aspect_ratio: Some(RouterAspectRatio::WideTwentyOneByNine),
..base_builder()
@@ -249,7 +249,7 @@ mod tests {
#[test]
fn quality_high_maps_to_full() {
use seedance2pro_client::generate::image::generate_midjourney_v7_niji::GenerateMidjourneyV7NijiQuality as Q;
use kinovi_web_client::generate::image::generate_midjourney_v7_niji::GenerateMidjourneyV7NijiQuality as Q;
let builder = GenerateImageRequestBuilder {
quality: Some(RouterQuality::High),
..base_builder()
@@ -1,4 +1,4 @@
use seedance2pro_client::generate::image::generate_midjourney_v7_niji::{
use kinovi_web_client::generate::image::generate_midjourney_v7_niji::{
GenerateMidjourneyV7NijiAspectRatio, GenerateMidjourneyV7NijiRequest, KinoviMidjourneyBatchCount,
};
@@ -1,4 +1,4 @@
use seedance2pro_client::generate::image::generate_midjourney_v7_niji::{
use kinovi_web_client::generate::image::generate_midjourney_v7_niji::{
GenerateMidjourneyV7NijiAspectRatio, GenerateMidjourneyV7NijiQuality,
GenerateMidjourneyV7NijiRequest, KinoviMidjourneyBatchCount,
};
@@ -29,7 +29,7 @@ impl KinoviMidjourney7NijiDraftState {
&mut self,
draft_context: &ImageGenerationDraftContext<'_>,
) -> Result<KinoviMidjourney7NijiRequestState, ArtcraftRouterError> {
let client = draft_context.get_seedance2pro_client_ref()?;
let client = draft_context.get_kinovi_web_client_ref()?;
let session = &client.session;
let mut reference_image_urls = None;
@@ -1,12 +1,12 @@
use seedance2pro_client::generate::image::generate_midjourney_v7_niji::{
use kinovi_web_client::generate::image::generate_midjourney_v7_niji::{
generate_midjourney_v7_niji, GenerateMidjourneyV7NijiArgs, GenerateMidjourneyV7NijiRequest,
};
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_image::generate_image_response::{
GenerateImageResponse, Seedance2proImageResponsePayload,
GenerateImageResponse, KinoviWebImageResponsePayload,
};
#[derive(Debug, Clone)]
@@ -17,7 +17,7 @@ pub struct KinoviMidjourney7NijiRequestState {
impl KinoviMidjourney7NijiRequestState {
pub async fn send(
&self,
client: &RouterSeedance2ProClient,
client: &RouterKinoviWebClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let args = GenerateMidjourneyV7NijiArgs {
session: &client.session,
@@ -27,9 +27,9 @@ impl KinoviMidjourney7NijiRequestState {
let response = generate_midjourney_v7_niji(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?;
Ok(GenerateImageResponse::Seedance2Pro(Seedance2proImageResponsePayload {
Ok(GenerateImageResponse::KinoviWeb(KinoviWebImageResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
maybe_order_ids: response.order_ids,
@@ -42,7 +42,7 @@ impl KinoviMidjourney7NijiRequestState {
mod tests {
use std::collections::HashMap;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
@@ -51,7 +51,7 @@ mod tests {
use crate::api::router_provider::RouterProvider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::client::router_client::RouterClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest;
use crate::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext;
@@ -62,7 +62,7 @@ mod tests {
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::Midjourney7Niji,
provider: RouterProvider::Seedance2Pro,
provider: RouterProvider::KinoviWeb,
prompt: Some("a magical anime fox spirit in a moonlit bamboo forest".to_string()),
image_inputs: None,
resolution: None,
@@ -80,9 +80,9 @@ mod tests {
fn get_kinovi_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt")
.expect("Failed to read seedance2pro cookies");
let session = Seedance2ProSession::from_cookies_string(cookies.trim().to_string());
RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session))
.expect("Failed to read kinovi_web cookies");
let session = KinoviWebSession::from_cookies_string(cookies.trim().to_string());
RouterClient::KinoviWeb(RouterKinoviWebClient::new(session))
}
#[tokio::test]
@@ -96,9 +96,9 @@ mod tests {
_ => panic!("expected direct Request for text-to-image"),
};
let response = request.send(client.get_seedance2pro_client_ref().unwrap())
let response = request.send(client.get_kinovi_web_client_ref().unwrap())
.await.expect("send should succeed");
let payload = response.get_seedance2pro_payload().expect("expected seedance2pro payload");
let payload = response.get_kinovi_web_payload().expect("expected kinovi_web payload");
println!("v7-niji t2i — task_id={}, order_id={}", payload.task_id, payload.order_id);
assert!(!payload.task_id.is_empty());
}
@@ -133,7 +133,7 @@ mod tests {
.finalize(ctx).await.expect("finalize");
let response = request.send_request(&client).await.expect("send_request");
let payload = response.get_seedance2pro_payload().expect("expected seedance2pro payload");
let payload = response.get_kinovi_web_payload().expect("expected kinovi_web payload");
println!("v7-niji with ref — task_id={}, order_id={}", payload.task_id, payload.order_id);
assert!(!payload.task_id.is_empty());
}
@@ -1,4 +1,4 @@
use seedance2pro_client::generate::image::generate_midjourney_v8::{
use kinovi_web_client::generate::image::generate_midjourney_v8::{
GenerateMidjourneyV8AspectRatio, GenerateMidjourneyV8Quality,
GenerateMidjourneyV8Request, KinoviMidjourneyBatchCount,
};
@@ -147,7 +147,7 @@ pub(crate) fn plan_batch_count(
#[cfg(test)]
mod tests {
use seedance2pro_client::generate::image::generate_midjourney_v8::KinoviMidjourneyBatchCount;
use kinovi_web_client::generate::image::generate_midjourney_v8::KinoviMidjourneyBatchCount;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
@@ -164,7 +164,7 @@ mod tests {
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::Midjourney8,
provider: RouterProvider::Seedance2Pro,
provider: RouterProvider::KinoviWeb,
prompt: Some("a spooky horror scene".to_string()),
image_inputs: None,
resolution: None,
@@ -1,4 +1,4 @@
use seedance2pro_client::generate::image::generate_midjourney_v8::{
use kinovi_web_client::generate::image::generate_midjourney_v8::{
GenerateMidjourneyV8AspectRatio, GenerateMidjourneyV8Request, KinoviMidjourneyBatchCount,
};
@@ -1,4 +1,4 @@
use seedance2pro_client::generate::image::generate_midjourney_v8::{
use kinovi_web_client::generate::image::generate_midjourney_v8::{
GenerateMidjourneyV8AspectRatio, GenerateMidjourneyV8Quality,
GenerateMidjourneyV8Request, KinoviMidjourneyBatchCount,
};
@@ -29,7 +29,7 @@ impl KinoviMidjourney8DraftState {
&mut self,
draft_context: &ImageGenerationDraftContext<'_>,
) -> Result<KinoviMidjourney8RequestState, ArtcraftRouterError> {
let client = draft_context.get_seedance2pro_client_ref()?;
let client = draft_context.get_kinovi_web_client_ref()?;
let session = &client.session;
let mut reference_image_urls = None;
@@ -1,12 +1,12 @@
use seedance2pro_client::generate::image::generate_midjourney_v8::{
use kinovi_web_client::generate::image::generate_midjourney_v8::{
generate_midjourney_v8, GenerateMidjourneyV8Args, GenerateMidjourneyV8Request,
};
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_image::generate_image_response::{
GenerateImageResponse, Seedance2proImageResponsePayload,
GenerateImageResponse, KinoviWebImageResponsePayload,
};
#[derive(Debug, Clone)]
@@ -17,7 +17,7 @@ pub struct KinoviMidjourney8RequestState {
impl KinoviMidjourney8RequestState {
pub async fn send(
&self,
client: &RouterSeedance2ProClient,
client: &RouterKinoviWebClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let args = GenerateMidjourneyV8Args {
session: &client.session,
@@ -27,9 +27,9 @@ impl KinoviMidjourney8RequestState {
let response = generate_midjourney_v8(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?;
Ok(GenerateImageResponse::Seedance2Pro(Seedance2proImageResponsePayload {
Ok(GenerateImageResponse::KinoviWeb(KinoviWebImageResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
maybe_order_ids: response.order_ids,
@@ -42,7 +42,7 @@ impl KinoviMidjourney8RequestState {
mod tests {
use std::collections::HashMap;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
@@ -51,7 +51,7 @@ mod tests {
use crate::api::router_provider::RouterProvider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::client::router_client::RouterClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest;
use crate::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext;
@@ -62,7 +62,7 @@ mod tests {
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::Midjourney8,
provider: RouterProvider::Seedance2Pro,
provider: RouterProvider::KinoviWeb,
prompt: Some("a spooky horror scene with ghosts in an abandoned mansion".to_string()),
image_inputs: None,
resolution: None,
@@ -80,9 +80,9 @@ mod tests {
fn get_kinovi_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt")
.expect("Failed to read seedance2pro cookies");
let session = Seedance2ProSession::from_cookies_string(cookies.trim().to_string());
RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session))
.expect("Failed to read kinovi_web cookies");
let session = KinoviWebSession::from_cookies_string(cookies.trim().to_string());
RouterClient::KinoviWeb(RouterKinoviWebClient::new(session))
}
#[tokio::test]
@@ -96,9 +96,9 @@ mod tests {
_ => panic!("expected direct Request for text-to-image"),
};
let response = request.send(client.get_seedance2pro_client_ref().unwrap())
let response = request.send(client.get_kinovi_web_client_ref().unwrap())
.await.expect("send should succeed");
let payload = response.get_seedance2pro_payload().expect("expected seedance2pro payload");
let payload = response.get_kinovi_web_payload().expect("expected kinovi_web payload");
println!("v8 t2i — task_id={}, order_id={}", payload.task_id, payload.order_id);
assert!(!payload.task_id.is_empty());
}
@@ -133,7 +133,7 @@ mod tests {
.finalize(ctx).await.expect("finalize");
let response = request.send_request(&client).await.expect("send_request");
let payload = response.get_seedance2pro_payload().expect("expected seedance2pro payload");
let payload = response.get_kinovi_web_payload().expect("expected kinovi_web payload");
println!("v8 with ref — task_id={}, order_id={}", payload.task_id, payload.order_id);
assert!(!payload.task_id.is_empty());
}
@@ -1,21 +1,21 @@
use std::collections::HashMap;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_image::providers::kinovi::upload::upload_to_seedance2pro;
use crate::generate::generate_image::providers::kinovi::upload::upload_to_kinovi_web;
/// Resolve an [`ImageListRef`] to a list of public Seedance2Pro CDN URLs,
/// Resolve an [`ImageListRef`] to a list of public KinoviWeb CDN URLs,
/// uploading each image one at a time. Preserves order.
///
/// Returns `Ok(None)` when the input is `None` or an empty list — the
/// caller can then omit the field from the wire request entirely. (The
/// downstream Midjourney API rejects empty arrays.)
pub(crate) async fn resolve_and_upload_image_list(
session: &Seedance2ProSession,
session: &KinoviWebSession,
list: Option<ImageListRef>,
maybe_map: Option<&HashMap<MediaFileToken, String>>,
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
@@ -29,7 +29,7 @@ pub(crate) async fn resolve_and_upload_image_list(
let mut uploaded = Vec::with_capacity(source_urls.len());
for url in &source_urls {
uploaded.push(upload_to_seedance2pro(session, url).await?);
uploaded.push(upload_to_kinovi_web(session, url).await?);
}
Ok(Some(uploaded))
}
@@ -1,4 +1,4 @@
use seedance2pro_client::generate::image::generate_seedream_5p0_pro::{
use kinovi_web_client::generate::image::generate_seedream_5p0_pro::{
GenerateSeedream5p0ProRequest, KinoviSeedream5p0ProAspectRatio, KinoviSeedream5p0ProBatchCount,
KinoviSeedream5p0ProResolution, MAX_REFERENCE_IMAGES,
};
@@ -67,7 +67,7 @@ fn has_image_inputs(image_inputs: Option<&ImageListRef>) -> bool {
}
/// Reject over-limit reference lists before any uploads happen — the
/// Seedance2Pro client would reject them anyway, but only after the draft
/// KinoviWeb client would reject them anyway, but only after the draft
/// stage has already uploaded every image to the Kinovi CDN.
fn validate_reference_image_count(
image_inputs: Option<&ImageListRef>,
@@ -191,7 +191,7 @@ mod tests {
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::Seedream5p0Pro,
provider: RouterProvider::Seedance2Pro,
provider: RouterProvider::KinoviWeb,
prompt: Some("an anime girl riding a dinosaur".to_string()),
image_inputs: None,
resolution: None,
@@ -1,4 +1,4 @@
use seedance2pro_client::generate::image::generate_seedream_5p0_pro::{
use kinovi_web_client::generate::image::generate_seedream_5p0_pro::{
GenerateSeedream5p0ProRequest, KinoviSeedream5p0ProAspectRatio, KinoviSeedream5p0ProBatchCount,
KinoviSeedream5p0ProResolution,
};
@@ -1,4 +1,4 @@
use seedance2pro_client::generate::image::generate_seedream_5p0_pro::{
use kinovi_web_client::generate::image::generate_seedream_5p0_pro::{
GenerateSeedream5p0ProRequest, KinoviSeedream5p0ProAspectRatio, KinoviSeedream5p0ProBatchCount,
KinoviSeedream5p0ProResolution,
};
@@ -29,7 +29,7 @@ impl KinoviSeedream5p0ProDraftState {
&mut self,
draft_context: &ImageGenerationDraftContext<'_>,
) -> Result<KinoviSeedream5p0ProRequestState, ArtcraftRouterError> {
let client = draft_context.get_seedance2pro_client_ref()?;
let client = draft_context.get_kinovi_web_client_ref()?;
let session = &client.session;
let mut reference_image_urls = None;
@@ -1,12 +1,12 @@
use seedance2pro_client::generate::image::generate_seedream_5p0_pro::{
use kinovi_web_client::generate::image::generate_seedream_5p0_pro::{
generate_seedream_5p0_pro, GenerateSeedream5p0ProArgs, GenerateSeedream5p0ProRequest,
};
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_image::generate_image_response::{
GenerateImageResponse, Seedance2proImageResponsePayload,
GenerateImageResponse, KinoviWebImageResponsePayload,
};
#[derive(Debug, Clone)]
@@ -17,7 +17,7 @@ pub struct KinoviSeedream5p0ProRequestState {
impl KinoviSeedream5p0ProRequestState {
pub async fn send(
&self,
client: &RouterSeedance2ProClient,
client: &RouterKinoviWebClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let args = GenerateSeedream5p0ProArgs {
session: &client.session,
@@ -27,9 +27,9 @@ impl KinoviSeedream5p0ProRequestState {
let response = generate_seedream_5p0_pro(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?;
Ok(GenerateImageResponse::Seedance2Pro(Seedance2proImageResponsePayload {
Ok(GenerateImageResponse::KinoviWeb(KinoviWebImageResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
maybe_order_ids: response.order_ids,
@@ -42,7 +42,7 @@ impl KinoviSeedream5p0ProRequestState {
mod tests {
use std::collections::HashMap;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
@@ -52,7 +52,7 @@ mod tests {
use crate::api::router_resolution::RouterResolution;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::client::router_client::RouterClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest;
use crate::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext;
@@ -63,7 +63,7 @@ mod tests {
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::Seedream5p0Pro,
provider: RouterProvider::Seedance2Pro,
provider: RouterProvider::KinoviWeb,
prompt: Some("an anime girl riding a dinosaur through a neon city".to_string()),
image_inputs: None,
resolution: Some(RouterResolution::OneK),
@@ -81,9 +81,9 @@ mod tests {
fn get_kinovi_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt")
.expect("Failed to read seedance2pro cookies");
let session = Seedance2ProSession::from_cookies_string(cookies.trim().to_string());
RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session))
.expect("Failed to read kinovi_web cookies");
let session = KinoviWebSession::from_cookies_string(cookies.trim().to_string());
RouterClient::KinoviWeb(RouterKinoviWebClient::new(session))
}
#[tokio::test]
@@ -97,9 +97,9 @@ mod tests {
_ => panic!("expected direct Request for text-to-image"),
};
let response = request.send(client.get_seedance2pro_client_ref().unwrap())
let response = request.send(client.get_kinovi_web_client_ref().unwrap())
.await.expect("send should succeed");
let payload = response.get_seedance2pro_payload().expect("expected seedance2pro payload");
let payload = response.get_kinovi_web_payload().expect("expected kinovi_web payload");
println!("seedream 5.0 pro t2i — task_id={}, order_id={}", payload.task_id, payload.order_id);
assert!(!payload.task_id.is_empty());
}
@@ -134,7 +134,7 @@ mod tests {
.finalize(ctx).await.expect("finalize");
let response = request.send_request(&client).await.expect("send_request");
let payload = response.get_seedance2pro_payload().expect("expected seedance2pro payload");
let payload = response.get_kinovi_web_payload().expect("expected kinovi_web payload");
println!("seedream 5.0 pro with ref — task_id={}, order_id={}", payload.task_id, payload.order_id);
assert!(!payload.task_id.is_empty());
}
@@ -1,6 +1,6 @@
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro_client::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs};
use seedance2pro_client::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession;
use kinovi_web_client::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs};
use kinovi_web_client::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
use url_utils::extension::extract_extension_from_url::{extract_extension_from_url_str, ExtractExtensions};
use crate::errors::artcraft_router_error::ArtcraftRouterError;
@@ -8,14 +8,14 @@ use crate::errors::provider_error::ProviderError;
use crate::utils::download_file::download_file;
/// Downloads an image from a source URL and re-uploads it to the
/// Seedance2Pro/Kinovi CDN. Returns the public URL.
/// KinoviWeb/Kinovi CDN. Returns the public URL.
///
/// This is the image-side twin of `generate_video::providers::kinovi::upload`.
/// The actual upload path doesn't care whether the file is a video or an
/// image, but keeping the two helpers separate lets each side evolve
/// independently and makes intent clear at call sites.
pub(crate) async fn upload_to_seedance2pro(
session: &Seedance2ProSession,
pub(crate) async fn upload_to_kinovi_web(
session: &KinoviWebSession,
source_url: &str,
) -> Result<String, ArtcraftRouterError> {
let extension = extract_extension_from_url_str(source_url, &ExtractExtensions::All)
@@ -30,7 +30,7 @@ pub(crate) async fn upload_to_seedance2pro(
host_override: None,
})
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?;
let upload_response = upload_file(UploadFileArgs {
upload_url: prepare_response.upload_url,
@@ -38,7 +38,7 @@ pub(crate) async fn upload_to_seedance2pro(
host_override: None,
})
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?;
Ok(upload_response.public_url)
}
@@ -268,7 +268,7 @@ mod tests {
for provider in [
RouterProvider::GmiCloud,
RouterProvider::GrokApi,
RouterProvider::Seedance2Pro,
RouterProvider::KinoviWeb,
RouterProvider::WorldLabs,
] {
let result = builder_with_url_image(provider, RouterMeshModel::Hunyuan3d3).build2();
@@ -252,7 +252,7 @@ mod tests {
RouterProvider::Fal,
RouterProvider::GmiCloud,
RouterProvider::GrokApi,
RouterProvider::Seedance2Pro,
RouterProvider::KinoviWeb,
] {
let result = GenerateSplatRequestBuilder {
provider,
@@ -74,17 +74,17 @@ generate_video/
│ ├── seedance_2p0/ ← Seedance 2.0 Pro model (skips draft, returns Request directly)
│ │ ├── mod.rs
│ │ ├── build.rs ← builder → Request conversion + plan helpers
│ │ ├── cost.rs ← standalone cost estimation (independent of seedance2pro_client)
│ │ ├── cost.rs ← standalone cost estimation (independent of kinovi_web_client)
│ │ └── request.rs ← request state + send() via Artcraft multi-function API
│ └── seedance_2p0_fast/ ← Seedance 2.0 Fast model (skips draft, 480p/720p only)
│ ├── mod.rs
│ ├── build.rs ← builder → Request conversion + plan helpers (no 1080p)
│ ├── cost.rs ← standalone cost estimation with Fast-specific pricing
│ └── request.rs ← request state + send() via Artcraft omni-gen API
└── kinovi/ ← Kinovi/Seedance2Pro provider
└── kinovi/ ← Kinovi/KinoviWeb provider
├── mod.rs
├── resolve.rs ← shared: media token resolution, upload helpers
├── upload.rs ← shared: download + re-upload to Seedance2Pro CDN
├── upload.rs ← shared: download + re-upload to KinoviWeb CDN
├── seedance_2p0/ ← Seedance 2.0 Pro model
│ ├── mod.rs
│ ├── build.rs ← builder → draft conversion + plan helpers
@@ -43,6 +43,7 @@ use crate::generate::generate_video::providers::artcraft::veo_3_fast::build::bui
use crate::generate::generate_video::providers::artcraft::veo_3p1::build::build_artcraft_veo_3p1;
use crate::generate::generate_video::providers::artcraft::veo_3p1_fast::build::build_artcraft_veo_3p1_fast;
use crate::generate::generate_video::providers::artcraft::veo_3p1_lite::build::build_artcraft_veo_3p1_lite;
use crate::generate::generate_video::providers::artcraft::minimax_h3::build::build_artcraft_minimax_h3;
use crate::generate::generate_video::providers::artcraft::vidu_q3::build::build_artcraft_vidu_q3;
use crate::generate::generate_video::providers::artcraft::vidu_q3_turbo::build::build_artcraft_vidu_q3_turbo;
use crate::generate::generate_video::providers::kinovi::happy_horse_1p0::build::build_kinovi_happy_horse_1p0;
@@ -68,10 +69,13 @@ 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::fal::veo_3p1_lite::build::build_fal_veo_3p1_lite;
use crate::generate::generate_video::providers::fal::minimax_h3::build::build_fal_minimax_h3;
use crate::generate::generate_video::providers::fal::vidu_q3::build::build_fal_vidu_q3;
use crate::generate::generate_video::providers::fal::vidu_q3_turbo::build::build_fal_vidu_q3_turbo;
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::providers::artcraft::seedance_2p5_preview::build::build_artcraft_seedance_2p5_preview;
use crate::generate::generate_video::providers::kinovi::seedance_2p5_preview::build::build_kinovi_seedance_2p5_preview;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
/// RouterProvider-agnostic video generation request. Distilled by `build2()` into a
@@ -178,6 +182,7 @@ impl GenerateVideoRequestBuilder {
(RouterProvider::Artcraft, RouterVideoModel::Kling2p6Pro) => build_artcraft_kling_2_6_pro(self),
(RouterProvider::Artcraft, RouterVideoModel::Kling3p0Pro) => build_artcraft_kling_3p0_pro(self),
(RouterProvider::Artcraft, RouterVideoModel::Kling3p0Standard) => build_artcraft_kling_3p0_standard(self),
(RouterProvider::Artcraft, RouterVideoModel::MinimaxH3) => build_artcraft_minimax_h3(self),
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0) => build_artcraft_seedance_2p0(self),
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0Fast) => build_artcraft_seedance_2p0_fast(self),
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0Ultra) => build_artcraft_seedance_2p0_u(self),
@@ -189,6 +194,7 @@ impl GenerateVideoRequestBuilder {
(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::Seedance2p5Preview) => build_artcraft_seedance_2p5_preview(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),
@@ -211,6 +217,7 @@ impl GenerateVideoRequestBuilder {
(RouterProvider::Fal, RouterVideoModel::Kling2p6Pro) => build_fal_kling_2_6_pro(self),
(RouterProvider::Fal, RouterVideoModel::Kling3p0Pro) => build_fal_kling_3p0_pro(self),
(RouterProvider::Fal, RouterVideoModel::Kling3p0Standard) => build_fal_kling_3p0_standard(self),
(RouterProvider::Fal, RouterVideoModel::MinimaxH3) => build_fal_minimax_h3(self),
(RouterProvider::Fal, RouterVideoModel::Seedance10Lite) => build_fal_seedance_1p0_lite(self),
(RouterProvider::Fal, RouterVideoModel::Seedance1p5Pro) => build_fal_seedance_1p5_pro(self),
(RouterProvider::Fal, RouterVideoModel::Sora2) => build_fal_sora_2(self),
@@ -230,12 +237,13 @@ impl GenerateVideoRequestBuilder {
(RouterProvider::GrokApi, RouterVideoModel::GrokImagineVideo) => build_grok_api_grok_imagine_video(self),
(RouterProvider::GrokApi, RouterVideoModel::GrokImagineVideo1p5) => build_grok_api_grok_imagine_video_1p5(self),
// Kinovi
(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),
(RouterProvider::KinoviWeb, RouterVideoModel::HappyHorse1p0) => build_kinovi_happy_horse_1p0(self),
(RouterProvider::KinoviWeb, RouterVideoModel::Seedance2p0) => build_kinovi_seedance_2p0(self),
(RouterProvider::KinoviWeb, RouterVideoModel::Seedance2p0Fast) => build_kinovi_seedance_2p0_fast(self),
(RouterProvider::KinoviWeb, RouterVideoModel::Seedance2p0Mini) => build_kinovi_seedance_2p0_mini(self),
(RouterProvider::KinoviWeb, RouterVideoModel::Seedance2p0BytePlusMini) => build_kinovi_seedance_2p0_mini(self),
(RouterProvider::KinoviWeb, RouterVideoModel::Seedance2p0BytePlusUltraMini) => build_kinovi_seedance_2p0_mini(self),
(RouterProvider::KinoviWeb, RouterVideoModel::Seedance2p5Preview) => build_kinovi_seedance_2p5_preview(self),
_ => self.unsupported_provider_and_model(),
}
}
@@ -10,7 +10,7 @@ pub struct ArtcraftVideoResponsePayload {
}
#[derive(Clone, Debug)]
pub struct Seedance2proVideoResponsePayload {
pub struct KinoviWebVideoResponsePayload {
pub order_id: String,
pub task_id: String,
pub maybe_order_ids: Option<Vec<String>>,
@@ -58,7 +58,7 @@ pub enum GenerateVideoResponse {
Artcraft(ArtcraftVideoResponsePayload),
GmiCloud(GmiCloudVideoResponsePayload),
Grok(GrokVideoResponsePayload),
Seedance2Pro(Seedance2proVideoResponsePayload),
KinoviWeb(KinoviWebVideoResponsePayload),
Fal(FalVideoResponsePayload),
}
@@ -70,9 +70,9 @@ impl GenerateVideoResponse {
}
}
pub fn get_seedance2pro_payload(&self) -> Option<Seedance2proVideoResponsePayload> {
pub fn get_kinovi_web_payload(&self) -> Option<KinoviWebVideoResponsePayload> {
match self {
Self::Seedance2Pro(p) => Some(p.clone()),
Self::KinoviWeb(p) => Some(p.clone()),
_ => None,
}
}
@@ -14,8 +14,7 @@ use crate::generate::generate_video::video_generation_request::VideoGenerationRe
/// Same constraints as the v1 variant — xAI v1.5 still supports only 480p
/// and 720p and no 21:9 ultra-wide. The on-wire model identifier is
/// `grok-imagine-video-1.5`; the cost calculator in
/// [`super::cost`] keys off that to apply the v1.5 pricing tier (with a 5%
/// ArtCraft markup on top of the upstream rates).
/// [`super::cost`] keys off that to apply the v1.5 pricing tier.
pub fn build_artcraft_grok_imagine_video_1p5(
builder: GenerateVideoRequestBuilder,
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
@@ -14,10 +14,7 @@ use crate::generate::generate_video::providers::artcraft::grok_imagine_video_1p5
// ── Markup ──
//
// ArtCraft adds a modest 5% markup over the underlying xAI v1.5 pricing. The
// markup is applied at cent granularity with ceiling rounding so the final
// charge always covers the upstream cost (we never bill the user less than
// the cost of the API call we forwarded).
// The markup is applied at cent granularity with ceiling rounding.
const MARKUP_NUMERATOR: u64 = 105;
const MARKUP_DENOMINATOR: u64 = 100;
@@ -69,8 +66,7 @@ impl ArtcraftGrokImagineVideo1p5CostState {
// composing. The image requirement is enforced at send time (request.rs)
// and by the generate endpoints.
let base = self.base_cost_in_cents_for_batch();
// 5% markup, ceil so the user is always charged enough to cover the
// upstream grok cost regardless of rounding.
// Ceil at cent granularity.
let usd_cents = base.saturating_mul(MARKUP_NUMERATOR).div_ceil(MARKUP_DENOMINATOR);
Ok(VideoGenerationCostEstimate {
@@ -15,8 +15,8 @@ impl ArtcraftKling16ProCostState {
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// 5s = 52¢, 10s = 103¢.
let cost_in_usd_cents: u64 = if self.is_ten_seconds { 103 } else { 52 };
// 5s = 57¢, 10s = 113¢.
let cost_in_usd_cents: u64 = if self.is_ten_seconds { 113 } else { 57 };
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -51,11 +51,11 @@ mod tests {
}
#[test]
fn five_seconds_is_52() { assert_eq!(cost_cents(Some(5)), 52); }
fn five_seconds_is_57() { assert_eq!(cost_cents(Some(5)), 57); }
#[test]
fn ten_seconds_is_103() { assert_eq!(cost_cents(Some(10)), 103); }
fn ten_seconds_is_113() { assert_eq!(cost_cents(Some(10)), 113); }
#[test]
fn default_duration_is_5s_priced_at_52() { assert_eq!(cost_cents(None), 52); }
fn default_duration_is_5s_priced_at_57() { assert_eq!(cost_cents(None), 57); }
}
@@ -13,7 +13,7 @@ impl ArtcraftKling21MasterCostState {
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Mirrors fal_client kling_v2p1_master: 5s = $1.40, 10s = $2.80.
let cost_in_usd_cents: u64 = if self.is_ten_seconds { 280 } else { 140 };
let cost_in_usd_cents: u64 = if self.is_ten_seconds { 322 } else { 161 };
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -48,11 +48,11 @@ mod tests {
}
#[test]
fn five_seconds_is_140() { assert_eq!(cost_cents(Some(5)), 140); }
fn five_seconds_is_161() { assert_eq!(cost_cents(Some(5)), 161); }
#[test]
fn ten_seconds_is_280() { assert_eq!(cost_cents(Some(10)), 280); }
fn ten_seconds_is_322() { assert_eq!(cost_cents(Some(10)), 322); }
#[test]
fn default_duration_is_5s_priced_at_140() { assert_eq!(cost_cents(None), 140); }
fn default_duration_is_5s_priced_at_161() { assert_eq!(cost_cents(None), 161); }
}
@@ -13,7 +13,7 @@ impl ArtcraftKling21ProCostState {
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Mirrors fal_client kling_v2p1_pro: 5s = 45¢, 10s = 90¢.
let cost_in_usd_cents: u64 = if self.is_ten_seconds { 90 } else { 45 };
let cost_in_usd_cents: u64 = if self.is_ten_seconds { 104 } else { 52 };
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -48,11 +48,11 @@ mod tests {
}
#[test]
fn five_seconds_is_45() { assert_eq!(cost_cents(Some(5)), 45); }
fn five_seconds_is_52() { assert_eq!(cost_cents(Some(5)), 52); }
#[test]
fn ten_seconds_is_90() { assert_eq!(cost_cents(Some(10)), 90); }
fn ten_seconds_is_104() { assert_eq!(cost_cents(Some(10)), 104); }
#[test]
fn default_duration_is_5s_priced_at_45() { assert_eq!(cost_cents(None), 45); }
fn default_duration_is_5s_priced_at_52() { assert_eq!(cost_cents(None), 52); }
}
@@ -13,7 +13,7 @@ impl ArtcraftKling2p5TurboProCostState {
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Mirrors fal_client kling_v2p5_turbo_pro: 5s = 35¢, 10s = 70¢.
let cost_in_usd_cents: u64 = if self.is_ten_seconds { 70 } else { 35 };
let cost_in_usd_cents: u64 = if self.is_ten_seconds { 81 } else { 41 };
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -45,11 +45,11 @@ mod tests {
}
#[test]
fn five_seconds_is_35() { assert_eq!(cost_cents(Some(5)), 35); }
fn five_seconds_is_41() { assert_eq!(cost_cents(Some(5)), 41); }
#[test]
fn ten_seconds_is_70() { assert_eq!(cost_cents(Some(10)), 70); }
fn ten_seconds_is_81() { assert_eq!(cost_cents(Some(10)), 81); }
#[test]
fn default_duration_is_5s_priced_at_35() { assert_eq!(cost_cents(None), 35); }
fn default_duration_is_5s_priced_at_41() { assert_eq!(cost_cents(None), 41); }
}
@@ -21,10 +21,10 @@ impl ArtcraftKling2p6ProCostState {
// audio off: $0.07/sec → 5s=35¢, 10s=70¢
// audio on: $0.14/sec → 5s=70¢, 10s=140¢
let cost_in_usd_cents: u64 = match (self.generate_audio, self.is_ten_seconds) {
(false, false) => 35,
(false, true) => 70,
(true, false) => 70,
(true, true) => 140,
(false, false) => 41,
(false, true) => 81,
(true, false) => 81,
(true, true) => 161,
};
VideoGenerationCostEstimate {
@@ -58,16 +58,16 @@ mod tests {
}
#[test]
fn audio_off_5s_is_35() { assert_eq!(cost_cents(Some(5), Some(false)), 35); }
fn audio_off_5s_is_41() { assert_eq!(cost_cents(Some(5), Some(false)), 41); }
#[test]
fn audio_off_10s_is_70() { assert_eq!(cost_cents(Some(10), Some(false)), 70); }
fn audio_off_10s_is_81() { assert_eq!(cost_cents(Some(10), Some(false)), 81); }
#[test]
fn audio_on_5s_is_70() { assert_eq!(cost_cents(Some(5), Some(true)), 70); }
fn audio_on_5s_is_81() { assert_eq!(cost_cents(Some(5), Some(true)), 81); }
#[test]
fn audio_on_10s_is_140() { assert_eq!(cost_cents(Some(10), Some(true)), 140); }
fn audio_on_10s_is_161() { assert_eq!(cost_cents(Some(10), Some(true)), 161); }
#[test]
fn audio_default_is_on() {
@@ -1,4 +1,8 @@
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
// The markup is applied at cent granularity with ceiling rounding.
const MARKUP_NUMERATOR: u64 = 115;
const MARKUP_DENOMINATOR: u64 = 100;
use crate::generate::generate_video::providers::artcraft::kling_3p0_pro::request::ArtcraftKling3p0ProRequestState;
#[derive(Clone, Debug)]
@@ -17,12 +21,11 @@ impl ArtcraftKling3p0ProCostState {
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Mirrors fal_client kling_3p0_pro:
// audio off: $0.224/sec (rate=224 in tenths-of-cents)
// audio on: $0.336/sec (rate=336)
// ceiling-divided to whole cents.
// Base rates in tenths of a cent per second; the total is
// ceiling-divided to whole cents, then the markup applied.
let rate: u64 = if self.generate_audio { 336 } else { 224 };
let cost_in_usd_cents = (rate * self.duration_seconds + 9) / 10;
let base = (rate * self.duration_seconds + 9) / 10;
let cost_in_usd_cents = base.saturating_mul(MARKUP_NUMERATOR).div_ceil(MARKUP_DENOMINATOR);
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -57,24 +60,24 @@ mod tests {
#[test]
fn audio_on_5s_is_168() {
// rate=336, (336*5 + 9) / 10 = 1689/10 = 168.
assert_eq!(cost_cents(Some(5), Some(true)), 168);
assert_eq!(cost_cents(Some(5), Some(true)), 194);
}
#[test]
fn audio_off_5s_is_112() {
// rate=224, (224*5 + 9) / 10 = 1129/10 = 112.
assert_eq!(cost_cents(Some(5), Some(false)), 112);
assert_eq!(cost_cents(Some(5), Some(false)), 129);
}
#[test]
fn audio_on_10s_is_336() {
assert_eq!(cost_cents(Some(10), Some(true)), 336);
assert_eq!(cost_cents(Some(10), Some(true)), 387);
}
#[test]
fn audio_on_15s_is_504() {
// rate=336, (336*15 + 9) / 10 = 5049/10 = 504.
assert_eq!(cost_cents(Some(15), Some(true)), 504);
assert_eq!(cost_cents(Some(15), Some(true)), 580);
}
#[test]
@@ -1,6 +1,10 @@
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::artcraft::kling_3p0_standard::request::ArtcraftKling3p0StandardRequestState;
// The markup is applied at cent granularity with ceiling rounding.
const MARKUP_NUMERATOR: u64 = 115;
const MARKUP_DENOMINATOR: u64 = 100;
#[derive(Clone, Debug)]
pub struct ArtcraftKling3p0StandardCostState {
pub duration_seconds: u64,
@@ -16,11 +20,11 @@ impl ArtcraftKling3p0StandardCostState {
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Mirrors fal_client kling_3p0_standard:
// audio off: $0.168/sec (rate=168)
// audio on: $0.252/sec (rate=252)
// Base rates in tenths of a cent per second; the total is
// ceiling-divided to whole cents, then the markup applied.
let rate: u64 = if self.generate_audio { 252 } else { 168 };
let cost_in_usd_cents = (rate * self.duration_seconds + 9) / 10;
let base = (rate * self.duration_seconds + 9) / 10;
let cost_in_usd_cents = base.saturating_mul(MARKUP_NUMERATOR).div_ceil(MARKUP_DENOMINATOR);
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -53,26 +57,26 @@ mod tests {
}
#[test]
fn audio_on_5s_is_126() {
// rate=252, (252*5 + 9) / 10 = 1269/10 = 126.
assert_eq!(cost_cents(Some(5), Some(true)), 126);
fn audio_on_5s_is_145() {
// rate=252, base (252*5 + 9) / 10 = 126, ×115/100 ceiled = 145.
assert_eq!(cost_cents(Some(5), Some(true)), 145);
}
#[test]
fn audio_off_5s_is_84() {
// rate=168, (168*5 + 9) / 10 = 849/10 = 84.
assert_eq!(cost_cents(Some(5), Some(false)), 84);
fn audio_off_5s_is_97() {
// rate=168, base (168*5 + 9) / 10 = 84, ×115/100 ceiled = 97.
assert_eq!(cost_cents(Some(5), Some(false)), 97);
}
#[test]
fn audio_on_10s_is_252() {
assert_eq!(cost_cents(Some(10), Some(true)), 252);
fn audio_on_10s_is_290() {
assert_eq!(cost_cents(Some(10), Some(true)), 290);
}
#[test]
fn audio_on_15s_is_378() {
// (252*15 + 9) / 10 = 3789/10 = 378.
assert_eq!(cost_cents(Some(15), Some(true)), 378);
fn audio_on_15s_is_435() {
// base (252*15 + 9) / 10 = 378, ×115/100 ceiled = 435.
assert_eq!(cost_cents(Some(15), Some(true)), 435);
}
#[test]
@@ -0,0 +1,25 @@
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::minimax_h3::request::ArtcraftMinimaxH3RequestState;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_artcraft_minimax_h3(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let state = build_artcraft_minimax_h3_state(builder)?;
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftMinimaxH3(state)))
}
pub(crate) fn build_artcraft_minimax_h3_state(builder: GenerateVideoRequestBuilder) -> Result<ArtcraftMinimaxH3RequestState, ArtcraftRouterError> {
let request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::MinimaxH3,
SupportedResolutions::Full,
UltraWideSupport::Supported,
)?;
Ok(ArtcraftMinimaxH3RequestState { request })
}
@@ -0,0 +1,158 @@
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use crate::generate::generate_video::providers::artcraft::minimax_h3::request::ArtcraftMinimaxH3RequestState;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
/// Per-second rates in hundredths of a US cent.
const LOW_RES_RATE_HUNDREDTH_CENTS_PER_SEC: u64 = 1_840;
const HIGH_RES_RATE_HUNDREDTH_CENTS_PER_SEC: u64 = 2_990;
/// The first 5 reference images are included; each additional image bills a
/// flat fee (in hundredths of a US cent).
const INCLUDED_REFERENCE_IMAGES: u64 = 5;
const EXTRA_REFERENCE_IMAGE_HUNDREDTH_CENTS: u64 = 920;
#[derive(Clone, Debug)]
pub struct ArtcraftMinimaxH3CostState {
pub duration_seconds: u64,
pub is_2k: bool,
pub reference_image_count: u64,
}
impl ArtcraftMinimaxH3CostState {
pub fn from_request(request: &ArtcraftMinimaxH3RequestState) -> Self {
Self {
// MiniMax H3 defaults None → 5s.
duration_seconds: request.request.duration_seconds.map(u64::from).unwrap_or(5),
is_2k: is_2k(request.request.resolution),
reference_image_count: request.request.reference_image_media_tokens
.as_ref()
.map_or(0, |tokens| tokens.len() as u64),
}
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
let rate = if self.is_2k {
HIGH_RES_RATE_HUNDREDTH_CENTS_PER_SEC
} else {
LOW_RES_RATE_HUNDREDTH_CENTS_PER_SEC
};
let extra_images = self.reference_image_count.saturating_sub(INCLUDED_REFERENCE_IMAGES);
// Round up to the next whole cent.
let cost_in_usd_cents =
(rate * self.duration_seconds + extra_images * EXTRA_REFERENCE_IMAGE_HUNDREDTH_CENTS)
.div_ceil(100);
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
/// MiniMax H3 offers 768P and 2K output; 1080p and above land on the 2K tier,
/// while 720p and below render at 768P. The default (unset) resolution is 2K.
fn is_2k(resolution: Option<CommonResolutionEnum>) -> bool {
match resolution {
None => true, // defaults to 2K
Some(CommonResolutionEnum::TenEightyP)
| Some(CommonResolutionEnum::OneK)
| Some(CommonResolutionEnum::TwoK)
| Some(CommonResolutionEnum::ThreeK)
| Some(CommonResolutionEnum::FourK) => true,
Some(CommonResolutionEnum::HalfK)
| Some(CommonResolutionEnum::FourEightyP)
| Some(CommonResolutionEnum::SevenTwentyP) => false,
}
}
#[cfg(test)]
mod tests {
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_provider::RouterProvider;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::artcraft::minimax_h3::build::build_artcraft_minimax_h3_state;
use crate::generate::generate_video::providers::artcraft::minimax_h3::cost::ArtcraftMinimaxH3CostState;
#[test]
fn default_resolution_5s_is_150() { assert_eq!(cost_cents(Some(5), None, 0), 150); }
#[test]
fn default_duration_is_5s() { assert_eq!(cost_cents(None, None, 0), 150); }
#[test]
fn low_res_5s_is_92() { assert_eq!(cost_cents(Some(5), Some(RouterResolution::SevenTwentyP), 0), 92); }
#[test]
fn four_eighty_p_lands_on_low_tier() {
assert_eq!(cost_cents(Some(5), Some(RouterResolution::FourEightyP), 0), 92);
}
#[test]
fn low_res_15s_is_276() { assert_eq!(cost_cents(Some(15), Some(RouterResolution::SevenTwentyP), 0), 276); }
#[test]
fn high_res_10s_is_299() { assert_eq!(cost_cents(Some(10), Some(RouterResolution::TenEightyP), 0), 299); }
#[test]
fn odd_duration_rounds_up_to_whole_cents() {
// 7s high res: 2990 × 7 = 20930 hundredth-cents → 210 cents.
assert_eq!(cost_cents(Some(7), None, 0), 210);
}
#[test]
fn five_reference_images_are_included() {
assert_eq!(cost_cents(Some(5), None, 5), cost_cents(Some(5), None, 0));
}
#[test]
fn nine_reference_images_add_the_extra_image_fee() {
// 2990 × 5 + 4 × 920 = 18630 hundredth-cents → 187 cents.
assert_eq!(cost_cents(Some(5), None, 9), 187);
}
#[test]
fn resolution_classifier_defaults_2k() {
assert!(super::is_2k(None));
assert!(super::is_2k(Some(CommonResolutionEnum::TenEightyP)));
assert!(!super::is_2k(Some(CommonResolutionEnum::SevenTwentyP)));
assert!(!super::is_2k(Some(CommonResolutionEnum::FourEightyP)));
}
fn cost_cents(
duration_seconds: Option<u16>,
resolution: Option<RouterResolution>,
reference_image_count: usize,
) -> u64 {
let reference_images = if reference_image_count == 0 {
None
} else {
Some(ImageListRef::MediaFileTokens(
(0..reference_image_count)
.map(|i| MediaFileToken::new(format!("m_test_{i}")))
.collect(),
))
};
let b = GenerateVideoRequestBuilder {
model: RouterVideoModel::MinimaxH3,
provider: RouterProvider::Artcraft,
prompt: Some("test".to_string()),
duration_seconds,
resolution,
reference_images,
..Default::default()
};
let state = build_artcraft_minimax_h3_state(b).unwrap();
ArtcraftMinimaxH3CostState::from_request(&state).estimate_cost().cost_in_usd_cents.unwrap()
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,117 @@
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 ArtcraftMinimaxH3RequestState {
pub request: OmniGenVideoCostAndGenerateRequest,
}
impl ArtcraftMinimaxH3RequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
send_artcraft_omni_video_request(&self.request, client).await
}
}
#[cfg(test)]
mod tests {
use tokens::tokens::media_files::MediaFileToken;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::utils::api_host::ApiHost;
use test_data::web::image_media_tokens::JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_provider::RouterProvider;
use crate::api::router_video_model::RouterVideoModel;
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;
#[tokio::test]
#[ignore] // sends a real generation to the Artcraft backend, incurs cost
async fn text_to_video() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A red kite soaring over coastal cliffs, cinematic.".to_string()),
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // sends a real generation to the Artcraft backend, incurs cost
async fn keyframe_start_frame() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("The dog stands up and walks toward the lake.".to_string()),
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()))),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // sends a real generation to the Artcraft backend, incurs cost
async fn reference_images_to_video() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("Image 1 is the dog. The dog explores a mossy forest clearing.".to_string()),
reference_images: Some(ImageListRef::MediaFileTokens(vec![MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string())])),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
// ── Helpers ──
fn artcraft_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::MinimaxH3,
provider: RouterProvider::Artcraft,
duration_seconds: Some(5),
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)"),
};
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateVideoResponse::Artcraft(p) => {
println!("inference_job_token={:?}", p.inference_job_token);
println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens);
}
other => println!("response: {:?}", other),
}
response
}
}
@@ -12,6 +12,7 @@ pub mod kling_2_5_turbo_pro;
pub mod kling_2_6_pro;
pub mod kling_3p0_pro;
pub mod kling_3p0_standard;
pub mod minimax_h3;
pub mod preview_model;
pub mod preview_model_fast;
pub(crate) mod request_common;
@@ -29,6 +30,7 @@ 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 seedance_2p5_preview;
pub mod sora_2;
pub mod sora_2_pro;
pub mod veo_2;
@@ -8,6 +8,10 @@ use fal_client::requests_old::webhook::video::text::enqueue_seedance_1p5_pro_tex
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::artcraft::seedance_1p5_pro::request::ArtcraftSeedance1p5ProRequestState;
// The markup is applied at cent granularity with ceiling rounding.
const MARKUP_NUMERATOR: u64 = 115;
const MARKUP_DENOMINATOR: u64 = 100;
#[derive(Clone, Debug)]
pub struct ArtcraftSeedance1p5ProCostState {
pub duration: Option<EnqueueSeedance1p5ProTextToVideoDuration>,
@@ -37,7 +41,8 @@ impl ArtcraftSeedance1p5ProCostState {
aspect_ratio: None,
generate_audio: self.generate_audio,
};
let cost_in_usd_cents = req.calculate_cost_in_cents();
let base = req.calculate_cost_in_cents();
let cost_in_usd_cents = base.saturating_mul(MARKUP_NUMERATOR).div_ceil(MARKUP_DENOMINATOR);
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -96,14 +101,14 @@ mod tests {
// Numeric literals mirror the v1 Artcraft Seedance 1.5 Pro tests we deleted:
// resolution=None + generate_audio=None → fal client defaults (720p, audio on).
#[test]
fn default_5s_is_26() { assert_eq!(cost_cents(Some(5)), 26); }
fn default_5s_is_30() { assert_eq!(cost_cents(Some(5)), 30); }
#[test]
fn default_4s_is_26() { assert_eq!(cost_cents(Some(4)), 26); }
fn default_4s_is_30() { assert_eq!(cost_cents(Some(4)), 30); }
#[test]
fn default_10s_is_65() { assert_eq!(cost_cents(Some(10)), 65); }
fn default_10s_is_75() { assert_eq!(cost_cents(Some(10)), 75); }
#[test]
fn default_12s_is_78() { assert_eq!(cost_cents(Some(12)), 78); }
fn default_12s_is_90() { assert_eq!(cost_cents(Some(12)), 90); }
}
@@ -8,25 +8,25 @@ use crate::generate::generate_video::video_generation_cost_estimate::VideoGenera
//
// ArtCraft credits: 100 credits = $1.00. Credits always equal USD cents.
//
// The per-second USD cost varies by resolution. We compute cents directly
// from the upstream credit rates and their credit-package prices,
// then set ArtCraft credits = cents.
// The per-second USD price varies by resolution.
/// USD cents per second by resolution, derived from upstream rates:
/// 480p: 15 upstream-credits/sec ÷ 193 upstream-credits/$1 × 100 ≈ 7.772 ¢/s
/// 720p: 40 upstream-credits/sec ÷ 250 upstream-credits/$1 × 100 = 16.0 ¢/s
/// 1080p: 90 upstream-credits/sec ÷ 193 upstream-credits/$1 × 100 ≈ 46.632 ¢/s
/// USD cents per second by resolution.
///
/// We keep these as f64 because per-second rates are fractional; rounding
/// happens once at the end after multiplying by duration × batch.
///
/// NB: Derivation is historical (193 credits/$1 package). The upstream
/// package is now 525,000 credits for $2,159.0909 (~243 credits/$1), but
/// user pricing is intentionally kept at these 193-derived rates.
/// Kept as f64 because per-second rates are fractional; rounding happens
/// once at the end after multiplying by duration × batch.
const CENTS_PER_SECOND_480P: f64 = 7.772;
const CENTS_PER_SECOND_720P: f64 = 16.0;
const CENTS_PER_SECOND_1080P: f64 = 46.632;
/// USD cents per second, in hundredths of a cent, when one or more
/// reference videos are attached. Held as integer hundredths so the math is
/// exact; rounded up to whole cents once, after multiplying by
/// duration × batch.
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_480P: u64 = 881;
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_720P: u64 = 2_205;
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_1080P: u64 = 5_110;
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_4K: u64 = 11_380;
pub struct ArtcraftSeedance2p0CostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
@@ -48,6 +48,19 @@ impl ArtcraftSeedance2p0CostState {
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
if self.has_video_reference {
let usd_cents = self.with_video_reference_usd_cents();
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,
};
}
if self.resolution == CommonResolution::FourK {
let usd_cents = seedance_2p0_four_k_usd_cents(
self.duration_seconds,
@@ -85,12 +98,31 @@ impl ArtcraftSeedance2p0CostState {
failures_are_refunded: None,
}
}
/// Price when one or more reference videos are attached.
fn with_video_reference_usd_cents(&self) -> u64 {
let centi_cents_per_second = match self.resolution {
CommonResolution::FourEightyP => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_480P,
CommonResolution::TenEightyP => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_1080P,
CommonResolution::FourK => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_4K,
// Everything else (including 720p) prices at 720p.
_ => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_720P,
};
let seconds = self.duration_seconds as u64 * self.batch_count as u64;
// Round up to whole cents.
(centi_cents_per_second * seconds).div_ceil(100)
}
}
#[cfg(test)]
mod tests {
use tokens::tokens::media_files::MediaFileToken;
use crate::api::router_provider::RouterProvider;
use crate::api::router_resolution::RouterResolution;
use crate::api::video_list_ref::VideoListRef;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
// -- 720p pricing --
@@ -202,10 +234,110 @@ mod tests {
#[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);
assert_eq!(artcraft_4k_cents(4, 1, true), 456);
assert_eq!(artcraft_4k_cents(5, 1, true), 569);
assert_eq!(artcraft_4k_cents(10, 1, true), 1138);
assert_eq!(artcraft_4k_cents(15, 1, true), 1707);
}
}
// -- With-video-reference pricing --
mod video_reference_pricing {
use super::*;
#[test]
fn table_480p() {
let expected = [36, 45, 53, 62, 71, 80, 89, 97, 106, 115, 124, 133];
for (i, cents) in expected.iter().enumerate() {
let duration = (i + 4) as u16;
assert_eq!(
cost_cents_with_video_ref(Some(RouterResolution::FourEightyP), duration, 1),
*cents,
"480p {duration}s",
);
}
}
#[test]
fn table_720p() {
let expected = [89, 111, 133, 155, 177, 199, 221, 243, 265, 287, 309, 331];
for (i, cents) in expected.iter().enumerate() {
let duration = (i + 4) as u16;
assert_eq!(
cost_cents_with_video_ref(Some(RouterResolution::SevenTwentyP), duration, 1),
*cents,
"720p {duration}s",
);
}
}
#[test]
fn table_1080p() {
let expected = [205, 256, 307, 358, 409, 460, 511, 563, 614, 665, 716, 767];
for (i, cents) in expected.iter().enumerate() {
let duration = (i + 4) as u16;
assert_eq!(
cost_cents_with_video_ref(Some(RouterResolution::TenEightyP), duration, 1),
*cents,
"1080p {duration}s",
);
}
}
#[test]
fn table_4k() {
let expected = [456, 569, 683, 797, 911, 1025, 1138, 1252, 1366, 1480, 1594, 1707];
for (i, cents) in expected.iter().enumerate() {
let duration = (i + 4) as u16;
assert_eq!(
cost_cents_with_video_ref(Some(RouterResolution::FourK), duration, 1),
*cents,
"4K {duration}s",
);
}
}
/// Batches price identically to the equivalent total seconds.
#[test]
fn batch_multiplies_total_seconds() {
assert_eq!(
cost_cents_with_video_ref(Some(RouterResolution::FourEightyP), 5, 2),
cost_cents_with_video_ref(Some(RouterResolution::FourEightyP), 10, 1),
);
assert_eq!(
cost_cents_with_video_ref(Some(RouterResolution::FourK), 5, 2),
cost_cents_with_video_ref(Some(RouterResolution::FourK), 10, 1),
);
}
/// Image and audio references do NOT trigger the with-reference pricing —
/// only reference videos do. (The non-reference price is unchanged.)
#[test]
fn image_and_audio_references_price_as_no_reference() {
use crate::api::audio_list_ref::AudioListRef;
use crate::api::image_list_ref::ImageListRef;
let builder = GenerateVideoRequestBuilder {
provider: RouterProvider::Artcraft,
resolution: Some(RouterResolution::SevenTwentyP),
duration_seconds: Some(5),
video_batch_count: Some(1),
reference_images: Some(ImageListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_img".to_string()),
])),
reference_audio: Some(AudioListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_audio".to_string()),
])),
..Default::default()
};
let cents = builder.build2()
.expect("build2 should succeed")
.estimate_cost()
.expect("estimate_cost should succeed")
.cost_in_usd_cents
.unwrap();
assert_eq!(cents, cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1));
}
}
@@ -308,4 +440,28 @@ mod tests {
.cost_in_usd_cents
.unwrap()
}
/// Like [`cost_cents`], with one reference video attached.
fn cost_cents_with_video_ref(
resolution: Option<RouterResolution>,
duration_seconds: u16,
video_batch_count: u16,
) -> u64 {
let builder = GenerateVideoRequestBuilder {
provider: RouterProvider::Artcraft,
resolution,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
reference_videos: Some(VideoListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_ref".to_string()),
])),
..Default::default()
};
builder.build2()
.expect("build2 should succeed")
.estimate_cost()
.expect("estimate_cost should succeed")
.cost_in_usd_cents
.unwrap()
}
}
@@ -25,7 +25,7 @@ impl ArtcraftSeedance2p0BytePlusMiniCostState {
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// BytePlus / BytePlus Ultra Mini share a higher-margin rate set; see
// BytePlus / BytePlus Ultra Mini share a rate set; see
// seedance_common::seedance_2p0_byteplus_mini_usd_cents.
let usd_cents = seedance_2p0_byteplus_mini_usd_cents(
self.resolution,
@@ -58,8 +58,7 @@ mod tests {
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.
// Every combination, driven through the full build pipeline.
mod pricing_480p {
use super::*;
@@ -25,7 +25,7 @@ impl ArtcraftSeedance2p0BytePlusUltraMiniCostState {
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// BytePlus / BytePlus Ultra Mini share a higher-margin rate set; see
// BytePlus / BytePlus Ultra Mini share a rate set; see
// seedance_common::seedance_2p0_byteplus_mini_usd_cents.
let usd_cents = seedance_2p0_byteplus_mini_usd_cents(
self.resolution,
@@ -58,8 +58,7 @@ mod tests {
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.
// Every combination, driven through the full build pipeline.
mod pricing_480p {
use super::*;
@@ -7,21 +7,22 @@ use crate::generate::generate_video::providers::artcraft::seedance_2p0_fast::req
//
// ArtCraft credits: 100 credits = $1.00. Credits always equal USD cents.
//
// The per-second USD cost varies by resolution. We derive cents from the
// upstream credit rates and their credit-package prices, then set
// ArtCraft credits = cents.
// The per-second USD price varies by resolution.
/// USD cents per second by resolution, derived from upstream Fast rates:
/// 480p: 10 upstream-credits/sec / 193 upstream-credits/$1 * 100 ~= 5.181 c/s
/// (historical 193 credits/$1 derivation; the upstream package is now
/// ~243 credits/$1 but user pricing is intentionally unchanged)
/// 720p: 28 upstream-credits/sec / 220 upstream-credits/$1 * 100 ~= 12.727 c/s
/// USD cents per second by resolution.
///
/// We keep these as f64 because per-second rates are fractional; rounding
/// happens once at the end after multiplying by duration * batch.
/// Kept as f64 because per-second rates are fractional; rounding happens
/// once at the end after multiplying by duration * batch.
const CENTS_PER_SECOND_480P: f64 = 5.181;
const CENTS_PER_SECOND_720P: f64 = 12.727;
/// USD cents per second, in hundredths of a cent, when one or more
/// reference videos are attached. Held as integer hundredths so the math is
/// exact; rounded up to whole cents once, after multiplying by
/// duration * batch.
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_480P: u64 = 841;
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_720P: u64 = 1_560;
pub struct ArtcraftSeedance2p0FastCostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
@@ -43,6 +44,19 @@ impl ArtcraftSeedance2p0FastCostState {
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
if self.has_video_reference {
let usd_cents = self.with_video_reference_usd_cents();
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,
// Everything else (including 720p and unsupported resolutions) prices at 720p.
@@ -62,13 +76,30 @@ impl ArtcraftSeedance2p0FastCostState {
failures_are_refunded: None,
}
}
/// Price when one or more reference videos are attached.
fn with_video_reference_usd_cents(&self) -> u64 {
let centi_cents_per_second = match self.resolution {
CommonResolution::FourEightyP => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_480P,
// Everything else (including 720p and unsupported resolutions) prices at 720p.
_ => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_720P,
};
let seconds = self.duration_seconds as u64 * self.batch_count as u64;
// Round up to whole cents.
(centi_cents_per_second * seconds).div_ceil(100)
}
}
#[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;
// -- 720p pricing --
@@ -128,6 +159,81 @@ mod tests {
}
}
// -- With-video-reference pricing --
mod video_reference_pricing {
use super::*;
#[test]
fn table_480p() {
let expected = [34, 43, 51, 59, 68, 76, 85, 93, 101, 110, 118, 127];
for (i, cents) in expected.iter().enumerate() {
let duration = (i + 4) as u16;
assert_eq!(
cost_cents_with_video_ref(Some(RouterResolution::FourEightyP), duration, 1),
*cents,
"480p {duration}s",
);
}
}
#[test]
fn table_720p() {
let expected = [63, 78, 94, 110, 125, 141, 156, 172, 188, 203, 219, 234];
for (i, cents) in expected.iter().enumerate() {
let duration = (i + 4) as u16;
assert_eq!(
cost_cents_with_video_ref(Some(RouterResolution::SevenTwentyP), duration, 1),
*cents,
"720p {duration}s",
);
}
}
/// Batches price identically to the equivalent total seconds.
#[test]
fn batch_multiplies_total_seconds() {
assert_eq!(
cost_cents_with_video_ref(Some(RouterResolution::FourEightyP), 5, 2),
cost_cents_with_video_ref(Some(RouterResolution::FourEightyP), 10, 1),
);
assert_eq!(
cost_cents_with_video_ref(Some(RouterResolution::SevenTwentyP), 5, 2),
cost_cents_with_video_ref(Some(RouterResolution::SevenTwentyP), 10, 1),
);
}
/// Image and audio references do NOT trigger the with-reference pricing --
/// only reference videos do. (The non-reference price is unchanged.)
#[test]
fn image_and_audio_references_price_as_no_reference() {
use crate::api::audio_list_ref::AudioListRef;
use crate::api::image_list_ref::ImageListRef;
let builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0Fast,
provider: RouterProvider::Artcraft,
resolution: Some(RouterResolution::SevenTwentyP),
duration_seconds: Some(5),
video_batch_count: Some(1),
reference_images: Some(ImageListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_img".to_string()),
])),
reference_audio: Some(AudioListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_audio".to_string()),
])),
..Default::default()
};
let cents = builder.build2()
.expect("build2 should succeed")
.estimate_cost()
.expect("estimate_cost should succeed")
.cost_in_usd_cents
.unwrap();
assert_eq!(cents, cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1));
}
}
// -- Relative pricing --
mod relative_pricing_tests {
@@ -216,4 +322,29 @@ mod tests {
.cost_in_usd_cents
.unwrap()
}
/// Like [`cost_cents`], with one reference video attached.
fn cost_cents_with_video_ref(
resolution: Option<RouterResolution>,
duration_seconds: u16,
video_batch_count: u16,
) -> u64 {
let builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0Fast,
provider: RouterProvider::Artcraft,
resolution,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
reference_videos: Some(VideoListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_ref".to_string()),
])),
..Default::default()
};
builder.build2()
.expect("build2 should succeed")
.estimate_cost()
.expect("estimate_cost should succeed")
.cost_in_usd_cents
.unwrap()
}
}
@@ -58,8 +58,7 @@ mod tests {
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.
// Every combination, driven through the full build pipeline.
mod pricing_480p {
use super::*;
@@ -0,0 +1,250 @@
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
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::artcraft::build_common::{
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
};
use crate::generate::generate_video::providers::artcraft::seedance_2p5_preview::request::ArtcraftSeedance2p5PreviewRequestState;
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_2p5_preview(mut builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
// Parity with the Kinovi build: 2.5 Preview only operates in reference mode
// (no keyframe start/end frames) and has no character support. Erroring
// here keeps the billing estimate consistent with the execution build.
if builder.start_frame.is_some() {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "start_frame",
value: "Seedance 2.5 Preview has no keyframe mode; use reference_images".to_string(),
}));
}
if builder.end_frame.is_some() {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "end_frame",
value: "Seedance 2.5 Preview has no keyframe mode; use reference_images".to_string(),
}));
}
if builder.reference_character_tokens.is_some() {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "reference_character_tokens",
value: "Seedance 2.5 Preview does not support character references".to_string(),
}));
}
// 2.5 Preview allows 4-30 second durations (longer than the shared 4-15
// range in build_common) and generates a single video per request. Plan
// both here, leaving None for the shared builder, then set them after.
let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?;
plan_batch_count(builder.video_batch_count.take(), strategy)?;
let mut request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::Seedance2p5Preview,
SupportedResolutions::Fast,
UltraWideSupport::Supported,
)?;
request.duration_seconds = duration_seconds;
request.video_batch_count = Some(1);
let state = ArtcraftSeedance2p5PreviewRequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p5Preview(state)))
}
// Seedance 2.5 Preview supports durations of 4-30 seconds.
fn plan_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<u16>, ArtcraftRouterError> {
const MIN: u16 = 4;
const MAX: u16 = 30;
match duration_seconds {
None => Ok(None),
Some(d) if d >= MIN && d <= MAX => Ok(Some(d)),
Some(d) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "duration_seconds",
value: format!("{}", d),
}))
}
_ => Ok(Some(d.clamp(MIN, MAX))),
},
}
}
// Seedance 2.5 Preview generates a single video per request (no batching).
fn plan_batch_count(
video_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<(), ArtcraftRouterError> {
let count = video_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(()),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "video_batch_count",
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(()),
},
}
}
#[cfg(test)]
mod tests {
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::character_list_ref::CharacterListRef;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
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 model_is_correct() {
let req = unwrap_request(builder_with(|_| {}));
assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p5Preview)));
}
#[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 duration_30_is_passed_through() {
let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(30); }));
assert_eq!(req.request.duration_seconds, Some(30));
}
#[test]
fn duration_clamped_to_max_30() {
let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(99); }));
assert_eq!(req.request.duration_seconds, Some(30));
}
#[test]
fn duration_clamped_to_min_4() {
let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(2); }));
assert_eq!(req.request.duration_seconds, Some(4));
}
#[test]
fn batch_count_is_always_one() {
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(4); }));
assert_eq!(req.request.video_batch_count, Some(1));
}
#[test]
fn batch_over_one_error_out() {
let result = build_artcraft_seedance_2p5_preview(builder_with(|b| {
b.video_batch_count = Some(4);
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
}));
assert!(result.is_err());
}
}
mod unsupported_option_errors {
use super::*;
#[test]
fn start_frame_errors() {
let result = build_artcraft_seedance_2p5_preview(builder_with(|b| {
b.start_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_start".to_string())));
}));
assert!(result.is_err());
}
#[test]
fn end_frame_errors() {
let result = build_artcraft_seedance_2p5_preview(builder_with(|b| {
b.end_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_end".to_string())));
}));
assert!(result.is_err());
}
#[test]
fn character_references_error() {
let result = build_artcraft_seedance_2p5_preview(builder_with(|b| {
b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(vec![
CharacterToken::new("char_abc".to_string()),
]));
}));
assert!(result.is_err());
}
}
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_1080p_downgrades_to_720p() {
let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::TenEightyP); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
}
}
mod media_token_tests {
use super::*;
#[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));
}
}
fn builder_with(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
let mut builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p5Preview,
provider: RouterProvider::Artcraft,
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
};
f(&mut builder);
builder
}
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p5PreviewRequestState {
let result = build_artcraft_seedance_2p5_preview(builder).expect("build should succeed");
match result {
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p5Preview(state)) => state,
_ => panic!("expected ArtcraftSeedance2p5Preview request"),
}
}
}
@@ -0,0 +1,142 @@
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_2p5_preview_usd_cents;
use crate::generate::generate_video::providers::artcraft::seedance_2p5_preview::request::ArtcraftSeedance2p5PreviewRequestState;
/// Only the resolution and duration matter — 2.5 Preview has no
/// video-reference surcharge and no batching.
pub struct ArtcraftSeedance2p5PreviewCostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
}
impl ArtcraftSeedance2p5PreviewCostState {
pub fn from_request(request: &ArtcraftSeedance2p5PreviewRequestState) -> Self {
let resolution = request.request.resolution
.unwrap_or(CommonResolution::SevenTwentyP);
let duration_seconds = request.request.duration_seconds.unwrap_or(5);
Self { resolution, duration_seconds }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
let usd_cents = seedance_2p5_preview_usd_cents(
self.resolution,
self.duration_seconds,
);
// 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 crate::api::router_provider::RouterProvider;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
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;
use tokens::tokens::media_files::MediaFileToken;
mod pricing_480p {
use super::*;
#[test]
fn full_duration_table() {
assert_eq!(cents(Some(RouterResolution::FourEightyP), 4), 86);
assert_eq!(cents(Some(RouterResolution::FourEightyP), 10), 214);
assert_eq!(cents(Some(RouterResolution::FourEightyP), 15), 321);
assert_eq!(cents(Some(RouterResolution::FourEightyP), 20), 428);
assert_eq!(cents(Some(RouterResolution::FourEightyP), 25), 535);
assert_eq!(cents(Some(RouterResolution::FourEightyP), 30), 642);
}
}
mod pricing_720p {
use super::*;
#[test]
fn full_duration_table() {
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 4), 172);
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 10), 428);
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 15), 642);
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 20), 856);
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 25), 1070);
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 30), 1283);
}
#[test]
fn none_defaults_to_720p() {
assert_eq!(cents(None, 10), cents(Some(RouterResolution::SevenTwentyP), 10));
}
}
mod reference_invariance_tests {
use super::*;
/// References never affect 2.5 Preview pricing.
#[test]
fn video_reference_does_not_change_price() {
let without = cents(Some(RouterResolution::SevenTwentyP), 10);
let estimate = estimate_with(|b| {
b.resolution = Some(RouterResolution::SevenTwentyP);
b.duration_seconds = Some(10);
b.reference_videos = Some(VideoListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_ref".to_string()),
]));
});
assert_eq!(estimate.cost_in_usd_cents, Some(without));
}
}
mod credits_tests {
use super::*;
#[test]
fn credits_equal_usd_cents() {
for dur in [4u16, 10, 15, 20, 25, 30] {
let estimate = estimate_with(|b| {
b.resolution = Some(RouterResolution::FourEightyP);
b.duration_seconds = Some(dur);
});
assert_eq!(estimate.cost_in_credits, estimate.cost_in_usd_cents);
}
}
}
// ── Helpers ──
fn estimate_with(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> VideoGenerationCostEstimate {
let mut builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p5Preview,
provider: RouterProvider::Artcraft,
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
};
f(&mut builder);
builder.build2()
.expect("build should succeed")
.estimate_cost()
.expect("estimate should succeed")
}
fn cents(resolution: Option<RouterResolution>, duration_seconds: u16) -> u64 {
estimate_with(|b| {
b.resolution = resolution;
b.duration_seconds = Some(duration_seconds);
}).cost_in_usd_cents.unwrap()
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,17 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_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 ArtcraftSeedance2p5PreviewRequestState {
pub request: OmniGenVideoCostAndGenerateRequest,
}
impl ArtcraftSeedance2p5PreviewRequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
send_artcraft_omni_video_request(&self.request, client).await
}
}
@@ -15,7 +15,7 @@ const FOUR_K_CENTI_CENTS_PER_SECOND: u64 = 8660;
/// 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.
/// ArtCraft's 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
@@ -39,13 +39,13 @@ pub fn seedance_2p0_four_k_usd_cents(
// ── 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).
// Mini offers only 480p and 720p. The rates below are the price in USD cents
// per second; 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.
// The regular Mini and the BytePlus / BytePlus Ultra Minis have separate
// rate sets and helpers below.
/// Regular Mini — 480p price, USD cents per second.
const MINI_CENTS_PER_SECOND_480P: f64 = 3.24074074;
@@ -65,7 +65,7 @@ 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.
/// ArtCraft's 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
@@ -88,9 +88,8 @@ pub fn seedance_2p0_mini_usd_cents(
)
}
/// 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).
/// ArtCraft's price (USD cents) for the Seedance 2.0 BytePlus Mini and
/// BytePlus Ultra Mini (which share the same rates).
///
/// Only 480p and 720p are offered; any other resolution prices at 720p. A
/// reference video adds the per-second surcharge. The fractional total is
@@ -113,6 +112,35 @@ pub fn seedance_2p0_byteplus_mini_usd_cents(
)
}
// ── Seedance 2.5 Preview pricing ──
//
// 2.5 Preview offers only 480p and 720p, generates one video per request (no
// batching), and — unlike the 2.0 family — references of any kind do NOT
// change the price.
/// Seedance 2.5 Preview — 480p price, USD cents per second.
const SEEDANCE_2P5_PREVIEW_CENTS_PER_SECOND_480P: f64 = 21.38234568;
/// Seedance 2.5 Preview — 720p price, USD cents per second.
const SEEDANCE_2P5_PREVIEW_CENTS_PER_SECOND_720P: f64 = 42.76469136;
/// ArtCraft's price (USD cents) for Seedance 2.5 Preview.
///
/// Only 480p and 720p are offered; any other resolution prices at 720p.
/// References never affect the price and there is no batching. The fractional
/// total is rounded UP to a whole cent.
pub fn seedance_2p5_preview_usd_cents(
resolution: CommonResolution,
duration_seconds: u16,
) -> u64 {
let cents_per_second = match resolution {
CommonResolution::FourEightyP => SEEDANCE_2P5_PREVIEW_CENTS_PER_SECOND_480P,
// Everything else (including 720p and unsupported resolutions) prices at 720p.
_ => SEEDANCE_2P5_PREVIEW_CENTS_PER_SECOND_720P,
};
(cents_per_second * duration_seconds as f64).ceil() as u64
}
#[allow(clippy::too_many_arguments)]
fn mini_usd_cents(
resolution: CommonResolution,
@@ -179,8 +207,7 @@ mod tests {
// ── 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.
// at batch 1, rounded up to whole cents.
mod mini {
use super::*;
@@ -253,8 +280,8 @@ mod tests {
// ── 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.
// Same combinations as the regular Mini; the prices are equal-or-higher
// than the regular Mini.
mod byteplus_mini {
use super::*;
@@ -15,8 +15,9 @@ impl ArtcraftSora2CostState {
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Sora 2: $0.10/second. Default duration 4s.
let cost_in_usd_cents = self.duration_seconds * 10;
// Sora 2: 11.5¢/second (held in hundredths of a cent, rounded up to
// whole cents). Default duration 4s.
let cost_in_usd_cents = (self.duration_seconds * 1_150).div_ceil(100);
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -48,14 +49,14 @@ mod tests {
}
#[test]
fn four_seconds_is_40() { assert_eq!(cost_cents(Some(4)), 40); }
fn four_seconds_is_46() { assert_eq!(cost_cents(Some(4)), 46); }
#[test]
fn eight_seconds_is_80() { assert_eq!(cost_cents(Some(8)), 80); }
fn eight_seconds_is_92() { assert_eq!(cost_cents(Some(8)), 92); }
#[test]
fn twelve_seconds_is_120() { assert_eq!(cost_cents(Some(12)), 120); }
fn twelve_seconds_is_138() { assert_eq!(cost_cents(Some(12)), 138); }
#[test]
fn default_duration_is_4s_priced_at_40() { assert_eq!(cost_cents(None), 40); }
fn default_duration_is_4s_priced_at_46() { assert_eq!(cost_cents(None), 46); }
}
@@ -19,9 +19,10 @@ impl ArtcraftSora2ProCostState {
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Sora 2 Pro: $0.30/sec @ 720p, $0.50/sec @ 1080p. Default 4s.
let per_second_cents: u64 = if self.is_ten_eighty_p { 50 } else { 30 };
let cost_in_usd_cents = per_second_cents * self.duration_seconds;
// Sora 2 Pro: 34.5¢/sec @ 720p, 57.5¢/sec @ 1080p (held in hundredths
// of a cent, rounded up to whole cents). Default 4s.
let per_second_centi_cents: u64 = if self.is_ten_eighty_p { 5_750 } else { 3_450 };
let cost_in_usd_cents = (per_second_centi_cents * self.duration_seconds).div_ceil(100);
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -77,30 +78,30 @@ mod tests {
use super::*;
#[test]
fn p720_4s_is_120() { assert_eq!(cost_cents(Some(4), Some(RouterResolution::SevenTwentyP), false), 120); }
fn p720_4s_is_138() { assert_eq!(cost_cents(Some(4), Some(RouterResolution::SevenTwentyP), false), 138); }
#[test]
fn p720_8s_is_240() { assert_eq!(cost_cents(Some(8), Some(RouterResolution::SevenTwentyP), false), 240); }
fn p720_8s_is_276() { assert_eq!(cost_cents(Some(8), Some(RouterResolution::SevenTwentyP), false), 276); }
#[test]
fn p720_12s_is_360() { assert_eq!(cost_cents(Some(12), Some(RouterResolution::SevenTwentyP), false), 360); }
fn p720_12s_is_414() { assert_eq!(cost_cents(Some(12), Some(RouterResolution::SevenTwentyP), false), 414); }
#[test]
fn p1080_4s_is_200() { assert_eq!(cost_cents(Some(4), Some(RouterResolution::TenEightyP), false), 200); }
fn p1080_4s_is_230() { assert_eq!(cost_cents(Some(4), Some(RouterResolution::TenEightyP), false), 230); }
#[test]
fn p1080_12s_is_600() { assert_eq!(cost_cents(Some(12), Some(RouterResolution::TenEightyP), false), 600); }
fn p1080_12s_is_690() { assert_eq!(cost_cents(Some(12), Some(RouterResolution::TenEightyP), false), 690); }
#[test]
fn t2v_default_resolution_priced_as_1080p() {
// T2V (no start_frame) with no resolution → 1080p default → 200¢ at 4s.
assert_eq!(cost_cents(Some(4), None, false), 200);
// T2V (no start_frame) with no resolution → 1080p default → 230¢ at 4s.
assert_eq!(cost_cents(Some(4), None, false), 230);
}
#[test]
fn i2v_default_resolution_priced_as_720p() {
// I2V (with start_frame) with no resolution → 720p default → 120¢ at 4s.
assert_eq!(cost_cents(Some(4), None, true), 120);
// I2V (with start_frame) with no resolution → 720p default → 138¢ at 4s.
assert_eq!(cost_cents(Some(4), None, true), 138);
}
}

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